From 35b618c979a9a796998c1741c06f0a5fd3b4993e Mon Sep 17 00:00:00 2001 From: Charlotte Date: Fri, 26 Apr 2024 14:35:48 +0100 Subject: [PATCH] migrate header files from support-dotcom-components --- .../src/components/SupportTheG.importable.tsx | 2 +- .../marketing/header/Header.stories.tsx | 51 ++++ .../components/marketing/header/Header.tsx | 141 ++++++++++ .../marketing/header/HeaderWrapper.tsx | 186 +++++++++++++ .../header/SignInPromptHeader.stories.tsx | 69 +++++ .../marketing/header/SignInPromptHeader.tsx | 246 ++++++++++++++++++ .../header/common/HeaderDecorator.tsx | 19 ++ .../marketing/hooks/useHasBeenSeen.ts | 53 ++++ 8 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 dotcom-rendering/src/components/marketing/header/Header.stories.tsx create mode 100644 dotcom-rendering/src/components/marketing/header/Header.tsx create mode 100644 dotcom-rendering/src/components/marketing/header/HeaderWrapper.tsx create mode 100644 dotcom-rendering/src/components/marketing/header/SignInPromptHeader.stories.tsx create mode 100644 dotcom-rendering/src/components/marketing/header/SignInPromptHeader.tsx create mode 100644 dotcom-rendering/src/components/marketing/header/common/HeaderDecorator.tsx create mode 100644 dotcom-rendering/src/components/marketing/hooks/useHasBeenSeen.ts diff --git a/dotcom-rendering/src/components/SupportTheG.importable.tsx b/dotcom-rendering/src/components/SupportTheG.importable.tsx index 055e0417a76..aab9587c4ba 100644 --- a/dotcom-rendering/src/components/SupportTheG.importable.tsx +++ b/dotcom-rendering/src/components/SupportTheG.importable.tsx @@ -415,7 +415,7 @@ const ReaderRevenueLinksNative = ({ }; /** - * Container for `ReaderRevenueLinksRemote` or `ReaderRevenueLinksRemote` + * Container for `ReaderRevenueLinksRemote` or `ReaderRevenueLinksNative` * * ## Why does this need to be an Island? * diff --git a/dotcom-rendering/src/components/marketing/header/Header.stories.tsx b/dotcom-rendering/src/components/marketing/header/Header.stories.tsx new file mode 100644 index 00000000000..2506404058f --- /dev/null +++ b/dotcom-rendering/src/components/marketing/header/Header.stories.tsx @@ -0,0 +1,51 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/4925ef1e0ced5d221f1122afe79f93bd7448e0e5/packages/modules/src/modules/headers/Header.stories.tsx + */ +import type { Meta, StoryFn } from '@storybook/react'; +import { HeaderDecorator } from './common/HeaderDecorator'; +import { HeaderUnvalidated as Header } from './Header'; + +export default { + component: Header, + title: 'Headers/Header', + decorators: [HeaderDecorator], +} as Meta; + +const Template: StoryFn = (props) =>
; + +export const DefaultHeader = Template.bind({}); +DefaultHeader.args = { + content: { + heading: 'Support the Guardian', + subheading: 'Available for everyone, funded by readers', + primaryCta: { + baseUrl: 'https://support.theguardian.com/contribute', + text: 'Contribute', + }, + secondaryCta: { + baseUrl: '', + text: 'Subscribe', + }, + }, + mobileContent: { + heading: '', + subheading: '', + primaryCta: { + baseUrl: 'https://support.theguardian.com/contribute', + text: 'Support us', + }, + }, + tracking: { + ophanPageId: 'pvid', + platformId: 'GUARDIAN_WEB', + referrerUrl: 'https://theguardian.com/uk', + clientName: 'dcr', + abTestName: 'test-name', + abTestVariant: 'variant-name', + campaignCode: 'campaign-code', + componentType: 'ACQUISITIONS_HEADER', + }, + countryCode: 'GB', +}; diff --git a/dotcom-rendering/src/components/marketing/header/Header.tsx b/dotcom-rendering/src/components/marketing/header/Header.tsx new file mode 100644 index 00000000000..b788c957da5 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/header/Header.tsx @@ -0,0 +1,141 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/4925ef1e0ced5d221f1122afe79f93bd7448e0e5/packages/modules/src/modules/headers/Header.tsx + */ +import { css } from '@emotion/react'; +import { + from, + headline, + palette as sourcePalette, + textSans, +} from '@guardian/source-foundations'; +import { + Hide, + LinkButton, + SvgArrowRightStraight, + themeButtonReaderRevenueBrand, +} from '@guardian/source-react-components'; +import type { ReactComponent } from '../lib/ReactComponent'; +import type { HeaderRenderProps } from './HeaderWrapper'; +import { headerWrapper, validatedHeaderWrapper } from './HeaderWrapper'; + +const messageStyles = (isThankYouMessage: boolean) => css` + color: ${sourcePalette.brandAlt[400]}; + ${headline.xxsmall({ fontWeight: 'bold' })}; + margin-bottom: 3px; + + ${from.desktop} { + ${headline.xsmall({ fontWeight: 'bold' })} + } + + ${from.leftCol} { + ${isThankYouMessage + ? headline.small({ fontWeight: 'bold' }) + : headline.medium({ fontWeight: 'bold' })} + } +`; + +const linkStyles = css` + height: 32px; + min-height: 32px; + ${textSans.small({ fontWeight: 'bold' })}; + border-radius: 16px; + padding: 0 12px 0 12px; + line-height: 18px; + margin-right: 10px; + margin-bottom: 6px; + + svg { + width: 24px; + } +`; + +const subMessageStyles = css` + color: ${sourcePalette.neutral[100]}; + ${textSans.medium()}; + margin: 5px 0; +`; + +// override user agent styles +const headingStyles = css` + margin: 0; + font-size: 100%; +`; + +const Header: ReactComponent = ( + props: HeaderRenderProps, +) => { + const { heading, subheading, primaryCta, secondaryCta } = props.content; + + const onClick = () => { + props.onCtaClick?.(); + }; + return ( +
+ +
+

{heading}

+
+ +
+
{subheading}
+
+
+ + {primaryCta && ( + <> + + } + iconSide="right" + nudgeIcon={true} + css={linkStyles} + > + {primaryCta.ctaText} + + + + + + {props.mobileContent?.primaryCta?.ctaText ?? + primaryCta.ctaText} + + + + )} + + {secondaryCta && ( + + } + iconSide="right" + nudgeIcon={true} + css={linkStyles} + > + {secondaryCta.ctaText} + + + )} +
+ ); +}; + +const unvalidated = headerWrapper(Header); +const validated = validatedHeaderWrapper(Header); +export { validated as Header, unvalidated as HeaderUnvalidated }; diff --git a/dotcom-rendering/src/components/marketing/header/HeaderWrapper.tsx b/dotcom-rendering/src/components/marketing/header/HeaderWrapper.tsx new file mode 100644 index 00000000000..bca5ab66a8a --- /dev/null +++ b/dotcom-rendering/src/components/marketing/header/HeaderWrapper.tsx @@ -0,0 +1,186 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/4925ef1e0ced5d221f1122afe79f93bd7448e0e5/packages/modules/src/modules/headers/HeaderWrapper.tsx + */ +import type { + Cta, + HeaderProps, + OphanAction, +} from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { headerPropsSchema } from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { useCallback, useEffect } from 'react'; +import { type HasBeenSeen, useHasBeenSeen } from '../hooks/useHasBeenSeen'; +import type { ReactComponent } from '../lib/ReactComponent'; +import { + addRegionIdAndTrackingParamsToSupportUrl, + addTrackingParamsToProfileUrl, + createClickEventFromTracking, + isProfileUrl, +} from '../lib/tracking'; +import { withParsedProps } from '../shared/ModuleWrapper'; + +export interface HeaderEnrichedCta { + ctaUrl: string; + ctaText: string; +} + +export interface HeaderRenderedContent { + heading: string; + subheading: string; + primaryCta: HeaderEnrichedCta | null; + secondaryCta: HeaderEnrichedCta | null; + benefits: string[] | null; +} + +export interface HeaderRenderProps { + content: HeaderRenderedContent; + mobileContent?: HeaderRenderedContent; + onCtaClick?: () => void; // only used by sign in prompt header +} + +export const headerWrapper = ( + Header: ReactComponent, +): ReactComponent => { + const Wrapped: ReactComponent = ({ + content, + mobileContent, + tracking, + countryCode, + submitComponentEvent, + numArticles, + }) => { + const buildEnrichedCta = (cta: Cta): HeaderEnrichedCta => { + if (isProfileUrl(cta.baseUrl)) { + return { + ctaUrl: addTrackingParamsToProfileUrl( + cta.baseUrl, + tracking, + ), + ctaText: cta.text, + }; + } + return { + ctaUrl: addRegionIdAndTrackingParamsToSupportUrl( + cta.baseUrl, + tracking, + numArticles, + countryCode, + ), + ctaText: cta.text, + }; + }; + + const primaryCta = content.primaryCta + ? buildEnrichedCta(content.primaryCta) + : null; + const secondaryCta = content.secondaryCta + ? buildEnrichedCta(content.secondaryCta) + : null; + const benefits = content.benefits ?? null; + + const renderedContent: HeaderRenderedContent = { + heading: content.heading, + subheading: content.subheading, + primaryCta, + secondaryCta, + benefits, + }; + + const mobilePrimaryCta = mobileContent?.primaryCta + ? buildEnrichedCta(mobileContent.primaryCta) + : primaryCta; + + const mobileSecondaryCta = mobileContent?.secondaryCta + ? buildEnrichedCta(mobileContent.secondaryCta) + : secondaryCta; + + const renderedMobileContent = mobileContent + ? ({ + heading: mobileContent.heading, + subheading: mobileContent.subheading, + primaryCta: mobilePrimaryCta, + secondaryCta: mobileSecondaryCta, + } as HeaderRenderedContent) + : undefined; + + const { abTestName, abTestVariant, componentType, campaignCode } = + tracking; + + const onCtaClick = (componentId: string) => { + return (): void => { + const componentClickEvent = createClickEventFromTracking( + tracking, + `${componentId} : cta`, + ); + if (submitComponentEvent) { + submitComponentEvent(componentClickEvent); + } + }; + }; + + const sendOphanEvent = useCallback( + (action: OphanAction): void => { + if (submitComponentEvent) { + submitComponentEvent({ + component: { + componentType, + id: campaignCode, + campaignCode, + }, + action, + abTest: { + name: abTestName, + variant: abTestVariant, + }, + }); + } + }, + [ + abTestName, + abTestVariant, + campaignCode, + componentType, + submitComponentEvent, + ], + ); + + const [hasBeenSeen, setNode] = useHasBeenSeen( + { + threshold: 0, + }, + true, + ) as HasBeenSeen; + + useEffect(() => { + if (hasBeenSeen) { + sendOphanEvent('VIEW'); + } + }, [hasBeenSeen, sendOphanEvent]); + + useEffect(() => { + sendOphanEvent('INSERT'); + }, [sendOphanEvent]); + + return ( +
+
+
+ ); + }; + return Wrapped; +}; + +const validate = (props: unknown): props is HeaderProps => { + const result = headerPropsSchema.safeParse(props); + return result.success; +}; + +export const validatedHeaderWrapper = ( + Header: ReactComponent, +): ReactComponent => + withParsedProps(headerWrapper(Header), validate); diff --git a/dotcom-rendering/src/components/marketing/header/SignInPromptHeader.stories.tsx b/dotcom-rendering/src/components/marketing/header/SignInPromptHeader.stories.tsx new file mode 100644 index 00000000000..e5d7153c893 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/header/SignInPromptHeader.stories.tsx @@ -0,0 +1,69 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/4925ef1e0ced5d221f1122afe79f93bd7448e0e5/packages/modules/src/modules/headers/SignInPromptHeader.stories.tsx + */ +import type { Meta, StoryFn } from '@storybook/react'; +import { HeaderDecorator } from './common/HeaderDecorator'; +import { SignInPromptHeaderUnvalidated as SignInPromptHeader } from './SignInPromptHeader'; + +export default { + component: SignInPromptHeader, + title: 'Headers/SignInPromptHeader', + decorators: [HeaderDecorator], +} as Meta; + +const Template: StoryFn = (props) => ( + +); + +const baseArgs = { + content: { + heading: 'Thank you for subscribing', + subheading: 'Remember to sign in for a better experience', + primaryCta: { + baseUrl: 'https://profile.theguardian.com/register', + text: 'Complete registration', + }, + benefits: [ + 'Ad free', + 'Fewer interruptions', + 'Newsletters and comments', + 'Ad free', + ], + }, + mobileContent: { + heading: '', + subheading: '', + }, + tracking: { + ophanPageId: 'pvid', + platformId: 'GUARDIAN_WEB', + referrerUrl: 'https://theguardian.com/uk', + clientName: 'dcr', + abTestName: 'test-name', + abTestVariant: 'variant-name', + campaignCode: 'campaign-code', + componentType: 'ACQUISITIONS_HEADER', + } as const, + countryCode: 'GB', +}; + +export const DefaultHeader = Template.bind({}); +DefaultHeader.args = baseArgs; + +export const ManyBenefits = Template.bind({}); +ManyBenefits.args = { + ...baseArgs, + content: { + ...baseArgs.content, + benefits: ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven'], + }, +}; + +export const WithoutBenefits = Template.bind({}); +const { benefits, ...contentWithoutBenefits } = baseArgs.content; +WithoutBenefits.args = { + ...baseArgs, + content: contentWithoutBenefits, +}; diff --git a/dotcom-rendering/src/components/marketing/header/SignInPromptHeader.tsx b/dotcom-rendering/src/components/marketing/header/SignInPromptHeader.tsx new file mode 100644 index 00000000000..6c84fb3c8aa --- /dev/null +++ b/dotcom-rendering/src/components/marketing/header/SignInPromptHeader.tsx @@ -0,0 +1,246 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/4925ef1e0ced5d221f1122afe79f93bd7448e0e5/packages/modules/src/modules/headers/SignInPromptHeader.tsx + */ +import { css } from '@emotion/react'; +import { + from, + headline, + lineHeights, + palette, + space, + textSans, + until, +} from '@guardian/source-foundations'; +import { + Hide, + LinkButton, + themeButtonBrand, +} from '@guardian/source-react-components'; +import { useEffect, useMemo, useState } from 'react'; +import type { ReactComponent } from '../lib/ReactComponent'; +import type { HeaderRenderProps } from './HeaderWrapper'; +import { headerWrapper, validatedHeaderWrapper } from './HeaderWrapper'; + +const FADE_TIME_MS = 300; +const TEXT_DELAY_MS = 1500; +const ANIMATION_DELAY_MS = 150; +const DOTS_COUNT = 3; + +const headingStyles = () => css` + color: ${palette.neutral[100]}; + ${headline.xxxsmall({ fontWeight: 'bold' })}; + margin: 0; + + ${from.desktop} { + ${headline.xsmall({ fontWeight: 'bold' })}; + } +`; + +const subHeadingStyles = css` + color: ${palette.brandAlt[400]}; + ${textSans.small({ fontWeight: 'regular' })}; + line-height: ${lineHeights.tight} !important; + margin: 0; + + ${until.desktop} { + font-size: 12px; + } +`; + +const benefitsWrapper = css` + margin: 0 0 ${space[1]}px; + height: 16px; + position: relative; + + ${from.desktop} { + margin: 0 0 ${space[2]}px; + height: 20px; + } +`; + +const benefitStyles = css` + display: flex; +`; + +const dotsWrapper = css` + position: absolute; + display: flex; +`; + +const dotStyles = css` + background: ${palette.brandAlt[400]}; + width: 9px; + height: 9px; + border-radius: 50%; + margin-top: 4px; + margin-right: ${space[1]}px; + + ${from.desktop} { + width: 11px; + height: 11px; + margin-top: 5px; + margin-right: 6px; + } +`; + +const benefitTextStyles = css` + color: ${palette.neutral[100]}; + ${textSans.small()}; + line-height: ${lineHeights.regular} !important; + + ${until.desktop} { + line-height: 1rem !important; + font-size: 12px; + } +`; + +const fadeable = css` + transition: opacity 150ms linear; + opacity: 0; +`; + +const visible = css` + opacity: 1; +`; + +const SignInPromptHeader: ReactComponent = (props) => { + const { heading, subheading, primaryCta, benefits } = props.content; + const [benefitIndex, setBenefitIndex] = useState(-1); + const [benefitVisible, setBenefitVisible] = useState(false); + const [dotsVisible, setDotsVisible] = useState(() => { + const initialState = new Array(DOTS_COUNT); + initialState.fill(false); + return initialState; + }); + const benefitText = useMemo( + () => benefits?.[benefitIndex] ?? '', + [benefits, benefitIndex], + ); + const benefitCss = [benefitStyles, fadeable]; + + if (benefitVisible) { + benefitCss.push(visible); + } + + useEffect(() => { + let timeout: ReturnType | null = null; + const animationSteps: { callback: () => void; ms: number }[] = []; + + const queueAnimation = (callback: () => void, ms: number) => { + animationSteps.push({ callback, ms }); + }; + + if (benefits === null || !benefits.length) { + return; + } + + if (benefitIndex === -1) { + setBenefitIndex(0); + return; + } + + for (let i = 0; i < DOTS_COUNT; i++) { + const delay = i === 0 ? 0 : FADE_TIME_MS + ANIMATION_DELAY_MS; + // Fade in individual dots + queueAnimation(() => { + setDotsVisible((currentState) => { + const newState = [...currentState]; + newState.splice(i, 1, true); + return newState; + }); + }, delay); + } + + // Fade out all dots + queueAnimation(() => { + const newState = new Array(DOTS_COUNT).fill(false); + setDotsVisible(newState); + }, FADE_TIME_MS + ANIMATION_DELAY_MS); + + // Fade in benefit text + queueAnimation(() => { + setBenefitVisible(true); + }, FADE_TIME_MS + ANIMATION_DELAY_MS); + + if (benefitIndex < benefits.length - 1) { + // Fade out benefit text + queueAnimation(() => { + setBenefitVisible(false); + }, FADE_TIME_MS + TEXT_DELAY_MS); + + // Trigger this effect to run again + queueAnimation(() => { + setBenefitIndex(benefitIndex + 1); + }, FADE_TIME_MS + ANIMATION_DELAY_MS); + } + + const tick = () => { + const animationStep = animationSteps.shift(); + + if (!animationStep) { + return; + } + + timeout = setTimeout(() => { + animationStep.callback(); + tick(); + }, animationStep.ms); + }; + + // Start this stage of the animation + tick(); + + return () => { + // Clear any timeouts still running in case of unexpected unmount + if (timeout) { + clearTimeout(timeout); + } + }; + }, [benefits, benefitIndex]); + + return ( + +

{heading}

+

{subheading}

+ +
+
+ {dotsVisible.map((dotVisible, index) => { + const dotCss = [dotStyles, fadeable]; + + if (dotVisible) { + dotCss.push(visible); + } + + return
; + })} +
+
+
+ {benefitText} +
+
+ + {primaryCta && ( + + {primaryCta.ctaText} + + )} + + ); +}; + +const unvalidated = headerWrapper(SignInPromptHeader); +const validated = validatedHeaderWrapper(SignInPromptHeader); +export { + validated as SignInPromptHeader, + unvalidated as SignInPromptHeaderUnvalidated, +}; diff --git a/dotcom-rendering/src/components/marketing/header/common/HeaderDecorator.tsx b/dotcom-rendering/src/components/marketing/header/common/HeaderDecorator.tsx new file mode 100644 index 00000000000..ed97bdf87ca --- /dev/null +++ b/dotcom-rendering/src/components/marketing/header/common/HeaderDecorator.tsx @@ -0,0 +1,19 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/4925ef1e0ced5d221f1122afe79f93bd7448e0e5/packages/modules/src/modules/headers/common/HeaderDecorator.tsx + */ +import { css } from '@emotion/react'; +import { palette } from '@guardian/source-foundations'; +import type { Decorator } from '@storybook/react'; + +const background = css` + background-color: ${palette.brand[400]}; + padding: 10px; +`; + +export const HeaderDecorator: Decorator = (Story) => ( +
+ +
+); diff --git a/dotcom-rendering/src/components/marketing/hooks/useHasBeenSeen.ts b/dotcom-rendering/src/components/marketing/hooks/useHasBeenSeen.ts new file mode 100644 index 00000000000..43b95887af3 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/hooks/useHasBeenSeen.ts @@ -0,0 +1,53 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/4925ef1e0ced5d221f1122afe79f93bd7448e0e5/packages/modules/src/hooks/useHasBeenSeen.ts + */ +import libDebounce from 'lodash.debounce'; +import { useEffect, useRef, useState } from 'react'; + +export type HasBeenSeen = [boolean, (el: HTMLDivElement) => void]; + +const useHasBeenSeen = ( + options: IntersectionObserverInit, + debounce?: boolean, +): HasBeenSeen => { + const [hasBeenSeen, setHasBeenSeen] = useState(false); + const [node, setNode] = useState(null); + + const observer = useRef(null); + + // Enabling debouncing ensures the target element intersects for at least + // 200ms before the callback is executed + const intersectionFn: IntersectionObserverCallback = ([entry]) => { + if (entry?.isIntersecting) { + setHasBeenSeen(true); + } + }; + const intersectionCallback = debounce + ? libDebounce(intersectionFn, 200) + : intersectionFn; + + useEffect(() => { + if (observer.current) { + observer.current.disconnect(); + } + + observer.current = new window.IntersectionObserver( + intersectionCallback, + options, + ); + + const { current: currentObserver } = observer; + + if (node) { + currentObserver.observe(node); + } + + return (): void => currentObserver.disconnect(); + }, [node, options, intersectionCallback]); + + return [hasBeenSeen, setNode]; +}; + +export { useHasBeenSeen };