Skip to content

Commit f8af913

Browse files
chore(runway): cherry-pick feat(rewards): benefits preview uses Tag for available count cp-7.78.0 (#30211)
- feat(rewards): benefits preview uses Tag for available count (#30196) <!-- 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** > **Section header spacing alignment:** [`CampaignsPreview.tsx`](app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx) — the campaigns preview **header row** now uses **`gap-1`** instead of **`gap-2`** between the title row items (spinner when shown, heading, chevron), matching the **Benefits** preview header spacing on the Rewards dashboard. The Rewards dashboard **Benefits** preview header previously used a numeric `BadgeCount` beside the section title. This change replaces it with a design-system **`Tag`** (`TagSeverity.Neutral`) that shows how many benefits are available using the localized string `rewards.benefits.available_count` (e.g. `%{count} available` in English). Counts above 99 display as `99+`. The header row is full width with **`justifyContent: space-between`**: the title and chevron stay on the leading side; the tag sits on the **trailing** edge so it matches the intended layout. Spacing uses `gap-1` between the title and chevron. The empty-state header no longer renders a redundant null badge slot. **Motivation:** Align with design (muted pill copy and alignment) and keep copy in i18n for the translation pipeline (English source string only in `en.json`). **Automated tests:** `yarn jest app/components/UI/Rewards/components/Benefits/BenefitsPreview.test.tsx` ## **Changelog** CHANGELOG entry: Updated the Rewards benefits preview header to show how many benefits are available using a tag label next to the section title; aligned campaigns preview section header spacing with the benefits preview (`gap-1`). ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Rewards benefits preview header Scenario: User sees benefits count when they have benefits Given the user is opted into Rewards and the benefits API returns at least one benefit When user opens the Rewards dashboard (home) and scrolls to the Benefits preview Then a neutral tag shows the correct count and available wording for the current locale And the tag is aligned to the far right of the header row with the title and chevron grouped on the left And tapping the header row still navigates to the full benefits view Scenario: User has no benefits Given the user has no benefits in the list When user opens the Rewards dashboard and views the Benefits preview Then the count tag is not shown and the empty state behaves as before Feature: Rewards campaigns preview header spacing Scenario: User compares section headers on Rewards home Given the user is on the Rewards dashboard home tab When user views the Campaigns preview section header Then the title row uses gap-1 between the title row elements, matching the Benefits preview header spacing ``` ## **Screenshots/Recordings** ### **Before** Numeric badge style (`BadgeCount`) adjacent to the Benefits title (previous implementation). ### **After** <img width="413" height="827" alt="image" src="https://github.com/user-attachments/assets/bd00ee97-2ae3-4ccb-b924-51ba6d0ca677" /> Neutral `Tag` with “{count} available” (per locale), title + chevron on the left, tag aligned to the trailing edge. (Screenshot also attached in an earlier PR update.) ## **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] > **Low Risk** > Low risk UI/i18n change with unit test updates; primary risk is minor layout/regression in rewards preview headers and count formatting around the 99/99+ boundary. > > **Overview** > The Benefits preview header now replaces the numeric `BadgeCount` with a neutral design-system `Tag` that shows a localized `rewards.benefits.available_count` label (capped to `99+`), and adjusts header layout to keep the title+chevron grouped left with the tag right-aligned. > > Campaigns preview header spacing is tightened (`gap-1`), and tests were expanded to cover the new benefits count behavior (including empty state and 99/99+ cases) plus treating `undefined` campaigns as an empty list. Adds the new English i18n key `rewards.benefits.available_count`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5ece706. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [f8f126d](f8f126d) Co-authored-by: Andrew Cohen <imandrewcohen@gmail.com>
1 parent 71d5a1c commit f8af913

5 files changed

Lines changed: 140 additions & 23 deletions

File tree

app/components/UI/Rewards/components/Benefits/BenefitsPreview.test.tsx

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ const mockNavigate = jest.fn();
1212
const mockUseSelector = jest.fn();
1313
const mockUseBenefits = jest.fn();
1414

15-
const mockStrings = jest.fn((key: string) => {
15+
const mockStrings = jest.fn((key: string, params?: Record<string, unknown>) => {
16+
if (
17+
key === 'rewards.benefits.available_count' &&
18+
params &&
19+
typeof params.count === 'string'
20+
) {
21+
return `${params.count} available`;
22+
}
1623
const translations: Record<string, string> = {
1724
'rewards.benefits.title': 'Benefits',
1825
'rewards.benefits.empty-list': 'No benefits available yet',
@@ -42,7 +49,8 @@ jest.mock('../../hooks/useBenefits', () => ({
4249
}));
4350

4451
jest.mock('../../../../../../locales/i18n', () => ({
45-
strings: (key: string) => mockStrings(key),
52+
strings: (key: string, params?: Record<string, unknown>) =>
53+
mockStrings(key, params),
4654
}));
4755

4856
jest.mock('@metamask/design-system-twrnc-preset', () => {
@@ -126,7 +134,10 @@ describe('BenefitsPreview', () => {
126134
it('requests rewards benefits title copy from i18n', () => {
127135
render(<BenefitsPreview />);
128136

129-
expect(mockStrings).toHaveBeenCalledWith('rewards.benefits.title');
137+
expect(mockStrings).toHaveBeenCalledWith(
138+
'rewards.benefits.title',
139+
undefined,
140+
);
130141
});
131142

132143
it('reads subscription benefits and loading state from the store', () => {
@@ -169,7 +180,20 @@ describe('BenefitsPreview', () => {
169180

170181
expect(getByTestId('benefit-empty-list')).toBeOnTheScreen();
171182
expect(getByText('No benefits available yet')).toBeOnTheScreen();
172-
expect(mockStrings).toHaveBeenCalledWith('rewards.benefits.empty-list');
183+
expect(mockStrings).toHaveBeenCalledWith(
184+
'rewards.benefits.empty-list',
185+
undefined,
186+
);
187+
});
188+
189+
it('does not request available_count copy when there are no benefits', () => {
190+
render(<BenefitsPreview />);
191+
192+
const availableCountCalls = mockStrings.mock.calls.filter(
193+
(call) => call[0] === 'rewards.benefits.available_count',
194+
);
195+
196+
expect(availableCountCalls).toHaveLength(0);
173197
});
174198

175199
it('does not render benefit details container without benefits', () => {
@@ -225,6 +249,70 @@ describe('BenefitsPreview', () => {
225249
Routes.REWARD_BENEFITS_FULL_VIEW,
226250
);
227251
});
252+
253+
it('shows available benefits count in the header tag', () => {
254+
const { getByText } = render(<BenefitsPreview />);
255+
256+
expect(getByText('2 available')).toBeOnTheScreen();
257+
expect(mockStrings).toHaveBeenCalledWith(
258+
'rewards.benefits.available_count',
259+
{
260+
count: '2',
261+
},
262+
);
263+
});
264+
265+
it('caps displayed benefits count at 99+ in the header tag', () => {
266+
mockBenefits = Array.from({ length: 100 }, (_, index) => ({
267+
id: index + 1,
268+
longTitle: `Benefit ${index + 1}`,
269+
shortDescription: 'd',
270+
}));
271+
272+
const { getByText } = render(<BenefitsPreview />);
273+
274+
expect(getByText('99+ available')).toBeOnTheScreen();
275+
expect(mockStrings).toHaveBeenCalledWith(
276+
'rewards.benefits.available_count',
277+
{
278+
count: '99+',
279+
},
280+
);
281+
});
282+
283+
it('displays numeric count 99 in the header tag when there are exactly 99 benefits', () => {
284+
mockBenefits = Array.from({ length: 99 }, (_, index) => ({
285+
id: index + 1,
286+
longTitle: `Benefit ${index + 1}`,
287+
shortDescription: 'd',
288+
}));
289+
290+
const { getByText } = render(<BenefitsPreview />);
291+
292+
expect(getByText('99 available')).toBeOnTheScreen();
293+
expect(mockStrings).toHaveBeenCalledWith(
294+
'rewards.benefits.available_count',
295+
{
296+
count: '99',
297+
},
298+
);
299+
});
300+
301+
it('renders a single benefit card when the list has one item', () => {
302+
mockBenefits = [
303+
{ id: 42, longTitle: 'Solo benefit', shortDescription: 'only one' },
304+
];
305+
306+
const { getByTestId, getByText, queryByTestId } = render(
307+
<BenefitsPreview />,
308+
);
309+
310+
expect(
311+
getByTestId(REWARDS_VIEW_SELECTORS.TOP_BENEFIT_DETAILS),
312+
).toBeOnTheScreen();
313+
expect(getByText('Solo benefit')).toBeOnTheScreen();
314+
expect(queryByTestId('benefit-card-2')).toBeNull();
315+
});
228316
});
229317

230318
describe('header without benefits', () => {

app/components/UI/Rewards/components/Benefits/BenefitsPreview.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import {
2-
BadgeCount,
3-
BadgeCountSize,
42
Box,
53
BoxAlignItems,
6-
BoxBackgroundColor,
74
BoxFlexDirection,
5+
BoxJustifyContent,
86
Icon,
7+
IconColor,
98
IconName,
109
IconSize,
1110
Skeleton,
11+
Tag,
12+
TagSeverity,
1213
Text,
1314
TextColor,
1415
TextVariant,
@@ -41,41 +42,54 @@ const BenefitsPreview = () => {
4142
const hasBenefits = benefits.length > 0;
4243
const topBenefits = benefits.slice(0, 3);
4344

45+
const benefitsCountLabel =
46+
benefits.length > 99 ? '99+' : String(benefits.length);
47+
4448
const benefitsCountBadge =
4549
benefits.length > 0 ? (
46-
<BadgeCount
47-
count={benefits.length}
48-
max={99}
49-
size={BadgeCountSize.Lg}
50-
twClassName={`${BoxBackgroundColor.BackgroundMuted} min-w-6 h-6`}
51-
textProps={{ color: TextColor.TextDefault }}
52-
/>
50+
<Tag severity={TagSeverity.Neutral}>
51+
<Text variant={TextVariant.BodySm} color={TextColor.TextAlternative}>
52+
{strings('rewards.benefits.available_count', {
53+
count: benefitsCountLabel,
54+
})}
55+
</Text>
56+
</Tag>
5357
) : null;
5458

5559
const displayHeader = hasBenefits ? (
5660
<Pressable onPress={handleNavigateToBenefitsFullView}>
5761
<Box
5862
flexDirection={BoxFlexDirection.Row}
5963
alignItems={BoxAlignItems.Center}
60-
twClassName="gap-2"
64+
justifyContent={BoxJustifyContent.Between}
65+
twClassName="w-full"
6166
>
62-
<Text variant={TextVariant.HeadingMd}>
63-
{strings('rewards.benefits.title')}
64-
</Text>
67+
<Box
68+
flexDirection={BoxFlexDirection.Row}
69+
alignItems={BoxAlignItems.Center}
70+
twClassName="gap-1"
71+
>
72+
<Text variant={TextVariant.HeadingMd}>
73+
{strings('rewards.benefits.title')}
74+
</Text>
75+
<Icon
76+
name={IconName.ArrowRight}
77+
size={IconSize.Md}
78+
color={IconColor.IconAlternative}
79+
/>
80+
</Box>
6581
{benefitsCountBadge}
66-
<Icon name={IconName.ArrowRight} size={IconSize.Md} />
6782
</Box>
6883
</Pressable>
6984
) : (
7085
<Box
7186
flexDirection={BoxFlexDirection.Row}
7287
alignItems={BoxAlignItems.Center}
73-
twClassName="gap-2"
88+
twClassName="gap-1"
7489
>
7590
<Text variant={TextVariant.HeadingMd}>
7691
{strings('rewards.benefits.title')}
7792
</Text>
78-
{benefitsCountBadge}
7993
</Box>
8094
);
8195

app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,20 @@ describe('CampaignsPreview', () => {
129129
expect(queryByTestId('campaign-tile-campaign-1')).toBeNull();
130130
});
131131

132+
it('treats undefined campaigns as an empty list for featured selection', () => {
133+
mockUseRewardCampaigns.mockReturnValue({
134+
...mockHookDefaults,
135+
campaigns: undefined as unknown as CampaignDto[],
136+
});
137+
138+
const { getByTestId, queryByTestId } = render(<CampaignsPreview />);
139+
140+
expect(
141+
getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW),
142+
).toBeOnTheScreen();
143+
expect(queryByTestId('campaign-tile-campaign-1')).toBeNull();
144+
});
145+
132146
it('renders loading skeleton when campaigns have never been loaded', () => {
133147
mockUseRewardCampaigns.mockReturnValue({
134148
...mockHookDefaults,

app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const CampaignsPreview: React.FC = () => {
5757
<Box
5858
flexDirection={BoxFlexDirection.Row}
5959
alignItems={BoxAlignItems.Center}
60-
twClassName="gap-2"
60+
twClassName="gap-1"
6161
>
6262
{(isLoading || !hasLoaded) && !hasFeaturedCampaigns && (
6363
<ActivityIndicator size="small" color={colors.primary.default} />

locales/languages/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8592,7 +8592,8 @@
85928592
"title_claim": "Claim Benefit",
85938593
"action": "Claim",
85948594
"empty-list": "You don’t have any benefits right now.",
8595-
"powered_by": "Powered by"
8595+
"powered_by": "Powered by",
8596+
"available_count": "{{count}} available"
85968597
},
85978598
"end_of_season_rewards": {
85988599
"confirm_label_default": "Confirm",

0 commit comments

Comments
 (0)