Compare commits

...

15 Commits

Author SHA1 Message Date
Austin Chen
583a797a61 WIP: Try a toast that counts down before betting
There's some weird timing/React hook error that sometimes allows this thing to bet twice? IDK, my brain is fried trying to understand it.

Also, progress bar that scrolls down might be cooler than a number countdown? See https://www.npmjs.com/package/react-toastify, although the API for that is more unwieldy/less Tailwind-friendly?
2022-05-22 14:05:44 -07:00
Austin Chen
139902f15e Install react-hot-toaster 2022-05-22 14:02:25 -07:00
Austin Chen
668d111973 FR: "MULTI" => "MANY", single => "TOP" 2022-05-22 12:09:05 -07:00
Austin Chen
31e1f4ba28 Improve click targets 2022-05-22 12:08:30 -07:00
Austin Chen
f3092892d8 Show "M$20" on hover, hide arrows when closed 2022-05-21 19:52:50 -07:00
Austin Chen
1adfb874c3 Fix bet width, remove "chance/expected" 2022-05-21 17:03:59 -07:00
Austin Chen
3aad05e2a6 Clean up hover areas 2022-05-21 16:30:50 -07:00
Austin Chen
13de5de24d Default large nums to 2 sigfigs 2022-05-21 16:28:47 -07:00
Austin Chen
1cfbd86bd0 Fix down triangle & padding 2022-05-21 15:48:47 -07:00
Austin Chen
ba9411161d Set correct hover states the arrows 2022-05-21 15:34:18 -07:00
Austin Chen
fd49ccea39 Use triangles instead of planes 2022-05-21 14:33:10 -07:00
Austin Chen
1b17d3a102 Fix Free Response card layouts 2022-05-21 13:50:34 -07:00
Austin Chen
98510f233d Hide liquidity injection star, for now 2022-05-21 13:32:57 -07:00
Austin Chen
d1fab9937f Align bet icons with the percentages 2022-05-21 13:26:34 -07:00
Austin Chen
3d368216b2 Play with using 3 icons for 1-click usage 2022-05-21 11:58:22 -07:00
9 changed files with 360 additions and 94 deletions

View File

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

View File

@ -1,7 +1,11 @@
import clsx from 'clsx'
import Link from 'next/link'
import { Row } from '../layout/row'
import { formatLargeNumber, formatPercent } from 'common/util/format'
import {
formatLargeNumber,
formatMoney,
formatPercent,
} from 'common/util/format'
import {
Contract,
contractPath,
@ -25,8 +29,14 @@ import {
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'
import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import toast from 'react-hot-toast'
import { CheckIcon, XIcon } from '@heroicons/react/solid'
import { useEffect, useState } from 'react'
import { APIError, placeBet } from 'web/lib/firebase/api-call'
// Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO)
@ -50,6 +60,9 @@ function getNumericScale(contract: NumericContract) {
}
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 (
@ -83,35 +96,121 @@ export function ContractCard(props: {
const prob = getProb(contract)
const color = getColor(contract)
const marketClosed = (contract.closeTime || Infinity) < Date.now()
const showTopBar = prob >= 0.5 || marketClosed
// TODO: switch to useContract after you place a bet on it
function betToast(outcome: string) {
let canceled = false
const toastId = toast.custom(
<BetToast
outcome={outcome}
seconds={3}
onToastFinish={onToastFinish}
onToastCancel={onToastCancel}
/>,
{
duration: 3000,
}
)
function onToastCancel() {
toast.remove(toastId)
canceled = true
}
function onToastFinish() {
if (canceled) return
console.log('Finishing toast')
toast.remove(toastId)
placeBet({
amount: 10,
outcome,
contractId: contract.id,
})
.then((r) => {
// Success
console.log('placed bet. Result:', r)
toast.success('Bet placed!', { duration: 1000 })
})
.catch((e) => {
// Failure
if (e instanceof APIError) {
toast.error(e.toString(), { duration: 1000 })
} else {
console.error(e)
toast.error('Could not place bet')
}
})
}
}
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
)}
>
<Row>
<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
marketClosed ? 'right-[-6.5rem]' : 'right-0'
)}
>
<Link href={contractPath(contract)}>
<a className="absolute left-0 right-0 top-0 bottom-0" />
<a className="absolute top-0 left-0 right-0 bottom-0" />
</Link>
<AbbrContractDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
<Row className={clsx('justify-between gap-4')}>
<Col className="gap-3">
</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>
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseTopAnswer
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
)}
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
</Col>
<Col className="relative -my-4 -mr-5 min-w-[6rem] justify-center gap-2 pr-5 pl-3 align-middle">
{!marketClosed && (
<div>
<div
className="peer absolute top-0 left-0 right-0 h-[50%]"
onClick={() => betToast('YES')}
></div>
<div className="my-1 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(20)}
</div>
{contract.createdTime % 3 == 0 ? (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5 text-opacity-60 peer-hover:text-opacity-100',
`text-${color}`
)}
/>
) : (
<TriangleFillIcon className="mx-auto h-5 w-5 text-gray-200 peer-hover:text-gray-400" />
)}
</div>
)}
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center"
@ -125,7 +224,6 @@ export function ContractCard(props: {
contract={contract as NumericContract}
/>
)}
</Row>
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
@ -135,6 +233,30 @@ export function ContractCard(props: {
/>
)}
{!marketClosed && (
<div>
<div
className="peer absolute bottom-0 left-0 right-0 h-[50%]"
onClick={() => betToast('NO')}
></div>
{contract.createdTime % 3 == 2 ? (
<TriangleDownFillIcon
className={clsx(
'mx-auto h-5 w-5 text-opacity-60 peer-hover:text-opacity-100',
`text-${color}`
)}
/>
) : (
<TriangleDownFillIcon className="mx-auto h-5 w-5 text-gray-200 peer-hover:text-gray-400" />
)}
<div className="my-1 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(20)}
</div>
</div>
)}
</Col>
</Row>
<div
className={clsx(
'absolute right-0 top-0 w-2 rounded-tr-md',
@ -182,15 +304,31 @@ export function BinaryResolutionOrChance(props: {
) : (
<>
<div className={textColor}>{getBinaryProbPercent(contract)}</div>
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
chance
</div>
</>
)}
</Col>
)
}
function FreeResponseTopAnswer(props: {
contract: FreeResponseContract
truncate: 'short' | 'long' | 'none'
className?: string
}) {
const { contract, truncate } = props
const { resolution } = contract
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'
@ -217,16 +355,10 @@ 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>
</Col>
</Row>
)
@ -241,6 +373,7 @@ export function NumericResolutionOrExpectation(props: {
}) {
const { contract, className } = props
const { resolution } = contract
const textColor = `text-${getColor(contract)}`
const resolutionValue =
contract.resolutionValue ?? getValueFromBucket(resolution ?? '', contract)
@ -254,12 +387,72 @@ 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>
</>
)}
</Col>
)
}
function BetToast(props: {
outcome: string
seconds: number
onToastFinish: () => void
onToastCancel: () => void
}) {
const { outcome, seconds, onToastFinish, onToastCancel } = props
// Track the number of seconds left, starting with durationMs
const [secondsLeft, setSecondsLeft] = useState(seconds)
console.log('renderings using', secondsLeft)
// Update the secondsLeft state every second
useEffect(() => {
const interval = setInterval(() => {
setSecondsLeft((seconds) => seconds - 1)
}, 1000)
return () => clearInterval(interval)
}, [])
if (secondsLeft <= 0) {
console.log('finishing')
onToastFinish()
// return null
}
return (
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
<div className="p-4">
<div className="flex items-center">
<div className="flex w-0 flex-1 justify-between">
<p className="w-0 flex-1 text-sm font-medium text-gray-900">
Betting M$10 on {outcome} in {secondsLeft}s
</p>
<button
type="button"
onClick={onToastCancel}
className="ml-3 flex-shrink-0 rounded-md bg-white text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Cancel
</button>
</div>
{/* <div className="ml-4 flex flex-shrink-0">
<button
type="button"
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={() => {
// TODO
// setShow(false)
}}
>
<span className="sr-only">Close</span>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div> */}
</div>
</div>
</div>
)
}

View File

@ -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,26 +42,20 @@ 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">
{/* {contract.createdTime % 3 == 1 ? (
<SolidStarIcon className="h-6 w-6 text-indigo-600" />
) : (
<StarIcon className="h-6 w-6 text-gray-400" />
)} */}
<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)}
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
<Row className="gap-0.5">
@ -80,6 +69,41 @@ export function AbbrContractDetails(props: {
<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 (
<Col className="gap-2">
<Row className="items-center justify-between">
<AvatarDetails contract={contract} />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
</Row>
</Col>
)
@ -93,7 +117,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">

View File

@ -84,11 +84,12 @@ export function FreeResponseOutcomeLabel(props: {
if (!chosen) return <AnswerNumberLabel number={resolution} />
return (
<FreeResponseAnswerToolTip text={chosen.text}>
<AnswerLabel
<span className="text-blue-400">TOP</span>
{/* <AnswerLabel
answer={chosen}
truncate={truncate}
className={answerClassName}
/>
/> */}
</FreeResponseAnswerToolTip>
)
}
@ -117,7 +118,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 }) {

View File

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

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

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

View File

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

View File

@ -3056,6 +3056,11 @@ globby@^11.0.4:
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.0.0, google-auth-library@^7.6.1, google-auth-library@^7.9.2:
version "7.11.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.11.0.tgz#b63699c65037310a424128a854ba7e736704cbdb"
@ -4716,6 +4721,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"