diff --git a/app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx b/app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx index 450de2d9439..5f70901f974 100644 --- a/app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx +++ b/app/components/UI/Trending/Views/RWATokensFullView/RWATokensFullView.tsx @@ -1,4 +1,6 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import TrendingQuickBuy from '../../components/TrendingQuickBuy/TrendingQuickBuy'; import { strings } from '../../../../../../locales/i18n'; import { PriceChangeOption, @@ -8,8 +10,22 @@ import { useRwaTokens } from '../../hooks/useRwaTokens/useRwaTokens'; import { useTokenListFilters } from '../../hooks/useTokenListFilters/useTokenListFilters'; import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListPageLayout'; import { RWA_NETWORKS_LIST } from '../../utils/trendingNetworksList'; +import { useABTest } from '../../../../../hooks/useABTest'; +import { + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, +} from '../../../../Views/TrendingView/search/abTestConfig'; const RWATokensFullView = () => { + const [quickTradeToken, setQuickTradeToken] = useState( + null, + ); + const { variant: quickBuyVariant } = useABTest( + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, + ); const filters = useTokenListFilters({ timeOption: TimeOption.TwentyFourHours, }); @@ -53,6 +69,16 @@ const RWATokensFullView = () => { allowedNetworks={RWA_NETWORKS_LIST} onLoadMore={loadMore} isLoadingMore={isLoadingMore} + onQuickTrade={ + quickBuyVariant.showQuickTradeButton ? setQuickTradeToken : undefined + } + quickBuyNode={ + setQuickTradeToken(null)} + source="explore_stocks" + /> + } /> ); }; diff --git a/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx index a3afcab39c4..5f167e47a3c 100644 --- a/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; +import TrendingQuickBuy from '../../components/TrendingQuickBuy/TrendingQuickBuy'; import { View, RefreshControl } from 'react-native'; import { useRoute, type RouteProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -28,6 +29,12 @@ import { FilterButton } from '../../components/FilterBar/FilterBar'; import TokenListPageLayout from '../../components/TokenListPageLayout/TokenListPageLayout'; import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; import type { Theme } from '../../../../../util/theme/models'; +import { useABTest } from '../../../../../hooks/useABTest'; +import { + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, +} from '../../../../Views/TrendingView/search/abTestConfig'; export interface TrendingTokensFullViewParams { initialTimeOption?: TimeOption; @@ -43,6 +50,7 @@ export interface TrendingTokensDataProps { theme: Theme; onLoadMore?: () => void; isLoadingMore?: boolean; + onQuickTrade?: (token: TrendingAsset) => void; search: { searchResults: TrendingAsset[]; @@ -62,6 +70,7 @@ export const TrendingTokensData = (props: TrendingTokensDataProps) => { theme, onLoadMore, isLoadingMore, + onQuickTrade, } = props; const tw = useTailwind(); @@ -95,6 +104,7 @@ export const TrendingTokensData = (props: TrendingTokensDataProps) => { filterContext={filterContext} onLoadMore={onLoadMore} isLoadingMore={isLoadingMore} + onQuickTrade={onQuickTrade} refreshControl={ { const TrendingTokensFullView = () => { const sessionManager = TrendingFeedSessionManager.getInstance(); + const [quickTradeToken, setQuickTradeToken] = useState( + null, + ); + const { variant: quickBuyVariant } = useABTest( + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, + ); const { params } = useRoute< RouteProp<{ TrendingTokensFullView: TrendingTokensFullViewParams }> @@ -259,6 +277,16 @@ const TrendingTokensFullView = () => { selectedTime={filters.selectedTimeOption} /> } + onQuickTrade={ + quickBuyVariant.showQuickTradeButton ? setQuickTradeToken : undefined + } + quickBuyNode={ + setQuickTradeToken(null)} + source="explore_trending" + /> + } /> ); }; diff --git a/app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx b/app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx index 0287b2218fb..fec9aee4cef 100644 --- a/app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx +++ b/app/components/UI/Trending/components/TokenListPageLayout/TokenListPageLayout.tsx @@ -42,6 +42,10 @@ export interface TokenListPageLayoutProps { onLoadMore?: () => void; /** Whether a pagination request is in flight. */ isLoadingMore?: boolean; + /** When provided, shows a Quick Trade flash button on each token row. */ + onQuickTrade?: (token: TrendingAsset) => void; + /** Overlay node (e.g. TrendingQuickBuy sheet) rendered outside the scroll area. */ + quickBuyNode?: React.ReactNode; } /** @@ -65,6 +69,8 @@ const TokenListPageLayout: React.FC = ({ extraBottomSheets, onLoadMore, isLoadingMore, + onQuickTrade, + quickBuyNode, }) => { const tw = useTailwind(); const theme = useAppThemeFromContext(); @@ -112,6 +118,7 @@ const TokenListPageLayout: React.FC = ({ theme={theme} onLoadMore={onLoadMore} isLoadingMore={isLoadingMore} + onQuickTrade={onQuickTrade} /> = ({ sortDirection={filters.priceChangeSortDirection} /> {extraBottomSheets} + {quickBuyNode} ); }; diff --git a/app/components/UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy.test.tsx b/app/components/UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy.test.tsx new file mode 100644 index 00000000000..f4c7525ae89 --- /dev/null +++ b/app/components/UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy.test.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import TrendingQuickBuy from './TrendingQuickBuy'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { SocialLeaderboardEventProperties } from '../../../../Views/SocialLeaderboard/analytics'; + +const mockTrack = jest.fn(); +jest.mock( + '../../../../Views/SocialLeaderboard/analytics/useSocialLeaderboardAnalytics', + () => ({ + useSocialLeaderboardAnalytics: () => ({ track: mockTrack }), + }), +); + +const mockQuickBuyRoot = jest.fn(); +jest.mock( + '../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy', + () => ({ + QuickBuy: { + Root: (props: Record) => { + mockQuickBuyRoot(props); + return null; + }, + }, + }), +); + +jest.mock( + '../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features', + () => ({ + TOP_TRADERS_QUICK_BUY_FEATURES: { tradeModes: ['buy', 'sell'] }, + }), +); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const makeToken = (overrides: Partial = {}): TrendingAsset => ({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + price: '1.00', + marketCap: 75_000_000_000, + aggregatedUsdVolume: 900_000_000, + ...overrides, +}); + +describe('TrendingQuickBuy', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders QuickBuy.Root with isVisible=false when token is null', () => { + render(); + + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ isVisible: false }), + ); + }); + + it('renders QuickBuy.Root with isVisible=true when token is provided', () => { + render(); + + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ isVisible: true }), + ); + }); + + it('maps an ERC-20 TrendingAsset to the correct QuickBuyTarget', () => { + const token = makeToken({ + assetId: 'eip155:1/erc20:0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + name: 'Synthetix', + }); + + render(); + + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + chain: 'eip155:1', + tokenAddress: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + tokenSymbol: 'SNX', + tokenName: 'Synthetix', + }, + }), + ); + }); + + it('maps a native (slip44) TrendingAsset to the zero address', () => { + const token = makeToken({ + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + }); + + render(); + + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + tokenAddress: '0x0000000000000000000000000000000000000000', + tokenSymbol: 'ETH', + }), + }), + ); + }); + + it('passes analyticsContext with source=explore_search to QuickBuy.Root', () => { + render(); + + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ + analyticsContext: expect.objectContaining({ source: 'explore_search' }), + }), + ); + }); + + it('fires SOCIAL_QUICK_BUY_SHEET_VIEWED when token transitions from null to non-null', () => { + const token = makeToken(); + render(); + + expect(mockTrack).toHaveBeenCalledTimes(1); + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_QUICK_BUY_SHEET_VIEWED, + expect.objectContaining({ + [SocialLeaderboardEventProperties.SOURCE]: 'explore_search', + [SocialLeaderboardEventProperties.CAIP19]: token.assetId, + [SocialLeaderboardEventProperties.MARKET_CAP]: token.marketCap, + }), + ); + }); + + it('does not fire SOCIAL_QUICK_BUY_SHEET_VIEWED when token is null', () => { + render(); + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('does not re-fire the event while the same token stays visible', () => { + const token = makeToken(); + const { rerender } = render( + , + ); + + // Simulate a re-render with the same token (e.g. parent re-renders) + rerender(); + + expect(mockTrack).toHaveBeenCalledTimes(1); + }); + + it('fires the event again when a new token is opened after closing', () => { + const tokenA = makeToken({ + assetId: 'eip155:1/erc20:0xaaa', + symbol: 'AAA', + name: 'Token A', + }); + const tokenB = makeToken({ + assetId: 'eip155:1/erc20:0xbbb', + symbol: 'BBB', + name: 'Token B', + }); + + const { rerender } = render( + , + ); + // Close sheet + rerender(); + // Open with a new token + rerender(); + + expect(mockTrack).toHaveBeenCalledTimes(2); + expect(mockTrack).toHaveBeenNthCalledWith( + 2, + MetaMetricsEvents.SOCIAL_QUICK_BUY_SHEET_VIEWED, + expect.objectContaining({ + [SocialLeaderboardEventProperties.CAIP19]: tokenB.assetId, + }), + ); + }); + + it('forwards onClose to QuickBuy.Root', () => { + const onClose = jest.fn(); + render(); + + expect(mockQuickBuyRoot).toHaveBeenCalledWith( + expect.objectContaining({ onClose }), + ); + }); +}); diff --git a/app/components/UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy.tsx b/app/components/UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy.tsx new file mode 100644 index 00000000000..d0a0094e9f4 --- /dev/null +++ b/app/components/UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy.tsx @@ -0,0 +1,69 @@ +import type { TrendingAsset } from '@metamask/assets-controllers'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { QuickBuy } from '../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy'; +import { TOP_TRADERS_QUICK_BUY_FEATURES } from '../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features'; +import type { QuickBuyTarget } from '../../../../Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types'; +import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../../../constants/bridge'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + useSocialLeaderboardAnalytics, + SocialLeaderboardEventProperties, + type QuickBuySheetSource, +} from '../../../../Views/SocialLeaderboard/analytics'; + +export interface TrendingQuickBuyProps { + token: TrendingAsset | null; + onClose: () => void; + /** Analytics surface identifier. Defaults to `'explore_search'`. */ + source?: QuickBuySheetSource; +} + +const TrendingQuickBuy: React.FC = ({ + token, + onClose, + source = 'explore_search', +}) => { + const { track } = useSocialLeaderboardAnalytics(); + const prevTokenRef = useRef(null); + + const target = useMemo((): QuickBuyTarget | null => { + if (!token) return null; + const [caipChainId, assetIdentifier] = token.assetId.split('/'); + if (!caipChainId) return null; + const isNative = assetIdentifier?.startsWith('slip44:'); + const tokenAddress = isNative + ? NATIVE_SWAPS_TOKEN_ADDRESS + : (assetIdentifier?.split(':')[1] ?? ''); + return { + chain: caipChainId as QuickBuyTarget['chain'], + tokenAddress, + tokenSymbol: token.symbol ?? '', + tokenName: token.name ?? '', + }; + }, [token]); + + useEffect(() => { + if (token && !prevTokenRef.current) { + track(MetaMetricsEvents.SOCIAL_QUICK_BUY_SHEET_VIEWED, { + [SocialLeaderboardEventProperties.SOURCE]: source, + [SocialLeaderboardEventProperties.CAIP19]: token.assetId, + ...(typeof token.marketCap === 'number' + ? { [SocialLeaderboardEventProperties.MARKET_CAP]: token.marketCap } + : {}), + }); + } + prevTokenRef.current = token; + }, [token, track, source]); + + return ( + + ); +}; + +export default TrendingQuickBuy; diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts index c07be3bc8e0..0dab3cb45be 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts @@ -1,8 +1,9 @@ import { StyleSheet } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; -const styleSheet = (_params: { theme: Theme }) => - StyleSheet.create({ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + return StyleSheet.create({ container: { display: 'flex', flexDirection: 'row', @@ -38,6 +39,16 @@ const styleSheet = (_params: { theme: Theme }) => stockBadgeWrapper: { marginTop: 4, }, + quickTradeButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: theme.colors.background.muted, + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + }, }); +}; export default styleSheet; diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index 0e4fbb01ab0..281f097b4f1 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -1816,6 +1816,67 @@ describe('TrendingTokenRowItem', () => { }); }); + describe('Quick Trade button (onQuickTrade)', () => { + it('does not render the quick trade button when onQuickTrade is not provided', () => { + const token = createMockToken(); + + const { queryByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + expect(queryByTestId('quick-trade-button')).toBeNull(); + }); + + it('renders the quick trade button when onQuickTrade is provided', () => { + const token = createMockToken(); + const onQuickTrade = jest.fn(); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + expect(getByTestId('quick-trade-button')).toBeOnTheScreen(); + }); + + it('calls onQuickTrade with the token when the button is pressed', () => { + const token = createMockToken(); + const onQuickTrade = jest.fn(); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + fireEvent.press(getByTestId('quick-trade-button')); + + expect(onQuickTrade).toHaveBeenCalledTimes(1); + expect(onQuickTrade).toHaveBeenCalledWith(token); + }); + + it('does not trigger row navigation when the quick trade button is pressed', async () => { + const token = createMockToken(); + const onQuickTrade = jest.fn(); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + fireEvent.press(getByTestId('quick-trade-button')); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + }); + describe('memoization', () => { it('is wrapped in React.memo to avoid re-renders in hot lists', () => { // React.memo components expose the `react.memo` symbol on `$$typeof`. diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index e0a7a536ad5..52473abebc0 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import { getTrendingTokenRowItemTestId } from './TrendingTokenRowItem.testIds'; -import { TouchableOpacity, View } from 'react-native'; +import { Pressable, TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; import Text, { TextColor, @@ -8,6 +8,12 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import styleSheet from './TrendingTokenRowItem.styles'; +import { + Icon, + IconColor, + IconName, + IconSize, +} from '@metamask/design-system-react-native'; import { TrendingAsset } from '@metamask/assets-controllers'; import TrendingTokenLogo from '../TrendingTokenLogo'; import Badge, { @@ -36,7 +42,6 @@ import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; import { useTrendingTokenPress } from '../../hooks/useTrendingTokenPress/useTrendingTokenPress'; import SecurityTrustInlineBadge from '../../../SecurityTrust/components/SecurityTrustInlineBadge/SecurityTrustInlineBadge'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; - /** * Gets the text color for price percentage change */ @@ -85,6 +90,8 @@ interface TrendingTokenRowItemProps { * `testID` (and E2E selectors) unique per instance. */ testIdInstanceKey?: string; + /** When provided, shows a circular Quick Trade button on the right of the row. */ + onQuickTrade?: (token: TrendingAsset) => void; } /** @@ -136,6 +143,7 @@ const TrendingTokenRowItem = ({ onPress, onCardPress, testIdInstanceKey, + onQuickTrade, }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); const currentCurrency = useSelector(selectCurrentCurrency) || 'usd'; @@ -260,6 +268,27 @@ const TrendingTokenRowItem = ({ ) )} + {onQuickTrade && ( + { + e?.stopPropagation?.(); + onQuickTrade(token); + }} + hitSlop={8} + testID="quick-trade-button" + accessibilityRole="button" + style={({ pressed }) => [ + styles.quickTradeButton, + pressed && { opacity: 0.5 }, + ]} + > + + + )} ); }; diff --git a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx index 08157a72633..08681a815eb 100644 --- a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx +++ b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx @@ -44,6 +44,10 @@ export interface TrendingTokensListProps { * Whether a pagination request is in flight. Shows a spinner in the list footer. */ isLoadingMore?: boolean; + /** + * When provided, shows a Quick Trade flash button on each row. + */ + onQuickTrade?: (token: TrendingAsset) => void; } /** @@ -60,6 +64,7 @@ const TrendingTokensList: React.FC = React.memo( filterContext, onLoadMore, isLoadingMore, + onQuickTrade, }) => { const renderItem = useCallback( ({ item, index }: { item: TrendingAsset; index: number }) => ( @@ -68,9 +73,10 @@ const TrendingTokensList: React.FC = React.memo( selectedTimeOption={selectedTimeOption} position={index} filterContext={filterContext} + onQuickTrade={onQuickTrade} /> ), - [selectedTimeOption, filterContext], + [selectedTimeOption, filterContext, onQuickTrade], ); const keyExtractor = useCallback( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/abTestConfig.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/abTestConfig.ts index 068ce1d1544..66462edbe66 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/abTestConfig.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/abTestConfig.ts @@ -44,4 +44,15 @@ export const SOCIAL_AI_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMappi EVENT_NAME.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, EVENT_NAME.SOCIAL_QUICK_BUY_TRADE_COMPLETED, ], + // Only applies to Token Details / Market Insights flows — not Explore + excludeWhenPropertiesMatch: { + source: [ + 'explore_search', + 'explore_crypto', + 'explore_now', + 'explore_rwas', + 'explore_trending', + 'explore_stocks', + ] as const, + }, }; diff --git a/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts b/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts index ccd77bf2b0b..5da6405d85f 100644 --- a/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts +++ b/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts @@ -87,7 +87,13 @@ export type SocialLeaderboardSource = | 'profile_position' | 'asset_details' | 'market_insights' - | 'security_trust'; + | 'security_trust' + | 'explore_search' + | 'explore_crypto' + | 'explore_now' + | 'explore_rwas' + | 'explore_trending' + | 'explore_stocks'; export type LeaderboardScreenViewedSource = Extract< SocialLeaderboardSource, @@ -122,4 +128,10 @@ export type QuickBuySheetSource = Extract< | 'asset_details' | 'market_insights' | 'security_trust' + | 'explore_search' + | 'explore_crypto' + | 'explore_now' + | 'explore_rwas' + | 'explore_trending' + | 'explore_stocks' >; diff --git a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx index 65c39dc734a..a3783230c91 100644 --- a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx +++ b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx @@ -6,6 +6,14 @@ import React, { useState, } from 'react'; import { ActivityIndicator, Keyboard, Platform } from 'react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import TrendingQuickBuy from '../../../../UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy'; +import { useABTest } from '../../../../../hooks/useABTest'; +import { + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, +} from '../../search/abTestConfig'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -64,6 +72,15 @@ const FullFeedList: React.FC = ({ }) => { const tw = useTailwind(); const flashListRef = useRef>(null); + const [quickTradeToken, setQuickTradeToken] = useState( + null, + ); + + const { variant: quickBuyVariant } = useABTest( + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, + ); useEffect(() => { flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); @@ -82,6 +99,12 @@ const FullFeedList: React.FC = ({ resetScrollTracking(); }, [searchQuery, resetScrollTracking]); + const handleQuickTrade = + (feedId === 'tokens' || feedId === 'stocks') && + quickBuyVariant.showQuickTradeButton + ? setQuickTradeToken + : undefined; + const renderItem: ListRenderItem = useCallback( ({ item, index }) => ( = ({ searchQuery={searchQuery} tabName={tabName} resultCount={resultCount} + onQuickTrade={handleQuickTrade} /> ), - [feedId, searchQuery, tabName, resultCount], + [feedId, searchQuery, tabName, resultCount, handleQuickTrade], ); const keyExtractor = useCallback( @@ -134,20 +158,26 @@ const FullFeedList: React.FC = ({ } return ( - + <> + + setQuickTradeToken(null)} + /> + ); }; diff --git a/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx index 3cf28243e0b..7e4bf4a00fc 100644 --- a/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx @@ -15,6 +15,8 @@ interface TokenRowItemProps { tokenDetailsSource?: TokenDetailsSource; /** Called synchronously before the card's press handler fires. */ onCardPress?: () => void; + /** When provided, shows the Quick Trade button on the row. */ + onQuickTrade?: (token: TrendingAsset) => void; } /** Token row used inside the home tabs. */ @@ -23,6 +25,7 @@ export const TokenRowItem: React.FC = ({ index, tokenDetailsSource, onCardPress, + onQuickTrade, }) => ( = ({ filterContext={DEFAULT_TOKENS_FILTER_CONTEXT} tokenDetailsSource={tokenDetailsSource} onCardPress={onCardPress} + onQuickTrade={onQuickTrade} /> ); @@ -38,12 +42,14 @@ export const TokenSearchRowItem: React.FC = ({ token, index, tokenDetailsSource, + onQuickTrade, }) => ( ); diff --git a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx index 7dda22b128d..61161351160 100644 --- a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx @@ -1,19 +1,25 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Pressable, StyleSheet } from 'react-native'; import { useSelector } from 'react-redux'; import { Box, - TabEmptyState, - Text, - TextVariant, - TextColor, + BoxAlignItems, + BoxFlexDirection, FontWeight, Icon, + IconColor, IconName, IconSize, - IconColor, - BoxFlexDirection, - BoxAlignItems, + TabEmptyState, + Text, + TextVariant, + TextColor, SectionDivider, SectionHeader as MMDSSectionHeader, } from '@metamask/design-system-react-native'; @@ -36,6 +42,13 @@ import SearchFeedRow, { SearchFeedSkeleton, getItemId } from './SearchFeedRow'; import { MAX_ITEMS_PER_SECTION, getViewMoreLabel } from './viewMoreLabel'; import type { FlatListItem, ListItemHeader } from './searchTypes'; import CryptoMoversPillItem from '../feeds/tokens/CryptoMoversPillItem'; +import TrendingQuickBuy from '../../../UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy'; +import { useABTest } from '../../../../hooks/useABTest'; +import { + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, +} from './abTestConfig'; const POPULAR_ASSETS: TrendingAsset[] = [ { @@ -96,6 +109,16 @@ const ExploreSearchResults: React.FC = ({ [sections], ); + const [quickTradeToken, setQuickTradeToken] = useState( + null, + ); + + const { variant: quickBuyVariant } = useABTest( + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, + ); + const { onScrollBeginDrag, resetScrollTracking } = useScrollTracking( 'scrolled', searchQuery, @@ -249,6 +272,12 @@ const ExploreSearchResults: React.FC = ({ searchQuery={searchQuery} tabName={activeTab} resultCount={totalResultCount} + onQuickTrade={ + (item.feedId === 'tokens' || item.feedId === 'stocks') && + quickBuyVariant.showQuickTradeButton + ? setQuickTradeToken + : undefined + } /> ); }, @@ -258,6 +287,7 @@ const ExploreSearchResults: React.FC = ({ searchQuery, activeTab, totalResultCount, + quickBuyVariant.showQuickTradeButton, ], ); @@ -353,6 +383,10 @@ const ExploreSearchResults: React.FC = ({ ListFooterComponent={renderFooter} onScrollBeginDrag={onScrollBeginDrag} /> + setQuickTradeToken(null)} + /> ); }; diff --git a/app/components/Views/TrendingView/search/SearchFeedRow.test.tsx b/app/components/Views/TrendingView/search/SearchFeedRow.test.tsx index ece52dcb695..6b15ece0e93 100644 --- a/app/components/Views/TrendingView/search/SearchFeedRow.test.tsx +++ b/app/components/Views/TrendingView/search/SearchFeedRow.test.tsx @@ -31,17 +31,25 @@ jest.mock('./analytics', () => ({ trackExploreSearchEvent: jest.fn(), })); +const mockOnQuickTrade = jest.fn(); + jest.mock('../feeds/tokens/TokenRowItem', () => ({ TokenSearchRowItem: ({ token, tokenDetailsSource, + onQuickTrade, }: { token: TrendingAsset; tokenDetailsSource?: TokenDetailsSource; + onQuickTrade?: (t: TrendingAsset) => void; }) => ( - - {token.assetId} - + onQuickTrade?.(token)} + > + {token.assetId} + ), CryptoMoversSearchRowItem: ({ token }: { token: TrendingAsset }) => ( {token.assetId} @@ -217,6 +225,66 @@ describe('SearchFeedRow', () => { }); }); +describe('onQuickTrade prop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards onQuickTrade to TokenSearchRowItem for the tokens feed', () => { + const token = { assetId: 'eip155:1/erc20:0xabc' } as TrendingAsset; + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('stub-token-row')); + expect(mockOnQuickTrade).toHaveBeenCalledWith(token); + }); + + it('forwards onQuickTrade to TokenSearchRowItem for the stocks feed', () => { + const token = { assetId: 'eip155:1/erc20:0xabc' } as TrendingAsset; + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('stub-token-row')); + expect(mockOnQuickTrade).toHaveBeenCalledWith(token); + }); + + it('renders tokens feed without onQuickTrade when prop is not provided', () => { + const token = { assetId: 'eip155:1/erc20:0xabc' } as TrendingAsset; + + const { getByTestId } = render( + , + ); + + // pressing does not call anything (no handler wired) + fireEvent.press(getByTestId('stub-token-row')); + expect(mockOnQuickTrade).not.toHaveBeenCalled(); + }); +}); + describe('SearchFeedSkeleton', () => { it('uses site skeleton for sites and predictions', () => { const { getByTestId, rerender } = render( diff --git a/app/components/Views/TrendingView/search/SearchFeedRow.tsx b/app/components/Views/TrendingView/search/SearchFeedRow.tsx index c86d32d0253..4e7a46a90ea 100644 --- a/app/components/Views/TrendingView/search/SearchFeedRow.tsx +++ b/app/components/Views/TrendingView/search/SearchFeedRow.tsx @@ -21,28 +21,9 @@ interface SearchFeedRowProps { searchQuery: string; tabName: SearchFeedPill; resultCount?: number; + onQuickTrade?: (token: TrendingAsset) => void; } -const renderRow = (feedId: SearchFeedId, item: unknown, index: number) => { - switch (feedId) { - case 'tokens': - case 'stocks': - return ( - - ); - case 'perps': - return ; - case 'predictions': - return ; - case 'sites': - return ; - } -}; - export const getItemId = (feedId: SearchFeedId, item: unknown): string => { switch (feedId) { case 'tokens': @@ -65,6 +46,7 @@ const SearchFeedRow: React.FC = ({ searchQuery, tabName, resultCount, + onQuickTrade, }) => { const searchQueryRef = useRef(searchQuery); searchQueryRef.current = searchQuery; @@ -83,7 +65,28 @@ const SearchFeedRow: React.FC = ({ }); }, [feedId, tabName, item, index]); - return {renderRow(feedId, item, index)}; + const row = (() => { + switch (feedId) { + case 'tokens': + case 'stocks': + return ( + + ); + case 'perps': + return ; + case 'predictions': + return ; + case 'sites': + return ; + } + })(); + + return {row}; }; /** Skeleton row appropriate for a given feed. */ diff --git a/app/components/Views/TrendingView/search/abTestConfig.test.ts b/app/components/Views/TrendingView/search/abTestConfig.test.ts new file mode 100644 index 00000000000..26c7aa09dfd --- /dev/null +++ b/app/components/Views/TrendingView/search/abTestConfig.test.ts @@ -0,0 +1,95 @@ +// abTestConfig — structural tests +// +// Verifies flag key, variant names, and analytics mapping are correctly wired +// so a misconfiguration (e.g. wrong flag key or missing event) is caught +// before shipping to LaunchDarkly. + +import { + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, + EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING, + ExploreQuickBuyVariant, +} from './abTestConfig'; +import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events'; + +describe('EXPLORE_QUICK_BUY_AB_KEY', () => { + it('matches the LaunchDarkly flag name', () => { + expect(EXPLORE_QUICK_BUY_AB_KEY).toBe( + 'assetsASSETS3380AbtestExploreQuickBuy', + ); + }); +}); + +describe('EXPLORE_QUICK_BUY_VARIANTS', () => { + it('control hides the quick trade button', () => { + expect( + EXPLORE_QUICK_BUY_VARIANTS[ExploreQuickBuyVariant.Control] + .showQuickTradeButton, + ).toBe(false); + }); + + it('treatment shows the quick trade button', () => { + expect( + EXPLORE_QUICK_BUY_VARIANTS[ExploreQuickBuyVariant.Treatment] + .showQuickTradeButton, + ).toBe(true); + }); + + it('includes a control variant (required by useABTest)', () => { + expect(ExploreQuickBuyVariant.Control).toBe('control'); + }); +}); + +describe('EXPLORE_QUICK_BUY_EXPOSURE_METADATA', () => { + it('has human-readable variation names for both variants', () => { + expect( + EXPLORE_QUICK_BUY_EXPOSURE_METADATA.variationNames[ + ExploreQuickBuyVariant.Control + ], + ).toBeTruthy(); + expect( + EXPLORE_QUICK_BUY_EXPOSURE_METADATA.variationNames[ + ExploreQuickBuyVariant.Treatment + ], + ).toBeTruthy(); + }); +}); + +describe('EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING', () => { + it('uses the correct flag key', () => { + expect(EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING.flagKey).toBe( + EXPLORE_QUICK_BUY_AB_KEY, + ); + }); + + it('includes all valid variants', () => { + expect(EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING.validVariants).toEqual( + expect.arrayContaining(Object.values(ExploreQuickBuyVariant)), + ); + }); + + it('enriches SOCIAL_QUICK_BUY_SHEET_VIEWED', () => { + expect(EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING.eventNames).toContain( + EVENT_NAME.SOCIAL_QUICK_BUY_SHEET_VIEWED, + ); + }); + + it('enriches SOCIAL_QUICK_BUY_TRADE_SUBMITTED', () => { + expect(EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING.eventNames).toContain( + EVENT_NAME.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, + ); + }); + + it('enriches SOCIAL_QUICK_BUY_TRADE_COMPLETED', () => { + expect(EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING.eventNames).toContain( + EVENT_NAME.SOCIAL_QUICK_BUY_TRADE_COMPLETED, + ); + }); + + it('enriches SOCIAL_QUICK_BUY_DISMISSED', () => { + expect(EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING.eventNames).toContain( + EVENT_NAME.SOCIAL_QUICK_BUY_DISMISSED, + ); + }); +}); diff --git a/app/components/Views/TrendingView/search/abTestConfig.ts b/app/components/Views/TrendingView/search/abTestConfig.ts new file mode 100644 index 00000000000..221f2fe41bb --- /dev/null +++ b/app/components/Views/TrendingView/search/abTestConfig.ts @@ -0,0 +1,59 @@ +import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events'; +import type { ABTestAnalyticsMapping } from '../../../../util/analytics/abTestAnalytics.types'; + +// --- Explore Search Quick Buy A/B Test (ASSETS-3380) --- + +export const EXPLORE_QUICK_BUY_AB_KEY = 'assetsASSETS3380AbtestExploreQuickBuy'; + +export enum ExploreQuickBuyVariant { + Control = 'control', + Treatment = 'treatment', +} + +export const EXPLORE_QUICK_BUY_VARIANTS: Record< + ExploreQuickBuyVariant, + { showQuickTradeButton: boolean } +> = { + [ExploreQuickBuyVariant.Control]: { showQuickTradeButton: false }, + [ExploreQuickBuyVariant.Treatment]: { showQuickTradeButton: true }, +}; + +/** + * Shared exposure metadata so both surfaces (ExploreSearchResults and + * FullFeedList) emit identical `Experiment Viewed` properties. + */ +export const EXPLORE_QUICK_BUY_EXPOSURE_METADATA = { + experimentName: 'Explore Search Quick Buy Button', + variationNames: { + [ExploreQuickBuyVariant.Control]: 'No quick buy button on token rows', + [ExploreQuickBuyVariant.Treatment]: 'Flash quick buy button on token rows', + }, +}; + +/** + * Auto-enriches Quick Buy business events with the active A/B variant so the + * data team can slice trade metrics by experiment arm. + */ +export const EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMapping = + { + flagKey: EXPLORE_QUICK_BUY_AB_KEY, + validVariants: Object.values(ExploreQuickBuyVariant), + eventNames: [ + EVENT_NAME.SOCIAL_QUICK_BUY_SHEET_VIEWED, + EVENT_NAME.SOCIAL_QUICK_BUY_AMOUNT_SELECTED, + EVENT_NAME.SOCIAL_QUICK_BUY_TRADE_SUBMITTED, + EVENT_NAME.SOCIAL_QUICK_BUY_TRADE_COMPLETED, + EVENT_NAME.SOCIAL_QUICK_BUY_DISMISSED, + ], + // This A/B test applies to all Explore surfaces + injectWhenPropertiesMatch: { + source: [ + 'explore_search', + 'explore_crypto', + 'explore_now', + 'explore_rwas', + 'explore_trending', + 'explore_stocks', + ] as const, + }, + }; diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx index cee6d609a44..126b2d1a66d 100644 --- a/app/components/Views/TrendingView/tabs/CryptoTab.tsx +++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import type { ListRenderItem } from '@shopify/flash-list'; @@ -33,6 +34,17 @@ import TileCarousel from '../components/TileCarousel'; import type { TabProps } from '../hooks/useExploreRefresh'; import { trackExploreInteracted } from '../search/analytics'; import { TrendingViewSelectorsIDs } from '../TrendingView.testIds'; +import TrendingQuickBuy from '../../../UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy'; +import { useABTest } from '../../../../hooks/useABTest'; +import { + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, +} from '../search/abTestConfig'; + +const styles = StyleSheet.create({ + container: { flex: 1 }, +}); interface CryptoPerpsBlockProps { refresh: TabProps['refresh']; @@ -100,6 +112,16 @@ const CryptoTabContent: React.FC = ({ useNavigation>(); const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const [quickTradeToken, setQuickTradeToken] = useState( + null, + ); + + const { variant: quickBuyVariant } = useABTest( + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, + ); + const tokens = useTokensFeed({ refresh }); const cryptoPredictions = usePredictionsFeed({ variant: 'crypto', @@ -129,9 +151,12 @@ const CryptoTabContent: React.FC = ({ item_clicked: item.assetId, }) } + onQuickTrade={ + quickBuyVariant.showQuickTradeButton ? setQuickTradeToken : undefined + } /> ), - [], + [quickBuyVariant.showQuickTradeButton], ); const showTokens = tokens.isLoading || tokens.data.length > 0; @@ -218,13 +243,21 @@ const CryptoTabContent: React.FC = ({ ]); return ( - - - + + + + + + setQuickTradeToken(null)} + source="explore_crypto" + /> + ); }; diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx index c8140a21b4d..62fd064a4d3 100644 --- a/app/components/Views/TrendingView/tabs/RwasTab.tsx +++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import type { ListRenderItem } from '@shopify/flash-list'; @@ -42,6 +43,17 @@ import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import type { TabProps } from '../hooks/useExploreRefresh'; import { trackExploreInteracted } from '../search/analytics'; import { TrendingViewSelectorsIDs } from '../TrendingView.testIds'; +import TrendingQuickBuy from '../../../UI/Trending/components/TrendingQuickBuy/TrendingQuickBuy'; +import { useABTest } from '../../../../hooks/useABTest'; +import { + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, +} from '../search/abTestConfig'; + +const styles = StyleSheet.create({ + container: { flex: 1 }, +}); /** Perps category pills for the RWAs tab, in perps display order. */ const RWA_PERPS_CATEGORIES: PerpsFilterKey[] = [ @@ -108,6 +120,16 @@ const RwasTabContent: React.FC = ({ const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const [quickTradeToken, setQuickTradeToken] = useState( + null, + ); + + const { variant: quickBuyVariant } = useABTest( + EXPLORE_QUICK_BUY_AB_KEY, + EXPLORE_QUICK_BUY_VARIANTS, + EXPLORE_QUICK_BUY_EXPOSURE_METADATA, + ); + const politics = usePredictionsFeed({ variant: 'politics', refresh }); const stocks = useStocksFeed({ refresh, @@ -133,9 +155,12 @@ const RwasTabContent: React.FC = ({ item_clicked: item.assetId, }) } + onQuickTrade={ + quickBuyVariant.showQuickTradeButton ? setQuickTradeToken : undefined + } /> ), - [], + [quickBuyVariant.showQuickTradeButton], ); const showStocks = stocks.isLoading || stocks.data.length > 0; @@ -225,13 +250,21 @@ const RwasTabContent: React.FC = ({ ]); return ( - - - + + + + + + setQuickTradeToken(null)} + source="explore_rwas" + /> + ); }; diff --git a/app/util/analytics/abTestAnalytics.types.ts b/app/util/analytics/abTestAnalytics.types.ts index fb1424d513e..3de222f89fb 100644 --- a/app/util/analytics/abTestAnalytics.types.ts +++ b/app/util/analytics/abTestAnalytics.types.ts @@ -11,7 +11,18 @@ export interface ABTestAnalyticsMapping { >; /** * When set, `active_ab_tests` for this flag is only injected if every entry - * matches `event.properties` (strict `===`). Omit for unconditional injection. + * matches `event.properties`. Values may be a scalar or an array of scalars + * (any one matching value satisfies the condition). */ - injectWhenPropertiesMatch?: Readonly>; + injectWhenPropertiesMatch?: Readonly< + Record + >; + /** + * When set, `active_ab_tests` for this flag is NOT injected if any entry + * matches `event.properties`. Values may be a scalar or an array of scalars + * (any one matching value causes exclusion). + */ + excludeWhenPropertiesMatch?: Readonly< + Record + >; } diff --git a/app/util/analytics/abTestAnalyticsRegistry.ts b/app/util/analytics/abTestAnalyticsRegistry.ts index d5722907aac..8c1834e4b47 100644 --- a/app/util/analytics/abTestAnalyticsRegistry.ts +++ b/app/util/analytics/abTestAnalyticsRegistry.ts @@ -12,6 +12,7 @@ import { import { AMBIENT_PRICE_COLOR_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/TokenDetails/components/abTestConfig'; import { SOCIAL_AI_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING } from '../../components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/abTestConfig'; import { WHATS_HAPPENING_EXPLORE_AB_TEST_ANALYTICS_MAPPING } from '../../components/Views/TrendingView/abTestConfig'; +import { EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING } from '../../components/Views/TrendingView/search/abTestConfig'; import { ONBOARDING_CHECKLIST_STEPPER_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/WalletHomeOnboardingSteps/abTestConfig'; export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [ @@ -34,6 +35,7 @@ export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [ // Explore WHATS_HAPPENING_EXPLORE_AB_TEST_ANALYTICS_MAPPING, + EXPLORE_QUICK_BUY_AB_TEST_ANALYTICS_MAPPING, // Token Details AMBIENT_PRICE_COLOR_AB_TEST_ANALYTICS_MAPPING, diff --git a/app/util/analytics/enrichWithABTests.ts b/app/util/analytics/enrichWithABTests.ts index 80c02984452..2908368d203 100644 --- a/app/util/analytics/enrichWithABTests.ts +++ b/app/util/analytics/enrichWithABTests.ts @@ -58,9 +58,30 @@ const eventMatchesInjectGate = ( if (!gate || Object.keys(gate).length === 0) { return true; } - return Object.entries(gate).every( - ([propertyKey, expected]) => properties[propertyKey] === expected, - ); + return Object.entries(gate).every(([propertyKey, expected]) => { + const actual = properties[propertyKey]; + if (Array.isArray(expected)) { + return (expected as readonly unknown[]).includes(actual); + } + return actual === expected; + }); +}; + +const eventMatchesExcludeGate = ( + properties: Record, + mapping: ABTestAnalyticsMapping, +): boolean => { + const gate = mapping.excludeWhenPropertiesMatch; + if (!gate || Object.keys(gate).length === 0) { + return false; + } + return Object.entries(gate).some(([propertyKey, excluded]) => { + const actual = properties[propertyKey]; + if (Array.isArray(excluded)) { + return (excluded as readonly unknown[]).includes(actual); + } + return actual === excluded; + }); }; export const getRemoteFeatureFlagsFromState = ( @@ -89,7 +110,8 @@ export const enrichWithABTests = < (mapping) => hasEventName(mapping, event.name) && eventMatchesPropertyRequirements(mapping, event) && - eventMatchesInjectGate(event.properties, mapping), + eventMatchesInjectGate(event.properties, mapping) && + !eventMatchesExcludeGate(event.properties, mapping), ); if (relevantMappings.length === 0) { diff --git a/locales/languages/en.json b/locales/languages/en.json index d0104e34e68..57b8736461b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -9703,6 +9703,7 @@ "low_to_high": "Low to high", "apply": "Apply", "search_placeholder": "Search tokens, markets and URLs", + "quick_trade": "Quick Buy & Sell", "cancel": "Cancel", "perps": "Perps", "rwa_perps_section": "Perps",