From a1d1ba2090603d45c45e32784232cb4e9860d0b5 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:20:12 +0000 Subject: [PATCH 1/5] Add importable wrapper for match info component --- .../FootballMatchInfoWrapper.importable.tsx | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx diff --git a/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx b/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx new file mode 100644 index 00000000000..25433e617c3 --- /dev/null +++ b/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx @@ -0,0 +1,55 @@ +import { useApiWithFetcher } from '../lib/useApi'; +import { FootballMatchInfo } from './FootballMatchInfo'; +import { FootballMatchStats, parseMatchStats } from '../footballMatchStats'; +import { error, fromValibot, ok, Result } from '../lib/result'; +import { safeParse } from 'valibot'; +import { feFootballMatchStatsSchema } from '../frontend/feFootballMatchInfoPage'; +import { Placeholder } from './Placeholder'; + +const Loading = () => ; + +export const FootballMatchInfoWrapper = ({ + matchStatsUrl, +}: { + matchStatsUrl: string; +}) => { + const { data, error, loading } = useApiWithFetcher< + FootballMatchStats, + string + >(matchStatsUrl, parse, { + errorRetryCount: 1, + }); + + if (loading) return ; + + if (error) { + // Send the error to Sentry and then prevent the element from rendering + // window.guardian.modules.sentry.reportError(error, 'match-tabs'); + + return null; + } + + if (data) { + return ; + } + + return null; +}; + +const parse: (json: unknown) => Result = ( + json: unknown, +) => { + const feData = fromValibot(safeParse(feFootballMatchStatsSchema, json)); + + if (!feData.ok) { + return error('Failed to validate match stats json'); + } + + const parsedMatchStats = parseMatchStats(feData.value); + + if (!parsedMatchStats.ok) { + return error('Failed to parse the match stats from the stats json'); + } + + return ok(parsedMatchStats.value); +}; From d5ed3dbc4be8ce1e3d2575f662304918c76b9e65 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:24:33 +0000 Subject: [PATCH 2/5] Update article schema with match header and stats urls --- dotcom-rendering/src/frontend/feArticle.ts | 2 ++ dotcom-rendering/src/frontend/schemas/feArticle.json | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/dotcom-rendering/src/frontend/feArticle.ts b/dotcom-rendering/src/frontend/feArticle.ts index def14ffb92b..a73b602a575 100644 --- a/dotcom-rendering/src/frontend/feArticle.ts +++ b/dotcom-rendering/src/frontend/feArticle.ts @@ -104,6 +104,8 @@ export interface FEArticle { pageType: PageType; matchUrl?: string; + matchHeaderUrl?: string; + matchStatsUrl?: string; matchType?: MatchType; isSpecialReport: boolean; diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 08f14d53b63..e9e66e3ab07 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -368,6 +368,12 @@ "matchUrl": { "type": "string" }, + "matchHeaderUrl": { + "type": "string" + }, + "matchStatsUrl": { + "type": "string" + }, "matchType": { "$ref": "#/definitions/MatchType" }, From 4b36ff883ed1816c94af250247fa3393a3f0c481 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:21:17 +0000 Subject: [PATCH 3/5] Render football match info wrapper in standard layout --- .../FootballMatchInfoWrapper.importable.tsx | 26 +++--- .../src/layouts/StandardLayout.tsx | 82 ++++++++++++++++--- dotcom-rendering/src/lib/useApi.ts | 45 ++++++++++ 3 files changed, 131 insertions(+), 22 deletions(-) diff --git a/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx b/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx index 25433e617c3..5c7a1ec4cc7 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx @@ -1,9 +1,12 @@ -import { useApiWithFetcher } from '../lib/useApi'; -import { FootballMatchInfo } from './FootballMatchInfo'; -import { FootballMatchStats, parseMatchStats } from '../footballMatchStats'; -import { error, fromValibot, ok, Result } from '../lib/result'; +import { log } from '@guardian/libs'; import { safeParse } from 'valibot'; +import type { FootballMatchStats } from '../footballMatchStats'; +import { parseMatchStats } from '../footballMatchStats'; import { feFootballMatchStatsSchema } from '../frontend/feFootballMatchInfoPage'; +import type { Result } from '../lib/result'; +import { error, fromValibot, ok } from '../lib/result'; +import { useApiWithParse } from '../lib/useApi'; +import { FootballMatchInfo } from './FootballMatchInfo'; import { Placeholder } from './Placeholder'; const Loading = () => ; @@ -13,18 +16,21 @@ export const FootballMatchInfoWrapper = ({ }: { matchStatsUrl: string; }) => { - const { data, error, loading } = useApiWithFetcher< - FootballMatchStats, - string - >(matchStatsUrl, parse, { + const { + data, + error: apiError, + loading, + } = useApiWithParse(matchStatsUrl, parse, { errorRetryCount: 1, }); if (loading) return ; - if (error) { + if (apiError) { // Send the error to Sentry and then prevent the element from rendering - // window.guardian.modules.sentry.reportError(error, 'match-tabs'); + window.guardian.modules.sentry.reportError(apiError, 'match-stats'); + + log('dotcom', apiError); return null; } diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 95ac6cbd73f..7b2a56f96e9 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/react'; +import { log } from '@guardian/libs'; import { from, palette as sourcePalette, @@ -23,6 +24,7 @@ import { Carousel } from '../components/Carousel.importable'; import { DecideLines } from '../components/DecideLines'; import { DirectoryPageNav } from '../components/DirectoryPageNav'; import { DiscussionLayout } from '../components/DiscussionLayout'; +import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.importable'; import { Footer } from '../components/Footer'; import { GetMatchNav } from '../components/GetMatchNav.importable'; import { GetMatchStats } from '../components/GetMatchStats.importable'; @@ -54,7 +56,9 @@ import { import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; import { decideStoryPackageTrails } from '../lib/decideTrail'; +import { safeParseURL } from '../lib/parse'; import { parse } from '../lib/slot-machine-flags'; +import { useBetaAB } from '../lib/useAB'; import type { NavType } from '../model/extract-nav'; import { palette as themePalette } from '../palette'; import type { ArticleDeprecated } from '../types/article'; @@ -344,6 +348,11 @@ export const StandardLayout = (props: WebProps | AppProps) => { editionId, } = article; + const abTests = useBetaAB(); + const isInFootballRedesignVariantGroup = + abTests?.isUserInTestGroup('webex-football-redesign', 'variant') ?? + false; + const isWeb = renderingTarget === 'Web'; const isApps = renderingTarget === 'Apps'; @@ -361,6 +370,11 @@ export const StandardLayout = (props: WebProps | AppProps) => { ? article.matchUrl : undefined; + const footballMatchStatsUrl = + article.matchType === 'FootballMatchType' + ? article.matchStatsUrl + : undefined; + const isMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; @@ -722,18 +736,17 @@ export const StandardLayout = (props: WebProps | AppProps) => { shouldHideAds={article.shouldHideAds} idApiUrl={article.config.idApiUrl} /> - {format.design === ArticleDesign.MatchReport && - !!footballMatchUrl && ( - - - - )} + {isApps && ( { ); }; + +export const MatchInfoContainer = ({ + isMatchReport, + isInVariantGroup, + footballMatchUrl, + footballMatchStatsUrl, + format, +}: { + isMatchReport: boolean; + isInVariantGroup: boolean; + footballMatchUrl: string | undefined; + footballMatchStatsUrl: string | undefined; + format: ArticleFormat; +}) => { + if (isMatchReport && isInVariantGroup && !!footballMatchStatsUrl) { + const parsedUrl = safeParseURL(footballMatchStatsUrl); + if (!parsedUrl.ok) { + log( + 'dotcom', + new Error( + `Failed to parse match stats URL: ${footballMatchStatsUrl}`, + ), + ); + + return null; + } + return ( + + + + ); + } + + if (isMatchReport && !!footballMatchUrl) { + return ( + + + + ); + } + + return null; +}; diff --git a/dotcom-rendering/src/lib/useApi.ts b/dotcom-rendering/src/lib/useApi.ts index 02f18fcdee5..d859d9065d8 100644 --- a/dotcom-rendering/src/lib/useApi.ts +++ b/dotcom-rendering/src/lib/useApi.ts @@ -1,6 +1,8 @@ import { isUndefined } from '@guardian/libs'; +import { useMemo } from 'react'; import type { SWRConfiguration } from 'swr'; import useSWR from 'swr'; +import type { Result } from './result'; function checkForErrors(response: Response) { if (!response.ok) { @@ -49,3 +51,46 @@ export const useApi = ( loading: !!url && isUndefined(error) && isUndefined(data), }; }; + +const fetcherWithParse = + (parse: (json: unknown) => Result) => + (url: string): Promise => + fetch(url) + .then((res) => res.json()) + .then(parse) + .then((result) => { + if (!result.ok) { + throw new Error(result.error); + } else { + return result.value; + } + }) + .catch(() => { + throw new Error('Failed to fetch match header json'); + }); + +/** + * A custom hook to make a GET request using the given url using the SWR lib (https://swr.vercel.app/). + * + * @template Data assert the expected response type + * @template Err assert the potential error type + * @param {Url} url - The API endpoint. Falsy values will prevent any network requests + * @param {(json: unknown) => Result} parse - A function that takes the raw json response and returns a Result with the type safe parsed data or an error message + * @param {SWRConfiguration} options - The SWR config object - https://swr.vercel.app/docs/api#options + * @returns {ApiResponse} + * */ +export const useApiWithParse = ( + url: string, + parse: (json: unknown) => Result, + options?: SWRConfiguration, +): ApiResponse => { + const fetchData = useMemo(() => fetcherWithParse(parse), [parse]); + + const { data, error } = useSWR(url, fetchData, options); + + return { + data, + error, + loading: !!url && isUndefined(error) && isUndefined(data), + }; +}; From 6ce18a17dfae9d8a51f41cb2ca8c40134ed862fe Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:16:56 +0000 Subject: [PATCH 4/5] Add redesigned match header to standard layout --- .../FootballMatchHeader.stories.tsx | 9 +- .../FootballMatchHeader.tsx | 2 +- .../src/components/FootballMatchInfoPage.tsx | 2 +- .../src/layouts/StandardLayout.tsx | 84 +++++++++++++++++-- 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx index baaa2cf063e..4008e226150 100644 --- a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx +++ b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx @@ -68,9 +68,8 @@ export const Fixture = { reportURL: undefined, }), refreshInterval: 3_000, - matchHeaderURL: new URL( + matchHeaderURL: 'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json', - ), }, play: async ({ canvas, canvasElement, step }) => { const nav = canvas.getByRole('navigation'); @@ -107,9 +106,8 @@ export const Live = { args: { initialTab: 'live', edition: 'EUR', - matchHeaderURL: new URL( + matchHeaderURL: 'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json', - ), refreshInterval: Fixture.args.refreshInterval, getHeaderData: () => getMockData({ @@ -152,9 +150,8 @@ export const Result = { args: { initialTab: 'report', edition: 'AU', - matchHeaderURL: new URL( + matchHeaderURL: 'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json', - ), refreshInterval: Fixture.args.refreshInterval, getHeaderData: () => getMockData({ diff --git a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx index 0c8f6454c72..738c951b75f 100644 --- a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx +++ b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx @@ -34,7 +34,7 @@ export type FootballMatchHeaderProps = { initialTab: ComponentProps['selected']; initialData?: HeaderData; edition: EditionId; - matchHeaderURL: URL; + matchHeaderURL: string; }; type Props = FootballMatchHeaderProps & { diff --git a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx index e0c4b1a7bdc..1e3d42ccaad 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx @@ -42,7 +42,7 @@ export const FootballMatchInfoPage = ({ }, }} edition={edition} - matchHeaderURL={matchHeaderUrl} + matchHeaderURL={matchHeaderUrl.href} />
diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 7b2a56f96e9..c63cf2ebfe1 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -24,6 +24,7 @@ import { Carousel } from '../components/Carousel.importable'; import { DecideLines } from '../components/DecideLines'; import { DirectoryPageNav } from '../components/DirectoryPageNav'; import { DiscussionLayout } from '../components/DiscussionLayout'; +import { FootballMatchHeaderWrapper } from '../components/FootballMatchHeaderWrapper.importable'; import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.importable'; import { Footer } from '../components/Footer'; import { GetMatchNav } from '../components/GetMatchNav.importable'; @@ -56,6 +57,7 @@ import { import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; import { decideStoryPackageTrails } from '../lib/decideTrail'; +import type { EditionId } from '../lib/edition'; import { safeParseURL } from '../lib/parse'; import { parse } from '../lib/slot-machine-flags'; import { useBetaAB } from '../lib/useAB'; @@ -69,10 +71,12 @@ const StandardGrid = ({ children, isMatchReport, isMedia, + isInFootballRedesignVariantGroup, }: { children: React.ReactNode; isMatchReport: boolean; isMedia: boolean; + isInFootballRedesignVariantGroup: boolean; }) => (
{ editionId, } = article; + const isWeb = renderingTarget === 'Web'; + const isApps = renderingTarget === 'Apps'; + const abTests = useBetaAB(); const isInFootballRedesignVariantGroup = - abTests?.isUserInTestGroup('webex-football-redesign', 'variant') ?? + (abTests?.isUserInTestGroup('webex-football-redesign', 'variant') && + isWeb) ?? false; - const isWeb = renderingTarget === 'Web'; - const isApps = renderingTarget === 'Apps'; - const showBodyEndSlot = isWeb && (parse(article.slotMachineFlags ?? '').showBodyEnd || @@ -375,6 +395,11 @@ export const StandardLayout = (props: WebProps | AppProps) => { ? article.matchStatsUrl : undefined; + const footballMatchHeaderUrl = + article.matchType === 'FootballMatchType' + ? article.matchHeaderUrl + : undefined; + const isMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; @@ -442,6 +467,13 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} + + {isWeb && renderAds && hasSurveyAd && ( )} @@ -469,6 +501,9 @@ export const StandardLayout = (props: WebProps | AppProps) => {
@@ -1093,7 +1128,44 @@ export const StandardLayout = (props: WebProps | AppProps) => { ); }; -export const MatchInfoContainer = ({ +const MatchHeaderContainer = ({ + isMatchReport, + isInVariantGroup, + footballMatchHeaderUrl, + editionId, +}: { + isMatchReport: boolean; + isInVariantGroup: boolean; + footballMatchHeaderUrl: string | undefined; + editionId: EditionId; +}) => { + if (isMatchReport && isInVariantGroup && !!footballMatchHeaderUrl) { + const parsedUrl = safeParseURL(footballMatchHeaderUrl); + if (!parsedUrl.ok) { + log( + 'dotcom', + new Error( + `Failed to parse match header URL: ${footballMatchHeaderUrl}`, + ), + ); + + return null; + } + return ( + + + + ); + } + + return null; +}; + +const MatchInfoContainer = ({ isMatchReport, isInVariantGroup, footballMatchUrl, From c883844c37472941c91bb60440004b2fbe9e7f81 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:15:51 +0000 Subject: [PATCH 5/5] Remove article title for redesigned match reports --- .../src/layouts/StandardLayout.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index c63cf2ebfe1..6aac6bbc26a 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -561,16 +561,19 @@ export const StandardLayout = (props: WebProps | AppProps) => { />
- - - + {!isInFootballRedesignVariantGroup && ( + + + + )} + {format.theme === ArticleSpecial.Labs ? ( <>