diff --git a/dotcom-rendering/fixtures/manual/productBlockElement.ts b/dotcom-rendering/fixtures/manual/productBlockElement.ts index 3f15a360496..09356182ed5 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 { + ProductBlockElement, + SummaryProduct, +} from '../../src/types/content'; import { productImage } from './productImage'; export const exampleProduct: ProductBlockElement = { @@ -247,7 +250,7 @@ export const exampleProduct: ProductBlockElement = { ], }; -export const exampleAtAGlanceProductArray: ProductBlockElement[] = [ +const exampleAtAGlanceProductArray: ProductBlockElement[] = [ { _type: 'model.dotcomrendering.pageElements.ProductBlockElement', elementId: 'b85ec38b-091b-40c2-8902-a9114df3cfe3', @@ -531,3 +534,9 @@ export const exampleAtAGlanceProductArray: ProductBlockElement[] = [ id: '098', }, ]; + +export const exampleSummaryProducts: SummaryProduct[] = + 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..5245413cefa 100644 --- a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx +++ b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx @@ -8,9 +8,10 @@ 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 { ProductBlockElement } from '../types/content'; +import type { SummaryProduct } from '../types/content'; import { ProductLinkButton } from './Button/ProductLinkButton'; import { ProductCardImage } from './ProductCardImage'; @@ -81,10 +82,11 @@ export const HorizontalSummaryProductCard = ({ product, format, }: { - product: ProductBlockElement; + product: SummaryProduct; format: ArticleFormat; }) => { - const cardCta = product.productCtas[0]; + const { productBlock, ctaIndex } = product; + const cardCta = productBlock.productCtas[ctaIndex]; if (!cardCta) { return null; } @@ -95,15 +97,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" @@ -120,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.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..d07ff9707e1 100644 --- a/dotcom-rendering/src/components/ProductCarouselCard.tsx +++ b/dotcom-rendering/src/components/ProductCarouselCard.tsx @@ -9,14 +9,15 @@ 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 { ProductBlockElement } from '../types/content'; +import type { SummaryProduct } from '../types/content'; import { ProductLinkButton } from './Button/ProductLinkButton'; import { ProductCardImage } from './ProductCardImage'; export type ProductCarouselCardProps = { - product: ProductBlockElement; + product: SummaryProduct; format: ArticleFormat; }; @@ -83,10 +84,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 +101,7 @@ export const ProductCarouselCard = ({ {hasHeading && ( <>
- {product.primaryHeadingText} + {productBlock.primaryHeadingText}
{productAndBrandName} @@ -106,7 +112,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/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..a9b50d9be29 --- /dev/null +++ b/dotcom-rendering/src/components/ProductCtaList.tsx @@ -0,0 +1,88 @@ +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 { SummaryProduct } 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: SummaryProduct }) => { + const { productBlock, ctaIndex } = product; + const cta = productBlock.productCtas[ctaIndex]; + return ( +
  • +

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

    + {cta && ( + + )} +
  • + ); +}; + +export const ProductCtaList = ({ + products, + format, + title, +}: { + products: SummaryProduct[]; + 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 d72dd4af825..7d70943e689 100644 --- a/dotcom-rendering/src/components/ProductSummary.island.tsx +++ b/dotcom-rendering/src/components/ProductSummary.island.tsx @@ -1,48 +1,64 @@ import type { ArticleFormat } from '../lib/articleFormat'; -import type { ABTestVariant } from '../model/enhance-product-summary'; -import type { ProductBlockElement } from '../types/content'; +import type { + ProductSummaryDisplayType, + SummaryProduct, +} from '../types/content'; import { Island } from './Island'; +import { ProductCtaList } from './ProductCtaList'; import { ScrollableProduct } from './ScrollableProduct.island'; import { StackedProducts } from './StackedProducts.island'; export const ProductSummary = ({ + title, products, format, - variant, + displayType, }: { - products: ProductBlockElement[]; + title: string; + products: SummaryProduct[]; format: ArticleFormat; - variant: ABTestVariant; + displayType: ProductSummaryDisplayType; }) => { - if (variant === 'carousel') { - return ( - - - - ); - } - - if (variant === 'stacked-default') { - return ( - - + + + ); + case 'StackedCard': + return ( + + + + ); + case 'StackedCardExpanded': + return ( + + + + ); + case 'CtaList': + return ( + - - ); + ); } - - return ( - - - - ); }; diff --git a/dotcom-rendering/src/components/ScrollableProduct.island.tsx b/dotcom-rendering/src/components/ScrollableProduct.island.tsx index 7fe38e8c373..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 { ProductBlockElement } from '../types/content'; +import type { SummaryProduct } from '../types/content'; import { CarouselCount } from './CarouselCount'; import { CarouselNavigationButtons } from './CarouselNavigationButtons'; import { ProductCarouselCard } from './ProductCarouselCard'; @@ -39,7 +39,8 @@ export type FixedSlideWidth = { }; type Props = { - products: ProductBlockElement[]; + title: string; + products: SummaryProduct[]; 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}
    { css={carouselStyles} data-heatphan-type="carousel" > - {products.map( - (product: ProductBlockElement, index: number) => ( -
  • - -
  • - ), - )} + {products.map((product: SummaryProduct, 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 aff435c4753..a929b45a887 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 { SummaryProduct } from '../types/content'; import { HorizontalSummaryProductCard } from './HorizontalSummaryProductCard'; import { Subheading } from './Subheading'; @@ -31,12 +31,12 @@ export const theme: Partial = { export const StackedProducts = ({ products, - heading, + title, format, showAllProducts, }: { - products: ProductBlockElement[]; - heading: string; + products: SummaryProduct[]; + title: string; format: ArticleFormat; showAllProducts: boolean; }) => { @@ -54,7 +54,7 @@ export const StackedProducts = ({ ]} > - {heading} + {title} {products.length > cardsShownByDefault && !showAllProducts && (

    @@ -78,9 +78,12 @@ export const StackedProducts = ({ `, ]} > - {products.map((product: ProductBlockElement, index) => ( + {products.map((product: SummaryProduct, index) => (

    { + return cardCta.text !== '' ? cardCta.text : `Buy at ${cardCta.retailer}`; +}; 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 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": { 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..50aa4446b78 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,34 @@ import type { + EnhancedProductSummaryElement, FEElement, ProductBlockElement, ProductSummaryElement, + SummaryProductRef, } from '../types/content'; -export const linkElement = (url: string, label: string): FEElement => +export const productSummaryElement = ( + summaryProducts: SummaryProductRef[], +): ProductSummaryElement => ({ - _type: 'model.dotcomrendering.pageElements.LinkBlockElement', - url, - label, - }) as FEElement; - -export const productElement = (urls: string[]): ProductBlockElement => + _type: 'model.dotcomrendering.pageElements.ProductSummaryElement', + products: summaryProducts, + }) 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..c42fe32c220 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.test.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.test.ts @@ -1,413 +1,87 @@ -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'; - + 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', - ), - 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', + 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( + selectedIds.map((id) => ({ productId: id, ctaIndex: 0 })), ), - 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 output = enhanceProductSummary(input); - const carousel = findCarousel(output); + const enhancedProductSummaryElement = + findEnhancedProductSummary(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', + expect(enhancedProductSummaryElement?.products).toHaveLength(2); + expect( + enhancedProductSummaryElement?.products.map( + (mapping) => mapping.productBlock.id, ), - 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(); + ).toEqual(selectedIds); }); - 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', - ]), + it('enhances product summary elements with the correct CTA indices', () => { + const summaryProducts = [ + { productId: '1', ctaIndex: 0 }, + { productId: '3', ctaIndex: 1 }, ]; - - 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', - ]), + 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({ - pageId: allowedPageId, - serverSideABTests: { - 'thefilter-at-a-glance-redesign-v2': 'stacked-default', - }, - renderingTarget: 'Apps', - filterAtAGlanceEnabled: true, - })(input); - - const stackedDefault = findStackedDefault(output); - - expect(stackedDefault).toBeUndefined(); - }); + const output = enhanceProductSummary(input); - it('does not return stacked cards when the filterAtAGlance switch is OFF', () => { - const allowedPageId = - 'thefilter/test-article-example-for-product-summary'; + const enhancedProductSummaryElement = + findEnhancedProductSummary(output); - 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', + expect(enhancedProductSummaryElement?.products).toHaveLength(2); + expect( + enhancedProductSummaryElement?.products.map( + (mapping) => mapping.ctaIndex, ), - 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: false, - })(input); - - const stackedDefault = findStackedDefault(output); - - expect(stackedDefault).toBeUndefined(); + ).toEqual([0, 1]); }); }); diff --git a/dotcom-rendering/src/model/enhance-product-summary.ts b/dotcom-rendering/src/model/enhance-product-summary.ts index 1b06979c40d..c0ecf6d4c4f 100644 --- a/dotcom-rendering/src/model/enhance-product-summary.ts +++ b/dotcom-rendering/src/model/enhance-product-summary.ts @@ -1,243 +1,49 @@ -import type { FEElement, ProductBlockElement } from '../types/content'; -import type { RenderingTarget } from '../types/renderingTarget'; -import { generateId } from './enhance-H2s'; +import type { + FEElement, + SummaryProduct, + SummaryProductRef, +} from '../types/content'; -/** - * 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' - ); -}; - -// 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 getSummaryProducts = ( 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 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() !== ''), - ), - ); - -// 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; - } - - output.push(element); - continue; + summaryProducts: SummaryProductRef[], +): SummaryProduct[] => + pageElements.reduce((acc, element) => { + if ( + element._type !== + 'model.dotcomrendering.pageElements.ProductBlockElement' + ) { + return acc; } - // 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); + const matchingSummaryProduct = summaryProducts.find( + (summaryProduct) => summaryProduct.productId === element.id, + ); - // 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; + if (!matchingSummaryProduct) { + return acc; } - 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']; + acc.push({ + productBlock: element, + ctaIndex: matchingSummaryProduct.ctaIndex, + }); + + return acc; + }, []); + +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: getSummaryProducts(elements, element.products), + }; + } - // 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); + default: + return element; } - - 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..7ccf6d79448 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'; @@ -80,6 +81,7 @@ export const enhanceElements = ), enhanceDividers, enhanceH2s, + enhanceProductSummary, enhanceInteractiveAtomElements(format), enhanceInteractiveContentsElements, enhanceBlockquotes(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 6c5f9bd858d..969ea3a6339 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -526,10 +526,28 @@ export interface ProductBlockElement { lowestPrice?: string; } +export interface SummaryProductRef { + productId: string; + ctaIndex: number; +} + +export interface SummaryProduct { + productBlock: ProductBlockElement; + ctaIndex: number; +} + export interface ProductSummaryElement { _type: 'model.dotcomrendering.pageElements.ProductSummaryElement'; - matchedProducts: ProductBlockElement[]; - variant: 'carousel' | 'stacked-default' | 'stacked-expanded'; + products: SummaryProductRef[]; + displayType: ProductSummaryDisplayType; + title: string; +} + +export interface EnhancedProductSummaryElement { + _type: 'model.dotcomrendering.pageElements.EnhancedProductSummaryElement'; + products: SummaryProduct[]; + displayType: ProductSummaryDisplayType; + title: string; } export interface RecipeFeaturedImage { @@ -925,6 +943,7 @@ export type FEElement = | CrosswordElement | ProductBlockElement | ProductSummaryElement + | EnhancedProductSummaryElement | RecipeBlockElement; // ------------------------------------- @@ -983,6 +1002,12 @@ export type ProductStarRating = | '5' | 'none-selected'; +export type ProductSummaryDisplayType = + | 'Carousel' + | 'CtaList' + | 'StackedCard' + | 'StackedCardExpanded'; + export interface SrcSetItem { src: string; width: number;