Skip to content

Commit dd9d99f

Browse files
feat(rewards): update mUSD calculator tab with slider and revamped design (#29758)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR replaces the placeholder mUSD Rewards tab with a calculator-style experience so users can explore estimated earnings from holding mUSD. The tab now shows a hero (hold / earn messaging), an amount slider with labeled scale points ($100–$10,000), estimated yearly / monthly / daily earnings derived from a fixed APY constant, disclaimer copy, and primary CTAs (buy / swap). Slider mapping, snapping, and clamping live in pure helpers (`musdCalculatorSlider`) with unit tests. Strings are added under `rewards.musd.*` across supported locales. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Updated the mUSD bonus calculator in Rewards with a fresh design. ## **Related issues** No issue: Rewards mUSD calculator UI iteration (no GitHub or Jira ticket linked). ## **Manual testing steps** ```gherkin Feature: Rewards mUSD calculator tab Scenario: User views calculator and adjusts amount Given the user opens MetaMask Mobile and navigates to Rewards When the user selects the mUSD calculator tab (or equivalent entry for mUSD) Then the calculator hero, amount slider, estimated earnings, disclaimer, and CTAs are visible Scenario: User moves the slider Given the mUSD calculator tab is open When the user drags the amount slider across the track Then the displayed amount and estimated earnings update consistently and remain within the expected min/max range ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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. <!-- Generated with the help of the pr-description AI skill --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk due to a substantial UI rewrite introducing custom gesture-driven slider behavior (Reanimated/Gesture Handler) and new i18n keys, which could affect interaction/formatting and non-English copy if translations are incomplete. > > **Overview** > Replaces the Rewards mUSD calculator tab UI with a new slider-driven experience: a redesigned hero/earnings display, editable amount input, and a custom press/drag slider (with snapping/clamping) that updates estimated daily/yearly earnings. > > Adds pure slider mapping helpers in `musdCalculatorSlider` (with unit tests), updates button wiring/testIDs and interaction tests (press, pan gestures, input normalization), and wraps `MusdCalculatorView` content in a `KeyboardAvoidingView` with a new test. > > Updates `rewards.musd.*` strings (new English copy/keys) and removes the prior localized string set in many non-English locale files. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fb1b788. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Christian Montoya <christian.montoya@consensys.net>
1 parent c89c486 commit dd9d99f

21 files changed

Lines changed: 718 additions & 304 deletions

app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ describe('MusdCalculatorView', () => {
6666
expect(getByTestId('musd-calculator-tab')).toBeOnTheScreen();
6767
});
6868

69+
it('wraps the calculator in a keyboard avoiding view', () => {
70+
const { getByTestId } = render(<MusdCalculatorView />);
71+
expect(
72+
getByTestId('musd-calculator-keyboard-avoiding-view'),
73+
).toBeOnTheScreen();
74+
});
75+
6976
it('tracks REWARDS_PAGE_VIEWED on mount with page_type musd_calculator', () => {
7077
render(<MusdCalculatorView />);
7178

app/components/UI/Rewards/Views/MusdCalculatorView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { HeaderStandard } from '@metamask/design-system-react-native';
33
import { useNavigation } from '@react-navigation/native';
4+
import { KeyboardAvoidingView, Platform } from 'react-native';
45
import { SafeAreaView } from 'react-native-safe-area-context';
56
import { useTailwind } from '@metamask/design-system-twrnc-preset';
67
import ErrorBoundary from '../../../Views/ErrorBoundary';
@@ -25,7 +26,14 @@ const MusdCalculatorView: React.FC = () => {
2526
onBack={() => navigation.goBack()}
2627
backButtonProps={{ testID: 'header-back-button' }}
2728
/>
28-
<MusdCalculatorTab />
29+
<KeyboardAvoidingView
30+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
31+
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
32+
style={tw.style('flex-1')}
33+
testID="musd-calculator-keyboard-avoiding-view"
34+
>
35+
<MusdCalculatorTab />
36+
</KeyboardAvoidingView>
2937
</SafeAreaView>
3038
</ErrorBoundary>
3139
);
Lines changed: 181 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,47 @@
11
import React from 'react';
2-
import { render, fireEvent, waitFor } from '@testing-library/react-native';
2+
import { render, fireEvent, waitFor, act } from '@testing-library/react-native';
33
import MusdCalculatorTab from './MusdCalculatorTab';
44
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
55
import { createMockUseAnalyticsHook } from '../../../../../../util/test/analyticsMock';
66
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
7+
import { amountToPercent } from '../../../utils/musdCalculatorSlider';
8+
9+
const mockPanGestureHandlers: {
10+
onBegin?: (event: { x: number }) => void;
11+
onUpdate?: (event: { x: number }) => void;
12+
onFinalize?: (event: { x: number }) => void;
13+
} = {};
14+
15+
jest.mock('react-native-gesture-handler', () => ({
16+
GestureHandlerRootView: jest.requireActual('react-native').View,
17+
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
18+
Gesture: {
19+
Pan: jest.fn(() => ({
20+
minDistance: jest.fn().mockReturnThis(),
21+
onBegin: jest.fn(function (
22+
this: unknown,
23+
handler: (event: { x: number }) => void,
24+
) {
25+
mockPanGestureHandlers.onBegin = handler;
26+
return this;
27+
}),
28+
onUpdate: jest.fn(function (
29+
this: unknown,
30+
handler: (event: { x: number }) => void,
31+
) {
32+
mockPanGestureHandlers.onUpdate = handler;
33+
return this;
34+
}),
35+
onFinalize: jest.fn(function (
36+
this: unknown,
37+
handler: (event: { x: number }) => void,
38+
) {
39+
mockPanGestureHandlers.onFinalize = handler;
40+
return this;
41+
}),
42+
})),
43+
},
44+
}));
745

846
const mockGoToSwaps = jest.fn();
947
const mockTrackEvent = jest.fn();
@@ -22,6 +60,13 @@ jest.mock('../../../../../../../locales/i18n', () => ({
2260
strings: jest.fn((key: string) => key),
2361
}));
2462

63+
jest.mock('../../../../SimulationDetails/FiatDisplay/useFiatFormatter', () =>
64+
jest.fn(
65+
() => (value: { toNumber: () => number }) =>
66+
`$${value.toNumber().toLocaleString('en-US')}`,
67+
),
68+
);
69+
2570
jest.mock('../../../../../../core/DeeplinkManager', () => ({
2671
handleDeeplink: jest.fn(),
2772
}));
@@ -43,6 +88,9 @@ jest.mock('../../../../../../util/theme', () => {
4388
describe('MusdCalculatorTab', () => {
4489
beforeEach(() => {
4590
jest.clearAllMocks();
91+
mockPanGestureHandlers.onBegin = undefined;
92+
mockPanGestureHandlers.onUpdate = undefined;
93+
mockPanGestureHandlers.onFinalize = undefined;
4694
jest.mocked(useAnalytics).mockReturnValue(
4795
createMockUseAnalyticsHook({
4896
trackEvent: mockTrackEvent,
@@ -51,28 +99,28 @@ describe('MusdCalculatorTab', () => {
5199
);
52100
});
53101

54-
it('renders all calculator UI elements', () => {
55-
const { getByText } = render(<MusdCalculatorTab />);
56-
57-
expect(getByText('rewards.musd.title')).toBeTruthy();
58-
expect(getByText('rewards.musd.description')).toBeTruthy();
59-
expect(getByText('rewards.musd.amount_label')).toBeTruthy();
60-
expect(getByText('rewards.musd.estimated_bonus')).toBeTruthy();
61-
expect(getByText('rewards.musd.initial_amount')).toBeTruthy();
62-
expect(getByText('rewards.musd.daily_bonus')).toBeTruthy();
63-
expect(getByText('rewards.musd.annualized_bonus')).toBeTruthy();
64-
expect(getByText('rewards.musd.disclaimer_brief')).toBeTruthy();
65-
expect(getByText('rewards.musd.buy_button')).toBeTruthy();
66-
expect(getByText('rewards.musd.swap_button')).toBeTruthy();
102+
it('renders calculator layout and controls', () => {
103+
const { getByText, getByTestId } = render(<MusdCalculatorTab />);
104+
105+
expect(getByText('rewards.musd.hero_hold')).toBeOnTheScreen();
106+
expect(getByText('rewards.musd.hero_earn')).toBeOnTheScreen();
107+
expect(getByText('rewards.musd.slider_amount_label')).toBeOnTheScreen();
108+
expect(getByText('rewards.musd.disclaimer_calculator')).toBeOnTheScreen();
109+
expect(getByTestId('musd-slider-scale-min')).toHaveTextContent('$100');
110+
expect(getByTestId('musd-slider-scale-mid')).toHaveTextContent('$1,000');
111+
expect(getByTestId('musd-slider-scale-max')).toHaveTextContent('$10,000');
112+
expect(getByText('rewards.musd.buy_button')).toBeOnTheScreen();
113+
expect(getByText('rewards.musd.swap_button')).toBeOnTheScreen();
114+
expect(getByTestId('musd-slider-track')).toBeOnTheScreen();
67115
});
68116

69117
it('calls handleDeeplink and tracks buy_musd event when Buy button is pressed', () => {
70118
const { handleDeeplink } = jest.requireMock(
71119
'../../../../../../core/DeeplinkManager',
72120
);
73121

74-
const { getByText } = render(<MusdCalculatorTab />);
75-
fireEvent.press(getByText('rewards.musd.buy_button'));
122+
const { getByTestId } = render(<MusdCalculatorTab />);
123+
fireEvent.press(getByTestId('musd-buy-button'));
76124

77125
expect(handleDeeplink).toHaveBeenCalledWith({
78126
uri: expect.stringContaining('link.metamask.io/buy'),
@@ -87,8 +135,8 @@ describe('MusdCalculatorTab', () => {
87135
});
88136

89137
it('navigates to swap screen and tracks swap_to_musd event when Swap button is pressed', () => {
90-
const { getByText } = render(<MusdCalculatorTab />);
91-
fireEvent.press(getByText('rewards.musd.swap_button'));
138+
const { getByTestId } = render(<MusdCalculatorTab />);
139+
fireEvent.press(getByTestId('musd-swap-button'));
92140

93141
expect(mockGoToSwaps).toHaveBeenCalled();
94142
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
@@ -100,56 +148,141 @@ describe('MusdCalculatorTab', () => {
100148
expect(mockTrackEvent).toHaveBeenCalled();
101149
});
102150

103-
it('updates input value when amount changes', () => {
104-
const { getByTestId } = render(<MusdCalculatorTab />);
151+
it('updates amount and earnings when the track is pressed', async () => {
152+
const { getByTestId, getByText, queryByText } = render(
153+
<MusdCalculatorTab />,
154+
);
155+
const track = getByTestId('musd-slider-track');
156+
157+
fireEvent(track, 'layout', {
158+
nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
159+
});
105160

106-
const input = getByTestId('musd-amount-input');
107-
fireEvent.changeText(input, '5000');
161+
const locationX = (amountToPercent(5000) / 100) * 300;
162+
fireEvent(track, 'pressIn', {
163+
nativeEvent: { locationX },
164+
});
108165

109-
expect(input.props.value).toBe('5000');
166+
await waitFor(() => {
167+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
168+
'value',
169+
'$5,000',
170+
);
171+
expect(
172+
getByText(/rewards\.musd\.earnings_per_day_suffix/),
173+
).toBeOnTheScreen();
174+
expect(
175+
queryByText(/rewards\.musd\.earnings_per_month_suffix/),
176+
).toBeNull();
177+
expect(getByText(/\$0\.41/)).toBeOnTheScreen();
178+
expect(getByText(/\$150/)).toBeOnTheScreen();
179+
});
110180
});
111181

112-
it('sanitizes non-numeric input', () => {
113-
const { getByTestId } = render(<MusdCalculatorTab />);
182+
it('allows editing the amount and updates earnings from typed values', async () => {
183+
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
184+
const amountInput = getByTestId('musd-slider-amount-display');
185+
186+
fireEvent(amountInput, 'focus');
187+
fireEvent.changeText(amountInput, '5000');
188+
189+
await waitFor(() => {
190+
expect(amountInput).toHaveProp('value', '5000');
191+
expect(getByText(/\$150/)).toBeOnTheScreen();
192+
});
114193

115-
const input = getByTestId('musd-amount-input');
116-
fireEvent.changeText(input, 'abc123def');
194+
fireEvent(amountInput, 'endEditing');
117195

118-
expect(input.props.value).toBe('123');
196+
await waitFor(() => {
197+
expect(amountInput).toHaveProp('value', '$5,000');
198+
});
119199
});
120200

121-
it('allows decimal input', () => {
122-
const { getByTestId } = render(<MusdCalculatorTab />);
201+
it('accepts amounts over the slider maximum', async () => {
202+
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
203+
const amountInput = getByTestId('musd-slider-amount-display');
204+
205+
fireEvent(amountInput, 'focus');
206+
fireEvent.changeText(amountInput, '12000');
123207

124-
const input = getByTestId('musd-amount-input');
125-
fireEvent.changeText(input, '1000.50');
208+
await waitFor(() => {
209+
expect(amountInput).toHaveProp('value', '12000');
210+
expect(getByText(/\$360/)).toBeOnTheScreen();
211+
});
212+
});
213+
214+
it('normalizes decorated decimal input while editing', async () => {
215+
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
216+
const amountInput = getByTestId('musd-slider-amount-display');
217+
218+
fireEvent(amountInput, 'focus');
219+
fireEvent.changeText(amountInput, '$1,234.56.78');
220+
221+
await waitFor(() => {
222+
expect(amountInput).toHaveProp('value', '1234.5678');
223+
expect(getByText(/\$37\.037/)).toBeOnTheScreen();
224+
});
225+
});
226+
227+
it('treats invalid numeric input as zero', async () => {
228+
const { getByTestId, getAllByText } = render(<MusdCalculatorTab />);
229+
const amountInput = getByTestId('musd-slider-amount-display');
126230

127-
expect(input.props.value).toBe('1000.50');
231+
fireEvent(amountInput, 'focus');
232+
fireEvent.changeText(amountInput, '.');
233+
234+
await waitFor(() => {
235+
expect(amountInput).toHaveProp('value', '.');
236+
expect(getAllByText(/\$0/).length).toBeGreaterThan(0);
237+
});
128238
});
129239

130-
it('rejects multiple decimal points', () => {
240+
it('ignores slider presses before the track is measured', () => {
131241
const { getByTestId } = render(<MusdCalculatorTab />);
132242

133-
const input = getByTestId('musd-amount-input');
134-
fireEvent.changeText(input, '1000.50');
135-
fireEvent.changeText(input, '1000.50.25');
243+
fireEvent(getByTestId('musd-slider-track'), 'pressIn', {
244+
nativeEvent: { locationX: 200 },
245+
});
136246

137-
expect(input.props.value).toBe('1000.50');
247+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
248+
'value',
249+
'$1,000',
250+
);
138251
});
139252

140-
it('calculates correct bonus values for $5000 input', async () => {
141-
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
253+
it('updates amount through pan gesture handlers', async () => {
254+
const { getByTestId } = render(<MusdCalculatorTab />);
255+
const track = getByTestId('musd-slider-track');
142256

143-
const input = getByTestId('musd-amount-input');
144-
fireEvent.changeText(input, '5000');
257+
fireEvent(track, 'layout', {
258+
nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
259+
});
260+
261+
act(() => {
262+
mockPanGestureHandlers.onBegin?.({ x: 150 });
263+
mockPanGestureHandlers.onUpdate?.({ x: 300 });
264+
mockPanGestureHandlers.onFinalize?.({ x: 300 });
265+
});
145266

146-
// Initial amount: $5,000.00
147-
// Daily bonus: $5000 * 0.03 / 365 = $0.41
148-
// Annualized bonus: $5000 * 0.03 = $150.00
149267
await waitFor(() => {
150-
expect(getByText('$5,000')).toBeOnTheScreen();
151-
expect(getByText('$0.41')).toBeOnTheScreen();
152-
expect(getByText('$150')).toBeOnTheScreen();
268+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
269+
'value',
270+
'$10,000',
271+
);
153272
});
154273
});
274+
275+
it('ignores pan gesture handlers before the track is measured', () => {
276+
const { getByTestId } = render(<MusdCalculatorTab />);
277+
278+
act(() => {
279+
mockPanGestureHandlers.onBegin?.({ x: 150 });
280+
mockPanGestureHandlers.onUpdate?.({ x: 300 });
281+
});
282+
283+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
284+
'value',
285+
'$1,000',
286+
);
287+
});
155288
});

0 commit comments

Comments
 (0)