diff --git a/common/answer.ts b/common/answer.ts index 9dcc3828..9616eed6 100644 --- a/common/answer.ts +++ b/common/answer.ts @@ -1,3 +1,4 @@ +import { JSONContent } from '@tiptap/core' import { User } from './user' export type Answer = { @@ -12,6 +13,7 @@ export type Answer = { avatarUrl?: string text: string + description?: string | JSONContent } export const getNoneAnswer = (contractId: string, creator: User) => { diff --git a/common/calculate.ts b/common/calculate.ts index 758fc3cd..8284a19f 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -24,6 +24,7 @@ import { FreeResponseContract, PseudoNumericContract, MultipleChoiceContract, + AnswerContract, } from './contract' import { floatingEqual } from './util/math' @@ -237,9 +238,7 @@ export function getContractBetNullMetrics() { } } -export function getTopAnswer( - contract: FreeResponseContract | MultipleChoiceContract -) { +export function getTopAnswer(contract: AnswerContract) { const { answers } = contract const top = maxBy( answers?.map((answer) => ({ diff --git a/common/contract.ts b/common/contract.ts index c414a332..28096049 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -10,6 +10,7 @@ export type AnyOutcomeType = | PseudoNumeric | FreeResponse | Numeric + | Bounty export type AnyContractType = | (CPMM & Binary) | (CPMM & PseudoNumeric) @@ -17,6 +18,7 @@ export type AnyContractType = | (DPM & FreeResponse) | (DPM & Numeric) | (DPM & MultipleChoice) + | (DPM & Bounty) export type Contract = { id: string @@ -64,10 +66,15 @@ export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse export type MultipleChoiceContract = Contract & MultipleChoice +export type BountyContract = Contract & Bounty export type DPMContract = Contract & DPM export type CPMMContract = Contract & CPMM export type DPMBinaryContract = BinaryContract & DPM export type CPMMBinaryContract = BinaryContract & CPMM +export type AnswerContract = + | FreeResponseContract + | MultipleChoiceContract + | BountyContract export type DPM = { mechanism: 'dpm-2' @@ -127,6 +134,21 @@ export type Numeric = { resolutionValue?: number } +export type Bounty = { + outcomeType: 'BOUNTY' + // One answer for each submission + answers: Answer[] + // Resolution = which answer id the bounty went to + resolution?: string | 'MKT' | 'CANCEL' + resolutions?: { [outcome: string]: number } // Used for MKT resolution + + // Amount contributed to each bounty, and by who + prizes: { + [giverId: string]: number + } + totalPrizes: number +} + export type outcomeType = AnyOutcomeType['outcomeType'] export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const @@ -136,6 +158,7 @@ export const OUTCOME_TYPES = [ 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC', + 'BOUNTY', ] as const export const MAX_QUESTION_LENGTH = 480 diff --git a/common/new-bet.ts b/common/new-bet.ts index 576f35f8..c1cc386f 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -15,6 +15,7 @@ import { getCpmmProbability, } from './calculate-cpmm' import { + AnswerContract, CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, @@ -323,7 +324,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract | MultipleChoiceContract, + contract: AnswerContract, loanAmount: number ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/new-contract.ts b/common/new-contract.ts index ad7dc5a2..727816ff 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -1,6 +1,7 @@ import { range } from 'lodash' import { Binary, + Bounty, Contract, CPMM, DPM, @@ -45,16 +46,22 @@ export function getNewContract( ) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - const propsByOutcomeType = - outcomeType === 'BINARY' - ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) - : outcomeType === 'PSEUDO_NUMERIC' - ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) - : outcomeType === 'NUMERIC' - ? getNumericProps(ante, bucketCount, min, max) - : outcomeType === 'MULTIPLE_CHOICE' - ? getMultipleChoiceProps(ante, answers) - : getFreeAnswerProps(ante) + const PROPS = { + BINARY: getBinaryCpmmProps(initialProb, ante), // getBinaryDpmProps(initialProb, ante) + PSEUDO_NUMERIC: getPseudoNumericCpmmProps( + initialProb, + ante, + min, + max, + isLogScale + ), + FREE_RESPONSE: getFreeAnswerProps(ante), + MULTIPLE_CHOICE: getMultipleChoiceProps(ante, answers), + NUMERIC: getNumericProps(ante, bucketCount, min, max), + BOUNTY: getBountyProps(ante, creator), + } + + const propsByOutcomeType = PROPS[outcomeType] || PROPS['FREE_RESPONSE'] const contract: Contract = removeUndefinedProps({ id, @@ -157,6 +164,18 @@ const getFreeAnswerProps = (ante: number) => { return system } +function getBountyProps(ante: number, creator: User) { + const system: DPM & Bounty = { + ...getFreeAnswerProps(ante), + outcomeType: 'BOUNTY', + prizes: { + [creator.id]: ante, + }, + totalPrizes: ante, + } + return system +} + const getMultipleChoiceProps = (ante: number, answers: string[]) => { const numAnswers = answers.length const betAnte = ante / numAnswers diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index 7d4a0185..75cb5156 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -3,6 +3,7 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' import { deductDpmFees, getDpmProbability } from './calculate-dpm' import { + AnswerContract, DPMContract, FreeResponseContract, MultipleChoiceContract, @@ -184,7 +185,7 @@ export const getDpmMktPayouts = ( export const getPayoutsMultiOutcome = ( resolutions: { [outcome: string]: number }, - contract: FreeResponseContract | MultipleChoiceContract, + contract: AnswerContract, bets: Bet[] ) => { const poolTotal = sum(Object.values(contract.pool)) diff --git a/firebase.json b/firebase.json index 25f9b61f..9afae810 100644 --- a/firebase.json +++ b/firebase.json @@ -7,5 +7,11 @@ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "emulators": { + "firestore": { + "port": 8080, + "host": "127.0.0.1" + } } } diff --git a/functions/src/add-bounty.ts b/functions/src/add-bounty.ts new file mode 100644 index 00000000..5c9e2400 --- /dev/null +++ b/functions/src/add-bounty.ts @@ -0,0 +1,55 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { APIError, newEndpoint, validate } from './api' + +const firestore = admin.firestore() + +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) + +export const addbounty = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) + + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + if (contract.outcomeType !== 'BOUNTY') + throw new APIError(400, "Can't add bounties to non-BOUNTY contracts") + + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Contract is closed') + + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') + + // TODO: Capture with txns? + transaction.update(userDoc, { + balance: user.balance - amount, + totalDeposits: user.totalDeposits - amount, + }) + + const existingPrize = contract.prizes[user.id] ?? 0 + + transaction.update(contractDoc, { + prizes: { + ...contract.prizes, + [user.id]: existingPrize + amount, + }, + totalPrizes: contract.totalPrizes + amount, + }) + + return { status: 'success' } + }) +}) diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 2abaf44d..cb936d9f 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -36,7 +36,10 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'FREE_RESPONSE') + if ( + contract.outcomeType !== 'FREE_RESPONSE' && + contract.outcomeType !== 'BOUNTY' + ) throw new APIError(400, 'Requires a free response contract') const { closeTime, volume } = contract diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index cef0dd48..3168ae0e 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -251,7 +251,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { ) ) await contractRef.update({ answers: answerObjects }) - } else if (outcomeType === 'FREE_RESPONSE') { + } else if (outcomeType === 'FREE_RESPONSE' || outcomeType === 'BOUNTY') { const noneAnswerDoc = firestore .collection(`contracts/${contract.id}/answers`) .doc('0') diff --git a/functions/src/index.ts b/functions/src/index.ts index 07b37648..5e83ac6f 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -66,6 +66,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { getcustomtoken } from './get-custom-token' +import { addbounty } from './add-bounty' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -91,6 +92,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const getCustomTokenFunction = toCloudFunction(getcustomtoken) +const addBountyFunction = toCloudFunction(addbounty) export { healthFunction as health, @@ -114,4 +116,5 @@ export { getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, getCustomTokenFunction as getcustomtoken, + addBountyFunction as addbounty, } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 6f8ea2e9..b32ad030 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -2,12 +2,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { difference, mapValues, groupBy, sumBy } from 'lodash' -import { - Contract, - FreeResponseContract, - MultipleChoiceContract, - RESOLUTIONS, -} from '../../common/contract' +import { AnswerContract, Contract, RESOLUTIONS } from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -298,10 +293,7 @@ function getResolutionParams(contract: Contract, body: string) { throw new APIError(500, `Invalid outcome type: ${outcomeType}`) } -function validateAnswer( - contract: FreeResponseContract | MultipleChoiceContract, - answer: number -) { +function validateAnswer(contract: AnswerContract, 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`) diff --git a/functions/src/serve.ts b/functions/src/serve.ts index bf96db20..85066cd0 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { getcustomtoken } from './get-custom-token' +import { addbounty } from './add-bounty' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +addJsonEndpointRoute('/addbounty', addbounty) addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) diff --git a/web/README.md b/web/README.md index 2bfd5056..ad9e82bc 100644 --- a/web/README.md +++ b/web/README.md @@ -9,7 +9,7 @@ To run the development server, install [Yarn 1.x][yarn], and then in this direct 3. Your site will be available on http://localhost:3000 Check package.json for other command-line tasks. (e.g. `yarn dev` will point the development server at the prod -database. `yarn emulate` will run against a local emulated database, if you are serving it via `yarn serve` from the +database. `yarn dev:emulate` will run against a local emulated database, if you are serving it via `yarn serve` from the [`functions/` package][functions-readme].) ## Tech stack diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 238c7783..74595d0d 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { XIcon } from '@heroicons/react/solid' import { Answer } from 'common/answer' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { AnswerContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' import { APIError, placeBet } from 'web/lib/firebase/api' @@ -30,7 +30,7 @@ import { AlertBox } from '../alert-box' export function AnswerBetPanel(props: { answer: Answer - contract: FreeResponseContract | MultipleChoiceContract + contract: AnswerContract closePanel: () => void className?: string isModal?: boolean diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index f1ab2f88..2049630f 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { Answer } from 'common/answer' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { AnswerContract } 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 | MultipleChoiceContract + contract: AnswerContract showChoice: 'radio' | 'checkbox' | undefined chosenProb: number | undefined totalChosenProb?: number diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 0a4ac1e1..8d39b0b0 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx' import { sum } from 'lodash' import { useState } from 'react' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { AnswerContract } 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: FreeResponseContract | MultipleChoiceContract + contract: AnswerContract resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined setResolveOption: ( option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 27152db9..d85a85bc 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -5,14 +5,18 @@ import { groupBy, sortBy, sumBy } from 'lodash' import { memo } from 'react' import { Bet } from 'common/bet' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { + AnswerContract, + 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 | MultipleChoiceContract + contract: AnswerContract bets: Bet[] height?: number }) { @@ -178,10 +182,7 @@ function formatTime( return d.format(format) } -const computeProbsByOutcome = ( - bets: Bet[], - contract: FreeResponseContract | MultipleChoiceContract -) => { +const computeProbsByOutcome = (bets: Bet[], contract: AnswerContract) => { const { totalBets, outcomeType } = contract const betsByOutcome = groupBy(bets, (bet) => bet.outcome) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 6e0bfef6..4b76d96b 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,7 +1,12 @@ import { sortBy, partition, sum, uniq } from 'lodash' import { useEffect, useState } from 'react' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' +import { + AnswerContract, + BountyContract, + FreeResponseContract, + MultipleChoiceContract, +} from 'common/contract' import { Col } from '../layout/col' import { useUser } from 'web/hooks/use-user' import { getDpmOutcomeProbability } from 'common/calculate-dpm' @@ -25,9 +30,7 @@ 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 | MultipleChoiceContract -}) { +export function AnswersPanel(props: { contract: AnswerContract }) { const { contract } = props const { creatorId, resolution, resolutions, totalBets, outcomeType } = contract @@ -136,7 +139,7 @@ export function AnswersPanel(props: {
No answers yet...
)} - {outcomeType === 'FREE_RESPONSE' && + {(outcomeType === 'FREE_RESPONSE' || outcomeType === 'BOUNTY') && tradingAllowed(contract) && (!resolveOption || resolveOption === 'CANCEL') && ( @@ -158,7 +161,7 @@ export function AnswersPanel(props: { } function getAnswerItems( - contract: FreeResponseContract | MultipleChoiceContract, + contract: AnswerContract, answers: Answer[], user: User | undefined | null ) { @@ -184,7 +187,7 @@ function getAnswerItems( } function OpenAnswer(props: { - contract: FreeResponseContract | MultipleChoiceContract + contract: AnswerContract answer: Answer items: ActivityItem[] type: string diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index ce266778..715a9824 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Textarea from 'react-expanding-textarea' import { findBestMatch } from 'string-similarity' -import { FreeResponseContract } from 'common/contract' +import { BountyContract, FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' import { APIError, createAnswer } from 'web/lib/firebase/api' @@ -26,7 +26,9 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' import { lowerCase } from 'lodash' -export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { +export function CreateAnswerPanel(props: { + contract: FreeResponseContract | BountyContract +}) { const { contract } = props const user = useUser() const [text, setText] = useState('') diff --git a/web/components/bounty/bounty-box.tsx b/web/components/bounty/bounty-box.tsx new file mode 100644 index 00000000..868834c7 --- /dev/null +++ b/web/components/bounty/bounty-box.tsx @@ -0,0 +1,73 @@ +import clsx from 'clsx' +import { BountyContract } from 'common/contract' +import { User } from 'common/user' +import { useState } from 'react' +import { addBounty } from 'web/lib/firebase/api' +import { BuyAmountInput } from '../amount-input' +import { Spacer } from '../layout/spacer' +import { Title } from '../title' + +export function BountyBox(props: { + className?: string + user?: User | null + contract: BountyContract +}) { + const { className, user, contract } = props + const [amount, setAmount] = useState() + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState() + + const donateDisabled = isSubmitting || !amount || error + + const onSubmit: React.FormEventHandler = async (e) => { + if (!user || donateDisabled) return + + e.preventDefault() + setIsSubmitting(true) + setError(undefined) + + await addBounty({ + amount, + contractId: contract.id, + }) + + setIsSubmitting(false) + setAmount(undefined) + } + + return ( +
+ + <form onSubmit={onSubmit}> + <label + className="mb-2 block text-sm text-gray-500" + htmlFor="donate-input" + > + Contribute + </label> + <BuyAmountInput + inputClassName="w-full max-w-none donate-input" + amount={amount} + onChange={setAmount} + error={error} + setError={setError} + /> + + <Spacer h={8} /> + + {user && ( + <button + type="submit" + className={clsx( + 'btn w-full', + donateDisabled ? 'btn-disabled' : 'btn-primary', + isSubmitting && 'loading' + )} + > + Add to bounty + </button> + )} + </form> + </div> + ) +} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 3d4fa8d1..7b0ac818 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -1,11 +1,17 @@ import clsx from 'clsx' import Link from 'next/link' import { Row } from '../layout/row' -import { formatLargeNumber, formatPercent } from 'common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from 'common/util/format' import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { Col } from '../layout/col' import { + AnswerContract, BinaryContract, + BountyContract, Contract, FreeResponseContract, MultipleChoiceContract, @@ -156,6 +162,13 @@ export function ContractCard(props: { truncate="long" /> )} + + {outcomeType === 'BOUNTY' && ( + <BountyValue + className="items-center self-center pr-5" + contract={contract} + /> + )} <ProbBar contract={contract} /> </> )} @@ -216,8 +229,25 @@ export function BinaryResolutionOrChance(props: { ) } +export function BountyValue(props: { + contract: BountyContract + large?: boolean + className?: string +}) { + const { contract, large, className } = props + const textColor = `text-${getColor(contract)}` + return ( + <Col className={clsx(large ? 'text-3xl' : 'text-2xl', className)}> + <div className={textColor}>{formatMoney(contract.totalPrizes)}</div> + <div className={clsx(textColor, large ? 'text-xl' : 'text-base')}> + bounty + </div> + </Col> + ) +} + function FreeResponseTopAnswer(props: { - contract: FreeResponseContract | MultipleChoiceContract + contract: AnswerContract truncate: 'short' | 'long' | 'none' className?: string }) { diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 168ada50..3871673c 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -32,14 +32,15 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { 'userId' ).length - const typeDisplay = - outcomeType === 'BINARY' - ? 'YES / NO' - : outcomeType === 'FREE_RESPONSE' - ? 'Free response' - : outcomeType === 'MULTIPLE_CHOICE' - ? 'Multiple choice' - : 'Numeric' + const TYPES = { + BINARY: 'YES / NO', + FREE_RESPONSE: 'Free Response', + MULTIPLE_CHOICE: 'Multiple Choice', + NUMERIC: 'Numeric (deprecated)', + PSEUDO_NUMERIC: 'Numeric', + BOUNTY: 'Bounty', + } + const typeDisplay = TYPES[outcomeType] || 'Unknown' return ( <> diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index b95bb02b..83bdccc3 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -10,6 +10,7 @@ import { Row } from '../layout/row' import { Linkify } from '../linkify' import { BinaryResolutionOrChance, + BountyValue, FreeResponseResolutionOrChance, NumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation, @@ -44,7 +45,6 @@ export const ContractOverview = (props: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> - {isBinary && ( <BinaryResolutionOrChance className="hidden items-end xl:flex" @@ -52,20 +52,24 @@ export const ContractOverview = (props: { large /> )} - {isPseudoNumeric && ( <PseudoNumericResolutionOrExpectation contract={contract} className="hidden items-end xl:flex" /> )} - {outcomeType === 'NUMERIC' && ( <NumericResolutionOrExpectation contract={contract} className="hidden items-end xl:flex" /> )} + {outcomeType === 'BOUNTY' && ( + <BountyValue + contract={contract} + className="hidden items-end xl:flex" + /> + )} </Row> {isBinary ? ( diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index eb455df0..ecadb4ee 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -56,7 +56,8 @@ export function ContractTabs(props: { tips={tips} user={user} mode={ - contract.outcomeType === 'FREE_RESPONSE' + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'BOUNTY' ? 'free-response-comment-answer-groups' : 'comments' } diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 85e171d8..414ca4fd 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -3,7 +3,9 @@ import { Answer } from 'common/answer' import { getProbability } from 'common/calculate' import { getValueFromBucket } from 'common/calculate-dpm' import { + AnswerContract, BinaryContract, + BountyContract, Contract, FreeResponseContract, MultipleChoiceContract, @@ -77,7 +79,7 @@ export function BinaryContractOutcomeLabel(props: { } export function FreeResponseOutcomeLabel(props: { - contract: FreeResponseContract | MultipleChoiceContract + contract: AnswerContract resolution: string | 'CANCEL' | 'MKT' truncate: 'short' | 'long' | 'none' answerClassName?: string diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 5f250ce7..0dcf0dd3 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -88,3 +88,7 @@ export function acceptChallenge(params: any) { export function getCurrentUser(params: any) { return call(getFunctionUrl('getcurrentuser'), 'GET', params) } + +export function addBounty(params: any) { + return call(getFunctionUrl('addbounty'), 'POST', params) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 94773b6d..763a908b 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -42,6 +42,7 @@ import { listUsers } from 'web/lib/firebase/users' import { FeedComment } from 'web/components/feed/feed-comments' import { Title } from 'web/components/title' import { FeedBet } from 'web/components/feed/feed-bets' +import { BountyBox } from 'web/components/bounty/bounty-box' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -116,30 +117,38 @@ export function ContractPageSidebar(props: { const isBinary = outcomeType === 'BINARY' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isNumeric = outcomeType === 'NUMERIC' + const isBounty = outcomeType === 'BOUNTY' const allowTrade = tradingAllowed(contract) const allowResolve = !isResolved && isCreator && !!user const hasSidePanel = - (isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) + (isBinary || isNumeric || isPseudoNumeric || isBounty) && + (allowTrade || allowResolve) + if (!hasSidePanel) { + return null + } - return hasSidePanel ? ( + return ( <Col className="gap-4"> {allowTrade && (isNumeric ? ( <NumericBetPanel className="hidden xl:flex" contract={contract} /> + ) : isBounty ? ( + <BountyBox contract={contract} user={user ?? null} /> ) : ( <BetPanel className="hidden xl:flex" contract={contract as CPMMBinaryContract} /> ))} + {allowResolve && (isNumeric || isPseudoNumeric ? ( <NumericResolutionPanel creator={user} contract={contract} /> - ) : ( + ) : isBinary ? ( <ResolutionPanel creator={user} contract={contract} /> - ))} + ) : null)} </Col> - ) : null + ) } export function ContractPageContent( @@ -227,7 +236,8 @@ export function ContractPageContent( )} {(outcomeType === 'FREE_RESPONSE' || - outcomeType === 'MULTIPLE_CHOICE') && ( + outcomeType === 'MULTIPLE_CHOICE' || + outcomeType === 'BOUNTY') && ( <> <Spacer h={4} /> <AnswersPanel contract={contract} /> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab566c9e..fa68eaaa 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -56,12 +56,6 @@ export default function Create(props: { auth: { user: User } }) { const { user } = props.auth const router = useRouter() const params = router.query as NewQuestionParams - // TODO: Not sure why Question is pulled out as its own component; - // Maybe merge into newContract and then we don't need useEffect here. - const [question, setQuestion] = useState('') - useEffect(() => { - setQuestion(params.q ?? '') - }, [params.q]) if (!router.isReady) return <div /> @@ -76,26 +70,7 @@ export default function Create(props: { auth: { user: User } }) { <div className="rounded-lg px-6 py-4 sm:py-0"> <Title className="!mt-0" text="Create a market" /> - <form> - <div className="form-control w-full"> - <label className="label"> - <span className="mb-1"> - Question<span className={'text-red-700'}>*</span> - </span> - </label> - - <Textarea - placeholder="e.g. Will the Democrats win the 2024 US presidential election?" - className="input input-bordered resize-none" - autoFocus - maxLength={MAX_QUESTION_LENGTH} - value={question} - onChange={(e) => setQuestion(e.target.value || '')} - /> - </div> - </form> - <Spacer h={6} /> - <NewContract question={question} params={params} creator={user} /> + <NewContract params={params} creator={user} /> </div> </div> </Page> @@ -103,12 +78,9 @@ export default function Create(props: { auth: { user: User } }) { } // Allow user to create a new contract -export function NewContract(props: { - creator: User - question: string - params?: NewQuestionParams -}) { - const { creator, question, params } = props +function NewContract(props: { creator: User; params?: NewQuestionParams }) { + const { creator, params } = props + const [question, setQuestion] = useState('') const { groupId, initValue } = params ?? {} const [outcomeType, setOutcomeType] = useState<outcomeType>( (params?.outcomeType as outcomeType) ?? 'BINARY' @@ -131,6 +103,24 @@ export function NewContract(props: { } }) }, [creator.id, groupId]) + + useEffect(() => { + setQuestion(params?.q ?? '') + }, [params?.q]) + + const questionLabel = outcomeType === 'BOUNTY' ? 'Bounty' : 'Question' + const placeholders = { + BINARY: 'e.g. Will the Democrats win the 2024 US presidential election?', + MULTIPLE_CHOICE: + 'e.g. Which basketball team will be the March Madness champion?', + FREE_RESPONSE: 'e.g. What programming language should I learn next?', + NUMERIC: '', // Numeric type is deprecated + PSEUDO_NUMERIC: + 'e.g. How many people will show up to Taco Tuesday this week?', + BOUNTY: 'e.g. Add Dark Mode to Manifold Markets', + } + const placeholder = placeholders[outcomeType] ?? placeholders['BINARY'] + const [ante, _setAnte] = useState(FIXED_ANTE) // If params.closeTime is set, extract out the specified date and time @@ -255,7 +245,7 @@ export function NewContract(props: { return ( <div> <label className="label"> - <span className="mb-1">Answer type</span> + <span className="mb-1">Market type</span> </label> <ChoicesToggleGroup currentChoice={outcomeType} @@ -272,6 +262,7 @@ export function NewContract(props: { 'Multiple choice': 'MULTIPLE_CHOICE', 'Free response': 'FREE_RESPONSE', Numeric: 'PSEUDO_NUMERIC', + Bounty: 'BOUNTY', }} isSubmitting={isSubmitting} className={'col-span-4'} @@ -284,6 +275,27 @@ export function NewContract(props: { <Spacer h={6} /> + <form> + <div className="form-control w-full"> + <label className="label"> + <span className="mb-1"> + {questionLabel} + <span className={'text-red-700'}>*</span> + </span> + </label> + + <Textarea + placeholder={placeholder} + className="input input-bordered resize-none" + autoFocus + maxLength={MAX_QUESTION_LENGTH} + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + </div> + </form> + <Spacer h={6} /> + {outcomeType === 'MULTIPLE_CHOICE' && ( <MultipleChoiceAnswers answers={answers} setAnswers={setAnswers} /> )} @@ -380,7 +392,7 @@ export function NewContract(props: { <div className="form-control mb-1 items-start"> <label className="label mb-1 gap-2"> - <span>Question closes in</span> + <span>{questionLabel} closes in</span> <InfoTooltip text="Betting will be halted after this time (local timezone)." /> </label> <Row className={'w-full items-center gap-2'}> @@ -436,7 +448,7 @@ export function NewContract(props: { <Row className="items-end justify-between"> <div className="form-control mb-1 items-start"> <label className="label mb-1 gap-2"> - <span>Cost</span> + <span>{outcomeType === 'BOUNTY' ? 'Initial Bounty' : 'Cost'}</span> <InfoTooltip text={`Cost to create your question. This amount is used to subsidize betting.`} />