diff --git a/dotcom-rendering/src/components/InlineProductCard.stories.tsx b/dotcom-rendering/src/components/InlineProductCard.stories.tsx new file mode 100644 index 00000000000..afd7a31e65c --- /dev/null +++ b/dotcom-rendering/src/components/InlineProductCard.stories.tsx @@ -0,0 +1,55 @@ +import { breakpoints } from '@guardian/source/foundations'; +import type { Meta } from '@storybook/react'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import type { InlineProductCardProps } from './InlineProductCard'; +import { InlineProductCard } from './InlineProductCard'; + +const meta = { + component: InlineProductCard, + title: 'Components/InlineProductCard', + parameters: { + chromatic: { + viewports: [ + breakpoints.mobile, + breakpoints.mobileMedium, + breakpoints.desktop, + ], + }, + + formats: [ + { + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + theme: Pillar.Lifestyle, + }, + ], + }, +} satisfies Meta; + +export default meta; + +const sampleProductCard: InlineProductCardProps = { + format: { + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + theme: Pillar.Lifestyle, + }, + image: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg', + primaryUrl: 'https://www.aircraft.com/lume', + primaryCTA: 'Buy at AirCraft', + primaryPrice: '£199.99', + secondaryCTA: '£199.99 at Amazon', + secondaryUrl: + 'https://www.amazon.co.uk/AirCraft-Home-Backlight-Oscillating-Circulator/dp/B0D8QNGB3M', + brandName: 'AirCraft', + productName: 'Lume', + statistics: [ + { name: 'What we love', value: 'It packs away pretty small' }, + { + name: 'What we don\t love', + value: 'there’s nowhere to stow the remote control', + }, + ], +}; + +export const Default = () => ; diff --git a/dotcom-rendering/src/components/InlineProductCard.tsx b/dotcom-rendering/src/components/InlineProductCard.tsx new file mode 100644 index 00000000000..151b4272458 --- /dev/null +++ b/dotcom-rendering/src/components/InlineProductCard.tsx @@ -0,0 +1,236 @@ +import { css } from '@emotion/react'; +import { + from, + headlineMedium20, + headlineMedium24, + space, + textSans15, + textSans17, + textSans20, + until, +} from '@guardian/source/foundations'; +import type { ArticleFormat } from '../lib/articleFormat'; +import { palette } from '../palette'; +import { Picture } from './Picture'; +import { ProductLinkButton } from './ProductLinkButton'; +import { stripHtmlFromString } from './TextBlockComponent'; + +export type Statistics = { + name: string; + value: string; +}; + +export type InlineProductCardProps = { + format: ArticleFormat; + brandName: string; + productName: string; + image: string; + primaryCTA: string; + primaryUrl: string; + primaryPrice: string; + secondaryCTA?: string; + secondaryUrl?: string; + statistics: Statistics[]; +}; + +const card = css` + ${from.wide} { + display: none; + } + + background-color: ${palette('--product-card-background')}; + padding: ${space[2]}px ${space[3]}px ${space[3]}px; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 12px; + row-gap: ${space[3]}px; + border-top: 1px solid ${palette('--section-border-lifestyle')}; + max-width: 100%; + + img { + height: 165px; + width: 165px; + object-fit: cover; + } + + ${from.phablet} { + img { + height: 328px; + width: 328px; + } + } + + ${from.desktop} { + img { + height: 288px; + width: 288px; + } + } +`; + +const productInfoContainer = css` + display: flex; + flex-direction: column; + gap: ${space[1]}px; + ${textSans20}; + + ${until.mobileLandscape} { + ${textSans17}; + } +`; + +const primaryHeading = css` + ${headlineMedium24}; + ${until.mobileLandscape} { + ${headlineMedium20}; + } +`; + +const productNameStyle = css` + ${textSans20}; + ${until.mobileLandscape} { + ${textSans17}; + } +`; + +const priceStyle = css` + font-weight: 700; +`; + +const mobileButtonWrapper = css` + display: flex; + flex-direction: column; + gap: ${space[1]}px; + width: 100%; + grid-column: 1 / span 2; + margin-top: ${space[1]}px; + + ${from.mobileLandscape} { + display: none; + } +`; + +const desktopButtonWrapper = css` + display: none; + + ${from.mobileLandscape} { + display: flex; + flex-direction: column; + gap: ${space[1]}px; + margin-top: 8px; + } +`; + +const statisticsContainer = css` + grid-column: 1 / span 2; + border-top: 1px solid ${palette('--section-border')}; + padding-top: ${space[3]}px; + display: grid; + gap: ${space[2]}px; + + ${from.mobileLandscape} { + grid-template-columns: 1fr 1fr; + } +`; + +const statisticItem = css` + ${textSans15}; + ${from.phablet} { + ${textSans17}; + } + strong { + font-weight: 700; + } +`; + +const Statistic = ({ name, value }: Statistics) => ( +
+ {name} +
+ {value} +
+); + +export const InlineProductCard = ({ + format, + brandName, + productName, + image, + primaryCTA, + primaryUrl, + primaryPrice, + secondaryCTA, + secondaryUrl, + statistics, +}: InlineProductCardProps) => ( +
+ {!!image && ( + + )} +
+
{brandName}
+
{productName}
+
{primaryPrice}
+ +
+ + {!!secondaryCTA && !!secondaryUrl && ( + + )} +
+
+ +
+ + {!!secondaryCTA && !!secondaryUrl && ( + + )} +
+ + {statistics.length > 0 && ( +
+ {statistics.map((statistic) => ( + + ))} +
+ )} +
+); diff --git a/dotcom-rendering/src/components/LeftColProductCard.stories.tsx b/dotcom-rendering/src/components/LeftColProductCard.stories.tsx new file mode 100644 index 00000000000..75c67328ebe --- /dev/null +++ b/dotcom-rendering/src/components/LeftColProductCard.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta } from '@storybook/react'; +import type { ArticleFormat } from '../lib/articleFormat'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import type { LeftColProductCardProps } from './LeftColProductCard'; +import { LeftColProductCard } from './LeftColProductCard'; + +const format: ArticleFormat = { + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + theme: Pillar.Lifestyle, +}; +const meta = { + component: LeftColProductCard, + title: 'Components/LeftColProductCard', + parameters: { + layout: 'padded', + formats: [ + { + design: ArticleDesign.Standard, + display: ArticleDesign.Standard, + theme: Pillar.Lifestyle, + }, + ], + }, +} satisfies Meta; + +export default meta; + +const sampleProductCard: LeftColProductCardProps = { + format, + brandName: 'AirCraft', + productName: 'Lume', + image: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg', + primaryCta: 'Buy at AirCraft', + primaryUrl: 'https://www.aircraft.com/lume', + primaryPrice: '£199.99', + statistics: [ + { name: 'What we love', value: 'It packs away pretty small' }, + { + name: "What we don't love", + value: 'there’s nowhere to stow the remote control', + }, + ], +}; + +export const Default = () => ; + +export const WithNoStatistics = () => ( + +); diff --git a/dotcom-rendering/src/components/LeftColProductCard.tsx b/dotcom-rendering/src/components/LeftColProductCard.tsx new file mode 100644 index 00000000000..0aab2734849 --- /dev/null +++ b/dotcom-rendering/src/components/LeftColProductCard.tsx @@ -0,0 +1,148 @@ +import { css } from '@emotion/react'; +import { + from, + headlineMedium17, + space, + textSans15, + textSans17, +} from '@guardian/source/foundations'; +import type { ArticleFormat } from '../lib/articleFormat'; +import { palette } from '../palette'; +import { Picture } from './Picture'; +import { ProductLinkButton } from './ProductLinkButton'; + +export type Statistics = { + name: string; + value: string; +}; + +export type LeftColProductCardProps = { + brandName: string; + productName: string; + image: string; + primaryCta: string; + primaryUrl: string; + primaryPrice: string; + statistics: Statistics[]; + format: ArticleFormat; + noHeadings?: boolean; +}; + +const card = (noHeadings?: boolean) => css` + display: none; + ${from.wide} { + top: ${space[3]}px; + position: sticky; + display: block; + margin-top: ${noHeadings ? 0 : space[3]}px; + width: 220px; + border-top: 1px solid ${palette('--section-border-lifestyle')}; + } + img { + height: 220px; + width: 220px; + } + strong { + font-weight: 700; + } +`; + +const productInfoContainer = css` + display: grid; + row-gap: ${space[1]}px; + padding: ${space[1]}px 10px ${space[2]}px 0; +`; + +const primaryHeading = css` + ${headlineMedium17}; +`; + +const secondaryHeading = css` + ${textSans17}; +`; + +const priceRowStyle = css` + ${textSans17}; +`; + +const buttonOverride = css` + padding-bottom: ${space[4]}px; + min-width: 100%; +`; + +const statisticsContainer = css` + border-top: 1px solid ${palette('--section-border')}; + padding-top: ${space[3]}px; + padding-bottom: ${space[4]}px; + display: grid; + row-gap: ${space[3]}px; +`; + +const Statistic = ({ name, value }: Statistics) => ( +
+ {name} +
+ {value} +
+); + +//todo -- make this a proper image generateSources() etc. + +export const LeftColProductCard = ({ + brandName, + productName, + image, + primaryCta, + primaryUrl, + primaryPrice, + statistics, + format, + noHeadings, +}: LeftColProductCardProps) => ( +
+ {!!image && ( + + )} +
+
{brandName}
+
{productName}
+
+ {primaryPrice} +
+
+
+ +
+ {statistics.length > 0 && ( +
+ {statistics.map((statistic) => ( + + ))} +
+ )} +
+); diff --git a/dotcom-rendering/src/components/Picture.tsx b/dotcom-rendering/src/components/Picture.tsx index 9fc9c8b4238..23fefe9a8d1 100644 --- a/dotcom-rendering/src/components/Picture.tsx +++ b/dotcom-rendering/src/components/Picture.tsx @@ -20,7 +20,8 @@ export type Orientation = 'portrait' | 'landscape'; type PictureRoleType = | RoleType // Custom image role types that are used but do not come from CAPI / FE - | 'podcastCover'; + | 'podcastCover' + | 'productCard'; type Props = { role: PictureRoleType; @@ -268,6 +269,11 @@ const decideImageWidths = ({ { breakpoint: breakpoints.mobile, width: 140 }, { breakpoint: breakpoints.wide, width: 219 }, ]; + case 'productCard': + return [ + { breakpoint: breakpoints.wide, width: 220 }, + { breakpoint: breakpoints.mobile, width: 165 }, + ]; case 'inline': default: return [ @@ -358,6 +364,16 @@ const decideImageWidths = ({ ]; case 'halfWidth': return [{ breakpoint: breakpoints.mobile, width: 445 }]; + case 'podcastCover': + return [ + { breakpoint: breakpoints.mobile, width: 140 }, + { breakpoint: breakpoints.wide, width: 219 }, + ]; + case 'productCard': + return [ + { breakpoint: breakpoints.mobile, width: 165 }, + { breakpoint: breakpoints.wide, width: 220 }, + ]; case 'inline': default: return [ diff --git a/dotcom-rendering/src/components/ProductElement.stories.tsx b/dotcom-rendering/src/components/ProductElement.stories.tsx new file mode 100644 index 00000000000..dd61816fd26 --- /dev/null +++ b/dotcom-rendering/src/components/ProductElement.stories.tsx @@ -0,0 +1,581 @@ +import { css } from '@emotion/react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { getNestedArticleElement } from '../lib/renderElement'; +import type { ProductBlockElement } from '../types/content'; +import { ArticleContainer } from './ArticleContainer'; +import { ProductElement } from './ProductElement'; +import { Section as SectionComponent } from './Section'; + +const ArticleElementComponent = getNestedArticleElement({ + abTests: {}, + ajaxUrl: '', + editionId: 'UK', + isAdFreeUser: false, + isSensitive: false, + pageId: 'testID', + switches: {}, + webTitle: 'Storybook page', + shouldHideAds: false, +}); + +const product: ProductBlockElement = { + _type: 'model.dotcomrendering.pageElements.ProductBlockElement', + elementId: 'b1f6e8e2-3f3a-4f0c-8d1e-5f3e3e3e3e3e', + primaryHeading: 'Best fan overall', + secondaryHeading: 'AirCraft Lume', + brandName: 'AirCraft', + productName: 'Lume', + image: { + index: 0, + fields: { + height: '500', + width: '500', + }, + mediaType: 'Image', + mimeType: 'image/jpeg', + url: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg', + }, + primaryProductUrl: 'https://www.aircraft.com/lume', + primaryPrice: '£199.99', + primaryRetailer: 'AirCraft', + primaryCta: '£199.99 at AirCraft', + starRating: 'none-selected', + secondaryProductUrl: + 'https://www.amazon.co.uk/Devola-16-Inch-Desk-Fan/dp/B0B3Z9K5XH?tag=theguardianbookshop-21&ascsubtag=trd-10001-1b2f-00000-00000-a0000-00000-00000-00000', + secondaryCta: '£132.99 at Amazon', + secondaryPrice: '£132.99', + secondaryRetailer: 'Amazon', + statistics: [ + { name: 'What we love', value: 'It packs away pretty small' }, + { + name: "What we don't love", + value: 'there’s nowhere to stow the remote control', + }, + ], + content: [ + { + displayCredit: true, + _type: 'model.dotcomrendering.pageElements.ImageBlockElement', + role: 'inline', + media: { + allImages: [ + { + index: 0, + fields: { + aspectRatio: '1:1', + height: '3000', + width: '3000', + }, + mediaType: 'Image', + mimeType: 'image/jpeg', + url: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/3000.jpg', + }, + { + index: 1, + fields: { + aspectRatio: '1:1', + isMaster: 'true', + height: '3000', + width: '3000', + }, + mediaType: 'Image', + mimeType: 'image/jpeg', + url: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg', + }, + { + index: 2, + fields: { + aspectRatio: '1:1', + height: '2000', + width: '2000', + }, + mediaType: 'Image', + mimeType: 'image/jpeg', + url: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/2000.jpg', + }, + { + index: 3, + fields: { + aspectRatio: '1:1', + height: '1000', + width: '1000', + }, + mediaType: 'Image', + mimeType: 'image/jpeg', + url: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/1000.jpg', + }, + { + index: 4, + fields: { + aspectRatio: '1:1', + height: '500', + width: '500', + }, + mediaType: 'Image', + mimeType: 'image/jpeg', + url: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/500.jpg', + }, + { + index: 5, + fields: { + aspectRatio: '1:1', + height: '140', + width: '140', + }, + mediaType: 'Image', + mimeType: 'image/jpeg', + url: 'https://media.guim.co.uk/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/140.jpg', + }, + ], + }, + elementId: '080282cc-33a4-4114-a5ee-b97b63ac51bf', + imageSources: [ + { + weighting: 'inline', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=85&auto=format&fit=max&s=f7e3a2e8c13b193540c8ae42d9dea333', + width: 620, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=45&auto=format&fit=max&dpr=2&s=9cf84257cf5c6cfa04fbfde83dacf666', + width: 1240, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=85&auto=format&fit=max&s=773c2392e25264e7972d2b630463a17e', + width: 605, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=45&auto=format&fit=max&dpr=2&s=5e275875a24721a6193bda0aec92e343', + width: 1210, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=85&auto=format&fit=max&s=09a1b939ac71389be24d1fc14e3a9948', + width: 445, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=45&auto=format&fit=max&dpr=2&s=9b2caa94ed0906fdc84ed7cb2bbdb027', + width: 890, + }, + ], + }, + { + weighting: 'thumbnail', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=140&quality=85&auto=format&fit=max&s=113846f24c7151138c5574ccdb944305', + width: 140, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=140&quality=45&auto=format&fit=max&dpr=2&s=3e4284bcdafd196b93d87357fc4cf8a1', + width: 280, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=120&quality=85&auto=format&fit=max&s=704af97f121b57063a33624ae4db883b', + width: 120, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=120&quality=45&auto=format&fit=max&dpr=2&s=c6b991e368a19fb5def4307978ad5795', + width: 240, + }, + ], + }, + { + weighting: 'supporting', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=380&quality=85&auto=format&fit=max&s=1b1657aa27762ca98fa3bee3864b41e7', + width: 380, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=380&quality=45&auto=format&fit=max&dpr=2&s=338d07b846956f2cc52ebb96d8d2e2b2', + width: 760, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=300&quality=85&auto=format&fit=max&s=b78e29df91a4f09fefb77fb859b34a9c', + width: 300, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=300&quality=45&auto=format&fit=max&dpr=2&s=2fb46c78776a2fa19c61cfd9f0501d88', + width: 600, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=85&auto=format&fit=max&s=f7e3a2e8c13b193540c8ae42d9dea333', + width: 620, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=45&auto=format&fit=max&dpr=2&s=9cf84257cf5c6cfa04fbfde83dacf666', + width: 1240, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=85&auto=format&fit=max&s=773c2392e25264e7972d2b630463a17e', + width: 605, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=45&auto=format&fit=max&dpr=2&s=5e275875a24721a6193bda0aec92e343', + width: 1210, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=85&auto=format&fit=max&s=09a1b939ac71389be24d1fc14e3a9948', + width: 445, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=45&auto=format&fit=max&dpr=2&s=9b2caa94ed0906fdc84ed7cb2bbdb027', + width: 890, + }, + ], + }, + { + weighting: 'showcase', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=860&quality=85&auto=format&fit=max&s=b610c55994b1dd7cf8af561317afdb77', + width: 860, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=860&quality=45&auto=format&fit=max&dpr=2&s=2da421bcc9ebb0867101ecf2e237b545', + width: 1720, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=780&quality=85&auto=format&fit=max&s=634de1870d9f9edcaca6e406b236f6cc', + width: 780, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=780&quality=45&auto=format&fit=max&dpr=2&s=8ca2912c78c722dc08b067bee110983e', + width: 1560, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=85&auto=format&fit=max&s=f7e3a2e8c13b193540c8ae42d9dea333', + width: 620, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=45&auto=format&fit=max&dpr=2&s=9cf84257cf5c6cfa04fbfde83dacf666', + width: 1240, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=85&auto=format&fit=max&s=773c2392e25264e7972d2b630463a17e', + width: 605, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=45&auto=format&fit=max&dpr=2&s=5e275875a24721a6193bda0aec92e343', + width: 1210, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=85&auto=format&fit=max&s=09a1b939ac71389be24d1fc14e3a9948', + width: 445, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=45&auto=format&fit=max&dpr=2&s=9b2caa94ed0906fdc84ed7cb2bbdb027', + width: 890, + }, + ], + }, + { + weighting: 'halfwidth', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=85&auto=format&fit=max&s=f7e3a2e8c13b193540c8ae42d9dea333', + width: 620, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=620&quality=45&auto=format&fit=max&dpr=2&s=9cf84257cf5c6cfa04fbfde83dacf666', + width: 1240, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=85&auto=format&fit=max&s=773c2392e25264e7972d2b630463a17e', + width: 605, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=605&quality=45&auto=format&fit=max&dpr=2&s=5e275875a24721a6193bda0aec92e343', + width: 1210, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=85&auto=format&fit=max&s=09a1b939ac71389be24d1fc14e3a9948', + width: 445, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=445&quality=45&auto=format&fit=max&dpr=2&s=9b2caa94ed0906fdc84ed7cb2bbdb027', + width: 890, + }, + ], + }, + { + weighting: 'immersive', + srcSet: [ + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=1900&quality=85&auto=format&fit=max&s=04fc8464f7adc070d493971e596dd1b0', + width: 1900, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=1900&quality=45&auto=format&fit=max&dpr=2&s=069ff9cf30b65771aacb6a500d6a2b30', + width: 3800, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=1300&quality=85&auto=format&fit=max&s=143abf482708dad17207c61f778b2299', + width: 1300, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=1300&quality=45&auto=format&fit=max&dpr=2&s=e82acf4c43793f7ab898e3bd59bdf245', + width: 2600, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=1140&quality=85&auto=format&fit=max&s=09df21398496d0e973217fd17f1a596a', + width: 1140, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=1140&quality=45&auto=format&fit=max&dpr=2&s=71989a2384c0ee81da93e3c3c9a61e90', + width: 2280, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=980&quality=85&auto=format&fit=max&s=daaa86aff0048afffb22dd37c5d3a790', + width: 980, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=980&quality=45&auto=format&fit=max&dpr=2&s=b16dc64073e83b5d1bbe4c49ae29d53f', + width: 1960, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=740&quality=85&auto=format&fit=max&s=0c006a9886134157111c288b3ea262bd', + width: 740, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=740&quality=45&auto=format&fit=max&dpr=2&s=9727ebc350b7d9209eba8bcd0ffbd477', + width: 1480, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=660&quality=85&auto=format&fit=max&s=63faff5989a938f0e4eb5e0242f42939', + width: 660, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=660&quality=45&auto=format&fit=max&dpr=2&s=70d09944cc01dce3c367f081064ccd40', + width: 1320, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=480&quality=85&auto=format&fit=max&s=5cf16379711fb436e20774155c87e98b', + width: 480, + }, + { + src: 'https://i.guim.co.uk/img/media/ed32f52c10d742be18c4ff1b218dce611e71f57e/500_0_3000_3000/master/3000.jpg?width=480&quality=45&auto=format&fit=max&dpr=2&s=3c5641138614f64f6be03faa224963b9', + width: 960, + }, + ], + }, + ], + data: { + alt: 'AirCraft reshot', + credit: 'Photograph: Caramel Quin/The Guardian', + }, + }, + { + _type: 'model.dotcomrendering.pageElements.LinkBlockElement', + url: 'https://go.skimresources.com/?id=114047X1572903&url=https%3A%2F%2Faircraftvacuums.com%2Fproduct%2Flume-fan-air-circulator%2F%23buynow&sref=https://www.theguardian.com/thefilter/2025/jun/17/best-fans-uk.json?dcr', + label: '£149 at AirCraft', + elementId: 'f9b3e7a9-788b-4ada-bd99-f7c40b843db7', + linkType: 'ProductButton', + }, + { + _type: 'model.dotcomrendering.pageElements.TextBlockElement', + html: '

Preorder now for delivery during week commencing 22 September

', + elementId: 'a55f8c48-4be4-4e14-92f8-483ecf0c75fc', + }, + { + _type: 'model.dotcomrendering.pageElements.TextBlockElement', + html: '

This pedestal fan was hard to fault, with an elegant design that boasts a dimmable backlight (three brightness levels or you can turn it off, all with the remote control). It’s billed as height adjustable, but rather than scooting up and down, you can remove the bottom pole to convert it into a 63cm desk fan. There’s an LED display and touch controls on the front, and other features include a 12-hour timer and a sleep mode.

', + elementId: 'd9ae99e6-8131-431c-a692-3c96769b8aff', + }, + { + _type: 'model.dotcomrendering.pageElements.TextBlockElement', + html: '

Of all I tested, this is probably the best fan for sleeping: it’s the best for low noise relative to wind speed. Pick a lower setting for silent cooling, a higher setting if you’re happy to doze off to the white noise, or use the sleep button if you’d like it to gradually reduce power in the night. I found the lower settings cooling enough to get to sleep on a hot night during a heatwave.

', + elementId: 'b03cb827-c743-4b21-9999-5ab8ae707bcb', + }, + { + _type: 'model.dotcomrendering.pageElements.TextBlockElement', + html: '

Why we love it
When I’m working from home or relaxing (I can’t say chilling out when it’s 30C+), the AirCraft Lume is the fan that I reach for. I love it for many reasons. It’s light but with a reassuringly heavy base, giving it Weeble-like stability: it’s difficult to knock over. It packs away pretty small for the winter because the pole comes apart. I also liked that the packaging is almost plastic-free.

', + elementId: 'c8e237ad-68a1-4b85-8f18-b823cca792ec', + }, + { + _type: 'model.dotcomrendering.pageElements.TextBlockElement', + html: '

It oscillates both horizontally and vertically, so it can circulate air nicely around a whole room. Most importantly of all, this fan can really shift air – its 5.9m/s (metres a second) result was the best on test, and you can really feel it. During a recent heatwave, I found the powerful breeze genuinely cooling. And it’s quiet: in fact, I can’t hear it at all on levels 1-5, so I need to be careful that I remember to turn off the fan when I step away. All for a reasonable price, too.

', + elementId: 'b395e534-5a28-4562-a529-9a623acfedf0', + }, + { + _type: 'model.dotcomrendering.pageElements.TextBlockElement', + html: '

It’s a shame that … there’s nowhere to stow the remote control when it’s not in use.

', + elementId: 'dfdb1862-d836-4950-ac8c-14dd4a446e1c', + }, + ], +}; +const meta = { + component: ProductElement, + title: 'Components/ProductElement', + parameters: { + formats: [ + { + design: ArticleDesign.Review, + display: ArticleDesign.Review, + theme: Pillar.Lifestyle, + }, + ], + }, + args: { + product, + format: { + design: ArticleDesign.Review, + display: ArticleDisplay.Showcase, + theme: Pillar.Lifestyle, + }, + ArticleElementComponent, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; + +export default meta; + +export const Default = {}; + +export const withoutHeading: StoryFn = () => { + return ( + <> + + `, + secondaryHeading: ``, + }} + format={{ + design: ArticleDesign.Review, + display: ArticleDisplay.Showcase, + theme: Pillar.Lifestyle, + }} + ArticleElementComponent={ArticleElementComponent} + /> + + ); +}; + +export const MultipleProducts: StoryFn = () => { + return ( + <> + + + + ); +}; + +export const MultipleProductsWithoutStats: StoryFn = () => { + return ( + <> + + + + ); +}; + +export const withoutFields: StoryFn = () => { + return ( + <> + + + ); +}; diff --git a/dotcom-rendering/src/components/ProductElement.tsx b/dotcom-rendering/src/components/ProductElement.tsx new file mode 100644 index 00000000000..8d9de112773 --- /dev/null +++ b/dotcom-rendering/src/components/ProductElement.tsx @@ -0,0 +1,127 @@ +import { css } from '@emotion/react'; +import { from } from '@guardian/source/foundations'; +import type { ReactNode } from 'react'; +import type { ArticleFormat } from '../lib/articleFormat'; +import { parseHtml } from '../lib/domUtils'; +import type { NestedArticleElement } from '../lib/renderElement'; +import type { FEElement, ProductBlockElement } from '../types/content'; +import { InlineProductCard } from './InlineProductCard'; +import { LeftColProductCard } from './LeftColProductCard'; +import { buildElementTree } from './SubheadingBlockComponent'; + +export type Product = { + primaryHeadline: string; + secondaryHeadline: string; + brandName: string; + productName: string; + image: string; + url: string; + price: string; + retailer: string; + cta: string; + secondaryCTA?: string; + secondaryUrl?: string; + statistics: { + name: string; + value: string; + }[]; + content: FEElement[]; +}; + +const LeftColProductCardContainer = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); +export const ProductElement = ({ + product, + ArticleElementComponent, + format, +}: { + product: ProductBlockElement; + ArticleElementComponent: NestedArticleElement; + format: ArticleFormat; +}) => { + const subheadingHtml = parseHtml( + [ + product.primaryHeading + ? `

${ + product.primaryHeading + }

` + : '', + product.secondaryHeading + ? `

${ + product.secondaryHeading + }

` + : '', + ].join(''), + ); + + const isSubheading = subheadingHtml.textContent + ? subheadingHtml.textContent.trim().length > 0 + : false; + + return ( +
+ {isSubheading && + Array.from(subheadingHtml.childNodes).map( + buildElementTree(format), + )} + + + + {product.content.map((element, index) => ( + + ))} + +
+ ); +}; diff --git a/dotcom-rendering/src/components/ProductLinkButton.tsx b/dotcom-rendering/src/components/ProductLinkButton.tsx index cdcd9e92ff4..5fffe049c4f 100644 --- a/dotcom-rendering/src/components/ProductLinkButton.tsx +++ b/dotcom-rendering/src/components/ProductLinkButton.tsx @@ -1,13 +1,22 @@ +import type { SerializedStyles } from '@emotion/react'; import { css } from '@emotion/react'; import { space } from '@guardian/source/foundations'; +import type { + ButtonPriority, + ThemeButton, +} from '@guardian/source/react-components'; import { LinkButton, SvgArrowRightStraight, } from '@guardian/source/react-components'; +import { palette } from '../palette'; type ProductLinkButtonProps = { label: string; url: string; + size?: 'default' | 'small'; + cssOverrides?: SerializedStyles; + priority?: ButtonPriority; dataComponent?: string; }; @@ -20,9 +29,22 @@ const linkButtonStyles = css` overflow-wrap: break-word; `; +export const theme: Partial = { + backgroundPrimary: palette('--product-button-primary-background'), + backgroundPrimaryHover: palette( + '--product-button-primary-background-hover', + ), + // todo: make a new palette variable for this + textTertiary: palette('--product-button-primary-background'), + borderTertiary: palette('--product-button-primary-background'), +}; + export const ProductLinkButton = ({ label, url, + size = 'default', + cssOverrides, + priority = 'primary', dataComponent = 'in-body-product-link-button', }: ProductLinkButtonProps) => { return ( @@ -31,12 +53,18 @@ export const ProductLinkButton = ({ rel="sponsored noreferrer noopener" target="_blank" iconSide="right" + priority={priority} aria-label={`Open ${label} in a new tab`} icon={} + theme={theme} data-ignore="global-link-styling" data-link-name="in body link" data-spacefinder-role="inline" - cssOverrides={[linkButtonStyles]} + size={size} + cssOverrides={[ + linkButtonStyles, + ...(cssOverrides ? [cssOverrides] : []), + ]} data-component={dataComponent} > {label} diff --git a/dotcom-rendering/src/components/TextBlockComponent.tsx b/dotcom-rendering/src/components/TextBlockComponent.tsx index ffbd262cc41..882834a2c72 100644 --- a/dotcom-rendering/src/components/TextBlockComponent.tsx +++ b/dotcom-rendering/src/components/TextBlockComponent.tsx @@ -43,7 +43,7 @@ const isOpenQuote = (t: string): boolean => { ].includes(t); }; -const stripHtmlFromString = (html: string): string => { +export const stripHtmlFromString = (html: string): string => { // https://stackoverflow.com/questions/822452/strip-html-from-text-javascript is // a good discussion on how this can be done. Of the two approaches, regex and // DOM, both have unikely failure scenarios but the impact for failure with DOM diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 37cf0c9dd64..eab843b1531 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -869,6 +869,9 @@ }, { "$ref": "#/definitions/CrosswordElement" + }, + { + "$ref": "#/definitions/ProductBlockElement" } ] }, @@ -2507,6 +2510,9 @@ "type": "string", "const": "model.dotcomrendering.pageElements.LinkBlockElement" }, + "elementId": { + "type": "string" + }, "url": { "type": "string" }, @@ -2520,6 +2526,7 @@ }, "required": [ "_type", + "elementId", "label", "linkType", "url" @@ -4414,6 +4421,107 @@ "Record": { "type": "object" }, + "ProductBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.ProductBlockElement" + }, + "elementId": { + "type": "string" + }, + "secondaryProductUrl": { + "type": "string" + }, + "brandName": { + "type": "string" + }, + "secondaryPrice": { + "type": "string" + }, + "primaryProductUrl": { + "type": "string" + }, + "starRating": { + "type": "string" + }, + "productName": { + "type": "string" + }, + "primaryRetailer": { + "type": "string" + }, + "image": { + "$ref": "#/definitions/Image" + }, + "primaryPrice": { + "type": "string" + }, + "primaryCta": { + "type": "string" + }, + "secondaryHeading": { + "type": "string" + }, + "primaryHeading": { + "type": "string" + }, + "secondaryRetailer": { + "type": "string" + }, + "secondaryCta": { + "type": "string" + }, + "statistics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + } + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/FEElement" + } + }, + "h2Id": { + "type": "string" + } + }, + "required": [ + "_type", + "brandName", + "content", + "elementId", + "image", + "primaryCta", + "primaryHeading", + "primaryPrice", + "primaryProductUrl", + "primaryRetailer", + "productName", + "secondaryCta", + "secondaryHeading", + "secondaryPrice", + "secondaryProductUrl", + "secondaryRetailer", + "starRating", + "statistics" + ] + }, "Block": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index eaf02865a40..ad4165c2be8 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -36,6 +36,7 @@ import { MultiBylines } from '../components/MultiBylines'; import { MultiImageBlockComponent } from '../components/MultiImageBlockComponent'; import { NumberedTitleBlockComponent } from '../components/NumberedTitleBlockComponent'; import { PersonalityQuizAtom } from '../components/PersonalityQuizAtom.importable'; +import { ProductElement } from '../components/ProductElement'; import { ProductLinkButton } from '../components/ProductLinkButton'; import { ProfileAtomWrapper } from '../components/ProfileAtomWrapper.importable'; import { PullQuoteBlockComponent } from '../components/PullQuoteBlockComponent'; @@ -122,11 +123,19 @@ const updateRole = (el: FEElement, format: ArticleFormat): FEElement => { } return el; + case 'model.dotcomrendering.pageElements.ProductBlockElement': + return { + ...el, + content: el.content.map((nestedElement) => + 'role' in nestedElement + ? { ...nestedElement, role: 'inline' } + : nestedElement, + ), + }; default: if (isBlog && 'role' in el) { el.role = 'inline'; } - return el; } }; @@ -578,6 +587,29 @@ export const renderElement = ({ )} ); + case 'model.dotcomrendering.pageElements.ProductBlockElement': + return ( + <> + + + ); case 'model.dotcomrendering.pageElements.PullquoteBlockElement': return ( ": { "type": "object" }, + "ProductBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.ProductBlockElement" + }, + "elementId": { + "type": "string" + }, + "secondaryProductUrl": { + "type": "string" + }, + "brandName": { + "type": "string" + }, + "secondaryPrice": { + "type": "string" + }, + "primaryProductUrl": { + "type": "string" + }, + "starRating": { + "type": "string" + }, + "productName": { + "type": "string" + }, + "primaryRetailer": { + "type": "string" + }, + "image": { + "$ref": "#/definitions/Image" + }, + "primaryPrice": { + "type": "string" + }, + "primaryCta": { + "type": "string" + }, + "secondaryHeading": { + "type": "string" + }, + "primaryHeading": { + "type": "string" + }, + "secondaryRetailer": { + "type": "string" + }, + "secondaryCta": { + "type": "string" + }, + "statistics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ] + } + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/FEElement" + } + }, + "h2Id": { + "type": "string" + } + }, + "required": [ + "_type", + "brandName", + "content", + "elementId", + "image", + "primaryCta", + "primaryHeading", + "primaryPrice", + "primaryProductUrl", + "primaryRetailer", + "productName", + "secondaryCta", + "secondaryHeading", + "secondaryPrice", + "secondaryProductUrl", + "secondaryRetailer", + "starRating", + "statistics" + ] + }, "Attributes": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/model/enhance-H2s.ts b/dotcom-rendering/src/model/enhance-H2s.ts index f881c55d0b5..94977982f1f 100644 --- a/dotcom-rendering/src/model/enhance-H2s.ts +++ b/dotcom-rendering/src/model/enhance-H2s.ts @@ -1,5 +1,5 @@ import { JSDOM } from 'jsdom'; -import type { FEElement, SubheadingBlockElement } from '../types/content'; +import type { FEElement } from '../types/content'; import { isLegacyTableOfContents } from './isLegacyTableOfContents'; const shouldUseLegacyIDs = (elements: FEElement[]): boolean => { @@ -11,8 +11,8 @@ const shouldUseLegacyIDs = (elements: FEElement[]): boolean => { ); }; -const extractText = (element: SubheadingBlockElement): string => { - const frag = JSDOM.fragment(element.html); +export const extractText = (html: string): string => { + const frag = JSDOM.fragment(html); if (!frag.firstElementChild) return ''; return frag.textContent?.trim() ?? ''; }; @@ -47,11 +47,11 @@ export const slugify = (text: string): string => { /** * This function attempts to create a slugified string to use as the id. It fails over to elementId. */ -const generateId = (element: SubheadingBlockElement, existingIds: string[]) => { - const text = extractText(element); - if (!text) return element.elementId; +const generateId = (elementId: string, html: string, existingIds: string[]) => { + const text = extractText(html); + if (!text) return elementId; const slug = slugify(text); - if (!slug) return element.elementId; + if (!slug) return elementId; const unique = getUnique(slug, existingIds); existingIds.push(slug); return unique; @@ -71,7 +71,7 @@ export const enhanceH2s = (elements: FEElement[]): FEElement[] => { ) { const id = shouldUseElementId ? element.elementId - : generateId(element, slugifiedIds); + : generateId(element.elementId, element.html, slugifiedIds); const withId = element.html.replace( '

', @@ -82,6 +82,19 @@ export const enhanceH2s = (elements: FEElement[]): FEElement[] => { ...element, html: withId, }; + } else if ( + element._type === + 'model.dotcomrendering.pageElements.ProductBlockElement' + ) { + const subheadingHtml = `${element.primaryHeading || ''} ${ + element.secondaryHeading || '' + }`; + + const h2Id = shouldUseElementId + ? element.elementId + : generateId(element.elementId, subheadingHtml, slugifiedIds); + + return { ...element, h2Id }; } else { // Otherwise, do nothing return element; diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index 25ea4ec34a7..53c0393a727 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -5089,6 +5089,11 @@ const privacyTextSupportingSubduedLight: PaletteFunction = () => const privacyTextSupportingSubduedDark: PaletteFunction = () => sourcePalette.neutral[60]; +const productCardBackgroundLight: PaletteFunction = () => + sourcePalette.neutral[97]; +const productCardBackgroundDark: PaletteFunction = () => + sourcePalette.neutral[97]; + const privacyTextRegularLight: PaletteFunction = () => sourcePalette.neutral[7]; const privacyTextDark: PaletteFunction = () => sourcePalette.neutral[86]; const witnessTitleText: PaletteFunction = ({ theme }) => { @@ -5380,6 +5385,15 @@ const discussionSubduedDark: PaletteFunction = () => sourcePalette.neutral[60]; const discussionLinkLight: PaletteFunction = () => sourcePalette.brand[500]; const discussionLinkDark: PaletteFunction = () => sourcePalette.brand[800]; +const productButtonPrimaryBackgroundLight: PaletteFunction = (format) => + discussionPrimaryButtonBackgroundLight(format); +const productButtonPrimaryBackgroundDark: PaletteFunction = (format) => + discussionPrimaryButtonBackgroundDark(format); +const productButtonPrimaryBackgroundHoverLight: PaletteFunction = (format) => + discussionButtonHover(format); +const productButtonPrimaryBackgroundHoverDark: PaletteFunction = (format) => + discussionButtonHover(format); + const discussionPrimaryButtonBackgroundLight: PaletteFunction = ({ theme }) => { switch (theme) { case Pillar.News: @@ -7516,6 +7530,18 @@ const paletteColours = { light: privacyTextSupportingSubduedLight, dark: privacyTextSupportingSubduedDark, }, + '--product-button-primary-background': { + light: productButtonPrimaryBackgroundLight, + dark: productButtonPrimaryBackgroundDark, + }, + '--product-button-primary-background-hover': { + light: productButtonPrimaryBackgroundHoverLight, + dark: productButtonPrimaryBackgroundHoverDark, + }, + '--product-card-background': { + light: productCardBackgroundLight, + dark: productCardBackgroundDark, + }, '--pullquote-background': { light: pullQuoteBackgroundLight, dark: pullQuoteBackgroundDark, diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 9e5d2390e42..3b6370998de 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -395,6 +395,7 @@ export interface ListItem { export interface LinkBlockElement { _type: 'model.dotcomrendering.pageElements.LinkBlockElement'; + elementId: string; url: string; label: string; linkType: 'ProductButton'; @@ -468,6 +469,28 @@ export interface InteractiveContentsBlockElement { endDocumentElementId?: string; } +export interface ProductBlockElement { + _type: 'model.dotcomrendering.pageElements.ProductBlockElement'; + elementId: string; + secondaryProductUrl: string; + brandName: string; + secondaryPrice: string; + primaryProductUrl: string; + starRating: string; + productName: string; + primaryRetailer: string; + image: Image; + primaryPrice: string; + primaryCta: string; + secondaryHeading: string; + primaryHeading: string; + secondaryRetailer: string; + secondaryCta: string; + statistics: { name: string; value: string }[]; + content: FEElement[]; + h2Id?: string; +} + interface ProfileAtomBlockElement { _type: 'model.dotcomrendering.pageElements.ProfileAtomBlockElement'; elementId: string; @@ -832,7 +855,8 @@ export type FEElement = | VineBlockElement | YoutubeBlockElement | WitnessTypeBlockElement - | CrosswordElement; + | CrosswordElement + | ProductBlockElement; // ------------------------------------- // Misc