Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 320 additions & 0 deletions dotcom-rendering/src/components/AdmiralScript.importable.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useAB> | 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<typeof useAB> | 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<typeof useAB> | 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<typeof useAB> | 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<typeof useAB> | 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<typeof useAB> | 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<string, unknown>;
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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +48,9 @@ export const AllEditorialNewslettersPage = ({
<Island priority="feature" defer={{ until: 'idle' }}>
<AlreadyVisited />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<AdmiralScript />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<FocusStyles />
</Island>
Expand Down
4 changes: 4 additions & 0 deletions dotcom-rendering/src/components/ArticlePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -106,6 +107,9 @@ export const ArticlePage = (props: WebProps | AppProps) => {
<Island priority="feature" defer={{ until: 'idle' }}>
<AlreadyVisited />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<AdmiralScript />
</Island>
<Island priority="critical">
<Metrics
commercialMetricsEnabled={
Expand Down
4 changes: 4 additions & 0 deletions dotcom-rendering/src/components/FrontPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { rootStyles } from '../lib/rootStyles';
import { filterABTestSwitches } from '../model/enhance-switches';
import type { NavType } from '../model/extract-nav';
import type { Front } from '../types/front';
import { AdmiralScript } from './AdmiralScript.importable';
import { AlreadyVisited } from './AlreadyVisited.importable';
import { BrazeMessaging } from './BrazeMessaging.importable';
import { useConfig } from './ConfigContext';
Expand Down Expand Up @@ -61,6 +62,9 @@ export const FrontPage = ({ front, NAV }: Props) => {
<Island priority="feature" defer={{ until: 'idle' }}>
<AlreadyVisited />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<AdmiralScript />
</Island>
<Island priority="enhancement" defer={{ until: 'idle' }}>
<FocusStyles />
</Island>
Expand Down
4 changes: 4 additions & 0 deletions dotcom-rendering/src/components/TagPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,6 +59,9 @@ export const TagPage = ({ tagPage, NAV }: Props) => {
<Island priority="feature" defer={{ until: 'idle' }}>
<AlreadyVisited />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<AdmiralScript />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<FocusStyles />
</Island>
Expand Down
Loading
Loading