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..633c94f3ac4 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,23 @@ 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: React.ReactNode; + }) =>
{children}
, })); const defaultProps = { @@ -92,12 +68,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 +121,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 +134,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..9893bfd8b52 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'; @@ -18,22 +12,13 @@ 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; - 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; } /** @@ -46,9 +31,7 @@ export const EmailSignUpWrapper = ({ index, listId, identityName, - category, idApiUrl, - exampleUrl, name, description, illustrationSquare, @@ -56,14 +39,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 +60,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) => ( - - - - )} + + + ); 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} -

- -
-
- )} -