From 7def51535be3000dabc3973f2bac3e625124bcd6 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Wed, 5 Nov 2025 16:11:35 +0100 Subject: [PATCH 1/5] [NT-1754] Surface merge tag functionality in RN implementation. Break app.tsx into components for lint --- implementations/react-native/App.tsx | 426 +++-------------- .../react-native/MergeTagScreen.tsx | 443 ++++++++++++++++++ .../components/InstructionsCard.tsx | 74 +++ .../react-native/components/LoadingScreen.tsx | 33 ++ .../react-native/components/MainScreen.tsx | 64 +++ .../react-native/components/SDKConfigCard.tsx | 68 +++ .../react-native/components/SDKStatusCard.tsx | 77 +++ implementations/react-native/env.config.ts | 2 + implementations/react-native/types.ts | 48 ++ .../react-native/utils/sdkHelpers.ts | 86 ++++ 10 files changed, 956 insertions(+), 365 deletions(-) create mode 100644 implementations/react-native/MergeTagScreen.tsx create mode 100644 implementations/react-native/components/InstructionsCard.tsx create mode 100644 implementations/react-native/components/LoadingScreen.tsx create mode 100644 implementations/react-native/components/MainScreen.tsx create mode 100644 implementations/react-native/components/SDKConfigCard.tsx create mode 100644 implementations/react-native/components/SDKStatusCard.tsx create mode 100644 implementations/react-native/types.ts create mode 100644 implementations/react-native/utils/sdkHelpers.ts diff --git a/implementations/react-native/App.tsx b/implementations/react-native/App.tsx index 7d0b2069..b96fd0d5 100644 --- a/implementations/react-native/App.tsx +++ b/implementations/react-native/App.tsx @@ -6,268 +6,31 @@ */ import React, { useEffect, useState } from 'react' -import { - ActivityIndicator, - SafeAreaView, - ScrollView, - StatusBar, - StyleSheet, - Text, - TouchableOpacity, - useColorScheme, - View, -} from 'react-native' - -import Optimization, { OptimizationProvider } from '@contentful/optimization-react-native' -import { createClient, type Entry } from 'contentful' -import { ENV_CONFIG } from './env.config' +import { useColorScheme } from 'react-native' + +import type Optimization from '@contentful/optimization-react-native' +import { OptimizationProvider } from '@contentful/optimization-react-native' +import type { Entry } from 'contentful' +import { LoadingScreen } from './components/LoadingScreen' +import { MainScreen } from './components/MainScreen' +import { MergeTagScreen } from './MergeTagScreen' import { TestTrackingScreen } from './TestTrackingScreen' +import type { SDKInfo, ThemeColors } from './types' +import { fetchEntriesFromMockServer, fetchMergeTagEntry, initializeSDK } from './utils/sdkHelpers' -interface SDKInfo { - clientId: string - environment: string - initialized: boolean - timestamp: string -} - -interface ThemeColors { - backgroundColor: string - cardBackground: string - textColor: string - mutedTextColor: string - successColor: string - errorColor: string -} - -interface SDKStatusCardProps { - sdkLoaded: boolean - sdkError: string | null - colors: ThemeColors -} - -interface SDKConfigCardProps { - sdkInfo: SDKInfo - colors: ThemeColors -} - -interface InstructionsCardProps { - colors: ThemeColors - onTestTracking: () => void -} - -interface LoadingScreenProps { - colors: ThemeColors - isDarkMode: boolean -} - -interface MainScreenProps { - colors: ThemeColors - isDarkMode: boolean - sdkLoaded: boolean - sdkError: string | null - sdkInfo: SDKInfo | null - onTestTracking: () => void -} - -function SDKStatusCard({ sdkLoaded, sdkError, colors }: SDKStatusCardProps): React.JSX.Element { - const { cardBackground, textColor, mutedTextColor, successColor, errorColor } = colors - - return ( - - SDK Status - - {!sdkLoaded && !sdkError && ( - - - Initializing SDK... - - )} - - {sdkLoaded && ( - - - - ✓ SDK Loaded Successfully - - - )} - - {sdkError && ( - - - - ✗ Error: {sdkError} - - - )} - - ) -} - -function SDKConfigCard({ sdkInfo, colors }: SDKConfigCardProps): React.JSX.Element { - const { cardBackground, textColor, mutedTextColor } = colors - - return ( - - Configuration - - - Client ID: - - {sdkInfo.clientId} - - - - - Environment: - - {sdkInfo.environment} - - - - - Initialized At: - - {new Date(sdkInfo.timestamp).toLocaleTimeString()} - - - - ) -} - -function InstructionsCard({ colors, onTestTracking }: InstructionsCardProps): React.JSX.Element { - const { cardBackground, textColor, mutedTextColor } = colors - - return ( - - Next Steps - - • The Optimization SDK is now initialized and ready to use{'\n'}• You can now implement - experiences and personalization{'\n'}• Check the console for additional SDK logs{'\n'}• - Modify this app to test SDK features - - - - Test Viewport Tracking → - - - ) -} - -function LoadingScreen({ colors, isDarkMode }: LoadingScreenProps): React.JSX.Element { - return ( - - - - - - Loading entries from mock server... - - - - ) -} - -function MainScreen({ - colors, - isDarkMode, - sdkLoaded, - sdkError, - sdkInfo, - onTestTracking, -}: MainScreenProps): React.JSX.Element { - return ( - - - - {/* Header */} - - - Contentful Optimization - - - React Native SDK Implementation - - - - - - {sdkInfo && } - - - - - ) -} - -// Initialize the Optimization SDK -async function initializeSDK( - setSdkInfo: (info: SDKInfo) => void, - setSdk: (sdk: Optimization) => void, - setSdkLoaded: (loaded: boolean) => void, - setSdkError: (error: string | null) => void, -): Promise { - try { - const { - optimization: { clientId, environment }, - api: { experienceBaseUrl, insightsBaseUrl }, - } = ENV_CONFIG - - const sdkInstance = await Optimization.create({ - clientId, - environment, - api: { - personalization: { baseUrl: experienceBaseUrl }, - analytics: { baseUrl: insightsBaseUrl }, - }, - }) - - setSdkInfo({ - clientId, - environment, - initialized: true, - timestamp: new Date().toISOString(), - }) - setSdk(sdkInstance) - setSdkLoaded(true) - } catch (error) { - setSdkError(error instanceof Error ? error.message : 'Unknown error') +function getThemeColors(isDarkMode: boolean): ThemeColors { + return { + backgroundColor: isDarkMode ? '#1a1a1a' : '#f5f5f5', + cardBackground: isDarkMode ? '#2d2d2d' : '#ffffff', + textColor: isDarkMode ? '#ffffff' : '#000000', + mutedTextColor: isDarkMode ? '#a0a0a0' : '#666666', + successColor: '#22c55e', + errorColor: '#ef4444', + accentColor: '#8b5cf6', } } -// Fetch entries from mock server -async function fetchEntriesFromMockServer( - setPersonalizedEntry: (entry: Entry) => void, - setProductEntry: (entry: Entry) => void, -): Promise { - const { - contentful: { spaceId, environment, accessToken, host, basePath }, - entries: { personalized, product }, - } = ENV_CONFIG - - const contentful = createClient({ - space: spaceId, - environment, - accessToken, - host, - basePath, - insecure: true, - }) - - const [personalizedEntryData, productEntryData] = await Promise.all([ - contentful.getEntry(personalized, { include: 10 }), - contentful.getEntry(product, { include: 10 }), - ]) - - setPersonalizedEntry(personalizedEntryData) - setProductEntry(productEntryData) -} - -// eslint-disable-next-line complexity -- Main app component, complexity is minimal after refactoring +// eslint-disable-next-line complexity -- Main app component requires conditional rendering logic function App(): React.JSX.Element { const isDarkMode = useColorScheme() === 'dark' @@ -276,46 +39,72 @@ function App(): React.JSX.Element { const [sdkInfo, setSdkInfo] = useState(null) const [sdk, setSdk] = useState(null) const [showTestScreen, setShowTestScreen] = useState(false) + const [showMergeTagScreen, setShowMergeTagScreen] = useState(false) const [personalizedEntry, setPersonalizedEntry] = useState(null) const [productEntry, setProductEntry] = useState(null) + const [mergeTagEntry, setMergeTagEntry] = useState(null) const [entriesLoading, setEntriesLoading] = useState(false) useEffect(() => { void initializeSDK(setSdkInfo, setSdk, setSdkLoaded, setSdkError) }, []) - const fetchEntries = async (): Promise => { + const fetchWithErrorHandling = async ( + fetchFn: () => Promise, + errorMsg: string, + ): Promise => { setEntriesLoading(true) try { - await fetchEntriesFromMockServer(setPersonalizedEntry, setProductEntry) + await fetchFn() } catch (error) { - setSdkError( - `Failed to fetch entries: ${error instanceof Error ? error.message : 'Unknown error'}`, - ) + setSdkError(`${errorMsg}: ${error instanceof Error ? error.message : 'Unknown error'}`) } finally { setEntriesLoading(false) } } - const colors: ThemeColors = { - backgroundColor: isDarkMode ? '#1a1a1a' : '#f5f5f5', - cardBackground: isDarkMode ? '#2d2d2d' : '#ffffff', - textColor: isDarkMode ? '#ffffff' : '#000000', - mutedTextColor: isDarkMode ? '#a0a0a0' : '#666666', - successColor: '#22c55e', - errorColor: '#ef4444', + const fetchEntries = async (): Promise => { + await fetchWithErrorHandling(async () => { + await fetchEntriesFromMockServer(setPersonalizedEntry, setProductEntry) + }, 'Failed to fetch entries') } + const fetchMergeTag = async (): Promise => { + await fetchWithErrorHandling(async () => { + await fetchMergeTagEntry(setMergeTagEntry) + }, 'Failed to fetch merge tag entry') + } + + const colors = getThemeColors(isDarkMode) + const handleTestTracking = (): void => { setShowTestScreen(true) void fetchEntries() } + const handleTestMergeTags = (): void => { + setShowMergeTagScreen(true) + void fetchMergeTag() + } + const handleBack = (): void => { setShowTestScreen(false) + setShowMergeTagScreen(false) + } + + if (showMergeTagScreen && sdk && mergeTagEntry) { + return ( + + + + ) } - // Show test screen if requested and data is available if (showTestScreen && sdk && personalizedEntry && productEntry) { return ( @@ -330,12 +119,10 @@ function App(): React.JSX.Element { ) } - // Show loading screen while fetching entries - if (showTestScreen && entriesLoading) { + if ((showTestScreen || showMergeTagScreen) && entriesLoading) { return } - // Main screen return ( ) } -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollContent: { - padding: 20, - paddingTop: 40, - }, - header: { - marginBottom: 30, - }, - title: { - fontSize: 32, - fontWeight: 'bold', - marginBottom: 8, - }, - subtitle: { - fontSize: 16, - fontWeight: '500', - }, - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - cardTitle: { - fontSize: 20, - fontWeight: '600', - marginBottom: 16, - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - statusIndicator: { - width: 12, - height: 12, - borderRadius: 6, - }, - statusText: { - fontSize: 16, - fontWeight: '500', - }, - infoRow: { - marginBottom: 12, - }, - infoLabel: { - fontSize: 14, - fontWeight: '500', - marginBottom: 4, - }, - infoValue: { - fontSize: 16, - fontWeight: '400', - fontFamily: 'monospace', - }, - instructionText: { - fontSize: 14, - lineHeight: 22, - }, - testButton: { - marginTop: 20, - padding: 16, - borderRadius: 8, - alignItems: 'center', - }, - testButtonText: { - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - loadingText: { - marginTop: 16, - fontSize: 16, - }, -}) - export default App diff --git a/implementations/react-native/MergeTagScreen.tsx b/implementations/react-native/MergeTagScreen.tsx new file mode 100644 index 00000000..a7735cee --- /dev/null +++ b/implementations/react-native/MergeTagScreen.tsx @@ -0,0 +1,443 @@ +/** + * Merge Tag Test Screen - Demonstrates merge tag resolution functionality + * + * This screen shows how merge tags embedded in Contentful rich text fields + * are resolved using the current user profile data. + */ + +import React, { useEffect, useState } from 'react' +import { + SafeAreaView, + ScrollView, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + useColorScheme, + View, +} from 'react-native' + +import type Optimization from '@contentful/optimization-react-native' +import type { MergeTagEntry, Profile } from '@contentful/optimization-react-native' +import type { Entry } from 'contentful' + +interface ThemeColors { + backgroundColor: string + cardBackground: string + textColor: string + mutedTextColor: string + successColor: string + errorColor: string + accentColor: string +} + +interface MergeTagScreenProps { + colors: ThemeColors + onBack: () => void + sdk: Optimization + mergeTagEntry: Entry +} + +interface RichTextNode { + nodeType: string + data?: { + target?: { + sys?: { + id?: string + type?: string + linkType?: string + } + } + } + content?: RichTextNode[] +} + +interface RichTextField { + nodeType: string + content?: RichTextNode[] +} + +interface EmbeddedEntryNode { + nodeType: string + data: { + target: { + sys: { + id: string + type: string + linkType: string + } + } + } +} + +function findMergeTagEntries( + fragment: RichTextField | RichTextNode, + mergeTagEntries: EmbeddedEntryNode[] = [], +): EmbeddedEntryNode[] { + if (!fragment.content) return mergeTagEntries + + const embeddedEntries = fragment.content.filter( + (item): item is EmbeddedEntryNode => + item.nodeType.startsWith('embedded') && + 'data' in item && + item.data?.target?.sys?.id !== undefined, + ) + + mergeTagEntries.push(...embeddedEntries) + + fragment.content + .filter((item): item is RichTextNode => 'content' in item && Array.isArray(item.content)) + .forEach((item) => findMergeTagEntries(item, mergeTagEntries)) + + return mergeTagEntries +} + +interface TextNode { + nodeType: string + value: string +} + +function isTextNode(item: unknown): item is TextNode { + return ( + typeof item === 'object' && + item !== null && + 'nodeType' in item && + 'value' in item && + typeof (item as { value: unknown }).value === 'string' + ) +} + +function extractTextFromRichText(node: RichTextField | RichTextNode): string { + if (!node.content) return '' + + return node.content + .map((item) => { + if (item.nodeType === 'text' && isTextNode(item)) { + return item.value + } + if (item.nodeType.startsWith('embedded')) { + return '[MERGE TAG]' + } + if ('content' in item) { + return extractTextFromRichText(item) + } + return '' + }) + .join('') +} + +export function MergeTagScreen({ + colors, + onBack, + sdk, + mergeTagEntry, +}: MergeTagScreenProps): React.JSX.Element { + const [profile, setProfile] = useState(undefined) + const [resolvedValues, setResolvedValues] = useState>([]) + const [mergeTagDetails, setMergeTagDetails] = useState([]) + + useEffect(() => { + void sdk.personalization.page({ url: 'merge-tags-demo' }) + }, [sdk]) + + useEffect(() => { + const subscription = sdk.states.profile.subscribe((currentProfile) => { + setProfile(currentProfile) + }) + + return () => { + subscription.unsubscribe() + } + }, [sdk]) + + useEffect(() => { + const { fields } = mergeTagEntry + const richTextField = Object.values(fields).find((field): field is RichTextField => { + if (typeof field !== 'object' || field === null || !('nodeType' in field)) { + return false + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Checking nodeType property dynamically + const fieldWithNodeType = field as { nodeType: unknown } + return ( + typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' + ) + }) + + if (richTextField && profile) { + const embeddedNodes = findMergeTagEntries(richTextField) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Contentful Entry includes are not strictly typed + const entryWithIncludes = mergeTagEntry as { includes?: { Entry?: Entry[] } } + const { includes } = entryWithIncludes + const includedEntries = includes?.Entry ?? [] + + const mergeTagEntriesList: MergeTagEntry[] = [] + const resolvedValuesList: Array<{ id: string; value: unknown }> = [] + + embeddedNodes.forEach((node) => { + const { data } = node + const { target } = data + const { sys } = target + const { id: targetId } = sys + const includedEntry = includedEntries.find((entry) => entry.sys.id === targetId) + + if (includedEntry && includedEntry.sys.contentType.sys.id === 'nt_mergetag') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Validated by contentType check + const mergeTagEntry = includedEntry as MergeTagEntry + mergeTagEntriesList.push(mergeTagEntry) + + const resolvedValue = sdk.personalization.getMergeTagValue(mergeTagEntry, profile) + const mergeTagFields = mergeTagEntry.fields as { nt_mergetag_id: string } + resolvedValuesList.push({ + id: mergeTagFields.nt_mergetag_id, + value: resolvedValue, + }) + } + }) + + setMergeTagDetails(mergeTagEntriesList) + setResolvedValues(resolvedValuesList) + } + }, [mergeTagEntry, sdk, profile]) + + const richTextField = Object.values(mergeTagEntry.fields).find( + (field): field is RichTextField => { + if (typeof field !== 'object' || field === null || !('nodeType' in field)) { + return false + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Checking nodeType property dynamically + const fieldWithNodeType = field as { nodeType: unknown } + return ( + typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' + ) + }, + ) + + const originalText = richTextField ? extractTextFromRichText(richTextField) : '' + const resolvedText = originalText.replace( + /\[MERGE TAG\]/g, + () => resolvedValues[0]?.value?.toString() ?? '[NOT RESOLVED]', + ) + + return ( + + + + + + ← Back + + Merge Tags Demo + + + + + About Merge Tags + + Merge tags allow you to embed personalized data from the user profile directly into + Contentful content. They resolve dynamically based on the current visitor's profile. + + + + + Original Text + {originalText} + + + + Resolved Text + {resolvedText} + + + {mergeTagDetails.length > 0 && ( + + Merge Tag Details + {mergeTagDetails.map((mergeTag, index) => { + const tagFields = mergeTag.fields as { + nt_name: string + nt_mergetag_id: string + nt_fallback?: string + } + return ( + + + + Name: + + + {tagFields.nt_name} + + + + + Path: + + + {tagFields.nt_mergetag_id} + + + + + Fallback: + + + {tagFields.nt_fallback ?? 'None'} + + + + + Resolved Value: + + + {resolvedValues[index]?.value?.toString() ?? 'N/A'} + + + + ) + })} + + )} + + {profile ? ( + + + Current Profile Data + + + + + Profile ID: + + + {profile.id} + + + + + Continent: + + + {profile.location.continent} + + + + City: + + {profile.location.city} + + + + Country: + + {profile.location.countryCode} + + + + + ) : null} + + + How It Works + + 1. Contentful entry contains embedded merge tag entry{'\n'} + 2. Merge tag specifies a path in the profile (e.g., "location.continent"){'\n'} + 3. SDK resolves the value from the current user profile{'\n'} + 4. If the value exists, it's used; otherwise, fallback is returned{'\n'} + 5. The resolved value is displayed in place of the merge tag + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + }, + backButton: { + padding: 8, + }, + backButtonText: { + fontSize: 16, + fontWeight: '600', + }, + title: { + fontSize: 20, + fontWeight: '600', + marginLeft: 16, + }, + scrollContent: { + padding: 16, + }, + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + cardText: { + fontSize: 14, + lineHeight: 22, + }, + codeText: { + fontSize: 14, + lineHeight: 22, + fontFamily: 'monospace', + }, + resolvedText: { + fontSize: 16, + lineHeight: 24, + fontWeight: '500', + }, + detailSection: { + marginTop: 8, + }, + detailRow: { + flexDirection: 'row', + marginBottom: 8, + alignItems: 'flex-start', + }, + detailLabel: { + fontSize: 14, + fontWeight: '600', + width: 120, + }, + detailValue: { + fontSize: 14, + flex: 1, + fontFamily: 'monospace', + }, + resolvedValue: { + fontSize: 16, + fontWeight: '600', + flex: 1, + }, + bottomSpacer: { + height: 40, + }, +}) diff --git a/implementations/react-native/components/InstructionsCard.tsx b/implementations/react-native/components/InstructionsCard.tsx new file mode 100644 index 00000000..063cbd27 --- /dev/null +++ b/implementations/react-native/components/InstructionsCard.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import type { InstructionsCardProps } from '../types' + +export function InstructionsCard({ + colors, + onTestTracking, + onTestMergeTags, +}: InstructionsCardProps): React.JSX.Element { + const { cardBackground, textColor, mutedTextColor } = colors + + return ( + + Next Steps + + • The Optimization SDK is now initialized and ready to use{'\n'}• You can now implement + experiences and personalization{'\n'}• Check the console for additional SDK logs{'\n'}• + Modify this app to test SDK features + + + + Test Viewport Tracking → + + + + Test Merge Tags → + + + ) +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + cardTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 16, + }, + instructionText: { + fontSize: 14, + lineHeight: 22, + }, + testButton: { + marginTop: 20, + padding: 16, + borderRadius: 8, + alignItems: 'center', + }, + testButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, +}) diff --git a/implementations/react-native/components/LoadingScreen.tsx b/implementations/react-native/components/LoadingScreen.tsx new file mode 100644 index 00000000..5fda867c --- /dev/null +++ b/implementations/react-native/components/LoadingScreen.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { ActivityIndicator, SafeAreaView, StatusBar, StyleSheet, Text, View } from 'react-native' +import type { LoadingScreenProps } from '../types' + +export function LoadingScreen({ colors, isDarkMode }: LoadingScreenProps): React.JSX.Element { + return ( + + + + + + Loading entries from mock server... + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + loadingText: { + marginTop: 16, + fontSize: 16, + }, +}) diff --git a/implementations/react-native/components/MainScreen.tsx b/implementations/react-native/components/MainScreen.tsx new file mode 100644 index 00000000..df0a9d25 --- /dev/null +++ b/implementations/react-native/components/MainScreen.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native' +import type { MainScreenProps } from '../types' +import { InstructionsCard } from './InstructionsCard' +import { SDKConfigCard } from './SDKConfigCard' +import { SDKStatusCard } from './SDKStatusCard' + +export function MainScreen({ + colors, + isDarkMode, + sdkLoaded, + sdkError, + sdkInfo, + onTestTracking, + onTestMergeTags, +}: MainScreenProps): React.JSX.Element { + return ( + + + + + + Contentful Optimization + + + React Native SDK Implementation + + + + + + {sdkInfo && } + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + padding: 20, + paddingTop: 40, + }, + header: { + marginBottom: 30, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + fontWeight: '500', + }, +}) diff --git a/implementations/react-native/components/SDKConfigCard.tsx b/implementations/react-native/components/SDKConfigCard.tsx new file mode 100644 index 00000000..50a9c0bc --- /dev/null +++ b/implementations/react-native/components/SDKConfigCard.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { StyleSheet, Text, View } from 'react-native' +import type { SDKConfigCardProps } from '../types' + +export function SDKConfigCard({ sdkInfo, colors }: SDKConfigCardProps): React.JSX.Element { + const { cardBackground, textColor, mutedTextColor } = colors + + return ( + + Configuration + + + Client ID: + + {sdkInfo.clientId} + + + + + Environment: + + {sdkInfo.environment} + + + + + Initialized At: + + {new Date(sdkInfo.timestamp).toLocaleTimeString()} + + + + ) +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + cardTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 16, + }, + infoRow: { + marginBottom: 12, + }, + infoLabel: { + fontSize: 14, + fontWeight: '500', + marginBottom: 4, + }, + infoValue: { + fontSize: 16, + fontWeight: '400', + fontFamily: 'monospace', + }, +}) diff --git a/implementations/react-native/components/SDKStatusCard.tsx b/implementations/react-native/components/SDKStatusCard.tsx new file mode 100644 index 00000000..ec33f759 --- /dev/null +++ b/implementations/react-native/components/SDKStatusCard.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' +import type { SDKStatusCardProps } from '../types' + +export function SDKStatusCard({ + sdkLoaded, + sdkError, + colors, +}: SDKStatusCardProps): React.JSX.Element { + const { cardBackground, textColor, mutedTextColor, successColor, errorColor } = colors + + return ( + + SDK Status + + {!sdkLoaded && !sdkError && ( + + + Initializing SDK... + + )} + + {sdkLoaded && ( + + + + ✓ SDK Loaded Successfully + + + )} + + {sdkError && ( + + + + ✗ Error: {sdkError} + + + )} + + ) +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + cardTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 16, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + statusIndicator: { + width: 12, + height: 12, + borderRadius: 6, + }, + statusText: { + fontSize: 16, + fontWeight: '500', + }, +}) diff --git a/implementations/react-native/env.config.ts b/implementations/react-native/env.config.ts index ed5d1522..fb6e5432 100644 --- a/implementations/react-native/env.config.ts +++ b/implementations/react-native/env.config.ts @@ -24,6 +24,7 @@ interface EnvConfig { entries: { personalized: string product: string + mergeTag: string } } @@ -53,5 +54,6 @@ export const ENV_CONFIG: EnvConfig = { entries: { personalized: '2Z2WLOx07InSewC3LUB3eX', // Baseline with experiences product: '1MwiFl4z7gkwqGYdvCmr8c', // Simple content entry + mergeTag: '1MwiFl4z7gkwqGYdvCmr8c', // Entry with merge tag in rich text }, } diff --git a/implementations/react-native/types.ts b/implementations/react-native/types.ts new file mode 100644 index 00000000..51e87861 --- /dev/null +++ b/implementations/react-native/types.ts @@ -0,0 +1,48 @@ +export interface SDKInfo { + clientId: string + environment: string + initialized: boolean + timestamp: string +} + +export interface ThemeColors { + backgroundColor: string + cardBackground: string + textColor: string + mutedTextColor: string + successColor: string + errorColor: string + accentColor: string +} + +export interface SDKStatusCardProps { + sdkLoaded: boolean + sdkError: string | null + colors: ThemeColors +} + +export interface SDKConfigCardProps { + sdkInfo: SDKInfo + colors: ThemeColors +} + +export interface InstructionsCardProps { + colors: ThemeColors + onTestTracking: () => void + onTestMergeTags: () => void +} + +export interface LoadingScreenProps { + colors: ThemeColors + isDarkMode: boolean +} + +export interface MainScreenProps { + colors: ThemeColors + isDarkMode: boolean + sdkLoaded: boolean + sdkError: string | null + sdkInfo: SDKInfo | null + onTestTracking: () => void + onTestMergeTags: () => void +} diff --git a/implementations/react-native/utils/sdkHelpers.ts b/implementations/react-native/utils/sdkHelpers.ts new file mode 100644 index 00000000..bc760b83 --- /dev/null +++ b/implementations/react-native/utils/sdkHelpers.ts @@ -0,0 +1,86 @@ +import type Optimization from '@contentful/optimization-react-native' +import { createClient, type Entry } from 'contentful' +import { ENV_CONFIG } from '../env.config' +import type { SDKInfo } from '../types' + +export async function initializeSDK( + setSdkInfo: (info: SDKInfo) => void, + setSdk: (sdk: Optimization) => void, + setSdkLoaded: (loaded: boolean) => void, + setSdkError: (error: string | null) => void, +): Promise { + const { default: Optimization } = await import('@contentful/optimization-react-native') + + try { + const { + optimization: { clientId, environment }, + api: { experienceBaseUrl, insightsBaseUrl }, + } = ENV_CONFIG + + const sdkInstance = await Optimization.create({ + clientId, + environment, + api: { + personalization: { baseUrl: experienceBaseUrl }, + analytics: { baseUrl: insightsBaseUrl }, + }, + }) + + setSdkInfo({ + clientId, + environment, + initialized: true, + timestamp: new Date().toISOString(), + }) + setSdk(sdkInstance) + setSdkLoaded(true) + } catch (error) { + setSdkError(error instanceof Error ? error.message : 'Unknown error') + } +} + +export async function fetchEntriesFromMockServer( + setPersonalizedEntry: (entry: Entry) => void, + setProductEntry: (entry: Entry) => void, +): Promise { + const { + contentful: { spaceId, environment, accessToken, host, basePath }, + entries: { personalized, product }, + } = ENV_CONFIG + + const contentful = createClient({ + space: spaceId, + environment, + accessToken, + host, + basePath, + insecure: true, + }) + + const [personalizedEntryData, productEntryData] = await Promise.all([ + contentful.getEntry(personalized, { include: 10 }), + contentful.getEntry(product, { include: 10 }), + ]) + + setPersonalizedEntry(personalizedEntryData) + setProductEntry(productEntryData) +} + +export async function fetchMergeTagEntry(setMergeTagEntry: (entry: Entry) => void): Promise { + const { + contentful: { spaceId, environment, accessToken, host, basePath }, + entries: { mergeTag }, + } = ENV_CONFIG + + const contentful = createClient({ + space: spaceId, + environment, + accessToken, + host, + basePath, + insecure: true, + }) + + const mergeTagEntryData = await contentful.getEntry(mergeTag, { include: 10 }) + setMergeTagEntry(mergeTagEntryData) +} From 19e68e6a24826efeb8b282620789e5bdae6677ab Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 6 Nov 2025 10:44:55 +0100 Subject: [PATCH 2/5] [NT-1754] Break MergeTagScreen into smaller files --- implementations/react-native/App.tsx | 2 +- .../react-native/MergeTagScreen.tsx | 443 ------------------ .../screens/MergeTagScreen/MergeTagScreen.tsx | 199 ++++++++ .../MergeTagScreen/components/InfoCard.tsx | 59 +++ .../components/MergeTagDetailCard.tsx | 119 +++++ .../MergeTagScreen/components/ProfileCard.tsx | 92 ++++ .../components/TextDisplayCard.tsx | 74 +++ .../screens/MergeTagScreen/index.ts | 7 + .../screens/MergeTagScreen/types.ts | 61 +++ .../MergeTagScreen/utils/richTextUtils.ts | 62 +++ 10 files changed, 674 insertions(+), 444 deletions(-) delete mode 100644 implementations/react-native/MergeTagScreen.tsx create mode 100644 implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx create mode 100644 implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx create mode 100644 implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx create mode 100644 implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx create mode 100644 implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx create mode 100644 implementations/react-native/screens/MergeTagScreen/index.ts create mode 100644 implementations/react-native/screens/MergeTagScreen/types.ts create mode 100644 implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts diff --git a/implementations/react-native/App.tsx b/implementations/react-native/App.tsx index b96fd0d5..71aa23ad 100644 --- a/implementations/react-native/App.tsx +++ b/implementations/react-native/App.tsx @@ -13,7 +13,7 @@ import { OptimizationProvider } from '@contentful/optimization-react-native' import type { Entry } from 'contentful' import { LoadingScreen } from './components/LoadingScreen' import { MainScreen } from './components/MainScreen' -import { MergeTagScreen } from './MergeTagScreen' +import { MergeTagScreen } from './screens/MergeTagScreen' import { TestTrackingScreen } from './TestTrackingScreen' import type { SDKInfo, ThemeColors } from './types' import { fetchEntriesFromMockServer, fetchMergeTagEntry, initializeSDK } from './utils/sdkHelpers' diff --git a/implementations/react-native/MergeTagScreen.tsx b/implementations/react-native/MergeTagScreen.tsx deleted file mode 100644 index a7735cee..00000000 --- a/implementations/react-native/MergeTagScreen.tsx +++ /dev/null @@ -1,443 +0,0 @@ -/** - * Merge Tag Test Screen - Demonstrates merge tag resolution functionality - * - * This screen shows how merge tags embedded in Contentful rich text fields - * are resolved using the current user profile data. - */ - -import React, { useEffect, useState } from 'react' -import { - SafeAreaView, - ScrollView, - StatusBar, - StyleSheet, - Text, - TouchableOpacity, - useColorScheme, - View, -} from 'react-native' - -import type Optimization from '@contentful/optimization-react-native' -import type { MergeTagEntry, Profile } from '@contentful/optimization-react-native' -import type { Entry } from 'contentful' - -interface ThemeColors { - backgroundColor: string - cardBackground: string - textColor: string - mutedTextColor: string - successColor: string - errorColor: string - accentColor: string -} - -interface MergeTagScreenProps { - colors: ThemeColors - onBack: () => void - sdk: Optimization - mergeTagEntry: Entry -} - -interface RichTextNode { - nodeType: string - data?: { - target?: { - sys?: { - id?: string - type?: string - linkType?: string - } - } - } - content?: RichTextNode[] -} - -interface RichTextField { - nodeType: string - content?: RichTextNode[] -} - -interface EmbeddedEntryNode { - nodeType: string - data: { - target: { - sys: { - id: string - type: string - linkType: string - } - } - } -} - -function findMergeTagEntries( - fragment: RichTextField | RichTextNode, - mergeTagEntries: EmbeddedEntryNode[] = [], -): EmbeddedEntryNode[] { - if (!fragment.content) return mergeTagEntries - - const embeddedEntries = fragment.content.filter( - (item): item is EmbeddedEntryNode => - item.nodeType.startsWith('embedded') && - 'data' in item && - item.data?.target?.sys?.id !== undefined, - ) - - mergeTagEntries.push(...embeddedEntries) - - fragment.content - .filter((item): item is RichTextNode => 'content' in item && Array.isArray(item.content)) - .forEach((item) => findMergeTagEntries(item, mergeTagEntries)) - - return mergeTagEntries -} - -interface TextNode { - nodeType: string - value: string -} - -function isTextNode(item: unknown): item is TextNode { - return ( - typeof item === 'object' && - item !== null && - 'nodeType' in item && - 'value' in item && - typeof (item as { value: unknown }).value === 'string' - ) -} - -function extractTextFromRichText(node: RichTextField | RichTextNode): string { - if (!node.content) return '' - - return node.content - .map((item) => { - if (item.nodeType === 'text' && isTextNode(item)) { - return item.value - } - if (item.nodeType.startsWith('embedded')) { - return '[MERGE TAG]' - } - if ('content' in item) { - return extractTextFromRichText(item) - } - return '' - }) - .join('') -} - -export function MergeTagScreen({ - colors, - onBack, - sdk, - mergeTagEntry, -}: MergeTagScreenProps): React.JSX.Element { - const [profile, setProfile] = useState(undefined) - const [resolvedValues, setResolvedValues] = useState>([]) - const [mergeTagDetails, setMergeTagDetails] = useState([]) - - useEffect(() => { - void sdk.personalization.page({ url: 'merge-tags-demo' }) - }, [sdk]) - - useEffect(() => { - const subscription = sdk.states.profile.subscribe((currentProfile) => { - setProfile(currentProfile) - }) - - return () => { - subscription.unsubscribe() - } - }, [sdk]) - - useEffect(() => { - const { fields } = mergeTagEntry - const richTextField = Object.values(fields).find((field): field is RichTextField => { - if (typeof field !== 'object' || field === null || !('nodeType' in field)) { - return false - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Checking nodeType property dynamically - const fieldWithNodeType = field as { nodeType: unknown } - return ( - typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' - ) - }) - - if (richTextField && profile) { - const embeddedNodes = findMergeTagEntries(richTextField) - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Contentful Entry includes are not strictly typed - const entryWithIncludes = mergeTagEntry as { includes?: { Entry?: Entry[] } } - const { includes } = entryWithIncludes - const includedEntries = includes?.Entry ?? [] - - const mergeTagEntriesList: MergeTagEntry[] = [] - const resolvedValuesList: Array<{ id: string; value: unknown }> = [] - - embeddedNodes.forEach((node) => { - const { data } = node - const { target } = data - const { sys } = target - const { id: targetId } = sys - const includedEntry = includedEntries.find((entry) => entry.sys.id === targetId) - - if (includedEntry && includedEntry.sys.contentType.sys.id === 'nt_mergetag') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Validated by contentType check - const mergeTagEntry = includedEntry as MergeTagEntry - mergeTagEntriesList.push(mergeTagEntry) - - const resolvedValue = sdk.personalization.getMergeTagValue(mergeTagEntry, profile) - const mergeTagFields = mergeTagEntry.fields as { nt_mergetag_id: string } - resolvedValuesList.push({ - id: mergeTagFields.nt_mergetag_id, - value: resolvedValue, - }) - } - }) - - setMergeTagDetails(mergeTagEntriesList) - setResolvedValues(resolvedValuesList) - } - }, [mergeTagEntry, sdk, profile]) - - const richTextField = Object.values(mergeTagEntry.fields).find( - (field): field is RichTextField => { - if (typeof field !== 'object' || field === null || !('nodeType' in field)) { - return false - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Checking nodeType property dynamically - const fieldWithNodeType = field as { nodeType: unknown } - return ( - typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' - ) - }, - ) - - const originalText = richTextField ? extractTextFromRichText(richTextField) : '' - const resolvedText = originalText.replace( - /\[MERGE TAG\]/g, - () => resolvedValues[0]?.value?.toString() ?? '[NOT RESOLVED]', - ) - - return ( - - - - - - ← Back - - Merge Tags Demo - - - - - About Merge Tags - - Merge tags allow you to embed personalized data from the user profile directly into - Contentful content. They resolve dynamically based on the current visitor's profile. - - - - - Original Text - {originalText} - - - - Resolved Text - {resolvedText} - - - {mergeTagDetails.length > 0 && ( - - Merge Tag Details - {mergeTagDetails.map((mergeTag, index) => { - const tagFields = mergeTag.fields as { - nt_name: string - nt_mergetag_id: string - nt_fallback?: string - } - return ( - - - - Name: - - - {tagFields.nt_name} - - - - - Path: - - - {tagFields.nt_mergetag_id} - - - - - Fallback: - - - {tagFields.nt_fallback ?? 'None'} - - - - - Resolved Value: - - - {resolvedValues[index]?.value?.toString() ?? 'N/A'} - - - - ) - })} - - )} - - {profile ? ( - - - Current Profile Data - - - - - Profile ID: - - - {profile.id} - - - - - Continent: - - - {profile.location.continent} - - - - City: - - {profile.location.city} - - - - Country: - - {profile.location.countryCode} - - - - - ) : null} - - - How It Works - - 1. Contentful entry contains embedded merge tag entry{'\n'} - 2. Merge tag specifies a path in the profile (e.g., "location.continent"){'\n'} - 3. SDK resolves the value from the current user profile{'\n'} - 4. If the value exists, it's used; otherwise, fallback is returned{'\n'} - 5. The resolved value is displayed in place of the merge tag - - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - }, - backButton: { - padding: 8, - }, - backButtonText: { - fontSize: 16, - fontWeight: '600', - }, - title: { - fontSize: 20, - fontWeight: '600', - marginLeft: 16, - }, - scrollContent: { - padding: 16, - }, - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - cardTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 12, - }, - cardText: { - fontSize: 14, - lineHeight: 22, - }, - codeText: { - fontSize: 14, - lineHeight: 22, - fontFamily: 'monospace', - }, - resolvedText: { - fontSize: 16, - lineHeight: 24, - fontWeight: '500', - }, - detailSection: { - marginTop: 8, - }, - detailRow: { - flexDirection: 'row', - marginBottom: 8, - alignItems: 'flex-start', - }, - detailLabel: { - fontSize: 14, - fontWeight: '600', - width: 120, - }, - detailValue: { - fontSize: 14, - flex: 1, - fontFamily: 'monospace', - }, - resolvedValue: { - fontSize: 16, - fontWeight: '600', - flex: 1, - }, - bottomSpacer: { - height: 40, - }, -}) diff --git a/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx b/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx new file mode 100644 index 00000000..687f6072 --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx @@ -0,0 +1,199 @@ +/** + * Merge Tag Test Screen - Demonstrates merge tag resolution functionality + * + * This screen shows how merge tags embedded in Contentful rich text fields + * are resolved using the current user profile data. + */ + +import React, { useEffect, useState } from 'react' +import { + SafeAreaView, + ScrollView, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + useColorScheme, + View, +} from 'react-native' + +import type { MergeTagEntry, Profile } from '@contentful/optimization-react-native' +import type { Entry } from 'contentful' + +import { InfoCard } from './components/InfoCard' +import { MergeTagDetailCard } from './components/MergeTagDetailCard' +import { ProfileCard } from './components/ProfileCard' +import { TextDisplayCard } from './components/TextDisplayCard' +import type { MergeTagScreenProps, RichTextField } from './types' +import { extractTextFromRichText, findMergeTagEntries } from './utils/richTextUtils' + +export function MergeTagScreen({ + colors, + onBack, + sdk, + mergeTagEntry, +}: MergeTagScreenProps): React.JSX.Element { + const [profile, setProfile] = useState(undefined) + const [resolvedValues, setResolvedValues] = useState>([]) + const [mergeTagDetails, setMergeTagDetails] = useState([]) + + useEffect(() => { + void sdk.personalization.page({ url: 'merge-tags-demo' }) + }, [sdk]) + + useEffect(() => { + const subscription = sdk.states.profile.subscribe((currentProfile) => { + setProfile(currentProfile) + }) + + return () => { + subscription.unsubscribe() + } + }, [sdk]) + + useEffect(() => { + const { fields } = mergeTagEntry + const richTextField = Object.values(fields).find((field): field is RichTextField => { + if (typeof field !== 'object' || field === null || !('nodeType' in field)) { + return false + } + const fieldWithNodeType = field as { nodeType: unknown } + return ( + typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' + ) + }) + + if (richTextField && profile) { + const embeddedNodes = findMergeTagEntries(richTextField) + + const entryWithIncludes = mergeTagEntry as { includes?: { Entry?: Entry[] } } + const { includes } = entryWithIncludes + const includedEntries = includes?.Entry ?? [] + + const mergeTagEntriesList: MergeTagEntry[] = [] + const resolvedValuesList: Array<{ id: string; value: unknown }> = [] + + embeddedNodes.forEach((node) => { + const { data } = node + const { target } = data + const { sys } = target + const { id: targetId } = sys + const includedEntry = includedEntries.find((entry) => entry.sys.id === targetId) + + if (includedEntry && includedEntry.sys.contentType.sys.id === 'nt_mergetag') { + const mergeTagEntry = includedEntry as MergeTagEntry + mergeTagEntriesList.push(mergeTagEntry) + + const resolvedValue = sdk.personalization.getMergeTagValue(mergeTagEntry, profile) + const mergeTagFields = mergeTagEntry.fields as { nt_mergetag_id: string } + resolvedValuesList.push({ + id: mergeTagFields.nt_mergetag_id, + value: resolvedValue, + }) + } + }) + + setMergeTagDetails(mergeTagEntriesList) + setResolvedValues(resolvedValuesList) + } + }, [mergeTagEntry, sdk, profile]) + + const richTextField = Object.values(mergeTagEntry.fields).find( + (field): field is RichTextField => { + if (typeof field !== 'object' || field === null || !('nodeType' in field)) { + return false + } + + const fieldWithNodeType = field as { nodeType: unknown } + return ( + typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' + ) + }, + ) + + const originalText = richTextField ? extractTextFromRichText(richTextField) : '' + const resolvedText = originalText.replace( + /\[MERGE TAG\]/g, + () => resolvedValues[0]?.value?.toString() ?? '[NOT RESOLVED]', + ) + + return ( + + + + + + ← Back + + Merge Tags Demo + + + + + + + + + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + }, + backButton: { + padding: 8, + }, + backButtonText: { + fontSize: 16, + fontWeight: '600', + }, + title: { + fontSize: 20, + fontWeight: '600', + marginLeft: 16, + }, + scrollContent: { + padding: 16, + }, + bottomSpacer: { + height: 40, + }, +}) diff --git a/implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx new file mode 100644 index 00000000..0f732e10 --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx @@ -0,0 +1,59 @@ +/** + * InfoCard - Displays informational content in a card layout + */ + +import React from 'react' +import { StyleSheet, Text, View } from 'react-native' + +import type { ThemeColors } from '../types' + +interface InfoCardProps { + title: string + content: string + colors: ThemeColors + accentBackground?: boolean +} + +export function InfoCard({ + title, + content, + colors, + accentBackground = false, +}: InfoCardProps): React.JSX.Element { + const backgroundColor = accentBackground ? colors.accentColor : colors.cardBackground + const titleColor = accentBackground ? '#ffffff' : colors.textColor + const contentColor = accentBackground ? '#ffffff' : colors.mutedTextColor + + return ( + + {title} + {content} + + ) +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + content: { + fontSize: 14, + lineHeight: 22, + }, +}) + diff --git a/implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx new file mode 100644 index 00000000..d02d8c4c --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx @@ -0,0 +1,119 @@ +/** + * MergeTagDetailCard - Displays details about merge tags and their resolved values + */ + +import React from 'react' +import { StyleSheet, Text, View } from 'react-native' + +import type { MergeTagEntry } from '@contentful/optimization-react-native' + +import type { ThemeColors } from '../types' + +interface MergeTagDetailCardProps { + mergeTagDetails: MergeTagEntry[] + resolvedValues: Array<{ id: string; value: unknown }> + colors: ThemeColors +} + +interface DetailRowProps { + label: string + value: string + colors: ThemeColors + highlight?: boolean +} + +function DetailRow({ label, value, colors, highlight = false }: DetailRowProps): React.JSX.Element { + return ( + + {label}: + + {value} + + + ) +} + +export function MergeTagDetailCard({ + mergeTagDetails, + resolvedValues, + colors, +}: MergeTagDetailCardProps): React.JSX.Element | null { + if (mergeTagDetails.length === 0) return null + + return ( + + Merge Tag Details + {mergeTagDetails.map((mergeTag, index) => { + const tagFields = mergeTag.fields as { + nt_name: string + nt_mergetag_id: string + nt_fallback?: string + } + return ( + + + + + + + ) + })} + + ) +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + detailSection: { + marginTop: 8, + }, + detailRow: { + flexDirection: 'row', + marginBottom: 8, + alignItems: 'flex-start', + }, + detailLabel: { + fontSize: 14, + fontWeight: '600', + width: 120, + }, + detailValue: { + fontSize: 14, + flex: 1, + fontFamily: 'monospace', + }, + highlightedValue: { + fontSize: 16, + fontWeight: '600', + flex: 1, + }, +}) diff --git a/implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx new file mode 100644 index 00000000..380732c1 --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx @@ -0,0 +1,92 @@ +/** + * ProfileCard - Displays current user profile information + */ + +import React from 'react' +import { StyleSheet, Text, View } from 'react-native' + +import type { Profile } from '@contentful/optimization-react-native' + +import type { ThemeColors } from '../types' + +interface ProfileCardProps { + profile: Profile | undefined + colors: ThemeColors +} + +interface DetailRowProps { + label: string + value: string + colors: ThemeColors + ellipsizeMode?: 'middle' | 'head' | 'tail' | 'clip' +} + +function DetailRow({ label, value, colors, ellipsizeMode }: DetailRowProps): React.JSX.Element { + return ( + + {label}: + + {value} + + + ) +} + +export function ProfileCard({ profile, colors }: ProfileCardProps): React.JSX.Element | null { + if (!profile) return null + + return ( + + Current Profile Data + + + + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + detailSection: { + marginTop: 8, + }, + detailRow: { + flexDirection: 'row', + marginBottom: 8, + alignItems: 'flex-start', + }, + detailLabel: { + fontSize: 14, + fontWeight: '600', + width: 120, + }, + detailValue: { + fontSize: 14, + flex: 1, + fontFamily: 'monospace', + }, +}) diff --git a/implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx new file mode 100644 index 00000000..8a9c1294 --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx @@ -0,0 +1,74 @@ +/** + * TextDisplayCard - Displays text content with optional monospace styling + */ + +import React from 'react' +import { StyleSheet, Text, View } from 'react-native' + +import type { ThemeColors } from '../types' + +interface TextDisplayCardProps { + title: string + content: string + colors: ThemeColors + monospace?: boolean + accentBackground?: boolean +} + +export function TextDisplayCard({ + title, + content, + colors, + monospace = false, + accentBackground = false, +}: TextDisplayCardProps): React.JSX.Element { + const backgroundColor = accentBackground ? colors.accentColor : colors.cardBackground + const titleColor = accentBackground ? '#ffffff' : colors.textColor + const contentColor = accentBackground ? '#ffffff' : colors.mutedTextColor + + return ( + + {title} + + {content} + + + ) +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 3, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + regularContent: { + fontSize: 16, + lineHeight: 24, + fontWeight: '500', + }, + monospaceContent: { + fontSize: 14, + lineHeight: 22, + fontFamily: 'monospace', + }, +}) + diff --git a/implementations/react-native/screens/MergeTagScreen/index.ts b/implementations/react-native/screens/MergeTagScreen/index.ts new file mode 100644 index 00000000..b145ed50 --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/index.ts @@ -0,0 +1,7 @@ +/** + * MergeTagScreen barrel export + */ + +export { MergeTagScreen } from './MergeTagScreen' +export type { MergeTagScreenProps, ThemeColors } from './types' + diff --git a/implementations/react-native/screens/MergeTagScreen/types.ts b/implementations/react-native/screens/MergeTagScreen/types.ts new file mode 100644 index 00000000..1c9f4306 --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/types.ts @@ -0,0 +1,61 @@ +/** + * Type definitions for MergeTagScreen + */ + +import type Optimization from '@contentful/optimization-react-native' +import type { Entry } from 'contentful' + +export interface ThemeColors { + backgroundColor: string + cardBackground: string + textColor: string + mutedTextColor: string + successColor: string + errorColor: string + accentColor: string +} + +export interface MergeTagScreenProps { + colors: ThemeColors + onBack: () => void + sdk: Optimization + mergeTagEntry: Entry +} + +export interface RichTextNode { + nodeType: string + data?: { + target?: { + sys?: { + id?: string + type?: string + linkType?: string + } + } + } + content?: RichTextNode[] +} + +export interface RichTextField { + nodeType: string + content?: RichTextNode[] +} + +export interface EmbeddedEntryNode { + nodeType: string + data: { + target: { + sys: { + id: string + type: string + linkType: string + } + } + } +} + +export interface TextNode { + nodeType: string + value: string +} + diff --git a/implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts b/implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts new file mode 100644 index 00000000..e85039d3 --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts @@ -0,0 +1,62 @@ +/** + * Utility functions for processing Contentful rich text fields + */ + +import type { + EmbeddedEntryNode, + RichTextField, + RichTextNode, + TextNode, +} from '../types' + +export function findMergeTagEntries( + fragment: RichTextField | RichTextNode, + mergeTagEntries: EmbeddedEntryNode[] = [], +): EmbeddedEntryNode[] { + if (!fragment.content) return mergeTagEntries + + const embeddedEntries = fragment.content.filter( + (item): item is EmbeddedEntryNode => + item.nodeType.startsWith('embedded') && + 'data' in item && + item.data?.target?.sys?.id !== undefined, + ) + + mergeTagEntries.push(...embeddedEntries) + + fragment.content + .filter((item): item is RichTextNode => 'content' in item && Array.isArray(item.content)) + .forEach((item) => findMergeTagEntries(item, mergeTagEntries)) + + return mergeTagEntries +} + +export function isTextNode(item: unknown): item is TextNode { + return ( + typeof item === 'object' && + item !== null && + 'nodeType' in item && + 'value' in item && + typeof (item as { value: unknown }).value === 'string' + ) +} + +export function extractTextFromRichText(node: RichTextField | RichTextNode): string { + if (!node.content) return '' + + return node.content + .map((item) => { + if (item.nodeType === 'text' && isTextNode(item)) { + return item.value + } + if (item.nodeType.startsWith('embedded')) { + return '[MERGE TAG]' + } + if ('content' in item) { + return extractTextFromRichText(item) + } + return '' + }) + .join('') +} + From 4682b288e07bb56bff9cc94fff3321ae0354b608 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Thu, 6 Nov 2025 11:13:11 +0100 Subject: [PATCH 3/5] [NT-1754] Fix lint on MergeTagScreen --- .../screens/MergeTagScreen/MergeTagScreen.tsx | 40 +++++-------------- .../screens/MergeTagScreen/types.ts | 26 +++++++++++- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx b/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx index 687f6072..cb861dd8 100644 --- a/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx +++ b/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx @@ -18,13 +18,13 @@ import { } from 'react-native' import type { MergeTagEntry, Profile } from '@contentful/optimization-react-native' -import type { Entry } from 'contentful' import { InfoCard } from './components/InfoCard' import { MergeTagDetailCard } from './components/MergeTagDetailCard' import { ProfileCard } from './components/ProfileCard' import { TextDisplayCard } from './components/TextDisplayCard' -import type { MergeTagScreenProps, RichTextField } from './types' +import type { EntryWithIncludes, MergeTagScreenProps } from './types' +import { isMergeTagEntry, isRichTextField } from './types' import { extractTextFromRichText, findMergeTagEntries } from './utils/richTextUtils' export function MergeTagScreen({ @@ -53,20 +53,12 @@ export function MergeTagScreen({ useEffect(() => { const { fields } = mergeTagEntry - const richTextField = Object.values(fields).find((field): field is RichTextField => { - if (typeof field !== 'object' || field === null || !('nodeType' in field)) { - return false - } - const fieldWithNodeType = field as { nodeType: unknown } - return ( - typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' - ) - }) + const richTextField = Object.values(fields).find(isRichTextField) if (richTextField && profile) { const embeddedNodes = findMergeTagEntries(richTextField) - const entryWithIncludes = mergeTagEntry as { includes?: { Entry?: Entry[] } } + const entryWithIncludes = mergeTagEntry as EntryWithIncludes const { includes } = entryWithIncludes const includedEntries = includes?.Entry ?? [] @@ -80,12 +72,13 @@ export function MergeTagScreen({ const { id: targetId } = sys const includedEntry = includedEntries.find((entry) => entry.sys.id === targetId) - if (includedEntry && includedEntry.sys.contentType.sys.id === 'nt_mergetag') { - const mergeTagEntry = includedEntry as MergeTagEntry - mergeTagEntriesList.push(mergeTagEntry) + if (includedEntry && isMergeTagEntry(includedEntry)) { + mergeTagEntriesList.push(includedEntry) - const resolvedValue = sdk.personalization.getMergeTagValue(mergeTagEntry, profile) - const mergeTagFields = mergeTagEntry.fields as { nt_mergetag_id: string } + const resolvedValue = sdk.personalization.getMergeTagValue(includedEntry, profile) + const mergeTagFields = includedEntry.fields as { + nt_mergetag_id: string + } resolvedValuesList.push({ id: mergeTagFields.nt_mergetag_id, value: resolvedValue, @@ -98,18 +91,7 @@ export function MergeTagScreen({ } }, [mergeTagEntry, sdk, profile]) - const richTextField = Object.values(mergeTagEntry.fields).find( - (field): field is RichTextField => { - if (typeof field !== 'object' || field === null || !('nodeType' in field)) { - return false - } - - const fieldWithNodeType = field as { nodeType: unknown } - return ( - typeof fieldWithNodeType.nodeType === 'string' && fieldWithNodeType.nodeType === 'document' - ) - }, - ) + const richTextField = Object.values(mergeTagEntry.fields).find(isRichTextField) const originalText = richTextField ? extractTextFromRichText(richTextField) : '' const resolvedText = originalText.replace( diff --git a/implementations/react-native/screens/MergeTagScreen/types.ts b/implementations/react-native/screens/MergeTagScreen/types.ts index 1c9f4306..1ff20114 100644 --- a/implementations/react-native/screens/MergeTagScreen/types.ts +++ b/implementations/react-native/screens/MergeTagScreen/types.ts @@ -3,6 +3,7 @@ */ import type Optimization from '@contentful/optimization-react-native' +import type { MergeTagEntry } from '@contentful/optimization-react-native' import type { Entry } from 'contentful' export interface ThemeColors { @@ -37,8 +38,9 @@ export interface RichTextNode { } export interface RichTextField { - nodeType: string - content?: RichTextNode[] + nodeType: 'document' + content: RichTextNode[] + data: Record } export interface EmbeddedEntryNode { @@ -59,3 +61,23 @@ export interface TextNode { value: string } +export function isRichTextField(field: unknown): field is RichTextField { + return ( + typeof field === 'object' && + field !== null && + 'nodeType' in field && + (field as { nodeType: unknown }).nodeType === 'document' && + 'content' in field && + Array.isArray((field as { content: unknown }).content) + ) +} + +export interface EntryWithIncludes extends Entry { + includes?: { + Entry?: Entry[] + } +} + +export function isMergeTagEntry(entry: Entry): entry is MergeTagEntry { + return entry.sys.contentType.sys.id === 'nt_mergetag' +} From 24ba3746b73c30bbf45e5755b3c3c7212dd85874 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Fri, 7 Nov 2025 16:06:16 +0100 Subject: [PATCH 4/5] [NT-1754] Remove dashboard app and replace with minimal merge tag surfacing --- implementations/react-native/App.tsx | 138 ++------ .../react-native/TestTrackingScreen.tsx | 311 ------------------ .../components/InstructionsCard.tsx | 74 ----- .../react-native/components/LoadingScreen.tsx | 33 -- .../react-native/components/MainScreen.tsx | 64 ---- .../components/RichTextRenderer.tsx | 120 +++++++ .../react-native/components/SDKConfigCard.tsx | 68 ---- .../react-native/components/SDKStatusCard.tsx | 77 ----- implementations/react-native/e2e/app.test.js | 73 +--- implementations/react-native/env.config.ts | 9 +- .../react-native/screens/MergeTagScreen.tsx | 84 +++++ .../screens/MergeTagScreen/MergeTagScreen.tsx | 181 ---------- .../MergeTagScreen/components/InfoCard.tsx | 59 ---- .../components/MergeTagDetailCard.tsx | 119 ------- .../MergeTagScreen/components/ProfileCard.tsx | 92 ------ .../components/TextDisplayCard.tsx | 74 ----- .../screens/MergeTagScreen/index.ts | 7 - .../screens/MergeTagScreen/types.ts | 83 ----- .../MergeTagScreen/utils/richTextUtils.ts | 62 ---- implementations/react-native/types.ts | 48 --- .../react-native/utils/sdkHelpers.ts | 102 ++---- 21 files changed, 288 insertions(+), 1590 deletions(-) delete mode 100644 implementations/react-native/TestTrackingScreen.tsx delete mode 100644 implementations/react-native/components/InstructionsCard.tsx delete mode 100644 implementations/react-native/components/LoadingScreen.tsx delete mode 100644 implementations/react-native/components/MainScreen.tsx create mode 100644 implementations/react-native/components/RichTextRenderer.tsx delete mode 100644 implementations/react-native/components/SDKConfigCard.tsx delete mode 100644 implementations/react-native/components/SDKStatusCard.tsx create mode 100644 implementations/react-native/screens/MergeTagScreen.tsx delete mode 100644 implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx delete mode 100644 implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx delete mode 100644 implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx delete mode 100644 implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx delete mode 100644 implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx delete mode 100644 implementations/react-native/screens/MergeTagScreen/index.ts delete mode 100644 implementations/react-native/screens/MergeTagScreen/types.ts delete mode 100644 implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts delete mode 100644 implementations/react-native/types.ts diff --git a/implementations/react-native/App.tsx b/implementations/react-native/App.tsx index 71aa23ad..f8f48161 100644 --- a/implementations/react-native/App.tsx +++ b/implementations/react-native/App.tsx @@ -1,139 +1,57 @@ -/** - * Sample React Native App - Contentful Optimization SDK Implementation - * https://github.com/facebook/react-native - * - * @format - */ - import React, { useEffect, useState } from 'react' -import { useColorScheme } from 'react-native' +import { SafeAreaView, StyleSheet, Text } from 'react-native' import type Optimization from '@contentful/optimization-react-native' import { OptimizationProvider } from '@contentful/optimization-react-native' import type { Entry } from 'contentful' -import { LoadingScreen } from './components/LoadingScreen' -import { MainScreen } from './components/MainScreen' -import { MergeTagScreen } from './screens/MergeTagScreen' -import { TestTrackingScreen } from './TestTrackingScreen' -import type { SDKInfo, ThemeColors } from './types' -import { fetchEntriesFromMockServer, fetchMergeTagEntry, initializeSDK } from './utils/sdkHelpers' -function getThemeColors(isDarkMode: boolean): ThemeColors { - return { - backgroundColor: isDarkMode ? '#1a1a1a' : '#f5f5f5', - cardBackground: isDarkMode ? '#2d2d2d' : '#ffffff', - textColor: isDarkMode ? '#ffffff' : '#000000', - mutedTextColor: isDarkMode ? '#a0a0a0' : '#666666', - successColor: '#22c55e', - errorColor: '#ef4444', - accentColor: '#8b5cf6', - } -} +import { MergeTagScreen } from './screens/MergeTagScreen' +import { fetchMergeTagEntry, initializeSDK } from './utils/sdkHelpers' -// eslint-disable-next-line complexity -- Main app component requires conditional rendering logic function App(): React.JSX.Element { - const isDarkMode = useColorScheme() === 'dark' - - const [sdkLoaded, setSdkLoaded] = useState(false) - const [sdkError, setSdkError] = useState(null) - const [sdkInfo, setSdkInfo] = useState(null) const [sdk, setSdk] = useState(null) - const [showTestScreen, setShowTestScreen] = useState(false) - const [showMergeTagScreen, setShowMergeTagScreen] = useState(false) - const [personalizedEntry, setPersonalizedEntry] = useState(null) - const [productEntry, setProductEntry] = useState(null) const [mergeTagEntry, setMergeTagEntry] = useState(null) - const [entriesLoading, setEntriesLoading] = useState(false) + const [sdkError, setSdkError] = useState(null) useEffect(() => { - void initializeSDK(setSdkInfo, setSdk, setSdkLoaded, setSdkError) + void initializeSDK(setSdk, setSdkError) }, []) - const fetchWithErrorHandling = async ( - fetchFn: () => Promise, - errorMsg: string, - ): Promise => { - setEntriesLoading(true) - try { - await fetchFn() - } catch (error) { - setSdkError(`${errorMsg}: ${error instanceof Error ? error.message : 'Unknown error'}`) - } finally { - setEntriesLoading(false) + useEffect(() => { + if (sdk) { + void fetchMergeTagEntry(setMergeTagEntry, setSdkError) } - } - - const fetchEntries = async (): Promise => { - await fetchWithErrorHandling(async () => { - await fetchEntriesFromMockServer(setPersonalizedEntry, setProductEntry) - }, 'Failed to fetch entries') - } + }, [sdk]) - const fetchMergeTag = async (): Promise => { - await fetchWithErrorHandling(async () => { - await fetchMergeTagEntry(setMergeTagEntry) - }, 'Failed to fetch merge tag entry') - } - - const colors = getThemeColors(isDarkMode) - - const handleTestTracking = (): void => { - setShowTestScreen(true) - void fetchEntries() - } - - const handleTestMergeTags = (): void => { - setShowMergeTagScreen(true) - void fetchMergeTag() - } - - const handleBack = (): void => { - setShowTestScreen(false) - setShowMergeTagScreen(false) - } - - if (showMergeTagScreen && sdk && mergeTagEntry) { + if (sdkError) { return ( - - - + + {sdkError} + ) } - if (showTestScreen && sdk && personalizedEntry && productEntry) { + if (!sdk || !mergeTagEntry) { return ( - - - + + Loading... + ) } - if ((showTestScreen || showMergeTagScreen) && entriesLoading) { - return - } - return ( - + + + ) } +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}) + export default App diff --git a/implementations/react-native/TestTrackingScreen.tsx b/implementations/react-native/TestTrackingScreen.tsx deleted file mode 100644 index 2777a937..00000000 --- a/implementations/react-native/TestTrackingScreen.tsx +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Test Tracking Screen - Demonstrates viewport tracking functionality - * - * This screen demonstrates both the new Personalization and Analytics components - * with real Contentful entry data from the mock server. - */ - -import React, { useEffect, useState } from 'react' -import { - SafeAreaView, - StatusBar, - StyleSheet, - Text, - TouchableOpacity, - useColorScheme, - View, -} from 'react-native' - -import type Optimization from '@contentful/optimization-react-native' -import { Analytics, Personalization, ScrollProvider } from '@contentful/optimization-react-native' -import type { Entry } from 'contentful' - -interface ThemeColors { - backgroundColor: string - cardBackground: string - textColor: string - mutedTextColor: string - successColor: string - errorColor: string -} - -interface TestTrackingScreenProps { - colors: ThemeColors - onBack: () => void - sdk: Optimization - personalizedEntry: Entry - productEntry: Entry -} - -/** - * Helper to safely extract text from Contentful field values - * Handles both plain strings and rich text document objects - */ -function getFieldText(field: unknown): string { - if (typeof field === 'string') { - return field - } - - // Handle rich text document - if (field && typeof field === 'object' && 'nodeType' in field && field.nodeType === 'document') { - return '[Rich Text Content]' - } - - return '' -} - -export function TestTrackingScreen({ - colors, - onBack, - sdk, - personalizedEntry, - productEntry, -}: TestTrackingScreenProps): React.JSX.Element { - const [trackedEvents, setTrackedEvents] = useState([]) - - useEffect(() => { - // Listen to the event stream to capture tracking events - const subscription = sdk.states.eventStream.subscribe((event) => { - if (event?.type === 'component') { - const { componentId } = event as { componentId?: string } - const timestamp = new Date().toLocaleTimeString() - setTrackedEvents((prev) => [...prev, `${timestamp}: Tracked "${componentId}"`]) - } - }) - - return () => { - subscription.unsubscribe() - } - }, [sdk]) - - return ( - - - - {/* Header with back button */} - - - ← Back - - Component Tracking Test - - - - {/* Info section */} - - - Component Tracking Demo - - - Scroll down to see the {''} and {''} components using - real entries from the mock server. Each tracks when visible for a specified duration. - - - 📝 Note: "Component tracking" refers to tracking Contentful entry components (CMS - content), not React Native UI components. - - - 🔗 Using mock server data - Entry IDs: {personalizedEntry.sys.id}, {productEntry.sys.id} - - - - - Spacer Content - - This creates scrollable content. Keep scrolling to reach the tracked components below... - - - - {/* Personalization component example */} - - {(resolvedEntry) => ( - - {''} - - {getFieldText(resolvedEntry.fields.internalTitle) || 'Personalized Content'} - - - {getFieldText(resolvedEntry.fields.text) || 'Content loaded from mock server'} - - - Entry ID: {resolvedEntry.sys.id} - {'\n'} - Content Type: {resolvedEntry.sys.contentType.sys.id} - {'\n'} - Tracking: 80% visible for 2000ms - - - )} - - - - More Content - - Keep scrolling to see the Analytics component next... - - - - - - {''} - - {getFieldText(productEntry.fields.internalTitle) || 'Analytics Entry'} - - - {getFieldText(productEntry.fields.text) || 'Content loaded from mock server'} - - - Entry ID: {productEntry.sys.id} - {'\n'} - Content Type: {productEntry.sys.contentType.sys.id} - {'\n'} - Tracking: 90% visible for 1500ms (custom) - - - - - {/* Event log */} - - Event Log - {trackedEvents.length === 0 ? ( - - No events tracked yet. Scroll up to bring the tracked component into view. - - ) : ( - trackedEvents.map((event, index) => ( - - ✓ {event} - - )) - )} - - - {/* Bottom spacer */} - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - testHeader: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - }, - backButton: { - padding: 8, - }, - backButtonText: { - fontSize: 16, - fontWeight: '600', - }, - testTitle: { - fontSize: 20, - fontWeight: '600', - marginLeft: 16, - }, - fillerSection: { - padding: 24, - marginHorizontal: 16, - marginVertical: 8, - borderRadius: 12, - minHeight: 200, - }, - sectionTitle: { - fontSize: 20, - fontWeight: '600', - marginBottom: 12, - }, - sectionText: { - fontSize: 14, - lineHeight: 22, - }, - trackedView: { - padding: 24, - marginHorizontal: 16, - marginVertical: 8, - borderRadius: 12, - minHeight: 200, - }, - componentLabel: { - fontSize: 12, - fontWeight: '600', - color: 'rgba(255, 255, 255, 0.7)', - marginBottom: 8, - letterSpacing: 0.5, - }, - trackedViewTitle: { - fontSize: 24, - fontWeight: 'bold', - color: '#ffffff', - marginBottom: 12, - }, - trackedViewText: { - fontSize: 16, - color: '#ffffff', - marginBottom: 16, - lineHeight: 24, - }, - trackedViewDetails: { - fontSize: 12, - color: 'rgba(255, 255, 255, 0.8)', - fontFamily: 'monospace', - lineHeight: 18, - }, - statsSection: { - padding: 24, - marginHorizontal: 16, - marginVertical: 8, - borderRadius: 12, - }, - statText: { - fontSize: 16, - fontWeight: '600', - marginBottom: 8, - }, - eventLog: { - padding: 24, - marginHorizontal: 16, - marginVertical: 8, - borderRadius: 12, - minHeight: 150, - }, - eventText: { - fontSize: 14, - fontFamily: 'monospace', - marginBottom: 8, - }, - bottomSpacer: { - height: 100, - }, -}) diff --git a/implementations/react-native/components/InstructionsCard.tsx b/implementations/react-native/components/InstructionsCard.tsx deleted file mode 100644 index 063cbd27..00000000 --- a/implementations/react-native/components/InstructionsCard.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' -import type { InstructionsCardProps } from '../types' - -export function InstructionsCard({ - colors, - onTestTracking, - onTestMergeTags, -}: InstructionsCardProps): React.JSX.Element { - const { cardBackground, textColor, mutedTextColor } = colors - - return ( - - Next Steps - - • The Optimization SDK is now initialized and ready to use{'\n'}• You can now implement - experiences and personalization{'\n'}• Check the console for additional SDK logs{'\n'}• - Modify this app to test SDK features - - - - Test Viewport Tracking → - - - - Test Merge Tags → - - - ) -} - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - cardTitle: { - fontSize: 20, - fontWeight: '600', - marginBottom: 16, - }, - instructionText: { - fontSize: 14, - lineHeight: 22, - }, - testButton: { - marginTop: 20, - padding: 16, - borderRadius: 8, - alignItems: 'center', - }, - testButtonText: { - color: '#ffffff', - fontSize: 16, - fontWeight: '600', - }, -}) diff --git a/implementations/react-native/components/LoadingScreen.tsx b/implementations/react-native/components/LoadingScreen.tsx deleted file mode 100644 index 5fda867c..00000000 --- a/implementations/react-native/components/LoadingScreen.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' -import { ActivityIndicator, SafeAreaView, StatusBar, StyleSheet, Text, View } from 'react-native' -import type { LoadingScreenProps } from '../types' - -export function LoadingScreen({ colors, isDarkMode }: LoadingScreenProps): React.JSX.Element { - return ( - - - - - - Loading entries from mock server... - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - loadingText: { - marginTop: 16, - fontSize: 16, - }, -}) diff --git a/implementations/react-native/components/MainScreen.tsx b/implementations/react-native/components/MainScreen.tsx deleted file mode 100644 index df0a9d25..00000000 --- a/implementations/react-native/components/MainScreen.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' -import { SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native' -import type { MainScreenProps } from '../types' -import { InstructionsCard } from './InstructionsCard' -import { SDKConfigCard } from './SDKConfigCard' -import { SDKStatusCard } from './SDKStatusCard' - -export function MainScreen({ - colors, - isDarkMode, - sdkLoaded, - sdkError, - sdkInfo, - onTestTracking, - onTestMergeTags, -}: MainScreenProps): React.JSX.Element { - return ( - - - - - - Contentful Optimization - - - React Native SDK Implementation - - - - - - {sdkInfo && } - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollContent: { - padding: 20, - paddingTop: 40, - }, - header: { - marginBottom: 30, - }, - title: { - fontSize: 32, - fontWeight: 'bold', - marginBottom: 8, - }, - subtitle: { - fontSize: 16, - fontWeight: '500', - }, -}) diff --git a/implementations/react-native/components/RichTextRenderer.tsx b/implementations/react-native/components/RichTextRenderer.tsx new file mode 100644 index 00000000..a39d3718 --- /dev/null +++ b/implementations/react-native/components/RichTextRenderer.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useState } from 'react' +import { Text, View } from 'react-native' + +import type Optimization from '@contentful/optimization-react-native' +import type { MergeTagEntry, Profile } from '@contentful/optimization-react-native' +import type { Entry } from 'contentful' + +interface RichTextNode { + nodeType: string + data?: unknown + content?: RichTextNode[] + value?: string +} + +interface RichTextField { + nodeType: 'document' + content: RichTextNode[] +} + +interface EmbeddedEntryInlineNode { + nodeType: 'embedded-entry-inline' + data: { + target: { + sys: { + id: string + type: string + linkType: string + } + } + } +} + +interface RichTextRendererProps { + richText: RichTextField + sdk: Optimization + includes?: { Entry?: Entry[] } +} + +function isMergeTagEntry(entry: Entry): entry is MergeTagEntry { + return entry.sys.contentType.sys.id === 'nt_mergetag' +} + +function isEmbeddedEntryInline(node: RichTextNode): node is EmbeddedEntryInlineNode { + return node.nodeType === 'embedded-entry-inline' +} + +function renderTextNode(node: RichTextNode): string | null { + if (node.nodeType === 'text' && node.value) { + return node.value + } + return null +} + +function renderEmbeddedEntry( + node: EmbeddedEntryInlineNode, + includes: { Entry?: Entry[] } | undefined, + sdk: Optimization, + profile: Profile | undefined, +): string { + const { + data: { + target: { + sys: { id: targetId }, + }, + }, + } = node + const includedEntry = includes?.Entry?.find((entry) => entry.sys.id === targetId) + + if (includedEntry && isMergeTagEntry(includedEntry) && profile) { + const resolvedValue = sdk.personalization.getMergeTagValue(includedEntry, profile) + return resolvedValue?.toString() ?? '' + } + + return '[Merge Tag]' +} + +export function RichTextRenderer({ + richText, + sdk, + includes, +}: RichTextRendererProps): React.JSX.Element { + const [profile, setProfile] = useState(undefined) + + useEffect(() => { + const subscription = sdk.states.profile.subscribe((currentProfile) => { + setProfile(currentProfile) + }) + + return () => { + subscription.unsubscribe() + } + }, [sdk]) + + const renderNode = (node: RichTextNode, index: number): React.ReactNode => { + const textContent = renderTextNode(node) + if (textContent) { + return textContent + } + + if (node.nodeType === 'paragraph' && node.content) { + return ( + + {node.content.map((child, childIndex) => renderNode(child, childIndex))} + + ) + } + + if (isEmbeddedEntryInline(node)) { + return renderEmbeddedEntry(node, includes, sdk, profile) + } + + if (node.content) { + return node.content.map((child, childIndex) => renderNode(child, childIndex)) + } + + return null + } + + return {richText.content.map((node, index) => renderNode(node, index))} +} diff --git a/implementations/react-native/components/SDKConfigCard.tsx b/implementations/react-native/components/SDKConfigCard.tsx deleted file mode 100644 index 50a9c0bc..00000000 --- a/implementations/react-native/components/SDKConfigCard.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react' -import { StyleSheet, Text, View } from 'react-native' -import type { SDKConfigCardProps } from '../types' - -export function SDKConfigCard({ sdkInfo, colors }: SDKConfigCardProps): React.JSX.Element { - const { cardBackground, textColor, mutedTextColor } = colors - - return ( - - Configuration - - - Client ID: - - {sdkInfo.clientId} - - - - - Environment: - - {sdkInfo.environment} - - - - - Initialized At: - - {new Date(sdkInfo.timestamp).toLocaleTimeString()} - - - - ) -} - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - cardTitle: { - fontSize: 20, - fontWeight: '600', - marginBottom: 16, - }, - infoRow: { - marginBottom: 12, - }, - infoLabel: { - fontSize: 14, - fontWeight: '500', - marginBottom: 4, - }, - infoValue: { - fontSize: 16, - fontWeight: '400', - fontFamily: 'monospace', - }, -}) diff --git a/implementations/react-native/components/SDKStatusCard.tsx b/implementations/react-native/components/SDKStatusCard.tsx deleted file mode 100644 index ec33f759..00000000 --- a/implementations/react-native/components/SDKStatusCard.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react' -import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' -import type { SDKStatusCardProps } from '../types' - -export function SDKStatusCard({ - sdkLoaded, - sdkError, - colors, -}: SDKStatusCardProps): React.JSX.Element { - const { cardBackground, textColor, mutedTextColor, successColor, errorColor } = colors - - return ( - - SDK Status - - {!sdkLoaded && !sdkError && ( - - - Initializing SDK... - - )} - - {sdkLoaded && ( - - - - ✓ SDK Loaded Successfully - - - )} - - {sdkError && ( - - - - ✗ Error: {sdkError} - - - )} - - ) -} - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - cardTitle: { - fontSize: 20, - fontWeight: '600', - marginBottom: 16, - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - statusIndicator: { - width: 12, - height: 12, - borderRadius: 6, - }, - statusText: { - fontSize: 16, - fontWeight: '500', - }, -}) diff --git a/implementations/react-native/e2e/app.test.js b/implementations/react-native/e2e/app.test.js index 5ec4c87c..0d071baf 100644 --- a/implementations/react-native/e2e/app.test.js +++ b/implementations/react-native/e2e/app.test.js @@ -1,4 +1,4 @@ -describe('ReactNativeApp - Contentful Optimization SDK', () => { +describe('ReactNativeApp - Merge Tag Reference Implementation', () => { beforeAll(async () => { await device.launchApp() }) @@ -7,82 +7,35 @@ describe('ReactNativeApp - Contentful Optimization SDK', () => { await device.reloadReactNative() }) - describe('App Header', () => { - it('should display app title', async () => { - await expect(element(by.id('appTitle'))).toBeVisible() - await expect(element(by.text('Contentful Optimization'))).toBeVisible() - }) - - it('should display app subtitle', async () => { - await expect(element(by.id('appSubtitle'))).toBeVisible() - await expect(element(by.text('React Native SDK Implementation'))).toBeVisible() - }) - }) - - describe('SDK Status Card', () => { - it('should display SDK status card', async () => { - await expect(element(by.id('sdkStatusCard'))).toBeVisible() - }) - - it('should show SDK initialization or success state', async () => { - // Wait for either initializing or loaded state - await waitFor(element(by.id('sdkLoaded'))) - .toBeVisible() - .withTimeout(10000) - - await expect(element(by.id('sdkLoadedText'))).toBeVisible() - }) - }) - - describe('SDK Configuration Card', () => { - it('should display configuration card when SDK is loaded', async () => { - // Wait for SDK to load - await waitFor(element(by.id('sdkLoaded'))) + describe('Merge Tag Rendering', () => { + it('should display the merge tag screen', async () => { + await waitFor(element(by.text(/This is a merge tag content entry/i))) .toBeVisible() .withTimeout(10000) - - // Configuration card should be visible - await expect(element(by.id('sdkConfigCard'))).toBeVisible() }) - it('should display client ID', async () => { - await waitFor(element(by.id('clientIdValue'))) + it('should render rich text with resolved merge tag value', async () => { + await waitFor(element(by.text(/continent/i))) .toBeVisible() .withTimeout(10000) }) - it('should display environment', async () => { - await waitFor(element(by.id('environmentValue'))) - .toBeVisible() - .withTimeout(10000) - }) + it('should display resolved merge tag inline with text', async () => { + const expectedPattern = + /This is a merge tag content entry that displays the visitor's continent/i - it('should display timestamp', async () => { - await waitFor(element(by.id('timestampValue'))) + await waitFor(element(by.text(expectedPattern))) .toBeVisible() .withTimeout(10000) }) }) - describe('Instructions Card', () => { - it('should display instructions card', async () => { - await expect(element(by.id('instructionsCard'))).toBeVisible() - }) - - it('should display instructions text', async () => { - await expect(element(by.id('instructionsText'))).toBeVisible() - }) - }) - describe('Error Handling', () => { - it('should handle SDK errors gracefully', async () => { - // If there's an error, the error text should be visible - // This test will pass if no error occurs (normal flow) + it('should handle missing rich text field gracefully', async () => { try { - await expect(element(by.id('sdkError'))).not.toBeVisible() + await expect(element(by.text('No rich text field found'))).not.toBeVisible() } catch (e) { - // If error is visible, verify the error text element exists - await expect(element(by.id('sdkErrorText'))).toBeVisible() + await expect(element(by.text('No rich text field found'))).toBeVisible() } }) }) diff --git a/implementations/react-native/env.config.ts b/implementations/react-native/env.config.ts index fb6e5432..a88ba6f7 100644 --- a/implementations/react-native/env.config.ts +++ b/implementations/react-native/env.config.ts @@ -28,7 +28,7 @@ interface EnvConfig { } } -export const ENV_CONFIG: EnvConfig = { +export const ENV_CONFIG = { // Contentful Configuration contentful: { spaceId: 'test-space', @@ -56,4 +56,9 @@ export const ENV_CONFIG: EnvConfig = { product: '1MwiFl4z7gkwqGYdvCmr8c', // Simple content entry mergeTag: '1MwiFl4z7gkwqGYdvCmr8c', // Entry with merge tag in rich text }, -} +} as const satisfies EnvConfig + +export const { + contentful: { spaceId: CONTENTFUL_SPACE_ID, accessToken: CONTENTFUL_ACCESS_TOKEN }, + optimization: { clientId: NINETAILED_CLIENT_ID }, +} = ENV_CONFIG diff --git a/implementations/react-native/screens/MergeTagScreen.tsx b/implementations/react-native/screens/MergeTagScreen.tsx new file mode 100644 index 00000000..3911100f --- /dev/null +++ b/implementations/react-native/screens/MergeTagScreen.tsx @@ -0,0 +1,84 @@ +import React, { useEffect } from 'react' +import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native' + +import type Optimization from '@contentful/optimization-react-native' +import type { Entry } from 'contentful' + +import { RichTextRenderer } from '../components/RichTextRenderer' + +interface MergeTagScreenProps { + sdk: Optimization + mergeTagEntry: Entry +} + +interface RichTextNode { + nodeType: string + data?: unknown + content?: RichTextNode[] + value?: string +} + +interface RichTextField { + nodeType: 'document' + content: RichTextNode[] +} + +interface EntryWithIncludes extends Entry { + includes?: { + Entry?: Entry[] + } +} + +function isRichTextField(field: unknown): field is RichTextField { + return ( + typeof field === 'object' && + field !== null && + 'nodeType' in field && + (field as { nodeType: unknown }).nodeType === 'document' && + 'content' in field && + Array.isArray((field as { content: unknown }).content) + ) +} + +export function MergeTagScreen({ sdk, mergeTagEntry }: MergeTagScreenProps): React.JSX.Element { + useEffect(() => { + void sdk.personalization.page({ properties: { url: 'merge-tags' } }) + }, [sdk]) + + const richTextField = Object.values(mergeTagEntry.fields).find(isRichTextField) + const entryWithIncludes = mergeTagEntry as EntryWithIncludes + + if (!richTextField) { + return ( + + No rich text field found + + ) + } + + return ( + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + padding: 16, + }, + textContainer: { + marginVertical: 8, + }, +}) diff --git a/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx b/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx deleted file mode 100644 index cb861dd8..00000000 --- a/implementations/react-native/screens/MergeTagScreen/MergeTagScreen.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Merge Tag Test Screen - Demonstrates merge tag resolution functionality - * - * This screen shows how merge tags embedded in Contentful rich text fields - * are resolved using the current user profile data. - */ - -import React, { useEffect, useState } from 'react' -import { - SafeAreaView, - ScrollView, - StatusBar, - StyleSheet, - Text, - TouchableOpacity, - useColorScheme, - View, -} from 'react-native' - -import type { MergeTagEntry, Profile } from '@contentful/optimization-react-native' - -import { InfoCard } from './components/InfoCard' -import { MergeTagDetailCard } from './components/MergeTagDetailCard' -import { ProfileCard } from './components/ProfileCard' -import { TextDisplayCard } from './components/TextDisplayCard' -import type { EntryWithIncludes, MergeTagScreenProps } from './types' -import { isMergeTagEntry, isRichTextField } from './types' -import { extractTextFromRichText, findMergeTagEntries } from './utils/richTextUtils' - -export function MergeTagScreen({ - colors, - onBack, - sdk, - mergeTagEntry, -}: MergeTagScreenProps): React.JSX.Element { - const [profile, setProfile] = useState(undefined) - const [resolvedValues, setResolvedValues] = useState>([]) - const [mergeTagDetails, setMergeTagDetails] = useState([]) - - useEffect(() => { - void sdk.personalization.page({ url: 'merge-tags-demo' }) - }, [sdk]) - - useEffect(() => { - const subscription = sdk.states.profile.subscribe((currentProfile) => { - setProfile(currentProfile) - }) - - return () => { - subscription.unsubscribe() - } - }, [sdk]) - - useEffect(() => { - const { fields } = mergeTagEntry - const richTextField = Object.values(fields).find(isRichTextField) - - if (richTextField && profile) { - const embeddedNodes = findMergeTagEntries(richTextField) - - const entryWithIncludes = mergeTagEntry as EntryWithIncludes - const { includes } = entryWithIncludes - const includedEntries = includes?.Entry ?? [] - - const mergeTagEntriesList: MergeTagEntry[] = [] - const resolvedValuesList: Array<{ id: string; value: unknown }> = [] - - embeddedNodes.forEach((node) => { - const { data } = node - const { target } = data - const { sys } = target - const { id: targetId } = sys - const includedEntry = includedEntries.find((entry) => entry.sys.id === targetId) - - if (includedEntry && isMergeTagEntry(includedEntry)) { - mergeTagEntriesList.push(includedEntry) - - const resolvedValue = sdk.personalization.getMergeTagValue(includedEntry, profile) - const mergeTagFields = includedEntry.fields as { - nt_mergetag_id: string - } - resolvedValuesList.push({ - id: mergeTagFields.nt_mergetag_id, - value: resolvedValue, - }) - } - }) - - setMergeTagDetails(mergeTagEntriesList) - setResolvedValues(resolvedValuesList) - } - }, [mergeTagEntry, sdk, profile]) - - const richTextField = Object.values(mergeTagEntry.fields).find(isRichTextField) - - const originalText = richTextField ? extractTextFromRichText(richTextField) : '' - const resolvedText = originalText.replace( - /\[MERGE TAG\]/g, - () => resolvedValues[0]?.value?.toString() ?? '[NOT RESOLVED]', - ) - - return ( - - - - - - ← Back - - Merge Tags Demo - - - - - - - - - - - - - - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - }, - backButton: { - padding: 8, - }, - backButtonText: { - fontSize: 16, - fontWeight: '600', - }, - title: { - fontSize: 20, - fontWeight: '600', - marginLeft: 16, - }, - scrollContent: { - padding: 16, - }, - bottomSpacer: { - height: 40, - }, -}) diff --git a/implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx deleted file mode 100644 index 0f732e10..00000000 --- a/implementations/react-native/screens/MergeTagScreen/components/InfoCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * InfoCard - Displays informational content in a card layout - */ - -import React from 'react' -import { StyleSheet, Text, View } from 'react-native' - -import type { ThemeColors } from '../types' - -interface InfoCardProps { - title: string - content: string - colors: ThemeColors - accentBackground?: boolean -} - -export function InfoCard({ - title, - content, - colors, - accentBackground = false, -}: InfoCardProps): React.JSX.Element { - const backgroundColor = accentBackground ? colors.accentColor : colors.cardBackground - const titleColor = accentBackground ? '#ffffff' : colors.textColor - const contentColor = accentBackground ? '#ffffff' : colors.mutedTextColor - - return ( - - {title} - {content} - - ) -} - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - title: { - fontSize: 18, - fontWeight: '600', - marginBottom: 12, - }, - content: { - fontSize: 14, - lineHeight: 22, - }, -}) - diff --git a/implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx deleted file mode 100644 index d02d8c4c..00000000 --- a/implementations/react-native/screens/MergeTagScreen/components/MergeTagDetailCard.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * MergeTagDetailCard - Displays details about merge tags and their resolved values - */ - -import React from 'react' -import { StyleSheet, Text, View } from 'react-native' - -import type { MergeTagEntry } from '@contentful/optimization-react-native' - -import type { ThemeColors } from '../types' - -interface MergeTagDetailCardProps { - mergeTagDetails: MergeTagEntry[] - resolvedValues: Array<{ id: string; value: unknown }> - colors: ThemeColors -} - -interface DetailRowProps { - label: string - value: string - colors: ThemeColors - highlight?: boolean -} - -function DetailRow({ label, value, colors, highlight = false }: DetailRowProps): React.JSX.Element { - return ( - - {label}: - - {value} - - - ) -} - -export function MergeTagDetailCard({ - mergeTagDetails, - resolvedValues, - colors, -}: MergeTagDetailCardProps): React.JSX.Element | null { - if (mergeTagDetails.length === 0) return null - - return ( - - Merge Tag Details - {mergeTagDetails.map((mergeTag, index) => { - const tagFields = mergeTag.fields as { - nt_name: string - nt_mergetag_id: string - nt_fallback?: string - } - return ( - - - - - - - ) - })} - - ) -} - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - title: { - fontSize: 18, - fontWeight: '600', - marginBottom: 12, - }, - detailSection: { - marginTop: 8, - }, - detailRow: { - flexDirection: 'row', - marginBottom: 8, - alignItems: 'flex-start', - }, - detailLabel: { - fontSize: 14, - fontWeight: '600', - width: 120, - }, - detailValue: { - fontSize: 14, - flex: 1, - fontFamily: 'monospace', - }, - highlightedValue: { - fontSize: 16, - fontWeight: '600', - flex: 1, - }, -}) diff --git a/implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx deleted file mode 100644 index 380732c1..00000000 --- a/implementations/react-native/screens/MergeTagScreen/components/ProfileCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ProfileCard - Displays current user profile information - */ - -import React from 'react' -import { StyleSheet, Text, View } from 'react-native' - -import type { Profile } from '@contentful/optimization-react-native' - -import type { ThemeColors } from '../types' - -interface ProfileCardProps { - profile: Profile | undefined - colors: ThemeColors -} - -interface DetailRowProps { - label: string - value: string - colors: ThemeColors - ellipsizeMode?: 'middle' | 'head' | 'tail' | 'clip' -} - -function DetailRow({ label, value, colors, ellipsizeMode }: DetailRowProps): React.JSX.Element { - return ( - - {label}: - - {value} - - - ) -} - -export function ProfileCard({ profile, colors }: ProfileCardProps): React.JSX.Element | null { - if (!profile) return null - - return ( - - Current Profile Data - - - - - - - - ) -} - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - title: { - fontSize: 18, - fontWeight: '600', - marginBottom: 12, - }, - detailSection: { - marginTop: 8, - }, - detailRow: { - flexDirection: 'row', - marginBottom: 8, - alignItems: 'flex-start', - }, - detailLabel: { - fontSize: 14, - fontWeight: '600', - width: 120, - }, - detailValue: { - fontSize: 14, - flex: 1, - fontFamily: 'monospace', - }, -}) diff --git a/implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx b/implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx deleted file mode 100644 index 8a9c1294..00000000 --- a/implementations/react-native/screens/MergeTagScreen/components/TextDisplayCard.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * TextDisplayCard - Displays text content with optional monospace styling - */ - -import React from 'react' -import { StyleSheet, Text, View } from 'react-native' - -import type { ThemeColors } from '../types' - -interface TextDisplayCardProps { - title: string - content: string - colors: ThemeColors - monospace?: boolean - accentBackground?: boolean -} - -export function TextDisplayCard({ - title, - content, - colors, - monospace = false, - accentBackground = false, -}: TextDisplayCardProps): React.JSX.Element { - const backgroundColor = accentBackground ? colors.accentColor : colors.cardBackground - const titleColor = accentBackground ? '#ffffff' : colors.textColor - const contentColor = accentBackground ? '#ffffff' : colors.mutedTextColor - - return ( - - {title} - - {content} - - - ) -} - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - padding: 20, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 3, - }, - title: { - fontSize: 18, - fontWeight: '600', - marginBottom: 12, - }, - regularContent: { - fontSize: 16, - lineHeight: 24, - fontWeight: '500', - }, - monospaceContent: { - fontSize: 14, - lineHeight: 22, - fontFamily: 'monospace', - }, -}) - diff --git a/implementations/react-native/screens/MergeTagScreen/index.ts b/implementations/react-native/screens/MergeTagScreen/index.ts deleted file mode 100644 index b145ed50..00000000 --- a/implementations/react-native/screens/MergeTagScreen/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * MergeTagScreen barrel export - */ - -export { MergeTagScreen } from './MergeTagScreen' -export type { MergeTagScreenProps, ThemeColors } from './types' - diff --git a/implementations/react-native/screens/MergeTagScreen/types.ts b/implementations/react-native/screens/MergeTagScreen/types.ts deleted file mode 100644 index 1ff20114..00000000 --- a/implementations/react-native/screens/MergeTagScreen/types.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Type definitions for MergeTagScreen - */ - -import type Optimization from '@contentful/optimization-react-native' -import type { MergeTagEntry } from '@contentful/optimization-react-native' -import type { Entry } from 'contentful' - -export interface ThemeColors { - backgroundColor: string - cardBackground: string - textColor: string - mutedTextColor: string - successColor: string - errorColor: string - accentColor: string -} - -export interface MergeTagScreenProps { - colors: ThemeColors - onBack: () => void - sdk: Optimization - mergeTagEntry: Entry -} - -export interface RichTextNode { - nodeType: string - data?: { - target?: { - sys?: { - id?: string - type?: string - linkType?: string - } - } - } - content?: RichTextNode[] -} - -export interface RichTextField { - nodeType: 'document' - content: RichTextNode[] - data: Record -} - -export interface EmbeddedEntryNode { - nodeType: string - data: { - target: { - sys: { - id: string - type: string - linkType: string - } - } - } -} - -export interface TextNode { - nodeType: string - value: string -} - -export function isRichTextField(field: unknown): field is RichTextField { - return ( - typeof field === 'object' && - field !== null && - 'nodeType' in field && - (field as { nodeType: unknown }).nodeType === 'document' && - 'content' in field && - Array.isArray((field as { content: unknown }).content) - ) -} - -export interface EntryWithIncludes extends Entry { - includes?: { - Entry?: Entry[] - } -} - -export function isMergeTagEntry(entry: Entry): entry is MergeTagEntry { - return entry.sys.contentType.sys.id === 'nt_mergetag' -} diff --git a/implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts b/implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts deleted file mode 100644 index e85039d3..00000000 --- a/implementations/react-native/screens/MergeTagScreen/utils/richTextUtils.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Utility functions for processing Contentful rich text fields - */ - -import type { - EmbeddedEntryNode, - RichTextField, - RichTextNode, - TextNode, -} from '../types' - -export function findMergeTagEntries( - fragment: RichTextField | RichTextNode, - mergeTagEntries: EmbeddedEntryNode[] = [], -): EmbeddedEntryNode[] { - if (!fragment.content) return mergeTagEntries - - const embeddedEntries = fragment.content.filter( - (item): item is EmbeddedEntryNode => - item.nodeType.startsWith('embedded') && - 'data' in item && - item.data?.target?.sys?.id !== undefined, - ) - - mergeTagEntries.push(...embeddedEntries) - - fragment.content - .filter((item): item is RichTextNode => 'content' in item && Array.isArray(item.content)) - .forEach((item) => findMergeTagEntries(item, mergeTagEntries)) - - return mergeTagEntries -} - -export function isTextNode(item: unknown): item is TextNode { - return ( - typeof item === 'object' && - item !== null && - 'nodeType' in item && - 'value' in item && - typeof (item as { value: unknown }).value === 'string' - ) -} - -export function extractTextFromRichText(node: RichTextField | RichTextNode): string { - if (!node.content) return '' - - return node.content - .map((item) => { - if (item.nodeType === 'text' && isTextNode(item)) { - return item.value - } - if (item.nodeType.startsWith('embedded')) { - return '[MERGE TAG]' - } - if ('content' in item) { - return extractTextFromRichText(item) - } - return '' - }) - .join('') -} - diff --git a/implementations/react-native/types.ts b/implementations/react-native/types.ts deleted file mode 100644 index 51e87861..00000000 --- a/implementations/react-native/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -export interface SDKInfo { - clientId: string - environment: string - initialized: boolean - timestamp: string -} - -export interface ThemeColors { - backgroundColor: string - cardBackground: string - textColor: string - mutedTextColor: string - successColor: string - errorColor: string - accentColor: string -} - -export interface SDKStatusCardProps { - sdkLoaded: boolean - sdkError: string | null - colors: ThemeColors -} - -export interface SDKConfigCardProps { - sdkInfo: SDKInfo - colors: ThemeColors -} - -export interface InstructionsCardProps { - colors: ThemeColors - onTestTracking: () => void - onTestMergeTags: () => void -} - -export interface LoadingScreenProps { - colors: ThemeColors - isDarkMode: boolean -} - -export interface MainScreenProps { - colors: ThemeColors - isDarkMode: boolean - sdkLoaded: boolean - sdkError: string | null - sdkInfo: SDKInfo | null - onTestTracking: () => void - onTestMergeTags: () => void -} diff --git a/implementations/react-native/utils/sdkHelpers.ts b/implementations/react-native/utils/sdkHelpers.ts index bc760b83..f13ad112 100644 --- a/implementations/react-native/utils/sdkHelpers.ts +++ b/implementations/react-native/utils/sdkHelpers.ts @@ -1,86 +1,56 @@ import type Optimization from '@contentful/optimization-react-native' +import { logger } from '@contentful/optimization-react-native' import { createClient, type Entry } from 'contentful' -import { ENV_CONFIG } from '../env.config' -import type { SDKInfo } from '../types' + +const INCLUDE_DEPTH = 10 +const MERGE_TAG_ENTRY_ID = '1MwiFl4z7gkwqGYdvCmr8c' export async function initializeSDK( - setSdkInfo: (info: SDKInfo) => void, setSdk: (sdk: Optimization) => void, - setSdkLoaded: (loaded: boolean) => void, - setSdkError: (error: string | null) => void, + setSdkError: (error: string) => void, ): Promise { - const { default: Optimization } = await import('@contentful/optimization-react-native') - try { - const { - optimization: { clientId, environment }, - api: { experienceBaseUrl, insightsBaseUrl }, - } = ENV_CONFIG + const { default: OptimizationSDK } = await import('@contentful/optimization-react-native') - const sdkInstance = await Optimization.create({ - clientId, - environment, + const sdkInstance = await OptimizationSDK.create({ + clientId: 'test-client-id', + environment: 'main', api: { - personalization: { baseUrl: experienceBaseUrl }, - analytics: { baseUrl: insightsBaseUrl }, + personalization: { baseUrl: 'http://localhost/experience/' }, + analytics: { baseUrl: 'http://localhost/insights/' }, }, }) - setSdkInfo({ - clientId, - environment, - initialized: true, - timestamp: new Date().toISOString(), - }) setSdk(sdkInstance) - setSdkLoaded(true) - } catch (error) { - setSdkError(error instanceof Error ? error.message : 'Unknown error') + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + setSdkError(`Failed to initialize SDK: ${errorMessage}`) } } -export async function fetchEntriesFromMockServer( - setPersonalizedEntry: (entry: Entry) => void, - setProductEntry: (entry: Entry) => void, +export async function fetchMergeTagEntry( + setMergeTagEntry: (entry: Entry | null) => void, + setSdkError: (error: string) => void, ): Promise { - const { - contentful: { spaceId, environment, accessToken, host, basePath }, - entries: { personalized, product }, - } = ENV_CONFIG - - const contentful = createClient({ - space: spaceId, - environment, - accessToken, - host, - basePath, - insecure: true, - }) - - const [personalizedEntryData, productEntryData] = await Promise.all([ - contentful.getEntry(personalized, { include: 10 }), - contentful.getEntry(product, { include: 10 }), - ]) - - setPersonalizedEntry(personalizedEntryData) - setProductEntry(productEntryData) -} - -export async function fetchMergeTagEntry(setMergeTagEntry: (entry: Entry) => void): Promise { - const { - contentful: { spaceId, environment, accessToken, host, basePath }, - entries: { mergeTag }, - } = ENV_CONFIG + try { + const client = createClient({ + space: 'test-space', + environment: 'master', + accessToken: 'test-token', + host: 'localhost', + basePath: '/contentful', + insecure: true, + }) - const contentful = createClient({ - space: spaceId, - environment, - accessToken, - host, - basePath, - insecure: true, - }) + const mergeTagEntryData = await client.getEntry(MERGE_TAG_ENTRY_ID, { + include: INCLUDE_DEPTH, + }) - const mergeTagEntryData = await contentful.getEntry(mergeTag, { include: 10 }) - setMergeTagEntry(mergeTagEntryData) + setMergeTagEntry(mergeTagEntryData) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorText = `Failed to fetch merge tag entry: ${errorMessage}` + logger.error(errorText) + setSdkError(errorText) + } } From 8e75e2122d58deac7093d9475756ab794fd8bc4d Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Tue, 11 Nov 2025 10:48:37 +0100 Subject: [PATCH 5/5] [NT-1754] Properly handle returned value from CDA SDK for merge tag --- .../components/RichTextRenderer.tsx | 81 +++++++++++++------ .../react-native/screens/MergeTagScreen.tsx | 32 ++++---- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/implementations/react-native/components/RichTextRenderer.tsx b/implementations/react-native/components/RichTextRenderer.tsx index a39d3718..ccd741d7 100644 --- a/implementations/react-native/components/RichTextRenderer.tsx +++ b/implementations/react-native/components/RichTextRenderer.tsx @@ -3,6 +3,7 @@ import { Text, View } from 'react-native' import type Optimization from '@contentful/optimization-react-native' import type { MergeTagEntry, Profile } from '@contentful/optimization-react-native' +import { logger } from '@contentful/optimization-react-native' import type { Entry } from 'contentful' interface RichTextNode { @@ -20,20 +21,13 @@ interface RichTextField { interface EmbeddedEntryInlineNode { nodeType: 'embedded-entry-inline' data: { - target: { - sys: { - id: string - type: string - linkType: string - } - } + target: Entry } } interface RichTextRendererProps { richText: RichTextField sdk: Optimization - includes?: { Entry?: Entry[] } } function isMergeTagEntry(entry: Entry): entry is MergeTagEntry { @@ -51,38 +45,73 @@ function renderTextNode(node: RichTextNode): string | null { return null } +function logAndReturnFallback(message: string): string { + logger.error(`[RichTextRenderer] ${message}`) + return '[Merge Tag]' +} + +function convertToString(value: unknown): string { + if (typeof value === 'object' && value !== null) { + return JSON.stringify(value) + } + return String(value) +} + +function resolveMergeTagValue( + includedEntry: MergeTagEntry, + sdk: Optimization, + profile: Profile, +): string { + const resolvedValue = sdk.personalization.getMergeTagValue(includedEntry, profile) + + if (resolvedValue === undefined || resolvedValue === null) { + logger.error( + `[RichTextRenderer] Failed to resolve merge tag: getMergeTagValue returned ${String(resolvedValue)} for merge tag "${includedEntry.fields.nt_name}" (nt_mergetag_id: ${includedEntry.fields.nt_mergetag_id})`, + ) + return includedEntry.fields.nt_fallback?.toString() ?? '[Merge Tag]' + } + + const valueString = convertToString(resolvedValue) + logger.debug( + `[RichTextRenderer] Successfully resolved merge tag "${includedEntry.fields.nt_name}" to value: ${valueString}`, + ) + return valueString +} + function renderEmbeddedEntry( node: EmbeddedEntryInlineNode, - includes: { Entry?: Entry[] } | undefined, sdk: Optimization, profile: Profile | undefined, ): string { const { - data: { - target: { - sys: { id: targetId }, - }, - }, + data: { target: includedEntry }, } = node - const includedEntry = includes?.Entry?.find((entry) => entry.sys.id === targetId) - if (includedEntry && isMergeTagEntry(includedEntry) && profile) { - const resolvedValue = sdk.personalization.getMergeTagValue(includedEntry, profile) - return resolvedValue?.toString() ?? '' + if (!isMergeTagEntry(includedEntry)) { + return logAndReturnFallback( + `Failed to resolve merge tag: entry with ID "${includedEntry.sys.id}" is not a merge tag entry (contentType: ${includedEntry.sys.contentType.sys.id})`, + ) } - return '[Merge Tag]' + if (!profile) { + return logAndReturnFallback( + `Failed to resolve merge tag: no profile available for merge tag "${includedEntry.fields.nt_name}" (ID: ${includedEntry.sys.id})`, + ) + } + + return resolveMergeTagValue(includedEntry, sdk, profile) } -export function RichTextRenderer({ - richText, - sdk, - includes, -}: RichTextRendererProps): React.JSX.Element { +export function RichTextRenderer({ richText, sdk }: RichTextRendererProps): React.JSX.Element { const [profile, setProfile] = useState(undefined) - useEffect(() => { const subscription = sdk.states.profile.subscribe((currentProfile) => { + logger.debug( + '[RichTextRenderer] Profile received:', + currentProfile + ? `ID: ${currentProfile.id}, location.continent: ${String(currentProfile.location.continent)}` + : 'undefined', + ) setProfile(currentProfile) }) @@ -106,7 +135,7 @@ export function RichTextRenderer({ } if (isEmbeddedEntryInline(node)) { - return renderEmbeddedEntry(node, includes, sdk, profile) + return renderEmbeddedEntry(node, sdk, profile) } if (node.content) { diff --git a/implementations/react-native/screens/MergeTagScreen.tsx b/implementations/react-native/screens/MergeTagScreen.tsx index 3911100f..92f46312 100644 --- a/implementations/react-native/screens/MergeTagScreen.tsx +++ b/implementations/react-native/screens/MergeTagScreen.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react' import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native' import type Optimization from '@contentful/optimization-react-native' +import { logger } from '@contentful/optimization-react-native' import type { Entry } from 'contentful' import { RichTextRenderer } from '../components/RichTextRenderer' @@ -23,12 +24,6 @@ interface RichTextField { content: RichTextNode[] } -interface EntryWithIncludes extends Entry { - includes?: { - Entry?: Entry[] - } -} - function isRichTextField(field: unknown): field is RichTextField { return ( typeof field === 'object' && @@ -42,11 +37,24 @@ function isRichTextField(field: unknown): field is RichTextField { export function MergeTagScreen({ sdk, mergeTagEntry }: MergeTagScreenProps): React.JSX.Element { useEffect(() => { - void sdk.personalization.page({ properties: { url: 'merge-tags' } }) - }, [sdk]) + const subscription = sdk.states.profile.subscribe((profile) => { + logger.info('[MergeTagScreen] Profile updated:', JSON.stringify(profile, null, 2)) + }) + + void sdk.personalization + .page({ properties: { url: 'merge-tags' } }) + .then((response) => { + logger.info('[MergeTagScreen] Page call response:', JSON.stringify(response, null, 2)) + }) + .catch((error: unknown) => { + logger.error('[MergeTagScreen] Page call error:', error) + }) + return () => { + subscription.unsubscribe() + } + }, [sdk]) const richTextField = Object.values(mergeTagEntry.fields).find(isRichTextField) - const entryWithIncludes = mergeTagEntry as EntryWithIncludes if (!richTextField) { return ( @@ -60,11 +68,7 @@ export function MergeTagScreen({ sdk, mergeTagEntry }: MergeTagScreenProps): Rea - +