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)"
+			}
+		}
 	}
 }