Challenge bets

This commit is contained in:
Ian Philips 2022-07-21 10:08:50 -06:00
parent 921ac4b2a9
commit 2ce508382a
22 changed files with 1688 additions and 82 deletions

52
common/challenge.ts Normal file
View File

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

View File

@ -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'

View File

@ -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;

View File

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

View File

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

View File

@ -42,3 +42,4 @@ export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
export * from './accept-challenge'

View File

@ -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: {
<Spacer h={8} />
{user && (
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: betChoice === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting
? 'Submitting...'
: isLimitOrder
? 'Submit order'
: 'Submit bet'}
</button>
<Col>
<button
className={clsx(
'btn mb-2 flex-1',
betDisabled
? 'btn-disabled'
: betChoice === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting
? 'Submitting...'
: isLimitOrder
? 'Submit order'
: 'Submit bet'}
</button>
<CreateChallengeButton user={user} contract={contract} />
</Col>
)}
{wasSubmitted && (
@ -400,29 +404,41 @@ function QuickOrLimitBet(props: {
const { isLimitOrder, setIsLimitOrder } = props
return (
<Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div>
<Row className="mt-1 items-center gap-2">
<PillButton
selected={!isLimitOrder}
onSelect={() => {
setIsLimitOrder(false)
track('select quick order')
}}
>
Quick
</PillButton>
<PillButton
selected={isLimitOrder}
onSelect={() => {
setIsLimitOrder(true)
track('select limit order')
}}
>
Limit
</PillButton>
<Col className="align-center mb-4 justify-between">
<Row>
<div className="text-4xl">Bet</div>
<Row className="mt-1 w-full items-center justify-end gap-0.5">
<PillButton
selected={!isLimitOrder}
onSelect={() => {
setIsLimitOrder(false)
track('select quick order')
}}
>
Quick
</PillButton>
<PillButton
selected={isLimitOrder}
onSelect={() => {
setIsLimitOrder(true)
track('select limit order')
}}
>
Limit
</PillButton>
<PillButton
selected={isLimitOrder}
onSelect={() => {
setIsLimitOrder(true)
track('select limit order')
}}
>
Peer
</PillButton>
</Row>
</Row>
</Row>
<Row className={'mt-2 justify-end'}></Row>
</Col>
)
}

View File

@ -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: {
: ''}
</div>
</Col>
<Col className={clsx('items-center', className)}>
<CreateChallengeButton user={user} contract={contract} />
</Col>
<Modal open={open} setOpen={setOpen}>
<SimpleBetPanel

View File

@ -0,0 +1,132 @@
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/lib/util/format'
import { Button } from 'web/components/button'
import clsx from 'clsx'
import { InfoTooltip } from 'web/components/info-tooltip'
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 yourProb = 1 - challenge.creatorsOutcomeProb
useEffect(() => {
setErrorText('')
}, [open])
if (!user) return <SignUpPrompt label={'Sign up to accept this challenge'} />
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 (
<>
<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(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>
)}
</>
)
}

View File

@ -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>
</>
)}
</>
)
}

View File

@ -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

View File

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

View File

@ -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'}
/>

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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 ? (

View File

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

View File

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

View File

@ -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">

View File

@ -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 = ''
}