Skip to content

Commit 9e07d1e

Browse files
admiral-migration: add AdmiralScript component and AB test integration
1 parent 243aad5 commit 9e07d1e

8 files changed

Lines changed: 350 additions & 1 deletion

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { isInUsa } from '@guardian/commercial-core/geo/geo-utils';
2+
import type { Admiral, AdmiralEvent } from '@guardian/commercial-core/types';
3+
import { cmp, getCookie, log } from '@guardian/libs';
4+
import { useEffect } from 'react';
5+
import { useAB } from '../lib/useAB';
6+
7+
/**
8+
* Fetches AB test variant name for Admiral, as there are two variants
9+
*/
10+
const getAdmiralAbTestVariant = (
11+
ab: ReturnType<typeof useAB> | undefined,
12+
): string | undefined => {
13+
if (ab?.api.isUserInVariant('AdmiralAdblockRecovery', 'variant-detect')) {
14+
return 'variant-detect';
15+
}
16+
if (ab?.api.isUserInVariant('AdmiralAdblockRecovery', 'variant-recover')) {
17+
return 'variant-recover';
18+
}
19+
if (ab?.api.isUserInVariant('AdmiralAdblockRecovery', 'control')) {
20+
return 'control';
21+
}
22+
return undefined;
23+
};
24+
25+
/**
26+
* Sends component events to Ophan with the componentType of `AD_BLOCK_RECOVERY`
27+
* as well as sending the AB test participation
28+
*
29+
* @param ab - The AB test API from useAB hook
30+
* @param overrides allows overriding / setting values for `action` and `value`
31+
*/
32+
const recordAdmiralOphanEvent = (
33+
ab: ReturnType<typeof useAB> | undefined,
34+
{
35+
action,
36+
value,
37+
}: {
38+
action: 'INSERT' | 'DETECT' | 'VIEW' | 'CLOSE';
39+
value?: string;
40+
},
41+
) => {
42+
const abTestVariant = getAdmiralAbTestVariant(ab);
43+
44+
window.guardian.ophan?.record({
45+
componentEvent: {
46+
component: {
47+
componentType: 'AD_BLOCK_RECOVERY',
48+
id: 'admiral-adblock-recovery',
49+
},
50+
action,
51+
...(value && { value }),
52+
...(abTestVariant && {
53+
abTest: {
54+
name: 'AdmiralAdblockRecovery',
55+
variant: abTestVariant,
56+
},
57+
}),
58+
},
59+
});
60+
};
61+
62+
// Admiral event types
63+
type MeasureDetectedEvent = {
64+
adblocking: boolean;
65+
whitelisted: boolean;
66+
subscribed: boolean;
67+
};
68+
69+
type CandidateShownEvent = {
70+
candidateID: string;
71+
variantID?: string;
72+
candidateGroups: string[];
73+
};
74+
75+
type CandidateDismissedEvent = {
76+
candidateID: string;
77+
candidateGroups: string[];
78+
};
79+
80+
// Admiral event handlers
81+
const handleMeasureDetectedEvent = (
82+
ab: ReturnType<typeof useAB> | undefined,
83+
event: AdmiralEvent,
84+
): void => {
85+
const isMeasureDetectedEvent = (
86+
e: AdmiralEvent,
87+
): e is MeasureDetectedEvent =>
88+
typeof e === 'object' &&
89+
'adblocking' in e &&
90+
'whitelisted' in e &&
91+
'subscribed' in e;
92+
93+
if (isMeasureDetectedEvent(event)) {
94+
if (event.adblocking) {
95+
log(
96+
'commercial',
97+
'🛡️ Admiral - user has an adblocker and it is enabled',
98+
);
99+
recordAdmiralOphanEvent(ab, { action: 'DETECT', value: 'blocked' });
100+
}
101+
if (event.whitelisted) {
102+
log(
103+
'commercial',
104+
'🛡️ Admiral - user has seen Engage and subsequently disabled their adblocker',
105+
);
106+
recordAdmiralOphanEvent(ab, {
107+
action: 'DETECT',
108+
value: 'whitelisted',
109+
});
110+
}
111+
if (event.subscribed) {
112+
log(
113+
'commercial',
114+
'🛡️ Admiral - user has an active subscription to a transact plan',
115+
);
116+
}
117+
} else {
118+
log(
119+
'commercial',
120+
`🛡️ Admiral - Event is not of expected format of measure.detected ${JSON.stringify(
121+
event,
122+
)}`,
123+
);
124+
}
125+
};
126+
127+
const handleCandidateShownEvent = (
128+
ab: ReturnType<typeof useAB> | undefined,
129+
event: AdmiralEvent,
130+
): void => {
131+
const isCandidateShownEvent = (e: AdmiralEvent): e is CandidateShownEvent =>
132+
typeof e === 'object' &&
133+
'candidateID' in e &&
134+
'variantID' in e &&
135+
'candidateGroups' in e;
136+
137+
if (isCandidateShownEvent(event)) {
138+
log(
139+
'commercial',
140+
`🛡️ Admiral - Launching candidate ${event.candidateID}`,
141+
);
142+
recordAdmiralOphanEvent(ab, {
143+
action: 'VIEW',
144+
value: event.candidateID,
145+
});
146+
} else {
147+
log(
148+
'commercial',
149+
`🛡️ Admiral - Event is not of expected format of candidate.shown ${JSON.stringify(
150+
event,
151+
)}`,
152+
);
153+
}
154+
};
155+
156+
const handleCandidateDismissedEvent = (
157+
ab: ReturnType<typeof useAB> | undefined,
158+
event: AdmiralEvent,
159+
): void => {
160+
const isCandidateDismissedEvent = (
161+
e: AdmiralEvent,
162+
): e is CandidateDismissedEvent =>
163+
typeof e === 'object' && 'candidateID' in e && 'candidateGroups' in e;
164+
165+
if (isCandidateDismissedEvent(event)) {
166+
log(
167+
'commercial',
168+
`🛡️ Admiral - Candidate ${event.candidateID} was dismissed`,
169+
);
170+
recordAdmiralOphanEvent(ab, {
171+
action: 'CLOSE',
172+
value: event.candidateID,
173+
});
174+
} else {
175+
log(
176+
'commercial',
177+
`🛡️ Admiral - Event is not of expected format of candidate.dismissed ${JSON.stringify(
178+
event,
179+
)}`,
180+
);
181+
}
182+
};
183+
184+
const setUpAdmiralEventLogger = (
185+
admiral: Admiral,
186+
ab: ReturnType<typeof useAB> | undefined,
187+
): void => {
188+
admiral('after', 'measure.detected', function (event) {
189+
handleMeasureDetectedEvent(ab, event);
190+
});
191+
192+
admiral('after', 'candidate.shown', function (event) {
193+
handleCandidateShownEvent(ab, event);
194+
});
195+
196+
admiral('after', 'candidate.dismissed', function (event) {
197+
handleCandidateDismissedEvent(ab, event);
198+
});
199+
};
200+
201+
export const AdmiralScript = () => {
202+
const ab = useAB();
203+
const abTestVariant = getAdmiralAbTestVariant(ab);
204+
const isInVariant = abTestVariant?.startsWith('variant') ?? false;
205+
206+
useEffect(() => {
207+
/**
208+
* The Admiral bootstrap script should only run under the following conditions:
209+
*
210+
* - Should not run if the CMP is due to show
211+
* - Should only run in the US
212+
* - Should only run if in the variant of the AB test
213+
* - Should not run if the gu_hide_support_messaging cookie is set
214+
* - Should not run for content marked as: shouldHideAdverts, shouldHideReaderRevenue, isSensitive
215+
* - Should not run for paid-content sponsorship type (includes Hosted Content)
216+
* - Should not run for certain sections
217+
*/
218+
const page = window.guardian.config.page;
219+
220+
const shouldRun =
221+
cmp.hasInitialised() &&
222+
!cmp.willShowPrivacyMessageSync() &&
223+
isInUsa() &&
224+
isInVariant &&
225+
!getCookie({
226+
name: 'gu_hide_support_messaging',
227+
shouldMemoize: true,
228+
}) &&
229+
!page.shouldHideAdverts &&
230+
!page.shouldHideReaderRevenue &&
231+
!page.isSensitive &&
232+
page.sponsorshipType !== 'paid-content' &&
233+
![
234+
'about',
235+
'info',
236+
'membership',
237+
'help',
238+
'guardian-live-australia',
239+
'gnm-archive',
240+
'guardian-labs',
241+
'thefilter',
242+
].includes(page.section ?? '');
243+
if (!shouldRun) return;
244+
245+
// Record INSERT Ophan event
246+
recordAdmiralOphanEvent(ab, { action: 'INSERT' });
247+
248+
// Initialize Admiral Adblock Recovery
249+
log('commercial', '🛡️ Initialising Admiral Adblock Recovery');
250+
251+
// Set up window.admiral stub
252+
// This initializes admiral before the bootstrap script loads
253+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Required for Admiral stub initialization
254+
type AdmiralStub = Admiral & { q?: any[] };
255+
const w = window as Window & { admiral?: AdmiralStub };
256+
257+
if (!w.admiral) {
258+
// Create the Admiral stub function with queue
259+
const stub = function (...args: unknown[]) {
260+
if (!stub.q) stub.q = [];
261+
stub.q.push(args);
262+
} as AdmiralStub;
263+
w.admiral = stub;
264+
}
265+
266+
log('commercial', '🛡️ Setting up Admiral event logger');
267+
268+
// Set up Admiral event logging
269+
setUpAdmiralEventLogger(w.admiral, ab);
270+
271+
// Set AB test targeting
272+
if (abTestVariant) {
273+
w.admiral('targeting', 'set', 'guAbTest', abTestVariant);
274+
}
275+
276+
// Mark that DCR owns Admiral initialization
277+
window.guardian.config.switches.dcrOwnsAdmiral = true;
278+
279+
// Load Admiral bootstrap script
280+
const BASE_AJAX_URL =
281+
window.guardian.config.stage === 'CODE'
282+
? 'https://code.api.nextgen.guardianapps.co.uk'
283+
: 'https://api.nextgen.guardianapps.co.uk';
284+
285+
const admiralScript = document.createElement('script');
286+
admiralScript.src = `${BASE_AJAX_URL}/commercial/admiral-bootstrap.js`;
287+
admiralScript.async = true;
288+
document.head.appendChild(admiralScript);
289+
290+
log(
291+
'commercial',
292+
`🛡️ Loading Admiral bootstrap script: ${admiralScript.src}`,
293+
);
294+
295+
log('commercial', '🛡️ Admiral initialization complete');
296+
297+
return () => {
298+
// Clean up Admiral bootstrap script
299+
admiralScript.parentNode?.removeChild(admiralScript);
300+
};
301+
}, [ab, isInVariant, abTestVariant]);
302+
303+
return null;
304+
};

dotcom-rendering/src/components/AllEditorialNewslettersPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
55
import { rootStyles } from '../lib/rootStyles';
66
import type { NavType } from '../model/extract-nav';
77
import type { DCRNewslettersPageType } from '../types/newslettersPage';
8+
import { AdmiralScript } from './AdmiralScript.importable';
89
import { AlreadyVisited } from './AlreadyVisited.importable';
910
import { useConfig } from './ConfigContext';
1011
import { FocusStyles } from './FocusStyles.importable';
@@ -47,6 +48,9 @@ export const AllEditorialNewslettersPage = ({
4748
<Island priority="feature" defer={{ until: 'idle' }}>
4849
<AlreadyVisited />
4950
</Island>
51+
<Island priority="feature" defer={{ until: 'idle' }}>
52+
<AdmiralScript />
53+
</Island>
5054
<Island priority="feature" defer={{ until: 'idle' }}>
5155
<FocusStyles />
5256
</Island>

dotcom-rendering/src/components/ArticlePage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { filterABTestSwitches } from '../model/enhance-switches';
88
import type { NavType } from '../model/extract-nav';
99
import type { Article } from '../types/article';
1010
import type { RenderingTarget } from '../types/renderingTarget';
11+
import { AdmiralScript } from './AdmiralScript.importable';
1112
import { AlreadyVisited } from './AlreadyVisited.importable';
1213
import { BrazeMessaging } from './BrazeMessaging.importable';
1314
import { useConfig } from './ConfigContext';
@@ -106,6 +107,9 @@ export const ArticlePage = (props: WebProps | AppProps) => {
106107
<Island priority="feature" defer={{ until: 'idle' }}>
107108
<AlreadyVisited />
108109
</Island>
110+
<Island priority="feature" defer={{ until: 'idle' }}>
111+
<AdmiralScript />
112+
</Island>
109113
<Island priority="critical">
110114
<Metrics
111115
commercialMetricsEnabled={

dotcom-rendering/src/components/FrontPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { rootStyles } from '../lib/rootStyles';
77
import { filterABTestSwitches } from '../model/enhance-switches';
88
import type { NavType } from '../model/extract-nav';
99
import type { Front } from '../types/front';
10+
import { AdmiralScript } from './AdmiralScript.importable';
1011
import { AlreadyVisited } from './AlreadyVisited.importable';
1112
import { BrazeMessaging } from './BrazeMessaging.importable';
1213
import { useConfig } from './ConfigContext';
@@ -61,6 +62,9 @@ export const FrontPage = ({ front, NAV }: Props) => {
6162
<Island priority="feature" defer={{ until: 'idle' }}>
6263
<AlreadyVisited />
6364
</Island>
65+
<Island priority="feature" defer={{ until: 'idle' }}>
66+
<AdmiralScript />
67+
</Island>
6468
<Island priority="enhancement" defer={{ until: 'idle' }}>
6569
<FocusStyles />
6670
</Island>

dotcom-rendering/src/components/TagPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { rootStyles } from '../lib/rootStyles';
77
import { filterABTestSwitches } from '../model/enhance-switches';
88
import type { NavType } from '../model/extract-nav';
99
import type { TagPage as TagPageModel } from '../types/tagPage';
10+
import { AdmiralScript } from './AdmiralScript.importable';
1011
import { AlreadyVisited } from './AlreadyVisited.importable';
1112
import { useConfig } from './ConfigContext';
1213
import { DarkModeMessage } from './DarkModeMessage';
@@ -58,6 +59,9 @@ export const TagPage = ({ tagPage, NAV }: Props) => {
5859
<Island priority="feature" defer={{ until: 'idle' }}>
5960
<AlreadyVisited />
6061
</Island>
62+
<Island priority="feature" defer={{ until: 'idle' }}>
63+
<AdmiralScript />
64+
</Island>
6165
<Island priority="feature" defer={{ until: 'idle' }}>
6266
<FocusStyles />
6367
</Island>
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import type { ABTest } from '@guardian/ab-core';
22
import { abTestTest } from './tests/ab-test-test';
3+
import { admiralAdblockRecovery } from './tests/admiral-adblock-recovery';
34
import { noAuxiaSignInGate } from './tests/no-auxia-sign-in-gate';
45
import { userBenefitsApi } from './tests/user-benefits-api';
56

67
// keep in sync with ab-tests in frontend
78
// https://github.com/guardian/frontend/tree/main/static/src/javascripts/projects/common/modules/experiments/ab-tests.ts
8-
export const tests: ABTest[] = [abTestTest, userBenefitsApi, noAuxiaSignInGate];
9+
export const tests: ABTest[] = [
10+
abTestTest,
11+
userBenefitsApi,
12+
noAuxiaSignInGate,
13+
admiralAdblockRecovery,
14+
];

0 commit comments

Comments
 (0)