Skip to content

Commit af6ba34

Browse files
authored
feat: allow add account predict flow (#22856)
## **Description** Switches the reward point calculation for predict from a hard coded approach to using the rewards API. It also uses the same approach recently introduced in the swaps flow, to allow add their account if it's not correctly tied to their rewards subscription. ## **Changelog** CHANGELOG entry: Estimate reward points in predict flow ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/RWDS-801 ## **Screenshots/Recordings** ### **After** <img width="732" height="1041" alt="Screenshot-88" src="https://github.com/user-attachments/assets/9e4e27c6-2fab-4382-8d0f-69d861f4780b" /> --- <img width="701" height="823" alt="Screenshot-2025-11-18-10:30:28" src="https://github.com/user-attachments/assets/b8fd489e-610a-422c-8d23-67ae24daa4d3" /> --- <img width="500" height="600" alt="Screenshot-2025-11-18-10:31:15" src="https://github.com/user-attachments/assets/64bbfc9e-4bd2-48e7-9574-479f6299a2f5" /> --- <img width="774" height="307" alt="Screenshot-2025-11-18-10:32:21" src="https://github.com/user-attachments/assets/996f3b03-dfc2-4897-8c65-2d81b8e584a2" /> ## **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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > Switch Predict to API-based points estimation with opt-in handling, update UI to show points/add-account, and extend rewards types/tests. > > - **Predict flow**: > - Update `PredictBuyPreview` to use `usePredictRewards(totalFee)` and drive rewards UI; compute `shouldShowRewardsRow` and pass `accountOptedIn`, `estimatedPoints`, loading/error to `PredictFeeSummary`. > - `PredictFeeSummary` props renamed `shouldShowRewards` → `shouldShowRewardsRow`; add `accountOptedIn`, error tooltip; render `AddRewardsAccount` when not opted in; wire `RewardPointsAnimation` states. > - **Hook: `usePredictRewards`**: > - New signature accepts `totalFeeAmountUsd`; selects multichain account by `POLYGON_MAINNET_CAIP_CHAIN_ID` and formats CAIP-10 without bridge-controller. > - Checks feature flag and subscription, opt-in status/support; estimates points via `RewardsController:estimatePoints` using `POLYGON_USDC_CAIP_ASSET_ID` and `parseUnits` with 6 decimals. > - Subscribes to `RewardsController:accountLinked`; returns `{enabled,isLoading,accountOptedIn,shouldShowRewardsRow,estimatedPoints,hasError}`. > - **Rewards UI**: > - `RewardPointsAnimation`: make `infoOnPress` optional; hide info icon unless provided; support `hideValue` from hook. > - **Types/Constants**: > - Extend rewards types with `EstimatePredictContextDto`, add `PREDICT` to `PointsEventEarnType`. > - Add `POLYGON_USDC_CAIP_ASSET_ID` constant. > - **Tests**: > - Comprehensive updates for new props/flows, loading/error states, subscription/opt-in paths, and animation behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dadabd0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f01fb75 commit af6ba34

File tree

10 files changed

+845
-200
lines changed

10 files changed

+845
-200
lines changed

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

Lines changed: 180 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ jest.mock('../../utils/format', () => ({
1010
),
1111
}));
1212

13+
// Mock i18n strings
14+
jest.mock('../../../../../../locales/i18n', () => ({
15+
strings: jest.fn((key: string) => {
16+
const mockStrings: Record<string, string> = {
17+
'predict.fee_summary.fees': 'Fees',
18+
'predict.fee_summary.total': 'Total',
19+
'predict.fee_summary.estimated_points': 'Est. points',
20+
'predict.fee_summary.points_tooltip': 'Points',
21+
'predict.fee_summary.points_tooltip_content_1':
22+
'Points are how you earn MetaMask Rewards for completing transactions, like when you swap, bridge, or predict.',
23+
'predict.fee_summary.points_tooltip_content_2':
24+
'Keep in mind this value is an estimate and will be finalized once the transaction is complete. Points can take up to 1 hour to be confirmed in your Rewards balance.',
25+
'predict.fee_summary.points_error': "We can't load points right now",
26+
'predict.fee_summary.points_error_content':
27+
"You'll still earn any points for this transaction. We'll notify you once they've been added to your account. You can also check your rewards tab in about an hour.",
28+
};
29+
return mockStrings[key] || key;
30+
}),
31+
}));
32+
1333
// Mock ButtonIcon
1434
jest.mock(
1535
'../../../../../component-library/components/Buttons/ButtonIcon',
@@ -52,14 +72,16 @@ jest.mock(
5272
field,
5373
value,
5474
}: {
55-
field: { label: { text: string } };
56-
value: { label: React.ReactNode };
75+
field: { label: { text: string }; tooltip?: unknown };
76+
value: { label: React.ReactNode; tooltip?: unknown };
5777
}) =>
5878
React.createElement(
5979
View,
6080
{ testID: 'key-value-row' },
6181
React.createElement(RNText, null, field.label.text),
6282
value.label,
83+
value.tooltip &&
84+
React.createElement(View, { testID: 'value-tooltip' }, 'Tooltip'),
6385
);
6486
},
6587
);
@@ -70,10 +92,10 @@ jest.mock('../../../Rewards/components/RewardPointsAnimation', () => {
7092
const { Text: RNText } = jest.requireActual('react-native');
7193
return {
7294
__esModule: true,
73-
default: ({ value }: { value: number }) =>
95+
default: ({ value, state }: { value: number; state: string }) =>
7496
React.createElement(
7597
RNText,
76-
{ testID: 'rewards-animation' },
98+
{ testID: 'rewards-animation', 'data-state': state },
7799
`${value} points`,
78100
),
79101
RewardAnimationState: {
@@ -84,6 +106,24 @@ jest.mock('../../../Rewards/components/RewardPointsAnimation', () => {
84106
};
85107
});
86108

109+
// Mock AddRewardsAccount
110+
jest.mock(
111+
'../../../Rewards/components/AddRewardsAccount/AddRewardsAccount',
112+
() => {
113+
const React = jest.requireActual('react');
114+
const { View } = jest.requireActual('react-native');
115+
return {
116+
__esModule: true,
117+
default: () =>
118+
React.createElement(
119+
View,
120+
{ testID: 'add-rewards-account' },
121+
'Add Rewards Account',
122+
),
123+
};
124+
},
125+
);
126+
87127
describe('PredictFeeSummary', () => {
88128
const defaultProps = {
89129
disabled: false,
@@ -173,10 +213,10 @@ describe('PredictFeeSummary', () => {
173213
});
174214

175215
describe('Rewards Row', () => {
176-
it('does not display rewards row when shouldShowRewards is false', () => {
216+
it('does not display rewards row when shouldShowRewardsRow is false', () => {
177217
const props = {
178218
...defaultProps,
179-
shouldShowRewards: false,
219+
shouldShowRewardsRow: false,
180220
estimatedPoints: 100,
181221
};
182222

@@ -186,12 +226,14 @@ describe('PredictFeeSummary', () => {
186226

187227
expect(queryByText('Est. points')).toBeNull();
188228
expect(queryByTestId('rewards-animation')).toBeNull();
229+
expect(queryByTestId('add-rewards-account')).toBeNull();
189230
});
190231

191-
it('displays rewards row when shouldShowRewards is true', () => {
232+
it('displays rewards row when shouldShowRewardsRow is true', () => {
192233
const props = {
193234
...defaultProps,
194-
shouldShowRewards: true,
235+
shouldShowRewardsRow: true,
236+
accountOptedIn: true,
195237
estimatedPoints: 50,
196238
};
197239

@@ -203,10 +245,11 @@ describe('PredictFeeSummary', () => {
203245
expect(getByTestId('rewards-animation')).toBeOnTheScreen();
204246
});
205247

206-
it('displays correct estimated points value', () => {
248+
it('displays correct estimated points value when account is opted in', () => {
207249
const props = {
208250
...defaultProps,
209-
shouldShowRewards: true,
251+
shouldShowRewardsRow: true,
252+
accountOptedIn: true,
210253
estimatedPoints: 123,
211254
};
212255

@@ -215,24 +258,148 @@ describe('PredictFeeSummary', () => {
215258
expect(getByText('123 points')).toBeOnTheScreen();
216259
});
217260

218-
it('displays zero points when estimatedPoints is 0', () => {
261+
it('displays zero points when estimatedPoints is 0 and account is opted in', () => {
219262
const props = {
220263
...defaultProps,
221-
shouldShowRewards: true,
264+
shouldShowRewardsRow: true,
265+
accountOptedIn: true,
222266
estimatedPoints: 0,
223267
};
224268

225269
const { getByText } = render(<PredictFeeSummary {...props} />);
226270

227271
expect(getByText('0 points')).toBeOnTheScreen();
228272
});
273+
274+
it('displays AddRewardsAccount when accountOptedIn is false', () => {
275+
const props = {
276+
...defaultProps,
277+
shouldShowRewardsRow: true,
278+
accountOptedIn: false,
279+
estimatedPoints: 100,
280+
};
281+
282+
const { getByTestId, queryByTestId } = render(
283+
<PredictFeeSummary {...props} />,
284+
);
285+
286+
expect(getByTestId('add-rewards-account')).toBeOnTheScreen();
287+
expect(queryByTestId('rewards-animation')).toBeNull();
288+
});
289+
290+
it('displays AddRewardsAccount when accountOptedIn is null', () => {
291+
const props = {
292+
...defaultProps,
293+
shouldShowRewardsRow: true,
294+
accountOptedIn: null,
295+
estimatedPoints: 100,
296+
};
297+
298+
const { getByTestId, queryByTestId } = render(
299+
<PredictFeeSummary {...props} />,
300+
);
301+
302+
expect(getByTestId('add-rewards-account')).toBeOnTheScreen();
303+
expect(queryByTestId('rewards-animation')).toBeNull();
304+
});
305+
306+
it('displays loading state when isLoadingRewards is true', () => {
307+
const props = {
308+
...defaultProps,
309+
shouldShowRewardsRow: true,
310+
accountOptedIn: true,
311+
estimatedPoints: 50,
312+
isLoadingRewards: true,
313+
};
314+
315+
const { getByTestId } = render(<PredictFeeSummary {...props} />);
316+
317+
const animation = getByTestId('rewards-animation');
318+
expect(animation).toBeOnTheScreen();
319+
expect(animation.props['data-state']).toBe('Loading');
320+
});
321+
322+
it('displays error state when hasRewardsError is true', () => {
323+
const props = {
324+
...defaultProps,
325+
shouldShowRewardsRow: true,
326+
accountOptedIn: true,
327+
estimatedPoints: 50,
328+
hasRewardsError: true,
329+
};
330+
331+
const { getByTestId } = render(<PredictFeeSummary {...props} />);
332+
333+
const animation = getByTestId('rewards-animation');
334+
expect(animation).toBeOnTheScreen();
335+
expect(animation.props['data-state']).toBe('ErrorState');
336+
});
337+
338+
it('displays idle state when not loading and no error', () => {
339+
const props = {
340+
...defaultProps,
341+
shouldShowRewardsRow: true,
342+
accountOptedIn: true,
343+
estimatedPoints: 50,
344+
isLoadingRewards: false,
345+
hasRewardsError: false,
346+
};
347+
348+
const { getByTestId } = render(<PredictFeeSummary {...props} />);
349+
350+
const animation = getByTestId('rewards-animation');
351+
expect(animation).toBeOnTheScreen();
352+
expect(animation.props['data-state']).toBe('Idle');
353+
});
354+
355+
it('displays error tooltip when hasRewardsError is true', () => {
356+
const props = {
357+
...defaultProps,
358+
shouldShowRewardsRow: true,
359+
accountOptedIn: true,
360+
estimatedPoints: 50,
361+
hasRewardsError: true,
362+
};
363+
364+
const { getByTestId } = render(<PredictFeeSummary {...props} />);
365+
366+
expect(getByTestId('value-tooltip')).toBeOnTheScreen();
367+
});
368+
369+
it('does not display error tooltip when hasRewardsError is false', () => {
370+
const props = {
371+
...defaultProps,
372+
shouldShowRewardsRow: true,
373+
accountOptedIn: true,
374+
estimatedPoints: 50,
375+
hasRewardsError: false,
376+
};
377+
378+
const { queryByTestId } = render(<PredictFeeSummary {...props} />);
379+
380+
expect(queryByTestId('value-tooltip')).toBeNull();
381+
});
382+
383+
it('handles null estimatedPoints when account is opted in', () => {
384+
const props = {
385+
...defaultProps,
386+
shouldShowRewardsRow: true,
387+
accountOptedIn: true,
388+
estimatedPoints: null,
389+
};
390+
391+
const { getByText } = render(<PredictFeeSummary {...props} />);
392+
393+
expect(getByText('0 points')).toBeOnTheScreen();
394+
});
229395
});
230396

231397
describe('Rewards Row Position', () => {
232398
it('renders rewards row after Total row', () => {
233399
const props = {
234400
...defaultProps,
235-
shouldShowRewards: true,
401+
shouldShowRewardsRow: true,
402+
accountOptedIn: true,
236403
estimatedPoints: 50,
237404
};
238405

app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ import RewardsAnimations, {
2121
RewardAnimationState,
2222
} from '../../../Rewards/components/RewardPointsAnimation';
2323
import { formatPrice } from '../../utils/format';
24+
import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount';
2425

2526
interface PredictFeeSummaryProps {
2627
disabled: boolean;
2728
providerFee: number;
2829
metamaskFee: number;
2930
total: number;
30-
shouldShowRewards?: boolean;
31-
estimatedPoints?: number;
31+
shouldShowRewardsRow?: boolean;
32+
accountOptedIn?: boolean | null;
33+
estimatedPoints?: number | null;
3234
isLoadingRewards?: boolean;
3335
hasRewardsError?: boolean;
3436
onFeesInfoPress?: () => void;
@@ -39,7 +41,8 @@ const PredictFeeSummary: React.FC<PredictFeeSummaryProps> = ({
3941
metamaskFee,
4042
providerFee,
4143
total,
42-
shouldShowRewards = false,
44+
shouldShowRewardsRow = false,
45+
accountOptedIn = null,
4346
estimatedPoints = 0,
4447
isLoadingRewards = false,
4548
hasRewardsError = false,
@@ -87,7 +90,7 @@ const PredictFeeSummary: React.FC<PredictFeeSummaryProps> = ({
8790
</Box>
8891

8992
{/* Estimated Points Row */}
90-
{shouldShowRewards && (
93+
{shouldShowRewardsRow && (
9194
<KeyValueRow
9295
field={{
9396
label: {
@@ -112,16 +115,20 @@ const PredictFeeSummary: React.FC<PredictFeeSummaryProps> = ({
112115
justifyContent={BoxJustifyContent.Center}
113116
gap={1}
114117
>
115-
<RewardsAnimations
116-
value={estimatedPoints}
117-
state={
118-
isLoadingRewards
119-
? RewardAnimationState.Loading
120-
: hasRewardsError
121-
? RewardAnimationState.ErrorState
122-
: RewardAnimationState.Idle
123-
}
124-
/>
118+
{accountOptedIn ? (
119+
<RewardsAnimations
120+
value={estimatedPoints ?? 0}
121+
state={
122+
isLoadingRewards
123+
? RewardAnimationState.Loading
124+
: hasRewardsError
125+
? RewardAnimationState.ErrorState
126+
: RewardAnimationState.Idle
127+
}
128+
/>
129+
) : (
130+
<AddRewardsAccount />
131+
)}
125132
</Box>
126133
),
127134
...(hasRewardsError && {

0 commit comments

Comments
 (0)