From aa185ad82809b961ada7bc33d62bbd3fa8ae10d8 Mon Sep 17 00:00:00 2001 From: George Richmond Date: Wed, 20 May 2026 10:11:57 +0100 Subject: [PATCH 01/12] Add US marketing toggle hiding functionality for newsletter sign-up --- dotcom-rendering/.storybook/preview.ts | 2 + .../ManyNewsletterSignUp.island.test.tsx | 246 ++++++++++++++++ .../ManyNewsletterSignUp.island.tsx | 28 +- .../NewsletterSignupForm.island.stories.tsx | 28 ++ .../NewsletterSignupForm.island.test.tsx | 172 +++++++++++ .../NewsletterSignupForm.island.tsx | 9 +- .../components/SecureSignup.island.test.tsx | 273 ++++++++++++++++++ .../src/components/SecureSignup.island.tsx | 81 +++++- .../src/lib/newsletter-sign-up-requests.ts | 7 + .../lib/useHideMarketingToggleForCountry.ts | 22 ++ .../src/lib/useNewsletterSignupForm.ts | 49 +++- 11 files changed, 901 insertions(+), 16 deletions(-) create mode 100644 dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx create mode 100644 dotcom-rendering/src/components/SecureSignup.island.test.tsx create mode 100644 dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts diff --git a/dotcom-rendering/.storybook/preview.ts b/dotcom-rendering/.storybook/preview.ts index df84f396f25..03bd690454b 100644 --- a/dotcom-rendering/.storybook/preview.ts +++ b/dotcom-rendering/.storybook/preview.ts @@ -30,6 +30,8 @@ sb.mock(import('../src/lib/fetchEmail.ts'), { spy: true }); sb.mock(import('../src/lib/useNewsletterSignupForm.ts'), { spy: true }); // @ts-ignore -- Storybook wants the file extension, TS does not. sb.mock(import('../src/lib/useAB.ts'), { spy: true }); +// @ts-ignore -- Storybook wants the file extension, TS does not. +sb.mock(import('../src/lib/useCountryCode.ts'), { spy: true }); // Prevent components being lazy rendered when we're taking Chromatic snapshots Lazy.disabled = isChromatic(); diff --git a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx new file mode 100644 index 00000000000..f53b2b636bc --- /dev/null +++ b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx @@ -0,0 +1,246 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { + reportTrackingEvent, + requestMultipleSignUps, +} from '../lib/newsletter-sign-up-requests'; +import { useAuthStatus, useIsSignedIn } from '../lib/useAuthStatus'; +import { useCountryCode } from '../lib/useCountryCode'; +import { ConfigProvider } from './ConfigContext'; +import { ManyNewsletterSignUp } from './ManyNewsletterSignUp.island'; +import { BUTTON_ROLE } from './NewsletterCard'; + +const NEWSLETTER_IDENTITY_NAME = 'morning-briefing'; +const NEWSLETTER_LIST_ID = 1234; +const TEST_EMAIL = 'reader@example.com'; +const SIGN_UP_BUTTON_NAME = 'Sign up for the newsletter you selected'; + +jest.mock('../lib/useAuthStatus', () => ({ + useAuthStatus: jest.fn().mockReturnValue({ kind: 'SignedOut' }), + useIsSignedIn: jest.fn(), +})); + +jest.mock('../lib/useCountryCode', () => ({ + useCountryCode: jest.fn(), +})); + +jest.mock('../lib/newsletterSubscriptionCache', () => ({ + clearSubscriptionCache: jest.fn(), +})); + +jest.mock('../lib/newsletter-sign-up-requests', () => ({ + reportTrackingEvent: jest.fn().mockResolvedValue(undefined), + requestMultipleSignUps: jest.fn(), +})); + +jest.mock('react-google-recaptcha', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => null), +})); + +const renderSignupForm = () => + render( + + + , + ); + +/** + * Adds a fake newsletter toggle button to the document body so that + * ManyNewsletterSignUp can discover it via its DOM event listeners. + * Must be added before renderSignupForm() so the useEffect attaches listeners. + */ +const addNewsletterButton = ( + identityName: string, + listId: number, +): HTMLButtonElement => { + const button = document.createElement('button'); + button.setAttribute('data-role', BUTTON_ROLE); + button.setAttribute('data-identity-name', identityName); + button.setAttribute('data-list-id', String(listId)); + document.body.appendChild(button); + return button; +}; + +const openSignupForm = async ( + testUser: ReturnType, +): Promise => { + const newsletterButton = addNewsletterButton( + NEWSLETTER_IDENTITY_NAME, + NEWSLETTER_LIST_ID, + ); + renderSignupForm(); + await testUser.click(newsletterButton); + + await waitFor(() => { + expect(screen.getByLabelText('Enter your email')).toBeInTheDocument(); + }); +}; + +const submitForm = async ( + testUser: ReturnType, + email = TEST_EMAIL, +): Promise => { + await testUser.type(screen.getByLabelText('Enter your email'), email); + await testUser.click( + screen.getByRole('button', { name: SIGN_UP_BUTTON_NAME }), + ); +}; + +describe('ManyNewsletterSignUp', () => { + const pageConfig = window.guardian.config + .page as typeof window.guardian.config.page & { + ajaxUrl?: string; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + (useIsSignedIn as jest.Mock).mockReturnValue(false); + (useAuthStatus as jest.Mock).mockReturnValue({ kind: 'SignedOut' }); + (useCountryCode as jest.Mock).mockReturnValue(undefined); + + pageConfig.ajaxUrl = 'https://api.nextgen.guardianapps.co.uk'; + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + false; + + (requestMultipleSignUps as jest.Mock).mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + for (const el of document.querySelectorAll( + `[data-role=${BUTTON_ROLE}]`, + )) { + el.remove(); + } + }); + + describe('US hide marketing toggle (us-signup-hide-marketing-toggle switch)', () => { + beforeEach(() => { + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + true; + }); + + it('switch on + US + signed out: hides marketing checkbox, sends marketingOptInHidden=true', async () => { + const testUser = user.setup(); + (useCountryCode as jest.Mock).mockReturnValue('US'); + await openSignupForm(testUser); + + expect( + screen.queryByLabelText(/Get updates about our journalism/), + ).not.toBeInTheDocument(); + + await submitForm(testUser); + + await waitFor(() => { + expect(requestMultipleSignUps).toHaveBeenCalledWith( + TEST_EMAIL, + [NEWSLETTER_IDENTITY_NAME], + '', + true, + true, + ); + }); + }); + + it('switch on + US + signed out: tracking uses similar-guardian-products-optin-hidden-us', async () => { + const testUser = user.setup(); + (useCountryCode as jest.Mock).mockReturnValue('US'); + await openSignupForm(testUser); + await submitForm(testUser); + + await waitFor(() => { + expect(reportTrackingEvent).toHaveBeenCalledWith( + 'ManyNewsletterSignUp', + 'success-response', + expect.anything(), + expect.objectContaining({ + marketingOptInType: + 'similar-guardian-products-optin-hidden-us', + }), + ); + }); + }); + + it('switch on + non-US + signed out: shows checkbox, no marketingOptInHidden', async () => { + const testUser = user.setup(); + (useCountryCode as jest.Mock).mockReturnValue('GB'); + await openSignupForm(testUser); + + expect( + await screen.findByLabelText( + /Get updates about our journalism/, + ), + ).toBeInTheDocument(); + + await submitForm(testUser); + + await waitFor(() => { + expect(requestMultipleSignUps).toHaveBeenCalledWith( + TEST_EMAIL, + [NEWSLETTER_IDENTITY_NAME], + '', + true, + undefined, + ); + }); + }); + + it('switch on + pending country (undefined) + signed out: shows checkbox', async () => { + const testUser = user.setup(); + (useCountryCode as jest.Mock).mockReturnValue(undefined); + await openSignupForm(testUser); + + expect( + await screen.findByLabelText( + /Get updates about our journalism/, + ), + ).toBeInTheDocument(); + }); + + it('switch on + US + signed in: checkbox not shown (signed-in), no marketingOptInHidden', async () => { + const testUser = user.setup(); + (useCountryCode as jest.Mock).mockReturnValue('US'); + (useIsSignedIn as jest.Mock).mockReturnValue(true); + (useAuthStatus as jest.Mock).mockReturnValue({ kind: 'SignedIn' }); + await openSignupForm(testUser); + + expect( + screen.queryByLabelText(/Get updates about our journalism/), + ).not.toBeInTheDocument(); + + await submitForm(testUser); + + await waitFor(() => { + expect(requestMultipleSignUps).toHaveBeenCalledWith( + TEST_EMAIL, + [NEWSLETTER_IDENTITY_NAME], + '', + undefined, + undefined, + ); + }); + }); + + it('switch off + US + signed out: shows checkbox', async () => { + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + false; + const testUser = user.setup(); + (useCountryCode as jest.Mock).mockReturnValue('US'); + await openSignupForm(testUser); + + expect( + await screen.findByLabelText( + /Get updates about our journalism/, + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx index 7d5f2c41941..b51fb01ce34 100644 --- a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx +++ b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx @@ -19,6 +19,7 @@ import { } from '../lib/newsletter-sign-up-requests'; import { clearSubscriptionCache } from '../lib/newsletterSubscriptionCache'; import { useAuthStatus, useIsSignedIn } from '../lib/useAuthStatus'; +import { useHideMarketingToggleForCountry } from '../lib/useHideMarketingToggleForCountry'; import { useConfig } from './ConfigContext'; import { Flex } from './Flex'; import { ManyNewslettersForm } from './ManyNewslettersForm'; @@ -137,6 +138,9 @@ export const ManyNewsletterSignUp = ({ }: Props) => { const isSignedIn = useIsSignedIn(); const authStatus = useAuthStatus(); + const hideMarketingToggle = useHideMarketingToggleForCountry(); + /** True when the marketing toggle is hidden for this user due to country policy. */ + const marketingOptInHidden = hideMarketingToggle && isSignedIn === false; const [newslettersToSignUpFor, setNewslettersToSignUpFor] = useState< Array<{ @@ -248,6 +252,11 @@ export const ManyNewsletterSignUp = ({ const listIds = newslettersToSignUpFor.map( (newsletter) => newsletter.listId, ); + const effectiveMarketingOptIn = marketingOptInHidden + ? true + : marketingOptIn; + const shouldTrackMarketingOptInType = + marketingOptInHidden || marketingOptIn !== undefined; void reportTrackingEvent( 'ManyNewsletterSignUp', @@ -262,12 +271,15 @@ export const ManyNewsletterSignUp = ({ email, identityNames, reCaptchaToken, - marketingOptIn, + effectiveMarketingOptIn, + marketingOptInHidden ? true : undefined, ).catch(() => { return undefined; }); - const marketingOptInType = marketingOptIn + const marketingOptInType = marketingOptInHidden + ? 'similar-guardian-products-optin-hidden-us' + : effectiveMarketingOptIn ? 'similar-guardian-products-optin' : 'similar-guardian-products-optout'; @@ -281,7 +293,9 @@ export const ManyNewsletterSignUp = ({ renderingTarget, { listIds, - ...(marketingOptIn !== undefined && { marketingOptInType }), + ...(shouldTrackMarketingOptInType && { + marketingOptInType, + }), // If the backend handles the failure and responds with an informative // error message (E.G. "Service unavailable", "Invalid email" etc) this // should be included in the event data. @@ -300,7 +314,7 @@ export const ManyNewsletterSignUp = ({ renderingTarget, { listIds, - ...(marketingOptIn !== undefined && { marketingOptInType }), + ...(shouldTrackMarketingOptInType && { marketingOptInType }), }, ); @@ -418,7 +432,11 @@ export const ManyNewsletterSignUp = ({ status, }} newsletterCount={newslettersToSignUpFor.length} - marketingOptIn={marketingOptIn} + marketingOptIn={ + marketingOptInHidden + ? undefined + : marketingOptIn + } setMarketingOptIn={setMarketingOptIn} useReCaptcha={useReCaptcha} captchaSiteKey={captchaSiteKey} diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx index 3aa7fc4b5c9..065f5ad6153 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx @@ -2,6 +2,7 @@ import { createRef } from 'react'; import type ReactGoogleRecaptcha from 'react-google-recaptcha'; import { fn, mocked } from 'storybook/test'; import preview from '../../.storybook/preview'; +import { useCountryCode } from '../lib/useCountryCode'; import { useNewsletterSignupForm } from '../lib/useNewsletterSignupForm'; import type { NewsletterSignupFormState } from '../lib/useNewsletterSignupForm'; import type { NewsletterPreviewAction } from './NewsletterPreviewButton'; @@ -81,6 +82,7 @@ const mockForm = (state: Partial) => ({ isInteracted: false, showMarketingToggle: false, marketingOptIn: undefined, + marketingOptInHidden: false, isWaitingForResponse: false, responseOk: undefined, errorMessage: undefined, @@ -243,3 +245,29 @@ export const AlreadySubscribed = meta.story({ mocked(useNewsletterSignupForm).mockReturnValue(mockForm({})); }, }); + +/** + * US user — marketing toggle is hidden and the user is silently enrolled in + * similar_guardian_products. + */ +export const USHideMarketingToggle = meta.story({ + args: defaultArgs, + beforeEach() { + mocked(useCountryCode).mockReturnValue('US'); + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + true; + mocked(useNewsletterSignupForm).mockReturnValue( + mockForm({ + userEmail: 'reader@example.com', + isInteracted: true, + showMarketingToggle: false, + marketingOptIn: true, + }), + ); + }, + afterEach() { + mocked(useCountryCode).mockReset(); + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + false; + }, +}); diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx index be56b95f2df..4c499381850 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx @@ -6,6 +6,7 @@ import { lazyFetchEmailWithTimeout } from '../lib/fetchEmail'; import { clearSubscriptionCache } from '../lib/newsletterSubscriptionCache'; import { useAuthStatus, useIsSignedIn } from '../lib/useAuthStatus'; import { useBrowserId } from '../lib/useBrowserId'; +import { useCountryCode } from '../lib/useCountryCode'; import { ConfigProvider } from './ConfigContext'; import { NewsletterSignupForm } from './NewsletterSignupForm.island'; @@ -30,6 +31,10 @@ jest.mock('../lib/useBrowserId', () => ({ useBrowserId: jest.fn(), })); +jest.mock('../lib/useCountryCode', () => ({ + useCountryCode: jest.fn(), +})); + type RecaptchaProps = { // eslint-disable-next-line react/no-unused-prop-types -- false positive onChange?: (token: string | null) => void; @@ -95,6 +100,26 @@ const getTrackedEventDescription = (call: unknown[]): string => { return value.eventDescription; }; +const getTrackedPayloadForEvent = ( + eventDescription: string, +): { eventDescription: string; marketingOptInType?: string } | undefined => { + const trackingCalls = (submitComponentEvent as jest.Mock).mock + .calls as Array<[{ value: string }]>; + + for (const [payload] of trackingCalls) { + const parsed = JSON.parse(payload.value) as { + eventDescription: string; + marketingOptInType?: string; + }; + + if (parsed.eventDescription === eventDescription) { + return parsed; + } + } + + return undefined; +}; + const getRequestBodyParams = (callIndex = 0): URLSearchParams => { const [, requestInit] = (global.fetch as jest.Mock).mock.calls[ callIndex @@ -136,6 +161,7 @@ describe('NewsletterSignupForm', () => { (useIsSignedIn as jest.Mock).mockReturnValue(false); (useAuthStatus as jest.Mock).mockReturnValue({ kind: 'SignedOut' }); (useBrowserId as jest.Mock).mockReturnValue('test-browser-id'); + (useCountryCode as jest.Mock).mockReturnValue(undefined); (lazyFetchEmailWithTimeout as jest.Mock).mockReturnValue(() => Promise.resolve(null), ); @@ -144,6 +170,8 @@ describe('NewsletterSignupForm', () => { pageConfig.ajaxUrl = 'https://api.nextgen.guardianapps.co.uk'; pageConfig.idApiUrl = 'https://idapi.nextgen.guardianapps.co.uk'; pageConfig.googleRecaptchaSiteKey = 'test-site-key'; + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + false; if (window.guardian.ophan) { window.guardian.ophan.pageViewId = 'test-page-view-id'; } @@ -192,6 +220,13 @@ describe('NewsletterSignupForm', () => { 'form-submission', 'submission-confirmed', ]); + expect( + getTrackedPayloadForEvent('form-submission')?.marketingOptInType, + ).toBe('similar-guardian-products-optin'); + expect( + getTrackedPayloadForEvent('submission-confirmed') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optin'); }); it('supports tab order from email input to marketing toggle to sign up', async () => { @@ -230,6 +265,13 @@ describe('NewsletterSignupForm', () => { const params = getRequestBodyParams(); expect(params.get('marketing')).toBe('false'); + expect( + getTrackedPayloadForEvent('form-submission')?.marketingOptInType, + ).toBe('similar-guardian-products-optout'); + expect( + getTrackedPayloadForEvent('submission-confirmed') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optout'); }); it('supports keyboard interaction for marketing toggle and submit button', async () => { @@ -444,4 +486,134 @@ describe('NewsletterSignupForm', () => { screen.getByRole('button', { name: 'Sign up' }), ).toBeInTheDocument(); }); + + describe('US hide marketing toggle (us-signup-hide-marketing-toggle switch)', () => { + beforeEach(() => { + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + true; + }); + + it('switch on + US + signed out: hides toggle, sends marketing=true and marketingOptInHidden=true', async () => { + (useCountryCode as jest.Mock).mockReturnValue('US'); + const testUser = user.setup(); + renderForm(); + + await typeEmailAddress(testUser); + + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + + await testUser.click( + screen.getByRole('button', { name: 'Sign up' }), + ); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + const params = getRequestBodyParams(); + expect(params.get('marketing')).toBe('true'); + expect(params.get('marketingOptInHidden')).toBe('true'); + expect( + getTrackedPayloadForEvent('form-submission') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optin-hidden-us'); + expect( + getTrackedPayloadForEvent('submission-confirmed') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optin-hidden-us'); + }); + + it('switch on + non-US + signed out: shows toggle, does not send marketingOptInHidden', async () => { + (useCountryCode as jest.Mock).mockReturnValue('GB'); + const testUser = user.setup(); + renderForm(); + + await typeEmailAddress(testUser); + + expect(screen.getByRole('switch')).toBeInTheDocument(); + + await testUser.click( + screen.getByRole('button', { name: 'Sign up' }), + ); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + expect( + getRequestBodyParams().get('marketingOptInHidden'), + ).toBeNull(); + expect( + getTrackedPayloadForEvent('form-submission') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optin'); + expect( + getTrackedPayloadForEvent('submission-confirmed') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optin'); + }); + + it('switch on + pending country (undefined) + signed out: shows toggle', async () => { + (useCountryCode as jest.Mock).mockReturnValue(undefined); + const testUser = user.setup(); + renderForm(); + + await typeEmailAddress(testUser); + + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('switch on + US + signed in: toggle not shown (signed-in behaviour), no marketingOptInHidden', async () => { + (useCountryCode as jest.Mock).mockReturnValue('US'); + (useIsSignedIn as jest.Mock).mockReturnValue(true); + (useAuthStatus as jest.Mock).mockReturnValue({ + kind: 'SignedIn', + accessToken: { accessToken: 'token' }, + idToken: { claims: { email: 'signed.in@example.com' } }, + }); + (lazyFetchEmailWithTimeout as jest.Mock).mockReturnValue(() => + Promise.resolve('signed.in@example.com'), + ); + + renderForm(); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Sign up' }), + ).toBeInTheDocument(); + }); + + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + + const testUser = user.setup(); + await testUser.click( + screen.getByRole('button', { name: 'Sign up' }), + ); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + expect( + getRequestBodyParams().get('marketingOptInHidden'), + ).toBeNull(); + expect( + getTrackedPayloadForEvent('form-submission') + ?.marketingOptInType, + ).toBeUndefined(); + expect( + getTrackedPayloadForEvent('submission-confirmed') + ?.marketingOptInType, + ).toBeUndefined(); + }); + + it('switch off + US + signed out: shows toggle', async () => { + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + false; + (useCountryCode as jest.Mock).mockReturnValue('US'); + const testUser = user.setup(); + renderForm(); + + await typeEmailAddress(testUser); + + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + }); }); diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx index 74c096833ff..dfe603c8d0d 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx @@ -17,6 +17,7 @@ import { ToggleSwitch } from '@guardian/source-development-kitchen/react-compone // Use the default export instead. import type { ChangeEvent } from 'react'; import ReactGoogleRecaptcha from 'react-google-recaptcha'; +import { useHideMarketingToggleForCountry } from '../lib/useHideMarketingToggleForCountry'; import { useNewsletterSignupForm } from '../lib/useNewsletterSignupForm'; import { palette } from '../palette'; import { useConfig } from './ConfigContext'; @@ -273,6 +274,7 @@ const NewsletterSignupFormActive = ({ abTest, }: Omit) => { const { renderingTarget } = useConfig(); + const hideMarketingToggle = useHideMarketingToggleForCountry(); const { userEmail, @@ -295,7 +297,12 @@ const NewsletterSignupFormActive = ({ handleSubmit, handleSubmitButtonClick, handleReset, - } = useNewsletterSignupForm(newsletterId, renderingTarget, abTest); + } = useNewsletterSignupForm( + newsletterId, + renderingTarget, + abTest, + hideMarketingToggle, + ); const hasResponse = typeof responseOk === 'boolean'; const hasNonValidationError = !!errorMessage && !isValidationError; diff --git a/dotcom-rendering/src/components/SecureSignup.island.test.tsx b/dotcom-rendering/src/components/SecureSignup.island.test.tsx new file mode 100644 index 00000000000..3502fe53127 --- /dev/null +++ b/dotcom-rendering/src/components/SecureSignup.island.test.tsx @@ -0,0 +1,273 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { forwardRef, useImperativeHandle } from 'react'; +import { sendNewsletterSignupEvent } from '../lib/newsletterSignupTracking'; +import { useAuthStatus, useIsSignedIn } from '../lib/useAuthStatus'; +import { useBrowserId } from '../lib/useBrowserId'; +import { useCountryCode } from '../lib/useCountryCode'; +import { ConfigProvider } from './ConfigContext'; +import { SecureSignup } from './SecureSignup.island'; + +jest.mock('../lib/useAuthStatus', () => ({ + useAuthStatus: jest.fn(), + useIsSignedIn: jest.fn(), +})); + +jest.mock('../lib/useBrowserId', () => ({ + useBrowserId: jest.fn(), +})); + +jest.mock('../lib/useCountryCode', () => ({ + useCountryCode: jest.fn(), +})); + +jest.mock('../lib/fetchEmail', () => ({ + lazyFetchEmailWithTimeout: jest.fn(() => () => Promise.resolve(null)), +})); + +jest.mock('../lib/newsletterSignupTracking', () => ({ + EVENT_DESCRIPTION_TO_ACTION: {}, + NEWSLETTER_SIGNUP_COMPONENT_ID: { control: () => 'test-id' }, + sendNewsletterSignupEvent: jest.fn(), +})); + +type RecaptchaProps = { + // eslint-disable-next-line react/no-unused-prop-types -- false positive + onChange?: (token: string | null) => void; + // eslint-disable-next-line react/no-unused-prop-types -- false positive + onError?: () => void; +}; +type RecaptchaHandle = { execute: () => void; reset: () => void }; + +// The default mock resolves the captcha immediately with a valid token. +let recaptchaBehaviour: ( + handle: RecaptchaHandle, + props: RecaptchaProps, +) => void = (_handle, props) => props.onChange?.('test-recaptcha-token'); + +jest.mock('react-google-recaptcha', () => ({ + __esModule: true, + default: forwardRef( + function MockRecaptcha(props, ref) { + useImperativeHandle(ref, () => ({ + execute: () => + recaptchaBehaviour( + { execute: () => undefined, reset: () => undefined }, + props, + ), + reset: () => undefined, + })); + return null; + }, + ), +})); + +const renderSecureSignup = () => + render( + + + , + ); + +const getRequestBodyParams = (callIndex = 0): URLSearchParams => { + const [, requestInit] = (global.fetch as jest.Mock).mock.calls[ + callIndex + ] as [string, RequestInit]; + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- just a test + return new URLSearchParams(requestInit.body?.toString() ?? ''); +}; + +const getTrackingPayloadForEvent = (eventDescription: string) => { + const trackingCalls = (sendNewsletterSignupEvent as jest.Mock).mock + .calls as Array< + [ + { + value: { + eventDescription: string; + marketingOptInType?: string; + }; + }, + ] + >; + + for (const [payload] of trackingCalls) { + if (payload.value.eventDescription === eventDescription) { + return payload.value; + } + } + + return undefined; +}; + +describe('SecureSignup — US marketing toggle hiding', () => { + const pageConfig = window.guardian.config + .page as typeof window.guardian.config.page & { + ajaxUrl?: string; + idApiUrl?: string; + googleRecaptchaSiteKey?: string; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + recaptchaBehaviour = (_handle, props) => + props.onChange?.('test-recaptcha-token'); + + (useIsSignedIn as jest.Mock).mockReturnValue(false); + (useAuthStatus as jest.Mock).mockReturnValue({ kind: 'SignedOut' }); + (useBrowserId as jest.Mock).mockReturnValue('test-browser-id'); + (useCountryCode as jest.Mock).mockReturnValue(undefined); + + pageConfig.ajaxUrl = 'https://api.nextgen.guardianapps.co.uk'; + pageConfig.idApiUrl = 'https://idapi.nextgen.guardianapps.co.uk'; + pageConfig.googleRecaptchaSiteKey = 'test-site-key'; + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + false; + if (window.guardian.ophan) { + window.guardian.ophan.pageViewId = 'test-page-view-id'; + } + global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response); + }); + + // captchaSiteKey is set in a useEffect — wait for the reCAPTCHA widget + // to mount before typing and submitting. + const typeEmailAndSubmit = async ( + testUser: ReturnType, + ) => { + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Sign up' }), + ).toBeInTheDocument(), + ); + await testUser.type( + screen.getByLabelText('Enter your email address'), + 'reader@example.com', + ); + await testUser.click(screen.getByRole('button', { name: 'Sign up' })); + }; + + describe('US hide marketing toggle (us-signup-hide-marketing-toggle switch)', () => { + beforeEach(() => { + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + true; + }); + + it('switch on + US + signed out: hides marketing checkbox, sends marketing=true and marketingOptInHidden=true', async () => { + (useCountryCode as jest.Mock).mockReturnValue('US'); + const testUser = user.setup(); + + renderSecureSignup(); + + expect( + screen.queryByLabelText( + 'Get updates about our journalism and ways to support and enjoy our work.', + ), + ).not.toBeInTheDocument(); + + await typeEmailAndSubmit(testUser); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + const params = getRequestBodyParams(); + expect(params.get('marketing')).toBe('true'); + expect(params.get('marketingOptInHidden')).toBe('true'); + expect( + getTrackingPayloadForEvent('form-submission') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optin-hidden-us'); + expect( + getTrackingPayloadForEvent('submission-confirmed') + ?.marketingOptInType, + ).toBe('similar-guardian-products-optin-hidden-us'); + }); + + it('switch on + non-US + signed out: shows marketing checkbox, no marketingOptInHidden', async () => { + (useCountryCode as jest.Mock).mockReturnValue('GB'); + const testUser = user.setup(); + + renderSecureSignup(); + + expect( + screen.getByLabelText( + 'Get updates about our journalism and ways to support and enjoy our work.', + ), + ).toBeInTheDocument(); + + await typeEmailAndSubmit(testUser); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + expect( + getRequestBodyParams().get('marketingOptInHidden'), + ).toBeNull(); + }); + + it('switch on + pending country (undefined) + signed out: shows marketing checkbox', () => { + (useCountryCode as jest.Mock).mockReturnValue(undefined); + renderSecureSignup(); + + expect( + screen.getByLabelText( + 'Get updates about our journalism and ways to support and enjoy our work.', + ), + ).toBeInTheDocument(); + }); + + it('switch on + US + signed in: checkbox hidden by signed-in, no marketingOptInHidden', async () => { + (useCountryCode as jest.Mock).mockReturnValue('US'); + (useIsSignedIn as jest.Mock).mockReturnValue(true); + (useAuthStatus as jest.Mock).mockReturnValue({ kind: 'SignedIn' }); + const testUser = user.setup(); + + renderSecureSignup(); + + expect( + screen.queryByLabelText( + 'Get updates about our journalism and ways to support and enjoy our work.', + ), + ).not.toBeInTheDocument(); + + await typeEmailAndSubmit(testUser); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + expect( + getRequestBodyParams().get('marketingOptInHidden'), + ).toBeNull(); + expect( + getTrackingPayloadForEvent('form-submission') + ?.marketingOptInType, + ).toBeUndefined(); + expect( + getTrackingPayloadForEvent('submission-confirmed') + ?.marketingOptInType, + ).toBeUndefined(); + }); + + it('switch off + US + signed out: shows marketing checkbox', () => { + window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + false; + (useCountryCode as jest.Mock).mockReturnValue('US'); + renderSecureSignup(); + + expect( + screen.getByLabelText( + 'Get updates about our journalism and ways to support and enjoy our work.', + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/dotcom-rendering/src/components/SecureSignup.island.tsx b/dotcom-rendering/src/components/SecureSignup.island.tsx index d6975debab0..c2d737de55a 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.tsx @@ -29,6 +29,7 @@ import { import { clearSubscriptionCache } from '../lib/newsletterSubscriptionCache'; import { useAuthStatus, useIsSignedIn } from '../lib/useAuthStatus'; import { useBrowserId } from '../lib/useBrowserId'; +import { useHideMarketingToggleForCountry } from '../lib/useHideMarketingToggleForCountry'; import { palette } from '../palette'; import type { RenderingTarget } from '../types/renderingTarget'; import { useConfig } from './ConfigContext'; @@ -141,6 +142,7 @@ const buildFormData = ( token: string, marketingOptIn?: boolean, browserId?: string, + marketingOptInHidden?: boolean, ): FormData => { const pageRef = window.location.origin + window.location.pathname; const refViewId = window.guardian.ophan?.pageViewId ?? ''; @@ -160,6 +162,10 @@ const buildFormData = ( formData.append('marketing', marketingOptIn ? 'true' : 'false'); } + if (marketingOptInHidden === true) { + formData.append('marketingOptInHidden', 'true'); + } + if (browserId !== undefined) { formData.append('browserId', browserId); } @@ -167,7 +173,37 @@ const buildFormData = ( return formData; }; -const resolveEmailIfSignedIn = async (): Promise => { +type MarketingOptInType = + | 'similar-guardian-products-optin-hidden-us' + | 'similar-guardian-products-optin' + | 'similar-guardian-products-optout'; + +const getMarketingOptInType = ({ + isSignedIn, + marketingOptIn, + marketingOptInHidden, +}: { + isSignedIn: boolean | 'Pending'; + marketingOptIn: boolean | undefined; + marketingOptInHidden: boolean; +}): MarketingOptInType | undefined => { + if (isSignedIn !== false) { + return; + } + if (marketingOptInHidden) { + return 'similar-guardian-products-optin-hidden-us'; + } + return marketingOptIn + ? 'similar-guardian-products-optin' + : 'similar-guardian-products-optout'; +}; + +const resolveEmailForSignedInUser = async ( + isSignedIn: boolean | 'Pending', +): Promise => { + if (isSignedIn !== true) { + return; + } const { idApiUrl } = window.guardian.config.page; if (!idApiUrl) { return; @@ -209,13 +245,17 @@ const sendTracking = ( eventDescription: NewsletterEventDescription, renderingTarget: RenderingTarget, abTest?: AbTest, + extraDetails?: Record, ): void => { sendNewsletterSignupEvent({ action: EVENT_DESCRIPTION_TO_ACTION[eventDescription], identityName: newsletterId, componentId: NEWSLETTER_SIGNUP_COMPONENT_ID.control(newsletterId), renderingTarget, - value: { eventDescription }, + value: { + eventDescription, + ...extraDetails, + }, abTest, }); }; @@ -251,6 +291,8 @@ export const SecureSignup = ({ ); const isSignedIn = useIsSignedIn(); const authStatus = useAuthStatus(); + const hideMarketingToggle = useHideMarketingToggleForCountry(); + const marketingOptInHidden = hideMarketingToggle && isSignedIn === false; useEffect(() => { if (isSignedIn !== 'Pending' && !isSignedIn) { @@ -260,11 +302,20 @@ export const SecureSignup = ({ useEffect(() => { setCaptchaSiteKey(window.guardian.config.page.googleRecaptchaSiteKey); - void resolveEmailIfSignedIn().then((email) => { + }, []); + + useEffect(() => { + if (isSignedIn !== true) { + setUserEmail(undefined); + setHideEmailInput(false); + return; + } + + void resolveEmailForSignedInUser(isSignedIn).then((email) => { setUserEmail(email); setHideEmailInput(isString(email)); }); - }, []); + }, [isSignedIn]); const { renderingTarget } = useConfig(); const browserId = useBrowserId(); @@ -274,15 +325,30 @@ export const SecureSignup = ({ const input: HTMLInputElement | null = document.querySelector('input[type="email"]') ?? null; const emailAddress: string = input?.value ?? ''; + const effectiveMarketingOptIn = marketingOptInHidden + ? true + : marketingOptIn; + const marketingOptInType = getMarketingOptInType({ + isSignedIn, + marketingOptIn: effectiveMarketingOptIn, + marketingOptInHidden, + }); - sendTracking(newsletterId, 'form-submission', renderingTarget, abTest); + sendTracking( + newsletterId, + 'form-submission', + renderingTarget, + abTest, + marketingOptInType ? { marketingOptInType } : undefined, + ); const formData = buildFormData( emailAddress, newsletterId, token, - marketingOptIn, + effectiveMarketingOptIn, browserId, + marketingOptInHidden ? true : undefined, ); const response = await postFormData( @@ -306,6 +372,7 @@ export const SecureSignup = ({ response.ok ? 'submission-confirmed' : 'submission-failed', renderingTarget, abTest, + marketingOptInType ? { marketingOptInType } : undefined, ); }; @@ -388,7 +455,7 @@ export const SecureSignup = ({ value={userEmail ?? ''} onChange={(e) => setUserEmail(e.target.value)} /> - {isSignedIn === false && ( + {isSignedIn === false && !marketingOptInHidden && ( { const pageRef = window.location.origin + window.location.pathname; const refViewId = window.guardian.ophan?.pageViewId ?? ''; @@ -34,6 +35,10 @@ const buildNewsletterSignUpFormData = ( formData.append('marketing', marketingOptIn ? 'true' : 'false'); } + if (marketingOptInHidden === true) { + formData.append('marketingOptInHidden', 'true'); + } + return formData; }; @@ -67,12 +72,14 @@ export const requestMultipleSignUps = async ( newsletterIds: string[], recaptchaToken: string, marketingOptIn?: boolean, + marketingOptInHidden?: boolean, ): Promise => { const data = buildNewsletterSignUpFormData( emailAddress, newsletterIds, recaptchaToken, marketingOptIn, + marketingOptInHidden, ); return await postFormData( diff --git a/dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts b/dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts new file mode 100644 index 00000000000..9a074fa4aa2 --- /dev/null +++ b/dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts @@ -0,0 +1,22 @@ +import { useCountryCode } from './useCountryCode'; + +/** + * Returns `true` when the marketing opt-in toggle should be hidden for this + * user's country. + * + * Currently implements US-only hiding, gated behind the + * `us-signup-hide-marketing-toggle` switch. The hook name is country-agnostic + * so future countries can be added without renaming. + * + * - Returns `false` while the country code is still being resolved (`undefined`). + * - Returns `false` if the switch is off. + * - Returns `true` only when the switch is on **and** `countryCode === 'US'`. + */ +export const useHideMarketingToggleForCountry = (): boolean => { + const countryCode = useCountryCode('useHideMarketingToggleForCountry'); + const switchEnabled = + window.guardian.config.switches['us-signup-hide-marketing-toggle'] === + true; + + return switchEnabled && countryCode === 'US'; +}; diff --git a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts index aad805622dd..d74768c59fe 100644 --- a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts +++ b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts @@ -26,6 +26,7 @@ const buildFormData = ( token: string, marketingOptIn?: boolean, browserId?: string, + marketingOptInHidden?: boolean, ): FormData => { const pageRef = window.location.origin + window.location.pathname; const refViewId = window.guardian.ophan?.pageViewId ?? ''; @@ -45,6 +46,10 @@ const buildFormData = ( formData.append('marketing', marketingOptIn ? 'true' : 'false'); } + if (marketingOptInHidden === true) { + formData.append('marketingOptInHidden', 'true'); + } + if (browserId !== undefined) { formData.append('browserId', browserId); } @@ -99,13 +104,17 @@ const sendTracking = ( eventDescription: NewsletterEventDescription, renderingTarget: RenderingTarget, abTest?: AbTest, + extraDetails?: Record, ): void => { sendNewsletterSignupEvent({ action: EVENT_DESCRIPTION_TO_ACTION[eventDescription], identityName: newsletterId, componentId: NEWSLETTER_SIGNUP_COMPONENT_ID.variant(newsletterId), renderingTarget, - value: { eventDescription }, + value: { + eventDescription, + ...extraDetails, + }, abTest, }); }; @@ -135,6 +144,12 @@ export type NewsletterSignupFormState = { /** `true` for signed-out users — shows the marketing opt-in toggle. */ showMarketingToggle: boolean; marketingOptIn: boolean | undefined; + /** + * `true` when the marketing toggle is hidden by country policy (switch on, + * US, signed out). Included in the sign-up payload so the backend knows + * the opt-in was implicit. + */ + marketingOptInHidden: boolean; /** `true` while the POST request is in-flight. */ isWaitingForResponse: boolean; @@ -186,6 +201,7 @@ export const useNewsletterSignupForm = ( newsletterId: string, renderingTarget: RenderingTarget, abTest?: AbTest, + hideMarketingToggle = false, ): NewsletterSignupFormState => { const recaptchaRef = useRef(null); const [captchaSiteKey, setCaptchaSiteKey] = useState(); @@ -215,6 +231,8 @@ export const useNewsletterSignupForm = ( const marketingOptInRef = useRef(marketingOptIn); const browserIdRef = useRef(browserId); const authStatusRef = useRef(authStatus); + const isSignedInRef = useRef(isSignedIn); + const hideMarketingToggleRef = useRef(hideMarketingToggle); useEffect(() => { marketingOptInRef.current = marketingOptIn; }, [marketingOptIn]); @@ -224,6 +242,12 @@ export const useNewsletterSignupForm = ( useEffect(() => { authStatusRef.current = authStatus; }, [authStatus]); + useEffect(() => { + isSignedInRef.current = isSignedIn; + }, [isSignedIn]); + useEffect(() => { + hideMarketingToggleRef.current = hideMarketingToggle; + }, [hideMarketingToggle]); // The email address that was validated at submit-time. We stash it in a // ref and read it back when the captcha resolves, so it can't change out @@ -270,19 +294,36 @@ export const useNewsletterSignupForm = ( const submitForm = useCallback( async (emailAddress: string, token: string): Promise => { + const marketingOptInHidden = + hideMarketingToggleRef.current && + isSignedInRef.current === false; + const effectiveMarketingOptIn = marketingOptInHidden + ? true + : marketingOptInRef.current; + const marketingOptInType = + isSignedInRef.current === false + ? marketingOptInHidden + ? 'similar-guardian-products-optin-hidden-us' + : effectiveMarketingOptIn + ? 'similar-guardian-products-optin' + : 'similar-guardian-products-optout' + : undefined; + sendTracking( newsletterId, 'form-submission', renderingTarget, abTest, + marketingOptInType ? { marketingOptInType } : undefined, ); const formData = buildFormData( emailAddress, newsletterId, token, - marketingOptInRef.current, + effectiveMarketingOptIn, browserIdRef.current, + marketingOptInHidden ? true : undefined, ); const response = await postFormData( @@ -307,6 +348,7 @@ export const useNewsletterSignupForm = ( response.ok ? 'submission-confirmed' : 'submission-failed', renderingTarget, abTest, + marketingOptInType ? { marketingOptInType } : undefined, ); }, [abTest, newsletterId, renderingTarget], @@ -451,8 +493,9 @@ export const useNewsletterSignupForm = ( userEmail, isSignedIn: hasPrefilledEmail, isInteracted, - showMarketingToggle: isSignedIn === false, + showMarketingToggle: isSignedIn === false && !hideMarketingToggle, marketingOptIn, + marketingOptInHidden: hideMarketingToggle && isSignedIn === false, isWaitingForResponse, responseOk, errorMessage, From c7e319a5f8018c9e0191c8f5616619165d6d8c00 Mon Sep 17 00:00:00 2001 From: George Richmond Date: Wed, 20 May 2026 10:39:06 +0100 Subject: [PATCH 02/12] Refactor marketing opt-in logic to ensure proper handling of hidden toggle state --- dotcom-rendering/src/components/SecureSignup.island.tsx | 4 +++- dotcom-rendering/src/lib/useNewsletterSignupForm.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/SecureSignup.island.tsx b/dotcom-rendering/src/components/SecureSignup.island.tsx index c2d737de55a..54c1c862335 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.tsx @@ -253,8 +253,8 @@ const sendTracking = ( componentId: NEWSLETTER_SIGNUP_COMPONENT_ID.control(newsletterId), renderingTarget, value: { - eventDescription, ...extraDetails, + eventDescription, }, abTest, }); @@ -326,6 +326,8 @@ export const SecureSignup = ({ document.querySelector('input[type="email"]') ?? null; const emailAddress: string = input?.value ?? ''; const effectiveMarketingOptIn = marketingOptInHidden + ? true + : isSignedIn === false && marketingOptIn === undefined ? true : marketingOptIn; const marketingOptInType = getMarketingOptInType({ diff --git a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts index d74768c59fe..81e9300a6e2 100644 --- a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts +++ b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts @@ -112,8 +112,8 @@ const sendTracking = ( componentId: NEWSLETTER_SIGNUP_COMPONENT_ID.variant(newsletterId), renderingTarget, value: { - eventDescription, ...extraDetails, + eventDescription, }, abTest, }); @@ -299,6 +299,9 @@ export const useNewsletterSignupForm = ( isSignedInRef.current === false; const effectiveMarketingOptIn = marketingOptInHidden ? true + : isSignedInRef.current === false && + marketingOptInRef.current === undefined + ? true : marketingOptInRef.current; const marketingOptInType = isSignedInRef.current === false From ab0929d13f40ecb964284ed4124abac40ac066d7 Mon Sep 17 00:00:00 2001 From: George Richmond Date: Wed, 20 May 2026 10:44:59 +0100 Subject: [PATCH 03/12] Ensure proper check for window object in marketing toggle visibility logic --- dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts b/dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts index 9a074fa4aa2..f9a0d7b5e91 100644 --- a/dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts +++ b/dotcom-rendering/src/lib/useHideMarketingToggleForCountry.ts @@ -15,8 +15,9 @@ import { useCountryCode } from './useCountryCode'; export const useHideMarketingToggleForCountry = (): boolean => { const countryCode = useCountryCode('useHideMarketingToggleForCountry'); const switchEnabled = + typeof window !== 'undefined' && window.guardian.config.switches['us-signup-hide-marketing-toggle'] === - true; + true; return switchEnabled && countryCode === 'US'; }; From 144788bfe7fa593962efa453d87ea78fd7157ae1 Mon Sep 17 00:00:00 2001 From: George Richmond Date: Wed, 20 May 2026 11:11:23 +0100 Subject: [PATCH 04/12] simplify auth email effect to signed-in-only updates --- .../components/SecureSignup.island.test.tsx | 33 ++++++++++--------- .../src/components/SecureSignup.island.tsx | 14 +++----- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/dotcom-rendering/src/components/SecureSignup.island.test.tsx b/dotcom-rendering/src/components/SecureSignup.island.test.tsx index 3502fe53127..79d8d5acc7a 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.test.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.test.tsx @@ -62,22 +62,23 @@ jest.mock('react-google-recaptcha', () => ({ ), })); -const renderSecureSignup = () => - render( - - - , - ); +const secureSignupElement = () => ( + + + +); + +const renderSecureSignup = () => render(secureSignupElement()); const getRequestBodyParams = (callIndex = 0): URLSearchParams => { const [, requestInit] = (global.fetch as jest.Mock).mock.calls[ diff --git a/dotcom-rendering/src/components/SecureSignup.island.tsx b/dotcom-rendering/src/components/SecureSignup.island.tsx index 54c1c862335..eb53388a9ef 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.tsx @@ -305,16 +305,12 @@ export const SecureSignup = ({ }, []); useEffect(() => { - if (isSignedIn !== true) { - setUserEmail(undefined); - setHideEmailInput(false); - return; + if (isSignedIn === true) { + void resolveEmailForSignedInUser(isSignedIn).then((email) => { + setUserEmail(email); + setHideEmailInput(isString(email)); + }); } - - void resolveEmailForSignedInUser(isSignedIn).then((email) => { - setUserEmail(email); - setHideEmailInput(isString(email)); - }); }, [isSignedIn]); const { renderingTarget } = useConfig(); const browserId = useBrowserId(); From 7ae7f1475737ca48c52ea1b9290d5bf967a2312c Mon Sep 17 00:00:00 2001 From: George Richmond Date: Wed, 20 May 2026 12:50:19 +0100 Subject: [PATCH 05/12] Refactor marketing toggle logic to use consistent naming and improve condition checks --- .../src/components/ManyNewsletterSignUp.island.test.tsx | 9 ++++----- .../src/components/ManyNewsletterSignUp.island.tsx | 6 +++--- .../components/NewsletterSignupForm.island.stories.tsx | 6 ++---- .../src/components/NewsletterSignupForm.island.test.tsx | 9 ++++----- .../src/components/SecureSignup.island.test.tsx | 9 ++++----- dotcom-rendering/src/components/SecureSignup.island.tsx | 6 +++--- .../src/lib/useHideMarketingToggleForCountry.ts | 5 ++--- dotcom-rendering/src/lib/useNewsletterSignupForm.ts | 8 ++++---- 8 files changed, 26 insertions(+), 32 deletions(-) diff --git a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx index f53b2b636bc..76060ef766d 100644 --- a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx +++ b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.test.tsx @@ -108,8 +108,7 @@ describe('ManyNewsletterSignUp', () => { (useCountryCode as jest.Mock).mockReturnValue(undefined); pageConfig.ajaxUrl = 'https://api.nextgen.guardianapps.co.uk'; - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = - false; + window.guardian.config.switches['usSignupHideMarketingToggle'] = false; (requestMultipleSignUps as jest.Mock).mockResolvedValue({ ok: true }); }); @@ -122,9 +121,9 @@ describe('ManyNewsletterSignUp', () => { } }); - describe('US hide marketing toggle (us-signup-hide-marketing-toggle switch)', () => { + describe('US hide marketing toggle (usSignupHideMarketingToggle switch)', () => { beforeEach(() => { - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + window.guardian.config.switches['usSignupHideMarketingToggle'] = true; }); @@ -230,7 +229,7 @@ describe('ManyNewsletterSignUp', () => { }); it('switch off + US + signed out: shows checkbox', async () => { - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + window.guardian.config.switches['usSignupHideMarketingToggle'] = false; const testUser = user.setup(); (useCountryCode as jest.Mock).mockReturnValue('US'); diff --git a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx index b51fb01ce34..9fcc892a956 100644 --- a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx +++ b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx @@ -140,7 +140,7 @@ export const ManyNewsletterSignUp = ({ const authStatus = useAuthStatus(); const hideMarketingToggle = useHideMarketingToggleForCountry(); /** True when the marketing toggle is hidden for this user due to country policy. */ - const marketingOptInHidden = hideMarketingToggle && isSignedIn === false; + const marketingOptInHidden = hideMarketingToggle && isSignedIn !== true; const [newslettersToSignUpFor, setNewslettersToSignUpFor] = useState< Array<{ @@ -226,7 +226,7 @@ export const ManyNewsletterSignUp = ({ }, [status]); useEffect(() => { - if (isSignedIn !== 'Pending' && !isSignedIn) { + if (isSignedIn !== true) { setMarketingOptIn(true); } }, [isSignedIn]); @@ -433,7 +433,7 @@ export const ManyNewsletterSignUp = ({ }} newsletterCount={newslettersToSignUpFor.length} marketingOptIn={ - marketingOptInHidden + marketingOptInHidden || isSignedIn === true ? undefined : marketingOptIn } diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx index 065f5ad6153..2e869d76ae6 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx @@ -254,8 +254,7 @@ export const USHideMarketingToggle = meta.story({ args: defaultArgs, beforeEach() { mocked(useCountryCode).mockReturnValue('US'); - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = - true; + window.guardian.config.switches['usSignupHideMarketingToggle'] = true; mocked(useNewsletterSignupForm).mockReturnValue( mockForm({ userEmail: 'reader@example.com', @@ -267,7 +266,6 @@ export const USHideMarketingToggle = meta.story({ }, afterEach() { mocked(useCountryCode).mockReset(); - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = - false; + window.guardian.config.switches['usSignupHideMarketingToggle'] = false; }, }); diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx index 4c499381850..41c4d83d963 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.test.tsx @@ -170,8 +170,7 @@ describe('NewsletterSignupForm', () => { pageConfig.ajaxUrl = 'https://api.nextgen.guardianapps.co.uk'; pageConfig.idApiUrl = 'https://idapi.nextgen.guardianapps.co.uk'; pageConfig.googleRecaptchaSiteKey = 'test-site-key'; - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = - false; + window.guardian.config.switches['usSignupHideMarketingToggle'] = false; if (window.guardian.ophan) { window.guardian.ophan.pageViewId = 'test-page-view-id'; } @@ -487,9 +486,9 @@ describe('NewsletterSignupForm', () => { ).toBeInTheDocument(); }); - describe('US hide marketing toggle (us-signup-hide-marketing-toggle switch)', () => { + describe('US hide marketing toggle (usSignupHideMarketingToggle switch)', () => { beforeEach(() => { - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + window.guardian.config.switches['usSignupHideMarketingToggle'] = true; }); @@ -605,7 +604,7 @@ describe('NewsletterSignupForm', () => { }); it('switch off + US + signed out: shows toggle', async () => { - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + window.guardian.config.switches['usSignupHideMarketingToggle'] = false; (useCountryCode as jest.Mock).mockReturnValue('US'); const testUser = user.setup(); diff --git a/dotcom-rendering/src/components/SecureSignup.island.test.tsx b/dotcom-rendering/src/components/SecureSignup.island.test.tsx index 79d8d5acc7a..2b3cfb1a21d 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.test.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.test.tsx @@ -132,8 +132,7 @@ describe('SecureSignup — US marketing toggle hiding', () => { pageConfig.ajaxUrl = 'https://api.nextgen.guardianapps.co.uk'; pageConfig.idApiUrl = 'https://idapi.nextgen.guardianapps.co.uk'; pageConfig.googleRecaptchaSiteKey = 'test-site-key'; - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = - false; + window.guardian.config.switches['usSignupHideMarketingToggle'] = false; if (window.guardian.ophan) { window.guardian.ophan.pageViewId = 'test-page-view-id'; } @@ -157,9 +156,9 @@ describe('SecureSignup — US marketing toggle hiding', () => { await testUser.click(screen.getByRole('button', { name: 'Sign up' })); }; - describe('US hide marketing toggle (us-signup-hide-marketing-toggle switch)', () => { + describe('US hide marketing toggle (usSignupHideMarketingToggle switch)', () => { beforeEach(() => { - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + window.guardian.config.switches['usSignupHideMarketingToggle'] = true; }); @@ -259,7 +258,7 @@ describe('SecureSignup — US marketing toggle hiding', () => { }); it('switch off + US + signed out: shows marketing checkbox', () => { - window.guardian.config.switches['us-signup-hide-marketing-toggle'] = + window.guardian.config.switches['usSignupHideMarketingToggle'] = false; (useCountryCode as jest.Mock).mockReturnValue('US'); renderSecureSignup(); diff --git a/dotcom-rendering/src/components/SecureSignup.island.tsx b/dotcom-rendering/src/components/SecureSignup.island.tsx index eb53388a9ef..8b0f42c6915 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.tsx @@ -292,7 +292,7 @@ export const SecureSignup = ({ const isSignedIn = useIsSignedIn(); const authStatus = useAuthStatus(); const hideMarketingToggle = useHideMarketingToggleForCountry(); - const marketingOptInHidden = hideMarketingToggle && isSignedIn === false; + const marketingOptInHidden = hideMarketingToggle && isSignedIn !== true; useEffect(() => { if (isSignedIn !== 'Pending' && !isSignedIn) { @@ -323,7 +323,7 @@ export const SecureSignup = ({ const emailAddress: string = input?.value ?? ''; const effectiveMarketingOptIn = marketingOptInHidden ? true - : isSignedIn === false && marketingOptIn === undefined + : isSignedIn !== true && marketingOptIn === undefined ? true : marketingOptIn; const marketingOptInType = getMarketingOptInType({ @@ -453,7 +453,7 @@ export const SecureSignup = ({ value={userEmail ?? ''} onChange={(e) => setUserEmail(e.target.value)} /> - {isSignedIn === false && !marketingOptInHidden && ( + {isSignedIn !== true && !marketingOptInHidden && ( { const countryCode = useCountryCode('useHideMarketingToggleForCountry'); const switchEnabled = typeof window !== 'undefined' && - window.guardian.config.switches['us-signup-hide-marketing-toggle'] === - true; + window.guardian.config.switches['usSignupHideMarketingToggle'] === true; return switchEnabled && countryCode === 'US'; }; diff --git a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts index 81e9300a6e2..7913ad827ae 100644 --- a/dotcom-rendering/src/lib/useNewsletterSignupForm.ts +++ b/dotcom-rendering/src/lib/useNewsletterSignupForm.ts @@ -296,10 +296,10 @@ export const useNewsletterSignupForm = ( async (emailAddress: string, token: string): Promise => { const marketingOptInHidden = hideMarketingToggleRef.current && - isSignedInRef.current === false; + isSignedInRef.current !== true; const effectiveMarketingOptIn = marketingOptInHidden ? true - : isSignedInRef.current === false && + : isSignedInRef.current !== true && marketingOptInRef.current === undefined ? true : marketingOptInRef.current; @@ -496,9 +496,9 @@ export const useNewsletterSignupForm = ( userEmail, isSignedIn: hasPrefilledEmail, isInteracted, - showMarketingToggle: isSignedIn === false && !hideMarketingToggle, + showMarketingToggle: isSignedIn !== true && !hideMarketingToggle, marketingOptIn, - marketingOptInHidden: hideMarketingToggle && isSignedIn === false, + marketingOptInHidden: hideMarketingToggle && isSignedIn !== true, isWaitingForResponse, responseOk, errorMessage, From 6241985c110489efda34762ecffac5e0f336c326 Mon Sep 17 00:00:00 2001 From: George Richmond Date: Wed, 20 May 2026 13:00:09 +0100 Subject: [PATCH 06/12] Refactor marketing opt-in logic to improve handling of hidden toggle state and introduce utility functions for effective opt-in determination --- .../ManyNewsletterSignUp.island.tsx | 42 +++++++------- .../NewsletterSignupForm.island.stories.tsx | 2 +- .../src/components/SecureSignup.island.tsx | 54 ++++++----------- .../src/lib/newsletter-marketing-opt-in.ts | 58 +++++++++++++++++++ .../src/lib/useNewsletterSignupForm.ts | 41 ++++++------- 5 files changed, 118 insertions(+), 79 deletions(-) create mode 100644 dotcom-rendering/src/lib/newsletter-marketing-opt-in.ts diff --git a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx index 9fcc892a956..f4de881fc8b 100644 --- a/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx +++ b/dotcom-rendering/src/components/ManyNewsletterSignUp.island.tsx @@ -13,6 +13,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; // that version will compile and render but is non-functional. // Use the default export instead. import type ReactGoogleRecaptcha from 'react-google-recaptcha'; +import { + getEffectiveMarketingOptIn, + getMarketingOptInType, +} from '../lib/newsletter-marketing-opt-in'; import { reportTrackingEvent, requestMultipleSignUps, @@ -140,7 +144,8 @@ export const ManyNewsletterSignUp = ({ const authStatus = useAuthStatus(); const hideMarketingToggle = useHideMarketingToggleForCountry(); /** True when the marketing toggle is hidden for this user due to country policy. */ - const marketingOptInHidden = hideMarketingToggle && isSignedIn !== true; + const marketingOptInHiddenForCountry = + hideMarketingToggle && isSignedIn !== true; const [newslettersToSignUpFor, setNewslettersToSignUpFor] = useState< Array<{ @@ -252,11 +257,16 @@ export const ManyNewsletterSignUp = ({ const listIds = newslettersToSignUpFor.map( (newsletter) => newsletter.listId, ); - const effectiveMarketingOptIn = marketingOptInHidden - ? true - : marketingOptIn; - const shouldTrackMarketingOptInType = - marketingOptInHidden || marketingOptIn !== undefined; + const effectiveMarketingOptIn = getEffectiveMarketingOptIn({ + marketingOptInHiddenForCountry, + isSignedIn, + marketingOptIn, + }); + const marketingOptInType = getMarketingOptInType({ + marketingOptInHiddenForCountry, + isSignedIn, + effectiveMarketingOptIn, + }); void reportTrackingEvent( 'ManyNewsletterSignUp', @@ -272,17 +282,11 @@ export const ManyNewsletterSignUp = ({ identityNames, reCaptchaToken, effectiveMarketingOptIn, - marketingOptInHidden ? true : undefined, + marketingOptInHiddenForCountry ? true : undefined, ).catch(() => { return undefined; }); - const marketingOptInType = marketingOptInHidden - ? 'similar-guardian-products-optin-hidden-us' - : effectiveMarketingOptIn - ? 'similar-guardian-products-optin' - : 'similar-guardian-products-optout'; - if (!response?.ok) { const responseText = response ? await response.text() @@ -293,14 +297,9 @@ export const ManyNewsletterSignUp = ({ renderingTarget, { listIds, - ...(shouldTrackMarketingOptInType && { + ...(marketingOptInType !== undefined && { marketingOptInType, }), - // If the backend handles the failure and responds with an informative - // error message (E.G. "Service unavailable", "Invalid email" etc) this - // should be included in the event data. - // If not, the response text will be the HTML for the default error page - // which would not be helpful to include it in the tracking data. responseText: responseText.substring(0, 100), }, ); @@ -314,7 +313,7 @@ export const ManyNewsletterSignUp = ({ renderingTarget, { listIds, - ...(shouldTrackMarketingOptInType && { marketingOptInType }), + ...(marketingOptInType !== undefined && { marketingOptInType }), }, ); @@ -433,7 +432,8 @@ export const ManyNewsletterSignUp = ({ }} newsletterCount={newslettersToSignUpFor.length} marketingOptIn={ - marketingOptInHidden || isSignedIn === true + marketingOptInHiddenForCountry || + isSignedIn === true ? undefined : marketingOptIn } diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx index 2e869d76ae6..d487056118b 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.stories.tsx @@ -82,7 +82,7 @@ const mockForm = (state: Partial) => ({ isInteracted: false, showMarketingToggle: false, marketingOptIn: undefined, - marketingOptInHidden: false, + marketingOptInHiddenForCountry: false, isWaitingForResponse: false, responseOk: undefined, errorMessage: undefined, diff --git a/dotcom-rendering/src/components/SecureSignup.island.tsx b/dotcom-rendering/src/components/SecureSignup.island.tsx index 8b0f42c6915..a584044ce0a 100644 --- a/dotcom-rendering/src/components/SecureSignup.island.tsx +++ b/dotcom-rendering/src/components/SecureSignup.island.tsx @@ -20,6 +20,10 @@ import { useEffect, useRef, useState } from 'react'; // Use the default export instead. import ReactGoogleRecaptcha from 'react-google-recaptcha'; import { lazyFetchEmailWithTimeout } from '../lib/fetchEmail'; +import { + getEffectiveMarketingOptIn, + getMarketingOptInType, +} from '../lib/newsletter-marketing-opt-in'; import { EVENT_DESCRIPTION_TO_ACTION, NEWSLETTER_SIGNUP_COMPONENT_ID, @@ -142,7 +146,7 @@ const buildFormData = ( token: string, marketingOptIn?: boolean, browserId?: string, - marketingOptInHidden?: boolean, + marketingOptInHiddenForCountry?: boolean, ): FormData => { const pageRef = window.location.origin + window.location.pathname; const refViewId = window.guardian.ophan?.pageViewId ?? ''; @@ -162,7 +166,7 @@ const buildFormData = ( formData.append('marketing', marketingOptIn ? 'true' : 'false'); } - if (marketingOptInHidden === true) { + if (marketingOptInHiddenForCountry === true) { formData.append('marketingOptInHidden', 'true'); } @@ -173,31 +177,6 @@ const buildFormData = ( return formData; }; -type MarketingOptInType = - | 'similar-guardian-products-optin-hidden-us' - | 'similar-guardian-products-optin' - | 'similar-guardian-products-optout'; - -const getMarketingOptInType = ({ - isSignedIn, - marketingOptIn, - marketingOptInHidden, -}: { - isSignedIn: boolean | 'Pending'; - marketingOptIn: boolean | undefined; - marketingOptInHidden: boolean; -}): MarketingOptInType | undefined => { - if (isSignedIn !== false) { - return; - } - if (marketingOptInHidden) { - return 'similar-guardian-products-optin-hidden-us'; - } - return marketingOptIn - ? 'similar-guardian-products-optin' - : 'similar-guardian-products-optout'; -}; - const resolveEmailForSignedInUser = async ( isSignedIn: boolean | 'Pending', ): Promise => { @@ -292,7 +271,8 @@ export const SecureSignup = ({ const isSignedIn = useIsSignedIn(); const authStatus = useAuthStatus(); const hideMarketingToggle = useHideMarketingToggleForCountry(); - const marketingOptInHidden = hideMarketingToggle && isSignedIn !== true; + const marketingOptInHiddenForCountry = + hideMarketingToggle && isSignedIn !== true; useEffect(() => { if (isSignedIn !== 'Pending' && !isSignedIn) { @@ -321,15 +301,15 @@ export const SecureSignup = ({ const input: HTMLInputElement | null = document.querySelector('input[type="email"]') ?? null; const emailAddress: string = input?.value ?? ''; - const effectiveMarketingOptIn = marketingOptInHidden - ? true - : isSignedIn !== true && marketingOptIn === undefined - ? true - : marketingOptIn; + const effectiveMarketingOptIn = getEffectiveMarketingOptIn({ + marketingOptInHiddenForCountry, + isSignedIn, + marketingOptIn, + }); const marketingOptInType = getMarketingOptInType({ + marketingOptInHiddenForCountry, isSignedIn, - marketingOptIn: effectiveMarketingOptIn, - marketingOptInHidden, + effectiveMarketingOptIn, }); sendTracking( @@ -346,7 +326,7 @@ export const SecureSignup = ({ token, effectiveMarketingOptIn, browserId, - marketingOptInHidden ? true : undefined, + marketingOptInHiddenForCountry ? true : undefined, ); const response = await postFormData( @@ -453,7 +433,7 @@ export const SecureSignup = ({ value={userEmail ?? ''} onChange={(e) => setUserEmail(e.target.value)} /> - {isSignedIn !== true && !marketingOptInHidden && ( + {isSignedIn !== true && !marketingOptInHiddenForCountry && (