diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx index 28433c5cfc5..9a573c2634f 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx @@ -187,7 +187,11 @@ const buildRRBannerConfigWith = ({ inNoShowMobileAboveNavVariant, }), show: ({ name, props }: ModuleData) => ( - + ), }, timeoutMillis: DEFAULT_BANNER_TIMEOUT_MILLIS, diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.test.ts b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.test.ts index b3ce088b553..8c3675a6384 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.test.ts +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.test.ts @@ -19,7 +19,18 @@ jest.mock('../../lib/fetchEmail', () => ({ lazyFetchEmailWithTimeout: jest.fn(), })); +jest.mock('../../lib/identity', () => ({ + getAuthStatus: jest.fn(), +})); + +jest.mock('../../lib/newsletterSubscriptionCache', () => ({ + fetchNewsletterSubscriptions: jest.fn(), + getCachedSubscriptions: jest.fn(), + shouldInvalidateCache: jest.fn(), +})); + const mockGetCookie = jest.mocked(getCookie); +mockGetCookie.mockReturnValue(null); const buildBannerPayload = async ( inAuxiaVariant: boolean, @@ -47,6 +58,7 @@ const buildBannerPayload = async ( userConsent, hideSupportMessagingForUser: false, inAuxiaVariant, + userSubscriptions: [], }); }; @@ -71,6 +83,54 @@ describe('getAuxiaRolloutMvtId', () => { }); describe('banner request targeting payload', () => { + it.each([[[6042, 1234]], [[]]])( + 'includes user subscriptions in the SDC payload: %j', + async (userSubscriptions: number[]) => { + const payload = await buildPayload({ + isSignedIn: false, + contentType: 'Article', + shouldHideReaderRevenue: false, + isMinuteArticle: false, + isPaidContent: false, + isSensitive: false, + tags: [], + contributionsServiceUrl: 'https://contributions.example.com', + countryCode: 'GB', + optedOutOfArticleCount: false, + asyncArticleCounts: Promise.resolve(undefined), + userConsent: true, + hideSupportMessagingForUser: false, + userSubscriptions, + }); + + expect(payload.targeting).toHaveProperty( + 'userSubscriptions', + userSubscriptions, + ); + }, + ); + + it('sends an empty subscription list when there are no subscriptions', async () => { + const payload = await buildPayload({ + isSignedIn: true, + contentType: 'Article', + shouldHideReaderRevenue: false, + isMinuteArticle: false, + isPaidContent: false, + isSensitive: false, + tags: [], + contributionsServiceUrl: 'https://contributions.example.com', + countryCode: 'GB', + optedOutOfArticleCount: false, + asyncArticleCounts: Promise.resolve(undefined), + userConsent: true, + hideSupportMessagingForUser: false, + userSubscriptions: [], + }); + + expect(payload.targeting).toHaveProperty('userSubscriptions', []); + }); + it.each([ ['valid MVT ID', '123456', 123456], ['missing MVT ID', null, undefined], diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index c813b8ba4b0..dd4a3eacddd 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -30,7 +30,13 @@ import { import { getToday } from '../../lib/dailyArticleCount'; import { lazyFetchEmailWithTimeout } from '../../lib/fetchEmail'; import { getZIndex } from '../../lib/getZIndex'; +import { getAuthStatus } from '../../lib/identity'; import type { CanShowResult } from '../../lib/messagePicker'; +import { + fetchNewsletterSubscriptions, + getCachedSubscriptions, + shouldInvalidateCache, +} from '../../lib/newsletterSubscriptionCache'; import { getBanner } from '../../lib/sdcRequests'; import type { RenderingTarget } from '../../types/renderingTarget'; import type { TagType } from '../../types/tag'; @@ -61,6 +67,7 @@ type BuildPayloadProps = BaseProps & { asyncArticleCounts: Promise; userConsent: boolean; hideSupportMessagingForUser: boolean; + userSubscriptions: number[]; }; const MAX_AUXIA_ROLLOUT_MVT_ID = 1_000_000; @@ -172,6 +179,7 @@ export const buildPayload = async ({ inHoldbackGroup, inAuxiaVariant, isSensitive, + userSubscriptions, }: BuildPayloadProps): Promise => { const getBrowserId = (): string | undefined => { if (inAuxiaVariant !== true) { @@ -197,6 +205,7 @@ export const buildPayload = async ({ const targeting: BannerPayload['targeting'] & { auxiaRolloutMvtId?: number; + userSubscriptions: number[]; } = { shouldHideReaderRevenue, isPaidContent, @@ -223,6 +232,7 @@ export const buildPayload = async ({ inHoldbackGroup, browserId: getBrowserId(), isSensitive, + userSubscriptions, ...(auxiaRolloutMvtId === undefined ? {} : { auxiaRolloutMvtId }), }; @@ -248,6 +258,7 @@ export const canShowRRBanner: CanShowFunctionType< signInBannerLastClosedAt, abandonedBasketBannerLastClosedAt, isPreview, + idApiUrl, renderingTarget, asyncArticleCounts, ophanPageViewId, @@ -310,6 +321,23 @@ export const canShowRRBanner: CanShowFunctionType< const userConsent = await hasRequiredConsents(); const optedOutOfArticleCount = await hasOptedOutOfArticleCount(); + let userSubscriptions: number[] = []; + if (isSignedIn) { + const authStatus = await getAuthStatus(); + if (authStatus.kind === 'SignedIn') { + const userId = authStatus.idToken.claims.sub; + const cachedSubscriptions = getCachedSubscriptions(); + userSubscriptions = + cachedSubscriptions && + !shouldInvalidateCache(cachedSubscriptions, userId) + ? cachedSubscriptions.listIds + : ((await fetchNewsletterSubscriptions( + idApiUrl, + userId, + authStatus, + )) ?? []); + } + } const bannerPayload = await buildPayload({ isSignedIn, countryCode, @@ -329,6 +357,7 @@ export const canShowRRBanner: CanShowFunctionType< asyncArticleCounts, userConsent, hideSupportMessagingForUser, + userSubscriptions, pageId, inHoldbackGroup, inAuxiaVariant, @@ -384,15 +413,22 @@ export const canShowRRBanner: CanShowFunctionType< export const ReaderRevenueBanner = ({ name, props, -}: ModuleData) => { + idApiUrl, +}: ModuleData & { + idApiUrl: string; +}) => { const [Banner, setBanner] = useState(null); - useEffect(() => { (name === 'SignInPromptBanner' ? /* webpackChunkName: "sign-in-prompt-banner" */ import(`../marketing/banners/signInPrompt/SignInPromptBanner`) - : /* webpackChunkName: "designable-banner" */ - import(`../marketing/banners/designableBanner/Banner`) + : name === 'NewsletterBanner' + ? /* webpackChunkName: "newsletter-banner" */ + import( + `../marketing/banners/newsletterBanner/NewsletterBanner` + ) + : /* webpackChunkName: "designable-banner" */ + import(`../marketing/banners/designableBanner/Banner`) ) .then((bannerModule: Record) => { setBanner(() => bannerModule[name] ?? null); @@ -416,7 +452,7 @@ export const ReaderRevenueBanner = ({ `} > {} - + ); } diff --git a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx index 8b05fac94d3..1156d446a61 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx @@ -39,6 +39,7 @@ import type { BannerId, BannerRenderedContent, BannerRenderProps, + PromotedNewsletter, } from './types'; function getComponentIds(bannerId: BannerId) { @@ -128,6 +129,11 @@ const withBannerData = isCollapsible, contributionsServiceUrl, } = bannerProps; + const { idApiUrl, promotedNewsletter } = + bannerProps as CloseableBannerProps & { + idApiUrl?: string; + promotedNewsletter?: PromotedNewsletter; + }; const [hasBeenSeen, setNode] = useIsInView({ debounce: true, @@ -410,6 +416,8 @@ const withBannerData = design, promoCodes, isCollapsible, + idApiUrl, + promotedNewsletter, }; return ( diff --git a/dotcom-rendering/src/components/marketing/banners/common/types.tsx b/dotcom-rendering/src/components/marketing/banners/common/types.tsx index 3a48bd8be1a..7e3289303d4 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/types.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/types.tsx @@ -16,8 +16,17 @@ import type { } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ChoiceCardsSettings } from '@guardian/support-dotcom-components/dist/shared/types/props/choiceCards'; import type { Tracking } from '@guardian/support-dotcom-components/dist/shared/types/props/shared'; +import type { Newsletter } from '../../../../types/content'; -export type BannerId = 'designable-banner' | 'sign-in-prompt-banner'; +export type BannerId = + | 'designable-banner' + | 'sign-in-prompt-banner' + | 'newsletter-banner'; + +export type PromotedNewsletter = Pick< + Newsletter, + 'listId' | 'identityName' | 'name' | 'frequency' +>; export interface BannerEnrichedCta { ctaUrl: string; @@ -81,4 +90,6 @@ export interface BannerRenderProps { design?: ConfigurableDesign; promoCodes?: string[]; isCollapsible?: boolean; + idApiUrl?: string; + promotedNewsletter?: PromotedNewsletter; } diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx new file mode 100644 index 00000000000..95f4c6975dd --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx @@ -0,0 +1,103 @@ +import { css } from '@emotion/react'; +import { + palette, + space, + textSans17, + until, +} from '@guardian/source/foundations'; +import { + Button, + Link, + SvgAlertRound, + SvgReload, +} from '@guardian/source/react-components'; + +const responseBoxStyles = css` + padding: 7px 12px 16px 7.5px; + border: 1px solid ${palette.neutral[86]}; + border-radius: 4px; + margin-top: ${space[2]}px; + ${until.tablet} { + margin: ${space[2]}px 10px 0 10px; + } +`; + +const errorContainerStyles = css` + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + column-gap: 7.5px; + color: ${palette.error[400]}; + + > svg { + width: 32px; + height: 32px; + fill: currentColor; + } +`; + +const errorContentStyles = css` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${space[2]}px; + padding-top: 5px; +`; + +const errorTextStyles = css` + ${textSans17}; +`; + +const errorLinkStyles = css` + &, + &:visited, + &:hover, + &:active, + &:focus { + color: inherit; + font: inherit; + } +`; + +const retryButtonStyles = css` + min-width: 240px; + min-height: 44px; + svg { + width: 24px; + height: 24px; + } +`; + +export const FailureMessage = ({ + onRetry, + text, +}: { + onRetry: React.MouseEventHandler; + text: string; +}) => ( +
+ +
+ + {text} Please try again or contact{' '} + + customer.help@theguardian.com + + + +
+
+); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx new file mode 100644 index 00000000000..04ec6b9a19e --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -0,0 +1,202 @@ +import { createRef, useState } from 'react'; +import type ReactGoogleRecaptcha from 'react-google-recaptcha'; +import { fn, mocked } from 'storybook/test'; +import preview from '../../../../../.storybook/preview'; +import { useIsSignedIn } from '../../../../lib/useAuthStatus'; +import { useCountryCode } from '../../../../lib/useCountryCode'; +import { useNewsletterSignupForm } from '../../../../lib/useNewsletterSignupForm'; +import type { NewsletterSignupFormState } from '../../../../lib/useNewsletterSignupForm'; +import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; +import { design, props } from '../utils/storybook'; +import { NewsletterBanner } from './NewsletterBanner'; + +const meta = preview.meta({ + title: 'Components/marketing/NewsletterBanner', + component: NewsletterBanner, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + ...props, + content: { + heading: 'Sign up to The Stakes 2026', + paragraphs: [ + 'From the races and candidates to watch to the issues shaping American politics.', + ], + }, + isCollapsible: false, + promotedNewsletter: { + listId: 6042, + identityName: 'the-stakes-us-election-edition', + name: 'The Stakes 2026', + frequency: 'Weekly', + }, + design: { + ...design, + headerImage: { + mobileUrl: + 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', + tabletUrl: + 'https://i.guim.co.uk/img/media/20cc6e0fa146574bb9c4ed410ac1a089fab02ce0/0_0_1428_1344/500.png?width=500&quality=75&s=fe64f647f74a3cb671f8035a473b895f', + desktopUrl: + 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', + altText: 'The Stakes newsletter', + }, + }, + submitComponentEvent: async () => Promise.resolve(), + idApiUrl: 'https://idapi.theguardian.com', + }, + beforeEach() { + mocked(useIsSignedIn).mockReturnValue(false); + mocked(useNewsletterSubscription).mockReturnValue(false); + mocked(useNewsletterSignupForm).mockReturnValue(signedOutFormState); + }, +}); + +export default meta; + +const noopHandlers: Pick< + NewsletterSignupFormState, + | 'handleEmailChange' + | 'handleEmailFocus' + | 'handleEmailInvalid' + | 'handleMarketingToggle' + | 'handleSubmit' + | 'handleSubmitButtonClick' + | 'handleReset' + | 'handleCaptchaComplete' + | 'handleCaptchaLoadError' +> = { + handleEmailChange: fn(), + handleEmailFocus: fn(), + handleEmailInvalid: fn(), + handleMarketingToggle: fn(), + handleSubmit: fn(), + handleSubmitButtonClick: fn(), + handleReset: fn(), + handleCaptchaComplete: fn(), + handleCaptchaLoadError: fn(), +}; + +const signedOutFormState: NewsletterSignupFormState = { + userEmail: undefined, + isSignedIn: false, + isInteracted: false, + showMarketingToggle: false, + marketingOptIn: undefined, + marketingOptInHiddenForCountry: false, + isWaitingForResponse: false, + responseOk: undefined, + errorMessage: undefined, + isValidationError: false, + recaptchaRef: createRef(), + captchaSiteKey: undefined, + ...noopHandlers, +}; + +const useInteractiveSignedOutForm = (): NewsletterSignupFormState => { + const [userEmail, setUserEmail] = useState(''); + const [isInteracted, setIsInteracted] = useState(false); + const [marketingOptIn, setMarketingOptIn] = useState(true); + + return { + ...signedOutFormState, + userEmail, + isInteracted, + showMarketingToggle: isInteracted, + marketingOptIn, + handleEmailFocus: () => setIsInteracted(true), + handleEmailChange: (value) => { + setUserEmail(value); + setIsInteracted(true); + }, + handleMarketingToggle: () => setMarketingOptIn((current) => !current), + }; +}; + +export const SignedOut = meta.story({ + beforeEach() { + mocked(useNewsletterSignupForm).mockImplementation( + useInteractiveSignedOutForm, + ); + }, +}); + +export const SignedIn = meta.story({ + beforeEach() { + mocked(useIsSignedIn).mockReturnValue(true); + mocked(useNewsletterSignupForm).mockReturnValue({ + ...signedOutFormState, + userEmail: 'reader@example.com', + isSignedIn: true, + isInteracted: true, + }); + }, +}); + +export const USHideMarketingToggle = meta.story({ + beforeEach() { + mocked(useCountryCode).mockReturnValue('US'); + const previousSwitch = + window.guardian.config.switches['usSignupHideMarketingToggle']; + window.guardian.config.switches['usSignupHideMarketingToggle'] = true; + mocked(useNewsletterSignupForm).mockReturnValue({ + ...signedOutFormState, + userEmail: 'reader@example.com', + isInteracted: true, + marketingOptIn: true, + marketingOptInHiddenForCountry: true, + }); + + return () => { + mocked(useCountryCode).mockReset(); + window.guardian.config.switches['usSignupHideMarketingToggle'] = + previousSwitch; + }; + }, +}); + +export const SubmissionFailed = meta.story({ + beforeEach() { + mocked(useNewsletterSignupForm).mockReturnValue({ + ...signedOutFormState, + responseOk: false, + }); + }, +}); + +export const Success = meta.story({ + beforeEach() { + mocked(useNewsletterSignupForm).mockReturnValue({ + ...signedOutFormState, + responseOk: true, + }); + }, +}); + +const useSuccessfulForm = (): NewsletterSignupFormState => { + const form = useInteractiveSignedOutForm(); + const [responseOk, setResponseOk] = useState(); + + return { + ...form, + responseOk, + handleSubmit: (event) => { + event.preventDefault(); + setResponseOk(true); + }, + }; +}; + +export const SuccessTransition = meta.story({ + beforeEach() { + mocked(useNewsletterSignupForm).mockImplementation(useSuccessfulForm); + }, +}); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts new file mode 100644 index 00000000000..ad1312a93ed --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts @@ -0,0 +1,308 @@ +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; +import { + between, + from, + neutral, + palette, + space, + until, +} from '@guardian/source/foundations'; +import { contentTransitionEasing } from '../../shared/transitions'; + +const phabletContentMaxWidth = '492px'; +export const transitionDurationMs = 350; +const successTransition = `${transitionDurationMs}ms ${contentTransitionEasing}`; + +export const styles = { + layout: css` + display: grid; + position: relative; + margin: 0 auto; + max-width: 660px; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'copy-container'; + + ${from.phablet} { + max-width: 740px; + } + ${between.phablet.and.desktop} { + position: static; + } + ${from.desktop} { + padding: 0 ${space[3]}px; + max-width: 980px; + grid-template-columns: minmax(0, 1fr) 380px auto; + grid-template-areas: 'copy-container main-image close-button'; + gap: 20px; + } + ${from.leftCol} { + max-width: 1140px; + grid-template-columns: 140px 1px minmax(0, 460px) 380px auto; + gap: 10px; + grid-template-areas: 'logo vert-line copy-container main-image close-button'; + } + ${from.wide} { + max-width: 1300px; + grid-template-columns: 219px 1px minmax(0, 491px) 400px auto; + } + `, + dismissal: (isDismissing: boolean): SerializedStyles => css` + transform: translateY(${isDismissing ? '100%' : '0'}); + transition: transform ${successTransition}; + pointer-events: ${isDismissing ? 'none' : 'auto'}; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + `, + panel: (isVisible: boolean): SerializedStyles => css` + display: grid; + grid-template-rows: ${isVisible ? '1fr' : '0fr'}; + opacity: ${isVisible ? 1 : 0}; + transition: + grid-template-rows ${successTransition}, + opacity ${successTransition}; + + > div { + min-height: 0; + overflow: hidden; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + `, + successContent: css` + position: relative; + max-width: 1300px; + margin: 0 auto; + padding: ${space[6]}px 64px ${space[6]}px 0; + + ${from.leftCol} { + padding-top: 36px; + padding-left: 10px; + } + `, + container: (background: string, textColor: string): SerializedStyles => css` + background: ${background}; + color: ${textColor}; + position: relative; + max-height: 65vh; + max-height: 65svh; + overflow-y: auto; + + * { + box-sizing: border-box; + } + + ${from.phablet} { + border-top: 1px solid ${neutral[0]}; + } + `, + panels: css` + grid-column: copy-container-start / -1; + grid-row: 1; + min-width: 0; + + ${from.desktop} { + grid-column: copy-container-start / close-button-start; + } + `, + signupLayout: css` + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'copy-container'; + + ${from.desktop} { + grid-template-columns: minmax(0, 1fr) 380px; + grid-template-areas: 'copy-container main-image'; + gap: 20px; + } + + ${from.leftCol} { + gap: 10px; + } + + ${from.wide} { + grid-template-columns: minmax(0, 1fr) 400px; + } + `, + contentContainer: css` + grid-area: copy-container; + width: 100%; + max-width: 100%; + align-self: start; + + ${between.phablet.and.desktop} { + max-width: ${phabletContentMaxWidth}; + justify-self: center; + } + + ${from.leftCol} { + padding-left: 10px; + } + `, + content: css` + display: flex; + flex-direction: column; + margin: 28px 0; + ${until.desktop} { + margin: ${space[3]}px 0; + } + ${until.tablet} { + margin-top: 0; + } + `, + frequencyRow: css` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${space[2]}px; + + ${until.mobile} { + min-height: 40px; + padding-right: 52px; + } + `, + closeButton: css` + grid-area: close-button; + grid-row: 1; + align-self: start; + z-index: 1; + + ${until.desktop} { + position: absolute; + top: ${space[2]}px; + right: ${space[3]}px; + } + ${until.mobile} { + > div { + margin: 0; + padding: 0; + position: static; + } + } + `, + textImageRow: css` + display: grid; + grid-template-columns: minmax(0, 1fr) 90px; + column-gap: ${space[3]}px; + align-items: start; + margin-top: ${space[2]}px; + + ${from.desktop} { + display: block; + } + ${until.tablet} { + margin: ${space[2]}px 10px 0; + } + `, + textCopy: css` + min-width: 0; + `, + mobileImage: css` + width: 90px; + height: 90px; + justify-self: end; + align-self: start; + overflow: hidden; + border-radius: 50%; + + > div { + grid-area: auto; + width: 90px; + max-width: 90px; + height: 90px; + margin: 0; + padding: 0; + } + + > div > picture { + display: block; + width: 90px; + height: 90px; + } + + > div > picture img { + width: 90px; + height: 90px; + max-height: 90px; + padding: 0; + object-fit: cover; + } + + ${from.desktop} { + display: none; + } + `, + desktopImage: css` + grid-area: main-image; + + ${until.desktop} { + display: none; + } + + > div { + grid-area: auto; + } + `, + verticalLine: css` + grid-area: vert-line; + background-color: ${neutral[0]}; + opacity: 0.2; + pointer-events: none; + + ${until.leftCol} { + display: none; + } + + ${from.leftCol} { + margin-top: 36px; + margin-bottom: 0; + } + `, + horizontalLine: css` + width: 100%; + height: 1px; + background-color: ${neutral[0]}; + opacity: 0.2; + pointer-events: none; + + ${until.mobile} { + background-color: transparent; + + &::before { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 1px; + background-color: ${neutral[0]}; + } + } + `, + headingAfterDivider: css` + > div { + padding-top: 0; + } + + && h2 { + margin: 0; + } + `, + subheading: css` + color: ${palette.neutral[20]}; + + > div { + margin-bottom: ${space[2]}px; + ${until.desktop} { + margin-bottom: ${space[4]}px; + } + } + + p { + margin-bottom: 0; + } + `, +}; diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx new file mode 100644 index 00000000000..6f8b76d021c --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx @@ -0,0 +1,117 @@ +import { bannerSchema } from '@guardian/support-dotcom-components'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { design, props } from '../utils/storybook'; +import { NewsletterBanner } from './NewsletterBanner'; + +const sdcNewsletterProps = { + ...props, + content: { + heading: 'Sign up to The Stakes 2026', + paragraphs: [ + 'From the races and candidates to watch to the issues shaping American politics.', + ], + }, + submitComponentEvent: async () => Promise.resolve(), + isCollapsible: false, + design: { + ...design, + headerImage: { + mobileUrl: + 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', + tabletUrl: + 'https://i.guim.co.uk/img/media/20cc6e0fa146574bb9c4ed410ac1a089fab02ce0/0_0_1428_1344/500.png?width=500&quality=75&s=fe64f647f74a3cb671f8035a473b895f', + desktopUrl: + 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', + altText: 'The Stakes 2026', + }, + }, + promotedNewsletter: { + listId: 6042, + identityName: 'the-stakes-us-election-edition', + name: 'The Stakes 2026', + frequency: 'Weekly', + }, +}; + +jest.mock('../../../../lib/useNewsletterSubscription', () => ({ + useNewsletterSubscription: jest.fn(), +})); + +jest.mock('../../../../lib/useMatchMedia', () => ({ + ...jest.requireActual('../../../../lib/useMatchMedia'), + useMatchMedia: () => false, +})); + +jest.mock('./NewsletterSignupForm', () => ({ + NewsletterSignupForm: ({ onSuccess }: { onSuccess: () => void }) => ( + + ), +})); + +describe('NewsletterBanner configuration', () => { + it('passes SDC newsletter settings through to the banner wrapper', () => { + const banner = NewsletterBanner({ + ...sdcNewsletterProps, + idApiUrl: 'https://idapi.theguardian.com', + }); + + expect(banner.props).toMatchObject(sdcNewsletterProps); + expect(bannerSchema.safeParse(banner.props).success).toBe(true); + }); + + it('preserves runtime tracking and service props', () => { + const runtimeProps = { + tracking: props.tracking, + articleCounts: props.articleCounts, + countryCode: 'US', + fetchEmail: jest.fn(), + submitComponentEvent: jest.fn(), + contributionsServiceUrl: 'https://contributions.example.com', + idApiUrl: 'https://idapi.theguardian.com', + }; + const banner = NewsletterBanner({ + ...sdcNewsletterProps, + ...runtimeProps, + }); + + expect(banner.props).toMatchObject(runtimeProps); + }); + + it('keeps the same close button outside the transitioning panels after signup', () => { + render( + , + ); + const closeButton = screen.getByRole('button', { name: 'Close' }); + + fireEvent.click(screen.getByRole('button', { name: 'Sign up' })); + + expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength( + 1, + ); + expect(screen.getByRole('button', { name: 'Close' })).toBe(closeButton); + expect(closeButton.closest('[aria-hidden]')).toBeNull(); + expect(closeButton.closest('[inert]')).toBeNull(); + + fireEvent.click(closeButton); + + expect( + closeButton.closest('[role="alert"]')?.hasAttribute('inert'), + ).toBe(true); + }); + + it('uses the image from the SDC design headerImage', () => { + render( + , + ); + + expect( + screen.getAllByAltText('The Stakes 2026').length, + ).toBeGreaterThan(0); + }); +}); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx new file mode 100644 index 00000000000..9fce8a2d541 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -0,0 +1,178 @@ +import { hexColourToString } from '@guardian/support-dotcom-components'; +import type { BannerProps } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { ComponentType } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { validatedBannerWrapper } from '../common/BannerWrapper'; +import type { BannerRenderProps, PromotedNewsletter } from '../common/types'; +import type { BannerData } from '../designableBanner/BannerProps'; +import { BannerBody } from '../designableBanner/components/BannerBody'; +import { BannerCloseButton } from '../designableBanner/components/BannerCloseButton'; +import { BannerHeader } from '../designableBanner/components/BannerHeader'; +import { BannerVisual } from '../designableBanner/components/BannerVisual'; +import { useDesignableBannerModel } from '../designableBanner/useDesignableBannerModel'; +import { styles, transitionDurationMs } from './NewsletterBanner.styles'; +import { NewsletterBannerLogo } from './NewsletterBannerLogo'; +import { NewsletterFrequencyTag } from './NewsletterFrequencyTag'; +import { NewsletterSignupForm } from './NewsletterSignupForm'; +import { SuccessMessage } from './SuccessMessage'; +import { useSuccessDismissal } from './useSuccessDismissal'; + +export const NewsletterBannerComponent = ( + props: BannerRenderProps, +): JSX.Element | null => { + const newsletter = props.promotedNewsletter; + const bannerRef = useRef(null); + const [isSuccess, setIsSuccess] = useState(false); + const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ + ...props, + }); + const { isDismissing, dismiss, ...dismissalHandlers } = useSuccessDismissal( + { + isSuccess, + bannerRef, + onClose: baseBannerData?.actions.onClose, + durationMs: transitionDurationMs, + }, + ); + + useEffect(() => { + bannerRef.current?.focus(); + }, [isSuccess]); + + if (!newsletter || !isOpen || !baseBannerData || !props.design) { + return null; + } + + const { basic } = props.design.colours; + const bannerData: BannerData = { + ...baseBannerData, + actions: { + ...baseBannerData.actions, + onClose: isSuccess ? dismiss : baseBannerData.actions.onClose, + }, + selectors: { + ...baseBannerData.selectors, + showHeaderImage: false, + showBody: true, + showBodyVisual: true, + }, + settings: { + ...baseBannerData.settings, + imageSettings: + baseBannerData.settings.headerSettings?.headerImage ?? + baseBannerData.settings.imageSettings, + }, + }; + + return ( +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+ setIsSuccess(true)} + /> +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ ); +}; + +const ValidatedNewsletterBanner = validatedBannerWrapper( + NewsletterBannerComponent, + 'newsletter-banner', +) as ComponentType< + BannerProps & { + idApiUrl: string; + promotedNewsletter?: PromotedNewsletter; + } +>; + +export const NewsletterBanner = ({ + idApiUrl, + ...props +}: BannerProps & { + idApiUrl: string; + promotedNewsletter?: PromotedNewsletter; +}): JSX.Element => ; diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBannerLogo.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBannerLogo.tsx new file mode 100644 index 00000000000..e7ab50aa53a --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBannerLogo.tsx @@ -0,0 +1,25 @@ +import { css } from '@emotion/react'; +import { from, neutral, until } from '@guardian/source/foundations'; +import { SvgGuardianLogo } from '@guardian/source/react-components'; + +const styles = css` + grid-area: logo; + + ${until.leftCol} { + display: none; + } + + ${from.leftCol} { + justify-self: end; + width: 128px; + height: 41px; + justify-content: end; + margin-top: 28px; + } +`; + +export const NewsletterBannerLogo = () => ( +
+ +
+); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx new file mode 100644 index 00000000000..092b349a041 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx @@ -0,0 +1,62 @@ +import { css } from '@emotion/react'; +import { space, textSansBold15, until } from '@guardian/source/foundations'; +import { SvgNewsletterFilled } from '@guardian/source/react-components'; +import { palette as themePalette } from '../../../../palette'; + +const frequencyTagStyles = css` + display: flex; + align-items: center; + gap: 6px; + color: ${themePalette('--newsletter-card-frequency-tag')}; + ${textSansBold15}; + padding: ${space[2]}px 0; + ${until.desktop} { + padding: ${space[3]}px 0 ${space[2]}px; + } + ${until.tablet} { + padding: 18px 10px 14px 10px; + } +`; + +const frequencyTextStyles = css` + display: flex; + flex-wrap: wrap; + column-gap: ${space[1]}px; +`; + +const frequencyLabelStyles = css` + white-space: nowrap; +`; + +const badgeStyles = css` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: ${themePalette('--newsletter-card-badge-background')}; + + svg { + fill: ${themePalette('--newsletter-card-badge-icon')}; + height: 18px; + width: 18px; + } +`; + +export const NewsletterFrequencyTag = ({ + frequency, +}: { + frequency: string; +}) => ( +
+ + + + + Free newsletter | + {frequency} + +
+); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx new file mode 100644 index 00000000000..df14d3e441f --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx @@ -0,0 +1,97 @@ +import { + fireEvent, + render, + screen as testingScreen, +} from '@testing-library/react'; +import ReactGoogleRecaptcha from 'react-google-recaptcha'; +import { NewsletterSignupForm } from './NewsletterSignupForm'; + +const mockHandleSubmit = jest.fn((event: React.FormEvent) => + event.preventDefault(), +); +const mockHandleSubmitButtonClick = jest.fn(); +const mockHandleEmailFocus = jest.fn(); +let mockResponseOk: boolean | undefined; + +jest.mock('../../../ConfigContext', () => ({ + useConfig: () => ({ renderingTarget: 'Web' }), +})); + +jest.mock('../../../../lib/useHideMarketingToggleForCountry', () => ({ + useHideMarketingToggleForCountry: () => false, +})); + +jest.mock('../../../../lib/useNewsletterSignupForm', () => ({ + useNewsletterSignupForm: jest.fn(() => ({ + userEmail: 'test@example.com', + isSignedIn: false, + isInteracted: false, + showMarketingToggle: false, + isWaitingForResponse: false, + captchaSiteKey: 'test-site-key', + responseOk: mockResponseOk, + handleSubmit: mockHandleSubmit, + handleSubmitButtonClick: mockHandleSubmitButtonClick, + handleEmailFocus: mockHandleEmailFocus, + })), +})); + +jest.mock('react-google-recaptcha', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('../../../NewsletterPrivacyMessage', () => ({ + NewsletterPrivacyMessage: () => null, +})); + +describe('newsletter banner signup form', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockResponseOk = undefined; + }); + + it('renders CAPTCHA and delegates submission to the signup hook', () => { + const onSuccess = jest.fn(); + render( + , + ); + + expect(ReactGoogleRecaptcha).toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + + fireEvent.focus(testingScreen.getByRole('textbox')); + fireEvent.click(testingScreen.getByRole('button', { name: 'Sign up' })); + + expect(mockHandleSubmit).toHaveBeenCalledTimes(1); + expect(mockHandleSubmitButtonClick).toHaveBeenCalledTimes(1); + expect(mockHandleEmailFocus).toHaveBeenCalledTimes(1); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('calls onSuccess only after the signup hook reports success', () => { + const onSuccess = jest.fn(); + const renderForm = () => ( + + ); + const { rerender } = render(renderForm()); + + expect(onSuccess).not.toHaveBeenCalled(); + + mockResponseOk = false; + rerender(renderForm()); + expect(onSuccess).not.toHaveBeenCalled(); + + mockResponseOk = true; + rerender(renderForm()); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx new file mode 100644 index 00000000000..7a5448e8825 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -0,0 +1,355 @@ +import { css } from '@emotion/react'; +import type { AbTest } from '@guardian/ophan-tracker-js'; +import { from, space, until } from '@guardian/source/foundations'; +import { Button, TextInput } from '@guardian/source/react-components'; +import { ToggleSwitch } from '@guardian/source-development-kitchen/react-components'; +// Note - the package also exports a component as a named export "ReCAPTCHA", +// that version will compile and render but is non-functional. +// Use the default export instead. +import { type ChangeEvent, useEffect } 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'; +import { NewsletterPrivacyMessage } from '../../../NewsletterPrivacyMessage'; +import { contentTransitionEasing } from '../../shared/transitions'; +import { FailureMessage } from './ErrorMessage'; + +type NewsletterSignupFormProps = { + newsletterId: string; + componentId: string; + abTest?: AbTest; + isModal?: boolean; + onSuccess?: () => void; +}; + +const newsletterFormTransition = `0.2s ${contentTransitionEasing}`; + +const formStyles = css` + display: grid; + column-gap: ${space[3]}px; + row-gap: ${space[2]}px; + align-items: end; + + ${until.tablet} { + row-gap: ${space[3]}px; + } +`; + +const collapsedFormStyles = css` + row-gap: 0; +`; + +const bannerSignedInLayoutStyles = css` + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'submit'; + margin-top: ${space[2]}px; + row-gap: ${space[4]}px; + ${until.tablet} { + padding: 0 10px; + margin-top: 0; + } +`; + +const bannerSignedOutLayoutStyles = css` + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: 'email submit'; + + ${until.tablet} { + grid-template-columns: 1fr; + grid-template-areas: + 'email' + 'submit'; + padding: 0 10px; + } +`; + +const signedInButtonStyles = css` + max-width: 100%; + ${from.tablet} { + width: 240px; + } +`; + +const emailFieldStyles = css` + grid-area: email; + min-width: 0; + + label div { + color: ${palette('--newsletter-signup-input-text')}; + font-size: 14px; + } + + input { + color: ${palette('--newsletter-signup-input-text')}; + background-color: ${palette('--newsletter-signup-input-background')}; + } + + [role='alert'] { + min-height: 24px; + line-height: 24px; + margin-bottom: 6px; + + svg { + width: 24px; + height: 24px; + transform: none; + } + } +`; + +const additionalFieldsStyles = (isExpanded: boolean) => css` + display: grid; + grid-column: 1 / -1; + grid-template-rows: ${isExpanded ? '1fr' : '0fr'}; + margin-top: -${space[2]}px; + transition: ${newsletterFormTransition}; + transition-property: grid-template-rows; + + > div { + min-height: 0; + overflow: hidden; + } + + ${until.tablet} { + margin-top: -${space[3]}px; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +`; + +const additionalFieldsContentStyles = css` + padding-top: ${space[2]}px; + padding-bottom: 28px; + + ${until.tablet} { + padding-top: ${space[3]}px; + } +`; + +const toggleContainerStyles = css` + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${space[3]}px; + min-width: 0; +`; + +const marketingToggleBoxStyles = css` + width: 100%; + padding: ${space[2]}px; + border: 1px solid ${palette('--card-border-supporting')}; + border-radius: 4px; + label { + color: ${palette('--newsletter-signup-toggle-text')}; + } +`; + +const recaptchaContainerStyles = css` + .grecaptcha-badge { + visibility: hidden; + } +`; + +const getPrivacyContainerStyles = (isSignedIn: boolean | 'Pending') => css` + margin-top: ${space[2]}px; + ${until.tablet} { + margin-top: ${space[3]}px; + } + grid-column: 1 / -1; + ${isSignedIn === true && + css` + margin-top: 0; + `}; +`; + +const submitButtonContainerStyles = css` + grid-area: submit; + width: 100%; + display: flex; + gap: ${space[2]}px; + ${from.tablet} { + width: auto; + } +`; + +const submitButtonStyles = css` + width: 100%; + ${from.tablet} { + width: auto; + } +`; + +/** + * # Newsletter Signup Form + * + * Renders a newsletter signup form with email input, optional marketing opt-in + * toggle, privacy message, reCAPTCHA, and success/failure feedback states. + * All logic lives in {@link useNewsletterSignupForm}. + */ + +export const NewsletterSignupForm = ({ + newsletterId, + componentId, + abTest, + isModal = false, + onSuccess, +}: NewsletterSignupFormProps) => { + const { renderingTarget } = useConfig(); + const hideMarketingToggle = useHideMarketingToggleForCountry(); + + const { + userEmail, + isSignedIn, + isInteracted, + showMarketingToggle, + marketingOptIn, + isWaitingForResponse, + responseOk, + errorMessage, + isValidationError, + recaptchaRef, + captchaSiteKey, + handleCaptchaComplete, + handleCaptchaLoadError, + handleEmailChange, + handleEmailFocus, + handleEmailInvalid, + handleMarketingToggle, + handleSubmit, + handleSubmitButtonClick, + handleReset, + } = useNewsletterSignupForm( + newsletterId, + renderingTarget, + componentId, + abTest, + hideMarketingToggle, + ); + + const hasResponse = typeof responseOk === 'boolean'; + const hasNonValidationError = + errorMessage !== undefined && errorMessage !== '' && !isValidationError; + const isSuccess = hasResponse && !!responseOk; + const showForm = (!hasResponse || isSuccess) && !hasNonValidationError; + + useEffect(() => { + if (isSuccess) onSuccess?.(); + }, [isSuccess, onSuccess]); + + // Show the failure box for server errors (responseOk === false) and for + // reCAPTCHA / network errors that occur before a response is received. + const showFailure = (hasResponse && !responseOk) || hasNonValidationError; + const failureMessage = hasNonValidationError + ? errorMessage + : 'Sign up failed.'; + const showAdditionalFields = + isModal || isInteracted || (userEmail ?? '') !== ''; + // isValidationError comes from the hook — true only for inline field + // errors (empty / bad format), false for reCAPTCHA / network errors. + + return ( + <> +
+ {isSignedIn ? ( + + ) : ( +
+ ) => + handleEmailChange(e.target.value) + } + onInvalid={handleEmailInvalid} + /> +
+ )} +
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + {showFailure && ( + + )} + {captchaSiteKey !== undefined && captchaSiteKey !== '' && ( +
+ +
+ )} + + ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx new file mode 100644 index 00000000000..f1451f4784c --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx @@ -0,0 +1,71 @@ +import { css } from '@emotion/react'; +import { + palette, + space, + textSans15, + until, +} from '@guardian/source/foundations'; +import { LinkButton, SvgTickRound } from '@guardian/source/react-components'; + +const successContainerStyles = css` + display: grid; + grid-template-columns: 34.5px minmax(0, 1fr); + color: ${palette.success[400]}; + ${until.tablet} { + padding: 0 10px; + } + + > svg { + width: 34.5px; + height: 34.5px; + transform: translate(-5.75px, -5.75px); + fill: currentColor; + } +`; + +const successContentStyles = css` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${space[2]}px; +`; + +const successTextStyles = css` + ${textSans15}; + color: ${palette.neutral[20]}; + + strong { + font-weight: 700; + } +`; + +export const SuccessMessage = ({ + newsletterName, + frequency, +}: { + newsletterName: string; + frequency: string; +}) => { + return ( +
+ +
+ + You're signed up! +   + + You will receive {newsletterName}{' '} + {frequency.toLowerCase()}. + + + + Browse more newsletters + +
+
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.test.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.test.tsx new file mode 100644 index 00000000000..a692c6bfd96 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.test.tsx @@ -0,0 +1,140 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useRef } from 'react'; +import { useSuccessDismissal } from './useSuccessDismissal'; + +const onClose = jest.fn(); + +const TestBanner = ({ isSuccess = true }: { isSuccess?: boolean }) => { + const bannerRef = useRef(null); + const { isDismissing, dismiss, ...handlers } = useSuccessDismissal({ + isSuccess, + bannerRef, + onClose, + durationMs: 350, + }); + return ( +
+ {isDismissing ? 'Dismissing' : 'Visible'} + + +
+ ); +}; + +const advance = (duration: number) => { + act(() => jest.advanceTimersByTime(duration)); +}; + +describe('success banner dismissal', () => { + beforeEach(() => { + jest.useFakeTimers(); + onClose.mockClear(); + jest.spyOn(window, 'matchMedia').mockReturnValue({ + matches: false, + } as MediaQueryList); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('waits seven seconds, then closes after the exit animation', () => { + render(); + advance(6999); + expect(screen.getByText('Visible')).toBeInTheDocument(); + advance(1); + expect(screen.getByText('Dismissing')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + advance(350); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not automatically dismiss before signup succeeds', () => { + render(); + fireEvent.scroll(window); + advance(10000); + expect(screen.getByText('Visible')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('preserves the remaining time while hovered', () => { + render(); + advance(3000); + fireEvent.mouseEnter(screen.getByTestId('banner')); + advance(10000); + expect(screen.getByText('Visible')).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId('banner')); + advance(3999); + expect(screen.getByText('Visible')).toBeInTheDocument(); + advance(1); + expect(screen.getByText('Dismissing')).toBeInTheDocument(); + }); + + it('pauses while controls have focus, including moves within the banner', () => { + render(); + advance(2000); + act(() => screen.getByRole('button', { name: 'Close' }).focus()); + advance(10000); + act(() => screen.getByRole('button', { name: 'Browse' }).focus()); + advance(10000); + expect(screen.getByText('Visible')).toBeInTheDocument(); + act(() => screen.getByRole('button', { name: 'Browse' }).blur()); + advance(4999); + expect(screen.getByText('Visible')).toBeInTheDocument(); + advance(1); + expect(screen.getByText('Dismissing')).toBeInTheDocument(); + }); + + it('only resumes when both hover and focus have ended', () => { + render(); + fireEvent.mouseEnter(screen.getByTestId('banner')); + act(() => screen.getByRole('button', { name: 'Close' }).focus()); + fireEvent.mouseLeave(screen.getByTestId('banner')); + advance(10000); + expect(screen.getByText('Visible')).toBeInTheDocument(); + act(() => screen.getByRole('button', { name: 'Close' }).blur()); + advance(7000); + expect(screen.getByText('Dismissing')).toBeInTheDocument(); + }); + + it('ignores banner scrolling but dismisses for article scrolling', () => { + render(); + fireEvent.scroll(screen.getByTestId('banner')); + expect(screen.getByText('Visible')).toBeInTheDocument(); + fireEvent.scroll(document); + expect(screen.getByText('Dismissing')).toBeInTheDocument(); + advance(350); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('animates a manual close and only closes once', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + fireEvent.scroll(window); + expect(onClose).not.toHaveBeenCalled(); + advance(350); + expect(onClose).toHaveBeenCalledTimes(1); + advance(10000); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('skips the exit delay for reduced motion', () => { + jest.mocked(window.matchMedia).mockReturnValue({ + matches: true, + } as MediaQueryList); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + advance(0); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('cleans up timers and scroll listeners on unmount', () => { + const { unmount } = render(); + unmount(); + fireEvent.scroll(window); + advance(10000); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.ts b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.ts new file mode 100644 index 00000000000..b4a1c268967 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.ts @@ -0,0 +1,89 @@ +import type { + HTMLAttributes, + FocusEvent as ReactFocusEvent, + RefObject, +} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +type SuccessDismissal = Pick< + HTMLAttributes, + 'onMouseEnter' | 'onMouseLeave' | 'onFocusCapture' | 'onBlurCapture' +> & { + isDismissing: boolean; + dismiss: () => void; +}; + +export const useSuccessDismissal = ({ + isSuccess, + bannerRef, + onClose, + durationMs, +}: { + isSuccess: boolean; + bannerRef: RefObject; + onClose: (() => void) | undefined; + durationMs: number; +}): SuccessDismissal => { + const [isDismissing, setIsDismissing] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const remainingMs = useRef(7000); + const closeRef = useRef(onClose); + + useEffect(() => { + closeRef.current = onClose; + }, [onClose]); + + const dismiss = useCallback(() => setIsDismissing(true), []); + + useEffect(() => { + if (!isSuccess || isDismissing || isHovered || isFocused) return; + const startedAt = Date.now(); + const timeout = window.setTimeout(dismiss, remainingMs.current); + return () => { + window.clearTimeout(timeout); + remainingMs.current = Math.max( + 0, + remainingMs.current - (Date.now() - startedAt), + ); + }; + }, [dismiss, isSuccess, isDismissing, isHovered, isFocused]); + + useEffect(() => { + if (!isSuccess || isDismissing) return; + const handleScroll = (event: Event) => { + if ( + event.target instanceof Node && + bannerRef.current?.contains(event.target) === true + ) { + return; + } + dismiss(); + }; + window.addEventListener('scroll', handleScroll, true); + return () => window.removeEventListener('scroll', handleScroll, true); + }, [bannerRef, dismiss, isSuccess, isDismissing]); + + useEffect(() => { + if (!isDismissing) return; + const reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + const timeout = window.setTimeout( + () => closeRef.current?.(), + reducedMotion ? 0 : durationMs, + ); + return () => window.clearTimeout(timeout); + }, [durationMs, isDismissing]); + + return { + isDismissing, + dismiss, + onMouseEnter: () => setIsHovered(true), + onMouseLeave: () => setIsHovered(false), + onFocusCapture: (event: ReactFocusEvent) => + setIsFocused(event.target !== event.currentTarget), + onBlurCapture: (event: ReactFocusEvent) => + setIsFocused(event.currentTarget.contains(event.relatedTarget)), + }; +}; diff --git a/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx b/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx index b304137a2e5..37d4c8d845d 100644 --- a/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx +++ b/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx @@ -24,10 +24,11 @@ import { useEffect } from 'react'; import sanitise from 'sanitize-html'; import { useIsInView } from '../../../lib/useIsInView'; import type { ChoiceCardDesignSettings } from '../banners/designableBanner/settings'; +import { contentTransitionEasing } from './transitions'; export type ChoiceCard = _ChoiceCard & { defaultExpanded?: boolean }; -const choiceCardTransition = '0.2s cubic-bezier(0.27, 1.06, 0.18, 1)'; +const choiceCardTransition = `0.2s ${contentTransitionEasing}`; const styles = { radioGroup: css` diff --git a/dotcom-rendering/src/components/marketing/shared/transitions.ts b/dotcom-rendering/src/components/marketing/shared/transitions.ts new file mode 100644 index 00000000000..0705dece5a6 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/shared/transitions.ts @@ -0,0 +1 @@ +export const contentTransitionEasing = 'cubic-bezier(0.27, 1.06, 0.18, 1)';