From 5277d0750d5ef5d2b2a4bc7935a957d04636cef6 Mon Sep 17 00:00:00 2001 From: "ben.dudiak-fry" Date: Wed, 5 Aug 2026 16:51:33 +0100 Subject: [PATCH 1/3] Revert "Add A/B test for in-article newsletter signup with no preview variant (#16380)" This reverts commit 5f85626588ac97ab48f0b80d3eaa6bc8fc0fc424. --- ab-testing/config/abTests.ts | 13 -- .../EmailSignUpWrapper.island.test.tsx | 120 ++---------------- .../components/EmailSignUpWrapper.island.tsx | 74 ++--------- .../NewsletterSignupCardContainer.test.tsx | 77 ++--------- .../NewsletterSignupCardContainer.tsx | 27 ++-- .../NewsletterSignupForm.island.test.tsx | 33 +---- .../src/lib/newsletterSignupAbTest.ts | 34 ----- 7 files changed, 45 insertions(+), 333 deletions(-) delete mode 100644 dotcom-rendering/src/lib/newsletterSignupAbTest.ts diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index b547f91f7c7..3bb802db48e 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -82,19 +82,6 @@ const ABTests: ABTest[] = [ groups: ["control", "variant"], shouldForceMetricsCollection: true, }, - { - name: "newsletters-in-article-signup-preview", - description: - "Test in-article newsletter signup with illustrated preview CTA vs without preview CTA", - owners: ["newsletters.dev@guardian.co.uk"], - expirationDate: "2026-07-21", - type: "client", - status: "ON", - audienceSize: 50 / 100, - audienceSpace: "A", - groups: ["illustrated", "without-preview"], - shouldForceMetricsCollection: false, - }, { name: "fronts-and-curation-loop-click-through", description: diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx index c844032f504..6dfed7691ea 100644 --- a/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx +++ b/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx @@ -1,12 +1,7 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import { submitComponentEvent } from '../client/ophan/ophan'; -import { - NEWSLETTER_PREVIEW_AB_TEST_NAME, - NEWSLETTER_PREVIEW_VARIANT, -} from '../lib/newsletterSignupAbTest'; import { NEWSLETTER_SIGNUP_COMPONENT_ID } from '../lib/newsletterSignupTracking'; -import { useAB } from '../lib/useAB'; import { useIsSignedIn } from '../lib/useAuthStatus'; import { useNewsletterSubscription } from '../lib/useNewsletterSubscription'; import { ConfigProvider } from './ConfigContext'; @@ -24,42 +19,27 @@ jest.mock('../lib/useNewsletterSubscription', () => ({ useNewsletterSubscription: jest.fn(), })); -jest.mock('../lib/useAB', () => ({ - useAB: jest.fn(), -})); - // Avoid rendering real island children in unit tests jest.mock('./Island', () => ({ Island: ({ children }: { children: React.ReactNode }) => <>{children}, })); -const mockNewsletterSignupForm = jest.fn(); - jest.mock('./NewsletterSignupForm.island', () => ({ - NewsletterSignupForm: (props: unknown) => { - mockNewsletterSignupForm(props); - return ( -
NewsletterSignupForm
- ); - }, + NewsletterSignupForm: () => ( +
NewsletterSignupForm
+ ), })); -const mockNewsletterSignupCardContainer = jest.fn(); - jest.mock('./NewsletterSignupCardContainer', () => ({ NewsletterSignupCardContainer: ({ children, - ...props }: { children: (openPreview: (() => void) | undefined) => React.ReactNode; - }) => { - mockNewsletterSignupCardContainer(props); - return ( -
- {children(undefined)} -
- ); - }, + }) => ( +
+ {children(undefined)} +
+ ), })); const defaultProps = { @@ -92,12 +72,6 @@ describe('EmailSignUpWrapper', () => { jest.resetAllMocks(); (useIsSignedIn as jest.Mock).mockReturnValue(false); (useNewsletterSubscription as jest.Mock).mockReturnValue(false); - (useAB as jest.Mock).mockReturnValue({ - getParticipations: () => ({ - [NEWSLETTER_PREVIEW_AB_TEST_NAME]: - NEWSLETTER_PREVIEW_VARIANT.illustrated, - }), - }); }); describe('rendering', () => { @@ -151,58 +125,6 @@ describe('EmailSignUpWrapper', () => { expect(submitComponentEvent).toHaveBeenCalledTimes(1); }); - it('passes AB metadata and keeps preview enabled in the illustrated arm', () => { - renderWrapper(); - - expect(mockNewsletterSignupCardContainer).toHaveBeenCalledWith( - expect.objectContaining({ - enablePreview: true, - abTest: { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant: NEWSLETTER_PREVIEW_VARIANT.illustrated, - }, - }), - ); - expect(mockNewsletterSignupForm).toHaveBeenCalledWith( - expect.objectContaining({ - abTest: { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant: NEWSLETTER_PREVIEW_VARIANT.illustrated, - }, - }), - ); - expect(submitComponentEvent).toHaveBeenCalledWith( - expect.objectContaining({ - abTest: { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant: NEWSLETTER_PREVIEW_VARIANT.illustrated, - }, - }), - 'Web', - ); - }); - - it('disables preview in the without-preview arm', () => { - (useAB as jest.Mock).mockReturnValue({ - getParticipations: () => ({ - [NEWSLETTER_PREVIEW_AB_TEST_NAME]: - NEWSLETTER_PREVIEW_VARIANT.withoutPreview, - }), - }); - - renderWrapper(); - - expect(mockNewsletterSignupCardContainer).toHaveBeenCalledWith( - expect.objectContaining({ - enablePreview: false, - abTest: { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant: NEWSLETTER_PREVIEW_VARIANT.withoutPreview, - }, - }), - ); - }); - it('does not fire a VIEW event while subscription status is loading', () => { (useNewsletterSubscription as jest.Mock).mockReturnValue(undefined); renderWrapper(); @@ -216,31 +138,5 @@ describe('EmailSignUpWrapper', () => { expect(submitComponentEvent).not.toHaveBeenCalled(); }); - - it('still fires the VIEW event without AB metadata if the AB framework never resolves', () => { - jest.useFakeTimers(); - - try { - (useAB as jest.Mock).mockReturnValue(undefined); - - renderWrapper(); - - // The VIEW event is deferred while waiting for the AB framework. - expect(submitComponentEvent).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(2000); - - expect(submitComponentEvent).toHaveBeenCalledTimes(1); - expect(submitComponentEvent).toHaveBeenCalledWith( - expect.objectContaining({ - action: 'VIEW', - abTest: undefined, - }), - 'Web', - ); - } finally { - jest.useRealTimers(); - } - }); }); }); diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx index 1e008b86454..84b203d8dbf 100644 --- a/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx +++ b/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx @@ -1,14 +1,8 @@ import { useEffect, useRef } from 'react'; -import { - isWithoutPreviewVariant, - NEWSLETTER_PREVIEW_AB_TEST_NAME, - resolveNewsletterPreviewAbTest, -} from '../lib/newsletterSignupAbTest'; import { NEWSLETTER_SIGNUP_COMPONENT_ID, sendNewsletterSignupEvent, } from '../lib/newsletterSignupTracking'; -import { useAB } from '../lib/useAB'; import { useIsSignedIn } from '../lib/useAuthStatus'; import { useNewsletterSubscription } from '../lib/useNewsletterSubscription'; import { useConfig } from './ConfigContext'; @@ -17,13 +11,9 @@ import { InlineSkipToWrapper } from './InlineSkipToWrapper'; import { Island } from './Island'; import { NewsletterSignupCardContainer } from './NewsletterSignupCardContainer'; import { NewsletterSignupForm } from './NewsletterSignupForm.island'; - -/** - * How long to wait for the AB framework to resolve before firing the VIEW - * event without test metadata. Ensures newsletter view tracking still fires - * even if the AB framework never initialises. - */ -const AB_RESOLUTION_TIMEOUT_MS = 2000; +// When the next A/B experiment is added (e.g. preview-button test), import +// useAB and AB_TEST_NAME from their respective modules and thread them through +// sendNewsletterSignupEvent's `abTest` param and NewsletterSignupForm's `abTest` prop. interface EmailSignUpWrapperProps extends EmailSignUpProps { index: number; @@ -56,14 +46,8 @@ export const EmailSignUpWrapper = ({ theme, }: EmailSignUpWrapperProps) => { const { renderingTarget } = useConfig(); - const abTests = useAB(); const isSignedIn = useIsSignedIn(); const isSubscribed = useNewsletterSubscription(listId, idApiUrl); - const isABResolved = abTests !== undefined; - const previewVariant = - abTests?.getParticipations()[NEWSLETTER_PREVIEW_AB_TEST_NAME]; - const abTest = resolveNewsletterPreviewAbTest(previewVariant); - const enablePreview = !isWithoutPreviewVariant(previewVariant); const componentId = NEWSLETTER_SIGNUP_COMPONENT_ID.inArticleSignupForm(identityName); @@ -83,44 +67,17 @@ export const EmailSignUpWrapper = ({ if (viewFiredRef.current) { return; } - - const fireView = () => { - if (viewFiredRef.current) { - return; - } - viewFiredRef.current = true; - sendNewsletterSignupEvent({ - action: 'VIEW', - identityName, - componentId, - renderingTarget, - abTest, - value: { - eventDescription: 'newsletter-signup-viewed', - }, - }); - }; - - // When the AB framework has resolved, fire immediately with the test - // metadata attached. Otherwise wait briefly for it to resolve so we can - // attribute the view to the correct arm — but never block the VIEW event - // indefinitely: the AB framework can fail to initialise, and newsletter - // tracking must continue to work regardless. - if (isABResolved) { - fireView(); - return; - } - - const timeoutId = setTimeout(fireView, AB_RESOLUTION_TIMEOUT_MS); - return () => clearTimeout(timeoutId); - }, [ - abTest, - componentId, - identityName, - isABResolved, - isSubscribed, - renderingTarget, - ]); + viewFiredRef.current = true; + sendNewsletterSignupEvent({ + action: 'VIEW', + identityName, + componentId, + renderingTarget, + value: { + eventDescription: 'newsletter-signup-viewed', + }, + }); + }, [componentId, identityName, isSubscribed, renderingTarget]); return ( {(previewAction) => ( @@ -149,7 +104,6 @@ export const EmailSignUpWrapper = ({ frequency={frequency} previewAction={previewAction} componentId={componentId} - abTest={abTest} isAlreadySubscribed={isSubscribed} /> diff --git a/dotcom-rendering/src/components/NewsletterSignupCardContainer.test.tsx b/dotcom-rendering/src/components/NewsletterSignupCardContainer.test.tsx index 6da440ca162..d98347f41f9 100644 --- a/dotcom-rendering/src/components/NewsletterSignupCardContainer.test.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupCardContainer.test.tsx @@ -1,10 +1,6 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { submitComponentEvent } from '../client/ophan/ophan'; -import { - NEWSLETTER_PREVIEW_AB_TEST_NAME, - NEWSLETTER_PREVIEW_VARIANT, -} from '../lib/newsletterSignupAbTest'; import { NEWSLETTER_SIGNUP_COMPONENT_ID } from '../lib/newsletterSignupTracking'; import { NewsletterSignupCardContainer } from './NewsletterSignupCardContainer'; @@ -30,13 +26,18 @@ const renderContainer = (props = {}) => isSignedIn={true} {...props} > - {(previewAction) => - previewAction?.behaviour === 'modal' ? ( - - ) : null - } + {(previewAction) => ( + + )} , ); @@ -62,12 +63,7 @@ describe('NewsletterSignupCardContainer', () => { describe('preview tracking', () => { it('fires an EXPAND event with the variant component id when the preview is opened', () => { - renderContainer({ - abTest: { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant: NEWSLETTER_PREVIEW_VARIANT.illustrated, - }, - }); + renderContainer(); fireEvent.click( screen.getByRole('button', { name: 'Preview latest' }), @@ -82,10 +78,6 @@ describe('NewsletterSignupCardContainer', () => { ), }, action: 'EXPAND', - abTest: { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant: NEWSLETTER_PREVIEW_VARIANT.illustrated, - }, value: expect.stringContaining( '"eventDescription":"preview-open"', ), @@ -141,16 +133,6 @@ describe('NewsletterSignupCardContainer', () => { firstCallCount, ); }); - - it('does not render any preview trigger when preview is disabled', () => { - renderContainer({ enablePreview: false }); - - expect( - screen.queryByRole('button', { name: 'Preview latest' }), - ).not.toBeInTheDocument(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(submitComponentEvent).not.toHaveBeenCalled(); - }); }); it('renders a preview link for apps and tracks the open event', () => { @@ -203,37 +185,4 @@ describe('NewsletterSignupCardContainer', () => { ); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - - it('does not render a preview link for apps when preview is disabled', () => { - render( - - {(previewAction) => - previewAction?.behaviour === 'link' ? ( - - Preview latest - - ) : null - } - , - ); - - expect( - screen.queryByRole('link', { name: 'Preview latest' }), - ).not.toBeInTheDocument(); - expect(submitComponentEvent).not.toHaveBeenCalled(); - }); }); diff --git a/dotcom-rendering/src/components/NewsletterSignupCardContainer.tsx b/dotcom-rendering/src/components/NewsletterSignupCardContainer.tsx index d5ba102f366..1f374f7c93c 100644 --- a/dotcom-rendering/src/components/NewsletterSignupCardContainer.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupCardContainer.tsx @@ -1,5 +1,4 @@ import { css } from '@emotion/react'; -import type { AbTest } from '@guardian/ophan-tracker-js'; import { palette as sourcePalette, space } from '@guardian/source/foundations'; import { useCallback, useState } from 'react'; import { buildNewsletterPreviewUrl } from '../lib/newsletterPreviewUrl'; @@ -21,14 +20,12 @@ const sendPreviewTracking = ({ renderingTarget, renderUrl, isSignedIn, - abTest, }: { identityName: string; eventDescription: PreviewEventDescription; renderingTarget: RenderingTarget; renderUrl: string; isSignedIn?: boolean | 'Pending'; - abTest?: AbTest; }) => { sendNewsletterSignupEvent({ action: eventDescription === 'preview-open' ? 'EXPAND' : 'CLOSE', @@ -37,7 +34,6 @@ const sendPreviewTracking = ({ NEWSLETTER_SIGNUP_COMPONENT_ID.inArticleSignupForm(identityName), renderingTarget, value: { eventDescription, renderUrl, isSignedIn }, - abTest, }); }; @@ -51,8 +47,6 @@ type Props = Pick< renderingTarget: RenderingTarget; theme: string; isSignedIn?: boolean | 'Pending'; - abTest?: AbTest; - enablePreview?: boolean; children?: ( previewAction: NewsletterPreviewAction | undefined, ) => React.ReactNode; @@ -76,8 +70,6 @@ export const NewsletterSignupCardContainer = ({ illustrationSquare, children, isSignedIn, - abTest, - enablePreview = true, }: Props) => { const [isPreviewOpen, setIsPreviewOpen] = useState(false); @@ -85,10 +77,10 @@ export const NewsletterSignupCardContainer = ({ exampleUrl, category, }); - const hasPreviewUrl = enablePreview && Boolean(renderUrl); + const hasPreviewUrl = Boolean(renderUrl); const openPreview = useCallback(() => { - if (renderUrl === undefined || renderUrl === '') { + if (!renderUrl) { return; } @@ -100,16 +92,15 @@ export const NewsletterSignupCardContainer = ({ renderingTarget, renderUrl, isSignedIn, - abTest, }); } return true; }); - }, [abTest, identityName, isSignedIn, renderingTarget, renderUrl]); + }, [identityName, isSignedIn, renderingTarget, renderUrl]); const trackPreviewLinkOpen = useCallback(() => { - if (renderUrl === undefined || renderUrl === '') { + if (!renderUrl) { return; } @@ -119,26 +110,24 @@ export const NewsletterSignupCardContainer = ({ renderingTarget, renderUrl, isSignedIn, - abTest, }); - }, [abTest, identityName, isSignedIn, renderingTarget, renderUrl]); + }, [identityName, isSignedIn, renderingTarget, renderUrl]); const closePreview = useCallback(() => { setIsPreviewOpen((isOpen) => { - if (isOpen && renderUrl !== undefined && renderUrl !== '') { + if (isOpen && renderUrl) { sendPreviewTracking({ identityName, eventDescription: 'preview-close', renderingTarget, renderUrl, isSignedIn, - abTest, }); } return false; }); - }, [abTest, identityName, isSignedIn, renderingTarget, renderUrl]); + }, [identityName, isSignedIn, renderingTarget, renderUrl]); const previewAction = hasPreviewUrl ? renderingTarget === 'Apps' @@ -155,7 +144,7 @@ export const NewsletterSignupCardContainer = ({ return (
- {enablePreview && isPreviewOpen && hasPreviewUrl && ( + {isPreviewOpen && hasPreviewUrl && ( ({ ), })); -const renderForm = ( - props?: Partial>, -) => +const renderForm = () => render( , ); @@ -245,28 +238,6 @@ describe('NewsletterSignupForm', () => { ).toBe('similar-guardian-products-optin'); }); - it('forwards AB metadata to tracking events when provided', async () => { - const testUser = user.setup(); - const abTest = { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant: NEWSLETTER_PREVIEW_VARIANT.illustrated, - } as const; - - renderForm({ abTest }); - - await typeEmailAddress(testUser); - await testUser.click(screen.getByRole('button', { name: 'Sign up' })); - - await waitFor(() => { - expect(screen.getByText("You're signed up!")).toBeInTheDocument(); - }); - - const trackingCalls = (submitComponentEvent as jest.Mock).mock - .calls as Array<[{ abTest?: { name: string; variant: string } }]>; - const [firstEventPayload] = trackingCalls[0] ?? [{}]; - expect(firstEventPayload.abTest).toEqual(abTest); - }); - it('supports tab order from email input to marketing toggle to sign up', async () => { const testUser = user.setup(); diff --git a/dotcom-rendering/src/lib/newsletterSignupAbTest.ts b/dotcom-rendering/src/lib/newsletterSignupAbTest.ts deleted file mode 100644 index 768f3b7da8c..00000000000 --- a/dotcom-rendering/src/lib/newsletterSignupAbTest.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { AbTest } from '@guardian/ophan-tracker-js'; - -export const NEWSLETTER_PREVIEW_AB_TEST_NAME = - 'newsletters-in-article-signup-preview'; - -export const NEWSLETTER_PREVIEW_VARIANT = { - illustrated: 'illustrated', - withoutPreview: 'without-preview', -} as const; - -export type NewsletterPreviewVariant = - (typeof NEWSLETTER_PREVIEW_VARIANT)[keyof typeof NEWSLETTER_PREVIEW_VARIANT]; - -export const isNewsletterPreviewVariant = ( - variant: string | undefined, -): variant is NewsletterPreviewVariant => - variant === NEWSLETTER_PREVIEW_VARIANT.illustrated || - variant === NEWSLETTER_PREVIEW_VARIANT.withoutPreview; - -export const isWithoutPreviewVariant = (variant: string | undefined): boolean => - variant === NEWSLETTER_PREVIEW_VARIANT.withoutPreview; - -export const resolveNewsletterPreviewAbTest = ( - variant: string | undefined, -): AbTest | undefined => { - if (!isNewsletterPreviewVariant(variant)) { - return; - } - - return { - name: NEWSLETTER_PREVIEW_AB_TEST_NAME, - variant, - }; -}; From 4d11a56d0b17affd75d6dc5d8ca19f1c2cf9639e Mon Sep 17 00:00:00 2001 From: "ben.dudiak-fry" Date: Mon, 10 Aug 2026 13:27:13 +0100 Subject: [PATCH 2/3] feat: remove newsletters preview Removes preview button from Newsletter signup cards. Additionally removes NewsletterPreviewModal and NewsletterPreviewButton as they are no longer used --- .../EmailSignUpWrapper.island.test.tsx | 8 +- .../components/EmailSignUpWrapper.island.tsx | 32 +- .../components/EmailSignUpWrapper.stories.tsx | 1 - .../components/NewsletterPreviewButton.tsx | 62 -- .../NewsletterPreviewModal.stories.tsx | 53 -- .../NewsletterPreviewModal.test.tsx | 361 --------- .../src/components/NewsletterPreviewModal.tsx | 741 ------------------ .../src/components/NewsletterSignupCard.tsx | 17 - .../NewsletterSignupCardContainer.test.tsx | 188 ----- .../NewsletterSignupCardContainer.tsx | 129 +-- .../NewsletterSignupForm.island.stories.tsx | 17 +- .../NewsletterSignupForm.island.tsx | 26 +- .../src/lib/newsletterPreviewUrl.test.ts | 99 --- .../src/lib/newsletterPreviewUrl.ts | 118 --- 14 files changed, 29 insertions(+), 1823 deletions(-) delete mode 100644 dotcom-rendering/src/components/NewsletterPreviewButton.tsx delete mode 100644 dotcom-rendering/src/components/NewsletterPreviewModal.stories.tsx delete mode 100644 dotcom-rendering/src/components/NewsletterPreviewModal.test.tsx delete mode 100644 dotcom-rendering/src/components/NewsletterPreviewModal.tsx delete mode 100644 dotcom-rendering/src/components/NewsletterSignupCardContainer.test.tsx delete mode 100644 dotcom-rendering/src/lib/newsletterPreviewUrl.test.ts delete mode 100644 dotcom-rendering/src/lib/newsletterPreviewUrl.ts diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx index 6dfed7691ea..633c94f3ac4 100644 --- a/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx +++ b/dotcom-rendering/src/components/EmailSignUpWrapper.island.test.tsx @@ -34,12 +34,8 @@ jest.mock('./NewsletterSignupCardContainer', () => ({ NewsletterSignupCardContainer: ({ children, }: { - children: (openPreview: (() => void) | undefined) => React.ReactNode; - }) => ( -
- {children(undefined)} -
- ), + children: React.ReactNode; + }) =>
{children}
, })); const defaultProps = { diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx index 84b203d8dbf..9893bfd8b52 100644 --- a/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx +++ b/dotcom-rendering/src/components/EmailSignUpWrapper.island.tsx @@ -11,19 +11,14 @@ import { InlineSkipToWrapper } from './InlineSkipToWrapper'; import { Island } from './Island'; import { NewsletterSignupCardContainer } from './NewsletterSignupCardContainer'; import { NewsletterSignupForm } from './NewsletterSignupForm.island'; -// When the next A/B experiment is added (e.g. preview-button test), import -// useAB and AB_TEST_NAME from their respective modules and thread them through -// sendNewsletterSignupEvent's `abTest` param and NewsletterSignupForm's `abTest` prop. interface EmailSignUpWrapperProps extends EmailSignUpProps { index: number; listId: number; identityName: string; - category?: string; /** Illustration image URL (square crop) for the NewsletterSignupCard */ illustrationSquare?: string; idApiUrl: string; - exampleUrl?: string; } /** @@ -36,9 +31,7 @@ export const EmailSignUpWrapper = ({ index, listId, identityName, - category, idApiUrl, - exampleUrl, name, description, illustrationSquare, @@ -90,24 +83,17 @@ export const EmailSignUpWrapper = ({ illustrationSquare={illustrationSquare} frequency={frequency} theme={theme} - identityName={identityName} - category={category} - exampleUrl={exampleUrl} - renderingTarget={renderingTarget} isSignedIn={isSignedIn} > - {(previewAction) => ( - - - - )} + + + ); diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx index a3e92e981dd..b8c814dc933 100644 --- a/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx +++ b/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx @@ -23,7 +23,6 @@ const defaultArgs = { frequency: 'Weekly', theme: 'sport', idApiUrl: 'https://idapi.theguardian.com', - exampleUrl: 'https://www.theguardian.com/email/the-recap', illustrationSquare: 'https://i.guim.co.uk/img/uploads/2023/11/01/SaturdayEdition_-_5-3.jpg?width=220&dpr=2&s=none&crop=5%3A3', } satisfies Story['args']; diff --git a/dotcom-rendering/src/components/NewsletterPreviewButton.tsx b/dotcom-rendering/src/components/NewsletterPreviewButton.tsx deleted file mode 100644 index c290046f729..00000000000 --- a/dotcom-rendering/src/components/NewsletterPreviewButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { Size, ThemeButton } from '@guardian/source/react-components'; -import { Button, LinkButton, SvgEye } from '@guardian/source/react-components'; -import { palette } from '../palette'; - -export type NewsletterPreviewAction = - | { - behaviour: 'modal'; - onClick: () => void; - } - | { - behaviour: 'link'; - href: string; - onClick: () => void; - }; - -/** - * Colour overrides for newsletter tertiary buttons so that they are visible - * in both light and dark mode, independent of the article theme. - * - * Used by the preview button and the "Browse more newsletters" link. - */ -export const newsletterTertiaryButtonTheme: Partial = { - textTertiary: palette('--newsletter-preview-button-text'), - borderTertiary: palette('--newsletter-preview-button-border'), - backgroundTertiaryHover: palette('--newsletter-preview-button-hover'), -}; - -export const NewsletterPreviewButton = ({ - previewAction, - size = 'default', -}: { - previewAction: NewsletterPreviewAction; - size?: Size; -}) => - previewAction.behaviour === 'link' ? ( - } - iconSide="left" - href={previewAction.href} - target="_blank" - rel="noreferrer" - onClick={previewAction.onClick} - size={size} - theme={newsletterTertiaryButtonTheme} - > - Preview latest - - ) : ( - - ); diff --git a/dotcom-rendering/src/components/NewsletterPreviewModal.stories.tsx b/dotcom-rendering/src/components/NewsletterPreviewModal.stories.tsx deleted file mode 100644 index c15e4c1cc66..00000000000 --- a/dotcom-rendering/src/components/NewsletterPreviewModal.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { breakpoints } from '@guardian/source/foundations'; -import { fn } from 'storybook/test'; -import preview from '../../.storybook/preview'; -import { NewsletterPreviewModal } from './NewsletterPreviewModal'; - -const meta = preview.meta({ - title: 'Components/NewsletterPreviewModal', - component: NewsletterPreviewModal, -}); - -const defaultArgs = { - newsletterName: 'The Long Wave', - renderUrl: 'about:blank', - onClose: fn(), -}; - -export const Default = meta.story({ - args: { - ...defaultArgs, - }, -}); - -export const LongTitle = meta.story({ - args: { - ...defaultArgs, - newsletterName: - 'The European politics briefing with a deliberately very long newsletter title', - }, -}); - -export const Mobile = meta.story({ - args: { - ...defaultArgs, - }, - parameters: { - chromatic: { - viewports: [breakpoints.mobile], - }, - }, -}); - -export const MobileLongTitle = meta.story({ - args: { - ...defaultArgs, - newsletterName: - 'The European politics briefing with a deliberately very long newsletter title', - }, - parameters: { - chromatic: { - viewports: [breakpoints.mobile], - }, - }, -}); diff --git a/dotcom-rendering/src/components/NewsletterPreviewModal.test.tsx b/dotcom-rendering/src/components/NewsletterPreviewModal.test.tsx deleted file mode 100644 index d83dd212ae2..00000000000 --- a/dotcom-rendering/src/components/NewsletterPreviewModal.test.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import '@testing-library/jest-dom'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { NewsletterPreviewModal } from './NewsletterPreviewModal'; - -const baseProps = { - newsletterName: 'Morning Briefing', - renderUrl: - 'https://email-rendering.guardianapis.com/fronts/email/europe/daily?variant=persephone&readonly=true&embed=true', -}; - -describe('NewsletterPreviewModal', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders a labelled dialog and focuses it on mount', () => { - render(); - - const dialog = screen.getByRole('dialog', { - name: 'Morning Briefing preview', - }); - - expect(dialog).toBeInTheDocument(); - expect(dialog).toHaveFocus(); - }); - - it('keeps focus inside the dialog when tabbing forwards from the end', () => { - const outsideButton = document.createElement('button'); - outsideButton.textContent = 'Outside control'; - document.body.appendChild(outsideButton); - - try { - render( - , - ); - - const dialog = screen.getByRole('dialog'); - - expect(dialog).toHaveFocus(); - outsideButton.focus(); - expect(outsideButton).toHaveFocus(); - - dialog.focus(); - fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); - const lastFocusable = document.activeElement as HTMLElement; - expect(dialog).toContainElement(lastFocusable); - - fireEvent.keyDown(document, { key: 'Tab' }); - expect(dialog).toContainElement( - document.activeElement as HTMLElement, - ); - - expect(outsideButton).not.toHaveFocus(); - } finally { - document.body.removeChild(outsideButton); - } - }); - - it('keeps focus inside the dialog when tabbing backwards from the start', () => { - const outsideButton = document.createElement('button'); - outsideButton.textContent = 'Outside control'; - document.body.appendChild(outsideButton); - - try { - render( - , - ); - - const dialog = screen.getByRole('dialog'); - - expect(dialog).toHaveFocus(); - outsideButton.focus(); - expect(outsideButton).toHaveFocus(); - - dialog.focus(); - fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); - expect(dialog).toContainElement( - document.activeElement as HTMLElement, - ); - - expect(outsideButton).not.toHaveFocus(); - } finally { - document.body.removeChild(outsideButton); - } - }); - - it('restores focus to previously focused element on unmount', () => { - const trigger = document.createElement('button'); - trigger.textContent = 'Open preview'; - document.body.appendChild(trigger); - trigger.focus(); - - const { unmount } = render( - , - ); - - expect(screen.getByRole('dialog')).toHaveFocus(); - - unmount(); - - expect(trigger).toHaveFocus(); - document.body.removeChild(trigger); - }); - - it('calls onClose after the close animation when clicking away from the dialog', () => { - jest.useFakeTimers(); - - try { - const onClose = jest.fn(); - - render(); - - const dialog = screen.getByRole('dialog'); - const overlay = dialog.parentElement; - expect(overlay).not.toBeNull(); - - fireEvent.mouseDown(dialog); - expect(onClose).not.toHaveBeenCalled(); - - fireEvent.mouseDown(overlay as HTMLElement); - expect(onClose).not.toHaveBeenCalled(); - - act(() => { - jest.advanceTimersByTime(225); - }); - - expect(onClose).toHaveBeenCalledTimes(1); - } finally { - jest.useRealTimers(); - } - }); - - it('calls onClose after the close animation when Escape is pressed inside the dialog', () => { - jest.useFakeTimers(); - - try { - const onClose = jest.fn(); - - render(); - - const dialog = screen.getByRole('dialog'); - dialog.focus(); - - fireEvent.keyDown(document, { key: 'Escape' }); - expect(onClose).not.toHaveBeenCalled(); - - act(() => { - jest.advanceTimersByTime(225); - }); - - expect(onClose).toHaveBeenCalledTimes(1); - } finally { - jest.useRealTimers(); - } - }); - - it('locks page scrolling while open and restores it on unmount', () => { - const previousRootOverflow = document.documentElement.style.overflow; - const previousBodyOverflow = document.body.style.overflow; - - document.documentElement.style.overflow = 'auto'; - document.body.style.overflow = 'scroll'; - - const { unmount } = render( - , - ); - let isUnmounted = false; - - try { - expect(document.documentElement.style.overflow).toBe('hidden'); - expect(document.body.style.overflow).toBe('hidden'); - - unmount(); - isUnmounted = true; - - expect(document.documentElement.style.overflow).toBe('auto'); - expect(document.body.style.overflow).toBe('scroll'); - } finally { - if (!isUnmounted) { - unmount(); - } - document.documentElement.style.overflow = previousRootOverflow; - document.body.style.overflow = previousBodyOverflow; - } - }); - - it('shows a skeleton while loading and hides it once iframe is loaded', () => { - render(); - - expect( - screen.getByLabelText('Loading newsletter preview'), - ).toBeInTheDocument(); - - const iframe = screen.getByTitle('Morning Briefing preview'); - fireEvent.load(iframe); - - expect( - screen.queryByLabelText('Loading newsletter preview'), - ).not.toBeInTheDocument(); - expect( - screen.queryByText('Preview failed to load'), - ).not.toBeInTheDocument(); - }); - - it('shows a timeout fallback and allows retrying the preview load', () => { - jest.useFakeTimers(); - - try { - render( - , - ); - - act(() => { - jest.advanceTimersByTime(10_000); - }); - - expect( - screen.getByText('Preview failed to load'), - ).toBeInTheDocument(); - expect( - screen.getByText( - 'The preview is taking longer than expected. You can retry loading it.', - ), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Retry preview' }), - ).toBeInTheDocument(); - - fireEvent.click( - screen.getByRole('button', { name: 'Retry preview' }), - ); - - expect( - screen.getByLabelText('Loading newsletter preview'), - ).toBeInTheDocument(); - - const iframe = screen.getByTitle('Morning Briefing preview'); - fireEvent.load(iframe); - - expect( - screen.queryByText('Preview failed to load'), - ).not.toBeInTheDocument(); - } finally { - jest.useRealTimers(); - } - }); - - it('shows failure state when iframe posts embed-status with ok=false', () => { - render(); - const iframe = screen.getByTitle('Morning Briefing preview'); - if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('Expected preview element to be an iframe'); - } - const iframeSource = iframe.contentWindow; - if (!iframeSource) { - throw new Error('Expected iframe contentWindow to be available'); - } - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - origin: 'https://email-rendering.guardianapis.com', - source: iframeSource, - data: { type: 'embed-status', ok: false, code: 500 }, - }), - ); - }); - - expect(screen.getByText('Preview failed to load')).toBeInTheDocument(); - expect( - screen.getByText( - 'This preview is currently unavailable. Please try again shortly.', - ), - ).toBeInTheDocument(); - }); - - it('keeps failure state when embed-status reports failure before iframe load event', () => { - render(); - - const iframe = screen.getByTitle('Morning Briefing preview'); - if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('Expected preview element to be an iframe'); - } - const iframeSource = iframe.contentWindow; - if (!iframeSource) { - throw new Error('Expected iframe contentWindow to be available'); - } - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - origin: 'https://email-rendering.guardianapis.com', - source: iframeSource, - data: { type: 'embed-status', ok: false }, - }), - ); - }); - - fireEvent.load(iframe); - - expect(screen.getByText('Preview failed to load')).toBeInTheDocument(); - expect( - screen.getByText( - 'This preview is currently unavailable. Please try again shortly.', - ), - ).toBeInTheDocument(); - }); - - it('hides skeleton when iframe posts embed-status with ok=true', () => { - render(); - const iframe = screen.getByTitle('Morning Briefing preview'); - if (!(iframe instanceof HTMLIFrameElement)) { - throw new Error('Expected preview element to be an iframe'); - } - const iframeSource = iframe.contentWindow; - if (!iframeSource) { - throw new Error('Expected iframe contentWindow to be available'); - } - - expect( - screen.getByLabelText('Loading newsletter preview'), - ).toBeInTheDocument(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - origin: 'https://email-rendering.guardianapis.com', - source: iframeSource, - data: { type: 'embed-status', ok: true }, - }), - ); - }); - - expect( - screen.queryByLabelText('Loading newsletter preview'), - ).not.toBeInTheDocument(); - expect( - screen.queryByText('Preview failed to load'), - ).not.toBeInTheDocument(); - }); - - it('ignores embed-status messages without a matching iframe source', () => { - render(); - - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - origin: 'https://email-rendering.guardianapis.com', - data: { type: 'embed-status', ok: false }, - }), - ); - }); - - expect( - screen.getByLabelText('Loading newsletter preview'), - ).toBeInTheDocument(); - expect( - screen.queryByText('Preview failed to load'), - ).not.toBeInTheDocument(); - }); -}); diff --git a/dotcom-rendering/src/components/NewsletterPreviewModal.tsx b/dotcom-rendering/src/components/NewsletterPreviewModal.tsx deleted file mode 100644 index bfaec455fc5..00000000000 --- a/dotcom-rendering/src/components/NewsletterPreviewModal.tsx +++ /dev/null @@ -1,741 +0,0 @@ -import { css } from '@emotion/react'; -import { - from, - headlineMedium20, - headlineMedium24, - palette, - space, - textSans15, -} from '@guardian/source/foundations'; -import { Button, SvgCross } from '@guardian/source/react-components'; -import { useCallback, useEffect, useId, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { getZIndex } from '../lib/getZIndex'; -import { EMAIL_PREVIEW_ORIGIN } from '../lib/newsletterPreviewUrl'; - -const PREVIEW_LOAD_TIMEOUT_MS = 10_000; -const OPEN_ANIMATION_DURATION_MS = 300; -const CLOSE_ANIMATION_DURATION_MS = 225; -const MOBILE_PREVIEW_IFRAME_HEIGHT_PX = 10000; -const TIMEOUT_FAILURE_MESSAGE = - 'The preview is taking longer than expected. You can retry loading it.'; -const UNAVAILABLE_FAILURE_MESSAGE = - 'This preview is currently unavailable. Please try again shortly.'; - -type EmbedStatusMessage = { - type: 'embed-status'; - ok: boolean; -}; - -const parseEmbedStatusMessage = ( - data: unknown, -): EmbedStatusMessage | undefined => { - let payload: unknown = data; - - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - return undefined; - } - } - - if (!payload || typeof payload !== 'object') { - return undefined; - } - - const { type, ok } = payload as { - type?: unknown; - ok?: unknown; - }; - - if (type !== 'embed-status' || typeof ok !== 'boolean') { - return undefined; - } - - return { - type: 'embed-status', - ok, - }; -}; - -const getTrustedIframeOrigin = (url: string): string | undefined => { - try { - const origin = new URL(url).origin; - return origin === EMAIL_PREVIEW_ORIGIN ? origin : undefined; - } catch { - return undefined; - } -}; - -const isTrustedIframeMessage = ({ - event, - trustedOrigin, - iframeWindow, -}: { - event: MessageEvent; - trustedOrigin: string; - iframeWindow: Window | null; -}): boolean => { - const isTrustedOrigin = event.origin === trustedOrigin; - const isExpectedSource = - iframeWindow !== null && event.source === iframeWindow; - - if (!isTrustedOrigin || !isExpectedSource) { - return false; - } - - return true; -}; - -const FOCUSABLE_SELECTOR = - 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])'; - -const previewOverlayStyles = (isVisible: boolean) => css` - position: fixed; - inset: 0; - display: flex; - align-items: flex-end; - justify-content: center; - padding: ${space[3]}px 0 0; - height: 100vh; - height: 100svh; - background-color: rgba(0, 0, 0, ${isVisible ? 0.75 : 0}); - transition: background-color - ${isVisible - ? OPEN_ANIMATION_DURATION_MS - : CLOSE_ANIMATION_DURATION_MS}ms - ease; - z-index: ${getZIndex('lightbox')}; - will-change: background-color; - - @supports (height: 100dvh) { - height: 100dvh; - } - - ${from.tablet} { - align-items: center; - padding: ${space[3]}px; - } - - @media (prefers-reduced-motion: reduce) { - transition: none; - } -`; - -const previewDialogStyles = (isVisible: boolean) => css` - display: flex; - flex-direction: column; - background: ${palette.neutral[100]}; - width: 100%; - height: min(82vh, 760px); - height: min(82svh, 760px); - border-radius: ${space[3]}px ${space[3]}px 0 0; - overflow: hidden; - transform: translateY(${isVisible ? '0' : '100%'}); - transition: transform - ${isVisible - ? OPEN_ANIMATION_DURATION_MS - : CLOSE_ANIMATION_DURATION_MS}ms - ease; - will-change: transform; - - @supports (height: 100dvh) { - height: min(82dvh, 760px); - } - - ${from.tablet} { - width: min(652px, 100%); - height: min(90vh, 900px); - border-radius: ${space[2]}px; - transform: none; - opacity: ${isVisible ? 1 : 0}; - transition: opacity ${isVisible ? 225 : 175}ms ease; - will-change: opacity; - } - - @media (prefers-reduced-motion: reduce) { - transition: none; - transform: none; - opacity: 1; - } -`; - -const previewHeaderStyles = css` - display: flex; - align-items: flex-start; - padding: ${space[4]}px ${space[3]}px; - border-bottom: 1px solid ${palette.neutral[86]}; - - ${from.tablet} { - justify-content: space-between; - } -`; - -const previewTitleStyles = css` - ${headlineMedium20}; - color: ${palette.neutral[7]}; - margin: 0; - - ${from.tablet} { - ${headlineMedium24}; - padding-right: ${space[3]}px; - } -`; - -const previewFrameStyles = css` - height: ${MOBILE_PREVIEW_IFRAME_HEIGHT_PX}px; - width: 100%; - min-height: 100%; - min-width: 100%; - display: block; - border: 0; - background: ${palette.neutral[100]}; - padding: 0; - - ${from.tablet} { - height: 1px; - width: 1px; - } -`; - -const previewFrameContainerStyles = css` - position: relative; - display: flex; - min-height: 0; - flex: 1; - background: ${palette.neutral[100]}; - overflow-y: auto; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; - touch-action: pan-y; - - ${from.tablet} { - overflow: hidden; - padding: 0 ${space[6]}px; - } -`; - -const previewIframeVisibilityStyles = (isVisible: boolean) => css` - opacity: ${isVisible ? 1 : 0}; - visibility: ${isVisible ? 'visible' : 'hidden'}; - transition: opacity 180ms ease; - - @media (prefers-reduced-motion: reduce) { - transition: none; - } -`; - -const previewLoadingOverlayStyles = (isVisible: boolean) => css` - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - justify-content: flex-start; - padding: 0 ${space[3]}px ${space[4]}px; - background: ${palette.neutral[100]}; - overflow-y: hidden; - opacity: ${isVisible ? 1 : 0}; - pointer-events: ${isVisible ? 'auto' : 'none'}; - transition: opacity 180ms ease; - will-change: opacity; - - ${from.tablet} { - padding: 0 ${space[9]}px ${space[9]}px; - } - - @media (prefers-reduced-motion: reduce) { - transition: none; - } -`; - -const previewSkeletonBlockStyles = css` - width: 100%; - flex-shrink: 0; - background: linear-gradient( - 90deg, - ${palette.neutral[86]} 25%, - ${palette.neutral[97]} 50%, - ${palette.neutral[86]} 75% - ); - background-size: 200% 100%; - animation: preview-skeleton-shimmer 1.2s linear infinite; - - @keyframes preview-skeleton-shimmer { - from { - background-position: 200% 0; - } - to { - background-position: -200% 0; - } - } -`; - -const previewSkeletonBannerStyles = css` - ${previewSkeletonBlockStyles}; - height: 96px; -`; - -const previewSkeletonLargeStyles = css` - ${previewSkeletonBlockStyles}; - height: 332px; - margin: 16px 0; -`; - -const previewSkeletonSmallStyles = css` - ${previewSkeletonBlockStyles}; - height: 52px; - margin-top: 16px; -`; - -const previewStatusStyles = css` - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - padding: ${space[4]}px ${space[3]}px; - background: ${palette.neutral[100]}; - text-align: center; - - ${from.tablet} { - padding: ${space[6]}px; - } -`; - -const previewStatusInnerStyles = css` - max-width: 520px; -`; - -const previewStatusTitleStyles = css` - ${headlineMedium20}; - margin: 0 0 ${space[2]}px; - color: ${palette.neutral[7]}; -`; - -const previewStatusBodyStyles = css` - ${textSans15}; - margin: 0 0 ${space[4]}px; - color: ${palette.neutral[20]}; -`; - -const desktopCloseButtonStyles = css` - padding: 0; - min-width: 32px; - min-height: 32px; - width: 32px; - height: 32px; - border: 0; - border-radius: 50%; - background: ${palette.neutral[93]}; - color: ${palette.brand[400]}; - display: none; - - &&:hover, - &&:focus { - background: ${palette.neutral[86]}; - border: 0; - color: ${palette.brand[400]}; - } - - ${from.tablet} { - display: inline-flex; - } -`; - -const mobileCloseBarStyles = css` - padding: ${space[3]}px ${space[3]}px - calc(${space[6]}px + env(safe-area-inset-bottom)); - border-top: 1px solid ${palette.neutral[86]}; - background: ${palette.neutral[100]}; - position: relative; - z-index: 1; - box-shadow: 0 0 14px rgba(0, 0, 0, 0.4); - - ${from.tablet} { - display: none; - } -`; - -const mobileCloseButtonStyles = css` - && { - width: 100%; - justify-content: center; - background: ${palette.neutral[100]}; - border: 1px solid ${palette.brand[400]}; - color: ${palette.brand[400]}; - } - - &&:hover, - &&:focus { - background: ${palette.neutral[100]}; - border-color: ${palette.brand[400]}; - color: ${palette.brand[400]}; - } -`; - -type Props = { - newsletterName: string; - renderUrl: string; - onClose: () => void; -}; - -export const NewsletterPreviewModal = ({ - newsletterName, - renderUrl, - onClose, -}: Props) => { - const overlayRef = useRef(null); - const dialogRef = useRef(null); - const iframeRef = useRef(null); - const hasEmbedStatusFailureRef = useRef(false); - const closeTimeoutRef = useRef(null); - const titleId = useId(); - const [isVisible, setIsVisible] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [hasLoadFailed, setHasLoadFailed] = useState(false); - const [failureMessage, setFailureMessage] = useState( - UNAVAILABLE_FAILURE_MESSAGE, - ); - const [iframeKey, setIframeKey] = useState(0); - - const trustedIframeOrigin = getTrustedIframeOrigin(renderUrl); - - const applyEmbedStatus = (ok: boolean) => { - hasEmbedStatusFailureRef.current = !ok; - - if (!ok) { - setFailureMessage(UNAVAILABLE_FAILURE_MESSAGE); - } - - setIsLoading(false); - setHasLoadFailed(!ok); - }; - - const getVisibleFocusableElements = (dialog: HTMLElement): HTMLElement[] => - Array.from( - dialog.querySelectorAll(FOCUSABLE_SELECTOR), - ).filter((element) => { - const computedStyle = window.getComputedStyle(element); - return ( - computedStyle.display !== 'none' && - computedStyle.visibility !== 'hidden' && - element.getAttribute('aria-hidden') !== 'true' - ); - }); - - const requestClose = useCallback(() => { - if (closeTimeoutRef.current !== null) { - return; - } - - setIsVisible(false); - closeTimeoutRef.current = window.setTimeout(() => { - closeTimeoutRef.current = null; - onClose(); - }, CLOSE_ANIMATION_DURATION_MS); - }, [onClose]); - - useEffect(() => { - const animationFrameId = window.requestAnimationFrame(() => { - setIsVisible(true); - }); - - return () => { - window.cancelAnimationFrame(animationFrameId); - }; - }, []); - - useEffect(() => { - const rootElement = document.documentElement; - const previousRootOverflow = rootElement.style.overflow; - const previousBodyOverflow = document.body.style.overflow; - - rootElement.style.overflow = 'hidden'; - document.body.style.overflow = 'hidden'; - - return () => { - if (closeTimeoutRef.current !== null) { - window.clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - rootElement.style.overflow = previousRootOverflow; - document.body.style.overflow = previousBodyOverflow; - }; - }, []); - - useEffect(() => { - if (!dialogRef.current) { - return; - } - - const dialogElement = dialogRef.current; - const previouslyFocusedElement = - document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - - dialogElement.focus(); - - return () => { - if ( - previouslyFocusedElement && - document.contains(previouslyFocusedElement) - ) { - previouslyFocusedElement.focus(); - } - }; - }, []); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent): void => { - if (!dialogRef.current) { - return; - } - - const dialogElement = dialogRef.current; - if (!dialogElement.contains(document.activeElement)) { - return; - } - - if (event.key === 'Escape') { - event.stopPropagation(); - requestClose(); - return; - } - - if (event.key !== 'Tab') { - return; - } - - const focusableElements = - getVisibleFocusableElements(dialogElement); - if (focusableElements.length === 0) { - event.preventDefault(); - dialogElement.focus(); - return; - } - - const firstFocusableElement = focusableElements[0]!; - const lastFocusableElement = - focusableElements[focusableElements.length - 1]!; - - if (event.shiftKey) { - if ( - document.activeElement === firstFocusableElement || - document.activeElement === dialogElement - ) { - event.preventDefault(); - lastFocusableElement.focus(); - } - return; - } - - if (document.activeElement === lastFocusableElement) { - event.preventDefault(); - firstFocusableElement.focus(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [requestClose]); - - useEffect(() => { - const overlayElement = overlayRef.current; - if (!overlayElement) { - return; - } - - const handleOverlayMouseDown = (event: MouseEvent) => { - if (event.target === overlayElement) { - requestClose(); - } - }; - - overlayElement.addEventListener('mousedown', handleOverlayMouseDown); - - return () => { - overlayElement.removeEventListener( - 'mousedown', - handleOverlayMouseDown, - ); - }; - }, [requestClose]); - - useEffect(() => { - hasEmbedStatusFailureRef.current = false; - setIsLoading(true); - setHasLoadFailed(false); - setFailureMessage(UNAVAILABLE_FAILURE_MESSAGE); - }, [renderUrl, iframeKey]); - - useEffect(() => { - if (!isLoading) { - return; - } - - const timeoutId = window.setTimeout(() => { - setFailureMessage(TIMEOUT_FAILURE_MESSAGE); - setHasLoadFailed(true); - setIsLoading(false); - }, PREVIEW_LOAD_TIMEOUT_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [isLoading]); - - useEffect(() => { - if (!trustedIframeOrigin) { - return; - } - - const handleMessage = (event: MessageEvent) => { - if (!iframeRef.current) { - return; - } - - const iframeWindow = iframeRef.current.contentWindow; - if ( - !isTrustedIframeMessage({ - event, - trustedOrigin: trustedIframeOrigin, - iframeWindow, - }) - ) { - return; - } - - const embedStatusMessage = parseEmbedStatusMessage(event.data); - if (!embedStatusMessage) { - return; - } - - applyEmbedStatus(embedStatusMessage.ok); - }; - - window.addEventListener('message', handleMessage); - - return () => { - window.removeEventListener('message', handleMessage); - }; - }, [trustedIframeOrigin]); - - const handleIframeLoad = () => { - if (hasEmbedStatusFailureRef.current) { - return; - } - setIsLoading(false); - setHasLoadFailed(false); - }; - - const handleIframeError = () => { - setFailureMessage(UNAVAILABLE_FAILURE_MESSAGE); - setHasLoadFailed(true); - setIsLoading(false); - }; - - const retryLoad = () => { - setIframeKey((currentKey) => currentKey + 1); - }; - - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
-
-
-

- {newsletterName} preview -

- -
-
-
-
-
-
-
-
-
- {hasLoadFailed && ( -
-
-

- Preview failed to load -

-

- {failureMessage} -

- -
-
- )} -