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) + } +}