diff --git a/.env.production b/.env.production index ad5b04eaba..3d88b3641c 100644 --- a/.env.production +++ b/.env.production @@ -5,6 +5,6 @@ REACT_APP_GOOGLE_ANALYTICS_ID="G-KDP9B6W4H8" REACT_APP_INFURA_KEY="099fc58e0de9451d80b18d7c74caa7c1" REACT_APP_MOONPAY_API="https://api.moonpay.com" REACT_APP_MOONPAY_LINK="https://us-central1-uniswap-mobile.cloudfunctions.net/signMoonpayLink" -REACT_APP_MOONPAY_PUBLISHABLE_KEY="" +REACT_APP_MOONPAY_PUBLISHABLE_KEY="pk_live_uQG4BJC4w3cxnqpcSqAfohdBFDTsY6E" REACT_APP_FIREBASE_KEY="AIzaSyBcZWwTcTJHj_R6ipZcrJkXdq05PuX0Rs0" THE_GRAPH_SCHEMA_ENDPOINT="https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3" diff --git a/src/components/Button/LoadingButtonSpinner.tsx b/src/components/Button/LoadingButtonSpinner.tsx new file mode 100644 index 0000000000..f429ffac04 --- /dev/null +++ b/src/components/Button/LoadingButtonSpinner.tsx @@ -0,0 +1,13 @@ +import { SpinnerSVG } from 'theme' + +const ButtonLoadingSpinner = (props: React.ComponentPropsWithoutRef<'svg'>) => ( + + + + +) + +export default ButtonLoadingSpinner diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 252bc75a55..dd245272b7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -5,6 +5,8 @@ import styled, { DefaultTheme, useTheme } from 'styled-components/macro' import { RowBetween } from '../Row' +export { default as LoadingButtonSpinner } from './LoadingButtonSpinner' + type ButtonProps = Omit export const BaseButton = styled(RebassButton)< @@ -362,18 +364,6 @@ export function ButtonRadioChecked({ active = false, children, ...rest }: { acti } } -const ButtonOverlay = styled.div` - background-color: transparent; - bottom: 0; - border-radius: 16px; - height: 100%; - left: 0; - position: absolute; - right: 0; - top: 0; - transition: 150ms ease background-color; - width: 100%; -` export enum ButtonSize { small, medium, @@ -466,7 +456,18 @@ function pickThemeButtonTextColor({ theme, emphasis }: { theme: DefaultTheme; em return theme.textPrimary } } - +const ButtonOverlay = styled.div` + background-color: transparent; + bottom: 0; + border-radius: inherit; + height: 100%; + left: 0; + position: absolute; + right: 0; + top: 0; + transition: 150ms ease background-color; + width: 100%; +` const BaseThemeButton = styled.button` align-items: center; background-color: ${pickThemeButtonBackgroundColor}; @@ -484,16 +485,13 @@ const BaseThemeButton = styled.button` padding: ${pickThemeButtonPadding}; position: relative; transition: 150ms ease opacity; + user-select: none; :active { ${ButtonOverlay} { background-color: ${({ theme }) => theme.stateOverlayPressed}; } } - :disabled { - cursor: default; - opacity: 0.6; - } :focus { ${ButtonOverlay} { background-color: ${({ theme }) => theme.stateOverlayPressed}; @@ -504,6 +502,17 @@ const BaseThemeButton = styled.button` background-color: ${({ theme }) => theme.stateOverlayHover}; } } + :disabled { + cursor: default; + opacity: 0.6; + } + :disabled:active, + :disabled:focus, + :disabled:hover { + ${ButtonOverlay} { + background-color: transparent; + } + } ` interface ThemeButtonProps extends React.ComponentPropsWithoutRef<'button'>, BaseButtonProps {} diff --git a/src/components/FiatOnrampAnnouncement/index.tsx b/src/components/FiatOnrampAnnouncement/index.tsx index 18f1eca500..ef4c4c62f9 100644 --- a/src/components/FiatOnrampAnnouncement/index.tsx +++ b/src/components/FiatOnrampAnnouncement/index.tsx @@ -93,13 +93,13 @@ const Body = styled(ThemedText.BodySmall)` export function FiatOnrampAnnouncement() { const { account } = useWeb3React() - const [fiatOnrampAcknowledged, acknowledge] = useFiatOnrampAck() + const [acks, acknowledge] = useFiatOnrampAck() const handleClose = useCallback( (e: MouseEvent) => { e.preventDefault() e.stopPropagation() - acknowledge() + acknowledge({ user: false }) }, [acknowledge] ) @@ -107,11 +107,11 @@ export function FiatOnrampAnnouncement() { const toggleWalletDropdown = useToggleWalletDropdown() const handleClick = useCallback(() => { toggleWalletDropdown() - acknowledge() + acknowledge({ user: true }) }, [acknowledge, toggleWalletDropdown]) const fiatOnrampFlag = useFiatOnrampFlag() - if (!account || fiatOnrampAcknowledged || fiatOnrampFlag === BaseVariant.Control) { + if (!account || acks?.user || fiatOnrampFlag === BaseVariant.Control) { return null } return ( diff --git a/src/components/NavBar/MenuDropdown.tsx b/src/components/NavBar/MenuDropdown.tsx index 3db7a98fc1..981028eb4e 100644 --- a/src/components/NavBar/MenuDropdown.tsx +++ b/src/components/NavBar/MenuDropdown.tsx @@ -14,8 +14,7 @@ import { } from 'nft/components/icons' import { body, bodySmall } from 'nft/css/common.css' import { themeVars } from 'nft/css/sprinkles.css' -import { ReactNode, useCallback, useReducer, useRef } from 'react' -import { CreditCard } from 'react-feather' +import { ReactNode, useReducer, useRef } from 'react' import { NavLink, NavLinkProps } from 'react-router-dom' import styled from 'styled-components/macro' import { isDevelopmentEnv, isStagingEnv } from 'utils/env' @@ -26,28 +25,6 @@ import * as styles from './MenuDropdown.css' import { NavDropdown } from './NavDropdown' import { NavIcon } from './NavIcon' -const BuyCryptoButton = styled.button` - background-color: transparent; - border: 1px solid transparent; - border-radius: 12px; - color: ${({ theme }) => theme.textPrimary}; - cursor: pointer; - display: flex; - line-height: 24px; - outline: none; - padding: 8px; - text-decoration: none; - width: full; - white-space: nowrap; - transition-duration: ${({ theme }) => theme.transition.duration.fast}; - transition-timing-function: ease-in-out; - transition-property: opacity, color, background-color; - - :hover { - background: ${({ theme }) => theme.stateOverlayHover}; - } -` - const PrimaryMenuRow = ({ to, href, @@ -74,8 +51,13 @@ const PrimaryMenuRow = ({ ) } +const StyledBox = styled(Box)` + align-items: center; + display: flex; + justify-content: center; +` const PrimaryMenuRowText = ({ children }: { children: ReactNode }) => { - return {children} + return {children} } PrimaryMenuRow.Text = PrimaryMenuRowText @@ -139,14 +121,9 @@ export const MenuDropdown = () => { const [isOpen, toggleOpen] = useReducer((s) => !s, false) const togglePrivacyPolicy = useToggleModal(ApplicationModal.PRIVACY_POLICY) const openFeatureFlagsModal = useToggleModal(ApplicationModal.FEATURE_FLAGS) - const ref = useRef(null) useOnClickOutside(ref, isOpen ? toggleOpen : undefined) - const handleBuyCryptoClick = useCallback(() => { - toggleOpen() - }, []) - return ( <> @@ -158,14 +135,6 @@ export const MenuDropdown = () => { - - - - - - Buy Crypto - - diff --git a/src/components/WalletDropdown/AuthenticatedHeader.tsx b/src/components/WalletDropdown/AuthenticatedHeader.tsx index d0c90e82bc..38e5d52a10 100644 --- a/src/components/WalletDropdown/AuthenticatedHeader.tsx +++ b/src/components/WalletDropdown/AuthenticatedHeader.tsx @@ -1,6 +1,8 @@ import { Trans } from '@lingui/macro' import { CurrencyAmount, Token } from '@uniswap/sdk-core' import { useWeb3React } from '@web3-react/core' +import { ButtonEmphasis, ButtonSize, LoadingButtonSpinner, ThemeButton } from 'components/Button' +import Tooltip from 'components/Tooltip' import { getConnection } from 'connection/utils' import { getChainInfoOrDefault } from 'constants/chainInfo' import { SupportedChainId } from 'constants/chains' @@ -9,31 +11,58 @@ import { useFiatOnrampFlag } from 'featureFlags/flags/fiatOnramp' import useCopyClipboard from 'hooks/useCopyClipboard' import useStablecoinPrice from 'hooks/useStablecoinPrice' import useNativeCurrency from 'lib/hooks/useNativeCurrency' +import ms from 'ms.macro' import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hooks' import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable' import { ProfilePageStateType } from 'nft/types' -import { useCallback, useMemo } from 'react' -import { Copy, CreditCard, ExternalLink, Power } from 'react-feather' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Copy, CreditCard, ExternalLink as ExternalLinkIcon, Info, Power } from 'react-feather' import { useNavigate } from 'react-router-dom' import { Text } from 'rebass' import { useCurrencyBalanceString } from 'state/connection/hooks' import { useAppDispatch } from 'state/hooks' +import { useFiatOnrampAck } from 'state/user/hooks' import { updateSelectedWallet } from 'state/user/reducer' -import styled, { css } from 'styled-components/macro' -import { ThemedText } from 'theme' +import styled, { css, keyframes } from 'styled-components/macro' +import { ExternalLink, ThemedText } from 'theme' import { shortenAddress } from '../../nft/utils/address' -import { useCloseModal, useOpenModal, useToggleModal } from '../../state/application/hooks' +import { useCloseModal, useFiatOnrampAvailability, useOpenModal, useToggleModal } from '../../state/application/hooks' import { ApplicationModal } from '../../state/application/reducer' import { useUserHasAvailableClaim, useUserUnclaimedAmount } from '../../state/claim/hooks' -import { ButtonEmphasis, ButtonSize, ThemeButton } from '../Button' import StatusIcon from '../Identicon/StatusIcon' import IconButton, { IconHoverText } from './IconButton' -const BuyCryptoButton = styled(ThemeButton)` - margin-top: 12px; +const BuyCryptoButtonBorderKeyframes = keyframes` + 0% { + border-color: transparent; + } + 33% { + border-color: hsla(225, 95%, 63%, 1); + } + 66% { + border-color: hsla(267, 95%, 63%, 1); + } + 100% { + border-color: transparent; + } ` +const BuyCryptoButton = styled(ThemeButton)<{ $animateBorder: boolean }>` + border-color: transparent; + border-radius: 12px; + border-style: solid; + border-width: 1px; + height: 40px; + margin-top: 12px; + animation-direction: alternate; + animation-duration: ${({ theme }) => theme.transition.duration.slow}; + animation-fill-mode: none; + animation-iteration-count: 2; + animation-name: ${BuyCryptoButtonBorderKeyframes}; + animation-play-state: ${({ $animateBorder }) => ($animateBorder ? 'running' : 'paused')}; + animation-timing-function: ${({ theme }) => theme.transition.timing.inOut}; +` const WalletButton = styled(ThemeButton)` border-radius: 12px; padding-top: 10px; @@ -81,7 +110,20 @@ const USDText = styled.div` color: ${({ theme }) => theme.textSecondary}; margin-top: 8px; ` - +const FiatOnrampNotAvailableText = styled(ThemedText.Caption)` + align-items: center; + color: ${({ theme }) => theme.textSecondary}; + display: flex; + justify-content: center; +` +const FiatOnrampAvailabilityExternalLink = styled(ExternalLink)` + align-items: center; + display: flex; + height: 14px; + justify-content: center; + margin-left: 6px; + width: 14px; +` const FlexContainer = styled.div` display: flex; ` @@ -114,6 +156,11 @@ const AccountContainer = styled(ThemedText.BodySmall)` color: ${({ theme }) => theme.textSecondary}; margin-top: 2.5px; ` +const StyledInfoIcon = styled(Info)` + height: 12px; + width: 12px; + flex: 1 1 auto; +` const BalanceWrapper = styled.div` padding: 16px 0; @@ -178,7 +225,49 @@ const AuthenticatedHeader = () => { }, [clearCollectionFilters, closeModal, navigate, resetSellAssets, setSellPageState]) const fiatOnrampFlag = useFiatOnrampFlag() + + // animate the border of the buy crypto button when a user navigates here from the feature announcement + // can be removed when components/FiatOnrampAnnouncment.tsx is no longer used + const [acknowledgements, acknowledge] = useFiatOnrampAck() + const animateBuyCryptoButtonBorder = acknowledgements?.user && !acknowledgements.system + useEffect(() => { + let stale = false + let timeoutId = 0 + if (animateBuyCryptoButtonBorder) { + timeoutId = setTimeout(() => { + if (stale) return + acknowledge({ system: true }) + }, ms`2 seconds`) as unknown as number + // as unknown as number is necessary so it's not incorrectly typed as a NodeJS.Timeout + } + return () => { + stale = true + clearTimeout(timeoutId) + } + }, [acknowledge, animateBuyCryptoButtonBorder]) + const openFiatOnrampModal = useOpenModal(ApplicationModal.FIAT_ONRAMP) + const [shouldCheck, setShouldCheck] = useState(false) + const { + available: fiatOnrampAvailable, + availabilityChecked: fiatOnrampAvailabilityChecked, + error, + loading: fiatOnrampAvailabilityLoading, + } = useFiatOnrampAvailability(shouldCheck, openFiatOnrampModal) + + const handleBuyCryptoClick = useCallback(() => { + if (!fiatOnrampAvailabilityChecked) { + setShouldCheck(true) + } else if (fiatOnrampAvailable) { + openFiatOnrampModal() + } + }, [fiatOnrampAvailabilityChecked, fiatOnrampAvailable, openFiatOnrampModal]) + const disableBuyCryptoButton = Boolean( + error || (!fiatOnrampAvailable && fiatOnrampAvailabilityChecked) || fiatOnrampAvailabilityLoading + ) + const [showFiatOnrampUnavailableTooltip, setShow] = useState(false) + const openFiatOnrampUnavailableTooltip = useCallback(() => setShow(true), [setShow]) + const closeFiatOnrampUnavailableTooltip = useCallback(() => setShow(false), [setShow]) return ( @@ -200,7 +289,7 @@ const AuthenticatedHeader = () => { {isCopied ? Copied! : Copy} - + Explore @@ -224,9 +313,50 @@ const AuthenticatedHeader = () => { View and sell NFTs {fiatOnrampFlag === BaseVariant.Enabled && ( - - Buy crypto - + <> + + {fiatOnrampAvailabilityLoading ? ( + <> + + Checking availability + + ) : error ? ( + {error} + ) : ( + <> + Buy crypto + + )} + + {Boolean(!fiatOnrampAvailable && fiatOnrampAvailabilityChecked) && ( + + Not available in your region + + Moonpay is not supported in some regions in and outside of the US. Click to learn more. + + } + > + + + + + + )} + )} {isUnclaimed && ( diff --git a/src/state/application/hooks.ts b/src/state/application/hooks.ts index 442866ddb4..d0f7307f85 100644 --- a/src/state/application/hooks.ts +++ b/src/state/application/hooks.ts @@ -1,15 +1,85 @@ import { DEFAULT_TXN_DISMISS_MS } from 'constants/misc' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useAppDispatch, useAppSelector } from 'state/hooks' import { AppState } from '../index' -import { addPopup, ApplicationModal, PopupContent, removePopup, setOpenModal } from './reducer' +import { + addPopup, + ApplicationModal, + PopupContent, + removePopup, + setFiatOnrampAvailability, + setOpenModal, +} from './reducer' export function useModalIsOpen(modal: ApplicationModal): boolean { const openModal = useAppSelector((state: AppState) => state.application.openModal) return openModal === modal } +/** @ref https://dashboard.moonpay.com/api_reference/client_side_api#ip_addresses */ +interface MoonpayIPAddressesResponse { + alpha3?: string + isAllowed?: boolean + isBuyAllowed?: boolean + isSellAllowed?: boolean +} + +async function getMoonpayAvailability(): Promise { + const moonpayPublishableKey = process.env.REACT_APP_MOONPAY_PUBLISHABLE_KEY + if (!moonpayPublishableKey) { + throw new Error('Must provide a publishable key for moonpay.') + } + const moonpayApiURI = process.env.REACT_APP_MOONPAY_API + if (!moonpayApiURI) { + throw new Error('Must provide an api endpoint for moonpay.') + } + const res = await fetch(`${moonpayApiURI}/v4/ip_address?apiKey=${moonpayPublishableKey}`) + const data = await (res.json() as Promise) + return data.isBuyAllowed ?? false +} + +export function useFiatOnrampAvailability(shouldCheck: boolean, callback?: () => void) { + const dispatch = useAppDispatch() + const { available, availabilityChecked } = useAppSelector((state: AppState) => state.application.fiatOnramp) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + async function checkAvailability() { + setError(null) + setLoading(true) + try { + const result = await getMoonpayAvailability() + if (stale) return + dispatch(setFiatOnrampAvailability(result)) + if (result && callback) { + callback() + } + } catch (e) { + console.error('Error checking onramp availability', e.toString()) + if (stale) return + setError('Error, try again later.') + dispatch(setFiatOnrampAvailability(false)) + } finally { + if (stale) return + setLoading(false) + } + } + + if (!availabilityChecked && shouldCheck) { + checkAvailability() + } + + let stale = false + return () => { + stale = true + } + }, [availabilityChecked, callback, dispatch, shouldCheck]) + + return { available, availabilityChecked, loading, error } +} + export function useToggleModal(modal: ApplicationModal): () => void { const isOpen = useModalIsOpen(modal) const dispatch = useAppDispatch() diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index 5c293b207a..e74bd0077c 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -41,11 +41,13 @@ type PopupList = Array<{ key: string; show: boolean; content: PopupContent; remo export interface ApplicationState { readonly chainId: number | null + readonly fiatOnramp: { available: boolean; availabilityChecked: boolean } readonly openModal: ApplicationModal | null readonly popupList: PopupList } const initialState: ApplicationState = { + fiatOnramp: { available: false, availabilityChecked: false }, chainId: null, openModal: null, popupList: [], @@ -55,6 +57,9 @@ const applicationSlice = createSlice({ name: 'application', initialState, reducers: { + setFiatOnrampAvailability(state, { payload: available }) { + state.fiatOnramp = { available, availabilityChecked: true } + }, updateChainId(state, action) { const { chainId } = action.payload state.chainId = chainId @@ -82,5 +87,6 @@ const applicationSlice = createSlice({ }, }) -export const { updateChainId, setOpenModal, addPopup, removePopup } = applicationSlice.actions +export const { updateChainId, setFiatOnrampAvailability, setOpenModal, addPopup, removePopup } = + applicationSlice.actions export default applicationSlice.reducer diff --git a/src/state/user/hooks.tsx b/src/state/user/hooks.tsx index cb365246b5..6354978a38 100644 --- a/src/state/user/hooks.tsx +++ b/src/state/user/hooks.tsx @@ -17,7 +17,7 @@ import { AppState } from '../index' import { addSerializedPair, addSerializedToken, - updateFiatOnrampAcknowledged, + updateFiatOnrampAcknowledgments, updateHideClosedPositions, updateHideNFTWelcomeModal, updateShowNftPromoBanner, @@ -26,7 +26,7 @@ import { updateUserDeadline, updateUserExpertMode, updateUserLocale, - updateUserSlippageTolerance + updateUserSlippageTolerance, } from './reducer' import { SerializedPair, SerializedToken } from './types' @@ -106,18 +106,23 @@ export function useExpertModeManager(): [boolean, () => void] { return [expertMode, toggleSetExpertMode] } -export function useFiatOnrampAck(): [boolean, (b?: boolean) => void] { +interface FiatOnrampAcknowledgements { + user: boolean + system: boolean +} +export function useFiatOnrampAck(): [ + FiatOnrampAcknowledgements, + (acknowledgements: Partial) => void +] { const dispatch = useAppDispatch() - const fiatOnrampAcknowledged = useAppSelector((state) => state.user.fiatOnrampAcknowledged) - - const toggleSetExpertMode = useCallback( - (b = true) => { - dispatch(updateFiatOnrampAcknowledged(b)) + const fiatOnrampAcknowledgments = useAppSelector((state) => state.user.fiatOnrampAcknowledgments) + const setAcknowledgements = useCallback( + (acks: Partial) => { + dispatch(updateFiatOnrampAcknowledgments(acks)) }, [dispatch] ) - - return [fiatOnrampAcknowledged, toggleSetExpertMode] + return [fiatOnrampAcknowledgments, setAcknowledgements] } export function useHideNFTWelcomeModal(): [boolean | undefined, () => void] { const dispatch = useAppDispatch() diff --git a/src/state/user/reducer.ts b/src/state/user/reducer.ts index 4c6e0af0bc..b46dce19fc 100644 --- a/src/state/user/reducer.ts +++ b/src/state/user/reducer.ts @@ -9,7 +9,7 @@ import { SerializedPair, SerializedToken } from './types' const currentTimestamp = () => new Date().getTime() export interface UserState { - fiatOnrampAcknowledged: boolean + fiatOnrampAcknowledgments: { user: boolean; system: boolean } selectedWallet?: ConnectionType @@ -63,7 +63,7 @@ function pairKey(token0Address: string, token1Address: string) { } export const initialState: UserState = { - fiatOnrampAcknowledged: false, + fiatOnrampAcknowledgments: { user: false, system: false }, selectedWallet: undefined, matchesDarkMode: false, userDarkMode: null, @@ -87,8 +87,8 @@ const userSlice = createSlice({ name: 'user', initialState, reducers: { - updateFiatOnrampAcknowledged(state, { payload }) { - state.fiatOnrampAcknowledged = payload + updateFiatOnrampAcknowledgments(state, { payload }: { payload: { user?: boolean; system?: boolean } }) { + state.fiatOnrampAcknowledgments = { ...state.fiatOnrampAcknowledgments, ...payload } }, updateSelectedWallet(state, { payload: { wallet } }) { state.selectedWallet = wallet @@ -189,7 +189,7 @@ const userSlice = createSlice({ export const { addSerializedPair, addSerializedToken, - updateFiatOnrampAcknowledged, + updateFiatOnrampAcknowledgments, updateSelectedWallet, updateHideClosedPositions, updateMatchesDarkMode,