diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index d41992102745..e2d3c376de31 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -881,6 +881,12 @@ "message": "Buy $1", "description": "$1 is the ticker symbol of a an asset the user is being prompted to purchase" }, + "buyCrypto": { + "message": "Buy crypto" + }, + "buyFirstCrypto": { + "message": "Buy your first crypto with a debit or credit card." + }, "buyMoreAsset": { "message": "Buy more $1", "description": "$1 is the ticker symbol of a an asset the user is being prompted to purchase" @@ -1538,6 +1544,9 @@ "deposit": { "message": "Deposit" }, + "depositCrypto": { + "message": "Deposit crypto from another account with a wallet address or QR code." + }, "deprecatedGoerliNtwrkMsg": { "message": "Because of updates to the Ethereum system, the Goerli test network will be phased out soon." }, @@ -2143,6 +2152,12 @@ "genericExplorerView": { "message": "View account on $1" }, + "getStarted": { + "message": "Get Started" + }, + "getStartedByFundingWallet": { + "message": "Get started by adding some crypto to your wallet." + }, "getStartedWithNFTs": { "message": "Get $1 to buy NFTs", "description": "$1 is the token symbol" @@ -2707,6 +2722,9 @@ "link": { "message": "Link" }, + "linkCentralizedExchanges": { + "message": "Link your Coinbase or Binance accounts to transfer crypto to MetaMask for free." + }, "links": { "message": "Links" }, @@ -4220,6 +4238,9 @@ "receive": { "message": "Receive" }, + "receiveCrypto": { + "message": "Receive crypto" + }, "recipientAddressPlaceholder": { "message": "Enter public address (0x) or ENS name" }, @@ -4635,6 +4656,9 @@ "selectEnableDisplayMediaPrivacyPreference": { "message": "Turn on Display NFT Media" }, + "selectFundingMethod": { + "message": "Select a funding method" + }, "selectHdPath": { "message": "Select HD path" }, @@ -6118,6 +6142,9 @@ "transfer": { "message": "Transfer" }, + "transferCrypto": { + "message": "Transfer crypto" + }, "transferFrom": { "message": "Transfer from" }, diff --git a/ui/components/app/assets/asset-list/asset-list.js b/ui/components/app/assets/asset-list/asset-list.js index 81c70d433dc9..916d86a07c26 100644 --- a/ui/components/app/assets/asset-list/asset-list.js +++ b/ui/components/app/assets/asset-list/asset-list.js @@ -35,14 +35,17 @@ import { DetectedTokensBanner, TokenListItem, ImportTokenLink, + ReceiveModal, } from '../../../multichain'; import { useAccountTotalFiatBalance } from '../../../../hooks/useAccountTotalFiatBalance'; import { useIsOriginalNativeTokenSymbol } from '../../../../hooks/useIsOriginalNativeTokenSymbol'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; import { showPrimaryCurrency, showSecondaryCurrency, } from '../../../../../shared/modules/currency-display.utils'; import { roundToDecimalPlacesRemovingExtraZeroes } from '../../../../helpers/utils/util'; +import { FundingMethodModal } from '../../../multichain/funding-method-modal/funding-method-modal'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { RAMPS_CARD_VARIANT_TYPES, @@ -66,6 +69,7 @@ const AssetList = ({ onClickAsset, showTokensLinks }) => { type, rpcUrl, ); + const t = useI18nContext(); const trackEvent = useContext(MetaMetricsContext); const balance = useSelector(getMultichainSelectedAccountCachedBalance); const balanceIsLoading = !balance; @@ -101,6 +105,14 @@ const AssetList = ({ onClickAsset, showTokensLinks }) => { getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, ); + const [showFundingMethodModal, setShowFundingMethodModal] = useState(false); + const [showReceiveModal, setShowReceiveModal] = useState(false); + + const onClickReceive = () => { + setShowFundingMethodModal(false); + setShowReceiveModal(true); + }; + const { tokensWithBalances, loading } = useAccountTotalFiatBalance( selectedAccount, shouldHideZeroBalanceTokens, @@ -151,6 +163,9 @@ const AssetList = ({ onClickAsset, showTokensLinks }) => { ? RAMPS_CARD_VARIANT_TYPES.BTC : RAMPS_CARD_VARIANT_TYPES.TOKEN } + handleOnClick={ + isBtc ? undefined : () => setShowFundingMethodModal(true) + } /> ) : null ///: END:ONLY_INCLUDE_IF @@ -213,6 +228,20 @@ const AssetList = ({ onClickAsset, showTokensLinks }) => { {showDetectedTokens && ( )} + {showReceiveModal && selectedAccount?.address && ( + setShowReceiveModal(false)} + /> + )} + {showFundingMethodModal && ( + setShowFundingMethodModal(false)} + title={t('selectFundingMethod')} + onClickReceive={onClickReceive} + /> + )} ); }; diff --git a/ui/components/multichain/funding-method-modal/funding-method-item.tsx b/ui/components/multichain/funding-method-modal/funding-method-item.tsx new file mode 100644 index 000000000000..85fa9e268ef5 --- /dev/null +++ b/ui/components/multichain/funding-method-modal/funding-method-item.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Box, Text, Icon, IconName } from '../../component-library'; +import { + Display, + FlexDirection, + TextVariant, + TextColor, + AlignItems, +} from '../../../helpers/constants/design-system'; + +type FundingMethodItemProps = { + icon: IconName; + title: string; + description: string; + onClick: () => void; +}; + +const FundingMethodItem: React.FC = ({ + icon, + title, + description, + onClick, +}) => ( + + + + {title} + + {description} + + + +); + +export default FundingMethodItem; diff --git a/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx b/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx new file mode 100644 index 000000000000..509a4aa60a2a --- /dev/null +++ b/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { renderWithProvider } from '../../../../test/jest/rendering'; +import mockState from '../../../../test/data/mock-state.json'; +import useRamps from '../../../hooks/ramps/useRamps/useRamps'; +import { FundingMethodModal } from './funding-method-modal'; + +jest.mock('../../../hooks/ramps/useRamps/useRamps', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockStore = configureMockStore([thunk]); + +describe('FundingMethodModal', () => { + let store = configureMockStore([thunk])(mockState); + let openBuyCryptoInPdapp: jest.Mock<() => void>; + + beforeEach(() => { + store = mockStore(mockState); + openBuyCryptoInPdapp = jest.fn(); + (useRamps as jest.Mock).mockReturnValue({ openBuyCryptoInPdapp }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the modal when isOpen is true', () => { + const { getByTestId, getByText } = renderWithProvider( + , + store, + ); + + expect(getByTestId('funding-method-modal')).toBeInTheDocument(); + expect(getByText('Test Modal')).toBeInTheDocument(); + }); + + it('should not render the modal when isOpen is false', () => { + const { queryByTestId } = renderWithProvider( + , + store, + ); + + expect(queryByTestId('funding-method-modal')).toBeNull(); + }); + + it('should call openBuyCryptoInPdapp when the Buy Crypto item is clicked', () => { + const { getByText } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByText('Buy crypto')); + expect(openBuyCryptoInPdapp).toHaveBeenCalled(); + }); + + it('should call onClickReceive when the Receive Crypto item is clicked', () => { + const onClickReceive = jest.fn(); + const { getByText } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByText('Receive crypto')); + expect(onClickReceive).toHaveBeenCalled(); + }); + + it('should open a new tab with the correct URL when Transfer Crypto item is clicked', () => { + global.platform.openTab = jest.fn(); + + const { getByText } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByText('Transfer crypto')); + expect(global.platform.openTab).toHaveBeenCalledWith({ + url: expect.stringContaining('transfer'), + }); + }); +}); diff --git a/ui/components/multichain/funding-method-modal/funding-method-modal.tsx b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx new file mode 100644 index 000000000000..47d6ed22c2e8 --- /dev/null +++ b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useContext } from 'react'; +import { useSelector } from 'react-redux'; +import { CaipChainId } from '@metamask/utils'; +import { + Modal, + ModalContent, + ModalOverlay, + ModalHeader, + Text, + IconName, +} from '../../component-library'; +import { + TextVariant, + TextAlign, +} from '../../../helpers/constants/design-system'; +import { + getMultichainCurrentNetwork, + getMultichainDefaultToken, +} from '../../../selectors/multichain'; +import useRamps, { + RampsMetaMaskEntry, +} from '../../../hooks/ramps/useRamps/useRamps'; +import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; +import { + getMetaMetricsId, + getParticipateInMetaMetrics, + getDataCollectionForMarketing, + getSelectedAccount, +} from '../../../selectors'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { ChainId } from '../../../../shared/constants/network'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import FundingMethodItem from './funding-method-item'; + +type FundingMethodModalProps = { + isOpen: boolean; + onClose: () => void; + title: string; + onClickReceive: () => void; +}; + +export const FundingMethodModal: React.FC = ({ + isOpen, + onClose, + title, + onClickReceive, +}) => { + const t = useI18nContext(); + const trackEvent = useContext(MetaMetricsContext); + const { openBuyCryptoInPdapp } = useRamps(); + const { address: accountAddress } = useSelector(getSelectedAccount); + const { chainId } = useSelector(getMultichainCurrentNetwork); + const { symbol } = useSelector(getMultichainDefaultToken); + const metaMetricsId = useSelector(getMetaMetricsId); + const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); + const isMarketingEnabled = useSelector(getDataCollectionForMarketing); + + const handleTransferCryptoClick = useCallback(() => { + trackEvent({ + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + location: RampsMetaMaskEntry?.TokensBanner, + text: 'Transfer crypto', + chain_id: chainId, + token_symbol: symbol, + }, + }); + + const url = getPortfolioUrl( + 'transfer', + 'ext_funding_method_modal', + metaMetricsId, + isMetaMetricsEnabled, + isMarketingEnabled, + accountAddress, + 'transfer', + ); + global.platform.openTab({ url }); + }, [ + metaMetricsId, + isMetaMetricsEnabled, + isMarketingEnabled, + chainId, + symbol, + accountAddress, + ]); + + const handleBuyCryptoClick = useCallback(() => { + trackEvent({ + event: MetaMetricsEventName.NavBuyButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + location: RampsMetaMaskEntry?.TokensBanner, + text: 'Buy crypto', + chain_id: chainId, + token_symbol: symbol, + }, + }); + openBuyCryptoInPdapp(chainId as ChainId | CaipChainId); + }, [chainId, symbol]); + + return ( + + + + + + {title} + + + + + + + + ); +}; diff --git a/ui/components/multichain/funding-method-modal/index.scss b/ui/components/multichain/funding-method-modal/index.scss new file mode 100644 index 000000000000..193b26fc0052 --- /dev/null +++ b/ui/components/multichain/funding-method-modal/index.scss @@ -0,0 +1,7 @@ +.funding-method-item { + cursor: pointer; + + &:hover { + background-color: var(--color-background-hover); + } +} diff --git a/ui/components/multichain/funding-method-modal/index.ts b/ui/components/multichain/funding-method-modal/index.ts new file mode 100644 index 000000000000..68ac981efe14 --- /dev/null +++ b/ui/components/multichain/funding-method-modal/index.ts @@ -0,0 +1 @@ +export { FundingMethodModal } from './funding-method-modal'; diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index 1a078ee82c81..5ecc5a2a7d3a 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -48,5 +48,6 @@ export { NotificationListItemSnap } from './notification-list-item-snap'; export { NotificationsTagCounter } from './notifications-tag-counter'; export { Toast, ToastContainer } from './toast'; export { PermissionDetailsModal } from './permission-details-modal'; +export { ReceiveModal } from './receive-modal'; export { EditNetworksModal } from './edit-networks-modal'; export { EditAccountsModal } from './edit-accounts-modal'; diff --git a/ui/components/multichain/multichain-components.scss b/ui/components/multichain/multichain-components.scss index 8ab0c6202a5a..2d2d6b3fdef0 100644 --- a/ui/components/multichain/multichain-components.scss +++ b/ui/components/multichain/multichain-components.scss @@ -28,6 +28,7 @@ @import 'network-list-menu/select-rpc-url-modal'; @import 'product-tour-popover'; @import 'nft-item'; +@import 'funding-method-modal'; @import 'badge-status'; @import 'import-tokens-modal'; @import 'asset-picker-amount'; diff --git a/ui/components/multichain/ramps-card/ramps-card.js b/ui/components/multichain/ramps-card/ramps-card.js index cc017421a63e..6d988f8a8bad 100644 --- a/ui/components/multichain/ramps-card/ramps-card.js +++ b/ui/components/multichain/ramps-card/ramps-card.js @@ -39,12 +39,14 @@ export const RAMPS_CARD_VARIANTS = { [RAMPS_CARD_VARIANT_TYPES.TOKEN]: { illustrationSrc: './images/ramps-card-token-illustration.png', gradient: + // eslint-disable-next-line @metamask/design-tokens/color-no-hex 'linear-gradient(90deg, #0189EC 0%, #4B7AED 35%, #6774EE 58%, #706AF4 80.5%, #7C5BFC 100%)', title: 'fundYourWallet', - body: 'fundYourWalletDescription', + body: 'getStartedByFundingWallet', }, [RAMPS_CARD_VARIANT_TYPES.NFT]: { illustrationSrc: './images/ramps-card-nft-illustration.png', + // eslint-disable-next-line @metamask/design-tokens/color-no-hex gradient: 'linear-gradient(90deg, #F6822D 0%, #F894A7 52%, #ED94FB 92.5%)', title: 'getStartedWithNFTs', body: 'getStartedWithNFTsDescription', @@ -52,6 +54,7 @@ export const RAMPS_CARD_VARIANTS = { [RAMPS_CARD_VARIANT_TYPES.ACTIVITY]: { illustrationSrc: './images/ramps-card-activity-illustration.png', gradient: + // eslint-disable-next-line @metamask/design-tokens/color-no-hex 'linear-gradient(90deg, #57C5DC 0%, #06BFDD 49.39%, #35A9C7 100%)', title: 'startYourJourney', @@ -60,6 +63,7 @@ export const RAMPS_CARD_VARIANTS = { [RAMPS_CARD_VARIANT_TYPES.BTC]: { illustrationSrc: './images/ramps-card-btc-illustration.png', gradient: + // eslint-disable-next-line @metamask/design-tokens/color-no-hex 'linear-gradient(90deg, #017ED9 0%, #446FD9 35%, #5E6AD9 58%, #635ED9 80.5%, #6855D9 92.5%, #6A4FD9 100%)', title: 'fundYourWallet', body: 'fundYourWalletDescription', @@ -73,7 +77,7 @@ const metamaskEntryMap = { [RAMPS_CARD_VARIANT_TYPES.BTC]: RampsMetaMaskEntry.BtcBanner, }; -export const RampsCard = ({ variant }) => { +export const RampsCard = ({ variant, handleOnClick }) => { const t = useI18nContext(); const { gradient, illustrationSrc, title, body } = RAMPS_CARD_VARIANTS[variant]; @@ -83,6 +87,8 @@ export const RampsCard = ({ variant }) => { const { chainId, nickname } = useSelector(getMultichainCurrentNetwork); const { symbol } = useSelector(getMultichainDefaultToken); + const isTokenVariant = variant === RAMPS_CARD_VARIANT_TYPES.TOKEN; + useEffect(() => { trackEvent({ event: MetaMetricsEventName.EmptyBuyBannerDisplayed, @@ -129,8 +135,11 @@ export const RampsCard = ({ variant }) => { {t(title, [symbol])} {t(body, [symbol])} - - {t('buyToken', [symbol])} + + {isTokenVariant ? t('getStarted') : t('buyToken', [symbol])} ); @@ -138,4 +147,5 @@ export const RampsCard = ({ variant }) => { RampsCard.propTypes = { variant: PropTypes.oneOf(Object.values(RAMPS_CARD_VARIANT_TYPES)), + handleOnClick: PropTypes.oneOfType([PropTypes.func, PropTypes.undefined]), }; diff --git a/ui/helpers/utils/portfolio.js b/ui/helpers/utils/portfolio.js index 3aa5d8f592b2..97d6eb528d81 100644 --- a/ui/helpers/utils/portfolio.js +++ b/ui/helpers/utils/portfolio.js @@ -4,6 +4,8 @@ export function getPortfolioUrl( metaMetricsId = '', metricsEnabled = false, marketingEnabled = false, + accountAddress, + tab, ) { const baseUrl = process.env.PORTFOLIO_URL || ''; const url = new URL(endpoint, baseUrl); @@ -15,5 +17,13 @@ export function getPortfolioUrl( url.searchParams.append('metricsEnabled', String(metricsEnabled)); url.searchParams.append('marketingEnabled', String(marketingEnabled)); + if (accountAddress) { + url.searchParams.append('accountAddress', accountAddress); + } + + if (tab) { + url.searchParams.append('tab', tab); + } + return url.href; }