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:
parent
76841e53b1
commit
e4377ee3a3
|
@ -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}
|
||||
|
|
|
@ -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,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} />
|
||||
|
||||
|
|
93
web/components/bet-row.tsx
Normal file
93
web/components/bet-row.tsx
Normal 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"
|
||||
>
|
||||
​
|
||||
</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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue
Block a user