Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions implementations/web-sdk_angular/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"outputPath": "dist/web-sdk_angular",
"index": "src/index.html",
"browser": "src/main.ts",
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": { "entry": "src/server.ts" },
"tsConfig": "tsconfig.json",
"assets": [],
"styles": ["./node_modules/e2e-web/src/theme.css"],
Expand Down
6 changes: 6 additions & 0 deletions implementations/web-sdk_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/platform-server": "^22.0.0",
"@angular/router": "^22.0.0",
"@angular/ssr": "^22.0.0",
"@contentful/optimization-node": "*",
"@contentful/optimization-web": "*",
"@contentful/optimization-web-preview-panel": "*",
"@contentful/rich-text-types": "^17.2.7",
"contentful": "^11.12.4",
"express": "5.2.1",
"express-rate-limit": "8.2.2",
"rxjs": "~7.8.0",
"tslib": "^2.8.1"
},
Expand All @@ -40,6 +45,7 @@
"@angular/build": "^22.0.0",
"@angular/cli": "^22.0.0",
"@angular/compiler-cli": "^22.0.0",
"@types/express": "5.0.6",
"@types/node": "^24.0.13",
"dotenv": "^17.4.2",
"pm2": "^6.0.14",
Expand Down
1 change: 1 addition & 0 deletions implementations/web-sdk_angular/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ 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-node': 'file:../../pkgs/contentful-optimization-node-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'
14 changes: 14 additions & 0 deletions implementations/web-sdk_angular/src/app/app.config.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { mergeApplicationConfig, type ApplicationConfig } from '@angular/core'
import { provideServerRendering, withRoutes } from '@angular/ssr'
import { appConfig } from './app.config'
import { serverRoutes } from './app.routes.server'
import { provideServerOptimizationInitializer } from './services/optimization'

const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes)),
provideServerOptimizationInitializer(),
],
}

export const config = mergeApplicationConfig(appConfig, serverConfig)
2 changes: 2 additions & 0 deletions implementations/web-sdk_angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core'
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'
import { provideRouter } from '@angular/router'
import { routes } from './app.routes'
import { provideContentfulOptimizationConfig, resolveLogLevel } from './config'
Expand All @@ -12,6 +13,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideClientHydration(withEventReplay()),
provideRouter(routes),
provideContentfulOptimizationConfig({
clientId: environment.PUBLIC_NINETAILED_CLIENT_ID,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { RenderMode, type ServerRoute } from '@angular/ssr'

export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Server }]
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import { Component, computed, inject, input } from '@angular/core'
import { Component, computed, effect, inject, input } from '@angular/core'
import { NgLiveUpdates } from '../../services/live-updates'
import { NgContentfulOptimization } from '../../services/optimization'
import { fromSdkState } from '../../utils'

const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent'

/**
* Mirror the SDK's internal consent state into a cookie the SSR server can
* read on the next request. Without this, the server can't tell whether the
* user has granted consent and falls back to baseline rendering. Matches the
* pattern used by `nextjs-sdk_ssr/components/InteractiveControls.tsx`.
*/
function syncConsentCookie(consent: boolean): void {
if (typeof document === 'undefined') return
const value = consent ? 'granted' : 'denied'
document.cookie = `${APP_PERSONALIZATION_CONSENT_COOKIE}=${value}; Path=/; SameSite=Lax`
}

@Component({
selector: 'app-control-panel',
templateUrl: './index.html',
Expand All @@ -22,21 +36,34 @@ export class ControlPanel {
)
// This is an active exposure stream. Core does not mark one-off flag reads as
// tracked until a flag-view event is actually accepted.
protected readonly booleanFlag = fromSdkState<unknown>(
this.optimization.sdk.states.flag('boolean'),
protected readonly booleanFlag = fromSdkState<unknown>(() =>
this.optimization.withSdk((sdk) => sdk.states.flag('boolean')),
)

constructor() {
effect(() => {
const value = this.consent()
if (typeof value === 'boolean') syncConsentCookie(value)
})
}

protected toggleConsent(): void {
this.optimization.sdk.consent(this.consent() !== true)
this.optimization.withSdk((sdk) => {
sdk.consent(this.consent() !== true)
})
}

protected identify(): void {
void this.optimization.sdk.identify({ userId: 'charles', traits: { identified: true } })
this.optimization.withSdk((sdk) => {
void sdk.identify({ userId: 'charles', traits: { identified: true } })
})
}

protected reset(): void {
this.optimization.sdk.reset()
void this.optimization.sdk.page()
this.optimization.withSdk((sdk) => {
sdk.reset()
void sdk.page()
})
}

protected trackConversion(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ export class TrackingLog {
})

constructor() {
const { optimization } = this
const { context } = optimization
if (context.platform !== 'browser') return
const { sdk } = context

let pageSeq = 0
let componentSeq = 0
const sub = this.optimization.sdk.states.eventStream.subscribe((raw) => {
const sub = sdk.states.eventStream.subscribe((raw) => {
if (raw != null) {
this.rawEventsCount.update((n) => n + 1)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export class PageTwo {
}

protected readonly trackConversion = (): void => {
void this.optimization.sdk.trackView({
componentId: PAGE_TWO_COMPONENT_ID,
viewId: crypto.randomUUID(),
viewDurationMs: 0,
this.optimization.withSdk((sdk) => {
void sdk.trackView({
componentId: PAGE_TWO_COMPONENT_ID,
viewId: crypto.randomUUID(),
viewDurationMs: 0,
})
})
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { inject, Injectable, resource, type ResourceRef } from '@angular/core'
import { inject, Injectable, resource, TransferState, type ResourceRef } from '@angular/core'
import type { ContentfulClientApi, Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful'
import { getOrCreateBaseClient, NG_CONTENTFUL_OPTIMIZATION_CONFIG } from '../config'
import { SERVER_RESOLVED_ENTRIES_KEY } from '../transfer-state-keys'

export interface ContentEntryFields {
text?: EntryFieldTypes.Text | EntryFieldTypes.RichText
Expand All @@ -15,7 +16,8 @@ const INCLUDE_DEPTH = 10
@Injectable({ providedIn: 'root' })
export class NgContentfulClient {
private readonly client: ContentfulClientApi<undefined>
private readonly locale: string
private readonly transferState = inject(TransferState)
readonly locale: string

constructor() {
const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG)
Expand All @@ -41,8 +43,14 @@ export class NgContentfulClient {
loadEntries = (ids: readonly string[]): ResourceRef<Map<string, ContentfulEntry> | undefined> =>
resource({
loader: async (): Promise<Map<string, ContentfulEntry>> => {
const list = await this.fetchEntries(ids)
return new Map(list.map((e) => [e.sys.id, e]))
const handoff = this.transferState.get(SERVER_RESOLVED_ENTRIES_KEY, undefined)
if (handoff && ids.every((id) => Object.hasOwn(handoff, id))) {
// Hydration path: server already fetched these baselines and stamped
// them into TransferState. Skip the duplicate CDA roundtrip.
return new Map(ids.map((id) => [id, handoff[id].baseline as Entry<ContentEntrySkeleton>]))
}
const list = await this.fetchEntries<ContentEntrySkeleton>(ids)
return new Map(list.map((entry) => [entry.sys.id, entry]))
},
})
}
44 changes: 35 additions & 9 deletions implementations/web-sdk_angular/src/app/services/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ElementRef,
inject,
signal,
TransferState,
untracked,
type Signal,
} from '@angular/core'
Expand All @@ -14,6 +15,7 @@ import { isMergeTagEntry, type MergeTagEntry } from '@contentful/optimization-we
import type { Document, Text } from '@contentful/rich-text-types'
import { INLINES } from '@contentful/rich-text-types'
import type { Entry } from 'contentful'
import { SERVER_RESOLVED_ENTRIES_KEY } from '../transfer-state-keys'
import { isRecord } from '../utils'
import { NgContentfulOptimization } from './optimization'

Expand Down Expand Up @@ -78,14 +80,18 @@ function setupManualTracking(result: Signal<ResolvedEntry>, manualTracking: Sign
})

function track(): void {
const { entryId, optimizationId, sticky, variantIndex } = result()
optimization.sdk.tracking.enableElement('views', elementRef.nativeElement, {
data: { entryId, optimizationId, sticky, variantIndex },
optimization.withSdk((sdk) => {
const { entryId, optimizationId, sticky, variantIndex } = result()
sdk.tracking.enableElement('views', elementRef.nativeElement, {
data: { entryId, optimizationId, sticky, variantIndex },
})
})
}

function clear(): void {
optimization.sdk.tracking.clearElement('views', elementRef.nativeElement)
optimization.withSdk((sdk) => {
sdk.tracking.clearElement('views', elementRef.nativeElement)
})
}

effect(() => {
Expand All @@ -108,6 +114,7 @@ export function injectContentfulEntry({
manualTracking?: Signal<boolean>
}): Signal<ResolvedEntry> {
const optimization = inject(NgContentfulOptimization)
const transferState = inject(TransferState)

function liveRead<T>(sig: Signal<T>): T {
if (isLive()) return sig()
Expand All @@ -120,12 +127,29 @@ export function injectContentfulEntry({

const variant = computed(() => {
const raw = entry()
if (optimization.context.platform === 'browser') {
return {
raw,
resolved: optimization.context.sdk.resolveOptimizedEntry(
raw,
liveRead(optimization.selectedOptimizations),
),
}
}
// Server render: lift the server-resolved entry from TransferState if present
// so the initial HTML reflects the personalized variant. Falls back to the
// baseline when no handoff exists (e.g. consent denied — server skipped resolve).
const handoff = transferState.get(SERVER_RESOLVED_ENTRIES_KEY, undefined)
const slot = handoff?.[raw.sys.id]
if (slot?.isVariant) {
return {
raw,
resolved: { entry: slot.resolvedEntry, selectedOptimization: slot.selectedOptimization },
}
}
return {
raw,
resolved: optimization.sdk.resolveOptimizedEntry(
raw,
liveRead(optimization.selectedOptimizations),
),
resolved: { entry: slot?.resolvedEntry ?? raw, selectedOptimization: undefined },
}
})

Expand All @@ -134,7 +158,9 @@ export function injectContentfulEntry({
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
const value = profile
? optimization.withSdk((sdk) => sdk.getMergeTagValue(target, profile))
: undefined
if (value !== undefined) mergeTagResolved = true
else mergeTagResolved ??= false
return value ?? target.fields.nt_fallback
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@ import { fromSdkState } from '../utils'
import { NgContentfulOptimization } from './optimization'

function clickPreviewPanelToggle(): void {
if (typeof document === 'undefined') return
const panel = document.querySelector('ctfl-opt-preview-panel')
const btn = panel?.shadowRoot?.querySelector<HTMLButtonElement>('button.toggle-drawer')
btn?.click()
}

@Injectable({ providedIn: 'root' })
export class NgLiveUpdates {
private readonly sdk = inject(NgContentfulOptimization).sdk
private readonly optimization = inject(NgContentfulOptimization)

private readonly globalLiveUpdatesSignal = signal(false)
private readonly previewPanelAttached = fromSdkState<boolean>(
this.sdk.states.previewPanelAttached,
private readonly previewPanelAttached = fromSdkState<boolean>(() =>
this.optimization.withSdk((sdk) => sdk.states.previewPanelAttached),
)
private readonly previewPanelOpen = fromSdkState<boolean>(() =>
this.optimization.withSdk((sdk) => sdk.states.previewPanelOpen),
)
private readonly previewPanelOpen = fromSdkState<boolean>(this.sdk.states.previewPanelOpen)

readonly globalLiveUpdates = this.globalLiveUpdatesSignal.asReadonly()
readonly previewPanelVisible = computed(
Expand Down
Loading