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/components/FootballMatchInfoWrapper.importable.tsx b/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx new file mode 100644 index 00000000000..5c7a1ec4cc7 --- /dev/null +++ b/dotcom-rendering/src/components/FootballMatchInfoWrapper.importable.tsx @@ -0,0 +1,61 @@ +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 = () => ; + +export const FootballMatchInfoWrapper = ({ + matchStatsUrl, +}: { + matchStatsUrl: string; +}) => { + const { + data, + error: apiError, + loading, + } = useApiWithParse(matchStatsUrl, parse, { + errorRetryCount: 1, + }); + + if (loading) return ; + + if (apiError) { + // Send the error to Sentry and then prevent the element from rendering + window.guardian.modules.sentry.reportError(apiError, 'match-stats'); + + log('dotcom', apiError); + + 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); +}; 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" }, diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 95ac6cbd73f..6aac6bbc26a 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,8 @@ 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'; import { GetMatchStats } from '../components/GetMatchStats.importable'; @@ -54,7 +57,10 @@ 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'; import type { NavType } from '../model/extract-nav'; import { palette as themePalette } from '../palette'; import type { ArticleDeprecated } from '../types/article'; @@ -65,10 +71,12 @@ const StandardGrid = ({ children, isMatchReport, isMedia, + isInFootballRedesignVariantGroup, }: { children: React.ReactNode; isMatchReport: boolean; isMedia: boolean; + isInFootballRedesignVariantGroup: boolean; }) => (
{ const isWeb = renderingTarget === 'Web'; const isApps = renderingTarget === 'Apps'; + const abTests = useBetaAB(); + const isInFootballRedesignVariantGroup = + (abTests?.isUserInTestGroup('webex-football-redesign', 'variant') && + isWeb) ?? + false; + const showBodyEndSlot = isWeb && (parse(article.slotMachineFlags ?? '').showBodyEnd || @@ -361,6 +390,16 @@ export const StandardLayout = (props: WebProps | AppProps) => { ? article.matchUrl : undefined; + const footballMatchStatsUrl = + article.matchType === 'FootballMatchType' + ? article.matchStatsUrl + : undefined; + + const footballMatchHeaderUrl = + article.matchType === 'FootballMatchType' + ? article.matchHeaderUrl + : undefined; + const isMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; @@ -428,6 +467,13 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} + + {isWeb && renderAds && hasSurveyAd && ( )} @@ -455,6 +501,9 @@ export const StandardLayout = (props: WebProps | AppProps) => {
@@ -512,16 +561,19 @@ export const StandardLayout = (props: WebProps | AppProps) => { />
- - - + {!isInFootballRedesignVariantGroup && ( + + + + )} + {format.theme === ArticleSpecial.Labs ? ( <> @@ -722,18 +774,17 @@ export const StandardLayout = (props: WebProps | AppProps) => { shouldHideAds={article.shouldHideAds} idApiUrl={article.config.idApiUrl} /> - {format.design === ArticleDesign.MatchReport && - !!footballMatchUrl && ( - - - - )} + {isApps && ( { ); }; + +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, + 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), + }; +};