diff --git a/examples/nextjs/.env.example b/examples/nextjs/.env.example index 2bddab2..4a714fb 100644 --- a/examples/nextjs/.env.example +++ b/examples/nextjs/.env.example @@ -3,3 +3,9 @@ SPACE_ID= ENVIRONMENT_ID=master CDA_TOKEN= + +# Personalization SDK (Milestone 13 spike smoke). NEXT_PUBLIC_* prefix so the +# values are inlined into the client bundle — required because the +# optimization SDK is browser-only. +NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID= +NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT=main diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 5790948..fa2143b 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -22,7 +22,41 @@ cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN npm run dev ``` -Then visit `http://localhost:3000/` — the slug becomes the Experience ID passed to `client.view.getExperience`. +Then visit `http://localhost:3000/` — the slug becomes the Experience ID passed to `client.view.getExperienceWithOverrides`. + +## Personalization (NT-3613 spike smoke) + +This example was clobbered as the manual browser smoke for **Milestone 13** of the [optimization spike](../../../optimization/documentation/specs/2026-07-07-exo-optimization-personalization-and-analytics-spike-plan.md). Not intended to be merged as-is. + +**What's wired:** + +- `lib/delivery-client.ts` now calls `view.getExperienceWithOverrides(...)` with `extensions.sourceMap: {}` so the response carries `extensions.sourceMap` — the per-node personalization provenance table. +- `lib/optimization-client.ts` (new, `'use client'`) constructs a browser-only `ContentfulOptimization` instance keyed off `NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID`. Module-scope singleton so navigation doesn't spin up a second instance. +- `components/ClientExperience.tsx` (new client boundary) mounts `` with `optimization={{ enabled: true, client: optimizationClient }}`. After hydration, `ClientExperienceRenderer` publishes `OptimizationProvider` to the subtree and calls `attachInteractionRuntime({ views, clicks, hovers })`. +- `app/[slug]/page.tsx` — server component fetches with sourceMap, threads it through `resolveExperience(view, config, { sourceMap })`, and hands the plan to ``. + +**One-time setup (the optimization peer isn't published to npm):** + +```sh +# 1. Build + pack the sibling repo's packages +cd /path/to/contentful/optimization +pnpm build:pkgs + +# 2. Back to this workspace — the file: link in package.json points at ../../../optimization/pkgs/*.tgz +cd /path/to/contentful/experiences +npm install +``` + +**What to look for in the browser:** + +- Personalized nodes carry `data-ctfl-node-id` + six more `data-ctfl-*` attrs in the SSR HTML (`view-source`). +- `exo_node_view` fires on scroll into a personalized node (Network → filter `insights`). +- `click` / `hover` fire for personalized nodes only; non-personalized nodes emit nothing. +- `display: contents` on the wrapper does not break flex / grid layout in the fixture. + +**What's not wired (deferred from the spike):** + +- The `getPersonalizationRequest()` → server POST → `ingestPersonalizationResponse()` round-trip described in spec §4. The SDK is browser-only, so this needs a fetch layer between client and the delivery-client-owning server. Left as follow-up. ## Two routes, same data diff --git a/examples/nextjs/app/[slug]/page.tsx b/examples/nextjs/app/[slug]/page.tsx index 198fc66..6fe0f8d 100644 --- a/examples/nextjs/app/[slug]/page.tsx +++ b/examples/nextjs/app/[slug]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer, resolveExperience } from '@contentful/experiences-react'; +import { resolveExperience } from '@contentful/experiences-react'; +import { ClientExperience } from '@/components/ClientExperience'; import { fetchExperience } from '@/lib/delivery-client'; import { experienceConfig } from '@/lib/experience-config'; @@ -11,10 +12,14 @@ interface PageProps { export default async function ExperiencePage({ params }: PageProps) { const { slug: experienceId } = await params; - const { payload } = await fetchExperience(experienceId); + // Server-side fetch via `getExperienceWithOverrides` returns the plan + // *and* an `extensions.sourceMap` that describes how each node was picked. + // Threading the sourceMap through `resolveExperience` puts it on the plan + // so per-node instrumentation can light up after hydration. + const { payload, sourceMap } = await fetchExperience(experienceId); if (!payload.nodes.length) notFound(); - const experience = await resolveExperience(payload, experienceConfig); + const experience = await resolveExperience(payload, experienceConfig, { sourceMap }); - return ; + return ; } diff --git a/examples/nextjs/components/ClientExperience.tsx b/examples/nextjs/components/ClientExperience.tsx new file mode 100644 index 0000000..9dedc66 --- /dev/null +++ b/examples/nextjs/components/ClientExperience.tsx @@ -0,0 +1,32 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { ClientExperienceRenderer, type PortableRenderPlan } from '@contentful/experiences-react'; + +import { experienceConfig } from '@/lib/experience-config'; +import { getOptimizationClient } from '@/lib/optimization-client'; + +export interface ClientExperienceProps { + experience: PortableRenderPlan; +} + +/** + * Client boundary for the personalization pipeline. The optimization SDK is + * browser-only, so we can't construct it on the server — this component owns + * that lifetime and hands the singleton to `ClientExperienceRenderer` via the + * `optimization` prop. The renderer publishes `OptimizationProvider` to the + * subtree and, after hydration, calls `attachInteractionRuntime({ views, + * clicks, hovers })` — from that point on, personalized nodes emit + * `exo_node_view` / `click` / `hover` events as the user interacts with them. + */ +export function ClientExperience({ experience }: ClientExperienceProps): ReactNode { + const optimization = getOptimizationClient(); + + return ( + + ); +} diff --git a/examples/nextjs/lib/delivery-client.ts b/examples/nextjs/lib/delivery-client.ts index a97de8c..86331f7 100644 --- a/examples/nextjs/lib/delivery-client.ts +++ b/examples/nextjs/lib/delivery-client.ts @@ -2,38 +2,57 @@ * Server-side fetch of an Experience via @contentful/experience-delivery. * Requires SPACE_ID and CDA_TOKEN in the environment. * - * The delivery client's `GetExperienceViewResponse` is structurally - * compatible with our `ExperiencePayload`, so we hand it straight to - * `buildPlan` without normalization. + * Uses `view.getExperienceWithOverrides` (not `getExperience`) so the response + * carries `extensions.sourceMap` — the personalization SDK's per-node + * provenance table that `resolveExperience` threads onto the plan and the + * adapter consumes at render time. Requesting `extensions.sourceMap: {}` in + * the request body opts the response into carrying the field. */ import 'server-only'; import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; -import type { ExperiencePayload } from '@contentful/experiences-react'; +import type { + DeliveryViewSourceMap, + ExperiencePayload, +} from '@contentful/experiences-react'; const experienceClient = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! }); export interface FetchExperienceResult { payload: ExperiencePayload; + sourceMap: DeliveryViewSourceMap | undefined; } -/** - * The delivery client returns a `GetExperienceViewResponse` whose shape is - * structurally compatible with our `ExperiencePayload`. No normalization - * step needed — pass it straight to `buildPlan`. - */ export async function fetchExperience( experienceId: string, - options: { locale?: string; preview?: boolean } = {} + options: { locale?: string; preview?: boolean } = {}, ): Promise { const spaceId = process.env.SPACE_ID || ''; const environmentId = process.env.ENVIRONMENT_ID ?? 'master'; - const response = await experienceClient.view.getExperience(spaceId, environmentId, experienceId, { - locale: options.locale, - preview: options.preview ? 'true' : undefined, - }); + const response = await experienceClient.view.getExperienceWithOverrides( + spaceId, + environmentId, + experienceId, + { + locale: options.locale, + preview: options.preview ? 'true' : undefined, + extensions: { + // Opting in to the sourceMap response field. Passing `{}` is enough — + // any value present in the request body is treated as opt-in by XDA. + sourceMap: {}, + }, + }, + ); + + const { extensions, ...rest } = response; - return { payload: response }; + return { + // The overrides response is structurally compatible with our + // `ExperiencePayload` — just strip the `extensions` field so it isn't + // interpreted as node data. + payload: rest as unknown as ExperiencePayload, + sourceMap: extensions?.sourceMap as DeliveryViewSourceMap | undefined, + }; } diff --git a/examples/nextjs/lib/experience-config-advanced.tsx b/examples/nextjs/lib/experience-config-advanced.tsx index c8c26d0..3e0d44c 100644 --- a/examples/nextjs/lib/experience-config-advanced.tsx +++ b/examples/nextjs/lib/experience-config-advanced.tsx @@ -1,83 +1,60 @@ /** - * Advanced integration config — shows the configuration knobs you reach for - * when the simple config (`./experience-config.tsx`) isn't enough. + * Advanced integration config — shows the knobs you reach for when the + * simple config (`./experience-config.tsx`) isn't enough: * - * Differences from the simple config: * - **Async `resolveData`** on `button` — a deliberately slow fake fetch, * plus a synthetic localized URL derived from `experience.metadata.locale`. - * Demonstrates that resolvers run **in parallel across nodes** before - * rendering, and that they have access to `experience.metadata` (which the - * advanced page passes in via `resolveExperience`'s third argument). - * - **Reads `experience.metadata`** to build a per-page label — proves the + * Resolvers run in parallel across nodes before rendering, and they + * receive `experience.metadata` (which the advanced page passes in via + * `resolveExperience`'s third argument). + * - **Reads `experience.metadata`** to build a per-page URL, proving that * metadata is threaded through every resolver. - * - * Templates and the rest of the components are identical to the simple - * config. Only the `button` is enriched. */ import { defineComponent, - defineTemplate, type Components, type Config, type Templates, } from '@contentful/experiences-react'; import { Button, type ButtonProps } from '@/components/Button'; -import { Header, type HeaderProps } from '@/components/Header'; -import { Page, type PageProps } from '@/components/Page'; -import { Text, type TextProps } from '@/components/Text'; +import { Header } from '@/components/Header'; +import { Page } from '@/components/Page'; +import { Text } from '@/components/Text'; -// Pretend this fetches enrichment from a catalog or pricing API. The point -// is just that it's async and takes non-trivial time — the SDK fans these -// out across all nodes in parallel via Promise.all when resolveExperience is called. +// Pretend this fetches enrichment from a catalog or pricing API. The point is +// just that it's async and takes non-trivial time — the SDK fans these out +// across all nodes in parallel via Promise.all in resolveExperience. async function fetchButtonEnrichment(text: string): Promise<{ formattedText: string }> { await new Promise((resolve) => setTimeout(resolve, 50)); return { formattedText: text.toUpperCase() }; } const components: Components = { + // defineComponent narrows the resolveData ctx + return type to ButtonProps. button: defineComponent({ defaults: { type: 'primary' }, resolveData: async ({ content, experience }) => { const rawText = (content.text as string) ?? 'Button'; const { formattedText } = await fetchButtonEnrichment(rawText); - - // experience.metadata is passed in by the page via - // resolveExperience(payload, config, { experience: { metadata: {...} } }) const locale = (experience.metadata.locale as string) ?? 'en-US'; const slug = (experience.metadata.slug as string) ?? ''; - return { text: formattedText, url: `/${locale}/${slug}`, }; }, - render: Button, - }), - - header: defineComponent({ - defaults: { variant: 'h2', text: 'Hello World' }, - render: Header, + component: Button, }), - text: defineComponent({ - render: Text, - }), + header: { component: Header, defaults: { variant: 'h2', text: 'Hello World' } }, + text: Text, }; const templates: Templates = { - hi: defineTemplate({ - defaults: { title: 'Welcome (advanced)' }, - render: Page, - }), - hero: defineTemplate({ - defaults: { title: 'Featured (advanced)' }, - render: Page, - }), + hi: { component: Page, defaults: { title: 'Welcome (advanced)' } }, + hero: { component: Page, defaults: { title: 'Featured (advanced)' } }, }; -export const advancedExperienceConfig: Config = { - components, - templates, -}; +export const advancedExperienceConfig: Config = { components, templates }; diff --git a/examples/nextjs/lib/experience-config.tsx b/examples/nextjs/lib/experience-config.tsx index 3c8f0bd..26a20fd 100644 --- a/examples/nextjs/lib/experience-config.tsx +++ b/examples/nextjs/lib/experience-config.tsx @@ -2,77 +2,36 @@ * The integration layer between Contentful's Experience payload shape and * the customer's design system. * - * The design-system components in `../components/` stay free of any - * `@contentful/*` imports — they remain portable and don't know they're - * being driven by Contentful. This file is where SDK-shaped concerns - * (defaults, async resolvers, slot binding, prop reshaping) live. + * Design-system components live in `../components/` and stay free of any + * `@contentful/*` imports. A component that needs the Experience runtime + * context or the raw Contentful payload calls `useExperience()` / + * `useContentfulComponent()` at its top. * - * - `components` — `componentTypeId` → `defineComponent(...)`. Keys match - * the segment after the last slash in `componentType.sys.urn`. - * - `templates` — `templateId` → `defineTemplate(...)`. Keys match the - * segment after the last slash in `payload.sys.template.sys.urn`. - * - `experienceConfig` — the composed `Config` object handed to - * `resolveExperience` and ``. + * - `components` — `componentTypeId` → bare component OR config object. + * Keys match the segment after the last slash in `componentType.sys.urn`. + * - `templates` — `templateId` → bare component OR config object. Keys + * match the segment after the last slash in `payload.sys.template.sys.urn`. */ -import type { ReactNode } from 'react'; +import { type Components, type Config, type Templates } from '@contentful/experiences-react'; -import { - defineComponent, - defineTemplate, - type Components, - type Config, - type Templates, -} from '@contentful/experiences-react'; - -import { Button, type ButtonProps } from '@/components/Button'; -import { Header, type HeaderProps } from '@/components/Header'; -import { Page, type PageProps } from '@/components/Page'; -import { Text, type TextProps } from '@/components/Text'; +import { Button } from '@/components/Button'; +import { Header } from '@/components/Header'; +import { Page } from '@/components/Page'; +import { Text } from '@/components/Text'; const components: Components = { - button: defineComponent({ - defaults: { type: 'primary' }, - resolveData: async ({ content, design, experience }) => { - // server side rendering for the contentful design system - // console.log("design", design); - - // add expensive thing here and an async call - return { - text: content.text as string, - children: content.testSlot as ReactNode, - design: design, - experience: experience, - }; - }, - render: (props) => { - // client side rendering for the contentful design system - return