Skip to content

Commit c15e817

Browse files
chore(runway): cherry-pick fix(predict): cp-7.62.0 display user positions in sport market cards (#24895)
- fix(predict): cp-7.62.0 display user positions in sport market cards (#24853) ## **Description** This PR integrates user positions into `PredictMarketSportCard` with conditional rendering of action buttons based on position state. **Problem**: Sport market cards always showed bet buttons regardless of whether the user had open positions. **Solution**: Created a new `PredictSportCardFooter` component that: - Fetches positions via `usePredictPositions` - Shows skeleton loader while positions are loading (prevents flash of bet buttons → picks) - Conditionally renders UI based on market status and positions: | Market Status | Positions | Rendered UI | |---------------|-----------|-------------| | `open` | None | Bet buttons | | `open` | Has open positions | Positions only (no buttons) | | `closed` | Any | Positions only (if any) | | `resolved` | Has claimable positions | Positions + Claim button | ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-492 ## **Manual testing steps** ```gherkin Feature: Sport Market Card Position Display Scenario: user sees bet buttons when no positions exist Given user has no positions on a sport market When user views the sport market card Then user sees Yes/No bet buttons Scenario: user sees positions when they have open positions Given user has open positions on a sport market When user views the sport market card Then user sees their positions displayed And user does not see bet buttons Scenario: user sees claim button when positions are claimable Given user has claimable positions on a resolved sport market When user views the sport market card Then user sees their positions displayed And user sees a Claim button with the claimable amount Scenario: user sees skeleton while positions load Given user opens the predict feed When sport market cards are loading Then user sees skeleton placeholders in card footers And user does not see a flash of bet buttons ``` ## **Screenshots/Recordings** ### **Before** <!-- Cards always showed bet buttons, then flashed to positions when loaded --> ### **After** <!-- Cards show skeleton → then positions (or buttons if no positions) --> <img width="382" height="805" alt="Screenshot 2026-01-16 at 10 17 34 PM" src="https://github.com/user-attachments/assets/4732657b-ccf5-4272-ae75-53dd9822d330" /> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Integrates position-aware footer into sport market cards and refines picks rendering to avoid UI flash and enable claims. > > - Introduces `PredictSportCardFooter` used by `PredictMarketSportCard` to: fetch positions (`usePredictPositions`), show a skeleton while loading, and render `PredictPicksForCard`, bet buttons, or a claim button based on market/position state; resolves `entryPoint` (overrides with trending when applicable) > - Removes inline action handling from `PredictMarketSportCard`; delegates to `PredictSportCardFooter` > - Enhances `PredictPicksForCard` to accept optional `positions`, `showSeparator`, disable internal fetching when positions are provided, return `null` when no positions, and display `currentValue` instead of `amount` > - Adds comprehensive tests for footer behavior, navigation/guarded actions, claim flow, and updated picks behavior > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c2516ae. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [2a9d9ea](2a9d9ea) Co-authored-by: Luis Taniça <matallui@gmail.com>
1 parent 16dafe9 commit c15e817

7 files changed

Lines changed: 1235 additions & 150 deletions

File tree

app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx

Lines changed: 85 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { PredictEventValues } from '../../constants/eventNames';
77
import PredictMarketSportCard from './';
88
import Routes from '../../../../../constants/navigation/Routes';
99

10-
// Mock navigation
1110
const mockNavigate = jest.fn();
1211
jest.mock('@react-navigation/native', () => ({
1312
...jest.requireActual('@react-navigation/native'),
@@ -16,7 +15,6 @@ jest.mock('@react-navigation/native', () => ({
1615
}),
1716
}));
1817

19-
// Mock TrendingFeedSessionManager
2018
const mockIsFromTrending = jest.fn();
2119
jest.mock('../../../Trending/services/TrendingFeedSessionManager', () => ({
2220
__esModule: true,
@@ -29,38 +27,6 @@ jest.mock('../../../Trending/services/TrendingFeedSessionManager', () => ({
2927
},
3028
}));
3129

32-
// Mock formatGameStartTime for consistent test results across locales
33-
jest.mock('../../utils/format', () => ({
34-
...jest.requireActual('../../utils/format'),
35-
formatGameStartTime: jest.fn((startTime: string | undefined) => {
36-
if (!startTime) {
37-
return { date: 'TBD', time: '' };
38-
}
39-
// Return predictable values for tests
40-
return { date: 'Sun, Feb 8', time: '3:30 PM' };
41-
}),
42-
}));
43-
44-
// Mock usePredictActionGuard
45-
const mockExecuteGuardedAction = jest.fn((action: () => void) => action());
46-
jest.mock('../../hooks/usePredictActionGuard', () => ({
47-
usePredictActionGuard: () => ({
48-
executeGuardedAction: mockExecuteGuardedAction,
49-
isEligible: true,
50-
hasNoBalance: false,
51-
}),
52-
}));
53-
54-
// Mock useLiveMarketPrices
55-
jest.mock('../../hooks/useLiveMarketPrices', () => ({
56-
useLiveMarketPrices: () => ({
57-
prices: new Map(),
58-
getPrice: jest.fn(),
59-
isConnected: false,
60-
lastUpdateTime: null,
61-
}),
62-
}));
63-
6430
jest.mock('../PredictSportScoreboard/PredictSportScoreboard', () => {
6531
const { View, Text } = jest.requireActual('react-native');
6632
return {
@@ -87,6 +53,28 @@ jest.mock('../PredictSportScoreboard/PredictSportScoreboard', () => {
8753
};
8854
});
8955

56+
jest.mock('../PredictSportCardFooter', () => {
57+
const { View, Text } = jest.requireActual('react-native');
58+
return {
59+
PredictSportCardFooter: function MockPredictSportCardFooter({
60+
market,
61+
entryPoint,
62+
testID,
63+
}: {
64+
market: { id: string };
65+
entryPoint?: string;
66+
testID?: string;
67+
}) {
68+
return (
69+
<View testID={testID ?? 'mock-footer'}>
70+
<Text testID="mock-footer-market-id">{market.id}</Text>
71+
<Text testID="mock-footer-entry-point">{entryPoint}</Text>
72+
</View>
73+
);
74+
},
75+
};
76+
});
77+
9078
const mockMarket: PredictMarketType = {
9179
id: 'test-market-sport-1',
9280
providerId: 'test-provider',
@@ -154,9 +142,6 @@ describe('PredictMarketSportCard', () => {
154142
beforeEach(() => {
155143
jest.clearAllMocks();
156144
mockIsFromTrending.mockReturnValue(false);
157-
mockExecuteGuardedAction.mockImplementation((action: () => void) =>
158-
action(),
159-
);
160145
});
161146

162147
afterEach(() => {
@@ -176,61 +161,6 @@ describe('PredictMarketSportCard', () => {
176161
expect(getByText('3:30 PM')).toBeOnTheScreen();
177162
});
178163

179-
it('renders team buttons with prices', () => {
180-
const { getByText } = renderWithProvider(
181-
<PredictMarketSportCard market={mockMarket} />,
182-
{ state: initialState },
183-
);
184-
185-
expect(getByText('SEA · 77¢')).toBeOnTheScreen();
186-
expect(getByText('DEN · 23¢')).toBeOnTheScreen();
187-
});
188-
189-
it('calls executeGuardedAction and navigates to buy preview when team button is pressed', () => {
190-
const { getByText } = renderWithProvider(
191-
<PredictMarketSportCard market={mockMarket} />,
192-
{ state: initialState },
193-
);
194-
195-
fireEvent.press(getByText('SEA · 77¢'));
196-
197-
expect(mockExecuteGuardedAction).toHaveBeenCalledWith(
198-
expect.any(Function),
199-
{
200-
checkBalance: true,
201-
attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
202-
},
203-
);
204-
expect(mockNavigate).toHaveBeenCalledWith(
205-
Routes.PREDICT.MODALS.BUY_PREVIEW,
206-
{
207-
market: mockMarket,
208-
outcome: mockMarket.outcomes[0],
209-
outcomeToken: expect.objectContaining({
210-
id: 'token-away',
211-
title: 'SEA',
212-
}),
213-
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
214-
},
215-
);
216-
});
217-
218-
it('hides action buttons when game has ended', () => {
219-
const endedMarket: PredictMarketType = {
220-
...mockMarket,
221-
game: mockMarket.game
222-
? { ...mockMarket.game, status: 'ended' }
223-
: undefined,
224-
};
225-
226-
const { queryByTestId } = renderWithProvider(
227-
<PredictMarketSportCard market={endedMarket} testID="sport-card" />,
228-
{ state: initialState },
229-
);
230-
231-
expect(queryByTestId('sport-card-action-buttons')).toBeNull();
232-
});
233-
234164
it('navigates to market details when pressed', () => {
235165
const { getByTestId } = renderWithProvider(
236166
<PredictMarketSportCard market={mockMarket} testID="sport-market-card" />,
@@ -431,4 +361,67 @@ describe('PredictMarketSportCard', () => {
431361
// Should render without crashing
432362
expect(getByTestId('sport-market-card')).toBeOnTheScreen();
433363
});
364+
365+
describe('footer integration', () => {
366+
it('renders PredictSportCardFooter with correct market', () => {
367+
const { getByTestId } = renderWithProvider(
368+
<PredictMarketSportCard market={mockMarket} testID="sport-card" />,
369+
{ state: initialState },
370+
);
371+
372+
expect(getByTestId('mock-footer-market-id').props.children).toBe(
373+
'test-market-sport-1',
374+
);
375+
});
376+
377+
it('renders PredictSportCardFooter with correct testID', () => {
378+
const { getByTestId } = renderWithProvider(
379+
<PredictMarketSportCard market={mockMarket} testID="sport-card" />,
380+
{ state: initialState },
381+
);
382+
383+
expect(getByTestId('sport-card-footer')).toBeOnTheScreen();
384+
});
385+
386+
it('renders PredictSportCardFooter with default testID when no testID provided', () => {
387+
const { getByTestId } = renderWithProvider(
388+
<PredictMarketSportCard market={mockMarket} />,
389+
{ state: initialState },
390+
);
391+
392+
expect(getByTestId('mock-footer')).toBeOnTheScreen();
393+
});
394+
395+
it('passes resolved entry point to footer', () => {
396+
const { getByTestId } = renderWithProvider(
397+
<PredictMarketSportCard
398+
market={mockMarket}
399+
testID="sport-card"
400+
entryPoint={PredictEventValues.ENTRY_POINT.HOMEPAGE_POSITIONS}
401+
/>,
402+
{ state: initialState },
403+
);
404+
405+
expect(getByTestId('mock-footer-entry-point').props.children).toBe(
406+
PredictEventValues.ENTRY_POINT.HOMEPAGE_POSITIONS,
407+
);
408+
});
409+
410+
it('passes trending entry point to footer when from trending', () => {
411+
mockIsFromTrending.mockReturnValue(true);
412+
413+
const { getByTestId } = renderWithProvider(
414+
<PredictMarketSportCard
415+
market={mockMarket}
416+
testID="sport-card"
417+
entryPoint={PredictEventValues.ENTRY_POINT.PREDICT_FEED}
418+
/>,
419+
{ state: initialState },
420+
);
421+
422+
expect(getByTestId('mock-footer-entry-point').props.children).toBe(
423+
PredictEventValues.ENTRY_POINT.TRENDING,
424+
);
425+
});
426+
});
434427
});

app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,9 @@ import {
66
} from '@metamask/design-system-react-native';
77
import { useTailwind } from '@metamask/design-system-twrnc-preset';
88
import { NavigationProp, useNavigation } from '@react-navigation/native';
9-
import React, { useCallback } from 'react';
9+
import React from 'react';
1010
import { TouchableOpacity } from 'react-native';
11-
import {
12-
PredictMarket as PredictMarketType,
13-
PredictOutcomeToken,
14-
} from '../../types';
11+
import { PredictMarket as PredictMarketType } from '../../types';
1512
import {
1613
PredictNavigationParamList,
1714
PredictEntryPoint,
@@ -21,8 +18,7 @@ import Routes from '../../../../../constants/navigation/Routes';
2118
import TrendingFeedSessionManager from '../../../Trending/services/TrendingFeedSessionManager';
2219
import PredictSportTeamGradient from '../PredictSportTeamGradient/PredictSportTeamGradient';
2320
import PredictSportScoreboard from '../PredictSportScoreboard/PredictSportScoreboard';
24-
import { PredictActionButtons } from '../PredictActionButtons';
25-
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
21+
import { PredictSportCardFooter } from '../PredictSportCardFooter';
2622

2723
interface PredictMarketSportCardProps {
2824
market: PredictMarketType;
@@ -44,34 +40,7 @@ const PredictMarketSportCard: React.FC<PredictMarketSportCardProps> = ({
4440
useNavigation<NavigationProp<PredictNavigationParamList>>();
4541
const tw = useTailwind();
4642

47-
const { executeGuardedAction } = usePredictActionGuard({
48-
providerId: market.providerId,
49-
navigation,
50-
});
51-
52-
const outcome = market.outcomes?.[0];
5343
const game = market.game;
54-
const isEnded = game?.status === 'ended';
55-
56-
const handleBetPress = useCallback(
57-
(token: PredictOutcomeToken) => {
58-
executeGuardedAction(
59-
() => {
60-
navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, {
61-
market,
62-
outcome,
63-
outcomeToken: token,
64-
entryPoint: resolvedEntryPoint,
65-
});
66-
},
67-
{
68-
checkBalance: true,
69-
attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
70-
},
71-
);
72-
},
73-
[executeGuardedAction, navigation, market, outcome, resolvedEntryPoint],
74-
);
7544

7645
return (
7746
<TouchableOpacity
@@ -110,14 +79,11 @@ const PredictMarketSportCard: React.FC<PredictMarketSportCardProps> = ({
11079
/>
11180
)}
11281

113-
{outcome && !isEnded && (
114-
<PredictActionButtons
115-
market={market}
116-
outcome={outcome}
117-
onBetPress={handleBetPress}
118-
testID={testID ? `${testID}-action-buttons` : undefined}
119-
/>
120-
)}
82+
<PredictSportCardFooter
83+
market={market}
84+
entryPoint={resolvedEntryPoint}
85+
testID={testID ? `${testID}-footer` : undefined}
86+
/>
12187
</Box>
12288
</PredictSportTeamGradient>
12389
</TouchableOpacity>

0 commit comments

Comments
 (0)