diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 09cd6782..6aa6287d 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -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 @@ -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 @@ -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' +; + {(resolvedEntry) => } + +``` + +`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 + } +> + {(resolvedEntry) => } + +``` + +If `loadingFallback` is not provided, rendering follows the regular baseline/resolved path. + +#### Nested Composition + +Nested personalizations are supported by explicit composition: + +```tsx + + {(resolvedParent) => ( + + + {(resolvedChild) => } + + + )} + +``` + +#### 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` diff --git a/packages/web/frameworks/react-web-sdk/dev/App.tsx b/packages/web/frameworks/react-web-sdk/dev/App.tsx index a3963282..3833bf9e 100644 --- a/packages/web/frameworks/react-web-sdk/dev/App.tsx +++ b/packages/web/frameworks/react-web-sdk/dev/App.tsx @@ -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([]) + + 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): void => { + void promise.catch(() => null) + } return (

@contentful/optimization-react-web

-

Minimal live integration with OptimizationRoot config props.

+

Dev app split into modules for easier review and iteration.

SDK Wiring

OptimizationRoot: Active

-

{`Optimization SDK: ${optimization.constructor.name}`}

+

{`Optimization SDK: ${sdkName}`}

{`Global liveUpdates: ${globalLiveUpdates ? 'ON' : 'OFF'}`}

+

{`Preview panel visible: ${previewPanelVisible ? 'Open' : 'Closed'}`}

-
- {sectionTitles.map((title) => ( -
-

{title}

-

Scaffold placeholder. Runtime behavior will be implemented in follow-up tickets.

-
- ))} -
+ { + 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' }, + }), + ) + }} + /> + + + +
) } diff --git a/packages/web/frameworks/react-web-sdk/dev/components/EntryPanel.tsx b/packages/web/frameworks/react-web-sdk/dev/components/EntryPanel.tsx new file mode 100644 index 00000000..c1b90fd7 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/components/EntryPanel.tsx @@ -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 ( +
+

{title}

+

{getFieldText(resolvedEntry.fields.internalTitle) || 'No internalTitle field'}

+

{getFieldText(resolvedEntry.fields.text) || 'No text field'}

+

+ Entry ID: {resolvedEntry.sys.id} +

+

+ Type: {resolvedEntry.sys.contentType.sys.id} +

+
+ ) +} diff --git a/packages/web/frameworks/react-web-sdk/dev/constants.ts b/packages/web/frameworks/react-web-sdk/dev/constants.ts new file mode 100644 index 00000000..fdf49d71 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/constants.ts @@ -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 diff --git a/packages/web/frameworks/react-web-sdk/dev/contentful.ts b/packages/web/frameworks/react-web-sdk/dev/contentful.ts new file mode 100644 index 00000000..40bc63b9 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/contentful.ts @@ -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> { + 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() + entries.forEach((entry) => { + byId.set(entry.sys.id, entry) + }) + + return byId +} diff --git a/packages/web/frameworks/react-web-sdk/dev/hooks/useDevEntries.ts b/packages/web/frameworks/react-web-sdk/dev/hooks/useDevEntries.ts new file mode 100644 index 00000000..e124446c --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/hooks/useDevEntries.ts @@ -0,0 +1,44 @@ +import type { Entry } from 'contentful' +import { useEffect, useState } from 'react' +import { fetchDevEntries } from '../contentful' + +export interface UseDevEntriesResult { + entriesById: Map + loading: boolean + error: string | null +} + +export function useDevEntries(): UseDevEntriesResult { + const [entriesById, setEntriesById] = useState>(new Map()) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + + async function load(): Promise { + 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 } +} diff --git a/packages/web/frameworks/react-web-sdk/dev/hooks/useOptimizationState.ts b/packages/web/frameworks/react-web-sdk/dev/hooks/useOptimizationState.ts new file mode 100644 index 00000000..ca920129 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/hooks/useOptimizationState.ts @@ -0,0 +1,71 @@ +import type { Profile, SelectedPersonalizationArray } from '@contentful/optimization-api-schemas' +import { useEffect, useState } from 'react' +import type { OptimizationWebSdk } from '../../src' + +const MAX_EVENT_LOG_ITEMS = 20 + +export interface UseOptimizationStateResult { + consent: boolean | undefined + profile: Profile | undefined + personalizations: SelectedPersonalizationArray | undefined + previewPanelOpen: boolean + eventLog: string[] +} + +export function useOptimizationState(optimization: OptimizationWebSdk): UseOptimizationStateResult { + const [consent, setConsent] = useState(undefined) + const [profile, setProfile] = useState(undefined) + const [personalizations, setPersonalizations] = useState< + SelectedPersonalizationArray | undefined + >(undefined) + const [previewPanelOpen, setPreviewPanelOpen] = useState(false) + const [eventLog, setEventLog] = useState([]) + + useEffect(() => { + const consentSub = optimization.states.consent.subscribe((nextConsent) => { + setConsent(nextConsent) + }) + const profileSub = optimization.states.profile.subscribe((nextProfile) => { + setProfile(nextProfile) + }) + const personalizationsSub = optimization.states.personalizations.subscribe( + (nextPersonalizations) => { + setPersonalizations(nextPersonalizations) + }, + ) + const previewPanelSub = optimization.states.previewPanelOpen.subscribe((isOpen) => { + setPreviewPanelOpen(isOpen) + }) + const eventSub = optimization.states.eventStream.subscribe((event) => { + if (!event) return + + const timestamp = new Date().toLocaleTimeString() + const eventName = + event.type === 'track' + ? `track:${event.event}` + : event.type === 'identify' + ? 'identify' + : event.type === 'page' + ? 'page' + : event.type === 'screen' + ? `screen:${event.name}` + : event.type === 'component' + ? `component:${event.componentId}` + : event.type + + setEventLog((previous) => + [`${timestamp} ${eventName}`, ...previous].slice(0, MAX_EVENT_LOG_ITEMS), + ) + }) + + return () => { + consentSub.unsubscribe() + profileSub.unsubscribe() + personalizationsSub.unsubscribe() + previewPanelSub.unsubscribe() + eventSub.unsubscribe() + } + }, [optimization]) + + return { consent, profile, personalizations, previewPanelOpen, eventLog } +} diff --git a/packages/web/frameworks/react-web-sdk/dev/sections/ControlsSection.tsx b/packages/web/frameworks/react-web-sdk/dev/sections/ControlsSection.tsx new file mode 100644 index 00000000..637daba0 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/sections/ControlsSection.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react' + +interface ControlsSectionProps { + consent: boolean | undefined + eventLog: string[] + onGrantConsent: () => void + onRevokeConsent: () => void + onIdentify: () => void + onReset: () => void + onSendPage: () => void + onSendTrack: () => void +} + +export function ControlsSection({ + consent, + eventLog, + onGrantConsent, + onRevokeConsent, + onIdentify, + onReset, + onSendPage, + onSendTrack, +}: ControlsSectionProps): ReactElement { + return ( +
+
+

Consent

+

{`Current consent: ${String(consent)}`}

+
+ + +
+
+ +
+

Identify / Reset

+
+ + +
+
+ +
+

Event Stream

+
+ + +
+
+          {eventLog.length ? eventLog.join('\n') : 'No events yet.'}
+        
+
+
+ ) +} diff --git a/packages/web/frameworks/react-web-sdk/dev/sections/PersonalizationSection.tsx b/packages/web/frameworks/react-web-sdk/dev/sections/PersonalizationSection.tsx new file mode 100644 index 00000000..14df51a1 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/sections/PersonalizationSection.tsx @@ -0,0 +1,120 @@ +import type { SelectedPersonalizationArray } from '@contentful/optimization-api-schemas' +import type { Entry } from 'contentful' +import { useEffect, useState, type ReactElement } from 'react' +import { Personalization } from '../../src' +import { EntryPanel } from '../components/EntryPanel' +import type { DatasetSnapshot } from '../types' +import { readTrackingDataset, toJsonPreview } from '../utils' + +interface PersonalizationSectionProps { + baselineDefault?: Entry + baselineLive?: Entry + baselineLocked?: Entry + baselineNestedParent?: Entry + baselineNestedChild?: Entry + personalizations: SelectedPersonalizationArray | undefined +} + +export function PersonalizationSection({ + baselineDefault, + baselineLive, + baselineLocked, + baselineNestedParent, + baselineNestedChild, + personalizations, +}: PersonalizationSectionProps): ReactElement { + const [datasetDefault, setDatasetDefault] = useState(null) + const [datasetLive, setDatasetLive] = useState(null) + const [datasetLocked, setDatasetLocked] = useState(null) + + useEffect(() => { + setDatasetDefault(readTrackingDataset('personalization-default')) + setDatasetLive(readTrackingDataset('personalization-live')) + setDatasetLocked(readTrackingDataset('personalization-locked')) + }, [personalizations, baselineDefault, baselineLive, baselineLocked]) + + return ( + <> +
+ {baselineDefault ? ( + ( +
+

Personalization (inherits root liveUpdates)

+

Loading personalized content...

+
+ )} + > + {(resolvedEntry) => ( + + )} +
+ ) : null} + + {baselineLive ? ( + + {(resolvedEntry) => ( + + )} + + ) : null} + + {baselineLocked ? ( + + {(resolvedEntry) => ( + + )} + + ) : null} +
+ +
+ {baselineNestedParent && baselineNestedChild ? ( +
+

Nested Personalization

+ + {(parentResolved) => ( + <> +

{`Parent resolved ID: ${parentResolved.sys.id}`}

+ + {(childResolved) =>

{`Child resolved ID: ${childResolved.sys.id}`}

} +
+ + )} +
+
+ ) : null} + +
+

Tracking Attributes

+
+            {toJsonPreview({
+              inherited: datasetDefault,
+              liveTrue: datasetLive,
+              liveFalse: datasetLocked,
+            })}
+          
+
+
+ + ) +} diff --git a/packages/web/frameworks/react-web-sdk/dev/sections/StateSection.tsx b/packages/web/frameworks/react-web-sdk/dev/sections/StateSection.tsx new file mode 100644 index 00000000..44130ab8 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/sections/StateSection.tsx @@ -0,0 +1,62 @@ +import type { Profile, SelectedPersonalizationArray } from '@contentful/optimization-api-schemas' +import type { ReactElement } from 'react' +import { ENTRY_IDS } from '../constants' +import type { ResolveResult } from '../types' +import { toJsonPreview } from '../utils' + +interface StateSectionProps { + globalLiveUpdates: boolean + previewPanelVisible: boolean + previewPanelOpen: boolean + personalizations: SelectedPersonalizationArray | undefined + profile: Profile | undefined + entriesLoadedCount: number + entriesLoading: boolean + entriesError: string | null + resolveResults: ResolveResult[] + onResolveEntries: () => void +} + +export function StateSection({ + globalLiveUpdates, + previewPanelVisible, + previewPanelOpen, + personalizations, + profile, + entriesLoadedCount, + entriesLoading, + entriesError, + resolveResults, + onResolveEntries, +}: StateSectionProps): ReactElement { + const personalizationCount = Array.isArray(personalizations) ? personalizations.length : 0 + + return ( +
+
+

State Inspectors

+

{`Global liveUpdates: ${globalLiveUpdates ? 'ON' : 'OFF'}`}

+

{`Preview panel (context): ${previewPanelVisible ? 'Open' : 'Closed'}`}

+

{`Preview panel (state): ${previewPanelOpen ? 'Open' : 'Closed'}`}

+

{`Personalizations selected: ${personalizationCount}`}

+
{toJsonPreview(profile)}
+
+ +
+

Entry Resolver

+

Runs `optimization.personalizeEntry` for all baseline entries loaded below.

+ +
{toJsonPreview(resolveResults)}
+
+ +
+

Entry Loading

+

{`Loaded: ${entriesLoadedCount}/${ENTRY_IDS.length}`}

+

{entriesLoading ? 'Loading...' : 'Ready'}

+

{entriesError ? `Error: ${entriesError}` : 'No errors'}

+
+
+ ) +} diff --git a/packages/web/frameworks/react-web-sdk/dev/styles.css b/packages/web/frameworks/react-web-sdk/dev/styles.css index 9c5b9605..68593726 100644 --- a/packages/web/frameworks/react-web-sdk/dev/styles.css +++ b/packages/web/frameworks/react-web-sdk/dev/styles.css @@ -62,3 +62,21 @@ body { color: #334155; line-height: 1.4; } + +.dashboard__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.dashboard__pre { + margin-top: 0.75rem; + max-height: 220px; + overflow: auto; + padding: 0.75rem; + border-radius: 0.5rem; + background: #f1f5f9; + border: 1px solid #cbd5e1; + font-size: 0.75rem; + line-height: 1.35; +} diff --git a/packages/web/frameworks/react-web-sdk/dev/types.ts b/packages/web/frameworks/react-web-sdk/dev/types.ts new file mode 100644 index 00000000..cd33ac35 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/types.ts @@ -0,0 +1,15 @@ +export interface DatasetSnapshot { + ctflEntryId?: string + ctflPersonalizationId?: string + ctflVariantIndex?: string + ctflSticky?: string + ctflDuplicationScope?: string +} + +export interface ResolveResult { + baselineId: string + resolvedId: string + personalizationId?: string + variantIndex?: number + sticky?: boolean +} diff --git a/packages/web/frameworks/react-web-sdk/dev/utils.ts b/packages/web/frameworks/react-web-sdk/dev/utils.ts new file mode 100644 index 00000000..072d7547 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/dev/utils.ts @@ -0,0 +1,34 @@ +import type { DatasetSnapshot } from './types' + +export function getFieldText(field: unknown): string { + if (typeof field === 'string') return field + + if (field && typeof field === 'object' && 'nodeType' in field) { + return '[Rich Text Content]' + } + + return '' +} + +export function toJsonPreview(value: unknown): string { + if (value === undefined) return 'undefined' + + try { + return JSON.stringify(value, null, 2) + } catch { + return '[Unserializable value]' + } +} + +export function readTrackingDataset(testId: string): DatasetSnapshot | null { + const element = document.querySelector(`[data-testid="${testId}"]`) + if (!(element instanceof HTMLElement)) return null + + return { + ctflEntryId: element.dataset.ctflEntryId, + ctflPersonalizationId: element.dataset.ctflPersonalizationId, + ctflVariantIndex: element.dataset.ctflVariantIndex, + ctflSticky: element.dataset.ctflSticky, + ctflDuplicationScope: element.dataset.ctflDuplicationScope, + } +} diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 984b716f..0e22f028 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -96,6 +96,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "build-tools": "workspace:*", + "contentful": "catalog:", "happy-dom": "catalog:", "rimraf": "catalog:", "tslib": "catalog:", diff --git a/packages/web/frameworks/react-web-sdk/src/context/LiveUpdatesContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/LiveUpdatesContext.tsx index d2eed27c..26518aa0 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/LiveUpdatesContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/LiveUpdatesContext.tsx @@ -2,6 +2,8 @@ import { createContext } from 'react' export interface LiveUpdatesContextValue { readonly globalLiveUpdates: boolean + readonly previewPanelVisible: boolean + setPreviewPanelVisible: (visible: boolean) => void } export const LiveUpdatesContext = createContext(null) diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 21267900..4d8051c4 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -6,6 +6,7 @@ import { OptimizationContext, OptimizationProvider, OptimizationRoot, + Personalization, useAnalytics, useLiveUpdates, useOptimization, @@ -42,6 +43,7 @@ describe('@contentful/optimization-react-web core providers', () => { expect(useOptimization).toBeTypeOf('function') expect(useLiveUpdates).toBeTypeOf('function') expect(usePersonalization).toBeTypeOf('function') + expect(Personalization).toBeTypeOf('function') expect(useAnalytics).toBeTypeOf('function') }) diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts index ecf3eca0..be04f05c 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.ts +++ b/packages/web/frameworks/react-web-sdk/src/index.ts @@ -6,6 +6,11 @@ export { OptimizationContext } from './context/OptimizationContext' export type { OptimizationContextValue } from './context/OptimizationContext' export { useLiveUpdates } from './hooks/useLiveUpdates' export { useOptimization } from './hooks/useOptimization' +export { Personalization } from './personalization/Personalization' +export type { + PersonalizationLoadingFallback, + PersonalizationProps, +} from './personalization/Personalization' export { usePersonalization } from './personalization/usePersonalization' export type { UsePersonalizationResult } from './personalization/usePersonalization' export { LiveUpdatesProvider } from './provider/LiveUpdatesProvider' diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx new file mode 100644 index 00000000..c696d983 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx @@ -0,0 +1,430 @@ +import type { SelectedPersonalizationArray } from '@contentful/optimization-web/api-schemas' +import type { ResolvedData } from '@contentful/optimization-web/core-sdk' +import type { Entry, EntrySkeletonType } from 'contentful' +import { act, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import type { LiveUpdatesContextValue } from '../context/LiveUpdatesContext' +import { LiveUpdatesContext } from '../context/LiveUpdatesContext' +import { OptimizationContext } from '../context/OptimizationContext' +import { Personalization } from './Personalization' + +type TestEntry = Entry +type PersonalizationState = SelectedPersonalizationArray | undefined +type PersonalizeEntry = ( + entry: TestEntry, + personalizations: PersonalizationState, +) => ResolvedData +type PersonalizationsSubscriber = (value: PersonalizationState) => void +type CanPersonalizeSubscriber = (value: boolean) => void + +interface RuntimeOptimization { + personalizeEntry: PersonalizeEntry + states: { + canPersonalize: { + subscribe: (next: CanPersonalizeSubscriber) => { unsubscribe: () => void } + } + personalizations: { + subscribe: (next: PersonalizationsSubscriber) => { unsubscribe: () => void } + } + } +} + +function makeEntry(id: string): TestEntry { + const entry: TestEntry = { + fields: { title: id }, + metadata: { tags: [] }, + sys: { + contentType: { sys: { id: 'test-content-type', linkType: 'ContentType', type: 'Link' } }, + createdAt: '2024-01-01T00:00:00.000Z', + environment: { sys: { id: 'main', linkType: 'Environment', type: 'Link' } }, + id, + publishedVersion: 1, + revision: 1, + space: { sys: { id: 'space-id', linkType: 'Space', type: 'Link' } }, + type: 'Entry', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + } + + return entry +} + +function createRuntime(personalizeEntry: PersonalizeEntry): { + emit: (value: PersonalizationState) => Promise + optimization: RuntimeOptimization +} { + const subscribers = new Set() + const canPersonalizeSubscribers = new Set() + let current: PersonalizationState = undefined + let canPersonalize = false + + const optimization: RuntimeOptimization = { + personalizeEntry, + states: { + canPersonalize: { + subscribe(next: CanPersonalizeSubscriber) { + canPersonalizeSubscribers.add(next) + next(canPersonalize) + + return { + unsubscribe() { + canPersonalizeSubscribers.delete(next) + }, + } + }, + }, + personalizations: { + subscribe(next: PersonalizationsSubscriber) { + subscribers.add(next) + next(current) + + return { + unsubscribe() { + subscribers.delete(next) + }, + } + }, + }, + }, + } + + async function emit(value: PersonalizationState): Promise { + current = value + canPersonalize = value !== undefined + + await act(async () => { + await Promise.resolve() + canPersonalizeSubscribers.forEach((subscriber) => { + subscriber(canPersonalize) + }) + subscribers.forEach((subscriber) => { + subscriber(value) + }) + }) + } + + return { emit, optimization } +} + +function defaultLiveUpdatesContext(): LiveUpdatesContextValue { + return { + globalLiveUpdates: false, + previewPanelVisible: false, + setPreviewPanelVisible() { + return undefined + }, + } +} + +async function renderComponent( + node: ReactNode, + optimization: RuntimeOptimization, + liveUpdatesContext = defaultLiveUpdatesContext(), +): Promise<{ container: HTMLDivElement; unmount: () => Promise }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + await act(async () => { + await Promise.resolve() + root.render( + // @ts-expect-error test double only implements the subset used by Personalization + + {node} + , + ) + }) + + return { + container, + async unmount() { + await act(async () => { + await Promise.resolve() + root.unmount() + }) + container.remove() + }, + } +} + +function getWrapper(container: HTMLElement): HTMLElement { + const { firstElementChild: wrapper } = container + + if (!(wrapper instanceof HTMLElement)) { + throw new TypeError('Expected first child to be an HTMLElement') + } + + return wrapper +} + +function readTitle(entry: TestEntry): string { + const { + fields: { title }, + } = entry + return typeof title === 'string' ? title : '' +} + +describe('Personalization', () => { + const baseline = makeEntry('baseline') + const variantA = makeEntry('variant-a') + const variantB = makeEntry('variant-b') + + const baselineParent = makeEntry('parent-baseline') + const variantParent = makeEntry('parent-variant') + const baselineChild = makeEntry('child-baseline') + const variantChild = makeEntry('child-variant') + + const variantOneState: SelectedPersonalizationArray = [ + { + experienceId: 'exp-hero', + sticky: true, + variantIndex: 1, + variants: { + baseline: 'variant-a', + }, + }, + ] + + const variantTwoState: SelectedPersonalizationArray = [ + { + experienceId: 'exp-hero', + sticky: false, + variantIndex: 2, + variants: { + baseline: 'variant-b', + }, + }, + ] + + void afterEach(() => { + document.body.innerHTML = '' + }) + + it('renders baseline by default when personalization is unresolved and no loading fallback is provided', async () => { + const { optimization } = createRuntime((entry, personalizations) => { + if (!personalizations?.length) return { entry } + return { entry: variantA, personalization: personalizations[0] } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + expect(view.container.textContent).toContain('baseline') + + const wrapper = getWrapper(view.container) + expect(wrapper.dataset.ctflEntryId).toBe('baseline') + expect(wrapper.dataset.ctflPersonalizationId).toBeUndefined() + expect(wrapper.dataset.ctflVariantIndex).toBe('0') + + await view.unmount() + }) + + it('locks to first non-undefined personalization state when live updates are disabled', async () => { + const { optimization, emit } = createRuntime((entry, personalizations) => { + const selected = personalizations?.[0] + const variant = selected ? { 1: variantA, 2: variantB }[selected.variantIndex] : undefined + if (variant && selected) return { entry: variant, personalization: selected } + return { entry } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + await emit(variantOneState) + expect(view.container.textContent).toContain('variant-a') + + await emit(variantTwoState) + expect(view.container.textContent).toContain('variant-a') + + await view.unmount() + }) + + it('updates continuously when liveUpdates is true', async () => { + const { optimization, emit } = createRuntime((entry, personalizations) => { + const selected = personalizations?.[0] + const variant = selected ? { 1: variantA, 2: variantB }[selected.variantIndex] : undefined + if (variant && selected) return { entry: variant, personalization: selected } + return { entry } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + await emit(variantOneState) + expect(view.container.textContent).toContain('variant-a') + + await emit(variantTwoState) + expect(view.container.textContent).toContain('variant-b') + + await view.unmount() + }) + + it('uses loadingFallback while unresolved and removes resolved tracking attrs during loading', async () => { + const { optimization, emit } = createRuntime((entry, personalizations) => { + if (!personalizations?.length) return { entry } + return { entry: variantA, personalization: personalizations[0] } + }) + + const view = await renderComponent( + 'loading'}> + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + expect(view.container.textContent).toContain('loading') + + const loadingWrapper = getWrapper(view.container) + expect(loadingWrapper.dataset.ctflEntryId).toBeUndefined() + + await emit(variantOneState) + + expect(view.container.textContent).toContain('variant-a') + const resolvedWrapper = getWrapper(view.container) + expect(resolvedWrapper.dataset.ctflEntryId).toBe('variant-a') + + await view.unmount() + }) + + it('maps data-ctfl-* attributes from resolved personalization metadata', async () => { + const { optimization, emit } = createRuntime((entry, personalizations) => { + const selected = personalizations?.[0] + if (!selected) return { entry } + + return { + entry: variantB, + personalization: { + ...selected, + duplicationScope: 'session', + }, + } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + await emit(variantTwoState) + + const wrapper = getWrapper(view.container) + expect(wrapper.dataset.ctflEntryId).toBe('variant-b') + expect(wrapper.dataset.ctflPersonalizationId).toBe('exp-hero') + expect(wrapper.dataset.ctflSticky).toBe('false') + expect(wrapper.dataset.ctflVariantIndex).toBe('2') + expect(wrapper.dataset.ctflDuplicationScope).toBe('session') + + await view.unmount() + }) + + it('supports testId/data-testid props with data-testid precedence', async () => { + const { optimization } = createRuntime((entry) => ({ entry })) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + const wrapper = getWrapper(view.container) + expect(wrapper.dataset.testid).toBe('direct') + + await view.unmount() + }) + + it('supports nested personalization composition', async () => { + const nestedState: SelectedPersonalizationArray = [ + { + experienceId: 'exp-nested', + sticky: true, + variantIndex: 1, + variants: { + 'parent-baseline': 'parent-variant', + 'child-baseline': 'child-variant', + }, + }, + ] + + const { optimization, emit } = createRuntime((entry, personalizations) => { + const selected = personalizations?.[0] + if (!selected) return { entry } + + if (entry.sys.id === 'parent-baseline') { + return { entry: variantParent, personalization: selected } + } + + if (entry.sys.id === 'child-baseline') { + return { entry: variantChild, personalization: selected } + } + + return { entry } + }) + + const view = await renderComponent( + + {(parentResolved) => ( +
+

{readTitle(parentResolved)}

+ + {(childResolved) =>

{readTitle(childResolved)}

} +
+
+ )} +
, + optimization, + ) + + await emit(nestedState) + + expect(view.container.textContent).toContain('parent-variant') + expect(view.container.textContent).toContain('child-variant') + + await view.unmount() + }) + + it('preview panel visibility forces live updates even when component liveUpdates is false', async () => { + const { optimization, emit } = createRuntime((entry, personalizations) => { + const selected = personalizations?.[0] + const variant = selected ? { 1: variantA, 2: variantB }[selected.variantIndex] : undefined + if (variant && selected) return { entry: variant, personalization: selected } + return { entry } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + { + globalLiveUpdates: false, + previewPanelVisible: true, + setPreviewPanelVisible() { + return undefined + }, + }, + ) + + await emit(variantOneState) + expect(view.container.textContent).toContain('variant-a') + + await emit(variantTwoState) + expect(view.container.textContent).toContain('variant-b') + + await view.unmount() + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx new file mode 100644 index 00000000..3b420cab --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -0,0 +1,181 @@ +import type { + SelectedPersonalization, + SelectedPersonalizationArray, +} from '@contentful/optimization-web/api-schemas' +import type { ResolvedData } from '@contentful/optimization-web/core-sdk' +import type { Entry, EntrySkeletonType } from 'contentful' +import { useEffect, useMemo, useState, type JSX, type ReactNode } from 'react' +import { useLiveUpdates } from '../hooks/useLiveUpdates' +import { useOptimization } from '../hooks/useOptimization' + +export type PersonalizationLoadingFallback = ReactNode | (() => ReactNode) +export type PersonalizationWrapperElement = 'div' | 'span' + +/** + * Props for the {@link Personalization} component. + * + * @public + */ +export interface PersonalizationProps { + /** + * The baseline Contentful entry fetched with `include: 10`. + * Must include `nt_experiences` field with linked personalization data. + */ + baselineEntry: Entry + + /** + * Render prop that receives the resolved variant entry. + */ + children: (resolvedEntry: Entry) => ReactNode + + /** + * Whether this component should react to personalization state changes in real-time. + * When `undefined`, inherits from the `liveUpdates` prop on {@link OptimizationRoot}. + */ + liveUpdates?: boolean + + /** + * Wrapper element used to mount tracking attributes. + * Defaults to `div`. + */ + as?: PersonalizationWrapperElement + + /** + * Optional test id prop. + */ + testId?: string + + /** + * Optional data-testid prop. + */ + 'data-testid'?: string + + /** + * Optional fallback rendered while personalization state is unresolved. + */ + loadingFallback?: PersonalizationLoadingFallback +} + +function resolveLoadingFallback( + loadingFallback: PersonalizationLoadingFallback | undefined, +): ReactNode { + if (typeof loadingFallback === 'function') return loadingFallback() + return loadingFallback +} + +const WRAPPER_STYLE = Object.freeze({ display: 'contents' as const }) + +function resolveDuplicationScope( + personalization: SelectedPersonalization | undefined, +): string | undefined { + const candidate = + personalization && typeof personalization === 'object' && 'duplicationScope' in personalization + ? personalization.duplicationScope + : undefined + if (typeof candidate !== 'string') return undefined + return candidate.trim() ? candidate : undefined +} + +function resolveShouldLiveUpdate(params: { + previewPanelVisible: boolean + componentLiveUpdates: boolean | undefined + globalLiveUpdates: boolean +}): boolean { + const { previewPanelVisible, componentLiveUpdates, globalLiveUpdates } = params + if (previewPanelVisible) return true + return componentLiveUpdates ?? globalLiveUpdates +} + +function resolveTrackingAttributes( + resolvedData: ResolvedData, +): Record { + const { personalization } = resolvedData + + return { + 'data-ctfl-duplication-scope': resolveDuplicationScope(personalization), + 'data-ctfl-entry-id': resolvedData.entry.sys.id, + 'data-ctfl-personalization-id': personalization?.experienceId, + 'data-ctfl-sticky': + personalization?.sticky === undefined ? undefined : String(personalization.sticky), + 'data-ctfl-variant-index': String(personalization?.variantIndex ?? 0), + } +} + +export function Personalization({ + baselineEntry, + children, + liveUpdates, + as = 'div', + testId, + 'data-testid': dataTestIdProp, + loadingFallback, +}: PersonalizationProps): JSX.Element { + const optimization = useOptimization() + const liveUpdatesContext = useLiveUpdates() + + const shouldLiveUpdate = resolveShouldLiveUpdate({ + componentLiveUpdates: liveUpdates, + globalLiveUpdates: liveUpdatesContext.globalLiveUpdates, + previewPanelVisible: liveUpdatesContext.previewPanelVisible, + }) + + const [lockedPersonalizations, setLockedPersonalizations] = useState< + SelectedPersonalizationArray | undefined + >(undefined) + const [canPersonalize, setCanPersonalize] = useState(false) + + useEffect(() => { + const personalizationsSubscription = optimization.states.personalizations.subscribe((p) => { + setLockedPersonalizations((previous) => { + if (shouldLiveUpdate) { + // Live updates enabled - always update state + return p + } + + if (previous === undefined && p !== undefined) { + // First non-undefined value - lock it + return p + } + + // Otherwise ignore updates (we're locked to the initial value) + return previous + }) + }) + const canPersonalizeSubscription = optimization.states.canPersonalize.subscribe((value) => { + setCanPersonalize(value) + }) + + return () => { + personalizationsSubscription.unsubscribe() + canPersonalizeSubscription.unsubscribe() + } + }, [optimization, shouldLiveUpdate]) + + const resolvedData: ResolvedData = useMemo( + () => optimization.personalizeEntry(baselineEntry, lockedPersonalizations), + [optimization, baselineEntry, lockedPersonalizations], + ) + + const isLoading = !canPersonalize + const showLoadingFallback = loadingFallback !== undefined && isLoading + const dataTestId = dataTestIdProp ?? testId + const Wrapper = as + + if (showLoadingFallback) { + return ( + + {resolveLoadingFallback(loadingFallback)} + + ) + } + + const trackingAttributes = resolveTrackingAttributes(resolvedData) + + return ( + + {children(resolvedData.entry)} + + ) +} + +export default Personalization diff --git a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx index 54cba874..5d27360d 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx @@ -1,5 +1,7 @@ import type { PropsWithChildren, ReactElement } from 'react' +import { useEffect, useState } from 'react' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' +import { useOptimization } from '../hooks/useOptimization' export interface LiveUpdatesProviderProps extends PropsWithChildren { readonly globalLiveUpdates?: boolean @@ -9,8 +11,22 @@ export function LiveUpdatesProvider({ children, globalLiveUpdates = false, }: LiveUpdatesProviderProps): ReactElement { + const optimization = useOptimization() + const [previewPanelVisible, setPreviewPanelVisible] = useState(false) + + useEffect(() => { + const sub = optimization.states.previewPanelOpen.subscribe((isOpen) => { + setPreviewPanelVisible(isOpen) + }) + return () => { + sub.unsubscribe() + } + }, [optimization]) + return ( - + {children} ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe833ee..b25f33f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -618,6 +618,9 @@ importers: build-tools: specifier: workspace:* version: link:../../../../lib/build-tools + contentful: + specifier: 'catalog:' + version: 11.10.5 happy-dom: specifier: 'catalog:' version: 20.6.1