Skip to content

Commit 7176f71

Browse files
authored
feat: render rich text from contentful (#27658)
<!-- 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** Render contentful rich text <!-- 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: ## **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** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-03-18 at 17 27 27" src="https://github.com/user-attachments/assets/2082d21c-3ba2-4a0c-ae9e-c1f2b74c273e" /> <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-03-18 at 17 27 20" src="https://github.com/user-attachments/assets/911e52e2-4404-476a-a7ac-3074776f92e5" /> ## **Pre-merge author checklist** - [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. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Adds a new rich-text renderer and switches rewards screens to render Contentful-provided documents and open links via in-app browser navigation, which may affect UI rendering and link handling for live campaign content. > > **Overview** > Enables Rewards UI to render **Contentful rich text documents** (paragraphs, headings, lists, text marks, and hyperlinks) via a new `ContentfulRichText` component, with hyperlinks opening in the in-app browser. > > Updates `CampaignMechanicsView` to render `howItWorks.notes` only when it is a valid Contentful `document` (removing the previous structured-notes parsing and related testIDs), and updates `CampaignOptInSheet` to prefer `campaign.termsAndConditions` rich text with a static fallback. > > Adds comprehensive unit tests for the new rich-text renderer and adjusts existing tests for the new rendering/navigation behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 70a7760. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ab3db47 commit 7176f71

6 files changed

Lines changed: 715 additions & 129 deletions

File tree

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

Lines changed: 98 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,34 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => {
8888
};
8989
});
9090

91+
jest.mock('../components/ContentfulRichText/ContentfulRichText', () => {
92+
const ReactActual = jest.requireActual('react');
93+
const { View, Text: RNText } = jest.requireActual('react-native');
94+
const isDocumentFn = (value: unknown): boolean =>
95+
value !== null &&
96+
typeof value === 'object' &&
97+
'nodeType' in (value as Record<string, unknown>) &&
98+
(value as Record<string, unknown>).nodeType === 'document' &&
99+
'content' in (value as Record<string, unknown>) &&
100+
Array.isArray((value as Record<string, unknown>).content);
101+
return {
102+
__esModule: true,
103+
isDocument: isDocumentFn,
104+
default: ({
105+
document: doc,
106+
testID,
107+
}: {
108+
document: unknown;
109+
testID?: string;
110+
}) =>
111+
ReactActual.createElement(
112+
View,
113+
{ testID },
114+
ReactActual.createElement(RNText, null, JSON.stringify(doc)),
115+
),
116+
};
117+
});
118+
91119
jest.mock('../hooks/useRewardCampaigns');
92120
const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction<
93121
typeof useRewardCampaigns
@@ -196,7 +224,21 @@ describe('CampaignMechanicsView', () => {
196224
});
197225

198226
describe('notes section', () => {
199-
it('renders notes section when notes has valid shape', () => {
227+
const richTextNotes = {
228+
nodeType: 'document',
229+
data: {},
230+
content: [
231+
{
232+
nodeType: 'paragraph',
233+
data: {},
234+
content: [
235+
{ nodeType: 'text', value: 'Important notes', marks: [], data: {} },
236+
],
237+
},
238+
],
239+
};
240+
241+
it('renders notes section with ContentfulRichText when notes is present', () => {
200242
mockUseRewardCampaigns.mockReturnValue({
201243
...hookDefaults,
202244
campaigns: [
@@ -210,39 +252,16 @@ describe('CampaignMechanicsView', () => {
210252
title: 'How it works',
211253
description: 'Earn rewards',
212254
phases: [],
213-
notes: {
214-
title: 'Important notes',
215-
description: 'Please read carefully',
216-
items: [
217-
{ title: 'Note 1', description: 'Detail 1' },
218-
{ title: 'Note 2', description: 'Detail 2' },
219-
],
220-
},
255+
notes: richTextNotes,
221256
},
222257
},
223258
}),
224259
],
225260
});
226-
const { getByTestId, getByText } = render(<CampaignMechanicsView />);
261+
const { getByTestId } = render(<CampaignMechanicsView />);
227262
expect(
228263
getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION),
229264
).toBeDefined();
230-
expect(
231-
getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_TITLE),
232-
).toHaveTextContent('Important notes');
233-
expect(
234-
getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_DESCRIPTION),
235-
).toHaveTextContent('Please read carefully');
236-
expect(
237-
getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM}-0`),
238-
).toBeDefined();
239-
expect(
240-
getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_TITLE}-0`),
241-
).toHaveTextContent('Note 1');
242-
expect(
243-
getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_DESCRIPTION}-0`),
244-
).toHaveTextContent('Detail 1');
245-
expect(getByText('Note 2')).toBeDefined();
246265
});
247266

248267
it('does not render notes section when notes is null', () => {
@@ -271,7 +290,58 @@ describe('CampaignMechanicsView', () => {
271290
).toBeNull();
272291
});
273292

274-
it('does not render notes section when notes has invalid shape', () => {
293+
it('does not render notes section when howItWorks has no notes field', () => {
294+
mockUseRewardCampaigns.mockReturnValue({
295+
...hookDefaults,
296+
campaigns: [
297+
createTestCampaign({
298+
details: {
299+
image: {
300+
lightModeUrl: 'https://example.com/light.png',
301+
darkModeUrl: 'https://example.com/dark.png',
302+
},
303+
howItWorks: {
304+
title: 'How it works',
305+
description: 'Earn rewards',
306+
phases: [],
307+
},
308+
},
309+
}),
310+
],
311+
});
312+
const { queryByTestId } = render(<CampaignMechanicsView />);
313+
expect(
314+
queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION),
315+
).toBeNull();
316+
});
317+
318+
it('does not render notes section when notes is a non-document object', () => {
319+
mockUseRewardCampaigns.mockReturnValue({
320+
...hookDefaults,
321+
campaigns: [
322+
createTestCampaign({
323+
details: {
324+
image: {
325+
lightModeUrl: 'https://example.com/light.png',
326+
darkModeUrl: 'https://example.com/dark.png',
327+
},
328+
howItWorks: {
329+
title: 'How it works',
330+
description: 'Earn rewards',
331+
phases: [],
332+
notes: { title: 'Only title' },
333+
},
334+
},
335+
}),
336+
],
337+
});
338+
const { queryByTestId } = render(<CampaignMechanicsView />);
339+
expect(
340+
queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION),
341+
).toBeNull();
342+
});
343+
344+
it('does not render notes section when notes is a string', () => {
275345
mockUseRewardCampaigns.mockReturnValue({
276346
...hookDefaults,
277347
campaigns: [
@@ -285,7 +355,7 @@ describe('CampaignMechanicsView', () => {
285355
title: 'How it works',
286356
description: 'Earn rewards',
287357
phases: [],
288-
notes: { title: 'Only title' }, // missing items
358+
notes: 'just a string',
289359
},
290360
},
291361
}),

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

Lines changed: 8 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import React, { useMemo } from 'react';
22
import { ScrollView } from 'react-native';
33
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
4-
import {
5-
Box,
6-
Text,
7-
TextVariant,
8-
TextColor,
9-
FontWeight,
10-
} from '@metamask/design-system-react-native';
4+
import { Box } from '@metamask/design-system-react-native';
115
import { useTailwind } from '@metamask/design-system-twrnc-preset';
126
import { SafeAreaView } from 'react-native-safe-area-context';
137
import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard';
148
import ErrorBoundary from '../../../Views/ErrorBoundary';
159
import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks';
10+
import ContentfulRichText, {
11+
isDocument,
12+
} from '../components/ContentfulRichText/ContentfulRichText';
1613
import { useRewardCampaigns } from '../hooks/useRewardCampaigns';
1714
import { strings } from '../../../../../locales/i18n';
1815

@@ -22,41 +19,10 @@ type CampaignMechanicsRouteParams = {
2219
CampaignMechanics: { campaignId: string };
2320
};
2421

25-
interface CampaignNoteItem {
26-
title: string;
27-
description: string;
28-
}
29-
30-
interface CampaignNotes {
31-
title: string;
32-
description: string;
33-
items: CampaignNoteItem[];
34-
}
35-
36-
function parseCampaignNotes(notes: unknown): CampaignNotes | null {
37-
if (
38-
notes !== null &&
39-
typeof notes === 'object' &&
40-
!Array.isArray(notes) &&
41-
'title' in notes &&
42-
'description' in notes &&
43-
'items' in notes &&
44-
Array.isArray((notes as { items: unknown }).items)
45-
) {
46-
return notes as CampaignNotes;
47-
}
48-
return null;
49-
}
50-
5122
export const CAMPAIGN_MECHANICS_TEST_IDS = {
5223
CONTAINER: 'campaign-mechanics-container',
5324
HOW_IT_WORKS_SECTION: 'campaign-mechanics-how-it-works',
5425
NOTES_SECTION: 'campaign-mechanics-notes',
55-
NOTES_TITLE: 'campaign-mechanics-notes-title',
56-
NOTES_DESCRIPTION: 'campaign-mechanics-notes-description',
57-
NOTE_ITEM: 'campaign-mechanics-note-item',
58-
NOTE_ITEM_TITLE: 'campaign-mechanics-note-item-title',
59-
NOTE_ITEM_DESCRIPTION: 'campaign-mechanics-note-item-description',
6026
} as const;
6127

6228
const CampaignMechanicsView: React.FC = () => {
@@ -73,7 +39,7 @@ const CampaignMechanicsView: React.FC = () => {
7339
);
7440

7541
const howItWorks = campaign?.details?.howItWorks ?? null;
76-
const notes = parseCampaignNotes(howItWorks?.notes);
42+
const notes = howItWorks?.notes ?? null;
7743

7844
return (
7945
<ErrorBoundary navigation={navigation} view="CampaignMechanicsView">
@@ -101,47 +67,12 @@ const CampaignMechanicsView: React.FC = () => {
10167
</Box>
10268
)}
10369

104-
{notes && (
70+
{isDocument(notes) && (
10571
<Box
106-
twClassName="px-4 py-4 gap-3"
72+
twClassName="px-4 py-4"
10773
testID={CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION}
10874
>
109-
<Text
110-
variant={TextVariant.HeadingMd}
111-
fontWeight={FontWeight.Bold}
112-
testID={CAMPAIGN_MECHANICS_TEST_IDS.NOTES_TITLE}
113-
>
114-
{notes.title}
115-
</Text>
116-
<Text
117-
variant={TextVariant.BodyMd}
118-
color={TextColor.TextAlternative}
119-
testID={CAMPAIGN_MECHANICS_TEST_IDS.NOTES_DESCRIPTION}
120-
>
121-
{notes.description}
122-
</Text>
123-
{notes.items.map((item, index) => (
124-
<Box
125-
key={`note-item-${index}`}
126-
twClassName="gap-1"
127-
testID={`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM}-${index}`}
128-
>
129-
<Text
130-
variant={TextVariant.BodyMd}
131-
fontWeight={FontWeight.Bold}
132-
testID={`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_TITLE}-${index}`}
133-
>
134-
{item.title}
135-
</Text>
136-
<Text
137-
variant={TextVariant.BodyMd}
138-
color={TextColor.TextAlternative}
139-
testID={`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_DESCRIPTION}-${index}`}
140-
>
141-
{item.description}
142-
</Text>
143-
</Box>
144-
))}
75+
<ContentfulRichText document={notes} />
14576
</Box>
14677
)}
14778
</ScrollView>

0 commit comments

Comments
 (0)