From aefb9d7d1a53252788be1097bd5890bd44d8a6d7 Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Wed, 3 Jun 2026 16:47:25 +0100 Subject: [PATCH 1/8] wip: support product summary --- .../src/components/ProductSummary.island.tsx | 21 +- .../components/ScrollableProduct.island.tsx | 11 +- .../src/components/StackedProducts.island.tsx | 6 +- .../src/frontend/schemas/feArticle.json | 68 ++++- dotcom-rendering/src/lib/renderElement.tsx | 7 +- dotcom-rendering/src/model/block-schema.json | 68 ++++- .../src/model/enhance-product-summary.ts | 265 ++---------------- dotcom-rendering/src/model/enhanceBlocks.ts | 2 + dotcom-rendering/src/model/validate.ts | 2 + dotcom-rendering/src/types/content.ts | 20 +- 10 files changed, 198 insertions(+), 272 deletions(-) diff --git a/dotcom-rendering/src/components/ProductSummary.island.tsx b/dotcom-rendering/src/components/ProductSummary.island.tsx index d72dd4af825..9cdd688829e 100644 --- a/dotcom-rendering/src/components/ProductSummary.island.tsx +++ b/dotcom-rendering/src/components/ProductSummary.island.tsx @@ -1,33 +1,38 @@ import type { ArticleFormat } from '../lib/articleFormat'; -import type { ABTestVariant } from '../model/enhance-product-summary'; import type { ProductBlockElement } from '../types/content'; import { Island } from './Island'; import { ScrollableProduct } from './ScrollableProduct.island'; import { StackedProducts } from './StackedProducts.island'; export const ProductSummary = ({ + title, products, format, - variant, + displayType, }: { + title: string; products: ProductBlockElement[]; format: ArticleFormat; - variant: ABTestVariant; + displayType: string; // ToDo: type this }) => { - if (variant === 'carousel') { + if (displayType === 'CAROUSEL') { return ( - + ); } - if (variant === 'stacked-default') { + if (displayType === 'STACKED_CARD') { return ( @@ -39,7 +44,7 @@ export const ProductSummary = ({ diff --git a/dotcom-rendering/src/components/ScrollableProduct.island.tsx b/dotcom-rendering/src/components/ScrollableProduct.island.tsx index 7fe38e8c373..e1b4446434b 100644 --- a/dotcom-rendering/src/components/ScrollableProduct.island.tsx +++ b/dotcom-rendering/src/components/ScrollableProduct.island.tsx @@ -39,6 +39,7 @@ export type FixedSlideWidth = { }; type Props = { + title: string; products: ProductBlockElement[]; format: ArticleFormat; }; @@ -148,7 +149,7 @@ const generateFixedWidthColumStyles = ({ * we can move quickly. There will be some work to define a base carousel at some * point to see what functionality can be shared. */ -export const ScrollableProduct = ({ products, format }: Props) => { +export const ScrollableProduct = ({ title, products, format }: Props) => { const carouselRef = useRef(null); const [previousButtonEnabled, setPreviousButtonEnabled] = useState(false); const [nextButtonEnabled, setNextButtonEnabled] = useState(true); @@ -283,12 +284,8 @@ export const ScrollableProduct = ({ products, format }: Props) => { return ( <>
- - At a glance + + {title}
= { export const StackedProducts = ({ products, - heading, + title, format, showAllProducts, }: { products: ProductBlockElement[]; - heading: string; + title: string; format: ArticleFormat; showAllProducts: boolean; }) => { @@ -54,7 +54,7 @@ export const StackedProducts = ({ ]} > - {heading} + {title} {products.length > cardsShownByDefault && !showAllProducts && (

diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index ca5c3a9f85f..98dc570dcf7 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -893,6 +893,9 @@ }, { "$ref": "#/definitions/ProductSummaryElement" + }, + { + "$ref": "#/definitions/EnhancedProductSummaryElement" } ] }, @@ -4792,25 +4795,76 @@ "type": "string", "const": "model.dotcomrendering.pageElements.ProductSummaryElement" }, - "matchedProducts": { + "products": { + "type": "array", + "items": { + "$ref": "#/definitions/ProductSummaryMap" + } + }, + "displayType": { + "enum": [ + "CAROUSEL", + "CTA_LIST", + "STACKED_CARD" + ], + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "_type", + "displayType", + "products", + "title" + ] + }, + "ProductSummaryMap": { + "type": "object", + "properties": { + "productId": { + "type": "string" + }, + "ctaIndex": { + "type": "number" + } + }, + "required": [ + "ctaIndex", + "productId" + ] + }, + "EnhancedProductSummaryElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.EnhancedProductSummaryElement" + }, + "products": { "type": "array", "items": { "$ref": "#/definitions/ProductBlockElement" } }, - "variant": { + "displayType": { "enum": [ - "carousel", - "stacked-default", - "stacked-expanded" + "CAROUSEL", + "CTA_LIST", + "STACKED_CARD" ], "type": "string" + }, + "title": { + "type": "string" } }, "required": [ "_type", - "matchedProducts", - "variant" + "displayType", + "products", + "title" ] }, "Block": { diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 0a1593d173c..1f14e06273f 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -993,12 +993,13 @@ export const renderElement = ({ /> ); - case 'model.dotcomrendering.pageElements.ProductSummaryElement': + case 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement': return ( ); case 'model.dotcomrendering.pageElements.AudioBlockElement': diff --git a/dotcom-rendering/src/model/block-schema.json b/dotcom-rendering/src/model/block-schema.json index fa2e910b4b1..63fb64f7cf2 100644 --- a/dotcom-rendering/src/model/block-schema.json +++ b/dotcom-rendering/src/model/block-schema.json @@ -370,6 +370,9 @@ }, { "$ref": "#/definitions/ProductSummaryElement" + }, + { + "$ref": "#/definitions/EnhancedProductSummaryElement" } ] }, @@ -4269,25 +4272,76 @@ "type": "string", "const": "model.dotcomrendering.pageElements.ProductSummaryElement" }, - "matchedProducts": { + "products": { + "type": "array", + "items": { + "$ref": "#/definitions/ProductSummaryMap" + } + }, + "displayType": { + "enum": [ + "CAROUSEL", + "CTA_LIST", + "STACKED_CARD" + ], + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "_type", + "displayType", + "products", + "title" + ] + }, + "ProductSummaryMap": { + "type": "object", + "properties": { + "productId": { + "type": "string" + }, + "ctaIndex": { + "type": "number" + } + }, + "required": [ + "ctaIndex", + "productId" + ] + }, + "EnhancedProductSummaryElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.EnhancedProductSummaryElement" + }, + "products": { "type": "array", "items": { "$ref": "#/definitions/ProductBlockElement" } }, - "variant": { + "displayType": { "enum": [ - "carousel", - "stacked-default", - "stacked-expanded" + "CAROUSEL", + "CTA_LIST", + "STACKED_CARD" ], "type": "string" + }, + "title": { + "type": "string" } }, "required": [ "_type", - "matchedProducts", - "variant" + "displayType", + "products", + "title" ] }, "Attributes": { diff --git a/dotcom-rendering/src/model/enhance-product-summary.ts b/dotcom-rendering/src/model/enhance-product-summary.ts index 1b06979c40d..be9374bf26e 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.ts @@ -1,243 +1,40 @@ -import type { FEElement, ProductBlockElement } from '../types/content'; -import type { RenderingTarget } from '../types/renderingTarget'; -import { generateId } from './enhance-H2s'; - -/** - * This file contains logic used specifically for an A/B test that replaces - * the "At a glance" section with a product carousel on selected articles. - * If the experiment is successful, this logic may be refactored or relocated - * further up the rendering pipeline. - */ - -export type ABTestVariant = 'carousel' | 'stacked-default' | 'stacked-expanded'; - -/** - * 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[] = [ - 'thefilter/2025/nov/18/best-pillows-tested-uk', - 'thefilter/2025/jan/29/best-sunrise-alarm-clocks', - 'thefilter/2025/dec/28/best-running-watches-tested-uk', - 'thefilter/2025/mar/02/best-air-fryers', - 'thefilter/2024/nov/14/the-8-best-video-doorbells-tried-and-tested', - 'thefilter/2024/nov/26/best-robot-vacuum-mop', - 'thefilter/2025/sep/19/best-led-red-light-therapy-face-masks', - 'thefilter/2024/dec/27/best-electric-blankets-heated-throws', - 'technology/article/2024/jul/08/the-best-apple-iphones-in-2024-tested-reviewed-and-ranked', - 'thefilter/2025/dec/31/best-cross-trainers-ellipticals-uk', - 'thefilter/2026/jan/02/best-concealer-tested-uk', - 'thefilter/2025/jun/10/best-apple-watch', - 'thefilter/2026/jan/15/best-hand-cream-tested-uk', - 'thefilter/2026/jan/21/best-weighted-blanket-uk', - 'thefilter/2024/oct/18/best-heated-clothes-airers-dryer-save-time-money-laundry', - 'thefilter/2026/jan/23/best-duvets-tested-uk', - 'thefilter/2025/jan/23/best-womens-hiking-walking-boots', - 'thefilter/2024/nov/07/the-8-best-electric-heaters-tried-and-tested-from-traditional-stove-style-units-to-modern-smart-models', - 'thefilter/2024/oct/10/best-walking-boots-hiking-men-tried-tested', - 'thefilter-us/2025/dec/27/best-wine-subscriptions-us', - 'thefilter-us/2025/dec/14/best-cordless-leaf-blowers-battery-powered', - 'thefilter-us/2026/jan/07/best-packing-cubes', - 'thefilter-us/2026/jan/09/best-induction-cookware', - 'thefilter/2025/feb/12/best-flower-delivery', - 'thefilter-us/2026/feb/06/best-personal-travel-item-backpacks-us', - 'thefilter/2024/nov/21/best-coffee-machines', - 'thefilter/2026/feb/04/best-soup-maker-uk', - 'thefilter-us/2025/oct/01/best-best-bath-towels-us', - 'thefilter/2025/apr/03/best-walking-pads-under-desk-treadmills-uk', - 'thefilter-us/2026/feb/15/best-winter-boots-women', - 'thefilter-us/2026/feb/13/best-winter-jackets-men', - 'thefilter/2026/feb/13/best-vacuum-cleaners-uk-tested', - 'thefilter/2024/dec/15/best-womens-waterproof-jackets', - 'thefilter/2026/feb/20/best-drills-power-cordless-uk', - 'thefilter/2026/feb/19/best-steam-irons-uk-tested', - 'thefilter/2025/jul/22/best-electric-kettles-uk', - 'thefilter/2026/apr/12/best-barefoot-shoes-tested-uk', - 'thefilter/2026/apr/10/best-meal-delivery-service-food-recipe-kit-tested-uk', - 'thefilter/2026/apr/08/best-carry-on-luggage-cabin-bags-uk', - 'thefilter/2025/jun/03/best-water-flosser-uk', - 'thefilter-us/2026/apr/09/sonos-portable-speaker-review', - 'thefilter/2025/aug/31/best-mattress-toppers-uk', - 'thefilter/2025/may/13/best-hot-brushes-uk', - 'thefilter/2025/feb/06/best-mattress', - 'thefilter/2025/may/28/best-fake-tan-uk', - 'thefilter/2025/apr/18/best-pressure-washers-cleaners-uk', - 'thefilter/2025/may/18/best-suitcases-luggage-uk', - 'thefilter/2025/may/21/best-eye-creams-serums-uk', -]; - -const isEligibleForSummary = (pageId: string) => { - return allowedPageIds.includes(pageId); -}; - -const isCarouselOrStacked = (string: string) => { - return ( - string === 'carousel' || - string === 'stacked-default' || - string === 'stacked-expanded' +import type { + FEElement, + ProductBlockElement, + ProductSummaryMap, +} from '../types/content'; + +const productIsInSummary = ( + product: ProductBlockElement, + summaryProducts: ProductSummaryMap[], +): boolean => { + return summaryProducts.some( + (summaryProduct) => summaryProduct.productId === product.id, ); }; - -// 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 = ( +const findSummaryProducts = ( pageElements: FEElement[], - urls: string[], + summaryProducts: ProductSummaryMap[], ): 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 isEndOfAtAGlanceSection = (element: FEElement) => - // A subheading, divider, or a product block signals the end of the "At a glance" section - element._type === - 'model.dotcomrendering.pageElements.SubheadingBlockElement' || - element._type === - 'model.dotcomrendering.pageElements.DividerBlockElement' || - element._type === 'model.dotcomrendering.pageElements.ProductBlockElement'; - -const getAtAGlanceUrls = (elements: FEElement[]): string[] => - Array.from( - new Set( - extractAtAGlanceUrls(elements).filter((url) => url.trim() !== ''), - ), + pageElements.filter( + (el): el is ProductBlockElement => + el._type === + 'model.dotcomrendering.pageElements.ProductBlockElement' && + productIsInSummary(el, summaryProducts), ); -// A carousel is only rendered when at least 3 matching products are found -const shouldRenderSummary = (products: ProductBlockElement[]): boolean => - products.length >= 3; - -/** - * Iterates through the page elements and conditionally replaces the - * "At a glance" section with a ProductCarouselElement. - * - * High-level flow: - * - * - When we encounter the "At a glance" subheading, we treat this as the start - * of the section and begin collecting its elements. - * - * - We continue collecting elements until we reach either: - * - another SubheadingBlockElement, or - * - a DividerBlockElement - * which marks the end of the "At a glance" section. - * - * - Once the section ends, we extract product URLs from the collected elements - * and attempt to find matching ProductBlockElements elsewhere on the page. - * - * - If enough matching products are found, we replace the entire "At a glance" - * section with a single ProductCarouselElement. - * - * - Otherwise, we fall back to rendering the original "At a glance" elements - * unchanged. - */ - -const insertSummaryPlaceholder = ( - elements: FEElement[], - abTestVariant: ABTestVariant, -): FEElement[] => { - const output: FEElement[] = []; - let inAtAGlanceSection = false; - let atAGlanceElements: FEElement[] = []; - - // Iterate through the page, tracking when we enter and exit the "At a glance" section - for (const element of elements) { - // Start collecting elements belonging to the "At a glance" section - if (!inAtAGlanceSection) { - if (isAtAGlance(element)) { - inAtAGlanceSection = true; - atAGlanceElements = [element]; - continue; +export const enhanceProductSummary = (elements: FEElement[]): FEElement[] => + elements.map((element) => { + switch (element._type) { + case 'model.dotcomrendering.pageElements.ProductSummaryElement': { + return { + ...element, + _type: 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement', + products: findSummaryProducts(elements, element.products), + }; } - output.push(element); - continue; + default: + return element; } - - // Hitting a divider or another subheading means we've reached the end - // of the current "At a glance" section - if (isEndOfAtAGlanceSection(element)) { - inAtAGlanceSection = false; - - const urls = getAtAGlanceUrls(atAGlanceElements); - const matchedProducts = findMatchingProducts(elements, urls); - - // Decide whether to replace the section with a carousel or keep it as is - if (shouldRenderSummary(matchedProducts)) { - output.push({ - _type: 'model.dotcomrendering.pageElements.ProductSummaryElement', - matchedProducts, - variant: abTestVariant, - } as FEElement); - } else { - // Not enough products matched, so return original elements - output.push(...atAGlanceElements); - } - - output.push(element); - atAGlanceElements = []; - continue; - } - - atAGlanceElements.push(element); - } - - return output; -}; - -export const enhanceProductSummary = - ({ - pageId, - serverSideABTests, - renderingTarget, - filterAtAGlanceEnabled, - }: { - pageId: string; - serverSideABTests?: Record; - renderingTarget: RenderingTarget; - filterAtAGlanceEnabled: boolean; - }) => - (elements: FEElement[]): FEElement[] => { - const abTestVariant = - serverSideABTests?.['thefilter-at-a-glance-redesign-v2']; - - // do nothing if article is not on allow list / not in the test / variant is 'control' / renderingTarget is Apps / filterAtAGlance switch is OFF - if ( - filterAtAGlanceEnabled && - abTestVariant && - isCarouselOrStacked(abTestVariant) && - isEligibleForSummary(pageId) && - renderingTarget === 'Web' - ) { - return insertSummaryPlaceholder(elements, abTestVariant); - } - - return elements; - }; - -// Exports are for testing purposes only -export const _testOnly = { - extractAtAGlanceUrls, - findMatchingProducts, - insertSummaryPlaceholder, - allowedPageIds, -}; + }); diff --git a/dotcom-rendering/src/model/enhanceBlocks.ts b/dotcom-rendering/src/model/enhanceBlocks.ts index f631206a039..31dd768dbc0 100644 --- a/dotcom-rendering/src/model/enhanceBlocks.ts +++ b/dotcom-rendering/src/model/enhanceBlocks.ts @@ -21,6 +21,7 @@ import { enhanceElementsImages, enhanceImages } from './enhance-images'; import { enhanceInteractiveAtomElements } from './enhance-interactive-atom'; import { enhanceInteractiveContentsElements } from './enhance-interactive-contents-elements'; import { enhanceNumberedLists } from './enhance-numbered-lists'; +import { enhanceProductSummary } from './enhance-product-summary'; import { enhanceTweets } from './enhance-tweets'; import { enhanceGuVideos } from './enhance-videos'; import { enhanceLists } from './enhanceLists'; @@ -78,6 +79,7 @@ export const enhanceElements = enhanceProductElement( enhanceElements(format, blockId, options, true), ), + enhanceProductSummary, enhanceDividers, enhanceH2s, enhanceInteractiveAtomElements(format), diff --git a/dotcom-rendering/src/model/validate.ts b/dotcom-rendering/src/model/validate.ts index 89791de1f3d..4b9d09ec45c 100644 --- a/dotcom-rendering/src/model/validate.ts +++ b/dotcom-rendering/src/model/validate.ts @@ -58,6 +58,8 @@ const validateFootballMatchInfoPage = ajv.compile( ); export const validateAsFEArticle = (data: unknown): FEArticle => { + console.log('data to validate', data); + if (validateArticle(data)) return data; const url = diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 3390bf275ee..b306ae52ae1 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -525,10 +525,23 @@ export interface ProductBlockElement { lowestPrice?: string; } +export interface ProductSummaryMap { + productId: string; + ctaIndex: number; +} + export interface ProductSummaryElement { _type: 'model.dotcomrendering.pageElements.ProductSummaryElement'; - matchedProducts: ProductBlockElement[]; - variant: 'carousel' | 'stacked-default' | 'stacked-expanded'; + products: ProductSummaryMap[]; + displayType: 'CTA_LIST' | 'CAROUSEL' | 'STACKED_CARD'; + title: string; +} + +export interface EnhancedProductSummaryElement { + _type: 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement'; + products: ProductBlockElement[]; + displayType: 'CTA_LIST' | 'CAROUSEL' | 'STACKED_CARD'; + title: string; } interface ProfileAtomBlockElement { @@ -901,7 +914,8 @@ export type FEElement = | WitnessTypeBlockElement | CrosswordElement | ProductBlockElement - | ProductSummaryElement; + | ProductSummaryElement + | EnhancedProductSummaryElement; // ------------------------------------- // Misc From 4831633e1e18268b95287b405e9bf8ad4432f9a4 Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Mon, 8 Jun 2026 10:53:06 +0100 Subject: [PATCH 2/8] unit test enhanced product summary --- .../enhance-product-summary.test-helpers.ts | 68 +-- .../src/model/enhance-product-summary.test.ts | 423 ++---------------- 2 files changed, 40 insertions(+), 451 deletions(-) diff --git a/dotcom-rendering/src/model/enhance-product-summary.test-helpers.ts b/dotcom-rendering/src/model/enhance-product-summary.test-helpers.ts index e849eba292b..415c02b73b8 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.test-helpers.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.test-helpers.ts @@ -1,69 +1,31 @@ import type { + EnhancedProductSummaryElement, FEElement, ProductBlockElement, ProductSummaryElement, } from '../types/content'; -export const linkElement = (url: string, label: string): FEElement => +export const productSummaryElement = (ids: string[]): ProductSummaryElement => ({ - _type: 'model.dotcomrendering.pageElements.LinkBlockElement', - url, - label, - }) as FEElement; - -export const productElement = (urls: string[]): ProductBlockElement => + _type: 'model.dotcomrendering.pageElements.ProductSummaryElement', + products: ids.map((id) => ({ productId: id, ctaIndex: 0 })), + }) as ProductSummaryElement; + +export const productElement = ( + urls: string[], + id: string, +): ProductBlockElement => ({ _type: 'model.dotcomrendering.pageElements.ProductBlockElement', productCtas: urls.map((url) => ({ url })), + id, }) 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 const findCarousel = ( - elements: FEElement[], -): ProductSummaryElement | undefined => - elements.find( - (el): el is ProductSummaryElement => - el._type === - 'model.dotcomrendering.pageElements.ProductSummaryElement' && - el.variant === 'carousel', - ); - -export const findStackedDefault = ( - elements: FEElement[], -): ProductSummaryElement | undefined => - elements.find( - (el): el is ProductSummaryElement => - el._type === - 'model.dotcomrendering.pageElements.ProductSummaryElement' && - el.variant === 'stacked-default', - ); - -export const findStackedExpanded = ( +export const findEnhancedProductSummary = ( elements: FEElement[], -): ProductSummaryElement | undefined => +): EnhancedProductSummaryElement | undefined => elements.find( - (el): el is ProductSummaryElement => + (el): el is EnhancedProductSummaryElement => el._type === - 'model.dotcomrendering.pageElements.ProductSummaryElement' && - el.variant === 'stacked-expanded', + 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement', ); diff --git a/dotcom-rendering/src/model/enhance-product-summary.test.ts b/dotcom-rendering/src/model/enhance-product-summary.test.ts index 88455f113b5..5f713099165 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.test.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.test.ts @@ -1,413 +1,40 @@ -import { _testOnly, enhanceProductSummary } from './enhance-product-summary'; +import { enhanceProductSummary } from './enhance-product-summary'; import { - atAGlanceHeading, - dividerElement, - findCarousel, - findStackedDefault, - findStackedExpanded, - linkElement, + findEnhancedProductSummary, productElement, - textElement, + productSummaryElement, } from './enhance-product-summary.test-helpers'; -const { extractAtAGlanceUrls, findMatchingProducts, insertSummaryPlaceholder } = - _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 = insertSummaryPlaceholder(input, 'carousel'); - - 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 = insertSummaryPlaceholder(input, 'carousel'); - - const carousel = findCarousel(output); - expect(carousel).toBeUndefined(); - }); -}); - -describe('insertSummaryPlaceholder – 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 = insertSummaryPlaceholder(input, 'carousel'); - const carousel = findCarousel(output); - expect(carousel).toBeUndefined(); - }); - - it('does not insert a summary 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 = insertSummaryPlaceholder(input, 'carousel'); - - const carousel = findCarousel(output); - expect(carousel).toBeUndefined(); - }); - - it('returns an empty array for empty input', () => { - expect(insertSummaryPlaceholder([], 'carousel')).toEqual([]); - }); -}); - describe('enhanceProductSummary', () => { - beforeAll(() => { - _testOnly.allowedPageIds.push( - 'thefilter/test-article-example-for-product-summary', - ); - }); - - it('enhances elements with a product carousel for allowlisted pages', () => { - const allowedPageId = - 'thefilter/test-article-example-for-product-summary'; - - 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 = enhanceProductSummary({ - pageId: allowedPageId, - serverSideABTests: { - 'thefilter-at-a-glance-redesign-v2': 'carousel', - }, - renderingTarget: 'Web', - filterAtAGlanceEnabled: true, - })(input); - - const carousel = findCarousel(output); - - expect(carousel).toBeDefined(); - }); - - it('enhances elements with a default stacked product component for allowlisted pages', () => { - const allowedPageId = - 'thefilter/test-article-example-for-product-summary'; - - 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 = enhanceProductSummary({ - pageId: allowedPageId, - serverSideABTests: { - 'thefilter-at-a-glance-redesign-v2': 'stacked-default', - }, - renderingTarget: 'Web', - filterAtAGlanceEnabled: true, - })(input); - - const stackedDefault = findStackedDefault(output); - - expect(stackedDefault).toBeDefined(); - }); - - it('enhances elements with an expanded stacked product component for allowlisted pages', () => { - const allowedPageId = - 'thefilter/test-article-example-for-product-summary'; - - 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 = enhanceProductSummary({ - pageId: allowedPageId, - serverSideABTests: { - 'thefilter-at-a-glance-redesign-v2': 'stacked-expanded', - }, - renderingTarget: 'Web', - filterAtAGlanceEnabled: true, - })(input); - - const stackedExpanded = findStackedExpanded(output); - - expect(stackedExpanded).toBeDefined(); - }); - - it('does not return stacked cards when the rendering target is apps', () => { - const allowedPageId = - 'thefilter/test-article-example-for-product-summary'; - - 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 = enhanceProductSummary({ - pageId: allowedPageId, - serverSideABTests: { - 'thefilter-at-a-glance-redesign-v2': 'stacked-default', - }, - renderingTarget: 'Apps', - filterAtAGlanceEnabled: true, - })(input); - - const stackedDefault = findStackedDefault(output); - - expect(stackedDefault).toBeUndefined(); - }); - - it('does not return stacked cards when the filterAtAGlance switch is OFF', () => { - const allowedPageId = - 'thefilter/test-article-example-for-product-summary'; - + it('enhances product summary elements with its selected product elements', () => { + const selectedIds = ['1', '2']; 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', + productElement( + [ + 'https://www.homebase.co.uk/en-uk/tower-airx-t17166-5l-grey-single-basket-air-fryer-digital-air-fryer/p/0757395', + ], + '1', ), - 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', + ], + '2', ), - linkElement( - 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK', - 'Buy now', + productElement( + [ + 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK', + ], + '3', ), - 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', - ]), + productSummaryElement(selectedIds), ]; - const output = enhanceProductSummary({ - pageId: allowedPageId, - serverSideABTests: { - 'thefilter-at-a-glance-redesign-v2': 'stacked-default', - }, - renderingTarget: 'Web', - filterAtAGlanceEnabled: false, - })(input); + const output = enhanceProductSummary(input); - const stackedDefault = findStackedDefault(output); + const enhancedProductSummaryElement = + findEnhancedProductSummary(output); - expect(stackedDefault).toBeUndefined(); + expect(enhancedProductSummaryElement?.products).toHaveLength(2); }); }); From 2b4b242a21e582615c3b507cde05a68434007102 Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Mon, 8 Jun 2026 11:03:11 +0100 Subject: [PATCH 3/8] update stories --- dotcom-rendering/src/components/ScrollableProducts.stories.tsx | 1 + dotcom-rendering/src/components/StackedProducts.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/ScrollableProducts.stories.tsx b/dotcom-rendering/src/components/ScrollableProducts.stories.tsx index 411c9d1b9d3..9dc5792efd3 100644 --- a/dotcom-rendering/src/components/ScrollableProducts.stories.tsx +++ b/dotcom-rendering/src/components/ScrollableProducts.stories.tsx @@ -18,6 +18,7 @@ const meta = preview.meta({ }, }, args: { + title: 'At a glance', products: [ { ...exampleProduct, diff --git a/dotcom-rendering/src/components/StackedProducts.stories.tsx b/dotcom-rendering/src/components/StackedProducts.stories.tsx index 1b1aed5637e..13bbd8db17c 100644 --- a/dotcom-rendering/src/components/StackedProducts.stories.tsx +++ b/dotcom-rendering/src/components/StackedProducts.stories.tsx @@ -9,7 +9,7 @@ const meta = preview.meta({ component: StackedProducts, args: { products: exampleAtAGlanceProductArray, - heading: 'At a glance', + title: 'At a glance', format: { design: ArticleDesign.Review, display: ArticleDisplay.Standard, From 1b75028a6a983a3533035f9184b09a76bf58c021 Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Mon, 8 Jun 2026 14:25:56 +0100 Subject: [PATCH 4/8] refactor to choose correct cta --- .../fixtures/manual/productBlockElement.ts | 13 +++- .../HorizontalSummaryProductCard.stories.tsx | 5 +- .../HorizontalSummaryProductCard.tsx | 19 +++-- .../ProductCarouselCard.stories.tsx | 40 ++++++---- .../src/components/ProductCarouselCard.tsx | 37 +++++---- .../src/components/ProductSummary.island.tsx | 78 ++++++++++--------- .../components/ScrollableProduct.island.tsx | 10 +-- .../components/ScrollableProducts.stories.tsx | 47 +++++++---- .../src/components/StackedProducts.island.tsx | 11 ++- .../components/StackedProducts.stories.tsx | 8 +- .../src/frontend/schemas/feArticle.json | 40 ++++++---- dotcom-rendering/src/model/block-schema.json | 40 ++++++---- .../enhance-product-summary.test-helpers.ts | 7 +- .../src/model/enhance-product-summary.test.ts | 49 +++++++++++- .../src/model/enhance-product-summary.ts | 45 ++++++----- dotcom-rendering/src/types/content.ts | 17 +++- 16 files changed, 308 insertions(+), 158 deletions(-) diff --git a/dotcom-rendering/fixtures/manual/productBlockElement.ts b/dotcom-rendering/fixtures/manual/productBlockElement.ts index 54d96702139..664c440ddd3 100644 --- a/dotcom-rendering/fixtures/manual/productBlockElement.ts +++ b/dotcom-rendering/fixtures/manual/productBlockElement.ts @@ -1,5 +1,8 @@ import { extractHeadingText } from '../../src/model/enhanceProductElement'; -import type { ProductBlockElement } from '../../src/types/content'; +import type { + EnhancedProductSummaryMap, + ProductBlockElement, +} from '../../src/types/content'; import { productImage } from './productImage'; export const exampleProduct: ProductBlockElement = { @@ -245,7 +248,7 @@ export const exampleProduct: ProductBlockElement = { ], }; -export const exampleAtAGlanceProductArray: ProductBlockElement[] = [ +const exampleAtAGlanceProductArray: ProductBlockElement[] = [ { _type: 'model.dotcomrendering.pageElements.ProductBlockElement', elementId: 'b85ec38b-091b-40c2-8902-a9114df3cfe3', @@ -529,3 +532,9 @@ export const exampleAtAGlanceProductArray: ProductBlockElement[] = [ id: '098', }, ]; + +export const exampleSummaryProducts: EnhancedProductSummaryMap[] = + exampleAtAGlanceProductArray.map((p) => ({ + productBlock: p, + ctaIndex: 0, + })); diff --git a/dotcom-rendering/src/components/HorizontalSummaryProductCard.stories.tsx b/dotcom-rendering/src/components/HorizontalSummaryProductCard.stories.tsx index 7ec9dca16f9..f9dc8bdae07 100644 --- a/dotcom-rendering/src/components/HorizontalSummaryProductCard.stories.tsx +++ b/dotcom-rendering/src/components/HorizontalSummaryProductCard.stories.tsx @@ -9,7 +9,10 @@ const meta = preview.meta({ title: 'Components/Horizontal Summary Product Card', component: HorizontalSummaryProductCard, args: { - product: { ...exampleProduct, h2Id: 'example-1' }, + product: { + productBlock: { ...exampleProduct, h2Id: 'example-1' }, + ctaIndex: 0, + }, format: { design: ArticleDesign.Standard, display: ArticleDisplay.Standard, diff --git a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx index 6c1a38e8b13..fda20bfd508 100644 --- a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx +++ b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx @@ -10,7 +10,7 @@ import { import { Link } from '@guardian/source/react-components'; import type { ArticleFormat } from '../lib/articleFormat'; import { palette } from '../palette'; -import type { ProductBlockElement } from '../types/content'; +import type { EnhancedProductSummaryMap } from '../types/content'; import { ProductLinkButton } from './Button/ProductLinkButton'; import { ProductCardImage } from './ProductCardImage'; @@ -81,10 +81,11 @@ export const HorizontalSummaryProductCard = ({ product, format, }: { - product: ProductBlockElement; + product: EnhancedProductSummaryMap; format: ArticleFormat; }) => { - const cardCta = product.productCtas[0]; + const { productBlock, ctaIndex } = product; + const cardCta = productBlock.productCtas[ctaIndex]; if (!cardCta) { return null; } @@ -95,15 +96,19 @@ export const HorizontalSummaryProductCard = ({

-
{product.primaryHeadingText}
-
{product.secondaryHeadingText}
+
+ {productBlock.primaryHeadingText} +
+
+ {productBlock.secondaryHeadingText} +
event.stopPropagation()} cssOverrides={readMore} data-component="at-a-glance-stacked-card-read-more" diff --git a/dotcom-rendering/src/components/ProductCarouselCard.stories.tsx b/dotcom-rendering/src/components/ProductCarouselCard.stories.tsx index ba92e505523..2f1f38ae22d 100644 --- a/dotcom-rendering/src/components/ProductCarouselCard.stories.tsx +++ b/dotcom-rendering/src/components/ProductCarouselCard.stories.tsx @@ -9,7 +9,10 @@ const meta = preview.meta({ component: ProductCarouselCard, title: 'Components/ProductCarouselCard', args: { - product: { ...exampleProduct, h2Id: 'h2-id' }, + product: { + productBlock: { ...exampleProduct, h2Id: 'h2-id' }, + ctaIndex: 0, + }, format: { design: ArticleDesign.Standard, display: ArticleDisplay.Standard, @@ -32,22 +35,25 @@ export const Default = meta.story(); export const WithLongHeadingProductNameAndCTA = meta.story({ args: { product: { - ...exampleProduct, - h2Id: 'h2-id', - primaryHeadingHtml: 'Super long product category review name', - primaryHeadingText: extractHeadingText( - 'Super long product: category review name:', - ), - productName: - 'Sky Kettle with a super duper long name that goes on and on', - productCtas: [ - { - url: 'https://www.johnlewis.com/bosch-twk7203gb-sky-variable-temperature-kettle-1-7l-black/p3228625', - text: '', - retailer: 'John Lewis with a very long name', - price: '£45.99', - }, - ], + productBlock: { + ...exampleProduct, + h2Id: 'h2-id', + primaryHeadingHtml: 'Super long product category review name', + primaryHeadingText: extractHeadingText( + 'Super long product: category review name:', + ), + productName: + 'Sky Kettle with a super duper long name that goes on and on', + productCtas: [ + { + url: 'https://www.johnlewis.com/bosch-twk7203gb-sky-variable-temperature-kettle-1-7l-black/p3228625', + text: '', + retailer: 'John Lewis with a very long name', + price: '£45.99', + }, + ], + }, + ctaIndex: 0, }, }, }); diff --git a/dotcom-rendering/src/components/ProductCarouselCard.tsx b/dotcom-rendering/src/components/ProductCarouselCard.tsx index b5dcfad992b..fb4dccb13a0 100644 --- a/dotcom-rendering/src/components/ProductCarouselCard.tsx +++ b/dotcom-rendering/src/components/ProductCarouselCard.tsx @@ -11,12 +11,12 @@ import { import { Link } from '@guardian/source/react-components'; import type { ArticleFormat } from '../lib/articleFormat'; import { palette } from '../palette'; -import type { ProductBlockElement } from '../types/content'; +import type { EnhancedProductSummaryMap } from '../types/content'; import { ProductLinkButton } from './Button/ProductLinkButton'; import { ProductCardImage } from './ProductCardImage'; export type ProductCarouselCardProps = { - product: ProductBlockElement; + product: EnhancedProductSummaryMap; format: ArticleFormat; }; @@ -83,10 +83,15 @@ export const ProductCarouselCard = ({ product, format, }: ProductCarouselCardProps) => { - const hasHeading = !!product.primaryHeadingHtml; - const firstCta = product.productCtas[0]; - const headingId = product.h2Id; - const productAndBrandName = [product.brandName, product.productName] + const { productBlock, ctaIndex } = product; + + const hasHeading = !!productBlock.primaryHeadingHtml; + const cardCta = productBlock.productCtas[ctaIndex]; + const headingId = productBlock.h2Id; + const productAndBrandName = [ + productBlock.brandName, + productBlock.productName, + ] .filter(Boolean) .join(' '); return ( @@ -95,7 +100,7 @@ export const ProductCarouselCard = ({ {hasHeading && ( <>
- {product.primaryHeadingText} + {productBlock.primaryHeadingText}
{productAndBrandName} @@ -106,7 +111,7 @@ export const ProductCarouselCard = ({
{!isUndefined(headingId) && hasHeading && - product.displayType !== 'ProductCardOnly' && ( + productBlock.displayType !== 'ProductCardOnly' && (
{!hasHeading && (
-
{product.brandName}
-
{product.productName}
+
{productBlock.brandName}
+
+ {productBlock.productName} +
)} - {firstCta && ( + {cardCta && ( <> -
{firstCta.price}
+
{cardCta.price}
diff --git a/dotcom-rendering/src/components/ProductSummary.island.tsx b/dotcom-rendering/src/components/ProductSummary.island.tsx index 9cdd688829e..c423fce08f3 100644 --- a/dotcom-rendering/src/components/ProductSummary.island.tsx +++ b/dotcom-rendering/src/components/ProductSummary.island.tsx @@ -1,5 +1,8 @@ import type { ArticleFormat } from '../lib/articleFormat'; -import type { ProductBlockElement } from '../types/content'; +import type { + EnhancedProductSummaryMap, + ProductSummaryDisplayType, +} from '../types/content'; import { Island } from './Island'; import { ScrollableProduct } from './ScrollableProduct.island'; import { StackedProducts } from './StackedProducts.island'; @@ -11,43 +14,44 @@ export const ProductSummary = ({ displayType, }: { title: string; - products: ProductBlockElement[]; + products: EnhancedProductSummaryMap[]; format: ArticleFormat; - displayType: string; // ToDo: type this + displayType: ProductSummaryDisplayType; }) => { - if (displayType === 'CAROUSEL') { - return ( - - - - ); + switch (displayType) { + case 'Carousel': + return ( + + + + ); + case 'StackedCard': + return ( + + + + ); + case 'StackedCardExpanded': + return ( + + + + ); + case 'CtaList': + return <>ToDo; } - - if (displayType === 'STACKED_CARD') { - return ( - - - - ); - } - - return ( - - - - ); }; diff --git a/dotcom-rendering/src/components/ScrollableProduct.island.tsx b/dotcom-rendering/src/components/ScrollableProduct.island.tsx index e1b4446434b..5cb002db4ca 100644 --- a/dotcom-rendering/src/components/ScrollableProduct.island.tsx +++ b/dotcom-rendering/src/components/ScrollableProduct.island.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { nestedOphanComponents } from '../lib/ophan-helpers'; import { palette } from '../palette'; -import type { ProductBlockElement } from '../types/content'; +import type { EnhancedProductSummaryMap } from '../types/content'; import { CarouselCount } from './CarouselCount'; import { CarouselNavigationButtons } from './CarouselNavigationButtons'; import { ProductCarouselCard } from './ProductCarouselCard'; @@ -40,7 +40,7 @@ export type FixedSlideWidth = { type Props = { title: string; - products: ProductBlockElement[]; + products: EnhancedProductSummaryMap[]; format: ArticleFormat; }; @@ -301,11 +301,11 @@ export const ScrollableProduct = ({ title, products, format }: Props) => { data-heatphan-type="carousel" > {products.map( - (product: ProductBlockElement, index: number) => ( + (product: EnhancedProductSummaryMap, index: number) => (
  • Product 0', - h2Id: 'product', + productBlock: { + ...exampleProduct, + primaryHeadingHtml: 'Product 0', + h2Id: 'product', + }, + ctaIndex: 0, }, { - ...exampleProduct, - primaryHeadingHtml: 'Product 1', - h2Id: 'product-1', - productName: 'Lorem ipsum dolor sit amet', + productBlock: { + ...exampleProduct, + primaryHeadingHtml: 'Product 1', + h2Id: 'product-1', + productName: 'Lorem ipsum dolor sit amet', + }, + ctaIndex: 0, }, { - ...exampleProduct, - primaryHeadingHtml: 'Product 2', - h2Id: 'product-2', + productBlock: { + ...exampleProduct, + primaryHeadingHtml: 'Product 2', + h2Id: 'product-2', + }, + ctaIndex: 0, }, { - ...exampleProduct, - primaryHeadingHtml: 'Product 3', - h2Id: 'product-3', + productBlock: { + ...exampleProduct, + primaryHeadingHtml: 'Product 3', + h2Id: 'product-3', + }, + ctaIndex: 0, }, { - ...exampleProduct, - primaryHeadingHtml: 'Product 4', - h2Id: 'product-4', + productBlock: { + ...exampleProduct, + primaryHeadingHtml: 'Product 4', + h2Id: 'product-4', + }, + ctaIndex: 0, }, ], format: { diff --git a/dotcom-rendering/src/components/StackedProducts.island.tsx b/dotcom-rendering/src/components/StackedProducts.island.tsx index d3b23c5768b..e8b583a6b4d 100644 --- a/dotcom-rendering/src/components/StackedProducts.island.tsx +++ b/dotcom-rendering/src/components/StackedProducts.island.tsx @@ -7,7 +7,7 @@ import { import { useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { palette } from '../palette'; -import type { ProductBlockElement } from '../types/content'; +import type { EnhancedProductSummaryMap } from '../types/content'; import { HorizontalSummaryProductCard } from './HorizontalSummaryProductCard'; import { Subheading } from './Subheading'; @@ -35,7 +35,7 @@ export const StackedProducts = ({ format, showAllProducts, }: { - products: ProductBlockElement[]; + products: EnhancedProductSummaryMap[]; title: string; format: ArticleFormat; showAllProducts: boolean; @@ -78,9 +78,12 @@ export const StackedProducts = ({ `, ]} > - {products.map((product: ProductBlockElement, index) => ( + {products.map((product: EnhancedProductSummaryMap, index) => (
    +export const productSummaryElement = ( + summaryProducts: ProductSummaryMap[], +): ProductSummaryElement => ({ _type: 'model.dotcomrendering.pageElements.ProductSummaryElement', - products: ids.map((id) => ({ productId: id, ctaIndex: 0 })), + products: summaryProducts, }) as ProductSummaryElement; export const productElement = ( diff --git a/dotcom-rendering/src/model/enhance-product-summary.test.ts b/dotcom-rendering/src/model/enhance-product-summary.test.ts index 5f713099165..c42fe32c220 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.test.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.test.ts @@ -27,7 +27,9 @@ describe('enhanceProductSummary', () => { ], '3', ), - productSummaryElement(selectedIds), + productSummaryElement( + selectedIds.map((id) => ({ productId: id, ctaIndex: 0 })), + ), ]; const output = enhanceProductSummary(input); @@ -36,5 +38,50 @@ describe('enhanceProductSummary', () => { findEnhancedProductSummary(output); expect(enhancedProductSummaryElement?.products).toHaveLength(2); + expect( + enhancedProductSummaryElement?.products.map( + (mapping) => mapping.productBlock.id, + ), + ).toEqual(selectedIds); + }); + + it('enhances product summary elements with the correct CTA indices', () => { + const summaryProducts = [ + { productId: '1', ctaIndex: 0 }, + { productId: '3', ctaIndex: 1 }, + ]; + const input = [ + productElement( + [ + 'https://www.homebase.co.uk/en-uk/tower-airx-t17166-5l-grey-single-basket-air-fryer-digital-air-fryer/p/0757395', + ], + '1', + ), + productElement( + [ + 'https://www.lakeland.co.uk/27537/lakeland-slimline-air-fryer-black-8l', + ], + '2', + ), + productElement( + [ + 'https://ninjakitchen.co.uk/product/ninja-double-stack-xl-9-5l-air-fryer-sl400uk-zidSL400UK', + ], + '3', + ), + productSummaryElement(summaryProducts), + ]; + + const output = enhanceProductSummary(input); + + const enhancedProductSummaryElement = + findEnhancedProductSummary(output); + + expect(enhancedProductSummaryElement?.products).toHaveLength(2); + expect( + enhancedProductSummaryElement?.products.map( + (mapping) => mapping.ctaIndex, + ), + ).toEqual([0, 1]); }); }); diff --git a/dotcom-rendering/src/model/enhance-product-summary.ts b/dotcom-rendering/src/model/enhance-product-summary.ts index be9374bf26e..af4c07c785c 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.ts @@ -1,27 +1,36 @@ import type { + EnhancedProductSummaryMap, FEElement, - ProductBlockElement, ProductSummaryMap, } from '../types/content'; -const productIsInSummary = ( - product: ProductBlockElement, - summaryProducts: ProductSummaryMap[], -): boolean => { - return summaryProducts.some( - (summaryProduct) => summaryProduct.productId === product.id, - ); -}; -const findSummaryProducts = ( +const getSummaryProducts = ( pageElements: FEElement[], summaryProducts: ProductSummaryMap[], -): ProductBlockElement[] => - pageElements.filter( - (el): el is ProductBlockElement => - el._type === - 'model.dotcomrendering.pageElements.ProductBlockElement' && - productIsInSummary(el, summaryProducts), - ); +): EnhancedProductSummaryMap[] => + pageElements.reduce((acc, element) => { + if ( + element._type !== + 'model.dotcomrendering.pageElements.ProductBlockElement' + ) { + return acc; + } + + const matchingSummaryProduct = summaryProducts.find( + (summaryProduct) => summaryProduct.productId === element.id, + ); + + if (!matchingSummaryProduct) { + return acc; + } + + acc.push({ + productBlock: element, + ctaIndex: matchingSummaryProduct.ctaIndex, + }); + + return acc; + }, []); export const enhanceProductSummary = (elements: FEElement[]): FEElement[] => elements.map((element) => { @@ -30,7 +39,7 @@ export const enhanceProductSummary = (elements: FEElement[]): FEElement[] => return { ...element, _type: 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement', - products: findSummaryProducts(elements, element.products), + products: getSummaryProducts(elements, element.products), }; } diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index b306ae52ae1..87cd350b08a 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -530,17 +530,22 @@ export interface ProductSummaryMap { ctaIndex: number; } +export interface EnhancedProductSummaryMap { + productBlock: ProductBlockElement; + ctaIndex: number; +} + export interface ProductSummaryElement { _type: 'model.dotcomrendering.pageElements.ProductSummaryElement'; products: ProductSummaryMap[]; - displayType: 'CTA_LIST' | 'CAROUSEL' | 'STACKED_CARD'; + displayType: ProductSummaryDisplayType; title: string; } export interface EnhancedProductSummaryElement { _type: 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement'; - products: ProductBlockElement[]; - displayType: 'CTA_LIST' | 'CAROUSEL' | 'STACKED_CARD'; + products: EnhancedProductSummaryMap[]; + displayType: ProductSummaryDisplayType; title: string; } @@ -973,6 +978,12 @@ export type ProductStarRating = | '5' | 'none-selected'; +export type ProductSummaryDisplayType = + | 'Carousel' + | 'CtaList' + | 'StackedCard' + | 'StackedCardExpanded'; + export interface SrcSetItem { src: string; width: number; From ebdf0a27f8685ca177ad4f4a04c35f466b0ea2a0 Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Tue, 16 Jun 2026 14:13:12 +0100 Subject: [PATCH 5/8] feat: add product cta list component --- .../src/components/ProductCtaList.stories.tsx | 27 ++++++ .../src/components/ProductCtaList.tsx | 87 +++++++++++++++++++ .../src/components/ProductSummary.island.tsx | 9 +- 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 dotcom-rendering/src/components/ProductCtaList.stories.tsx create mode 100644 dotcom-rendering/src/components/ProductCtaList.tsx diff --git a/dotcom-rendering/src/components/ProductCtaList.stories.tsx b/dotcom-rendering/src/components/ProductCtaList.stories.tsx new file mode 100644 index 00000000000..425d3c552a3 --- /dev/null +++ b/dotcom-rendering/src/components/ProductCtaList.stories.tsx @@ -0,0 +1,27 @@ +import { centreColumnDecorator } from '../../.storybook/decorators/gridDecorators'; +import preview from '../../.storybook/preview'; +import { exampleProduct } from '../../fixtures/manual/productBlockElement'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { ProductCtaList } from './ProductCtaList'; + +const meta = preview.meta({ + component: ProductCtaList, + title: 'Components/ProductCtaList', + args: { + products: [ + { + productBlock: { ...exampleProduct, h2Id: 'h2-id' }, + ctaIndex: 0, + }, + ], + format: { + design: ArticleDesign.Review, + display: ArticleDisplay.Standard, + theme: Pillar.Lifestyle, + }, + title: 'At a glance', + }, + decorators: [centreColumnDecorator], +}); + +export const Default = meta.story(); diff --git a/dotcom-rendering/src/components/ProductCtaList.tsx b/dotcom-rendering/src/components/ProductCtaList.tsx new file mode 100644 index 00000000000..5e51685183b --- /dev/null +++ b/dotcom-rendering/src/components/ProductCtaList.tsx @@ -0,0 +1,87 @@ +import { css } from '@emotion/react'; +import { article17, palette, remSpace } from '@guardian/source/foundations'; +import type { ArticleFormat } from '../lib/articleFormat'; +import type { EnhancedProductSummaryMap } from '../types/content'; +import { ProductLinkButton } from './Button/ProductLinkButton'; +import { Subheading } from './Subheading'; + +const listStyles = css` + li { + ${article17} + margin-bottom: ${remSpace[1]}; + padding-left: ${remSpace[5]}; + + p { + margin: -1.5rem 0 0 0; + } + } + + li::before { + display: inline-block; + content: ''; + border-radius: 50%; + height: ${remSpace[3]}; + width: ${remSpace[3]}; + background-color: ${palette.neutral[86]}; + margin-left: -${remSpace[5]}; + margin-right: ${remSpace[2]}; + } + + strong { + font-weight: bold; + } + + a { + margin-top: ${remSpace[3]}; + margin-bottom: ${remSpace[3]}; + margin-left: -${remSpace[5]}; + } +`; + +const ListItem = ({ product }: { product: EnhancedProductSummaryMap }) => { + const { productBlock, ctaIndex } = product; + const cta = productBlock.productCtas[ctaIndex]; + return ( +
  • +

    + {productBlock.primaryHeadingText} +
    + {productBlock.brandName} +

    + {cta && ( + + )} +
  • + ); +}; + +export const ProductCtaList = ({ + products, + format, + title, +}: { + products: EnhancedProductSummaryMap[]; + format: ArticleFormat; + title: string; +}) => { + return ( + <> + + {title} + +
      + {products.map((product) => ( + + ))} +
    + + ); +}; diff --git a/dotcom-rendering/src/components/ProductSummary.island.tsx b/dotcom-rendering/src/components/ProductSummary.island.tsx index c423fce08f3..ca5212fef65 100644 --- a/dotcom-rendering/src/components/ProductSummary.island.tsx +++ b/dotcom-rendering/src/components/ProductSummary.island.tsx @@ -4,6 +4,7 @@ import type { ProductSummaryDisplayType, } from '../types/content'; import { Island } from './Island'; +import { ProductCtaList } from './ProductCtaList'; import { ScrollableProduct } from './ScrollableProduct.island'; import { StackedProducts } from './StackedProducts.island'; @@ -52,6 +53,12 @@ export const ProductSummary = ({ ); case 'CtaList': - return <>ToDo; + return ( + + ); } }; From 2c9e57d798c3dd9bf34ca323c3c7cdff1be20dc0 Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Fri, 19 Jun 2026 09:57:34 +0100 Subject: [PATCH 6/8] enhance summary after h2s and try get product link CTA override --- .../src/components/HorizontalSummaryProductCard.tsx | 3 ++- dotcom-rendering/src/components/ProductCarouselCard.tsx | 3 ++- dotcom-rendering/src/components/ProductCtaList.tsx | 3 ++- dotcom-rendering/src/lib/affiliateLinksUtils.ts | 5 +++++ dotcom-rendering/src/model/enhanceBlocks.ts | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx index fda20bfd508..da78d8a6d00 100644 --- a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx +++ b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx @@ -8,6 +8,7 @@ import { textSansBold17, } from '@guardian/source/foundations'; import { Link } from '@guardian/source/react-components'; +import { getProductLinkLabel } from '../lib/affiliateLinksUtils'; import type { ArticleFormat } from '../lib/articleFormat'; import { palette } from '../palette'; import type { EnhancedProductSummaryMap } from '../types/content'; @@ -125,7 +126,7 @@ export const HorizontalSummaryProductCard = ({ xCustComponentId="horizontal-summary-card" fullwidth={true} minimisePadding={true} - label={'Buy at ' + cardCta.retailer} + label={getProductLinkLabel(cardCta)} url={cardCta.url} />
    diff --git a/dotcom-rendering/src/components/ProductCarouselCard.tsx b/dotcom-rendering/src/components/ProductCarouselCard.tsx index fb4dccb13a0..744e543e81c 100644 --- a/dotcom-rendering/src/components/ProductCarouselCard.tsx +++ b/dotcom-rendering/src/components/ProductCarouselCard.tsx @@ -9,6 +9,7 @@ import { textSansBold17, } from '@guardian/source/foundations'; import { Link } from '@guardian/source/react-components'; +import { getProductLinkLabel } from '../lib/affiliateLinksUtils'; import type { ArticleFormat } from '../lib/articleFormat'; import { palette } from '../palette'; import type { EnhancedProductSummaryMap } from '../types/content'; @@ -144,7 +145,7 @@ export const ProductCarouselCard = ({
    { {cta && ( diff --git a/dotcom-rendering/src/lib/affiliateLinksUtils.ts b/dotcom-rendering/src/lib/affiliateLinksUtils.ts index bbe31e11f46..68fe364992b 100644 --- a/dotcom-rendering/src/lib/affiliateLinksUtils.ts +++ b/dotcom-rendering/src/lib/affiliateLinksUtils.ts @@ -1,4 +1,5 @@ import type { ABParticipations } from '../experiments/lib/ab-tests'; +import type { ProductCta } from '../types/content'; export const SKIMLINK_REL = 'sponsored noreferrer noopener'; @@ -137,3 +138,7 @@ export const buildXcustParamForAffiliateLink = ({ xcustComponentId, }); }; + +export const getProductLinkLabel = (cardCta: ProductCta): string => { + return cardCta.text !== '' ? cardCta.text : `Buy at ${cardCta.retailer}`; +}; diff --git a/dotcom-rendering/src/model/enhanceBlocks.ts b/dotcom-rendering/src/model/enhanceBlocks.ts index 31dd768dbc0..7ccf6d79448 100644 --- a/dotcom-rendering/src/model/enhanceBlocks.ts +++ b/dotcom-rendering/src/model/enhanceBlocks.ts @@ -79,9 +79,9 @@ export const enhanceElements = enhanceProductElement( enhanceElements(format, blockId, options, true), ), - enhanceProductSummary, enhanceDividers, enhanceH2s, + enhanceProductSummary, enhanceInteractiveAtomElements(format), enhanceInteractiveContentsElements, enhanceBlockquotes(format), From 072a773b33de1a89a356a8992c4f9bbb7638982c Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Mon, 22 Jun 2026 11:43:16 +0100 Subject: [PATCH 7/8] refactor: rename to SummaryProduct(Ref) --- .../fixtures/manual/productBlockElement.ts | 4 +- .../HorizontalSummaryProductCard.tsx | 4 +- .../src/components/ProductCarouselCard.tsx | 4 +- .../src/components/ProductCtaList.tsx | 6 +-- .../src/components/ProductSummary.island.tsx | 4 +- .../components/ScrollableProduct.island.tsx | 40 +++++++++---------- .../src/components/StackedProducts.island.tsx | 6 +-- .../src/frontend/schemas/feArticle.json | 8 ++-- dotcom-rendering/src/model/block-schema.json | 8 ++-- .../enhance-product-summary.test-helpers.ts | 4 +- .../src/model/enhance-product-summary.ts | 10 ++--- dotcom-rendering/src/types/content.ts | 8 ++-- 12 files changed, 52 insertions(+), 54 deletions(-) diff --git a/dotcom-rendering/fixtures/manual/productBlockElement.ts b/dotcom-rendering/fixtures/manual/productBlockElement.ts index 664c440ddd3..e06faf6b76c 100644 --- a/dotcom-rendering/fixtures/manual/productBlockElement.ts +++ b/dotcom-rendering/fixtures/manual/productBlockElement.ts @@ -1,7 +1,7 @@ import { extractHeadingText } from '../../src/model/enhanceProductElement'; import type { - EnhancedProductSummaryMap, ProductBlockElement, + SummaryProduct, } from '../../src/types/content'; import { productImage } from './productImage'; @@ -533,7 +533,7 @@ const exampleAtAGlanceProductArray: ProductBlockElement[] = [ }, ]; -export const exampleSummaryProducts: EnhancedProductSummaryMap[] = +export const exampleSummaryProducts: SummaryProduct[] = exampleAtAGlanceProductArray.map((p) => ({ productBlock: p, ctaIndex: 0, diff --git a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx index da78d8a6d00..5245413cefa 100644 --- a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx +++ b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx @@ -11,7 +11,7 @@ import { Link } from '@guardian/source/react-components'; import { getProductLinkLabel } from '../lib/affiliateLinksUtils'; import type { ArticleFormat } from '../lib/articleFormat'; import { palette } from '../palette'; -import type { EnhancedProductSummaryMap } from '../types/content'; +import type { SummaryProduct } from '../types/content'; import { ProductLinkButton } from './Button/ProductLinkButton'; import { ProductCardImage } from './ProductCardImage'; @@ -82,7 +82,7 @@ export const HorizontalSummaryProductCard = ({ product, format, }: { - product: EnhancedProductSummaryMap; + product: SummaryProduct; format: ArticleFormat; }) => { const { productBlock, ctaIndex } = product; diff --git a/dotcom-rendering/src/components/ProductCarouselCard.tsx b/dotcom-rendering/src/components/ProductCarouselCard.tsx index 744e543e81c..d07ff9707e1 100644 --- a/dotcom-rendering/src/components/ProductCarouselCard.tsx +++ b/dotcom-rendering/src/components/ProductCarouselCard.tsx @@ -12,12 +12,12 @@ import { Link } from '@guardian/source/react-components'; import { getProductLinkLabel } from '../lib/affiliateLinksUtils'; import type { ArticleFormat } from '../lib/articleFormat'; import { palette } from '../palette'; -import type { EnhancedProductSummaryMap } from '../types/content'; +import type { SummaryProduct } from '../types/content'; import { ProductLinkButton } from './Button/ProductLinkButton'; import { ProductCardImage } from './ProductCardImage'; export type ProductCarouselCardProps = { - product: EnhancedProductSummaryMap; + product: SummaryProduct; format: ArticleFormat; }; diff --git a/dotcom-rendering/src/components/ProductCtaList.tsx b/dotcom-rendering/src/components/ProductCtaList.tsx index ed377ca241a..a9b50d9be29 100644 --- a/dotcom-rendering/src/components/ProductCtaList.tsx +++ b/dotcom-rendering/src/components/ProductCtaList.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/react'; import { article17, palette, remSpace } from '@guardian/source/foundations'; import { getProductLinkLabel } from '../lib/affiliateLinksUtils'; import type { ArticleFormat } from '../lib/articleFormat'; -import type { EnhancedProductSummaryMap } from '../types/content'; +import type { SummaryProduct } from '../types/content'; import { ProductLinkButton } from './Button/ProductLinkButton'; import { Subheading } from './Subheading'; @@ -39,7 +39,7 @@ const listStyles = css` } `; -const ListItem = ({ product }: { product: EnhancedProductSummaryMap }) => { +const ListItem = ({ product }: { product: SummaryProduct }) => { const { productBlock, ctaIndex } = product; const cta = productBlock.productCtas[ctaIndex]; return ( @@ -66,7 +66,7 @@ export const ProductCtaList = ({ format, title, }: { - products: EnhancedProductSummaryMap[]; + products: SummaryProduct[]; format: ArticleFormat; title: string; }) => { diff --git a/dotcom-rendering/src/components/ProductSummary.island.tsx b/dotcom-rendering/src/components/ProductSummary.island.tsx index ca5212fef65..7d70943e689 100644 --- a/dotcom-rendering/src/components/ProductSummary.island.tsx +++ b/dotcom-rendering/src/components/ProductSummary.island.tsx @@ -1,7 +1,7 @@ import type { ArticleFormat } from '../lib/articleFormat'; import type { - EnhancedProductSummaryMap, ProductSummaryDisplayType, + SummaryProduct, } from '../types/content'; import { Island } from './Island'; import { ProductCtaList } from './ProductCtaList'; @@ -15,7 +15,7 @@ export const ProductSummary = ({ displayType, }: { title: string; - products: EnhancedProductSummaryMap[]; + products: SummaryProduct[]; format: ArticleFormat; displayType: ProductSummaryDisplayType; }) => { diff --git a/dotcom-rendering/src/components/ScrollableProduct.island.tsx b/dotcom-rendering/src/components/ScrollableProduct.island.tsx index 5cb002db4ca..97cbab3f3fe 100644 --- a/dotcom-rendering/src/components/ScrollableProduct.island.tsx +++ b/dotcom-rendering/src/components/ScrollableProduct.island.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import { nestedOphanComponents } from '../lib/ophan-helpers'; import { palette } from '../palette'; -import type { EnhancedProductSummaryMap } from '../types/content'; +import type { SummaryProduct } from '../types/content'; import { CarouselCount } from './CarouselCount'; import { CarouselNavigationButtons } from './CarouselNavigationButtons'; import { ProductCarouselCard } from './ProductCarouselCard'; @@ -40,7 +40,7 @@ export type FixedSlideWidth = { type Props = { title: string; - products: EnhancedProductSummaryMap[]; + products: SummaryProduct[]; format: ArticleFormat; }; @@ -300,25 +300,23 @@ export const ScrollableProduct = ({ title, products, format }: Props) => { css={carouselStyles} data-heatphan-type="carousel" > - {products.map( - (product: EnhancedProductSummaryMap, index: number) => ( -
  • - -
  • - ), - )} + {products.map((product: SummaryProduct, index: number) => ( +
  • + +
  • + ))} - {products.map((product: EnhancedProductSummaryMap, index) => ( + {products.map((product: SummaryProduct, index) => (
    ({ _type: 'model.dotcomrendering.pageElements.ProductSummaryElement', diff --git a/dotcom-rendering/src/model/enhance-product-summary.ts b/dotcom-rendering/src/model/enhance-product-summary.ts index af4c07c785c..c0ecf6d4c4f 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.ts @@ -1,14 +1,14 @@ import type { - EnhancedProductSummaryMap, FEElement, - ProductSummaryMap, + SummaryProduct, + SummaryProductRef, } from '../types/content'; const getSummaryProducts = ( pageElements: FEElement[], - summaryProducts: ProductSummaryMap[], -): EnhancedProductSummaryMap[] => - pageElements.reduce((acc, element) => { + summaryProducts: SummaryProductRef[], +): SummaryProduct[] => + pageElements.reduce((acc, element) => { if ( element._type !== 'model.dotcomrendering.pageElements.ProductBlockElement' diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 87cd350b08a..e84260425d5 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -525,26 +525,26 @@ export interface ProductBlockElement { lowestPrice?: string; } -export interface ProductSummaryMap { +export interface SummaryProductRef { productId: string; ctaIndex: number; } -export interface EnhancedProductSummaryMap { +export interface SummaryProduct { productBlock: ProductBlockElement; ctaIndex: number; } export interface ProductSummaryElement { _type: 'model.dotcomrendering.pageElements.ProductSummaryElement'; - products: ProductSummaryMap[]; + products: SummaryProductRef[]; displayType: ProductSummaryDisplayType; title: string; } export interface EnhancedProductSummaryElement { _type: 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement'; - products: EnhancedProductSummaryMap[]; + products: SummaryProduct[]; displayType: ProductSummaryDisplayType; title: string; } From f396f01929ea2a64ac5dce7b7e0ba58d4d235c85 Mon Sep 17 00:00:00 2001 From: Andrew Howe-Ely Date: Mon, 22 Jun 2026 11:51:33 +0100 Subject: [PATCH 8/8] fix schema merge conflict --- .../src/frontend/schemas/feArticle.json | 88 ++++++++++++++++--- dotcom-rendering/src/model/block-schema.json | 88 ++++++++++++++++--- 2 files changed, 156 insertions(+), 20 deletions(-) diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 3c7cdf9ab7d..1f6b61f09e2 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -894,6 +894,9 @@ { "$ref": "#/definitions/ProductSummaryElement" }, + { + "$ref": "#/definitions/EnhancedProductSummaryElement" + }, { "$ref": "#/definitions/RecipeBlockElement" } @@ -4799,25 +4802,90 @@ "type": "string", "const": "model.dotcomrendering.pageElements.ProductSummaryElement" }, - "matchedProducts": { + "products": { "type": "array", "items": { - "$ref": "#/definitions/ProductBlockElement" + "$ref": "#/definitions/SummaryProductRef" } }, - "variant": { - "enum": [ - "carousel", - "stacked-default", - "stacked-expanded" - ], + "displayType": { + "$ref": "#/definitions/ProductSummaryDisplayType" + }, + "title": { + "type": "string" + } + }, + "required": [ + "_type", + "displayType", + "products", + "title" + ] + }, + "SummaryProductRef": { + "type": "object", + "properties": { + "productId": { + "type": "string" + }, + "ctaIndex": { + "type": "number" + } + }, + "required": [ + "ctaIndex", + "productId" + ] + }, + "ProductSummaryDisplayType": { + "enum": [ + "Carousel", + "CtaList", + "StackedCard", + "StackedCardExpanded" + ], + "type": "string" + }, + "EnhancedProductSummaryElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.EnhancedProductSummaryElement" + }, + "products": { + "type": "array", + "items": { + "$ref": "#/definitions/SummaryProduct" + } + }, + "displayType": { + "$ref": "#/definitions/ProductSummaryDisplayType" + }, + "title": { "type": "string" } }, "required": [ "_type", - "matchedProducts", - "variant" + "displayType", + "products", + "title" + ] + }, + "SummaryProduct": { + "type": "object", + "properties": { + "productBlock": { + "$ref": "#/definitions/ProductBlockElement" + }, + "ctaIndex": { + "type": "number" + } + }, + "required": [ + "ctaIndex", + "productBlock" ] }, "RecipeBlockElement": { diff --git a/dotcom-rendering/src/model/block-schema.json b/dotcom-rendering/src/model/block-schema.json index 230ae08536f..3fdf5c6c615 100644 --- a/dotcom-rendering/src/model/block-schema.json +++ b/dotcom-rendering/src/model/block-schema.json @@ -371,6 +371,9 @@ { "$ref": "#/definitions/ProductSummaryElement" }, + { + "$ref": "#/definitions/EnhancedProductSummaryElement" + }, { "$ref": "#/definitions/RecipeBlockElement" } @@ -4276,25 +4279,90 @@ "type": "string", "const": "model.dotcomrendering.pageElements.ProductSummaryElement" }, - "matchedProducts": { + "products": { "type": "array", "items": { - "$ref": "#/definitions/ProductBlockElement" + "$ref": "#/definitions/SummaryProductRef" } }, - "variant": { - "enum": [ - "carousel", - "stacked-default", - "stacked-expanded" - ], + "displayType": { + "$ref": "#/definitions/ProductSummaryDisplayType" + }, + "title": { + "type": "string" + } + }, + "required": [ + "_type", + "displayType", + "products", + "title" + ] + }, + "SummaryProductRef": { + "type": "object", + "properties": { + "productId": { + "type": "string" + }, + "ctaIndex": { + "type": "number" + } + }, + "required": [ + "ctaIndex", + "productId" + ] + }, + "ProductSummaryDisplayType": { + "enum": [ + "Carousel", + "CtaList", + "StackedCard", + "StackedCardExpanded" + ], + "type": "string" + }, + "EnhancedProductSummaryElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.EnhancedProductSummaryElement" + }, + "products": { + "type": "array", + "items": { + "$ref": "#/definitions/SummaryProduct" + } + }, + "displayType": { + "$ref": "#/definitions/ProductSummaryDisplayType" + }, + "title": { "type": "string" } }, "required": [ "_type", - "matchedProducts", - "variant" + "displayType", + "products", + "title" + ] + }, + "SummaryProduct": { + "type": "object", + "properties": { + "productBlock": { + "$ref": "#/definitions/ProductBlockElement" + }, + "ctaIndex": { + "type": "number" + } + }, + "required": [ + "ctaIndex", + "productBlock" ] }, "RecipeBlockElement": {