diff --git a/.github/workflows/flaky-test-report.yml b/.github/workflows/flaky-test-report.yml index ee604b335ac..66281d23ca7 100644 --- a/.github/workflows/flaky-test-report.yml +++ b/.github/workflows/flaky-test-report.yml @@ -4,7 +4,23 @@ on: schedule: # Once every day from Monday-Friday at 08:00 UTC - cron: 0 8 * * 1-5 + # 10:00 UTC (Europe morning), Monday–Friday — Playwright test health report + - cron: '0 10 * * 1-5' + # 15:00 UTC (US morning), Monday–Friday — Playwright test health report + - cron: '0 15 * * 1-5' workflow_dispatch: + inputs: + job: + description: Which test report to generate + type: choice + options: + - detox + - appium + default: detox + lookback_days: + description: Number of days of main CI runs to scan (Appium report only) + type: string + default: '1' permissions: contents: read @@ -12,6 +28,9 @@ permissions: jobs: flaky-test-report: + if: >- + (github.event_name == 'schedule' && github.event.schedule == '0 8 * * 1-5') || + (github.event_name == 'workflow_dispatch' && inputs.job == 'detox') runs-on: ubuntu-latest steps: - name: Generate flaky test report @@ -21,3 +40,19 @@ jobs: workflow-id: ci.yml github-token: ${{ secrets.GITHUB_TOKEN }} slack-webhook-flaky-tests: ${{ secrets.SLACK_WEBHOOK_FLAKY_TESTS }} + + playwright-test-health-report: + if: >- + (github.event_name == 'schedule' && (github.event.schedule == '0 10 * * 1-5' || github.event.schedule == '0 15 * * 1-5')) || + (github.event_name == 'workflow_dispatch' && inputs.job == 'appium') + runs-on: ubuntu-latest + steps: + - name: Generate Playwright test health report + uses: MetaMask/github-tools/.github/actions/playwright-test-health-report@4a2ddd75e9a8182261f0f07fbfc332cc856c89a0 + with: + repository: ${{ github.event.repository.name }} + workflow-ids: ci.yml + github-token: ${{ secrets.GITHUB_TOKEN }} + slack-webhook: ${{ secrets.SLACK_WEBHOOK_PLAYWRIGHT_HEALTH_REPORT }} + lookback-days: ${{ github.event_name == 'workflow_dispatch' && inputs.lookback_days || '1' }} + report-title: Playwright Test Health Report (Mobile Appium) diff --git a/.js.env.example b/.js.env.example index e0e4990f340..79ee93a4ed1 100644 --- a/.js.env.example +++ b/.js.env.example @@ -145,9 +145,11 @@ export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01" # Money Hub export MM_MONEY_HUB_ENABLED="false" +# Money export MM_MONEY_PAYMENT_TOKENS_BLOCKLIST="" export MM_MONEY_DEPOSIT_NO_FEE_TOKENS="" export MM_MONEY_DEPOSIT_MIN_ASSET_BALANCE="0.01" +export MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED="true" # Activates remote feature flag override mode. # Remote feature flag values won't be updated, diff --git a/app/animations/money_account_first_time_deposit_with_parallax.riv b/app/animations/money_account_first_time_deposit_with_parallax.riv new file mode 100644 index 00000000000..23c12a800c5 Binary files /dev/null and b/app/animations/money_account_first_time_deposit_with_parallax.riv differ diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 2ebd5812e12..bee7ae3ba07 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -127,6 +127,7 @@ import { } from '../../UI/Money/routes'; import MoneyOnboardingView from '../../UI/Money/Views/MoneyOnboardingView'; import MoneyPotentialEarningsView from '../../UI/Money/Views/MoneyPotentialEarningsView'; +import MoneyFirstTimeDepositView from '../../UI/Money/Views/MoneyFirstTimeDepositView'; import { selectMoneyEnableMoneyAccountFlag } from '../../UI/Money/selectors/featureFlags'; import { selectIsMoneyAccountGeoEligible } from '../../UI/Money/selectors/eligibility'; import { BridgeTransactionDetails } from '../../UI/Bridge/components/TransactionDetails/TransactionDetails'; @@ -1221,6 +1222,15 @@ const MainNavigator = () => { component={MoneyOnboardingView} options={{ headerShown: false, ...fadeNativeOptions }} /> + ({ mockSelectMarketInsightsPerpsEnabled(state), })); +jest.mock('../../UI/Money/Views/MoneyFirstTimeDepositView', () => ({ + __esModule: true, + default: () => null, +})); + jest.mock('../../hooks/useAnalytics/useAnalytics'); jest.mock('../../UI/Money/components/MoneyTabPressTracker', () => ({ diff --git a/app/components/UI/Bridge/_mocks_/initialState.ts b/app/components/UI/Bridge/_mocks_/initialState.ts index d93addab3e5..9492e6d277a 100644 --- a/app/components/UI/Bridge/_mocks_/initialState.ts +++ b/app/components/UI/Bridge/_mocks_/initialState.ts @@ -15,12 +15,9 @@ import { ethers } from 'ethers'; import { formatChainIdToCaip, StatusTypes } from '@metamask/bridge-controller'; import { AccountTreeControllerState } from '@metamask/account-tree-controller'; -jest.mock( - '../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion', - () => ({ - hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), - }), -); +jest.mock('../../../../util/remoteFeatureFlag', () => ({ + hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), +})); export const ethChainId = '0x1' as Hex; export const optimismChainId = '0xa' as Hex; diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx index f5db00dd0bd..fe9516606b4 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx @@ -84,12 +84,9 @@ jest.mock('../../../../../hooks', () => ({ // Mock hasMinimumRequiredVersion so that selectBridgeFeatureFlags does not call // compareVersions (which requires a real app version string unavailable in tests). -jest.mock( - '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion', - () => ({ - hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), - }), -); +jest.mock('../../../../../util/remoteFeatureFlag', () => ({ + hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), +})); // Mock navigation const mockNavigate = jest.fn(); diff --git a/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx b/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx index e58d41c394b..bbe0f7146e0 100644 --- a/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx +++ b/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx @@ -51,12 +51,9 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ replace: mockReplace }), })); -jest.mock( - '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion', - () => ({ - hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), - }), -); +jest.mock('../../../../../util/remoteFeatureFlag', () => ({ + hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), +})); import { useParams } from '../../../../../util/navigation/navUtils'; import { useLatestBalance } from '../../hooks/useLatestBalance'; diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx b/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx index a8b5ade2500..f16d2279c1c 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx @@ -25,12 +25,9 @@ jest.mock('../../utils/quoteUtils', () => ({ shouldRefreshQuote: jest.fn(), })); -jest.mock( - '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion', - () => ({ - hasMinimumRequiredVersion: jest.fn(() => true), - }), -); +jest.mock('../../../../../util/remoteFeatureFlag', () => ({ + hasMinimumRequiredVersion: jest.fn(() => true), +})); jest.mock('@metamask/bridge-controller', () => { const actual = jest.requireActual('@metamask/bridge-controller'); diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx index f3ef369cd01..3d6d571f753 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx @@ -14,12 +14,9 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); -jest.mock( - '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion', - () => ({ - hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), - }), -); +jest.mock('../../../../../util/remoteFeatureFlag', () => ({ + hasMinimumRequiredVersion: jest.fn().mockReturnValue(true), +})); describe('useBridgeQuoteEvents', () => { beforeEach(() => { diff --git a/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts b/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts index 20c5721e52d..fcffaf113d5 100644 --- a/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts +++ b/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts @@ -19,12 +19,9 @@ jest.mock('../../../../core/Engine', () => ({ })); const mockHasMinimumRequiredVersion = jest.fn(); -jest.mock( - '../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion', - () => ({ - hasMinimumRequiredVersion: () => mockHasMinimumRequiredVersion(), - }), -); +jest.mock('../../../../util/remoteFeatureFlag', () => ({ + hasMinimumRequiredVersion: () => mockHasMinimumRequiredVersion(), +})); const mockPopularTokens = [ createMockPopularToken({ diff --git a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx index 922310e5349..a8c0ce172d0 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx @@ -10,6 +10,10 @@ const mockEventBuilder = { build: jest.fn().mockReturnValue({ event: 'built' }), }; +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn(() => '99.0.0'), +})); + jest.mock('../../../../../component-library/components/Toast', () => { const React = jest.requireActual('react'); const ToastContext = React.createContext({ toastRef: null }); @@ -36,6 +40,9 @@ jest.mock('../../util/metrics', () => ({ CardActions: { CASHBACK_BUTTON: 'CASHBACK_BUTTON', }, + CardEntryPoint: { + CASHBACK: 'CASHBACK', + }, })); jest.mock('@react-navigation/native', () => { @@ -78,6 +85,12 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'You need at least one approved funding source on Linea before redeeming cashback.', 'card.cashback_screen.funding_required.confirm_button_label': 'Set up funding', + 'card.cashback_screen.money_account_required.title': + 'Set up Money Account', + 'card.cashback_screen.money_account_required.description': + 'You need a linked Money Account before redeeming cashback.', + 'card.cashback_screen.money_account_required.confirm_button_label': + 'Set up Money Account', }; return translations[key] ?? key; }, @@ -86,6 +99,38 @@ jest.mock('../../../../../../locales/i18n', () => ({ const mockWithdraw = jest.fn(); const mockFetchEstimation = jest.fn().mockResolvedValue(undefined); const mockResetWithdraw = jest.fn(); +const mockStartLinkFlow = jest.fn(); + +let mockLinkageReturn = { + startLinkFlow: mockStartLinkFlow, + canLink: true, +}; + +jest.mock('../../hooks/useMoneyAccountCardLinkage', () => ({ + useMoneyAccountCardLinkage: jest.fn(() => mockLinkageReturn), +})); + +const mockSelectCardHasApprovedLineaFunding = jest.fn(); +const mockSelectCardHomeDataStatus = jest.fn(); +const mockSelectCardLineaUsdcToken = jest.fn(); +const mockSelectIsCardResidencyBlocked = jest.fn(); +const mockSelectIsMoneyAccountDelegatedForCard = jest.fn(); +const mockSelectMoneyEnableMoneyAccountFlag = jest.fn(); + +jest.mock('../../../../../selectors/cardController', () => ({ + selectCardHasApprovedLineaFunding: () => + mockSelectCardHasApprovedLineaFunding(), + selectCardHomeDataStatus: () => mockSelectCardHomeDataStatus(), + selectCardLineaUsdcToken: () => mockSelectCardLineaUsdcToken(), + selectIsCardResidencyBlocked: () => mockSelectIsCardResidencyBlocked(), + selectIsMoneyAccountDelegatedForCard: () => + mockSelectIsMoneyAccountDelegatedForCard(), +})); + +jest.mock('../../../Money/selectors/featureFlags', () => ({ + selectMoneyEnableMoneyAccountFlag: () => + mockSelectMoneyEnableMoneyAccountFlag(), +})); let mockHookReturn = { cashbackWallet: null as { @@ -124,7 +169,8 @@ import { ToastContext } from '../../../../../component-library/components/Toast' import Cashback from './Cashback'; import { CashbackSelectors } from './Cashback.testIds'; import Routes from '../../../../../constants/navigation/Routes'; -import { FundingAssetStatus } from '../../../../../core/Engine/controllers/card-controller/provider-types'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { CASHBACK_MONEY_ACCOUNT_ORIGIN } from '../../hooks/useCardPostAuthRedirect'; const mockToastRef = { current: { @@ -133,48 +179,12 @@ const mockToastRef = { }, }; -const mockLineaUsdcFundingAsset = { +const MOCK_LINEA_USDC_TOKEN = { symbol: 'USDC', name: 'USD Coin', address: '0xusdc000000000000000000000000000000000001', - walletAddress: '0xwallet000000000000000000000000000000000002', decimals: 6, - chainId: 'eip155:59144', - spendableBalance: '10', - spendingCap: '100', - priority: 1, - status: FundingAssetStatus.Active, -}; - -const mockLineaUsdcDelegationSettings = { - networks: [ - { - network: 'linea', - environment: 'production', - chainId: '59144', - delegationContract: '0xdeleg000000000000000000000000000000000004', - tokens: { - USDC: { - address: '0xusdc000000000000000000000000000000000001', - symbol: 'USDC', - decimals: 6, - }, - }, - }, - ], - count: 1, - _links: { self: '/v1/delegation/chain/config' }, -}; - -const mockCardHomeData = { - primaryFundingAsset: mockLineaUsdcFundingAsset, - fundingAssets: [mockLineaUsdcFundingAsset], - availableFundingAssets: [mockLineaUsdcFundingAsset], - card: null, - account: null, - alerts: [], - actions: [], - delegationSettings: mockLineaUsdcDelegationSettings, + caipChainId: 'eip155:59144', }; const CashbackWithToast = () => ( @@ -183,36 +193,47 @@ const CashbackWithToast = () => ( ); -function render(cardControllerOverrides = {}) { +interface SelectorOverrides { + hasApprovedLineaFunding?: boolean; + cardHomeDataStatus?: string; + lineaUsdcToken?: unknown; + residencyBlocked?: boolean; + moneyAccountDelegated?: boolean; + moneyAccountEnabled?: boolean; +} + +function render(overrides: SelectorOverrides = {}) { + if (overrides.hasApprovedLineaFunding !== undefined) { + mockSelectCardHasApprovedLineaFunding.mockReturnValue( + overrides.hasApprovedLineaFunding, + ); + } + if (overrides.cardHomeDataStatus !== undefined) { + mockSelectCardHomeDataStatus.mockReturnValue(overrides.cardHomeDataStatus); + } + if (overrides.lineaUsdcToken !== undefined) { + mockSelectCardLineaUsdcToken.mockReturnValue(overrides.lineaUsdcToken); + } + if (overrides.residencyBlocked !== undefined) { + mockSelectIsCardResidencyBlocked.mockReturnValue( + overrides.residencyBlocked, + ); + } + if (overrides.moneyAccountDelegated !== undefined) { + mockSelectIsMoneyAccountDelegatedForCard.mockReturnValue( + overrides.moneyAccountDelegated, + ); + } + if (overrides.moneyAccountEnabled !== undefined) { + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue( + overrides.moneyAccountEnabled, + ); + } + return renderScreen( CashbackWithToast, - { - name: 'Cashback', - }, - { - state: { - engine: { - backgroundState: { - PreferencesController: { - isIpfsGatewayEnabled: true, - }, - MoneyAccountController: { - moneyAccounts: {}, - }, - CardController: { - selectedCountry: null, - activeProviderId: 'baanx', - isAuthenticated: true, - cardholderAccounts: [], - providerData: {}, - cardHomeData: mockCardHomeData, - cardHomeDataStatus: 'success', - ...cardControllerOverrides, - }, - }, - }, - }, - }, + { name: 'Cashback' }, + { state: { engine: { backgroundState } } }, ); } @@ -220,6 +241,18 @@ describe('Cashback Component', () => { beforeEach(() => { jest.clearAllMocks(); mockCreateEventBuilder.mockReturnValue(mockEventBuilder); + mockStartLinkFlow.mockReset(); + mockLinkageReturn = { + startLinkFlow: mockStartLinkFlow, + canLink: true, + }; + + mockSelectCardHasApprovedLineaFunding.mockReturnValue(true); + mockSelectCardHomeDataStatus.mockReturnValue('success'); + mockSelectCardLineaUsdcToken.mockReturnValue(MOCK_LINEA_USDC_TOKEN); + mockSelectIsCardResidencyBlocked.mockReturnValue(false); + mockSelectIsMoneyAccountDelegatedForCard.mockReturnValue(false); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false); mockHookReturn = { cashbackWallet: null, @@ -423,12 +456,7 @@ describe('Cashback Component', () => { describe('Linea funding requirement', () => { it('shows a warning when the user has no approved Linea funding', () => { - render({ - cardHomeData: { - ...mockCardHomeData, - fundingAssets: [], - }, - }); + render({ hasApprovedLineaFunding: false }); expect( screen.getByTestId(CashbackSelectors.FUNDING_WARNING), @@ -442,12 +470,7 @@ describe('Cashback Component', () => { }); it('navigates to Spending Limit with USDC on Linea pre-selected', () => { - render({ - cardHomeData: { - ...mockCardHomeData, - fundingAssets: [], - }, - }); + render({ hasApprovedLineaFunding: false }); fireEvent.press(screen.getByText('Set up funding')); @@ -474,12 +497,7 @@ describe('Cashback Component', () => { price: '0.50', }; - render({ - cardHomeData: { - ...mockCardHomeData, - fundingAssets: [], - }, - }); + render({ hasApprovedLineaFunding: false }); fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON)); @@ -500,10 +518,7 @@ describe('Cashback Component', () => { price: '0.50', }; - render({ - cardHomeData: null, - cardHomeDataStatus: 'error', - }); + render({ cardHomeDataStatus: 'error' }); expect( screen.queryByTestId(CashbackSelectors.FUNDING_WARNING), @@ -522,6 +537,208 @@ describe('Cashback Component', () => { }); }); + describe('Money Account funding requirement (supported regions)', () => { + const setWithdrawableWallet = () => { + mockHookReturn.cashbackWallet = { + id: 'w1', + balance: '10.00', + currency: 'musd', + isWithdrawable: true, + type: 'reward', + }; + mockHookReturn.estimation = { + wei: '100000', + eth: '0.0001', + price: '0.50', + }; + }; + + it('shows a Money Account warning and starts link flow when no Money Account is delegated', () => { + setWithdrawableWallet(); + + render({ moneyAccountEnabled: true, hasApprovedLineaFunding: false }); + + expect( + screen.getByTestId(CashbackSelectors.FUNDING_WARNING), + ).toBeOnTheScreen(); + expect( + screen.getAllByText('Set up Money Account').length, + ).toBeGreaterThan(0); + expect( + screen.getByText( + 'You need a linked Money Account before redeeming cashback.', + ), + ).toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId('confirm-button')); + + expect(mockStartLinkFlow).toHaveBeenCalledWith( + CASHBACK_MONEY_ACCOUNT_ORIGIN, + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('hides the Money Account setup CTA when the link flow cannot complete (canLink is false)', () => { + setWithdrawableWallet(); + mockLinkageReturn = { + startLinkFlow: mockStartLinkFlow, + canLink: false, + }; + + render({ moneyAccountEnabled: true, hasApprovedLineaFunding: false }); + + expect( + screen.queryByTestId(CashbackSelectors.FUNDING_WARNING), + ).not.toBeOnTheScreen(); + expect(mockStartLinkFlow).not.toHaveBeenCalled(); + + fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON)); + + expect(mockWithdraw).not.toHaveBeenCalled(); + }); + + it('blocks withdrawal when no Money Account is delegated in a supported region', () => { + setWithdrawableWallet(); + + render({ moneyAccountEnabled: true, hasApprovedLineaFunding: false }); + + fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON)); + + expect(mockWithdraw).not.toHaveBeenCalled(); + }); + + it('allows withdrawal when Money Account is delegated in a supported region', () => { + setWithdrawableWallet(); + + render({ + moneyAccountEnabled: true, + moneyAccountDelegated: true, + hasApprovedLineaFunding: false, + }); + + expect( + screen.queryByTestId(CashbackSelectors.FUNDING_WARNING), + ).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON)); + + expect(mockWithdraw).toHaveBeenCalledWith('9.5'); + }); + }); + + describe('UK / restricted region funding requirement', () => { + const setWithdrawableWallet = () => { + mockHookReturn.cashbackWallet = { + id: 'w1', + balance: '10.00', + currency: 'musd', + isWithdrawable: true, + type: 'reward', + }; + mockHookReturn.estimation = { + wei: '100000', + eth: '0.0001', + price: '0.50', + }; + }; + + it('allows withdrawal when only Linea funding is approved', () => { + setWithdrawableWallet(); + + render({ + moneyAccountEnabled: true, + residencyBlocked: true, + hasApprovedLineaFunding: true, + moneyAccountDelegated: false, + }); + + expect( + screen.queryByTestId(CashbackSelectors.FUNDING_WARNING), + ).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON)); + + expect(mockWithdraw).toHaveBeenCalledWith('9.5'); + }); + + it('allows withdrawal when only Money Account is delegated', () => { + setWithdrawableWallet(); + + render({ + moneyAccountEnabled: true, + residencyBlocked: true, + hasApprovedLineaFunding: false, + moneyAccountDelegated: true, + }); + + expect( + screen.queryByTestId(CashbackSelectors.FUNDING_WARNING), + ).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON)); + + expect(mockWithdraw).toHaveBeenCalledWith('9.5'); + }); + + it('shows Linea warning and redirects to Spending Limit when neither destination is configured', () => { + setWithdrawableWallet(); + + render({ + moneyAccountEnabled: true, + residencyBlocked: true, + hasApprovedLineaFunding: false, + moneyAccountDelegated: false, + }); + + expect( + screen.getByTestId(CashbackSelectors.FUNDING_WARNING), + ).toBeOnTheScreen(); + expect(screen.getByText('Set up Linea funding')).toBeOnTheScreen(); + + fireEvent.press(screen.getByText('Set up funding')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.SPENDING_LIMIT, { + flow: 'enable', + selectedToken: expect.objectContaining({ + symbol: 'USDC', + caipChainId: 'eip155:59144', + }), + }); + expect(mockStartLinkFlow).not.toHaveBeenCalled(); + }); + }); + + describe('Money Account feature flag disabled', () => { + it('uses the UK-style Linea or Money Account gate when the Money Account FF is off', () => { + mockHookReturn.cashbackWallet = { + id: 'w1', + balance: '10.00', + currency: 'musd', + isWithdrawable: true, + type: 'reward', + }; + mockHookReturn.estimation = { + wei: '100000', + eth: '0.0001', + price: '0.50', + }; + + render({ + moneyAccountEnabled: false, + moneyAccountDelegated: true, + hasApprovedLineaFunding: false, + }); + + expect( + screen.queryByTestId(CashbackSelectors.FUNDING_WARNING), + ).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON)); + + expect(mockWithdraw).toHaveBeenCalledWith('9.5'); + }); + }); + describe('withdraw action', () => { it('calls withdraw with net amount on button press', () => { mockHookReturn.cashbackWallet = { diff --git a/app/components/UI/Card/Views/Cashback/Cashback.tsx b/app/components/UI/Card/Views/Cashback/Cashback.tsx index f0e178854c4..0e43aa74a67 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.tsx @@ -22,6 +22,8 @@ import { } from '../../../../../component-library/components/Toast'; import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow/KeyValueRow'; import useCashbackWallet from '../../hooks/useCashbackWallet'; +import { useMoneyAccountCardLinkage } from '../../hooks/useMoneyAccountCardLinkage'; +import { CASHBACK_MONEY_ACCOUNT_ORIGIN } from '../../hooks/useCardPostAuthRedirect'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions } from '../../util/metrics'; @@ -32,7 +34,10 @@ import { selectCardHasApprovedLineaFunding, selectCardHomeDataStatus, selectCardLineaUsdcToken, + selectIsCardResidencyBlocked, + selectIsMoneyAccountDelegatedForCard, } from '../../../../../selectors/cardController'; +import { selectMoneyEnableMoneyAccountFlag } from '../../../Money/selectors/featureFlags'; import CardMessageBox from '../../components/CardMessageBox/CardMessageBox'; import { CardMessageBoxType } from '../../types'; import Routes from '../../../../../constants/navigation/Routes'; @@ -52,8 +57,15 @@ const Cashback: React.FC = () => { const hasApprovedLineaFunding = useSelector( selectCardHasApprovedLineaFunding, ); + const isMoneyAccountDelegated = useSelector( + selectIsMoneyAccountDelegatedForCard, + ); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); + const isResidencyBlocked = useSelector(selectIsCardResidencyBlocked); const lineaUsdcToken = useSelector(selectCardLineaUsdcToken); const cardHomeDataStatus = useSelector(selectCardHomeDataStatus); + const { startLinkFlow, canLink: canLinkMoneyAccount } = + useMoneyAccountCardLinkage(); const { cashbackWallet, @@ -81,11 +93,16 @@ const Cashback: React.FC = () => { cardHomeDataStatus === 'idle' || cardHomeDataStatus === 'loading'; const hasFundingStatusError = cardHomeDataStatus === 'error'; const isFundingStatusLoaded = cardHomeDataStatus === 'success'; - const requiresLineaFunding = - isFundingStatusLoaded && !hasApprovedLineaFunding; + const useMoneyAccountFlow = isMoneyAccountEnabled && !isResidencyBlocked; + const hasApprovedRedemptionDestination = useMoneyAccountFlow + ? isMoneyAccountDelegated + : hasApprovedLineaFunding || isMoneyAccountDelegated; + const needsSetup = isFundingStatusLoaded && !hasApprovedRedemptionDestination; const isFundingStatusUnavailable = isFundingStatusLoading || hasFundingStatusError; const showLoadingError = !!error || hasFundingStatusError; + const showSetupBanner = + needsSetup && (!useMoneyAccountFlow || canLinkMoneyAccount); useEffect(() => { if (cashbackWallet) { @@ -150,7 +167,7 @@ const Cashback: React.FC = () => { ); const handleWithdraw = useCallback(() => { - if (requiresLineaFunding || isFundingStatusUnavailable) return; + if (needsSetup || isFundingStatusUnavailable) return; trackEvent( createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) @@ -166,7 +183,7 @@ const Cashback: React.FC = () => { withdraw, trackEvent, createEventBuilder, - requiresLineaFunding, + needsSetup, isFundingStatusUnavailable, ]); @@ -177,13 +194,22 @@ const Cashback: React.FC = () => { }); }, [navigation, lineaUsdcToken]); + const handleSetupPress = useCallback(() => { + if (useMoneyAccountFlow) { + startLinkFlow(CASHBACK_MONEY_ACCOUNT_ORIGIN); + return; + } + + handleNavigateToSpendingLimit(); + }, [useMoneyAccountFlow, startLinkFlow, handleNavigateToSpendingLimit]); + const isProcessing = isWithdrawing || monitoringStatus === 'monitoring'; const buttonLabel = useMemo(() => { if ( !isWithdrawable || hasInsufficientBalance || - requiresLineaFunding || + needsSetup || isFundingStatusUnavailable ) { return strings('card.cashback_screen.withdraw_unavailable'); @@ -192,10 +218,14 @@ const Cashback: React.FC = () => { }, [ isWithdrawable, hasInsufficientBalance, - requiresLineaFunding, + needsSetup, isFundingStatusUnavailable, ]); + const fundingWarningMessageType = useMoneyAccountFlow + ? CardMessageBoxType.CashbackMoneyAccountRequired + : CardMessageBoxType.CashbackFundingRequired; + const isButtonDisabled = isLoading || !isWithdrawable || @@ -203,7 +233,7 @@ const Cashback: React.FC = () => { isEstimating || hasInsufficientBalance || isFundingStatusUnavailable || - requiresLineaFunding; + needsSetup; return ( { {...headerHandlers} /> - {requiresLineaFunding ? ( + {showSetupBanner ? ( ) : null} diff --git a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx index 7806633cef4..11e2f6061e2 100644 --- a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx +++ b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx @@ -29,6 +29,12 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'You need at least one approved funding source on Linea before redeeming cashback.', 'card.cashback_screen.funding_required.confirm_button_label': 'Set up funding', + 'card.cashback_screen.money_account_required.title': + 'Set up Money Account', + 'card.cashback_screen.money_account_required.description': + 'You need a linked Money Account before redeeming cashback.', + 'card.cashback_screen.money_account_required.confirm_button_label': + 'Set up Money Account', }; return mockStrings[key] || key; }), @@ -220,6 +226,33 @@ describe('CardMessageBox', () => { }); }); + describe('CashbackMoneyAccountRequired warning', () => { + it('renders title and description', () => { + const { getByText } = renderWithProvider(() => ( + + )); + + expect(getByText('Set up Money Account')).toBeOnTheScreen(); + expect( + getByText('You need a linked Money Account before redeeming cashback.'), + ).toBeOnTheScreen(); + }); + + it('calls onConfirm when the Money Account setup button is pressed', () => { + const { getByTestId } = renderWithProvider(() => ( + + )); + + fireEvent.press(getByTestId('confirm-button')); + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + }); + describe('common behaviors', () => { it('renders dismiss button when onDismiss is provided', () => { const { getByTestId } = renderWithProvider(() => ( @@ -273,6 +306,7 @@ describe('CardMessageBox', () => { CardMessageBoxType.CardProvisioning, CardMessageBoxType.AuthPrompt, CardMessageBoxType.CashbackFundingRequired, + CardMessageBoxType.CashbackMoneyAccountRequired, ]; allMessageTypes.forEach((messageType) => { diff --git a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx index 8691e1a58fc..bfa155cd0d2 100644 --- a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx +++ b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx @@ -74,6 +74,16 @@ const CardMessageBox = ({ 'card.cashback_screen.funding_required.confirm_button_label', ), }, + [CardMessageBoxType.CashbackMoneyAccountRequired]: { + variant: CardMessageBoxVariant.Warning, + title: strings('card.cashback_screen.money_account_required.title'), + description: strings( + 'card.cashback_screen.money_account_required.description', + ), + confirmButtonLabel: strings( + 'card.cashback_screen.money_account_required.confirm_button_label', + ), + }, }), [], ); diff --git a/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts b/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts index b8efa5bca4d..929990a69f3 100644 --- a/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts +++ b/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; import type { LinkFlowOrigin } from './useMoneyAccountCardLinkage'; +import { CardEntryPoint } from '../util/metrics'; export const MONEY_HOME_CARD_ORIGIN: LinkFlowOrigin = { screen: Routes.HOME_TABS, @@ -11,6 +12,11 @@ export const MONEY_HOME_CARD_ORIGIN: LinkFlowOrigin = { }, }; +export const CASHBACK_MONEY_ACCOUNT_ORIGIN: LinkFlowOrigin = { + screen: Routes.CARD.CASHBACK, + entrypoint: CardEntryPoint.CASHBACK, +}; + const isLinkFlowOrigin = (value: unknown): value is LinkFlowOrigin => typeof value === 'object' && value !== null && diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx index 8cb4ff321d0..b9c54064fe7 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx @@ -22,6 +22,7 @@ import { selectPendingMoneyAccountCardLink, setPendingMoneyAccountCardLink, } from '../../../../core/redux/slices/card'; +import { selectIsMoneyAccountGeoEligible } from '../../Money/selectors/eligibility'; import { selectMoneyEnableMoneyAccountFlag } from '../../Money/selectors/featureFlags'; import { resolveMoneyAccountCardToken } from '../../../../core/Engine/controllers/card-controller/utils/moneyAccountCardToken'; import Routes from '../../../../constants/navigation/Routes'; @@ -158,7 +159,7 @@ const buildSelectors = ( overrides: { primaryMoneyAccount?: { address: string } | undefined; vaultConfig?: { chainId?: string } | undefined; - isMoneyAccountEnabled?: boolean; + isMoneyAccountVisible?: boolean; isCardAuthenticated?: boolean; isCardVerified?: boolean; isCardholder?: boolean; @@ -175,7 +176,7 @@ const buildSelectors = ( ) => ({ primaryMoneyAccount: { address: MONEY_ACCOUNT_ADDRESS }, vaultConfig: { chainId: '0x8f' }, - isMoneyAccountEnabled: true, + isMoneyAccountVisible: true, isCardAuthenticated: true, isCardVerified: true, isCardholder: false, @@ -217,7 +218,8 @@ const applySelectorMocks = (state: ReturnType) => { return state.primaryMoneyAccount; if (selector === selectMoneyAccountVaultConfig) return state.vaultConfig; if (selector === selectMoneyEnableMoneyAccountFlag) - return state.isMoneyAccountEnabled; + return state.isMoneyAccountVisible; + if (selector === selectIsMoneyAccountGeoEligible) return true; if (selector === selectIsCardAuthenticated) return state.isCardAuthenticated; if (selector === selectIsCardVerified) return state.isCardVerified; @@ -319,7 +321,7 @@ describe('useMoneyAccountCardLinkage', () => { }); it('reports canLink=false when the feature flag is off', () => { - applySelectorMocks(buildSelectors({ isMoneyAccountEnabled: false })); + applySelectorMocks(buildSelectors({ isMoneyAccountVisible: false })); const { result } = renderLinkageHook(); expect(result.current.canLink).toBe(false); }); diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx index 118172e1271..695b1a64bbe 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx @@ -44,6 +44,7 @@ import { selectPendingMoneyAccountCardLink, setPendingMoneyAccountCardLink, } from '../../../../core/redux/slices/card'; +import { selectIsMoneyAccountGeoEligible } from '../../Money/selectors/eligibility'; import { selectMoneyEnableMoneyAccountFlag } from '../../Money/selectors/featureFlags'; import { hasMoneyAccountCardRequirements, @@ -123,6 +124,11 @@ export const useMoneyAccountCardLinkage = const isMoneyAccountEnabled = useSelector( selectMoneyEnableMoneyAccountFlag, ); + const isMoneyAccountGeoEligible = useSelector( + selectIsMoneyAccountGeoEligible, + ); + const isMoneyAccountVisible = + isMoneyAccountEnabled && isMoneyAccountGeoEligible; const isCardAuthenticated = useSelector(selectIsCardAuthenticated); const isCardVerified = useSelector(selectIsCardVerified); const isCardholder = useSelector(selectIsCardholder); @@ -161,7 +167,7 @@ export const useMoneyAccountCardLinkage = : null; const hasMoneyAccountBaseRequirements = hasMoneyAccountCardRequirements({ - isMoneyAccountEnabled, + isMoneyAccountEnabled: isMoneyAccountVisible, vaultConfig, moneyAccountAddress: primaryMoneyAccount?.address, }); diff --git a/app/components/UI/Card/types.ts b/app/components/UI/Card/types.ts index 83bdc798892..aee28fa1aec 100644 --- a/app/components/UI/Card/types.ts +++ b/app/components/UI/Card/types.ts @@ -39,6 +39,7 @@ export enum CardMessageBoxType { CardProvisioning = 'card_provisioning', AuthPrompt = 'auth_prompt', CashbackFundingRequired = 'cashback_funding_required', + CashbackMoneyAccountRequired = 'cashback_money_account_required', } export type CardUserPhase = diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts index c411fe0f5ae..ab950aa75d4 100644 --- a/app/components/UI/Card/util/metrics.ts +++ b/app/components/UI/Card/util/metrics.ts @@ -85,6 +85,7 @@ enum CardEntryPoint { CARD_HOME_MONEY_ACCOUNT_CARD = 'CARD_HOME_MONEY_ACCOUNT_CARD', MONEY_LINK_CARD_SHEET = 'MONEY_LINK_CARD_SHEET', SPENDING_LIMIT_SPEND_AND_EARN_PROMO = 'SPENDING_LIMIT_SPEND_AND_EARN_PROMO', + CASHBACK = 'CASHBACK', } enum CardFlow { diff --git a/app/components/UI/Card/util/residency.ts b/app/components/UI/Card/util/residency.ts index 2077566dbaf..80b842475ea 100644 --- a/app/components/UI/Card/util/residency.ts +++ b/app/components/UI/Card/util/residency.ts @@ -2,7 +2,7 @@ * Builds a residency region code from authenticated card KYC data. * US cardholders with a known state use the `US-{STATE}` pattern (e.g. US-CA) * so blocked-region lists can target specific states via - * `selectMusdConversionBlockedCountries`. + * `selectMoneyAccountGeoBlockedCountries`. */ export const buildCardResidencyRegion = ( countryOfResidence: string | null, diff --git a/app/components/UI/Card/util/truncateAddress.test.ts b/app/components/UI/Card/util/truncateAddress.test.ts index 9e618b7b294..1bcaeebd078 100644 --- a/app/components/UI/Card/util/truncateAddress.test.ts +++ b/app/components/UI/Card/util/truncateAddress.test.ts @@ -2,17 +2,23 @@ jest.mock('@metamask/utils', () => ({ isHexAddress: jest.fn(), })); +jest.mock('@solana/addresses', () => ({ + isAddress: jest.fn(), +})); + jest.mock('../../../../util/address', () => ({ safeToChecksumAddress: jest.fn(), })); import { isHexAddress } from '@metamask/utils'; +import { isAddress } from '@solana/addresses'; import { safeToChecksumAddress } from '../../../../util/address'; import { truncateAddress } from './truncateAddress'; const mockIsHexAddress = isHexAddress as jest.MockedFunction< typeof isHexAddress >; +const mockIsAddress = isAddress as jest.MockedFunction; const mockSafeToChecksumAddress = safeToChecksumAddress as jest.MockedFunction< typeof safeToChecksumAddress >; @@ -20,6 +26,30 @@ const mockSafeToChecksumAddress = safeToChecksumAddress as jest.MockedFunction< describe('truncateAddress', () => { beforeEach(() => { jest.clearAllMocks(); + mockIsAddress.mockReturnValue(false); + }); + + describe('valid Solana addresses', () => { + const solanaAddress = '7EcDhSYGxXyscQzY5BF8zY3j6ftUpF8E5uMEnDZmv7d'; + + it('truncates a Solana address with default chars without checksumming', () => { + mockIsAddress.mockReturnValue(true); + + const result = truncateAddress(solanaAddress); + + expect(mockIsAddress).toHaveBeenCalledWith(solanaAddress); + expect(mockIsHexAddress).not.toHaveBeenCalled(); + expect(mockSafeToChecksumAddress).not.toHaveBeenCalled(); + expect(result).toBe('7EcD...mv7d'); + }); + + it('truncates a Solana address with custom character count', () => { + mockIsAddress.mockReturnValue(true); + + const result = truncateAddress(solanaAddress, 6); + + expect(result).toBe('7EcDhS...DZmv7d'); + }); }); describe('valid hex addresses', () => { @@ -82,10 +112,12 @@ describe('truncateAddress', () => { describe('non-hex inputs (display labels)', () => { it('returns a non-hex string verbatim (so labels like "Money account" are not mangled)', () => { const label = 'Money account'; + mockIsAddress.mockReturnValue(false); mockIsHexAddress.mockReturnValue(false); const result = truncateAddress(label); + expect(mockIsAddress).toHaveBeenCalledWith(label); expect(mockIsHexAddress).toHaveBeenCalledWith(label); expect(mockSafeToChecksumAddress).not.toHaveBeenCalled(); expect(result).toBe(label); @@ -93,6 +125,7 @@ describe('truncateAddress', () => { it('returns an alphanumeric string without 0x prefix unchanged', () => { const value = '1234567890abcdef1234567890abcdef12345678'; + mockIsAddress.mockReturnValue(false); mockIsHexAddress.mockReturnValue(false); const result = truncateAddress(value); @@ -128,6 +161,7 @@ describe('truncateAddress', () => { it('returns a single non-hex character unchanged', () => { const value = 'x'; + mockIsAddress.mockReturnValue(false); mockIsHexAddress.mockReturnValue(false); const result = truncateAddress(value); diff --git a/app/components/UI/Card/util/truncateAddress.ts b/app/components/UI/Card/util/truncateAddress.ts index 6a91dd35fa0..7f3cbdfacde 100644 --- a/app/components/UI/Card/util/truncateAddress.ts +++ b/app/components/UI/Card/util/truncateAddress.ts @@ -1,5 +1,6 @@ import { isHexAddress } from '@metamask/utils'; import { safeToChecksumAddress } from '../../../../util/address'; +import { isAddress } from '@solana/addresses'; /** * Truncates a hex wallet address for display (e.g. `0x1234...5678`). @@ -16,6 +17,10 @@ export const truncateAddress = ( return undefined; } + if (isAddress(address)) { + return `${address.slice(0, chars)}...${address.slice(-chars)}`; + } + if (!isHexAddress(address)) { return address; } diff --git a/app/components/UI/Carousel/types.ts b/app/components/UI/Carousel/types.ts index 2b5404254ed..e8652b8eb36 100644 --- a/app/components/UI/Carousel/types.ts +++ b/app/components/UI/Carousel/types.ts @@ -1,5 +1,5 @@ import { ViewStyle } from 'react-native'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; +import { WalletClientType } from '../../../core/SnapKeyring/types'; import { CaipChainId } from '@metamask/utils'; interface NavigationParams { diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.test.tsx b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.test.tsx new file mode 100644 index 00000000000..ec864cec517 --- /dev/null +++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.test.tsx @@ -0,0 +1,456 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import { BackHandler } from 'react-native'; +import { Accelerometer } from 'expo-sensors'; +import MoneyFirstTimeDepositView from './MoneyFirstTimeDepositView'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; +import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; +import { SCREEN_NAMES } from '../../constants/moneyEvents'; +import { MoneyFirstTimeDepositViewTestIds } from './MoneyFirstTimeDepositView.testIds'; + +const mockNavigate = jest.fn(); +const mockTrackScreenViewed = jest.fn(); + +jest.mock('../../hooks/useMoneyAnalytics', () => ({ + useMoneyAnalytics: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('../../hooks/useMountEffect', () => ({ + __esModule: true, + default: (fn: () => void) => fn(), +})); + +let mockTriggerCallbacks: Record void> = {}; +const mockSetString = jest.fn(); +const mockSetNumber = jest.fn(); +const mockRemove = jest.fn(); +let mockOnError: ((error: { message: string }) => void) | undefined; + +jest.mock('rive-react-native', () => { + const mockRiveRef = { + setNumber: (...args: unknown[]) => mockSetNumber(...args), + }; + + return { + __esModule: true, + default: jest.fn(({ onError, ...props }) => { + mockOnError = onError; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native'); + return ; + }), + useRive: () => [jest.fn(), mockRiveRef], + useRiveString: () => [undefined, mockSetString], + useRiveTrigger: ( + _riveRef: unknown, + path: string, + callback?: () => void, + ) => { + if (callback) { + mockTriggerCallbacks[path] = callback; + } + return jest.fn(); + }, + AutoBind: (value: boolean) => ({ type: 'autobind', value }), + Fit: { Layout: 'layout' }, + RNRiveError: class {}, + }; +}); + +jest.mock( + '../../../../../animations/money_account_first_time_deposit_with_parallax.riv', + () => 1, + { virtual: true }, +); + +jest.mock('expo-sensors', () => ({ + Accelerometer: { + setUpdateInterval: jest.fn(), + addListener: jest.fn(), + }, +})); + +describe('MoneyFirstTimeDepositView', () => { + interface SensorData { + x: number; + y: number; + } + let sensorCallback: ((data: SensorData) => void) | undefined; + let rafCallback: ((time: number) => void) | undefined; + let originalRaf: typeof global.requestAnimationFrame; + let originalCaf: typeof global.cancelAnimationFrame; + + beforeEach(() => { + jest.clearAllMocks(); + mockTriggerCallbacks = {}; + mockOnError = undefined; + sensorCallback = undefined; + rafCallback = undefined; + + (Accelerometer.addListener as jest.Mock).mockImplementation( + (cb: (data: SensorData) => void) => { + sensorCallback = cb; + return { remove: mockRemove }; + }, + ); + + originalRaf = global.requestAnimationFrame; + originalCaf = global.cancelAnimationFrame; + global.requestAnimationFrame = jest.fn((cb: (time: number) => void) => { + rafCallback = cb; + return 0; + }); + global.cancelAnimationFrame = jest.fn(); + + (useMoneyAnalytics as jest.Mock).mockReturnValue({ + trackScreenViewed: mockTrackScreenViewed, + }); + }); + + afterEach(() => { + global.requestAnimationFrame = originalRaf; + global.cancelAnimationFrame = originalCaf; + }); + + describe('Rendering', () => { + it('renders the Rive animation component', () => { + const { getByTestId } = render(); + + expect( + getByTestId(MoneyFirstTimeDepositViewTestIds.RIVE_ANIMATION), + ).toBeOnTheScreen(); + }); + }); + + describe('Analytics', () => { + it('initializes useMoneyAnalytics with first-time deposit screen name', () => { + render(); + + expect(useMoneyAnalytics).toHaveBeenCalledWith({ + screen_name: SCREEN_NAMES.MONEY_FIRST_TIME_DEPOSIT, + }); + }); + + it('fires trackScreenViewed on mount', () => { + render(); + + expect(mockTrackScreenViewed).toHaveBeenCalledTimes(1); + }); + }); + + describe('Rive text initialization', () => { + it('sets title and content strings on mount', () => { + render(); + + expect(mockSetString).toHaveBeenCalledWith( + strings('money.first_time_deposit.title'), + ); + expect(mockSetString).toHaveBeenCalledWith( + strings('money.first_time_deposit.content'), + ); + }); + }); + + describe('Done trigger', () => { + it('navigates home when done trigger fires', () => { + render(); + + mockTriggerCallbacks.done(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }); + }); + }); + + describe('Error handling', () => { + it('navigates home when Rive reports an error', () => { + render(); + + mockOnError?.({ message: 'test rive error' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }); + }); + }); + + describe('Android back button', () => { + it('registers a BackHandler listener on mount', () => { + const addSpy = jest.spyOn(BackHandler, 'addEventListener'); + + render(); + + expect(addSpy).toHaveBeenCalledWith( + 'hardwareBackPress', + expect.any(Function), + ); + + addSpy.mockRestore(); + }); + + it('navigates home and returns true when hardware back is pressed', () => { + let backPressHandler: (() => boolean) | undefined; + jest + .spyOn(BackHandler, 'addEventListener') + .mockImplementation((_event, handler) => { + backPressHandler = handler as () => boolean; + return { remove: jest.fn() }; + }); + + render(); + + const result = backPressHandler?.(); + + expect(result).toBe(true); + expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }); + }); + }); + + describe('Parallax', () => { + // Controllable clock. `mockNow` starts at 0 so `calibrationStartedAt` + // (captured during render) is 0; tests advance it explicitly to cross the + // calibration window rather than relying on Date.now() call ordering. + let mockNow: number; + let dateSpy: jest.SpyInstance; + + beforeEach(() => { + mockNow = 0; + dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => mockNow); + }); + + afterEach(() => { + dateSpy.mockRestore(); + }); + + it('keeps parallax centered during calibration regardless of device tilt', () => { + // Clock never advances past CALIBRATION_DURATION_MS (300ms), so no + // baseline is ever computed and the calibration branch stays active. + render(); + + // A strong tilt arrives mid-calibration; it must be ignored. + act(() => { + sensorCallback?.({ x: 0.3, y: 0.3 }); + }); + + // One render frame. Targets are pinned to 50, so EMA smoothing leaves + // the pushed values at the centered default: 50 + 0.04 * (50 - 50) = 50. + act(() => { + rafCallback?.(0); + }); + + expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50); + expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50); + }); + + it('measures live parallax relative to the resting baseline', () => { + render(); + + // Advance past the calibration window, then take a calibration reading. + // Threshold crossed, so baseline = {0.1, 0.05}. + mockNow = 301; + act(() => { + sensorCallback?.({ x: 0.1, y: 0.05 }); + }); + + // Live reading identical to the baseline. After subtraction the g-force + // is 0, so gForceToParallax(0) = 50 (perfectly centered). Without baseline + // subtraction this would be gForceToParallax(0.1) = 60 instead. + act(() => { + sensorCallback?.({ x: 0.1, y: 0.05 }); + }); + + act(() => { + rafCallback?.(0); + }); + + // smoothed = 50 + 0.04 * (50 - 50) = 50 + expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50); + expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50); + }); + + it('averages multiple calibration samples into the resting baseline', () => { + render(); + + // First sample lands inside the calibration window (clock still 0). + act(() => { + sensorCallback?.({ x: 0.1, y: 0.1 }); + }); + + // Advance past the window, then a second sample. It is accumulated first + // (sum = 0.1 + 0.3 = 0.4 over count = 2) and only then is the baseline + // computed: 0.4 / 2 = 0.2 on both axes (a true average, not last-wins). + mockNow = 301; + act(() => { + sensorCallback?.({ x: 0.3, y: 0.3 }); + }); + + // Live reading. With averaged baseline 0.2: + // 0.4 - 0.2 = 0.2 -> gForceToParallax(0.2) = 70 on both axes + // If the baseline were last-wins (0.3) it would be gForceToParallax(0.1) + // = 60 instead, giving 50.4 below rather than 50.8. + act(() => { + sensorCallback?.({ x: 0.4, y: 0.4 }); + }); + + act(() => { + rafCallback?.(0); + }); + + // smoothed = 50 + 0.04 * (70 - 50) = 50.8 + expect(mockSetNumber).toHaveBeenCalledWith( + 'xValue', + expect.closeTo(50.8), + ); + expect(mockSetNumber).toHaveBeenCalledWith( + 'yValue', + expect.closeTo(50.8), + ); + }); + + it('converts tilt through gForceToParallax and EMA smoothing before pushing to Rive', () => { + render(); + + // Calibration reading establishes baseline = {0.1, 0.05}. + mockNow = 301; + act(() => { + sensorCallback?.({ x: 0.1, y: 0.05 }); + }); + + // Live reading. Baseline-relative g-force: + // x: 0.3 - 0.1 = 0.2 -> gForceToParallax(0.2) = ((0.2/0.5 + 1)/2)*100 = 70 + // y: 0.15 - 0.05 = 0.1 -> gForceToParallax(0.1) = ((0.1/0.5 + 1)/2)*100 = 60 + act(() => { + sensorCallback?.({ x: 0.3, y: 0.15 }); + }); + + // One render frame applies EMA smoothing (SMOOTHING_FACTOR = 0.04): + // x: 50 + 0.04 * (70 - 50) = 50.8 + // y: 50 + 0.04 * (60 - 50) = 50.4 + act(() => { + rafCallback?.(0); + }); + + expect(mockSetNumber).toHaveBeenCalledWith( + 'xValue', + expect.closeTo(50.8), + ); + expect(mockSetNumber).toHaveBeenCalledWith( + 'yValue', + expect.closeTo(50.4), + ); + }); + + it('clamps tilt beyond TILT_SENSITIVITY to the parallax extremes', () => { + render(); + + // Calibration reading establishes baseline = {0, 0}. + mockNow = 301; + act(() => { + sensorCallback?.({ x: 0, y: 0 }); + }); + + // Live reading well beyond +/- TILT_SENSITIVITY (0.5): + // x: 0.8 clamps to 0.5 -> gForceToParallax = 100 (max extreme) + // y: -0.8 clamps to -0.5 -> gForceToParallax = 0 (min extreme) + // Without clamping these would be 130 and -30 respectively. + act(() => { + sensorCallback?.({ x: 0.8, y: -0.8 }); + }); + + act(() => { + rafCallback?.(0); + }); + + // smoothed = 50 + 0.04 * (100 - 50) = 52 ; 50 + 0.04 * (0 - 50) = 48 + expect(mockSetNumber).toHaveBeenCalledWith('xValue', expect.closeTo(52)); + expect(mockSetNumber).toHaveBeenCalledWith('yValue', expect.closeTo(48)); + }); + + it('pushes the centered default to Rive on the first frame before any readings', () => { + render(); + + // No sensor readings yet: targets are at their initial 50, so the first + // smoothed frame stays centered: 50 + 0.04 * (50 - 50) = 50. + act(() => { + rafCallback?.(0); + }); + + expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50); + expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50); + }); + + it('keeps the render loop scheduled after a setNumber error', () => { + mockSetNumber.mockImplementationOnce(() => { + throw new Error('Rive binding not ready'); + }); + + render(); + + const rafSpy = global.requestAnimationFrame as jest.Mock; + // Ignore the initial schedule queued during effect setup. + rafSpy.mockClear(); + + // setNumber throws on this frame; the catch must swallow it AND the loop + // must still reschedule the next frame. + expect(() => { + act(() => { + rafCallback?.(0); + }); + }).not.toThrow(); + + expect(rafSpy).toHaveBeenCalled(); + }); + + it('still pushes the centered default when the accelerometer is unavailable', () => { + (Accelerometer.addListener as jest.Mock).mockImplementationOnce(() => { + throw new Error('Sensor unavailable'); + }); + + expect(() => render()).not.toThrow(); + + // The render loop is set up after the sensor try/catch, so it still runs + // and pushes the static center even though no listener was attached. + act(() => { + rafCallback?.(0); + }); + + expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50); + expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50); + }); + + it('unsubscribes the sensor and cancels the frame on unmount', () => { + const { unmount } = render(); + const capturedRafCallback = rafCallback; + + // Cleanup: sets active = false, cancels the frame, removes the listener. + unmount(); + + expect(mockRemove).toHaveBeenCalled(); + expect(global.cancelAnimationFrame).toHaveBeenCalled(); + + mockSetNumber.mockClear(); + const rafSpy = global.requestAnimationFrame as jest.Mock; + rafSpy.mockClear(); + + // A stale frame firing after unmount must not push to Rive, but it does + // reschedule (the loop only stops once cancelAnimationFrame wins the race). + act(() => { + capturedRafCallback?.(0); + }); + + expect(mockSetNumber).not.toHaveBeenCalled(); + expect(rafSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.testIds.ts b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.testIds.ts new file mode 100644 index 00000000000..c7ecabdc588 --- /dev/null +++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.testIds.ts @@ -0,0 +1,3 @@ +export const MoneyFirstTimeDepositViewTestIds = { + RIVE_ANIMATION: 'money-first-time-deposit-rive-animation', +} as const; diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.tsx b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.tsx new file mode 100644 index 00000000000..ff5fead573b --- /dev/null +++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.tsx @@ -0,0 +1,303 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { BackHandler } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { type StackNavigationProp } from '@react-navigation/stack'; +import { Accelerometer } from 'expo-sensors'; +import Rive, { + AutoBind, + useRive, + useRiveString, + useRiveTrigger, + Fit, + RNRiveError, +} from 'rive-react-native'; +import { createProjectLogger } from '@metamask/utils'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; +import { SCREEN_NAMES } from '../../constants/moneyEvents'; +import { MoneyFirstTimeDepositViewTestIds } from './MoneyFirstTimeDepositView.testIds'; +import useMountEffect from '../../hooks/useMountEffect'; + +const log = createProjectLogger('money-first-time-deposit'); + +// -- Rive animation assets ------------------------------------------------ + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs +const MoneyFirstTimeDepositAnimationWithParallax = require('../../../../../animations/money_account_first_time_deposit_with_parallax.riv'); + +// -- Rive data-binding names ---------------------------------------------- +// These MUST match the names authored in the .riv file. If the Rive designer +// renames any of these, update the constants here to keep the binding working. + +/** Name of the Rive artboard that contains the first-time deposit animation. */ +const RIVE_ARTBOARD_NAME = 'Intro'; + +/** Trigger that kicks off the intro animation sequence. Fire once on mount. */ +const RIVE_START_TRIGGER = 'start'; + +/** Trigger fired by the Rive "Done" button. Listened to for navigating home. */ +const RIVE_DONE_TRIGGER = 'done'; + +/** Text data-binding path for the headline shown during the animation. */ +const RIVE_TITLE_PATH = 'title'; + +/** Text data-binding path for the body copy shown during the animation. */ +const RIVE_CONTENT_PATH = 'content'; + +/** Number data-binding (0–100) for horizontal parallax driven by device tilt. */ +const RIVE_X_VALUE_PATH = 'xValue'; + +/** Number data-binding (0–100) for vertical parallax driven by device tilt. */ +const RIVE_Y_VALUE_PATH = 'yValue'; + +// -- Accelerometer / parallax tuning constants ---------------------------- + +/** + * How often (ms) the device delivers a new accelerometer reading. + * 33 ms ≈ 30 readings/sec. Lower = smoother but more CPU/battery. + */ +const SENSOR_POLL_INTERVAL_MS = 33; + +/** + * Exponential Moving Average (EMA) smoothing factor applied each frame. + * Formula: smoothedValue += SMOOTHING_FACTOR * (targetValue - smoothedValue) + * + * Range 0 to 1: + * 0.05 = very smooth / floaty, significant lag behind real tilt + * 0.12 = balanced — absorbs hand tremor, still tracks intentional tilt + * 0.40 = snappy, almost raw — good for fast-moving effects + * 1.00 = no smoothing at all (raw sensor, jittery) + */ +const SMOOTHING_FACTOR = 0.04; + +/** + * Maximum accelerometer g-force that maps to the 0 or 100 parallax extreme. + * Anything beyond this is clamped. Determines how far you must tilt to reach + * full parallax range. + * + * 0.15 = ~8° tilt, very sensitive, small wrist movements register + * 0.30 = ~17° tilt, moderate, deliberate tilt needed + * 0.50 = ~30° tilt, conservative, phone must be angled significantly + */ +const TILT_SENSITIVITY = 0.5; + +/** + * Duration (ms) of the calibration window on mount. During this window the + * accelerometer averages the device's resting orientation to establish a + * "neutral baseline". This means parallax is relative to how the user + * actually holds their phone, not relative to perfectly flat. + * + * Parallax stays centered (50, 50) during calibration so the text reveal + * animation plays cleanly regardless of hold angle. + */ +const CALIBRATION_DURATION_MS = 300; + +/** + * Converts a raw accelerometer g-force value (relative to baseline) into + * the 0-100 parallax range expected by the Rive data bindings. + * + * -TILT_SENSITIVITY maps to 0 (tilted fully one direction) + * 0 maps to 50 (neutral / resting position) + * +TILT_SENSITIVITY maps to 100 (tilted fully the other direction) + * + * Values outside the range are clamped to prevent overshooting. + */ +function gForceToParallax(gForce: number): number { + const clamped = Math.max( + -TILT_SENSITIVITY, + Math.min(TILT_SENSITIVITY, gForce), + ); + return ((clamped / TILT_SENSITIVITY + 1) / 2) * 100; +} + +const MoneyFirstTimeDepositView = () => { + const navigation = + useNavigation>>(); + + const { trackScreenViewed } = useMoneyAnalytics({ + screen_name: SCREEN_NAMES.MONEY_FIRST_TIME_DEPOSIT, + }); + + const goHome = useCallback(() => { + navigation.navigate(Routes.HOME_TABS, { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }); + }, [navigation]); + + useMountEffect(trackScreenViewed); + + // Intercept Android hardware-back so it behaves like tapping "Done". + useEffect(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + goHome(); + return true; + }); + return () => sub.remove(); + }, [goHome]); + + // -- Rive setup --------------------------------------------------------- + + const [ref, riveRef] = useRive(); + + // Flips true once data bindings are established. Guards accelerometer + // pushes so they don't fire before Rive is ready to accept values. + const dataBindingsReady = useRef(false); + + const [, setTitle] = useRiveString(riveRef, RIVE_TITLE_PATH); + const [, setContent] = useRiveString(riveRef, RIVE_CONTENT_PATH); + + const fireStart = useRiveTrigger(riveRef, RIVE_START_TRIGGER); + + // Listen for the Rive "Done" button trigger → navigate home. + useRiveTrigger(riveRef, RIVE_DONE_TRIGGER, () => { + goHome(); + }); + + // Once the Rive ref is ready, inject i18n text and kick off the animation. + useEffect(() => { + if (!riveRef) return; + + setTitle(strings('money.first_time_deposit.title')); + setContent(strings('money.first_time_deposit.content')); + fireStart?.(); + dataBindingsReady.current = true; + }, [riveRef, setTitle, setContent, fireStart]); + + // -- Accelerometer-driven parallax -------------------------------------- + // + // Architecture (two decoupled loops): + // + // Accelerometer listener → writes raw target values into refs + // (runs at SENSOR_POLL_INTERVAL_MS) + // + // requestAnimationFrame → reads targets, applies EMA smoothing, + // pushes smoothed values to Rive + // (runs at display refresh rate) + // + // This avoids flooding the React Native bridge when the sensor fires + // faster than the screen can render. + // + // On mount, a short calibration phase averages the device's resting + // orientation so that "neutral" = however the user holds their phone. + + // EMA-smoothed values that get pushed to Rive each frame. Start centered. + const smoothedX = useRef(50); + const smoothedY = useRef(50); + + // Raw parallax targets written by the sensor listener. + const targetX = useRef(50); + const targetY = useRef(50); + + // Resting orientation learned during calibration. Null until calibrated. + const restingBaseline = useRef<{ x: number; y: number } | null>(null); + + // Running sum/count for the calibration window's average. + const calibrationAccumulator = useRef({ sumX: 0, sumY: 0, count: 0 }); + + useEffect(() => { + if (!riveRef) return; + + Accelerometer.setUpdateInterval(SENSOR_POLL_INTERVAL_MS); + + let active = true; + let rafId: number; + let subscription: ReturnType | null = + null; + const calibrationStartedAt = Date.now(); + + try { + subscription = Accelerometer.addListener(({ x, y }) => { + if (!active) return; + + const baseline = restingBaseline.current; + + // --- Calibration phase --- + // Average the accelerometer readings to learn the device's held-rest + // orientation. Parallax stays centered (50, 50) so the text reveal + // plays cleanly regardless of how the user is holding the phone. + if (baseline === null) { + const acc = calibrationAccumulator.current; + acc.sumX += x; + acc.sumY += y; + acc.count += 1; + targetX.current = 50; + targetY.current = 50; + + if ( + Date.now() - calibrationStartedAt >= CALIBRATION_DURATION_MS && + acc.count > 0 + ) { + const baselineValue = { + x: acc.sumX / acc.count, + y: acc.sumY / acc.count, + }; + restingBaseline.current = baselineValue; + } + return; + } + + // --- Live phase --- + // Subtract the baseline so parallax is relative to the user's + // resting hold, not relative to "device perfectly flat on a table". + targetX.current = gForceToParallax(x - baseline.x); + targetY.current = gForceToParallax(y - baseline.y); + }); + } catch { + // Sensor unavailable — parallax stays at static center. + } + + /** rAF render loop: smooth the raw targets and push to Rive. */ + const renderParallax = () => { + if (!active || !dataBindingsReady.current) { + rafId = requestAnimationFrame(renderParallax); + return; + } + + smoothedX.current += + SMOOTHING_FACTOR * (targetX.current - smoothedX.current); + smoothedY.current += + SMOOTHING_FACTOR * (targetY.current - smoothedY.current); + + try { + riveRef.setNumber(RIVE_X_VALUE_PATH, smoothedX.current); + riveRef.setNumber(RIVE_Y_VALUE_PATH, smoothedY.current); + } catch { + // Data binding not ready yet — skip this frame. + } + + rafId = requestAnimationFrame(renderParallax); + }; + + rafId = requestAnimationFrame(renderParallax); + + return () => { + active = false; + cancelAnimationFrame(rafId); + subscription?.remove(); + }; + }, [riveRef]); + + const handleError = useCallback( + (riveError: RNRiveError) => { + log(`Rive error: ${riveError.message}`); + goHome(); + }, + [goHome], + ); + + return ( + + ); +}; + +export default MoneyFirstTimeDepositView; diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/index.ts b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/index.ts new file mode 100644 index 00000000000..7e0e1572725 --- /dev/null +++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/index.ts @@ -0,0 +1 @@ +export { default } from './MoneyFirstTimeDepositView'; diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 026e06f014d..427efc48462 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -52,6 +52,8 @@ import { CardEntryPoint, CardScreens, } from '../../../Card/util/metrics'; +import { selectIsMoneyAccountGeoEligible } from '../../selectors/eligibility'; +import { selectMoneyEnableMoneyAccountFlag } from '../../selectors/featureFlags'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); @@ -169,6 +171,16 @@ jest.mock('../../../../../selectors/cardController', () => ({ selectIsMoneyAccountDelegatedForCard: jest.fn(() => false), })); +jest.mock('../../selectors/eligibility', () => ({ + ...jest.requireActual('../../selectors/eligibility'), + selectIsMoneyAccountGeoEligible: jest.fn(() => true), +})); + +jest.mock('../../selectors/featureFlags', () => ({ + ...jest.requireActual('../../selectors/featureFlags'), + selectMoneyEnableMoneyAccountFlag: jest.fn(() => true), +})); + jest.mock('../../../Card/hooks/useMoneyAccountCardLinkage', () => ({ __esModule: true, useMoneyAccountCardLinkage: jest.fn(() => ({ @@ -244,6 +256,12 @@ jest.mock('../../hooks/useOnboardingStep', () => ({ const mockSelectIsCardholder = jest.mocked(selectIsCardholder); const mockSelectHasMetalCard = jest.mocked(selectHasMetalCard); const mockSelectCardHomeDataStatus = jest.mocked(selectCardHomeDataStatus); +const mockSelectIsMoneyAccountGeoEligible = jest.mocked( + selectIsMoneyAccountGeoEligible, +); +const mockSelectMoneyEnableMoneyAccountFlag = jest.mocked( + selectMoneyEnableMoneyAccountFlag, +); const mockUseMoneyAccountCardLinkage = jest.mocked(useMoneyAccountCardLinkage); const mockOpenLinkCardSheet = jest.fn(); const mockStartLinkFlow = jest.fn(); @@ -368,6 +386,8 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(false); mockSelectHasMetalCard.mockReturnValue(false); mockSelectCardHomeDataStatus.mockReturnValue('idle'); + mockSelectIsMoneyAccountGeoEligible.mockReturnValue(true); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(true); mockOpenLinkCardSheet.mockReset(); mockStartLinkFlow.mockReset(); @@ -2106,6 +2126,44 @@ describe('MoneyHomeView', () => { ).toBeOnTheScreen(); }); + it('hides the upsell MetaMask Card when the Money account is not visible', () => { + mockSelectIsCardholder.mockReturnValue(false); + mockSelectIsMoneyAccountGeoEligible.mockReturnValue(false); + + const { queryByTestId } = renderWithProvider(); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + }); + + it('hides the verifying MetaMask Card when the Money account is not visible', () => { + mockSelectIsCardholder.mockReturnValue(false); + mockSelectIsMoneyAccountGeoEligible.mockReturnValue(false); + mockUseMoneyAccountCardLinkage.mockReturnValue({ + hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, + isCardAuthenticated: true, + isCardVerified: false, + isCardLinkedToMoneyAccount: false, + primaryMoneyAccount: { address: '0xabc' }, + moneyAccountCardToken: { symbol: 'USDC' }, + canLink: false, + status: 'idle', + isLinking: false, + error: null, + startLinkFlow: mockStartLinkFlow, + openLinkCardSheet: mockOpenLinkCardSheet, + reset: jest.fn(), + } as unknown as ReturnType); + + const { queryByTestId } = renderWithProvider(); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + }); + it('renders upsell MetaMask Card below Activity and Earn on your crypto', () => { mockSelectIsCardholder.mockReturnValue(false); @@ -2264,9 +2322,41 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, - animation: 'slide_from_bottom', }); }); + + it('navigates to Card root with push animation when Card button is pressed in manage mode', () => { + mockSelectIsCardholder.mockReturnValue(true); + mockUseMoneyAccountCardLinkage.mockReturnValue({ + hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, + isCardAuthenticated: false, + isCardLinkedToMoneyAccount: true, + primaryMoneyAccount: undefined, + moneyAccountCardToken: null, + canLink: false, + status: 'idle', + isLinking: false, + error: null, + startLinkFlow: mockStartLinkFlow, + openLinkCardSheet: mockOpenLinkCardSheet, + reset: jest.fn(), + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + jest.clearAllMocks(); + + fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.CARD_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.CARD.ROOT, + expect.objectContaining({ animation: 'slide_from_bottom' }), + ); + }); }); describe('manage card balance', () => { diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index 0378bea16d5..24a38b8cf67 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -51,6 +51,8 @@ import { selectHasMetalCard, selectIsCardholder, } from '../../../../../selectors/cardController'; +import { selectIsMoneyAccountGeoEligible } from '../../selectors/eligibility'; +import { selectMoneyEnableMoneyAccountFlag } from '../../selectors/featureFlags'; import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage'; import { useCardHomeData } from '../../../Card/hooks/useCardHomeData'; import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect'; @@ -160,6 +162,12 @@ const MoneyHomeView = () => { const isCardholder = useSelector(selectIsCardholder); const cardHomeDataStatus = useSelector(selectCardHomeDataStatus); const hasMetalCard = useSelector(selectHasMetalCard); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); + const isMoneyAccountGeoEligible = useSelector( + selectIsMoneyAccountGeoEligible, + ); + const isMoneyAccountVisible = + isMoneyAccountEnabled && isMoneyAccountGeoEligible; const { startLinkFlow, isCardAuthenticated, @@ -171,6 +179,17 @@ const MoneyHomeView = () => { isResidencyBlocked, } = useMoneyAccountCardLinkage(); + const metamaskCardMode = deriveMoneyMetaMaskCardMode({ + isCardLinkedToMoneyAccount, + isCardholder, + isCardAuthenticated, + isCardVerified, + isResidencyBlocked, + isMoneyAccountVisible, + hasMoneyAccountBaseRequirements, + hasMoneyAccountRequirements, + }); + let displayState: MoneyBalanceDisplayState; if (!hasMoneyAccount) { displayState = { kind: 'noAccount' }; @@ -315,12 +334,14 @@ const MoneyHomeView = () => { }, [navigation, trackButtonClicked]); const navigateToCardHome = useCallback(() => { + const isUpsell = metamaskCardMode === 'upsell'; + navigation.navigate(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, - animation: 'slide_from_bottom', + ...(isUpsell ? { animation: 'slide_from_bottom' } : {}), }); - }, [navigation]); + }, [navigation, metamaskCardMode]); const handleCardHeaderPress = useCallback(() => { trackSurfaceClicked({ @@ -581,16 +602,6 @@ const MoneyHomeView = () => { [navigation, trackActivitySurfaceClicked], ); - const metamaskCardMode = deriveMoneyMetaMaskCardMode({ - isCardLinkedToMoneyAccount, - isCardholder, - isCardAuthenticated, - isCardVerified, - isResidencyBlocked, - hasMoneyAccountBaseRequirements, - hasMoneyAccountRequirements, - }); - const { primaryToken: cardPrimaryToken } = useCardHomeData(); const cardBalance = cardPrimaryToken?.balanceFiat ?? formattedZero; const cardState = deriveCardState({ diff --git a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx index 728a9dcb03c..b54862a81bf 100644 --- a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx +++ b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx @@ -239,8 +239,7 @@ const MoneyOnboardingView = () => { artboardName={RIVE_ARTBOARD_NAME} stateMachineName={RIVE_STATE_MACHINE_NAME} dataBinding={AutoBind(true)} - fit={Fit.Layout} - layoutScaleFactor={PixelRatio.get()} + fit={Fit.FitWidth} onStateChanged={handleStateChanged} testID={MoneyOnboardingViewTestIds.RIVE_ANIMATION} /> diff --git a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx index cfad4916ba2..34a367de512 100644 --- a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx +++ b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx @@ -4,6 +4,9 @@ import MoneyTransactionMonitor from './MoneyTransactionMonitor'; import { useMoneyTransactionStatus } from '../../hooks/useMoneyTransactionStatus'; jest.mock('../../hooks/useMoneyTransactionStatus'); +jest.mock('../../hooks/useMoneyFirstTimeDepositTracker', () => ({ + useMoneyFirstTimeDepositTracker: jest.fn(), +})); describe('MoneyTransactionMonitor', () => { const mockUseMoneyTransactionStatus = jest.mocked(useMoneyTransactionStatus); diff --git a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx index 93b82af3cd1..1d3e4172ac6 100644 --- a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx +++ b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { useRefreshMoneyBalanceOnTxConfirm } from '../../hooks/useRefreshMoneyBalanceOnTxConfirm'; import { useMoneyTransactionStatus } from '../../hooks/useMoneyTransactionStatus'; +import { useMoneyFirstTimeDepositTracker } from '../../hooks/useMoneyFirstTimeDepositTracker'; const MoneyTransactionMonitor: React.FC = () => { useMoneyTransactionStatus(); useRefreshMoneyBalanceOnTxConfirm(); + useMoneyFirstTimeDepositTracker(); return null; }; diff --git a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx index 6dfaaa0cc99..4c945c3ed2c 100644 --- a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx +++ b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx @@ -5,6 +5,13 @@ import { UserActionType } from '../../../../actions/user/types'; import { selectMoneyOnboardingSeen } from '../../../../reducers/user/selectors'; import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController'; +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + const mockDispatch = jest.fn(); const mockUseSelector = jest.fn(); diff --git a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx index 0d8d96997bf..8200891eec9 100644 --- a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx +++ b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx @@ -1,5 +1,7 @@ import React, { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import { type StackNavigationProp } from '@react-navigation/stack'; import { useTheme } from '../../../../util/theme'; import { @@ -21,11 +23,13 @@ import { import styleSheet from '../../../Views/Settings/DeveloperOptions/DeveloperOptions.styles'; import ClipboardManager from '../../../../core/ClipboardManager'; import { STEPPER_IDS } from '../hooks/useOnboardingStep'; +import Routes from '../../../../constants/navigation/Routes'; export const MoneyUiDeveloperOptionsSection = () => { const dispatch = useDispatch(); const theme = useTheme(); const { styles } = useStyles(styleSheet, { theme }); + const navigation = useNavigation(); const hasSeenMoneyOnboarding = useSelector(selectMoneyOnboardingSeen); const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount); @@ -45,6 +49,10 @@ export const MoneyUiDeveloperOptionsSection = () => { } }, [moneyAccountAddress]); + const handlePreviewFirstTimeDepositAnimation = useCallback(() => { + navigation.navigate(Routes.MONEY.FIRST_TIME_DEPOSIT); + }, [navigation]); + return ( @@ -105,6 +113,24 @@ export const MoneyUiDeveloperOptionsSection = () => { {'Reset onboarding stepper'} + + + {'Preview first-time deposit animation'} + + + ); }; diff --git a/app/components/UI/Money/constants/moneyEvents.ts b/app/components/UI/Money/constants/moneyEvents.ts index 00b95adbec5..b7f9fc4bb97 100644 --- a/app/components/UI/Money/constants/moneyEvents.ts +++ b/app/components/UI/Money/constants/moneyEvents.ts @@ -16,6 +16,7 @@ export enum SCREEN_NAMES { MONEY_ACTIVITY = 'money_activity', MONEY_ACTIVITY_DETAILS = 'money_activity_details', MONEY_POTENTIAL_EARNINGS = 'money_potential_earnings', + MONEY_FIRST_TIME_DEPOSIT = 'money_first_time_deposit', } export enum BOTTOM_SHEET_NAMES { diff --git a/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.test.ts b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.test.ts new file mode 100644 index 00000000000..7996c862352 --- /dev/null +++ b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.test.ts @@ -0,0 +1,201 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { useMoneyFirstTimeDepositTracker } from './useMoneyFirstTimeDepositTracker'; +import { store } from '../../../../store'; +import { selectMoneyFirstTimeDepositAnimationEnabledFlag } from '../selectors/featureFlags'; +import { hasPriorMoneyDeposit } from '../utils/firstTimeDeposit'; +import Routes from '../../../../constants/navigation/Routes'; +import Engine from '../../../../core/Engine'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('../../../../store', () => ({ + store: { getState: jest.fn() }, +})); + +jest.mock('../selectors/featureFlags', () => ({ + selectMoneyFirstTimeDepositAnimationEnabledFlag: jest.fn(), +})); + +jest.mock('../utils/firstTimeDeposit', () => ({ + hasPriorMoneyDeposit: jest.fn(), +})); + +jest.mock('../utils/moneyTransactionGuards', () => ({ + isMoneyDepositTx: jest.fn( + (tx: { type: string; nestedTransactions?: { type: string }[] }) => + tx.type === 'moneyAccountDeposit' || + tx.nestedTransactions?.some((n) => n.type === 'moneyAccountDeposit'), + ), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, +})); + +const mockedFlagSelector = + selectMoneyFirstTimeDepositAnimationEnabledFlag as unknown as jest.Mock; +const mockedHasPrior = hasPriorMoneyDeposit as jest.Mock; +const mockedGetState = store.getState as jest.Mock; +const mockedSubscribe = Engine.controllerMessenger.subscribe as jest.Mock; +const mockedUnsubscribe = Engine.controllerMessenger.unsubscribe as jest.Mock; + +const makeTx = ( + id: string, + type: TransactionType, + status: TransactionStatus = TransactionStatus.confirmed, +): TransactionMeta => + ({ + id, + type, + status, + time: 0, + txParams: {}, + }) as unknown as TransactionMeta; + +const captureSubscribedCallback = () => + mockedSubscribe.mock.calls[0][1] as (tx: TransactionMeta) => void; + +describe('useMoneyFirstTimeDepositTracker', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetState.mockReturnValue({}); + mockedFlagSelector.mockReturnValue(true); + mockedHasPrior.mockReturnValue(false); + }); + + it('subscribes to TransactionController:transactionConfirmed on mount', () => { + renderHook(() => useMoneyFirstTimeDepositTracker()); + + expect(mockedSubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionConfirmed', + expect.any(Function), + ); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useMoneyFirstTimeDepositTracker()); + const subscribedCallback = captureSubscribedCallback(); + + unmount(); + + expect(mockedUnsubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionConfirmed', + subscribedCallback, + ); + }); + + it('does not navigate when tx status is not confirmed', () => { + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + act(() => { + callback( + makeTx( + 'tx-1', + TransactionType.moneyAccountDeposit, + TransactionStatus.submitted, + ), + ); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when tx is not a money deposit', () => { + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + act(() => { + callback(makeTx('tx-1', TransactionType.contractInteraction)); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when feature flag is disabled', () => { + mockedFlagSelector.mockReturnValue(false); + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + act(() => { + callback(makeTx('tx-1', TransactionType.moneyAccountDeposit)); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not navigate when prior money deposit exists', () => { + mockedHasPrior.mockReturnValue(true); + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + act(() => { + callback(makeTx('tx-1', TransactionType.moneyAccountDeposit)); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('navigates directly to first-time deposit for eligible deposit', () => { + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + act(() => { + callback(makeTx('tx-1', TransactionType.moneyAccountDeposit)); + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.FIRST_TIME_DEPOSIT); + }); + + it('navigates directly to first-time deposit for eligible nested batch deposit', () => { + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + const batchTx = { + ...makeTx('batch-1', TransactionType.batch), + nestedTransactions: [{ type: TransactionType.moneyAccountDeposit }], + } as unknown as TransactionMeta; + + act(() => { + callback(batchTx); + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.FIRST_TIME_DEPOSIT); + }); + + it('passes current tx id to hasPriorMoneyDeposit', () => { + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + act(() => { + callback(makeTx('tx-42', TransactionType.moneyAccountDeposit)); + }); + + expect(mockedHasPrior).toHaveBeenCalledWith(expect.anything(), 'tx-42'); + }); + + it('reads state imperatively via store.getState', () => { + renderHook(() => useMoneyFirstTimeDepositTracker()); + const callback = captureSubscribedCallback(); + + act(() => { + callback(makeTx('tx-1', TransactionType.moneyAccountDeposit)); + }); + + expect(mockedGetState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.ts b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.ts new file mode 100644 index 00000000000..cb1c8df8c24 --- /dev/null +++ b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.ts @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { + TransactionMeta, + TransactionStatus, +} from '@metamask/transaction-controller'; +import Engine from '../../../../core/Engine'; +import Routes from '../../../../constants/navigation/Routes'; +import { store } from '../../../../store'; +import { selectMoneyFirstTimeDepositAnimationEnabledFlag } from '../selectors/featureFlags'; +import { isMoneyDepositTx } from '../utils/moneyTransactionGuards'; +import { hasPriorMoneyDeposit } from '../utils/firstTimeDeposit'; + +export const useMoneyFirstTimeDepositTracker = () => { + const navigation = useNavigation(); + + useEffect(() => { + const handleTransactionConfirmed = (tx: TransactionMeta) => { + if (tx.status !== TransactionStatus.confirmed) return; + + const state = store.getState(); + + if ( + !isMoneyDepositTx(tx) || + !selectMoneyFirstTimeDepositAnimationEnabledFlag(state) || + hasPriorMoneyDeposit(state, tx.id) + ) { + return; + } + + navigation.navigate(Routes.MONEY.FIRST_TIME_DEPOSIT); + }; + + Engine.controllerMessenger.subscribe( + 'TransactionController:transactionConfirmed', + handleTransactionConfirmed, + ); + + return () => { + Engine.controllerMessenger.unsubscribe( + 'TransactionController:transactionConfirmed', + handleTransactionConfirmed, + ); + }; + }, [navigation]); +}; diff --git a/app/components/UI/Money/selectors/eligibility.test.ts b/app/components/UI/Money/selectors/eligibility.test.ts index 3ce33c3fc33..0a25b9d9c7c 100644 --- a/app/components/UI/Money/selectors/eligibility.test.ts +++ b/app/components/UI/Money/selectors/eligibility.test.ts @@ -1,6 +1,24 @@ -import { selectIsMoneyAccountGeoEligible } from './eligibility'; +import { + selectIsMoneyAccountGeoEligible, + selectIsUserInUS, + selectIsUsUnauthenticatedNonCardholder, +} from './eligibility'; +import { + selectIsCardAuthenticated, + selectHasCardholderAccounts, +} from '../../../../selectors/cardController'; import type { RootState } from '../../../../reducers'; +jest.mock('../../../../selectors/cardController', () => ({ + selectIsCardAuthenticated: jest.fn(), + selectHasCardholderAccounts: jest.fn(), +})); + +const mockSelectIsCardAuthenticated = + selectIsCardAuthenticated as unknown as jest.MockedFunction<() => boolean>; +const mockSelectHasCardholderAccounts = + selectHasCardholderAccounts as unknown as jest.MockedFunction<() => boolean>; + describe('selectIsMoneyAccountGeoEligible', () => { const createStateWithGeolocation = ( geolocation: string | null | undefined, @@ -153,3 +171,93 @@ describe('selectIsMoneyAccountGeoEligible', () => { } }); }); + +describe('selectIsUserInUS', () => { + const createStateWithGeolocation = (geolocation: string | null | undefined) => + ({ + engine: { + backgroundState: { + GeolocationController: { + location: geolocation, + }, + }, + }, + }) as unknown as RootState; + + it('returns true for a plain US country code', () => { + expect(selectIsUserInUS(createStateWithGeolocation('US'))).toBe(true); + }); + + it('returns true for a US country-region code', () => { + expect(selectIsUserInUS(createStateWithGeolocation('US-CA'))).toBe(true); + }); + + it('is case-insensitive', () => { + expect(selectIsUserInUS(createStateWithGeolocation('us'))).toBe(true); + }); + + it('returns false for a non-US country', () => { + expect(selectIsUserInUS(createStateWithGeolocation('GB'))).toBe(false); + }); + + it('returns false when geolocation is unknown', () => { + expect(selectIsUserInUS(createStateWithGeolocation('UNKNOWN'))).toBe(false); + }); + + it('returns false when geolocation is undefined', () => { + expect(selectIsUserInUS(createStateWithGeolocation(undefined))).toBe(false); + }); +}); + +describe('selectIsUsUnauthenticatedNonCardholder', () => { + const createState = (geolocation: string | null | undefined) => + ({ + engine: { + backgroundState: { + GeolocationController: { + location: geolocation, + }, + }, + }, + }) as unknown as RootState; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true for a US user who is not authenticated and not a cardholder', () => { + mockSelectIsCardAuthenticated.mockReturnValue(false); + mockSelectHasCardholderAccounts.mockReturnValue(false); + + expect(selectIsUsUnauthenticatedNonCardholder(createState('US'))).toBe( + true, + ); + }); + + it('returns false when the user is not in the US', () => { + mockSelectIsCardAuthenticated.mockReturnValue(false); + mockSelectHasCardholderAccounts.mockReturnValue(false); + + expect(selectIsUsUnauthenticatedNonCardholder(createState('GB'))).toBe( + false, + ); + }); + + it('returns false when the US user is authenticated', () => { + mockSelectIsCardAuthenticated.mockReturnValue(true); + mockSelectHasCardholderAccounts.mockReturnValue(false); + + expect(selectIsUsUnauthenticatedNonCardholder(createState('US'))).toBe( + false, + ); + }); + + it('returns false when any wallet account is a cardholder', () => { + mockSelectIsCardAuthenticated.mockReturnValue(false); + mockSelectHasCardholderAccounts.mockReturnValue(true); + + expect(selectIsUsUnauthenticatedNonCardholder(createState('US'))).toBe( + false, + ); + }); +}); diff --git a/app/components/UI/Money/selectors/eligibility.ts b/app/components/UI/Money/selectors/eligibility.ts index 2a14aad31cb..9b6c1c3448b 100644 --- a/app/components/UI/Money/selectors/eligibility.ts +++ b/app/components/UI/Money/selectors/eligibility.ts @@ -7,11 +7,18 @@ */ import { createSelector } from 'reselect'; +import type { RootState } from '../../../../reducers'; import { selectMoneyAccountGeoBlockedCountries } from './featureFlags'; import { getDetectedGeolocation } from '../../../../reducers/fiatOrders'; +import { + selectIsCardAuthenticated, + selectHasCardholderAccounts, +} from '../../../../selectors/cardController'; + +const US_COUNTRY_CODE = 'US'; export const selectIsMoneyAccountGeoEligible = createSelector( - selectMoneyAccountGeoBlockedCountries, + (state: RootState) => selectMoneyAccountGeoBlockedCountries(state), getDetectedGeolocation, (blockedCountries, geolocation): boolean => { const userCountry = geolocation?.toUpperCase().split('-')[0] ?? null; @@ -24,3 +31,32 @@ export const selectIsMoneyAccountGeoEligible = createSelector( ); }, ); + +/** + * Whether the user's detected geolocation resolves to the United States. + * + * The Ramps geolocation API returns codes like "US" or "US-CA" + * (country-region), so only the leading country segment is compared. + * Defaults to `false` when geolocation is unknown or still loading. + */ +export const selectIsUserInUS = createSelector( + getDetectedGeolocation, + (geolocation): boolean => + (geolocation?.toUpperCase().split('-')[0] ?? null) === US_COUNTRY_CODE, +); + +/** + * Whether the user is in the US, not authenticated with the card service, and + * not a cardholder. + * + * Cardholder status is evaluated wallet-wide (any account) rather than for the + * currently selected account, so an existing card customer never matches this + * condition after switching accounts. + */ +export const selectIsUsUnauthenticatedNonCardholder = createSelector( + selectIsUserInUS, + selectIsCardAuthenticated, + selectHasCardholderAccounts, + (isInUS, isCardAuthenticated, hasCardholderAccounts): boolean => + isInUS && !isCardAuthenticated && !hasCardholderAccounts, +); diff --git a/app/components/UI/Money/selectors/featureFlags.test.ts b/app/components/UI/Money/selectors/featureFlags.test.ts index ee5e18ec4e6..20bfaa75d52 100644 --- a/app/components/UI/Money/selectors/featureFlags.test.ts +++ b/app/components/UI/Money/selectors/featureFlags.test.ts @@ -10,6 +10,7 @@ import { selectMoneyTokensSortMode, selectMoneyAccountGeoBlockedCountries, DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES, + selectMoneyFirstTimeDepositAnimationEnabledFlag, } from './featureFlags'; jest.mock('../../../../core/Engine', () => ({ @@ -484,3 +485,98 @@ describe('selectMoneyAccountGeoBlockedCountries', () => { expect(DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES).toContain('GB'); }); }); + +describe('selectMoneyFirstTimeDepositAnimationEnabledFlag', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns true when remote flag is enabled and version requirement is met', () => { + mockedValidate.mockReturnValue(true); + + const state = createState({ + earnMoneyFirstTimeDepositAnimationEnabled: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + const result = selectMoneyFirstTimeDepositAnimationEnabledFlag( + state as never, + ); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is disabled', () => { + mockedValidate.mockReturnValue(false); + + const state = createState({ + earnMoneyFirstTimeDepositAnimationEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + const result = selectMoneyFirstTimeDepositAnimationEnabledFlag( + state as never, + ); + + expect(result).toBe(false); + }); + + it('defaults to true when remote flag returns undefined and env is unset', () => { + mockedValidate.mockReturnValue(undefined); + delete process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED; + + const state = createState({ + _unique: 'first-deposit-default-on', + }); + + const result = selectMoneyFirstTimeDepositAnimationEnabledFlag( + state as never, + ); + + expect(result).toBe(true); + }); + + it('returns false when env var is set to false and remote is undefined', () => { + mockedValidate.mockReturnValue(undefined); + process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED = 'false'; + + const state = createState({ + _unique: 'first-deposit-env-false', + }); + + const result = selectMoneyFirstTimeDepositAnimationEnabledFlag( + state as never, + ); + + expect(result).toBe(false); + }); + + it('remote flag takes precedence over env var', () => { + mockedValidate.mockReturnValue(false); + process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED = 'true'; + + const state = createState({ + earnMoneyFirstTimeDepositAnimationEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + const result = selectMoneyFirstTimeDepositAnimationEnabledFlag( + state as never, + ); + + expect(result).toBe(false); + }); +}); diff --git a/app/components/UI/Money/selectors/featureFlags.ts b/app/components/UI/Money/selectors/featureFlags.ts index 200ba522b2c..1861f801b9c 100644 --- a/app/components/UI/Money/selectors/featureFlags.ts +++ b/app/components/UI/Money/selectors/featureFlags.ts @@ -39,6 +39,21 @@ export const selectMoneyHubEnabledFlag = createSelector( }, ); +/** + * Kill-switch for the first-time deposit Rive animation. + * Defaults to ON (true). + */ +export const selectMoneyFirstTimeDepositAnimationEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.earnMoneyFirstTimeDepositAnimationEnabled as unknown as VersionGatedFeatureFlag; + const local = + process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED !== 'false'; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? local; + }, +); + /** * Selects the blocked payment tokens for Money surfaces from remote config or local fallback. * Returns a wildcard blocklist mapping chain IDs (or "*") to token symbols (or ["*"]). diff --git a/app/components/UI/Money/utils/firstTimeDeposit.test.ts b/app/components/UI/Money/utils/firstTimeDeposit.test.ts new file mode 100644 index 00000000000..9e98f15560a --- /dev/null +++ b/app/components/UI/Money/utils/firstTimeDeposit.test.ts @@ -0,0 +1,97 @@ +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { hasPriorMoneyDeposit } from './firstTimeDeposit'; +import { selectNonReplacedTransactions } from '../../../../selectors/transactionController'; + +jest.mock('../../../../selectors/transactionController', () => ({ + selectNonReplacedTransactions: jest.fn(), +})); + +const mockedSelectNonReplacedTransactions = + selectNonReplacedTransactions as unknown as jest.MockedFunction< + (state: unknown) => TransactionMeta[] + >; + +const baseTx = { + status: TransactionStatus.confirmed, + time: 0, + txParams: {}, +} as unknown as TransactionMeta; + +const makeTx = ( + id: string, + type: TransactionType, + nested?: { type: TransactionType }[], +): TransactionMeta => + ({ + ...baseTx, + id, + type, + nestedTransactions: nested, + }) as unknown as TransactionMeta; + +const mockState = {} as never; + +describe('hasPriorMoneyDeposit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns false when transaction list is empty', () => { + mockedSelectNonReplacedTransactions.mockReturnValue([]); + + const result = hasPriorMoneyDeposit(mockState, 'current-tx'); + + expect(result).toBe(false); + }); + + it('returns false when only transaction is the current one', () => { + mockedSelectNonReplacedTransactions.mockReturnValue([ + makeTx('current-tx', TransactionType.moneyAccountDeposit), + ]); + + const result = hasPriorMoneyDeposit(mockState, 'current-tx'); + + expect(result).toBe(false); + }); + + it('returns true when a prior top-level deposit exists', () => { + mockedSelectNonReplacedTransactions.mockReturnValue([ + makeTx('current-tx', TransactionType.moneyAccountDeposit), + makeTx('prior-tx', TransactionType.moneyAccountDeposit), + ]); + + const result = hasPriorMoneyDeposit(mockState, 'current-tx'); + + expect(result).toBe(true); + }); + + it('returns true when a prior nested batch deposit exists', () => { + mockedSelectNonReplacedTransactions.mockReturnValue([ + makeTx('current-tx', TransactionType.moneyAccountDeposit), + makeTx('prior-batch', TransactionType.batch, [ + { type: TransactionType.moneyAccountDeposit }, + ]), + ]); + + const result = hasPriorMoneyDeposit(mockState, 'current-tx'); + + expect(result).toBe(true); + }); + + it('returns false when prior transactions are non-deposit types', () => { + mockedSelectNonReplacedTransactions.mockReturnValue([ + makeTx('current-tx', TransactionType.moneyAccountDeposit), + makeTx('other-1', TransactionType.moneyAccountWithdraw), + makeTx('other-2', TransactionType.contractInteraction), + makeTx('other-3', TransactionType.incoming), + ]); + + const result = hasPriorMoneyDeposit(mockState, 'current-tx'); + + expect(result).toBe(false); + }); +}); diff --git a/app/components/UI/Money/utils/firstTimeDeposit.ts b/app/components/UI/Money/utils/firstTimeDeposit.ts new file mode 100644 index 00000000000..0c3c9f26054 --- /dev/null +++ b/app/components/UI/Money/utils/firstTimeDeposit.ts @@ -0,0 +1,22 @@ +import { TransactionStatus } from '@metamask/transaction-controller'; +import { RootState } from '../../../../reducers'; +import { selectNonReplacedTransactions } from '../../../../selectors/transactionController'; +import { isMoneyDepositTx } from './moneyTransactionGuards'; + +/** + * Returns true if the wallet has any prior self-initiated Money deposit + * transactions, excluding the transaction with `currentTxId`. + */ +export function hasPriorMoneyDeposit( + state: RootState, + currentTxId: string, +): boolean { + const transactions = selectNonReplacedTransactions(state); + + return transactions.some( + (tx) => + tx.id !== currentTxId && + isMoneyDepositTx(tx) && + tx.status === TransactionStatus.confirmed, + ); +} diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts index b37cfe772fc..30030665fb4 100644 --- a/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts +++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts @@ -6,6 +6,7 @@ const baseInput = { isCardAuthenticated: false, isCardVerified: false, isResidencyBlocked: false, + isMoneyAccountVisible: true, hasMoneyAccountBaseRequirements: true, hasMoneyAccountRequirements: true, }; @@ -103,6 +104,49 @@ describe('deriveMoneyMetaMaskCardMode', () => { expect(deriveMoneyMetaMaskCardMode(baseInput)).toBe('upsell'); }); + it('returns null for upsell when the Money account is not visible', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isMoneyAccountVisible: false, + }), + ).toBeNull(); + }); + + it('returns null for verifying when the Money account is not visible', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardAuthenticated: true, + isCardVerified: false, + isMoneyAccountVisible: false, + }), + ).toBeNull(); + }); + + it('returns manage when linked even if the Money account is not visible', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardLinkedToMoneyAccount: true, + isMoneyAccountVisible: false, + }), + ).toBe('manage'); + }); + + it('returns link for a cardholder when full requirements are met even if visibility is off', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardholder: true, + isCardAuthenticated: true, + isCardVerified: true, + hasMoneyAccountRequirements: true, + isMoneyAccountVisible: false, + }), + ).toBe('link'); + }); + it('prefers manage over all other states when the card is linked', () => { expect( deriveMoneyMetaMaskCardMode({ diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts index e38a103b108..9a4871e0802 100644 --- a/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts +++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts @@ -6,6 +6,7 @@ export interface MoneyMetaMaskCardModeInput { isCardAuthenticated: boolean; isCardVerified: boolean; isResidencyBlocked: boolean; + isMoneyAccountVisible: boolean; hasMoneyAccountBaseRequirements: boolean; hasMoneyAccountRequirements: boolean; } @@ -16,6 +17,7 @@ export const deriveMoneyMetaMaskCardMode = ({ isCardAuthenticated, isCardVerified, isResidencyBlocked, + isMoneyAccountVisible, hasMoneyAccountBaseRequirements, hasMoneyAccountRequirements, }: MoneyMetaMaskCardModeInput): MoneyMetaMaskCardMode | null => { @@ -36,8 +38,8 @@ export const deriveMoneyMetaMaskCardMode = ({ } // Authenticated but identity not yet verified -> verification in progress. - if (isCardAuthenticated) return 'verifying'; + if (isCardAuthenticated) return isMoneyAccountVisible ? 'verifying' : null; // Brand-new user -> upsell. - return 'upsell'; + return isMoneyAccountVisible ? 'upsell' : null; }; diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx index 4ca734fefa1..4814edb59ee 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx @@ -62,7 +62,7 @@ import AvatarFavicon from '../../../component-library/components/Avatars/Avatar/ import AvatarToken from '../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import AccountConnectCreateInitialAccount from '../../Views/MultichainAccounts/shared/AccountConnectCreateInitialAccount'; import { SolScope } from '@metamask/keyring-api'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; +import { WalletClientType } from '../../../core/SnapKeyring/types'; import { endTrace, trace, TraceName } from '../../../util/trace'; import { Text as TextComponent, diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts b/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts index f85a1f205aa..8c51303a386 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts @@ -2,7 +2,7 @@ import { CaipAccountId } from '@metamask/utils'; import { USER_INTENT } from '../../../constants/permissions'; import { Account, EnsByAccountAddress } from '../../hooks/useAccounts'; import { NetworkAvatarProps } from '../../Views/MultichainAccounts/shared/AccountConnect.types'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; +import { WalletClientType } from '../../../core/SnapKeyring/types'; import { SolScope } from '@metamask/keyring-api'; export interface PermissionsSummaryProps { currentPageInformation: { diff --git a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx index 820c239d5c1..3c964d0afff 100644 --- a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx +++ b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx @@ -13,6 +13,7 @@ import { } from '../../types'; import FeaturedCarouselSportCard from './FeaturedCarouselSportCard'; import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds'; +import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ @@ -83,6 +84,14 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({ useLiveGameUpdates: () => ({ gameUpdate: null }), })); +const mockGetLivePrice = jest.fn(); +jest.mock('../../hooks/useLiveMarketPrices', () => ({ + useLiveMarketPrices: jest.fn(() => ({ + getPrice: mockGetLivePrice, + })), +})); +const mockUseLiveMarketPrices = jest.mocked(useLiveMarketPrices); + jest.mock('../../constants/sportLeagueConfigs', () => ({ getLeagueConfig: () => ({}), })); @@ -107,6 +116,24 @@ const initialState = { }, }; +const stateWithSportCardLivePricesEnabled = (enabled: boolean) => ({ + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags, + predictSportCardLivePrices: { + enabled, + minimumVersion: '0.0.0', + }, + }, + }, + }, + }, +}); + const createMockOutcome = ( tokens: PredictOutcomeToken[], volume = 1500000, @@ -184,6 +211,7 @@ const createMockSportMarket = ( describe('FeaturedCarouselSportCard', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetLivePrice.mockReturnValue(undefined); }); it('renders league name and live indicator for ongoing games', () => { @@ -257,7 +285,9 @@ describe('FeaturedCarouselSportCard', () => { { state: initialState }, ); - expect(getByText(strings('predict.outcome_draw'))).toBeOnTheScreen(); + expect( + getByText(`${strings('predict.outcome_draw')} 20%`), + ).toBeOnTheScreen(); }); it.each(['nba', 'nfl'] as const)( @@ -414,4 +444,60 @@ describe('FeaturedCarouselSportCard', () => { }), ); }); + + it('renders live best ask prices when available', () => { + mockGetLivePrice.mockImplementation((tokenId: string) => ({ + tokenId, + price: 0, + bestBid: 0, + bestAsk: + tokenId === 'home-token' + ? 0.75 + : tokenId === 'draw-token' + ? 0.18 + : 0.25, + })); + const market = createMockSportMarket(); + + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('$133.33')).toBeOnTheScreen(); + expect(getByText('$400.00')).toBeOnTheScreen(); + expect(getByText('75%')).toBeOnTheScreen(); + expect( + getByText(`${strings('predict.outcome_draw')} 18%`), + ).toBeOnTheScreen(); + expect(getByText('25%')).toBeOnTheScreen(); + }); + + it('renders static prices and disables live subscriptions when the flag is off', () => { + mockGetLivePrice.mockImplementation((tokenId: string) => ({ + tokenId, + price: 0, + bestBid: 0, + bestAsk: 0.99, + })); + const market = createMockSportMarket(); + + const { getByText, queryByText } = renderWithProvider( + , + { state: stateWithSportCardLivePricesEnabled(false) }, + ); + + expect(getByText('$166.67')).toBeOnTheScreen(); + expect(getByText('$250.00')).toBeOnTheScreen(); + expect(getByText('60%')).toBeOnTheScreen(); + expect( + getByText(`${strings('predict.outcome_draw')} 20%`), + ).toBeOnTheScreen(); + expect(getByText('40%')).toBeOnTheScreen(); + expect(queryByText('99%')).not.toBeOnTheScreen(); + expect(mockUseLiveMarketPrices).toHaveBeenLastCalledWith( + ['home-token', 'draw-token', 'away-token'], + { enabled: false }, + ); + }); }); diff --git a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx index d5c2f779a09..b81764c3de9 100644 --- a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx +++ b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { TouchableOpacity } from 'react-native'; import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -19,6 +20,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { PredictMarket, PredictMarketGame, + PredictMarketStatus, PredictOutcomeToken, } from '../../types'; import { @@ -30,10 +32,13 @@ import { PredictEventValues } from '../../constants/eventNames'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { usePredictPreviewSheet } from '../../contexts'; import { useLiveGameUpdates } from '../../hooks/useLiveGameUpdates'; +import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; import { isDrawCapableLeague } from '../../constants/sports'; +import { selectPredictSportCardLivePricesEnabledFlag } from '../../selectors/featureFlags'; import PredictSportTeamLogo from '../PredictSportTeamLogo/PredictSportTeamLogo'; import { getLeagueConfig } from '../../constants/sportLeagueConfigs'; import { parseScore } from '../../utils/gameParser'; +import { isValidPrice } from '../../utils/prices'; import FeaturedCarouselCardFooter from './FeaturedCarouselCardFooter'; import FeaturedCarouselPayoutRow from './FeaturedCarouselPayoutRow'; import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds'; @@ -62,6 +67,9 @@ const FeaturedCarouselSportCard: React.FC = ({ useNavigation>(); const { openBuySheet } = usePredictPreviewSheet(); const { executeGuardedAction } = usePredictActionGuard({ navigation }); + const livePricesEnabled = useSelector( + selectPredictSportCardLivePricesEnabledFlag, + ); const game = market.game as PredictMarketGame; const config = getLeagueConfig(game.league); @@ -117,6 +125,31 @@ const FeaturedCarouselSportCard: React.FC = ({ const drawToken = showDraw ? outcome?.tokens?.find((t) => t.title?.toLowerCase() === 'draw') : undefined; + const tokenIds = useMemo( + () => + [homeToken, drawToken, awayToken] + .map((token) => token?.id) + .filter((id): id is string => Boolean(id)), + [awayToken, drawToken, homeToken], + ); + const { getPrice } = useLiveMarketPrices(tokenIds, { + enabled: + livePricesEnabled && + market.status === PredictMarketStatus.OPEN && + tokenIds.length > 0, + }); + + const getDisplayPrice = useCallback( + (token: PredictOutcomeToken): number => { + if (!livePricesEnabled) { + return token.price; + } + + const liveBestAsk = getPrice(token.id)?.bestAsk; + return isValidPrice(liveBestAsk) ? liveBestAsk : token.price; + }, + [getPrice, livePricesEnabled], + ); const handleCardPress = useCallback(() => { navigation.navigate(Routes.PREDICT.ROOT, { @@ -265,7 +298,7 @@ const FeaturedCarouselSportCard: React.FC = ({ {game.homeTeam.name} {homeToken && ( - + )} @@ -279,7 +312,7 @@ const FeaturedCarouselSportCard: React.FC = ({ {game.awayTeam.name} {awayToken && ( - + )} @@ -298,7 +331,9 @@ const FeaturedCarouselSportCard: React.FC = ({ style={tw.style('font-medium')} color={TextColor.SuccessDefault} > - {formatPercentage(Math.round(homeToken.price * 100))} + {formatPercentage( + Math.round(getDisplayPrice(homeToken) * 100), + )} @@ -315,7 +350,10 @@ const FeaturedCarouselSportCard: React.FC = ({ style={tw.style('font-medium')} color={TextColor.TextDefault} > - {strings('predict.outcome_draw')} + {strings('predict.outcome_draw')}{' '} + {formatPercentage( + Math.round(getDisplayPrice(drawToken) * 100), + )} @@ -333,7 +371,9 @@ const FeaturedCarouselSportCard: React.FC = ({ style={tw.style('font-medium')} color={TextColor.SuccessDefault} > - {formatPercentage(Math.round(awayToken.price * 100))} + {formatPercentage( + Math.round(getDisplayPrice(awayToken) * 100), + )} diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomeCards.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomeCards.tsx new file mode 100644 index 00000000000..b876b24f108 --- /dev/null +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomeCards.tsx @@ -0,0 +1,300 @@ +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { PredictMarketGame, PredictOutcomeGroup } from '../../types'; +import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; +import { isMoneylineLikeMarketType } from '../../constants/sports'; +import PredictSportOutcomeCard from '../PredictSportOutcomeCard'; +import { + buildButtons, + buildMoneylineButtons, + buildMoneylineSubtitle, + buildSubtitle, + type BuyHandler, + formatOutcomeCardTitle, + getDefaultLineIndex, + getFallbackSportsMarketTypeLabel, + getSportsMarketTypeLabel, + getTranslatedSportsMarketTypeLabel, + sortMoneylineOutcomes, +} from './utils'; + +const SimpleOutcomeCard = memo( + ({ + outcome, + title, + onBuyPress, + game, + sportsMarketType, + testID, + }: { + outcome: PredictOutcomeGroup['outcomes'][number]; + title: string; + onBuyPress: BuyHandler; + game?: PredictMarketGame; + sportsMarketType?: string; + testID: string; + }) => ( + + ), +); + +SimpleOutcomeCard.displayName = 'SimpleOutcomeCard'; + +const LineOutcomeCard = memo( + ({ + outcomes, + title, + onBuyPress, + game, + sportsMarketType, + testID, + }: { + outcomes: PredictOutcomeGroup['outcomes']; + title?: string; + onBuyPress: BuyHandler; + game?: PredictMarketGame; + sportsMarketType?: string; + testID: string; + }) => { + const lineIndices = useMemo( + () => + outcomes + .map((outcome, index) => (outcome.line != null ? index : -1)) + .filter((index) => index !== -1), + [outcomes], + ); + + const lines = useMemo( + () => lineIndices.map((i) => Math.abs(outcomes[i].line ?? 0)), + [lineIndices, outcomes], + ); + + const defaultSelectedIdx = useMemo( + () => getDefaultLineIndex(outcomes, lineIndices), + [outcomes, lineIndices], + ); + + const [selectedIdx, setSelectedIdx] = useState(defaultSelectedIdx); + + useEffect(() => { + setSelectedIdx(defaultSelectedIdx); + }, [defaultSelectedIdx]); + + const handleSelectLine = useCallback( + (_line: number, indexInLines: number) => { + setSelectedIdx(indexInLines); + }, + [], + ); + + const selectedOutcome = useMemo( + () => outcomes[lineIndices[selectedIdx]] ?? outcomes[0], + [outcomes, lineIndices, selectedIdx], + ); + + if (!selectedOutcome) return null; + + return ( + + ); + }, +); + +LineOutcomeCard.displayName = 'LineOutcomeCard'; + +const MoneylineCard = memo( + ({ + outcomes, + onBuyPress, + game, + title, + testID, + }: { + outcomes: PredictOutcomeGroup['outcomes']; + onBuyPress: BuyHandler; + game?: PredictMarketGame; + title: string; + testID: string; + }) => { + const subtitle = useMemo( + () => buildMoneylineSubtitle(outcomes), + [outcomes], + ); + const tokenIds = useMemo( + () => + sortMoneylineOutcomes(outcomes, game) + .map((outcome) => outcome.tokens[0]?.id) + .filter((id): id is string => Boolean(id)), + [outcomes, game], + ); + const { getPrice } = useLiveMarketPrices(tokenIds); + const buttons = useMemo( + () => buildMoneylineButtons(outcomes, onBuyPress, game, getPrice), + [outcomes, onBuyPress, game, getPrice], + ); + + return ( + + ); + }, +); + +MoneylineCard.displayName = 'MoneylineCard'; + +const SubgroupCards = memo( + ({ + subgroup, + onBuyPress, + game, + groupKey, + index, + }: { + subgroup: PredictOutcomeGroup; + onBuyPress: BuyHandler; + game?: PredictMarketGame; + groupKey: string; + index: number; + }) => { + const translatedTitle = getTranslatedSportsMarketTypeLabel(subgroup.key); + const firstOutcomeTitle = subgroup.outcomes[0] + ? formatOutcomeCardTitle(subgroup.outcomes[0]) + : undefined; + const title = getFallbackSportsMarketTypeLabel( + subgroup.key, + translatedTitle ?? firstOutcomeTitle, + ); + const testID = `${groupKey}-${subgroup.key}-${index}`; + + if ( + isMoneylineLikeMarketType(subgroup.key) && + subgroup.outcomes.length > 1 + ) { + return ( + + ); + } + + if (subgroup.outcomes.length === 1) { + return ( + + ); + } + + return ( + + ); + }, +); + +SubgroupCards.displayName = 'SubgroupCards'; + +export const OutcomesContent = memo( + ({ + group, + onBuyPress, + game, + }: { + group: PredictOutcomeGroup; + onBuyPress: BuyHandler; + game?: PredictMarketGame; + }) => { + if (group.subgroups && group.subgroups.length > 0) { + return ( + <> + {group.subgroups.map((subgroup, index) => ( + + ))} + + ); + } + + const firstType = group.outcomes[0]?.sportsMarketType; + if ( + firstType && + isMoneylineLikeMarketType(firstType) && + group.outcomes.length > 1 + ) { + return ( + + ); + } + + return ( + <> + {group.outcomes.map((outcome, index) => ( + + ))} + + ); + }, +); + +OutcomesContent.displayName = 'OutcomesContent'; diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx index 0fb0c4cb5f2..932ac2e6fa1 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import PredictGameOutcomesTab, { - getSportsMarketTypeLabel, -} from './PredictGameOutcomesTab'; +import Engine from '../../../../../core/Engine'; +import PredictGameOutcomesTab from './PredictGameOutcomesTab'; import type { PredictMarketGame, PredictOutcome, @@ -12,7 +11,6 @@ import type { import type { PredictSportOutcomeButton } from '../PredictSportOutcomeCard'; import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsContent.testIds'; import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; -import Logger from '../../../../../util/Logger'; jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { @@ -40,11 +38,30 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -jest.mock('../../../../../util/Logger', () => ({ - __esModule: true, - default: { error: jest.fn() }, +jest.mock('../../../../../core/Engine', () => { + const mockContext = { + PredictController: { + getPrices: jest.fn(), + }, + }; + + return { + context: mockContext, + }; +}); + +const mockGetPrices = jest.mocked(Engine.context.PredictController.getPrices); + +const mockUsePredictPreviewSheet = jest.fn(() => ({ + isBuySheetOpen: false, +})); + +jest.mock('../../contexts', () => ({ + usePredictPreviewSheet: () => mockUsePredictPreviewSheet(), })); +const emptyPriceResponse = { providerId: '', results: [] }; + const mockGetLivePrice = jest.fn(); jest.mock('../../hooks/useLiveMarketPrices', () => ({ @@ -67,6 +84,7 @@ interface CapturedCard { buttonLayout?: string; lines?: number[]; selectedLine?: number; + selectedIndex?: number; testID?: string; } @@ -79,6 +97,7 @@ interface MockCardProps { buttonLayout?: string; lines?: number[]; selectedLine?: number; + selectedIndex?: number; onSelectLine?: (line: number, index: number) => void; testID?: string; } @@ -99,6 +118,7 @@ jest.mock('../PredictSportOutcomeCard', () => { buttonLayout: props.buttonLayout, lines: props.lines, selectedLine: props.selectedLine, + selectedIndex: props.selectedIndex, testID: props.testID, }); return ( @@ -214,93 +234,15 @@ const mockGame: PredictMarketGame = { describe('PredictGameOutcomesTab', () => { beforeEach(() => { jest.clearAllMocks(); + mockUsePredictPreviewSheet.mockReturnValue({ + isBuySheetOpen: false, + }); mockGetLivePrice.mockReturnValue(undefined); + mockGetPrices.mockResolvedValue(emptyPriceResponse); mockCapturedCards = []; }); - describe('getSportsMarketTypeLabel', () => { - it('returns translated label for known type', () => { - expect(getSportsMarketTypeLabel('moneyline')).toBe('Moneyline'); - }); - - it('returns translated label for basketball market types', () => { - expect(getSportsMarketTypeLabel('basketball_odd_even')).toBe( - 'Odd/Even Score', - ); - expect(getSportsMarketTypeLabel('basketball_team_to_score_first')).toBe( - 'Team to Score First', - ); - }); - - it('returns translated label for tennis market types', () => { - expect(getSportsMarketTypeLabel('tennis_match_totals')).toBe( - 'Total Games', - ); - expect(getSportsMarketTypeLabel('tennis_set_handicap')).toBe( - 'Set Handicap', - ); - expect(getSportsMarketTypeLabel('tennis_first_set_winner')).toBe( - '1st Set Winner', - ); - }); - - it('returns title-cased fallback for unknown type', () => { - expect(getSportsMarketTypeLabel('unknown_type')).toBe('Unknown Type'); - }); - - it('returns provided fallback when translation is missing', () => { - expect( - getSportsMarketTypeLabel('basketball_unknown_market', 'Fallback Title'), - ).toBe('Fallback Title'); - }); - - it('logs missing translations only once per key', () => { - const mockLoggerError = jest.mocked(Logger.error); - const type = 'basketball_logged_once_market'; - const key = `predict.sports_market_types.${type}`; - const message = `Missing Predict sports market type translation: ${key}`; - - expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe( - 'Fallback Title', - ); - expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe( - 'Fallback Title', - ); - - expect(mockLoggerError).toHaveBeenCalledTimes(1); - expect(mockLoggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message, - }), - { - message, - context: { - key, - type, - }, - }, - ); - }); - }); - describe('group selection', () => { - it('renders outcomes content container', () => { - const groups = [createGroup({ key: 'game_lines' })]; - - const { getByTestId } = render( - , - ); - - expect( - getByTestId(PREDICT_GAME_DETAILS_CONTENT_TEST_IDS.OUTCOMES_CONTENT), - ).toBeOnTheScreen(); - }); - it('renders nothing inside container when no group matches activeChipKey', () => { const groups = [createGroup({ key: 'game_lines' })]; @@ -358,83 +300,6 @@ describe('PredictGameOutcomesTab', () => { expect(getByTestId('points-outcome-0')).toBeOnTheScreen(); expect(getByTestId('points-outcome-1')).toBeOnTheScreen(); }); - - it('uses formatOutcomeCardTitle to strip O/U suffix from card title', () => { - const outcome = createOutcome({ - id: 'o-ou', - groupItemTitle: 'O/U 45.5 : Points (Over/Under)', - }); - const groups = [createGroup({ key: 'points', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].title).toBe('O/U 45.5'); - }); - - it('extracts player name from O/U title with colon separator', () => { - const outcome = createOutcome({ - id: 'o-player-ou', - groupItemTitle: 'Dean Wade: Points O/U 0.5', - }); - const groups = [createGroup({ key: 'points', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].title).toBe('Dean Wade'); - }); - - it('preserves title when no O/U colon pattern exists', () => { - const outcome = createOutcome({ - id: 'o-plain', - groupItemTitle: 'Player Props', - }); - const groups = [createGroup({ key: 'points', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].title).toBe('Player Props'); - }); - - it('falls back to outcome.title when groupItemTitle is missing', () => { - const outcome = createOutcome({ - id: 'o-notitle', - groupItemTitle: undefined, - title: 'Fallback Title', - }); - const groups = [createGroup({ key: 'points', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].title).toBe('Fallback Title'); - }); }); describe('subgroups', () => { @@ -519,6 +384,39 @@ describe('PredictGameOutcomesTab', () => { expect(mockCapturedCards[0].selectedLine).toBe(3.5); }); + it('assigns first_half_moneyline variants through subgroup routing', () => { + const outcome = createOutcome({ + sportsMarketType: 'first_half_moneyline', + tokens: [ + createToken({ shortTitle: 'TA' }), + createToken({ shortTitle: 'TB' }), + ], + }); + const subgroups: PredictOutcomeGroup[] = [ + createGroup({ + key: 'first_half_moneyline', + outcomes: [outcome], + }), + ]; + const groups = [ + createGroup({ key: 'first_half', outcomes: [], subgroups }), + ]; + + render( + , + ); + + expect(mockCapturedCards[0].buttons[0].variant).toBe('yes'); + expect(mockCapturedCards[0].buttons[0].teamColor).toBe( + TEST_HEX_COLORS.PURE_RED, + ); + }); + it('uses outcome title when subgroup market type translation is missing', () => { const subgroups: PredictOutcomeGroup[] = [ createGroup({ @@ -593,15 +491,21 @@ describe('PredictGameOutcomesTab', () => { }); }); - describe('button building', () => { - it('converts token price to cents for button price', () => { - const outcome = createOutcome({ - tokens: [ - createToken({ price: 0.65, shortTitle: 'TA' }), - createToken({ price: 0.35, shortTitle: 'TB' }), - ], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; + describe('line selection', () => { + it('preserves incoming line order', () => { + const subgroups: PredictOutcomeGroup[] = [ + createGroup({ + key: 'spreads', + outcomes: [ + createOutcome({ id: 's1', line: 7.5 }), + createOutcome({ id: 's2', line: 3.5 }), + createOutcome({ id: 's3', line: 10.5 }), + ], + }), + ]; + const groups = [ + createGroup({ key: 'game_lines', outcomes: [], subgroups }), + ]; render( { />, ); - expect(mockCapturedCards[0].buttons[0].price).toBe(65); - expect(mockCapturedCards[0].buttons[1].price).toBe(35); + expect(mockCapturedCards[0].lines).toEqual([7.5, 3.5, 10.5]); }); - it('uses shortTitle for button label when available', () => { - const outcome = createOutcome({ - tokens: [createToken({ title: 'Full Team Name', shortTitle: 'FTN' })], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; + it('defaults selected line to the highest volume outcome', () => { + const subgroups: PredictOutcomeGroup[] = [ + createGroup({ + key: 'spreads', + outcomes: [ + createOutcome({ + id: 's1', + line: 7.5, + volume: 1000, + liquidity: 10000, + }), + createOutcome({ + id: 's2', + line: 3.5, + volume: 5000, + liquidity: 50, + }), + ], + }), + ]; + const groups = [ + createGroup({ key: 'game_lines', outcomes: [], subgroups }), + ]; render( { />, ); - expect(mockCapturedCards[0].buttons[0].label).toBe('FTN'); + expect(mockCapturedCards[0].selectedLine).toBe(3.5); + expect(mockCapturedCards[0].selectedIndex).toBe(1); }); - it('falls back to title when shortTitle is missing', () => { - const outcome = createOutcome({ - tokens: [ - createToken({ title: 'Full Team Name', shortTitle: undefined }), - ], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; + it('switches displayed outcome when line is selected', () => { + const subgroups: PredictOutcomeGroup[] = [ + createGroup({ + key: 'spreads', + outcomes: [ + createOutcome({ + id: 's1', + line: 3.5, + volume: 5000, + tokens: [ + createToken({ shortTitle: 'TA', price: 0.6 }), + createToken({ shortTitle: 'TB', price: 0.4 }), + ], + }), + createOutcome({ + id: 's2', + line: 7.5, + volume: 1000, + tokens: [ + createToken({ shortTitle: 'TA', price: 0.8 }), + createToken({ shortTitle: 'TB', price: 0.2 }), + ], + }), + ], + }), + ]; + const groups = [ + createGroup({ key: 'game_lines', outcomes: [], subgroups }), + ]; - render( + const { getByTestId } = render( { />, ); - expect(mockCapturedCards[0].buttons[0].label).toBe('Full Team Name'); + expect(mockCapturedCards[0].buttons[0].price).toBe(60); + + mockCapturedCards = []; + fireEvent(getByTestId('game_lines-spreads-0-line-1-7.5'), 'touchEnd'); + + expect(mockCapturedCards[0].buttons[0].price).toBe(80); }); - it('calls onBuyPress with outcome and token when button is pressed', () => { - const token = createToken({ id: 'tok-press', shortTitle: 'TA' }); - const outcome = createOutcome({ id: 'out-press', tokens: [token] }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; + it('selects duplicate line labels by index', () => { + const subgroups: PredictOutcomeGroup[] = [ + createGroup({ + key: 'spreads', + outcomes: [ + createOutcome({ + id: 'spread-plus-2.5', + line: -2.5, + tokens: [createToken({ shortTitle: 'TA +2.5', price: 0.9 })], + }), + createOutcome({ + id: 'spread-plus-1.5', + line: -1.5, + tokens: [createToken({ shortTitle: 'TA +1.5', price: 0.8 })], + }), + createOutcome({ + id: 'spread-minus-1.5', + line: -1.5, + tokens: [createToken({ shortTitle: 'TA -1.5', price: 0.6 })], + }), + createOutcome({ + id: 'spread-minus-2.5', + line: -2.5, + tokens: [createToken({ shortTitle: 'TA -2.5', price: 0.4 })], + }), + ], + }), + ]; + const groups = [ + createGroup({ key: 'game_lines', outcomes: [], subgroups }), + ]; const { getByTestId } = render( { />, ); - fireEvent(getByTestId('game_lines-outcome-0-btn-0'), 'touchEnd'); + expect(mockCapturedCards[0].lines).toEqual([2.5, 1.5, 1.5, 2.5]); + + mockCapturedCards = []; + fireEvent(getByTestId('game_lines-spreads-0-line-2-1.5'), 'touchEnd'); - expect(mockOnBuyPress).toHaveBeenCalledWith(outcome, token); + expect(mockCapturedCards[0].selectedLine).toBe(1.5); + expect(mockCapturedCards[0].selectedIndex).toBe(2); + expect(mockCapturedCards[0].buttons[0].price).toBe(60); }); }); - describe('moneyline variant and team colors', () => { - it('assigns team colors for moneyline type', () => { - const outcome = createOutcome({ - sportsMarketType: 'moneyline', - tokens: [ - createToken({ shortTitle: 'TA', price: 0.6 }), - createToken({ shortTitle: 'TB', price: 0.4 }), - ], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; - - render( + describe('empty states', () => { + it('renders container with empty groups array', () => { + const { getByTestId } = render( , - ); - - expect(mockCapturedCards[0].buttons[0].teamColor).toBe( - TEST_HEX_COLORS.PURE_RED, - ); - expect(mockCapturedCards[0].buttons[1].teamColor).toBe( - TEST_HEX_COLORS.PURE_BLUE, - ); - }); - - it('assigns yes/no variants for 2-button moneyline', () => { - const outcome = createOutcome({ - sportsMarketType: 'moneyline', - tokens: [ - createToken({ shortTitle: 'TA' }), - createToken({ shortTitle: 'TB' }), - ], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].variant).toBe('yes'); - expect(mockCapturedCards[0].buttons[1].variant).toBe('no'); - }); - - it('assigns draw variant for middle button in 3-button moneyline', () => { - const outcome = createOutcome({ - sportsMarketType: 'moneyline', - tokens: [ - createToken({ id: 't1', shortTitle: 'TA' }), - createToken({ id: 't2', shortTitle: 'Draw' }), - createToken({ id: 't3', shortTitle: 'TB' }), - ], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].variant).toBe('yes'); - expect(mockCapturedCards[0].buttons[1].variant).toBe('draw'); - expect(mockCapturedCards[0].buttons[2].variant).toBe('no'); - }); - - it('assigns first_half_moneyline as moneyline type', () => { - const outcome = createOutcome({ - sportsMarketType: 'first_half_moneyline', - tokens: [ - createToken({ shortTitle: 'TA' }), - createToken({ shortTitle: 'TB' }), - ], - }); - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ - key: 'first_half_moneyline', - outcomes: [outcome], - }), - ]; - const groups = [ - createGroup({ key: 'first_half', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].variant).toBe('yes'); - expect(mockCapturedCards[0].buttons[0].teamColor).toBe( - TEST_HEX_COLORS.PURE_RED, - ); - }); - - it('assigns tennis first set winner team colors from normalized token labels', () => { - const tennisGame: PredictMarketGame = { - ...mockGame, - league: 'atp', - homeTeam: { - ...mockGame.homeTeam, - name: 'Ilya Ivashka', - abbreviation: 'ivashka', - alias: 'I. Ivashka', - color: TEST_HEX_COLORS.PURE_RED, - }, - awayTeam: { - ...mockGame.awayTeam, - name: 'Hamish Stewart', - abbreviation: 'stewart', - alias: 'H. Stewart', - color: TEST_HEX_COLORS.PURE_BLUE, - }, - }; - const outcome = createOutcome({ - sportsMarketType: 'tennis_first_set_winner', - tokens: [ - createToken({ shortTitle: 'IVASHKA' }), - createToken({ shortTitle: 'STEWART' }), - ], - }); - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ - key: 'tennis_first_set_winner', - outcomes: [outcome], - }), - ]; - const groups = [ - createGroup({ key: 'first_set', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0]).toEqual( - expect.objectContaining({ - label: 'IVASHKA', - variant: 'yes', - teamColor: TEST_HEX_COLORS.PURE_RED, - }), - ); - expect(mockCapturedCards[0].buttons[1]).toEqual( - expect.objectContaining({ - label: 'STEWART', - variant: 'no', - teamColor: TEST_HEX_COLORS.PURE_BLUE, - }), - ); - }); - - it('assigns draw variant and no team colors for non-moneyline types', () => { - const outcome = createOutcome({ - sportsMarketType: 'spreads', - tokens: [ - createToken({ shortTitle: 'TA' }), - createToken({ shortTitle: 'TB' }), - ], - }); - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'spreads', outcomes: [outcome] }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].variant).toBe('draw'); - expect(mockCapturedCards[0].buttons[1].variant).toBe('draw'); - expect(mockCapturedCards[0].buttons[0].teamColor).toBeUndefined(); - expect(mockCapturedCards[0].buttons[1].teamColor).toBeUndefined(); - }); - - it('returns undefined team color when abbreviation does not match any team', () => { - const outcome = createOutcome({ - sportsMarketType: 'moneyline', - tokens: [createToken({ shortTitle: 'UNKNOWN' })], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].teamColor).toBeUndefined(); - }); - - it('returns undefined team color when game is not provided', () => { - const outcome = createOutcome({ - sportsMarketType: 'moneyline', - tokens: [createToken({ shortTitle: 'TA' })], - }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].teamColor).toBeUndefined(); - }); - }); - - describe('subtitle formatting', () => { - it('formats volume into subtitle with dollar sign and Vol suffix', () => { - const outcome = createOutcome({ volume: 50000 }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].subtitle).toBe('$50k Vol'); - }); - - it('formats large volume with M suffix', () => { - const outcome = createOutcome({ volume: 2500000 }); - const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].subtitle).toBe('$2.5M Vol'); - }); - }); - - describe('line selection', () => { - it('sorts lines numerically in ascending order', () => { - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ - key: 'spreads', - outcomes: [ - createOutcome({ id: 's1', line: 7.5 }), - createOutcome({ id: 's2', line: 3.5 }), - createOutcome({ id: 's3', line: 10.5 }), - ], - }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].lines).toEqual([3.5, 7.5, 10.5]); - }); - - it('defaults selected line to the first outcome line', () => { - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ - key: 'spreads', - outcomes: [ - createOutcome({ id: 's1', line: 7.5 }), - createOutcome({ id: 's2', line: 3.5 }), - ], - }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].selectedLine).toBe(3.5); - }); - - it('switches displayed outcome when line is selected', () => { - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ - key: 'spreads', - outcomes: [ - createOutcome({ - id: 's1', - line: 3.5, - volume: 1000, - tokens: [ - createToken({ shortTitle: 'TA', price: 0.6 }), - createToken({ shortTitle: 'TB', price: 0.4 }), - ], - }), - createOutcome({ - id: 's2', - line: 7.5, - volume: 5000, - tokens: [ - createToken({ shortTitle: 'TA', price: 0.8 }), - createToken({ shortTitle: 'TB', price: 0.2 }), - ], - }), - ], - }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - const { getByTestId } = render( - , - ); - - expect(mockCapturedCards[0].buttons[0].price).toBe(60); - - mockCapturedCards = []; - fireEvent(getByTestId('game_lines-spreads-0-line-1-7.5'), 'touchEnd'); - - expect(mockCapturedCards[0].buttons[0].price).toBe(80); - }); - }); - - describe('empty states', () => { - it('renders container with empty groups array', () => { - const { getByTestId } = render( - { expect(mockCapturedCards).toHaveLength(1); expect(mockCapturedCards[0].buttonLayout).toBe('inlineNoSeparator'); expect(mockCapturedCards[0].buttons[0].label).toBe('HOM'); - expect(mockCapturedCards[0].buttons[1].label).toBe('Draw'); - expect(mockCapturedCards[0].buttons[2].label).toBe('AWY'); - }); - - it('sums volumes from all outcomes for moneyline subgroup subtitle', () => { - const outcomes = [ - createOutcome({ - id: 'ml-a', - groupItemThreshold: 0, - volume: 10000, - tokens: [createToken({ shortTitle: 'TA', price: 0.6 })], - }), - createOutcome({ - id: 'ml-b', - groupItemThreshold: 1, - volume: 20000, - tokens: [createToken({ shortTitle: 'TB', price: 0.4 })], - }), - ]; - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'moneyline', outcomes }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].subtitle).toBe('$30k Vol'); - }); - - it('builds moneyline buttons with correct prices and variants', () => { - const outcomes = [ - createOutcome({ - id: 'ml-h', - groupItemThreshold: 0, - volume: 1000, - tokens: [createToken({ shortTitle: 'TA', price: 0.45 })], - }), - createOutcome({ - id: 'ml-d', - groupItemThreshold: 1, - volume: 1000, - tokens: [createToken({ shortTitle: 'Draw', price: 0.3 })], - }), - createOutcome({ - id: 'ml-a', - groupItemThreshold: 2, - volume: 1000, - tokens: [createToken({ shortTitle: 'TB', price: 0.25 })], - }), - ]; - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'moneyline', outcomes }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].price).toBe(45); + expect(mockCapturedCards[0].buttons[0].price).toBe(55); expect(mockCapturedCards[0].buttons[0].variant).toBe('yes'); - expect(mockCapturedCards[0].buttons[1].price).toBe(30); + expect(mockCapturedCards[0].buttons[1].label).toBe('Draw'); + expect(mockCapturedCards[0].buttons[1].price).toBe(20); expect(mockCapturedCards[0].buttons[1].variant).toBe('draw'); + expect(mockCapturedCards[0].buttons[2].label).toBe('AWY'); expect(mockCapturedCards[0].buttons[2].price).toBe(25); expect(mockCapturedCards[0].buttons[2].variant).toBe('no'); }); @@ -1232,39 +765,6 @@ describe('PredictGameOutcomesTab', () => { expect(mockCapturedCards[0].buttons[1].price).toBe(24); }); - it('falls back to static token price when live best ask is zero', () => { - mockGetLivePrice.mockReturnValue({ - tokenId: 'tok-a', - price: 0, - bestBid: 0, - bestAsk: 0, - }); - - const outcomes = [ - createOutcome({ - id: 'ml-a', - tokens: [createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.45 })], - }), - ]; - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'moneyline', outcomes }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].price).toBe(45); - }); - it('calls onBuyPress with correct outcome and token for moneyline button', () => { const tokenA = createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.6 }); const tokenB = createToken({ id: 'tok-b', shortTitle: 'TB', price: 0.4 }); @@ -1300,169 +800,6 @@ describe('PredictGameOutcomesTab', () => { expect(mockOnBuyPress).toHaveBeenCalledWith(outcomeB, tokenB); }); - - it('skips outcomes without tokens when building moneyline buttons', () => { - const outcomes = [ - createOutcome({ - id: 'ml-home', - groupItemThreshold: 0, - groupItemTitle: 'Home Win', - tokens: [createToken({ shortTitle: 'HOM', price: 0.55 })], - }), - createOutcome({ - id: 'ml-empty', - groupItemThreshold: 1, - groupItemTitle: 'Draw', - tokens: [], - }), - createOutcome({ - id: 'ml-away', - groupItemThreshold: 2, - groupItemTitle: 'Away Win', - tokens: [createToken({ shortTitle: 'AWY', price: 0.45 })], - }), - ]; - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'moneyline', outcomes }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons).toHaveLength(2); - expect(mockCapturedCards[0].buttons[0].label).toBe('HOM'); - expect(mockCapturedCards[0].buttons[1].label).toBe('AWY'); - }); - }); - - describe('moneyline sorting without thresholds', () => { - it('places home team first, draw middle, away last when game is provided', () => { - const outcomes = [ - createOutcome({ - id: 'ml-draw', - groupItemTitle: 'Draw', - volume: 1000, - tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })], - }), - createOutcome({ - id: 'ml-away', - groupItemTitle: 'Away Win', - volume: 2000, - tokens: [createToken({ shortTitle: 'TB', price: 0.3 })], - }), - createOutcome({ - id: 'ml-home', - groupItemTitle: 'Home Win', - volume: 3000, - tokens: [createToken({ shortTitle: 'TA', price: 0.5 })], - }), - ]; - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'moneyline', outcomes }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].label).toBe('TA'); - expect(mockCapturedCards[0].buttons[1].label).toBe('Draw'); - expect(mockCapturedCards[0].buttons[2].label).toBe('TB'); - }); - - it('places draw second when no game is provided and no thresholds', () => { - const outcomes = [ - createOutcome({ - id: 'ml-draw', - groupItemTitle: 'Draw', - volume: 1000, - tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })], - }), - createOutcome({ - id: 'ml-first', - groupItemTitle: 'Team X', - volume: 2000, - tokens: [createToken({ shortTitle: 'TX', price: 0.4 })], - }), - createOutcome({ - id: 'ml-second', - groupItemTitle: 'Team Y', - volume: 3000, - tokens: [createToken({ shortTitle: 'TY', price: 0.4 })], - }), - ]; - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'moneyline', outcomes }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].label).toBe('TX'); - expect(mockCapturedCards[0].buttons[1].label).toBe('Draw'); - expect(mockCapturedCards[0].buttons[2].label).toBe('TY'); - }); - - it('preserves original order when draw is present but fewer than 2 non-draw outcomes', () => { - const outcomes = [ - createOutcome({ - id: 'ml-draw', - groupItemTitle: 'Draw', - volume: 1000, - tokens: [createToken({ shortTitle: 'Draw', price: 0.5 })], - }), - createOutcome({ - id: 'ml-only', - groupItemTitle: 'Team X', - volume: 2000, - tokens: [createToken({ shortTitle: 'TX', price: 0.5 })], - }), - ]; - const subgroups: PredictOutcomeGroup[] = [ - createGroup({ key: 'moneyline', outcomes }), - ]; - const groups = [ - createGroup({ key: 'game_lines', outcomes: [], subgroups }), - ]; - - render( - , - ); - - expect(mockCapturedCards[0].buttons[0].label).toBe('Draw'); - expect(mockCapturedCards[0].buttons[1].label).toBe('TX'); - }); }); describe('flat outcomes moneyline rendering', () => { @@ -1531,25 +868,4 @@ describe('PredictGameOutcomesTab', () => { expect(mockCapturedCards[0].buttonLayout).toBeUndefined(); }); }); - - describe('formatOutcomeCardTitle edge cases', () => { - it('strips O/U suffix when title has colon but no player pattern match', () => { - const outcome = createOutcome({ - id: 'o-colon-ou', - groupItemTitle: 'Total: O/U 2.5', - }); - const groups = [createGroup({ key: 'totals', outcomes: [outcome] })]; - - render( - , - ); - - expect(mockCapturedCards[0].title).toBe('Total'); - }); - }); }); diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx index 862d1c5626d..aa5ef2ca4db 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo } from 'react'; import { Box } from '@metamask/design-system-react-native'; import type { PredictMarketGame, @@ -6,484 +6,11 @@ import type { PredictOutcomeGroup, PredictOutcomeToken, } from '../../types'; -import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; -import type { PredictBetButtonVariant } from '../PredictActionButtons/PredictActionButtons.types'; -import PredictSportOutcomeCard, { - type PredictSportOutcomeButton, -} from '../PredictSportOutcomeCard'; -import { formatVolume } from '../../utils/format'; -import { isValidPrice } from '../../utils/prices'; -import { isMoneylineLikeMarketType } from '../../constants/sports'; -import { strings } from '../../../../../../locales/i18n'; -import Logger from '../../../../../util/Logger'; import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsContent.testIds'; +import { OutcomesContent } from './PredictGameOutcomeCards'; +import { usePricedOutcomeGroup } from './usePricedOutcomeGroup'; -const I18N_PREFIX = 'predict.sports_market_types'; -const MISSING_TRANSLATION_PREFIX = '[missing'; -const loggedMissingTranslationKeys = new Set(); - -const toTitleCase = (str: string): string => - str - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - -const isMissingTranslation = (value: string, key: string): boolean => - value === key || value.startsWith(MISSING_TRANSLATION_PREFIX); - -const logMissingSportsMarketTypeTranslation = ( - key: string, - type: string, -): void => { - if (loggedMissingTranslationKeys.has(key)) return; - - loggedMissingTranslationKeys.add(key); - const message = `Missing Predict sports market type translation: ${key}`; - Logger.error(new Error(message), { - message, - context: { key, type }, - }); -}; - -const getTranslatedSportsMarketTypeLabel = ( - type: string, -): string | undefined => { - const key = `${I18N_PREFIX}.${type}`; - const label = strings(key); - if (typeof label !== 'string' || isMissingTranslation(label, key)) { - logMissingSportsMarketTypeTranslation(key, type); - return undefined; - } - return label; -}; - -export const getSportsMarketTypeLabel = ( - type: string, - fallbackTitle?: string, -): string => - getTranslatedSportsMarketTypeLabel(type) ?? - fallbackTitle ?? - toTitleCase(type); - -type BuyHandler = (outcome: PredictOutcome, token: PredictOutcomeToken) => void; - -const O_U_PLAYER_PATTERN = /^(.+?):\s+\w+ O\/U/; - -const formatOutcomeCardTitle = (outcome: PredictOutcome): string => { - const raw = outcome.groupItemTitle || outcome.title; - if (!raw.includes('O/U')) return raw; - - const match = raw.match(O_U_PLAYER_PATTERN); - if (match) return match[1].trim(); - - const colonIdx = raw.indexOf(': '); - if (colonIdx !== -1) return raw.slice(0, colonIdx).trim(); - - return raw; -}; - -const getTeamColor = ( - tokenTitle: string, - game?: PredictMarketGame, -): string | undefined => { - if (!game) return undefined; - - const normalizedTokenTitle = tokenTitle.trim().toLowerCase(); - const homeLabels = [ - game.homeTeam.abbreviation, - game.homeTeam.name, - game.homeTeam.alias, - ] - .filter((label): label is string => Boolean(label)) - .map((label) => label.trim().toLowerCase()); - const awayLabels = [ - game.awayTeam.abbreviation, - game.awayTeam.name, - game.awayTeam.alias, - ] - .filter((label): label is string => Boolean(label)) - .map((label) => label.trim().toLowerCase()); - - if (homeLabels.includes(normalizedTokenTitle)) return game.homeTeam.color; - if (awayLabels.includes(normalizedTokenTitle)) return game.awayTeam.color; - return undefined; -}; - -const getButtonVariant = ( - index: number, - total: number, - moneyline: boolean, -): PredictBetButtonVariant => { - if (!moneyline) return 'draw'; - if (total === 3 && index === 1) return 'draw'; - return index === 0 ? 'yes' : 'no'; -}; - -const buildButtons = ( - outcome: PredictOutcome, - onBuyPress: BuyHandler, - game?: PredictMarketGame, - sportsMarketType?: string, -): PredictSportOutcomeButton[] => { - const moneyline = isMoneylineLikeMarketType(sportsMarketType); - return outcome.tokens.map((token, index) => ({ - label: token.shortTitle ?? token.title, - price: Math.round(token.price * 100), - onPress: () => onBuyPress(outcome, token), - variant: getButtonVariant(index, outcome.tokens.length, moneyline), - teamColor: moneyline - ? getTeamColor(token.shortTitle ?? token.title, game) - : undefined, - })); -}; - -const buildSubtitle = (outcome: PredictOutcome): string => - `$${formatVolume(outcome.volume)} Vol`; - -const SimpleOutcomeCard = memo( - ({ - outcome, - title, - onBuyPress, - game, - sportsMarketType, - testID, - }: { - outcome: PredictOutcome; - title: string; - onBuyPress: BuyHandler; - game?: PredictMarketGame; - sportsMarketType?: string; - testID: string; - }) => ( - - ), -); - -SimpleOutcomeCard.displayName = 'SimpleOutcomeCard'; - -const LineOutcomeCard = memo( - ({ - outcomes, - title, - onBuyPress, - game, - sportsMarketType, - testID, - }: { - outcomes: PredictOutcome[]; - title?: string; - onBuyPress: BuyHandler; - game?: PredictMarketGame; - sportsMarketType?: string; - testID: string; - }) => { - const lineIndices = useMemo( - () => - outcomes - .map((o, i) => (o.line != null ? i : -1)) - .filter((i) => i !== -1) - .sort((a, b) => { - const lineA = outcomes[a].line ?? 0; - const lineB = outcomes[b].line ?? 0; - return lineA - lineB; - }), - [outcomes], - ); - - const lines = useMemo( - () => lineIndices.map((i) => Math.abs(outcomes[i].line ?? 0)), - [lineIndices, outcomes], - ); - - const [selectedIdx, setSelectedIdx] = useState(0); - - useEffect(() => { - setSelectedIdx(0); - }, [outcomes]); - - const handleSelectLine = useCallback( - (_line: number, indexInLines: number) => { - setSelectedIdx(indexInLines); - }, - [], - ); - - const selectedOutcome = useMemo( - () => outcomes[lineIndices[selectedIdx]] ?? outcomes[0], - [outcomes, lineIndices, selectedIdx], - ); - - if (!selectedOutcome) return null; - - return ( - - ); - }, -); - -LineOutcomeCard.displayName = 'LineOutcomeCard'; - -const isDrawOutcome = (outcome: PredictOutcome): boolean => - outcome.groupItemTitle?.toLowerCase().startsWith('draw') ?? false; - -const sortMoneylineOutcomes = ( - outcomes: PredictOutcome[], - game?: PredictMarketGame, -): PredictOutcome[] => { - const hasThresholds = outcomes.some((o) => o.groupItemThreshold != null); - if (hasThresholds) { - return [...outcomes].sort( - (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0), - ); - } - - const draw = outcomes.find(isDrawOutcome); - const nonDraw = outcomes.filter((o) => !isDrawOutcome(o)); - if (!draw || nonDraw.length < 2) { - return [...outcomes]; - } - - if (game) { - const homeAbbr = game.homeTeam.abbreviation; - const home = nonDraw.find((o) => o.tokens[0]?.shortTitle === homeAbbr); - const away = nonDraw.find((o) => o !== home); - if (home && away) { - return [home, draw, away]; - } - } - - const sorted = [...nonDraw].sort((a, b) => - (a.groupItemTitle ?? '').localeCompare(b.groupItemTitle ?? ''), - ); - return [sorted[0], draw, ...sorted.slice(1)]; -}; - -const buildMoneylineButtons = ( - outcomes: PredictOutcome[], - onBuyPress: BuyHandler, - game?: PredictMarketGame, - getPrice?: ReturnType['getPrice'], -): PredictSportOutcomeButton[] => { - const sortedWithTokens = sortMoneylineOutcomes(outcomes, game).filter( - (outcome) => outcome.tokens[0] !== undefined, - ); - - return sortedWithTokens.map((outcome, i) => { - const yesToken = outcome.tokens[0]; - const liveBestAsk = getPrice?.(yesToken.id)?.bestAsk; - const price = isValidPrice(liveBestAsk) ? liveBestAsk : yesToken.price; - - return { - label: yesToken.shortTitle ?? yesToken.title, - price: Math.round(price * 100), - onPress: () => onBuyPress(outcome, yesToken), - variant: getButtonVariant(i, sortedWithTokens.length, true), - teamColor: getTeamColor(yesToken.shortTitle ?? yesToken.title, game), - }; - }); -}; - -const buildMoneylineSubtitle = (outcomes: PredictOutcome[]): string => { - const totalVolume = outcomes.reduce((sum, o) => sum + o.volume, 0); - return `$${formatVolume(totalVolume)} Vol`; -}; - -const MoneylineCard = memo( - ({ - outcomes, - onBuyPress, - game, - title, - testID, - }: { - outcomes: PredictOutcome[]; - onBuyPress: BuyHandler; - game?: PredictMarketGame; - title: string; - testID: string; - }) => { - const subtitle = useMemo( - () => buildMoneylineSubtitle(outcomes), - [outcomes], - ); - const tokenIds = useMemo( - () => - sortMoneylineOutcomes(outcomes, game) - .map((outcome) => outcome.tokens[0]?.id) - .filter((id): id is string => Boolean(id)), - [outcomes, game], - ); - const { getPrice } = useLiveMarketPrices(tokenIds); - const buttons = useMemo( - () => buildMoneylineButtons(outcomes, onBuyPress, game, getPrice), - [outcomes, onBuyPress, game, getPrice], - ); - - return ( - - ); - }, -); - -MoneylineCard.displayName = 'MoneylineCard'; - -const SubgroupCards = memo( - ({ - subgroup, - onBuyPress, - game, - groupKey, - index, - }: { - subgroup: PredictOutcomeGroup; - onBuyPress: BuyHandler; - game?: PredictMarketGame; - groupKey: string; - index: number; - }) => { - const translatedTitle = getTranslatedSportsMarketTypeLabel(subgroup.key); - const firstOutcomeTitle = subgroup.outcomes[0] - ? formatOutcomeCardTitle(subgroup.outcomes[0]) - : undefined; - const title = - translatedTitle ?? firstOutcomeTitle ?? toTitleCase(subgroup.key); - const testID = `${groupKey}-${subgroup.key}-${index}`; - - if ( - isMoneylineLikeMarketType(subgroup.key) && - subgroup.outcomes.length > 1 - ) { - return ( - - ); - } - - if (subgroup.outcomes.length === 1) { - return ( - - ); - } - - return ( - - ); - }, -); - -SubgroupCards.displayName = 'SubgroupCards'; - -const OutcomesContent = memo( - ({ - group, - onBuyPress, - game, - }: { - group: PredictOutcomeGroup; - onBuyPress: BuyHandler; - game?: PredictMarketGame; - }) => { - if (group.subgroups && group.subgroups.length > 0) { - return ( - <> - {group.subgroups.map((subgroup, index) => ( - - ))} - - ); - } - - const firstType = group.outcomes[0]?.sportsMarketType; - if ( - firstType && - isMoneylineLikeMarketType(firstType) && - group.outcomes.length > 1 - ) { - return ( - - ); - } - - return ( - <> - {group.outcomes.map((outcome, index) => ( - - ))} - - ); - }, -); - -OutcomesContent.displayName = 'OutcomesContent'; +export { getSportsMarketTypeLabel } from './utils'; export interface OutcomesTabProps { groupMap: Map; @@ -495,13 +22,14 @@ export interface OutcomesTabProps { const PredictGameOutcomesTab = memo( ({ groupMap, game, activeChipKey, onBuyPress }: OutcomesTabProps) => { const selectedGroup = groupMap.get(activeChipKey); + const pricedGroup = usePricedOutcomeGroup(selectedGroup); return ( - {selectedGroup && ( + {pricedGroup && ( diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.test.tsx new file mode 100644 index 00000000000..ceb77a94b96 --- /dev/null +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.test.tsx @@ -0,0 +1,288 @@ +import { renderHook } from '@testing-library/react-native'; +import type { + GetPriceResponse, + PredictOutcome, + PredictOutcomeGroup, + PredictOutcomeToken, +} from '../../types'; +import { usePricedOutcomeGroup } from './usePricedOutcomeGroup'; + +const mockUsePredictPreviewSheet = jest.fn(() => ({ + isBuySheetOpen: false, +})); + +jest.mock('../../contexts', () => ({ + usePredictPreviewSheet: () => mockUsePredictPreviewSheet(), +})); + +const mockUsePredictPrices = jest.fn(); + +jest.mock('../../hooks/usePredictPrices', () => ({ + usePredictPrices: (options: unknown) => mockUsePredictPrices(options), +})); + +const emptyPriceResponse: GetPriceResponse = { + providerId: '', + results: [], +}; + +const createToken = ( + overrides: Partial = {}, +): PredictOutcomeToken => + ({ + id: 'token-1', + title: 'Team A', + shortTitle: 'TA', + price: 0.65, + ...overrides, + }) as PredictOutcomeToken; + +const createOutcome = ( + overrides: Partial = {}, +): PredictOutcome => + ({ + id: 'outcome-1', + marketId: 'market-1', + title: 'Team A vs Team B', + groupItemTitle: 'Team A vs Team B', + status: 'open', + volume: 50000, + sportsMarketType: 'points', + tokens: [createToken({ id: 'tok-points', price: 0.65 })], + ...overrides, + }) as PredictOutcome; + +const createGroup = ( + overrides: Partial = {}, +): PredictOutcomeGroup => ({ + key: 'points', + outcomes: [createOutcome()], + ...overrides, +}); + +describe('usePricedOutcomeGroup', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUsePredictPreviewSheet.mockReturnValue({ isBuySheetOpen: false }); + mockUsePredictPrices.mockReturnValue({ + prices: emptyPriceResponse, + isFetching: false, + error: null, + refetch: jest.fn(), + }); + }); + + it('does not fetch prices while the buy sheet is open', () => { + mockUsePredictPreviewSheet.mockReturnValue({ isBuySheetOpen: true }); + const group = createGroup(); + + renderHook(() => usePricedOutcomeGroup(group)); + + expect(mockUsePredictPrices).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + queries: [], + }), + ); + }); + + it('applies sell prices to pollable group outcomes', () => { + const group = createGroup({ + key: 'points', + outcomes: [ + createOutcome({ + id: 'o-points', + sportsMarketType: 'points', + tokens: [createToken({ id: 'tok-points', price: 0.65 })], + }), + ], + }); + mockUsePredictPrices.mockReturnValue({ + prices: { + providerId: 'polymarket', + results: [ + { + marketId: 'market-1', + outcomeId: 'o-points', + outcomeTokenId: 'tok-points', + entry: { buy: 0.71, sell: 0.72 }, + }, + ], + }, + isFetching: false, + error: null, + refetch: jest.fn(), + }); + + const { result } = renderHook(() => usePricedOutcomeGroup(group)); + + expect(mockUsePredictPrices).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + queries: [ + { + marketId: 'market-1', + outcomeId: 'o-points', + outcomeTokenId: 'tok-points', + }, + ], + }), + ); + expect(result.current?.outcomes[0].tokens[0].price).toBe(0.72); + }); + + it('re-targets price queries when selected group changes', () => { + const pointsGroup = createGroup({ + key: 'points', + outcomes: [ + createOutcome({ + id: 'o-points', + sportsMarketType: 'points', + tokens: [createToken({ id: 'tok-points', price: 0.65 })], + }), + ], + }); + const touchdownsGroup = createGroup({ + key: 'touchdowns', + outcomes: [ + createOutcome({ + id: 'o-td', + sportsMarketType: 'touchdowns', + tokens: [createToken({ id: 'tok-td', price: 0.4 })], + }), + ], + }); + + const { rerender } = renderHook( + ({ group }: { group: PredictOutcomeGroup | undefined }) => + usePricedOutcomeGroup(group), + { initialProps: { group: pointsGroup } }, + ); + + expect(mockUsePredictPrices).toHaveBeenLastCalledWith( + expect.objectContaining({ + queries: [ + { + marketId: 'market-1', + outcomeId: 'o-points', + outcomeTokenId: 'tok-points', + }, + ], + }), + ); + + rerender({ group: touchdownsGroup }); + + expect(mockUsePredictPrices).toHaveBeenLastCalledWith( + expect.objectContaining({ + queries: [ + { + marketId: 'market-1', + outcomeId: 'o-td', + outcomeTokenId: 'tok-td', + }, + ], + }), + ); + }); + + it('polls spread subgroup outcomes but excludes moneyline subgroups', () => { + const group = createGroup({ + key: 'game_lines', + outcomes: [], + subgroups: [ + createGroup({ + key: 'spreads', + outcomes: [ + createOutcome({ + id: 'sp-1', + sportsMarketType: 'spreads', + line: 3.5, + tokens: [ + createToken({ id: 'tok-sp-a', shortTitle: 'TA', price: 0.6 }), + createToken({ id: 'tok-sp-b', shortTitle: 'TB', price: 0.4 }), + ], + }), + ], + }), + createGroup({ + key: 'moneyline', + outcomes: [ + createOutcome({ + id: 'ml-home', + sportsMarketType: 'moneyline', + tokens: [ + createToken({ + id: 'tok-ml-home', + shortTitle: 'TA', + price: 0.6, + }), + ], + }), + createOutcome({ + id: 'ml-away', + sportsMarketType: 'moneyline', + tokens: [ + createToken({ + id: 'tok-ml-away', + shortTitle: 'TB', + price: 0.4, + }), + ], + }), + ], + }), + ], + }); + mockUsePredictPrices.mockReturnValue({ + prices: { + providerId: 'polymarket', + results: [ + { + marketId: 'market-1', + outcomeId: 'sp-1', + outcomeTokenId: 'tok-sp-a', + entry: { buy: 0.67, sell: 0.68 }, + }, + { + marketId: 'market-1', + outcomeId: 'sp-1', + outcomeTokenId: 'tok-sp-b', + entry: { buy: 0.31, sell: 0.32 }, + }, + ], + }, + isFetching: false, + error: null, + refetch: jest.fn(), + }); + + const { result } = renderHook(() => usePricedOutcomeGroup(group)); + + expect(mockUsePredictPrices).toHaveBeenCalledWith( + expect.objectContaining({ + queries: [ + { + marketId: 'market-1', + outcomeId: 'sp-1', + outcomeTokenId: 'tok-sp-a', + }, + { + marketId: 'market-1', + outcomeId: 'sp-1', + outcomeTokenId: 'tok-sp-b', + }, + ], + }), + ); + expect(result.current?.subgroups?.[0].outcomes[0].tokens[0].price).toBe( + 0.68, + ); + expect(result.current?.subgroups?.[0].outcomes[0].tokens[1].price).toBe( + 0.32, + ); + expect(result.current?.subgroups?.[1].outcomes[0].tokens[0].price).toBe( + 0.6, + ); + }); +}); diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.ts b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.ts new file mode 100644 index 00000000000..65d101bed2e --- /dev/null +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.ts @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useRef } from 'react'; +import type { + GetPriceResponse, + PredictOutcome, + PredictOutcomeGroup, + PredictOutcomeToken, + PriceQuery, +} from '../../types'; +import { usePredictPrices } from '../../hooks/usePredictPrices'; +import { isMoneylineLikeMarketType } from '../../constants/sports'; +import { usePredictPreviewSheet } from '../../contexts'; +import { isValidPrice } from '../../utils/prices'; + +const PRICE_POLL_INTERVAL_MS = 2000; +const EMPTY_PRICE_QUERIES: PriceQuery[] = []; + +const flattenGroupOutcomes = (group: PredictOutcomeGroup): PredictOutcome[] => [ + ...group.outcomes, + ...(group.subgroups?.flatMap((subgroup) => subgroup.outcomes) ?? []), +]; + +const shouldPollOutcomePrices = ( + outcome: PredictOutcome, + groupKey?: string, +): boolean => !isMoneylineLikeMarketType(groupKey ?? outcome.sportsMarketType); + +const getPollableOutcomes = (group: PredictOutcomeGroup): PredictOutcome[] => { + const groupOutcomes = group.outcomes.filter((outcome) => + shouldPollOutcomePrices(outcome), + ); + const subgroupOutcomes = + group.subgroups?.flatMap((subgroup) => + isMoneylineLikeMarketType(subgroup.key) + ? [] + : subgroup.outcomes.filter((outcome) => + shouldPollOutcomePrices(outcome, subgroup.key), + ), + ) ?? []; + + return [...groupOutcomes, ...subgroupOutcomes]; +}; + +const buildPriceQueries = (outcomes: PredictOutcome[]): PriceQuery[] => + outcomes.flatMap((outcome) => + outcome.tokens.map((token) => ({ + marketId: outcome.marketId, + outcomeId: outcome.id, + outcomeTokenId: token.id, + })), + ); + +const buildPriceResultsByTokenId = ( + prices: GetPriceResponse, +): Map => + new Map(prices.results.map((result) => [result.outcomeTokenId, result])); + +const getRestAskPriceForBuyButton = ( + token: PredictOutcomeToken, + pricesByTokenId: Map, +): number => { + const restEntry = pricesByTokenId.get(token.id); + return isValidPrice(restEntry?.entry.sell) + ? restEntry.entry.sell + : token.price; +}; + +const applyPricesToOutcomes = ( + outcomes: PredictOutcome[], + pricesByTokenId: Map, + shouldApplyPrices: (outcome: PredictOutcome) => boolean, + previousById?: Map, +): { outcomes: PredictOutcome[]; changed: boolean } => { + let changed = false; + const nextOutcomes = outcomes.map((outcome) => { + if (!shouldApplyPrices(outcome)) { + return outcome; + } + + const previousOutcome = previousById?.get(outcome.id); + let outcomeChanged = !previousOutcome; + + const tokens = outcome.tokens.map((token) => { + const price = getRestAskPriceForBuyButton(token, pricesByTokenId); + const previousToken = previousOutcome?.tokens.find( + (t) => t.id === token.id, + ); + if (previousToken && previousToken.price === price) { + return previousToken; + } + outcomeChanged = true; + return { ...token, price }; + }); + + if (previousOutcome && !outcomeChanged) { + changed = changed || previousOutcome !== outcome; + return previousOutcome; + } + + changed = true; + return { ...outcome, tokens }; + }); + + return { outcomes: changed ? nextOutcomes : outcomes, changed }; +}; + +const applyPricesToGroup = ( + group: PredictOutcomeGroup, + prices: GetPriceResponse, + previousGroup: PredictOutcomeGroup | null, + previousPricedOutcomes: PredictOutcome[], +): PredictOutcomeGroup => { + const canReusePreviousPrices = previousGroup === group; + const previousById = canReusePreviousPrices + ? new Map(previousPricedOutcomes.map((outcome) => [outcome.id, outcome])) + : undefined; + const pricesByTokenId = buildPriceResultsByTokenId(prices); + + const pricedOutcomes = applyPricesToOutcomes( + group.outcomes, + pricesByTokenId, + (outcome) => shouldPollOutcomePrices(outcome), + previousById, + ); + + let subgroupChanged = false; + const pricedSubgroups = group.subgroups?.map((subgroup) => { + if (isMoneylineLikeMarketType(subgroup.key)) { + return subgroup; + } + + const result = applyPricesToOutcomes( + subgroup.outcomes, + pricesByTokenId, + (outcome) => shouldPollOutcomePrices(outcome, subgroup.key), + previousById, + ); + + if (!result.changed) { + return subgroup; + } + + subgroupChanged = true; + return { ...subgroup, outcomes: result.outcomes }; + }); + + if (!pricedOutcomes.changed && !subgroupChanged) { + return group; + } + + return { + ...group, + outcomes: pricedOutcomes.outcomes, + ...(pricedSubgroups && { subgroups: pricedSubgroups }), + }; +}; + +export const usePricedOutcomeGroup = ( + selectedGroup?: PredictOutcomeGroup, +): PredictOutcomeGroup | undefined => { + const { isBuySheetOpen } = usePredictPreviewSheet(); + + const pollableOutcomes = useMemo( + () => (selectedGroup ? getPollableOutcomes(selectedGroup) : []), + [selectedGroup], + ); + + const priceQueries = useMemo( + () => buildPriceQueries(pollableOutcomes), + [pollableOutcomes], + ); + + const shouldFetchPrices = priceQueries.length > 0 && !isBuySheetOpen; + const activePriceQueries = shouldFetchPrices + ? priceQueries + : EMPTY_PRICE_QUERIES; + + const { prices } = usePredictPrices({ + queries: activePriceQueries, + enabled: shouldFetchPrices, + pollingInterval: shouldFetchPrices ? PRICE_POLL_INTERVAL_MS : undefined, + }); + + const previousSelectedGroupRef = useRef(null); + const previousPricedOutcomesRef = useRef([]); + + const pricedGroup = useMemo(() => { + if (!selectedGroup) { + return undefined; + } + + return applyPricesToGroup( + selectedGroup, + prices, + previousSelectedGroupRef.current, + previousPricedOutcomesRef.current, + ); + }, [selectedGroup, prices]); + + useEffect(() => { + if (!selectedGroup || !pricedGroup) { + previousSelectedGroupRef.current = null; + previousPricedOutcomesRef.current = []; + return; + } + + previousSelectedGroupRef.current = selectedGroup; + previousPricedOutcomesRef.current = flattenGroupOutcomes(pricedGroup); + }, [selectedGroup, pricedGroup]); + + return pricedGroup; +}; diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/utils.test.ts b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.test.ts new file mode 100644 index 00000000000..2980e1c4b8b --- /dev/null +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.test.ts @@ -0,0 +1,575 @@ +import type { + PredictMarketGame, + PredictOutcome, + PredictOutcomeToken, +} from '../../types'; +import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; +import Logger from '../../../../../util/Logger'; +import { + buildButtons, + buildMoneylineButtons, + buildMoneylineSubtitle, + buildSubtitle, + formatOutcomeCardTitle, + getSportsMarketTypeLabel, + sortMoneylineOutcomes, +} from './utils'; + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const translations: Record = { + 'predict.sports_market_types.moneyline': 'Moneyline', + 'predict.sports_market_types.spreads': 'Spreads', + 'predict.sports_market_types.totals': 'Totals', + 'predict.sports_market_types.points': 'Points', + 'predict.sports_market_types.basketball_total_points': 'Totals', + 'predict.sports_market_types.basketball_odd_even': 'Odd/Even Score', + 'predict.sports_market_types.basketball_team_to_score_first': + 'Team to Score First', + 'predict.sports_market_types.tennis_set_totals': 'Total Sets', + 'predict.sports_market_types.tennis_set_handicap': 'Set Handicap', + 'predict.sports_market_types.tennis_match_totals': 'Total Games', + 'predict.sports_market_types.tennis_first_set_totals': + '1st Set Total Games', + 'predict.sports_market_types.tennis_first_set_winner': '1st Set Winner', + 'predict.sports_market_types.tennis_completed_match': 'Completed Match', + }; + if (key.startsWith('predict.sports_market_types.basketball_')) { + return translations[key] ?? `[missing "${key}" translation]`; + } + return translations[key] ?? key; + }), +})); + +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + +const createToken = ( + overrides: Partial = {}, +): PredictOutcomeToken => + ({ + id: 'token-1', + title: 'Team A', + shortTitle: 'TA', + price: 0.65, + ...overrides, + }) as PredictOutcomeToken; + +const createOutcome = ( + overrides: Partial = {}, +): PredictOutcome => + ({ + id: 'outcome-1', + marketId: 'market-1', + title: 'Team A vs Team B', + groupItemTitle: 'Team A vs Team B', + status: 'open', + volume: 50000, + sportsMarketType: 'moneyline', + tokens: [ + createToken({ + id: 'token-a', + title: 'Team A', + shortTitle: 'TA', + price: 0.65, + }), + createToken({ + id: 'token-b', + title: 'Team B', + shortTitle: 'TB', + price: 0.35, + }), + ], + ...overrides, + }) as PredictOutcome; + +const mockGame: PredictMarketGame = { + id: 'game-1', + homeTeam: { + id: 'team-home', + name: 'Team A', + abbreviation: 'TA', + color: TEST_HEX_COLORS.PURE_RED, + alias: 'Team A', + logo: 'https://example.com/logo-a.png', + }, + awayTeam: { + id: 'team-away', + name: 'Team B', + abbreviation: 'TB', + color: TEST_HEX_COLORS.PURE_BLUE, + alias: 'Team B', + logo: 'https://example.com/logo-b.png', + }, + startTime: '2024-12-31T20:00:00Z', + status: 'scheduled' as const, + league: 'nfl' as const, + elapsed: null, + period: null, + score: null, +}; + +const mockOnBuyPress = jest.fn(); + +describe('PredictGameDetailsContent utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getSportsMarketTypeLabel', () => { + it('returns translated label for known type', () => { + expect(getSportsMarketTypeLabel('moneyline')).toBe('Moneyline'); + }); + + it('returns translated label for basketball and tennis market types', () => { + expect(getSportsMarketTypeLabel('basketball_odd_even')).toBe( + 'Odd/Even Score', + ); + expect(getSportsMarketTypeLabel('tennis_match_totals')).toBe( + 'Total Games', + ); + expect(getSportsMarketTypeLabel('tennis_first_set_winner')).toBe( + '1st Set Winner', + ); + }); + + it('returns title-cased fallback for unknown type', () => { + expect(getSportsMarketTypeLabel('unknown_type')).toBe('Unknown Type'); + }); + + it('returns provided fallback when translation is missing', () => { + expect( + getSportsMarketTypeLabel('basketball_unknown_market', 'Fallback Title'), + ).toBe('Fallback Title'); + }); + + it('logs missing translations only once per key', () => { + const mockLoggerError = jest.mocked(Logger.error); + const type = 'basketball_logged_once_market'; + const key = `predict.sports_market_types.${type}`; + const message = `Missing Predict sports market type translation: ${key}`; + + expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe( + 'Fallback Title', + ); + expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe( + 'Fallback Title', + ); + + expect(mockLoggerError).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ message }), + { message, context: { key, type } }, + ); + }); + }); + + describe('formatOutcomeCardTitle', () => { + it('strips O/U suffix from card title', () => { + const outcome = createOutcome({ + groupItemTitle: 'O/U 45.5 : Points (Over/Under)', + }); + + expect(formatOutcomeCardTitle(outcome)).toBe('O/U 45.5'); + }); + + it('extracts player name from O/U title with colon separator', () => { + const outcome = createOutcome({ + groupItemTitle: 'Dean Wade: Points O/U 0.5', + }); + + expect(formatOutcomeCardTitle(outcome)).toBe('Dean Wade'); + }); + + it('preserves title when no O/U colon pattern exists', () => { + const outcome = createOutcome({ groupItemTitle: 'Player Props' }); + + expect(formatOutcomeCardTitle(outcome)).toBe('Player Props'); + }); + + it('falls back to outcome.title when groupItemTitle is missing', () => { + const outcome = createOutcome({ + groupItemTitle: undefined, + title: 'Fallback Title', + }); + + expect(formatOutcomeCardTitle(outcome)).toBe('Fallback Title'); + }); + + it('strips O/U suffix when title has colon but no player pattern match', () => { + const outcome = createOutcome({ groupItemTitle: 'Total: O/U 2.5' }); + + expect(formatOutcomeCardTitle(outcome)).toBe('Total'); + }); + }); + + describe('buildButtons', () => { + it('converts token price to cents and uses shortTitle when available', () => { + const outcome = createOutcome({ + tokens: [ + createToken({ price: 0.65, shortTitle: 'TA' }), + createToken({ price: 0.35, shortTitle: 'TB' }), + ], + }); + + const buttons = buildButtons(outcome, mockOnBuyPress); + + expect(buttons[0].price).toBe(65); + expect(buttons[0].label).toBe('TA'); + expect(buttons[1].price).toBe(35); + expect(buttons[1].label).toBe('TB'); + }); + + it('falls back to title when shortTitle is missing', () => { + const outcome = createOutcome({ + tokens: [ + createToken({ title: 'Full Team Name', shortTitle: undefined }), + ], + }); + + expect(buildButtons(outcome, mockOnBuyPress)[0].label).toBe( + 'Full Team Name', + ); + }); + + it('assigns moneyline variants and team colors', () => { + const twoButtonOutcome = createOutcome({ + sportsMarketType: 'moneyline', + tokens: [ + createToken({ shortTitle: 'TA' }), + createToken({ shortTitle: 'TB' }), + ], + }); + const threeButtonOutcome = createOutcome({ + sportsMarketType: 'moneyline', + tokens: [ + createToken({ id: 't1', shortTitle: 'TA' }), + createToken({ id: 't2', shortTitle: 'Draw' }), + createToken({ id: 't3', shortTitle: 'TB' }), + ], + }); + + const twoButtons = buildButtons( + twoButtonOutcome, + mockOnBuyPress, + mockGame, + 'moneyline', + ); + const threeButtons = buildButtons( + threeButtonOutcome, + mockOnBuyPress, + mockGame, + 'moneyline', + ); + + expect(twoButtons[0].variant).toBe('yes'); + expect(twoButtons[1].variant).toBe('no'); + expect(twoButtons[0].teamColor).toBe(TEST_HEX_COLORS.PURE_RED); + expect(twoButtons[1].teamColor).toBe(TEST_HEX_COLORS.PURE_BLUE); + expect(threeButtons[1].variant).toBe('draw'); + }); + + it('assigns draw variant and no team colors for non-moneyline types', () => { + const buttons = buildButtons( + createOutcome({ + sportsMarketType: 'spreads', + tokens: [ + createToken({ shortTitle: 'TA' }), + createToken({ shortTitle: 'TB' }), + ], + }), + mockOnBuyPress, + mockGame, + 'spreads', + ); + + expect(buttons[0].variant).toBe('draw'); + expect(buttons[1].variant).toBe('draw'); + expect(buttons[0].teamColor).toBeUndefined(); + expect(buttons[1].teamColor).toBeUndefined(); + }); + + it('returns undefined team color for unknown abbreviations or missing game', () => { + const unknownAbbrButtons = buildButtons( + createOutcome({ + sportsMarketType: 'moneyline', + tokens: [createToken({ shortTitle: 'UNKNOWN' })], + }), + mockOnBuyPress, + mockGame, + 'moneyline', + ); + const noGameButtons = buildButtons( + createOutcome({ + sportsMarketType: 'moneyline', + tokens: [createToken({ shortTitle: 'TA' })], + }), + mockOnBuyPress, + undefined, + 'moneyline', + ); + + expect(unknownAbbrButtons[0].teamColor).toBeUndefined(); + expect(noGameButtons[0].teamColor).toBeUndefined(); + }); + + it('assigns tennis first set winner team colors from normalized token labels', () => { + const tennisGame: PredictMarketGame = { + ...mockGame, + league: 'atp', + homeTeam: { + ...mockGame.homeTeam, + name: 'Ilya Ivashka', + abbreviation: 'ivashka', + alias: 'I. Ivashka', + color: TEST_HEX_COLORS.PURE_RED, + }, + awayTeam: { + ...mockGame.awayTeam, + name: 'Hamish Stewart', + abbreviation: 'stewart', + alias: 'H. Stewart', + color: TEST_HEX_COLORS.PURE_BLUE, + }, + }; + const buttons = buildButtons( + createOutcome({ + sportsMarketType: 'tennis_first_set_winner', + tokens: [ + createToken({ shortTitle: 'IVASHKA' }), + createToken({ shortTitle: 'STEWART' }), + ], + }), + mockOnBuyPress, + tennisGame, + 'tennis_first_set_winner', + ); + + expect(buttons[0].teamColor).toBe(TEST_HEX_COLORS.PURE_RED); + expect(buttons[1].teamColor).toBe(TEST_HEX_COLORS.PURE_BLUE); + }); + + it('calls onBuyPress with outcome and token when button is pressed', () => { + const token = createToken({ id: 'tok-press', shortTitle: 'TA' }); + const outcome = createOutcome({ id: 'out-press', tokens: [token] }); + + buildButtons(outcome, mockOnBuyPress)[0].onPress(); + + expect(mockOnBuyPress).toHaveBeenCalledWith(outcome, token); + }); + }); + + describe('buildSubtitle', () => { + it.each([ + [50000, '$50k Vol'], + [2500000, '$2.5M Vol'], + ])('formats volume %i as %s', (volume, expected) => { + expect(buildSubtitle(createOutcome({ volume }))).toBe(expected); + }); + }); + + describe('buildMoneylineSubtitle', () => { + it('sums volumes from all outcomes', () => { + const outcomes = [ + createOutcome({ volume: 10000 }), + createOutcome({ volume: 20000 }), + ]; + + expect(buildMoneylineSubtitle(outcomes)).toBe('$30k Vol'); + }); + }); + + describe('sortMoneylineOutcomes', () => { + it('places home team first, draw middle, away last when game is provided', () => { + const outcomes = [ + createOutcome({ + id: 'ml-draw', + groupItemTitle: 'Draw', + tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })], + }), + createOutcome({ + id: 'ml-away', + groupItemTitle: 'Away Win', + tokens: [createToken({ shortTitle: 'TB', price: 0.3 })], + }), + createOutcome({ + id: 'ml-home', + groupItemTitle: 'Home Win', + tokens: [createToken({ shortTitle: 'TA', price: 0.5 })], + }), + ]; + + const sorted = sortMoneylineOutcomes(outcomes, mockGame); + + expect(sorted.map((o) => o.tokens[0]?.shortTitle)).toEqual([ + 'TA', + 'Draw', + 'TB', + ]); + }); + + it('places draw second when no game is provided and no thresholds', () => { + const outcomes = [ + createOutcome({ + id: 'ml-draw', + groupItemTitle: 'Draw', + tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })], + }), + createOutcome({ + id: 'ml-first', + groupItemTitle: 'Team X', + tokens: [createToken({ shortTitle: 'TX', price: 0.4 })], + }), + createOutcome({ + id: 'ml-second', + groupItemTitle: 'Team Y', + tokens: [createToken({ shortTitle: 'TY', price: 0.4 })], + }), + ]; + + const sorted = sortMoneylineOutcomes(outcomes); + + expect(sorted.map((o) => o.tokens[0]?.shortTitle)).toEqual([ + 'TX', + 'Draw', + 'TY', + ]); + }); + + it('preserves original order when draw is present but fewer than 2 non-draw outcomes', () => { + const outcomes = [ + createOutcome({ + id: 'ml-draw', + groupItemTitle: 'Draw', + tokens: [createToken({ shortTitle: 'Draw', price: 0.5 })], + }), + createOutcome({ + id: 'ml-only', + groupItemTitle: 'Team X', + tokens: [createToken({ shortTitle: 'TX', price: 0.5 })], + }), + ]; + + const sorted = sortMoneylineOutcomes(outcomes, mockGame); + + expect(sorted.map((o) => o.tokens[0]?.shortTitle)).toEqual([ + 'Draw', + 'TX', + ]); + }); + + it('sorts by groupItemThreshold when thresholds are present', () => { + const outcomes = [ + createOutcome({ + id: 'ml-2', + groupItemThreshold: 2, + tokens: [createToken({ shortTitle: 'AWY', price: 0.25 })], + }), + createOutcome({ + id: 'ml-0', + groupItemThreshold: 0, + tokens: [createToken({ shortTitle: 'HOM', price: 0.55 })], + }), + createOutcome({ + id: 'ml-1', + groupItemThreshold: 1, + tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })], + }), + ]; + + const buttons = buildMoneylineButtons(outcomes, mockOnBuyPress, mockGame); + + expect(buttons.map((button) => button.label)).toEqual([ + 'HOM', + 'Draw', + 'AWY', + ]); + expect(buttons.map((button) => button.price)).toEqual([55, 20, 25]); + expect(buttons.map((button) => button.variant)).toEqual([ + 'yes', + 'draw', + 'no', + ]); + }); + }); + + describe('buildMoneylineButtons', () => { + it('uses live best ask prices when available', () => { + const outcomes = [ + createOutcome({ + id: 'ml-a', + tokens: [createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.45 })], + }), + createOutcome({ + id: 'ml-b', + tokens: [createToken({ id: 'tok-b', shortTitle: 'TB', price: 0.55 })], + }), + ]; + const getPrice = jest.fn((tokenId: string) => ({ + tokenId, + price: 0, + bestBid: 0, + bestAsk: tokenId === 'tok-a' ? 0.76 : 0.24, + })); + + const buttons = buildMoneylineButtons( + outcomes, + mockOnBuyPress, + mockGame, + getPrice, + ); + + expect(buttons[0].price).toBe(76); + expect(buttons[1].price).toBe(24); + }); + + it('falls back to static token price when live best ask is zero', () => { + const outcomes = [ + createOutcome({ + id: 'ml-a', + tokens: [createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.45 })], + }), + ]; + const getPrice = jest.fn(() => ({ + tokenId: 'tok-a', + price: 0, + bestBid: 0, + bestAsk: 0, + })); + + const buttons = buildMoneylineButtons( + outcomes, + mockOnBuyPress, + mockGame, + getPrice, + ); + + expect(buttons[0].price).toBe(45); + }); + + it('skips outcomes without tokens', () => { + const outcomes = [ + createOutcome({ + id: 'ml-empty', + tokens: [], + }), + createOutcome({ + id: 'ml-home', + groupItemThreshold: 0, + tokens: [createToken({ shortTitle: 'HOM', price: 0.6 })], + }), + createOutcome({ + id: 'ml-away', + groupItemThreshold: 1, + tokens: [createToken({ shortTitle: 'AWY', price: 0.4 })], + }), + ]; + + const buttons = buildMoneylineButtons(outcomes, mockOnBuyPress, mockGame); + + expect(buttons.map((button) => button.label)).toEqual(['HOM', 'AWY']); + }); + }); +}); diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/utils.ts b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.ts new file mode 100644 index 00000000000..60e71cc0741 --- /dev/null +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.ts @@ -0,0 +1,224 @@ +import type { + PredictMarketGame, + PredictOutcome, + PredictOutcomeToken, + PriceUpdate, +} from '../../types'; +import type { PredictBetButtonVariant } from '../PredictActionButtons/PredictActionButtons.types'; +import type { PredictSportOutcomeButton } from '../PredictSportOutcomeCard'; +import { formatVolume } from '../../utils/format'; +import { isValidPrice } from '../../utils/prices'; +import { isMoneylineLikeMarketType } from '../../constants/sports'; +import { strings } from '../../../../../../locales/i18n'; +import Logger from '../../../../../util/Logger'; + +const I18N_PREFIX = 'predict.sports_market_types'; +const MISSING_TRANSLATION_PREFIX = '[missing'; +const loggedMissingTranslationKeys = new Set(); +const O_U_PLAYER_PATTERN = /^(.+?):\s+\w+ O\/U/; + +export type BuyHandler = ( + outcome: PredictOutcome, + token: PredictOutcomeToken, +) => void; + +export type LivePriceGetter = (tokenId: string) => PriceUpdate | undefined; + +const toTitleCase = (str: string): string => + str + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + +const isMissingTranslation = (value: string, key: string): boolean => + value === key || value.startsWith(MISSING_TRANSLATION_PREFIX); + +const logMissingSportsMarketTypeTranslation = ( + key: string, + type: string, +): void => { + if (loggedMissingTranslationKeys.has(key)) return; + + loggedMissingTranslationKeys.add(key); + const message = `Missing Predict sports market type translation: ${key}`; + Logger.error(new Error(message), { + message, + context: { key, type }, + }); +}; + +export const getTranslatedSportsMarketTypeLabel = ( + type: string, +): string | undefined => { + const key = `${I18N_PREFIX}.${type}`; + const label = strings(key); + if (typeof label !== 'string' || isMissingTranslation(label, key)) { + logMissingSportsMarketTypeTranslation(key, type); + return undefined; + } + return label; +}; + +export const getSportsMarketTypeLabel = ( + type: string, + fallbackTitle?: string, +): string => + getTranslatedSportsMarketTypeLabel(type) ?? + fallbackTitle ?? + toTitleCase(type); + +export const getFallbackSportsMarketTypeLabel = ( + type: string, + fallbackTitle?: string, +): string => fallbackTitle ?? toTitleCase(type); + +export const formatOutcomeCardTitle = (outcome: PredictOutcome): string => { + const raw = outcome.groupItemTitle || outcome.title; + if (!raw.includes('O/U')) return raw; + + const match = raw.match(O_U_PLAYER_PATTERN); + if (match) return match[1].trim(); + + const colonIdx = raw.indexOf(': '); + if (colonIdx !== -1) return raw.slice(0, colonIdx).trim(); + + return raw; +}; + +const getTeamColor = ( + tokenTitle: string, + game?: PredictMarketGame, +): string | undefined => { + if (!game) return undefined; + + const normalizedTokenTitle = tokenTitle.trim().toLowerCase(); + const homeLabels = [ + game.homeTeam.abbreviation, + game.homeTeam.name, + game.homeTeam.alias, + ] + .filter((label): label is string => Boolean(label)) + .map((label) => label.trim().toLowerCase()); + const awayLabels = [ + game.awayTeam.abbreviation, + game.awayTeam.name, + game.awayTeam.alias, + ] + .filter((label): label is string => Boolean(label)) + .map((label) => label.trim().toLowerCase()); + + if (homeLabels.includes(normalizedTokenTitle)) return game.homeTeam.color; + if (awayLabels.includes(normalizedTokenTitle)) return game.awayTeam.color; + return undefined; +}; + +const getButtonVariant = ( + index: number, + total: number, + moneyline: boolean, +): PredictBetButtonVariant => { + if (!moneyline) return 'draw'; + if (total === 3 && index === 1) return 'draw'; + return index === 0 ? 'yes' : 'no'; +}; + +export const buildButtons = ( + outcome: PredictOutcome, + onBuyPress: BuyHandler, + game?: PredictMarketGame, + sportsMarketType?: string, +): PredictSportOutcomeButton[] => { + const moneyline = isMoneylineLikeMarketType(sportsMarketType); + return outcome.tokens.map((token, index) => ({ + label: token.shortTitle ?? token.title, + price: Math.round(token.price * 100), + onPress: () => onBuyPress(outcome, token), + variant: getButtonVariant(index, outcome.tokens.length, moneyline), + teamColor: moneyline + ? getTeamColor(token.shortTitle ?? token.title, game) + : undefined, + })); +}; + +export const buildSubtitle = (outcome: PredictOutcome): string => + `$${formatVolume(outcome.volume)} Vol`; + +export const getDefaultLineIndex = ( + outcomes: PredictOutcome[], + lineIndices: number[], +): number => { + if (lineIndices.length === 0) return 0; + + return lineIndices.reduce((bestIndex, outcomeIndex, lineIndex) => { + const bestOutcome = outcomes[lineIndices[bestIndex]]; + const outcome = outcomes[outcomeIndex]; + return (outcome?.volume ?? 0) > (bestOutcome?.volume ?? 0) + ? lineIndex + : bestIndex; + }, 0); +}; + +const isDrawOutcome = (outcome: PredictOutcome): boolean => + outcome.groupItemTitle?.toLowerCase().startsWith('draw') ?? false; + +export const sortMoneylineOutcomes = ( + outcomes: PredictOutcome[], + game?: PredictMarketGame, +): PredictOutcome[] => { + const hasThresholds = outcomes.some((o) => o.groupItemThreshold != null); + if (hasThresholds) { + return [...outcomes].sort( + (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0), + ); + } + + const draw = outcomes.find(isDrawOutcome); + const nonDraw = outcomes.filter((o) => !isDrawOutcome(o)); + if (!draw || nonDraw.length < 2) { + return [...outcomes]; + } + + if (game) { + const homeAbbr = game.homeTeam.abbreviation; + const home = nonDraw.find((o) => o.tokens[0]?.shortTitle === homeAbbr); + const away = nonDraw.find((o) => o !== home); + if (home && away) { + return [home, draw, away]; + } + } + + const sorted = [...nonDraw].sort((a, b) => + (a.groupItemTitle ?? '').localeCompare(b.groupItemTitle ?? ''), + ); + return [sorted[0], draw, ...sorted.slice(1)]; +}; + +export const buildMoneylineButtons = ( + outcomes: PredictOutcome[], + onBuyPress: BuyHandler, + game?: PredictMarketGame, + getPrice?: LivePriceGetter, +): PredictSportOutcomeButton[] => { + const sortedWithTokens = sortMoneylineOutcomes(outcomes, game).filter( + (outcome) => outcome.tokens[0] !== undefined, + ); + + return sortedWithTokens.map((outcome, i) => { + const yesToken = outcome.tokens[0]; + const liveBestAsk = getPrice?.(yesToken.id)?.bestAsk; + const price = isValidPrice(liveBestAsk) ? liveBestAsk : yesToken.price; + + return { + label: yesToken.shortTitle ?? yesToken.title, + price: Math.round(price * 100), + onPress: () => onBuyPress(outcome, yesToken), + variant: getButtonVariant(i, sortedWithTokens.length, true), + teamColor: getTeamColor(yesToken.shortTitle ?? yesToken.title, game), + }; + }); +}; + +export const buildMoneylineSubtitle = (outcomes: PredictOutcome[]): string => { + const totalVolume = outcomes.reduce((sum, o) => sum + o.volume, 0); + return `$${formatVolume(totalVolume)} Vol`; +}; diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx index 1e775e385ea..5d02257ff8f 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx @@ -11,6 +11,7 @@ import { import { PredictEventValues } from '../../constants/eventNames'; import PredictMarketSportCard from './'; import Routes from '../../../../../constants/navigation/Routes'; +import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ @@ -51,6 +52,14 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({ useLiveGameUpdates: () => ({ gameUpdate: mockGameUpdate }), })); +const mockGetLivePrice = jest.fn(); +jest.mock('../../hooks/useLiveMarketPrices', () => ({ + useLiveMarketPrices: jest.fn(() => ({ + getPrice: mockGetLivePrice, + })), +})); +const mockUseLiveMarketPrices = jest.mocked(useLiveMarketPrices); + jest.mock('../../constants/sportLeagueConfigs', () => ({ getLeagueConfig: () => ({}), })); @@ -126,10 +135,29 @@ const initialState = { }, }; +const stateWithSportCardLivePricesEnabled = (enabled: boolean) => ({ + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags, + predictSportCardLivePrices: { + enabled, + minimumVersion: '0.0.0', + }, + }, + }, + }, + }, +}); + describe('PredictMarketSportCard', () => { beforeEach(() => { jest.clearAllMocks(); mockIsFromTrending.mockReturnValue(false); + mockGetLivePrice.mockReturnValue(undefined); mockGameUpdate = null; }); @@ -158,6 +186,52 @@ describe('PredictMarketSportCard', () => { expect(getByText('ENG 62¢')).toBeOnTheScreen(); }); + it('renders live Moneyline best ask prices when available', () => { + mockGetLivePrice.mockImplementation((tokenId: string) => ({ + tokenId, + price: 0, + bestBid: 0, + bestAsk: + tokenId === 'token-home' + ? 0.71 + : tokenId === 'token-draw' + ? 0.12 + : 0.29, + })); + + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('SPA 71¢')).toBeOnTheScreen(); + expect(getByText('DRAW 12¢')).toBeOnTheScreen(); + expect(getByText('ENG 29¢')).toBeOnTheScreen(); + }); + + it('renders static prices and disables live subscriptions when the flag is off', () => { + mockGetLivePrice.mockImplementation((tokenId: string) => ({ + tokenId, + price: 0, + bestBid: 0, + bestAsk: 0.99, + })); + + const { getByText, queryByText } = renderWithProvider( + , + { state: stateWithSportCardLivePricesEnabled(false) }, + ); + + expect(getByText('SPA 60¢')).toBeOnTheScreen(); + expect(getByText('DRAW 15¢')).toBeOnTheScreen(); + expect(getByText('ENG 62¢')).toBeOnTheScreen(); + expect(queryByText('SPA 99¢')).not.toBeOnTheScreen(); + expect(mockUseLiveMarketPrices).toHaveBeenLastCalledWith( + ['token-home', 'token-draw', 'token-away'], + { enabled: false }, + ); + }); + it('uses the main moneyline outcome when extended sports markets are present', () => { const extendedMarket: PredictMarketType = { ...mockMarket, diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx index b641bf2fc99..700494ce807 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx @@ -16,6 +16,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import React, { useCallback, useMemo } from 'react'; import { TouchableOpacity } from 'react-native'; +import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import { useTheme } from '../../../../../util/theme'; import { @@ -25,6 +26,7 @@ import { import { PredictEventValues } from '../../constants/eventNames'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { useLiveGameUpdates } from '../../hooks/useLiveGameUpdates'; +import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; import { usePredictPreviewSheet } from '../../contexts'; import { useResolvedPredictEntryPoint } from '../../hooks/useResolvedPredictEntryPoint'; import { @@ -42,6 +44,8 @@ import { import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; import PredictSportScoreboard from '../PredictSportScoreboard'; import { isGameEnded } from '../../utils/scoreboard'; +import { isValidPrice } from '../../utils/prices'; +import { selectPredictSportCardLivePricesEnabledFlag } from '../../selectors/featureFlags'; interface PredictMarketSportCardProps { market: PredictMarketType; @@ -210,6 +214,9 @@ const PredictMarketSportCard: React.FC = ({ useNavigation>(); const { openBuySheet } = usePredictPreviewSheet(); const { executeGuardedAction } = usePredictActionGuard({ navigation }); + const livePricesEnabled = useSelector( + selectPredictSportCardLivePricesEnabledFlag, + ); const game = market.game as PredictMarketGame | undefined; const { gameUpdate } = useLiveGameUpdates(game?.id ?? null); @@ -294,6 +301,25 @@ const PredictMarketSportCard: React.FC = ({ market.status === PredictMarketStatus.OPEN && !gameEnded && buttonItems.length > 0; + const tokenIds = useMemo( + () => buttonItems.map((item) => item.token.id), + [buttonItems], + ); + const { getPrice } = useLiveMarketPrices(tokenIds, { + enabled: showBuyButtons && livePricesEnabled, + }); + + const getDisplayPrice = useCallback( + (token: PredictOutcomeToken): number => { + if (!livePricesEnabled) { + return token.price; + } + + const liveBestAsk = getPrice(token.id)?.bestAsk; + return isValidPrice(liveBestAsk) ? liveBestAsk : token.price; + }, + [getPrice, livePricesEnabled], + ); const getButtonTextColorClass = (item: SportOutcomeButtonItem): string => { if (item.teamColor) return 'text-white'; @@ -381,7 +407,8 @@ const PredictMarketSportCard: React.FC = ({ getButtonTextColorClass(item), )} > - {item.label.toUpperCase()} {formatCents(item.token.price)} + {item.label.toUpperCase()}{' '} + {formatCents(getDisplayPrice(item.token))} diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx index be0761b9e32..0ca912a0c1c 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx @@ -47,6 +47,13 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({ useLiveGameUpdates: () => ({ gameUpdate: null }), })); +const mockGetLivePrice = jest.fn(); +jest.mock('../../hooks/useLiveMarketPrices', () => ({ + useLiveMarketPrices: jest.fn(() => ({ + getPrice: mockGetLivePrice, + })), +})); + jest.mock('../../constants/sportLeagueConfigs', () => ({ getLeagueConfig: () => ({}), })); @@ -121,6 +128,7 @@ const initialState = { describe('PredictMarketSportCardWrapper', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetLivePrice.mockReturnValue(undefined); mockUsePredictMarket.mockReturnValue({ data: null, isLoading: false, @@ -129,10 +137,6 @@ describe('PredictMarketSportCardWrapper', () => { } as unknown as ReturnType); }); - afterEach(() => { - jest.resetAllMocks(); - }); - describe('loading state', () => { it('returns null when fetching market data', () => { mockUsePredictMarket.mockReturnValue({ diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts index ee858f491b2..60baf3b5efd 100644 --- a/app/components/UI/Predict/constants/eventNames.ts +++ b/app/components/UI/Predict/constants/eventNames.ts @@ -112,6 +112,7 @@ export const PredictEventValues = { HOMEPAGE_FEATURED_CAROUSEL: 'homepage_featured_carousel', HOMEPAGE_FEATURED_LIST: 'homepage_featured_list', MAIN_TRADE_BUTTON: 'main_trade_button', + HOMESCREEN_PILL: 'homescreen_pill', REWARDS: 'rewards', GTM_MODAL: 'gtm_modal', BACKGROUND: 'background', diff --git a/app/components/UI/Predict/constants/flags.ts b/app/components/UI/Predict/constants/flags.ts index 02a24981bbf..001953efa1b 100644 --- a/app/components/UI/Predict/constants/flags.ts +++ b/app/components/UI/Predict/constants/flags.ts @@ -30,6 +30,7 @@ export const DEFAULT_EXTENDED_SPORTS_MARKETS_FLAG: PredictExtendedSportsMarketsF enabled: false, minimumVersion: '', leagues: [], + enabledSportsMarketTypes: [], }; export const DEFAULT_MARKET_HIGHLIGHTS_FLAG: PredictMarketHighlightsFlag = { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index f2532ae263a..1b6a5a03544 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -352,6 +352,7 @@ const defaultFeatureFlags: PredictFeatureFlags = { feeCollection: DEFAULT_FEE_COLLECTION_FLAG, liveSportsLeagues: [], extendedSportsMarketsLeagues: [], + enabledSportsMarketTypes: [], marketHighlightsFlag: { enabled: false, highlights: [], @@ -362,6 +363,7 @@ const defaultFeatureFlags: PredictFeatureFlags = { predictUpDownEnabled: false, predictPortfolioEnabled: false, predictHomeRedesignEnabled: false, + predictSportCardLivePricesEnabled: true, predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG, }; diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 2787319fcdf..319bc047c8b 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -333,6 +333,10 @@ export class PolymarketProvider implements PredictProvider { return this.#getFeatureFlags().extendedSportsMarketsLeagues; } + #getEnabledSportsMarketTypes(): string[] { + return this.#getFeatureFlags().enabledSportsMarketTypes; + } + #createTeamLookup( enabled: boolean, ): @@ -389,6 +393,7 @@ export class PolymarketProvider implements PredictProvider { sortMarketsBy: 'price', teamLookup, extendedSportsMarketsLeagues: this.#getExtendedSportsMarketsLeagues(), + enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(), }); if (filterEmptyOutcomes) { @@ -845,6 +850,7 @@ export class PolymarketProvider implements PredictProvider { category: PolymarketProvider.FALLBACK_CATEGORY, teamLookup, extendedSportsMarketsLeagues, + enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(), }); if (!parsedMarket) { @@ -1111,6 +1117,7 @@ export class PolymarketProvider implements PredictProvider { category: PolymarketProvider.FALLBACK_CATEGORY, teamLookup, extendedSportsMarketsLeagues: this.#getExtendedSportsMarketsLeagues(), + enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(), }); } catch (error) { DevLogger.log('Error fetching series events via Polymarket API:', error); @@ -1149,6 +1156,7 @@ export class PolymarketProvider implements PredictProvider { sortMarketsBy: 'price', teamLookup, extendedSportsMarketsLeagues: this.#getExtendedSportsMarketsLeagues(), + enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(), }) .filter((m) => m.status === 'open' && m.outcomes.length > 0) .map((market) => { diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index 5d0114c3d7a..8b004f0da2f 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -117,6 +117,12 @@ export const SPORTS_MARKET_TYPE_TO_GROUP: Record = { tennis_first_set_totals: 'first_set', }; +export const SUPPORTED_SPORTS_MARKET_TYPES: ReadonlySet = new Set([ + 'moneyline', + 'spreads', + 'totals', +]); + export const GROUP_ORDER: string[] = [ 'game_lines', 'first_half', diff --git a/app/components/UI/Predict/providers/polymarket/outcomeGrouping.test.ts b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.test.ts new file mode 100644 index 00000000000..5324507915a --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.test.ts @@ -0,0 +1,156 @@ +import type { PredictOutcome } from '../../types'; +import { POLYMARKET_PROVIDER_ID } from './constants'; +import { + buildOutcomeGroups, + normalizeEnabledSportsMarketTypes, +} from './outcomeGrouping'; + +function createGroupingOutcome( + id: string, + sportsMarketType: string, + line?: number, + shortTitle = id, +): PredictOutcome { + return { + id, + providerId: POLYMARKET_PROVIDER_ID, + marketId: 'market-1', + title: id, + description: id, + image: 'icon.png', + status: 'open', + tokens: [{ id: `${id}-token`, title: shortTitle, shortTitle, price: 0.5 }], + volume: 100, + groupItemTitle: id, + sportsMarketType, + ...(line !== undefined && { line }), + }; +} + +describe('outcomeGrouping', () => { + describe('normalizeEnabledSportsMarketTypes', () => { + it('defaults to supported market types when value is missing', () => { + expect(normalizeEnabledSportsMarketTypes(undefined)).toEqual([ + 'moneyline', + 'spreads', + 'totals', + ]); + }); + + it('honors explicit empty array', () => { + expect(normalizeEnabledSportsMarketTypes([])).toEqual([]); + }); + + it('filters unsupported types and deduplicates case-insensitively', () => { + expect( + normalizeEnabledSportsMarketTypes([ + 'moneyline', + 'MONEYLINE', + 'spreads', + 'totals', + 'first_half_moneyline', + 'points', + ]), + ).toEqual(['moneyline', 'spreads', 'totals']); + }); + }); + + describe('buildOutcomeGroups', () => { + it('groups tennis first set markets separately from game lines', () => { + const groups = buildOutcomeGroups([ + createGroupingOutcome('moneyline', 'moneyline'), + createGroupingOutcome('set-total', 'tennis_set_totals'), + createGroupingOutcome('match-total', 'tennis_match_totals'), + createGroupingOutcome('completed', 'tennis_completed_match'), + createGroupingOutcome('first-set-winner', 'tennis_first_set_winner'), + createGroupingOutcome('first-set-total', 'tennis_first_set_totals'), + ]); + + expect(groups.map((group) => group.key)).toEqual([ + 'game_lines', + 'first_set', + ]); + expect(groups[0].subgroups?.map((group) => group.key)).toEqual([ + 'moneyline', + 'tennis_set_totals', + 'tennis_match_totals', + 'tennis_completed_match', + ]); + expect(groups[1].subgroups?.map((group) => group.key)).toEqual([ + 'tennis_first_set_winner', + 'tennis_first_set_totals', + ]); + }); + + it('orders spread subgroup outcomes descending when first spread is team one negative', () => { + const groups = buildOutcomeGroups([ + createGroupingOutcome('moneyline', 'moneyline'), + createGroupingOutcome('arg-minus-1.5', 'spreads', -1.5, 'ARG -1.5'), + createGroupingOutcome('arg-plus-1.5', 'spreads', -1.5, 'ARG +1.5'), + createGroupingOutcome('arg-minus-2.5', 'spreads', -2.5, 'ARG -2.5'), + createGroupingOutcome('arg-plus-2.5', 'spreads', -2.5, 'ARG +2.5'), + ]); + + const spreadsSubgroup = groups[0].subgroups?.find( + (subgroup) => subgroup.key === 'spreads', + ); + + expect(spreadsSubgroup?.outcomes.map((outcome) => outcome.id)).toEqual([ + 'arg-plus-2.5', + 'arg-plus-1.5', + 'arg-minus-1.5', + 'arg-minus-2.5', + ]); + expect( + spreadsSubgroup?.outcomes.map((outcome) => Math.abs(outcome.line ?? 0)), + ).toEqual([2.5, 1.5, 1.5, 2.5]); + }); + + it('orders spread subgroup outcomes ascending when first spread is team two negative', () => { + const groups = buildOutcomeGroups([ + createGroupingOutcome('moneyline', 'moneyline'), + createGroupingOutcome('irq-plus-1.5', 'spreads', -1.5, 'IRQ +1.5'), + createGroupingOutcome('irq-plus-2.5', 'spreads', -2.5, 'IRQ +2.5'), + createGroupingOutcome('irq-minus-2.5', 'spreads', -2.5, 'IRQ -2.5'), + createGroupingOutcome('irq-minus-1.5', 'spreads', -1.5, 'IRQ -1.5'), + createGroupingOutcome('irq-plus-3.5', 'spreads', -3.5, 'IRQ +3.5'), + createGroupingOutcome('irq-minus-3.5', 'spreads', -3.5, 'IRQ -3.5'), + ]); + + const spreadsSubgroup = groups[0].subgroups?.find( + (subgroup) => subgroup.key === 'spreads', + ); + + expect(spreadsSubgroup?.outcomes.map((outcome) => outcome.id)).toEqual([ + 'irq-minus-3.5', + 'irq-minus-2.5', + 'irq-minus-1.5', + 'irq-plus-1.5', + 'irq-plus-2.5', + 'irq-plus-3.5', + ]); + expect( + spreadsSubgroup?.outcomes.map((outcome) => Math.abs(outcome.line ?? 0)), + ).toEqual([3.5, 2.5, 1.5, 1.5, 2.5, 3.5]); + }); + + it('orders non-spread line subgroup outcomes by line', () => { + const groups = buildOutcomeGroups([ + createGroupingOutcome('moneyline', 'moneyline'), + createGroupingOutcome('total-216.5', 'totals', 216.5), + createGroupingOutcome('total-211.5', 'totals', 211.5), + createGroupingOutcome('total-214.5', 'totals', 214.5), + ]); + + const totalsSubgroup = groups[0].subgroups?.find( + (subgroup) => subgroup.key === 'totals', + ); + + expect(totalsSubgroup?.outcomes.map((outcome) => outcome.id)).toEqual([ + 'total-211.5', + 'total-214.5', + 'total-216.5', + ]); + }); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/outcomeGrouping.ts b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.ts new file mode 100644 index 00000000000..865fa5d6390 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.ts @@ -0,0 +1,248 @@ +import type { PredictOutcome, PredictOutcomeGroup } from '../../types'; +import { + DEFAULT_GROUP_KEY, + GROUP_ORDER, + SPORTS_MARKET_TYPE_PRIORITIES, + SPORTS_MARKET_TYPE_TO_GROUP, + SUPPORTED_SPORTS_MARKET_TYPES, +} from './constants'; +import type { PolymarketApiMarket } from './types'; + +const DISPLAYED_SPREAD_LINE_PATTERN = /([+-]\d+(?:\.\d+)?)$/; + +export const normalizeSportsMarketType = (type: string): string => { + const lower = type.toLowerCase(); + if (lower.startsWith('first_half_')) { + return lower.slice('first_half_'.length); + } + return lower; +}; + +const getSportsMarketTypePriority = (type: string): number => + SPORTS_MARKET_TYPE_PRIORITIES[type.toLowerCase()] ?? 3; + +const isSpreadOutcomeType = (sportsMarketType?: string): boolean => + sportsMarketType?.toLowerCase().includes('spread') ?? false; + +export const isLineOutcomeType = (sportsMarketType?: string): boolean => { + const normalizedType = normalizeSportsMarketType(sportsMarketType ?? ''); + return isSpreadOutcomeType(normalizedType) || normalizedType === 'totals'; +}; + +const getDisplayedSpreadLine = ( + outcome: PredictOutcome, +): number | undefined => { + const label = outcome.tokens[0]?.shortTitle ?? outcome.tokens[0]?.title; + const match = label?.trim().match(DISPLAYED_SPREAD_LINE_PATTERN); + if (!match) return undefined; + + const line = Number(match[1]); + return Number.isFinite(line) ? line : undefined; +}; + +const sortLineOutcomesForDisplay = ( + outcomes: PredictOutcome[], + sportsMarketType?: string, +): PredictOutcome[] => { + const hasLineOutcomes = outcomes.some((outcome) => outcome.line != null); + if (!hasLineOutcomes) return outcomes; + + const shouldSortByDisplayedSpreadLine = + isSpreadOutcomeType(sportsMarketType) && + outcomes.every( + (outcome) => + outcome.line == null || getDisplayedSpreadLine(outcome) !== undefined, + ); + + const firstLineOutcome = outcomes.find((outcome) => outcome.line != null); + const firstDisplayedSpreadLine = + shouldSortByDisplayedSpreadLine && firstLineOutcome + ? getDisplayedSpreadLine(firstLineOutcome) + : undefined; + const sortSpreadAscending = + firstDisplayedSpreadLine !== undefined && firstDisplayedSpreadLine > 0; + + return [...outcomes].sort((a, b) => { + if (shouldSortByDisplayedSpreadLine) { + const lineA = getDisplayedSpreadLine(a) ?? 0; + const lineB = getDisplayedSpreadLine(b) ?? 0; + return sortSpreadAscending ? lineA - lineB : lineB - lineA; + } + + return (a.line ?? 0) - (b.line ?? 0); + }); +}; + +export const normalizeEnabledSportsMarketTypes = (value: unknown): string[] => { + const rawTypes = Array.isArray(value) + ? value + : [...SUPPORTED_SPORTS_MARKET_TYPES]; + + return [ + ...new Set(rawTypes.map((type) => String(type).toLowerCase())), + ].filter((type) => SUPPORTED_SPORTS_MARKET_TYPES.has(type)); +}; + +export const filterGroupableOutcomes = ( + outcomes: PredictOutcome[], + enabledSportsMarketTypes: string[], +): PredictOutcome[] => { + const enabledSportsMarketTypeSet = new Set(enabledSportsMarketTypes); + + return outcomes.filter((outcome) => { + if (!outcome.sportsMarketType) return false; + + return enabledSportsMarketTypeSet.has( + outcome.sportsMarketType.toLowerCase(), + ); + }); +}; + +export function buildOutcomeGroups( + outcomes: PredictOutcome[], +): PredictOutcomeGroup[] { + if (outcomes.length === 0) { + return []; + } + + const groupMap = new Map(); + + for (const outcome of outcomes) { + const groupKey = + (outcome.sportsMarketType && + SPORTS_MARKET_TYPE_TO_GROUP[outcome.sportsMarketType]) || + DEFAULT_GROUP_KEY; + + const bucket = groupMap.get(groupKey); + if (bucket) { + bucket.push(outcome); + } else { + groupMap.set(groupKey, [outcome]); + } + } + + for (const [, groupOutcomes] of groupMap) { + groupOutcomes.sort((a, b) => { + const priorityDiff = + getSportsMarketTypePriority( + normalizeSportsMarketType(a.sportsMarketType ?? ''), + ) - + getSportsMarketTypePriority( + normalizeSportsMarketType(b.sportsMarketType ?? ''), + ); + if (priorityDiff !== 0) { + return priorityDiff; + } + if ( + a.sportsMarketType === b.sportsMarketType && + isLineOutcomeType(a.sportsMarketType) + ) { + return 0; + } + const volumeDiff = b.volume - a.volume; + if (volumeDiff !== 0) return volumeDiff; + return (b.liquidity ?? 0) - (a.liquidity ?? 0); + }); + } + + const groupEntries = [...groupMap.entries()]; + groupEntries.sort((a, b) => { + const aIndex = GROUP_ORDER.indexOf(a[0]); + const bIndex = GROUP_ORDER.indexOf(b[0]); + const aPriority = aIndex === -1 ? GROUP_ORDER.length : aIndex; + const bPriority = bIndex === -1 ? GROUP_ORDER.length : bIndex; + if (aPriority !== bPriority) { + return aPriority - bPriority; + } + return a[0].localeCompare(b[0]); + }); + + return groupEntries.map(([key, groupOutcomes]) => { + const typeMap = new Map(); + for (const outcome of groupOutcomes) { + const type = outcome.sportsMarketType ?? key; + const bucket = typeMap.get(type); + if (bucket) { + bucket.push(outcome); + } else { + typeMap.set(type, [outcome]); + } + } + + if (typeMap.size < 2) { + return { + key, + outcomes: sortLineOutcomesForDisplay( + groupOutcomes, + groupOutcomes[0]?.sportsMarketType ?? key, + ), + }; + } + + const subgroupEntries = [...typeMap.entries()]; + subgroupEntries.sort( + (a, b) => + getSportsMarketTypePriority(normalizeSportsMarketType(a[0])) - + getSportsMarketTypePriority(normalizeSportsMarketType(b[0])), + ); + + return { + key, + outcomes: [], + subgroups: subgroupEntries.map(([subKey, subOutcomes]) => ({ + key: subKey, + outcomes: sortLineOutcomesForDisplay(subOutcomes, subKey), + })), + }; + }); +} + +const sortByLiquidityAndVolume = ( + markets: PolymarketApiMarket[], +): PolymarketApiMarket[] => + [...markets].sort((a, b) => { + const aScore = (a.liquidity ?? 0) + (a.volumeNum ?? 0); + const bScore = (b.liquidity ?? 0) + (b.volumeNum ?? 0); + return bScore - aScore; + }); + +/** + * Sort sport markets by: + * 1. Group by sportsMarketType + * 2. Order groups: moneyline first, spreads second, totals third, then alphabetically + * 3. Within each group, sort by liquidity + volume (descending) + * 4. Return flattened array of all groups in order + */ +export const sortGameMarkets = ( + markets: PolymarketApiMarket[], +): PolymarketApiMarket[] => { + const groupedMarkets = markets.reduce>( + (acc, market) => { + const type = market.sportsMarketType ?? 'other'; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(market); + return acc; + }, + {}, + ); + + const sortedTypes = Object.keys(groupedMarkets).sort((a, b) => { + const priorityA = getSportsMarketTypePriority(a); + const priorityB = getSportsMarketTypePriority(b); + + if (priorityA === priorityB && priorityA === 3) { + return a.toLowerCase().localeCompare(b.toLowerCase()); + } + + return priorityA - priorityB; + }); + + // Preserve API order for line markets; their display order is derived later. + return sortedTypes.flatMap((type) => + isLineOutcomeType(type) + ? groupedMarkets[type] + : sortByLiquidityAndVolume(groupedMarkets[type]), + ); +}; diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 67c054353a7..bf509e7e1a7 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -13,7 +13,6 @@ import { } from './constants'; import { buildMarketListQueryParams, - buildOutcomeGroups, calculateConservativeBuyMarketFee, clearClobMarketInfoCache, clearClobMarketInfoSessionState, @@ -147,6 +146,89 @@ describe('polymarket utils', () => { ...overrides, }); + const createNbaTeam = ( + abbreviation: string, + overrides: Partial = {}, + ): PolymarketApiTeam => ({ + id: `team-${abbreviation}`, + name: abbreviation.toUpperCase(), + logo: `${abbreviation}.png`, + abbreviation, + color: 'red', + alias: abbreviation.toUpperCase(), + league: 'nba', + ...overrides, + }); + + const nbaTeamsByAbbreviation: Record = { + bos: createNbaTeam('bos'), + nyk: createNbaTeam('nyk', { color: 'blue' }), + }; + + const createSportsMarket = ({ + id, + sportsMarketType, + volume = 100, + }: { + id: string; + sportsMarketType?: string; + volume?: number; + }): PolymarketApiEvent['markets'][number] => + ({ + conditionId: id, + question: `${id} question`, + description: `${id} description`, + icon: 'icon.png', + image: 'image.png', + groupItemTitle: id, + sportsMarketType, + status: 'open', + volumeNum: volume, + liquidity: 100, + negRisk: false, + clobTokenIds: `["${id}-yes","${id}-no"]`, + outcomes: '["Yes","No"]', + outcomePrices: '["0.5","0.5"]', + closed: false, + active: true, + acceptingOrders: true, + resolvedBy: '', + orderPriceMinTickSize: 0.01, + umaResolutionStatus: '', + }) as PolymarketApiEvent['markets'][number]; + + const createNbaGameEvent = ( + markets: PolymarketApiEvent['markets'], + ): PolymarketApiEvent => ({ + id: 'nba-game-event', + slug: 'nba-bos-nyk-2026-06-12', + title: 'Boston Celtics vs New York Knicks', + description: 'NBA game', + icon: 'icon.png', + closed: false, + active: true, + series: [ + { + id: 'nba-series', + slug: 'nba', + title: 'NBA', + recurrence: 'daily', + }, + ], + markets, + tags: [ + { id: 'games', label: 'Games', slug: 'games' }, + { id: 'nba', label: 'NBA', slug: 'nba' }, + ], + teams: Object.values(nbaTeamsByAbbreviation), + liquidity: 100, + volume: 100, + gameId: 'nba-game-1', + startTime: '2026-06-12T20:00:00.000Z', + live: false, + ended: false, + }); + it('parses activity with deterministic ids when transaction hash is missing', () => { const rawActivity = createRawActivity({ transactionHash: undefined as unknown as string, @@ -167,47 +249,127 @@ describe('polymarket utils', () => { expect(parsedActivity[0].id).not.toBe(parsedActivity[1].id); }); - it('groups tennis first set markets separately from game lines', () => { - const createOutcome = ( - id: string, - sportsMarketType: string, - ): PredictOutcome => ({ - id, - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - title: id, - description: id, - image: 'icon.png', - status: 'open', - tokens: [{ id: `${id}-token`, title: 'Yes', price: 0.5 }], - volume: 100, - groupItemTitle: id, - sportsMarketType, + it('builds outcome groups only from supported and enabled sports market types', () => { + const event = createNbaGameEvent([ + createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }), + createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }), + createSportsMarket({ id: 'totals', sportsMarketType: 'totals' }), + createSportsMarket({ id: 'points', sportsMarketType: 'points' }), + createSportsMarket({ + id: 'first-half-moneyline', + sportsMarketType: 'first_half_moneyline', + }), + ]); + + const [market] = parsePolymarketEvents([event], { + category: 'hot', + teamLookup: (_league, abbreviation) => + nbaTeamsByAbbreviation[abbreviation], + extendedSportsMarketsLeagues: ['nba'], + enabledSportsMarketTypes: [ + 'moneyline', + 'spreads', + 'totals', + 'points', + 'first_half_moneyline', + ], }); - const groups = buildOutcomeGroups([ - createOutcome('moneyline', 'moneyline'), - createOutcome('set-total', 'tennis_set_totals'), - createOutcome('match-total', 'tennis_match_totals'), - createOutcome('completed', 'tennis_completed_match'), - createOutcome('first-set-winner', 'tennis_first_set_winner'), - createOutcome('first-set-total', 'tennis_first_set_totals'), + expect(market.outcomes).toHaveLength(5); + expect(market.outcomes.map((outcome) => outcome.sportsMarketType)).toEqual( + expect.arrayContaining([ + 'moneyline', + 'spreads', + 'totals', + 'points', + 'first_half_moneyline', + ]), + ); + expect(market.outcomeGroups).toEqual([ + expect.objectContaining({ + key: 'game_lines', + outcomes: [], + subgroups: [ + expect.objectContaining({ key: 'moneyline' }), + expect.objectContaining({ key: 'spreads' }), + expect.objectContaining({ key: 'totals' }), + ], + }), + ]); + }); + + it('keeps supported but unlisted sports market types out of outcome groups', () => { + const event = createNbaGameEvent([ + createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }), + createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }), + createSportsMarket({ id: 'totals', sportsMarketType: 'totals' }), + ]); + + const [market] = parsePolymarketEvents([event], { + category: 'hot', + teamLookup: (_league, abbreviation) => + nbaTeamsByAbbreviation[abbreviation], + extendedSportsMarketsLeagues: ['nba'], + enabledSportsMarketTypes: ['moneyline'], + }); + + expect(market.outcomes).toHaveLength(3); + expect(market.outcomes.map((outcome) => outcome.sportsMarketType)).toEqual( + expect.arrayContaining(['moneyline', 'spreads', 'totals']), + ); + expect(market.outcomeGroups).toEqual([ + expect.objectContaining({ + key: 'game_lines', + outcomes: [expect.objectContaining({ sportsMarketType: 'moneyline' })], + }), ]); + }); - expect(groups.map((group) => group.key)).toEqual([ - 'game_lines', - 'first_set', + it('defaults outcome groups to supported market types when enabledSportsMarketTypes is missing', () => { + const event = createNbaGameEvent([ + createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }), + createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }), ]); - expect(groups[0].subgroups?.map((group) => group.key)).toEqual([ - 'moneyline', - 'tennis_set_totals', - 'tennis_match_totals', - 'tennis_completed_match', + + const [market] = parsePolymarketEvents([event], { + category: 'hot', + teamLookup: (_league, abbreviation) => + nbaTeamsByAbbreviation[abbreviation], + extendedSportsMarketsLeagues: ['nba'], + }); + + expect(market.outcomes).toHaveLength(2); + expect(market.outcomes.map((outcome) => outcome.sportsMarketType)).toEqual( + expect.arrayContaining(['moneyline', 'spreads']), + ); + expect(market.outcomeGroups).toEqual([ + expect.objectContaining({ + key: 'game_lines', + outcomes: [], + subgroups: [ + expect.objectContaining({ key: 'moneyline' }), + expect.objectContaining({ key: 'spreads' }), + ], + }), ]); - expect(groups[1].subgroups?.map((group) => group.key)).toEqual([ - 'tennis_first_set_winner', - 'tennis_first_set_totals', + }); + + it('does not build outcome groups when enabledSportsMarketTypes is empty', () => { + const event = createNbaGameEvent([ + createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }), + createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }), ]); + + const [market] = parsePolymarketEvents([event], { + category: 'hot', + teamLookup: (_league, abbreviation) => + nbaTeamsByAbbreviation[abbreviation], + extendedSportsMarketsLeagues: ['nba'], + enabledSportsMarketTypes: [], + }); + + expect(market.outcomes).toHaveLength(2); + expect(market.outcomeGroups).toBeUndefined(); }); it('parses World Cup game events with game metadata when team data is available', () => { diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index d51aa008095..c21b1e0b6f1 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -18,7 +18,6 @@ import { type PredictPosition, PredictActivity, PredictOutcome, - PredictOutcomeGroup, PredictOutcomeToken, PredictMarketGame, } from '../../types'; @@ -50,10 +49,8 @@ import type { import { ClobAuthDomain, DEFAULT_CLOB_BASE_URL, - DEFAULT_GROUP_KEY, EIP712Domain, GROUP_ORDER, - SPORTS_MARKET_TYPE_PRIORITIES, HASH_ZERO_BYTES32, MATIC_CONTRACTS_V2, MSG_TO_SIGN, @@ -87,8 +84,15 @@ import { PREDICT_WORLD_CUP_DEFAULT_TAG_SLUG } from '../../constants/flags'; import { PredictFeeCollection } from '../../types/flags'; import { roundToFiveDecimals } from '../../utils/orders'; import { getMinAmountReceivedWithSlippage } from './protocol/slippage'; +import { + buildOutcomeGroups, + filterGroupableOutcomes, + normalizeEnabledSportsMarketTypes, + sortGameMarkets, +} from './outcomeGrouping'; export { SPORTS_MARKET_TYPE_TO_GROUP, GROUP_ORDER } from './constants'; +export { buildOutcomeGroups, sortGameMarkets } from './outcomeGrouping'; /** * Parse a fetch `Response` body as JSON, raising a contextual error when the @@ -640,122 +644,12 @@ function replaceAll(s: string, search: string, replace: string) { return s.split(search).join(replace); } -const normalizeSportsMarketType = (type: string): string => { - const lower = type.toLowerCase(); - if (lower.startsWith('first_half_')) { - return lower.slice('first_half_'.length); - } - return lower; -}; - -const getSportsMarketTypePriority = (type: string): number => - SPORTS_MARKET_TYPE_PRIORITIES[type.toLowerCase()] ?? 3; - -export function buildOutcomeGroups( - outcomes: PredictOutcome[], -): PredictOutcomeGroup[] { - if (outcomes.length === 0) { - return []; - } - - const groupMap = new Map(); - - for (const outcome of outcomes) { - const groupKey = - (outcome.sportsMarketType && - SPORTS_MARKET_TYPE_TO_GROUP[outcome.sportsMarketType]) || - DEFAULT_GROUP_KEY; - - const bucket = groupMap.get(groupKey); - if (bucket) { - bucket.push(outcome); - } else { - groupMap.set(groupKey, [outcome]); - } - } - - for (const [, groupOutcomes] of groupMap) { - groupOutcomes.sort((a, b) => { - const priorityDiff = - getSportsMarketTypePriority( - normalizeSportsMarketType(a.sportsMarketType ?? ''), - ) - - getSportsMarketTypePriority( - normalizeSportsMarketType(b.sportsMarketType ?? ''), - ); - if (priorityDiff !== 0) { - return priorityDiff; - } - const volumeDiff = b.volume - a.volume; - if (volumeDiff !== 0) return volumeDiff; - return (b.liquidity ?? 0) - (a.liquidity ?? 0); - }); - } - - const groupEntries = [...groupMap.entries()]; - groupEntries.sort((a, b) => { - const aIndex = GROUP_ORDER.indexOf(a[0]); - const bIndex = GROUP_ORDER.indexOf(b[0]); - const aPriority = aIndex === -1 ? GROUP_ORDER.length : aIndex; - const bPriority = bIndex === -1 ? GROUP_ORDER.length : bIndex; - if (aPriority !== bPriority) { - return aPriority - bPriority; - } - return a[0].localeCompare(b[0]); - }); - - return groupEntries.map(([key, groupOutcomes]) => { - const typeMap = new Map(); - for (const outcome of groupOutcomes) { - const type = outcome.sportsMarketType ?? key; - const bucket = typeMap.get(type); - if (bucket) { - bucket.push(outcome); - } else { - typeMap.set(type, [outcome]); - } - } - - if (typeMap.size < 2) { - return { key, outcomes: groupOutcomes }; - } - - const subgroupEntries = [...typeMap.entries()]; - subgroupEntries.sort( - (a, b) => - getSportsMarketTypePriority(normalizeSportsMarketType(a[0])) - - getSportsMarketTypePriority(normalizeSportsMarketType(b[0])), - ); - - return { - key, - outcomes: [], - subgroups: subgroupEntries.map(([subKey, subOutcomes]) => ({ - key: subKey, - outcomes: subOutcomes, - })), - }; - }); -} - export const isSpreadMarket = (market: PolymarketApiMarket): boolean => market.sportsMarketType?.toLowerCase().includes('spread') ?? false; const isMoneylineLikeMarket = (market: PolymarketApiMarket): boolean => isMoneylineLikeMarketType(market.sportsMarketType); -/** - * Sort markets within a sports market type group by liquidity + volume (descending) - */ -const sortByLiquidityAndVolume = ( - markets: PolymarketApiMarket[], -): PolymarketApiMarket[] => - [...markets].sort((a, b) => { - const aScore = (a.liquidity ?? 0) + (a.volumeNum ?? 0); - const bScore = (b.liquidity ?? 0) + (b.volumeNum ?? 0); - return bScore - aScore; - }); - const formatMarketGroupItemTitle = (market: PolymarketApiMarket): string => { const groupItemTitle = market.groupItemTitle ?? market.question ?? ''; @@ -906,48 +800,6 @@ const parsePolymarketMarketOutcomes = ( return sortOutcomeTokens(outcomeTokens, market, event); }; -/** - * Sort sport markets by: - * 1. Group by sportsMarketType - * 2. Order groups: moneyline first, spreads second, totals third, then alphabetically - * 3. Within each group, sort by liquidity + volume (descending) - * 4. Return flattened array of all groups in order - */ -export const sortGameMarkets = ( - markets: PolymarketApiMarket[], -): PolymarketApiMarket[] => { - // Group markets by sportsMarketType - const groupedMarkets = markets.reduce>( - (acc, market) => { - const type = market.sportsMarketType ?? 'other'; - if (!acc[type]) { - acc[type] = []; - } - acc[type].push(market); - return acc; - }, - {}, - ); - - // Get all unique types and sort them by priority - const sortedTypes = Object.keys(groupedMarkets).sort((a, b) => { - const priorityA = getSportsMarketTypePriority(a); - const priorityB = getSportsMarketTypePriority(b); - - // If same priority (both are "other" category), sort alphabetically - if (priorityA === priorityB && priorityA === 3) { - return a.toLowerCase().localeCompare(b.toLowerCase()); - } - - return priorityA - priorityB; - }); - - // Sort each group by liquidity + volume, then flatten - return sortedTypes.flatMap((type) => - sortByLiquidityAndVolume(groupedMarkets[type]), - ); -}; - export const sortMarketsByField = ( markets: PolymarketApiMarket[], sortBy: 'price' | 'ascending' | 'descending', @@ -1070,6 +922,7 @@ export interface ParsePolymarketEventsOptions { sortMarketsBy?: 'price' | 'ascending' | 'descending'; teamLookup?: PolymarketTeamLookupFn; extendedSportsMarketsLeagues?: string[]; + enabledSportsMarketTypes?: string[]; } export const parsePolymarketEvents = ( @@ -1082,7 +935,12 @@ export const parsePolymarketEvents = ( ? { category: categoryOrOptions, sortMarketsBy } : categoryOrOptions; - const { category, teamLookup, extendedSportsMarketsLeagues } = options; + const { + category, + teamLookup, + extendedSportsMarketsLeagues, + enabledSportsMarketTypes, + } = options; const sortBy = options.sortMarketsBy ?? sortMarketsBy; return events.flatMap((event: PolymarketApiEvent) => { @@ -1135,10 +993,17 @@ export const parsePolymarketEvents = ( game && eventLeague && extendedSportsMarketsLeagues?.includes(eventLeague); + const normalizedEnabledSportsMarketTypes = + normalizeEnabledSportsMarketTypes(enabledSportsMarketTypes); - const outcomeGroups = outcomeGroupingEnabled - ? buildOutcomeGroups(outcomes) - : undefined; + const outcomesForGroups = outcomeGroupingEnabled + ? filterGroupableOutcomes(outcomes, normalizedEnabledSportsMarketTypes) + : []; + + const outcomeGroups = + outcomesForGroups.length > 0 + ? buildOutcomeGroups(outcomesForGroups) + : undefined; const priceToBeat = parseEventPriceToBeat(event); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 2ede9695501..c939676167f 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -11,6 +11,7 @@ import { selectPredictHomeRedesignEnabledFlag, selectPredictHotTabFlag, selectPredictPortfolioEnabledFlag, + selectPredictSportCardLivePricesEnabledFlag, selectPredictUpDownEnabledFlag, selectPredictWithAnyTokenEnabledFlag, selectPredictWorldCupConfig, @@ -1782,6 +1783,35 @@ describe('Predict Feature Flag Selectors', () => { }); }); + describe('selectPredictSportCardLivePricesEnabledFlag', () => { + it('defaults to true when the remote flag is missing', () => { + expect( + selectPredictSportCardLivePricesEnabledFlag(mockedEmptyFlagsState), + ).toBe(true); + }); + + it('returns false when the remote flag is disabled', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictSportCardLivePrices: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + expect(selectPredictSportCardLivePricesEnabledFlag(state)).toBe(false); + }); + }); + describe('selectPredictFeaturedCarouselEnabledFlag', () => { it('returns true when flag is enabled and version requirement is met', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index 3bf603691a4..f4c2369317e 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -182,6 +182,11 @@ export const selectPredictHomeRedesignEnabledFlag = createSelector( (flags) => flags.predictHomeRedesignEnabled, ); +export const selectPredictSportCardLivePricesEnabledFlag = createSelector( + selectPredictFeatureFlags, + (flags) => flags.predictSportCardLivePricesEnabled, +); + export const selectPredictFeaturedCarouselEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 9ec21374123..6fd42af8f67 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -22,6 +22,7 @@ export interface PredictMarketHighlightsFlag extends VersionGatedFeatureFlag { export interface PredictExtendedSportsMarketsFlag extends VersionGatedFeatureFlag { leagues: string[]; + enabledSportsMarketTypes: string[]; } export interface PredictWorldCupStageConfig { @@ -49,6 +50,7 @@ export interface PredictFeatureFlags { feeCollection: PredictFeeCollection; liveSportsLeagues: string[]; extendedSportsMarketsLeagues: string[]; + enabledSportsMarketTypes: string[]; marketHighlightsFlag: PredictMarketHighlightsFlag; fakOrdersEnabled: boolean; predictWithAnyTokenEnabled: boolean; @@ -56,6 +58,7 @@ export interface PredictFeatureFlags { predictWorldCup: PredictWorldCupConfig; predictPortfolioEnabled: boolean; predictHomeRedesignEnabled: boolean; + predictSportCardLivePricesEnabled: boolean; } export interface PredictHotTabFlag extends VersionGatedFeatureFlag { diff --git a/app/components/UI/Predict/types/navigation.ts b/app/components/UI/Predict/types/navigation.ts index c491caf1147..cab38b4d691 100644 --- a/app/components/UI/Predict/types/navigation.ts +++ b/app/components/UI/Predict/types/navigation.ts @@ -28,6 +28,7 @@ export type PredictEntryPoint = | typeof PredictEventValues.ENTRY_POINT.HOMEPAGE_FEATURED_CAROUSEL | typeof PredictEventValues.ENTRY_POINT.HOMEPAGE_FEATURED_LIST | typeof PredictEventValues.ENTRY_POINT.MAIN_TRADE_BUTTON + | typeof PredictEventValues.ENTRY_POINT.HOMESCREEN_PILL | typeof PredictEventValues.ENTRY_POINT.BACKGROUND | typeof PredictEventValues.ENTRY_POINT.TRENDING_SEARCH | typeof PredictEventValues.ENTRY_POINT.TRENDING diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts index d42dbd867ea..c1e0f22fde7 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts @@ -28,12 +28,14 @@ describe('resolvePredictFeatureFlags', () => { feeCollection: DEFAULT_FEE_COLLECTION_FLAG, liveSportsLeagues: [], extendedSportsMarketsLeagues: [], + enabledSportsMarketTypes: [], marketHighlightsFlag: DEFAULT_MARKET_HIGHLIGHTS_FLAG, fakOrdersEnabled: false, predictWithAnyTokenEnabled: false, predictUpDownEnabled: false, predictPortfolioEnabled: false, predictHomeRedesignEnabled: false, + predictSportCardLivePricesEnabled: true, predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG, }); }); @@ -216,6 +218,39 @@ describe('resolvePredictFeatureFlags', () => { expect(result.predictWithAnyTokenEnabled).toBe(false); }); + describe('predictSportCardLivePricesEnabled', () => { + it('defaults to true so sport cards fetch live prices by default', () => { + const result = resolvePredictFeatureFlags({}); + + expect(result.predictSportCardLivePricesEnabled).toBe(true); + }); + + it('returns false when the remote flag is disabled and version gate passes', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if ( + flag && + typeof flag === 'object' && + 'enabled' in flag && + 'minimumVersion' in flag + ) { + return (flag as { enabled: boolean }).enabled; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictSportCardLivePrices: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + }); + + expect(result.predictSportCardLivePricesEnabled).toBe(false); + }); + }); + describe('predictWorldCup', () => { it('returns default disabled config when flag is missing', () => { const result = resolvePredictFeatureFlags({}); @@ -690,4 +725,162 @@ describe('resolvePredictFeatureFlags', () => { expect(result.extendedSportsMarketsLeagues).toEqual([]); }); }); + + describe('enabledSportsMarketTypes', () => { + it('returns empty array when flag is missing', () => { + const result = resolvePredictFeatureFlags({}); + + expect(result.enabledSportsMarketTypes).toEqual([]); + }); + + it('returns supported market types when enabledSportsMarketTypes is missing', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if (flag && typeof flag === 'object' && 'leagues' in flag) { + return true; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictExtendedSportsMarkets: { + enabled: true, + minimumVersion: '1.0.0', + leagues: ['nba'], + }, + }, + }); + + expect(result.enabledSportsMarketTypes).toEqual([ + 'moneyline', + 'spreads', + 'totals', + ]); + }); + + it('returns empty array when enabledSportsMarketTypes is explicit empty array', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if (flag && typeof flag === 'object' && 'leagues' in flag) { + return true; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictExtendedSportsMarkets: { + enabled: true, + minimumVersion: '1.0.0', + leagues: ['nba'], + enabledSportsMarketTypes: [], + }, + }, + }); + + expect(result.enabledSportsMarketTypes).toEqual([]); + }); + + it('returns empty array when flag is disabled', () => { + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictExtendedSportsMarkets: { + ...DEFAULT_EXTENDED_SPORTS_MARKETS_FLAG, + enabled: false, + leagues: ['nba'], + enabledSportsMarketTypes: ['moneyline', 'spreads', 'totals'], + }, + }, + }); + + expect(result.enabledSportsMarketTypes).toEqual([]); + }); + + it('returns empty array when version check fails', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if (flag && typeof flag === 'object' && 'leagues' in flag) { + return false; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictExtendedSportsMarkets: { + enabled: true, + minimumVersion: '99.0.0', + leagues: ['nba'], + enabledSportsMarketTypes: ['moneyline', 'spreads', 'totals'], + }, + }, + }); + + expect(result.enabledSportsMarketTypes).toEqual([]); + }); + + it('returns filtered market types when enabled and version check passes', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { + if (flag && typeof flag === 'object' && 'leagues' in flag) { + return true; + } + return undefined; + }); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictExtendedSportsMarkets: { + enabled: true, + minimumVersion: '1.0.0', + leagues: ['nba'], + enabledSportsMarketTypes: [ + 'moneyline', + 'MONEYLINE', + 'spreads', + 'totals', + 'first_half_moneyline', + 'points', + ], + }, + }, + }); + + expect(result.enabledSportsMarketTypes).toEqual([ + 'moneyline', + 'spreads', + 'totals', + ]); + }); + + it('applies local override over remote flag', () => { + mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => + Boolean( + flag && + typeof flag === 'object' && + 'enabled' in flag && + 'leagues' in flag && + (flag as { enabled: boolean }).enabled, + ), + ); + + const result = resolvePredictFeatureFlags({ + remoteFeatureFlags: { + predictExtendedSportsMarkets: { + enabled: true, + minimumVersion: '1.0.0', + leagues: ['nba'], + enabledSportsMarketTypes: ['moneyline', 'spreads'], + }, + }, + localOverrides: { + predictExtendedSportsMarkets: { + enabled: true, + minimumVersion: '1.0.0', + leagues: ['nba'], + enabledSportsMarketTypes: ['totals'], + }, + }, + }); + + expect(result.enabledSportsMarketTypes).toEqual(['totals']); + }); + }); }); diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts index 29d30fefc42..8ea70c602ee 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts @@ -10,6 +10,7 @@ import { DEFAULT_PREDICT_WORLD_CUP_FLAG, } from '../constants/flags'; import { filterSupportedLeagues } from '../constants/sports'; +import { normalizeEnabledSportsMarketTypes } from '../providers/polymarket/outcomeGrouping'; import { parse, PredictFeeCollectionSchema, @@ -85,11 +86,16 @@ export function resolvePredictFeatureFlags( unwrapRemoteFeatureFlag( flags.predictExtendedSportsMarkets, ) ?? DEFAULT_EXTENDED_SPORTS_MARKETS_FLAG; - const extendedSportsMarketsLeagues = validatedVersionGatedFeatureFlag( - extendedSportsFlag, - ) + const extendedSportsMarketsEnabled = + validatedVersionGatedFeatureFlag(extendedSportsFlag); + const extendedSportsMarketsLeagues = extendedSportsMarketsEnabled ? filterSupportedLeagues(extendedSportsFlag.leagues ?? []) : []; + const enabledSportsMarketTypes = extendedSportsMarketsEnabled + ? normalizeEnabledSportsMarketTypes( + extendedSportsFlag.enabledSportsMarketTypes, + ) + : []; const fakOrdersEnabled = resolveVersionGatedBooleanFlag( flags.predictFakOrders, ); @@ -105,6 +111,10 @@ export function resolvePredictFeatureFlags( const predictHomeRedesignEnabled = resolveVersionGatedBooleanFlag( flags.predictHomeRedesign, ); + const predictSportCardLivePricesEnabled = resolveVersionGatedBooleanFlag( + flags.predictSportCardLivePrices, + true, + ); const parsedPredictWorldCup = parse( unwrapRemoteFeatureFlag( flags.predictWorldCup, @@ -122,12 +132,14 @@ export function resolvePredictFeatureFlags( feeCollection, liveSportsLeagues, extendedSportsMarketsLeagues, + enabledSportsMarketTypes, marketHighlightsFlag, fakOrdersEnabled, predictWithAnyTokenEnabled, predictUpDownEnabled, predictPortfolioEnabled, predictHomeRedesignEnabled, + predictSportCardLivePricesEnabled, predictWorldCup, }; } diff --git a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx index 45444d4d9ab..c14d44f9ee9 100644 --- a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx +++ b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx @@ -21,7 +21,7 @@ import { PredictSearchSelectorsIDs, } from '../../Predict.testIds'; import { PREDICT_HEADER_STACKED_TEST_IDS } from '../../components/PredictHeaderStacked'; -import { MOCK_PREDICT_MARKET } from '../../../../../../tests/component-view/fixtures/predict'; +import { MOCK_PREDICT_LIVE_SPORT_MARKET } from '../../../../../../tests/component-view/fixtures/predict'; const SEARCH_PLACEHOLDER = 'Search prediction markets'; const PREDICTIONS_TITLE = 'Predictions'; @@ -47,11 +47,15 @@ describe('PredictHome', () => { ( Engine.context.PredictController.getMarkets as jest.Mock ).mockResolvedValue({ markets: [], nextCursor: null }); - // The Live Now section hides itself when there is no data, so it needs at - // least one live market to render for the composition/order assertion. + // Live Now hides when empty after load and only keeps scoreboard-capable + // markets (`game` present). The sport card also needs full team/league data + // once the carousel renders loaded cards (see MOCK_PREDICT_LIVE_SPORT_MARKET). ( Engine.context.PredictController.listMarkets as jest.Mock - ).mockResolvedValue({ markets: [MOCK_PREDICT_MARKET], nextCursor: null }); + ).mockResolvedValue({ + markets: [MOCK_PREDICT_LIVE_SPORT_MARKET], + nextCursor: null, + }); ( Engine.context.PredictController.listFilterOptions as jest.Mock ).mockResolvedValue([ @@ -111,26 +115,15 @@ describe('PredictHome', () => { }); it('composes the section placeholders in Figma order', async () => { - const { findByTestId, getByTestId } = renderPredictHomeView(); + const { findByTestId } = renderPredictHomeView(); - // Wait for the shell to mount. + // Wait for the shell and async sections (Live Now loads via React Query). await findByTestId(PredictHomeSelectorsIDs.CONTAINER); - - expect( - getByTestId(PredictHomeSelectorsIDs.PORTFOLIO_MODULE), - ).toBeOnTheScreen(); - expect( - getByTestId(PredictHomeSelectorsIDs.LIVE_NOW_SECTION), - ).toBeOnTheScreen(); - expect( - getByTestId(PredictHomeSelectorsIDs.CATEGORIES_SECTION), - ).toBeOnTheScreen(); - expect( - getByTestId(PredictHomeSelectorsIDs.POPULAR_TODAY_SECTION), - ).toBeOnTheScreen(); - expect( - getByTestId(PredictHomeSelectorsIDs.TRENDING_SECTION), - ).toBeOnTheScreen(); + await findByTestId(PredictHomeSelectorsIDs.PORTFOLIO_MODULE); + await findByTestId(PredictHomeSelectorsIDs.LIVE_NOW_SECTION); + await findByTestId(PredictHomeSelectorsIDs.CATEGORIES_SECTION); + await findByTestId(PredictHomeSelectorsIDs.POPULAR_TODAY_SECTION); + await findByTestId(PredictHomeSelectorsIDs.TRENDING_SECTION); }); }); diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index f57bea9716a..568ec33b0e6 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -30,6 +30,7 @@ import PredictThePitchCampaignDetailsView from './Views/PredictThePitchCampaignD import PredictThePitchCampaignWinningView from './Views/PredictThePitchCampaignWinningView'; import PredictThePitchCampaignLeaderboardView from './Views/PredictThePitchCampaignLeaderboardView'; import PredictThePitchCampaignPortfolioView from './Views/PredictThePitchCampaignPortfolioView'; +import PredictThePitchCampaignStatsView from './Views/PredictThePitchCampaignStatsView'; import { useSelector } from 'react-redux'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; import { selectIsRewardsVersionBlocked } from '../../../reducers/rewards/selectors'; @@ -277,6 +278,11 @@ const RewardsNavigator: React.FC = () => { component={PredictThePitchCampaignLeaderboardView} options={{ headerShown: false }} /> + { PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME, ), ).toBeDefined(); + expect( + getByTestId( + PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED, + ), + ).toBeDefined(); + }); + + it('shows x/y markets when below minimum', () => { + mockUseGetPosition.mockReturnValue({ + position: { + ...basePosition, + marketsTraded: 2, + minimumMarketsTraded: 3, + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + const { getByTestId } = render(); + + expect( + getByTestId( + PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED, + ).props.children, + ).toBe('2/3'); + }); + + it('shows count only when markets traded meets minimum', () => { + mockUseGetPosition.mockReturnValue({ + position: { + ...basePosition, + marketsTraded: 5, + minimumMarketsTraded: 3, + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + const { getByTestId } = render(); + + expect( + getByTestId( + PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED, + ).props.children, + ).toBe('5'); + }); + + it('hides the markets traded cell when marketsTraded is null', () => { + mockUseGetPosition.mockReturnValue({ + position: { + ...basePosition, + marketsTraded: null, + minimumMarketsTraded: 3, + } as unknown as PredictThePitchLeaderboardPositionDto, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + const { queryByTestId } = render(); + + expect( + queryByTestId( + PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED, + ), + ).toBeNull(); + }); + + it('hides the markets traded cell when position is null', () => { + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + const { queryByTestId } = render(); + + expect( + queryByTestId( + PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED, + ), + ).toBeNull(); }); it('shows fallback performance values when position numeric fields are invalid', () => { diff --git a/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx index b8898bf7f27..299df7d6d4d 100644 --- a/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx @@ -42,6 +42,8 @@ export const PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS = { PERFORMANCE_PNL: 'predict-the-pitch-campaign-stats-view-performance-pnl', PERFORMANCE_VOLUME: 'predict-the-pitch-campaign-stats-view-performance-volume', + PERFORMANCE_MARKETS_TRADED: + 'predict-the-pitch-campaign-stats-view-performance-markets-traded', QUALIFIED_CARD: 'predict-the-pitch-campaign-stats-view-qualified-card', QUALIFY_FOR_RANK_CARD: 'predict-the-pitch-campaign-stats-view-qualify-for-rank-card', @@ -98,6 +100,16 @@ const PredictThePitchCampaignStatsView: React.FC = () => { const pnlDisplay = pnl != null ? formatSignedUsd(pnl) : '-'; const volumeDisplay = volume != null ? formatUsd(volume) : '-'; + const marketsTraded = position?.marketsTraded ?? null; + const minimumMarketsTraded = position?.minimumMarketsTraded ?? null; + const showMarketsTraded = + marketsTraded != null && minimumMarketsTraded != null; + const marketsDisplay = showMarketsTraded + ? marketsTraded < minimumMarketsTraded + ? `${marketsTraded}/${minimumMarketsTraded}` + : String(marketsTraded) + : ''; + const isPending = position != null && !position.eligible; const isEligible = position != null && position.eligible; @@ -185,7 +197,20 @@ const PredictThePitchCampaignStatsView: React.FC = () => { PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME } /> - + {showMarketsTraded ? ( + + ) : ( + + )} {showQualifiedCard && ( diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx index 3e1f21a7870..1defeacad1b 100644 --- a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx @@ -5,7 +5,7 @@ import { useSelector } from 'react-redux'; import Routes from '../../../../constants/navigation/Routes'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; -import { VIP_REFEREE_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipRefereeSplashScreen'; +import { VIP_REFEREE_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipSplashScreenLayout'; import RewardsVipRefereeSplashView from './RewardsVipRefereeSplashView'; const mockNavigateDispatch = jest.fn(); @@ -102,11 +102,11 @@ jest.mock('../../../../images/rewards/vip_splash.png', () => 1); jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string, opts?: Record) => { const translations: Record = { - 'rewards.vip.referee_splash_title': 'WELCOME\nTO MOCK\nFOX VIP', - 'rewards.vip.referee_splash_description': + 'rewards.vip.splash_title': 'WELCOME\nTO MOCK\nFOX COLLECTIVE', + 'rewards.vip.splash_description': 'Placeholder referee splash copy for tests only.', 'rewards.vip.referee_splash_continue': 'Continue', - 'rewards.vip.referee_splash_not_now': 'Not now', + 'rewards.vip.splash_not_now': 'Not now', }; if (key === 'rewards.vip.referee_referred_by') { return `Referred by ${opts?.code ?? ''}`; @@ -165,8 +165,42 @@ describe('RewardsVipRefereeSplashView', () => { expect( getByTestId(VIP_REFEREE_SPLASH_SCREEN_TEST_IDS.CONTAINER), ).toBeOnTheScreen(); - expect(getAllByText('WELCOME\nTO MOCK\nFOX VIP')[0]).toBeOnTheScreen(); + expect( + getAllByText('WELCOME\nTO MOCK\nFOX COLLECTIVE')[0], + ).toBeOnTheScreen(); expect(getByText('Referred by TESTCODE')).toBeOnTheScreen(); + expect( + getByTestId(VIP_REFEREE_SPLASH_SCREEN_TEST_IDS.REFERRED_BY), + ).toBeOnTheScreen(); + }); + + it('omits the referred-by footer when no referral code is available', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return mockSubscriptionId; + if (selector === selectVipProgramEnabled) return mockIsVipProgramEnabled; + + return ( + selector as (state: { + rewards: { + isVipReferee: boolean; + referredByVipCode: string | null; + vipRefereeSplashAccepted: Record; + }; + }) => unknown + )({ + rewards: { + isVipReferee: mockIsVipReferee, + referredByVipCode: null, + vipRefereeSplashAccepted: mockVipRefereeSplashAccepted, + }, + }); + }); + + const { queryByTestId } = render(); + + expect( + queryByTestId(VIP_REFEREE_SPLASH_SCREEN_TEST_IDS.REFERRED_BY), + ).toBeNull(); }); it('replaces with the referee view when continue is pressed', () => { diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx index a01ede5df7b..1221ebef505 100644 --- a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx @@ -1,6 +1,12 @@ import React, { useCallback, useEffect } from 'react'; import { StackActions, useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; +import { + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Routes from '../../../../constants/navigation/Routes'; import { selectHasAcceptedVipRefereeInvite, @@ -9,11 +15,16 @@ import { } from '../../../../reducers/rewards/selectors'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; +import { strings } from '../../../../../locales/i18n'; import ErrorBoundary from '../../../Views/ErrorBoundary'; -import VipRefereeSplashScreen from '../components/Vip/VipRefereeSplashScreen'; +import { VIP_GOLD_TEXT_MUTED } from '../components/Vip/Vip.constants'; +import VipSplashScreenLayout, { + VIP_REFEREE_SPLASH_SCREEN_TEST_IDS, +} from '../components/Vip/VipSplashScreenLayout'; const RewardsVipRefereeSplashViewContent: React.FC = () => { const navigation = useNavigation(); + const tw = useTailwind(); const subscriptionId = useSelector(selectRewardsSubscriptionId); const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipReferee = useSelector(selectIsVipReferee); @@ -62,10 +73,32 @@ const RewardsVipRefereeSplashViewContent: React.FC = () => { } return ( - + {strings('rewards.vip.referee_referred_by', { + code: referredByVipCode, + })} + + ) : null + } /> ); }; diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx index c98c2de0a43..66cb05465da 100644 --- a/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx @@ -135,11 +135,13 @@ jest.mock('../../../../../locales/i18n', () => ({ if (key === 'rewards.vip.referee_referred_by') { return `Referred by ${params?.code ?? ''}`; } + if (key === 'rewards.vip.referee_points_to_label') { + return `Points to ${params?.code ?? ''}`; + } const translations: Record = { 'rewards.vip.referee_page_title': 'VIP Pilot', 'rewards.vip.referee_stats_title': 'Stats', 'rewards.vip.referee_period_last_30d': 'Last 30d', - 'rewards.vip.referee_points_label': 'Points', 'rewards.vip.referee_swaps_volume_label': 'Swaps volume', 'rewards.vip.referee_perps_volume_label': 'Perps volume', 'rewards.vip.referee_error_title': 'Error title', @@ -280,21 +282,57 @@ describe('RewardsVipRefereeView', () => { ).toBeOnTheScreen(); expect(getByText('VIP Pilot')).toBeOnTheScreen(); expect(getByText('Referred by TESTCODE')).toBeOnTheScreen(); - expect( - getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.POINTS), - ).toBeOnTheScreen(); + expect(getByText('Swaps volume')).toBeOnTheScreen(); + expect(getByText('Perps volume')).toBeOnTheScreen(); + expect(getByText('Points to TESTCODE')).toBeOnTheScreen(); expect( getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.SWAPS_VOLUME), ).toBeOnTheScreen(); expect( getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.PERPS_VOLUME), ).toBeOnTheScreen(); + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.POINTS_TO), + ).toBeOnTheScreen(); expect(mockUseTrack).toHaveBeenCalledWith({ page_type: 'vip_referee', enabled: true, }); }); + it('displays swaps and perps volume in separate stat cells', () => { + const { getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.SWAPS_VOLUME), + ).toHaveTextContent(/\$1,000/); + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.PERPS_VOLUME), + ).toHaveTextContent(/\$2,000/); + }); + + it('displays dashboard points in the points-to stat cell', () => { + const { getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.POINTS_TO), + ).toHaveTextContent(/1,234/); + }); + + it('renders an empty points-to label when referredByCode is missing', () => { + mockUseVipRefereeDashboard.mockReturnValue({ + dashboard: { ...defaultDashboard, referredByCode: null }, + isLoading: false, + hasError: false, + hasAttemptedFetch: true, + fetchVipRefereeDashboard: mockFetch, + }); + + const { getByText } = render(); + + expect(getByText('Points to ')).toBeOnTheScreen(); + }); + it('renders the "Last updated" row when computedAt is present', () => { const { getByTestId } = render(); @@ -405,7 +443,7 @@ describe('RewardsVipRefereeView', () => { screen: Routes.WEBVIEW.SIMPLE, params: { url: expect.stringContaining( - `priority=vip&account=${encodeURIComponent(mockAccountAddress)}`, + `priority=vip&address=${encodeURIComponent(mockAccountAddress)}`, ), title: 'Contact support', }, diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx index f2e91e42ab7..3901fb58485 100644 --- a/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx @@ -56,9 +56,9 @@ export const REWARDS_VIP_REFEREE_VIEW_TEST_IDS = { SKELETON: 'rewards-vip-referee-view-skeleton', ERROR: 'rewards-vip-referee-view-error', REFERRED_BY_CARD: 'rewards-vip-referee-view-referred-by-card', - POINTS: 'rewards-vip-referee-view-points', SWAPS_VOLUME: 'rewards-vip-referee-view-swaps-volume', PERPS_VOLUME: 'rewards-vip-referee-view-perps-volume', + POINTS_TO: 'rewards-vip-referee-view-points-to', LAST_UPDATED: 'rewards-vip-referee-view-last-updated', CONTACT_SUPPORT_BUTTON: 'rewards-vip-referee-view-contact-support-button', } as const; @@ -197,8 +197,8 @@ const RewardsVipRefereeViewContent: React.FC = () => { - - + + @@ -207,8 +207,8 @@ const RewardsVipRefereeViewContent: React.FC = () => { - - + + @@ -269,55 +269,57 @@ const RewardsVipRefereeViewContent: React.FC = () => { - {strings('rewards.vip.referee_points_label')} + {strings('rewards.vip.referee_swaps_volume_label')} - {formatNumber(dashboard.points)} + {formatUsd(dashboard.swapsVolume)} - {strings('rewards.vip.referee_swaps_volume_label')} + {strings('rewards.vip.referee_perps_volume_label')} - {formatUsd(dashboard.swapsVolume)} + {formatUsd(dashboard.perpsVolume)} - {strings('rewards.vip.referee_perps_volume_label')} + {strings('rewards.vip.referee_points_to_label', { + code: dashboard.referredByCode ?? '', + })} - {formatUsd(dashboard.perpsVolume)} + {formatNumber(dashboard.points)} diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx index 1ad36d44110..f59398c3a26 100644 --- a/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx @@ -8,7 +8,7 @@ import { selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; -import { VIP_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipSplashScreen'; +import { VIP_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipSplashScreenLayout'; import { useVipDashboard } from '../hooks/useVipDashboard'; import RewardsVipSplashView from './RewardsVipSplashView'; diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx index 8c4f109fdf3..dca3c2f27bc 100644 --- a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx @@ -8,8 +8,11 @@ import { selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; +import { strings } from '../../../../../locales/i18n'; import ErrorBoundary from '../../../Views/ErrorBoundary'; -import VipSplashScreen from '../components/Vip/VipSplashScreen'; +import VipSplashScreenLayout, { + VIP_SPLASH_SCREEN_TEST_IDS, +} from '../components/Vip/VipSplashScreenLayout'; import { useVipDashboard } from '../hooks/useVipDashboard'; const RewardsVipSplashViewContent: React.FC = () => { @@ -59,9 +62,18 @@ const RewardsVipSplashViewContent: React.FC = () => { } return ( - ); }; diff --git a/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx b/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx deleted file mode 100644 index 8b16ffc6ab4..00000000000 --- a/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import MaskedView from '@react-native-masked-view/masked-view'; -import React from 'react'; -import { Image, Pressable } from 'react-native'; -import LinearGradient from 'react-native-linear-gradient'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { - Box, - FontWeight, - Text, - TextVariant, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { strings } from '../../../../../../locales/i18n'; -import VipSplashFox from '../../../../../images/rewards/vip_splash.png'; -import { - VIP_GOLD_BORDER_DEFAULT, - VIP_GOLD_TEXT_DEFAULT, - VIP_GOLD_TEXT_MUTED, - VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND, - VIP_SPLASH_BACKGROUND_GRADIENT_COLORS, - VIP_SPLASH_TITLE_GRADIENT_COLORS, -} from './Vip.constants'; - -export const VIP_REFEREE_SPLASH_SCREEN_TEST_IDS = { - CONTAINER: 'rewards-vip-referee-splash-screen', - TITLE: 'rewards-vip-referee-splash-title', - DESCRIPTION: 'rewards-vip-referee-splash-description', - FOX: 'rewards-vip-referee-splash-fox', - REFERRED_BY: 'rewards-vip-referee-splash-referred-by', - CONTINUE_BUTTON: 'rewards-vip-referee-splash-continue-button', - NOT_NOW_BUTTON: 'rewards-vip-referee-splash-not-now-button', -} as const; - -interface VipRefereeSplashScreenProps { - onContinue: () => void; - onNotNow: () => void; - referredByCode?: string | null; -} - -const titleColorStyle = { color: VIP_SPLASH_TITLE_GRADIENT_COLORS[0] }; -const titleFontStyle = { - fontFamily: 'MMPoly-Regular', - fontWeight: '400' as const, - includeFontPadding: false, - letterSpacing: 0, -}; -const descriptionColorStyle = { color: VIP_GOLD_TEXT_MUTED }; -const referredByColorStyle = { color: VIP_GOLD_TEXT_MUTED }; -const continueButtonBorderStyle = { borderColor: VIP_GOLD_BORDER_DEFAULT }; -const continueButtonBackgroundStyle = { - backgroundColor: VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND, -}; -const continueButtonTextStyle = { color: VIP_GOLD_TEXT_DEFAULT }; -const notNowButtonTextStyle = { color: VIP_GOLD_TEXT_MUTED }; - -const VipRefereeGradientTitle = () => { - const tw = useTailwind(); - const title = strings('rewards.vip.referee_splash_title'); - const titleStyle = tw.style( - 'text-center text-[42px] leading-[42px] pt-[6px]', - titleFontStyle, - titleColorStyle, - ); - - return ( - - {title} - - } - style={tw.style('self-stretch')} - > - - - {title} - - - - ); -}; - -const VipRefereeSplashScreen: React.FC = ({ - onContinue, - onNotNow, - referredByCode, -}) => { - const tw = useTailwind(); - - return ( - - - - - - {strings('rewards.vip.referee_splash_description')} - - - - - - {referredByCode ? ( - - {strings('rewards.vip.referee_referred_by', { - code: referredByCode, - })} - - ) : null} - - - {strings('rewards.vip.referee_splash_continue')} - - - - - {strings('rewards.vip.referee_splash_not_now')} - - - - - - ); -}; - -export default VipRefereeSplashScreen; diff --git a/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.test.tsx b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.test.tsx new file mode 100644 index 00000000000..7959f07831c --- /dev/null +++ b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import VipSplashGradientTitle from './VipSplashGradientTitle'; + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + + return { + Text: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(Text, { testID }, children), + TextVariant: { DisplayMd: 'displayMd' }, + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('react-native-linear-gradient', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return function MockLinearGradient({ + children, + }: { + children?: React.ReactNode; + }) { + return ReactActual.createElement(View, null, children); + }; +}); + +jest.mock('@react-native-masked-view/masked-view', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return function MockMaskedView({ + children, + maskElement, + }: { + children?: React.ReactNode; + maskElement?: React.ReactNode; + }) { + return ReactActual.createElement(View, null, maskElement, children); + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + if (key === 'rewards.vip.splash_title') { + return 'WELCOME\nTO GOLD FOX\nCOLLECTIVE'; + } + return key; + }), +})); + +describe('VipSplashGradientTitle', () => { + it('renders the shared splash title with the provided testID', () => { + const { getAllByText, getByTestId } = render( + , + ); + + expect(getByTestId('vip-splash-title')).toBeOnTheScreen(); + expect(getAllByText('WELCOME\nTO GOLD FOX\nCOLLECTIVE')).toHaveLength(2); + }); +}); diff --git a/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.tsx b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.tsx new file mode 100644 index 00000000000..d715110c006 --- /dev/null +++ b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.tsx @@ -0,0 +1,62 @@ +import MaskedView from '@react-native-masked-view/masked-view'; +import React from 'react'; +import LinearGradient from 'react-native-linear-gradient'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../../locales/i18n'; +import { VIP_SPLASH_TITLE_GRADIENT_COLORS } from './Vip.constants'; + +const titleColorStyle = { color: VIP_SPLASH_TITLE_GRADIENT_COLORS[0] }; +const titleFontStyle = { + fontFamily: 'MMPoly-Regular', + fontWeight: '400' as const, + includeFontPadding: false, + letterSpacing: 0, +}; + +interface VipSplashGradientTitleProps { + testID: string; +} + +const VipSplashGradientTitle: React.FC = ({ + testID, +}) => { + const tw = useTailwind(); + const title = strings('rewards.vip.splash_title'); + const titleStyle = tw.style( + 'text-center text-[38px] leading-[38px] pt-[6px]', + titleFontStyle, + titleColorStyle, + ); + + return ( + + {title} + + } + style={tw.style('self-stretch')} + > + + + {title} + + + + ); +}; + +export default VipSplashGradientTitle; diff --git a/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.test.tsx b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.test.tsx new file mode 100644 index 00000000000..bb0d962d7cb --- /dev/null +++ b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import VipSplashScreenLayout, { + VIP_SPLASH_SCREEN_TEST_IDS, +} from './VipSplashScreenLayout'; + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText } = jest.requireActual('react-native'); + + return { + Box: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { testID }, children), + FontWeight: { Bold: 'bold', Medium: 'medium' }, + Text: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(RNText, { testID }, children), + TextVariant: { BodyMd: 'bodyMd' }, + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('react-native-linear-gradient', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return function MockLinearGradient({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) { + return ReactActual.createElement(View, { testID }, children); + }; +}); + +jest.mock('@react-native-masked-view/masked-view', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return function MockMaskedView({ + children, + maskElement, + }: { + children?: React.ReactNode; + maskElement?: React.ReactNode; + }) { + return ReactActual.createElement(View, null, maskElement, children); + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { testID }, children), + }; +}); + +jest.mock('../../../../../images/rewards/vip_splash.png', () => 1); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const translations: Record = { + 'rewards.vip.splash_title': 'WELCOME\nTO GOLD FOX\nCOLLECTIVE', + 'rewards.vip.splash_description': 'Exclusive perks by invitation only.', + 'rewards.vip.splash_not_now': 'Not now', + }; + return translations[key] ?? key; + }), +})); + +const defaultProps = { + testIDs: { + container: VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER, + title: VIP_SPLASH_SCREEN_TEST_IDS.TITLE, + description: VIP_SPLASH_SCREEN_TEST_IDS.DESCRIPTION, + fox: VIP_SPLASH_SCREEN_TEST_IDS.FOX, + primaryButton: VIP_SPLASH_SCREEN_TEST_IDS.ACCEPT_BUTTON, + notNowButton: VIP_SPLASH_SCREEN_TEST_IDS.NOT_NOW_BUTTON, + }, + onPrimaryPress: jest.fn(), + onNotNow: jest.fn(), + primaryButtonLabel: 'Accept invite', +}; + +describe('VipSplashScreenLayout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the shared splash content and primary button label', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER)).toBeOnTheScreen(); + expect(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.TITLE)).toBeOnTheScreen(); + expect( + getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.DESCRIPTION), + ).toHaveTextContent('Exclusive perks by invitation only.'); + expect(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.FOX)).toBeOnTheScreen(); + expect(getByText('Accept invite')).toBeOnTheScreen(); + expect(getByText('Not now')).toBeOnTheScreen(); + }); + + it('invokes the primary and not-now callbacks when their buttons are pressed', () => { + const onPrimaryPress = jest.fn(); + const onNotNow = jest.fn(); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.ACCEPT_BUTTON)); + fireEvent.press(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.NOT_NOW_BUTTON)); + + expect(onPrimaryPress).toHaveBeenCalledTimes(1); + expect(onNotNow).toHaveBeenCalledTimes(1); + }); + + it('renders optional footer content above the primary button', () => { + const { getByTestId, getByText } = render( + Referred by TESTCODE + } + />, + ); + + expect(getByTestId('vip-splash-footer')).toBeOnTheScreen(); + expect(getByText('Referred by TESTCODE')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.tsx similarity index 58% rename from app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx rename to app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.tsx index 0ae150e4df1..21957098a6d 100644 --- a/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx +++ b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.tsx @@ -1,5 +1,4 @@ -import MaskedView from '@react-native-masked-view/masked-view'; -import React from 'react'; +import React, { type ReactNode } from 'react'; import { Image, Pressable } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -18,8 +17,8 @@ import { VIP_GOLD_TEXT_MUTED, VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND, VIP_SPLASH_BACKGROUND_GRADIENT_COLORS, - VIP_SPLASH_TITLE_GRADIENT_COLORS, } from './Vip.constants'; +import VipSplashGradientTitle from './VipSplashGradientTitle'; export const VIP_SPLASH_SCREEN_TEST_IDS = { CONTAINER: 'rewards-vip-splash-screen', @@ -30,68 +29,47 @@ export const VIP_SPLASH_SCREEN_TEST_IDS = { NOT_NOW_BUTTON: 'rewards-vip-splash-not-now-button', } as const; -interface VipSplashScreenProps { - onAcceptInvite: () => void; +export const VIP_REFEREE_SPLASH_SCREEN_TEST_IDS = { + CONTAINER: 'rewards-vip-referee-splash-screen', + TITLE: 'rewards-vip-referee-splash-title', + DESCRIPTION: 'rewards-vip-referee-splash-description', + FOX: 'rewards-vip-referee-splash-fox', + REFERRED_BY: 'rewards-vip-referee-splash-referred-by', + CONTINUE_BUTTON: 'rewards-vip-referee-splash-continue-button', + NOT_NOW_BUTTON: 'rewards-vip-referee-splash-not-now-button', +} as const; + +export interface VipSplashScreenLayoutTestIDs { + container: string; + title: string; + description: string; + fox: string; + primaryButton: string; + notNowButton: string; +} + +interface VipSplashScreenLayoutProps { + testIDs: VipSplashScreenLayoutTestIDs; + onPrimaryPress: () => void; onNotNow: () => void; + primaryButtonLabel: string; + footerContent?: ReactNode; } -const titleColorStyle = { color: VIP_SPLASH_TITLE_GRADIENT_COLORS[0] }; -const titleFontStyle = { - fontFamily: 'MMPoly-Regular', - fontWeight: '400' as const, - includeFontPadding: false, - letterSpacing: 0, -}; const descriptionColorStyle = { color: VIP_GOLD_TEXT_MUTED }; -const acceptButtonBorderStyle = { borderColor: VIP_GOLD_BORDER_DEFAULT }; -const acceptButtonBackgroundStyle = { +const primaryButtonBorderStyle = { borderColor: VIP_GOLD_BORDER_DEFAULT }; +const primaryButtonBackgroundStyle = { backgroundColor: VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND, }; -const acceptButtonTextStyle = { color: VIP_GOLD_TEXT_DEFAULT }; +const primaryButtonTextStyle = { color: VIP_GOLD_TEXT_DEFAULT }; const notNowButtonTextStyle = { color: VIP_GOLD_TEXT_MUTED }; -const VipGradientTitle = () => { - const tw = useTailwind(); - const title = strings('rewards.vip.splash_title'); - const titleStyle = tw.style( - 'text-center text-[42px] leading-[42px] pt-[6px]', - titleFontStyle, - titleColorStyle, - ); - - return ( - - {title} - - } - style={tw.style('self-stretch')} - > - - - {title} - - - - ); -}; - -const VipSplashScreen: React.FC = ({ - onAcceptInvite, +const VipSplashScreenLayout: React.FC = ({ + testIDs, + onPrimaryPress, onNotNow, + primaryButtonLabel, + footerContent, }) => { const tw = useTailwind(); @@ -101,11 +79,11 @@ const VipSplashScreen: React.FC = ({ start={{ x: 0.1, y: 0 }} end={{ x: 0.5, y: 1 }} style={tw.style('flex-1')} - testID={VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER} + testID={testIDs.container} > - + = ({ 'mt-[18px] max-w-[326px] text-center leading-[22px]', descriptionColorStyle, )} - testID={VIP_SPLASH_SCREEN_TEST_IDS.DESCRIPTION} + testID={testIDs.description} > {strings('rewards.vip.splash_description')} + {footerContent} - {strings('rewards.vip.splash_accept_invite')} + {primaryButtonLabel} = ({ ); }; -export default VipSplashScreen; +export default VipSplashScreenLayout; diff --git a/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx b/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx index 7a7aec0d583..41c3715ad5b 100644 --- a/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx +++ b/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx @@ -63,7 +63,7 @@ export interface PillScrollListProps { * Multi-row horizontal scroll of pill-shaped items. Used for compact movers sections. * Splits incoming data evenly between rows. */ -const DEFAULT_WRAPPER_TW = '-mx-4 bg-transparent mt-3 mb-9' as const; +const DEFAULT_WRAPPER_TW = '-mx-4 bg-transparent' as const; function PillScrollList({ data, diff --git a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx index 774b93d9628..7cc13a1d737 100644 --- a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx +++ b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx @@ -33,6 +33,7 @@ jest.mock('../../../selectors/featureFlagController/whatsHappening', () => ({ })); jest.mock('./hooks', () => ({ + ...jest.requireActual('./hooks'), useWhatsHappening: jest.fn(() => ({ items: [], isLoading: false, diff --git a/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx b/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx index a2dfce3c5ab..5a0801b4a7b 100644 --- a/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx +++ b/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx @@ -8,17 +8,15 @@ import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, + StyleSheet, View, } from 'react-native'; import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - SectionDivider, - SectionHeader, - TextVariant, -} from '@metamask/design-system-react-native'; +import { Box, TextVariant } from '@metamask/design-system-react-native'; +import SectionHeader from '../../Views/TrendingView/components/SectionHeader'; +import TempSectionHeader from '../../../component-library/components-temp/SectionHeader'; import ErrorState from '../../Views/Homepage/components/ErrorState'; import ViewMoreCard from '../../Views/Homepage/components/ViewMoreCard'; import { SectionRefreshHandle } from '../../Views/Homepage/types'; @@ -30,9 +28,14 @@ import { MAX_ITEMS_DISPLAYED, WhatsHappeningInteractionType, WhatsHappeningView, + WhatsHappeningSource, type WhatsHappeningSourceValue, } from './constants'; -import { useWhatsHappening } from './hooks'; +import { + useWhatsHappening, + isWhatsHappeningSectionVisible, + type UseWhatsHappeningResult, +} from './hooks'; import type { WhatsHappeningItem } from './types'; import { WhatsHappeningCard, WhatsHappeningCardSkeleton } from './components'; import { WhatsHappeningSelectorsIDs } from './WhatsHappening.testIds'; @@ -54,14 +57,22 @@ const SKELETON_KEYS = Array.from( (__, i) => `skeleton-${i}`, ); +const styles = StyleSheet.create({ + sectionGap: { gap: 12 }, +}); + interface WhatsHappeningSectionProps { source: WhatsHappeningSourceValue; + /** When true, the parent Explore feed supplies the section header. */ + hideHeader?: boolean; + /** Optional pre-fetched feed state (avoids duplicate requests in Explore). */ + feed?: UseWhatsHappeningResult; } const WhatsHappeningSection = forwardRef< SectionRefreshHandle, WhatsHappeningSectionProps ->(({ source }, ref) => { +>(({ source, hideHeader = false, feed }, ref) => { const currentIndexRef = useRef(0); const tw = useTailwind(); const navigation = useNavigation(); @@ -69,8 +80,10 @@ const WhatsHappeningSection = forwardRef< const isEnabled = useSelector(selectWhatsHappeningEnabled); const title = strings('whats_happening.title'); - const { items, isLoading, error, refresh } = - useWhatsHappening(MAX_ITEMS_DISPLAYED); + const internalFeed = useWhatsHappening(MAX_ITEMS_DISPLAYED, { + enabled: feed === undefined, + }); + const { items, isLoading, error, refresh } = feed ?? internalFeed; useImperativeHandle(ref, () => ({ refresh }), [refresh]); @@ -124,6 +137,17 @@ const WhatsHappeningSection = forwardRef< return null; } + const useExploreLayout = + hideHeader && source === WhatsHappeningSource.Explore; + + const header = hideHeader ? null : ( + + ); + const carouselContent = hasError ? ( ); - if (hasError) { + if (!isWhatsHappeningSectionVisible({ isLoading, items, error })) { + return null; + } + + if (useExploreLayout) { return ( - - + - {carouselContent} + {carouselContent} ); } - if (!isLoading && items.length === 0) { - return null; - } - return ( - - - + + {header} {carouselContent} - + ); }); diff --git a/app/components/UI/WhatsHappening/hooks/index.ts b/app/components/UI/WhatsHappening/hooks/index.ts index 47bd130d8df..5f23138f658 100644 --- a/app/components/UI/WhatsHappening/hooks/index.ts +++ b/app/components/UI/WhatsHappening/hooks/index.ts @@ -1,2 +1,8 @@ -export { useWhatsHappening } from './useWhatsHappening'; -export type { UseWhatsHappeningResult } from './useWhatsHappening'; +export { + useWhatsHappening, + isWhatsHappeningSectionVisible, +} from './useWhatsHappening'; +export type { + UseWhatsHappeningResult, + UseWhatsHappeningOptions, +} from './useWhatsHappening'; diff --git a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts index 9373d059a9b..b29f73394d5 100644 --- a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts +++ b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts @@ -1,6 +1,9 @@ import { renderHook, waitFor } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; -import { useWhatsHappening } from './useWhatsHappening'; +import { + isWhatsHappeningSectionVisible, + useWhatsHappening, +} from './useWhatsHappening'; const mockFetchMarketOverview = jest.fn(); @@ -199,4 +202,58 @@ describe('useWhatsHappening', () => { expect(result.current.items).toHaveLength(0); expect(result.current.error).toBeNull(); }); + + it('does not fetch when enabled option is false', async () => { + const { result } = renderHook(() => + useWhatsHappening(5, { enabled: false }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockFetchMarketOverview).not.toHaveBeenCalled(); + expect(result.current.items).toHaveLength(0); + expect(result.current.error).toBeNull(); + }); +}); + +describe('isWhatsHappeningSectionVisible', () => { + it('returns true while loading', () => { + expect( + isWhatsHappeningSectionVisible({ + isLoading: true, + items: [], + error: null, + }), + ).toBe(true); + }); + + it('returns true when items are available', () => { + expect( + isWhatsHappeningSectionVisible({ + isLoading: false, + items: [{ id: 'trend-0' } as never], + error: null, + }), + ).toBe(true); + }); + + it('returns true when an error is present', () => { + expect( + isWhatsHappeningSectionVisible({ + isLoading: false, + items: [], + error: 'Network error', + }), + ).toBe(true); + }); + + it('returns false for an empty loaded feed without error', () => { + expect( + isWhatsHappeningSectionVisible({ + isLoading: false, + items: [], + error: null, + }), + ).toBe(false); + }); }); diff --git a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts index a29c743e98c..bdf0978ad54 100644 --- a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts +++ b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts @@ -15,6 +15,18 @@ export interface UseWhatsHappeningResult { refresh: () => Promise; } +export interface UseWhatsHappeningOptions { + /** When false, skips fetching (for parents that supply their own feed). */ + enabled?: boolean; +} + +export const isWhatsHappeningSectionVisible = ({ + isLoading, + items, + error, +}: Pick): boolean => + isLoading || items.length > 0 || Boolean(error); + const mapTrendsToItems = ( overview: MarketOverview, limit: number, @@ -40,15 +52,20 @@ const mapTrendsToItems = ( * @param limit - Maximum number of items to return (default: 5) * @returns Object with items, isLoading, error, refresh */ -export const useWhatsHappening = (limit = 5): UseWhatsHappeningResult => { - const isEnabled = useSelector(selectWhatsHappeningEnabled); +export const useWhatsHappening = ( + limit = 5, + options?: UseWhatsHappeningOptions, +): UseWhatsHappeningResult => { + const isFeatureEnabled = useSelector(selectWhatsHappeningEnabled); + const isHookEnabled = options?.enabled ?? true; + const isActive = isFeatureEnabled && isHookEnabled; const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(!!isEnabled); + const [isLoading, setIsLoading] = useState(isActive); const [error, setError] = useState(null); const fetchItems = useCallback(async () => { - if (!isEnabled) { + if (!isActive) { setItems([]); setIsLoading(false); setError(null); @@ -75,7 +92,7 @@ export const useWhatsHappening = (limit = 5): UseWhatsHappeningResult => { } finally { setIsLoading(false); } - }, [isEnabled, limit]); + }, [isActive, limit]); const refresh = useCallback(async () => { await fetchItems(); diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx index 402702a81f1..47babfaf6c7 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx @@ -25,13 +25,12 @@ describe('TopTraderCard', () => { jest.clearAllMocks(); }); - it('renders username and 7D PnL', () => { + it('renders username and PnL', () => { renderWithProvider( , ); expect(screen.getByText('alpha.eth')).toBeOnTheScreen(); expect(screen.getByText('+$963.1K')).toBeOnTheScreen(); - expect(screen.getByText(/7D/)).toBeOnTheScreen(); }); it('does not display ROI percentage on the card', () => { diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx index 49832504d52..516f3d7a18c 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx @@ -1,6 +1,7 @@ import { Box, BoxAlignItems, + BoxFlexDirection, Button, ButtonSize, ButtonVariant, @@ -34,15 +35,15 @@ export interface TopTraderCardProps { testID?: string; } -// Fixed dimensions for every tile in the homepage Top Traders carousel -const AVATAR_SIZE = 60; -export const TOP_TRADER_CARD_WIDTH = 200; +// Fixed dimensions for every tile in the homepage Top Traders carousel. +const AVATAR_SIZE = 40; +export const TOP_TRADER_CARD_WIDTH = 155; /** * TopTraderCard -- compact card for the homepage horizontal scroll. * - * Displays a trader's avatar (top-left), username, 7D PnL stats, and a - * Follow/Following toggle pinned to the bottom. + * Lays out the trader's avatar with the username + 7D PnL stacked to its + * right, and a small Follow / Following toggle pinned below. */ const TopTraderCard: React.FC = ({ trader, @@ -55,7 +56,7 @@ const TopTraderCard: React.FC = ({ return ( = ({ disabled={!onTraderPress} testID={`top-trader-card-pressable-${trader.id}`} > - + = ({ testID={`top-trader-avatar-${trader.id}`} /> - + {trader.username} - - - {pnlText} - - - {' 7D'} - + {pnlText} @@ -119,8 +110,7 @@ const TopTraderCard: React.FC = ({ variant={ trader.isFollowing ? ButtonVariant.Secondary : ButtonVariant.Primary } - size={ButtonSize.Md} - twClassName="mt-2" + size={ButtonSize.Sm} isFullWidth onPress={() => onFollowPress(trader.id)} > diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx index 49129af2672..ec404fce9ab 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx @@ -18,22 +18,22 @@ const TopTraderCardSkeleton: React.FC = () => { return ( - - - - - - + + + + + + - + diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx index c69fd826b5d..5329698b331 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx @@ -27,14 +27,38 @@ describe('TraderRow', () => { jest.clearAllMocks(); }); - it('renders rank, username, ROI, and PnL', () => { + it('renders username and the full (non-abbreviated) PnL', () => { renderWithProvider( , ); - expect(screen.getByText('1')).toBeOnTheScreen(); expect(screen.getByText('alpha.eth')).toBeOnTheScreen(); - expect(screen.getByText('+43.0%')).toBeOnTheScreen(); - expect(screen.getByText('+$963.1K')).toBeOnTheScreen(); + expect(screen.getByText('+$963,146.80')).toBeOnTheScreen(); + }); + + it('does not render the abbreviated PnL, ROI %, or timeframe suffix', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('+$963.1K')).toBeNull(); + expect(screen.queryByText('+43.0%')).toBeNull(); + expect(screen.queryByText(/30D/)).toBeNull(); + expect(screen.queryByText(/7D/)).toBeNull(); + }); + + it('renders a podium medal for ranks 1-3', () => { + renderWithProvider( + , + ); + expect(screen.getByTestId('rank-medal-1')).toBeOnTheScreen(); + }); + + it('does not render a podium medal for ranks outside 1-3', () => { + const offPodium = { ...baseTrader, rank: 4 }; + renderWithProvider( + , + ); + expect(screen.queryByTestId('rank-medal-4')).toBeNull(); + expect(screen.queryByTestId('rank-medal-1')).toBeNull(); }); it('renders avatar image when avatarUri is present', () => { @@ -114,7 +138,7 @@ describe('TraderRow', () => { expect(mockOnTraderPress).not.toHaveBeenCalled(); }); - it('displays negative ROI and PnL values', () => { + it('displays negative PnL values with a leading minus', () => { const negativeTrader: TopTrader = { ...baseTrader, percentageChange: -15.3, @@ -123,7 +147,6 @@ describe('TraderRow', () => { renderWithProvider( , ); - expect(screen.getByText('-15.3%')).toBeOnTheScreen(); expect(screen.getByText('-$500.00')).toBeOnTheScreen(); }); @@ -168,16 +191,15 @@ describe('TraderRow', () => { it('renders the stats line with numberOfLines=1 so it does not wrap when the button grows', () => { const trader: TopTrader = { ...baseTrader, - percentageChange: 28233, pnlValue: 407000, }; renderWithProvider( , ); - const roiSegment = screen.getByText('+28233.0%'); + const pnlSegment = screen.getByText('+$407,000.00'); const statsText = findAncestor( - roiSegment, + pnlSegment, (node) => node.props?.numberOfLines === 1, ); @@ -199,24 +221,6 @@ describe('TraderRow', () => { expect(resolveMinWidth(buttonWithMinWidth as ReactTestInstance)).toBe(96); }); - it('renders the rank on a single line so the trailing dot does not wrap for double-digit ranks', () => { - const doubleDigitTrader: TopTrader = { - ...baseTrader, - rank: 20, - overallRank: 20, - }; - renderWithProvider( - , - ); - - const rankText = screen.getByText('20'); - - expect(rankText.props.numberOfLines).toBe(1); - }); - it('vertically centers the Follow button so it sits in the middle of the row (overrides ButtonBase self-start default)', () => { renderWithProvider( , diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx index 9ed45045aa0..f019e79e5d1 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx @@ -13,12 +13,12 @@ import { } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import React from 'react'; -import { TouchableOpacity } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import { strings } from '../../../../../../../locales/i18n'; -import { TopRankAvatar, TopRankIndicator } from '../topRank'; +import { RankMedal, isTopRank } from '../topRank'; import type { TopTrader } from '../types'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog -import { formatSignedAbbreviatedUsd } from '../../../../SocialLeaderboard/utils/formatters'; +import { formatSignedUsd } from '../../../../SocialLeaderboard/utils/formatters'; import TraderAvatar from './TraderAvatar'; const AVATAR_SIZE = 40; @@ -41,8 +41,8 @@ export interface TraderRowProps { /** * TraderRow -- a single row in the Top Traders leaderboard. * - * Displays the trader's rank, avatar, username, performance stats, - * and a Follow / Following toggle button. + * Displays the trader's avatar (with a podium medal badge for ranks 1–3), + * username, 30D PnL, and a Follow / Following toggle button. */ const TraderRow: React.FC = ({ trader, @@ -52,11 +52,9 @@ const TraderRow: React.FC = ({ }) => { const tw = useTailwind(); - const roiSign = trader.percentageChange >= 0 ? '+' : ''; - const roiText = `${roiSign}${trader.percentageChange.toFixed(1)}%`; - const pnlText = formatSignedAbbreviatedUsd(trader.pnlValue); + const pnlText = formatSignedUsd(trader.pnlValue); const isPnlPositive = trader.pnlValue >= 0; - const isRoiPositive = trader.percentageChange >= 0; + const showMedal = isTopRank(trader.rank); return ( = ({ - - - + - + {showMedal ? ( + // Offset so the medal bottom (incl. its 2px border) sits ~10px + // below the avatar's bottom edge. + + + + ) : null} + = ({ variant={TextVariant.BodySm} fontWeight={FontWeight.Medium} numberOfLines={1} + twClassName={ + isPnlPositive ? 'text-success-default' : 'text-error-default' + } > - - {roiText} - - - {' \u00B7 '} - - - {pnlText} - - - {' 7D'} - + {pnlText} diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx index dab0631b403..f5d29ee1c15 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx @@ -27,15 +27,6 @@ const TraderRowSkeleton: React.FC = () => { highlightColor={colors.background.subsection} > - {/* Rank placeholder — outer cell mirrors the rendered rank's - `w-8 text-right` (32px container, content right-aligned) so the - bar's visible right edge lines up with where the loaded digit - sits. The inner bar's width (`w-4`) approximates a 1–2 digit - rank glyph. */} - - - - {/* Avatar placeholder */} diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/CrownEmoji.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/CrownEmoji.tsx deleted file mode 100644 index feeb81040c5..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/CrownEmoji.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React from 'react'; -import { Text, View } from 'react-native'; -import { isTopRank, PODIUM_EMOJI } from './topRank.colors'; - -interface CrownEmojiProps { - rank: number; - children: React.ReactNode; -} - -/** - * Floating medal emoji anchored above the top-right corner of the avatar - * (rank 1 = 🥇, rank 2 = 🥈, rank 3 = 🥉). - * - * Slight rotation makes the emoji look like it is sitting on the avatar's - * shoulder rather than perfectly aligned, which reads better at this size. - */ -const CrownEmoji: React.FC = ({ rank, children }) => { - const tw = useTailwind(); - - if (!isTopRank(rank)) { - return <>{children}; - } - - const emoji = PODIUM_EMOJI[rank as 1 | 2 | 3]; - - return ( - - {children} - - {emoji} - - - ); -}; - -export default CrownEmoji; diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/GradientRing.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/GradientRing.tsx deleted file mode 100644 index ac14f3a3587..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/GradientRing.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import { View } from 'react-native'; -import LinearGradient from 'react-native-linear-gradient'; -import { getMedalColors } from './topRank.colors'; - -interface GradientRingProps { - rank: number; - children: React.ReactNode; -} - -const RING_WIDTH = 4; -const AVATAR_SIZE = 40; -const OUTER_SIZE = AVATAR_SIZE + RING_WIDTH * 2; -const INSET = -RING_WIDTH; -const OUTER_RADIUS = OUTER_SIZE / 2; - -// Narrow diagonal sheen band rendered on top of the base gradient. The -// transparent → bright-white → transparent ramp simulates a polished-metal -// specular highlight; running it perpendicular to the base gradient maximises -// the cross-pattern that reads as "shiny chrome". -const SHEEN_COLORS = [ - 'rgba(255, 255, 255, 0)', - 'rgba(255, 255, 255, 0.65)', - 'rgba(255, 255, 255, 0)', -] as const; -const SHEEN_LOCATIONS = [0.38, 0.5, 0.62] as const; - -/** - * Chrome-like multi-stop gradient ring with a perpendicular specular sheen - * overlay for a polished-metal look around an avatar. - * - * The wrapper keeps the avatar's exact 40x40 footprint so the row's flex - * layout is identical to a non-podium row (no horizontal shift). The - * gradient (and its sheen overlay) bleeds 4px outside the footprint on every - * side, with the avatar (an opaque round image) sitting on top to mask the - * center, leaving only the visible ring. - */ -const GradientRing: React.FC = ({ rank, children }) => { - const colors = getMedalColors(rank); - - if (!colors) { - return <>{children}; - } - - const ringStyle = { - position: 'absolute' as const, - top: INSET, - left: INSET, - right: INSET, - bottom: INSET, - borderRadius: OUTER_RADIUS, - }; - - return ( - - - - {children} - - ); -}; - -export default GradientRing; diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.test.tsx new file mode 100644 index 00000000000..e8f6a5a3c6c --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import RankMedal, { isTopRank } from './RankMedal'; + +describe('isTopRank', () => { + it.each([1, 2, 3])('returns true for podium rank %s', (rank) => { + expect(isTopRank(rank)).toBe(true); + }); + + it.each([0, 4, 5, 100, -1])('returns false for rank %s', (rank) => { + expect(isTopRank(rank)).toBe(false); + }); +}); + +describe('RankMedal', () => { + it.each([1, 2, 3])('renders a medal for podium rank %s', (rank) => { + render(); + + expect(screen.getByTestId(`rank-medal-${rank}`)).toBeOnTheScreen(); + }); + + it('sizes the medal from the size prop', () => { + render(); + + expect(screen.getByTestId('rank-medal-2').props.height).toBe(30); + }); + + it.each([0, 4, 5, 100, -1])( + 'renders nothing for non-podium rank %s', + (rank) => { + render(); + + expect(screen.queryByTestId('rank-medal-test')).toBeNull(); + }, + ); + + it('honors a custom testID', () => { + render(); + + expect(screen.getByTestId('custom-medal')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.tsx new file mode 100644 index 00000000000..333cdcb79e0 --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import type { SvgProps } from 'react-native-svg'; +import RankBadge1 from './assets/rank-badge-1.svg'; +import RankBadge2 from './assets/rank-badge-2.svg'; +import RankBadge3 from './assets/rank-badge-3.svg'; + +type SvgComponent = React.FC; + +/** Whether `rank` is a podium position (1, 2 or 3). */ +export const isTopRank = (rank: number): boolean => rank >= 1 && rank <= 3; + +/** + * RankMedal — the leaderboard podium badge (medallion + ribbon + rank numeral) + * for ranks 1–3. + * + * Renders the exact Figma exports (nodes `2521:11941/11960/11978`): the medal + * shape, the baked-in rank number, and the 2px backing border are all part of + * the vector. Ranks outside 1–3 render nothing. + */ + +// Source artboard for a single medal export. It is 24x30 because the 2px +// border wraps the 20x26 medallion on every side, so rendering at the natural +// artboard size displays the medallion at its intended 20x26. +const VIEW_BOX_WIDTH = 24; +const VIEW_BOX_HEIGHT = 30; +const ASPECT_RATIO = VIEW_BOX_WIDTH / VIEW_BOX_HEIGHT; + +const BADGE_BY_RANK: Record<1 | 2 | 3, SvgComponent> = { + 1: RankBadge1, + 2: RankBadge2, + 3: RankBadge3, +}; + +export interface RankMedalProps { + /** Podium rank; only 1, 2 and 3 render a medal. */ + rank: number; + /** + * Rendered height in px; width is derived from the medal aspect ratio. + * Defaults to the export artboard height so the medallion renders at 20x26. + */ + size?: number; + testID?: string; +} + +const RankMedal: React.FC = ({ + rank, + size = VIEW_BOX_HEIGHT, + testID, +}) => { + if (!isTopRank(rank)) { + return null; + } + + const Badge = BADGE_BY_RANK[rank as 1 | 2 | 3]; + const width = size * ASPECT_RATIO; + + return ( + + ); +}; + +export default RankMedal; diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.test.tsx deleted file mode 100644 index cf1f7ab8445..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { screen } from '@testing-library/react-native'; -import React from 'react'; -import { Text, View } from 'react-native'; -import renderWithProvider from '../../../../../../util/test/renderWithProvider'; -import TopRankAvatar from './TopRankAvatar'; - -const renderWithRank = (rank: number) => - renderWithProvider( - - - A - - , - ); - -describe('TopRankAvatar', () => { - it.each([1, 2, 3])( - 'wraps the avatar with a gradient ring and floating crown for rank %s', - (rank) => { - renderWithRank(rank); - - expect( - screen.getByTestId(`top-rank-gradient-ring-${rank}`), - ).toBeOnTheScreen(); - expect(screen.getByTestId(`top-rank-crown-${rank}`)).toBeOnTheScreen(); - expect(screen.getByTestId('avatar-child')).toBeOnTheScreen(); - }, - ); - - it.each([0, 4, 5, 19, 100])( - 'passes children through unchanged for non-podium rank %s', - (rank) => { - renderWithRank(rank); - - expect(screen.getByTestId('avatar-child')).toBeOnTheScreen(); - expect(screen.queryByTestId(/top-rank-/)).not.toBeOnTheScreen(); - }, - ); -}); diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.tsx deleted file mode 100644 index 0c4699847e9..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import GradientRing from './GradientRing'; -import CrownEmoji from './CrownEmoji'; -import { isTopRank } from './topRank.colors'; - -interface TopRankAvatarProps { - /** - * Rank used to decide whether to decorate the avatar. Pass the trader's - * overall (unfiltered) rank so the gold/silver/bronze treatment only fires - * for true top-3 traders. - */ - rank: number; - children: React.ReactNode; -} - -/** - * Wraps an avatar with the top-rank decoration for podium ranks (1–3): a - * chrome gradient ring around the avatar plus a floating crown / medal emoji - * above its top-right corner. - * - * For ranks > 3, `children` is rendered unchanged. - */ -const TopRankAvatar: React.FC = ({ rank, children }) => { - if (!isTopRank(rank)) { - return <>{children}; - } - - return ( - - {children} - - ); -}; - -export default TopRankAvatar; diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.test.tsx deleted file mode 100644 index c03837e554a..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import renderWithProvider from '../../../../../../util/test/renderWithProvider'; -import TopRankIndicator from './TopRankIndicator'; - -describe('TopRankIndicator', () => { - it.each([1, 2, 3])( - 'colors the rank digit with the medal color for podium rank %s', - (rank) => { - renderWithProvider(); - - expect( - screen.getByTestId(`top-rank-medal-color-${rank}`), - ).toBeOnTheScreen(); - expect(screen.getByText(String(rank))).toBeOnTheScreen(); - }, - ); - - it.each([0, 4, 19, 100])( - 'renders the plain rank digit for non-podium rank %s', - (rank) => { - renderWithProvider(); - - expect(screen.queryByTestId(/top-rank-medal-color-/)).toBeNull(); - expect(screen.getByText(String(rank))).toBeOnTheScreen(); - }, - ); - - describe('podiumRank gating', () => { - it('does not apply the podium color when the overall rank is outside the top 3 even if the displayed rank is 1', () => { - renderWithProvider(); - - expect(screen.queryByTestId('top-rank-medal-color-1')).toBeNull(); - expect(screen.queryByTestId('top-rank-medal-color-19')).toBeNull(); - expect(screen.getByText('1')).toBeOnTheScreen(); - }); - - it('applies the podium color when the overall rank is in the top 3 even if the displayed rank is larger', () => { - renderWithProvider(); - - expect(screen.getByTestId('top-rank-medal-color-2')).toBeOnTheScreen(); - expect(screen.getByText('5')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.tsx deleted file mode 100644 index ea2d7b7763f..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { - FontWeight, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import React from 'react'; -import { - getMedalColors, - getPodiumRankIndicatorStyle, - isTopRank, -} from './topRank.colors'; - -interface TopRankIndicatorProps { - /** The rank digit to display in the cell (e.g. position within a filter). */ - rank: number; - /** - * Optional gating rank used to decide whether podium styling applies. - * Defaults to `rank`. Pass the trader's overall (unfiltered) rank when the - * displayed rank is a filtered position so the gold/silver/bronze treatment - * only fires for true top-3 traders. - */ - podiumRank?: number; -} - -/** - * Renders the rank cell for a `TraderRow`. For podium ranks (1–3), the digit - * is rendered bold and tinted with the matching medal color so it visually - * echoes the avatar's gradient ring. For other ranks it renders as the - * secondary (muted) body text so podium ranks read as the focal ranks. - */ -const TopRankIndicator: React.FC = ({ - rank, - podiumRank = rank, -}) => { - if (isTopRank(podiumRank)) { - const colors = getMedalColors(podiumRank); - return ( - - {rank} - - ); - } - - return ( - - {rank} - - ); -}; - -export default TopRankIndicator; diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-1.svg b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-1.svg new file mode 100644 index 00000000000..69416d04611 --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-1.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-2.svg b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-2.svg new file mode 100644 index 00000000000..2ba0516cc91 --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-3.svg b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-3.svg new file mode 100644 index 00000000000..bae1e111279 --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-3.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts b/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts index 30d68639a29..884b79fb13a 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts +++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts @@ -1,2 +1 @@ -export { default as TopRankAvatar } from './TopRankAvatar'; -export { default as TopRankIndicator } from './TopRankIndicator'; +export { default as RankMedal, isTopRank } from './RankMedal'; diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.test.ts b/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.test.ts deleted file mode 100644 index cf8078da692..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable @metamask/design-tokens/color-no-hex */ -import { getMedalColors, isTopRank, PODIUM_EMOJI } from './topRank.colors'; - -describe('isTopRank', () => { - it.each([1, 2, 3])('returns true for podium rank %s', (rank) => { - expect(isTopRank(rank)).toBe(true); - }); - - it.each([0, 4, 5, 100, -1])('returns false for rank %s', (rank) => { - expect(isTopRank(rank)).toBe(false); - }); -}); - -describe('getMedalColors', () => { - it('returns the gold palette for rank 1', () => { - const colors = getMedalColors(1); - - expect(colors).not.toBeNull(); - expect(colors?.rankDigit).toBe('#FFEE58'); - expect(colors?.gradient.length).toBeGreaterThanOrEqual(3); - expect(colors?.gradientLocations.length).toBe(colors?.gradient.length); - }); - - it('returns the silver palette for rank 2', () => { - expect(getMedalColors(2)?.rankDigit).toBe('#ECEFF1'); - }); - - it('returns the bronze palette for rank 3', () => { - expect(getMedalColors(3)?.rankDigit).toBe('#FFAB40'); - }); - - it.each([0, 4, 5, 100, -1])('returns null for rank %s', (rank) => { - expect(getMedalColors(rank)).toBeNull(); - }); -}); - -describe('PODIUM_EMOJI', () => { - it('uses gold/silver/bronze medal emojis for ranks 1, 2 and 3', () => { - expect(PODIUM_EMOJI[1]).toBe('🥇'); - expect(PODIUM_EMOJI[2]).toBe('🥈'); - expect(PODIUM_EMOJI[3]).toBe('🥉'); - }); -}); diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.ts b/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.ts deleted file mode 100644 index 185ba98fffb..00000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint-disable @metamask/design-tokens/color-no-hex -- Decorative podium medal chrome; no DS tokens for multi-stop metallic gradients. */ -/** - * Metallic palette for the top-rank decorations. - * - * Each medal exposes a bright `rankDigit` color for legible rank numerals on - * dark backgrounds and a multi-stop chrome gradient used by the avatar ring. - */ - -export interface MedalColors { - /** - * High-luminance tint for the rank numeral on dark backgrounds. Kept separate - * from the chrome `gradient` palette so the digit stays as legible and vivid - * as default body text (ranks 4+), which muted metallics alone do not achieve. - */ - rankDigit: string; - /** - * Multi-stop chrome gradient (dark rim → bright peak → mid → deep → bright - * peak → mid → dark rim). The alternating peaks fake the polished-metal - * look of a real coin/medal rim. Must contain at least 3 stops. - */ - gradient: readonly string[]; - /** - * Stop positions (0–1) for `gradient`. Length must equal `gradient.length`. - */ - gradientLocations: readonly number[]; -} - -// Stop positions shared by all chrome palettes. Two narrow bright peaks at -// ~15% and ~65% create the alternating highlight/shadow bands that give the -// ring its specular "shiny" appearance. -const CHROME_LOCATIONS: readonly number[] = [0, 0.15, 0.3, 0.5, 0.65, 0.85, 1]; - -const GOLD: MedalColors = { - rankDigit: '#FFEE58', - gradient: [ - '#5C4400', - '#FFF8DC', - '#FFC400', - '#8B6508', - '#FFE066', - '#D4AF37', - '#3D2C00', - ], - gradientLocations: CHROME_LOCATIONS, -}; - -const SILVER: MedalColors = { - rankDigit: '#ECEFF1', - gradient: [ - '#2A2A2A', - '#FFFFFF', - '#D8D8D8', - '#5A5A5A', - '#F0F0F0', - '#A0A0A0', - '#1F1F1F', - ], - gradientLocations: CHROME_LOCATIONS, -}; - -const BRONZE: MedalColors = { - rankDigit: '#FFAB40', - gradient: [ - '#3D2410', - '#FFD7A8', - '#CD7F32', - '#5C3712', - '#F4A460', - '#A0612A', - '#2D1A08', - ], - gradientLocations: CHROME_LOCATIONS, -}; - -/** Whether `rank` is a podium position (1, 2 or 3). */ -export const isTopRank = (rank: number): boolean => rank >= 1 && rank <= 3; - -/** - * Returns the metallic color set for a given rank, or `null` if the rank is - * not a podium position (rank > 3). - */ -export const getMedalColors = (rank: number): MedalColors | null => { - switch (rank) { - case 1: - return GOLD; - case 2: - return SILVER; - case 3: - return BRONZE; - default: - return null; - } -}; - -/** - * Medal emoji that floats above each podium avatar. The same glyph family - * (gold / silver / bronze medal) is used for all three ranks so they share - * a consistent visual footprint. - */ -export const PODIUM_EMOJI: Readonly> = { - 1: '🥇', - 2: '🥈', - 3: '🥉', -}; - -/** - * Subtle shadow under podium rank numerals for contrast on varied backgrounds. - */ -export const PODIUM_RANK_TEXT_SHADOW_COLOR = 'rgba(0, 0, 0, 0.35)'; - -/** Style fragment shared by all podium rank digits (shadow only; color from medal). */ -const PODIUM_RANK_TEXT_SHADOW_STYLE = { - textShadowColor: PODIUM_RANK_TEXT_SHADOW_COLOR, - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, -} as const; - -/** - * Combined podium rank numeral style for a medal `rankDigit` color. - */ -export const getPodiumRankIndicatorStyle = (rankDigit: string) => ({ - color: rankDigit, - ...PODIUM_RANK_TEXT_SHADOW_STYLE, -}); diff --git a/app/components/Views/Homepage/abTestConfig.ts b/app/components/Views/Homepage/abTestConfig.ts index 4e10961dfd1..e278d8ec728 100644 --- a/app/components/Views/Homepage/abTestConfig.ts +++ b/app/components/Views/Homepage/abTestConfig.ts @@ -241,3 +241,75 @@ export const PREDICT_HOMEPAGE_DISCOVERY_AB_KEY = PREDICT_POSITIONS_EMPTY_STATE_AB_KEY; export const PREDICT_HOMEPAGE_DISCOVERY_VARIANTS = PREDICT_POSITIONS_EMPTY_STATE_VARIANTS; + +// ─── Homepage discovery pills (TMCU-926) ───────────────────────────────────── + +/** + * LaunchDarkly / remote flag key. Pattern: `{team}{TICKET}Abtest{Name}` — keep in + * sync with the flag in LD (team `home`, ticket TMCU-926). + */ +export const HOMEPAGE_DISCOVERY_PILLS_AB_KEY = + 'homeTMCU926AbtestDiscoveryPills'; + +export enum HomepageDiscoveryPillsVariant { + Control = 'control', + GrayIcons = 'grayIcons', + ColorIcons = 'colorIcons', +} + +export type HomepageDiscoveryPillIconStyle = 'gray' | 'color'; + +interface HomepageDiscoveryPillsVariantConfig { + showPills: boolean; + iconStyle: HomepageDiscoveryPillIconStyle | null; +} + +export const HOMEPAGE_DISCOVERY_PILLS_VARIANTS: Record< + HomepageDiscoveryPillsVariant, + HomepageDiscoveryPillsVariantConfig +> = { + [HomepageDiscoveryPillsVariant.Control]: { + showPills: false, + iconStyle: null, + }, + [HomepageDiscoveryPillsVariant.GrayIcons]: { + showPills: true, + iconStyle: 'gray', + }, + [HomepageDiscoveryPillsVariant.ColorIcons]: { + showPills: true, + iconStyle: 'color', + }, +}; + +export const HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS = { + experimentName: 'Homepage discovery pills', + variationNames: { + control: 'Current homepage without discovery pills', + grayIcons: 'Discovery pills with gray icons', + colorIcons: 'Discovery pills with color icons', + }, +} as const; + +export const HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMapping = + { + flagKey: HOMEPAGE_DISCOVERY_PILLS_AB_KEY, + validVariants: Object.values(HomepageDiscoveryPillsVariant), + eventNames: [EVENT_NAME.HOME_VIEWED], + }; + +/** + * Builds `active_ab_tests` entries for swap / perps / predict transaction flows + * when the homepage discovery-pills experiment assignment is active. + */ +export function getHomepageDiscoveryPillsTransactionActiveAbTests( + isAssignmentActive: boolean, + variantName: string, +): TransactionActiveAbTestEntry[] | undefined { + if (!isAssignmentActive) { + return undefined; + } + return [ + createActiveABTestAssignment(HOMEPAGE_DISCOVERY_PILLS_AB_KEY, variantName), + ]; +} diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPillIcon.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPillIcon.tsx new file mode 100644 index 00000000000..d3f7d346ae2 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPillIcon.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Image } from 'react-native'; +import { + Icon, + IconColor, + IconSize, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { HomepageDiscoveryPillIconStyle } from '../../abTestConfig'; +import { + HOMEPAGE_DISCOVERY_PILL_COLOR_ICON_SOURCES, + HOMEPAGE_DISCOVERY_PILL_GRAY_ICONS, + type HomepageDiscoveryPillId, +} from './homepageDiscoveryPills.constants'; + +interface HomepageDiscoveryPillIconProps { + pillId: HomepageDiscoveryPillId; + iconStyle: HomepageDiscoveryPillIconStyle; +} + +const HomepageDiscoveryPillIcon: React.FC = ({ + pillId, + iconStyle, +}) => { + const tw = useTailwind(); + if (iconStyle === 'color') { + return ( + + ); + } + + return ( + + ); +}; + +export default HomepageDiscoveryPillIcon; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.test.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.test.tsx new file mode 100644 index 00000000000..93bd93b59b4 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.test.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { selectPerpsEnabledFlag } from '../../../../UI/Perps/selectors/featureFlags'; +import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags'; +import { HomepageDiscoveryPillsTestIds } from './HomepageDiscoveryPills.testIds'; +import { HOMESCREEN_PILL_SOURCE } from './useHomepageDiscoveryPillsNavigation'; + +const mockNavigate = jest.fn(); +const mockTrackPillTapped = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('../../hooks/usePillViewedEvent', () => ({ + __esModule: true, + default: () => ({ trackPillTapped: mockTrackPillTapped }), +})); + +jest.mock('../../../../../constants/navigation/exploreTabIndices', () => ({ + EXPLORE_TAB_INDEX: { + NOW: 0, + MACRO: 1, + RWAS: 2, + CRYPTO: 3, + SPORTS: 4, + SITES: 5, + }, +})); + +const mockUseSelector = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (selector: unknown) => mockUseSelector(selector), +})); + +import HomepageDiscoveryPills from './HomepageDiscoveryPills'; + +const mockSelectorState = ({ + isPerpsEnabled = true, + isPredictEnabled = true, +}: { + isPerpsEnabled?: boolean; + isPredictEnabled?: boolean; +} = {}) => { + mockUseSelector.mockImplementation((selector: unknown) => { + if (selector === selectPerpsEnabledFlag) { + return isPerpsEnabled; + } + if (selector === selectPredictEnabledFlag) { + return isPredictEnabled; + } + return undefined; + }); +}; + +describe('HomepageDiscoveryPills', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectorState(); + }); + + it('renders crypto and stocks when perps and predict are disabled', () => { + mockSelectorState({ isPerpsEnabled: false, isPredictEnabled: false }); + + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')), + ).toBeNull(); + expect( + queryByTestId(HomepageDiscoveryPillsTestIds.pill('predictions')), + ).toBeNull(); + expect( + queryByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')), + ).toBeOnTheScreen(); + expect( + queryByTestId(HomepageDiscoveryPillsTestIds.pill('stocks')), + ).toBeOnTheScreen(); + }); + + it('renders enabled discovery pills', () => { + const { getByTestId } = render(); + + expect( + getByTestId(HomepageDiscoveryPillsTestIds.CONTAINER), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageDiscoveryPillsTestIds.pill('predictions')), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')), + ).toBeOnTheScreen(); + expect( + getByTestId(HomepageDiscoveryPillsTestIds.pill('stocks')), + ).toBeOnTheScreen(); + }); + + it('omits perps pill when perps flag is disabled', () => { + mockSelectorState({ isPerpsEnabled: false }); + + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')), + ).toBeNull(); + expect( + queryByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')), + ).toBeOnTheScreen(); + }); + + it('navigates to perps home when perpetuals pill is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + params: { source: HOMESCREEN_PILL_SOURCE }, + }); + }); + + it('navigates to predict feed when predictions pill is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(HomepageDiscoveryPillsTestIds.pill('predictions')), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { entryPoint: HOMESCREEN_PILL_SOURCE }, + }); + }); + + it('navigates to explore crypto tab when crypto pill is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto'))); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRENDING_VIEW, { + screen: Routes.TRENDING_FEED, + params: { initialTab: 3, source: HOMESCREEN_PILL_SOURCE }, + }); + }); + + it('navigates to explore RWAs tab when stocks pill is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('stocks'))); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRENDING_VIEW, { + screen: Routes.TRENDING_FEED, + params: { initialTab: 2, source: HOMESCREEN_PILL_SOURCE }, + }); + }); + + it('tracks pill_tapped before navigation when a pill is pressed', () => { + const onPillPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto'))); + + expect(mockTrackPillTapped).toHaveBeenCalledWith('crypto', 3); + expect(onPillPress).toHaveBeenCalledWith('crypto', 3); + expect(mockNavigate).toHaveBeenCalled(); + }); + + it('reports fixed pill position when perps and predict pills are hidden', () => { + mockSelectorState({ isPerpsEnabled: false, isPredictEnabled: false }); + const { getByTestId } = render(); + + fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto'))); + + expect(mockTrackPillTapped).toHaveBeenCalledWith('crypto', 3); + }); +}); diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.testIds.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.testIds.ts new file mode 100644 index 00000000000..2704acead42 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.testIds.ts @@ -0,0 +1,7 @@ +import type { HomepageDiscoveryPillId } from './homepageDiscoveryPills.constants'; + +export const HomepageDiscoveryPillsTestIds = { + CONTAINER: 'homepage-discovery-pills', + pill: (pillId: HomepageDiscoveryPillId) => + `homepage-discovery-pill-${pillId}`, +} as const; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.tsx new file mode 100644 index 00000000000..9e412c81ca2 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { strings } from '../../../../../../locales/i18n'; +import ButtonToggle from '../../../../../component-library/components-temp/Buttons/ButtonToggle'; +import { ButtonSize } from '../../../../../component-library/components/Buttons/Button'; +import { selectPerpsEnabledFlag } from '../../../../UI/Perps/selectors/featureFlags'; +import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags'; +import HomepageDiscoveryPillIcon from './HomepageDiscoveryPillIcon'; +import { + HOMEPAGE_DISCOVERY_PILL_IDS, + type HomepageDiscoveryPillId, +} from './homepageDiscoveryPills.constants'; +import { HomepageDiscoveryPillsTestIds } from './HomepageDiscoveryPills.testIds'; +import type { HomepageDiscoveryPillsProps } from './HomepageDiscoveryPills.types'; +import usePillViewedEvent from '../../hooks/usePillViewedEvent'; +import { useHomepageDiscoveryPillsNavigation } from './useHomepageDiscoveryPillsNavigation'; + +const PILL_LABEL_KEYS: Record< + HomepageDiscoveryPillId, + | 'homepage.sections.perpetuals' + | 'homepage.sections.predictions' + | 'trending.stocks' + | 'trending.crypto' +> = { + perpetuals: 'homepage.sections.perpetuals', + predictions: 'homepage.sections.predictions', + stocks: 'trending.stocks', + crypto: 'trending.crypto', +}; + +const HomepageDiscoveryPills: React.FC = ({ + iconStyle, + onPillPress, +}) => { + const tw = useTailwind(); + const { navigateToPill } = useHomepageDiscoveryPillsNavigation(); + const { trackPillTapped } = usePillViewedEvent(); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); + + const visiblePillIds = useMemo( + () => + HOMEPAGE_DISCOVERY_PILL_IDS.filter((pillId) => { + if (pillId === 'perpetuals') { + return isPerpsEnabled; + } + if (pillId === 'predictions') { + return isPredictEnabled; + } + return true; + }), + [isPerpsEnabled, isPredictEnabled], + ); + + const handlePillPress = useCallback( + (pillId: HomepageDiscoveryPillId, position: number) => { + trackPillTapped(pillId, position); + onPillPress?.(pillId, position); + navigateToPill(pillId); + }, + [navigateToPill, onPillPress, trackPillTapped], + ); + + if (visiblePillIds.length === 0) { + return null; + } + + return ( + + {visiblePillIds.map((pillId) => ( + + + + {strings(PILL_LABEL_KEYS[pillId])} + + + } + isActive={false} + onPress={() => + handlePillPress(pillId, HOMEPAGE_DISCOVERY_PILL_IDS.indexOf(pillId)) + } + size={ButtonSize.Md} + style={tw.style('rounded-xl py-2 px-3')} + testID={HomepageDiscoveryPillsTestIds.pill(pillId)} + accessibilityLabel={strings(PILL_LABEL_KEYS[pillId])} + /> + ))} + + ); +}; + +export default HomepageDiscoveryPills; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.types.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.types.ts new file mode 100644 index 00000000000..78e2a8e4c90 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.types.ts @@ -0,0 +1,7 @@ +import type { HomepageDiscoveryPillIconStyle } from '../../abTestConfig'; +import type { HomepageDiscoveryPillId } from './homepageDiscoveryPills.constants'; + +export interface HomepageDiscoveryPillsProps { + iconStyle: HomepageDiscoveryPillIconStyle; + onPillPress?: (pillId: HomepageDiscoveryPillId, position: number) => void; +} diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/homepageDiscoveryPills.constants.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/homepageDiscoveryPills.constants.ts new file mode 100644 index 00000000000..beba18cc730 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/homepageDiscoveryPills.constants.ts @@ -0,0 +1,43 @@ +import { IconName } from '@metamask/design-system-react-native'; +import discoveryPillCrypto from '../../../../../images/discovery_pill_crypto.png'; +import discoveryPillPerps from '../../../../../images/discovery_pill_perps.png'; +import discoveryPillPredictions from '../../../../../images/discovery_pill_predictions.png'; +import discoveryPillStocks from '../../../../../images/discovery_pill_stocks.png'; + +export const HOMEPAGE_DISCOVERY_PILL_IDS = [ + 'perpetuals', + 'predictions', + 'stocks', + 'crypto', +] as const; + +export type HomepageDiscoveryPillId = + (typeof HOMEPAGE_DISCOVERY_PILL_IDS)[number]; + +/** Segment `section_name` values for `Home Viewed` (`interaction_type: pill_tapped`). */ +export const HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES = { + perpetuals: 'perps', + predictions: 'predict', + stocks: 'stocks', + crypto: 'crypto', +} as const satisfies Record; + +export type HomepageDiscoveryPillSectionName = + (typeof HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES)[HomepageDiscoveryPillId]; + +export const HOMEPAGE_DISCOVERY_PILL_GRAY_ICONS: Record< + HomepageDiscoveryPillId, + IconName +> = { + perpetuals: IconName.Candlestick, + predictions: IconName.Predictions, + stocks: IconName.Chart, + crypto: IconName.Coin, +} as const; + +export const HOMEPAGE_DISCOVERY_PILL_COLOR_ICON_SOURCES = { + perpetuals: discoveryPillPerps, + predictions: discoveryPillPredictions, + stocks: discoveryPillStocks, + crypto: discoveryPillCrypto, +} as const; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/index.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/index.ts new file mode 100644 index 00000000000..b3358a513a6 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/index.ts @@ -0,0 +1,11 @@ +export { default as HomepageDiscoveryPills } from './HomepageDiscoveryPills'; +export { HomepageDiscoveryPillsTestIds } from './HomepageDiscoveryPills.testIds'; +export { + HOMEPAGE_DISCOVERY_PILL_IDS, + type HomepageDiscoveryPillId, +} from './homepageDiscoveryPills.constants'; +export { + HOMESCREEN_PILL_SOURCE, + useHomepageDiscoveryPillsNavigation, +} from './useHomepageDiscoveryPillsNavigation'; +export type { HomepageDiscoveryPillsProps } from './HomepageDiscoveryPills.types'; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/useHomepageDiscoveryPillsNavigation.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/useHomepageDiscoveryPillsNavigation.ts new file mode 100644 index 00000000000..77cfa399357 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/useHomepageDiscoveryPillsNavigation.ts @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { EXPLORE_TAB_INDEX } from '../../../../../constants/navigation/exploreTabIndices'; +import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames'; +import type { HomepageDiscoveryPillId } from './homepageDiscoveryPills.constants'; + +export const HOMESCREEN_PILL_SOURCE = + PredictEventValues.ENTRY_POINT.HOMESCREEN_PILL; + +export function useHomepageDiscoveryPillsNavigation() { + const navigation = useNavigation(); + + const navigateToPill = useCallback( + (pillId: HomepageDiscoveryPillId) => { + switch (pillId) { + case 'perpetuals': + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + params: { source: HOMESCREEN_PILL_SOURCE }, + }); + break; + case 'predictions': + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { + entryPoint: HOMESCREEN_PILL_SOURCE, + }, + }); + break; + case 'crypto': + navigation.navigate(Routes.TRENDING_VIEW, { + screen: Routes.TRENDING_FEED, + params: { + initialTab: EXPLORE_TAB_INDEX.CRYPTO, + source: HOMESCREEN_PILL_SOURCE, + }, + }); + break; + case 'stocks': + navigation.navigate(Routes.TRENDING_VIEW, { + screen: Routes.TRENDING_FEED, + params: { + initialTab: EXPLORE_TAB_INDEX.RWAS, + source: HOMESCREEN_PILL_SOURCE, + }, + }); + break; + default: { + const exhaustiveCheck: never = pillId; + return exhaustiveCheck; + } + } + }, + [navigation], + ); + + return { navigateToPill }; +} diff --git a/app/components/Views/Homepage/hooks/usePillViewedEvent.test.ts b/app/components/Views/Homepage/hooks/usePillViewedEvent.test.ts new file mode 100644 index 00000000000..cb30f684e76 --- /dev/null +++ b/app/components/Views/Homepage/hooks/usePillViewedEvent.test.ts @@ -0,0 +1,180 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import usePillViewedEvent from './usePillViewedEvent'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ builtEvent: true })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: mockAddProperties, +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +let mockContextValue = { + subscribeToScroll: jest.fn(() => jest.fn()), + viewportHeight: 800, + containerScreenY: 0, + entryPoint: 'app_opened', + visitId: 1, + notifySectionViewed: jest.fn(), + getViewedSectionCount: jest.fn(() => 0), + getVisitMaxDepth: jest.fn(() => -1), + appSessionId: 'test-session-id', +}; + +jest.mock('../context/HomepageScrollContext', () => ({ + useHomepageScrollContext: () => mockContextValue, +})); + +describe('usePillViewedEvent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockContextValue = { + subscribeToScroll: jest.fn(() => jest.fn()), + viewportHeight: 800, + containerScreenY: 0, + entryPoint: 'app_opened', + visitId: 1, + notifySectionViewed: jest.fn(), + getViewedSectionCount: jest.fn(() => 0), + getVisitMaxDepth: jest.fn(() => -1), + appSessionId: 'test-session-id', + }; + }); + + describe('event shape', () => { + it('uses the HOME_VIEWED MetaMetrics event', () => { + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('crypto', 2); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HOME_VIEWED, + ); + }); + + it('fires with interaction_type: pill_tapped', () => { + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('crypto', 2); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ interaction_type: 'pill_tapped' }), + ); + }); + + it('fires with location: home', () => { + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('crypto', 2); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'home' }), + ); + }); + + it('maps pill id to section_name', () => { + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('perpetuals', 0); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ section_name: 'perps' }), + ); + }); + + it('includes position', () => { + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('stocks', 1); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ position: 1 }), + ); + }); + + it('includes entry_point from context', () => { + mockContextValue = { ...mockContextValue, entryPoint: 'home_tab' }; + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('predictions', 1); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ entry_point: 'home_tab' }), + ); + }); + + it('passes the built event to trackEvent', () => { + const builtEvent = { builtEvent: true }; + mockBuild.mockReturnValue(builtEvent); + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('crypto', 3); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith(builtEvent); + }); + + it('fires with all required properties', () => { + mockContextValue = { + ...mockContextValue, + entryPoint: 'navigated_back', + appSessionId: 'full-props-session', + visitId: 3, + }; + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped('predictions', 1); + }); + + expect(mockAddProperties).toHaveBeenCalledWith({ + interaction_type: 'pill_tapped', + location: 'home', + section_name: 'predict', + position: 1, + entry_point: 'navigated_back', + app_session_id: 'full-props-session', + visit_number: 3, + }); + }); + }); + + describe('section names', () => { + it.each([ + ['perpetuals', 'perps'], + ['predictions', 'predict'], + ['stocks', 'stocks'], + ['crypto', 'crypto'], + ] as const)('maps %s to section_name %s', (pillId, sectionName) => { + const { result } = renderHook(() => usePillViewedEvent()); + + act(() => { + result.current.trackPillTapped(pillId, 0); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ section_name: sectionName }), + ); + }); + }); +}); diff --git a/app/components/Views/Homepage/hooks/usePillViewedEvent.ts b/app/components/Views/Homepage/hooks/usePillViewedEvent.ts new file mode 100644 index 00000000000..57deafd89e2 --- /dev/null +++ b/app/components/Views/Homepage/hooks/usePillViewedEvent.ts @@ -0,0 +1,52 @@ +import { useCallback, useRef } from 'react'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { + HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES, + type HomepageDiscoveryPillId, +} from '../components/HomepageDiscoveryPills/homepageDiscoveryPills.constants'; +import { useHomepageScrollContext } from '../context/HomepageScrollContext'; + +/** + * Fires a `Home Viewed` Segment event with `interaction_type: 'pill_tapped'` + * when a homepage discovery pill is tapped. + * + * - `visit_number` increments with each homepage visit via `visitId` from HomepageScrollContext. + * - `active_ab_tests` is injected automatically by the analytics registry (`HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING` in abTestConfig.ts). + */ +const usePillViewedEvent = () => { + const { entryPoint, visitId, appSessionId } = useHomepageScrollContext(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const entryPointRef = useRef(entryPoint); + entryPointRef.current = entryPoint; + + const appSessionIdRef = useRef(appSessionId); + appSessionIdRef.current = appSessionId; + + const visitIdRef = useRef(visitId); + visitIdRef.current = visitId; + + const trackPillTapped = useCallback( + (pillId: HomepageDiscoveryPillId, position: number) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.HOME_VIEWED) + .addProperties({ + interaction_type: 'pill_tapped', + location: 'home', + section_name: HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES[pillId], + position, + entry_point: entryPointRef.current, + app_session_id: appSessionIdRef.current, + visit_number: visitIdRef.current, + }) + .build(), + ); + }, + [trackEvent, createEventBuilder], + ); + + return { trackPillTapped }; +}; + +export default usePillViewedEvent; diff --git a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx index 7d408f123bd..baa717cf3c4 100644 --- a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx +++ b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx @@ -116,15 +116,6 @@ jest.mock( }), ); -jest.mock('../../../../../core/SnapKeyring/MultichainWalletSnapClient', () => ({ - MultichainWalletSnapFactory: { - createClient: jest.fn(), - }, - WalletClientType: { - Solana: 'solana', - }, -})); - const mockGetInternalAccountsFromWallet = getInternalAccountsFromWallet as jest.Mock; const mockUseWalletBalances = useWalletBalances as jest.Mock; diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx index 7f5b855e618..91572508324 100644 --- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx @@ -227,14 +227,14 @@ describe('TopTradersView', () => { }); }); - it('renders the rank for the top trader', () => { + it('renders a podium medal for the top trader', () => { renderWithProvider(); - expect(screen.getByText('1')).toBeOnTheScreen(); + expect(screen.getByTestId('rank-medal-1')).toBeOnTheScreen(); }); - it('renders the ROI for the first trader', () => { + it('renders the full PnL for the first trader', () => { renderWithProvider(); - expect(screen.getByText('+43.0%')).toBeOnTheScreen(); + expect(screen.getByText('+$963,146.80')).toBeOnTheScreen(); }); it('calls toggleFollow with analytics context when Follow button is pressed', () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index 4e96242dc95..3c885544d6d 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -382,7 +382,6 @@ const TraderPositionView = () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx index c09857509a9..5fc0127abd1 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx @@ -1,116 +1,40 @@ -import React, { createRef } from 'react'; +import React from 'react'; import { render, screen } from '@testing-library/react-native'; -import { TextInput } from 'react-native'; import QuickBuyAmountSection from './QuickBuyAmountSection'; -const defaultProps = { - amountDisplayMode: 'fiat' as const, - fiatAmountLabel: '$0', - destSymbol: 'ETH', - estimatedReceiveAmount: undefined, - isQuoteLoading: false, - hiddenInputRef: createRef(), - onAmountAreaPress: jest.fn(), - onAmountChange: jest.fn(), -}; - describe('QuickBuyAmountSection', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders the fiat amount as primary in fiat mode', () => { - render( - , - ); - expect(screen.getByText('$50')).toBeOnTheScreen(); - }); - - it('renders a non-USD preformatted fiat label as primary in fiat mode', () => { - render( - , - ); - expect(screen.getByText('50 €')).toBeOnTheScreen(); - }); - - it('shows the zero placeholder label when no amount is entered', () => { - render(); - expect(screen.getByText('$0')).toBeOnTheScreen(); - }); - - it('renders the crypto amount as primary in crypto mode', () => { - render( - , - ); - expect(screen.getByText('0.025 ETH')).toBeOnTheScreen(); - }); - - it('shows 0 crypto placeholder when estimatedCryptoAmount is undefined', () => { - render( - , - ); - expect(screen.getByText('0 ETH')).toBeOnTheScreen(); + const baseProps = { + amountDisplayMode: 'fiat' as const, + fiatAmountLabel: '$2.55', + destSymbol: 'GIGA', + estimatedReceiveAmount: '56.52037', + isQuoteLoading: false, + }; + + it('renders the secondary amount label when not loading', () => { + render(); + + expect(screen.getByText('$2.55')).toBeOnTheScreen(); + expect(screen.getByText('56.52037 GIGA')).toBeOnTheScreen(); + expect( + screen.queryByTestId('quick-buy-amount-loading'), + ).not.toBeOnTheScreen(); }); - it('shows the estimated crypto amount with destSymbol as the secondary label', () => { - render( - , - ); - expect(screen.getByText('123.45 ETH')).toBeOnTheScreen(); - }); + it('shows skeleton and token symbol while quote is loading', () => { + render(); - it('replaces the secondary label with a spinner when isQuoteLoading', () => { - render( - , - ); - expect(screen.getByTestId('quick-buy-amount-area')).toBeOnTheScreen(); + expect(screen.getByText('$2.55')).toBeOnTheScreen(); + expect(screen.getByTestId('quick-buy-amount-loading')).toBeOnTheScreen(); expect( - screen.getByTestId('quick-buy-amount-loading-spinner'), + screen.getByTestId('quick-buy-amount-loading-skeleton'), ).toBeOnTheScreen(); - // Secondary label is replaced by spinner — crypto label should NOT be present - expect(screen.queryByText('0 ETH')).not.toBeOnTheScreen(); - }); - - it('shows the secondary label when NOT loading', () => { - render(); - expect(screen.getByText('0 ETH')).toBeOnTheScreen(); - }); - - it('does not render a toggle button', () => { - render(); expect( - screen.queryByTestId('quick-buy-toggle-amount-display'), + screen.getByTestId('quick-buy-amount-loading-symbol'), + ).toHaveTextContent('GIGA'); + expect( + screen.queryByTestId('quick-buy-amount-loading-icon'), ).not.toBeOnTheScreen(); - }); - - it('does not render available balance text', () => { - render(); - expect(screen.queryByText(/available/)).not.toBeOnTheScreen(); + expect(screen.queryByText('56.52037 GIGA')).not.toBeOnTheScreen(); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx index 03e593a0488..f6efc4fd4ee 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { TextInput } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, Text, @@ -7,11 +8,10 @@ import { TextColor, FontWeight, BoxAlignItems, + BoxFlexDirection, BoxJustifyContent, - IconColor, - IconSize, - Spinner, } from '@metamask/design-system-react-native'; +import { Skeleton } from '../../../../../../../component-library/components-temp/Skeleton'; import type { QuickBuyAmountDisplayMode } from '../types'; import { formatTokenAmount } from '../../../../utils/formatters'; @@ -52,6 +52,8 @@ const QuickBuyAmountSection: React.FC = ({ sourceCryptoAmount, sourceSymbol, }) => { + const tw = useTailwind(); + const cryptoAmountLabel = estimatedReceiveAmount ? `${formatTokenAmount(parseFloat(estimatedReceiveAmount))} ${destSymbol}` : `0 ${destSymbol}`; @@ -90,11 +92,26 @@ const QuickBuyAmountSection: React.FC = ({ {isQuoteLoading ? ( - + + + + {destSymbol} + + ) : ( { it('prefers formattedRate (quote-based) over formattedExchangeRate when a quote is available', () => { (useQuickBuyContext as jest.Mock).mockReturnValue({ ...baseContext, - formattedRate: '1 ETH = 4381.23 REPPO', - formattedExchangeRate: '1 ETH = 1000 USDC', + formattedRate: '1 SOL = 25,738.44 GIGA', + formattedExchangeRate: '1 SOL = 23,529 GIGA', setActiveScreen, }); render(); - expect(screen.getByText('1 ETH = 4381.23 REPPO')).toBeOnTheScreen(); - expect(screen.queryByText('1 ETH = 1000 USDC')).not.toBeOnTheScreen(); + expect(screen.getByText('1 SOL = 25,738.44 GIGA')).toBeOnTheScreen(); + expect(screen.queryByText('1 SOL = 23,529 GIGA')).not.toBeOnTheScreen(); }); it('shows formattedRate even when formattedExchangeRate is undefined', () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index e5196eac4c3..ca864f66b64 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -879,7 +879,7 @@ export function useQuickBuyController( () => tradeMode === 'sell' ? formatExchangeRate(sourceToken, destToken) - : formatExchangeRate(destTokenForRate, sourceToken), + : formatExchangeRate(sourceToken, destTokenForRate), [destToken, destTokenForRate, sourceToken, tradeMode], ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index e3a608cb8aa..996d9d15484 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -1471,6 +1471,41 @@ describe('useQuickBuyController', () => { }); }); + describe('formattedExchangeRate', () => { + it('shows pay-with token on the left in buy mode pre-quote', () => { + const solToken = createSourceToken({ + symbol: 'SOL', + currencyExchangeRate: 150, + }); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [solToken], + isLoading: false, + }); + (useQuickBuySetup as jest.Mock).mockReturnValue({ + chainId: '0x1', + destToken: { + address: '0xDEST', + chainId: '0x1', + decimals: 18, + symbol: 'GIGA', + name: 'Gigachad', + }, + isLoading: false, + isUnsupportedChain: false, + }); + (usePositionTokenBalance as jest.Mock).mockReturnValue({ + currencyExchangeRate: 0.006375, + }); + + const { result } = renderHook(() => + useQuickBuyController(createTarget({ tokenSymbol: 'GIGA' }), jest.fn()), + ); + + expect(result.current.formattedExchangeRate).toMatch(/^1 SOL = /); + expect(result.current.formattedExchangeRate).not.toMatch(/^1 GIGA = /); + }); + }); + describe('source token auto-selection', () => { it('auto-selects the first option when options load (legacy — native on dest chain matches priority 1)', () => { const firstToken = createSourceToken({ symbol: 'ETH' }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx index 2d3790e210a..c0ba6e62d27 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx @@ -19,30 +19,24 @@ const REAL_AVATAR_URL = 'https://example.com/avatar.png'; const TRADER_ADDRESS = '0x0000000000000000000000000000000000000001'; describe('TradeRow', () => { - it('renders the bought label for an enter trade', () => { - renderWithProvider( - , - ); + it('renders the "Bought" label for an enter trade (no trader name)', () => { + renderWithProvider(); - expect(screen.getByText('nodestack.eth bought')).toBeOnTheScreen(); + expect(screen.getByText('Bought')).toBeOnTheScreen(); + expect(screen.queryByText(/nodestack\.eth/)).toBeNull(); }); - it('renders the sold label for an exit trade', () => { - renderWithProvider( - , - ); + it('renders the "Sold" label for an exit trade (no trader name)', () => { + renderWithProvider(); - expect(screen.getByText('nodestack.eth sold')).toBeOnTheScreen(); + expect(screen.getByText('Sold')).toBeOnTheScreen(); + expect(screen.queryByText(/nodestack\.eth/)).toBeNull(); }); it('renders the avatar image when a real image url is provided', () => { renderWithProvider( , @@ -54,11 +48,7 @@ describe('TradeRow', () => { it('renders the Maskicon fallback when the image url is absent', () => { renderWithProvider( - , + , ); expect(screen.UNSAFE_queryByType(AvatarAccount)).not.toBeNull(); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx index eccc0346057..3a0a6da30ff 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx @@ -20,14 +20,12 @@ const AVATAR_SIZE = 32; export interface TradeRowProps { trade: Trade; - traderName: string; traderImageUrl?: string; traderAddress?: string; } const TradeRow: React.FC = ({ trade, - traderName, traderImageUrl, traderAddress, }) => { @@ -38,19 +36,11 @@ const TradeRow: React.FC = ({ // Perp fills read as "opened"/"closed" (vs spot "bought"/"sold"). const actionLabel = isPerp ? isEntry - ? strings('social_leaderboard.trader_position.opened', { - name: traderName, - }) - : strings('social_leaderboard.trader_position.closed_action', { - name: traderName, - }) + ? strings('social_leaderboard.trader_position.opened') + : strings('social_leaderboard.trader_position.closed_action') : isEntry - ? strings('social_leaderboard.trader_position.bought', { - name: traderName, - }) - : strings('social_leaderboard.trader_position.sold', { - name: traderName, - }); + ? strings('social_leaderboard.trader_position.bought') + : strings('social_leaderboard.trader_position.sold'); return ( = ({ )} - {/* The absolute amount stays neutral; the percentage carries the - red/green so coloring is consistent with the positions list and - with spot positions. */} + {/* The absolute amount carries the red/green; the percentage below + stays neutral (white). */} - {pnlValue != null ? formatPnl(pnlValue) : '\u2014'} - - + {pnlValue != null ? formatPnl(pnlValue) : '\u2014'} + + {formatPercent(pnlPercent)} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx index 6bc066fd955..5e9ff2f1c6f 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx @@ -40,13 +40,13 @@ interface TooltipProps { /** Smaller than TRADE_MARKER_RADIUS so the end-dot doesn't look like a trade marker. */ const END_DOT_DIAMETER = 5; /** - * Trade marker geometry — the inner colored disk is 16x16 with a 4px ring + * Trade marker geometry — the inner colored disk is 10x10 with a 2px ring * matching the chart background. SVG strokes are centered on the circumference, - * so we set r = innerRadius + strokeWidth/2 (8 + 2 = 10) to make the ring + * so we set r = innerRadius + strokeWidth/2 (5 + 1 = 6) to make the ring * sit fully outside the visible inner disk. */ -const TRADE_MARKER_INNER_RADIUS = 8; -const TRADE_MARKER_BORDER_WIDTH = 4; +const TRADE_MARKER_INNER_RADIUS = 5; +const TRADE_MARKER_BORDER_WIDTH = 2; const TRADE_MARKER_RADIUS = TRADE_MARKER_INNER_RADIUS + TRADE_MARKER_BORDER_WIDTH / 2; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.test.tsx new file mode 100644 index 00000000000..77a9d65eb62 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; +import type { Trade } from '@metamask/social-controllers'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import TraderTradesSection from './TraderTradesSection'; + +const makeTrade = (overrides: Partial = {}): Trade => ({ + intent: 'enter', + direction: 'buy', + tokenAmount: 1, + usdCost: 23155.92, + timestamp: 1_700_000_000_000, + transactionHash: '0xabc', + ...overrides, +}); + +describe('TraderTradesSection', () => { + it('renders the Trades title', () => { + renderWithProvider(); + + expect(screen.getByText('Trades')).toBeOnTheScreen(); + }); + + it('does not render a white underline below the title', () => { + renderWithProvider(); + + let node: ReactTestInstance | null = screen.getByText('Trades'); + while (node) { + const flat = StyleSheet.flatten(node.props.style) as + | { borderBottomWidth?: number } + | undefined; + expect(flat?.borderBottomWidth).toBeFalsy(); + node = node.parent; + } + }); + + it('renders a TradeRow for each trade', () => { + renderWithProvider( + , + ); + + expect(screen.getByText('Bought')).toBeOnTheScreen(); + expect(screen.getByText('Sold')).toBeOnTheScreen(); + }); + + it('renders the empty state when there are no trades', () => { + renderWithProvider(); + + expect(screen.queryByText('Bought')).toBeNull(); + expect(screen.queryByText('Sold')).toBeNull(); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx index 28629cacc49..69c31ef500e 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx @@ -12,20 +12,18 @@ import TradeRow from './TradeRow'; export interface TraderTradesSectionProps { trades: Trade[]; - traderName: string; traderImageUrl?: string; traderAddress?: string; } const TraderTradesSection: React.FC = ({ trades, - traderName, traderImageUrl, traderAddress, }) => ( <> - + = ({ {strings('social_leaderboard.trader_position.trades')} - + {trades.length > 0 ? ( @@ -42,7 +40,6 @@ const TraderTradesSection: React.FC = ({ diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx index 0f817ddf218..4b36a138ea4 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx @@ -824,11 +824,11 @@ describe('TraderProfileView', () => { renderWithProvider(); - // Sum is 1,000,000 — hyperliquid is included; rendered with the shared - // abbreviated formatter (main): 1,000,000 → +$1M - expect(screen.getByText('+$1M')).toBeOnTheScreen(); + // Sum is 1,000,000 — hyperliquid is included; rendered with the full + // no-decimals formatter: 1,000,000 → +$1,000,000 + expect(screen.getByText('+$1,000,000')).toBeOnTheScreen(); // And the trader's global pnl7d (999,999) is NOT what we display - expect(screen.queryByText('+$999,999.00')).not.toBeOnTheScreen(); + expect(screen.queryByText('+$999,999')).not.toBeOnTheScreen(); }); it('shows the Hyperliquid PnL for a perps-only trader (regression: was 0)', () => { @@ -847,9 +847,9 @@ describe('TraderProfileView', () => { renderWithProvider(); - // 1,474,000 rendered with the shared abbreviated formatter (main) → +$1.5M - expect(screen.getByText('+$1.5M')).toBeOnTheScreen(); - expect(screen.queryByText('+$0.00')).not.toBeOnTheScreen(); + // 1,474,000 rendered with the full no-decimals formatter → +$1,474,000 + expect(screen.getByText('+$1,474,000')).toBeOnTheScreen(); + expect(screen.queryByText('$0')).not.toBeOnTheScreen(); }); it('sums negative per-chain values (incl. hyperliquid) into a negative total', () => { @@ -872,8 +872,8 @@ describe('TraderProfileView', () => { renderWithProvider(); // -1000 + -2500 + 500 + -50000 = -53000 (hyperliquid included); - // rendered with the shared abbreviated formatter (main): -53,000 → -$53K - expect(screen.getByText('-$53K')).toBeOnTheScreen(); + // rendered with the full no-decimals formatter: -53,000 → -$53,000 + expect(screen.getByText('-$53,000')).toBeOnTheScreen(); }); it('treats a missing chain entry as 0', () => { @@ -890,7 +890,7 @@ describe('TraderProfileView', () => { renderWithProvider(); - expect(screen.getByText('+$7.5K')).toBeOnTheScreen(); + expect(screen.getByText('+$7,500')).toBeOnTheScreen(); }); it('falls back to the global stats.pnl7d when perChainPnl7d is empty', () => { @@ -907,7 +907,7 @@ describe('TraderProfileView', () => { renderWithProvider(); - expect(screen.getByText('+$20.6K')).toBeOnTheScreen(); + expect(screen.getByText('+$20,610')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx index 31ad3e30e98..5daa7ae7bb5 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx @@ -388,7 +388,6 @@ const TraderProfileView = () => { profile={profile.profile} followerCount={profile.followerCount} twitterHandle={profile.socialHandles?.twitter} - rank={traderRank} /> )} diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx index 5a7bc0d321d..57acd28292b 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx @@ -70,12 +70,14 @@ describe('PositionRow', () => { expect(screen.getByText('$2,259.96')).toBeOnTheScreen(); }); - it('renders unrealized $ PnL alongside the percent on the bottom-right', () => { + it('renders only the bare percent on the bottom-right (no absolute PnL)', () => { renderWithProvider(); - expect(screen.getByText('+$1,059.96 (+182%)')).toBeOnTheScreen(); + expect(screen.getByText('+182%')).toBeOnTheScreen(); + expect(screen.queryByText('+$1,059.96 (+182%)')).toBeNull(); + expect(screen.queryByText('+$1,059.96')).toBeNull(); }); - it('renders negative unrealized $ PnL with the percent', () => { + it('renders the negative percent without the absolute PnL', () => { const position = { ...basePosition, pnlValueUsd: -250, @@ -83,10 +85,11 @@ describe('PositionRow', () => { }; renderWithProvider(); - expect(screen.getByText('-$250.00 (-25%)')).toBeOnTheScreen(); + expect(screen.getByText('-25%')).toBeOnTheScreen(); + expect(screen.queryByText('-$250.00 (-25%)')).toBeNull(); }); - it('falls back to just the percent when pnlValueUsd is missing', () => { + it('renders the percent even when pnlValueUsd is missing', () => { const position = { ...basePosition, pnlValueUsd: null, @@ -119,7 +122,7 @@ describe('PositionRow', () => { expect(dashes.length).toBeGreaterThanOrEqual(1); }); - it('renders zero unrealized $ PnL with zero percent', () => { + it('renders zero percent when unrealized PnL is zero', () => { const position = { ...basePosition, pnlValueUsd: 0, @@ -127,7 +130,8 @@ describe('PositionRow', () => { }; renderWithProvider(); - expect(screen.getByText('$0.00 (+0%)')).toBeOnTheScreen(); + expect(screen.getByText('+0%')).toBeOnTheScreen(); + expect(screen.queryByText('$0.00 (+0%)')).toBeNull(); }); it('renders negative USD value', () => { diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx index 2b28662fb00..1adb8f795e2 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx @@ -125,9 +125,7 @@ const PositionRow: React.FC = ({ twClassName={pnlColorClass} color={pnlColorClass ? undefined : TextColor.TextAlternative} > - {position.pnlValueUsd != null - ? `${formatSignedUsd(position.pnlValueUsd)} (${formatPercent(displayPnlPercent)})` - : formatPercent(displayPnlPercent)} + {formatPercent(displayPnlPercent)} ) : ( { ).toBeOnTheScreen(); }); + it('does not render a podium medal badge on the profile avatar', () => { + renderWithProvider( + , + ); + expect(screen.queryByTestId('rank-medal-1')).toBeNull(); + expect(screen.queryByTestId('rank-medal-2')).toBeNull(); + expect(screen.queryByTestId('rank-medal-3')).toBeNull(); + }); + it('renders Maskicon fallback when imageUrl is falsy', () => { const profileNoImage = { ...baseProfile, imageUrl: '' }; renderWithProvider( diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx index 875c708d149..834e0d6a669 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx @@ -17,8 +17,6 @@ import { strings } from '../../../../../../locales/i18n'; import type { TraderProfile } from '@metamask/social-controllers'; import { TraderProfileViewSelectorsIDs } from '../TraderProfileView.testIds'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog -import { TopRankAvatar } from '../../../Homepage/Sections/TopTraders/topRank'; -// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import TraderAvatar from '../../../Homepage/Sections/TopTraders/components/TraderAvatar'; const AVATAR_SIZE = 40; @@ -27,18 +25,12 @@ export interface ProfileHeaderProps { profile: TraderProfile; followerCount: number; twitterHandle?: string | null; - /** - * Optional leaderboard rank used to render the top-rank decoration - * (gradient ring + crown emoji) around the avatar for ranks 1-3. - */ - rank?: number; } const ProfileHeader: React.FC = ({ profile, followerCount, twitterHandle, - rank, }) => { const handleTwitterPress = useCallback(() => { if (twitterHandle) { @@ -54,13 +46,11 @@ const ProfileHeader: React.FC = ({ gap={4} testID={TraderProfileViewSelectorsIDs.HEADER} > - - - + { expect(screen.getByText('0%')).toBeOnTheScreen(); }); - it('abbreviates positive PnL with K suffix', () => { + it('renders the full positive PnL with no decimals (no abbreviation)', () => { renderWithProvider(); - expect(screen.getByText('+$20.6K')).toBeOnTheScreen(); + expect(screen.getByText('+$20,610')).toBeOnTheScreen(); }); - it('abbreviates large positive PnL with M suffix', () => { + it('renders large positive PnL in full with no decimals', () => { const stats = { ...baseStats, pnl7d: 1_170_000 }; renderWithProvider(); - expect(screen.getByText('+$1.2M')).toBeOnTheScreen(); + expect(screen.getByText('+$1,170,000')).toBeOnTheScreen(); }); it('renders dash when pnl7d is null', () => { @@ -57,22 +57,22 @@ describe('StatsRow', () => { expect(dashes.length).toBeGreaterThanOrEqual(2); }); - it('abbreviates negative PnL with K suffix', () => { + it('renders negative PnL in full with a leading minus', () => { const stats = { ...baseStats, pnl7d: -5000 }; renderWithProvider(); - expect(screen.getByText('-$5K')).toBeOnTheScreen(); + expect(screen.getByText('-$5,000')).toBeOnTheScreen(); }); - it('renders sub-$1K PnL with two decimal places (no abbreviation)', () => { + it('renders sub-$1K PnL without decimals', () => { const stats = { ...baseStats, pnl7d: 500 }; renderWithProvider(); - expect(screen.getByText('+$500.00')).toBeOnTheScreen(); + expect(screen.getByText('+$500')).toBeOnTheScreen(); }); - it('rounds sub-$1K PnL to two decimal places', () => { + it('rounds fractional PnL to a whole dollar', () => { const stats = { ...baseStats, pnl7d: 500.236 }; renderWithProvider(); - expect(screen.getByText('+$500.24')).toBeOnTheScreen(); + expect(screen.getByText('+$500')).toBeOnTheScreen(); }); it('renders both null winRate and null pnl as dashes', () => { @@ -115,21 +115,21 @@ describe('StatsRow', () => { }); }); - it('renders win rate label', () => { + it('renders the win rate label', () => { renderWithProvider(); - expect(screen.getByText('win rate')).toBeOnTheScreen(); + expect(screen.getByText('Win rate')).toBeOnTheScreen(); }); - it('renders 7D Return label', () => { + it('renders the 7D P&L label', () => { renderWithProvider(); - expect(screen.getByText('7D Return')).toBeOnTheScreen(); + expect(screen.getByText('7D P&L')).toBeOnTheScreen(); }); - it('renders hold time label', () => { + it('renders the avg. hold label', () => { renderWithProvider(); - expect(screen.getByText('hold time')).toBeOnTheScreen(); + expect(screen.getByText('Avg. hold')).toBeOnTheScreen(); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx index 28945448a3d..76cd512d9e4 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx @@ -12,7 +12,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import type { TraderStats } from '@metamask/social-controllers'; -import { formatSignedAbbreviatedUsd } from '../../utils/formatters'; +import { formatSignedFullUsdNoDecimals } from '../../utils/formatters'; import { TraderProfileViewSelectorsIDs } from '../TraderProfileView.testIds'; export interface StatsRowProps { @@ -50,7 +50,7 @@ const StatsRow: React.FC = ({ stats, holdTimeMinutes }) => { const isWinRatePositive = (stats.winRate7d ?? 0) > 0; const hasPnl = stats.pnl7d != null; - const pnl = formatSignedAbbreviatedUsd(stats.pnl7d); + const pnl = formatSignedFullUsdNoDecimals(stats.pnl7d); const isPnlPositive = stats.pnl7d != null && stats.pnl7d >= 0; return ( diff --git a/app/components/Views/SocialLeaderboard/utils/formatters.test.ts b/app/components/Views/SocialLeaderboard/utils/formatters.test.ts index 3ae3a5b8499..97df2dbe6b0 100644 --- a/app/components/Views/SocialLeaderboard/utils/formatters.test.ts +++ b/app/components/Views/SocialLeaderboard/utils/formatters.test.ts @@ -2,6 +2,7 @@ import { formatUsd, formatSignedUsd, formatSignedAbbreviatedUsd, + formatSignedFullUsdNoDecimals, formatTokenAmount, formatPercent, formatTradeDate, @@ -90,6 +91,29 @@ describe('formatSignedAbbreviatedUsd', () => { }); }); +describe('formatSignedFullUsdNoDecimals', () => { + it('formats the full number with commas and no decimals, signed', () => { + expect(formatSignedFullUsdNoDecimals(82610666)).toBe('+$82,610,666'); + }); + + it('rounds away fractional digits', () => { + expect(formatSignedFullUsdNoDecimals(782360.66)).toBe('+$782,361'); + }); + + it('prefixes negative values with -', () => { + expect(formatSignedFullUsdNoDecimals(-1234)).toBe('-$1,234'); + }); + + it('renders zero without a sign', () => { + expect(formatSignedFullUsdNoDecimals(0)).toBe('$0'); + }); + + it('returns an em dash for null and undefined', () => { + expect(formatSignedFullUsdNoDecimals(null)).toBe('\u2014'); + expect(formatSignedFullUsdNoDecimals(undefined)).toBe('\u2014'); + }); +}); + describe('formatTokenAmount', () => { it('abbreviates billions (e.g. 1.5B)', () => { expect(formatTokenAmount(1500000000)).toBe('1.50B'); diff --git a/app/components/Views/SocialLeaderboard/utils/formatters.ts b/app/components/Views/SocialLeaderboard/utils/formatters.ts index 294395bde7c..5acad3b20a4 100644 --- a/app/components/Views/SocialLeaderboard/utils/formatters.ts +++ b/app/components/Views/SocialLeaderboard/utils/formatters.ts @@ -33,6 +33,28 @@ export function formatSignedUsd(value: number | null | undefined): string { return sign + formatPerpsFiat(Math.abs(value), { stripTrailingZeros: false }); } +/** + * Signed USD with the full (non-abbreviated) number, thousands separators and + * no fractional digits. Used for the profile 30D P&L headline + * (e.g. `+$82,610,666`). Zero renders without a sign. + */ +export function formatSignedFullUsdNoDecimals( + value: number | null | undefined, +): string { + if (value == null) return EM_DASH; + if (value === 0) { + return formatPerpsFiat(0, { minimumDecimals: 0, maximumDecimals: 0 }); + } + const sign = value > 0 ? '+' : '-'; + return ( + sign + + formatPerpsFiat(Math.abs(value), { + minimumDecimals: 0, + maximumDecimals: 0, + }) + ); +} + // Ordered largest → smallest. Walk down and promote when rounding pushes a // value past the bucket boundary (e.g. `999_999` rounds to `1000K`, which // we want as `$1M`, not `$1000K`). diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 0863486494a..2d23dca50f4 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { TouchableOpacity } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { RouteProp, useFocusEffect, @@ -43,6 +42,7 @@ import { trackExploreInteracted, type ExploreTabName, } from './search/analytics'; +import { EXPLORE_TAB_INDEX } from '../../../constants/navigation/exploreTabIndices'; const TAB_NAMES: ExploreTabName[] = [ 'Now', @@ -53,29 +53,32 @@ const TAB_NAMES: ExploreTabName[] = [ 'Sites', ]; -export const EXPLORE_TAB_INDEX = { - NOW: 0, - MACRO: 1, - RWAS: 2, - CRYPTO: 3, - SPORTS: 4, - SITES: 5, -} as const; +export { EXPLORE_TAB_INDEX } from '../../../constants/navigation/exploreTabIndices'; interface ExploreFeedRouteParams { initialTab?: number | null; + source?: string; } const useExploreTabNavigationEffect = (opts: { tabsListRef: React.RefObject; + tabNames: ExploreTabName[]; + pendingExploreEntrySourceRef: React.MutableRefObject; + previousTabRef: React.MutableRefObject; }) => { - const { tabsListRef } = opts; + const { + tabsListRef, + tabNames, + pendingExploreEntrySourceRef, + previousTabRef, + } = opts; const route = useRoute>(); const { setParams } = useNavigation(); const initialTabIndex = Object.values(EXPLORE_TAB_INDEX).find( (tab) => tab === route.params?.initialTab, ); + const entrySource = route.params?.source; useFocusEffect( useCallback(() => { @@ -83,9 +86,41 @@ const useExploreTabNavigationEffect = (opts: { return; } + const destinationTab = tabNames[initialTabIndex]; + if (entrySource) { + pendingExploreEntrySourceRef.current = entrySource; + } + + const currentIndex = tabsListRef.current?.getCurrentIndex(); tabsListRef.current?.goToTabIndex(initialTabIndex); - setParams?.({ initialTab: null }); - }, [initialTabIndex, setParams, tabsListRef]), + + // goToTabIndex only invokes onChangeTab when the tab actually changes. + if (entrySource && destinationTab && currentIndex === initialTabIndex) { + pendingExploreEntrySourceRef.current = undefined; + trackExploreInteracted({ + interaction_type: 'tab_switched', + tab_name: destinationTab, + previous_tab: previousTabRef.current, + source: entrySource, + }); + } else if ( + entrySource && + pendingExploreEntrySourceRef.current === entrySource + ) { + // Tab switch did not run (e.g. tabs not mounted) — discard stale source. + pendingExploreEntrySourceRef.current = undefined; + } + + setParams?.({ initialTab: null, source: undefined }); + }, [ + entrySource, + initialTabIndex, + pendingExploreEntrySourceRef, + previousTabRef, + setParams, + tabNames, + tabsListRef, + ]), ); }; @@ -96,9 +131,30 @@ export const ExploreFeed: React.FC = () => { const tabProps = useExploreRefresh(); const tabsListRef = useRef(null); const sessionManager = TrendingFeedSessionManager.getInstance(); + const previousTabRef = useRef('Now'); + const pendingExploreEntrySourceRef = useRef(undefined); + + const handleTabChange = useCallback(({ i }: { i: number }) => { + const destinationTab = TAB_NAMES[i]; + if (!destinationTab) return; + const source = pendingExploreEntrySourceRef.current; + pendingExploreEntrySourceRef.current = undefined; + trackExploreInteracted({ + interaction_type: 'tab_switched', + tab_name: destinationTab, + previous_tab: previousTabRef.current, + ...(source ? { source } : {}), + }); + previousTabRef.current = destinationTab; + }, []); // Handle tab navigation from route params - useExploreTabNavigationEffect({ tabsListRef }); + useExploreTabNavigationEffect({ + tabsListRef, + tabNames: TAB_NAMES, + pendingExploreEntrySourceRef, + previousTabRef, + }); // Initialize session and enable AppState listener on mount useEffect(() => { @@ -146,22 +202,8 @@ export const ExploreFeed: React.FC = () => { navigation.navigate(Routes.EXPLORE_SEARCH); }, [navigation]); - const previousTabRef = useRef('Now'); - - const handleTabChange = useCallback(({ i }: { i: number }) => { - const destinationTab = TAB_NAMES[i]; - if (!destinationTab) return; - trackExploreInteracted({ - interaction_type: 'tab_switched', - tab_name: destinationTab, - previous_tab: previousTabRef.current, - }); - previousTabRef.current = destinationTab; - }, []); - return ( - @@ -253,6 +295,6 @@ export const ExploreFeed: React.FC = () => { )} - + ); }; diff --git a/app/components/Views/TrendingView/components/CardList.tsx b/app/components/Views/TrendingView/components/CardList.tsx index ec0c06c3ad1..722abad7a95 100644 --- a/app/components/Views/TrendingView/components/CardList.tsx +++ b/app/components/Views/TrendingView/components/CardList.tsx @@ -8,7 +8,6 @@ const DEFAULT_MAX_ITEMS = 3; const styles = StyleSheet.create({ cardContainer: { padding: 0, - marginBottom: 28, borderWidth: 0, }, }); diff --git a/app/components/Views/TrendingView/components/ExploreScroll.tsx b/app/components/Views/TrendingView/components/ExploreScroll.tsx index 51bcd00357e..d26afeb6dfc 100644 --- a/app/components/Views/TrendingView/components/ExploreScroll.tsx +++ b/app/components/Views/TrendingView/components/ExploreScroll.tsx @@ -11,8 +11,8 @@ interface ExploreScrollProps { } /** - * Vertical ScrollView wrapper for an Explore tab body. Owns horizontal and top - * padding and pull-to-refresh wiring. + * Vertical ScrollView wrapper for an Explore tab body. Owns horizontal, top, and + * bottom padding and pull-to-refresh wiring. */ const ExploreScroll: React.FC = ({ refreshing, @@ -27,6 +27,7 @@ const ExploreScroll: React.FC = ({ { + it('does not render a divider before the first section', () => { + render( + First }, + { key: 'second', content: Second }, + ]} + />, + ); + + expect(screen.getByText('First')).toBeOnTheScreen(); + expect(screen.getByText('Second')).toBeOnTheScreen(); + expect(screen.getAllByTestId('explore-section-divider')).toHaveLength(1); + }); + + it('renders no dividers for a single section', () => { + render( + Only }]} + />, + ); + + expect(screen.queryByTestId('explore-section-divider')).toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/components/ExploreSectionList.tsx b/app/components/Views/TrendingView/components/ExploreSectionList.tsx new file mode 100644 index 00000000000..b4564291ac6 --- /dev/null +++ b/app/components/Views/TrendingView/components/ExploreSectionList.tsx @@ -0,0 +1,44 @@ +import React, { Fragment } from 'react'; +import { Box, SectionDivider } from '@metamask/design-system-react-native'; + +export interface ExploreSectionItem { + key: string; + content: React.ReactNode; + /** Vertical card lists omit pb-3 before the next section. */ + isVerticalList?: boolean; +} + +interface ExploreSectionListProps { + sections: ExploreSectionItem[]; +} + +/** + * Renders Explore tab sections with dividers between visible blocks. + * The tab owns section order and visibility; sections render content only. + */ +const ExploreSectionList: React.FC = ({ + sections, +}) => ( + <> + {sections.map((section, index) => ( + + {index > 0 ? ( + + + + ) : null} + + {section.content} + + + ))} + +); + +export default ExploreSectionList; diff --git a/app/components/Views/TrendingView/components/HorizontalCarousel.tsx b/app/components/Views/TrendingView/components/HorizontalCarousel.tsx index b417808a30d..1ce718469fc 100644 --- a/app/components/Views/TrendingView/components/HorizontalCarousel.tsx +++ b/app/components/Views/TrendingView/components/HorizontalCarousel.tsx @@ -44,7 +44,7 @@ function HorizontalCarousel({ const displayData = isLoading ? skeletonData : data; return ( - + = ({ const tw = useTailwind(); return ( - + ({ }; return ( - + = ({ @@ -30,6 +35,8 @@ const SectionHeader: React.FC = ({ testID, tabName, sectionName, + titleTwClassName, + subtitleTwClassName, }) => { const handleViewAll = useCallback(() => { if (tabName && sectionName) { @@ -39,23 +46,24 @@ const SectionHeader: React.FC = ({ }, [onViewAll, tabName, sectionName]); return ( - <> - + - {subtitle && ( + {subtitle ? ( {subtitle} - )} - + ) : null} + ); }; diff --git a/app/components/Views/TrendingView/components/TileCarousel.tsx b/app/components/Views/TrendingView/components/TileCarousel.tsx index 14f45cfa920..749ea42068a 100644 --- a/app/components/Views/TrendingView/components/TileCarousel.tsx +++ b/app/components/Views/TrendingView/components/TileCarousel.tsx @@ -20,12 +20,6 @@ export interface TileCarouselProps { max?: number; testID?: string; viewMoreTestID?: string; - /** - * When true, uses `mb-7` after the carousel so spacing matches {@link CardList} - * section tails (~28px). Use for the first tile strip on Sites (Recents) to - * mirror Crypto "Trending" → next section rhythm. - */ - compactSectionTail?: boolean; } /** @@ -42,13 +36,12 @@ function TileCarousel({ max = TILE_CAROUSEL_DEFAULT_MAX_TILES, testID, viewMoreTestID, - compactSectionTail = false, }: TileCarouselProps) { const tw = useTailwind(); const displayItems = useMemo(() => data.slice(0, max), [data, max]); return ( - + {isLoading ? ( diff --git a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx index d2f6a2c68ba..7dda22b128d 100644 --- a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx @@ -14,7 +14,8 @@ import { IconColor, BoxFlexDirection, BoxAlignItems, - BoxJustifyContent, + SectionDivider, + SectionHeader as MMDSSectionHeader, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list'; @@ -131,43 +132,42 @@ const ExploreSearchResults: React.FC = ({ section.total, ); return ( - - - {item.title} - - {viewMoreLabel !== null && ( - handleViewMore(section)} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={`${viewMoreLabel} ${item.title}`} - style={({ pressed }) => [ - pressedStyle.pressable, - pressed && { opacity: 0.5 }, - ]} - > - - {viewMoreLabel} - - - - )} + + {!item.isFirstHeader ? : null} + handleViewMore(section)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={`${viewMoreLabel} ${item.title}`} + style={({ pressed }) => [ + pressedStyle.pressable, + pressed && { opacity: 0.5 }, + ]} + > + + {viewMoreLabel} + + + + ) : undefined + } + /> ); }, @@ -182,12 +182,19 @@ const ExploreSearchResults: React.FC = ({ const flatData = useMemo(() => { const result: FlatListItem[] = []; const visibleSections = isBasicFunctionalityEnabled ? sections : []; + let headerCount = 0; visibleSections.forEach((section) => { const { feedId, title, items, isLoading } = section; if (!isLoading && items.length === 0) return; - result.push({ type: 'header', feedId, title }); + result.push({ + type: 'header', + feedId, + title, + isFirstHeader: headerCount === 0, + }); + headerCount += 1; if (isLoading) { for (let i = 0; i < MAX_ITEMS_PER_SECTION; i++) { diff --git a/app/components/Views/TrendingView/search/analytics.ts b/app/components/Views/TrendingView/search/analytics.ts index 733a145fe05..bda5c92e3d4 100644 --- a/app/components/Views/TrendingView/search/analytics.ts +++ b/app/components/Views/TrendingView/search/analytics.ts @@ -100,6 +100,8 @@ export interface ExploreInteractedProperties { token_symbol?: string; chain_id?: string; item_clicked?: string; + /** Entry surface when the user arrived on Explore (e.g. `homescreen_pill`). */ + source?: string; } export const trackExploreInteracted = ( diff --git a/app/components/Views/TrendingView/search/searchTypes.ts b/app/components/Views/TrendingView/search/searchTypes.ts index 7ab3f505a84..c65b522b029 100644 --- a/app/components/Views/TrendingView/search/searchTypes.ts +++ b/app/components/Views/TrendingView/search/searchTypes.ts @@ -4,6 +4,7 @@ export interface ListItemHeader { type: 'header'; feedId: SearchFeedId; title: string; + isFirstHeader: boolean; } export interface ListItemData { diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx index 55c57b1f4bc..c9c3dcc1b6f 100644 --- a/app/components/Views/TrendingView/tabs/CryptoTab.tsx +++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx @@ -1,7 +1,6 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; -import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; import type { TrendingAsset } from '@metamask/assets-controllers'; import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; @@ -26,6 +25,9 @@ import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarousel import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation'; import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; +import ExploreSectionList, { + type ExploreSectionItem, +} from '../components/ExploreSectionList'; import SectionHeader from '../components/SectionHeader'; import TileCarousel from '../components/TileCarousel'; import type { TabProps } from '../hooks/useExploreRefresh'; @@ -50,7 +52,7 @@ const CryptoPerpsBlock: React.FC = ({ if (!perps.isLoading && perps.data.length === 0) return null; return ( - + <> onViewAll(perps.defaultSortOptionId)} @@ -83,11 +85,15 @@ const CryptoPerpsBlock: React.FC = ({ testID="explore-crypto_perps-carousel" viewMoreTestID="crypto_perps-view-more-card" /> - + ); }; -const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { +const CryptoTabContent: React.FC = ({ + refresh, + refreshing, + onRefresh, +}) => { const navigation = useNavigation(); const perpsNavigation = useNavigation>(); @@ -98,6 +104,11 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { variant: 'crypto', refresh, }); + const cryptoPerps = usePerpsFeed({ + variant: 'crypto', + refresh, + withTileExtras: true, + }); const renderTokenItem: ListRenderItem = useCallback( ({ item, index }) => ( @@ -123,56 +134,103 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { ); const showTokens = tokens.isLoading || tokens.data.length > 0; + const showCryptoPerps = + isPerpsEnabled && (cryptoPerps.isLoading || cryptoPerps.data.length > 0); + const showPredictions = + cryptoPredictions.isLoading || cryptoPredictions.data.length > 0; - return ( - - {showTokens && ( - - - navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) - } - testID="section-header-view-all-tokens" - tabName="Crypto" - sectionName="tokens_trending" - /> - - data={tokens.data} - isLoading={tokens.isLoading} - renderItem={renderTokenItem} - Skeleton={TrendingTokensSkeleton} - idPrefix="tokens" - /> - - )} + const sections = useMemo((): ExploreSectionItem[] => { + const items: ExploreSectionItem[] = []; - {isPerpsEnabled && ( - + if (showTokens) { + items.push({ + key: 'tokens', + isVerticalList: true, + content: ( + <> + + navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-tokens" + tabName="Crypto" + sectionName="tokens_trending" + /> + + data={tokens.data} + isLoading={tokens.isLoading} + renderItem={renderTokenItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="tokens" + /> + + ), + }); + } + + if (showCryptoPerps) { + items.push({ + key: 'crypto_perps', + content: ( navigateToPerpsMarketList(perpsNavigation, 'crypto', sortOptionId) } /> - - )} + ), + }); + } - navigateToExplorePredictionsList(navigation, 'crypto')} - /> + if (showPredictions) { + items.push({ + key: 'predictions', + content: ( + + navigateToExplorePredictionsList(navigation, 'crypto') + } + /> + ), + }); + } + + return items; + }, [ + showTokens, + showCryptoPerps, + showPredictions, + navigation, + tokens.data, + tokens.isLoading, + renderTokenItem, + refresh, + perpsNavigation, + cryptoPredictions, + ]); + + return ( + + ); }; +const CryptoTab: React.FC = (props) => ( + + + +); + export default CryptoTab; diff --git a/app/components/Views/TrendingView/tabs/DappsTab.tsx b/app/components/Views/TrendingView/tabs/DappsTab.tsx index 2f5d2e9e515..947a1f9e973 100644 --- a/app/components/Views/TrendingView/tabs/DappsTab.tsx +++ b/app/components/Views/TrendingView/tabs/DappsTab.tsx @@ -1,6 +1,5 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem'; @@ -16,6 +15,9 @@ import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton import { useSitesFeed } from '../feeds/sites/useSitesFeed'; import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; +import ExploreSectionList, { + type ExploreSectionItem, +} from '../components/ExploreSectionList'; import SectionHeader from '../components/SectionHeader'; import TileCarousel from '../components/TileCarousel'; import type { TabProps } from '../hooks/useExploreRefresh'; @@ -72,21 +74,88 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const showFavorites = favorites.isLoading || favorites.data.length > 0; const showSites = sites.isLoading || sites.data.length > 0; - return ( - - {showRecents && ( - + const sections = useMemo((): ExploreSectionItem[] => { + const items: ExploreSectionItem[] = []; + + if (showRecents) { + items.push({ + key: 'recents', + content: ( + <> + + + data={recents.data} + isLoading={recents.isLoading} + renderItem={(site, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_recents', + asset_type: 'dapp', + position: index, + item_clicked: site.url, + }) + } + /> + )} + keyExtractor={(site) => site.url} + Skeleton={SiteTileSkeleton} + testID="explore-dapps_recents-carousel" + /> + + ), + }); + } + + if (showFavorites) { + items.push({ + key: 'favorites', + isVerticalList: true, + content: ( + <> + + navigation.navigate(Routes.SITES_FULL_VIEW, { + mode: 'favorites', + }) + } + testID="section-header-view-all-dapps_favorites" + tabName="Sites" + sectionName="sites_favorites" + /> + + data={favorites.data} + isLoading={favorites.isLoading} + renderItem={renderFavorite} + Skeleton={SiteSkeleton} + idPrefix="dapps_favorites" + /> + + ), + }); + } + + items.push({ + key: 'networks', + content: ( + <> - data={recents.data} - isLoading={recents.isLoading} + data={networks.data} + isLoading={false} renderItem={(site, index) => ( = ({ refresh, refreshing, onRefresh }) => { trackExploreInteracted({ interaction_type: 'section_item_tapped', tab_name: 'Sites', - section_name: 'sites_recents', + section_name: 'sites_ecosystems', asset_type: 'dapp', position: index, item_clicked: site.url, @@ -104,81 +173,61 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { )} keyExtractor={(site) => site.url} Skeleton={SiteTileSkeleton} - compactSectionTail - testID="explore-dapps_recents-carousel" - /> - - )} - - {showFavorites && ( - - - navigation.navigate(Routes.SITES_FULL_VIEW, { mode: 'favorites' }) - } - testID="section-header-view-all-dapps_favorites" - tabName="Sites" - sectionName="sites_favorites" + testID="explore-dapps_networks-carousel" /> - - data={favorites.data} - isLoading={favorites.isLoading} - renderItem={renderFavorite} - Skeleton={SiteSkeleton} - idPrefix="dapps_favorites" - /> - - )} + + ), + }); - - - - data={networks.data} - isLoading={false} - renderItem={(site, index) => ( - - trackExploreInteracted({ - interaction_type: 'section_item_tapped', - tab_name: 'Sites', - section_name: 'sites_ecosystems', - asset_type: 'dapp', - position: index, - item_clicked: site.url, - }) - } + if (showSites) { + items.push({ + key: 'sites', + isVerticalList: true, + content: ( + <> + navigation.navigate(Routes.SITES_FULL_VIEW)} + testID="section-header-view-all-sites" + tabName="Sites" + sectionName="sites_popular" + /> + + data={sites.data} + isLoading={sites.isLoading} + renderItem={renderSite} + Skeleton={SiteSkeleton} + idPrefix="sites" /> - )} - keyExtractor={(site) => site.url} - Skeleton={SiteTileSkeleton} - testID="explore-dapps_networks-carousel" - /> - + + ), + }); + } - {showSites && ( - - navigation.navigate(Routes.SITES_FULL_VIEW)} - testID="section-header-view-all-sites" - tabName="Sites" - sectionName="sites_popular" - /> - - data={sites.data} - isLoading={sites.isLoading} - renderItem={renderSite} - Skeleton={SiteSkeleton} - idPrefix="sites" - /> - - )} + return items; + }, [ + showRecents, + showFavorites, + showSites, + recents.data, + recents.isLoading, + favorites.data, + favorites.isLoading, + networks.data, + navigation, + renderFavorite, + sites.data, + sites.isLoading, + renderSite, + ]); + + return ( + + ); }; diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx index 74e8e957dec..366adc635eb 100644 --- a/app/components/Views/TrendingView/tabs/MacroTab.tsx +++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx @@ -22,6 +22,9 @@ import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection'; import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation'; import ExploreScroll from '../components/ExploreScroll'; +import ExploreSectionList, { + type ExploreSectionItem, +} from '../components/ExploreSectionList'; import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import type { TabProps } from '../hooks/useExploreRefresh'; @@ -79,7 +82,11 @@ const MacroPerpsBlock: React.FC = ({ ); }; -const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { +const MacroTabContent: React.FC = ({ + refresh, + refreshing, + onRefresh, +}) => { const appNavigation = useNavigation(); const perpsNavigation = useNavigation>(); @@ -87,34 +94,73 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const isPredictEnabled = useSelector(selectPredictEnabledFlag); const politics = usePredictionsFeed({ variant: 'politics', refresh }); + const macroPerps = usePerpsFeed({ variant: 'macro', refresh }); - return ( - - - navigateToExplorePredictionsList(appNavigation, 'politics') - } - isEnabled={isPredictEnabled} - /> - - {isPerpsEnabled && ( - + const showPredictions = + isPredictEnabled && (politics.isLoading || politics.data.length > 0); + const showPerps = + isPerpsEnabled && (macroPerps.isLoading || macroPerps.data.length > 0); + + const sections = useMemo((): ExploreSectionItem[] => { + const items: ExploreSectionItem[] = []; + + if (showPredictions) { + items.push({ + key: 'predictions', + content: ( + + navigateToExplorePredictionsList(appNavigation, 'politics') + } + isEnabled={isPredictEnabled} + /> + ), + }); + } + + if (showPerps) { + items.push({ + key: 'perps', + isVerticalList: true, + content: ( navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId) } /> - - )} + ), + }); + } + + return items; + }, [ + showPredictions, + showPerps, + politics, + isPredictEnabled, + appNavigation, + refresh, + perpsNavigation, + ]); + + return ( + + ); }; +const MacroTab: React.FC = (props) => ( + + + +); + export default MacroTab; diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx index 7891d5dfbbc..83343aa9b24 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.test.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -154,13 +154,72 @@ const mockWhatsHappeningImpl = jest.fn( () => null, ); +const mockWhatsHappeningRefresh = jest.fn(); +const mockUseWhatsHappening = jest.fn(() => ({ + items: [] as { id: string }[], + isLoading: false, + error: null as string | null, + refresh: mockWhatsHappeningRefresh, +})); + +jest.mock('../../../UI/WhatsHappening/hooks', () => ({ + ...jest.requireActual('../../../UI/WhatsHappening/hooks'), + useWhatsHappening: () => mockUseWhatsHappening(), +})); + +const mockPredictionsCarouselSection = jest.fn( + (props: { + idPrefix?: string; + title?: string; + onViewAll?: () => void; + isEnabled?: boolean; + feed?: { isLoading: boolean; data: unknown[] }; + }) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createElement, Fragment } = require('react'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Pressable, Text } = require('react-native'); + + if ( + !props.isEnabled || + (!props.feed?.isLoading && props.feed?.data.length === 0) + ) { + return null; + } + + return createElement( + Fragment, + null, + createElement( + Pressable, + { + testID: `section-header-view-all-${props.idPrefix}`, + onPress: props.onViewAll, + }, + createElement(Text, null, props.title), + ), + ); + }, +); + +jest.mock('../feeds/predictions/PredictionsCarouselSection', () => ({ + __esModule: true, + default: (props: { + idPrefix?: string; + title?: string; + onViewAll?: () => void; + isEnabled?: boolean; + feed?: { isLoading: boolean; data: unknown[] }; + }) => mockPredictionsCarouselSection(props), +})); + jest.mock('../../../UI/WhatsHappening', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { forwardRef } = require('react'); return { __esModule: true, - default: forwardRef((_props: unknown, ref: unknown) => - mockWhatsHappeningImpl(ref), + default: forwardRef((props: unknown, ref: unknown) => + mockWhatsHappeningImpl({ ...(props as object), ref }), ), }; }); @@ -256,6 +315,12 @@ beforeEach(() => { isLoading: false, isEnabled: false, }); + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: null, + refresh: mockWhatsHappeningRefresh, + }); mockWhatsHappeningImpl.mockReturnValue(null); }); @@ -283,6 +348,12 @@ describe('NowTab — WhatsHappeningSection integration', () => { if (selector === selectWhatsHappeningEnabled) return true; return mockSelectorBase(selector); }); + mockUseWhatsHappening.mockReturnValue({ + items: [{ id: 'trend-0' }], + isLoading: false, + error: null, + refresh: mockWhatsHappeningRefresh, + }); (mockWhatsHappeningImpl as jest.Mock).mockReturnValue( React.createElement('View', { testID: 'whats-happening-carousel', @@ -307,19 +378,26 @@ describe('NowTab — WhatsHappeningSection integration', () => { expect(screen.queryByTestId('whats-happening-carousel')).toBeNull(); }); - it('passes a ref to WhatsHappeningSection so pull-to-refresh can trigger it', () => { + it('calls whatsHappening.refresh when pull-to-refresh is triggered', () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectWhatsHappeningEnabled) return true; return mockSelectorBase(selector); }); - renderNowTab(); + const { rerender } = renderNowTab(); + + expect(mockWhatsHappeningRefresh).not.toHaveBeenCalled(); + + rerender( + + + , + ); - // The mock's first argument is the forwarded ref (we dropped props in the mock). - // It should be a React ref object so the useEffect bridge can call .refresh(). - expect(mockWhatsHappeningImpl).toHaveBeenCalled(); - const [forwardedRef] = mockWhatsHappeningImpl.mock.calls[0]; - expect(forwardedRef).not.toBeNull(); + expect(mockWhatsHappeningRefresh).toHaveBeenCalledTimes(1); }); it('renders Predict before Whats Happening for the control variant', () => { @@ -332,6 +410,12 @@ describe('NowTab — WhatsHappeningSection integration', () => { data: [{ id: 'market-1' }], isLoading: false, }); + mockUseWhatsHappening.mockReturnValue({ + items: [{ id: 'trend-0' }], + isLoading: false, + error: null, + refresh: mockWhatsHappeningRefresh, + }); mockWhatsHappeningImpl.mockReturnValue( React.createElement('View', { testID: whatsHappeningSectionTestId, @@ -357,6 +441,12 @@ describe('NowTab — WhatsHappeningSection integration', () => { data: [{ id: 'market-1' }], isLoading: false, }); + mockUseWhatsHappening.mockReturnValue({ + items: [{ id: 'trend-0' }], + isLoading: false, + error: null, + refresh: mockWhatsHappeningRefresh, + }); mockWhatsHappeningImpl.mockReturnValue( React.createElement('View', { testID: whatsHappeningSectionTestId, @@ -370,6 +460,57 @@ describe('NowTab — WhatsHappeningSection integration', () => { predictSectionTestId, ]); }); + + it('does not add a divider before Predict when Whats Happening feed is empty', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + if (selector === selectPredictEnabledFlag) return true; + return mockSelectorBase(selector); + }); + mockTreatmentAbTest(); + mockUsePredictionsFeed.mockReturnValue({ + data: [{ id: 'market-1' }], + isLoading: false, + }); + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: null, + refresh: mockWhatsHappeningRefresh, + }); + + renderNowTab(); + + expect(screen.queryByTestId('explore-section-divider')).toBeNull(); + }); + + it('adds a divider before Predict when Whats Happening has content', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + if (selector === selectPredictEnabledFlag) return true; + return mockSelectorBase(selector); + }); + mockTreatmentAbTest(); + mockUsePredictionsFeed.mockReturnValue({ + data: [{ id: 'market-1' }], + isLoading: false, + }); + mockUseWhatsHappening.mockReturnValue({ + items: [{ id: 'trend-0' }], + isLoading: false, + error: null, + refresh: mockWhatsHappeningRefresh, + }); + mockWhatsHappeningImpl.mockReturnValue( + React.createElement('View', { + testID: whatsHappeningSectionTestId, + }), + ); + + renderNowTab(); + + expect(screen.getByTestId('explore-section-divider')).toBeOnTheScreen(); + }); }); describe('NowTab — Perps Movers "View All" navigation', () => { diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index cba004d7279..5846fb9b7ad 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -1,13 +1,6 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; -import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; import type { TrendingAsset } from '@metamask/assets-controllers'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; @@ -50,15 +43,23 @@ import { import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; +import ExploreSectionList, { + type ExploreSectionItem, +} from '../components/ExploreSectionList'; +import SectionHeader from '../components/SectionHeader'; import PillScrollList from '../components/PillScrollList'; import PillRow, { type PillOption } from '../components/PillRow'; -import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; import { trackExploreInteracted } from '../search/analytics'; import WhatsHappeningSection from '../../../UI/WhatsHappening'; -import { WhatsHappeningSource } from '../../../UI/WhatsHappening/constants'; -// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog -import type { SectionRefreshHandle } from '../../Homepage/types'; +import { + MAX_ITEMS_DISPLAYED, + WhatsHappeningSource, +} from '../../../UI/WhatsHappening/constants'; +import { + isWhatsHappeningSectionVisible, + useWhatsHappening, +} from '../../../UI/WhatsHappening/hooks'; import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; import { useABTest } from '../../../../hooks'; import { @@ -146,7 +147,7 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { if (!perps.isLoading && perps.data.length === 0) return null; return ( - + <> @@ -192,7 +193,7 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { Skeleton={CryptoMoversSkeleton} listTestId="explore-perps-pills-list" /> - + ); }; @@ -200,7 +201,11 @@ const CRYPTO_MOVERS_TIME_OPTION = TimeOption.OneHour; const CRYPTO_MOVERS_ROW_COUNT = 3; const CRYPTO_MOVERS_MAX_PILLS = 18; -const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { +const NowTabContent: React.FC = ({ + refresh, + refreshing, + onRefresh, +}) => { const navigation = useNavigation(); const perpsNavigation = useNavigation>(); @@ -212,12 +217,13 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { WHATS_HAPPENING_EXPLORE_VARIANTS, ); - const whatsHappeningRef = useRef(null); + const whatsHappening = useWhatsHappening(MAX_ITEMS_DISPLAYED); + const refreshWhatsHappening = whatsHappening.refresh; useEffect(() => { if (refresh.trigger === 0) return; - whatsHappeningRef.current?.refresh(); - }, [refresh.trigger]); + void refreshWhatsHappening(); + }, [refresh.trigger, refreshWhatsHappening]); const worldCupPredictions = useWorldCupPredictionsFeed({ enabled: isPredictEnabled, @@ -230,6 +236,11 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const displayedPredictions = worldCupPredictions.isEnabled ? worldCupPredictions : predictions; + const perpsFeed = usePerpsFeed({ + variant: 'all', + refresh, + withTileExtras: false, + }); const cryptoMovers = useTokensFeed({ refresh, hideRiskyTokens: true, @@ -263,45 +274,168 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { [], ); + const showWhatsHappening = + isWhatsHappeningEnabled && isWhatsHappeningSectionVisible(whatsHappening); + const showPredictions = + isPredictEnabled && + (displayedPredictions.isLoading || displayedPredictions.data.length > 0); const showCryptoMovers = cryptoMovers.isLoading || cryptoMovers.data.length > 0; + const showPerps = + isPerpsEnabled && (perpsFeed.isLoading || perpsFeed.data.length > 0); const showStocks = stocks.isLoading || stocks.data.length > 0; - const whatsHappeningSection = isWhatsHappeningEnabled ? ( - - - - ) : null; + const sections = useMemo((): ExploreSectionItem[] => { + const items: ExploreSectionItem[] = []; + const whFirst = whatsHappeningExploreVariant.whatsHappeningBeforePredict; - const predictionsSection = ( - - worldCupPredictions.isEnabled - ? navigateToExploreWorldCupPredictions(navigation) - : navigateToExplorePredictionsList(navigation, 'trending') - } - isEnabled={isPredictEnabled} - /> - ); + const whatsHappeningSection: ExploreSectionItem = { + key: 'wh', + content: ( + + ), + }; + + const predictionsSection: ExploreSectionItem = { + key: 'predict', + content: ( + + worldCupPredictions.isEnabled + ? navigateToExploreWorldCupPredictions(navigation) + : navigateToExplorePredictionsList(navigation, 'trending') + } + isEnabled={isPredictEnabled} + /> + ), + }; - const orderedIntroSections = - whatsHappeningExploreVariant.whatsHappeningBeforePredict - ? [whatsHappeningSection, predictionsSection] - : [predictionsSection, whatsHappeningSection]; + if (whFirst) { + if (showWhatsHappening) items.push(whatsHappeningSection); + if (showPredictions) items.push(predictionsSection); + } else { + if (showPredictions) items.push(predictionsSection); + if (showWhatsHappening) items.push(whatsHappeningSection); + } + + if (showCryptoMovers) { + items.push({ + key: 'crypto_movers', + content: ( + <> + + navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW, { + initialTimeOption: CRYPTO_MOVERS_TIME_OPTION, + }) + } + testID="section-header-view-all-crypto_movers" + tabName="Now" + sectionName="tokens_movers" + /> + + data={cryptoMovers.data} + isLoading={cryptoMovers.isLoading} + renderItem={(token, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'tokens_movers', + asset_type: 'token', + position: index, + token_symbol: token.symbol, + chain_id: getCaipChainIdFromAssetId(token.assetId), + item_clicked: token.assetId, + }) + } + /> + )} + keyExtractor={(token) => token.assetId ?? ''} + Skeleton={CryptoMoversSkeleton} + listTestId="explore-crypto_movers-pills-list" + rowCount={CRYPTO_MOVERS_ROW_COUNT} + maxPills={CRYPTO_MOVERS_MAX_PILLS} + /> + + ), + }); + } + + if (showPerps) { + items.push({ + key: 'perps', + content: , + }); + } + + if (showStocks) { + items.push({ + key: 'stocks', + isVerticalList: true, + content: ( + <> + + navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-stocks" + tabName="Now" + sectionName="stocks" + /> + + data={stocks.data} + isLoading={stocks.isLoading} + renderItem={renderTokenItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="stocks" + /> + + ), + }); + } + + return items; + }, [ + whatsHappeningExploreVariant.whatsHappeningBeforePredict, + showWhatsHappening, + showPredictions, + showCryptoMovers, + showPerps, + showStocks, + whatsHappening, + displayedPredictions, + worldCupPredictions.isEnabled, + isPredictEnabled, + navigation, + cryptoMovers.data, + cryptoMovers.isLoading, + refresh, + perpsNavigation, + stocks.data, + stocks.isLoading, + renderTokenItem, + ]); return ( = ({ refresh, refreshing, onRefresh }) => { onRefresh={onRefresh} testID={TrendingViewSelectorsIDs.EXPLORE_NOW_SCROLL_VIEW} > - {orderedIntroSections} - - {showCryptoMovers && ( - - - navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW, { - initialTimeOption: CRYPTO_MOVERS_TIME_OPTION, - }) - } - testID="section-header-view-all-crypto_movers" - tabName="Now" - sectionName="tokens_movers" - /> - - data={cryptoMovers.data} - isLoading={cryptoMovers.isLoading} - renderItem={(token, index) => ( - - trackExploreInteracted({ - interaction_type: 'section_item_tapped', - tab_name: 'Now', - section_name: 'tokens_movers', - asset_type: 'token', - position: index, - token_symbol: token.symbol, - chain_id: getCaipChainIdFromAssetId(token.assetId), - item_clicked: token.assetId, - }) - } - /> - )} - keyExtractor={(token) => token.assetId ?? ''} - Skeleton={CryptoMoversSkeleton} - listTestId="explore-crypto_movers-pills-list" - rowCount={CRYPTO_MOVERS_ROW_COUNT} - maxPills={CRYPTO_MOVERS_MAX_PILLS} - /> - - )} - - {isPerpsEnabled && ( - - - - )} - - {showStocks && ( - - - navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) - } - testID="section-header-view-all-stocks" - tabName="Now" - sectionName="stocks" - /> - - data={stocks.data} - isLoading={stocks.isLoading} - renderItem={renderTokenItem} - Skeleton={TrendingTokensSkeleton} - idPrefix="stocks" - /> - - )} + ); }; +const NowTab: React.FC = (props) => ( + + + +); + export default NowTab; diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx index d5dde08ffc3..c8140a21b4d 100644 --- a/app/components/Views/TrendingView/tabs/RwasTab.tsx +++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; -import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; import type { TrendingAsset } from '@metamask/assets-controllers'; import { @@ -35,8 +34,11 @@ import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarousel import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation'; import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; -import type { PillToggleCardListTab } from '../components/PillToggleCardList'; +import ExploreSectionList, { + type ExploreSectionItem, +} from '../components/ExploreSectionList'; import SectionHeader from '../components/SectionHeader'; +import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import type { TabProps } from '../hooks/useExploreRefresh'; import { trackExploreInteracted } from '../search/analytics'; import { TrendingViewSelectorsIDs } from '../TrendingView.testIds'; @@ -95,7 +97,11 @@ const RwaPerpsBlock: React.FC = ({ ); }; -const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { +const RwasTabContent: React.FC = ({ + refresh, + refreshing, + onRefresh, +}) => { const appNavigation = useNavigation(); const perpsNavigation = useNavigation>(); @@ -107,6 +113,7 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { refresh, pageSize: STOCKS_FEED_PREVIEW_PAGE_SIZE, }); + const rwaPerps = usePerpsFeed({ variant: 'rwa', refresh }); const renderStockItem: ListRenderItem = useCallback( ({ item, index }) => ( @@ -132,59 +139,106 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { ); const showStocks = stocks.isLoading || stocks.data.length > 0; + const showPredictions = + isPredictEnabled && (politics.isLoading || politics.data.length > 0); + const showPerps = + isPerpsEnabled && (rwaPerps.isLoading || rwaPerps.data.length > 0); - return ( - - - navigateToExplorePredictionsList(appNavigation, 'politics') - } - isEnabled={isPredictEnabled} - /> + const sections = useMemo((): ExploreSectionItem[] => { + const items: ExploreSectionItem[] = []; - {showStocks && ( - - - appNavigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) + navigateToExplorePredictionsList(appNavigation, 'politics') } - testID="section-header-view-all-stocks" - tabName="RWAs" - sectionName="stocks" - /> - - data={stocks.data} - isLoading={stocks.isLoading} - renderItem={renderStockItem} - Skeleton={TrendingTokensSkeleton} - idPrefix="stocks" + isEnabled={isPredictEnabled} /> - - )} + ), + }); + } - {isPerpsEnabled && ( - + if (showStocks) { + items.push({ + key: 'stocks', + isVerticalList: true, + content: ( + <> + + appNavigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-stocks" + tabName="RWAs" + sectionName="stocks" + /> + + data={stocks.data} + isLoading={stocks.isLoading} + renderItem={renderStockItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="stocks" + /> + + ), + }); + } + + if (showPerps) { + items.push({ + key: 'perps', + isVerticalList: true, + content: ( navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId) } /> - - )} + ), + }); + } + + return items; + }, [ + showPredictions, + showStocks, + showPerps, + politics, + isPredictEnabled, + appNavigation, + stocks.data, + stocks.isLoading, + renderStockItem, + refresh, + perpsNavigation, + ]); + + return ( + + ); }; +const RwasTab: React.FC = (props) => ( + + + +); + export default RwasTab; diff --git a/app/components/Views/TrendingView/tabs/SportsTab.tsx b/app/components/Views/TrendingView/tabs/SportsTab.tsx index 41fb0dcaa16..f7367a83903 100644 --- a/app/components/Views/TrendingView/tabs/SportsTab.tsx +++ b/app/components/Views/TrendingView/tabs/SportsTab.tsx @@ -12,6 +12,7 @@ import { BoxFlexDirection, BoxJustifyContent, FontWeight, + SectionDivider, TabEmptyState, Text, TextColor, @@ -36,8 +37,8 @@ import { navigateToExplorePredictionsList, navigateToExploreWorldCupPredictions, } from '../feeds/predictions/predictionsNavigation'; -import PillRow from '../components/PillRow'; import SectionHeader from '../components/SectionHeader'; +import PillRow from '../components/PillRow'; import type { TabProps } from '../hooks/useExploreRefresh'; import { trackExploreInteracted, @@ -72,42 +73,43 @@ const SportsListHeader: React.FC = ({ showAllSportsEmpty, navigation, }) => ( - - - showWorldCupPredictions - ? navigateToExploreWorldCupPredictions(navigation) - : navigateToExplorePredictionsList(navigation, 'sports') - } - isEnabled={showSportsPredictions} - /> + <> + + + showWorldCupPredictions + ? navigateToExploreWorldCupPredictions(navigation) + : navigateToExplorePredictionsList(navigation, 'sports') + } + isEnabled={showSportsPredictions} + /> + + {showSportsPredictions ? : null} - - - + {showAllSportsSkeleton && ( @@ -130,7 +132,7 @@ const SportsListHeader: React.FC = ({ )} - + ); const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { @@ -240,7 +242,6 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { ); - // When loading or empty, data is empty — header renders those states. const listData = showAllSportsSkeleton || showAllSportsEmpty ? [] : active.marketData; @@ -254,7 +255,7 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { ListFooterComponent={listFooter} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} - contentContainerStyle={tw.style('px-4')} + contentContainerStyle={tw.style('px-4 pt-3 pb-4')} testID={`all-sports-list-${activeKey}`} refreshControl={ { it('renders wallet home with minimal state and shows key UI elements', () => { @@ -135,4 +139,103 @@ describeForPlatforms('Wallet', () => { expect(accountPicker).toBeOnTheScreen(); fireEvent.press(accountPicker); }); + + const walletStateOverrides = { + settings: { + basicFunctionalityEnabled: true, + }, + engine: { + backgroundState: { + MultichainNetworkController: { + isEvmSelected: true, + }, + RewardsController: { + activeAccount: null, + }, + PreferencesController: { + tokenSortConfig: { + key: 'tokenFiatAmount', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + }, + }, + }, + }; + + const renderWalletWithState = ( + configure: ( + builder: ReturnType, + ) => ReturnType, + ) => { + const state = configure(initialStateWallet()).build(); + + return renderComponentViewScreen( + Wallet as unknown as React.ComponentType, + { name: Routes.WALLET_VIEW }, + { state }, + ); + }; + + describe('card header button', () => { + it('hides the card button when the Money account is visible', () => { + const { queryByTestId } = renderWalletWithState((builder) => + builder + .withRemoteFeatureFlags({ + moneyEnableMoneyAccount: { + enabled: true, + minimumVersion: '0.0.0', + }, + moneyAccountGeoBlockedCountries: { blockedRegions: ['GB'] }, + }) + .withOverrides({ + ...walletStateOverrides, + engine: { + backgroundState: { + ...walletStateOverrides.engine.backgroundState, + GeolocationController: { + location: 'US', + }, + }, + }, + } as unknown as Record), + ); + + expect( + queryByTestId(WalletViewSelectorsIDs.CARD_BUTTON), + ).not.toBeOnTheScreen(); + }); + + it('shows the card button when Money account is disabled', () => { + const { getByTestId } = renderWalletView(defaultWalletOverrides); + + expect(getByTestId(WalletViewSelectorsIDs.CARD_BUTTON)).toBeOnTheScreen(); + }); + + it('shows the card button when Money account is enabled but geo-ineligible', () => { + const { getByTestId } = renderWalletWithState((builder) => + builder + .withRemoteFeatureFlags({ + moneyEnableMoneyAccount: { + enabled: true, + minimumVersion: '0.0.0', + }, + moneyAccountGeoBlockedCountries: { blockedRegions: ['GB'] }, + }) + .withOverrides({ + ...walletStateOverrides, + engine: { + backgroundState: { + ...walletStateOverrides.engine.backgroundState, + GeolocationController: { + location: 'GB', + }, + }, + }, + } as unknown as Record), + ); + + expect(getByTestId(WalletViewSelectorsIDs.CARD_BUTTON)).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 569940b275a..56f5a457185 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -112,16 +112,49 @@ jest.mock('../../UI/NetworkConnectionBanner', () => () => null); // Control discovery tabs AB test variant per test (default control so existing tests are unaffected) let mockDiscoveryTabsVariantName = 'control'; +let mockDiscoveryPillsVariantName = 'control'; jest.mock('../../../hooks', () => ({ ...jest.requireActual('../../../hooks'), - useABTest: jest.fn(() => ({ - variantName: mockDiscoveryTabsVariantName, - variant: { - discoveryTabsEnabled: mockDiscoveryTabsVariantName === 'treatment', - }, - })), + useABTest: jest.fn((flagKey: string) => { + if (flagKey === 'homeTMCU926AbtestDiscoveryPills') { + const isGrayIcons = mockDiscoveryPillsVariantName === 'grayIcons'; + const isColorIcons = mockDiscoveryPillsVariantName === 'colorIcons'; + const showPills = isGrayIcons || isColorIcons; + + return { + variantName: mockDiscoveryPillsVariantName, + variant: { + showPills, + iconStyle: isGrayIcons ? 'gray' : isColorIcons ? 'color' : null, + }, + isActive: showPills, + }; + } + + return { + variantName: mockDiscoveryTabsVariantName, + variant: { + discoveryTabsEnabled: mockDiscoveryTabsVariantName === 'treatment', + }, + isActive: mockDiscoveryTabsVariantName === 'treatment', + }; + }), })); +const mockHomepageDiscoveryPills = jest.fn(); +jest.mock('../Homepage/components/HomepageDiscoveryPills', () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + HomepageDiscoveryPills: (props: { iconStyle: string }) => { + mockHomepageDiscoveryPills(props); + return React.createElement(View, { + testID: 'homepage-discovery-pills-mock', + }); + }, + }; +}); + // Track HomepageDiscoveryTabs renders const mockHomepageDiscoveryTabs = jest.fn(); const mockHomepageDiscoveryTabsRefresh = jest.fn(async () => undefined); @@ -1695,6 +1728,90 @@ describe('HomepageDiscoveryTabs AB test', () => { }); }); +describe('HomepageDiscoveryPills AB test', () => { + let mockNavigation: NavigationProp; + + beforeEach(() => { + jest.clearAllMocks(); + mockDiscoveryTabsVariantName = 'control'; + mockDiscoveryPillsVariantName = 'control'; + mockHomepageDiscoveryPills.mockClear(); + + mockNavigation = { + navigate: mockNavigate, + setOptions: mockSetOptions, + addListener: jest.fn(() => jest.fn()), + isFocused: jest.fn(() => false), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'stack' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'tab' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => undefined), + })), + })), + } as unknown as NavigationProp; + + jest + .mocked(useSelector) + .mockImplementation((callback: (state: unknown) => unknown) => + callback(mockInitialState), + ); + }); + + afterEach(() => { + mockDiscoveryTabsVariantName = 'control'; + mockDiscoveryPillsVariantName = 'control'; + jest.clearAllMocks(); + }); + + it('renders discovery pills on legacy homepage when grayIcons variant is active', () => { + mockDiscoveryPillsVariantName = 'grayIcons'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryPills).toHaveBeenCalledWith( + expect.objectContaining({ iconStyle: 'gray' }), + ); + }); + + it('does not render discovery pills when control variant is active', () => { + mockDiscoveryPillsVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryPills).not.toHaveBeenCalled(); + }); + + it('does not render discovery pills when discovery tabs treatment is active', () => { + mockDiscoveryPillsVariantName = 'grayIcons'; + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryPills).not.toHaveBeenCalled(); + }); +}); + describe('useHomeDeepLinkEffects', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 0a0a10b97ea..ad2b0086196 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -133,11 +133,16 @@ import Homepage from '../Homepage'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import HomepageDiscoveryTabs from '../Homepage/components/HomepageDiscoveryTabs'; import { + HOMEPAGE_DISCOVERY_PILLS_AB_KEY, + HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS, + HOMEPAGE_DISCOVERY_PILLS_VARIANTS, HUB_PAGE_DISCOVERY_TABS_AB_KEY, HUB_PAGE_DISCOVERY_TABS_VARIANTS, HubPageDiscoveryTabsVariant, // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog } from '../Homepage/abTestConfig'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { HomepageDiscoveryPills } from '../Homepage/components/HomepageDiscoveryPills'; import { useABTest } from '../../../hooks'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import { HomepageScrollContext } from '../Homepage/context/HomepageScrollContext'; @@ -783,9 +788,22 @@ const Wallet = ({ HUB_PAGE_DISCOVERY_TABS_VARIANTS, ); + const { variant: discoveryPillsVariant } = useABTest( + HOMEPAGE_DISCOVERY_PILLS_AB_KEY, + HOMEPAGE_DISCOVERY_PILLS_VARIANTS, + HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS, + ); + const isDiscoveryTabsTreatment = discoveryTabsVariantName === HubPageDiscoveryTabsVariant.Treatment; + const discoveryPillsIconStyle = discoveryPillsVariant.iconStyle; + const showDiscoveryPills = + discoveryPillsVariant.showPills && + !isDiscoveryTabsTreatment && + showWalletHomeMainActions && + discoveryPillsIconStyle !== null; + const isPerpsEnabled = isPerpsFlagEnabled; const homepageDiscoveryTabsRef = useRef(null); @@ -1039,6 +1057,10 @@ const Wallet = ({ /> ) : null; + const homepageDiscoveryPills = showDiscoveryPills ? ( + + ) : null; + const portfolioHeaderBase = ( {bannerContent} @@ -1047,6 +1069,7 @@ const Wallet = ({ {/* Hide growth banners when money account is enabled but user is geo-blocked */} {(!isMoneyAccountEnabled || isMoneyAccountGeoEligible) && homeGrowthBannerContent} + {homepageDiscoveryPills} {isMoneyAccountVisible && } ); @@ -1061,6 +1084,7 @@ const Wallet = ({ {/* Hide growth banners when money account is enabled but user is geo-blocked */} {(!isMoneyAccountEnabled || isMoneyAccountGeoEligible) && homeGrowthBannerContent} + {homepageDiscoveryPills} {isMoneyAccountVisible && } ); @@ -1131,10 +1155,12 @@ const Wallet = ({ } hitSlop={touchAreaSlop} /> - + {!isMoneyAccountVisible && ( + + )} {isNotificationsFeatureEnabled() ? ( ({ + onConfirm: mockOnLedgerConfirm, +})); jest.mock('./useLedgerConfirm', () => ({ - useLedgerConfirm: () => ({ onConfirm: mockOnLedgerConfirm }), + useLedgerConfirm: (options: unknown) => mockUseLedgerConfirm(options), })); const mockOnQrConfirm = jest.fn().mockResolvedValue(undefined); +const mockUseQrConfirm = jest.fn((_options?: unknown) => ({ + onConfirm: mockOnQrConfirm, +})); jest.mock('../../../../core/HardwareWallet/hooks/useQrConfirm', () => ({ - useQrConfirm: () => ({ onConfirm: mockOnQrConfirm }), + useQrConfirm: (options: unknown) => mockUseQrConfirm(options), })); const mockIsConfirmationFromQrAccount = jest.requireMock( @@ -95,6 +101,40 @@ describe('useConfirmAction', () => { }); }); + it('passes the confirmation from address to hardware wallet confirmation hooks', () => { + renderHookWithProvider(() => useConfirmActions(), { + state: personalSignatureConfirmationState, + }); + + expect(mockUseLedgerConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + fromAddress: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + }), + ); + expect(mockUseQrConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + fromAddress: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + }), + ); + }); + + it('passes the transaction metadata from address when approval request has no from address', () => { + renderHookWithProvider(() => useConfirmActions(), { + state: stakingDepositConfirmationState, + }); + + expect(mockUseLedgerConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + fromAddress: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + }), + ); + expect(mockUseQrConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + fromAddress: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164', + }), + ); + }); + it('sets signing confirmed and shows scanner when QR signing is in progress', async () => { const clearSecurityAlertResponseSpy = jest.spyOn( PPOMUtil, diff --git a/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts b/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts index bb33f91f150..94d60c149e7 100644 --- a/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts +++ b/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts @@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react-native'; import { useLedgerConfirm } from './useLedgerConfirm'; const mockEnsureDeviceReady = jest.fn(); +const mockSetPendingOperationAddress = jest.fn(); const mockShowAwaitingConfirmation = jest.fn(); const mockHideAwaitingConfirmation = jest.fn(); const mockShowHardwareWalletError = jest.fn(); @@ -10,6 +11,7 @@ const mockIsUserCancellation = jest.fn().mockReturnValue(false); jest.mock('../../../../core/HardwareWallet', () => ({ useHardwareWallet: () => ({ ensureDeviceReady: mockEnsureDeviceReady, + setPendingOperationAddress: mockSetPendingOperationAddress, showAwaitingConfirmation: mockShowAwaitingConfirmation, hideAwaitingConfirmation: mockHideAwaitingConfirmation, showHardwareWalletError: mockShowHardwareWalletError, @@ -49,6 +51,14 @@ describe('useLedgerConfirm', () => { }); expect(mockEnsureDeviceReady).toHaveBeenCalledWith('device-123'); + expect(mockSetPendingOperationAddress).toHaveBeenNthCalledWith( + 1, + defaultOptions.fromAddress, + ); + expect( + mockSetPendingOperationAddress.mock.invocationCallOrder[0], + ).toBeLessThan(mockEnsureDeviceReady.mock.invocationCallOrder[0]); + expect(mockSetPendingOperationAddress).toHaveBeenLastCalledWith(null); expect(mockGetDeviceIdForAddress).toHaveBeenCalledWith( defaultOptions.fromAddress, ); diff --git a/app/components/Views/confirmations/hooks/useLedgerConfirm.ts b/app/components/Views/confirmations/hooks/useLedgerConfirm.ts index 601e3668396..07b3b844aca 100644 --- a/app/components/Views/confirmations/hooks/useLedgerConfirm.ts +++ b/app/components/Views/confirmations/hooks/useLedgerConfirm.ts @@ -7,7 +7,7 @@ import { import { getDeviceIdForAddress } from '../../../../core/HardwareWallet/helpers'; interface UseLedgerConfirmOptions { - fromAddress?: string; + fromAddress: string; onReject: () => void; onTransactionConfirm: (opts?: { onError?: (err: unknown) => void; @@ -30,6 +30,7 @@ export function useLedgerConfirm({ }: UseLedgerConfirmOptions) { const { ensureDeviceReady, + setPendingOperationAddress, showHardwareWalletError, showAwaitingConfirmation, hideAwaitingConfirmation, @@ -46,8 +47,15 @@ export function useLedgerConfirm({ onReject(); }; + if (!fromAddress) { + rejectOnce(); + return; + } + + setPendingOperationAddress(fromAddress); + try { - const deviceId = await getDeviceIdForAddress(fromAddress ?? ''); + const deviceId = await getDeviceIdForAddress(fromAddress); const isReady = await ensureDeviceReady(deviceId); if (!isReady) { @@ -79,6 +87,8 @@ export function useLedgerConfirm({ } rejectOnce(); + } finally { + setPendingOperationAddress(null); } }, [ onReject, @@ -89,6 +99,7 @@ export function useLedgerConfirm({ showAwaitingConfirmation, hideAwaitingConfirmation, showHardwareWalletError, + setPendingOperationAddress, fromAddress, ]); diff --git a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts index 371f1a1702d..ee204870ec6 100644 --- a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts +++ b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts @@ -461,6 +461,40 @@ describe('useNetworkEnablement', () => { }); }); + describe('referential stability', () => { + it('returns a stable object and popularNetworks array across re-renders', () => { + // Stable selector results so only the hook's own memoization is exercised. + const enabledNetworks = { + eip155: { '0x1': true, '0x89': false }, + }; + const networkConfigs = { '0x1': {} }; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectEnabledNetworksByNamespace) { + return enabledNetworks; + } + if (selector === selectChainId) { + return '0x1'; + } + if (selector === selectIsEvmNetworkSelected) { + return true; + } + if (selector === selectNetworkConfigurations) { + return networkConfigs; + } + return undefined; + }); + + const { result, rerender } = renderHook(() => useNetworkEnablement()); + const first = result.current; + const firstPopularNetworks = result.current.popularNetworks; + + rerender({}); + + expect(result.current).toBe(first); + expect(result.current.popularNetworks).toBe(firstPopularNetworks); + }); + }); + describe('hook return values', () => { it('returns all expected properties', () => { const { result } = renderHook(() => useNetworkEnablement()); diff --git a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts index 5f7724dcafd..09942cedf23 100644 --- a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts +++ b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts @@ -13,7 +13,16 @@ import { toHex } from '@metamask/controller-utils'; import Engine from '../../../core/Engine'; import { selectEnabledNetworksByNamespace } from '../../../selectors/networkEnablementController'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; -import { selectChainId } from '../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; + +// Stable empty-array fallback shared by all three popular-network lists, so an +// empty result keeps a constant identity across renders. Typed `never[]` +// (the bottom array type) so it's assignable to each list's element type +// without widening the consumer-visible array types. +const EMPTY_ARRAY: never[] = []; /** * Manages network enablement state across namespaces (EVM, Bitcoin, etc). @@ -61,12 +70,31 @@ export const useNetworkEnablement = () => { [], ); - const popularEvmNetworksList = - networkEnablementController?.listPopularEvmNetworks?.() ?? []; - const popularMultichainNetworksList = - networkEnablementController?.listPopularMultichainNetworks?.() ?? []; - const popularNetworksList = - networkEnablementController?.listPopularNetworks?.() ?? []; + // The popular-network lists are derived from the configured networks (the + // controller filters POPULAR_NETWORKS by NetworkController / + // MultichainNetworkController state), so key them on the network + // configurations as well as the controller instance — this avoids both a + // fresh array on every render and serving a stale list when configs change. + // `selectNetworkConfigurations` is a deep-equal selector covering both EVM + // and non-EVM configs, so it only changes when the configs actually change. + const networkConfigurations = useSelector(selectNetworkConfigurations); + + const [ + popularEvmNetworksList, + popularMultichainNetworksList, + popularNetworksList, + ] = useMemo( + () => [ + networkEnablementController?.listPopularEvmNetworks?.() ?? EMPTY_ARRAY, + networkEnablementController?.listPopularMultichainNetworks?.() ?? + EMPTY_ARRAY, + networkEnablementController?.listPopularNetworks?.() ?? EMPTY_ARRAY, + ], + // `networkConfigurations` is not referenced directly, but the controller + // methods above read that state internally, so it must invalidate the memo. + // eslint-disable-next-line react-hooks/exhaustive-deps + [networkEnablementController, networkConfigurations], + ); const enabledNetworksForCurrentNamespace = useMemo( () => enabledNetworksByNamespace?.[namespace] || {}, @@ -141,20 +169,40 @@ export const useNetworkEnablement = () => { [isNetworkEnabled, enableNetwork], ); - return { - namespace, - enabledNetworksByNamespace, - enabledNetworksForCurrentNamespace, - enabledNetworksForAllNamespaces, - networkEnablementController, - enableNetwork, - disableNetwork, - enableAllPopularNetworks, - popularEvmNetworks: popularEvmNetworksList, - popularMultichainNetworks: popularMultichainNetworksList, - popularNetworks: popularNetworksList, - isNetworkEnabled, - hasOneEnabledNetwork, - tryEnableEvmNetwork, - }; + // Memoize the returned object so consumers (this hook is used in ~22 files, + // many feeding it into useMemo/useEffect deps) get a stable identity. + return useMemo( + () => ({ + namespace, + enabledNetworksByNamespace, + enabledNetworksForCurrentNamespace, + enabledNetworksForAllNamespaces, + networkEnablementController, + enableNetwork, + disableNetwork, + enableAllPopularNetworks, + popularEvmNetworks: popularEvmNetworksList, + popularMultichainNetworks: popularMultichainNetworksList, + popularNetworks: popularNetworksList, + isNetworkEnabled, + hasOneEnabledNetwork, + tryEnableEvmNetwork, + }), + [ + namespace, + enabledNetworksByNamespace, + enabledNetworksForCurrentNamespace, + enabledNetworksForAllNamespaces, + networkEnablementController, + enableNetwork, + disableNetwork, + enableAllPopularNetworks, + popularEvmNetworksList, + popularMultichainNetworksList, + popularNetworksList, + isNetworkEnabled, + hasOneEnabledNetwork, + tryEnableEvmNetwork, + ], + ); }; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 320d36dfcc3..43e635c628c 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -461,6 +461,7 @@ const Routes = { HOW_IT_WORKS: 'MoneyHowItWorks', POTENTIAL_EARNINGS: 'MoneyPotentialEarnings', ONBOARDING: 'MoneyOnboarding', + FIRST_TIME_DEPOSIT: 'MoneyFirstTimeDeposit', TRANSACTION_DETAILS: 'MoneyTransactionDetails', MODALS: { ROOT: 'MoneyModals', diff --git a/app/constants/navigation/exploreTabIndices.ts b/app/constants/navigation/exploreTabIndices.ts new file mode 100644 index 00000000000..6b35a09625d --- /dev/null +++ b/app/constants/navigation/exploreTabIndices.ts @@ -0,0 +1,11 @@ +export const EXPLORE_TAB_INDEX = { + NOW: 0, + MACRO: 1, + RWAS: 2, + CRYPTO: 3, + SPORTS: 4, + SITES: 5, +} as const; + +export type ExploreTabIndex = + (typeof EXPLORE_TAB_INDEX)[keyof typeof EXPLORE_TAB_INDEX]; diff --git a/app/constants/urls.test.ts b/app/constants/urls.test.ts index 9c0d124ccd9..e1e45c0c891 100644 --- a/app/constants/urls.test.ts +++ b/app/constants/urls.test.ts @@ -1,11 +1,11 @@ import { buildVipPrioritySupportUrl, METAMASK_SUPPORT_URL } from './urls'; describe('buildVipPrioritySupportUrl', () => { - it('appends VIP priority and account query params to the default support URL', () => { + it('appends VIP priority and address query params to the default support URL', () => { const account = '0xAbC0000000000000000000000000000000000123'; expect(buildVipPrioritySupportUrl(account)).toBe( - `${METAMASK_SUPPORT_URL}&priority=vip&account=${encodeURIComponent(account)}`, + `${METAMASK_SUPPORT_URL}&priority=vip&address=${encodeURIComponent(account)}`, ); }); @@ -13,7 +13,7 @@ describe('buildVipPrioritySupportUrl', () => { const baseUrl = 'https://support.metamask.io/'; expect(buildVipPrioritySupportUrl('0xabc', baseUrl)).toBe( - `${baseUrl}?priority=vip&account=0xabc`, + `${baseUrl}?priority=vip&address=0xabc`, ); }); @@ -21,17 +21,17 @@ describe('buildVipPrioritySupportUrl', () => { const baseUrl = 'https://support.metamask.io/?utm_source=mobile_app'; expect(buildVipPrioritySupportUrl('0xabc', baseUrl)).toBe( - `${baseUrl}&priority=vip&account=0xabc`, + `${baseUrl}&priority=vip&address=0xabc`, ); }); - it('URL-encodes the account address', () => { + it('URL-encodes the wallet address', () => { const account = '0xabc&foo=bar?baz'; expect( buildVipPrioritySupportUrl(account, 'https://support.example.com'), ).toBe( - `https://support.example.com?priority=vip&account=${encodeURIComponent(account)}`, + `https://support.example.com?priority=vip&address=${encodeURIComponent(account)}`, ); }); }); diff --git a/app/constants/urls.ts b/app/constants/urls.ts index 24c97eab113..ff2c4a39c90 100644 --- a/app/constants/urls.ts +++ b/app/constants/urls.ts @@ -59,17 +59,16 @@ export const REWARDS_LEARN_MORE_URL = `https://support.metamask.io/manage-crypto // Builds the priority-support help-center URL for VIP users. Includes a fixed // `priority=vip` tag so the support-side automation can fast-path the request to a -// human, plus the user's account address so that automation can verify enrollment via -// the existing support API (GET /support/subscriptions/check?account=, which keys off -// the account address and returns tier/subscription status). +// human, plus the user's wallet address so that automation can verify enrollment via +// the existing support API (GET /support/subscriptions/check?account=). // NOTE: `priority=vip` is a placeholder value pending support/Intercom confirmation; -// `account` matches the support endpoint's query-param name. +// `address` matches the help-center URL param name expected by support automation. export const buildVipPrioritySupportUrl = ( account: string, baseUrl: string = METAMASK_SUPPORT_URL, ) => { const separator = baseUrl.includes('?') ? '&' : '?'; - return `${baseUrl}${separator}priority=vip&account=${encodeURIComponent(account)}`; + return `${baseUrl}${separator}priority=vip&address=${encodeURIComponent(account)}`; }; // Perps diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx index 196d49eecdf..c190257bf3e 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useRef, useCallback, useEffect } from 'react'; import { StyleSheet } from 'react-native'; - +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import BottomSheet, { BottomSheetRef, } from '../../../../component-library/components/BottomSheets/BottomSheet'; @@ -270,15 +270,17 @@ export const HardwareWalletBottomSheet: React.FC< // eslint-disable-next-line @typescript-eslint/no-deprecated return ( - - {renderContent()} - + + + {renderContent()} + + ); }; diff --git a/app/core/HardwareWallet/helpers.ts b/app/core/HardwareWallet/helpers.ts index e48a8fb8e93..2b189a35d57 100644 --- a/app/core/HardwareWallet/helpers.ts +++ b/app/core/HardwareWallet/helpers.ts @@ -12,6 +12,8 @@ import { getDeviceId } from '../Ledger/Ledger'; const LEDGER_DEVICE_ID_LOOKUP_TIMEOUT_MS = 5000; +type NoEmptyAddress = T extends '' ? never : T; + const createLedgerDeviceIdLookupTimeoutError = () => new HardwareWalletError('Ledger device id lookup timed out', { code: ErrorCode.DeviceUnresponsive, @@ -88,9 +90,10 @@ export function getHardwareWalletTypeForAddress( * * Some hardware wallets, like Ledger over BLE, require a persisted device id * to reconnect. Others, like QR signers, do not. + * @param address - The wallet address to get the device id for. It cannot be an empty string. */ export async function getDeviceIdForAddress( - address: string, + address: NoEmptyAddress, // !This value cannot be an empty string. ): Promise { const walletType = getHardwareWalletTypeForAddress(address); diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.test.ts b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts index 8be8ac1efd7..4750249d2be 100644 --- a/app/core/HardwareWallet/hooks/useQrConfirm.test.ts +++ b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts @@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react-native'; const mockEnsureDeviceReady = jest.fn(); const mockSetTargetWalletType = jest.fn(); +const mockSetPendingOperationAddress = jest.fn(); const mockShowAwaitingConfirmation = jest.fn(); const mockHideAwaitingConfirmation = jest.fn(); const mockShowHardwareWalletError = jest.fn(); @@ -14,6 +15,7 @@ jest.mock('..', () => ({ useHardwareWallet: () => ({ ensureDeviceReady: mockEnsureDeviceReady, setTargetWalletType: mockSetTargetWalletType, + setPendingOperationAddress: mockSetPendingOperationAddress, showAwaitingConfirmation: mockShowAwaitingConfirmation, hideAwaitingConfirmation: mockHideAwaitingConfirmation, showHardwareWalletError: mockShowHardwareWalletError, @@ -23,24 +25,6 @@ jest.mock('..', () => ({ mockExecuteHardwareWalletOperation(...args), })); -const mockApprovalRequest = { requestData: { from: '0xTestAddress' } }; -const mockTransactionMetadata = { txParams: { from: '0xTestAddress' } }; - -jest.mock( - '../../../components/Views/confirmations/hooks/useApprovalRequest', - () => ({ - __esModule: true, - default: () => ({ approvalRequest: mockApprovalRequest }), - }), -); - -jest.mock( - '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest', - () => ({ - useTransactionMetadataRequest: () => mockTransactionMetadata, - }), -); - const mockIsSigningQRObject = { current: false }; jest.mock( @@ -62,6 +46,7 @@ describe('useQrConfirm', () => { const executeApproval = jest.fn().mockResolvedValue(undefined); const defaultOptions = { + fromAddress: '0xTestAddress', onReject, onTransactionConfirm, executeApproval, @@ -93,14 +78,9 @@ describe('useQrConfirm', () => { }); it('calls rejectOnce when no fromAddress is available', async () => { - const originalRequestData = mockApprovalRequest.requestData; - const originalTxParams = mockTransactionMetadata.txParams; - mockApprovalRequest.requestData = - {} as typeof mockApprovalRequest.requestData; - mockTransactionMetadata.txParams = - {} as typeof mockTransactionMetadata.txParams; - - const { result } = renderHook(() => useQrConfirm(defaultOptions)); + const { result } = renderHook(() => + useQrConfirm({ ...defaultOptions, fromAddress: '' }), + ); await act(async () => { await result.current.onConfirm(); @@ -108,9 +88,6 @@ describe('useQrConfirm', () => { expect(onReject).toHaveBeenCalledTimes(1); expect(mockExecuteHardwareWalletOperation).not.toHaveBeenCalled(); - - mockApprovalRequest.requestData = originalRequestData; - mockTransactionMetadata.txParams = originalTxParams; }); it('calls executeHardwareWalletOperation for message signing', async () => { @@ -124,6 +101,7 @@ describe('useQrConfirm', () => { expect.objectContaining({ address: '0xTestAddress', operationType: 'message', + setPendingOperationAddress: mockSetPendingOperationAddress, }), ); }); @@ -141,6 +119,7 @@ describe('useQrConfirm', () => { expect.objectContaining({ address: '0xTestAddress', operationType: 'transaction', + setPendingOperationAddress: mockSetPendingOperationAddress, }), ); }); @@ -230,13 +209,10 @@ describe('useQrConfirm', () => { expect(onReject).toHaveBeenCalledTimes(1); }); - it('uses from address from transaction metadata when approval request has no from', async () => { - const originalRequestData = mockApprovalRequest.requestData; - mockApprovalRequest.requestData = - {} as typeof mockApprovalRequest.requestData; - mockTransactionMetadata.txParams = { from: '0xTxMetaAddress' }; - - const { result } = renderHook(() => useQrConfirm(defaultOptions)); + it('uses the explicit from address option', async () => { + const { result } = renderHook(() => + useQrConfirm({ ...defaultOptions, fromAddress: '0xExplicitAddress' }), + ); await act(async () => { await result.current.onConfirm(); @@ -244,11 +220,9 @@ describe('useQrConfirm', () => { expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith( expect.objectContaining({ - address: '0xTxMetaAddress', + address: '0xExplicitAddress', }), ); - - mockApprovalRequest.requestData = originalRequestData; }); it('rethrows error from onTransactionConfirm onError callback', async () => { diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.ts b/app/core/HardwareWallet/hooks/useQrConfirm.ts index f41d9120afa..e96009ab13e 100644 --- a/app/core/HardwareWallet/hooks/useQrConfirm.ts +++ b/app/core/HardwareWallet/hooks/useQrConfirm.ts @@ -6,10 +6,9 @@ import { isUserCancellation, } from '..'; import { useQRHardwareContext } from '../../../components/Views/confirmations/context/qr-hardware-context'; -import useApprovalRequest from '../../../components/Views/confirmations/hooks/useApprovalRequest'; -import { useTransactionMetadataRequest } from '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; interface UseQrConfirmOptions { + fromAddress: string; onReject: () => void; onTransactionConfirm: (opts?: { onError?: (err: unknown) => void; @@ -28,6 +27,7 @@ interface UseQrConfirmOptions { * @returns An `onConfirm` callback for the confirmation submit action. */ export function useQrConfirm({ + fromAddress, onReject, onTransactionConfirm, executeApproval, @@ -35,6 +35,7 @@ export function useQrConfirm({ }: UseQrConfirmOptions) { const { ensureDeviceReady, + setPendingOperationAddress, showAwaitingConfirmation, hideAwaitingConfirmation, showHardwareWalletError, @@ -42,9 +43,6 @@ export function useQrConfirm({ const { isSigningQRObject } = useQRHardwareContext(); - const { approvalRequest } = useApprovalRequest(); - const transactionMetadata = useTransactionMetadataRequest(); - const hasRejectedRef = useRef(false); const executeQrConfirmation = useCallback(async () => { @@ -69,10 +67,6 @@ export function useQrConfirm({ onReject(); }; - const fromAddress = - (approvalRequest?.requestData?.from as string) || - (transactionMetadata?.txParams?.from as string); - if (!fromAddress) { rejectOnce(); return; @@ -96,6 +90,7 @@ export function useQrConfirm({ address: fromAddress, operationType: isTransactionReq ? 'transaction' : 'message', ensureDeviceReady, + setPendingOperationAddress, showAwaitingConfirmation, hideAwaitingConfirmation, showHardwareWalletError, @@ -109,13 +104,13 @@ export function useQrConfirm({ rejectOnce(); } }, [ - approvalRequest?.requestData?.from, - transactionMetadata?.txParams?.from, + fromAddress, isSigningQRObject, executeQrConfirmation, isTransactionReq, onReject, ensureDeviceReady, + setPendingOperationAddress, showAwaitingConfirmation, hideAwaitingConfirmation, showHardwareWalletError, diff --git a/app/core/SnapKeyring/MultichainWalletSnapClient.test.ts b/app/core/SnapKeyring/MultichainWalletSnapClient.test.ts deleted file mode 100644 index 004060b3fdd..00000000000 --- a/app/core/SnapKeyring/MultichainWalletSnapClient.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { - BitcoinWalletSnapClient, - MultichainWalletSnapClient, - MultichainWalletSnapFactory, - SolanaWalletSnapClient, - TronWalletSnapClient, - WalletClientType, -} from './MultichainWalletSnapClient'; -import { CaipChainId, SnapId } from '@metamask/snaps-sdk'; -import Engine from '../Engine'; -import { Sender } from '@metamask/keyring-snap-client'; -import { SnapKeyring } from '@metamask/eth-snap-keyring'; -import { SolScope } from '@metamask/keyring-api'; -import { BitcoinWalletSnapSender } from './BitcoinWalletSnap'; -import { SolanaWalletSnapSender } from './SolanaWalletSnap'; -import { TronWalletSnapSender } from './TronWalletSnap'; - -const mockSnapKeyring = { - createAccount: jest.fn(), - discoverAccounts: jest.fn(), -}; - -jest.mock('../Engine', () => ({ - controllerMessenger: { - call: jest.fn(), - }, - getSnapKeyring: () => mockSnapKeyring, - context: { - AccountsController: { - getNextAvailableAccountName: jest.fn().mockReturnValue('Snap Account 1'), - }, - }, -})); - -const createMockKeyringClient = () => ({ - listAccounts: jest.fn(), - getAccount: jest.fn(), - getAccountBalances: jest.fn(), - createAccount: jest.fn(), - discoverAccounts: jest.fn(), -}); - -let mockKeyringClient = createMockKeyringClient(); - -jest.mock('@metamask/keyring-snap-client', () => ({ - KeyringClient: jest.fn().mockImplementation(() => mockKeyringClient), - Sender: jest.fn(), -})); - -describe('MultichainWalletSnapClient', () => { - const mockSnapId = 'mock-snap-id' as SnapId; - const mockSnapName = 'mock-snap-name'; - const mockSnapKeyringOptions = { - displayConfirmation: false, - displayAccountNameSuggestion: false, - setSelectedAccount: false, - }; - - class TestMultichainWalletSnapClient extends MultichainWalletSnapClient { - constructor() { - super(mockSnapId, mockSnapName, mockSnapKeyringOptions); - } - - getScope(): CaipChainId { - return SolScope.Mainnet; - } - - protected getSnapSender(): Sender { - return {} as Sender; - } - - getClientType(): WalletClientType { - return WalletClientType.Solana; - } - - public testWithSnapKeyring( - callback: (keyring: SnapKeyring) => Promise, - ) { - return this.withSnapKeyring(callback); - } - } - - let client: TestMultichainWalletSnapClient; - - beforeEach(() => { - jest.clearAllMocks(); - mockKeyringClient = createMockKeyringClient(); - client = new TestMultichainWalletSnapClient(); - }); - - describe('getSnapId', () => { - it('should return the snap ID', () => { - expect(client.getSnapId()).toBe(mockSnapId); - }); - }); - - describe('getSnapName', () => { - it('should return the snap name', () => { - expect(client.getSnapName()).toBe(mockSnapName); - }); - }); - - describe('withSnapKeyring', () => { - it('calls the callback with the keyring', async () => { - const mockOptions = {}; - const mockCallback = async (keyring: SnapKeyring) => { - await keyring.createAccount( - mockSnapId, - mockOptions, - mockSnapKeyringOptions, - ); - }; - - await client.testWithSnapKeyring(mockCallback); - - expect(mockSnapKeyring.createAccount).toHaveBeenCalledWith( - mockSnapId, - mockOptions, - mockSnapKeyringOptions, - ); - }); - - it('handles errors from the controller messenger', async () => { - const mockError = new Error('Test error'); - const mockCallback = jest.fn().mockRejectedValueOnce(mockError); - - await expect(client.testWithSnapKeyring(mockCallback)).rejects.toThrow( - 'Test error', - ); - }); - }); - - describe('createAccount', () => { - it('creates an account with the provided options', async () => { - const mockOptions = { - scope: SolScope.Mainnet, - accountNameSuggestion: 'Solana Account 1', - entropySource: 'test-entropy', - }; - - await client.createAccount(mockOptions); - - expect(mockSnapKeyring.createAccount).toHaveBeenCalledWith( - mockSnapId, - mockOptions, - mockSnapKeyringOptions, - ); - }); - }); - - describe('discoverAccounts', () => { - it('discovers accounts with the provided parameters', async () => { - const mockScopes = [SolScope.Mainnet]; - const mockEntropySource = 'test-entropy'; - const mockGroupIndex = 0; - const mockDiscoveredAccounts = [ - { - type: 'bip44', - scope: [SolScope.Mainnet], - derivationPath: "m/44'/60'/0'/0", - }, - ]; - - mockKeyringClient.discoverAccounts.mockResolvedValueOnce( - mockDiscoveredAccounts, - ); - - const result = await client.discoverAccounts( - mockScopes, - mockEntropySource, - mockGroupIndex, - ); - - expect(result).toEqual(mockDiscoveredAccounts); - expect(mockKeyringClient.discoverAccounts).toHaveBeenCalledWith( - mockScopes, - mockEntropySource, - mockGroupIndex, - ); - }); - }); - - describe('addDiscoveredAccounts', () => { - it('adds discovered accounts to the keyring', async () => { - const expectAccountName = 'Solana Account '; - const mockEntropySource = 'test-entropy'; - const mockDiscoveredAccounts = [ - { - type: 'bip44', - scope: [SolScope.Mainnet], - derivationPath: "m/44'/60'/0'/0", - }, - ]; - - const mockKeyring = { - createAccount: jest.fn(), - }; - - mockKeyringClient.discoverAccounts - .mockResolvedValueOnce(mockDiscoveredAccounts) - .mockResolvedValueOnce([]); - - (Engine.controllerMessenger.call as jest.Mock).mockImplementation( - async (_, __, callback) => { - await callback({ keyring: mockKeyring }); - }, - ); - - await client.addDiscoveredAccounts(mockEntropySource, SolScope.Mainnet); - - expect(mockSnapKeyring.createAccount).toHaveBeenCalledTimes(1); - expect(mockSnapKeyring.createAccount).toHaveBeenCalledWith( - mockSnapId, - { - accountNameSuggestion: expectAccountName, - derivationPath: mockDiscoveredAccounts[0].derivationPath, - entropySource: mockEntropySource, - scope: SolScope.Mainnet, - }, - expect.objectContaining({ - displayConfirmation: false, - displayAccountNameSuggestion: false, - setSelectedAccount: false, - }), - ); - }); - }); -}); - -describe('MultichainWalletSnapFactory', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('creates a BitcoinWalletSnapClient', () => { - const bitcoinClient = MultichainWalletSnapFactory.createClient( - WalletClientType.Bitcoin, - ); - - expect(bitcoinClient).toBeInstanceOf(BitcoinWalletSnapClient); - }); - - it('creates a SolanaWalletSnapClient', () => { - const solanaClient = MultichainWalletSnapFactory.createClient( - WalletClientType.Solana, - ); - expect(solanaClient).toBeInstanceOf(SolanaWalletSnapClient); - }); - - it('creates a TronWalletSnapClient', () => { - const tronClient = MultichainWalletSnapFactory.createClient( - WalletClientType.Tron, - ); - expect(tronClient).toBeInstanceOf(TronWalletSnapClient); - }); - - it('throws if an invalid wallet type is provided', () => { - expect(() => - MultichainWalletSnapFactory.createClient('invalid' as WalletClientType), - ).toThrow('Unsupported client type: invalid'); - }); - - it('passes options to the client', () => { - const mockOptions = { - displayConfirmation: true, - displayAccountNameSuggestion: true, - setSelectedAccount: false, - }; - - const snapClient = MultichainWalletSnapFactory.createClient( - WalletClientType.Bitcoin, - mockOptions, - ); - - expect(snapClient.snapKeyringOptions).toStrictEqual(mockOptions); - }); -}); - -describe('Wallet Client Implementations', () => { - const mockSnapKeyringOptions = { - displayConfirmation: false, - displayAccountNameSuggestion: false, - setSelectedAccount: false, - }; - - class TestBitcoinWalletSnapClient extends BitcoinWalletSnapClient { - constructor() { - super(mockSnapKeyringOptions); - } - - public testGetSnapSender(): Sender { - return this.getSnapSender(); - } - } - - class TestSolanaWalletSnapClient extends SolanaWalletSnapClient { - constructor() { - super(mockSnapKeyringOptions); - } - - public testGetSnapSender(): Sender { - return this.getSnapSender(); - } - } - - class TestTronWalletSnapClient extends TronWalletSnapClient { - constructor() { - super(mockSnapKeyringOptions); - } - - public testGetSnapSender(): Sender { - return this.getSnapSender(); - } - } - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('BitcoinWalletSnapClient', () => { - it('should create a BitcoinWalletSnapClient', () => { - const bitcoinClient = new BitcoinWalletSnapClient(mockSnapKeyringOptions); - expect(bitcoinClient).toBeDefined(); - }); - - it('should return Bitcoin client type', () => { - const bitcoinClient = new BitcoinWalletSnapClient(mockSnapKeyringOptions); - expect(bitcoinClient.getClientType()).toBe(WalletClientType.Bitcoin); - }); - - it('should return a BitcoinWalletSnapSender instance', () => { - const bitcoinClient = new TestBitcoinWalletSnapClient(); - const sender = bitcoinClient.testGetSnapSender(); - expect(sender).toBeDefined(); - expect(sender).toBeInstanceOf(BitcoinWalletSnapSender); - }); - }); - - describe('SolanaWalletSnapClient', () => { - it('should create a SolanaWalletSnapClient', () => { - const solanaClient = new SolanaWalletSnapClient(mockSnapKeyringOptions); - expect(solanaClient).toBeDefined(); - }); - - it('should return Solana client type', () => { - const solanaClient = new SolanaWalletSnapClient(mockSnapKeyringOptions); - expect(solanaClient.getClientType()).toBe(WalletClientType.Solana); - }); - - it('should return a SolanaWalletSnapSender instance', () => { - const solanaClient = new TestSolanaWalletSnapClient(); - const sender = solanaClient.testGetSnapSender(); - expect(sender).toBeDefined(); - expect(sender).toBeInstanceOf(SolanaWalletSnapSender); - }); - }); - - describe('TronWalletSnapClient', () => { - it('should create a TronWalletSnapClient', () => { - const tronClient = new TronWalletSnapClient(mockSnapKeyringOptions); - expect(tronClient).toBeDefined(); - }); - - it('should return Tron client type', () => { - const tronClient = new TronWalletSnapClient(mockSnapKeyringOptions); - expect(tronClient.getClientType()).toBe(WalletClientType.Tron); - }); - - it('should return a TronWalletSnapSender instance', () => { - const tronClient = new TestTronWalletSnapClient(); - const sender = tronClient.testGetSnapSender(); - expect(sender).toBeDefined(); - expect(sender).toBeInstanceOf(TronWalletSnapSender); - }); - - it('should return the correct snap ID and name', () => { - const tronClient = new TronWalletSnapClient(mockSnapKeyringOptions); - expect(tronClient.getSnapId()).toBeDefined(); - expect(tronClient.getSnapName()).toBeDefined(); - }); - }); -}); diff --git a/app/core/SnapKeyring/MultichainWalletSnapClient.ts b/app/core/SnapKeyring/MultichainWalletSnapClient.ts deleted file mode 100644 index 58ae4afe716..00000000000 --- a/app/core/SnapKeyring/MultichainWalletSnapClient.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { CaipChainId, Json, SnapId } from '@metamask/snaps-sdk'; -import { KeyringClient, Sender } from '@metamask/keyring-snap-client'; -import { - ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) - BtcScope, - ///: END:ONLY_INCLUDE_IF - EntropySourceId, - ///: BEGIN:ONLY_INCLUDE_IF(solana) - SolScope, - ///: END:ONLY_INCLUDE_IF - ///: BEGIN:ONLY_INCLUDE_IF(tron) - TrxScope, - ///: END:ONLY_INCLUDE_IF -} from '@metamask/keyring-api'; -import { captureException } from '@sentry/react-native'; -import { - BITCOIN_WALLET_SNAP_ID, - BITCOIN_WALLET_NAME, - BitcoinWalletSnapSender, -} from './BitcoinWalletSnap'; -import { - SOLANA_WALLET_SNAP_ID, - SOLANA_WALLET_NAME, - SolanaWalletSnapSender, -} from './SolanaWalletSnap'; -///: BEGIN:ONLY_INCLUDE_IF(tron) -import { - TRON_WALLET_SNAP_ID, - TRON_WALLET_NAME, - TronWalletSnapSender, -} from './TronWalletSnap'; -///: END:ONLY_INCLUDE_IF -import Engine from '../Engine'; -import { SnapKeyring } from '@metamask/eth-snap-keyring'; -import { store } from '../../store'; -import { startPerformanceTrace } from '../redux/slices/performance'; -import { PerformanceEventNames } from '../redux/slices/performance/constants'; -import { getMultichainAccountName } from './utils/getMultichainAccountName'; -import { endTrace, trace, TraceName, TraceOperation } from '../../util/trace'; -import { getTraceTags } from '../../util/sentry/tags'; -import { - ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) - BITCOIN_DISCOVERY_PENDING, - ///: END:ONLY_INCLUDE_IF - ///: BEGIN:ONLY_INCLUDE_IF(solana) - SOLANA_DISCOVERY_PENDING, - ///: END:ONLY_INCLUDE_IF - ///: BEGIN:ONLY_INCLUDE_IF(tron) - TRON_DISCOVERY_PENDING, - ///: END:ONLY_INCLUDE_IF -} from '../../constants/storage'; - -export enum WalletClientType { - ///: BEGIN:ONLY_INCLUDE_IF(solana) - Solana = 'solana', - ///: END:ONLY_INCLUDE_IF - - ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) - Bitcoin = 'bitcoin', - ///: END:ONLY_INCLUDE_IF - - ///: BEGIN:ONLY_INCLUDE_IF(tron) - Tron = 'tron', - ///: END:ONLY_INCLUDE_IF -} - -export const WALLET_SNAP_MAP = { - ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) - [WalletClientType.Bitcoin]: { - id: BITCOIN_WALLET_SNAP_ID, - name: BITCOIN_WALLET_NAME, - discoveryScope: BtcScope.Mainnet, - discoveryStorageId: BITCOIN_DISCOVERY_PENDING, - }, - ///: END:ONLY_INCLUDE_IF - - ///: BEGIN:ONLY_INCLUDE_IF(solana) - [WalletClientType.Solana]: { - id: SOLANA_WALLET_SNAP_ID, - name: SOLANA_WALLET_NAME, - discoveryScope: SolScope.Mainnet, - discoveryStorageId: SOLANA_DISCOVERY_PENDING, - }, - ///: END:ONLY_INCLUDE_IF - - ///: BEGIN:ONLY_INCLUDE_IF(tron) - [WalletClientType.Tron]: { - id: TRON_WALLET_SNAP_ID, - name: TRON_WALLET_NAME, - discoveryScope: TrxScope.Mainnet, - discoveryStorageId: TRON_DISCOVERY_PENDING, - }, - ///: END:ONLY_INCLUDE_IF -}; - -export interface MultichainWalletSnapOptions { - scope?: CaipChainId; - synchronize?: boolean; - entropySource?: string; - accountNameSuggestion?: string; - derivationPath?: string; -} - -interface SnapKeyringOptions { - displayConfirmation: boolean; - displayAccountNameSuggestion: boolean; - setSelectedAccount: boolean; -} - -export abstract class MultichainWalletSnapClient { - readonly snapId: SnapId; - readonly snapName: string; - readonly snapKeyringOptions: SnapKeyringOptions; - - protected constructor( - snapId: SnapId, - snapName: string, - snapKeyringOptions: SnapKeyringOptions, - ) { - this.snapId = snapId; - this.snapName = snapName; - this.snapKeyringOptions = snapKeyringOptions; - } - - getSnapId(): SnapId { - return this.snapId; - } - - getSnapName(): string { - return this.snapName; - } - - abstract getClientType(): WalletClientType; - protected abstract getSnapSender(): Sender; - - /** - * Executes a callback function with a SnapKeyring instance. - * This method ensures proper initialization of the SnapKeyring and provides a safe way to interact with it. - * - * @param callback - An async function that receives a SnapKeyring instance and performs operations with it - * @returns A Promise that resolves when the callback execution is complete - * @throws Error if the SnapKeyring cannot be initialized or if the callback execution fails - * - */ - protected async withSnapKeyring( - callback: (keyring: SnapKeyring) => Promise, - ) { - const snapKeyring = (await Engine.getSnapKeyring()) as SnapKeyring; - - return await callback(snapKeyring); - } - - /** - * Starts sentry tracing. - * - * @param name Trace name. - * @param op Trace operation. - */ - private startTrace(name: TraceName, op: TraceOperation) { - trace({ - name, - op, - tags: { - 'snap.id': this.snapId, - ...getTraceTags(store.getState()), - }, - }); - } - - /** - * Ends sentry tracing. - * - * @param name Trace name. - */ - private endTrace(name: TraceName) { - endTrace({ - name, - }); - } - - /** - * Creates a new account using the SnapKeyring. - * This method wraps the account creation process with proper SnapKeyring initialization and error handling. - * - * @param options - Configuration options for creating the multichain wallet account - * @param snapKeyringOptions - Configuration options for the SnapKeyring - * @returns A Promise that resolves when the account creation is complete - * @throws Error if the account creation fails or if the SnapKeyring cannot be accessed - * - */ - async createAccount( - options: MultichainWalletSnapOptions, - snapKeyringOptions?: SnapKeyringOptions, - ) { - // This flow is async and start here, to end in the `SnapKeyring.addAccountFinalize` method. - store.dispatch( - startPerformanceTrace({ - eventName: PerformanceEventNames.AddSnapAccount, - }), - ); - - // Same here. - this.startTrace( - TraceName.CreateSnapAccount, - TraceOperation.CreateSnapAccount, - ); - - const accountName = - options?.accountNameSuggestion ?? - getMultichainAccountName(options.scope, this.getClientType()); - - return await this.withSnapKeyring( - async (keyring) => - await keyring.createAccount( - this.snapId, - { - ...options, - accountNameSuggestion: accountName, - } as unknown as Record, - snapKeyringOptions ?? this.snapKeyringOptions, - ), - ); - } - - /** - * Discovers accounts for the specified scopes using the provided entropy source. - * - * @param scopes - Array of CAIP-2 chain IDs to discover accounts for - * @param entropySource - The source of entropy to use for account discovery - * @param groupIndex - The index of the account group to discover - * @returns A Promise that resolves with the discovered accounts - * @throws Error if account discovery fails - */ - async discoverAccounts( - scopes: CaipChainId[], - entropySource: EntropySourceId, - groupIndex: number, - ) { - const keyringApiClient = new KeyringClient(this.getSnapSender()); - const accounts = await keyringApiClient.discoverAccounts( - scopes, - entropySource, - groupIndex, - ); - - return accounts; - } - /** - * Adds discovered accounts to the SnapKeyring. - * This method discovers accounts for the configured scopes and adds them to the keyring. - * - * @param entropySource - The source of entropy to use for account discovery - * @returns A Promise that resolves when all accounts have been added - * @throws Error if account discovery or addition fails - */ - /** - * Adds discovered accounts to the SnapKeyring. - * This method discovers accounts for the configured scopes and adds them to the keyring. - * - * @param entropySource - The source of entropy to use for account discovery - * @param scope - The CAIP-2 chain ID to discover accounts for - * @returns A Promise that resolves when all accounts have been added - * @throws Error if account discovery or addition fails - */ - async addDiscoveredAccounts( - entropySource: EntropySourceId, - scope: CaipChainId, - ) { - this.startTrace( - TraceName.SnapDiscoverAccounts, - TraceOperation.DiscoverAccounts, - ); - - let totalDiscoveredAccounts = 0; - - for (let index = 0; ; index++) { - const discoveredAccounts = await this.discoverAccounts( - [scope], - entropySource, - index, - ); - - // No accounts discovered - if (discoveredAccounts.length === 0) { - // For the first index, create a default account - if (index === 0) { - try { - await this.createAccount( - { - scope, - entropySource, - synchronize: false, - }, - { - displayConfirmation: false, - displayAccountNameSuggestion: false, - setSelectedAccount: false, - }, - ); - } catch (error) { - captureException(new Error(`Failed to create account ${error}`)); - } - } - // Stop discovering accounts when none are found - break; - } - - // Process discovered accounts sequentially - for (const account of discoveredAccounts) { - try { - await this.createAccount( - { - scope, - derivationPath: account.derivationPath, - entropySource, - }, - { - displayConfirmation: false, - displayAccountNameSuggestion: false, - setSelectedAccount: false, - }, - ); - totalDiscoveredAccounts += 1; - } catch (error) { - captureException(new Error(`Failed to create account ${error}`)); - } - } - } - - this.endTrace(TraceName.SnapDiscoverAccounts); - - return totalDiscoveredAccounts; - } -} - -export class BitcoinWalletSnapClient extends MultichainWalletSnapClient { - constructor(snapKeyringOptions: SnapKeyringOptions) { - super(BITCOIN_WALLET_SNAP_ID, BITCOIN_WALLET_NAME, snapKeyringOptions); - } - - getClientType(): WalletClientType { - return WalletClientType.Bitcoin; - } - - protected getSnapSender(): Sender { - return new BitcoinWalletSnapSender(); - } - - async createAccount( - options: MultichainWalletSnapOptions, - snapKeyringOptions?: SnapKeyringOptions, - ) { - return super.createAccount(options, snapKeyringOptions); - } -} - -export class SolanaWalletSnapClient extends MultichainWalletSnapClient { - constructor(snapKeyringOptions: SnapKeyringOptions) { - super(SOLANA_WALLET_SNAP_ID, SOLANA_WALLET_NAME, snapKeyringOptions); - } - - getClientType(): WalletClientType { - return WalletClientType.Solana; - } - - protected getSnapSender(): Sender { - return new SolanaWalletSnapSender(); - } -} - -///: BEGIN:ONLY_INCLUDE_IF(tron) -export class TronWalletSnapClient extends MultichainWalletSnapClient { - constructor(snapKeyringOptions: SnapKeyringOptions) { - super(TRON_WALLET_SNAP_ID, TRON_WALLET_NAME, snapKeyringOptions); - } - - getClientType(): WalletClientType { - return WalletClientType.Tron; - } - - protected getSnapSender(): Sender { - return new TronWalletSnapSender(); - } - - async createAccount( - options: MultichainWalletSnapOptions, - snapKeyringOptions?: SnapKeyringOptions, - ) { - return super.createAccount(options, snapKeyringOptions); - } -} -///: END:ONLY_INCLUDE_IF - -export class MultichainWalletSnapFactory { - private static defaultOptions: SnapKeyringOptions = { - displayConfirmation: false, - displayAccountNameSuggestion: false, - setSelectedAccount: true, - }; - - static createClient( - clientType: WalletClientType, - options: Partial = {}, - ): MultichainWalletSnapClient { - const snapKeyringOptions = { - ...this.defaultOptions, - ...options, - }; - - switch (clientType) { - case WalletClientType.Bitcoin: - return new BitcoinWalletSnapClient(snapKeyringOptions); - case WalletClientType.Solana: - return new SolanaWalletSnapClient(snapKeyringOptions); - ///: BEGIN:ONLY_INCLUDE_IF(tron) - case WalletClientType.Tron: - return new TronWalletSnapClient(snapKeyringOptions); - ///: END:ONLY_INCLUDE_IF - default: - throw new Error(`Unsupported client type: ${clientType}`); - } - } -} diff --git a/app/core/SnapKeyring/types.ts b/app/core/SnapKeyring/types.ts index 8bedfbd9507..6c50b1a9f2e 100644 --- a/app/core/SnapKeyring/types.ts +++ b/app/core/SnapKeyring/types.ts @@ -50,3 +50,17 @@ export type SnapKeyringBuilderMessenger = Messenger< SnapKeyringBuilderAllowActions, never >; + +export enum WalletClientType { + ///: BEGIN:ONLY_INCLUDE_IF(solana) + Solana = 'solana', + ///: END:ONLY_INCLUDE_IF + + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) + Bitcoin = 'bitcoin', + ///: END:ONLY_INCLUDE_IF + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + Tron = 'tron', + ///: END:ONLY_INCLUDE_IF +} diff --git a/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts b/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts index 4d30cc68349..e2f10fc0b19 100644 --- a/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts +++ b/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts @@ -1,6 +1,6 @@ import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; import { getMultichainAccountName } from './getMultichainAccountName'; -import { WalletClientType } from '../MultichainWalletSnapClient'; +import { WalletClientType } from '../types'; describe('getMultichainAccountName', () => { it('returns the next available account name if no scope or client type is provided', () => { diff --git a/app/core/SnapKeyring/utils/getMultichainAccountName.ts b/app/core/SnapKeyring/utils/getMultichainAccountName.ts index 77abb35db55..4a924deccfa 100644 --- a/app/core/SnapKeyring/utils/getMultichainAccountName.ts +++ b/app/core/SnapKeyring/utils/getMultichainAccountName.ts @@ -1,7 +1,7 @@ import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; import { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../locales/i18n'; -import { WalletClientType } from '../MultichainWalletSnapClient'; +import { WalletClientType } from '../types'; export function getMultichainAccountName( scope?: CaipChainId, diff --git a/app/core/Vault.test.ts b/app/core/Vault.test.ts index c0146cf4375..bb5b9c423c0 100644 --- a/app/core/Vault.test.ts +++ b/app/core/Vault.test.ts @@ -204,19 +204,6 @@ jest.mock('../util/trace', () => ({ endTrace: jest.fn(), })); -const mockMultichainWalletSnapClient = { - createAccount: jest.fn(), -}; - -jest.mock('./SnapKeyring/MultichainWalletSnapClient', () => ({ - ...jest.requireActual('./SnapKeyring/MultichainWalletSnapClient'), - MultichainWalletSnapFactory: { - createClient: jest - .fn() - .mockImplementation(() => mockMultichainWalletSnapClient), - }, -})); - describe('Vault', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts index 4869a402c85..db2e0fd764c 100644 --- a/app/core/redux/slices/bridge/index.ts +++ b/app/core/redux/slices/bridge/index.ts @@ -50,7 +50,7 @@ import { } from '../../../../selectors/assets/assets-migration'; import { selectCanSignTransactions } from '../../../../selectors/accountsController'; import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; -import { hasMinimumRequiredVersion } from './utils/hasMinimumRequiredVersion'; +import { hasMinimumRequiredVersion } from '../../../../util/remoteFeatureFlag'; import { Bip44TokensForDefaultPairs } from '../../../../components/UI/Bridge/constants/default-swap-dest-tokens'; import { normalizeTokenAddress } from '../../../../components/UI/Bridge/utils/tokenUtils'; import { isStockRwaBridgeToken } from '../../../../components/UI/Bridge/utils/isStockRwaBridgeToken'; diff --git a/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts b/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts index 770a6afae72..08b0f73a97c 100644 --- a/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts +++ b/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts @@ -31,7 +31,7 @@ jest.mock('../../../Engine/controllers/remote-feature-flag-controller', () => ({ })); // Avoid compareVersions path by forcing minimum version check to pass -jest.mock('./utils/hasMinimumRequiredVersion', () => ({ +jest.mock('../../../../util/remoteFeatureFlag', () => ({ hasMinimumRequiredVersion: () => true, })); diff --git a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.test.ts b/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.test.ts deleted file mode 100644 index f43a1c60532..00000000000 --- a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getVersion } from 'react-native-device-info'; -import { hasMinimumRequiredVersion } from './hasMinimumRequiredVersion'; - -jest.mock('react-native-device-info', () => ({ - getVersion: jest.fn(), -})); - -describe('hasMinimumRequiredVersion', () => { - const mockGetVersion = getVersion as jest.MockedFunction; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('when the env var is set to true', () => { - it('returns true when the version is greater than the required version', () => { - mockGetVersion.mockReturnValue('2.0.0'); - expect(hasMinimumRequiredVersion('1.0.0')).toBe(true); - }); - - it('returns false when the version is less than the required version', () => { - mockGetVersion.mockReturnValue('1.0.0'); - expect(hasMinimumRequiredVersion('2.0.0')).toBe(false); - }); - }); -}); diff --git a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.ts b/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.ts deleted file mode 100644 index 0340b8aa9d2..00000000000 --- a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.ts +++ /dev/null @@ -1,10 +0,0 @@ -import compareVersions from 'compare-versions'; -import { getVersion } from 'react-native-device-info'; - -export const hasMinimumRequiredVersion = ( - minRequiredVersion: string | undefined, -) => { - if (!minRequiredVersion) return false; - const currentVersion = getVersion(); - return compareVersions.compare(currentVersion, minRequiredVersion, '>='); -}; diff --git a/app/images/discovery_pill_crypto.png b/app/images/discovery_pill_crypto.png new file mode 100644 index 00000000000..26a5d661895 Binary files /dev/null and b/app/images/discovery_pill_crypto.png differ diff --git a/app/images/discovery_pill_perps.png b/app/images/discovery_pill_perps.png new file mode 100644 index 00000000000..fff09a8a5e7 Binary files /dev/null and b/app/images/discovery_pill_perps.png differ diff --git a/app/images/discovery_pill_predictions.png b/app/images/discovery_pill_predictions.png new file mode 100644 index 00000000000..ba16dca9683 Binary files /dev/null and b/app/images/discovery_pill_predictions.png differ diff --git a/app/images/discovery_pill_stocks.png b/app/images/discovery_pill_stocks.png new file mode 100644 index 00000000000..3de401f30d2 Binary files /dev/null and b/app/images/discovery_pill_stocks.png differ diff --git a/app/selectors/cardController.test.ts b/app/selectors/cardController.test.ts index dbaae6a6d22..8696f804ef5 100644 --- a/app/selectors/cardController.test.ts +++ b/app/selectors/cardController.test.ts @@ -1498,8 +1498,8 @@ describe('selectCardResidencyRegion', () => { }); describe('selectIsCardResidencyBlocked', () => { - const musdBlockedFlags = (blockedRegions: string[]) => ({ - earnMusdConversionGeoBlockedCountries: { blockedRegions }, + const moneyBlockedFlags = (blockedRegions: string[]) => ({ + moneyAccountGeoBlockedCountries: { blockedRegions }, }); it('returns false when countryOfResidence is null (fail-open)', () => { @@ -1526,7 +1526,7 @@ describe('selectIsCardResidencyBlocked', () => { } as unknown as CardControllerState['cardHomeData'], }, undefined, - musdBlockedFlags(['GB']), + moneyBlockedFlags(['GB']), ); expect(selectIsCardResidencyBlocked(state)).toBe(true); }); @@ -1547,7 +1547,7 @@ describe('selectIsCardResidencyBlocked', () => { } as unknown as CardControllerState['cardHomeData'], }, undefined, - musdBlockedFlags(['GB']), + moneyBlockedFlags(['GB']), ); expect(selectIsCardResidencyBlocked(state)).toBe(false); }); @@ -1568,7 +1568,7 @@ describe('selectIsCardResidencyBlocked', () => { } as unknown as CardControllerState['cardHomeData'], }, undefined, - musdBlockedFlags(['US-CA']), + moneyBlockedFlags(['US-CA']), ); expect(selectIsCardResidencyBlocked(state)).toBe(true); }); @@ -1589,7 +1589,7 @@ describe('selectIsCardResidencyBlocked', () => { } as unknown as CardControllerState['cardHomeData'], }, undefined, - musdBlockedFlags(['US-CA']), + moneyBlockedFlags(['US-CA']), ); expect(selectIsCardResidencyBlocked(state)).toBe(false); }); @@ -1610,7 +1610,7 @@ describe('selectIsCardResidencyBlocked', () => { } as unknown as CardControllerState['cardHomeData'], }, undefined, - musdBlockedFlags(['US']), + moneyBlockedFlags(['US']), ); expect(selectIsCardResidencyBlocked(state)).toBe(true); }); @@ -1618,8 +1618,8 @@ describe('selectIsCardResidencyBlocked', () => { describe('selectCardAvailableTokens residency blocking', () => { const WALLET_A = '0xwalletA000000000000000000000000000000001'; - const musdBlockedFlags = (blockedRegions: string[]) => ({ - earnMusdConversionGeoBlockedCountries: { blockedRegions }, + const moneyBlockedFlags = (blockedRegions: string[]) => ({ + moneyAccountGeoBlockedCountries: { blockedRegions }, }); it('suppresses Money Account placeholder entries when residency is blocked', () => { @@ -1645,7 +1645,7 @@ describe('selectCardAvailableTokens residency blocking', () => { homeData as unknown as CardControllerState['cardHomeData'], }, MONAD_VEDA_FEATURE_FLAG, - musdBlockedFlags(['GB']), + moneyBlockedFlags(['GB']), ); expect(selectCardAvailableTokens(state)).toStrictEqual([]); }); @@ -1687,7 +1687,7 @@ describe('selectCardAvailableTokens residency blocking', () => { homeData as unknown as CardControllerState['cardHomeData'], }, MONAD_VEDA_FEATURE_FLAG, - musdBlockedFlags(['GB']), + moneyBlockedFlags(['GB']), ); const tokens = selectCardAvailableTokens(state); expect(tokens).toHaveLength(1); diff --git a/app/selectors/cardController.ts b/app/selectors/cardController.ts index a5f3074458c..6ce6bf5e414 100644 --- a/app/selectors/cardController.ts +++ b/app/selectors/cardController.ts @@ -30,7 +30,7 @@ import { isEthAccount } from '../core/Multichain/utils'; import { isMoneyAccountDelegatedForCard } from '../core/Engine/controllers/card-controller/utils/moneyAccountCardToken'; import { selectPrimaryMoneyAccount } from './moneyAccountController'; import { selectCardFeatureFlag } from './featureFlagController/card'; -import { selectMusdConversionBlockedCountries } from '../components/UI/Earn/selectors/featureFlags'; +import { selectMoneyAccountGeoBlockedCountries } from '../components/UI/Money/selectors/featureFlags'; import { buildCardResidencyRegion, isCardResidencyInBlockedRegions, @@ -182,7 +182,7 @@ export const selectCardResidencyRegion = createSelector( ); const selectCardResidencyBlockedRegions = (state: RootState): string[] => - selectMusdConversionBlockedCountries(state); + selectMoneyAccountGeoBlockedCountries(state); export const selectIsCardResidencyBlocked = createSelector( selectCardResidencyRegion, diff --git a/app/util/analytics/abTestAnalyticsRegistry.ts b/app/util/analytics/abTestAnalyticsRegistry.ts index 76c48d7c4e4..7db332161c2 100644 --- a/app/util/analytics/abTestAnalyticsRegistry.ts +++ b/app/util/analytics/abTestAnalyticsRegistry.ts @@ -4,6 +4,7 @@ import { NUMPAD_QUICK_ACTIONS_AB_TEST_ANALYTICS_MAPPING } from '../../components import { POST_TRADE_MODAL_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/PostTradeBottomSheet/abTestConfig'; import { TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/TokenSelectorItem.abTestConfig'; import { + HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING, HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING, HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING, HUB_PAGE_DISCOVERY_TABS_AB_TEST_ANALYTICS_MAPPING, @@ -24,6 +25,7 @@ export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [ TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING, // Homepage + HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING, HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING, HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING, HUB_PAGE_DISCOVERY_TABS_AB_TEST_ANALYTICS_MAPPING, diff --git a/locales/languages/de.json b/locales/languages/de.json index 090da3370dd..a38d0fcc82e 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Punkte aus Empfehlungen", "referrals_label": "VIP-Empfehlungen", "tier_benefits_title": "Vorteile der Stufe", - "splash_title": "WILLKOMMEN\nBEIM GOLD-\nFOX-VIP", + "splash_title": "WILLKOMMEN\nBEIM GOLD FOX\nCOLLECTIVE", "splash_description": "Schalten Sie exklusive Vorteile, frühe Funktionen und besondere Belohnungen frei. Nur auf Einladung.", "splash_accept_invite": "Einladung annehmen", "splash_not_now": "Nicht jetzt", @@ -8842,17 +8842,14 @@ "tiers_title": "Stufen", "tier_thresholds": "{{points}} Punkte", "bps_value": "{{bps}} Basispunkte", - "referee_splash_title": "WILLKOMMEN\nBEIM GOLD-\nFOX-VIP", - "referee_splash_description": "Schalten Sie exklusive Vorteile, frühe Funktionen und besondere Belohnungen frei. Nur auf Einladung.", "referee_splash_continue": "Fortfahren", - "referee_splash_not_now": "Nicht jetzt", "referee_referred_by": "Empfohlen von {{code}}", "referee_page_title": "VIP-Pilot", "referee_stats_title": "Statistiken", "referee_period_last_30d": "Letzten 30 Tage", - "referee_points_label": "Punkte", "referee_swaps_volume_label": "Swap-Volumen", "referee_perps_volume_label": "Perps-Volumen", + "referee_points_to_label": "Punkte für {{code}}", "referee_error_title": "Wir konnten Ihre Daten zur VIP-Referenzperson nicht laden", "referee_error_description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "last_updated": "Zuletzt aktualisiert: {{time}}" diff --git a/locales/languages/el.json b/locales/languages/el.json index b98551668a0..91394a1ac2c 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Πόντοι από παραπομπές", "referrals_label": "VIP παραπομπές", "tier_benefits_title": "Οφέλη επιπέδου", - "splash_title": "ΚΑΛΩΣ ΗΡΘΑΤΕ\nΣΤΟ GOLD\nFOX VIP", + "splash_title": "ΚΑΛΩΣ ΗΡΘΑΤΕ\nΣΤΟ GOLD FOX\nCOLLECTIVE", "splash_description": "Ξεκλειδώστε αποκλειστικά προνόμια, πρώιμη πρόσβαση σε λειτουργίες και επιλεγμένες ανταμοιβές. Μόνο με πρόσκληση.", "splash_accept_invite": "Αποδοχή πρόσκλησης", "splash_not_now": "Όχι τώρα", @@ -8842,17 +8842,14 @@ "tiers_title": "Επίπεδα", "tier_thresholds": "{{points}} πόντοι", "bps_value": "{{bps}} bps", - "referee_splash_title": "ΚΑΛΩΣ ΗΡΘΑΤΕ\nΣΤΟ GOLD\nFOX VIP", - "referee_splash_description": "Ξεκλειδώστε αποκλειστικά προνόμια, πρώιμη πρόσβαση σε λειτουργίες και επιλεγμένες ανταμοιβές. Μόνο με πρόσκληση.", "referee_splash_continue": "Συνεχίστε", - "referee_splash_not_now": "Όχι τώρα", "referee_referred_by": "Παραπομπή από {{code}}", "referee_page_title": "VIP δοκιμαστικό πρόγραμμα", "referee_stats_title": "Στατιστικά", "referee_period_last_30d": "Τελευταίες 30 ημέρες", - "referee_points_label": "Πόντοι", "referee_swaps_volume_label": "Όγκος ανταλλαγών", "referee_perps_volume_label": "Όγκος perps", + "referee_points_to_label": "Πόντοι προς {{code}}", "referee_error_title": "Δεν ήταν δυνατή η φόρτωση των στοιχείων της VIP παραπομπής σας", "referee_error_description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά.", "last_updated": "Τελευταία ενημέρωση: {{time}}" diff --git a/locales/languages/en.json b/locales/languages/en.json index 724fa475ec7..bfbb93252ce 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1074,9 +1074,9 @@ "trader_profile": { "followers_count": "{{count}} follower", "followers_count_plural": "{{count}} followers", - "win_rate": "win rate", - "pnl_7d": "7D Return", - "hold_time": "hold time", + "win_rate": "Win rate", + "pnl_7d": "7D P&L", + "hold_time": "Avg. hold", "hold_time_minutes": "{{count}}m", "hold_time_hours": "{{count}} hrs", "hold_time_days": "{{count}} days", @@ -1098,8 +1098,8 @@ "trades": "Trades", "buy": "Buy", "trade": "Trade", - "bought": "{{name}} bought", - "sold": "{{name}} sold", + "bought": "Bought", + "sold": "Sold", "no_trades": "No trades yet", "closed_position": "Closed position", "fallback_title": "Position not available", @@ -1109,8 +1109,8 @@ "sell": "Sell", "long": "Long", "short": "Short", - "opened": "{{name}} opened", - "closed_action": "{{name}} closed" + "opened": "Opened", + "closed_action": "Closed" }, "quick_buy": { "title": "Buy {{symbol}}", @@ -7020,6 +7020,10 @@ } } }, + "first_time_deposit": { + "title": "First deposit, done", + "content": "Your funds are on the way to your account and will start earning soon." + }, "rive_onboarding": { "step1_title": "Money accounts are here", "step1_body": "Earn up to {{percentage}}% APY on your balance, available across your entire wallet.", @@ -8704,6 +8708,11 @@ "title": "Set up Linea funding", "description": "You need at least one approved funding source on Linea before redeeming mUSD back.", "confirm_button_label": "Set up funding" + }, + "money_account_required": { + "title": "Set up Money Account", + "description": "You need a linked Money Account before redeeming mUSD back.", + "confirm_button_label": "Set up Money Account" } }, "change_asset": { @@ -8927,7 +8936,7 @@ "points_from_referrals_label": "Points from referrals", "referrals_label": "VIP Referrals", "tier_benefits_title": "Tier benefits", - "splash_title": "WELCOME\nTO GOLD\nFOX VIP", + "splash_title": "WELCOME\nTO GOLD FOX\nCOLLECTIVE", "splash_description": "Unlock exclusive perks, early features, and curated rewards. By invitation only.", "splash_accept_invite": "Accept invite", "splash_not_now": "Not now", @@ -8942,17 +8951,14 @@ "tiers_title": "Tiers", "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} bps", - "referee_splash_title": "WELCOME\nTO GOLD\nFOX VIP", - "referee_splash_description": "Unlock exclusive perks, early features, and curated rewards. By invitation only.", "referee_splash_continue": "Continue", - "referee_splash_not_now": "Not now", "referee_referred_by": "Referred by {{code}}", "referee_page_title": "VIP Pilot", "referee_stats_title": "Stats", "referee_period_last_30d": "Last 30d", - "referee_points_label": "Points", "referee_swaps_volume_label": "Swaps volume", "referee_perps_volume_label": "Perps volume", + "referee_points_to_label": "Points to {{code}}", "referee_error_title": "We couldn’t load your VIP referee details", "referee_error_description": "Check your connection and try again.", "referee_contact_support": "Contact support", diff --git a/locales/languages/es.json b/locales/languages/es.json index 84630cf2f4b..575b2278b2d 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Puntos por referidos", "referrals_label": "Referidos VIP", "tier_benefits_title": "Beneficios del nivel", - "splash_title": "BIENVENIDO\nA GOLD\nFOX VIP", + "splash_title": "BIENVENIDO\nA GOLD FOX\nCOLLECTIVE", "splash_description": "Desbloquea ventajas exclusivas, funciones anticipadas y recompensas seleccionadas. Solo por invitación.", "splash_accept_invite": "Aceptar invitación", "splash_not_now": "Ahora no", @@ -8842,17 +8842,14 @@ "tiers_title": "Niveles", "tier_thresholds": "{{points}} puntos", "bps_value": "{{bps}} bps", - "referee_splash_title": "BIENVENIDO\nA GOLD\nFOX VIP", - "referee_splash_description": "Desbloquea ventajas exclusivas, funciones anticipadas y recompensas seleccionadas. Solo por invitación.", "referee_splash_continue": "Continuar", - "referee_splash_not_now": "Ahora no", "referee_referred_by": "Recomendado por {{code}}", "referee_page_title": "Piloto VIP", "referee_stats_title": "Estadísticas", "referee_period_last_30d": "Últimos 30 días", - "referee_points_label": "Puntos", "referee_swaps_volume_label": "Volumen de canjes", "referee_perps_volume_label": "Volumen de Perps", + "referee_points_to_label": "Puntos para {{code}}", "referee_error_title": "No pudimos cargar los datos de tu referente VIP", "referee_error_description": "Comprueba tu conexión y vuelve a intentarlo.", "last_updated": "Última actualización: {{time}}" diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 616ce346227..54d404cf2cb 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Points de parrainages", "referrals_label": "Parrainages VIP", "tier_benefits_title": "Avantages par niveau", - "splash_title": "BIENVENUE\nDANS GOLD\nFOX VIP", + "splash_title": "BIENVENUE\nDANS GOLD FOX\nCOLLECTIVE", "splash_description": "Bénéficiez d’avantages exclusifs, de fonctionnalités en avant-première et de récompenses privilégiées. Sur invitation uniquement.", "splash_accept_invite": "Accepter l’invite", "splash_not_now": "Pas maintenant", @@ -8842,17 +8842,14 @@ "tiers_title": "Niveaux", "tier_thresholds": "{{points}} points", "bps_value": "{{bps}} points de base", - "referee_splash_title": "BIENVENUE\nDANS GOLD\nFOX VIP", - "referee_splash_description": "Bénéficiez d’avantages exclusifs, de fonctionnalités en avant-première et de récompenses privilégiées. Sur invitation uniquement.", "referee_splash_continue": "Continuer", - "referee_splash_not_now": "Pas maintenant", "referee_referred_by": "Parrainé par {{code}}", "referee_page_title": "VIP Pilot", "referee_stats_title": "Statistiques", "referee_period_last_30d": "30 derniers jours", - "referee_points_label": "Points", "referee_swaps_volume_label": "Volume des swaps", "referee_perps_volume_label": "Volume des perps", + "referee_points_to_label": "Points pour {{code}}", "referee_error_title": "Nous n’avons pas pu charger les informations de votre parrain VIP", "referee_error_description": "Vérifiez votre connexion et réessayez.", "last_updated": "Dernière mise à jour : {{time}}" diff --git a/locales/languages/hi.json b/locales/languages/hi.json index a3a19bb8824..2f5b15c5edb 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "रेफरल से पॉइंट्स", "referrals_label": "वीआईपी रेफरल", "tier_benefits_title": "टियर लाभ", - "splash_title": "गोल्ड\nफॉक्स वीआईपी में\nआपका स्वागत है", + "splash_title": "गोल्ड फॉक्स\nCOLLECTIVE में\nआपका स्वागत है", "splash_description": "विशेष लाभ, नए फीचर्स तक जल्दी पहुँच और चुने हुए रिवॉर्ड्स अनलॉक करें। केवल आमंत्रण द्वारा।", "splash_accept_invite": "आमंत्रण स्वीकार करें", "splash_not_now": "अभी नहीं", @@ -8842,17 +8842,14 @@ "tiers_title": "टियर्स", "tier_thresholds": "{{points}} पॉइंट्स", "bps_value": "{{bps}} बीपीएस", - "referee_splash_title": "गोल्ड\nफॉक्स वीआईपी में\nआपका स्वागत है", - "referee_splash_description": "विशेष लाभ, नए फीचर्स तक जल्दी पहुँच और चुने हुए रिवॉर्ड्स अनलॉक करें। केवल आमंत्रण द्वारा।", "referee_splash_continue": "जारी रखें", - "referee_splash_not_now": "अभी नहीं", "referee_referred_by": "{{code}} द्वारा रेफर किया गया", "referee_page_title": "VIP पायलट", "referee_stats_title": "आँकड़े", "referee_period_last_30d": "पिछले 30 दिन", - "referee_points_label": "पॉइंट्स", "referee_swaps_volume_label": "स्वैप्स वॉल्यूम", "referee_perps_volume_label": "पर्प्स वॉल्यूम", + "referee_points_to_label": "{{code}} के लिए पॉइंट्स", "referee_error_title": "हम आपके VIP रेफरी की जानकारी लोड नहीं कर सके", "referee_error_description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।", "last_updated": "पिछला अपडेट इस समय पर किया गया: {{time}}" diff --git a/locales/languages/id.json b/locales/languages/id.json index 0d34fa0b793..fdeff4df6f6 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Poin dari referensi", "referrals_label": "Referensi VIP", "tier_benefits_title": "Manfaat tingkatan", - "splash_title": "SELAMAT DATANG\nDI GOLD\nFOX VIP", + "splash_title": "SELAMAT DATANG\nDI GOLD FOX\nCOLLECTIVE", "splash_description": "Dapatkan keuntungan eksklusif, fitur-fitur awal, dan reward pilihan. Hanya dengan undangan.", "splash_accept_invite": "Terima undangan", "splash_not_now": "Tidak sekarang", @@ -8842,17 +8842,14 @@ "tiers_title": "Tingkatan", "tier_thresholds": "{{points}} poin", "bps_value": "{{bps}} bps", - "referee_splash_title": "SELAMAT DATANG\nDI GOLD\nFOX VIP", - "referee_splash_description": "Dapatkan keuntungan eksklusif, fitur-fitur awal, dan reward pilihan. Hanya dengan undangan.", "referee_splash_continue": "Lanjutkan", - "referee_splash_not_now": "Tidak sekarang", "referee_referred_by": "Direferensikan oleh {{code}}", "referee_page_title": "Pilot VIP", "referee_stats_title": "Statistik", "referee_period_last_30d": "30 hari terakhir", - "referee_points_label": "Poin", "referee_swaps_volume_label": "Volume swap", "referee_perps_volume_label": "Volume perp", + "referee_points_to_label": "Poin untuk {{code}}", "referee_error_title": "Kami tidak dapat memuat detail perujuk VIP Anda", "referee_error_description": "Periksa koneksi Anda dan coba lagi.", "last_updated": "Terakhir diperbarui: {{time}}" diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 836747ed120..d05b6da097d 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "紹介からのポイント", "referrals_label": "VIP紹介", "tier_benefits_title": "レベル特典", - "splash_title": "GOLD FOX\nVIPへ\nようこそ", + "splash_title": "GOLD FOX\nCOLLECTIVEへ\nようこそ", "splash_description": "限定特典、先行機能、キュレートされたリワードのロックを解除。招待制。", "splash_accept_invite": "招待を受け入れる", "splash_not_now": "後で", @@ -8842,17 +8842,14 @@ "tiers_title": "レベル", "tier_thresholds": "{{points}}ポイント", "bps_value": "{{bps}}bps", - "referee_splash_title": "GOLD FOX\nVIPへ\nようこそ", - "referee_splash_description": "限定特典、先行機能、キュレートされたリワードのロックを解除。招待制。", "referee_splash_continue": "続行", - "referee_splash_not_now": "後で", "referee_referred_by": "Referred by {{code}}", "referee_page_title": "VIP Pilot", "referee_stats_title": "統計", "referee_period_last_30d": "Last 30d", - "referee_points_label": "ポイント", "referee_swaps_volume_label": "Swaps volume", "referee_perps_volume_label": "Perps volume", + "referee_points_to_label": "{{code}}へのポイント", "referee_error_title": "We couldn’t load your VIP referee details", "referee_error_description": "接続を確認して、もう一度お試しください。", "last_updated": "最終更新時刻: {{time}}" diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 6eb62c72c20..7edbc40b2f0 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "추천으로 얻은 포인트", "referrals_label": "VIP 추천", "tier_benefits_title": "티어 혜택", - "splash_title": "골드 폭스 VIP에\n오신 것을\n환영합니다", + "splash_title": "골드 폭스 COLLECTIVE에\n오신 것을\n환영합니다", "splash_description": "독점적 혜택, 우선 이용 가능한 기능, 엄선된 보상을 만나보세요. 초대받은 사용자만 이용할 수 있습니다.", "splash_accept_invite": "초대 수락", "splash_not_now": "나중에", @@ -8842,17 +8842,14 @@ "tiers_title": "티어", "tier_thresholds": "{{points}}포인트", "bps_value": "{{bps}} bps", - "referee_splash_title": "골드 폭스 VIP에\n오신 것을\n환영합니다", - "referee_splash_description": "독점적 혜택, 우선 이용 가능한 기능, 엄선된 보상을 만나보세요. 초대받은 사용자만 이용할 수 있습니다.", "referee_splash_continue": "계속", - "referee_splash_not_now": "나중에", "referee_referred_by": "추천 코드: {{code}}", "referee_page_title": "VIP 파일럿", "referee_stats_title": "통계", "referee_period_last_30d": "최근 30일", - "referee_points_label": "포인트", "referee_swaps_volume_label": "스왑 거래량", "referee_perps_volume_label": "무기한 선물 거래량", + "referee_points_to_label": "{{code}}에 대한 포인트", "referee_error_title": "추천받은 VIP 사용자 정보를 불러올 수 없습니다", "referee_error_description": "연결 상태를 확인하고 다시 시도하세요.", "last_updated": "마지막 업데이트: {{time}}" diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 182a987839f..16fddaf80f9 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Pontos de indicações", "referrals_label": "Indicações VIP", "tier_benefits_title": "Benefícios de nível", - "splash_title": "BOAS-VINDAS\nAO GOLD\nFOX VIP", + "splash_title": "BOAS-VINDAS\nAO GOLD FOX\nCOLLECTIVE", "splash_description": "Desbloqueie vantagens exclusivas, acesso antecipado a recursos e recompensas selecionadas. Somente por convite.", "splash_accept_invite": "Aceitar convite", "splash_not_now": "Agora não", @@ -8842,17 +8842,14 @@ "tiers_title": "Níveis", "tier_thresholds": "{{points}} pontos", "bps_value": "{{bps}} bps", - "referee_splash_title": "BOAS-VINDAS\nAO GOLD\nFOX VIP", - "referee_splash_description": "Desbloqueie vantagens exclusivas, acesso antecipado a recursos e recompensas selecionadas. Somente por convite.", "referee_splash_continue": "Continuar", - "referee_splash_not_now": "Agora não", "referee_referred_by": "Indicado por {{code}}", "referee_page_title": "Piloto VIP", "referee_stats_title": "Estatísticas", "referee_period_last_30d": "Últimos 30 dias", - "referee_points_label": "Pontos", "referee_swaps_volume_label": "Volume de trocas", "referee_perps_volume_label": "Volume de perps", + "referee_points_to_label": "Pontos para {{code}}", "referee_error_title": "Não foi possível carregar os detalhes do seu árbitro VIP", "referee_error_description": "Verifique sua conexão e tente novamente.", "last_updated": "Última atualização: {{time}}" diff --git a/locales/languages/ru.json b/locales/languages/ru.json index d12f54d676e..08059baa1bb 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Баллы от рефералов", "referrals_label": "VIP-рефералы", "tier_benefits_title": "Преимущества уровня", - "splash_title": "ДОБРО ПОЖАЛОВАТЬ\nВ GOLD\nFOX VIP", + "splash_title": "ДОБРО ПОЖАЛОВАТЬ\nВ GOLD FOX\nCOLLECTIVE", "splash_description": "Разблокируйте эксклюзивные привилегии, ранние функции и специально подобранные награды. Только по приглашению.", "splash_accept_invite": "Принять приглашение", "splash_not_now": "Не сейчас", @@ -8842,17 +8842,14 @@ "tiers_title": "Уровни", "tier_thresholds": "{{points}} балла(-ов)", "bps_value": "{{bps}} базисных пункта(-ов)", - "referee_splash_title": "ДОБРО ПОЖАЛОВАТЬ\nВ GOLD\nFOX VIP", - "referee_splash_description": "Разблокируйте эксклюзивные привилегии, ранние функции и специально подобранные награды. Только по приглашению.", "referee_splash_continue": "Продолжить", - "referee_splash_not_now": "Не сейчас", "referee_referred_by": "По рекомендации {{code}}", "referee_page_title": "VIP Pilot", "referee_stats_title": "Статистика", "referee_period_last_30d": "Последние 30 дн.", - "referee_points_label": "Баллы", "referee_swaps_volume_label": "Объем свопов", "referee_perps_volume_label": "Объем перпов", + "referee_points_to_label": "Баллы для {{code}}", "referee_error_title": "Не удалось загрузить данные вашего VIP-приглашенного", "referee_error_description": "Проверьте соединение и повторите попытку.", "last_updated": "Последнее обновление: {{time}}" diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 1dc79f1e181..2429180c0f7 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Mga punto mula sa mga referral", "referrals_label": "Mga Referral ng VIP", "tier_benefits_title": "Mga benepisyo ng tier", - "splash_title": "MALIGAYANG PAGDATING\nSA GOLD\nFOX VIP", + "splash_title": "MALIGAYANG PAGDATING\nSA GOLD FOX\nCOLLECTIVE", "splash_description": "I-unlock ang mga eksklusibong perk, mga unang feature, at na-curate na reward. Para sa imbitado lang.", "splash_accept_invite": "Tanggapin ang imbitasyon", "splash_not_now": "Hindi sa ngayon", @@ -8842,17 +8842,14 @@ "tiers_title": "Mga Tier", "tier_thresholds": "{{points}} (na) point", "bps_value": "{{bps}} bps", - "referee_splash_title": "MALIGAYANG PAGDATING\nSA GOLD\nFOX VIP", - "referee_splash_description": "I-unlock ang mga eksklusibong perk, mga unang feature, at na-curate na reward. Para sa imbitado lang.", "referee_splash_continue": "Magpatuloy", - "referee_splash_not_now": "Hindi sa ngayon", "referee_referred_by": "Referred by {{code}}", "referee_page_title": "VIP Pilot", "referee_stats_title": "Estadistika", "referee_period_last_30d": "Last 30d", - "referee_points_label": "Mga Point", "referee_swaps_volume_label": "Swaps volume", "referee_perps_volume_label": "Perps volume", + "referee_points_to_label": "Mga puntos para kay {{code}}", "referee_error_title": "We couldn’t load your VIP referee details", "referee_error_description": "Suriin ang iyong koneksyon at subukang muli.", "last_updated": "Huling na-update: {{time}}" diff --git a/locales/languages/tr.json b/locales/languages/tr.json index c9e3b0d9833..c723a738b22 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Referanslardan gelen puanlar", "referrals_label": "VIP Referanslar", "tier_benefits_title": "Aşama faydaları", - "splash_title": "GOLD\nFOX VIP'YE\nHOŞ GELDİNİZ", + "splash_title": "GOLD FOX\nCOLLECTIVE'YE\nHOŞ GELDİNİZ", "splash_description": "Özel avantajların, erken erişim özelliklerinin ve seçkin ödüllerin kilidini açın. Yalnızca davet yoluyla.", "splash_accept_invite": "Daveti kabul et", "splash_not_now": "Şimdi değil", @@ -8842,17 +8842,14 @@ "tiers_title": "Aşamalar", "tier_thresholds": "{{points}} puan", "bps_value": "{{bps}} bps", - "referee_splash_title": "GOLD\nFOX VIP'YE\nHOŞ GELDİNİZ", - "referee_splash_description": "Özel avantajların, erken erişim özelliklerinin ve seçkin ödüllerin kilidini açın. Yalnızca davet yoluyla.", "referee_splash_continue": "Devam et", - "referee_splash_not_now": "Şimdi değil", "referee_referred_by": "Öneren {{code}}", "referee_page_title": "VIP Pilot", "referee_stats_title": "İstatistikler", "referee_period_last_30d": "Son 30g", - "referee_points_label": "Puan", "referee_swaps_volume_label": "Takas hacmi", "referee_perps_volume_label": "Perps hacmi", + "referee_points_to_label": "{{code}} için puan", "referee_error_title": "VIP referans bilgilerinizi yükleyemedik", "referee_error_description": "Bağlantınızı kontrol edin ve tekrar deneyin.", "last_updated": "Son güncelleme: {{time}}" diff --git a/locales/languages/vi.json b/locales/languages/vi.json index da3fde37ef2..d2e3aa22437 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "Điểm từ giới thiệu", "referrals_label": "Giới thiệu VIP", "tier_benefits_title": "Quyền lợi bậc", - "splash_title": "CHÀO MỪNG\nĐẾN VỚI\nVIP CÁO VÀNG", + "splash_title": "CHÀO MỪNG\nĐẾN VỚI\nGOLD FOX COLLECTIVE", "splash_description": "Mở khóa các đặc quyền độc quyền, tính năng sớm và phần thưởng được tuyển chọn. Chỉ dành cho người được mời.", "splash_accept_invite": "Chấp nhận lời mời", "splash_not_now": "Không phải bây giờ", @@ -8842,17 +8842,14 @@ "tiers_title": "Bậc", "tier_thresholds": "{{points}} điểm", "bps_value": "{{bps}} bps", - "referee_splash_title": "CHÀO MỪNG\nĐẾN VỚI\nVIP CÁO VÀNG", - "referee_splash_description": "Mở khóa các đặc quyền độc quyền, tính năng sớm và phần thưởng được tuyển chọn. Chỉ dành cho người được mời.", "referee_splash_continue": "Tiếp tục", - "referee_splash_not_now": "Không phải bây giờ", "referee_referred_by": "Được giới thiệu bởi {{code}}", "referee_page_title": "Chương trình thử nghiệm VIP", "referee_stats_title": "Thống kê", "referee_period_last_30d": "30 ngày gần nhất", - "referee_points_label": "Điểm", "referee_swaps_volume_label": "Khối lượng hoán đổi", "referee_perps_volume_label": "Khối lượng giao dịch hợp đồng vĩnh cửu", + "referee_points_to_label": "Điểm cho {{code}}", "referee_error_title": "Chúng tôi không thể tải thông tin người giới thiệu VIP của bạn", "referee_error_description": "Kiểm tra kết nối của bạn và thử lại.", "last_updated": "Cập nhật lần cuối: {{time}}" diff --git a/locales/languages/zh.json b/locales/languages/zh.json index e5197c219c3..32fe83b00ed 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8827,7 +8827,7 @@ "points_from_referrals_label": "通过推荐所获积分", "referrals_label": "VIP 推荐", "tier_benefits_title": "等级权益", - "splash_title": "欢迎\n成为黄金\nFOX VIP", + "splash_title": "欢迎\n加入 GOLD FOX\nCOLLECTIVE", "splash_description": "解锁专属权益、抢先体验功能及精选奖励。仅限受邀用户。", "splash_accept_invite": "接受邀请", "splash_not_now": "暂时不", @@ -8842,17 +8842,14 @@ "tiers_title": "等级", "tier_thresholds": "{{points}} 积分", "bps_value": "{{bps}} 个基点", - "referee_splash_title": "欢迎\n成为黄金\nFOX VIP", - "referee_splash_description": "解锁专属权益、抢先体验功能及精选奖励。仅限受邀用户。", "referee_splash_continue": "继续", - "referee_splash_not_now": "暂时不", "referee_referred_by": "由 {{code}} 推荐", "referee_page_title": "VIP 抢先体验计划", "referee_stats_title": "统计数据", "referee_period_last_30d": "过去 30 天", - "referee_points_label": "积分", "referee_swaps_volume_label": "兑换交易量", "referee_perps_volume_label": "永续合约交易量", + "referee_points_to_label": "给 {{code}} 的积分", "referee_error_title": "无法加载您的 VIP 被推荐人详情", "referee_error_description": "请检查连接后重试。", "last_updated": "最后更新于:{{time}}" diff --git a/package.json b/package.json index 11d1c630797..8ba3595bf17 100644 --- a/package.json +++ b/package.json @@ -296,7 +296,7 @@ "@metamask/metamask-eth-abis": "3.1.1", "@metamask/mobile-wallet-protocol-core": "^0.4.0", "@metamask/mobile-wallet-protocol-wallet-client": "^0.3.0", - "@metamask/money-account-balance-service": "^2.0.0", + "@metamask/money-account-balance-service": "^2.1.0", "@metamask/money-account-controller": "^0.2.0", "@metamask/money-account-upgrade-controller": "^2.0.5", "@metamask/multichain-account-service": "^10.0.2", diff --git a/tests/component-view/fixtures/predict.ts b/tests/component-view/fixtures/predict.ts index 972600f7f7e..70a6254a92f 100644 --- a/tests/component-view/fixtures/predict.ts +++ b/tests/component-view/fixtures/predict.ts @@ -34,3 +34,62 @@ export const MOCK_PREDICT_MARKET: PredictMarket = { liquidity: 500_000, volume: 1_000_000, }; + +/** Scoreboard-capable live sports market for Live Now / sport-card view tests. */ +export const MOCK_PREDICT_LIVE_SPORT_MARKET: PredictMarket = { + id: 'market-live-sport-1', + providerId: 'polymarket', + slug: 'spain-vs-england-live', + title: 'Spain vs England', + description: 'Live World Cup matchup between Spain and England', + image: '', + status: 'open', + recurrence: Recurrence.NONE, + category: 'sports', + tags: ['World Cup'], + outcomes: [ + { + id: 'outcome-game-winner', + providerId: 'polymarket', + marketId: 'market-live-sport-1', + title: 'Game Winner', + description: 'Who will win the game', + image: '', + status: 'open', + tokens: [ + { id: 'token-home', title: 'Spain', price: 0.6 }, + { id: 'token-draw', title: 'Draw', price: 0.15 }, + { id: 'token-away', title: 'England', price: 0.62 }, + ], + volume: 1_000_000, + groupItemTitle: '', + }, + ], + liquidity: 500_000, + volume: 1_000_000, + game: { + id: 'game-live-1', + startTime: '2026-06-08T21:30:00Z', + status: 'ongoing', + league: 'fifwc', + elapsed: "45'", + period: '1H', + score: { home: 1, away: 0, raw: '0-1' }, + awayTeam: { + id: 'england', + name: 'England', + logo: 'https://example.com/england.png', + abbreviation: 'ENG', + color: '#FF0000', + alias: 'England', + }, + homeTeam: { + id: 'spain', + name: 'Spain', + logo: 'https://example.com/spain.png', + abbreviation: 'SPA', + color: '#FF8800', + alias: 'Spain', + }, + }, +}; diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index 224cda6c91c..6b9c5d9ecf6 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -356,7 +356,11 @@ jest.mock('../../app/core/Engine', () => { getCryptoTargetPrice: jest.fn().mockResolvedValue(69000), subscribeToMarketPrices: jest.fn(() => () => undefined), subscribeToCryptoPrices: jest.fn(() => () => undefined), - getConnectionStatus: jest.fn(() => ({ marketConnected: false })), + subscribeToGameUpdates: jest.fn(() => () => undefined), + getConnectionStatus: jest.fn(() => ({ + marketConnected: false, + sportsConnected: false, + })), trackFeedViewed: jest.fn(), trackTabChanged: jest.fn(), trackBannerAction: jest.fn(), diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 0ef103cf04b..355ae3b602d 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -3230,6 +3230,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + earnMoneyFirstTimeDepositAnimationEnabled: { + name: 'earnMoneyFirstTimeDepositAnimationEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, + earnMoneyHubEnabled: { name: 'earnMoneyHubEnabled', type: FeatureFlagType.Remote, @@ -4877,6 +4885,16 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + homeTMCU926AbtestDiscoveryPills: { + name: 'homeTMCU926AbtestDiscoveryPills', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + homepageRedesignV1: { name: 'homepageRedesignV1', type: FeatureFlagType.Remote, @@ -5388,6 +5406,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + predictSportCardLivePrices: { + name: 'predictSportCardLivePrices', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + minimumVersion: '7.81.0', + enabled: true, + }, + status: FeatureFlagStatus.Active, + }, + smartTransactionsAllowedRpcHosts: { name: 'smartTransactionsAllowedRpcHosts', type: FeatureFlagType.Remote, diff --git a/yarn.lock b/yarn.lock index 159dd2ff0c5..58a91f2c502 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9189,9 +9189,9 @@ __metadata: languageName: node linkType: hard -"@metamask/money-account-balance-service@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/money-account-balance-service@npm:2.0.0" +"@metamask/money-account-balance-service@npm:^2.1.0": + version: 2.1.0 + resolution: "@metamask/money-account-balance-service@npm:2.1.0" dependencies: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" @@ -9203,7 +9203,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - checksum: 10/47c66554d975250d8de73287d7eb35fb3c3d57de53514902ec5801482f20c12e69c8ebc2910996a3c97c73d5acdc403660e53b755aff88a7877a3979f6cc9544 + checksum: 10/3bc2947f2b93c0d3ceaa5637c728998153d3dc1a2bf4b6e6d503b9cf4e8cbf8288f76189b378e190c67d324652985cd6fc48c55506511f972f94e6e837b3df89 languageName: node linkType: hard @@ -35466,7 +35466,7 @@ __metadata: "@metamask/mobile-provider": "npm:^3.0.0" "@metamask/mobile-wallet-protocol-core": "npm:^0.4.0" "@metamask/mobile-wallet-protocol-wallet-client": "npm:^0.3.0" - "@metamask/money-account-balance-service": "npm:^2.0.0" + "@metamask/money-account-balance-service": "npm:^2.1.0" "@metamask/money-account-controller": "npm:^0.2.0" "@metamask/money-account-upgrade-controller": "npm:^2.0.5" "@metamask/multichain-account-service": "npm:^10.0.2"