Skip to content

Commit c23ca53

Browse files
authored
feat(Rewards): Put Earn Rewards banners in a carousel (#30170)
<!-- 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** This puts the banners in the Earn Rewards section of the Rewards tab into a carousel, so they only take up 1 line of vertical real estate at most, ensuring other content gets more visibility. <!-- 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? --> ## **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: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/RWDS-1300 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <img width="1179" height="2556" alt="IMG_2114" src="https://github.com/user-attachments/assets/ecfc3319-7a3a-445f-ad63-68f5973d101e" /> ### **After** <img width="1170" height="2532" alt="Simulator Screenshot - iPhone 16e - 2026-05-14 at 00 42 18" src="https://github.com/user-attachments/assets/ca5bca85-5160-4b64-9b2d-b8da05de6b5b" /> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate UI/UX change that replaces static cards with a custom snap-offset carousel and gesture-based press suppression, which could introduce layout/tap regressions across screen sizes. > > **Overview** > Converts the `EarnRewardsPreview` banners from stacked full-width cards into a horizontal “peek” carousel using `ScrollView` with explicit `snapToOffsets`, dynamic card sizing via `useWindowDimensions`, and consistent inter-card spacing/padding. > > Preserves existing visibility rules (mUSD card vs skeleton based on geo, Card banner always present) while building the carousel slots dynamically, and disables banner presses only during active drag to prevent accidental taps while swiping. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7888b5d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e6caa4c commit c23ca53

1 file changed

Lines changed: 123 additions & 29 deletions

File tree

app/components/UI/Rewards/components/EarnRewards/EarnRewardsPreview.tsx

Lines changed: 123 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import React, { useCallback } from 'react';
1+
import React, { useCallback, useMemo, useState } from 'react';
22
import {
33
ActivityIndicator,
44
Image,
55
ImageSourcePropType,
66
Pressable,
7+
ScrollView,
78
StyleSheet,
9+
useWindowDimensions,
10+
View,
811
} from 'react-native';
912
import { useNavigation } from '@react-navigation/native';
1013
import { useSelector } from 'react-redux';
@@ -36,9 +39,20 @@ import cardImage from '../../../../../images/rewards/rewards-card-earn.png';
3639

3740
const AVATAR_SIZE = 78;
3841
const UK_COUNTRY_CODE = 'GB';
42+
// Horizontal padding matches px-4 (16 px per side). CARD_GAP is the space
43+
// between adjacent carousel slides. PEEK_WIDTH is how many pixels of the
44+
// next card show on the right edge as a swipe affordance.
45+
const HORIZONTAL_PADDING = 16;
46+
const CARD_GAP = 12;
47+
const PEEK_WIDTH = 24;
3948

4049
const styles = StyleSheet.create({
4150
avatar: { width: AVATAR_SIZE, height: AVATAR_SIZE },
51+
cardGap: { marginRight: CARD_GAP },
52+
// Symmetric padding so the first card snaps flush to the left edge (peek on
53+
// the right) and the last card snaps flush to the right edge (peek on the
54+
// left). Snap points are defined explicitly via snapToOffsets.
55+
carouselContent: { paddingHorizontal: HORIZONTAL_PADDING },
4256
});
4357

4458
interface EarnCardProps {
@@ -47,6 +61,12 @@ interface EarnCardProps {
4761
subtitle: string;
4862
onPress: () => void;
4963
testID: string;
64+
/**
65+
* When true the card cannot be pressed and will not show pressed state.
66+
* Used to suppress accidental taps while the user is actively dragging
67+
* the carousel.
68+
*/
69+
disabled?: boolean;
5070
}
5171

5272
const EarnCard: React.FC<EarnCardProps> = ({
@@ -55,12 +75,14 @@ const EarnCard: React.FC<EarnCardProps> = ({
5575
subtitle,
5676
onPress,
5777
testID,
78+
disabled,
5879
}) => {
5980
const tw = useTailwind();
6081

6182
return (
6283
<Pressable
6384
testID={testID}
85+
disabled={disabled}
6486
style={({ pressed }) =>
6587
tw.style(
6688
'rounded-xl bg-muted flex-row items-center p-4 gap-4',
@@ -88,19 +110,32 @@ const EarnCard: React.FC<EarnCardProps> = ({
88110
);
89111
};
90112

113+
type CarouselSlotKey = 'musd-skeleton' | 'musd' | 'card';
114+
91115
/**
92116
* EarnRewardsPreview shows the "Earn rewards" section on the dashboard.
93117
*
94-
* - mUSD calculator card: shown when geoLocation has settled AND is not UK.
95-
* 'UNKNOWN' is treated as non-UK so mUSD is shown. Hidden only when undefined (loading)
96-
* or 'GB' to prevent flash for UK users.
97-
* - MetaMask Card card: shown when card geo is loaded (no country restriction).
98-
* - While geo is loading (status 'idle' or 'loading'): skeletons shown; title always visible
118+
* mUSD calculator card: shown when geoLocation has settled AND is not UK.
119+
* 'UNKNOWN' is treated as non-UK so mUSD is shown. Hidden only when undefined
120+
* (loading) or 'GB' to prevent flash for UK users.
121+
* MetaMask Card card: always shown.
122+
* While geo is loading (status 'idle' or 'loading'): a skeleton occupies the
123+
* mUSD slot; the Card slot is always present.
124+
* Cards are displayed in a horizontal peek carousel that scales to any
125+
* number of items: every card except the last snaps flush-left with the next
126+
* card peeking on the right; the last card snaps flush-right with the
127+
* previous card peeking on the left. No pagination dots. If there are no
128+
* items at all the entire section is not rendered.
99129
*/
100130
const EarnRewardsPreview: React.FC = () => {
101131
const tw = useTailwind();
102132
const navigation = useNavigation();
103133
const { colors } = useTheme();
134+
const { width: screenWidth } = useWindowDimensions();
135+
// Only the left padding consumes viewport space at scroll position 0.
136+
// The right side is filled by CARD_GAP + the next card's peek, so only one
137+
// HORIZONTAL_PADDING is subtracted.
138+
const cardWidth = screenWidth - HORIZONTAL_PADDING - CARD_GAP - PEEK_WIDTH;
104139

105140
// mUSD geo check - hide for UK users, require positive geo confirmation to avoid flash
106141
const geoLocation = useSelector(selectGeolocationLocation);
@@ -109,7 +144,7 @@ const EarnRewardsPreview: React.FC = () => {
109144
const showMusdCard =
110145
geoLocation !== undefined && geoLocation !== UK_COUNTRY_CODE;
111146

112-
// Card check — isCardGeoLoaded flips true when loadCardholderAccounts settles
147+
// Card check — subtitle varies by cardholder status; card is always rendered
113148
const isCardholder = useSelector(selectIsCardholder);
114149
const isAuthenticatedCard = useSelector(selectIsCardAuthenticated);
115150
const cardSubtitle =
@@ -125,15 +160,54 @@ const EarnRewardsPreview: React.FC = () => {
125160
handleDeeplink({ uri: 'metamask://card-onboarding' });
126161
}, []);
127162

163+
// Disable presses only while the user is actively dragging the carousel.
164+
// We intentionally do NOT include momentum-scroll state here so that taps
165+
// during deceleration still register and stop the scroll.
166+
const [isDragging, setIsDragging] = useState(false);
167+
const handleScrollBeginDrag = useCallback(() => setIsDragging(true), []);
168+
const handleScrollEndDrag = useCallback(() => setIsDragging(false), []);
169+
170+
// Build the ordered list of carousel slots, preserving existing visibility logic.
171+
const items: CarouselSlotKey[] = [];
172+
if (isMusdGeoLoading && !showMusdCard) {
173+
items.push('musd-skeleton');
174+
} else if (showMusdCard) {
175+
items.push('musd');
176+
}
177+
items.push('card');
178+
const itemCount = items.length;
179+
180+
// Explicit snap offsets give the first card a flush-left snap (peek on right)
181+
// and the last card a flush-right snap (peek on left). With symmetric
182+
// padding, the trailing edge is at:
183+
// contentWidth = 2 * HORIZONTAL_PADDING
184+
// + itemCount * cardWidth
185+
// + (itemCount - 1) * CARD_GAP
186+
// and maxScroll = contentWidth - screenWidth.
187+
const snapOffsets = useMemo(() => {
188+
const contentWidth =
189+
HORIZONTAL_PADDING * 2 +
190+
itemCount * cardWidth +
191+
Math.max(0, itemCount - 1) * CARD_GAP;
192+
const maxScroll = Math.max(0, contentWidth - screenWidth);
193+
return Array.from({ length: itemCount }, (_, i) =>
194+
i === itemCount - 1 ? maxScroll : i * (cardWidth + CARD_GAP),
195+
);
196+
}, [itemCount, cardWidth, screenWidth]);
197+
198+
if (itemCount === 0) return null;
199+
200+
const cardStyle = { width: cardWidth };
201+
128202
return (
129203
<Box
130-
twClassName="gap-3 px-4 pb-3"
204+
twClassName="gap-3 pb-3"
131205
testID={REWARDS_VIEW_SELECTORS.EARN_REWARDS_PREVIEW}
132206
>
133207
<Box
134208
flexDirection={BoxFlexDirection.Row}
135209
alignItems={BoxAlignItems.Center}
136-
twClassName="gap-2"
210+
twClassName="gap-2 px-4"
137211
>
138212
{isMusdGeoLoading && (
139213
<ActivityIndicator size="small" color={colors.primary.default} />
@@ -143,26 +217,46 @@ const EarnRewardsPreview: React.FC = () => {
143217
</Text>
144218
</Box>
145219

146-
{isMusdGeoLoading && !showMusdCard ? (
147-
<Skeleton style={tw.style('h-28 rounded-xl')} />
148-
) : (
149-
showMusdCard && (
150-
<EarnCard
151-
testID={REWARDS_VIEW_SELECTORS.EARN_REWARDS_MUSD_CARD}
152-
image={musdImage}
153-
title={strings('rewards.earn_rewards.musd_title')}
154-
subtitle={strings('rewards.earn_rewards.musd_subtitle')}
155-
onPress={handleMusdPress}
156-
/>
157-
)
158-
)}
159-
<EarnCard
160-
testID={REWARDS_VIEW_SELECTORS.EARN_REWARDS_CARD_CARD}
161-
image={cardImage}
162-
title={strings('rewards.earn_rewards.card_title')}
163-
subtitle={cardSubtitle}
164-
onPress={handleCardPress}
165-
/>
220+
<ScrollView
221+
horizontal
222+
showsHorizontalScrollIndicator={false}
223+
decelerationRate="fast"
224+
snapToOffsets={snapOffsets}
225+
onScrollBeginDrag={handleScrollBeginDrag}
226+
onScrollEndDrag={handleScrollEndDrag}
227+
contentContainerStyle={styles.carouselContent}
228+
>
229+
{items.map((item, index) => (
230+
<View
231+
key={item}
232+
style={[cardStyle, index < items.length - 1 && styles.cardGap]}
233+
>
234+
{item === 'musd-skeleton' && (
235+
<Skeleton style={tw.style('h-28 rounded-xl')} />
236+
)}
237+
{item === 'musd' && (
238+
<EarnCard
239+
testID={REWARDS_VIEW_SELECTORS.EARN_REWARDS_MUSD_CARD}
240+
image={musdImage}
241+
title={strings('rewards.earn_rewards.musd_title')}
242+
subtitle={strings('rewards.earn_rewards.musd_subtitle')}
243+
onPress={handleMusdPress}
244+
disabled={isDragging}
245+
/>
246+
)}
247+
{item === 'card' && (
248+
<EarnCard
249+
testID={REWARDS_VIEW_SELECTORS.EARN_REWARDS_CARD_CARD}
250+
image={cardImage}
251+
title={strings('rewards.earn_rewards.card_title')}
252+
subtitle={cardSubtitle}
253+
onPress={handleCardPress}
254+
disabled={isDragging}
255+
/>
256+
)}
257+
</View>
258+
))}
259+
</ScrollView>
166260
</Box>
167261
);
168262
};

0 commit comments

Comments
 (0)