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
This commit is contained in:
Austin Chen 2022-01-26 14:08:03 -06:00 committed by GitHub
parent 76841e53b1
commit e4377ee3a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 182 additions and 35 deletions

View File

@ -14,6 +14,8 @@ export function AmountInput(props: {
disabled?: boolean disabled?: boolean
className?: string className?: string
inputClassName?: string inputClassName?: string
// Needed to focus the amount input
inputRef?: React.MutableRefObject<any>
}) { }) {
const { const {
amount, amount,
@ -24,6 +26,7 @@ export function AmountInput(props: {
className, className,
inputClassName, inputClassName,
minimumAmount, minimumAmount,
inputRef,
} = props } = props
const user = useUser() const user = useUser()
@ -56,6 +59,7 @@ export function AmountInput(props: {
error && 'input-error', error && 'input-error',
inputClassName inputClassName
)} )}
ref={inputRef}
type="text" type="text"
placeholder="0" placeholder="0"
maxLength={9} maxLength={9}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useUser } from '../hooks/use-user' import { useUser } from '../hooks/use-user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -26,18 +26,34 @@ import { AmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from './info-tooltip'
import { OutcomeLabel } from './outcome-label' 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<HTMLElement>, () => void] {
const htmlElRef = useRef<HTMLElement>(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(() => { useEffect(() => {
// warm up cloud function // warm up cloud function
placeBet({}).catch() placeBet({}).catch()
}, []) }, [])
const { contract, className } = props const { contract, className, title, selected } = props
const user = useUser() const user = useUser()
const [betChoice, setBetChoice] = useState<'YES' | 'NO'>('YES') const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [inputRef, focusAmountInput] = useFocus()
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -46,11 +62,15 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
function onBetChoice(choice: 'YES' | 'NO') { function onBetChoice(choice: 'YES' | 'NO') {
setBetChoice(choice) setBetChoice(choice)
setWasSubmitted(false) setWasSubmitted(false)
focusAmountInput()
} }
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false) setWasSubmitted(false)
setBetAmount(newAmount) setBetAmount(newAmount)
if (!betChoice) {
setBetChoice('YES')
}
} }
async function submitBet() { async function submitBet() {
@ -88,14 +108,14 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
const resultProb = getProbabilityAfterBet( const resultProb = getProbabilityAfterBet(
contract.totalShares, contract.totalShares,
betChoice, betChoice || 'YES',
betAmount ?? 0 betAmount ?? 0
) )
const shares = calculateShares( const shares = calculateShares(
contract.totalShares, contract.totalShares,
betAmount ?? 0, betAmount ?? 0,
betChoice betChoice || 'YES'
) )
const currentPayout = betAmount const currentPayout = betAmount
@ -108,14 +128,18 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = (currentReturn * 100).toFixed() + '%' const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
const panelTitle = title ?? `Buy ${betChoice || 'shares'}`
if (title) {
focusAmountInput()
}
return ( return (
<Col <Col
className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)} className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)}
> >
<Title <Title
className="mt-0 whitespace-nowrap text-neutral" className={clsx('!mt-0 text-neutral', title ? '!text-xl' : '')}
text={`Buy ${betChoice}`} text={panelTitle}
/> />
<div className="mt-2 mb-1 text-sm text-gray-500">Outcome</div> <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} error={error}
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef}
/> />
<Spacer h={4} /> <Spacer h={4} />
@ -144,22 +169,27 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
<div>{formatPercent(resultProb)}</div> <div>{formatPercent(resultProb)}</div>
</Row> </Row>
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500"> {betChoice && (
Payout if <OutcomeLabel outcome={betChoice} /> <>
<InfoTooltip <Spacer h={4} />
text={`Current payout for ${formatWithCommas( <Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
shares Payout if <OutcomeLabel outcome={betChoice} />
)} / ${formatWithCommas( <InfoTooltip
shares + text={`Current payout for ${formatWithCommas(
contract.totalShares[betChoice] - shares
contract.phantomShares[betChoice] )} / ${formatWithCommas(
)} ${betChoice} shares`} shares +
/> contract.totalShares[betChoice] -
</Row> contract.phantomShares[betChoice]
<div> )} ${betChoice} shares`}
{formatMoney(currentPayout)} />
&nbsp; <span>(+{currentReturnPercent})</span> </Row>
</div> <div>
{formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span>
</div>
</>
)}
<Spacer h={6} /> <Spacer h={6} />

View File

@ -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"
>
&#8203;
</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>
)
}

View File

@ -19,6 +19,7 @@ import {
Contract, Contract,
contractPath, contractPath,
updateContract, updateContract,
tradingAllowed,
} from '../lib/firebase/contracts' } from '../lib/firebase/contracts'
import { useUser } from '../hooks/use-user' import { useUser } from '../hooks/use-user'
import { Linkify } from './linkify' import { Linkify } from './linkify'
@ -38,6 +39,8 @@ import { JoinSpans } from './join-spans'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { outcome } from '../../common/contract' import { outcome } from '../../common/contract'
import { fromNow } from '../lib/util/time' import { fromNow } from '../lib/util/time'
import BetRow from './bet-row'
import clsx from 'clsx'
import { parseTags } from '../../common/util/parse' import { parseTags } from '../../common/util/parse'
export function AvatarWithIcon(props: { username: string; avatarUrl: string }) { export function AvatarWithIcon(props: { username: string; avatarUrl: string }) {
@ -655,7 +658,7 @@ export function ContractFeed(props: {
return ( return (
<div className="flow-root"> <div className="flow-root">
<ul role="list" className="-mb-8"> <ul role="list" className={clsx(tradingAllowed(contract) ? '' : '-mb-8')}>
{items.map((activityItem, activityItemIdx) => ( {items.map((activityItem, activityItemIdx) => (
<li key={activityItem.id}> <li key={activityItem.id}>
<div className="relative pb-8"> <div className="relative pb-8">
@ -694,6 +697,7 @@ export function ContractFeed(props: {
</li> </li>
))} ))}
</ul> </ul>
{tradingAllowed(contract) && <BetRow contract={contract} />}
</div> </div>
) )
} }

View File

@ -5,7 +5,7 @@ import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
export function YesNoSelector(props: { export function YesNoSelector(props: {
selected: 'YES' | 'NO' selected?: 'YES' | 'NO'
onSelect: (selected: 'YES' | 'NO') => void onSelect: (selected: 'YES' | 'NO') => void
className?: string className?: string
}) { }) {
@ -13,19 +13,28 @@ export function YesNoSelector(props: {
return ( return (
<Row className={clsx('space-x-3', className)}> <Row className={clsx('space-x-3', className)}>
<Button <button
color={selected === 'YES' ? 'green' : 'gray'} 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')} onClick={() => onSelect('YES')}
> >
YES YES
</Button> </button>
<button
<Button className={clsx(
color={selected === 'NO' ? 'red' : 'gray'} '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')} onClick={() => onSelect('NO')}
> >
NO NO
</Button> </button>
</Row> </Row>
) )
} }

View File

@ -52,6 +52,13 @@ export function contractMetrics(contract: Contract) {
return { truePool, probPercent, startProb, createdDate, resolvedDate } return { truePool, probPercent, startProb, createdDate, resolvedDate }
} }
export function tradingAllowed(contract: Contract) {
return (
!contract.isResolved &&
(!contract.closeTime || contract.closeTime > Date.now())
)
}
const db = getFirestore(app) const db = getFirestore(app)
export const contractCollection = collection(db, 'contracts') export const contractCollection = collection(db, 'contracts')

View File

@ -15,6 +15,7 @@ import {
contractMetrics, contractMetrics,
Contract, Contract,
getContractFromSlug, getContractFromSlug,
tradingAllowed,
} from '../../lib/firebase/contracts' } from '../../lib/firebase/contracts'
import { SEO } from '../../components/SEO' import { SEO } from '../../components/SEO'
import { Page } from '../../components/page' import { Page } from '../../components/page'
@ -70,8 +71,7 @@ export default function ContractPage(props: {
const { creatorId, isResolved, resolution, question } = contract const { creatorId, isResolved, resolution, question } = contract
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const allowTrade = const allowTrade = tradingAllowed(contract)
!isResolved && (!contract.closeTime || contract.closeTime > Date.now())
const allowResolve = !isResolved && isCreator && !!user const allowResolve = !isResolved && isCreator && !!user
const { probPercent } = contractMetrics(contract) const { probPercent } = contractMetrics(contract)