From 9ba7c0452456b4c2da28a9359af9a78cfcb1da5b Mon Sep 17 00:00:00 2001 From: Boa Date: Wed, 20 Apr 2022 08:13:39 -0600 Subject: [PATCH] Sell shares mobile (#86) * Abstract sell shares row to component * Allow sell row to show just a button This is nice for the feed and on a bet's mobile interface. * Add and use floor shares * Allow sell button on the same line as bet button * Move use save shares to own file * Make sure to sell non-integer shares * Create SellButon & sell non-integer shares * Remove props prefixes * Break out sell modal and button --- web/components/bet-panel.tsx | 152 +++++------------------------ web/components/bet-row.tsx | 26 ++++- web/components/sell-button.tsx | 64 ++++++++++++ web/components/sell-modal.tsx | 47 +++++++++ web/components/sell-row.tsx | 77 +++++++++++++++ web/components/use-save-shares.ts | 59 +++++++++++ web/components/yes-no-selector.tsx | 71 +++++++++----- 7 files changed, 338 insertions(+), 158 deletions(-) create mode 100644 web/components/sell-button.tsx create mode 100644 web/components/sell-modal.tsx create mode 100644 web/components/sell-row.tsx create mode 100644 web/components/use-save-shares.ts diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 67fdf30c..492a1b21 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' -import _ from 'lodash' import { useUser } from '../hooks/use-user' import { Binary, CPMM, DPM, FullContract } from '../../common/contract' @@ -19,7 +18,7 @@ import { Bet } from '../../common/bet' import { placeBet, sellShares } from '../lib/firebase/api-call' import { BuyAmountInput, SellAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel, OutcomeLabel } from './outcome-label' +import { BinaryOutcomeLabel } from './outcome-label' import { calculatePayoutAfterCorrectBet, calculateShares, @@ -32,61 +31,30 @@ import { calculateCpmmSale, getCpmmProbability, } from '../../common/calculate-cpmm' -import { Modal } from './layout/modal' +import { SellRow } from './sell-row' +import { useSaveShares } from './use-save-shares' export function BetPanel(props: { contract: FullContract className?: string }) { const { contract, className } = props - const { mechanism } = contract - const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) - - const [showSellModal, setShowSellModal] = useState(false) - - const { yesShares, noShares } = useSaveShares(contract, userBets) - - const shares = yesShares || noShares - const sharesOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined + const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined return ( - {sharesOutcome && user && mechanism === 'cpmm-1' && ( - - -
- You have {formatWithCommas(Math.floor(shares))}{' '} - shares -
- - - - {showSellModal && ( - } - user={user} - userBets={userBets ?? []} - shares={shares} - sharesOutcome={sharesOutcome} - setOpen={setShowSellModal} - /> - )} -
- - )} - + ('BUY') - const { yesShares, noShares } = useSaveShares(contract, userBets) + const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( + contract, + userBets + ) - const shares = yesShares || noShares - const sharesOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined + const floorShares = yesFloorShares || noFloorShares + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined useEffect(() => { // Switch back to BUY if the user has sold all their shares. @@ -146,7 +121,7 @@ export function BetPanelSwitcher(props: {
- You have {formatWithCommas(Math.floor(shares))}{' '} + You have {formatWithCommas(floorShares)}{' '} shares
@@ -394,7 +369,7 @@ function BuyPanel(props: { ) } -function SellPanel(props: { +export function SellPanel(props: { contract: FullContract userBets: Bet[] shares: number @@ -493,78 +468,3 @@ function SellPanel(props: { ) } - -const useSaveShares = ( - contract: FullContract, - userBets: Bet[] | undefined -) => { - const [savedShares, setSavedShares] = useState< - { yesShares: number; noShares: number } | undefined - >() - - const [yesBets, noBets] = _.partition( - userBets ?? [], - (bet) => bet.outcome === 'YES' - ) - const [yesShares, noShares] = [ - _.sumBy(yesBets, (bet) => bet.shares), - _.sumBy(noBets, (bet) => bet.shares), - ] - - useEffect(() => { - // Save yes and no shares to local storage. - const savedShares = localStorage.getItem(`${contract.id}-shares`) - if (!userBets && savedShares) { - setSavedShares(JSON.parse(savedShares)) - } - - if (userBets) { - const updatedShares = { yesShares, noShares } - localStorage.setItem( - `${contract.id}-shares`, - JSON.stringify(updatedShares) - ) - } - }, [contract.id, userBets, noShares, yesShares]) - - if (userBets) return { yesShares, noShares } - return savedShares ?? { yesShares: 0, noShares: 0 } -} - -function SellSharesModal(props: { - contract: FullContract - userBets: Bet[] - shares: number - sharesOutcome: 'YES' | 'NO' - user: User - setOpen: (open: boolean) => void -}) { - const { contract, shares, sharesOutcome, userBets, user, setOpen } = props - - return ( - - - - - <div className="mb-6"> - You have {formatWithCommas(Math.floor(shares))}{' '} - <OutcomeLabel - outcome={sharesOutcome} - contract={contract} - truncate="long" - />{' '} - shares - </div> - - <SellPanel - contract={contract} - shares={shares} - sharesOutcome={sharesOutcome} - user={user} - userBets={userBets ?? []} - onSellSuccess={() => setOpen(false)} - /> - </Col> - </Modal> - ) -} diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index e1b99c3a..4285fe98 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx' import { useState } from 'react' import { BetPanelSwitcher } from './bet-panel' @@ -6,6 +5,10 @@ import { Row } from './layout/row' import { YesNoSelector } from './yes-no-selector' import { Binary, CPMM, DPM, FullContract } from '../../common/contract' import { Modal } from './layout/modal' +import { SellButton } from './sell-button' +import { useUser } from '../hooks/use-user' +import { useUserContractBets } from '../hooks/use-user-bets' +import { useSaveShares } from './use-save-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { @@ -13,16 +16,19 @@ export default function BetRow(props: { className?: string labelClassName?: string }) { - const { className, labelClassName } = props + const { className, labelClassName, contract } = props const [open, setOpen] = useState(false) const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>( undefined ) + const user = useUser() + const userBets = useUserContractBets(user?.id, contract.id) + const { yesFloorShares, noFloorShares } = useSaveShares(contract, userBets) return ( <> <div className={className}> - <Row className="items-center justify-end gap-2"> + <Row className="mt-2 justify-end space-x-3"> {/* <div className={clsx('mr-2 text-gray-400', labelClassName)}> Place a trade </div> */} @@ -32,12 +38,22 @@ export default function BetRow(props: { setOpen(true) setBetChoice(choice) }} + replaceNoButton={ + yesFloorShares > noFloorShares && yesFloorShares > 0 ? ( + <SellButton contract={contract} user={user} /> + ) : undefined + } + replaceYesButton={ + noFloorShares > yesFloorShares && noFloorShares > 0 ? ( + <SellButton contract={contract} user={user} /> + ) : undefined + } /> </Row> <Modal open={open} setOpen={setOpen}> <BetPanelSwitcher - contract={props.contract} - title={props.contract.question} + contract={contract} + title={contract.question} selected={betChoice} onBetSuccess={() => setOpen(false)} /> diff --git a/web/components/sell-button.tsx b/web/components/sell-button.tsx new file mode 100644 index 00000000..3b66d46a --- /dev/null +++ b/web/components/sell-button.tsx @@ -0,0 +1,64 @@ +import { Binary, CPMM, DPM, FullContract } from '../../common/contract' +import { User } from '../../common/user' +import { useUserContractBets } from '../hooks/use-user-bets' +import { useState } from 'react' +import { useSaveShares } from './use-save-shares' +import { Col } from './layout/col' +import clsx from 'clsx' +import { SellSharesModal } from './sell-modal' + +export function SellButton(props: { + contract: FullContract<DPM | CPMM, Binary> + user: User | null | undefined +}) { + const { contract, user } = props + + const userBets = useUserContractBets(user?.id, contract.id) + const [showSellModal, setShowSellModal] = useState(false) + + const { mechanism } = contract + const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( + contract, + userBets + ) + const floorShares = yesFloorShares || noFloorShares + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined + + if (sharesOutcome && user && mechanism === 'cpmm-1') { + return ( + <Col className={'items-center'}> + <button + className={clsx( + 'btn-sm w-24 gap-1', + // from the yes-no-selector: + 'flex inline-flex flex-row items-center justify-center rounded-3xl border-2 p-2', + sharesOutcome === 'NO' + ? 'hover:bg-primary-focus border-primary hover:border-primary-focus text-primary hover:text-white' + : 'border-red-400 text-red-500 hover:border-red-500 hover:bg-red-500 hover:text-white' + )} + onClick={() => setShowSellModal(true)} + > + {'Sell ' + sharesOutcome} + </button> + <div className={'mt-1 w-24 text-center text-sm text-gray-500'}> + {'(' + floorShares + ' shares)'} + </div> + {showSellModal && ( + <SellSharesModal + contract={contract as FullContract<CPMM, Binary>} + user={user} + userBets={userBets ?? []} + shares={yesShares || noShares} + sharesOutcome={sharesOutcome} + setOpen={setShowSellModal} + /> + )} + </Col> + ) + } + return <div /> +} diff --git a/web/components/sell-modal.tsx b/web/components/sell-modal.tsx new file mode 100644 index 00000000..19954d7f --- /dev/null +++ b/web/components/sell-modal.tsx @@ -0,0 +1,47 @@ +import { Binary, CPMM, FullContract } from '../../common/contract' +import { Bet } from '../../common/bet' +import { User } from '../../common/user' +import { Modal } from './layout/modal' +import { Col } from './layout/col' +import { Title } from './title' +import { formatWithCommas } from '../../common/util/format' +import { OutcomeLabel } from './outcome-label' +import { SellPanel } from './bet-panel' + +export function SellSharesModal(props: { + contract: FullContract<CPMM, Binary> + userBets: Bet[] + shares: number + sharesOutcome: 'YES' | 'NO' + user: User + setOpen: (open: boolean) => void +}) { + const { contract, shares, sharesOutcome, userBets, user, setOpen } = props + + return ( + <Modal open={true} setOpen={setOpen}> + <Col className="rounded-md bg-white px-8 py-6"> + <Title className="!mt-0" text={'Sell shares'} /> + + <div className="mb-6"> + You have {formatWithCommas(Math.floor(shares))}{' '} + <OutcomeLabel + outcome={sharesOutcome} + contract={contract} + truncate={'short'} + />{' '} + shares + </div> + + <SellPanel + contract={contract} + shares={shares} + sharesOutcome={sharesOutcome} + user={user} + userBets={userBets ?? []} + onSellSuccess={() => setOpen(false)} + /> + </Col> + </Modal> + ) +} diff --git a/web/components/sell-row.tsx b/web/components/sell-row.tsx new file mode 100644 index 00000000..c30f799e --- /dev/null +++ b/web/components/sell-row.tsx @@ -0,0 +1,77 @@ +import { Binary, CPMM, DPM, FullContract } from '../../common/contract' +import { User } from '../../common/user' +import { useState } from 'react' +import { Col } from './layout/col' +import { Row } from './layout/row' +import { formatWithCommas } from '../../common/util/format' +import { OutcomeLabel } from './outcome-label' +import { useUserContractBets } from '../hooks/use-user-bets' +import { useSaveShares } from './use-save-shares' +import { SellSharesModal } from './sell-modal' + +export function SellRow(props: { + contract: FullContract<DPM | CPMM, Binary> + user: User | null | undefined + className?: string +}) { + const { className, contract, user } = props + + const userBets = useUserContractBets(user?.id, contract.id) + const [showSellModal, setShowSellModal] = useState(false) + + const { mechanism } = contract + const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares( + contract, + userBets + ) + const floorShares = yesFloorShares || noFloorShares + const sharesOutcome = yesFloorShares + ? 'YES' + : noFloorShares + ? 'NO' + : undefined + + if (sharesOutcome && user && mechanism === 'cpmm-1') { + return ( + <div> + <Col className={className}> + <Row className="items-center justify-between gap-2 "> + <div> + You have {formatWithCommas(floorShares)}{' '} + <OutcomeLabel + outcome={sharesOutcome} + contract={contract} + truncate={'short'} + />{' '} + shares + </div> + + <button + className="btn btn-sm" + style={{ + backgroundColor: 'white', + border: '2px solid', + color: '#3D4451', + }} + onClick={() => setShowSellModal(true)} + > + Sell + </button> + </Row> + </Col> + {showSellModal && ( + <SellSharesModal + contract={contract as FullContract<CPMM, Binary>} + user={user} + userBets={userBets ?? []} + shares={yesShares || noShares} + sharesOutcome={sharesOutcome} + setOpen={setShowSellModal} + /> + )} + </div> + ) + } + + return <div /> +} diff --git a/web/components/use-save-shares.ts b/web/components/use-save-shares.ts new file mode 100644 index 00000000..467e19ae --- /dev/null +++ b/web/components/use-save-shares.ts @@ -0,0 +1,59 @@ +import { Binary, CPMM, DPM, FullContract } from '../../common/contract' +import { Bet } from '../../common/bet' +import { useEffect, useState } from 'react' +import _ from 'lodash' + +export const useSaveShares = ( + contract: FullContract<CPMM | DPM, Binary>, + userBets: Bet[] | undefined +) => { + const [savedShares, setSavedShares] = useState< + | { + yesShares: number + noShares: number + yesFloorShares: number + noFloorShares: number + } + | undefined + >() + + const [yesBets, noBets] = _.partition( + userBets ?? [], + (bet) => bet.outcome === 'YES' + ) + const [yesShares, noShares] = [ + _.sumBy(yesBets, (bet) => bet.shares), + _.sumBy(noBets, (bet) => bet.shares), + ] + + const [yesFloorShares, noFloorShares] = [ + Math.floor(yesShares), + Math.floor(noShares), + ] + + useEffect(() => { + // Save yes and no shares to local storage. + const savedShares = localStorage.getItem(`${contract.id}-shares`) + if (!userBets && savedShares) { + setSavedShares(JSON.parse(savedShares)) + } + + if (userBets) { + const updatedShares = { yesShares, noShares } + localStorage.setItem( + `${contract.id}-shares`, + JSON.stringify(updatedShares) + ) + } + }, [contract.id, userBets, noShares, yesShares]) + + if (userBets) return { yesShares, noShares, yesFloorShares, noFloorShares } + return ( + savedShares ?? { + yesShares: 0, + noShares: 0, + yesFloorShares: 0, + noFloorShares: 0, + } + ) +} diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index d2e7ae41..4bb7949f 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -9,40 +9,57 @@ export function YesNoSelector(props: { onSelect: (selected: 'YES' | 'NO') => void className?: string btnClassName?: string + replaceYesButton?: React.ReactNode + replaceNoButton?: React.ReactNode }) { - const { selected, onSelect, className, btnClassName } = props + const { + selected, + onSelect, + className, + btnClassName, + replaceNoButton, + replaceYesButton, + } = props const commonClassNames = 'inline-flex flex-1 items-center justify-center rounded-3xl border-2 p-2' return ( <Row className={clsx('space-x-3', className)}> - <button - className={clsx( - commonClassNames, - 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', - selected == 'YES' - ? 'bg-primary text-white' - : 'text-primary bg-transparent', - btnClassName - )} - onClick={() => onSelect('YES')} - > - Bet YES - </button> - <button - className={clsx( - commonClassNames, - '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', - btnClassName - )} - onClick={() => onSelect('NO')} - > - Bet NO - </button> + {replaceYesButton ? ( + replaceYesButton + ) : ( + <button + className={clsx( + commonClassNames, + 'hover:bg-primary-focus border-primary hover:border-primary-focus hover:text-white', + selected == 'YES' + ? 'bg-primary text-white' + : 'text-primary bg-transparent', + btnClassName + )} + onClick={() => onSelect('YES')} + > + Bet YES + </button> + )} + {replaceNoButton ? ( + replaceNoButton + ) : ( + <button + className={clsx( + commonClassNames, + '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', + btnClassName + )} + onClick={() => onSelect('NO')} + > + Bet NO + </button> + )} </Row> ) }