From 945fbe3c6f09faa3c958a5f7995dc4fcc954d33f Mon Sep 17 00:00:00 2001 From: LauraPinilla <54566275+LauraPinilla@users.noreply.github.com> Date: Mon, 20 Nov 2023 15:49:38 -0600 Subject: [PATCH] ASUB-7841 Subscriptions Paywall - migrating from v1 to v2 (#1802) * Subscriptions Paywall - Arc block migrating from v1 to v2 * PR feedback * fix test --------- Co-authored-by: Matthew Kim <matthew.kim@washpost.com> --- blocks/subscriptions-block/_index.scss | 42 +++ .../components/PaywallOffer/index.jsx | 109 ++++++++ .../components/PaywallOffer/index.test.js | 122 +++++++++ .../components/RegwallOffer/index.jsx | 41 +++ .../components/RegwallOffer/index.test.js | 69 +++++ .../components/SubscriptionDialog/index.jsx | 72 +++++ .../SubscriptionDialog/index.story.jsx | 51 ++++ .../SubscriptionDialog/index.test.js | 66 +++++ .../components/SubscriptionOverlay/index.jsx | 103 +++++++ .../SubscriptionOverlay/index.story.jsx | 140 ++++++++++ .../SubscriptionOverlay/index.test.jsx | 117 ++++++++ .../components/usePaywall.js | 115 ++++++++ .../components/usePaywall.test.js | 257 ++++++++++++++++++ .../features/paywall/default.jsx | 151 ++++++++++ .../features/paywall/default.test.jsx | 144 ++++++++++ blocks/subscriptions-block/themes/news.json | 94 +++++++ 16 files changed, 1693 insertions(+) create mode 100644 blocks/subscriptions-block/components/PaywallOffer/index.jsx create mode 100644 blocks/subscriptions-block/components/PaywallOffer/index.test.js create mode 100644 blocks/subscriptions-block/components/RegwallOffer/index.jsx create mode 100644 blocks/subscriptions-block/components/RegwallOffer/index.test.js create mode 100644 blocks/subscriptions-block/components/SubscriptionDialog/index.jsx create mode 100644 blocks/subscriptions-block/components/SubscriptionDialog/index.story.jsx create mode 100644 blocks/subscriptions-block/components/SubscriptionDialog/index.test.js create mode 100644 blocks/subscriptions-block/components/SubscriptionOverlay/index.jsx create mode 100644 blocks/subscriptions-block/components/SubscriptionOverlay/index.story.jsx create mode 100644 blocks/subscriptions-block/components/SubscriptionOverlay/index.test.jsx create mode 100644 blocks/subscriptions-block/components/usePaywall.js create mode 100644 blocks/subscriptions-block/components/usePaywall.test.js create mode 100644 blocks/subscriptions-block/features/paywall/default.jsx create mode 100644 blocks/subscriptions-block/features/paywall/default.test.jsx diff --git a/blocks/subscriptions-block/_index.scss b/blocks/subscriptions-block/_index.scss index 5827ec0bcf..88f015e8a4 100644 --- a/blocks/subscriptions-block/_index.scss +++ b/blocks/subscriptions-block/_index.scss @@ -204,3 +204,45 @@ @include scss.block-components("offer"); @include scss.block-properties("offer"); } + +.b-paywall { + &__overlay { + &-content { + @include scss.block-components("paywall-overlay-content"); + @include scss.block-properties("paywall-overlay-content"); + } + + @include scss.block-components("paywall-overlay"); + @include scss.block-properties("paywall-overlay"); + } + + &__subscription-dialog { + &-link-prompt { + @include scss.block-components("paywall-subscription-dialog-reason-prompt"); + @include scss.block-properties("paywall-subscription-dialog-reason-prompt"); + + &-pre-link { + @include scss.block-components("paywall-subscription-dialog-link-prompt-pre-link"); + @include scss.block-properties("paywall-subscription-dialog-link-prompt-pre-link"); + } + + &-link { + @include scss.block-components("paywall-subscription-dialog-link-prompt-link"); + @include scss.block-properties("paywall-subscription-dialog-link-prompt-link"); + } + } + + &-offer-info{ + @include scss.block-components("paywall-subscription-dialog-offer-info"); + @include scss.block-properties("paywall-subscription-dialog-offer-info"); + } + + &-subheadline{ + @include scss.block-components("paywall-subscription-dialog-subheadline"); + @include scss.block-properties("paywall-subscription-dialog-subheadline"); + } + + @include scss.block-components("paywall-subscription-dialog"); + @include scss.block-properties("paywall-subscription-dialog"); + } +} diff --git a/blocks/subscriptions-block/components/PaywallOffer/index.jsx b/blocks/subscriptions-block/components/PaywallOffer/index.jsx new file mode 100644 index 0000000000..582a18edb7 --- /dev/null +++ b/blocks/subscriptions-block/components/PaywallOffer/index.jsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef, useState } from "react"; +import { isServerSide } from "@wpmedia/arc-themes-components"; +import isUrl from "is-url"; +import useOffer from "../useOffer"; +import SubscriptionOverlay from "../SubscriptionOverlay"; +import SubscriptionDialog from "../SubscriptionDialog"; + +const isPaywallCampaignURL = (payWallCode) => payWallCode && isUrl(payWallCode); + +const PaywallOffer = ({ + isLoggedIn, + actionText, + actionUrl, + campaignCode = null, + displayMode, + linkPrompt, + linkText, + linkUrl, + reasonPrompt, + usePortal = true, + className +}) => { + // the paywall code (otherwise known as a campaign code) + const [payWallCode, setPayWallCode] = useState(); + + const { offer, fetchOffer } = useOffer({ campaignCode }); + + /** + * payWallOffer is the most updated offer that is returned from + * the fetchOffer method in the useOffer hook. + * We can't use the offer object that is returned from the useOffer + * directly as that causes a recursive call to the second useEffect. + * + * When the payWallOffer is updated in the second hook it's current + * value will be applied to the selectedOffer state to update the dom. + */ + const payWallOffer = useRef(offer); + + const [selectedOffer, setSelectedOffer] = useState(payWallOffer.current); + + /** + * If campaignCode is passed in as a prop, + * use that. Otherwise use what is returned from + * usePaywall hook and at the very least, set it + * to "default" + */ + useEffect(() => { + const campaign = campaignCode || "default"; + setPayWallCode(campaign); + }, [campaignCode]); + + // This will grab the offer corresponding to the paywall code + useEffect(() => { + const fetchNewOffer = async () => { + payWallOffer.current = await fetchOffer(payWallCode); + if (payWallOffer.current) { + setSelectedOffer(payWallOffer.current); + } + }; + if ( + payWallCode && + !isUrl(payWallCode) && + (!payWallOffer.current || payWallOffer.current.pw !== payWallCode) + ) { + fetchNewOffer(); + } + return () => { + payWallOffer.current = null; + }; + }, [payWallCode, fetchOffer]); + + /** + * Return null if server side, not paywalled, doesn't have an offer to show + * or if the user is logged in. + */ + if (isServerSide() || !selectedOffer) { + return null; + } + + /** + * Need to determine the campaign code. + * First we see if we have been provided with a campaignCode prop. + * If not then we check if the payWallCode is not a url, if its + * not, then we use that. If it a url (Why would it be a URL??) then + * we just set the campaign code to "default" + */ + const campaign = campaignCode || (!isPaywallCampaignURL(payWallCode) ? payWallCode : "default"); + const actionUrlFinal = + !campaign || campaign === "default" ? actionUrl : `${actionUrl}?campaign=${campaign}`; + + return ( + <SubscriptionOverlay displayMode={displayMode} usePortal={usePortal} className={className}> + <SubscriptionDialog + isLoggedIn={isLoggedIn} + actionText={actionText} + actionUrl={isPaywallCampaignURL(payWallCode) ? payWallCode : actionUrlFinal} + headline={selectedOffer.pageTitle} + linkPrompt={linkPrompt} + linkText={linkText} + linkUrl={linkUrl} + reasonPrompt={reasonPrompt} + subHeadline={selectedOffer.pageSubTitle} + className={className} + /> + </SubscriptionOverlay> + ); +}; + +export default PaywallOffer; \ No newline at end of file diff --git a/blocks/subscriptions-block/components/PaywallOffer/index.test.js b/blocks/subscriptions-block/components/PaywallOffer/index.test.js new file mode 100644 index 0000000000..285cdc3802 --- /dev/null +++ b/blocks/subscriptions-block/components/PaywallOffer/index.test.js @@ -0,0 +1,122 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import isUrl from "is-url"; + +import { isServerSide } from "@wpmedia/arc-themes-components"; + +import PaywallOffer from "."; +import usePaywall from "../usePaywall"; +import useOffer from "../useOffer"; + +jest.mock("@wpmedia/arc-themes-components", () => ({ + __esModule: true, + isServerSide: jest.fn(() => false), +})); + +jest.mock("is-url", () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +jest.mock("../../components/useOffer"); +jest.mock("../../../identity-block"); +jest.mock("../../components/usePaywall"); + +useOffer.mockReturnValue({ + offer: { + pageTitle: "this the offer title", + pageSubTitle: "this the offer subtitle", + }, + fetchOffer: () => ({ + pageTitle: "this the offer title", + pageSubTitle: "this the offer subtitle", + }), +}); + +usePaywall.mockReturnValue({ + campaignCode: "default", + isPaywalled: true, + isRegisterwalled: false, +}); + +/** + * Below I pass usePortal to false that will in return + * pass this down to the Subscription Overlay. + * This boolean prevents ReactDOM.createPortal from being used. + * Just checking with isServerSide doesn't work. Jest and Enzyme + * still have poor support for ReactDOM.createPortal, so we need a way + * to conditionally render ReactDOM.createPortal. + */ +describe("The PaywallOffer component ", () => { + it("returns null if serverSide", () => { + isServerSide.mockReturnValue(true); + render( + <PaywallOffer + actionText="Subscribe" + actionUrl="/offer/" + campaignCode="defaultish" + linkPrompt="Already a subscriber?" + linkText="Log In." + linkUrl="/account/login" + reasonPrompt="Subscribe to continue reading." + usePortal={false} + /> + ); + expect(screen.html()).toBe(null); + isServerSide.mockReset(); + }); + + it("renders with correct markup", () => { + render( + <PaywallOffer + actionText="Subscribe" + actionUrl="/offer/" + campaignCode="defaultish" + linkPrompt="Already a subscriber?" + linkText="Log In." + linkUrl="/account/login" + reasonPrompt="Subscribe to continue reading." + usePortal={false} + /> + ); + + expect(screen.getByText("Subscribe to continue reading.")).not.toBeNull(); + expect(screen.getByText("Already a subscriber?")).not.toBeNull(); + expect(screen.getByText("Log In.").closest("a")).toHaveAttribute("href", "/account/login"); + + expect(screen.getByText("this the offer title")).not.toBeNull(); + expect(screen.getByText("this the offer subtitle")).not.toBeNull(); + expect(screen.getByText("Subscribe")).not.toBeNull(); + expect(screen.getByText("Subscribe").closest("a")).toHaveAttribute( + "href", + "/offer/?campaign=defaultish" + ); + }); + + it("renders campaignCode if its a url", () => { + isUrl.mockReturnValue(true); + render( + <PaywallOffer + actionText="Subscribe" + actionUrl="/offer/" + campaignCode="./" + usePortal={false} + /> + ); + expect(screen.getByText("Subscribe")).not.toBeNull(); + expect(screen.getByText("Subscribe").closest("a")).toHaveAttribute( + "href", + "/offer/?campaign=./" + ); + isUrl.mockReset(); + }); + + it("renders without a query param if campaignCode is not passed", () => { + isUrl.mockReturnValue(true); + render(<PaywallOffer actionText="Subscribe" actionUrl="/offer/" usePortal={false} />); + expect(screen.getByRole("button")).not.toBeNull(); + expect(screen.getByText("Subscribe")).not.toBeNull(); + expect(screen.getByRole("button").toHaveAttribute("href", "/offer/")); + isUrl.mockReset(); + }); +}); diff --git a/blocks/subscriptions-block/components/RegwallOffer/index.jsx b/blocks/subscriptions-block/components/RegwallOffer/index.jsx new file mode 100644 index 0000000000..a67ffe546a --- /dev/null +++ b/blocks/subscriptions-block/components/RegwallOffer/index.jsx @@ -0,0 +1,41 @@ +import React from "react"; + +import { isServerSide } from "@wpmedia/arc-themes-components"; +import SubscriptionOverlay from "../SubscriptionOverlay"; +import SubscriptionDialog from "../SubscriptionDialog"; + +const RegwallOffer = ({ + actionText, + actionUrl, + displayMode, + headlineText, + linkPrompt, + linkText, + linkUrl, + reasonPrompt, + subheadlineText, + usePortal = true, + className, +}) => { + if (isServerSide()) { + return null; + } + + return ( + <SubscriptionOverlay displayMode={displayMode} usePortal={usePortal} className={className}> + <SubscriptionDialog + actionText={actionText} + actionUrl={actionUrl} + headline={headlineText} + linkPrompt={linkPrompt} + linkText={linkText} + linkUrl={linkUrl} + reasonPrompt={reasonPrompt} + subHeadline={subheadlineText} + className={className} + /> + </SubscriptionOverlay> + ); +}; + +export default RegwallOffer; \ No newline at end of file diff --git a/blocks/subscriptions-block/components/RegwallOffer/index.test.js b/blocks/subscriptions-block/components/RegwallOffer/index.test.js new file mode 100644 index 0000000000..5e9f2c920d --- /dev/null +++ b/blocks/subscriptions-block/components/RegwallOffer/index.test.js @@ -0,0 +1,69 @@ +import React from "react"; +import { render } from "@testing-library/react"; + +import { isServerSide } from "@wpmedia/arc-themes-components"; + +import RegwallOffer from "."; + +jest.mock("@wpmedia/arc-themes-components", () => ({ + __esModule: true, + isServerSide: jest.fn(() => false), +})); + +jest.mock("is-url", () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +/** + * Below I pass usePortal to false that will in return + * pass this down to the Subscription Overlay. + * This boolean prevents ReactDOM.createPortal from being used. + * Just checking with isServerSide doesn't work. Jest and Enzyme + * still have poor support for ReactDOM.createPortal, so we need a way + * to conditionally render ReactDOM.createPortal. + */ +describe("The RegwallOffer component ", () => { + it("returns null if serverSide", () => { + isServerSide.mockReturnValue(true); + render( + <RegwallOffer + actionText="Subscribe" + actionUrl="/account/signup" + headlineText="Headline" + linkPrompt="Already a subscriber?" + linkText="Log In." + linkUrl="/account/login" + reasonPrompt="Subscribe to continue reading." + subheadlineText="Subheadline" + usePortal={false} + /> + ); + expect(screen.html()).toBe(null); + isServerSide.mockReset(); + }); + + it("renders with correct markup", () => { + render( + <RegwallOffer + actionText="Subscribe" + actionUrl="/account/signup" + headlineText="Headline" + linkPrompt="Already a subscriber?" + linkText="Log In." + linkUrl="/account/login" + reasonPrompt="Subscribe to continue reading." + subheadlineText="Subheadline" + usePortal={false} + /> + ); + + expect(screen.getByText("Subscribe to continue reading.")).not.toBeNull(); + expect(screen.getByText("Already a subscriber?")).not.toBeNull(); + expect(screen.getByText("Log In.").closest("a")).toHaveAttribute("href", "/account/login"); + + expect(screen.getByText("Headline")).not.toBeNull(); + expect(screen.getByText("Subheadline")).not.toBeNull(); + expect(screen.getByText("Subscribe").closest("a")).toHaveAttribute("href", "/account/signup"); + }); +}); diff --git a/blocks/subscriptions-block/components/SubscriptionDialog/index.jsx b/blocks/subscriptions-block/components/SubscriptionDialog/index.jsx new file mode 100644 index 0000000000..2cde99708c --- /dev/null +++ b/blocks/subscriptions-block/components/SubscriptionDialog/index.jsx @@ -0,0 +1,72 @@ +import React from "react"; + +import PropTypes from "@arc-fusion/prop-types"; +import { Heading, Link, Button, Stack } from "@wpmedia/arc-themes-components"; + +const SubscriptionDialog = ({ + isLoggedIn, + actionText, + actionUrl, + reasonPrompt, + headline, + linkText, + linkPrompt, + linkUrl, + subHeadline, + className, +}) => { + + return ( + <Stack as="div" className={`${className}__subscription-dialog`}> + <Stack> + {reasonPrompt ? ( + <div + className={`${className}__subscription-dialog-reason-prompt`} + dangerouslySetInnerHTML={{ __html: reasonPrompt }} + /> + ) : null} + {!isLoggedIn ? ( + <div className={`${className}__subscription-dialog-link-prompt`}> + {linkPrompt ? ( + <span + className={`${className}__subscription-dialog-link-prompt-pre-link`} + dangerouslySetInnerHTML={{ __html: linkPrompt }} + /> + ) : null} + <Link href={linkUrl} className={`${className}__subscription-dialog-link-prompt-link`}> + {linkText} + </Link> + </div> + ) : null} + </Stack> + <Stack className={`${className}__subscription-dialog-offer-info`}> + {headline ? <Heading dangerouslySetInnerHTML={{ __html: headline }} /> : null} + {subHeadline ? ( + <div + className={`${className}__subscription-dialog-subheadline`} + dangerouslySetInnerHTML={{ __html: subHeadline }} + /> + ) : null} + </Stack> + {actionUrl && actionText ? ( + <Button size="large" variant="primary" type="button" href={actionUrl}> + <span>{actionText}</span> + </Button> + ) : null} + </Stack> + ); +}; + +SubscriptionDialog.propTypes = { + isLoggedIn: PropTypes.boolean, + actionText: PropTypes.string, + actionUrl: PropTypes.string, + reasonPrompt: PropTypes.oneOfType([PropTypes.string, PropTypes.element, PropTypes.node]), + headline: PropTypes.oneOfType([PropTypes.string, PropTypes.element, PropTypes.node]), + linkText: PropTypes.string.isRequired, + linkPrompt: PropTypes.string, + linkUrl: PropTypes.string.isRequired, + subHeadline: PropTypes.oneOfType([PropTypes.string, PropTypes.element, PropTypes.node]), +}; + +export default SubscriptionDialog; diff --git a/blocks/subscriptions-block/components/SubscriptionDialog/index.story.jsx b/blocks/subscriptions-block/components/SubscriptionDialog/index.story.jsx new file mode 100644 index 0000000000..1f14769d2b --- /dev/null +++ b/blocks/subscriptions-block/components/SubscriptionDialog/index.story.jsx @@ -0,0 +1,51 @@ +import React from "react"; + +import SubscriptionDialog from "."; + +export default { + title: "Blocks/Subscriptions/Components/Subscription Dialog", + parameters: { + chromatic: { viewports: [320, 1023, 1200] }, + }, +}; + +export const allFields = () => ( + <SubscriptionDialog + actionText="Subscribe" + actionUrl="#" + reasonPrompt="Subscribe to continue reading Storybook documents." + headline="Get every storybook item for only $9 a month" + linkText="Log In" + linkPrompt="Already a Storybook patron?" + linkUrl="#" + subHeadline="You may <b>think</b> you want to cancel, but deep down you know you don't." + /> +); + +export const minimalFields = () => <SubscriptionDialog linkText="Link" linkUrl="#" />; + +export const withButton = () => ( + <SubscriptionDialog + actionAriaLabel="ariaAction" + actionText="Subscribe" + actionUrl="#" + linkText="Log In" + linkUrl="#" + /> +); + +export const withReason = () => ( + <SubscriptionDialog reasonPrompt="That is restricted." linkText="Log In" linkUrl="#" /> +); + +export const withLinkPrompt = () => ( + <SubscriptionDialog linkPrompt="You must Log in." linkText="Log In" linkUrl="#" /> +); + +export const withHeadline = () => ( + <SubscriptionDialog headline="This is a headline!" linkText="This is a link" linkUrl="#" /> +); + +export const withSubheadline = () => ( + <SubscriptionDialog subHeadline="This is a subheadline!" linkText="This is a link" linkUrl="#" /> +); \ No newline at end of file diff --git a/blocks/subscriptions-block/components/SubscriptionDialog/index.test.js b/blocks/subscriptions-block/components/SubscriptionDialog/index.test.js new file mode 100644 index 0000000000..4f9d2cb4d6 --- /dev/null +++ b/blocks/subscriptions-block/components/SubscriptionDialog/index.test.js @@ -0,0 +1,66 @@ +import React from "react"; +import { render } from "@testing-library/react"; + +import SubscriptionDialog from "."; + +describe("SubscriptionDialog", () => { + it("renders with minimal required properties", () => { + render(<SubscriptionDialog linkText="Log in" linkUrl="/" />); + + expect(screen.getByText('Log In.').closest('a')).toHaveAttribute('href', '/'); + }); + + it("renders the button as a link", () => { + render( + <SubscriptionDialog linkText="Log in" linkUrl="/" actionText="Press Me!" actionUrl="/" /> + ); + + expect(screen.getByText('Press Me!').closest('a')).toHaveAttribute('href', '/'); + }); + + it("does not render if required action part is missing", () => { + render( + <SubscriptionDialog linkText="Log in" linkUrl="/" actionText="Press Me!" /> + ); + + expect(screen.getByText('Log in').closest('a')).toHaveAttribute('href', '/'); + }); + + it("does not render if other required action part is missing", () => { + render(<SubscriptionDialog linkText="Log in" linkUrl="/" actionUrl="/" />); + + expect(screen.getByText('Log in').closest('a')).toHaveAttribute('href', '/'); + }); + + it("renders the headline", () => { + render( + <SubscriptionDialog linkText="Log in" linkUrl="/" headline="Headline 1" /> + ); + + expect(screen.getByText("Headline")).not.toBeNull(); + }); + + it("renders the subheadlines", () => { + render( + <SubscriptionDialog linkText="Log in" linkUrl="/" subHeadline="Headline 2" /> + ); + + expect(screen.getByText("Headline 2")).not.toBeNull(); + }); + + it("renders the reason", () => { + render( + <SubscriptionDialog linkText="Log in" linkUrl="/" reasonPrompt="You need to do this." /> + ); + + expect(screen.getByText("You need to do this.")).not.toBeNull(); + }); + + it("renders the link prompt text", () => { + render( + <SubscriptionDialog linkText="Log in" linkUrl="/" linkPrompt="You should log in." /> + ); + + expect(screen.getByText("You should log in.")).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/blocks/subscriptions-block/components/SubscriptionOverlay/index.jsx b/blocks/subscriptions-block/components/SubscriptionOverlay/index.jsx new file mode 100644 index 0000000000..f6a76de787 --- /dev/null +++ b/blocks/subscriptions-block/components/SubscriptionOverlay/index.jsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState, useRef } from "react"; +import * as ReactDOM from "react-dom"; + +import { isServerSide } from "@wpmedia/arc-themes-components"; + +export const Portal = ({ children }) => { + if (isServerSide()) return null; + + return ReactDOM.createPortal(children, document.body); +}; + +/** + * The usePortal param should always be true, with the exception + * of unit testing where portal needs to be false to prevent + * jest and enzyme errors because + * Portals are not currently supported by the server renderer. + * + * Just checking for isServerSide is not enough. + * + * `displayMode` param is unused at this time. + * We implemented 'bottomHalf' below. + * It can be ['bottomHalf', 'full', 'modal']. + * + */ +const SubscriptionOverlay = ({ children, usePortal = true, className }) => { + const overlayRef = useRef(); + const contentRef = useRef(); + const [scrollDelta, setScrollDelta] = useState(0); + const [overlayTouchLast, setOverlayTouchLast] = useState(0); + const [contentTouchLast, setContentTouchLast] = useState(0); + + useEffect(() => { + const disableScroll = (event) => event.preventDefault(); + const scrollElement = overlayRef.current.ownerDocument.scrollingElement; + const { overflow } = scrollElement.style; + + scrollElement.addEventListener("scroll", disableScroll); + scrollElement.style.overflow = "hidden"; + scrollElement.style.maxHeight = "100vh"; + + return () => { + scrollElement.removeEventListener("scroll", disableScroll); + scrollElement.style.overflow = overflow; + scrollElement.style.maxHeight = "auto"; + }; + }, [overlayRef]); + + useEffect(() => { + const contentTopFactor = overlayRef.current.scrollTop / overlayRef.current.clientHeight; + + if (contentRef.current.scrollTop >= 0 && contentTopFactor < 0.25) { + overlayRef.current.scrollTop += scrollDelta; + } else { + contentRef.current.scrollTop += scrollDelta; + } + }, [contentRef, overlayRef, scrollDelta]); + + const renderInternalOverlay = () => ( + <section + className={`${className}__overlay`} + ref={overlayRef} + onWheel={(event) => { + setScrollDelta(event.deltaY); + }} + onTouchMove={(event) => { + setScrollDelta(overlayTouchLast - event.changedTouches[0].clientY); + setOverlayTouchLast(event.changedTouches[0].clientY); + }} + onTouchStart={(event) => { + setOverlayTouchLast(event.changedTouches[0].clientY); + }} + role="alert" + > + <div + className={`${className}__overlay-content`} + onWheel={(event) => { + setScrollDelta(event.deltaY); + }} + onTouchMove={(event) => { + setScrollDelta(contentTouchLast - event.changedTouches[0].clientY); + setContentTouchLast(event.changedTouches[0].clientY); + }} + onTouchStart={(event) => { + setContentTouchLast(event.changedTouches[0].clientY); + }} + ref={contentRef} + > + {children} + </div> + </section> + ); + + const renderOverlay = () => { + if (!usePortal || isServerSide()) { + return <>{renderInternalOverlay()}</>; + } + return <Portal>{renderInternalOverlay()}</Portal>; + }; + + return renderOverlay(); +}; + +export default SubscriptionOverlay; diff --git a/blocks/subscriptions-block/components/SubscriptionOverlay/index.story.jsx b/blocks/subscriptions-block/components/SubscriptionOverlay/index.story.jsx new file mode 100644 index 0000000000..711ccb3e9b --- /dev/null +++ b/blocks/subscriptions-block/components/SubscriptionOverlay/index.story.jsx @@ -0,0 +1,140 @@ +import React from "react"; + +import SubscriptionOverlay from "."; + +export default { + title: "Blocks/Subscriptions/Components/Subscription Overlay", + parameters: { + chromatic: { viewports: [320, 1200] }, + }, +}; + +const LongArticle = () => { + const textStyle = { + margin: "0.5em auto", + padding: "0.5em", + maxWidth: "60vw", + }; + return ( + <article style={textStyle}> + <button type="button">Focusable element</button> + <p style={textStyle}> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce elementum tincidunt placerat. + Integer bibendum, libero sed rhoncus dapibus, ipsum nulla condimentum est, in gravida sapien + risus nec libero. Nunc consectetur purus urna, id vehicula ante hendrerit at. Vivamus ac leo + id lorem commodo scelerisque. Vestibulum rhoncus sed lacus eget tempus. Quisque odio elit, + euismod a ipsum eu, posuere aliquet justo. Ut finibus est sapien, vitae mattis neque + tincidunt et. Etiam tincidunt congue sem. Curabitur tempus, nisl eget ornare gravida, magna + tortor ultricies elit, eu commodo dolor dui vel quam. Suspendisse potenti. Vestibulum + dignissim elit tortor, vel scelerisque mauris consectetur eget. + </p> + <p style={textStyle}> + Vestibulum blandit vel neque sollicitudin venenatis. Maecenas erat velit, eleifend at nisi + sed, vulputate fermentum nibh. Vestibulum ut elementum ex. Vivamus convallis dolor eget + dictum facilisis. Duis maximus tortor vitae nisi viverra luctus. Vivamus ultrices laoreet + lectus, non dictum massa efficitur nec. Integer vitae nunc elementum, imperdiet metus ut, + mattis lacus. Cras finibus lorem sed risus accumsan, et tempus nulla cursus. + </p> + <p style={textStyle}> + In in massa vitae mauris sagittis cursus. Donec fringilla elit a lacus placerat varius. + Nulla aliquet pulvinar eros eu egestas. Ut sed odio sed risus scelerisque efficitur. Nullam + at pretium est. Integer tristique tristique magna ut tempus. Fusce sagittis tincidunt + tristique. Praesent ac interdum diam. Etiam rutrum, dui eu iaculis volutpat, nisl nunc + vehicula lacus, vitae rhoncus metus leo sit amet nibh. Praesent aliquet erat id fermentum + ultrices. Integer ac arcu sit amet mauris aliquam aliquet. Integer et nunc vel lectus congue + luctus non in tellus. Mauris quis neque gravida, mollis quam id, auctor odio. Donec + placerat, sapien eget rhoncus varius, nulla eros aliquet neque, vel maximus nibh libero id + erat. + </p> + <p style={textStyle}> + Integer et dolor ut nulla mattis lobortis vitae sed enim. In quis dolor nec ex gravida + sollicitudin. Fusce cursus eleifend fringilla. Donec magna risus, laoreet pulvinar justo ut, + dignissim vulputate velit. Nulla vehicula, tellus eu condimentum hendrerit, sem leo + facilisis purus, pellentesque viverra turpis mi a odio. Maecenas blandit diam tincidunt, + volutpat magna sed, viverra justo. Quisque non mollis sem, eget fringilla felis. Aliquam + maximus urna vitae velit sollicitudin, vel fringilla enim scelerisque. Vestibulum varius + lacinia dui. Praesent fringilla metus id enim fringilla, nec finibus dolor condimentum. + Donec non lorem purus. Donec sit amet dolor libero. Aliquam egestas facilisis dapibus. + </p> + <p style={textStyle}> + Maecenas non eros eget nulla efficitur pulvinar. Duis varius bibendum tellus vitae feugiat. + In vel ipsum non lorem tincidunt aliquet non vitae purus. Vivamus nec dolor id mauris + efficitur accumsan. Vestibulum ullamcorper convallis odio. Fusce rhoncus nibh sed metus + sodales, vel efficitur metus fringilla. Pellentesque ut erat ex. + </p> + <p style={textStyle}> + Phasellus ac mattis enim. Sed non massa ut nulla aliquet pellentesque et vel est. Proin + consequat massa a ipsum placerat luctus. Nullam ultrices, tortor sit amet mattis vestibulum, + nulla justo pretium purus, eu lacinia lacus libero eu nibh. In tellus ex, molestie ut enim + a, pharetra convallis arcu. Duis sit amet ultricies sem, eget rhoncus erat. Nullam volutpat, + risus cursus tincidunt dapibus, libero ligula tempus tortor, eget fringilla diam eros et mi. + Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis + egestas. Donec sagittis, lorem ut porta mollis, magna quam luctus mi, id condimentum odio + ligula sed odio. Duis sagittis consequat metus, nec convallis nisl consequat a. Duis at + turpis cursus, gravida ipsum in, fermentum sem. Donec venenatis, lacus nec molestie + pulvinar, magna sem hendrerit ex, ac efficitur augue magna nec lorem. Vivamus et vehicula + mi. Vestibulum blandit lorem et massa semper dignissim. Aliquam vehicula et urna at finibus. + Nam non ante tempus, sodales odio in, pretium justo. + </p> + <p style={textStyle}> + Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. + Praesent interdum ultrices odio sed vestibulum. Quisque iaculis turpis sed tincidunt + pellentesque. Vestibulum molestie dui purus, quis maximus felis scelerisque ut. Donec sit + amet diam faucibus dui blandit viverra. Pellentesque ultricies lorem orci, ut tempor eros + finibus a. Fusce ac venenatis nisi. Etiam quis elit lacinia, tincidunt ligula sed, aliquet + libero. Donec eu elit ut ipsum maximus dignissim et vitae libero. Suspendisse potenti. Nulla + ullamcorper molestie nulla, sed accumsan nibh vulputate in. Phasellus pharetra accumsan + lacus vitae congue. + </p> + <p style={textStyle}> + Pellentesque non pellentesque velit. Nullam id enim erat. In eu condimentum mi. Proin sit + amet blandit tortor. Morbi vehicula vehicula magna, in pretium arcu hendrerit a. Vivamus a + sem sem. Nam vestibulum lectus id augue facilisis tincidunt. Nullam finibus semper nisi. + Duis vehicula tellus vitae sem facilisis ultricies. Integer et nunc at tortor ultrices + congue at vitae risus. Aenean ut nunc id quam pulvinar vehicula eu et libero. Ut nec erat et + ex vulputate vestibulum. Nam mattis pretium felis, sit amet pellentesque justo ultricies et. + Donec consectetur eleifend fermentum. + </p> + <p style={textStyle}> + Proin in ex quam. Quisque eget tellus tellus. Donec ac tortor feugiat enim condimentum + euismod. Mauris ac maximus leo. Nullam at tempor leo. Praesent vitae diam in diam iaculis + pharetra. Suspendisse a nulla quis quam dictum pharetra viverra ac nulla. Vestibulum + sollicitudin nunc vel odio blandit scelerisque. Nam eget sem et diam molestie tempor eget ut + lorem. Pellentesque pretium rutrum vulputate. Cras fermentum sapien eu mollis tristique. + Quisque pulvinar massa est, sodales sodales felis sodales at. Fusce aliquet, massa ac + posuere tincidunt, erat lacus ullamcorper nunc, eget scelerisque augue nibh eu dui. Donec + sodales neque bibendum arcu lacinia, sit amet sollicitudin neque tincidunt. + </p> + <p style={textStyle}> + Suspendisse vel egestas orci, vitae dictum velit. Donec imperdiet ipsum mauris, eget mollis + lectus gravida congue. Vestibulum porttitor id dolor id sagittis. Sed diam ligula, dictum + ullamcorper tincidunt quis, vehicula eget ex. Aliquam hendrerit dapibus pharetra. Duis ut + metus convallis, placerat magna id, lobortis purus. Vestibulum ut massa id dolor placerat + consectetur. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac + turpis egestas. Nam at interdum arcu, id feugiat dolor. Etiam aliquet vestibulum dolor vel + fermentum. + </p> + </article> + ); +}; + +export const simpleContent = () => ( + <SubscriptionOverlay> + <div>This is some overlay content</div> + </SubscriptionOverlay> +); + +export const multiPageContent = () => ( + <SubscriptionOverlay> + <LongArticle /> + </SubscriptionOverlay> +); + +export const scrollDisabledBackground = () => ( + <> + <LongArticle /> + <SubscriptionOverlay> + <LongArticle /> + </SubscriptionOverlay> + </> +); \ No newline at end of file diff --git a/blocks/subscriptions-block/components/SubscriptionOverlay/index.test.jsx b/blocks/subscriptions-block/components/SubscriptionOverlay/index.test.jsx new file mode 100644 index 0000000000..aa3394f999 --- /dev/null +++ b/blocks/subscriptions-block/components/SubscriptionOverlay/index.test.jsx @@ -0,0 +1,117 @@ +import React, { useState } from "react"; +import { render } from "@testing-library/react"; + +import SubscriptionOverlay from "."; + +Object.defineProperty(global.document, "ownerDocument", { + value: global.document, +}); +Object.defineProperty(global.document, "scrollingElement", { + value: global.document, +}); +Object.defineProperty(global.document, "style", { + value: { overflow: "auto" }, +}); + +describe("SubscriptionOverlay", () => { + it("renders with minimal required properties", () => { + render(<SubscriptionOverlay />); + + expect(screen.find(".xpmedia-subscription-overlay").at(0)).toExist(); + }); + + it("renders required a11y properties", () => { + render(<SubscriptionOverlay />); + + expect(screen.getByRole("alert").length).toEqual(1); + }); + + it("renders a child in the appropriate container", () => { + render( + <SubscriptionOverlay> + <div className="findThis" /> + </SubscriptionOverlay> + ); + + expect(screen.find(".xpmedia-subscription-overlay-content > .findThis").at(0)).toExist(); + }); + + it("handles scrolling", () => { + // Scrolling is difficult to test functionality in Enzyme jsDoc so this just test the events + render( + <SubscriptionOverlay> + <div>Some Text</div> + </SubscriptionOverlay> + ); + const contentContainer = screen.find(".xpmedia-subscription-overlay-content").at(0); + + const eventUp = { + deltaY: 1, + }; + contentContainer.simulate("wheel", eventUp); + + const eventDown = { + deltaY: -1, + }; + contentContainer.simulate("wheel", eventDown); + + expect(contentContainer).toExist(); + }); + + it("handles touch events", () => { + // Scrolling is difficult to test functionality in Enzyme jsDoc so this just test the events + render( + <SubscriptionOverlay> + <div>Some Text</div> + </SubscriptionOverlay> + ); + const contentContainer = screen.find(".xpmedia-subscription-overlay-content").at(0); + + const eventStart = { + changedTouches: [{ clientY: 9 }], + }; + contentContainer.simulate("touchstart", eventStart); + + const eventUp = { + changedTouches: [{ clientY: 10 }], + }; + contentContainer.simulate("touchmove", eventUp); + + const eventDown = { + changedTouches: [{ clientY: 9 }], + }; + contentContainer.simulate("touchmove", eventDown); + + expect(contentContainer).toExist(); + }); + + it("cleans itself up", () => { + const ShowHide = () => { + const [visible, setVisible] = useState(false); + const Button = () => ( + <div> + <button onClick={() => setVisible(!visible)} type="button"> + {visible ? "Hide" : "Show"} + </button> + </div> + ); + return ( + <> + <Button /> + {visible ? ( + <SubscriptionOverlay> + <Button /> + </SubscriptionOverlay> + ) : null} + </> + ); + }; + render(<ShowHide />); + + + expect(system.find(".xpmedia-subscription-overlay").at(0)).toExist(); + + system.find(".xpmedia-subscription-overlay-content button").at(0).simulate("click"); + expect(system.find(".xpmedia-subscription-overlay").at(0)).not.toExist(); + }); +}); \ No newline at end of file diff --git a/blocks/subscriptions-block/components/usePaywall.js b/blocks/subscriptions-block/components/usePaywall.js new file mode 100644 index 0000000000..3a5686a4ea --- /dev/null +++ b/blocks/subscriptions-block/components/usePaywall.js @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; + +import { useFusionContext } from "fusion:context"; +import getProperties from "fusion:properties"; + +import { useIdentity } from "@wpmedia/identity-block"; +import { isServerSide } from "@wpmedia/arc-themes-components"; + +const usePaywall = () => { + const { arcSite, globalContent } = useFusionContext(); + const contentIdentifier = globalContent?.canonical_url; + const contentRestriction = globalContent?.content_restrictions?.content_code; + const contentType = globalContent?.type; + + const { api } = getProperties(arcSite); + + const [campaignCode, setCampaignCode] = useState(); + const [results, setResults] = useState(); + const [isPaywalled, setIsPaywalled] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [triggeredRule, setTriggeredRule] = useState(); + const { Identity, isInitialized: isIdentityInitialized } = useIdentity(); + + // eslint-disable-next-line no-underscore-dangle + const rules = (!isServerSide() && window?.ArcP?._rules) || []; + const apiOrigin = api?.retail?.origin; + + useEffect(() => { + if (isIdentityInitialized) { + Identity.isLoggedIn().then(setIsLoggedIn); + } + }, [Identity, isIdentityInitialized]); + + useEffect(() => { + const runPaywall = async () => { + // Subs ArcP.run assumes https://, so we need to strip it from the endpoint origin. + setResults( + await window?.ArcP?.run({ + apiOrigin: apiOrigin.replace(/^https?:\/\//i, ""), + contentIdentifier, + contentRestriction, + contentType, + Identity, + paywallFunction: (campaign) => { + setCampaignCode(campaign); + setIsPaywalled(true); + }, + section: globalContent.taxonomy?.primary_section._id, + }) + ); + }; + + if ( + apiOrigin && + contentIdentifier && + contentRestriction && + isIdentityInitialized && + !isPaywalled && + !isServerSide() + ) { + runPaywall(); + } + }, [apiOrigin, globalContent, Identity, isIdentityInitialized, isPaywalled]); + + useEffect(() => { + if (results?.triggered && rules?.length) { + const { id: triggerId, rc: triggerCount } = results.triggered; + + const triggeringRule = rules.find(({ id }) => triggerId === id); + + // Rule is bypassed by registered or subscribed users + // Registered e: [true], ent:[false] + // Subscribed (SKU): e: [true, SKU1,...], ent: [false] + // Subscribed (entitlement): e: [false], ent: [true, ENT1, ...] + if (triggeringRule?.e?.length === 1 && triggeringRule?.e[0] === true && isLoggedIn) { + // we currently only support rule triggers of ['>', count] + const triggerableRules = ({ rt: [op, count] }) => op === ">" && triggerCount > count; + const byDescendingTriggerCount = ({ rt: [, a] }, { rt: [, b] }) => b - a; + const withRestrictedStatus = ({ e: [hasOpportunity, skuId] }) => hasOpportunity && !!skuId; + + const paywallableRule = rules + .filter(triggerableRules) + .sort(byDescendingTriggerCount) + .find(withRestrictedStatus); + + setTriggeredRule( + paywallableRule && paywallableRule !== triggeringRule ? paywallableRule : triggeringRule + ); + } else { + setTriggeredRule(triggeringRule); + } + } + }, [results, rules, isLoggedIn]); + + if (isServerSide()) { + return { + campaignCode: undefined, + isPaywalled: false, + isRegisterwalled: false, + }; + } + + return { + isLoggedIn, + campaignCode, + isPaywalled, + isRegisterwalled: + triggeredRule?.e?.length === 1 && + triggeredRule?.e[0] === true && + triggeredRule?.ent?.length === 1 && + triggeredRule?.ent[0] === false, + }; +}; + +export default usePaywall; diff --git a/blocks/subscriptions-block/components/usePaywall.test.js b/blocks/subscriptions-block/components/usePaywall.test.js new file mode 100644 index 0000000000..c4a6873344 --- /dev/null +++ b/blocks/subscriptions-block/components/usePaywall.test.js @@ -0,0 +1,257 @@ +import React from "react"; +import { render } from "@testing-library/react"; + +import { isServerSide } from "@wpmedia/arc-themes-components"; +import { useFusionContext } from "fusion:context"; + +import usePaywall from "./usePaywall"; + +jest.mock("@arc-publishing/sdk-identity", () => ({ + __esModule: true, + default: { + apiOrigin: "", + options: jest.fn(), + }, +})); + +jest.mock("@wpmedia/identity-block", () => ({ + __esModule: true, + useIdentity: jest.fn(() => ({ + Identity: { + isLoggedIn: jest.fn(() => Promise.resolve(true)), + }, + isInitialized: true, + })), +})); + +jest.mock("@wpmedia/engine-theme-sdk", () => ({ + __esModule: true, + isServerSide: jest.fn(() => false), +})); + +jest.mock("fusion:properties", () => + jest.fn(() => ({ + // arcSite + api: { + retail: { + origin: "http://origin/", + }, + }, + })) +); + +jest.mock("fusion:context", () => ({ + __esModule: true, + useFusionContext: jest.fn(() => ({ + arcSite: "TestSite", + globalContent: { + canonical_url: "http://canonical/", + content_restrictions: { + content_code: "restriction_content_code", + }, + taxonomy: { + primary_section: { + _id: "primary_section_id", + }, + }, + type: "contentType", + }, + })), +})); + +global.window.ArcP = { + _rules: [ + { + e: [true], + id: "rule1", + rt: [">", 1], + }, + ], + run: jest.fn((obj) => { + obj.paywallFunction("campaign"); + return { + triggered: { + e: [true], + id: "rule1", + rc: 1, + }, + }; + }), +}; + +const getPaywallObject = () => { + let paywallObject; + const Test = () => { + paywallObject = usePaywall(); + return null; + }; + render(<Test />); + return paywallObject; +}; + +describe("Identity usePaywall Hook", () => { + beforeEach(() => { + useFusionContext.mockReturnValue({ + arcSite: "TestSite1", + globalContent: { + canonical_url: "http://canonical/", + content_restrictions: { + content_code: "restriction_content_code", + }, + taxonomy: { + primary_section: { + _id: "primary_section_id", + }, + }, + type: "contentType", + }, + }); + }); + + afterEach(() => { + useFusionContext.mockReset(); + }); + + it("initially renders with paywall flag", () => { + const paywallObject = getPaywallObject(); + expect(paywallObject.isPaywalled).toBe(true); + }); + + it("properly initializes and sets isPaywalled true", () => { + const paywallObject = getPaywallObject(); + expect(paywallObject.isPaywalled).toBe(true); + }); + + it("returns null if serverSide", () => { + isServerSide.mockReturnValue(true); + const paywallObject = getPaywallObject(); + expect(paywallObject.isPaywalled).toBe(false); + isServerSide.mockReset(); + }); + + it("returns null if there are no results", () => { + window.ArcP.run.mockImplementation(() => null); + const paywallObject = getPaywallObject(); + expect(paywallObject.isPaywalled).toBe(false); + window.ArcP.run.mockReset(); + }); +}); + +describe("Identity usePaywall Hook rule handling", () => { + beforeEach(() => { + useFusionContext.mockReturnValue({ + arcSite: "TestSite3", + globalContent: { + canonical_url: "http://canonical/", + content_restrictions: { + content_code: "restriction_content_code", + }, + taxonomy: { + primary_section: { + _id: "primary_section_id", + }, + }, + type: "contentType", + }, + }); + }); + + afterEach(() => { + useFusionContext.mockReset(); + }); + + it("handles simple paywalled rule", () => { + const expectedResult = { + triggered: { + e: [true, "content paywall status"], + id: "rule1", + rc: 2, + }, + }; + + global.window.ArcP = { + _rules: [ + { + e: [true], + id: "rule1", + rt: [">", 1], + }, + ], + run: jest.fn((obj) => { + obj.paywallFunction("campaign"); + return expectedResult; + }), + }; + + const paywallObject = getPaywallObject(); + expect(paywallObject.isPaywalled).toBe(true); + }); + + it("handles multiple rules matching when triggered", () => { + const expectedResult = { + triggered: { + e: [true], + id: "rule2", + rc: 3, + }, + }; + + global.window.ArcP = { + _rules: [ + { + e: [true], + id: "rule1", + rt: [">", 1], + }, + { + e: [true, "content paywall status"], + id: "rule2", + rt: [">", 1], + }, + ], + run: jest.fn((obj) => { + obj.paywallFunction("campaign"); + return expectedResult; + }), + }; + + const paywallObject = getPaywallObject(); + expect(paywallObject.isPaywalled).toBe(true); + }); + + it("handles multiple rules matching when triggered is different", () => { + const expectedResult = { + triggered: { + e: [true], + id: "rule1", + rc: 3, + }, + }; + + global.window.ArcP = { + _rules: [ + { + e: [true], + id: "rule1", + rt: [">", 1], + }, + { + e: [true, "content paywall status"], + id: "rule2", + rt: [">", 1], + }, + ], + run: jest.fn((obj) => { + obj.paywallFunction("campaign"); + return expectedResult; + }), + }; + + const paywallObject = getPaywallObject(); + expect(paywallObject.isPaywalled).toBe(true); + }); + + afterEach(() => { + useFusionContext.mockReset(); + }); +}); \ No newline at end of file diff --git a/blocks/subscriptions-block/features/paywall/default.jsx b/blocks/subscriptions-block/features/paywall/default.jsx new file mode 100644 index 0000000000..311ac8dcf5 --- /dev/null +++ b/blocks/subscriptions-block/features/paywall/default.jsx @@ -0,0 +1,151 @@ +import React from "react"; +import { isServerSide } from "@wpmedia/arc-themes-components"; +import PropTypes from "@arc-fusion/prop-types"; + +import { useFusionContext } from "fusion:context"; + +import PaywallOffer from "../../components/PaywallOffer"; +import RegwallOffer from "../../components/RegwallOffer"; +import usePaywall from "../../components/usePaywall"; + +const BLOCK_CLASS_NAME = "b-paywall"; + +const Paywall = ({ customFields }) => { + const { isAdmin } = useFusionContext(); + + const { + adminViewState, + displayMode, + linkText, + linkUrl, + payActionText, + payActionUrl, + payLinkPrompt, + payReasonPrompt, + registerActionText, + registerActionUrl, + registerHeaderText, + registerSubHeaderText, + registerReasonPrompt, + } = customFields; + + const { isLoggedIn, isPaywalled, isRegisterwalled } = usePaywall(); + + if (!isServerSide()) { + if ((!isAdmin && isRegisterwalled) || (isAdmin && adminViewState === "showRegwall")) { + return ( + <RegwallOffer + actionText={registerActionText} + actionUrl={registerActionUrl} + displayMode={displayMode} + headlineText={registerHeaderText} + linkPrompt={payLinkPrompt} + linkText={linkText} + linkUrl={linkUrl} + reasonPrompt={registerReasonPrompt} + subheadlineText={registerSubHeaderText} + className={BLOCK_CLASS_NAME} + /> + ); + } + if ((!isAdmin && isPaywalled) || (isAdmin && adminViewState === "showPaywall")) { + return ( + <PaywallOffer + isLoggedIn={isLoggedIn} + actionText={payActionText} + actionUrl={payActionUrl} + displayMode={displayMode} + linkPrompt={payLinkPrompt} + linkText={linkText} + linkUrl={linkUrl} + reasonPrompt={payReasonPrompt} + className={BLOCK_CLASS_NAME} + /> + ); + } + } + return null; +}; + +Paywall.propTypes = { + customFields: PropTypes.shape({ + adminViewState: PropTypes.oneOf(["hide", "showRegwall", "showPaywall"]).tag({ + defaultValue: "hide", + description: "Determines which view is shown here in the admin screen. ", + label: "Preview", + labels: { + hide: "Hide", + showRegwall: "Show Registration Wall", + showPaywall: "Show Payment Wall", + }, + }), + displayMode: PropTypes.oneOf(["bottomHalf", "full", "modal"]).tag({ + defaultValue: "bottomHalf", + description: "Determines how the dialog will present itself to the user when required.", + hidden: true, + label: "Paywall Display Mode", + labels: { + bottomHalf: "Show as bottom half of screen", + full: "Show as a full page cover", + modal: "Show as a modal dialog", + }, + }), + payLinkPrompt: PropTypes.string.tag({ + label: "Log In text for paywall", + defaultValue: "Already a subscriber?", + }), + linkText: PropTypes.string.tag({ + label: "Log In link text", + defaultValue: "Log In.", + }), + linkUrl: PropTypes.string.tag({ + label: "Log In link URL", + defaultValue: "/account/login/", + }), + payActionText: PropTypes.string.tag({ + group: "Payment Wall", + label: "CTA button text", + defaultValue: "Subscribe", + }), + payActionUrl: PropTypes.string.tag({ + group: "Payment Wall", + label: "CTA button URL", + defaultValue: "/offer/", + }), + payReasonPrompt: PropTypes.string.tag({ + group: "Payment Wall", + label: "Reason prompt text", + defaultValue: "Subscribe to continue reading.", + }), + registerActionText: PropTypes.string.tag({ + group: "Registration Wall", + label: "CTA button text", + defaultValue: "", + }), + registerActionUrl: PropTypes.string.tag({ + group: "Registration Wall", + label: "CTA button URL", + defaultValue: "/account/signup/", + }), + registerHeaderText: PropTypes.string.tag({ + group: "Registration Wall", + label: "Header Text", + defaultValue: "", + }), + registerSubHeaderText: PropTypes.string.tag({ + group: "Registration Wall", + label: "Subheader Text", + defaultValue: "", + }), + registerReasonPrompt: PropTypes.string.tag({ + group: "Registration Wall", + label: "Reason prompt text", + defaultValue: "Register to continue reading.", + }), + }), +}; + +Paywall.icon = "tag-dollar"; +Paywall.label = "Subscriptions Paywall - Arc Block"; + +export default Paywall; diff --git a/blocks/subscriptions-block/features/paywall/default.test.jsx b/blocks/subscriptions-block/features/paywall/default.test.jsx new file mode 100644 index 0000000000..e02fc1ea1a --- /dev/null +++ b/blocks/subscriptions-block/features/paywall/default.test.jsx @@ -0,0 +1,144 @@ +import React from "react"; +import { render } from "@testing-library/react"; + +import { useFusionContext } from "fusion:context"; +import getProperties from "fusion:properties"; + +import Paywall from "./default"; +import usePaywall from "../../components/usePaywall"; + +jest.mock("../../components/useOffer"); +jest.mock("../../components/usePaywall"); +jest.mock("../../components/PaywallOffer", () => () => <paywall-offer />); +jest.mock("../../components/RegwallOffer", () => () => <regwall-offer />); + +jest.mock("fusion:context", () => ({ + __esModule: true, + useFusionContext: jest.fn(() => ({ + isAdmin: false, + })), +})); + +describe("The Paywall feature", () => { + it("renders Paywall when isPaywalled is true and isRegisterwalled is false", () => { + usePaywall.mockReturnValue({ + isPaywalled: true, + isRegisterwalled: false, + }); + getProperties.mockReturnValue({ + locale: "en", + }); + render( + <Paywall + customFields={{ + adminViewState: "hide", + displayMode: "", + campaignCode: "default", + linkText: "Log In.", + linkUrl: "/account/login", + payActionText: "Subscribe", + payActionUrl: "/offer/", + payReasonPrompt: "Pay Reason", + registerActionText: "/Register/Action", + registerActionUrl: "/Register/Action", + registerHeaderText: "Register Now", + registerSubHeaderText: "to gain access", + registerReasonPrompt: "Register Reason", + }} + /> + ); + + expect(screen.containsMatchingElement(<paywall-offer />)).toEqual(true); + }); + + it("renders Regwall when isRegisterwalled is true", () => { + usePaywall.mockReturnValue({ + isPaywalled: true, + isRegisterwalled: true, + }); + getProperties.mockReturnValue({ + locale: "en", + }); + render( + <Paywall + customFields={{ + adminViewState: "hide", + displayMode: "", + campaignCode: "default", + linkText: "Log In.", + linkUrl: "/account/login", + payActionText: "Subscribe", + payActionUrl: "/offer/", + payReasonPrompt: "Pay Reason", + registerActionText: "/Register/Action", + registerActionUrl: "/Register/Action", + registerHeaderText: "Register Now", + registerSubHeaderText: "to gain access", + registerReasonPrompt: "Register Reason", + }} + /> + ); + + expect(screen.containsMatchingElement(<regwall-offer />)).toEqual(true); + }); + + it("renders Regwall component when isAdmin and adminViewState is showRegwall", () => { + useFusionContext.mockReturnValue({ + isAdmin: true, + }); + getProperties.mockReturnValue({ + locale: "en", + }); + render( + <Paywall + customFields={{ + adminViewState: "showRegwall", + displayMode: "", + campaignCode: "default", + linkText: "Log In.", + linkUrl: "/account/login", + payActionText: "Subscribe", + payActionUrl: "/offer/", + payReasonPrompt: "Pay Reason", + registerActionText: "/Register/Action", + registerActionUrl: "/Register/Action", + registerHeaderText: "Register Now", + registerSubHeaderText: "to gain access", + registerReasonPrompt: "Register Reason", + }} + /> + ); + + expect(screen.containsMatchingElement(<regwall-offer />)).toEqual(true); + }); + + it("renders Paywall component when isAdmin and adminViewState is showPaywall", () => { + useFusionContext.mockReturnValue({ + isAdmin: true, + }); + getProperties.mockReturnValue({ + locale: "en", + }); + render( + <Paywall + customFields={{ + adminViewState: "showPaywall", + displayMode: "", + campaignCode: "default", + linkText: "Log In.", + linkUrl: "/account/login", + payActionText: "Subscribe", + payActionUrl: "/offer/", + payReasonPrompt: "Pay Reason", + registerActionText: "/Register/Action", + registerActionUrl: "/Register/Action", + registerHeaderText: "Register Now", + registerSubHeaderText: "to gain access", + registerReasonPrompt: "Register Reason", + }} + /> + ); + + expect(screen.containsMatchingElement(<paywall-offer />)).toEqual(true); + }); +}); \ No newline at end of file diff --git a/blocks/subscriptions-block/themes/news.json b/blocks/subscriptions-block/themes/news.json index d0279ae3d9..d9b8f9a6ea 100644 --- a/blocks/subscriptions-block/themes/news.json +++ b/blocks/subscriptions-block/themes/news.json @@ -400,5 +400,99 @@ } } } + }, + "paywall-overlay": { + "styles": { + "default": { + "background-color": "rgba(0, 0, 0, 0.63)", + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + "overflow": "auto", + "pointer-events": "all", + "position": "fixed", + "height": "100vh", + "z-index": 1 + } + } + }, + "paywall-overlay-content": { + "styles": { + "default": { + "align-items": "center", + "justify-content": "center", + "background-color": "var(--global-white)", + "display": "flex", + "flex-direction": "column", + "margin-top": "50vh", + "max-height": "75vh", + "min-height": "50vh", + "overflow": "auto", + "components": { + "stack": { + "width": "100%" + } + } + } + } + }, + "paywall-subscription-dialog": { + "styles": { + "default": { + "padding": "var(--global-spacing-6)", + "align-items": "center", + "grid-template-rows": "auto", + "gap": "var(--global-spacing-6)", + "font-family": "var(--font-family-primary)", + "components": { + "stack": { + "align-items": "center", + "grid-template-rows": "auto" + } + } + } + } + }, + "paywall-subscription-dialog-reason-prompt": { + "styles": { + "default": { + "font-family": "var(--font-family-primary)" + } + } + }, + "paywall-subscription-dialog-link-prompt-pre-link": { + "styles": { + "default": { + "margin-right": "var(--global-spacing-2)" + } + } + }, + "paywall-subscription-dialog-link-prompt-link": { + "styles": { + "default": { + "font-weight": "var(--heading-level-4-font-weight)" + } + } + }, + "paywall-subscription-dialog-offer-info":{ + "styles": { + "default": { + "gap": "var(--global-spacing-4)", + "components": { + "heading": { + "font-size": "var(--heading-level-2-font-size)", + "text-align": "center" + } + } + } + } + }, + "paywall-subscription-dialog-subheadline":{ + "styles": { + "default": { + "font-size": "var(--heading-level-6-font-size)" + } + } } }