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:
		
							parent
							
								
									98a0ed99c9
								
							
						
					
					
						commit
						4f3202f90b
					
				|  | @ -3,7 +3,6 @@ import React from 'react' | |||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { formatMoney } from 'common/util/format' | ||||
| import { Col } from './layout/col' | ||||
| import { Spacer } from './layout/spacer' | ||||
| import { SiteLink } from './site-link' | ||||
| import { ENV_CONFIG } from 'common/envs/constants' | ||||
| 
 | ||||
|  | @ -37,7 +36,7 @@ export function AmountInput(props: { | |||
| 
 | ||||
|   return ( | ||||
|     <Col className={className}> | ||||
|       <label className="input-group"> | ||||
|       <label className="input-group mb-4"> | ||||
|         <span className="bg-gray-200 text-sm">{label}</span> | ||||
|         <input | ||||
|           className={clsx( | ||||
|  | @ -57,8 +56,6 @@ export function AmountInput(props: { | |||
|         /> | ||||
|       </label> | ||||
| 
 | ||||
|       <Spacer h={4} /> | ||||
| 
 | ||||
|       {error && ( | ||||
|         <div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> | ||||
|           {error === 'Insufficient balance' ? ( | ||||
|  | @ -115,6 +112,8 @@ export function BuyAmountInput(props: { | |||
|       } else { | ||||
|         setError(undefined) | ||||
|       } | ||||
|     } else { | ||||
|       setError(undefined) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -9,8 +9,8 @@ import { useUserContractBets } from 'web/hooks/use-user-bets' | |||
| import { useSaveBinaryShares } from './use-save-binary-shares' | ||||
| import { Col } from './layout/col' | ||||
| 
 | ||||
| // Inline version of a bet panel. Opens BetPanel in a new modal.
 | ||||
| export default function BetRow(props: { | ||||
| /** Button that opens BetPanel in a new modal */ | ||||
| export default function BetButton(props: { | ||||
|   contract: CPMMBinaryContract | PseudoNumericContract | ||||
|   className?: string | ||||
|   btnClassName?: string | ||||
							
								
								
									
										127
									
								
								web/components/bet-inline.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								web/components/bet-inline.tsx
									
									
									
									
									
										Normal 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> | ||||
|   ) | ||||
| } | ||||
|  | @ -1,12 +1,8 @@ | |||
| import { ReactNode } from 'react' | ||||
| import clsx from 'clsx' | ||||
| 
 | ||||
| export function Button(props: { | ||||
|   className?: string | ||||
|   onClick?: () => void | ||||
|   children?: ReactNode | ||||
|   size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | ||||
|   color?: | ||||
| export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | ||||
| export type ColorType = | ||||
|   | 'green' | ||||
|   | 'red' | ||||
|   | 'blue' | ||||
|  | @ -15,6 +11,13 @@ export function Button(props: { | |||
|   | 'gray' | ||||
|   | 'gradient' | ||||
|   | 'gray-white' | ||||
| 
 | ||||
| export function Button(props: { | ||||
|   className?: string | ||||
|   onClick?: () => void | ||||
|   children?: ReactNode | ||||
|   size?: SizeType | ||||
|   color?: ColorType | ||||
|   type?: 'button' | 'reset' | 'submit' | ||||
|   disabled?: boolean | ||||
| }) { | ||||
|  |  | |||
|  | @ -185,11 +185,16 @@ export function BinaryResolutionOrChance(props: { | |||
|   contract: BinaryContract | ||||
|   large?: boolean | ||||
|   className?: string | ||||
|   probAfter?: number // 0 to 1
 | ||||
| }) { | ||||
|   const { contract, large, className } = props | ||||
|   const { contract, large, className, probAfter } = props | ||||
|   const { resolution } = contract | ||||
|   const textColor = `text-${getColor(contract)}` | ||||
| 
 | ||||
|   const before = getBinaryProbPercent(contract) | ||||
|   const after = probAfter && formatPercent(probAfter) | ||||
|   const probChanged = before !== after | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> | ||||
|       {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')}> | ||||
|             chance | ||||
|           </div> | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ import { | |||
|   PseudoNumericResolutionOrExpectation, | ||||
| } from './contract-card' | ||||
| import { Bet } from 'common/bet' | ||||
| import BetRow from '../bet-row' | ||||
| import BetButton from '../bet-button' | ||||
| import { AnswersGraph } from '../answers/answers-graph' | ||||
| import { Contract, CPMMBinaryContract } from 'common/contract' | ||||
| import { ContractDescription } from './contract-description' | ||||
|  | @ -73,18 +73,18 @@ export const ContractOverview = (props: { | |||
|             <BinaryResolutionOrChance contract={contract} /> | ||||
| 
 | ||||
|             {tradingAllowed(contract) && ( | ||||
|               <BetRow contract={contract as CPMMBinaryContract} /> | ||||
|               <BetButton contract={contract as CPMMBinaryContract} /> | ||||
|             )} | ||||
|           </Row> | ||||
|         ) : isPseudoNumeric ? ( | ||||
|           <Row className="items-center justify-between gap-4 xl:hidden"> | ||||
|             <PseudoNumericResolutionOrExpectation contract={contract} /> | ||||
|             {tradingAllowed(contract) && <BetRow contract={contract} />} | ||||
|             {tradingAllowed(contract) && <BetButton contract={contract} />} | ||||
|           </Row> | ||||
|         ) : isPseudoNumeric ? ( | ||||
|           <Row className="items-center justify-between gap-4 xl:hidden"> | ||||
|             <PseudoNumericResolutionOrExpectation contract={contract} /> | ||||
|             {tradingAllowed(contract) && <BetRow contract={contract} />} | ||||
|             {tradingAllowed(contract) && <BetButton contract={contract} />} | ||||
|           </Row> | ||||
|         ) : ( | ||||
|           (outcomeType === 'FREE_RESPONSE' || | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ import { BinaryResolutionOrChance } from '../contract/contract-card' | |||
| import { SiteLink } from '../site-link' | ||||
| import { Col } from '../layout/col' | ||||
| import { UserLink } from '../user-page' | ||||
| import BetRow from '../bet-row' | ||||
| import BetButton from '../bet-button' | ||||
| import { Avatar } from '../avatar' | ||||
| import { ActivityItem } from './activity-items' | ||||
| import { useUser } from 'web/hooks/use-user' | ||||
|  | @ -76,7 +76,7 @@ export function FeedItems(props: { | |||
|       ) : ( | ||||
|         outcomeType === 'BINARY' && | ||||
|         tradingAllowed(contract) && ( | ||||
|           <BetRow | ||||
|           <BetButton | ||||
|             contract={contract as CPMMBinaryContract} | ||||
|             className={clsx('mb-2', betRowClassName)} | ||||
|           /> | ||||
|  |  | |||
|  | @ -2,17 +2,21 @@ import React from 'react' | |||
| import { useUser } from 'web/hooks/use-user' | ||||
| import { firebaseLogin } from 'web/lib/firebase/users' | ||||
| import { withTracking } from 'web/lib/service/analytics' | ||||
| import { Button } from './button' | ||||
| import { Button, SizeType } from './button' | ||||
| 
 | ||||
| export function SignUpPrompt(props: { label?: string; className?: string }) { | ||||
|   const { label, className } = props | ||||
| export function SignUpPrompt(props: { | ||||
|   label?: string | ||||
|   className?: string | ||||
|   size?: SizeType | ||||
| }) { | ||||
|   const { label, className, size = 'lg' } = props | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   return user === null ? ( | ||||
|     <Button | ||||
|       onClick={withTracking(firebaseLogin, 'sign up to bet')} | ||||
|       className={className} | ||||
|       size="lg" | ||||
|       size={size} | ||||
|       color="gradient" | ||||
|     > | ||||
|       {label ?? 'Sign up to bet!'} | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ export function YesNoSelector(props: { | |||
|             'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', | ||||
|             selected == 'YES' | ||||
|               ? 'bg-primary text-white' | ||||
|               : 'text-primary bg-transparent', | ||||
|               : 'text-primary bg-white', | ||||
|             btnClassName | ||||
|           )} | ||||
|           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', | ||||
|             selected == 'NO' | ||||
|               ? 'bg-red-400 text-white' | ||||
|               : 'bg-transparent text-red-400', | ||||
|               : 'bg-white text-red-400', | ||||
|             btnClassName | ||||
|           )} | ||||
|           onClick={() => onSelect('NO')} | ||||
|  |  | |||
|  | @ -1,8 +1,10 @@ | |||
| import { Bet } from 'common/bet' | ||||
| import { Contract, CPMMBinaryContract } from 'common/contract' | ||||
| import { Contract } from 'common/contract' | ||||
| import { DOMAIN } from 'common/envs/constants' | ||||
| import { useState } from 'react' | ||||
| 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 { | ||||
|   BinaryResolutionOrChance, | ||||
|   FreeResponseResolutionOrChance, | ||||
|  | @ -19,7 +21,6 @@ import { SiteLink } from 'web/components/site-link' | |||
| import { useContractWithPreload } from 'web/hooks/use-contract' | ||||
| import { useMeasureSize } from 'web/hooks/use-measure-size' | ||||
| import { fromPropz, usePropz } from 'web/hooks/use-propz' | ||||
| import { useWindowSize } from 'web/hooks/use-window-size' | ||||
| import { listAllBets } from 'web/lib/firebase/bets' | ||||
| import { | ||||
|   contractPath, | ||||
|  | @ -88,18 +89,15 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { | |||
| 
 | ||||
|   const href = `https://${DOMAIN}${contractPath(contract)}` | ||||
| 
 | ||||
|   const { height: windowHeight } = useWindowSize() | ||||
|   const { setElem, height: topSectionHeight } = useMeasureSize() | ||||
|   const paddingBottom = 8 | ||||
|   const { setElem, height: graphHeight } = useMeasureSize() | ||||
| 
 | ||||
|   const graphHeight = | ||||
|     windowHeight && topSectionHeight | ||||
|       ? windowHeight - topSectionHeight - paddingBottom | ||||
|       : 0 | ||||
|   const [betPanelOpen, setBetPanelOpen] = useState(false) | ||||
| 
 | ||||
|   const [probAfter, setProbAfter] = useState<number>() | ||||
| 
 | ||||
|   return ( | ||||
|     <Col className="w-full flex-1 bg-white"> | ||||
|       <div className="relative flex flex-col pt-2" ref={setElem}> | ||||
|     <Col className="h-[100vh] w-full bg-white"> | ||||
|       <div className="relative flex flex-col pt-2"> | ||||
|         <div className="px-3 text-xl text-indigo-700 md:text-2xl"> | ||||
|           <SiteLink href={href}>{question}</SiteLink> | ||||
|         </div> | ||||
|  | @ -114,25 +112,24 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { | |||
|             disabled | ||||
|           /> | ||||
| 
 | ||||
|           {isBinary && ( | ||||
|             <Row className="items-center gap-4"> | ||||
|               {tradingAllowed(contract) && ( | ||||
|                 <BetRow | ||||
|                   contract={contract as CPMMBinaryContract} | ||||
|                   betPanelClassName="scale-75" | ||||
|                 /> | ||||
|           {(isBinary || isPseudoNumeric) && | ||||
|             tradingAllowed(contract) && | ||||
|             !betPanelOpen && ( | ||||
|               <Button color="gradient" onClick={() => setBetPanelOpen(true)}> | ||||
|                 Bet | ||||
|               </Button> | ||||
|             )} | ||||
|               <BinaryResolutionOrChance contract={contract} /> | ||||
|             </Row> | ||||
| 
 | ||||
|           {isBinary && ( | ||||
|             <BinaryResolutionOrChance | ||||
|               contract={contract} | ||||
|               probAfter={probAfter} | ||||
|               className="items-center" | ||||
|             /> | ||||
|           )} | ||||
| 
 | ||||
|           {isPseudoNumeric && ( | ||||
|             <Row className="items-center gap-4"> | ||||
|               {tradingAllowed(contract) && ( | ||||
|                 <BetRow contract={contract} betPanelClassName="scale-75" /> | ||||
|               )} | ||||
|             <PseudoNumericResolutionOrExpectation contract={contract} /> | ||||
|             </Row> | ||||
|           )} | ||||
| 
 | ||||
|           {outcomeType === 'FREE_RESPONSE' && ( | ||||
|  | @ -150,7 +147,16 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { | |||
|         <Spacer h={2} /> | ||||
|       </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) && ( | ||||
|           <ContractProbGraph | ||||
|             contract={contract} | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user