diff --git a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
index 4a9549fa563..4e307974e8d 100644
--- a/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
+++ b/dotcom-rendering/playwright/tests/banner.e2e.spec.ts
@@ -103,14 +103,74 @@ 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 daily view count 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.
+ return 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');
+ // Two article loads today (each increments gu.history.dailyArticleCount),
+ // sent 0-based, so the second load sends 1.
+ expect(body.gandalfPageViewCount).toBe(1);
+ });
});
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..e66e6a6c5e3 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,22 @@ 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 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;
data?: AuxiaProxyGetTreatmentsProxyResponseData;
@@ -191,6 +208,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 +258,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.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 23f9d796d0f..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';
@@ -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) => {
@@ -404,6 +436,13 @@ 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';
+ const lastRecordedView = useRef();
+
useEffect(() => {
const signInGate = document.getElementById('sign-in-gate');
if (signInGate) {
@@ -413,26 +452,42 @@ const ShowSignInGateAuxia = ({
}, [setNode, setSignInGatePlaceholder]);
useEffect(() => {
- if (hasBeenSeen) {
+ // 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
- 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(
@@ -463,8 +518,10 @@ const ShowSignInGateAuxia = ({
}
}, [
hasBeenSeen,
+ isMandatoryPopup,
browserId,
contributionsServiceUrl,
+ isGandalf,
renderingTarget,
treatmentId,
userTreatment,
@@ -497,7 +554,7 @@ const ShowSignInGateAuxia = ({
setHasScroll(scrollHeight > viewportHeight);
}, []);
- const shouldShowV2Gate = hasBeenSeen ?? !hasScroll;
+ const shouldShowV2Gate = isMandatoryPopup || (hasBeenSeen ?? !hasScroll);
return (
<>
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.test.tsx
index d5830ed1e96..9fa8433afed 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,32 @@ describe('StickyBottomBanner', () => {
);
expect(candidateIds).toContain('reader-revenue-banner');
});
+
+ it('passes the 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({
+ countryCode: 'GB',
+ }),
+ );
+ });
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
index 00a3168518c..44a20ce3958 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx
@@ -372,6 +372,7 @@ export const StickyBottomBanner = ({
pageId,
contributionsServiceUrl,
editionId,
+ countryCode,
},
host,
);
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.test.tsx
index 51f1ba06ae5..0d4b0a193d1 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 { getDailyArticleCount, getToday } from '../../lib/dailyArticleCount';
+import type { AuxiaAPIResponseDataUserTreatment } from '../SignInGate/types';
import type { AuxiaGateDisplayData } from '../SignInGate/types';
import type { CanShowSignInGateProps } from './SignInGatePortal';
import { canShowSignInGatePortal } from './SignInGatePortal';
@@ -10,12 +12,20 @@ jest.mock('../../lib/auxia', () => ({
buildAuxiaGateDisplayData: jest.fn(),
}));
+jest.mock('../../lib/dailyArticleCount', () => ({
+ getDailyArticleCount: jest.fn().mockReturnValue(undefined),
+ getToday: jest.fn().mockReturnValue(200),
+}));
+
// Mock document.getElementById
const mockGetElementById = jest.fn();
Object.defineProperty(document, 'getElementById', {
value: mockGetElementById,
});
+const mockGetDailyArticleCount = jest.mocked(getDailyArticleCount);
+const mockGetToday = jest.mocked(getToday);
+
const canShowProps: CanShowSignInGateProps = {
isSignedIn: false,
isPaidContent: false,
@@ -26,8 +36,33 @@ const canShowProps: CanShowSignInGateProps = {
contentType: 'Article',
sectionId: 'section',
tags: [],
+ countryCode: 'NZ',
};
+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 +178,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 +214,103 @@ 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 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
+ >
+ ).mockResolvedValue(makeAuxiaReturn(undefined, true));
+
+ await canShowSignInGatePortal(canShowProps);
+
+ expect(buildAuxiaGateDisplayData).toHaveBeenCalledWith(
+ 'https://contributions.local',
+ 'page-id',
+ 'UK',
+ 'Article',
+ 'section',
+ [],
+ 0,
+ 3,
+ );
+ });
+
+ 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));
+
+ await canShowSignInGatePortal(canShowProps);
+
+ expect(buildAuxiaGateDisplayData).toHaveBeenCalledWith(
+ 'https://contributions.local',
+ 'page-id',
+ 'UK',
+ 'Article',
+ 'section',
+ [],
+ 0,
+ 0,
+ );
+ });
+
+ it('returns no gate but carries the marker metadata on a free Gandalf pageview', 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. The meta carries the country so the
+ // selector can build the Ophan variant.
+ expect(result).toEqual({
+ show: false,
+ meta: {
+ ...makeAuxiaReturn(undefined, true),
+ gandalfCountryCode: 'NZ',
+ },
+ });
+ });
+
+ it('shows the gate 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' },
+ });
});
});
});
diff --git a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
index 48b00dffb0e..fa64173158f 100644
--- a/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
+++ b/dotcom-rendering/src/components/StickyBottomBanner/SignInGatePortal.tsx
@@ -1,6 +1,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 type { CanShowResult } from '../../lib/messagePicker';
import { useAuthStatus } from '../../lib/useAuthStatus';
@@ -150,6 +152,7 @@ export interface CanShowSignInGateProps {
contentType?: string;
sectionId?: string;
tags?: TagType[];
+ countryCode?: CountryCode;
}
export const canShowSignInGatePortal = async ({
isSignedIn,
@@ -161,8 +164,9 @@ export const canShowSignInGatePortal = async ({
contentType,
sectionId,
tags,
+ 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 +178,7 @@ export const canShowSignInGatePortal = async ({
return Promise.resolve({ show: false });
}
- if (isPaidContent || isPreview || isSignedIn) {
+ if (isPaidContent || isPreview || isSignedIn === true) {
return Promise.resolve({ show: false });
}
@@ -193,19 +197,40 @@ 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 ?? '',
+ pageId,
editionId,
contentType,
sectionId,
tags,
retrieveLastGateDismissedCount('AuxiaSignInGate'),
+ viewCountToday,
);
+ 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 15ee7b2183a..d987f84e6a8 100644
--- a/dotcom-rendering/src/layouts/FrontLayout.tsx
+++ b/dotcom-rendering/src/layouts/FrontLayout.tsx
@@ -632,6 +632,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/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 && }
);
});