diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index aef2224674e..d96358110c2 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -197,6 +197,17 @@ const ABTests: ABTest[] = [ groups: ["control", "variant-1"], shouldForceMetricsCollection: false, }, + { + name: "webx-cricket-redesign", + description: "Redesign of the cricket header and scorecard on web", + owners: ["dotcom.platform@theguardian.com"], + status: "ON", + expirationDate: "2026-08-01", + type: "server", + audienceSize: 0 / 100, + groups: ["enable"], + shouldForceMetricsCollection: false, + }, ]; const activeABtests = ABTests.filter((test) => test.status === "ON"); diff --git a/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.stories.tsx b/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.stories.tsx index 4230553befe..83635424295 100644 --- a/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.stories.tsx +++ b/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { expect, within } from 'storybook/test'; import type { CricketMatch } from '../../cricketMatchV2'; import type { EditionId } from '../../lib/edition'; import { CricketMatchHeader } from './CricketMatchHeader'; @@ -89,7 +90,7 @@ const defaultInnings = { const baseArgs = { edition: 'UK' as EditionId, - match: { + initialData: { kind: 'Fixture' as CricketMatch['kind'], series: 'Ashes 2025–2026', competition: 'Second Test Match', @@ -112,17 +113,50 @@ const baseArgs = { infoURL: new URL( 'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket#scorecard', ), + matchHeaderURL: + 'https://api.nextgen.guardianapps.co.uk/sport/cricket/match-header/2026-06-13/australia-women-s-cricket-team.json', + refreshInterval: 3_000, + getHeaderData: () => Promise.resolve(undefined), }; export const Fixture = { args: baseArgs, + play: async ({ canvas, canvasElement, step }) => { + const nav = canvas.getByRole('navigation'); + + await step( + 'Placeholder not shown as we have initial data and can render header', + async () => { + void expect( + canvasElement.querySelector('[data-name="placeholder"]'), + ).toBeNull(); + + const initialTabs = within(nav).getAllByRole('listitem'); + + void expect(initialTabs.length).toBe(1); + void expect(initialTabs[0]).toHaveTextContent('Scorecard'); + }, + ); + + await step('Fetch updated match header data', async () => { + // Wait for 'Ashes 2025–2026' to appear which indicates match header + // data has been fetched and the UI updated on the client + await canvas.findByText('Ashes 2025–2026'); + void canvas.findByText('England'); + void canvas.findByText('Australia'); + + const updatedTabs = within(nav).getAllByRole('listitem'); + void expect(updatedTabs.length).toBe(1); + void expect(updatedTabs[0]).toHaveTextContent('Scorecard'); + }); + }, } satisfies Story; export const Live = { args: { ...baseArgs, - match: { - ...Fixture.args.match, + initialData: { + ...Fixture.args.initialData, kind: 'Live' as CricketMatch['kind'], day: 2, innings: [ @@ -160,6 +194,29 @@ export const Live = { 'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket', ), }, + play: async ({ canvas, step }) => { + await step('Fetch match header data and render UI', async () => { + // Wait for 'Ashes 2025–2026' to appear which signals match header + // data has been fetched and the UI rendered on the client + await canvas.findByText('Ashes 2025–2026'); + void canvas.findByText('England'); + void canvas.findByText('Australia'); + + void expect( + canvas.getByLabelText('169 runs, 0 wickets fallen'), + ).toBeInTheDocument(); + void expect( + canvas.getByLabelText('173 runs, 3 wickets fallen'), + ).toBeInTheDocument(); + + const nav = canvas.getByRole('navigation'); + const tabs = within(nav).getAllByRole('listitem'); + + void expect(tabs.length).toBe(2); + void expect(tabs[0]).toHaveTextContent('Live feed'); + void expect(tabs[1]).toHaveTextContent('Scorecard'); + }); + }, } satisfies Story; export const LiveYetToBat = { @@ -167,8 +224,8 @@ export const LiveYetToBat = { args: { ...baseArgs, edition: 'UK' as EditionId, - match: { - ...Fixture.args.match, + initialData: { + ...Fixture.args.initialData, kind: 'Live', day: 2, innings: [ @@ -200,8 +257,8 @@ export const Result = { args: { ...baseArgs, edition: 'UK' as EditionId, - match: { - ...Fixture.args.match, + initialData: { + ...Fixture.args.initialData, kind: 'Result', day: 4, innings: [ @@ -264,14 +321,30 @@ export const Result = { 'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket', ), }, + play: async ({ canvas, step }) => { + await step('Fetch match header data and render UI', async () => { + // Wait for 'Ashes 2025–2026' to appear which signals match header + // data has been fetched and the UI rendered on the client + await canvas.findByText('Ashes 2025–2026'); + void canvas.findByText('England'); + void canvas.findByText('Australia'); + + const nav = canvas.getByRole('navigation'); + const tabs = within(nav).getAllByRole('listitem'); + + void expect(tabs.length).toBe(2); + void expect(tabs[0]).toHaveTextContent('Live feed'); + void expect(tabs[1]).toHaveTextContent('Scorecard'); + }); + }, } satisfies Story; export const ResultWinByWickets = { args: { ...baseArgs, edition: 'UK' as EditionId, - match: { - ...Fixture.args.match, + initialData: { + ...Fixture.args.initialData, kind: 'Result', innings: [ { @@ -321,8 +394,8 @@ export const ResultDrawn = { args: { ...baseArgs, edition: 'UK' as EditionId, - match: { - ...Fixture.args.match, + initialData: { + ...Fixture.args.initialData, kind: 'Result', day: 4, innings: [ diff --git a/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.tsx b/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.tsx index edce73ffbcd..23819d5b59e 100644 --- a/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.tsx +++ b/dotcom-rendering/src/components/CricketMatchHeader/CricketMatchHeader.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/react'; +import { log } from '@guardian/libs'; import { from, headlineBold20Object, @@ -14,6 +15,8 @@ import { until, } from '@guardian/source/foundations'; import { Fragment, type ReactNode, useMemo } from 'react'; +import type { SWRConfiguration } from 'swr'; +import useSWR from 'swr'; import type { CricketMatch, CricketResult, @@ -35,19 +38,47 @@ import { primaryText, secondaryText, } from '../FootballMatchHeader/colours'; +import type { TabName } from '../FootballMatchHeader/Tabs'; import { Tabs } from '../FootballMatchHeader/Tabs'; +import { Placeholder } from '../Placeholder'; +import type { CricketHeaderData } from './headerData'; +import { parse as parseHeaderData } from './headerData'; -type Props = { +export type CricketMatchHeaderProps = { + initialData?: CricketMatch; + matchHeaderURL: string; edition: EditionId; - match: CricketMatch; selectedTab: 'info' | 'live' | 'report'; reportURL?: URL; liveURL?: URL; infoURL?: URL; }; +type Props = CricketMatchHeaderProps & { + getHeaderData: (url: string) => Promise; + refreshInterval: number; +}; + export const CricketMatchHeader = (props: Props) => { - const match = props.match; + const { data } = useSWR( + props.matchHeaderURL, + fetcher(props.selectedTab, props.getHeaderData), + swrOptions(props.refreshInterval), + ); + const match = data?.match ?? props.initialData; + + if (match === undefined) { + return ( + + ); + } return (
{ ); }; +const swrOptions = ( + refreshInterval: number, +): SWRConfiguration => ({ + errorRetryCount: 1, + refreshInterval: (latestData: CricketHeaderData | undefined) => { + return latestData?.match.kind === 'Live' || + latestData?.match.kind === 'Fixture' + ? refreshInterval + : 0; + }, +}); + +const fetcher = + (selected: TabName, getHeaderData: Props['getHeaderData']) => + (url: string): Promise => + getHeaderData(url) + .then(parseHeaderData(selected)) + .then((result) => { + if (!result.ok) { + log('dotcom', result.error); + throw new Error(); + } else { + return result.value; + } + }) + .catch(() => { + log('dotcom', 'Failed to fetch match header json'); + throw new Error(); + }); + const StatusLine = (props: { match: CricketMatch; edition: EditionId }) => (

; + match: CricketMatch; +}; + +export const parse = + (selected: CricketHeaderData['tabs']['selected']) => + (json: unknown): Result => { + const feData = fromValibot(safeParse(feCricketMatchHeaderSchema, json)); + + if (!feData.ok) { + return error('Failed to validate match header json'); + } + + const parsedMatch = parseCricketMatchV2(feData.value.cricketMatch); + + if (!parsedMatch.ok) { + return error('Failed to parse the match from the header json'); + } + + const maybeTabs = createTabs( + selected, + feData.value, + parsedMatch.value.kind, + ); + + if (!maybeTabs.ok) { + return error( + `The match header data contained an invalid ${maybeTabs.error.kind} URL`, + ); + } + + return ok({ + match: parsedMatch.value, + tabs: maybeTabs.value, + }); + }; + +type MatchURLError = { + kind: 'live' | 'report'; +}; + +const createTabs = ( + selected: CricketHeaderData['tabs']['selected'], + feData: FECricketMatchHeader, + matchKind: CricketMatch['kind'], +): Result => { + const reportURL = + feData.reportURL !== undefined + ? safeParseURL(feData.reportURL) + : undefined; + const liveURL = + feData.liveURL !== undefined ? safeParseURL(feData.liveURL) : undefined; + if (reportURL !== undefined && !reportURL.ok) { + return error({ kind: 'report' }); + } + + if (liveURL !== undefined && !liveURL.ok) { + return error({ kind: 'live' }); + } + + switch (selected) { + case 'info': + return ok({ + matchKind, + sportKind: 'cricket', + selected, + reportURL: reportURL?.value, + liveURL: liveURL?.value, + }); + case 'live': + return ok({ + matchKind, + sportKind: 'cricket', + selected, + reportURL: reportURL?.value, + }); + case 'report': + return ok({ + matchKind, + sportKind: 'cricket', + selected, + liveURL: liveURL?.value, + }); + } +}; diff --git a/dotcom-rendering/src/components/CricketMatchHeaderWrapper.island.tsx b/dotcom-rendering/src/components/CricketMatchHeaderWrapper.island.tsx new file mode 100644 index 00000000000..c4d34f4ca93 --- /dev/null +++ b/dotcom-rendering/src/components/CricketMatchHeaderWrapper.island.tsx @@ -0,0 +1,32 @@ +import type { CricketMatch } from '../cricketMatchV2'; +import type { CricketMatchHeaderProps } from './CricketMatchHeader/CricketMatchHeader'; +import { CricketMatchHeader } from './CricketMatchHeader/CricketMatchHeader'; + +type Props = + | (CricketMatchHeaderProps & { + selectedTab: 'info'; + initialData: CricketMatch; + }) + | (CricketMatchHeaderProps & { + selectedTab: 'live' | 'report'; + initialData?: never; + }); + +export const CricketMatchHeaderWrapper = (props: Props) => ( + +); + +const fixHydration = (initialData: CricketMatch): CricketMatch => ({ + ...initialData, + matchDate: new Date(initialData.matchDate), +}); + +const getHeaderData = (url: string): Promise => + fetch(url).then((res) => res.json()); diff --git a/dotcom-rendering/src/components/CricketScorecardPageNew.stories.tsx b/dotcom-rendering/src/components/CricketScorecardPageNew.stories.tsx index ff54be283bd..8c8ddf21c7b 100644 --- a/dotcom-rendering/src/components/CricketScorecardPageNew.stories.tsx +++ b/dotcom-rendering/src/components/CricketScorecardPageNew.stories.tsx @@ -65,10 +65,11 @@ const baseArgs = { 'R S Madugalle', ], }, - selectedTab: 'info' as 'info' | 'live' | 'report', infoURL: new URL( 'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket#scorecard', ), + matchHeaderURL: + 'https://api.nextgen.guardianapps.co.uk/sport/cricket/match-header/2026-06-13/australia-women-s-cricket-team.json', edition: 'UK', } satisfies ComponentProps; @@ -81,7 +82,6 @@ export const CricketScorecardPageNewLive = meta.story({ name: 'Cricket Scorecard Page Live (New)', args: { ...baseArgs, - selectedTab: 'info', liveURL: new URL( 'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket', diff --git a/dotcom-rendering/src/components/CricketScorecardPageNew.tsx b/dotcom-rendering/src/components/CricketScorecardPageNew.tsx index 20210933518..3929b47b8b4 100644 --- a/dotcom-rendering/src/components/CricketScorecardPageNew.tsx +++ b/dotcom-rendering/src/components/CricketScorecardPageNew.tsx @@ -4,31 +4,31 @@ import type { CricketMatch } from '../cricketMatchV2'; import { grid } from '../grid'; import { type EditionId } from '../lib/edition'; import { palette } from '../palette'; -import { CricketMatchHeader } from './CricketMatchHeader/CricketMatchHeader'; +import { CricketMatchHeaderWrapper } from './CricketMatchHeaderWrapper.island'; import { CricketScorecardNew } from './CricketScorecardNew'; -import type { TabName } from './FootballMatchHeader/Tabs'; export const CricketScorecardPageNew = ({ match, edition, - selectedTab, + matchHeaderURL, infoURL, liveURL, reportURL, }: { match: CricketMatch; edition: EditionId; - selectedTab: TabName; + matchHeaderURL: string; infoURL?: URL; liveURL?: URL; reportURL?: URL; }) => { return (

- ; diff --git a/dotcom-rendering/src/layouts/LiveLayout.tsx b/dotcom-rendering/src/layouts/LiveLayout.tsx index c195252f4fe..9546cf39489 100644 --- a/dotcom-rendering/src/layouts/LiveLayout.tsx +++ b/dotcom-rendering/src/layouts/LiveLayout.tsx @@ -19,6 +19,7 @@ import { ArticleMetaApps } from '../components/ArticleMeta.apps'; import { ArticleMeta } from '../components/ArticleMeta.web'; import { ArticleTitle } from '../components/ArticleTitle'; import { Carousel } from '../components/Carousel.island'; +import { CricketMatchHeaderWrapper } from '../components/CricketMatchHeaderWrapper.island'; import { DecideLines } from '../components/DecideLines'; import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland'; import { DiscussionLayout } from '../components/DiscussionLayout'; @@ -50,6 +51,7 @@ import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; import { decideStoryPackageTrails } from '../lib/decideTrail'; import { getZIndex } from '../lib/getZIndex'; +import { useAB } from '../lib/useAB'; import { worldCupTagId } from '../lib/worldCup2026'; import type { NavType } from '../model/extract-nav'; import { palette as themePalette } from '../palette'; @@ -281,11 +283,6 @@ export const LiveLayout = (props: WebProps | AppsProps) => { const { branding } = article.commercialProperties[article.editionId]; - const footballMatchUrl = - article.matchType === 'FootballMatchType' - ? article.matchUrl - : undefined; - const footballMatchHeaderUrl = article.matchType === 'FootballMatchType' ? article.matchHeaderUrl @@ -296,9 +293,6 @@ export const LiveLayout = (props: WebProps | AppsProps) => { ? article.matchStatsUrl : undefined; - const footballMatchLeagueName = article.sectionLabel; - const footballMatchLeagueUrl = `${article.guardianBaseURL}/${article.sectionUrl}`; - const cricketMatchUrl = article.matchType === 'CricketMatchType' ? article.matchUrl : undefined; @@ -383,71 +377,11 @@ export const LiveLayout = (props: WebProps | AppsProps) => { )} - {footballMatchUrl ? ( - footballMatchHeaderUrl && ( - <> - - - - - - ) - ) : ( -
- - - - - -
- {!footballMatchUrl && ( - - )} -
-
-
-
- )} - +
{ id="maincontent" css={[ bodyWrapper, - !!footballMatchUrl && + !!footballMatchHeaderUrl && footballMatchBodyWrapper, ]} > @@ -1147,3 +1081,102 @@ export const LiveLayout = (props: WebProps | AppsProps) => { ); }; + +const Header = (props: { + renderingTarget: RenderingTarget; + format: ArticleFormat; + article: ArticleDeprecated; +}) => { + const footballMatchLeagueName = props.article.sectionLabel; + const footballMatchLeagueUrl = `${props.article.guardianBaseURL}/${props.article.sectionUrl}`; + const footballMatchHeaderUrl = + props.article.matchType === 'FootballMatchType' + ? props.article.matchHeaderUrl + : undefined; + const cricketMatchHeaderUrl = + props.article.matchType === 'CricketMatchType' + ? props.article.matchHeaderUrl + : undefined; + + const ab = useAB(); + const isCricketRedesignEnabled = Boolean( + ab?.isUserInTestGroup('webx-cricket-redesign', 'enable'), + ); + + const isApps = props.renderingTarget === 'Apps'; + + if (footballMatchHeaderUrl) { + return ( + <> + + + + + + ); + } + + if (!isApps && cricketMatchHeaderUrl && isCricketRedesignEnabled) { + return ( + + + + ); + } + + return ( +
+ + + + + +
+ {!footballMatchHeaderUrl && ( + + )} +
+
+
+
+ ); +};