diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 895bd300..56efc707 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -194,12 +194,14 @@ jobs: - run: | echo "VITE_NINETAILED_CLIENT_ID=${{secrets.NINETAILED_CLIENT_ID}}" >>implementations/node/.env echo "VITE_NINETAILED_ENVIRONMENT=${{secrets.NINETAILED_ENVIRONMENT}}" >>implementations/node/.env + echo "VITE_EXPERIENCE_API_BASE_URL=http://localhost/experience/" >>implementations/node/.env + echo "VITE_INSIGHTS_API_BASE_URL=http://localhost/insights/" >>implementations/node/.env echo "VITE_CONTENTFUL_TOKEN=${{secrets.CONTENTFUL_TOKEN}}" >>implementations/node/.env echo "VITE_CONTENTFUL_PREVIEW_TOKEN=${{secrets.CONTENTFUL_PREVIEW_TOKEN}}" >>implementations/node/.env echo "VITE_CONTENTFUL_ENVIRONMENT=${{secrets.CONTENTFUL_ENVIRONMENT}}" >>implementations/node/.env echo "VITE_CONTENTFUL_SPACE_ID=${{secrets.CONTENTFUL_SPACE_ID}}" >>implementations/node/.env - echo "VITE_EXPERIENCE_API_BASE_URL=http://localhost/experience/" >>implementations/node/.env - echo "VITE_INSIGHTS_API_BASE_URL=http://localhost/insights/" >>implementations/node/.env + echo "VITE_CONTENTFUL_CDA_HOST=localhost" >>implementations/node/.env + echo "VITE_CONTENTFUL_BASE_PATH=contentful" >>implementations/node/.env - uses: pnpm/action-setup@v4 @@ -218,6 +220,15 @@ jobs: - run: pnpm --filter @implementation/node test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: ci-results-node + path: | + ./implementations/node/playwright-report/ + ./implementations/node/test-results/ + retention-days: 1 + e2e-web: name: E2E Web Vanilla 🖥️ runs-on: ubuntu-latest @@ -231,12 +242,14 @@ jobs: - run: | echo "VITE_NINETAILED_CLIENT_ID=${{secrets.NINETAILED_CLIENT_ID}}" >>implementations/web-vanilla/.env echo "VITE_NINETAILED_ENVIRONMENT=${{secrets.NINETAILED_ENVIRONMENT}}" >>implementations/web-vanilla/.env + echo "VITE_EXPERIENCE_API_BASE_URL=http://localhost:8000/experience/" >>implementations/web-vanilla/.env + echo "VITE_INSIGHTS_API_BASE_URL=http://localhost:8000/insights/" >>implementations/web-vanilla/.env echo "VITE_CONTENTFUL_TOKEN=${{secrets.CONTENTFUL_TOKEN}}" >>implementations/web-vanilla/.env echo "VITE_CONTENTFUL_PREVIEW_TOKEN=${{secrets.CONTENTFUL_PREVIEW_TOKEN}}" >>implementations/web-vanilla/.env echo "VITE_CONTENTFUL_ENVIRONMENT=${{secrets.CONTENTFUL_ENVIRONMENT}}" >>implementations/web-vanilla/.env echo "VITE_CONTENTFUL_SPACE_ID=${{secrets.CONTENTFUL_SPACE_ID}}" >>implementations/web-vanilla/.env - echo "VITE_EXPERIENCE_API_BASE_URL=http://localhost/experience/" >>implementations/web-vanilla/.env - echo "VITE_INSIGHTS_API_BASE_URL=http://localhost/insights/" >>implementations/web-vanilla/.env + echo "VITE_CONTENTFUL_CDA_HOST=localhost:8000" >>implementations/web-vanilla/.env + echo "VITE_CONTENTFUL_BASE_PATH=contentful" >>implementations/web-vanilla/.env - uses: pnpm/action-setup@v4 @@ -254,3 +267,12 @@ jobs: - run: pnpx playwright install --with-deps - run: pnpm --filter @implementation/web-vanilla test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: ci-results-web-vanilla + path: | + ./implementations/web-vanilla/playwright-report/ + ./implementations/web-vanilla/test-results/ + retention-days: 1 diff --git a/implementations/node-ssr/.env.example b/implementations/node-ssr/.env.example index eb49fa29..8c4628b9 100644 --- a/implementations/node-ssr/.env.example +++ b/implementations/node-ssr/.env.example @@ -1,12 +1,13 @@ VITE_NINETAILED_CLIENT_ID= VITE_NINETAILED_ENVIRONMENT= +VITE_EXPERIENCE_API_BASE_URL= +VITE_INSIGHTS_API_BASE_URL= + VITE_CONTENTFUL_TOKEN= VITE_CONTENTFUL_PREVIEW_TOKEN= VITE_CONTENTFUL_ENVIRONMENT= VITE_CONTENTFUL_SPACE_ID= -VITE_EXPERIENCE_API_BASE_URL= -VITE_INSIGHTS_API_BASE_URL= VITE_CONTENTFUL_CDA_HOST= VITE_CONTENTFUL_BASE_PATH= diff --git a/implementations/node/.env.example b/implementations/node/.env.example index eb49fa29..8c4628b9 100644 --- a/implementations/node/.env.example +++ b/implementations/node/.env.example @@ -1,12 +1,13 @@ VITE_NINETAILED_CLIENT_ID= VITE_NINETAILED_ENVIRONMENT= +VITE_EXPERIENCE_API_BASE_URL= +VITE_INSIGHTS_API_BASE_URL= + VITE_CONTENTFUL_TOKEN= VITE_CONTENTFUL_PREVIEW_TOKEN= VITE_CONTENTFUL_ENVIRONMENT= VITE_CONTENTFUL_SPACE_ID= -VITE_EXPERIENCE_API_BASE_URL= -VITE_INSIGHTS_API_BASE_URL= VITE_CONTENTFUL_CDA_HOST= VITE_CONTENTFUL_BASE_PATH= diff --git a/implementations/react-native/.env.example b/implementations/react-native/.env.example index 55acc468..8c4628b9 100644 --- a/implementations/react-native/.env.example +++ b/implementations/react-native/.env.example @@ -1,12 +1,13 @@ -NINETAILED_CLIENT_ID= -NINETAILED_ENVIRONMENT= +VITE_NINETAILED_CLIENT_ID= +VITE_NINETAILED_ENVIRONMENT= -CONTENTFUL_TOKEN= -CONTENTFUL_PREVIEW_TOKEN= -CONTENTFUL_ENVIRONMENT= -CONTENTFUL_SPACE_ID= +VITE_EXPERIENCE_API_BASE_URL= +VITE_INSIGHTS_API_BASE_URL= -EXPERIENCE_API_BASE_URL= -INSIGHTS_API_BASE_URL= -CONTENTFUL_CDA_HOST= -CONTENTFUL_BASE_PATH= +VITE_CONTENTFUL_TOKEN= +VITE_CONTENTFUL_PREVIEW_TOKEN= +VITE_CONTENTFUL_ENVIRONMENT= +VITE_CONTENTFUL_SPACE_ID= + +VITE_CONTENTFUL_CDA_HOST= +VITE_CONTENTFUL_BASE_PATH= diff --git a/implementations/web-vanilla/.env.example b/implementations/web-vanilla/.env.example index eb49fa29..8c4628b9 100644 --- a/implementations/web-vanilla/.env.example +++ b/implementations/web-vanilla/.env.example @@ -1,12 +1,13 @@ VITE_NINETAILED_CLIENT_ID= VITE_NINETAILED_ENVIRONMENT= +VITE_EXPERIENCE_API_BASE_URL= +VITE_INSIGHTS_API_BASE_URL= + VITE_CONTENTFUL_TOKEN= VITE_CONTENTFUL_PREVIEW_TOKEN= VITE_CONTENTFUL_ENVIRONMENT= VITE_CONTENTFUL_SPACE_ID= -VITE_EXPERIENCE_API_BASE_URL= -VITE_INSIGHTS_API_BASE_URL= VITE_CONTENTFUL_CDA_HOST= VITE_CONTENTFUL_BASE_PATH= diff --git a/implementations/web-vanilla/e2e/example.spec.ts b/implementations/web-vanilla/e2e/example.spec.ts index f28aab5d..8acf19c6 100644 --- a/implementations/web-vanilla/e2e/example.spec.ts +++ b/implementations/web-vanilla/e2e/example.spec.ts @@ -1,9 +1,11 @@ import { expect, test } from '@playwright/test' -const CLIENT_ID = process.env.VITE_NINETAILED_CLIENT_ID ?? 'error' - -test('displays client ID', async ({ page }) => { +test('displays merge tag rich text', async ({ page }) => { await page.goto('/') - await expect(page.getByTestId('clientId')).toHaveText(CLIENT_ID) + await page.waitForLoadState('domcontentloaded') + + await expect( + page.getByText('This is a baseline content entry for an A/B/C experiment: A'), + ).toBeVisible() }) diff --git a/implementations/web-vanilla/nginx/templates/default.conf.template b/implementations/web-vanilla/nginx/templates/default.conf.template index b6efb85c..b054038d 100644 --- a/implementations/web-vanilla/nginx/templates/default.conf.template +++ b/implementations/web-vanilla/nginx/templates/default.conf.template @@ -8,13 +8,16 @@ server { set $NGINX_NINETAILED_CLIENT_ID "${VITE_NINETAILED_CLIENT_ID}"; set $NGINX_NINETAILED_ENVIRONMENT "${VITE_NINETAILED_ENVIRONMENT}"; + set $NGINX_EXPERIENCE_API_BASE_URL "${VITE_EXPERIENCE_API_BASE_URL}"; + set $NGINX_INSIGHTS_API_BASE_URL "${VITE_INSIGHTS_API_BASE_URL}"; + set $NGINX_CONTENTFUL_TOKEN "${VITE_CONTENTFUL_TOKEN}"; set $NGINX_CONTENTFUL_PREVIEW_TOKEN "${VITE_CONTENTFUL_PREVIEW_TOKEN}"; set $NGINX_CONTENTFUL_ENVIRONMENT "${VITE_CONTENTFUL_ENVIRONMENT}"; set $NGINX_CONTENTFUL_SPACE_ID "${VITE_CONTENTFUL_SPACE_ID}"; - set $NGINX_EXPERIENCE_API_BASE_URL "${VITE_EXPERIENCE_API_BASE_URL}"; - set $NGINX_INSIGHTS_API_BASE_URL "${VITE_INSIGHTS_API_BASE_URL}"; + set $NGINX_CONTENTFUL_CDA_HOST "${VITE_CONTENTFUL_CDA_HOST}"; + set $NGINX_CONTENTFUL_BASE_PATH "${VITE_CONTENTFUL_BASE_PATH}"; listen 80; diff --git a/implementations/web-vanilla/public/index.html b/implementations/web-vanilla/public/index.html index d68c91ec..e61a62d5 100644 --- a/implementations/web-vanilla/public/index.html +++ b/implementations/web-vanilla/public/index.html @@ -2,25 +2,353 @@ Test SDK page - + + + - diff --git a/lib/mocks/src/server.ts b/lib/mocks/src/server.ts index 62240715..744ba259 100644 --- a/lib/mocks/src/server.ts +++ b/lib/mocks/src/server.ts @@ -5,10 +5,10 @@ import { getHandlers as getContentfulHandlers } from './contentful-handlers' import { getHandlers as getExperienceHandlers } from './experience-handlers' import { getHandlers as getInsightsHandlers } from './insights-handlers' -const CONTENTFUL_BASE_URL = process.env.CONTENTFUL_BASE_URL ?? 'http://localhost/contentful/' -const EXPERIENCE_BASE_URL = process.env.EXPERIENCE_BASE_URL ?? 'http://localhost/experience/' -const INSIGHTS_BASE_URL = process.env.INSIGHTS_BASE_URL ?? 'http://localhost/insights/' -const PORT = Number(process.env.PORT ?? 80) +const CONTENTFUL_BASE_URL = process.env.CONTENTFUL_BASE_URL ?? 'http://localhost:8000/contentful/' +const EXPERIENCE_BASE_URL = process.env.EXPERIENCE_BASE_URL ?? 'http://localhost:8000/experience/' +const INSIGHTS_BASE_URL = process.env.INSIGHTS_BASE_URL ?? 'http://localhost:8000/insights/' +const PORT = Number(process.env.PORT ?? 8000) const app = createServer( ...getContentfulHandlers(CONTENTFUL_BASE_URL), diff --git a/platforms/javascript/node/.env.example b/platforms/javascript/node/.env.example index eb49fa29..8c4628b9 100644 --- a/platforms/javascript/node/.env.example +++ b/platforms/javascript/node/.env.example @@ -1,12 +1,13 @@ VITE_NINETAILED_CLIENT_ID= VITE_NINETAILED_ENVIRONMENT= +VITE_EXPERIENCE_API_BASE_URL= +VITE_INSIGHTS_API_BASE_URL= + VITE_CONTENTFUL_TOKEN= VITE_CONTENTFUL_PREVIEW_TOKEN= VITE_CONTENTFUL_ENVIRONMENT= VITE_CONTENTFUL_SPACE_ID= -VITE_EXPERIENCE_API_BASE_URL= -VITE_INSIGHTS_API_BASE_URL= VITE_CONTENTFUL_CDA_HOST= VITE_CONTENTFUL_BASE_PATH= diff --git a/platforms/javascript/web/.env.example b/platforms/javascript/web/.env.example index eb49fa29..8c4628b9 100644 --- a/platforms/javascript/web/.env.example +++ b/platforms/javascript/web/.env.example @@ -1,12 +1,13 @@ VITE_NINETAILED_CLIENT_ID= VITE_NINETAILED_ENVIRONMENT= +VITE_EXPERIENCE_API_BASE_URL= +VITE_INSIGHTS_API_BASE_URL= + VITE_CONTENTFUL_TOKEN= VITE_CONTENTFUL_PREVIEW_TOKEN= VITE_CONTENTFUL_ENVIRONMENT= VITE_CONTENTFUL_SPACE_ID= -VITE_EXPERIENCE_API_BASE_URL= -VITE_INSIGHTS_API_BASE_URL= VITE_CONTENTFUL_CDA_HOST= VITE_CONTENTFUL_BASE_PATH= diff --git a/platforms/javascript/web/index.html b/platforms/javascript/web/index.html index 1bf3779c..53a3c856 100644 --- a/platforms/javascript/web/index.html +++ b/platforms/javascript/web/index.html @@ -28,29 +28,31 @@ sans-serif; } h1, - main { - max-width: 1040px; - margin-inline: auto; - } main { display: grid; grid-gap: 2rem; grid-template-rows: auto 1fr; grid-template-columns: 1fr auto; + max-width: 1040px; + margin-inline: auto; } - section { - overflow: scroll; - - &:first-of-type { - grid-column: 1 / span 2; - } + section:first-of-type { + grid-column: 1 / span 2; } ol { margin: 0; } + summary { + cursor: pointer; + } form { margin-block: 1rem; } + fieldset { + display: grid; + grid-template-columns: 1fr auto; + grid-gap: 1rem; + } button { cursor: pointer; @@ -69,22 +71,16 @@ } } } - fieldset { - display: grid; - grid-template-columns: 1fr auto; - grid-gap: 1rem; - } - summary { - cursor: pointer; - } - #entry-elements { + #auto-observe, + #manually-observe { display: grid; grid-gap: 2rem; - grid-auto-rows: 50dvh; + grid-auto-rows: 75dvh; justify-items: center; align-items: center; + margin-block-end: 2rem; - > p { + > div { display: grid; width: 100%; height: 100%; @@ -96,10 +92,125 @@ -
- +

Optimization Web SDK Dev Development Dashboard

+ +
+
+

Utilities

+ + + + + + | + + + + + | + + +

+          
+ +
+
+ | + + +

+          
+ +
+
+ | + + +

+          
+ +
+
+
+ +
+

Entry Data

+ +
+
+ Resolve Contentful Entry + + +
+
+ +
    +
    + +
    +

    Event Stream

    + +
      +
      + +
      +

      Entries

      + +
      +
      +
      +
      + + + + + + + + - -

      Optimization Web SDK Dev Development Dashboard

      - -
      -
      -

      States

      + /* --- Entry Form --- */ - - | - - -
      
      -          
      - -
      -
      - | - - -
      
      -          
      - -
      -
      - | - - -
      
      -          
      - -
      -
      -
      - -
      -

      Entries

      - -
      -
      - Resolve Contentful Entry - - -
      -
      - -
        -
        + const form = document.querySelector('#entry-form') + const LAST_ENTRY_ID_KEY = '__ctfl_dev_last_entry_id__' -
        -

        Event Stream

        + form.elements.entryId.value = localStorage.getItem(LAST_ENTRY_ID_KEY) -
          -
          + form.addEventListener('submit', async (event) => { + event.preventDefault() -
          -
          + const data = new FormData(form) + const entryId = data.get('entryId') - + await addPersonalizedEntry(entryId) - + localStorage.setItem(LAST_ENTRY_ID_KEY, entryId) + }) + diff --git a/platforms/javascript/web/src/AutoEntryViewTracking.ts b/platforms/javascript/web/src/AutoEntryViewTracking.ts new file mode 100644 index 00000000..155f6884 --- /dev/null +++ b/platforms/javascript/web/src/AutoEntryViewTracking.ts @@ -0,0 +1,150 @@ +import { + type AnalyticsStateful, + logger, + type PersonalizationStateful, +} from '@contentful/optimization-core' +import type { + ElementExistenceObserverOptions, + ElementViewCallbackInfo, + ElementViewObserver, +} from './observers' + +export type CtflDataset = DOMStringMap & { + ctflEntryId: string + ctflDuplicationScope?: string + ctflPersonalizationId?: string + ctflSticky?: 'true' | 'false' + ctflVariantIndex?: string +} + +export type EntryElement = (HTMLElement | SVGElement) & { dataset: CtflDataset } + +// This does not support legacy browsers that don't support `dataset` on `SVGElement` +export function isEntryElement(element: Element): element is EntryElement { + const isWeb = typeof HTMLElement !== 'undefined' && typeof SVGElement !== 'undefined' + + if (!isWeb || element.nodeType !== 1) return false + + if (!('dataset' in element)) return false + + if (!element.dataset || typeof element.dataset !== 'object') return false + + if (!('ctflEntryId' in element.dataset)) return false + + const { + dataset: { ctflEntryId }, + } = element + + return typeof ctflEntryId === 'string' && ctflEntryId.trim().length > 0 +} + +export interface EntryData { + duplicationScope?: string + entryId: string + personalizationId?: string + sticky?: boolean + variantIndex?: number +} + +export function isEntryData(data?: unknown): data is EntryData { + if (!data) return false + if (typeof data !== 'object') return false + return 'entryId' in data && typeof data.entryId === 'string' && !!data.entryId.trim().length +} + +function parseSticky(sticky: string | undefined): boolean { + return (sticky?.trim().toLowerCase() ?? '') === 'true' +} + +// Only non-negative integers allowed +function parseVariantIndex(variantIndex: string | undefined): number | undefined { + if (variantIndex === undefined || !/^\d+$/.test(variantIndex)) return undefined + const n = Number(variantIndex) + return Number.isSafeInteger(n) ? n : undefined +} + +export const createAutoTrackingEntryViewCallback = + ({ + personalization, + analytics, + }: { + personalization: PersonalizationStateful + analytics: AnalyticsStateful + }) => + async (element: Element, info: ElementViewCallbackInfo): Promise => { + if (!isEntryData(info.data) && !isEntryElement(element)) return + + let duplicationScope: string | undefined = undefined + let entryId: string | undefined = undefined + let personalizationId: string | undefined = undefined + let sticky: boolean | undefined = undefined + let variantIndex: number | undefined = undefined + + if (isEntryData(info.data)) { + ;({ + data: { duplicationScope, entryId, personalizationId, sticky, variantIndex }, + } = info) + } else if (isEntryElement(element)) { + ;({ + dataset: { + ctflDuplicationScope: duplicationScope, + ctflEntryId: entryId, + ctflPersonalizationId: personalizationId, + }, + } = element) + + const { + dataset: { ctflSticky, ctflVariantIndex }, + } = element + + sticky = parseSticky(ctflSticky) + variantIndex = parseVariantIndex(ctflVariantIndex) + } + + if (!entryId) { + logger.warn( + '[Element View Observer Callback] No entry data found; please add data attributes or observe with data info', + ) + return + } + + if (sticky) + await personalization.trackComponentView( + { + componentId: entryId, + experienceId: personalizationId, + variantIndex, + }, + duplicationScope, + ) + + await analytics.trackComponentView( + { + componentId: entryId, + experienceId: personalizationId, + variantIndex, + }, + duplicationScope, + ) + } + +export const createAutoTrackingEntryExistenceCallback = ( + entryViewObserver: ElementViewObserver, +): ElementExistenceObserverOptions => ({ + onRemoved: (elements: readonly Element[]): void => { + elements.forEach((element) => { + if (!entryViewObserver.getStats(element)) return + + logger.info('[Optimization Web SDK] Auto-removing element:', element) + entryViewObserver.unobserve(element) + }) + }, + onAdded: (elements: readonly Element[]): void => { + elements.forEach((element) => { + if (!isEntryElement(element)) return + + logger.info('[Optimization Web SDK] Auto-observing element:', element) + entryViewObserver.observe(element) + }) + }, +}) diff --git a/platforms/javascript/web/src/Optimization.ts b/platforms/javascript/web/src/Optimization.ts index 0a99209c..d9ae8e5a 100644 --- a/platforms/javascript/web/src/Optimization.ts +++ b/platforms/javascript/web/src/Optimization.ts @@ -9,12 +9,16 @@ import { } from '@contentful/optimization-core' import { merge } from 'es-toolkit' import Cookies from 'js-cookie' +import { + createAutoTrackingEntryExistenceCallback, + createAutoTrackingEntryViewCallback, + isEntryElement, +} from './AutoEntryViewTracking' import { beaconHandler } from './beacon' import { getAnonymousId, getLocale, getPageProperties, getUserAgent } from './builders' import { ANONYMOUS_ID_COOKIE } from './global-constants' import { ElementExistenceObserver, - type ElementViewCallbackInfo, type ElementViewElementOptions, ElementViewObserver, type ElementViewOptions, @@ -33,49 +37,6 @@ export interface OptimizationWebConfig extends CoreStatefulConfig { elementViewObserveOptions?: ElementViewOptions } -export type CtflDataset = DOMStringMap & { - ctflEntryId: string - ctflDuplicationScope?: string - ctflPersonalizationId?: string - ctflSticky?: 'true' | 'false' - ctflVariantIndex?: string -} - -export type EntryElement = (HTMLElement | SVGElement) & { dataset: CtflDataset } - -// This does not support legacy browsers that don't support `dataset` on `SVGElement` -export function isEntryElement(element: Element): element is EntryElement { - const isWeb = typeof HTMLElement !== 'undefined' && typeof SVGElement !== 'undefined' - - if (!isWeb || element.nodeType !== 1) return false - - if (!('dataset' in element)) return false - - if (!element.dataset || typeof element.dataset !== 'object') return false - - if (!('ctflEntryId' in element.dataset)) return false - - const { - dataset: { ctflEntryId }, - } = element - - return typeof ctflEntryId === 'string' && ctflEntryId.trim().length > 0 -} - -export interface EntryData { - duplicationScope?: string - entryId: string - personalizationId?: string - sticky?: boolean - variantIndex?: number -} - -export function isEntryData(data?: unknown): data is EntryData { - if (!data) return false - if (typeof data !== 'object') return false - return 'entryId' in data && typeof data.entryId === 'string' && !!data.entryId.trim().length -} - function mergeConfig({ app, defaults, @@ -123,20 +84,9 @@ function mergeConfig({ ) } -function parseSticky(sticky: string | undefined): boolean { - return (sticky?.trim().toLowerCase() ?? '') === 'true' -} - -// Only non-negative integers allowed -function parseVariantIndex(variantIndex: string | undefined): number | undefined { - if (variantIndex === undefined || !/^\d+$/.test(variantIndex)) return undefined - const n = Number(variantIndex) - return Number.isSafeInteger(n) ? n : undefined -} - class Optimization extends CoreStateful { - readonly #elementViewObserver: ElementViewObserver - readonly #elementExistenceObserver: ElementExistenceObserver + #elementViewObserver?: ElementViewObserver = undefined + #elementExistenceObserver?: ElementExistenceObserver = undefined constructor(config: OptimizationWebConfig) { if (window.optimization) throw new Error('Optimization is already initialized') @@ -185,104 +135,53 @@ class Optimization extends CoreStateful { LocalStore.personalizations = value }) + } + autoTrackEntryViews(options?: ElementViewElementOptions): void { this.#elementViewObserver = new ElementViewObserver( - async (element: Element, info: ElementViewCallbackInfo) => { - if (!isEntryData(info.data) && !isEntryElement(element)) return - - let duplicationScope: string | undefined = undefined - let entryId: string | undefined = undefined - let personalizationId: string | undefined = undefined - let sticky: boolean | undefined = undefined - let variantIndex: number | undefined = undefined - - if (isEntryData(info.data)) { - ;({ - data: { duplicationScope, entryId, personalizationId, sticky, variantIndex }, - } = info) - } else if (isEntryElement(element)) { - ;({ - dataset: { - ctflDuplicationScope: duplicationScope, - ctflEntryId: entryId, - ctflPersonalizationId: personalizationId, - }, - } = element) - - const { - dataset: { ctflSticky, ctflVariantIndex }, - } = element - - sticky = parseSticky(ctflSticky) - variantIndex = parseVariantIndex(ctflVariantIndex) - } - - if (!entryId) { - logger.warn( - '[Element View Observer Callback] No entry data found; please add data attributes or observe with data info', - ) - return - } - - if (sticky) - await this.personalization.trackComponentView( - { - componentId: entryId, - experienceId: personalizationId, - variantIndex, - }, - duplicationScope, - ) - - await this.analytics.trackComponentView( - { - componentId: entryId, - experienceId: personalizationId, - variantIndex, - }, - duplicationScope, - ) - }, + createAutoTrackingEntryViewCallback({ + personalization: this.personalization, + analytics: this.analytics, + }), ) - this.#elementExistenceObserver = new ElementExistenceObserver({ - onRemoved: (elements: readonly Element[]): void => { - elements.forEach((element) => { - if (!this.#elementViewObserver.getStats(element)) return - - logger.info('[Optimization Web SDK] Auto-removing element:', element) - this.#elementViewObserver.unobserve(element) - }) - }, - onAdded: (elements: readonly Element[]): void => { - elements.forEach((element) => { - if (!isEntryElement(element)) return - - logger.info('[Optimization Web SDK] Auto-observing element:', element) - this.#elementViewObserver.observe(element) - }) - }, - }) - - window.optimization = this - } + this.#elementExistenceObserver = new ElementExistenceObserver( + createAutoTrackingEntryExistenceCallback(this.#elementViewObserver), + ) - autoTrackEntryViews(options?: ElementViewElementOptions): void { + // Fully-automated observation for elements with ctfl data attributes const entries = document.querySelectorAll('[data-ctfl-entry-id]') entries.forEach((element) => { if (!isEntryElement(element)) return logger.info('[Optimization Web SDK] Auto-observing element (init):', element) - this.#elementViewObserver.observe(element, { + + this.#elementViewObserver?.observe(element, { ...options, }) }) } + trackEntryViewForElement(element: Element, options?: ElementViewElementOptions): void { + logger.info('[Optimization Web SDK] Manually observing element:', element) + this.#elementViewObserver?.observe(element, options) + } + + untrackEntryViewForElement(element: Element): void { + this.#elementViewObserver?.observe(element) + } + disconnectAutoTrackEntryViews(): void { - this.#elementExistenceObserver.disconnect() - this.#elementViewObserver.disconnect() + this.#elementExistenceObserver?.disconnect() + this.#elementViewObserver?.disconnect() + } + + reset(): void { + this.disconnectAutoTrackEntryViews() + Cookies.remove(ANONYMOUS_ID_COOKIE) + LocalStore.reset() + super.reset() } } diff --git a/platforms/javascript/web/src/index.ts b/platforms/javascript/web/src/index.ts index 312ebae4..a53fced3 100644 --- a/platforms/javascript/web/src/index.ts +++ b/platforms/javascript/web/src/index.ts @@ -1,8 +1,6 @@ -import Optimization from './Optimization' +export { default as Optimization } from './Optimization' export * from './beacon/beaconHandler' export * from './builders/EventBuilder' export * from './global-constants' export * from './storage/LocalStore' - -export default Optimization diff --git a/platforms/javascript/web/src/observers/ElementViewObserver.ts b/platforms/javascript/web/src/observers/ElementViewObserver.ts index 24d7c64c..3114eb4a 100644 --- a/platforms/javascript/web/src/observers/ElementViewObserver.ts +++ b/platforms/javascript/web/src/observers/ElementViewObserver.ts @@ -65,10 +65,10 @@ class ElementViewObserver { } /** Observe an element with optional per-element overrides and data. */ - public observe(element: Element, perElement?: ElementViewElementOptions): void { + public observe(element: Element, options?: ElementViewElementOptions): void { let state = this.states.get(element) if (!state) { - state = this.createState(element, perElement) + state = this.createState(element, options) this.states.set(element, state) this.activeStates.add(state) this.ensureSweeper() diff --git a/platforms/javascript/web/src/storage/LocalStore.ts b/platforms/javascript/web/src/storage/LocalStore.ts index e1265cf9..7666e909 100644 --- a/platforms/javascript/web/src/storage/LocalStore.ts +++ b/platforms/javascript/web/src/storage/LocalStore.ts @@ -9,6 +9,16 @@ export const PROFILE_CACHE = '__ctfl_opt_profile__' export const PERSONALIZATIONS_CACHE = '__ctfl_opt_personalizations__' const LocalStore = { + reset(options = { resetConsent: false, resetDebug: false }) { + if (options.resetConsent) localStorage.removeItem(CONSENT) + if (options.resetDebug) localStorage.removeItem(DEBUG_FLAG) + + localStorage.removeItem(ANONYMOUS_ID) + localStorage.removeItem(CHANGES_CACHE) + localStorage.removeItem(PROFILE_CACHE) + localStorage.removeItem(PERSONALIZATIONS_CACHE) + }, + get anonymousId(): string | undefined { return localStorage.getItem(ANONYMOUS_ID) ?? undefined },