Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions packages/web/frameworks/react-web-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ React Web SDK package for `@contentful/optimization-react-web`.

## Status

Core root/provider primitives are implemented.
Core root/provider primitives and the `Personalization` component are implemented.

- `OptimizationProvider` + `useOptimization()` context behavior
- `LiveUpdatesProvider` + `useLiveUpdates()` global live updates context
- `OptimizationRoot` provider composition and defaults
- `Personalization` entry resolution, lock/live-update behavior, loading fallback, and
data-attribute mapping

## Purpose

Expand Down Expand Up @@ -40,6 +42,7 @@ pnpm dev
- package metadata and dual module exports
- `rslib`/`rsbuild`/`rstest`/TypeScript baseline aligned with Web SDK patterns
- core provider/root/context primitives in `src/`
- `Personalization` component with loading-state support and Web SDK data-attribute tracking
- scaffold dev dashboard harness in `dev/` for consent, identify/reset, state, events, and entries

## Usage
Expand Down Expand Up @@ -94,16 +97,84 @@ Available config props:
- `useOptimization()` throws if used outside `OptimizationProvider`.
- `useLiveUpdates()` throws if used outside `LiveUpdatesProvider`.

### Personalization Component

```tsx
import { Personalization } from '@contentful/optimization-react-web'
;<Personalization baselineEntry={baselineEntry}>
{(resolvedEntry) => <HeroCard entry={resolvedEntry} />}
</Personalization>
```

`Personalization` behavior:

- Default mode locks to the first non-`undefined` personalization state.
- `liveUpdates={true}` enables continuous updates as personalization state changes.
- If `liveUpdates` is omitted, global root `liveUpdates` is used.
- If both are omitted, live updates default to `false`.

#### Loading Fallback

When `loadingFallback` is provided, it is rendered until personalization state is first resolved.

```tsx
<Personalization
baselineEntry={baselineEntry}
loadingFallback={() => <Skeleton label="Loading personalized content" />}
>
{(resolvedEntry) => <HeroCard entry={resolvedEntry} />}
</Personalization>
```

If `loadingFallback` is not provided, rendering follows the regular baseline/resolved path.

#### Nested Composition

Nested personalizations are supported by explicit composition:

```tsx
<Personalization baselineEntry={parentEntry}>
{(resolvedParent) => (
<ParentSection entry={resolvedParent}>
<Personalization baselineEntry={childEntry}>
{(resolvedChild) => <ChildSection entry={resolvedChild} />}
</Personalization>
</ParentSection>
)}
</Personalization>
```

#### Auto-Tracking Data Attributes

When resolved content is rendered, the wrapper emits attributes used by
`@contentful/optimization-web` automatic tracking:

- `data-ctfl-entry-id` (always present on resolved content wrapper)
- `data-ctfl-personalization-id` (when personalized)
- `data-ctfl-sticky` (when available)
- `data-ctfl-variant-index` (when personalized)
- `data-ctfl-duplication-scope` (when available)

To consume those attributes automatically, enable Web SDK auto-tracking with one of:

- `autoTrackEntryInteraction: { views: true }` during `OptimizationRoot` initialization
- `optimization.tracking.enable('views')` / equivalent runtime setup APIs when applicable

When `loadingFallback` is shown, resolved-content tracking attributes are not emitted.

### Live Updates Resolution Semantics

Consumers should resolve live updates behavior with:

```ts
const isLiveUpdatesEnabled = componentLiveUpdates ?? liveUpdatesContext.globalLiveUpdates
const isLiveUpdatesEnabled =
liveUpdatesContext.previewPanelVisible ||
(componentLiveUpdates ?? liveUpdatesContext.globalLiveUpdates)
```

This gives:

- preview panel open override first
- component-level `liveUpdates` prop override first
- then root-level `liveUpdates`
- then default `false`
Expand Down
125 changes: 104 additions & 21 deletions packages/web/frameworks/react-web-sdk/dev/App.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,126 @@
import type { ReactElement } from 'react'
import { type ReactElement, useMemo, useState } from 'react'
import { useLiveUpdates, useOptimization } from '../src'

const sectionTitles = [
'Consent',
'Identify / Reset',
'State Inspectors',
'Event Stream',
'Entry Resolver',
'Entry Rendering / Observation',
] as const
import { BASELINE_IDS } from './constants'
import { useDevEntries } from './hooks/useDevEntries'
import { useOptimizationState } from './hooks/useOptimizationState'
import { ControlsSection } from './sections/ControlsSection'
import { PersonalizationSection } from './sections/PersonalizationSection'
import { StateSection } from './sections/StateSection'
import type { ResolveResult } from './types'

export function App(): ReactElement {
const { globalLiveUpdates } = useLiveUpdates()
const optimization = useOptimization()
const { globalLiveUpdates, previewPanelVisible } = useLiveUpdates()
const { entriesById, loading: entriesLoading, error: entriesError } = useDevEntries()
const { consent, profile, personalizations, previewPanelOpen, eventLog } =
useOptimizationState(optimization)
const [resolveResults, setResolveResults] = useState<ResolveResult[]>([])

const baselineDefault = entriesById.get(BASELINE_IDS.default)
const baselineLive = entriesById.get(BASELINE_IDS.live)
const baselineLocked = entriesById.get(BASELINE_IDS.locked)
const baselineNestedParent = entriesById.get(BASELINE_IDS.nestedParent)
const baselineNestedChild = entriesById.get(BASELINE_IDS.nestedChild)

const { size: resolvedEntryCount } = entriesById
const sdkName = useMemo(() => optimization.constructor.name, [optimization])

const handleResolveEntries = (): void => {
const nextResults: ResolveResult[] = []

entriesById.forEach((entry) => {
const resolved = optimization.personalizeEntry(entry, personalizations)
nextResults.push({
baselineId: entry.sys.id,
resolvedId: resolved.entry.sys.id,
personalizationId: resolved.personalization?.experienceId,
variantIndex: resolved.personalization?.variantIndex,
sticky: resolved.personalization?.sticky,
})
})

setResolveResults(nextResults)
}

const fireAndReport = (promise: Promise<unknown>): void => {
void promise.catch(() => null)
}

return (
<main className="dashboard">
<header className="dashboard__header">
<h1>@contentful/optimization-react-web</h1>
<p>Minimal live integration with OptimizationRoot config props.</p>
<p>Dev app split into modules for easier review and iteration.</p>
</header>

<section className="dashboard__grid" style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>
<article className="dashboard__card">
<h2>SDK Wiring</h2>
<p>OptimizationRoot: Active</p>
<p>{`Optimization SDK: ${optimization.constructor.name}`}</p>
<p>{`Optimization SDK: ${sdkName}`}</p>
<p>{`Global liveUpdates: ${globalLiveUpdates ? 'ON' : 'OFF'}`}</p>
<p>{`Preview panel visible: ${previewPanelVisible ? 'Open' : 'Closed'}`}</p>
</article>
</section>

<section className="dashboard__grid">
{sectionTitles.map((title) => (
<article className="dashboard__card" key={title}>
<h2>{title}</h2>
<p>Scaffold placeholder. Runtime behavior will be implemented in follow-up tickets.</p>
</article>
))}
</section>
<ControlsSection
consent={consent}
eventLog={eventLog}
onGrantConsent={() => {
optimization.consent(true)
}}
onRevokeConsent={() => {
optimization.consent(false)
}}
onIdentify={() => {
fireAndReport(
optimization.identify({
userId: 'demo-user-123',
traits: { plan: 'pro', region: 'eu', source: 'react-web-sdk-dev' },
}),
)
}}
onReset={() => {
optimization.reset()
}}
onSendPage={() => {
fireAndReport(
optimization.page({
properties: { title: 'React Web SDK Dev Harness', path: '/dev' },
}),
)
}}
onSendTrack={() => {
fireAndReport(
optimization.track({
event: 'dev_app_custom_event',
properties: { source: 'react-web-sdk/dev/App.tsx' },
}),
)
}}
/>

<StateSection
globalLiveUpdates={globalLiveUpdates}
previewPanelVisible={previewPanelVisible}
previewPanelOpen={previewPanelOpen}
personalizations={personalizations}
profile={profile}
entriesLoadedCount={resolvedEntryCount}
entriesLoading={entriesLoading}
entriesError={entriesError}
resolveResults={resolveResults}
onResolveEntries={handleResolveEntries}
/>

<PersonalizationSection
baselineDefault={baselineDefault}
baselineLive={baselineLive}
baselineLocked={baselineLocked}
baselineNestedParent={baselineNestedParent}
baselineNestedChild={baselineNestedChild}
personalizations={personalizations}
/>
</main>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Entry } from 'contentful'
import type { ReactElement } from 'react'
import { getFieldText } from '../utils'

interface EntryPanelProps {
title: string
resolvedEntry: Entry
}

export function EntryPanel({ title, resolvedEntry }: EntryPanelProps): ReactElement {
return (
<article className="dashboard__card">
<h2>{title}</h2>
<p>{getFieldText(resolvedEntry.fields.internalTitle) || 'No internalTitle field'}</p>
<p>{getFieldText(resolvedEntry.fields.text) || 'No text field'}</p>
<p>
<strong>Entry ID:</strong> {resolvedEntry.sys.id}
</p>
<p>
<strong>Type:</strong> {resolvedEntry.sys.contentType.sys.id}
</p>
</article>
)
}
21 changes: 21 additions & 0 deletions packages/web/frameworks/react-web-sdk/dev/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const DEFAULT_CONTENTFUL_SPACE_ID = 'test-space'
export const DEFAULT_CONTENTFUL_ENVIRONMENT = 'master'
export const DEFAULT_CONTENTFUL_TOKEN = 'test-token'
export const DEFAULT_CONTENTFUL_HOST = 'localhost:8000'
export const DEFAULT_CONTENTFUL_BASE_PATH = '/contentful/'

export const ENTRY_IDS = [
'1MwiFl4z7gkwqGYdvCmr8c',
'4ib0hsHWoSOnCVdDkizE8d',
'xFwgG3oNaOcjzWiGe4vXo',
'2Z2WLOx07InSewC3LUB3eX',
'6zqoWXyiSrf0ja7I2WGtYj',
] as const

export const BASELINE_IDS = {
default: '2Z2WLOx07InSewC3LUB3eX',
live: 'xFwgG3oNaOcjzWiGe4vXo',
locked: '4ib0hsHWoSOnCVdDkizE8d',
nestedParent: '1MwiFl4z7gkwqGYdvCmr8c',
nestedChild: '6zqoWXyiSrf0ja7I2WGtYj',
} as const
40 changes: 40 additions & 0 deletions packages/web/frameworks/react-web-sdk/dev/contentful.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createClient, type Entry } from 'contentful'
import {
DEFAULT_CONTENTFUL_BASE_PATH,
DEFAULT_CONTENTFUL_ENVIRONMENT,
DEFAULT_CONTENTFUL_HOST,
DEFAULT_CONTENTFUL_SPACE_ID,
DEFAULT_CONTENTFUL_TOKEN,
ENTRY_IDS,
} from './constants'

function getEnvString(key: string): string | undefined {
const value: unknown = Reflect.get(import.meta.env as object, key)

if (typeof value !== 'string') return undefined

const normalized = value.trim()
return normalized.length > 0 ? normalized : undefined
}

export async function fetchDevEntries(): Promise<Map<string, Entry>> {
const client = createClient({
space: getEnvString('PUBLIC_CONTENTFUL_SPACE_ID') ?? DEFAULT_CONTENTFUL_SPACE_ID,
environment: getEnvString('PUBLIC_CONTENTFUL_ENVIRONMENT') ?? DEFAULT_CONTENTFUL_ENVIRONMENT,
accessToken: getEnvString('PUBLIC_CONTENTFUL_TOKEN') ?? DEFAULT_CONTENTFUL_TOKEN,
host: getEnvString('PUBLIC_CONTENTFUL_CDA_HOST') ?? DEFAULT_CONTENTFUL_HOST,
basePath: getEnvString('PUBLIC_CONTENTFUL_BASE_PATH') ?? DEFAULT_CONTENTFUL_BASE_PATH,
insecure: true,
})

const entries = await Promise.all(
ENTRY_IDS.map(async (id) => await client.getEntry(id, { include: 10 })),
)

const byId = new Map<string, Entry>()
entries.forEach((entry) => {
byId.set(entry.sys.id, entry)
})

return byId
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Entry } from 'contentful'
import { useEffect, useState } from 'react'
import { fetchDevEntries } from '../contentful'

export interface UseDevEntriesResult {
entriesById: Map<string, Entry>
loading: boolean
error: string | null
}

export function useDevEntries(): UseDevEntriesResult {
const [entriesById, setEntriesById] = useState<Map<string, Entry>>(new Map())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
let active = true

async function load(): Promise<void> {
setLoading(true)
setError(null)

try {
const loadedEntries = await fetchDevEntries()
if (active) setEntriesById(loadedEntries)
} catch (caughtError) {
if (!active) return
setError(
caughtError instanceof Error ? caughtError.message : 'Unknown entries loading error',
)
} finally {
if (active) setLoading(false)
}
}

void load()

return () => {
active = false
}
}, [])

return { entriesById, loading, error }
}
Loading