diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index 6857483777a..e532ebcb742 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -48,6 +48,7 @@ const getStories = () => { "./app/component-library/components-temp/Buttons/ButtonToggle/ButtonToggle.stories.tsx": require("../app/component-library/components-temp/Buttons/ButtonToggle/ButtonToggle.stories.tsx"), "./app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx": require("../app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx"), "./app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx": require("../app/component-library/components-temp/HeaderCenter/HeaderCenter.stories.tsx"), + "./app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx": require("../app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx"), "./app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx": require("../app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx"), "./app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx"), "./app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx"), diff --git a/android/app/build.gradle b/android/app/build.gradle index 4f06959781d..a66dba67dd7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -371,7 +371,6 @@ android { dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation(files("../libs/ecies.aar")) - implementation(files("../libs/nativesdk.aar")) implementation("com.facebook.react:react-android") implementation 'org.apache.commons:commons-compress:1.22' androidTestImplementation 'androidx.test:core:1.5.0' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e398c99dbf1..9d30b90cb86 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -184,11 +184,5 @@ android:resource="@xml/filepaths" /> - - - diff --git a/android/app/src/main/java/io/metamask/MainApplication.kt b/android/app/src/main/java/io/metamask/MainApplication.kt index cc96024a9a7..442d52188e3 100644 --- a/android/app/src/main/java/io/metamask/MainApplication.kt +++ b/android/app/src/main/java/io/metamask/MainApplication.kt @@ -27,7 +27,6 @@ import cl.json.ShareApplication import io.branch.rnbranch.RNBranchModule import io.metamask.nativeModules.PreventScreenshotPackage import io.metamask.nativeModules.RCTMinimizerPackage -import io.metamask.nativesdk.NativeSDKPackage import io.metamask.nativeModules.RNTar.RNTarPackage import io.metamask.nativeModules.NotificationPackage @@ -43,9 +42,8 @@ class MainApplication : Application(), ShareApplication, ReactApplication { // Add all our custom packages packages.add(PreventScreenshotPackage()) packages.add(RCTMinimizerPackage()) - packages.add(NativeSDKPackage()) packages.add(RNTarPackage()) - packages.add(NotificationPackage()) + packages.add(NotificationPackage()) return packages } diff --git a/android/libs/nativesdk.aar b/android/libs/nativesdk.aar deleted file mode 100644 index 990866e39ab..00000000000 Binary files a/android/libs/nativesdk.aar and /dev/null differ diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx new file mode 100644 index 00000000000..1c9b6419bfe --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.stories.tsx @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +import React from 'react'; + +import { + Box, + Text, + TextVariant, + IconName, +} from '@metamask/design-system-react-native'; + +import HeaderWithTitleLeft from './HeaderWithTitleLeft'; + +const HeaderWithTitleLeftMeta = { + title: 'Components Temp / HeaderWithTitleLeft', + component: HeaderWithTitleLeft, + argTypes: { + twClassName: { + control: 'text', + }, + }, +}; + +export default HeaderWithTitleLeftMeta; + +const SampleNFTImage = () => ( + + NFT + +); + +export const Default = { + args: { + titleLeftProps: { + topLabel: 'Send', + title: '$4.42', + }, + }, +}; + +export const OnBack = { + render: () => ( + console.log('Back pressed')} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + endAccessory: , + }} + /> + ), +}; + +export const WithBottomLabel = { + render: () => ( + console.log('Back pressed')} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + bottomLabel: '0.002 ETH', + endAccessory: , + }} + /> + ), +}; + +export const EndButtonIconProps = { + render: () => ( + console.log('Back pressed')} + endButtonIconProps={[ + { + iconName: IconName.Close, + onPress: () => console.log('Close pressed'), + }, + ]} + titleLeftProps={{ + topLabel: 'Send', + title: '$4.42', + endAccessory: , + }} + /> + ), +}; + +export const BackButtonProps = { + render: () => ( + console.log('Custom back pressed'), + }} + titleLeftProps={{ + topLabel: 'Receive', + title: '$1,234.56', + }} + /> + ), +}; + +export const TitleLeft = { + render: () => ( + console.log('Back pressed')} + titleLeft={ + + Custom Title Section + + This is a completely custom title section + + + } + /> + ), +}; + +export const NoBackButton = { + render: () => ( + + ), +}; diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.test.tsx b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.test.tsx new file mode 100644 index 00000000000..406c8e08553 --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.test.tsx @@ -0,0 +1,218 @@ +// Third party dependencies. +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Text, IconName } from '@metamask/design-system-react-native'; + +// Internal dependencies. +import HeaderWithTitleLeft from './HeaderWithTitleLeft'; + +const TEST_IDS = { + CONTAINER: 'header-with-title-left-container', + TITLE_SECTION: 'header-with-title-left-title-section', + BACK_BUTTON: 'header-with-title-left-back-button', + TITLE_LEFT: 'title-left', + HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory', +}; + +describe('HeaderWithTitleLeft', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders container with correct testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); + }); + + it('renders title section when titleLeftProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen(); + }); + + it('renders TitleLeft with props when titleLeftProps provided', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByTestId(TEST_IDS.TITLE_LEFT)).toBeOnTheScreen(); + }); + + it('renders custom titleLeft node when provided', () => { + const { getByText } = render( + Custom Title Section} />, + ); + + expect(getByText('Custom Title Section')).toBeOnTheScreen(); + }); + + it('titleLeft takes priority over titleLeftProps', () => { + const { getByText, queryByText } = render( + Custom Node} + titleLeftProps={{ title: 'Props Title' }} + />, + ); + + expect(getByText('Custom Node')).toBeOnTheScreen(); + expect(queryByText('Props Title')).toBeNull(); + }); + + it('does not render title section when neither titleLeft nor titleLeftProps provided', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(TEST_IDS.TITLE_SECTION)).toBeNull(); + }); + }); + + describe('back button', () => { + it('renders back button when onBack provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); + }); + + it('renders back button when backButtonProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); + }); + + it('calls onBack when back button pressed', () => { + const onBack = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('calls backButtonProps.onPress when back button pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('backButtonProps.onPress takes priority over onBack', () => { + const onBack = jest.fn(); + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('does not render back button when neither onBack nor backButtonProps provided', () => { + const { queryByLabelText } = render( + , + ); + + expect(queryByLabelText('Arrow Left')).toBeNull(); + }); + }); + + describe('props forwarding', () => { + it('forwards endButtonIconProps to HeaderBase', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.HEADER_BASE_END_ACCESSORY)).toBeOnTheScreen(); + }); + + it('accepts custom testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-header')).toBeOnTheScreen(); + }); + + it('forwards startButtonIconProps directly when provided', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-start-button')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.tsx b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.tsx new file mode 100644 index 00000000000..6059575b75e --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.tsx @@ -0,0 +1,94 @@ +// Third party dependencies. +import React, { useMemo } from 'react'; + +// External dependencies. +import { + Box, + IconName, + ButtonIconProps, +} from '@metamask/design-system-react-native'; + +// Internal dependencies. +import HeaderBase from '../../components/HeaderBase'; +import TitleLeft from '../TitleLeft'; +import { HeaderWithTitleLeftProps } from './HeaderWithTitleLeft.types'; + +/** + * HeaderWithTitleLeft is a header component that combines HeaderBase (with back button) + * on top and a TitleLeft section below it. + * + * @example + * ```tsx + * + * }} + * /> + * ``` + */ +const HeaderWithTitleLeft: React.FC = ({ + onBack, + backButtonProps, + titleLeft, + titleLeftProps, + startButtonIconProps, + twClassName, + testID, + titleSectionTestID, + ...headerBaseProps +}) => { + // Build startButtonIconProps with back button if onBack or backButtonProps is provided + const resolvedStartButtonIconProps = useMemo(() => { + if (startButtonIconProps) { + // If startButtonIconProps is explicitly provided, use it as-is + return startButtonIconProps; + } + + if (onBack || backButtonProps) { + const backProps: ButtonIconProps = { + iconName: IconName.ArrowLeft, + ...(backButtonProps || {}), + onPress: backButtonProps?.onPress ?? onBack, + }; + return backProps; + } + + return undefined; + }, [startButtonIconProps, onBack, backButtonProps]); + + // Render title section content + const renderTitleSection = () => { + if (titleLeft) { + return titleLeft; + } + if (titleLeftProps) { + return ; + } + return null; + }; + + const hasTitleSection = titleLeft || titleLeftProps; + + const resolvedTwClassName = twClassName ? `px-2 ${twClassName}` : 'px-2'; + + return ( + + {/* HeaderBase section */} + + + {/* TitleLeft section */} + {hasTitleSection && ( + {renderTitleSection()} + )} + + ); +}; + +export default HeaderWithTitleLeft; diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.types.ts b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.types.ts new file mode 100644 index 00000000000..97804f07b0a --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.types.ts @@ -0,0 +1,40 @@ +// Third party dependencies. +import { ReactNode } from 'react'; + +// External dependencies. +import { ButtonIconProps } from '@metamask/design-system-react-native'; + +// Internal dependencies. +import { HeaderBaseProps } from '../../components/HeaderBase'; +import { TitleLeftProps } from '../TitleLeft/TitleLeft.types'; + +/** + * HeaderWithTitleLeft component props. + */ +export interface HeaderWithTitleLeftProps + extends Omit { + /** + * Callback when the back button is pressed. + * If provided, a back button will be rendered as startAccessory. + */ + onBack?: () => void; + /** + * Additional props to pass to the back ButtonIcon. + * If provided, a back button will be rendered with these props spread. + */ + backButtonProps?: Omit; + /** + * Custom node to render in the title section. + * If provided, takes priority over titleLeftProps. + */ + titleLeft?: ReactNode; + /** + * Props to pass to the TitleLeft component. + * Only used if titleLeft is not provided. + */ + titleLeftProps?: TitleLeftProps; + /** + * Test ID for the title section wrapper. + */ + titleSectionTestID?: string; +} diff --git a/app/component-library/components-temp/HeaderWithTitleLeft/index.ts b/app/component-library/components-temp/HeaderWithTitleLeft/index.ts new file mode 100644 index 00000000000..6a5de15f41f --- /dev/null +++ b/app/component-library/components-temp/HeaderWithTitleLeft/index.ts @@ -0,0 +1,2 @@ +export { default } from './HeaderWithTitleLeft'; +export type { HeaderWithTitleLeftProps } from './HeaderWithTitleLeft.types'; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index c0f312c5774..d925339c351 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -43,9 +43,7 @@ import { useSelector } from 'react-redux'; import React from 'react'; import CardHome from './CardHome'; import { cardDefaultNavigationOptions } from '../../routes'; -import renderWithProvider, { - renderScreen, -} from '../../../../../util/test/renderWithProvider'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; import { withCardSDK } from '../../sdk'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import Routes from '../../../../../constants/navigation/Routes'; @@ -2497,7 +2495,7 @@ describe('CardHome Component', () => { }); }); - it('does nothing when no error exists', async () => { + it('does nothing when no error exists', () => { // Given: authenticated user without error setupMockSelectors({ isAuthenticated: true }); mockIsAuthenticationError.mockReturnValue(false); @@ -2510,7 +2508,6 @@ describe('CardHome Component', () => { render(); // Then: should not trigger authentication error handling - await new Promise((r) => setTimeout(r, 100)); expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled(); expect(mockResetAuthenticatedData).not.toHaveBeenCalled(); expect(mockClearAllCache).not.toHaveBeenCalled(); @@ -2519,7 +2516,7 @@ describe('CardHome Component', () => { ); }); - it('does nothing when user is not authenticated', async () => { + it('does nothing when user is not authenticated', () => { // Given: non-authenticated user with error setupMockSelectors({ isAuthenticated: false }); mockIsAuthenticationError.mockReturnValue(false); @@ -2532,13 +2529,12 @@ describe('CardHome Component', () => { render(); // Then: should not trigger authentication error handling - await new Promise((r) => setTimeout(r, 100)); expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled(); expect(mockResetAuthenticatedData).not.toHaveBeenCalled(); expect(mockClearAllCache).not.toHaveBeenCalled(); }); - it('does nothing when error is not an authentication error', async () => { + it('does nothing when error is not an authentication error', () => { // Given: authenticated user with non-authentication error setupMockSelectors({ isAuthenticated: true }); mockIsAuthenticationError.mockReturnValue(false); @@ -2551,7 +2547,6 @@ describe('CardHome Component', () => { render(); // Then: should not trigger authentication error handling - await new Promise((r) => setTimeout(r, 100)); expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled(); expect(mockResetAuthenticatedData).not.toHaveBeenCalled(); expect(mockClearAllCache).not.toHaveBeenCalled(); @@ -2668,8 +2663,8 @@ describe('CardHome Component', () => { // Given: authenticated user with persistent authentication error setupMockSelectors({ isAuthenticated: true }); mockIsAuthenticationError.mockReturnValue(true); - const WrappedCardHome = withCardSDK(CardHome); + // Setup mock to return same error for multiple renders setupLoadCardDataMock({ error: 'First auth error', isAuthenticated: true, @@ -2684,27 +2679,13 @@ describe('CardHome Component', () => { priorityToken: mockPriorityToken, }); - // When: component renders twice with the same authentication error - const { rerender } = renderWithProvider(, { - state: { - engine: { - backgroundState, - }, - }, - }); + // When: component renders with authentication error + render(); // Then: cleanup runs once on initial render await waitFor(() => { expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1); }); - - // When: component re-renders with same error - rerender(); - - // Then: cleanup does not run again for unchanged error - await waitFor(() => { - expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1); - }); }); it('does not dispatch Redux actions if token removal throws and component unmounts', async () => { diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 7649709f20c..9f677c27f34 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -23,7 +23,12 @@ import Icon, { import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; -import { StackActions, useNavigation } from '@react-navigation/native'; +import { + StackActions, + useNavigation, + useRoute, + RouteProp, +} from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; import SensitiveText, { SensitiveTextLength, @@ -87,6 +92,13 @@ import SpendingLimitProgressBar from '../../components/SpendingLimitProgressBar/ import { createAddFundsModalNavigationDetails } from '../../components/AddFundsBottomSheet/AddFundsBottomSheet'; import { createAssetSelectionModalNavigationDetails } from '../../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet'; +/** + * Route params for CardHome screen + */ +interface CardHomeRouteParams { + showDeeplinkToast?: boolean; +} + /** * CardHome Component * @@ -111,11 +123,14 @@ const CardHome = () => { const isComponentUnmountedRef = useRef(false); const hasShownKYCAlertRef = useRef(false); const hasShownKYCErrorAlertRef = useRef(false); + const hasShownDeeplinkToast = useRef(false); const [ isCloseSpendingLimitWarningShown, setIsCloseSpendingLimitWarningShown, ] = useState(true); + const route = + useRoute>(); const { trackEvent, createEventBuilder } = useMetrics(); const navigation = useNavigation(); const dispatch = useDispatch(); @@ -260,6 +275,25 @@ const CardHome = () => { isSDKLoading, ]); + // Show toast notification when navigating from deeplink + useEffect(() => { + if ( + route.params?.showDeeplinkToast && + !hasShownDeeplinkToast.current && + toastRef?.current + ) { + hasShownDeeplinkToast.current = true; + toastRef.current.showToast({ + variant: ToastVariants.Icon, + labelOptions: [ + { label: strings('card.card_button_already_enabled_toast') }, + ], + hasNoTimeout: false, + iconName: IconName.Info, + }); + } + }, [route.params?.showDeeplinkToast, toastRef]); + const addFundsAction = useCallback(() => { trackEvent( createEventBuilder(MetaMetricsEvents.CARD_ADD_FUNDS_CLICKED).build(), diff --git a/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts b/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts index 45f06b57ba5..fb76237a534 100644 --- a/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts +++ b/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts @@ -8,16 +8,24 @@ jest.mock('react-redux', () => ({ const mockUseSelector = useSelector as jest.MockedFunction; +interface MockSelectorsParams { + displayCardButtonFeatureFlag: boolean | null | undefined; + alwaysShowCardButton: boolean | null | undefined; + cardGeoLocation: string; + cardSupportedCountries: Record | null | undefined; +} + const mockSelectors = ({ displayCardButtonFeatureFlag, alwaysShowCardButton, -}: { - displayCardButtonFeatureFlag: boolean | null | undefined; - alwaysShowCardButton: boolean | null | undefined; -}) => { + cardGeoLocation, + cardSupportedCountries, +}: MockSelectorsParams) => { mockUseSelector .mockReturnValueOnce(displayCardButtonFeatureFlag) - .mockReturnValueOnce(alwaysShowCardButton); + .mockReturnValueOnce(alwaysShowCardButton) + .mockReturnValueOnce(cardGeoLocation) + .mockReturnValueOnce(cardSupportedCountries); }; describe('useIsBaanxLoginEnabled', () => { @@ -25,87 +33,176 @@ describe('useIsBaanxLoginEnabled', () => { jest.clearAllMocks(); }); - it('returns false when both flags are null', () => { - mockSelectors({ - displayCardButtonFeatureFlag: null, - alwaysShowCardButton: null, + describe('when alwaysShowCardButton is enabled', () => { + it('returns true regardless of other flag states', () => { + mockSelectors({ + displayCardButtonFeatureFlag: false, + alwaysShowCardButton: true, + cardGeoLocation: 'US', + cardSupportedCountries: { US: false }, + }); + + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(true); }); - const { result } = renderHook(() => useIsBaanxLoginEnabled()); + it('returns true even when country is not supported', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: true, + cardGeoLocation: 'XX', + cardSupportedCountries: {}, + }); - expect(result.current).toBe(false); + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(true); + }); }); - it('returns false when both flags are undefined', () => { - mockSelectors({ - displayCardButtonFeatureFlag: undefined, - alwaysShowCardButton: undefined, + describe('when alwaysShowCardButton is disabled', () => { + it('returns true when country is supported and feature flag is enabled', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: false, + cardGeoLocation: 'US', + cardSupportedCountries: { US: true }, + }); + + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(true); }); - const { result } = renderHook(() => useIsBaanxLoginEnabled()); + it('returns false when country is supported but feature flag is disabled', () => { + mockSelectors({ + displayCardButtonFeatureFlag: false, + alwaysShowCardButton: false, + cardGeoLocation: 'US', + cardSupportedCountries: { US: true }, + }); - expect(result.current).toBe(false); - }); + const { result } = renderHook(() => useIsBaanxLoginEnabled()); - it('returns false when both flags are disabled', () => { - mockSelectors({ - displayCardButtonFeatureFlag: false, - alwaysShowCardButton: false, + expect(result.current).toBe(false); }); - const { result } = renderHook(() => useIsBaanxLoginEnabled()); + it('returns false when feature flag is enabled but country is not supported', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: false, + cardGeoLocation: 'XX', + cardSupportedCountries: { US: true, GB: true }, + }); - expect(result.current).toBe(false); - }); + const { result } = renderHook(() => useIsBaanxLoginEnabled()); - it('returns true when feature flag is enabled and experimental switch is disabled', () => { - mockSelectors({ - displayCardButtonFeatureFlag: true, - alwaysShowCardButton: false, + expect(result.current).toBe(false); }); - const { result } = renderHook(() => useIsBaanxLoginEnabled()); + it('returns false when country is explicitly set to false in supported countries', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: false, + cardGeoLocation: 'US', + cardSupportedCountries: { US: false }, + }); - expect(result.current).toBe(true); + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(false); + }); }); - it('returns true when experimental switch is enabled and feature flag is disabled', () => { - mockSelectors({ - displayCardButtonFeatureFlag: false, - alwaysShowCardButton: true, + describe('edge cases', () => { + it('returns false when all flags are null', () => { + mockSelectors({ + displayCardButtonFeatureFlag: null, + alwaysShowCardButton: null, + cardGeoLocation: 'US', + cardSupportedCountries: null, + }); + + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(false); }); - const { result } = renderHook(() => useIsBaanxLoginEnabled()); + it('returns false when all flags are undefined', () => { + mockSelectors({ + displayCardButtonFeatureFlag: undefined, + alwaysShowCardButton: undefined, + cardGeoLocation: 'US', + cardSupportedCountries: undefined, + }); - expect(result.current).toBe(true); - }); + const { result } = renderHook(() => useIsBaanxLoginEnabled()); - it('returns true when both flags are enabled', () => { - mockSelectors({ - displayCardButtonFeatureFlag: true, - alwaysShowCardButton: true, + expect(result.current).toBe(false); }); - const { result } = renderHook(() => useIsBaanxLoginEnabled()); + it('returns false when cardSupportedCountries is empty object', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: false, + cardGeoLocation: 'US', + cardSupportedCountries: {}, + }); - expect(result.current).toBe(true); + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(false); + }); + + it('returns false when cardGeoLocation is empty string', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: false, + cardGeoLocation: '', + cardSupportedCountries: { US: true }, + }); + + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(false); + }); }); - it('returns true when experimental switch is enabled regardless of feature flag state', () => { - mockSelectors({ - displayCardButtonFeatureFlag: false, - alwaysShowCardButton: true, + describe('multiple supported countries', () => { + it('returns true when user is in one of multiple supported countries', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: false, + cardGeoLocation: 'GB', + cardSupportedCountries: { US: true, GB: true, CA: true }, + }); + + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(true); }); - const { result } = renderHook(() => useIsBaanxLoginEnabled()); + it('returns false when user is not in any supported country', () => { + mockSelectors({ + displayCardButtonFeatureFlag: true, + alwaysShowCardButton: false, + cardGeoLocation: 'DE', + cardSupportedCountries: { US: true, GB: true, CA: true }, + }); - expect(result.current).toBe(true); + const { result } = renderHook(() => useIsBaanxLoginEnabled()); + + expect(result.current).toBe(false); + }); }); it('updates when flag values change', () => { mockSelectors({ displayCardButtonFeatureFlag: false, alwaysShowCardButton: false, + cardGeoLocation: 'US', + cardSupportedCountries: { US: true }, }); const { result, rerender } = renderHook(() => useIsBaanxLoginEnabled()); @@ -114,6 +211,8 @@ describe('useIsBaanxLoginEnabled', () => { mockSelectors({ displayCardButtonFeatureFlag: false, alwaysShowCardButton: true, + cardGeoLocation: 'US', + cardSupportedCountries: { US: true }, }); rerender(); diff --git a/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts b/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts index d94a74a9dc0..8cb77a2c494 100644 --- a/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts +++ b/app/components/UI/Card/hooks/isBaanxLoginEnabled.ts @@ -1,17 +1,41 @@ import { useSelector } from 'react-redux'; -import { selectDisplayCardButtonFeatureFlag } from '../../../../selectors/featureFlagController/card'; -import { selectAlwaysShowCardButton } from '../../../../core/redux/slices/card'; +import { + selectCardSupportedCountries, + selectDisplayCardButtonFeatureFlag, +} from '../../../../selectors/featureFlagController/card'; +import { + selectAlwaysShowCardButton, + selectCardGeoLocation, +} from '../../../../core/redux/slices/card'; + +export const isBaanxLoginEnabled = (params: { + alwaysShowCardButton: boolean; + cardGeoLocation: string; + cardSupportedCountries: Record; + displayCardButtonFeatureFlag: boolean; +}) => + params.alwaysShowCardButton || + (params.cardSupportedCountries?.[params.cardGeoLocation] === true && + params.displayCardButtonFeatureFlag) || + false; const useIsBaanxLoginEnabled = () => { const displayCardButtonFeatureFlag = useSelector( selectDisplayCardButtonFeatureFlag, ); const alwaysShowCardButton = useSelector(selectAlwaysShowCardButton); + const cardGeoLocation = useSelector(selectCardGeoLocation); + const cardSupportedCountries = useSelector(selectCardSupportedCountries); // If user has explicitly enabled the experimental switch, // they should have full access to the feature including authentication/onboarding, // regardless of the progressive rollout flag state - return alwaysShowCardButton || displayCardButtonFeatureFlag || false; + return isBaanxLoginEnabled({ + alwaysShowCardButton, + cardGeoLocation, + displayCardButtonFeatureFlag, + cardSupportedCountries: cardSupportedCountries as Record, + }); }; export default useIsBaanxLoginEnabled; diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts index 44e0e813f16..e817c49810a 100644 --- a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts +++ b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts @@ -136,6 +136,7 @@ describe('useGetDelegationSettings', () => { expect.any(Function), { cacheDuration: 600000, // 10 minutes in milliseconds + fetchOnMount: true, }, ); }); @@ -148,6 +149,7 @@ describe('useGetDelegationSettings', () => { expect(options).toEqual({ cacheDuration: 10 * 60 * 1000, + fetchOnMount: true, }); }); }); diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.ts index ed49a8c11d4..a07c558266e 100644 --- a/app/components/UI/Card/hooks/useGetDelegationSettings.ts +++ b/app/components/UI/Card/hooks/useGetDelegationSettings.ts @@ -20,11 +20,10 @@ const useGetDelegationSettings = () => { return sdk.getDelegationSettings(); }, [sdk, isAuthenticated]); - return useWrapWithCache( - 'delegation-settings', - fetchDelegationSettings, - { cacheDuration: 10 * 60 * 1000 }, // 10 minutes cache - ); + return useWrapWithCache('delegation-settings', fetchDelegationSettings, { + cacheDuration: 10 * 60 * 1000, // 10 minutes cache + fetchOnMount: isAuthenticated, + }); }; export default useGetDelegationSettings; diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts index acf7127708a..f028c48bbe8 100644 --- a/app/components/UI/Card/util/metrics.ts +++ b/app/components/UI/Card/util/metrics.ts @@ -55,4 +55,9 @@ enum CardActions { NAVIGATE_TO_CARD_PAGE = 'NAVIGATE_TO_CARD_PAGE', } -export { CardScreens, CardActions }; +enum CardDeeplinkActions { + CARD_ONBOARDING = 'CARD_ONBOARDING', + CARD_HOME = 'CARD_HOME', +} + +export { CardScreens, CardActions, CardDeeplinkActions }; diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 96ad227fa4a..2c59753eaaa 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -30,8 +30,10 @@ import { PerpsTransaction, } from '../../types/transactionHistory'; import { + formatPerpsFiat, formatPositiveFiat, formatTransactionDate, + PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { styleSheet } from './PerpsPositionTransactionView.styles'; @@ -103,7 +105,9 @@ const PerpsPositionTransactionView: React.FC = () => { transaction.fill?.action === 'Closed' ? strings('perps.transactions.position.close_price') : strings('perps.transactions.position.entry_price'), - value: formatPositiveFiat(transaction.fill.entryPrice), + value: formatPerpsFiat(transaction.fill.entryPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }), }, ].filter(Boolean); diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx index b9706d954b8..8ab4571fee0 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx @@ -107,6 +107,10 @@ jest.mock('bignumber.js', () => ({ // Mock stream hooks jest.mock('../../hooks/stream', () => ({ usePerpsLivePrices: jest.fn(() => ({})), + usePerpsTopOfBook: jest.fn(() => ({ + bestBid: '2995', + bestAsk: '3005', + })), })); // Mock usePerpsConnection hook @@ -114,6 +118,32 @@ jest.mock('../../hooks/index', () => ({ usePerpsConnection: jest.fn(), })); +// Mock usePerpsEventTracking hook +jest.mock('../../hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(() => ({ + track: jest.fn(), + })), +})); + +// Mock eventNames constants +jest.mock('../../constants/eventNames', () => ({ + PerpsEventProperties: { + INTERACTION_TYPE: 'interaction_type', + SETTING_TYPE: 'setting_type', + INPUT_METHOD: 'input_method', + ASSET: 'asset', + DIRECTION: 'direction', + }, + PerpsEventValues: { + INTERACTION_TYPE: { SETTING_CHANGED: 'setting_changed' }, + INPUT_METHOD: { + PRESET: 'preset', + PERCENTAGE_BUTTON: 'percentage_button', + KEYBOARD: 'keyboard', + }, + }, +})); + // Mock Keypad component from Base // Mock BottomSheet components jest.mock( @@ -329,9 +359,11 @@ describe('PerpsLimitPriceBottomSheet', () => { jest.clearAllMocks(); mockUseTheme.mockReturnValue(mockTheme); - // Mock usePerpsLivePrices hook to return empty by default - const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + // Mock stream hooks + const { usePerpsLivePrices, usePerpsTopOfBook } = + jest.requireMock('../../hooks/stream'); usePerpsLivePrices.mockReturnValue({}); + usePerpsTopOfBook.mockReturnValue({ bestBid: '2995', bestAsk: '3005' }); // Mock usePerpsConnection hook const { usePerpsConnection } = jest.requireMock('../../hooks/index'); @@ -451,43 +483,83 @@ describe('PerpsLimitPriceBottomSheet', () => { }); describe('Quick Action Buttons', () => { - it('displays direction-specific preset buttons for long orders', () => { - // Act + it('displays Mid, Bid, and percentage preset buttons for long orders', () => { render(); - // Assert - Long orders show negative percentages (buy below market) + expect( + screen.getByText('perps.order.limit_price_modal.mid_price'), + ).toBeOnTheScreen(); + expect( + screen.getByText('perps.order.limit_price_modal.bid_price'), + ).toBeOnTheScreen(); expect(screen.getByText('-1%')).toBeOnTheScreen(); expect(screen.getByText('-2%')).toBeOnTheScreen(); - expect(screen.getByText('-5%')).toBeOnTheScreen(); - expect(screen.getByText('-10%')).toBeOnTheScreen(); }); - it('displays direction-specific preset buttons for short orders', () => { - // Act + it('displays Mid, Ask, and percentage preset buttons for short orders', () => { render( , ); - // Assert - Short orders show positive percentages (sell above market) + expect( + screen.getByText('perps.order.limit_price_modal.mid_price'), + ).toBeOnTheScreen(); + expect( + screen.getByText('perps.order.limit_price_modal.ask_price'), + ).toBeOnTheScreen(); expect(screen.getByText('+1%')).toBeOnTheScreen(); expect(screen.getByText('+2%')).toBeOnTheScreen(); - expect(screen.getByText('+5%')).toBeOnTheScreen(); - expect(screen.getByText('+10%')).toBeOnTheScreen(); }); - it('calculates price based on current market price for long orders', () => { - // Act + it('sets price when Mid button is pressed', () => { + render(); + + const midButton = screen.getByText( + 'perps.order.limit_price_modal.mid_price', + ); + fireEvent.press(midButton); + + // Verify limit price was updated to current price (3000) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000'); + }); + + it('sets price when Bid button is pressed for long orders', () => { + render(); + + const bidButton = screen.getByText( + 'perps.order.limit_price_modal.bid_price', + ); + fireEvent.press(bidButton); + + // Verify limit price was updated to bid price (2995 from mock) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('2995'); + }); + + it('sets price when Ask button is pressed for short orders', () => { + render( + , + ); + + const askButton = screen.getByText( + 'perps.order.limit_price_modal.ask_price', + ); + fireEvent.press(askButton); + + // Verify limit price was updated to ask price (3005 from mock) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3005'); + }); + + it('sets price when percentage button is pressed for long orders', () => { render(); const onePercentButton = screen.getByText('-1%'); fireEvent.press(onePercentButton); - // Assert - Button exists and is pressable - expect(onePercentButton).toBeOnTheScreen(); + // Verify limit price was updated (BigNumber mock returns base price) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000'); }); - it('calculates price based on current market price for short orders', () => { - // Act + it('sets price when percentage button is pressed for short orders', () => { render( , ); @@ -495,8 +567,8 @@ describe('PerpsLimitPriceBottomSheet', () => { const onePercentButton = screen.getByText('+1%'); fireEvent.press(onePercentButton); - // Assert - Button exists and is pressable - expect(onePercentButton).toBeOnTheScreen(); + // Verify limit price was updated (BigNumber mock returns base price) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('3000'); }); }); diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx index fb1298f5b0b..33acf906346 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx @@ -23,12 +23,18 @@ import { } from '../../utils/formatUtils'; import { getPerpsDisplaySymbol } from '../../utils/marketUtils'; import { createStyles } from './PerpsLimitPriceBottomSheet.styles'; -import { usePerpsLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream'; import { PERPS_CONSTANTS, LIMIT_PRICE_CONFIG, } from '../../constants/perpsConfig'; import { BigNumber } from 'bignumber.js'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; interface PerpsLimitPriceBottomSheetProps { isVisible: boolean; @@ -46,7 +52,7 @@ interface PerpsLimitPriceBottomSheetProps { * Modal for setting limit order prices with direction-specific presets * * Features: - * - Direction-aware presets (Long: -1%, -2%, -5%, -10% | Short: +1%, +2%, +5%, +10%) + * - Direction-aware presets (Long: Mid, Bid, -1%, -2% | Short: Mid, Ask, +1%, +2%) * - Custom keypad for price input * - Real-time current market price display * - Automatic preset calculation based on current price @@ -72,6 +78,12 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ // Initialize with initial limit price or empty to show placeholder const [limitPrice, setLimitPrice] = useState(initialLimitPrice || ''); + // Track input method for MetaMetrics (preset = Mid/Bid/Ask, percentage_button = %, keyboard = manual) + const [inputMethod, setInputMethod] = useState(null); + + // MetaMetrics tracking + const { track } = usePerpsEventTracking(); + // Get real-time price data with 1000ms throttle for limit price bottom sheet // Only subscribe when visible const priceData = usePerpsLivePrices({ @@ -85,8 +97,15 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ ? parseFloat(currentPriceData.price) : passedCurrentPrice; + // Get top of book (bid/ask) data for Bid/Ask preset buttons + // Note: Mid price comes from currentPrice above (from allMids stream) + const topOfBook = usePerpsTopOfBook({ symbol: isVisible ? asset : '' }); + const bidPrice = topOfBook?.bestBid; + const askPrice = topOfBook?.bestAsk; + useEffect(() => { if (isVisible) { + setInputMethod(null); // Reset input method tracking for new session bottomSheetRef.current?.onOpenBottomSheet(); // Start cursor blinking animation @@ -114,6 +133,19 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ const handleConfirm = () => { // Remove any formatting (commas, dollar signs) before passing the value const cleanPrice = limitPrice.replace(/[$,]/g, ''); + + // Track limit price input method + if (inputMethod) { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.SETTING_CHANGED, + [PerpsEventProperties.SETTING_TYPE]: 'limit_price', + [PerpsEventProperties.INPUT_METHOD]: inputMethod, + [PerpsEventProperties.ASSET]: asset, + [PerpsEventProperties.DIRECTION]: direction, + }); + } + // Only call onConfirm; parent controls visibility. Avoid calling onClose here // to distinguish between confirm vs dismiss (onClose used for cancel/dismiss). onConfirm(cleanPrice); @@ -127,6 +159,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ return; // Ignore input that would exceed 9 digits } setLimitPrice(value || ''); + setInputMethod(PerpsEventValues.INPUT_METHOD.KEYBOARD); }, [], ); @@ -334,23 +367,69 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY} - {/* Quick percentage buttons - Direction-specific presets using config */} + {/* Quick preset buttons - Mid/Bid/Ask + percentage presets */} + {/* Mid price button - uses currentPrice which is the mid price from allMids stream */} + { + if (currentPrice) { + setLimitPrice( + formatWithSignificantDigits(currentPrice, 4).value.toString(), + ); + setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET); + } + }} + > + + {strings('perps.order.limit_price_modal.mid_price')} + + + {direction === 'long' ? ( - // For long orders: buy below market using LONG_PRESETS + // For long orders: Mid, Bid, -1%, -2% <> - {LIMIT_PRICE_CONFIG.LONG_PRESETS.map((percentage) => ( - + {/* Bid price button */} + { + const price = bidPrice || currentPriceData?.price; + if (price) { setLimitPrice( formatWithSignificantDigits( - parseFloat(calculatePriceForPercentage(percentage)), + parseFloat(price), 4, ).value.toString(), - ) + ); + setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET); } + }} + > + + {strings('perps.order.limit_price_modal.bid_price')} + + + + {/* Percentage presets */} + {LIMIT_PRICE_CONFIG.LONG_PRESETS.map((percentage) => ( + { + const calculatedPrice = + calculatePriceForPercentage(percentage); + if (calculatedPrice) { + setLimitPrice( + formatWithSignificantDigits( + parseFloat(calculatedPrice), + 4, + ).value.toString(), + ); + setInputMethod( + PerpsEventValues.INPUT_METHOD.PERCENTAGE_BUTTON, + ); + } + }} > {percentage > 0 ? '+' : ''} @@ -360,15 +439,49 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ ))} ) : ( - // For short orders: sell above market using SHORT_PRESETS + // For short orders: Mid, Ask, +1%, +2% <> + {/* Ask price button */} + { + const price = askPrice || currentPriceData?.price; + if (price) { + setLimitPrice( + formatWithSignificantDigits( + parseFloat(price), + 4, + ).value.toString(), + ); + setInputMethod(PerpsEventValues.INPUT_METHOD.PRESET); + } + }} + > + + {strings('perps.order.limit_price_modal.ask_price')} + + + + {/* Percentage presets */} {LIMIT_PRICE_CONFIG.SHORT_PRESETS.map((percentage) => ( - setLimitPrice(calculatePriceForPercentage(percentage)) - } + onPress={() => { + const calculatedPrice = + calculatePriceForPercentage(percentage); + if (calculatedPrice) { + setLimitPrice( + formatWithSignificantDigits( + parseFloat(calculatedPrice), + 4, + ).value.toString(), + ); + setInputMethod( + PerpsEventValues.INPUT_METHOD.PERCENTAGE_BUTTON, + ); + } + }} > {percentage > 0 ? '+' : ''} diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx index f154163cb5b..4b113f1ace9 100644 --- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.test.tsx @@ -210,7 +210,7 @@ describe('PerpsMarketTradesList', () => { render(); - expect(screen.queryByText('See all')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('see-all-button')).not.toBeOnTheScreen(); }); }); @@ -237,7 +237,7 @@ describe('PerpsMarketTradesList', () => { expect(screen.getByText('Recent activity')).toBeOnTheScreen(); }); - it('does not render See all button when empty', () => { + it('renders See all button when empty', () => { mockUsePerpsLiveFills.mockReturnValue({ fills: [], isInitialLoading: false, @@ -245,7 +245,7 @@ describe('PerpsMarketTradesList', () => { render(); - expect(screen.queryByText('See all')).not.toBeOnTheScreen(); + expect(screen.getByTestId('see-all-button')).toBeOnTheScreen(); }); }); @@ -272,7 +272,7 @@ describe('PerpsMarketTradesList', () => { render(); expect(screen.getByText('Recent activity')).toBeOnTheScreen(); - expect(screen.getByText('See all')).toBeOnTheScreen(); + expect(screen.getByTestId('see-all-button')).toBeOnTheScreen(); }); it('renders trade subtitles correctly', () => { @@ -347,7 +347,7 @@ describe('PerpsMarketTradesList', () => { render(); - const seeAllButton = screen.getByText('See all'); + const seeAllButton = screen.getByTestId('see-all-button'); fireEvent.press(seeAllButton); expect(mockNavigate).toHaveBeenCalledTimes(1); diff --git a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx index 1e0eba09cd3..730ddc955dd 100644 --- a/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTradesList/PerpsMarketTradesList.tsx @@ -137,8 +137,8 @@ const PerpsMarketTradesList: React.FC = ({ {strings('perps.market.recent_trades')} - {!isLoading && trades.length > 0 && ( - + {!isLoading && ( + {strings('perps.home.see_all')} diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts index c52e9a587f5..cc863692224 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.styles.ts @@ -89,10 +89,10 @@ const createStyles = (params: { flexDirection: 'column', alignItems: 'stretch', gap: 16, + marginBottom: 16, }, skipButton: { paddingHorizontal: 16, - marginBottom: 16, alignSelf: 'center', opacity: params.vars.shouldShowSkipButton ? 1 : 0, }, diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx index 236fa44a1bc..4b3ff1f5e29 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx @@ -262,7 +262,7 @@ describe('PerpsTutorialCarousel', () => { expect(mockMarkTutorialCompleted).toHaveBeenCalled(); }); - it('enables skip button on last screen for eligible users', async () => { + it("shows Learn more and Let's go buttons on last screen", async () => { render(); // Navigate to the last screen @@ -273,9 +273,14 @@ describe('PerpsTutorialCarousel', () => { screen.getByText(strings('perps.tutorial.ready_to_trade.title')), ).toBeOnTheScreen(); - // Skip button should be enabled on last screen for eligible users - const skipButton = screen.getByTestId('perps-tutorial-skip-button'); - expect(skipButton.props.disabled).toBe(false); + // Skip button is hidden on last screen + expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull(); + + // Learn more button should be visible on last screen + const learnMoreButton = screen.getByTestId( + 'perps-tutorial-learn-more-button', + ); + expect(learnMoreButton).toBeOnTheScreen(); // Main "Let's go" button should be visible const continueButton = screen.getByTestId( @@ -375,7 +380,7 @@ describe('PerpsTutorialCarousel', () => { } }); - it('shows "Got it" button on last screen for eligible users', async () => { + it('shows "Let\'s go" and "Learn more" buttons on last screen for eligible users', async () => { render(); // Navigate through all screens to get to last screen @@ -392,9 +397,14 @@ describe('PerpsTutorialCarousel', () => { ); expect(continueButton).toBeOnTheScreen(); - // Skip button should be enabled for eligible users on last screen - const skipButton = screen.getByTestId('perps-tutorial-skip-button'); - expect(skipButton.props.disabled).toBe(false); + // Learn more button should be visible on last screen + const learnMoreButton = screen.getByTestId( + 'perps-tutorial-learn-more-button', + ); + expect(learnMoreButton).toBeOnTheScreen(); + + // Skip button is hidden on last screen + expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull(); }); it('navigates to perps home when eligible user completes tutorial', async () => { @@ -509,7 +519,7 @@ describe('PerpsTutorialCarousel', () => { ).not.toBeOnTheScreen(); }); - it('shows "Let\'s go" button and disables skip button on last screen for non-eligible users', async () => { + it('shows "Let\'s go" button and hides skip button on last screen for non-eligible users', async () => { render(); // Navigate through all screens to get to last screen (4 clicks for 5 screens) @@ -526,9 +536,8 @@ describe('PerpsTutorialCarousel', () => { ); expect(continueButton).toBeOnTheScreen(); - // Skip button should be disabled on last screen - const skipButton = screen.getByTestId('perps-tutorial-skip-button'); - expect(skipButton.props.disabled).toBe(true); + // Skip button is hidden on last screen (for both eligible and non-eligible users) + expect(screen.queryByTestId('perps-tutorial-skip-button')).toBeNull(); }); it('shows skip button for non-eligible users on non-last screens', () => { diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx index bc2e86c756e..b4ba78cfa2b 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx @@ -26,6 +26,7 @@ import Text, { import { useStyles } from '../../../../../component-library/hooks'; import Routes from '../../../../../constants/navigation/Routes'; import NavigationService from '../../../../../core/NavigationService'; +import { EXTERNAL_LINK_TYPE } from '../../../../../constants/browser'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import { PerpsEventProperties, @@ -118,7 +119,6 @@ const getTutorialScreens = (isEligible: boolean): TutorialScreen[] => { id: 'ready_to_trade', title: strings('perps.tutorial.ready_to_trade.title'), description: strings('perps.tutorial.ready_to_trade.description'), - footerText: strings('perps.tutorial.ready_to_trade.footer_text'), riveArtboardName: PERPS_RIVE_ARTBOARD_NAMES.READY, }; @@ -357,6 +357,18 @@ const PerpsTutorialCarousel: React.FC = () => { navigateToMarketsList, ]); + const handleLearnMore = useCallback(() => { + NavigationService.navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: 'https://support.metamask.io/manage-crypto/trade/perps', + linkType: EXTERNAL_LINK_TYPE, + timestamp: Date.now(), + fromPerps: true, + }, + }); + }, []); + const renderTabBar = () => ; const buttonLabel = useMemo(() => { @@ -464,6 +476,16 @@ const PerpsTutorialCarousel: React.FC = () => { {/* Footer */} + {isLastScreen && ( +