diff --git a/dotcom-rendering/src/components/ScrollableProduct.importable.tsx b/dotcom-rendering/src/components/ScrollableProduct.importable.tsx
index 884f7878c67..a3c544fe3de 100644
--- a/dotcom-rendering/src/components/ScrollableProduct.importable.tsx
+++ b/dotcom-rendering/src/components/ScrollableProduct.importable.tsx
@@ -318,7 +318,7 @@ export const ScrollableProduct = ({ products, format }: Props) => {
-
{
/>
))}
-
+
);
+ case 'model.dotcomrendering.pageElements.ProductCarouselElement':
+ return (
+
+
+
+ );
case 'model.dotcomrendering.pageElements.AudioBlockElement':
case 'model.dotcomrendering.pageElements.ContentAtomBlockElement':
case 'model.dotcomrendering.pageElements.GenericAtomBlockElement':
diff --git a/dotcom-rendering/src/model/block-schema.json b/dotcom-rendering/src/model/block-schema.json
index 3ce096f8990..d12b642f0e8 100644
--- a/dotcom-rendering/src/model/block-schema.json
+++ b/dotcom-rendering/src/model/block-schema.json
@@ -361,6 +361,9 @@
},
{
"$ref": "#/definitions/ProductBlockElement"
+ },
+ {
+ "$ref": "#/definitions/ProductCarouselElement"
}
]
},
@@ -4101,6 +4104,25 @@
],
"type": "string"
},
+ "ProductCarouselElement": {
+ "type": "object",
+ "properties": {
+ "_type": {
+ "type": "string",
+ "const": "model.dotcomrendering.pageElements.ProductCarouselElement"
+ },
+ "matchedProducts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/ProductBlockElement"
+ }
+ }
+ },
+ "required": [
+ "_type",
+ "matchedProducts"
+ ]
+ },
"Attributes": {
"type": "object",
"properties": {
diff --git a/dotcom-rendering/src/model/enhance-H2s.ts b/dotcom-rendering/src/model/enhance-H2s.ts
index e8c727e04aa..72ee2e0699a 100644
--- a/dotcom-rendering/src/model/enhance-H2s.ts
+++ b/dotcom-rendering/src/model/enhance-H2s.ts
@@ -47,7 +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 = (elementId: string, html: string, existingIds: string[]) => {
+export const generateId = (
+ elementId: string,
+ html: string,
+ existingIds: string[],
+): string => {
const text = extractText(html);
if (!text) return elementId;
const slug = slugify(text);
diff --git a/dotcom-rendering/src/model/enhance-product-carousel.test-helpers.ts b/dotcom-rendering/src/model/enhance-product-carousel.test-helpers.ts
new file mode 100644
index 00000000000..1c2260a6fb9
--- /dev/null
+++ b/dotcom-rendering/src/model/enhance-product-carousel.test-helpers.ts
@@ -0,0 +1,49 @@
+import type { FEElement, ProductBlockElement } from '../types/content';
+
+export const linkElement = (url: string, label: string): FEElement =>
+ ({
+ _type: 'model.dotcomrendering.pageElements.LinkBlockElement',
+ url,
+ label,
+ }) as FEElement;
+
+export const productElement = (urls: string[]): ProductBlockElement =>
+ ({
+ _type: 'model.dotcomrendering.pageElements.ProductBlockElement',
+ productCtas: urls.map((url) => ({ url })),
+ }) as ProductBlockElement;
+
+export const atAGlanceHeading = (): FEElement =>
+ ({
+ _type: 'model.dotcomrendering.pageElements.SubheadingBlockElement',
+ text: 'At a glance',
+ html: 'At a glance',
+ elementId: 'at-a-glance',
+ }) as FEElement;
+
+export const dividerElement = (): FEElement =>
+ ({
+ _type: 'model.dotcomrendering.pageElements.DividerBlockElement',
+ elementId: 'divider',
+ }) as FEElement;
+
+export const textElement = (html: string): FEElement =>
+ ({
+ _type: 'model.dotcomrendering.pageElements.TextBlockElement',
+ html,
+ elementId: '4',
+ }) as FEElement;
+
+export type ProductCarouselTestElement = FEElement & {
+ _type: 'model.dotcomrendering.pageElements.ProductCarouselElement';
+ matchedProducts: ProductBlockElement[];
+};
+
+export const findCarousel = (
+ elements: FEElement[],
+): ProductCarouselTestElement | undefined =>
+ elements.find(
+ (el): el is ProductCarouselTestElement =>
+ el._type ===
+ 'model.dotcomrendering.pageElements.ProductCarouselElement',
+ );
diff --git a/dotcom-rendering/src/model/enhance-product-carousel.test.ts b/dotcom-rendering/src/model/enhance-product-carousel.test.ts
new file mode 100644
index 00000000000..4ad6c5a28d5
--- /dev/null
+++ b/dotcom-rendering/src/model/enhance-product-carousel.test.ts
@@ -0,0 +1,230 @@
+import { _testOnly, enhanceProductCarousel } from './enhance-product-carousel';
+import {
+ atAGlanceHeading,
+ dividerElement,
+ findCarousel,
+ linkElement,
+ productElement,
+ textElement,
+} from './enhance-product-carousel.test-helpers';
+
+const {
+ extractAtAGlanceUrls,
+ findMatchingProducts,
+ insertCarouselPlaceholder,
+} = _testOnly;
+
+describe('extractAtAGlanceUrls', () => {
+ it('returns only URLs from LinkBlockElements', () => {
+ const elements = [
+ linkElement(
+ 'https://shop.tefal.co.uk/easy-fry-dual-xxl-ey942bg0-air-fryer-java-pepper-11l',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef',
+ 'Buy now',
+ ),
+ textElement('just text with no url'),
+ ];
+
+ expect(extractAtAGlanceUrls(elements)).toEqual([
+ 'https://shop.tefal.co.uk/easy-fry-dual-xxl-ey942bg0-air-fryer-java-pepper-11l',
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef',
+ ]);
+ });
+});
+
+describe('findMatchingProducts', () => {
+ it('finds products with matching CTA URLs', () => {
+ const products = [
+ productElement([
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef/',
+ ]),
+ productElement([
+ 'https://www.procook.co.uk/product/procook-12-in-1-air-fryer-grill-black',
+ ]),
+ productElement([
+ 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK',
+ ]),
+ productElement([
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ ]),
+ ];
+
+ const result = findMatchingProducts(products, [
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef/',
+ 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK',
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ ]);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('returns an empty array if no product CTA URLs match', () => {
+ const products = [
+ productElement([
+ 'https://ao.com/product/ec230bk-delonghi-stilosa-traditional-pump-espresso-coffee-machine-black-79705-66.aspx',
+ ]),
+ productElement([
+ 'https://petertysonelectricals.co.uk/delonghi-ecam290-83-tb-magnifica-evo-fully-automatic-bean-to-cup-machine-titanium-black',
+ ]),
+ ];
+
+ const result = findMatchingProducts(products, [
+ 'https://notfound.com/product-x',
+ 'https://another.com/product-y',
+ ]);
+
+ expect(result).toEqual([]);
+ });
+});
+
+describe('insertCarouselPlaceholder', () => {
+ it('inserts a ProductCarouselElement after the At a glance section', () => {
+ const input = [
+ atAGlanceHeading(),
+ linkElement(
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK',
+ 'Buy now',
+ ),
+ dividerElement(),
+ productElement([
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef',
+ ]),
+ productElement([
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ ]),
+ productElement([
+ 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK',
+ ]),
+ ];
+
+ const output = insertCarouselPlaceholder(input);
+
+ const carousel = findCarousel(output);
+ expect(carousel).toBeDefined();
+ });
+
+ it('does nothing when no At a glance section is present', () => {
+ const input = [
+ linkElement(
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ 'Buy now',
+ ),
+ productElement([
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ ]),
+ ];
+
+ const output = insertCarouselPlaceholder(input);
+
+ const carousel = findCarousel(output);
+ expect(carousel).toBeUndefined();
+ });
+});
+
+describe('insertCarouselPlaceholder – edge cases', () => {
+ it('does not insert a carousel when fewer than three products match', () => {
+ const input = [
+ atAGlanceHeading(),
+ linkElement(
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK',
+ 'Buy now',
+ ),
+ dividerElement(),
+ productElement([
+ 'https://casodesign.co.uk/product/caso-design-airfry-duo-chef',
+ ]),
+ ];
+
+ const output = insertCarouselPlaceholder(input);
+ const carousel = findCarousel(output);
+ expect(carousel).toBeUndefined();
+ });
+
+ it('does not insert a carousel if At a glance section has no LinkBlockElements', () => {
+ const input = [
+ atAGlanceHeading(),
+ textElement('No links here'),
+ dividerElement(),
+ productElement([
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ ]),
+ ];
+
+ const output = insertCarouselPlaceholder(input);
+
+ const carousel = findCarousel(output);
+ expect(carousel).toBeUndefined();
+ });
+
+ it('returns an empty array for empty input', () => {
+ expect(insertCarouselPlaceholder([])).toEqual([]);
+ });
+});
+
+describe('enhanceProductCarousel', () => {
+ beforeAll(() => {
+ _testOnly.allowedPageIds.push(
+ 'thefilter/test-article-example-for-product-carousel',
+ );
+ });
+
+ it('enhances elements with a product carousel for allowlisted pages', () => {
+ const allowedPageId =
+ 'thefilter/test-article-example-for-product-carousel';
+
+ const input = [
+ atAGlanceHeading(),
+ linkElement(
+ 'https://www.homebase.co.uk/en-uk/tower-airx-t17166-5l-grey-single-basket-air-fryer-digital-air-fryer/p/0757395',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ 'Buy now',
+ ),
+ linkElement(
+ 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK',
+ 'Buy now',
+ ),
+ dividerElement(),
+ productElement([
+ 'https://www.homebase.co.uk/en-uk/tower-airx-t17166-5l-grey-single-basket-air-fryer-digital-air-fryer/p/0757395',
+ ]),
+ productElement([
+ 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l',
+ ]),
+ productElement([
+ 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK',
+ ]),
+ ];
+
+ const output = enhanceProductCarousel(allowedPageId)(input);
+
+ const carousel = findCarousel(output);
+ expect(carousel).toBeDefined();
+ });
+});
diff --git a/dotcom-rendering/src/model/enhance-product-carousel.ts b/dotcom-rendering/src/model/enhance-product-carousel.ts
new file mode 100644
index 00000000000..4b260c6a595
--- /dev/null
+++ b/dotcom-rendering/src/model/enhance-product-carousel.ts
@@ -0,0 +1,121 @@
+import type { FEElement, ProductBlockElement } from '../types/content';
+import { generateId } from './enhance-H2s';
+
+/*List of page IDs eligible for product carousel enhancement.
+For example thefilter/2025/jan/29/best-sunrise-alarm-clocks
+Update list with actual article URLs as needed.*/
+
+export const allowedPageIds: string[] = [];
+
+const isEligibleForCarousel = (pageId: string) =>
+ allowedPageIds.includes(pageId);
+
+// Extract URLs from 'At a glance' section elements
+export const extractAtAGlanceUrls = (elements: FEElement[]): string[] =>
+ elements
+ .filter(
+ (el) =>
+ el._type ===
+ 'model.dotcomrendering.pageElements.LinkBlockElement',
+ )
+ .map((el) => el.url);
+
+// Find product elements which have a matching URL in their CTAs
+const findMatchingProducts = (
+ pageElements: FEElement[],
+ urls: string[],
+): ProductBlockElement[] =>
+ pageElements
+ .filter(
+ (el) =>
+ el._type ===
+ 'model.dotcomrendering.pageElements.ProductBlockElement',
+ )
+ .filter((el) => el.productCtas.some((cta) => urls.includes(cta.url)));
+
+// Only insert the carousel in this one specific spot
+const isAtAGlance = (element: FEElement) =>
+ element._type ===
+ 'model.dotcomrendering.pageElements.SubheadingBlockElement' &&
+ generateId(element.elementId, element.html, []) === 'at-a-glance';
+
+const isSubheadingOrDivider = (element: FEElement) =>
+ // if an element is one of these then we're likely leaving the 'At a glance' section
+ element._type ===
+ 'model.dotcomrendering.pageElements.SubheadingBlockElement' ||
+ element._type === 'model.dotcomrendering.pageElements.DividerBlockElement';
+
+const getAtAGlanceUrls = (elements: FEElement[]): string[] =>
+ Array.from(
+ new Set(
+ extractAtAGlanceUrls(elements).filter((url) => url.trim() !== ''),
+ ),
+ );
+
+const shouldRenderCarousel = (products: ProductBlockElement[]): boolean =>
+ products.length >= 3;
+
+const insertCarouselPlaceholder = (elements: FEElement[]): FEElement[] => {
+ const output: FEElement[] = [];
+ let inAtAGlanceSection = false;
+ let atAGlanceElements: FEElement[] = [];
+
+ // Loop through elements tracking "At a glance" section and inserting carousel if needed
+ for (const element of elements) {
+ if (!inAtAGlanceSection) {
+ if (isAtAGlance(element)) {
+ inAtAGlanceSection = true;
+ atAGlanceElements = [element];
+ continue;
+ }
+
+ output.push(element);
+ continue;
+ }
+
+ // We are inside an "At a glance" section
+ if (isSubheadingOrDivider(element)) {
+ inAtAGlanceSection = false;
+
+ const urls = getAtAGlanceUrls(atAGlanceElements);
+ const matchedProducts = findMatchingProducts(elements, urls);
+
+ if (shouldRenderCarousel(matchedProducts)) {
+ output.push({
+ _type: 'model.dotcomrendering.pageElements.ProductCarouselElement',
+ matchedProducts,
+ } as FEElement);
+ } else {
+ // Less than two products matched, so return original elements
+ output.push(...atAGlanceElements);
+ }
+
+ output.push(element);
+ atAGlanceElements = [];
+ continue;
+ }
+
+ atAGlanceElements.push(element);
+ }
+
+ return output;
+};
+
+export const enhanceProductCarousel =
+ (pageId: string) =>
+ (elements: FEElement[]): FEElement[] => {
+ // do nothing if article is not on allow list
+ if (isEligibleForCarousel(pageId)) {
+ return insertCarouselPlaceholder(elements);
+ }
+
+ return elements;
+ };
+
+// Exports are for testing purposes only
+export const _testOnly = {
+ extractAtAGlanceUrls,
+ findMatchingProducts,
+ insertCarouselPlaceholder,
+ allowedPageIds,
+};
diff --git a/dotcom-rendering/src/model/enhanceBlocks.ts b/dotcom-rendering/src/model/enhanceBlocks.ts
index 91161f640be..1a685409b7f 100644
--- a/dotcom-rendering/src/model/enhanceBlocks.ts
+++ b/dotcom-rendering/src/model/enhanceBlocks.ts
@@ -19,6 +19,7 @@ import { enhanceH2s } from './enhance-H2s';
import { enhanceElementsImages, enhanceImages } from './enhance-images';
import { enhanceInteractiveContentsElements } from './enhance-interactive-contents-elements';
import { enhanceNumberedLists } from './enhance-numbered-lists';
+import { enhanceProductCarousel } from './enhance-product-carousel';
import { enhanceTweets } from './enhance-tweets';
import { enhanceGuVideos } from './enhance-videos';
import { enhanceLists } from './enhanceLists';
@@ -34,6 +35,7 @@ type Options = {
audioArticleImage?: ImageBlockElement;
tags?: TagType[];
shouldHideAds: boolean;
+ pageId: string;
};
const enhanceNewsletterSignup =
@@ -94,6 +96,7 @@ export const enhanceElements =
options.shouldHideAds,
),
enhanceDisclaimer(options.hasAffiliateLinksDisclaimer, isNested),
+ enhanceProductCarousel(options.pageId),
].reduce(
(enhancedBlocks, enhancer) => enhancer(enhancedBlocks),
elements,
diff --git a/dotcom-rendering/src/model/enhanceProductElement.test.ts b/dotcom-rendering/src/model/enhanceProductElement.test.ts
index 0d6a8250dd5..ffa29a1af7c 100644
--- a/dotcom-rendering/src/model/enhanceProductElement.test.ts
+++ b/dotcom-rendering/src/model/enhanceProductElement.test.ts
@@ -81,6 +81,7 @@ const elementsEnhancer = enhanceElements(
imagesForLightbox: [],
hasAffiliateLinksDisclaimer: false,
shouldHideAds: false,
+ pageId: '',
},
true,
);
diff --git a/dotcom-rendering/src/model/pinnedPost.ts b/dotcom-rendering/src/model/pinnedPost.ts
index d71496f2270..1ad205ef29f 100644
--- a/dotcom-rendering/src/model/pinnedPost.ts
+++ b/dotcom-rendering/src/model/pinnedPost.ts
@@ -7,6 +7,7 @@ export const enhancePinnedPost = (
format: ArticleFormat,
renderingTarget: RenderingTarget,
pinnedPost: Block | undefined,
+ pageId: string,
): Block | undefined => {
if (pinnedPost === undefined) {
return undefined;
@@ -18,5 +19,6 @@ export const enhancePinnedPost = (
promotedNewsletter: undefined,
hasAffiliateLinksDisclaimer: false,
shouldHideAds: false,
+ pageId,
})[0];
};
diff --git a/dotcom-rendering/src/server/handler.article.apps.ts b/dotcom-rendering/src/server/handler.article.apps.ts
index c1b1e98d418..199ffae2529 100644
--- a/dotcom-rendering/src/server/handler.article.apps.ts
+++ b/dotcom-rendering/src/server/handler.article.apps.ts
@@ -54,6 +54,7 @@ export const handleAppsBlocks: RequestHandler = ({ body }, res) => {
imagesForLightbox: [],
hasAffiliateLinksDisclaimer: false,
shouldHideAds,
+ pageId,
});
const html = renderAppsBlocks({
blocks: enhancedBlocks,
diff --git a/dotcom-rendering/src/server/handler.article.web.ts b/dotcom-rendering/src/server/handler.article.web.ts
index 8be60e2d738..8b36610a107 100644
--- a/dotcom-rendering/src/server/handler.article.web.ts
+++ b/dotcom-rendering/src/server/handler.article.web.ts
@@ -57,6 +57,7 @@ export const handleBlocks: RequestHandler = ({ body }, res) => {
imagesForLightbox: [],
hasAffiliateLinksDisclaimer: false,
shouldHideAds,
+ pageId,
});
const html = renderBlocks({
blocks: enhancedBlocks,
diff --git a/dotcom-rendering/src/types/article.ts b/dotcom-rendering/src/types/article.ts
index 47a0b002fed..75703c02764 100644
--- a/dotcom-rendering/src/types/article.ts
+++ b/dotcom-rendering/src/types/article.ts
@@ -103,6 +103,7 @@ export const enhanceArticleType = (
audioArticleImage: data.audioArticleImage,
tags: data.tags,
shouldHideAds: data.shouldHideAds,
+ pageId: data.pageId,
});
const crosswordBlock = buildCrosswordBlock(data);
@@ -179,6 +180,7 @@ export const enhanceArticleType = (
format,
renderingTarget,
data.pinnedPost,
+ data.pageId,
),
standfirst: enhanceStandfirst(data.standfirst),
commercialProperties: enhanceCommercialProperties(
diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts
index 3a915ceb030..bdea58efe59 100644
--- a/dotcom-rendering/src/types/content.ts
+++ b/dotcom-rendering/src/types/content.ts
@@ -489,6 +489,11 @@ export interface ProductBlockElement {
lowestPrice?: string;
}
+export interface ProductCarouselElement {
+ _type: 'model.dotcomrendering.pageElements.ProductCarouselElement';
+ matchedProducts: ProductBlockElement[];
+}
+
interface ProfileAtomBlockElement {
_type: 'model.dotcomrendering.pageElements.ProfileAtomBlockElement';
elementId: string;
@@ -854,7 +859,8 @@ export type FEElement =
| YoutubeBlockElement
| WitnessTypeBlockElement
| CrosswordElement
- | ProductBlockElement;
+ | ProductBlockElement
+ | ProductCarouselElement;
// -------------------------------------
// Misc