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
className?: string
inputClassName?: string
// Needed to focus the amount input
inputRef?: React.MutableRefObject<any>
}) {
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}

View File

@ -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<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(() => {
// 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<number | undefined>(undefined)
const [inputRef, focusAmountInput] = useFocus()
const [error, setError] = useState<string | undefined>()
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 (
<Col
className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)}
>
<Title
className="mt-0 whitespace-nowrap text-neutral"
text={`Buy ${betChoice}`}
className={clsx('!mt-0 text-neutral', title ? '!text-xl' : '')}
text={panelTitle}
/>
<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,6 +169,9 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
<div>{formatPercent(resultProb)}</div>
</Row>
{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
@ -160,6 +188,8 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
{formatMoney(currentPayout)}
&nbsp; <span>(+{currentReturnPercent})</span>
</div>
</>
)}
<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,
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>
)
}

View File

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

View File

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

View File

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