From 29440ced6f0c2c60308f6eb236da507c5a69c73f Mon Sep 17 00:00:00 2001 From: Rob Phillips Date: Thu, 9 Jul 2026 11:31:54 +0100 Subject: [PATCH 1/6] Product card images now have the lightbox option. --- .../HorizontalSummaryProductCard.tsx | 1 + .../src/components/LightboxLink.tsx | 7 +- .../src/components/ProductCardImage.tsx | 67 ++++++--- .../components/ProductCardInline.stories.tsx | 1 + .../src/components/ProductCardInline.tsx | 3 + .../components/ProductCardLeftCol.stories.tsx | 1 + .../src/components/ProductCardLeftCol.tsx | 3 + .../src/components/ProductCarouselCard.tsx | 1 + .../src/components/ProductElement.tsx | 2 + .../src/model/buildLightboxImages.test.ts | 129 ++++++++++++++++++ .../src/model/buildLightboxImages.ts | 60 ++++++++ .../src/model/enhance-images.test.ts | 112 ++++++++++++++- dotcom-rendering/src/model/enhance-images.ts | 23 ++++ dotcom-rendering/src/types/content.ts | 1 + 14 files changed, 387 insertions(+), 24 deletions(-) create mode 100644 dotcom-rendering/src/model/buildLightboxImages.test.ts diff --git a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx index 6c1a38e8b13..f715bdea1c6 100644 --- a/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx +++ b/dotcom-rendering/src/components/HorizontalSummaryProductCard.tsx @@ -95,6 +95,7 @@ export const HorizontalSummaryProductCard = ({ diff --git a/dotcom-rendering/src/components/LightboxLink.tsx b/dotcom-rendering/src/components/LightboxLink.tsx index fc0ee92e704..3f098481dee 100644 --- a/dotcom-rendering/src/components/LightboxLink.tsx +++ b/dotcom-rendering/src/components/LightboxLink.tsx @@ -15,13 +15,13 @@ import type { RoleType } from '../types/content'; type Props = { elementId: string; - role: RoleType; + role: RoleType | 'productCard'; format: ArticleFormat; isMainMedia?: boolean; position: number; }; -function decideSize(role: RoleType, format: ArticleFormat) { +function decideSize(role: RoleType | 'productCard', format: ArticleFormat) { const smallStyles = css` height: 32px; width: 32px; @@ -47,7 +47,8 @@ function decideSize(role: RoleType, format: ArticleFormat) { default: { switch (role) { case 'halfWidth': - case 'supporting': { + case 'supporting': + case 'productCard': { return smallStyles; } case 'inline': diff --git a/dotcom-rendering/src/components/ProductCardImage.tsx b/dotcom-rendering/src/components/ProductCardImage.tsx index b0b1c6607f5..f2c295c6824 100644 --- a/dotcom-rendering/src/components/ProductCardImage.tsx +++ b/dotcom-rendering/src/components/ProductCardImage.tsx @@ -1,12 +1,16 @@ import { css } from '@emotion/react'; +import { isUndefined } from '@guardian/libs'; import type { HTMLAttributes } from 'react'; import type { ArticleFormat } from '../lib/articleFormat'; import type { ProductImage } from '../types/content'; import { Caption } from './Caption'; +import { useConfig } from './ConfigContext'; +import { LightboxLink } from './LightboxLink'; import { Picture } from './Picture'; interface ProductCardImageProps extends HTMLAttributes { format: ArticleFormat; + elementId: string; image?: ProductImage; url?: string; xCustComponentId?: string; @@ -14,14 +18,22 @@ interface ProductCardImageProps extends HTMLAttributes { export const ProductCardImage = ({ format, + elementId, image, url, xCustComponentId, }: ProductCardImageProps) => { + const { renderingTarget } = useConfig(); + if (!image) { return null; } + const isWeb = renderingTarget === 'Web'; + // The whole image is already a link to the retailer when a CTA url exists, + // so we only add the lightbox affordance when there isn't a competing link. + const lightboxPosition = isWeb && !url ? image.position : undefined; + const ProductPicture = () => ( - {url ? ( - +
+ {url ? ( + + + + ) : ( - - ) : ( - - )} + )} + {!isUndefined(lightboxPosition) && ( + + )} +
( export const ProductCardInline = ({ format, + elementId, brandName, productName, image, @@ -194,6 +196,7 @@ export const ProductCardInline = ({
( ); export const ProductCardLeftCol = ({ + elementId, brandName, productName, image, @@ -100,6 +102,7 @@ export const ProductCardLeftCol = ({
diff --git a/dotcom-rendering/src/components/ProductElement.tsx b/dotcom-rendering/src/components/ProductElement.tsx index 6843b63c3ea..f16cf668b2a 100644 --- a/dotcom-rendering/src/components/ProductElement.tsx +++ b/dotcom-rendering/src/components/ProductElement.tsx @@ -57,6 +57,7 @@ export const ProductElement = ({ {showProductCard && ( ({ + id: 'block-1', + elements, + attributes: emptyAttributes, + primaryDateLine: '', + secondaryDateLine: '', +}); + +describe('buildLightboxImages', () => { + it("includes a product's own image when it is large enough", () => { + const product: ProductBlockElement = { + ...baseProduct, + image: largeProductImage, + }; + + const result = buildLightboxImages(format, [buildBlock([product])], []); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + masterUrl: largeProductImage.url, + elementId: product.elementId, + width: largeProductImage.width, + height: largeProductImage.height, + position: 1, + }); + }); + + it("excludes a product's own image when it is too small", () => { + const product: ProductBlockElement = { + ...baseProduct, + image: smallProductImage, + }; + + const result = buildLightboxImages(format, [buildBlock([product])], []); + + expect(result).toEqual([]); + }); + + it('excludes a product with no image', () => { + const result = buildLightboxImages( + format, + [buildBlock([baseProduct])], + [], + ); + + expect(result).toEqual([]); + }); + + it("includes images nested inside a product's content", () => { + const product: ProductBlockElement = { + ...baseProduct, + content: [largeImage], + }; + + const result = buildLightboxImages(format, [buildBlock([product])], []); + + expect(result).toHaveLength(1); + expect(result[0]?.elementId).toEqual(largeImage.elementId); + }); + + it('assigns positions in document order across regular and product images', () => { + const product: ProductBlockElement = { + ...baseProduct, + elementId: 'product-2', + image: largeProductImage, + content: [largeImage], + }; + + const result = buildLightboxImages(format, [buildBlock([product])], []); + + expect(result.map((image) => image.elementId)).toEqual([ + largeImage.elementId, + product.elementId, + ]); + expect(result.map((image) => image.position)).toEqual([1, 2]); + }); +}); diff --git a/dotcom-rendering/src/model/buildLightboxImages.ts b/dotcom-rendering/src/model/buildLightboxImages.ts index f742f5cd756..dbcf0ea48ab 100644 --- a/dotcom-rendering/src/model/buildLightboxImages.ts +++ b/dotcom-rendering/src/model/buildLightboxImages.ts @@ -7,6 +7,7 @@ import type { FEElement, ImageBlockElement, ImageForLightbox, + ProductBlockElement, } from '../types/content'; import { getCartoonImageForLightbox, @@ -92,12 +93,58 @@ const getImages = ( event.body.flatMap(getImages), ), ); + case 'model.dotcomrendering.pageElements.ProductBlockElement': + return element.content.flatMap(getImages); default: return []; } }; +/** + * Finds every ProductBlockElement reachable from an element, including ones + * nested inside lists, timelines, or another product's content. + */ +const getProducts = (element: FEElement): ProductBlockElement[] => { + switch (element._type) { + case 'model.dotcomrendering.pageElements.ProductBlockElement': + return [element, ...element.content.flatMap(getProducts)]; + case 'model.dotcomrendering.pageElements.ListBlockElement': + return element.items.flatMap((item) => + item.elements.flatMap(getProducts), + ); + case 'model.dotcomrendering.pageElements.TimelineBlockElement': + return element.sections.flatMap((section) => + section.events.flatMap((event) => + event.body.flatMap(getProducts), + ), + ); + + default: + return []; + } +}; + +const buildProductLightboxImage = ( + element: ProductBlockElement, +): Omit | undefined => { + const { image } = element; + if (!image) return; + + if (!isLightboxable(image.width, image.height)) return; + + return { + masterUrl: image.url, + width: image.width, + height: image.height, + elementId: element.elementId, + displayCredit: image.displayCredit, + alt: image.alt, + credit: image.credit, + caption: image.caption, + }; +}; + /** * Reads the elements array and returns a new array of data based on the * ImageBlockElement elements that it finds. It does not mutate. This new @@ -142,4 +189,17 @@ export const buildLightboxImages = ( ), ), ) + .concat( + blocks.flatMap((block) => + block.elements.flatMap((element) => + getProducts(element).flatMap((product) => { + const lightboxImage = + buildProductLightboxImage(product); + return isUndefined(lightboxImage) + ? [] + : [lightboxImage]; + }), + ), + ), + ) .map((image, index) => ({ ...image, position: index + 1 })); diff --git a/dotcom-rendering/src/model/enhance-images.test.ts b/dotcom-rendering/src/model/enhance-images.test.ts index fff943dcdae..d5c7949e129 100644 --- a/dotcom-rendering/src/model/enhance-images.test.ts +++ b/dotcom-rendering/src/model/enhance-images.test.ts @@ -2,7 +2,11 @@ import { PhotoEssay } from '../../fixtures/generated/fe-articles/PhotoEssay'; import { Standard as ExampleArticle } from '../../fixtures/generated/fe-articles/Standard'; import { images } from '../../fixtures/generated/images'; import { decideFormat, Pillar } from '../lib/articleFormat'; -import type { FEElement } from '../types/content'; +import type { + FEElement, + ProductBlockElement, + ProductImage, +} from '../types/content'; import { enhanceElementsImages, enhanceImages } from './enhance-images'; const exampleArticleFormat = decideFormat(ExampleArticle.format); @@ -599,6 +603,112 @@ describe('Enhance Images', () => { ).toEqual(expectedOutput); }); + it("adds the lightbox position to a product's own image", () => { + const productImage: ProductImage = { + url: 'https://media.guim.co.uk/product/725.jpg', + caption: 'Product image', + credit: 'Photograph: Test/The Guardian', + alt: 'A product', + displayCredit: false, + height: 725, + width: 725, + }; + + const productElement: ProductBlockElement = { + _type: 'model.dotcomrendering.pageElements.ProductBlockElement', + elementId: 'product-1', + brandName: 'Acme', + starRating: '5', + productName: 'Widget', + image: productImage, + primaryHeadingHtml: '', + secondaryHeadingHtml: '', + customAttributes: [], + content: [], + id: '123', + displayType: 'InlineOnly', + productCtas: [], + }; + + const input: FEElement[] = [productElement]; + + const expectedOutput: FEElement[] = [ + { + ...productElement, + image: { ...productImage, position: 7 }, + }, + ]; + + expect( + enhanceImages(exampleArticleFormat, [ + { + masterUrl: productImage.url, + elementId: productElement.elementId, + width: productImage.width, + height: productImage.height, + position: 7, + }, + ])(input), + ).toEqual(expectedOutput); + }); + + it('leaves a product element unchanged when its image has no lightbox match', () => { + const productImage: ProductImage = { + url: 'https://media.guim.co.uk/product/725.jpg', + caption: 'Product image', + credit: 'Photograph: Test/The Guardian', + alt: 'A product', + displayCredit: false, + height: 725, + width: 725, + }; + + const productElement: ProductBlockElement = { + _type: 'model.dotcomrendering.pageElements.ProductBlockElement', + elementId: 'product-1', + brandName: 'Acme', + starRating: '5', + productName: 'Widget', + image: productImage, + primaryHeadingHtml: '', + secondaryHeadingHtml: '', + customAttributes: [], + content: [], + id: '123', + displayType: 'InlineOnly', + productCtas: [], + }; + + const input: FEElement[] = [productElement]; + + expect(enhanceImages(exampleArticleFormat, [])(input)).toEqual( + input, + ); + }); + + it('leaves a product element with no image unchanged', () => { + const productElement: ProductBlockElement = { + _type: 'model.dotcomrendering.pageElements.ProductBlockElement', + elementId: 'product-1', + brandName: 'Acme', + starRating: '5', + productName: 'Widget', + primaryHeadingHtml: '', + secondaryHeadingHtml: '', + customAttributes: [], + content: [], + id: '123', + displayType: 'InlineOnly', + productCtas: [], + }; + + const input: FEElement[] = [productElement]; + + expect(enhanceImages(exampleArticleFormat, [])(input)).toEqual( + input, + ); + }); + it('does not strip the captions from any preceding halfwidth images if the special caption is not placed immediately after them', () => { const input: FEElement[] = [ { ...image, role: 'halfWidth' }, diff --git a/dotcom-rendering/src/model/enhance-images.ts b/dotcom-rendering/src/model/enhance-images.ts index 44024de18db..e0376a1e75c 100644 --- a/dotcom-rendering/src/model/enhance-images.ts +++ b/dotcom-rendering/src/model/enhance-images.ts @@ -13,6 +13,7 @@ import type { ImageBlockElement, ImageForLightbox, MultiImageBlockElement, + ProductBlockElement, SubheadingBlockElement, TextBlockElement, } from '../types/content'; @@ -72,6 +73,16 @@ export const isCartoon = ( ); }; +export const isProduct = ( + element?: FEElement, +): element is ProductBlockElement => { + if (!element) return false; + return ( + element._type === + 'model.dotcomrendering.pageElements.ProductBlockElement' + ); +}; + const isTitle = (element?: FEElement): element is SubheadingBlockElement => { if (!element) return false; // Checks if this element is a 'title' based on the convention:

Title text

@@ -330,6 +341,18 @@ const addImagePositions = ( }; } + if (isProduct(element)) { + if (!element.image) return element; + + const position = imagesForLightbox.find( + ({ masterUrl }) => element.image?.url === masterUrl, + )?.position; + + return isUndefined(position) + ? element + : { ...element, image: { ...element.image, position } }; + } + if (!isImage(element) && !isCartoon(element)) { return element; } diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 6c5f9bd858d..66df45437f9 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -996,6 +996,7 @@ export interface ProductImage { displayCredit: boolean; height: number; width: number; + position?: number; } export interface Image { From c2c53f18f85ef9ab0d9b13539663e5644e57201f Mon Sep 17 00:00:00 2001 From: Rob Phillips Date: Thu, 9 Jul 2026 15:44:09 +0100 Subject: [PATCH 2/6] Links in lightbox + image caption. --- .../src/components/ArticlePage.tsx | 2 + .../components/Button/ProductLinkButton.tsx | 4 +- .../src/components/HostedContentPage.tsx | 2 + .../src/components/Lightbox.stories.tsx | 45 +++- dotcom-rendering/src/components/Lightbox.tsx | 3 + .../src/components/LightboxImages.tsx | 45 +++- .../src/components/LightboxJavascript.tsx | 8 +- .../src/components/LightboxLayout.island.tsx | 19 +- .../src/components/ProductCardButtons.tsx | 4 + dotcom-rendering/src/lib/theFilter.test.ts | 31 +++ dotcom-rendering/src/lib/theFilter.ts | 9 + .../src/model/buildLightboxImages.test.ts | 218 ++++++++++++++++++ .../src/model/buildLightboxImages.ts | 117 +++++----- dotcom-rendering/src/types/content.ts | 7 + 14 files changed, 451 insertions(+), 63 deletions(-) create mode 100644 dotcom-rendering/src/lib/theFilter.test.ts create mode 100644 dotcom-rendering/src/lib/theFilter.ts diff --git a/dotcom-rendering/src/components/ArticlePage.tsx b/dotcom-rendering/src/components/ArticlePage.tsx index a5b9d8eaf06..0db8a8f6d86 100644 --- a/dotcom-rendering/src/components/ArticlePage.tsx +++ b/dotcom-rendering/src/components/ArticlePage.tsx @@ -4,6 +4,7 @@ import { DecideLayout } from '../layouts/DecideLayout'; import { buildAdTargeting } from '../lib/ad-targeting'; import { ArticleDesign } from '../lib/articleFormat'; import { rootStyles } from '../lib/rootStyles'; +import { isFilterPageId } from '../lib/theFilter'; import type { NavType } from '../model/extract-nav'; import type { Article } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; @@ -78,6 +79,7 @@ export const ArticlePage = (props: WebProps | AppProps) => { ; }; const fullWidthStyle = css` @@ -56,6 +57,7 @@ export const ProductLinkButton = ({ priority = 'primary', dataComponent, xCustComponentId, + themeOverrides, }: ProductLinkButtonProps) => { const cssOverrides: SerializedStyles[] = [ heightAutoStyle, @@ -69,7 +71,7 @@ export const ProductLinkButton = ({ href={url} rel={SKIMLINK_REL} priority={priority} - theme={theme} + theme={{ ...theme, ...themeOverrides }} data-component={dataComponent} data-ignore="global-link-styling" data-link-name={`product link button ${priority}`} diff --git a/dotcom-rendering/src/components/HostedContentPage.tsx b/dotcom-rendering/src/components/HostedContentPage.tsx index f95ae6b3d3e..d6a8c9f9c19 100644 --- a/dotcom-rendering/src/components/HostedContentPage.tsx +++ b/dotcom-rendering/src/components/HostedContentPage.tsx @@ -5,6 +5,7 @@ import { HostedGalleryLayout } from '../layouts/HostedGalleryLayout'; import { HostedVideoLayout } from '../layouts/HostedVideoLayout'; import { ArticleDesign } from '../lib/articleFormat'; import { rootStyles } from '../lib/rootStyles'; +import { isFilterPageId } from '../lib/theFilter'; import type { Article } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; import { AlreadyVisited } from './AlreadyVisited.island'; @@ -91,6 +92,7 @@ export const HostedContentPage = (props: WebProps | AppProps) => { { @@ -92,6 +92,49 @@ export const WithCredit = { }, } satisfies Story; +const testProductCtas: ProductCta[] = [ + { + url: 'https://example.com/buy-1', + text: '', + retailer: 'Amazon', + price: '£19.99', + }, + { + url: 'https://example.com/buy-2', + text: '', + retailer: 'John Lewis', + price: '£21.00', + }, +]; + +export const WithProductCtas = { + args: { + format: { + display: ArticleDisplay.Standard, + design: ArticleDesign.Standard, + theme: Pillar.News, + }, + images: [ + { ...testImage, title: 'Title', productCtas: testProductCtas }, + ], + isFilterArticle: true, + }, +} satisfies Story; + +export const WithProductCtasNonFilterArticle = { + args: { + format: { + display: ArticleDisplay.Standard, + design: ArticleDesign.Standard, + theme: Pillar.News, + }, + images: [ + { ...testImage, title: 'Title', productCtas: testProductCtas }, + ], + isFilterArticle: false, + }, +} satisfies Story; + export const WithRating = { args: { format: { diff --git a/dotcom-rendering/src/components/Lightbox.tsx b/dotcom-rendering/src/components/Lightbox.tsx index 356d8fbb72a..906637697d0 100644 --- a/dotcom-rendering/src/components/Lightbox.tsx +++ b/dotcom-rendering/src/components/Lightbox.tsx @@ -12,6 +12,7 @@ interface BaseProps { format: ArticleFormat; renderingTarget: RenderingTarget; switches: Switches; + isFilterArticle: boolean; } interface WebProps extends BaseProps { @@ -29,6 +30,7 @@ export const Lightbox = ({ lightboxImages, renderingTarget, switches, + isFilterArticle, }: WebProps | AppProps) => { switch (renderingTarget) { case 'Web': @@ -46,6 +48,7 @@ export const Lightbox = ({ diff --git a/dotcom-rendering/src/components/LightboxImages.tsx b/dotcom-rendering/src/components/LightboxImages.tsx index f24d2e26607..1e0ca88c058 100644 --- a/dotcom-rendering/src/components/LightboxImages.tsx +++ b/dotcom-rendering/src/components/LightboxImages.tsx @@ -16,11 +16,13 @@ import type { ImageForLightbox } from '../types/content'; import { LightboxCaption } from './LightboxCaption'; import { LightboxLoader } from './LightboxLoader'; import { Picture } from './Picture'; +import { ProductCardButtons } from './ProductCardButtons'; import { StarRating } from './StarRating/StarRating'; type Props = { format: ArticleFormat; images: ImageForLightbox[]; + isFilterArticle?: boolean; }; const liStyles = css` @@ -134,6 +136,16 @@ const starRatingMarginStyles = css` margin-bottom: ${space[3]}px; } `; + +const productCtasStyles = css` + display: flex; + flex-direction: column; + gap: ${space[1]}px; + margin-bottom: ${space[2]}px; + ${from.tablet} { + margin-bottom: ${space[3]}px; + } +`; const Selection = ({ countOfImages, initialPosition = 1, @@ -179,7 +191,11 @@ const Selection = ({ ); }; -export const LightboxImages = ({ format, images }: Props) => { +export const LightboxImages = ({ + format, + images, + isFilterArticle = false, +}: Props) => { const [loaded, setLoaded] = useState(new Set()); useEffect(() => { @@ -200,6 +216,11 @@ export const LightboxImages = ({ format, images }: Props) => { return previousSize !== newSize ? new Set(set) : set; }); + const hasProductCtas = !!image.productCtas?.length; + // The "show info without needing to click 'i'" override is + // currently scoped to Filter articles only. + const alwaysShowInfo = hasProductCtas && isFilterArticle; + return (
  • { onLoad={onLoad} loading="lazy" /> -
  • )} + {!!image.productCtas?.length && ( +
    + +
    + )} + { /** * Hydration has been requested so the first step is to render the list of images and put them into @@ -628,7 +630,11 @@ export const LightboxJavascript = ({ log('dotcom', '💡 Generating HTML for lightbox images...'); return (
      - +
    ); }; diff --git a/dotcom-rendering/src/components/LightboxLayout.island.tsx b/dotcom-rendering/src/components/LightboxLayout.island.tsx index 8401dbe5e77..69bfe409866 100644 --- a/dotcom-rendering/src/components/LightboxLayout.island.tsx +++ b/dotcom-rendering/src/components/LightboxLayout.island.tsx @@ -21,6 +21,7 @@ import { LightboxJavascript } from './LightboxJavascript'; type Props = { format: ArticleFormat; images: ImageForLightbox[]; + isFilterArticle?: boolean; }; const lightboxStyles = css` @@ -33,6 +34,12 @@ const lightboxStyles = css` aside { display: none; } + /* Product links and their accompanying text are commercial content, + not decorative caption info, so they stay visible even when the + reader has chosen to hide the info panel. */ + aside.lightbox-product-info { + display: block; + } ${until.tablet} { /* Also hide the nav controls when on mobile */ nav { @@ -196,7 +203,11 @@ const Selection = ({ ); }; -export const LightboxLayout = ({ format, images }: Props) => { +export const LightboxLayout = ({ + format, + images, + isFilterArticle = false, +}: Props) => { return ( <> { hidden={true} >
    - +