Simple bet interface in embeds (#775)

* rename BetRow -> BetButton

* Replace bet modal in embed with inline betting

- Also simplifies graph height calculation

* Move bet row above graph, in "mini modal"

* Show signup button if not signed up

* Show probability change

* Show error after modal

- Show balance if insufficient funds
- Clear error from amount input if amount deleted entirely

* Fix error state conditions

- Reset amount input on success
- Reset success state on user input

* Make input smaller (80px)
This commit is contained in:
Sinclair Chen 2022-08-19 10:07:48 -07:00 committed by GitHub
parent 98a0ed99c9
commit 4f3202f90b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 209 additions and 58 deletions

View File

@ -3,7 +3,6 @@ import React from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
@ -37,7 +36,7 @@ export function AmountInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group"> <label className="input-group mb-4">
<span className="bg-gray-200 text-sm">{label}</span> <span className="bg-gray-200 text-sm">{label}</span>
<input <input
className={clsx( className={clsx(
@ -57,8 +56,6 @@ export function AmountInput(props: {
/> />
</label> </label>
<Spacer h={4} />
{error && ( {error && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? ( {error === 'Insufficient balance' ? (
@ -115,6 +112,8 @@ export function BuyAmountInput(props: {
} else { } else {
setError(undefined) setError(undefined)
} }
} else {
setError(undefined)
} }
} }

View File

@ -9,8 +9,8 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares' import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col' import { Col } from './layout/col'
// Inline version of a bet panel. Opens BetPanel in a new modal. /** Button that opens BetPanel in a new modal */
export default function BetRow(props: { export default function BetButton(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
className?: string className?: string
btnClassName?: string btnClassName?: string

View File

@ -0,0 +1,127 @@
import { track } from '@amplitude/analytics-browser'
import clsx from 'clsx'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { getBinaryCpmmBetInfo } from 'common/new-bet'
import { APIError } from 'web/lib/firebase/api'
import { useEffect, useState } from 'react'
import { useMutation } from 'react-query'
import { placeBet } from 'web/lib/firebase/api'
import { BuyAmountInput } from './amount-input'
import { Button } from './button'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { SignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
import { Col } from './layout/col'
import { XIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
// adapted from bet-panel.ts
export function BetInline(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
setProbAfter: (probAfter: number) => void
onClose: () => void
}) {
const { contract, className, setProbAfter, onClose } = props
const user = useUser()
const [outcome, setOutcome] = useState<'YES' | 'NO'>('YES')
const [amount, setAmount] = useState<number>()
const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES',
amount ?? 0,
contract,
undefined,
unfilledBets
)
const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
const submitBet = useMutation(
() => placeBet({ outcome, amount, contractId: contract.id }),
{
onError: (e) =>
setError(e instanceof APIError ? e.toString() : 'Error placing bet'),
onSuccess: () => {
track('bet', {
location: 'embed',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount,
outcome,
isLimitOrder: false,
})
setAmount(undefined)
},
}
)
// reset error / success state on user change
useEffect(() => {
amount && submitBet.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [outcome, amount])
const tooFewFunds = error === 'Insufficient balance'
const betDisabled = submitBet.isLoading || tooFewFunds || !amount
return (
<Col className={clsx('items-center', className)}>
<Row className="h-12 items-stretch gap-3 rounded bg-indigo-200 py-2 px-3">
<div className="text-xl">Bet</div>
<YesNoSelector
className="space-x-0"
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
selected={outcome}
onSelect={setOutcome}
isPseudoNumeric={isPseudoNumeric}
/>
<BuyAmountInput
className="-mb-4"
inputClassName={clsx(
'input-sm w-20 !text-base',
error && 'input-error'
)}
amount={amount}
onChange={setAmount}
error="" // handle error ourselves
setError={setError}
/>
{user && (
<Button
color={({ YES: 'green', NO: 'red' } as const)[outcome]}
size="xs"
disabled={betDisabled}
onClick={() => submitBet.mutate()}
>
{submitBet.isLoading
? 'Submitting'
: submitBet.isSuccess
? 'Success!'
: 'Submit'}
</Button>
)}
<SignUpPrompt size="xs" />
<button onClick={onClose}>
<XIcon className="ml-1 h-6 w-6" />
</button>
</Row>
{error && (
<div className="text-error my-1 text-sm">
{error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`}
</div>
)}
</Col>
)
}

View File

@ -1,20 +1,23 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type ColorType =
| 'green'
| 'red'
| 'blue'
| 'indigo'
| 'yellow'
| 'gray'
| 'gradient'
| 'gray-white'
export function Button(props: { export function Button(props: {
className?: string className?: string
onClick?: () => void onClick?: () => void
children?: ReactNode children?: ReactNode
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' size?: SizeType
color?: color?: ColorType
| 'green'
| 'red'
| 'blue'
| 'indigo'
| 'yellow'
| 'gray'
| 'gradient'
| 'gray-white'
type?: 'button' | 'reset' | 'submit' type?: 'button' | 'reset' | 'submit'
disabled?: boolean disabled?: boolean
}) { }) {

View File

@ -185,11 +185,16 @@ export function BinaryResolutionOrChance(props: {
contract: BinaryContract contract: BinaryContract
large?: boolean large?: boolean
className?: string className?: string
probAfter?: number // 0 to 1
}) { }) {
const { contract, large, className } = props const { contract, large, className, probAfter } = props
const { resolution } = contract const { resolution } = contract
const textColor = `text-${getColor(contract)}` const textColor = `text-${getColor(contract)}`
const before = getBinaryProbPercent(contract)
const after = probAfter && formatPercent(probAfter)
const probChanged = before !== after
return ( return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> <Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
{resolution ? ( {resolution ? (
@ -206,7 +211,14 @@ export function BinaryResolutionOrChance(props: {
</> </>
) : ( ) : (
<> <>
<div className={textColor}>{getBinaryProbPercent(contract)}</div> {probAfter && probChanged ? (
<div>
<span className="text-gray-500 line-through">{before}</span>
<span className={textColor}>{after}</span>
</div>
) : (
<div className={textColor}>{before}</div>
)}
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}> <div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
chance chance
</div> </div>

View File

@ -15,7 +15,7 @@ import {
PseudoNumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation,
} from './contract-card' } from './contract-card'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetRow from '../bet-row' import BetButton from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph' import { AnswersGraph } from '../answers/answers-graph'
import { Contract, CPMMBinaryContract } from 'common/contract' import { Contract, CPMMBinaryContract } from 'common/contract'
import { ContractDescription } from './contract-description' import { ContractDescription } from './contract-description'
@ -73,18 +73,18 @@ export const ContractOverview = (props: {
<BinaryResolutionOrChance contract={contract} /> <BinaryResolutionOrChance contract={contract} />
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<BetRow contract={contract as CPMMBinaryContract} /> <BetButton contract={contract as CPMMBinaryContract} />
)} )}
</Row> </Row>
) : isPseudoNumeric ? ( ) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} /> <PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />} {tradingAllowed(contract) && <BetButton contract={contract} />}
</Row> </Row>
) : isPseudoNumeric ? ( ) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} /> <PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && <BetRow contract={contract} />} {tradingAllowed(contract) && <BetButton contract={contract} />}
</Row> </Row>
) : ( ) : (
(outcomeType === 'FREE_RESPONSE' || (outcomeType === 'FREE_RESPONSE' ||

View File

@ -19,7 +19,7 @@ import { BinaryResolutionOrChance } from '../contract/contract-card'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { UserLink } from '../user-page' import { UserLink } from '../user-page'
import BetRow from '../bet-row' import BetButton from '../bet-button'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items' import { ActivityItem } from './activity-items'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
@ -76,7 +76,7 @@ export function FeedItems(props: {
) : ( ) : (
outcomeType === 'BINARY' && outcomeType === 'BINARY' &&
tradingAllowed(contract) && ( tradingAllowed(contract) && (
<BetRow <BetButton
contract={contract as CPMMBinaryContract} contract={contract as CPMMBinaryContract}
className={clsx('mb-2', betRowClassName)} className={clsx('mb-2', betRowClassName)}
/> />

View File

@ -2,17 +2,21 @@ import React from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { Button } from './button' import { Button, SizeType } from './button'
export function SignUpPrompt(props: { label?: string; className?: string }) { export function SignUpPrompt(props: {
const { label, className } = props label?: string
className?: string
size?: SizeType
}) {
const { label, className, size = 'lg' } = props
const user = useUser() const user = useUser()
return user === null ? ( return user === null ? (
<Button <Button
onClick={withTracking(firebaseLogin, 'sign up to bet')} onClick={withTracking(firebaseLogin, 'sign up to bet')}
className={className} className={className}
size="lg" size={size}
color="gradient" color="gradient"
> >
{label ?? 'Sign up to bet!'} {label ?? 'Sign up to bet!'}

View File

@ -38,7 +38,7 @@ export function YesNoSelector(props: {
'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white',
selected == 'YES' selected == 'YES'
? 'bg-primary text-white' ? 'bg-primary text-white'
: 'text-primary bg-transparent', : 'text-primary bg-white',
btnClassName btnClassName
)} )}
onClick={() => onSelect('YES')} onClick={() => onSelect('YES')}
@ -55,7 +55,7 @@ export function YesNoSelector(props: {
'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white', 'border-red-400 hover:border-red-500 hover:bg-red-500 hover:text-white',
selected == 'NO' selected == 'NO'
? 'bg-red-400 text-white' ? 'bg-red-400 text-white'
: 'bg-transparent text-red-400', : 'bg-white text-red-400',
btnClassName btnClassName
)} )}
onClick={() => onSelect('NO')} onClick={() => onSelect('NO')}

View File

@ -1,8 +1,10 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract, CPMMBinaryContract } from 'common/contract' import { Contract } from 'common/contract'
import { DOMAIN } from 'common/envs/constants' import { DOMAIN } from 'common/envs/constants'
import { useState } from 'react'
import { AnswersGraph } from 'web/components/answers/answers-graph' import { AnswersGraph } from 'web/components/answers/answers-graph'
import BetRow from 'web/components/bet-row' import { BetInline } from 'web/components/bet-inline'
import { Button } from 'web/components/button'
import { import {
BinaryResolutionOrChance, BinaryResolutionOrChance,
FreeResponseResolutionOrChance, FreeResponseResolutionOrChance,
@ -19,7 +21,6 @@ import { SiteLink } from 'web/components/site-link'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { useMeasureSize } from 'web/hooks/use-measure-size' import { useMeasureSize } from 'web/hooks/use-measure-size'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useWindowSize } from 'web/hooks/use-window-size'
import { listAllBets } from 'web/lib/firebase/bets' import { listAllBets } from 'web/lib/firebase/bets'
import { import {
contractPath, contractPath,
@ -88,18 +89,15 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const href = `https://${DOMAIN}${contractPath(contract)}` const href = `https://${DOMAIN}${contractPath(contract)}`
const { height: windowHeight } = useWindowSize() const { setElem, height: graphHeight } = useMeasureSize()
const { setElem, height: topSectionHeight } = useMeasureSize()
const paddingBottom = 8
const graphHeight = const [betPanelOpen, setBetPanelOpen] = useState(false)
windowHeight && topSectionHeight
? windowHeight - topSectionHeight - paddingBottom const [probAfter, setProbAfter] = useState<number>()
: 0
return ( return (
<Col className="w-full flex-1 bg-white"> <Col className="h-[100vh] w-full bg-white">
<div className="relative flex flex-col pt-2" ref={setElem}> <div className="relative flex flex-col pt-2">
<div className="px-3 text-xl text-indigo-700 md:text-2xl"> <div className="px-3 text-xl text-indigo-700 md:text-2xl">
<SiteLink href={href}>{question}</SiteLink> <SiteLink href={href}>{question}</SiteLink>
</div> </div>
@ -114,25 +112,24 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
disabled disabled
/> />
{(isBinary || isPseudoNumeric) &&
tradingAllowed(contract) &&
!betPanelOpen && (
<Button color="gradient" onClick={() => setBetPanelOpen(true)}>
Bet
</Button>
)}
{isBinary && ( {isBinary && (
<Row className="items-center gap-4"> <BinaryResolutionOrChance
{tradingAllowed(contract) && ( contract={contract}
<BetRow probAfter={probAfter}
contract={contract as CPMMBinaryContract} className="items-center"
betPanelClassName="scale-75" />
/>
)}
<BinaryResolutionOrChance contract={contract} />
</Row>
)} )}
{isPseudoNumeric && ( {isPseudoNumeric && (
<Row className="items-center gap-4"> <PseudoNumericResolutionOrExpectation contract={contract} />
{tradingAllowed(contract) && (
<BetRow contract={contract} betPanelClassName="scale-75" />
)}
<PseudoNumericResolutionOrExpectation contract={contract} />
</Row>
)} )}
{outcomeType === 'FREE_RESPONSE' && ( {outcomeType === 'FREE_RESPONSE' && (
@ -150,7 +147,16 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
<Spacer h={2} /> <Spacer h={2} />
</div> </div>
<div className="mx-1" style={{ paddingBottom }}> {(isBinary || isPseudoNumeric) && betPanelOpen && (
<BetInline
contract={contract as any}
setProbAfter={setProbAfter}
onClose={() => setBetPanelOpen(false)}
className="self-center"
/>
)}
<div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}>
{(isBinary || isPseudoNumeric) && ( {(isBinary || isPseudoNumeric) && (
<ContractProbGraph <ContractProbGraph
contract={contract} contract={contract}