Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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.ifBrowser((sdk) => sdk.states.flag('boolean')),
this.optimization.runtime().states.flag('boolean'),
)

constructor() {
Expand All @@ -39,22 +39,20 @@ export class ControlPanel {
}

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

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

protected reset(): void {
this.optimization.ifBrowser((sdk) => {
sdk.reset()
void sdk.page()
})
const runtime = this.optimization.runtime()
runtime.reset()
void runtime.page()
}

protected trackConversion(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { NgTemplateOutlet } from '@angular/common'
import { Component, computed, forwardRef, inject, input } from '@angular/core'
import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'
import {
isMergeTagEntry,
isRecord,
isResolvedContentfulEntry,
isRichTextDocument,
isUnresolvedEntryLink,
type MergeTagEntry,
} from '@contentful/optimization-web/api-schemas'
import { BLOCKS, INLINES } from '@contentful/rich-text-types'
import {
Expand All @@ -17,6 +20,7 @@ import {
import type { ContentEntrySkeleton, ContentfulEntry } from '../../services/contentful-client'
import { injectContentfulEntry } from '../../services/entry'
import { NgLiveUpdates } from '../../services/live-updates'
import { NgContentfulOptimization } from '../../services/optimization'

// — Badge —

Expand Down Expand Up @@ -57,8 +61,26 @@ function escape(text: string): string {
}

type NodeRenderer = (children: () => string, data: Record<string, unknown>) => string
type MergeTagValueResolver = (target: MergeTagEntry) => string | undefined

/**
* Substitution outcome tracked alongside a single rich-text walk. Callers pass
* a fresh cell in; the walker flips `resolved` to true or false depending on
* whether the first encountered merge tag returned a value. Kept out of the
* entry-resolution layer so it lives next to the badge that consumes it —
* mirrors the Next.js reference which resolves at render time via
* `OptimizedEntryRenderContext.getMergeTagValue`.
*/
interface MergeTagRenderState {
resolved: boolean | undefined
}

// `INLINES.EMBEDDED_ENTRY` is a string enum member; widen it once at module
// scope so the render walk compares plain strings and `@typescript-eslint/no-
// unsafe-enum-comparison` stays quiet without a cast at the call site.
const EMBEDDED_ENTRY_NODE_TYPE: string = INLINES.EMBEDDED_ENTRY

const RENDERERS: Partial<Record<string, NodeRenderer>> = {
const BLOCK_RENDERERS: Partial<Record<string, NodeRenderer>> = {
[BLOCKS.PARAGRAPH]: (children) => `<p>${children()}</p>`,
[BLOCKS.HEADING_1]: (children) => `<h1>${children()}</h1>`,
[BLOCKS.HEADING_2]: (children) => `<h2>${children()}</h2>`,
Expand All @@ -79,16 +101,38 @@ const RENDERERS: Partial<Record<string, NodeRenderer>> = {
},
}

function renderNode(node: unknown): string {
function renderMergeTag(
data: Record<string, unknown>,
getMergeTagValue: MergeTagValueResolver | undefined,
state: MergeTagRenderState,
): string {
if (!('target' in data)) return ''
const { target } = data
if (isUnresolvedEntryLink(target) || !isMergeTagEntry(target)) return ''
const value = getMergeTagValue?.(target)
if (value !== undefined) {
state.resolved = true
return escape(value)
}
state.resolved ??= false
return typeof target.fields.nt_fallback === 'string' ? escape(target.fields.nt_fallback) : ''
}

function renderNode(
node: unknown,
getMergeTagValue: MergeTagValueResolver | undefined,
state: MergeTagRenderState,
): 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('')
const children = (): string => content.map((c) => renderNode(c, getMergeTagValue, state)).join('')

if (nodeType === 'text') return escape(value)
const { [nodeType]: renderer } = RENDERERS
if (nodeType === EMBEDDED_ENTRY_NODE_TYPE) return renderMergeTag(data, getMergeTagValue, state)
const { [nodeType]: renderer } = BLOCK_RENDERERS
return renderer !== undefined ? renderer(children, data) : children()
}

Expand All @@ -108,6 +152,7 @@ export class EntryCard {

private readonly sanitizer = inject(DomSanitizer)
private readonly liveUpdatesService = inject(NgLiveUpdates)
private readonly optimization = inject(NgContentfulOptimization)

private readonly isLive = computed(() => {
if (this.liveUpdatesService.previewPanelVisible()) return true
Expand All @@ -120,14 +165,29 @@ export class EntryCard {
manualTracking: this.manualTracking,
})

protected readonly effectiveTestId = computed(() => this.testId() ?? this.resolved().baselineId)
protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined)
protected readonly richTextHtml = computed<SafeHtml | undefined>(() => {
// Rich text and merge-tag detection share a single walk so the badge signal
// reflects the substitution outcome for exactly this render.
private readonly renderedRichText = computed<
{ html: SafeHtml; mergeTagResolved: boolean | undefined } | undefined
>(() => {
const { entry } = this.resolved()
const doc = Object.values(entry.fields).find(isRichTextDocument)
if (!doc) return undefined
return this.sanitizer.bypassSecurityTrustHtml(renderNode(doc))
const profile = this.optimization.profile()
const runtime = this.optimization.runtime()
const getMergeTagValue = profile
? (target: MergeTagEntry): string | undefined => runtime.getMergeTagValue(target, profile)
: undefined
const state: MergeTagRenderState = { resolved: undefined }
const html = this.sanitizer.bypassSecurityTrustHtml(renderNode(doc, getMergeTagValue, state))
return { html, mergeTagResolved: state.resolved }
})

protected readonly effectiveTestId = computed(() => this.testId() ?? this.resolved().baselineId)
protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined)
protected readonly richTextHtml = computed<SafeHtml | undefined>(
() => this.renderedRichText()?.html,
)
protected readonly entryText = computed(() => {
const text: unknown = this.resolved().entry.fields.text
return typeof text === 'string' ? text : 'No content'
Expand All @@ -139,8 +199,7 @@ export class EntryCard {
: []
})
protected readonly badges = computed(() => {
const r = this.resolved()
const mergeTag = mergeTagKey(r.mergeTagResolved)
const mergeTag = mergeTagKey(this.renderedRichText()?.mergeTagResolved)
const scenario = this.clickScenario()
const keys: BadgeKey[] = [
...(mergeTag ? [mergeTag] : []),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component, DestroyRef, computed, inject, signal } from '@angular/core'
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import type { OptimizationEventStreamEvent } from '@contentful/optimization-web/core-sdk'
import { interval } from 'rxjs'
import { NgContentfulOptimization } from '../../services/optimization'

Expand Down Expand Up @@ -28,18 +29,29 @@ function timeAgo(firedAt: number, now: number): string {
return `${Math.floor(m / MINUTES_PER_HOUR)}h`
}

type StreamEvent = OptimizationEventStreamEvent
type EventOfType<T extends StreamEvent['type']> = Extract<StreamEvent, { type: T }>

@Component({
selector: 'app-tracking-log',
templateUrl: './index.html',
})
export class TrackingLog {
private readonly optimization = inject(NgContentfulOptimization)
private readonly destroyRef = inject(DestroyRef)

private readonly events = signal<Map<string, AnalyticsEvent>>(new Map())
private readonly rawEventsCount = signal(0)
private readonly tick = toSignal(interval(TICK_INTERVAL_SECONDS * MS_PER_SECOND), {
initialValue: 0,
})

// Per-event-type sequence counters. Externalized from the dispatch so each
// handler stays a straight input→track mapping without threading closure
// state through the switch.
private pageSeq = 0
private componentSeq = 0

protected readonly rawEventsDisplay = this.rawEventsCount.asReadonly()
protected readonly displayEvents = computed(() => {
this.tick()
Expand All @@ -50,80 +62,99 @@ 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 = sdk.states.eventStream.subscribe((raw) => {
if (raw != null) {
this.rawEventsCount.update((n) => n + 1)
}
switch (raw?.type) {
case 'page': {
const {
properties: { url },
} = raw
pageSeq += 1
const pathname = (() => {
try {
return new URL(url, window.location.origin).pathname
} catch {
return url
}
})()
this.track({ type: 'page', value: pathname, key: `page-${pageSeq}-${url}` })
break
}
case 'component': {
const { componentId, viewId, viewDurationMs } = raw
if (viewId) {
this.track({
type: 'view',
value: componentId,
key: `view-${viewId}`,
viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined,
})
} else {
componentSeq += 1
this.track(
{ type: 'comp', value: componentId, key: `component-${componentId}-${componentSeq}` },
`event-component-${componentId}`,
)
}
break
}
case 'component_hover': {
const { componentId, hoverId, hoverDurationMs } = raw
if (hoverId) {
this.track({
type: 'hover',
value: componentId,
key: `component_hover-hover-${hoverId}`,
hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined,
hoverId,
})
} else {
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
}
let sub: { unsubscribe: () => void } | undefined = undefined

// Re-subscribe when the runtime swaps from the SSR snapshot runtime to
// the live SDK. The snapshot runtime's static eventStream never emits, so
// the initial subscription is a harmless no-op that is torn down on swap.
effect(() => {
const runtime = this.optimization.runtime()
sub?.unsubscribe()
sub = runtime.states.eventStream.subscribe((raw) => {
this.dispatch(raw)
})
})
inject(DestroyRef).onDestroy(() => {
sub.unsubscribe()

this.destroyRef.onDestroy(() => {
sub?.unsubscribe()
})
}

private dispatch(raw: StreamEvent | undefined): void {
if (raw == null) return
this.rawEventsCount.update((n) => n + 1)

switch (raw.type) {
case 'page':
this.handlePage(raw)
break
case 'component':
this.handleComponent(raw)
break
case 'component_hover':
this.handleHover(raw)
break
case 'component_click':
this.handleClick(raw)
break
default:
break
}
}

private handlePage(raw: EventOfType<'page'>): void {
const {
properties: { url },
} = raw
this.pageSeq += 1
const pathname = (() => {
try {
return new URL(url, window.location.origin).pathname
} catch {
return url
}
})()
this.track({ type: 'page', value: pathname, key: `page-${this.pageSeq}-${url}` })
}

private handleComponent(raw: EventOfType<'component'>): void {
const { componentId, viewId, viewDurationMs } = raw
if (viewId) {
this.track({
type: 'view',
value: componentId,
key: `view-${viewId}`,
viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined,
})
return
}
this.componentSeq += 1
this.track(
{ type: 'comp', value: componentId, key: `component-${componentId}-${this.componentSeq}` },
`event-component-${componentId}`,
)
}

private handleHover(raw: EventOfType<'component_hover'>): void {
const { componentId, hoverId, hoverDurationMs } = raw
if (hoverId) {
this.track({
type: 'hover',
value: componentId,
key: `component_hover-hover-${hoverId}`,
hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined,
hoverId,
})
return
}
this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` })
}

private handleClick(raw: EventOfType<'component_click'>): void {
const { componentId } = raw
this.track({ type: 'click', value: componentId, key: `component_click-${componentId}` })
}

private track(
event: Omit<AnalyticsEvent, 'count' | 'firedAt' | 'testId'>,
testId?: string,
Expand Down
Loading