diff --git a/dotcom-rendering/fixtures/manual/productImage.ts b/dotcom-rendering/fixtures/manual/productImage.ts
new file mode 100644
index 00000000000..ce99b96cbe7
--- /dev/null
+++ b/dotcom-rendering/fixtures/manual/productImage.ts
@@ -0,0 +1,11 @@
+import type { ProductImage } from '../../src/types/content';
+
+export const productImage: ProductImage = {
+ url: 'https://media.guimcode.co.uk/cb193848ed75d40103eceaf12b448de2330770dc/0_0_725_725/725.jpg',
+ caption: 'Filter-2 test image for live demo',
+ height: 1,
+ width: 1,
+ alt: 'Bosch Sky kettle',
+ credit: 'Photograph: Rachel Ogden/The Guardian',
+ displayCredit: false,
+};
diff --git a/dotcom-rendering/src/components/ProductCardInline.stories.tsx b/dotcom-rendering/src/components/ProductCardInline.stories.tsx
index 26dccc6c6a2..da56fd375e0 100644
--- a/dotcom-rendering/src/components/ProductCardInline.stories.tsx
+++ b/dotcom-rendering/src/components/ProductCardInline.stories.tsx
@@ -1,20 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { centreColumnDecorator } from '../../.storybook/decorators/gridDecorators';
import { allModes } from '../../.storybook/modes';
+import { productImage } from '../../fixtures/manual/productImage';
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
-import type { ProductImage } from '../types/content';
import { ProductCardInline } from './ProductCardInline';
-const productImage: ProductImage = {
- url: 'https://media.guimcode.co.uk/cb193848ed75d40103eceaf12b448de2330770dc/0_0_725_725/725.jpg',
- caption: 'Filter-2 test image for live demo',
- height: 1,
- width: 1,
- alt: 'Bosch Sky kettle',
- credit: 'Photograph: Rachel Ogden/The Guardian',
- displayCredit: false,
-};
-
const meta = {
component: ProductCardInline,
title: 'Components/ProductCardInline',
diff --git a/dotcom-rendering/src/components/ProductCardLeftCol.stories.tsx b/dotcom-rendering/src/components/ProductCardLeftCol.stories.tsx
index ec6a20a1878..f32c1a10fef 100644
--- a/dotcom-rendering/src/components/ProductCardLeftCol.stories.tsx
+++ b/dotcom-rendering/src/components/ProductCardLeftCol.stories.tsx
@@ -1,8 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { allModes } from '../../.storybook/modes';
+import { productImage } from '../../fixtures/manual/productImage';
import type { ArticleFormat } from '../lib/articleFormat';
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
-import type { ProductImage } from '../types/content';
import { ProductCardLeftCol } from './ProductCardLeftCol';
const format: ArticleFormat = {
@@ -11,16 +11,6 @@ const format: ArticleFormat = {
theme: Pillar.Lifestyle,
};
-const productImage: ProductImage = {
- url: 'https://media.guimcode.co.uk/cb193848ed75d40103eceaf12b448de2330770dc/0_0_725_725/725.jpg',
- caption: 'Filter-2 test image for live demo',
- height: 1,
- width: 1,
- alt: 'Bosch Sky kettle',
- credit: 'Photograph: Rachel Ogden/The Guardian',
- displayCredit: false,
-};
-
const meta = {
component: ProductCardLeftCol,
title: 'Components/ProductCardLeftCol',
diff --git a/dotcom-rendering/src/components/ProductElement.stories.tsx b/dotcom-rendering/src/components/ProductElement.stories.tsx
new file mode 100644
index 00000000000..d99d4acdd77
--- /dev/null
+++ b/dotcom-rendering/src/components/ProductElement.stories.tsx
@@ -0,0 +1,389 @@
+import type { Meta, StoryObj } from '@storybook/react-webpack5';
+import { centreColumnDecorator } from '../../.storybook/decorators/gridDecorators';
+import { allModes } from '../../.storybook/modes';
+import { productImage } from '../../fixtures/manual/productImage';
+import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
+import { getNestedArticleElement } from '../lib/renderElement';
+import type { ProductBlockElement } from '../types/content';
+import { ProductElement } from './ProductElement';
+
+const ArticleElementComponent = getNestedArticleElement({
+ abTests: {},
+ ajaxUrl: '',
+ editionId: 'UK',
+ isAdFreeUser: false,
+ isSensitive: false,
+ pageId: 'testID',
+ switches: {},
+ webTitle: 'Storybook page',
+ shouldHideAds: false,
+});
+
+const product = {
+ _type: 'model.dotcomrendering.pageElements.ProductBlockElement',
+ elementId: 'b1f6e8e2-3f3a-4f0c-8d1e-5f3e3e3e3e3e',
+ primaryHeadingHtml: 'Best Kettle overall',
+ secondaryHeadingHtml: 'Bosch Sky Kettle',
+ brandName: 'Bosch',
+ productName: 'Sky Kettle',
+ image: productImage,
+ displayType: 'InlineWithProductCard',
+ lowestPrice: '£39.99',
+ customAttributes: [
+ { name: 'What we love', value: 'It pours well and looks great' },
+ {
+ name: "What we don't love",
+ value: 'The handle feels a bit cheap compared to the rest of it',
+ },
+ ],
+ productCtas: [
+ {
+ url: 'https://www.johnlewis.com/bosch-twk7203gb-sky-variable-temperature-kettle-1-7l-black/p3228625',
+ text: '',
+ retailer: 'John Lewis',
+ price: '£45.99',
+ },
+ {
+ url: 'https://www.amazon.co.uk/Bosch-TWK7203GB-Sky-Variable-Temperature/dp/B07Z8VQ2V6',
+ text: '',
+ retailer: 'Amazon',
+ price: '£39.99',
+ },
+ ],
+ starRating: 'none-selected',
+ content: [
+ {
+ displayCredit: true,
+ _type: 'model.dotcomrendering.pageElements.ImageBlockElement',
+ role: 'inline',
+ media: {
+ allImages: [
+ {
+ index: 0,
+ fields: {
+ height: '3213',
+ width: '3213',
+ },
+ mediaType: 'Image',
+ mimeType: 'image/jpeg',
+ url: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/3213.jpg',
+ },
+ {
+ index: 1,
+ fields: {
+ isMaster: 'true',
+ height: '3213',
+ width: '3213',
+ },
+ mediaType: 'Image',
+ mimeType: 'image/jpeg',
+ url: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ },
+ {
+ index: 2,
+ fields: {
+ height: '2000',
+ width: '2000',
+ },
+ mediaType: 'Image',
+ mimeType: 'image/jpeg',
+ url: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/2000.jpg',
+ },
+ {
+ index: 3,
+ fields: {
+ height: '1000',
+ width: '1000',
+ },
+ mediaType: 'Image',
+ mimeType: 'image/jpeg',
+ url: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/1000.jpg',
+ },
+ {
+ index: 4,
+ fields: {
+ height: '500',
+ width: '500',
+ },
+ mediaType: 'Image',
+ mimeType: 'image/jpeg',
+ url: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/500.jpg',
+ },
+ {
+ index: 5,
+ fields: {
+ height: '140',
+ width: '140',
+ },
+ mediaType: 'Image',
+ mimeType: 'image/jpeg',
+ url: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/140.jpg',
+ },
+ ],
+ },
+ elementId: '76686aa1-2e47-4616-b4ed-a88d0afe07ed',
+ imageSources: [
+ {
+ weighting: 'inline',
+ srcSet: [
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 620,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 1240,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 605,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 1210,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 445,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 890,
+ },
+ ],
+ },
+ {
+ weighting: 'thumbnail',
+ srcSet: [
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 140,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 280,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 120,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 240,
+ },
+ ],
+ },
+ {
+ weighting: 'supporting',
+ srcSet: [
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 380,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 760,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 300,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 600,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 620,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 1240,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 605,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 1210,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 445,
+ },
+ {
+ src: 'https://media.guimcode.co.uk/bacede2d976c26d6a224ede074d5634a3f1d304f/0_0_3213_3213/master/3213.jpg',
+ width: 890,
+ },
+ ],
+ },
+ ],
+ data: {
+ alt: 'Testing Bosch Sky Kettle',
+ credit: 'Photograph: Rachel Ogden/The Guardian',
+ },
+ },
+ {
+ _type: 'model.dotcomrendering.pageElements.LinkBlockElement',
+ url: 'https://www.johnlewis.com/bosch-twk7203gb-sky-variable-temperature-kettle-1-7l-black/p3228625',
+ label: '£79.99 at John Lewis',
+ linkType: 'ProductButton',
+ },
+ {
+ _type: 'model.dotcomrendering.pageElements.LinkBlockElement',
+ url: 'https://www.amazon.co.uk/Bosch-TWK7203GB-Sky-Variable-Temperature/dp/B07Z8VQ2V6',
+ label: '£79.99 at Amazon',
+ linkType: 'ProductButton',
+ },
+ {
+ _type: 'model.dotcomrendering.pageElements.TextBlockElement',
+ html: '
Offering variable temperatures and a double-walled stainless-steel housing, the 3kW Sky is a brilliant blend of robust form and function. It boasts a low minimum boil (300ml), a keep-warm setting and touch controls.
',
+ elementId: '4a27eb68-6a03-4e82-a7d0-e4f1ef3ccb6f',
+ },
+ {
+ _type: 'model.dotcomrendering.pageElements.TextBlockElement',
+ html: 'Why we love it
I found it difficult to select a best kettle from so many that performed well, but the Bosch Sky clinched it because it’s such a good all-rounder that will suit most people. It pours well, has a button that’s within easy reach of the handle so it’s simple to open the lid without touching it, and it’s insulated so the exterior doesn’t become too hot to touch. From a design perspective, it has a more industrial feel than many others – no frippery here – but not too modern that it wouldn’t fit into most kitchens. Its display is thoughtfully designed, easy to keep clean and lights up as it heats.
',
+ elementId: 'f48f03d4-bece-4763-874b-4027a311643e',
+ },
+ {
+ _type: 'model.dotcomrendering.pageElements.TextBlockElement',
+ html: 'The exterior doesn’t get too hot (up to 40C), and while it wasn’t the fastest to boil in testing, it was only seconds behind the Dualit below. It clicked off at boiling point, and the water was still a toasty 78C 30 minutes later. At the hour point, it was 66C, and two hours 52C, meaning you’ll spend less time and energy reboiling.
',
+ elementId: 'df571922-33b1-416a-8b3d-ee756b638cc1',
+ },
+ {
+ _type: 'model.dotcomrendering.pageElements.TextBlockElement',
+ html: 'It’s a shame that … its premium look ends at the handle, which seems cheap and plasticky next to the sleek aesthetic of the rest of it.
',
+ elementId: 'd98fc724-8908-46e2-acc6-4739ad4d5719',
+ },
+ ],
+} satisfies ProductBlockElement;
+
+const meta = {
+ component: ProductElement,
+ title: 'Components/ProductElement',
+ parameters: {
+ chromatic: {
+ modes: {
+ 'light mobile': allModes['light mobile'],
+ 'light desktop': allModes['light desktop'],
+ 'light wide': allModes['light wide'],
+ dark: allModes['dark'],
+ },
+ },
+ },
+ args: {
+ product,
+ format: {
+ design: ArticleDesign.Review,
+ display: ArticleDisplay.Standard,
+ theme: Pillar.Lifestyle,
+ },
+ ArticleElementComponent,
+ shouldShowLeftColCard: true,
+ },
+ decorators: [centreColumnDecorator],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default = {} satisfies Story;
+
+export const WithoutHeading = {
+ args: {
+ product: {
+ ...product,
+ primaryHeadingHtml: '',
+ secondaryHeadingHtml: '',
+ },
+ },
+} satisfies Story;
+
+export const DisplayCredit = {
+ args: {
+ product: {
+ ...product,
+ image: { ...productImage, displayCredit: true },
+ },
+ },
+} satisfies Story;
+
+export const NoSecondaryHeading = {
+ args: {
+ product: {
+ ...product,
+ primaryHeadingHtml: 'Primary heading only',
+ secondaryHeadingHtml: '',
+ },
+ },
+} satisfies Story;
+
+export const NoPrimaryHeading = {
+ args: {
+ product: {
+ ...product,
+ primaryHeadingHtml: '',
+ secondaryHeadingHtml: 'Secondary heading only',
+ },
+ },
+} satisfies Story;
+
+export const DisplayTypeProductCardOnly = {
+ args: {
+ product: {
+ ...product,
+ displayType: 'ProductCardOnly',
+ },
+ },
+} satisfies Story;
+
+export const DisplayTypeInlineOnly = {
+ args: {
+ product: {
+ ...product,
+ displayType: 'InlineOnly',
+ },
+ },
+} satisfies Story;
+
+export const MultipleProducts = {
+ render: (args) => (
+ <>
+
+
+ >
+ ),
+} satisfies Story;
+
+export const MultipleProductsWithoutStats = {
+ render: (args) => (
+ <>
+
+
+ >
+ ),
+} satisfies Story;
+
+export const EmptyFields = {
+ args: {
+ product: {
+ ...product,
+ image: undefined,
+ primaryHeadingHtml: '',
+ secondaryHeadingHtml: '',
+ brandName: '',
+ productName: '',
+ productCtas: [],
+ customAttributes: [],
+ lowestPrice: undefined,
+ },
+ },
+} satisfies Story;
diff --git a/dotcom-rendering/src/components/ProductElement.tsx b/dotcom-rendering/src/components/ProductElement.tsx
new file mode 100644
index 00000000000..66d22e8ffa3
--- /dev/null
+++ b/dotcom-rendering/src/components/ProductElement.tsx
@@ -0,0 +1,133 @@
+import { css } from '@emotion/react';
+import { from } from '@guardian/source/foundations';
+import type { ReactNode } from 'react';
+import type { ArticleFormat } from '../lib/articleFormat';
+import { parseHtml } from '../lib/domUtils';
+import type { NestedArticleElement } from '../lib/renderElement';
+import type { ProductBlockElement } from '../types/content';
+import { ProductCardInline } from './ProductCardInline';
+import { ProductCardLeftCol } from './ProductCardLeftCol';
+import { buildElementTree } from './SubheadingBlockComponent';
+
+const contentContainer = css`
+ position: relative;
+`;
+
+const LeftColProductCardContainer = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+export const ProductElement = ({
+ product,
+ ArticleElementComponent,
+ format,
+ shouldShowLeftColCard,
+}: {
+ product: ProductBlockElement;
+ ArticleElementComponent: NestedArticleElement;
+ format: ArticleFormat;
+ shouldShowLeftColCard: boolean;
+}) => {
+ const showContent =
+ product.displayType === 'InlineOnly' ||
+ product.displayType === 'InlineWithProductCard';
+ const showProductCard =
+ product.displayType === 'ProductCardOnly' ||
+ product.displayType === 'InlineWithProductCard';
+ return (
+ <>
+ {showContent && (
+
+ )}
+ {showProductCard && (
+
+ )}
+ >
+ );
+};
+
+const Content = ({
+ product,
+ ArticleElementComponent,
+ format,
+ shouldShowLeftColCard,
+}: {
+ product: ProductBlockElement;
+ ArticleElementComponent: NestedArticleElement;
+ format: ArticleFormat;
+ shouldShowLeftColCard: boolean;
+}) => {
+ const showLeftCol =
+ product.displayType === 'InlineWithProductCard' &&
+ shouldShowLeftColCard;
+ const subheadingHtml = parseHtml(
+ `${
+ product.primaryHeadingHtml
+ ? `${product.primaryHeadingHtml}
`
+ : ''
+ } ${product.secondaryHeadingHtml || ''}
`,
+ );
+
+ const isSubheading = subheadingHtml.textContent
+ ? subheadingHtml.textContent.trim().length > 0
+ : false;
+ return (
+
+ {isSubheading &&
+ Array.from(subheadingHtml.childNodes).map(
+ buildElementTree(format),
+ )}
+
+ {showLeftCol && (
+
+
+
+ )}
+ {product.content.map((element, index) => (
+
+ ))}
+
+
+ );
+};
diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json
index d78571aa9a7..d7360a7fe75 100644
--- a/dotcom-rendering/src/frontend/schemas/feArticle.json
+++ b/dotcom-rendering/src/frontend/schemas/feArticle.json
@@ -870,6 +870,9 @@
},
{
"$ref": "#/definitions/CrosswordElement"
+ },
+ {
+ "$ref": "#/definitions/ProductBlockElement"
}
]
},
@@ -4420,6 +4423,163 @@
"Record": {
"type": "object"
},
+ "ProductBlockElement": {
+ "type": "object",
+ "properties": {
+ "_type": {
+ "type": "string",
+ "const": "model.dotcomrendering.pageElements.ProductBlockElement"
+ },
+ "elementId": {
+ "type": "string"
+ },
+ "brandName": {
+ "type": "string"
+ },
+ "starRating": {
+ "$ref": "#/definitions/ProductStarRating"
+ },
+ "productName": {
+ "type": "string"
+ },
+ "image": {
+ "$ref": "#/definitions/ProductImage"
+ },
+ "secondaryHeadingHtml": {
+ "type": "string"
+ },
+ "primaryHeadingHtml": {
+ "type": "string"
+ },
+ "customAttributes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "value"
+ ]
+ }
+ },
+ "content": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/FEElement"
+ }
+ },
+ "h2Id": {
+ "type": "string"
+ },
+ "displayType": {
+ "$ref": "#/definitions/ProductDisplayType"
+ },
+ "productCtas": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "retailer": {
+ "type": "string"
+ },
+ "price": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "price",
+ "retailer",
+ "text",
+ "url"
+ ]
+ }
+ },
+ "lowestPrice": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "_type",
+ "brandName",
+ "content",
+ "customAttributes",
+ "displayType",
+ "elementId",
+ "primaryHeadingHtml",
+ "productCtas",
+ "productName",
+ "secondaryHeadingHtml",
+ "starRating"
+ ]
+ },
+ "ProductStarRating": {
+ "enum": [
+ "0",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "none-selected"
+ ],
+ "type": "string"
+ },
+ "ProductImage": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string"
+ },
+ "caption": {
+ "type": "string"
+ },
+ "credit": {
+ "type": "string"
+ },
+ "alt": {
+ "type": "string"
+ },
+ "displayCredit": {
+ "type": "boolean"
+ },
+ "height": {
+ "type": "number"
+ },
+ "width": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "alt",
+ "caption",
+ "credit",
+ "displayCredit",
+ "height",
+ "url",
+ "width"
+ ]
+ },
+ "ProductDisplayType": {
+ "enum": [
+ "InlineOnly",
+ "InlineWithProductCard",
+ "ProductCardOnly"
+ ],
+ "type": "string"
+ },
"Block": {
"type": "object",
"properties": {
diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx
index eaf02865a40..cdfa0bc458f 100644
--- a/dotcom-rendering/src/lib/renderElement.tsx
+++ b/dotcom-rendering/src/lib/renderElement.tsx
@@ -36,6 +36,7 @@ import { MultiBylines } from '../components/MultiBylines';
import { MultiImageBlockComponent } from '../components/MultiImageBlockComponent';
import { NumberedTitleBlockComponent } from '../components/NumberedTitleBlockComponent';
import { PersonalityQuizAtom } from '../components/PersonalityQuizAtom.importable';
+import { ProductElement } from '../components/ProductElement';
import { ProductLinkButton } from '../components/ProductLinkButton';
import { ProfileAtomWrapper } from '../components/ProfileAtomWrapper.importable';
import { PullQuoteBlockComponent } from '../components/PullQuoteBlockComponent';
@@ -122,11 +123,19 @@ const updateRole = (el: FEElement, format: ArticleFormat): FEElement => {
}
return el;
+ case 'model.dotcomrendering.pageElements.ProductBlockElement':
+ return {
+ ...el,
+ content: el.content.map((nestedElement) =>
+ 'role' in nestedElement
+ ? { ...nestedElement, role: 'inline' }
+ : nestedElement,
+ ),
+ };
default:
if (isBlog && 'role' in el) {
el.role = 'inline';
}
-
return el;
}
};
@@ -578,6 +587,28 @@ export const renderElement = ({
)}
>
);
+ case 'model.dotcomrendering.pageElements.ProductBlockElement':
+ return (
+
+ );
case 'model.dotcomrendering.pageElements.PullquoteBlockElement':
return (
": {
"type": "object"
},
+ "ProductBlockElement": {
+ "type": "object",
+ "properties": {
+ "_type": {
+ "type": "string",
+ "const": "model.dotcomrendering.pageElements.ProductBlockElement"
+ },
+ "elementId": {
+ "type": "string"
+ },
+ "brandName": {
+ "type": "string"
+ },
+ "starRating": {
+ "$ref": "#/definitions/ProductStarRating"
+ },
+ "productName": {
+ "type": "string"
+ },
+ "image": {
+ "$ref": "#/definitions/ProductImage"
+ },
+ "secondaryHeadingHtml": {
+ "type": "string"
+ },
+ "primaryHeadingHtml": {
+ "type": "string"
+ },
+ "customAttributes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "value"
+ ]
+ }
+ },
+ "content": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/FEElement"
+ }
+ },
+ "h2Id": {
+ "type": "string"
+ },
+ "displayType": {
+ "$ref": "#/definitions/ProductDisplayType"
+ },
+ "productCtas": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "retailer": {
+ "type": "string"
+ },
+ "price": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "price",
+ "retailer",
+ "text",
+ "url"
+ ]
+ }
+ },
+ "lowestPrice": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "_type",
+ "brandName",
+ "content",
+ "customAttributes",
+ "displayType",
+ "elementId",
+ "primaryHeadingHtml",
+ "productCtas",
+ "productName",
+ "secondaryHeadingHtml",
+ "starRating"
+ ]
+ },
+ "ProductStarRating": {
+ "enum": [
+ "0",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "none-selected"
+ ],
+ "type": "string"
+ },
+ "ProductImage": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string"
+ },
+ "caption": {
+ "type": "string"
+ },
+ "credit": {
+ "type": "string"
+ },
+ "alt": {
+ "type": "string"
+ },
+ "displayCredit": {
+ "type": "boolean"
+ },
+ "height": {
+ "type": "number"
+ },
+ "width": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "alt",
+ "caption",
+ "credit",
+ "displayCredit",
+ "height",
+ "url",
+ "width"
+ ]
+ },
+ "ProductDisplayType": {
+ "enum": [
+ "InlineOnly",
+ "InlineWithProductCard",
+ "ProductCardOnly"
+ ],
+ "type": "string"
+ },
"Attributes": {
"type": "object",
"properties": {
diff --git a/dotcom-rendering/src/model/enhance-H2s.test.ts b/dotcom-rendering/src/model/enhance-H2s.test.ts
index 39b6af03d58..5264ae4d5b8 100644
--- a/dotcom-rendering/src/model/enhance-H2s.test.ts
+++ b/dotcom-rendering/src/model/enhance-H2s.test.ts
@@ -1,6 +1,20 @@
-import type { FEElement } from '../types/content';
+import type { FEElement, ProductBlockElement } from '../types/content';
import { enhanceH2s } from './enhance-H2s';
+const mockProductElement: FEElement = {
+ _type: 'model.dotcomrendering.pageElements.ProductBlockElement',
+ elementId: 'productMockId',
+ primaryHeadingHtml: 'Primary Heading',
+ secondaryHeadingHtml: 'Secondary Heading',
+ content: [],
+ customAttributes: [],
+ productCtas: [],
+ brandName: 'Brand',
+ displayType: 'InlineOnly',
+ starRating: '5',
+ productName: 'Product Name',
+};
+
describe('Enhance h2 Embeds', () => {
it('sets an id when it is an h2 of type SubheadingBlockElement', () => {
const input: FEElement[] = [
@@ -179,4 +193,77 @@ describe('Enhance h2 Embeds', () => {
expect(enhanceH2s(input)).toEqual(expectedOutput);
});
+
+ it('should add H2 id to mockProductElement', () => {
+ const input = [mockProductElement];
+ const expectedOutput: ProductBlockElement[] = [
+ {
+ ...mockProductElement,
+ h2Id: 'primary-heading-secondary-heading',
+ },
+ ];
+
+ expect(enhanceH2s(input)).toEqual(expectedOutput);
+ });
+
+ it('should use elementId as h2Id when primary and secondary headings are empty', () => {
+ const input = [
+ {
+ ...mockProductElement,
+ primaryHeadingHtml: '',
+ secondaryHeadingHtml: '',
+ },
+ ];
+ const expectedOutput: ProductBlockElement[] = [
+ {
+ ...mockProductElement,
+ primaryHeadingHtml: '',
+ secondaryHeadingHtml: '',
+ h2Id: 'productMockId',
+ },
+ ];
+
+ expect(enhanceH2s(input)).toEqual(expectedOutput);
+ });
+
+ it('multiple productElements should have unique h2Ids', () => {
+ const input = [mockProductElement, mockProductElement];
+ const expectedOutput: ProductBlockElement[] = [
+ {
+ ...mockProductElement,
+ h2Id: 'primary-heading-secondary-heading',
+ },
+ {
+ ...mockProductElement,
+ h2Id: 'primary-heading-secondary-heading-1',
+ },
+ ];
+
+ expect(enhanceH2s(input)).toEqual(expectedOutput);
+ });
+
+ it('product element with a subheading element', () => {
+ const input: FEElement[] = [
+ mockProductElement,
+ {
+ _type: 'model.dotcomrendering.pageElements.SubheadingBlockElement',
+ elementId: 'mockId',
+ html: 'primary heading secondary heading
',
+ },
+ ];
+
+ const expectedOutput: FEElement[] = [
+ {
+ ...mockProductElement,
+ h2Id: 'primary-heading-secondary-heading',
+ },
+ {
+ _type: 'model.dotcomrendering.pageElements.SubheadingBlockElement',
+ elementId: 'mockId',
+ html: "primary heading secondary heading
",
+ },
+ ];
+
+ expect(enhanceH2s(input)).toEqual(expectedOutput);
+ });
});
diff --git a/dotcom-rendering/src/model/enhance-H2s.ts b/dotcom-rendering/src/model/enhance-H2s.ts
index f881c55d0b5..e8c727e04aa 100644
--- a/dotcom-rendering/src/model/enhance-H2s.ts
+++ b/dotcom-rendering/src/model/enhance-H2s.ts
@@ -1,5 +1,5 @@
import { JSDOM } from 'jsdom';
-import type { FEElement, SubheadingBlockElement } from '../types/content';
+import type { FEElement } from '../types/content';
import { isLegacyTableOfContents } from './isLegacyTableOfContents';
const shouldUseLegacyIDs = (elements: FEElement[]): boolean => {
@@ -11,8 +11,8 @@ const shouldUseLegacyIDs = (elements: FEElement[]): boolean => {
);
};
-const extractText = (element: SubheadingBlockElement): string => {
- const frag = JSDOM.fragment(element.html);
+const extractText = (html: string): string => {
+ const frag = JSDOM.fragment(html);
if (!frag.firstElementChild) return '';
return frag.textContent?.trim() ?? '';
};
@@ -47,11 +47,11 @@ export const slugify = (text: string): string => {
/**
* This function attempts to create a slugified string to use as the id. It fails over to elementId.
*/
-const generateId = (element: SubheadingBlockElement, existingIds: string[]) => {
- const text = extractText(element);
- if (!text) return element.elementId;
+const generateId = (elementId: string, html: string, existingIds: string[]) => {
+ const text = extractText(html);
+ if (!text) return elementId;
const slug = slugify(text);
- if (!slug) return element.elementId;
+ if (!slug) return elementId;
const unique = getUnique(slug, existingIds);
existingIds.push(slug);
return unique;
@@ -71,7 +71,7 @@ export const enhanceH2s = (elements: FEElement[]): FEElement[] => {
) {
const id = shouldUseElementId
? element.elementId
- : generateId(element, slugifiedIds);
+ : generateId(element.elementId, element.html, slugifiedIds);
const withId = element.html.replace(
'',
@@ -82,6 +82,20 @@ export const enhanceH2s = (elements: FEElement[]): FEElement[] => {
...element,
html: withId,
};
+ } else if (
+ element._type ===
+ 'model.dotcomrendering.pageElements.ProductBlockElement'
+ ) {
+ const subheadingHtml = `${
+ element.primaryHeadingHtml
+ ? `${element.primaryHeadingHtml}`
+ : ''
+ } ${element.secondaryHeadingHtml || ''}
`;
+
+ const h2Id = shouldUseElementId
+ ? element.elementId
+ : generateId(element.elementId, subheadingHtml, slugifiedIds);
+ return { ...element, h2Id };
} else {
// Otherwise, do nothing
return element;
diff --git a/dotcom-rendering/src/model/enhanceBlocks.ts b/dotcom-rendering/src/model/enhanceBlocks.ts
index 024daffd8db..efb473a1d4a 100644
--- a/dotcom-rendering/src/model/enhanceBlocks.ts
+++ b/dotcom-rendering/src/model/enhanceBlocks.ts
@@ -22,6 +22,7 @@ import { enhanceNumberedLists } from './enhance-numbered-lists';
import { enhanceTweets } from './enhance-tweets';
import { enhanceGuVideos } from './enhance-videos';
import { enhanceLists } from './enhanceLists';
+import { enhanceProductElement } from './enhanceProductElement';
import { enhanceTimeline } from './enhanceTimeline';
import { insertPromotedNewsletter } from './insertPromotedNewsletter';
@@ -68,6 +69,9 @@ export const enhanceElements =
options.tags,
),
enhanceTimeline(enhanceElements(format, blockId, options, true)),
+ enhanceProductElement(
+ enhanceElements(format, blockId, options, true),
+ ),
enhanceDividers,
enhanceH2s,
enhanceInteractiveContentsElements,
diff --git a/dotcom-rendering/src/model/enhanceProductElement.test.ts b/dotcom-rendering/src/model/enhanceProductElement.test.ts
new file mode 100644
index 00000000000..3eca867b47d
--- /dev/null
+++ b/dotcom-rendering/src/model/enhanceProductElement.test.ts
@@ -0,0 +1,170 @@
+// typescript
+import type { ArticleFormat } from '../lib/articleFormat';
+import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
+import type {
+ FEElement,
+ ProductBlockElement,
+ ProductImage,
+} from '../types/content';
+import { enhanceElements } from './enhanceBlocks';
+import { enhanceProductElement } from './enhanceProductElement';
+
+const productImage: ProductImage = {
+ url: 'https://media.guimcode.co.uk/cb193848ed75d40103eceaf12b448de2330770dc/0_0_725_725/725.jpg',
+ caption: 'Filter-2 test image for live demo',
+ height: 1,
+ width: 1,
+ alt: 'Bosch Sky kettle',
+ credit: 'Photograph: Rachel Ogden/The Guardian',
+ displayCredit: false,
+};
+
+const productBlockElement: ProductBlockElement = {
+ _type: 'model.dotcomrendering.pageElements.ProductBlockElement',
+ elementId: 'product-1',
+ brandName: 'Bosch',
+ starRating: '5',
+ productName: 'Sky Kettle',
+ image: productImage,
+ secondaryHeadingHtml: 'Best Kettle Overall',
+ primaryHeadingHtml: 'Bosch Sky Kettle',
+ customAttributes: [
+ { 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',
+ },
+ ],
+ content: [
+ {
+ _type: 'model.dotcomrendering.pageElements.SubheadingBlockElement',
+ elementId: 'mockId',
+ html: 'Unique Heading.
',
+ },
+ ],
+ displayType: 'InlineOnly',
+ productCtas: [
+ {
+ url: 'https://www.johnlewis.com/bosch-twk7203gb-sky-variable-temperature-kettle-1-7l-black/p3228625',
+ text: 'Buy now',
+ retailer: 'John Lewis',
+ price: '£79.99',
+ },
+ {
+ url: 'https://www.johnlewis.com/bosch-twk7203gb-sky-variable-temperature-kettle-1-7l-black/p3228625',
+ text: 'Buy now',
+ retailer: 'John Lewis',
+ price: '£29.99',
+ },
+ ],
+};
+
+const expectedEnhancedContent = [
+ {
+ _type: 'model.dotcomrendering.pageElements.SubheadingBlockElement',
+ elementId: 'mockId',
+ html: "Unique Heading.
",
+ },
+];
+
+const format: ArticleFormat = {
+ design: ArticleDesign.Standard,
+ display: ArticleDisplay.Standard,
+ theme: Pillar.Lifestyle,
+};
+
+const elementsEnhancer = enhanceElements(
+ format,
+ 'block-id',
+ {
+ renderingTarget: 'Web',
+ promotedNewsletter: undefined,
+ imagesForLightbox: [],
+ hasAffiliateLinksDisclaimer: false,
+ shouldHideAds: false,
+ },
+ true,
+);
+
+describe('enhanceProductBlockElements', () => {
+ const inputElements: FEElement[] = [productBlockElement];
+ const result = enhanceProductElement(elementsEnhancer)(inputElements);
+
+ expect(result).toHaveLength(1);
+
+ const enhancedElements = result[0];
+ if (
+ enhancedElements === undefined ||
+ enhancedElements._type !==
+ 'model.dotcomrendering.pageElements.ProductBlockElement'
+ ) {
+ throw new Error(
+ 'Expected "enhancedElements" to be a ProductBlockElement',
+ );
+ }
+
+ it('enhances the content of a ProductBlockElement', () => {
+ expect(enhancedElements.content).toEqual(expectedEnhancedContent);
+ });
+
+ it('adds the lowestPrice to a ProductBlockElement', () => {
+ expect(enhancedElements.lowestPrice).toEqual('£29.99');
+ });
+
+ it('returns undefined for lowestPrice if there are no productCTAs', () => {
+ const productBlockWithoutCtas: ProductBlockElement = {
+ ...productBlockElement,
+ productCtas: [],
+ };
+ const inputElementsNoCtas: FEElement[] = [productBlockWithoutCtas];
+ const resultNoCtas =
+ enhanceProductElement(elementsEnhancer)(inputElementsNoCtas);
+
+ expect(resultNoCtas).toHaveLength(1);
+
+ const enhancedElementNoCtas = resultNoCtas[0];
+ if (
+ enhancedElementNoCtas === undefined ||
+ enhancedElementNoCtas._type !==
+ 'model.dotcomrendering.pageElements.ProductBlockElement'
+ ) {
+ throw new Error(
+ 'Expected "enhancedElementNoCtas" to be a ProductBlockElement',
+ );
+ }
+
+ expect(enhancedElementNoCtas.lowestPrice).toBeUndefined();
+ });
+
+ it('expect lowest price to be £29.99 if one cta is NaN', () => {
+ const productBlockWithNaNCta: ProductBlockElement = {
+ ...productBlockElement,
+ productCtas: [
+ {
+ url: 'https://www.johnlewis.com/bosch-twk7203gb-sky-variable-temperature-kettle-1-7l-black/p3228625',
+ text: 'Buy now',
+ retailer: 'John Lewis',
+ price: '£88.882.22',
+ },
+ ...productBlockElement.productCtas,
+ ],
+ };
+ const inputElementsWithNaN: FEElement[] = [productBlockWithNaNCta];
+ const resultWithNaN =
+ enhanceProductElement(elementsEnhancer)(inputElementsWithNaN);
+ expect(resultWithNaN).toHaveLength(1);
+
+ const enhancedElementWithNaN = resultWithNaN[0];
+ if (
+ enhancedElementWithNaN === undefined ||
+ enhancedElementWithNaN._type !==
+ 'model.dotcomrendering.pageElements.ProductBlockElement'
+ ) {
+ throw new Error(
+ 'Expected "enhancedElementWithNaN" to be a ProductBlockElement',
+ );
+ }
+
+ expect(enhancedElementWithNaN.lowestPrice).toEqual('£29.99');
+ });
+});
diff --git a/dotcom-rendering/src/model/enhanceProductElement.ts b/dotcom-rendering/src/model/enhanceProductElement.ts
new file mode 100644
index 00000000000..9b46a9dc0e9
--- /dev/null
+++ b/dotcom-rendering/src/model/enhanceProductElement.ts
@@ -0,0 +1,74 @@
+import type {
+ FEElement,
+ ProductBlockElement,
+ ProductCta,
+} from '../types/content';
+
+type ElementsEnhancer = (elements: FEElement[]) => FEElement[];
+
+const enhanceProductBlockElement = (
+ element: ProductBlockElement,
+ elementsEnhancer: ElementsEnhancer,
+): ProductBlockElement => ({
+ ...element,
+ content: elementsEnhancer(element.content),
+ lowestPrice: getLowestPrice(element.productCtas),
+});
+
+/**
+ * Gets the lowest price from an array of product CTAs.
+ *
+ * Each CTA may contain a price in a localized string format (e.g. "£29.99", "$39.99").
+ * Prices are validated upstream in Flexible Content:
+ * https://github.com/guardian/flexible-content/blob/4e6097d3d23412432a9d8f50f2415a1ae622dc5b/composer/src/js/prosemirror-setup/elements/product/ProductSpec.tsx#L31
+ *
+ * Implementation details:
+ * - Extracts the floating-point number from the price string (e.g. "$26.99" → 26.99).
+ * - Removes commas to handle formatted prices like "1,299.99".
+ * - Converts the cleaned string to a number for comparison.
+ * - Skips any CTA where the parsed value is `NaN`.
+ *
+ * @param {ProductCta[]} ctas - Array of CTA objects containing price strings.
+ * @returns {string | undefined} The lowest price string, or `undefined` if no valid prices are found.
+ */
+const getLowestPrice = (ctas: ProductCta[]): string | undefined => {
+ if (ctas.length === 0) {
+ return undefined;
+ }
+
+ let lowestCta: ProductCta | null = null;
+ let lowestPrice: number | null = null;
+
+ for (const cta of ctas) {
+ const priceMatch = cta.price.match(/[\d,.]+/);
+ if (priceMatch) {
+ const priceNumber = parseFloat(priceMatch[0].replace(/,/g, ''));
+ if (Number.isNaN(priceNumber)) {
+ continue;
+ }
+ if (lowestPrice === null || priceNumber < lowestPrice) {
+ lowestPrice = priceNumber;
+ lowestCta = cta;
+ }
+ }
+ }
+
+ return lowestCta?.price;
+};
+
+const enhance =
+ (elementsEnhancer: ElementsEnhancer) =>
+ (element: FEElement): FEElement[] => {
+ if (
+ element._type ===
+ 'model.dotcomrendering.pageElements.ProductBlockElement'
+ ) {
+ return [enhanceProductBlockElement(element, elementsEnhancer)];
+ }
+ return [element];
+ };
+
+export const enhanceProductElement =
+ (elementsEnhancer: ElementsEnhancer) =>
+ (elements: FEElement[]): FEElement[] =>
+ elements.flatMap(enhance(elementsEnhancer));
diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts
index a59e1cb2bfa..37b0274f15c 100644
--- a/dotcom-rendering/src/types/content.ts
+++ b/dotcom-rendering/src/types/content.ts
@@ -469,6 +469,23 @@ export interface InteractiveContentsBlockElement {
endDocumentElementId?: string;
}
+export interface ProductBlockElement {
+ _type: 'model.dotcomrendering.pageElements.ProductBlockElement';
+ elementId: string;
+ brandName: string;
+ starRating: ProductStarRating;
+ productName: string;
+ image?: ProductImage;
+ secondaryHeadingHtml: string;
+ primaryHeadingHtml: string;
+ customAttributes: ProductCustomAttribute[];
+ content: FEElement[];
+ h2Id?: string;
+ displayType: ProductDisplayType;
+ productCtas: ProductCta[];
+ lowestPrice?: string;
+}
+
interface ProfileAtomBlockElement {
_type: 'model.dotcomrendering.pageElements.ProfileAtomBlockElement';
elementId: string;
@@ -833,7 +850,8 @@ export type FEElement =
| VineBlockElement
| YoutubeBlockElement
| WitnessTypeBlockElement
- | CrosswordElement;
+ | CrosswordElement
+ | ProductBlockElement;
// -------------------------------------
// Misc
@@ -865,6 +883,11 @@ export interface ImageSource {
srcSet: SrcSetItem[];
}
+export type ProductDisplayType =
+ | 'InlineOnly'
+ | 'ProductCardOnly'
+ | 'InlineWithProductCard';
+
export type ProductCta = {
url: string;
text: string;
@@ -877,6 +900,15 @@ export type ProductCustomAttribute = {
value: string;
};
+export type ProductStarRating =
+ | '0'
+ | '1'
+ | '2'
+ | '3'
+ | '4'
+ | '5'
+ | 'none-selected';
+
export interface SrcSetItem {
src: string;
width: number;