From a39bfe861c87c76c3d59f32206754905e8a0cf9f Mon Sep 17 00:00:00 2001
From: Juarez Mota
Date: Wed, 2 Sep 2026 20:44:22 +0100
Subject: [PATCH 1/6] Add Gandalf sign-in gate counter, popup hosting and Ophan
identity
Support the Gandalf sign-in gate journey (the Guardian-managed, Auxia-free
100% rollout): a dedicated persistent pageview counter, kept per country
because campaigns differ by country group and idempotent per Ophan pageview
id, that only advances once SDC confirms an active, eligible pageview via
the gandalfSignInGate response marker; the counter is sent to
/auxia/get-treatments so SDC can decide between the free-view marker and
the non-dismissible popup treatment. Gandalf responses report to Ophan
under a stable GandalfSignInGate identity with a per-country variant
(gandalf-) and never call Auxia for treatment interactions.
Sign-in gate mount points are added to fronts, tag pages, galleries,
pictures, liveblogs and full page interactives, and withSignInGateSlot now
guarantees a host for bodies with fewer than two elements so the v2 popup
can be selected everywhere the journey applies.
---
.../playwright/tests/banner.e2e.spec.ts | 44 +++++
.../src/components/SignInGate/types.ts | 28 ++++
.../components/SignInGateSelector.island.tsx | 96 +++++++----
.../StickyBottomBanner.island.test.tsx | 30 ++++
.../components/StickyBottomBanner.island.tsx | 2 +
.../SignInGatePortal.test.tsx | 157 +++++++++++++++++-
.../StickyBottomBanner/SignInGatePortal.tsx | 39 ++++-
dotcom-rendering/src/layouts/FrontLayout.tsx | 3 +
.../src/layouts/FullPageInteractiveLayout.tsx | 3 +
.../src/layouts/GalleryLayout.tsx | 3 +
dotcom-rendering/src/layouts/LiveLayout.tsx | 3 +
.../src/layouts/PictureLayout.tsx | 3 +
.../src/layouts/TagPageLayout.tsx | 3 +
dotcom-rendering/src/lib/auxia.ts | 4 +
dotcom-rendering/src/lib/gandalf.test.ts | 76 +++++++++
dotcom-rendering/src/lib/gandalf.ts | 60 +++++++
.../src/lib/withSignInGateSlot.test.tsx | 78 +++++++++
.../src/lib/withSignInGateSlot.tsx | 13 +-
18 files changed, 608 insertions(+), 37 deletions(-)
create mode 100644 dotcom-rendering/src/lib/gandalf.test.ts
create mode 100644 dotcom-rendering/src/lib/gandalf.ts
create mode 100644 dotcom-rendering/src/lib/withSignInGateSlot.test.tsx
diff --git a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
index 4a9549fa563..62eaf63ff9f 100644
--- a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
+++ b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
@@ -111,6 +111,50 @@ test.describe('Sign-in gate portal', function () {
await auxiaRequestPromise;
});
+
+ test('sends the Gandalf pageview counter for New Zealand readers', async ({
+ page,
+ context,
+ }) => {
+ await optOutOfArticleCountConsent(context);
+
+ const auxiaUrl =
+ 'https://contributions.guardianapis.com/auxia/get-treatments';
+ const auxiaRequestPromise = page.waitForRequest((request) => {
+ if (!requestBodyHasProperties(request, auxiaUrl, ['isSupporter'])) {
+ return false;
+ }
+ const body = request.postDataJSON() as Record;
+ return body.gandalfPageViewCount === 0;
+ });
+
+ await loadPage({
+ page,
+ path: ARTICLE_PATH,
+ waitUntil: 'domcontentloaded',
+ region: 'GB',
+ preventSupportBanner: false,
+ overrides: {
+ configOverrides: {
+ frontendAssetsFullURL: LOCAL_ASSET_ORIGIN,
+ },
+ },
+ });
+
+ await cmpAcceptAll(page);
+
+ await page.evaluate(() => {
+ // Set geolocation to NZ for the Gandalf proof of concept
+ window.localStorage.setItem('gu.geo.override', 'NZ');
+ });
+
+ await page.reload({ waitUntil: 'domcontentloaded' });
+
+ const auxiaRequest = await auxiaRequestPromise;
+ const body = auxiaRequest.postDataJSON() as Record;
+ expect(body.countryCode).toBe('NZ');
+ expect(body.gandalfPageViewCount).toBe(0);
+ });
});
test.describe('Banner browserId targeting', function () {
diff --git a/dotcom-rendering/src/components/SignInGate/types.ts b/dotcom-rendering/src/components/SignInGate/types.ts
index ad269ce62d2..28e77db667d 100644
--- a/dotcom-rendering/src/components/SignInGate/types.ts
+++ b/dotcom-rendering/src/components/SignInGate/types.ts
@@ -137,6 +137,7 @@ export interface AuxiaProxyGetTreatmentsPayload {
showDefaultGate: ShowGateValues; // [3]
gateDisplayCount: number;
hideSupportMessagingTimestamp: number | undefined; // [4]
+ gandalfPageViewCount?: number; // [5] gandalfPageViewCount
}
// [1]
@@ -183,6 +184,23 @@ export interface AuxiaProxyGetTreatmentsPayload {
// It is either undefined or return the timestamp carried by cookie `gu_hide_support_messaging`
// See: https://github.com/guardian/support-frontend/blob/7a5c0f9209054c24934b876771392531c261f51c/support-frontend/assets/helpers/storage/contributionsCookies.ts#L11
+// [5] gandalfPageViewCount
+//
+// date: 2nd September 2026
+// comment group: gandalf
+//
+// "Gandalf" is the marketing name for the Guardian-managed sign-in gate
+// journey: a 100% rollout run entirely by Guardian rules with no Auxia
+// involvement, currently live for New Zealand and extendable to further
+// countries via the gandalfSignInGateCountries channel switch.
+//
+// `gandalfPageViewCount` is the 0-based number of eligible pageviews the
+// reader has already completed in the request's country under the active
+// Gandalf rules (see src/lib/gandalf.ts). Counters are per country, because
+// campaigns differ by country group. It is optional so older payloads and
+// traffic outside the Gandalf countries are unaffected; SDC treats a missing
+// value as 0.
+
export interface AuxiaProxyGetTreatmentsResponse {
status: boolean;
data?: AuxiaProxyGetTreatmentsProxyResponseData;
@@ -191,6 +209,12 @@ export interface AuxiaProxyGetTreatmentsResponse {
export interface AuxiaProxyGetTreatmentsProxyResponseData {
responseId: string;
userTreatment?: AuxiaAPIResponseDataUserTreatment;
+ // Set to true on responses produced by the active Gandalf rules, both
+ // when no gate should display (the pageview still counts towards the free
+ // allowance) and when the Guardian-managed non-dismissible popup is
+ // returned. When present, the client must not make any Auxia interaction
+ // call and reports to Ophan under the stable Gandalf identity.
+ gandalfSignInGate?: boolean;
}
// Log Treatment Interaction
@@ -235,6 +259,10 @@ export interface AuxiaGateReaderPersonalData {
export interface AuxiaGateDisplayData {
browserId: string | undefined;
auxiaData: AuxiaProxyGetTreatmentsProxyResponseData;
+ // The country code the gate request was made for. Set by the client so the
+ // selector can build the per-country Gandalf Ophan variant
+ // (gandalf-) without re-resolving geolocation.
+ gandalfCountryCode?: string;
}
export type SignInGatePropsAuxia = {
diff --git a/dotcom-rendering/src/components/SignInGateSelector.island.tsx b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
index 23f9d796d0f..ec4e51f21ea 100644
--- a/dotcom-rendering/src/components/SignInGateSelector.island.tsx
+++ b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
@@ -149,6 +149,7 @@ interface ShowSignInGateAuxiaProps {
browserId: string | undefined;
treatmentId: string;
renderingTarget: RenderingTarget;
+ isGandalf: boolean;
logTreatmentInteractionCall: (
interactionType: AuxiaInteractionInteractionType,
actionName?: AuxiaInteractionActionName,
@@ -276,13 +277,33 @@ const SignInGateSelectorAuxia = ({
undefined,
);
+ // Gandalf (comment group: gandalf) — the Guardian-managed sign-in gate
+ // journey (marketing name). SDC marks responses produced by the active
+ // Gandalf rules. For those responses we report to Ophan under a stable
+ // Gandalf identity with a per-country variant instead of the Auxia
+ // experiment metadata, and we never call Auxia's LogTreatmentInteraction
+ // endpoint. This is reporting metadata only — there is no A/B test
+ // allocation behind it.
+ const isGandalf =
+ auxiaGateDisplayData?.auxiaData.gandalfSignInGate === true;
+ const gandalfCountryCode = auxiaGateDisplayData?.gandalfCountryCode;
+
// We are using CurrentSignInGateABTest, with the details of the Auxia experiment,
// to allow Ophan tracking
- const abTest: CurrentSignInGateABTest = {
- name: 'AuxiaSignInGate', // value of dataLinkNames
- variant: 'auxia-signin-gate', // variant id
- id: 'AuxiaSignInGate', // test id
- };
+ const abTest: CurrentSignInGateABTest = isGandalf
+ ? {
+ name: 'GandalfSignInGate', // value of dataLinkNames
+ variant:
+ gandalfCountryCode !== undefined
+ ? `gandalf-${gandalfCountryCode.toLowerCase()}` // per-country variant
+ : 'gandalf-rollout', // variant id
+ id: 'GandalfSignInGate', // test id
+ }
+ : {
+ name: 'AuxiaSignInGate', // value of dataLinkNames
+ variant: 'auxia-signin-gate', // variant id
+ id: 'AuxiaSignInGate', // test id
+ };
const { renderingTarget } = useConfig();
@@ -292,7 +313,7 @@ const SignInGateSelectorAuxia = ({
// this hook will fire when the sign in gate is dismissed
// which will happen when the showGate state is set to false
// this only happens within the dismissGate method
- if (isGateDismissed) {
+ if (isGateDismissed === true) {
document.dispatchEvent(
new CustomEvent('article:sign-in-gate-dismissed'),
);
@@ -330,16 +351,20 @@ const SignInGateSelectorAuxia = ({
return (
<>
- {!isGateDismissed &&
+ {isGateDismissed !== true &&
auxiaGateDisplayData?.auxiaData.userTreatment !== undefined && (
setIsGateDismissed(!show)}
- abTest={buildAbTestTrackingAuxiaVariant(
- auxiaGateDisplayData.auxiaData.userTreatment
- .treatmentId,
- )}
+ abTest={
+ isGandalf
+ ? abTest
+ : buildAbTestTrackingAuxiaVariant(
+ auxiaGateDisplayData.auxiaData
+ .userTreatment.treatmentId,
+ )
+ }
userTreatment={
auxiaGateDisplayData.auxiaData.userTreatment
}
@@ -350,10 +375,16 @@ const SignInGateSelectorAuxia = ({
.treatmentId
}
renderingTarget={renderingTarget}
+ isGandalf={isGandalf}
logTreatmentInteractionCall={async (
interactionType: AuxiaInteractionInteractionType,
actionName?: AuxiaInteractionActionName,
) => {
+ // Gandalf: never contact Auxia for
+ // Guardian-managed treatments.
+ if (isGandalf) {
+ return;
+ }
await auxiaLogTreatmentInteraction(
contributionsServiceUrl,
auxiaGateDisplayData.auxiaData.userTreatment!,
@@ -390,6 +421,7 @@ const ShowSignInGateAuxia = ({
browserId,
treatmentId,
renderingTarget,
+ isGandalf,
logTreatmentInteractionCall,
signInGateVersion,
}: ShowSignInGateAuxiaProps) => {
@@ -413,26 +445,29 @@ const ShowSignInGateAuxia = ({
}, [setNode, setSignInGatePlaceholder]);
useEffect(() => {
- if (hasBeenSeen) {
+ if (hasBeenSeen === true) {
// Tell Auxia
- void auxiaLogTreatmentInteraction(
- contributionsServiceUrl,
- userTreatment,
- 'VIEWED',
- '',
- browserId,
- ).catch((error) => {
- const errorReport = new Error(
- `Failed to log treatment interaction`,
- {
- cause: error,
- },
- );
- window.guardian.modules.sentry.reportError(
- errorReport,
- 'sign-in-gate',
- );
- });
+ // Gandalf: never contact Auxia for Guardian-managed treatments.
+ if (!isGandalf) {
+ void auxiaLogTreatmentInteraction(
+ contributionsServiceUrl,
+ userTreatment,
+ 'VIEWED',
+ '',
+ browserId,
+ ).catch((error) => {
+ const errorReport = new Error(
+ `Failed to log treatment interaction`,
+ {
+ cause: error,
+ },
+ );
+ window.guardian.modules.sentry.reportError(
+ errorReport,
+ 'sign-in-gate',
+ );
+ });
+ }
// Tell Ophan
void submitComponentEventTracking(
@@ -465,6 +500,7 @@ const ShowSignInGateAuxia = ({
hasBeenSeen,
browserId,
contributionsServiceUrl,
+ isGandalf,
renderingTarget,
treatmentId,
userTreatment,
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
index d5830ed1e96..b2c9929b769 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
@@ -4,6 +4,7 @@ import { pickMessage } from '../lib/messagePicker';
import { useAB } from '../lib/useAB';
import { ConfigProvider } from './ConfigContext';
import { isInUsStateForAbTest } from './marketing/lib/consentBannerTest';
+import { canShowSignInGatePortal } from './StickyBottomBanner/SignInGatePortal';
import { StickyBottomBanner } from './StickyBottomBanner.island';
jest.mock('../lib/messagePicker', () => ({
@@ -241,4 +242,33 @@ describe('StickyBottomBanner', () => {
);
expect(candidateIds).toContain('reader-revenue-banner');
});
+
+ it('passes the pageview id and country to the sign-in gate candidate', async () => {
+ mockUseAB.mockReturnValue(undefined);
+ mockIsInUsState.mockReturnValue(false);
+ mockGetAlreadyVisitedCount.mockReturnValue(0);
+ // Invoke the candidates' canShow so the (mocked) sign-in gate portal
+ // receives its props, then resolve with no message.
+ mockPickMessage.mockImplementation(async (config) => {
+ await Promise.all(
+ config.candidates.map((candidateConfig) =>
+ candidateConfig.candidate.canShow().catch(() => undefined),
+ ),
+ );
+ return { type: 'NoMessageSelected' };
+ });
+
+ renderStickyBottomBanner();
+
+ await waitFor(() => {
+ expect(canShowSignInGatePortal).toHaveBeenCalled();
+ });
+
+ expect(canShowSignInGatePortal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ophanPageViewId: 'test-page-view-id',
+ countryCode: 'GB',
+ }),
+ );
+ });
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
index 00a3168518c..1c7aaa39fd2 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
@@ -372,6 +372,8 @@ export const StickyBottomBanner = ({
pageId,
contributionsServiceUrl,
editionId,
+ ophanPageViewId,
+ countryCode,
},
host,
);
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
index 51f1ba06ae5..f51645245bd 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
@@ -1,6 +1,8 @@
// Mock the auxia module before imports so the mock is applied when the module
// under test is evaluated.
import { buildAuxiaGateDisplayData } from '../../lib/auxia';
+import { incrementGandalfPageViewCount } from '../../lib/gandalf';
+import type { AuxiaAPIResponseDataUserTreatment } from '../SignInGate/types';
import type { AuxiaGateDisplayData } from '../SignInGate/types';
import type { CanShowSignInGateProps } from './SignInGatePortal';
import { canShowSignInGatePortal } from './SignInGatePortal';
@@ -10,6 +12,11 @@ jest.mock('../../lib/auxia', () => ({
buildAuxiaGateDisplayData: jest.fn(),
}));
+jest.mock('../../lib/gandalf', () => ({
+ getGandalfPageViewCount: jest.fn().mockReturnValue(0),
+ incrementGandalfPageViewCount: jest.fn(),
+}));
+
// Mock document.getElementById
const mockGetElementById = jest.fn();
Object.defineProperty(document, 'getElementById', {
@@ -26,8 +33,38 @@ const canShowProps: CanShowSignInGateProps = {
contentType: 'Article',
sectionId: 'section',
tags: [],
+ ophanPageViewId: 'test-page-view-id',
+ countryCode: 'NZ',
};
+const mockIncrementGandalfPageViewCount = jest.mocked(
+ incrementGandalfPageViewCount,
+);
+
+const makeUserTreatment = (
+ treatmentType: AuxiaAPIResponseDataUserTreatment['treatmentType'],
+): AuxiaAPIResponseDataUserTreatment => ({
+ treatmentId: 't1',
+ treatmentTrackingId: 'tt1',
+ rank: '1',
+ contentLanguageCode: 'en',
+ treatmentContent: 'content',
+ treatmentType,
+ surface: 'surface',
+});
+
+const makeAuxiaReturn = (
+ userTreatment: AuxiaAPIResponseDataUserTreatment | undefined,
+ gandalfSignInGate?: boolean,
+): AuxiaGateDisplayData => ({
+ browserId: 'browser-1',
+ auxiaData: {
+ responseId: 'resp1',
+ userTreatment,
+ ...(gandalfSignInGate !== undefined ? { gandalfSignInGate } : {}),
+ },
+});
+
describe('SignInGatePortal', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -143,7 +180,10 @@ describe('SignInGatePortal', () => {
const result = await canShowSignInGatePortal(canShowProps);
- expect(result).toEqual({ show: true, meta: auxiaReturn });
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
});
it('should return true when isSignedIn is undefined but other params allow gate', async () => {
@@ -176,7 +216,120 @@ describe('SignInGatePortal', () => {
isSignedIn: undefined,
});
- expect(result).toEqual({ show: true, meta: auxiaReturn });
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
+ });
+ });
+
+ describe('Gandalf (Guardian-managed sign-in gate journey)', () => {
+ it('sends the current per-country pageview count to SDC', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(makeAuxiaReturn(undefined, true));
+
+ await canShowSignInGatePortal(canShowProps);
+
+ expect(buildAuxiaGateDisplayData).toHaveBeenCalledWith(
+ 'https://contributions.local',
+ 'page-id',
+ 'UK',
+ 'Article',
+ 'section',
+ [],
+ 0,
+ 0,
+ );
+ });
+
+ it('counts the pageview when SDC returns the active Gandalf marker without a treatment', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(makeAuxiaReturn(undefined, true));
+
+ const result = await canShowSignInGatePortal(canShowProps);
+
+ // No gate on a free pageview, but the pageview counted. The meta
+ // carries the country so the selector can build the Ophan variant.
+ expect(result).toEqual({
+ show: false,
+ meta: {
+ ...makeAuxiaReturn(undefined, true),
+ gandalfCountryCode: 'NZ',
+ },
+ });
+ expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
+ 'NZ',
+ 'test-page-view-id',
+ );
+ });
+
+ it('counts the pageview when SDC returns the Gandalf popup treatment', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ const auxiaReturn = makeAuxiaReturn(
+ makeUserTreatment('NONDISMISSIBLE_SIGN_IN_GATE_POPUP'),
+ true,
+ );
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(auxiaReturn);
+
+ const result = await canShowSignInGatePortal(canShowProps);
+
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
+ expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
+ 'NZ',
+ 'test-page-view-id',
+ );
+ });
+
+ it('does not count the pageview without the Gandalf marker', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ const auxiaReturn = makeAuxiaReturn(
+ makeUserTreatment('DISMISSABLE_SIGN_IN_GATE'),
+ );
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(auxiaReturn);
+
+ const result = await canShowSignInGatePortal(canShowProps);
+
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
+ expect(mockIncrementGandalfPageViewCount).not.toHaveBeenCalled();
+ });
+
+ it('does not count the pageview when the marker is explicitly false', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ const auxiaReturn = makeAuxiaReturn(
+ makeUserTreatment('DISMISSABLE_SIGN_IN_GATE'),
+ false,
+ );
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(auxiaReturn);
+
+ await canShowSignInGatePortal(canShowProps);
+
+ expect(mockIncrementGandalfPageViewCount).not.toHaveBeenCalled();
});
});
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
index 48b00dffb0e..f9a1175a2c7 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
@@ -1,7 +1,12 @@
+import type { CountryCode } from '@guardian/libs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildAuxiaGateDisplayData } from '../../lib/auxia';
import type { EditionId } from '../../lib/edition';
+import {
+ getGandalfPageViewCount,
+ incrementGandalfPageViewCount,
+} from '../../lib/gandalf';
import type { CanShowResult } from '../../lib/messagePicker';
import { useAuthStatus } from '../../lib/useAuthStatus';
import type { TagType } from '../../types/tag';
@@ -150,6 +155,8 @@ export interface CanShowSignInGateProps {
contentType?: string;
sectionId?: string;
tags?: TagType[];
+ ophanPageViewId: string;
+ countryCode?: CountryCode;
}
export const canShowSignInGatePortal = async ({
isSignedIn,
@@ -161,8 +168,10 @@ export const canShowSignInGatePortal = async ({
contentType,
sectionId,
tags,
+ ophanPageViewId,
+ countryCode,
}: CanShowSignInGateProps): Promise> => {
- if (!window.guardian.config.switches.signInGate) {
+ if (window.guardian.config.switches.signInGate !== true) {
// Gates are disabled from the Frontend switchboard
return Promise.resolve({ show: false });
}
@@ -174,7 +183,7 @@ export const canShowSignInGatePortal = async ({
return Promise.resolve({ show: false });
}
- if (isPaidContent || isPreview || isSignedIn) {
+ if (isPaidContent || isPreview || isSignedIn === true) {
return Promise.resolve({ show: false });
}
@@ -195,17 +204,39 @@ export const canShowSignInGatePortal = async ({
try {
const auxiaData = await buildAuxiaGateDisplayData(
contributionsServiceUrl,
- pageId ?? '',
+ pageId,
editionId,
contentType,
sectionId,
tags,
retrieveLastGateDismissedCount('AuxiaSignInGate'),
+ // 0-based count of previously completed eligible pageviews for this
+ // country. SDC only consumes this for active Gandalf traffic; the
+ // counter itself is advanced below once SDC confirms the pageview
+ // counted.
+ getGandalfPageViewCount(countryCode ?? ''),
);
+ // Gandalf (the Guardian-managed sign-in gate journey): SDC marks
+ // responses produced by the active Gandalf rules. The pageview counted
+ // towards the country's free allowance even when no gate is displayed,
+ // so record it exactly once per pageview.
+ if (auxiaData?.auxiaData.gandalfSignInGate === true) {
+ incrementGandalfPageViewCount(countryCode ?? '', ophanPageViewId);
+ }
+
+ const meta = (
+ auxiaData
+ ? {
+ ...auxiaData,
+ gandalfCountryCode: countryCode,
+ }
+ : auxiaData
+ ) as AuxiaGateDisplayData;
+
return {
show: auxiaData?.auxiaData.userTreatment !== undefined,
- meta: auxiaData as AuxiaGateDisplayData,
+ meta,
};
} catch (e) {
const message = `SignInGatePortal canShowSignInGatePortal - error: ${String(
diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx
index 78a5ee7d428..436d3067a89 100644
--- a/dotcom-rendering/src/layouts/FrontLayout.tsx
+++ b/dotcom-rendering/src/layouts/FrontLayout.tsx
@@ -626,6 +626,9 @@ export const FrontLayout = ({ front, NAV }: Props) => {
/>
+ {/* Mount point for the sign-in gate portal, which is not provided by
+ an article body on fronts */}
+
{
/>
+ {/* Mount point for the sign-in gate portal, which is not
+ provided by an article body on full page interactives */}
+
{
editionId={frontendData.editionId}
/>
+ {/* Mount point for the sign-in gate portal, which is not
+ provided by an article body on galleries */}
+
{
/>
+ {/* Mount point for the sign-in gate portal: live blog bodies
+ render through LiveBlogRenderer, which provides no slot */}
+
{
/>
+ {/* Mount point for the sign-in gate portal, which is not
+ provided by an article body on picture pages */}
+
{
editionId={tagPage.editionId}
/>
+ {/* Mount point for the sign-in gate portal, which is not provided by
+ an article body on tag pages */}
+
=> {
const articleIdentifier = `www.theguardian.com/${pageId}`;
const url = `${contributionsServiceUrl}/auxia/get-treatments`;
@@ -147,6 +148,7 @@ const fetchProxyGetTreatments = async (
showDefaultGate,
gateDisplayCount,
hideSupportMessagingTimestamp,
+ gandalfPageViewCount,
};
const params = { method: 'POST', headers, body: JSON.stringify(payload) };
@@ -215,6 +217,7 @@ export const buildAuxiaGateDisplayData = async (
sectionId: string,
tags: TagType[],
gateDismissCount: number,
+ gandalfPageViewCount?: number,
): Promise => {
const readerPersonalData = await decideAuxiaProxyReaderPersonalData();
const tagIds = tags.map((tag) => tag.id);
@@ -242,6 +245,7 @@ export const buildAuxiaGateDisplayData = async (
showDefaultGate,
gateDisplayCount,
hideSupportMessagingTimestamp,
+ gandalfPageViewCount,
);
if (response.status && response.data) {
diff --git a/dotcom-rendering/src/lib/gandalf.test.ts b/dotcom-rendering/src/lib/gandalf.test.ts
new file mode 100644
index 00000000000..e87552c91be
--- /dev/null
+++ b/dotcom-rendering/src/lib/gandalf.test.ts
@@ -0,0 +1,76 @@
+import {
+ getGandalfPageViewCount,
+ incrementGandalfPageViewCount,
+} from './gandalf';
+
+// Mirrors the storage key shape in gandalf.ts
+const countKey = (countryCode: string): string =>
+ `gu.gandalf.pageViewCount.${countryCode.toLowerCase()}`;
+
+describe('gandalf pageview counter', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ describe('getGandalfPageViewCount', () => {
+ it('returns 0 when nothing is stored for the country', () => {
+ expect(getGandalfPageViewCount('NZ')).toBe(0);
+ });
+
+ it('returns the stored count for the country', () => {
+ localStorage.setItem(countKey('NZ'), '4');
+ expect(getGandalfPageViewCount('NZ')).toBe(4);
+ });
+
+ it('fails safe to 0 for malformed values', () => {
+ localStorage.setItem(countKey('NZ'), 'not-a-number');
+ expect(getGandalfPageViewCount('NZ')).toBe(0);
+ });
+
+ it('fails safe to 0 for negative values', () => {
+ localStorage.setItem(countKey('NZ'), '-3');
+ expect(getGandalfPageViewCount('NZ')).toBe(0);
+ });
+ });
+
+ describe('incrementGandalfPageViewCount', () => {
+ it('increments the country count by one', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ });
+
+ it('keeps one counter per country', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('NZ', 'page-view-2');
+ incrementGandalfPageViewCount('AU', 'page-view-2');
+ expect(getGandalfPageViewCount('NZ')).toBe(2);
+ expect(getGandalfPageViewCount('AU')).toBe(1);
+ });
+
+ it('is idempotent for the same pageview id within a country', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ });
+
+ it('counts the same pageview id separately per country', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('AU', 'page-view-2');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ expect(getGandalfPageViewCount('AU')).toBe(1);
+ });
+
+ it('continues from a pre-existing stored count', () => {
+ localStorage.setItem(countKey('NZ'), '2');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(3);
+ });
+
+ it('resets a malformed stored value to 1 on increment', () => {
+ localStorage.setItem(countKey('NZ'), 'garbage');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ });
+ });
+});
diff --git a/dotcom-rendering/src/lib/gandalf.ts b/dotcom-rendering/src/lib/gandalf.ts
new file mode 100644
index 00000000000..ae9619c7e4b
--- /dev/null
+++ b/dotcom-rendering/src/lib/gandalf.ts
@@ -0,0 +1,60 @@
+import { storage } from '@guardian/libs';
+
+// Gandalf: the Guardian-managed sign-in gate journey (marketing name).
+//
+// A 100% rollout, run entirely by Guardian rules with no Auxia involvement,
+// currently live for New Zealand and extendable to further countries via the
+// gandalfSignInGateCountries channel switch.
+//
+// A dedicated, persistent counter of completed eligible pageviews, kept per
+// country because campaigns differ by country group. It is deliberately
+// independent of `gu.history.dailyArticleCount` (which resets daily and only
+// counts a subset of content types) and of `gate_display_count` (which counts
+// gate renders, not pageviews).
+//
+// The counter is 0-based: it stores the number of eligible pageviews already
+// completed for the country. The first three eligible pageviews (counts 0, 1,
+// 2 sent to SDC) are free; from the fourth onwards SDC returns the
+// non-dismissible popup.
+//
+// The counter only advances after SDC confirms the pageview was an active,
+// eligible pageview for a Gandalf country (see the gandalfSignInGate response
+// marker), so unlisted-country traffic and excluded pages never consume the
+// allowance. It is browser-local: clearing storage or using a new/incognito
+// browser resets the allowance. This is accepted for the proof of concept.
+
+const pageViewCountKey = (countryCode: string): string =>
+ `gu.gandalf.pageViewCount.${countryCode.toLowerCase()}`;
+
+const lastCountedPageViewIdKey = (countryCode: string): string =>
+ `gu.gandalf.lastCountedPageViewId.${countryCode.toLowerCase()}`;
+
+/**
+ * Returns the 0-based number of eligible pageviews already completed in the
+ * given country. Fails safe to 0 if the stored value is missing or malformed.
+ */
+export const getGandalfPageViewCount = (countryCode: string): number => {
+ const raw = storage.local.getRaw(pageViewCountKey(countryCode));
+ const count = parseInt(raw ?? '', 10);
+ return Number.isInteger(count) && count >= 0 ? count : 0;
+};
+
+/**
+ * Records one completed eligible pageview for the country. Idempotent per
+ * pageview ID, so React effect re-runs (e.g. Strict Mode) increment at most
+ * once per pageview.
+ */
+export const incrementGandalfPageViewCount = (
+ countryCode: string,
+ pageViewId: string,
+): void => {
+ if (
+ storage.local.getRaw(lastCountedPageViewIdKey(countryCode)) ===
+ pageViewId
+ ) {
+ return;
+ }
+ storage.local.setRaw(lastCountedPageViewIdKey(countryCode), pageViewId);
+ const count = getGandalfPageViewCount(countryCode);
+ storage.local.setRaw(pageViewCountKey(countryCode), (count + 1).toString());
+};
diff --git a/dotcom-rendering/src/lib/withSignInGateSlot.test.tsx b/dotcom-rendering/src/lib/withSignInGateSlot.test.tsx
new file mode 100644
index 00000000000..cf9a2496247
--- /dev/null
+++ b/dotcom-rendering/src/lib/withSignInGateSlot.test.tsx
@@ -0,0 +1,78 @@
+import type { JSX } from 'react';
+import { renderToString } from 'react-dom/server';
+import { withSignInGateSlot } from './withSignInGateSlot';
+
+const makeElement = (key: number): JSX.Element => (
+ Element {key}
+);
+
+// renderToString inserts comment markers between text nodes and expressions;
+// strip them so substring assertions are stable.
+const stripMarkers = (html: string): string => html.replace(//g, '');
+
+const renderSlot = (
+ renderedElements: Array,
+): string =>
+ stripMarkers(
+ renderToString(
+ <>{withSignInGateSlot({ ...baseProps, renderedElements })}>,
+ ),
+ );
+
+const baseProps = {
+ contentType: 'Article',
+ sectionId: 'uk-news',
+ tags: [],
+ isPaidContent: false,
+ isPreview: false,
+ host: 'https://theguardian.com',
+ pageId: 'world/2026/sep/01/test',
+ idUrl: 'https://profile.theguardian.com',
+ isSensitive: false,
+ isDev: false,
+ contributionsServiceUrl: 'https://contributions.guardianapis.com',
+ editionId: 'UK' as const,
+};
+
+describe('withSignInGateSlot', () => {
+ it('inserts the placeholder after the second element', () => {
+ const html = renderSlot([
+ makeElement(0),
+ makeElement(1),
+ makeElement(2),
+ ]);
+
+ const secondElementEnd = html.indexOf('Element 1
');
+ const thirdElementStart = html.indexOf('Element 2');
+ const placeholderIndex = html.indexOf('id="sign-in-gate"');
+
+ expect(placeholderIndex).toBeGreaterThan(secondElementEnd);
+ expect(placeholderIndex).toBeLessThan(thirdElementStart);
+ });
+
+ it('provides exactly one placeholder', () => {
+ const html = renderSlot([
+ makeElement(0),
+ makeElement(1),
+ makeElement(2),
+ ]);
+
+ expect(html.split('id="sign-in-gate"')).toHaveLength(2); // one occurrence
+ });
+
+ it('appends the placeholder after the last element when the body has one element', () => {
+ const html = renderSlot([makeElement(0)]);
+
+ expect(html).toContain('Element 0');
+ expect(html).toContain('id="sign-in-gate"');
+ const elementEnd = html.indexOf('Element 0');
+ const placeholderIndex = html.indexOf('id="sign-in-gate"');
+ expect(placeholderIndex).toBeGreaterThan(elementEnd);
+ });
+
+ it('still provides a placeholder when the body has no elements', () => {
+ const html = renderSlot([]);
+
+ expect(html).toContain('id="sign-in-gate"');
+ });
+});
diff --git a/dotcom-rendering/src/lib/withSignInGateSlot.tsx b/dotcom-rendering/src/lib/withSignInGateSlot.tsx
index 131f2dc864f..037683d15e0 100644
--- a/dotcom-rendering/src/lib/withSignInGateSlot.tsx
+++ b/dotcom-rendering/src/lib/withSignInGateSlot.tsx
@@ -24,12 +24,23 @@ type Props = {
export const withSignInGateSlot = ({
renderedElements,
}: Props): React.ReactNode => {
+ // The SignInGatePortal requires a #sign-in-gate element to exist before it
+ // can select the gate, even when the (v2 popup) gate ultimately portals to
+ // document.body. Bodies with fewer than two renderable elements (e.g. some
+ // crosswords, pictures and interactives) would otherwise never provide one,
+ // so fall back to appending the placeholder after the last element.
+ if (renderedElements.length === 0) {
+ return [];
+ }
return renderedElements.map((element, i) => {
+ const isAfterSecondElement =
+ i === 1 ||
+ (renderedElements.length < 2 && i === renderedElements.length - 1);
return (
{element}
{/* Add the placeholder div after the second article element */}
- {i === 1 && }
+ {isAfterSecondElement && }
);
});
From 4c2c3aa66be18815697ee09ad1c93a3d4c565fe8 Mon Sep 17 00:00:00 2001
From: Juarez Mota
Date: Wed, 2 Sep 2026 20:44:22 +0100
Subject: [PATCH 2/6] Add Gandalf sign-in gate counter, popup hosting and Ophan
identity
Support the Gandalf sign-in gate journey (the Guardian-managed, Auxia-free
100% rollout): a dedicated persistent pageview counter, kept per country
because campaigns differ by country group and idempotent per Ophan pageview
id, that only advances once SDC confirms an active, eligible pageview via
the gandalfSignInGate response marker; the counter is sent to
/auxia/get-treatments so SDC can decide between the free-view marker and
the non-dismissible popup treatment. Gandalf responses report to Ophan
under a stable GandalfSignInGate identity with a per-country variant
(gandalf-) and never call Auxia for treatment interactions.
Sign-in gate mount points are added to fronts, tag pages, galleries,
pictures, liveblogs and full page interactives, and withSignInGateSlot now
guarantees a host for bodies with fewer than two elements so the v2 popup
can be selected everywhere the journey applies.
---
.../playwright/tests/banner.e2e.spec.ts | 62 ++++++-
.../src/components/SignInGate/types.ts | 28 ++++
.../components/SignInGateSelector.island.tsx | 96 +++++++----
.../StickyBottomBanner.island.test.tsx | 30 ++++
.../components/StickyBottomBanner.island.tsx | 2 +
.../SignInGatePortal.test.tsx | 157 +++++++++++++++++-
.../StickyBottomBanner/SignInGatePortal.tsx | 39 ++++-
dotcom-rendering/src/layouts/FrontLayout.tsx | 3 +
.../src/layouts/FullPageInteractiveLayout.tsx | 3 +
.../src/layouts/GalleryLayout.tsx | 3 +
dotcom-rendering/src/layouts/LiveLayout.tsx | 3 +
.../src/layouts/PictureLayout.tsx | 3 +
.../src/layouts/TagPageLayout.tsx | 3 +
dotcom-rendering/src/lib/auxia.ts | 4 +
dotcom-rendering/src/lib/gandalf.test.ts | 76 +++++++++
dotcom-rendering/src/lib/gandalf.ts | 60 +++++++
.../src/lib/withSignInGateSlot.test.tsx | 78 +++++++++
.../src/lib/withSignInGateSlot.tsx | 13 +-
18 files changed, 624 insertions(+), 39 deletions(-)
create mode 100644 dotcom-rendering/src/lib/gandalf.test.ts
create mode 100644 dotcom-rendering/src/lib/gandalf.ts
create mode 100644 dotcom-rendering/src/lib/withSignInGateSlot.test.tsx
diff --git a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
index 4a9549fa563..b96effb9401 100644
--- a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
+++ b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
@@ -103,14 +103,72 @@ test.describe('Sign-in gate portal', function () {
await cmpAcceptAll(page);
await page.evaluate(() => {
- // Set geolocation to IE to force the sign-in gate to appear
- window.localStorage.setItem('gu.geo.override', 'IE');
+ // Set geolocation to IE to force the sign-in gate to appear.
+ // storage.local expects the { value } wrapper format.
+ window.localStorage.setItem(
+ 'gu.geo.override',
+ JSON.stringify({ value: 'IE' }),
+ );
});
await page.reload({ waitUntil: 'domcontentloaded' });
await auxiaRequestPromise;
});
+
+ test('sends the Gandalf pageview counter for New Zealand readers', async ({
+ page,
+ context,
+ }) => {
+ await optOutOfArticleCountConsent(context);
+
+ const auxiaUrl =
+ 'https://contributions.guardianapis.com/auxia/get-treatments';
+ const auxiaRequestPromise = page.waitForRequest((request) => {
+ if (!requestBodyHasProperties(request, auxiaUrl, ['isSupporter'])) {
+ return false;
+ }
+ const body = request.postDataJSON() as Record;
+ // Match only the post-reload request: the first load runs with the
+ // default (GB) geolocation and also sends a count of 0.
+ return body.gandalfPageViewCount === 0 && body.countryCode === 'NZ';
+ });
+
+ await loadPage({
+ page,
+ path: ARTICLE_PATH,
+ waitUntil: 'domcontentloaded',
+ region: 'GB',
+ preventSupportBanner: false,
+ overrides: {
+ configOverrides: {
+ frontendAssetsFullURL: LOCAL_ASSET_ORIGIN,
+ },
+ },
+ });
+
+ await cmpAcceptAll(page);
+
+ // Set geolocation to NZ for the Gandalf proof of concept. This must be
+ // an init script (not page.evaluate) because loadPage registers its
+ // own init script that resets gu.geo.override to GB on every
+ // navigation, including the reload below; init scripts run in
+ // registration order, so this one runs last and wins. storage.local
+ // expects the { value } wrapper format that storage.local.set writes.
+ await page.addInitScript(() => {
+ window.localStorage.setItem(
+ 'gu.geo.override',
+ JSON.stringify({ value: 'NZ' }),
+ );
+ });
+
+ await page.reload({ waitUntil: 'domcontentloaded' });
+
+ const auxiaRequest = await auxiaRequestPromise;
+ const body = auxiaRequest.postDataJSON() as Record;
+ expect(body.countryCode).toBe('NZ');
+ expect(body.gandalfPageViewCount).toBe(0);
+ });
});
test.describe('Banner browserId targeting', function () {
diff --git a/dotcom-rendering/src/components/SignInGate/types.ts b/dotcom-rendering/src/components/SignInGate/types.ts
index ad269ce62d2..28e77db667d 100644
--- a/dotcom-rendering/src/components/SignInGate/types.ts
+++ b/dotcom-rendering/src/components/SignInGate/types.ts
@@ -137,6 +137,7 @@ export interface AuxiaProxyGetTreatmentsPayload {
showDefaultGate: ShowGateValues; // [3]
gateDisplayCount: number;
hideSupportMessagingTimestamp: number | undefined; // [4]
+ gandalfPageViewCount?: number; // [5] gandalfPageViewCount
}
// [1]
@@ -183,6 +184,23 @@ export interface AuxiaProxyGetTreatmentsPayload {
// It is either undefined or return the timestamp carried by cookie `gu_hide_support_messaging`
// See: https://github.com/guardian/support-frontend/blob/7a5c0f9209054c24934b876771392531c261f51c/support-frontend/assets/helpers/storage/contributionsCookies.ts#L11
+// [5] gandalfPageViewCount
+//
+// date: 2nd September 2026
+// comment group: gandalf
+//
+// "Gandalf" is the marketing name for the Guardian-managed sign-in gate
+// journey: a 100% rollout run entirely by Guardian rules with no Auxia
+// involvement, currently live for New Zealand and extendable to further
+// countries via the gandalfSignInGateCountries channel switch.
+//
+// `gandalfPageViewCount` is the 0-based number of eligible pageviews the
+// reader has already completed in the request's country under the active
+// Gandalf rules (see src/lib/gandalf.ts). Counters are per country, because
+// campaigns differ by country group. It is optional so older payloads and
+// traffic outside the Gandalf countries are unaffected; SDC treats a missing
+// value as 0.
+
export interface AuxiaProxyGetTreatmentsResponse {
status: boolean;
data?: AuxiaProxyGetTreatmentsProxyResponseData;
@@ -191,6 +209,12 @@ export interface AuxiaProxyGetTreatmentsResponse {
export interface AuxiaProxyGetTreatmentsProxyResponseData {
responseId: string;
userTreatment?: AuxiaAPIResponseDataUserTreatment;
+ // Set to true on responses produced by the active Gandalf rules, both
+ // when no gate should display (the pageview still counts towards the free
+ // allowance) and when the Guardian-managed non-dismissible popup is
+ // returned. When present, the client must not make any Auxia interaction
+ // call and reports to Ophan under the stable Gandalf identity.
+ gandalfSignInGate?: boolean;
}
// Log Treatment Interaction
@@ -235,6 +259,10 @@ export interface AuxiaGateReaderPersonalData {
export interface AuxiaGateDisplayData {
browserId: string | undefined;
auxiaData: AuxiaProxyGetTreatmentsProxyResponseData;
+ // The country code the gate request was made for. Set by the client so the
+ // selector can build the per-country Gandalf Ophan variant
+ // (gandalf-) without re-resolving geolocation.
+ gandalfCountryCode?: string;
}
export type SignInGatePropsAuxia = {
diff --git a/dotcom-rendering/src/components/SignInGateSelector.island.tsx b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
index 23f9d796d0f..ec4e51f21ea 100644
--- a/dotcom-rendering/src/components/SignInGateSelector.island.tsx
+++ b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
@@ -149,6 +149,7 @@ interface ShowSignInGateAuxiaProps {
browserId: string | undefined;
treatmentId: string;
renderingTarget: RenderingTarget;
+ isGandalf: boolean;
logTreatmentInteractionCall: (
interactionType: AuxiaInteractionInteractionType,
actionName?: AuxiaInteractionActionName,
@@ -276,13 +277,33 @@ const SignInGateSelectorAuxia = ({
undefined,
);
+ // Gandalf (comment group: gandalf) — the Guardian-managed sign-in gate
+ // journey (marketing name). SDC marks responses produced by the active
+ // Gandalf rules. For those responses we report to Ophan under a stable
+ // Gandalf identity with a per-country variant instead of the Auxia
+ // experiment metadata, and we never call Auxia's LogTreatmentInteraction
+ // endpoint. This is reporting metadata only — there is no A/B test
+ // allocation behind it.
+ const isGandalf =
+ auxiaGateDisplayData?.auxiaData.gandalfSignInGate === true;
+ const gandalfCountryCode = auxiaGateDisplayData?.gandalfCountryCode;
+
// We are using CurrentSignInGateABTest, with the details of the Auxia experiment,
// to allow Ophan tracking
- const abTest: CurrentSignInGateABTest = {
- name: 'AuxiaSignInGate', // value of dataLinkNames
- variant: 'auxia-signin-gate', // variant id
- id: 'AuxiaSignInGate', // test id
- };
+ const abTest: CurrentSignInGateABTest = isGandalf
+ ? {
+ name: 'GandalfSignInGate', // value of dataLinkNames
+ variant:
+ gandalfCountryCode !== undefined
+ ? `gandalf-${gandalfCountryCode.toLowerCase()}` // per-country variant
+ : 'gandalf-rollout', // variant id
+ id: 'GandalfSignInGate', // test id
+ }
+ : {
+ name: 'AuxiaSignInGate', // value of dataLinkNames
+ variant: 'auxia-signin-gate', // variant id
+ id: 'AuxiaSignInGate', // test id
+ };
const { renderingTarget } = useConfig();
@@ -292,7 +313,7 @@ const SignInGateSelectorAuxia = ({
// this hook will fire when the sign in gate is dismissed
// which will happen when the showGate state is set to false
// this only happens within the dismissGate method
- if (isGateDismissed) {
+ if (isGateDismissed === true) {
document.dispatchEvent(
new CustomEvent('article:sign-in-gate-dismissed'),
);
@@ -330,16 +351,20 @@ const SignInGateSelectorAuxia = ({
return (
<>
- {!isGateDismissed &&
+ {isGateDismissed !== true &&
auxiaGateDisplayData?.auxiaData.userTreatment !== undefined && (
setIsGateDismissed(!show)}
- abTest={buildAbTestTrackingAuxiaVariant(
- auxiaGateDisplayData.auxiaData.userTreatment
- .treatmentId,
- )}
+ abTest={
+ isGandalf
+ ? abTest
+ : buildAbTestTrackingAuxiaVariant(
+ auxiaGateDisplayData.auxiaData
+ .userTreatment.treatmentId,
+ )
+ }
userTreatment={
auxiaGateDisplayData.auxiaData.userTreatment
}
@@ -350,10 +375,16 @@ const SignInGateSelectorAuxia = ({
.treatmentId
}
renderingTarget={renderingTarget}
+ isGandalf={isGandalf}
logTreatmentInteractionCall={async (
interactionType: AuxiaInteractionInteractionType,
actionName?: AuxiaInteractionActionName,
) => {
+ // Gandalf: never contact Auxia for
+ // Guardian-managed treatments.
+ if (isGandalf) {
+ return;
+ }
await auxiaLogTreatmentInteraction(
contributionsServiceUrl,
auxiaGateDisplayData.auxiaData.userTreatment!,
@@ -390,6 +421,7 @@ const ShowSignInGateAuxia = ({
browserId,
treatmentId,
renderingTarget,
+ isGandalf,
logTreatmentInteractionCall,
signInGateVersion,
}: ShowSignInGateAuxiaProps) => {
@@ -413,26 +445,29 @@ const ShowSignInGateAuxia = ({
}, [setNode, setSignInGatePlaceholder]);
useEffect(() => {
- if (hasBeenSeen) {
+ if (hasBeenSeen === true) {
// Tell Auxia
- void auxiaLogTreatmentInteraction(
- contributionsServiceUrl,
- userTreatment,
- 'VIEWED',
- '',
- browserId,
- ).catch((error) => {
- const errorReport = new Error(
- `Failed to log treatment interaction`,
- {
- cause: error,
- },
- );
- window.guardian.modules.sentry.reportError(
- errorReport,
- 'sign-in-gate',
- );
- });
+ // Gandalf: never contact Auxia for Guardian-managed treatments.
+ if (!isGandalf) {
+ void auxiaLogTreatmentInteraction(
+ contributionsServiceUrl,
+ userTreatment,
+ 'VIEWED',
+ '',
+ browserId,
+ ).catch((error) => {
+ const errorReport = new Error(
+ `Failed to log treatment interaction`,
+ {
+ cause: error,
+ },
+ );
+ window.guardian.modules.sentry.reportError(
+ errorReport,
+ 'sign-in-gate',
+ );
+ });
+ }
// Tell Ophan
void submitComponentEventTracking(
@@ -465,6 +500,7 @@ const ShowSignInGateAuxia = ({
hasBeenSeen,
browserId,
contributionsServiceUrl,
+ isGandalf,
renderingTarget,
treatmentId,
userTreatment,
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
index d5830ed1e96..b2c9929b769 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
@@ -4,6 +4,7 @@ import { pickMessage } from '../lib/messagePicker';
import { useAB } from '../lib/useAB';
import { ConfigProvider } from './ConfigContext';
import { isInUsStateForAbTest } from './marketing/lib/consentBannerTest';
+import { canShowSignInGatePortal } from './StickyBottomBanner/SignInGatePortal';
import { StickyBottomBanner } from './StickyBottomBanner.island';
jest.mock('../lib/messagePicker', () => ({
@@ -241,4 +242,33 @@ describe('StickyBottomBanner', () => {
);
expect(candidateIds).toContain('reader-revenue-banner');
});
+
+ it('passes the pageview id and country to the sign-in gate candidate', async () => {
+ mockUseAB.mockReturnValue(undefined);
+ mockIsInUsState.mockReturnValue(false);
+ mockGetAlreadyVisitedCount.mockReturnValue(0);
+ // Invoke the candidates' canShow so the (mocked) sign-in gate portal
+ // receives its props, then resolve with no message.
+ mockPickMessage.mockImplementation(async (config) => {
+ await Promise.all(
+ config.candidates.map((candidateConfig) =>
+ candidateConfig.candidate.canShow().catch(() => undefined),
+ ),
+ );
+ return { type: 'NoMessageSelected' };
+ });
+
+ renderStickyBottomBanner();
+
+ await waitFor(() => {
+ expect(canShowSignInGatePortal).toHaveBeenCalled();
+ });
+
+ expect(canShowSignInGatePortal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ophanPageViewId: 'test-page-view-id',
+ countryCode: 'GB',
+ }),
+ );
+ });
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
index 00a3168518c..1c7aaa39fd2 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
@@ -372,6 +372,8 @@ export const StickyBottomBanner = ({
pageId,
contributionsServiceUrl,
editionId,
+ ophanPageViewId,
+ countryCode,
},
host,
);
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
index 51f1ba06ae5..f51645245bd 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
@@ -1,6 +1,8 @@
// Mock the auxia module before imports so the mock is applied when the module
// under test is evaluated.
import { buildAuxiaGateDisplayData } from '../../lib/auxia';
+import { incrementGandalfPageViewCount } from '../../lib/gandalf';
+import type { AuxiaAPIResponseDataUserTreatment } from '../SignInGate/types';
import type { AuxiaGateDisplayData } from '../SignInGate/types';
import type { CanShowSignInGateProps } from './SignInGatePortal';
import { canShowSignInGatePortal } from './SignInGatePortal';
@@ -10,6 +12,11 @@ jest.mock('../../lib/auxia', () => ({
buildAuxiaGateDisplayData: jest.fn(),
}));
+jest.mock('../../lib/gandalf', () => ({
+ getGandalfPageViewCount: jest.fn().mockReturnValue(0),
+ incrementGandalfPageViewCount: jest.fn(),
+}));
+
// Mock document.getElementById
const mockGetElementById = jest.fn();
Object.defineProperty(document, 'getElementById', {
@@ -26,8 +33,38 @@ const canShowProps: CanShowSignInGateProps = {
contentType: 'Article',
sectionId: 'section',
tags: [],
+ ophanPageViewId: 'test-page-view-id',
+ countryCode: 'NZ',
};
+const mockIncrementGandalfPageViewCount = jest.mocked(
+ incrementGandalfPageViewCount,
+);
+
+const makeUserTreatment = (
+ treatmentType: AuxiaAPIResponseDataUserTreatment['treatmentType'],
+): AuxiaAPIResponseDataUserTreatment => ({
+ treatmentId: 't1',
+ treatmentTrackingId: 'tt1',
+ rank: '1',
+ contentLanguageCode: 'en',
+ treatmentContent: 'content',
+ treatmentType,
+ surface: 'surface',
+});
+
+const makeAuxiaReturn = (
+ userTreatment: AuxiaAPIResponseDataUserTreatment | undefined,
+ gandalfSignInGate?: boolean,
+): AuxiaGateDisplayData => ({
+ browserId: 'browser-1',
+ auxiaData: {
+ responseId: 'resp1',
+ userTreatment,
+ ...(gandalfSignInGate !== undefined ? { gandalfSignInGate } : {}),
+ },
+});
+
describe('SignInGatePortal', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -143,7 +180,10 @@ describe('SignInGatePortal', () => {
const result = await canShowSignInGatePortal(canShowProps);
- expect(result).toEqual({ show: true, meta: auxiaReturn });
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
});
it('should return true when isSignedIn is undefined but other params allow gate', async () => {
@@ -176,7 +216,120 @@ describe('SignInGatePortal', () => {
isSignedIn: undefined,
});
- expect(result).toEqual({ show: true, meta: auxiaReturn });
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
+ });
+ });
+
+ describe('Gandalf (Guardian-managed sign-in gate journey)', () => {
+ it('sends the current per-country pageview count to SDC', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(makeAuxiaReturn(undefined, true));
+
+ await canShowSignInGatePortal(canShowProps);
+
+ expect(buildAuxiaGateDisplayData).toHaveBeenCalledWith(
+ 'https://contributions.local',
+ 'page-id',
+ 'UK',
+ 'Article',
+ 'section',
+ [],
+ 0,
+ 0,
+ );
+ });
+
+ it('counts the pageview when SDC returns the active Gandalf marker without a treatment', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(makeAuxiaReturn(undefined, true));
+
+ const result = await canShowSignInGatePortal(canShowProps);
+
+ // No gate on a free pageview, but the pageview counted. The meta
+ // carries the country so the selector can build the Ophan variant.
+ expect(result).toEqual({
+ show: false,
+ meta: {
+ ...makeAuxiaReturn(undefined, true),
+ gandalfCountryCode: 'NZ',
+ },
+ });
+ expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
+ 'NZ',
+ 'test-page-view-id',
+ );
+ });
+
+ it('counts the pageview when SDC returns the Gandalf popup treatment', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ const auxiaReturn = makeAuxiaReturn(
+ makeUserTreatment('NONDISMISSIBLE_SIGN_IN_GATE_POPUP'),
+ true,
+ );
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(auxiaReturn);
+
+ const result = await canShowSignInGatePortal(canShowProps);
+
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
+ expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
+ 'NZ',
+ 'test-page-view-id',
+ );
+ });
+
+ it('does not count the pageview without the Gandalf marker', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ const auxiaReturn = makeAuxiaReturn(
+ makeUserTreatment('DISMISSABLE_SIGN_IN_GATE'),
+ );
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(auxiaReturn);
+
+ const result = await canShowSignInGatePortal(canShowProps);
+
+ expect(result).toEqual({
+ show: true,
+ meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ });
+ expect(mockIncrementGandalfPageViewCount).not.toHaveBeenCalled();
+ });
+
+ it('does not count the pageview when the marker is explicitly false', async () => {
+ mockGetElementById.mockReturnValue(document.createElement('div'));
+ const auxiaReturn = makeAuxiaReturn(
+ makeUserTreatment('DISMISSABLE_SIGN_IN_GATE'),
+ false,
+ );
+ (
+ buildAuxiaGateDisplayData as jest.MockedFunction<
+ typeof buildAuxiaGateDisplayData
+ >
+ ).mockResolvedValue(auxiaReturn);
+
+ await canShowSignInGatePortal(canShowProps);
+
+ expect(mockIncrementGandalfPageViewCount).not.toHaveBeenCalled();
});
});
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
index 48b00dffb0e..f9a1175a2c7 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
@@ -1,7 +1,12 @@
+import type { CountryCode } from '@guardian/libs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildAuxiaGateDisplayData } from '../../lib/auxia';
import type { EditionId } from '../../lib/edition';
+import {
+ getGandalfPageViewCount,
+ incrementGandalfPageViewCount,
+} from '../../lib/gandalf';
import type { CanShowResult } from '../../lib/messagePicker';
import { useAuthStatus } from '../../lib/useAuthStatus';
import type { TagType } from '../../types/tag';
@@ -150,6 +155,8 @@ export interface CanShowSignInGateProps {
contentType?: string;
sectionId?: string;
tags?: TagType[];
+ ophanPageViewId: string;
+ countryCode?: CountryCode;
}
export const canShowSignInGatePortal = async ({
isSignedIn,
@@ -161,8 +168,10 @@ export const canShowSignInGatePortal = async ({
contentType,
sectionId,
tags,
+ ophanPageViewId,
+ countryCode,
}: CanShowSignInGateProps): Promise> => {
- if (!window.guardian.config.switches.signInGate) {
+ if (window.guardian.config.switches.signInGate !== true) {
// Gates are disabled from the Frontend switchboard
return Promise.resolve({ show: false });
}
@@ -174,7 +183,7 @@ export const canShowSignInGatePortal = async ({
return Promise.resolve({ show: false });
}
- if (isPaidContent || isPreview || isSignedIn) {
+ if (isPaidContent || isPreview || isSignedIn === true) {
return Promise.resolve({ show: false });
}
@@ -195,17 +204,39 @@ export const canShowSignInGatePortal = async ({
try {
const auxiaData = await buildAuxiaGateDisplayData(
contributionsServiceUrl,
- pageId ?? '',
+ pageId,
editionId,
contentType,
sectionId,
tags,
retrieveLastGateDismissedCount('AuxiaSignInGate'),
+ // 0-based count of previously completed eligible pageviews for this
+ // country. SDC only consumes this for active Gandalf traffic; the
+ // counter itself is advanced below once SDC confirms the pageview
+ // counted.
+ getGandalfPageViewCount(countryCode ?? ''),
);
+ // Gandalf (the Guardian-managed sign-in gate journey): SDC marks
+ // responses produced by the active Gandalf rules. The pageview counted
+ // towards the country's free allowance even when no gate is displayed,
+ // so record it exactly once per pageview.
+ if (auxiaData?.auxiaData.gandalfSignInGate === true) {
+ incrementGandalfPageViewCount(countryCode ?? '', ophanPageViewId);
+ }
+
+ const meta = (
+ auxiaData
+ ? {
+ ...auxiaData,
+ gandalfCountryCode: countryCode,
+ }
+ : auxiaData
+ ) as AuxiaGateDisplayData;
+
return {
show: auxiaData?.auxiaData.userTreatment !== undefined,
- meta: auxiaData as AuxiaGateDisplayData,
+ meta,
};
} catch (e) {
const message = `SignInGatePortal canShowSignInGatePortal - error: ${String(
diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx
index 78a5ee7d428..436d3067a89 100644
--- a/dotcom-rendering/src/layouts/FrontLayout.tsx
+++ b/dotcom-rendering/src/layouts/FrontLayout.tsx
@@ -626,6 +626,9 @@ export const FrontLayout = ({ front, NAV }: Props) => {
/>
+ {/* Mount point for the sign-in gate portal, which is not provided by
+ an article body on fronts */}
+
{
/>
+ {/* Mount point for the sign-in gate portal, which is not
+ provided by an article body on full page interactives */}
+
{
editionId={frontendData.editionId}
/>
+ {/* Mount point for the sign-in gate portal, which is not
+ provided by an article body on galleries */}
+
{
/>
+ {/* Mount point for the sign-in gate portal: live blog bodies
+ render through LiveBlogRenderer, which provides no slot */}
+
{
/>
+ {/* Mount point for the sign-in gate portal, which is not
+ provided by an article body on picture pages */}
+
{
editionId={tagPage.editionId}
/>
+ {/* Mount point for the sign-in gate portal, which is not provided by
+ an article body on tag pages */}
+
=> {
const articleIdentifier = `www.theguardian.com/${pageId}`;
const url = `${contributionsServiceUrl}/auxia/get-treatments`;
@@ -147,6 +148,7 @@ const fetchProxyGetTreatments = async (
showDefaultGate,
gateDisplayCount,
hideSupportMessagingTimestamp,
+ gandalfPageViewCount,
};
const params = { method: 'POST', headers, body: JSON.stringify(payload) };
@@ -215,6 +217,7 @@ export const buildAuxiaGateDisplayData = async (
sectionId: string,
tags: TagType[],
gateDismissCount: number,
+ gandalfPageViewCount?: number,
): Promise => {
const readerPersonalData = await decideAuxiaProxyReaderPersonalData();
const tagIds = tags.map((tag) => tag.id);
@@ -242,6 +245,7 @@ export const buildAuxiaGateDisplayData = async (
showDefaultGate,
gateDisplayCount,
hideSupportMessagingTimestamp,
+ gandalfPageViewCount,
);
if (response.status && response.data) {
diff --git a/dotcom-rendering/src/lib/gandalf.test.ts b/dotcom-rendering/src/lib/gandalf.test.ts
new file mode 100644
index 00000000000..e87552c91be
--- /dev/null
+++ b/dotcom-rendering/src/lib/gandalf.test.ts
@@ -0,0 +1,76 @@
+import {
+ getGandalfPageViewCount,
+ incrementGandalfPageViewCount,
+} from './gandalf';
+
+// Mirrors the storage key shape in gandalf.ts
+const countKey = (countryCode: string): string =>
+ `gu.gandalf.pageViewCount.${countryCode.toLowerCase()}`;
+
+describe('gandalf pageview counter', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ describe('getGandalfPageViewCount', () => {
+ it('returns 0 when nothing is stored for the country', () => {
+ expect(getGandalfPageViewCount('NZ')).toBe(0);
+ });
+
+ it('returns the stored count for the country', () => {
+ localStorage.setItem(countKey('NZ'), '4');
+ expect(getGandalfPageViewCount('NZ')).toBe(4);
+ });
+
+ it('fails safe to 0 for malformed values', () => {
+ localStorage.setItem(countKey('NZ'), 'not-a-number');
+ expect(getGandalfPageViewCount('NZ')).toBe(0);
+ });
+
+ it('fails safe to 0 for negative values', () => {
+ localStorage.setItem(countKey('NZ'), '-3');
+ expect(getGandalfPageViewCount('NZ')).toBe(0);
+ });
+ });
+
+ describe('incrementGandalfPageViewCount', () => {
+ it('increments the country count by one', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ });
+
+ it('keeps one counter per country', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('NZ', 'page-view-2');
+ incrementGandalfPageViewCount('AU', 'page-view-2');
+ expect(getGandalfPageViewCount('NZ')).toBe(2);
+ expect(getGandalfPageViewCount('AU')).toBe(1);
+ });
+
+ it('is idempotent for the same pageview id within a country', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ });
+
+ it('counts the same pageview id separately per country', () => {
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ incrementGandalfPageViewCount('AU', 'page-view-2');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ expect(getGandalfPageViewCount('AU')).toBe(1);
+ });
+
+ it('continues from a pre-existing stored count', () => {
+ localStorage.setItem(countKey('NZ'), '2');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(3);
+ });
+
+ it('resets a malformed stored value to 1 on increment', () => {
+ localStorage.setItem(countKey('NZ'), 'garbage');
+ incrementGandalfPageViewCount('NZ', 'page-view-1');
+ expect(getGandalfPageViewCount('NZ')).toBe(1);
+ });
+ });
+});
diff --git a/dotcom-rendering/src/lib/gandalf.ts b/dotcom-rendering/src/lib/gandalf.ts
new file mode 100644
index 00000000000..ae9619c7e4b
--- /dev/null
+++ b/dotcom-rendering/src/lib/gandalf.ts
@@ -0,0 +1,60 @@
+import { storage } from '@guardian/libs';
+
+// Gandalf: the Guardian-managed sign-in gate journey (marketing name).
+//
+// A 100% rollout, run entirely by Guardian rules with no Auxia involvement,
+// currently live for New Zealand and extendable to further countries via the
+// gandalfSignInGateCountries channel switch.
+//
+// A dedicated, persistent counter of completed eligible pageviews, kept per
+// country because campaigns differ by country group. It is deliberately
+// independent of `gu.history.dailyArticleCount` (which resets daily and only
+// counts a subset of content types) and of `gate_display_count` (which counts
+// gate renders, not pageviews).
+//
+// The counter is 0-based: it stores the number of eligible pageviews already
+// completed for the country. The first three eligible pageviews (counts 0, 1,
+// 2 sent to SDC) are free; from the fourth onwards SDC returns the
+// non-dismissible popup.
+//
+// The counter only advances after SDC confirms the pageview was an active,
+// eligible pageview for a Gandalf country (see the gandalfSignInGate response
+// marker), so unlisted-country traffic and excluded pages never consume the
+// allowance. It is browser-local: clearing storage or using a new/incognito
+// browser resets the allowance. This is accepted for the proof of concept.
+
+const pageViewCountKey = (countryCode: string): string =>
+ `gu.gandalf.pageViewCount.${countryCode.toLowerCase()}`;
+
+const lastCountedPageViewIdKey = (countryCode: string): string =>
+ `gu.gandalf.lastCountedPageViewId.${countryCode.toLowerCase()}`;
+
+/**
+ * Returns the 0-based number of eligible pageviews already completed in the
+ * given country. Fails safe to 0 if the stored value is missing or malformed.
+ */
+export const getGandalfPageViewCount = (countryCode: string): number => {
+ const raw = storage.local.getRaw(pageViewCountKey(countryCode));
+ const count = parseInt(raw ?? '', 10);
+ return Number.isInteger(count) && count >= 0 ? count : 0;
+};
+
+/**
+ * Records one completed eligible pageview for the country. Idempotent per
+ * pageview ID, so React effect re-runs (e.g. Strict Mode) increment at most
+ * once per pageview.
+ */
+export const incrementGandalfPageViewCount = (
+ countryCode: string,
+ pageViewId: string,
+): void => {
+ if (
+ storage.local.getRaw(lastCountedPageViewIdKey(countryCode)) ===
+ pageViewId
+ ) {
+ return;
+ }
+ storage.local.setRaw(lastCountedPageViewIdKey(countryCode), pageViewId);
+ const count = getGandalfPageViewCount(countryCode);
+ storage.local.setRaw(pageViewCountKey(countryCode), (count + 1).toString());
+};
diff --git a/dotcom-rendering/src/lib/withSignInGateSlot.test.tsx b/dotcom-rendering/src/lib/withSignInGateSlot.test.tsx
new file mode 100644
index 00000000000..cf9a2496247
--- /dev/null
+++ b/dotcom-rendering/src/lib/withSignInGateSlot.test.tsx
@@ -0,0 +1,78 @@
+import type { JSX } from 'react';
+import { renderToString } from 'react-dom/server';
+import { withSignInGateSlot } from './withSignInGateSlot';
+
+const makeElement = (key: number): JSX.Element => (
+ Element {key}
+);
+
+// renderToString inserts comment markers between text nodes and expressions;
+// strip them so substring assertions are stable.
+const stripMarkers = (html: string): string => html.replace(//g, '');
+
+const renderSlot = (
+ renderedElements: Array,
+): string =>
+ stripMarkers(
+ renderToString(
+ <>{withSignInGateSlot({ ...baseProps, renderedElements })}>,
+ ),
+ );
+
+const baseProps = {
+ contentType: 'Article',
+ sectionId: 'uk-news',
+ tags: [],
+ isPaidContent: false,
+ isPreview: false,
+ host: 'https://theguardian.com',
+ pageId: 'world/2026/sep/01/test',
+ idUrl: 'https://profile.theguardian.com',
+ isSensitive: false,
+ isDev: false,
+ contributionsServiceUrl: 'https://contributions.guardianapis.com',
+ editionId: 'UK' as const,
+};
+
+describe('withSignInGateSlot', () => {
+ it('inserts the placeholder after the second element', () => {
+ const html = renderSlot([
+ makeElement(0),
+ makeElement(1),
+ makeElement(2),
+ ]);
+
+ const secondElementEnd = html.indexOf('Element 1');
+ const thirdElementStart = html.indexOf('Element 2');
+ const placeholderIndex = html.indexOf('id="sign-in-gate"');
+
+ expect(placeholderIndex).toBeGreaterThan(secondElementEnd);
+ expect(placeholderIndex).toBeLessThan(thirdElementStart);
+ });
+
+ it('provides exactly one placeholder', () => {
+ const html = renderSlot([
+ makeElement(0),
+ makeElement(1),
+ makeElement(2),
+ ]);
+
+ expect(html.split('id="sign-in-gate"')).toHaveLength(2); // one occurrence
+ });
+
+ it('appends the placeholder after the last element when the body has one element', () => {
+ const html = renderSlot([makeElement(0)]);
+
+ expect(html).toContain('Element 0');
+ expect(html).toContain('id="sign-in-gate"');
+ const elementEnd = html.indexOf('Element 0');
+ const placeholderIndex = html.indexOf('id="sign-in-gate"');
+ expect(placeholderIndex).toBeGreaterThan(elementEnd);
+ });
+
+ it('still provides a placeholder when the body has no elements', () => {
+ const html = renderSlot([]);
+
+ expect(html).toContain('id="sign-in-gate"');
+ });
+});
diff --git a/dotcom-rendering/src/lib/withSignInGateSlot.tsx b/dotcom-rendering/src/lib/withSignInGateSlot.tsx
index 131f2dc864f..037683d15e0 100644
--- a/dotcom-rendering/src/lib/withSignInGateSlot.tsx
+++ b/dotcom-rendering/src/lib/withSignInGateSlot.tsx
@@ -24,12 +24,23 @@ type Props = {
export const withSignInGateSlot = ({
renderedElements,
}: Props): React.ReactNode => {
+ // The SignInGatePortal requires a #sign-in-gate element to exist before it
+ // can select the gate, even when the (v2 popup) gate ultimately portals to
+ // document.body. Bodies with fewer than two renderable elements (e.g. some
+ // crosswords, pictures and interactives) would otherwise never provide one,
+ // so fall back to appending the placeholder after the last element.
+ if (renderedElements.length === 0) {
+ return [];
+ }
return renderedElements.map((element, i) => {
+ const isAfterSecondElement =
+ i === 1 ||
+ (renderedElements.length < 2 && i === renderedElements.length - 1);
return (
{element}
{/* Add the placeholder div after the second article element */}
- {i === 1 && }
+ {isAfterSecondElement && }
);
});
From 2a9362afe1e5ffa8ffe4e26dbbf96ce778c590ad Mon Sep 17 00:00:00 2001
From: Juarez Mota
Date: Thu, 10 Sep 2026 12:13:15 +0100
Subject: [PATCH 3/6] Show the Gandalf mandatory popup without waiting for
scroll
The v2 gate only rendered once the inline #sign-in-gate host element
intersected the viewport (useIsInView). That is correct for the inline
dismissible gate, but the non-dismissible popup is a modal: on long pages
the host sits far below the fold, so the popup never appeared. Render it
on mount and record its VIEW event immediately.
---
.../src/components/SignInGateSelector.island.tsx | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/dotcom-rendering/src/components/SignInGateSelector.island.tsx b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
index ec4e51f21ea..5a688f56a8d 100644
--- a/dotcom-rendering/src/components/SignInGateSelector.island.tsx
+++ b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
@@ -436,6 +436,12 @@ const ShowSignInGateAuxia = ({
threshold: 0,
});
+ // The non-dismissible popup is a modal, so it must appear immediately
+ // rather than waiting for the reader to scroll to the inline host
+ // element, which on long pages sits far below the viewport.
+ const isMandatoryPopup =
+ userTreatment.treatmentType === 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP';
+
useEffect(() => {
const signInGate = document.getElementById('sign-in-gate');
if (signInGate) {
@@ -445,7 +451,9 @@ const ShowSignInGateAuxia = ({
}, [setNode, setSignInGatePlaceholder]);
useEffect(() => {
- if (hasBeenSeen === true) {
+ // The mandatory popup is shown on mount (see shouldShowV2Gate), so
+ // its view is recorded immediately instead of waiting for scroll.
+ if (hasBeenSeen === true || isMandatoryPopup) {
// Tell Auxia
// Gandalf: never contact Auxia for Guardian-managed treatments.
if (!isGandalf) {
@@ -498,6 +506,7 @@ const ShowSignInGateAuxia = ({
}
}, [
hasBeenSeen,
+ isMandatoryPopup,
browserId,
contributionsServiceUrl,
isGandalf,
@@ -533,7 +542,7 @@ const ShowSignInGateAuxia = ({
setHasScroll(scrollHeight > viewportHeight);
}, []);
- const shouldShowV2Gate = hasBeenSeen ?? !hasScroll;
+ const shouldShowV2Gate = isMandatoryPopup || (hasBeenSeen ?? !hasScroll);
return (
<>
From 31259dc70f1183c295ede9c1926588e295c9f22f Mon Sep 17 00:00:00 2001
From: Juarez Mota
Date: Fri, 11 Sep 2026 10:01:36 +0100
Subject: [PATCH 4/6] Add view deduplication to prevent duplicate Gandalf
sign-in gate tracking
The mandatory popup now records its VIEW event exactly once per treatment
identity (treatmentId + treatmentTrackingId) by tracking the last recorded
view in a ref. Visibility changes, equivalent treatment objects supplied as
new props, and StrictMode effect replays no longer duplicate the view count,
Ophan event, or Auxia interaction. A new treatment identity or component
remount still records a fresh view as intended.
---
.../SignInGateSelector.island.test.tsx | 190 ++++++++++++++++++
.../components/SignInGateSelector.island.tsx | 14 +-
2 files changed, 203 insertions(+), 1 deletion(-)
create mode 100644 dotcom-rendering/src/components/SignInGateSelector.island.test.tsx
diff --git a/dotcom-rendering/src/components/SignInGateSelector.island.test.tsx b/dotcom-rendering/src/components/SignInGateSelector.island.test.tsx
new file mode 100644
index 00000000000..93d3243e2f3
--- /dev/null
+++ b/dotcom-rendering/src/components/SignInGateSelector.island.test.tsx
@@ -0,0 +1,190 @@
+import { storage } from '@guardian/libs';
+import { cleanup, render, screen as testScreen } from '@testing-library/react';
+import { StrictMode } from 'react';
+import { useIsInView } from '../lib/useIsInView';
+import { submitComponentEventTracking } from './SignInGate/componentEventTracking';
+import type { AuxiaAPIResponseDataUserTreatment } from './SignInGate/types';
+import { SignInGateSelector } from './SignInGateSelector.island';
+
+jest.mock('../lib/useIsInView', () => ({ useIsInView: jest.fn() }));
+jest.mock('../lib/usePageViewId', () => ({
+ usePageViewId: () => 'test-page-view',
+}));
+jest.mock('./ConfigContext', () => ({
+ useConfig: () => ({ renderingTarget: 'Web' }),
+}));
+jest.mock('./SignInGate/componentEventTracking', () => ({
+ submitComponentEventTracking: jest.fn().mockResolvedValue(undefined),
+}));
+jest.mock('./SignInGate/gateDesigns/SignInGateAuxiaV1', () => ({
+ SignInGateAuxiaV1: () => ,
+}));
+jest.mock('./SignInGate/gateDesigns/SignInGateAuxiaV2', () => ({
+ SignInGateAuxiaV2: () => ,
+}));
+
+const makeTreatment = (
+ overrides: Partial = {},
+): AuxiaAPIResponseDataUserTreatment => ({
+ treatmentId: 'test-treatment',
+ treatmentTrackingId: 'test-tracking',
+ treatmentType: 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP',
+ treatmentContent: '{}',
+ rank: '1',
+ contentLanguageCode: 'en',
+ surface: 'test-surface',
+ ...overrides,
+});
+
+const makeProps = (
+ userTreatment = makeTreatment(),
+ gandalfSignInGate = true,
+) => ({
+ isPaidContent: false,
+ isPreview: false,
+ pageId: 'crosswords/quick/16914',
+ contributionsServiceUrl: 'https://contributions.example.com',
+ auxiaGateDisplayData: {
+ browserId: undefined,
+ gandalfCountryCode: 'NZ',
+ auxiaData: {
+ responseId: 'test-response',
+ gandalfSignInGate,
+ userTreatment,
+ },
+ },
+});
+
+const mockSetNode = jest.fn();
+const mockUseIsInView = jest.mocked(useIsInView);
+const mockTrack = jest.mocked(submitComponentEventTracking);
+const mockModalOpen = jest.fn();
+const mockFetch = jest.fn, Parameters>();
+const originalFetch = global.fetch;
+
+const expectViews = (count: number) => {
+ expect(mockTrack).toHaveBeenCalledTimes(count);
+ expect(storage.local.getRaw('gate_display_count')).toBe(String(count));
+ expect(mockModalOpen).toHaveBeenCalledTimes(count);
+};
+
+describe('SignInGateSelector view tracking', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ global.fetch = mockFetch;
+ mockFetch.mockResolvedValue({
+ json: () => Promise.resolve({}),
+ } as Response);
+ storage.local.setRaw('gate_display_count', '0');
+ mockUseIsInView.mockReturnValue([null, mockSetNode]);
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ configurable: true,
+ value: 3000,
+ });
+ document.addEventListener('modal:open', mockModalOpen);
+ });
+
+ afterEach(() => {
+ cleanup();
+ document.removeEventListener('modal:open', mockModalOpen);
+ jest.restoreAllMocks();
+ global.fetch = originalFetch;
+ });
+
+ it('shows a mandatory popup without scrolling and does not recount when the placeholder becomes visible', () => {
+ const props = makeProps();
+ const { rerender } = render();
+
+ expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
+ expectViews(1);
+ expect(mockTrack).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'VIEW' }),
+ 'Web',
+ );
+
+ mockUseIsInView.mockReturnValue([true, mockSetNode]);
+ rerender();
+ expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
+ expectViews(1);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('does not recount an equivalent treatment supplied as a new object', () => {
+ const { rerender } = render();
+ rerender();
+ expectViews(1);
+ });
+
+ it.each([
+ { treatmentId: 'another-treatment' },
+ { treatmentTrackingId: 'another-tracking' },
+ ])('records a new treatment identity: %j', (identity) => {
+ const { rerender } = render();
+ rerender(
+ ,
+ );
+ expectViews(2);
+ });
+
+ it('records another view when the gate is unmounted and displayed again', () => {
+ const { unmount } = render();
+ unmount();
+ render();
+ expectViews(2);
+ });
+
+ it('does not duplicate a view when StrictMode replays effects', () => {
+ render(
+
+
+ ,
+ );
+ expectViews(1);
+ });
+
+ it('keeps a dismissible popup deferred until visibility and records it only once', () => {
+ const treatment = makeTreatment({
+ treatmentType: 'DISMISSABLE_SIGN_IN_GATE_POPUP',
+ });
+ const { rerender } = render(
+ ,
+ );
+ expect(testScreen.queryByTestId('v2-gate')).not.toBeInTheDocument();
+ expectViews(0);
+
+ mockUseIsInView.mockReturnValue([true, mockSetNode]);
+ rerender();
+ expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
+ expectViews(1);
+ rerender();
+ expectViews(1);
+ });
+
+ it('preserves inline gate rendering and visibility-based tracking', () => {
+ const props = makeProps(
+ makeTreatment({ treatmentType: 'DISMISSABLE_SIGN_IN_GATE' }),
+ );
+ const { rerender } = render();
+ expect(testScreen.getByTestId('v1-gate')).toBeInTheDocument();
+ expectViews(0);
+ mockUseIsInView.mockReturnValue([true, mockSetNode]);
+ rerender();
+ expectViews(1);
+ });
+
+ it('records the Auxia VIEWED interaction only once for non-Gandalf treatments', () => {
+ const props = makeProps(makeTreatment(), false);
+ const { rerender } = render();
+ mockUseIsInView.mockReturnValue([true, mockSetNode]);
+ rerender();
+ expectViews(1);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://contributions.example.com/auxia/log-treatment-interaction',
+ expect.objectContaining({ method: 'POST' }),
+ );
+ expect(mockFetch.mock.calls[0]?.[1]?.body).toContain(
+ '"interactionType":"VIEWED"',
+ );
+ });
+});
diff --git a/dotcom-rendering/src/components/SignInGateSelector.island.tsx b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
index 5a688f56a8d..2fe00dcbb1a 100644
--- a/dotcom-rendering/src/components/SignInGateSelector.island.tsx
+++ b/dotcom-rendering/src/components/SignInGateSelector.island.tsx
@@ -1,5 +1,5 @@
import { getCookie, isUndefined, storage } from '@guardian/libs';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { constructQuery } from '../lib/querystring';
import { useIsInView } from '../lib/useIsInView';
import { useOnce } from '../lib/useOnce';
@@ -441,6 +441,7 @@ const ShowSignInGateAuxia = ({
// element, which on long pages sits far below the viewport.
const isMandatoryPopup =
userTreatment.treatmentType === 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP';
+ const lastRecordedView = useRef();
useEffect(() => {
const signInGate = document.getElementById('sign-in-gate');
@@ -454,6 +455,17 @@ const ShowSignInGateAuxia = ({
// The mandatory popup is shown on mount (see shouldShowV2Gate), so
// its view is recorded immediately instead of waiting for scroll.
if (hasBeenSeen === true || isMandatoryPopup) {
+ const viewIdentity = JSON.stringify([
+ treatmentId,
+ userTreatment.treatmentTrackingId,
+ ]);
+ // Visibility changes and equivalent treatment objects must not
+ // record the same display again. A new treatment or mount can.
+ if (lastRecordedView.current === viewIdentity) {
+ return;
+ }
+ lastRecordedView.current = viewIdentity;
+
// Tell Auxia
// Gandalf: never contact Auxia for Guardian-managed treatments.
if (!isGandalf) {
From fd536f7205ef394d124320ee5f58e365a59f1ec0 Mon Sep 17 00:00:00 2001
From: Juarez Mota
Date: Mon, 14 Sep 2026 10:16:05 +0100
Subject: [PATCH 5/6] Remove per-country Gandalf pageview counter in favor of
global count
The Gandalf sign-in gate counter is now global rather than per-country. Storage keys, function signatures, and tests updated to remove countryCode parameter. Counter still advances once per eligible pageview via idempotent pageview ID tracking.
---
.../src/components/SignInGate/types.ts | 8 +--
.../SignInGatePortal.test.tsx | 4 +-
.../StickyBottomBanner/SignInGatePortal.tsx | 14 ++--
dotcom-rendering/src/lib/gandalf.test.ts | 66 ++++++++-----------
dotcom-rendering/src/lib/gandalf.ts | 51 ++++++--------
5 files changed, 60 insertions(+), 83 deletions(-)
diff --git a/dotcom-rendering/src/components/SignInGate/types.ts b/dotcom-rendering/src/components/SignInGate/types.ts
index 28e77db667d..0321fc87ce0 100644
--- a/dotcom-rendering/src/components/SignInGate/types.ts
+++ b/dotcom-rendering/src/components/SignInGate/types.ts
@@ -195,11 +195,9 @@ export interface AuxiaProxyGetTreatmentsPayload {
// countries via the gandalfSignInGateCountries channel switch.
//
// `gandalfPageViewCount` is the 0-based number of eligible pageviews the
-// reader has already completed in the request's country under the active
-// Gandalf rules (see src/lib/gandalf.ts). Counters are per country, because
-// campaigns differ by country group. It is optional so older payloads and
-// traffic outside the Gandalf countries are unaffected; SDC treats a missing
-// value as 0.
+// reader has already completed under the active Gandalf rules (see
+// src/lib/gandalf.ts). It is optional so older payloads and traffic outside
+// the Gandalf countries are unaffected; SDC treats a missing value as 0.
export interface AuxiaProxyGetTreatmentsResponse {
status: boolean;
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
index f51645245bd..aa27a59de92 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
@@ -224,7 +224,7 @@ describe('SignInGatePortal', () => {
});
describe('Gandalf (Guardian-managed sign-in gate journey)', () => {
- it('sends the current per-country pageview count to SDC', async () => {
+ it('sends the current pageview count to SDC', async () => {
mockGetElementById.mockReturnValue(document.createElement('div'));
(
buildAuxiaGateDisplayData as jest.MockedFunction<
@@ -266,7 +266,6 @@ describe('SignInGatePortal', () => {
},
});
expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
- 'NZ',
'test-page-view-id',
);
});
@@ -290,7 +289,6 @@ describe('SignInGatePortal', () => {
meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
});
expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
- 'NZ',
'test-page-view-id',
);
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
index f9a1175a2c7..619b4ded3d4 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
@@ -210,19 +210,19 @@ export const canShowSignInGatePortal = async ({
sectionId,
tags,
retrieveLastGateDismissedCount('AuxiaSignInGate'),
- // 0-based count of previously completed eligible pageviews for this
- // country. SDC only consumes this for active Gandalf traffic; the
- // counter itself is advanced below once SDC confirms the pageview
+ // 0-based count of previously completed eligible pageviews. SDC
+ // only consumes this for active Gandalf traffic; the counter
+ // itself is advanced below once SDC confirms the pageview
// counted.
- getGandalfPageViewCount(countryCode ?? ''),
+ getGandalfPageViewCount(),
);
// Gandalf (the Guardian-managed sign-in gate journey): SDC marks
// responses produced by the active Gandalf rules. The pageview counted
- // towards the country's free allowance even when no gate is displayed,
- // so record it exactly once per pageview.
+ // towards the free allowance even when no gate is displayed, so record
+ // it exactly once per pageview.
if (auxiaData?.auxiaData.gandalfSignInGate === true) {
- incrementGandalfPageViewCount(countryCode ?? '', ophanPageViewId);
+ incrementGandalfPageViewCount(ophanPageViewId);
}
const meta = (
diff --git a/dotcom-rendering/src/lib/gandalf.test.ts b/dotcom-rendering/src/lib/gandalf.test.ts
index e87552c91be..9def69db189 100644
--- a/dotcom-rendering/src/lib/gandalf.test.ts
+++ b/dotcom-rendering/src/lib/gandalf.test.ts
@@ -4,8 +4,7 @@ import {
} from './gandalf';
// Mirrors the storage key shape in gandalf.ts
-const countKey = (countryCode: string): string =>
- `gu.gandalf.pageViewCount.${countryCode.toLowerCase()}`;
+const countKey = 'gu.gandalf.pageViewCount';
describe('gandalf pageview counter', () => {
beforeEach(() => {
@@ -13,64 +12,55 @@ describe('gandalf pageview counter', () => {
});
describe('getGandalfPageViewCount', () => {
- it('returns 0 when nothing is stored for the country', () => {
- expect(getGandalfPageViewCount('NZ')).toBe(0);
+ it('returns 0 when nothing is stored', () => {
+ expect(getGandalfPageViewCount()).toBe(0);
});
- it('returns the stored count for the country', () => {
- localStorage.setItem(countKey('NZ'), '4');
- expect(getGandalfPageViewCount('NZ')).toBe(4);
+ it('returns the stored count', () => {
+ localStorage.setItem(countKey, '4');
+ expect(getGandalfPageViewCount()).toBe(4);
});
it('fails safe to 0 for malformed values', () => {
- localStorage.setItem(countKey('NZ'), 'not-a-number');
- expect(getGandalfPageViewCount('NZ')).toBe(0);
+ localStorage.setItem(countKey, 'not-a-number');
+ expect(getGandalfPageViewCount()).toBe(0);
});
it('fails safe to 0 for negative values', () => {
- localStorage.setItem(countKey('NZ'), '-3');
- expect(getGandalfPageViewCount('NZ')).toBe(0);
+ localStorage.setItem(countKey, '-3');
+ expect(getGandalfPageViewCount()).toBe(0);
});
});
describe('incrementGandalfPageViewCount', () => {
- it('increments the country count by one', () => {
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- expect(getGandalfPageViewCount('NZ')).toBe(1);
+ it('increments the count by one', () => {
+ incrementGandalfPageViewCount('page-view-1');
+ expect(getGandalfPageViewCount()).toBe(1);
});
- it('keeps one counter per country', () => {
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- incrementGandalfPageViewCount('NZ', 'page-view-2');
- incrementGandalfPageViewCount('AU', 'page-view-2');
- expect(getGandalfPageViewCount('NZ')).toBe(2);
- expect(getGandalfPageViewCount('AU')).toBe(1);
+ it('is idempotent for the same pageview id', () => {
+ incrementGandalfPageViewCount('page-view-1');
+ incrementGandalfPageViewCount('page-view-1');
+ incrementGandalfPageViewCount('page-view-1');
+ expect(getGandalfPageViewCount()).toBe(1);
});
- it('is idempotent for the same pageview id within a country', () => {
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- expect(getGandalfPageViewCount('NZ')).toBe(1);
- });
-
- it('counts the same pageview id separately per country', () => {
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- incrementGandalfPageViewCount('AU', 'page-view-2');
- expect(getGandalfPageViewCount('NZ')).toBe(1);
- expect(getGandalfPageViewCount('AU')).toBe(1);
+ it('counts distinct pageview ids separately', () => {
+ incrementGandalfPageViewCount('page-view-1');
+ incrementGandalfPageViewCount('page-view-2');
+ expect(getGandalfPageViewCount()).toBe(2);
});
it('continues from a pre-existing stored count', () => {
- localStorage.setItem(countKey('NZ'), '2');
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- expect(getGandalfPageViewCount('NZ')).toBe(3);
+ localStorage.setItem(countKey, '2');
+ incrementGandalfPageViewCount('page-view-1');
+ expect(getGandalfPageViewCount()).toBe(3);
});
it('resets a malformed stored value to 1 on increment', () => {
- localStorage.setItem(countKey('NZ'), 'garbage');
- incrementGandalfPageViewCount('NZ', 'page-view-1');
- expect(getGandalfPageViewCount('NZ')).toBe(1);
+ localStorage.setItem(countKey, 'garbage');
+ incrementGandalfPageViewCount('page-view-1');
+ expect(getGandalfPageViewCount()).toBe(1);
});
});
});
diff --git a/dotcom-rendering/src/lib/gandalf.ts b/dotcom-rendering/src/lib/gandalf.ts
index ae9619c7e4b..866dc3a0371 100644
--- a/dotcom-rendering/src/lib/gandalf.ts
+++ b/dotcom-rendering/src/lib/gandalf.ts
@@ -6,16 +6,15 @@ import { storage } from '@guardian/libs';
// currently live for New Zealand and extendable to further countries via the
// gandalfSignInGateCountries channel switch.
//
-// A dedicated, persistent counter of completed eligible pageviews, kept per
-// country because campaigns differ by country group. It is deliberately
-// independent of `gu.history.dailyArticleCount` (which resets daily and only
-// counts a subset of content types) and of `gate_display_count` (which counts
-// gate renders, not pageviews).
+// A dedicated, persistent counter of completed eligible pageviews. It is
+// deliberately independent of `gu.history.dailyArticleCount` (which resets
+// daily and only counts a subset of content types), of `gate_display_count`
+// (which counts gate renders, not pageviews) and of `gu.alreadyVisited`
+// (which counts every consented pageview and is an ad-targeting input).
//
// The counter is 0-based: it stores the number of eligible pageviews already
-// completed for the country. The first three eligible pageviews (counts 0, 1,
-// 2 sent to SDC) are free; from the fourth onwards SDC returns the
-// non-dismissible popup.
+// completed. The first three eligible pageviews (counts 0, 1, 2 sent to SDC)
+// are free; from the fourth onwards SDC returns the non-dismissible popup.
//
// The counter only advances after SDC confirms the pageview was an active,
// eligible pageview for a Gandalf country (see the gandalfSignInGate response
@@ -23,38 +22,30 @@ import { storage } from '@guardian/libs';
// allowance. It is browser-local: clearing storage or using a new/incognito
// browser resets the allowance. This is accepted for the proof of concept.
-const pageViewCountKey = (countryCode: string): string =>
- `gu.gandalf.pageViewCount.${countryCode.toLowerCase()}`;
+const pageViewCountKey = 'gu.gandalf.pageViewCount';
-const lastCountedPageViewIdKey = (countryCode: string): string =>
- `gu.gandalf.lastCountedPageViewId.${countryCode.toLowerCase()}`;
+const lastCountedPageViewIdKey = 'gu.gandalf.lastCountedPageViewId';
/**
- * Returns the 0-based number of eligible pageviews already completed in the
- * given country. Fails safe to 0 if the stored value is missing or malformed.
+ * Returns the 0-based number of eligible pageviews already completed.
+ * Fails safe to 0 if the stored value is missing or malformed.
*/
-export const getGandalfPageViewCount = (countryCode: string): number => {
- const raw = storage.local.getRaw(pageViewCountKey(countryCode));
+export const getGandalfPageViewCount = (): number => {
+ const raw = storage.local.getRaw(pageViewCountKey);
const count = parseInt(raw ?? '', 10);
return Number.isInteger(count) && count >= 0 ? count : 0;
};
/**
- * Records one completed eligible pageview for the country. Idempotent per
- * pageview ID, so React effect re-runs (e.g. Strict Mode) increment at most
- * once per pageview.
+ * Records one completed eligible pageview. Idempotent per pageview ID, so
+ * React effect re-runs (e.g. Strict Mode) increment at most once per
+ * pageview.
*/
-export const incrementGandalfPageViewCount = (
- countryCode: string,
- pageViewId: string,
-): void => {
- if (
- storage.local.getRaw(lastCountedPageViewIdKey(countryCode)) ===
- pageViewId
- ) {
+export const incrementGandalfPageViewCount = (pageViewId: string): void => {
+ if (storage.local.getRaw(lastCountedPageViewIdKey) === pageViewId) {
return;
}
- storage.local.setRaw(lastCountedPageViewIdKey(countryCode), pageViewId);
- const count = getGandalfPageViewCount(countryCode);
- storage.local.setRaw(pageViewCountKey(countryCode), (count + 1).toString());
+ storage.local.setRaw(lastCountedPageViewIdKey, pageViewId);
+ const count = getGandalfPageViewCount();
+ storage.local.setRaw(pageViewCountKey, (count + 1).toString());
};
From d7f7665d1f7a66b60b8b3262b770507468ca28f0 Mon Sep 17 00:00:00 2001
From: Juarez Mota
Date: Mon, 14 Sep 2026 17:19:56 +0100
Subject: [PATCH 6/6] Replace Gandalf pageview counter with daily article count
for sign-in gate
The Gandalf sign-in gate now uses gu.history.dailyArticleCount instead of a dedicated persistent counter. The daily count resets each day and is sent 0-based (current pageview excluded) to SDC. Remove gandalf.ts module, incrementGandalfPageViewCount calls, ophanPageViewId parameter from canShowSignInGatePortal, and update tests and comments to reflect the new counting mechanism.
---
.../playwright/tests/banner.e2e.spec.ts | 10 +-
.../src/components/SignInGate/types.ts | 9 +-
.../StickyBottomBanner.island.test.tsx | 3 +-
.../components/StickyBottomBanner.island.tsx | 1 -
.../SignInGatePortal.test.tsx | 93 ++++++++-----------
.../StickyBottomBanner/SignInGatePortal.tsx | 32 +++----
dotcom-rendering/src/lib/gandalf.test.ts | 66 -------------
dotcom-rendering/src/lib/gandalf.ts | 51 ----------
8 files changed, 63 insertions(+), 202 deletions(-)
delete mode 100644 dotcom-rendering/src/lib/gandalf.test.ts
delete mode 100644 dotcom-rendering/src/lib/gandalf.ts
diff --git a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
index b96effb9401..4e307974e8d 100644
--- a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
+++ b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
@@ -116,7 +116,7 @@ test.describe('Sign-in gate portal', function () {
await auxiaRequestPromise;
});
- test('sends the Gandalf pageview counter for New Zealand readers', async ({
+ test('sends the daily view count for New Zealand readers', async ({
page,
context,
}) => {
@@ -130,8 +130,8 @@ test.describe('Sign-in gate portal', function () {
}
const body = request.postDataJSON() as Record;
// Match only the post-reload request: the first load runs with the
- // default (GB) geolocation and also sends a count of 0.
- return body.gandalfPageViewCount === 0 && body.countryCode === 'NZ';
+ // default (GB) geolocation.
+ return body.countryCode === 'NZ';
});
await loadPage({
@@ -167,7 +167,9 @@ test.describe('Sign-in gate portal', function () {
const auxiaRequest = await auxiaRequestPromise;
const body = auxiaRequest.postDataJSON() as Record;
expect(body.countryCode).toBe('NZ');
- expect(body.gandalfPageViewCount).toBe(0);
+ // Two article loads today (each increments gu.history.dailyArticleCount),
+ // sent 0-based, so the second load sends 1.
+ expect(body.gandalfPageViewCount).toBe(1);
});
});
diff --git a/dotcom-rendering/src/components/SignInGate/types.ts b/dotcom-rendering/src/components/SignInGate/types.ts
index 0321fc87ce0..e66e6a6c5e3 100644
--- a/dotcom-rendering/src/components/SignInGate/types.ts
+++ b/dotcom-rendering/src/components/SignInGate/types.ts
@@ -194,10 +194,11 @@ export interface AuxiaProxyGetTreatmentsPayload {
// involvement, currently live for New Zealand and extendable to further
// countries via the gandalfSignInGateCountries channel switch.
//
-// `gandalfPageViewCount` is the 0-based number of eligible pageviews the
-// reader has already completed under the active Gandalf rules (see
-// src/lib/gandalf.ts). It is optional so older payloads and traffic outside
-// the Gandalf countries are unaffected; SDC treats a missing value as 0.
+// `gandalfPageViewCount` is the 0-based number of views the reader has
+// already completed today (gu.history.dailyArticleCount, see
+// src/lib/dailyArticleCount.ts). It is optional so older payloads and traffic
+// outside the Gandalf countries are unaffected; SDC treats a missing value
+// as 0.
export interface AuxiaProxyGetTreatmentsResponse {
status: boolean;
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
index b2c9929b769..9fa8433afed 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
@@ -243,7 +243,7 @@ describe('StickyBottomBanner', () => {
expect(candidateIds).toContain('reader-revenue-banner');
});
- it('passes the pageview id and country to the sign-in gate candidate', async () => {
+ it('passes the country to the sign-in gate candidate', async () => {
mockUseAB.mockReturnValue(undefined);
mockIsInUsState.mockReturnValue(false);
mockGetAlreadyVisitedCount.mockReturnValue(0);
@@ -266,7 +266,6 @@ describe('StickyBottomBanner', () => {
expect(canShowSignInGatePortal).toHaveBeenCalledWith(
expect.objectContaining({
- ophanPageViewId: 'test-page-view-id',
countryCode: 'GB',
}),
);
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
index 1c7aaa39fd2..44a20ce3958 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
@@ -372,7 +372,6 @@ export const StickyBottomBanner = ({
pageId,
contributionsServiceUrl,
editionId,
- ophanPageViewId,
countryCode,
},
host,
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
index aa27a59de92..0d4b0a193d1 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
@@ -1,7 +1,7 @@
// Mock the auxia module before imports so the mock is applied when the module
// under test is evaluated.
import { buildAuxiaGateDisplayData } from '../../lib/auxia';
-import { incrementGandalfPageViewCount } from '../../lib/gandalf';
+import { getDailyArticleCount, getToday } from '../../lib/dailyArticleCount';
import type { AuxiaAPIResponseDataUserTreatment } from '../SignInGate/types';
import type { AuxiaGateDisplayData } from '../SignInGate/types';
import type { CanShowSignInGateProps } from './SignInGatePortal';
@@ -12,9 +12,9 @@ jest.mock('../../lib/auxia', () => ({
buildAuxiaGateDisplayData: jest.fn(),
}));
-jest.mock('../../lib/gandalf', () => ({
- getGandalfPageViewCount: jest.fn().mockReturnValue(0),
- incrementGandalfPageViewCount: jest.fn(),
+jest.mock('../../lib/dailyArticleCount', () => ({
+ getDailyArticleCount: jest.fn().mockReturnValue(undefined),
+ getToday: jest.fn().mockReturnValue(200),
}));
// Mock document.getElementById
@@ -23,6 +23,9 @@ Object.defineProperty(document, 'getElementById', {
value: mockGetElementById,
});
+const mockGetDailyArticleCount = jest.mocked(getDailyArticleCount);
+const mockGetToday = jest.mocked(getToday);
+
const canShowProps: CanShowSignInGateProps = {
isSignedIn: false,
isPaidContent: false,
@@ -33,14 +36,9 @@ const canShowProps: CanShowSignInGateProps = {
contentType: 'Article',
sectionId: 'section',
tags: [],
- ophanPageViewId: 'test-page-view-id',
countryCode: 'NZ',
};
-const mockIncrementGandalfPageViewCount = jest.mocked(
- incrementGandalfPageViewCount,
-);
-
const makeUserTreatment = (
treatmentType: AuxiaAPIResponseDataUserTreatment['treatmentType'],
): AuxiaAPIResponseDataUserTreatment => ({
@@ -224,8 +222,12 @@ describe('SignInGatePortal', () => {
});
describe('Gandalf (Guardian-managed sign-in gate journey)', () => {
- it('sends the current pageview count to SDC', async () => {
+ it('sends today’s view count (0-based) to SDC', async () => {
mockGetElementById.mockReturnValue(document.createElement('div'));
+ // 4 views today: the current pageview is included, so the portal
+ // sends 3 (0-based).
+ mockGetDailyArticleCount.mockReturnValue([{ day: 200, count: 4 }]);
+ mockGetToday.mockReturnValue(200);
(
buildAuxiaGateDisplayData as jest.MockedFunction<
typeof buildAuxiaGateDisplayData
@@ -242,61 +244,60 @@ describe('SignInGatePortal', () => {
'section',
[],
0,
- 0,
+ 3,
);
});
- it('counts the pageview when SDC returns the active Gandalf marker without a treatment', async () => {
+ it('sends 0 when the latest daily count is not from today', async () => {
mockGetElementById.mockReturnValue(document.createElement('div'));
+ mockGetDailyArticleCount.mockReturnValue([{ day: 199, count: 9 }]);
+ mockGetToday.mockReturnValue(200);
(
buildAuxiaGateDisplayData as jest.MockedFunction<
typeof buildAuxiaGateDisplayData
>
).mockResolvedValue(makeAuxiaReturn(undefined, true));
- const result = await canShowSignInGatePortal(canShowProps);
+ await canShowSignInGatePortal(canShowProps);
- // No gate on a free pageview, but the pageview counted. The meta
- // carries the country so the selector can build the Ophan variant.
- expect(result).toEqual({
- show: false,
- meta: {
- ...makeAuxiaReturn(undefined, true),
- gandalfCountryCode: 'NZ',
- },
- });
- expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
- 'test-page-view-id',
+ expect(buildAuxiaGateDisplayData).toHaveBeenCalledWith(
+ 'https://contributions.local',
+ 'page-id',
+ 'UK',
+ 'Article',
+ 'section',
+ [],
+ 0,
+ 0,
);
});
- it('counts the pageview when SDC returns the Gandalf popup treatment', async () => {
+ it('returns no gate but carries the marker metadata on a free Gandalf pageview', async () => {
mockGetElementById.mockReturnValue(document.createElement('div'));
- const auxiaReturn = makeAuxiaReturn(
- makeUserTreatment('NONDISMISSIBLE_SIGN_IN_GATE_POPUP'),
- true,
- );
(
buildAuxiaGateDisplayData as jest.MockedFunction<
typeof buildAuxiaGateDisplayData
>
- ).mockResolvedValue(auxiaReturn);
+ ).mockResolvedValue(makeAuxiaReturn(undefined, true));
const result = await canShowSignInGatePortal(canShowProps);
+ // No gate on a free pageview. The meta carries the country so the
+ // selector can build the Ophan variant.
expect(result).toEqual({
- show: true,
- meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
+ show: false,
+ meta: {
+ ...makeAuxiaReturn(undefined, true),
+ gandalfCountryCode: 'NZ',
+ },
});
- expect(mockIncrementGandalfPageViewCount).toHaveBeenCalledWith(
- 'test-page-view-id',
- );
});
- it('does not count the pageview without the Gandalf marker', async () => {
+ it('shows the gate when SDC returns the Gandalf popup treatment', async () => {
mockGetElementById.mockReturnValue(document.createElement('div'));
const auxiaReturn = makeAuxiaReturn(
- makeUserTreatment('DISMISSABLE_SIGN_IN_GATE'),
+ makeUserTreatment('NONDISMISSIBLE_SIGN_IN_GATE_POPUP'),
+ true,
);
(
buildAuxiaGateDisplayData as jest.MockedFunction<
@@ -310,24 +311,6 @@ describe('SignInGatePortal', () => {
show: true,
meta: { ...auxiaReturn, gandalfCountryCode: 'NZ' },
});
- expect(mockIncrementGandalfPageViewCount).not.toHaveBeenCalled();
- });
-
- it('does not count the pageview when the marker is explicitly false', async () => {
- mockGetElementById.mockReturnValue(document.createElement('div'));
- const auxiaReturn = makeAuxiaReturn(
- makeUserTreatment('DISMISSABLE_SIGN_IN_GATE'),
- false,
- );
- (
- buildAuxiaGateDisplayData as jest.MockedFunction<
- typeof buildAuxiaGateDisplayData
- >
- ).mockResolvedValue(auxiaReturn);
-
- await canShowSignInGatePortal(canShowProps);
-
- expect(mockIncrementGandalfPageViewCount).not.toHaveBeenCalled();
});
});
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
index 619b4ded3d4..fa64173158f 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
@@ -2,11 +2,8 @@ import type { CountryCode } from '@guardian/libs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildAuxiaGateDisplayData } from '../../lib/auxia';
+import { getDailyArticleCount, getToday } from '../../lib/dailyArticleCount';
import type { EditionId } from '../../lib/edition';
-import {
- getGandalfPageViewCount,
- incrementGandalfPageViewCount,
-} from '../../lib/gandalf';
import type { CanShowResult } from '../../lib/messagePicker';
import { useAuthStatus } from '../../lib/useAuthStatus';
import type { TagType } from '../../types/tag';
@@ -155,7 +152,6 @@ export interface CanShowSignInGateProps {
contentType?: string;
sectionId?: string;
tags?: TagType[];
- ophanPageViewId: string;
countryCode?: CountryCode;
}
export const canShowSignInGatePortal = async ({
@@ -168,7 +164,6 @@ export const canShowSignInGatePortal = async ({
contentType,
sectionId,
tags,
- ophanPageViewId,
countryCode,
}: CanShowSignInGateProps): Promise> => {
if (window.guardian.config.switches.signInGate !== true) {
@@ -202,6 +197,17 @@ export const canShowSignInGatePortal = async ({
}
try {
+ // Today's view count (gu.history.dailyArticleCount). The count is
+ // incremented for the current pageview before the banner flow runs,
+ // so it is 1-based: the 4th view of the day sends 3. SDC only
+ // consumes this for active Gandalf traffic.
+ const dailyHistory = getDailyArticleCount();
+ const latestDay = dailyHistory?.[0];
+ const viewCountToday =
+ latestDay?.day === getToday()
+ ? Math.max(latestDay.count - 1, 0)
+ : 0;
+
const auxiaData = await buildAuxiaGateDisplayData(
contributionsServiceUrl,
pageId,
@@ -210,21 +216,9 @@ export const canShowSignInGatePortal = async ({
sectionId,
tags,
retrieveLastGateDismissedCount('AuxiaSignInGate'),
- // 0-based count of previously completed eligible pageviews. SDC
- // only consumes this for active Gandalf traffic; the counter
- // itself is advanced below once SDC confirms the pageview
- // counted.
- getGandalfPageViewCount(),
+ viewCountToday,
);
- // Gandalf (the Guardian-managed sign-in gate journey): SDC marks
- // responses produced by the active Gandalf rules. The pageview counted
- // towards the free allowance even when no gate is displayed, so record
- // it exactly once per pageview.
- if (auxiaData?.auxiaData.gandalfSignInGate === true) {
- incrementGandalfPageViewCount(ophanPageViewId);
- }
-
const meta = (
auxiaData
? {
diff --git a/dotcom-rendering/src/lib/gandalf.test.ts b/dotcom-rendering/src/lib/gandalf.test.ts
deleted file mode 100644
index 9def69db189..00000000000
--- a/dotcom-rendering/src/lib/gandalf.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import {
- getGandalfPageViewCount,
- incrementGandalfPageViewCount,
-} from './gandalf';
-
-// Mirrors the storage key shape in gandalf.ts
-const countKey = 'gu.gandalf.pageViewCount';
-
-describe('gandalf pageview counter', () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- describe('getGandalfPageViewCount', () => {
- it('returns 0 when nothing is stored', () => {
- expect(getGandalfPageViewCount()).toBe(0);
- });
-
- it('returns the stored count', () => {
- localStorage.setItem(countKey, '4');
- expect(getGandalfPageViewCount()).toBe(4);
- });
-
- it('fails safe to 0 for malformed values', () => {
- localStorage.setItem(countKey, 'not-a-number');
- expect(getGandalfPageViewCount()).toBe(0);
- });
-
- it('fails safe to 0 for negative values', () => {
- localStorage.setItem(countKey, '-3');
- expect(getGandalfPageViewCount()).toBe(0);
- });
- });
-
- describe('incrementGandalfPageViewCount', () => {
- it('increments the count by one', () => {
- incrementGandalfPageViewCount('page-view-1');
- expect(getGandalfPageViewCount()).toBe(1);
- });
-
- it('is idempotent for the same pageview id', () => {
- incrementGandalfPageViewCount('page-view-1');
- incrementGandalfPageViewCount('page-view-1');
- incrementGandalfPageViewCount('page-view-1');
- expect(getGandalfPageViewCount()).toBe(1);
- });
-
- it('counts distinct pageview ids separately', () => {
- incrementGandalfPageViewCount('page-view-1');
- incrementGandalfPageViewCount('page-view-2');
- expect(getGandalfPageViewCount()).toBe(2);
- });
-
- it('continues from a pre-existing stored count', () => {
- localStorage.setItem(countKey, '2');
- incrementGandalfPageViewCount('page-view-1');
- expect(getGandalfPageViewCount()).toBe(3);
- });
-
- it('resets a malformed stored value to 1 on increment', () => {
- localStorage.setItem(countKey, 'garbage');
- incrementGandalfPageViewCount('page-view-1');
- expect(getGandalfPageViewCount()).toBe(1);
- });
- });
-});
diff --git a/dotcom-rendering/src/lib/gandalf.ts b/dotcom-rendering/src/lib/gandalf.ts
deleted file mode 100644
index 866dc3a0371..00000000000
--- a/dotcom-rendering/src/lib/gandalf.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { storage } from '@guardian/libs';
-
-// Gandalf: the Guardian-managed sign-in gate journey (marketing name).
-//
-// A 100% rollout, run entirely by Guardian rules with no Auxia involvement,
-// currently live for New Zealand and extendable to further countries via the
-// gandalfSignInGateCountries channel switch.
-//
-// A dedicated, persistent counter of completed eligible pageviews. It is
-// deliberately independent of `gu.history.dailyArticleCount` (which resets
-// daily and only counts a subset of content types), of `gate_display_count`
-// (which counts gate renders, not pageviews) and of `gu.alreadyVisited`
-// (which counts every consented pageview and is an ad-targeting input).
-//
-// The counter is 0-based: it stores the number of eligible pageviews already
-// completed. The first three eligible pageviews (counts 0, 1, 2 sent to SDC)
-// are free; from the fourth onwards SDC returns the non-dismissible popup.
-//
-// The counter only advances after SDC confirms the pageview was an active,
-// eligible pageview for a Gandalf country (see the gandalfSignInGate response
-// marker), so unlisted-country traffic and excluded pages never consume the
-// allowance. It is browser-local: clearing storage or using a new/incognito
-// browser resets the allowance. This is accepted for the proof of concept.
-
-const pageViewCountKey = 'gu.gandalf.pageViewCount';
-
-const lastCountedPageViewIdKey = 'gu.gandalf.lastCountedPageViewId';
-
-/**
- * Returns the 0-based number of eligible pageviews already completed.
- * Fails safe to 0 if the stored value is missing or malformed.
- */
-export const getGandalfPageViewCount = (): number => {
- const raw = storage.local.getRaw(pageViewCountKey);
- const count = parseInt(raw ?? '', 10);
- return Number.isInteger(count) && count >= 0 ? count : 0;
-};
-
-/**
- * Records one completed eligible pageview. Idempotent per pageview ID, so
- * React effect re-runs (e.g. Strict Mode) increment at most once per
- * pageview.
- */
-export const incrementGandalfPageViewCount = (pageViewId: string): void => {
- if (storage.local.getRaw(lastCountedPageViewIdKey) === pageViewId) {
- return;
- }
- storage.local.setRaw(lastCountedPageViewIdKey, pageViewId);
- const count = getGandalfPageViewCount();
- storage.local.setRaw(pageViewCountKey, (count + 1).toString());
-};