Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
793ac1c
Creating the Left Col product card
charleycampbell Aug 15, 2025
b291d25
Added InlineProductCard.tsx
oliverabrahams Aug 15, 2025
de2ccad
Some styling improvements
oliverabrahams Aug 15, 2025
90ad062
WIP added product Element
oliverabrahams Aug 18, 2025
12ce487
WIP added product Element
oliverabrahams Aug 18, 2025
4eacc77
Sort out headline
oliverabrahams Aug 18, 2025
43b0ba3
Merge branch 'main' into mob/inline-product-card
oliverabrahams Aug 18, 2025
82c797c
improve stories
oliverabrahams Aug 19, 2025
fdc624b
improve stories
oliverabrahams Aug 19, 2025
684a916
make gen-schema and remove rich link
oliverabrahams Aug 19, 2025
19c9bc3
add new palette colours
oliverabrahams Aug 20, 2025
a82a504
added second button to the inline product card
oliverabrahams Aug 20, 2025
cefbedf
added second button to the inline product card
oliverabrahams Aug 20, 2025
3dd52d4
added second button to the inline product card
oliverabrahams Aug 20, 2025
87ee500
remove display none from product card
oliverabrahams Aug 20, 2025
fa52508
adding new storybook for product cards without stats
charleycampbell Aug 20, 2025
cdb9572
if no stats
oliverabrahams Aug 20, 2025
c1d798c
improve story presentation using sectionComponent and ArticleContainer
oliverabrahams Aug 21, 2025
9b11353
improve story presentation
oliverabrahams Aug 21, 2025
9e41fe7
add a bold weighting to <strong> in the cards
oliverabrahams Aug 21, 2025
422e5c1
improvements
oliverabrahams Aug 21, 2025
adb8d4e
improvements
oliverabrahams Aug 22, 2025
4ef25ca
Merge branch 'main' into mob/inline-product-card
oliverabrahams Aug 27, 2025
1817190
Merge branch 'main' into mob/inline-product-card
oliverabrahams Sep 9, 2025
c002b28
add product element model to DCR
oliverabrahams Sep 22, 2025
c480b34
sort out roles for product element
oliverabrahams Sep 23, 2025
ada06a7
sort out roles for product element
oliverabrahams Sep 23, 2025
8240e4d
fix stories
oliverabrahams Sep 24, 2025
766ea33
add not showing headline
oliverabrahams Sep 24, 2025
45a61b4
working on image
oliverabrahams Sep 24, 2025
e831b54
add format to left col card
oliverabrahams Sep 26, 2025
b362e38
Merge branch 'main' into mob/inline-product-card
emma-imber Sep 30, 2025
0c6a2be
Generate new schemas
emma-imber Sep 30, 2025
16128e4
Merge branch 'main' into mob/inline-product-card
charleycampbell Oct 1, 2025
9c76474
Left col card changes:
charleycampbell Oct 2, 2025
de79f73
- minor tweaks to inline card so its more aligned with the figma
charleycampbell Oct 2, 2025
66a9599
aligining crd to image
charleycampbell Oct 2, 2025
daf6bc2
fixing sticky scroll
charleycampbell Oct 3, 2025
cc0dd39
primary and secondary headings sit one on top if the other
charleycampbell Oct 6, 2025
6e6f48f
story where now fields are rendered
charleycampbell Oct 6, 2025
583b174
Merge branch 'main' into mob/inline-product-card
charleycampbell Oct 6, 2025
6ace9c1
Add nested spacefinder role
emma-imber Oct 6, 2025
7f9991d
replacing my regex with a function already in DCR
charleycampbell Oct 7, 2025
860018d
Merge branch 'main' into mob/inline-product-card
charleycampbell Oct 7, 2025
a36dbbd
CTA wording '£X at name of retailer' replaced after confirmation from…
charleycampbell Oct 7, 2025
f0aea33
Fixing build by implicitly calling variables primaryUrl instead of url
charleycampbell Oct 7, 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
56 changes: 56 additions & 0 deletions dotcom-rendering/src/components/InlineProductCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { breakpoints } from '@guardian/source/foundations';
import type { Meta } from '@storybook/react';
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
import type { InlineProductCardProps } from './InlineProductCard';
import { InlineProductCard } from './InlineProductCard';

const meta = {
component: InlineProductCard,
title: 'Components/InlineProductCard',
parameters: {
chromatic: {
viewports: [
breakpoints.mobile,
breakpoints.mobileMedium,
breakpoints.desktop,
],
},

formats: [
{
design: ArticleDesign.Standard,
display: ArticleDisplay.Standard,
theme: Pillar.Lifestyle,
},
],
},
} satisfies Meta<typeof InlineProductCard>;

export default meta;

const sampleProductCard: InlineProductCardProps = {
format: {
design: ArticleDesign.Standard,
display: ArticleDisplay.Standard,
theme: Pillar.Lifestyle,
},
image: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg',
primaryUrl: 'https://www.aircraft.com/lume',
primaryCTA: 'Buy at AirCraft',
primaryPrice: '£199.99',
primaryRetailer: 'AirCraft',
secondaryCTA: '£199.99 at Amazon',
secondaryUrl:
'https://www.amazon.co.uk/AirCraft-Home-Backlight-Oscillating-Circulator/dp/B0D8QNGB3M',
brandName: 'AirCraft',
productName: 'Lume',
statistics: [
{ name: 'What we love', value: 'It packs away pretty small' },
{
name: 'What we don\t love',
value: 'there’s nowhere to stow the remote control',
},
],
};

export const Default = () => <InlineProductCard {...sampleProductCard} />;
196 changes: 196 additions & 0 deletions dotcom-rendering/src/components/InlineProductCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { css } from '@emotion/react';
import {
from,
headlineMedium20,
space,
textSans15,
textSans17,
} from '@guardian/source/foundations';
import type { ReactNode } from 'react';
import type { ArticleFormat } from '../lib/articleFormat';
import { palette } from '../palette';
import { Picture } from './Picture';
import { ProductLinkButton } from './ProductLinkButton';
import { stripHtmlFromString } from './TextBlockComponent';

export type Statistics = {
name: string;
value: string;
};

const card = css`
background-color: ${palette('--product-card-background')};
padding: ${space[2]}px ${space[3]}px ${space[3]}px ${space[3]}px;
column-gap: 10px;
display: grid;
max-width: 100%;
min-width: 100%;
row-gap: ${space[4]}px;
grid-template-columns: 1fr 1fr;
strong {
font-weight: 700;
}
img {
height: 165px;
width: 165px;
}
border-top: 1px solid ${palette('--section-border-lifestyle')};
${from.wide} {
display: none;
}
`;

export type InlineProductCardProps = {
format: ArticleFormat;
brandName: string;
productName: string;
image: string;
primaryCTA: string;
primaryUrl: string;
primaryPrice: string;
primaryRetailer: string;
secondaryCTA?: string;
secondaryUrl?: string;
statistics: Statistics[];
};

const productInfoContainer = css`
${textSans17};
display: flex;
flex-direction: column;
align-items: flex-start;
gap: ${space[2]}px;
`;

const primaryHeading = css`
${headlineMedium20};
`;

const statisticsContainer = css`
grid-column: span 2;
border-top: 1px solid ${palette('--section-border')};
display: grid;
grid-template-columns: 1fr;
gap: 2px;
`;

const Statistic = ({ name, value }: Statistics) => (
<div
css={css`
${textSans15};
margin-top: ${space[2]}px;
`}
>
<strong>{name}</strong> <br /> {value}
</div>
);

const ButtonContainer = ({ children }: { children: ReactNode }) => (
<div
css={css`
grid-column: span 2;
display: flex;
flex-direction: column;
gap: ${space[1]}px;
padding-bottom: ${space[2]}px;
`}
>
{children}
</div>
);

const RetailerLink = ({
primaryUrl,
primaryRetailer,
}: {
primaryUrl: string;
primaryRetailer: string;
}) => (
<a
css={css`
color: ${palette('--article-text')};
border-bottom: 1px solid ${palette('--article-link-border')};
text-decoration: none;
:hover,
:active {
border-bottom: 1px solid ${palette('--article-text')};
}
`}
href={primaryUrl}

Check warning

Code scanning / CodeQL

Client-side URL redirect Medium

Untrusted URL redirection depends on a
user-provided value
.
Untrusted URL redirection depends on a
user-provided value
.
Untrusted URL redirection depends on a
user-provided value
.
Untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI 1 day ago

To fix this client-side URL redirect vulnerability, the anchor tag and button must not use a raw URL provided by article data without validation. The safest and most common approach is to restrict URLs to a known set of allowed domains or relative paths (e.g., only links matching theguardian.com or subdomains thereof, or using protocol-relative/absolute paths beginning with /). We should introduce a helper function, e.g., isSafeUrl(url: string): boolean, that enforces this by regex-matching a whitelist of host/domains, or simply allows only relative URLs. Before passing any primaryUrl (and secondaryUrl, etc.) to href or similar, check using this function. If unsafe, fallback to a harmless value ('#' or undefined).

The changes affect:

  • dotcom-rendering/src/components/InlineProductCard.tsx (modify RetailerLink, ProductLinkButton usage, validation).
  • Add an isSafeUrl utility function (may be placed at the top or near the relevant code for now).
Suggested changeset 1
dotcom-rendering/src/components/InlineProductCard.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/dotcom-rendering/src/components/InlineProductCard.tsx b/dotcom-rendering/src/components/InlineProductCard.tsx
--- a/dotcom-rendering/src/components/InlineProductCard.tsx
+++ b/dotcom-rendering/src/components/InlineProductCard.tsx
@@ -13,6 +13,14 @@
 import { ProductLinkButton } from './ProductLinkButton';
 import { stripHtmlFromString } from './TextBlockComponent';
 
+// Only allow relative links (/...) or absolute links to trusted domains
+const isSafeUrl = (url: string): boolean => {
+	// allow only relative URLs and absolute URLs to theguardian.com and its subdomains
+	const guardianRegex = /^https?:\/\/([a-zA-Z0-9-]+\.)*theguardian\.com(\/|$)/i;
+	const relativeRegex = /^\/(?!\/)/;
+	return Boolean(url) && (guardianRegex.test(url) || relativeRegex.test(url));
+};
+
 export type Statistics = {
 	name: string;
 	value: string;
@@ -105,22 +113,25 @@
 }: {
 	primaryUrl: string;
 	primaryRetailer: string;
-}) => (
-	<a
-		css={css`
-			color: ${palette('--article-text')};
-			border-bottom: 1px solid ${palette('--article-link-border')};
-			text-decoration: none;
-			:hover,
-			:active {
-				border-bottom: 1px solid ${palette('--article-text')};
-			}
-		`}
-		href={primaryUrl}
-	>
-		{primaryRetailer}
-	</a>
-);
+}) => {
+	const safeUrl = isSafeUrl(primaryUrl) ? primaryUrl : '#';
+	return (
+		<a
+			css={css`
+				color: ${palette('--article-text')};
+				border-bottom: 1px solid ${palette('--article-link-border')};
+				text-decoration: none;
+				:hover,
+				:active {
+					border-bottom: 1px solid ${palette('--article-text')};
+				}
+			`}
+			href={safeUrl}
+		>
+			{primaryRetailer}
+		</a>
+	);
+};
 
 const ProductInfoContainer = ({ children }: { children: ReactNode }) => (
 	<div css={productInfoContainer}>{children}</div>
@@ -165,7 +176,7 @@
 		<ButtonContainer>
 			<ProductLinkButton
 				label={stripHtmlFromString(primaryCTA)}
-				url={primaryUrl}
+				url={isSafeUrl(primaryUrl) ? primaryUrl : '#'}
 				cssOverrides={css`
 					width: 100%;
 				`}
@@ -176,7 +187,7 @@
 						width: 100%;
 					`}
 					label={stripHtmlFromString(secondaryCTA)}
-					url={secondaryUrl}
+					url={isSafeUrl(secondaryUrl!) ? secondaryUrl : '#'}
 					priority={'tertiary'}
 				/>
 			)}
EOF
Copilot is powered by AI and may make mistakes. Always verify output.

Check failure

Code scanning / CodeQL

Client-side cross-site scripting High

Cross-site scripting vulnerability due to
user-provided value
.
Cross-site scripting vulnerability due to
user-provided value
.
Cross-site scripting vulnerability due to
user-provided value
.
Cross-site scripting vulnerability due to
user-provided value
.

Copilot Autofix

AI 1 day ago

To fix the problem, we must sanitize or validate the primaryUrl before using it as the href of the <a> tag in the RetailerLink component within InlineProductCard.tsx. The best way is to restrict primaryUrl to URLs with a safe scheme (http: or https:), and fallback to a safe default (e.g., #) or don't render the link if given an unsafe value.

Specifically:

  • In InlineProductCard.tsx, before passing primaryUrl to the anchor's href attribute, validate that it starts with http:// or https://.
  • If the check fails, replace it with a safe value such as "#", or do not render the link at all.
  • Implement a small utility function, such as isSafeUrl, within the file to perform this check.

No new imports are needed; the logic can be added directly in the file.


Suggested changeset 1
dotcom-rendering/src/components/InlineProductCard.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/dotcom-rendering/src/components/InlineProductCard.tsx b/dotcom-rendering/src/components/InlineProductCard.tsx
--- a/dotcom-rendering/src/components/InlineProductCard.tsx
+++ b/dotcom-rendering/src/components/InlineProductCard.tsx
@@ -99,28 +99,35 @@
 	</div>
 );
 
+// Utility to check if a URL is safe (http/https only)
+const isSafeUrl = (url: string): boolean =>
+	/^https?:\/\//i.test(url);
+
 const RetailerLink = ({
 	primaryUrl,
 	primaryRetailer,
 }: {
 	primaryUrl: string;
 	primaryRetailer: string;
-}) => (
-	<a
-		css={css`
-			color: ${palette('--article-text')};
-			border-bottom: 1px solid ${palette('--article-link-border')};
-			text-decoration: none;
-			:hover,
-			:active {
-				border-bottom: 1px solid ${palette('--article-text')};
-			}
-		`}
-		href={primaryUrl}
-	>
-		{primaryRetailer}
-	</a>
-);
+}) => {
+	const safeUrl = isSafeUrl(primaryUrl) ? primaryUrl : '#';
+	return (
+		<a
+			css={css`
+				color: ${palette('--article-text')};
+				border-bottom: 1px solid ${palette('--article-link-border')};
+				text-decoration: none;
+				:hover,
+				:active {
+					border-bottom: 1px solid ${palette('--article-text')};
+				}
+			`}
+			href={safeUrl}
+		>
+			{primaryRetailer}
+		</a>
+	);
+};
 
 const ProductInfoContainer = ({ children }: { children: ReactNode }) => (
 	<div css={productInfoContainer}>{children}</div>
EOF
@@ -99,28 +99,35 @@
</div>
);

// Utility to check if a URL is safe (http/https only)
const isSafeUrl = (url: string): boolean =>
/^https?:\/\//i.test(url);

const RetailerLink = ({
primaryUrl,
primaryRetailer,
}: {
primaryUrl: string;
primaryRetailer: string;
}) => (
<a
css={css`
color: ${palette('--article-text')};
border-bottom: 1px solid ${palette('--article-link-border')};
text-decoration: none;
:hover,
:active {
border-bottom: 1px solid ${palette('--article-text')};
}
`}
href={primaryUrl}
>
{primaryRetailer}
</a>
);
}) => {
const safeUrl = isSafeUrl(primaryUrl) ? primaryUrl : '#';
return (
<a
css={css`
color: ${palette('--article-text')};
border-bottom: 1px solid ${palette('--article-link-border')};
text-decoration: none;
:hover,
:active {
border-bottom: 1px solid ${palette('--article-text')};
}
`}
href={safeUrl}
>
{primaryRetailer}
</a>
);
};

const ProductInfoContainer = ({ children }: { children: ReactNode }) => (
<div css={productInfoContainer}>{children}</div>
Copilot is powered by AI and may make mistakes. Always verify output.
>
{primaryRetailer}
</a>
);

const ProductInfoContainer = ({ children }: { children: ReactNode }) => (
<div css={productInfoContainer}>{children}</div>
);

export const InlineProductCard = ({
format,
brandName,
productName,
image,
primaryCTA,
primaryUrl,
primaryPrice,
primaryRetailer,
secondaryCTA,
secondaryUrl,
statistics,
}: InlineProductCardProps) => (
<div css={card}>
{!!image && (
<Picture
role={'productCard'}
format={format}
master={image}
alt={productName + brandName}
height={165}
width={165}
loading={'eager'}
/>
)}
<ProductInfoContainer>
<div css={primaryHeading}>{brandName}</div>
<div>{productName}</div>
<div>
<strong>{primaryPrice}</strong> at{' '}
<RetailerLink
primaryUrl={primaryUrl}
primaryRetailer={primaryRetailer}
/>
</div>
</ProductInfoContainer>
<ButtonContainer>
<ProductLinkButton
label={stripHtmlFromString(primaryCTA)}
url={primaryUrl}
cssOverrides={css`
width: 100%;
`}
/>
{!!secondaryCTA && !!secondaryUrl && (
<ProductLinkButton
cssOverrides={css`
width: 100%;
`}
label={stripHtmlFromString(secondaryCTA)}
url={secondaryUrl}
priority={'tertiary'}
/>
)}
</ButtonContainer>
{statistics.length > 0 && (
<div css={statisticsContainer}>
{statistics.map((statistic) => (
<Statistic
key={statistic.name}
name={statistic.name}
value={statistic.value}
/>
))}
</div>
)}
</div>
);
51 changes: 51 additions & 0 deletions dotcom-rendering/src/components/LeftColProductCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Meta } from '@storybook/react';
import type { ArticleFormat } from '../lib/articleFormat';
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
import type { LeftColProductCardProps } from './LeftColProductCard';
import { LeftColProductCard } from './LeftColProductCard';

const format: ArticleFormat = {
design: ArticleDesign.Standard,
display: ArticleDisplay.Standard,
theme: Pillar.Lifestyle,
};
const meta = {
component: LeftColProductCard,
title: 'Components/LeftColProductCard',
parameters: {
layout: 'padded',
formats: [
{
design: ArticleDesign.Standard,
display: ArticleDesign.Standard,
theme: Pillar.Lifestyle,
},
],
},
} satisfies Meta<typeof LeftColProductCard>;

export default meta;

const sampleProductCard: LeftColProductCardProps = {
format,
brandName: 'AirCraft',
productName: 'Lume',
image: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg',
primaryCta: 'Buy at AirCraft',
primaryUrl: 'https://www.aircraft.com/lume',
primaryPrice: '£199.99',
primaryRetailer: 'AirCraft',
statistics: [
{ name: 'What we love', value: 'It packs away pretty small' },
{
name: "What we don't love",
value: 'there’s nowhere to stow the remote control',
},
],
};

export const Default = () => <LeftColProductCard {...sampleProductCard} />;

export const WithNoStatistics = () => (
<LeftColProductCard {...sampleProductCard} statistics={[]} />
);
Loading
Loading