From 3c14be4dd3bb40f9f7d959dd4c3040f28f916e27 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Thu, 15 Jan 2026 11:01:14 +0000 Subject: [PATCH 1/7] refactor(wip): designable banner v2 using compound pattern --- .../StickyBottomBanner.importable.tsx | 6 +- .../ReaderRevenueBanner.tsx | 10 +- .../banners/common/BannerWrapper.tsx | 6 +- .../marketing/banners/common/types.tsx | 4 +- .../banners/designableBanner/v2/Banner.tsx | 546 ++++++++++++ .../designableBanner/v2/BannerContext.tsx | 48 ++ .../v2/components/BannerArticleCount.tsx | 54 ++ .../v2/components/BannerBody.tsx | 63 ++ .../v2/components/BannerChoiceCards.tsx | 205 +++++ .../v2/components/BannerCloseButton.tsx | 174 ++++ .../v2/components/BannerContent.tsx | 39 + .../v2/components/BannerCtas.tsx | 161 ++++ .../v2/components/BannerHeader.tsx | 147 ++++ .../v2/components/BannerLogo.tsx | 38 + .../v2/components/BannerReminder.tsx | 59 ++ .../v2/components/BannerTicker.tsx | 30 + .../v2/components/BannerVisual.tsx | 164 ++++ .../banners/designableBanner/v2/index.ts | 17 + .../v2/stories/DesignableBannerV2.stories.tsx | 786 ++++++++++++++++++ .../designableBanner/v2/tests/Banner.test.tsx | 306 +++++++ .../banners/designableBanner/v2/useBanner.ts | 12 + .../marketing/banners/utils/withCloseable.tsx | 1 + .../marketing/shared/ThreeTierChoiceCards.tsx | 2 +- 23 files changed, 2872 insertions(+), 6 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerContext.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerHeader.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerLogo.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerReminder.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerTicker.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/index.ts create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/useBanner.ts diff --git a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx index 73ef5947c82..febbf5ffb9e 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx @@ -343,8 +343,12 @@ export const StickyBottomBanner = ({ host, ); + // Check both window.location.search and the full URL for force-banner parameter + // This handles cases where the local dev server has a different URL structure + const fullUrl = window.location.href; const hasForceBannerParam = - window.location.search.includes('force-banner'); + window.location.search.includes('force-banner') || + fullUrl.includes('force-banner'); const hasForceBrazeMessageParam = window.location.hash.includes( 'force-braze-message', ); diff --git a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx index 9860cc4b5a5..88e373ebcb2 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner/ReaderRevenueBanner.tsx @@ -297,14 +297,22 @@ export const ReaderRevenueBanner = ({ const [Banner, setBanner] = useState(null); useEffect(() => { + const params = new URLSearchParams(window.location.search); + const version = params.get('banner-version'); + (name === 'SignInPromptBanner' ? /* webpackChunkName: "sign-in-prompt-banner" */ import(`../marketing/banners/signInPrompt/SignInPromptBanner`) + : version === 'v2' + ? /* webpackChunkName: "designable-banner-v2" */ + import(`../marketing/banners/designableBanner/v2/Banner`) : /* webpackChunkName: "designable-banner" */ import(`../marketing/banners/designableBanner/DesignableBanner`) ) .then((bannerModule: { [key: string]: React.ElementType }) => { - setBanner(() => bannerModule[name] ?? null); + // When using banner-version=v2, always use DesignableBanner export + const bannerName = version === 'v2' ? 'DesignableBanner' : name; + setBanner(() => bannerModule[bannerName] ?? null); }) .catch((error) => { const msg = `Error importing RR banner: ${String(error)}`; diff --git a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx index 1810f51a3e6..ac51a534846 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/BannerWrapper.tsx @@ -95,6 +95,7 @@ const withBannerData = abandonedBasket, promoCodes, isCollapsible, + children, } = bannerProps; const [hasBeenSeen, setNode] = useIsInView({ @@ -331,6 +332,7 @@ const withBannerData = onNotNowClick, onCollapseClick, onExpandClick, + bannerChannel, content: { mainContent: renderedContent, mobileContent: renderedMobileContent ?? renderedContent, @@ -353,12 +355,12 @@ const withBannerData = return (
- + {children}
); } } catch (err) { - console.log(err); + console.error(err); } return <>; diff --git a/dotcom-rendering/src/components/marketing/banners/common/types.tsx b/dotcom-rendering/src/components/marketing/banners/common/types.tsx index 5d4c8fc8d31..a1ffed2bef5 100644 --- a/dotcom-rendering/src/components/marketing/banners/common/types.tsx +++ b/dotcom-rendering/src/components/marketing/banners/common/types.tsx @@ -9,6 +9,7 @@ import type { ReminderFields } from '@guardian/support-dotcom-components/dist/sh import type { ArticleCounts, ArticleCountType, + BannerChannel, ConfigurableDesign, SelectedAmountsVariant, SeparateArticleCount, @@ -65,6 +66,7 @@ export interface BannerRenderProps { onSignInClick?: () => void; onCollapseClick: () => void; onExpandClick: () => void; + bannerChannel: BannerChannel; reminderTracking: ContributionsReminderTracking; content: BannerTextContent; countryCode?: string; @@ -78,7 +80,7 @@ export interface BannerRenderProps { choiceCardAmounts?: SelectedAmountsVariant; choiceCardsSettings?: ChoiceCardsSettings; tracking: Tracking; - submitComponentEvent?: (componentEvent: ComponentEvent) => void; + submitComponentEvent?: (componentEvent: ComponentEvent) => Promise; design?: ConfigurableDesign; promoCodes?: string[]; isCollapsible?: boolean; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx new file mode 100644 index 00000000000..3941eb11c7b --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx @@ -0,0 +1,546 @@ +import { css } from '@emotion/react'; +import { from, neutral, space, until } from '@guardian/source/foundations'; +import { hexColourToString } from '@guardian/support-dotcom-components'; +import type { + BannerDesignHeaderImage, + BannerDesignImage, + ConfigurableDesign, + Image, +} from '@guardian/support-dotcom-components/dist/shared/types'; +import type { ChoiceCard } from '@guardian/support-dotcom-components/dist/shared/types/props/choiceCards'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + removeMediaRulePrefix, + useMatchMedia, +} from '../../../../../lib/useMatchMedia'; +import { getChoiceCards } from '../../../lib/choiceCards'; +import { + bannerWrapper, + validatedBannerWrapper, +} from '../../common/BannerWrapper'; +import type { BannerRenderProps } from '../../common/types'; +import type { BannerTemplateSettings, ChoiceCardSettings } from '../settings'; +import { BannerContext, type BannerContextType } from './BannerContext'; +import { BannerArticleCount } from './components/BannerArticleCount'; +import { BannerBody } from './components/BannerBody'; +import { BannerChoiceCards } from './components/BannerChoiceCards'; +import { BannerCloseButton } from './components/BannerCloseButton'; +import { BannerContent } from './components/BannerContent'; +import { BannerCtas } from './components/BannerCtas'; +import { BannerHeader } from './components/BannerHeader'; +import { BannerLogo } from './components/BannerLogo'; +import { BannerTicker } from './components/BannerTicker'; +import { BannerVisual } from './components/BannerVisual'; + +const buildImageSettings = ( + design: BannerDesignImage | BannerDesignHeaderImage, +): Image | undefined => { + return { + mainUrl: design.mobileUrl, + mobileUrl: design.mobileUrl, + tabletUrl: design.tabletUrl, + desktopUrl: design.desktopUrl, + wideUrl: design.desktopUrl, + altText: design.altText, + }; +}; + +const buildMainImageSettings = ( + design: ConfigurableDesign, +): Image | undefined => { + if (design.visual?.kind !== 'Image') { + return undefined; + } + return buildImageSettings(design.visual); +}; + +const buildHeaderImageSettings = ( + design: ConfigurableDesign, +): Image | undefined => { + if (!design.headerImage) { + return undefined; + } + return buildImageSettings(design.headerImage); +}; + +const buildChoiceCardSettings = ( + design: ConfigurableDesign, +): ChoiceCardSettings | undefined => { + if (design.visual?.kind !== 'ChoiceCards') { + return undefined; + } + const { + buttonColour, + buttonTextColour, + buttonBorderColour, + buttonSelectColour, + buttonSelectTextColour, + buttonSelectBorderColour, + buttonSelectMarkerColour, + pillTextColour, + pillBackgroundColour, + } = design.visual; + return { + buttonColour: buttonColour + ? hexColourToString(buttonColour) + : undefined, + buttonTextColour: buttonTextColour + ? hexColourToString(buttonTextColour) + : undefined, + buttonBorderColour: buttonBorderColour + ? hexColourToString(buttonBorderColour) + : undefined, + buttonSelectColour: buttonSelectColour + ? hexColourToString(buttonSelectColour) + : undefined, + buttonSelectTextColour: buttonSelectTextColour + ? hexColourToString(buttonSelectTextColour) + : undefined, + buttonSelectBorderColour: buttonSelectBorderColour + ? hexColourToString(buttonSelectBorderColour) + : undefined, + buttonSelectMarkerColour: buttonSelectMarkerColour + ? hexColourToString(buttonSelectMarkerColour) + : undefined, + pillTextColour: pillTextColour + ? hexColourToString(pillTextColour) + : undefined, + pillBackgroundColour: pillBackgroundColour + ? hexColourToString(pillBackgroundColour) + : undefined, + }; +}; + +interface BannerComponentProps extends BannerRenderProps { + children?: React.ReactNode; +} + +const Banner = ({ + content, + onCloseClick, + onCollapseClick, + onExpandClick, + articleCounts, + onCtaClick, + onSecondaryCtaClick, + bannerChannel, + reminderTracking, + separateArticleCountSettings, + tickerSettings, + choiceCardsSettings, + submitComponentEvent, + tracking, + design, + countryCode, + promoCodes, + separateArticleCount, + isCollapsible, + children, +}: BannerComponentProps): JSX.Element | null => { + const isTabletOrAbove = useMatchMedia(removeMediaRulePrefix(from.tablet)); + const bannerRef = useRef(null); + + useEffect(() => { + if (bannerRef.current) { + bannerRef.current.focus(); + } + }, []); + + const choiceCards = useMemo( + () => getChoiceCards(isTabletOrAbove, choiceCardsSettings), + [isTabletOrAbove, choiceCardsSettings], + ); + + const defaultChoiceCard = choiceCards?.find((cc) => cc.isDefault); + + const [selectedChoiceCard, setSelectedChoiceCard] = useState< + ChoiceCard | undefined + >(defaultChoiceCard); + + const isCollapsableBanner: boolean = + isCollapsible ?? + (tracking.abTestVariant.includes('COLLAPSABLE_V1') || + tracking.abTestVariant.includes('COLLAPSABLE_V2_MAYBE_LATER')); + + const [isCollapsed, setIsCollapsed] = + useState(isCollapsableBanner); + + const handleToggleCollapse = useCallback(() => { + const nextCollapsed = !isCollapsed; + setIsCollapsed(nextCollapsed); + if (nextCollapsed) { + onCollapseClick(); + } else { + onExpandClick(); + } + }, [isCollapsed, onCollapseClick, onExpandClick]); + + const settings = useMemo((): BannerTemplateSettings | undefined => { + if (!design) { + return undefined; + } + + const { + basic, + primaryCta, + secondaryCta, + highlightedText, + closeButton, + ticker, + } = design.colours; + + const imageSettings = buildMainImageSettings(design); + const choiceCardSettings = buildChoiceCardSettings(design); + + return { + containerSettings: { + backgroundColour: hexColourToString(basic.background), + textColor: hexColourToString(basic.bodyText), + }, + headerSettings: { + textColour: hexColourToString(basic.headerText), + headerImage: buildHeaderImageSettings(design), + }, + primaryCtaSettings: { + default: { + backgroundColour: hexColourToString( + primaryCta.default.background, + ), + textColour: hexColourToString(primaryCta.default.text), + }, + }, + secondaryCtaSettings: { + default: { + backgroundColour: hexColourToString( + secondaryCta.default.background, + ), + textColour: hexColourToString(secondaryCta.default.text), + border: `1px solid ${ + secondaryCta.default.border + ? hexColourToString(secondaryCta.default.border) + : undefined + }`, + }, + }, + closeButtonSettings: { + default: { + backgroundColour: hexColourToString( + closeButton.default.background, + ), + textColour: hexColourToString(closeButton.default.text), + border: `1px solid ${ + closeButton.default.border + ? hexColourToString(closeButton.default.border) + : '#DCDCDC' // Fallback to specialReport[100] equivalent if needed, but let's use a safe hex for now + }`, + }, + }, + highlightedTextSettings: { + textColour: hexColourToString(highlightedText.text), + highlightColour: hexColourToString(highlightedText.highlight), + }, + articleCountTextColour: hexColourToString(basic.articleCountText), + choiceCardSettings, + imageSettings, + bannerId: 'designable-banner', + tickerStylingSettings: { + filledProgressColour: hexColourToString(ticker.filledProgress), + progressBarBackgroundColour: hexColourToString( + ticker.progressBarBackground, + ), + headlineColour: hexColourToString(ticker.headlineColour), + totalColour: hexColourToString(ticker.totalColour), + goalColour: hexColourToString(ticker.goalColour), + }, + }; + }, [design]); + + const contextValue: BannerContextType | null = useMemo(() => { + if (!design || !settings) { + return null; + } + return { + bannerChannel, + content, + design, + tracking, + articleCounts, + tickerSettings, + separateArticleCount, + separateArticleCountSettings, + promoCodes, + countryCode, + reminderTracking, + settings, + isCollapsed, + isCollapsible: isCollapsableBanner, + isTabletOrAbove, + choices: choiceCards, + selectedChoiceCard, + actions: { + onClose: onCloseClick, + onToggleCollapse: handleToggleCollapse, + onCtaClick, + onSecondaryCtaClick, + onChoiceCardChange: setSelectedChoiceCard, + submitComponentEvent, + }, + }; + }, [ + content, + design, + tracking, + articleCounts, + tickerSettings, + separateArticleCountSettings, + promoCodes, + countryCode, + settings, + isCollapsed, + isCollapsableBanner, + isTabletOrAbove, + choiceCards, + selectedChoiceCard, + onCloseClick, + handleToggleCollapse, + onCtaClick, + onSecondaryCtaClick, + submitComponentEvent, + separateArticleCount, + reminderTracking, + bannerChannel, + ]); + + if (!design || !settings || !contextValue) { + return null; + } + + const contextClassName = + isCollapsableBanner || + tracking.abTestVariant.includes('COLLAPSABLE_V2_MAYBE_LATER') + ? 'maybe-later' + : ''; + + const cardsImageOrSpaceTemplateString = isCollapsableBanner + ? 'main-image' + : '.'; + + return ( + +
+
+
+ {children ?? ( + <> + + + + + + + + + + + + + )} +
+
+ + ); +}; + +const phabletContentMaxWidth = '492px'; + +const styles = { + outerContainer: (background: string, textColor: string = 'inherit') => css` + background: ${background}; + color: ${textColor}; + bottom: 0px; + max-height: 65vh; + max-height: 65svh; + + * { + box-sizing: border-box; + } + ${from.phablet} { + border-top: 1px solid ${neutral[0]}; + } + b, + strong { + font-weight: bold; + } + padding: 0 auto; + `, + layoutOverrides: (cardsImageOrSpaceTemplateString: string) => css` + display: grid; + background: inherit; + position: relative; + bottom: 0px; + + /* mobile first */ + ${until.phablet} { + max-width: 660px; + margin: 0 auto; + padding: ${space[3]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: auto max(${phabletContentMaxWidth} auto); + grid-template-areas: + '. . .' + '. copy-container close-button' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString}' + '. cta-container cta-container'; + } + ${from.phablet} { + max-width: 740px; + margin: 0 auto; + padding: ${space[3]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: minmax(0, 0.5fr) ${phabletContentMaxWidth} max-content minmax( + 0, + 0.5fr + ); + grid-template-rows: auto auto auto; + grid-template-areas: + '. copy-container close-button close-button' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString} .' + '. cta-container cta-container .'; + } + ${from.desktop} { + max-width: 980px; + align-self: stretch; + padding: ${space[3]}px ${space[1]}px 0 ${space[3]}px; + grid-template-columns: auto 380px auto; + grid-template-rows: auto auto; + + grid-template-areas: + 'copy-container ${cardsImageOrSpaceTemplateString} close-button' + 'cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.leftCol} { + max-width: 1140px; + bottom: 0px; + /* the vertical line aligns with that of standard article */ + grid-column-gap: 10px; + grid-template-columns: 140px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.wide} { + max-width: 1300px; + /* the vertical line aligns with that of standard article */ + grid-template-columns: 219px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + `, + collapsedLayoutOverrides: (cardsImageOrSpaceTemplateString: string) => css` + display: grid; + background: inherit; + position: relative; + bottom: 0px; + + /* mobile first */ + ${until.phablet} { + max-width: 660px; + margin: 0 auto; + padding: ${space[2]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: auto max(${phabletContentMaxWidth} auto); + grid-template-areas: ${` + '. . .' + '. copy-container close-button' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString}' + '. cta-container cta-container' + `}; + } + ${from.phablet} { + max-width: 740px; + margin: 0 auto; + padding: ${space[2]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: + minmax(0, 0.5fr) + ${phabletContentMaxWidth} + 1fr + 0; + grid-template-rows: auto auto; + grid-template-areas: + '. copy-container close-button .' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString} .' + '. cta-container cta-container .'; + } + ${from.desktop} { + max-width: 980px; + padding: ${space[1]}px ${space[1]}px 0 ${space[3]}px; + grid-template-columns: auto 380px minmax(100px, auto); + grid-template-rows: auto auto; + + grid-template-areas: + 'copy-container ${cardsImageOrSpaceTemplateString} close-button' + 'cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.leftCol} { + max-width: 1140px; + bottom: 0px; + /* the vertical line aligns with that of standard article */ + grid-column-gap: 10px; + grid-template-columns: 140px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button ' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.wide} { + max-width: 1300px; + /* the vertical line aligns with that of standard article */ + grid-template-columns: 219px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button ' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + `, + verticalLine: css` + grid-area: vert-line; + + ${until.leftCol} { + display: none; + } + ${from.leftCol} { + background-color: ${neutral[0]}; + width: 1px; + opacity: 0.2; + margin: ${space[6]}px ${space[2]}px 0 ${space[2]}px; + } + `, +}; + +const unvalidated = bannerWrapper(Banner, 'designable-banner'); +const validated = validatedBannerWrapper(Banner, 'designable-banner'); + +export { + Banner as BannerComponent, + unvalidated as DesignableBannerUnvalidated, + validated as DesignableBanner, +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerContext.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerContext.tsx new file mode 100644 index 00000000000..63f26c7db0a --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerContext.tsx @@ -0,0 +1,48 @@ +import type { BannerChannel } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { ChoiceCard } from '@guardian/support-dotcom-components/dist/shared/types/props/choiceCards'; +import type { Dispatch, SetStateAction } from 'react'; +import { createContext } from 'react'; +import type { + BannerRenderProps, + ContributionsReminderTracking, +} from '../../common/types'; +import type { BannerTemplateSettings } from '../settings'; + +export interface BannerContextType { + // --- Raw Data --- + bannerChannel: BannerChannel; + content: BannerRenderProps['content']; + design: BannerRenderProps['design']; + tracking: BannerRenderProps['tracking']; + articleCounts: BannerRenderProps['articleCounts']; + tickerSettings?: BannerRenderProps['tickerSettings']; + separateArticleCount?: boolean; + separateArticleCountSettings?: BannerRenderProps['separateArticleCountSettings']; + promoCodes?: BannerRenderProps['promoCodes']; + countryCode?: BannerRenderProps['countryCode']; + reminderTracking: ContributionsReminderTracking; + + // --- Derived Settings --- + settings: BannerTemplateSettings; + + // --- UI State --- + isCollapsed: boolean; + isCollapsible: boolean; + isTabletOrAbove: boolean; + choices: ChoiceCard[] | undefined; + selectedChoiceCard: ChoiceCard | undefined; + + // --- Actions --- + actions: { + onClose: () => void; + onToggleCollapse: () => void; + onCtaClick: () => void; + onSecondaryCtaClick: () => void; + onChoiceCardChange: Dispatch>; + submitComponentEvent: BannerRenderProps['submitComponentEvent']; + }; +} + +export const BannerContext = createContext( + undefined, +); diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx new file mode 100644 index 00000000000..35d49869c75 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx @@ -0,0 +1,54 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold15, + headlineBold17, +} from '@guardian/source/foundations'; +import { CustomArticleCountCopy } from '../../components/CustomArticleCountCopy'; +import { DesignableBannerArticleCountOptOut } from '../../components/DesignableBannerArticleCountOptOut'; +import { useBanner } from '../useBanner'; + +const containsArticleCountTemplate = (copy: string): boolean => + copy.includes('%%ARTICLE_COUNT%%'); + +const styles = { + container: (textColor: string = 'inherit') => css` + margin: 0; + color: ${textColor}; + ${headlineBold15} + ${from.desktop} { + ${headlineBold17} + } + `, +}; + +export const BannerArticleCount = (): JSX.Element | null => { + const { articleCounts, settings, separateArticleCountSettings } = + useBanner(); + const numArticles = articleCounts.forTargetedWeeks; + const copy = separateArticleCountSettings?.copy; + + if (copy && containsArticleCountTemplate(copy)) { + return ( + + ); + } + + return ( +
+ {numArticles >= 50 + ? "Congratulations on being one of our top readers globally - you've read " + : "You've read "} + {' '} + in the last year +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx new file mode 100644 index 00000000000..51e3d705dd3 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx @@ -0,0 +1,63 @@ +import { css } from '@emotion/react'; +import { + from, + textEgyptian15, + textEgyptian17, + textEgyptianBold15, + textEgyptianBold17, +} from '@guardian/source/foundations'; +import { createBannerBodyCopy } from '../../components/BannerText'; +import { useBanner } from '../useBanner'; + +const getStyles = (textColour: string, highlightColour?: string) => ({ + container: css` + p { + margin: 0 0 0.5em 0; + } + ${textEgyptian15}; + ${from.desktop} { + ${textEgyptian17}; + } + `, + highlightedText: css` + display: inline; + color: ${textColour}; + + ${highlightColour + ? ` + background: ${highlightColour}; + box-shadow: 2px 0 0 ${highlightColour}, -2px 0 0 ${highlightColour}; + box-decoration-break: clone; + ` + : ''} + + ${textEgyptianBold15}; + ${from.desktop} { + ${textEgyptianBold17}; + } + `, +}); + +export const BannerBody = (): JSX.Element | null => { + const { content, settings, isTabletOrAbove, isCollapsed } = useBanner(); + + const textColour = settings.containerSettings.textColor ?? ''; + const highlightColour = settings.highlightedTextSettings.highlightColour; + + const styles = getStyles(textColour, highlightColour); + + const mainOrMobileContent = isTabletOrAbove + ? content.mainContent + : content.mobileContent; + + return ( +
+ {!isCollapsed && + createBannerBodyCopy( + mainOrMobileContent.paragraphs, + mainOrMobileContent.highlightedText, + styles, + )} +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx new file mode 100644 index 00000000000..6c575314b97 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx @@ -0,0 +1,205 @@ +import { css } from '@emotion/react'; +import { + between, + from, + palette, + space, + until, +} from '@guardian/source/foundations'; +import { + LinkButton, + SvgArrowRightStraight, +} from '@guardian/source/react-components'; +import { enrichSupportUrl, getChoiceCardUrl } from '../../../../lib/tracking'; +import { ThreeTierChoiceCards } from '../../../../shared/ThreeTierChoiceCards'; +import { buttonStyles, buttonThemes } from '../../styles/buttonStyles'; +import { useBanner } from '../useBanner'; + +const styles = { + threeTierChoiceCardsContainer: css` + grid-area: choice-cards-container; + max-width: 100%; + + ${until.desktop} { + margin-top: -${space[6]}px; + } + ${from.phablet} { + max-width: 492px; // phabletContentMaxWidth + } + ${from.desktop} { + justify-self: end; + padding-right: ${space[8]}px; + width: 299px; + } + ${between.desktop.and.wide} { + width: 380px; + } + ${from.wide} { + align-self: start; + width: 380px; + } + `, + ctaContainer: (isCollapsed: boolean, backgroundColor: string) => css` + grid-area: cc_cta; + display: flex; + align-items: center; + flex-direction: column; + gap: ${space[4]}px; + margin-top: ${space[3]}px; + + .maybe-later & { + flex-direction: row; + flex-wrap: wrap; + padding: ${space[3]}px; + + ${from.phablet} { + flex-direction: row; + padding: ${space[3]}px 0; + } + } + + ${until.phablet} { + width: 100vw; + position: sticky; + bottom: 0; + padding: ${space[3]}px; + background-color: ${backgroundColor}; + box-shadow: 0 -${space[1]}px ${space[3]}px 0 rgba(0, 0, 0, 0.25); + margin-right: -${space[3]}px; + margin-left: -${space[3]}px; + + a { + flex: 1 1 0; + } + } + + ${between.phablet.and.desktop} { + bottom: 0; + margin-top: ${space[3]}px; + margin-bottom: ${space[6]}px; + a { + width: 100%; + } + > span { + width: auto; + } + } + + ${from.desktop} { + flex-direction: row; + margin-bottom: ${space[6]}px; + gap: ${space[2]}px; + margin-top: ${isCollapsed ? `${space[6]}px` : `${space[3]}px`}; + margin-right: 0; + margin-left: 0; + + .maybe-later & { + flex-wrap: nowrap; + } + + a { + width: 100%; + } + + > span { + width: auto; + } + } + `, + linkButtonStyles: css` + border-color: ${palette.brandAlt[400]}; + width: 100%; + `, + maybeLaterButtonSizing: css` + flex: 1 1 0; + ${from.desktop} { + width: 118px; + } + `, + maybeLaterButton: css` + width: 100%; + `, +}; + +export const BannerChoiceCards = (): JSX.Element | null => { + const { + settings, + isCollapsed, + choices, + selectedChoiceCard, + actions, + tracking, + promoCodes, + countryCode, + content, + isTabletOrAbove, + } = useBanner(); + + if (!settings.choiceCardSettings || !selectedChoiceCard || !choices) { + return null; + } + + const mainOrMobileContent = isTabletOrAbove + ? content.mainContent + : content.mobileContent; + + return ( +
+ {!isCollapsed && ( + + )} +
+ } + iconSide="right" + target="_blank" + rel="noopener noreferrer" + > + {isCollapsed + ? mainOrMobileContent.primaryCta?.ctaText + : 'Continue'} + + {isCollapsed && ( +
+ + Maybe later + +
+ )} +
+
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx new file mode 100644 index 00000000000..38400844463 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx @@ -0,0 +1,174 @@ +import { css } from '@emotion/react'; +import { from, space, until } from '@guardian/source/foundations'; +import { + Button, + LinkButton, + SvgChevronDownSingle, + SvgChevronUpSingle, + SvgCross, +} from '@guardian/source/react-components'; +import { buttonStyles, buttonThemes } from '../../styles/buttonStyles'; +import { useBanner } from '../useBanner'; + +const styles = { + closeButtonContainer: css` + grid-area: close-button; + ${until.phablet} { + padding-bottom: ${space[4]}px; + justify-self: end; + position: sticky; + top: 10px; + } + ${from.phablet} { + margin-top: ${space[2]}px; + padding-right: ${space[2]}px; + position: sticky; + } + ${from.desktop} { + margin-top: ${space[6]}px; + justify-self: end; + } + ${from.leftCol} { + justify-self: start; + padding-left: ${space[8]}px; + } + `, + closeAndCollapseButtonContainer: (isCollapsed: boolean) => css` + grid-area: close-button; + display: flex; + justify-content: space-between; + + ${until.phablet} { + flex-direction: row-reverse; + position: sticky; + top: ${space[2]}px; + } + ${from.phablet} { + flex-direction: row; + column-gap: ${space[0]}px; + padding-right: ${space[2]}px; + margin-top: ${space[2]}px; + } + ${from.desktop} { + flex-direction: ${isCollapsed ? 'row' : 'row-reverse'}; + margin-top: ${isCollapsed ? space[9] : space[6]}px; + } + + .maybe-later & { + flex-direction: row-reverse; + } + `, + closeButtonOverrides: css` + height: 40px; + min-height: 40px; + width: 40px; + min-width: 40px; + `, + closeABTestButtonOverrides: css` + justify-self: end; + width: max-content; + padding: 0; + border: 0; + text-decoration: underline; + font-weight: normal; + font-size: 16px; + border-radius: unset; + background-color: inherit; + ${from.desktop} { + margin-top: ${space[1]}px; + } + `, + iconOverrides: (background?: string, text?: string) => css` + background-color: ${background ?? 'inherit'}; + path { + fill: ${text ?? 'white'}; + } + margin-top: ${space[1]}px; + margin-right: ${space[1]}px; + + ${from.desktop} { + margin: 0; + } + `, + collapsableButtonContainer: css` + margin-left: ${space[2]}px; + ${from.desktop} { + margin: 0; + } + `, +}; + +export const BannerCloseButton = (): JSX.Element => { + const { isCollapsible, isCollapsed, settings, actions } = useBanner(); + + if (isCollapsible) { + return ( +
+
+ +
+ {isCollapsed && ( + + Close + + )} +
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx new file mode 100644 index 00000000000..2021a02063e --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx @@ -0,0 +1,39 @@ +import { css } from '@emotion/react'; +import { from, space } from '@guardian/source/foundations'; +import { useBanner } from '../useBanner'; + +const phabletContentMaxWidth = '492px'; + +const styles = { + contentContainer: css` + grid-area: copy-container; + + max-width: 100%; + align-self: start; + + ${from.phablet} { + max-width: ${phabletContentMaxWidth}; + } + ${from.desktop} { + padding-right: ${space[5]}px; + margin-bottom: ${space[2]}px; + } + ${from.leftCol} { + padding-left: ${space[3]}px; + } + `, +}; + +export const BannerContent = ({ + children, +}: { + children: React.ReactNode; +}): JSX.Element | null => { + const { design } = useBanner(); + + if (!design) { + return null; + } + + return
{children}
; +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx new file mode 100644 index 00000000000..48e15aa3c5d --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx @@ -0,0 +1,161 @@ +import { css } from '@emotion/react'; +import { from, space, until } from '@guardian/source/foundations'; +import { LinkButton } from '@guardian/source/react-components'; +import { SecondaryCtaType } from '@guardian/support-dotcom-components'; +import { buttonStyles, buttonThemes } from '../../styles/buttonStyles'; +import { useBanner } from '../useBanner'; + +const styles = { + /* ctas for use with main images */ + outerImageCtaContainer: css` + grid-area: cta-container; + + display: flex; + background-color: inherit; + align-items: center; + justify-content: stretch; + flex-direction: column; + gap: ${space[4]}px; + + ${until.phablet} { + width: 100vw; + position: sticky; + bottom: 0; + padding-top: ${space[2]}px; + padding-bottom: ${space[2]}px; + box-shadow: 0 -${space[1]}px ${space[3]}px 0 rgba(0, 0, 0, 0.25); + margin-right: -${space[3]}px; + margin-left: -${space[3]}px; + + a { + width: calc(100% - 24px); + } + } + ${from.phablet} { + justify-self: stretch; + align-items: start; + width: 100%; + margin-bottom: ${space[2]}px; + margin-left: 0px; + margin-right: 0px; + } + ${from.desktop} { + width: 100%; + flex-wrap: nowrap; + margin-bottom: ${space[2]}px; + } + ${from.leftCol} { + align-items: center; + } + `, + innerImageCtaContainer: css` + display: flex; + width: calc(100% - 24px); + flex-wrap: wrap; + flex-direction: row; + gap: ${space[2]}px; + justify-content: stretch; + margin: 0; + + > a { + flex: 1 0 100%; + justify-content: center; + } + + ${from.tablet} { + justify-content: center; + max-width: 100%; + } + + ${from.desktop} { + > a { + flex-direction: column; + flex: 1 0 50%; + justify-self: stretch; + } + flex-direction: row; + flex-wrap: nowrap; + justify-content: start; + } + `, +}; + +export const BannerCtas = (): JSX.Element | null => { + const { + content, + settings, + actions, + isTabletOrAbove, + selectedChoiceCard, + isCollapsed, + } = useBanner(); + + if (selectedChoiceCard) { + return null; + } + + const mainOrMobileContent = isTabletOrAbove + ? content.mainContent + : content.mobileContent; + + const { primaryCta, secondaryCta } = mainOrMobileContent; + + if (!primaryCta && !secondaryCta && !isCollapsed) { + return null; + } + + return ( +
+
+ {primaryCta && ( + + {primaryCta.ctaText} + + )} + {secondaryCta?.type === SecondaryCtaType.Custom && ( + + {secondaryCta.cta.ctaText} + + )} + {isCollapsed && ( + + Maybe later + + )} +
+
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerHeader.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerHeader.tsx new file mode 100644 index 00000000000..fd349dae0dc --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerHeader.tsx @@ -0,0 +1,147 @@ +import { css } from '@emotion/react'; +import { + from, + headlineMedium17, + headlineMedium24, + headlineMedium28, + headlineMedium34, + headlineMedium42, + neutral, + space, + until, +} from '@guardian/source/foundations'; +import type { JSX } from 'react'; +import { useBanner } from '../useBanner'; +import { BannerVisual } from './BannerVisual'; + +const getStyles = ( + textColour: string | undefined, + backgroundColour: string, + headlineSize: 'small' | 'medium' | 'large', + isCollapsed: boolean, + hasHeaderImage: boolean, + hasMainImage: boolean, +) => { + const color = textColour ?? neutral[0]; + const copyTopMargin = hasHeaderImage ? space[1] : space[1]; + const containerMargin = + isCollapsed || hasHeaderImage ? `${space[6]}px` : '0'; + + const mobileHeadlineSize = + headlineSize === 'small' || isCollapsed + ? `${headlineMedium17}` + : `${headlineMedium28}`; + + const phabletHeadline = isCollapsed + ? `${headlineMedium24}` + : `${headlineMedium34}`; + + const leftColHeadline = isCollapsed + ? `${headlineMedium24}` + : `${headlineMedium42}`; + + const phabletContentMaxWidth = '492px'; + + return { + container: css` + position: relative; + margin-bottom: ${containerMargin}; + `, + header: css` + h2 { + color: ${color}; + margin: ${copyTopMargin}px 0 ${space[2]}px 0; + + ${until.phablet} { + ${mobileHeadlineSize}; + } + + ${from.phablet} { + ${phabletHeadline}; + } + + ${from.leftCol} { + ${leftColHeadline}; + } + } + `, + headerContainer: css` + align-self: stretch; + justify-self: stretch; + + ${until.phablet} { + ${hasMainImage + ? '' + : `max-width: calc(100% - 40px - ${space[3]}px);`} + } + + ${from.phablet} { + background: ${backgroundColour}; + max-width: ${phabletContentMaxWidth}; + } + + ${from.desktop} { + padding-top: ${space[3]}px; + padding-right: ${space[5]}px; + } + `, + headerWithImageContainer: css` + max-width: 100%; + + ${from.tablet} { + max-width: ${phabletContentMaxWidth}; + } + + ${from.desktop} { + background: ${backgroundColour}; + padding-top: ${space[3]}px; + } + `, + }; +}; + +export const BannerHeader = (): JSX.Element | null => { + const { content, design, settings, isCollapsed, isTabletOrAbove } = + useBanner(); + + if (!design) { + return null; + } + + const headlineSize = design.fonts?.heading.size ?? 'medium'; + const styles = getStyles( + settings.headerSettings?.textColour, + settings.containerSettings.backgroundColour, + headlineSize, + isCollapsed, + !!settings.headerSettings?.headerImage, + !!settings.imageSettings, + ); + + const containerCss = settings.headerSettings?.headerImage + ? styles.headerWithImageContainer + : styles.headerContainer; + + return ( +
+
+
+ {settings.headerSettings?.headerImage && ( + + )} + {(content.mainContent.heading ?? + content.mobileContent.heading) && ( +

+ {isTabletOrAbove && !isCollapsed + ? content.mainContent.heading + : content.mobileContent.heading} +

+ )} +
+
+
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerLogo.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerLogo.tsx new file mode 100644 index 00000000000..128e4ff4cee --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerLogo.tsx @@ -0,0 +1,38 @@ +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 { useBanner } from '../useBanner'; + +const styles = { + guardianLogoContainer: css` + grid-area: logo; + + ${until.leftCol} { + display: none; + } + ${from.leftCol} { + justify-self: end; + width: 128px; + height: 41px; + justify-content: end; + margin-top: ${space[5]}px; + } + `, +}; + +export const BannerLogo = (): JSX.Element | null => { + const { design } = useBanner(); + + if (!design) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerReminder.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerReminder.tsx new file mode 100644 index 00000000000..d70919a7787 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerReminder.tsx @@ -0,0 +1,59 @@ +import { css } from '@emotion/react'; +import { space } from '@guardian/source/foundations'; +import { SecondaryCtaType } from '@guardian/support-dotcom-components'; +import { useContributionsReminderSignup } from '../../../../hooks/useContributionsReminderSignup'; +import { DesignableBannerReminderSignedOut } from '../../components/DesignableBannerReminderSignedOut'; +import { useBanner } from '../useBanner'; + +const styles = { + container: css` + grid-row: 4; + grid-column: 1 / span 2; + order: 5; + margin-top: ${space[3]}px; + `, +}; + +export const BannerReminder = (): JSX.Element | null => { + const { content, reminderTracking, settings, isTabletOrAbove } = + useBanner(); + + const mainOrMobileContent = isTabletOrAbove + ? content.mainContent + : content.mobileContent; + + const { secondaryCta } = mainOrMobileContent; + + const reminderFields = + secondaryCta?.type === SecondaryCtaType.ContributionsReminder + ? secondaryCta.reminderFields + : undefined; + + const { reminderStatus, createReminder } = useContributionsReminderSignup( + reminderFields?.reminderPeriod ?? '', + 'WEB', + 'BANNER', + 'PRE', + reminderFields?.reminderOption, + ); + + if (secondaryCta?.type !== SecondaryCtaType.ContributionsReminder) { + return null; + } + + const onReminderSetClick = (email: string) => { + reminderTracking.onReminderSetClick(); + createReminder(email); + }; + + return ( +
+ +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerTicker.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerTicker.tsx new file mode 100644 index 00000000000..d9fb56f4d00 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerTicker.tsx @@ -0,0 +1,30 @@ +import { Ticker } from '@guardian/source-development-kitchen/react-components'; +import { templateSpacing } from '../../styles/templateStyles'; +import { useBanner } from '../useBanner'; + +export const BannerTicker = (): JSX.Element | null => { + const { tickerSettings, settings, isCollapsed } = useBanner(); + + if ( + !tickerSettings?.tickerData || + isCollapsed || + !settings.tickerStylingSettings + ) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx new file mode 100644 index 00000000000..d2d3c2fe8da --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx @@ -0,0 +1,164 @@ +import { css } from '@emotion/react'; +import { between, from, space } from '@guardian/source/foundations'; +import type { Image } from '@guardian/support-dotcom-components/dist/shared/types'; +import type { ImageAttrs } from '../../../../shared/ResponsiveImage'; +import { ResponsiveImage } from '../../../../shared/ResponsiveImage'; +import { useBanner } from '../useBanner'; + +interface BannerVisualProps { + settings?: Image; + isHeaderImage?: boolean; +} + +const getImageUrl = ( + isCollapsed: boolean, + collapsedUrl: string | undefined, + originalUrl: string, +): string => { + if (isCollapsed) { + return collapsedUrl ?? originalUrl; + } + return originalUrl; +}; + +const getStyles = (isHeaderImage = false) => { + if (isHeaderImage) { + return { + container: css` + height: 100%; + width: 100%; + + img { + height: 100%; + object-fit: contain; + display: block; + width: 100%; + ${between.phablet.and.desktop} { + width: 492px; + } + } + `, + }; + } + return { + container: css` + grid-area: main-image; + display: block; + width: calc(100% + 20px); + margin-left: -10px; + margin-right: -10px; + + img { + width: 100%; + object-fit: contain; + display: block; + + ${from.phablet} { + max-height: 332px; + padding-bottom: 16px; + } + ${from.desktop} { + max-height: none; + padding-bottom: 0; + } + } + + ${from.tablet} { + height: 100%; + width: 100%; + align-items: center; + } + + ${from.phablet} { + max-width: 492px; // phabletContentMaxWidth + justify-self: center; + } + ${from.desktop} { + margin-top: ${space[6]}px; + padding-left: ${space[2]}px; + justify-self: end; + } + ${between.desktop.and.wide} { + max-width: 380px; + } + ${from.wide} { + max-width: 485px; + align-self: start; + } + `, + }; +}; + +export const BannerVisual = ({ + settings, + isHeaderImage, +}: BannerVisualProps): JSX.Element | null => { + const { settings: bannerSettings, isCollapsed } = useBanner(); + + const imageSettings = settings ?? bannerSettings.imageSettings; + + if (!imageSettings || (isCollapsed && !isHeaderImage)) { + return null; + } + + const baseImage: ImageAttrs = { + url: imageSettings.mainUrl, + media: '', + alt: imageSettings.altText, + }; + + const images: ImageAttrs[] = []; + const styles = getStyles(isHeaderImage); + + if (imageSettings.mobileUrl) { + images.push({ + url: imageSettings.mobileUrl, + media: '(max-width: 739px)', + }); + } + if (imageSettings.tabletUrl) { + images.push({ + url: getImageUrl( + isCollapsed, + imageSettings.mobileUrl, + imageSettings.tabletUrl, + ), + media: '(max-width: 979px)', + }); + } + if (imageSettings.desktopUrl) { + images.push({ + url: getImageUrl( + isCollapsed, + imageSettings.tabletUrl, + imageSettings.desktopUrl, + ), + media: '(max-width: 1139px)', + }); + } + if (imageSettings.leftColUrl) { + images.push({ + url: imageSettings.leftColUrl, + media: '(max-width: 1299px)', + }); + } + if (imageSettings.wideUrl) { + images.push({ + url: getImageUrl( + isCollapsed, + imageSettings.tabletUrl, + imageSettings.wideUrl, + ), + media: '', + }); + } + + return ( + + ); +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/index.ts b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/index.ts new file mode 100644 index 00000000000..5ee91650a6d --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/index.ts @@ -0,0 +1,17 @@ +export { + DesignableBanner as Banner, + DesignableBannerUnvalidated as BannerUnvalidated, + BannerComponent, +} from './Banner'; +export { BannerHeader } from './components/BannerHeader'; +export { BannerBody } from './components/BannerBody'; +export { BannerVisual } from './components/BannerVisual'; +export { BannerCtas } from './components/BannerCtas'; +export { BannerCloseButton } from './components/BannerCloseButton'; +export { BannerChoiceCards } from './components/BannerChoiceCards'; +export { BannerTicker } from './components/BannerTicker'; +export { BannerArticleCount } from './components/BannerArticleCount'; +export { BannerLogo } from './components/BannerLogo'; +export { BannerContent } from './components/BannerContent'; +export { BannerReminder } from './components/BannerReminder'; +export { useBanner } from './useBanner'; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx new file mode 100644 index 00000000000..ff3f3a806f6 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx @@ -0,0 +1,786 @@ +import type { OphanComponentType } from '@guardian/libs'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { + BannerArticleCount, + BannerBody, + BannerChoiceCards, + BannerCloseButton, + BannerComponent, + BannerContent, + BannerCtas, + BannerHeader, + BannerLogo, + BannerReminder, + BannerTicker, + BannerVisual, +} from '../index'; + +const meta: Meta = { + title: 'Components/Marketing/DesignableBannerV2', + component: BannerComponent, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +type Story = StoryObj; + +const hex = (r: string, g: string, b: string) => ({ + r, + g, + b, + kind: 'hex' as const, +}); + +const stringToHexColour = (hexStr: string) => { + const r = hexStr.substring(0, 2); + const g = hexStr.substring(2, 4); + const b = hexStr.substring(4, 6); + return hex(r, g, b); +}; + +const design = { + colours: { + basic: { + background: hex('F1', 'F8', 'FC'), + bodyText: hex('00', '00', '00'), + headerText: hex('00', '00', '00'), + articleCountText: hex('00', '00', '00'), + logo: hex('00', '00', '00'), + }, + primaryCta: { + default: { + background: hex('FF', 'E5', '00'), + text: hex('05', '29', '62'), + }, + }, + secondaryCta: { + default: { + background: hex('F1', 'F8', 'FC'), + text: hex('00', '00', '00'), + border: hex('00', '00', '00'), + }, + }, + highlightedText: { + text: hex('00', '00', '00'), + highlight: hex('FF', 'E5', '00'), + }, + closeButton: { + default: { + background: hex('E6', 'EC', 'EF'), + text: hex('00', '00', '00'), + border: hex('E6', 'EC', 'EF'), + }, + }, + ticker: { + filledProgress: hex('05', '29', '62'), + progressBarBackground: hex('cc', 'cc', 'cc'), + headlineColour: hex('05', '29', '62'), + totalColour: hex('05', '29', '62'), + goalColour: hex('05', '29', '62'), + }, + }, + fonts: { + heading: { + size: 'medium' as const, + }, + }, +}; + +const content = { + mainContent: { + heading: Show your support for reader-funded journalism, + paragraphs: [ + + Fearless, investigative reporting shapes a fairer world. At the + Guardian, our independence allows us to chase the truth wherever + it takes us. We have no shareholders. No vested + interests. Just the determination and passion to bring readers + quality reporting, including groundbreaking investigations. + , + + We do not shy away. And we provide all this for free, for + everyone. + , + ], + highlightedText: ( + + Show your support today from just £1, or sustain us long term + with a little more. Thank you. + + ), + primaryCta: { + ctaText: 'Support once', + ctaUrl: 'https://support.theguardian.com/contribute/one-off', + }, + secondaryCta: { + type: 'Custom' as const, + cta: { + ctaText: 'Support monthly', + ctaUrl: 'https://support.theguardian.com/contribute/recurring', + }, + }, + }, + mobileContent: { + heading: Show your support for reader-funded journalism, + paragraphs: [ + + Fearless, investigative reporting shapes a fairer world. At the + Guardian, our independence allows us to chase the truth wherever + it takes us. We have no shareholders. No vested + interests. Just the determination and passion to bring readers + quality reporting, including groundbreaking investigations. + , + + We do not shy away. And we provide all this for free, for + everyone. + , + ], + highlightedText: ( + + Show your support today from just £1, or sustain us long term + with a little more. Thank you. + + ), + primaryCta: { + ctaText: 'Support us', + ctaUrl: 'https://support.theguardian.com/contribute/one-off', + }, + secondaryCta: { + type: 'Custom' as const, + cta: { + ctaText: 'Learn more', + ctaUrl: 'https://support.theguardian.com/contribute/recurring', + }, + }, + }, +}; + +const tracking = { + ophanPageId: 'kbluzw2csbf83eabedel', + componentType: 'ACQUISITIONS_ENGAGEMENT_BANNER' as OphanComponentType, + platformId: 'GUARDIAN_WEB', + referrerUrl: 'http://localhost:3030/Article', + abTestName: 'UsEoyAppealBannerSupporters', + abTestVariant: 'control', + campaignCode: 'UsEoyAppealBanner_control', + products: [], +}; + +const tickerSettings = { + currencySymbol: '£', + copy: { + countLabel: '', + goalCopy: 'Goal', + }, + tickerData: { + total: 500000, + goal: 1000000, + }, + name: 'US' as const, +}; + +const headerImage = { + mobileUrl: + 'https://i.guim.co.uk/img/media/036510bc15ecdba97355f464006e3db5fbde9129/0_0_620_180/master/620.jpg?width=310&height=90&quality=100&s=01c604815a2f9980a1227c0d91ffa6b1', + tabletUrl: + 'https://i.guim.co.uk/img/media/7030f9d98e368d6e5c7a34c643c76d7d1f5ac63c/0_0_1056_366/master/1056.jpg?width=528&height=183&quality=100&s=f0c02cddda84dfaf4ef261d91bd26159', + desktopUrl: + 'https://i.guim.co.uk/img/media/3c1cb611785d3dccc2674636a6f692da1e2fcdb6/0_0_1392_366/master/1392.jpg?width=696&height=183&quality=100&s=5935c1ae5e8cbc5d9ed616bbadb3b09e', + altText: "Guardian: Our Planet can't Speak for itself", +}; + +const regularImage = { + kind: 'Image' as const, + 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: 'Example alt text', +}; + +const choiceCardsSettings = { + choiceCards: [ + { + product: { + supportTier: 'Contribution' as const, + ratePlan: 'Monthly' as const, + }, + label: 'Support £5/month', + isDefault: false, + benefits: [ + { + copy: 'Give to the Guardian every month with Support', + }, + ], + }, + { + product: { + supportTier: 'SupporterPlus' as const, + ratePlan: 'Monthly' as const, + }, + label: 'Support £12/month', + isDefault: true, + benefitsLabel: + 'Unlock All-access digital benefits:', + benefits: [ + { + copy: 'Unlimited access to the Guardian app', + }, + { copy: 'Unlimited access to our new Feast App' }, + { copy: 'Ad-free reading on all your devices' }, + { + copy: 'Exclusive newsletter for supporters, sent every week from the Guardian newsroom', + }, + { copy: 'Far fewer asks for support' }, + ], + pill: { + copy: 'Recommended', + }, + }, + { + product: { + supportTier: 'OneOff' as const, + }, + label: `Support with another amount`, + isDefault: false, + benefits: [ + { + copy: 'We welcome support of any size, any time', + }, + ], + }, + ], + mobileChoiceCards: [ + { + product: { + supportTier: 'Contribution' as const, + ratePlan: 'Monthly' as const, + }, + label: 'Support £5/month', + isDefault: false, + benefits: [ + { + copy: 'Give to the Guardian every month with Support', + }, + ], + }, + { + product: { + supportTier: 'SupporterPlus' as const, + ratePlan: 'Monthly' as const, + }, + label: 'Support £12/month', + isDefault: true, + benefitsLabel: 'Unlock All-access digital benefits:', + benefits: [ + { copy: 'Unlimited access to the Guardian app' }, + { copy: 'Unlimited access to our new Feast App' }, + ], + pill: { + copy: 'Recommended', + }, + }, + { + product: { + supportTier: 'OneOff' as const, + }, + label: `Support with another amount`, + isDefault: false, + benefits: [ + { + copy: 'We welcome support of any size, any time', + }, + ], + }, + ], +}; + +const choiceCardsWithMixedDestinations = { + choiceCards: [ + { + product: { + supportTier: 'Contribution' as const, + ratePlan: 'Monthly' as const, + }, + label: 'Support £5/month', + isDefault: false, + destination: 'LandingPage' as const, + benefits: [ + { + copy: 'Give to the Guardian every month with Support', + }, + ], + }, + { + product: { + supportTier: 'SupporterPlus' as const, + ratePlan: 'Monthly' as const, + }, + label: 'Support £12/month', + isDefault: true, + destination: 'Checkout' as const, + benefitsLabel: + 'Unlock All-access digital benefits:', + benefits: [ + { + copy: 'Unlimited access to the Guardian app', + }, + { copy: 'Unlimited access to our new Feast App' }, + { copy: 'Ad-free reading on all your devices' }, + { + copy: 'Exclusive newsletters for subscribers', + }, + ], + }, + { + product: { + supportTier: 'OneOff' as const, + }, + label: 'One-time support', + isDefault: false, + destination: 'Checkout' as const, + benefits: [ + { + copy: 'Support the Guardian with a one-time contribution', + }, + ], + }, + ], +}; + +const contentNoHeading = { + ...content, + mainContent: { + ...content.mainContent, + heading: null, + }, + mobileContent: { + ...content.mobileContent, + heading: null, + }, +}; + +const contentWithReminder = { + ...content, + mainContent: { + ...content.mainContent, + secondaryCta: { + type: 'ContributionsReminder' as const, + }, + }, +}; + +export const Default: Story = { + name: 'Basic DesignableBanner', + render: (args) => ( + + + + + + + + + + + ), + args: { + design: design as any, + content: content as any, + tracking, + articleCounts: { + forTargetedWeeks: 0, + for52Weeks: 0, + }, + submitComponentEvent: () => Promise.resolve(), + onCloseClick: () => { + /* close */ + }, + onCollapseClick: () => { + /* collapse */ + }, + onExpandClick: () => { + /* expand */ + }, + onCtaClick: () => { + /* cta */ + }, + onSecondaryCtaClick: () => { + /* secondary cta */ + }, + onNotNowClick: () => { + /* not now */ + }, + reminderTracking: { + onReminderCtaClick: () => { + /* reminder cta */ + }, + onReminderSetClick: () => { + /* reminder set */ + }, + onReminderCloseClick: () => { + /* reminder close */ + }, + }, + bannerChannel: 'contributions' as const, + }, +}; + +export const WithThreeTierChoiceCards: Story = { + name: 'With three tier choice cards', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...Default.args, + design: { + ...design, + visual: { + kind: 'ChoiceCards', + buttonColour: stringToHexColour('F1F8FC'), + }, + } as any, + tracking: { + ...tracking, + abTestVariant: 'THREE_TIER_CHOICE_CARDS', + }, + choiceCardsSettings: choiceCardsSettings as any, + }, +}; + +export const ThreeTierChoiceCardsWithHeaderImageAndCopy: Story = { + name: 'With three tier choice cards + header image + header copy', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + design: { + ...design, + headerImage, + visual: { + kind: 'ChoiceCards', + buttonColour: stringToHexColour('F1F8FC'), + }, + } as any, + }, +}; + +export const HeaderImageOnly: Story = { + name: 'With header image and no header copy', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + content: contentNoHeading as any, + design: { + ...design, + headerImage, + visual: { + kind: 'ChoiceCards', + buttonColour: stringToHexColour('FFFFFF'), + }, + colours: { + ...design.colours, + basic: { + ...design.colours.basic, + background: stringToHexColour('FFFFFF'), + }, + }, + } as any, + }, +}; + +export const MainImage: Story = { + name: 'With main image', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...Default.args, + design: { + ...design, + visual: regularImage, + } as any, + tracking: { + ...tracking, + abTestVariant: 'MAIN_IMAGE', + }, + }, +}; + +export const WithTickerAndThreeTierChoiceCards: Story = { + name: 'With ticker + three tier choice cards', + render: (args) => ( + + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + tickerSettings: tickerSettings as any, + }, +}; + +export const WithThreeTierChoiceCardsAndArticleCount: Story = { + name: 'With article count + three tier choice cards', + render: (args) => ( + + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + separateArticleCountSettings: { + type: 'above', + } as any, + }, +}; + +export const NoChoiceCardOrImage: Story = { + name: 'With no choice cards or image', + render: (args) => ( + + + + + + + + + + ), + args: { + ...Default.args, + design: { + ...design, + visual: undefined, + } as any, + }, +}; + +export const CollapsableWithThreeTierChoiceCards: Story = { + name: 'Collapsable with three tier choice cards', + render: (args) => ( + + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + tickerSettings: tickerSettings as any, + separateArticleCountSettings: { + type: 'above', + } as any, + isCollapsible: true, + }, +}; + +export const CollapsableWithMainImage: Story = { + name: 'Collapsable with main image', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...MainImage.args, + isCollapsible: true, + }, +}; + +export const WithReminder: Story = { + name: 'With contributions reminder', + render: (args) => ( + + + + + + + + + + + + ), + args: { + ...Default.args, + content: contentWithReminder as any, + }, +}; + +export const CollapsableMaybeLaterVariant: Story = { + name: 'Collapsable - Maybe later variant', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...CollapsableWithMainImage.args, + }, +}; + +export const CollapsableWithThreeTierChoiceCardsMaybeLaterVariant: Story = { + name: 'Collapsable with three tier choice cards - Maybe later variant', + render: (args) => ( + + + + + + + + + + + + ), + args: { + ...CollapsableWithThreeTierChoiceCards.args, + }, +}; + +export const WithMixedDestinations: Story = { + name: 'With destinationUrl on all choice cards', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + choiceCardsSettings: choiceCardsWithMixedDestinations as any, + }, +}; + +export const DesignThreeAnimatedHeaderImage: Story = { + name: 'With animated header image', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + content: contentNoHeading as any, + design: { + ...design, + headerImage: { + mobileUrl: + 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', + tabletUrl: + 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', + desktopUrl: + 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', + altText: 'Guardian: News provider of the year', + }, + visual: { + kind: 'ChoiceCards', + buttonColour: stringToHexColour('FFFFFF'), + }, + colours: { + ...design.colours, + basic: { + ...design.colours.basic, + background: stringToHexColour('FFFFFF'), + }, + }, + } as any, + }, +}; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx new file mode 100644 index 00000000000..7eb539abfea --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx @@ -0,0 +1,306 @@ +import type { OphanComponentType } from '@guardian/libs'; +import type { + ConfigurableDesign, + HexColour, + TickerName, +} from '@guardian/support-dotcom-components/dist/shared/types'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { BannerRenderProps } from '../../../common/types'; +import { + BannerArticleCount, + BannerBody, + BannerChoiceCards, + BannerCloseButton, + BannerComponent, + BannerContent, + BannerCtas, + BannerHeader, + BannerTicker, +} from '../index'; + +jest.mock('../../../../../../lib/useMatchMedia', () => ({ + useMatchMedia: jest.fn(() => true), + removeMediaRulePrefix: jest.fn(() => ''), +})); + +const hex = (r: string, g: string, b: string): HexColour => + ({ + r, + g, + b, + kind: 'hex', + }) as any; + +const mockProps: BannerRenderProps = { + onCtaClick: jest.fn(), + onSecondaryCtaClick: jest.fn(), + onNotNowClick: jest.fn(), + onCloseClick: jest.fn(), + onCollapseClick: jest.fn(), + onExpandClick: jest.fn(), + submitComponentEvent: jest.fn(() => Promise.resolve()), + reminderTracking: { + onReminderCtaClick: jest.fn(), + onReminderSetClick: jest.fn(), + onReminderCloseClick: jest.fn(), + }, + content: { + mainContent: { + heading: Main Heading, + paragraphs: [Main Paragraph 1], + highlightedText: Main Highlighted, + primaryCta: { + ctaText: 'Main CTA', + ctaUrl: 'https://example.com/main', + }, + secondaryCta: null, + }, + mobileContent: { + heading: Mobile Heading, + paragraphs: [Mobile Paragraph 1], + highlightedText: null, + primaryCta: { + ctaText: 'Mobile CTA', + ctaUrl: 'https://example.com/mobile', + }, + secondaryCta: null, + }, + }, + tracking: { + abTestName: 'test', + abTestVariant: 'variant', + campaignCode: 'campaign', + componentType: 'ACQUISITIONS_HEADER' as OphanComponentType, + products: [], + ophanPageId: 'page-id', + platformId: 'platform-id', + referrerUrl: 'referrer-url', + }, + articleCounts: { + forTargetedWeeks: 0, + for52Weeks: 0, + }, + design: { + colours: { + basic: { + background: hex('F6', 'F6', 'F6'), + bodyText: hex('12', '12', '12'), + headerText: hex('12', '12', '12'), + articleCountText: hex('12', '12', '12'), + logo: hex('05', '29', '62'), + }, + primaryCta: { + default: { + background: hex('05', '29', '62'), + text: hex('FF', 'FF', 'FF'), + }, + }, + secondaryCta: { + default: { + background: hex('FF', 'FF', 'FF'), + text: hex('05', '29', '62'), + border: hex('05', '29', '62'), + }, + }, + highlightedText: { + text: hex('12', '12', '12'), + highlight: hex('FF', 'E5', '00'), + }, + closeButton: { + default: { + background: hex('FF', 'FF', 'FF'), + text: hex('05', '29', '62'), + border: hex('05', '29', '62'), + }, + }, + ticker: { + filledProgress: hex('05', '29', '62'), + progressBarBackground: hex('FF', 'FF', 'FF'), + headlineColour: hex('12', '12', '12'), + totalColour: hex('05', '29', '62'), + goalColour: hex('05', '29', '62'), + }, + }, + } as ConfigurableDesign, + bannerChannel: 'contributions', +}; + +describe('DesignableBanner V2', () => { + it('renders the banner with heading and body', () => { + render( + + + + + + + , + ); + + expect(screen.getByText('Main Heading')).toBeInTheDocument(); + expect(screen.getByText('Main Paragraph 1')).toBeInTheDocument(); + expect(screen.getByText('Main Highlighted')).toBeInTheDocument(); + }); + + it('calls onCtaClick when the primary CTA is clicked', () => { + render( + + + + + , + ); + + const cta = screen.getByText('Main CTA'); + fireEvent.click(cta); + + expect(mockProps.onCtaClick).toHaveBeenCalled(); + }); + + it('calls onCloseClick when the close button is clicked', () => { + render( + + + , + ); + + const closeButton = screen.getByRole('button', { name: /Close/i }); + fireEvent.click(closeButton); + + expect(mockProps.onCloseClick).toHaveBeenCalled(); + }); + + it('renders as collapsed when isCollapsible is true', () => { + render( + + + + + + , + ); + + // When collapsed, the body is usually hidden or different + // Based on BannerBody.tsx: {!isCollapsed && (...)} + expect(screen.queryByText('Main Paragraph 1')).not.toBeInTheDocument(); + // Header renders mobile content when collapsed + expect(screen.getByText('Mobile Heading')).toBeInTheDocument(); + }); + + it('toggles collapse when the toggle button is clicked', () => { + render( + + + + + , + ); + + expect(screen.queryByText('Main Paragraph 1')).not.toBeInTheDocument(); + + const toggleButton = screen.getByRole('button', { + name: /Expand banner/i, + }); + fireEvent.click(toggleButton); + + expect(screen.getByText('Main Paragraph 1')).toBeInTheDocument(); + expect(mockProps.onExpandClick).toHaveBeenCalled(); + }); + + it('renders the ticker when tickerSettings are provided', () => { + const tickerProps: BannerRenderProps = { + ...mockProps, + tickerSettings: { + name: 'US_2024' as TickerName, + tickerData: { total: 100, goal: 200 }, + currencySymbol: '£', + copy: { + countLabel: 'Total raised', + goalCopy: 'Goal', + }, + }, + }; + + render( + + + , + ); + + // Ticker is a complex component, we just check if the container is there + // or if we should mock it. For now, let's see if it renders something recognizable. + expect(screen.getByText('Total raised')).toBeInTheDocument(); + }); + + it('renders choice cards when choiceCardSettings are provided', () => { + const choiceCardProps: BannerRenderProps = { + ...mockProps, + choiceCardsSettings: { + choiceCards: [ + { + product: { + supportTier: 'Contribution', + ratePlan: 'Monthly', + }, + label: '£10', + isDefault: true, + benefits: [], + }, + { + product: { + supportTier: 'Contribution', + ratePlan: 'Monthly', + }, + label: '£20', + isDefault: false, + benefits: [], + }, + ], + } as any, + design: { + ...mockProps.design!, + visual: { + kind: 'ChoiceCards', + buttonColour: hex('FF', 'FF', 'FF'), + buttonTextColour: hex('00', '00', '00'), + buttonBorderColour: hex('00', '00', '00'), + buttonSelectColour: hex('00', '00', '00'), + buttonSelectTextColour: hex('FF', 'FF', 'FF'), + buttonSelectBorderColour: hex('00', '00', '00'), + } as any, + }, + }; + + render( + + + , + ); + + expect(screen.getByText('£10')).toBeInTheDocument(); + expect(screen.getByText('£20')).toBeInTheDocument(); + }); + + it('renders article count when separateArticleCount is true', () => { + const articleCountProps: BannerRenderProps = { + ...mockProps, + separateArticleCount: true, + articleCounts: { + forTargetedWeeks: 5, + for52Weeks: 10, + }, + }; + + render( + + + , + ); + + // We look for the article count text + // Usually it's something like "You've read 5 articles..." + expect(screen.getByText(/You've read/)).toBeInTheDocument(); + expect(screen.getByText(/5/)).toBeInTheDocument(); + }); +}); diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/useBanner.ts b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/useBanner.ts new file mode 100644 index 00000000000..131478684b5 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/useBanner.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { BannerContext, type BannerContextType } from './BannerContext'; + +export const useBanner = (): BannerContextType => { + const context = useContext(BannerContext); + if (!context) { + throw new Error( + 'Banner sub-components must be rendered within a provider', + ); + } + return context; +}; diff --git a/dotcom-rendering/src/components/marketing/banners/utils/withCloseable.tsx b/dotcom-rendering/src/components/marketing/banners/utils/withCloseable.tsx index 639fa03ab1e..deb6a6aa581 100644 --- a/dotcom-rendering/src/components/marketing/banners/utils/withCloseable.tsx +++ b/dotcom-rendering/src/components/marketing/banners/utils/withCloseable.tsx @@ -11,6 +11,7 @@ import { setChannelClosedTimestamp } from './localStorage'; export interface CloseableBannerProps extends BannerProps { onClose: () => void; + children?: React.ReactNode; } const withCloseable = ( diff --git a/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx b/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx index 6930bd0b954..70291b5a6e8 100644 --- a/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx +++ b/dotcom-rendering/src/components/marketing/shared/ThreeTierChoiceCards.tsx @@ -117,7 +117,7 @@ type ThreeTierChoiceCardsProps = { setSelectedChoiceCard: Dispatch>; choices: ChoiceCard[]; id: 'epic' | 'banner'; // uniquely identify this choice cards component to avoid conflicting with others - submitComponentEvent?: (componentEvent: ComponentEvent) => void; + submitComponentEvent?: (componentEvent: ComponentEvent) => Promise; choiceCardSettings?: ChoiceCardSettings; }; From 687aeb8cdf6688a6a036991814d1da70776daade Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Fri, 16 Jan 2026 16:38:13 +0000 Subject: [PATCH 2/7] refactor(designable-banner-v2): add close handling, tracking integration, and layout improvements - Add close button functionality with channel timestamp storage and custom event dispatch - Integrate tracking handlers for close, collapse, expand, and CTA clicks - Add isOpen state to control banner visibility - Reset selectedChoiceCard when choiceCards change - Initialize isCollapsed to false instead of using isCollapsableBanner - Create combined handlers that run both tracking and prop callbacks --- .../banners/designableBanner/v2/Banner.tsx | 500 +++++++++++------- .../designableBanner/v2/componentIds.ts | 14 + .../v2/components/BannerBody.tsx | 28 +- .../v2/components/BannerChoiceCards.tsx | 23 +- .../v2/components/BannerCloseButton.tsx | 18 - .../v2/components/BannerContent.tsx | 1 - .../v2/components/BannerCtas.tsx | 12 +- .../v2/components/BannerVisual.tsx | 20 +- .../v2/stories/DesignableBannerV2.stories.tsx | 14 +- .../designableBanner/v2/tests/Banner.test.tsx | 20 +- 10 files changed, 401 insertions(+), 249 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/componentIds.ts diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx index 3941eb11c7b..f8c9bf9973d 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx @@ -1,5 +1,11 @@ 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 { BannerDesignHeaderImage, @@ -14,13 +20,16 @@ import { useMatchMedia, } from '../../../../../lib/useMatchMedia'; import { getChoiceCards } from '../../../lib/choiceCards'; +import { createClickEventFromTracking } from '../../../lib/tracking'; import { bannerWrapper, validatedBannerWrapper, } from '../../common/BannerWrapper'; import type { BannerRenderProps } from '../../common/types'; +import { setChannelClosedTimestamp } from '../../utils/localStorage'; import type { BannerTemplateSettings, ChoiceCardSettings } from '../settings'; import { BannerContext, type BannerContextType } from './BannerContext'; +import { getComponentIds } from './componentIds'; import { BannerArticleCount } from './components/BannerArticleCount'; import { BannerBody } from './components/BannerBody'; import { BannerChoiceCards } from './components/BannerChoiceCards'; @@ -115,6 +124,196 @@ interface BannerComponentProps extends BannerRenderProps { children?: React.ReactNode; } +const phabletContentMaxWidth = '492px'; + +const styles = { + outerContainer: (background: string, textColor: string = 'inherit') => css` + background: ${background}; + color: ${textColor}; + bottom: 0px; + max-height: 65vh; + max-height: 65svh; + + * { + box-sizing: border-box; + } + ${from.phablet} { + border-top: 1px solid ${neutral[0]}; + } + b, + strong { + font-weight: bold; + } + padding: 0 auto; + `, + layoutOverrides: (cardsImageOrSpaceTemplateString: string) => css` + display: grid; + background: inherit; + position: relative; + bottom: 0px; + + /* mobile first */ + ${until.phablet} { + max-width: 660px; + margin: 0 auto; + padding: ${space[3]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: auto max(${phabletContentMaxWidth} auto); + grid-template-areas: + '. . .' + '. copy-container close-button' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString}' + '. cta-container cta-container'; + } + ${from.phablet} { + max-width: 740px; + margin: 0 auto; + padding: ${space[3]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: minmax(0, 0.5fr) ${phabletContentMaxWidth} max-content minmax( + 0, + 0.5fr + ); + grid-template-rows: auto auto auto; + grid-template-areas: + '. copy-container close-button close-button' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString} .' + '. cta-container cta-container .'; + } + ${from.desktop} { + max-width: 980px; + align-self: stretch; + padding: ${space[3]}px ${space[1]}px 0 ${space[3]}px; + grid-template-columns: auto 380px auto; + grid-template-rows: auto auto; + + grid-template-areas: + 'copy-container ${cardsImageOrSpaceTemplateString} close-button' + 'cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.leftCol} { + max-width: 1140px; + bottom: 0px; + /* the vertical line aligns with that of standard article */ + grid-column-gap: 10px; + grid-template-columns: 140px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.wide} { + max-width: 1300px; + /* the vertical line aligns with that of standard article */ + grid-template-columns: 219px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + `, + collapsedLayoutOverrides: (cardsImageOrSpaceTemplateString: string) => css` + display: grid; + background: inherit; + position: relative; + bottom: 0px; + + /* mobile first */ + ${until.phablet} { + max-width: 660px; + margin: 0 auto; + padding: ${space[2]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: auto max(${phabletContentMaxWidth} auto); + grid-template-areas: ${` + '. . .' + '. copy-container close-button' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString}' + '. cta-container cta-container' + `}; + } + ${from.phablet} { + max-width: 740px; + margin: 0 auto; + padding: ${space[2]}px ${space[3]}px 0 ${space[3]}px; + grid-template-columns: + minmax(0, 0.5fr) + ${phabletContentMaxWidth} + 1fr + 0; + grid-template-rows: auto auto; + grid-template-areas: + '. copy-container close-button .' + '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString} .' + '. cta-container cta-container .'; + } + ${from.desktop} { + max-width: 980px; + padding: ${space[1]}px ${space[1]}px 0 ${space[3]}px; + grid-template-columns: auto 380px minmax(100px, auto); + grid-template-rows: auto auto; + + grid-template-areas: + 'copy-container ${cardsImageOrSpaceTemplateString} close-button' + 'cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.leftCol} { + max-width: 1140px; + bottom: 0px; + /* the vertical line aligns with that of standard article */ + grid-column-gap: 10px; + grid-template-columns: 140px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button ' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + ${from.wide} { + max-width: 1300px; + /* the vertical line aligns with that of standard article */ + grid-template-columns: 219px 1px min(460px) min(380px) auto; + grid-template-rows: auto auto; + grid-template-areas: + 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button ' + '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; + } + `, + bannerVisualContainer: css` + grid-area: main-image; + + margin-left: ${space[2]}px; + margin-right: ${space[2]}px; + + ${from.phablet} { + max-width: ${phabletContentMaxWidth}; + justify-self: center; + } + ${from.desktop} { + margin-top: ${space[6]}px; + padding-left: ${space[2]}px; + justify-self: end; + } + ${between.desktop.and.wide} { + max-width: 380px; + } + ${from.wide} { + max-width: 485px; + align-self: start; + } + `, + verticalLine: css` + grid-area: vert-line; + pointer-events: none; + + ${until.leftCol} { + display: none; + } + ${from.leftCol} { + background-color: ${neutral[0]}; + width: 1px; + opacity: 0.2; + margin: ${space[6]}px ${space[2]}px 0 ${space[2]}px; + } + `, +}; + const Banner = ({ content, onCloseClick, @@ -139,6 +338,7 @@ const Banner = ({ }: BannerComponentProps): JSX.Element | null => { const isTabletOrAbove = useMatchMedia(removeMediaRulePrefix(from.tablet)); const bannerRef = useRef(null); + const [isOpen, setIsOpen] = useState(true); useEffect(() => { if (bannerRef.current) { @@ -157,13 +357,30 @@ const Banner = ({ ChoiceCard | undefined >(defaultChoiceCard); + // Reset selectedChoiceCard when choiceCards change + useEffect(() => { + if (!choiceCards || choiceCards.length === 0) { + setSelectedChoiceCard(undefined); + } + }, [choiceCards]); + const isCollapsableBanner: boolean = isCollapsible ?? (tracking.abTestVariant.includes('COLLAPSABLE_V1') || tracking.abTestVariant.includes('COLLAPSABLE_V2_MAYBE_LATER')); - const [isCollapsed, setIsCollapsed] = - useState(isCollapsableBanner); + const [isCollapsed, setIsCollapsed] = useState(false); + + const handleClose = useCallback((): void => { + setChannelClosedTimestamp(bannerChannel); + setIsOpen(false); + document.body.focus(); + document.dispatchEvent( + new CustomEvent('banner:close', { + detail: { bannerId: 'designable-banner' }, + }), + ); + }, [bannerChannel]); const handleToggleCollapse = useCallback(() => { const nextCollapsed = !isCollapsed; @@ -255,6 +472,91 @@ const Banner = ({ }; }, [design]); + // Create tracking handlers that always run + const componentIds = getComponentIds('designable-banner'); + const trackingHandlers = useMemo(() => { + if (!tracking || !submitComponentEvent) { + return { + onCloseClick: () => {}, + onCollapseClick: () => {}, + onExpandClick: () => {}, + onCtaClick: () => {}, + onSecondaryCtaClick: () => {}, + }; + } + + const clickHandlerFor = (componentId: string, close: boolean) => { + return (): void => { + const componentClickEvent = createClickEventFromTracking( + tracking, + componentId, + ); + void submitComponentEvent(componentClickEvent); + if (close) { + // This would need the onClose function from withCloseable HOC + // For now, just handle tracking + } + }; + }; + + return { + onCloseClick: clickHandlerFor(componentIds.close, true), + onCollapseClick: clickHandlerFor(componentIds.collapse, false), + onExpandClick: clickHandlerFor(componentIds.expand, false), + onCtaClick: clickHandlerFor(componentIds.cta, true), + onSecondaryCtaClick: clickHandlerFor( + componentIds.secondaryCta, + true, + ), + }; + }, [ + tracking, + submitComponentEvent, + componentIds.close, + componentIds.collapse, + componentIds.expand, + componentIds.cta, + componentIds.secondaryCta, + ]); + + // Create combined handlers that run both tracking and prop handlers + const combinedHandlers = useMemo(() => { + return { + onClose: () => { + // Always run tracking + trackingHandlers.onCloseClick(); + // Also run prop handler if it exists + onCloseClick(); + // Run the close handler + handleClose(); + }, + onCtaClick: () => { + trackingHandlers.onCtaClick(); + onCtaClick(); + }, + onSecondaryCtaClick: () => { + trackingHandlers.onSecondaryCtaClick(); + onSecondaryCtaClick(); + }, + onCollapseClick: () => { + trackingHandlers.onCollapseClick(); + onCollapseClick(); + }, + onExpandClick: () => { + trackingHandlers.onExpandClick(); + onExpandClick(); + }, + }; + }, [ + trackingHandlers, + onCloseClick, + onCtaClick, + onSecondaryCtaClick, + onCollapseClick, + onExpandClick, + handleClose, + ]); + const contextValue: BannerContextType | null = useMemo(() => { if (!design || !settings) { return null; @@ -278,10 +580,10 @@ const Banner = ({ choices: choiceCards, selectedChoiceCard, actions: { - onClose: onCloseClick, + onClose: combinedHandlers.onClose, onToggleCollapse: handleToggleCollapse, - onCtaClick, - onSecondaryCtaClick, + onCtaClick: combinedHandlers.onCtaClick, + onSecondaryCtaClick: combinedHandlers.onSecondaryCtaClick, onChoiceCardChange: setSelectedChoiceCard, submitComponentEvent, }, @@ -301,17 +603,15 @@ const Banner = ({ isTabletOrAbove, choiceCards, selectedChoiceCard, - onCloseClick, + combinedHandlers, handleToggleCollapse, - onCtaClick, - onSecondaryCtaClick, submitComponentEvent, separateArticleCount, reminderTracking, bannerChannel, ]); - if (!design || !settings || !contextValue) { + if (!isOpen) { return null; } @@ -321,19 +621,23 @@ const Banner = ({ ? 'maybe-later' : ''; - const cardsImageOrSpaceTemplateString = isCollapsableBanner - ? 'main-image' + const cardsImageOrSpaceTemplateString = settings + ? settings.choiceCardSettings + ? 'choice-cards-container' + : settings.imageSettings + ? 'main-image' + : '.' : '.'; return ( - +
@@ -370,172 +674,6 @@ const Banner = ({ ); }; -const phabletContentMaxWidth = '492px'; - -const styles = { - outerContainer: (background: string, textColor: string = 'inherit') => css` - background: ${background}; - color: ${textColor}; - bottom: 0px; - max-height: 65vh; - max-height: 65svh; - - * { - box-sizing: border-box; - } - ${from.phablet} { - border-top: 1px solid ${neutral[0]}; - } - b, - strong { - font-weight: bold; - } - padding: 0 auto; - `, - layoutOverrides: (cardsImageOrSpaceTemplateString: string) => css` - display: grid; - background: inherit; - position: relative; - bottom: 0px; - - /* mobile first */ - ${until.phablet} { - max-width: 660px; - margin: 0 auto; - padding: ${space[3]}px ${space[3]}px 0 ${space[3]}px; - grid-template-columns: auto max(${phabletContentMaxWidth} auto); - grid-template-areas: - '. . .' - '. copy-container close-button' - '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString}' - '. cta-container cta-container'; - } - ${from.phablet} { - max-width: 740px; - margin: 0 auto; - padding: ${space[3]}px ${space[3]}px 0 ${space[3]}px; - grid-template-columns: minmax(0, 0.5fr) ${phabletContentMaxWidth} max-content minmax( - 0, - 0.5fr - ); - grid-template-rows: auto auto auto; - grid-template-areas: - '. copy-container close-button close-button' - '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString} .' - '. cta-container cta-container .'; - } - ${from.desktop} { - max-width: 980px; - align-self: stretch; - padding: ${space[3]}px ${space[1]}px 0 ${space[3]}px; - grid-template-columns: auto 380px auto; - grid-template-rows: auto auto; - - grid-template-areas: - 'copy-container ${cardsImageOrSpaceTemplateString} close-button' - 'cta-container ${cardsImageOrSpaceTemplateString} .'; - } - ${from.leftCol} { - max-width: 1140px; - bottom: 0px; - /* the vertical line aligns with that of standard article */ - grid-column-gap: 10px; - grid-template-columns: 140px 1px min(460px) min(380px) auto; - grid-template-rows: auto auto; - grid-template-areas: - 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button' - '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; - } - ${from.wide} { - max-width: 1300px; - /* the vertical line aligns with that of standard article */ - grid-template-columns: 219px 1px min(460px) min(380px) auto; - grid-template-rows: auto auto; - grid-template-areas: - 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button' - '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; - } - `, - collapsedLayoutOverrides: (cardsImageOrSpaceTemplateString: string) => css` - display: grid; - background: inherit; - position: relative; - bottom: 0px; - - /* mobile first */ - ${until.phablet} { - max-width: 660px; - margin: 0 auto; - padding: ${space[2]}px ${space[3]}px 0 ${space[3]}px; - grid-template-columns: auto max(${phabletContentMaxWidth} auto); - grid-template-areas: ${` - '. . .' - '. copy-container close-button' - '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString}' - '. cta-container cta-container' - `}; - } - ${from.phablet} { - max-width: 740px; - margin: 0 auto; - padding: ${space[2]}px ${space[3]}px 0 ${space[3]}px; - grid-template-columns: - minmax(0, 0.5fr) - ${phabletContentMaxWidth} - 1fr - 0; - grid-template-rows: auto auto; - grid-template-areas: - '. copy-container close-button .' - '. ${cardsImageOrSpaceTemplateString} ${cardsImageOrSpaceTemplateString} .' - '. cta-container cta-container .'; - } - ${from.desktop} { - max-width: 980px; - padding: ${space[1]}px ${space[1]}px 0 ${space[3]}px; - grid-template-columns: auto 380px minmax(100px, auto); - grid-template-rows: auto auto; - - grid-template-areas: - 'copy-container ${cardsImageOrSpaceTemplateString} close-button' - 'cta-container ${cardsImageOrSpaceTemplateString} .'; - } - ${from.leftCol} { - max-width: 1140px; - bottom: 0px; - /* the vertical line aligns with that of standard article */ - grid-column-gap: 10px; - grid-template-columns: 140px 1px min(460px) min(380px) auto; - grid-template-rows: auto auto; - grid-template-areas: - 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button ' - '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; - } - ${from.wide} { - max-width: 1300px; - /* the vertical line aligns with that of standard article */ - grid-template-columns: 219px 1px min(460px) min(380px) auto; - grid-template-rows: auto auto; - grid-template-areas: - 'logo vert-line copy-container ${cardsImageOrSpaceTemplateString} close-button ' - '. vert-line cta-container ${cardsImageOrSpaceTemplateString} .'; - } - `, - verticalLine: css` - grid-area: vert-line; - - ${until.leftCol} { - display: none; - } - ${from.leftCol} { - background-color: ${neutral[0]}; - width: 1px; - opacity: 0.2; - margin: ${space[6]}px ${space[2]}px 0 ${space[2]}px; - } - `, -}; - const unvalidated = bannerWrapper(Banner, 'designable-banner'); const validated = validatedBannerWrapper(Banner, 'designable-banner'); diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/componentIds.ts b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/componentIds.ts new file mode 100644 index 00000000000..28b7bdaaf5b --- /dev/null +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/componentIds.ts @@ -0,0 +1,14 @@ +import type { BannerId } from '../../common/types'; + +export const getComponentIds = (bannerId: BannerId) => ({ + close: `${bannerId} : close`, + cta: `${bannerId} : cta`, + secondaryCta: `${bannerId} : secondary-cta`, + notNow: `${bannerId} : not now`, + signIn: `${bannerId} : sign in`, + reminderCta: `${bannerId} : reminder-cta`, + reminderSet: `${bannerId} : reminder-set`, + reminderClose: `${bannerId} : reminder-close`, + collapse: `${bannerId} : collapse`, + expand: `${bannerId} : expand`, +}); diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx index 51e3d705dd3..70aba78d8b7 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx @@ -1,22 +1,25 @@ import { css } from '@emotion/react'; -import { - from, - textEgyptian15, - textEgyptian17, - textEgyptianBold15, - textEgyptianBold17, -} from '@guardian/source/foundations'; +import { from, space, textSans15 } from '@guardian/source/foundations'; import { createBannerBodyCopy } from '../../components/BannerText'; import { useBanner } from '../useBanner'; const getStyles = (textColour: string, highlightColour?: string) => ({ container: css` + margin-bottom: ${space[4]}px; + + ${from.tablet} { + margin-bottom: ${space[6]}px; + } + p { margin: 0 0 0.5em 0; } - ${textEgyptian15}; - ${from.desktop} { - ${textEgyptian17}; + ${textSans15}; + span { + ${textSans15}; + } + .rr_banner_highlight > span { + font-weight: 700; } `, highlightedText: css` @@ -30,11 +33,6 @@ const getStyles = (textColour: string, highlightColour?: string) => ({ box-decoration-break: clone; ` : ''} - - ${textEgyptianBold15}; - ${from.desktop} { - ${textEgyptianBold17}; - } `, }); diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx index 6c575314b97..fbd0cf57a73 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx @@ -10,11 +10,14 @@ import { LinkButton, SvgArrowRightStraight, } from '@guardian/source/react-components'; +import { SecondaryCtaType } from '@guardian/support-dotcom-components'; import { enrichSupportUrl, getChoiceCardUrl } from '../../../../lib/tracking'; import { ThreeTierChoiceCards } from '../../../../shared/ThreeTierChoiceCards'; import { buttonStyles, buttonThemes } from '../../styles/buttonStyles'; import { useBanner } from '../useBanner'; +const phabletContentMaxWidth = '492px'; + const styles = { threeTierChoiceCardsContainer: css` grid-area: choice-cards-container; @@ -24,7 +27,7 @@ const styles = { margin-top: -${space[6]}px; } ${from.phablet} { - max-width: 492px; // phabletContentMaxWidth + max-width: ${phabletContentMaxWidth}; } ${from.desktop} { justify-self: end; @@ -181,6 +184,24 @@ export const BannerChoiceCards = (): JSX.Element | null => { ? mainOrMobileContent.primaryCta?.ctaText : 'Continue'} + {!isCollapsed && + mainOrMobileContent.secondaryCta?.type === + SecondaryCtaType.Custom && ( + + {mainOrMobileContent.secondaryCta.cta.ctaText} + + )} {isCollapsed && (
{ {isCollapsed ? 'Expand banner' : 'Collapse banner'}
- {isCollapsed && ( - - Close - - )}
); } diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx index 2021a02063e..5124f4e96c9 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerContent.tsx @@ -16,7 +16,6 @@ const styles = { } ${from.desktop} { padding-right: ${space[5]}px; - margin-bottom: ${space[2]}px; } ${from.leftCol} { padding-left: ${space[3]}px; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx index 48e15aa3c5d..72ec2012ec4 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCtas.tsx @@ -1,7 +1,6 @@ import { css } from '@emotion/react'; import { from, space, until } from '@guardian/source/foundations'; import { LinkButton } from '@guardian/source/react-components'; -import { SecondaryCtaType } from '@guardian/support-dotcom-components'; import { buttonStyles, buttonThemes } from '../../styles/buttonStyles'; import { useBanner } from '../useBanner'; @@ -100,6 +99,15 @@ export const BannerCtas = (): JSX.Element | null => { const { primaryCta, secondaryCta } = mainOrMobileContent; + // Check if secondaryCta has the expected structure + const hasCustomCta = + secondaryCta && + 'type' in secondaryCta && + 'cta' in secondaryCta && + typeof secondaryCta.cta === 'object' && + 'ctaUrl' in secondaryCta.cta && + 'ctaText' in secondaryCta.cta; + if (!primaryCta && !secondaryCta && !isCollapsed) { return null; } @@ -122,7 +130,7 @@ export const BannerCtas = (): JSX.Element | null => { {primaryCta.ctaText} )} - {secondaryCta?.type === SecondaryCtaType.Custom && ( + {hasCustomCta && ( { } return { container: css` - grid-area: main-image; display: block; width: calc(100% + 20px); margin-left: -10px; @@ -68,23 +67,6 @@ const getStyles = (isHeaderImage = false) => { width: 100%; align-items: center; } - - ${from.phablet} { - max-width: 492px; // phabletContentMaxWidth - justify-self: center; - } - ${from.desktop} { - margin-top: ${space[6]}px; - padding-left: ${space[2]}px; - justify-self: end; - } - ${between.desktop.and.wide} { - max-width: 380px; - } - ${from.wide} { - max-width: 485px; - align-self: start; - } `, }; }; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx index ff3f3a806f6..46bf329aaa4 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx @@ -394,8 +394,8 @@ export const Default: Story = { content: content as any, tracking, articleCounts: { - forTargetedWeeks: 0, - for52Weeks: 0, + forTargetedWeeks: 12, + for52Weeks: 12, }, submitComponentEvent: () => Promise.resolve(), onCloseClick: () => { @@ -624,6 +624,7 @@ export const CollapsableWithThreeTierChoiceCards: Story = { + @@ -699,6 +700,10 @@ export const CollapsableMaybeLaterVariant: Story = { ), args: { ...CollapsableWithMainImage.args, + tracking: { + ...tracking, + abTestVariant: 'COLLAPSABLE_V2_MAYBE_LATER', + }, }, }; @@ -709,6 +714,7 @@ export const CollapsableWithThreeTierChoiceCardsMaybeLaterVariant: Story = { + @@ -719,6 +725,10 @@ export const CollapsableWithThreeTierChoiceCardsMaybeLaterVariant: Story = { ), args: { ...CollapsableWithThreeTierChoiceCards.args, + tracking: { + ...tracking, + abTestVariant: 'COLLAPSABLE_V2_MAYBE_LATER', + }, }, }; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx index 7eb539abfea..c19ddda9c8d 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/tests/Banner.test.tsx @@ -171,7 +171,7 @@ describe('DesignableBanner V2', () => { expect(mockProps.onCloseClick).toHaveBeenCalled(); }); - it('renders as collapsed when isCollapsible is true', () => { + it('renders as uncollapsed by default when isCollapsible is true', () => { render( @@ -181,11 +181,9 @@ describe('DesignableBanner V2', () => { , ); - // When collapsed, the body is usually hidden or different - // Based on BannerBody.tsx: {!isCollapsed && (...)} - expect(screen.queryByText('Main Paragraph 1')).not.toBeInTheDocument(); - // Header renders mobile content when collapsed - expect(screen.getByText('Mobile Heading')).toBeInTheDocument(); + // Now starts uncollapsed by default + expect(screen.getByText('Main Paragraph 1')).toBeInTheDocument(); + expect(screen.getByText('Main Heading')).toBeInTheDocument(); }); it('toggles collapse when the toggle button is clicked', () => { @@ -197,15 +195,17 @@ describe('DesignableBanner V2', () => { , ); - expect(screen.queryByText('Main Paragraph 1')).not.toBeInTheDocument(); + // Starts uncollapsed + expect(screen.getByText('Main Paragraph 1')).toBeInTheDocument(); const toggleButton = screen.getByRole('button', { - name: /Expand banner/i, + name: /Collapse banner/i, }); fireEvent.click(toggleButton); - expect(screen.getByText('Main Paragraph 1')).toBeInTheDocument(); - expect(mockProps.onExpandClick).toHaveBeenCalled(); + // Now should be collapsed + expect(screen.queryByText('Main Paragraph 1')).not.toBeInTheDocument(); + expect(mockProps.onCollapseClick).toHaveBeenCalled(); }); it('renders the ticker when tickerSettings are provided', () => { From 6adfdf91450056504e0924d975e78033cb3e9969 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Mon, 19 Jan 2026 10:56:03 +0000 Subject: [PATCH 3/7] refactor(designable-banner-v2): improve mobile layout and conditional rendering --- .../banners/designableBanner/v2/Banner.tsx | 16 +- .../v2/components/BannerArticleCount.tsx | 16 +- .../v2/components/BannerVisual.tsx | 40 +++-- .../v2/stories/DesignableBannerV2.stories.tsx | 158 +++++++++--------- 4 files changed, 125 insertions(+), 105 deletions(-) diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx index f8c9bf9973d..76c7986c7c7 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx @@ -475,23 +475,13 @@ const Banner = ({ // Create tracking handlers that always run const componentIds = getComponentIds('designable-banner'); const trackingHandlers = useMemo(() => { - if (!tracking || !submitComponentEvent) { - return { - onCloseClick: () => {}, - onCollapseClick: () => {}, - onExpandClick: () => {}, - onCtaClick: () => {}, - onSecondaryCtaClick: () => {}, - }; - } - const clickHandlerFor = (componentId: string, close: boolean) => { return (): void => { const componentClickEvent = createClickEventFromTracking( tracking, componentId, ); - void submitComponentEvent(componentClickEvent); + void submitComponentEvent?.(componentClickEvent); if (close) { // This would need the onClose function from withCloseable HOC // For now, just handle tracking @@ -636,8 +626,8 @@ const Banner = ({ role="alert" tabIndex={-1} css={styles.outerContainer( - settings?.containerSettings?.backgroundColour ?? '', - settings?.containerSettings?.textColor ?? 'inherit', + settings?.containerSettings.backgroundColour ?? '', + settings?.containerSettings.textColor ?? 'inherit', )} className={contextClassName} > diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx index 35d49869c75..081104b1e5a 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx @@ -3,6 +3,7 @@ import { from, headlineBold15, headlineBold17, + space, } from '@guardian/source/foundations'; import { CustomArticleCountCopy } from '../../components/CustomArticleCountCopy'; import { DesignableBannerArticleCountOptOut } from '../../components/DesignableBannerArticleCountOptOut'; @@ -14,6 +15,7 @@ const containsArticleCountTemplate = (copy: string): boolean => const styles = { container: (textColor: string = 'inherit') => css` margin: 0; + margin-bottom: ${space[3]}px; color: ${textColor}; ${headlineBold15} ${from.desktop} { @@ -23,8 +25,18 @@ const styles = { }; export const BannerArticleCount = (): JSX.Element | null => { - const { articleCounts, settings, separateArticleCountSettings } = - useBanner(); + const { + articleCounts, + settings, + separateArticleCountSettings, + isCollapsed, + } = useBanner(); + + // Don't render article count when banner is collapsed + if (isCollapsed) { + return null; + } + const numArticles = articleCounts.forTargetedWeeks; const copy = separateArticleCountSettings?.copy; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx index 5e3c7853327..49e3c6fd4a7 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerVisual.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { between, from } from '@guardian/source/foundations'; +import { between, from, space } from '@guardian/source/foundations'; import type { Image } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ImageAttrs } from '../../../../shared/ResponsiveImage'; import { ResponsiveImage } from '../../../../shared/ResponsiveImage'; @@ -42,10 +42,27 @@ const getStyles = (isHeaderImage = false) => { } return { container: css` - display: block; - width: calc(100% + 20px); - margin-left: -10px; - margin-right: -10px; + grid-area: main-image; + + margin-left: ${space[2]}px; + margin-right: ${space[2]}px; + + ${from.phablet} { + max-width: 492px; + justify-self: center; + } + ${from.desktop} { + margin-top: ${space[6]}px; + padding-left: ${space[2]}px; + justify-self: end; + } + ${between.desktop.and.wide} { + max-width: 380px; + } + ${from.wide} { + max-width: 485px; + align-self: start; + } img { width: 100%; @@ -136,11 +153,12 @@ export const BannerVisual = ({ } return ( - +
+ +
); }; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx index 46bf329aaa4..0c0b9a521d1 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/stories/DesignableBannerV2.stories.tsx @@ -551,6 +551,49 @@ export const MainImage: Story = { }, }; +export const DesignThreeAnimatedHeaderImage: Story = { + name: 'With animated header image', + render: (args) => ( + + + + + + + + + + + ), + args: { + ...WithThreeTierChoiceCards.args, + content: contentNoHeading as any, + design: { + ...design, + headerImage: { + mobileUrl: + 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', + tabletUrl: + 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', + desktopUrl: + 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', + altText: 'Guardian: News provider of the year', + }, + visual: { + kind: 'ChoiceCards', + buttonColour: stringToHexColour('FFFFFF'), + }, + colours: { + ...design.colours, + basic: { + ...design.colours.basic, + background: stringToHexColour('FFFFFF'), + }, + }, + } as any, + }, +}; + export const WithTickerAndThreeTierChoiceCards: Story = { name: 'With ticker + three tier choice cards', render: (args) => ( @@ -617,15 +660,13 @@ export const NoChoiceCardOrImage: Story = { }, }; -export const CollapsableWithThreeTierChoiceCards: Story = { - name: 'Collapsable with three tier choice cards', +export const WithMixedDestinations: Story = { + name: 'With destinationUrl on all choice cards', render: (args) => ( - - @@ -635,31 +676,7 @@ export const CollapsableWithThreeTierChoiceCards: Story = { ), args: { ...WithThreeTierChoiceCards.args, - tickerSettings: tickerSettings as any, - separateArticleCountSettings: { - type: 'above', - } as any, - isCollapsible: true, - }, -}; - -export const CollapsableWithMainImage: Story = { - name: 'Collapsable with main image', - render: (args) => ( - - - - - - - - - - - ), - args: { - ...MainImage.args, - isCollapsible: true, + choiceCardsSettings: choiceCardsWithMixedDestinations as any, }, }; @@ -684,56 +701,54 @@ export const WithReminder: Story = { }, }; -export const CollapsableMaybeLaterVariant: Story = { - name: 'Collapsable - Maybe later variant', +export const CollapsableWithThreeTierChoiceCards: Story = { + name: 'Collapsable with three tier choice cards', render: (args) => ( + + + - ), args: { - ...CollapsableWithMainImage.args, - tracking: { - ...tracking, - abTestVariant: 'COLLAPSABLE_V2_MAYBE_LATER', - }, + ...WithThreeTierChoiceCards.args, + tickerSettings: tickerSettings as any, + separateArticleCountSettings: { + type: 'above', + } as any, + isCollapsible: true, }, }; -export const CollapsableWithThreeTierChoiceCardsMaybeLaterVariant: Story = { - name: 'Collapsable with three tier choice cards - Maybe later variant', +export const CollapsableWithMainImage: Story = { + name: 'Collapsable with main image', render: (args) => ( - - - + ), args: { - ...CollapsableWithThreeTierChoiceCards.args, - tracking: { - ...tracking, - abTestVariant: 'COLLAPSABLE_V2_MAYBE_LATER', - }, + ...MainImage.args, + isCollapsible: true, }, }; -export const WithMixedDestinations: Story = { - name: 'With destinationUrl on all choice cards', +export const CollapsableMaybeLaterVariant: Story = { + name: 'Collapsable - Maybe later variant', render: (args) => ( @@ -741,24 +756,29 @@ export const WithMixedDestinations: Story = { - + ), args: { - ...WithThreeTierChoiceCards.args, - choiceCardsSettings: choiceCardsWithMixedDestinations as any, + ...CollapsableWithMainImage.args, + tracking: { + ...tracking, + abTestVariant: 'COLLAPSABLE_V2_MAYBE_LATER', + }, }, }; -export const DesignThreeAnimatedHeaderImage: Story = { - name: 'With animated header image', +export const CollapsableWithThreeTierChoiceCardsMaybeLaterVariant: Story = { + name: 'Collapsable with three tier choice cards - Maybe later variant', render: (args) => ( + + @@ -767,30 +787,10 @@ export const DesignThreeAnimatedHeaderImage: Story = { ), args: { - ...WithThreeTierChoiceCards.args, - content: contentNoHeading as any, - design: { - ...design, - headerImage: { - mobileUrl: - 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', - tabletUrl: - 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', - desktopUrl: - 'https://uploads.guim.co.uk/2024/05/13/GuardianLogo.svg', - altText: 'Guardian: News provider of the year', - }, - visual: { - kind: 'ChoiceCards', - buttonColour: stringToHexColour('FFFFFF'), - }, - colours: { - ...design.colours, - basic: { - ...design.colours.basic, - background: stringToHexColour('FFFFFF'), - }, - }, - } as any, + ...CollapsableWithThreeTierChoiceCards.args, + tracking: { + ...tracking, + abTestVariant: 'COLLAPSABLE_V2_MAYBE_LATER', + }, }, }; From e1683761a1736745bfc4b1c4a6f0db8bb5fbabeb Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Tue, 20 Jan 2026 15:38:29 +0000 Subject: [PATCH 4/7] refactor(designable-banner-v2): replace context with prop drilling for banner data --- .../banners/designableBanner/v2/Banner.tsx | 83 ++++---- .../v2/{BannerContext.tsx => BannerProps.ts} | 7 +- .../v2/components/BannerArticleCount.tsx | 32 +-- .../v2/components/BannerBody.tsx | 23 +- .../v2/components/BannerChoiceCards.tsx | 90 ++++---- .../v2/components/BannerCloseButton.tsx | 42 ++-- .../v2/components/BannerContent.tsx | 8 +- .../v2/components/BannerCtas.tsx | 51 +++-- .../v2/components/BannerHeader.tsx | 45 ++-- .../v2/components/BannerLogo.tsx | 16 +- .../v2/components/BannerReminder.tsx | 23 +- .../v2/components/BannerTicker.tsx | 28 +-- .../v2/components/BannerVisual.tsx | 18 +- .../banners/designableBanner/v2/index.ts | 2 +- .../v2/stories/DesignableBannerV2.stories.tsx | 201 +----------------- .../designableBanner/v2/tests/Banner.test.tsx | 71 +------ .../banners/designableBanner/v2/useBanner.ts | 12 -- 17 files changed, 254 insertions(+), 498 deletions(-) rename dotcom-rendering/src/components/marketing/banners/designableBanner/v2/{BannerContext.tsx => BannerProps.ts} (89%) delete mode 100644 dotcom-rendering/src/components/marketing/banners/designableBanner/v2/useBanner.ts diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx index 76c7986c7c7..75896cde290 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/Banner.tsx @@ -28,7 +28,7 @@ import { import type { BannerRenderProps } from '../../common/types'; import { setChannelClosedTimestamp } from '../../utils/localStorage'; import type { BannerTemplateSettings, ChoiceCardSettings } from '../settings'; -import { BannerContext, type BannerContextType } from './BannerContext'; +import type { BannerData } from './BannerProps'; import { getComponentIds } from './componentIds'; import { BannerArticleCount } from './components/BannerArticleCount'; import { BannerBody } from './components/BannerBody'; @@ -120,10 +120,6 @@ const buildChoiceCardSettings = ( }; }; -interface BannerComponentProps extends BannerRenderProps { - children?: React.ReactNode; -} - const phabletContentMaxWidth = '492px'; const styles = { @@ -334,8 +330,7 @@ const Banner = ({ promoCodes, separateArticleCount, isCollapsible, - children, -}: BannerComponentProps): JSX.Element | null => { +}: BannerRenderProps): JSX.Element | null => { const isTabletOrAbove = useMatchMedia(removeMediaRulePrefix(from.tablet)); const bannerRef = useRef(null); const [isOpen, setIsOpen] = useState(true); @@ -547,7 +542,7 @@ const Banner = ({ handleClose, ]); - const contextValue: BannerContextType | null = useMemo(() => { + const bannerData: BannerData | null = useMemo(() => { if (!design || !settings) { return null; } @@ -601,7 +596,7 @@ const Banner = ({ bannerChannel, ]); - if (!isOpen) { + if (!isOpen || !bannerData) { return null; } @@ -620,47 +615,41 @@ const Banner = ({ : '.'; return ( - +
-
-
- {children ?? ( - <> - - - - - - - - - - - - - )} -
+
+ + + + + + + + + + +
- +
); }; diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerContext.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerProps.ts similarity index 89% rename from dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerContext.tsx rename to dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerProps.ts index 63f26c7db0a..7c4ef119215 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerContext.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/BannerProps.ts @@ -1,14 +1,13 @@ import type { BannerChannel } from '@guardian/support-dotcom-components/dist/shared/types'; import type { ChoiceCard } from '@guardian/support-dotcom-components/dist/shared/types/props/choiceCards'; import type { Dispatch, SetStateAction } from 'react'; -import { createContext } from 'react'; import type { BannerRenderProps, ContributionsReminderTracking, } from '../../common/types'; import type { BannerTemplateSettings } from '../settings'; -export interface BannerContextType { +export interface BannerData { // --- Raw Data --- bannerChannel: BannerChannel; content: BannerRenderProps['content']; @@ -42,7 +41,3 @@ export interface BannerContextType { submitComponentEvent: BannerRenderProps['submitComponentEvent']; }; } - -export const BannerContext = createContext( - undefined, -); diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx index 081104b1e5a..64c7d469edf 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerArticleCount.tsx @@ -7,7 +7,7 @@ import { } from '@guardian/source/foundations'; import { CustomArticleCountCopy } from '../../components/CustomArticleCountCopy'; import { DesignableBannerArticleCountOptOut } from '../../components/DesignableBannerArticleCountOptOut'; -import { useBanner } from '../useBanner'; +import type { BannerData } from '../BannerProps'; const containsArticleCountTemplate = (copy: string): boolean => copy.includes('%%ARTICLE_COUNT%%'); @@ -24,41 +24,41 @@ const styles = { `, }; -export const BannerArticleCount = (): JSX.Element | null => { - const { - articleCounts, - settings, - separateArticleCountSettings, - isCollapsed, - } = useBanner(); - - // Don't render article count when banner is collapsed - if (isCollapsed) { +export const BannerArticleCount = ({ + bannerData, +}: { + bannerData: BannerData; +}): JSX.Element | null => { + if ( + bannerData.isCollapsed || + (!bannerData.separateArticleCount && + !bannerData.separateArticleCountSettings) + ) { return null; } - const numArticles = articleCounts.forTargetedWeeks; - const copy = separateArticleCountSettings?.copy; + const numArticles = bannerData.articleCounts.forTargetedWeeks; + const copy = bannerData.separateArticleCountSettings?.copy; if (copy && containsArticleCountTemplate(copy)) { return ( ); } return ( -
+
{numArticles >= 50 ? "Congratulations on being one of our top readers globally - you've read " : "You've read "} {' '} in the last year
diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx index 70aba78d8b7..b7f657a30de 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerBody.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/react'; import { from, space, textSans15 } from '@guardian/source/foundations'; import { createBannerBodyCopy } from '../../components/BannerText'; -import { useBanner } from '../useBanner'; +import type { BannerData } from '../BannerProps'; const getStyles = (textColour: string, highlightColour?: string) => ({ container: css` @@ -36,21 +36,24 @@ const getStyles = (textColour: string, highlightColour?: string) => ({ `, }); -export const BannerBody = (): JSX.Element | null => { - const { content, settings, isTabletOrAbove, isCollapsed } = useBanner(); - - const textColour = settings.containerSettings.textColor ?? ''; - const highlightColour = settings.highlightedTextSettings.highlightColour; +export const BannerBody = ({ + bannerData, +}: { + bannerData: BannerData; +}): JSX.Element | null => { + const textColour = bannerData.settings.containerSettings.textColor ?? ''; + const highlightColour = + bannerData.settings.highlightedTextSettings.highlightColour; const styles = getStyles(textColour, highlightColour); - const mainOrMobileContent = isTabletOrAbove - ? content.mainContent - : content.mobileContent; + const mainOrMobileContent = bannerData.isTabletOrAbove + ? bannerData.content.mainContent + : bannerData.content.mobileContent; return (
- {!isCollapsed && + {!bannerData.isCollapsed && createBannerBodyCopy( mainOrMobileContent.paragraphs, mainOrMobileContent.highlightedText, diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx index fbd0cf57a73..40bb02f58df 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerChoiceCards.tsx @@ -14,7 +14,7 @@ import { SecondaryCtaType } from '@guardian/support-dotcom-components'; import { enrichSupportUrl, getChoiceCardUrl } from '../../../../lib/tracking'; import { ThreeTierChoiceCards } from '../../../../shared/ThreeTierChoiceCards'; import { buttonStyles, buttonThemes } from '../../styles/buttonStyles'; -import { useBanner } from '../useBanner'; +import type { BannerData } from '../BannerProps'; const phabletContentMaxWidth = '492px'; @@ -124,95 +124,101 @@ const styles = { `, }; -export const BannerChoiceCards = (): JSX.Element | null => { - const { - settings, - isCollapsed, - choices, - selectedChoiceCard, - actions, - tracking, - promoCodes, - countryCode, - content, - isTabletOrAbove, - } = useBanner(); - - if (!settings.choiceCardSettings || !selectedChoiceCard || !choices) { +export const BannerChoiceCards = ({ + bannerData, +}: { + bannerData: BannerData; +}): JSX.Element | null => { + if ( + !bannerData.settings.choiceCardSettings || + !bannerData.selectedChoiceCard || + !bannerData.choices + ) { return null; } - const mainOrMobileContent = isTabletOrAbove - ? content.mainContent - : content.mobileContent; + const mainOrMobileContent = bannerData.isTabletOrAbove + ? bannerData.content.mainContent + : bannerData.content.mobileContent; return (
- {!isCollapsed && ( + {!bannerData.isCollapsed && ( )}
} iconSide="right" target="_blank" rel="noopener noreferrer" > - {isCollapsed + {bannerData.isCollapsed ? mainOrMobileContent.primaryCta?.ctaText : 'Continue'} - {!isCollapsed && + {!bannerData.isCollapsed && mainOrMobileContent.secondaryCta?.type === SecondaryCtaType.Custom && ( {mainOrMobileContent.secondaryCta.cta.ctaText} )} - {isCollapsed && ( + {bannerData.isCollapsed && (
diff --git a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx index d3f49313ece..624b8042205 100644 --- a/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx +++ b/dotcom-rendering/src/components/marketing/banners/designableBanner/v2/components/BannerCloseButton.tsx @@ -7,7 +7,7 @@ import { SvgCross, } from '@guardian/source/react-components'; import { buttonStyles, buttonThemes } from '../../styles/buttonStyles'; -import { useBanner } from '../useBanner'; +import type { BannerData } from '../BannerProps'; const styles = { closeButtonContainer: css` @@ -97,25 +97,32 @@ const styles = { `, }; -export const BannerCloseButton = (): JSX.Element => { - const { isCollapsible, isCollapsed, settings, actions } = useBanner(); - - if (isCollapsible) { +export const BannerCloseButton = ({ + bannerData, +}: { + bannerData: BannerData; +}): JSX.Element => { + if (bannerData.isCollapsible) { return ( -
+
@@ -138,13 +147,16 @@ export const BannerCloseButton = (): JSX.Element => { return (