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"