Implement quick betting: directly from the market card (#291)
* Play with using 3 icons for 1-click usage * Align bet icons with the percentages * Hide liquidity injection star, for now * Fix Free Response card layouts * Use triangles instead of planes * Set correct hover states the arrows * Fix down triangle & padding * Default large nums to 2 sigfigs * Clean up hover areas * Fix bet width, remove "chance/expected" * Show "M$20" on hover, hide arrows when closed * Improve click targets * FR: "MULTI" => "MANY", single => "TOP" * Install react-hot-toaster * Implement quick betting on binary questions * Handle different kinds of markets * Extract out QuickBet into its own component * Minor tweaks * Visually separate out quick bet pane * Hide quick bet for FR markets with no answers * Fill in which bets the user has already placed * Animate movements, fix binary direction * Hover arrows are now always gray * Pull out code into quick-bet.tsx * Minor comments * Fix import ts-ignore is scary * Fixes from James's feedback * Hide text only on quickbet
This commit is contained in:
parent
a8e47d4fc7
commit
8cedf93901
|
@ -30,17 +30,17 @@ export function formatPercent(zeroToOne: number) {
|
|||
}
|
||||
|
||||
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
|
||||
export function formatLargeNumber(num: number, sigfigs = 3): string {
|
||||
export function formatLargeNumber(num: number, sigfigs = 2): string {
|
||||
const absNum = Math.abs(num)
|
||||
if (absNum < 1000) {
|
||||
return num.toPrecision(sigfigs)
|
||||
return '' + Number(num.toPrecision(sigfigs))
|
||||
}
|
||||
|
||||
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
|
||||
const suffixIdx = Math.floor(Math.log10(absNum) / 3)
|
||||
const suffixStr = suffix[suffixIdx]
|
||||
const numStr = (num / Math.pow(10, 3 * suffixIdx)).toPrecision(sigfigs)
|
||||
return `${numStr}${suffixStr}`
|
||||
return `${Number(numStr)}${suffixStr}`
|
||||
}
|
||||
|
||||
export function toCamelCase(words: string) {
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
Contract,
|
||||
contractPath,
|
||||
getBinaryProbPercent,
|
||||
getBinaryProb,
|
||||
listenForContract,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import {
|
||||
|
@ -22,54 +22,13 @@ import {
|
|||
AnswerLabel,
|
||||
BinaryContractOutcomeLabel,
|
||||
FreeResponseOutcomeLabel,
|
||||
OUTCOME_TO_COLOR,
|
||||
} from '../outcome-label'
|
||||
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
|
||||
import { AbbrContractDetails } from './contract-details'
|
||||
import { AvatarDetails, MiscDetails } from './contract-details'
|
||||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||
|
||||
// Return a number from 0 to 1 for this contract
|
||||
// Resolved contracts are set to 1, for coloring purposes (even if NO)
|
||||
function getProb(contract: Contract) {
|
||||
const { outcomeType, resolution } = contract
|
||||
return resolution
|
||||
? 1
|
||||
: outcomeType === 'BINARY'
|
||||
? getBinaryProb(contract)
|
||||
: outcomeType === 'FREE_RESPONSE'
|
||||
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
||||
: outcomeType === 'NUMERIC'
|
||||
? getNumericScale(contract as NumericContract)
|
||||
: 1 // Should not happen
|
||||
}
|
||||
|
||||
function getNumericScale(contract: NumericContract) {
|
||||
const { min, max } = contract
|
||||
const ev = getExpectedValue(contract)
|
||||
return (ev - min) / (max - min)
|
||||
}
|
||||
|
||||
function getColor(contract: Contract) {
|
||||
const { resolution } = contract
|
||||
if (resolution) {
|
||||
return (
|
||||
// @ts-ignore; TODO: Have better typing for contract.resolution?
|
||||
OUTCOME_TO_COLOR[resolution] ||
|
||||
// If resolved to a FR answer, use 'primary'
|
||||
'primary'
|
||||
)
|
||||
}
|
||||
if (contract.outcomeType === 'NUMERIC') {
|
||||
return 'blue-400'
|
||||
}
|
||||
|
||||
const marketClosed = (contract.closeTime || Infinity) < Date.now()
|
||||
return marketClosed
|
||||
? 'gray-400'
|
||||
: getProb(contract) >= 0.5
|
||||
? 'primary'
|
||||
: 'red-400'
|
||||
}
|
||||
import { useEffect, useState } from 'react'
|
||||
import { QuickBet, QuickOutcomeView, ProbBar, getColor } from './quick-bet'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
|
@ -77,80 +36,67 @@ export function ContractCard(props: {
|
|||
showCloseTime?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, showHotVolume, showCloseTime, className } = props
|
||||
const { showHotVolume, showCloseTime, className } = props
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const { question, outcomeType } = contract
|
||||
|
||||
const prob = getProb(contract)
|
||||
const color = getColor(contract)
|
||||
const marketClosed = (contract.closeTime || Infinity) < Date.now()
|
||||
const showTopBar = prob >= 0.5 || marketClosed
|
||||
const showQuickBet = !(
|
||||
marketClosed ||
|
||||
(outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined)
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Col
|
||||
className={clsx(
|
||||
'relative gap-3 rounded-lg bg-white p-6 pr-7 shadow-md hover:bg-gray-100',
|
||||
'relative gap-3 rounded-lg bg-white py-4 pl-6 pr-5 shadow-md hover:cursor-pointer hover:bg-gray-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Link href={contractPath(contract)}>
|
||||
<a className="absolute left-0 right-0 top-0 bottom-0" />
|
||||
</Link>
|
||||
|
||||
<AbbrContractDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showCloseTime={showCloseTime}
|
||||
/>
|
||||
|
||||
<Row className={clsx('justify-between gap-4')}>
|
||||
<Col className="gap-3">
|
||||
<Row className={clsx(showQuickBet ? 'divide-x' : '')}>
|
||||
<Col className="relative flex-1 gap-3 pr-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'peer absolute -left-6 -top-4 -bottom-4 z-10',
|
||||
// Hack: Extend the clickable area for closed markets
|
||||
showQuickBet ? 'right-0' : 'right-[-6.5rem]'
|
||||
)}
|
||||
>
|
||||
<Link href={contractPath(contract)}>
|
||||
<a className="absolute top-0 left-0 right-0 bottom-0" />
|
||||
</Link>
|
||||
</div>
|
||||
<AvatarDetails contract={contract} />
|
||||
<p
|
||||
className="break-words font-medium text-indigo-700"
|
||||
className="break-words font-medium text-indigo-700 peer-hover:underline peer-hover:decoration-indigo-400 peer-hover:decoration-2"
|
||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||
>
|
||||
{question}
|
||||
</p>
|
||||
</Col>
|
||||
{outcomeType === 'BINARY' && (
|
||||
<BinaryResolutionOrChance
|
||||
className="items-center"
|
||||
contract={contract}
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
className="items-center"
|
||||
contract={contract as NumericContract}
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
<FreeResponseTopAnswer
|
||||
contract={contract as FullContract<DPM, FreeResponse>}
|
||||
truncate="long"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showCloseTime={showCloseTime}
|
||||
/>
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
<QuickBet contract={contract} />
|
||||
) : (
|
||||
<Col className="m-auto pl-2">
|
||||
<QuickOutcomeView contract={contract} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
<FreeResponseResolutionOrChance
|
||||
className="self-end text-gray-600"
|
||||
contract={contract as FullContract<DPM, FreeResponse>}
|
||||
truncate="long"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute right-0 top-0 w-2 rounded-tr-md',
|
||||
'bg-gray-200'
|
||||
)}
|
||||
style={{ height: `${100 * (1 - prob)}%` }}
|
||||
></div>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute right-0 bottom-0 w-2 rounded-br-md',
|
||||
`bg-${color}`,
|
||||
// If we're showing the full bar, also round the top
|
||||
prob === 1 ? 'rounded-tr-md' : ''
|
||||
)}
|
||||
style={{ height: `${100 * prob}%` }}
|
||||
></div>
|
||||
<ProbBar contract={contract} />
|
||||
</Col>
|
||||
</div>
|
||||
)
|
||||
|
@ -160,8 +106,9 @@ export function BinaryResolutionOrChance(props: {
|
|||
contract: FullContract<DPM | CPMM, Binary>
|
||||
large?: boolean
|
||||
className?: string
|
||||
hideText?: boolean
|
||||
}) {
|
||||
const { contract, large, className } = props
|
||||
const { contract, large, className, hideText } = props
|
||||
const { resolution } = contract
|
||||
const textColor = `text-${getColor(contract)}`
|
||||
|
||||
|
@ -182,21 +129,42 @@ export function BinaryResolutionOrChance(props: {
|
|||
) : (
|
||||
<>
|
||||
<div className={textColor}>{getBinaryProbPercent(contract)}</div>
|
||||
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
|
||||
chance
|
||||
</div>
|
||||
{!hideText && (
|
||||
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
|
||||
chance
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function FreeResponseResolutionOrChance(props: {
|
||||
function FreeResponseTopAnswer(props: {
|
||||
contract: FreeResponseContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, truncate, className } = props
|
||||
const { contract, truncate } = props
|
||||
|
||||
const topAnswer = getTopAnswer(contract)
|
||||
|
||||
return topAnswer ? (
|
||||
<AnswerLabel
|
||||
className="!text-gray-600"
|
||||
answer={topAnswer}
|
||||
truncate={truncate}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
export function FreeResponseResolutionOrChance(props: {
|
||||
contract: FreeResponseContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
hideText?: boolean
|
||||
}) {
|
||||
const { contract, truncate, className, hideText } = props
|
||||
const { resolution } = contract
|
||||
|
||||
const topAnswer = getTopAnswer(contract)
|
||||
|
@ -217,16 +185,11 @@ export function FreeResponseResolutionOrChance(props: {
|
|||
) : (
|
||||
topAnswer && (
|
||||
<Row className="items-center gap-6">
|
||||
<AnswerLabel
|
||||
className="!text-gray-600"
|
||||
answer={topAnswer}
|
||||
truncate={truncate}
|
||||
/>
|
||||
<Col className={clsx('text-3xl', textColor)}>
|
||||
<div>
|
||||
{formatPercent(getOutcomeProbability(contract, topAnswer.id))}
|
||||
</div>
|
||||
<div className="text-base">chance</div>
|
||||
{!hideText && <div className="text-base">chance</div>}
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
|
@ -238,9 +201,11 @@ export function FreeResponseResolutionOrChance(props: {
|
|||
export function NumericResolutionOrExpectation(props: {
|
||||
contract: NumericContract
|
||||
className?: string
|
||||
hideText?: boolean
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
const { contract, className, hideText } = props
|
||||
const { resolution } = contract
|
||||
const textColor = `text-${getColor(contract)}`
|
||||
|
||||
const resolutionValue =
|
||||
contract.resolutionValue ?? getValueFromBucket(resolution ?? '', contract)
|
||||
|
@ -254,10 +219,12 @@ export function NumericResolutionOrExpectation(props: {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-3xl text-blue-400">
|
||||
<div className={clsx('text-3xl', textColor)}>
|
||||
{formatLargeNumber(getExpectedValue(contract))}
|
||||
</div>
|
||||
<div className="text-base text-blue-400">expected</div>
|
||||
{!hideText && (
|
||||
<div className={clsx('text-base', textColor)}>expected</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
|
|
@ -5,7 +5,9 @@ import {
|
|||
PencilIcon,
|
||||
CurrencyDollarIcon,
|
||||
TrendingUpIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import { StarIcon as SolidStarIcon } from '@heroicons/react/solid'
|
||||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { UserLink } from '../user-page'
|
||||
|
@ -26,20 +28,13 @@ import NewContractBadge from '../new-contract-badge'
|
|||
import { CATEGORY_LIST } from 'common/categories'
|
||||
import { TagsList } from '../tags-list'
|
||||
|
||||
export function AbbrContractDetails(props: {
|
||||
export function MiscDetails(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showCloseTime?: boolean
|
||||
}) {
|
||||
const { contract, showHotVolume, showCloseTime } = props
|
||||
const {
|
||||
volume,
|
||||
volume24Hours,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
closeTime,
|
||||
tags,
|
||||
} = contract
|
||||
const { volume, volume24Hours, closeTime, tags } = contract
|
||||
const { volumeLabel } = contractMetrics(contract)
|
||||
// Show at most one category that this contract is tagged by
|
||||
const categories = CATEGORY_LIST.filter((category) =>
|
||||
|
@ -47,41 +42,62 @@ export function AbbrContractDetails(props: {
|
|||
).slice(0, 1)
|
||||
|
||||
return (
|
||||
<Col className={clsx('gap-2 text-sm text-gray-500')}>
|
||||
<Row className="items-center justify-between">
|
||||
<Row className="items-center gap-2">
|
||||
<Avatar
|
||||
username={creatorUsername}
|
||||
avatarUrl={contract.creatorAvatarUrl}
|
||||
size={6}
|
||||
/>
|
||||
<UserLink name={creatorName} username={creatorUsername} />
|
||||
</Row>
|
||||
<Row className="items-center gap-3 text-sm text-gray-400">
|
||||
{categories.length > 0 && (
|
||||
<TagsList className="text-gray-400" tags={categories} noLabel />
|
||||
)}
|
||||
|
||||
<Row className="gap-3 text-gray-400">
|
||||
{categories.length > 0 && (
|
||||
<TagsList className="text-gray-400" tags={categories} noLabel />
|
||||
)}
|
||||
|
||||
{showHotVolume ? (
|
||||
<Row className="gap-0.5">
|
||||
<TrendingUpIcon className="h-5 w-5" />{' '}
|
||||
{formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
) : showCloseTime ? (
|
||||
<Row className="gap-0.5">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||
{fromNow(closeTime || 0)}
|
||||
</Row>
|
||||
) : volume > 0 ? (
|
||||
<Row>{volumeLabel}</Row>
|
||||
) : (
|
||||
<NewContractBadge />
|
||||
)}
|
||||
{showHotVolume ? (
|
||||
<Row className="gap-0.5">
|
||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
</Row>
|
||||
</Col>
|
||||
) : showCloseTime ? (
|
||||
<Row className="gap-0.5">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||
{fromNow(closeTime || 0)}
|
||||
</Row>
|
||||
) : volume > 0 ? (
|
||||
<Row>{volumeLabel}</Row>
|
||||
) : (
|
||||
<NewContractBadge />
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function AvatarDetails(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { creatorName, creatorUsername } = contract
|
||||
|
||||
return (
|
||||
<Row className="items-center gap-2 text-sm text-gray-500">
|
||||
<Avatar
|
||||
username={creatorUsername}
|
||||
avatarUrl={contract.creatorAvatarUrl}
|
||||
size={6}
|
||||
/>
|
||||
<UserLink name={creatorName} username={creatorUsername} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function AbbrContractDetails(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showCloseTime?: boolean
|
||||
}) {
|
||||
const { contract, showHotVolume, showCloseTime } = props
|
||||
return (
|
||||
<Row className="items-center justify-between">
|
||||
<AvatarDetails contract={contract} />
|
||||
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showCloseTime={showCloseTime}
|
||||
/>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -93,7 +109,7 @@ export function ContractDetails(props: {
|
|||
}) {
|
||||
const { contract, bets, isCreator, disabled } = props
|
||||
const { closeTime, creatorName, creatorUsername } = contract
|
||||
const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract)
|
||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
|
|
241
web/components/contract/quick-bet.tsx
Normal file
241
web/components/contract/quick-bet.tsx
Normal file
|
@ -0,0 +1,241 @@
|
|||
import clsx from 'clsx'
|
||||
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
|
||||
import { getExpectedValue } from 'common/calculate-dpm'
|
||||
import {
|
||||
Contract,
|
||||
FullContract,
|
||||
CPMM,
|
||||
DPM,
|
||||
Binary,
|
||||
NumericContract,
|
||||
FreeResponse,
|
||||
} from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
import { placeBet } from 'web/lib/firebase/api-call'
|
||||
import { getBinaryProb } from 'web/lib/firebase/contracts'
|
||||
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
|
||||
import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
|
||||
import { Col } from '../layout/col'
|
||||
import { OUTCOME_TO_COLOR } from '../outcome-label'
|
||||
import { useSaveShares } from '../use-save-shares'
|
||||
import {
|
||||
BinaryResolutionOrChance,
|
||||
NumericResolutionOrExpectation,
|
||||
FreeResponseResolutionOrChance,
|
||||
} from './contract-card'
|
||||
|
||||
export function QuickBet(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
|
||||
const user = useUser()
|
||||
const userBets = useUserContractBets(user?.id, contract.id)
|
||||
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
|
||||
contract as FullContract<CPMM | DPM, Binary>,
|
||||
userBets
|
||||
)
|
||||
// TODO: For some reason, Floor Shares are inverted for non-BINARY markets
|
||||
const hasUpShares =
|
||||
contract.outcomeType === 'BINARY' ? yesFloorShares : noFloorShares
|
||||
const hasDownShares =
|
||||
contract.outcomeType === 'BINARY' ? noFloorShares : yesFloorShares
|
||||
|
||||
const color = getColor(contract)
|
||||
|
||||
async function placeQuickBet(direction: 'UP' | 'DOWN') {
|
||||
const betPromise = async () => {
|
||||
const outcome = quickOutcome(contract, direction)
|
||||
return await placeBet({
|
||||
amount: 10,
|
||||
outcome,
|
||||
contractId: contract.id,
|
||||
})
|
||||
}
|
||||
const shortQ = contract.question.slice(0, 20)
|
||||
toast.promise(betPromise(), {
|
||||
loading: `${formatMoney(10)} on "${shortQ}"...`,
|
||||
success: `${formatMoney(10)} on "${shortQ}"...`,
|
||||
error: (err) => `${err.message}`,
|
||||
})
|
||||
}
|
||||
|
||||
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
|
||||
if (contract.outcomeType === 'BINARY') {
|
||||
return direction === 'UP' ? 'YES' : 'NO'
|
||||
}
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
// TODO: Implement shorting of free response answers
|
||||
if (direction === 'DOWN') {
|
||||
throw new Error("Can't short free response answers")
|
||||
}
|
||||
return getTopAnswer(contract)?.id
|
||||
}
|
||||
if (contract.outcomeType === 'NUMERIC') {
|
||||
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
|
||||
throw new Error("Can't quick bet on numeric markets")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx(
|
||||
'relative -my-4 -mr-5 min-w-[6rem] justify-center gap-2 pr-5 pl-3 align-middle',
|
||||
// Use this for colored QuickBet panes
|
||||
// `bg-opacity-10 bg-${color}`
|
||||
'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{/* Up bet triangle */}
|
||||
<div>
|
||||
<div
|
||||
className="peer absolute top-0 left-0 right-0 h-[50%]"
|
||||
onClick={() => placeQuickBet('UP')}
|
||||
></div>
|
||||
<div className="mt-2 text-center text-xs text-transparent peer-hover:text-gray-400">
|
||||
{formatMoney(10)}
|
||||
</div>
|
||||
|
||||
{hasUpShares > 0 ? (
|
||||
<TriangleFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
`text-${color} text-opacity-70 peer-hover:text-gray-400`
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TriangleFillIcon className="mx-auto h-5 w-5 text-gray-200 peer-hover:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<QuickOutcomeView contract={contract} />
|
||||
|
||||
{/* Down bet triangle */}
|
||||
<div>
|
||||
<div
|
||||
className="peer absolute bottom-0 left-0 right-0 h-[50%]"
|
||||
onClick={() => placeQuickBet('DOWN')}
|
||||
></div>
|
||||
{hasDownShares > 0 ? (
|
||||
<TriangleDownFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
`text-${color} text-opacity-70 peer-hover:text-gray-400`
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TriangleDownFillIcon className="mx-auto h-5 w-5 text-gray-200 peer-hover:text-gray-400" />
|
||||
)}
|
||||
<div className="mb-2 text-center text-xs text-transparent peer-hover:text-gray-400">
|
||||
{formatMoney(10)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProbBar(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const color = getColor(contract)
|
||||
const prob = getProb(contract)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute right-0 top-0 w-2 rounded-tr-md transition-all',
|
||||
'bg-gray-200'
|
||||
)}
|
||||
style={{ height: `${100 * (1 - prob)}%` }}
|
||||
></div>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute right-0 bottom-0 w-2 rounded-br-md transition-all',
|
||||
`bg-${color}`,
|
||||
// If we're showing the full bar, also round the top
|
||||
prob === 1 ? 'rounded-tr-md' : ''
|
||||
)}
|
||||
style={{ height: `${100 * prob}%` }}
|
||||
></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuickOutcomeView(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { outcomeType } = contract
|
||||
return (
|
||||
<>
|
||||
{outcomeType === 'BINARY' && (
|
||||
<BinaryResolutionOrChance
|
||||
className="items-center"
|
||||
contract={contract}
|
||||
hideText
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
className="items-center"
|
||||
contract={contract as NumericContract}
|
||||
hideText
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
<FreeResponseResolutionOrChance
|
||||
className="self-end text-gray-600"
|
||||
contract={contract as FullContract<DPM, FreeResponse>}
|
||||
truncate="long"
|
||||
hideText
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Return a number from 0 to 1 for this contract
|
||||
// Resolved contracts are set to 1, for coloring purposes (even if NO)
|
||||
function getProb(contract: Contract) {
|
||||
const { outcomeType, resolution } = contract
|
||||
return resolution
|
||||
? 1
|
||||
: outcomeType === 'BINARY'
|
||||
? getBinaryProb(contract)
|
||||
: outcomeType === 'FREE_RESPONSE'
|
||||
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
||||
: outcomeType === 'NUMERIC'
|
||||
? getNumericScale(contract as NumericContract)
|
||||
: 1 // Should not happen
|
||||
}
|
||||
|
||||
function getNumericScale(contract: NumericContract) {
|
||||
const { min, max } = contract
|
||||
const ev = getExpectedValue(contract)
|
||||
return (ev - min) / (max - min)
|
||||
}
|
||||
|
||||
export function getColor(contract: Contract) {
|
||||
// TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind
|
||||
// TODO: Try injecting a gradient here
|
||||
// return 'primary'
|
||||
const { resolution } = contract
|
||||
if (resolution) {
|
||||
return (
|
||||
// @ts-ignore; TODO: Have better typing for contract.resolution?
|
||||
OUTCOME_TO_COLOR[resolution] ||
|
||||
// If resolved to a FR answer, use 'primary'
|
||||
'primary'
|
||||
)
|
||||
}
|
||||
if (contract.outcomeType === 'NUMERIC') {
|
||||
return 'blue-400'
|
||||
}
|
||||
|
||||
const marketClosed = (contract.closeTime || Infinity) < Date.now()
|
||||
return marketClosed
|
||||
? 'gray-400'
|
||||
: getProb(contract) >= 0.5
|
||||
? 'primary'
|
||||
: 'red-400'
|
||||
}
|
|
@ -84,11 +84,7 @@ export function FreeResponseOutcomeLabel(props: {
|
|||
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||
return (
|
||||
<FreeResponseAnswerToolTip text={chosen.text}>
|
||||
<AnswerLabel
|
||||
answer={chosen}
|
||||
truncate={truncate}
|
||||
className={answerClassName}
|
||||
/>
|
||||
<span className="text-blue-400">TOP</span>
|
||||
</FreeResponseAnswerToolTip>
|
||||
)
|
||||
}
|
||||
|
@ -117,7 +113,7 @@ export function ProbLabel() {
|
|||
}
|
||||
|
||||
export function MultiLabel() {
|
||||
return <span className="text-blue-400">MULTI</span>
|
||||
return <span className="text-blue-400">MANY</span>
|
||||
}
|
||||
|
||||
export function ProbPercentLabel(props: { prob: number }) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import clsx from 'clsx'
|
||||
import { BottomNavBar } from './nav/nav-bar'
|
||||
import Sidebar from './nav/sidebar'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
export function Page(props: {
|
||||
margin?: boolean
|
||||
|
@ -20,6 +21,7 @@ export function Page(props: {
|
|||
)}
|
||||
style={suspend ? visuallyHiddenStyle : undefined}
|
||||
>
|
||||
<Toaster />
|
||||
<Sidebar className="sticky top-4 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" />
|
||||
<main
|
||||
className={clsx(
|
||||
|
|
17
web/lib/icons/triangle-down-fill-icon.tsx
Normal file
17
web/lib/icons/triangle-down-fill-icon.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
// Icon from Bootstrap: https://icons.getbootstrap.com/
|
||||
export default function TriangleDownFillIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
transform="rotate(-180 8 8)"
|
||||
d="M7.022 1.566a1.13 1.13 0 0 1 1.96 0l6.857 11.667c.457.778-.092 1.767-.98 1.767H1.144c-.889 0-1.437-.99-.98-1.767L7.022 1.566z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
16
web/lib/icons/triangle-fill-icon.tsx
Normal file
16
web/lib/icons/triangle-fill-icon.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Icon from Bootstrap: https://icons.getbootstrap.com/
|
||||
export default function TriangleFillIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.022 1.566a1.13 1.13 0 0 1 1.96 0l6.857 11.667c.457.778-.092 1.767-.98 1.767H1.144c-.889 0-1.437-.99-.98-1.767L7.022 1.566z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
|
@ -35,6 +35,7 @@
|
|||
"react-confetti": "6.0.1",
|
||||
"react-dom": "17.0.2",
|
||||
"react-expanding-textarea": "2.3.5",
|
||||
"react-hot-toast": "^2.2.0",
|
||||
"react-instantsearch-hooks-web": "6.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -2974,6 +2974,11 @@ globby@^11.1.0:
|
|||
merge2 "^1.4.1"
|
||||
slash "^3.0.0"
|
||||
|
||||
goober@^2.1.1:
|
||||
version "2.1.9"
|
||||
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.9.tgz#0faee08fab1a5d55b23e9ec043bb5a1b46fa025a"
|
||||
integrity sha512-PAtnJbrWtHbfpJUIveG5PJIB6Mc9Kd0gimu9wZwPyA+wQUSeOeA4x4Ug16lyaaUUKZ/G6QEH1xunKOuXP1F4Vw==
|
||||
|
||||
google-auth-library@^7.14.0, google-auth-library@^7.14.1:
|
||||
version "7.14.1"
|
||||
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c"
|
||||
|
@ -4501,6 +4506,13 @@ react-expanding-textarea@2.3.5:
|
|||
react-with-forwarded-ref "^0.3.3"
|
||||
tslib "^2.0.3"
|
||||
|
||||
react-hot-toast@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.2.0.tgz#ab6f4caed4214b9534f94bb8cfaaf21b051e62b9"
|
||||
integrity sha512-248rXw13uhf/6TNDVzagX+y7R8J183rp7MwUMNkcrBRyHj/jWOggfXTGlM8zAOuh701WyVW+eUaWG2LeSufX9g==
|
||||
dependencies:
|
||||
goober "^2.1.1"
|
||||
|
||||
react-instantsearch-hooks-web@6.24.1:
|
||||
version "6.24.1"
|
||||
resolved "https://registry.yarnpkg.com/react-instantsearch-hooks-web/-/react-instantsearch-hooks-web-6.24.1.tgz#392be70c584583f3cd9fe22eda5a59f7449e5ac9"
|
||||
|
|
Loading…
Reference in New Issue
Block a user