diff --git a/.github/actions/ci-status-gate/action.yml b/.github/actions/ci-status-gate/action.yml new file mode 100644 index 00000000000..5c5f1434875 --- /dev/null +++ b/.github/actions/ci-status-gate/action.yml @@ -0,0 +1,158 @@ +name: CI Status Gate +description: Evaluate required CI job results and fail on unexpected skips or failed jobs. + +inputs: + needs-json: + description: JSON representation of the calling job's needs context. + required: true + requirement-context-json: + description: JSON representation of get-requirements outputs. + required: true + e2e-job-regex: + description: Regex matching E2E build/test jobs whose skipped result is allowed. Failed or cancelled E2E jobs still fail. + required: false + default: '^e2e-' + event-name: + description: GitHub event name for the current workflow run. + required: true + is-fork: + description: Whether the current pull request originates from a fork. When true, skipped jobs are treated as allowed skips. + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Evaluate CI status + shell: bash + env: + NEEDS_JSON: ${{ inputs.needs-json }} + REQUIREMENT_CONTEXT_JSON: ${{ inputs.requirement-context-json }} + E2E_JOB_REGEX: ${{ inputs.e2e-job-regex }} + EVENT_NAME: ${{ inputs.event-name }} + IS_FORK: ${{ inputs.is-fork }} + run: | + set -euo pipefail + + get_requirement() { + local key="$1" + jq -nr --arg key "$key" 'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] // "false"' + } + + sanitize_markdown_cell() { + local value="$1" + value="${value//$'\n'/ }" + value="${value//|/\\|}" + printf '%s' "$value" + } + + add_summary_row() { + local job_name result decision reason + job_name="$(sanitize_markdown_cell "$1")" + result="$(sanitize_markdown_cell "$2")" + decision="$(sanitize_markdown_cell "$3")" + reason="$(sanitize_markdown_cell "$4")" + + printf '| `%s` | `%s` | %s | %s |\n' \ + "$job_name" "$result" "$decision" "$reason" >> "$summary_file" + } + + mark_failure() { + local message="$1" + failed="true" + echo "::error::$message" + } + + validate_json_type() { + local variable_name="$1" + local expected_type="$2" + + if ! jq -en --arg variable_name "$variable_name" --arg expected_type "$expected_type" \ + '(env[$variable_name] | fromjson | type) == $expected_type' >/dev/null 2>&1; then + echo "::error::$variable_name is not a valid JSON $expected_type" + exit 1 + fi + } + + require_requirement_key() { + local key="$1" + + if ! jq -en --arg key "$key" \ + 'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] != null' >/dev/null 2>&1; then + echo "::error::REQUIREMENT_CONTEXT_JSON is missing or null for required key: $key" + exit 1 + fi + } + + validate_json_type NEEDS_JSON object + validate_json_type REQUIREMENT_CONTEXT_JSON object + + for required_key in skip_everything block_merge_for_e2e_readiness; do + require_requirement_key "$required_key" + done + + skip_everything="$(get_requirement skip_everything)" + block_merge_for_e2e_readiness="$(get_requirement block_merge_for_e2e_readiness)" + + if [[ "$block_merge_for_e2e_readiness" == "true" ]]; then + echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging." + exit 1 + fi + + if [[ "$skip_everything" == "true" ]]; then + echo "skip_everything=true; treating all jobs as passed" + exit 0 + fi + + failed="false" + summary_file="$(mktemp)" + trap 'if [[ -n "${GITHUB_STEP_SUMMARY:-}" && -f "$summary_file" ]]; then cat "$summary_file" >> "$GITHUB_STEP_SUMMARY"; fi; rm -f "$summary_file"' EXIT + job_count=0 + + { + echo "### CI Status Gate" + echo + echo "| Job | Result | Decision | Reason |" + echo "| --- | --- | --- | --- |" + } >> "$summary_file" + + while IFS=$'\t' read -r job_name result; do + job_count=$((job_count + 1)) + + case "$result" in + success) + add_summary_row "$job_name" "$result" "pass" "job succeeded" + ;; + failure|cancelled) + mark_failure "$job_name finished with result: $result" + add_summary_row "$job_name" "$result" "fail" "job did not complete successfully" + ;; + skipped) + if [[ "$job_name" =~ $E2E_JOB_REGEX ]]; then + add_summary_row "$job_name" "$result" "pass" "skipped E2E jobs are allowed" + elif [[ "$EVENT_NAME" == "merge_group" ]]; then + add_summary_row "$job_name" "$result" "pass" "merge queue skip is allowed" + elif [[ "$IS_FORK" == "true" ]]; then + add_summary_row "$job_name" "$result" "pass" "fork-only skip is allowed" + else + mark_failure "$job_name was skipped unexpectedly" + add_summary_row "$job_name" "$result" "fail" "skip was not expected" + fi + ;; + *) + mark_failure "$job_name has unknown result: $result" + add_summary_row "$job_name" "$result" "fail" "job result is unknown" + ;; + esac + done < <(jq -nr 'env.NEEDS_JSON | fromjson | to_entries[] | [.key, (.value.result // "")] | @tsv') + + if [[ "$job_count" -eq 0 ]]; then + echo "::error::NEEDS_JSON does not contain any jobs" + exit 1 + fi + + if [[ "$failed" == "true" ]]; then + exit 1 + fi + + echo "All required jobs passed" diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index 0e54460e06e..07669962f93 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -283,6 +283,9 @@ runs: node_modules .yarn/install-state.gz key: ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}- + continue-on-error: true - name: Install JavaScript dependencies with retry id: yarn-install @@ -387,19 +390,19 @@ runs: ${{ runner.os }}-cocoapods-specs- continue-on-error: true - - name: Clear CocoaPods trunk to prevent stale specs - if: ${{ inputs.platform == 'ios' }} - run: pod repo remove trunk || true - shell: bash - - name: Install CocoaPods via bundler if: ${{ inputs.platform == 'ios'}} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 30 + max_attempts: 3 + retry_wait_seconds: 60 + on_retry_command: | + echo "::warning::CocoaPods install failed, retrying after trunk cleanup..." + pod repo remove trunk || true command: cd ios && bundle exec pod install --repo-update + env: + COCOAPODS_DISABLE_STATS: 'true' - name: Install applesimutils if: ${{ inputs.platform == 'ios' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96719067513..139886e7570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -937,113 +937,41 @@ jobs: fi fi - all-jobs-pass: - name: All jobs pass - runs-on: ubuntu-latest - if: ${{ !cancelled() }} - needs: - [ - check-diff, - dedupe, - scripts, - unit-tests, - component-view-tests, - check-workflows, - js-bundle-size-check, - sonar-cloud-quality-gate-status, - ] - outputs: - ALL_JOBS_PASSED: ${{ steps.jobs-passed-status.outputs.ALL_JOBS_PASSED }} - steps: - - name: Set jobs passed status - id: jobs-passed-status - env: - NEEDS_CONTEXT: ${{ toJSON(needs) }} - EVENT_NAME: ${{ github.event_name }} - IS_FORK: ${{ github.event.pull_request.head.repo.fork }} - run: | - # Check results of all required jobs dynamically - # On merge_group events, "skipped" is acceptable (some jobs intentionally skip) - # On fork PRs, "skipped" is acceptable (secret-dependent jobs are intentionally skipped) - # On other events (push to main), all jobs must succeed - - FAILED="false" - - while read -r job_name result; do - if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then - echo "::error::Job '$job_name' failed with result: $result" - FAILED="true" - elif [[ "$result" == "skipped" ]]; then - if [[ "$EVENT_NAME" == "merge_group" ]] || [[ "$IS_FORK" == "true" ]]; then - echo "Job '$job_name' was skipped (OK for merge_group events and fork PRs)" - else - echo "::error::Job '$job_name' was unexpectedly skipped on $EVENT_NAME event" - FAILED="true" - fi - else - echo "Job '$job_name' passed" - fi - done < <(echo "$NEEDS_CONTEXT" | jq -r 'to_entries[] | "\(.key) \(.value.result)"') - - if [[ "$FAILED" == "true" ]]; then - echo "Some required jobs failed" - exit 1 - fi - - echo "ALL_JOBS_PASSED=true" >> "$GITHUB_OUTPUT" - check-all-jobs-pass: name: Check all jobs pass - if: ${{ !cancelled() }} + # Run the aggregate gate even when optional dependencies are skipped. + # The composite action decides which skipped jobs are acceptable. + if: ${{ always() && !cancelled() }} runs-on: ubuntu-latest needs: - get_requirements - - all-jobs-pass + - check-diff + - dedupe + - scripts + - unit-tests + - component-view-tests + - check-workflows + - js-bundle-size-check + - sonar-cloud-quality-gate-status - build-android-apks - build-ios-apps - e2e-smoke-tests-android - e2e-smoke-tests-ios - env: - SKIPPED: ${{ needs.get_requirements.outputs.skip_everything == 'true' }} steps: - - name: Block merge while pr-not-ready-for-e2e label is applied - if: ${{ needs.get_requirements.outputs.block_merge_for_e2e_readiness == 'true' }} - run: | - echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging." - exit 1 - - run: | - # If the merge queue was skipped, consider all jobs as passed - if [[ "$SKIPPED" == "true" ]]; then - echo "Merge queue skipped, considering all jobs as passed" - exit 0 - fi - - # Check if all non-E2E jobs passed - if [[ "${{ needs.all-jobs-pass.outputs.ALL_JOBS_PASSED }}" != "true" ]]; then - echo "Non-E2E jobs failed" - exit 1 - fi - - # Check E2E build + smoke results only if E2E should have run. - # 'skipped' is acceptable — covers merge_group, fork PRs, ignorable-only changes, - # platform-only PRs, and AI selection returning zero tags. - # 'failure'/'cancelled' on any of build or smoke must block merge. - if [[ "${{ needs.get_requirements.outputs.skip_e2e }}" != "true" ]]; then - for entry in \ - "build-android-apks:${{ needs.build-android-apks.result }}" \ - "e2e-smoke-tests-android:${{ needs.e2e-smoke-tests-android.result }}" \ - "build-ios-apps:${{ needs.build-ios-apps.result }}" \ - "e2e-smoke-tests-ios:${{ needs.e2e-smoke-tests-ios.result }}"; do - name="${entry%%:*}" - result="${entry#*:}" - if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then - echo "::error::Required E2E job '$name' did not succeed (result: $result)" - exit 1 - fi - done - fi + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + sparse-checkout: | + .github/actions/ci-status-gate - echo "All required jobs passed" + - name: Evaluate CI status + uses: ./.github/actions/ci-status-gate + with: + needs-json: ${{ toJSON(needs) }} + requirement-context-json: ${{ toJSON(needs.get_requirements.outputs) }} + e2e-job-regex: '^(build-android-apks|build-ios-apps|e2e-smoke-tests-android|e2e-smoke-tests-ios)$' + event-name: ${{ github.event_name }} + is-fork: ${{ github.event.pull_request.head.repo.fork == true }} log-merge-group-failure: name: Log merge group failure diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index 4ed5495738c..7371d347cfb 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -16,6 +16,9 @@ import OndoCampaignRwaSelectorView from './Views/OndoCampaignRwaSelectorView'; import OndoCampaignPortfolioView from './Views/OndoCampaignPortfolioView'; import OndoCampaignStatsView from './Views/OndoCampaignStatsView'; import CampaignTourStepView from './Views/CampaignTourStepView'; +import PerpsTradingCampaignDetailsView from './Views/PerpsTradingCampaignDetailsView'; +import PerpsTradingCampaignLeaderboardView from './Views/PerpsTradingCampaignLeaderboardView'; +import PerpsTradingCampaignStatsView from './Views/PerpsTradingCampaignStatsView'; import { useDispatch, useSelector } from 'react-redux'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; import { @@ -92,6 +95,8 @@ const RewardsNavigator: React.FC = () => { navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW); } else if (pendingDeeplink?.campaign === 'season1') { navigation.navigate(Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW); + } else if (pendingDeeplink?.campaign === 'perps-comp') { + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW); } else if (pendingDeeplink?.page === 'musd') { navigation.navigate(Routes.REWARDS_MUSD_CALCULATOR_VIEW); } else if (pendingDeeplink?.page === 'benefits') { @@ -194,6 +199,21 @@ const RewardsNavigator: React.FC = () => { component={OndoCampaignStatsView} options={{ headerShown: false }} /> + + + ) : null} diff --git a/app/components/UI/Rewards/Views/CampaignTourStepView.tsx b/app/components/UI/Rewards/Views/CampaignTourStepView.tsx index 7dc70bfa7b5..81e81f80205 100644 --- a/app/components/UI/Rewards/Views/CampaignTourStepView.tsx +++ b/app/components/UI/Rewards/Views/CampaignTourStepView.tsx @@ -28,6 +28,7 @@ import { import ScrollableTabView from '@tommasini/react-native-scrollable-tab-view'; import { selectCampaignById } from '../../../../reducers/rewards/selectors'; import Routes from '../../../../constants/navigation/Routes'; +import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; import ProgressIndicator from '../components/Onboarding/ProgressIndicator'; import CampaignTourStep, { CAMPAIGN_TOUR_STEP_TEST_IDS, @@ -63,13 +64,19 @@ const CampaignTourStepView: React.FC = () => { typeof ScrollableTabView & { goToPage: (page: number) => void } >(null); + const campaignType = campaign?.type; + const navigateToDetails = useCallback(() => { + const detailsRoute = + campaignType === CampaignType.PERPS_TRADING + ? Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW + : Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW; navigation.dispatch( - StackActions.replace(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { + StackActions.replace(detailsRoute, { campaignId, }), ); - }, [navigation, campaignId]); + }, [navigation, campaignId, campaignType]); const currentStep = tour?.[currentTab]; const isLastStep = tour ? currentTab === tour.length - 1 : false; diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx index f51095950e9..0093688e435 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx @@ -3,7 +3,7 @@ import { render, fireEvent } from '@testing-library/react-native'; import OndoCampaignDetailsView, { CAMPAIGN_DETAILS_TEST_IDS, } from './OndoCampaignDetailsView'; -import { CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/CampaignStatsSummary'; +import { ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/OndoCampaignStatsSummary'; import { ONDO_PRIZE_POOL_TEST_IDS } from '../components/Campaigns/OndoPrizePool'; import { CAMPAIGN_CTA_TEST_IDS } from '../components/Campaigns/CampaignOptInCta'; import { CAMPAIGN_ENDED_STATS_TEST_IDS } from '../components/Campaigns/CampaignEndedStats'; @@ -153,18 +153,17 @@ jest.mock('../components/Campaigns/CampaignEndedStats', () => { }; }); -const mockCampaignStatsSummary = jest.fn(); -jest.mock('../components/Campaigns/CampaignStatsSummary', () => { +const mockOndoCampaignStatsSummary = jest.fn(); +jest.mock('../components/Campaigns/OndoCampaignStatsSummary', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); - const { CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds } = jest.requireActual( - '../components/Campaigns/CampaignStatsSummary', - ); + const { ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds } = + jest.requireActual('../components/Campaigns/OndoCampaignStatsSummary'); return { __esModule: true, - CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds, + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds, default: (props: Record) => { - mockCampaignStatsSummary(props); + mockOndoCampaignStatsSummary(props); return ReactActual.createElement(View, { testID: actualTestIds.CONTAINER, }); @@ -556,7 +555,7 @@ describe('OndoCampaignDetailsView', () => { beforeEach(() => { jest.clearAllMocks(); mockIsTokenTradingOpen.mockReturnValue(true); - mockCampaignStatsSummary.mockReset(); + mockOndoCampaignStatsSummary.mockReset(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); mockUseGetCampaignParticipantStatus.mockReturnValue({ status: null, @@ -763,7 +762,7 @@ describe('OndoCampaignDetailsView', () => { expect(queryByTestId('campaign-how-it-works')).toBeNull(); }); - it('renders CampaignStatsSummary when user has portfolio positions', () => { + it('renders OndoCampaignStatsSummary when user has portfolio positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -783,11 +782,11 @@ describe('OndoCampaignDetailsView', () => { }); const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); }); - it('does not render CampaignStatsSummary when participant has no positions', () => { + it('does not render OndoCampaignStatsSummary when participant has no positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -800,7 +799,7 @@ describe('OndoCampaignDetailsView', () => { }); const { queryByTestId } = render(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); }); @@ -940,7 +939,7 @@ describe('OndoCampaignDetailsView', () => { }); describe('stats summary and leaderboard', () => { - it('shows CampaignStatsSummary when participant is opted in with positions', () => { + it('shows OndoCampaignStatsSummary when participant is opted in with positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -960,18 +959,18 @@ describe('OndoCampaignDetailsView', () => { }); const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); }); - it('does not show CampaignStatsSummary when not opted in and campaign is active', () => { + it('does not show OndoCampaignStatsSummary when not opted in and campaign is active', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], }); const { queryByTestId } = render(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); @@ -995,7 +994,7 @@ describe('OndoCampaignDetailsView', () => { ); expect(getByTestId('ondo-leaderboard')).toBeDefined(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); @@ -1166,7 +1165,7 @@ describe('OndoCampaignDetailsView', () => { }); }); - describe('ineligible state — isIneligible prop passed to CampaignStatsSummary', () => { + describe('ineligible state — isIneligible prop passed to OndoCampaignStatsSummary', () => { const setupWithPositions = () => { mockUseGetCampaignParticipantStatus.mockReturnValue({ status: { optedIn: true, participantCount: 1 }, @@ -1201,7 +1200,7 @@ describe('OndoCampaignDetailsView', () => { }); setupWithPositions(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: true }), ); }); @@ -1224,7 +1223,7 @@ describe('OndoCampaignDetailsView', () => { }); setupWithPositions(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: false }), ); }); @@ -1266,7 +1265,7 @@ describe('OndoCampaignDetailsView', () => { refetch: jest.fn(), }); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: false }), ); }); @@ -1439,10 +1438,10 @@ describe('OndoCampaignDetailsView', () => { ); }); - it('passes winner outcome props to CampaignStatsSummary when campaign is complete', () => { + it('passes winner outcome props to OndoCampaignStatsSummary when campaign is complete', () => { setupWinner(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isCampaignComplete: true, outcomeStatus: 'pending', @@ -1451,7 +1450,7 @@ describe('OndoCampaignDetailsView', () => { ); }); - it('passes no outcome status to CampaignStatsSummary when user has no outcome', () => { + it('passes no outcome status to OndoCampaignStatsSummary when user has no outcome', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [ @@ -1473,7 +1472,7 @@ describe('OndoCampaignDetailsView', () => { }); render(); expect( - mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.outcomeStatus, + mockOndoCampaignStatsSummary.mock.calls.at(-1)?.[0]?.outcomeStatus, ).toBeUndefined(); }); @@ -1482,7 +1481,7 @@ describe('OndoCampaignDetailsView', () => { mockNavigate.mockClear(); render(); const onWinnerPress = - mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.onWinnerPress; + mockOndoCampaignStatsSummary.mock.calls.at(-1)?.[0]?.onWinnerPress; expect(typeof onWinnerPress).toBe('function'); onWinnerPress(); expect(mockNavigate).toHaveBeenCalledWith( diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx index 3f5cdc13c01..9fd0cd8f975 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx @@ -42,8 +42,8 @@ import OndoPortfolio from '../components/Campaigns/OndoPortfolio'; import OndoAccountPickerSheet from '../components/Campaigns/OndoAccountPickerSheet'; import OndoCampaignCTA from '../components/Campaigns/OndoCampaignCTA'; import OndoNotEligibleSheet from '../components/Campaigns/OndoNotEligibleSheet'; -import CampaignStatsSummary from '../components/Campaigns/CampaignStatsSummary'; import CampaignEndedStats from '../components/Campaigns/CampaignEndedStats'; +import OndoCampaignStatsSummary from '../components/Campaigns/OndoCampaignStatsSummary'; import OndoPrizePool from '../components/Campaigns/OndoPrizePool'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; @@ -408,7 +408,7 @@ const OndoCampaignDetailsView: React.FC = () => { /> - { {strings('rewards.ondo_campaign_leaderboard.title')} diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx index 7dda7d1b74c..b17b28efa50 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx @@ -21,8 +21,8 @@ import ErrorBoundary from '../../../Views/ErrorBoundary'; import CampaignViewHeader from '../components/Campaigns/CampaignViewHeader'; import { StatCell, - CAMPAIGN_STATS_SUMMARY_TEST_IDS, -} from '../components/Campaigns/CampaignStatsSummary'; + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from '../components/Campaigns/OndoCampaignStatsSummary'; import LeaderboardPositionHeader from '../components/Campaigns/LeaderboardPositionHeader'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { getTierMinNetDeposit } from '../components/Campaigns/OndoLeaderboard.utils'; @@ -47,6 +47,7 @@ type OndoCampaignStatsRouteParams = { export const ONDO_CAMPAIGN_STATS_VIEW_TEST_IDS = { CONTAINER: 'ondo-campaign-stats-view-container', + LAST_COMPUTED: 'ondo-campaign-stats-view-last-computed', } as const; const CheckIcon: React.FC = () => ( @@ -346,7 +347,9 @@ const OndoCampaignStatsView: React.FC = () => { {!isCampaignComplete && isIneligible && ( { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); + const { CAMPAIGN_LEADERBOARD_TEST_IDS } = jest.requireActual< + typeof import('../components/Campaigns/OndoLeaderboard') + >('../components/Campaigns/OndoLeaderboard'); return { __esModule: true, default: (props: Record) => { @@ -95,6 +98,7 @@ jest.mock('../components/Campaigns/OndoLeaderboard', () => { testID: 'campaign-leaderboard', }); }, + CAMPAIGN_LEADERBOARD_TEST_IDS, }; }); diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx index bb957308a99..fd965e88005 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx @@ -36,7 +36,6 @@ import { useGetOndoPortfolioPosition } from '../hooks/useGetOndoPortfolioPositio import { useGetOndoCampaignDeposits } from '../hooks/useGetOndoCampaignDeposits'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useOndoLeaderboardPositionDisplay } from '../hooks/useOndoLeaderboardPositionDisplay'; -import { getCurrentPrize } from '../components/Campaigns/OndoPrizePool'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; import { @@ -44,6 +43,8 @@ import { selectCampaignById, } from '../../../../reducers/rewards/selectors'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import { computePrizePoolProgress } from '../utils/prizePoolUtils'; +import { BREAKPOINTS } from '../components/Campaigns/OndoPrizePool'; // ParamListBase requires an index signature, which interfaces don't support // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -104,7 +105,13 @@ const OndoLeaderboardView: React.FC = () => { }); const prizePoolValue = deposits?.totalUsdDeposited - ? formatUsd(getCurrentPrize(parseFloat(deposits.totalUsdDeposited))) + ? formatUsd( + computePrizePoolProgress( + BREAKPOINTS, + parseFloat(deposits.totalUsdDeposited), + (m) => m.deposit, + ).currentPrize, + ) : undefined; const { @@ -116,6 +123,7 @@ const OndoLeaderboardView: React.FC = () => { isLoading: isLeaderboardLoading, hasError: hasLeaderboardError, isLeaderboardNotYetComputed, + computedAt: leaderboardComputedAt, refetch: refetchLeaderboard, } = useGetOndoLeaderboard(campaignId, { defaultTier: position?.projectedTier, diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx new file mode 100644 index 00000000000..54fe6b66bee --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx @@ -0,0 +1,597 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignDetailsView, { + PERPS_CAMPAIGN_DETAILS_TEST_IDS, +} from './PerpsTradingCampaignDetailsView'; +import { + type CampaignDto, + CampaignType, + type PerpsTradingCampaignLeaderboardEntry, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +const mockRouteState: { params: { campaignId?: string } } = { + params: { campaignId: 'perps-campaign-1' }, +}; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + navigate: mockNavigate, + addListener: jest.fn(() => jest.fn()), + isFocused: () => true, + }), + useRoute: () => mockRouteState, +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const Skeleton = (props: Record) => + ReactActual.createElement(View, { testID: 'skeleton', ...props }); + return { ...actual, Skeleton }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + endButtonIconProps, + }: { + title: string; + onBack: () => void; + endButtonIconProps?: { testID?: string; onPress?: () => void }[]; + }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'perps-details-back-button', + }), + ...(endButtonIconProps ?? []).map((btn, i) => + ReactActual.createElement(Pressable, { + key: i, + onPress: btn.onPress, + testID: btn.testID ?? `end-button-${i}`, + }), + ), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + edges?: unknown; + style?: unknown; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('../components/Campaigns/CampaignStatus', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: { name: string } }) => + ReactActual.createElement( + View, + { testID: 'campaign-status' }, + ReactActual.createElement(Text, null, campaign.name), + ), + }; +}); + +jest.mock('../components/Campaigns/CampaignHowItWorks', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-how-it-works' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'perps-campaign-stats-summary-container', + }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignPrizePool', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'perps-prize-pool' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS: { + TOTAL_PARTICIPANTS: 'perps-campaign-leaderboard-total-participants', + }, + default: () => + ReactActual.createElement(View, { testID: 'perps-leaderboard' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignCTA', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const { getCampaignStatus } = jest.requireActual( + '../components/Campaigns/CampaignTile.utils', + ) as typeof import('../components/Campaigns/CampaignTile.utils'); + return { + __esModule: true, + default: ({ campaign }: { campaign: CampaignDto }) => + getCampaignStatus(campaign) === 'complete' + ? null + : ReactActual.createElement(View, { testID: 'perps-trading-cta' }), + }; +}); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + }) => + ReactActual.createElement( + View, + { testID: 'campaigns-load-error-banner' }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'campaigns-error-retry' }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../hooks/useGetCampaignParticipantStatus'); +const mockUseGetCampaignParticipantStatus = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboard'); +const mockUseGetPerpsTradingCampaignLeaderboard = + useGetPerpsTradingCampaignLeaderboard as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboard + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +const mockUseGetPerpsTradingCampaignLeaderboardPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignVolume'); +const mockUseGetPerpsTradingCampaignVolume = + useGetPerpsTradingCampaignVolume as jest.MockedFunction< + typeof useGetPerpsTradingCampaignVolume + >; + +import { useSelector } from 'react-redux'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; + +const mockUseSelector = useSelector as jest.MockedFunction; + +const mockFetchCampaigns = jest.fn(); + +function buildPerpsCampaign(overrides: Partial = {}): CampaignDto { + return { + id: 'perps-campaign-1', + type: CampaignType.PERPS_TRADING, + name: 'Perps Trading', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2026-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, + }; +} + +function toMockLeaderboardPosition( + position: { rank: number; neighbors: unknown[] } | null, +): PerpsTradingCampaignLeaderboardPositionDto | null { + if (!position) { + return null; + } + return { + rank: position.rank, + pnl: 0, + notionalVolume: 0, + marginDeployed: 0, + qualified: true, + neighbors: position.neighbors as PerpsTradingCampaignLeaderboardEntry[], + computedAt: '2025-08-15T12:00:00.000Z', + }; +} + +const defaultLeaderboardHook = { + leaderboard: { + campaignId: 'perps-campaign-1', + entries: [], + totalParticipants: 0, + computedAt: '2025-08-15T12:00:00.000Z', + }, + isLoading: false, + hasError: false, + isLeaderboardNotYetComputed: false, + refetch: jest.fn(), +}; + +const defaultVolumeHook = { + volume: { + totalUsdVolume: '1000000', + }, + isLoading: false, + hasError: false, + refetch: jest.fn(), +}; + +function setupHooks( + overrides: { + campaigns?: CampaignDto[]; + isCampaignsLoading?: boolean; + hasCampaignsError?: boolean; + participant?: { optedIn: boolean }; + position?: { rank: number; neighbors: unknown[] } | null; + totalParticipants?: number; + } = {}, +) { + const { + campaigns = [buildPerpsCampaign()], + isCampaignsLoading = false, + hasCampaignsError = false, + participant = { optedIn: false }, + position = null, + totalParticipants: totalParticipantsOverride, + } = overrides; + + mockUseRewardCampaigns.mockReturnValue({ + campaigns, + isLoading: isCampaignsLoading, + hasError: hasCampaignsError, + fetchCampaigns: mockFetchCampaigns, + categorizedCampaigns: { active: [], upcoming: [], previous: [] }, + hasLoaded: true, + } as ReturnType); + + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { + optedIn: participant.optedIn, + participantCount: 0, + }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + } as ReturnType); + + const leaderboard = { + ...defaultLeaderboardHook.leaderboard, + ...(totalParticipantsOverride !== undefined + ? { totalParticipants: totalParticipantsOverride } + : {}), + }; + + mockUseGetPerpsTradingCampaignLeaderboard.mockReturnValue({ + ...defaultLeaderboardHook, + leaderboard, + } as ReturnType); + + mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({ + position: toMockLeaderboardPosition(position), + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + } as ReturnType); + + mockUseGetPerpsTradingCampaignVolume.mockReturnValue({ + ...defaultVolumeHook, + } as ReturnType); +} + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string, params?: { count?: string }) => { + if ( + key === 'rewards.perps_trading_campaign.leaderboard_total_participants' && + params?.count !== undefined + ) { + return `${params.count} participants`; + } + const map: Record = { + 'rewards.perps_trading_campaign.title': 'Perps Trading', + 'rewards.perps_trading_campaign.stats_title': 'Stats', + 'rewards.perps_trading_campaign.prize_pool_title': 'Prize pool', + 'rewards.perps_trading_campaign.leaderboard_title': 'Leaderboard', + 'rewards.campaigns_view.error_title': 'Error', + 'rewards.campaigns_view.error_description': 'Try again', + 'rewards.campaigns_view.retry_button': 'Retry', + }; + return map[key] ?? key; + }, +})); + +describe('PerpsTradingCampaignDetailsView', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); + jest.clearAllMocks(); + mockRouteState.params = { campaignId: 'perps-campaign-1' }; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectReferralCode) { + return 'ref-code'; + } + return undefined; + }); + setupHooks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders skeletons while campaigns load and no campaign resolved', () => { + setupHooks({ campaigns: [], isCampaignsLoading: true }); + const { getAllByTestId, queryByTestId } = render( + , + ); + + expect(getAllByTestId('skeleton').length).toBeGreaterThanOrEqual(2); + expect(queryByTestId('campaign-status')).toBeNull(); + }); + + it('renders campaigns error banner and retries fetchCampaigns', () => { + setupHooks({ + campaigns: [], + isCampaignsLoading: false, + hasCampaignsError: true, + }); + const { getByTestId } = render(); + + fireEvent.press(getByTestId('campaigns-error-retry')); + expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); + }); + + it('renders header, campaign status, prize pool, leaderboard, and CTA for active campaign', () => { + const { getByTestId, getByText } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER), + ).toBeDefined(); + expect(getByTestId('header')).toBeDefined(); + expect(getByTestId('campaign-status')).toBeDefined(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(getByTestId('perps-leaderboard')).toBeDefined(); + expect(getByTestId('perps-trading-cta')).toBeDefined(); + }); + + it('hides How it works when the user has a leaderboard position', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: { rank: 5, neighbors: [] }, + }); + + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + + it('shows How it works when active, user has no leaderboard position, and details include howItWorks', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('shows stats header when user has a leaderboard position', () => { + setupHooks({ + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + }); + + const { getByTestId } = render(); + expect(getByTestId('perps-campaign-stats-summary-container')).toBeDefined(); + }); + + it('navigates to stats when stats header row is pressed and user has a position', () => { + setupHooks({ + participant: { optedIn: true }, + position: { rank: 2, neighbors: [] }, + }); + + const { getByText } = render(); + + fireEvent.press(getByText('Stats')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS, + { campaignId: 'perps-campaign-1' }, + ); + }); + + it('navigates to full leaderboard and mechanics help', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.press(getByText('Leaderboard')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD, + { campaignId: 'perps-campaign-1' }, + ); + + fireEvent.press(getByTestId('perps-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: 'perps-campaign-1' }, + ); + }); + + it('complete campaign shows leaderboard without stats row and hides CTA', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('perps-leaderboard')).toBeDefined(); + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + expect(queryByTestId('perps-prize-pool')).toBeNull(); + expect(queryByTestId('perps-trading-cta')).toBeNull(); + }); + + it('displays total participant count when the leaderboard reports participants', () => { + setupHooks({ totalParticipants: 1500 }); + + const { getByText, getByTestId } = render( + , + ); + + expect( + getByTestId('perps-campaign-leaderboard-total-participants'), + ).toBeDefined(); + expect(getByText('1,500 participants')).toBeDefined(); + }); + + it('hides the prize pool section for upcoming campaigns', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + }), + ], + }); + + const { queryByTestId } = render(); + expect(queryByTestId('perps-prize-pool')).toBeNull(); + }); + + it('resolves campaign by PERPS_TRADING type when route has no campaignId', () => { + mockRouteState.params = {}; + setupHooks({ + campaigns: [buildPerpsCampaign({ id: 'resolved-by-type' })], + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-status')).toBeDefined(); + + fireEvent.press(getByTestId('perps-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: 'resolved-by-type' }, + ); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx new file mode 100644 index 00000000000..0310b69d8ea --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx @@ -0,0 +1,365 @@ +import React, { useCallback, useMemo } from 'react'; +import { Pressable, ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import CampaignStatus from '../components/Campaigns/CampaignStatus'; +import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import PerpsTradingCampaignLeaderboard, { + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS, +} from '../components/Campaigns/PerpsTradingCampaignLeaderboard'; +import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool'; +import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA'; +import PerpsCampaignStatsSummary from '../components/Campaigns/PerpsCampaignStatsSummary'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { + CampaignType, + OndoCampaignHowItWorks, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignDetailsRouteParams = { + RewardsPerpsTradingCampaignDetails: { campaignId?: string }; +}; + +export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = { + CONTAINER: 'perps-campaign-details-container', +} as const; + +const PerpsTradingCampaignDetailsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignDetailsRouteParams, + 'RewardsPerpsTradingCampaignDetails' + > + >(); + const routeCampaignId = route.params?.campaignId; + const referralCode = useSelector(selectReferralCode); + + const { + campaigns, + isLoading: isCampaignsLoading, + hasError: hasCampaignsError, + fetchCampaigns, + } = useRewardCampaigns(); + + const campaign = useMemo( + () => + campaigns.find((c) => + routeCampaignId + ? c.id === routeCampaignId + : c.type === CampaignType.PERPS_TRADING, + ) ?? null, + [campaigns, routeCampaignId], + ); + + const effectiveCampaignId = routeCampaignId ?? campaign?.id ?? ''; + + const { + status: participantStatusData, + isLoading: isParticipantStatusLoading, + } = useGetCampaignParticipantStatus(effectiveCampaignId || undefined); + + const isOptedIn = participantStatusData?.optedIn === true; + const campaignStatus = campaign ? getCampaignStatus(campaign) : null; + const isActive = campaignStatus === 'active'; + const isComplete = campaignStatus === 'complete'; + + const { + leaderboard, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, + refetch: refetchLeaderboard, + } = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined); + + const { position } = useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? effectiveCampaignId || undefined : undefined, + ); + + const { + volume, + isLoading: isVolumeLoading, + hasError: hasVolumeError, + refetch: refetchVolume, + } = useGetPerpsTradingCampaignVolume(effectiveCampaignId || undefined); + + const leaderboardUserPosition = useMemo( + () => + position + ? { rank: position.rank, neighbors: position.neighbors ?? [] } + : null, + [position], + ); + + const hasPosition = Boolean(leaderboardUserPosition); + const totalParticipants = leaderboard?.totalParticipants ?? 0; + + const { + showHowItWorksSection, + showStatsSummarySection, + showPrizePoolSection, + showLeaderboardSection, + } = useMemo(() => { + if (!campaign) { + return { + showHowItWorksSection: false, + showStatsSummarySection: false, + showPrizePoolSection: false, + showLeaderboardSection: false, + }; + } + + return { + showHowItWorksSection: + Boolean(campaign.details?.howItWorks) && isActive && !hasPosition, + showStatsSummarySection: hasPosition, + showPrizePoolSection: isActive, + showLeaderboardSection: true, + }; + }, [campaign, isActive, hasPosition]); + + const navigateToLeaderboard = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + const navigateToStats = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + const navigateToMechanics = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-details-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + navigateToMechanics, + 'perps-details-mechanics-button', + )} + includesTopInset + /> + + + {isCampaignsLoading && !campaign && ( + + + + + )} + + {!isCampaignsLoading && hasCampaignsError && !campaign && ( + + + + )} + + {campaign && ( + <> + + + {showHowItWorksSection && ( + + + + )} + + {showStatsSummarySection && ( + + + + + {strings('rewards.perps_trading_campaign.stats_title')} + + + + + + + )} + + {showPrizePoolSection && ( + <> + + + + {strings( + 'rewards.perps_trading_campaign.prize_pool_title', + )} + + + + + )} + + {showLeaderboardSection && ( + <> + + + + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_title', + )} + + + + + + {totalParticipants > 0 && ( + + {strings( + 'rewards.perps_trading_campaign.leaderboard_total_participants', + { count: totalParticipants.toLocaleString() }, + )} + + )} + + + + + )} + + )} + + + {/* Bottom CTA */} + {campaign && ( + + )} + + + ); +}; + +export default PerpsTradingCampaignDetailsView; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx new file mode 100644 index 00000000000..b4a0ce8bccd --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx @@ -0,0 +1,305 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignLeaderboardView, { + PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS, +} from './PerpsTradingCampaignLeaderboardView'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { + CampaignType, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; + +const mockGoBack = jest.fn(); +const mockPerpsLeaderboard = jest.fn(); +const mockPerpsStatsHeader = jest.fn(); + +const CAMPAIGN_ID = 'perps-lb-campaign-1'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: CAMPAIGN_ID }, + }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + backButtonProps, + }: { + title: string; + onBack: () => void; + backButtonProps?: { testID?: string }; + }) => + ReactActual.createElement( + View, + { testID: 'perps-lb-header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: backButtonProps?.testID ?? 'perps-lb-back', + }), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsLeaderboard(props); + return ReactActual.createElement(View, { + testID: 'perps-leaderboard-mock', + }); + }, + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignStatsHeader', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsStatsHeader(props); + return ReactActual.createElement(View, { + testID: 'perps-lb-stats-header-mock', + }); + }, + }; +}); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboard'); +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +jest.mock('../hooks/useGetCampaignParticipantStatus'); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseGetLeaderboard = + useGetPerpsTradingCampaignLeaderboard as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboard + >; +const mockUseGetPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockUseGetParticipant = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 4, + pnl: 1000, + notionalVolume: 10_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +const leaderboardHookDefaults = { + leaderboard: { + campaignId: CAMPAIGN_ID, + computedAt: '2025-01-01T00:00:00.000Z', + entries: [{ rank: 1, referralCode: 'A', pnl: 1, qualified: true }], + totalParticipants: 50, + }, + isLoading: false, + hasError: false, + isLeaderboardNotYetComputed: false, + refetch: jest.fn(), +}; + +const mockCampaign = { + id: CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Perps Test', + startDate: '2024-01-01T00:00:00Z', + endDate: '2099-12-31T23:59:59Z', + termsAndConditions: null, + excludedRegions: [], + featured: false, + details: { howItWorks: { title: '', description: '', steps: [] } }, +}; + +const mockState = { + rewards: { + referralCode: 'REFCODE99', + campaigns: [mockCampaign], + }, +}; + +describe('PerpsTradingCampaignLeaderboardView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector(mockState), + ); + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetLeaderboard.mockReturnValue(leaderboardHookDefaults); + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders with the correct container testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-leaderboard-back-button')); + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('fetches leaderboard with route campaignId', () => { + render(); + expect(mockUseGetLeaderboard).toHaveBeenCalledWith(CAMPAIGN_ID); + }); + + it('does not render the stats header when the user is not opted in', () => { + const { queryByTestId } = render(); + expect(queryByTestId('perps-lb-stats-header-mock')).toBeNull(); + }); + + it('passes undefined to position hook when not opted in', () => { + render(); + expect(mockUseGetPosition).toHaveBeenCalledWith(undefined); + }); + + it('renders the stats header and passes campaignId to position hook when opted in', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 10 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + const { getByTestId } = render(); + expect(getByTestId('perps-lb-stats-header-mock')).toBeDefined(); + expect(mockUseGetPosition).toHaveBeenCalledWith(CAMPAIGN_ID); + expect(mockPerpsStatsHeader).toHaveBeenCalledWith( + expect.objectContaining({ + position: basePosition, + isLoading: false, + }), + ); + }); + + it('passes leaderboard data and user position to PerpsTradingCampaignLeaderboard', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 10 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + render(); + expect(mockPerpsLeaderboard).toHaveBeenCalledWith( + expect.objectContaining({ + entries: leaderboardHookDefaults.leaderboard?.entries, + isLoading: leaderboardHookDefaults.isLoading, + hasError: leaderboardHookDefaults.hasError, + isLeaderboardNotYetComputed: + leaderboardHookDefaults.isLeaderboardNotYetComputed, + currentUserReferralCode: 'REFCODE99', + userPosition: { + rank: basePosition.rank, + neighbors: basePosition.neighbors, + }, + campaignId: CAMPAIGN_ID, + onRetry: leaderboardHookDefaults.refetch, + isCampaignComplete: false, + }), + ); + }); + + it('renders the leaderboard section', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-leaderboard-mock')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx new file mode 100644 index 00000000000..a62780a64e2 --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx @@ -0,0 +1,142 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { Box, TextVariant } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useSelector } from 'react-redux'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import PerpsTradingCampaignLeaderboard from '../components/Campaigns/PerpsTradingCampaignLeaderboard'; +import PerpsTradingCampaignStatsHeader from '../components/Campaigns/PerpsTradingCampaignStatsHeader'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { + selectReferralCode, + selectCampaignById, +} from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignLeaderboardRouteParams = { + RewardsPerpsTradingCampaignLeaderboard: { campaignId: string }; +}; + +export const PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS = { + CONTAINER: 'perps-campaign-leaderboard-view-container', +} as const; + +const PerpsTradingCampaignLeaderboardView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignLeaderboardRouteParams, + 'RewardsPerpsTradingCampaignLeaderboard' + > + >(); + const { campaignId } = route.params; + const referralCode = useSelector(selectReferralCode); + + const selectCampaign = useMemo( + () => selectCampaignById(campaignId), + [campaignId], + ); + const campaign = useSelector(selectCampaign); + + const { status: participantStatus } = + useGetCampaignParticipantStatus(campaignId); + const isOptedIn = participantStatus?.optedIn === true; + + const { position, isLoading: isPositionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? campaignId : undefined, + ); + + const { + leaderboard, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, + refetch: refetchLeaderboard, + } = useGetPerpsTradingCampaignLeaderboard(campaignId); + + const leaderboardUserPosition = useMemo( + () => + position + ? { rank: position.rank, neighbors: position.neighbors ?? [] } + : null, + [position], + ); + + const isCampaignComplete = + campaign != null && getCampaignStatus(campaign) === 'complete'; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-leaderboard-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + () => + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId, + }), + 'perps-leaderboard-mechanics-button', + )} + includesTopInset + /> + + {/* User position header */} + {isOptedIn && ( + <> + + + + + + )} + + {/* Full leaderboard */} + + + + + + + ); +}; + +export default PerpsTradingCampaignLeaderboardView; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx new file mode 100644 index 00000000000..179886d186f --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx @@ -0,0 +1,392 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignStatsView, { + PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS, +} from './PerpsTradingCampaignStatsView'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { + CampaignType, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); +const mockPerpsStatsHeader = jest.fn(); + +const CAMPAIGN_ID = 'perps-stats-campaign-1'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), + useRoute: () => ({ + params: { campaignId: CAMPAIGN_ID }, + }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const Skeleton = (props: Record) => + ReactActual.createElement(View, { testID: 'skeleton', ...props }); + return { ...actual, Skeleton }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + backButtonProps, + endButtonIconProps, + }: { + title: string; + onBack: () => void; + backButtonProps?: { testID?: string }; + endButtonIconProps?: { testID?: string; onPress?: () => void }[]; + }) => + ReactActual.createElement( + View, + { testID: 'perps-stats-header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: backButtonProps?.testID ?? 'perps-stats-back', + }), + ...(endButtonIconProps ?? []).map((btn, i) => + ReactActual.createElement(Pressable, { + key: i, + onPress: btn.onPress, + testID: btn.testID, + }), + ), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('../components/Campaigns/PerpsTradingCampaignStatsHeader', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsStatsHeader(props); + return ReactActual.createElement(View, { + testID: 'perps-stats-header-mock', + }); + }, + }; +}); + +jest.mock('../utils/formatUtils', () => ({ + formatSignedUsd: (value: number) => `SIGNED_USD_${String(value)}`, + formatUsd: (value: number) => `USD_${String(value)}`, + formatRewardsTimeOnly: () => 'TIME_STUB', +})); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { + testID: testID ?? 'rewards-error-banner', + }), + }; +}); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +jest.mock('../hooks/useGetCampaignParticipantStatus'); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseGetPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockUseGetParticipant = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 4, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +const mockCampaign = { + id: CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Perps Stats Test', + startDate: '2024-01-01T00:00:00Z', + endDate: '2099-12-31T23:59:59Z', + termsAndConditions: null, + excludedRegions: [], + featured: false, + details: { howItWorks: { title: '', description: '', steps: [] } }, +}; + +const mockState = { + rewards: { + campaigns: [mockCampaign], + }, +}; + +describe('PerpsTradingCampaignStatsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector(mockState), + ); + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 5 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders with the correct container testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-stats-back-button')); + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('navigates to campaign mechanics when the header mechanics button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-stats-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: CAMPAIGN_ID }, + ); + }); + + it('passes position to stats header with PnL and computed-at hidden', () => { + render(); + expect(mockPerpsStatsHeader).toHaveBeenCalledWith( + expect.objectContaining({ + position: basePosition, + isLoading: false, + showPnl: false, + showComputedAt: false, + }), + ); + }); + + it('passes undefined to position hook when not opted in', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + render(); + expect(mockUseGetPosition).toHaveBeenCalledWith(undefined); + }); + + it('renders performance section labels and stat testIDs when opted in with position', () => { + const { getByTestId, getByText } = render( + , + ); + expect( + getByText('rewards.perps_trading_campaign.performance_title'), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN), + ).toBeDefined(); + }); + + it('shows last-computed when position has a timestamp', () => { + const { getByTestId } = render(); + const el = getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED); + expect(el.props.children).toBe( + 'rewards.perps_trading_campaign.last_updated', + ); + }); + + it('hides last-computed when there is no position', () => { + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED), + ).toBeNull(); + }); + + it("shows You're qualified card under performance when active and user is qualified", () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeNull(); + }); + + it('hides qualification cards when campaign is complete and shows last-computed after performance when position exists', () => { + const completeCampaign = { + ...mockCampaign, + endDate: '2020-01-01T00:00:00Z', + }; + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { campaigns: [completeCampaign] }, + }), + ); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeNull(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeNull(); + const last = getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED); + const qualified = queryByTestId( + PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD, + ); + expect(qualified).toBeNull(); + expect(last).toBeDefined(); + }); + + it('shows Qualify for rank card when pending and notional is below threshold', () => { + mockUseGetPosition.mockReturnValue({ + position: { + ...basePosition, + qualified: false, + notionalVolume: 5_000, + marginDeployed: 500, + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeNull(); + }); + + it('shows error banner when hasError is true and no position data', () => { + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId('rewards-error-banner')).toBeDefined(); + }); + + it('hides error banner when hasError is false', () => { + const { queryByTestId } = render(); + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); + + it('hides error banner when there is an error but position data is already loaded', () => { + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx new file mode 100644 index 00000000000..8e6213ed14d --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx @@ -0,0 +1,278 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconName, + IconColor, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useSelector } from 'react-redux'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; +import PerpsTradingCampaignStatsHeader from '../components/Campaigns/PerpsTradingCampaignStatsHeader'; +import { StatCell } from '../components/Campaigns/OndoCampaignStatsSummary'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { selectCampaignById } from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../utils/perpsCampaignConstants'; +import { + formatRewardsTimeOnly, + formatSignedUsd, + formatUsd, +} from '../utils/formatUtils'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignStatsRouteParams = { + RewardsPerpsTradingCampaignStats: { campaignId: string }; +}; + +export const PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS = { + CONTAINER: 'perps-campaign-stats-view-container', + PERFORMANCE_PNL: 'perps-campaign-stats-view-performance-pnl', + PERFORMANCE_VOLUME: 'perps-campaign-stats-view-performance-volume', + PERFORMANCE_MARGIN: 'perps-campaign-stats-view-performance-margin', + QUALIFIED_CARD: 'perps-campaign-stats-view-qualified-card', + QUALIFY_FOR_RANK_CARD: 'perps-campaign-stats-view-qualify-for-rank-card', + LAST_COMPUTED: 'perps-campaign-stats-view-last-computed', +} as const; + +const CheckIcon: React.FC = () => ( + +); + +const PerpsTradingCampaignStatsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignStatsRouteParams, + 'RewardsPerpsTradingCampaignStats' + > + >(); + const { campaignId } = route.params; + + const selectCampaign = useMemo( + () => selectCampaignById(campaignId), + [campaignId], + ); + const campaign = useSelector(selectCampaign); + + const { status: participantStatusData } = + useGetCampaignParticipantStatus(campaignId); + const isOptedIn = participantStatusData?.optedIn === true; + + const { position, isLoading, hasError, refetch } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? campaignId : undefined, + ); + + const pnlValue = position ? formatSignedUsd(position.pnl) : '—'; + const pnlColor = position + ? position.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const volumeValue = position ? formatUsd(position.notionalVolume) : '—'; + const marginValue = position ? formatUsd(position.marginDeployed) : '—'; + const isQualified = position != null && position.qualified; + const isPending = position != null && !position.qualified; + + const isCampaignComplete = + campaign != null && getCampaignStatus(campaign) === 'complete'; + + const notionalGap = position + ? Math.max(0, PERPS_QUALIFICATION_NOTIONAL_USD - position.notionalVolume) + : 0; + + const showQualifiedCard = + !isCampaignComplete && isQualified && position != null; + + const showQualifyForRankCard = + !isCampaignComplete && isPending && position != null && notionalGap > 0; + + const positionError = hasError && !position; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-stats-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + () => + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId, + }), + 'perps-stats-mechanics-button', + )} + includesTopInset + /> + + + + + + + + {strings('rewards.perps_trading_campaign.performance_title')} + + + + + + + + + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} + /> + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} + /> + + + {showQualifiedCard && ( + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_title', + )} + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_description', + )} + + + )} + + {showQualifyForRankCard && ( + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_title', + )} + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_description', + { + notionalRemaining: formatUsd(notionalGap), + }, + )} + + + )} + + {/* ── Last updated ── */} + {position?.computedAt && ( + + {strings('rewards.perps_trading_campaign.last_updated', { + time: formatRewardsTimeOnly(new Date(position.computedAt)), + })} + + )} + + {/* ── Error banner ── */} + {positionError && ( + + )} + + + + + ); +}; + +export default PerpsTradingCampaignStatsView; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx index 59b768e2f23..d9cfefa4068 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx @@ -8,7 +8,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import type { CampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { StatCell } from './CampaignStatsSummary'; +import { StatCell } from './OndoCampaignStatsSummary'; import RewardsErrorBanner from '../RewardsErrorBanner'; import { strings } from '../../../../../../locales/i18n'; import { formatCompactUsd, formatPercentChange } from '../../utils/formatUtils'; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx new file mode 100644 index 00000000000..bcf271f5968 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Skeleton } from '@metamask/design-system-react-native'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const actual = jest.requireActual('@metamask/design-system-react-native'); + const { View } = jest.requireActual('react-native'); + /** Avoid Skeleton Animated/act noise while keeping type identity for row counts. */ + function SkeletonMock(props: object) { + return ReactActual.createElement(View, props); + } + SkeletonMock.displayName = 'Skeleton'; + return { + ...actual, + Skeleton: SkeletonMock, + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon'); + +jest.mock('./OndoCampaignStatsSummary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + PendingTag: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID }), + }; +}); + +const IDS = CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS; + +const baseEntry = { + rank: 7, + referralCode: 'USER01', + qualified: true, +}; + +describe('CampaignLeaderboardEntryRow', () => { + it('renders padded rank, referral code, and formatted metric', () => { + const formatPrimaryMetric = jest.fn(() => '+12.5%'); + const isPositivePrimaryMetric = jest.fn(() => true); + + const { getByText } = render( + , + ); + + expect(getByText('07')).toBeDefined(); + expect(getByText('USER01')).toBeDefined(); + expect(getByText('+12.5%')).toBeDefined(); + expect(formatPrimaryMetric).toHaveBeenCalledWith(baseEntry); + expect(isPositivePrimaryMetric).toHaveBeenCalledWith(baseEntry); + }); + + it('sets row testID from shared ENTRY_ROW and rank', () => { + const { getByTestId } = render( + 'm'} + isPositivePrimaryMetric={() => true} + />, + ); + + expect(getByTestId(`${IDS.ENTRY_ROW}-3`)).toBeDefined(); + }); + + it('shows pending tag when current user is unqualified and campaign is active', () => { + const { getByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(getByTestId(IDS.PENDING_TAG)).toBeDefined(); + }); + + it('hides pending tag when campaign is complete', () => { + const { queryByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(queryByTestId(IDS.PENDING_TAG)).toBeNull(); + }); + + it('hides pending tag when row is not the current user', () => { + const { queryByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(queryByTestId(IDS.PENDING_TAG)).toBeNull(); + }); +}); + +describe('CampaignLeaderboardSkeleton', () => { + it('uses shared LOADING testID', () => { + const { getByTestId } = render(); + + expect(getByTestId(IDS.LOADING)).toBeDefined(); + }); + + it('renders default skeleton row count (10 rows × 3 skeletons each)', () => { + const { UNSAFE_getAllByType } = render(); + + expect(UNSAFE_getAllByType(Skeleton)).toHaveLength(30); + }); + + it('respects skeletonRowCount', () => { + const { UNSAFE_getAllByType } = render( + , + ); + + expect(UNSAFE_getAllByType(Skeleton)).toHaveLength(12); + }); +}); + +describe('CampaignLeaderboardNeighborSeparator', () => { + it('uses shared NEIGHBOR_SEPARATOR testID and ellipsis label', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByText('•••')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx new file mode 100644 index 00000000000..45b9fa6eb2e --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextColor, + TextVariant, + FontWeight, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import CrownIcon from '../../../../../images/rewards/crown.svg'; +import { PendingTag } from './OndoCampaignStatsSummary'; + +/** Shared testIDs for leaderboard rows, pending tag, separator, and skeleton (Ondo + Perps). */ +export const CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS = { + ENTRY_ROW: 'campaign-leaderboard-entry-row', + PENDING_TAG: 'campaign-leaderboard-pending-tag', + NEIGHBOR_SEPARATOR: 'campaign-leaderboard-neighbor-separator', + LOADING: 'campaign-leaderboard-loading', +} as const; + +/** Fields required to render a campaign leaderboard row (Ondo, Perps, etc.). */ +export interface CampaignLeaderboardRowEntry { + rank: number; + referralCode: string; + qualified: boolean; +} + +export interface CampaignLeaderboardEntryRowProps< + T extends CampaignLeaderboardRowEntry, +> { + entry: T; + isCurrentUser?: boolean; + showCrown?: boolean; + /** When true, hides the pending tag for the current user’s row (campaign ended). */ + isCampaignComplete?: boolean; + formatPrimaryMetric: (entry: T) => string; + isPositivePrimaryMetric: (entry: T) => boolean; +} + +export function CampaignLeaderboardEntryRow< + T extends CampaignLeaderboardRowEntry, +>({ + entry, + isCurrentUser = false, + showCrown = false, + isCampaignComplete = false, + formatPrimaryMetric, + isPositivePrimaryMetric, +}: CampaignLeaderboardEntryRowProps) { + const isPositive = isPositivePrimaryMetric(entry); + const textColor = isCurrentUser + ? isPositive + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + const isPending = !entry.qualified; + const rowBg = isCurrentUser + ? isPending + ? 'bg-muted' + : isPositive + ? 'bg-success-muted' + : 'bg-error-muted' + : ''; + + const showPendingTag = isCurrentUser && isPending && !isCampaignComplete; + + return ( + + + + {String(entry.rank).padStart(2, '0')} + + + + {entry.referralCode} + + {showCrown && entry.rank <= 5 && ( + + )} + + {showPendingTag && ( + + )} + + + {formatPrimaryMetric(entry)} + + + ); +} + +export interface CampaignLeaderboardSkeletonProps { + /** Number of placeholder rows (default 10; Perps uses 5). */ + skeletonRowCount?: number; +} + +const DEFAULT_SKELETON_ROW_COUNT = 10; + +export const CampaignLeaderboardSkeleton: React.FC< + CampaignLeaderboardSkeletonProps +> = ({ skeletonRowCount = DEFAULT_SKELETON_ROW_COUNT }) => { + const tw = useTailwind(); + const rows = Array.from( + { length: skeletonRowCount }, + (_, index) => index + 1, + ); + + return ( + + + {rows.map((i) => ( + + + + + + + + + + ))} + + + ); +}; + +export const CampaignLeaderboardNeighborSeparator: React.FC = () => ( + + + + ••• + + + +); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx index 579ebae406d..9d717e57db3 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -42,6 +42,7 @@ interface CampaignTileProps { * Tapping behavior is determined by campaign type: * - ONDO_HOLDING: navigates to Ondo campaign details * - SEASON_1: navigates to season one campaign details + * - PERPS_TRADING: navigates to Perps Trading campaign details * - Unsupported types: non-interactive unless onPress is provided * - With onPress: executes custom handler regardless of type */ @@ -59,7 +60,9 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { const { status: participantStatus, isLoading: isParticipantStatusLoading } = useGetCampaignParticipantStatus( - campaignStatus === 'active' && campaign.type === CampaignType.ONDO_HOLDING + campaignStatus === 'active' && + (campaign.type === CampaignType.ONDO_HOLDING || + campaign.type === CampaignType.PERPS_TRADING) ? campaign.id : undefined, ); @@ -110,6 +113,19 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { navigation.navigate(Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW, { campaignId: campaign.id, }); + } else if (campaign.type === CampaignType.PERPS_TRADING) { + if (shouldShowTour) { + navigation.navigate(Routes.REWARDS_CAMPAIGN_TOUR_STEP, { + campaignId: campaign.id, + }); + } else { + navigation.navigate( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + { + campaignId: campaign.id, + }, + ); + } } }; diff --git a/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx b/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx index 66db7e8a976..7ebb591e7c3 100644 --- a/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx +++ b/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx @@ -14,7 +14,11 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { StatCell, PendingTag, IneligibleTag } from './CampaignStatsSummary'; +import { + StatCell, + PendingTag, + IneligibleTag, +} from './OndoCampaignStatsSummary'; import { strings } from '../../../../../../locales/i18n'; export const LEADERBOARD_POSITION_HEADER_TEST_IDS = { @@ -23,6 +27,7 @@ export const LEADERBOARD_POSITION_HEADER_TEST_IDS = { RETURN_VALUE: 'leaderboard-position-header-return', TIER_VALUE: 'leaderboard-position-header-tier', PRIZE_POOL_VALUE: 'leaderboard-position-header-prize-pool', + COMPUTED_AT: 'leaderboard-position-header-computed-at', PENDING_TAG: 'leaderboard-position-header-pending-tag', INELIGIBLE_TAG: 'leaderboard-position-header-ineligible-tag', QUALIFIED_ICON: 'leaderboard-position-header-qualified-icon', @@ -41,6 +46,8 @@ interface LeaderboardPositionHeaderProps { showPrizePool?: boolean; prizePoolValue?: string; prizePoolLoading?: boolean; + showComputedAt?: boolean; + computedAt?: string | null; } const LeaderboardPositionHeader: React.FC = ({ @@ -58,6 +65,7 @@ const LeaderboardPositionHeader: React.FC = ({ prizePoolLoading = false, }) => { const tw = useTailwind(); + const showSubtextRow = showReturn && Boolean(returnValue); return ( = ({ {isLoading ? ( - + <> + + {showSubtextRow && ( + + )} + ) : ( <> = ({ > {rank} - {showReturn && returnValue && ( - - {returnValue} - + + {showReturn && returnValue && ( + + {returnValue} + + )} + + )} )} diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx similarity index 77% rename from app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx rename to app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx index 41ee6d2e016..2ba425c2bf6 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { Text as RNText } from 'react-native'; import { TextColor } from '@metamask/design-system-react-native'; -import CampaignStatsSummary, { +import OndoCampaignStatsSummary, { IneligibleTag, PendingTag, StatCell, - CAMPAIGN_STATS_SUMMARY_TEST_IDS, -} from './CampaignStatsSummary'; + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from './OndoCampaignStatsSummary'; import type { CampaignLeaderboardPositionDto, OndoGmPortfolioSummaryDto, @@ -168,67 +168,69 @@ const baseProps = { }, }; -describe('CampaignStatsSummary', () => { +describe('OndoCampaignStatsSummary', () => { beforeEach(() => { jest.clearAllMocks(); }); it('renders all stats when both position and summary are provided', () => { - const { getByTestId } = render(); + const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('$13,057.58'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('Silver'); }); it('displays dash for rank and tier when leaderboard position is null but return from portfolio', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('-'); }); it('displays dash for return when portfolio summary is null', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('-'); }); it('displays dash for market value when portfolio summary is null', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); }); @@ -240,7 +242,7 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.color, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .color, ).toBe(TextColor.ErrorDefault); }); it('uses success color for market value when portfolioPnl is positive', () => { - const { getByTestId } = render(); + const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.color, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .color, ).toBe(TextColor.SuccessDefault); }); it('omits valueColor for market value when portfolioSummary is null', () => { const { getByTestId } = render( - , + , ); // Returns '-' and uses the StatCell default color (TextDefault) expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('-'); }); @@ -278,14 +283,14 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('-5.00%'); }); @@ -299,39 +304,39 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId, getAllByText } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeOnTheScreen(); expect(getAllByText('Pending')).toHaveLength(1); }); it('renders check icon on rank cell and no Pending tags when qualified is true', () => { const { getByTestId, queryAllByText, queryByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.QUALIFIED_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.QUALIFIED_TAG), ).toBeOnTheScreen(); expect(queryAllByText('Pending')).toHaveLength(0); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); it('does not render tags when leaderboardPosition is null', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); @@ -339,7 +344,7 @@ describe('CampaignStatsSummary', () => { it('shows skeletons for leaderboard cells when leaderboard is loading with no data', () => { const { queryByTestId } = render( - { ); // Return and market value still render since portfolio is fine - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeDefined(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeDefined(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeDefined(); }); it('shows stale leaderboard data instead of skeletons when loading with existing data', () => { const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); }); @@ -375,7 +382,7 @@ describe('CampaignStatsSummary', () => { it('shows skeleton for market value cell when portfolio is loading with no data', () => { const { queryByTestId } = render( - { ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); // Return also shows skeleton since it now comes from portfolio - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeNull(); // Leaderboard cells still render - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeDefined(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK), + ).toBeDefined(); }); it('shows stale market value data instead of skeleton when loading with existing data', () => { const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('$13,057.58'); }); @@ -408,7 +420,7 @@ describe('CampaignStatsSummary', () => { it('shows all skeletons when both sources are loading with no data', () => { const { queryByTestId } = render( - { />, ); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeNull(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); }); // ── Leaderboard error ───────────────────────────────────────────── it('shows stats error banner when leaderboard fails with no data', () => { const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeDefined(); }); it('calls both refetches on stats error retry when leaderboard fails', () => { const { getByTestId } = render( - { ); fireEvent.press( - getByTestId(`${CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), + getByTestId(`${ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), ); expect(mockLeaderboardRefetch).toHaveBeenCalledTimes(1); expect(mockPortfolioRefetch).toHaveBeenCalledTimes(1); @@ -459,14 +473,14 @@ describe('CampaignStatsSummary', () => { it('hides stats error when stale leaderboard data exists', () => { const { queryByTestId } = render( - , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeNull(); }); @@ -474,7 +488,7 @@ describe('CampaignStatsSummary', () => { it('shows stats error banner when portfolio fails with no data', () => { const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeDefined(); }); it('calls both refetches on stats error retry when portfolio fails', () => { const { getByTestId } = render( - { ); fireEvent.press( - getByTestId(`${CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), + getByTestId(`${ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), ); expect(mockPortfolioRefetch).toHaveBeenCalledTimes(1); expect(mockLeaderboardRefetch).toHaveBeenCalledTimes(1); @@ -506,7 +520,7 @@ describe('CampaignStatsSummary', () => { it('shows a single stats error banner when both sources fail with no data', () => { const { getAllByTestId } = render( - { ); expect( - getAllByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getAllByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toHaveLength(1); }); @@ -529,21 +543,21 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 0, }; const { getByTestId, getAllByText } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), ).toBeOnTheScreen(); expect(getAllByText('Ineligible')).toHaveLength(1); }); it('shows dash for rank and tier when isIneligible=true even with leaderboard data', () => { const { getByTestId } = render( - { />, ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('-'); }); it('shows not-eligible banner when isIneligible=true', () => { const { getByTestId, getByText } = render( - { />, ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeOnTheScreen(); expect(getByText('Not eligible')).toBeOnTheScreen(); }); it('hides pending tags when isIneligible=true', () => { const { queryAllByText } = render( - { it('does not show ineligible tags when isIneligible=false', () => { const { queryAllByText, queryByTestId } = render( - { ); expect(queryAllByText('Ineligible')).toHaveLength(0); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); it('does not show not-eligible banner when isIneligible defaults to false', () => { - const { queryByTestId } = render(); + const { queryByTestId } = render( + , + ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); @@ -623,10 +639,14 @@ describe('CampaignStatsSummary', () => { it('hides IneligibleTag from rank cell suffix when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), ).toBeNull(); }); @@ -637,20 +657,20 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 3, }; const { queryByTestId } = render( - , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); it('hides qualified card when isCampaignComplete=true', () => { const { queryByText } = render( - { it('hides not-eligible banner when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); @@ -675,7 +699,7 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 4, }; const { queryByText } = render( - { it('hides market value cell when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); }); it('shows market value cell when isCampaignComplete=false', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeDefined(); }); it('shows outcome banner when isCampaignComplete=true and outcome is provided', () => { const { getByTestId } = render( - { it('does not show outcome banner when isCampaignComplete=false', () => { const { queryByTestId } = render( - { it('shows the qualified explainer card when qualified and tierMinDeposit is set', () => { const { getByText } = render( - , + , ); expect(getByText('You are qualified')).toBeOnTheScreen(); expect(getByText(/Qualified copy/)).toBeOnTheScreen(); @@ -744,7 +768,7 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 4, }; const { getByText } = render( - { qualifiedDays: 10, }; const { queryByText } = render( - = ({ ); }; -export const CAMPAIGN_STATS_SUMMARY_TEST_IDS = { +export const ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS = { CONTAINER: 'campaign-stats-summary-container', RETURN: 'campaign-stats-summary-return', MARKET_VALUE: 'campaign-stats-summary-market-value', @@ -121,7 +121,7 @@ interface DataSourceState { refetch: () => void; } -interface CampaignStatsSummaryProps { +interface OndoCampaignStatsSummaryProps { leaderboardPosition: CampaignLeaderboardPositionDto | null; portfolioSummary: OndoGmPortfolioSummaryDto | null; leaderboard: DataSourceState; @@ -136,7 +136,7 @@ interface CampaignStatsSummaryProps { onWinnerPress?: () => void; } -const CampaignStatsSummary: React.FC = ({ +const OndoCampaignStatsSummary: React.FC = ({ leaderboardPosition, portfolioSummary, leaderboard, @@ -184,29 +184,32 @@ const CampaignStatsSummary: React.FC = ({ : formatTierDisplayName(leaderboardPosition.projectedTier); return ( - + {/* Rank | Tier */} ) : !isCampaignComplete && isPending ? ( ) : isQualified ? ( ) : undefined } @@ -215,7 +218,7 @@ const CampaignStatsSummary: React.FC = ({ label={strings('rewards.ondo_campaign_stats.label_tier')} value={tierValue} isLoading={leaderboardLoading} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER} /> @@ -226,7 +229,7 @@ const CampaignStatsSummary: React.FC = ({ value={returnValue} isLoading={portfolioLoading} valueColor={returnColor} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN} /> {!isCampaignComplete && ( = ({ value={marketValue} isLoading={portfolioLoading} valueColor={returnColor} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE} /> )} @@ -272,7 +275,7 @@ const CampaignStatsSummary: React.FC = ({ {!isCampaignComplete && isIneligible && ( {strings('rewards.ondo_campaign_stats.not_eligible_title')} @@ -337,11 +340,11 @@ const CampaignStatsSummary: React.FC = ({ portfolio.refetch(); }} confirmButtonLabel={strings('rewards.ondo_campaign_stats.retry')} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR} /> )} ); }; -export default CampaignStatsSummary; +export default OndoCampaignStatsSummary; diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx index c70ec6cc636..43c1aa1b231 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx @@ -12,15 +12,17 @@ import { TextColor, TextVariant, FontWeight, - Skeleton, } from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import type { CampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import CrownIcon from '../../../../../images/rewards/crown.svg'; -import { PendingTag } from './CampaignStatsSummary'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; import { formatRateOfReturn, formatTierDisplayName, @@ -30,13 +32,14 @@ export const CAMPAIGN_LEADERBOARD_TEST_IDS = { CONTAINER: 'campaign-leaderboard-container', TIER_TOGGLE: 'campaign-leaderboard-tier-toggle', LIST: 'campaign-leaderboard-list', - ENTRY_ROW: 'campaign-leaderboard-entry-row', - PENDING_TAG: 'campaign-leaderboard-pending-tag', - NEIGHBOR_SEPARATOR: 'campaign-leaderboard-neighbor-separator', - LOADING: 'campaign-leaderboard-loading', + ENTRY_ROW: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.ENTRY_ROW, + PENDING_TAG: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.PENDING_TAG, + NEIGHBOR_SEPARATOR: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.NEIGHBOR_SEPARATOR, + LOADING: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.LOADING, ERROR: 'campaign-leaderboard-error', EMPTY: 'campaign-leaderboard-empty', NOT_YET_COMPUTED: 'campaign-leaderboard-not-yet-computed', + LAST_COMPUTED: 'campaign-leaderboard-last-computed', } as const; const MAX_ENTRIES_LIMIT = 20; @@ -72,137 +75,6 @@ interface CampaignLeaderboardProps { hideTierHeader?: boolean; } -/** - * LeaderboardEntryRow displays a single leaderboard entry - */ -const LeaderboardEntryRow: React.FC<{ - entry: CampaignLeaderboardEntry; - isCurrentUser?: boolean; - showCrown?: boolean; - isCampaignComplete?: boolean; -}> = ({ - entry, - isCurrentUser = false, - showCrown = false, - isCampaignComplete = false, -}) => { - const isPositiveReturn = entry.rateOfReturn >= 0; - const textColor = isCurrentUser - ? isPositiveReturn - ? TextColor.SuccessDefault - : TextColor.ErrorDefault - : TextColor.TextDefault; - const isPending = !entry.qualified; - const rowBg = isCurrentUser - ? isPending - ? 'bg-muted' - : isPositiveReturn - ? 'bg-success-muted' - : 'bg-error-muted' - : ''; - - return ( - - - - {String(entry.rank).padStart(2, '0')} - - - - {entry.referralCode} - - {showCrown && entry.rank <= 5 && ( - - )} - - {isCurrentUser && isPending && !isCampaignComplete && ( - - )} - - - {formatRateOfReturn(entry.rateOfReturn)} - - - ); -}; - -/** - * LeaderboardSkeleton displays loading skeleton for the leaderboard section - */ -const LeaderboardSkeleton: React.FC = () => { - const tw = useTailwind(); - - return ( - - {/* Leaderboard rows skeleton */} - - {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ( - - - - - - - - - - ))} - - - ); -}; - -/** - * OndoLeaderboard displays the leaderboard tiers and entries for a campaign. - * Position-specific data (user rank, tier, deposited value) is handled separately - * by the OndoLeaderboardPosition component. - */ -const NeighborSeparator: React.FC = () => ( - - - - ••• - - - -); - const OndoLeaderboard: React.FC = ({ tierNames, selectedTier, @@ -305,7 +177,7 @@ const OndoLeaderboard: React.FC = ({ ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { @@ -399,24 +271,30 @@ const OndoLeaderboard: React.FC = ({ {visibleEntries.length > 0 ? ( {visibleEntries.map((entry) => ( - formatRateOfReturn(e.rateOfReturn)} + isPositivePrimaryMetric={(e) => e.rateOfReturn >= 0} /> ))} {showSplitView && userPosition && ( <> - + {userPosition.neighbors.map((entry) => ( - + formatRateOfReturn(e.rateOfReturn) + } + isPositivePrimaryMetric={(e) => e.rateOfReturn >= 0} /> ))} diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts index ae2b7f7ac1d..ef0a551d155 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts @@ -1,6 +1,5 @@ import { buildLeaderboardUserPosition, - formatComputedAt, formatRateOfReturn, formatTierDisplayName, getCampaignTierNames, @@ -9,13 +8,20 @@ import { import type { CampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string) => { + strings: (key: string, params?: Record) => { const t: Record = { 'rewards.ondo_campaign_leaderboard.tier_starter': 'Bronze', 'rewards.ondo_campaign_leaderboard.tier_mid': 'Silver', 'rewards.ondo_campaign_leaderboard.tier_upper': 'Platinum', + 'rewards.perps_trading_campaign.last_updated': 'Last updated: {{time}}', }; - return t[key] ?? key; + let template = t[key] ?? key; + if (params) { + for (const [paramKey, value] of Object.entries(params)) { + template = template.split(`{{${paramKey}}}`).join(value); + } + } + return template; }, default: { locale: 'en-US' }, })); @@ -47,35 +53,6 @@ describe('OndoLeaderboard.utils', () => { }); }); - describe('formatComputedAt', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-03-20T12:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns empty string for null', () => { - expect(formatComputedAt(null)).toBe(''); - }); - - it('returns empty string for empty string', () => { - expect(formatComputedAt('')).toBe(''); - }); - - it('returns a non-empty string for a valid ISO timestamp', () => { - const result = formatComputedAt('2024-03-20T12:00:00.000Z'); - expect(result).toBeTruthy(); - expect(typeof result).toBe('string'); - }); - - it('returns empty string for an unparseable value', () => { - expect(formatComputedAt('not-a-date')).toBe(''); - }); - }); - describe('formatTierDisplayName', () => { it('maps STARTER to Bronze', () => { expect(formatTierDisplayName('STARTER')).toBe('Bronze'); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts index 11c7e36b140..bceab2d2e8d 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts @@ -6,10 +6,7 @@ import type { } from '../../../../../core/Engine/controllers/rewards-controller/types'; // Re-export shared helpers so existing consumers keep working -export { - formatPercentChange as formatRateOfReturn, - formatComputedAt, -} from '../../utils/formatUtils'; +export { formatPercentChange as formatRateOfReturn } from '../../utils/formatUtils'; // ── Tier display names ────────────────────────────────────────────────── diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx index c8c0e11a814..ce235b5c58b 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx @@ -169,10 +169,6 @@ jest.mock('../../../../../util/ondoGeoRestrictions', () => ({ isGeoRestricted: jest.fn(() => false), })); -jest.mock('./OndoLeaderboard.utils', () => ({ - formatComputedAt: jest.fn(), -})); - jest.mock('../../../../../images/rewards/rewards-no-positions.svg', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx index 1cc026ed32c..bd1e1c9cb21 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx @@ -1,9 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import OndoPrizePool, { - ONDO_PRIZE_POOL_TEST_IDS, - getCurrentPrize, -} from './OndoPrizePool'; +import OndoPrizePool, { ONDO_PRIZE_POOL_TEST_IDS } from './OndoPrizePool'; jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); @@ -218,40 +215,3 @@ describe('OndoPrizePool', () => { expect(mockRefetch).toHaveBeenCalledTimes(1); }); }); - -describe('getCurrentPrize', () => { - it('returns $25,000 for $0 deposits', () => { - expect(getCurrentPrize(0)).toBe(25_000); - }); - - it('returns $25,000 for deposits below $1.5M', () => { - expect(getCurrentPrize(500_000)).toBe(25_000); - expect(getCurrentPrize(1_499_999)).toBe(25_000); - }); - - it('returns $50,000 at exactly $1.5M', () => { - expect(getCurrentPrize(1_500_000)).toBe(50_000); - }); - - it('returns $50,000 for deposits between $1.5M and $3.5M', () => { - expect(getCurrentPrize(2_000_000)).toBe(50_000); - expect(getCurrentPrize(3_499_999)).toBe(50_000); - }); - - it('returns $75,000 at exactly $3.5M', () => { - expect(getCurrentPrize(3_500_000)).toBe(75_000); - }); - - it('returns $75,000 for deposits between $3.5M and $6M', () => { - expect(getCurrentPrize(4_500_000)).toBe(75_000); - expect(getCurrentPrize(5_999_999)).toBe(75_000); - }); - - it('returns $100,000 at exactly $6M', () => { - expect(getCurrentPrize(6_000_000)).toBe(100_000); - }); - - it('returns $100,000 for deposits above $6M', () => { - expect(getCurrentPrize(10_000_000)).toBe(100_000); - }); -}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx index a614713eb56..c734e835ff8 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx @@ -13,6 +13,7 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import RewardsErrorBanner from '../RewardsErrorBanner'; import { formatCompactUsd, formatUsd } from '../../utils/formatUtils'; +import { computePrizePoolProgress } from '../../utils/prizePoolUtils'; import { strings } from '../../../../../../locales/i18n'; export const ONDO_PRIZE_POOL_TEST_IDS = { @@ -30,50 +31,6 @@ export const BREAKPOINTS = [ { deposit: 6_000_000, prize: 100_000 }, ] as const; -export function getCurrentPrize(totalDeposited: number): number { - for (let i = BREAKPOINTS.length - 1; i >= 0; i--) { - if (totalDeposited >= BREAKPOINTS[i].deposit) { - return BREAKPOINTS[i].prize; - } - } - return BREAKPOINTS[0].prize; -} - -function computeProgress(totalDeposited: number) { - let currentIndex = 0; - for (let i = BREAKPOINTS.length - 1; i >= 0; i--) { - if (totalDeposited >= BREAKPOINTS[i].deposit) { - currentIndex = i; - break; - } - } - - const current = BREAKPOINTS[currentIndex]; - const next = BREAKPOINTS[currentIndex + 1]; - - if (!next) { - return { - progress: 1, - currentPrize: current.prize, - nextPrize: null, - nextThreshold: current.deposit, - isMaxTier: true, - }; - } - - const rangeDeposit = next.deposit - current.deposit; - const progressInRange = totalDeposited - current.deposit; - const progress = Math.min(progressInRange / rangeDeposit, 1); - - return { - progress, - currentPrize: current.prize, - nextPrize: next.prize, - nextThreshold: next.deposit, - isMaxTier: false, - }; -} - interface OndoPrizePoolProps { totalUsdDeposited: string | null; isLoading: boolean; @@ -103,7 +60,11 @@ const OndoPrizePool: React.FC = ({ isMaxTier: false, }; } - return computeProgress(parseFloat(totalUsdDeposited)); + return computePrizePoolProgress( + BREAKPOINTS, + parseFloat(totalUsdDeposited), + (m) => m.deposit, + ); }, [totalUsdDeposited]); const progressPercent: `${number}%` = `${Math.round(progress * 100)}%`; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx new file mode 100644 index 00000000000..4039d10506b --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import PerpsCampaignStatsSummary, { + PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from './PerpsCampaignStatsSummary'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const TEST_IDS = PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS; + +const mockLeaderboard = { + campaignId: 'c1', + computedAt: '2025-01-01T00:00:00.000Z', + entries: [], + totalParticipants: 0, +}; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 7, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +describe('PerpsCampaignStatsSummary', () => { + it('renders container and four stat labels', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_rank'), + ).toBeDefined(); + expect(getByText('rewards.perps_trading_campaign.label_pnl')).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_volume'), + ).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_margin'), + ).toBeDefined(); + expect(getByText('07')).toBeDefined(); + expect(getByText('+$1,500.25')).toBeDefined(); + }); + + it('uses success color for non-negative pnl', () => { + const { getByTestId } = render( + , + ); + const pnlCell = getByTestId(TEST_IDS.PNL); + expect(pnlCell.props.color).toBe(TextColor.SuccessDefault); + }); + + it('uses error color for negative pnl', () => { + const { getByTestId } = render( + , + ); + const pnlCell = getByTestId(TEST_IDS.PNL); + expect(pnlCell.props.color).toBe(TextColor.ErrorDefault); + }); + + it('renders em dashes when position is null', () => { + const { getAllByText } = render( + , + ); + expect(getAllByText('—').length).toBeGreaterThanOrEqual(4); + }); + + it('shows pending tag on rank when campaign is active and user is not qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.PENDING_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_TAG)).toBeNull(); + }); + + it('shows qualified check on rank when user is qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it('does not show pending tag on rank when campaign is complete and user is not qualified', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + expect(queryByTestId(TEST_IDS.QUALIFIED_TAG)).toBeNull(); + }); + + it('shows qualified check when campaign is complete and user is qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it("shows You're qualified card when campaign is active and user is qualified", () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_CARD)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); + }); + + it("hides You're qualified card when campaign is complete", () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.QUALIFIED_CARD)).toBeNull(); + }); + + it('shows Qualify for rank card when pending and below qualification thresholds', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_CARD)).toBeNull(); + }); + + it('hides Qualify for rank card when notional volume already meets threshold even if still pending', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx new file mode 100644 index 00000000000..16a2608dfa0 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { formatSignedUsd, formatUsd } from '../../utils/formatUtils'; +import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../../utils/perpsCampaignConstants'; +import { PendingTag, StatCell } from './OndoCampaignStatsSummary'; + +const PERPS_NOTIONAL_THRESHOLD_LABEL = formatUsd( + PERPS_QUALIFICATION_NOTIONAL_USD, +); + +export const PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS = { + CONTAINER: 'perps-campaign-stats-summary-container', + RANK: 'perps-campaign-stats-summary-rank', + PNL: 'perps-campaign-stats-summary-pnl', + NOTIONAL_VOLUME: 'perps-campaign-stats-summary-notional-volume', + MARGIN_DEPLOYED: 'perps-campaign-stats-summary-margin-deployed', + PENDING_TAG: 'perps-campaign-stats-summary-pending-tag', + QUALIFIED_TAG: 'perps-campaign-stats-summary-qualified-tag', + QUALIFIED_CARD: 'perps-campaign-stats-summary-qualified-card', + QUALIFY_FOR_RANK_CARD: 'perps-campaign-stats-summary-qualify-for-rank-card', +} as const; + +export interface PerpsCampaignStatsSummaryProps { + leaderboardPosition: PerpsTradingCampaignLeaderboardPositionDto | null; + /** Passed for future use (e.g. leaderboard-level metadata); stats values come from `leaderboardPosition`. */ + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + /** When false, pending (not yet qualified) users see a {@link PendingTag} next to rank. */ + isCampaignComplete?: boolean; +} + +const PerpsCampaignStatsSummary: React.FC = ({ + leaderboardPosition, + leaderboard: _leaderboard, + isCampaignComplete = false, +}) => { + const isPending = + leaderboardPosition != null && !leaderboardPosition.qualified; + const isQualified = + leaderboardPosition != null && leaderboardPosition.qualified; + + const rankDisplay = leaderboardPosition + ? String(leaderboardPosition.rank).padStart(2, '0') + : '—'; + + const pnlDisplay = leaderboardPosition + ? formatSignedUsd(leaderboardPosition.pnl) + : '—'; + + const pnlColor = leaderboardPosition + ? leaderboardPosition.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const volumeDisplay = leaderboardPosition + ? formatUsd(leaderboardPosition.notionalVolume) + : '—'; + + const marginDisplay = leaderboardPosition + ? formatUsd(leaderboardPosition.marginDeployed) + : '—'; + + const notionalGap = leaderboardPosition + ? Math.max( + 0, + PERPS_QUALIFICATION_NOTIONAL_USD - leaderboardPosition.notionalVolume, + ) + : 0; + + const showQualifiedCard = + !isCampaignComplete && isQualified && leaderboardPosition != null; + + const showQualifyForRankCard = + !isCampaignComplete && + isPending && + leaderboardPosition != null && + notionalGap > 0; + + return ( + + + + ) : isQualified ? ( + + ) : undefined + } + /> + + + + + + + + {showQualifiedCard && ( + + + {strings('rewards.perps_trading_campaign.stats_qualified_title')} + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_description', + )} + + + )} + + {showQualifyForRankCard && ( + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_title', + )} + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_description', + { + notionalRemaining: formatUsd(notionalGap), + }, + )} + + + )} + + ); +}; + +export default PerpsCampaignStatsSummary; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx new file mode 100644 index 00000000000..45efd5f19a9 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignCTA from './PerpsTradingCampaignCTA'; +import { CAMPAIGN_CTA_TEST_IDS } from './CampaignOptInCta'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { selectPerpsEligibility } from '../../../Perps/selectors/perpsController'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +const mockHandleDeeplink = jest.fn(); +jest.mock('../../../../../core/DeeplinkManager', () => ({ + handleDeeplink: (...args: unknown[]) => mockHandleDeeplink(...args), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockShowToast = jest.fn(); +const mockEntriesClosed = jest.fn(() => ({ variant: 'icon' })); + +jest.mock('../../hooks/useRewardsToast', () => ({ + __esModule: true, + default: () => ({ + showToast: mockShowToast, + RewardsToastOptions: { + success: jest.fn(), + error: jest.fn(), + entriesClosed: mockEntriesClosed, + }, + }), +})); + +jest.mock('./CampaignOptInSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-opt-in-sheet' }), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const map: Record = { + 'rewards.perps_trading_campaign.open_position_cta': 'Open Position', + 'rewards.campaign_details.join_campaign': 'Join Campaign', + 'rewards.campaign.geo_locked_cta': 'Geo locked', + 'rewards.campaign.geo_locked_toast_title': 'Not available', + 'rewards.campaign.geo_locked_toast_description': 'Region restricted', + }; + return map[key] ?? key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +function buildCampaign(overrides: Partial = {}): CampaignDto { + return { + id: 'perps-campaign-1', + type: CampaignType.PERPS_TRADING, + name: 'Perps Trading Campaign', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2026-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, + }; +} + +const notOptedIn = { + status: { optedIn: false, participantCount: 0 } as const, + isLoading: false, +}; + +const optedIn = { + status: { optedIn: true, participantCount: 1 } as const, + isLoading: false, +}; + +describe('PerpsTradingCampaignCTA', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); + jest.clearAllMocks(); + mockHandleDeeplink.mockResolvedValue(undefined); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEligibility) { + return true; + } + return undefined; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders nothing while participant status is loading', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders nothing when campaign is upcoming', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders nothing when campaign is complete', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('when opted in, shows Open Position and calls handleDeeplink with perps market-list URL', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('Open Position')).toBeOnTheScreen(); + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(mockHandleDeeplink).toHaveBeenCalledWith({ + uri: 'https://link.metamask.io/perps?screen=market-list', + }); + }); + + it('when not opted in and geo-ineligible, shows geo locked CTA and toast on press', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEligibility) { + return false; + } + return undefined; + }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('Geo locked')).toBeOnTheScreen(); + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(mockEntriesClosed).toHaveBeenCalledWith( + 'Not available', + 'Region restricted', + ); + expect(mockShowToast).toHaveBeenCalledWith({ variant: 'icon' }); + expect(mockHandleDeeplink).not.toHaveBeenCalled(); + }); + + it('when not opted in and eligible, shows Join Campaign and opens opt-in sheet', () => { + const { getByTestId, getByText, queryByTestId } = render( + , + ); + + expect(queryByTestId('campaign-opt-in-sheet')).toBeNull(); + expect(getByText('Join Campaign')).toBeOnTheScreen(); + + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(getByTestId('campaign-opt-in-sheet')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx new file mode 100644 index 00000000000..43f39e96ee0 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + Button, + ButtonSize, + ButtonVariant, + IconName, +} from '@metamask/design-system-react-native'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { UseGetCampaignParticipantStatusResult } from '../../hooks/useGetCampaignParticipantStatus'; +import { getCampaignStatus } from './CampaignTile.utils'; +import { strings } from '../../../../../../locales/i18n'; +import CampaignOptInSheet from './CampaignOptInSheet'; +import { CAMPAIGN_CTA_TEST_IDS } from './CampaignOptInCta'; +import { selectPerpsEligibility } from '../../../Perps/selectors/perpsController'; +import { useSelector } from 'react-redux'; +import useRewardsToast from '../../hooks/useRewardsToast'; +import { handleDeeplink } from '../../../../../core/DeeplinkManager'; + +interface PerpsTradingCampaignCTAProps { + campaign: CampaignDto; + participantStatus: Pick< + UseGetCampaignParticipantStatusResult, + 'status' | 'isLoading' + >; +} + +const PerpsTradingCampaignCTA: React.FC = ({ + campaign, + participantStatus, +}) => { + const { showToast, RewardsToastOptions } = useRewardsToast(); + const isPerpsEligible = useSelector(selectPerpsEligibility); + const [isOptInSheetOpen, setIsOptInSheetOpen] = useState(false); + + const campaignStatus = getCampaignStatus(campaign); + const isLoading = participantStatus.isLoading; + const isOptedIn = participantStatus?.status?.optedIn === true; + + const handleGeoLockedPress = useCallback(() => { + showToast( + RewardsToastOptions.entriesClosed( + strings('rewards.campaign.geo_locked_toast_title'), + strings('rewards.campaign.geo_locked_toast_description'), + ), + ); + }, [showToast, RewardsToastOptions]); + + const handleJoinPress = useCallback(() => { + setIsOptInSheetOpen(true); + }, []); + + const handleOpenPosition = useCallback(async () => { + await handleDeeplink({ + uri: 'https://link.metamask.io/perps?screen=market-list', + }); + }, []); + + if (isLoading) { + return null; + } + + // Campaign complete — no CTA (leaderboard section handles it) + if (campaignStatus === 'complete') { + return null; + } + + if (campaignStatus !== 'active') { + return null; + } + + // Opted in — show "Open Position" + if (isOptedIn) { + return ( + + + + ); + } + + // Not opted in — geo-restricted + if (!isPerpsEligible) { + return ( + + + + ); + } + + // Not opted in — eligible + return ( + <> + + + + {isOptInSheetOpen && ( + setIsOptInSheetOpen(false)} + /> + )} + + ); +}; + +export default PerpsTradingCampaignCTA; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx new file mode 100644 index 00000000000..8b15a8f46ca --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignLeaderboard, { + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS, +} from './PerpsTradingCampaignLeaderboard'; +import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatSignedUsd: (value: number) => `$${value.toFixed(2)}`, +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children?: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon'); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('../../../../../constants/navigation/Routes', () => ({ + __esModule: true, + default: { + BROWSER: { HOME: 'BrowserHome', VIEW: 'BrowserView' }, + }, +})); + +const TEST_IDS = PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS; + +const createPerpsEntry = ( + overrides: Partial = {}, +): PerpsTradingCampaignLeaderboardEntry => ({ + rank: 1, + referralCode: 'REF001', + pnl: 100, + qualified: true, + ...overrides, +}); + +const defaultProps = { + entries: [ + createPerpsEntry({ rank: 1, referralCode: 'A' }), + createPerpsEntry({ rank: 2, referralCode: 'B' }), + ], + isLoading: false, + hasError: false, +}; + +describe('PerpsTradingCampaignLeaderboard', () => { + beforeEach(() => { + mockNavigate.mockClear(); + }); + + it('renders container and list with entry testIDs', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect(getByTestId(TEST_IDS.LIST)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(TEST_IDS.POWERED_BY)).toBeDefined(); + }); + + it('navigates to in-app browser with HyperTracker attribution URL when brand is pressed', () => { + const { getByText } = render( + , + ); + fireEvent.press( + getByText( + 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand', + ), + ); + + expect(mockNavigate).toHaveBeenCalledWith( + 'BrowserHome', + expect.objectContaining({ + screen: 'BrowserView', + params: expect.objectContaining({ + newTabUrl: + 'https://hypertracker.io?utm_source=metamask&utm_medium=leaderboard&utm_campaign=partner-attribution', + }), + }), + ); + }); + + describe('split view top count (preview vs full, ranks 21–22 vs other)', () => { + const tenEntries = Array.from({ length: 10 }, (_, i) => + createPerpsEntry({ + rank: i + 1, + referralCode: `S${String(i + 1).padStart(3, '0')}`, + pnl: 10 - i, + }), + ); + + it('preview mode shows top 3 then separator and neighbors when rank is outside range', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-2`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-3`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-4`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-250`)).toBeDefined(); + }); + + const twentyFiveEntries = Array.from({ length: 25 }, (_, i) => + createPerpsEntry({ + rank: i + 1, + referralCode: `R${String(i + 1).padStart(3, '0')}`, + pnl: 1000 - i, + }), + ); + + it('full mode shows 18 top rows when user rank is 21 (reduced top strip)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-18`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-19`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-20`)).toBeDefined(); + }); + + it('full mode shows 20 top rows when user rank is 23 (standard strip)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-20`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-21`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-22`)).toBeDefined(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx new file mode 100644 index 00000000000..8537bdc3051 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx @@ -0,0 +1,240 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatSignedUsd } from '../../utils/formatUtils'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; +import Routes from '../../../../../constants/navigation/Routes'; +import { HYPERTRACKER_ATTRIBUTION_URL } from '../../utils/perpsCampaignConstants'; + +export const PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS = { + CONTAINER: 'perps-campaign-leaderboard-container', + LIST: 'perps-campaign-leaderboard-list', + ENTRY_ROW: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.ENTRY_ROW, + PENDING_TAG: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.PENDING_TAG, + NEIGHBOR_SEPARATOR: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.NEIGHBOR_SEPARATOR, + LOADING: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.LOADING, + ERROR: 'perps-campaign-leaderboard-error', + EMPTY: 'perps-campaign-leaderboard-empty', + NOT_YET_COMPUTED: 'perps-campaign-leaderboard-not-yet-computed', + TOTAL_PARTICIPANTS: 'perps-campaign-leaderboard-total-participants', + POWERED_BY: 'perps-campaign-leaderboard-powered-by', +} as const; + +const MAX_ENTRIES_LIMIT = 20; +const SPLIT_VIEW_TOP_COUNT_PREVIEW = 3; +/** Ranks just below the first page: show one fewer top rows to keep split view from crowding the neighbor block. */ +const FULL_SPLIT_TOP_REDUCED_AT_RANKS: readonly number[] = [21, 22]; + +interface UserPosition { + rank: number; + neighbors: PerpsTradingCampaignLeaderboardEntry[]; +} + +export interface PerpsTradingCampaignLeaderboardProps { + entries: PerpsTradingCampaignLeaderboardEntry[]; + isLoading: boolean; + hasError: boolean; + isLeaderboardNotYetComputed?: boolean; + onRetry?: () => void; + currentUserReferralCode?: string | null; + maxEntries?: number; + userPosition?: UserPosition | null; + campaignId?: string; + isCampaignComplete?: boolean; +} + +const PerpsTradingCampaignLeaderboard: React.FC< + PerpsTradingCampaignLeaderboardProps +> = ({ + entries, + isLoading, + hasError, + isLeaderboardNotYetComputed = false, + onRetry, + currentUserReferralCode, + maxEntries, + userPosition, + isCampaignComplete = false, +}) => { + const navigation = useNavigation(); + + const handleHyperTrackerPress = useCallback(() => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: HYPERTRACKER_ATTRIBUTION_URL, + timestamp: Date.now(), + }, + }); + }, [navigation]); + + const isPreview = maxEntries != null; + + const effectiveMaxEntries = + maxEntries != null && maxEntries <= MAX_ENTRIES_LIMIT + ? maxEntries + : MAX_ENTRIES_LIMIT; + + /** Top rows above the neighbor separator in split view (preview: 3; full: 18 for rank 21–22, else 20). */ + const splitViewTopCount = useMemo(() => { + if (isPreview) { + return SPLIT_VIEW_TOP_COUNT_PREVIEW; + } + const rank = userPosition?.rank; + if (rank == null) { + return MAX_ENTRIES_LIMIT; + } + return FULL_SPLIT_TOP_REDUCED_AT_RANKS.includes(rank) + ? MAX_ENTRIES_LIMIT - 2 + : MAX_ENTRIES_LIMIT; + }, [isPreview, userPosition?.rank]); + + const showSplitView = useMemo(() => { + if (!userPosition) return false; + return ( + userPosition.rank > effectiveMaxEntries && + userPosition.neighbors.length > 0 + ); + }, [userPosition, effectiveMaxEntries]); + + const visibleEntries = useMemo(() => { + if (showSplitView) { + return entries.slice(0, splitViewTopCount); + } + return entries.slice(0, effectiveMaxEntries); + }, [entries, effectiveMaxEntries, showSplitView, splitViewTopCount]); + + const isCurrentUser = useCallback( + (entry: PerpsTradingCampaignLeaderboardEntry) => + !!currentUserReferralCode && + entry.referralCode === currentUserReferralCode, + [currentUserReferralCode], + ); + + if (isLoading && entries.length === 0) { + return ; + } + + if (hasError && entries.length === 0) { + return ( + + + + ); + } + + if (isLeaderboardNotYetComputed && !isLoading && entries.length === 0) { + return ( + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_not_yet_computed', + )} + + + ); + } + + if (entries.length === 0) { + return ( + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_not_yet_computed', + )} + + + ); + } + + return ( + + {/* Leaderboard list */} + + {visibleEntries.map((entry) => ( + formatSignedUsd(e.pnl)} + isPositivePrimaryMetric={(e) => e.pnl >= 0} + /> + ))} + {showSplitView && userPosition && ( + <> + + {userPosition.neighbors.map((entry) => ( + formatSignedUsd(e.pnl)} + isPositivePrimaryMetric={(e) => e.pnl >= 0} + /> + ))} + + )} + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_powered_by_prefix', + )} + + {strings( + 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand', + )} + + + + ); +}; + +export default PerpsTradingCampaignLeaderboard; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx new file mode 100644 index 00000000000..360a3b52187 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignPrizePool, { + PERPS_PRIZE_POOL_TEST_IDS, +} from './PerpsTradingCampaignPrizePool'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + testID, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: `${testID}-retry` }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const t: Record = { + 'rewards.perps_trading_campaign.prize_pool_error_title': + 'Prize pool unavailable', + 'rewards.perps_trading_campaign.prize_pool_error_description': + 'Could not load prize pool.', + 'rewards.perps_trading_campaign.prize_pool_retry_button': 'Retry', + 'rewards.perps_trading_campaign.prize_pool_current_label': 'Current', + 'rewards.perps_trading_campaign.prize_pool_next_label': 'Next', + 'rewards.perps_trading_campaign.prize_pool_volume_subtext': + '{{current}} of {{target}} volume', + 'rewards.perps_trading_campaign.prize_pool_max_tier_subtext': + '{{maxThreshold}}+ volume — all milestones reached', + 'rewards.perps_trading_campaign.prize_pool_max_badge': 'Max prize', + }; + let result = t[key] ?? key; + if (params) { + Object.entries(params).forEach(([k, v]) => { + result = result.replace(`{{${k}}}`, v); + }); + } + return result; + }, + default: { locale: 'en-US' }, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatUsd: (value: string | number) => + `$${Number(value).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`, + formatCompactUsd: (value: number) => { + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(0)}K`; + } + return `$${value}`; + }, +})); + +const mockRefetch = jest.fn(); + +const baseProps = { + totalNotionalVolume: '7500000' as string | null, + isLoading: false, + hasError: false, + refetch: mockRefetch, +}; + +describe('PerpsTradingCampaignPrizePool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders container, progress bar, and subtext when data is provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.CONTAINER)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeDefined(); + }); + + it('shows current and next prize between $5M and $10M notional', () => { + const { getByText } = render( + , + ); + + expect(getByText('$15,000.00')).toBeDefined(); + expect(getByText('$20,000.00')).toBeDefined(); + }); + + it('computes 50% progress halfway between $5M and $10M volume', () => { + const { getByTestId } = render( + , + ); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '50%' }); + }); + + it('shows max badge and full progress at $40M notional (top tier)', () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.MAX_BADGE)).toBeDefined(); + expect(getByText('Max prize')).toBeDefined(); + expect(getByText('$50,000.00')).toBeDefined(); + expect(queryByText('Next')).toBeNull(); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '100%' }); + + const subtext = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT); + expect(subtext.props.children).toBe( + '$40M+ volume — all milestones reached', + ); + }); + + it('does not show max badge below top tier', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.MAX_BADGE)).toBeNull(); + }); + + it('with null volume and not loading, shows first-tier defaults ($10k → $15k)', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText('$10,000.00')).toBeDefined(); + expect(getByText('$15,000.00')).toBeDefined(); + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '0%' }); + }); + + it('with zero notional string uses first milestone segment (0% in range to $5M)', () => { + const { getByTestId } = render( + , + ); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '0%' }); + }); + + it('shows skeleton when loading with no volume data', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.CONTAINER)).toBeDefined(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeNull(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeNull(); + }); + + it('shows stale content when loading but volume already exists', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeDefined(); + }); + + it('shows error banner when hasError and no volume data', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER)).toBeDefined(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeNull(); + }); + + it('hides error banner when hasError but stale volume exists', () => { + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER)).toBeNull(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + }); + + it('calls refetch when error retry is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId(`${PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER}-retry`), + ); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('renders volume subtext with compact amounts', () => { + const { getByTestId } = render( + , + ); + + const subtext = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT); + expect(subtext.props.children).toBe('$7.5M of $10M volume'); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx new file mode 100644 index 00000000000..c639cd74baa --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx @@ -0,0 +1,196 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatCompactUsd, formatUsd } from '../../utils/formatUtils'; +import { computePrizePoolProgress } from '../../utils/prizePoolUtils'; +import { strings } from '../../../../../../locales/i18n'; + +export const PERPS_PRIZE_POOL_TEST_IDS = { + CONTAINER: 'perps-prize-pool-container', + PROGRESS_BAR: 'perps-prize-pool-progress-bar', + MAX_BADGE: 'perps-prize-pool-max-badge', + SUBTEXT: 'perps-prize-pool-subtext', + ERROR_BANNER: 'perps-prize-pool-error-banner', +} as const; + +// $10k base prize, scales by $5k per $5M notional volume, up to $50k at $40M +export const PERPS_PRIZE_POOL_MILESTONES = [ + { notionalVolume: 0, prize: 10_000 }, + { notionalVolume: 5_000_000, prize: 15_000 }, + { notionalVolume: 10_000_000, prize: 20_000 }, + { notionalVolume: 15_000_000, prize: 25_000 }, + { notionalVolume: 20_000_000, prize: 30_000 }, + { notionalVolume: 25_000_000, prize: 35_000 }, + { notionalVolume: 30_000_000, prize: 40_000 }, + { notionalVolume: 35_000_000, prize: 45_000 }, + { notionalVolume: 40_000_000, prize: 50_000 }, +] as const; + +interface PerpsTradingCampaignPrizePoolProps { + totalNotionalVolume: string | null; + isLoading: boolean; + hasError: boolean; + refetch: () => void; +} + +const PerpsTradingCampaignPrizePool: React.FC< + PerpsTradingCampaignPrizePoolProps +> = ({ totalNotionalVolume, isLoading, hasError, refetch }) => { + const tw = useTailwind(); + + const showSkeleton = isLoading && !totalNotionalVolume; + const showError = hasError && !totalNotionalVolume; + + const { progress, currentPrize, nextPrize, nextThreshold, isMaxTier } = + useMemo(() => { + if (!totalNotionalVolume) { + return { + progress: 0, + currentPrize: PERPS_PRIZE_POOL_MILESTONES[0].prize, + nextPrize: PERPS_PRIZE_POOL_MILESTONES[1].prize as number | null, + nextThreshold: PERPS_PRIZE_POOL_MILESTONES[1].notionalVolume, + isMaxTier: false, + }; + } + return computePrizePoolProgress( + PERPS_PRIZE_POOL_MILESTONES, + parseFloat(totalNotionalVolume), + (m) => m.notionalVolume, + ); + }, [totalNotionalVolume]); + + const progressPercent: `${number}%` = `${Math.round(progress * 100)}%`; + const currentVolume = totalNotionalVolume + ? parseFloat(totalNotionalVolume) + : 0; + + if (showError) { + return ( + + + + ); + } + + if (showSkeleton) { + return ( + + + + + ); + } + + return ( + + + + + {strings('rewards.perps_trading_campaign.prize_pool_current_label')} + + + {formatUsd(currentPrize)} + + + {isMaxTier ? ( + + + + {strings('rewards.perps_trading_campaign.prize_pool_max_badge')} + + + + ) : nextPrize !== null ? ( + + + {strings('rewards.perps_trading_campaign.prize_pool_next_label')} + + + {formatUsd(nextPrize)} + + + ) : null} + + + + + + + + {isMaxTier + ? strings( + 'rewards.perps_trading_campaign.prize_pool_max_tier_subtext', + { + maxThreshold: formatCompactUsd(nextThreshold), + }, + ) + : strings( + 'rewards.perps_trading_campaign.prize_pool_volume_subtext', + { + current: formatCompactUsd(currentVolume), + target: formatCompactUsd(nextThreshold), + }, + )} + + + ); +}; + +export default PerpsTradingCampaignPrizePool; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx new file mode 100644 index 00000000000..d103b8e8c58 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import PerpsTradingCampaignStatsHeader, { + PERPS_STATS_HEADER_TEST_IDS, +} from './PerpsTradingCampaignStatsHeader'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => { + const fmt = (n: number) => + n.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return { + formatSignedUsd: (value: number) => { + if (value < 0) { + return `-$${fmt(Math.abs(value))}`; + } + if (value > 0) { + return `+$${fmt(value)}`; + } + return '$0.00'; + }, + formatRewardsTimeOnly: () => 'time-stub', + }; +}); + +const TEST_IDS = PERPS_STATS_HEADER_TEST_IDS; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 7, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +describe('PerpsTradingCampaignStatsHeader', () => { + it('renders container and your-rank label', () => { + const { getByTestId, getByText } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_your_rank'), + ).toBeDefined(); + }); + + it('shows padded rank, positive PnL with success color, and qualified icon when qualified', () => { + const { getByTestId, getByText, queryByTestId } = render( + , + ); + const rank = getByTestId(TEST_IDS.RANK_VALUE); + expect(rank.props.children).toBe('07'); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(pnl.props.color).toBe(TextColor.SuccessDefault); + expect(getByText('+$1,500.25', { exact: true })).toBeDefined(); + expect(getByTestId(TEST_IDS.QUALIFIED_ICON)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it('uses error color and minus sign in display for negative PnL', () => { + const { getByTestId } = render( + , + ); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(pnl.props.color).toBe(TextColor.ErrorDefault); + }); + + it('shows pending tag and no qualified icon when not qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.PENDING_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_ICON)).toBeNull(); + }); + + it('shows em dashes for rank and PnL when position is null', () => { + const { getByTestId } = render( + , + ); + const rank = getByTestId(TEST_IDS.RANK_VALUE); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(rank.props.children).toBe('—'); + expect(pnl.props.children).toBe('—'); + expect(pnl.props.color).toBe(TextColor.TextDefault); + }); + + it('hides PnL and computed-at subtext when showPnl and showComputedAt are false', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PNL_VALUE)).toBeNull(); + expect(queryByTestId(TEST_IDS.COMPUTED_AT)).toBeNull(); + }); + + it('shows computed-at line when showComputedAt is true and position has a timestamp', () => { + const { getByTestId } = render( + , + ); + const computed = getByTestId(TEST_IDS.COMPUTED_AT); + expect(computed.props.children).toBe( + 'rewards.perps_trading_campaign.last_updated', + ); + }); + + it('omits computed-at when formatted label is empty', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.COMPUTED_AT)).toBeNull(); + }); + + it('skips rank and PnL testIDs and shows loading skeletons when isLoading is true', () => { + const { queryByTestId, getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect(queryByTestId(TEST_IDS.RANK_VALUE)).toBeNull(); + expect(queryByTestId(TEST_IDS.PNL_VALUE)).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx new file mode 100644 index 00000000000..63c4bdee9a7 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { PendingTag } from './OndoCampaignStatsSummary'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { + formatRewardsTimeOnly, + formatSignedUsd, +} from '../../utils/formatUtils'; + +export const PERPS_STATS_HEADER_TEST_IDS = { + CONTAINER: 'perps-stats-header-container', + RANK_VALUE: 'perps-stats-header-rank', + PNL_VALUE: 'perps-stats-header-pnl', + COMPUTED_AT: 'perps-stats-header-computed-at', + PENDING_TAG: 'perps-stats-header-pending-tag', + QUALIFIED_ICON: 'perps-stats-header-qualified-icon', +} as const; + +interface PerpsTradingCampaignStatsHeaderProps { + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isLoading?: boolean; + /** When true, shows PnL under the rank in BodySm (same pattern as return in LeaderboardPositionHeader). */ + showPnl?: boolean; + /** When true, shows formatted `computedAt` time on the same row as PnL, right-aligned in alternative text color. */ + showComputedAt?: boolean; +} + +const PerpsTradingCampaignStatsHeader: React.FC< + PerpsTradingCampaignStatsHeaderProps +> = ({ + position, + isLoading = false, + showPnl = true, + showComputedAt = true, +}) => { + const tw = useTailwind(); + + const isPending = position != null && !position.qualified; + const isQualified = position != null && position.qualified; + + const rankValue = position ? String(position.rank).padStart(2, '0') : '—'; + const pnlValue = position ? formatSignedUsd(position.pnl) : '—'; + const pnlColor = position + ? position.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const computedAtLabel = position?.computedAt + ? strings('rewards.perps_trading_campaign.last_updated', { + time: formatRewardsTimeOnly(new Date(position.computedAt)), + }) + : ''; + + const showSubtextRow = showPnl || showComputedAt; + + return ( + + + + + {strings('rewards.perps_trading_campaign.label_your_rank')} + + {isPending && ( + + )} + {isQualified && ( + + )} + + + {isLoading ? ( + <> + + {showSubtextRow && ( + + )} + + ) : ( + <> + + {rankValue} + + {showSubtextRow && ( + + + {showPnl && ( + + {pnlValue} + + )} + + {showComputedAt && computedAtLabel.length > 0 && ( + + {computedAtLabel} + + )} + + )} + + )} + + + ); +}; + +export default PerpsTradingCampaignStatsHeader; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts new file mode 100644 index 00000000000..007822dca57 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts @@ -0,0 +1,244 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignLeaderboard } from './useGetPerpsTradingCampaignLeaderboard'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignLeaderboard, + selectPerpsTradingCampaignLeaderboardLoading, + selectPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignLeaderboard: jest.fn(), + selectPerpsTradingCampaignLeaderboardLoading: jest.fn(), + selectPerpsTradingCampaignLeaderboardError: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignLeaderboard: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboard', + payload, + })), + setPerpsTradingCampaignLeaderboardLoading: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardLoading', + payload, + })), + setPerpsTradingCampaignLeaderboardError: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardError', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; + +const CAMPAIGN_ID = 'campaign-123'; +const MOCK_LEADERBOARD: PerpsTradingCampaignLeaderboardDto = { + campaignId: CAMPAIGN_ID, + computedAt: '2024-03-20T12:00:00.000Z', + entries: [ + { rank: 1, referralCode: 'ABC123', pnl: 1500, qualified: true }, + { rank: 2, referralCode: 'DEF456', pnl: 800, qualified: true }, + ], + totalParticipants: 50, +}; + +interface SelectorState { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + isLoading: boolean; + hasError: boolean; +} + +function setupSelectors(state: SelectorState) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsTradingCampaignLeaderboard) + return state.leaderboard; + if (selector === selectPerpsTradingCampaignLeaderboardLoading) + return state.isLoading; + if (selector === selectPerpsTradingCampaignLeaderboardError) + return state.hasError; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignLeaderboard', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ leaderboard: null, isLoading: false, hasError: false }); + }); + + it('does not fetch when campaignId is undefined but resets loading and error', async () => { + renderHook(() => useGetPerpsTradingCampaignLeaderboard(undefined)); + + expect(mockCall).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(false), + ); + }); + + it('fetches leaderboard and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_LEADERBOARD as never); + + renderHook(() => useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(false), + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignLeaderboard', + CAMPAIGN_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboard(MOCK_LEADERBOARD), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + }); + + it('dispatches error action on non-404 fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + renderHook(() => useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + }); + + it('returns isLeaderboardNotYetComputed true on 404 error', async () => { + mockCall.mockRejectedValueOnce( + new Error('leaderboard failed: 404') as never, + ); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(true); + expect(mockDispatch).not.toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(true), + ); + }); + + it('returns leaderboard data from selector', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.leaderboard).toEqual(MOCK_LEADERBOARD); + }); + + it('returns loading state from selector', () => { + setupSelectors({ leaderboard: null, isLoading: true, hasError: false }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns error state from selector', () => { + setupSelectors({ leaderboard: null, isLoading: false, hasError: true }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.hasError).toBe(true); + }); + + it('refetch function re-fetches the leaderboard', async () => { + mockCall.mockResolvedValue(MOCK_LEADERBOARD as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(true), + ); + }); + + it('returns isLeaderboardNotYetComputed false initially', () => { + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(undefined), + ); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); + + it('returns isLeaderboardNotYetComputed false on non-404 error', async () => { + mockCall.mockRejectedValueOnce(new Error('Server error') as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts new file mode 100644 index 00000000000..6e7e7e8b953 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignLeaderboard, + selectPerpsTradingCampaignLeaderboardLoading, + selectPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseGetPerpsTradingCampaignLeaderboardResult { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + isLoading: boolean; + hasError: boolean; + isLeaderboardNotYetComputed: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignLeaderboard = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignLeaderboardResult => { + const dispatch = useDispatch(); + const leaderboard = useSelector(selectPerpsTradingCampaignLeaderboard); + const isLoading = useSelector(selectPerpsTradingCampaignLeaderboardLoading); + const hasError = useSelector(selectPerpsTradingCampaignLeaderboardError); + const [isLeaderboardNotYetComputed, setIsLeaderboardNotYetComputed] = + useState(false); + + const fetchLeaderboard = useCallback(async (): Promise => { + if (!campaignId) { + dispatch(setPerpsTradingCampaignLeaderboardLoading(false)); + dispatch(setPerpsTradingCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); + return; + } + + try { + dispatch(setPerpsTradingCampaignLeaderboardLoading(true)); + dispatch(setPerpsTradingCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignLeaderboard', + campaignId, + ); + dispatch(setPerpsTradingCampaignLeaderboard(result)); + } catch (error) { + const is404 = error instanceof Error && error.message.includes('404'); + if (is404) { + setIsLeaderboardNotYetComputed(true); + } else { + dispatch(setPerpsTradingCampaignLeaderboardError(true)); + } + } finally { + dispatch(setPerpsTradingCampaignLeaderboardLoading(false)); + } + }, [dispatch, campaignId]); + + useEffect(() => { + fetchLeaderboard(); + }, [fetchLeaderboard]); + + return { + leaderboard, + isLoading, + hasError, + isLeaderboardNotYetComputed, + refetch: fetchLeaderboard, + }; +}; + +export default useGetPerpsTradingCampaignLeaderboard; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts new file mode 100644 index 00000000000..320a3921c75 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts @@ -0,0 +1,260 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from './useGetPerpsTradingCampaignLeaderboardPosition'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; +import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('./useInvalidateByRewardEvents', () => ({ + useInvalidateByRewardEvents: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), + selectCampaignParticipantOptedIn: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignLeaderboardPositionById: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignLeaderboardPosition: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardPosition', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseInvalidateByRewardEvents = + useInvalidateByRewardEvents as jest.MockedFunction< + typeof useInvalidateByRewardEvents + >; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSelectPositionById = + selectPerpsTradingCampaignLeaderboardPositionById as jest.MockedFunction< + typeof selectPerpsTradingCampaignLeaderboardPositionById + >; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MOCK_POSITION: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 5, + pnl: 1500, + notionalVolume: 30000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2024-03-20T12:00:00.000Z', +}; + +interface SelectorState { + subscriptionId: string | null; + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isOptedIn?: boolean; +} + +function setupSelectors(state: SelectorState) { + const isOptedIn = state.isOptedIn ?? true; + const mockPositionSelector = jest.fn().mockReturnValue(state.position); + const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectPositionById.mockReturnValue(mockPositionSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return state.subscriptionId; + if (selector === mockPositionSelector) return state.position; + if (selector === mockOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignLeaderboardPosition', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ subscriptionId: SUBSCRIPTION_ID, position: null }); + }); + + it('does not fetch when subscriptionId is missing', async () => { + setupSelectors({ subscriptionId: null, position: null }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when not opted in', async () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: null, + isOptedIn: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when campaignId is undefined', async () => { + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(undefined), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('fetches position and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_POSITION as never); + + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignLeaderboardPosition', + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId: SUBSCRIPTION_ID, + campaignId: CAMPAIGN_ID, + position: MOCK_POSITION, + }), + ); + }); + + it('sets hasError on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasError).toBe(true); + }); + + it('returns position data from selector', () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: MOCK_POSITION, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toEqual(MOCK_POSITION); + }); + + it('returns null position when not loaded', () => { + setupSelectors({ subscriptionId: SUBSCRIPTION_ID, position: null }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toBeNull(); + }); + + it('refetch function re-fetches the position', async () => { + mockCall.mockResolvedValue(MOCK_POSITION as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + }); + + it('calls selectPerpsTradingCampaignLeaderboardPositionById with subscriptionId and campaignId', () => { + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockSelectPositionById).toHaveBeenCalledWith( + SUBSCRIPTION_ID, + CAMPAIGN_ID, + ); + }); + + it('subscribes to leaderboardPositionInvalidated to auto-refetch', () => { + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + 'RewardsController:leaderboardPositionInvalidated', + ]), + expect.any(Function), + ); + }); + + it('sets hasFetched after successful fetch', async () => { + mockCall.mockResolvedValueOnce(MOCK_POSITION as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasFetched).toBe(true); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts new file mode 100644 index 00000000000..aaa206cc69f --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; +import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; + +export interface UseGetPerpsTradingCampaignLeaderboardPositionResult { + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isLoading: boolean; + hasError: boolean; + hasFetched: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignLeaderboardPosition = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignLeaderboardPositionResult => { + const dispatch = useDispatch(); + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const position = useSelector( + selectPerpsTradingCampaignLeaderboardPositionById( + subscriptionId ?? undefined, + campaignId, + ), + ); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + const fetchPosition = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setIsLoading(false); + setHasError(false); + setHasFetched(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignLeaderboardPosition', + campaignId, + subscriptionId, + ); + dispatch( + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId, + campaignId, + position: result, + }), + ); + } catch { + setHasError(true); + } finally { + setIsLoading(false); + setHasFetched(true); + } + }, [dispatch, subscriptionId, campaignId, isOptedIn]); + + useEffect(() => { + fetchPosition(); + }, [fetchPosition]); + + const invalidationEvents = useMemo( + () => ['RewardsController:leaderboardPositionInvalidated'] as const, + [], + ); + useInvalidateByRewardEvents(invalidationEvents, fetchPosition); + + return { position, isLoading, hasError, hasFetched, refetch: fetchPosition }; +}; + +export default useGetPerpsTradingCampaignLeaderboardPosition; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts new file mode 100644 index 00000000000..dacbffded39 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts @@ -0,0 +1,196 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignVolume } from './useGetPerpsTradingCampaignVolume'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignVolume, + selectPerpsTradingCampaignVolumeLoading, + selectPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignVolumeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignVolume: jest.fn(), + selectPerpsTradingCampaignVolumeLoading: jest.fn(), + selectPerpsTradingCampaignVolumeError: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignVolume: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolume', + payload, + })), + setPerpsTradingCampaignVolumeLoading: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolumeLoading', + payload, + })), + setPerpsTradingCampaignVolumeError: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolumeError', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; + +const CAMPAIGN_ID = 'campaign-123'; +const MOCK_VOLUME: PerpsTradingCampaignVolumeDto = { + totalUsdVolume: '5000000', +}; + +interface SelectorState { + volume: PerpsTradingCampaignVolumeDto | null; + isLoading: boolean; + hasError: boolean; +} + +function setupSelectors(state: SelectorState) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsTradingCampaignVolume) return state.volume; + if (selector === selectPerpsTradingCampaignVolumeLoading) + return state.isLoading; + if (selector === selectPerpsTradingCampaignVolumeError) + return state.hasError; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignVolume', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ volume: null, isLoading: false, hasError: false }); + }); + + it('does not fetch when campaignId is undefined but resets loading and error', async () => { + renderHook(() => useGetPerpsTradingCampaignVolume(undefined)); + + expect(mockCall).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(false), + ); + }); + + it('fetches volume and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_VOLUME as never); + + renderHook(() => useGetPerpsTradingCampaignVolume(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(false), + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignVolume', + CAMPAIGN_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolume(MOCK_VOLUME), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + }); + + it('dispatches error action on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + renderHook(() => useGetPerpsTradingCampaignVolume(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + }); + + it('returns volume data from selector', () => { + setupSelectors({ + volume: MOCK_VOLUME, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.volume).toEqual(MOCK_VOLUME); + }); + + it('returns loading state from selector', () => { + setupSelectors({ volume: null, isLoading: true, hasError: false }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns error state from selector', () => { + setupSelectors({ volume: null, isLoading: false, hasError: true }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.hasError).toBe(true); + }); + + it('refetch function re-fetches the volume', async () => { + mockCall.mockResolvedValue(MOCK_VOLUME as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(true), + ); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts new file mode 100644 index 00000000000..b5a2974dbd0 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignVolume, + selectPerpsTradingCampaignVolumeLoading, + selectPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards'; + +export interface UseGetPerpsTradingCampaignVolumeResult { + volume: ReturnType; + isLoading: boolean; + hasError: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignVolume = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignVolumeResult => { + const dispatch = useDispatch(); + const volume = useSelector(selectPerpsTradingCampaignVolume); + const isLoading = useSelector(selectPerpsTradingCampaignVolumeLoading); + const hasError = useSelector(selectPerpsTradingCampaignVolumeError); + + const fetchVolume = useCallback(async (): Promise => { + if (!campaignId) { + dispatch(setPerpsTradingCampaignVolumeLoading(false)); + dispatch(setPerpsTradingCampaignVolumeError(false)); + return; + } + + try { + dispatch(setPerpsTradingCampaignVolumeLoading(true)); + dispatch(setPerpsTradingCampaignVolumeError(false)); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignVolume', + campaignId, + ); + dispatch(setPerpsTradingCampaignVolume(result)); + } catch { + dispatch(setPerpsTradingCampaignVolumeError(true)); + } finally { + dispatch(setPerpsTradingCampaignVolumeLoading(false)); + } + }, [dispatch, campaignId]); + + useEffect(() => { + fetchVolume(); + }, [fetchVolume]); + + return { volume, isLoading, hasError, refetch: fetchVolume }; +}; + +export default useGetPerpsTradingCampaignVolume; diff --git a/app/components/UI/Rewards/utils/formatUtils.test.ts b/app/components/UI/Rewards/utils/formatUtils.test.ts index afeadfd37d1..8424cb81242 100644 --- a/app/components/UI/Rewards/utils/formatUtils.test.ts +++ b/app/components/UI/Rewards/utils/formatUtils.test.ts @@ -17,7 +17,6 @@ import { validateEmail, formatPercentChange, isPercentChangeNonNegative, - formatComputedAt, getChainHex, getAssetReference, parseCaip19, @@ -44,24 +43,33 @@ jest.mock('../../../../util/date', () => ({ // Mock i18n strings jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { - const t: Record = { - 'rewards.events.to': 'to', - 'rewards.events.type.swap': 'Swap', - 'rewards.events.type.referral_action': 'Referral action', - 'rewards.events.type.sign_up_bonus': 'Sign up bonus', - 'rewards.events.type.loyalty_bonus': 'Loyalty bonus', - 'rewards.events.type.one_time_bonus': 'One-time bonus', - 'rewards.events.type.open_position': 'Opened position', - 'rewards.events.type.close_position': 'Closed position', - 'rewards.events.type.take_profit': 'Take profit', - 'rewards.events.type.stop_loss': 'Stop loss', - 'rewards.events.type.uncategorized_event': 'Uncategorized event', - 'perps.market.long': 'Long', - 'perps.market.short': 'Short', - }; - return t[key] || key; - }), + strings: jest.fn( + (key: string, params: Record | undefined) => { + const t: Record = { + 'rewards.events.to': 'to', + 'rewards.events.type.swap': 'Swap', + 'rewards.events.type.referral_action': 'Referral action', + 'rewards.events.type.sign_up_bonus': 'Sign up bonus', + 'rewards.events.type.loyalty_bonus': 'Loyalty bonus', + 'rewards.events.type.one_time_bonus': 'One-time bonus', + 'rewards.events.type.open_position': 'Opened position', + 'rewards.events.type.close_position': 'Closed position', + 'rewards.events.type.take_profit': 'Take profit', + 'rewards.events.type.stop_loss': 'Stop loss', + 'rewards.events.type.uncategorized_event': 'Uncategorized event', + 'perps.market.long': 'Long', + 'perps.market.short': 'Short', + 'rewards.perps_trading_campaign.last_updated': 'Last updated: {{time}}', + }; + let template = t[key] ?? key; + if (params) { + for (const [paramKey, value] of Object.entries(params)) { + template = template.split(`{{${paramKey}}}`).join(value); + } + } + return template; + }, + ), default: { locale: 'en-US', }, @@ -1445,25 +1453,6 @@ describe('formatUtils', () => { }); }); - describe('formatComputedAt', () => { - it('returns empty string for null', () => { - expect(formatComputedAt(null)).toBe(''); - }); - - it('returns empty string for empty string', () => { - expect(formatComputedAt('')).toBe(''); - }); - - it('returns HH:MM:SS for a valid ISO timestamp', () => { - const result = formatComputedAt('2026-03-28T14:30:45.000Z'); - expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); - }); - - it('returns empty string for unparseable value', () => { - expect(formatComputedAt('not-a-date')).toBe(''); - }); - }); - describe('getChainHex', () => { it('extracts hex chain ID from EIP-155 CAIP-19', () => { expect(getChainHex('eip155:1/erc20:0xabc')).toBe('0x1'); @@ -1611,8 +1600,20 @@ describe('formatUtils', () => { expect(formatSignedUsd('0')).toBe('$0.00'); }); - it('returns raw string for non-numeric input', () => { - expect(formatSignedUsd('abc')).toBe('abc'); + it('prepends + for positive number input', () => { + expect(formatSignedUsd(5000)).toBe('+$5,000.00'); + }); + + it('formats negative number input', () => { + expect(formatSignedUsd(-1250.5)).toBe('$-1,250.50'); + }); + + it('returns em dash for non-numeric string', () => { + expect(formatSignedUsd('abc')).toBe('—'); + }); + + it('returns em dash for empty string', () => { + expect(formatSignedUsd('')).toBe('—'); }); }); diff --git a/app/components/UI/Rewards/utils/formatUtils.ts b/app/components/UI/Rewards/utils/formatUtils.ts index ff2390e95c1..19eaf683800 100644 --- a/app/components/UI/Rewards/utils/formatUtils.ts +++ b/app/components/UI/Rewards/utils/formatUtils.ts @@ -8,7 +8,7 @@ import { parseCaipChainId, } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import I18n from '../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../locales/i18n'; import { getTimeDifferenceFromNow } from '../../../../util/date'; import formatFiat from '../../../../util/formatFiat'; import { getIntlNumberFormatter } from '../../../../util/intl'; @@ -367,10 +367,10 @@ export const formatCompactUsd = (value: number): string => { * @example formatSignedUsd('-1250.50') // '-$1,250.50' * @example formatSignedUsd(null) // '—' */ -export const formatSignedUsd = (value: string | null): string => { +export const formatSignedUsd = (value: string | number | null): string => { if (value === null) return '—'; - const num = parseFloat(value); - if (Number.isNaN(num)) return value; + const num = typeof value === 'number' ? value : parseFloat(value); + if (Number.isNaN(num)) return '—'; const sign = num > 0 ? '+' : ''; return `${sign}${formatUsd(value)}`; }; @@ -425,22 +425,6 @@ export function formatOrdinalRank(rank: number): string { return `${n}${suffix}`; } -// ── Timestamp formatting ──────────────────────────────────────────────── - -/** - * Formats an ISO 8601 timestamp to `HH:MM:SS`. - * Returns '' for null or unparseable values. - */ -export const formatComputedAt = (isoString: string | null): string => { - if (!isoString) return ''; - const date = new Date(isoString); - if (isNaN(date.getTime())) return ''; - const h = date.getHours().toString().padStart(2, '0'); - const m = date.getMinutes().toString().padStart(2, '0'); - const s = date.getSeconds().toString().padStart(2, '0'); - return `${h}:${m}:${s}`; -}; - // ── CAIP-19 / address helpers ─────────────────────────────────────────── /** diff --git a/app/components/UI/Rewards/utils/perpsCampaignConstants.ts b/app/components/UI/Rewards/utils/perpsCampaignConstants.ts new file mode 100644 index 00000000000..ee988ff0387 --- /dev/null +++ b/app/components/UI/Rewards/utils/perpsCampaignConstants.ts @@ -0,0 +1,9 @@ +/** + * Notional volume (USD) required to qualify for the perps trading competition leaderboard. + * Aligns with backend / UI rules for `qualified` on leaderboard position. + */ +export const PERPS_QUALIFICATION_NOTIONAL_USD = 25_000; + +/** HyperTracker attribution URL for the perps trading campaign leaderboard. */ +export const HYPERTRACKER_ATTRIBUTION_URL = + 'https://hypertracker.io?utm_source=metamask&utm_medium=leaderboard&utm_campaign=partner-attribution'; diff --git a/app/components/UI/Rewards/utils/prizePoolUtils.test.ts b/app/components/UI/Rewards/utils/prizePoolUtils.test.ts new file mode 100644 index 00000000000..6457bf916ec --- /dev/null +++ b/app/components/UI/Rewards/utils/prizePoolUtils.test.ts @@ -0,0 +1,79 @@ +import { computePrizePoolProgress } from './prizePoolUtils'; + +describe('computePrizePoolProgress', () => { + const ondoLike = [ + { deposit: 0, prize: 25_000 }, + { deposit: 1_500_000, prize: 50_000 }, + { deposit: 3_500_000, prize: 75_000 }, + { deposit: 6_000_000, prize: 100_000 }, + ] as const; + + it('returns first-tier defaults when amount is below first threshold above zero', () => { + const result = computePrizePoolProgress( + ondoLike, + 1_000_000, + (m) => m.deposit, + ); + expect(result.currentPrize).toBe(25_000); + expect(result.nextPrize).toBe(50_000); + expect(result.nextThreshold).toBe(1_500_000); + expect(result.isMaxTier).toBe(false); + expect(result.progress).toBeCloseTo(1_000_000 / 1_500_000); + }); + + it('returns max tier when amount meets final milestone', () => { + const result = computePrizePoolProgress( + ondoLike, + 6_000_000, + (m) => m.deposit, + ); + expect(result.progress).toBe(1); + expect(result.currentPrize).toBe(100_000); + expect(result.nextPrize).toBeNull(); + expect(result.nextThreshold).toBe(6_000_000); + expect(result.isMaxTier).toBe(true); + }); + + it('interpolates progress within a tier (perps-style notionalVolume)', () => { + const perpsLike = [ + { notionalVolume: 0, prize: 10_000 }, + { notionalVolume: 5_000_000, prize: 15_000 }, + { notionalVolume: 10_000_000, prize: 20_000 }, + ] as const; + + const mid = 7_500_000; + const result = computePrizePoolProgress( + perpsLike, + mid, + (m) => m.notionalVolume, + ); + expect(result.currentPrize).toBe(15_000); + expect(result.nextPrize).toBe(20_000); + expect(result.nextThreshold).toBe(10_000_000); + expect(result.progress).toBe(0.5); + }); + + it('returns zero progress at the start of the first tier', () => { + const result = computePrizePoolProgress(ondoLike, 0, (m) => m.deposit); + expect(result.progress).toBe(0); + expect(result.currentPrize).toBe(25_000); + expect(result.nextThreshold).toBe(1_500_000); + }); + + it('returns expected currentPrize at each Ondo-style tier boundary', () => { + const cp = (amount: number) => + computePrizePoolProgress(ondoLike, amount, (m) => m.deposit).currentPrize; + + expect(cp(0)).toBe(25_000); + expect(cp(500_000)).toBe(25_000); + expect(cp(1_499_999)).toBe(25_000); + expect(cp(1_500_000)).toBe(50_000); + expect(cp(2_000_000)).toBe(50_000); + expect(cp(3_499_999)).toBe(50_000); + expect(cp(3_500_000)).toBe(75_000); + expect(cp(4_500_000)).toBe(75_000); + expect(cp(5_999_999)).toBe(75_000); + expect(cp(6_000_000)).toBe(100_000); + expect(cp(10_000_000)).toBe(100_000); + }); +}); diff --git a/app/components/UI/Rewards/utils/prizePoolUtils.ts b/app/components/UI/Rewards/utils/prizePoolUtils.ts new file mode 100644 index 00000000000..5f31c32c109 --- /dev/null +++ b/app/components/UI/Rewards/utils/prizePoolUtils.ts @@ -0,0 +1,49 @@ +export interface PrizePoolProgressResult { + progress: number; + currentPrize: number; + nextPrize: number | null; + nextThreshold: number; + isMaxTier: boolean; +} + +/** + * Computes progress toward the next prize tier from sorted milestones (ascending threshold). + */ +export function computePrizePoolProgress( + milestones: readonly T[], + totalAmount: number, + getThreshold: (m: T) => number, +): PrizePoolProgressResult { + let currentIndex = 0; + for (let i = milestones.length - 1; i >= 0; i--) { + if (totalAmount >= getThreshold(milestones[i])) { + currentIndex = i; + break; + } + } + + const current = milestones[currentIndex]; + const next = milestones[currentIndex + 1]; + + if (!next) { + return { + progress: 1, + currentPrize: current.prize, + nextPrize: null, + nextThreshold: getThreshold(current), + isMaxTier: true, + }; + } + + const rangeAmount = getThreshold(next) - getThreshold(current); + const progressInRange = totalAmount - getThreshold(current); + const progress = Math.min(progressInRange / rangeAmount, 1); + + return { + progress, + currentPrize: current.prize, + nextPrize: next.prize, + nextThreshold: getThreshold(next), + isMaxTier: false, + }; +} diff --git a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx index ff4289db332..17168682b60 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx @@ -23,7 +23,8 @@ const mockTraders = [ const mockUseTopTraders = jest.fn((_options?: unknown) => ({ traders: mockTraders, isLoading: false, - error: null, + isFetching: false, + error: null as string | null, refresh: mockRefetch, toggleFollow: jest.fn(), })); @@ -75,6 +76,7 @@ describe('TopTradersSection', () => { mockUseTopTraders.mockReturnValue({ traders: mockTraders, isLoading: false, + isFetching: false, error: null, refresh: mockRefetch, toggleFollow: jest.fn(), @@ -85,6 +87,7 @@ describe('TopTradersSection', () => { mockUseTopTraders.mockReturnValue({ traders: [], isLoading: false, + isFetching: false, error: null, refresh: mockRefetch, toggleFollow: jest.fn(), @@ -97,6 +100,7 @@ describe('TopTradersSection', () => { mockUseTopTraders.mockReturnValue({ traders: [], isLoading: true, + isFetching: true, error: null, refresh: mockRefetch, toggleFollow: jest.fn(), @@ -139,6 +143,86 @@ describe('TopTradersSection', () => { ); }); + it('renders the error state instead of the carousel when the fetch fails', () => { + mockUseTopTraders.mockReturnValue({ + traders: [], + isLoading: false, + isFetching: false, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect(screen.queryByTestId('homepage-top-traders-carousel')).toBeNull(); + expect( + screen.getByTestId('homepage-top-traders-section-root'), + ).toBeOnTheScreen(); + }); + + it('calls refresh when the retry button in the error state is pressed', async () => { + mockUseTopTraders.mockReturnValue({ + traders: [], + isLoading: false, + isFetching: false, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + fireEvent.press(screen.getByText('Retry')); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('renders skeletons instead of error state while a retry is in flight', () => { + mockUseTopTraders.mockReturnValue({ + traders: [], + isLoading: false, + isFetching: true, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect( + screen.getByTestId('homepage-top-traders-carousel'), + ).toBeOnTheScreen(); + expect(screen.queryByText('Retry')).toBeNull(); + }); + + it('keeps cached traders visible when a background refetch fails', () => { + mockUseTopTraders.mockReturnValue({ + traders: mockTraders, + isLoading: false, + isFetching: false, + error: 'Network error', + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect(screen.getByTestId('top-trader-card-trader-1')).toBeOnTheScreen(); + expect(screen.queryByText('Retry')).toBeNull(); + }); + + it('keeps cached traders and ViewMoreCard visible during a background refetch', () => { + mockUseTopTraders.mockReturnValue({ + traders: mockTraders, + isLoading: false, + isFetching: true, + error: null, + refresh: mockRefetch, + toggleFollow: jest.fn(), + }); + renderWithProvider(); + + expect(screen.getByTestId('top-trader-card-trader-1')).toBeOnTheScreen(); + expect(screen.getByTestId('top-traders-view-more-card')).toBeOnTheScreen(); + }); + it('exposes refresh via ref and resolves when called', async () => { const ref = createRef(); renderWithProvider(); diff --git a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx index e4d4e987664..e1005c6c2ac 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx @@ -16,6 +16,7 @@ import SectionHeader from '../../../../../component-library/components-temp/Sect import Routes from '../../../../../constants/navigation/Routes'; import type { RootStackParamList } from '../../../../../core/NavigationService/types'; import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard'; +import ErrorState from '../../components/ErrorState'; import ViewMoreCard from '../../components/ViewMoreCard'; import useHomeViewedEvent, { HomeSectionNames, @@ -55,10 +56,11 @@ const TopTradersSection = forwardRef< const isEnabled = useSelector(selectSocialLeaderboardEnabled); const title = strings('homepage.sections.top_traders'); - const { traders, isLoading, refresh, toggleFollow } = useTopTraders({ - limit: HOME_TRADER_LIMIT, - enabled: isEnabled, - }); + const { traders, isLoading, isFetching, error, refresh, toggleFollow } = + useTopTraders({ + limit: HOME_TRADER_LIMIT, + enabled: isEnabled, + }); useImperativeHandle( ref, @@ -68,7 +70,11 @@ const TopTradersSection = forwardRef< [refresh], ); - const willRender = isEnabled && (isLoading || traders.length > 0); + const isInFlight = isLoading || isFetching; + const hasTraders = traders.length > 0; + const hasError = Boolean(error); + const showError = hasError && !isFetching && !hasTraders; + const willRender = isEnabled && (isInFlight || hasError || hasTraders); const { onLayout } = useHomeViewedEvent({ sectionRef: willRender ? sectionViewRef : null, @@ -82,12 +88,23 @@ const TopTradersSection = forwardRef< useSectionPerformance({ sectionId: HomeSectionNames.TOP_TRADERS, - contentReady: !isLoading && traders.length > 0, - isEmpty: !isLoading && traders.length === 0, + contentReady: !isLoading && hasTraders, + // Exclude error renders from the empty bucket so Sentry doesn't conflate + // visible error states (which render the retry UI) with truly empty + // sections. Without this, a fetch error with no cached traders would be + // reported as `content_state: 'empty'`. + isEmpty: !isLoading && !hasError && !hasTraders, isLoading, - enabled: isEnabled, + // Disable telemetry once we render the error UI so the in-flight TTC and + // data-fetch spans get closed via the hook's cleanup instead of remaining + // open until the user navigates away. + enabled: isEnabled && !showError, }); + const showSkeletons = isInFlight && !hasTraders; + const showViewMore = hasTraders; + const isEmpty = !isInFlight && !hasError && !hasTraders; + const handleViewAll = useCallback(() => { navigation.navigate(Routes.SOCIAL_LEADERBOARD.VIEW); }, [navigation]); @@ -103,10 +120,30 @@ const TopTradersSection = forwardRef< [navigation], ); - if (!isEnabled || (!isLoading && traders.length === 0)) { + if (!isEnabled || isEmpty) { return null; } + if (showError) { + return ( + + + + + + + ); + } + return ( - {isLoading + {showSkeletons ? SKELETON_KEYS.map((key) => ) : traders.map((trader) => ( ))} - {!isLoading && traders.length > 0 && ( + {showViewMore && ( ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockRefetch = jest.fn(); const mockUseQuery = useQuery as jest.MockedFunction; const mockUseSelector = useSelector as jest.MockedFunction; @@ -74,6 +81,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: mockRefetch, ...overrides, @@ -83,6 +91,7 @@ describe('useTopTraders', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); mockUseSelector.mockImplementation((selector) => { if (selector === selectIsUnlocked) return true; return []; // default for other selectors (e.g. selectFollowingProfileIds) @@ -163,13 +172,17 @@ describe('useTopTraders', () => { expect(result.current.error).toBe('Network error'); }); - it('logs the full error object via Logger.error', () => { + it('logs the full error object via Logger.error with enriched extras', () => { const err = new Error('Network error'); mockUseQuery.mockReturnValue(makeQueryResult({ error: err })); renderHook(() => useTopTraders()); expect(Logger.error).toHaveBeenCalledWith( err, - 'useTopTraders: leaderboard fetch failed', + expect.objectContaining({ + message: 'useTopTraders: leaderboard fetch failed', + endpoint: 'leaderboard', + errorCategory: expect.any(String), + }), ); }); @@ -330,9 +343,47 @@ describe('useTopTraders', () => { expect(Logger.error).toHaveBeenCalledWith( err, - 'useTopTraders: refresh failed', + expect.objectContaining({ + message: 'useTopTraders: refresh failed', + endpoint: 'leaderboard', + errorCategory: expect.any(String), + }), + ); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb when an error is set', () => { + const err = new Error('fetch failed'); + mockUseQuery.mockReturnValue(makeQueryResult({ error: err })); + renderHook(() => useTopTraders()); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining( + 'social_service.leaderboard.failure', + ), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const err = Object.assign(new Error('Unauthorized'), { httpStatus: 401 }); + mockUseQuery.mockReturnValue(makeQueryResult({ error: err })); + renderHook(() => useTopTraders()); + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain( + 'status=401', ); }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue( + makeQueryResult({ data: mockLeaderboardResponse as never }), + ); + renderHook(() => useTopTraders()); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); describe('options', () => { diff --git a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts index a4aa3386664..51effe0f10a 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts +++ b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts @@ -9,10 +9,17 @@ import Logger from '../../../../../../util/Logger'; import { useFollowToggleMany } from '../../../../../hooks/useFollowToggle'; import { selectIsUnlocked } from '../../../../../../selectors/keyringController'; import type { TopTrader } from '../types'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../../util/social/socialServiceTelemetry'; export interface UseTopTradersResult { traders: TopTrader[]; isLoading: boolean; + isFetching: boolean; error: string | null; refresh: () => Promise; toggleFollow: (addressOrId: string) => void; @@ -37,10 +44,11 @@ export const useTopTraders = ( fetchOptions, ]; - const { data, isLoading, error, refetch } = useQuery({ - queryKey, - enabled: (options?.enabled ?? true) && isUnlocked, - }); + const { data, isLoading, isFetching, error, refetch } = + useQuery({ + queryKey, + enabled: (options?.enabled ?? true) && isUnlocked, + }); const { isFollowing, toggleFollow } = useFollowToggleMany(); @@ -66,20 +74,43 @@ export const useTopTraders = ( try { await refetch(); } catch (err) { - Logger.error(err as Error, 'useTopTraders: refresh failed'); + Logger.error( + err as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTopTraders: refresh failed', + endpoint: 'leaderboard', + error: err, + queryParams: { limit: options?.limit ?? 0 }, + }), + ); throw err; } - }, [refetch]); + }, [refetch, options?.limit]); useEffect(() => { if (error) { - Logger.error(error as Error, 'useTopTraders: leaderboard fetch failed'); + Logger.error( + error as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTopTraders: leaderboard fetch failed', + endpoint: 'leaderboard', + error, + queryParams: { limit: options?.limit ?? 0 }, + }), + ); + addSocialBreadcrumb({ + endpoint: 'leaderboard', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + queryParams: { limit: options?.limit ?? 0 }, + }); } - }, [error]); + }, [error, options?.limit]); return { traders, isLoading, + isFetching, error: error instanceof Error ? error.message : error ? String(error) : null, refresh, diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts index 10ec450c28d..904fc75d8c1 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts @@ -1,5 +1,6 @@ import { renderHook, act } from '@testing-library/react-native'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import Logger from '../../../../../util/Logger'; import { useFollowedTraders } from './useFollowedTraders'; @@ -9,6 +10,12 @@ jest.mock('../../../../../util/Logger', () => ({ jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockRefetch = jest.fn(); const mockUseQuery = useQuery as jest.MockedFunction; @@ -18,6 +25,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: mockRefetch, ...overrides, @@ -45,6 +53,7 @@ describe('useFollowedTraders', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); }); describe('query configuration', () => { @@ -135,13 +144,17 @@ describe('useFollowedTraders', () => { expect(result.current.error).toBe('raw error'); }); - it('logs query errors', () => { + it('logs query errors with enriched extras', () => { const error = new Error('fetch failed'); mockUseQuery.mockReturnValue(makeQueryResult({ error })); renderHook(() => useFollowedTraders()); expect(Logger.error).toHaveBeenCalledWith( error, - 'useFollowedTraders: following fetch failed', + expect.objectContaining({ + message: 'useFollowedTraders: following fetch failed', + endpoint: 'following', + errorCategory: expect.any(String), + }), ); }); @@ -177,8 +190,46 @@ describe('useFollowedTraders', () => { expect(Logger.error).toHaveBeenCalledWith( error, - 'useFollowedTraders: refresh failed', + expect.objectContaining({ + message: 'useFollowedTraders: refresh failed', + endpoint: 'following', + errorCategory: expect.any(String), + }), + ); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb when an error is set', () => { + const error = new Error('fetch failed'); + mockUseQuery.mockReturnValue(makeQueryResult({ error })); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining('social_service.following.failure'), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const error = Object.assign(new Error('Unauthorized'), { + httpStatus: 401, + }); + mockUseQuery.mockReturnValue(makeQueryResult({ error })); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain( + 'status=401', ); }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue( + makeQueryResult({ data: fixtureFollowing as never }), + ); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts index e19a1c41730..dad7d878154 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts @@ -2,6 +2,12 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from '@metamask/react-data-query'; import type { FollowingResponse } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; export interface FollowedTrader { /** Clicker profile ID. */ @@ -67,7 +73,14 @@ export const useFollowedTraders = ( try { await refetch(); } catch (err) { - Logger.error(err as Error, 'useFollowedTraders: refresh failed'); + Logger.error( + err as Error, + buildSocialErrorExtras({ + legacyMessage: 'useFollowedTraders: refresh failed', + endpoint: 'following', + error: err, + }), + ); throw err; } }, [refetch]); @@ -76,8 +89,17 @@ export const useFollowedTraders = ( if (error) { Logger.error( error as Error, - 'useFollowedTraders: following fetch failed', + buildSocialErrorExtras({ + legacyMessage: 'useFollowedTraders: following fetch failed', + endpoint: 'following', + error, + }), ); + addSocialBreadcrumb({ + endpoint: 'following', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + }); } }, [error]); diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx index 64136921239..f2603e7c76c 100644 --- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx @@ -63,6 +63,7 @@ const fixtureTraders: TopTrader[] = [ const defaultUseTopTradersResult: UseTopTradersResult = { traders: fixtureTraders, isLoading: false, + isFetching: false, error: null, refresh: mockRefresh as () => Promise, toggleFollow: mockToggleFollow, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts index d9e00b39e0a..8a954918d20 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import type { Position } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; @@ -20,6 +21,12 @@ jest.mock('../../../../../util/Logger', () => ({ jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockUseQuery = useQuery as jest.MockedFunction; const mockUseSelector = useSelector as jest.MockedFunction; @@ -119,7 +126,7 @@ describe('useTraderPosition', () => { expect(result.current.position).toBeUndefined(); }); - it('returns the error message and logs on failure', () => { + it('returns the error message and logs on failure with enriched extras', () => { const fetchError = new Error('boom'); mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); @@ -128,7 +135,47 @@ describe('useTraderPosition', () => { expect(result.current.error).toBe('boom'); expect(Logger.error).toHaveBeenCalledWith( fetchError, - 'useTraderPosition: fetch failed', + expect.objectContaining({ + message: 'useTraderPosition: fetch failed', + endpoint: 'position_by_id', + errorCategory: expect.any(String), + }), ); }); + + it('emits a failure breadcrumb when an error is set', () => { + const fetchError = new Error('boom'); + mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining( + 'social_service.position_by_id.failure', + ), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const fetchError = Object.assign(new Error('Unauthorized'), { + httpStatus: 401, + }); + mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain('status=401'); + }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue(makeQueryResult({ data: mockPosition })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts index 9eb66a40ead..06c6aff0f83 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts @@ -3,6 +3,12 @@ import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; import type { Position } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; export interface UseTraderPositionResult { @@ -34,7 +40,19 @@ export const useTraderPosition = ( useEffect(() => { if (error) { - Logger.error(error as Error, 'useTraderPosition: fetch failed'); + Logger.error( + error as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPosition: fetch failed', + endpoint: 'position_by_id', + error, + }), + ); + addSocialBreadcrumb({ + endpoint: 'position_by_id', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + }); } }, [error]); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts index 72426532143..3d515e912c0 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import Logger from '../../../../../util/Logger'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; import { useTraderPositions } from './useTraderPositions'; @@ -19,6 +20,12 @@ jest.mock('../../../../../util/Logger', () => ({ jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockUseQuery = useQuery as jest.MockedFunction; const mockUseSelector = useSelector as jest.MockedFunction; @@ -28,6 +35,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: jest.fn(), ...overrides, @@ -75,6 +83,7 @@ describe('useTraderPositions', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); mockUseSelector.mockImplementation((selector) => { if (selector === selectIsUnlocked) return true; return undefined; @@ -278,7 +287,7 @@ describe('useTraderPositions', () => { expect(result.current.error).toBe('raw error'); }); - it('logs the combined error', () => { + it('logs the open error with enriched extras including the endpoint', () => { const error = new Error('fetch failed'); mockUseQuery @@ -289,7 +298,30 @@ describe('useTraderPositions', () => { expect(Logger.error).toHaveBeenCalledWith( error, - 'useTraderPositions: positions fetch failed', + expect.objectContaining({ + message: 'useTraderPositions: positions fetch failed', + endpoint: 'open_positions', + errorCategory: expect.any(String), + }), + ); + }); + + it('logs the closed error with enriched extras including the endpoint', () => { + const error = new Error('closed fetch failed'); + + mockUseQuery + .mockReturnValueOnce(makeQueryResult()) + .mockReturnValueOnce(makeQueryResult({ error })); + + renderHook(() => useTraderPositions('trader-1')); + + expect(Logger.error).toHaveBeenCalledWith( + error, + expect.objectContaining({ + message: 'useTraderPositions: positions fetch failed', + endpoint: 'closed_positions', + errorCategory: expect.any(String), + }), ); }); @@ -297,5 +329,78 @@ describe('useTraderPositions', () => { renderHook(() => useTraderPositions('trader-1')); expect(Logger.error).not.toHaveBeenCalled(); }); + + it('does NOT include addressOrId in the Logger.error extras', () => { + const error = new Error('fetch failed'); + + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('0xSensitiveAddress')); + + const call = (Logger.error as jest.Mock).mock.calls[0]; + const extras = call[1]; + const serialised = JSON.stringify(extras); + expect(serialised).not.toContain('0xSensitiveAddress'); + expect(Object.keys(extras)).not.toContain('addressOrId'); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb for open_positions on error', () => { + const error = new Error('open failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('trader-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + message: expect.stringContaining( + 'social_service.open_positions.failure', + ), + }), + ); + }); + + it('emits a failure breadcrumb for closed_positions on error', () => { + const error = new Error('closed failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult()) + .mockReturnValueOnce(makeQueryResult({ error })); + + renderHook(() => useTraderPositions('trader-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + message: expect.stringContaining( + 'social_service.closed_positions.failure', + ), + }), + ); + }); + + it('does not emit a breadcrumb when there are no errors', () => { + renderHook(() => useTraderPositions('trader-1')); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('never includes addressOrId in breadcrumb data', () => { + const error = new Error('open failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('0xSensitiveAddress')); + + mockAddBreadcrumb.mock.calls.forEach(([breadcrumb]) => { + const serialised = JSON.stringify(breadcrumb); + expect(serialised).not.toContain('0xSensitiveAddress'); + }); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts index 5f04513c1ec..d083ee59c2f 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts @@ -7,6 +7,12 @@ import type { Position, } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; import { selectIsUnlocked } from '../../../../../selectors/keyringController'; const EMPTY_POSITIONS: Position[] = []; @@ -52,16 +58,43 @@ export const useTraderPositions = ( const openPositions = openData?.positions ?? EMPTY_POSITIONS; const closedPositions = closedData?.positions ?? EMPTY_POSITIONS; - const combinedError = openError ?? closedError; + useEffect(() => { + if (openError) { + Logger.error( + openError as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPositions: positions fetch failed', + endpoint: 'open_positions', + error: openError, + }), + ); + addSocialBreadcrumb({ + endpoint: 'open_positions', + errorCategory: categoriseSocialError(openError), + httpStatus: extractHttpStatus(openError), + }); + } + }, [openError]); useEffect(() => { - if (combinedError) { + if (closedError) { Logger.error( - combinedError as Error, - 'useTraderPositions: positions fetch failed', + closedError as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPositions: positions fetch failed', + endpoint: 'closed_positions', + error: closedError, + }), ); + addSocialBreadcrumb({ + endpoint: 'closed_positions', + errorCategory: categoriseSocialError(closedError), + httpStatus: extractHttpStatus(closedError), + }); } - }, [combinedError]); + }, [closedError]); + + const combinedError = openError ?? closedError; return { openPositions, diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 55e69ffe02a..13c14514deb 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -64,6 +64,20 @@ jest.mock('../../../hooks/pay/useTransactionPayWithdraw', () => ({ })), })); jest.mock('../../../../../../util/transaction-controller', () => ({})); +jest.mock('../../../../../UI/Money/hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: () => ({ + vaultApyQuery: { data: { apy: 5.5 }, isLoading: false }, + }), +})); +jest.mock( + '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', + () => ({ + __esModule: true, + default: () => (value: { toString: () => string }) => + `$${Number(value.toString()).toFixed(2)}`, + }), +); jest.mock('../../../../../../core/Engine', () => ({ context: { TransactionPayController: { diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 95ff6dc6f93..225da89ba8d 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -2,6 +2,7 @@ import React, { ReactNode, memo, useCallback, useState } from 'react'; import { toCaipAssetType } from '@metamask/utils'; import { TransactionType } from '@metamask/transaction-controller'; import { PayTokenAmount, PayTokenAmountSkeleton } from '../../pay-token-amount'; +import { ProjectedFiveYearBalance } from '../../projected-five-year-balance'; import { PayWithRow, PayWithRowSkeleton } from '../../rows/pay-with-row'; import { BridgeFeeRow } from '../../rows/bridge-fee-row'; import { BridgeTimeRow } from '../../rows/bridge-time-row'; @@ -208,12 +209,16 @@ export const CustomAmountInfo: React.FC = memo( onPress={handleAmountPress} disabled={!hasTokens} /> - {!hidePayTokenAmount && disablePay !== true && ( - - )} + {!hidePayTokenAmount && + disablePay !== true && + (isMoneyAccountDeposit ? ( + + ) : ( + + ))} {!hidePayTokenAmount && children} ); +} + +describe('ProjectedFiveYearBalance', () => { + const formatFiat = jest.fn( + (value: BigNumber) => `$${value.toFixed(2, BigNumber.ROUND_HALF_UP)}`, + ); + + beforeEach(() => { + jest.clearAllMocks(); + useFiatFormatterMock.mockReturnValue(formatFiat); + }); + + it('renders label and projected balance for $1,000 at 5% APY over 5 years (~$1,276.28)', () => { + mockBalance({ apy: 5 }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId('projected-five-year-balance')).toBeOnTheScreen(); + expect(getByText(LABEL, { exact: false })).toBeOnTheScreen(); + // 1000 * (1.05)^5 = 1276.2815625 + expect(getByText('$1276.28')).toBeOnTheScreen(); + }); + + it('matches the Figma example: $1,000 at the design APY rounds to $1,114.36 when APY=2.18', () => { + mockBalance({ apy: 2.18 }); + + const { getByText } = render( + , + ); + + // 1000 * (1.0218)^5 ≈ 1113.86 — sanity-checks the compounding formula + // tracks the figma direction (label + green dollar amount); exact APY/value + // is product-driven, this just guards the math. + expect(getByText(/^\$1\d{3}\.\d{2}$/)).toBeOnTheScreen(); + }); + + it('returns null while APY is loading', () => { + mockBalance({ apy: undefined, isLoading: true }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY data is unavailable', () => { + mockBalance({ apy: undefined }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY is negative', () => { + mockBalance({ apy: -1 }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('returns null when APY is not finite', () => { + mockBalance({ apy: Number.POSITIVE_INFINITY }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); + + it('renders $0.00 when apy is 0% (compounding identity)', () => { + mockBalance({ apy: 0 }); + + const { getByText } = render(); + + expect(getByText('$0.00')).toBeOnTheScreen(); + }); + + it('treats empty amountFiat as zero', () => { + mockBalance({ apy: 5 }); + + const { getByText } = render(); + + expect(getByText('$0.00')).toBeOnTheScreen(); + }); + + it('passes a BigNumber to the fiat formatter', () => { + mockBalance({ apy: 5 }); + + render(); + + expect(formatFiat).toHaveBeenCalledTimes(1); + const passed = formatFiat.mock.calls[0][0]; + expect(BigNumber.isBigNumber(passed)).toBe(true); + // 1000 * 1.05^5 = 1276.2815625 + expect(passed.toFixed(4)).toBe('1276.2816'); + }); + + it('returns null when amountFiat is non-numeric', () => { + mockBalance({ apy: 5 }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('projected-five-year-balance')).toBeNull(); + }); +}); diff --git a/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx b/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx new file mode 100644 index 00000000000..527af80e6ed --- /dev/null +++ b/app/components/Views/confirmations/components/projected-five-year-balance/projected-five-year-balance.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import BigNumber from 'bignumber.js'; +import { + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import useMoneyAccountBalance from '../../../../UI/Money/hooks/useMoneyAccountBalance'; +import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { strings } from '../../../../../../locales/i18n'; + +const PROJECTION_YEARS = 5; + +export interface ProjectedFiveYearBalanceProps { + amountFiat: string; +} + +export function ProjectedFiveYearBalance({ + amountFiat, +}: ProjectedFiveYearBalanceProps) { + const { vaultApyQuery } = useMoneyAccountBalance(); + const formatFiat = useFiatFormatter(); + + const projected = useMemo(() => { + const apy = vaultApyQuery.data?.apy; + if (typeof apy !== 'number' || !isFinite(apy) || apy < 0) { + return null; + } + + const amount = new BigNumber(amountFiat || '0'); + if (!amount.isFinite()) { + return null; + } + + const growthFactor = new BigNumber(1).plus( + new BigNumber(apy).dividedBy(100), + ); + return amount.multipliedBy(growthFactor.pow(PROJECTION_YEARS)); + }, [amountFiat, vaultApyQuery.data?.apy]); + + if (vaultApyQuery.isLoading || projected === null) { + return null; + } + + return ( + + + {strings('confirm.custom_amount.projected_five_year_balance')}{' '} + + {formatFiat(projected)} + + + + ); +} diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index ec03391eee0..323016d1752 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -115,6 +115,11 @@ const Routes = { REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW: 'RewardsOndoCampaignPortfolioView', REWARDS_ONDO_CAMPAIGN_STATS: 'RewardsOndoCampaignStats', REWARDS_CAMPAIGN_TOUR_STEP: 'RewardsCampaignTourStep', + REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW: + 'RewardsPerpsTradingCampaignDetails', + REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD: + 'RewardsPerpsTradingCampaignLeaderboard', + REWARDS_PERPS_TRADING_CAMPAIGN_STATS: 'RewardsPerpsTradingCampaignStats', TRENDING_VIEW: 'TrendingView', TRENDING_FEED: 'TrendingFeed', WHATS_HAPPENING_DETAIL: 'WhatsHappeningDetailView', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index f5fda37b14b..be1f9d95265 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -702,6 +702,47 @@ export type RewardsControllerInvalidateSubscriptionCacheAction = { handler: RewardsController['invalidateSubscriptionCache']; }; +/** + * Get the perps trading campaign leaderboard. + * This is a public endpoint - no authentication required. + * Results are cached for 5 minutes. + * + * @param campaignId - The campaign ID to get leaderboard for. + * @returns The leaderboard entries and metadata. + */ +export type RewardsControllerGetPerpsTradingCampaignLeaderboardAction = { + type: `RewardsController:getPerpsTradingCampaignLeaderboard`; + handler: RewardsController['getPerpsTradingCampaignLeaderboard']; +}; + +/** + * Get the current user's position on the perps trading campaign leaderboard. + * This is an authenticated endpoint. + * Results are cached for 5 minutes. + * + * @param campaignId - The campaign ID to get position for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's leaderboard position, or null if not found. + */ +export type RewardsControllerGetPerpsTradingCampaignLeaderboardPositionAction = + { + type: `RewardsController:getPerpsTradingCampaignLeaderboardPosition`; + handler: RewardsController['getPerpsTradingCampaignLeaderboardPosition']; + }; + +/** + * Get the perps trading campaign aggregate volume (public stats). + * This is a public endpoint - no authentication required. + * Results are cached for 1 minute. + * + * @param campaignId - The campaign ID to get volume for. + * @returns Current aggregate notional volume for the campaign. + */ +export type RewardsControllerGetPerpsTradingCampaignVolumeAction = { + type: `RewardsController:getPerpsTradingCampaignVolume`; + handler: RewardsController['getPerpsTradingCampaignVolume']; +}; + /** * Union of all RewardsController action types. */ @@ -771,4 +812,7 @@ export type RewardsControllerMethodActions = | RewardsControllerApplyBonusCodeAction | RewardsControllerGetClientVersionRequirementsAction | RewardsControllerInvalidateReferralDetailsCacheAction - | RewardsControllerInvalidateSubscriptionCacheAction; + | RewardsControllerInvalidateSubscriptionCacheAction + | RewardsControllerGetPerpsTradingCampaignLeaderboardAction + | RewardsControllerGetPerpsTradingCampaignLeaderboardPositionAction + | RewardsControllerGetPerpsTradingCampaignVolumeAction; diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 4c61d9b9d8e..ae1187bb7ad 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -16124,6 +16124,9 @@ describe('RewardsController', () => { ondoCampaignLeaderboard: {}, ondoCampaignLeaderboardPositions: {}, ondoCampaignPortfolio: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEstimateHistory: [], pointsEvents: {}, seasonStatuses: {}, @@ -16150,6 +16153,9 @@ describe('RewardsController', () => { ondoCampaignLeaderboard: {}, ondoCampaignLeaderboardPositions: {}, ondoCampaignPortfolio: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEstimateHistory: [], pointsEvents: {}, rewardsEnvUrl: null, @@ -16181,6 +16187,9 @@ describe('RewardsController', () => { ondoCampaignLeaderboard: {}, ondoCampaignLeaderboardPositions: {}, ondoCampaignPortfolio: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEvents: {}, rewardsEnvUrl: null, seasonStatuses: {}, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index b9667dce4e7..33aa24818e1 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -29,6 +29,9 @@ import { type OndoGmPortfolioState, type OndoGmCampaignDepositsDto, type OndoGmCampaignParticipantOutcomeDto, + type PerpsTradingCampaignLeaderboardDto, + type PerpsTradingCampaignLeaderboardPositionDto, + type PerpsTradingCampaignVolumeDto, type PaginatedOndoGmActivityDto, type OndoGmActivityState, type PointsEstimateHistoryEntry, @@ -141,6 +144,16 @@ const ONDO_CAMPAIGN_ACTIVITY_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute cac // Campaign participant outcome cache threshold const ONDO_CAMPAIGN_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes +// Perps Trading Campaign leaderboard cache threshold (HyperTracker refreshes every ~15 min) +const PERPS_TRADING_CAMPAIGN_LEADERBOARD_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes + +// Perps Trading Campaign leaderboard position cache threshold +const PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS = + 1000 * 60 * 5; // 5 minutes + +// Perps Trading Campaign volume cache threshold +const PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute + // Opt-in status stale threshold for not opted-in accounts to force a fresh check const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60; // 1 hour @@ -253,6 +266,24 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + perpsTradingCampaignLeaderboard: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + perpsTradingCampaignLeaderboardPositions: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + perpsTradingCampaignVolume: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, pointsEstimateHistory: { includeInStateLogs: true, persist: true, @@ -295,6 +326,9 @@ export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ ondoCampaignPortfolio: {}, ondoCampaignActivity: {}, ondoCampaignDeposits: {}, + perpsTradingCampaignLeaderboard: {}, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: {}, pointsEstimateHistory: [], rewardsEnvUrl: null, }); @@ -409,6 +443,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'getOndoCampaignActivity', 'getOndoCampaignPortfolioPosition', 'getOndoCampaignParticipantOutcome', + 'getPerpsTradingCampaignLeaderboard', + 'getPerpsTradingCampaignLeaderboardPosition', + 'getPerpsTradingCampaignVolume', 'getOptInStatus', 'getPerpsDiscountForAccount', 'getPointsEvents', @@ -4428,4 +4465,186 @@ export class RewardsController extends BaseController< seasonId || 'all seasons', ); } + + /** + * Get the perps trading campaign leaderboard. + * This is a public endpoint - no authentication required. + * Results are cached for 5 minutes. + * @param campaignId - The campaign ID to get leaderboard for. + * @returns The leaderboard entries and metadata. + */ + async getPerpsTradingCampaignLeaderboard( + campaignId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return { campaignId, computedAt: '', entries: [], totalParticipants: 0 }; + } + + const result = await wrapWithCache({ + key: campaignId, + ttl: PERPS_TRADING_CAMPAIGN_LEADERBOARD_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.perpsTradingCampaignLeaderboard[k]; + if (!cached) return undefined; + return { + payload: { + campaignId: cached.campaignId, + computedAt: cached.computedAt, + entries: cached.entries, + totalParticipants: cached.totalParticipants, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => { + Logger.log( + 'RewardsController: Fetching fresh perps trading campaign leaderboard via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignLeaderboard', + campaignId, + )) as PerpsTradingCampaignLeaderboardDto; + }, + writeCache: (k, payload) => { + this.update((state) => { + state.perpsTradingCampaignLeaderboard[k] = { + campaignId: payload.campaignId, + computedAt: payload.computedAt, + entries: payload.entries, + totalParticipants: payload.totalParticipants, + lastFetched: Date.now(), + }; + }); + }, + }); + return result; + } + + /** + * Get the current user's position on the perps trading campaign leaderboard. + * This is an authenticated endpoint. + * Results are cached for 5 minutes. + * @param campaignId - The campaign ID to get position for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's leaderboard position, or null if not found. + */ + async getPerpsTradingCampaignLeaderboardPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return null; + } + + const key = `${subscriptionId}:${campaignId}`; + const result = + await wrapWithCache({ + key, + ttl: PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.perpsTradingCampaignLeaderboardPositions[k]; + if (!cached) return undefined; + if ('notFound' in cached) { + return { payload: null, lastFetched: cached.lastFetched }; + } + return { + payload: { + rank: cached.rank, + pnl: cached.pnl, + notionalVolume: cached.notionalVolume, + marginDeployed: cached.marginDeployed, + qualified: cached.qualified, + neighbors: cached.neighbors, + computedAt: cached.computedAt, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => + this.#withAuthRetry(async () => { + Logger.log( + 'RewardsController: Fetching fresh perps trading campaign leaderboard position via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition', + campaignId, + subscriptionId, + )) as PerpsTradingCampaignLeaderboardPositionDto | null; + }, subscriptionId), + writeCache: (k, payload) => { + if (payload === null) { + this.update((state) => { + state.perpsTradingCampaignLeaderboardPositions[k] = { + notFound: true, + lastFetched: Date.now(), + }; + }); + } else { + this.update((state) => { + state.perpsTradingCampaignLeaderboardPositions[k] = { + rank: payload.rank, + pnl: payload.pnl, + notionalVolume: payload.notionalVolume, + marginDeployed: payload.marginDeployed, + qualified: payload.qualified, + neighbors: payload.neighbors, + computedAt: payload.computedAt, + lastFetched: Date.now(), + }; + }); + } + }, + }); + return result; + } + + /** + * Get the perps trading campaign aggregate volume (public stats). + * This is a public endpoint - no authentication required. + * Results are cached for 1 minute. + * @param campaignId - The campaign ID to get volume for. + * @returns Current aggregate notional volume for the campaign. + */ + async getPerpsTradingCampaignVolume( + campaignId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return { + totalUsdVolume: '0', + }; + } + + const result = await wrapWithCache({ + key: campaignId, + ttl: PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.perpsTradingCampaignVolume[k]; + if (!cached) return undefined; + return { + payload: { + totalUsdVolume: cached.totalUsdVolume, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => { + Logger.log( + 'RewardsController: Fetching fresh perps trading campaign volume via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignVolume', + campaignId, + )) as PerpsTradingCampaignVolumeDto; + }, + writeCache: (k, payload) => { + this.update((state) => { + state.perpsTradingCampaignVolume[k] = { + totalUsdVolume: payload.totalUsdVolume, + lastFetched: Date.now(), + }; + }); + }, + }); + return result; + } } diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index 0893163f302..a7c8b574492 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -5087,4 +5087,141 @@ describe('RewardsDataService', () => { ).rejects.toThrow('Get Ondo GM participant outcome failed: 500'); }); }); + + describe('getPerpsTradingCampaignLeaderboard', () => { + const mockCampaignId = 'perps-campaign-api-1'; + const mockLeaderboard = { + campaignId: mockCampaignId, + computedAt: '2025-08-15T12:00:00.000Z', + entries: [], + totalParticipants: 0, + }; + + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockLeaderboard), + } as unknown as Response); + }); + + it('calls the public perps leaderboard endpoint with GET and returns data', async () => { + const result = + await service.getPerpsTradingCampaignLeaderboard(mockCampaignId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/leaderboard`, + expect.objectContaining({ method: 'GET' }), + ); + expect(result).toEqual(mockLeaderboard); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 502 } as Response); + + await expect( + service.getPerpsTradingCampaignLeaderboard(mockCampaignId), + ).rejects.toThrow('Get perps trading campaign leaderboard failed: 502'); + }); + }); + + describe('getPerpsTradingCampaignLeaderboardPosition', () => { + const mockCampaignId = 'perps-campaign-api-2'; + const mockSubscriptionId = 'sub-perps-1'; + const mockToken = 'test-bearer-token'; + const mockPosition = { + rank: 4, + pnl: 12.5, + notionalVolume: 8000, + marginDeployed: 1200, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', + }; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: mockToken, + }); + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockPosition), + } as unknown as Response); + }); + + it('calls the authenticated me endpoint and returns the position', async () => { + const result = await service.getPerpsTradingCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/leaderboard/me`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'rewards-access-token': mockToken, + }), + }), + ); + expect(result).toEqual(mockPosition); + }); + + it('returns null on 404', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 } as Response); + + const result = await service.getPerpsTradingCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + }); + + it('throws on non-404 error responses', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 503 } as Response); + + await expect( + service.getPerpsTradingCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ), + ).rejects.toThrow( + 'Get perps trading campaign leaderboard position failed: 503', + ); + }); + }); + + describe('getPerpsTradingCampaignVolume', () => { + const mockCampaignId = 'perps-campaign-api-3'; + const mockVolume = { + totalUsdVolume: '2500000', + }; + + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockVolume), + } as unknown as Response); + }); + + it('calls the public volume stats endpoint with GET and returns data', async () => { + const result = + await service.getPerpsTradingCampaignVolume(mockCampaignId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/stats/total-volume`, + expect.objectContaining({ method: 'GET' }), + ); + expect(result).toEqual(mockVolume); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 } as Response); + + await expect( + service.getPerpsTradingCampaignVolume(mockCampaignId), + ).rejects.toThrow('Get perps trading campaign volume failed: 500'); + }); + }); }); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index dec6ea02b0d..1170e963d60 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -35,6 +35,9 @@ import type { PaginatedOndoGmActivityDto, OndoGmCampaignDepositsDto, OndoGmCampaignParticipantOutcomeDto, + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, + PerpsTradingCampaignVolumeDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -263,6 +266,21 @@ export interface RewardsDataServiceGetOndoCampaignParticipantOutcomeAction { handler: RewardsDataService['getOndoCampaignParticipantOutcome']; } +export interface RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignLeaderboard`; + handler: RewardsDataService['getPerpsTradingCampaignLeaderboard']; +} + +export interface RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignLeaderboardPosition`; + handler: RewardsDataService['getPerpsTradingCampaignLeaderboardPosition']; +} + +export interface RewardsDataServiceGetPerpsTradingCampaignVolumeAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignVolume`; + handler: RewardsDataService['getPerpsTradingCampaignVolume']; +} + export interface RewardsDataServiceGetRewardsEnvUrlAction { type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`; handler: RewardsDataService['getRewardsEnvUrl']; @@ -334,7 +352,10 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetOndoCampaignActivityAction | RewardsDataServiceGetOndoCampaignActivityLastUpdatedAction | RewardsDataServiceGetOndoCampaignDepositsAction - | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction; + | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; export type RewardsDataServiceMessenger = Messenger< typeof SERVICE_NAME, @@ -509,6 +530,18 @@ export class RewardsDataService { `${SERVICE_NAME}:getOndoCampaignParticipantOutcome`, this.getOndoCampaignParticipantOutcome.bind(this), ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignLeaderboard`, + this.getPerpsTradingCampaignLeaderboard.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignLeaderboardPosition`, + this.getPerpsTradingCampaignLeaderboardPosition.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignVolume`, + this.getPerpsTradingCampaignVolume.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getRewardsEnvUrl`, this.getRewardsEnvUrl.bind(this), @@ -1720,4 +1753,78 @@ export class RewardsDataService { return (await response.json()) as OndoGmCampaignParticipantOutcomeDto; } + + /** + * Get the Perps Trading Campaign leaderboard (top 20 entries, no tiers). + * Public endpoint — no authentication required. + * @param campaignId - The campaign ID. + */ + async getPerpsTradingCampaignLeaderboard( + campaignId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/leaderboard`, + { method: 'GET' }, + ); + + if (!response.ok) { + throw new Error( + `Get perps trading campaign leaderboard failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignLeaderboardDto; + } + + /** + * Get the current user's position on the Perps Trading Campaign leaderboard. + * Authenticated endpoint. + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's position, or null if not found (404). + */ + async getPerpsTradingCampaignLeaderboardPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/leaderboard/me`, + { method: 'GET' }, + subscriptionId, + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error( + `Get perps trading campaign leaderboard position failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignLeaderboardPositionDto; + } + + /** + * Get the Perps Trading Campaign volume stats. + * Public endpoint — no authentication required. + * @param campaignId - The campaign ID. + */ + async getPerpsTradingCampaignVolume( + campaignId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/stats/total-volume`, + { method: 'GET' }, + ); + + if (!response.ok) { + throw new Error( + `Get perps trading campaign volume failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignVolumeDto; + } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 8be5b549819..16cd347e36f 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -698,6 +698,108 @@ export type OndoGmCampaignDepositsState = { lastFetched: number; }; +// ─── Perps Trading Campaign ──────────────────────────────────────────────── + +/** + * A single entry in the Perps Trading Campaign leaderboard (no tiers). + */ +export interface PerpsTradingCampaignLeaderboardEntry { + rank: number; + referralCode: string; + /** Signed USD PnL for the campaign window */ + pnl: number; + /** true when notional volume ≥ $25k AND margin deployed ≥ $1k */ + qualified: boolean; +} + +/** + * Response DTO for GET /perps-trading/:campaignId/leaderboard (public, no auth). + */ +export interface PerpsTradingCampaignLeaderboardDto { + campaignId: string; + /** ISO timestamp — display as "last updated" (refreshes ~every 15 min) */ + computedAt: string; + entries: PerpsTradingCampaignLeaderboardEntry[]; + totalParticipants: number; +} + +/** + * Response DTO for GET /perps-trading/:campaignId/leaderboard/me (authenticated). + */ +export interface PerpsTradingCampaignLeaderboardPositionDto { + rank: number; + /** Signed USD PnL */ + pnl: number; + /** Cumulative notional volume traded during the competition window (USD) */ + notionalVolume: number; + /** Cumulative initial margin deployed during the competition window (USD) */ + marginDeployed: number; + qualified: boolean; + neighbors: PerpsTradingCampaignLeaderboardEntry[]; + computedAt: string; +} + +/** + * Response DTO for GET /perps-trading/:campaignId/stats/volume (public). + */ +export interface PerpsTradingCampaignVolumeDto { + /** Current aggregate notional volume across all participants (USD string) */ + totalUsdVolume: string; +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignLeaderboardState = { + campaignId: string; + computedAt: string; + entries: { + rank: number; + referralCode: string; + pnl: number; + qualified: boolean; + }[]; + totalParticipants: number; + lastFetched: number; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignLeaderboardPositionFoundState = { + rank: number; + pnl: number; + notionalVolume: number; + marginDeployed: number; + qualified: boolean; + neighbors: { + rank: number; + referralCode: string; + pnl: number; + qualified: boolean; + }[]; + computedAt: string; + lastFetched: number; +}; + +/** Sentinel stored when the API returns null (user not on leaderboard), so the TTL is respected. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignLeaderboardPositionNotFoundState = { + notFound: true; + lastFetched: number; +}; + +export type PerpsTradingCampaignLeaderboardPositionState = + | PerpsTradingCampaignLeaderboardPositionFoundState + | PerpsTradingCampaignLeaderboardPositionNotFoundState; + +/** + * Cached campaign volume (explicit shape for Json / StateConstraint compatibility). + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type PerpsTradingCampaignVolumeState = { + totalUsdVolume: string; + lastFetched: number; +}; + +// ─── End Perps Trading Campaign ──────────────────────────────────────────── + /** * State for cached leaderboard data in the controller */ @@ -1847,6 +1949,18 @@ export type RewardsControllerState = { }; /** Ondo campaign deposits keyed by campaignId (public endpoint). */ ondoCampaignDeposits: { [campaignId: string]: OndoGmCampaignDepositsState }; + /** Perps Trading Campaign leaderboard keyed by campaignId (public endpoint). */ + perpsTradingCampaignLeaderboard: { + [campaignId: string]: PerpsTradingCampaignLeaderboardState; + }; + /** Perps Trading Campaign leaderboard position keyed by compositeId (subscriptionId:campaignId). */ + perpsTradingCampaignLeaderboardPositions: { + [compositeId: string]: PerpsTradingCampaignLeaderboardPositionState; + }; + /** Perps Trading Campaign volume keyed by campaignId (public endpoint). */ + perpsTradingCampaignVolume: { + [campaignId: string]: PerpsTradingCampaignVolumeState; + }; /** * History of points estimates for Customer Support diagnostics. * Stores the last N successful estimates to verify user-reported discrepancies. diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index a6c6e538c66..34d15885a4c 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -66,6 +66,9 @@ import { RewardsDataServiceGetOndoCampaignActivityLastUpdatedAction, RewardsDataServiceGetOndoCampaignDepositsAction, RewardsDataServiceGetOndoCampaignParticipantOutcomeAction, + RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction, + RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction, + RewardsDataServiceGetPerpsTradingCampaignVolumeAction, } from '../../controllers/rewards-controller/services/rewards-data-service'; import { RootMessenger } from '../../types'; @@ -118,7 +121,10 @@ type AllowedActions = | RewardsDataServiceGetOndoCampaignActivityAction | RewardsDataServiceGetOndoCampaignActivityLastUpdatedAction | RewardsDataServiceGetOndoCampaignDepositsAction - | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction; + | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction + | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; // Don't reexport as per guidelines type AllowedEvents = @@ -193,12 +199,15 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getOndoCampaignActivityLastUpdated', 'RewardsDataService:getOndoCampaignDeposits', 'RewardsDataService:getOndoCampaignParticipantOutcome', + 'RewardsDataService:getPerpsTradingCampaignLeaderboard', + 'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition', + 'RewardsDataService:getPerpsTradingCampaignVolume', ], events: [ 'AccountTreeController:selectedAccountGroupChange', 'KeyringController:unlock', ], - }); + } as Parameters[0]); return messenger; } diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 27741555808..9ee33ec2753 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -41,6 +41,13 @@ import rewardsReducer, { setOndoCampaignDeposits, setOndoCampaignDepositsLoading, setOndoCampaignDepositsError, + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, + setPerpsTradingCampaignLeaderboardPosition, + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, bulkLinkStarted, bulkLinkAccountResult, bulkLinkCompleted, @@ -66,6 +73,8 @@ import { CampaignLeaderboardPositionDto, OndoGmPortfolioDto, OndoGmActivityEntryDto, + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { AccountGroupId } from '@metamask/account-api'; import { brandColor } from '@metamask/design-tokens'; @@ -2078,6 +2087,13 @@ describe('rewardsReducer', () => { versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, + perpsTradingCampaignLeaderboard: null, + perpsTradingCampaignLeaderboardLoading: false, + perpsTradingCampaignLeaderboardError: false, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: null, + perpsTradingCampaignVolumeLoading: false, + perpsTradingCampaignVolumeError: false, pendingDeeplink: null, dismissedCampaignOutcomeToasts: {}, }; @@ -2200,6 +2216,13 @@ describe('rewardsReducer', () => { versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, + perpsTradingCampaignLeaderboard: null, + perpsTradingCampaignLeaderboardLoading: false, + perpsTradingCampaignLeaderboardError: false, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: null, + perpsTradingCampaignVolumeLoading: false, + perpsTradingCampaignVolumeError: false, pendingDeeplink: null, dismissedCampaignOutcomeToasts: {}, }; @@ -5576,6 +5599,187 @@ describe('setOndoCampaignActivity', () => { }); }); +const mockPerpsLeaderboard: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-c-1', + computedAt: '2025-08-15T12:00:00.000Z', + entries: [], + totalParticipants: 42, +}; + +const mockPerpsPosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 2, + pnl: 100, + notionalVolume: 5000, + marginDeployed: 1000, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', +}; + +describe('setPerpsTradingCampaignLeaderboard', () => { + it('sets leaderboard data and clears error', () => { + const stateWithError: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboardError: true, + }; + + const state = rewardsReducer( + stateWithError, + setPerpsTradingCampaignLeaderboard(mockPerpsLeaderboard), + ); + + expect(state.perpsTradingCampaignLeaderboard).toEqual(mockPerpsLeaderboard); + expect(state.perpsTradingCampaignLeaderboardError).toBe(false); + }); + + it('sets leaderboard to null', () => { + const stateWithData: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboard: mockPerpsLeaderboard, + }; + + const state = rewardsReducer( + stateWithData, + setPerpsTradingCampaignLeaderboard(null), + ); + + expect(state.perpsTradingCampaignLeaderboard).toBeNull(); + }); +}); + +describe('setPerpsTradingCampaignLeaderboardLoading', () => { + it('sets loading to true when no leaderboard is cached', () => { + const state = rewardsReducer( + initialState, + setPerpsTradingCampaignLeaderboardLoading(true), + ); + + expect(state.perpsTradingCampaignLeaderboardLoading).toBe(true); + }); + + it('does not set loading to true when leaderboard is already present', () => { + const stateWithLeaderboard: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboard: mockPerpsLeaderboard, + perpsTradingCampaignLeaderboardLoading: false, + }; + + const state = rewardsReducer( + stateWithLeaderboard, + setPerpsTradingCampaignLeaderboardLoading(true), + ); + + expect(state.perpsTradingCampaignLeaderboardLoading).toBe(false); + }); + + it('clears loading to false', () => { + const stateWithLoading: RewardsState = { + ...initialState, + perpsTradingCampaignLeaderboardLoading: true, + }; + + const state = rewardsReducer( + stateWithLoading, + setPerpsTradingCampaignLeaderboardLoading(false), + ); + + expect(state.perpsTradingCampaignLeaderboardLoading).toBe(false); + }); +}); + +describe('setPerpsTradingCampaignLeaderboardError', () => { + it('sets and clears the error flag', () => { + const withError = rewardsReducer( + initialState, + setPerpsTradingCampaignLeaderboardError(true), + ); + expect(withError.perpsTradingCampaignLeaderboardError).toBe(true); + + const cleared = rewardsReducer( + withError, + setPerpsTradingCampaignLeaderboardError(false), + ); + expect(cleared.perpsTradingCampaignLeaderboardError).toBe(false); + }); +}); + +describe('setPerpsTradingCampaignLeaderboardPosition', () => { + it('stores a position for subscription + campaign and removes on null', () => { + let state = rewardsReducer( + initialState, + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId: 'sub-p', + campaignId: 'camp-p', + position: mockPerpsPosition, + }), + ); + + expect( + state.perpsTradingCampaignLeaderboardPositions['sub-p:camp-p'], + ).toEqual(mockPerpsPosition); + + state = rewardsReducer( + state, + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId: 'sub-p', + campaignId: 'camp-p', + position: null, + }), + ); + + expect( + state.perpsTradingCampaignLeaderboardPositions['sub-p:camp-p'], + ).toBeUndefined(); + }); +}); + +describe('perps trading campaign volume', () => { + const mockVolume: RewardsState['perpsTradingCampaignVolume'] = { + totalUsdVolume: '1000000', + }; + + it('setPerpsTradingCampaignVolume sets data and clears error', () => { + const stateWithError: RewardsState = { + ...initialState, + perpsTradingCampaignVolumeError: true, + }; + + const state = rewardsReducer( + stateWithError, + setPerpsTradingCampaignVolume(mockVolume), + ); + + expect(state.perpsTradingCampaignVolume).toEqual(mockVolume); + expect(state.perpsTradingCampaignVolumeError).toBe(false); + }); + + it('setPerpsTradingCampaignVolumeLoading(true) is skipped when volume is cached', () => { + const stateWithVolume: RewardsState = { + ...initialState, + perpsTradingCampaignVolume: mockVolume, + perpsTradingCampaignVolumeLoading: false, + }; + + const state = rewardsReducer( + stateWithVolume, + setPerpsTradingCampaignVolumeLoading(true), + ); + + expect(state.perpsTradingCampaignVolumeLoading).toBe(false); + }); + + it('setPerpsTradingCampaignVolumeError toggles the flag', () => { + const on = rewardsReducer( + initialState, + setPerpsTradingCampaignVolumeError(true), + ); + expect(on.perpsTradingCampaignVolumeError).toBe(true); + + const off = rewardsReducer(on, setPerpsTradingCampaignVolumeError(false)); + expect(off.perpsTradingCampaignVolumeError).toBe(false); + }); +}); + describe('ondoCampaignDeposits', () => { it('setOndoCampaignDeposits sets data and clears error', () => { const deposits = { totalUsdDeposited: '1250000.000000' }; diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index 0833317ace0..b733c5c10d8 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -17,6 +17,9 @@ import { OndoGmPortfolioDto, OndoGmActivityEntryDto, OndoGmCampaignDepositsDto, + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, + PerpsTradingCampaignVolumeDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { OnboardingStep } from './types'; import { AccountGroupId } from '@metamask/account-api'; @@ -165,6 +168,22 @@ export interface RewardsState { ondoCampaignDepositsLoading: boolean; ondoCampaignDepositsError: boolean; + // Perps Trading Campaign leaderboard + perpsTradingCampaignLeaderboard: PerpsTradingCampaignLeaderboardDto | null; + perpsTradingCampaignLeaderboardLoading: boolean; + perpsTradingCampaignLeaderboardError: boolean; + + // Perps Trading Campaign leaderboard position (user's own position) + perpsTradingCampaignLeaderboardPositions: Record< + string, + PerpsTradingCampaignLeaderboardPositionDto + >; + + // Perps Trading Campaign volume (public stats; UI derives prize-pool display from notional volume) + perpsTradingCampaignVolume: PerpsTradingCampaignVolumeDto | null; + perpsTradingCampaignVolumeLoading: boolean; + perpsTradingCampaignVolumeError: boolean; + // Pending deeplink navigation intent, stored in Redux so it survives the // UnmountOnBlur remount of RewardsHome when navigating from outside the tab. pendingDeeplink: PendingDeeplink | null; @@ -179,7 +198,7 @@ export interface RewardsState { */ export interface PendingDeeplink { page?: 'campaigns' | 'musd' | 'benefits'; - campaign?: 'ondo' | 'season1'; + campaign?: 'ondo' | 'season1' | 'perps-comp'; } export const initialState: RewardsState = { @@ -278,6 +297,15 @@ export const initialState: RewardsState = { ondoCampaignDepositsLoading: false, ondoCampaignDepositsError: false, + // Perps Trading Campaign initial state + perpsTradingCampaignLeaderboard: null, + perpsTradingCampaignLeaderboardLoading: false, + perpsTradingCampaignLeaderboardError: false, + perpsTradingCampaignLeaderboardPositions: {}, + perpsTradingCampaignVolume: null, + perpsTradingCampaignVolumeLoading: false, + perpsTradingCampaignVolumeError: false, + pendingDeeplink: null, dismissedCampaignOutcomeToasts: {}, @@ -700,6 +728,72 @@ const rewardsSlice = createSlice({ state.ondoCampaignDepositsError = action.payload; }, + // Perps Trading Campaign leaderboard reducers + setPerpsTradingCampaignLeaderboard: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignLeaderboard = action.payload; + state.perpsTradingCampaignLeaderboardError = false; + }, + setPerpsTradingCampaignLeaderboardLoading: ( + state, + action: PayloadAction, + ) => { + if (action.payload && state.perpsTradingCampaignLeaderboard) { + return; + } + state.perpsTradingCampaignLeaderboardLoading = action.payload; + }, + setPerpsTradingCampaignLeaderboardError: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignLeaderboardError = action.payload; + }, + + // Perps Trading Campaign leaderboard position reducers + setPerpsTradingCampaignLeaderboardPosition: ( + state, + action: PayloadAction<{ + subscriptionId: string; + campaignId: string; + position: PerpsTradingCampaignLeaderboardPositionDto | null; + }>, + ) => { + const key = `${action.payload.subscriptionId}:${action.payload.campaignId}`; + if (action.payload.position) { + state.perpsTradingCampaignLeaderboardPositions[key] = + action.payload.position; + } else { + delete state.perpsTradingCampaignLeaderboardPositions[key]; + } + }, + + // Perps Trading Campaign volume reducers + setPerpsTradingCampaignVolume: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignVolume = action.payload; + state.perpsTradingCampaignVolumeError = false; + }, + setPerpsTradingCampaignVolumeLoading: ( + state, + action: PayloadAction, + ) => { + if (action.payload && state.perpsTradingCampaignVolume) { + return; + } + state.perpsTradingCampaignVolumeLoading = action.payload; + }, + setPerpsTradingCampaignVolumeError: ( + state, + action: PayloadAction, + ) => { + state.perpsTradingCampaignVolumeError = action.payload; + }, + // Bulk link reducers bulkLinkStarted: ( state, @@ -909,6 +1003,14 @@ export const { setOndoCampaignDeposits, setOndoCampaignDepositsLoading, setOndoCampaignDepositsError, + // Perps Trading Campaign actions + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, + setPerpsTradingCampaignLeaderboardPosition, + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, // Bulk link actions bulkLinkStarted, bulkLinkAccountResult, diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index c5d7e2bd356..78fc6bc1d76 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -310,3 +310,34 @@ export const selectPendingDeeplink = (state: RootState) => export const selectDismissedCampaignOutcomeToasts = (state: RootState) => state.rewards.dismissedCampaignOutcomeToasts; + +// Perps Trading Campaign leaderboard selectors +export const selectPerpsTradingCampaignLeaderboard = (state: RootState) => + state.rewards.perpsTradingCampaignLeaderboard; + +export const selectPerpsTradingCampaignLeaderboardLoading = ( + state: RootState, +) => state.rewards.perpsTradingCampaignLeaderboardLoading; + +export const selectPerpsTradingCampaignLeaderboardError = (state: RootState) => + state.rewards.perpsTradingCampaignLeaderboardError; + +// Perps Trading Campaign leaderboard position selectors +export const selectPerpsTradingCampaignLeaderboardPositionById = + (subscriptionId: string | undefined, campaignId: string | undefined) => + (state: RootState) => + subscriptionId && campaignId + ? (state.rewards.perpsTradingCampaignLeaderboardPositions[ + `${subscriptionId}:${campaignId}` + ] ?? null) + : null; + +// Perps Trading Campaign prize pool selectors +export const selectPerpsTradingCampaignVolume = (state: RootState) => + state.rewards.perpsTradingCampaignVolume; + +export const selectPerpsTradingCampaignVolumeLoading = (state: RootState) => + state.rewards.perpsTradingCampaignVolumeLoading; + +export const selectPerpsTradingCampaignVolumeError = (state: RootState) => + state.rewards.perpsTradingCampaignVolumeError; diff --git a/app/util/social/socialServiceTelemetry.test.ts b/app/util/social/socialServiceTelemetry.test.ts new file mode 100644 index 00000000000..6baffe893c8 --- /dev/null +++ b/app/util/social/socialServiceTelemetry.test.ts @@ -0,0 +1,283 @@ +import { addBreadcrumb } from '@sentry/react-native'; +import { + extractHttpStatus, + categoriseSocialError, + buildSocialErrorExtras, + addSocialBreadcrumb, + type SocialEndpoint, +} from './socialServiceTelemetry'; + +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + +// --------------------------------------------------------------------------- +// Helpers to create errors matching the shapes SocialService throws +// --------------------------------------------------------------------------- + +function makeHttpError( + status: number, + message: string, +): Error & { httpStatus: number } { + const err = new Error(message) as Error & { httpStatus: number }; + err.httpStatus = status; + return err; +} + +// --------------------------------------------------------------------------- +// extractHttpStatus +// --------------------------------------------------------------------------- + +describe('extractHttpStatus', () => { + it('returns the status from an HttpError instance', () => { + expect(extractHttpStatus(makeHttpError(401, 'Unauthorized'))).toBe(401); + }); + + it('returns the status from a plain object with httpStatus', () => { + expect(extractHttpStatus({ httpStatus: 503 })).toBe(503); + }); + + it('returns undefined for a plain Error without httpStatus', () => { + expect(extractHttpStatus(new Error('plain error'))).toBeUndefined(); + }); + + it('returns undefined for null', () => { + expect(extractHttpStatus(null)).toBeUndefined(); + }); + + it('returns undefined for a string', () => { + expect(extractHttpStatus('some error string')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// categoriseSocialError +// --------------------------------------------------------------------------- + +describe('categoriseSocialError', () => { + it('returns http_error when the error has httpStatus', () => { + expect(categoriseSocialError(makeHttpError(401, 'Unauthorized'))).toBe( + 'http_error', + ); + }); + + it('returns schema_error for "invalid response" messages', () => { + expect( + categoriseSocialError( + new Error('SocialService: Leaderboard returned invalid response'), + ), + ).toBe('schema_error'); + }); + + it('returns auth_failure for auth/JWT/bearer/unauthorized messages', () => { + expect( + categoriseSocialError(new Error('getBearerToken: auth token expired')), + ).toBe('auth_failure'); + expect(categoriseSocialError(new Error('JWT verification failed'))).toBe( + 'auth_failure', + ); + expect(categoriseSocialError(new Error('unauthorized access'))).toBe( + 'auth_failure', + ); + expect(categoriseSocialError(new Error('Bearer token missing'))).toBe( + 'auth_failure', + ); + }); + + it('does not classify crypto-token errors as auth_failure', () => { + // Crypto wallet errors frequently reference tokens but are not auth issues. + expect( + categoriseSocialError(new Error('unknown token contract 0xabc')), + ).toBe('unknown'); + expect(categoriseSocialError(new Error('tokenAddress invalid'))).toBe( + 'unknown', + ); + expect( + categoriseSocialError(new Error('tokenSymbol could not be resolved')), + ).toBe('unknown'); + }); + + it('returns network_error for network/timeout/aborted messages', () => { + expect(categoriseSocialError(new Error('Network request failed'))).toBe( + 'network_error', + ); + expect(categoriseSocialError(new Error('Request timed out'))).toBe( + 'network_error', + ); + expect(categoriseSocialError(new Error('The operation was aborted'))).toBe( + 'network_error', + ); + expect( + categoriseSocialError(new Error('connect ETIMEDOUT 1.2.3.4:443')), + ).toBe('network_error'); + }); + + it('returns unknown for unrecognised errors', () => { + expect(categoriseSocialError(new Error('Something went wrong'))).toBe( + 'unknown', + ); + }); + + it('returns unknown for null / undefined', () => { + expect(categoriseSocialError(null)).toBe('unknown'); + expect(categoriseSocialError(undefined)).toBe('unknown'); + }); + + it('http_error takes precedence over message matching', () => { + // An HttpError message also contains "unauthorized" but httpStatus wins + const err = makeHttpError(401, 'unauthorized'); + expect(categoriseSocialError(err)).toBe('http_error'); + }); +}); + +// --------------------------------------------------------------------------- +// buildSocialErrorExtras +// --------------------------------------------------------------------------- + +describe('buildSocialErrorExtras', () => { + it('preserves legacyMessage verbatim under the message field', () => { + const legacy = 'useTopTraders: leaderboard fetch failed'; + const result = buildSocialErrorExtras({ + legacyMessage: legacy, + endpoint: 'leaderboard', + error: new Error('something'), + }); + expect(result.message).toBe(legacy); + }); + + it('includes endpoint, errorCategory, errorMessage', () => { + const error = new Error( + 'SocialService: Leaderboard returned invalid response', + ); + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'leaderboard', + error, + }); + expect(result.endpoint).toBe('leaderboard'); + expect(result.errorCategory).toBe('schema_error'); + expect(result.errorMessage).toBe(error.message); + }); + + it('includes httpStatus when the error is an HttpError', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'following', + error: makeHttpError(403, 'Forbidden'), + }); + expect(result.httpStatus).toBe(403); + expect(result.errorCategory).toBe('http_error'); + }); + + it('omits httpStatus when not an HttpError', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'leaderboard', + error: new Error('plain'), + }); + expect(result.httpStatus).toBeUndefined(); + }); + + it('includes durationMs and queryParams when provided', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'open_positions', + error: new Error('plain'), + durationMs: 250, + queryParams: { limit: 10 }, + }); + expect(result.durationMs).toBe(250); + expect(result.queryParams).toEqual({ limit: 10 }); + }); + + it('does NOT leak an addressOrId field even if accidentally passed', () => { + const result = buildSocialErrorExtras({ + legacyMessage: 'msg', + endpoint: 'open_positions', + error: new Error('plain'), + // Simulate a caller accidentally passing address-like data via queryParams + queryParams: { limit: 5 }, + }); + const serialised = JSON.stringify(result); + expect(serialised).not.toMatch(/0x[0-9a-fA-F]{40}/); + expect(Object.keys(result)).not.toContain('addressOrId'); + expect(Object.keys(result)).not.toContain('address'); + expect(Object.keys(result)).not.toContain('profileId'); + }); +}); + +// --------------------------------------------------------------------------- +// addSocialBreadcrumb +// --------------------------------------------------------------------------- + +describe('addSocialBreadcrumb', () => { + beforeEach(() => { + mockAddBreadcrumb.mockClear(); + }); + + it('emits a breadcrumb with category social_service and level error', () => { + addSocialBreadcrumb({ endpoint: 'leaderboard' }); + expect(mockAddBreadcrumb).toHaveBeenCalledTimes(1); + const call = mockAddBreadcrumb.mock.calls[0][0]; + expect(call.category).toBe('social_service'); + expect(call.level).toBe('error'); + }); + + it('formats the failure message with just the endpoint when no extras are given', () => { + addSocialBreadcrumb({ endpoint: 'leaderboard' }); + const { message } = mockAddBreadcrumb.mock.calls[0][0]; + expect(message).toBe('social_service.leaderboard.failure'); + }); + + it('appends status and category when provided', () => { + addSocialBreadcrumb({ + endpoint: 'following', + httpStatus: 401, + errorCategory: 'http_error', + }); + const { message } = mockAddBreadcrumb.mock.calls[0][0]; + expect(message).toBe( + 'social_service.following.failure status=401 category=http_error', + ); + }); + + it('includes structured data payload alongside message string', () => { + addSocialBreadcrumb({ + endpoint: 'open_positions', + httpStatus: 503, + errorCategory: 'http_error', + queryParams: { limit: 5 }, + }); + const { data } = mockAddBreadcrumb.mock.calls[0][0]; + expect(data.endpoint).toBe('open_positions'); + expect(data.httpStatus).toBe(503); + expect(data.errorCategory).toBe('http_error'); + expect(data.queryParams).toEqual({ limit: 5 }); + }); + + it('omits optional fields from the data payload when not provided', () => { + addSocialBreadcrumb({ endpoint: 'leaderboard' }); + const { data } = mockAddBreadcrumb.mock.calls[0][0]; + expect(data.httpStatus).toBeUndefined(); + expect(data.errorCategory).toBeUndefined(); + expect(data.queryParams).toBeUndefined(); + }); + + it('uses a consistent endpoint key across all supported endpoints', () => { + const endpoints: SocialEndpoint[] = [ + 'leaderboard', + 'following', + 'open_positions', + 'closed_positions', + 'position_by_id', + ]; + endpoints.forEach((endpoint) => { + mockAddBreadcrumb.mockClear(); + addSocialBreadcrumb({ endpoint }); + const { message } = mockAddBreadcrumb.mock.calls[0][0]; + expect(message).toBe(`social_service.${endpoint}.failure`); + }); + }); +}); diff --git a/app/util/social/socialServiceTelemetry.ts b/app/util/social/socialServiceTelemetry.ts new file mode 100644 index 00000000000..90dc13315c1 --- /dev/null +++ b/app/util/social/socialServiceTelemetry.ts @@ -0,0 +1,199 @@ +import { addBreadcrumb } from '@sentry/react-native'; + +/** + * The set of SocialService API endpoints we instrument. + * Matches the `queryKey` prefixes used in the three hooks. + */ +export type SocialEndpoint = + | 'leaderboard' + | 'following' + | 'open_positions' + | 'closed_positions' + | 'position_by_id'; + +/** + * Coarse-grained error category derived from the thrown error shape. + * Lets Sentry Discover filter errors without reading full messages. + * + * Categories map to distinct throw sites in SocialService: + * - http_error → SocialService.#throwIfNotOk (non-2xx HTTP response) + * - schema_error → superstruct `is()` check on response body + * - auth_failure → AuthenticationController:getBearerToken rejection + * - network_error → fetch() itself rejected (no response received) + * - unknown → anything else + */ +export type SocialErrorCategory = + | 'http_error' + | 'schema_error' + | 'auth_failure' + | 'network_error' + | 'unknown'; + +/** + * The shape returned by buildSocialErrorExtras, intended to replace + * the string second argument to Logger.error while preserving it. + */ +export interface SocialErrorExtras { + /** Original log message — preserved verbatim for backward-compatible Sentry searches. */ + message: string; + endpoint: SocialEndpoint; + errorCategory: SocialErrorCategory; + httpStatus?: number; + durationMs?: number; + queryParams?: Record; + errorMessage?: string; +} + +/** + * Extract the HTTP status from an error, if present. + * HttpError from @metamask/controller-utils exposes `.httpStatus`. + */ +export function extractHttpStatus(error: unknown): number | undefined { + if ( + error !== null && + typeof error === 'object' && + 'httpStatus' in error && + typeof (error as Record).httpStatus === 'number' + ) { + return (error as Record).httpStatus as number; + } + return undefined; +} + +/** + * Categorise a thrown error into a coarse bucket so it can be + * filtered in Sentry Discover without full-text searches. + */ +export function categoriseSocialError(error: unknown): SocialErrorCategory { + if (extractHttpStatus(error) !== undefined) { + return 'http_error'; + } + + const message = error instanceof Error ? error.message : String(error ?? ''); + + if (/invalid response/i.test(message)) { + return 'schema_error'; + } + + // Match auth-specific terms only. The bare word "token" is intentionally + // excluded because crypto wallet errors frequently mention tokens + // (e.g. "unknown token contract", "tokenAddress invalid") and would + // otherwise be misclassified as auth failures, polluting Sentry filters. + if (/auth|jwt|unauthor|bearer/i.test(message)) { + return 'auth_failure'; + } + + if (/network|timed?\s*out|timeout|aborted|connect/i.test(message)) { + return 'network_error'; + } + + return 'unknown'; +} + +/** + * Build the enriched extras object for an existing Logger.error call. + * + * The `legacyMessage` string is preserved verbatim under `message` so + * any existing Sentry searches on that string keep working. Structured + * fields are added alongside without replacing the existing event. + * + * Usage — replace the string second arg while keeping the call intact: + * ```ts + * // Before + * Logger.error(err, 'useTopTraders: leaderboard fetch failed'); + * + * // After + * Logger.error( + * err, + * buildSocialErrorExtras({ + * legacyMessage: 'useTopTraders: leaderboard fetch failed', + * endpoint: 'leaderboard', + * error: err, + * }), + * ); + * ``` + */ +export function buildSocialErrorExtras({ + legacyMessage, + endpoint, + error, + queryParams, + durationMs, +}: { + legacyMessage: string; + endpoint: SocialEndpoint; + error: unknown; + queryParams?: Record; + durationMs?: number; +}): SocialErrorExtras { + const errorCategory = categoriseSocialError(error); + const httpStatus = extractHttpStatus(error); + const errorMessage = + error instanceof Error ? error.message : String(error ?? ''); + + const extras: SocialErrorExtras = { + message: legacyMessage, + endpoint, + errorCategory, + errorMessage, + }; + + if (httpStatus !== undefined) { + extras.httpStatus = httpStatus; + } + if (durationMs !== undefined) { + extras.durationMs = durationMs; + } + if (queryParams !== undefined) { + extras.queryParams = queryParams; + } + + return extras; +} + +/** + * Add a Sentry breadcrumb when a SocialService fetch fails. + * + * The `message` string encodes discriminating fields inline so they are + * searchable in Sentry Discover via `breadcrumbs.message:"..."`: + * + * social_service.leaderboard.failure status=401 category=http_error + * + * Sentry Discover query examples: + * breadcrumbs.category:social_service + * breadcrumbs.message:"social_service.leaderboard" + * breadcrumbs.message:"status=401" + * breadcrumbs.message:"category=auth_failure" + */ +export function addSocialBreadcrumb({ + endpoint, + errorCategory, + httpStatus, + queryParams, +}: { + endpoint: SocialEndpoint; + errorCategory?: SocialErrorCategory; + httpStatus?: number; + queryParams?: Record; +}): void { + const parts: string[] = [`social_service.${endpoint}.failure`]; + + if (httpStatus !== undefined) { + parts.push(`status=${httpStatus}`); + } + if (errorCategory !== undefined) { + parts.push(`category=${errorCategory}`); + } + + addBreadcrumb({ + category: 'social_service', + level: 'error', + message: parts.join(' '), + data: { + endpoint, + ...(httpStatus !== undefined && { httpStatus }), + ...(errorCategory !== undefined && { errorCategory }), + ...(queryParams !== undefined && { queryParams }), + }, + }); +} diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 2a23440052f..b6c21b31582 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -729,6 +729,9 @@ "ondoCampaignActivity": {}, "ondoCampaignDeposits": {}, "ondoCampaignLeaderboard": {}, + "perpsTradingCampaignLeaderboard": {}, + "perpsTradingCampaignLeaderboardPositions": {}, + "perpsTradingCampaignVolume": {}, "campaignParticipantStatus": {}, "unlockedRewards": {}, "seasonStatuses": {}, diff --git a/locales/languages/en.json b/locales/languages/en.json index fd5fa26939b..62fc87108e4 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7107,7 +7107,8 @@ "custom_amount": { "buy_button": "Buy crypto", "buy_predict": "Add funds to your wallet to use Predictions.", - "buy_perps": "Add funds to your wallet to use Perps." + "buy_perps": "Add funds to your wallet to use Perps.", + "projected_five_year_balance": "Projected 5-year balance:" }, "unlimited": "Unlimited", "all": "All", @@ -8593,6 +8594,46 @@ "cancel": "Cancel", "confirm": "I understand" }, + "perps_trading_campaign": { + "title": "Perps Trading Competition", + "stats_title": "Stats", + "performance_title": "Performance", + "label_rank": "Rank", + "label_your_rank": "Your rank", + "label_pnl": "PnL", + "label_volume": "Volume", + "label_notional_volume": "Notional volume", + "label_margin": "Margin", + "label_margin_deployed": "Margin deployed", + "pending": "Pending", + "qualified": "Qualified", + "open_position_cta": "Open Position", + "leaderboard_title": "Leaderboard", + "leaderboard_total_participants": "{{count}} participants", + "last_updated": "Last updated: {{time}}", + "leaderboard_error_loading": "Failed to load leaderboard", + "leaderboard_error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "leaderboard_not_yet_computed": "Leaderboard hasn't been computed yet. Check back soon.", + "leaderboard_powered_by_prefix": "Powered by ", + "leaderboard_hypertracker_brand": "HyperTracker", + "prize_pool_title": "Prize pool", + "prize_pool_current_label": "Current", + "prize_pool_next_label": "Next", + "prize_pool_volume_subtext": "{{current}} of {{target}} volume", + "prize_pool_max_tier_subtext": "{{maxThreshold}}+ volume — all milestones reached", + "prize_pool_max_badge": "Max", + "prize_pool_error_title": "Failed to load prize pool", + "prize_pool_error_description": "There was an error loading the prize pool. Please try again.", + "prize_pool_retry_button": "Retry", + "stats_notional_volume_threshold": "${{amount}} required", + "stats_qualified_title": "You're qualified", + "stats_qualified_description": "Keep trading to improve your PnL and climb the leaderboard before the competition ends.", + "stats_qualify_for_rank_title": "Qualify for this rank", + "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", + "stats_error_title": "Unable to load stats", + "stats_error_description": "We had a problem loading your stats. Please try again.", + "stats_retry": "Retry" + }, "campaigns_preview": { "title": "Campaigns", "coming_soon": "Coming soon", diff --git a/package.json b/package.json index 8b22e7d6c60..5b02f70ebb9 100644 --- a/package.json +++ b/package.json @@ -175,7 +175,7 @@ "@unrs/resolver-binding-wasm32-wasi": "npm:npm-empty-package@1.0.0", "d3-color": "3.1.0", "napi-postinstall": "npm:npm-empty-package@1.0.0", - "axios": "^1.15.0", + "axios": "^1.15.1", "lodash": "4.18.1", "redux-persist-filesystem-storage/react-native-blob-util": "^0.19.9", "@ethersproject/providers/ws": "^7.5.10", @@ -231,7 +231,7 @@ "@metamask/account-tree-controller": "^7.1.0", "@metamask/accounts-controller": "^37.2.0", "@metamask/address-book-controller": "^7.1.0", - "@metamask/ai-controllers": "^0.6.0", + "@metamask/ai-controllers": "0.6.3", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", diff --git a/tests/api-mocking/mock-responses/musd/musd-mocks.ts b/tests/api-mocking/mock-responses/musd/musd-mocks.ts index 3e1e1172ca5..4764a34093a 100644 --- a/tests/api-mocking/mock-responses/musd/musd-mocks.ts +++ b/tests/api-mocking/mock-responses/musd/musd-mocks.ts @@ -147,8 +147,6 @@ export async function setupMusdMocks( earnMusdConvertibleTokensAllowlist: { '*': ['USDC'] }, earnMusdConversionMinAssetBalanceRequired: 0.01, earnMusdConversionGeoBlockedCountries: { blockedRegions: ['GB'] }, - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, }); await setupMockRequest(mockServer, { diff --git a/tests/page-objects/wallet/NetworkManager.ts b/tests/page-objects/wallet/NetworkManager.ts index d9a943128e2..f62e9c5bb2f 100644 --- a/tests/page-objects/wallet/NetworkManager.ts +++ b/tests/page-objects/wallet/NetworkManager.ts @@ -8,7 +8,10 @@ import { NetworkManagerSelectorText, } from '../../../app/components/UI/NetworkMultiSelector/NetworkManager.testIds'; import TestHelpers from '../../helpers'; -import { WalletViewSelectorsIDs } from '../../../app/components/Views/Wallet/WalletView.testIds'; +import { + WalletViewSelectorsIDs, + WalletViewSelectorsText, +} from '../../../app/components/Views/Wallet/WalletView.testIds'; class NetworkManager { /** @@ -192,6 +195,45 @@ class NetworkManager { await this.waitForNetworkManagerToLoad(); } + /** + * Navigate to the TokensFullView (via the homepage Tokens section header) + * so that the network filter control bar becomes accessible. + */ + async navigateToTokensFullView(): Promise { + const tokensSectionHeader = Matchers.getElementByText( + WalletViewSelectorsText.TOKENS_SECTION, + ); + await Gestures.waitAndTap(tokensSectionHeader, { + checkStability: true, + elemDescription: 'Tokens Section Header (navigate to full view)', + }); + } + + /** + * Navigate back from TokensFullView to the homepage. + */ + async navigateBackFromTokensFullView(): Promise { + const backButton = Matchers.getElementByID( + WalletViewSelectorsIDs.BACK_BUTTON, + ); + await Gestures.waitAndTap(backButton, { + elemDescription: 'Back button (return from TokensFullView)', + }); + } + + /** + * Open the network manager from the redesigned homepage. + * The TOKEN_NETWORK_FILTER control only exists in TokensFullView, + * so this navigates there first, then opens the network manager sheet. + */ + async openNetworkManagerFromHomepage(): Promise { + await this.navigateToTokensFullView(); + await Gestures.waitAndTap(this.openNetworkManagerButton, { + elemDescription: 'Open Network Manager Button (from TokensFullView)', + }); + await this.waitForNetworkManagerToLoad(); + } + /** * Check if the network manager is currently visible */ diff --git a/tests/page-objects/wallet/WalletView.ts b/tests/page-objects/wallet/WalletView.ts index 609abbbacf3..88698d2804f 100644 --- a/tests/page-objects/wallet/WalletView.ts +++ b/tests/page-objects/wallet/WalletView.ts @@ -3,6 +3,7 @@ import { WalletViewSelectorsText, } from '../../../app/components/Views/Wallet/WalletView.testIds'; import { EARN_TEST_IDS } from '../../../app/components/UI/Earn/constants/testIds'; +import { CashGetMusdEmptyStateSelectors } from '../../../app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.testIds'; import { SECONDARY_BALANCE_BUTTON_TEST_ID } from '../../../app/components/UI/AssetElement/index.constants'; import { PredictTabViewSelectorsIDs, @@ -339,6 +340,10 @@ class WalletView { ); } + get cashGetMusdContainer(): DetoxElement { + return Matchers.getElementByID(CashGetMusdEmptyStateSelectors.CONTAINER); + } + get getMusdButton(): DetoxElement { return Matchers.getElementByText('Get mUSD'); } diff --git a/tests/smoke/confirmations/send/send-btc-token.spec.ts b/tests/smoke/confirmations/send/send-btc-token.spec.ts index a54820eea77..388ce6182a3 100644 --- a/tests/smoke/confirmations/send/send-btc-token.spec.ts +++ b/tests/smoke/confirmations/send/send-btc-token.spec.ts @@ -5,8 +5,6 @@ import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; import { loginToApp } from '../../../flows/wallet.flow'; import Assertions from '../../../framework/Assertions'; -import { Mockttp } from 'mockttp'; -import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper'; const TOKEN = 'Bitcoin'; @@ -16,16 +14,6 @@ describe(SmokeConfirmations('Send Bitcoin'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock: async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock( - mockServer, - { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }, - 1000, - ); - }, }, async () => { await loginToApp(); diff --git a/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts b/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts index 693796babfc..427e723676a 100644 --- a/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts +++ b/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts @@ -6,6 +6,7 @@ import ConfirmationUITypes from '../../../../page-objects/Browser/Confirmations/ import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; import FooterActions from '../../../../page-objects/Browser/Confirmations/FooterActions'; import NetworkListModal from '../../../../page-objects/Network/NetworkListModal'; +import NetworkManager from '../../../../page-objects/wallet/NetworkManager'; import RowComponents from '../../../../page-objects/Browser/Confirmations/RowComponents'; import SwitchAccountModal from '../../../../page-objects/wallet/SwitchAccountModal'; import TabBarComponent from '../../../../page-objects/wallet/TabBarComponent'; @@ -46,9 +47,11 @@ const localNodeOptions = [ ]; async function changeNetworkFromNetworkListModal() { - await WalletView.tapTokenNetworkFilter(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.tapOnCustomTab(); await NetworkListModal.changeNetworkTo(LOCAL_CHAIN_NAME); + await NetworkManager.navigateBackFromTokensFullView(); } async function checkConfirmationPage() { @@ -95,10 +98,7 @@ describe(SmokeConfirmations('7702 - smart account'), () => { }); await setupRemoteFeatureFlagsMock( mockServer, - Object.assign({}, ...confirmationFeatureFlags, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }), + Object.assign({}, ...confirmationFeatureFlags), ); }; beforeAll(async () => { diff --git a/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts b/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts index 756528f1b21..73327d5eb7a 100644 --- a/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts +++ b/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts @@ -9,8 +9,8 @@ import ConfirmationFooterActions from '../../../page-objects/Browser/Confirmatio import ConfirmationUITypes from '../../../page-objects/Browser/Confirmations/ConfirmationUITypes'; import TestDApp from '../../../page-objects/Browser/TestDApp'; import NetworkListModal from '../../../page-objects/Network/NetworkListModal'; +import NetworkManager from '../../../page-objects/wallet/NetworkManager'; import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; -import WalletView from '../../../page-objects/wallet/WalletView'; import { SmokeConfirmations } from '../../../tags.js'; import Assertions from '../../../framework/Assertions'; import { loginToApp } from '../../../flows/wallet.flow'; @@ -21,24 +21,24 @@ import { confirmationFeatureFlags } from '../../../api-mocking/mock-responses/fe import { Mockttp } from 'mockttp'; import { LocalNode } from '../../../framework/types'; import { AnvilManager } from '../../../seeder/anvil-manager'; +import WalletView from '../../../page-objects/wallet/WalletView'; const LOCAL_CHAIN_ID = '0x539'; const LOCAL_CHAIN_NAME = 'Localhost'; async function changeNetworkFromNetworkListModal(networkName: string) { await TabBarComponent.tapWallet(); - await WalletView.tapTokenNetworkFilter(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.changeNetworkTo(networkName); + await NetworkManager.navigateBackFromTokensFullView(); } describe(SmokeConfirmations('Dapp Network Switching'), () => { const testSpecificMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock( mockServer, - Object.assign({}, ...confirmationFeatureFlags, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }), + Object.assign({}, ...confirmationFeatureFlags), ); }; @@ -137,11 +137,13 @@ describe(SmokeConfirmations('Dapp Network Switching'), () => { // Change the network to Localhost in app (custom network) await TabBarComponent.tapWallet(); - await WalletView.tapTokenNetworkFilter(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.tapOnCustomTab(); await NetworkListModal.selectNetworkInCustomTab(LOCAL_CHAIN_NAME); - // Check activity tab (already on wallet from helper, just navigate) + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await Assertions.expectTextDisplayed('Confirmed'); }, diff --git a/tests/smoke/networks/network-manager.spec.ts b/tests/smoke/networks/network-manager.spec.ts index 1013e787bc4..9e9c9793dfd 100644 --- a/tests/smoke/networks/network-manager.spec.ts +++ b/tests/smoke/networks/network-manager.spec.ts @@ -5,19 +5,8 @@ import { withFixtures } from '../../framework/fixtures/FixtureHelper'; import NetworkManager from '../../page-objects/wallet/NetworkManager'; import { NetworkToCaipChainId } from '../../../app/components/UI/NetworkMultiSelector/NetworkMultiSelector.constants'; import Assertions from '../../framework/Assertions'; -import { Mockttp } from 'mockttp'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; -import { remoteFeatureFlagHomepageSectionsV1Enabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; -import WalletView from '../../page-objects/wallet/WalletView'; -import TokensFullView from '../../page-objects/wallet/HomeSections'; describe(SmokeNetworkAbstractions('Network Manager'), () => { - const testSpecificMock = async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - ...remoteFeatureFlagHomepageSectionsV1Enabled(), - }); - }; - beforeAll(async () => { jest.setTimeout(170000); }); @@ -25,21 +14,13 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { it('should reflect the correct enabled networks state in the network manager', async () => { await withFixtures( { - fixture: new FixtureBuilder().build(), + fixture: new FixtureBuilder().withPopularNetworks().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); + await NetworkManager.openNetworkManagerFromHomepage(); await Assertions.expectElementToBeVisible( NetworkManager.popularNetworksContainer, @@ -47,10 +28,11 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { // Default fixture starts with Polygon as the active chain, so a single // network is selected rather than "all networks" await Assertions.expectElementToBeVisible( - NetworkManager.selectAllPopularNetworksNotSelected, + NetworkManager.selectAllPopularNetworksSelected, ); - // Verify individual networks reflect their selected/not-selected state + // Verify individual popular networks are in the "not selected" state + // (since "Select All" is selected, individual rows show as not-selected) const popularNetworks = [ NetworkToCaipChainId.ETHEREUM, NetworkToCaipChainId.LINEA, @@ -67,28 +49,16 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { it('should reflect the enabled networks state in the network manager, when all popular networks are selected', async () => { await withFixtures( { - fixture: new FixtureBuilder().build(), + fixture: new FixtureBuilder().withPopularNetworks().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); + await NetworkManager.openNetworkManagerFromHomepage(); + // verify popular networks container is visible + await NetworkManager.checkPopularNetworksContainerIsVisible(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); - - // Default fixture starts with Polygon selected — tap "Select All" to - // move into the all-networks-selected state. This dismisses the sheet. - await NetworkManager.tapSelectAllPopularNetworks(); - - // Re-open to verify the all-selected state persisted - await NetworkManager.openNetworkManager(); + // verify all popular networks are selected await NetworkManager.checkAllPopularNetworksIsSelected(); }, ); @@ -99,33 +69,25 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); + // Navigate to TokensFullView then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - // Default fixture starts with Polygon selected (known bug with - // homepageSectionsV1 flag). Select Ethereum and verify the control bar. - await NetworkManager.openNetworkManager(); + // Select Ethereum — sheet closes, lands back on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, ); - // Re-open and verify Ethereum is marked as selected + // Re-open network manager (already in TokensFullView) await NetworkManager.openNetworkManager(); await NetworkManager.checkNetworkIsSelected( NetworkToCaipChainId.ETHEREUM, ); - // Switch to Linea and verify the control bar updates + // Select Linea and check if Ethereum is deselected await NetworkManager.tapNetwork(NetworkToCaipChainId.LINEA); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.LINEA, @@ -145,32 +107,24 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); // Tap custom networks tab and check custom networks container is visible await NetworkManager.tapCustomNetworksTab(); await NetworkManager.checkCustomNetworksContainerIsVisible(); - // Tap localhost network and check base control bar text + // Tap localhost network — sheet closes, lands back on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.LOCALHOST); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.LOCALHOST, ); - // Re-open and verify network manager defaults back to custom tab - // since the last selected network was a custom network + // Re-open network manager (already in TokensFullView) await NetworkManager.openNetworkManager(); await NetworkManager.checkCustomNetworksContainerIsVisible(); }, @@ -182,19 +136,10 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { { fixture: new FixtureBuilder().build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - await NetworkManager.openNetworkManager(); + await NetworkManager.openNetworkManagerFromHomepage(); await NetworkManager.checkPopularNetworksContainerIsVisible(); }, ); diff --git a/tests/smoke/networks/network-manager2.spec.ts b/tests/smoke/networks/network-manager2.spec.ts index 7d53ece2e3b..767a4db7ac6 100644 --- a/tests/smoke/networks/network-manager2.spec.ts +++ b/tests/smoke/networks/network-manager2.spec.ts @@ -15,19 +15,9 @@ import ConnectBottomSheet from '../../page-objects/Browser/ConnectBottomSheet'; import { CustomNetworks } from '../../resources/networks.e2e'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; -import { remoteFeatureFlagHomepageSectionsV1Enabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; -import WalletView from '../../page-objects/wallet/WalletView'; -import TokensFullView from '../../page-objects/wallet/HomeSections'; const POLYGON = CustomNetworks.Tenderly.Polygon.providerConfig.nickname; -const testSpecificMock = async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - carouselBanners: false, - ...remoteFeatureFlagHomepageSectionsV1Enabled(), - }); -}; - describe(SmokeNetworkAbstractions('Network Manager'), () => { beforeAll(async () => { jest.setTimeout(170000); @@ -43,8 +33,6 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { const solanaTestMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { carouselBanners: false, - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, }); }; @@ -89,11 +77,13 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { async () => { await loginToApp(); - // Open network manager directly (old homepage flow, no TokensFullView) - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView, then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Popular'); - // Select Solana network + // Select Solana network — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.SOLANA); // Verify SOL is visible in the Solana-filtered view @@ -134,23 +124,17 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { ]) .build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - // Open network manager and verify initial state - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView, then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Popular'); - // Select Ethereum network + // Select Ethereum network — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, @@ -195,29 +179,34 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { name: 'LineaETH', }, ]) + .withTokens( + [ + { + address: '0x0000000000000000000000000000000000000000', + symbol: 'SepoliaETH', + decimals: 18, + name: 'SepoliaETH', + }, + ], + '0xaa36a7', // Sepolia chain ID + ) .build(), restartDevice: true, - testSpecificMock, }, async () => { await loginToApp(); - await Assertions.expectElementToBeVisible(WalletView.container, { - description: 'Wallet homepage should be visible', - }); - - await WalletView.tapOnNewTokensSection(); - await TokensFullView.waitForVisible(); - - // Open network manager and verify initial state - await NetworkManager.openNetworkManager(); + // Navigate to TokensFullView, then open network manager + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); // Switch to custom networks tab await NetworkManager.tapCustomNetworksTab(); await NetworkManager.checkCustomNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Custom'); - // Select a custom network (Linea Sepolia) + // Select a custom network (Linea Sepolia) — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM_SEPOLIA); await NetworkManager.checkBaseControlBarText( @@ -243,8 +232,6 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { const dappTestMock = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { carouselBanners: false, - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, }); }; @@ -269,17 +256,21 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { async () => { await loginToApp(); - // Step 1: Open the network manager from the wallet homepage directly - // (homepageSectionsV1 is disabled for this test — old tab bar is active) - await NetworkManager.openNetworkManager(); + // Step 1: Navigate to TokensFullView, then select Ethereum + await NetworkManager.openNetworkManagerFromHomepage(); + await NetworkManager.waitForNetworkManagerToLoad(); + await NetworkManager.checkPopularNetworksContainerIsVisible(); await NetworkManager.checkTabIsSelected('Popular'); - // Select Ethereum as the active network + // Select Ethereum as the active network — sheet closes, lands on TokensFullView await NetworkManager.tapNetwork(NetworkToCaipChainId.ETHEREUM); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, ); + // Go back to homepage before navigating to browser + await NetworkManager.navigateBackFromTokensFullView(); + // Step 2: Navigate to dapp and request network addition await navigateToBrowserView(); await Browser.navigateToTestDApp(); @@ -311,7 +302,8 @@ describe(SmokeNetworkAbstractions('Network Manager'), () => { await Browser.tapCloseBrowserButton(); await TabBarComponent.tapWallet(); - // Verify Ethereum is still the active network (preservation) + // Navigate to TokensFullView to verify Ethereum is still the active network + await NetworkManager.navigateToTokensFullView(); await NetworkManager.checkBaseControlBarText( NetworkToCaipChainId.ETHEREUM, ); diff --git a/tests/smoke/stake/stake-action-smoke.spec.ts b/tests/smoke/stake/stake-action-smoke.spec.ts index b0fe488ccf0..90dfd41c37d 100644 --- a/tests/smoke/stake/stake-action-smoke.spec.ts +++ b/tests/smoke/stake/stake-action-smoke.spec.ts @@ -9,6 +9,7 @@ import FixtureBuilder, { } from '../../framework/fixtures/FixtureBuilder'; import WalletView from '../../page-objects/wallet/WalletView'; import NetworkListModal from '../../page-objects/Network/NetworkListModal'; +import NetworkManager from '../../page-objects/wallet/NetworkManager'; import { SmokeStake } from '../../tags'; import Assertions from '../../framework/Assertions'; import StakeView from '../../page-objects/Stake/StakeView'; @@ -16,7 +17,6 @@ import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; import { AnvilManager } from '../../seeder/anvil-manager'; import { Mockttp } from 'mockttp'; import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; describe(SmokeStake('Stake from Actions'), (): void => { const FIRST_ROW: number = 0; @@ -61,11 +61,6 @@ describe(SmokeStake('Stake from Actions'), (): void => { ], restartDevice: true, testSpecificMock: async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }); - // Mock Accounts API V4 (flat array) so the app reports correct ETH balance. // Without this, the default mock returns 0 balance and the Earn button // is hidden (StakeButton returns null when balanceFiatNumber < 0.01). @@ -175,11 +170,12 @@ describe(SmokeStake('Stake from Actions'), (): void => { // Go back to Home tab await TabBarComponent.tapHome(); - // Open network picker and select Localhost - await WalletView.tapTokenNetworkFilter(); + // Navigate to TokensFullView and filter by Localhost + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); await NetworkListModal.changeNetworkTo('Localhost'); - // Verify staked asset in wallet + // Verify staked asset in wallet (now in TokensFullView) await Assertions.expectTextDisplayed('Staked Ethereum'); await Assertions.expectTextDisplayed('1 ETH'); await Assertions.expectTextDisplayed('$4,291.85'); diff --git a/tests/smoke/wallet/helpers/musd-fixture.ts b/tests/smoke/wallet/helpers/musd-fixture.ts index 6141e1c3605..a1cb9f112a7 100644 --- a/tests/smoke/wallet/helpers/musd-fixture.ts +++ b/tests/smoke/wallet/helpers/musd-fixture.ts @@ -64,6 +64,7 @@ export async function createMusdFixture( ]; return new FixtureBuilder() + .withPopularNetworks() .withNetworkController({ chainId: CHAIN_IDS.MAINNET, rpcUrl: `http://localhost:${rpcPort}`, @@ -71,7 +72,6 @@ export async function createMusdFixture( nickname: 'Ethereum Mainnet', ticker: 'ETH', }) - .withNetworkEnabledMap({ eip155: { [CHAIN_IDS.MAINNET]: true } }) .withMetaMetricsOptIn() .withTokensForAllPopularNetworks(baseTokens) .withTokenRates( diff --git a/tests/smoke/wallet/incoming-transactions.spec.ts b/tests/smoke/wallet/incoming-transactions.spec.ts index 6fc378af2b5..b5c46a49497 100644 --- a/tests/smoke/wallet/incoming-transactions.spec.ts +++ b/tests/smoke/wallet/incoming-transactions.spec.ts @@ -20,8 +20,8 @@ import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; import ToastModal from '../../page-objects/wallet/ToastModal'; import { MockApiEndpoint, TestSpecificMock } from '../../framework/types'; import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import UnifiedTransactionsView from '../../page-objects/Transactions/UnifiedTransactionsView'; +import NetworkManager from '../../page-objects/wallet/NetworkManager'; // EVM-only account tree to prevent Solana snap from fetching live transactions const EVM_ONLY_ACCOUNT_TREE = { @@ -125,10 +125,6 @@ function createAccountsTestSpecificMock( transactions: Record[] = [], ): TestSpecificMock { return async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - homepageRedesignV1: { enabled: false, minimumVersion: '0.0.0' }, - homepageSectionsV1: { enabled: false, minimumVersion: '0.0.0' }, - }); const mock = mockAccountsApi(transactions); await setupMockRequest(mockServer, { requestMethod: 'GET', @@ -192,6 +188,11 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { }, async () => { await loginToApp(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); + await NetworkManager.tapSelectAllPopularNetworks(); + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await UnifiedTransactionsView.swipeDown(); await Assertions.expectTextDisplayed('Received ETH'); @@ -249,6 +250,11 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { }, async () => { await loginToApp(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); + await NetworkManager.tapSelectAllPopularNetworks(); + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await UnifiedTransactionsView.swipeDown(); await Assertions.expectTextDisplayed('Sent ETH'); @@ -274,6 +280,11 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { }, async () => { await loginToApp(); + await NetworkManager.navigateToTokensFullView(); + await NetworkManager.openNetworkManager(); + await NetworkManager.tapSelectAllPopularNetworks(); + await NetworkManager.navigateBackFromTokensFullView(); + await TabBarComponent.tapActivity(); await UnifiedTransactionsView.swipeDown(); await Assertions.expectTextNotDisplayed('Received ETH'); diff --git a/tests/smoke/wallet/musd-conversion-happy-path.spec.ts b/tests/smoke/wallet/musd-conversion-happy-path.spec.ts index 8be43612e22..2c601717318 100644 --- a/tests/smoke/wallet/musd-conversion-happy-path.spec.ts +++ b/tests/smoke/wallet/musd-conversion-happy-path.spec.ts @@ -76,10 +76,10 @@ describe(SmokeWalletPlatform('mUSD Conversion Happy Path'), () => { // Verify mUSD CTA is visible and tap Get mUSD await Assertions.expectElementToBeVisible( - WalletView.musdConversionCta, + WalletView.cashGetMusdContainer, { timeout: 30000, - description: 'mUSD conversion CTA should be visible', + description: 'Cash section Get mUSD container should be visible', }, ); await WalletView.tapGetMusdButton(); diff --git a/yarn.lock b/yarn.lock index 2b86a672343..c0431f508c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7819,15 +7819,15 @@ __metadata: languageName: node linkType: hard -"@metamask/ai-controllers@npm:^0.6.0": - version: 0.6.0 - resolution: "@metamask/ai-controllers@npm:0.6.0" +"@metamask/ai-controllers@npm:0.6.3": + version: 0.6.3 + resolution: "@metamask/ai-controllers@npm:0.6.3" dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" + "@metamask/base-controller": "npm:^9.0.1" + "@metamask/messenger": "npm:^1.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/80d5cc15d28afeea893bf0b58eea42af85a10574e7d8622e0aa0f28db4e7f9a21524b7bf5d7c2aabc482e19952f3f24e492b096b605e1f3aecf25118bbed4687 + checksum: 10/678f024889a2fe691633df7b3cce529c8a87c0533096df9b238308736ce232f4a71f1a2f9a74cff16ef6cd635b551141517e8c53dedfc0a38a45d2771850b6b6 languageName: node linkType: hard @@ -22725,14 +22725,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.15.0": - version: 1.15.0 - resolution: "axios@npm:1.15.0" +"axios@npm:^1.15.1": + version: 1.15.2 + resolution: "axios@npm:1.15.2" dependencies: follow-redirects: "npm:^1.15.11" form-data: "npm:^4.0.5" proxy-from-env: "npm:^2.1.0" - checksum: 10/d39a2c0ebc7ff4739401b282e726cc2673377949d6c46d60eb619458f8d7a2f7eadbcada7097f4dbc7d5c59abb4d3bf6fac33d474412bc3415d3f5aa7ed45530 + checksum: 10/eebbd8cb777316d4252cd994a06ec9fb956ef519214a62dab6c5443ae8b753b5116e9a770502316789e6cdef1101e6aae53b6936d6a3791b2d66d75f4d7d2462 languageName: node linkType: hard @@ -35696,7 +35696,7 @@ __metadata: "@metamask/account-tree-controller": "npm:^7.1.0" "@metamask/accounts-controller": "npm:^37.2.0" "@metamask/address-book-controller": "npm:^7.1.0" - "@metamask/ai-controllers": "npm:^0.6.0" + "@metamask/ai-controllers": "npm:0.6.3" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^9.0.0"