From c8e84f7b406144a00372f4a791df30e3c4823e7f Mon Sep 17 00:00:00 2001 From: Charley_Campbell Date: Wed, 21 Jan 2026 11:41:26 +0000 Subject: [PATCH 1/4] Replacing the At a Glance list with a new product carousel, as part of an ABC test. --- .../src/frontend/schemas/feArticle.json | 22 ++ dotcom-rendering/src/lib/renderElement.tsx | 8 + dotcom-rendering/src/model/block-schema.json | 22 ++ dotcom-rendering/src/model/enhance-H2s.ts | 6 +- .../enhance-product-carousel.test-helpers.ts | 49 +++++ .../model/enhance-product-carousel.test.ts | 208 ++++++++++++++++++ .../src/model/enhance-product-carousel.ts | 122 ++++++++++ dotcom-rendering/src/model/enhanceBlocks.ts | 3 + .../src/model/enhanceProductElement.test.ts | 1 + dotcom-rendering/src/model/pinnedPost.ts | 2 + .../src/server/handler.article.apps.ts | 1 + .../src/server/handler.article.web.ts | 1 + dotcom-rendering/src/types/article.ts | 2 + dotcom-rendering/src/types/content.ts | 8 +- 14 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 dotcom-rendering/src/model/enhance-product-carousel.test-helpers.ts create mode 100644 dotcom-rendering/src/model/enhance-product-carousel.test.ts create mode 100644 dotcom-rendering/src/model/enhance-product-carousel.ts diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 608b48ed5ca..1a1968ab09a 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -873,6 +873,9 @@ }, { "$ref": "#/definitions/ProductBlockElement" + }, + { + "$ref": "#/definitions/ProductCarouselElement" } ] }, @@ -4613,6 +4616,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" + ] + }, "Block": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 3df1a7335d4..69d9c15ae94 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -43,6 +43,7 @@ import { PullQuoteBlockComponent } from '../components/PullQuoteBlockComponent'; import { QandaAtom } from '../components/QandaAtom.importable'; import { QAndAExplainers } from '../components/QAndAExplainers'; import { RichLinkComponent } from '../components/RichLinkComponent.importable'; +import { ScrollableProduct } from '../components/ScrollableProduct.importable'; import { SelfHostedVideoInArticle } from '../components/SelfHostedVideoInArticle'; import { SoundcloudBlockComponent } from '../components/SoundcloudBlockComponent'; import { SpotifyBlockComponent } from '../components/SpotifyBlockComponent.importable'; @@ -976,6 +977,13 @@ export const renderElement = ({ /> ); + 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..2f758ea109d --- /dev/null +++ b/dotcom-rendering/src/model/enhance-product-carousel.test.ts @@ -0,0 +1,208 @@ +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', + ]), + ]; + + 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', + ]); + + expect(result).toHaveLength(2); + }); + + 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', + ), + 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', + ]), + ]; + + 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 two 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', + ), + 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', + ), + 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', + ]), + ]; + + 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..ca6bccdb5b9 --- /dev/null +++ b/dotcom-rendering/src/model/enhance-product-carousel.ts @@ -0,0 +1,122 @@ +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 >= 2; + +const insertCarouselPlaceholder = (elements: FEElement[]): FEElement[] => { + if (!Array.isArray(elements) || elements.length === 0) return []; + + 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 = []; + } + + 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 From b129fdad27b8767f37840d6cdaa62a4f28ab5e51 Mon Sep 17 00:00:00 2001 From: Charley_Campbell Date: Wed, 21 Jan 2026 15:19:04 +0000 Subject: [PATCH 2/4] - Adds the carosuel to an island so it can hydrate and use js to render the nav buttons - Increases the product threshold from 2 to 3 for the carosuel to render - Removes the at a glance which is added in Composer as we have it as part of the new rendered elements --- dotcom-rendering/src/lib/renderElement.tsx | 10 ++++++---- dotcom-rendering/src/model/enhance-product-carousel.ts | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 69d9c15ae94..6e3dca90f61 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -979,10 +979,12 @@ export const renderElement = ({ ); case 'model.dotcomrendering.pageElements.ProductCarouselElement': return ( - + + + ); case 'model.dotcomrendering.pageElements.AudioBlockElement': case 'model.dotcomrendering.pageElements.ContentAtomBlockElement': diff --git a/dotcom-rendering/src/model/enhance-product-carousel.ts b/dotcom-rendering/src/model/enhance-product-carousel.ts index ca6bccdb5b9..bb7a3caaef2 100644 --- a/dotcom-rendering/src/model/enhance-product-carousel.ts +++ b/dotcom-rendering/src/model/enhance-product-carousel.ts @@ -53,7 +53,7 @@ const getAtAGlanceUrls = (elements: FEElement[]): string[] => ); const shouldRenderCarousel = (products: ProductBlockElement[]): boolean => - products.length >= 2; + products.length >= 3; const insertCarouselPlaceholder = (elements: FEElement[]): FEElement[] => { if (!Array.isArray(elements) || elements.length === 0) return []; @@ -68,6 +68,7 @@ const insertCarouselPlaceholder = (elements: FEElement[]): FEElement[] => { if (isAtAGlance(element)) { inAtAGlanceSection = true; atAGlanceElements = []; + continue; } output.push(element); From 736aa4489b6a38b219e8ca75eededd615f001b41 Mon Sep 17 00:00:00 2001 From: Charley_Campbell Date: Wed, 21 Jan 2026 16:54:09 +0000 Subject: [PATCH 3/4] -updates unit tests to match new logic - make carsouel an unordered list --- .../ScrollableProduct.importable.tsx | 4 +-- .../model/enhance-product-carousel.test.ts | 26 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) 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) => {
-
    { /> ))} -
+ { 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(2); + expect(result).toHaveLength(3); }); it('returns an empty array if no product CTA URLs match', () => { @@ -93,6 +97,10 @@ describe('insertCarouselPlaceholder', () => { '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', @@ -100,6 +108,9 @@ describe('insertCarouselPlaceholder', () => { 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); @@ -127,7 +138,7 @@ describe('insertCarouselPlaceholder', () => { }); describe('insertCarouselPlaceholder – edge cases', () => { - it('does not insert a carousel when fewer than two products match', () => { + it('does not insert a carousel when fewer than three products match', () => { const input = [ atAGlanceHeading(), linkElement( @@ -138,6 +149,10 @@ describe('insertCarouselPlaceholder – edge cases', () => { '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', @@ -191,6 +206,10 @@ describe('enhanceProductCarousel', () => { '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', @@ -198,6 +217,9 @@ describe('enhanceProductCarousel', () => { 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); From e2e8af4e38b6e9534659963a96b695a5f8824b4c Mon Sep 17 00:00:00 2001 From: Charley_Campbell Date: Thu, 22 Jan 2026 12:13:02 +0000 Subject: [PATCH 4/4] additional tweaks from PR suggestion --- dotcom-rendering/src/model/enhance-product-carousel.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dotcom-rendering/src/model/enhance-product-carousel.ts b/dotcom-rendering/src/model/enhance-product-carousel.ts index bb7a3caaef2..4b260c6a595 100644 --- a/dotcom-rendering/src/model/enhance-product-carousel.ts +++ b/dotcom-rendering/src/model/enhance-product-carousel.ts @@ -56,8 +56,6 @@ const shouldRenderCarousel = (products: ProductBlockElement[]): boolean => products.length >= 3; const insertCarouselPlaceholder = (elements: FEElement[]): FEElement[] => { - if (!Array.isArray(elements) || elements.length === 0) return []; - const output: FEElement[] = []; let inAtAGlanceSection = false; let atAGlanceElements: FEElement[] = []; @@ -67,7 +65,7 @@ const insertCarouselPlaceholder = (elements: FEElement[]): FEElement[] => { if (!inAtAGlanceSection) { if (isAtAGlance(element)) { inAtAGlanceSection = true; - atAGlanceElements = []; + atAGlanceElements = [element]; continue; }