diff --git a/platforms/javascript/node/index.pug b/platforms/javascript/node/index.pug index 9b97483c..31b35ba3 100644 --- a/platforms/javascript/node/index.pug +++ b/platforms/javascript/node/index.pug @@ -2,6 +2,128 @@ doctype html html(lang="en") head title Optimization Node SDK Dev Development Dashboard + + style. + html, + textarea, + input, + button { + font-family: + -apple-system, + BlinkMacSystemFont, + avenir next, + avenir, + segoe ui, + helvetica neue, + Adwaita Sans, + Cantarell, + Ubuntu, + roboto, + noto, + helvetica, + arial, + sans-serif; + } + h1, + main { + max-width: 1040px; + margin-inline: auto; + } + main { + display: grid; + grid-gap: 2rem; + grid-template-rows: auto 1fr; + } + section { + overflow: scroll; + + &:first-of-type { + grid-column: 1 / span 2; + } + } + ol { + margin: 0; + } + form { + margin-block: 1rem; + } + button { + cursor: pointer; + + &:not([type]) { + appearance: none; + background: transparent; + border: 0; + padding: 0; + font-family: inherit; + font-size: inherit; + text-decoration: underline; + + &:hover, + &:active { + color: blue; + } + } + } + fieldset { + display: grid; + grid-template-columns: 1fr auto; + grid-gap: 1rem; + } + summary { + cursor: pointer; + } + body h1 Optimization Node SDK Dev Development Dashboard - pre= fullProfile + + main + section + h2 States + + span#consent= consent ? 'Consented' : 'Unconsented' + | + | | + | + button Custom Flags + dialog + pre= JSON.stringify(flags, undefined, 2) + form(method='dialog') + button(type='submit') Close + | + | | + | + button Profile + dialog + pre= JSON.stringify(profile, undefined, 2) + form(method='dialog') + button(type='submit') Close + | + | | + | + button Personalizations + dialog + pre= JSON.stringify(personalizations, undefined, 2) + form(method='dialog') + button(type='submit') Close + + section + h2 Entries + + ol + each entry in entries + li + button= entry && entry.fields ? entry.fields.internalTitle : 'oops' + dialog + pre= entry + form(method='dialog') + button(type='submit') Close + + script. + const triggers = document.querySelectorAll('section > button, li > button') + triggers.forEach((trigger) => { + trigger.addEventListener('click', () => { + trigger.nextElementSibling.showModal() + }) + }) + diff --git a/platforms/javascript/node/package.json b/platforms/javascript/node/package.json index c758a7b0..5e061153 100644 --- a/platforms/javascript/node/package.json +++ b/platforms/javascript/node/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@types/express": "catalog:", "@vitest/coverage-v8": "catalog:", + "contentful": "catalog:", "express": "catalog:", "express-rate-limit": "catalog:", "pug": "^3.0.3", diff --git a/platforms/javascript/node/server.ts b/platforms/javascript/node/server.ts index 0cb2d360..9cbcd8dd 100644 --- a/platforms/javascript/node/server.ts +++ b/platforms/javascript/node/server.ts @@ -1,8 +1,18 @@ +import type { SelectedPersonalizationArray } from '@contentful/optimization-api-schemas' +import type { Entry } from 'contentful' +import * as contentful from 'contentful' import express, { type Express } from 'express' import rateLimit from 'express-rate-limit' import path from 'node:path' import Optimization from './src' +interface ContentEntrySkeleton { + contentTypeId: 'content' + fields: { + text: contentful.EntryFieldTypes.Text + } +} + const limiter = rateLimit({ windowMs: 900_000, max: 100, @@ -14,19 +24,68 @@ app.use(limiter) app.set('view engine', 'pug') // configure Pug as the view engine app.set('views', path.join(__dirname, '.')) // define the directory for view templates +const sdk = new Optimization({ + clientId: process.env.VITE_NINETAILED_CLIENT_ID ?? '', + environment: process.env.VITE_NINETAILED_ENVIRONMENT ?? '', + logLevel: 'debug', + api: { + analytics: { baseUrl: process.env.VITE_INSIGHTS_API_BASE_URL }, + personalization: { baseUrl: process.env.VITE_EXPERIENCE_API_BASE_URL }, + }, +}) + +const ctfl = contentful.createClient({ + accessToken: process.env.VITE_CONTENTFUL_TOKEN ?? '', + environment: process.env.VITE_CONTENTFUL_ENVIRONMENT ?? '', + space: process.env.VITE_CONTENTFUL_SPACE_ID ?? '', + host: process.env.VITE_CONTENTFUL_CDA_HOST ?? '', + basePath: process.env.VITE_CONTENTFUL_BASE_PATH ?? '', + insecure: Boolean(process.env.VITE_CONTENTFUL_CDA_HOST), +}) + +async function getEntries( + personalizations: SelectedPersonalizationArray = [], +): Promise>> { + const possibleEntries: Array | undefined> = await Promise.all( + personalizations.map(async ({ variants }) => { + const baselines = Object.keys(variants) + + if (!baselines.length || !baselines[0]) return + + try { + return await ctfl.getEntry(baselines[0], { + include: 10, + }) + } catch (_error) {} + }), + ) + + const entries: Array> = possibleEntries.filter( + (entry): entry is Entry => entry !== undefined, + ) + + return entries +} + app.get('/', limiter, async (_req, res) => { - const sdk = new Optimization({ - clientId: process.env.VITE_NINETAILED_CLIENT_ID ?? '', - environment: process.env.VITE_NINETAILED_ENVIRONMENT ?? '', - logLevel: 'debug', - api: { - analytics: { baseUrl: process.env.VITE_INSIGHTS_API_BASE_URL }, - personalization: { baseUrl: process.env.VITE_EXPERIENCE_API_BASE_URL }, - }, - }) - - const fullProfile = await sdk.personalization.page({}) - res.render('index', { fullProfile: JSON.stringify(fullProfile, undefined, 2) }) + const { profile, personalizations, changes } = + (await sdk.personalization.identify({ + userId: 'charles', + })) ?? {} + + const entries = await getEntries(personalizations) + + const pageData = { + consent: false, // Consent is handled manually, server-side + profile, + personalizations, + entries: entries.map((entry) => + sdk.personalization.personalizedEntryResolver.resolve(entry, personalizations), + ), + flags: sdk.personalization.flagsResolver.resolve(changes), + } + + res.render('index', { ...pageData }) }) const port = 3000 diff --git a/platforms/javascript/web/index.html b/platforms/javascript/web/index.html index de249c14..2a45f05c 100644 --- a/platforms/javascript/web/index.html +++ b/platforms/javascript/web/index.html @@ -249,7 +249,7 @@

Optimization Web SDK Dev Development Dashboard

-

States

+

States

| @@ -279,7 +279,7 @@

States

-

Entries

+

Entries

@@ -293,7 +293,7 @@

Entries

-

Event Stream

+

Event Stream

    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7494f0d..5a018759 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -420,6 +420,9 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 3.2.4(vitest@3.2.4(@types/node@24.2.0)(happy-dom@20.0.2)(jiti@2.5.1)(msw@2.10.5(@types/node@24.2.0)(typescript@5.9.2))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) + contentful: + specifier: 'catalog:' + version: 11.8.5 express: specifier: 'catalog:' version: 5.1.0 diff --git a/universal/api-client/src/builders/EventBuilder.ts b/universal/api-client/src/builders/EventBuilder.ts index 424a9c60..f3b16a29 100644 --- a/universal/api-client/src/builders/EventBuilder.ts +++ b/universal/api-client/src/builders/EventBuilder.ts @@ -43,7 +43,7 @@ const ComponentViewBuilderArgs = z.extend(UniversalEventBuilderArgs, { export type ComponentViewBuilderArgs = z.infer const IdentifyBuilderArgs = z.extend(UniversalEventBuilderArgs, { - traits: Traits, + traits: z.optional(Traits), userId: z.string(), }) export type IdentifyBuilderArgs = z.infer @@ -145,7 +145,7 @@ class EventBuilder { } buildIdentify(args: IdentifyBuilderArgs): IdentifyEvent { - const { traits, userId, ...universal } = IdentifyBuilderArgs.parse(args) + const { traits = {}, userId, ...universal } = IdentifyBuilderArgs.parse(args) return { ...this.buildUniversalEventProperties(universal), diff --git a/universal/core/src/personalization/resolvers/FlagsResolver.ts b/universal/core/src/personalization/resolvers/FlagsResolver.ts index bef847f0..dec51561 100644 --- a/universal/core/src/personalization/resolvers/FlagsResolver.ts +++ b/universal/core/src/personalization/resolvers/FlagsResolver.ts @@ -1,7 +1,9 @@ import type { ChangeArray, Flags } from '@contentful/optimization-api-client' const FlagsResolver = { - resolve(changes: ChangeArray): Flags { + resolve(changes?: ChangeArray): Flags { + if (!changes) return {} + return ( changes // .filter((change): change is VariableChange => change.type === 'Variable') diff --git a/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts b/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts index 39949bf3..2f5375ab 100644 --- a/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts +++ b/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts @@ -7,6 +7,7 @@ import { isPersonalizedEntry, type PersonalizationEntry, type PersonalizedEntry, + type SelectedPersonalization, type SelectedPersonalizationArray, } from '@contentful/optimization-api-client' import type { Entry } from 'contentful' @@ -18,51 +19,52 @@ const PersonalizedEntryResolver = { getPersonalizationEntry( { personalizedEntry, - personalizations, + selectedPersonalizations, }: { personalizedEntry: PersonalizedEntry - personalizations: SelectedPersonalizationArray + selectedPersonalizations: SelectedPersonalizationArray }, skipValidation = false, ): PersonalizationEntry | undefined { - if (!skipValidation && (!personalizations.length || !isPersonalizedEntry(personalizedEntry))) + if ( + !skipValidation && + (!selectedPersonalizations.length || !isPersonalizedEntry(personalizedEntry)) + ) return const personalizationEntry = personalizedEntry.fields.nt_experiences .filter((maybePersonalization) => isPersonalizationEntry(maybePersonalization)) .find((personalization) => - personalizations.some( + selectedPersonalizations.some( (selectedPersonalization) => selectedPersonalization.experienceId === personalization.sys.id, ), ) - personalizedEntry.fields.nt_sticky = personalizationEntry?.fields.nt_config?.sticky ?? false - return personalizationEntry }, - getSelectedVariantIndex( + getSelectedPersonalization( { personalizationEntry, - personalizations, + selectedPersonalizations, }: { personalizationEntry: PersonalizationEntry - personalizations: SelectedPersonalizationArray + selectedPersonalizations: SelectedPersonalizationArray }, skipValidation = false, - ) { + ): SelectedPersonalization | undefined { if ( !skipValidation && - (!personalizations.length || !isPersonalizationEntry(personalizationEntry)) + (!selectedPersonalizations.length || !isPersonalizationEntry(personalizationEntry)) ) - return 0 + return - const selectedPersonalization = personalizations.find( + const selectedPersonalization = selectedPersonalizations.find( ({ experienceId }) => experienceId === personalizationEntry.sys.id, ) - return selectedPersonalization?.variantIndex ?? 0 + return selectedPersonalization }, getSelectedVariant( @@ -115,20 +117,24 @@ const PersonalizedEntryResolver = { (variant) => variant.sys.id === selectedVariant.id, ) - const { - fields: { nt_config: originalEntryConfig }, - } = personalizationEntry - - if (selectedVariantEntry) selectedVariantEntry.fields.nt_config = originalEntryConfig - return selectedVariantEntry }, - resolve(entry: Entry, personalizations?: SelectedPersonalizationArray): Entry { + decorateSelectedVariantFields( + selectedVariantEntry: Entry, + selectedPersonalization: SelectedPersonalization | undefined, + ) { + selectedVariantEntry.fields.nt_personalization = selectedPersonalization ?? {} + }, + + resolve(entry: Entry, selectedPersonalizations?: SelectedPersonalizationArray): Entry { logger.info('[Personalization] Resolving personalized entry for baseline entry', entry.sys.id) - if (!personalizations?.length) { - logger.warn(RESOLUTION_WARNING_BASE, 'no personalizations exist for the current profile') + if (!selectedPersonalizations?.length) { + logger.warn( + RESOLUTION_WARNING_BASE, + 'no selectedPersonalizations exist for the current profile', + ) return entry } @@ -140,7 +146,7 @@ const PersonalizedEntryResolver = { const personalizationEntry = PersonalizedEntryResolver.getPersonalizationEntry( { personalizedEntry: entry, - personalizations, + selectedPersonalizations, }, true, ) @@ -153,14 +159,16 @@ const PersonalizedEntryResolver = { return entry } - const selectedVariantIndex = PersonalizedEntryResolver.getSelectedVariantIndex( + const selectedPersonalization = PersonalizedEntryResolver.getSelectedPersonalization( { personalizationEntry, - personalizations, + selectedPersonalizations, }, true, ) + const selectedVariantIndex = selectedPersonalization?.variantIndex ?? 0 + if (selectedVariantIndex === 0) { logger.info( `[Personalization] Resolved personalization entry for entry ${entry.sys.id} is baseline`, @@ -206,6 +214,11 @@ const PersonalizedEntryResolver = { ) } + PersonalizedEntryResolver.decorateSelectedVariantFields( + selectedVariantEntry, + selectedPersonalization, + ) + return selectedVariantEntry }, }