Merge branch 'main' into salemcenter
This commit is contained in:
commit
1aed9bb364
|
@ -5,12 +5,14 @@ import {
|
|||
CPMMBinaryContract,
|
||||
DPMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
} from './contract'
|
||||
import { User } from './user'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { noFees } from './fees'
|
||||
import { ENV_CONFIG } from './envs/constants'
|
||||
import { Answer } from './answer'
|
||||
|
||||
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
||||
|
||||
|
@ -111,6 +113,50 @@ export function getFreeAnswerAnte(
|
|||
return anteBet
|
||||
}
|
||||
|
||||
export function getMultipleChoiceAntes(
|
||||
creator: User,
|
||||
contract: MultipleChoiceContract,
|
||||
answers: string[],
|
||||
betDocIds: string[]
|
||||
) {
|
||||
const { totalBets, totalShares } = contract
|
||||
const amount = totalBets['0']
|
||||
const shares = totalShares['0']
|
||||
const p = 1 / answers.length
|
||||
|
||||
const { createdTime } = contract
|
||||
|
||||
const bets: Bet[] = answers.map((answer, i) => ({
|
||||
id: betDocIds[i],
|
||||
userId: creator.id,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
shares,
|
||||
outcome: i.toString(),
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isAnte: true,
|
||||
fees: noFees,
|
||||
}))
|
||||
|
||||
const { username, name, avatarUrl } = creator
|
||||
|
||||
const answerObjects: Answer[] = answers.map((answer, i) => ({
|
||||
id: i.toString(),
|
||||
number: i,
|
||||
contractId: contract.id,
|
||||
createdTime,
|
||||
userId: creator.id,
|
||||
username,
|
||||
name,
|
||||
avatarUrl,
|
||||
text: answer,
|
||||
}))
|
||||
|
||||
return { bets, answerObjects }
|
||||
}
|
||||
|
||||
export function getNumericAnte(
|
||||
anteBettorId: string,
|
||||
contract: NumericContract,
|
||||
|
|
|
@ -12,7 +12,9 @@ export class APIError extends Error {
|
|||
}
|
||||
|
||||
export function getFunctionUrl(name: string) {
|
||||
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
if (process.env.NEXT_PUBLIC_FUNCTIONS_URL) {
|
||||
return `${process.env.NEXT_PUBLIC_FUNCTIONS_URL}/${name}`
|
||||
} else if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
const { projectId, region } = ENV_CONFIG.firebaseConfig
|
||||
return `http://localhost:5001/${projectId}/${region}/${name}`
|
||||
} else {
|
||||
|
|
|
@ -26,6 +26,7 @@ export type Bet = {
|
|||
isAnte?: boolean
|
||||
isLiquidityProvision?: boolean
|
||||
isRedemption?: boolean
|
||||
challengeSlug?: string
|
||||
} & Partial<LimitProps>
|
||||
|
||||
export type NumericBet = Bet & {
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
BinaryContract,
|
||||
FreeResponseContract,
|
||||
PseudoNumericContract,
|
||||
MultipleChoiceContract,
|
||||
} from './contract'
|
||||
import { floatingEqual } from './util/math'
|
||||
|
||||
|
@ -200,7 +201,9 @@ export function getContractBetNullMetrics() {
|
|||
}
|
||||
}
|
||||
|
||||
export function getTopAnswer(contract: FreeResponseContract) {
|
||||
export function getTopAnswer(
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
) {
|
||||
const { answers } = contract
|
||||
const top = maxBy(
|
||||
answers?.map((answer) => ({
|
||||
|
|
63
common/challenge.ts
Normal file
63
common/challenge.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
export type Challenge = {
|
||||
// The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
|
||||
// Also functions as the unique id for the link.
|
||||
slug: string
|
||||
|
||||
// The user that created the challenge.
|
||||
creatorId: string
|
||||
creatorUsername: string
|
||||
creatorName: string
|
||||
creatorAvatarUrl?: string
|
||||
|
||||
// Displayed to people claiming the challenge
|
||||
message: string
|
||||
|
||||
// How much to put up
|
||||
creatorAmount: number
|
||||
|
||||
// YES or NO for now
|
||||
creatorOutcome: string
|
||||
|
||||
// Different than the creator
|
||||
acceptorOutcome: string
|
||||
acceptorAmount: number
|
||||
|
||||
// The probability the challenger thinks
|
||||
creatorOutcomeProb: number
|
||||
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
contractCreatorUsername: string
|
||||
|
||||
createdTime: number
|
||||
// If null, the link is valid forever
|
||||
expiresTime: number | null
|
||||
|
||||
// How many times the challenge can be used
|
||||
maxUses: number
|
||||
|
||||
// Used for simpler caching
|
||||
acceptedByUserIds: string[]
|
||||
// Successful redemptions of the link
|
||||
acceptances: Acceptance[]
|
||||
|
||||
// TODO: will have to fill this on resolve contract
|
||||
isResolved: boolean
|
||||
resolutionOutcome?: string
|
||||
}
|
||||
|
||||
export type Acceptance = {
|
||||
// User that accepted the challenge
|
||||
userId: string
|
||||
userUsername: string
|
||||
userName: string
|
||||
userAvatarUrl: string
|
||||
|
||||
// The ID of the successful bet that tracks the money moved
|
||||
betId: string
|
||||
|
||||
createdTime: number
|
||||
}
|
||||
|
||||
export const CHALLENGES_ENABLED = true
|
|
@ -169,7 +169,7 @@ export const charities: Charity[] = [
|
|||
{
|
||||
name: "Founder's Pledge Climate Change Fund",
|
||||
website: 'https://founderspledge.com/funds/climate-change-fund',
|
||||
photo: 'https://i.imgur.com/ZAhzHu4.png',
|
||||
photo: 'https://i.imgur.com/9turaJW.png',
|
||||
preview:
|
||||
'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.',
|
||||
description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally.
|
||||
|
@ -183,7 +183,7 @@ export const charities: Charity[] = [
|
|||
{
|
||||
name: "Founder's Pledge Patient Philanthropy Fund",
|
||||
website: 'https://founderspledge.com/funds/patient-philanthropy-fund',
|
||||
photo: 'https://i.imgur.com/ZAhzHu4.png',
|
||||
photo: 'https://i.imgur.com/LLR6CI6.png',
|
||||
preview:
|
||||
'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity',
|
||||
description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future.
|
||||
|
@ -551,6 +551,20 @@ With an emphasis on approval voting, we bring better elections to people across
|
|||
|
||||
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
|
||||
},
|
||||
{
|
||||
name: 'Founders Pledge Global Health and Development Fund',
|
||||
website: 'https://founderspledge.com/funds/global-health-and-development',
|
||||
photo: 'https://i.imgur.com/EXbxH7T.png',
|
||||
preview:
|
||||
'Tackling the vast global inequalities in health, wealth and opportunity',
|
||||
description: `Nearly half the world lives on less than $2.50 a day, yet giving by the world’s richest often overlooks the world’s poorest and most vulnerable. Despite the average American household being richer than 90% of the rest of the world, only 6% of US charitable giving goes to charities which work internationally.
|
||||
|
||||
This Fund is focused on helping those who need it most, wherever that help can make the biggest difference. By building a mixed portfolio of direct and indirect interventions, such as policy work, we aim to:
|
||||
|
||||
Improve the lives of the world's most vulnerable people.
|
||||
Reduce the number of easily preventable deaths worldwide.
|
||||
Work towards sustainable, systemic change.`,
|
||||
},
|
||||
].map((charity) => {
|
||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||
return {
|
||||
|
|
|
@ -4,13 +4,19 @@ import { JSONContent } from '@tiptap/core'
|
|||
import { GroupLink } from 'common/group'
|
||||
|
||||
export type AnyMechanism = DPM | CPMM
|
||||
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
|
||||
export type AnyOutcomeType =
|
||||
| Binary
|
||||
| MultipleChoice
|
||||
| PseudoNumeric
|
||||
| FreeResponse
|
||||
| Numeric
|
||||
export type AnyContractType =
|
||||
| (CPMM & Binary)
|
||||
| (CPMM & PseudoNumeric)
|
||||
| (DPM & Binary)
|
||||
| (DPM & FreeResponse)
|
||||
| (DPM & Numeric)
|
||||
| (DPM & MultipleChoice)
|
||||
|
||||
export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||
id: string
|
||||
|
@ -57,6 +63,7 @@ export type BinaryContract = Contract & Binary
|
|||
export type PseudoNumericContract = Contract & PseudoNumeric
|
||||
export type NumericContract = Contract & Numeric
|
||||
export type FreeResponseContract = Contract & FreeResponse
|
||||
export type MultipleChoiceContract = Contract & MultipleChoice
|
||||
export type DPMContract = Contract & DPM
|
||||
export type CPMMContract = Contract & CPMM
|
||||
export type DPMBinaryContract = BinaryContract & DPM
|
||||
|
@ -104,6 +111,13 @@ export type FreeResponse = {
|
|||
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
|
||||
}
|
||||
|
||||
export type MultipleChoice = {
|
||||
outcomeType: 'MULTIPLE_CHOICE'
|
||||
answers: Answer[]
|
||||
resolution?: string | 'MKT' | 'CANCEL'
|
||||
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
|
||||
}
|
||||
|
||||
export type Numeric = {
|
||||
outcomeType: 'NUMERIC'
|
||||
bucketCount: number
|
||||
|
@ -118,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
|||
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
|
||||
export const OUTCOME_TYPES = [
|
||||
'BINARY',
|
||||
'MULTIPLE_CHOICE',
|
||||
'FREE_RESPONSE',
|
||||
'PSEUDO_NUMERIC',
|
||||
'NUMERIC',
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
CPMMBinaryContract,
|
||||
DPMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from './contract'
|
||||
|
@ -322,7 +323,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
export const getNewMultiBetInfo = (
|
||||
outcome: string,
|
||||
amount: number,
|
||||
contract: FreeResponseContract,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
loanAmount: number
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
CPMM,
|
||||
DPM,
|
||||
FreeResponse,
|
||||
MultipleChoice,
|
||||
Numeric,
|
||||
outcomeType,
|
||||
PseudoNumeric,
|
||||
|
@ -30,7 +31,10 @@ export function getNewContract(
|
|||
bucketCount: number,
|
||||
min: number,
|
||||
max: number,
|
||||
isLogScale: boolean
|
||||
isLogScale: boolean,
|
||||
|
||||
// for multiple choice
|
||||
answers: string[]
|
||||
) {
|
||||
const tags = parseTags(
|
||||
[
|
||||
|
@ -48,6 +52,8 @@ export function getNewContract(
|
|||
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
|
||||
: outcomeType === 'NUMERIC'
|
||||
? getNumericProps(ante, bucketCount, min, max)
|
||||
: outcomeType === 'MULTIPLE_CHOICE'
|
||||
? getMultipleChoiceProps(ante, answers)
|
||||
: getFreeAnswerProps(ante)
|
||||
|
||||
const contract: Contract = removeUndefinedProps({
|
||||
|
@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => {
|
|||
return system
|
||||
}
|
||||
|
||||
const getMultipleChoiceProps = (ante: number, answers: string[]) => {
|
||||
const numAnswers = answers.length
|
||||
const betAnte = ante / numAnswers
|
||||
const betShares = Math.sqrt(ante ** 2 / numAnswers)
|
||||
|
||||
const defaultValues = (x: any) =>
|
||||
Object.fromEntries(range(0, numAnswers).map((k) => [k, x]))
|
||||
|
||||
const system: DPM & MultipleChoice = {
|
||||
mechanism: 'dpm-2',
|
||||
outcomeType: 'MULTIPLE_CHOICE',
|
||||
pool: defaultValues(betAnte),
|
||||
totalShares: defaultValues(betShares),
|
||||
totalBets: defaultValues(betAnte),
|
||||
answers: [],
|
||||
}
|
||||
|
||||
return system
|
||||
}
|
||||
|
||||
const getNumericProps = (
|
||||
ante: number,
|
||||
bucketCount: number,
|
||||
|
|
|
@ -37,6 +37,7 @@ export type notification_source_types =
|
|||
| 'group'
|
||||
| 'user'
|
||||
| 'bonus'
|
||||
| 'challenge'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -64,3 +65,4 @@ export type notification_reason_types =
|
|||
| 'tip_received'
|
||||
| 'bet_fill'
|
||||
| 'user_joined_from_your_group_invite'
|
||||
| 'challenge_accepted'
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
|
|
|
@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
|
|||
|
||||
import { Bet, NumericBet } from './bet'
|
||||
import { deductDpmFees, getDpmProbability } from './calculate-dpm'
|
||||
import { DPMContract, FreeResponseContract } from './contract'
|
||||
import {
|
||||
DPMContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
} from './contract'
|
||||
import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
|
||||
import { addObjects } from './util/object'
|
||||
|
||||
|
@ -180,7 +184,7 @@ export const getDpmMktPayouts = (
|
|||
|
||||
export const getPayoutsMultiOutcome = (
|
||||
resolutions: { [outcome: string]: number },
|
||||
contract: FreeResponseContract,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const poolTotal = sum(Object.values(contract.pool))
|
||||
|
|
|
@ -117,6 +117,7 @@ export const getDpmPayouts = (
|
|||
resolutionProbability?: number
|
||||
): PayoutInfo => {
|
||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const { outcomeType } = contract
|
||||
|
||||
switch (outcome) {
|
||||
case 'YES':
|
||||
|
@ -124,7 +125,8 @@ export const getDpmPayouts = (
|
|||
return getDpmStandardPayouts(outcome, contract, openBets)
|
||||
|
||||
case 'MKT':
|
||||
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
|
||||
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
||||
case 'CANCEL':
|
||||
|
@ -132,7 +134,7 @@ export const getDpmPayouts = (
|
|||
return getDpmCancelPayouts(contract, openBets)
|
||||
|
||||
default:
|
||||
if (contract.outcomeType === 'NUMERIC')
|
||||
if (outcomeType === 'NUMERIC')
|
||||
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
|
||||
|
||||
// Outcome is a free response answer id.
|
||||
|
|
|
@ -40,12 +40,14 @@ export type User = {
|
|||
referredByContractId?: string
|
||||
referredByGroupId?: string
|
||||
lastPingTime?: number
|
||||
shouldShowWelcome?: boolean
|
||||
}
|
||||
|
||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||
// for sus users, i.e. multiple sign ups for same person
|
||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
||||
|
||||
export type PrivateUser = {
|
||||
id: string // same as User.id
|
||||
username: string // denormalized from User
|
||||
|
@ -55,6 +57,7 @@ export type PrivateUser = {
|
|||
unsubscribedFromCommentEmails?: boolean
|
||||
unsubscribedFromAnswerEmails?: boolean
|
||||
unsubscribedFromGenericEmails?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
|
|
|
@ -20,7 +20,9 @@ import { Text } from '@tiptap/extension-text'
|
|||
// other tiptap extensions
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Mention } from '@tiptap/extension-mention'
|
||||
import Iframe from './tiptap-iframe'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
export function parseTags(text: string) {
|
||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||
|
@ -60,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) =>
|
|||
export const searchInAny = (query: string, ...fields: string[]) =>
|
||||
fields.some((field) => checkAgainstQuery(query, field))
|
||||
|
||||
/** @return user ids of all \@mentions */
|
||||
export function parseMentions(data: JSONContent): string[] {
|
||||
const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs
|
||||
if (data.type === 'mention' && data.attrs) {
|
||||
mentions.push(data.attrs.id as string)
|
||||
}
|
||||
return uniq(mentions)
|
||||
}
|
||||
|
||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||
export const exhibitExts = [
|
||||
Blockquote,
|
||||
|
@ -81,9 +92,9 @@ export const exhibitExts = [
|
|||
|
||||
Image,
|
||||
Link,
|
||||
Mention,
|
||||
Iframe,
|
||||
]
|
||||
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
|
||||
|
||||
export function richTextToString(text?: JSONContent) {
|
||||
return !text ? '' : generateText(text, exhibitExts)
|
||||
|
|
43
dev.sh
Executable file
43
dev.sh
Executable file
|
@ -0,0 +1,43 @@
|
|||
#!/bin/bash
|
||||
|
||||
ENV=${1:-dev}
|
||||
case $ENV in
|
||||
dev)
|
||||
FIREBASE_PROJECT=dev
|
||||
NEXT_ENV=DEV ;;
|
||||
prod)
|
||||
FIREBASE_PROJECT=prod
|
||||
NEXT_ENV=PROD ;;
|
||||
localdb)
|
||||
FIREBASE_PROJECT=dev
|
||||
NEXT_ENV=DEV
|
||||
EMULATOR=true ;;
|
||||
*)
|
||||
echo "Invalid environment; must be dev, prod, or localdb."
|
||||
exit 1
|
||||
esac
|
||||
|
||||
firebase use $FIREBASE_PROJECT
|
||||
|
||||
if [ ! -z $EMULATOR ]
|
||||
then
|
||||
npx concurrently \
|
||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||
-c green,white,magenta,cyan \
|
||||
"yarn --cwd=functions firestore" \
|
||||
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
|
||||
yarn --cwd=web serve" \
|
||||
"cross-env yarn --cwd=web ts-watch"
|
||||
else
|
||||
npx concurrently \
|
||||
-n FUNCTIONS,NEXT,TS \
|
||||
-c white,magenta,cyan \
|
||||
"yarn --cwd=functions dev" \
|
||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
|
||||
yarn --cwd=web serve" \
|
||||
"cross-env yarn --cwd=web ts-watch"
|
||||
fi
|
131
docs/docs/api.md
131
docs/docs/api.md
|
@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use
|
|||
|
||||
Requires no authorization.
|
||||
|
||||
### GET /v0/me
|
||||
|
||||
Returns the authenticated user.
|
||||
|
||||
### `GET /v0/groups`
|
||||
|
||||
Gets all groups, in no particular order.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/[slug]`
|
||||
|
||||
Gets a group by its slug.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/by-id/[id]`
|
||||
|
||||
Gets a group by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/markets`
|
||||
|
||||
Lists all markets, ordered by creation date descending.
|
||||
|
@ -481,6 +503,20 @@ Parameters:
|
|||
answer. For numeric markets, this is a string representing the target bucket,
|
||||
and an additional `value` parameter is required which is a number representing
|
||||
the target value. (Bet on numeric markets at your own peril.)
|
||||
- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing
|
||||
the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the
|
||||
probability percentage).
|
||||
The bet will execute immediately in the direction of `outcome`, but not beyond this
|
||||
specified limit. If not all the bet is filled, the bet will remain as an open offer
|
||||
that can later be matched against an opposite direction bet.
|
||||
- For example, if the current market probability is `50%`:
|
||||
- A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market
|
||||
probability moves down to `40%` and someone bets `M$15` of `NO` to match your
|
||||
bet odds.
|
||||
- A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely
|
||||
depending on current unfilled limit bets and the AMM's liquidity. Any remaining
|
||||
portion of the bet not filled would remain to be matched against in the future.
|
||||
- An unfilled limit order bet can be cancelled using the cancel API.
|
||||
|
||||
Example request:
|
||||
|
||||
|
@ -579,6 +615,26 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
|||
]}'
|
||||
```
|
||||
|
||||
### `POST /v0/market/[marketId]/sell`
|
||||
|
||||
Sells some quantity of shares in a binary market on behalf of the authorized user.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
|
||||
own one kind of shares, you will sell that kind of shares.
|
||||
- `shares`: Optional. The amount of shares to sell of the outcome given
|
||||
above. If not provided, all the shares you own will be sold.
|
||||
|
||||
Example request:
|
||||
|
||||
```
|
||||
$ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"outcome": "YES", "shares": 10}'
|
||||
```
|
||||
|
||||
### `GET /v0/bets`
|
||||
|
||||
Gets a list of bets, ordered by creation date descending.
|
||||
|
@ -597,7 +653,7 @@ Requires no authorization.
|
|||
|
||||
- Example request
|
||||
```
|
||||
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa
|
||||
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord
|
||||
```
|
||||
- Response type: A `Bet[]`.
|
||||
|
||||
|
@ -605,31 +661,60 @@ Requires no authorization.
|
|||
|
||||
```json
|
||||
[
|
||||
// Limit bet, partially filled.
|
||||
{
|
||||
"probAfter": 0.44418877319153904,
|
||||
"shares": -645.8346334931828,
|
||||
"isFilled": false,
|
||||
"amount": 15.596681605353808,
|
||||
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||
"probBefore": 0.5730753474948571,
|
||||
"isCancelled": false,
|
||||
"outcome": "YES",
|
||||
"contractId": "tgB1XmvFXZNhjr3xMNLp",
|
||||
"sale": {
|
||||
"betId": "RcOtarI3d1DUUTjiE0rx",
|
||||
"amount": 474.9999999999998
|
||||
},
|
||||
"createdTime": 1644602886293,
|
||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
||||
"probBefore": 0.7229189477449224,
|
||||
"id": "x9eNmCaqQeXW8AgJ8Zmp",
|
||||
"amount": -499.9999999999998
|
||||
},
|
||||
"fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
|
||||
"shares": 31.193363210707616,
|
||||
"limitProb": 0.5,
|
||||
"id": "yXB8lVbs86TKkhWA1FVi",
|
||||
"loanAmount": 0,
|
||||
"orderAmount": 100,
|
||||
"probAfter": 0.5730753474948571,
|
||||
"createdTime": 1659482775970,
|
||||
"fills": [
|
||||
{
|
||||
"probAfter": 0.9901970375647697,
|
||||
"contractId": "zdeaYVAfHlo9jKzWh57J",
|
||||
"outcome": "YES",
|
||||
"amount": 1,
|
||||
"id": "8PqxKYwXCcLYoXy2m2Nm",
|
||||
"shares": 1.0049875638533763,
|
||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
||||
"probBefore": 0.9900000000000001,
|
||||
"createdTime": 1644705818872
|
||||
"timestamp": 1659483249648,
|
||||
"matchedBetId": "MfrMd5HTiGASDXzqibr7",
|
||||
"amount": 15.596681605353808,
|
||||
"shares": 31.193363210707616
|
||||
}
|
||||
]
|
||||
},
|
||||
// Normal bet (no limitProb specified).
|
||||
{
|
||||
"shares": 17.350459904608414,
|
||||
"probBefore": 0.5304358279113885,
|
||||
"isFilled": true,
|
||||
"probAfter": 0.5730753474948571,
|
||||
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||
"amount": 10,
|
||||
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||
"id": "1LPJHNz5oAX4K6YtJlP1",
|
||||
"fees": {
|
||||
"platformFee": 0,
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0.4251333951457593
|
||||
},
|
||||
"isCancelled": false,
|
||||
"loanAmount": 0,
|
||||
"orderAmount": 10,
|
||||
"fills": [
|
||||
{
|
||||
"amount": 10,
|
||||
"matchedBetId": null,
|
||||
"shares": 17.350459904608414,
|
||||
"timestamp": 1659482757271
|
||||
}
|
||||
],
|
||||
"createdTime": 1659482757271,
|
||||
"outcome": "YES"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
|
|
@ -10,13 +10,16 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
|
||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
||||
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
|
||||
|
||||
## API / Dev
|
||||
|
||||
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
|
||||
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
||||
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
||||
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
||||
|
||||
## Bots
|
||||
|
||||
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
||||
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets
|
||||
|
|
|
@ -22,7 +22,7 @@ service cloud.firestore {
|
|||
allow read;
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']);
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
||||
// User referral rules
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
|
@ -39,6 +39,17 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/challenges/{challengeId}{
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/challenges/{challengeId}{
|
||||
allow read;
|
||||
allow create: if request.auth.uid == request.resource.data.creatorId;
|
||||
// allow update if there have been no claims yet and if the challenge is still open
|
||||
allow update: if request.auth.uid == resource.data.creatorId;
|
||||
}
|
||||
|
||||
match /users/{userId}/follows/{followUserId} {
|
||||
allow read;
|
||||
allow write: if request.auth.uid == userId;
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
"start": "yarn shell",
|
||||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log",
|
||||
"dev": "nodemon src/serve.ts",
|
||||
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
|
||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||
|
@ -27,7 +29,11 @@
|
|||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"dayjs": "1.11.4",
|
||||
"cors": "2.8.5",
|
||||
"express": "4.18.1",
|
||||
"firebase-admin": "10.0.0",
|
||||
"firebase-functions": "3.21.2",
|
||||
"lodash": "4.17.21",
|
||||
|
|
167
functions/src/accept-challenge.ts
Normal file
167
functions/src/accept-challenge.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { z } from 'zod'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { log } from './utils'
|
||||
import { Contract, CPMMBinaryContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { Acceptance, Challenge } from '../../common/challenge'
|
||||
import { CandidateBet } from '../../common/new-bet'
|
||||
import { createChallengeAcceptedNotification } from './create-notification'
|
||||
import { noFees } from '../../common/fees'
|
||||
import { formatMoney, formatPercent } from '../../common/util/format'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
challengeSlug: z.string(),
|
||||
outcomeType: z.literal('BINARY'),
|
||||
closeTime: z.number().gte(Date.now()),
|
||||
})
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const acceptchallenge = newEndpoint({}, async (req, auth) => {
|
||||
const { challengeSlug, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const challengeDoc = firestore.doc(
|
||||
`contracts/${contractId}/challenges/${challengeSlug}`
|
||||
)
|
||||
const [contractSnap, userSnap, challengeSnap] = await trans.getAll(
|
||||
contractDoc,
|
||||
userDoc,
|
||||
challengeDoc
|
||||
)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.')
|
||||
|
||||
const anyContract = contractSnap.data() as Contract
|
||||
const user = userSnap.data() as User
|
||||
const challenge = challengeSnap.data() as Challenge
|
||||
|
||||
if (challenge.acceptances.length > 0)
|
||||
throw new APIError(400, 'Challenge already accepted.')
|
||||
|
||||
const creatorDoc = firestore.doc(`users/${challenge.creatorId}`)
|
||||
const creatorSnap = await trans.get(creatorDoc)
|
||||
if (!creatorSnap.exists) throw new APIError(400, 'Creator not found.')
|
||||
const creator = creatorSnap.data() as User
|
||||
|
||||
const {
|
||||
creatorAmount,
|
||||
acceptorOutcome,
|
||||
creatorOutcome,
|
||||
creatorOutcomeProb,
|
||||
acceptorAmount,
|
||||
} = challenge
|
||||
|
||||
if (user.balance < acceptorAmount)
|
||||
throw new APIError(400, 'Insufficient balance.')
|
||||
|
||||
if (creator.balance < creatorAmount)
|
||||
throw new APIError(400, 'Creator has insufficient balance.')
|
||||
|
||||
const contract = anyContract as CPMMBinaryContract
|
||||
const shares = (1 / creatorOutcomeProb) * creatorAmount
|
||||
const createdTime = Date.now()
|
||||
const probOfYes =
|
||||
creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb
|
||||
|
||||
log(
|
||||
'Creating challenge bet for',
|
||||
user.username,
|
||||
shares,
|
||||
acceptorOutcome,
|
||||
'shares',
|
||||
'at',
|
||||
formatPercent(creatorOutcomeProb),
|
||||
'for',
|
||||
formatMoney(acceptorAmount)
|
||||
)
|
||||
|
||||
const yourNewBet: CandidateBet = removeUndefinedProps({
|
||||
orderAmount: acceptorAmount,
|
||||
amount: acceptorAmount,
|
||||
shares,
|
||||
isCancelled: false,
|
||||
contractId: contract.id,
|
||||
outcome: acceptorOutcome,
|
||||
probBefore: probOfYes,
|
||||
probAfter: probOfYes,
|
||||
loanAmount: 0,
|
||||
createdTime,
|
||||
fees: noFees,
|
||||
challengeSlug: challenge.slug,
|
||||
})
|
||||
|
||||
const yourNewBetDoc = contractDoc.collection('bets').doc()
|
||||
trans.create(yourNewBetDoc, {
|
||||
id: yourNewBetDoc.id,
|
||||
userId: user.id,
|
||||
...yourNewBet,
|
||||
})
|
||||
|
||||
trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) })
|
||||
|
||||
const creatorNewBet: CandidateBet = removeUndefinedProps({
|
||||
orderAmount: creatorAmount,
|
||||
amount: creatorAmount,
|
||||
shares,
|
||||
isCancelled: false,
|
||||
contractId: contract.id,
|
||||
outcome: creatorOutcome,
|
||||
probBefore: probOfYes,
|
||||
probAfter: probOfYes,
|
||||
loanAmount: 0,
|
||||
createdTime,
|
||||
fees: noFees,
|
||||
challengeSlug: challenge.slug,
|
||||
})
|
||||
const creatorBetDoc = contractDoc.collection('bets').doc()
|
||||
trans.create(creatorBetDoc, {
|
||||
id: creatorBetDoc.id,
|
||||
userId: creator.id,
|
||||
...creatorNewBet,
|
||||
})
|
||||
|
||||
trans.update(creatorDoc, {
|
||||
balance: FieldValue.increment(-creatorNewBet.amount),
|
||||
})
|
||||
|
||||
const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount
|
||||
trans.update(contractDoc, { volume })
|
||||
|
||||
trans.update(
|
||||
challengeDoc,
|
||||
removeUndefinedProps({
|
||||
acceptedByUserIds: [user.id],
|
||||
acceptances: [
|
||||
{
|
||||
userId: user.id,
|
||||
betId: yourNewBetDoc.id,
|
||||
createdTime,
|
||||
amount: acceptorAmount,
|
||||
userUsername: user.username,
|
||||
userName: user.name,
|
||||
userAvatarUrl: user.avatarUrl,
|
||||
} as Acceptance,
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
await createChallengeAcceptedNotification(
|
||||
user,
|
||||
creator,
|
||||
challenge,
|
||||
acceptorAmount,
|
||||
contract
|
||||
)
|
||||
log('Done, sent notification.')
|
||||
return yourNewBetDoc
|
||||
})
|
||||
|
||||
return { betId: result.id }
|
||||
})
|
|
@ -1,6 +1,7 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { logger } from 'firebase-functions/v2'
|
||||
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https'
|
||||
import { Request, RequestHandler, Response } from 'express'
|
||||
import { error } from 'firebase-functions/logger'
|
||||
import { HttpsOptions } from 'firebase-functions/v2/https'
|
||||
import { log } from './utils'
|
||||
import { z } from 'zod'
|
||||
import { APIError } from '../../common/api'
|
||||
|
@ -45,7 +46,7 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
|||
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
|
||||
} catch (err) {
|
||||
// This is somewhat suspicious, so get it into the firebase console
|
||||
logger.error('Error verifying Firebase JWT: ', err)
|
||||
error('Error verifying Firebase JWT: ', err)
|
||||
throw new APIError(403, 'Error validating token.')
|
||||
}
|
||||
case 'Key':
|
||||
|
@ -83,6 +84,11 @@ export const zTimestamp = () => {
|
|||
}, z.date())
|
||||
}
|
||||
|
||||
export type EndpointDefinition = {
|
||||
opts: EndpointOptions & { method: string }
|
||||
handler: RequestHandler
|
||||
}
|
||||
|
||||
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||
const result = schema.safeParse(val)
|
||||
if (!result.success) {
|
||||
|
@ -99,12 +105,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
|||
}
|
||||
}
|
||||
|
||||
interface EndpointOptions extends HttpsOptions {
|
||||
methods?: string[]
|
||||
export interface EndpointOptions extends HttpsOptions {
|
||||
method?: string
|
||||
}
|
||||
|
||||
const DEFAULT_OPTS = {
|
||||
methods: ['POST'],
|
||||
method: 'POST',
|
||||
minInstances: 1,
|
||||
concurrency: 100,
|
||||
memory: '2GiB',
|
||||
|
@ -113,16 +119,16 @@ const DEFAULT_OPTS = {
|
|||
}
|
||||
|
||||
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
|
||||
return onRequest(opts, async (req, res) => {
|
||||
log('Request processing started.')
|
||||
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
|
||||
return {
|
||||
opts,
|
||||
handler: async (req: Request, res: Response) => {
|
||||
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
|
||||
try {
|
||||
if (!opts.methods.includes(req.method)) {
|
||||
const allowed = opts.methods.join(', ')
|
||||
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.')
|
||||
res.status(200).json(await fn(req, authedUser))
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
|
@ -132,9 +138,10 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
|||
}
|
||||
res.status(e.code).json(output)
|
||||
} else {
|
||||
logger.error(e)
|
||||
error(e)
|
||||
res.status(500).json({ message: 'An unknown error occurred.' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
} as EndpointDefinition
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ const bodySchema = z.object({
|
|||
export const cancelbet = newEndpoint({}, async (req, auth) => {
|
||||
const { betId } = validate(bodySchema, req.body)
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
return await firestore.runTransaction(async (trans) => {
|
||||
const snap = await trans.get(
|
||||
firestore.collectionGroup('bets').where('id', '==', betId)
|
||||
)
|
||||
|
@ -28,8 +28,6 @@ export const cancelbet = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
return { ...bet, isCancelled: true }
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -2,33 +2,37 @@ import * as admin from 'firebase-admin'
|
|||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
CPMMBinaryContract,
|
||||
Contract,
|
||||
CPMMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MAX_QUESTION_LENGTH,
|
||||
MAX_TAG_LENGTH,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
OUTCOME_TYPES,
|
||||
} from '../../common/contract'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
|
||||
import { chargeUser } from './utils'
|
||||
import { chargeUser, getContract } from './utils'
|
||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||
|
||||
import {
|
||||
FIXED_ANTE,
|
||||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getMultipleChoiceAntes,
|
||||
getNumericAnte,
|
||||
} from '../../common/antes'
|
||||
import { getNoneAnswer } from '../../common/answer'
|
||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||
import { User } from '../../common/user'
|
||||
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
||||
import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group'
|
||||
import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { uniq, zip } from 'lodash'
|
||||
import { Bet } from '../../common/bet'
|
||||
|
||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
|
@ -79,11 +83,15 @@ const numericSchema = z.object({
|
|||
isLogScale: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const multipleChoiceSchema = z.object({
|
||||
answers: z.string().trim().min(1).array().min(2),
|
||||
})
|
||||
|
||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||
validate(bodySchema, req.body)
|
||||
|
||||
let min, max, initialProb, isLogScale
|
||||
let min, max, initialProb, isLogScale, answers
|
||||
|
||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||
let initialValue
|
||||
|
@ -97,12 +105,22 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
||||
|
||||
if (initialProb < 1 || initialProb > 99)
|
||||
throw new APIError(400, 'Invalid initial value.')
|
||||
if (outcomeType === 'PSEUDO_NUMERIC')
|
||||
throw new APIError(
|
||||
400,
|
||||
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
|
||||
)
|
||||
else throw new APIError(400, 'Invalid initial probability.')
|
||||
}
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
;({ initialProb } = validate(binarySchema, req.body))
|
||||
}
|
||||
|
||||
if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||
;({ answers } = validate(multipleChoiceSchema, req.body))
|
||||
}
|
||||
|
||||
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||
if (!userDoc.exists) {
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
|
@ -118,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
const slug = await getSlug(question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
|
||||
let group = null
|
||||
if (groupId) {
|
||||
const groupDocRef = await firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
if (!groupDoc.exists) {
|
||||
throw new APIError(400, 'No group exists with the given group ID.')
|
||||
}
|
||||
|
||||
group = groupDoc.data() as Group
|
||||
if (!group.memberIds.includes(user.id)) {
|
||||
throw new APIError(
|
||||
400,
|
||||
'User must be a member of the group to add markets to it.'
|
||||
)
|
||||
}
|
||||
if (!group.contractIds.includes(contractRef.id))
|
||||
await groupDocRef.update({
|
||||
contractIds: [...group.contractIds, contractRef.id],
|
||||
})
|
||||
}
|
||||
|
||||
console.log(
|
||||
'creating contract for',
|
||||
user.username,
|
||||
|
@ -162,13 +159,41 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
NUMERIC_BUCKET_COUNT,
|
||||
min ?? 0,
|
||||
max ?? 0,
|
||||
isLogScale ?? false
|
||||
isLogScale ?? false,
|
||||
answers ?? []
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
|
||||
await contractRef.create(contract)
|
||||
|
||||
let group = null
|
||||
if (groupId) {
|
||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
if (!groupDoc.exists) {
|
||||
throw new APIError(400, 'No group exists with the given group ID.')
|
||||
}
|
||||
|
||||
group = groupDoc.data() as Group
|
||||
if (
|
||||
!group.memberIds.includes(user.id) &&
|
||||
!group.anyoneCanJoin &&
|
||||
group.creatorId !== user.id
|
||||
) {
|
||||
throw new APIError(
|
||||
400,
|
||||
'User must be a member/creator of the group or group must be open to add markets to it.'
|
||||
)
|
||||
}
|
||||
if (!group.contractIds.includes(contractRef.id)) {
|
||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||
await groupDocRef.update({
|
||||
contractIds: uniq([...group.contractIds, contractRef.id]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const providerId = user.id
|
||||
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
|
@ -184,6 +209,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
)
|
||||
|
||||
await liquidityDoc.set(lp)
|
||||
} else if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
|
||||
const betDocs = (answers ?? []).map(() => betCol.doc())
|
||||
|
||||
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
|
||||
const answerDocs = (answers ?? []).map((_, i) =>
|
||||
answerCol.doc(i.toString())
|
||||
)
|
||||
|
||||
const { bets, answerObjects } = getMultipleChoiceAntes(
|
||||
user,
|
||||
contract as MultipleChoiceContract,
|
||||
answers ?? [],
|
||||
betDocs.map((bd) => bd.id)
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
|
||||
)
|
||||
await Promise.all(
|
||||
zip(answerObjects, answerDocs).map(([answer, doc]) =>
|
||||
doc?.create(answer as Answer)
|
||||
)
|
||||
)
|
||||
await contractRef.update({ answers: answerObjects })
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
const noneAnswerDoc = firestore
|
||||
.collection(`contracts/${contract.id}/answers`)
|
||||
|
@ -240,3 +290,38 @@ export async function getContractFromSlug(slug: string) {
|
|||
|
||||
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
||||
}
|
||||
|
||||
async function createGroupLinks(
|
||||
group: Group,
|
||||
contractIds: string[],
|
||||
userId: string
|
||||
) {
|
||||
for (const contractId of contractIds) {
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract?.groupSlugs?.includes(group.slug)) {
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
|
||||
})
|
||||
}
|
||||
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupLinks: [
|
||||
{
|
||||
groupId: group.id,
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
userId,
|
||||
createdTime: Date.now(),
|
||||
} as GroupLink,
|
||||
...(contract?.groupLinks ?? []),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import { getContractBetMetrics } from '../../common/calculate'
|
|||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { TipTxn } from '../../common/txn'
|
||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||
import { Challenge } from '../../common/challenge'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
type user_to_reason_texts = {
|
||||
|
@ -32,7 +33,7 @@ export const createNotification = async (
|
|||
miscData?: {
|
||||
contract?: Contract
|
||||
relatedSourceType?: notification_source_types
|
||||
relatedUserId?: string
|
||||
recipients?: string[]
|
||||
slug?: string
|
||||
title?: string
|
||||
}
|
||||
|
@ -40,7 +41,7 @@ export const createNotification = async (
|
|||
const {
|
||||
contract: sourceContract,
|
||||
relatedSourceType,
|
||||
relatedUserId,
|
||||
recipients,
|
||||
slug,
|
||||
title,
|
||||
} = miscData ?? {}
|
||||
|
@ -127,7 +128,7 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUsers = async (
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
|
@ -144,7 +145,7 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyFollowedUser = async (
|
||||
const notifyFollowedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
followedUserId: string
|
||||
) => {
|
||||
|
@ -154,21 +155,24 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceText: string
|
||||
) => {
|
||||
const taggedUsers = sourceText.match(/@\w+/g)
|
||||
if (!taggedUsers) return
|
||||
// await all get tagged users:
|
||||
const users = await Promise.all(
|
||||
taggedUsers.map(async (username) => {
|
||||
return await getUserByUsername(username.slice(1))
|
||||
})
|
||||
/** @deprecated parse from rich text instead */
|
||||
const parseMentions = async (source: string) => {
|
||||
const mentions = source.match(/@\w+/g)
|
||||
if (!mentions) return []
|
||||
return Promise.all(
|
||||
mentions.map(
|
||||
async (username) => (await getUserByUsername(username.slice(1)))?.id
|
||||
)
|
||||
users.forEach((taggedUser) => {
|
||||
if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
|
||||
userToReasonTexts[taggedUser.id] = {
|
||||
)
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userIds: (string | undefined)[]
|
||||
) => {
|
||||
userIds.forEach((id) => {
|
||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
||||
userToReasonTexts[id] = {
|
||||
reason: 'tagged_user',
|
||||
}
|
||||
})
|
||||
|
@ -253,7 +257,7 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyUserAddedToGroup = async (
|
||||
const notifyUserAddedToGroup = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string
|
||||
) => {
|
||||
|
@ -275,11 +279,14 @@ export const createNotification = async (
|
|||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
if (sourceType === 'follow' && relatedUserId) {
|
||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'group' && relatedUserId) {
|
||||
if (sourceUpdateType === 'created')
|
||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||
if (sourceType === 'follow' && recipients?.[0]) {
|
||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||
} else if (
|
||||
sourceType === 'group' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
recipients
|
||||
) {
|
||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||
}
|
||||
|
||||
// The following functions need sourceContract to be defined.
|
||||
|
@ -292,13 +299,10 @@ export const createNotification = async (
|
|||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
if (relatedUserId && relatedSourceType)
|
||||
await notifyRepliedUsers(
|
||||
userToReasonTexts,
|
||||
relatedUserId,
|
||||
relatedSourceType
|
||||
)
|
||||
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
||||
if (recipients?.[0] && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
||||
if (sourceText)
|
||||
notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText))
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
|
@ -307,6 +311,7 @@ export const createNotification = async (
|
|||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
|
@ -478,3 +483,35 @@ export const createReferralNotification = async (
|
|||
}
|
||||
|
||||
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
||||
|
||||
export const createChallengeAcceptedNotification = async (
|
||||
challenger: User,
|
||||
challengeCreator: User,
|
||||
challenge: Challenge,
|
||||
acceptedAmount: number,
|
||||
contract: Contract
|
||||
) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${challengeCreator.id}/notifications`)
|
||||
.doc()
|
||||
const notification: Notification = {
|
||||
id: notificationRef.id,
|
||||
userId: challengeCreator.id,
|
||||
reason: 'challenge_accepted',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: challenge.slug,
|
||||
sourceType: 'challenge',
|
||||
sourceUpdateType: 'updated',
|
||||
sourceUserName: challenger.name,
|
||||
sourceUserUsername: challenger.username,
|
||||
sourceUserAvatarUrl: challenger.avatarUrl,
|
||||
sourceText: acceptedAmount.toString(),
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceContractId: contract.id,
|
||||
sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import {
|
||||
MANIFOLD_AVATAR_URL,
|
||||
MANIFOLD_USERNAME,
|
||||
|
@ -24,7 +26,6 @@ import {
|
|||
import { track } from './analytics'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||
import { uniq } from 'lodash'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
@ -63,10 +64,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
const deviceUsedBefore =
|
||||
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
|
||||
|
||||
const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0
|
||||
|
||||
const balance =
|
||||
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE
|
||||
const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE
|
||||
|
||||
const user: User = {
|
||||
id: auth.uid,
|
||||
|
@ -80,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
followerCountCached: 0,
|
||||
followedCategories: DEFAULT_CATEGORIES,
|
||||
shouldShowWelcome: true,
|
||||
}
|
||||
|
||||
await firestore.collection('users').doc(auth.uid).create(user)
|
||||
|
@ -95,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
|
||||
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
await addUserToDefaultGroups(user)
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||
|
||||
return user
|
||||
|
@ -113,7 +112,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => {
|
|||
return !snap.empty
|
||||
}
|
||||
|
||||
const numberUsersWithIp = async (ipAddress: string) => {
|
||||
export const numberUsersWithIp = async (ipAddress: string) => {
|
||||
const snap = await firestore
|
||||
.collection('private-users')
|
||||
.where('initialIpAddress', '==', ipAddress)
|
||||
|
|
File diff suppressed because one or more lines are too long
738
functions/src/email-templates/creating-market.html
Normal file
738
functions/src/email-templates/creating-market.html
Normal file
|
@ -0,0 +1,738 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
>
|
||||
<head>
|
||||
<title>(no subject)</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Readex+Pro"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Readex+Pro"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="background-color: #f4f4f4">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 0px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="vertical-align: top"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 0px 25px 0px 25px;
|
||||
padding-top: 0px;
|
||||
padding-right: 25px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 550px">
|
||||
<a
|
||||
href="https://manifold.markets/home"
|
||||
target="_blank"
|
||||
><img
|
||||
alt=""
|
||||
height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
"
|
||||
width="550"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="vertical-align: top"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 0px 25px 20px 25px;
|
||||
padding-top: 0px;
|
||||
padding-right: 25px;
|
||||
padding-bottom: 20px;
|
||||
padding-left: 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 17px;
|
||||
letter-spacing: normal;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="
|
||||
line-height: 23px;
|
||||
margin: 10px 0;
|
||||
margin-top: 10px;
|
||||
"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>On Manifold Markets, several important factors
|
||||
go into making a good question. These lead to
|
||||
more people betting on them and allowing a more
|
||||
accurate prediction to be formed!</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>Manifold also gives its creators 10 Mana for
|
||||
each unique trader that bets on your
|
||||
market!</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #292fd7;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 20px;
|
||||
"
|
||||
><b>What makes a good question?</b></span
|
||||
>
|
||||
</p>
|
||||
<ul>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Clear resolution criteria. </b>This is
|
||||
needed so users know how you are going to
|
||||
decide on what the correct answer is.</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Clear resolution date</b>. This is
|
||||
sometimes slightly different from the closing
|
||||
date. We recommend leaving the market open up
|
||||
until you resolve it, but if it is different
|
||||
make sure you say what day you intend to
|
||||
resolve it in the description!</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Detailed description. </b>Use the rich
|
||||
text editor to create an easy to read
|
||||
description. Include any context or background
|
||||
information that could be useful to people who
|
||||
are interested in learning more that are
|
||||
uneducated on the subject.</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Add it to a group. </b>Groups are the
|
||||
primary way users filter for relevant markets.
|
||||
Also, consider making your own groups and
|
||||
inviting friends/interested communities to
|
||||
them from other sites!</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><b>Bonus: </b>Add a comment on your
|
||||
prediction and explain (with links and
|
||||
sources) supporting it.</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #292fd7;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 20px;
|
||||
"
|
||||
><b
|
||||
>Examples of markets you should
|
||||
emulate! </b
|
||||
></span
|
||||
>
|
||||
</p>
|
||||
<ul>
|
||||
<li style="line-height: 23px">
|
||||
<a
|
||||
class="link-build-content"
|
||||
style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"
|
||||
><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><u>This complex market</u></span
|
||||
></a
|
||||
><span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>
|
||||
about the project I am working on.</span
|
||||
>
|
||||
</li>
|
||||
<li style="line-height: 23px">
|
||||
<a
|
||||
class="link-build-content"
|
||||
style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"
|
||||
><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><u>This simple market</u></span
|
||||
></a
|
||||
><span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>
|
||||
about Manifold's weekly active
|
||||
users.</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>Why not </span>
|
||||
|
||||
|
||||
|
||||
<a
|
||||
class="link-build-content"
|
||||
style="color: inherit; text-decoration: none"
|
||||
target="_blank"
|
||||
href="https://manifold.markets/create"
|
||||
><span
|
||||
style="
|
||||
color: #55575d;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
><u>create a market</u></span
|
||||
></a
|
||||
><span
|
||||
style="
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>
|
||||
while it is still fresh on your mind?
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="line-height: 23px; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>Thanks for reading!</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="
|
||||
line-height: 23px;
|
||||
margin: 10px 0;
|
||||
margin-bottom: 10px;
|
||||
"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Readex Pro, Arial, Helvetica,
|
||||
sans-serif;
|
||||
font-size: 17px;
|
||||
"
|
||||
>David from Manifold</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to {{name}},
|
||||
<a
|
||||
href="{{unsubscribeLink}}"
|
||||
style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
"
|
||||
target="_blank"
|
||||
>click here to unsubscribe</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -165,7 +165,6 @@ export const sendWelcomeEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
// TODO: use manalinks to give out M$500
|
||||
export const sendOneWeekBonusEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser
|
||||
|
@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async (
|
|||
|
||||
await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Manifold one week anniversary gift',
|
||||
'Manifold Markets one week anniversary gift',
|
||||
'one-week',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink,
|
||||
manalink: '', // TODO
|
||||
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
||||
},
|
||||
{
|
||||
from: 'David from Manifold <david@manifold.markets>',
|
||||
|
|
18
functions/src/get-current-user.ts
Normal file
18
functions/src/get-current-user.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { User } from 'common/user'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { newEndpoint, APIError } from './api'
|
||||
|
||||
export const getcurrentuser = newEndpoint(
|
||||
{ method: 'GET' },
|
||||
async (_req, auth) => {
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const [userSnap] = await firestore.getAll(userDoc)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
|
||||
const user = userSnap.data() as User
|
||||
|
||||
return user
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -1,6 +1,6 @@
|
|||
import { newEndpoint } from './api'
|
||||
|
||||
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
|
||||
export const health = newEndpoint({ method: 'GET' }, async (_req, auth) => {
|
||||
return {
|
||||
message: 'Server is working.',
|
||||
uid: auth.uid,
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { onRequest } from 'firebase-functions/v2/https'
|
||||
import { EndpointDefinition } from './api'
|
||||
|
||||
admin.initializeApp()
|
||||
|
||||
|
@ -42,3 +44,71 @@ export * from './create-group'
|
|||
export * from './resolve-market'
|
||||
export * from './unsubscribe'
|
||||
export * from './stripe'
|
||||
export * from './mana-bonus-email'
|
||||
|
||||
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'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { acceptchallenge } from './accept-challenge'
|
||||
|
||||
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)
|
||||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
|
||||
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,
|
||||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
}
|
||||
|
|
42
functions/src/mana-bonus-email.ts
Normal file
42
functions/src/mana-bonus-email.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import * as dayjs from 'dayjs'
|
||||
|
||||
import { getPrivateUser } from './utils'
|
||||
import { sendOneWeekBonusEmail } from './emails'
|
||||
import { User } from 'common/user'
|
||||
|
||||
export const manabonusemail = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.pubsub.schedule('0 9 * * 1-7')
|
||||
.onRun(async () => {
|
||||
await sendOneWeekEmails()
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function sendOneWeekEmails() {
|
||||
const oneWeekAgo = dayjs().subtract(1, 'week').valueOf()
|
||||
const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf()
|
||||
|
||||
const userDocs = await firestore
|
||||
.collection('users')
|
||||
.where('createdTime', '<=', oneWeekAgo)
|
||||
.get()
|
||||
|
||||
for (const user of userDocs.docs.map((d) => d.data() as User)) {
|
||||
if (user.createdTime < twoWeekAgo) continue
|
||||
|
||||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser || privateUser.manaBonusEmailSent) continue
|
||||
|
||||
await firestore
|
||||
.collection('private-users')
|
||||
.doc(user.id)
|
||||
.update({ manaBonusEmailSent: true })
|
||||
|
||||
console.log('sending m$ bonus email to', user.username)
|
||||
await sendOneWeekBonusEmail(user, privateUser)
|
||||
return
|
||||
}
|
||||
}
|
|
@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore
|
|||
contractId: string
|
||||
}
|
||||
const { eventId } = context
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with answer')
|
||||
|
||||
const answer = change.data() as Answer
|
||||
// Ignore ante answer.
|
||||
if (answer.number === 0) return
|
||||
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with answer')
|
||||
|
||||
const answerCreator = await getUser(answer.userId)
|
||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||
|
||||
|
|
|
@ -68,9 +68,10 @@ export const onCreateCommentOnContract = functions
|
|||
? 'answer'
|
||||
: undefined
|
||||
|
||||
const relatedUserId = comment.replyToCommentId
|
||||
const repliedUserId = comment.replyToCommentId
|
||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||
: answer?.userId
|
||||
const recipients = repliedUserId ? [repliedUserId] : []
|
||||
|
||||
await createNotification(
|
||||
comment.id,
|
||||
|
@ -79,7 +80,7 @@ export const onCreateCommentOnContract = functions
|
|||
commentCreator,
|
||||
eventId,
|
||||
comment.text,
|
||||
{ contract, relatedSourceType, relatedUserId }
|
||||
{ contract, relatedSourceType, recipients }
|
||||
)
|
||||
|
||||
const recipientUserIds = uniq([
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
|
||||
export const onCreateContract = functions.firestore
|
||||
|
@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore
|
|||
const contractCreator = await getUser(contract.creatorId)
|
||||
if (!contractCreator) throw new Error('Could not find contract creator')
|
||||
|
||||
const desc = contract.description as JSONContent
|
||||
const mentioned = parseMentions(desc)
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'created',
|
||||
contractCreator,
|
||||
eventId,
|
||||
richTextToString(contract.description as JSONContent),
|
||||
{ contract }
|
||||
richTextToString(desc),
|
||||
{ contract, recipients: mentioned }
|
||||
)
|
||||
})
|
||||
|
|
|
@ -12,7 +12,6 @@ export const onCreateGroup = functions.firestore
|
|||
const groupCreator = await getUser(group.creatorId)
|
||||
if (!groupCreator) throw new Error('Could not find group creator')
|
||||
// create notifications for all members of the group
|
||||
for (const memberId of group.memberIds) {
|
||||
await createNotification(
|
||||
group.id,
|
||||
'group',
|
||||
|
@ -21,10 +20,9 @@ export const onCreateGroup = functions.firestore
|
|||
eventId,
|
||||
group.about,
|
||||
{
|
||||
relatedUserId: memberId,
|
||||
recipients: group.memberIds,
|
||||
slug: group.slug,
|
||||
title: group.name,
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
.onCreate(async (change, context) => {
|
||||
const liquidity = change.data() as LiquidityProvision
|
||||
const { eventId } = context
|
||||
const contract = await getContract(liquidity.contractId)
|
||||
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with liquidity')
|
||||
|
||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
||||
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
||||
|
||||
const contract = await getContract(liquidity.contractId)
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with liquidity')
|
||||
|
||||
const liquidityProvider = await getUser(liquidity.userId)
|
||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore
|
|||
followingUser,
|
||||
eventId,
|
||||
'',
|
||||
{ relatedUserId: follow.userId }
|
||||
{ recipients: [follow.userId] }
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Group } from '../../common/group'
|
||||
import { getContract } from './utils'
|
||||
import { uniq } from 'lodash'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onUpdateGroup = functions.firestore
|
||||
|
@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore
|
|||
const prevGroup = change.before.data() as Group
|
||||
const group = change.after.data() as Group
|
||||
|
||||
// ignore the update we just made
|
||||
// Ignore the activity update we just made
|
||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||
return
|
||||
|
||||
|
@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore
|
|||
.doc(group.id)
|
||||
.update({ mostRecentActivityTime: Date.now() })
|
||||
})
|
||||
|
||||
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
||||
for (const contractId of contractIds) {
|
||||
const contract = await getContract(contractId)
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupSlugs: uniq([
|
||||
...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ??
|
||||
[]),
|
||||
]),
|
||||
groupLinks: [
|
||||
...(contract?.groupLinks?.filter(
|
||||
(link) => link.groupId !== group.id
|
||||
) ?? []),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,8 +103,8 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
|||
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
|
||||
}
|
||||
|
||||
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
|
||||
await transaction.set(txnDoc, txn)
|
||||
const txnDoc = firestore.collection(`txns/`).doc(txn.id)
|
||||
transaction.set(txnDoc, txn)
|
||||
console.log('created referral with txn id:', txn.id)
|
||||
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
|
||||
transaction.update(referredByUserDoc, {
|
||||
|
|
|
@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
limitProb,
|
||||
unfilledBets
|
||||
)
|
||||
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
|
||||
} else if (
|
||||
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
mechanism == 'dpm-2'
|
||||
) {
|
||||
const { outcome } = validate(freeResponseSchema, req.body)
|
||||
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
||||
const answerSnap = await trans.get(answerDoc)
|
||||
|
|
|
@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
|||
import {
|
||||
Contract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
RESOLUTIONS,
|
||||
} from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
|
@ -17,6 +18,7 @@ import {
|
|||
groupPayoutsByUser,
|
||||
Payout,
|
||||
} from '../../common/payouts'
|
||||
import { isAdmin } from '../../common/envs/constants'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
|
@ -68,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] }
|
|||
|
||||
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||
const { contractId } = validate(bodySchema, req.body)
|
||||
const userId = auth.uid
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
|
@ -82,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
req.body
|
||||
)
|
||||
|
||||
if (creatorId !== userId)
|
||||
if (creatorId !== auth.uid && !isAdmin(auth.uid))
|
||||
throw new APIError(403, 'User is not creator of contract')
|
||||
|
||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||
|
@ -245,7 +245,10 @@ function getResolutionParams(contract: Contract, body: string) {
|
|||
...validate(pseudoNumericSchema, body),
|
||||
resolutions: undefined,
|
||||
}
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
} else if (
|
||||
outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE'
|
||||
) {
|
||||
const freeResponseParams = validate(freeResponseSchema, body)
|
||||
const { outcome } = freeResponseParams
|
||||
switch (outcome) {
|
||||
|
@ -292,7 +295,10 @@ function getResolutionParams(contract: Contract, body: string) {
|
|||
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
||||
}
|
||||
|
||||
function validateAnswer(contract: FreeResponseContract, answer: number) {
|
||||
function validateAnswer(
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
answer: number
|
||||
) {
|
||||
const validIds = contract.answers.map((a) => a.id)
|
||||
if (!validIds.includes(answer.toString())) {
|
||||
throw new APIError(400, `${answer} is not a valid answer ID`)
|
||||
|
|
55
functions/src/scripts/backfill-comment-ids.ts
Normal file
55
functions/src/scripts/backfill-comment-ids.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
// We have some old comments without IDs and user IDs. Let's fill them in.
|
||||
// Luckily, this was back when all comments had associated bets, so it's possible
|
||||
// to retrieve the user IDs through the bets.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { QueryDocumentSnapshot } from 'firebase-admin/firestore'
|
||||
import { initAdmin } from './script-init'
|
||||
import { log, writeAsync } from '../utils'
|
||||
import { Bet } from '../../../common/bet'
|
||||
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const getUserIdsByCommentId = async (comments: QueryDocumentSnapshot[]) => {
|
||||
const bets = await firestore.collectionGroup('bets').get()
|
||||
log(`Loaded ${bets.size} bets.`)
|
||||
const betsById = Object.fromEntries(
|
||||
bets.docs.map((b) => [b.id, b.data() as Bet])
|
||||
)
|
||||
return Object.fromEntries(
|
||||
comments.map((c) => [c.id, betsById[c.data().betId].userId])
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const commentsQuery = firestore.collectionGroup('comments')
|
||||
commentsQuery.get().then(async (commentSnaps) => {
|
||||
log(`Loaded ${commentSnaps.size} comments.`)
|
||||
const needsFilling = commentSnaps.docs.filter((ct) => {
|
||||
return !('id' in ct.data()) || !('userId' in ct.data())
|
||||
})
|
||||
log(`${needsFilling.length} comments need IDs.`)
|
||||
const userIdNeedsFilling = needsFilling.filter((ct) => {
|
||||
return !('userId' in ct.data())
|
||||
})
|
||||
log(`${userIdNeedsFilling.length} comments need user IDs.`)
|
||||
const userIdsByCommentId =
|
||||
userIdNeedsFilling.length > 0
|
||||
? await getUserIdsByCommentId(userIdNeedsFilling)
|
||||
: {}
|
||||
const updates = needsFilling.map((ct) => {
|
||||
const fields: { [k: string]: unknown } = {}
|
||||
if (!ct.data().id) {
|
||||
fields.id = ct.id
|
||||
}
|
||||
if (!ct.data().userId && userIdsByCommentId[ct.id]) {
|
||||
fields.userId = userIdsByCommentId[ct.id]
|
||||
}
|
||||
return { doc: ct.ref, fields }
|
||||
})
|
||||
log(`Updating ${updates.length} comments.`)
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all comments.`)
|
||||
})
|
||||
}
|
25
functions/src/scripts/backfill-group-ids.ts
Normal file
25
functions/src/scripts/backfill-group-ids.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
// We have some groups without IDs. Let's fill them in.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import { log, writeAsync } from '../utils'
|
||||
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
if (require.main === module) {
|
||||
const groupsQuery = firestore.collection('groups')
|
||||
groupsQuery.get().then(async (groupSnaps) => {
|
||||
log(`Loaded ${groupSnaps.size} groups.`)
|
||||
const needsFilling = groupSnaps.docs.filter((ct) => {
|
||||
return !('id' in ct.data())
|
||||
})
|
||||
log(`${needsFilling.length} groups need IDs.`)
|
||||
const updates = needsFilling.map((group) => {
|
||||
return { doc: group.ref, fields: { id: group.id } }
|
||||
})
|
||||
log(`Updating ${updates.length} groups.`)
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all groups.`)
|
||||
})
|
||||
}
|
|
@ -66,10 +66,18 @@ export const getServiceAccountCredentials = (env?: string) => {
|
|||
}
|
||||
|
||||
export const initAdmin = (env?: string) => {
|
||||
try {
|
||||
const serviceAccount = getServiceAccountCredentials(env)
|
||||
console.log(`Initializing connection to ${serviceAccount.project_id}...`)
|
||||
console.log(
|
||||
`Initializing connection to ${serviceAccount.project_id} Firebase...`
|
||||
)
|
||||
return admin.initializeApp({
|
||||
projectId: serviceAccount.project_id,
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
console.log(`Initializing connection to default Firebase...`)
|
||||
return admin.initializeApp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { sumBy, uniq } from 'lodash'
|
||||
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
|
@ -9,15 +9,15 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
|||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
import { getValues, log } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { floatingLesserEqual } from '../../common/util/math'
|
||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
shares: z.number(),
|
||||
outcome: z.enum(['YES', 'NO']),
|
||||
shares: z.number().optional(), // leave it out to sell all shares
|
||||
outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have
|
||||
})
|
||||
|
||||
export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||
|
@ -46,14 +46,37 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
throw new APIError(400, 'Trading is closed.')
|
||||
|
||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
|
||||
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
||||
sumBy(bets, (b) => b.shares)
|
||||
)
|
||||
|
||||
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
||||
let chosenOutcome: 'YES' | 'NO'
|
||||
if (outcome != null) {
|
||||
chosenOutcome = outcome
|
||||
} else {
|
||||
const nonzeroShares = Object.entries(sharesByOutcome).filter(
|
||||
([_k, v]) => !floatingEqual(0, v)
|
||||
)
|
||||
if (nonzeroShares.length == 0) {
|
||||
throw new APIError(400, "You don't own any shares in this market.")
|
||||
}
|
||||
if (nonzeroShares.length > 1) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`You own multiple kinds of shares, but did not specify which to sell.`
|
||||
)
|
||||
}
|
||||
chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO'
|
||||
}
|
||||
|
||||
if (!floatingLesserEqual(shares, maxShares))
|
||||
const maxShares = sharesByOutcome[chosenOutcome]
|
||||
const sharesToSell = shares ?? maxShares
|
||||
|
||||
if (!floatingLesserEqual(sharesToSell, maxShares))
|
||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||
|
||||
const soldShares = Math.min(shares, maxShares)
|
||||
const soldShares = Math.min(sharesToSell, maxShares)
|
||||
|
||||
const unfilledBetsSnap = await transaction.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
|
@ -62,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
outcome,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
prevLoanAmount,
|
||||
unfilledBets
|
||||
|
|
|
@ -26,9 +26,10 @@ export const sendTemplateEmail = (
|
|||
subject: string,
|
||||
templateId: string,
|
||||
templateData: Record<string, string>,
|
||||
options?: { from: string }
|
||||
options?: Partial<mailgun.messages.SendTemplateData>
|
||||
) => {
|
||||
const data = {
|
||||
const data: mailgun.messages.SendTemplateData = {
|
||||
...options,
|
||||
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
||||
to,
|
||||
subject,
|
||||
|
@ -36,6 +37,7 @@ export const sendTemplateEmail = (
|
|||
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
||||
}
|
||||
const mg = initMailgun()
|
||||
|
||||
return mg.messages().send(data, (error) => {
|
||||
if (error) console.log('Error sending email', error)
|
||||
else console.log('Sent template email', templateId, to, subject)
|
||||
|
|
70
functions/src/serve.ts
Normal file
70
functions/src/serve.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
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'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
|
||||
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)
|
||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
|
||||
app.listen(PORT)
|
||||
console.log(`Serving functions on port ${PORT}.`)
|
|
@ -1,7 +1,7 @@
|
|||
import { onRequest } from 'firebase-functions/v2/https'
|
||||
import * as admin from 'firebase-admin'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import { EndpointDefinition } from './api'
|
||||
import { getPrivateUser, getUser, isProd, payUser } from './utils'
|
||||
import { sendThankYouEmail } from './emails'
|
||||
import { track } from './analytics'
|
||||
|
@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd()
|
|||
10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE',
|
||||
}
|
||||
|
||||
export const createcheckoutsession = onRequest(
|
||||
{ minInstances: 1, secrets: ['STRIPE_APIKEY'] },
|
||||
async (req, res) => {
|
||||
export const createcheckoutsession: EndpointDefinition = {
|
||||
opts: { method: 'POST', minInstances: 1, secrets: ['STRIPE_APIKEY'] },
|
||||
handler: async (req, res) => {
|
||||
const userId = req.query.userId?.toString()
|
||||
|
||||
const manticDollarQuantity = req.query.manticDollarQuantity?.toString()
|
||||
|
@ -86,21 +86,24 @@ export const createcheckoutsession = onRequest(
|
|||
})
|
||||
|
||||
res.redirect(303, session.url || '')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export const stripewebhook = onRequest(
|
||||
{
|
||||
export const stripewebhook: EndpointDefinition = {
|
||||
opts: {
|
||||
method: 'POST',
|
||||
minInstances: 1,
|
||||
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
|
||||
},
|
||||
async (req, res) => {
|
||||
handler: async (req, res) => {
|
||||
const stripe = initStripe()
|
||||
let event
|
||||
|
||||
try {
|
||||
// Cloud Functions jam the raw body into a special `rawBody` property
|
||||
const rawBody = (req as any).rawBody ?? req.body
|
||||
event = stripe.webhooks.constructEvent(
|
||||
req.rawBody,
|
||||
rawBody,
|
||||
req.headers['stripe-signature'] as string,
|
||||
process.env.STRIPE_WEBHOOKSECRET as string
|
||||
)
|
||||
|
@ -116,8 +119,8 @@ export const stripewebhook = onRequest(
|
|||
}
|
||||
|
||||
res.status(200).send('success')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const issueMoneys = async (session: StripeSession) => {
|
||||
const { id: sessionId } = session
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { onRequest } from 'firebase-functions/v2/https'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { EndpointDefinition } from './api'
|
||||
import { getUser } from './utils'
|
||||
import { PrivateUser } from '../../common/user'
|
||||
|
||||
export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => {
|
||||
export const unsubscribe: EndpointDefinition = {
|
||||
opts: { method: 'GET', minInstances: 1 },
|
||||
handler: async (req, res) => {
|
||||
const id = req.query.id as string
|
||||
let type = req.query.type as string
|
||||
if (!id || !type) {
|
||||
|
@ -14,9 +16,12 @@ export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => {
|
|||
if (type === 'market-resolved') type = 'market-resolve'
|
||||
|
||||
if (
|
||||
!['market-resolve', 'market-comment', 'market-answer', 'generic'].includes(
|
||||
type
|
||||
)
|
||||
![
|
||||
'market-resolve',
|
||||
'market-comment',
|
||||
'market-answer',
|
||||
'generic',
|
||||
].includes(type)
|
||||
) {
|
||||
res.status(400).send('Invalid type parameter.')
|
||||
return
|
||||
|
@ -61,6 +66,7 @@ export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => {
|
|||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
||||
)
|
||||
else res.send(`${name}, you have been unsubscribed.`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -11,8 +11,6 @@ import { last } from 'lodash'
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const oneDay = 1000 * 60 * 60 * 24
|
||||
|
||||
const computeInvestmentValue = (
|
||||
bets: Bet[],
|
||||
contractsDict: { [k: string]: Contract }
|
||||
|
@ -59,8 +57,8 @@ export const updateMetricsCore = async () => {
|
|||
return {
|
||||
doc: firestore.collection('contracts').doc(contract.id),
|
||||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - oneDay),
|
||||
volume7Days: computeVolume(contractBets, now - oneDay * 7),
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,32 +1,35 @@
|
|||
# Installing
|
||||
1. `yarn install`
|
||||
2. `yarn start`
|
||||
3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]`
|
||||
4. `Manifold Markets` to `Which scope should contain your project? [Y/n] `
|
||||
5. `Y` to `Link to existing project? [Y/n] `
|
||||
6. `opengraph-image` to `What’s the name of your existing project?`
|
||||
|
||||
# Quickstart
|
||||
|
||||
1. To get started: `yarn install`
|
||||
2. To test locally: `yarn start`
|
||||
1. To test locally: `yarn start`
|
||||
The local image preview is broken for some reason; but the service works.
|
||||
E.g. try `http://localhost:3000/manifold.png`
|
||||
3. To deploy: push to Github
|
||||
2. To deploy: push to Github
|
||||
- note: (Not `dev` because that's reserved for Vercel)
|
||||
- note2: (Or `cd .. && vercel --prod`, I think)
|
||||
|
||||
For more info, see Contributing.md
|
||||
|
||||
- note2: You may have to configure Vercel the first time:
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
yarn run v1.22.10
|
||||
$ cd .. && vercel dev
|
||||
Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback
|
||||
? Set up and develop “~/Code/mantic”? [Y/n] y
|
||||
? Which scope should contain your project? Mantic Markets
|
||||
? Found project “mantic/mantic”. Link to it? [Y/n] n
|
||||
? Link to different existing project? [Y/n] y
|
||||
? What’s the name of your existing project? manifold-og-image
|
||||
```
|
||||
|
||||
- note2: (Not `dev` because that's reserved for Vercel)
|
||||
- note3: (Or `cd .. && vercel --prod`, I think)
|
||||
|
||||
(Everything below is from the original repo)
|
||||
|
||||
# Development
|
||||
- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI.
|
||||
- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters.
|
||||
- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to
|
||||
`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch.
|
||||
You have to find your opengraph-image branch's url and replace the part before `m.png` with it.
|
||||
- You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.`
|
||||
- Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached.
|
||||
- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github:
|
||||
![](../../../../../Desktop/Screen Shot 2022-08-01 at 2.56.42 PM.png)
|
||||
|
||||
|
||||
# [Open Graph Image as a Service](https://og-image.vercel.app)
|
||||
|
||||
<a href="https://twitter.com/vercel">
|
||||
|
|
203
og-image/api/_lib/challenge-template.ts
Normal file
203
og-image/api/_lib/challenge-template.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
import { sanitizeHtml } from './sanitizer'
|
||||
import { ParsedRequest } from './types'
|
||||
|
||||
function getCss(theme: string, fontSize: string) {
|
||||
let background = 'white'
|
||||
let foreground = 'black'
|
||||
let radial = 'lightgray'
|
||||
|
||||
if (theme === 'dark') {
|
||||
background = 'black'
|
||||
foreground = 'white'
|
||||
radial = 'dimgray'
|
||||
}
|
||||
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
|
||||
return `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
background: ${background};
|
||||
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
|
||||
background-size: 100px 100px;
|
||||
height: 100vh;
|
||||
font-family: "Readex Pro", sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
color: #D400FF;
|
||||
font-family: 'Vera';
|
||||
white-space: pre-wrap;
|
||||
letter-spacing: -5px;
|
||||
}
|
||||
|
||||
code:before, code:after {
|
||||
content: '\`';
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 75px;
|
||||
}
|
||||
|
||||
.plus {
|
||||
color: #BBB;
|
||||
font-family: Times New Roman, Verdana;
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin: 150px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
margin: 0 .05em 0 .1em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: 'Major Mono Display', monospace;
|
||||
font-size: ${sanitizeHtml(fontSize)};
|
||||
font-style: normal;
|
||||
color: ${foreground};
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.font-major-mono {
|
||||
font-family: "Major Mono Display", monospace;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #11b981;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export function getChallengeHtml(parsedReq: ParsedRequest) {
|
||||
const {
|
||||
theme,
|
||||
fontSize,
|
||||
question,
|
||||
creatorName,
|
||||
creatorAvatarUrl,
|
||||
challengerAmount,
|
||||
challengerOutcome,
|
||||
creatorAmount,
|
||||
creatorOutcome,
|
||||
acceptedName,
|
||||
acceptedAvatarUrl,
|
||||
} = parsedReq
|
||||
const MAX_QUESTION_CHARS = 78
|
||||
const truncatedQuestion =
|
||||
question.length > MAX_QUESTION_CHARS
|
||||
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
||||
: question
|
||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||
const hideAcceptedAvatar = acceptedAvatarUrl ? '' : 'hidden'
|
||||
const accepted = acceptedName !== ''
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Generated Image</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<style>
|
||||
${getCss(theme, fontSize)}
|
||||
</style>
|
||||
<body>
|
||||
<div class="px-24">
|
||||
|
||||
|
||||
<div class="flex flex-col justify-between gap-16 pt-2">
|
||||
<div class="flex flex-col text-indigo-700 mt-4 text-5xl leading-tight text-center">
|
||||
${truncatedQuestion}
|
||||
</div>
|
||||
<div class="flex flex-row grid grid-cols-3">
|
||||
<div class="flex flex-col justify-center items-center ${
|
||||
creatorOutcome === 'YES' ? 'text-primary' : 'text-red-500'
|
||||
}">
|
||||
|
||||
<!-- Creator user column-->
|
||||
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
|
||||
<p class="text-gray-900 text-4xl">${creatorName}</p>
|
||||
<img
|
||||
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAvatar}"
|
||||
src="${creatorAvatarUrl}"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-center items-center gap-3 mt-6">
|
||||
<div class="text-5xl">${'M$' + creatorAmount}</div>
|
||||
<div class="text-4xl">${'on'}</div>
|
||||
<div class="text-5xl ">${creatorOutcome}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS-->
|
||||
<div class="flex flex-col text-gray-900 text-6xl mt-8 text-center">
|
||||
VS
|
||||
</div>
|
||||
<div class="flex flex-col justify-center items-center ${
|
||||
challengerOutcome === 'YES' ? 'text-primary' : 'text-red-500'
|
||||
}">
|
||||
|
||||
<!-- Unaccepted user column-->
|
||||
<div class="flex flex-col align-bottom gap-6 items-center justify-center
|
||||
${accepted ? 'hidden' : ''}">
|
||||
<p class="text-gray-900 text-4xl">You</p>
|
||||
<img
|
||||
class="h-36 w-36 rounded-full bg-white flex items-center justify-center "
|
||||
src="https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<!-- Accepted user column-->
|
||||
<div class="flex flex-col align-bottom gap-6 items-center justify-center">
|
||||
<p class="text-gray-900 text-4xl">${acceptedName}</p>
|
||||
<img
|
||||
class="h-36 w-36 rounded-full bg-white flex items-center justify-center ${hideAcceptedAvatar}"
|
||||
src="${acceptedAvatarUrl}"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-center items-center gap-3 mt-6">
|
||||
<div class="text-5xl">${'M$' + challengerAmount}</div>
|
||||
<div class="text-4xl">${'on'}</div>
|
||||
<div class="text-5xl ">${challengerOutcome}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manifold logo -->
|
||||
<div class="flex flex-row justify-center absolute bottom-4 left-[24rem]">
|
||||
<a class="flex flex-row gap-3" href="/">
|
||||
<img
|
||||
class="sm:h-12 sm:w-12"
|
||||
src="https://manifold.markets/logo.png"
|
||||
width="40"
|
||||
height="40"
|
||||
alt=''
|
||||
/>
|
||||
<div
|
||||
class="hidden sm:flex font-major-mono lowercase mt-1 sm:text-3xl md:whitespace-nowrap"
|
||||
>
|
||||
Manifold Markets
|
||||
</div></a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
|
@ -16,10 +16,19 @@ export function parseRequest(req: IncomingMessage) {
|
|||
// Attributes for Manifold card:
|
||||
question,
|
||||
probability,
|
||||
numericValue,
|
||||
metadata,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
|
||||
// Challenge attributes:
|
||||
challengerAmount,
|
||||
challengerOutcome,
|
||||
creatorAmount,
|
||||
creatorOutcome,
|
||||
acceptedName,
|
||||
acceptedAvatarUrl,
|
||||
} = query || {}
|
||||
|
||||
if (Array.isArray(fontSize)) {
|
||||
|
@ -63,10 +72,17 @@ export function parseRequest(req: IncomingMessage) {
|
|||
question:
|
||||
getString(question) || 'Will you create a prediction market on Manifold?',
|
||||
probability: getString(probability),
|
||||
numericValue: getString(numericValue) || '',
|
||||
metadata: getString(metadata) || 'Jan 1 • M$ 123 pool',
|
||||
creatorName: getString(creatorName) || 'Manifold Markets',
|
||||
creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
|
||||
creatorAvatarUrl: getString(creatorAvatarUrl) || '',
|
||||
challengerAmount: getString(challengerAmount) || '',
|
||||
challengerOutcome: getString(challengerOutcome) || '',
|
||||
creatorAmount: getString(creatorAmount) || '',
|
||||
creatorOutcome: getString(creatorOutcome) || '',
|
||||
acceptedName: getString(acceptedName) || '',
|
||||
acceptedAvatarUrl: getString(acceptedAvatarUrl) || '',
|
||||
}
|
||||
parsedRequest.images = getDefaultImages(parsedRequest.images)
|
||||
return parsedRequest
|
||||
|
|
|
@ -91,6 +91,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
numericValue,
|
||||
} = parsedReq
|
||||
const MAX_QUESTION_CHARS = 100
|
||||
const truncatedQuestion =
|
||||
|
@ -126,7 +127,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mantic logo -->
|
||||
<!-- Manifold logo -->
|
||||
<div class="absolute right-24 top-8">
|
||||
<a class="flex flex-row gap-3" href="/"
|
||||
><img
|
||||
|
@ -150,6 +151,12 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
<div class="flex flex-col text-primary">
|
||||
<div class="text-8xl">${probability}</div>
|
||||
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
|
||||
<span class='text-blue-500 text-center'>
|
||||
<div class="text-8xl ">${
|
||||
numericValue !== '' && probability === '' ? numericValue : ''
|
||||
}</div>
|
||||
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,21 +1,29 @@
|
|||
export type FileType = "png" | "jpeg";
|
||||
export type Theme = "light" | "dark";
|
||||
export type FileType = 'png' | 'jpeg'
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
export interface ParsedRequest {
|
||||
fileType: FileType;
|
||||
text: string;
|
||||
theme: Theme;
|
||||
md: boolean;
|
||||
fontSize: string;
|
||||
images: string[];
|
||||
widths: string[];
|
||||
heights: string[];
|
||||
fileType: FileType
|
||||
text: string
|
||||
theme: Theme
|
||||
md: boolean
|
||||
fontSize: string
|
||||
images: string[]
|
||||
widths: string[]
|
||||
heights: string[]
|
||||
|
||||
// Attributes for Manifold card:
|
||||
question: string;
|
||||
probability: string;
|
||||
metadata: string;
|
||||
creatorName: string;
|
||||
creatorUsername: string;
|
||||
creatorAvatarUrl: string;
|
||||
question: string
|
||||
probability: string
|
||||
numericValue: string
|
||||
metadata: string
|
||||
creatorName: string
|
||||
creatorUsername: string
|
||||
creatorAvatarUrl: string
|
||||
// Challenge attributes:
|
||||
challengerAmount: string
|
||||
challengerOutcome: string
|
||||
creatorAmount: string
|
||||
creatorOutcome: string
|
||||
acceptedName: string
|
||||
acceptedAvatarUrl: string
|
||||
}
|
||||
|
|
|
@ -1,36 +1,38 @@
|
|||
import { IncomingMessage, ServerResponse } from "http";
|
||||
import { parseRequest } from "./_lib/parser";
|
||||
import { getScreenshot } from "./_lib/chromium";
|
||||
import { getHtml } from "./_lib/template";
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
import { parseRequest } from './_lib/parser'
|
||||
import { getScreenshot } from './_lib/chromium'
|
||||
import { getHtml } from './_lib/template'
|
||||
import { getChallengeHtml } from './_lib/challenge-template'
|
||||
|
||||
const isDev = !process.env.AWS_REGION;
|
||||
const isHtmlDebug = process.env.OG_HTML_DEBUG === "1";
|
||||
const isDev = !process.env.AWS_REGION
|
||||
const isHtmlDebug = process.env.OG_HTML_DEBUG === '1'
|
||||
|
||||
export default async function handler(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
) {
|
||||
try {
|
||||
const parsedReq = parseRequest(req);
|
||||
const html = getHtml(parsedReq);
|
||||
const parsedReq = parseRequest(req)
|
||||
let html = getHtml(parsedReq)
|
||||
if (parsedReq.challengerOutcome) html = getChallengeHtml(parsedReq)
|
||||
if (isHtmlDebug) {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end(html);
|
||||
return;
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end(html)
|
||||
return
|
||||
}
|
||||
const { fileType } = parsedReq;
|
||||
const file = await getScreenshot(html, fileType, isDev);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", `image/${fileType}`);
|
||||
const { fileType } = parsedReq
|
||||
const file = await getScreenshot(html, fileType, isDev)
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', `image/${fileType}`)
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
'Cache-Control',
|
||||
`public, immutable, no-transform, s-maxage=31536000, max-age=31536000`
|
||||
);
|
||||
res.end(file);
|
||||
)
|
||||
res.end(file)
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end("<h1>Internal Error</h1><p>Sorry, there was a problem</p>");
|
||||
console.error(e);
|
||||
res.statusCode = 500
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>')
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,10 +14,13 @@
|
|||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "5.25.0",
|
||||
"@typescript-eslint/parser": "5.25.0",
|
||||
"concurrently": "6.5.1",
|
||||
"eslint": "8.15.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"prettier": "2.5.0",
|
||||
"typescript": "4.6.4"
|
||||
"typescript": "4.6.4",
|
||||
"ts-node": "10.9.1",
|
||||
"nodemon": "2.0.19"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.43"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ReactNode } from 'react'
|
||||
import Head from 'next/head'
|
||||
import { Challenge } from 'common/challenge'
|
||||
|
||||
export type OgCardProps = {
|
||||
question: string
|
||||
|
@ -8,27 +9,51 @@ export type OgCardProps = {
|
|||
creatorName: string
|
||||
creatorUsername: string
|
||||
creatorAvatarUrl?: string
|
||||
numericValue?: string
|
||||
}
|
||||
|
||||
function buildCardUrl(props: OgCardProps) {
|
||||
function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||
const {
|
||||
creatorAmount,
|
||||
acceptances,
|
||||
acceptorAmount,
|
||||
creatorOutcome,
|
||||
acceptorOutcome,
|
||||
} = challenge || {}
|
||||
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
||||
|
||||
const probabilityParam =
|
||||
props.probability === undefined
|
||||
? ''
|
||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
||||
|
||||
const numericValueParam =
|
||||
props.numericValue === undefined
|
||||
? ''
|
||||
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
|
||||
|
||||
const creatorAvatarUrlParam =
|
||||
props.creatorAvatarUrl === undefined
|
||||
? ''
|
||||
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
||||
|
||||
const challengeUrlParams = challenge
|
||||
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
||||
`&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` +
|
||||
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
||||
: ''
|
||||
|
||||
// URL encode each of the props, then add them as query params
|
||||
return (
|
||||
`https://manifold-og-image.vercel.app/m.png` +
|
||||
`?question=${encodeURIComponent(props.question)}` +
|
||||
probabilityParam +
|
||||
numericValueParam +
|
||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||
creatorAvatarUrlParam +
|
||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
|
||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
||||
challengeUrlParams
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -38,8 +63,9 @@ export function SEO(props: {
|
|||
url?: string
|
||||
children?: ReactNode
|
||||
ogCardProps?: OgCardProps
|
||||
challenge?: Challenge
|
||||
}) {
|
||||
const { title, description, url, children, ogCardProps } = props
|
||||
const { title, description, url, children, ogCardProps, challenge } = props
|
||||
|
||||
return (
|
||||
<Head>
|
||||
|
@ -71,13 +97,13 @@ export function SEO(props: {
|
|||
<>
|
||||
<meta
|
||||
property="og:image"
|
||||
content={buildCardUrl(ogCardProps)}
|
||||
content={buildCardUrl(ogCardProps, challenge)}
|
||||
key="image1"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" key="card" />
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={buildCardUrl(ogCardProps)}
|
||||
content={buildCardUrl(ogCardProps, challenge)}
|
||||
key="image2"
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { Linkify } from './linkify'
|
||||
|
||||
export function AlertBox(props: { title: string; text: string }) {
|
||||
const { title, text } = props
|
||||
return (
|
||||
<div className="rounded-md bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Col className="rounded-md bg-yellow-50 p-4">
|
||||
<Row className="mb-2 flex-shrink-0">
|
||||
<ExclamationIcon
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<div className="mt-2 whitespace-pre-line text-sm text-yellow-700">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { BuyAmountInput } from '../amount-input'
|
||||
import { Col } from '../layout/col'
|
||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
||||
|
@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device'
|
|||
|
||||
export function AnswerBetPanel(props: {
|
||||
answer: Answer
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
closePanel: () => void
|
||||
className?: string
|
||||
isModal?: boolean
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Avatar } from '../avatar'
|
||||
|
@ -13,7 +13,7 @@ import { Linkify } from '../linkify'
|
|||
|
||||
export function AnswerItem(props: {
|
||||
answer: Answer
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
showChoice: 'radio' | 'checkbox' | undefined
|
||||
chosenProb: number | undefined
|
||||
totalChosenProb?: number
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx'
|
|||
import { sum } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Contract, FreeResponse } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||
import { Row } from '../layout/row'
|
||||
|
@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button'
|
|||
import { removeUndefinedProps } from 'common/util/object'
|
||||
|
||||
export function AnswerResolvePanel(props: {
|
||||
contract: Contract & FreeResponse
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||
setResolveOption: (
|
||||
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||
|
|
|
@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash'
|
|||
import { memo } from 'react'
|
||||
|
||||
import { Bet } from 'common/bet'
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { getOutcomeProbability } from 'common/calculate'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
const NUM_LINES = 6
|
||||
|
||||
export const AnswersGraph = memo(function AnswersGraph(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
bets: Bet[]
|
||||
height?: number
|
||||
}) {
|
||||
|
@ -178,15 +178,22 @@ function formatTime(
|
|||
return d.format(format)
|
||||
}
|
||||
|
||||
const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => {
|
||||
const { totalBets } = contract
|
||||
const computeProbsByOutcome = (
|
||||
bets: Bet[],
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
) => {
|
||||
const { totalBets, outcomeType } = contract
|
||||
|
||||
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
|
||||
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
|
||||
const maxProb = Math.max(
|
||||
...betsByOutcome[outcome].map((bet) => bet.probAfter)
|
||||
)
|
||||
return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001
|
||||
return (
|
||||
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
maxProb > 0.02 &&
|
||||
totalBets[outcome] > 0.000000001
|
||||
)
|
||||
})
|
||||
|
||||
const trackedOutcomes = sortBy(
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { sortBy, partition, sum, uniq } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||
|
@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page'
|
|||
import { Linkify } from 'web/components/linkify'
|
||||
import { BuyButton } from 'web/components/yes-no-selector'
|
||||
|
||||
export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
||||
export function AnswersPanel(props: {
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
}) {
|
||||
const { contract } = props
|
||||
const { creatorId, resolution, resolutions, totalBets } = contract
|
||||
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
||||
contract
|
||||
|
||||
const answers = useAnswers(contract.id) ?? contract.answers
|
||||
const [winningAnswers, losingAnswers] = partition(
|
||||
answers.filter(
|
||||
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
|
||||
(answer) =>
|
||||
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
totalBets[answer.id] > 0.000000001
|
||||
),
|
||||
(answer) =>
|
||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||
|
@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
|||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||
)}
|
||||
|
||||
{tradingAllowed(contract) &&
|
||||
{outcomeType === 'FREE_RESPONSE' &&
|
||||
tradingAllowed(contract) &&
|
||||
(!resolveOption || resolveOption === 'CANCEL') && (
|
||||
<CreateAnswerPanel contract={contract} />
|
||||
)}
|
||||
|
@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
|||
}
|
||||
|
||||
function getAnswerItems(
|
||||
contract: FreeResponseContract,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
answers: Answer[],
|
||||
user: User | undefined | null
|
||||
) {
|
||||
|
@ -178,7 +184,7 @@ function getAnswerItems(
|
|||
}
|
||||
|
||||
function OpenAnswer(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
answer: Answer
|
||||
items: ActivityItem[]
|
||||
type: string
|
||||
|
|
65
web/components/answers/multiple-choice-answers.tsx
Normal file
65
web/components/answers/multiple-choice-answers.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
|
||||
export function MultipleChoiceAnswers(props: {
|
||||
setAnswers: (answers: string[]) => void
|
||||
}) {
|
||||
const [answers, setInternalAnswers] = useState(['', '', ''])
|
||||
|
||||
const setAnswer = (i: number, answer: string) => {
|
||||
const newAnswers = setElement(answers, i, answer)
|
||||
setInternalAnswers(newAnswers)
|
||||
props.setAnswers(newAnswers)
|
||||
}
|
||||
|
||||
const removeAnswer = (i: number) => {
|
||||
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
|
||||
setInternalAnswers(newAnswers)
|
||||
props.setAnswers(newAnswers)
|
||||
}
|
||||
|
||||
const addAnswer = () => setAnswer(answers.length, '')
|
||||
|
||||
return (
|
||||
<Col>
|
||||
{answers.map((answer, i) => (
|
||||
<Row className="mb-2 items-center align-middle">
|
||||
{i + 1}.{' '}
|
||||
<Textarea
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(i, e.target.value)}
|
||||
className="textarea textarea-bordered ml-2 w-full resize-none"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={MAX_ANSWER_LENGTH}
|
||||
/>
|
||||
{answers.length > 2 && (
|
||||
<button
|
||||
className="btn btn-xs btn-outline ml-2"
|
||||
onClick={() => removeAnswer(i)}
|
||||
>
|
||||
<XIcon className="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<Row className="justify-end">
|
||||
<button className="btn btn-outline btn-xs" onClick={addAnswer}>
|
||||
Add answer
|
||||
</button>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
const setElement = <T,>(array: T[], i: number, elem: T) => {
|
||||
const newArray = array.concat()
|
||||
newArray[i] = elem
|
||||
return newArray
|
||||
}
|
|
@ -16,8 +16,7 @@ import {
|
|||
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { Bet, LimitBet } from 'common/bet'
|
||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
||||
import { sellShares } from 'web/lib/firebase/api'
|
||||
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import {
|
||||
|
@ -42,6 +41,8 @@ import { useUnfilledBets } from 'web/hooks/use-bets'
|
|||
import { LimitBets } from './limit-bets'
|
||||
import { PillButton } from './buttons/pill-button'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||
import { AlertBox } from './alert-box'
|
||||
|
||||
export function BetPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
|
@ -72,6 +73,7 @@ export function BetPanel(props: {
|
|||
<QuickOrLimitBet
|
||||
isLimitOrder={isLimitOrder}
|
||||
setIsLimitOrder={setIsLimitOrder}
|
||||
hideToggle={!user}
|
||||
/>
|
||||
<BuyPanel
|
||||
hidden={isLimitOrder}
|
||||
|
@ -85,9 +87,13 @@ export function BetPanel(props: {
|
|||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
|
||||
<SignUpPrompt />
|
||||
|
||||
{!user && <PlayMoneyDisclaimer />}
|
||||
</Col>
|
||||
{unfilledBets.length > 0 && (
|
||||
|
||||
{user && unfilledBets.length > 0 && (
|
||||
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
||||
)}
|
||||
</Col>
|
||||
|
@ -124,6 +130,7 @@ export function SimpleBetPanel(props: {
|
|||
<QuickOrLimitBet
|
||||
isLimitOrder={isLimitOrder}
|
||||
setIsLimitOrder={setIsLimitOrder}
|
||||
hideToggle={!user}
|
||||
/>
|
||||
<BuyPanel
|
||||
hidden={isLimitOrder}
|
||||
|
@ -140,7 +147,10 @@ export function SimpleBetPanel(props: {
|
|||
unfilledBets={unfilledBets}
|
||||
onBuySuccess={onBetSuccess}
|
||||
/>
|
||||
|
||||
<SignUpPrompt />
|
||||
|
||||
{!user && <PlayMoneyDisclaimer />}
|
||||
</Col>
|
||||
|
||||
{unfilledBets.length > 0 && (
|
||||
|
@ -254,6 +264,8 @@ function BuyPanel(props: {
|
|||
|
||||
const format = getFormattedMappedValue(contract)
|
||||
|
||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||
|
||||
return (
|
||||
<Col className={hidden ? 'hidden' : ''}>
|
||||
<div className="my-3 text-left text-sm text-gray-500">
|
||||
|
@ -277,6 +289,22 @@ function BuyPanel(props: {
|
|||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
|
||||
{(betAmount ?? 0) > 10 &&
|
||||
bankrollFraction >= 0.5 &&
|
||||
bankrollFraction <= 1 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`You might not want to spend ${formatPercent(
|
||||
bankrollFraction
|
||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||
user?.balance ?? 0
|
||||
)}`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
|
@ -322,7 +350,7 @@ function BuyPanel(props: {
|
|||
{user && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn flex-1',
|
||||
'btn mb-2 flex-1',
|
||||
betDisabled
|
||||
? 'btn-disabled'
|
||||
: outcome === 'YES'
|
||||
|
@ -688,12 +716,14 @@ function LimitOrderPanel(props: {
|
|||
function QuickOrLimitBet(props: {
|
||||
isLimitOrder: boolean
|
||||
setIsLimitOrder: (isLimitOrder: boolean) => void
|
||||
hideToggle?: boolean
|
||||
}) {
|
||||
const { isLimitOrder, setIsLimitOrder } = props
|
||||
const { isLimitOrder, setIsLimitOrder, hideToggle } = props
|
||||
|
||||
return (
|
||||
<Row className="align-center mb-4 justify-between">
|
||||
<div className="text-4xl">Bet</div>
|
||||
{!hideToggle && (
|
||||
<Row className="mt-1 items-center gap-2">
|
||||
<PillButton
|
||||
selected={!isLimitOrder}
|
||||
|
@ -714,6 +744,7 @@ function QuickOrLimitBet(props: {
|
|||
Limit
|
||||
</PillButton>
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -739,7 +770,9 @@ export function SellPanel(props: {
|
|||
const betDisabled = isSubmitting || !amount || error
|
||||
|
||||
// Sell all shares if remaining shares would be < 1
|
||||
const sellQuantity = amount === Math.floor(shares) ? shares : amount
|
||||
const isSellingAllShares = amount === Math.floor(shares)
|
||||
|
||||
const sellQuantity = isSellingAllShares ? shares : amount
|
||||
|
||||
async function submitSell() {
|
||||
if (!user || !amount) return
|
||||
|
@ -748,7 +781,7 @@ export function SellPanel(props: {
|
|||
setIsSubmitting(true)
|
||||
|
||||
await sellShares({
|
||||
shares: sellQuantity,
|
||||
shares: isSellingAllShares ? undefined : amount,
|
||||
outcome: sharesOutcome,
|
||||
contractId: contract.id,
|
||||
})
|
||||
|
|
|
@ -3,6 +3,7 @@ import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
|||
import dayjs from 'dayjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Bet } from 'web/lib/firebase/bets'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
|
@ -156,9 +157,7 @@ export function BetsList(props: {
|
|||
(c) => contractsMetrics[c.id].netPayout
|
||||
)
|
||||
|
||||
const totalPortfolio = currentNetInvestment + user.balance
|
||||
|
||||
const totalPnl = totalPortfolio - user.totalDeposits
|
||||
const totalPnl = user.profitCached.allTime
|
||||
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
||||
const investedProfitPercent =
|
||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||
|
@ -277,13 +276,7 @@ function ContractBets(props: {
|
|||
bets
|
||||
)
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'collapse collapse-arrow relative bg-white p-4 pr-6',
|
||||
collapsed ? 'collapse-close' : 'collapse-open pb-2'
|
||||
)}
|
||||
>
|
||||
<div tabIndex={0} className="relative bg-white p-4 pr-6">
|
||||
<Row
|
||||
className="cursor-pointer flex-wrap gap-2"
|
||||
onClick={() => setCollapsed((collapsed) => !collapsed)}
|
||||
|
@ -300,10 +293,11 @@ function ContractBets(props: {
|
|||
</Link>
|
||||
|
||||
{/* Show carrot for collapsing. Hack the positioning. */}
|
||||
<div
|
||||
className="collapse-title absolute h-0 min-h-0 w-0 p-0"
|
||||
style={{ top: -10, right: 0 }}
|
||||
/>
|
||||
{collapsed ? (
|
||||
<ChevronDownIcon className="absolute top-5 right-4 h-6 w-6" />
|
||||
) : (
|
||||
<ChevronUpIcon className="absolute top-5 right-4 h-6 w-6" />
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Row className="flex-1 items-center gap-2 text-sm text-gray-500">
|
||||
|
@ -335,55 +329,42 @@ function ContractBets(props: {
|
|||
</Row>
|
||||
</Col>
|
||||
|
||||
<Row className="mr-5 justify-end sm:mr-8">
|
||||
<Col>
|
||||
<Col className="mr-5 sm:mr-8">
|
||||
<div className="whitespace-nowrap text-right text-lg">
|
||||
{formatMoney(metric === 'profit' ? profit : payout)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ProfitBadge profitPercent={profitPercent} />
|
||||
</div>
|
||||
<ProfitBadge className="text-right" profitPercent={profitPercent} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<div
|
||||
className="collapse-content !px-0"
|
||||
style={{ backgroundColor: 'white' }}
|
||||
>
|
||||
<Spacer h={8} />
|
||||
|
||||
{!collapsed && (
|
||||
<div className="bg-white">
|
||||
<BetsSummary
|
||||
className="mr-5 flex-1 sm:mr-8"
|
||||
className="mt-8 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="bg-gray-50 px-4 py-2">Limit orders</div>
|
||||
<div className="mt-4 bg-gray-50 px-4 py-2">Limit orders</div>
|
||||
<LimitOrderTable
|
||||
contract={contract}
|
||||
limitBets={limitBets}
|
||||
isYou={true}
|
||||
isYou={isYourBets}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<div className="bg-gray-50 px-4 py-2">Bets</div>
|
||||
<div className="mt-4 bg-gray-50 px-4 py-2">Bets</div>
|
||||
<ContractBetsTable
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
isYourBets={isYourBets}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -427,7 +408,6 @@ export function BetsSummary(props: {
|
|||
|
||||
return (
|
||||
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
|
||||
<Row className="flex-wrap gap-4 sm:gap-6">
|
||||
{!isCpmm && (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
|
@ -440,29 +420,22 @@ export function BetsSummary(props: {
|
|||
<Col>
|
||||
<div className="text-sm text-gray-500">Payout</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(payout)}{' '}
|
||||
<ProfitBadge profitPercent={profitPercent} />
|
||||
{formatMoney(payout)} <ProfitBadge profitPercent={profitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
) : (
|
||||
<>
|
||||
{isBinary ? (
|
||||
) : isBinary ? (
|
||||
<>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if <YesLabel />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(yesWinnings)}
|
||||
</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>
|
||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||
</Col>
|
||||
</>
|
||||
) : isPseudoNumeric ? (
|
||||
|
@ -471,17 +444,13 @@ export function BetsSummary(props: {
|
|||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(yesWinnings)}
|
||||
</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>
|
||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||
</Col>
|
||||
</>
|
||||
) : (
|
||||
|
@ -492,8 +461,6 @@ export function BetsSummary(props: {
|
|||
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
||||
</Col>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
||||
<div className="whitespace-nowrap">
|
||||
|
@ -528,7 +495,6 @@ export function BetsSummary(props: {
|
|||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -689,7 +655,13 @@ function BetRow(props: {
|
|||
!isClosed &&
|
||||
!isSold &&
|
||||
!isAnte &&
|
||||
!isNumeric && <SellButton contract={contract} bet={bet} />}
|
||||
!isNumeric && (
|
||||
<SellButton
|
||||
contract={contract}
|
||||
bet={bet}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
|
||||
<td>
|
||||
|
@ -729,8 +701,12 @@ function BetRow(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function SellButton(props: { contract: Contract; bet: Bet }) {
|
||||
const { contract, bet } = props
|
||||
function SellButton(props: {
|
||||
contract: Contract
|
||||
bet: Bet
|
||||
unfilledBets: LimitBet[]
|
||||
}) {
|
||||
const { contract, bet, unfilledBets } = props
|
||||
const { outcome, shares, loanAmount } = bet
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
@ -740,8 +716,6 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
|||
outcome === 'NO' ? 'YES' : outcome
|
||||
)
|
||||
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
|
||||
const outcomeProb = getProbabilityAfterSale(
|
||||
contract,
|
||||
outcome,
|
||||
|
@ -787,8 +761,8 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ProfitBadge(props: { profitPercent: number }) {
|
||||
const { profitPercent } = props
|
||||
function ProfitBadge(props: { profitPercent: number; className?: string }) {
|
||||
const { profitPercent, className } = props
|
||||
if (!profitPercent) return null
|
||||
const colors =
|
||||
profitPercent > 0
|
||||
|
@ -799,7 +773,8 @@ function ProfitBadge(props: { profitPercent: number }) {
|
|||
<span
|
||||
className={clsx(
|
||||
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
|
||||
colors
|
||||
colors,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}
|
||||
|
|
|
@ -5,8 +5,16 @@ export function Button(props: {
|
|||
className?: string
|
||||
onClick?: () => void
|
||||
children?: ReactNode
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
color?:
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'yellow'
|
||||
| 'gray'
|
||||
| 'gradient'
|
||||
| 'gray-white'
|
||||
type?: 'button' | 'reset' | 'submit'
|
||||
disabled?: boolean
|
||||
}) {
|
||||
|
@ -21,11 +29,13 @@ export function Button(props: {
|
|||
} = props
|
||||
|
||||
const sizeClasses = {
|
||||
'2xs': 'px-2 py-1 text-xs',
|
||||
xs: 'px-2.5 py-1.5 text-sm',
|
||||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-4 py-2 text-base',
|
||||
xl: 'px-6 py-3 text-base',
|
||||
'2xl': 'px-6 py-3 text-xl',
|
||||
}[size]
|
||||
|
||||
return (
|
||||
|
@ -40,7 +50,10 @@ export function Button(props: {
|
|||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
||||
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200',
|
||||
color === 'gradient' &&
|
||||
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
|
||||
color === 'gray-white' &&
|
||||
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -15,8 +15,8 @@ export function PillButton(props: {
|
|||
className={clsx(
|
||||
'cursor-pointer select-none whitespace-nowrap rounded-full',
|
||||
selected
|
||||
? ['text-white', color ?? 'bg-gray-700']
|
||||
: 'bg-gray-100 hover:bg-gray-200',
|
||||
? ['text-white', color ?? 'bg-greyscale-6']
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
|
|
125
web/components/challenges/accept-challenge-button.tsx
Normal file
125
web/components/challenges/accept-challenge-button.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
import { Challenge } from 'common/challenge'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SignUpPrompt } from 'web/components/sign-up-prompt'
|
||||
import { acceptChallenge, APIError } from 'web/lib/firebase/api'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Title } from 'web/components/title'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Button } from 'web/components/button'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function AcceptChallengeButton(props: {
|
||||
user: User | null | undefined
|
||||
contract: Contract
|
||||
challenge: Challenge
|
||||
}) {
|
||||
const { user, challenge, contract } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { acceptorAmount, creatorAmount } = challenge
|
||||
|
||||
useEffect(() => {
|
||||
setErrorText('')
|
||||
}, [open])
|
||||
|
||||
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
|
||||
|
||||
const iAcceptChallenge = () => {
|
||||
setLoading(true)
|
||||
if (user.id === challenge.creatorId) {
|
||||
setErrorText('You cannot accept your own challenge!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
acceptChallenge({
|
||||
contractId: contract.id,
|
||||
challengeSlug: challenge.slug,
|
||||
outcomeType: contract.outcomeType,
|
||||
closeTime: contract.closeTime,
|
||||
})
|
||||
.then((r) => {
|
||||
console.log('accepted challenge. Result:', r)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
setLoading(false)
|
||||
if (e instanceof APIError) {
|
||||
setErrorText(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setErrorText('Error accepting challenge')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} setOpen={(newOpen) => setOpen(newOpen)} size={'sm'}>
|
||||
<Col className="gap-4 rounded-md bg-white px-8 py-6">
|
||||
<Col className={'gap-4'}>
|
||||
<div className={'flex flex-row justify-start '}>
|
||||
<Title text={"So you're in?"} className={'!my-2'} />
|
||||
</div>
|
||||
<Col className="w-full items-center justify-start gap-2">
|
||||
<Row className={'w-full justify-start gap-20'}>
|
||||
<span className={'min-w-[4rem] font-bold'}>Cost to you:</span>{' '}
|
||||
<span className={'text-red-500'}>
|
||||
{formatMoney(acceptorAmount)}
|
||||
</span>
|
||||
</Row>
|
||||
<Col className={'w-full items-center justify-start'}>
|
||||
<Row className={'w-full justify-start gap-10'}>
|
||||
<span className={'min-w-[4rem] font-bold'}>
|
||||
Potential payout:
|
||||
</span>{' '}
|
||||
<Row className={'items-center justify-center'}>
|
||||
<span className={'text-primary'}>
|
||||
{formatMoney(creatorAmount + acceptorAmount)}
|
||||
</span>
|
||||
</Row>
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
<Row className={'mt-4 justify-end gap-4'}>
|
||||
<Button
|
||||
color={'gray'}
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(false)}
|
||||
className={clsx('whitespace-nowrap')}
|
||||
>
|
||||
I'm out
|
||||
</Button>
|
||||
<Button
|
||||
color={'indigo'}
|
||||
disabled={loading}
|
||||
onClick={() => iAcceptChallenge()}
|
||||
className={clsx('min-w-[6rem] whitespace-nowrap')}
|
||||
>
|
||||
I'm in
|
||||
</Button>
|
||||
</Row>
|
||||
<Row>
|
||||
<span className={'text-error'}>{errorText}</span>
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
|
||||
{challenge.creatorId != user.id && (
|
||||
<Button
|
||||
color="gradient"
|
||||
size="2xl"
|
||||
onClick={() => setOpen(true)}
|
||||
className={clsx('whitespace-nowrap')}
|
||||
>
|
||||
Accept bet
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
251
web/components/challenges/create-challenge-modal.tsx
Normal file
251
web/components/challenges/create-challenge-modal.tsx
Normal file
|
@ -0,0 +1,251 @@
|
|||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Title } from '../title'
|
||||
import { User } from 'common/user'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Button } from '../button'
|
||||
import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||
import { BinaryContract } from 'common/contract'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { NoLabel, YesLabel } from '../outcome-label'
|
||||
import { QRCode } from '../qr-code'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { AmountInput } from '../amount-input'
|
||||
import { getProbability } from 'common/calculate'
|
||||
|
||||
type challengeInfo = {
|
||||
amount: number
|
||||
expiresTime: number | null
|
||||
message: string
|
||||
outcome: 'YES' | 'NO' | number
|
||||
acceptorAmount: number
|
||||
}
|
||||
|
||||
export function CreateChallengeModal(props: {
|
||||
user: User | null | undefined
|
||||
contract: BinaryContract
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { user, contract, isOpen, setOpen } = props
|
||||
const [challengeSlug, setChallengeSlug] = useState('')
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="gap-4 rounded-md bg-white px-8 py-6">
|
||||
{/*// add a sign up to challenge button?*/}
|
||||
{user && (
|
||||
<CreateChallengeForm
|
||||
user={user}
|
||||
contract={contract}
|
||||
onCreate={async (newChallenge) => {
|
||||
const challenge = await createChallenge({
|
||||
creator: user,
|
||||
creatorAmount: newChallenge.amount,
|
||||
expiresTime: newChallenge.expiresTime,
|
||||
message: newChallenge.message,
|
||||
acceptorAmount: newChallenge.acceptorAmount,
|
||||
outcome: newChallenge.outcome,
|
||||
contract: contract,
|
||||
})
|
||||
challenge && setChallengeSlug(getChallengeUrl(challenge))
|
||||
}}
|
||||
challengeSlug={challengeSlug}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateChallengeForm(props: {
|
||||
user: User
|
||||
contract: BinaryContract
|
||||
onCreate: (m: challengeInfo) => Promise<void>
|
||||
challengeSlug: string
|
||||
}) {
|
||||
const { user, onCreate, contract, challengeSlug } = props
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [finishedCreating, setFinishedCreating] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
||||
const defaultExpire = 'week'
|
||||
|
||||
const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
|
||||
|
||||
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
||||
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
||||
outcome: 'YES',
|
||||
amount: 100,
|
||||
acceptorAmount: 100,
|
||||
message: defaultMessage,
|
||||
})
|
||||
useEffect(() => {
|
||||
setError('')
|
||||
}, [challengeInfo])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!finishedCreating && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (user.balance < challengeInfo.amount) {
|
||||
setError('You do not have enough mana to create this challenge')
|
||||
return
|
||||
}
|
||||
setIsCreating(true)
|
||||
onCreate(challengeInfo).finally(() => setIsCreating(false))
|
||||
setFinishedCreating(true)
|
||||
}}
|
||||
>
|
||||
<Title className="!mt-2" text="Challenge bet " />
|
||||
|
||||
<div className="mb-8">
|
||||
Challenge a friend to bet on{' '}
|
||||
<span className="underline">{contract.question}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
||||
<div>You'll bet:</div>
|
||||
<Row
|
||||
className={
|
||||
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
|
||||
}
|
||||
>
|
||||
<AmountInput
|
||||
amount={challengeInfo.amount || undefined}
|
||||
onChange={(newAmount) =>
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
amount: newAmount ?? 0,
|
||||
acceptorAmount: editingAcceptorAmount
|
||||
? m.acceptorAmount
|
||||
: newAmount ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
error={undefined}
|
||||
label={'M$'}
|
||||
inputClassName="w-24"
|
||||
/>
|
||||
<span className={''}>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
|
||||
</Row>
|
||||
<Row className={'mt-3 max-w-xs justify-end'}>
|
||||
<Button
|
||||
color={'gray-white'}
|
||||
onClick={() =>
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<SwitchVerticalIcon className={'h-6 w-6'} />
|
||||
</Button>
|
||||
</Row>
|
||||
<Row className={'items-center'}>If they bet:</Row>
|
||||
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
|
||||
<div className={'w-32 sm:mr-1'}>
|
||||
<AmountInput
|
||||
amount={challengeInfo.acceptorAmount || undefined}
|
||||
onChange={(newAmount) => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
acceptorAmount: newAmount ?? 0,
|
||||
}
|
||||
})
|
||||
}}
|
||||
error={undefined}
|
||||
label={'M$'}
|
||||
inputClassName="w-24"
|
||||
/>
|
||||
</div>
|
||||
<span>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</div>
|
||||
<Button
|
||||
size="2xs"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingAcceptorAmount(true)
|
||||
|
||||
const p = getProbability(contract)
|
||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
||||
const { amount } = challengeInfo
|
||||
const acceptorAmount = Math.round(amount / prob - amount)
|
||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
||||
}}
|
||||
>
|
||||
Use market odds
|
||||
</Button>
|
||||
|
||||
<div className="mt-8">
|
||||
If the challenge is accepted, whoever is right will earn{' '}
|
||||
<span className="font-semibold">
|
||||
{formatMoney(
|
||||
challengeInfo.acceptorAmount + challengeInfo.amount || 0
|
||||
)}
|
||||
</span>{' '}
|
||||
in total.
|
||||
</div>
|
||||
|
||||
<Row className="mt-8 items-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color={'gradient'}
|
||||
size="xl"
|
||||
className={clsx(
|
||||
'whitespace-nowrap drop-shadow-md',
|
||||
isCreating ? 'disabled' : ''
|
||||
)}
|
||||
>
|
||||
Create challenge bet
|
||||
</Button>
|
||||
</Row>
|
||||
<Row className={'text-error'}>{error} </Row>
|
||||
</form>
|
||||
)}
|
||||
{finishedCreating && (
|
||||
<>
|
||||
<Title className="!my-0" text="Challenge Created!" />
|
||||
|
||||
<div>Share the challenge using the link.</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(challengeSlug)
|
||||
toast('Link copied to clipboard!')
|
||||
}}
|
||||
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
|
||||
>
|
||||
<LinkIcon className={'mr-2 h-5 w-5'} />
|
||||
Copy link
|
||||
</button>
|
||||
|
||||
<QRCode url={challengeSlug} className="self-center" />
|
||||
<Row className={'gap-1 text-gray-500'}>
|
||||
See your other
|
||||
<SiteLink className={'underline'} href={'/challenges'}>
|
||||
challenges
|
||||
</SiteLink>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -17,46 +17,45 @@ export function UserCommentsList(props: {
|
|||
contractsById: { [id: string]: Contract }
|
||||
}) {
|
||||
const { comments, contractsById } = props
|
||||
const commentsByContract = groupBy(comments, 'contractId')
|
||||
|
||||
const contractCommentPairs = Object.entries(commentsByContract)
|
||||
.map(
|
||||
([contractId, comments]) => [contractsById[contractId], comments] as const
|
||||
)
|
||||
.filter(([contract]) => contract)
|
||||
// we don't show comments in groups here atm, just comments on contracts
|
||||
const contractComments = comments.filter((c) => c.contractId)
|
||||
const commentsByContract = groupBy(contractComments, 'contractId')
|
||||
|
||||
return (
|
||||
<Col className={'bg-white'}>
|
||||
{contractCommentPairs.map(([contract, comments]) => (
|
||||
<div key={contract.id} className={'border-width-1 border-b p-5'}>
|
||||
<div className={'mb-2 text-sm text-indigo-700'}>
|
||||
<SiteLink href={contractPath(contract)}>
|
||||
{Object.entries(commentsByContract).map(([contractId, comments]) => {
|
||||
const contract = contractsById[contractId]
|
||||
return (
|
||||
<div key={contractId} className={'border-width-1 border-b p-5'}>
|
||||
<SiteLink
|
||||
className={'mb-2 block text-sm text-indigo-700'}
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
{contract.question}
|
||||
</SiteLink>
|
||||
</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>
|
||||
<ProfileComment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
className="relative flex items-start space-x-3 pb-6"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileComment(props: { comment: Comment }) {
|
||||
const { comment } = props
|
||||
function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||
const { comment, className } = props
|
||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||
// TODO: find and attach relevant bets by comment betId at some point
|
||||
return (
|
||||
<div>
|
||||
<Row className={'gap-4'}>
|
||||
<Row className={className}>
|
||||
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
<UserLink
|
||||
className="text-gray-500"
|
||||
|
@ -65,10 +64,8 @@ function ProfileComment(props: { comment: Comment }) {
|
|||
/>{' '}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</p>
|
||||
</div>
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,23 +1,14 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import algoliasearch from 'algoliasearch/lite'
|
||||
import {
|
||||
Configure,
|
||||
InstantSearch,
|
||||
SearchBox,
|
||||
SortBy,
|
||||
useInfiniteHits,
|
||||
useSortBy,
|
||||
} from 'react-instantsearch-hooks-web'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
|
||||
import {
|
||||
Sort,
|
||||
useInitialQueryAndSort,
|
||||
useUpdateQueryAndSort,
|
||||
} from '../hooks/use-sort-and-query-params'
|
||||
import { ContractsGrid } from './contract/contracts-list'
|
||||
ContractHighlightOptions,
|
||||
ContractsGrid,
|
||||
} from './contract/contracts-list'
|
||||
import { Row } from './layout/row'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -27,8 +18,9 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
|||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
|
||||
import { PillButton } from './buttons/pill-button'
|
||||
import { sortBy } from 'lodash'
|
||||
import { range, sortBy } from 'lodash'
|
||||
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||
import { Col } from './layout/col'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
|
@ -36,17 +28,17 @@ const searchClient = algoliasearch(
|
|||
)
|
||||
|
||||
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
||||
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
||||
|
||||
const sortIndexes = [
|
||||
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
|
||||
// { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
|
||||
{ label: 'Most popular', value: indexPrefix + 'contracts-score' },
|
||||
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
|
||||
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
|
||||
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
|
||||
{ label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' },
|
||||
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
|
||||
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
|
||||
const sortOptions = [
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
{ label: 'Trending', value: 'score' },
|
||||
{ label: 'Most traded', value: 'most-traded' },
|
||||
{ label: '24h volume', value: '24-hour-vol' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
{ label: 'Subsidy', value: 'liquidity' },
|
||||
{ label: 'Close date', value: 'close-date' },
|
||||
{ label: 'Resolve date', value: 'resolve-date' },
|
||||
]
|
||||
export const DEFAULT_SORT = 'score'
|
||||
|
||||
|
@ -64,11 +56,15 @@ export function ContractSearch(props: {
|
|||
excludeContractIds?: string[]
|
||||
groupSlug?: string
|
||||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
onContractClick?: (contract: Contract) => void
|
||||
showPlaceHolder?: boolean
|
||||
hideOrderSelector?: boolean
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideGroupLink?: boolean
|
||||
hideQuickBet?: boolean
|
||||
}
|
||||
}) {
|
||||
const {
|
||||
querySortOptions,
|
||||
|
@ -77,7 +73,8 @@ export function ContractSearch(props: {
|
|||
overrideGridClassName,
|
||||
hideOrderSelector,
|
||||
showPlaceHolder,
|
||||
hideQuickBet,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
} = props
|
||||
|
||||
const user = useUser()
|
||||
|
@ -100,31 +97,27 @@ export function ContractSearch(props: {
|
|||
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
|
||||
|
||||
const follows = useFollows(user?.id)
|
||||
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
||||
|
||||
const sort = sortIndexes
|
||||
.map(({ value }) => value)
|
||||
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
||||
? initialSort
|
||||
: querySortOptions?.defaultSort ?? DEFAULT_SORT
|
||||
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
|
||||
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
|
||||
defaultSort,
|
||||
shouldLoadFromStorage,
|
||||
})
|
||||
|
||||
const [filter, setFilter] = useState<filter>(
|
||||
querySortOptions?.defaultFilter ?? 'open'
|
||||
)
|
||||
const pillsEnabled = !additionalFilter
|
||||
const pillsEnabled = !additionalFilter && !query
|
||||
|
||||
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
||||
|
||||
const selectFilter = (pill: string | undefined) => () => {
|
||||
const selectPill = (pill: string | undefined) => () => {
|
||||
setPillFilter(pill)
|
||||
setPage(0)
|
||||
track('select search category', { category: pill ?? 'all' })
|
||||
}
|
||||
|
||||
const { filters, numericFilters } = useMemo(() => {
|
||||
let filters = [
|
||||
filter === 'open' ? 'isResolved:false' : '',
|
||||
filter === 'closed' ? 'isResolved:false' : '',
|
||||
filter === 'resolved' ? 'isResolved:true' : '',
|
||||
const additionalFilters = [
|
||||
additionalFilter?.creatorId
|
||||
? `creatorId:${additionalFilter.creatorId}`
|
||||
: '',
|
||||
|
@ -132,6 +125,14 @@ export function ContractSearch(props: {
|
|||
additionalFilter?.groupSlug
|
||||
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||
: '',
|
||||
]
|
||||
const facetFilters = query
|
||||
? additionalFilters
|
||||
: [
|
||||
...additionalFilters,
|
||||
filter === 'open' ? 'isResolved:false' : '',
|
||||
filter === 'closed' ? 'isResolved:false' : '',
|
||||
filter === 'resolved' ? 'isResolved:true' : '',
|
||||
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${pillFilter}`
|
||||
: '',
|
||||
|
@ -153,24 +154,97 @@ export function ContractSearch(props: {
|
|||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
].filter((f) => f)
|
||||
// Hack to make Algolia work.
|
||||
filters = ['', ...filters]
|
||||
|
||||
const numericFilters = [
|
||||
const numericFilters = query
|
||||
? []
|
||||
: [
|
||||
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
||||
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
||||
].filter((f) => f)
|
||||
|
||||
return { filters, numericFilters }
|
||||
}, [
|
||||
filter,
|
||||
Object.values(additionalFilter ?? {}).join(','),
|
||||
memberGroupSlugs.join(','),
|
||||
(follows ?? []).join(','),
|
||||
pillFilter,
|
||||
])
|
||||
|
||||
const indexName = `${indexPrefix}contracts-${sort}`
|
||||
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
|
||||
const searchIndex = useMemo(
|
||||
() => searchClient.initIndex(searchIndexName),
|
||||
[searchIndexName]
|
||||
)
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [numPages, setNumPages] = useState(1)
|
||||
const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>(
|
||||
{}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let wasMostRecentQuery = true
|
||||
const algoliaIndex = query ? searchIndex : index
|
||||
|
||||
algoliaIndex
|
||||
.search(query, {
|
||||
facetFilters,
|
||||
numericFilters,
|
||||
page,
|
||||
hitsPerPage: 20,
|
||||
})
|
||||
.then((results) => {
|
||||
if (!wasMostRecentQuery) return
|
||||
|
||||
if (page === 0) {
|
||||
setHitsByPage({
|
||||
[0]: results.hits as any as Contract[],
|
||||
})
|
||||
} else {
|
||||
setHitsByPage((hitsByPage) => ({
|
||||
...hitsByPage,
|
||||
[page]: results.hits,
|
||||
}))
|
||||
}
|
||||
setNumPages(results.nbPages)
|
||||
})
|
||||
return () => {
|
||||
wasMostRecentQuery = false
|
||||
}
|
||||
// Note numeric filters are unique based on current time, so can't compare
|
||||
// them by value.
|
||||
}, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter])
|
||||
|
||||
const loadMore = () => {
|
||||
if (page >= numPages - 1) return
|
||||
|
||||
const haveLoadedCurrentPage = hitsByPage[page]
|
||||
if (haveLoadedCurrentPage) setPage(page + 1)
|
||||
}
|
||||
|
||||
const hits = range(0, page + 1)
|
||||
.map((p) => hitsByPage[p] ?? [])
|
||||
.flat()
|
||||
|
||||
const contracts = hits.filter(
|
||||
(c) => !additionalFilter?.excludeContractIds?.includes(c.id)
|
||||
)
|
||||
|
||||
const showTime =
|
||||
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined
|
||||
|
||||
const updateQuery = (newQuery: string) => {
|
||||
setQuery(newQuery)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const selectFilter = (newFilter: filter) => {
|
||||
if (newFilter === filter) return
|
||||
setFilter(newFilter)
|
||||
setPage(0)
|
||||
trackCallback('select search filter', { filter: newFilter })
|
||||
}
|
||||
|
||||
const selectSort = (newSort: Sort) => {
|
||||
if (newSort === sort) return
|
||||
|
||||
setPage(0)
|
||||
setSort(newSort)
|
||||
track('select sort', { sort: newSort })
|
||||
}
|
||||
|
||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
return (
|
||||
|
@ -182,44 +256,40 @@ export function ContractSearch(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
||||
<Col>
|
||||
<Row className="gap-1 sm:gap-2">
|
||||
<SearchBox
|
||||
className="flex-1"
|
||||
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
|
||||
classNames={{
|
||||
form: 'before:top-6',
|
||||
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
|
||||
resetIcon: 'mt-2 hidden sm:flex',
|
||||
}}
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => updateQuery(e.target.value)}
|
||||
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
{/*// TODO track WHICH filter users are using*/}
|
||||
{!query && (
|
||||
<select
|
||||
className="!select !select-bordered"
|
||||
className="select select-bordered"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as filter)}
|
||||
onBlur={trackCallback('select search filter', { filter })}
|
||||
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
{!hideOrderSelector && (
|
||||
<SortBy
|
||||
items={sortIndexes}
|
||||
classNames={{
|
||||
select: '!select !select-bordered',
|
||||
}}
|
||||
onBlur={trackCallback('select search sort', { sort })}
|
||||
/>
|
||||
)}
|
||||
<Configure
|
||||
facetFilters={filters}
|
||||
numericFilters={numericFilters}
|
||||
// Page resets on filters change.
|
||||
page={0}
|
||||
/>
|
||||
{!hideOrderSelector && !query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={sort}
|
||||
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Spacer h={3} />
|
||||
|
@ -229,14 +299,14 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={'all'}
|
||||
selected={pillFilter === undefined}
|
||||
onSelect={selectFilter(undefined)}
|
||||
onSelect={selectPill(undefined)}
|
||||
>
|
||||
All
|
||||
</PillButton>
|
||||
<PillButton
|
||||
key={'personal'}
|
||||
selected={pillFilter === 'personal'}
|
||||
onSelect={selectFilter('personal')}
|
||||
onSelect={selectPill('personal')}
|
||||
>
|
||||
{user ? 'For you' : 'Featured'}
|
||||
</PillButton>
|
||||
|
@ -245,7 +315,7 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={'your-bets'}
|
||||
selected={pillFilter === 'your-bets'}
|
||||
onSelect={selectFilter('your-bets')}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your bets
|
||||
</PillButton>
|
||||
|
@ -256,7 +326,7 @@ export function ContractSearch(props: {
|
|||
<PillButton
|
||||
key={slug}
|
||||
selected={pillFilter === slug}
|
||||
onSelect={selectFilter(slug)}
|
||||
onSelect={selectPill(slug)}
|
||||
>
|
||||
{name}
|
||||
</PillButton>
|
||||
|
@ -272,95 +342,17 @@ export function ContractSearch(props: {
|
|||
memberGroupSlugs.length === 0 ? (
|
||||
<>You're not following anyone, nor in any of your own groups yet.</>
|
||||
) : (
|
||||
<ContractSearchInner
|
||||
querySortOptions={querySortOptions}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
hideQuickBet={hideQuickBet}
|
||||
excludeContractIds={additionalFilter?.excludeContractIds}
|
||||
/>
|
||||
)}
|
||||
</InstantSearch>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContractSearchInner(props: {
|
||||
querySortOptions?: {
|
||||
defaultSort: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
}
|
||||
onContractClick?: (contract: Contract) => void
|
||||
overrideGridClassName?: string
|
||||
hideQuickBet?: boolean
|
||||
excludeContractIds?: string[]
|
||||
}) {
|
||||
const {
|
||||
querySortOptions,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
hideQuickBet,
|
||||
excludeContractIds,
|
||||
} = props
|
||||
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
||||
|
||||
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
||||
shouldLoadFromStorage: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(initialQuery)
|
||||
}, [initialQuery])
|
||||
|
||||
const { currentRefinement: index } = useSortBy({
|
||||
items: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(query)
|
||||
}, [query])
|
||||
|
||||
const isFirstRender = useRef(true)
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const sort = index.split('contracts-')[1] as Sort
|
||||
if (sort) {
|
||||
setSort(sort)
|
||||
}
|
||||
}, [index])
|
||||
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setIsInitialLoad(false), 1000)
|
||||
return () => clearTimeout(id)
|
||||
}, [])
|
||||
|
||||
const { showMore, hits, isLastPage } = useInfiniteHits()
|
||||
let contracts = hits as any as Contract[]
|
||||
|
||||
if (isInitialLoad && contracts.length === 0) return <></>
|
||||
|
||||
const showTime = index.endsWith('close-date')
|
||||
? 'close-date'
|
||||
: index.endsWith('resolve-date')
|
||||
? 'resolve-date'
|
||||
: undefined
|
||||
|
||||
if (excludeContractIds)
|
||||
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
|
||||
|
||||
return (
|
||||
<ContractsGrid
|
||||
contracts={contracts}
|
||||
loadMore={showMore}
|
||||
hasMore={!isLastPage}
|
||||
loadMore={loadMore}
|
||||
hasMore={true}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
hideQuickBet={hideQuickBet}
|
||||
highlightOptions={highlightOptions}
|
||||
cardHideOptions={cardHideOptions}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
44
web/components/contract/contract-card-preview.tsx
Normal file
44
web/components/contract/contract-card-preview.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Contract } from 'common/contract'
|
||||
import { getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import { contractTextDetails } from 'web/components/contract/contract-details'
|
||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||
import { getProbability } from 'common/calculate'
|
||||
|
||||
export const getOpenGraphProps = (contract: Contract) => {
|
||||
const {
|
||||
resolution,
|
||||
question,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
outcomeType,
|
||||
creatorAvatarUrl,
|
||||
description: desc,
|
||||
} = contract
|
||||
const probPercent =
|
||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
||||
|
||||
const numericValue =
|
||||
outcomeType === 'PSEUDO_NUMERIC'
|
||||
? getFormattedMappedValue(contract)(getProbability(contract))
|
||||
: undefined
|
||||
|
||||
const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc)
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${stringDesc}`
|
||||
: probPercent
|
||||
? `${probPercent} chance. ${stringDesc}`
|
||||
: stringDesc
|
||||
|
||||
return {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
description,
|
||||
numericValue,
|
||||
}
|
||||
}
|
|
@ -5,9 +5,10 @@ import { formatLargeNumber, formatPercent } from 'common/util/format'
|
|||
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import {
|
||||
Contract,
|
||||
BinaryContract,
|
||||
Contract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
|
@ -24,12 +25,12 @@ import {
|
|||
} from 'common/calculate'
|
||||
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
||||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||
import { QuickBet, ProbBar, getColor } from './quick-bet'
|
||||
import { getColor, ProbBar, QuickBet } from './quick-bet'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
|
@ -38,8 +39,16 @@ export function ContractCard(props: {
|
|||
className?: string
|
||||
onClick?: () => void
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
}) {
|
||||
const { showHotVolume, showTime, className, onClick, hideQuickBet } = props
|
||||
const {
|
||||
showHotVolume,
|
||||
showTime,
|
||||
className,
|
||||
onClick,
|
||||
hideQuickBet,
|
||||
hideGroupLink,
|
||||
} = props
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const { question, outcomeType } = contract
|
||||
const { resolution } = contract
|
||||
|
@ -106,7 +115,8 @@ export function ContractCard(props: {
|
|||
{question}
|
||||
</p>
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' &&
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
(resolution ? (
|
||||
<FreeResponseOutcomeLabel
|
||||
contract={contract}
|
||||
|
@ -121,6 +131,7 @@ export function ContractCard(props: {
|
|||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
|
@ -148,7 +159,8 @@ export function ContractCard(props: {
|
|||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<FreeResponseResolutionOrChance
|
||||
className="self-end text-gray-600"
|
||||
contract={contract}
|
||||
|
@ -200,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
|
|||
}
|
||||
|
||||
function FreeResponseTopAnswer(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
}) {
|
||||
|
@ -218,7 +230,7 @@ function FreeResponseTopAnswer(props: {
|
|||
}
|
||||
|
||||
export function FreeResponseResolutionOrChance(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
}) {
|
||||
|
@ -305,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
|||
const { resolution, resolutionValue, resolutionProbability } = contract
|
||||
const textColor = `text-blue-400`
|
||||
|
||||
const value = resolution
|
||||
? resolutionValue
|
||||
? resolutionValue
|
||||
: getMappedValue(contract)(resolutionProbability ?? 0)
|
||||
: getMappedValue(contract)(getProbability(contract))
|
||||
|
||||
return (
|
||||
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
|
||||
{resolution ? (
|
||||
|
@ -314,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
|||
{resolution === 'CANCEL' ? (
|
||||
<CancelLabel />
|
||||
) : (
|
||||
<div className="text-blue-400">
|
||||
{resolutionValue
|
||||
? formatLargeNumber(resolutionValue)
|
||||
: formatNumericProbability(
|
||||
resolutionProbability ?? 0,
|
||||
contract
|
||||
)}
|
||||
<div
|
||||
className={clsx('tooltip', textColor)}
|
||||
data-tip={value.toFixed(2)}
|
||||
>
|
||||
{formatLargeNumber(value)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={clsx('text-3xl', textColor)}>
|
||||
{formatNumericProbability(getProbability(contract), contract)}
|
||||
<div
|
||||
className={clsx('tooltip text-3xl', textColor)}
|
||||
data-tip={value.toFixed(2)}
|
||||
>
|
||||
{formatLargeNumber(value)}
|
||||
</div>
|
||||
<div className={clsx('text-base', textColor)}>expected</div>
|
||||
</>
|
||||
|
|
|
@ -5,13 +5,13 @@ import {
|
|||
TrendingUpIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { UserLink } from '../user-page'
|
||||
import {
|
||||
Contract,
|
||||
contractMetrics,
|
||||
contractPath,
|
||||
updateContract,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import dayjs from 'dayjs'
|
||||
|
@ -24,11 +24,9 @@ import { Bet } from 'common/bet'
|
|||
import NewContractBadge from '../new-contract-badge'
|
||||
import { UserFollowButton } from '../follow-button'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
@ -42,8 +40,9 @@ export function MiscDetails(props: {
|
|||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showTime?: ShowTime
|
||||
hideGroupLink?: boolean
|
||||
}) {
|
||||
const { contract, showHotVolume, showTime } = props
|
||||
const { contract, showHotVolume, showTime, hideGroupLink } = props
|
||||
const {
|
||||
volume,
|
||||
volume24Hours,
|
||||
|
@ -80,7 +79,7 @@ export function MiscDetails(props: {
|
|||
<NewContractBadge />
|
||||
)}
|
||||
|
||||
{groupLinks && groupLinks.length > 0 && (
|
||||
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
|
||||
<SiteLink
|
||||
href={groupPath(groupLinks[0].slug)}
|
||||
className="text-sm text-gray-400"
|
||||
|
@ -146,6 +145,15 @@ export function ContractDetails(props: {
|
|||
const user = useUser()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const groupInfo = (
|
||||
<Row>
|
||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||
<span className={'line-clamp-1'}>
|
||||
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
||||
</span>
|
||||
</Row>
|
||||
)
|
||||
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
<Row className="items-center gap-2">
|
||||
|
@ -167,19 +175,18 @@ export function ContractDetails(props: {
|
|||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||
</Row>
|
||||
<Row>
|
||||
{disabled ? (
|
||||
groupInfo
|
||||
) : (
|
||||
<Button
|
||||
size={'xs'}
|
||||
className={'max-w-[200px]'}
|
||||
color={'gray-white'}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Row>
|
||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||
<span className={'line-clamp-1'}>
|
||||
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
||||
</span>
|
||||
</Row>
|
||||
{groupInfo}
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
<Modal open={open} setOpen={setOpen} size={'md'}>
|
||||
<Col
|
||||
|
@ -227,14 +234,6 @@ export function ContractDetails(props: {
|
|||
|
||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||
</Row>
|
||||
<ShareIconButton
|
||||
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||
user?.username && contract.creatorUsername !== user?.username
|
||||
? '?referrer=' + user?.username
|
||||
: ''
|
||||
}`}
|
||||
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||
/>
|
||||
|
||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||
</Row>
|
||||
|
|
|
@ -7,16 +7,12 @@ import { Bet } from 'common/bet'
|
|||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { contractPath, contractPool } from 'web/lib/firebase/contracts'
|
||||
import { contractPool } from 'web/lib/firebase/contracts'
|
||||
import { LiquidityPanel } from '../liquidity-panel'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { ShareEmbedButton } from '../share-embed-button'
|
||||
import { Title } from '../title'
|
||||
import { TweetButton } from '../tweet-button'
|
||||
import { InfoTooltip } from '../info-tooltip'
|
||||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -41,6 +37,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
? 'YES / NO'
|
||||
: outcomeType === 'FREE_RESPONSE'
|
||||
? 'Free response'
|
||||
: outcomeType === 'MULTIPLE_CHOICE'
|
||||
? 'Multiple choice'
|
||||
: 'Numeric'
|
||||
|
||||
return (
|
||||
|
@ -59,20 +57,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
<Col className="gap-4 rounded bg-white p-6">
|
||||
<Title className="!mt-0 !mb-0" text="Market info" />
|
||||
|
||||
<div>Share</div>
|
||||
|
||||
<Row className="justify-start gap-4">
|
||||
<TweetButton
|
||||
className="self-start"
|
||||
tweetText={getTweetText(contract)}
|
||||
/>
|
||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||
<DuplicateContractButton contract={contract} />
|
||||
</Row>
|
||||
<div />
|
||||
|
||||
<div>Stats</div>
|
||||
|
||||
<table className="table-compact table-zebra table w-full text-gray-500">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
@ -148,14 +132,3 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const getTweetText = (contract: Contract) => {
|
||||
const { question, resolution } = contract
|
||||
|
||||
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
||||
|
||||
const timeParam = `${Date.now()}`.substring(7)
|
||||
const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}`
|
||||
|
||||
return `${question}\n\n${url}${tweetDescription}`
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import React from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
|
@ -5,11 +8,9 @@ import { ContractProbGraph } from './contract-prob-graph'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Row } from '../layout/row'
|
||||
import { Linkify } from '../linkify'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import {
|
||||
FreeResponseResolutionOrChance,
|
||||
BinaryResolutionOrChance,
|
||||
FreeResponseResolutionOrChance,
|
||||
NumericResolutionOrExpectation,
|
||||
PseudoNumericResolutionOrExpectation,
|
||||
} from './contract-card'
|
||||
|
@ -19,8 +20,8 @@ import { AnswersGraph } from '../answers/answers-graph'
|
|||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { ContractDescription } from './contract-description'
|
||||
import { ContractDetails } from './contract-details'
|
||||
import { ShareMarket } from '../share-market'
|
||||
import { NumericGraph } from './numeric-graph'
|
||||
import { ShareRow } from './share-row'
|
||||
|
||||
export const ContractOverview = (props: {
|
||||
contract: Contract
|
||||
|
@ -32,6 +33,7 @@ export const ContractOverview = (props: {
|
|||
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
|
@ -85,7 +87,8 @@ export const ContractOverview = (props: {
|
|||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
</Row>
|
||||
) : (
|
||||
outcomeType === 'FREE_RESPONSE' &&
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
resolution && (
|
||||
<FreeResponseResolutionOrChance
|
||||
contract={contract}
|
||||
|
@ -110,12 +113,12 @@ export const ContractOverview = (props: {
|
|||
{(isBinary || isPseudoNumeric) && (
|
||||
<ContractProbGraph contract={contract} bets={bets} />
|
||||
)}{' '}
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<AnswersGraph contract={contract} bets={bets} />
|
||||
)}
|
||||
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
||||
{(contract.description || isCreator) && <Spacer h={6} />}
|
||||
{isCreator && <ShareMarket className="px-2" contract={contract} />}
|
||||
<ShareRow user={user} contract={contract} />
|
||||
<ContractDescription
|
||||
className="px-2"
|
||||
contract={contract}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Tabs } from '../layout/tabs'
|
|||
import { Col } from '../layout/col'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
|
||||
export function ContractTabs(props: {
|
||||
contract: Contract
|
||||
|
@ -18,10 +19,17 @@ export function ContractTabs(props: {
|
|||
comments: Comment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, bets, comments, tips, liquidityProvisions } = props
|
||||
const { contract, user, bets, tips, liquidityProvisions } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||
const visibleBets = bets.filter(
|
||||
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||
)
|
||||
|
||||
// Load comments here, so the badge count will be correct
|
||||
const updatedComments = useComments(contract.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
|
||||
const betActivity = (
|
||||
<ContractActivity
|
||||
|
@ -89,8 +97,12 @@ export function ContractTabs(props: {
|
|||
<Tabs
|
||||
currentPageForAnalytics={'contract'}
|
||||
tabs={[
|
||||
{ title: 'Comments', content: commentActivity },
|
||||
{ title: 'Bets', content: betActivity },
|
||||
{
|
||||
title: 'Comments',
|
||||
content: commentActivity,
|
||||
badge: `${comments.length}`,
|
||||
},
|
||||
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your bets', content: yourTrades }]),
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Contract } from '../../lib/firebase/contracts'
|
||||
import { User } from '../../lib/firebase/users'
|
||||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { ContractCard } from './contract-card'
|
||||
|
@ -9,6 +9,11 @@ import { useIsVisible } from 'web/hooks/use-is-visible'
|
|||
import { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type ContractHighlightOptions = {
|
||||
contractIds?: string[]
|
||||
highlightClassName?: string
|
||||
}
|
||||
|
||||
export function ContractsGrid(props: {
|
||||
contracts: Contract[]
|
||||
loadMore: () => void
|
||||
|
@ -16,7 +21,11 @@ export function ContractsGrid(props: {
|
|||
showTime?: ShowTime
|
||||
onContractClick?: (contract: Contract) => void
|
||||
overrideGridClassName?: string
|
||||
cardHideOptions?: {
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
}) {
|
||||
const {
|
||||
contracts,
|
||||
|
@ -25,9 +34,12 @@ export function ContractsGrid(props: {
|
|||
loadMore,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
hideQuickBet,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
} = props
|
||||
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
||||
|
||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||
const isBottomVisible = useIsVisible(elem)
|
||||
|
||||
|
@ -66,6 +78,12 @@ export function ContractsGrid(props: {
|
|||
onContractClick ? () => onContractClick(contract) : undefined
|
||||
}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
className={
|
||||
contractIds?.includes(contract.id)
|
||||
? highlightClassName
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
77
web/components/contract/share-modal.tsx
Normal file
77
web/components/contract/share-modal.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { LinkIcon } from '@heroicons/react/outline'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { ShareEmbedButton } from '../share-embed-button'
|
||||
import { Title } from '../title'
|
||||
import { TweetButton } from '../tweet-button'
|
||||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
import { Button } from '../button'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { User } from 'common/user'
|
||||
|
||||
export function ShareModal(props: {
|
||||
contract: Contract
|
||||
user: User | undefined | null
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { contract, user, isOpen, setOpen } = props
|
||||
|
||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||
|
||||
const copyPayload = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||
user?.username && contract.creatorUsername !== user?.username
|
||||
? '?referrer=' + user?.username
|
||||
: ''
|
||||
}`
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="gap-4 rounded bg-white p-4">
|
||||
<Title className="!mt-0 mb-2" text="Share this market" />
|
||||
|
||||
<Button
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className={'mb-2 flex max-w-xs self-center'}
|
||||
onClick={() => {
|
||||
copyToClipboard(copyPayload)
|
||||
track('copy share link')
|
||||
toast.success('Link copied!', {
|
||||
icon: linkIcon,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{linkIcon} Copy link
|
||||
</Button>
|
||||
|
||||
<Row className="justify-start gap-4 self-center">
|
||||
<TweetButton
|
||||
className="self-start"
|
||||
tweetText={getTweetText(contract)}
|
||||
/>
|
||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||
<DuplicateContractButton contract={contract} />
|
||||
</Row>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getTweetText = (contract: Contract) => {
|
||||
const { question, resolution } = contract
|
||||
|
||||
const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : ''
|
||||
|
||||
const timeParam = `${Date.now()}`.substring(7)
|
||||
const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}`
|
||||
|
||||
return `${question}\n\n${url}${tweetDescription}`
|
||||
}
|
59
web/components/contract/share-row.tsx
Normal file
59
web/components/contract/share-row.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import clsx from 'clsx'
|
||||
import { ShareIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'web/components/button'
|
||||
import { CreateChallengeModal } from '../challenges/create-challenge-modal'
|
||||
import { User } from 'common/user'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { ShareModal } from './share-modal'
|
||||
|
||||
export function ShareRow(props: {
|
||||
contract: Contract
|
||||
user: User | undefined | null
|
||||
}) {
|
||||
const { user, contract } = props
|
||||
const { outcomeType, resolution } = contract
|
||||
|
||||
const showChallenge =
|
||||
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Row className="mt-2">
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
className={'flex'}
|
||||
onClick={() => {
|
||||
setShareOpen(true)
|
||||
}}
|
||||
>
|
||||
<ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
|
||||
Share
|
||||
<ShareModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
contract={contract}
|
||||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showChallenge && (
|
||||
<Button size="lg" color="gray-white" onClick={() => setIsOpen(true)}>
|
||||
⚔️ Challenge
|
||||
<CreateChallengeModal
|
||||
isOpen={isOpen}
|
||||
setOpen={setIsOpen}
|
||||
user={user}
|
||||
contract={contract}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
|
|||
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||
}
|
||||
|
||||
if (contract.groupLinks && contract.groupLinks.length > 0) {
|
||||
params.groupId = contract.groupLinks[0].groupId
|
||||
}
|
||||
|
||||
return (
|
||||
`/create?` +
|
||||
Object.entries(params)
|
||||
|
|
|
@ -2,7 +2,6 @@ import React, { Fragment } from 'react'
|
|||
import { LinkIcon } from '@heroicons/react/outline'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
@ -14,6 +13,8 @@ export function CopyLinkButton(props: {
|
|||
tracking?: string
|
||||
buttonClassName?: string
|
||||
toastClassName?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
label?: string
|
||||
}) {
|
||||
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Mention } from '@tiptap/extension-mention'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Linkify } from './linkify'
|
||||
|
@ -19,6 +20,9 @@ import { useMutation } from 'react-query'
|
|||
import { exhibitExts } from 'common/util/parse'
|
||||
import { FileUploadButton } from './file-upload-button'
|
||||
import { linkClass } from './site-link'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||
import { DisplayMention } from './editor/mention'
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
|
||||
import { Modal } from './layout/modal'
|
||||
|
@ -40,12 +44,15 @@ export function useTextEditor(props: {
|
|||
}) {
|
||||
const { placeholder, max, defaultValue = '', disabled } = props
|
||||
|
||||
const users = useUsers()
|
||||
|
||||
const editorClass = clsx(
|
||||
proseClass,
|
||||
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
const editor = useEditor(
|
||||
{
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
|
@ -63,10 +70,15 @@ export function useTextEditor(props: {
|
|||
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||
},
|
||||
}),
|
||||
DisplayMention.configure({
|
||||
suggestion: mentionSuggestion(users),
|
||||
}),
|
||||
Iframe,
|
||||
],
|
||||
content: defaultValue,
|
||||
})
|
||||
},
|
||||
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
|
||||
)
|
||||
|
||||
const upload = useUploadMutation(editor)
|
||||
|
||||
|
@ -261,7 +273,11 @@ function RichContent(props: { content: JSONContent | string }) {
|
|||
const { content } = props
|
||||
const editor = useEditor({
|
||||
editorProps: { attributes: { class: proseClass } },
|
||||
extensions: exhibitExts,
|
||||
extensions: [
|
||||
// replace tiptap's Mention with ours, to add style and link
|
||||
...exhibitExts.filter((ex) => ex.name !== Mention.name),
|
||||
DisplayMention,
|
||||
],
|
||||
content,
|
||||
editable: false,
|
||||
})
|
||||
|
|
62
web/components/editor/mention-list.tsx
Normal file
62
web/components/editor/mention-list.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { SuggestionProps } from '@tiptap/suggestion'
|
||||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { Avatar } from '../avatar'
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||
export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
|
||||
const { items: users, command } = props
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
useEffect(() => setSelectedIndex(0), [users])
|
||||
|
||||
const submitUser = (index: number) => {
|
||||
const user = users[index]
|
||||
if (user) command({ id: user.id, label: user.username } as any)
|
||||
}
|
||||
|
||||
const onUp = () =>
|
||||
setSelectedIndex((i) => (i + users.length - 1) % users.length)
|
||||
const onDown = () => setSelectedIndex((i) => (i + 1) % users.length)
|
||||
const onEnter = () => submitUser(selectedIndex)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: any) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
onUp()
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
onDown()
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
onEnter()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{!users.length ? (
|
||||
<span className="m-1 whitespace-nowrap">No results...</span>
|
||||
) : (
|
||||
users.map((user, i) => (
|
||||
<button
|
||||
className={clsx(
|
||||
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
|
||||
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||
)}
|
||||
onClick={() => submitUser(i)}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||
{user.username}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
72
web/components/editor/mention-suggestion.ts
Normal file
72
web/components/editor/mention-suggestion.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { orderBy } from 'lodash'
|
||||
import tippy from 'tippy.js'
|
||||
import { MentionList } from './mention-list'
|
||||
|
||||
type Suggestion = MentionOptions['suggestion']
|
||||
|
||||
const beginsWith = (text: string, query: string) =>
|
||||
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||
export const mentionSuggestion = (users: User[]): Suggestion => ({
|
||||
items: ({ query }) =>
|
||||
orderBy(
|
||||
users.filter((u) => searchInAny(query, u.username, u.name)),
|
||||
[
|
||||
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
|
||||
'followerCountCached',
|
||||
],
|
||||
['desc', 'desc']
|
||||
).slice(0, 5),
|
||||
render: () => {
|
||||
let component: ReactRenderer
|
||||
let popup: ReturnType<typeof tippy>
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
})
|
||||
},
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
return true
|
||||
}
|
||||
return (component.ref as any)?.onKeyDown(props)
|
||||
},
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
29
web/components/editor/mention.tsx
Normal file
29
web/components/editor/mention.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import Mention from '@tiptap/extension-mention'
|
||||
import {
|
||||
mergeAttributes,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import { Linkify } from '../linkify'
|
||||
|
||||
const name = 'mention-component'
|
||||
|
||||
const MentionComponent = (props: any) => {
|
||||
return (
|
||||
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
|
||||
<Linkify text={'@' + props.node.attrs.label} />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mention extension that renders React. See:
|
||||
* https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
|
||||
* https://tiptap.dev/guide/node-views/react#render-a-react-component
|
||||
*/
|
||||
export const DisplayMention = Mention.extend({
|
||||
parseHTML: () => [{ tag: name }],
|
||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
|
||||
})
|
|
@ -2,7 +2,6 @@ import { Contract } from 'web/lib/firebase/contracts'
|
|||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { Bet } from 'common/bet'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
import { getSpecificContractActivityItems } from './activity-items'
|
||||
import { FeedItems } from './feed-items'
|
||||
import { User } from 'common/user'
|
||||
|
@ -26,11 +25,11 @@ export function ContractActivity(props: {
|
|||
props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
const updatedComments = useComments(contract.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
|
||||
const updatedBets = useBets(contract.id)
|
||||
const comments = props.comments
|
||||
const updatedBets = useBets(contract.id, {
|
||||
filterChallenges: false,
|
||||
filterRedemptions: true,
|
||||
})
|
||||
const bets = (updatedBets ?? props.bets).filter(
|
||||
(bet) => !bet.isRedemption && bet.amount !== 0
|
||||
)
|
||||
|
@ -50,6 +49,7 @@ export function ContractActivity(props: {
|
|||
items={items}
|
||||
className={className}
|
||||
betRowClassName={betRowClassName}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,11 +10,14 @@ import { UsersIcon } from '@heroicons/react/solid'
|
|||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
import React, { Fragment } from 'react'
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
|
||||
import { JoinSpans } from 'web/components/join-spans'
|
||||
import { UserLink } from '../user-page'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||
import { Challenge } from 'common/challenge'
|
||||
|
||||
export function FeedBet(props: {
|
||||
contract: Contract
|
||||
|
@ -79,7 +82,15 @@ export function BetStatusText(props: {
|
|||
const { outcomeType } = contract
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
const isFreeResponse = outcomeType === 'FREE_RESPONSE'
|
||||
const { amount, outcome, createdTime } = bet
|
||||
const { amount, outcome, createdTime, challengeSlug } = bet
|
||||
const [challenge, setChallenge] = React.useState<Challenge>()
|
||||
useEffect(() => {
|
||||
if (challengeSlug) {
|
||||
getChallenge(challengeSlug, contract.id).then((c) => {
|
||||
setChallenge(c)
|
||||
})
|
||||
}
|
||||
}, [challengeSlug, contract.id])
|
||||
|
||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||
const outOfTotalAmount =
|
||||
|
@ -133,6 +144,14 @@ export function BetStatusText(props: {
|
|||
{fromProb === toProb
|
||||
? `at ${fromProb}`
|
||||
: `from ${fromProb} to ${toProb}`}
|
||||
{challengeSlug && (
|
||||
<SiteLink
|
||||
href={challenge ? getChallengeUrl(challenge) : ''}
|
||||
className={'mx-1'}
|
||||
>
|
||||
[challenge]
|
||||
</SiteLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
|
|
|
@ -36,14 +36,18 @@ import {
|
|||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { CPMMBinaryContract, NumericContract } from 'common/contract'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
import { SignUpPrompt } from '../sign-up-prompt'
|
||||
import { User } from 'common/user'
|
||||
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
||||
|
||||
export function FeedItems(props: {
|
||||
contract: Contract
|
||||
items: ActivityItem[]
|
||||
className?: string
|
||||
betRowClassName?: string
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { contract, items, className, betRowClassName } = props
|
||||
const { contract, items, className, betRowClassName, user } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||
|
@ -67,11 +71,20 @@ export function FeedItems(props: {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{outcomeType === 'BINARY' && tradingAllowed(contract) && (
|
||||
|
||||
{!user ? (
|
||||
<Col className="mt-4 max-w-sm items-center xl:hidden">
|
||||
<SignUpPrompt />
|
||||
<PlayMoneyDisclaimer />
|
||||
</Col>
|
||||
) : (
|
||||
outcomeType === 'BINARY' &&
|
||||
tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
contract={contract as CPMMBinaryContract}
|
||||
className={clsx('mb-2', betRowClassName)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -77,8 +77,7 @@ export function LiquidityStatusText(props: {
|
|||
) : (
|
||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
||||
)}{' '}
|
||||
{bought} {money}
|
||||
{' of liquidity'}
|
||||
{bought} a subsidy of {money}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Button } from 'web/components/button'
|
|||
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||
import {
|
||||
addContractToGroup,
|
||||
canModifyGroupContracts,
|
||||
removeContractFromGroup,
|
||||
} from 'web/lib/firebase/groups'
|
||||
import { User } from 'common/user'
|
||||
|
@ -57,11 +58,11 @@ export function ContractGroupsList(props: {
|
|||
<Row className="line-clamp-1 items-center gap-2">
|
||||
<GroupLinkItem group={group} />
|
||||
</Row>
|
||||
{user && group.memberIds.includes(user.id) && (
|
||||
{user && canModifyGroupContracts(group, user.id) && (
|
||||
<Button
|
||||
color={'gray-white'}
|
||||
size={'xs'}
|
||||
onClick={() => removeContractFromGroup(group, contract)}
|
||||
onClick={() => removeContractFromGroup(group, contract, user.id)}
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
|
|
|
@ -46,7 +46,7 @@ export function CreateGroupButton(props: {
|
|||
const newGroup = {
|
||||
name: groupName,
|
||||
memberIds: memberUsers.map((user) => user.id),
|
||||
anyoneCanJoin: false,
|
||||
anyoneCanJoin: true,
|
||||
}
|
||||
const result = await createGroup(newGroup).catch((e) => {
|
||||
const errorDetails = e.details[0]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { User } from 'common/user'
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import React, { useEffect, memo, useState, useMemo } from 'react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Group } from 'common/group'
|
||||
|
@ -23,6 +23,9 @@ import { Tipper } from 'web/components/tipper'
|
|||
import { sum } from 'lodash'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||
|
||||
export function GroupChat(props: {
|
||||
messages: Comment[]
|
||||
|
@ -44,6 +47,13 @@ export function GroupChat(props: {
|
|||
const router = useRouter()
|
||||
const isMember = user && group.memberIds.includes(user?.id)
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
// Subtract bottom bar when it's showing (less than lg screen)
|
||||
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
|
||||
const remainingHeight =
|
||||
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
|
||||
|
||||
useMemo(() => {
|
||||
// Group messages with createdTime within 2 minutes of each other.
|
||||
const tempMessages = []
|
||||
|
@ -70,9 +80,10 @@ export function GroupChat(props: {
|
|||
}, [scrollToMessageRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSubmitting)
|
||||
scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 })
|
||||
}, [scrollToBottomRef, isSubmitting])
|
||||
if (scrollToBottomRef)
|
||||
scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 })
|
||||
// Must also listen to groupedMessages as they update the height of the messaging window
|
||||
}, [scrollToBottomRef, groupedMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const elementInUrl = router.asPath.split('#')[1]
|
||||
|
@ -81,6 +92,11 @@ export function GroupChat(props: {
|
|||
}
|
||||
}, [messages, router.asPath])
|
||||
|
||||
useEffect(() => {
|
||||
// is mobile?
|
||||
if (inputRef && width && width > 720) inputRef.focus()
|
||||
}, [inputRef, width])
|
||||
|
||||
function onReplyClick(comment: Comment) {
|
||||
setReplyToUsername(comment.userUsername)
|
||||
}
|
||||
|
@ -98,18 +114,6 @@ export function GroupChat(props: {
|
|||
setReplyToUsername('')
|
||||
inputRef?.focus()
|
||||
}
|
||||
function focusInput() {
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
// Subtract bottom bar when it's showing (less than lg screen)
|
||||
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
|
||||
const remainingHeight =
|
||||
(height ?? window.innerHeight) -
|
||||
(containerRef?.offsetTop ?? 0) -
|
||||
bottomBarHeight
|
||||
|
||||
return (
|
||||
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
|
||||
|
@ -140,7 +144,7 @@ export function GroupChat(props: {
|
|||
No messages yet. Why not{isMember ? ` ` : ' join and '}
|
||||
<button
|
||||
className={'cursor-pointer font-bold text-gray-700'}
|
||||
onClick={() => focusInput()}
|
||||
onClick={() => inputRef?.focus()}
|
||||
>
|
||||
add one?
|
||||
</button>
|
||||
|
@ -175,6 +179,117 @@ export function GroupChat(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function GroupChatInBubble(props: {
|
||||
messages: Comment[]
|
||||
user: User | null | undefined
|
||||
privateUser: PrivateUser | null | undefined
|
||||
group: Group
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { messages, user, group, tips, privateUser } = props
|
||||
const [shouldShowChat, setShouldShowChat] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const groupsWithChatEmphasis = [
|
||||
'welcome',
|
||||
'bugs',
|
||||
'manifold-features-25bad7c7792e',
|
||||
'updates',
|
||||
]
|
||||
if (
|
||||
router.asPath.includes('/chat') ||
|
||||
groupsWithChatEmphasis.includes(
|
||||
router.asPath.split('/group/')[1].split('/')[0]
|
||||
)
|
||||
) {
|
||||
setShouldShowChat(true)
|
||||
}
|
||||
// Leave chat open between groups if user is using chat?
|
||||
else {
|
||||
setShouldShowChat(false)
|
||||
}
|
||||
}, [router.asPath])
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx(
|
||||
'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
|
||||
shouldShowChat ? 'p-2m z-10 h-screen bg-white' : ''
|
||||
)}
|
||||
>
|
||||
{shouldShowChat && (
|
||||
<GroupChat messages={messages} user={user} group={group} tips={tips} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' +
|
||||
' border-transparent p-3 text-white shadow-sm lg:p-4' +
|
||||
' focus:outline-none focus:ring-2 focus:ring-offset-2 ' +
|
||||
' bottom-[70px] ',
|
||||
shouldShowChat
|
||||
? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto '
|
||||
: ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500'
|
||||
)}
|
||||
onClick={() => {
|
||||
// router.push('/chat')
|
||||
setShouldShowChat(!shouldShowChat)
|
||||
track('mobile group chat button')
|
||||
}}
|
||||
>
|
||||
{!shouldShowChat ? (
|
||||
<UsersIcon className="h-10 w-10" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
|
||||
)}
|
||||
{privateUser && (
|
||||
<GroupChatNotificationsIcon
|
||||
group={group}
|
||||
privateUser={privateUser}
|
||||
shouldSetAsSeen={shouldShowChat}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupChatNotificationsIcon(props: {
|
||||
group: Group
|
||||
privateUser: PrivateUser
|
||||
shouldSetAsSeen: boolean
|
||||
}) {
|
||||
const { privateUser, group, shouldSetAsSeen } = props
|
||||
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
|
||||
privateUser,
|
||||
{
|
||||
customHref: `/group/${group.slug}`,
|
||||
}
|
||||
)
|
||||
useEffect(() => {
|
||||
preferredNotificationsForThisGroup.forEach((notification) => {
|
||||
if (
|
||||
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
|
||||
// old style chat notif that simply ended with the group slug
|
||||
notification.isSeenOnHref?.endsWith(group.slug)
|
||||
) {
|
||||
setNotificationsAsSeen([notification])
|
||||
}
|
||||
})
|
||||
}, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
preferredNotificationsForThisGroup.length > 0 && !shouldSetAsSeen
|
||||
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
|
||||
: 'hidden'
|
||||
}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupMessage = memo(function GroupMessage_(props: {
|
||||
user: User | null | undefined
|
||||
comment: Comment
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
import clsx from 'clsx'
|
||||
import { CreateGroupButton } from 'web/components/groups/create-group-button'
|
||||
import { useState } from 'react'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
|
||||
|
@ -27,10 +27,15 @@ export function GroupSelector(props: {
|
|||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||
const { showSelector, showLabel, ignoreGroupIds } = options
|
||||
const [query, setQuery] = useState('')
|
||||
const memberGroups = (useMemberGroups(creator?.id) ?? []).filter(
|
||||
(group) => !ignoreGroupIds?.includes(group.id)
|
||||
const openGroups = useOpenGroups()
|
||||
const availableGroups = openGroups
|
||||
.concat(
|
||||
(useMemberGroups(creator?.id) ?? []).filter(
|
||||
(g) => !openGroups.map((og) => og.id).includes(g.id)
|
||||
)
|
||||
const filteredGroups = memberGroups.filter((group) =>
|
||||
)
|
||||
.filter((group) => !ignoreGroupIds?.includes(group.id))
|
||||
const filteredGroups = availableGroups.filter((group) =>
|
||||
searchInAny(query, group.name)
|
||||
)
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ export function InfoBox(props: {
|
|||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-black">{title}</h3>
|
||||
<div className="mt-2 text-sm text-black">
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -26,7 +26,7 @@ export function Modal(props: {
|
|||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
onClose={setOpen}
|
||||
>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
@ -57,7 +57,7 @@ export function Modal(props: {
|
|||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'my-8 mx-6 inline-block w-full transform overflow-hidden text-left align-bottom transition-all sm:align-middle',
|
||||
'my-8 mx-6 inline-block w-full transform overflow-hidden text-left align-bottom transition-all sm:self-center sm:align-middle',
|
||||
sizeClass,
|
||||
className
|
||||
)}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user