From 35edb74ece88d052a25efc9cec89dee94d6800ee Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Thu, 14 Dec 2023 08:49:28 +0000 Subject: [PATCH 01/18] Migrate standard epic to DCR --- .../SlotBodyEnd/ReaderRevenueEpic.tsx | 16 +- .../marketing/epics/BylineWithHeadshot.tsx | 97 ++++ .../marketing/epics/ContributionsEpic.tsx | 524 ++++++++++++++++++ ...butionsEpicArticleCountAboveWithOptOut.tsx | 500 +++++++++++++++++ .../epics/ContributionsEpicChoiceCards.tsx | 223 ++++++++ .../ContributionsEpicNewsletterSignup.tsx | 73 +++ .../epics/ContributionsEpicSignInCta.tsx | 53 ++ .../epics/ContributionsEpicTicker.tsx | 197 +++++++ .../components/marketing/epics/utils/ophan.ts | 46 ++ .../marketing/hooks/useArticleCountOptOut.ts | 37 ++ .../components/marketing/hooks/useTicker.ts | 29 + .../src/components/marketing/lib/stage.ts | 3 + .../marketing/shared/ModuleWrapper.tsx | 18 + 13 files changed, 1812 insertions(+), 4 deletions(-) create mode 100644 dotcom-rendering/src/components/marketing/epics/BylineWithHeadshot.tsx create mode 100644 dotcom-rendering/src/components/marketing/epics/ContributionsEpic.tsx create mode 100644 dotcom-rendering/src/components/marketing/epics/ContributionsEpicArticleCountAboveWithOptOut.tsx create mode 100644 dotcom-rendering/src/components/marketing/epics/ContributionsEpicChoiceCards.tsx create mode 100644 dotcom-rendering/src/components/marketing/epics/ContributionsEpicNewsletterSignup.tsx create mode 100644 dotcom-rendering/src/components/marketing/epics/ContributionsEpicSignInCta.tsx create mode 100644 dotcom-rendering/src/components/marketing/epics/ContributionsEpicTicker.tsx create mode 100644 dotcom-rendering/src/components/marketing/hooks/useArticleCountOptOut.ts create mode 100644 dotcom-rendering/src/components/marketing/hooks/useTicker.ts create mode 100644 dotcom-rendering/src/components/marketing/lib/stage.ts create mode 100644 dotcom-rendering/src/components/marketing/shared/ModuleWrapper.tsx diff --git a/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx b/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx index 9ad74e34803..f13380a3bb3 100644 --- a/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx +++ b/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx @@ -173,12 +173,20 @@ export const ReaderRevenueEpic = ({ 'contributions-epic-module', ); - window - .guardianPolyfilledImport(module.url) - .then((epicModule: { ContributionsEpic: EpicType }) => { + import( + /* webpackChunkName: "contributions-epic" */ `./marketing/epics/ContributionsEpic` + ) + .then((epicModule) => { endPerformanceMeasure(); - setEpic(() => epicModule.ContributionsEpic); // useState requires functions to be wrapped + // @ts-expect-error -- currently the type of the props in the response is too general + setEpic(() => epicModule.ContributionsEpic); }) + // window + // .guardianPolyfilledImport(module.url) + // .then((epicModule: { ContributionsEpic: EpicType }) => { + // endPerformanceMeasure(); + // setEpic(() => epicModule.ContributionsEpic); // useState requires functions to be wrapped + // }) .catch((error) => { const msg = error instanceof Error diff --git a/dotcom-rendering/src/components/marketing/epics/BylineWithHeadshot.tsx b/dotcom-rendering/src/components/marketing/epics/BylineWithHeadshot.tsx new file mode 100644 index 00000000000..6799fb485d6 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/BylineWithHeadshot.tsx @@ -0,0 +1,97 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/BylineWithHeadshot.tsx + */ +import { css } from '@emotion/react'; +import { body } from '@guardian/source-foundations'; +import { BylineWithImage } from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { ReactComponent } from '../lib/ReactComponent'; + +interface BylineWithHeadshotProps { + bylineWithImage: BylineWithImage; +} + +const bylineWithImageContainer = css` + margin: 0; + padding: 0; + position: relative; + width: 100%; + height: 130px; +`; + +const bylineCopyContainer = css` + width: 70%; + position: absolute; + bottom: 20px; + left: 0; +`; + +const bylineImageContainer = css` + max-width: 30%; + height: 100%; + position: absolute; + top: 0; + right: 0; +`; + +const bylineName = css` + ${body.medium({ fontWeight: 'bold' })}; + margin: 0; +`; + +const bylineDescription = css` + ${body.medium({ fontStyle: 'italic' })}; + margin: 0; +`; + +const bylineHeadshotImage = css` + height: 100%; + width: 100%; + object-fit: cover; +`; + +const bylineBottomDecoration = css` + position: absolute; + bottom: 0; + left: 0; + width: 100%; + background-repeat: repeat-x; + background-position: top; + background-size: 1px calc(0.25rem * 4 + 1px); + height: calc(0.25rem * 4 + 1px); + background-image: repeating-linear-gradient( + to bottom, + #dcdcdc, + #dcdcdc 1px, + transparent 1px, + transparent 0.25rem + ); +`; + +export const BylineWithHeadshot: ReactComponent = ({ + bylineWithImage, +}: BylineWithHeadshotProps) => { + const { name, description, headshot } = bylineWithImage; + + return ( +
+
+

{name}

+

{description}

+
+ {headshot && ( + <> +
+
+ {headshot.altText} +
+ + )} +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/epics/ContributionsEpic.tsx b/dotcom-rendering/src/components/marketing/epics/ContributionsEpic.tsx new file mode 100644 index 00000000000..f2cc5587a2c --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpic.tsx @@ -0,0 +1,524 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/ContributionsEpic.tsx + */ +import { useEffect, useState } from 'react'; +import { css } from '@emotion/react'; +import { body, headline } from '@guardian/source-foundations'; +import { palette, space } from '@guardian/source-foundations'; +import { from } from '@guardian/source-foundations'; +import { BylineWithHeadshot } from './BylineWithHeadshot'; +import { ContributionsEpicTicker } from './ContributionsEpicTicker'; +import { OphanTracking } from '../shared/ArticleCountOptOutPopup'; +import { ContributionsEpicArticleCountAboveWithOptOut } from './ContributionsEpicArticleCountAboveWithOptOut'; +import { useArticleCountOptOut } from '../hooks/useArticleCountOptOut'; +import { withParsedProps } from '../shared/ModuleWrapper'; +import { ContributionsEpicChoiceCards } from './ContributionsEpicChoiceCards'; +import { ContributionsEpicSignInCta } from './ContributionsEpicSignInCta'; +import ContributionsEpicNewsletterSignup from './ContributionsEpicNewsletterSignup'; +import { ContributionsEpicCtas } from './ContributionsEpicCtas'; +// TODO - do we need this in DCR? +// import { isValidApplePayWalletSession } from '../utils/applePay'; +import { OPHAN_COMPONENT_EVENT_APPLEPAY_AUTHORISED } from './utils/ophan'; +import { ReactComponent } from '../lib/ReactComponent'; +import { replaceArticleCount } from '../lib/replaceArticleCount'; +import { EpicProps } from '@guardian/support-dotcom-components/dist/shared/src/types/props/epic'; +import { ChoiceCardSelection } from '../lib/choiceCards'; +import { + ContributionFrequency, + Stage, +} from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { + containsNonArticleCountPlaceholder, + getLocalCurrencySymbol, + replaceNonArticleCountPlaceholders, +} from '@guardian/support-dotcom-components'; +import { logEpicView } from '../lib/viewLog'; +import { + addTrackingParamsToBodyLinks, + createInsertEventFromTracking, + createViewEventFromTracking, +} from '../lib/tracking'; +import { isProd } from '../lib/stage'; +import { useIsInView } from '../../../lib/useIsInView'; + +// CSS Styling +// ------------------------------------------- +const wrapperStyles = css` + padding: ${space[1]}px ${space[2]}px ${space[3]}px; + border-top: 1px solid ${palette.brandAlt[400]}; + background-color: ${palette.neutral[97]}; + + * { + ::selection { + background: ${palette.brandAlt[400]}; + } + ::-moz-selection { + background: ${palette.brandAlt[400]}; + } + } + + b, + strong { + font-weight: bold; + } +`; + +const headingStyles = css` + ${headline.xxsmall({ fontWeight: 'bold' })} + margin-top: 0; + margin-bottom: ${space[3]}px; +`; + +// Custom styles for tags in the Epic content +const linkStyles = css` + a { + color: ${palette.news[400]}; + text-decoration: none; + border-bottom: 1px solid ${palette.news[400]}; + } +`; + +const bodyStyles = css` + margin: 0 auto ${space[2]}px; + ${body.medium()}; + ${linkStyles} +`; + +const highlightWrapperStyles = css` + ${body.medium({ fontWeight: 'bold' })} + ${linkStyles} +`; + +const highlightStyles = css` + padding: 2px; + background-color: ${palette.brandAlt[400]}; +`; + +const imageWrapperStyles = css` + margin: ${space[3]}px 0 ${space[2]}px; + + ${from.tablet} { + margin: 10px 0; + } +`; + +const imageStyles = css` + height: 100%; + width: 100%; + object-fit: cover; +`; + +const articleCountAboveContainerStyles = css` + margin-bottom: ${space[4]}px; +`; + +// EpicHeader - local component +// ------------------------------------------- +interface EpicHeaderProps { + text: string; + numArticles: number; + tracking?: OphanTracking; + showAboveArticleCount: boolean; +} + +const EpicHeader: ReactComponent = ({ + text, + numArticles, + tracking, + showAboveArticleCount, +}: EpicHeaderProps) => { + const elements = replaceArticleCount( + text, + numArticles, + 'epic', + tracking, + !showAboveArticleCount, + ); + return

{elements}

; +}; + +// Highlighted - local component +// ------------------------------------------- +type HighlightedProps = { + highlightedText: string; + countryCode?: string; + numArticles: number; + tracking?: OphanTracking; + showAboveArticleCount: boolean; +}; + +const Highlighted: ReactComponent = ({ + highlightedText, + numArticles, + tracking, + showAboveArticleCount, +}: HighlightedProps) => { + const elements = replaceArticleCount( + highlightedText, + numArticles, + 'epic', + tracking, + !showAboveArticleCount, + ); + + return ( + + {' '} + {elements} + + ); +}; + +// EpicBodyParagraph - local component +// ------------------------------------------- +interface EpicBodyParagraphProps { + paragraph: string; + numArticles: number; + highlighted: JSX.Element | null; + tracking?: OphanTracking; + showAboveArticleCount: boolean; +} + +const EpicBodyParagraph: ReactComponent = ({ + paragraph, + numArticles, + highlighted, + tracking, + showAboveArticleCount, +}: EpicBodyParagraphProps) => { + const elements = replaceArticleCount( + paragraph, + numArticles, + 'epic', + tracking, + !showAboveArticleCount, + ); + + return ( +

+ {elements} + {highlighted ? highlighted : null} +

+ ); +}; + +// EpicBody - local component +// ------------------------------------------- +type BodyProps = { + paragraphs: string[]; + highlightedText?: string; + countryCode?: string; + numArticles: number; + tracking?: OphanTracking; + showAboveArticleCount: boolean; +}; + +const EpicBody: ReactComponent = ({ + countryCode, + numArticles, + paragraphs, + highlightedText, + tracking, + showAboveArticleCount, +}: BodyProps) => { + return ( + <> + {paragraphs.map((paragraph, idx) => { + const paragraphElement = ( + + ) : null + } + tracking={tracking} + showAboveArticleCount={showAboveArticleCount} + /> + ); + return paragraphElement; + })} + + ); +}; + +// ContributionsEpic - exported component +// ------------------------------------------- +const ContributionsEpic: ReactComponent = ({ + variant, + tracking, + countryCode, + articleCounts, + onReminderOpen, + fetchEmail, + submitComponentEvent, + openCmp, + hasConsentForArticleCount, + stage, +}: EpicProps) => { + const { + image, + tickerSettings, + showChoiceCards, + choiceCardAmounts, + forceApplePay, + name, + } = variant; + const [showApplePayButton, setShowApplePayButton] = + useState( + forceApplePay, + ); /* forceApplePay displays ApplePay button in storybook */ + + useEffect(() => { + const isInApplePayEpicTest = tracking.abTestName.includes('APPLE-PAY'); + if (isInApplePayEpicTest) { + // isValidApplePayWalletSession().then((validApplePayWalletSession) => { + // if (validApplePayWalletSession) { + // if (submitComponentEvent) { + // submitComponentEvent(OPHAN_COMPONENT_EVENT_APPLEPAY_AUTHORISED); + // } + // setShowApplePayButton(name === 'V1_APPLE_PAY'); + // } + // }); + } + }, []); + + const [choiceCardSelection, setChoiceCardSelection] = useState< + ChoiceCardSelection | undefined + >(); + + useEffect(() => { + if (showChoiceCards && choiceCardAmounts?.amountsCardData) { + const defaultFrequency: ContributionFrequency = + choiceCardAmounts.defaultContributionType || 'MONTHLY'; + const localAmounts = + choiceCardAmounts.amountsCardData[defaultFrequency]; + const defaultAmount = + localAmounts.defaultAmount || localAmounts.amounts[1] || 1; + + setChoiceCardSelection({ + frequency: defaultFrequency, + amount: defaultAmount, + }); + } + }, [showChoiceCards, choiceCardAmounts]); + + const currencySymbol = getLocalCurrencySymbol(countryCode); + + const { hasOptedOut, onArticleCountOptIn, onArticleCountOptOut } = + useArticleCountOptOut(); + + const [hasBeenSeen, setNode] = useIsInView({ + debounce: true, + threshold: 0, + }); + + useEffect(() => { + if (hasBeenSeen) { + // For the event stream + sendEpicViewEvent(tracking.referrerUrl, countryCode, stage); + + // For epic view count + logEpicView(tracking.abTestName); + + // For ophan + if (submitComponentEvent) { + submitComponentEvent( + createViewEventFromTracking( + tracking, + tracking.campaignCode, + ), + ); + } + } + }, [hasBeenSeen, submitComponentEvent]); + + useEffect(() => { + if (submitComponentEvent) { + submitComponentEvent( + createInsertEventFromTracking(tracking, tracking.campaignCode), + ); + } + }, [submitComponentEvent]); + + const cleanHighlighted = replaceNonArticleCountPlaceholders( + variant.highlightedText, + countryCode, + ); + + const sendEpicViewEvent = ( + url: string, + countryCode?: string, + stage?: Stage, + ): void => { + const path = 'events/epic-view'; + const host = isProd(stage) + ? 'https://contributions.guardianapis.com' + : 'https://contributions.code.dev-guardianapis.com'; + const body = JSON.stringify({ + url, + countryCode, + }); + + fetch(`${host}/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }).then((response) => { + if (!response.ok) { + console.log('Epic view event request failed', response); + } + }); + }; + + const cleanHeading = replaceNonArticleCountPlaceholders( + variant.heading, + countryCode, + ); + + const cleanParagraphs = variant.paragraphs + .map((paragraph) => + replaceNonArticleCountPlaceholders(paragraph, countryCode), + ) + .map((paragraph) => + addTrackingParamsToBodyLinks( + paragraph, + tracking, + articleCounts.for52Weeks, + countryCode, + ), + ); + + if ( + [cleanHighlighted, cleanHeading, ...cleanParagraphs].some( + containsNonArticleCountPlaceholder, + ) + ) { + return <>; // quick exit if something goes wrong. Ideally we'd throw and caller would catch/log but TODO that separately + } + + const ophanTracking: OphanTracking | undefined = submitComponentEvent && { + submitComponentEvent, + componentType: 'ACQUISITIONS_EPIC', + }; + + const showAboveArticleCount = !!( + variant.separateArticleCount?.type === 'above' && + hasConsentForArticleCount + ); + + return ( +
+ {showAboveArticleCount && ( +
+ +
+ )} + + {tickerSettings && tickerSettings.tickerData && ( + + )} + + {image && ( +
+ {image.altText} +
+ )} + + {cleanHeading && ( + + )} + + + + {variant.bylineWithImage && ( + + )} + + {choiceCardAmounts && ( + + )} + + {variant.newsletterSignup ? ( + + ) : ( + + )} + + {variant.showSignInLink && } +
+ ); +}; + +export const validate = (props: unknown): props is EpicProps => { + const result = epicPropsSchema.safeParse(props); + return result.success; +}; + +const validatedEpic = withParsedProps(ContributionsEpic, validate); +const unValidatedEpic = ContributionsEpic; +export { + validatedEpic as ContributionsEpic, + unValidatedEpic as ContributionsEpicUnvalidated, +}; diff --git a/dotcom-rendering/src/components/marketing/epics/ContributionsEpicArticleCountAboveWithOptOut.tsx b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicArticleCountAboveWithOptOut.tsx new file mode 100644 index 00000000000..58e19da841a --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicArticleCountAboveWithOptOut.tsx @@ -0,0 +1,500 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/ContributionsEpicArticleCountAboveWithOptOut.tsx + */ +import React, { useState } from 'react'; +import { body, textSans } from '@guardian/source-foundations'; +import { palette, space } from '@guardian/source-foundations'; +import { css } from '@emotion/react'; +import { + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_OPEN, + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_CLOSE, + OPHAN_COMPONENT_ARTICLE_COUNT_STAY_IN, + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT, + OPHAN_COMPONENT_ARTICLE_COUNT_STAY_OUT, + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_IN, +} from './utils/ophan'; +import { from, until } from '@guardian/source-foundations'; +import { ArticleCounts } from '../../../lib/articleCount'; +import { ArticleCountType } from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { OphanComponentEvent } from '@guardian/libs'; +import { ReactComponent } from '../lib/ReactComponent'; + +export interface ContributionsEpicArticleCountAboveWithOptOutProps { + articleCounts: ArticleCounts; + copy?: string; + countType?: ArticleCountType; + isArticleCountOn: boolean; + onArticleCountOptOut: () => void; + onArticleCountOptIn: () => void; + openCmp?: () => void; + submitComponentEvent?: (componentEvent: OphanComponentEvent) => void; +} + +export const ContributionsEpicArticleCountAboveWithOptOut: ReactComponent< + ContributionsEpicArticleCountAboveWithOptOutProps +> = ({ + articleCounts, + copy, + countType, + isArticleCountOn, + onArticleCountOptOut, + onArticleCountOptIn, + openCmp, + submitComponentEvent, +}: ContributionsEpicArticleCountAboveWithOptOutProps) => { + const [isOpen, setIsOpen] = useState(false); + + const onToggleClick = () => { + setIsOpen(!isOpen); + submitComponentEvent && + submitComponentEvent( + isOpen + ? OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_CLOSE + : OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_OPEN, + ); + }; + + const onStayInClick = () => { + setIsOpen(false); + submitComponentEvent && + submitComponentEvent(OPHAN_COMPONENT_ARTICLE_COUNT_STAY_IN); + }; + + const onOptOutClick = () => { + setIsOpen(false); + onArticleCountOptOut(); + submitComponentEvent && + submitComponentEvent(OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT); + }; + + const onOptInClick = () => { + setIsOpen(false); + onArticleCountOptIn(); + submitComponentEvent && + submitComponentEvent(OPHAN_COMPONENT_ARTICLE_COUNT_OPT_IN); + }; + + const onStayOutClick = () => { + setIsOpen(false); + submitComponentEvent && + submitComponentEvent(OPHAN_COMPONENT_ARTICLE_COUNT_STAY_OUT); + }; + + const articleCount = articleCounts[countType ?? 'for52Weeks']; + + return ( +
+ + + {isOpen && ( +
+
+
+ {isArticleCountOn ? ( + <> +
+ Many readers tell us they enjoy seeing how + many pieces of Guardian journalism they’ve + read, watched or listened to. So here’s your + count. Can we continue showing you this on + support appeals like this? +
+
+ + +
+ + ) : ( + <> +
+ Many readers tell us they enjoy seeing how + many pieces of Guardian journalism they’ve + read, watched or listened to. Can we start + showing you your article count on support + appeals like this? +
+
+ + +
+ + )} +
+
+ To opt out of other tracking activity, manage your{' '} + + Privacy Settings + +
+
+ )} +
+ ); +}; + +// --- Helper components --- // + +interface ArticleCountWithToggleProps { + articleCount: number; + isArticleCountOn: boolean; + onToggleClick: () => void; + copy?: string; +} + +const ArticleCountWithToggle: ReactComponent = ({ + isArticleCountOn, + articleCount, + onToggleClick, + copy, +}: ArticleCountWithToggleProps) => { + if (isArticleCountOn && articleCount >= 5) { + return ( +
+ + +
+
Article count
+ + on + +
+
+ ); + } + + if (!isArticleCountOn) { + return ( +
+
Article count
+ + off + +
+ ); + } + + return <>; +}; + +const ARTICLE_COUNT_TEMPLATE = '%%ARTICLE_COUNT%%'; +const containsArticleCountTemplate = (copy: string): boolean => + copy.includes(ARTICLE_COUNT_TEMPLATE); + +interface CustomArticleCountCopyProps { + articleCount: number; + copy: string; +} + +const CustomArticleCountCopy: ReactComponent = ({ + articleCount, + copy, +}) => { + const [copyHead, copyTail] = copy.split(ARTICLE_COUNT_TEMPLATE); + + return ( +
+ {copyHead} + {articleCount} articles + {copyTail.substring(1, 9) === 'articles' + ? copyTail.substring(9) + : copyTail} +
+ ); +}; + +interface ArticleCountProps { + articleCount: number; + copy?: string; +} + +const ArticleCount: ReactComponent = ({ + articleCount, + copy, +}) => { + if (copy && containsArticleCountTemplate(copy)) { + // Custom article count message + return ( + + ); + } else if (articleCount >= 50) { + return ( +
+ Congratulations on being one of our top readers globally – + you've read{' '} + {articleCount} articles in + the last year +
+ ); + } else { + return ( +
+ You've read{' '} + {articleCount} articles in + the last year +
+ ); + } +}; + +// --- Styles --- // + +const topContainer = css` + display: flex; + flex-direction: column-reverse; + + ${from.tablet} { + display: block; + margin-top: 10px; + } +`; + +const articleCountAboveContainerStyles = css` + font-style: italic; + ${body.small({ fontWeight: 'bold' })}; + + ${from.tablet} { + ${body.medium({ fontWeight: 'bold' })}; + } +`; + +const optOutContainer = css` + color: ${palette.opinion[400]}; +`; + +const articleCountOnHeaderContainerStyles = css` + display: flex; + justify-content: space-between; + flex-direction: column-reverse; + align-items: flex-start; + + ${from.tablet} { + flex-direction: row; + align-items: flex-start; + } +`; + +const articleCountWrapperStyles = css` + flex-shrink: 0; + display: flex; + flex-direction: row; + align-items: flex-start; + margin-right: ${space[2]}px; + margin-bottom: ${space[2]}px; + justify-content: start; + + ${from.tablet} { + margin-left: ${space[5]}px; + margin-bottom: 0; + justify-content: flex-end; + } +`; + +const articleCountTextStyles = css` + ${textSans.xxsmall()}; + margin-right: ${space[1]}px; + + ${from.tablet} { + ${textSans.small()}; + } +`; + +const articleCountCtaStyles = css` + margin-top: 0; + + ${textSans.xxsmall({ fontWeight: 'bold' })}; + + ${from.tablet} { + ${textSans.small({ fontWeight: 'bold' })}; + } +`; + +const articleCountDescriptionTopContainerStyles = css` + border-bottom: 1px solid ${palette.neutral[46]}; + position: relative; + margin-bottom: ${space[2]}px; + + ${from.tablet} { + margin-top: ${space[4]}px; + border-top: 1px solid ${palette.neutral[0]}; + border-bottom: 1px solid ${palette.neutral[0]}; + } +`; + +const articleCountDescriptionContainer = css` + align-items: center; + display: flex; + flex-direction: column; + padding: ${space[1]}px ${space[1]}px 0; + + ${from.tablet} { + flex-direction: row; + padding: ${space[1]}px 0; + align-items: start; + margin-top: ${space[1]}px; + } +`; + +const articleCountBodyTextStyles = css` + ${textSans.small()}; + width: 100%; + + ${from.tablet} { + width: 68%; + } +`; + +const articleCountCtasContainerStyles = css` + display: flex; + align-self: start; + margin-top: ${space[4]}px; + > * + * { + margin-left: ${space[3]}px; + } + + ${from.tablet} { + flex-direction: column; + margin-left: auto; + margin-top: ${space[2]}px; + justify-content: space-between; + > * + * { + margin-top: ${space[3]}px; + margin-left: 0; + } + } +`; + +const articleCountOptInCtaStyles = css` + background-color: ${palette.neutral[0]}; +`; + +const articleCountDefaultCtaStyles = css` + background-color: ${palette.neutral[0]}; + padding: auto ${space[6]}px; + + ${from.tablet} { + padding-left: ${space[5]}px; + } +`; + +const articleCountOptOutCtaStyles = css` + color: ${palette.neutral[0]}; + border: 1px solid ${palette.neutral[0]}; +`; + +const trackingSettingsContainerStyles = css` + margin: ${space[4]}px auto ${space[3]}px; + ${textSans.xxsmall()}; + + ${from.tablet} { + ${textSans.xsmall()}; + } +`; + +const privacySettingsLinkStyles = css` + ${textSans.xxsmall({ fontWeight: 'bold' })}; + + ${from.tablet} { + ${textSans.xsmall({ fontWeight: 'bold' })}; + } +`; + +const caretStyles = css` + &:before { + content: ''; + display: block; + position: absolute; + bottom: -14px; + width: 0; + height: 0; + border: 7px solid transparent; + border-top-color: ${palette.neutral[46]}; + + ${from.tablet} { + right: 5px; + bottom: 100%; + border: 10px solid transparent; + border-bottom-color: ${palette.neutral[0]}; + } + + ${until.tablet} { + left: 75px; + } + } + + &:after { + content: ''; + display: block; + position: absolute; + bottom: -12px; + width: 0; + height: 0; + border: 6px solid transparent; + border-top-color: ${palette.neutral[97]}; + + ${from.tablet} { + right: 6px; + bottom: 100%; + border: 9px solid transparent; + border-bottom-color: ${palette.neutral[97]}; + } + + ${until.tablet} { + left: 76px; + } + } +`; diff --git a/dotcom-rendering/src/components/marketing/epics/ContributionsEpicChoiceCards.tsx b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicChoiceCards.tsx new file mode 100644 index 00000000000..1c9457dfa13 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicChoiceCards.tsx @@ -0,0 +1,223 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/ContributionsEpicChoiceCards.tsx + */ +import React, { useEffect } from 'react'; +import { css } from '@emotion/react'; +import { until, visuallyHidden } from '@guardian/source-foundations'; +import { ChoiceCardSelection, contributionType } from '../lib/choiceCards'; +import { OphanComponentEvent } from '@guardian/libs'; +import { + ContributionFrequency, + contributionTabFrequencies, + SelectedAmountsVariant, +} from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { ReactComponent } from '../lib/ReactComponent'; +import { useIsInView } from '../../../lib/useIsInView'; + +// CSS Styling +// ------------------------------------------- +const frequencyChoiceCardGroupOverrides = css` + ${until.mobileLandscape} { + > div { + display: flex !important; + } + + > div label:nth-of-type(2) { + margin-left: 4px !important; + margin-right: 4px !important; + } + } +`; + +const hideChoiceCardGroupLegend = css` + legend { + ${visuallyHidden}; + } +`; + +// This `position: relative` is necessary to stop it jumping to the top of the page when a button is clicked +const container = css` + position: relative; +`; + +// ContributionsEpicChoiceCards - exported component +// ------------------------------------------- +interface EpicChoiceCardProps { + selection?: ChoiceCardSelection; + setSelectionsCallback: (choiceCardSelection: ChoiceCardSelection) => void; + submitComponentEvent?: (event: OphanComponentEvent) => void; + currencySymbol: string; + amountsTest: SelectedAmountsVariant; +} + +export const ContributionsEpicChoiceCards: ReactComponent< + EpicChoiceCardProps +> = ({ + selection, + setSelectionsCallback, + submitComponentEvent, + currencySymbol, + amountsTest, +}: EpicChoiceCardProps) => { + if (!selection || !amountsTest) { + return <>; + } + + const { + testName = 'test_undefined', + variantName = 'variant_undefined', + displayContributionType = contributionTabFrequencies, + amountsCardData, + } = amountsTest; + + if (!amountsCardData) { + return <>; + } + + const [hasBeenSeen, setNode] = useIsInView({ + debounce: true, + threshold: 0, + }); + + useEffect(() => { + if (hasBeenSeen) { + // For ophan + if (submitComponentEvent) { + submitComponentEvent({ + component: { + componentType: 'ACQUISITIONS_OTHER', + id: 'contributions-epic-choice-cards', + }, + action: 'VIEW', + abTest: { + name: testName, + variant: variantName, + }, + }); + } + } + }, [hasBeenSeen, submitComponentEvent]); + + const trackClick = (type: 'amount' | 'frequency'): void => { + if (submitComponentEvent) { + submitComponentEvent({ + component: { + componentType: 'ACQUISITIONS_OTHER', + id: `contributions-epic-choice-cards-change-${type}`, + }, + action: 'CLICK', + }); + } + }; + + const updateAmount = (amount: number | 'other') => { + trackClick('amount'); + setSelectionsCallback({ + frequency: selection.frequency, + amount: amount, + }); + }; + + const updateFrequency = (frequency: ContributionFrequency) => { + trackClick('frequency'); + setSelectionsCallback({ + frequency: frequency, + amount: amountsCardData[frequency].defaultAmount, + }); + }; + + const ChoiceCardAmount = ({ amount }: { amount?: number }) => { + if (amount) { + return ( + updateAmount(amount)} + /> + ); + } + return null; + }; + + const generateChoiceCardAmountsButtons = () => { + const productData = amountsCardData[selection.frequency]; + const requiredAmounts = productData.amounts; + const hideChooseYourAmount = productData.hideChooseYourAmount ?? false; + + // Something is wrong with the data + if (!Array.isArray(requiredAmounts) || !requiredAmounts.length) { + return ( + + ); + } + + return ( + <> + + + {hideChooseYourAmount ? ( + + ) : ( + updateAmount('other')} + /> + )} + + ); + }; + + const generateChoiceCardFrequencyTab = ( + frequency: ContributionFrequency, + ) => { + return ( + updateFrequency(frequency)} + /> + ); + }; + + return ( +
+
+ + {displayContributionType.map((f) => + generateChoiceCardFrequencyTab(f), + )} + +
+ + {generateChoiceCardAmountsButtons()} + +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/epics/ContributionsEpicNewsletterSignup.tsx b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicNewsletterSignup.tsx new file mode 100644 index 00000000000..fdb291c9add --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicNewsletterSignup.tsx @@ -0,0 +1,73 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/NewsletterSignup.tsx + */ +import { useEffect, useRef, useState } from 'react'; +import { css } from '@emotion/react'; +import { space } from '@guardian/source-foundations'; + +const containerStyles = css` + margin: ${space[6]}px ${space[2]}px ${space[1]}px 0; + width: 100%; +`; + +const ContributionsEpicNewsletterSignup = ({ + url, +}: { + url: string; +}): JSX.Element => { + const [iframeHeight, setIframeHeight] = useState(60); + const iframeRef = useRef(null); + + useEffect(() => { + // Handle iframe resize events. Based on https://github.com/guardian/dotcom-rendering/blob/main/dotcom-rendering/src/web/browser/newsletterEmbedIframe/init.ts + window.addEventListener('message', (event) => { + try { + // Check if this is the newsletter iframe + const contentWindow = iframeRef?.current?.contentWindow; + if ( + contentWindow && + event.source && + contentWindow === event.source + ) { + const message = JSON.parse(event.data); + + if (message.type === 'set-height') { + if (typeof message.value === 'number') { + setIframeHeight(message.value); + } else if (typeof message.value === 'string') { + const value = parseInt(message.value, 10); + if (Number.isInteger(value)) { + setIframeHeight(message.value); + } + } + } + } + } catch (err) { + console.log( + `Error handling event in epic NewsletterSignup: ${err}`, + ); + } + }); + }, []); + + return ( +
+