diff --git a/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts b/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts new file mode 100644 index 000000000000..bfb89d4f3a30 --- /dev/null +++ b/test/e2e/page-objects/pages/asset/asset-sticky-actions.ts @@ -0,0 +1,74 @@ +import { Driver } from '../../../webdriver/driver'; + +/** + * Sticky Buy / Swap CTA bar on the Token Detail Page V2. + * + * Screen: `#/asset/:chainId/:asset?/:id?` + * Owns: presence of the sticky footer and its Buy / Swap buttons, plus + * asserting the bar stays pinned to the bottom of the viewport after scroll. + * Boundaries: does not own destinations of Buy / Swap clicks. + * + * @see ui/pages/asset/components/asset-sticky-actions.tsx + */ +class AssetStickyActions { + private driver: Driver; + + private readonly scrollContainer = + '[data-testid="asset-page-scroll-container"]'; + + private readonly stickyActions = '[data-testid="asset-sticky-actions"]'; + + private readonly stickyBuy = '[data-testid="asset-sticky-buy"]'; + + private readonly stickySwap = '[data-testid="asset-sticky-swap"]'; + + constructor(driver: Driver) { + this.driver = driver; + } + + /** + * Waits for the sticky Buy and Swap CTAs to be present. + */ + async checkPageIsLoaded(): Promise { + console.log('Check asset sticky actions are loaded'); + await this.driver.waitForSelector(this.stickyActions); + await this.driver.waitForSelector(this.stickyBuy); + await this.driver.waitForSelector(this.stickySwap); + } + + /** + * Asserts the sticky bar is pinned near the bottom of the visual viewport. + * Uses a small tolerance so scrollbar / safe-area padding do not flake. + */ + async checkPinnedToViewportBottom(): Promise { + console.log('Check asset sticky actions are pinned to the viewport bottom'); + await this.driver.wait(async () => { + const isPinned = await this.driver.executeScript(` + const bar = document.querySelector('[data-testid="asset-sticky-actions"]'); + if (!bar) { + return false; + } + const rect = bar.getBoundingClientRect(); + return Math.abs(rect.bottom - window.innerHeight) < 8 && rect.top >= 0; + `); + return Boolean(isPinned); + }, this.driver.timeout); + } + + /** + * Scrolls the Token Detail Page scrollport to the bottom, far enough that a + * non-sticky footer would leave the viewport. + */ + async scrollToBottom(): Promise { + console.log('Scroll the token detail page to the bottom'); + await this.driver.waitForSelector(this.scrollContainer); + await this.driver.executeScript(` + const scroller = document.querySelector('[data-testid="asset-page-scroll-container"]'); + if (scroller) { + scroller.scrollTo(0, scroller.scrollHeight); + } + `); + } +} + +export default AssetStickyActions; diff --git a/test/e2e/tests/tokens/asset-sticky-actions.spec.ts b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts new file mode 100644 index 000000000000..1d05e2b8b459 --- /dev/null +++ b/test/e2e/tests/tokens/asset-sticky-actions.spec.ts @@ -0,0 +1,63 @@ +import { Mockttp } from 'mockttp'; +import { Context } from 'mocha'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import FixtureBuilderV2 from '../../fixtures/fixture-builder-v2'; +import { NETWORK_CLIENT_ID } from '../../constants'; +import { withFixtures } from '../../helpers'; +import { Driver } from '../../webdriver/driver'; +import HomePage from '../../page-objects/pages/home/homepage'; +import TokensTab from '../../page-objects/pages/home/tokens-tab'; +import AssetStickyActions from '../../page-objects/pages/asset/asset-sticky-actions'; +import { login } from '../../page-objects/flows/login.flow'; +import { mockHistoricalPricesV3, mockSpotPrices } from './utils/mocks'; + +describe('Asset sticky actions', function () { + const chainId = CHAIN_IDS.MAINNET; + + it('keeps Buy and Swap pinned to the bottom while the token detail page scrolls', async function () { + await withFixtures( + { + fixtures: new FixtureBuilderV2() + .withSelectedNetwork(NETWORK_CLIENT_ID.MAINNET) + .withEnabledNetworks({ eip155: { [chainId]: true } }) + .build(), + title: (this as Context).test?.fullTitle(), + ethConversionInUsd: 1700, + // Known SubscriptionsController startup race, unrelated to this page. + // Tracked in https://github.com/MetaMask/metamask-extension/issues/45612 + ignoredConsoleErrors: ['getSubscriptions'], + localNodeOptions: { + chainId: parseInt(chainId, 16), + }, + testSpecificMock: async (mockServer: Mockttp) => [ + await mockSpotPrices(mockServer, { + 'eip155:1/slip44:60': { + price: 1700, + marketCap: 382623505141, + pricePercentChange1d: 0, + }, + }), + await mockHistoricalPricesV3(mockServer, 'eip155:1', 'slip44:60'), + ], + }, + async ({ driver }: { driver: Driver }) => { + await login(driver); + + const homePage = new HomePage(driver); + await homePage.checkPageIsLoaded(); + + const tokensTab = new TokensTab(driver); + await tokensTab.openTokenDetails('Ethereum'); + + const stickyActions = new AssetStickyActions(driver); + await stickyActions.checkPageIsLoaded(); + await stickyActions.checkPinnedToViewportBottom(); + + await stickyActions.scrollToBottom(); + + await stickyActions.checkPageIsLoaded(); + await stickyActions.checkPinnedToViewportBottom(); + }, + ); + }); +}); diff --git a/ui/components/app/wallet-overview/coin-buttons.tsx b/ui/components/app/wallet-overview/coin-buttons.tsx index 029af5d1a0c5..53cac5d0d94b 100644 --- a/ui/components/app/wallet-overview/coin-buttons.tsx +++ b/ui/components/app/wallet-overview/coin-buttons.tsx @@ -96,7 +96,9 @@ const NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN: { [key: string]: BridgeAsset } = { [ARC_HEX_CHAIN_ID]: ARC_ERC20_USDC_BRIDGE_ASSET, }; -function getSwapNativeTokenWithOverridesForChain(chainId: string): BridgeAsset { +export function getSwapNativeTokenWithOverridesForChain( + chainId: string, +): BridgeAsset { const override = NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN[chainId]; return override ?? getNativeAssetForChainId(chainId); } diff --git a/ui/components/multichain/toast/index.scss b/ui/components/multichain/toast/index.scss index 7106d6874206..234e6a719d60 100644 --- a/ui/components/multichain/toast/index.scss +++ b/ui/components/multichain/toast/index.scss @@ -47,7 +47,7 @@ } /* Lift the toaster above fixed CTA footers */ -:root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar) { +:root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar, .asset-page__sticky-actions) { --toaster-bottom-offset: 80px; } diff --git a/ui/pages/asset/asset.scss b/ui/pages/asset/asset.scss index 1f06a45541c2..f89aafdee527 100644 --- a/ui/pages/asset/asset.scss +++ b/ui/pages/asset/asset.scss @@ -3,9 +3,20 @@ .asset { &__container { background-color: var(--color-background-default); + // `.main-container` owns `overflow-y: auto`, but the app shell's flex chain + // lets it keep its full content height, so it never actually scrolls — the + // scrolling happens further up on `.app`. That silently breaks + // `position: sticky` for the bottom CTA bar, whose nearest scrollport is + // then this non-scrolling box. Letting it shrink makes it the real + // scrollport so the CTA bar can pin to the bottom of the viewport. + min-height: 0; } } +.main-container-wrapper:has(> .asset__container) { + min-height: 0; +} + .asset-navigation { display: flex; align-items: center; @@ -107,3 +118,24 @@ height: 100%; border-radius: 1rem; } + +// Persistent bottom CTA bar (Buy / Swap) for the Token Detail Page V2. Mirrors +// the Mobile sticky footer: pinned to the bottom of the scroll area, elevated +// above the content, and padded for the device safe-area inset. +.asset-page__sticky-actions { + position: sticky; + inset-inline: 0; + bottom: 0; + // Direct flex child of the scrolling `.main-container`: never let it be + // compressed, and keep it at the bottom even when the page is short enough + // that it does not scroll. + flex-shrink: 0; + margin-top: auto; + z-index: 1; + background-color: var(--color-background-default); + border-top: 1px solid var(--color-border-muted); + box-shadow: 0 -4px 12px var(--color-shadow-default); + padding: 12px 16px; + // Safe-area handling for devices with a home indicator / rounded corners. + padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); +} diff --git a/ui/pages/asset/asset.tsx b/ui/pages/asset/asset.tsx index 79ac9d0ffd6f..feced278cc03 100644 --- a/ui/pages/asset/asset.tsx +++ b/ui/pages/asset/asset.tsx @@ -107,7 +107,10 @@ const Asset = () => { ]); return ( - + {renderContent()} ); diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index 6871c1f24a6d..1bb7bc91dfb9 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -106,6 +106,7 @@ import { isMusdToken } from '../../../components/app/musd/constants'; import { processAssetParams } from '../util'; import { AssetInactiveBadge } from '../../../components/app/assets/asset-inactive-badge/asset-inactive-badge'; import { AssetMarketDetails } from './asset-market-details'; +import { AssetStickyActions } from './asset-sticky-actions'; import AssetChart from './chart/asset-chart'; import { MarketClosedActionButton } from './market-closed-action-button'; import TokenButtons from './token-buttons'; @@ -732,6 +733,14 @@ const AssetPage = ({ onClose={() => setIsMarketClosedModalOpen(false)} /> + {/* Sibling of `asset__content` so it is a direct child of the scrolling + container, which is what lets it stick to the bottom of the viewport. */} + ); }; diff --git a/ui/pages/asset/components/asset-sticky-actions.test.tsx b/ui/pages/asset/components/asset-sticky-actions.test.tsx new file mode 100644 index 000000000000..25c0021b58f4 --- /dev/null +++ b/ui/pages/asset/components/asset-sticky-actions.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { mockNetworkState } from '../../../../test/stub/networks'; +import { AssetType } from '../../../../shared/constants/transaction'; +import { toAssetId } from '../../../../shared/lib/asset-utils'; +import { MetaMetricsSwapsEventSource } from '../../../../shared/constants/metametrics'; +import { Asset } from '../types/asset'; +import { AssetStickyActions } from './asset-sticky-actions'; + +const mockGoToBuy = jest.fn().mockResolvedValue(true); +jest.mock('../../../hooks/ramps/useRampsNavigation/useRampsNavigation', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({ + goToBuy: mockGoToBuy, + opensBuyInPortfolioTab: false, + }), +})); + +const mockOpenBridgeExperience = jest.fn(); +jest.mock('../../../hooks/bridge/useBridging', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({ openBridgeExperience: mockOpenBridgeExperience }), +})); + +const mockTrackEvent = jest.fn(); +jest.mock('../../../hooks/useAnalytics', () => { + const { createEventBuilder } = jest.requireActual( + '../../../../shared/lib/analytics/create-event-builder', + ); + return { + useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder }), + }; +}); + +const token = { + type: AssetType.token, + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: CHAIN_IDS.MAINNET, + decimals: 18, + symbol: 'DAI', + image: '', +} as Asset & { type: AssetType.token }; + +const store = configureMockStore()({ + metamask: { + ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), + useExternalServices: true, + }, +}); + +describe('AssetStickyActions', () => { + beforeEach(() => jest.clearAllMocks()); + + it('routes the Buy button through goToBuy with the token as intent assetId', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByTestId('asset-sticky-buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: toAssetId(token.address, token.chainId), + chainId: token.chainId, + }); + }); + + it('does not track a buy click when the ramps gate blocks the buy', async () => { + mockGoToBuy.mockResolvedValueOnce(false); + const { getByTestId } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByTestId('asset-sticky-buy')); + await waitFor(() => expect(mockGoToBuy).toHaveBeenCalled()); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('opens the swap experience with the token as the source asset', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + + fireEvent.click(getByTestId('asset-sticky-swap')); + expect(mockOpenBridgeExperience).toHaveBeenCalledWith( + MetaMetricsSwapsEventSource.TokenView, + token, + ); + }); + + it('disables swap while the stock market is closed', () => { + const { getByTestId } = renderWithProvider( + , + store, + ); + + expect(getByTestId('asset-sticky-swap')).toBeDisabled(); + }); +}); diff --git a/ui/pages/asset/components/asset-sticky-actions.tsx b/ui/pages/asset/components/asset-sticky-actions.tsx new file mode 100644 index 000000000000..0024b5f722b8 --- /dev/null +++ b/ui/pages/asset/components/asset-sticky-actions.tsx @@ -0,0 +1,197 @@ +import { + Box, + BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, + IconName, +} from '@metamask/design-system-react'; +import type { CaipAssetType } from '@metamask/utils'; +import React, { useCallback, useContext } from 'react'; +import { useSelector } from 'react-redux'; +import { ALL_ALLOWED_BRIDGE_CHAIN_IDS } from '../../../../shared/constants/bridge'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsSwapsEventSource, +} from '../../../../shared/constants/metametrics'; +import { toAssetId } from '../../../../shared/lib/asset-utils'; +import { getSwapNativeTokenWithOverridesForChain } from '../../../components/app/wallet-overview/coin-buttons'; +import { transitionForward } from '../../../components/ui/transition'; +import { I18nContext } from '../../../contexts/i18n'; +import { showBuyTabOpenedToast } from '../../../helpers/utils/show-buy-tab-opened-toast'; +import useBridging from '../../../hooks/bridge/useBridging'; +import useRampsNavigation from '../../../hooks/ramps/useRampsNavigation/useRampsNavigation'; +import { useAnalytics } from '../../../hooks/useAnalytics'; +import { getUseExternalServices } from '../../../selectors'; +import { isNativeAsset, type Asset } from '../types/asset'; +import { + useAssetPageSecurityTrustCtaGate, + useAssetPageSecurityTrustCtaGateReady, +} from './security-trust'; + +type AssetStickyActionsProps = { + asset: Asset; + /** + * CAIP-19 asset to pre-select when buying a native asset. Tokens derive it + * from their contract address instead. + */ + buyAssetId?: CaipAssetType; + /** Disables Swap while a stock token's market is closed */ + isMarketClosed?: boolean; + /** Native assets cannot swap from accounts that cannot sign */ + isSigningEnabled?: boolean; +}; + +/** + * Swap and Buy calls to action, pinned to the bottom of the asset page so they + * stay reachable while the rest of the page scrolls. + * + * @param props - The component props. + * @param props.asset - The native or token asset being displayed. + * @param props.buyAssetId - CAIP-19 asset to pre-select when buying a native asset. + * @param props.isMarketClosed - Whether the asset's stock market is closed. + * @param props.isSigningEnabled - Whether the selected account can sign. + */ +export const AssetStickyActions = ({ + asset, + buyAssetId, + isMarketClosed = false, + isSigningEnabled = true, +}: AssetStickyActionsProps) => { + const t = useContext(I18nContext); + const { trackEvent, createEventBuilder } = useAnalytics(); + const isExternalServicesEnabled = useSelector(getUseExternalServices); + const gateCtaAction = useAssetPageSecurityTrustCtaGate(); + const isCtaGateReady = useAssetPageSecurityTrustCtaGateReady(); + const { goToBuy, opensBuyInPortfolioTab } = useRampsNavigation(); + const { openBridgeExperience } = useBridging(); + + const isNative = isNativeAsset(asset); + const { chainId, symbol } = asset; + + const handleBuyClick = useCallback(async () => { + const runBuy = async () => { + const assetId = isNativeAsset(asset) + ? buyAssetId + : toAssetId(asset.address, chainId); + + const opened = await goToBuy({ assetId, chainId }); + // The ramps gate can block the buy and show its own modal; don't report a + // buy click in that case. + if (!opened) { + return; + } + + // Only the Portfolio paths open a browser tab; when goToBuy navigates + // in-app the "tab opened" toast would be misleading. + if (opensBuyInPortfolioTab) { + showBuyTabOpenedToast( + t('buyTabOpenedToastText'), + t('buyTabOpenedToastDescription'), + ); + } + + trackEvent( + createEventBuilder(MetaMetricsEventName.NavBuyButtonClicked) + .addCategory(MetaMetricsEventCategory.Navigation) + .addProperties({ + location: 'Token Overview', + text: 'Buy', + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: chainId, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + token_symbol: symbol, + }) + .build(), + ); + }; + + if (gateCtaAction) { + gateCtaAction(runBuy, 'buy'); + return; + } + + await runBuy(); + }, [ + asset, + buyAssetId, + chainId, + createEventBuilder, + gateCtaAction, + goToBuy, + opensBuyInPortfolioTab, + symbol, + t, + trackEvent, + ]); + + const handleSwapClick = useCallback(() => { + const runSwap = () => { + if (isNativeAsset(asset)) { + // Native swaps start from the chain's default bridge asset, which some + // chains override (e.g. Arc swaps the ERC20 flavor of USDC). + transitionForward(() => + openBridgeExperience( + MetaMetricsSwapsEventSource.MainView, + ALL_ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId) + ? getSwapNativeTokenWithOverridesForChain(chainId) + : undefined, + ), + ); + return; + } + + openBridgeExperience(MetaMetricsSwapsEventSource.TokenView, asset); + }; + + if (gateCtaAction) { + gateCtaAction(runSwap, 'swap'); + return; + } + + runSwap(); + }, [asset, chainId, gateCtaAction, openBridgeExperience]); + + const isSwapDisabled = + !isExternalServicesEnabled || + !isCtaGateReady || + isMarketClosed || + (isNative && !isSigningEnabled); + + return ( + + + + + ); +}; + +export default AssetStickyActions; diff --git a/ui/pages/asset/security-trust/security-trust-page.tsx b/ui/pages/asset/security-trust/security-trust-page.tsx index 0f4e98867c13..965b485573fd 100644 --- a/ui/pages/asset/security-trust/security-trust-page.tsx +++ b/ui/pages/asset/security-trust/security-trust-page.tsx @@ -678,7 +678,10 @@ const SecurityTrustPage = () => { ); return ( - + {pageContent} );