From 2ce508382ae646f98c36ad5c77bbe1ba143f404a Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 21 Jul 2022 10:08:50 -0600 Subject: [PATCH] Challenge bets --- common/challenge.ts | 52 +++ common/notification.ts | 2 + firestore.rules | 11 + functions/src/accept-challenge.ts | 173 +++++++ functions/src/create-notification.ts | 32 ++ functions/src/index.ts | 1 + web/components/bet-panel.tsx | 96 ++-- web/components/bet-row.tsx | 6 +- .../challenges/accept-challenge-button.tsx | 132 ++++++ .../challenges/create-challenge-button.tsx | 267 +++++++++++ web/components/copy-link-button.tsx | 29 +- .../portfolio/portfolio-value-section.tsx | 29 +- web/components/share-market.tsx | 8 +- web/components/sign-up-prompt.tsx | 5 +- web/hooks/use-user.ts | 2 +- web/lib/firebase/api.ts | 4 + web/lib/firebase/challenges.ts | 130 ++++++ web/pages/[username]/[contractSlug].tsx | 15 +- .../[contractSlug]/[challengeSlug].tsx | 426 ++++++++++++++++++ web/pages/challenges/index.tsx | 324 +++++++++++++ web/pages/embed/[username]/[contractSlug].tsx | 13 +- web/pages/notifications.tsx | 13 + 22 files changed, 1688 insertions(+), 82 deletions(-) create mode 100644 common/challenge.ts create mode 100644 functions/src/accept-challenge.ts create mode 100644 web/components/challenges/accept-challenge-button.tsx create mode 100644 web/components/challenges/create-challenge-button.tsx create mode 100644 web/lib/firebase/challenges.ts create mode 100644 web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx create mode 100644 web/pages/challenges/index.tsx diff --git a/common/challenge.ts b/common/challenge.ts new file mode 100644 index 00000000..be59a1dc --- /dev/null +++ b/common/challenge.ts @@ -0,0 +1,52 @@ +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 + + // Displayed to people claiming the challenge + message: string + + // How much to put up + amount: number + + // YES or NO for now + creatorsOutcome: string + + // Different than the creator + yourOutcome: string + + // The probability the challenger thinks + creatorsOutcomeProb: number + + contractId: string + contractSlug: 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[] + + isResolved: boolean + resolutionOutcome?: string +} + +export type Acceptance = { + userId: string + userUsername: string + userName: string + // The ID of the successful bet that tracks the money moved + betId: string + + createdTime: number +} diff --git a/common/notification.ts b/common/notification.ts index 5fd4236b..fa4cd90a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -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' diff --git a/firestore.rules b/firestore.rules index 96378d8b..c831ced5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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; diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts new file mode 100644 index 00000000..b9b83e1c --- /dev/null +++ b/functions/src/accept-challenge.ts @@ -0,0 +1,173 @@ +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 { + calculateCpmmPurchase, + getCpmmProbability, +} from '../../common/calculate-cpmm' +import { createChallengeAcceptedNotification } from './create-notification' + +const bodySchema = z.object({ + contractId: z.string(), + challengeSlug: z.string(), +}) +const firestore = admin.firestore() + +export const acceptchallenge = newEndpoint({}, async (req, auth) => { + log('Inside endpoint handler.') + const { challengeSlug, contractId } = validate(bodySchema, req.body) + const result = await firestore.runTransaction(async (trans) => { + log('Inside main transaction.') + 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.') + log('Loaded user and contract snapshots.') + + 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, 'User not found.') + const creator = creatorSnap.data() as User + + const { amount, yourOutcome, creatorsOutcome, creatorsOutcomeProb } = + challenge + if (user.balance < amount) throw new APIError(400, 'Insufficient balance.') + + const { closeTime, outcomeType } = anyContract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed.') + if (outcomeType !== 'BINARY') + throw new APIError(400, 'Challenges only accepted for binary markets.') + + const contract = anyContract as CPMMBinaryContract + log('contract stats:', contract.pool, contract.p) + const probs = getCpmmProbability(contract.pool, contract.p) + log('probs:', probs) + + const yourShares = (1 / (1 - creatorsOutcomeProb)) * amount + const yourNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: amount, + amount: amount, + shares: yourShares, + isCancelled: false, + contractId: contract.id, + outcome: yourOutcome, + probBefore: probs, + probAfter: probs, + loanAmount: 0, + createdTime: Date.now(), + fees: { creatorFee: 0, platformFee: 0, liquidityFee: 0 }, + }) + const yourNewBetDoc = contractDoc.collection('bets').doc() + trans.create(yourNewBetDoc, { + id: yourNewBetDoc.id, + userId: user.id, + ...yourNewBet, + }) + log('Created new bet document.') + + trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) }) + log('Updated user balance.') + + let cpmmState = { pool: contract.pool, p: contract.p } + const { newPool, newP } = calculateCpmmPurchase( + cpmmState, + yourNewBet.amount, + yourNewBet.outcome + ) + cpmmState = { pool: newPool, p: newP } + + const creatorShares = (1 / creatorsOutcomeProb) * amount + const creatorNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: amount, + amount: amount, + shares: creatorShares, + isCancelled: false, + contractId: contract.id, + outcome: creatorsOutcome, + probBefore: probs, + probAfter: probs, + loanAmount: 0, + createdTime: Date.now(), + fees: { creatorFee: 0, platformFee: 0, liquidityFee: 0 }, + }) + const creatorBetDoc = contractDoc.collection('bets').doc() + trans.create(creatorBetDoc, { + id: creatorBetDoc.id, + userId: creator.id, + ...creatorNewBet, + }) + log('Created new bet document.') + + trans.update(creatorDoc, { + balance: FieldValue.increment(-creatorNewBet.amount), + }) + log('Updated user balance.') + const newPurchaseStats = calculateCpmmPurchase( + cpmmState, + creatorNewBet.amount, + creatorNewBet.outcome + ) + cpmmState = { pool: newPurchaseStats.newPool, p: newPurchaseStats.newP } + + trans.update( + contractDoc, + removeUndefinedProps({ + pool: cpmmState.pool, + // p shouldn't have changed + p: contract.p, + volume: contract.volume + yourNewBet.amount + creatorNewBet.amount, + }) + ) + log('Updated contract properties.') + + trans.update( + challengeDoc, + removeUndefinedProps({ + acceptedByUserIds: [user.id], + acceptances: [ + { + userId: user.id, + betId: yourNewBetDoc.id, + createdTime: Date.now(), + userUsername: user.username, + userName: user.name, + } as Acceptance, + ], + }) + ) + + await createChallengeAcceptedNotification( + user, + creator, + challenge, + contract + ) + return yourNewBetDoc + }) + + return { betId: result.id } +}) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index bf2dd28a..480d72b1 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -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/lib/challenge' const firestore = admin.firestore() type user_to_reason_texts = { @@ -468,3 +469,34 @@ export const createReferralNotification = async ( } const groupPath = (groupSlug: string) => `/group/${groupSlug}` + +export const createChallengeAcceptedNotification = async ( + challenger: User, + challengeCreator: User, + challenge: Challenge, + 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: challenge.amount.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)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index df311886..ce6b5f35 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -42,3 +42,4 @@ export * from './create-group' export * from './resolve-market' export * from './unsubscribe' export * from './stripe' +export * from './accept-challenge' diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 0cbee7b5..081f1571 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -41,6 +41,7 @@ import { LimitBets } from './limit-bets' import { BucketInput } from './bucket-input' import { PillButton } from './buttons/pill-button' import { YesNoSelector } from './yes-no-selector' +import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -366,24 +367,27 @@ function BuyPanel(props: { {user && ( - + + + + )} {wasSubmitted && ( @@ -400,29 +404,41 @@ function QuickOrLimitBet(props: { const { isLimitOrder, setIsLimitOrder } = props return ( - -
Bet
- - { - setIsLimitOrder(false) - track('select quick order') - }} - > - Quick - - { - setIsLimitOrder(true) - track('select limit order') - }} - > - Limit - + + +
Bet
+ + { + setIsLimitOrder(false) + track('select quick order') + }} + > + Quick + + { + setIsLimitOrder(true) + track('select limit order') + }} + > + Limit + + { + setIsLimitOrder(true) + track('select limit order') + }} + > + Peer + +
-
+ + ) } diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 56fff9bd..933eb02f 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import React, { useState } from 'react' import clsx from 'clsx' import { SimpleBetPanel } from './bet-panel' @@ -8,6 +8,7 @@ import { useUser } from 'web/hooks/use-user' import { useUserContractBets } from 'web/hooks/use-user-bets' import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' +import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { @@ -48,6 +49,9 @@ export default function BetRow(props: { : ''} + + + { + setErrorText('') + }, [open]) + + if (!user) return + + 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, + }) + .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 ( + <> + setOpen(newOpen)} size={'sm'}> + + +
+ + </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(challenge.amount)} + </span> + </Row> + {/*<Row className={'w-full justify-start gap-8'}>*/} + {/* <span className={'min-w-[4rem] font-bold'}>Probability:</span>{' '}*/} + {/* <span className={'ml-[3px]'}>*/} + {/* {' '}*/} + {/* {Math.round(yourProb * 100) + '%'}*/} + {/* </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(challenge.amount / yourProb)} + </span> + {/*<InfoTooltip text={"If you're right"} />*/} + </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={'indigo'} + size={'xl'} + onClick={() => setOpen(true)} + className={clsx('whitespace-nowrap')} + > + I accept this challenge + </Button> + )} + </> + ) +} diff --git a/web/components/challenges/create-challenge-button.tsx b/web/components/challenges/create-challenge-button.tsx new file mode 100644 index 00000000..740f502d --- /dev/null +++ b/web/components/challenges/create-challenge-button.tsx @@ -0,0 +1,267 @@ +import clsx from 'clsx' +import { useState } from 'react' +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 dayjs from 'dayjs' +import { Button } from '../button' +import { DuplicateIcon } from '@heroicons/react/outline' +import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' +import { Contract } from 'common/contract' +import { track } from 'web/lib/service/analytics' +import { CopyLinkButton } from 'web/components/copy-link-button' +import { getOutcomeProbability } from 'common/lib/calculate' +import { SiteLink } from 'web/components/site-link' + +type challengeInfo = { + amount: number + expiresTime: number | null + message: string + outcome: 'YES' | 'NO' | number + prob: number +} +export function CreateChallengeButton(props: { + user: User | null | undefined + contract: Contract +}) { + const { user, contract } = props + const [open, setOpen] = useState(false) + const [highlightedSlug, setHighlightedSlug] = useState('') + + return ( + <> + <Modal open={open} setOpen={(newOpen) => setOpen(newOpen)}> + <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, + amount: newChallenge.amount, + expiresTime: newChallenge.expiresTime, + message: newChallenge.message, + prob: newChallenge.prob / 100, + outcome: newChallenge.outcome, + contract: contract, + }) + challenge && setHighlightedSlug(getChallengeUrl(challenge)) + }} + highlightedSlug={highlightedSlug} + /> + )} + </Col> + </Modal> + + <Button + color={'indigo'} + size={'lg'} + onClick={() => setOpen(true)} + className={clsx('whitespace-nowrap')} + > + Challenge + </Button> + </> + ) +} + +function CreateChallengeForm(props: { + user: User + contract: Contract + onCreate: (m: challengeInfo) => Promise<void> + highlightedSlug: string +}) { + const { user, onCreate, contract, highlightedSlug } = props + const [isCreating, setIsCreating] = useState(false) + const [finishedCreating, setFinishedCreating] = useState(false) + const [copyPressed, setCopyPressed] = useState(false) + setTimeout(() => setCopyPressed(false), 300) + const defaultExpire = 'week' + const isBinary = contract.outcomeType === 'BINARY' + const isNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' + + 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, + prob: Math.round(getOutcomeProbability(contract, 'YES') * 100), + message: defaultMessage, + }) + + return ( + <> + {!finishedCreating && ( + <form + onSubmit={(e) => { + e.preventDefault() + setIsCreating(true) + onCreate(challengeInfo).finally(() => setIsCreating(false)) + setFinishedCreating(true) + }} + > + <Title className="!my-2" text="Create a challenge bet" /> + <Row className="label ">You're betting</Row> + <div className="flex flex-col flex-wrap gap-x-5 gap-y-2"> + <Row className={'form-control w-full justify-start gap-4'}> + <Col> + <div className="relative"> + <span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> + M$ + </span> + <input + className="input input-bordered w-40 pl-10" + type="number" + min={1} + value={challengeInfo.amount} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { ...m, amount: parseInt(e.target.value) } + }) + } + /> + </div> + </Col> + <Col className={'mt-3 ml-1 text-gray-600'}>on</Col> + <Col> + {/*<label className="label">Outcome</label>*/} + {isBinary && ( + <select + className="form-select h-12 rounded-lg border-gray-300" + value={challengeInfo.outcome} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: e.target.value as 'YES' | 'NO', + } + }) + } + > + <option value="YES">Yes</option> + <option value="NO">No</option> + </select> + )} + {isNumeric && ( + <div className="relative"> + <input + className="input input-bordered w-full" + type="number" + min={contract.min} + max={contract.max} + value={challengeInfo.outcome} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + outcome: parseFloat(e.target.value) as number, + } + }) + } + /> + </div> + )} + </Col> + </Row> + <div className="form-control flex flex-row gap-8"> + {/*<Col className={'mt-9 justify-center'}>at</Col>*/} + <Col> + <label className="label ">At</label> + <div className="relative"> + <input + className="input input-bordered max-w-[5rem]" + type="number" + min={1} + max={100} + value={challengeInfo.prob} + onChange={(e) => + setChallengeInfo((m: challengeInfo) => { + return { + ...m, + prob: parseFloat(e.target.value), + } + }) + } + /> + <span className="absolute top-3.5 -right-5 text-sm text-gray-600"> + % + </span> + </div> + </Col> + </div> + + {/*<div className="form-control w-full">*/} + {/* <label className="label">Message</label>*/} + {/* <Textarea*/} + {/* placeholder={defaultMessage}*/} + {/* className="input input-bordered resize-none"*/} + {/* autoFocus*/} + {/* value={*/} + {/* challengeInfo.message !== defaultMessage*/} + {/* ? challengeInfo.message*/} + {/* : ''*/} + {/* }*/} + {/* rows={2}*/} + {/* onChange={(e) =>*/} + {/* setChallengeInfo((m: challengeInfo) => {*/} + {/* return { ...m, message: e.target.value }*/} + {/* })*/} + {/* }*/} + {/* />*/} + {/*</div>*/} + </div> + <Row className={'justify-end'}> + <Button + type="submit" + color={'indigo'} + className={clsx( + 'mt-8 whitespace-nowrap drop-shadow-md', + isCreating ? 'disabled' : '' + )} + > + Create + </Button> + </Row> + </form> + )} + {finishedCreating && ( + <> + <Title className="!my-0" text="Challenge Created!" /> + <Row + className={clsx( + 'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700', + copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' + )} + > + <div className="flex w-full select-text items-center truncate"> + {highlightedSlug} + </div> + + <CopyLinkButton + link={highlightedSlug} + onCopy={() => { + setCopyPressed(true) + track('copy share challenge') + }} + buttonClassName="btn-sm rounded-l-none" + toastClassName={'-left-40 -top-20 mt-1'} + icon={DuplicateIcon} + label={''} + /> + </Row> + <Row className={'gap-1'}> + See your other + <SiteLink className={'font-bold'} href={'/challenges'}> + challenges + </SiteLink> + </Row> + </> + )} + </> + ) +} diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index ab6dd66f..fb0afdf5 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -2,32 +2,26 @@ import React, { Fragment } from 'react' import { LinkIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' import clsx from 'clsx' - -import { Contract } from 'common/contract' -import { copyToClipboard } from 'web/lib/util/copy' -import { contractPath } from 'web/lib/firebase/contracts' -import { ENV_CONFIG } from 'common/envs/constants' import { ToastClipboard } from 'web/components/toast-clipboard' -import { track } from 'web/lib/service/analytics' - -function copyContractUrl(contract: Contract) { - copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`) -} +import { copyToClipboard } from 'web/lib/util/copy' export function CopyLinkButton(props: { - contract: Contract + link: string + onCopy?: () => void buttonClassName?: string toastClassName?: string + icon?: React.ComponentType<{ className?: string }> + label?: string }) { - const { contract, buttonClassName, toastClassName } = props + const { onCopy, link, buttonClassName, toastClassName, label } = props return ( <Menu as="div" className="relative z-10 flex-shrink-0" onMouseUp={() => { - copyContractUrl(contract) - track('copy share link') + copyToClipboard(link) + onCopy?.() }} > <Menu.Button @@ -36,8 +30,11 @@ export function CopyLinkButton(props: { buttonClassName )} > - <LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> - Copy link + {!props.icon && ( + <LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" /> + )} + {props.icon && <props.icon className={'h-4 w-4'} />} + {label ?? 'Copy link'} </Menu.Button> <Transition diff --git a/web/components/portfolio/portfolio-value-section.tsx b/web/components/portfolio/portfolio-value-section.tsx index fa50365b..611a19d1 100644 --- a/web/components/portfolio/portfolio-value-section.tsx +++ b/web/components/portfolio/portfolio-value-section.tsx @@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph' export const PortfolioValueSection = memo( function PortfolioValueSection(props: { portfolioHistory: PortfolioMetrics[] + disableSelector?: boolean }) { - const { portfolioHistory } = props + const { portfolioHistory, disableSelector } = props const lastPortfolioMetrics = last(portfolioHistory) const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime') @@ -30,7 +31,9 @@ export const PortfolioValueSection = memo( <div> <Row className="gap-8"> <div className="mb-4 w-full"> - <Col> + <Col + className={disableSelector ? 'items-center justify-center' : ''} + > <div className="text-sm text-gray-500">Portfolio value</div> <div className="text-lg"> {formatMoney( @@ -40,16 +43,18 @@ export const PortfolioValueSection = memo( </div> </Col> </div> - <select - className="select select-bordered self-start" - onChange={(e) => { - setPortfolioPeriod(e.target.value as Period) - }} - > - <option value="allTime">{allTimeLabel}</option> - <option value="weekly">7 days</option> - <option value="daily">24 hours</option> - </select> + {!disableSelector && ( + <select + className="select select-bordered self-start" + onChange={(e) => { + setPortfolioPeriod(e.target.value as Period) + }} + > + <option value="allTime">{allTimeLabel}</option> + <option value="weekly">7 days</option> + <option value="daily">24 hours</option> + </select> + )} </Row> <PortfolioValueGraph portfolioHistory={portfolioHistory} diff --git a/web/components/share-market.tsx b/web/components/share-market.tsx index a5da585f..89121377 100644 --- a/web/components/share-market.tsx +++ b/web/components/share-market.tsx @@ -1,8 +1,11 @@ import clsx from 'clsx' -import { Contract, contractUrl } from 'web/lib/firebase/contracts' +import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts' import { CopyLinkButton } from './copy-link-button' import { Col } from './layout/col' import { Row } from './layout/row' +import { copyToClipboard } from 'web/lib/util/copy' +import { ENV_CONFIG } from 'common/lib/envs/constants' +import { track } from 'web/lib/service/analytics' export function ShareMarket(props: { contract: Contract; className?: string }) { const { contract, className } = props @@ -18,7 +21,8 @@ export function ShareMarket(props: { contract: Contract; className?: string }) { value={contractUrl(contract)} /> <CopyLinkButton - contract={contract} + link={`https://${ENV_CONFIG.domain}${contractPath(contract)}`} + onCopy={() => track('copy share link')} buttonClassName="btn-md rounded-l-none" toastClassName={'-left-28 mt-1'} /> diff --git a/web/components/sign-up-prompt.tsx b/web/components/sign-up-prompt.tsx index 0edce22c..1a7c10b0 100644 --- a/web/components/sign-up-prompt.tsx +++ b/web/components/sign-up-prompt.tsx @@ -3,7 +3,8 @@ import { useUser } from 'web/hooks/use-user' import { firebaseLogin } from 'web/lib/firebase/users' import { withTracking } from 'web/lib/service/analytics' -export function SignUpPrompt() { +export function SignUpPrompt(props: { label?: string }) { + const { label } = props const user = useUser() return user === null ? ( @@ -11,7 +12,7 @@ export function SignUpPrompt() { className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600" onClick={withTracking(firebaseLogin, 'sign up to bet')} > - Sign up to bet! + {label ?? 'Sign up to bet!'} </button> ) : null } diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index e04a69ca..5ba99e3e 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { QueryClient } from 'react-query' -import { doc, DocumentData } from 'firebase/firestore' +import { doc, DocumentData, where } from 'firebase/firestore' import { PrivateUser } from 'common/user' import { getUser, diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index 27d6caa3..95e19fcb 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -80,3 +80,7 @@ export function claimManalink(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } + +export function acceptChallenge(params: any) { + return call(getFunctionUrl('acceptchallenge'), 'POST', params) +} diff --git a/web/lib/firebase/challenges.ts b/web/lib/firebase/challenges.ts new file mode 100644 index 00000000..6413c65b --- /dev/null +++ b/web/lib/firebase/challenges.ts @@ -0,0 +1,130 @@ +import { + collectionGroup, + orderBy, + query, + setDoc, + where, +} from 'firebase/firestore' +import { doc } from 'firebase/firestore' +import { Challenge } from 'common/challenge' +import { customAlphabet } from 'nanoid' +import { coll, listenForValue, listenForValues } from './utils' +import { useEffect, useState } from 'react' +import { User } from 'common/user' +import { db } from './init' +import { Contract } from 'common/contract' + +export const challenges = (contractId: string) => + coll<Challenge>(`contracts/${contractId}/challenges`) + +export function getChallengeUrl(challenge: Challenge) { + return `${location.protocol}//${location.host}/challenges/${challenge.creatorUsername}/${challenge.contractSlug}/${challenge.slug}` +} +export async function createChallenge(data: { + creator: User + outcome: 'YES' | 'NO' | number + prob: number + contract: Contract + amount: number + expiresTime: number | null + message: string +}) { + const { creator, amount, expiresTime, message, prob, contract, outcome } = + data + + // At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years + // See https://zelark.github.io/nano-id-cc/ + const nanoid = customAlphabet( + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 8 + ) + const slug = nanoid() + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) return null + + const challenge: Challenge = { + slug, + creatorId: creator.id, + creatorUsername: creator.username, + amount, + contractSlug: contract.slug, + contractId: contract.id, + creatorsOutcome: outcome.toString(), + yourOutcome: outcome === 'YES' ? 'NO' : 'YES', + creatorsOutcomeProb: prob, + createdTime: Date.now(), + expiresTime, + maxUses: 1, + acceptedByUserIds: [], + acceptances: [], + isResolved: false, + message, + } + + await setDoc(doc(challenges(contract.id), slug), challenge) + return challenge +} + +// TODO: This required an index, make sure to also set up in prod +function listUserChallenges(fromId?: string) { + return query( + collectionGroup(db, 'challenges'), + where('creatorId', '==', fromId), + orderBy('createdTime', 'desc') + ) +} + +function listChallenges() { + return query(collectionGroup(db, 'challenges')) +} + +export const useAcceptedChallenges = () => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + listenForValues(listChallenges(), (challenges: Challenge[]) => { + setLinks( + challenges + .sort((a: Challenge, b: Challenge) => b.createdTime - a.createdTime) + .filter((challenge) => challenge.acceptedByUserIds.length > 0) + ) + }) + }, []) + + return links +} + +export function listenForChallenge( + slug: string, + contractId: string, + setLinks: (challenge: Challenge | null) => void +) { + return listenForValue<Challenge>(doc(challenges(contractId), slug), setLinks) +} + +export function useChallenge(slug: string, contractId: string | undefined) { + const [challenge, setChallenge] = useState<Challenge | null>() + useEffect(() => { + if (slug && contractId) { + listenForChallenge(slug, contractId, setChallenge) + } + }, [contractId, slug]) + return challenge +} + +export function listenForUserChallenges( + fromId: string | undefined, + setLinks: (links: Challenge[]) => void +) { + return listenForValues<Challenge>(listUserChallenges(fromId), setLinks) +} + +export const useUserChallenges = (fromId: string) => { + const [links, setLinks] = useState<Challenge[]>([]) + + useEffect(() => { + return listenForUserChallenges(fromId, setLinks) + }, [fromId]) + + return links +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 17453770..7f66dbc4 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -46,6 +46,8 @@ import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useRouter } from 'next/router' import { useLiquidity } from 'web/hooks/use-liquidity' import { richTextToString } from 'common/util/parse' +import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button' +import { Row } from 'web/components/layout/row' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -173,10 +175,15 @@ export function ContractPageContent( (isNumeric ? ( <NumericBetPanel className="hidden xl:flex" contract={contract} /> ) : ( - <BetPanel - className="hidden xl:flex" - contract={contract as CPMMBinaryContract} - /> + <div> + <Row className={'my-4 hidden justify-end xl:flex'}> + <CreateChallengeButton user={user} contract={contract} /> + </Row> + <BetPanel + className="hidden xl:flex" + contract={contract as CPMMBinaryContract} + /> + </div> ))} {allowResolve && (isNumeric || isPseudoNumeric ? ( diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx new file mode 100644 index 00000000..13dad4c1 --- /dev/null +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -0,0 +1,426 @@ +import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { + Contract, + contractPath, + getContractFromSlug, +} from 'web/lib/firebase/contracts' +import { useContractWithPreload } from 'web/hooks/use-contract' +import { DOMAIN } from 'common/lib/envs/constants' +import { Col } from 'web/components/layout/col' +import { SiteLink } from 'web/components/site-link' +import { Spacer } from 'web/components/layout/spacer' +import { Row } from 'web/components/layout/row' + +import { Challenge } from 'common/challenge' +import { useChallenge } from 'web/lib/firebase/challenges' +import { getPortfolioHistory, getUserByUsername } from 'web/lib/firebase/users' +import { PortfolioMetrics, User } from 'common/user' +import { Page } from 'web/components/page' +import { useUser, useUserById } from 'web/hooks/use-user' +import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button' +import { Avatar } from 'web/components/avatar' +import { UserLink } from 'web/components/user-page' +import { useEffect, useState } from 'react' +import { BinaryOutcomeLabel } from 'web/components/outcome-label' +import { formatMoney } from 'common/lib/util/format' +import { last } from 'lodash' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { useWindowSize } from 'web/hooks/use-window-size' +import { Bet, listAllBets } from 'web/lib/firebase/bets' +import Confetti from 'react-confetti' +import { + BinaryResolutionOrChance, + PseudoNumericResolutionOrExpectation, +} from 'web/components/contract/contract-card' +import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' +export const getStaticProps = fromPropz(getStaticPropz) +export async function getStaticPropz(props: { + params: { username: string; contractSlug: string; challengeSlug: string } +}) { + const { username, contractSlug, challengeSlug } = props.params + const contract = (await getContractFromSlug(contractSlug)) || null + const user = (await getUserByUsername(username)) || null + const bets = contract?.id ? await listAllBets(contract.id) : [] + + return { + props: { + contract, + user, + slug: contractSlug, + challengeSlug, + bets, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function ChallengePage(props: { + contract: Contract | null + user: User + slug: string + bets: Bet[] + + challengeSlug: string +}) { + props = usePropz(props, getStaticPropz) ?? { + contract: null, + user: null, + challengeSlug: '', + bets: [], + + slug: '', + } + const contract = useContractWithPreload(props.contract) + const challenge = useChallenge(props.challengeSlug, contract?.id) + const { user, bets } = props + const currentUser = useUser() + + if (!contract || !challenge) { + return ( + <Col className={'min-h-screen items-center justify-center'}> + <LoadingIndicator /> + </Col> + ) + } + + if (challenge.acceptances.length >= challenge.maxUses) + return ( + <ClosedChallengeContent + contract={contract} + challenge={challenge} + creator={user} + bets={bets} + /> + ) + return ( + <OpenChallengeContent + user={currentUser} + contract={contract} + challenge={challenge} + creator={user} + /> + ) +} + +const userRow = (challenger: User) => ( + <Row className={'mb-2 w-full items-center justify-center gap-2'}> + <Avatar + size={12} + avatarUrl={challenger.avatarUrl} + username={challenger.username} + /> + <UserLink + className={'text-2xl'} + name={challenger.name} + username={challenger.username} + /> + </Row> +) + +function ClosedChallengeContent(props: { + contract: Contract + challenge: Challenge + creator: User + bets: Bet[] +}) { + const { contract, challenge, creator, bets } = props + const { resolution } = contract + const user = useUserById(challenge.acceptances[0].userId) + const [showConfetti, setShowConfetti] = useState(false) + const { width, height } = useWindowSize() + useEffect(() => { + if (challenge.acceptances.length === 0) return + if (challenge.acceptances[0].createdTime > Date.now() - 1000 * 60) + setShowConfetti(true) + }, [challenge.acceptances]) + const creatorWon = resolution === challenge.creatorsOutcome + + if (!user) return <LoadingIndicator /> + + const userWonCol = (user: User) => ( + <Col className="w-full items-start justify-center gap-1 p-4"> + <Row className={'mb-2 w-full items-center justify-center gap-2'}> + <span className={'mx-2 text-3xl'}>🥇</span> + <Avatar size={12} avatarUrl={user.avatarUrl} username={user.username} /> + <UserLink + className={'text-2xl'} + name={user.name} + username={user.username} + /> + <span className={'mx-2 text-3xl'}>🥇</span> + </Row> + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + WON{' '} + <span className={'text-primary'}> + {formatMoney(challenge.amount)} + </span> + </span> + </Row> + </Col> + ) + + const userLostCol = (challenger: User) => ( + <Col className="w-full items-start justify-center gap-1"> + {userRow(challenger)} + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + LOST{' '} + <span className={'text-red-500'}> + {formatMoney(challenge.amount)} + </span> + </span> + </Row> + </Col> + ) + + const userCol = ( + challenger: User, + outcome: string, + prob: number, + lost?: boolean + ) => ( + <Col className="w-full items-start justify-center gap-1"> + {userRow(challenger)} + <Row className={'w-full items-center justify-center'}> + {!lost ? ( + <span className={'text-lg'}> + is betting {formatMoney(challenge.amount)} + {' on '} + <BinaryOutcomeLabel outcome={outcome as any} /> at{' '} + {Math.round(prob * 100)}% + </span> + ) : ( + <span className={'text-lg'}> + LOST{' '} + <span className={'text-red-500'}> + {formatMoney(challenge.amount)} + </span> + </span> + )} + </Row> + </Col> + ) + return ( + <Page> + {showConfetti && ( + <Confetti + width={width ?? 500} + height={height ?? 500} + confettiSource={{ + x: ((width ?? 500) - 200) / 2, + y: 0, + w: 200, + h: 0, + }} + recycle={false} + initialVelocityY={{ min: 1, max: 3 }} + numberOfPieces={200} + /> + )} + <Col className=" w-full rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:items-center sm:justify-center sm:px-2 md:px-6 md:py-8"> + {!resolution && ( + <Row + className={ + 'items-center justify-center gap-2 text-xl text-gray-600' + } + > + <span className={'text-xl'}>⚔️️</span> + Challenge Accepted + <span className={'text-xl'}>⚔️️</span> + </Row> + )} + {resolution == 'YES' || resolution == 'NO' ? ( + <Col + className={ + 'max-h-[60vh] w-full content-between justify-between gap-1' + } + > + <Row className={'mt-4 w-full'}> + {userWonCol(creatorWon ? creator : user)} + </Row> + <Row className={'mt-4'}> + {userLostCol(creatorWon ? user : creator)} + </Row> + </Col> + ) : ( + <Col + className={ + 'h-full w-full content-between justify-between gap-1 py-10 sm:flex-row' + } + > + {userCol( + creator, + challenge.creatorsOutcome, + challenge.creatorsOutcomeProb + )} + <Col className="items-center justify-center py-4 text-xl">VS</Col> + {userCol( + user, + challenge.yourOutcome, + 1 - challenge.creatorsOutcomeProb + )} + </Col> + )} + <Spacer h={3} /> + <ChallengeContract contract={contract} bets={bets} /> + </Col> + </Page> + ) +} + +function ChallengeContract(props: { contract: Contract; bets: Bet[] }) { + const { contract, bets } = props + const { question } = contract + const href = `https://${DOMAIN}${contractPath(contract)}` + + const isBinary = contract.outcomeType === 'BINARY' + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' + return ( + <Col className="w-full flex-1 bg-white"> + <div className="relative flex flex-col pt-2"> + <Row className="justify-between px-3 text-xl text-indigo-700 md:text-2xl"> + <SiteLink href={href}>{question}</SiteLink> + {isBinary && <BinaryResolutionOrChance contract={contract} />} + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation contract={contract} /> + )} + </Row> + + <Spacer h={3} /> + + <div className="mx-1" style={{ paddingBottom: 50 }}> + {(isBinary || isPseudoNumeric) && ( + <ContractProbGraph contract={contract} bets={bets} height={500} /> + )} + </div> + </div> + </Col> + ) +} + +function OpenChallengeContent(props: { + contract: Contract + challenge: Challenge + creator: User + user: User | null | undefined +}) { + const { contract, challenge, creator, user } = props + const { question } = contract + const [creatorPortfolioHistory, setUsersCreatorPortfolioHistory] = useState< + PortfolioMetrics[] + >([]) + const [portfolioHistory, setUsersPortfolioHistory] = useState< + PortfolioMetrics[] + >([]) + useEffect(() => { + getPortfolioHistory(creator.id).then(setUsersCreatorPortfolioHistory) + if (user) getPortfolioHistory(user.id).then(setUsersPortfolioHistory) + }, [creator.id, user]) + + const href = `https://${DOMAIN}${contractPath(contract)}` + const { width, height } = useWindowSize() + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) + const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 + const remainingHeight = + (height ?? window.innerHeight) - + (containerRef?.offsetTop ?? 0) - + bottomBarHeight + + const userColumn = ( + challenger: User | null | undefined, + portfolioHistory: PortfolioMetrics[], + outcome: string + ) => { + const lastPortfolioMetrics = last(portfolioHistory) + const prob = + (outcome === challenge.creatorsOutcome + ? challenge.creatorsOutcomeProb + : 1 - challenge.creatorsOutcomeProb) * 100 + + return ( + <Col className="w-full items-start justify-center gap-1"> + {challenger ? ( + userRow(challenger) + ) : ( + <Row className={'mb-2 w-full items-center justify-center gap-2'}> + <Avatar size={12} avatarUrl={undefined} username={undefined} /> + <span className={'text-2xl'}>Your name here</span> + </Row> + )} + <Row className={'w-full items-center justify-center'}> + <span className={'text-lg'}> + is betting {formatMoney(challenge.amount)} + {' on '} + <BinaryOutcomeLabel outcome={outcome as any} /> at{' '} + {Math.round(prob)}% + </span> + </Row> + {/*// It could be fun to show each user's portfolio history here*/} + {/*// Also show how many challenges they've won*/} + {/*<Row className={'mt-4 hidden w-full items-center sm:block'}>*/} + {/* <PortfolioValueSection*/} + {/* disableSelector={true}*/} + {/* portfolioHistory={portfolioHistory}*/} + {/* />*/} + {/*</Row>*/} + <Row className={'w-full'}> + <Col className={'w-full items-center justify-center'}> + <div className="text-sm text-gray-500">Portfolio value</div> + + {challenger + ? formatMoney( + (lastPortfolioMetrics?.balance ?? 0) + + (lastPortfolioMetrics?.investmentValue ?? 0) + ) + : 'xxxx'} + </Col> + </Row> + </Col> + ) + } + return ( + <Page> + <Col + ref={setContainerRef} + style={{ height: remainingHeight }} + className=" w-full justify-between rounded border-0 border-gray-100 bg-white py-6 pl-1 pr-2 sm:px-2 md:px-6 md:py-8" + > + <Row className="px-3 pb-4 text-xl text-indigo-700 md:text-2xl"> + <SiteLink href={href}>{question}</SiteLink> + </Row> + <Col + className={ + 'h-full max-h-[50vh] w-full content-between justify-between gap-1 py-10 sm:flex-row' + } + > + {userColumn( + creator, + creatorPortfolioHistory, + challenge.creatorsOutcome + )} + <Col className="items-center justify-center py-4 text-4xl">VS</Col> + {userColumn( + user?.id === challenge.creatorId ? undefined : user, + portfolioHistory, + challenge.yourOutcome + )} + </Col> + <Spacer h={3} /> + + <Row className="my-4 w-full items-center justify-center"> + <AcceptChallengeButton + user={user} + contract={contract} + challenge={challenge} + /> + </Row> + </Col> + </Page> + ) +} diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx new file mode 100644 index 00000000..d5473e16 --- /dev/null +++ b/web/pages/challenges/index.tsx @@ -0,0 +1,324 @@ +import clsx from 'clsx' +import React, { useState } from 'react' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' +import { formatMoney } from 'common/util/format' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { SEO } from 'web/components/SEO' +import { Title } from 'web/components/title' +import { useUser } from 'web/hooks/use-user' +import { fromNow } from 'web/lib/util/time' +import { useUserById } from 'web/hooks/use-user' + +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import { + getChallengeUrl, + useAcceptedChallenges, + useUserChallenges, +} from 'web/lib/firebase/challenges' +import { Challenge, Acceptance } from 'common/challenge' +import { copyToClipboard } from 'web/lib/util/copy' +import { ToastClipboard } from 'web/components/toast-clipboard' +import { Tabs } from 'web/components/layout/tabs' +import { SiteLink } from 'web/components/site-link' +import { UserLink } from 'web/components/user-page' +dayjs.extend(customParseFormat) + +export function getManalinkUrl(slug: string) { + return `${location.protocol}//${location.host}/link/${slug}` +} + +export default function LinkPage() { + const user = useUser() + const userChallenges = useUserChallenges(user?.id ?? '') + const challenges = useAcceptedChallenges() + + return ( + <Page> + <SEO + title="Challenges" + description="Challenge your friends to a bet!" + url="/send" + /> + <Col className="w-full px-8"> + <Row className="items-center justify-between"> + <Title text="Challenges" /> + {/*{user && (*/} + {/* <CreateChallengeButton*/} + {/* user={user}*/} + {/* />*/} + {/*)}*/} + </Row> + <p>Find or create a question to challenge someone to a bet.</p> + <Tabs + tabs={[ + { + content: <AllLinksTable links={challenges} />, + title: 'All Challenges', + }, + ].concat( + user + ? { + content: <LinksTable links={userChallenges} />, + title: 'Your Challenges', + } + : [] + )} + /> + </Col> + </Page> + ) +} +// +// export function ClaimsList(props: { txns: ManalinkTxn[] }) { +// const { txns } = props +// return ( +// <> +// <h1 className="mb-4 text-xl font-semibold text-gray-900"> +// Claimed links +// </h1> +// {txns.map((txn) => ( +// <ClaimDescription txn={txn} key={txn.id} /> +// ))} +// </> +// ) +// } + +// export function ClaimDescription(props: { txn: ManalinkTxn }) { +// const { txn } = props +// const from = useUserById(txn.fromId) +// const to = useUserById(txn.toId) +// +// if (!from || !to) { +// return <>Loading...</> +// } +// +// return ( +// <div className="mb-2 flow-root pr-2 md:pr-0"> +// <div className="relative flex items-center space-x-3"> +// <Avatar username={to.name} avatarUrl={to.avatarUrl} size="sm" /> +// <div className="min-w-0 flex-1"> +// <p className="mt-0.5 text-sm text-gray-500"> +// <UserLink +// className="text-gray-500" +// username={to.username} +// name={to.name} +// />{' '} +// claimed {formatMoney(txn.amount)} from{' '} +// <UserLink +// className="text-gray-500" +// username={from.username} +// name={from.name} +// /> +// <RelativeTimestamp time={txn.createdTime} /> +// </p> +// </div> +// </div> +// </div> +// ) +// } + +function ClaimTableRow(props: { claim: Acceptance }) { + const { claim } = props + const who = useUserById(claim.userId) + return ( + <tr> + <td className="px-5 py-2">{who?.name || 'Loading...'}</td> + <td className="px-5 py-2">{`${new Date( + claim.createdTime + ).toLocaleString()}, ${fromNow(claim.createdTime)}`}</td> + </tr> + ) +} + +function LinkDetailsTable(props: { link: Challenge }) { + const { link } = props + return ( + <table className="w-full divide-y divide-gray-300 border border-gray-400"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className="px-5 py-2">Accepted by</th> + <th className="px-5 py-2">Time</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500"> + {link.acceptances.length ? ( + link.acceptances.map((claim) => <ClaimTableRow claim={claim} />) + ) : ( + <tr> + <td className="px-5 py-2" colSpan={2}> + No one's accepted this challenge yet. + </td> + </tr> + )} + </tbody> + </table> + ) +} + +function LinkTableRow(props: { link: Challenge; highlight: boolean }) { + const { link, highlight } = props + const [expanded, setExpanded] = useState(false) + return ( + <> + <LinkSummaryRow + link={link} + highlight={highlight} + expanded={expanded} + onToggle={() => setExpanded((exp) => !exp)} + /> + {expanded && ( + <tr> + <td className="bg-gray-100 p-3" colSpan={5}> + <LinkDetailsTable link={link} /> + </td> + </tr> + )} + </> + ) +} + +function LinkSummaryRow(props: { + link: Challenge + highlight: boolean + expanded: boolean + onToggle: () => void +}) { + const { link, highlight, expanded, onToggle } = props + const [showToast, setShowToast] = useState(false) + + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white', + highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : '' + ) + return ( + <tr id={link.slug} key={link.slug} className={className}> + <td className="py-4 pl-5" onClick={onToggle}> + {expanded ? ( + <ChevronUpIcon className="h-5 w-5" /> + ) : ( + <ChevronDownIcon className="h-5 w-5" /> + )} + </td> + + <td className="px-5 py-4 font-medium text-gray-900"> + {formatMoney(link.amount)} + </td> + <td + className="relative px-5 py-4" + onClick={() => { + copyToClipboard(getChallengeUrl(link)) + setShowToast(true) + setTimeout(() => setShowToast(false), 3000) + }} + > + {getChallengeUrl(link) + .replace('https://manifold.markets', '...') + .replace('http://localhost:3000', '...')} + {showToast && <ToastClipboard className={'left-10 -top-5'} />} + </td> + + <td className="px-5 py-4"> + {link.acceptedByUserIds.length > 0 ? 'Yes' : 'No'} + </td> + <td className="px-5 py-4"> + {link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)} + </td> + </tr> + ) +} + +function LinksTable(props: { links: Challenge[]; highlightedSlug?: string }) { + const { links, highlightedSlug } = props + return links.length == 0 ? ( + <p>You don't currently have any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th></th> + <th className="px-5 py-3.5">Amount</th> + <th className="px-5 py-3.5">Link</th> + <th className="px-5 py-3.5">Accepted</th> + <th className="px-5 py-3.5">Expires</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <LinkTableRow + link={link} + highlight={link.slug === highlightedSlug} + /> + ))} + </tbody> + </table> + </div> + ) +} +function AllLinksTable(props: { + links: Challenge[] + highlightedSlug?: string +}) { + const { links, highlightedSlug } = props + return links.length == 0 ? ( + <p>There aren't currently any challenges.</p> + ) : ( + <div className="overflow-scroll"> + <table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200"> + <thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900"> + <tr> + <th className="px-5 py-3.5">Amount</th> + <th className="px-5 py-3.5">Link</th> + <th className="px-5 py-3.5">Accepted By</th> + </tr> + </thead> + <tbody className={'divide-y divide-gray-200 bg-white'}> + {links.map((link) => ( + <PublicLinkTableRow + link={link} + highlight={link.slug === highlightedSlug} + /> + ))} + </tbody> + </table> + </div> + ) +} + +function PublicLinkTableRow(props: { link: Challenge; highlight: boolean }) { + const { link, highlight } = props + return <PublicLinkSummaryRow link={link} highlight={highlight} /> +} + +function PublicLinkSummaryRow(props: { link: Challenge; highlight: boolean }) { + const { link, highlight } = props + + const className = clsx( + 'whitespace-nowrap text-sm hover:cursor-pointer text-gray-500 hover:bg-sky-50 bg-white', + highlight ? 'bg-indigo-100 rounded-lg animate-pulse' : '' + ) + return ( + <tr id={link.slug} key={link.slug} className={className}> + <td className="px-5 py-4 font-medium text-gray-900"> + {formatMoney(link.amount)} + </td> + <td className="relative px-5 py-4"> + <SiteLink href={getChallengeUrl(link)}> + {getChallengeUrl(link) + .replace('https://manifold.markets', '...') + .replace('http://localhost:3000', '...')} + </SiteLink> + </td> + + <td className="px-5 py-4"> + <UserLink + name={link.acceptances[0].userName} + username={link.acceptances[0].userUsername} + /> + </td> + </tr> + ) +} diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 57189c0c..42e5bf5e 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -76,8 +76,12 @@ export default function ContractEmbedPage(props: { return <ContractEmbed contract={contract} bets={bets} /> } -function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { - const { contract, bets } = props +export function ContractEmbed(props: { + contract: Contract + bets: Bet[] + height?: number +}) { + const { contract, bets, height } = props const { question, outcomeType } = contract const isBinary = outcomeType === 'BINARY' @@ -89,10 +93,11 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { setElem, height: topSectionHeight } = useMeasureSize() const paddingBottom = 8 - const graphHeight = - windowHeight && topSectionHeight + const graphHeight = !height + ? windowHeight && topSectionHeight ? windowHeight - topSectionHeight - paddingBottom : 0 + : height return ( <Col className="w-full flex-1 bg-white"> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index d011e757..5484fba4 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -816,6 +816,7 @@ function getSourceUrl(notification: Notification) { if (sourceType === 'tip' && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` + if (sourceType === 'challenge') return `${sourceSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '', @@ -918,6 +919,15 @@ function NotificationTextLabel(props: { <span>of your limit order was filled</span> </> ) + } else if (sourceType === 'challenge' && sourceText) { + return ( + <> + <span> for </span> + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + </> + ) } return ( <div className={className ? className : 'line-clamp-4 whitespace-pre-line'}> @@ -972,6 +982,9 @@ function getReasonForShowingNotification( case 'bet': reasonText = 'bet against you' break + case 'challenge': + reasonText = 'accepted your challenge' + break default: reasonText = '' }