Skip to content

Commit

Permalink
feat: Web 2996 add fiat on ramp buy flow to swap modal on the interfa…
Browse files Browse the repository at this point in the history
…ce (Uniswap#6240)

* init

* testing if it works

* wip

* tooltip still not working correctly

* modal still not triggered after initial buy click

* remove invalid import

* region check fixed

* add disabled buy button treatment

* simplify and fix toggle twice bug

* no more state mgmt bugs finally

* rename vars for clarity and add todos

* add feature flag, remove toast

* keep wallet drawer open upon repeated buy clicks

* remove from feature flag modal for now

* unused vars

* first round respond to tina comments

* respond to tina padding comments, fix padding in response to cal feedback

* last round tina comments

* add tooltip delay requested by fred and cal

* middle of revisions, fiat buy flow readability wip

* hook logic refactor done + added basic unit test

* rename enum and add todo for unit tests

* mouseover tooltip disable properly

* fix mouseover tooltip not working, ensure dot working as expected, rename buyFiatClicked to buyFiatFlowCompleted

* change developer doc comment

* respond comments

* update snapshot test

* comments

* small changes + unit tests

* dedup

* remove enzyme

* Remove unecessary line

* simplify

* more cleanup

* add missing await

* more comments

* more comment responses

* more comment responses

* delay show fixes and respond to comments

* fix logic for show

* remove tooltip delay, unit test changes

* Update src/components/Popover/index.tsx

Co-authored-by: Zach Pomerantz <[email protected]>

* remove delay on tooltip

* missed one

* Update src/components/swap/SwapBuyFiatButton.test.tsx

Co-authored-by: Tina <[email protected]>

* comments

* .

* lint error

---------

Co-authored-by: Zach Pomerantz <[email protected]>
Co-authored-by: Tina <[email protected]>
  • Loading branch information
3 people authored Apr 6, 2023
1 parent 972a650 commit 55bd355
Show file tree
Hide file tree
Showing 13 changed files with 506 additions and 35 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@lingui/cli": "^3.9.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1",
"@testing-library/user-event": "^14.4.3",
"@typechain/ethers-v5": "^7.0.0",
"@types/array.prototype.flat": "^1.2.1",
"@types/array.prototype.flatmap": "^1.2.2",
Expand Down
2 changes: 1 addition & 1 deletion src/components/SearchModal/CurrencyList/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ jest.mock(
jest.mock('@web3-react/core', () => {
const web3React = jest.requireActual('@web3-react/core')
return {
...web3React,
useWeb3React: () => ({
account: '123',
isActive: true,
}),
...web3React,
}
})

Expand Down
61 changes: 41 additions & 20 deletions src/components/Tooltip/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { transparentize } from 'polished'
import { ReactNode, useCallback, useEffect, useState } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import styled from 'styled-components/macro'

import Popover, { PopoverProps } from '../Popover'

// TODO(WEB-3163): migrate noops throughout web to a shared util file.
const noop = () => null

export const TooltipContainer = styled.div`
max-width: 256px;
cursor: default;
padding: 0.6rem 1rem;
pointer-events: auto;
color: ${({ theme }) => theme.textPrimary};
font-weight: 400;
Expand All @@ -25,25 +29,26 @@ interface TooltipProps extends Omit<PopoverProps, 'content'> {
text: ReactNode
open?: () => void
close?: () => void
noOp?: () => void
disableHover?: boolean // disable the hover and content display
timeout?: number
}

interface TooltipContentProps extends Omit<PopoverProps, 'content'> {
content: ReactNode
onOpen?: () => void
open?: () => void
close?: () => void
// whether to wrap the content in a `TooltipContainer`
wrap?: boolean
disableHover?: boolean // disable the hover and content display
}

export default function Tooltip({ text, open, close, noOp, disableHover, ...rest }: TooltipProps) {
export default function Tooltip({ text, open, close, disableHover, ...rest }: TooltipProps) {
return (
<Popover
content={
text && (
<TooltipContainer onMouseEnter={disableHover ? noOp : open} onMouseLeave={disableHover ? noOp : close}>
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
{text}
</TooltipContainer>
)
Expand All @@ -53,15 +58,28 @@ export default function Tooltip({ text, open, close, noOp, disableHover, ...rest
)
}

function TooltipContent({ content, wrap = false, ...rest }: TooltipContentProps) {
return <Popover content={wrap ? <TooltipContainer>{content}</TooltipContainer> : content} {...rest} />
function TooltipContent({ content, wrap = false, open, close, disableHover, ...rest }: TooltipContentProps) {
return (
<Popover
content={
wrap ? (
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
{content}
</TooltipContainer>
) : (
content
)
}
{...rest}
/>
)
}

/** Standard text tooltip. */
export function MouseoverTooltip({ text, disableHover, children, timeout, ...rest }: Omit<TooltipProps, 'show'>) {
const [show, setShow] = useState(false)
const open = useCallback(() => text && setShow(true), [text, setShow])
const close = useCallback(() => setShow(false), [setShow])
const open = () => text && setShow(true)
const close = () => setShow(false)

useEffect(() => {
if (show && timeout) {
Expand All @@ -76,18 +94,16 @@ export function MouseoverTooltip({ text, disableHover, children, timeout, ...res
return
}, [timeout, show])

const noOp = () => null
return (
<Tooltip
{...rest}
open={open}
close={close}
noOp={noOp}
disableHover={disableHover}
show={show}
text={disableHover ? null : text}
>
<div onMouseEnter={disableHover ? noOp : open} onMouseLeave={disableHover || timeout ? noOp : close}>
<div onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover || timeout ? noop : close}>
{children}
</div>
</Tooltip>
Expand All @@ -103,18 +119,23 @@ export function MouseoverTooltipContent({
...rest
}: Omit<TooltipContentProps, 'show'>) {
const [show, setShow] = useState(false)
const open = useCallback(() => {
const open = () => {
setShow(true)
openCallback?.()
}, [openCallback])
const close = useCallback(() => setShow(false), [setShow])
}
const close = () => {
setShow(false)
}

return (
<TooltipContent {...rest} show={!disableHover && show} content={disableHover ? null : content}>
<div
style={{ display: 'inline-block', lineHeight: 0, padding: '0.25rem' }}
onMouseEnter={open}
onMouseLeave={close}
>
<TooltipContent
{...rest}
open={open}
close={close}
show={!disableHover && show}
content={disableHover ? null : content}
>
<div onMouseEnter={open} onMouseLeave={close}>
{children}
</div>
</TooltipContent>
Expand Down
111 changes: 111 additions & 0 deletions src/components/swap/SwapBuyFiatButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import userEvent from '@testing-library/user-event'
import { useWeb3React } from '@web3-react/core'
import { useWalletDrawer } from 'components/WalletDropdown'
import { fireEvent, render, screen } from 'test-utils'

import { useFiatOnrampAvailability, useOpenModal } from '../../state/application/hooks'
import SwapBuyFiatButton, { MOONPAY_REGION_AVAILABILITY_ARTICLE } from './SwapBuyFiatButton'

jest.mock('@web3-react/core', () => {
const web3React = jest.requireActual('@web3-react/core')
return {
...web3React,
useWeb3React: jest.fn(),
}
})

jest.mock('../../state/application/hooks')
const mockUseFiatOnrampAvailability = useFiatOnrampAvailability as jest.MockedFunction<typeof useFiatOnrampAvailability>
const mockUseOpenModal = useOpenModal as jest.MockedFunction<typeof useOpenModal>

jest.mock('components/WalletDropdown')
const mockUseWalletDrawer = useWalletDrawer as jest.MockedFunction<typeof useWalletDrawer>

const mockUseFiatOnRampsUnavailable = (shouldCheck: boolean) => {
return {
available: false,
availabilityChecked: shouldCheck,
error: null,
loading: false,
}
}

const mockUseFiatOnRampsAvailable = (shouldCheck: boolean) => {
if (shouldCheck) {
return {
available: true,
availabilityChecked: true,
error: null,
loading: false,
}
}
return {
available: false,
availabilityChecked: false,
error: null,
loading: false,
}
}

describe('SwapBuyFiatButton.tsx', () => {
let toggleWalletDrawer: jest.Mock<any, any>
let useOpenModal: jest.Mock<any, any>

beforeAll(() => {
toggleWalletDrawer = jest.fn()
useOpenModal = jest.fn()
})

beforeEach(() => {
jest.resetAllMocks()
;(useWeb3React as jest.Mock).mockReturnValue({
account: undefined,
isActive: false,
})
})

it('matches base snapshot', () => {
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsUnavailable)
mockUseWalletDrawer.mockImplementation(() => [false, toggleWalletDrawer])
const { asFragment } = render(<SwapBuyFiatButton />)
expect(asFragment()).toMatchSnapshot()
})

it('fiat on ramps available in region, account unconnected', async () => {
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsAvailable)
mockUseWalletDrawer.mockImplementation(() => [false, toggleWalletDrawer])
mockUseOpenModal.mockImplementation(() => useOpenModal)
render(<SwapBuyFiatButton />)
await userEvent.click(screen.getByTestId('buy-fiat-button'))
expect(toggleWalletDrawer).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('fiat-on-ramp-unavailable-tooltip')).not.toBeInTheDocument()
})

it('fiat on ramps available in region, account connected', async () => {
;(useWeb3React as jest.Mock).mockReturnValue({
account: '0x52270d8234b864dcAC9947f510CE9275A8a116Db',
isActive: true,
})
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsAvailable)
mockUseWalletDrawer.mockImplementation(() => [false, toggleWalletDrawer])
mockUseOpenModal.mockImplementation(() => useOpenModal)
render(<SwapBuyFiatButton />)
expect(screen.getByTestId('buy-fiat-flow-incomplete-indicator')).toBeInTheDocument()
await userEvent.click(screen.getByTestId('buy-fiat-button'))
expect(toggleWalletDrawer).toHaveBeenCalledTimes(0)
expect(useOpenModal).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('fiat-on-ramp-unavailable-tooltip')).not.toBeInTheDocument()
expect(screen.queryByTestId('buy-fiat-flow-incomplete-indicator')).not.toBeInTheDocument()
})

it('fiat on ramps unavailable in region', async () => {
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsUnavailable)
mockUseWalletDrawer.mockImplementation(() => [false, toggleWalletDrawer])
render(<SwapBuyFiatButton />)
await userEvent.click(screen.getByTestId('buy-fiat-button'))
fireEvent.mouseOver(screen.getByTestId('buy-fiat-button'))
expect(await screen.findByTestId('fiat-on-ramp-unavailable-tooltip')).toBeInTheDocument()
expect(await screen.findByText(/Learn more/i)).toHaveAttribute('href', MOONPAY_REGION_AVAILABILITY_ARTICLE)
expect(await screen.findByTestId('buy-fiat-button')).toBeDisabled()
})
})
131 changes: 131 additions & 0 deletions src/components/swap/SwapBuyFiatButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Trans } from '@lingui/macro'
import { useWeb3React } from '@web3-react/core'
import { ButtonText } from 'components/Button'
import { MouseoverTooltipContent } from 'components/Tooltip'
import { useWalletDrawer } from 'components/WalletDropdown'
import { useCallback, useEffect, useState } from 'react'
import { useBuyFiatFlowCompleted } from 'state/user/hooks'
import styled from 'styled-components/macro'
import { ExternalLink } from 'theme'

import { useFiatOnrampAvailability, useOpenModal } from '../../state/application/hooks'
import { ApplicationModal } from '../../state/application/reducer'

const Dot = styled.div`
height: 8px;
width: 8px;
background-color: ${({ theme }) => theme.accentActive};
border-radius: 50%;
`

export const MOONPAY_REGION_AVAILABILITY_ARTICLE =
'https://support.uniswap.org/hc/en-us/articles/11306664890381-Why-isn-t-MoonPay-available-in-my-region-'

enum BuyFiatFlowState {
// Default initial state. User is not actively trying to buy fiat.
INACTIVE,
// Buy fiat flow is active and region availability has been checked.
ACTIVE_CHECKING_REGION,
// Buy fiat flow is active, feature is available in user's region & needs wallet connection.
ACTIVE_NEEDS_ACCOUNT,
}

const StyledTextButton = styled(ButtonText)`
color: ${({ theme }) => theme.textSecondary};
gap: 4px;
&:focus {
text-decoration: none;
}
&:active {
text-decoration: none;
}
`

export default function SwapBuyFiatButton() {
const { account } = useWeb3React()
const openFiatOnRampModal = useOpenModal(ApplicationModal.FIAT_ONRAMP)
const [buyFiatFlowCompleted, setBuyFiatFlowCompleted] = useBuyFiatFlowCompleted()
const [checkFiatRegionAvailability, setCheckFiatRegionAvailability] = useState(false)
const {
available: fiatOnrampAvailable,
availabilityChecked: fiatOnrampAvailabilityChecked,
loading: fiatOnrampAvailabilityLoading,
} = useFiatOnrampAvailability(checkFiatRegionAvailability)
const [buyFiatFlowState, setBuyFiatFlowState] = useState(BuyFiatFlowState.INACTIVE)
const [walletDrawerOpen, toggleWalletDrawer] = useWalletDrawer()

/*
* Depending on the current state of the buy fiat flow the user is in (buyFiatFlowState),
* the desired behavior of clicking the 'Buy' button is different.
* 1) Initially upon first click, need to check the availability of the feature in the user's
* region, and continue the flow.
* 2) If the feature is available in the user's region, need to connect a wallet, and continue
* the flow.
* 3) If the feature is available and a wallet account is connected, show fiat on ramp modal.
* 4) If the feature is unavailable, show feature unavailable tooltip.
*/
const handleBuyCrypto = useCallback(() => {
if (!fiatOnrampAvailabilityChecked) {
setCheckFiatRegionAvailability(true)
setBuyFiatFlowState(BuyFiatFlowState.ACTIVE_CHECKING_REGION)
} else if (fiatOnrampAvailable && !account && !walletDrawerOpen) {
toggleWalletDrawer()
setBuyFiatFlowState(BuyFiatFlowState.ACTIVE_NEEDS_ACCOUNT)
} else if (fiatOnrampAvailable && account) {
openFiatOnRampModal()
setBuyFiatFlowCompleted(true)
setBuyFiatFlowState(BuyFiatFlowState.INACTIVE)
} else if (!fiatOnrampAvailable) {
setBuyFiatFlowCompleted(true)
setBuyFiatFlowState(BuyFiatFlowState.INACTIVE)
}
}, [
fiatOnrampAvailabilityChecked,
fiatOnrampAvailable,
account,
walletDrawerOpen,
toggleWalletDrawer,
openFiatOnRampModal,
setBuyFiatFlowCompleted,
])

// Continue buy fiat flow automatically when requisite state changes have occured.
useEffect(() => {
if (
(buyFiatFlowState === BuyFiatFlowState.ACTIVE_CHECKING_REGION && fiatOnrampAvailabilityChecked) ||
(account && buyFiatFlowState === BuyFiatFlowState.ACTIVE_NEEDS_ACCOUNT)
) {
handleBuyCrypto()
}
}, [account, handleBuyCrypto, buyFiatFlowState, fiatOnrampAvailabilityChecked])

const buyCryptoButtonDisabled =
(!fiatOnrampAvailable && fiatOnrampAvailabilityChecked) ||
fiatOnrampAvailabilityLoading ||
// When wallet drawer is open AND user is in the connect wallet step of the buy fiat flow, disable buy fiat button.
(walletDrawerOpen && buyFiatFlowState === BuyFiatFlowState.ACTIVE_NEEDS_ACCOUNT)

const fiatOnRampsUnavailableTooltipDisabled =
!fiatOnrampAvailabilityChecked || (fiatOnrampAvailabilityChecked && fiatOnrampAvailable)

return (
<MouseoverTooltipContent
wrap
content={
<div data-testid="fiat-on-ramp-unavailable-tooltip">
<Trans>Crypto purchases are not available in your region. </Trans>
<ExternalLink href={MOONPAY_REGION_AVAILABILITY_ARTICLE} style={{ paddingLeft: '4px' }}>
<Trans>Learn more</Trans>
</ExternalLink>
</div>
}
placement="bottom"
disableHover={fiatOnRampsUnavailableTooltipDisabled}
>
<StyledTextButton onClick={handleBuyCrypto} disabled={buyCryptoButtonDisabled} data-testid="buy-fiat-button">
<Trans>Buy</Trans>
{!buyFiatFlowCompleted && <Dot data-testid="buy-fiat-flow-incomplete-indicator" />}
</StyledTextButton>
</MouseoverTooltipContent>
)
}
Loading

0 comments on commit 55bd355

Please sign in to comment.