From 29f95ec114dee90eaab50de3f5045a1652376fdf Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Thu, 15 Oct 2026 13:31:37 +0100 Subject: [PATCH 01/18] newsletter banner initiation --- .../ReaderRevenueBanner.tsx | 9 +- .../marketing/banners/common/types.tsx | 5 +- .../banners/designableBanner/BannerProps.ts | 15 ++ .../components/BannerCloseButton.tsx | 4 +- .../components/BannerLogo.tsx | 4 +- .../components/BannerVisual.tsx | 4 +- .../newsletterBanner/NewsletterBanner.tsx | 183 ++++++++++++++++++ 7 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index c813b8ba4b0..24c0cf09a9d 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -391,8 +391,13 @@ export const ReaderRevenueBanner = ({ (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); diff --git a/dotcom-rendering/src/components/marketing/banners/common/types.tsx b/dotcom-rendering/src/components/marketing/banners/common/types.tsx index 3a48bd8be1a..ab86231ffee 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/types.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/types.tsx @@ -17,7 +17,10 @@ import type { 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'; -export type BannerId = 'designable-banner' | 'sign-in-prompt-banner'; +export type BannerId = + | 'designable-banner' + | 'sign-in-prompt-banner' + | 'newsletter-banner'; export interface BannerEnrichedCta { ctaUrl: string; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts b/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts index 3c9ae40aaed..61d6a471544 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts @@ -61,3 +61,18 @@ export interface BannerData { // --- Selectors --- selectors: BannerSelectors; } + +export type BannerVisualData = Pick & { + settings: Pick; + selectors: Pick; +}; + +export type BannerLogoData = Pick; + +export type BannerCloseButtonData = Pick< + BannerData, + 'isCollapsible' | 'isCollapsed' +> & { + settings: Pick; + actions: Pick; +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx index 867a85db22e..1a4379969bb 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx @@ -6,7 +6,7 @@ import { SvgChevronUpSingle, SvgCross, } from '@guardian/source/react-components'; -import type { BannerData } from '../BannerProps'; +import type { BannerCloseButtonData } from '../BannerProps'; import { buttonStyles, buttonThemes } from '../styles/buttonStyles'; const styles = { @@ -98,7 +98,7 @@ const styles = { export const BannerCloseButton = ({ bannerData, }: { - bannerData: BannerData; + bannerData: BannerCloseButtonData; }): JSX.Element => { if (bannerData.isCollapsible) { return ( diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx index 1428dbec25c..64bb1e2134d 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/react'; import { from, space, until } from '@guardian/source/foundations'; import { SvgGuardianLogo } from '@guardian/source/react-components'; import { hexColourToString } from '@guardian/support-dotcom-components'; -import type { BannerData } from '../BannerProps'; +import type { BannerLogoData } from '../BannerProps'; const styles = { guardianLogoContainer: css` @@ -24,7 +24,7 @@ const styles = { export const BannerLogo = ({ bannerData, }: { - bannerData: BannerData; + bannerData: BannerLogoData; }): JSX.Element | null => { if (!bannerData.design) { return null; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx index 5954c648a69..f7cb4d846ec 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx @@ -3,10 +3,10 @@ import { between, from, space } from '@guardian/source/foundations'; import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ImageAttrs } from '../../../shared/ResponsiveImage'; import { ResponsiveImage } from '../../../shared/ResponsiveImage'; -import type { BannerData } from '../BannerProps'; +import type { BannerVisualData } from '../BannerProps'; interface BannerVisualProps { - bannerData: BannerData; + bannerData: BannerVisualData; settings?: SupportImage; isHeaderImage?: boolean; } 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..e83be60a43f --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -0,0 +1,183 @@ +import { css } from '@emotion/react'; +import { from, neutral, space, until } from '@guardian/source/foundations'; +import { hexColourToString } from '@guardian/support-dotcom-components'; +import type { Image } from '@guardian/support-dotcom-components/dist/shared/types'; +import { useEffect, useRef, useState } from 'react'; +import { NewsletterSignupForm } from '../../../NewsletterSignupForm.island'; +import { validatedBannerWrapper } from '../common/BannerWrapper'; +import type { BannerRenderProps } from '../common/types'; +import { BannerCloseButton } from '../designableBanner/components/BannerCloseButton'; +import { BannerLogo } from '../designableBanner/components/BannerLogo'; +import { BannerVisual } from '../designableBanner/components/BannerVisual'; + +const prototypeNewsletter = { + newsletterId: 'saturday-edition', + newsletterName: 'Saturday Edition', + frequency: 'every Saturday', +}; + +const prototypeNewsletterImage: Image = { + mainUrl: + 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', + 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', + wideUrl: + 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', + altText: '', +}; + +const styles = { + container: (background: string, textColor: string) => css` + background: ${background}; + color: ${textColor}; + max-height: 65vh; + max-height: 65svh; + overflow-y: auto; + + * { + box-sizing: border-box; + } + + ${from.phablet} { + border-top: 1px solid ${neutral[0]}; + } + `, + layout: css` + display: grid; + position: relative; + margin: 0 auto; + padding: ${space[3]}px; + max-width: 660px; + gap: ${space[3]}px; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'copy-container close-button' + 'main-image main-image'; + + ${from.phablet} { + max-width: 740px; + } + ${from.desktop} { + max-width: 980px; + grid-template-columns: minmax(0, 1fr) 380px auto; + grid-template-areas: 'copy-container main-image close-button'; + } + ${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, 460px) 400px auto; + } + `, + form: css` + grid-area: copy-container; + min-width: 0; + align-self: start; + + ${from.desktop} { + padding-right: ${space[5]}px; + } + ${from.leftCol} { + padding-left: ${space[3]}px; + } + `, + verticalLine: css` + grid-area: vert-line; + background-color: ${neutral[0]}; + opacity: 0.2; + pointer-events: none; + + ${until.leftCol} { + display: none; + } + `, +}; + +const NewsletterBannerComponent = ( + props: BannerRenderProps, +): JSX.Element | null => { + const bannerRef = useRef(null); + const [isOpen, setIsOpen] = useState(true); + + useEffect(() => { + bannerRef.current?.focus(); + }, []); + + if (!isOpen || !props.design) { + return null; + } + + const { basic, closeButton } = props.design.colours; + const bannerData = { + design: props.design, + isCollapsed: false, + isCollapsible: false, + settings: { + imageSettings: prototypeNewsletterImage, + closeButtonSettings: { + default: { + backgroundColour: hexColourToString( + closeButton.default.background, + ), + textColour: hexColourToString(closeButton.default.text), + border: `1px solid ${ + closeButton.default.border + ? hexColourToString(closeButton.default.border) + : '#DCDCDC' + }`, + }, + }, + }, + selectors: { showBodyVisual: true }, + actions: { + onClose: () => { + props.onCloseClick(); + setIsOpen(false); + document.body.focus(); + }, + onToggleCollapse: props.onCollapseClick, + }, + }; + + return ( +
+
+ +
+
+ +
+ + +
+
+ ); +}; + +export const NewsletterBanner = validatedBannerWrapper( + NewsletterBannerComponent, + 'newsletter-banner', +); From d3a0ba0d8197dcc0ca9d7949ea249e6e91fe4752 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Thu, 15 Oct 2026 14:35:42 +0100 Subject: [PATCH 02/18] temp --- .../ReaderRevenueBanner.tsx | 4 +- .../banners/designableBanner/BannerProps.ts | 15 ---- .../components/BannerCloseButton.tsx | 4 +- .../components/BannerLogo.tsx | 4 +- .../components/BannerVisual.tsx | 4 +- .../newsletterBanner/NewsletterBanner.tsx | 85 +++++++++---------- 6 files changed, 50 insertions(+), 66 deletions(-) diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index 24c0cf09a9d..e9275696da0 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -382,11 +382,11 @@ export const canShowRRBanner: CanShowFunctionType< }; export const ReaderRevenueBanner = ({ - name, + //name, props, }: ModuleData) => { const [Banner, setBanner] = useState(null); - + const name = 'NewsletterBanner'; useEffect(() => { (name === 'SignInPromptBanner' ? /* webpackChunkName: "sign-in-prompt-banner" */ diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts b/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts index 61d6a471544..3c9ae40aaed 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/BannerProps.ts @@ -61,18 +61,3 @@ export interface BannerData { // --- Selectors --- selectors: BannerSelectors; } - -export type BannerVisualData = Pick & { - settings: Pick; - selectors: Pick; -}; - -export type BannerLogoData = Pick; - -export type BannerCloseButtonData = Pick< - BannerData, - 'isCollapsible' | 'isCollapsed' -> & { - settings: Pick; - actions: Pick; -}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx index 1a4379969bb..867a85db22e 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerCloseButton.tsx @@ -6,7 +6,7 @@ import { SvgChevronUpSingle, SvgCross, } from '@guardian/source/react-components'; -import type { BannerCloseButtonData } from '../BannerProps'; +import type { BannerData } from '../BannerProps'; import { buttonStyles, buttonThemes } from '../styles/buttonStyles'; const styles = { @@ -98,7 +98,7 @@ const styles = { export const BannerCloseButton = ({ bannerData, }: { - bannerData: BannerCloseButtonData; + bannerData: BannerData; }): JSX.Element => { if (bannerData.isCollapsible) { return ( diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx index 64bb1e2134d..1428dbec25c 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerLogo.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/react'; import { from, space, until } from '@guardian/source/foundations'; import { SvgGuardianLogo } from '@guardian/source/react-components'; import { hexColourToString } from '@guardian/support-dotcom-components'; -import type { BannerLogoData } from '../BannerProps'; +import type { BannerData } from '../BannerProps'; const styles = { guardianLogoContainer: css` @@ -24,7 +24,7 @@ const styles = { export const BannerLogo = ({ bannerData, }: { - bannerData: BannerLogoData; + bannerData: BannerData; }): JSX.Element | null => { if (!bannerData.design) { return null; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx index f7cb4d846ec..5954c648a69 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/components/BannerVisual.tsx @@ -3,10 +3,10 @@ import { between, from, space } from '@guardian/source/foundations'; import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ImageAttrs } from '../../../shared/ResponsiveImage'; import { ResponsiveImage } from '../../../shared/ResponsiveImage'; -import type { BannerVisualData } from '../BannerProps'; +import type { BannerData } from '../BannerProps'; interface BannerVisualProps { - bannerData: BannerVisualData; + bannerData: BannerData; settings?: SupportImage; isHeaderImage?: boolean; } diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index e83be60a43f..cb9635ec45c 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -2,13 +2,18 @@ import { css } from '@emotion/react'; import { from, neutral, space, until } from '@guardian/source/foundations'; import { hexColourToString } from '@guardian/support-dotcom-components'; import type { Image } from '@guardian/support-dotcom-components/dist/shared/types'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { NewsletterSignupForm } from '../../../NewsletterSignupForm.island'; import { validatedBannerWrapper } from '../common/BannerWrapper'; import type { BannerRenderProps } from '../common/types'; +import type { BannerData } from '../designableBanner/BannerProps'; +import { BannerBody } from '../designableBanner/components/BannerBody'; import { BannerCloseButton } from '../designableBanner/components/BannerCloseButton'; +import { BannerContent } from '../designableBanner/components/BannerContent'; +import { BannerHeader } from '../designableBanner/components/BannerHeader'; import { BannerLogo } from '../designableBanner/components/BannerLogo'; import { BannerVisual } from '../designableBanner/components/BannerVisual'; +import { useDesignableBannerModel } from '../designableBanner/useDesignableBannerModel'; const prototypeNewsletter = { newsletterId: 'saturday-edition', @@ -77,18 +82,6 @@ const styles = { grid-template-columns: 219px 1px minmax(0, 460px) 400px auto; } `, - form: css` - grid-area: copy-container; - min-width: 0; - align-self: start; - - ${from.desktop} { - padding-right: ${space[5]}px; - } - ${from.leftCol} { - padding-left: ${space[3]}px; - } - `, verticalLine: css` grid-area: vert-line; background-color: ${neutral[0]}; @@ -105,45 +98,49 @@ const NewsletterBannerComponent = ( props: BannerRenderProps, ): JSX.Element | null => { const bannerRef = useRef(null); - const [isOpen, setIsOpen] = useState(true); + const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ + ...props, + isCollapsible: false, + }); useEffect(() => { bannerRef.current?.focus(); }, []); - if (!isOpen || !props.design) { + if (!isOpen || !baseBannerData || !props.design) { return null; } - const { basic, closeButton } = props.design.colours; - const bannerData = { - design: props.design, - isCollapsed: false, - isCollapsible: false, + const { basic } = props.design.colours; + const newsletterCopy = { + ...props.content.mainContent, + heading: Sign up to The Stakes 2026, + paragraphs: [ + + From the races and candidates to watch to the issues shaping + American politics, The Stakes 2026 brings you expert analysis of + what really matters in the US midterms. + , + ], + highlightedText: null, + }; + const bannerData: BannerData = { + ...baseBannerData, + content: { + mainContent: newsletterCopy, + mobileContent: newsletterCopy, + }, settings: { + ...baseBannerData.settings, imageSettings: prototypeNewsletterImage, - closeButtonSettings: { - default: { - backgroundColour: hexColourToString( - closeButton.default.background, - ), - textColour: hexColourToString(closeButton.default.text), - border: `1px solid ${ - closeButton.default.border - ? hexColourToString(closeButton.default.border) - : '#DCDCDC' - }`, - }, - }, }, - selectors: { showBodyVisual: true }, - actions: { - onClose: () => { - props.onCloseClick(); - setIsOpen(false); - document.body.focus(); - }, - onToggleCollapse: props.onCollapseClick, + selectors: { + ...baseBannerData.selectors, + headingCopy: newsletterCopy, + copyForViewport: newsletterCopy, + showHeaderImage: false, + showBody: true, + showBodyVisual: true, }, }; @@ -160,7 +157,9 @@ const NewsletterBannerComponent = (
-
+ + + -
+
From 008871e579b6ce24808d3ff33d58c7b65fa6a651 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Fri, 16 Oct 2026 11:44:04 +0100 Subject: [PATCH 03/18] use hadrcoded newsletter data --- .../components/StickyBottomBanner.island.tsx | 7 ++- .../ReaderRevenueBanner.tsx | 12 ++-- .../newsletterBannerDetails.ts | 32 ++++++++++ .../banners/common/BannerWrapper.tsx | 7 +++ .../marketing/banners/common/types.tsx | 2 + .../newsletterBanner/NewsletterBanner.tsx | 61 +++++++++++-------- 6 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx index 28433c5cfc5..3e420e2885b 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx @@ -35,6 +35,7 @@ import { BrazeBanner, canShowBrazeBanner, } from './StickyBottomBanner/BrazeBanner'; +import { DEFAULT_PROMOTED_NEWSLETTER } from './StickyBottomBanner/newsletterBannerDetails'; import { canShowRRBanner, ReaderRevenueBanner, @@ -187,7 +188,11 @@ const buildRRBannerConfigWith = ({ inNoShowMobileAboveNavVariant, }), show: ({ name, props }: ModuleData) => ( - + ), }, timeoutMillis: DEFAULT_BANNER_TIMEOUT_MILLIS, diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index e9275696da0..5d17467722e 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -32,8 +32,10 @@ import { lazyFetchEmailWithTimeout } from '../../lib/fetchEmail'; import { getZIndex } from '../../lib/getZIndex'; import type { CanShowResult } from '../../lib/messagePicker'; import { getBanner } from '../../lib/sdcRequests'; +import type { Newsletter } from '../../types/content'; import type { RenderingTarget } from '../../types/renderingTarget'; import type { TagType } from '../../types/tag'; +import { DEFAULT_PROMOTED_NEWSLETTER } from './newsletterBannerDetails'; type BaseProps = { isSignedIn: boolean; @@ -382,11 +384,13 @@ export const canShowRRBanner: CanShowFunctionType< }; export const ReaderRevenueBanner = ({ - //name, + name, props, -}: ModuleData) => { + promotedNewsletter = DEFAULT_PROMOTED_NEWSLETTER, +}: ModuleData & { + promotedNewsletter?: Newsletter; +}) => { const [Banner, setBanner] = useState(null); - const name = 'NewsletterBanner'; useEffect(() => { (name === 'SignInPromptBanner' ? /* webpackChunkName: "sign-in-prompt-banner" */ @@ -421,7 +425,7 @@ export const ReaderRevenueBanner = ({ `} > {} - +
); } diff --git a/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts b/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts new file mode 100644 index 00000000000..b70a6e12c7c --- /dev/null +++ b/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts @@ -0,0 +1,32 @@ +import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { Newsletter } from '../../types/content'; + +export const prototypeNewsletterImage = { + mainUrl: + 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', + 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', + wideUrl: + 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', + altText: '', +} satisfies SupportImage; + +export const DEFAULT_PROMOTED_NEWSLETTER: Newsletter = { + listId: 6042, + identityName: 'the-stakes-us-election-edition', + name: 'Newsletter name placeholder', + description: 'Newsletter description placeholder', + frequency: 'Newsletter frequency placeholder', + successDescription: 'Newsletter success description placeholder', + theme: 'news', + group: 'Newsletter group placeholder', + category: 'fronts-based', + regionFocus: 'Newsletter region focus placeholder', + illustrationCard: prototypeNewsletterImage.mobileUrl, + illustrationSquare: prototypeNewsletterImage.mainUrl, + exampleUrl: 'Newsletter example URL placeholder', +}; diff --git a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx index 8b05fac94d3..2a25894680e 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx @@ -20,6 +20,7 @@ import type { } from '@guardian/support-dotcom-components/dist/shared/types'; import { useEffect } from 'react'; import { useIsInView } from '../../../../lib/useIsInView'; +import type { Newsletter } from '../../../../types/content'; import type { ReactComponent } from '../../lib/ReactComponent'; import { replaceArticleCount } from '../../lib/replaceArticleCount'; import { @@ -128,6 +129,11 @@ const withBannerData = isCollapsible, contributionsServiceUrl, } = bannerProps; + const promotedNewsletter = ( + bannerProps as CloseableBannerProps & { + promotedNewsletter?: Newsletter; + } + ).promotedNewsletter; const [hasBeenSeen, setNode] = useIsInView({ debounce: true, @@ -410,6 +416,7 @@ const withBannerData = design, promoCodes, isCollapsible, + 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 ab86231ffee..07c438eb89b 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/types.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/types.tsx @@ -16,6 +16,7 @@ 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' @@ -84,4 +85,5 @@ export interface BannerRenderProps { design?: ConfigurableDesign; promoCodes?: string[]; isCollapsible?: boolean; + promotedNewsletter?: Newsletter; } diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index cb9635ec45c..ccfe546b480 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -1,9 +1,13 @@ import { css } from '@emotion/react'; import { from, neutral, space, until } from '@guardian/source/foundations'; import { hexColourToString } from '@guardian/support-dotcom-components'; -import type { Image } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { BannerProps } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { ComponentType } from 'react'; import { useEffect, useRef } from 'react'; +import type { Newsletter } from '../../../../types/content'; import { NewsletterSignupForm } from '../../../NewsletterSignupForm.island'; +import { DEFAULT_PROMOTED_NEWSLETTER } from '../../../StickyBottomBanner/newsletterBannerDetails'; import { validatedBannerWrapper } from '../common/BannerWrapper'; import type { BannerRenderProps } from '../common/types'; import type { BannerData } from '../designableBanner/BannerProps'; @@ -15,26 +19,6 @@ import { BannerLogo } from '../designableBanner/components/BannerLogo'; import { BannerVisual } from '../designableBanner/components/BannerVisual'; import { useDesignableBannerModel } from '../designableBanner/useDesignableBannerModel'; -const prototypeNewsletter = { - newsletterId: 'saturday-edition', - newsletterName: 'Saturday Edition', - frequency: 'every Saturday', -}; - -const prototypeNewsletterImage: Image = { - mainUrl: - 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', - 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', - wideUrl: - 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', - altText: '', -}; - const styles = { container: (background: string, textColor: string) => css` background: ${background}; @@ -94,9 +78,24 @@ const styles = { `, }; +const buildNewsletterBannerImage = (newsletter: Newsletter): SupportImage => { + const cardImage = newsletter.illustrationCard!; + const squareImage = newsletter.illustrationSquare; + + return { + mainUrl: cardImage, + mobileUrl: squareImage, + tabletUrl: cardImage, + desktopUrl: cardImage, + wideUrl: cardImage, + altText: newsletter.name, + }; +}; + const NewsletterBannerComponent = ( props: BannerRenderProps, ): JSX.Element | null => { + const newsletter = props.promotedNewsletter ?? DEFAULT_PROMOTED_NEWSLETTER; const bannerRef = useRef(null); const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ ...props, @@ -132,7 +131,7 @@ const NewsletterBannerComponent = ( }, settings: { ...baseBannerData.settings, - imageSettings: prototypeNewsletterImage, + imageSettings: buildNewsletterBannerImage(newsletter), }, selectors: { ...baseBannerData.selectors, @@ -161,7 +160,9 @@ const NewsletterBannerComponent = ( ; + +export const NewsletterBanner = ({ + promotedNewsletter = DEFAULT_PROMOTED_NEWSLETTER, + ...props +}: BannerProps & { + promotedNewsletter?: Newsletter; +}): JSX.Element => ( + ); From 4ad6c6160bda3de0edc1eb93cacd6167755d506f Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Fri, 16 Oct 2026 15:44:05 +0100 Subject: [PATCH 04/18] default signed-out --- .../components/StickyBottomBanner.island.tsx | 1 + .../ReaderRevenueBanner.tsx | 11 +- .../newsletterBannerDetails.ts | 2 +- .../banners/common/BannerWrapper.tsx | 7 +- .../marketing/banners/common/types.tsx | 1 + .../NewsletterBanner.stories.tsx | 77 +++++ .../newsletterBanner/NewsletterBanner.tsx | 267 ++++++++++++++++-- .../newsletterBanner/NewsletterBannerLogo.tsx | 25 ++ .../NewsletterFrequencyTag.tsx | 55 ++++ 9 files changed, 411 insertions(+), 35 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBannerLogo.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx index 3e420e2885b..d90be3044c8 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx @@ -191,6 +191,7 @@ const buildRRBannerConfigWith = ({ ), diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index 5d17467722e..7d0835e7961 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -384,12 +384,15 @@ export const canShowRRBanner: CanShowFunctionType< }; export const ReaderRevenueBanner = ({ - name, + //name, props, + idApiUrl, promotedNewsletter = DEFAULT_PROMOTED_NEWSLETTER, }: ModuleData & { + idApiUrl: string; promotedNewsletter?: Newsletter; }) => { + const name: string = 'NewsletterBanner'; const [Banner, setBanner] = useState(null); useEffect(() => { (name === 'SignInPromptBanner' @@ -425,7 +428,11 @@ export const ReaderRevenueBanner = ({ `} > {} - +
); } diff --git a/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts b/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts index b70a6e12c7c..bf57755adee 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts +++ b/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts @@ -20,7 +20,7 @@ export const DEFAULT_PROMOTED_NEWSLETTER: Newsletter = { identityName: 'the-stakes-us-election-edition', name: 'Newsletter name placeholder', description: 'Newsletter description placeholder', - frequency: 'Newsletter frequency placeholder', + frequency: 'Weekly', successDescription: 'Newsletter success description placeholder', theme: 'news', group: 'Newsletter group placeholder', diff --git a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx index 2a25894680e..d1d86a3837c 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx @@ -129,11 +129,11 @@ const withBannerData = isCollapsible, contributionsServiceUrl, } = bannerProps; - const promotedNewsletter = ( + const { promotedNewsletter, idApiUrl } = bannerProps as CloseableBannerProps & { promotedNewsletter?: Newsletter; - } - ).promotedNewsletter; + idApiUrl?: string; + }; const [hasBeenSeen, setNode] = useIsInView({ debounce: true, @@ -417,6 +417,7 @@ const withBannerData = promoCodes, isCollapsible, promotedNewsletter, + idApiUrl, }; return ( diff --git a/dotcom-rendering/src/components/marketing/banners/common/types.tsx b/dotcom-rendering/src/components/marketing/banners/common/types.tsx index 07c438eb89b..ea5d884f006 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/types.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/types.tsx @@ -86,4 +86,5 @@ export interface BannerRenderProps { promoCodes?: string[]; isCollapsible?: boolean; promotedNewsletter?: Newsletter; + idApiUrl?: string; } 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..a01b99577a4 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -0,0 +1,77 @@ +import { createRef } 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 { useNewsletterSignupForm } from '../../../../lib/useNewsletterSignupForm'; +import type { NewsletterSignupFormState } from '../../../../lib/useNewsletterSignupForm'; +import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; +import { + DEFAULT_PROMOTED_NEWSLETTER, + prototypeNewsletterImage, +} from '../../../StickyBottomBanner/newsletterBannerDetails'; +import { props } from '../utils/storybook'; +import { NewsletterBanner } from './NewsletterBanner'; + +const meta = preview.meta({ + title: 'Components/marketing/NewsletterBanner', + component: NewsletterBanner, + args: { + ...props, + submitComponentEvent: async () => Promise.resolve(), + idApiUrl: 'https://idapi.theguardian.com', + promotedNewsletter: { + ...DEFAULT_PROMOTED_NEWSLETTER, + illustrationSquare: prototypeNewsletterImage.mobileUrl, + }, + }, +}); + +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, +}; + +export const SignedOut = meta.story({ + beforeEach() { + mocked(useIsSignedIn).mockReturnValue(false); + mocked(useNewsletterSubscription).mockReturnValue(false); + mocked(useNewsletterSignupForm).mockReturnValue(signedOutFormState); + }, +}); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index ccfe546b480..5c5afc6dca1 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -1,10 +1,17 @@ import { css } from '@emotion/react'; -import { from, neutral, space, until } from '@guardian/source/foundations'; +import { + between, + from, + neutral, + space, + until, +} from '@guardian/source/foundations'; import { hexColourToString } from '@guardian/support-dotcom-components'; import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; import type { BannerProps } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ComponentType } from 'react'; import { useEffect, useRef } from 'react'; +import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; import type { Newsletter } from '../../../../types/content'; import { NewsletterSignupForm } from '../../../NewsletterSignupForm.island'; import { DEFAULT_PROMOTED_NEWSLETTER } from '../../../StickyBottomBanner/newsletterBannerDetails'; @@ -13,16 +20,19 @@ import type { BannerRenderProps } from '../common/types'; import type { BannerData } from '../designableBanner/BannerProps'; import { BannerBody } from '../designableBanner/components/BannerBody'; import { BannerCloseButton } from '../designableBanner/components/BannerCloseButton'; -import { BannerContent } from '../designableBanner/components/BannerContent'; import { BannerHeader } from '../designableBanner/components/BannerHeader'; -import { BannerLogo } from '../designableBanner/components/BannerLogo'; import { BannerVisual } from '../designableBanner/components/BannerVisual'; import { useDesignableBannerModel } from '../designableBanner/useDesignableBannerModel'; +import { NewsletterBannerLogo } from './NewsletterBannerLogo'; +import { NewsletterFrequencyTag } from './NewsletterFrequencyTag'; + +const phabletContentMaxWidth = '492px'; const styles = { container: (background: string, textColor: string) => css` background: ${background}; color: ${textColor}; + position: relative; max-height: 65vh; max-height: 65svh; overflow-y: auto; @@ -39,21 +49,25 @@ const styles = { display: grid; position: relative; margin: 0 auto; - padding: ${space[3]}px; + padding: 0 ${space[3]}px; max-width: 660px; - gap: ${space[3]}px; - grid-template-columns: minmax(0, 1fr) auto; - grid-template-areas: - 'copy-container close-button' - 'main-image main-image'; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'copy-container'; ${from.phablet} { max-width: 740px; + padding: ${space[1]}px ${space[3]}px; + } + ${between.phablet.and.desktop} { + position: static; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'copy-container'; } ${from.desktop} { 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; @@ -63,7 +77,121 @@ const styles = { } ${from.wide} { max-width: 1300px; - grid-template-columns: 219px 1px minmax(0, 460px) 400px auto; + grid-template-columns: 219px 1px minmax(0, 491px) 400px auto; + } + `, + 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.desktop} { + padding-right: ${space[5]}px; + } + + ${from.leftCol} { + padding-left: ${space[3]}px; + } + `, + content: css` + display: flex; + flex-direction: column; + gap: ${space[2]}px; + margin: 28px 0; + ${until.desktop} { + margin: ${space[3]}px 10px; + } + ${until.phablet} { + margin-inline: 0; + } + `, + frequencyRow: css` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${space[2]}px; + `, + inlineCloseButton: css` + display: none; + + ${until.desktop} { + display: block; + flex-shrink: 0; + + > div { + margin: 0; + padding: 0; + position: static; + } + } + `, + outerCloseButton: css` + ${until.desktop} { + display: none; + } + `, + textImageRow: css` + display: grid; + grid-template-columns: minmax(0, 1fr) 90px; + column-gap: ${space[3]}px; + align-items: start; + + ${from.desktop} { + display: block; + } + `, + textCopy: css` + min-width: 0; + `, + mobileImage: css` + width: 90px; + height: 90px; + justify-self: end; + align-self: start; + overflow: hidden; + + > 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` @@ -75,6 +203,49 @@ const styles = { ${until.leftCol} { display: none; } + + ${from.leftCol} { + margin-top: 28px; + margin-bottom: calc(-${space[3]}px - 28px); + } + `, + horizontalLine: css` + width: 100%; + height: 1px; + background-color: ${neutral[0]}; + opacity: 0.2; + pointer-events: none; + + ${until.tablet} { + 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` + > div { + margin-bottom: 0; + } + + p { + margin-bottom: 0; + } `, }; @@ -92,10 +263,14 @@ const buildNewsletterBannerImage = (newsletter: Newsletter): SupportImage => { }; }; -const NewsletterBannerComponent = ( +export const NewsletterBannerComponent = ( props: BannerRenderProps, ): JSX.Element | null => { const newsletter = props.promotedNewsletter ?? DEFAULT_PROMOTED_NEWSLETTER; + const isSubscribed = useNewsletterSubscription( + newsletter.listId, + props.idApiUrl, + ); const bannerRef = useRef(null); const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ ...props, @@ -131,6 +306,14 @@ const NewsletterBannerComponent = ( }, settings: { ...baseBannerData.settings, + headerSettings: { + ...baseBannerData.settings.headerSettings, + textColour: neutral[7], + }, + highlightedTextSettings: { + ...baseBannerData.settings.highlightedTextSettings, + textColour: neutral[7], + }, imageSettings: buildNewsletterBannerImage(newsletter), }, selectors: { @@ -154,24 +337,47 @@ const NewsletterBannerComponent = ( )} >
- +
- - - - - - - +
+
+
+ +
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
); @@ -180,13 +386,16 @@ const NewsletterBannerComponent = ( const ValidatedNewsletterBanner = validatedBannerWrapper( NewsletterBannerComponent, 'newsletter-banner', -) as ComponentType; +) as ComponentType< + BannerProps & { promotedNewsletter?: Newsletter; idApiUrl: string } +>; export const NewsletterBanner = ({ promotedNewsletter = DEFAULT_PROMOTED_NEWSLETTER, ...props }: BannerProps & { promotedNewsletter?: Newsletter; + idApiUrl: string; }): JSX.Element => ( ( +
+ +
+); 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..b27e019d926 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx @@ -0,0 +1,55 @@ +import { css } from '@emotion/react'; +import { space, textSansBold15 } 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}; +`; + +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} + +
+); From 86643b57a981601dbb06f84a4e881a6ce7913296 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Mon, 19 Oct 2026 11:55:42 +0100 Subject: [PATCH 05/18] newsletter signup form: add new component for newsletter banner --- .../NewsletterSignupForm.island.tsx | 17 ++ .../NewsletterBanner.stories.tsx | 26 +- .../newsletterBanner/NewsletterBanner.tsx | 4 +- .../newsletterBanner/NewsletterSignupForm.tsx | 248 ++++++++++++++++++ 4 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx index 7d92298a56f..aaa790af860 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx @@ -436,3 +436,20 @@ const NewsletterSignupFormActive = ({ ); }; + +export { + emailFieldStyles, + FailureMessage, + formStyles, + getPrivacyContainerStyles, + getToggleContainerStyles, + marketingToggleBoxStyles, + type Props as NewsletterSignupFormProps, + primaryButtonTheme, + recaptchaContainerStyles, + signedInLayoutStyles, + signedOutLayoutStyles, + submitButtonContainerStyles, + submitButtonStyles, + SuccessMessage, +}; diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx index a01b99577a4..55685c8c07c 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -1,4 +1,4 @@ -import { createRef } from 'react'; +import { createRef, useState } from 'react'; import type ReactGoogleRecaptcha from 'react-google-recaptcha'; import { fn, mocked } from 'storybook/test'; import preview from '../../../../../.storybook/preview'; @@ -68,10 +68,32 @@ const signedOutFormState: NewsletterSignupFormState = { ...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(useIsSignedIn).mockReturnValue(false); mocked(useNewsletterSubscription).mockReturnValue(false); - mocked(useNewsletterSignupForm).mockReturnValue(signedOutFormState); + mocked(useNewsletterSignupForm).mockImplementation( + useInteractiveSignedOutForm, + ); }, }); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 5c5afc6dca1..305f74673bf 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -13,7 +13,6 @@ import type { ComponentType } from 'react'; import { useEffect, useRef } from 'react'; import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; import type { Newsletter } from '../../../../types/content'; -import { NewsletterSignupForm } from '../../../NewsletterSignupForm.island'; import { DEFAULT_PROMOTED_NEWSLETTER } from '../../../StickyBottomBanner/newsletterBannerDetails'; import { validatedBannerWrapper } from '../common/BannerWrapper'; import type { BannerRenderProps } from '../common/types'; @@ -25,6 +24,7 @@ import { BannerVisual } from '../designableBanner/components/BannerVisual'; import { useDesignableBannerModel } from '../designableBanner/useDesignableBannerModel'; import { NewsletterBannerLogo } from './NewsletterBannerLogo'; import { NewsletterFrequencyTag } from './NewsletterFrequencyTag'; +import { NewsletterSignupForm } from './NewsletterSignupForm'; const phabletContentMaxWidth = '492px'; @@ -271,6 +271,7 @@ export const NewsletterBannerComponent = ( newsletter.listId, props.idApiUrl, ); + console.log(isSubscribed); const bannerRef = useRef(null); const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ ...props, @@ -368,7 +369,6 @@ export const NewsletterBannerComponent = ( newsletterName={newsletter.name} frequency={newsletter.frequency} componentId="newsletter_banner" - isAlreadySubscribed={isSubscribed} />
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..b0ed509900a --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -0,0 +1,248 @@ +import { css } from '@emotion/react'; +import { 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 } from 'react'; +import ReactGoogleRecaptcha from 'react-google-recaptcha'; +import { useHideMarketingToggleForCountry } from '../../../../lib/useHideMarketingToggleForCountry'; +import { useNewsletterSignupForm } from '../../../../lib/useNewsletterSignupForm'; +import { useConfig } from '../../../ConfigContext'; +import { NewsletterPrivacyMessage } from '../../../NewsletterPrivacyMessage'; +import type { NewsletterSignupFormProps } from '../../../NewsletterSignupForm.island'; +import { + emailFieldStyles as baseEmailFieldStyles, + FailureMessage, + formStyles, + getPrivacyContainerStyles as getBasePrivacyContainerStyles, + getToggleContainerStyles, + marketingToggleBoxStyles, + primaryButtonTheme, + recaptchaContainerStyles, + signedInLayoutStyles, + signedOutLayoutStyles, + submitButtonContainerStyles, + submitButtonStyles, + SuccessMessage, +} from '../../../NewsletterSignupForm.island'; + +const newsletterFormTransition = '0.2s cubic-bezier(0.27, 1.06, 0.18, 1)'; + +const emailFieldStyles = css` + ${baseEmailFieldStyles}; + + label div { + font-size: 14px; + } +`; + +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; + + ${until.tablet} { + padding-top: ${space[3]}px; + } +`; + +const toggleContainerStyles = getToggleContainerStyles(true); + +const getPrivacyContainerStyles = ( + isSignedIn: boolean | 'Pending', + isModal: boolean, +) => css` + ${getBasePrivacyContainerStyles(isSignedIn, isModal)}; + grid-column: 1 / -1; +`; + +/** + * # 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, + newsletterName, + frequency, + componentId, + abTest, + isModal = false, +}: Omit) => { + 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 && !isValidationError; + const showForm = !hasResponse && !hasNonValidationError; + const showSuccess = hasResponse && !!responseOk; + // 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} + /> +
+ )} +
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + {showSuccess && ( + + )} + {showFailure && ( + + )} + {!!captchaSiteKey && ( +
+ +
+ )} + + ); +}; From 7293720c0052276260cf7b9b8f76a34a5f1a44a9 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Mon, 19 Oct 2026 14:22:13 +0100 Subject: [PATCH 06/18] signout wide viewport handower --- .../NewsletterBanner.stories.tsx | 60 ++++++++++++++++++- .../newsletterBanner/NewsletterBanner.tsx | 16 ++--- .../NewsletterFrequencyTag.tsx | 1 + .../newsletterBanner/NewsletterSignupForm.tsx | 10 +++- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx index 55685c8c07c..7c63de25913 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -3,6 +3,7 @@ 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'; @@ -25,6 +26,11 @@ const meta = preview.meta({ illustrationSquare: prototypeNewsletterImage.mobileUrl, }, }, + beforeEach() { + mocked(useIsSignedIn).mockReturnValue(false); + mocked(useNewsletterSubscription).mockReturnValue(false); + mocked(useNewsletterSignupForm).mockReturnValue(signedOutFormState); + }, }); export default meta; @@ -90,10 +96,60 @@ const useInteractiveSignedOutForm = (): NewsletterSignupFormState => { export const SignedOut = meta.story({ beforeEach() { - mocked(useIsSignedIn).mockReturnValue(false); - mocked(useNewsletterSubscription).mockReturnValue(false); 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, + }); + }, +}); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 305f74673bf..5e6e2d08b49 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -68,6 +68,7 @@ const styles = { grid-template-columns: minmax(0, 1fr) 380px auto; grid-template-areas: 'copy-container main-image close-button'; gap: 20px; + padding: 0 ${space[3]}px; } ${from.leftCol} { max-width: 1140px; @@ -91,18 +92,13 @@ const styles = { justify-self: center; } - ${from.desktop} { - padding-right: ${space[5]}px; - } - ${from.leftCol} { - padding-left: ${space[3]}px; + padding-left: 10px; } `, content: css` display: flex; flex-direction: column; - gap: ${space[2]}px; margin: 28px 0; ${until.desktop} { margin: ${space[3]}px 10px; @@ -205,8 +201,8 @@ const styles = { } ${from.leftCol} { - margin-top: 28px; - margin-bottom: calc(-${space[3]}px - 28px); + margin-top: 36px; + margin-bottom: 0; } `, horizontalLine: css` @@ -235,12 +231,12 @@ const styles = { } && h2 { - margin: 0; + margin: ${space[2]}px 0 0; } `, subheading: css` > div { - margin-bottom: 0; + margin-bottom: ${space[2]}px; } p { diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx index b27e019d926..5ac3f5063e0 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx @@ -9,6 +9,7 @@ const frequencyTagStyles = css` gap: 6px; color: ${themePalette('--newsletter-card-frequency-tag')}; ${textSansBold15}; + padding: ${space[2]}px 0; `; const frequencyTextStyles = css` diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx index b0ed509900a..e709d8baef7 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -30,6 +30,10 @@ import { const newsletterFormTransition = '0.2s cubic-bezier(0.27, 1.06, 0.18, 1)'; +const collapsedFormStyles = css` + row-gap: 0; +`; + const emailFieldStyles = css` ${baseEmailFieldStyles}; @@ -42,9 +46,10 @@ const additionalFieldsStyles = (isExpanded: boolean) => css` display: grid; grid-column: 1 / -1; grid-template-rows: ${isExpanded ? '1fr' : '0fr'}; - margin-top: -${space[2]}px; + margin-top: -${isExpanded ? space[2] : 0}px; transition: ${newsletterFormTransition}; transition-property: grid-template-rows; + padding-bottom: ${isExpanded ? 28 : 0}px; > div { min-height: 0; @@ -52,7 +57,7 @@ const additionalFieldsStyles = (isExpanded: boolean) => css` } ${until.tablet} { - margin-top: -${space[3]}px; + margin-top: -${isExpanded ? space[3] : 0}px; } @media (prefers-reduced-motion: reduce) { @@ -151,6 +156,7 @@ export const NewsletterSignupForm = ({ css={[ formStyles, isSignedIn ? signedInLayoutStyles : signedOutLayoutStyles, + !showAdditionalFields && collapsedFormStyles, ]} > {isSignedIn ? ( From a4f65ed43190c540081f84321371966113af4a58 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Tue, 20 Oct 2026 10:05:04 +0100 Subject: [PATCH 07/18] Error message block --- .../banners/newsletterBanner/ErrorMessage.tsx | 96 +++++++++++++++++++ .../newsletterBanner/NewsletterSignupForm.tsx | 20 ++-- 2 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx 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..ba0ebf6b171 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx @@ -0,0 +1,96 @@ +import { css } from '@emotion/react'; +import { palette, space, textSans17 } from '@guardian/source/foundations'; +import { + Button, + Link, + SvgAlertRound, + SvgReload, +} from '@guardian/source/react-components'; + +const responseBoxStyles = css` + width: 100%; + padding: 7px 12px 16px 7.5px; + border: 1px solid ${palette.neutral[86]}; + border-radius: 4px; + margin-top: ${space[2]}px; +`; + +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/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx index e709d8baef7..b5c043d6163 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -14,7 +14,6 @@ import { NewsletterPrivacyMessage } from '../../../NewsletterPrivacyMessage'; import type { NewsletterSignupFormProps } from '../../../NewsletterSignupForm.island'; import { emailFieldStyles as baseEmailFieldStyles, - FailureMessage, formStyles, getPrivacyContainerStyles as getBasePrivacyContainerStyles, getToggleContainerStyles, @@ -25,8 +24,8 @@ import { signedOutLayoutStyles, submitButtonContainerStyles, submitButtonStyles, - SuccessMessage, } from '../../../NewsletterSignupForm.island'; +import { FailureMessage } from './ErrorMessage'; const newsletterFormTransition = '0.2s cubic-bezier(0.27, 1.06, 0.18, 1)'; @@ -46,10 +45,9 @@ const additionalFieldsStyles = (isExpanded: boolean) => css` display: grid; grid-column: 1 / -1; grid-template-rows: ${isExpanded ? '1fr' : '0fr'}; - margin-top: -${isExpanded ? space[2] : 0}px; + margin-top: -${space[2]}px; transition: ${newsletterFormTransition}; transition-property: grid-template-rows; - padding-bottom: ${isExpanded ? 28 : 0}px; > div { min-height: 0; @@ -57,7 +55,7 @@ const additionalFieldsStyles = (isExpanded: boolean) => css` } ${until.tablet} { - margin-top: -${isExpanded ? space[3] : 0}px; + margin-top: -${space[3]}px; } @media (prefers-reduced-motion: reduce) { @@ -67,6 +65,7 @@ const additionalFieldsStyles = (isExpanded: boolean) => css` const additionalFieldsContentStyles = css` padding-top: ${space[2]}px; + padding-bottom: 28px; ${until.tablet} { padding-top: ${space[3]}px; @@ -93,8 +92,6 @@ const getPrivacyContainerStyles = ( export const NewsletterSignupForm = ({ newsletterId, - newsletterName, - frequency, componentId, abTest, isModal = false, @@ -134,7 +131,8 @@ export const NewsletterSignupForm = ({ const hasResponse = typeof responseOk === 'boolean'; const hasNonValidationError = !!errorMessage && !isValidationError; const showForm = !hasResponse && !hasNonValidationError; - const showSuccess = hasResponse && !!responseOk; + const isSuccess = hasResponse && !!responseOk; + console.log('isSuccess:', isSuccess); // 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; @@ -229,12 +227,6 @@ export const NewsletterSignupForm = ({ - {showSuccess && ( - - )} {showFailure && ( )} From 0d30f868b757caa9a19f696bf7349a571d1aab99 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Tue, 20 Oct 2026 14:24:16 +0100 Subject: [PATCH 08/18] error message styling fix --- .../newsletterBanner/NewsletterSignupForm.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx index b5c043d6163..085ae215950 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -39,6 +39,18 @@ const emailFieldStyles = css` label div { font-size: 14px; } + + [role='alert'] { + min-height: 24px; + line-height: 24px; + margin-bottom: 6px; + + svg { + width: 24px; + height: 24px; + transform: none; + } + } `; const additionalFieldsStyles = (isExpanded: boolean) => css` @@ -236,7 +248,7 @@ export const NewsletterSignupForm = ({ sitekey={captchaSiteKey} ref={recaptchaRef} onChange={handleCaptchaComplete} - onError={handleCaptchaLoadError} + onErrored={handleCaptchaLoadError} size="invisible" /> From 939ca72d54d356861484f8ea3179c971a432d652 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Tue, 20 Oct 2026 15:21:22 +0100 Subject: [PATCH 09/18] success transition --- .../NewsletterBanner.stories.tsx | 30 +++ .../newsletterBanner/NewsletterBanner.tsx | 180 ++++++++++++++---- .../newsletterBanner/NewsletterSignupForm.tsx | 18 +- .../newsletterBanner/SuccessMessage.tsx | 63 ++++++ .../marketing/shared/ThreeTierChoiceCards.tsx | 3 +- .../marketing/shared/transitions.ts | 1 + 6 files changed, 256 insertions(+), 39 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx create mode 100644 dotcom-rendering/src/components/marketing/shared/transitions.ts diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx index 7c63de25913..81a3e280c33 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -17,6 +17,16 @@ import { NewsletterBanner } from './NewsletterBanner'; const meta = preview.meta({ title: 'Components/marketing/NewsletterBanner', component: NewsletterBanner, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], args: { ...props, submitComponentEvent: async () => Promise.resolve(), @@ -153,3 +163,23 @@ export const Success = meta.story({ }); }, }); + +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.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 5e6e2d08b49..57f79be25c5 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -10,10 +10,11 @@ import { hexColourToString } from '@guardian/support-dotcom-components'; import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; import type { BannerProps } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ComponentType } from 'react'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; import type { Newsletter } from '../../../../types/content'; import { DEFAULT_PROMOTED_NEWSLETTER } from '../../../StickyBottomBanner/newsletterBannerDetails'; +import { contentTransitionEasing } from '../../shared/transitions'; import { validatedBannerWrapper } from '../common/BannerWrapper'; import type { BannerRenderProps } from '../common/types'; import type { BannerData } from '../designableBanner/BannerProps'; @@ -25,10 +26,51 @@ import { useDesignableBannerModel } from '../designableBanner/useDesignableBanne import { NewsletterBannerLogo } from './NewsletterBannerLogo'; import { NewsletterFrequencyTag } from './NewsletterFrequencyTag'; import { NewsletterSignupForm } from './NewsletterSignupForm'; +import { SuccessMessage } from './SuccessMessage'; const phabletContentMaxWidth = '492px'; +const successTransition = `350ms ${contentTransitionEasing}`; const styles = { + panel: (isVisible: boolean) => 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; + } + `, + successCloseButton: css` + position: absolute; + top: ${space[9]}px; + right: ${space[3]}px; + + > div { + position: static; + margin: 0; + padding: 0; + } + `, container: (background: string, textColor: string) => css` background: ${background}; color: ${textColor}; @@ -81,6 +123,29 @@ const styles = { grid-template-columns: 219px 1px minmax(0, 491px) 400px auto; } `, + panels: css` + grid-column: copy-container-start / -1; + min-width: 0; + `, + signupLayout: css` + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'copy-container'; + + ${from.desktop} { + grid-template-columns: minmax(0, 1fr) 380px auto; + grid-template-areas: 'copy-container main-image close-button'; + gap: 20px; + } + + ${from.leftCol} { + gap: 10px; + } + + ${from.wide} { + grid-template-columns: minmax(0, 1fr) 400px auto; + } + `, contentContainer: css` grid-area: copy-container; width: 100%; @@ -269,6 +334,7 @@ export const NewsletterBannerComponent = ( ); console.log(isSubscribed); const bannerRef = useRef(null); + const [isSuccess, setIsSuccess] = useState(false); const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ ...props, isCollapsible: false, @@ -276,7 +342,7 @@ export const NewsletterBannerComponent = ( useEffect(() => { bannerRef.current?.focus(); - }, []); + }, [isSuccess]); if (!isOpen || !baseBannerData || !props.design) { return null; @@ -336,44 +402,92 @@ export const NewsletterBannerComponent = (
-
-
-
- -
- -
-
-
-
-
-
- +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+ setIsSuccess(true)} + /> +
-
- +
+ +
+
+
-
- +
+
+
+
+
+ +
+ +
-
-
- -
-
- -
); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx index 085ae215950..ace51bb299f 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -5,7 +5,7 @@ import { ToggleSwitch } from '@guardian/source-development-kitchen/react-compone // 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 } from 'react'; +import { type ChangeEvent, useEffect } from 'react'; import ReactGoogleRecaptcha from 'react-google-recaptcha'; import { useHideMarketingToggleForCountry } from '../../../../lib/useHideMarketingToggleForCountry'; import { useNewsletterSignupForm } from '../../../../lib/useNewsletterSignupForm'; @@ -25,9 +25,10 @@ import { submitButtonContainerStyles, submitButtonStyles, } from '../../../NewsletterSignupForm.island'; +import { contentTransitionEasing } from '../../shared/transitions'; import { FailureMessage } from './ErrorMessage'; -const newsletterFormTransition = '0.2s cubic-bezier(0.27, 1.06, 0.18, 1)'; +const newsletterFormTransition = `0.2s ${contentTransitionEasing}`; const collapsedFormStyles = css` row-gap: 0; @@ -107,7 +108,10 @@ export const NewsletterSignupForm = ({ componentId, abTest, isModal = false, -}: Omit) => { + onSuccess, +}: Omit & { + onSuccess?: () => void; +}) => { const { renderingTarget } = useConfig(); const hideMarketingToggle = useHideMarketingToggleForCountry(); @@ -142,9 +146,13 @@ export const NewsletterSignupForm = ({ const hasResponse = typeof responseOk === 'boolean'; const hasNonValidationError = !!errorMessage && !isValidationError; - const showForm = !hasResponse && !hasNonValidationError; const isSuccess = hasResponse && !!responseOk; - console.log('isSuccess:', isSuccess); + 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; 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..2dded92b57a --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx @@ -0,0 +1,63 @@ +import { css } from '@emotion/react'; +import { palette, space, textSans15 } 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]}; + + > 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/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)'; From b1ca3f0eca49939f9cdb2a60d17d0ac7c6336bc4 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Wed, 21 Oct 2026 12:01:55 +0100 Subject: [PATCH 10/18] dismiss banner after successful signup --- .../newsletterBanner/NewsletterBanner.tsx | 38 ++++- .../useSuccessDismissal.test.tsx | 140 ++++++++++++++++++ .../newsletterBanner/useSuccessDismissal.ts | 89 +++++++++++ 3 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.test.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/useSuccessDismissal.ts diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 57f79be25c5..71946362c67 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -27,11 +27,22 @@ import { NewsletterBannerLogo } from './NewsletterBannerLogo'; import { NewsletterFrequencyTag } from './NewsletterFrequencyTag'; import { NewsletterSignupForm } from './NewsletterSignupForm'; import { SuccessMessage } from './SuccessMessage'; +import { useSuccessDismissal } from './useSuccessDismissal'; const phabletContentMaxWidth = '492px'; -const successTransition = `350ms ${contentTransitionEasing}`; +const transitionDurationMs = 350; +const successTransition = `${transitionDurationMs}ms ${contentTransitionEasing}`; const styles = { + dismissal: (isDismissing: boolean) => css` + transform: translateY(${isDismissing ? '100%' : '0'}); + transition: transform ${successTransition}; + pointer-events: ${isDismissing ? 'none' : 'auto'}; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + `, panel: (isVisible: boolean) => css` display: grid; grid-template-rows: ${isVisible ? '1fr' : '0fr'}; @@ -339,6 +350,14 @@ export const NewsletterBannerComponent = ( ...props, isCollapsible: false, }); + const { isDismissing, dismiss, ...dismissalHandlers } = useSuccessDismissal( + { + isSuccess, + bannerRef, + onClose: baseBannerData?.actions.onClose, + durationMs: transitionDurationMs, + }, + ); useEffect(() => { bannerRef.current?.focus(); @@ -363,6 +382,10 @@ export const NewsletterBannerComponent = ( }; const bannerData: BannerData = { ...baseBannerData, + actions: { + ...baseBannerData.actions, + onClose: isSuccess ? dismiss : baseBannerData.actions.onClose, + }, content: { mainContent: newsletterCopy, mobileContent: newsletterCopy, @@ -394,10 +417,15 @@ export const NewsletterBannerComponent = ( ref={bannerRef} role="alert" tabIndex={-1} - css={styles.container( - hexColourToString(basic.background), - hexColourToString(basic.bodyText), - )} + {...dismissalHandlers} + {...(isDismissing ? { inert: '' } : {})} + css={[ + styles.container( + hexColourToString(basic.background), + hexColourToString(basic.bodyText), + ), + styles.dismissal(isDismissing), + ]} >
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)), + }; +}; From 429dd59cc9d69a85dfb7c269781da82892e13db1 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Wed, 21 Oct 2026 13:48:26 +0100 Subject: [PATCH 11/18] fix signup button styles for signed in uses --- .../newsletterBanner/NewsletterSignupForm.tsx | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx index ace51bb299f..672e890e8d5 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { space, until } from '@guardian/source/foundations'; +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", @@ -15,7 +15,6 @@ import type { NewsletterSignupFormProps } from '../../../NewsletterSignupForm.is import { emailFieldStyles as baseEmailFieldStyles, formStyles, - getPrivacyContainerStyles as getBasePrivacyContainerStyles, getToggleContainerStyles, marketingToggleBoxStyles, primaryButtonTheme, @@ -34,6 +33,19 @@ const collapsedFormStyles = css` row-gap: 0; `; +const bannerSignedInLayoutStyles = css` + ${signedInLayoutStyles}; + margin-top: ${space[2]}px; + row-gap: ${space[4]}px; +`; + +const signedInButtonStyles = css` + max-width: 100%; + ${from.tablet} { + width: 240px; + } +`; + const emailFieldStyles = css` ${baseEmailFieldStyles}; @@ -87,12 +99,16 @@ const additionalFieldsContentStyles = css` const toggleContainerStyles = getToggleContainerStyles(true); -const getPrivacyContainerStyles = ( - isSignedIn: boolean | 'Pending', - isModal: boolean, -) => css` - ${getBasePrivacyContainerStyles(isSignedIn, isModal)}; +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; + `}; `; /** @@ -159,7 +175,8 @@ export const NewsletterSignupForm = ({ const failureMessage = hasNonValidationError ? errorMessage : 'Sign up failed.'; - const showAdditionalFields = isModal || isInteracted || !!userEmail; + const showAdditionalFields = + isModal || isInteracted || (userEmail ?? '') !== ''; // isValidationError comes from the hook — true only for inline field // errors (empty / bad format), false for reCAPTCHA / network errors. @@ -173,7 +190,9 @@ export const NewsletterSignupForm = ({ }} css={[ formStyles, - isSignedIn ? signedInLayoutStyles : signedOutLayoutStyles, + isSignedIn + ? bannerSignedInLayoutStyles + : signedOutLayoutStyles, !showAdditionalFields && collapsedFormStyles, ]} > @@ -197,7 +216,11 @@ export const NewsletterSignupForm = ({ )}
-
+
From b30a28c43c6a3b54376ccda51be9ef0d2b8d5ab1 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Thu, 22 Oct 2026 09:39:10 +0100 Subject: [PATCH 12/18] temporarily stub success handler for Q&A --- .../newsletterBanner/NewsletterBanner.tsx | 6 +- .../NewsletterSignupForm.test.tsx | 68 +++++++++++++++++++ .../newsletterBanner/NewsletterSignupForm.tsx | 47 ++++++++----- 3 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 71946362c67..512bb5e0676 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -339,11 +339,7 @@ export const NewsletterBannerComponent = ( props: BannerRenderProps, ): JSX.Element | null => { const newsletter = props.promotedNewsletter ?? DEFAULT_PROMOTED_NEWSLETTER; - const isSubscribed = useNewsletterSubscription( - newsletter.listId, - props.idApiUrl, - ); - console.log(isSubscribed); + useNewsletterSubscription(newsletter.listId, props.idApiUrl); const bannerRef = useRef(null); const [isSuccess, setIsSuccess] = useState(false); const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ 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..64dad10df2a --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx @@ -0,0 +1,68 @@ +import { + fireEvent, + render, + screen as testingScreen, +} from '@testing-library/react'; +import ReactGoogleRecaptcha from 'react-google-recaptcha'; +import { NewsletterSignupForm } from './NewsletterSignupForm'; + +const mockHandleSubmit = jest.fn(); +const mockHandleSubmitButtonClick = jest.fn(); +const mockHandleEmailFocus = jest.fn(); + +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', + 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 manual signup stub', () => { + it('skips CAPTCHA and real submission, and calls onSuccess', () => { + const onSuccess = jest.fn(); + render( + , + ); + + expect(ReactGoogleRecaptcha).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + + fireEvent.focus(testingScreen.getByRole('textbox')); + fireEvent.click(testingScreen.getByRole('button', { name: 'Sign up' })); + + expect(mockHandleSubmit).not.toHaveBeenCalled(); + expect(mockHandleSubmitButtonClick).not.toHaveBeenCalled(); + expect(mockHandleEmailFocus).toHaveBeenCalledTimes(1); + 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 index 672e890e8d5..ec628731bba 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -5,7 +5,7 @@ import { ToggleSwitch } from '@guardian/source-development-kitchen/react-compone // 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 { type ChangeEvent, useEffect, useState } from 'react'; import ReactGoogleRecaptcha from 'react-google-recaptcha'; import { useHideMarketingToggleForCountry } from '../../../../lib/useHideMarketingToggleForCountry'; import { useNewsletterSignupForm } from '../../../../lib/useNewsletterSignupForm'; @@ -130,6 +130,9 @@ export const NewsletterSignupForm = ({ }) => { const { renderingTarget } = useConfig(); const hideMarketingToggle = useHideMarketingToggleForCountry(); + // Temporary local stub for manual testing; disable before shipping. + const [stubSignup] = useState(true); + const [stubSuccess, setStubSuccess] = useState(false); const { userEmail, @@ -161,8 +164,9 @@ export const NewsletterSignupForm = ({ ); const hasResponse = typeof responseOk === 'boolean'; - const hasNonValidationError = !!errorMessage && !isValidationError; - const isSuccess = hasResponse && !!responseOk; + const hasNonValidationError = + errorMessage !== undefined && errorMessage !== '' && !isValidationError; + const isSuccess = stubSuccess || (hasResponse && !!responseOk); const showForm = (!hasResponse || isSuccess) && !hasNonValidationError; useEffect(() => { @@ -183,7 +187,14 @@ export const NewsletterSignupForm = ({ return ( <>
{ + event.preventDefault(); + setStubSuccess(true); + } + : handleSubmit + } id={`newsletter-signup-${newsletterId}`} style={{ display: !showForm ? 'none' : undefined, @@ -221,7 +232,9 @@ export const NewsletterSignupForm = ({ ? [submitButtonStyles, signedInButtonStyles] : submitButtonStyles } - onClick={handleSubmitButtonClick} + onClick={ + stubSignup ? undefined : handleSubmitButtonClick + } type="submit" theme={primaryButtonTheme} isLoading={isWaitingForResponse} @@ -268,17 +281,19 @@ export const NewsletterSignupForm = ({ {showFailure && ( )} - {!!captchaSiteKey && ( -
- -
- )} + {!stubSignup && + captchaSiteKey !== undefined && + captchaSiteKey !== '' && ( +
+ +
+ )} ); }; From c5ef9dd298eb0319476097d89a40a50d3554d786 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Thu, 22 Oct 2026 16:01:18 +0100 Subject: [PATCH 13/18] styles changes --- .../NewsletterBanner.styles.ts | 316 ++++++++++++++++++ .../newsletterBanner/NewsletterBanner.tsx | 303 +---------------- .../NewsletterFrequencyTag.tsx | 8 +- .../newsletterBanner/NewsletterSignupForm.tsx | 13 +- 4 files changed, 337 insertions(+), 303 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts 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..d9951936607 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts @@ -0,0 +1,316 @@ +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; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'copy-container'; + } + ${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; + } + `, + successCloseButton: css` + position: absolute; + top: ${space[9]}px; + right: ${space[3]}px; + + > div { + position: static; + margin: 0; + padding: 0; + } + `, + 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; + min-width: 0; + `, + signupLayout: css` + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'copy-container'; + + ${from.desktop} { + grid-template-columns: minmax(0, 1fr) 380px auto; + grid-template-areas: 'copy-container main-image close-button'; + gap: 20px; + } + + ${from.leftCol} { + gap: 10px; + } + + ${from.wide} { + grid-template-columns: minmax(0, 1fr) 400px auto; + } + `, + 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; + `, + inlineCloseButton: css` + display: none; + + ${until.mobile} { + display: block; + flex-shrink: 0; + + > div { + margin: 0; + padding: 0; + position: static; + } + } + `, + outerCloseButton: css` + ${until.desktop} { + position: absolute; + top: ${space[2]}px; + right: ${space[3]}px; + } + ${until.mobile} { + display: none; + } + `, + 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.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 512bb5e0676..851f44bc110 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -1,11 +1,4 @@ -import { css } from '@emotion/react'; -import { - between, - from, - neutral, - space, - until, -} from '@guardian/source/foundations'; +import { neutral } from '@guardian/source/foundations'; import { hexColourToString } from '@guardian/support-dotcom-components'; import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; import type { BannerProps } from '@guardian/support-dotcom-components/dist/shared/types'; @@ -14,7 +7,6 @@ import { useEffect, useRef, useState } from 'react'; import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; import type { Newsletter } from '../../../../types/content'; import { DEFAULT_PROMOTED_NEWSLETTER } from '../../../StickyBottomBanner/newsletterBannerDetails'; -import { contentTransitionEasing } from '../../shared/transitions'; import { validatedBannerWrapper } from '../common/BannerWrapper'; import type { BannerRenderProps } from '../common/types'; import type { BannerData } from '../designableBanner/BannerProps'; @@ -23,304 +15,13 @@ import { BannerCloseButton } from '../designableBanner/components/BannerCloseBut 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'; -const phabletContentMaxWidth = '492px'; -const transitionDurationMs = 350; -const successTransition = `${transitionDurationMs}ms ${contentTransitionEasing}`; - -const styles = { - dismissal: (isDismissing: boolean) => css` - transform: translateY(${isDismissing ? '100%' : '0'}); - transition: transform ${successTransition}; - pointer-events: ${isDismissing ? 'none' : 'auto'}; - - @media (prefers-reduced-motion: reduce) { - transition: none; - } - `, - panel: (isVisible: boolean) => 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; - } - `, - successCloseButton: css` - position: absolute; - top: ${space[9]}px; - right: ${space[3]}px; - - > div { - position: static; - margin: 0; - padding: 0; - } - `, - container: (background: string, textColor: string) => 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]}; - } - `, - layout: css` - display: grid; - position: relative; - margin: 0 auto; - padding: 0 ${space[3]}px; - max-width: 660px; - grid-template-columns: minmax(0, 1fr); - grid-template-areas: 'copy-container'; - - ${from.phablet} { - max-width: 740px; - padding: ${space[1]}px ${space[3]}px; - } - ${between.phablet.and.desktop} { - position: static; - grid-template-columns: minmax(0, 1fr); - grid-template-areas: 'copy-container'; - } - ${from.desktop} { - max-width: 980px; - grid-template-columns: minmax(0, 1fr) 380px auto; - grid-template-areas: 'copy-container main-image close-button'; - gap: 20px; - padding: 0 ${space[3]}px; - } - ${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; - } - `, - panels: css` - grid-column: copy-container-start / -1; - min-width: 0; - `, - signupLayout: css` - display: grid; - grid-template-columns: minmax(0, 1fr); - grid-template-areas: 'copy-container'; - - ${from.desktop} { - grid-template-columns: minmax(0, 1fr) 380px auto; - grid-template-areas: 'copy-container main-image close-button'; - gap: 20px; - } - - ${from.leftCol} { - gap: 10px; - } - - ${from.wide} { - grid-template-columns: minmax(0, 1fr) 400px auto; - } - `, - 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 10px; - } - ${until.phablet} { - margin-inline: 0; - } - `, - frequencyRow: css` - display: flex; - align-items: center; - justify-content: space-between; - gap: ${space[2]}px; - `, - inlineCloseButton: css` - display: none; - - ${until.desktop} { - display: block; - flex-shrink: 0; - - > div { - margin: 0; - padding: 0; - position: static; - } - } - `, - outerCloseButton: css` - ${until.desktop} { - display: none; - } - `, - textImageRow: css` - display: grid; - grid-template-columns: minmax(0, 1fr) 90px; - column-gap: ${space[3]}px; - align-items: start; - - ${from.desktop} { - display: block; - } - `, - textCopy: css` - min-width: 0; - `, - mobileImage: css` - width: 90px; - height: 90px; - justify-self: end; - align-self: start; - overflow: hidden; - - > 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.tablet} { - 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: ${space[2]}px 0 0; - } - `, - subheading: css` - > div { - margin-bottom: ${space[2]}px; - } - - p { - margin-bottom: 0; - } - `, -}; - const buildNewsletterBannerImage = (newsletter: Newsletter): SupportImage => { const cardImage = newsletter.illustrationCard!; const squareImage = newsletter.illustrationSquare; diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx index 5ac3f5063e0..092b349a041 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterFrequencyTag.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { space, textSansBold15 } from '@guardian/source/foundations'; +import { space, textSansBold15, until } from '@guardian/source/foundations'; import { SvgNewsletterFilled } from '@guardian/source/react-components'; import { palette as themePalette } from '../../../../palette'; @@ -10,6 +10,12 @@ const frequencyTagStyles = css` 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` diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx index ec628731bba..941f78ff3ec 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -37,6 +37,17 @@ const bannerSignedInLayoutStyles = css` ${signedInLayoutStyles}; margin-top: ${space[2]}px; row-gap: ${space[4]}px; + ${until.tablet} { + padding: 0 10px; + margin-top: 0; + } +`; + +const bannerSignedOutLayoutStyles = css` + ${signedOutLayoutStyles}; + ${until.tablet} { + padding: 0 10px; + } `; const signedInButtonStyles = css` @@ -203,7 +214,7 @@ export const NewsletterSignupForm = ({ formStyles, isSignedIn ? bannerSignedInLayoutStyles - : signedOutLayoutStyles, + : bannerSignedOutLayoutStyles, !showAdditionalFields && collapsedFormStyles, ]} > From 83dadbb4c2ec0a6e263be7a05d7a28182796cc4c Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Fri, 23 Oct 2026 14:51:13 +0100 Subject: [PATCH 14/18] refactoring --- .../components/StickyBottomBanner.island.tsx | 2 - .../ReaderRevenueBanner.tsx | 10 +- .../newsletterBannerDetails.ts | 110 ++++++++++++++---- .../banners/common/BannerWrapper.tsx | 10 +- .../marketing/banners/common/types.tsx | 2 - .../NewsletterBanner.stories.tsx | 8 -- .../NewsletterBanner.styles.ts | 2 - .../NewsletterBanner.test.tsx | 47 ++++++++ .../newsletterBanner/NewsletterBanner.tsx | 72 ++++-------- 9 files changed, 157 insertions(+), 106 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx diff --git a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx index d90be3044c8..9a573c2634f 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.island.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.island.tsx @@ -35,7 +35,6 @@ import { BrazeBanner, canShowBrazeBanner, } from './StickyBottomBanner/BrazeBanner'; -import { DEFAULT_PROMOTED_NEWSLETTER } from './StickyBottomBanner/newsletterBannerDetails'; import { canShowRRBanner, ReaderRevenueBanner, @@ -192,7 +191,6 @@ const buildRRBannerConfigWith = ({ name={name} props={props} idApiUrl={idApiUrl} - promotedNewsletter={DEFAULT_PROMOTED_NEWSLETTER} /> ), }, diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index 7d0835e7961..c8d5f0c2b7b 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -32,10 +32,8 @@ import { lazyFetchEmailWithTimeout } from '../../lib/fetchEmail'; import { getZIndex } from '../../lib/getZIndex'; import type { CanShowResult } from '../../lib/messagePicker'; import { getBanner } from '../../lib/sdcRequests'; -import type { Newsletter } from '../../types/content'; import type { RenderingTarget } from '../../types/renderingTarget'; import type { TagType } from '../../types/tag'; -import { DEFAULT_PROMOTED_NEWSLETTER } from './newsletterBannerDetails'; type BaseProps = { isSignedIn: boolean; @@ -387,10 +385,8 @@ export const ReaderRevenueBanner = ({ //name, props, idApiUrl, - promotedNewsletter = DEFAULT_PROMOTED_NEWSLETTER, }: ModuleData & { idApiUrl: string; - promotedNewsletter?: Newsletter; }) => { const name: string = 'NewsletterBanner'; const [Banner, setBanner] = useState(null); @@ -428,11 +424,7 @@ export const ReaderRevenueBanner = ({ `} > {} - +
); } diff --git a/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts b/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts index bf57755adee..a807fb435af 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts +++ b/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts @@ -1,32 +1,92 @@ -import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { + BannerProps, + Image as SupportImage, +} from '@guardian/support-dotcom-components/dist/shared/types'; import type { Newsletter } from '../../types/content'; -export const prototypeNewsletterImage = { - mainUrl: - 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', - 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', - wideUrl: - 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', - altText: '', -} satisfies SupportImage; +export type PromotedNewsletter = Pick< + Newsletter, + 'listId' | 'identityName' | 'name' | 'frequency' +>; -export const DEFAULT_PROMOTED_NEWSLETTER: Newsletter = { +export const DEFAULT_PROMOTED_NEWSLETTER: PromotedNewsletter = { listId: 6042, identityName: 'the-stakes-us-election-edition', - name: 'Newsletter name placeholder', - description: 'Newsletter description placeholder', + name: 'The Stakes 2026', frequency: 'Weekly', - successDescription: 'Newsletter success description placeholder', - theme: 'news', - group: 'Newsletter group placeholder', - category: 'fronts-based', - regionFocus: 'Newsletter region focus placeholder', - illustrationCard: prototypeNewsletterImage.mobileUrl, - illustrationSquare: prototypeNewsletterImage.mainUrl, - exampleUrl: 'Newsletter example URL placeholder', +}; + +export const NEWSLETTER_BANNER_CONFIG = { + bannerChannel: 'contributions', + content: { + heading: 'Sign up to The Stakes 2026', + paragraphs: [ + 'From the races and candidates to watch to the issues shaping American politics, The Stakes 2026 brings you expert analysis of what really matters in the US midterms.', + ], + }, + isCollapsible: false, + imageSettings: { + mainUrl: + 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', + 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', + wideUrl: + 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', + altText: '', + }, + design: { + colours: { + basic: { + background: { r: 'F3', g: 'F7', b: 'FF', kind: 'hex' }, + bodyText: { r: '33', g: '33', b: '33', kind: 'hex' }, + headerText: { r: '12', g: '12', b: '12', kind: 'hex' }, + articleCountText: { r: '00', g: '00', b: '00', kind: 'hex' }, + logo: { r: '00', g: '00', b: '00', kind: 'hex' }, + }, + highlightedText: { + text: { r: '33', g: '33', b: '33', kind: 'hex' }, + highlight: { r: 'F3', g: 'F7', b: 'FF', kind: 'hex' }, + }, + primaryCta: { + default: { + text: { r: '05', g: '29', b: '62', kind: 'hex' }, + background: { r: 'FF', g: 'E5', b: '00', kind: 'hex' }, + }, + }, + secondaryCta: { + default: { + text: { r: '00', g: '00', b: '00', kind: 'hex' }, + background: { r: 'F1', g: 'F8', b: 'FC', kind: 'hex' }, + border: { r: '00', g: '00', b: '00', kind: 'hex' }, + }, + }, + closeButton: { + default: { + text: { r: '00', g: '00', b: '00', kind: 'hex' }, + background: { r: 'E6', g: 'EC', b: 'EF', kind: 'hex' }, + border: { r: 'E6', g: 'EC', b: 'EF', kind: 'hex' }, + }, + }, + ticker: { + filledProgress: { r: '33', g: '33', b: '33', kind: 'hex' }, + progressBarBackground: { + r: 'F3', + g: 'F7', + b: 'FF', + kind: 'hex', + }, + headlineColour: { r: '33', g: '33', b: '33', kind: 'hex' }, + totalColour: { r: '33', g: '33', b: '33', kind: 'hex' }, + goalColour: { r: '33', g: '33', b: '33', kind: 'hex' }, + }, + }, + }, + promotedNewsletter: DEFAULT_PROMOTED_NEWSLETTER, +} satisfies Omit & { + imageSettings: SupportImage; + promotedNewsletter: PromotedNewsletter; }; diff --git a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx index d1d86a3837c..30709048dbd 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx @@ -20,7 +20,6 @@ import type { } from '@guardian/support-dotcom-components/dist/shared/types'; import { useEffect } from 'react'; import { useIsInView } from '../../../../lib/useIsInView'; -import type { Newsletter } from '../../../../types/content'; import type { ReactComponent } from '../../lib/ReactComponent'; import { replaceArticleCount } from '../../lib/replaceArticleCount'; import { @@ -129,11 +128,9 @@ const withBannerData = isCollapsible, contributionsServiceUrl, } = bannerProps; - const { promotedNewsletter, idApiUrl } = - bannerProps as CloseableBannerProps & { - promotedNewsletter?: Newsletter; - idApiUrl?: string; - }; + const { idApiUrl } = bannerProps as CloseableBannerProps & { + idApiUrl?: string; + }; const [hasBeenSeen, setNode] = useIsInView({ debounce: true, @@ -416,7 +413,6 @@ const withBannerData = design, promoCodes, isCollapsible, - promotedNewsletter, idApiUrl, }; diff --git a/dotcom-rendering/src/components/marketing/banners/common/types.tsx b/dotcom-rendering/src/components/marketing/banners/common/types.tsx index ea5d884f006..db2180ebd82 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/types.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/types.tsx @@ -16,7 +16,6 @@ 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' @@ -85,6 +84,5 @@ export interface BannerRenderProps { design?: ConfigurableDesign; promoCodes?: string[]; isCollapsible?: boolean; - promotedNewsletter?: Newsletter; idApiUrl?: string; } diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx index 81a3e280c33..c9d62e90e97 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -7,10 +7,6 @@ import { useCountryCode } from '../../../../lib/useCountryCode'; import { useNewsletterSignupForm } from '../../../../lib/useNewsletterSignupForm'; import type { NewsletterSignupFormState } from '../../../../lib/useNewsletterSignupForm'; import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; -import { - DEFAULT_PROMOTED_NEWSLETTER, - prototypeNewsletterImage, -} from '../../../StickyBottomBanner/newsletterBannerDetails'; import { props } from '../utils/storybook'; import { NewsletterBanner } from './NewsletterBanner'; @@ -31,10 +27,6 @@ const meta = preview.meta({ ...props, submitComponentEvent: async () => Promise.resolve(), idApiUrl: 'https://idapi.theguardian.com', - promotedNewsletter: { - ...DEFAULT_PROMOTED_NEWSLETTER, - illustrationSquare: prototypeNewsletterImage.mobileUrl, - }, }, beforeEach() { mocked(useIsSignedIn).mockReturnValue(false); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts index d9951936607..ceb091fb2ba 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts @@ -28,8 +28,6 @@ export const styles = { } ${between.phablet.and.desktop} { position: static; - grid-template-columns: minmax(0, 1fr); - grid-template-areas: 'copy-container'; } ${from.desktop} { padding: 0 ${space[3]}px; 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..03622f1c7bb --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx @@ -0,0 +1,47 @@ +import { neutral } from '@guardian/source/foundations'; +import { + bannerSchema, + hexColourToString, +} from '@guardian/support-dotcom-components'; +import { NEWSLETTER_BANNER_CONFIG } from '../../../StickyBottomBanner/newsletterBannerDetails'; +import { props } from '../utils/storybook'; +import { NewsletterBanner } from './NewsletterBanner'; + +describe('NewsletterBanner configuration', () => { + it('uses newsletter colours', () => { + const { colours } = NEWSLETTER_BANNER_CONFIG.design; + + expect(hexColourToString(colours.basic.background)).toBe('#F3F7FF'); + expect(hexColourToString(colours.basic.headerText)).toBe(neutral[7]); + expect(hexColourToString(colours.basic.bodyText)).toBe(neutral[20]); + }); + + it('uses local newsletter settings instead of SDC presentation settings', () => { + const banner = NewsletterBanner({ + ...props, + bannerChannel: 'subscriptions', + content: { heading: 'SDC heading' }, + design: undefined, + isCollapsible: true, + idApiUrl: 'https://idapi.theguardian.com', + }); + + expect(banner.props).toMatchObject(NEWSLETTER_BANNER_CONFIG); + 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({ ...props, ...runtimeProps }); + + expect(banner.props).toMatchObject(runtimeProps); + }); +}); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 851f44bc110..d699cde0954 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -1,12 +1,9 @@ -import { neutral } from '@guardian/source/foundations'; import { hexColourToString } from '@guardian/support-dotcom-components'; -import type { Image as SupportImage } from '@guardian/support-dotcom-components/dist/shared/types'; import type { BannerProps } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ComponentType } from 'react'; import { useEffect, useRef, useState } from 'react'; import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; -import type { Newsletter } from '../../../../types/content'; -import { DEFAULT_PROMOTED_NEWSLETTER } from '../../../StickyBottomBanner/newsletterBannerDetails'; +import { NEWSLETTER_BANNER_CONFIG } from '../../../StickyBottomBanner/newsletterBannerDetails'; import { validatedBannerWrapper } from '../common/BannerWrapper'; import type { BannerRenderProps } from '../common/types'; import type { BannerData } from '../designableBanner/BannerProps'; @@ -22,24 +19,10 @@ import { NewsletterSignupForm } from './NewsletterSignupForm'; import { SuccessMessage } from './SuccessMessage'; import { useSuccessDismissal } from './useSuccessDismissal'; -const buildNewsletterBannerImage = (newsletter: Newsletter): SupportImage => { - const cardImage = newsletter.illustrationCard!; - const squareImage = newsletter.illustrationSquare; - - return { - mainUrl: cardImage, - mobileUrl: squareImage, - tabletUrl: cardImage, - desktopUrl: cardImage, - wideUrl: cardImage, - altText: newsletter.name, - }; -}; - export const NewsletterBannerComponent = ( props: BannerRenderProps, ): JSX.Element | null => { - const newsletter = props.promotedNewsletter ?? DEFAULT_PROMOTED_NEWSLETTER; + const newsletter = NEWSLETTER_BANNER_CONFIG.promotedNewsletter; useNewsletterSubscription(newsletter.listId, props.idApiUrl); const bannerRef = useRef(null); const [isSuccess, setIsSuccess] = useState(false); @@ -65,18 +48,7 @@ export const NewsletterBannerComponent = ( } const { basic } = props.design.colours; - const newsletterCopy = { - ...props.content.mainContent, - heading: Sign up to The Stakes 2026, - paragraphs: [ - - From the races and candidates to watch to the issues shaping - American politics, The Stakes 2026 brings you expert analysis of - what really matters in the US midterms. - , - ], - highlightedText: null, - }; + const newsletterCopy = props.content.mainContent; const bannerData: BannerData = { ...baseBannerData, actions: { @@ -89,15 +61,7 @@ export const NewsletterBannerComponent = ( }, settings: { ...baseBannerData.settings, - headerSettings: { - ...baseBannerData.settings.headerSettings, - textColour: neutral[7], - }, - highlightedTextSettings: { - ...baseBannerData.settings.highlightedTextSettings, - textColour: neutral[7], - }, - imageSettings: buildNewsletterBannerImage(newsletter), + imageSettings: NEWSLETTER_BANNER_CONFIG.imageSettings, }, selectors: { ...baseBannerData.selectors, @@ -221,19 +185,25 @@ export const NewsletterBannerComponent = ( const ValidatedNewsletterBanner = validatedBannerWrapper( NewsletterBannerComponent, 'newsletter-banner', -) as ComponentType< - BannerProps & { promotedNewsletter?: Newsletter; idApiUrl: string } ->; +) as ComponentType; export const NewsletterBanner = ({ - promotedNewsletter = DEFAULT_PROMOTED_NEWSLETTER, - ...props -}: BannerProps & { - promotedNewsletter?: Newsletter; - idApiUrl: string; -}): JSX.Element => ( + tracking, + articleCounts, + countryCode, + fetchEmail, + submitComponentEvent, + contributionsServiceUrl, + idApiUrl, +}: BannerProps & { idApiUrl: string }): JSX.Element => ( ); From d693c2195eabffbb6ef1696cfdacc4744d4ee641 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Fri, 23 Oct 2026 15:14:15 +0100 Subject: [PATCH 15/18] styles fix --- .../banners/newsletterBanner/ErrorMessage.tsx | 11 ++++- .../NewsletterBanner.styles.ts | 48 ++++++++----------- .../NewsletterBanner.test.tsx | 43 +++++++++++++++++ .../newsletterBanner/NewsletterBanner.tsx | 18 ++----- .../newsletterBanner/SuccessMessage.tsx | 10 +++- 5 files changed, 85 insertions(+), 45 deletions(-) diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx index ba0ebf6b171..95f4c6975dd 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/ErrorMessage.tsx @@ -1,5 +1,10 @@ import { css } from '@emotion/react'; -import { palette, space, textSans17 } from '@guardian/source/foundations'; +import { + palette, + space, + textSans17, + until, +} from '@guardian/source/foundations'; import { Button, Link, @@ -8,11 +13,13 @@ import { } from '@guardian/source/react-components'; const responseBoxStyles = css` - width: 100%; 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` diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts index ceb091fb2ba..ad1312a93ed 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.styles.ts @@ -84,17 +84,6 @@ export const styles = { padding-left: 10px; } `, - successCloseButton: css` - position: absolute; - top: ${space[9]}px; - right: ${space[3]}px; - - > div { - position: static; - margin: 0; - padding: 0; - } - `, container: (background: string, textColor: string): SerializedStyles => css` background: ${background}; color: ${textColor}; @@ -113,7 +102,12 @@ export const styles = { `, 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; @@ -121,8 +115,8 @@ export const styles = { grid-template-areas: 'copy-container'; ${from.desktop} { - grid-template-columns: minmax(0, 1fr) 380px auto; - grid-template-areas: 'copy-container main-image close-button'; + grid-template-columns: minmax(0, 1fr) 380px; + grid-template-areas: 'copy-container main-image'; gap: 20px; } @@ -131,7 +125,7 @@ export const styles = { } ${from.wide} { - grid-template-columns: minmax(0, 1fr) 400px auto; + grid-template-columns: minmax(0, 1fr) 400px; } `, contentContainer: css` @@ -165,29 +159,29 @@ export const styles = { align-items: center; justify-content: space-between; gap: ${space[2]}px; - `, - inlineCloseButton: css` - display: none; ${until.mobile} { - display: block; - flex-shrink: 0; - - > div { - margin: 0; - padding: 0; - position: static; - } + min-height: 40px; + padding-right: 52px; } `, - outerCloseButton: css` + 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} { - display: none; + > div { + margin: 0; + padding: 0; + position: static; + } } `, textImageRow: css` diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx index 03622f1c7bb..cc4e796796c 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx @@ -3,10 +3,28 @@ import { bannerSchema, hexColourToString, } from '@guardian/support-dotcom-components'; +import { fireEvent, render, screen } from '@testing-library/react'; import { NEWSLETTER_BANNER_CONFIG } from '../../../StickyBottomBanner/newsletterBannerDetails'; import { props } from '../utils/storybook'; import { NewsletterBanner } from './NewsletterBanner'; +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('uses newsletter colours', () => { const { colours } = NEWSLETTER_BANNER_CONFIG.design; @@ -44,4 +62,29 @@ describe('NewsletterBanner configuration', () => { 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); + }); }); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index d699cde0954..19d836f5215 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -105,11 +105,6 @@ export const NewsletterBannerComponent = ( -
- -
@@ -149,11 +144,6 @@ export const NewsletterBannerComponent = (
-
- -
@@ -168,15 +158,13 @@ export const NewsletterBannerComponent = ( newsletterName={newsletter.name} frequency={newsletter.frequency} /> -
- -
+
+ +
); diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx index 2dded92b57a..f1451f4784c 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/SuccessMessage.tsx @@ -1,11 +1,19 @@ import { css } from '@emotion/react'; -import { palette, space, textSans15 } from '@guardian/source/foundations'; +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; From d217fe88fd032b6dfead9ef52208cf3fcba7f50d Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Fri, 23 Oct 2026 16:04:47 +0100 Subject: [PATCH 16/18] refactoring --- .../NewsletterSignupForm.island.tsx | 17 -- .../ReaderRevenueBanner.tsx | 3 +- .../newsletterBanner/NewsletterBanner.tsx | 2 - .../NewsletterSignupForm.test.tsx | 45 +++++- .../newsletterBanner/NewsletterSignupForm.tsx | 145 ++++++++++++------ 5 files changed, 133 insertions(+), 79 deletions(-) diff --git a/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx b/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx index aaa790af860..7d92298a56f 100644 --- a/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx +++ b/dotcom-rendering/src/components/NewsletterSignupForm.island.tsx @@ -436,20 +436,3 @@ const NewsletterSignupFormActive = ({ ); }; - -export { - emailFieldStyles, - FailureMessage, - formStyles, - getPrivacyContainerStyles, - getToggleContainerStyles, - marketingToggleBoxStyles, - type Props as NewsletterSignupFormProps, - primaryButtonTheme, - recaptchaContainerStyles, - signedInLayoutStyles, - signedOutLayoutStyles, - submitButtonContainerStyles, - submitButtonStyles, - SuccessMessage, -}; diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index c8d5f0c2b7b..d8c35ec5b5f 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -382,13 +382,12 @@ export const canShowRRBanner: CanShowFunctionType< }; export const ReaderRevenueBanner = ({ - //name, + name, props, idApiUrl, }: ModuleData & { idApiUrl: string; }) => { - const name: string = 'NewsletterBanner'; const [Banner, setBanner] = useState(null); useEffect(() => { (name === 'SignInPromptBanner' diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx index 19d836f5215..1ab152b989b 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -134,8 +134,6 @@ export const NewsletterBannerComponent = ( newsletterId={ newsletter.identityName } - newsletterName={newsletter.name} - frequency={newsletter.frequency} componentId="newsletter_banner" onSuccess={() => setIsSuccess(true)} /> diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx index 64dad10df2a..5f1f8e6c278 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx @@ -6,9 +6,12 @@ import { import ReactGoogleRecaptcha from 'react-google-recaptcha'; import { NewsletterSignupForm } from './NewsletterSignupForm'; -const mockHandleSubmit = jest.fn(); +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' }), @@ -26,6 +29,7 @@ jest.mock('../../../../lib/useNewsletterSignupForm', () => ({ showMarketingToggle: false, isWaitingForResponse: false, captchaSiteKey: 'test-site-key', + responseOk: mockResponseOk, handleSubmit: mockHandleSubmit, handleSubmitButtonClick: mockHandleSubmitButtonClick, handleEmailFocus: mockHandleEmailFocus, @@ -41,28 +45,53 @@ jest.mock('../../../NewsletterPrivacyMessage', () => ({ NewsletterPrivacyMessage: () => null, })); -describe('newsletter banner manual signup stub', () => { - it('skips CAPTCHA and real submission, and calls onSuccess', () => { +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).not.toHaveBeenCalled(); + expect(ReactGoogleRecaptcha).toHaveBeenCalled(); expect(onSuccess).not.toHaveBeenCalled(); fireEvent.focus(testingScreen.getByRole('textbox')); fireEvent.click(testingScreen.getByRole('button', { name: 'Sign up' })); - expect(mockHandleSubmit).not.toHaveBeenCalled(); - expect(mockHandleSubmitButtonClick).not.toHaveBeenCalled(); + 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 form = ( + + ); + const { rerender } = render(form); + + expect(onSuccess).not.toHaveBeenCalled(); + + mockResponseOk = false; + rerender(<>{form}); + expect(onSuccess).not.toHaveBeenCalled(); + + mockResponseOk = true; + rerender(form); 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 index 941f78ff3ec..7a5448e8825 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -1,40 +1,49 @@ 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, useState } from 'react'; +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 type { NewsletterSignupFormProps } from '../../../NewsletterSignupForm.island'; -import { - emailFieldStyles as baseEmailFieldStyles, - formStyles, - getToggleContainerStyles, - marketingToggleBoxStyles, - primaryButtonTheme, - recaptchaContainerStyles, - signedInLayoutStyles, - signedOutLayoutStyles, - submitButtonContainerStyles, - submitButtonStyles, -} from '../../../NewsletterSignupForm.island'; 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` - ${signedInLayoutStyles}; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'submit'; margin-top: ${space[2]}px; row-gap: ${space[4]}px; ${until.tablet} { @@ -44,8 +53,14 @@ const bannerSignedInLayoutStyles = css` `; const bannerSignedOutLayoutStyles = css` - ${signedOutLayoutStyles}; + 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; } `; @@ -58,12 +73,19 @@ const signedInButtonStyles = css` `; const emailFieldStyles = css` - ${baseEmailFieldStyles}; + 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; @@ -108,7 +130,30 @@ const additionalFieldsContentStyles = css` } `; -const toggleContainerStyles = getToggleContainerStyles(true); +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; @@ -122,6 +167,23 @@ const getPrivacyContainerStyles = (isSignedIn: boolean | 'Pending') => css` `}; `; +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 * @@ -136,14 +198,9 @@ export const NewsletterSignupForm = ({ abTest, isModal = false, onSuccess, -}: Omit & { - onSuccess?: () => void; -}) => { +}: NewsletterSignupFormProps) => { const { renderingTarget } = useConfig(); const hideMarketingToggle = useHideMarketingToggleForCountry(); - // Temporary local stub for manual testing; disable before shipping. - const [stubSignup] = useState(true); - const [stubSuccess, setStubSuccess] = useState(false); const { userEmail, @@ -177,7 +234,7 @@ export const NewsletterSignupForm = ({ const hasResponse = typeof responseOk === 'boolean'; const hasNonValidationError = errorMessage !== undefined && errorMessage !== '' && !isValidationError; - const isSuccess = stubSuccess || (hasResponse && !!responseOk); + const isSuccess = hasResponse && !!responseOk; const showForm = (!hasResponse || isSuccess) && !hasNonValidationError; useEffect(() => { @@ -198,14 +255,7 @@ export const NewsletterSignupForm = ({ return ( <> { - event.preventDefault(); - setStubSuccess(true); - } - : handleSubmit - } + onSubmit={handleSubmit} id={`newsletter-signup-${newsletterId}`} style={{ display: !showForm ? 'none' : undefined, @@ -243,11 +293,8 @@ export const NewsletterSignupForm = ({ ? [submitButtonStyles, signedInButtonStyles] : submitButtonStyles } - onClick={ - stubSignup ? undefined : handleSubmitButtonClick - } + onClick={handleSubmitButtonClick} type="submit" - theme={primaryButtonTheme} isLoading={isWaitingForResponse} loadingAnnouncement="Signing you up…" disabled={isWaitingForResponse} @@ -292,19 +339,17 @@ export const NewsletterSignupForm = ({ {showFailure && ( )} - {!stubSignup && - captchaSiteKey !== undefined && - captchaSiteKey !== '' && ( -
- -
- )} + {captchaSiteKey !== undefined && captchaSiteKey !== '' && ( +
+ +
+ )} ); }; From 2bbbee22ffe79ad0cd00b2db6e537b4d19c3e28d Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Mon, 26 Oct 2026 11:54:20 +0000 Subject: [PATCH 17/18] receive banner props from SDC --- .../ReaderRevenueBanner.test.ts | 60 ++++++++++++ .../ReaderRevenueBanner.tsx | 29 ++++++ .../newsletterBannerDetails.ts | 92 ------------------- .../banners/common/BannerWrapper.tsx | 10 +- .../marketing/banners/common/types.tsx | 7 ++ .../NewsletterBanner.stories.tsx | 27 +++++- .../NewsletterBanner.test.tsx | 81 ++++++++++------ .../newsletterBanner/NewsletterBanner.tsx | 57 ++++-------- .../NewsletterSignupForm.test.tsx | 8 +- 9 files changed, 207 insertions(+), 164 deletions(-) delete mode 100644 dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts 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 d8c35ec5b5f..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, diff --git a/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts b/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts deleted file mode 100644 index a807fb435af..00000000000 --- a/dotcom-rendering/src/components/StickyBottomBanner/newsletterBannerDetails.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { - BannerProps, - Image as SupportImage, -} from '@guardian/support-dotcom-components/dist/shared/types'; -import type { Newsletter } from '../../types/content'; - -export type PromotedNewsletter = Pick< - Newsletter, - 'listId' | 'identityName' | 'name' | 'frequency' ->; - -export const DEFAULT_PROMOTED_NEWSLETTER: PromotedNewsletter = { - listId: 6042, - identityName: 'the-stakes-us-election-edition', - name: 'The Stakes 2026', - frequency: 'Weekly', -}; - -export const NEWSLETTER_BANNER_CONFIG = { - bannerChannel: 'contributions', - content: { - heading: 'Sign up to The Stakes 2026', - paragraphs: [ - 'From the races and candidates to watch to the issues shaping American politics, The Stakes 2026 brings you expert analysis of what really matters in the US midterms.', - ], - }, - isCollapsible: false, - imageSettings: { - mainUrl: - 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', - 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', - wideUrl: - 'https://i.guim.co.uk/img/media/6c933a058d1ce37a5ad17f79895906150812dfee/0_0_1768_1420/500.png?width=500&quality=75&s=9277532ddf184a308e14218e3576543b', - altText: '', - }, - design: { - colours: { - basic: { - background: { r: 'F3', g: 'F7', b: 'FF', kind: 'hex' }, - bodyText: { r: '33', g: '33', b: '33', kind: 'hex' }, - headerText: { r: '12', g: '12', b: '12', kind: 'hex' }, - articleCountText: { r: '00', g: '00', b: '00', kind: 'hex' }, - logo: { r: '00', g: '00', b: '00', kind: 'hex' }, - }, - highlightedText: { - text: { r: '33', g: '33', b: '33', kind: 'hex' }, - highlight: { r: 'F3', g: 'F7', b: 'FF', kind: 'hex' }, - }, - primaryCta: { - default: { - text: { r: '05', g: '29', b: '62', kind: 'hex' }, - background: { r: 'FF', g: 'E5', b: '00', kind: 'hex' }, - }, - }, - secondaryCta: { - default: { - text: { r: '00', g: '00', b: '00', kind: 'hex' }, - background: { r: 'F1', g: 'F8', b: 'FC', kind: 'hex' }, - border: { r: '00', g: '00', b: '00', kind: 'hex' }, - }, - }, - closeButton: { - default: { - text: { r: '00', g: '00', b: '00', kind: 'hex' }, - background: { r: 'E6', g: 'EC', b: 'EF', kind: 'hex' }, - border: { r: 'E6', g: 'EC', b: 'EF', kind: 'hex' }, - }, - }, - ticker: { - filledProgress: { r: '33', g: '33', b: '33', kind: 'hex' }, - progressBarBackground: { - r: 'F3', - g: 'F7', - b: 'FF', - kind: 'hex', - }, - headlineColour: { r: '33', g: '33', b: '33', kind: 'hex' }, - totalColour: { r: '33', g: '33', b: '33', kind: 'hex' }, - goalColour: { r: '33', g: '33', b: '33', kind: 'hex' }, - }, - }, - }, - promotedNewsletter: DEFAULT_PROMOTED_NEWSLETTER, -} satisfies Omit & { - imageSettings: SupportImage; - promotedNewsletter: PromotedNewsletter; -}; diff --git a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx index 30709048dbd..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,9 +129,11 @@ const withBannerData = isCollapsible, contributionsServiceUrl, } = bannerProps; - const { idApiUrl } = bannerProps as CloseableBannerProps & { - idApiUrl?: string; - }; + const { idApiUrl, promotedNewsletter } = + bannerProps as CloseableBannerProps & { + idApiUrl?: string; + promotedNewsletter?: PromotedNewsletter; + }; const [hasBeenSeen, setNode] = useIsInView({ debounce: true, @@ -414,6 +417,7 @@ const withBannerData = 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 db2180ebd82..7e3289303d4 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/types.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/types.tsx @@ -16,12 +16,18 @@ 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' | 'newsletter-banner'; +export type PromotedNewsletter = Pick< + Newsletter, + 'listId' | 'identityName' | 'name' | 'frequency' +>; + export interface BannerEnrichedCta { ctaUrl: string; ctaText: string; @@ -85,4 +91,5 @@ export interface BannerRenderProps { promoCodes?: string[]; isCollapsible?: boolean; idApiUrl?: string; + promotedNewsletter?: PromotedNewsletter; } diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx index c9d62e90e97..04ec6b9a19e 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -7,7 +7,7 @@ import { useCountryCode } from '../../../../lib/useCountryCode'; import { useNewsletterSignupForm } from '../../../../lib/useNewsletterSignupForm'; import type { NewsletterSignupFormState } from '../../../../lib/useNewsletterSignupForm'; import { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; -import { props } from '../utils/storybook'; +import { design, props } from '../utils/storybook'; import { NewsletterBanner } from './NewsletterBanner'; const meta = preview.meta({ @@ -25,6 +25,31 @@ const meta = preview.meta({ ], 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', }, diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx index cc4e796796c..6f8b76d021c 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.test.tsx @@ -1,21 +1,44 @@ -import { neutral } from '@guardian/source/foundations'; -import { - bannerSchema, - hexColourToString, -} from '@guardian/support-dotcom-components'; +import { bannerSchema } from '@guardian/support-dotcom-components'; import { fireEvent, render, screen } from '@testing-library/react'; -import { NEWSLETTER_BANNER_CONFIG } from '../../../StickyBottomBanner/newsletterBannerDetails'; -import { props } from '../utils/storybook'; +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', - ), + ...jest.requireActual('../../../../lib/useMatchMedia'), useMatchMedia: () => false, })); @@ -26,25 +49,13 @@ jest.mock('./NewsletterSignupForm', () => ({ })); describe('NewsletterBanner configuration', () => { - it('uses newsletter colours', () => { - const { colours } = NEWSLETTER_BANNER_CONFIG.design; - - expect(hexColourToString(colours.basic.background)).toBe('#F3F7FF'); - expect(hexColourToString(colours.basic.headerText)).toBe(neutral[7]); - expect(hexColourToString(colours.basic.bodyText)).toBe(neutral[20]); - }); - - it('uses local newsletter settings instead of SDC presentation settings', () => { + it('passes SDC newsletter settings through to the banner wrapper', () => { const banner = NewsletterBanner({ - ...props, - bannerChannel: 'subscriptions', - content: { heading: 'SDC heading' }, - design: undefined, - isCollapsible: true, + ...sdcNewsletterProps, idApiUrl: 'https://idapi.theguardian.com', }); - expect(banner.props).toMatchObject(NEWSLETTER_BANNER_CONFIG); + expect(banner.props).toMatchObject(sdcNewsletterProps); expect(bannerSchema.safeParse(banner.props).success).toBe(true); }); @@ -58,7 +69,10 @@ describe('NewsletterBanner configuration', () => { contributionsServiceUrl: 'https://contributions.example.com', idApiUrl: 'https://idapi.theguardian.com', }; - const banner = NewsletterBanner({ ...props, ...runtimeProps }); + const banner = NewsletterBanner({ + ...sdcNewsletterProps, + ...runtimeProps, + }); expect(banner.props).toMatchObject(runtimeProps); }); @@ -66,7 +80,7 @@ describe('NewsletterBanner configuration', () => { it('keeps the same close button outside the transitioning panels after signup', () => { render( , ); @@ -87,4 +101,17 @@ describe('NewsletterBanner configuration', () => { 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 index 1ab152b989b..9fce8a2d541 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.tsx @@ -2,10 +2,8 @@ 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 { useNewsletterSubscription } from '../../../../lib/useNewsletterSubscription'; -import { NEWSLETTER_BANNER_CONFIG } from '../../../StickyBottomBanner/newsletterBannerDetails'; import { validatedBannerWrapper } from '../common/BannerWrapper'; -import type { BannerRenderProps } from '../common/types'; +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'; @@ -22,13 +20,11 @@ import { useSuccessDismissal } from './useSuccessDismissal'; export const NewsletterBannerComponent = ( props: BannerRenderProps, ): JSX.Element | null => { - const newsletter = NEWSLETTER_BANNER_CONFIG.promotedNewsletter; - useNewsletterSubscription(newsletter.listId, props.idApiUrl); + const newsletter = props.promotedNewsletter; const bannerRef = useRef(null); const [isSuccess, setIsSuccess] = useState(false); const { isOpen, bannerData: baseBannerData } = useDesignableBannerModel({ ...props, - isCollapsible: false, }); const { isDismissing, dismiss, ...dismissalHandlers } = useSuccessDismissal( { @@ -43,34 +39,29 @@ export const NewsletterBannerComponent = ( bannerRef.current?.focus(); }, [isSuccess]); - if (!isOpen || !baseBannerData || !props.design) { + if (!newsletter || !isOpen || !baseBannerData || !props.design) { return null; } const { basic } = props.design.colours; - const newsletterCopy = props.content.mainContent; const bannerData: BannerData = { ...baseBannerData, actions: { ...baseBannerData.actions, onClose: isSuccess ? dismiss : baseBannerData.actions.onClose, }, - content: { - mainContent: newsletterCopy, - mobileContent: newsletterCopy, - }, - settings: { - ...baseBannerData.settings, - imageSettings: NEWSLETTER_BANNER_CONFIG.imageSettings, - }, selectors: { ...baseBannerData.selectors, - headingCopy: newsletterCopy, - copyForViewport: newsletterCopy, showHeaderImage: false, showBody: true, showBodyVisual: true, }, + settings: { + ...baseBannerData.settings, + imageSettings: + baseBannerData.settings.headerSettings?.headerImage ?? + baseBannerData.settings.imageSettings, + }, }; return ( @@ -171,25 +162,17 @@ export const NewsletterBannerComponent = ( const ValidatedNewsletterBanner = validatedBannerWrapper( NewsletterBannerComponent, 'newsletter-banner', -) as ComponentType; +) as ComponentType< + BannerProps & { + idApiUrl: string; + promotedNewsletter?: PromotedNewsletter; + } +>; export const NewsletterBanner = ({ - tracking, - articleCounts, - countryCode, - fetchEmail, - submitComponentEvent, - contributionsServiceUrl, idApiUrl, -}: BannerProps & { idApiUrl: string }): JSX.Element => ( - -); + ...props +}: BannerProps & { + idApiUrl: string; + promotedNewsletter?: PromotedNewsletter; +}): JSX.Element => ; diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx index 5f1f8e6c278..df14d3e441f 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.test.tsx @@ -75,23 +75,23 @@ describe('newsletter banner signup form', () => { it('calls onSuccess only after the signup hook reports success', () => { const onSuccess = jest.fn(); - const form = ( + const renderForm = () => ( ); - const { rerender } = render(form); + const { rerender } = render(renderForm()); expect(onSuccess).not.toHaveBeenCalled(); mockResponseOk = false; - rerender(<>{form}); + rerender(renderForm()); expect(onSuccess).not.toHaveBeenCalled(); mockResponseOk = true; - rerender(form); + rerender(renderForm()); expect(onSuccess).toHaveBeenCalledTimes(1); }); }); From 992c111eedf19a0b2eaf5437e05b24e4f6509583 Mon Sep 17 00:00:00 2001 From: Anastasiia Balenko Date: Tue, 27 Oct 2026 15:23:30 +0000 Subject: [PATCH 18/18] small style fix and storybook update --- .../NewsletterBanner.stories.tsx | 12 ++++-- .../newsletterBanner/NewsletterSignupForm.tsx | 2 +- .../marketing/banners/utils/storybook.ts | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx index 04ec6b9a19e..8cb9e8fd1f7 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterBanner.stories.tsx @@ -7,7 +7,7 @@ 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 { newsletterDesign, props } from '../utils/storybook'; import { NewsletterBanner } from './NewsletterBanner'; const meta = preview.meta({ @@ -28,7 +28,13 @@ const meta = preview.meta({ content: { heading: 'Sign up to The Stakes 2026', paragraphs: [ - 'From the races and candidates to watch to the issues shaping American politics.', + 'From the races and candidates to watch to the issues shaping American politics, The Stakes 2026 brings you expert analysis of what really matters in the US midterms.', + ], + }, + mobileContent: { + heading: 'Sign up to The Stakes 2026', + paragraphs: [ + 'From the races and candidates to watch to the issues shaping American politics, The Stakes 2026 brings you expert analysis of what really matters in the US midterms.', ], }, isCollapsible: false, @@ -39,7 +45,7 @@ const meta = preview.meta({ frequency: 'Weekly', }, design: { - ...design, + ...newsletterDesign, headerImage: { mobileUrl: 'https://i.guim.co.uk/img/media/630a3735c02e195be89ab06fd1b8192959e282ab/0_0_1172_560/500.png?width=500&quality=75&s=937595b3f471d6591475955335c7c023', diff --git a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx index 7a5448e8825..0ac756f1d6d 100644 --- a/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx +++ b/dotcom-rendering/src/components/marketing/banners/newsletterBanner/NewsletterSignupForm.tsx @@ -158,7 +158,7 @@ const recaptchaContainerStyles = css` const getPrivacyContainerStyles = (isSignedIn: boolean | 'Pending') => css` margin-top: ${space[2]}px; ${until.tablet} { - margin-top: ${space[3]}px; + margin-top: ${isSignedIn ? 0 : space[3]}px; } grid-column: 1 / -1; ${isSignedIn === true && diff --git a/dotcom-rendering/src/components/marketing/banners/utils/storybook.ts b/dotcom-rendering/src/components/marketing/banners/utils/storybook.ts index 2fe03b9cedb..4786800c810 100644 --- a/dotcom-rendering/src/components/marketing/banners/utils/storybook.ts +++ b/dotcom-rendering/src/components/marketing/banners/utils/storybook.ts @@ -139,6 +139,49 @@ export const design: ConfigurableDesign = { }, }; +export const newsletterDesign: ConfigurableDesign = { + colours: { + basic: { + background: { r: 'F3', g: 'F7', b: 'FF', kind: 'hex' }, + bodyText: { r: '33', g: '33', b: '33', kind: 'hex' }, + headerText: { r: '12', g: '12', b: '12', kind: 'hex' }, + articleCountText: { r: '00', g: '00', b: '00', kind: 'hex' }, + logo: { r: '00', g: '00', b: '00', kind: 'hex' }, + }, + highlightedText: { + text: { r: '33', g: '33', b: '33', kind: 'hex' }, + highlight: { r: 'F3', g: 'F7', b: 'FF', kind: 'hex' }, + }, + primaryCta: { + default: { + text: { r: '05', g: '29', b: '62', kind: 'hex' }, + background: { r: 'FF', g: 'E5', b: '00', kind: 'hex' }, + }, + }, + secondaryCta: { + default: { + text: { r: '00', g: '00', b: '00', kind: 'hex' }, + background: { r: 'F1', g: 'F8', b: 'FC', kind: 'hex' }, + border: { r: '00', g: '00', b: '00', kind: 'hex' }, + }, + }, + closeButton: { + default: { + text: { r: '00', g: '00', b: '00', kind: 'hex' }, + background: { r: 'E6', g: 'EC', b: 'EF', kind: 'hex' }, + border: { r: 'E6', g: 'EC', b: 'EF', kind: 'hex' }, + }, + }, + ticker: { + filledProgress: { r: '33', g: '33', b: '33', kind: 'hex' }, + progressBarBackground: { r: 'F3', g: 'F7', b: 'FF', kind: 'hex' }, + headlineColour: { r: '33', g: '33', b: '33', kind: 'hex' }, + totalColour: { r: '33', g: '33', b: '33', kind: 'hex' }, + goalColour: { r: '33', g: '33', b: '33', kind: 'hex' }, + }, + }, +}; + export const props: BannerProps = { bannerChannel: 'contributions', countryCode: 'GB',