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'
|
| 'group'
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'bonus'
|
| 'bonus'
|
||||||
|
| 'challenge'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -64,3 +65,4 @@ export type notification_reason_types =
|
||||||
| 'tip_received'
|
| 'tip_received'
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
|
| 'challenge_accepted'
|
||||||
|
|
|
@ -39,6 +39,17 @@ service cloud.firestore {
|
||||||
allow read;
|
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} {
|
match /users/{userId}/follows/{followUserId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow write: if request.auth.uid == userId;
|
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 { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { TipTxn } from '../../common/txn'
|
import { TipTxn } from '../../common/txn'
|
||||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||||
|
import { Challenge } from 'common/lib/challenge'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
|
@ -468,3 +469,34 @@ export const createReferralNotification = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
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 './resolve-market'
|
||||||
export * from './unsubscribe'
|
export * from './unsubscribe'
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
|
export * from './accept-challenge'
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { LimitBets } from './limit-bets'
|
||||||
import { BucketInput } from './bucket-input'
|
import { BucketInput } from './bucket-input'
|
||||||
import { PillButton } from './buttons/pill-button'
|
import { PillButton } from './buttons/pill-button'
|
||||||
import { YesNoSelector } from './yes-no-selector'
|
import { YesNoSelector } from './yes-no-selector'
|
||||||
|
import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button'
|
||||||
|
|
||||||
export function BetPanel(props: {
|
export function BetPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
@ -366,24 +367,27 @@ function BuyPanel(props: {
|
||||||
<Spacer h={8} />
|
<Spacer h={8} />
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<Col>
|
||||||
className={clsx(
|
<button
|
||||||
'btn flex-1',
|
className={clsx(
|
||||||
betDisabled
|
'btn mb-2 flex-1',
|
||||||
? 'btn-disabled'
|
betDisabled
|
||||||
: betChoice === 'YES'
|
? 'btn-disabled'
|
||||||
? 'btn-primary'
|
: betChoice === 'YES'
|
||||||
: 'border-none bg-red-400 hover:bg-red-500',
|
? 'btn-primary'
|
||||||
isSubmitting ? 'loading' : ''
|
: 'border-none bg-red-400 hover:bg-red-500',
|
||||||
)}
|
isSubmitting ? 'loading' : ''
|
||||||
onClick={betDisabled ? undefined : submitBet}
|
)}
|
||||||
>
|
onClick={betDisabled ? undefined : submitBet}
|
||||||
{isSubmitting
|
>
|
||||||
? 'Submitting...'
|
{isSubmitting
|
||||||
: isLimitOrder
|
? 'Submitting...'
|
||||||
? 'Submit order'
|
: isLimitOrder
|
||||||
: 'Submit bet'}
|
? 'Submit order'
|
||||||
</button>
|
: 'Submit bet'}
|
||||||
|
</button>
|
||||||
|
<CreateChallengeButton user={user} contract={contract} />
|
||||||
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wasSubmitted && (
|
{wasSubmitted && (
|
||||||
|
@ -400,29 +404,41 @@ function QuickOrLimitBet(props: {
|
||||||
const { isLimitOrder, setIsLimitOrder } = props
|
const { isLimitOrder, setIsLimitOrder } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="align-center mb-4 justify-between">
|
<Col className="align-center mb-4 justify-between">
|
||||||
<div className="text-4xl">Bet</div>
|
<Row>
|
||||||
<Row className="mt-1 items-center gap-2">
|
<div className="text-4xl">Bet</div>
|
||||||
<PillButton
|
<Row className="mt-1 w-full items-center justify-end gap-0.5">
|
||||||
selected={!isLimitOrder}
|
<PillButton
|
||||||
onSelect={() => {
|
selected={!isLimitOrder}
|
||||||
setIsLimitOrder(false)
|
onSelect={() => {
|
||||||
track('select quick order')
|
setIsLimitOrder(false)
|
||||||
}}
|
track('select quick order')
|
||||||
>
|
}}
|
||||||
Quick
|
>
|
||||||
</PillButton>
|
Quick
|
||||||
<PillButton
|
</PillButton>
|
||||||
selected={isLimitOrder}
|
<PillButton
|
||||||
onSelect={() => {
|
selected={isLimitOrder}
|
||||||
setIsLimitOrder(true)
|
onSelect={() => {
|
||||||
track('select limit order')
|
setIsLimitOrder(true)
|
||||||
}}
|
track('select limit order')
|
||||||
>
|
}}
|
||||||
Limit
|
>
|
||||||
</PillButton>
|
Limit
|
||||||
|
</PillButton>
|
||||||
|
<PillButton
|
||||||
|
selected={isLimitOrder}
|
||||||
|
onSelect={() => {
|
||||||
|
setIsLimitOrder(true)
|
||||||
|
track('select limit order')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Peer
|
||||||
|
</PillButton>
|
||||||
|
</Row>
|
||||||
</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 clsx from 'clsx'
|
||||||
|
|
||||||
import { SimpleBetPanel } from './bet-panel'
|
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 { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { Col } from './layout/col'
|
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.
|
// Inline version of a bet panel. Opens BetPanel in a new modal.
|
||||||
export default function BetRow(props: {
|
export default function BetRow(props: {
|
||||||
|
@ -48,6 +49,9 @@ export default function BetRow(props: {
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col className={clsx('items-center', className)}>
|
||||||
|
<CreateChallengeButton user={user} contract={contract} />
|
||||||
|
</Col>
|
||||||
|
|
||||||
<Modal open={open} setOpen={setOpen}>
|
<Modal open={open} setOpen={setOpen}>
|
||||||
<SimpleBetPanel
|
<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 { LinkIcon } from '@heroicons/react/outline'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
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 { ToastClipboard } from 'web/components/toast-clipboard'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
|
|
||||||
function copyContractUrl(contract: Contract) {
|
|
||||||
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyLinkButton(props: {
|
export function CopyLinkButton(props: {
|
||||||
contract: Contract
|
link: string
|
||||||
|
onCopy?: () => void
|
||||||
buttonClassName?: string
|
buttonClassName?: string
|
||||||
toastClassName?: string
|
toastClassName?: string
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
label?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, buttonClassName, toastClassName } = props
|
const { onCopy, link, buttonClassName, toastClassName, label } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
as="div"
|
as="div"
|
||||||
className="relative z-10 flex-shrink-0"
|
className="relative z-10 flex-shrink-0"
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
copyContractUrl(contract)
|
copyToClipboard(link)
|
||||||
track('copy share link')
|
onCopy?.()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
|
@ -36,8 +30,11 @@ export function CopyLinkButton(props: {
|
||||||
buttonClassName
|
buttonClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
{!props.icon && (
|
||||||
Copy link
|
<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>
|
</Menu.Button>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
|
|
|
@ -10,8 +10,9 @@ import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||||
export const PortfolioValueSection = memo(
|
export const PortfolioValueSection = memo(
|
||||||
function PortfolioValueSection(props: {
|
function PortfolioValueSection(props: {
|
||||||
portfolioHistory: PortfolioMetrics[]
|
portfolioHistory: PortfolioMetrics[]
|
||||||
|
disableSelector?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { portfolioHistory } = props
|
const { portfolioHistory, disableSelector } = props
|
||||||
const lastPortfolioMetrics = last(portfolioHistory)
|
const lastPortfolioMetrics = last(portfolioHistory)
|
||||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
||||||
|
|
||||||
|
@ -30,7 +31,9 @@ export const PortfolioValueSection = memo(
|
||||||
<div>
|
<div>
|
||||||
<Row className="gap-8">
|
<Row className="gap-8">
|
||||||
<div className="mb-4 w-full">
|
<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-sm text-gray-500">Portfolio value</div>
|
||||||
<div className="text-lg">
|
<div className="text-lg">
|
||||||
{formatMoney(
|
{formatMoney(
|
||||||
|
@ -40,16 +43,18 @@ export const PortfolioValueSection = memo(
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</div>
|
</div>
|
||||||
<select
|
{!disableSelector && (
|
||||||
className="select select-bordered self-start"
|
<select
|
||||||
onChange={(e) => {
|
className="select select-bordered self-start"
|
||||||
setPortfolioPeriod(e.target.value as Period)
|
onChange={(e) => {
|
||||||
}}
|
setPortfolioPeriod(e.target.value as Period)
|
||||||
>
|
}}
|
||||||
<option value="allTime">{allTimeLabel}</option>
|
>
|
||||||
<option value="weekly">7 days</option>
|
<option value="allTime">{allTimeLabel}</option>
|
||||||
<option value="daily">24 hours</option>
|
<option value="weekly">7 days</option>
|
||||||
</select>
|
<option value="daily">24 hours</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<PortfolioValueGraph
|
<PortfolioValueGraph
|
||||||
portfolioHistory={portfolioHistory}
|
portfolioHistory={portfolioHistory}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import clsx from 'clsx'
|
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 { CopyLinkButton } from './copy-link-button'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
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 }) {
|
export function ShareMarket(props: { contract: Contract; className?: string }) {
|
||||||
const { contract, className } = props
|
const { contract, className } = props
|
||||||
|
@ -18,7 +21,8 @@ export function ShareMarket(props: { contract: Contract; className?: string }) {
|
||||||
value={contractUrl(contract)}
|
value={contractUrl(contract)}
|
||||||
/>
|
/>
|
||||||
<CopyLinkButton
|
<CopyLinkButton
|
||||||
contract={contract}
|
link={`https://${ENV_CONFIG.domain}${contractPath(contract)}`}
|
||||||
|
onCopy={() => track('copy share link')}
|
||||||
buttonClassName="btn-md rounded-l-none"
|
buttonClassName="btn-md rounded-l-none"
|
||||||
toastClassName={'-left-28 mt-1'}
|
toastClassName={'-left-28 mt-1'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { useUser } from 'web/hooks/use-user'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
export function SignUpPrompt() {
|
export function SignUpPrompt(props: { label?: string }) {
|
||||||
|
const { label } = props
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
|
||||||
return user === null ? (
|
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"
|
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')}
|
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
||||||
>
|
>
|
||||||
Sign up to bet!
|
{label ?? 'Sign up to bet!'}
|
||||||
</button>
|
</button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
import { doc, DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData, where } from 'firebase/firestore'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
|
|
|
@ -80,3 +80,7 @@ export function claimManalink(params: any) {
|
||||||
export function createGroup(params: any) {
|
export function createGroup(params: any) {
|
||||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
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 { useRouter } from 'next/router'
|
||||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||||
import { richTextToString } from 'common/util/parse'
|
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 const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: {
|
export async function getStaticPropz(props: {
|
||||||
|
@ -173,10 +175,15 @@ export function ContractPageContent(
|
||||||
(isNumeric ? (
|
(isNumeric ? (
|
||||||
<NumericBetPanel className="hidden xl:flex" contract={contract} />
|
<NumericBetPanel className="hidden xl:flex" contract={contract} />
|
||||||
) : (
|
) : (
|
||||||
<BetPanel
|
<div>
|
||||||
className="hidden xl:flex"
|
<Row className={'my-4 hidden justify-end xl:flex'}>
|
||||||
contract={contract as CPMMBinaryContract}
|
<CreateChallengeButton user={user} contract={contract} />
|
||||||
/>
|
</Row>
|
||||||
|
<BetPanel
|
||||||
|
className="hidden xl:flex"
|
||||||
|
contract={contract as CPMMBinaryContract}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{allowResolve &&
|
{allowResolve &&
|
||||||
(isNumeric || isPseudoNumeric ? (
|
(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} />
|
return <ContractEmbed contract={contract} bets={bets} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
export function ContractEmbed(props: {
|
||||||
const { contract, bets } = props
|
contract: Contract
|
||||||
|
bets: Bet[]
|
||||||
|
height?: number
|
||||||
|
}) {
|
||||||
|
const { contract, bets, height } = props
|
||||||
const { question, outcomeType } = contract
|
const { question, outcomeType } = contract
|
||||||
|
|
||||||
const isBinary = outcomeType === 'BINARY'
|
const isBinary = outcomeType === 'BINARY'
|
||||||
|
@ -89,10 +93,11 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
||||||
const { setElem, height: topSectionHeight } = useMeasureSize()
|
const { setElem, height: topSectionHeight } = useMeasureSize()
|
||||||
const paddingBottom = 8
|
const paddingBottom = 8
|
||||||
|
|
||||||
const graphHeight =
|
const graphHeight = !height
|
||||||
windowHeight && topSectionHeight
|
? windowHeight && topSectionHeight
|
||||||
? windowHeight - topSectionHeight - paddingBottom
|
? windowHeight - topSectionHeight - paddingBottom
|
||||||
: 0
|
: 0
|
||||||
|
: height
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="w-full flex-1 bg-white">
|
<Col className="w-full flex-1 bg-white">
|
||||||
|
|
|
@ -816,6 +816,7 @@ function getSourceUrl(notification: Notification) {
|
||||||
if (sourceType === 'tip' && sourceContractSlug)
|
if (sourceType === 'tip' && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
|
||||||
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||||
|
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
sourceId ?? '',
|
sourceId ?? '',
|
||||||
|
@ -918,6 +919,15 @@ function NotificationTextLabel(props: {
|
||||||
<span>of your limit order was filled</span>
|
<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 (
|
return (
|
||||||
<div className={className ? className : 'line-clamp-4 whitespace-pre-line'}>
|
<div className={className ? className : 'line-clamp-4 whitespace-pre-line'}>
|
||||||
|
@ -972,6 +982,9 @@ function getReasonForShowingNotification(
|
||||||
case 'bet':
|
case 'bet':
|
||||||
reasonText = 'bet against you'
|
reasonText = 'bet against you'
|
||||||
break
|
break
|
||||||
|
case 'challenge':
|
||||||
|
reasonText = 'accepted your challenge'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
reasonText = ''
|
reasonText = ''
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user