diff --git a/dotcom-rendering/src/components/AdmiralScript.importable.tsx b/dotcom-rendering/src/components/AdmiralScript.importable.tsx new file mode 100644 index 00000000000..2b8b7fcbc02 --- /dev/null +++ b/dotcom-rendering/src/components/AdmiralScript.importable.tsx @@ -0,0 +1,320 @@ +import { isInUsa } from '@guardian/commercial-core/geo/geo-utils'; +import type { Admiral, AdmiralEvent } from '@guardian/commercial-core/types'; +import { cmp, getCookie, log } from '@guardian/libs'; +import { useEffect } from 'react'; +import { useAB } from '../lib/useAB'; + +/** + * Fetches AB test variant name for Admiral, as there are two variants + */ +const getAdmiralAbTestVariant = ( + ab: ReturnType | undefined, +): string | undefined => { + if (ab?.api.isUserInVariant('AdmiralAdblockRecovery', 'variant-detect')) { + return 'variant-detect'; + } + if (ab?.api.isUserInVariant('AdmiralAdblockRecovery', 'variant-recover')) { + return 'variant-recover'; + } + if (ab?.api.isUserInVariant('AdmiralAdblockRecovery', 'control')) { + return 'control'; + } + return undefined; +}; + +/** + * Sends component events to Ophan with the componentType of `AD_BLOCK_RECOVERY` + * as well as sending the AB test participation + * + * @param ab - The AB test API from useAB hook + * @param overrides allows overriding / setting values for `action` and `value` + */ +const recordAdmiralOphanEvent = ( + ab: ReturnType | undefined, + { + action, + value, + }: { + action: 'INSERT' | 'DETECT' | 'VIEW' | 'CLOSE'; + value?: string; + }, +) => { + const abTestVariant = getAdmiralAbTestVariant(ab); + + window.guardian.ophan?.record({ + componentEvent: { + component: { + componentType: 'AD_BLOCK_RECOVERY', + id: 'admiral-adblock-recovery', + }, + action, + ...(value && { value }), + ...(abTestVariant && { + abTest: { + name: 'AdmiralAdblockRecovery', + variant: abTestVariant, + }, + }), + }, + }); +}; + +// Admiral event types +type MeasureDetectedEvent = { + adblocking: boolean; + whitelisted: boolean; + subscribed: boolean; +}; + +type CandidateShownEvent = { + candidateID: string; + variantID?: string; + candidateGroups: string[]; +}; + +type CandidateDismissedEvent = { + candidateID: string; + candidateGroups: string[]; +}; + +// Admiral event handlers +const handleMeasureDetectedEvent = ( + ab: ReturnType | undefined, + event: AdmiralEvent, +): void => { + const isMeasureDetectedEvent = ( + e: AdmiralEvent, + ): e is MeasureDetectedEvent => + typeof e === 'object' && + 'adblocking' in e && + 'whitelisted' in e && + 'subscribed' in e; + + if (!isMeasureDetectedEvent(event)) { + log( + 'commercial', + `🛡️ Admiral - Event is not of expected format of measure.detected ${JSON.stringify( + event, + )}`, + ); + return; + } + + if (event.adblocking) { + log( + 'commercial', + '🛡️ Admiral - user has an adblocker and it is enabled', + ); + recordAdmiralOphanEvent(ab, { action: 'DETECT', value: 'blocked' }); + } + if (event.whitelisted) { + log( + 'commercial', + '🛡️ Admiral - user has seen Engage and subsequently disabled their adblocker', + ); + recordAdmiralOphanEvent(ab, { + action: 'DETECT', + value: 'whitelisted', + }); + } + if (event.subscribed) { + log( + 'commercial', + '🛡️ Admiral - user has an active subscription to a transact plan', + ); + } +}; + +const handleCandidateShownEvent = ( + ab: ReturnType | undefined, + event: AdmiralEvent, +): void => { + const isCandidateShownEvent = (e: AdmiralEvent): e is CandidateShownEvent => + typeof e === 'object' && + 'candidateID' in e && + 'variantID' in e && + 'candidateGroups' in e; + + if (isCandidateShownEvent(event)) { + log( + 'commercial', + `🛡️ Admiral - Launching candidate ${event.candidateID}`, + ); + recordAdmiralOphanEvent(ab, { + action: 'VIEW', + value: event.candidateID, + }); + } else { + log( + 'commercial', + `🛡️ Admiral - Event is not of expected format of candidate.shown ${JSON.stringify( + event, + )}`, + ); + } +}; + +const handleCandidateDismissedEvent = ( + ab: ReturnType | undefined, + event: AdmiralEvent, +): void => { + const isCandidateDismissedEvent = ( + e: AdmiralEvent, + ): e is CandidateDismissedEvent => + typeof e === 'object' && 'candidateID' in e && 'candidateGroups' in e; + + if (isCandidateDismissedEvent(event)) { + log( + 'commercial', + `🛡️ Admiral - Candidate ${event.candidateID} was dismissed`, + ); + recordAdmiralOphanEvent(ab, { + action: 'CLOSE', + value: event.candidateID, + }); + } else { + log( + 'commercial', + `🛡️ Admiral - Event is not of expected format of candidate.dismissed ${JSON.stringify( + event, + )}`, + ); + } +}; + +const setUpAdmiralEventLogger = ( + admiral: Admiral, + ab: ReturnType | undefined, +): void => { + admiral('after', 'measure.detected', function (event) { + handleMeasureDetectedEvent(ab, event); + }); + + admiral('after', 'candidate.shown', function (event) { + handleCandidateShownEvent(ab, event); + }); + + admiral('after', 'candidate.dismissed', function (event) { + handleCandidateDismissedEvent(ab, event); + }); +}; + +// Check if Commercial has already initialized Admiral (bootstrap loaded, not just stub) +const isComHandlingAdmiral = (): boolean => { + // If window.admiral exists and has been initialized by bootstrap (not just the queue stub) + // the bootstrap replaces the stub with a proper function that doesn't have .q property + type AdmiralStub = Admiral & { q?: any[] }; + const w = window as Window & { admiral?: AdmiralStub }; + + const admiralExists = typeof w.admiral === 'function'; + const admiralAsRecord = w.admiral as unknown as Record; + const admiralIsOnlyStub = admiralExists && Array.isArray(admiralAsRecord.q); + const admiralIsInitialized = admiralExists && !admiralIsOnlyStub; + // Check for explicit DCR flag if set + const commercialOwnsAdmiral = + window.guardian.config.switches.dcrOwnsAdmiral === false; + + if (admiralIsInitialized || commercialOwnsAdmiral) { + log( + 'dotcom', + '🛡️ Admiral - Commercial is handling Admiral, skipping commercial initialization', + ); + return true; + } + return false; +}; + +export const AdmiralScript = () => { + const ab = useAB(); + const abTestVariant = getAdmiralAbTestVariant(ab); + const isInVariant = abTestVariant?.startsWith('variant') ?? false; + + useEffect(() => { + /** + * The Admiral bootstrap script should only run under the following conditions: + * + * - Should not run if the CMP is due to show + * - Should only run in the US + * - Should only run if in the variant of the AB test + * - Should not run if the gu_hide_support_messaging cookie is set + * - Should not run for content marked as: shouldHideAdverts, shouldHideReaderRevenue, isSensitive + * - Should not run for paid-content sponsorship type (includes Hosted Content) + * - Should not run for certain sections + */ + const page = window.guardian.config.page; + + const shouldRun = + !isComHandlingAdmiral() && + cmp.hasInitialised() && + !cmp.willShowPrivacyMessageSync() && + isInUsa() && + isInVariant && + !getCookie({ + name: 'gu_hide_support_messaging', + shouldMemoize: true, + }) && + !page.shouldHideAdverts && + !page.shouldHideReaderRevenue && + !page.isSensitive && + page.sponsorshipType !== 'paid-content' && + ![ + 'about', + 'info', + 'membership', + 'help', + 'guardian-live-australia', + 'gnm-archive', + 'guardian-labs', + 'thefilter', + ].includes(page.section ?? ''); + if (!shouldRun) return; + + // Record INSERT Ophan event + recordAdmiralOphanEvent(ab, { action: 'INSERT' }); + + // Initialize Admiral Adblock Recovery + log('dotcom', '🛡️ Initialising Admiral Adblock Recovery'); + + // Set up window.admiral stub + // This initializes admiral before the bootstrap script loads + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Required for Admiral stub initialization + type AdmiralStub = Admiral & { q?: any[] }; + const w = window as Window & { admiral?: AdmiralStub }; + + if (!w.admiral) { + // Create the Admiral stub function with queue + const stub = function (...args: unknown[]) { + if (!stub.q) stub.q = []; + stub.q.push(args); + } as AdmiralStub; + w.admiral = stub; + } + + log('dotcom', '🛡️ Setting up Admiral event logger'); + + // Set up Admiral event logging + setUpAdmiralEventLogger(w.admiral, ab); + + // Set AB test targeting + if (abTestVariant) { + w.admiral('targeting', 'set', 'guAbTest', abTestVariant); + } + + // Load Admiral bootstrap script + const BASE_AJAX_URL = window.guardian.config.page.ajaxUrl; + + const admiralScript = document.createElement('script'); + admiralScript.src = `${BASE_AJAX_URL}/commercial/admiral-bootstrap.js`; + admiralScript.async = true; + document.head.appendChild(admiralScript); + + log('dotcom', '🛡️ Admiral initialization complete'); + + return () => { + // Clean up Admiral bootstrap script + admiralScript.parentNode?.removeChild(admiralScript); + }; + }, [ab, isInVariant, abTestVariant]); + + return null; +}; diff --git a/dotcom-rendering/src/components/AllEditorialNewslettersPage.tsx b/dotcom-rendering/src/components/AllEditorialNewslettersPage.tsx index b1240773121..9035d2a2d3d 100644 --- a/dotcom-rendering/src/components/AllEditorialNewslettersPage.tsx +++ b/dotcom-rendering/src/components/AllEditorialNewslettersPage.tsx @@ -5,6 +5,7 @@ import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; import { rootStyles } from '../lib/rootStyles'; import type { NavType } from '../model/extract-nav'; import type { DCRNewslettersPageType } from '../types/newslettersPage'; +import { AdmiralScript } from './AdmiralScript.importable'; import { AlreadyVisited } from './AlreadyVisited.importable'; import { useConfig } from './ConfigContext'; import { FocusStyles } from './FocusStyles.importable'; @@ -47,6 +48,9 @@ export const AllEditorialNewslettersPage = ({ + + + diff --git a/dotcom-rendering/src/components/ArticlePage.tsx b/dotcom-rendering/src/components/ArticlePage.tsx index 1085c29d59b..aae9d285894 100644 --- a/dotcom-rendering/src/components/ArticlePage.tsx +++ b/dotcom-rendering/src/components/ArticlePage.tsx @@ -8,6 +8,7 @@ import { filterABTestSwitches } from '../model/enhance-switches'; import type { NavType } from '../model/extract-nav'; import type { Article } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; +import { AdmiralScript } from './AdmiralScript.importable'; import { AlreadyVisited } from './AlreadyVisited.importable'; import { BrazeMessaging } from './BrazeMessaging.importable'; import { useConfig } from './ConfigContext'; @@ -106,6 +107,9 @@ export const ArticlePage = (props: WebProps | AppProps) => { + + + { + + + diff --git a/dotcom-rendering/src/components/TagPage.tsx b/dotcom-rendering/src/components/TagPage.tsx index 1693f7be4cb..ebe8642869b 100644 --- a/dotcom-rendering/src/components/TagPage.tsx +++ b/dotcom-rendering/src/components/TagPage.tsx @@ -7,6 +7,7 @@ import { rootStyles } from '../lib/rootStyles'; import { filterABTestSwitches } from '../model/enhance-switches'; import type { NavType } from '../model/extract-nav'; import type { TagPage as TagPageModel } from '../types/tagPage'; +import { AdmiralScript } from './AdmiralScript.importable'; import { AlreadyVisited } from './AlreadyVisited.importable'; import { useConfig } from './ConfigContext'; import { DarkModeMessage } from './DarkModeMessage'; @@ -58,6 +59,9 @@ export const TagPage = ({ tagPage, NAV }: Props) => { + + + diff --git a/dotcom-rendering/src/experiments/ab-tests.ts b/dotcom-rendering/src/experiments/ab-tests.ts index f8491aa0bbf..74370f03349 100644 --- a/dotcom-rendering/src/experiments/ab-tests.ts +++ b/dotcom-rendering/src/experiments/ab-tests.ts @@ -1,7 +1,13 @@ import type { ABTest } from '@guardian/ab-core'; import { abTestTest } from './tests/ab-test-test'; +import { admiralAdblockRecovery } from './tests/admiral-adblock-recovery'; import { noAuxiaSignInGate } from './tests/no-auxia-sign-in-gate'; // keep in sync with ab-tests in frontend // https://github.com/guardian/frontend/tree/main/static/src/javascripts/projects/common/modules/experiments/ab-tests.ts -export const tests: ABTest[] = [abTestTest, noAuxiaSignInGate]; + +export const tests: ABTest[] = [ + abTestTest, + noAuxiaSignInGate, + admiralAdblockRecovery, +]; diff --git a/dotcom-rendering/src/experiments/tests/admiral-adblock-recovery.ts b/dotcom-rendering/src/experiments/tests/admiral-adblock-recovery.ts new file mode 100644 index 00000000000..34ebdb36601 --- /dev/null +++ b/dotcom-rendering/src/experiments/tests/admiral-adblock-recovery.ts @@ -0,0 +1,19 @@ +import type { ABTest } from '@guardian/ab-core'; + +export const admiralAdblockRecovery: ABTest = { + id: 'AdmiralAdblockRecovery', + author: '@commercial-dev', + start: '2025-08-13', + expiry: '2027-01-21', + audience: 1.0, + audienceOffset: 0, + audienceCriteria: 'US users only', + successMeasure: 'Reduction in ad block rate', + description: 'Test Admiral ad blocker detection and recovery modal', + variants: [ + { id: 'control', test: (): void => {} }, + { id: 'variant-detect', test: (): void => {} }, + { id: 'variant-recover', test: (): void => {} }, + ], + canRun: () => true, +}; diff --git a/dotcom-rendering/src/model/guardian.ts b/dotcom-rendering/src/model/guardian.ts index 4faebdf3c1d..f02ed50f7fa 100644 --- a/dotcom-rendering/src/model/guardian.ts +++ b/dotcom-rendering/src/model/guardian.ts @@ -41,6 +41,10 @@ export interface Guardian { userBenefitsApiUrl?: string; idApiUrl?: string; isPodcast?: boolean; + shouldHideAdverts?: boolean; + isSensitive?: boolean; + sponsorshipType?: string; + section?: string; }; libs: { googletag: string;