Skip to content
Draft
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
5 changes: 4 additions & 1 deletion implementations/react-web-sdk/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type JSX, useEffect, useMemo, useState } from 'react'
import { Link, Outlet, useOutletContext } from 'react-router-dom'
import { AnalyticsEventDisplay } from './components/AnalyticsEventDisplay'
import { ENTRY_IDS, LIVE_UPDATES_ENTRY_ID } from './config/entries'
import { HOME_PATH, PAGE_TWO_PATH } from './config/routes'
import { EXO_PATH, HOME_PATH, PAGE_TWO_PATH } from './config/routes'
import { fetchEntries, getContentfulConfigError } from './services/contentfulClient'
import type { ContentEntry } from './types/contentful'

Expand Down Expand Up @@ -138,6 +138,9 @@ export default function App(): JSX.Element {
<Link data-testid="link-home" to={HOME_PATH}>
Home
</Link>
<Link data-testid="link-exo" to={EXO_PATH}>
ExO
</Link>
<Link data-testid="link-page-two" to={PAGE_TWO_PATH}>
Go to Page Two
</Link>
Expand Down
261 changes: 261 additions & 0 deletions implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { useOptimizationContext, type OptimizationSdk } from '@contentful/optimization-react-web'
import type { JSX } from 'react'
import { useEffect, useState } from 'react'
import { isRecord } from '../utils/typeGuards'

interface NodeViewEventSummary {
entityId: string
entityKind: string
optimizationId: string
variant: string
viewDurationMs: number
viewId: string
}

interface BlockedNodeViewSummary {
method: string
reason: string
}

interface NodeViewDatasetSnapshot {
entityId: string | undefined
entityKind: string | undefined
nodeId: string | undefined
optimizationId: string | undefined
variant: string | undefined
}

interface NodeViewRuntimeSnapshot {
autoTrackNodeInteractionViews: boolean | undefined
matchingNodeElementsCount: number
runtimeStarted: boolean | undefined
}

const NODE_VIEW_SELECTOR = '[data-ctfl-node-id]'

function reflectGet(target: object, key: string): unknown {
return Reflect.get(target, key) as unknown
}

function isHtmlOrSvgElement(element: Element): element is HTMLElement | SVGElement {
if (typeof HTMLElement === 'undefined' || typeof SVGElement === 'undefined') {
return false
}

return element instanceof HTMLElement || element instanceof SVGElement
}

function readNodeViewTargetSnapshot(): NodeViewDatasetSnapshot | undefined {
const element = document.querySelector('[data-testid="node-view-target"]')
if (!element || !isHtmlOrSvgElement(element)) {
return undefined
}

const {
dataset: { ctflEntityId, ctflEntityKind, ctflNodeId, ctflOptimizationId, ctflVariant },
} = element

return {
entityId: ctflEntityId,
entityKind: ctflEntityKind,
nodeId: ctflNodeId,
optimizationId: ctflOptimizationId,
variant: ctflVariant,
}
}

function readRuntimeSnapshot(sdk: OptimizationSdk | undefined): NodeViewRuntimeSnapshot {
const matchingNodeElementsCount =
typeof document === 'undefined' ? 0 : document.querySelectorAll(NODE_VIEW_SELECTOR).length

if (!sdk) {
return {
autoTrackNodeInteractionViews: undefined,
matchingNodeElementsCount,
runtimeStarted: undefined,
}
}

const config = reflectGet(sdk, 'autoTrackNodeInteraction')
const views = config && typeof config === 'object' ? reflectGet(config, 'views') : undefined
const autoTrackNodeInteractionViews = typeof views === 'boolean' ? views : undefined

const runtime = reflectGet(sdk, 'nodeViewRuntime')
const runtimeStarted =
runtime && typeof runtime === 'object' ? reflectGet(runtime, 'detector') != null : undefined

return { autoTrackNodeInteractionViews, matchingNodeElementsCount, runtimeStarted }
}

function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}

function toNodeViewEvent(event: unknown): NodeViewEventSummary | undefined {
if (!isRecord(event) || event.type !== 'exo_view') return undefined

const entityId = asString(event.entityId)
const entityKind = asString(event.entityKind)
const optimizationId = asString(event.optimizationId)
const variant = asString(event.variant)
const viewId = asString(event.viewId)
const viewDurationMs = typeof event.viewDurationMs === 'number' ? event.viewDurationMs : undefined

if (
!entityId ||
!entityKind ||
!optimizationId ||
!variant ||
!viewId ||
viewDurationMs === undefined
) {
return undefined
}

return { entityId, entityKind, optimizationId, variant, viewId, viewDurationMs }
}

function toBlockedNodeViewSummary(event: unknown): BlockedNodeViewSummary | undefined {
if (!isRecord(event)) return undefined
const method = typeof event.method === 'string' ? event.method : undefined
const reason = typeof event.reason === 'string' ? event.reason : undefined
if (method !== 'trackNodeView' || reason === undefined) return undefined
return { method, reason }
}

interface NodeViewDebugState {
consent: boolean | undefined
latestBlockedNodeView: BlockedNodeViewSummary | undefined
latestNodeViewEvent: NodeViewEventSummary | undefined
nodeViewEventsSeen: number
profileId: string | undefined
runtimeSnapshot: NodeViewRuntimeSnapshot
targetSnapshot: NodeViewDatasetSnapshot | undefined
}

const INITIAL_RUNTIME_SNAPSHOT: NodeViewRuntimeSnapshot = {
autoTrackNodeInteractionViews: undefined,
matchingNodeElementsCount: 0,
runtimeStarted: undefined,
}

function useNodeViewDebugState(
sdk: OptimizationSdk | undefined,
isReady: boolean,
): NodeViewDebugState {
const [consent, setConsent] = useState<boolean | undefined>(undefined)
const [profileId, setProfileId] = useState<string | undefined>(undefined)
const [nodeViewEventsSeen, setNodeViewEventsSeen] = useState(0)
const [latestNodeViewEvent, setLatestNodeViewEvent] = useState<NodeViewEventSummary | undefined>(
undefined,
)
const [latestBlockedNodeView, setLatestBlockedNodeView] = useState<
BlockedNodeViewSummary | undefined
>(undefined)
const [targetSnapshot, setTargetSnapshot] = useState<NodeViewDatasetSnapshot | undefined>(
undefined,
)
const [runtimeSnapshot, setRuntimeSnapshot] =
useState<NodeViewRuntimeSnapshot>(INITIAL_RUNTIME_SNAPSHOT)

useEffect(() => {
if (!sdk || !isReady) {
setConsent(undefined)
setProfileId(undefined)
setNodeViewEventsSeen(0)
setLatestNodeViewEvent(undefined)
setLatestBlockedNodeView(undefined)
setTargetSnapshot(undefined)
setRuntimeSnapshot(INITIAL_RUNTIME_SNAPSHOT)
return
}

setTargetSnapshot(readNodeViewTargetSnapshot())
setRuntimeSnapshot(readRuntimeSnapshot(sdk))

const consentSub = sdk.states.consent.subscribe((value: boolean | undefined) => {
setConsent(value)
setRuntimeSnapshot(readRuntimeSnapshot(sdk))
})

const profileSub = sdk.states.profile.subscribe((value: unknown) => {
if (!isRecord(value) || typeof value.id !== 'string') {
setProfileId(undefined)
return
}
setProfileId(value.id)
})

const eventSub = sdk.states.eventStream.subscribe((event: unknown) => {
const nodeViewEvent = toNodeViewEvent(event)
if (!nodeViewEvent) return
setNodeViewEventsSeen((previous) => previous + 1)
setLatestNodeViewEvent(nodeViewEvent)
setTargetSnapshot(readNodeViewTargetSnapshot())
setRuntimeSnapshot(readRuntimeSnapshot(sdk))
})

const blockedSub = sdk.states.blockedEventStream.subscribe((event: unknown) => {
const blockedNodeView = toBlockedNodeViewSummary(event)
if (!blockedNodeView) return
setLatestBlockedNodeView(blockedNodeView)
})

return () => {
consentSub.unsubscribe()
profileSub.unsubscribe()
eventSub.unsubscribe()
blockedSub.unsubscribe()
}
}, [isReady, sdk])

return {
consent,
latestBlockedNodeView,
latestNodeViewEvent,
nodeViewEventsSeen,
profileId,
runtimeSnapshot,
targetSnapshot,
}
}

export function NodeViewDebugPanel(): JSX.Element {
const { isReady, sdk } = useOptimizationContext()
const {
consent,
latestBlockedNodeView,
latestNodeViewEvent,
nodeViewEventsSeen,
profileId,
runtimeSnapshot,
targetSnapshot,
} = useNodeViewDebugState(sdk, isReady)

return (
<section
style={{ border: '1px solid #ccc', borderRadius: 4, display: 'grid', gap: 8, padding: 12 }}
>
<h3>Node view debug panel</h3>
<p>Consent: {`${consent}`}</p>
<p>Profile ID: {profileId}</p>
<p>Node view events seen: {nodeViewEventsSeen}</p>
<p>Node target present: {`${targetSnapshot !== undefined}`}</p>
<p>Matching node elements: {runtimeSnapshot.matchingNodeElementsCount}</p>
<p>autoTrackNodeInteraction.views: {`${runtimeSnapshot.autoTrackNodeInteractionViews}`}</p>
<p>nodeViewRuntime started: {`${runtimeSnapshot.runtimeStarted}`}</p>
<p>nodeId: {targetSnapshot?.nodeId}</p>
<p>entityId: {targetSnapshot?.entityId}</p>
<p>entityKind: {targetSnapshot?.entityKind}</p>
<p>optimizationId: {targetSnapshot?.optimizationId}</p>
<p>variant: {targetSnapshot?.variant}</p>
<p>
Last blocked:{' '}
{latestBlockedNodeView && `${latestBlockedNodeView.method}:${latestBlockedNodeView.reason}`}
</p>
<p>Last exo_view viewId: {latestNodeViewEvent?.viewId}</p>
<p>Last exo_view duration: {latestNodeViewEvent?.viewDurationMs}ms</p>
<p>Insights events are queued by the SDK; network emission can lag behind event detection.</p>
</section>
)
}
1 change: 1 addition & 0 deletions implementations/react-web-sdk/src/config/routes.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const HOME_PATH = '/'
export const EXO_PATH = '/exo'
export const PAGE_TWO_PATH = '/page-two'
5 changes: 4 additions & 1 deletion implementations/react-web-sdk/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { type ReactElement, StrictMode, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom'
import App from './App'
import { HOME_PATH } from './config/routes'
import { EXO_PATH, HOME_PATH } from './config/routes'
import { ExoPage } from './pages/ExoPage'
import { HomePage } from './pages/HomePage'
import { PageTwoPage } from './pages/PageTwoPage'
import { getContentfulClient } from './services/contentfulClient'
Expand Down Expand Up @@ -66,6 +67,7 @@ function RootLayout(): ReactElement {
experienceBaseUrl: EXPERIENCE_BASE_URL,
}}
trackEntryInteraction={{ views: true, clicks: true, hovers: true }}
autoTrackNodeInteraction={{ views: true }}
logLevel={resolveLogLevel()}
app={{
name: 'ContentfulOptimization SDK - React Web SDK Reference',
Expand All @@ -89,6 +91,7 @@ const router = createBrowserRouter([
element: <App />,
children: [
{ index: true, element: <HomePage /> },
{ path: EXO_PATH.slice(1), element: <ExoPage /> },
{ path: 'page-two', element: <PageTwoPage /> },
{ path: '*', element: <Navigate replace to={HOME_PATH} /> },
],
Expand Down
13 changes: 13 additions & 0 deletions implementations/react-web-sdk/src/pages/ExoPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { JSX } from 'react'
import { NodeViewDebugPanel } from '../components/NodeViewDebugPanel'
import { NodeViewTrackingSection } from '../sections/NodeViewTrackingSection'

export function ExoPage(): JSX.Element {
return (
<section data-testid="exo-page" style={{ display: 'grid', gap: 16 }}>
<h2>ExO Node View</h2>
<NodeViewTrackingSection />
<NodeViewDebugPanel />
</section>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { JSX } from 'react'
import { useState } from 'react'

function NestedFragment({ isRoot = false }: { isRoot?: boolean }): JSX.Element {
const [hasChild, setHasChild] = useState(false)

return (
<div
data-ctfl-node-id="demo-fragment-node"
data-ctfl-entity-id="demo-fragment"
data-ctfl-entity-kind="Fragment"
data-ctfl-optimization-id="demo-experience"
data-ctfl-variant="demo-fragment-variant"
data-ctfl-parent-experience-id="demo-experience"
data-testid={isRoot ? 'node-view-target' : undefined}
style={{
border: '1px dashed #777',
borderRadius: 4,
padding: 12,
display: 'grid',
gap: 8,
}}
>
<p>
<strong>Fragment node</strong>
</p>
{!hasChild && (
<button
onClick={() => {
setHasChild(true)
}}
type="button"
>
Add nested Fragment
</button>
)}
{hasChild && <NestedFragment />}
</div>
)
}

export function NodeViewTrackingSection(): JSX.Element {
return (
<section style={{ display: 'grid', gap: 8 }}>
<h2>Node View Tracking</h2>
<p>
Each tracked node emits an <code>exo_view</code> event. The Fragment carries a{' '}
<code>parentExperienceId</code> to preserve the Experience → Fragment hierarchy.
</p>

<div
data-ctfl-node-id="demo-experience-node"
data-ctfl-entity-id="demo-experience"
data-ctfl-entity-kind="Experience"
data-ctfl-optimization-id="demo-experience"
data-ctfl-variant="demo-experience-variant"
style={{ border: '1px solid #aaa', borderRadius: 4, padding: 12, display: 'grid', gap: 8 }}
>
<p>
<strong>Experience node</strong>
</p>

<NestedFragment isRoot />
</div>
</section>
)
}