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} /> ))}