diff --git a/common/util/format.ts b/common/util/format.ts
index c68f319e..10b7c1de 100644
--- a/common/util/format.ts
+++ b/common/util/format.ts
@@ -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) {
diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx
index cf910ef3..8fffd844 100644
--- a/web/components/contract/contract-card.tsx
+++ b/web/components/contract/contract-card.tsx
@@ -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 (
-
-
-
-
-
-
-
-
+
+
+
+
{question}
-
- {outcomeType === 'BINARY' && (
-
- )}
- {outcomeType === 'NUMERIC' && (
- }
+ truncate="long"
+ />
+ )}
+
+
+
+ {showQuickBet ? (
+
+ ) : (
+
+
+
)}
-
- {outcomeType === 'FREE_RESPONSE' && (
- }
- truncate="long"
- />
- )}
-
-
-
+
)
@@ -160,8 +106,9 @@ export function BinaryResolutionOrChance(props: {
contract: FullContract
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: {
) : (
<>
{getBinaryProbPercent(contract)}
-
- chance
-
+ {!hideText && (
+
+ chance
+
+ )}
>
)}
)
}
-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 ? (
+
+ ) : 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 && (
-
{formatPercent(getOutcomeProbability(contract, topAnswer.id))}
- chance
+ {!hideText && chance
}
)
@@ -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: {
>
) : (
<>
-
+
{formatLargeNumber(getExpectedValue(contract))}
-
expected
+ {!hideText && (
+
expected
+ )}
>
)}
diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx
index 6a8f5c9f..ed07e409 100644
--- a/web/components/contract/contract-details.tsx
+++ b/web/components/contract/contract-details.tsx
@@ -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 (
-
-
-
-
-
-
+
+ {categories.length > 0 && (
+
+ )}
-
- {categories.length > 0 && (
-
- )}
-
- {showHotVolume ? (
-
- {' '}
- {formatMoney(volume24Hours)}
-
- ) : showCloseTime ? (
-
-
- {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
- {fromNow(closeTime || 0)}
-
- ) : volume > 0 ? (
- {volumeLabel}
- ) : (
-
- )}
+ {showHotVolume ? (
+
+ {formatMoney(volume24Hours)}
-
-
+ ) : showCloseTime ? (
+
+
+ {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
+ {fromNow(closeTime || 0)}
+
+ ) : volume > 0 ? (
+ {volumeLabel}
+ ) : (
+
+ )}
+
+ )
+}
+
+export function AvatarDetails(props: { contract: Contract }) {
+ const { contract } = props
+ const { creatorName, creatorUsername } = contract
+
+ return (
+
+
+
+
+ )
+}
+
+export function AbbrContractDetails(props: {
+ contract: Contract
+ showHotVolume?: boolean
+ showCloseTime?: boolean
+}) {
+ const { contract, showHotVolume, showCloseTime } = props
+ return (
+
+
+
+
+
)
}
@@ -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 (
diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx
new file mode 100644
index 00000000..35fcad50
--- /dev/null
+++ b/web/components/contract/quick-bet.tsx
@@ -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,
+ 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 (
+
+ {/* Up bet triangle */}
+
+
placeQuickBet('UP')}
+ >
+
+ {formatMoney(10)}
+
+
+ {hasUpShares > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Down bet triangle */}
+
+
placeQuickBet('DOWN')}
+ >
+ {hasDownShares > 0 ? (
+
+ ) : (
+
+ )}
+
+ {formatMoney(10)}
+
+
+
+ )
+}
+
+export function ProbBar(props: { contract: Contract }) {
+ const { contract } = props
+ const color = getColor(contract)
+ const prob = getProb(contract)
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export function QuickOutcomeView(props: { contract: Contract }) {
+ const { contract } = props
+ const { outcomeType } = contract
+ return (
+ <>
+ {outcomeType === 'BINARY' && (
+
+ )}
+
+ {outcomeType === 'NUMERIC' && (
+
+ )}
+
+ {outcomeType === 'FREE_RESPONSE' && (
+ }
+ 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'
+}
diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx
index 13152e4b..23730e60 100644
--- a/web/components/outcome-label.tsx
+++ b/web/components/outcome-label.tsx
@@ -84,11 +84,7 @@ export function FreeResponseOutcomeLabel(props: {
if (!chosen) return
return (
-
+ TOP
)
}
@@ -117,7 +113,7 @@ export function ProbLabel() {
}
export function MultiLabel() {
- return MULTI
+ return MANY
}
export function ProbPercentLabel(props: { prob: number }) {
diff --git a/web/components/page.tsx b/web/components/page.tsx
index c26980ab..faefb718 100644
--- a/web/components/page.tsx
+++ b/web/components/page.tsx
@@ -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}
>
+
+
+
+ )
+}
diff --git a/web/lib/icons/triangle-fill-icon.tsx b/web/lib/icons/triangle-fill-icon.tsx
new file mode 100644
index 00000000..e24c005f
--- /dev/null
+++ b/web/lib/icons/triangle-fill-icon.tsx
@@ -0,0 +1,16 @@
+// Icon from Bootstrap: https://icons.getbootstrap.com/
+export default function TriangleFillIcon(props: { className?: string }) {
+ return (
+
+ )
+}
diff --git a/web/package.json b/web/package.json
index d2fbfa98..c427557e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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": {
diff --git a/yarn.lock b/yarn.lock
index fc3b5de9..7eecfe4d 100644
--- a/yarn.lock
+++ b/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"