+ isIdentified: boolean
+ personalizationCount: number
+ onConsentChange: (accepted: boolean) => void
+ onIdentify: () => void
+ onReset: () => void
+}
+
+function isConsentAccepted(consent: boolean | undefined): boolean {
+ return consent === true
+}
+
+export function HomePage({
+ consent,
+ entriesById,
+ isIdentified,
+ personalizationCount,
+ onConsentChange,
+ onIdentify,
+ onReset,
+}: HomePageProps): JSX.Element {
+ return (
+ <>
+
+ Utilities
+
+
+ {isConsentAccepted(consent) ? (
+
+ ) : (
+
+ )}
+
+ {!isIdentified ? (
+
+ ) : (
+
+ )}
+
+
+ Consent: {String(consent)}
+ Personalizations: {personalizationCount}
+
+
+
+ Auto Observed Entries
+
+ {AUTO_OBSERVED_ENTRY_IDS.map((entryId) => {
+ const entry = entriesById.get(entryId)
+ if (!entry) {
+ return null
+ }
+
+ if (entry.sys.contentType.sys.id === 'nestedContent') {
+ return
+ }
+
+ return
+ })}
+
+
+
+
+ Manually Observed Entries
+
+ {MANUALLY_OBSERVED_ENTRY_IDS.map((entryId) => {
+ const entry = entriesById.get(entryId)
+ if (!entry) {
+ return null
+ }
+
+ return
+ })}
+
+
+ >
+ )
+}
diff --git a/implementations/web-react/src/pages/PageTwoPage.tsx b/implementations/web-react/src/pages/PageTwoPage.tsx
new file mode 100644
index 00000000..9410c299
--- /dev/null
+++ b/implementations/web-react/src/pages/PageTwoPage.tsx
@@ -0,0 +1,15 @@
+import type { JSX } from 'react'
+import { Link } from 'react-router-dom'
+import { HOME_PATH } from '../config/routes'
+
+export function PageTwoPage(): JSX.Element {
+ return (
+
+ Page Two
+ Secondary route for SPA navigation and page event validation.
+
+ Back to Home
+
+
+ )
+}
diff --git a/implementations/web-react/src/sections/ContentEntry.tsx b/implementations/web-react/src/sections/ContentEntry.tsx
new file mode 100644
index 00000000..8012d931
--- /dev/null
+++ b/implementations/web-react/src/sections/ContentEntry.tsx
@@ -0,0 +1,126 @@
+import { type JSX, useEffect, useMemo, useRef } from 'react'
+import { RichTextRenderer } from '../components/RichTextRenderer'
+import { useOptimization } from '../optimization/hooks/useOptimization'
+import { usePersonalization } from '../optimization/hooks/usePersonalization'
+import type { ContentfulEntry, RichTextDocument } from '../types/contentful'
+import { isRecord } from '../utils/typeGuards'
+
+interface ContentEntryProps {
+ entry: ContentfulEntry
+ observation: 'auto' | 'manual'
+}
+
+interface PersonalizationMeta {
+ experienceId?: string
+ sticky?: boolean
+ variantIndex?: number
+}
+
+function isRichTextField(field: unknown): field is RichTextDocument {
+ 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)
+ )
+}
+
+function getPersonalizationMeta(value: unknown): PersonalizationMeta {
+ if (!isRecord(value)) {
+ return {}
+ }
+
+ const experienceId = typeof value.experienceId === 'string' ? value.experienceId : undefined
+ const sticky = typeof value.sticky === 'boolean' ? value.sticky : undefined
+ const variantIndex = typeof value.variantIndex === 'number' ? value.variantIndex : undefined
+
+ return { experienceId, sticky, variantIndex }
+}
+
+function getEntryText(contentEntry: ContentfulEntry): string {
+ return typeof contentEntry.fields.text === 'string' ? contentEntry.fields.text : 'No content'
+}
+
+export function ContentEntry({ entry, observation }: ContentEntryProps): JSX.Element {
+ const { sdk, isReady } = useOptimization()
+ const { resolveEntry } = usePersonalization()
+ const containerRef = useRef(null)
+
+ const resolved = useMemo(() => resolveEntry(entry), [entry, resolveEntry])
+ const { entry: resolvedEntry, personalization } = resolved
+
+ const { experienceId, sticky, variantIndex } = useMemo(
+ () => getPersonalizationMeta(personalization),
+ [personalization],
+ )
+
+ useEffect(() => {
+ if (!isReady || sdk === undefined || observation !== 'manual') {
+ return
+ }
+
+ const { current: element } = containerRef
+ if (!element) {
+ return
+ }
+
+ sdk.untrackEntryViewForElement(element)
+
+ sdk.trackEntryViewForElement(element, {
+ data: {
+ entryId: resolvedEntry.sys.id,
+ personalizationId: experienceId,
+ sticky,
+ variantIndex,
+ },
+ })
+
+ return () => {
+ sdk.untrackEntryViewForElement(element)
+ }
+ }, [experienceId, isReady, observation, resolvedEntry.sys.id, sdk, sticky, variantIndex])
+
+ const richTextField = Object.values(resolvedEntry.fields).find(isRichTextField)
+
+ const fullLabel = `Entry: ${resolvedEntry.sys.id}`
+
+ const autoTrackingAttributes =
+ observation === 'auto'
+ ? {
+ 'data-ctfl-entry-id': resolvedEntry.sys.id,
+ 'data-ctfl-baseline-id': entry.sys.id,
+ 'data-ctfl-personalization-id': experienceId,
+ 'data-ctfl-sticky': sticky === undefined ? undefined : String(sticky),
+ 'data-ctfl-variant-index': variantIndex === undefined ? undefined : String(variantIndex),
+ }
+ : undefined
+
+ const manualTrackingAttributes =
+ observation === 'manual'
+ ? {
+ 'data-entry-id': entry.sys.id,
+ }
+ : undefined
+
+ return (
+
+
+
+ {richTextField ? (
+
+ ) : (
+
{getEntryText(resolvedEntry)}
+ )}
+
{`[Entry: ${entry.sys.id}]`}
+
+
+
+ )
+}
diff --git a/implementations/web-react/src/sections/NestedContentEntry.tsx b/implementations/web-react/src/sections/NestedContentEntry.tsx
new file mode 100644
index 00000000..32733f11
--- /dev/null
+++ b/implementations/web-react/src/sections/NestedContentEntry.tsx
@@ -0,0 +1,15 @@
+import type { JSX } from 'react'
+import type { ContentfulEntry } from '../types/contentful'
+import { NestedContentItem } from './NestedContentItem'
+
+interface NestedContentEntryProps {
+ entry: ContentfulEntry
+}
+
+export function NestedContentEntry({ entry }: NestedContentEntryProps): JSX.Element {
+ return (
+
+ )
+}
diff --git a/implementations/web-react/src/sections/NestedContentItem.tsx b/implementations/web-react/src/sections/NestedContentItem.tsx
new file mode 100644
index 00000000..bdd4e95d
--- /dev/null
+++ b/implementations/web-react/src/sections/NestedContentItem.tsx
@@ -0,0 +1,76 @@
+import { useMemo, type JSX } from 'react'
+import { usePersonalization } from '../optimization/hooks/usePersonalization'
+import type { ContentfulEntry } from '../types/contentful'
+import { isRecord } from '../utils/typeGuards'
+
+interface NestedContentItemProps {
+ entry: ContentfulEntry
+}
+
+interface PersonalizationMeta {
+ experienceId?: string
+ sticky?: boolean
+ variantIndex?: number
+}
+
+function isEntry(value: unknown): value is ContentfulEntry {
+ return (
+ isRecord(value) &&
+ isRecord(value.sys) &&
+ typeof value.sys.id === 'string' &&
+ isRecord(value.fields)
+ )
+}
+
+function getPersonalizationMeta(value: unknown): PersonalizationMeta {
+ if (!isRecord(value)) {
+ return {}
+ }
+
+ return {
+ experienceId: typeof value.experienceId === 'string' ? value.experienceId : undefined,
+ sticky: typeof value.sticky === 'boolean' ? value.sticky : undefined,
+ variantIndex: typeof value.variantIndex === 'number' ? value.variantIndex : undefined,
+ }
+}
+
+function renderText(contentEntry: ContentfulEntry): string {
+ return typeof contentEntry.fields.text === 'string' ? contentEntry.fields.text : ''
+}
+
+export function NestedContentItem({ entry }: NestedContentItemProps): JSX.Element {
+ const { resolveEntry } = usePersonalization()
+ const resolved = useMemo(() => resolveEntry(entry), [entry, resolveEntry])
+ const { entry: resolvedEntry, personalization } = resolved
+
+ const { experienceId, sticky, variantIndex } = useMemo(
+ () => getPersonalizationMeta(personalization),
+ [personalization],
+ )
+
+ const nestedEntries = Array.isArray(resolvedEntry.fields.nested)
+ ? resolvedEntry.fields.nested
+ : []
+
+ const text = renderText(resolvedEntry)
+ const fullLabel = `${text} [Entry: ${resolvedEntry.sys.id}]`
+
+ return (
+
+
+
{text}
+
{`[Entry: ${resolvedEntry.sys.id}]`}
+
+
+ {nestedEntries.filter(isEntry).map((nestedEntry) => (
+
+ ))}
+
+ )
+}
diff --git a/implementations/web-react/src/services/contentfulClient.ts b/implementations/web-react/src/services/contentfulClient.ts
new file mode 100644
index 00000000..e25fb483
--- /dev/null
+++ b/implementations/web-react/src/services/contentfulClient.ts
@@ -0,0 +1,59 @@
+import { createClient } from 'contentful'
+import type { ContentEntrySkeleton, ContentfulEntry } from '../types/contentful'
+
+const INCLUDE_DEPTH = 10
+const CONTENTFUL_ACCESS_TOKEN = import.meta.env.PUBLIC_CONTENTFUL_TOKEN?.trim() ?? ''
+const CONTENTFUL_BASE_PATH = import.meta.env.PUBLIC_CONTENTFUL_BASE_PATH?.trim()
+const CONTENTFUL_ENVIRONMENT = import.meta.env.PUBLIC_CONTENTFUL_ENVIRONMENT?.trim() ?? ''
+const CONTENTFUL_HOST = import.meta.env.PUBLIC_CONTENTFUL_CDA_HOST?.trim() ?? ''
+const CONTENTFUL_SPACE_ID = import.meta.env.PUBLIC_CONTENTFUL_SPACE_ID?.trim() ?? ''
+const MISSING_ENV_ERROR = [
+ ['PUBLIC_CONTENTFUL_TOKEN', CONTENTFUL_ACCESS_TOKEN],
+ ['PUBLIC_CONTENTFUL_ENVIRONMENT', CONTENTFUL_ENVIRONMENT],
+ ['PUBLIC_CONTENTFUL_SPACE_ID', CONTENTFUL_SPACE_ID],
+ ['PUBLIC_CONTENTFUL_CDA_HOST', CONTENTFUL_HOST],
+]
+ .filter(([, value]) => value.length === 0)
+ .map(([key]) => key)
+ .join(', ')
+
+function createContentfulClient(): ReturnType {
+ return createClient({
+ accessToken: CONTENTFUL_ACCESS_TOKEN,
+ environment: CONTENTFUL_ENVIRONMENT,
+ host: CONTENTFUL_HOST,
+ insecure: CONTENTFUL_HOST.includes('localhost'),
+ space: CONTENTFUL_SPACE_ID,
+ ...(CONTENTFUL_BASE_PATH ? { basePath: CONTENTFUL_BASE_PATH } : {}),
+ })
+}
+
+const contentfulClient = createContentfulClient()
+
+export function getContentfulConfigError(): string | null {
+ if (MISSING_ENV_ERROR.length === 0) {
+ return null
+ }
+
+ return `Missing required Contentful env vars: ${MISSING_ENV_ERROR}. See implementations/web-react/.env.example.`
+}
+
+export async function fetchEntry(entryId: string): Promise {
+ if (getContentfulConfigError()) {
+ return undefined
+ }
+
+ try {
+ return await contentfulClient.getEntry(entryId, {
+ include: INCLUDE_DEPTH,
+ })
+ } catch {
+ return undefined
+ }
+}
+
+export async function fetchEntries(entryIds: readonly string[]): Promise {
+ const fetchedEntries = await Promise.all(entryIds.map(fetchEntry))
+
+ return fetchedEntries.filter((entry): entry is ContentfulEntry => entry !== undefined)
+}
diff --git a/implementations/web-react/src/types/contentful.ts b/implementations/web-react/src/types/contentful.ts
new file mode 100644
index 00000000..67924a9c
--- /dev/null
+++ b/implementations/web-react/src/types/contentful.ts
@@ -0,0 +1,11 @@
+import type { Document } from '@contentful/rich-text-types'
+import type { Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful'
+
+export interface ContentEntryFields {
+ text?: EntryFieldTypes.Text | EntryFieldTypes.RichText
+ nested?: EntryFieldTypes.Array>
+}
+
+export type ContentEntrySkeleton = EntrySkeletonType
+export type ContentfulEntry = Entry
+export type RichTextDocument = Document
diff --git a/implementations/web-react/src/types/env.d.ts b/implementations/web-react/src/types/env.d.ts
new file mode 100644
index 00000000..573a3cf6
--- /dev/null
+++ b/implementations/web-react/src/types/env.d.ts
@@ -0,0 +1,17 @@
+interface ImportMetaEnv {
+ readonly DEV: boolean
+ readonly PUBLIC_CONTENTFUL_BASE_PATH?: string
+ readonly PUBLIC_CONTENTFUL_CDA_HOST?: string
+ readonly PUBLIC_CONTENTFUL_ENVIRONMENT?: string
+ readonly PUBLIC_CONTENTFUL_SPACE_ID?: string
+ readonly PUBLIC_CONTENTFUL_TOKEN?: string
+ readonly PUBLIC_OPTIMIZATION_LOG_LEVEL?: 'debug' | 'warn' | 'error'
+ readonly PUBLIC_EXPERIENCE_API_BASE_URL?: string
+ readonly PUBLIC_INSIGHTS_API_BASE_URL?: string
+ readonly PUBLIC_NINETAILED_CLIENT_ID?: string
+ readonly PUBLIC_NINETAILED_ENVIRONMENT?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/implementations/web-react/src/utils/typeGuards.ts b/implementations/web-react/src/utils/typeGuards.ts
new file mode 100644
index 00000000..533bad5e
--- /dev/null
+++ b/implementations/web-react/src/utils/typeGuards.ts
@@ -0,0 +1,3 @@
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
diff --git a/implementations/web-react/tsconfig.json b/implementations/web-react/tsconfig.json
new file mode 100644
index 00000000..2f183201
--- /dev/null
+++ b/implementations/web-react/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"]
+}
diff --git a/package.json b/package.json
index 723cb7b5..6a0adc18 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"implementation:node-ssr-only": "pnpm run implementation:run -- node-ssr-only",
"implementation:node-ssr-web-vanilla": "pnpm run implementation:run -- node-ssr-web-vanilla",
"implementation:react-native": "pnpm run implementation:run -- react-native",
+ "implementation:web-react": "pnpm run implementation:run -- web-react",
"implementation:install": "pnpm run implementation:run -- --all -- implementation:install",
"implementation:run": "tsx ./scripts/run-implementation-script.ts",
"implementation:web-vanilla": "pnpm run implementation:run -- web-vanilla",