Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WSTEAM1-1514: Add click tracking at top level #12360

Open
wants to merge 11 commits into
base: latest
Choose a base branch
from
12 changes: 8 additions & 4 deletions src/app/components/ATIAnalytics/canonical/sendBeaconLite.ts
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 16 additions & 4 deletions src/app/components/MostRead/Canonical/Item/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,6 +18,7 @@
} from '../../types';
import { Direction } from '../../../../models/types/global';
import Grid from '../../../../legacy/components/Grid';
import { RequestContext } from '#app/contexts/RequestContext';

Check failure on line 21 in src/app/components/MostRead/Canonical/Item/index.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

`#app/contexts/RequestContext` import should occur before import of `./index.styles`

export const getParentColumns = (columnLayout: ColumnLayout) => {
return columnLayout !== 'oneColumn'
Expand Down Expand Up @@ -46,14 +50,22 @@
size,
eventTrackingData,
}: PropsWithChildren<MostReadLinkProps>) => {
const clickTrackerHandler = useClickTrackerHandler(eventTrackingData);
const { isLite } = useContext(RequestContext);

const clickTrackerHandler = isLite
? {
[LITE_TRACKER_PARAM]: useConstructLiteSiteATIEventTrackUrl(eventTrackingData),

Check failure on line 57 in src/app/components/MostRead/Canonical/Item/index.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Insert `⏎·········`

Check failure on line 57 in src/app/components/MostRead/Canonical/Item/index.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

React Hook "useConstructLiteSiteATIEventTrackUrl" is called conditionally. React Hooks must be called in the exact same order in every component render
}
: {
onClick: useClickTrackerHandler(eventTrackingData),

Check failure on line 60 in src/app/components/MostRead/Canonical/Item/index.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

React Hook "useClickTrackerHandler" is called conditionally. React Hooks must be called in the exact same order in every component render
};
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

return (
<div css={getItemCss({ dir, size })} dir={dir}>
<a
css={[styles.link, size === 'default' && styles.defaultLink]}
href={href}
onClick={clickTrackerHandler}
{...clickTrackerHandler}
>
{title}
</a>
Expand Down
33 changes: 33 additions & 0 deletions src/app/hooks/useClickTrackerHandler/index.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

const useClickTrackerHandler = (props = {}) => {
const preventNavigation = props?.preventNavigation;
Expand Down Expand Up @@ -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;
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

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;
21 changes: 20 additions & 1 deletion src/app/hooks/useClickTrackerHandler/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import * as serviceContextModule from '../../contexts/ServiceContext';

import pidginData from './fixtureData/tori-51745682.json';
import useClickTrackerHandler from '.';
import useClickTrackerHandler, { useConstructLiteSiteATIEventTrackUrl } from '.';

Check failure on line 19 in src/app/hooks/useClickTrackerHandler/index.test.jsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Replace `·useConstructLiteSiteATIEventTrackUrl·` with `⏎··useConstructLiteSiteATIEventTrackUrl,⏎`

const trackingToggleSpy = jest.spyOn(trackingToggle, 'default');

Expand Down Expand Up @@ -588,3 +588,22 @@
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',
);
});
});
1 change: 1 addition & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare global {
bootstrap: () => void;
cmd: { push: () => void };
};
sendBeaconLite: (url: string, data?: BodyInit | null) => boolean;
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/server/Document/Renderers/LiteRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,6 +25,11 @@ export default function LitePageRenderer({
{helmetLinkTags}
{helmetScriptTags}
<style dangerouslySetInnerHTML={{ __html: styles }} />
<script
dangerouslySetInnerHTML={{
__html: `(${trackingScript.toString()})()`,
}}
/>
</head>
<body>{bodyContent}</body>
</html>
Expand Down
113 changes: 113 additions & 0 deletions src/server/utilities/liteATIClickTracking/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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));
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

trackingScript();

document.cookie =
Fixed Show fixed Hide fixed
'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
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,
},
});
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

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"}';
Fixed Show fixed Hide fixed
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];

expect(callParam).toContain(
'r=0x0x24x24&re=4060x1080&hl=16x30x2&lng=en-GB',
);
});

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);
});
});
94 changes: 94 additions & 0 deletions src/server/utilities/liteATIClickTracking/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
}
Loading