Skip to content

Commit 35fb472

Browse files
authored
feat: MUSD-455 bring back claim section on asset details screen (#27567)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR brings back the mUSD bonus claim section displayed on the asset overview screen. It was previously removed as part of this [PR](#26147). ## **Changelog** CHANGELOG entry: Restored mUSD claimable bonus claim section on asset overview screen ## **Related issues** Fixes: [MUSD-455: Bring back claim section on asset details screen](https://consensyssoftware.atlassian.net/browse/MUSD-455) ## **Manual testing steps** ```gherkin Feature: mUSD claimable bonus on asset overview Scenario: user views and claims their mUSD bonus Given user has claimable Merkl rewards above the minimum threshold When user navigates to the mUSD asset details screen Then a claim bonus section is displayed with the claimable amount When user taps "Claim" Then the claim confirmation bottom sheet appears Then the "Claim" button is guarded against repeated clicks after the first ``` ## **Screenshots/Recordings** ### **Before** Bonus claim section wasn't rendered on the asset overview screen. ### **After** https://github.com/user-attachments/assets/a4b1f910-a943-46a5-b5d2-ab183bba208f ## **Notes** - Fixed an edge case where the Claim button press-guard could get stuck if `claimRewards()` exits early (e.g. missing selected account or Linea network client id), causing subsequent taps to be ignored. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new claim CTA into the token details render path and wires it to rewards-claiming hooks, analytics, and external linking; this could impact performance (hook execution for many assets) and claim UX if gating/chain selection is wrong. > > **Overview** > Restores an **mUSD “Claimable bonus”** section on the asset details screen, showing the user’s claimable amount and a `Claim` button that triggers `useMerklBonusClaim`’s claim flow and disables/rejects repeated presses while a claim is in flight/pending. > > Adds an info tooltip with a link to Terms of Use (with new analytics location), plus new i18n strings and test IDs to support UI copy, tracking, and E2E automation. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0d81e8f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-1797260d-779d-4781-986b-1f8dc6c39c93"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-1797260d-779d-4781-986b-1f8dc6c39c93"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
1 parent 1a588d5 commit 35fb472

7 files changed

Lines changed: 561 additions & 0 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
import React from 'react';
2+
import { fireEvent } from '@testing-library/react-native';
3+
import { Linking } from 'react-native';
4+
import renderWithProvider from '../../../../../util/test/renderWithProvider';
5+
import AssetOverviewClaimBonus from '.';
6+
import {
7+
useMerklBonusClaim,
8+
MerklClaimData,
9+
} from '../MerklRewards/hooks/useMerklBonusClaim';
10+
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
11+
import useTooltipModal from '../../../../hooks/useTooltipModal';
12+
import { MetaMetricsEvents, EVENT_NAME } from '../../../../../core/Analytics';
13+
import { MUSD_EVENTS_CONSTANTS } from '../../constants/events/musdEvents';
14+
import AppConstants from '../../../../../core/AppConstants';
15+
import { ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS } from './AssetOverviewClaimBonus.testIds';
16+
import { TokenI } from '../../../Tokens/types';
17+
18+
jest.mock('../MerklRewards/hooks/useMerklBonusClaim');
19+
jest.mock('../../../../hooks/useAnalytics/useAnalytics');
20+
jest.mock('../../../../hooks/useTooltipModal');
21+
jest.mock('react-native/Libraries/Linking/Linking', () => ({
22+
addEventListener: jest.fn(),
23+
removeEventListener: jest.fn(),
24+
openURL: jest.fn(),
25+
canOpenURL: jest.fn(),
26+
getInitialURL: jest.fn(),
27+
}));
28+
29+
const mockUseMerklBonusClaim = useMerklBonusClaim as jest.MockedFunction<
30+
typeof useMerklBonusClaim
31+
>;
32+
33+
const createMockAsset = (overrides: Partial<TokenI> = {}): TokenI => ({
34+
address: '0x8d652c6d4A8F3Db96Cd866C1a9220B1447F29898',
35+
chainId: '0x1',
36+
symbol: 'aglaMerkl',
37+
aggregators: [],
38+
decimals: 18,
39+
image: '',
40+
name: 'Angle Merkl',
41+
balance: '1000',
42+
balanceFiat: '$100',
43+
logo: '',
44+
isETH: false,
45+
isNative: false,
46+
...overrides,
47+
});
48+
49+
const createMockMerklClaimData = (
50+
overrides: Partial<MerklClaimData> = {},
51+
): MerklClaimData => ({
52+
claimableReward: null,
53+
hasPendingClaim: false,
54+
isClaiming: false,
55+
claimRewards: jest.fn().mockResolvedValue(undefined),
56+
...overrides,
57+
});
58+
59+
describe('AssetOverviewClaimBonus', () => {
60+
const mockTrackEvent = jest.fn();
61+
const mockCreateEventBuilder = jest.fn();
62+
const mockAddProperties = jest.fn();
63+
const mockBuild = jest.fn();
64+
const mockOpenTooltipModal = jest.fn();
65+
const mockClaimRewards = jest.fn().mockResolvedValue(undefined);
66+
67+
beforeEach(() => {
68+
jest.clearAllMocks();
69+
70+
mockBuild.mockReturnValue({ name: 'mock-built-event' });
71+
mockAddProperties.mockReturnValue({ build: mockBuild });
72+
mockCreateEventBuilder.mockReturnValue({
73+
addProperties: mockAddProperties,
74+
});
75+
76+
(useAnalytics as jest.MockedFunction<typeof useAnalytics>).mockReturnValue({
77+
trackEvent: mockTrackEvent,
78+
createEventBuilder: mockCreateEventBuilder,
79+
} as unknown as ReturnType<typeof useAnalytics>);
80+
81+
(
82+
useTooltipModal as jest.MockedFunction<typeof useTooltipModal>
83+
).mockReturnValue({
84+
openTooltipModal: mockOpenTooltipModal,
85+
});
86+
87+
mockUseMerklBonusClaim.mockReturnValue(
88+
createMockMerklClaimData({
89+
claimableReward: '10.01',
90+
claimRewards: mockClaimRewards,
91+
}),
92+
);
93+
});
94+
95+
describe('rendering and visibility', () => {
96+
it('renders nothing when claimableReward is null', () => {
97+
mockUseMerklBonusClaim.mockReturnValue(
98+
createMockMerklClaimData({ claimableReward: null }),
99+
);
100+
101+
const { queryByTestId } = renderWithProvider(
102+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
103+
);
104+
105+
expect(
106+
queryByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CONTAINER),
107+
).toBeNull();
108+
});
109+
110+
it('renders claim section when claimableReward is present', () => {
111+
const { getByTestId } = renderWithProvider(
112+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
113+
);
114+
115+
expect(
116+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CONTAINER),
117+
).toBeOnTheScreen();
118+
expect(
119+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON),
120+
).toBeOnTheScreen();
121+
expect(
122+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON),
123+
).toBeOnTheScreen();
124+
expect(
125+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIMABLE_AMOUNT),
126+
).toBeOnTheScreen();
127+
});
128+
129+
it('displays formatted claimable reward amount with dollar sign', () => {
130+
mockUseMerklBonusClaim.mockReturnValue(
131+
createMockMerklClaimData({ claimableReward: '42.50' }),
132+
);
133+
134+
const { getByTestId } = renderWithProvider(
135+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
136+
);
137+
138+
expect(
139+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIMABLE_AMOUNT),
140+
).toHaveTextContent('$42.50');
141+
});
142+
});
143+
144+
describe('loading state', () => {
145+
it('disables claim button when isClaiming is true', () => {
146+
mockUseMerklBonusClaim.mockReturnValue(
147+
createMockMerklClaimData({
148+
claimableReward: '10.01',
149+
isClaiming: true,
150+
}),
151+
);
152+
153+
const { getByTestId } = renderWithProvider(
154+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
155+
);
156+
157+
const button = getByTestId(
158+
ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON,
159+
);
160+
expect(button).toBeDisabled();
161+
});
162+
163+
it('disables claim button when hasPendingClaim is true', () => {
164+
mockUseMerklBonusClaim.mockReturnValue(
165+
createMockMerklClaimData({
166+
claimableReward: '10.01',
167+
hasPendingClaim: true,
168+
}),
169+
);
170+
171+
const { getByTestId } = renderWithProvider(
172+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
173+
);
174+
175+
const button = getByTestId(
176+
ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON,
177+
);
178+
expect(button).toBeDisabled();
179+
});
180+
});
181+
182+
describe('claim action', () => {
183+
it('calls claimRewards on claim button press', () => {
184+
const { getByTestId } = renderWithProvider(
185+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
186+
);
187+
188+
fireEvent.press(
189+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON),
190+
);
191+
192+
expect(mockClaimRewards).toHaveBeenCalledTimes(1);
193+
});
194+
195+
it('fires MUSD_CLAIM_BONUS_BUTTON_CLICKED with correct properties on claim press', () => {
196+
const asset = createMockAsset();
197+
198+
const { getByTestId } = renderWithProvider(
199+
<AssetOverviewClaimBonus asset={asset} />,
200+
);
201+
202+
fireEvent.press(
203+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON),
204+
);
205+
206+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
207+
MetaMetricsEvents.MUSD_CLAIM_BONUS_BUTTON_CLICKED,
208+
);
209+
expect(mockAddProperties).toHaveBeenCalledWith(
210+
expect.objectContaining({
211+
location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.ASSET_OVERVIEW,
212+
action_type: 'claim_bonus',
213+
network_chain_id: asset.chainId,
214+
asset_symbol: asset.symbol,
215+
}),
216+
);
217+
expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
218+
});
219+
220+
it('prevents duplicate claim presses via isClaimPressedRef guard', () => {
221+
const { getByTestId } = renderWithProvider(
222+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
223+
);
224+
225+
const button = getByTestId(
226+
ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON,
227+
);
228+
fireEvent.press(button);
229+
fireEvent.press(button);
230+
231+
expect(mockClaimRewards).toHaveBeenCalledTimes(1);
232+
});
233+
});
234+
235+
describe('tooltip / info button', () => {
236+
it('opens tooltip modal with correct content on info button press', () => {
237+
const { getByTestId } = renderWithProvider(
238+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
239+
);
240+
241+
fireEvent.press(
242+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON),
243+
);
244+
245+
expect(mockOpenTooltipModal).toHaveBeenCalledTimes(1);
246+
247+
const [title, , footer, buttonText] = mockOpenTooltipModal.mock.calls[0];
248+
249+
expect(title).toBe('Claimable bonus');
250+
expect(footer).toBeUndefined();
251+
expect(buttonText).toBe('Sounds good');
252+
});
253+
254+
it('fires TOOLTIP_OPENED analytics on info button press', () => {
255+
const { getByTestId } = renderWithProvider(
256+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
257+
);
258+
259+
fireEvent.press(
260+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON),
261+
);
262+
263+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
264+
EVENT_NAME.TOOLTIP_OPENED,
265+
);
266+
expect(mockAddProperties).toHaveBeenCalledWith(
267+
expect.objectContaining({
268+
location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.ASSET_OVERVIEW,
269+
tooltip_name: 'claim_bonus_info',
270+
}),
271+
);
272+
expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
273+
});
274+
});
275+
276+
describe('terms link', () => {
277+
it('opens terms URL and fires analytics when terms link is pressed', () => {
278+
const { getByTestId } = renderWithProvider(
279+
<AssetOverviewClaimBonus asset={createMockAsset()} />,
280+
);
281+
282+
fireEvent.press(
283+
getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON),
284+
);
285+
286+
const tooltipDescription = mockOpenTooltipModal.mock.calls[0][1];
287+
288+
const { getByText } = renderWithProvider(tooltipDescription);
289+
fireEvent.press(getByText('Terms apply.'));
290+
291+
expect(Linking.openURL).toHaveBeenCalledWith(
292+
AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
293+
);
294+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
295+
MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED,
296+
);
297+
});
298+
});
299+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS = {
2+
CONTAINER: 'asset-overview-claim-bonus-container',
3+
INFO_BUTTON: 'asset-overview-claim-bonus-info-button',
4+
CLAIM_BUTTON: 'asset-overview-claim-bonus-claim-button',
5+
CLAIMABLE_AMOUNT: 'asset-overview-claim-bonus-claimable-amount',
6+
};

0 commit comments

Comments
 (0)