diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index cd243fa1704..3831b214ee0 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -67,7 +67,7 @@ "@guardian/source-foundations": "13.2.0", "@guardian/source-react-components": "16.0.1", "@guardian/source-react-components-development-kitchen": "15.0.0", - "@guardian/support-dotcom-components": "1.1.0", + "@guardian/support-dotcom-components": "1.1.1", "@guardian/tsconfig": "0.2.0", "@playwright/test": "1.40.1", "@sentry/browser": "7.75.1", diff --git a/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx b/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx index 9ad74e34803..57e603b9e74 100644 --- a/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx +++ b/dotcom-rendering/src/components/SlotBodyEnd/ReaderRevenueEpic.tsx @@ -173,9 +173,10 @@ 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 }) 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..8957a0d26aa --- /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, palette } from '@guardian/source-foundations'; +import type { BylineWithImage } from '@guardian/support-dotcom-components/dist/shared/src/types'; +import type { 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, + ${palette.neutral[86]}, + ${palette.neutral[86]} 1px, + transparent 1px, + transparent 0.25rem + ); +`; + +export const BylineWithHeadshot: ReactComponent = ({ + bylineWithImage, +}) => { + const { name, description, headshot } = bylineWithImage; + + return ( +
+
+

{name}

+

{description}

+
+ {headshot && ( + <> +
+
+ {headshot.altText} +
+ + )} +
+ ); +}; diff --git a/dotcom-rendering/src/components/marketing/epics/ContributionsEpic.stories.tsx b/dotcom-rendering/src/components/marketing/epics/ContributionsEpic.stories.tsx new file mode 100644 index 00000000000..52198d36a64 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpic.stories.tsx @@ -0,0 +1,408 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/ContributionsEpic.stories.tsx + */ +import { css } from '@emotion/react'; +import { SecondaryCtaType } from '@guardian/support-dotcom-components'; +import { + TickerCountType, + TickerEndType, +} from '@guardian/support-dotcom-components'; +import type { Meta, StoryObj } from '@storybook/react'; +import lzstring from 'lz-string'; +import React from 'react'; +import { ContributionsEpicUnvalidated as ContributionsEpic } from './ContributionsEpic'; +import { props } from './utils/storybook'; + +const style = css` + max-width: 620px; + margin: 3em auto; +`; + +const { variant, articleCounts, tracking } = props; + +type WithJsonProps = T & { json?: string }; +type Props = WithJsonProps>; +const meta: Meta = { + component: ContributionsEpic, + title: 'Components/marketing/ContributionsEpic', + args: { + variant, + articleCounts, + tracking, + countryCode: 'GB', + json: '', + stage: 'DEV', + }, + render: ({ json, ...args }) => { + const jsonProps = json + ? JSON.parse(lzstring.decompressFromEncodedURIComponent(json)) + : {}; + + return ( +
+ +
+ ); + }, +}; +export default meta; + +type Story = StoryObj; +export const Default: Story = { + storyName: 'Basic ContributionsEpic', +}; + +export const WithBackgroundImage: Story = { + storyName: 'ContributionsEpic with background image', + args: { + ...meta.args, + variant: { + ...props.variant, + image: { + mainUrl: + 'https://media.guim.co.uk/a2d31356be7dad09518b09aa5f39a4c7994e08c1/0_511_4262_2539/1000.jpg', + altText: 'An image of a cat', + }, + }, + }, +}; + +export const WithBylineAndHeadshot: Story = { + storyName: 'ContributionsEpic with byline + headshot image', + args: { + ...meta.args, + variant: { + ...props.variant, + separateArticleCount: { + type: 'above', + }, + bylineWithImage: { + name: 'Lenore Taylor', + description: 'Editor, Guardian Australia', + headshot: { + mainUrl: + 'https://i.guim.co.uk/img/media/8eda1b06a686fe5ab4f7246bd6b5f8e63851088e/0_0_300_250/300.png?quality=85&s=f42e9642f335d705cab8b712bbbcb64e', + altText: 'Lenore Taylor staff byline photograph', + }, + }, + heading: '', + paragraphs: [ + '… when I joined Guardian Australia as founding political editor, I wanted to be part of a project that brought a new, independent, fierce and progressive voice to one of the most heavily concentrated media markets in the world.', + 'From the start, we identified issues we felt were underreported and where we thought we could make a difference: the climate emergency, Indigenous affairs, gender equality, welfare policy, the treatment of asylum seekers. Nearly a decade later, and six years after I stepped up to be editor, I believe our reporting is making a difference.', + 'On climate, we have consistently called out inaction and written about how things might be. We have held policy-makers to account and documented how global heating is changing the lives of Australians. We have helped to shift the debate on Indigenous deaths in custody via our years-long Deaths Inside investigation, and produced award-winning coverage of the fight for gender equality.', + "But the fight for progress continues, and we can't do any of this without the support of our readers. It is your passion, engagement and financial contributions which underpin our journalism. We have no billionaire owner or shareholders. We are independent, and every dollar we receive is invested back into creating quality journalism that remains free and open for all to read.", + 'If you are able to help with a monthly or single contribution, it will boost our resources and enhance our ability to continue this vital work.', + 'Thank you', + ], + highlightedText: '', + }, + }, +}; + +export const WithBylineOnly: Story = { + storyName: 'ContributionsEpic with byline only', + args: { + ...meta.args, + variant: { + ...props.variant, + separateArticleCount: { + type: 'above', + }, + bylineWithImage: { + name: 'Lenore Taylor', + description: 'Editor, Guardian Australia', + }, + heading: '', + paragraphs: [ + '… when I joined Guardian Australia as founding political editor, I wanted to be part of a project that brought a new, independent, fierce and progressive voice to one of the most heavily concentrated media markets in the world.', + 'From the start, we identified issues we felt were underreported and where we thought we could make a difference: the climate emergency, Indigenous affairs, gender equality, welfare policy, the treatment of asylum seekers. Nearly a decade later, and six years after I stepped up to be editor, I believe our reporting is making a difference.', + 'On climate, we have consistently called out inaction and written about how things might be. We have held policy-makers to account and documented how global heating is changing the lives of Australians. We have helped to shift the debate on Indigenous deaths in custody via our years-long Deaths Inside investigation, and produced award-winning coverage of the fight for gender equality.', + "But the fight for progress continues, and we can't do any of this without the support of our readers. It is your passion, engagement and financial contributions which underpin our journalism. We have no billionaire owner or shareholders. We are independent, and every dollar we receive is invested back into creating quality journalism that remains free and open for all to read.", + 'If you are able to help with a monthly or single contribution, it will boost our resources and enhance our ability to continue this vital work.', + 'Thank you', + ], + highlightedText: '', + }, + }, +}; + +export const WithReminder: Story = { + storyName: 'ContributionsEpic with reminder', + args: { + ...meta.args, + variant: { + ...props.variant, + secondaryCta: { + type: SecondaryCtaType.ContributionsReminder, + }, + showReminderFields: { + reminderCta: 'Remind me in May', + reminderPeriod: '2020-05-01', + reminderLabel: 'May', + }, + }, + stage: 'DEV', + }, +}; + +export const WithReminderPrefilled: Story = { + storyName: 'ContributionsEpic with reminder pre-filled', + args: { + ...meta.args, + variant: { + ...props.variant, + secondaryCta: { + type: SecondaryCtaType.ContributionsReminder, + }, + showReminderFields: { + reminderCta: 'Remind me in May', + reminderPeriod: '2020-05-01', + reminderLabel: 'May', + }, + }, + fetchEmail: () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve('test@guardian.co.uk'); + }, 500); + }); + }, + }, +}; + +export const WithReminderAndSignInLink: Story = { + storyName: 'ContributionsEpic with reminder and sign-in link', + args: { + ...meta.args, + variant: { + ...props.variant, + showSignInLink: true, + secondaryCta: { + type: SecondaryCtaType.ContributionsReminder, + }, + showReminderFields: { + reminderCta: 'Remind me in May', + reminderPeriod: '2020-05-01', + reminderLabel: 'May', + }, + }, + }, +}; + +export const WithTicker: Story = { + storyName: 'ContributionsEpic with ticker', + args: { + ...meta.args, + variant: { + ...props.variant, + tickerSettings: { + countType: TickerCountType.money, + endType: TickerEndType.unlimited, + currencySymbol: '£', + copy: { + countLabel: 'contributed', + goalReachedPrimary: "We've met our goal - thank you", + goalReachedSecondary: + 'Contributions are still being accepted', + }, + tickerData: { + total: 10000, + goal: 100000, + }, + name: 'US', + }, + }, + }, +}; + +export const WithAboveArticleCount: Story = { + storyName: 'ContributionsEpic with article count above', + args: { + ...meta.args, + variant: { + ...props.variant, + separateArticleCount: { + type: 'above', + }, + }, + articleCounts: { + for52Weeks: 25, + forTargetedWeeks: 25, + }, + hasConsentForArticleCount: true, + }, +}; + +export const WithAboveTopReaderArticleCount: Story = { + storyName: 'ContributionsEpic with top reader article count above', + args: { + ...meta.args, + variant: { + ...props.variant, + separateArticleCount: { + type: 'above', + }, + }, + articleCounts: { + for52Weeks: 99, + forTargetedWeeks: 99, + }, + hasConsentForArticleCount: true, + }, +}; + +export const WithAboveArticleCountNoConsent: Story = { + storyName: 'ContributionsEpic with article count above but no consent', + args: { + ...meta.args, + variant: { + ...props.variant, + separateArticleCount: { + type: 'above', + }, + }, + articleCounts: { + for52Weeks: 99, + forTargetedWeeks: 99, + }, + hasConsentForArticleCount: false, + }, +}; + +export const WithChoiceCards: Story = { + storyName: 'ContributionsEpic with choice cards', + args: { + ...meta.args, + variant: { + ...props.variant, + secondaryCta: { + type: SecondaryCtaType.ContributionsReminder, + }, + showReminderFields: { + reminderCta: 'Remind me in October', + reminderPeriod: '2021-10-01', + reminderLabel: 'October', + }, + showChoiceCards: true, + choiceCardAmounts: { + testName: 'Storybook_test', + variantName: 'Control', + defaultContributionType: 'MONTHLY', + displayContributionType: ['ONE_OFF', 'MONTHLY', 'ANNUAL'], + amountsCardData: { + ONE_OFF: { + amounts: [5, 10, 15, 20], + defaultAmount: 5, + hideChooseYourAmount: false, + }, + MONTHLY: { + amounts: [6, 12], + defaultAmount: 12, + hideChooseYourAmount: true, + }, + ANNUAL: { + amounts: [50, 100, 150, 200], + defaultAmount: 100, + hideChooseYourAmount: true, + }, + }, + }, + }, + }, +}; + +export const WithChoiceCardsAndSignInLink: Story = { + storyName: 'ContributionsEpic with choice cards and sign-in link', + args: { + ...meta.args, + variant: { + ...props.variant, + name: 'V1_SIGN_IN', + showSignInLink: true, + showChoiceCards: true, + choiceCardAmounts: { + testName: 'Storybook_test', + variantName: 'Control', + defaultContributionType: 'MONTHLY', + displayContributionType: ['ONE_OFF', 'MONTHLY', 'ANNUAL'], + amountsCardData: { + ONE_OFF: { + amounts: [5, 10, 15, 20], + defaultAmount: 5, + hideChooseYourAmount: false, + }, + MONTHLY: { + amounts: [6, 12], + defaultAmount: 12, + hideChooseYourAmount: true, + }, + ANNUAL: { + amounts: [50, 100, 150, 200], + defaultAmount: 100, + hideChooseYourAmount: true, + }, + }, + }, + }, + }, +}; + +export const WithSignInLink: Story = { + storyName: 'ContributionsEpic with sign-in link', + args: { + ...meta.args, + variant: { + ...props.variant, + showSignInLink: true, + }, + }, +}; + +export const WithoutSupportUrl: Story = { + storyName: 'ContributionsEpic without support url', + args: { + ...meta.args, + variant: { + ...props.variant, + cta: { + baseUrl: 'https://theguardian.com', + text: 'The Guardian', + }, + }, + }, +}; + +export const WithNewsletterSignup: Story = { + storyName: 'ContributionsEpic with newsletter signup', + args: { + ...meta.args, + variant: { + ...props.variant, + highlightedText: undefined, + heading: 'Sign up to the Fiver', + paragraphs: [ + "Kick off your evenings with the Guardian's take on the world of football", + ], + newsletterSignup: { + url: 'https://www.theguardian.com/email/form/plaintone/rrcp-epic/4163', + }, + }, + }, +}; +export const WithParagraphLinks: Story = { + storyName: 'ContributionsEpic with paragraph links', + args: { + ...meta.args, + variant: { + ...props.variant, + paragraphs: [ + `... we have a small favour to ask. You've read %%ARTICLE_COUNT%% articles. More people, like you, are reading and supporting the Guardian’s independent, investigative journalism than ever before. And unlike many news organisations, we made the choice to keep our reporting open for all, regardless of where they live or what they can afford to pay.`, + 'The Guardian will engage with the most critical issues of our time – from the escalating climate catastrophe to widespread inequality to the influence of big tech on our lives. At a time when factual information is a necessity, we believe that each of us, around the world, deserves access to accurate reporting with integrity at its heart.', + 'Our editorial independence means we set our own agenda and voice our own opinions. Guardian journalism is free from commercial and political bias and not influenced by billionaire owners or shareholders. This means we can give a voice to those less heard, explore where others turn away, and rigorously challenge those in power.', + 'We hope you will consider supporting us today. We need your support to keep delivering quality journalism that’s open and independent. Every reader contribution, however big or small, is so valuable. ', + ], + }, + }, +}; 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..50e32c8efa6 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpic.tsx @@ -0,0 +1,491 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/ContributionsEpic.tsx + */ +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 { + containsNonArticleCountPlaceholder, + getLocalCurrencySymbol, + replaceNonArticleCountPlaceholders, +} from '@guardian/support-dotcom-components'; +import { epicPropsSchema } from '@guardian/support-dotcom-components'; +import type { + ContributionFrequency, + EpicProps, + Stage, +} from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { useEffect, useState } from 'react'; +import { useIsInView } from '../../../lib/useIsInView'; +import { useArticleCountOptOut } from '../hooks/useArticleCountOptOut'; +import type { ChoiceCardSelection } from '../lib/choiceCards'; +import type { ReactComponent } from '../lib/ReactComponent'; +import { replaceArticleCount } from '../lib/replaceArticleCount'; +import { isProd } from '../lib/stage'; +import { + addTrackingParamsToBodyLinks, + createInsertEventFromTracking, + createViewEventFromTracking, +} from '../lib/tracking'; +import { logEpicView } from '../lib/viewLog'; +import type { OphanTracking } from '../shared/ArticleCountOptOutPopup'; +import { withParsedProps } from '../shared/ModuleWrapper'; +import { BylineWithHeadshot } from './BylineWithHeadshot'; +import { ContributionsEpicArticleCountAboveWithOptOut } from './ContributionsEpicArticleCountAboveWithOptOut'; +import { ContributionsEpicChoiceCards } from './ContributionsEpicChoiceCards'; +import { ContributionsEpicCtas } from './ContributionsEpicCtas'; +import { ContributionsEpicNewsletterSignup } from './ContributionsEpicNewsletterSignup'; +import { ContributionsEpicSignInCta } from './ContributionsEpicSignInCta'; +import { ContributionsEpicTicker } from './ContributionsEpicTicker'; + +// 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; + 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; + numArticles: number; + tracking?: OphanTracking; + showAboveArticleCount: boolean; +}; + +const EpicBody: ReactComponent = ({ + numArticles, + paragraphs, + highlightedText, + tracking, + showAboveArticleCount, +}: BodyProps) => { + return ( + <> + {paragraphs.map((paragraph, idx) => { + const paragraphElement = ( + + ) : null + } + tracking={tracking} + showAboveArticleCount={showAboveArticleCount} + /> + ); + return paragraphElement; + })} + + ); +}; + +const sendEpicViewEvent = ( + url: string, + stage?: Stage, + countryCode?: string, +): void => { + const path = 'events/epic-view'; + const host = isProd(stage) + ? 'https://contributions.guardianapis.com' + : 'https://contributions.code.dev-guardianapis.com'; + const eventBody = JSON.stringify({ + url, + countryCode, + }); + + void fetch(`${host}/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: eventBody, + }).then((response) => { + if (!response.ok) { + console.log('Epic view event request failed', response); + } + }); +}; + +const ContributionsEpic: ReactComponent = ({ + variant, + tracking, + countryCode, + articleCounts, + onReminderOpen, + fetchEmail, + submitComponentEvent, + openCmp, + hasConsentForArticleCount, + stage, +}: EpicProps) => { + const { image, tickerSettings, showChoiceCards, choiceCardAmounts } = + variant; + + 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; + + 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 + if (!window?.guardian?.config?.isDev && stage !== 'DEV') { + sendEpicViewEvent(tracking.referrerUrl, stage, countryCode); + } + + // For epic view count + logEpicView(tracking.abTestName); + + // For ophan + if (submitComponentEvent) { + submitComponentEvent( + createViewEventFromTracking( + tracking, + tracking.campaignCode, + ), + ); + } + } + }, [hasBeenSeen, submitComponentEvent, countryCode, stage, tracking]); + + useEffect(() => { + if (submitComponentEvent) { + submitComponentEvent( + createInsertEventFromTracking(tracking, tracking.campaignCode), + ); + } + }, [submitComponentEvent, tracking]); + + const cleanHighlighted = replaceNonArticleCountPlaceholders( + variant.highlightedText, + countryCode, + ); + + 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?.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..1962f955ca2 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicArticleCountAboveWithOptOut.tsx @@ -0,0 +1,498 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/ContributionsEpicArticleCountAboveWithOptOut.tsx + */ +import { css } from '@emotion/react'; +import type { OphanComponentEvent } from '@guardian/libs'; +import { from, until } from '@guardian/source-foundations'; +import { palette, space } from '@guardian/source-foundations'; +import { body, textSans } from '@guardian/source-foundations'; +import { Button, ButtonLink } from '@guardian/source-react-components'; +import type { + ArticleCounts, + ArticleCountType, +} from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { useState } from 'react'; +import type { ReactComponent } from '../lib/ReactComponent'; +import { + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_IN, + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT, + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_CLOSE, + OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_OPEN, + OPHAN_COMPONENT_ARTICLE_COUNT_STAY_IN, + OPHAN_COMPONENT_ARTICLE_COUNT_STAY_OUT, +} from './utils/ophan'; + +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?.( + isOpen + ? OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_CLOSE + : OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT_OPEN, + ); + }; + + const onStayInClick = () => { + setIsOpen(false); + submitComponentEvent?.(OPHAN_COMPONENT_ARTICLE_COUNT_STAY_IN); + }; + + const onOptOutClick = () => { + setIsOpen(false); + onArticleCountOptOut(); + submitComponentEvent?.(OPHAN_COMPONENT_ARTICLE_COUNT_OPT_OUT); + }; + + const onOptInClick = () => { + setIsOpen(false); + onArticleCountOptIn(); + submitComponentEvent?.(OPHAN_COMPONENT_ARTICLE_COUNT_OPT_IN); + }; + + const onStayOutClick = () => { + setIsOpen(false); + 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/ContributionsEpicButtons.tsx b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicButtons.tsx index 3f83f6ea8e8..c4b130def84 100644 --- a/dotcom-rendering/src/components/marketing/epics/ContributionsEpicButtons.tsx +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicButtons.tsx @@ -57,6 +57,7 @@ const PrimaryCtaButton = ({ amountsTestName, amountsVariantName, numArticles, + submitComponentEvent, }: { cta?: Cta; tracking: Tracking; @@ -64,6 +65,7 @@ const PrimaryCtaButton = ({ amountsTestName?: string; amountsVariantName?: string; numArticles: number; + submitComponentEvent?: (event: OphanComponentEvent) => void; }): JSX.Element | null => { if (!cta) { return null; @@ -84,6 +86,7 @@ const PrimaryCtaButton = ({
@@ -98,11 +101,13 @@ const SecondaryCtaButton = ({ tracking, numArticles, countryCode, + submitComponentEvent, }: { cta: Cta; tracking: Tracking; countryCode?: string; numArticles: number; + submitComponentEvent?: (event: OphanComponentEvent) => void; }): JSX.Element | null => { const url = addRegionIdAndTrackingParamsToSupportUrl( cta.baseUrl, @@ -114,6 +119,7 @@ const SecondaryCtaButton = ({
@@ -204,6 +210,7 @@ export const ContributionsEpicButtons = ({ amountsTestName={amountsTestName} amountsVariantName={amountsVariantName} countryCode={countryCode} + submitComponentEvent={submitComponentEvent} /> {secondaryCta?.type === SecondaryCtaType.Custom && !!secondaryCta.cta.baseUrl && @@ -213,6 +220,7 @@ export const ContributionsEpicButtons = ({ tracking={tracking} countryCode={countryCode} numArticles={numArticles} + submitComponentEvent={submitComponentEvent} /> )} 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..7659f3c1013 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicChoiceCards.tsx @@ -0,0 +1,221 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/ContributionsEpicChoiceCards.tsx + */ +import { css } from '@emotion/react'; +import type { OphanComponentEvent } from '@guardian/libs'; +import { until, visuallyHidden } from '@guardian/source-foundations'; +import { ChoiceCard, ChoiceCardGroup } from '@guardian/source-react-components'; +import { contributionTabFrequencies } from '@guardian/support-dotcom-components'; +import type { + ContributionFrequency, + SelectedAmountsVariant, +} from '@guardian/support-dotcom-components/dist/shared/src/types'; +import { useEffect } from 'react'; +import { useIsInView } from '../../../lib/useIsInView'; +import { contributionType } from '../lib/choiceCards'; +import type { ChoiceCardSelection } from '../lib/choiceCards'; +import type { ReactComponent } from '../lib/ReactComponent'; + +// CSS Styling +// ------------------------------------------- +const frequencyChoiceCardGroupOverrides = css` + ${until.mobileLandscape} { + > div { + display: flex; + } + + > div label:nth-of-type(2) { + margin-left: 4px; + margin-right: 4px; + } + } +`; + +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) => { + const [hasBeenSeen, setNode] = useIsInView({ + debounce: true, + threshold: 0, + }); + + const { + testName = 'test_undefined', + variantName = 'variant_undefined', + displayContributionType = contributionTabFrequencies, + amountsCardData, + } = amountsTest; + + 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, testName, variantName]); + + if (!selection) { + return <>; + } + + 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, + }); + }; + + const updateFrequency = (frequency: ContributionFrequency) => { + trackClick('frequency'); + setSelectionsCallback({ + frequency, + amount: amountsCardData[frequency].defaultAmount, + }); + }; + + const ChoiceCardAmount = ({ amount }: { amount?: number }) => { + if (amount !== undefined) { + 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..4ce608d5fc2 --- /dev/null +++ b/dotcom-rendering/src/components/marketing/epics/ContributionsEpicNewsletterSignup.tsx @@ -0,0 +1,81 @@ +/** + * @file + * This file was migrated from: + * https://github.com/guardian/support-dotcom-components/blob/a482b35a25ca59f66501c4de02de817046206298/packages/modules/src/modules/epics/NewsletterSignup.tsx + */ +import { css } from '@emotion/react'; +import { isObject, log } from '@guardian/libs'; +import { space } from '@guardian/source-foundations'; +import { useEffect, useRef, useState } from 'react'; + +const containerStyles = css` + margin: ${space[6]}px ${space[2]}px ${space[1]}px 0; + width: 100%; +`; + +export 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: unknown = JSON.parse(event.data); + if ( + !isObject(message) || + typeof message.type !== 'string' + ) { + return; + } + + 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(value); + } + } + } + } + } catch (err) { + log( + 'supporterRevenue', + 'Error handling event in epic NewsletterSignup', + err, + ); + } + }); + }, []); + + return ( +
+