diff --git a/dotcom-rendering/playwright/tests/puzzles.sponsorship.e2e.spec.ts b/dotcom-rendering/playwright/tests/puzzles.sponsorship.e2e.spec.ts index 0491b5130d7..7471c4b7da1 100644 --- a/dotcom-rendering/playwright/tests/puzzles.sponsorship.e2e.spec.ts +++ b/dotcom-rendering/playwright/tests/puzzles.sponsorship.e2e.spec.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import { puzzlesBranding } from '../../fixtures/manual/puzzlesBranding'; -import { createPuzzlesPage } from '../../fixtures/manual/puzzlesPage'; +import { + createPuzzlesPage, + fullPuzzlesLayout, +} from '../../fixtures/manual/puzzlesPage'; import type { EditionId } from '../../src/lib/edition'; import { disableCMP } from '../lib/cmp'; import { loadPage } from '../lib/load-page'; @@ -69,3 +72,95 @@ test.describe('Puzzles hub sponsorship', () => { await expect(page.getByText('Supported by')).toHaveCount(0); }); }); + +test.describe('Puzzles container sponsorship', () => { + const wordGames = fullPuzzlesLayout.containers[0]!; + const wordWheel = wordGames.content.nestedContainers[0]!; + const layout = { + containers: [ + { + ...wordGames, + content: { ...wordGames.content, nestedContainers: [] }, + commercialProperties: { + AU: { adTargeting: [], branding: puzzlesBranding }, + }, + }, + wordWheel, + ], + }; + + for (const { editionId, isAdFreeUser } of [ + { editionId: 'AU', isAdFreeUser: false }, + { editionId: 'UK', isAdFreeUser: false }, + { editionId: 'AU', isAdFreeUser: true }, + ] as const) { + test(`targets only the sponsored container in ${editionId} (ad-free: ${isAdFreeUser})`, async ({ + context, + page, + }) => { + await disableCMP(context); + await loadPage({ + page, + path, + overrides: { + feFixture: createPuzzlesPage({ + editionId, + isAdFreeUser, + layout, + commercialProperties: {}, + }), + configOverrides, + }, + }); + + const sponsoredContainer = page.getByRole('region', { + name: 'Word games', + exact: true, + }); + await expect(sponsoredContainer).toBeVisible(); + await expect( + page + .getByRole('region', { name: 'Word wheel', exact: true }) + .getByText('Supported by'), + ).toHaveCount(0); + await expect( + page.locator('[data-component="puzzles-branding"]'), + ).toHaveCount(0); + await expect( + page.getByRole('link', { name: 'About this content' }), + ).toHaveCount(0); + const sponsorLink = sponsoredContainer.getByRole('link', { + name: 'Visit the Example sponsor website', + }); + if (editionId === 'UK') { + await expect(sponsorLink).toHaveCount(0); + await expect(page.getByText('Supported by')).toHaveCount(0); + return; + } + + await expect(sponsorLink).toBeVisible(); + await expect(sponsorLink).toHaveAttribute( + 'href', + 'https://example.com', + ); + await expect + .poll(() => + sponsorLink + .getByRole('img') + .evaluate( + (image: HTMLImageElement) => image.naturalWidth, + ), + ) + .toBeGreaterThan(0); + const clickEvent = interceptOphanRequest({ + page, + path: ADDITIONAL_REQUEST_PATH, + searchParamMatcher: (searchParams) => + searchParams.get('clickComponent') === + 'labs-logo | front-container-example-sponsor', + }); + await sponsorLink.click(); + await clickEvent; + }); + } +}); diff --git a/dotcom-rendering/src/components/PuzzlesDirectoryProgress.island.tsx b/dotcom-rendering/src/components/PuzzlesDirectoryProgress.island.tsx index 498059b654f..617ea400253 100644 --- a/dotcom-rendering/src/components/PuzzlesDirectoryProgress.island.tsx +++ b/dotcom-rendering/src/components/PuzzlesDirectoryProgress.island.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { PuzzleProgressItem } from '../lib/puzzleCardProgress'; import { puzzleTypeForCard } from '../lib/puzzleCardProgress'; import { usePuzzleProgress } from '../lib/usePuzzleProgress'; +import type { Branding } from '../types/branding'; import type { PuzzleContainer, PuzzleItem, @@ -16,6 +17,7 @@ type Props = { layout: PuzzlesLayoutType; renderAds: boolean; hideTopRule?: boolean; + containerBranding?: Partial>; }; const londonDateFormatter = new Intl.DateTimeFormat('en-GB', { @@ -132,6 +134,7 @@ export const PuzzlesDirectoryProgress = ({ layout, renderAds, hideTopRule, + containerBranding, }: Props) => { const progress = usePuzzleProgress(); const progressLayout = useMemo( @@ -144,6 +147,7 @@ export const PuzzlesDirectoryProgress = ({ layout={progressLayout} renderAds={renderAds} hideTopRule={hideTopRule} + containerBranding={containerBranding} /> ); }; diff --git a/dotcom-rendering/src/layouts/PuzzlesLayout.test.tsx b/dotcom-rendering/src/layouts/PuzzlesLayout.test.tsx index 9fe28e8d733..74275d89c11 100644 --- a/dotcom-rendering/src/layouts/PuzzlesLayout.test.tsx +++ b/dotcom-rendering/src/layouts/PuzzlesLayout.test.tsx @@ -2,6 +2,7 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { puzzlesBranding } from '../../fixtures/manual/puzzlesBranding'; +import type { Branding } from '../types/branding'; import { PuzzlesLayout } from './PuzzlesLayout'; jest.mock('../components/Masthead/Masthead', () => ({ @@ -11,8 +12,18 @@ jest.mock('../components/Footer', () => ({ Footer: () =>
, })); jest.mock('../components/PuzzlesDirectoryProgress.island', () => ({ - PuzzlesDirectoryProgress: ({ hideTopRule }: { hideTopRule?: boolean }) => ( -
+ PuzzlesDirectoryProgress: ({ + hideTopRule, + containerBranding, + }: { + hideTopRule?: boolean; + containerBranding?: Partial>; + }) => ( +
), })); jest.mock('../components/Section', () => ({ @@ -59,6 +70,57 @@ describe('PuzzlesLayout', () => { ).not.toBeInTheDocument(); }); + it.each(['AU', 'UK'])( + 'selects only %s container branding independently of hub branding', + (editionId) => { + render( + , + ); + + expect(screen.getByTestId('directory')).toHaveAttribute( + 'data-container-branding', + JSON.stringify( + editionId === 'AU' ? { crosswords: puzzlesBranding } : {}, + ), + ); + }, + ); + it('uses the requested puzzle palette when dark mode is available', () => { render( [ + container.id, + container.commercialProperties?.[ + puzzlesPage.editionId + ]?.branding, + ]), + )} /> diff --git a/dotcom-rendering/src/model/validate.puzzlesPage.test.ts b/dotcom-rendering/src/model/validate.puzzlesPage.test.ts index d8f8ae9fbf7..ffcbc9fecdc 100644 --- a/dotcom-rendering/src/model/validate.puzzlesPage.test.ts +++ b/dotcom-rendering/src/model/validate.puzzlesPage.test.ts @@ -1,4 +1,5 @@ import { puzzlesBranding } from '../../fixtures/manual/puzzlesBranding'; +import type { PuzzleContainer } from '../types/puzzlesPage'; import { validateAsPuzzlesPageType } from './validate'; const validPage = () => ({ @@ -323,6 +324,105 @@ describe('validateAsPuzzlesPageType', () => { ).toBe('Example sponsor'); }); + it.each([undefined, 'featured', 'standard'])( + 'accepts per-edition sponsorship on top-level %s containers', + (variant) => { + const page = validPage(); + Object.assign(page.layout.containers[0]!, { + variant, + commercialProperties: { + AU: { adTargeting: [], branding: puzzlesBranding }, + }, + }); + expect(validateAsPuzzlesPageType(page)).toBeDefined(); + }, + ); + + it.each([1, 2])('rejects sponsorship at nested depth %s', (depth) => { + const page = validPage(); + const nested: PuzzleContainer = { + id: 'nested-games', + title: 'Nested games', + content: { items: [], nestedContainers: [] }, + }; + let container = nested; + for (let parentIndex = 0; parentIndex < depth; parentIndex++) { + container = { + id: `parent-${parentIndex}`, + title: 'Parent', + content: { items: [], nestedContainers: [container] }, + }; + } + page.layout.containers = [container as never]; + expect(validateAsPuzzlesPageType(page)).toBeDefined(); + nested.commercialProperties = { + AU: { adTargeting: [], branding: puzzlesBranding }, + }; + expect(() => validateAsPuzzlesPageType(page)).toThrow(); + }); + + it.each([ + { + id: 'ad-container', + title: '', + variant: 'ad', + adSlot: 'inline1', + content: { items: [], nestedContainers: [] }, + }, + { + id: 'supporting-container', + title: '', + variant: 'supporting', + content: { items: [], nestedContainers: [] }, + supporting: { + usefulLinksTitle: 'Useful links', + usefulLinks: [], + popularTitle: 'Popular puzzles', + popularGroups: [], + }, + }, + ])( + 'rejects sponsorship on otherwise valid $variant containers', + (container) => { + const page = validPage(); + page.layout.containers = [container]; + expect(validateAsPuzzlesPageType(page)).toBeDefined(); + Object.assign(container, { + commercialProperties: { + AU: { adTargeting: [], branding: puzzlesBranding }, + }, + }); + expect(() => validateAsPuzzlesPageType(page)).toThrow(); + }, + ); + + it.each([ + null, + { XX: { adTargeting: [] } }, + { AU: { adTargeting: [], branding: { sponsorName: 'Missing logo' } } }, + ])( + 'rejects invalid container commercial properties: %j', + (commercialProperties) => { + for (const nested of [false, true]) { + const page = validPage(); + const container = { + id: 'invalid-sponsor', + title: 'Invalid sponsor', + content: { items: [], nestedContainers: [] }, + commercialProperties, + }; + if (nested) { + page.layout.containers[0]!.content.nestedContainers.push( + container as never, + ); + } else { + Object.assign(page.layout.containers[0]!, container); + } + expect(() => validateAsPuzzlesPageType(page)).toThrow(); + } + }, + ); + it.each([ [ 'branding without a logo', diff --git a/dotcom-rendering/src/model/validate.ts b/dotcom-rendering/src/model/validate.ts index 07c43111e95..3a8c1299709 100644 --- a/dotcom-rendering/src/model/validate.ts +++ b/dotcom-rendering/src/model/validate.ts @@ -286,6 +286,8 @@ const isPuzzleContainer = (data: unknown): data is PuzzleContainer => { data.adSlot !== 'mostpop'))) || (data.supporting !== undefined && !isSupportingContent(data.supporting)) || + (data.commercialProperties !== undefined && + !isPuzzlesCommercialProperties(data.commercialProperties)) || !isObject(data.content) ) { return false; @@ -435,13 +437,21 @@ export const validateAsPuzzlesPageType = (data: unknown): FEPuzzlesPageType => { const topLevelOnlyContainersValid = containers .slice(page.layout.containers.length) .every(({ variant }) => variant !== 'ad' && variant !== 'supporting'); + const containerSponsorshipValid = containers.every( + (container) => + container.commercialProperties === undefined || + (page.layout.containers.includes(container) && + container.variant !== 'ad' && + container.variant !== 'supporting'), + ); if ( !unique(containerIds) || !unique(itemIds) || !unique(adSlots) || !popularReferencesValid || - !topLevelOnlyContainersValid + !topLevelOnlyContainersValid || + !containerSponsorshipValid ) { throw new TypeError( 'Unable to validate request body for puzzles page.', diff --git a/dotcom-rendering/src/types/puzzlesPage.ts b/dotcom-rendering/src/types/puzzlesPage.ts index 95cc3a4f726..5da50ae68a4 100644 --- a/dotcom-rendering/src/types/puzzlesPage.ts +++ b/dotcom-rendering/src/types/puzzlesPage.ts @@ -44,6 +44,7 @@ export type PuzzleContainer = { desktopSpan?: number; adSlot?: string; supporting?: PuzzlesSupportingContent; + commercialProperties?: Partial; }; export type PuzzleLink = {