Challenge bets
This commit is contained in:
parent
921ac4b2a9
commit
2ce508382a
52
common/challenge.ts
Normal file
52
common/challenge.ts
Normal 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
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
|
|
173
functions/src/accept-challenge.ts
Normal file
173
functions/src/accept-challenge.ts
Normal 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 }
|
||||
})
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -42,3 +42,4 @@ export * from './create-group'
|
|||
export * from './resolve-market'
|
||||
export * from './unsubscribe'
|
||||
export * from './stripe'
|
||||
export * from './accept-challenge'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
132
web/components/challenges/accept-challenge-button.tsx
Normal file
132
web/components/challenges/accept-challenge-button.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
267
web/components/challenges/create-challenge-button.tsx
Normal file
267
web/components/challenges/create-challenge-button.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
130
web/lib/firebase/challenges.ts
Normal file
130
web/lib/firebase/challenges.ts
Normal 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
|
||||
}
|
|
@ -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 ? (
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
324
web/pages/challenges/index.tsx
Normal file
324
web/pages/challenges/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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 = ''
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user