Skip to content

Commit b000065

Browse files
authored
feat: Integrate Rewards with Predict (#22546)
# Predict Buy: Rewards Display and Fee Consolidation ## Overview This PR adds rewards point estimation and consolidates fee display in the Predict Buy confirmation screen. Users can now see estimated MetaMask Rewards points they'll earn from their transaction, and view detailed fee breakdowns through an intuitive bottom sheet. CHANGELOG entry: null https://github.com/user-attachments/assets/cf20a4e3-1375-4f9b-97ff-c75a3d68f737 ## Changes ### 🎁 Rewards Integration - **Added "Est. points" row** to the Predict Buy confirmation screen - Displays estimated rewards points based on MetaMask fee - **Calculation**: `Math.round(metamaskFee * 100)` (1 point per cent spent on MM fee) - Position: Last row after "Total" - White text with gray info icon for consistency - Reuses `RewardsAnimations` component from Swap/Bridge features - Conditional display based on `rewardsEnabled` feature flag and transaction amount ### 💰 Fee Display Consolidation - **Consolidated two fee rows into single "Fees" row** - Previously: Separate "Provider fee" and "MetaMask fee" rows - Now: Single "Fees" row showing sum of both fees - Added gray info icon that opens detailed breakdown - **New Fee Breakdown Bottom Sheet** (`PredictFeeBreakdownSheet`) - Displays individual fee breakdown: - Polymarket fee (provider fee) - MetaMask fee - Opens when user taps info icon next to "Fees" - Closes without navigating back (uses `shouldNavigateBack={false}`) ### 🎨 UI/UX Improvements - **Fee Summary Row Order**: 1. Fees (consolidated, with info icon) 2. Total 3. Est. points (when rewards enabled) - **Styling**: - Est. points text: White (`TextColor.Default`) - Info icons: Gray (`IconColor.Alternative`) - Consistent with design system ## Technical Details ### Files Modified #### Components - **`PredictBuyPreview.tsx`** - Import `selectRewardsEnabledFlag` selector - Calculate `estimatedPoints` from `metamaskFee` - Add `isFeeBreakdownVisible` state - Add `handleFeesInfoPress` and `handleFeeBreakdownClose` callbacks - Pass rewards props to `PredictFeeSummary` - Conditionally render `PredictFeeBreakdownSheet` - **`PredictFeeSummary.tsx`** - Remove individual fee rows - Add consolidated "Fees" row with `ButtonIcon` - Calculate `totalFees = providerFee + metamaskFee` - Move rewards row to last position (after Total) - Update text colors (white for Est. points label) - Add `onFeesInfoPress` callback prop #### New Files - **`PredictFeeBreakdownSheet.tsx`** - Bottom sheet component for fee breakdown - Displays provider and MetaMask fees separately - Uses `shouldNavigateBack={false}` to prevent parent modal closure - Accepts `onClose` callback - **`PredictFeeBreakdownSheet/index.ts`** - Export file for new component #### Localization - **`locales/languages/en.json`** ```json { "predict.fee_summary.fees": "Fees", "predict.fee_summary.provider_fee_label": "Polymarket fee", "predict.fee_summary.estimated_points": "Est. points", "predict.fee_summary.points_tooltip": "Points", "predict.fee_summary.points_tooltip_content_1": "Points are how you earn MetaMask Rewards for completing transactions, like when you swap, bridge, or predict.", "predict.fee_summary.points_tooltip_content_2": "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." } ``` ### Tests #### New Tests (22 total) - **`PredictFeeSummary.test.tsx`** (12 tests) - Consolidated fees display and calculation - Fees info icon callback handling - Rewards row conditional rendering - Rewards row positioning - Edge cases (zero fees, missing callbacks) - **`PredictFeeBreakdownSheet.test.tsx`** (10 tests - New file) - Bottom sheet rendering - Fee display (Polymarket and MetaMask) - `shouldNavigateBack` behavior - Close callback handling - Ref methods exposure - **`PredictBuyPreview.test.tsx`** (10 new tests) - Rewards calculation formula - Rewards point rounding - Feature flag conditional display - Fee breakdown sheet visibility - Loading state propagation #### Updated Tests (1) - Fixed existing test to expect "Fees" instead of "Provider fee" **Total Test Results**: 2,038 tests passing ✅ ## Rewards Calculation Logic ```typescript // Formula: 1 point per cent spent on MetaMask fee const estimatedPoints = useMemo( () => Math.round(metamaskFee * 100), [metamaskFee], ); // Display conditions const shouldShowRewards = rewardsEnabled && currentValue > 0; ``` ### Examples: - MetaMask fee: $0.50 → **50 points** - MetaMask fee: $1.23 → **123 points** - MetaMask fee: $0.00 → **0 points** ## Component Reusability This implementation reuses existing components: - ✅ `RewardsAnimations` (from Bridge/Swap) - ✅ `KeyValueRow` (component library) - ✅ `ButtonIcon` (design system) - ✅ `BottomSheet` (design system) - ✅ Design system tokens (`TextColor`, `IconColor`) ## Feature Flags - **Rewards display**: Controlled by `selectRewardsEnabledFlag` - **Fee breakdown**: Always available ## Testing Checklist - [x] Unit tests for all new components - [x] Unit tests for modified components - [x] All existing tests passing (2,038 tests) - [x] No linter errors - [x] Tests follow AAA pattern - [x] Tests follow project guidelines (no "should" in names) - [x] Edge cases covered (zero values, missing callbacks, etc.) ## Manual Testing ### Test Scenarios 1. **Rewards Display** - [ ] Verify Est. points row appears when rewards enabled - [ ] Verify points calculation: fee * 100 rounded - [ ] Verify Est. points row does NOT appear when rewards disabled - [ ] Verify Est. points row does NOT appear when amount is 0 2. **Fee Consolidation** - [ ] Verify single "Fees" row shows sum of provider + MetaMask fees - [ ] Verify info icon appears next to "Fees" label - [ ] Verify tapping info icon opens bottom sheet - [ ] Verify bottom sheet shows individual fee breakdown 3. **Bottom Sheet Behavior** - [ ] Verify bottom sheet displays "Polymarket fee" and "MetaMask fee" - [ ] Verify closing bottom sheet does NOT close parent modal - [ ] Verify bottom sheet closes on swipe down - [ ] Verify bottom sheet closes on backdrop tap 4. **Visual/Styling** - [ ] Verify Est. points text is white - [ ] Verify all info icons are gray - [ ] Verify row order: Fees → Total → Est. points - [ ] Verify layout on different screen sizes ## Before/After ### Before ``` Provider fee $0.10 MetaMask fee $0.04 Total $10.14 ``` ### After ``` Fees [i] $0.14 ← Tappable info icon Total $10.14 Est. points [i] 14 ← New rewards row (white text) ``` ### Fee Breakdown Sheet (when tapping [i]) ``` ╔════════════════════════╗ ║ Fees ║ ╠════════════════════════╣ ║ Polymarket fee $0.10 ║ ║ MetaMask fee $0.04 ║ ╚════════════════════════╝ ``` ## Commit History 1. `feat: Add rewards row to Predict Buy confirmation screen` - Initial rewards implementation 2. `refactor: Consolidate fee rows and add breakdown sheet` - Fee consolidation 3. `refactor: Move Est. points row to end and use white text` - Final positioning and styling 4. `test: Add comprehensive tests for rewards and fee breakdown features` - Complete test coverage ## Related Issues/PRs - Related to rewards feature integration - Consistent with Swap/Bridge rewards display patterns ## Checklist - [x] Code follows project coding guidelines - [x] Code follows React Native UI development guidelines - [x] Used design system components (`@metamask/design-system-react-native`) - [x] Used Tailwind CSS with `useTailwind()` hook - [x] No StyleSheet.create() used - [x] Proper TypeScript types (no `any`) - [x] Comprehensive unit tests added - [x] All tests passing - [x] No linter errors - [x] Localization strings added - [x] Feature flag integrated - [x] Component reusability maintained - [x] Follows AAA test pattern - [x] No breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds rewards point estimation and consolidates fee display with a tappable fees breakdown sheet in Predict Buy. > > - **Predict UI**: > - `PredictFeeSummary`: Replace separate fee rows with a single "Fees" row (info icon), display total fees, and add "Est. points" row using `RewardsAnimations`. > - `PredictBuyPreview`: Compute `estimatedPoints = Math.round(metamaskFee * 100)`, gate rewards by `selectRewardsEnabledFlag`, and open fee breakdown via `onFeesInfoPress`; pass rewards/fees props. > - **New Component**: > - `PredictFeeBreakdownSheet`: Bottom sheet showing per-fee breakdown (`Polymarket fee`, `MetaMask fee`), `shouldNavigateBack=false`, `onClose` support. > - **Localization**: > - Add strings for `predict.fee_summary.fees`, `provider_fee_label`, `estimated_points`, points tooltips/error content. > - **Tests**: > - Add tests for `PredictFeeBreakdownSheet` and rewards/fees behavior; update existing expectations from "Provider fee" to "Fees". > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 01962c5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dc67be4 commit b000065

File tree

8 files changed

+740
-69
lines changed

8 files changed

+740
-69
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import React, { useRef, useEffect } from 'react';
2+
import { render, act } from '@testing-library/react-native';
3+
import PredictFeeBreakdownSheet from './PredictFeeBreakdownSheet';
4+
import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
5+
6+
jest.mock('../../utils/format', () => ({
7+
formatPrice: jest.fn((value, options) =>
8+
value !== undefined
9+
? `$${value.toFixed(options?.maximumDecimals ?? 2)}`
10+
: '$0.00',
11+
),
12+
}));
13+
14+
// Mock BottomSheet
15+
jest.mock(
16+
'../../../../../component-library/components/BottomSheets/BottomSheet',
17+
() => {
18+
const ReactActual = jest.requireActual('react');
19+
const { View } = jest.requireActual('react-native');
20+
21+
return ReactActual.forwardRef(
22+
(
23+
props: {
24+
children?: React.ReactNode;
25+
onClose?: () => void;
26+
shouldNavigateBack?: boolean;
27+
},
28+
ref: React.Ref<{
29+
onCloseBottomSheet: (callback?: () => void) => void;
30+
onOpenBottomSheet: (callback?: () => void) => void;
31+
}>,
32+
) => {
33+
ReactActual.useImperativeHandle(ref, () => ({
34+
onCloseBottomSheet: (callback?: () => void) => {
35+
props.onClose?.();
36+
callback?.();
37+
},
38+
onOpenBottomSheet: (callback?: () => void) => {
39+
callback?.();
40+
},
41+
}));
42+
43+
return ReactActual.createElement(
44+
View,
45+
{ testID: 'bottom-sheet' },
46+
props.children,
47+
);
48+
},
49+
);
50+
},
51+
);
52+
53+
// Mock SheetHeader
54+
jest.mock(
55+
'../../../../../component-library/components/Sheet/SheetHeader',
56+
() => {
57+
const React = jest.requireActual('react');
58+
const { Text: RNText } = jest.requireActual('react-native');
59+
return ({ title }: { title: string }) =>
60+
React.createElement(RNText, { testID: 'sheet-header' }, title);
61+
},
62+
);
63+
64+
describe('PredictFeeBreakdownSheet', () => {
65+
const defaultProps = {
66+
providerFee: 0.1,
67+
metamaskFee: 0.05,
68+
};
69+
70+
beforeEach(() => {
71+
jest.clearAllMocks();
72+
});
73+
74+
afterEach(() => {
75+
jest.clearAllMocks();
76+
});
77+
78+
describe('Rendering', () => {
79+
it('renders bottom sheet with header', () => {
80+
const TestComponent = () => {
81+
const ref = useRef<BottomSheetRef>(null);
82+
return <PredictFeeBreakdownSheet ref={ref} {...defaultProps} />;
83+
};
84+
85+
const { getByTestId } = render(<TestComponent />);
86+
87+
expect(getByTestId('bottom-sheet')).toBeOnTheScreen();
88+
expect(getByTestId('sheet-header')).toBeOnTheScreen();
89+
});
90+
91+
it('renders Fees title in header', () => {
92+
const TestComponent = () => {
93+
const ref = useRef<BottomSheetRef>(null);
94+
return <PredictFeeBreakdownSheet ref={ref} {...defaultProps} />;
95+
};
96+
97+
const { getByText } = render(<TestComponent />);
98+
99+
expect(getByText('Fees')).toBeOnTheScreen();
100+
});
101+
});
102+
103+
describe('Fee Display', () => {
104+
it('displays Polymarket fee label and amount', () => {
105+
const props = { ...defaultProps, providerFee: 0.15 };
106+
const TestComponent = () => {
107+
const ref = useRef<BottomSheetRef>(null);
108+
return <PredictFeeBreakdownSheet ref={ref} {...props} />;
109+
};
110+
111+
const { getByText } = render(<TestComponent />);
112+
113+
expect(getByText('Polymarket fee')).toBeOnTheScreen();
114+
expect(getByText('$0.15')).toBeOnTheScreen();
115+
});
116+
117+
it('displays MetaMask fee label and amount', () => {
118+
const props = { ...defaultProps, metamaskFee: 0.08 };
119+
const TestComponent = () => {
120+
const ref = useRef<BottomSheetRef>(null);
121+
return <PredictFeeBreakdownSheet ref={ref} {...props} />;
122+
};
123+
124+
const { getByText } = render(<TestComponent />);
125+
126+
expect(getByText('MetaMask fee')).toBeOnTheScreen();
127+
expect(getByText('$0.08')).toBeOnTheScreen();
128+
});
129+
130+
it('displays zero fees correctly', () => {
131+
const props = { providerFee: 0, metamaskFee: 0 };
132+
const TestComponent = () => {
133+
const ref = useRef<BottomSheetRef>(null);
134+
return <PredictFeeBreakdownSheet ref={ref} {...props} />;
135+
};
136+
137+
const { getAllByText } = render(<TestComponent />);
138+
139+
const zeroAmounts = getAllByText('$0.00');
140+
expect(zeroAmounts.length).toBeGreaterThanOrEqual(2);
141+
});
142+
});
143+
144+
describe('Bottom Sheet Behavior', () => {
145+
it('passes shouldNavigateBack as false to BottomSheet', () => {
146+
const TestComponent = () => {
147+
const ref = useRef<BottomSheetRef>(null);
148+
return <PredictFeeBreakdownSheet ref={ref} {...defaultProps} />;
149+
};
150+
151+
const { getByTestId } = render(<TestComponent />);
152+
153+
expect(getByTestId('bottom-sheet')).toBeOnTheScreen();
154+
});
155+
156+
it('calls onClose callback when bottom sheet closes', () => {
157+
const mockOnClose = jest.fn();
158+
const TestComponent = () => {
159+
const ref = useRef<BottomSheetRef>(null);
160+
161+
useEffect(() => {
162+
act(() => {
163+
ref.current?.onCloseBottomSheet();
164+
});
165+
}, []);
166+
167+
return (
168+
<PredictFeeBreakdownSheet
169+
ref={ref}
170+
{...defaultProps}
171+
onClose={mockOnClose}
172+
/>
173+
);
174+
};
175+
176+
render(<TestComponent />);
177+
178+
expect(mockOnClose).toHaveBeenCalledTimes(1);
179+
});
180+
181+
it('does not crash when onClose is not provided', () => {
182+
const TestComponent = () => {
183+
const ref = useRef<BottomSheetRef>(null);
184+
185+
useEffect(() => {
186+
act(() => {
187+
ref.current?.onCloseBottomSheet();
188+
});
189+
}, []);
190+
191+
return <PredictFeeBreakdownSheet ref={ref} {...defaultProps} />;
192+
};
193+
194+
expect(() => render(<TestComponent />)).not.toThrow();
195+
});
196+
});
197+
198+
describe('Ref Methods', () => {
199+
it('exposes onOpenBottomSheet method', () => {
200+
const TestComponent = () => {
201+
const ref = useRef<BottomSheetRef>(null);
202+
203+
useEffect(() => {
204+
expect(ref.current?.onOpenBottomSheet).toBeDefined();
205+
}, []);
206+
207+
return <PredictFeeBreakdownSheet ref={ref} {...defaultProps} />;
208+
};
209+
210+
render(<TestComponent />);
211+
});
212+
213+
it('exposes onCloseBottomSheet method', () => {
214+
const TestComponent = () => {
215+
const ref = useRef<BottomSheetRef>(null);
216+
217+
useEffect(() => {
218+
expect(ref.current?.onCloseBottomSheet).toBeDefined();
219+
}, []);
220+
221+
return <PredictFeeBreakdownSheet ref={ref} {...defaultProps} />;
222+
};
223+
224+
render(<TestComponent />);
225+
});
226+
});
227+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { forwardRef } from 'react';
2+
import { Box } from '@metamask/design-system-react-native';
3+
import BottomSheet, {
4+
BottomSheetRef,
5+
} from '../../../../../component-library/components/BottomSheets/BottomSheet';
6+
import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader';
7+
import Text, {
8+
TextColor,
9+
TextVariant,
10+
} from '../../../../../component-library/components/Texts/Text';
11+
import { strings } from '../../../../../../locales/i18n';
12+
import { formatPrice } from '../../utils/format';
13+
14+
interface PredictFeeBreakdownSheetProps {
15+
providerFee: number;
16+
metamaskFee: number;
17+
onClose?: () => void;
18+
}
19+
20+
const PredictFeeBreakdownSheet = forwardRef<
21+
BottomSheetRef,
22+
PredictFeeBreakdownSheetProps
23+
>(({ providerFee, metamaskFee, onClose }, ref) => (
24+
<BottomSheet ref={ref} onClose={onClose} shouldNavigateBack={false}>
25+
<SheetHeader title={strings('predict.fee_summary.fees')} />
26+
<Box twClassName="px-4 pb-6 flex-col gap-4">
27+
{/* Provider Fee Row */}
28+
<Box twClassName="flex-row justify-between items-center">
29+
<Text color={TextColor.Alternative} variant={TextVariant.BodyMD}>
30+
{strings('predict.fee_summary.provider_fee_label')}
31+
</Text>
32+
<Text color={TextColor.Alternative} variant={TextVariant.BodyMD}>
33+
{formatPrice(providerFee, {
34+
maximumDecimals: 2,
35+
})}
36+
</Text>
37+
</Box>
38+
39+
{/* MetaMask Fee Row */}
40+
<Box twClassName="flex-row justify-between items-center">
41+
<Text color={TextColor.Alternative} variant={TextVariant.BodyMD}>
42+
{strings('predict.fee_summary.metamask_fee')}
43+
</Text>
44+
<Text color={TextColor.Alternative} variant={TextVariant.BodyMD}>
45+
{formatPrice(metamaskFee, {
46+
maximumDecimals: 2,
47+
})}
48+
</Text>
49+
</Box>
50+
</Box>
51+
</BottomSheet>
52+
));
53+
54+
PredictFeeBreakdownSheet.displayName = 'PredictFeeBreakdownSheet';
55+
56+
export default PredictFeeBreakdownSheet;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './PredictFeeBreakdownSheet';

0 commit comments

Comments
 (0)