From e4377ee3a3ec8730ec153c6925aa17b21c3f36f5 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Wed, 26 Jan 2022 14:08:03 -0600 Subject: [PATCH] Allow betting directly from the activity feed (#36) * Show a popup for betting on the Activity feed * Replace the popup with a YES/NO selector * Autofocus the bet amount * Hide BetRow when not appropriate * Make bet modal larger on desktop * Default to YES if no bet choice has been made yet --- web/components/amount-input.tsx | 4 ++ web/components/bet-panel.tsx | 78 ++++++++++++++------- web/components/bet-row.tsx | 93 +++++++++++++++++++++++++ web/components/contract-feed.tsx | 6 +- web/components/yes-no-selector.tsx | 25 ++++--- web/lib/firebase/contracts.ts | 7 ++ web/pages/[username]/[contractSlug].tsx | 4 +- 7 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 web/components/bet-row.tsx diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index ccbce060..9968a132 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -14,6 +14,8 @@ export function AmountInput(props: { disabled?: boolean className?: string inputClassName?: string + // Needed to focus the amount input + inputRef?: React.MutableRefObject }) { const { amount, @@ -24,6 +26,7 @@ export function AmountInput(props: { className, inputClassName, minimumAmount, + inputRef, } = props const user = useUser() @@ -56,6 +59,7 @@ export function AmountInput(props: { error && 'input-error', inputClassName )} + ref={inputRef} type="text" placeholder="0" maxLength={9} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 574fdd53..d8ed5667 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useUser } from '../hooks/use-user' import { Contract } from '../../common/contract' @@ -26,18 +26,34 @@ import { AmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' import { OutcomeLabel } from './outcome-label' -export function BetPanel(props: { contract: Contract; className?: string }) { +// Focus helper from https://stackoverflow.com/a/54159564/1222351 +function useFocus(): [React.RefObject, () => void] { + const htmlElRef = useRef(null) + const setFocus = () => { + htmlElRef.current && htmlElRef.current.focus() + } + + return [htmlElRef, setFocus] +} + +export function BetPanel(props: { + contract: Contract + className?: string + title?: string // Set if BetPanel is on a feed modal + selected?: 'YES' | 'NO' +}) { useEffect(() => { // warm up cloud function placeBet({}).catch() }, []) - const { contract, className } = props + const { contract, className, title, selected } = props const user = useUser() - const [betChoice, setBetChoice] = useState<'YES' | 'NO'>('YES') + const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState(undefined) + const [inputRef, focusAmountInput] = useFocus() const [error, setError] = useState() const [isSubmitting, setIsSubmitting] = useState(false) @@ -46,11 +62,15 @@ export function BetPanel(props: { contract: Contract; className?: string }) { function onBetChoice(choice: 'YES' | 'NO') { setBetChoice(choice) setWasSubmitted(false) + focusAmountInput() } function onBetChange(newAmount: number | undefined) { setWasSubmitted(false) setBetAmount(newAmount) + if (!betChoice) { + setBetChoice('YES') + } } async function submitBet() { @@ -88,14 +108,14 @@ export function BetPanel(props: { contract: Contract; className?: string }) { const resultProb = getProbabilityAfterBet( contract.totalShares, - betChoice, + betChoice || 'YES', betAmount ?? 0 ) const shares = calculateShares( contract.totalShares, betAmount ?? 0, - betChoice + betChoice || 'YES' ) const currentPayout = betAmount @@ -108,14 +128,18 @@ export function BetPanel(props: { contract: Contract; className?: string }) { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + const panelTitle = title ?? `Buy ${betChoice || 'shares'}` + if (title) { + focusAmountInput() + } return ( <div className="mt-2 mb-1 text-sm text-gray-500">Outcome</div> @@ -133,6 +157,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) { error={error} setError={setError} disabled={isSubmitting} + inputRef={inputRef} /> <Spacer h={4} /> @@ -144,22 +169,27 @@ export function BetPanel(props: { contract: Contract; className?: string }) { <div>{formatPercent(resultProb)}</div> </Row> - <Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> - Payout if <OutcomeLabel outcome={betChoice} /> - <InfoTooltip - text={`Current payout for ${formatWithCommas( - shares - )} / ${formatWithCommas( - shares + - contract.totalShares[betChoice] - - contract.phantomShares[betChoice] - )} ${betChoice} shares`} - /> - </Row> - <div> - {formatMoney(currentPayout)} -   <span>(+{currentReturnPercent})</span> - </div> + {betChoice && ( + <> + <Spacer h={4} /> + <Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> + Payout if <OutcomeLabel outcome={betChoice} /> + <InfoTooltip + text={`Current payout for ${formatWithCommas( + shares + )} / ${formatWithCommas( + shares + + contract.totalShares[betChoice] - + contract.phantomShares[betChoice] + )} ${betChoice} shares`} + /> + </Row> + <div> + {formatMoney(currentPayout)} +   <span>(+{currentReturnPercent})</span> + </div> + </> + )} <Spacer h={6} /> diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx new file mode 100644 index 00000000..5b140e27 --- /dev/null +++ b/web/components/bet-row.tsx @@ -0,0 +1,93 @@ +/* This example requires Tailwind CSS v2.0+ */ +import { Fragment, useState } from 'react' +import { Dialog, Transition } from '@headlessui/react' +import { Contract } from '../lib/firebase/contracts' +import { BetPanel } from './bet-panel' +import { Row } from './layout/row' +import { YesNoSelector } from './yes-no-selector' + +// Inline version of a bet panel. Opens BetPanel in a new modal. +export default function BetRow(props: { contract: Contract }) { + const [open, setOpen] = useState(false) + const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>( + undefined + ) + + return ( + <> + <div className="-mt-2 text-xl -mx-4"> + <Row className="items-center gap-2 justify-center"> + Buy + <YesNoSelector + className="w-72" + onSelect={(choice) => { + setOpen(true) + setBetChoice(choice) + }} + /> + </Row> + <Modal open={open} setOpen={setOpen}> + <BetPanel + contract={props.contract} + title={props.contract.question} + selected={betChoice} + /> + </Modal> + </div> + </> + ) +} + +// From https://tailwindui.com/components/application-ui/overlays/modals +export function Modal(props: { + children: React.ReactNode + open: boolean + setOpen: (open: boolean) => void +}) { + const { children, open, setOpen } = props + + return ( + <Transition.Root show={open} as={Fragment}> + <Dialog + as="div" + className="fixed z-10 inset-0 overflow-y-auto" + onClose={setOpen} + > + <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> + </Transition.Child> + + {/* This element is to trick the browser into centering the modal contents. */} + <span + className="hidden sm:inline-block sm:align-middle sm:h-screen" + aria-hidden="true" + > + ​ + </span> + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + enterTo="opacity-100 translate-y-0 sm:scale-100" + leave="ease-in duration-200" + leaveFrom="opacity-100 translate-y-0 sm:scale-100" + leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + > + <div className="inline-block align-bottom text-left overflow-hidden transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-6"> + {children} + </div> + </Transition.Child> + </div> + </Dialog> + </Transition.Root> + ) +} diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index e90f5544..ae518cce 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -19,6 +19,7 @@ import { Contract, contractPath, updateContract, + tradingAllowed, } from '../lib/firebase/contracts' import { useUser } from '../hooks/use-user' import { Linkify } from './linkify' @@ -38,6 +39,8 @@ import { JoinSpans } from './join-spans' import Textarea from 'react-expanding-textarea' import { outcome } from '../../common/contract' import { fromNow } from '../lib/util/time' +import BetRow from './bet-row' +import clsx from 'clsx' import { parseTags } from '../../common/util/parse' export function AvatarWithIcon(props: { username: string; avatarUrl: string }) { @@ -655,7 +658,7 @@ export function ContractFeed(props: { return ( <div className="flow-root"> - <ul role="list" className="-mb-8"> + <ul role="list" className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}> {items.map((activityItem, activityItemIdx) => ( <li key={activityItem.id}> <div className="relative pb-8"> @@ -694,6 +697,7 @@ export function ContractFeed(props: { </li> ))} </ul> + {tradingAllowed(contract) && <BetRow contract={contract} />} </div> ) } diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index 0fcde422..a44bedf5 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -5,7 +5,7 @@ import { Col } from './layout/col' import { Row } from './layout/row' export function YesNoSelector(props: { - selected: 'YES' | 'NO' + selected?: 'YES' | 'NO' onSelect: (selected: 'YES' | 'NO') => void className?: string }) { @@ -13,19 +13,28 @@ export function YesNoSelector(props: { return ( <Row className={clsx('space-x-3', className)}> - <Button - color={selected === 'YES' ? 'green' : 'gray'} + <button + className={clsx( + 'flex-1 inline-flex justify-center items-center p-2 hover:bg-primary-focus hover:text-white rounded-lg border-primary hover:border-primary-focus border-2', + selected == 'YES' + ? 'bg-primary text-white' + : 'bg-transparent text-primary' + )} onClick={() => onSelect('YES')} > YES - </Button> - - <Button - color={selected === 'NO' ? 'red' : 'gray'} + </button> + <button + className={clsx( + 'flex-1 inline-flex justify-center items-center p-2 hover:bg-red-500 hover:text-white rounded-lg border-red-400 hover:border-red-500 border-2', + selected == 'NO' + ? 'bg-red-400 text-white' + : 'bg-transparent text-red-400' + )} onClick={() => onSelect('NO')} > NO - </Button> + </button> </Row> ) } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index f7168e96..08466cd2 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -52,6 +52,13 @@ export function contractMetrics(contract: Contract) { return { truePool, probPercent, startProb, createdDate, resolvedDate } } +export function tradingAllowed(contract: Contract) { + return ( + !contract.isResolved && + (!contract.closeTime || contract.closeTime > Date.now()) + ) +} + const db = getFirestore(app) export const contractCollection = collection(db, 'contracts') diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 87a81bde..93f28746 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -15,6 +15,7 @@ import { contractMetrics, Contract, getContractFromSlug, + tradingAllowed, } from '../../lib/firebase/contracts' import { SEO } from '../../components/SEO' import { Page } from '../../components/page' @@ -70,8 +71,7 @@ export default function ContractPage(props: { const { creatorId, isResolved, resolution, question } = contract const isCreator = user?.id === creatorId - const allowTrade = - !isResolved && (!contract.closeTime || contract.closeTime > Date.now()) + const allowTrade = tradingAllowed(contract) const allowResolve = !isResolved && isCreator && !!user const { probPercent } = contractMetrics(contract)