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