Skip to content

Commit 41c11a5

Browse files
committed
Contentful Integration
1 parent 9146a10 commit 41c11a5

File tree

8 files changed

+435
-44
lines changed

8 files changed

+435
-44
lines changed

.js.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export MM_SECURITY_ALERTS_API_ENABLED="true"
8686
export GOOGLE_SERVICES_B64_ANDROID=""
8787
export GOOGLE_SERVICES_B64_IOS=""
8888
# Notifications Feature Announcements
89+
# These are Contentful variables used to fetch feature announcements
8990
export FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN=
9091
export FEATURES_ANNOUNCEMENTS_SPACE_ID=
9192

app/components/UI/Carousel/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export const PREDEFINED_SLIDES: CarouselSlide[] = [
101101
},
102102
];
103103

104-
export const BANNER_IMAGES: Record<SlideId, ImageSourcePropType> = {
104+
export const BANNER_IMAGES: Partial<Record<SlideId, ImageSourcePropType>> = {
105105
card: cardImage,
106106
fund: fundImage,
107107
cashout: cashoutImage,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { createClient, type Entry, type EntrySkeletonType } from 'contentful';
2+
import { CarouselSlide } from './types';
3+
import { isProduction } from '../../../util/environment';
4+
5+
export interface ContentfulCarouselSlideFields {
6+
headline: string;
7+
teaser: string;
8+
image: string;
9+
linkUrl: string;
10+
undismissable: boolean;
11+
startDate?: string;
12+
endDate?: string;
13+
}
14+
15+
export type ContentfulSlideSkeleton =
16+
EntrySkeletonType<ContentfulCarouselSlideFields>;
17+
18+
const space = process.env.FEATURES_ANNOUNCEMENTS_SPACE_ID;
19+
const accessToken = process.env.FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN;
20+
const environment = isProduction() ? 'master' : 'dev';
21+
const contentType = 'promotionalBanner';
22+
const defaultDomain = isProduction()
23+
? 'cdn.contentful.com'
24+
: 'preview.contentful.com';
25+
const host = `https://${defaultDomain}/spaces/${space}/environments/${environment}/entries`;
26+
27+
if (!space || !accessToken) {
28+
throw new Error(
29+
'Missing Contentful environment variables: CONTENTFUL_SPACE_ID or CONTENTFUL_ACCESS_TOKEN',
30+
);
31+
}
32+
33+
const contentfulClient = createClient({
34+
space,
35+
accessToken,
36+
environment,
37+
host,
38+
});
39+
40+
interface ContentfulSysField {
41+
sys: { id: string };
42+
}
43+
44+
export async function fetchCarouselSlidesFromContentful(): Promise<
45+
CarouselSlide[]
46+
> {
47+
const entries = await contentfulClient.getEntries({
48+
content_type: contentType,
49+
'fields.showInMobile': true, // Only banners marked for mobile
50+
});
51+
const assets = (entries.includes?.Asset ?? []) as any[];
52+
const resolveImage = (imageRef: ContentfulSysField) => {
53+
const asset = assets.find((a) => a.sys.id === imageRef?.sys?.id);
54+
const rawUrl = asset?.fields?.file?.url || '';
55+
return rawUrl.startsWith('//') ? `https:${rawUrl}` : rawUrl;
56+
};
57+
58+
return entries.items.map((entry) => {
59+
const {
60+
headline,
61+
teaser,
62+
image,
63+
linkUrl,
64+
undismissable,
65+
startDate,
66+
endDate,
67+
} = entry.fields;
68+
69+
return {
70+
id: `contentful-${entry.sys.id}`,
71+
title: headline,
72+
description: teaser,
73+
navigation: {
74+
type: 'url',
75+
href: linkUrl,
76+
},
77+
image: resolveImage(image as unknown as ContentfulSysField),
78+
undismissable: undismissable ?? false,
79+
testID: `carousel_slide_${entry.sys.id}`,
80+
testIDTitle: `carousel_slide_title_${entry.sys.id}`,
81+
testIDCloseButton: `carousel_slide_close_${entry.sys.id}`,
82+
startDate: startDate,
83+
endDate: endDate,
84+
};
85+
});
86+
}
87+
88+
export function isActive(
89+
slide: { startDate?: string; endDate?: string },
90+
now = new Date(),
91+
): boolean {
92+
const start = slide.startDate ? new Date(slide.startDate) : null;
93+
const end = slide.endDate ? new Date(slide.endDate) : null;
94+
if (start && now < start) return false;
95+
if (end && now > end) return false;
96+
return true;
97+
}

app/components/UI/Carousel/index.tsx

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,21 @@ import { SolAccountType } from '@metamask/keyring-api';
3131
import Engine from '../../../core/Engine';
3232
///: END:ONLY_INCLUDE_IF
3333
import { selectAddressHasTokenBalances } from '../../../selectors/tokenBalancesController';
34+
import {
35+
fetchCarouselSlidesFromContentful,
36+
isActive,
37+
} from './fetchCarouselSlidesFromContentful';
38+
import { selectContentfulCarouselEnabledFlag } from './selectors/featureFlags';
39+
40+
const MAX_CAROUSEL_SLIDES = 15;
3441

3542
const CarouselComponent: FC<CarouselProps> = ({ style }) => {
3643
const [selectedIndex, setSelectedIndex] = useState(0);
3744
const [pressedSlideId, setPressedSlideId] = useState<string | null>(null);
45+
const [fetchedSlides, setFetchedSlides] = useState<CarouselSlide[]>([]);
46+
const isContentfulCarouselEnabled = useSelector(
47+
selectContentfulCarouselEnabledFlag,
48+
);
3849
const { trackEvent, createEventBuilder } = useMetrics();
3950
const hasBalance = useSelector(selectAddressHasTokenBalances);
4051
const { colors } = useTheme();
@@ -51,49 +62,67 @@ const CarouselComponent: FC<CarouselProps> = ({ style }) => {
5162

5263
const isZeroBalance = !hasBalance;
5364

54-
const slidesConfig = useMemo(
55-
() =>
56-
PREDEFINED_SLIDES.map((slide) => {
57-
if (slide.id === 'fund' && isZeroBalance) {
58-
return {
59-
...slide,
60-
undismissable: true,
61-
};
62-
}
65+
// Fetch slides from Contentful
66+
useEffect(() => {
67+
const loadContentfulSlides = async () => {
68+
if (!isContentfulCarouselEnabled) return;
69+
try {
70+
const remoteSlides = await fetchCarouselSlidesFromContentful();
71+
const activeSlides = remoteSlides.filter((slide) => isActive(slide));
72+
setFetchedSlides(activeSlides);
73+
} catch (err) {
74+
console.warn('Failed to fetch Contentful slides:', err);
75+
}
76+
};
77+
loadContentfulSlides();
78+
}, [isContentfulCarouselEnabled]);
79+
80+
// Merge all slides (predefined + contentful),
81+
const slidesConfig = useMemo(() => {
82+
const baseSlides = [...PREDEFINED_SLIDES, ...fetchedSlides];
83+
return baseSlides.map((slide) => {
84+
if (slide.id === 'fund' && isZeroBalance) {
6385
return {
6486
...slide,
65-
undismissable: false,
87+
undismissable: true,
6688
};
67-
}),
68-
[isZeroBalance],
69-
);
89+
}
90+
return {
91+
...slide,
92+
undismissable: false,
93+
};
94+
});
95+
}, [isZeroBalance, fetchedSlides]);
7096

71-
const visibleSlides = useMemo(
72-
() =>
73-
slidesConfig.filter((slide) => {
74-
///: BEGIN:ONLY_INCLUDE_IF(solana)
75-
if (
76-
slide.id === 'solana' &&
77-
selectedAccount?.type === SolAccountType.DataAccount
78-
) {
79-
return false;
80-
}
81-
///: END:ONLY_INCLUDE_IF
97+
const visibleSlides = useMemo(() => {
98+
const filtered = slidesConfig.filter((slide: CarouselSlide) => {
99+
const isCurrentlyActive = isActive(slide);
82100

83-
if (slide.id === 'fund' && isZeroBalance) {
84-
return true;
85-
}
86-
return !dismissedBanners.includes(slide.id);
87-
}),
88-
[
89-
slidesConfig,
90-
isZeroBalance,
91-
dismissedBanners,
92101
///: BEGIN:ONLY_INCLUDE_IF(solana)
93-
selectedAccount,
102+
if (
103+
slide.id === 'solana' &&
104+
selectedAccount?.type === SolAccountType.DataAccount
105+
) {
106+
return false;
107+
}
94108
///: END:ONLY_INCLUDE_IF
95-
],
96-
);
109+
110+
if (slide.id === 'fund' && isZeroBalance) {
111+
return true;
112+
}
113+
114+
return isCurrentlyActive && !dismissedBanners.includes(slide.id);
115+
});
116+
return filtered.slice(0, MAX_CAROUSEL_SLIDES);
117+
}, [
118+
slidesConfig,
119+
isZeroBalance,
120+
dismissedBanners,
121+
///: BEGIN:ONLY_INCLUDE_IF(solana)
122+
selectedAccount,
123+
///: END:ONLY_INCLUDE_IF
124+
]);
125+
97126
const isSingleSlide = visibleSlides.length === 1;
98127

99128
const openUrl =
@@ -177,7 +206,11 @@ const CarouselComponent: FC<CarouselProps> = ({ style }) => {
177206
<View style={styles.slideContent}>
178207
<View style={styles.imageContainer}>
179208
<Image
180-
source={BANNER_IMAGES[slide.id]}
209+
source={
210+
slide.id.startsWith('contentful-')
211+
? { uri: slide.image }
212+
: BANNER_IMAGES[slide.id]
213+
}
181214
style={styles.bannerImage}
182215
resizeMode="contain"
183216
/>
@@ -219,7 +252,7 @@ const CarouselComponent: FC<CarouselProps> = ({ style }) => {
219252

220253
// Track banner display events when visible slides change
221254
useEffect(() => {
222-
visibleSlides.forEach((slide) => {
255+
visibleSlides.forEach((slide: CarouselSlide) => {
223256
trackEvent(
224257
createEventBuilder({
225258
category: 'Banner Display',
@@ -237,7 +270,7 @@ const CarouselComponent: FC<CarouselProps> = ({ style }) => {
237270
testID={WalletViewSelectorsIDs.CAROUSEL_PROGRESS_DOTS}
238271
style={styles.progressContainer}
239272
>
240-
{visibleSlides.map((slide, index) => (
273+
{visibleSlides.map((slide: CarouselSlide, index: number) => (
241274
<View
242275
key={slide.id}
243276
style={[
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createSelector } from 'reselect';
2+
import { getVersion } from 'react-native-device-info';
3+
import compareVersions from 'compare-versions';
4+
import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController';
5+
import { isProduction } from '../../../../../util/environment';
6+
7+
export interface LaunchDarklyFlag {
8+
enabled: boolean;
9+
minimumVersion: string;
10+
}
11+
12+
const hasMinimumRequiredVersion = (minRequiredVersion: string) => {
13+
if (!minRequiredVersion) return false;
14+
const currentVersion = getVersion();
15+
return compareVersions.compare(currentVersion, minRequiredVersion, '>=');
16+
};
17+
18+
const resolveFlag = (localFlag: boolean, remoteFlag: LaunchDarklyFlag) => {
19+
if (isProduction()) {
20+
return (
21+
Boolean(remoteFlag?.enabled) &&
22+
hasMinimumRequiredVersion(remoteFlag?.minimumVersion)
23+
);
24+
}
25+
return (
26+
localFlag ??
27+
(Boolean(remoteFlag?.enabled) &&
28+
hasMinimumRequiredVersion(remoteFlag?.minimumVersion))
29+
);
30+
};
31+
32+
export const selectContentfulCarouselEnabledFlag = createSelector(
33+
selectRemoteFeatureFlags,
34+
(remoteFlags): boolean => {
35+
const localFlag = process.env.MM_CONTENTFUL_CAROUSEL_ENABLED === 'true';
36+
const remoteFlag =
37+
remoteFlags?.contentfulCarouselEnabled as unknown as LaunchDarklyFlag;
38+
39+
return resolveFlag(localFlag, remoteFlag);
40+
},
41+
);

app/components/UI/Carousel/types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export type SlideId =
99
| 'aggregated'
1010
| 'multisrp'
1111
| 'backupAndSync'
12-
| 'solana';
12+
| 'solana'
13+
| `contentful-${string}`;
1314

1415
interface NavigationParams {
1516
address?: string;
@@ -52,10 +53,16 @@ export interface CarouselSlide {
5253
id: SlideId;
5354
title: string;
5455
description: string;
55-
image?: string;
56+
image: string;
5657
navigation: NavigationAction;
5758
dismissed?: boolean;
5859
undismissable?: boolean;
60+
href?: string;
61+
startDate?: string;
62+
endDate?: string;
63+
testID?: string;
64+
testIDTitle?: string;
65+
testIDCloseButton?: string;
5966
}
6067

6168
export interface CarouselProps {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@
288288
"cockatiel": "^3.1.2",
289289
"compare-versions": "^3.6.0",
290290
"content-hash": "2.5.2",
291+
"contentful": "^11.5.22",
291292
"cross-spawn": "7.0.6",
292293
"crypto-js": "^4.2.0",
293294
"d3-shape": "^3.2.0",

0 commit comments

Comments
 (0)