diff --git a/src/app/components/ATIAnalytics/canonical/index.test.tsx b/src/app/components/ATIAnalytics/canonical/index.test.tsx index 61ff5e9b1ea..ca43428bd29 100644 --- a/src/app/components/ATIAnalytics/canonical/index.test.tsx +++ b/src/app/components/ATIAnalytics/canonical/index.test.tsx @@ -47,10 +47,14 @@ describe('Canonical ATI Analytics', () => { expect(helmet.scriptTags).toHaveLength(1); expect(helmet.scriptTags[0].innerHTML).toEqual(` - var xhr = new XMLHttpRequest(); - xhr.open("GET", "${expectedUrl}", true); - xhr.withCredentials = true; - xhr.send(); + function sendBeaconLite (atiPageViewUrlString) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", atiPageViewUrlString, true); + xhr.withCredentials = true; + xhr.send(); + } + + sendBeaconLite("${expectedUrl}"); `); }); diff --git a/src/app/components/ATIAnalytics/canonical/sendBeaconLite.ts b/src/app/components/ATIAnalytics/canonical/sendBeaconLite.ts index 387975e2e95..0cfce17ec0c 100644 --- a/src/app/components/ATIAnalytics/canonical/sendBeaconLite.ts +++ b/src/app/components/ATIAnalytics/canonical/sendBeaconLite.ts @@ -1,8 +1,12 @@ const sendBeaconLite = (atiPageViewUrlString: string) => ` - var xhr = new XMLHttpRequest(); - xhr.open("GET", "${atiPageViewUrlString}", true); - xhr.withCredentials = true; - xhr.send(); + function sendBeaconLite (atiPageViewUrlString) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", atiPageViewUrlString, true); + xhr.withCredentials = true; + xhr.send(); + } + + sendBeaconLite("${atiPageViewUrlString}"); `; export default sendBeaconLite; diff --git a/src/app/components/MostRead/Canonical/Item/index.tsx b/src/app/components/MostRead/Canonical/Item/index.tsx index 9b993182848..d1b210074d6 100755 --- a/src/app/components/MostRead/Canonical/Item/index.tsx +++ b/src/app/components/MostRead/Canonical/Item/index.tsx @@ -1,7 +1,10 @@ /** @jsx jsx */ -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, useContext } from 'react'; import { jsx } from '@emotion/react'; -import useClickTrackerHandler from '#hooks/useClickTrackerHandler'; +import useClickTrackerHandler, { + LITE_TRACKER_PARAM, + useConstructLiteSiteATIEventTrackUrl, +} from '#hooks/useClickTrackerHandler'; import styles from './index.styles'; import { mostReadListGridProps, @@ -15,6 +18,7 @@ import { } from '../../types'; import { Direction } from '../../../../models/types/global'; import Grid from '../../../../legacy/components/Grid'; +import { RequestContext } from '#app/contexts/RequestContext'; export const getParentColumns = (columnLayout: ColumnLayout) => { return columnLayout !== 'oneColumn' @@ -46,14 +50,22 @@ export const MostReadLink = ({ size, eventTrackingData, }: PropsWithChildren) => { - const clickTrackerHandler = useClickTrackerHandler(eventTrackingData); + const { isLite } = useContext(RequestContext); + + const clickTrackerHandler = isLite + ? { + [LITE_TRACKER_PARAM]: useConstructLiteSiteATIEventTrackUrl(eventTrackingData), + } + : { + onClick: useClickTrackerHandler(eventTrackingData), + }; return (
{title} diff --git a/src/app/hooks/useClickTrackerHandler/index.jsx b/src/app/hooks/useClickTrackerHandler/index.jsx index 92ae9d74c83..18ff50290c1 100644 --- a/src/app/hooks/useClickTrackerHandler/index.jsx +++ b/src/app/hooks/useClickTrackerHandler/index.jsx @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import { useContext, useCallback, useState } from 'react'; +import { buildATIEventTrackUrl } from '#app/components/ATIAnalytics/atiUrl'; import { EventTrackingContext } from '../../contexts/EventTrackingContext'; import useTrackingToggle from '../useTrackingToggle'; import OPTIMIZELY_CONFIG from '../../lib/config/optimizely'; @@ -9,6 +10,7 @@ import { ServiceContext } from '../../contexts/ServiceContext'; import { isValidClick } from './clickTypes'; const EVENT_TYPE = 'click'; +export const LITE_TRACKER_PARAM = 'data-ati-tracking'; const useClickTrackerHandler = (props = {}) => { const preventNavigation = props?.preventNavigation; @@ -136,4 +138,35 @@ const useClickTrackerHandler = (props = {}) => { ); }; +export const useConstructLiteSiteATIEventTrackUrl = (props = {}) => { + const eventTrackingContext = useContext(EventTrackingContext); + + const componentName = props?.componentName; + const url = props?.url; + const advertiserID = props?.advertiserID; + const format = props?.format; + const detailedPlacement = props?.detailedPlacement; + + const { pageIdentifier, platform, producerId, statsDestination } = + eventTrackingContext; + + const campaignID = props?.campaignID || eventTrackingContext?.campaignID; + + const atiClickTrackingUrl = buildATIEventTrackUrl({ + pageIdentifier, + producerId, + platform, + statsDestination, + componentName, + campaignID, + format, + type: EVENT_TYPE, + advertiserID, + url, + detailedPlacement, + }); + + return atiClickTrackingUrl; +}; + export default useClickTrackerHandler; diff --git a/src/app/hooks/useClickTrackerHandler/index.test.jsx b/src/app/hooks/useClickTrackerHandler/index.test.jsx index 4dcfef622d3..669eca78d8e 100644 --- a/src/app/hooks/useClickTrackerHandler/index.test.jsx +++ b/src/app/hooks/useClickTrackerHandler/index.test.jsx @@ -16,7 +16,9 @@ import { import * as serviceContextModule from '../../contexts/ServiceContext'; import pidginData from './fixtureData/tori-51745682.json'; -import useClickTrackerHandler from '.'; +import useClickTrackerHandler, { + useConstructLiteSiteATIEventTrackUrl, +} from '.'; const trackingToggleSpy = jest.spyOn(trackingToggle, 'default'); @@ -588,3 +590,22 @@ describe('Error handling', () => { expect(global.fetch).not.toHaveBeenCalled(); }); }); + +describe('Lite Site - Click tracking', () => { + it('Returns a valid ati tracking url given the input props', () => { + const { result } = renderHook( + () => + useConstructLiteSiteATIEventTrackUrl({ + ...defaultProps, + campaignID: 'custom-campaign', + }), + { + wrapper, + }, + ); + + expect(result.current).toContain( + 'atc=PUB-[custom-campaign]-[brand]-[]-[CHD=promo::2]-[]-[]-[]-[]&type=AT', + ); + }); +}); diff --git a/src/global.d.ts b/src/global.d.ts index 4a9a61f0cc4..25c4d2f15a5 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -19,6 +19,7 @@ declare global { bootstrap: () => void; cmd: { push: () => void }; }; + sendBeaconLite: (url: string, data?: BodyInit | null) => boolean; } } diff --git a/src/server/Document/Renderers/LiteRenderer.tsx b/src/server/Document/Renderers/LiteRenderer.tsx index bf7f5664788..d392a4a1a27 100644 --- a/src/server/Document/Renderers/LiteRenderer.tsx +++ b/src/server/Document/Renderers/LiteRenderer.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/no-danger */ import React, { ReactElement, PropsWithChildren } from 'react'; +import trackingScript from '#src/server/utilities/liteATIClickTracking'; import { BaseRendererProps } from './types'; interface Props extends BaseRendererProps { @@ -24,6 +25,11 @@ export default function LitePageRenderer({ {helmetLinkTags} {helmetScriptTags} +
diff --git a/src/server/utilities/liteATIClickTracking/index.test.ts b/src/server/utilities/liteATIClickTracking/index.test.ts new file mode 100644 index 00000000000..22b99e630cf --- /dev/null +++ b/src/server/utilities/liteATIClickTracking/index.test.ts @@ -0,0 +1,118 @@ +import trackingScript from '.'; + +const dispatchClick = (targetElement: HTMLElement) => { + document.body.appendChild(targetElement); + const event = new MouseEvent('click', { + view: window, + bubbles: true, + cancelable: true, + }); + targetElement.dispatchEvent(event); +}; + +describe('Click tracking script', () => { + const randomUUIDMock = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date(1731515402000)); + + trackingScript(); + + document.cookie = + 'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete window.location; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.location = { + assign: jest.fn(), + }; + + window.sendBeaconLite = jest.fn(); + + Object.defineProperty(global, 'crypto', { + value: { + randomUUID: randomUUIDMock, + }, + }); + + window.dispatchEvent(new Event('load')); + }); + + it('Sets a new cookie if there is no atuserid cookie on the user browser', () => { + const anchorElement = document.createElement('a'); + anchorElement.setAttribute( + 'data-ati-tracking', + 'https://logws1363.ati-host.net/?', + ); + + randomUUIDMock.mockReturnValueOnce('randomUniqueId'); + dispatchClick(anchorElement); + + expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}'); + }); + + it('Reuses the atuserid cookie if there is a atuserid cookie on the user browser', () => { + const anchorElement = document.createElement('a'); + anchorElement.setAttribute( + 'data-ati-tracking', + 'https://logws1363.ati-host.net/?', + ); + + document.cookie = 'atuserid={"val":"oldCookieId"}'; + randomUUIDMock.mockReturnValueOnce('newCookieId'); + dispatchClick(anchorElement); + + const callParam = (window.sendBeaconLite as jest.Mock).mock.calls[0][0]; + + expect(callParam).toContain('idclient=oldCookieId'); + }); + + it('Calls sendBeaconLite() with the correct url', () => { + const anchorElement = document.createElement('a'); + anchorElement.setAttribute( + 'data-ati-tracking', + 'https://logws1363.ati-host.net/?', + ); + + window.screen = { + width: 100, + height: 400, + colorDepth: 24, + pixelDepth: 24, + availWidth: 400, + availHeight: 100, + orientation: 'landscape' as unknown as ScreenOrientation, + }; + window.innerWidth = 4060; + window.innerHeight = 1080; + Object.defineProperty(navigator, 'language', { + get() { + return 'en-GB'; + }, + }); + + dispatchClick(anchorElement); + + const callParam = (window.sendBeaconLite as jest.Mock).mock.calls[0][0]; + + const parsedATIParams = Object.fromEntries(new URLSearchParams(callParam)); + + expect(parsedATIParams).toMatchObject({ + hl: '16x30x2', + lng: 'en-GB', + r: '0x0x24x24', + re: '4060x1080', + }); + }); + + it('Does not call sendBeacon if the event has no data-ati-tracking parameter', () => { + const anchorElement = document.createElement('a'); + + dispatchClick(anchorElement); + + expect(window.sendBeaconLite).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/server/utilities/liteATIClickTracking/index.ts b/src/server/utilities/liteATIClickTracking/index.ts new file mode 100644 index 00000000000..4de61bb8382 --- /dev/null +++ b/src/server/utilities/liteATIClickTracking/index.ts @@ -0,0 +1,94 @@ +export default function trackingScript() { + window.addEventListener('load', () => { + document.addEventListener('click', (event: MouseEvent) => { + const targetElement = event.target as HTMLElement; + // eslint-disable-next-line no-undef + if (targetElement?.tagName === 'A') { + event.stopPropagation(); + event.preventDefault(); + + const atiURL = targetElement.getAttribute('data-ati-tracking'); + + if (atiURL == null) { + return; + } + + const currentAnchorElement = event.currentTarget as HTMLAnchorElement; + const nextPageUrl = currentAnchorElement?.href; + + const { + screen: { width, height, colorDepth, pixelDepth }, + innerWidth, + innerHeight, + } = window; + const now = new Date(); + const hours = now.getHours(); + const mins = now.getMinutes(); + const secs = now.getSeconds(); + + // COOKIE SETTINGS + const cookieName = 'atuserid'; + const expires = 397; // expires in 13 months + const cookiesForPage = `; ${document.cookie}`; + const atUserIdCookie = cookiesForPage.split(`; ${cookieName}=`); + let atUserIdValue = null; + + if (atUserIdCookie.length === 2) { + const cookieInfo = atUserIdCookie.pop()?.split(';').shift(); + + if (cookieInfo) { + const decodedCookie = decodeURI(cookieInfo); + const user = JSON.parse(decodedCookie); + atUserIdValue = user.val; + } + } + + if (!atUserIdValue && crypto.randomUUID) { + atUserIdValue = crypto.randomUUID(); + } + + const stringifiedCookieValue = JSON.stringify({ val: atUserIdValue }); + if (atUserIdValue) { + document.cookie = `${cookieName}=${stringifiedCookieValue}; path=/; max-age=${expires};`; + } + + const rValue = [ + width || 0, + height || 0, + colorDepth || 0, + pixelDepth || 0, + ].join('x'); + + const reValue = [innerWidth || 0, innerHeight || 0].join('x'); + + const hlValue = [hours, mins, secs].join('x'); + + let clientSideAtiURL = atiURL + .concat('&', 'r=', rValue) + .concat('&', 're=', reValue) + .concat('&', 'hl=', hlValue); + + if (navigator.language) { + clientSideAtiURL = clientSideAtiURL.concat( + '&', + 'lng=', + navigator.language, + ); + } + + if (atUserIdValue) { + clientSideAtiURL = clientSideAtiURL.concat( + '&', + 'idclient=', + atUserIdValue, + ); + } + + // eslint-disable-next-line no-undef -- This is provided in a helmet script + window.sendBeaconLite(clientSideAtiURL); + + window.location.assign(nextPageUrl); + } + }); + }); +}