diff --git a/libs/blocks/brand-concierge-global/brand-concierge-global.js b/libs/blocks/brand-concierge-global/brand-concierge-global.js index c2827aa6617..5179a350859 100644 --- a/libs/blocks/brand-concierge-global/brand-concierge-global.js +++ b/libs/blocks/brand-concierge-global/brand-concierge-global.js @@ -14,6 +14,7 @@ import { openSideModal, setAuthoredContent, } from '../brand-concierge/bc-bootstrap.js'; +import { initAnalytics } from '../brand-concierge/bc-analytics.js'; let stayActive = false; @@ -150,6 +151,8 @@ export default function init(el) { } }); + initAnalytics(); + const rows = el.querySelectorAll(':scope > div'); const [cards, input] = rows; setAuthoredContent(null, cards, input); diff --git a/libs/blocks/brand-concierge/bc-analytics.js b/libs/blocks/brand-concierge/bc-analytics.js index d79d3108a20..7fc61cad5d1 100644 --- a/libs/blocks/brand-concierge/bc-analytics.js +++ b/libs/blocks/brand-concierge/bc-analytics.js @@ -1,11 +1,49 @@ import { getConfig } from '../../utils/utils.js'; +import { getChatSessionId } from './bc-utils.js'; export function getAnalyticsLabel(step) { return `Filters|${getConfig()?.brandConciergeAA ? getConfig()?.brandConciergeAA : 'app-reco'}|bc#${step}`; } +const recordNavClick = ({ clickType, destinationPage, clickSource } = {}) => { + window.history.replaceState( + { + ...window.history.state, + bcClickType: clickType, + bcSourcePage: window.location.href, + bcDestinationPage: destinationPage ?? '', + bcClickSource: clickSource ?? '', + }, + '', + ); +}; + +const handleNav = (event) => { + switch (event.eventType) { + case 'card:clicked': + recordNavClick({ + clickType: 'product_card_cta', + destinationPage: event.data?.destinationUrl, + }); + break; + case 'cta:clicked': + recordNavClick({ clickType: 'cta', clickSource: event.data?.source }); + break; + case 'link:clicked': + recordNavClick({ + clickType: event.data?.element?.linkType ?? 'inline_hyperlink', + destinationPage: event.data?.element?.href, + }); + break; + default: + break; + } +}; + /* eslint-disable no-undef, no-underscore-dangle */ export const bcAnalytics = (event) => { + handleNav(event); + if (window?._satellite?.track) { switch (event.eventType) { case 'query:submitted': @@ -112,8 +150,74 @@ export const bcAnalytics = (event) => { }, }); break; + case 'navigation:backNavigation': { + const { + clickType = 'unknown', + sessionId = '', + sourcePage = '', + destinationPage = '', + clickSource = '', + loginStatus = '', + navigatedBack = true, + } = event.data ?? {}; + const eventName = `BC-chat_${clickType}_back_navigation`; + _satellite.track('event', { + data: { + web: { webInteraction: { name: `${eventName}|loginStatus:${loginStatus}` } }, + bc: { + eventName, + timestamp: new Date().toISOString(), + sessionId, + clickType, + sourcePage, + destinationPage, + clickSource, + navigatedBack, + loginStatus, + }, + _adobe_corpnew: { digitalData: { primaryEvent: { eventInfo: { interaction: { click: `${eventName}|session:${sessionId}|from:${sourcePage}|to:${destinationPage}|source:${clickSource}|nav:${navigatedBack}|loginStatus:${loginStatus}` } } } } }, + }, + }); + break; + } default: break; } } }; + +const initBackNavAnalytics = () => { + const emitAnalyticsIfBackNav = () => { + const state = window.history.state ?? {}; + if (!state.bcClickType) { + return; + } + + bcAnalytics({ + eventType: 'navigation:backNavigation', + data: { + clickType: state.bcClickType, + sessionId: getChatSessionId(), + sourcePage: state.bcSourcePage ?? window.location.href, + destinationPage: state.bcDestinationPage ?? '', + clickSource: state.bcClickSource ?? '', + loginStatus: window.adobeIMS?.isSignedInUser() ? 'logged-in' : 'logged-out', + navigatedBack: true, + }, + }); + }; + + // bfcache restore: page is brought back from memory, init does not re-run. + window.addEventListener('pageshow', (e) => { + if (e.persisted) emitAnalyticsIfBackNav(); + }); + + // full-page back/forward load: init re-runs, navigation type is back_forward. + if (window.performance.getEntriesByType('navigation')[0]?.type === 'back_forward') { + emitAnalyticsIfBackNav(); + } +}; + +export const initAnalytics = () => { + initBackNavAnalytics(); +}; diff --git a/libs/blocks/brand-concierge/bc-utils.js b/libs/blocks/brand-concierge/bc-utils.js index cd02e7b1f1b..4a4b9c72e5e 100644 --- a/libs/blocks/brand-concierge/bc-utils.js +++ b/libs/blocks/brand-concierge/bc-utils.js @@ -27,18 +27,28 @@ const getTargetHeight = (target) => { return target.scrollHeight + (parseFloat(marginBottom) * 2); }; +const BC_SESSION_COOKIE = 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_bc_session_id'; + export function hasChatCookie() { const cookies = document.cookie.split(';'); - const cookieName = 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_bc_session_id'; for (let i = 0; i < cookies.length; i += 1) { const cookie = cookies[i].trim(); - if (cookie.includes(cookieName)) { + if (cookie.includes(BC_SESSION_COOKIE)) { return true; } } return false; } +/** Get session ID used to correlate analytics events */ +export function getChatSessionId() { + const match = document.cookie + .split(';') + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(`${BC_SESSION_COOKIE}=`)); + return match ? decodeURIComponent(match.slice(BC_SESSION_COOKIE.length + 1)) : ''; +} + export function setCssGnavHeight() { const gnav = document.querySelector('header.global-navigation'); const localGnav = document.querySelector('div.feds-localnav'); diff --git a/libs/blocks/brand-concierge/brand-concierge.js b/libs/blocks/brand-concierge/brand-concierge.js index 7566e569485..5952304399a 100644 --- a/libs/blocks/brand-concierge/brand-concierge.js +++ b/libs/blocks/brand-concierge/brand-concierge.js @@ -1,4 +1,5 @@ import { createTag } from '../../utils/utils.js'; +import { initAnalytics } from './bc-analytics.js'; import { decorateBackground, decorateMarqueeBackground, @@ -86,6 +87,8 @@ export default async function init(el) { } }); + initAnalytics(); + setCssGnavHeight(); const rows = el.querySelectorAll(':scope > div'); diff --git a/libs/c2/blocks/brand-concierge/bc-analytics.js b/libs/c2/blocks/brand-concierge/bc-analytics.js index 21485196638..edec7c818c1 100644 --- a/libs/c2/blocks/brand-concierge/bc-analytics.js +++ b/libs/c2/blocks/brand-concierge/bc-analytics.js @@ -1,11 +1,49 @@ import { getConfig } from '../../../utils/utils.js'; +import { getChatSessionId } from './bc-utils.js'; export function getAnalyticsLabel(step) { return `Filters|${getConfig()?.brandConciergeAA ? getConfig()?.brandConciergeAA : 'app-reco'}|bc#${step}`; } +const recordNavClick = ({ clickType, destinationPage, clickSource } = {}) => { + window.history.replaceState( + { + ...window.history.state, + bcClickType: clickType, + bcSourcePage: window.location.href, + bcDestinationPage: destinationPage ?? '', + bcClickSource: clickSource ?? '', + }, + '', + ); +}; + +const handleNav = (event) => { + switch (event.eventType) { + case 'card:clicked': + recordNavClick({ + clickType: 'product_card_cta', + destinationPage: event.data?.destinationUrl, + }); + break; + case 'cta:clicked': + recordNavClick({ clickType: 'cta', clickSource: event.data?.source }); + break; + case 'link:clicked': + recordNavClick({ + clickType: event.data?.element?.linkType ?? 'inline_hyperlink', + destinationPage: event.data?.element?.href, + }); + break; + default: + break; + } +}; + /* eslint-disable no-undef, no-underscore-dangle */ export const bcAnalytics = (event) => { + handleNav(event); + if (window?._satellite?.track) { switch (event.eventType) { case 'query:submitted': @@ -112,8 +150,74 @@ export const bcAnalytics = (event) => { }, }); break; + case 'navigation:backNavigation': { + const { + clickType = 'unknown', + sessionId = '', + sourcePage = '', + destinationPage = '', + clickSource = '', + loginStatus = '', + navigatedBack = true, + } = event.data ?? {}; + const eventName = `BC-chat_${clickType}_back_navigation`; + _satellite.track('event', { + data: { + web: { webInteraction: { name: `${eventName}|loginStatus:${loginStatus}` } }, + bc: { + eventName, + timestamp: new Date().toISOString(), + sessionId, + clickType, + sourcePage, + destinationPage, + clickSource, + navigatedBack, + loginStatus, + }, + _adobe_corpnew: { digitalData: { primaryEvent: { eventInfo: { interaction: { click: `${eventName}|session:${sessionId}|from:${sourcePage}|to:${destinationPage}|source:${clickSource}|nav:${navigatedBack}|loginStatus:${loginStatus}` } } } } }, + }, + }); + break; + } default: break; } } }; + +const initBackNavAnalytics = () => { + const emitAnalyticsIfBackNav = () => { + const state = window.history.state ?? {}; + if (!state.bcClickType) { + return; + } + + bcAnalytics({ + eventType: 'navigation:backNavigation', + data: { + clickType: state.bcClickType, + sessionId: getChatSessionId(), + sourcePage: state.bcSourcePage ?? window.location.href, + destinationPage: state.bcDestinationPage ?? '', + clickSource: state.bcClickSource ?? '', + loginStatus: window.adobeIMS?.isSignedInUser() ? 'logged-in' : 'logged-out', + navigatedBack: true, + }, + }); + }; + + // bfcache restore: page is brought back from memory, init does not re-run. + window.addEventListener('pageshow', (e) => { + if (e.persisted) emitAnalyticsIfBackNav(); + }); + + // full-page back/forward load: init re-runs, navigation type is back_forward. + if (window.performance.getEntriesByType('navigation')[0]?.type === 'back_forward') { + emitAnalyticsIfBackNav(); + } +}; + +export const initAnalytics = () => { + initBackNavAnalytics(); +}; diff --git a/libs/c2/blocks/brand-concierge/bc-utils.js b/libs/c2/blocks/brand-concierge/bc-utils.js index fd23595c528..6872c36eb02 100644 --- a/libs/c2/blocks/brand-concierge/bc-utils.js +++ b/libs/c2/blocks/brand-concierge/bc-utils.js @@ -27,6 +27,17 @@ const getTargetHeight = (target) => { return target.scrollHeight + (parseFloat(marginBottom) * 2); }; +const BC_SESSION_COOKIE = 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_bc_session_id'; + +/** Get session ID used to correlate analytics events */ +export function getChatSessionId() { + const match = document.cookie + .split(';') + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(`${BC_SESSION_COOKIE}=`)); + return match ? decodeURIComponent(match.slice(BC_SESSION_COOKIE.length + 1)) : ''; +} + export function handleConsent(el) { if (!window.adobePrivacy) return; const cookieGrp = window.adobePrivacy.activeCookieGroups(); diff --git a/libs/c2/blocks/brand-concierge/brand-concierge.js b/libs/c2/blocks/brand-concierge/brand-concierge.js index ffdca8612b7..0653248a893 100644 --- a/libs/c2/blocks/brand-concierge/brand-concierge.js +++ b/libs/c2/blocks/brand-concierge/brand-concierge.js @@ -1,4 +1,5 @@ import { createTag } from '../../../utils/utils.js'; +import { initAnalytics } from './bc-analytics.js'; import { decorateBackground, decorateMarqueeBackground, @@ -81,6 +82,8 @@ export default async function init(el) { } }); + initAnalytics(); + const rows = el.querySelectorAll(':scope > div'); const [background, header, cards, input, legal] = rows; diff --git a/test/blocks/brand-concierge/brand-concierge.test.js b/test/blocks/brand-concierge/brand-concierge.test.js index 8f2916f010e..bb9df477e9a 100644 --- a/test/blocks/brand-concierge/brand-concierge.test.js +++ b/test/blocks/brand-concierge/brand-concierge.test.js @@ -7,8 +7,9 @@ import { setConfig } from '../../../libs/utils/utils.js'; setConfig({ codeRoot: '/libs', brandConciergeAA: 'testAA' }); const { default: init } = await import('../../../libs/blocks/brand-concierge/brand-concierge.js'); -const { updateReplicatedValue } = await import('../../../libs/blocks/brand-concierge/bc-utils.js'); +const { updateReplicatedValue, getChatSessionId } = await import('../../../libs/blocks/brand-concierge/bc-utils.js'); const { getUpdatedChatUIConfig, createSusiComponentForModal } = await import('../../../libs/blocks/brand-concierge/bc-bootstrap.js'); +const { bcAnalytics } = await import('../../../libs/blocks/brand-concierge/bc-analytics.js'); describe('Brand Concierge', () => { afterEach(() => { @@ -467,3 +468,94 @@ describe('Brand Concierge', () => { // Should not throw even without onSuccessfulToken handler }); }); + +/* eslint-disable no-underscore-dangle */ +describe('Brand Concierge back-navigation analytics', () => { + const SESSION_COOKIE = 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_bc_session_id'; + + afterEach(() => { + document.cookie = `${SESSION_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + delete window._satellite; + sinon.restore(); + }); + + describe('getChatSessionId', () => { + it('returns the decoded session id from the cookie', () => { + document.cookie = `${SESSION_COOKIE}=${encodeURIComponent('sess 123')}; path=/`; + expect(getChatSessionId()).to.equal('sess 123'); + }); + + it('returns an empty string when the cookie is absent', () => { + expect(getChatSessionId()).to.equal(''); + }); + }); + + describe('bcAnalytics navigation:backNavigation', () => { + it('tracks a back-navigation event with all required fields', () => { + const track = sinon.spy(); + window._satellite = { track }; + + bcAnalytics({ + eventType: 'navigation:backNavigation', + data: { + clickType: 'inline_hyperlink', + sessionId: 'sess-1', + sourcePage: 'https://www.adobe.com/source', + destinationPage: 'https://www.adobe.com/dest', + loginStatus: 'logged-in', + navigatedBack: true, + }, + }); + + expect(track.calledOnce).to.be.true; + const [type, payload] = track.firstCall.args; + expect(type).to.equal('event'); + const eventName = 'BC-chat_inline_hyperlink_back_navigation'; + expect(payload.data.web.webInteraction.name).to.equal(`${eventName}|loginStatus:logged-in`); + expect(payload.data.bc).to.include({ + eventName, + sessionId: 'sess-1', + clickType: 'inline_hyperlink', + sourcePage: 'https://www.adobe.com/source', + destinationPage: 'https://www.adobe.com/dest', + navigatedBack: true, + loginStatus: 'logged-in', + }); + expect(payload.data.bc.timestamp).to.be.a('string'); + }); + + it('does not track when _satellite is unavailable', () => { + delete window._satellite; + expect(() => bcAnalytics({ + eventType: 'navigation:backNavigation', + data: { clickType: 'cta' }, + })).to.not.throw(); + }); + }); + + describe('card:clicked navigation recording', () => { + it('records the resolved destinationUrl from the event payload', () => { + const replaceState = sinon.spy(window.history, 'replaceState'); + + bcAnalytics({ + eventType: 'card:clicked', + data: { destinationUrl: 'https://acrobat.adobe.com/pdf-editor?adobe_brand_concierge_source=bc-adobe-product-card' }, + }); + + expect(replaceState.calledOnce).to.be.true; + const [state] = replaceState.firstCall.args; + expect(state.bcClickType).to.equal('product_card_cta'); + expect(state.bcDestinationPage).to.equal('https://acrobat.adobe.com/pdf-editor?adobe_brand_concierge_source=bc-adobe-product-card'); + }); + + it('stores an empty destination without falling back to the referrer', () => { + const replaceState = sinon.spy(window.history, 'replaceState'); + + bcAnalytics({ eventType: 'card:clicked', data: {} }); + + const [state] = replaceState.firstCall.args; + expect(state.bcDestinationPage).to.equal(''); + }); + }); +}); +/* eslint-enable no-underscore-dangle */ diff --git a/test/c2/blocks/brand-concierge/brand-concierge.test.js b/test/c2/blocks/brand-concierge/brand-concierge.test.js new file mode 100644 index 00000000000..cabfb80b057 --- /dev/null +++ b/test/c2/blocks/brand-concierge/brand-concierge.test.js @@ -0,0 +1,99 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { setConfig } from '../../../../libs/utils/utils.js'; + +setConfig({ codeRoot: '/libs', brandConciergeAA: 'testAA' }); + +const { getChatSessionId } = await import('../../../../libs/c2/blocks/brand-concierge/bc-utils.js'); +const { bcAnalytics } = await import('../../../../libs/c2/blocks/brand-concierge/bc-analytics.js'); + +/* eslint-disable no-underscore-dangle */ +describe('Brand Concierge back-navigation analytics', () => { + const SESSION_COOKIE = 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_bc_session_id'; + + afterEach(() => { + document.cookie = `${SESSION_COOKIE}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + delete window._satellite; + sinon.restore(); + }); + + describe('getChatSessionId', () => { + it('returns the decoded session id from the cookie', () => { + document.cookie = `${SESSION_COOKIE}=${encodeURIComponent('sess 123')}; path=/`; + expect(getChatSessionId()).to.equal('sess 123'); + }); + + it('returns an empty string when the cookie is absent', () => { + expect(getChatSessionId()).to.equal(''); + }); + }); + + describe('bcAnalytics navigation:backNavigation', () => { + it('tracks a back-navigation event with all required fields', () => { + const track = sinon.spy(); + window._satellite = { track }; + + bcAnalytics({ + eventType: 'navigation:backNavigation', + data: { + clickType: 'inline_hyperlink', + sessionId: 'sess-1', + sourcePage: 'https://www.adobe.com/source', + destinationPage: 'https://www.adobe.com/dest', + loginStatus: 'logged-in', + navigatedBack: true, + }, + }); + + expect(track.calledOnce).to.be.true; + const [type, payload] = track.firstCall.args; + expect(type).to.equal('event'); + const eventName = 'BC-chat_inline_hyperlink_back_navigation'; + expect(payload.data.web.webInteraction.name).to.equal(`${eventName}|loginStatus:logged-in`); + expect(payload.data.bc).to.include({ + eventName, + sessionId: 'sess-1', + clickType: 'inline_hyperlink', + sourcePage: 'https://www.adobe.com/source', + destinationPage: 'https://www.adobe.com/dest', + navigatedBack: true, + loginStatus: 'logged-in', + }); + expect(payload.data.bc.timestamp).to.be.a('string'); + }); + + it('does not track when _satellite is unavailable', () => { + delete window._satellite; + expect(() => bcAnalytics({ + eventType: 'navigation:backNavigation', + data: { clickType: 'cta' }, + })).to.not.throw(); + }); + }); + + describe('card:clicked navigation recording', () => { + it('records the resolved destinationUrl from the event payload', () => { + const replaceState = sinon.spy(window.history, 'replaceState'); + + bcAnalytics({ + eventType: 'card:clicked', + data: { destinationUrl: 'https://acrobat.adobe.com/pdf-editor?adobe_brand_concierge_source=bc-adobe-product-card' }, + }); + + expect(replaceState.calledOnce).to.be.true; + const [state] = replaceState.firstCall.args; + expect(state.bcClickType).to.equal('product_card_cta'); + expect(state.bcDestinationPage).to.equal('https://acrobat.adobe.com/pdf-editor?adobe_brand_concierge_source=bc-adobe-product-card'); + }); + + it('stores an empty destination without falling back to the referrer', () => { + const replaceState = sinon.spy(window.history, 'replaceState'); + + bcAnalytics({ eventType: 'card:clicked', data: {} }); + + const [state] = replaceState.firstCall.args; + expect(state.bcDestinationPage).to.equal(''); + }); + }); +}); +/* eslint-enable no-underscore-dangle */