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 &&
}
); });