Improve Free response UI (#78)

* Add legend to free response graph

* Hide answers panel unless resolving. Correctly order answers

* No gray background for add answer & resolve panel. Tweak spacing

* Max answer length 240 chars

* Show answer text in resolution for market page, card instead of number.

* Remove remaining answer #'s. Refactor outcome/resolution labels.

* Move answer panel back up

* Tweak spacing

* Update placement of bet button on mobile for FR answer feed item

* Fix reversed feed for binary markets

* Show multi resolve options

* Clean up unused parts of answer item

* Lighten resolve buttons

* Show answer text in market resolve email
This commit is contained in:
James Grugett 2022-04-18 18:02:40 -05:00 committed by GitHub
parent 7abc11c146
commit 9c74f88b4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 448 additions and 289 deletions

View File

@ -29,3 +29,5 @@ export const getNoneAnswer = (contractId: string, creator: User) => {
text: 'None',
}
}
export const MAX_ANSWER_LENGTH = 240

View File

@ -38,6 +38,8 @@ export type FullContract<
T
export type Contract = FullContract<DPM | CPMM, Binary | Multi | FreeResponse>
export type BinaryContract = FullContract<DPM | CPMM, Binary>
export type FreeResponseContract = FullContract<DPM | CPMM, FreeResponse>
export type DPM = {
mechanism: 'dpm-2'
@ -61,18 +63,20 @@ export type Binary = {
outcomeType: 'BINARY'
initialProbability: number
resolutionProbability?: number // Used for BINARY markets resolved to MKT
resolution?: 'YES' | 'NO' | 'MKT' | 'CANCEL'
}
export type Multi = {
outcomeType: 'MULTI'
multiOutcomes: string[] // Used for outcomeType 'MULTI'.
resolutions?: { [outcome: string]: number } // Used for PROB
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
}
export type FreeResponse = {
outcomeType: 'FREE_RESPONSE'
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
resolutions?: { [outcome: string]: number } // Used for PROB
resolution?: string | 'MKT' | 'CANCEL'
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
}
export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE'

View File

@ -9,7 +9,7 @@ import {
} from '../../common/contract'
import { User } from '../../common/user'
import { getLoanAmount, getNewMultiBetInfo } from '../../common/new-bet'
import { Answer } from '../../common/answer'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { Bet } from '../../common/bet'
@ -31,7 +31,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
if (!text || typeof text !== 'string' || text.length > 10000)
if (!text || typeof text !== 'string' || text.length > MAX_ANSWER_LENGTH)
return { status: 'error', message: 'Invalid text' }
// Run as transaction to prevent race conditions.

View File

@ -5,7 +5,7 @@ import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract'
import { Contract, FreeResponseContract } from '../../common/contract'
import { CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format'
@ -98,6 +98,10 @@ const toDisplayResolution = (
if (resolution === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A'
const answer = (contract as FreeResponseContract).answers?.find(
(a) => a.id === resolution
)
if (answer) return answer.text
return `#${resolution}`
}

View File

@ -1,6 +1,5 @@
import clsx from 'clsx'
import _ from 'lodash'
import { useState } from 'react'
import { Answer } from '../../../common/answer'
import { DPM, FreeResponse, FullContract } from '../../../common/contract'
@ -8,19 +7,14 @@ import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Avatar } from '../avatar'
import { SiteLink } from '../site-link'
import { BuyButton } from '../yes-no-selector'
import { formatPercent } from '../../../common/util/format'
import { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
import { tradingAllowed } from '../../lib/firebase/contracts'
import { AnswerBetPanel } from './answer-bet-panel'
import { Linkify } from '../linkify'
import { User } from '../../../common/user'
import { ContractActivity } from '../feed/contract-activity'
export function AnswerItem(props: {
answer: Answer
contract: FullContract<DPM, FreeResponse>
user: User | null | undefined
showChoice: 'radio' | 'checkbox' | undefined
chosenProb: number | undefined
totalChosenProb?: number
@ -30,7 +24,6 @@ export function AnswerItem(props: {
const {
answer,
contract,
user,
showChoice,
chosenProb,
totalChosenProb,
@ -47,10 +40,6 @@ export function AnswerItem(props: {
const wasResolvedTo =
resolution === answer.id || (resolutions && resolutions[answer.id])
const [isBetting, setIsBetting] = useState(false)
const canBet = !isBetting && !showChoice && tradingAllowed(contract)
return (
<div
className={clsx(
@ -63,10 +52,8 @@ export function AnswerItem(props: {
? 'bg-gray-50'
: showChoice === 'radio'
? 'bg-green-50'
: 'bg-blue-50',
canBet && 'cursor-pointer hover:bg-gray-100'
: 'bg-blue-50'
)}
onClick={() => canBet && setIsBetting(true)}
>
<Col className="flex-1 gap-3">
<div className="whitespace-pre-line">
@ -83,124 +70,91 @@ export function AnswerItem(props: {
{/* TODO: Show total pool? */}
<div className="text-base">#{number}</div>
</Row>
{isBetting && (
<ContractActivity
className="hidden md:flex"
contract={contract}
bets={[]}
comments={[]}
user={user}
filterToOutcome={answer.id}
mode="all"
/>
)}
</Col>
{isBetting ? (
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setIsBetting(false)}
className="sm:w-72"
/>
) : (
<Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo &&
(showChoice === 'checkbox' ? (
<input
className="input input-bordered w-24 justify-self-end text-2xl"
type="number"
placeholder={`${roundedProb}`}
maxLength={9}
value={chosenProb ? Math.round(chosenProb) : ''}
onChange={(e) => {
const { value } = e.target
const numberValue = value
? parseInt(value.replace(/[^\d]/, ''))
: 0
if (!isNaN(numberValue)) onChoose(answer.id, numberValue)
}}
/>
) : (
<div
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
)}
>
{probPercent}
</div>
))}
{showChoice ? (
<div className="form-control py-1">
<label className="label cursor-pointer gap-3">
<span className="">Choose this answer</span>
{showChoice === 'radio' && (
<input
className={clsx('radio', chosenProb && '!bg-green-500')}
type="radio"
name="opt"
checked={isChosen}
onChange={() => onChoose(answer.id, 1)}
value={answer.id}
/>
)}
{showChoice === 'checkbox' && (
<input
className={clsx('checkbox', chosenProb && '!bg-blue-500')}
type="checkbox"
name="opt"
checked={isChosen}
onChange={() => {
if (isChosen) onDeselect(answer.id)
else {
onChoose(answer.id, 100 * prob)
}
}}
value={answer.id}
/>
)}
</label>
{showChoice === 'checkbox' && (
<div className="ml-1">
{chosenProb && totalChosenProb
? Math.round((100 * chosenProb) / totalChosenProb)
: 0}
% share
</div>
)}
</div>
<Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo &&
(showChoice === 'checkbox' ? (
<input
className="input input-bordered w-24 justify-self-end text-2xl"
type="number"
placeholder={`${roundedProb}`}
maxLength={9}
value={chosenProb ? Math.round(chosenProb) : ''}
onChange={(e) => {
const { value } = e.target
const numberValue = value
? parseInt(value.replace(/[^\d]/, ''))
: 0
if (!isNaN(numberValue)) onChoose(answer.id, numberValue)
}}
/>
) : (
<>
{tradingAllowed(contract) && (
<BuyButton
className="btn-md flex-initial justify-end self-end !px-8"
onClick={() => {
setIsBetting(true)
}}
<div
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
)}
>
{probPercent}
</div>
))}
{showChoice ? (
<div className="form-control py-1">
<label className="label cursor-pointer gap-3">
<span className="">Choose this answer</span>
{showChoice === 'radio' && (
<input
className={clsx('radio', chosenProb && '!bg-green-500')}
type="radio"
name="opt"
checked={isChosen}
onChange={() => onChoose(answer.id, 1)}
value={answer.id}
/>
)}
{wasResolvedTo && (
<Col className="items-end">
<div
className={clsx(
'text-xl',
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700'
)}
>
Chosen{' '}
{resolutions
? `${Math.round(resolutions[answer.id])}%`
: ''}
</div>
<div className="text-2xl text-gray-500">{probPercent}</div>
</Col>
{showChoice === 'checkbox' && (
<input
className={clsx('checkbox', chosenProb && '!bg-blue-500')}
type="checkbox"
name="opt"
checked={isChosen}
onChange={() => {
if (isChosen) onDeselect(answer.id)
else {
onChoose(answer.id, 100 * prob)
}
}}
value={answer.id}
/>
)}
</>
)}
</Row>
)}
</label>
{showChoice === 'checkbox' && (
<div className="ml-1">
{chosenProb && totalChosenProb
? Math.round((100 * chosenProb) / totalChosenProb)
: 0}
% share
</div>
)}
</div>
) : (
wasResolvedTo && (
<Col className="items-end">
<div
className={clsx(
'text-xl',
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700'
)}
>
Chosen{' '}
{resolutions ? `${Math.round(resolutions[answer.id])}%` : ''}
</div>
<div className="text-2xl text-gray-500">{probPercent}</div>
</Col>
)
)}
</Row>
</div>
)
}

View File

@ -71,7 +71,7 @@ export function AnswerResolvePanel(props: {
: 'btn-disabled'
return (
<Col className="gap-4 rounded bg-gray-50 p-4">
<Col className="gap-4 rounded">
<div>Resolve your market</div>
<Col className="gap-4 sm:flex-row sm:items-center">
<ChooseCancelSelector

View File

@ -38,7 +38,8 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
const { width } = useWindowSize()
const labelLength = !width || width > 800 ? 50 : 20
const isLargeWidth = !width || width > 800
const labelLength = isLargeWidth ? 50 : 20
const endTime =
resolutionTime || isClosed
@ -68,16 +69,15 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
answers?.find((answer) => answer.id === outcome)?.text ?? 'None'
const answerText =
answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '')
const id = `#${outcome}: ${answerText}`
return { id, data: points }
return { id: answerText, data: points }
})
data.reverse()
const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = !width || width < 800 ? 2 : 5
const numXTickValues = isLargeWidth ? 5 : 2
const hoursAgo = latestTime.subtract(5, 'hours')
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo)
? new Date(contract.createdTime)
@ -87,8 +87,8 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
return (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 350 : 225) }}
className="w-full"
style={{ height: height ?? (isLargeWidth ? 350 : 250) }}
>
<ResponsiveLine
data={data}
@ -116,6 +116,32 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
enableArea
areaOpacity={1}
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
legends={[
{
anchor: 'top-left',
direction: 'column',
justify: false,
translateX: isLargeWidth ? 5 : 2,
translateY: 0,
itemsSpacing: 0,
itemTextColor: 'black',
itemDirection: 'left-to-right',
itemWidth: isLargeWidth ? 288 : 138,
itemHeight: 20,
itemBackground: 'white',
itemOpacity: 0.9,
symbolSize: 12,
effects: [
{
on: 'hover',
style: {
itemBackground: 'rgba(255, 255, 255, 1)',
itemOpacity: 1,
},
},
],
},
]}
/>
</div>
)

View File

@ -3,7 +3,6 @@ import { useLayoutEffect, useState } from 'react'
import { DPM, FreeResponse, FullContract } from '../../../common/contract'
import { Col } from '../layout/col'
import { formatPercent } from '../../../common/util/format'
import { useUser } from '../../hooks/use-user'
import { getDpmOutcomeProbability } from '../../../common/calculate-dpm'
import { useAnswers } from '../../hooks/use-answers'
@ -11,6 +10,7 @@ import { tradingAllowed } from '../../lib/firebase/contracts'
import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
export function AnswersPanel(props: {
contract: FullContract<DPM, FreeResponse>
@ -31,7 +31,7 @@ export function AnswersPanel(props: {
resolutions ? -1 * resolutions[answer.id] : 0
),
..._.sortBy(
otherAnswers,
resolution ? [] : otherAnswers,
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
),
]
@ -82,40 +82,41 @@ export function AnswersPanel(props: {
return (
<Col className="gap-3">
{sortedAnswers.map((answer) => (
<AnswerItem
key={answer.id}
answer={answer}
contract={contract}
user={user}
showChoice={showChoice}
chosenProb={chosenAnswers[answer.id]}
totalChosenProb={chosenTotal}
onChoose={onChoose}
onDeselect={onDeselect}
/>
))}
{(resolveOption === 'CHOOSE' ||
resolveOption === 'CHOOSE_MULTIPLE' ||
resolution === 'MKT') &&
sortedAnswers.map((answer) => (
<AnswerItem
key={answer.id}
answer={answer}
contract={contract}
showChoice={showChoice}
chosenProb={chosenAnswers[answer.id]}
totalChosenProb={chosenTotal}
onChoose={onChoose}
onDeselect={onDeselect}
/>
))}
{sortedAnswers.length === 0 ? (
<div className="p-4 text-gray-500">No answers yet...</div>
) : (
<div className="self-end p-4 text-gray-500">
None of the above:{' '}
{formatPercent(getDpmOutcomeProbability(contract.totalShares, '0'))}
</div>
{sortedAnswers.length === 0 && (
<div className="pb-4 text-gray-500">No answers yet...</div>
)}
{tradingAllowed(contract) && !resolveOption && (
<CreateAnswerPanel contract={contract} />
)}
{tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} />
)}
{user?.id === creatorId && !resolution && (
<AnswerResolvePanel
contract={contract}
resolveOption={resolveOption}
setResolveOption={setResolveOption}
chosenAnswers={chosenAnswers}
/>
<>
<Spacer h={2} />
<AnswerResolvePanel
contract={contract}
resolveOption={resolveOption}
setResolveOption={setResolveOption}
chosenAnswers={chosenAnswers}
/>
</>
)}
</Col>
)

View File

@ -22,6 +22,7 @@ import {
} from '../../../common/calculate-dpm'
import { firebaseLogin } from '../../lib/firebase/users'
import { Bet } from '../../../common/bet'
import { MAX_ANSWER_LENGTH } from '../../../common/answer'
export function CreateAnswerPanel(props: {
contract: FullContract<DPM, FreeResponse>
@ -75,7 +76,7 @@ export function CreateAnswerPanel(props: {
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
return (
<Col className="gap-4 rounded bg-gray-50 p-4">
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2">
<div className="mb-1">Add your answer</div>
<Textarea
@ -84,7 +85,7 @@ export function CreateAnswerPanel(props: {
className="textarea textarea-bordered w-full resize-none"
placeholder="Type your answer..."
rows={1}
maxLength={10000}
maxLength={MAX_ANSWER_LENGTH}
/>
<div />
<Col

View File

@ -19,7 +19,7 @@ import { Bet } from '../../common/bet'
import { placeBet, sellShares } from '../lib/firebase/api-call'
import { BuyAmountInput, SellAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { OutcomeLabel } from './outcome-label'
import { BinaryOutcomeLabel, OutcomeLabel } from './outcome-label'
import {
calculatePayoutAfterCorrectBet,
calculateShares,
@ -58,7 +58,7 @@ export function BetPanel(props: {
<Row className="items-center justify-between gap-2">
<div>
You have {formatWithCommas(Math.floor(shares))}{' '}
<OutcomeLabel outcome={sharesOutcome} /> shares
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
</div>
<button
@ -147,7 +147,7 @@ export function BetPanelSwitcher(props: {
<Row className="items-center justify-between gap-2">
<div>
You have {formatWithCommas(Math.floor(shares))}{' '}
<OutcomeLabel outcome={sharesOutcome} /> shares
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
</div>
<button
@ -349,11 +349,12 @@ function BuyPanel(props: {
{contract.mechanism === 'dpm-2' ? (
<>
Estimated
<br /> payout if <OutcomeLabel outcome={betChoice ?? 'YES'} />
<br /> payout if{' '}
<BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
</>
) : (
<>
Payout if <OutcomeLabel outcome={betChoice ?? 'YES'} />
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
</>
)}
</div>
@ -547,7 +548,12 @@ function SellSharesModal(props: {
<div className="mb-6">
You have {formatWithCommas(Math.floor(shares))}{' '}
<OutcomeLabel outcome={sharesOutcome} /> shares
<OutcomeLabel
outcome={sharesOutcome}
contract={contract}
truncate="long"
/>{' '}
shares
</div>
<SellPanel

View File

@ -250,11 +250,16 @@ function MyContractBets(props: {
</Row>
<Row className="flex-1 items-center gap-2 text-sm text-gray-500">
{isBinary && (
{(isBinary || resolution) && (
<>
{resolution ? (
<div>
Resolved <OutcomeLabel outcome={resolution} />
Resolved{' '}
<OutcomeLabel
outcome={resolution}
contract={contract}
truncate="short"
/>
</div>
) : (
<div className="text-primary text-lg">{probPercent}</div>
@ -510,7 +515,15 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
</td>
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
<td>
<OutcomeLabel outcome={outcome} />
{outcome === '0' ? (
'ANTE'
) : (
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
)}
</td>
<td>{formatMoney(Math.abs(amount))}</td>
{!isCPMM && <td>{saleDisplay}</td>}
@ -561,7 +574,8 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
>
<div className="mb-4 text-2xl">
Sell {formatWithCommas(shares)} shares of{' '}
<OutcomeLabel outcome={outcome} /> for {formatMoney(saleAmount)}?
<OutcomeLabel outcome={outcome} contract={contract} truncate="long" />{' '}
for {formatMoney(saleAmount)}?
</div>
{!!loanAmount && (
<div className="mt-2">

View File

@ -3,7 +3,7 @@ import Link from 'next/link'
import { ClockIcon, DatabaseIcon, PencilIcon } from '@heroicons/react/outline'
import { TrendingUpIcon } from '@heroicons/react/solid'
import { Row } from '../layout/row'
import { formatMoney, formatPercent } from '../../../common/util/format'
import { formatMoney } from '../../../common/util/format'
import { UserLink } from '../user-page'
import {
Contract,
@ -19,9 +19,20 @@ import { fromNow } from '../../lib/util/time'
import { Avatar } from '../avatar'
import { Spacer } from '../layout/spacer'
import { useState } from 'react'
import { getProbability } from '../../../common/calculate'
import { ContractInfoDialog } from './contract-info-dialog'
import { Bet } from '../../../common/bet'
import {
Binary,
CPMM,
DPM,
FreeResponse,
FreeResponseContract,
FullContract,
} from '../../../common/contract'
import {
BinaryContractOutcomeLabel,
FreeResponseOutcomeLabel,
} from '../outcome-label'
export function ContractCard(props: {
contract: Contract
@ -30,7 +41,7 @@ export function ContractCard(props: {
className?: string
}) {
const { contract, showHotVolume, showCloseTime, className } = props
const { question } = contract
const { question, outcomeType, resolution } = contract
return (
<div>
@ -51,54 +62,48 @@ export function ContractCard(props: {
/>
<Spacer h={3} />
<Row className="justify-between gap-4">
<Row
className={clsx(
'justify-between gap-4',
outcomeType === 'FREE_RESPONSE' && 'flex-col items-start'
)}
>
<p
className="break-words font-medium text-indigo-700"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
>
{question}
</p>
<ResolutionOrChance className="items-center" contract={contract} />
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'FREE_RESPONSE' && resolution && (
<FreeResponseResolution
contract={contract as FullContract<DPM, FreeResponse>}
resolution={resolution}
truncate="long"
/>
)}
</Row>
</div>
</div>
)
}
export function ResolutionOrChance(props: {
contract: Contract
export function BinaryResolutionOrChance(props: {
contract: FullContract<DPM | CPMM, Binary>
large?: boolean
className?: string
}) {
const { contract, large, className } = props
const { resolution, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const { resolution } = contract
const marketClosed = (contract.closeTime || Infinity) < Date.now()
const resolutionColor =
{
YES: 'text-primary',
NO: 'text-red-400',
MKT: 'text-blue-400',
CANCEL: 'text-yellow-400',
'': '', // Empty if unresolved
}[resolution || ''] ?? 'text-primary'
const probColor = marketClosed ? 'text-gray-400' : 'text-primary'
const resolutionText =
{
YES: 'YES',
NO: 'NO',
MKT: isBinary
? formatPercent(
contract.resolutionProbability ?? getProbability(contract)
)
: 'MULTI',
CANCEL: 'N/A',
'': '',
}[resolution || ''] ?? `#${resolution}`
return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
{resolution ? (
@ -108,22 +113,41 @@ export function ResolutionOrChance(props: {
>
Resolved
</div>
<div className={resolutionColor}>{resolutionText}</div>
<BinaryContractOutcomeLabel
contract={contract}
resolution={resolution}
/>
</>
) : (
isBinary && (
<>
<div className={probColor}>{getBinaryProbPercent(contract)}</div>
<div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
chance
</div>
</>
)
<>
<div className={probColor}>{getBinaryProbPercent(contract)}</div>
<div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
chance
</div>
</>
)}
</Col>
)
}
export function FreeResponseResolution(props: {
contract: FreeResponseContract
resolution: string
truncate: 'short' | 'long' | 'none'
}) {
const { contract, resolution, truncate } = props
return (
<Col className="text-xl">
<div className={clsx('text-base text-gray-500')}>Resolved</div>
<FreeResponseOutcomeLabel
contract={contract}
resolution={resolution}
truncate={truncate}
/>
</Col>
)
}
function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean

View File

@ -6,7 +6,11 @@ import { useUser } from '../../hooks/use-user'
import { Row } from '../layout/row'
import { Linkify } from '../linkify'
import clsx from 'clsx'
import { ContractDetails, ResolutionOrChance } from './contract-card'
import {
FreeResponseResolution,
ContractDetails,
BinaryResolutionOrChance,
} from './contract-card'
import { Bet } from '../../../common/bet'
import { Comment } from '../../../common/comment'
import BetRow from '../bet-row'
@ -18,11 +22,10 @@ export const ContractOverview = (props: {
contract: Contract
bets: Bet[]
comments: Comment[]
children?: any
className?: string
}) => {
const { contract, bets, comments, children, className } = props
const { question, resolution, creatorId, outcomeType } = contract
const { contract, bets, className } = props
const { question, creatorId, outcomeType, resolution } = contract
const user = useUser()
const isCreator = user?.id === creatorId
@ -36,8 +39,8 @@ export const ContractOverview = (props: {
<Linkify text={question} />
</div>
{(isBinary || resolution) && (
<ResolutionOrChance
{isBinary && (
<BinaryResolutionOrChance
className="hidden items-end xl:flex"
contract={contract}
large
@ -45,15 +48,24 @@ export const ContractOverview = (props: {
)}
</Row>
<Row className="items-center justify-between gap-4 xl:hidden">
{(isBinary || resolution) && (
<ResolutionOrChance contract={contract} />
)}
{isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
{isBinary && tradingAllowed(contract) && (
<BetRow contract={contract} labelClassName="hidden" />
)}
</Row>
{tradingAllowed(contract) && (
<BetRow contract={contract} labelClassName="hidden" />
)}
</Row>
) : (
outcomeType === 'FREE_RESPONSE' &&
resolution && (
<FreeResponseResolution
contract={contract}
resolution={resolution}
truncate="none"
/>
)
)}
<ContractDetails
contract={contract}
@ -73,19 +85,13 @@ export const ContractOverview = (props: {
/>
)}
<Spacer h={6} />
{contract.description && <Spacer h={6} />}
<ContractDescription
className="px-2"
contract={contract}
isCreator={isCreator}
/>
<Spacer h={4} />
{children}
<Spacer h={4} />
</Col>
)
}

View File

@ -97,9 +97,10 @@ function groupBets(
hideOutcome: boolean
abbreviated: boolean
smallAvatar: boolean
reversed: boolean
}
) {
const { hideOutcome, abbreviated, smallAvatar } = options
const { hideOutcome, abbreviated, smallAvatar, reversed } = options
const commentsMap = mapCommentsByBetId(comments)
const items: ActivityItem[] = []
@ -171,7 +172,9 @@ function groupBets(
if (group.length > 0) {
pushGroup()
}
return abbreviated ? items.slice(-3) : items
const abbrItems = abbreviated ? items.slice(-3) : items
if (reversed) abbrItems.reverse()
return abbrItems
}
function getAnswerGroups(
@ -182,9 +185,10 @@ function getAnswerGroups(
options: {
sortByProb: boolean
abbreviated: boolean
reversed: boolean
}
) {
const { sortByProb, abbreviated } = options
const { sortByProb, abbreviated, reversed } = options
let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter(
(outcome) => getOutcomeProbability(contract, outcome) > 0.0001
@ -208,9 +212,8 @@ function getAnswerGroups(
outcomes = outcomes.slice(-2)
}
if (sortByProb) {
outcomes = _.sortBy(
outcomes,
(outcome) => -1 * getOutcomeProbability(contract, outcome)
outcomes = _.sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
)
} else {
// Sort by recent bet.
@ -233,6 +236,7 @@ function getAnswerGroups(
hideOutcome: true,
abbreviated,
smallAvatar: true,
reversed,
})
if (abbreviated) items = items.slice(-2)
@ -264,6 +268,8 @@ export function getAllContractActivityItems(
const { abbreviated } = options
const { outcomeType } = contract
const reversed = !abbreviated
bets =
outcomeType === 'BINARY'
? bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
@ -301,12 +307,14 @@ export function getAllContractActivityItems(
{
sortByProb: true,
abbreviated,
reversed,
}
)
: groupBets(bets, comments, contract, user?.id, {
hideOutcome: !!filterToOutcome,
abbreviated,
smallAvatar: !!filterToOutcome,
reversed: false,
}))
)
@ -317,14 +325,7 @@ export function getAllContractActivityItems(
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
}
if (!abbreviated) {
items.reverse()
for (const item of items) {
if (item.type === 'answergroup') {
item.items.reverse()
}
}
}
if (reversed) items.reverse()
return items
}
@ -362,12 +363,14 @@ export function getRecentContractActivityItems(
{
sortByProb: false,
abbreviated: true,
reversed: false,
}
)
: groupBets(bets, comments, contract, user?.id, {
hideOutcome: false,
abbreviated: true,
smallAvatar: false,
reversed: false,
})
return [questionItem, ...items]

View File

@ -26,7 +26,7 @@ import { Row } from '../layout/row'
import { createComment, MAX_COMMENT_LENGTH } from '../../lib/firebase/comments'
import { formatMoney, formatPercent } from '../../../common/util/format'
import { Comment } from '../../../common/comment'
import { ResolutionOrChance } from '../contract/contract-card'
import { BinaryResolutionOrChance } from '../contract/contract-card'
import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { UserLink } from '../user-page'
@ -144,7 +144,12 @@ export function FeedComment(props: {
{!hideOutcome && (
<>
{' '}
of <OutcomeLabel outcome={outcome} />
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}
<RelativeTimestamp time={createdTime} />
@ -227,7 +232,12 @@ export function FeedBet(props: {
{!hideOutcome && (
<>
{' '}
of <OutcomeLabel outcome={outcome} />
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}
<RelativeTimestamp time={createdTime} />
@ -344,8 +354,11 @@ export function FeedQuestion(props: {
{question}
</SiteLink>
</Col>
{(isBinary || resolution) && (
<ResolutionOrChance className="items-center" contract={contract} />
{isBinary && (
<BinaryResolutionOrChance
className="items-center"
contract={contract}
/>
)}
</Col>
{showDescription && (
@ -449,7 +462,12 @@ function FeedResolve(props: { contract: Contract }) {
name={creatorName}
username={creatorUsername}
/>{' '}
resolved this market to <OutcomeLabel outcome={resolution} />{' '}
resolved this market to{' '}
<OutcomeLabel
outcome={resolution}
contract={contract}
truncate="long"
/>{' '}
<RelativeTimestamp time={contract.resolutionTime || 0} />
</div>
</div>
@ -482,8 +500,12 @@ function FeedClose(props: { contract: Contract }) {
)
}
function BetGroupSpan(props: { bets: Bet[]; outcome?: string }) {
const { bets, outcome } = props
function BetGroupSpan(props: {
contract: Contract
bets: Bet[]
outcome?: string
}) {
const { contract, bets, outcome } = props
const numberTraders = _.uniqBy(bets, (b) => b.userId).length
@ -501,7 +523,12 @@ function BetGroupSpan(props: { bets: Bet[]; outcome?: string }) {
{outcome && (
<>
{' '}
of <OutcomeLabel outcome={outcome} />
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}{' '}
</span>
@ -513,7 +540,7 @@ function FeedBetGroup(props: {
bets: Bet[]
hideOutcome: boolean
}) {
const { bets, hideOutcome } = props
const { contract, bets, hideOutcome } = props
const betGroups = _.groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betGroups)
@ -535,6 +562,7 @@ function FeedBetGroup(props: {
{outcomes.map((outcome, index) => (
<Fragment key={outcome}>
<BetGroupSpan
contract={contract}
outcome={hideOutcome ? undefined : outcome}
bets={betGroups[outcome]}
/>
@ -583,7 +611,7 @@ function FeedAnswerGroup(props: {
<UserLink username={username} name={name} /> answered
</div>
<Row className="align-items justify-between gap-4">
<Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
@ -599,13 +627,13 @@ function FeedAnswerGroup(props: {
</span>
<BuyButton
className={clsx(
'btn-sm hidden flex-initial !px-6 sm:flex',
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</Row>
</Row>
</Col>
</Col>
</Row>
@ -628,15 +656,6 @@ function FeedAnswerGroup(props: {
</div>
</div>
))}
<div
className={clsx('ml-10 mt-4', tradingAllowed(contract) ? '' : 'hidden')}
>
<BuyButton
className="btn-sm !px-6 sm:hidden"
onClick={() => setOpen(true)}
/>
</div>
</Col>
)
}

View File

@ -1,13 +1,74 @@
import { Answer } from '../../common/answer'
import { getProbability } from '../../common/calculate'
import {
Binary,
Contract,
CPMM,
DPM,
FreeResponse,
FreeResponseContract,
FullContract,
} from '../../common/contract'
import { formatPercent } from '../../common/util/format'
export function OutcomeLabel(props: {
contract: Contract
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string
truncate: 'short' | 'long' | 'none'
}) {
const { outcome, contract, truncate } = props
if (contract.outcomeType === 'BINARY')
return <BinaryOutcomeLabel outcome={outcome as any} />
return (
<FreeResponseOutcomeLabel
contract={contract as FullContract<DPM, FreeResponse>}
resolution={outcome}
truncate={truncate}
/>
)
}
export function BinaryOutcomeLabel(props: {
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
}) {
const { outcome } = props
if (outcome === 'YES') return <YesLabel />
if (outcome === 'NO') return <NoLabel />
if (outcome === 'MKT') return <ProbLabel />
if (outcome === 'CANCEL') return <CancelLabel />
return <AnswerNumberLabel number={outcome} />
return <CancelLabel />
}
export function BinaryContractOutcomeLabel(props: {
contract: FullContract<DPM | CPMM, Binary>
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT'
}) {
const { contract, resolution } = props
if (resolution === 'MKT') {
const prob = contract.resolutionProbability ?? getProbability(contract)
return <ProbPercentLabel prob={prob} />
}
return <BinaryOutcomeLabel outcome={resolution} />
}
export function FreeResponseOutcomeLabel(props: {
contract: FreeResponseContract
resolution: string | 'CANCEL' | 'MKT'
truncate: 'short' | 'long' | 'none'
}) {
const { contract, resolution, truncate } = props
if (resolution === 'CANCEL') return <CancelLabel />
if (resolution === 'MKT') return <MultiLabel />
const { answers } = contract
const chosen = answers?.find((answer) => answer.id === resolution)
if (!chosen) return <AnswerNumberLabel number={resolution} />
return <AnswerLabel answer={chosen} truncate={truncate} />
}
export function YesLabel() {
@ -26,6 +87,32 @@ export function ProbLabel() {
return <span className="text-blue-400">PROB</span>
}
export function MultiLabel() {
return <span className="text-blue-400">MULTI</span>
}
export function ProbPercentLabel(props: { prob: number }) {
const { prob } = props
return <span className="text-blue-400">{formatPercent(prob)}</span>
}
export function AnswerNumberLabel(props: { number: string }) {
return <span className="text-primary">#{props.number}</span>
}
export function AnswerLabel(props: {
answer: Answer
truncate: 'short' | 'long' | 'none'
}) {
const { answer, truncate } = props
const { text } = answer
let truncated = text
if (truncate === 'short' && text.length > 20) {
truncated = text.slice(0, 10) + '...' + text.slice(-10)
} else if (truncate === 'long' && text.length > 75) {
truncated = text.slice(0, 75) + '...'
}
return <span className="text-primary">{truncated}</span>
}

View File

@ -195,7 +195,7 @@ function Button(props: {
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'gray' && 'bg-gray-300 text-gray-700 hover:bg-gray-400',
color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300',
className
)}
onClick={onClick}

View File

@ -98,7 +98,7 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
return <Custom404 />
}
const { creatorId, isResolved, question, outcomeType } = contract
const { creatorId, isResolved, question, outcomeType, resolution } = contract
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
@ -143,8 +143,10 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
contract={contract}
bets={bets ?? []}
comments={comments ?? []}
>
{contract.outcomeType === 'FREE_RESPONSE' && (
/>
{outcomeType === 'FREE_RESPONSE' &&
(!isResolved || resolution === 'MKT') && (
<>
<Spacer h={4} />
<AnswersPanel
@ -153,7 +155,6 @@ export function ContractPageContent(props: FirstArgument<typeof ContractPage>) {
<Spacer h={4} />
</>
)}
</ContractOverview>
{contract.isResolved && (
<>

View File

@ -8,8 +8,9 @@ import {
import { DOMAIN } from '../../../../common/envs/constants'
import { AnswersGraph } from '../../../components/answers/answers-graph'
import {
ResolutionOrChance,
BinaryResolutionOrChance,
ContractDetails,
FreeResponseResolution,
} from '../../../components/contract/contract-card'
import { ContractProbGraph } from '../../../components/contract/contract-prob-graph'
import { Col } from '../../../components/layout/col'
@ -118,8 +119,14 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
hideShareButtons
/>
{(isBinary || resolution) && (
<ResolutionOrChance contract={contract} />
{isBinary && <BinaryResolutionOrChance contract={contract} />}
{outcomeType === 'FREE_RESPONSE' && resolution && (
<FreeResponseResolution
contract={contract}
resolution={resolution}
truncate="long"
/>
)}
</Row>