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
82 changes: 29 additions & 53 deletions implementations/react-web-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ React framework package.
| ---------------------------- | ------------------------------------------------------------------------------------------ |
| Provider + initialization | `OptimizationRoot` |
| SPA page tracking | `ReactRouterAutoPageTracker` from `@contentful/optimization-react-web/router/react-router` |
| Entry resolution + rendering | `OptimizedEntry` (render-prop), `useEntryResolver().resolveEntry()` |
| Entry resolution + rendering | `OptimizedEntry` render prop |
| Live updates (global) | `OptimizationRoot liveUpdates` prop |
| Live updates (per-component) | `OptimizedEntry liveUpdates` prop |
| Live updates (locked) | `<OptimizedEntry liveUpdates={false}>` |
| Merge tag rendering | `useMergeTagResolver().getMergeTagValue()` |
| Nested personalization | Nested `<OptimizedEntry>` composition |
| Consent gating | `sdk.consent()` via `useOptimizationContext()` |
| Identify / reset | `sdk.identify()` / `sdk.reset()` via `useOptimizationContext()` |
| Auto view/click/hover | `trackEntryInteraction` on `OptimizationRoot` + `data-ctfl-*` attributes |
| Manual view tracking | `useOptimization().tracking.enableElement()` |
| Auto view/click/hover | `trackEntryInteraction` on `OptimizationRoot` + `OptimizedEntry` tracking props |
| Manual view tracking | `<OptimizedEntry trackViews={false}>` + `sdk.tracking.enableElement()` |
| Flag view tracking | `sdk.states.flag('boolean').subscribe()` |
| Analytics event stream | `sdk.states.eventStream.subscribe()` |
| Preview panel attachment | Env-gated `attachOptimizationPreviewPanel()` call |
Expand All @@ -57,8 +57,8 @@ React framework package.

This app defines one `APP_LOCALE`, passes it through the provider `locale` prop, and passes it
directly to Contentful CDA entry fetches. Do not use `contentful.js` `withAllLocales` or raw CDA
`locale=*` for entries passed to `OptimizedEntry` or `useEntryResolver()`; SDK entry resolution
expects direct single-locale fields such as `fields.nt_experiences` and `fields.nt_variants`. See
`locale=*` for entries passed to `OptimizedEntry`; SDK entry resolution expects direct single-locale
fields such as `fields.nt_experiences` and `fields.nt_variants`. See
[Locale handling in the Optimization SDK Suite](../../documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md)
for the broader locale model and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract)
Expand Down Expand Up @@ -291,66 +291,42 @@ function Controls() {
}
```

### Manual interaction tracking
### Auto tracking props

```tsx
import { useEntryResolver, useOptimization } from '@contentful/optimization-react-web'
import { useEffect, useRef } from 'react'

function ManuallyTrackedEntry({ entry }) {
const sdk = useOptimization()
const { resolveEntry } = useEntryResolver()
const ref = useRef(null)
const resolved = resolveEntry(entry)

useEffect(() => {
const el = ref.current
if (!el) return
sdk.tracking.enableElement('views', el, { data: { entryId: resolved.sys.id } })
return () => sdk.tracking.clearElement('views', el)
}, [resolved.sys.id, sdk.tracking])

return <div ref={ref}>{String(resolved.fields.text)}</div>
}
```

### Auto tracking attributes

For entries tracked via `trackEntryInteraction`, apply `data-ctfl-*` attributes directly on the
visible content element inside the render prop:
For entries tracked via `trackEntryInteraction`, configure Web SDK tracking behavior through
`OptimizedEntry` props. `OptimizedEntry` derives entry metadata attributes from the resolved entry
state.

```tsx
<OptimizedEntry baselineEntry={entry}>
{(resolvedEntry) => (
<div
data-ctfl-entry-id={resolvedEntry.sys.id}
data-ctfl-baseline-id={entry.sys.id}
data-ctfl-hover-duration-update-interval-ms="1000"
data-ctfl-clickable="true" // mark as click target
>
{String(resolvedEntry.fields.text)}
</div>
)}
<OptimizedEntry baselineEntry={entry} clickable hoverDurationUpdateIntervalMs={1000}>
{(resolvedEntry) => <div>{String(resolvedEntry.fields.text)}</div>}
</OptimizedEntry>
```

> [!NOTE]
>
> The `OptimizationRoot` `trackEntryInteraction` prop activates automatic view, click, and hover
> tracking for any DOM element that has `data-ctfl-entry-id`. The SDK's MutationObserver registers
> elements as they appear in the DOM after consent is given.
> tracking for resolved `OptimizedEntry` elements. The SDK's MutationObserver registers elements as
> they appear in the DOM after consent is given.

### Manual view tracking

For manually observed entries, this implementation still uses `OptimizedEntry` for entry resolution
and disables automatic view tracking with `trackViews={false}`. The render prop receives the
resolved entry, and the rendered element is registered with
`sdk.tracking.enableElement('views', element, { data: { entryId: resolvedEntry.sys.id } })`.

## Code orientation

| File or area | Purpose |
| ------------------------------------------ | ----------------------------------------------------------------- |
| `src/main.tsx` | Configures `OptimizationRoot` and `ReactRouterAutoPageTracker` |
| `src/App.tsx` | Subscribes to provider state and renders route-level controls |
| `src/sections/ContentEntry.tsx` | Demonstrates `OptimizedEntry`, `useEntryResolver()`, and tracking |
| `src/sections/LiveUpdatesExampleEntry.tsx` | Demonstrates locked and live entry resolution |
| `src/components/RichTextRenderer.tsx` | Demonstrates merge tag rendering with `useMergeTagResolver()` |
| `src/components/AnalyticsEventDisplay.tsx` | Displays event stream output from `sdk.states.eventStream` |
| Manual `selectedOptimizations` lock logic | `<OptimizedEntry liveUpdates={false}>` |
| File or area | Purpose |
| ------------------------------------------ | -------------------------------------------------------------- |
| `src/main.tsx` | Configures `OptimizationRoot` and `ReactRouterAutoPageTracker` |
| `src/App.tsx` | Subscribes to provider state and renders route-level controls |
| `src/sections/ContentEntry.tsx` | Demonstrates `OptimizedEntry` tracking props and manual views |
| `src/sections/LiveUpdatesExampleEntry.tsx` | Demonstrates locked and live entry resolution |
| `src/components/RichTextRenderer.tsx` | Demonstrates merge tag rendering with `useMergeTagResolver()` |
| `src/components/AnalyticsEventDisplay.tsx` | Displays event stream output from `sdk.states.eventStream` |
| Manual `selectedOptimizations` lock logic | `<OptimizedEntry liveUpdates={false}>` |

**What stays the same:** `contentfulClient.ts`, entry/route config, type definitions,
`RichTextRenderer`, E2E test files, page/section component structure.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ const clickScenarios: ClickScenario[] = [
]

async function readResolvedEntryId(page: Page, entryTestId: string): Promise<string> {
const entryId = await page.getByTestId(entryTestId).getAttribute('data-ctfl-entry-id')
const entryMetadata = page
.getByTestId(entryTestId)
.locator('xpath=ancestor::*[@data-ctfl-entry-id][1]')
const entryId = await entryMetadata
.getAttribute('data-ctfl-entry-id', { timeout: 500 })
.catch(() => undefined)

return entryId ?? ''
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ async function movePointerAwayFromEntries(page: Page): Promise<void> {
}

async function readResolvedEntryId(page: Page): Promise<string> {
const entryId = await page
const entryMetadata = page
.getByTestId(`content-${HOVER_ENTRY_BASELINE_ID}`)
.getAttribute('data-ctfl-entry-id')
.locator('xpath=ancestor::*[@data-ctfl-entry-id][1]')
const entryId = await entryMetadata
.getAttribute('data-ctfl-entry-id', { timeout: 500 })
.catch(() => undefined)

return entryId ?? ''
}
Expand Down
108 changes: 98 additions & 10 deletions implementations/react-web-sdk/e2e/events-consent-gating.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,76 @@
import { type Page, expect, test } from '@playwright/test'
import { type Locator, type Page, expect, test } from '@playwright/test'

async function scrollThroughEntries(page: Page): Promise<void> {
const entries = page.locator('[data-testid^="content-"]')
type EntryObservationScope = 'auto' | 'manual'

const ENTRY_SCOPE_SELECTOR_BY_SCOPE: Record<EntryObservationScope, string> = {
auto: '#auto-observed',
manual: '#manually-observed',
}

function resolveEntryLocator(page: Page, scope: EntryObservationScope): Locator {
return page.locator(
`${ENTRY_SCOPE_SELECTOR_BY_SCOPE[scope]} [data-testid^="content-"]:not([data-testid^="content-entry-"])`,
)
}

function resolveEntryViewEvent(page: Page, entryId: string): Locator {
return page.locator('[data-testid^="event-view-"]').filter({
hasText: `Entry/Flag: ${entryId}`,
})
}

async function waitForResolvedEntryWrappers(page: Page): Promise<void> {
await expect
.poll(async () => await page.locator('[data-ctfl-entry-id]').count(), {
message: 'resolved entry wrappers should be available',
})
.toBeGreaterThan(0)
await expect(page.locator('[data-ctfl-loading-layout-target="true"]')).toHaveCount(0)
}

async function readResolvedEntryId(page: Page, entryTestId: string): Promise<string> {
const entryMetadata = page
.getByTestId(entryTestId)
.locator('xpath=ancestor::*[@data-ctfl-entry-id][1]')
const entryId = await entryMetadata
.getAttribute('data-ctfl-entry-id', { timeout: 500 })
.catch(() => undefined)

return entryId ?? ''
}

async function resolveEntryTestIds(page: Page, scope: EntryObservationScope): Promise<string[]> {
await waitForResolvedEntryWrappers(page)

const entries = resolveEntryLocator(page, scope)
const entryCount = await entries.count()
const entryTestIds: string[] = []

for (let index = 0; index < entryCount; index += 1) {
await entries.nth(index).scrollIntoViewIfNeeded()
const entryTestId = await entries.nth(index).getAttribute('data-testid')

if (entryTestId) {
entryTestIds.push(entryTestId)
}
}

return entryTestIds
}

async function scrollThroughEntries(page: Page, scope: EntryObservationScope): Promise<string[]> {
const entryTestIds = await resolveEntryTestIds(page, scope)
const resolvedEntryIds: string[] = []

for (const entryTestId of entryTestIds) {
const resolvedEntryId = await readResolvedEntryId(page, entryTestId)
if (resolvedEntryId) {
resolvedEntryIds.push(resolvedEntryId)
}

await page.getByTestId(entryTestId).scrollIntoViewIfNeeded()
}

return resolvedEntryIds
}

test.describe('consent gating', () => {
Expand All @@ -16,26 +80,50 @@ test.describe('consent gating', () => {
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
})

test('allows page events without consent but gates entry view events', async ({ page }) => {
test('allows page events without consent but gates automatic and manual entry view events', async ({
page,
}) => {
const pageEvents = page.locator('[data-testid^="event-page-"]')
const viewEvents = page.locator('[data-testid^="event-view-"]')

await expect(pageEvents.first()).toBeVisible()

await scrollThroughEntries(page)
await scrollThroughEntries(page, 'auto')
await scrollThroughEntries(page, 'manual')
await expect(viewEvents).toHaveCount(0)
})

test('emits entry view events after consent is accepted', async ({ page }) => {
test('emits automatic entry view events after consent is accepted', async ({ page }) => {
const pageEvents = page.locator('[data-testid^="event-page-"]')

await expect(pageEvents.first()).toBeVisible()

await page.getByRole('button', { name: 'Accept Consent' }).click()
await expect(page.getByTestId('consent-status')).toHaveText('Consent: true')
const resolvedEntryIds = await scrollThroughEntries(page, 'auto')

for (const resolvedEntryId of resolvedEntryIds) {
await expect(
resolveEntryViewEvent(page, resolvedEntryId),
`automatic view event should render for "${resolvedEntryId}"`,
).toBeVisible()
}
})

test('emits manual entry view events after consent is accepted', async ({ page }) => {
const pageEvents = page.locator('[data-testid^="event-page-"]')
const viewEvents = page.locator('[data-testid^="event-view-"]')

await expect(pageEvents.first()).toBeVisible()

await page.getByRole('button', { name: 'Accept Consent' }).click()
await expect(page.getByTestId('consent-status')).toHaveText('Consent: true')
await scrollThroughEntries(page)
const resolvedEntryIds = await scrollThroughEntries(page, 'manual')

await expect.poll(async () => await viewEvents.count()).toBeGreaterThan(0)
for (const resolvedEntryId of resolvedEntryIds) {
await expect(
resolveEntryViewEvent(page, resolvedEntryId),
`manual view event should render for "${resolvedEntryId}"`,
).toBeVisible()
}
})
})
4 changes: 2 additions & 2 deletions implementations/react-web-sdk/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function AutoObservedEntries({ entriesById }: AutoObservedEntriesProps): JSX.Ele
key={entry.sys.id}
clickScenario={AUTO_OBSERVED_CLICK_SCENARIO_BY_ENTRY_ID[entry.sys.id]}
entry={entry}
observation="auto"
viewTracking="auto"
/>
)
})}
Expand All @@ -112,7 +112,7 @@ function ManuallyObservedEntries({ entriesById }: ManuallyObservedEntriesProps):
return null
}

return <ContentEntry key={entry.sys.id} entry={entry} observation="manual" />
return <ContentEntry key={entry.sys.id} entry={entry} viewTracking="manual" />
})}
</div>
)
Expand Down
4 changes: 2 additions & 2 deletions implementations/react-web-sdk/src/pages/PageTwoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function PageTwoPage(): JSX.Element {
{pageTwoAutoEntry ? (
<div>
<p>Auto tracked example</p>
<ContentEntry entry={pageTwoAutoEntry} observation="auto" />
<ContentEntry entry={pageTwoAutoEntry} viewTracking="auto" />
</div>
) : (
<p>Auto tracked entry is unavailable.</p>
Expand All @@ -56,7 +56,7 @@ export function PageTwoPage(): JSX.Element {
{pageTwoManualEntry ? (
<div>
<p>Manual tracked example</p>
<ContentEntry entry={pageTwoManualEntry} observation="manual" />
<ContentEntry entry={pageTwoManualEntry} viewTracking="manual" />
</div>
) : (
<p>Manual tracked entry is unavailable.</p>
Expand Down
Loading
Loading