From 053922740d6b514dbd085f68f403ca7ac1738c0f Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 15:36:48 -0400 Subject: [PATCH 01/12] feat(home): add TMCU-1103 action buttons grid AB config Register homeTMCU1103AbtestActionButtonsGrid variants, analytics mapping, and feature-flag registry entry for the 3-arm homepage test. --- app/components/Views/Homepage/abTestConfig.ts | 57 +++++++++++++++++++ app/util/analytics/abTestAnalyticsRegistry.ts | 2 + tests/feature-flags/feature-flag-registry.ts | 30 ++++++++++ 3 files changed, 89 insertions(+) diff --git a/app/components/Views/Homepage/abTestConfig.ts b/app/components/Views/Homepage/abTestConfig.ts index 4596092bbef..dd433db0e68 100644 --- a/app/components/Views/Homepage/abTestConfig.ts +++ b/app/components/Views/Homepage/abTestConfig.ts @@ -282,3 +282,60 @@ export function getHomepageDiscoveryPillsTransactionActiveAbTests( createActiveABTestAssignment(HOMEPAGE_DISCOVERY_PILLS_AB_KEY, variantName), ]; } + +// ─── Homepage action buttons 2×4 grid (TMCU-1103) ──────────────────────────── + +/** + * LaunchDarkly / remote flag key. Pattern: `{team}{TICKET}Abtest{Name}` — keep in + * sync with the flag in LD (team `home`, ticket TMCU-1103). + */ +export const HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY = + 'homeTMCU1103AbtestActionButtonsGrid'; + +export enum HomepageActionButtonsGridVariant { + Control = 'control', + Row1Top = 'row1Top', + Row2Top = 'row2Top', +} + +export type HomepageActionButtonsGridRowOrder = 'row1Top' | 'row2Top'; + +type HomepageActionButtonsGridVariantConfig = + | { layout: 'fourSquare' } + | { + layout: 'eightCircular'; + rowOrder: HomepageActionButtonsGridRowOrder; + }; + +export const HOMEPAGE_ACTION_BUTTONS_GRID_VARIANTS: Record< + HomepageActionButtonsGridVariant, + HomepageActionButtonsGridVariantConfig +> = { + [HomepageActionButtonsGridVariant.Control]: { + layout: 'fourSquare', + }, + [HomepageActionButtonsGridVariant.Row1Top]: { + layout: 'eightCircular', + rowOrder: 'row1Top', + }, + [HomepageActionButtonsGridVariant.Row2Top]: { + layout: 'eightCircular', + rowOrder: 'row2Top', + }, +}; + +export const HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_EXPOSURE_OPTIONS = { + experimentName: 'Homepage action buttons 2x4 grid', + variationNames: { + control: '4 square action buttons', + row1Top: '8 circular buttons, Row 1 top', + row2Top: '8 circular buttons, Row 2 top', + }, +} as const; + +export const HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMapping = + { + flagKey: HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY, + validVariants: Object.values(HomepageActionButtonsGridVariant), + eventNames: [EVENT_NAME.HOME_VIEWED, EVENT_NAME.ACTION_BUTTON_CLICKED], + }; diff --git a/app/util/analytics/abTestAnalyticsRegistry.ts b/app/util/analytics/abTestAnalyticsRegistry.ts index 7edb99c1734..385fbda2b1a 100644 --- a/app/util/analytics/abTestAnalyticsRegistry.ts +++ b/app/util/analytics/abTestAnalyticsRegistry.ts @@ -6,6 +6,7 @@ import { POST_TRADE_MODAL_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/ import { BRIDGE_TOKEN_SELECTOR_VERIFIED_BADGE_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/TokenButton.abTestConfig'; import { TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/TokenSelectorItem.abTestConfig'; import { + HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_ANALYTICS_MAPPING, HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING, HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING, HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING, @@ -31,6 +32,7 @@ export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [ TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING, // Homepage + HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_ANALYTICS_MAPPING, HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING, HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING, HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING, diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index e461e081e03..f7fd67414c9 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -5135,6 +5135,36 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + homeTMCU1103AbtestActionButtonsGrid: { + name: 'homeTMCU1103AbtestActionButtonsGrid', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: [ + { + name: 'control', + scope: { + type: 'percentage_rollout', + value: 1, + }, + }, + { + name: 'row1Top', + scope: { + type: 'percentage_rollout', + value: 1, + }, + }, + { + name: 'row2Top', + scope: { + type: 'percentage_rollout', + value: 1, + }, + }, + ], + status: FeatureFlagStatus.Active, + }, + homepageRedesignV1: { name: 'homepageRedesignV1', type: FeatureFlagType.Remote, From 695f0acf52326defbbe6b788e4f7c8b381b6aa08 Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 15:38:12 -0400 Subject: [PATCH 02/12] feat(analytics): extend action button types and positions for TMCU-1103 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sell/perps/predict/batch_swap/traders action names and positions 0–7 so the homepage 2×4 grid can track taps correctly. --- .../analytics/actionButtonTracking.test.ts | 42 +++++++++++++------ app/util/analytics/actionButtonTracking.ts | 13 +++++- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/util/analytics/actionButtonTracking.test.ts b/app/util/analytics/actionButtonTracking.test.ts index 4c7ebbe7f15..81be68b0a16 100644 --- a/app/util/analytics/actionButtonTracking.test.ts +++ b/app/util/analytics/actionButtonTracking.test.ts @@ -57,18 +57,33 @@ describe('actionButtonTracking', () => { expect(ActionButtonType.SWAP).toBe('swap'); expect(ActionButtonType.SEND).toBe('send'); expect(ActionButtonType.RECEIVE).toBe('receive'); + expect(ActionButtonType.SELL).toBe('sell'); + expect(ActionButtonType.PERPS).toBe('perps'); + expect(ActionButtonType.PREDICT).toBe('predict'); + expect(ActionButtonType.BATCH_SWAP).toBe('batch_swap'); + expect(ActionButtonType.TRADERS).toBe('traders'); }); it('covers all action button types', () => { // Given: all expected action types - const expectedTypes = ['buy', 'swap', 'send', 'receive']; + const expectedTypes = [ + 'buy', + 'swap', + 'send', + 'receive', + 'sell', + 'perps', + 'predict', + 'batch_swap', + 'traders', + ]; // When: checking enum values const actualTypes = Object.values(ActionButtonType); // Then: should match expected types expect(actualTypes).toEqual(expect.arrayContaining(expectedTypes)); - expect(actualTypes).toHaveLength(4); + expect(actualTypes).toHaveLength(9); }); }); @@ -108,19 +123,20 @@ describe('actionButtonTracking', () => { expect(ActionPosition.SECOND_POSITION).toBe(1); expect(ActionPosition.THIRD_POSITION).toBe(2); expect(ActionPosition.FOURTH_POSITION).toBe(3); + expect(ActionPosition.FIFTH_POSITION).toBe(4); + expect(ActionPosition.SIXTH_POSITION).toBe(5); + expect(ActionPosition.SEVENTH_POSITION).toBe(6); + expect(ActionPosition.EIGHTH_POSITION).toBe(7); }); - it('has corresponding ActionButtonType values', () => { - // Given/When/Then: position values should correspond to button type values - expect(ActionPosition.FIRST_POSITION).toBe(0); - expect(ActionPosition.SECOND_POSITION).toBe(1); - expect(ActionPosition.THIRD_POSITION).toBe(2); - expect(ActionPosition.FOURTH_POSITION).toBe(3); - // Verify ActionButtonType has corresponding string values - expect(ActionButtonType.BUY).toBe('buy'); - expect(ActionButtonType.SWAP).toBe('swap'); - expect(ActionButtonType.SEND).toBe('send'); - expect(ActionButtonType.RECEIVE).toBe('receive'); + it('covers eight positions for homepage grid AB tests', () => { + // Given/When: checking enum values + const actualPositions = Object.values(ActionPosition).filter( + (value) => typeof value === 'number', + ); + + // Then: should include positions 0–7 + expect(actualPositions).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); }); }); diff --git a/app/util/analytics/actionButtonTracking.ts b/app/util/analytics/actionButtonTracking.ts index 3b7241694f8..db1a480a358 100644 --- a/app/util/analytics/actionButtonTracking.ts +++ b/app/util/analytics/actionButtonTracking.ts @@ -14,17 +14,26 @@ export enum ActionButtonType { SWAP = 'swap', SEND = 'send', RECEIVE = 'receive', + SELL = 'sell', + PERPS = 'perps', + PREDICT = 'predict', + BATCH_SWAP = 'batch_swap', + TRADERS = 'traders', } /** - * The position of the button in the action button for remote config use and a/b testing - * Not in use but will be in the future + * The position of the button in the action button for remote config use and a/b testing. + * Supports up to 8 slots for the homepage 2×4 action-button grid AB test (TMCU-1103). */ export enum ActionPosition { FIRST_POSITION = 0, SECOND_POSITION = 1, THIRD_POSITION = 2, FOURTH_POSITION = 3, + FIFTH_POSITION = 4, + SIXTH_POSITION = 5, + SEVENTH_POSITION = 6, + EIGHTH_POSITION = 7, } /** From 2b3957e300f1b084a080dbb65a0bfe302e7ac2bc Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 15:46:38 -0400 Subject: [PATCH 03/12] feat(home): add HomepageActionButtonsGrid treatment UI for TMCU-1103 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained 2×4 circular action grid with per-button handlers, MMDS primitives, and skipActionButtonClickTracking on swap nav so home taps keep correct ACTION_BUTTON_CLICKED position. --- .../hooks/useSwapBridgeNavigation/index.ts | 41 ++++++--- .../HomepageActionButton.tsx | 75 ++++++++++++++++ .../HomepageActionButtonsGrid.testIds.ts | 11 +++ .../HomepageActionButtonsGrid.tsx | 89 +++++++++++++++++++ .../buttons/BatchSwapButton.tsx | 64 +++++++++++++ .../buttons/BuyButton.tsx | 49 ++++++++++ .../buttons/PerpsButton.tsx | 65 ++++++++++++++ .../buttons/PredictButton.tsx | 55 ++++++++++++ .../buttons/SellButton.tsx | 44 +++++++++ .../buttons/SendButton.tsx | 46 ++++++++++ .../buttons/SwapButton.tsx | 56 ++++++++++++ .../buttons/TradersButton.tsx | 52 +++++++++++ .../HomepageActionButtonsGrid/constants.ts | 52 +++++++++++ .../HomepageActionButtonsGrid/index.ts | 4 + .../HomepageActionButtonsGrid/types.ts | 17 ++++ locales/languages/en.json | 10 +++ 16 files changed, 716 insertions(+), 14 deletions(-) create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.testIds.ts create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PerpsButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PredictButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SellButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SwapButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.ts create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/index.ts create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/types.ts diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts index 0c8800b6a63..677641c8748 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts @@ -135,6 +135,7 @@ export const useSwapBridgeNavigation = ({ transactionActiveAbTests, skipLocationUpdate = false, swapButtonEventLocationOverride, + skipActionButtonClickTracking = false, }: { location: SwapBridgeNavigationLocation; sourcePage: string; @@ -161,6 +162,11 @@ export const useSwapBridgeNavigation = ({ swapButtonEventLocationOverride?: | ActionLocation | SwapBridgeNavigationLocation; + /** + * When true, skip consolidated ACTION_BUTTON_CLICKED from this hook so the + * caller can emit it with the correct location / position (e.g. homepage grid). + */ + skipActionButtonClickTracking?: boolean; }) => { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -370,20 +376,26 @@ export const useSwapBridgeNavigation = ({ } // Track Swap button click with new consolidated event - const isFromNavbar = location === SwapBridgeNavigationLocation.MainView; - const actionButtonProps = { - action_name: ActionButtonType.SWAP, - // Omit action_position for navbar to avoid confusion with main action buttons - ...(isFromNavbar - ? {} - : { action_position: ActionPosition.SECOND_POSITION }), - button_label: buttonLabel ?? strings('asset_overview.swap'), - location: isFromNavbar - ? ActionLocation.NAVBAR - : ActionLocation.ASSET_DETAILS, - }; - - trackActionButtonClick(trackEvent, createEventBuilder, actionButtonProps); + if (!skipActionButtonClickTracking) { + const isFromNavbar = location === SwapBridgeNavigationLocation.MainView; + const actionButtonProps = { + action_name: ActionButtonType.SWAP, + // Omit action_position for navbar to avoid confusion with main action buttons + ...(isFromNavbar + ? {} + : { action_position: ActionPosition.SECOND_POSITION }), + button_label: buttonLabel ?? strings('asset_overview.swap'), + location: isFromNavbar + ? ActionLocation.NAVBAR + : ActionLocation.ASSET_DETAILS, + }; + + trackActionButtonClick( + trackEvent, + createEventBuilder, + actionButtonProps, + ); + } const swapEventProperties = { location: @@ -421,6 +433,7 @@ export const useSwapBridgeNavigation = ({ getIsBridgeEnabledSource, skipLocationUpdate, swapButtonEventLocationOverride, + skipActionButtonClickTracking, transactionActiveAbTests, isBasicFunctionalityEnabled, prefetchPopularTokens, diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx new file mode 100644 index 00000000000..1e9e018cc79 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Pressable } from 'react-native'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +export interface HomepageActionButtonProps { + iconName: IconName; + label: string; + onPress: () => void; + isDisabled?: boolean; + testID?: string; +} + +/** + * Presentation-only circular action button (icon pill + label). + * Navigation and analytics live in each `buttons/*Button.tsx` caller. + */ +const HomepageActionButton = ({ + iconName, + label, + onPress, + isDisabled = false, + testID, +}: HomepageActionButtonProps) => { + const tw = useTailwind(); + + return ( + + + + + + {label} + + + ); +}; + +export default HomepageActionButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.testIds.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.testIds.ts new file mode 100644 index 00000000000..90461f9c387 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.testIds.ts @@ -0,0 +1,11 @@ +export const HomepageActionButtonsGridTestIds = { + CONTAINER: 'homepage-action-buttons-grid', + BUY_BUTTON: 'homepage-action-buttons-grid-buy', + SELL_BUTTON: 'homepage-action-buttons-grid-sell', + SWAP_BUTTON: 'homepage-action-buttons-grid-swap', + SEND_BUTTON: 'homepage-action-buttons-grid-send', + PERPS_BUTTON: 'homepage-action-buttons-grid-perps', + PREDICT_BUTTON: 'homepage-action-buttons-grid-predict', + BATCH_SWAP_BUTTON: 'homepage-action-buttons-grid-batch-swap', + TRADERS_BUTTON: 'homepage-action-buttons-grid-traders', +} as const; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx new file mode 100644 index 00000000000..2a4bf572d40 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { + Box, + BoxFlexDirection, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import BuyButton from './buttons/BuyButton'; +import SellButton from './buttons/SellButton'; +import SwapButton from './buttons/SwapButton'; +import SendButton from './buttons/SendButton'; +import PerpsButton from './buttons/PerpsButton'; +import PredictButton from './buttons/PredictButton'; +import BatchSwapButton from './buttons/BatchSwapButton'; +import TradersButton from './buttons/TradersButton'; +import { + ACTION_POSITION_BY_INDEX, + getOrderedButtonRows, + type HomepageActionButtonId, +} from './constants'; +import { HomepageActionButtonsGridTestIds } from './HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonsGridProps } from './types'; + +const renderButton = ( + id: HomepageActionButtonId, + actionPosition: (typeof ACTION_POSITION_BY_INDEX)[number], + onSend: () => void, +) => { + switch (id) { + case 'buy': + return ; + case 'sell': + return ; + case 'swap': + return ; + case 'send': + return ; + case 'perps': + return ; + case 'predict': + return ; + case 'batch_swap': + return ; + case 'traders': + return ; + default: { + const exhaustive: never = id; + return exhaustive; + } + } +}; + +/** + * Homepage 8-button 2×4 action grid for TMCU-1103 treatments. + * Row order is controlled by the AB variant (`row1Top` | `row2Top`). + */ +const HomepageActionButtonsGrid = ({ + rowOrder, + onSend, +}: HomepageActionButtonsGridProps) => { + const rows = getOrderedButtonRows(rowOrder); + + return ( + + {rows.map((rowIds, rowIndex) => ( + + {rowIds.map((id, colIndex) => { + const actionPosition = + ACTION_POSITION_BY_INDEX[rowIndex * 4 + colIndex]; + return ( + + {renderButton(id, actionPosition, onSend)} + + ); + })} + + ))} + + ); +}; + +export default HomepageActionButtonsGrid; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx new file mode 100644 index 00000000000..1d5f595f518 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx @@ -0,0 +1,64 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { BatchSellMetricsLocation } from '@metamask/bridge-controller'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import AppConstants from '../../../../../../core/AppConstants'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { selectIsSwapsEnabled } from '../../../../../../core/redux/slices/bridge'; +import type { RootState } from '../../../../../../reducers'; +import { selectBatchSellEnabled } from '../../../../../../selectors/featureFlagController/batchSell'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +/** + * Opens Batch Sell (trade-menu flow). Label matches Figma "Batch Swap". + * `batchSellLocation` left as Unknown until analytics defines a home entrypoint. + */ +const BatchSwapButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const isBatchSellEnabled = useSelector(selectBatchSellEnabled); + const isSwapsEnabled = useSelector((state: RootState) => + selectIsSwapsEnabled(state), + ); + const label = strings('homepage.action_buttons.batch_swap'); + const isDisabled = + !isBatchSellEnabled || !AppConstants.SWAPS.ACTIVE || !isSwapsEnabled; + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.BATCH_SWAP, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + + navigation.navigate(Routes.BRIDGE.ROOT, { + screen: Routes.BRIDGE.BATCH_SELL_TOKEN_SELECT, + params: { + batchSellLocation: BatchSellMetricsLocation.Unknown, + }, + }); + }, [actionPosition, createEventBuilder, label, navigation, trackEvent]); + + return ( + + ); +}; + +export default BatchSwapButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx new file mode 100644 index 00000000000..86438eed47a --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import useDepositEnabled from '../../../../../UI/Ramp/hooks/useDepositEnabled'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +const BuyButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const { isDepositEnabled } = useDepositEnabled(); + const isBuyingAvailable = isDepositEnabled || true; + const label = strings('homepage.action_buttons.buy'); + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.BUY, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.FUND_ACTION_MENU, + params: {}, + }); + }, [actionPosition, createEventBuilder, label, navigation, trackEvent]); + + return ( + + ); +}; + +export default BuyButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PerpsButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PerpsButton.tsx new file mode 100644 index 00000000000..08cabbaede8 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PerpsButton.tsx @@ -0,0 +1,65 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { selectCanSignTransactions } from '../../../../../../selectors/accountsController'; +import { selectPerpsEnabledFlag } from '../../../../../UI/Perps'; +import { selectIsFirstTimePerpsUser } from '../../../../../UI/Perps/selectors/perpsController'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +const PerpsButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const isFirstTimePerpsUser = useSelector(selectIsFirstTimePerpsUser); + const canSignTransactions = useSelector(selectCanSignTransactions); + const label = strings('homepage.action_buttons.perps'); + const isDisabled = !isPerpsEnabled || !canSignTransactions; + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.PERPS, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + + if (isFirstTimePerpsUser) { + navigation.navigate(Routes.PERPS.TUTORIAL); + return; + } + + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + }); + }, [ + actionPosition, + createEventBuilder, + isFirstTimePerpsUser, + label, + navigation, + trackEvent, + ]); + + return ( + + ); +}; + +export default PerpsButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PredictButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PredictButton.tsx new file mode 100644 index 00000000000..ed68f54c227 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/PredictButton.tsx @@ -0,0 +1,55 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { selectCanSignTransactions } from '../../../../../../selectors/accountsController'; +import { selectPredictEnabledFlag } from '../../../../../UI/Predict/selectors/featureFlags'; +import { PredictEventValues } from '../../../../../UI/Predict/constants/eventNames'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +const PredictButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const canSignTransactions = useSelector(selectCanSignTransactions); + const label = strings('homepage.action_buttons.predict'); + const isDisabled = !isPredictEnabled || !canSignTransactions; + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.PREDICT, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { + entryPoint: PredictEventValues.ENTRY_POINT.HOMESCREEN_PILL, + }, + }); + }, [actionPosition, createEventBuilder, label, navigation, trackEvent]); + + return ( + + ); +}; + +export default PredictButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SellButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SellButton.tsx new file mode 100644 index 00000000000..1db645b8ba9 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SellButton.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import { selectCanSignTransactions } from '../../../../../../selectors/accountsController'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { useRampNavigation } from '../../../../../UI/Ramp/hooks/useRampNavigation'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +const SellButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { + const { trackEvent, createEventBuilder } = useAnalytics(); + const { goToSell } = useRampNavigation(); + const canSignTransactions = useSelector(selectCanSignTransactions); + const label = strings('homepage.action_buttons.sell'); + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.SELL, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + goToSell(); + }, [actionPosition, createEventBuilder, goToSell, label, trackEvent]); + + return ( + + ); +}; + +export default SellButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx new file mode 100644 index 00000000000..a6f150004f9 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SendButton.tsx @@ -0,0 +1,46 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import { selectCanSignTransactions } from '../../../../../../selectors/accountsController'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +interface SendButtonProps extends HomepageActionButtonSlotProps { + onSend: () => void; +} + +const SendButton = ({ actionPosition, onSend }: SendButtonProps) => { + const { trackEvent, createEventBuilder } = useAnalytics(); + const canSignTransactions = useSelector(selectCanSignTransactions); + const label = strings('homepage.action_buttons.send'); + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.SEND, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + onSend(); + }, [actionPosition, createEventBuilder, label, onSend, trackEvent]); + + return ( + + ); +}; + +export default SendButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SwapButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SwapButton.tsx new file mode 100644 index 00000000000..1c63bb11d6c --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/SwapButton.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import AppConstants from '../../../../../../core/AppConstants'; +import { selectIsSwapsEnabled } from '../../../../../../core/redux/slices/bridge'; +import type { RootState } from '../../../../../../reducers'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { + SwapBridgeNavigationLocation, + useSwapBridgeNavigation, +} from '../../../../../UI/Bridge/hooks/useSwapBridgeNavigation'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +const SwapButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { + const { trackEvent, createEventBuilder } = useAnalytics(); + const isSwapsEnabled = useSelector((state: RootState) => + selectIsSwapsEnabled(state), + ); + const { goToSwaps } = useSwapBridgeNavigation({ + location: SwapBridgeNavigationLocation.MainView, + sourcePage: 'MainView', + skipActionButtonClickTracking: true, + }); + const label = strings('homepage.action_buttons.swap'); + const isDisabled = !AppConstants.SWAPS.ACTIVE || !isSwapsEnabled; + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.SWAP, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + goToSwaps(); + }, [actionPosition, createEventBuilder, goToSwaps, label, trackEvent]); + + return ( + + ); +}; + +export default SwapButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx new file mode 100644 index 00000000000..799d2c5b30b --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { IconName } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import { selectSocialLeaderboardEnabled } from '../../../../../../selectors/featureFlagController/socialLeaderboard'; +import type { AppNavigationProp } from '../../../../../../core/NavigationService/types'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { navigateToSocialLeaderboard } from '../../../../SocialLeaderboard/Onboarding/socialLeaderboardOnboardingNavigation'; +import { + ActionButtonType, + ActionLocation, + trackActionButtonClick, +} from '../../../../../../util/analytics/actionButtonTracking'; +import HomepageActionButton from '../HomepageActionButton'; +import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.testIds'; +import type { HomepageActionButtonSlotProps } from '../types'; + +const TradersButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const isSocialLeaderboardEnabled = useSelector( + selectSocialLeaderboardEnabled, + ); + const label = strings('homepage.action_buttons.traders'); + + const handlePress = useCallback(() => { + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.TRADERS, + action_position: actionPosition, + button_label: label, + location: ActionLocation.HOME, + }); + + navigateToSocialLeaderboard(navigation.navigate, { + source: 'home_carousel', + }); + }, [actionPosition, createEventBuilder, label, navigation, trackEvent]); + + return ( + + ); +}; + +export default TradersButton; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.ts new file mode 100644 index 00000000000..ce5e9092dac --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.ts @@ -0,0 +1,52 @@ +import { ActionPosition } from '../../../../../util/analytics/actionButtonTracking'; +import type { HomepageActionButtonsGridRowOrder } from '../../abTestConfig'; + +/** + * Row 1 (Figma Treatment 4): Buy, Sell, Swap, Send + * Row 2: Perps, Predict, Batch Swap, Traders + */ +export const HOMEPAGE_ACTION_BUTTON_IDS = [ + 'buy', + 'sell', + 'swap', + 'send', + 'perps', + 'predict', + 'batch_swap', + 'traders', +] as const; + +export type HomepageActionButtonId = + (typeof HOMEPAGE_ACTION_BUTTON_IDS)[number]; + +export const ROW1_BUTTON_IDS: HomepageActionButtonId[] = [ + 'buy', + 'sell', + 'swap', + 'send', +]; + +export const ROW2_BUTTON_IDS: HomepageActionButtonId[] = [ + 'perps', + 'predict', + 'batch_swap', + 'traders', +]; + +export const ACTION_POSITION_BY_INDEX: ActionPosition[] = [ + ActionPosition.FIRST_POSITION, + ActionPosition.SECOND_POSITION, + ActionPosition.THIRD_POSITION, + ActionPosition.FOURTH_POSITION, + ActionPosition.FIFTH_POSITION, + ActionPosition.SIXTH_POSITION, + ActionPosition.SEVENTH_POSITION, + ActionPosition.EIGHTH_POSITION, +]; + +export const getOrderedButtonRows = ( + rowOrder: HomepageActionButtonsGridRowOrder, +): HomepageActionButtonId[][] => + rowOrder === 'row1Top' + ? [ROW1_BUTTON_IDS, ROW2_BUTTON_IDS] + : [ROW2_BUTTON_IDS, ROW1_BUTTON_IDS]; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/index.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/index.ts new file mode 100644 index 00000000000..a1774f5c0f5 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/index.ts @@ -0,0 +1,4 @@ +export { default as HomepageActionButtonsGrid } from './HomepageActionButtonsGrid'; +export { default as HomepageActionButton } from './HomepageActionButton'; +export { HomepageActionButtonsGridTestIds } from './HomepageActionButtonsGrid.testIds'; +export type { HomepageActionButtonsGridProps } from './types'; diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/types.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/types.ts new file mode 100644 index 00000000000..3afc2a3123c --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/types.ts @@ -0,0 +1,17 @@ +import type { ActionPosition } from '../../../../../util/analytics/actionButtonTracking'; +import type { HomepageActionButtonsGridRowOrder } from '../../abTestConfig'; + +export interface HomepageActionButtonSlotProps { + /** On-screen position (0–7) for ACTION_BUTTON_CLICKED analytics. */ + actionPosition: ActionPosition; +} + +export interface HomepageActionButtonsGridProps { + rowOrder: HomepageActionButtonsGridRowOrder; + /** + * Send handler owned by Wallet (includes non-EVM / keyring-snaps paths). + */ + onSend: () => void; +} + +export type { HomepageActionButtonsGridRowOrder }; diff --git a/locales/languages/en.json b/locales/languages/en.json index 8487b9cbb2f..4970c27542c 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -10339,6 +10339,16 @@ } }, "homepage": { + "action_buttons": { + "buy": "Buy", + "sell": "Sell", + "swap": "Swap", + "send": "Send", + "perps": "Perps", + "predict": "Predict", + "batch_swap": "Batch Swap", + "traders": "Traders" + }, "sections": { "money": "Money", "money_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Money section on the homepage.", From 0cc2b2ddc20b35e7d5845545b5e70717b3da4c23 Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 15:50:15 -0400 Subject: [PATCH 04/12] feat(home): wire TMCU-1103 action buttons grid AB into Wallet Swap AssetDetailsActions for HomepageActionButtonsGrid on treatment variants behind the existing onboarding checklist gate. --- app/components/Views/Wallet/index.tsx | 66 ++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index cb66f96c035..bdcd7eac0f6 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -132,6 +132,9 @@ import Homepage from '../Homepage'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import HomepageDiscoveryTabs from '../Homepage/components/HomepageDiscoveryTabs'; import { + HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY, + HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_EXPOSURE_OPTIONS, + HOMEPAGE_ACTION_BUTTONS_GRID_VARIANTS, HOMEPAGE_DISCOVERY_PILLS_AB_KEY, HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS, HOMEPAGE_DISCOVERY_PILLS_VARIANTS, @@ -142,6 +145,8 @@ import { } from '../Homepage/abTestConfig'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import { HomepageDiscoveryPills } from '../Homepage/components/HomepageDiscoveryPills'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { HomepageActionButtonsGrid } from '../Homepage/components/HomepageActionButtonsGrid'; import { useABTest } from '../../../hooks'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import { HomepageScrollContext } from '../Homepage/context/HomepageScrollContext'; @@ -537,6 +542,30 @@ const Wallet = ({ ///: END:ONLY_INCLUDE_IF ]); + /** Navigation-only send for AB treatment buttons (they own ACTION_BUTTON_CLICKED). */ + const onSendWithoutTracking = useCallback(async () => { + try { + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + const wasHandledAsNonEvm = await sendNonEvmAsset( + InitSendLocation.HomePage, + ); + if (wasHandledAsNonEvm) { + return; + } + ///: END:ONLY_INCLUDE_IF + + navigateToSendPage({ location: InitSendLocation.HomePage }); + } catch (error) { + console.error('Error initiating send flow:', error); + navigateToSendPage({ location: InitSendLocation.HomePage }); + } + }, [ + navigateToSendPage, + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + sendNonEvmAsset, + ///: END:ONLY_INCLUDE_IF + ]); + const isDataCollectionForMarketingEnabled = useSelector( (state: RootState) => state.security.dataCollectionForMarketing, ); @@ -757,6 +786,12 @@ const Wallet = ({ HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS, ); + const { variant: actionButtonsGridVariant } = useABTest( + HOMEPAGE_ACTION_BUTTONS_GRID_AB_KEY, + HOMEPAGE_ACTION_BUTTONS_GRID_VARIANTS, + HOMEPAGE_ACTION_BUTTONS_GRID_AB_TEST_EXPOSURE_OPTIONS, + ); + const isDiscoveryTabsTreatment = discoveryTabsVariantName === HubPageDiscoveryTabsVariant.Treatment; @@ -1021,18 +1056,25 @@ const Wallet = ({ }; const walletHomeMainAssetDetailsActions = showWalletHomeMainActions ? ( - + actionButtonsGridVariant.layout === 'eightCircular' ? ( + + ) : ( + + ) ) : null; const homepageDiscoveryPills = showDiscoveryPills ? ( From eab94ff28a16e76bbaa07393076f88c7b7638aaa Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 15:55:26 -0400 Subject: [PATCH 05/12] test(home): cover TMCU-1103 action buttons grid AB wiring Add grid unit tests for row order and tap analytics, and Wallet branch coverage for control vs treatment variants. --- .../HomepageActionButtonsGrid.test.tsx | 216 ++++++++++++++++++ .../constants.test.ts | 36 +++ app/components/Views/Wallet/index.test.tsx | 125 ++++++++++ 3 files changed, 377 insertions(+) create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx create mode 100644 app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.test.ts diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx new file mode 100644 index 00000000000..c1fe01a3200 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { selectCanSignTransactions } from '../../../../../selectors/accountsController'; +import { selectIsSwapsEnabled } from '../../../../../core/redux/slices/bridge'; +import { selectBatchSellEnabled } from '../../../../../selectors/featureFlagController/batchSell'; +import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard'; +import { selectPerpsEnabledFlag } from '../../../../UI/Perps'; +import { selectIsFirstTimePerpsUser } from '../../../../UI/Perps/selectors/perpsController'; +import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags'; +import { + ActionButtonType, + ActionLocation, + ActionPosition, +} from '../../../../../util/analytics/actionButtonTracking'; +import { HomepageActionButtonsGridTestIds } from './HomepageActionButtonsGrid.testIds'; + +const mockTrackActionButtonClick = jest.fn(); +const mockGoToSell = jest.fn(); +const mockGoToSwaps = jest.fn(); +const mockNavigate = jest.fn(); +const mockOnSend = jest.fn(); +const mockNavigateToSocialLeaderboard = jest.fn(); + +jest.mock('../../../../../util/analytics/actionButtonTracking', () => { + const actual = jest.requireActual( + '../../../../../util/analytics/actionButtonTracking', + ); + return { + ...actual, + trackActionButtonClick: (...args: unknown[]) => + mockTrackActionButtonClick(...args), + }; +}); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn(), + })), + }), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('../../../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ + goToSell: mockGoToSell, + goToBuy: jest.fn(), + }), +})); + +jest.mock('../../../../UI/Ramp/hooks/useDepositEnabled', () => ({ + __esModule: true, + default: () => ({ isDepositEnabled: true }), +})); + +jest.mock('../../../../UI/Bridge/hooks/useSwapBridgeNavigation', () => ({ + SwapBridgeNavigationLocation: { MainView: 'MainView' }, + useSwapBridgeNavigation: () => ({ + goToSwaps: mockGoToSwaps, + }), +})); + +jest.mock( + '../../../SocialLeaderboard/Onboarding/socialLeaderboardOnboardingNavigation', + () => ({ + navigateToSocialLeaderboard: (...args: unknown[]) => + mockNavigateToSocialLeaderboard(...args), + }), +); + +const mockUseSelector = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (selector: unknown) => mockUseSelector(selector), +})); + +import HomepageActionButtonsGrid from './HomepageActionButtonsGrid'; + +const mockSelectorState = () => { + mockUseSelector.mockImplementation((selector: unknown) => { + if (selector === selectCanSignTransactions) { + return true; + } + if (selector === selectIsSwapsEnabled) { + return true; + } + if (selector === selectBatchSellEnabled) { + return true; + } + if (selector === selectSocialLeaderboardEnabled) { + return true; + } + if (selector === selectPerpsEnabledFlag) { + return true; + } + if (selector === selectIsFirstTimePerpsUser) { + return false; + } + if (selector === selectPredictEnabledFlag) { + return true; + } + // selectIsSwapsEnabled is often called as (state) => select...(state) + if (typeof selector === 'function') { + try { + return selector({}); + } catch { + return true; + } + } + return true; + }); +}; + +describe('HomepageActionButtonsGrid', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectorState(); + }); + + it('renders all eight buttons for row1Top order', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(HomepageActionButtonsGridTestIds.CONTAINER), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.BUY_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.SWAP_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.SEND_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.PERPS_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.PREDICT_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.BATCH_SWAP_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageActionButtonsGridTestIds.TRADERS_BUTTON), + ).toBeOnTheScreen(); + }); + + it('tracks sell tap with second position for row1Top', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON)); + + expect(mockTrackActionButtonClick).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + action_name: ActionButtonType.SELL, + action_position: ActionPosition.SECOND_POSITION, + location: ActionLocation.HOME, + }), + ); + expect(mockGoToSell).toHaveBeenCalledTimes(1); + }); + + it('tracks sell tap with sixth position for row2Top', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON)); + + expect(mockTrackActionButtonClick).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + action_name: ActionButtonType.SELL, + action_position: ActionPosition.SIXTH_POSITION, + location: ActionLocation.HOME, + }), + ); + }); + + it('calls onSend when send is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(HomepageActionButtonsGridTestIds.SEND_BUTTON)); + + expect(mockOnSend).toHaveBeenCalledTimes(1); + expect(mockTrackActionButtonClick).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + action_name: ActionButtonType.SEND, + action_position: ActionPosition.FOURTH_POSITION, + }), + ); + }); +}); diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.test.ts b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.test.ts new file mode 100644 index 00000000000..94f43c40c61 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/constants.test.ts @@ -0,0 +1,36 @@ +import { + ACTION_POSITION_BY_INDEX, + getOrderedButtonRows, + ROW1_BUTTON_IDS, + ROW2_BUTTON_IDS, +} from './constants'; +import { ActionPosition } from '../../../../../util/analytics/actionButtonTracking'; + +describe('HomepageActionButtonsGrid constants', () => { + it('returns Row 1 then Row 2 for row1Top', () => { + expect(getOrderedButtonRows('row1Top')).toEqual([ + ROW1_BUTTON_IDS, + ROW2_BUTTON_IDS, + ]); + }); + + it('returns Row 2 then Row 1 for row2Top', () => { + expect(getOrderedButtonRows('row2Top')).toEqual([ + ROW2_BUTTON_IDS, + ROW1_BUTTON_IDS, + ]); + }); + + it('maps eight on-screen slots to ActionPosition 0–7', () => { + expect(ACTION_POSITION_BY_INDEX).toEqual([ + ActionPosition.FIRST_POSITION, + ActionPosition.SECOND_POSITION, + ActionPosition.THIRD_POSITION, + ActionPosition.FOURTH_POSITION, + ActionPosition.FIFTH_POSITION, + ActionPosition.SIXTH_POSITION, + ActionPosition.SEVENTH_POSITION, + ActionPosition.EIGHTH_POSITION, + ]); + }); +}); diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 23076014bf7..cdfcf67e296 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -109,6 +109,7 @@ jest.mock('../../UI/NetworkConnectionBanner', () => () => null); // Control discovery tabs AB test variant per test (default control so existing tests are unaffected) let mockDiscoveryTabsVariantName = 'control'; let mockDiscoveryPillsVariantName = 'control'; +let mockActionButtonsGridVariantName = 'control'; jest.mock('../../../hooks', () => ({ ...jest.requireActual('../../../hooks'), useABTest: jest.fn((flagKey: string) => { @@ -127,6 +128,28 @@ jest.mock('../../../hooks', () => ({ }; } + if (flagKey === 'homeTMCU1103AbtestActionButtonsGrid') { + if (mockActionButtonsGridVariantName === 'row1Top') { + return { + variantName: 'row1Top', + variant: { layout: 'eightCircular', rowOrder: 'row1Top' }, + isActive: true, + }; + } + if (mockActionButtonsGridVariantName === 'row2Top') { + return { + variantName: 'row2Top', + variant: { layout: 'eightCircular', rowOrder: 'row2Top' }, + isActive: true, + }; + } + return { + variantName: 'control', + variant: { layout: 'fourSquare' }, + isActive: true, + }; + } + return { variantName: mockDiscoveryTabsVariantName, variant: { @@ -138,6 +161,7 @@ jest.mock('../../../hooks', () => ({ })); const mockHomepageDiscoveryPills = jest.fn(); +const mockHomepageActionButtonsGrid = jest.fn(); jest.mock('../Homepage/components/HomepageDiscoveryPills', () => { const React = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -150,6 +174,21 @@ jest.mock('../Homepage/components/HomepageDiscoveryPills', () => { }, }; }); +jest.mock('../Homepage/components/HomepageActionButtonsGrid', () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + HomepageActionButtonsGrid: (props: { + rowOrder: string; + onSend: () => void; + }) => { + mockHomepageActionButtonsGrid(props); + return React.createElement(View, { + testID: 'homepage-action-buttons-grid-mock', + }); + }, + }; +}); // Track HomepageDiscoveryTabs renders const mockHomepageDiscoveryTabs = jest.fn(); @@ -1788,6 +1827,7 @@ describe('HomepageDiscoveryPills AB test', () => { afterEach(() => { mockDiscoveryTabsVariantName = 'control'; mockDiscoveryPillsVariantName = 'control'; + mockActionButtonsGridVariantName = 'control'; jest.clearAllMocks(); }); @@ -1837,6 +1877,91 @@ describe('HomepageDiscoveryPills AB test', () => { }); }); +describe('HomepageActionButtonsGrid AB test', () => { + let mockNavigation: NavigationProp; + + beforeEach(() => { + jest.clearAllMocks(); + mockDiscoveryTabsVariantName = 'control'; + mockDiscoveryPillsVariantName = 'control'; + mockActionButtonsGridVariantName = 'control'; + mockHomepageActionButtonsGrid.mockClear(); + + mockNavigation = { + navigate: mockNavigate, + setOptions: mockSetOptions, + addListener: jest.fn(() => jest.fn()), + isFocused: jest.fn(() => false), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'stack' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'tab' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => undefined), + })), + })), + } as unknown as NavigationProp; + + jest + .mocked(useSelector) + .mockImplementation((callback: (state: unknown) => unknown) => + callback(mockInitialState), + ); + }); + + afterEach(() => { + mockActionButtonsGridVariantName = 'control'; + jest.clearAllMocks(); + }); + + it('renders action buttons grid when row1Top variant is active', () => { + mockActionButtonsGridVariantName = 'row1Top'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageActionButtonsGrid).toHaveBeenCalledWith( + expect.objectContaining({ rowOrder: 'row1Top' }), + ); + }); + + it('renders action buttons grid when row2Top variant is active', () => { + mockActionButtonsGridVariantName = 'row2Top'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageActionButtonsGrid).toHaveBeenCalledWith( + expect.objectContaining({ rowOrder: 'row2Top' }), + ); + }); + + it('does not render action buttons grid when control variant is active', () => { + mockActionButtonsGridVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageActionButtonsGrid).not.toHaveBeenCalled(); + }); +}); + describe('useHomeDeepLinkEffects', () => { beforeEach(() => { jest.clearAllMocks(); From 40780c43a0cd151d98f563053e5cf6908fb7286c Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 16:03:50 -0400 Subject: [PATCH 06/12] fix(home): route TMCU-1103 Buy button through goToBuy Use ramp goToBuy instead of FundActionMenu so Buy mirrors Sell as a direct ramp entrypoint from the action buttons grid. --- .../HomepageActionButtonsGrid.test.tsx | 8 ++------ .../buttons/BuyButton.tsx | 17 ++++------------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx index c1fe01a3200..9547830cc0a 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx @@ -15,6 +15,7 @@ import { import { HomepageActionButtonsGridTestIds } from './HomepageActionButtonsGrid.testIds'; const mockTrackActionButtonClick = jest.fn(); +const mockGoToBuy = jest.fn(); const mockGoToSell = jest.fn(); const mockGoToSwaps = jest.fn(); const mockNavigate = jest.fn(); @@ -51,16 +52,11 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../../UI/Ramp/hooks/useRampNavigation', () => ({ useRampNavigation: () => ({ + goToBuy: mockGoToBuy, goToSell: mockGoToSell, - goToBuy: jest.fn(), }), })); -jest.mock('../../../../UI/Ramp/hooks/useDepositEnabled', () => ({ - __esModule: true, - default: () => ({ isDepositEnabled: true }), -})); - jest.mock('../../../../UI/Bridge/hooks/useSwapBridgeNavigation', () => ({ SwapBridgeNavigationLocation: { MainView: 'MainView' }, useSwapBridgeNavigation: () => ({ diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx index 86438eed47a..c6b6bf59db4 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx @@ -1,10 +1,8 @@ import React, { useCallback } from 'react'; -import { useNavigation } from '@react-navigation/native'; import { IconName } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; -import Routes from '../../../../../../constants/navigation/Routes'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; -import useDepositEnabled from '../../../../../UI/Ramp/hooks/useDepositEnabled'; +import { useRampNavigation } from '../../../../../UI/Ramp/hooks/useRampNavigation'; import { ActionButtonType, ActionLocation, @@ -15,10 +13,8 @@ import { HomepageActionButtonsGridTestIds } from '../HomepageActionButtonsGrid.t import type { HomepageActionButtonSlotProps } from '../types'; const BuyButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { - const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); - const { isDepositEnabled } = useDepositEnabled(); - const isBuyingAvailable = isDepositEnabled || true; + const { goToBuy } = useRampNavigation(); const label = strings('homepage.action_buttons.buy'); const handlePress = useCallback(() => { @@ -28,17 +24,12 @@ const BuyButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { button_label: label, location: ActionLocation.HOME, }); - - navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.MODAL.FUND_ACTION_MENU, - params: {}, - }); - }, [actionPosition, createEventBuilder, label, navigation, trackEvent]); + goToBuy(); + }, [actionPosition, createEventBuilder, goToBuy, label, trackEvent]); return ( Date: Wed, 15 Jul 2026 16:10:20 -0400 Subject: [PATCH 07/12] fix(home): add muted border to action grid icon pills Match Figma Token pill styling with a subtle border-muted ring on the circular HomepageActionButton container. --- .../HomepageActionButtonsGrid/HomepageActionButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx index 1e9e018cc79..576dc22d187 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx @@ -51,7 +51,7 @@ const HomepageActionButton = ({ Date: Wed, 15 Jul 2026 16:17:13 -0400 Subject: [PATCH 08/12] feat(home): add optional two-line labels to action grid buttons Introduce allowTwoLineLabel on HomepageActionButton and the grid, defaulting to false so labels stay single-line until toggled on. --- .../HomepageActionButton.tsx | 13 ++++++++++-- .../HomepageActionButtonsGrid.tsx | 21 +++++++++++-------- .../buttons/BatchSwapButton.tsx | 6 +++++- .../buttons/BuyButton.tsx | 6 +++++- .../buttons/PerpsButton.tsx | 6 +++++- .../buttons/PredictButton.tsx | 6 +++++- .../buttons/SellButton.tsx | 6 +++++- .../buttons/SendButton.tsx | 7 ++++++- .../buttons/SwapButton.tsx | 6 +++++- .../buttons/TradersButton.tsx | 6 +++++- .../HomepageActionButtonsGrid/types.ts | 7 +++++++ 11 files changed, 71 insertions(+), 19 deletions(-) diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx index 576dc22d187..d671e00c6d5 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx @@ -20,6 +20,8 @@ export interface HomepageActionButtonProps { label: string; onPress: () => void; isDisabled?: boolean; + /** When true, label wraps to 2 lines before tail ellipsis. */ + allowTwoLineLabel?: boolean; testID?: string; } @@ -32,9 +34,11 @@ const HomepageActionButton = ({ label, onPress, isDisabled = false, + allowTwoLineLabel = false, testID, }: HomepageActionButtonProps) => { const tw = useTailwind(); + const labelNumberOfLines = allowTwoLineLabel ? 2 : 1; return ( {label} diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx index 2a4bf572d40..0047e50e6c9 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.tsx @@ -24,24 +24,26 @@ const renderButton = ( id: HomepageActionButtonId, actionPosition: (typeof ACTION_POSITION_BY_INDEX)[number], onSend: () => void, + allowTwoLineLabel: boolean, ) => { + const slotProps = { actionPosition, allowTwoLineLabel }; switch (id) { case 'buy': - return ; + return ; case 'sell': - return ; + return ; case 'swap': - return ; + return ; case 'send': - return ; + return ; case 'perps': - return ; + return ; case 'predict': - return ; + return ; case 'batch_swap': - return ; + return ; case 'traders': - return ; + return ; default: { const exhaustive: never = id; return exhaustive; @@ -56,6 +58,7 @@ const renderButton = ( const HomepageActionButtonsGrid = ({ rowOrder, onSend, + allowTwoLineLabel = false, }: HomepageActionButtonsGridProps) => { const rows = getOrderedButtonRows(rowOrder); @@ -76,7 +79,7 @@ const HomepageActionButtonsGrid = ({ ACTION_POSITION_BY_INDEX[rowIndex * 4 + colIndex]; return ( - {renderButton(id, actionPosition, onSend)} + {renderButton(id, actionPosition, onSend, allowTwoLineLabel)} ); })} diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx index 1d5f595f518..e31e43c7b2c 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BatchSwapButton.tsx @@ -23,7 +23,10 @@ import type { HomepageActionButtonSlotProps } from '../types'; * Opens Batch Sell (trade-menu flow). Label matches Figma "Batch Swap". * `batchSellLocation` left as Unknown until analytics defines a home entrypoint. */ -const BatchSwapButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { +const BatchSwapButton = ({ + actionPosition, + allowTwoLineLabel, +}: HomepageActionButtonSlotProps) => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const isBatchSellEnabled = useSelector(selectBatchSellEnabled); @@ -52,6 +55,7 @@ const BatchSwapButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { return ( { +const BuyButton = ({ + actionPosition, + allowTwoLineLabel, +}: HomepageActionButtonSlotProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const { goToBuy } = useRampNavigation(); const label = strings('homepage.action_buttons.buy'); @@ -29,6 +32,7 @@ const BuyButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { return ( { +const PerpsButton = ({ + actionPosition, + allowTwoLineLabel, +}: HomepageActionButtonSlotProps) => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); @@ -53,6 +56,7 @@ const PerpsButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { return ( { +const PredictButton = ({ + actionPosition, + allowTwoLineLabel, +}: HomepageActionButtonSlotProps) => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const isPredictEnabled = useSelector(selectPredictEnabledFlag); @@ -43,6 +46,7 @@ const PredictButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { return ( { +const SellButton = ({ + actionPosition, + allowTwoLineLabel, +}: HomepageActionButtonSlotProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const { goToSell } = useRampNavigation(); const canSignTransactions = useSelector(selectCanSignTransactions); @@ -32,6 +35,7 @@ const SellButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { return ( void; } -const SendButton = ({ actionPosition, onSend }: SendButtonProps) => { +const SendButton = ({ + actionPosition, + allowTwoLineLabel, + onSend, +}: SendButtonProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const canSignTransactions = useSelector(selectCanSignTransactions); const label = strings('homepage.action_buttons.send'); @@ -34,6 +38,7 @@ const SendButton = ({ actionPosition, onSend }: SendButtonProps) => { return ( { +const SwapButton = ({ + actionPosition, + allowTwoLineLabel, +}: HomepageActionButtonSlotProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const isSwapsEnabled = useSelector((state: RootState) => selectIsSwapsEnabled(state), @@ -44,6 +47,7 @@ const SwapButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { return ( { +const TradersButton = ({ + actionPosition, + allowTwoLineLabel, +}: HomepageActionButtonSlotProps) => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const isSocialLeaderboardEnabled = useSelector( @@ -40,6 +43,7 @@ const TradersButton = ({ actionPosition }: HomepageActionButtonSlotProps) => { return ( void; + /** + * When true, action labels wrap to 2 lines before ellipsizing (e.g. "Batch Swap"). + * @default false + */ + allowTwoLineLabel?: boolean; } export type { HomepageActionButtonsGridRowOrder }; From 94b014074dd188dc95930dab857d1873f9c04765 Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 16:20:08 -0400 Subject: [PATCH 09/12] fix(home): use People icon for Traders action button Match Figma by showing multiple people instead of a single user icon. --- .../HomepageActionButtonsGrid/buttons/TradersButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx index 369860fe2e6..a0d58829004 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/TradersButton.tsx @@ -44,7 +44,7 @@ const TradersButton = ({ return ( Date: Wed, 15 Jul 2026 16:48:11 -0400 Subject: [PATCH 10/12] fix(home): use flex-1 for action grid buttons Replace fixed 79px width so four columns share the row evenly and avoid clipping on narrow screens. --- .../HomepageActionButtonsGrid/HomepageActionButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx index d671e00c6d5..061212f4a8d 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx @@ -47,7 +47,7 @@ const HomepageActionButton = ({ disabled={isDisabled} onPress={isDisabled ? undefined : onPress} style={tw.style( - 'w-[79px] items-center', + 'min-w-0 flex-1 items-center', isDisabled ? 'opacity-50' : 'opacity-100', )} testID={testID} From a82b411092f1f61a90d1408ea008123af14f0202 Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 16:59:24 -0400 Subject: [PATCH 11/12] feat(home): add press feedback to action grid buttons Scale down on press and switch the pill from bg-muted to bg-muted-pressed to match MainActionButton interaction cues. --- .../HomepageActionButton.tsx | 90 +++++++++++-------- 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx index 061212f4a8d..c52a0e2a732 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButton.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable } from 'react-native'; +import { Animated, Pressable } from 'react-native'; import { Box, BoxAlignItems, @@ -14,6 +14,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useAnimatedPressable } from '../../../../../component-library/hooks'; export interface HomepageActionButtonProps { iconName: IconName; @@ -28,6 +29,7 @@ export interface HomepageActionButtonProps { /** * Presentation-only circular action button (icon pill + label). * Navigation and analytics live in each `buttons/*Button.tsx` caller. + * Press feedback: slight scale-down + muted → muted-pressed pill background. */ const HomepageActionButton = ({ iconName, @@ -39,45 +41,61 @@ const HomepageActionButton = ({ }: HomepageActionButtonProps) => { const tw = useTailwind(); const labelNumberOfLines = allowTwoLineLabel ? 2 : 1; + const { scaleAnim, handlePressIn, handlePressOut } = useAnimatedPressable(); return ( - - - - - - {label} - - + {({ pressed }) => ( + <> + + + + + {label} + + + )} + + ); }; From baf216a886d3cbcc7962c73fb511111c1811368e Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Wed, 15 Jul 2026 17:21:59 -0400 Subject: [PATCH 12/12] test(home): cover action grid analytics and grey-out Disable Buy when the account cannot sign, matching FundActionMenu, and expand grid unit tests for all eight button clicks and flag gates. --- .../HomepageActionButtonsGrid.test.tsx | 259 +++++++++++++----- .../buttons/BuyButton.tsx | 4 + 2 files changed, 201 insertions(+), 62 deletions(-) diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx index 9547830cc0a..bd2e495e2ee 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/HomepageActionButtonsGrid.test.tsx @@ -80,41 +80,100 @@ jest.mock('react-redux', () => ({ import HomepageActionButtonsGrid from './HomepageActionButtonsGrid'; -const mockSelectorState = () => { +interface SelectorOverrides { + canSignTransactions?: boolean; + isSwapsEnabled?: boolean; + isBatchSellEnabled?: boolean; + isSocialLeaderboardEnabled?: boolean; + isPerpsEnabled?: boolean; + isPredictEnabled?: boolean; + isFirstTimePerpsUser?: boolean; +} + +const mockSelectorState = (overrides: SelectorOverrides = {}) => { + const { + canSignTransactions = true, + isSwapsEnabled = true, + isBatchSellEnabled = true, + isSocialLeaderboardEnabled = true, + isPerpsEnabled = true, + isPredictEnabled = true, + isFirstTimePerpsUser = false, + } = overrides; + mockUseSelector.mockImplementation((selector: unknown) => { if (selector === selectCanSignTransactions) { - return true; + return canSignTransactions; } if (selector === selectIsSwapsEnabled) { - return true; + return isSwapsEnabled; } if (selector === selectBatchSellEnabled) { - return true; + return isBatchSellEnabled; } if (selector === selectSocialLeaderboardEnabled) { - return true; + return isSocialLeaderboardEnabled; } if (selector === selectPerpsEnabledFlag) { - return true; + return isPerpsEnabled; } if (selector === selectIsFirstTimePerpsUser) { - return false; + return isFirstTimePerpsUser; } if (selector === selectPredictEnabledFlag) { - return true; + return isPredictEnabled; } - // selectIsSwapsEnabled is often called as (state) => select...(state) + // Swap / Batch Swap wrap selectIsSwapsEnabled in an arrow selector. if (typeof selector === 'function') { - try { - return selector({}); - } catch { - return true; - } + return isSwapsEnabled; } return true; }); }; +const ROW1_TOP_BUTTON_CASES = [ + { + testId: HomepageActionButtonsGridTestIds.BUY_BUTTON, + actionName: ActionButtonType.BUY, + actionPosition: ActionPosition.FIRST_POSITION, + }, + { + testId: HomepageActionButtonsGridTestIds.SELL_BUTTON, + actionName: ActionButtonType.SELL, + actionPosition: ActionPosition.SECOND_POSITION, + }, + { + testId: HomepageActionButtonsGridTestIds.SWAP_BUTTON, + actionName: ActionButtonType.SWAP, + actionPosition: ActionPosition.THIRD_POSITION, + }, + { + testId: HomepageActionButtonsGridTestIds.SEND_BUTTON, + actionName: ActionButtonType.SEND, + actionPosition: ActionPosition.FOURTH_POSITION, + }, + { + testId: HomepageActionButtonsGridTestIds.PERPS_BUTTON, + actionName: ActionButtonType.PERPS, + actionPosition: ActionPosition.FIFTH_POSITION, + }, + { + testId: HomepageActionButtonsGridTestIds.PREDICT_BUTTON, + actionName: ActionButtonType.PREDICT, + actionPosition: ActionPosition.SIXTH_POSITION, + }, + { + testId: HomepageActionButtonsGridTestIds.BATCH_SWAP_BUTTON, + actionName: ActionButtonType.BATCH_SWAP, + actionPosition: ActionPosition.SEVENTH_POSITION, + }, + { + testId: HomepageActionButtonsGridTestIds.TRADERS_BUTTON, + actionName: ActionButtonType.TRADERS, + actionPosition: ActionPosition.EIGHTH_POSITION, + }, +] as const; + describe('HomepageActionButtonsGrid', () => { beforeEach(() => { jest.clearAllMocks(); @@ -129,50 +188,31 @@ describe('HomepageActionButtonsGrid', () => { expect( getByTestId(HomepageActionButtonsGridTestIds.CONTAINER), ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.BUY_BUTTON), - ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON), - ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.SWAP_BUTTON), - ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.SEND_BUTTON), - ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.PERPS_BUTTON), - ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.PREDICT_BUTTON), - ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.BATCH_SWAP_BUTTON), - ).toBeOnTheScreen(); - expect( - getByTestId(HomepageActionButtonsGridTestIds.TRADERS_BUTTON), - ).toBeOnTheScreen(); + ROW1_TOP_BUTTON_CASES.forEach(({ testId }) => { + expect(getByTestId(testId)).toBeOnTheScreen(); + }); }); - it('tracks sell tap with second position for row1Top', () => { - const { getByTestId } = render( - , - ); + it.each(ROW1_TOP_BUTTON_CASES)( + 'tracks $actionName tap with position $actionPosition for row1Top', + ({ testId, actionName, actionPosition }) => { + const { getByTestId } = render( + , + ); - fireEvent.press(getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON)); + fireEvent.press(getByTestId(testId)); - expect(mockTrackActionButtonClick).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - action_name: ActionButtonType.SELL, - action_position: ActionPosition.SECOND_POSITION, - location: ActionLocation.HOME, - }), - ); - expect(mockGoToSell).toHaveBeenCalledTimes(1); - }); + expect(mockTrackActionButtonClick).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + action_name: actionName, + action_position: actionPosition, + location: ActionLocation.HOME, + }), + ); + }, + ); it('tracks sell tap with sixth position for row2Top', () => { const { getByTestId } = render( @@ -200,13 +240,108 @@ describe('HomepageActionButtonsGrid', () => { fireEvent.press(getByTestId(HomepageActionButtonsGridTestIds.SEND_BUTTON)); expect(mockOnSend).toHaveBeenCalledTimes(1); - expect(mockTrackActionButtonClick).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - action_name: ActionButtonType.SEND, - action_position: ActionPosition.FOURTH_POSITION, - }), - ); + }); + + describe('grey-out when product flags are off', () => { + it('disables buy and sell when account cannot sign', () => { + mockSelectorState({ canSignTransactions: false }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(HomepageActionButtonsGridTestIds.BUY_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + expect( + getByTestId(HomepageActionButtonsGridTestIds.SELL_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + expect( + getByTestId(HomepageActionButtonsGridTestIds.SEND_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('disables swap when swaps are disabled', () => { + mockSelectorState({ isSwapsEnabled: false }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(HomepageActionButtonsGridTestIds.SWAP_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('disables perps when perps flag is off', () => { + mockSelectorState({ isPerpsEnabled: false }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(HomepageActionButtonsGridTestIds.PERPS_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('disables predict when predict flag is off', () => { + mockSelectorState({ isPredictEnabled: false }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(HomepageActionButtonsGridTestIds.PREDICT_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('disables batch swap when batch sell flag is off', () => { + mockSelectorState({ isBatchSellEnabled: false }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(HomepageActionButtonsGridTestIds.BATCH_SWAP_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('disables traders when social leaderboard flag is off', () => { + mockSelectorState({ isSocialLeaderboardEnabled: false }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(HomepageActionButtonsGridTestIds.TRADERS_BUTTON).props + .accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('does not track or navigate when a disabled button is pressed', () => { + mockSelectorState({ isPerpsEnabled: false }); + + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId(HomepageActionButtonsGridTestIds.PERPS_BUTTON), + ); + + expect(mockTrackActionButtonClick).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx index 1c04838312a..9674ddb182d 100644 --- a/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx +++ b/app/components/Views/Homepage/components/HomepageActionButtonsGrid/buttons/BuyButton.tsx @@ -1,6 +1,8 @@ import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; import { IconName } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; +import { selectCanSignTransactions } from '../../../../../../selectors/accountsController'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { useRampNavigation } from '../../../../../UI/Ramp/hooks/useRampNavigation'; import { @@ -18,6 +20,7 @@ const BuyButton = ({ }: HomepageActionButtonSlotProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const { goToBuy } = useRampNavigation(); + const canSignTransactions = useSelector(selectCanSignTransactions); const label = strings('homepage.action_buttons.buy'); const handlePress = useCallback(() => { @@ -34,6 +37,7 @@ const BuyButton = ({