diff --git a/implementations/react-native/App.tsx b/implementations/react-native/App.tsx
index 7d0b2069..f8f48161 100644
--- a/implementations/react-native/App.tsx
+++ b/implementations/react-native/App.tsx
@@ -1,442 +1,56 @@
-/**
- * Sample React Native App - Contentful Optimization SDK Implementation
- * https://github.com/facebook/react-native
- *
- * @format
- */
-
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 { TestTrackingScreen } from './TestTrackingScreen'
-
-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
-
-
-
-
+import { SafeAreaView, StyleSheet, Text } from 'react-native'
- {sdkInfo && }
+import type Optimization from '@contentful/optimization-react-native'
+import { OptimizationProvider } from '@contentful/optimization-react-native'
+import type { Entry } from 'contentful'
-
-
-
- )
-}
-
-// 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')
- }
-}
-
-// 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)
-}
+import { MergeTagScreen } from './screens/MergeTagScreen'
+import { fetchMergeTagEntry, initializeSDK } from './utils/sdkHelpers'
-// eslint-disable-next-line complexity -- Main app component, complexity is minimal after refactoring
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 [personalizedEntry, setPersonalizedEntry] = useState(null)
- const [productEntry, setProductEntry] = useState(null)
- const [entriesLoading, setEntriesLoading] = useState(false)
+ const [mergeTagEntry, setMergeTagEntry] = useState(null)
+ const [sdkError, setSdkError] = useState(null)
useEffect(() => {
- void initializeSDK(setSdkInfo, setSdk, setSdkLoaded, setSdkError)
+ void initializeSDK(setSdk, setSdkError)
}, [])
- const fetchEntries = async (): Promise => {
- setEntriesLoading(true)
- try {
- await fetchEntriesFromMockServer(setPersonalizedEntry, setProductEntry)
- } catch (error) {
- setSdkError(
- `Failed to fetch entries: ${error instanceof Error ? error.message : 'Unknown error'}`,
- )
- } finally {
- setEntriesLoading(false)
+ useEffect(() => {
+ if (sdk) {
+ void fetchMergeTagEntry(setMergeTagEntry, setSdkError)
}
- }
-
- const colors: ThemeColors = {
- backgroundColor: isDarkMode ? '#1a1a1a' : '#f5f5f5',
- cardBackground: isDarkMode ? '#2d2d2d' : '#ffffff',
- textColor: isDarkMode ? '#ffffff' : '#000000',
- mutedTextColor: isDarkMode ? '#a0a0a0' : '#666666',
- successColor: '#22c55e',
- errorColor: '#ef4444',
- }
-
- const handleTestTracking = (): void => {
- setShowTestScreen(true)
- void fetchEntries()
- }
-
- const handleBack = (): void => {
- setShowTestScreen(false)
- }
+ }, [sdk])
- // Show test screen if requested and data is available
- if (showTestScreen && sdk && personalizedEntry && productEntry) {
+ if (sdkError) {
return (
-
-
-
+
+ {sdkError}
+
)
}
- // Show loading screen while fetching entries
- if (showTestScreen && entriesLoading) {
- return
+ if (!sdk || !mergeTagEntry) {
+ return (
+
+ Loading...
+
+ )
}
- // 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,
},
})
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/RichTextRenderer.tsx b/implementations/react-native/components/RichTextRenderer.tsx
new file mode 100644
index 00000000..ccd741d7
--- /dev/null
+++ b/implementations/react-native/components/RichTextRenderer.tsx
@@ -0,0 +1,149 @@
+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 { logger } 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: Entry
+ }
+}
+
+interface RichTextRendererProps {
+ richText: RichTextField
+ sdk: Optimization
+}
+
+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 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,
+ sdk: Optimization,
+ profile: Profile | undefined,
+): string {
+ const {
+ data: { target: includedEntry },
+ } = node
+
+ 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})`,
+ )
+ }
+
+ 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 }: 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)
+ })
+
+ 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, 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/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 ed5d1522..a88ba6f7 100644
--- a/implementations/react-native/env.config.ts
+++ b/implementations/react-native/env.config.ts
@@ -24,10 +24,11 @@ interface EnvConfig {
entries: {
personalized: string
product: string
+ mergeTag: string
}
}
-export const ENV_CONFIG: EnvConfig = {
+export const ENV_CONFIG = {
// Contentful Configuration
contentful: {
spaceId: 'test-space',
@@ -53,5 +54,11 @@ 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
},
-}
+} 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..92f46312
--- /dev/null
+++ b/implementations/react-native/screens/MergeTagScreen.tsx
@@ -0,0 +1,88 @@
+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'
+
+interface MergeTagScreenProps {
+ sdk: Optimization
+ mergeTagEntry: Entry
+}
+
+interface RichTextNode {
+ nodeType: string
+ data?: unknown
+ content?: RichTextNode[]
+ value?: string
+}
+
+interface RichTextField {
+ nodeType: 'document'
+ content: RichTextNode[]
+}
+
+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(() => {
+ 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)
+
+ 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/utils/sdkHelpers.ts b/implementations/react-native/utils/sdkHelpers.ts
new file mode 100644
index 00000000..f13ad112
--- /dev/null
+++ b/implementations/react-native/utils/sdkHelpers.ts
@@ -0,0 +1,56 @@
+import type Optimization from '@contentful/optimization-react-native'
+import { logger } from '@contentful/optimization-react-native'
+import { createClient, type Entry } from 'contentful'
+
+const INCLUDE_DEPTH = 10
+const MERGE_TAG_ENTRY_ID = '1MwiFl4z7gkwqGYdvCmr8c'
+
+export async function initializeSDK(
+ setSdk: (sdk: Optimization) => void,
+ setSdkError: (error: string) => void,
+): Promise {
+ try {
+ const { default: OptimizationSDK } = await import('@contentful/optimization-react-native')
+
+ const sdkInstance = await OptimizationSDK.create({
+ clientId: 'test-client-id',
+ environment: 'main',
+ api: {
+ personalization: { baseUrl: 'http://localhost/experience/' },
+ analytics: { baseUrl: 'http://localhost/insights/' },
+ },
+ })
+
+ setSdk(sdkInstance)
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+ setSdkError(`Failed to initialize SDK: ${errorMessage}`)
+ }
+}
+
+export async function fetchMergeTagEntry(
+ setMergeTagEntry: (entry: Entry | null) => void,
+ setSdkError: (error: string) => void,
+): Promise {
+ try {
+ const client = createClient({
+ space: 'test-space',
+ environment: 'master',
+ accessToken: 'test-token',
+ host: 'localhost',
+ basePath: '/contentful',
+ insecure: true,
+ })
+
+ const mergeTagEntryData = await client.getEntry(MERGE_TAG_ENTRY_ID, {
+ include: INCLUDE_DEPTH,
+ })
+
+ 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)
+ }
+}