Skip to content

Commit bc1c9bc

Browse files
Merge branch 'main' into mob/scrollable-products
2 parents a3497bc + 7faca38 commit bc1c9bc

12 files changed

+507
-58
lines changed

dotcom-rendering/fixtures/manual/productBlockElement.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { productImage } from './productImage';
44
export const exampleProduct: ProductBlockElement = {
55
_type: 'model.dotcomrendering.pageElements.ProductBlockElement',
66
elementId: 'b1f6e8e2-3f3a-4f0c-8d1e-5f3e3e3e3e3e',
7-
primaryHeadingHtml: '<em>Best Kettle overall</em>',
7+
primaryHeadingHtml: 'Best overall',
88
secondaryHeadingHtml: 'Bosch Sky Kettle',
99
brandName: 'Bosch',
1010
productName: 'Sky Kettle',

dotcom-rendering/src/components/Discussion/AbuseReportForm.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,12 @@ export const AbuseReportForm = ({
225225
formVariables.categoryId === legalIssueCategoryId;
226226

227227
return (
228-
<div aria-modal="true" ref={modalRef}>
228+
<div
229+
role="dialog"
230+
aria-modal="true"
231+
aria-label="Report abuse"
232+
ref={modalRef}
233+
>
229234
<form css={formWrapper} onSubmit={onSubmit}>
230235
<div
231236
css={[

dotcom-rendering/src/components/Dropdown.importable.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface DropdownLinkType {
3131
isActive?: boolean;
3232
dataLinkName: string;
3333
notifications?: Notification[];
34+
onClick?: () => void;
3435
}
3536

3637
interface Props {
@@ -370,6 +371,7 @@ const DropdownLink = ({ link, index }: DropdownLinkProps) => {
370371
renderingTarget,
371372
);
372373
}
374+
link.onClick?.();
373375
}}
374376
>
375377
<div>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2+
import { centreColumnDecorator } from '../../.storybook/decorators/gridDecorators';
3+
import { allModes } from '../../.storybook/modes';
4+
import { exampleProduct } from '../../fixtures/manual/productBlockElement';
5+
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
6+
import { HorizontalSummaryProductCard } from './HorizontalSummaryProductCard';
7+
8+
const meta = {
9+
title: 'Components/Horizontal Summary Product Card',
10+
component: HorizontalSummaryProductCard,
11+
args: {
12+
product: { ...exampleProduct, h2Id: 'example-1' },
13+
format: {
14+
design: ArticleDesign.Standard,
15+
display: ArticleDisplay.Standard,
16+
theme: Pillar.Lifestyle,
17+
},
18+
},
19+
parameters: {
20+
chromatic: {
21+
modes: {
22+
'vertical mobile': allModes['vertical mobile'],
23+
'vertical wide': allModes['vertical wide'],
24+
},
25+
},
26+
},
27+
decorators: [centreColumnDecorator],
28+
} satisfies Meta<typeof HorizontalSummaryProductCard>;
29+
30+
export default meta;
31+
32+
type Story = StoryObj<typeof meta>;
33+
34+
export const Default = {} satisfies Story;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { css } from '@emotion/react';
2+
import {
3+
from,
4+
headlineBold20,
5+
headlineMedium17,
6+
space,
7+
textSansBold15,
8+
textSansBold17,
9+
} from '@guardian/source/foundations';
10+
import type { ArticleFormat } from '../lib/articleFormat';
11+
import { palette } from '../palette';
12+
import type { ProductBlockElement } from '../types/content';
13+
import { ProductCardImage } from './ProductCardImage';
14+
import { ProductLinkButton } from './ProductLinkButton';
15+
16+
const horizontalCard = css`
17+
position: relative;
18+
border-top: 1px solid ${palette('--section-border')};
19+
padding-top: ${space[2]}px;
20+
display: grid;
21+
grid-template-columns: 118px 1fr;
22+
grid-column-gap: 10px;
23+
grid-row-gap: ${space[2]}px;
24+
grid-template-areas:
25+
'image information'
26+
'button button';
27+
${from.phablet} {
28+
grid-template-areas: 'image information';
29+
}
30+
`;
31+
const imageContainer = css`
32+
img {
33+
width: 100%;
34+
height: auto;
35+
}
36+
grid-area: image;
37+
`;
38+
const informationContainer = css`
39+
display: flex;
40+
flex-direction: column;
41+
grid-area: information;
42+
`;
43+
44+
const buttonContainer = css`
45+
grid-area: button;
46+
${from.phablet} {
47+
grid-area: information;
48+
position: absolute;
49+
width: 220px;
50+
bottom: 0;
51+
right: 0;
52+
}
53+
`;
54+
55+
const readMore = css`
56+
${textSansBold15};
57+
text-decoration-line: underline;
58+
text-decoration-color: ${palette('--product-card-read-more-decoration')};
59+
color: ${palette('--product-card-read-more')};
60+
text-underline-offset: 20%;
61+
`;
62+
63+
const productCardHeading = css`
64+
${headlineBold20};
65+
color: ${palette('--product-card-headline')};
66+
`;
67+
68+
const secondaryHeading = css`
69+
${headlineMedium17};
70+
`;
71+
72+
const price = css`
73+
margin-top: auto;
74+
${textSansBold17};
75+
`;
76+
77+
export const HorizontalSummaryProductCard = ({
78+
product,
79+
format,
80+
}: {
81+
product: ProductBlockElement;
82+
format: ArticleFormat;
83+
}) => {
84+
const cardCta = product.productCtas[0];
85+
if (!cardCta) return null;
86+
87+
return (
88+
<div css={horizontalCard}>
89+
<div css={imageContainer}>
90+
<ProductCardImage
91+
format={format}
92+
image={product.image}
93+
url={cardCta.url}
94+
/>
95+
</div>
96+
<div css={informationContainer}>
97+
<div
98+
css={productCardHeading}
99+
dangerouslySetInnerHTML={{
100+
__html: product.primaryHeadingHtml,
101+
}}
102+
></div>
103+
<div css={secondaryHeading}>{product.secondaryHeadingHtml}</div>
104+
<a href={`#${product.h2Id}`} css={readMore}>
105+
Read more
106+
</a>
107+
<div css={price}>{cardCta.price}</div>
108+
</div>
109+
<div css={buttonContainer}>
110+
<ProductLinkButton
111+
size="small"
112+
fullwidth={true}
113+
minimisePadding={true}
114+
label={'Buy at ' + cardCta.retailer}
115+
url={cardCta.url}
116+
/>
117+
</div>
118+
</div>
119+
);
120+
};

dotcom-rendering/src/components/ManyNewsletterSignUp.importable.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717
reportTrackingEvent,
1818
requestMultipleSignUps,
1919
} from '../lib/newsletter-sign-up-requests';
20-
import { useIsSignedIn } from '../lib/useAuthStatus';
20+
import { clearSubscriptionCache } from '../lib/newsletterSubscriptionCache';
21+
import { useAuthStatus, useIsSignedIn } from '../lib/useAuthStatus';
2122
import { useConfig } from './ConfigContext';
2223
import { Flex } from './Flex';
2324
import { ManyNewslettersForm } from './ManyNewslettersForm';
@@ -131,6 +132,7 @@ export const ManyNewsletterSignUp = ({
131132
visibleRecaptcha = false,
132133
}: Props) => {
133134
const isSignedIn = useIsSignedIn();
135+
const authStatus = useAuthStatus();
134136

135137
const [newslettersToSignUpFor, setNewslettersToSignUpFor] = useState<
136138
{
@@ -297,6 +299,11 @@ export const ManyNewsletterSignUp = ({
297299
...(marketingOptIn !== undefined && { marketingOptInType }),
298300
},
299301
);
302+
303+
if (authStatus.kind === 'SignedIn') {
304+
clearSubscriptionCache();
305+
}
306+
300307
setStatus('Success');
301308
};
302309

dotcom-rendering/src/components/SecureSignup.importable.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import { useEffect, useRef, useState } from 'react';
2121
import ReactGoogleRecaptcha from 'react-google-recaptcha';
2222
import { submitComponentEvent } from '../client/ophan/ophan';
2323
import { lazyFetchEmailWithTimeout } from '../lib/fetchEmail';
24-
import { useIsSignedIn } from '../lib/useAuthStatus';
24+
import { clearSubscriptionCache } from '../lib/newsletterSubscriptionCache';
25+
import { useAuthStatus, useIsSignedIn } from '../lib/useAuthStatus';
2526
import { palette } from '../palette';
2627
import type { RenderingTarget } from '../types/renderingTarget';
2728
import { useConfig } from './ConfigContext';
@@ -286,6 +287,7 @@ export const SecureSignup = ({
286287
undefined,
287288
);
288289
const isSignedIn = useIsSignedIn();
290+
const authStatus = useAuthStatus();
289291

290292
useEffect(() => {
291293
if (isSignedIn !== 'Pending' && !isSignedIn) {
@@ -327,6 +329,10 @@ export const SecureSignup = ({
327329
setIsWaitingForResponse(false);
328330
setResponseOk(response.ok);
329331

332+
if (response.ok && authStatus.kind === 'SignedIn') {
333+
clearSubscriptionCache();
334+
}
335+
330336
sendTracking(
331337
newsletterId,
332338
response.ok ? 'submission-confirmed' : 'submission-failed',

dotcom-rendering/src/components/TopBar.importable.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Hide } from '@guardian/source/react-components';
1010
import { useEffect, useState } from 'react';
1111
import { addTrackingCodesToUrl } from '../lib/acquisitions';
1212
import type { EditionId } from '../lib/edition';
13+
import { clearSubscriptionCache } from '../lib/newsletterSubscriptionCache';
1314
import { nestedOphanComponents } from '../lib/ophan-helpers';
1415
import { useAuthStatus } from '../lib/useAuthStatus';
1516
import { usePageViewId } from '../lib/usePageViewId';
@@ -124,6 +125,12 @@ export const TopBar = ({
124125
setReferrerUrl(window.location.origin + window.location.pathname);
125126
}, []);
126127

128+
useEffect(() => {
129+
if (authStatus.kind === 'SignedOut') {
130+
clearSubscriptionCache();
131+
}
132+
}, [authStatus.kind]);
133+
127134
const printSubscriptionsHref = addTrackingCodesToUrl({
128135
base: `https://support.theguardian.com/subscribe${
129136
editionId === 'UK' ? '' : '/weekly'

dotcom-rendering/src/components/TopBarMyAccount.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react';
1414
import { getZIndex } from '../lib/getZIndex';
1515
import type { SignedIn } from '../lib/identity';
1616
import { createAuthenticationEventParams } from '../lib/identity-component-event';
17+
import { clearSubscriptionCache } from '../lib/newsletterSubscriptionCache';
1718
import {
1819
addNotificationsToDropdownLinks,
1920
mapBrazeCardsToNotifications,
@@ -131,6 +132,9 @@ export const buildIdentityLinks = (
131132

132133
return links.map((link) => ({
133134
...link,
135+
...(link.id === 'sign_out'
136+
? { onClick: () => clearSubscriptionCache() }
137+
: {}),
134138
dataLinkName: nestedOphanComponents(
135139
'header',
136140
'topbar',
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { storage } from '@guardian/libs';
2+
import type { SignedIn } from './identity';
3+
import { getOptionsHeaders } from './identity';
4+
5+
const CACHE_KEY = 'gu.newsletter.subscriptions';
6+
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
7+
8+
interface NewsletterSubscriptionCache {
9+
listIds: number[];
10+
timestamp: number;
11+
userId: string;
12+
}
13+
14+
export const getCachedSubscriptions =
15+
(): NewsletterSubscriptionCache | null => {
16+
try {
17+
const cached = storage.session.get(CACHE_KEY);
18+
if (!cached || typeof cached !== 'string') return null;
19+
20+
const parsed = JSON.parse(cached) as NewsletterSubscriptionCache;
21+
22+
const now = Date.now();
23+
if (now - parsed.timestamp > CACHE_DURATION_MS) {
24+
storage.session.remove(CACHE_KEY);
25+
return null;
26+
}
27+
28+
return parsed;
29+
} catch (error) {
30+
storage.session.remove(CACHE_KEY);
31+
return null;
32+
}
33+
};
34+
35+
export const setCachedSubscriptions = (
36+
listIds: number[],
37+
userId: string,
38+
): void => {
39+
try {
40+
const cache: NewsletterSubscriptionCache = {
41+
listIds,
42+
timestamp: Date.now(),
43+
userId,
44+
};
45+
storage.session.set(CACHE_KEY, JSON.stringify(cache));
46+
} catch (error) {
47+
// Silent failure - cache update is not critical
48+
}
49+
};
50+
51+
export const clearSubscriptionCache = (): void => {
52+
storage.session.remove(CACHE_KEY);
53+
};
54+
55+
export const shouldInvalidateCache = (
56+
cache: NewsletterSubscriptionCache,
57+
currentUserId?: string,
58+
): boolean => {
59+
if (!currentUserId || cache.userId !== currentUserId) {
60+
return true;
61+
}
62+
return false;
63+
};
64+
65+
export interface NewsletterSubscriptionResponse {
66+
result: {
67+
subscriptions: Array<{
68+
listId: string;
69+
}>;
70+
};
71+
}
72+
73+
/**
74+
* Fetches newsletter subscriptions from the Identity API and updates the cache.
75+
*/
76+
export const fetchNewsletterSubscriptions = async (
77+
idApiUrl: string,
78+
userId: string,
79+
authStatus: SignedIn,
80+
): Promise<number[] | null> => {
81+
try {
82+
const options = getOptionsHeaders(authStatus);
83+
const response = await fetch(`${idApiUrl}/users/me/newsletters`, {
84+
method: 'GET',
85+
credentials: 'include',
86+
...options,
87+
});
88+
89+
if (!response.ok) {
90+
return null;
91+
}
92+
93+
const data = (await response.json()) as NewsletterSubscriptionResponse;
94+
const newsletters = data.result.subscriptions;
95+
const listIds = newsletters.map((n) => Number(n.listId));
96+
97+
setCachedSubscriptions(listIds, userId);
98+
return listIds;
99+
} catch (error) {
100+
return null;
101+
}
102+
};

0 commit comments

Comments
 (0)