Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
74f916e
Hide signUp card for signed in users in newsletter
Marianaguardian Nov 26, 2025
6c6ffc1
removed unused imports
Marianaguardian Nov 26, 2025
177412b
fixed lint issues
Marianaguardian Nov 26, 2025
b999d89
Merge branch 'main' of https://github.com/guardian/dotcom-rendering i…
Marianaguardian Nov 26, 2025
da6e644
Merge branch 'main' of https://github.com/guardian/dotcom-rendering i…
Marianaguardian Nov 26, 2025
a5b9c4b
remove console
Marianaguardian Nov 26, 2025
7ad7a22
Merge branch 'main' of https://github.com/guardian/dotcom-rendering i…
Marianaguardian Nov 26, 2025
6f12d91
updated from main branch
Marianaguardian Nov 26, 2025
bb54b31
updated idApiUrl type
Marianaguardian Nov 26, 2025
4927841
Merge branch 'main' of https://github.com/guardian/dotcom-rendering i…
Marianaguardian Nov 26, 2025
8582503
Minor changes for testing
Marianaguardian Nov 28, 2025
b56c69f
Merge branch 'main' into 14890-show-or-hide-the-sign-up-newsletter-co…
Marianaguardian Nov 28, 2025
618f558
reverted test changes and correct response structure for user newslet…
Marianaguardian Dec 1, 2025
08ba7cc
Merge branch '14890-show-or-hide-the-sign-up-newsletter-component' of…
Marianaguardian Dec 1, 2025
6e620a5
Merge branch 'main' into 14890-show-or-hide-the-sign-up-newsletter-co…
Marianaguardian Dec 1, 2025
8eed71c
render newsletter list id of current news letter for testing and upda…
Marianaguardian Dec 1, 2025
97e519d
added console logs for debuging
Marianaguardian Dec 1, 2025
f0de441
render auth status and api reponse
Marianaguardian Dec 1, 2025
d5dbdaa
fix lint error
Marianaguardian Dec 1, 2025
6161c89
Removed debugging logs and cleaned up code
Marianaguardian Dec 1, 2025
67ce48a
Wrapped email signup wrapper in Island for runtime render
Marianaguardian Dec 1, 2025
c190059
Addressed PR comments
Marianaguardian Dec 2, 2025
0a9eb75
Merge branch 'main' of https://github.com/guardian/dotcom-rendering i…
Marianaguardian Dec 2, 2025
c2f8825
resolved lint issue
Marianaguardian Dec 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions dotcom-rendering/.storybook/decorators/authDecorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Decorator } from '@storybook/react-webpack5';
import { customMockFetch } from '../../src/lib/mockRESTCalls';

// Extend window type for auth state mock
declare global {
interface Window {
__STORYBOOK_AUTH_STATE__?: 'SignedIn' | 'SignedOut';
}
}

/**
* Decorator for signed-out user state.
* Sets the auth state to 'SignedOut' so useAuthStatus returns { kind: 'SignedOut' }.
*/
export const signedOutDecorator: Decorator = (Story) => {
window.__STORYBOOK_AUTH_STATE__ = 'SignedOut';
return <Story />;
};

/**
* Creates a decorator for signed-in user state with custom newsletter subscriptions.
* Sets the auth state to 'SignedIn' and mocks the newsletters API response.
*
* @param subscriptions - Array of newsletter subscriptions to return from the API.
* Each subscription should have a `listId` string.
* @returns A Storybook decorator
*
* @example
* // User signed in but not subscribed to any newsletters
* decorators: [signedInDecorator([])]
*
* @example
* // User signed in and subscribed to newsletter with listId 4147
* decorators: [signedInDecorator([{ listId: '4147' }])]
*/
export const signedInDecorator = (
subscriptions: Array<{ listId: string }> = [],
): Decorator => {
return (Story) => {
window.__STORYBOOK_AUTH_STATE__ = 'SignedIn';
window.fetch = customMockFetch([
{
mockedMethod: 'GET',
mockedUrl: /.*idapi\.theguardian\.com\/users\/me\/newsletters/,
mockedStatus: 200,
mockedBody: {
result: {
subscriptions,
},
},
},
]) as typeof window.fetch;
return <Story />;
};
};
4 changes: 4 additions & 0 deletions dotcom-rendering/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ const webpackConfig = (config: Configuration) => {
`${path.resolve(__dirname, '../src/lib/bridgetApi')}$`
] = path.resolve(__dirname, './mocks/bridgetApi.ts');

// Mock identity module for storybook to control auth state
config.resolve.alias[`${path.resolve(__dirname, '../src/lib/identity')}$`] =
path.resolve(__dirname, './mocks/identity.ts');

const webpackLoaders = getLoaders('client.web');

// https://swc.rs/docs/usage/swc-loader#with-babel-loader
Expand Down
132 changes: 132 additions & 0 deletions dotcom-rendering/.storybook/mocks/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Mock identity module for Storybook.
*
* This allows stories to control the authentication state
* by setting window.__STORYBOOK_AUTH_STATE__ before rendering.
*/

import type { AuthStatus, SignedIn } from '../../src/lib/identity';

// Extend window type for our mock
declare global {
interface Window {
__STORYBOOK_AUTH_STATE__?: 'SignedIn' | 'SignedOut';
}
}

const mockAccessToken = {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to mock all this? Is it not possible to just mock the useAuthStatus hook?

expiresAt: Date.now() / 1000 + 3600,
scopes: ['openid', 'profile', 'email'],
clockSkew: 0,
accessToken: 'mock-access-token-for-storybook',
claims: {
aud: 'guardian-frontend',
auth_time: Date.now() / 1000,
cid: 'guardian-frontend',
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
iss: 'https://profile.theguardian.com',
jti: 'mock-jti',
scp: ['openid', 'profile', 'email'],
sub: 'mock-user-id',
uid: 'mock-uid',
ver: 1,
email_validated: true,
identity_username: 'storybook-user',
legacy_identity_id: 'mock-legacy-id',
user_groups: [],
},
tokenType: 'Bearer' as const,
};

const mockIdToken = {
idToken: 'mock-id-token-for-storybook',
issuer: 'https://profile.theguardian.com',
clientId: 'guardian-frontend',
nonce: 'mock-nonce',
clockSkew: 0,
expiresAt: Date.now() / 1000 + 3600,
scopes: ['openid', 'profile', 'email'],
claims: {
aud: 'guardian-frontend',
auth_time: Date.now() / 1000,
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
iss: 'https://profile.theguardian.com',
sub: 'mock-user-id',
identity_username: 'storybook-user',
email_validated: true,
email: 'storybook@example.com',
braze_uuid: 'mock-braze-uuid',
user_groups: [],
legacy_identity_id: 'mock-legacy-id',
amr: ['pwd'],
at_hash: 'mock-at-hash',
idp: 'guardian',
jti: 'mock-jti',
name: 'Storybook User',
nonce: 'mock-nonce',
ver: 1,
},
};

export async function getAuthState() {
const authState = window.__STORYBOOK_AUTH_STATE__ ?? 'SignedOut';

if (authState === 'SignedIn') {
return {
isAuthenticated: true,
accessToken: mockAccessToken,
idToken: mockIdToken,
};
}

return {
isAuthenticated: false,
accessToken: undefined,
idToken: undefined,
};
}

export function getSignedInStatus(authState: {
isAuthenticated: boolean;
accessToken?: typeof mockAccessToken;
idToken?: typeof mockIdToken;
}): AuthStatus {
if (
authState.isAuthenticated &&
authState.accessToken &&
authState.idToken
) {
return {
kind: 'SignedIn',
accessToken: authState.accessToken,
idToken: authState.idToken,
} as unknown as SignedIn;
}

return { kind: 'SignedOut' };
}

export const getOptionsHeaders = (authStatus: SignedIn): RequestInit => {
return {
headers: {
Authorization: `Bearer ${authStatus.accessToken.accessToken}`,
'X-GU-IS-OAUTH': 'true',
},
};
};

export const isUserLoggedIn = (): Promise<boolean> =>
getAuthStatus().then((authStatus) =>
authStatus.kind === 'SignedIn' ? true : false,
);

export const getAuthStatus = async (): Promise<AuthStatus> => {
const authState = await getAuthState();
return getSignedInStatus(authState);
};

export async function isSignedInAuthState() {
return getAuthState();
}
1 change: 1 addition & 0 deletions dotcom-rendering/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ style.appendChild(document.createTextNode(css));
},
page: {
ajaxUrl: 'https://api.nextgen.guardianapps.co.uk',
idApiUrl: 'https://idapi.theguardian.com',
},
tests: {},
switches: {},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useState } from 'react';
import { useNewsletterSubscription } from '../lib/useNewsletterSubscription';
import type { EmailSignUpProps } from './EmailSignup';
import { EmailSignup } from './EmailSignup';
import { InlineSkipToWrapper } from './InlineSkipToWrapper';
Expand All @@ -7,16 +9,39 @@ import { SecureSignup } from './SecureSignup.importable';

interface EmailSignUpWrapperProps extends EmailSignUpProps {
index: number;
listId: 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;
}

/**
* 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,
...emailSignUpProps
}: EmailSignUpWrapperProps) => {
const [idApiUrl] = useState(() => {
if (typeof window === 'undefined') return undefined;
return window.guardian?.config?.page?.idApiUrl ?? undefined;
});
const isSubscribed = useNewsletterSubscription(listId, idApiUrl);

// Don't render if user is signed in and already subscribed
if (isSubscribed === true) {
return null;
}

return (
<InlineSkipToWrapper
id={`EmailSignup-skip-link-${index}`}
Expand Down
34 changes: 31 additions & 3 deletions dotcom-rendering/src/components/EmailSignUpWrapper.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,63 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { EmailSignUpWrapper } from './EmailSignUpWrapper';
import {
signedInDecorator,
signedOutDecorator,
} from '../../.storybook/decorators/authDecorator';
import { EmailSignUpWrapper } from './EmailSignUpWrapper.importable';

const meta: Meta<typeof EmailSignUpWrapper> = {
title: 'Components/EmailSignUpWrapper',
component: EmailSignUpWrapper,
};

type Story = StoryObj<typeof EmailSignUpWrapper>;

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 weekends 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',
} satisfies Story['args'];
type Story = StoryObj<typeof EmailSignUpWrapper>;

// Default story - signed out user sees the signup form
export const DefaultStory: Story = {
args: {
hidePrivacyMessage: true,
...defaultArgs,
},
decorators: [signedOutDecorator],
};

export const DefaultStoryWithPrivacy: Story = {
args: {
hidePrivacyMessage: false,
...defaultArgs,
},
decorators: [signedOutDecorator],
};

// User is signed in but NOT subscribed - signup form is visible
export const SignedInNotSubscribed: Story = {
args: {
hidePrivacyMessage: false,
...defaultArgs,
},
decorators: [signedInDecorator([])],
};

// 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,
},
decorators: [signedInDecorator([{ listId: String(defaultArgs.listId) }])],
};

export default meta;
9 changes: 7 additions & 2 deletions dotcom-rendering/src/lib/renderElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { CommentBlockComponent } from '../components/CommentBlockComponent';
import { CrosswordComponent } from '../components/CrosswordComponent.importable';
import { DividerBlockComponent } from '../components/DividerBlockComponent';
import { DocumentBlockComponent } from '../components/DocumentBlockComponent.importable';
import { EmailSignUpWrapper } from '../components/EmailSignUpWrapper';
import { EmailSignUpWrapper } from '../components/EmailSignUpWrapper.importable';
import { EmbedBlockComponent } from '../components/EmbedBlockComponent.importable';
import { ExplainerAtom } from '../components/ExplainerAtom';
import { Figure } from '../components/Figure';
Expand Down Expand Up @@ -546,6 +546,7 @@ export const renderElement = ({
case 'model.dotcomrendering.pageElements.NewsletterSignupBlockElement':
const emailSignUpProps = {
index,
listId: element.newsletter.listId,
identityName: element.newsletter.identityName,
description: element.newsletter.description,
name: element.newsletter.name,
Expand All @@ -554,7 +555,11 @@ export const renderElement = ({
theme: element.newsletter.theme,
};
if (isListElement || isTimeline) return null;
return <EmailSignUpWrapper {...emailSignUpProps} />;
return (
<Island priority="feature" defer={{ until: 'visible' }}>
<EmailSignUpWrapper {...emailSignUpProps} />
</Island>
);
case 'model.dotcomrendering.pageElements.AdPlaceholderBlockElement':
return renderAds && <AdPlaceholder />;
case 'model.dotcomrendering.pageElements.NumberedTitleBlockElement':
Expand Down
Loading
Loading