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
|
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}
|
||||||
|
|
|
@ -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)}
|
/>
|
||||||
<span>(+{currentReturnPercent})</span>
|
</Row>
|
||||||
</div>
|
<div>
|
||||||
|
{formatMoney(currentPayout)}
|
||||||
|
<span>(+{currentReturnPercent})</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Spacer h={6} />
|
<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,
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user