diff --git a/.nvmrc b/.nvmrc index 3fe3b157..5bf4400f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.13.0 +24.15.0 diff --git a/implementations/web-sdk_angular/.env.example b/implementations/web-sdk_angular/.env.example new file mode 100644 index 00000000..b5612b79 --- /dev/null +++ b/implementations/web-sdk_angular/.env.example @@ -0,0 +1,15 @@ +DOTENV_CONFIG_QUIET=true + +PUBLIC_NINETAILED_CLIENT_ID="mock-client-id" +PUBLIC_NINETAILED_ENVIRONMENT="main" + +PUBLIC_EXPERIENCE_API_BASE_URL="http://localhost:8000/experience/" +PUBLIC_INSIGHTS_API_BASE_URL="http://localhost:8000/insights/" + +PUBLIC_CONTENTFUL_TOKEN="mock-token" +PUBLIC_CONTENTFUL_ENVIRONMENT="master" +PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" + +PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" +PUBLIC_CONTENTFUL_BASE_PATH="contentful" +PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="true" diff --git a/implementations/web-sdk_angular/.gitignore b/implementations/web-sdk_angular/.gitignore new file mode 100644 index 00000000..de059b7b --- /dev/null +++ b/implementations/web-sdk_angular/.gitignore @@ -0,0 +1,23 @@ +# dependencies +node_modules/ + +# build output +dist/ + +# Angular cache +.angular/ + +# env files (copy from .env.example to set up locally) +.env +.env*.local + +# logs produced by launch-reference-app.sh +logs/ + +# debug +*.log +.pnpm-debug.log* + +# misc +.DS_Store +*.tsbuildinfo diff --git a/implementations/web-sdk_angular/AGENTS.md b/implementations/web-sdk_angular/AGENTS.md new file mode 100644 index 00000000..1caec55f --- /dev/null +++ b/implementations/web-sdk_angular/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +Angular SPA reference implementation of `@contentful/optimization-web`. Serves the app on +`http://localhost:4200` via the Angular CLI dev server alongside the shared mock API server. + +## Rules + +- Do not add local adapter shims; import SDK behaviour directly from the published package surface + when it exists. +- This implementation uses Angular CLI (`@angular/build`) and PM2-managed mock server processes. +- Use standalone components (no NgModule). +- Use modern Angular patterns: signals and `computed()` for state, `inject()` for dependency + injection (not constructor injection), `input()` / `output()` for component I/O, `toSignal()` to + bridge RxJS observables to templates, and `@if` / `@for` control flow syntax. +- Name files and classes after the concept only — no Angular-role suffixes anywhere. File: `home.ts` + not `home.component.ts`. Class: `Home` not `HomeComponent`, `Optimization` not + `OptimizationService`, `MergeTag` not `MergeTagPipe`, etc. +- Avoid unsafe type assertions (`as SomeType`). Use `isRecord()` and typed type-guard functions + instead. The `no-unsafe-type-assertion` ESLint rule is enforced and blocks commits. +- Use explicit named types instead of `ReturnType` inference. Prefer the actual type + (e.g. `Signal`, `ContentfulClientApi`) over derived magic types. +- Follow the Angular style guide class member ordering: inputs → injected dependencies → private + state → constructor (effects/setup) → protected state (template-facing computed/signals) → + lifecycle hooks → public methods → private methods. + +## Commands + +```sh +pnpm implementation:run -- web-sdk_angular implementation:install +pnpm implementation:run -- web-sdk_angular serve:mocks +pnpm implementation:run -- web-sdk_angular dev +pnpm implementation:run -- web-sdk_angular build +pnpm implementation:run -- web-sdk_angular typecheck +``` + +## Validate + +- Run `typecheck` for TypeScript changes. +- Run `build` for production bundling changes. +- Run lint via `npx eslint ` from the implementation directory for source file changes. +- The pre-commit hook runs lint and Prettier automatically — fix any errors before committing. diff --git a/implementations/web-sdk_angular/README.md b/implementations/web-sdk_angular/README.md new file mode 100644 index 00000000..bedf4e02 --- /dev/null +++ b/implementations/web-sdk_angular/README.md @@ -0,0 +1,89 @@ +

+ + Contentful Logo + +

+ +

Contentful Personalization & Analytics

+ +

Web SDK + Angular Reference Implementation

+ +
+ +[Guides](https://contentful.github.io/optimization/documents/Documentation.Guides.html) · +[Reference](https://contentful.github.io/optimization) · [Contributing](../../CONTRIBUTING.md) + +
+ +> [!WARNING] +> +> The Optimization SDK Suite is pre-release (alpha). Breaking changes can be published at any time. + +Reference implementation of `@contentful/optimization-web` for Angular applications. Demonstrates +all SDK integration patterns — entry resolution, auto and manual tracking, consent, identify/reset, +live updates, nested entries, rich text with merge tags, feature flags, analytics event display, and +the preview panel. + +## What this demonstrates + +- SDK initialisation as a singleton Angular service +- Page tracking on every route change via the Angular router +- Entry resolution with variant/baseline display +- Auto-tracking via `data-ctfl-*` DOM attributes +- Manual tracking via `sdk.tracking.enableElement` +- Click scenarios: direct, descendant, ancestor +- Consent gating +- Identify and reset with session persistence +- Live updates: global toggle and per-entry override +- Preview panel forced-live mode +- Nested entries with recursive resolution +- Rich text rendering with inline merge tags +- Feature flag subscription with auto-emitted view events +- Analytics event display with heartbeat deduplication +- Multi-route navigation with conversion tracking + +## Prerequisites + +- Node.js ^24.15.0 (to match `.nvmrc`) +- pnpm 10.x + +## Quick start + +From the **repository root**: + +```sh +pnpm build:pkgs +pnpm implementation:run -- web-sdk_angular implementation:install +pnpm implementation:run -- web-sdk_angular serve:mocks +pnpm implementation:run -- web-sdk_angular dev +``` + +The app is available at `http://localhost:4200`. The mock server must be running for entry data and +variant resolution to work. + +Other commands from the **repository root**: + +```sh +pnpm implementation:run -- web-sdk_angular build +pnpm implementation:run -- web-sdk_angular typecheck +``` + +Or equivalently from the **implementation directory**: + +```sh +pnpm dev +pnpm build +pnpm typecheck +``` + +## Environment variables + +Copy `.env.example` to `.env`: + +```sh +cp .env.example .env +``` + +## Related + +- [@contentful/optimization-web](../../packages/web/web-sdk/README.md) — Web SDK diff --git a/implementations/web-sdk_angular/angular.json b/implementations/web-sdk_angular/angular.json new file mode 100644 index 00000000..92e1c3d0 --- /dev/null +++ b/implementations/web-sdk_angular/angular.json @@ -0,0 +1,62 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "web-sdk_angular": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/web-sdk_angular", + "index": "src/index.html", + "browser": "src/main.ts", + "tsConfig": "tsconfig.json", + "assets": [], + "styles": ["src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "web-sdk_angular:build:production" + }, + "development": { + "buildTarget": "web-sdk_angular:build:development" + } + }, + "defaultConfiguration": "development", + "options": { + "port": 4200 + } + } + } + } + }, + "cli": { + "analytics": false, + "packageManager": "pnpm" + } +} diff --git a/implementations/web-sdk_angular/package.json b/implementations/web-sdk_angular/package.json new file mode 100644 index 00000000..c41f6c0a --- /dev/null +++ b/implementations/web-sdk_angular/package.json @@ -0,0 +1,38 @@ +{ + "name": "@implementation/web-sdk_angular", + "private": true, + "version": "0.0.0", + "description": "Reference implementation of @contentful/optimization-web for Angular applications.", + "license": "MIT", + "scripts": { + "dev": "ng serve", + "build": "ng build", + "clean": "rimraf ./dist", + "serve:mocks": "pm2 start --name web-sdk_angular-mocks \"pnpm --dir ../../lib/mocks serve\"", + "serve:mocks:stop": "pm2 stop web-sdk_angular-mocks && pm2 delete web-sdk_angular-mocks", + "test:unit": "echo \"No unit tests necessary\"", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@angular/common": "^22.0.0", + "@angular/compiler": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/platform-browser": "^22.0.0", + "@angular/router": "^22.0.0", + "@contentful/optimization-web": "*", + "@contentful/optimization-web-preview-panel": "*", + "@contentful/rich-text-types": "^17.2.7", + "contentful": "^11.12.4", + "rxjs": "~7.8.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@angular/build": "^22.0.0", + "@angular/cli": "^22.0.0", + "@angular/compiler-cli": "^22.0.0", + "@types/node": "^24.0.13", + "pm2": "^6.0.14", + "rimraf": "^6.1.3", + "typescript": "~6.0.3" + } +} diff --git a/implementations/web-sdk_angular/pnpm-workspace.yaml b/implementations/web-sdk_angular/pnpm-workspace.yaml new file mode 100644 index 00000000..691b6b5e --- /dev/null +++ b/implementations/web-sdk_angular/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +sharedWorkspaceLockfile: false + +overrides: + '@contentful/optimization-api-client': 'file:../../pkgs/contentful-optimization-api-client-0.0.0.tgz' + '@contentful/optimization-api-schemas': 'file:../../pkgs/contentful-optimization-api-schemas-0.0.0.tgz' + '@contentful/optimization-core': 'file:../../pkgs/contentful-optimization-core-0.0.0.tgz' + '@contentful/optimization-web': 'file:../../pkgs/contentful-optimization-web-0.0.0.tgz' + '@contentful/optimization-web-preview-panel': 'file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz' diff --git a/implementations/web-sdk_angular/src/app/app.config.ts b/implementations/web-sdk_angular/src/app/app.config.ts new file mode 100644 index 00000000..416fcbba --- /dev/null +++ b/implementations/web-sdk_angular/src/app/app.config.ts @@ -0,0 +1,38 @@ +import { + type ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideZonelessChangeDetection, +} from '@angular/core' +import { provideRouter } from '@angular/router' +import { routes } from './app.routes' +import { provideContentfulOptimizationConfig, resolveLogLevel } from './config' + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZonelessChangeDetection(), + provideRouter(routes), + provideContentfulOptimizationConfig({ + clientId: import.meta.env.PUBLIC_NINETAILED_CLIENT_ID ?? 'mock-client-id', + environment: import.meta.env.PUBLIC_NINETAILED_ENVIRONMENT ?? 'main', + insightsBaseUrl: + import.meta.env.PUBLIC_INSIGHTS_API_BASE_URL ?? 'http://localhost:8000/insights/', + experienceBaseUrl: + import.meta.env.PUBLIC_EXPERIENCE_API_BASE_URL ?? 'http://localhost:8000/experience/', + logLevel: resolveLogLevel(import.meta.env.PUBLIC_OPTIMIZATION_LOG_LEVEL), + locale: 'en-US', + app: { name: 'ContentfulOptimization SDK - Angular Web Reference', version: '0.0.0' }, + autoTrackEntryInteraction: { views: true, clicks: true, hovers: true }, + contentful: { + accessToken: import.meta.env.PUBLIC_CONTENTFUL_TOKEN ?? 'mock-token', + environment: import.meta.env.PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', + spaceId: import.meta.env.PUBLIC_CONTENTFUL_SPACE_ID ?? 'mock-space-id', + cdaHost: import.meta.env.PUBLIC_CONTENTFUL_CDA_HOST ?? 'localhost:8000', + basePath: import.meta.env.PUBLIC_CONTENTFUL_BASE_PATH ?? 'contentful', + }, + ...(import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL !== 'false' + ? { previewPanel: {} } + : {}), + }), + ], +} diff --git a/implementations/web-sdk_angular/src/app/app.html b/implementations/web-sdk_angular/src/app/app.html new file mode 100644 index 00000000..bb589212 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/app.html @@ -0,0 +1,18 @@ + +
+ +
+ +
+
diff --git a/implementations/web-sdk_angular/src/app/app.routes.ts b/implementations/web-sdk_angular/src/app/app.routes.ts new file mode 100644 index 00000000..00e8dc96 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/app.routes.ts @@ -0,0 +1,9 @@ +import type { Routes } from '@angular/router' +import { Home } from './pages/home' +import { PageTwo } from './pages/page-two' + +export const routes: Routes = [ + { path: '', component: Home }, + { path: 'page-two', component: PageTwo }, + { path: '**', redirectTo: '' }, +] diff --git a/implementations/web-sdk_angular/src/app/app.ts b/implementations/web-sdk_angular/src/app/app.ts new file mode 100644 index 00000000..b5801f89 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/app.ts @@ -0,0 +1,16 @@ +import { Component, inject } from '@angular/core' +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router' +import { TrackingLog } from './components/tracking-log' +import { NgContentfulOptimization } from './services/optimization' + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink, RouterLinkActive, TrackingLog], + templateUrl: './app.html', +}) +export class App { + constructor() { + // forces singleton creation on startup to wire up page tracking before first route + inject(NgContentfulOptimization) + } +} diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.html b/implementations/web-sdk_angular/src/app/components/control-panel/index.html new file mode 100644 index 00000000..48e887ce --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.html @@ -0,0 +1,108 @@ +
+

SDK state

+ +
+ Consent + {{ consent() ?? 'undefined' }} + @if (consent() === true) { + + } @else { + + } + + Identified + {{ isIdentified() ? 'Yes' : 'No' }} + @if (isIdentified()) { + + } @else { + + } + + Live updates + {{ liveUpdatesService.globalLiveUpdates() ? 'ON' : 'OFF' }} + + + Preview panel + {{ liveUpdatesService.previewPanelVisible() ? 'Open' : 'Closed' }} + + + Flag "boolean" + {{ booleanFlag() ?? 'undefined' }} + + + Active optimizations + {{ optimizationCount() }} + +
+ + @if (onTrackConversion()) { +
+ +
+ } +
diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.scss b/implementations/web-sdk_angular/src/app/components/control-panel/index.scss new file mode 100644 index 00000000..320167cc --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.scss @@ -0,0 +1,95 @@ +.control-panel { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + box-shadow: var(--shadow); +} + +.control-panel__title { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: 0.5rem; +} + +.control-panel__table { + display: grid; + grid-template-columns: 9rem 5rem auto; + gap: 0.4rem 1.5rem; + align-items: center; + justify-items: start; + width: fit-content; +} + +.control-panel__actions { + margin-top: 1.25rem; + width: fit-content; +} + +.control-panel__row-label { + font-size: 0.825rem; + color: var(--color-text-muted); + white-space: nowrap; +} + +.control-panel__row-value { + font-size: 0.825rem; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.875rem; + font-size: 0.8125rem; + font-weight: 500; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + transform 0.1s; +} + +.btn:active { + transform: scale(0.95); +} + +.btn--sm { + padding: 0.1rem 0.45rem; + font-size: 0.7rem; +} + +.btn--secondary { + background: var(--color-surface); + color: var(--color-text); + border-color: var(--color-border); +} + +.btn--secondary:hover { + background: var(--color-bg); +} + +.btn--danger { + background: #dc2626; + color: #fff; + border-color: #dc2626; +} + +.btn--danger:hover { + background: #b91c1c; + border-color: #b91c1c; +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts new file mode 100644 index 00000000..e10cb831 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -0,0 +1,44 @@ +import { Component, computed, inject, input } from '@angular/core' +import { NgLiveUpdates } from '../../services/live-updates' +import { NgContentfulOptimization } from '../../services/optimization' +import { fromSdkState } from '../../utils' + +@Component({ + selector: 'app-control-panel', + templateUrl: './index.html', + styleUrl: './index.scss', +}) +export class ControlPanel { + readonly onTrackConversion = input<(() => void) | undefined>(undefined) + + private readonly optimization = inject(NgContentfulOptimization) + protected readonly liveUpdatesService = inject(NgLiveUpdates) + + protected readonly consent = this.optimization.consent + protected readonly isIdentified = computed(() => + Boolean(this.optimization.profile()?.traits.identified), + ) + protected readonly optimizationCount = computed( + () => this.optimization.selectedOptimizations()?.length ?? 0, + ) + protected readonly booleanFlag = fromSdkState( + this.optimization.sdk.states.flag('boolean'), + ) + + protected toggleConsent(): void { + this.optimization.sdk.consent(this.consent() !== true) + } + + protected identify(): void { + void this.optimization.sdk.identify({ userId: 'charles', traits: { identified: true } }) + } + + protected reset(): void { + this.optimization.sdk.reset() + void this.optimization.sdk.page() + } + + protected trackConversion(): void { + this.onTrackConversion()?.() + } +} diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.html b/implementations/web-sdk_angular/src/app/components/entry-card/index.html new file mode 100644 index 00000000..8f3a2fe7 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.html @@ -0,0 +1,73 @@ +@let scenario = clickScenario(); + +
+ @if (scenario === 'ancestor' && !manualTracking()) { +
+ +
+ } @else { + + } +
+ + + @if (resolved(); as r) { +
+
+
+

+ base{{ r.baselineId }} +

+

+ var{{ isVariant() ? r.entryId : '—' }} +

+

+ exp{{ r.optimizationId ?? '—' }} +

+
+
+ @for (badge of badges(); track badge.key) { + + } +
+
+ +
+ @if (richTextHtml(); as html) { +
+ } @else { +

{{ entryText() }}

+ } +
+ + @if (scenario === 'descendant') { + + } @if (nestedEntries().length > 0) { +
+ @for (child of nestedEntries(); track child.sys.id) { + + } +
+ } +
+ } +
diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.scss b/implementations/web-sdk_angular/src/app/components/entry-card/index.scss new file mode 100644 index 00000000..76596d72 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.scss @@ -0,0 +1,153 @@ +.entry-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left: 4px solid #8c2eea; + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-size: 0.9rem; + box-shadow: var(--shadow); + transition: + border-color 0.2s, + background 0.2s; +} + +.entry-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.entry-card__badges { + display: flex; + align-items: flex-start; + gap: 0.2rem; + flex-wrap: nowrap; + flex-shrink: 0; + justify-content: flex-end; +} + +.entry-card__badge { + font-size: 0.5rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 0.05rem 0.25rem; + border-radius: 999px; + background: var(--color-border); + color: var(--color-text-muted); +} + +.entry-card__badge--variant { + background: #dcfce7; + color: #15803d; +} + +.entry-card__badge--mergetag { + background: #dcfce7; + color: #15803d; +} + +.entry-card__badge--mergetag-fallback { + background: #f1f5f9; + color: #475569; +} + +.entry-card__badge--direct, +.entry-card__badge--ancestor, +.entry-card__badge--descendant { + background: #ffedd5; + color: #c2410c; +} + +.entry-card__badge--live-on { + background: #dcfce7; + color: #15803d; +} + +.entry-card__badge--live-off { + background: #f1f5f9; + color: #475569; +} + +.entry-card__badge--live-always-on { + background: #f3e8ff; + color: #7e22ce; +} + +.entry-card__badge--live-always-off { + background: #fee2e2; + color: #b91c1c; +} + +.nested-children { + display: grid; + gap: 0.5rem; + margin-top: 0.5rem; + padding-left: 0.75rem; +} + +.entry-card__ids { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.05rem 0.4rem; + min-width: 0; +} + +.entry-card__id { + display: contents; +} + +.entry-card__id-label { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + opacity: 0.6; + white-space: nowrap; + align-self: baseline; +} + +.entry-card__id-value { + font-size: 0.75rem; + color: var(--color-text-muted); + font-family: monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.rich-text p { + margin-bottom: 0.4rem; +} + +.rich-text h1, +.rich-text h2, +.rich-text h3, +.rich-text h4, +.rich-text h5, +.rich-text h6 { + margin-bottom: 0.3rem; + color: var(--color-text); +} + +.rich-text ul, +.rich-text ol { + padding-left: 1.25rem; + margin-bottom: 0.4rem; +} + +.rich-text blockquote { + border-left: 3px solid var(--color-border); + padding-left: 0.75rem; + color: var(--color-text-muted); + margin-bottom: 0.4rem; +} + +.rich-text a { + color: var(--color-primary); + text-decoration: underline; +} diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts new file mode 100644 index 00000000..2ff8322b --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts @@ -0,0 +1,161 @@ +import { NgTemplateOutlet } from '@angular/common' +import { Component, computed, forwardRef, inject, input } from '@angular/core' +import { DomSanitizer, type SafeHtml } from '@angular/platform-browser' +import { BLOCKS, INLINES, type Document } from '@contentful/rich-text-types' +import { + BADGE_MAP, + type BadgeKey, + type EntryClickScenario, + type LiveMode, + type MergeTagMode, +} from '../../fixtures' +import type { ContentfulEntry } from '../../services/contentful-client' +import { injectContentfulEntry } from '../../services/entry' +import { NgLiveUpdates } from '../../services/live-updates' +import { isRecord } from '../../utils' + +// — Badge — + +function liveModeKey(override: boolean | undefined, isLive: boolean): LiveMode { + if (override === true) return 'live-always-on' + if (override === false) return 'live-always-off' + return isLive ? 'live-on' : 'live-off' +} + +function mergeTagKey(resolved: boolean | undefined): MergeTagMode | undefined { + if (resolved === true) return 'mergetag' + if (resolved === false) return 'mergetag-fallback' + return undefined +} + +@Component({ + selector: 'app-entry-card-badge', + template: `{{ label() }}`, + styleUrl: './index.scss', +}) +export class Badge { + readonly label = input.required() + readonly key = input.required() + readonly title = input('') +} + +// — Rich text renderer — + +function isRichTextField(field: unknown): field is Document { + return isRecord(field) && field.nodeType === 'document' && Array.isArray(field.content) +} + +function escape(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +type NodeRenderer = (children: () => string, data: Record) => string + +const RENDERERS: Partial> = { + [BLOCKS.PARAGRAPH]: (children) => `

${children()}

`, + [BLOCKS.HEADING_1]: (children) => `

${children()}

`, + [BLOCKS.HEADING_2]: (children) => `

${children()}

`, + [BLOCKS.HEADING_3]: (children) => `

${children()}

`, + [BLOCKS.HEADING_4]: (children) => `

${children()}

`, + [BLOCKS.HEADING_5]: (children) => `
${children()}
`, + [BLOCKS.HEADING_6]: (children) => `
${children()}
`, + [BLOCKS.UL_LIST]: (children) => `
    ${children()}
`, + [BLOCKS.OL_LIST]: (children) => `
    ${children()}
`, + [BLOCKS.LIST_ITEM]: (children) => `
  • ${children()}
  • `, + [BLOCKS.QUOTE]: (children) => `
    ${children()}
    `, + [BLOCKS.HR]: () => `
    `, + [BLOCKS.EMBEDDED_ENTRY]: () => '', + [BLOCKS.EMBEDDED_ASSET]: () => '', + [INLINES.HYPERLINK]: (children, data) => { + const uri = typeof data.uri === 'string' ? escape(data.uri) : '#' + return `${children()}` + }, +} + +function renderNode(node: unknown): string { + if (!isRecord(node)) return '' + const nodeType = typeof node.nodeType === 'string' ? node.nodeType : '' + const value = typeof node.value === 'string' ? node.value : '' + const content = Array.isArray(node.content) ? node.content : [] + const data = isRecord(node.data) ? node.data : {} + const children = (): string => content.map((c) => renderNode(c)).join('') + + if (nodeType === 'text') return escape(value) + const { [nodeType]: renderer } = RENDERERS + return renderer !== undefined ? renderer(children, data) : children() +} + +// — Entry card — + +function isContentfulEntry(value: unknown): value is ContentfulEntry { + return ( + isRecord(value) && + isRecord(value.sys) && + typeof value.sys.id === 'string' && + isRecord(value.fields) + ) +} + +@Component({ + selector: 'app-entry-card', + imports: [NgTemplateOutlet, Badge, forwardRef(() => EntryCard)], + templateUrl: './index.html', + styleUrl: './index.scss', +}) +export class EntryCard { + readonly entry = input.required() + readonly manualTracking = input(false) + readonly clickScenario = input(undefined) + readonly liveUpdates = input(undefined) + + private readonly sanitizer = inject(DomSanitizer) + private readonly liveUpdatesService = inject(NgLiveUpdates) + + private readonly isLive = computed(() => { + if (this.liveUpdatesService.previewPanelVisible()) return true + return this.liveUpdates() ?? this.liveUpdatesService.globalLiveUpdates() + }) + + protected readonly resolved = injectContentfulEntry({ + entry: this.entry, + isLive: this.isLive, + manualTracking: this.manualTracking, + }) + + protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined) + protected readonly richTextHtml = computed(() => { + const { entry } = this.resolved() + const doc = Object.values(entry.fields).find(isRichTextField) + if (!doc) return undefined + return this.sanitizer.bypassSecurityTrustHtml(renderNode(doc)) + }) + protected readonly entryText = computed(() => { + const text: unknown = this.resolved().entry.fields.text + return typeof text === 'string' ? text : 'No content' + }) + protected readonly nestedEntries = computed(() => { + const nested: unknown = this.resolved().entry.fields.nested + return Array.isArray(nested) ? nested.filter(isContentfulEntry) : [] + }) + protected readonly badges = computed(() => { + const r = this.resolved() + const mergeTag = mergeTagKey(r.mergeTagResolved) + const scenario = this.clickScenario() + const keys: BadgeKey[] = [ + ...(mergeTag ? [mergeTag] : []), + ...(scenario ? [scenario] : []), + this.isVariant() ? 'variant' : 'baseline', + this.manualTracking() ? 'manual' : 'auto', + liveModeKey(this.liveUpdates(), this.isLive()), + ] + return keys.map((k) => ({ key: k, ...BADGE_MAP[k] })) + }) +} diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.html b/implementations/web-sdk_angular/src/app/components/tracking-log/index.html new file mode 100644 index 00000000..ead12748 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.html @@ -0,0 +1,21 @@ +
    +

    Tracking

    + @let events = displayEvents(); @if (events.length === 0) { +

    No events tracked yet

    + } @else { + + @for (event of events; track event.key) { + + + + + + + } +
    {{ event.timeAgo }} + {{ event.type }} + {{ event.value }}@if (event.count > 1) { ×{{ event.count }} }
    + } +
    diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.scss b/implementations/web-sdk_angular/src/app/components/tracking-log/index.scss new file mode 100644 index 00000000..1e9c45fc --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.scss @@ -0,0 +1,95 @@ +.tracking-log__title { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: 0.5rem; +} + +.tracking-log { + padding: 1rem; + display: grid; + gap: 0.5rem; +} + +.tracking-log__empty { + font-size: 0.8rem; + color: var(--color-text-muted); + margin: 0; +} + +.tracking-log__table { + width: 100%; + border-collapse: collapse; + font-size: 0.775rem; +} + +.tracking-log__table tr { + vertical-align: baseline; +} + +.tracking-log__table td { + padding: 0.15rem 0.4rem 0.15rem 0; +} + +.tracking-log__type { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.35rem; + border-radius: 4px; + white-space: nowrap; + background: var(--color-border); + color: var(--color-text-muted); +} + +.tracking-log__type--page { + background: #dbeafe; + color: #1d4ed8; +} + +.tracking-log__type--view { + background: #dcfce7; + color: #15803d; +} + +.tracking-log__type--comp { + background: #d1fae5; + color: #065f46; +} + +.tracking-log__type--click { + background: #fef9c3; + color: #854d0e; +} + +.tracking-log__type--hover { + background: #f3e8ff; + color: #7e22ce; +} + +.tracking-log__label { + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.tracking-log__count { + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-muted); + white-space: nowrap; + opacity: 0.6; +} + +.tracking-log__time { + font-size: 0.7rem; + color: var(--color-text-muted); + white-space: nowrap; + opacity: 0.5; + padding-right: 0.5rem; +} diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts new file mode 100644 index 00000000..b663d16c --- /dev/null +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -0,0 +1,98 @@ +import { Component, DestroyRef, computed, inject, signal } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { interval } from 'rxjs' +import { NgContentfulOptimization } from '../../services/optimization' + +interface AnalyticsEvent { + type: string + value: string + testId: string + key: string + count: number + firedAt: number +} + +const MS_PER_SECOND = 1000 +const SECONDS_PER_MINUTE = 60 +const MINUTES_PER_HOUR = 60 +const TICK_INTERVAL_SECONDS = 5 + +function timeAgo(firedAt: number, now: number): string { + const s = Math.floor((now - firedAt) / MS_PER_SECOND) + if (s < SECONDS_PER_MINUTE) return `${s}s ago` + const m = Math.floor(s / SECONDS_PER_MINUTE) + if (m < MINUTES_PER_HOUR) return `${m}m ago` + return `${Math.floor(m / MINUTES_PER_HOUR)}h ago` +} + +@Component({ + selector: 'app-tracking-log', + templateUrl: './index.html', + styleUrl: './index.scss', +}) +export class TrackingLog { + private readonly optimization = inject(NgContentfulOptimization) + + private readonly events = signal>(new Map()) + private readonly tick = toSignal(interval(TICK_INTERVAL_SECONDS * MS_PER_SECOND), { + initialValue: 0, + }) + protected readonly displayEvents = computed(() => { + this.tick() + const now = Date.now() + return [...this.events().values()] + .sort((a, b) => b.firedAt - a.firedAt) + .map((e) => ({ ...e, timeAgo: timeAgo(e.firedAt, now) })) + }) + + constructor() { + const sub = this.optimization.sdk.states.eventStream.subscribe((raw) => { + switch (raw?.type) { + case 'page': { + const { + properties: { url }, + } = raw + this.track({ type: 'page', value: url, key: `page-${url}` }) + break + } + case 'component': { + const { componentId, viewId } = raw + this.track({ + type: viewId ? 'view' : 'comp', + value: componentId, + key: `component-${componentId}`, + }) + break + } + case 'component_hover': { + const { componentId } = raw + this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) + break + } + case 'component_click': { + const { componentId } = raw + this.track({ type: 'click', value: componentId, key: `component_click-${componentId}` }) + break + } + default: + break + } + }) + inject(DestroyRef).onDestroy(() => { + sub.unsubscribe() + }) + } + + private track(event: Omit): void { + const { key } = event + this.events.update((map) => { + const existing = map.get(key) + return new Map(map).set(key, { + ...event, + testId: `event-${key}`, + count: (existing?.count ?? 0) + 1, + firedAt: Date.now(), + }) + }) + } +} diff --git a/implementations/web-sdk_angular/src/app/config.ts b/implementations/web-sdk_angular/src/app/config.ts new file mode 100644 index 00000000..12fa2a95 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/config.ts @@ -0,0 +1,56 @@ +import { InjectionToken, type ValueProvider } from '@angular/core' +import { type ContentfulClientApi, createClient } from 'contentful' + +export interface NgContentfulOptimizationConfig { + clientId: string + environment: string + insightsBaseUrl: string + experienceBaseUrl: string + logLevel?: 'debug' | 'warn' | 'error' + locale: string + app: { name: string; version: string } + autoTrackEntryInteraction?: { views?: boolean; clicks?: boolean; hovers?: boolean } + contentful: { + accessToken: string + environment: string + spaceId: string + cdaHost: string + basePath: string + } + previewPanel?: { + nonce?: string + } +} + +export function resolveLogLevel(raw: string | undefined): 'debug' | 'warn' | 'error' { + if (raw === 'debug' || raw === 'warn' || raw === 'error') return raw + return 'debug' +} + +export const NG_CONTENTFUL_OPTIMIZATION_CONFIG = new InjectionToken( + 'NG_CONTENTFUL_OPTIMIZATION_CONFIG', +) + +export function provideContentfulOptimizationConfig( + config: NgContentfulOptimizationConfig, +): ValueProvider { + return { provide: NG_CONTENTFUL_OPTIMIZATION_CONFIG, useValue: config } +} + +// Shared base client singleton — one CDA connection for both NgContentfulClient and the preview +// panel attachment. The Contentful client is stateless so it is never torn down. +let baseClient: ContentfulClientApi | undefined = undefined + +export function getOrCreateBaseClient( + config: NgContentfulOptimizationConfig, +): ContentfulClientApi { + baseClient ??= createClient({ + accessToken: config.contentful.accessToken, + environment: config.contentful.environment, + space: config.contentful.spaceId, + host: config.contentful.cdaHost, + insecure: config.contentful.cdaHost.includes('localhost'), + basePath: config.contentful.basePath, + }) + return baseClient +} diff --git a/implementations/web-sdk_angular/src/app/fixtures.ts b/implementations/web-sdk_angular/src/app/fixtures.ts new file mode 100644 index 00000000..205bec59 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/fixtures.ts @@ -0,0 +1,107 @@ +export type EntryClickScenario = 'direct' | 'descendant' | 'ancestor' +export type LiveMode = 'live-on' | 'live-off' | 'live-always-on' | 'live-always-off' +export type MergeTagMode = 'mergetag' | 'mergetag-fallback' + +export type BadgeKey = + | 'variant' + | 'baseline' + | 'auto' + | 'manual' + | LiveMode + | MergeTagMode + | EntryClickScenario + +export const BADGE_MAP: Record = { + variant: { + label: 'variant', + title: 'This entry is a variant selected by the optimization SDK', + }, + baseline: { + label: 'baseline', + title: 'This entry is the baseline (no optimization applied)', + }, + auto: { + label: 'tracking ↺', + title: + 'View, click, and hover events fire automatically via data-ctfl-* attributes once consent is granted — content resolution is unaffected', + }, + manual: { + label: 'tracking ⚙', + title: + 'View events fire via explicit enableElement calls; no click or hover events — content resolution is unaffected', + }, + 'live-on': { + label: 'live ✓', + title: 'Following global toggle — currently live, re-resolves on profile change', + }, + 'live-off': { + label: 'live ✗', + title: 'Following global toggle — currently frozen, will update when toggle is ON', + }, + 'live-always-on': { + label: '📌 live ✓', + title: 'Per-entry override: always re-resolves on profile change', + }, + 'live-always-off': { + label: '📌 live ✗', + title: 'Per-entry override: ignores the global toggle, does not update on profile change', + }, + mergetag: { + label: 'merge tag ✓', + title: 'Rich text merge tags resolved with visitor profile', + }, + 'mergetag-fallback': { + label: 'merge tag ✗', + title: 'Rich text merge tags showing fallback — no visitor profile', + }, + direct: { + label: 'direct', + title: 'Click tracking fires directly on this entry element', + }, + ancestor: { + label: 'ancestor', + title: 'Click tracking fires on an ancestor wrapper element', + }, + descendant: { + label: 'descendant', + title: 'Click tracking fires from a descendant button inside this entry', + }, +} + +const clickScenarios: Record = { + '4ib0hsHWoSOnCVdDkizE8d': 'direct', + xFwgG3oNaOcjzWiGe4vXo: 'descendant', + '2Z2WLOx07InSewC3LUB3eX': 'ancestor', +} + +const pageTwoAuto = '2Z2WLOx07InSewC3LUB3eX' as const +const pageTwoManual = '5XHssysWUDECHzKLzoIsg1' as const + +const homeAuto = [ + '1JAU028vQ7v6nB2swl3NBo', + '1MwiFl4z7gkwqGYdvCmr8c', + '4ib0hsHWoSOnCVdDkizE8d', + 'xFwgG3oNaOcjzWiGe4vXo', + '2Z2WLOx07InSewC3LUB3eX', +] as const + +const homeManual = [ + '5XHssysWUDECHzKLzoIsg1', + '6zqoWXyiSrf0ja7I2WGtYj', + '7pa5bOx8Z9NmNcr7mISvD', +] as const + +export const FIXTURES = { + home: { + ids: [...new Set([...homeAuto, ...homeManual])] as const, + auto: homeAuto, + manual: homeManual, + liveUpdates: '2Z2WLOx07InSewC3LUB3eX' as const, + clickScenarios, + }, + pageTwo: { + ids: [pageTwoAuto, pageTwoManual] as const, + auto: pageTwoAuto, + manual: pageTwoManual, + }, +} as const diff --git a/implementations/web-sdk_angular/src/app/pages/home/index.html b/implementations/web-sdk_angular/src/app/pages/home/index.html new file mode 100644 index 00000000..23363f3e --- /dev/null +++ b/implementations/web-sdk_angular/src/app/pages/home/index.html @@ -0,0 +1,47 @@ +@if (entries.isLoading()) { +

    Loading entries…

    +} @else { + + + + +
    +
    +

    Live updates

    +
    + @let liveEntry = entryFor(liveUpdatesEntryId); @if (liveEntry) { +
    + + + +
    + } +
    + +
    +
    +
    +

    Auto-observed

    +
    +
    + @for (id of autoIds; track id) { @let entry = entryFor(id); @if (entry) { + + } } +
    +
    + +
    +
    +

    Manually-observed

    +
    +
    + @for (id of manualIds; track id) { @let entry = entryFor(id); @if (entry) { + + } } +
    +
    +
    +} diff --git a/implementations/web-sdk_angular/src/app/pages/home/index.ts b/implementations/web-sdk_angular/src/app/pages/home/index.ts new file mode 100644 index 00000000..6d18b765 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/pages/home/index.ts @@ -0,0 +1,29 @@ +import { Component, inject } from '@angular/core' +import { ControlPanel } from '../../components/control-panel' +import { EntryCard } from '../../components/entry-card' +import { FIXTURES } from '../../fixtures' +import type { ContentfulEntry } from '../../services/contentful-client' +import { NgContentfulClient } from '../../services/contentful-client' +import { NgLiveUpdates } from '../../services/live-updates' + +@Component({ + selector: 'app-home', + imports: [EntryCard, ControlPanel], + templateUrl: './index.html', + host: { style: 'display: contents' }, +}) +export class Home { + private readonly contentfulClient = inject(NgContentfulClient) + protected readonly liveUpdatesService = inject(NgLiveUpdates) + protected readonly autoIds = FIXTURES.home.auto + protected readonly manualIds = FIXTURES.home.manual + protected readonly liveUpdatesEntryId = FIXTURES.home.liveUpdates + + protected readonly entries = this.contentfulClient.loadEntries(FIXTURES.home.ids) + + protected entryFor(id: string): ContentfulEntry | undefined { + return this.entries.value()?.get(id) + } + + protected readonly clickScenarios = FIXTURES.home.clickScenarios +} diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.html b/implementations/web-sdk_angular/src/app/pages/page-two/index.html new file mode 100644 index 00000000..bfc61be6 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.html @@ -0,0 +1,32 @@ +@if (entries.isLoading()) { +

    Loading entries…

    +} @else { + + + + +
    + @if (autoEntry(); as entry) { +
    +
    +

    Auto-observed

    +
    +
    + +
    +
    + } @if (manualEntry(); as entry) { +
    +
    +

    Manually-observed

    +
    +
    + +
    +
    + } +
    +} diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts new file mode 100644 index 00000000..a2563ab3 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts @@ -0,0 +1,38 @@ +import { Component, inject } from '@angular/core' +import { ControlPanel } from '../../components/control-panel' +import { EntryCard } from '../../components/entry-card' +import { FIXTURES } from '../../fixtures' +import type { ContentfulEntry } from '../../services/contentful-client' +import { NgContentfulClient } from '../../services/contentful-client' +import { NgContentfulOptimization } from '../../services/optimization' + +const PAGE_TWO_COMPONENT_ID = 'page-two-conversion' + +@Component({ + selector: 'app-page-two', + imports: [EntryCard, ControlPanel], + templateUrl: './index.html', + host: { style: 'display: contents' }, +}) +export class PageTwo { + private readonly optimization = inject(NgContentfulOptimization) + private readonly contentfulClient = inject(NgContentfulClient) + + protected readonly entries = this.contentfulClient.loadEntries(FIXTURES.pageTwo.ids) + + protected autoEntry(): ContentfulEntry | undefined { + return this.entries.value()?.get(FIXTURES.pageTwo.auto) + } + + protected manualEntry(): ContentfulEntry | undefined { + return this.entries.value()?.get(FIXTURES.pageTwo.manual) + } + + protected readonly trackConversion = (): void => { + void this.optimization.sdk.trackView({ + componentId: PAGE_TWO_COMPONENT_ID, + viewId: crypto.randomUUID(), + viewDurationMs: 0, + }) + } +} diff --git a/implementations/web-sdk_angular/src/app/services/contentful-client.ts b/implementations/web-sdk_angular/src/app/services/contentful-client.ts new file mode 100644 index 00000000..b7c68cd6 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/services/contentful-client.ts @@ -0,0 +1,48 @@ +import { inject, Injectable, resource, type ResourceRef } from '@angular/core' +import type { ContentfulClientApi, Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' +import { getOrCreateBaseClient, NG_CONTENTFUL_OPTIMIZATION_CONFIG } from '../config' + +export interface ContentEntryFields { + text?: EntryFieldTypes.Text | EntryFieldTypes.RichText + nested?: EntryFieldTypes.Array> +} + +export type ContentEntrySkeleton = EntrySkeletonType +export type ContentfulEntry = Entry + +const INCLUDE_DEPTH = 10 + +@Injectable({ providedIn: 'root' }) +export class NgContentfulClient { + private readonly client: ContentfulClientApi + private readonly locale: string + + constructor() { + const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) + this.client = getOrCreateBaseClient(config) + ;({ locale: this.locale } = config) + } + + async fetchEntry(entryId: string): Promise | undefined> { + try { + return await this.client.getEntry(entryId, { include: INCLUDE_DEPTH, locale: this.locale }) + } catch { + return undefined + } + } + + async fetchEntries( + entryIds: readonly string[], + ): Promise>> { + const results = await Promise.all(entryIds.map(async (id) => await this.fetchEntry(id))) + return results.filter((e): e is Entry => e !== undefined) + } + + loadEntries = (ids: readonly string[]): ResourceRef | undefined> => + resource({ + loader: async (): Promise> => { + const list = await this.fetchEntries(ids) + return new Map(list.map((e) => [e.sys.id, e])) + }, + }) +} diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts new file mode 100644 index 00000000..8d68a617 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -0,0 +1,152 @@ +import { + afterNextRender, + computed, + DestroyRef, + effect, + ElementRef, + inject, + signal, + untracked, + type Signal, +} from '@angular/core' + +import { isMergeTagEntry, type MergeTagEntry } from '@contentful/optimization-web/api-schemas' +import type { Document, Text } from '@contentful/rich-text-types' +import { INLINES } from '@contentful/rich-text-types' +import type { Entry } from 'contentful' +import { isRecord } from '../utils' +import { NgContentfulOptimization } from './optimization' + +export type ObservationMode = 'auto' | 'manual' + +type MergeTagResolver = (target: MergeTagEntry) => string | undefined + +export interface ResolvedEntry { + entry: Entry + baselineId: string + entryId: string + optimizationId: string | undefined + sticky: boolean | undefined + variantIndex: number | undefined + mergeTagResolved: boolean | undefined +} + +function isRichTextDocument(value: unknown): value is Document { + return isRecord(value) && value.nodeType === 'document' +} + +function resolveNode(node: unknown, resolveMergeTag: MergeTagResolver): unknown { + if (!isRecord(node)) return node + const { data } = node + if (node.nodeType === INLINES.EMBEDDED_ENTRY && isRecord(data)) { + const { target } = data + if (isMergeTagEntry(target)) { + return { + nodeType: 'text', + value: resolveMergeTag(target) ?? '', + marks: [], + data: {}, + } satisfies Text + } + } + if (Array.isArray(node.content)) { + return { ...node, content: node.content.map((child) => resolveNode(child, resolveMergeTag)) } + } + return node +} + +function resolveEntryMergeTags(entry: Entry, resolveMergeTag: MergeTagResolver): Entry { + return Object.assign({}, entry, { + fields: Object.fromEntries( + Object.entries(entry.fields).map(([key, value]) => [ + key, + isRichTextDocument(value) ? resolveNode(value, resolveMergeTag) : value, + ]), + ), + }) as Entry +} + +function setupManualTracking(result: Signal, manualTracking: Signal): void { + const optimization = inject(NgContentfulOptimization) + const elementRef = inject>(ElementRef) + const destroyRef = inject(DestroyRef) + + const domReady = signal(false) + + afterNextRender(() => { + domReady.set(true) + }) + + function track(): void { + const { entryId, optimizationId, sticky, variantIndex } = result() + optimization.sdk.tracking.enableElement('views', elementRef.nativeElement, { + data: { entryId, optimizationId, sticky, variantIndex }, + }) + } + + function clear(): void { + optimization.sdk.tracking.clearElement('views', elementRef.nativeElement) + } + + effect(() => { + clear() + if (domReady() && manualTracking()) { + track() + } + }) + + destroyRef.onDestroy(clear) +} + +export function injectContentfulEntry({ + entry, + isLive = signal(false), + manualTracking = signal(false), +}: { + entry: Signal + isLive?: Signal + manualTracking?: Signal +}): Signal { + const optimization = inject(NgContentfulOptimization) + + function liveRead(sig: Signal): T { + return isLive() ? sig() : untracked(sig) + } + + const variant = computed(() => { + const raw = entry() + return { + raw, + resolved: optimization.sdk.resolveOptimizedEntry( + raw, + liveRead(optimization.selectedOptimizations), + ), + } + }) + + const result = computed(() => { + const { raw, resolved } = variant() + const profile = liveRead(optimization.profile) + let mergeTagResolved: boolean | undefined = undefined + const entry = resolveEntryMergeTags(resolved.entry, (target) => { + const value = profile ? optimization.sdk.getMergeTagValue(target, profile) : undefined + if (value !== undefined) mergeTagResolved = true + else mergeTagResolved ??= false + return value ?? target.fields.nt_fallback + }) + + return { + entry, + baselineId: raw.sys.id, + entryId: resolved.entry.sys.id, + optimizationId: resolved.selectedOptimization?.experienceId, + sticky: resolved.selectedOptimization?.sticky, + variantIndex: resolved.selectedOptimization?.variantIndex, + mergeTagResolved, + } + }) + + setupManualTracking(result, manualTracking) + + return result +} diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts new file mode 100644 index 00000000..55a9aad1 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts @@ -0,0 +1,23 @@ +import { computed, inject, Injectable, signal } from '@angular/core' +import { fromSdkState } from '../utils' +import { NgContentfulOptimization } from './optimization' + +@Injectable({ providedIn: 'root' }) +export class NgLiveUpdates { + private readonly sdk = inject(NgContentfulOptimization).sdk + + private readonly globalLiveUpdatesSignal = signal(false) + private readonly previewPanelAttached = fromSdkState( + this.sdk.states.previewPanelAttached, + ) + private readonly previewPanelOpen = fromSdkState(this.sdk.states.previewPanelOpen) + + readonly globalLiveUpdates = this.globalLiveUpdatesSignal.asReadonly() + readonly previewPanelVisible = computed( + () => (this.previewPanelAttached() ?? false) && (this.previewPanelOpen() ?? false), + ) + + toggle(): void { + this.globalLiveUpdatesSignal.update((v) => !v) + } +} diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts new file mode 100644 index 00000000..03c1665e --- /dev/null +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -0,0 +1,109 @@ +import { computed, inject, Injectable, type OnDestroy, type Signal } from '@angular/core' +import { NavigationEnd, Router } from '@angular/router' +import ContentfulOptimization from '@contentful/optimization-web' +import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' +import { Profile } from '@contentful/optimization-web/api-schemas' +import type { Subscription } from 'rxjs' +import { filter } from 'rxjs/operators' +import type { NgContentfulOptimizationConfig } from '../config' +import { + getOrCreateBaseClient, + NG_CONTENTFUL_OPTIMIZATION_CONFIG, + resolveLogLevel, +} from '../config' +import { fromSdkState } from '../utils' + +export type NgContentfulOptimizationInstance = ContentfulOptimization + +let instance: NgContentfulOptimizationInstance | undefined = undefined +let attachmentStarted = false + +async function attachPreviewPanel( + sdk: NgContentfulOptimizationInstance, + config: NgContentfulOptimizationConfig, +): Promise { + if (attachmentStarted) return + attachmentStarted = true + try { + const contentfulClient = getOrCreateBaseClient(config) + const { default: attach } = await import('@contentful/optimization-web-preview-panel') + await attach({ + contentful: contentfulClient, + optimization: sdk, + nonce: config.previewPanel?.nonce, + }) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + if (!msg.includes('already been attached')) throw err + } +} + +function getOrCreateInstance( + config: NgContentfulOptimizationConfig, +): NgContentfulOptimizationInstance { + instance ??= new ContentfulOptimization({ + clientId: config.clientId, + environment: config.environment, + logLevel: resolveLogLevel(config.logLevel), + autoTrackEntryInteraction: config.autoTrackEntryInteraction ?? { + views: true, + clicks: true, + hovers: true, + }, + locale: config.locale, + app: config.app, + api: { + insightsBaseUrl: config.insightsBaseUrl, + experienceBaseUrl: config.experienceBaseUrl, + }, + }) + return instance +} + +@Injectable({ providedIn: 'root' }) +export class NgContentfulOptimization implements OnDestroy { + readonly sdk: NgContentfulOptimizationInstance + readonly consent: Signal + readonly profile: Signal + readonly selectedOptimizations: Signal + + private readonly routerSubscription: Subscription + + constructor() { + const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) + const router = inject(Router) + + this.sdk = getOrCreateInstance(config) + + if (config.previewPanel !== undefined) { + void attachPreviewPanel(this.sdk, config) + } + + this.consent = fromSdkState(this.sdk.states.consent) + + const rawProfile = fromSdkState(this.sdk.states.profile) + this.profile = computed(() => { + const result = Profile.safeParse(rawProfile()) + if (!result.success) return undefined + // anonymous profiles exist after reset — only expose when the user is identified + return result.data.traits.identified ? result.data : undefined + }) + + this.selectedOptimizations = fromSdkState(this.sdk.states.selectedOptimizations) + + // Page events must fire on every route change including the initial load. + // The SDK uses the current URL to resolve which experiences apply to the user. + this.routerSubscription = router.events + .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) + .subscribe((e) => { + void this.sdk.page({ properties: { url: window.location.origin + e.urlAfterRedirects } }) + }) + } + + ngOnDestroy(): void { + this.routerSubscription.unsubscribe() + this.sdk.destroy() + instance = undefined + attachmentStarted = false + } +} diff --git a/implementations/web-sdk_angular/src/app/utils.ts b/implementations/web-sdk_angular/src/app/utils.ts new file mode 100644 index 00000000..bad4b097 --- /dev/null +++ b/implementations/web-sdk_angular/src/app/utils.ts @@ -0,0 +1,21 @@ +import { DestroyRef, inject, signal, type Signal } from '@angular/core' + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export interface SdkObservable { + subscribe: (fn: (v: T) => void) => { unsubscribe: () => void } +} + +export function fromSdkState(obs: SdkObservable): Signal { + const s = signal(undefined) + const destroyRef = inject(DestroyRef) + const sub = obs.subscribe((v) => { + s.set(v) + }) + destroyRef.onDestroy(() => { + sub.unsubscribe() + }) + return s.asReadonly() +} diff --git a/implementations/web-sdk_angular/src/index.html b/implementations/web-sdk_angular/src/index.html new file mode 100644 index 00000000..d06d6204 --- /dev/null +++ b/implementations/web-sdk_angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Web SDK + Angular Reference + + + + + + + diff --git a/implementations/web-sdk_angular/src/main.ts b/implementations/web-sdk_angular/src/main.ts new file mode 100644 index 00000000..6a96941b --- /dev/null +++ b/implementations/web-sdk_angular/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser' +import { App } from './app/app' +import { appConfig } from './app/app.config' + +bootstrapApplication(App, appConfig).catch((err: unknown) => { + throw err +}) diff --git a/implementations/web-sdk_angular/src/styles.css b/implementations/web-sdk_angular/src/styles.css new file mode 100644 index 00000000..0878faa4 --- /dev/null +++ b/implementations/web-sdk_angular/src/styles.css @@ -0,0 +1,240 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --color-bg: #f9fafb; + --color-surface: #ffffff; + --color-border: #e5e7eb; + --color-text: #111827; + --color-text-muted: #6b7280; + --color-primary: #8c2eea; + --color-primary-hover: #7e29d3; + --color-nav-bg: #3b0764; + --color-nav-text: #f3e8ff; + --color-nav-link: #d8b4fe; + --color-nav-link-hover: #ffffff; + --radius: 8px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +html, +body { + height: 100%; +} + +body { + font-family: + system-ui, + -apple-system, + sans-serif; + font-size: 15px; + line-height: 1.6; + color: var(--color-text); + background: var(--color-bg); +} + +a { + color: var(--color-primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1 { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +h2 { + font-size: 1.15rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +p { + color: var(--color-text-muted); + margin-bottom: 0.5rem; +} + +button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.9rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + transition: background 0.15s; +} + +button:hover { + background: var(--color-bg); +} + +/* Nav */ + +nav { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0 2rem; + height: 52px; + background: var(--color-nav-bg); + color: var(--color-nav-text); + box-shadow: var(--shadow); +} + +nav a { + font-size: 0.9rem; + font-weight: 500; + color: var(--color-nav-link); + text-decoration: none; + padding: 0.25rem 0; + border-bottom: 2px solid transparent; + transition: + color 0.15s, + border-color 0.15s; +} + +nav a:hover, +nav a.active { + color: var(--color-nav-link-hover); + border-bottom-color: var(--color-nav-link-hover); + text-decoration: none; +} + +/* Layout */ + +.app-layout { + display: grid; + grid-template-columns: 340px 1fr; + grid-template-areas: 'sidebar main'; + align-items: start; + min-height: calc(100vh - 52px); +} + +.app-sidebar { + grid-area: sidebar; + position: sticky; + top: 0; + height: calc(100vh - 52px); + overflow-y: auto; + border-right: 1px solid var(--color-border); + background: var(--color-surface); +} + +main { + grid-area: main; + min-width: 0; + padding: 1.5rem 24rem 3rem 1.5rem; + display: grid; + gap: 2.5rem; +} + +.sections-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + align-items: stretch; +} + +.sections-row--two { + grid-template-columns: repeat(2, 1fr); + align-items: start; +} + +main > .page-section, +main > .sections-row { + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +/* Page header */ + +.page-header { + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.page-header__sub { + font-size: 0.875rem; + color: var(--color-text-muted); + margin-top: 0.25rem; + margin-bottom: 0; +} + +.page-loading { + color: var(--color-text-muted); + font-size: 0.9rem; +} + +/* Page section */ + +.page-section { + display: grid; + gap: 0.5rem; +} + +.page-section__header { + margin-bottom: 1rem; +} + +.page-section__header h2 { + margin-bottom: 0.1rem; +} + +.page-section__header p { + font-size: 0.825rem; + margin: 0; +} + +/* Tooltip */ + +[data-tooltip] { + position: relative; + cursor: default; +} + +[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + background: #1e293b; + color: #f8fafc; + font-size: 0.65rem; + font-weight: 400; + letter-spacing: 0; + text-transform: none; + padding: 0.3rem 0.5rem; + border-radius: 4px; + pointer-events: none; + z-index: 100; +} + +/* Entry grid */ + +.entry-grid { + display: grid; + gap: 0.5rem; +} diff --git a/implementations/web-sdk_angular/src/types/env.d.ts b/implementations/web-sdk_angular/src/types/env.d.ts new file mode 100644 index 00000000..2bd15a93 --- /dev/null +++ b/implementations/web-sdk_angular/src/types/env.d.ts @@ -0,0 +1,18 @@ +interface ImportMetaEnv { + readonly PUBLIC_NINETAILED_CLIENT_ID?: string + readonly PUBLIC_NINETAILED_ENVIRONMENT?: string + readonly PUBLIC_INSIGHTS_API_BASE_URL?: string + readonly PUBLIC_EXPERIENCE_API_BASE_URL?: string + readonly PUBLIC_CONTENTFUL_TOKEN?: string + readonly PUBLIC_CONTENTFUL_PREVIEW_TOKEN?: string + readonly PUBLIC_CONTENTFUL_ENVIRONMENT?: string + readonly PUBLIC_CONTENTFUL_SPACE_ID?: string + readonly PUBLIC_CONTENTFUL_CDA_HOST?: string + readonly PUBLIC_CONTENTFUL_BASE_PATH?: string + readonly PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL?: string + readonly PUBLIC_OPTIMIZATION_LOG_LEVEL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/implementations/web-sdk_angular/tsconfig.json b/implementations/web-sdk_angular/tsconfig.json new file mode 100644 index 00000000..4efba62a --- /dev/null +++ b/implementations/web-sdk_angular/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "dom"], + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index bc799311..01477fe5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "implementation:react-native-sdk": "pnpm run implementation:run -- react-native-sdk", "implementation:run": "tsx ./scripts/run-implementation-script.ts", "implementation:react-web-sdk": "pnpm run implementation:run -- react-web-sdk", + "implementation:web-sdk_angular": "pnpm run implementation:run -- web-sdk_angular", "implementation:web-sdk_react": "pnpm run implementation:run -- web-sdk_react", "implementation:react-web-sdk+node-sdk_nextjs-ssr": "pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr", "implementation:react-web-sdk+node-sdk_nextjs-ssr-csr": "pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr",