diff --git a/implementations/react-web-sdk/src/App.tsx b/implementations/react-web-sdk/src/App.tsx index 27ef004c..28d3b59b 100644 --- a/implementations/react-web-sdk/src/App.tsx +++ b/implementations/react-web-sdk/src/App.tsx @@ -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' @@ -138,6 +138,9 @@ export default function App(): JSX.Element { Home + + ExO + Go to Page Two diff --git a/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx b/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx new file mode 100644 index 00000000..e983359b --- /dev/null +++ b/implementations/react-web-sdk/src/components/NodeViewDebugPanel.tsx @@ -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(undefined) + const [profileId, setProfileId] = useState(undefined) + const [nodeViewEventsSeen, setNodeViewEventsSeen] = useState(0) + const [latestNodeViewEvent, setLatestNodeViewEvent] = useState( + undefined, + ) + const [latestBlockedNodeView, setLatestBlockedNodeView] = useState< + BlockedNodeViewSummary | undefined + >(undefined) + const [targetSnapshot, setTargetSnapshot] = useState( + undefined, + ) + const [runtimeSnapshot, setRuntimeSnapshot] = + useState(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 ( +
+

Node view debug panel

+

Consent: {`${consent}`}

+

Profile ID: {profileId}

+

Node view events seen: {nodeViewEventsSeen}

+

Node target present: {`${targetSnapshot !== undefined}`}

+

Matching node elements: {runtimeSnapshot.matchingNodeElementsCount}

+

autoTrackNodeInteraction.views: {`${runtimeSnapshot.autoTrackNodeInteractionViews}`}

+

nodeViewRuntime started: {`${runtimeSnapshot.runtimeStarted}`}

+

nodeId: {targetSnapshot?.nodeId}

+

entityId: {targetSnapshot?.entityId}

+

entityKind: {targetSnapshot?.entityKind}

+

optimizationId: {targetSnapshot?.optimizationId}

+

variant: {targetSnapshot?.variant}

+

+ Last blocked:{' '} + {latestBlockedNodeView && `${latestBlockedNodeView.method}:${latestBlockedNodeView.reason}`} +

+

Last exo_view viewId: {latestNodeViewEvent?.viewId}

+

Last exo_view duration: {latestNodeViewEvent?.viewDurationMs}ms

+

Insights events are queued by the SDK; network emission can lag behind event detection.

+
+ ) +} diff --git a/implementations/react-web-sdk/src/config/routes.ts b/implementations/react-web-sdk/src/config/routes.ts index 27c95c63..516c92c8 100644 --- a/implementations/react-web-sdk/src/config/routes.ts +++ b/implementations/react-web-sdk/src/config/routes.ts @@ -1,2 +1,3 @@ export const HOME_PATH = '/' +export const EXO_PATH = '/exo' export const PAGE_TWO_PATH = '/page-two' diff --git a/implementations/react-web-sdk/src/main.tsx b/implementations/react-web-sdk/src/main.tsx index 03c6378d..16b1c450 100644 --- a/implementations/react-web-sdk/src/main.tsx +++ b/implementations/react-web-sdk/src/main.tsx @@ -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' @@ -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', @@ -89,6 +91,7 @@ const router = createBrowserRouter([ element: , children: [ { index: true, element: }, + { path: EXO_PATH.slice(1), element: }, { path: 'page-two', element: }, { path: '*', element: }, ], diff --git a/implementations/react-web-sdk/src/pages/ExoPage.tsx b/implementations/react-web-sdk/src/pages/ExoPage.tsx new file mode 100644 index 00000000..ba443909 --- /dev/null +++ b/implementations/react-web-sdk/src/pages/ExoPage.tsx @@ -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 ( +
+

ExO Node View

+ + +
+ ) +} diff --git a/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx new file mode 100644 index 00000000..2cb3b5bd --- /dev/null +++ b/implementations/react-web-sdk/src/sections/NodeViewTrackingSection.tsx @@ -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 ( +
+

+ Fragment node +

+ {!hasChild && ( + + )} + {hasChild && } +
+ ) +} + +export function NodeViewTrackingSection(): JSX.Element { + return ( +
+

Node View Tracking

+

+ Each tracked node emits an exo_view event. The Fragment carries a{' '} + parentExperienceId to preserve the Experience → Fragment hierarchy. +

+ +
+

+ Experience node +

+ + +
+
+ ) +}