@@ -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;