diff --git a/dotcom-rendering/.storybook/preview.ts b/dotcom-rendering/.storybook/preview.ts
index a7087193223..678a597e431 100644
--- a/dotcom-rendering/.storybook/preview.ts
+++ b/dotcom-rendering/.storybook/preview.ts
@@ -12,6 +12,7 @@ import { Picture } from '../src/components/Picture';
import { mockFetch } from '../src/lib/mockRESTCalls';
import { setABTests } from '../src/lib/useAB';
import { ConfigContextDecorator } from './decorators/configContextDecorator';
+import { sb } from 'storybook/test';
import { Preview } from '@storybook/react-webpack5';
import {
globalColourScheme,
@@ -19,6 +20,11 @@ import {
} from './toolbar/globalColourScheme';
import { palette as sourcePalette } from '@guardian/source/foundations';
+// Set up module mocking for auth and newsletter subscription hooks
+sb.mock(import('../src/lib/useNewsletterSubscription.ts'), { spy: true });
+sb.mock(import('../src/lib/useAuthStatus.ts'), { spy: true });
+sb.mock(import('../src/lib/fetchEmail.ts'), { spy: true });
+
// Prevent components being lazy rendered when we're taking Chromatic snapshots
Lazy.disabled = isChromatic();
Picture.disableLazyLoading = isChromatic();
@@ -64,6 +70,7 @@ style.appendChild(document.createTextNode(css));
},
page: {
ajaxUrl: 'https://api.nextgen.guardianapps.co.uk',
+ idApiUrl: 'https://idapi.theguardian.com',
},
tests: {},
switches: {},
diff --git a/dotcom-rendering/src/components/ArticleBody.tsx b/dotcom-rendering/src/components/ArticleBody.tsx
index 21bf20612e4..25d010855aa 100644
--- a/dotcom-rendering/src/components/ArticleBody.tsx
+++ b/dotcom-rendering/src/components/ArticleBody.tsx
@@ -55,6 +55,7 @@ type Props = {
isRightToLeftLang?: boolean;
shouldHideAds: boolean;
serverTime?: number;
+ idApiUrl?: string;
};
const globalOlStyles = () => css`
@@ -139,6 +140,7 @@ export const ArticleBody = ({
editionId,
shouldHideAds,
serverTime,
+ idApiUrl,
}: Props) => {
const isInteractiveContent =
format.design === ArticleDesign.Interactive ||
@@ -208,6 +210,7 @@ export const ArticleBody = ({
editionId={editionId}
shouldHideAds={shouldHideAds}
serverTime={serverTime}
+ idApiUrl={idApiUrl}
/>
);
@@ -256,6 +259,7 @@ export const ArticleBody = ({
editionId={editionId}
contributionsServiceUrl={contributionsServiceUrl}
shouldHideAds={shouldHideAds}
+ idApiUrl={idApiUrl}
/>
{hasObserverPublicationTag && }
diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.importable.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.importable.tsx
new file mode 100644
index 00000000000..42511e07716
--- /dev/null
+++ b/dotcom-rendering/src/components/EmailSignUpWrapper.importable.tsx
@@ -0,0 +1,77 @@
+import type { Breakpoint } from '@guardian/source/foundations';
+import { useNewsletterSubscription } from '../lib/useNewsletterSubscription';
+import type { EmailSignUpProps } from './EmailSignup';
+import { EmailSignup } from './EmailSignup';
+import { InlineSkipToWrapper } from './InlineSkipToWrapper';
+import { Island } from './Island';
+import { NewsletterPrivacyMessage } from './NewsletterPrivacyMessage';
+import { Placeholder } from './Placeholder';
+import { SecureSignup } from './SecureSignup.importable';
+
+/**
+ * Approximate heights of the EmailSignup component at different breakpoints.
+ */
+const PLACEHOLDER_HEIGHTS = new Map([
+ ['mobile', 220],
+ ['tablet', 180],
+ ['desktop', 180],
+]);
+
+interface EmailSignUpWrapperProps extends EmailSignUpProps {
+ index: number;
+ listId: number;
+ identityName: string;
+ successDescription: string;
+ idApiUrl: string;
+ /** You should only set this to true if the privacy message will be shown elsewhere on the page */
+ hidePrivacyMessage?: boolean;
+}
+
+/**
+ * EmailSignUpWrapper as an importable island component.
+ *
+ * This component needs to be hydrated client-side because it uses
+ * the useNewsletterSubscription hook which depends on auth status
+ * to determine if the user is already subscribed to the newsletter.
+ *
+ * If the user is signed in and already subscribed, this component
+ * will return null (hide the signup form).
+ */
+export const EmailSignUpWrapper = ({
+ index,
+ listId,
+ idApiUrl,
+ ...emailSignUpProps
+}: EmailSignUpWrapperProps) => {
+ const isSubscribed = useNewsletterSubscription(listId, idApiUrl);
+
+ // Show placeholder while subscription status is being determined
+ // This prevents layout shift in both subscribed and non-subscribed cases
+ if (isSubscribed === undefined) {
+ return ;
+ }
+
+ // Don't render if user is signed in and already subscribed
+ if (isSubscribed) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {!emailSignUpProps.hidePrivacyMessage && (
+
+ )}
+
+
+ );
+};
diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx
index 8ad4d287d88..15edf86e418 100644
--- a/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx
+++ b/dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx
@@ -1,28 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
-import { EmailSignUpWrapper } from './EmailSignUpWrapper';
+import { mocked } from 'storybook/test';
+import { lazyFetchEmailWithTimeout } from '../lib/fetchEmail';
+import { useIsSignedIn } from '../lib/useAuthStatus';
+import { useNewsletterSubscription } from '../lib/useNewsletterSubscription';
+import { EmailSignUpWrapper } from './EmailSignUpWrapper.importable';
const meta: Meta = {
title: 'Components/EmailSignUpWrapper',
component: EmailSignUpWrapper,
};
+type Story = StoryObj;
+
const defaultArgs = {
index: 10,
+ listId: 4147,
identityName: 'the-recap',
description:
- 'The best of our sports journalism from the past seven days and a heads-up on the weekend’s action',
+ "The best of our sports journalism from the past seven days and a heads-up on the weekend's action",
name: 'The Recap',
frequency: 'Weekly',
successDescription: "We'll send you The Recap every week",
theme: 'sport',
+ idApiUrl: 'https://idapi.theguardian.com',
} satisfies Story['args'];
-type Story = StoryObj;
+// Loading state - shows placeholder while auth status is being determined
+// This prevents layout shift when subscription status is resolved
+export const Placeholder: Story = {
+ args: {
+ hidePrivacyMessage: false,
+ ...defaultArgs,
+ },
+ async beforeEach() {
+ mocked(useNewsletterSubscription).mockReturnValue(undefined);
+ },
+};
+
+// Default story - signed out user sees the signup form with email input
export const DefaultStory: Story = {
args: {
hidePrivacyMessage: true,
...defaultArgs,
},
+ async beforeEach() {
+ mocked(useNewsletterSubscription).mockReturnValue(false);
+ mocked(useIsSignedIn).mockReturnValue(false);
+ mocked(lazyFetchEmailWithTimeout).mockReturnValue(() =>
+ Promise.resolve(null),
+ );
+ },
};
export const DefaultStoryWithPrivacy: Story = {
@@ -30,6 +57,40 @@ export const DefaultStoryWithPrivacy: Story = {
hidePrivacyMessage: false,
...defaultArgs,
},
+ async beforeEach() {
+ mocked(useNewsletterSubscription).mockReturnValue(false);
+ mocked(useIsSignedIn).mockReturnValue(false);
+ mocked(lazyFetchEmailWithTimeout).mockReturnValue(() =>
+ Promise.resolve(null),
+ );
+ },
+};
+
+// User is signed in but NOT subscribed - email field is hidden, only signup button shows
+export const SignedInNotSubscribed: Story = {
+ args: {
+ hidePrivacyMessage: false,
+ ...defaultArgs,
+ },
+ async beforeEach() {
+ mocked(useNewsletterSubscription).mockReturnValue(false);
+ mocked(useIsSignedIn).mockReturnValue(true);
+ mocked(lazyFetchEmailWithTimeout).mockReturnValue(() =>
+ Promise.resolve('test@example.com'),
+ );
+ },
+};
+
+// User is signed in and IS subscribed - component returns null (hidden)
+// Note: This story will render nothing as the component returns null when subscribed
+export const SignedInAlreadySubscribed: Story = {
+ args: {
+ hidePrivacyMessage: false,
+ ...defaultArgs,
+ },
+ async beforeEach() {
+ mocked(useNewsletterSubscription).mockReturnValue(true);
+ },
};
export default meta;
diff --git a/dotcom-rendering/src/components/EmailSignUpWrapper.tsx b/dotcom-rendering/src/components/EmailSignUpWrapper.tsx
deleted file mode 100644
index b55112e23e9..00000000000
--- a/dotcom-rendering/src/components/EmailSignUpWrapper.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { EmailSignUpProps } from './EmailSignup';
-import { EmailSignup } from './EmailSignup';
-import { InlineSkipToWrapper } from './InlineSkipToWrapper';
-import { Island } from './Island';
-import { NewsletterPrivacyMessage } from './NewsletterPrivacyMessage';
-import { SecureSignup } from './SecureSignup.importable';
-
-interface EmailSignUpWrapperProps extends EmailSignUpProps {
- index: number;
- identityName: string;
- successDescription: string;
- /** You should only set this to true if the privacy message will be shown elsewhere on the page */
- hidePrivacyMessage?: boolean;
-}
-
-export const EmailSignUpWrapper = ({
- index,
- ...emailSignUpProps
-}: EmailSignUpWrapperProps) => {
- return (
-
-
-
-
-
- {!emailSignUpProps.hidePrivacyMessage && (
-
- )}
-
-
- );
-};
diff --git a/dotcom-rendering/src/components/LiveBlock.stories.tsx b/dotcom-rendering/src/components/LiveBlock.stories.tsx
index d6a9ac3b5f2..0cde52f5c3b 100644
--- a/dotcom-rendering/src/components/LiveBlock.stories.tsx
+++ b/dotcom-rendering/src/components/LiveBlock.stories.tsx
@@ -81,6 +81,7 @@ export const VideoAsSecond = () => {
isPinnedPost={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -129,6 +130,7 @@ export const Title = () => {
isPinnedPost={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -198,6 +200,7 @@ export const Video = () => {
isPinnedPost={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -242,6 +245,7 @@ export const RichLink = () => {
isPinnedPost={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -277,6 +281,7 @@ export const FirstImage = () => {
isPinnedPost={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -338,6 +343,7 @@ export const ImageRoles = () => {
isSensitive={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -388,6 +394,7 @@ export const Thumbnail = () => {
isSensitive={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -424,6 +431,7 @@ export const ImageAndTitle = () => {
isPinnedPost={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -456,6 +464,7 @@ export const Updated = () => {
isPinnedPost={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -492,6 +501,7 @@ export const Contributor = () => {
isSensitive={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -526,6 +536,7 @@ export const NoAvatar = () => {
isSensitive={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
@@ -563,6 +574,7 @@ export const TitleAndContributor = () => {
isSensitive={false}
editionId={'UK'}
shouldHideAds={false}
+ idApiUrl="https://idapi.theguardian.com"
/>
);
diff --git a/dotcom-rendering/src/components/LiveBlock.tsx b/dotcom-rendering/src/components/LiveBlock.tsx
index effb6cc8218..9d328dd5ba9 100644
--- a/dotcom-rendering/src/components/LiveBlock.tsx
+++ b/dotcom-rendering/src/components/LiveBlock.tsx
@@ -27,6 +27,7 @@ type Props = {
editionId: EditionId;
shouldHideAds: boolean;
serverTime?: number;
+ idApiUrl?: string;
};
export const LiveBlock = ({
@@ -46,6 +47,7 @@ export const LiveBlock = ({
editionId,
shouldHideAds,
serverTime,
+ idApiUrl,
}: Props) => {
if (block.elements.length === 0) return null;
@@ -91,6 +93,7 @@ export const LiveBlock = ({
isPinnedPost={isPinnedPost}
editionId={editionId}
shouldHideAds={shouldHideAds}
+ idApiUrl={idApiUrl}
/>
))}