Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions dotcom-rendering/src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { CardCommentCount } from '../CardCommentCount.island';
import { CardHeadline, type ResponsiveFontSize } from '../CardHeadline';
import type { Loading } from '../CardPicture';
import { CardPicture } from '../CardPicture';
import { CardSnapAtom } from '../CardSnapAtom';
import { Island } from '../Island';
import { LatestLinks } from '../LatestLinks.island';
import { Pill } from '../Pill';
Expand Down Expand Up @@ -508,6 +509,12 @@ export const Card = ({
);
}

// SPIKE: render an atom (guide, Q&A, profile, timeline, audio, explainer or
// CTA) that arrived on a front as a `LinkSnap`.
if (snapData?.atoms) {
return <CardSnapAtom atoms={snapData.atoms} />;
}

// Determine if the card is used within a gallery article
const isInGalleryContext = contextFormat?.design === ArticleDesign.Gallery;

Expand Down
128 changes: 128 additions & 0 deletions dotcom-rendering/src/components/CardSnapAtom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { SnapAtoms } from '../types/front';
import { AudioAtomWrapper } from './AudioAtomWrapper.island';
import { CallToActionAtom } from './CallToActionAtom';
import { ExplainerAtom } from './ExplainerAtom';
import { FootballCompetitionAtom } from './FootballCompetitionAtom.island';
import { GuideAtomWrapper } from './GuideAtomWrapper.island';
import { Island } from './Island';
import { ProfileAtomWrapper } from './ProfileAtomWrapper.island';
import { QandaAtom } from './QandaAtom.island';
import { TimelineAtom } from './TimelineAtom.island';

type Props = {
atoms: SnapAtoms;
};

/**
* Renders an atom (guide, Q&A, profile, timeline, audio, explainer or CTA) that
* has been placed on a front as a snap and pre-fetched by facia-press.
*
* Mirrors the equivalent atom cases in `renderElement`, keeping the conditional
* atom logic out of `Card`.
*/
export const CardSnapAtom = ({ atoms }: Props) => {
if (atoms.guide) {
return (
<Island priority="feature" defer={{ until: 'visible' }}>
<GuideAtomWrapper
id={atoms.guide.id}
title={atoms.guide.title}
html={atoms.guide.html}
image={atoms.guide.img}
credit={atoms.guide.credit}
/>
</Island>
);
}

if (atoms.qanda) {
return (
<Island priority="feature" defer={{ until: 'visible' }}>
<QandaAtom
id={atoms.qanda.id}
title={atoms.qanda.title}
html={atoms.qanda.html}
image={atoms.qanda.img}
credit={atoms.qanda.credit}
/>
</Island>
);
}

if (atoms.profile) {
return (
<Island priority="feature" defer={{ until: 'visible' }}>
<ProfileAtomWrapper
id={atoms.profile.id}
title={atoms.profile.title}
html={atoms.profile.html}
image={atoms.profile.img}
credit={atoms.profile.credit}
/>
</Island>
);
}

if (atoms.timeline) {
return (
<Island priority="feature" defer={{ until: 'visible' }}>
<TimelineAtom
id={atoms.timeline.id}
title={atoms.timeline.title}
events={atoms.timeline.events}
description={atoms.timeline.description}
/>
</Island>
);
}

if (atoms.audio) {
return (
<Island priority="critical" defer={{ until: 'visible' }}>
<AudioAtomWrapper
id={atoms.audio.id}
trackUrl={atoms.audio.trackUrl}
kicker={atoms.audio.kicker}
title={atoms.audio.title}
duration={atoms.audio.duration}
contentIsNotSensitive={true}
aCastisEnabled={false}
readerCanBeShownAds={false}
/>
</Island>
);
}

if (atoms.explainer) {
return (
<ExplainerAtom
id={atoms.explainer.id}
title={atoms.explainer.title}
html={atoms.explainer.body}
/>
);
}

if (atoms.cta) {
return (
<CallToActionAtom
linkUrl={atoms.cta.url}
backgroundImage={atoms.cta.image}
text={atoms.cta.label}
buttonText={atoms.cta.btnText}
/>
);
}

if (atoms.tempfootballcompetition) {
return (
<Island priority="feature" defer={{ until: 'visible' }}>
<FootballCompetitionAtom
footballCompetitionData={atoms.tempfootballcompetition}
/>
</Island>
);
}

return null;
};
85 changes: 85 additions & 0 deletions dotcom-rendering/src/components/FootballCompetitionAtom.island.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import useSWR from 'swr';
import { omit, safeParse } from 'valibot';
import { parse as parseFootballMatches } from '../footballMatches';
import { feFootballMatchDaySchema } from '../frontend/feFootballMatchDay';
import { FootballMatchDay } from './FootballMatchDay';

type Props = {
footballCompetitionData: {
competitionId: string;
componentType: string;
};
};

// The public match-day JSON endpoint returns the full DCR page payload, which
// does not include the `competitionTag` field that the embed endpoint provides.
// We therefore validate the fields we actually receive and derive the tag from
// the first competition's URL below.
const matchDayResponseSchema = omit(feFootballMatchDaySchema, [
'competitionTag',
]);

const fetcher = (url: string): Promise<unknown> =>
fetch(url).then((res) => res.json());

export const FootballCompetitionAtom = ({ footballCompetitionData }: Props) => {
// competition id is e.g. 700, so need to map to a competition name, e.g. Premier League, La Liga, etc.
// the endpoint is hardcoded and irrespective of the competition, so we can comment the below out
// production use of this component would imply using the map to adjust the fetched url with the relevant competition name
const competitionIdToNameMap: Record<string, string> = {
700: 'world-cup-2026',
100: 'premier-league',
501: 'champions-league-qualifying',
};

const competitionName =
competitionIdToNameMap[footballCompetitionData.competitionId];

const { data, error } = useSWR<unknown, Error>(
///
// `https://api.nextgen.guardianapps.co.uk/football/${footballCompetitionData.competitionId}/embed
// https://www.theguardian.com/football/world-cup-2026/live.json?dcr=true
// production use of this component would likely require using the live endpoint, but this will only return data if there's an actual match on the day
// for testing purposes, we can hardcode a match day endpoint to return data for a specific date, e.g. 2026-07-09, which provides at least one result
// 'https://api.nextgen.guardianapps.co.uk/football/match-day/2026/jul/09.json?dcr=true',
`https://api.nextgen.guardianapps.co.uk/football/${competitionName}/live.json?dcr=true`,
fetcher,
);

if (error) {
return <div>Failed to load football data</div>;
}

if (data === undefined) {
return <div>Loading…</div>;
}

const validated = safeParse(matchDayResponseSchema, data);
if (!validated.success) {
return <div>Unexpected football data shape</div>;
}

const { matchesList, editionId, guardianBaseURL } = validated.output;

const parsedMatches = parseFootballMatches(matchesList);
if (!parsedMatches.ok) {
return <div>Could not parse football matches</div>;
}

// Derive the competition tag (e.g. "world-cup-2026") from the first
// competition's URL (e.g. "/football/world-cup-2026").
const competitionTag =
matchesList[0]?.competitionMatches[0]?.competitionSummary.url.replace(
'/football/',
'',
) ?? '';

return (
<FootballMatchDay
competitionTag={competitionTag}
matches={parsedMatches.value}
guardianBaseUrl={guardianBaseURL}
edition={editionId}
/>
);
};
23 changes: 23 additions & 0 deletions dotcom-rendering/src/frontend/feFront.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import type { EditionId } from '../lib/edition';
import type { EditionBranding } from '../types/branding';
import type { StageType, Switches } from '../types/config';
import type {
AudioAtomBlockElement,
BoostLevel,
CallToActionAtomBlockElement,
ExplainerAtomBlockElement,
GuideAtomBlockElement,
Image,
Newsletter,
ProfileAtomBlockElement,
QABlockElement,
StarRating,
TimelineAtomBlockElement,
} from '../types/content';
import type { FooterType } from '../types/footer';
import type { FENavType } from '../types/frontend';
Expand Down Expand Up @@ -275,6 +282,22 @@ export type FESnap = {
embedHtml?: string;
embedCss?: string;
embedJs?: string;
/**
* Atom block elements pre-fetched by facia-press so that atoms placed on
* fronts (as snaps) can be rendered server-side. Each field mirrors the
* equivalent DCR block element for that atom type.
*/
GuideAtom?: GuideAtomBlockElement;
QandaAtom?: QABlockElement;
ProfileAtom?: ProfileAtomBlockElement;
TimelineAtom?: TimelineAtomBlockElement;
AudioAtom?: AudioAtomBlockElement;
ExplainerAtom?: ExplainerAtomBlockElement;
CtaAtom?: CallToActionAtomBlockElement;
TempFootballCompetitionAtom?: {
competitionId: string;
componentType: string;
};
};

export type FEAspectRatio = '5:3' | '5:4' | '4:5' | '1:1';
Expand Down
42 changes: 42 additions & 0 deletions dotcom-rendering/src/frontend/schemas/feArticle.json
Original file line number Diff line number Diff line change
Expand Up @@ -5854,6 +5854,48 @@
},
"embedJs": {
"type": "string"
},
"atoms": {
"description": "Atom block elements that have been placed on a front as a snap and\npre-fetched by facia-press so they can be rendered server-side. Each field\nmirrors the equivalent DCR block element for that atom type.",
"type": "object",
"properties": {
"guide": {
"$ref": "#/definitions/GuideAtomBlockElement"
},
"qanda": {
"$ref": "#/definitions/QABlockElement"
},
"profile": {
"$ref": "#/definitions/ProfileAtomBlockElement"
},
"timeline": {
"$ref": "#/definitions/TimelineAtomBlockElement"
},
"audio": {
"$ref": "#/definitions/AudioAtomBlockElement"
},
"explainer": {
"$ref": "#/definitions/ExplainerAtomBlockElement"
},
"cta": {
"$ref": "#/definitions/CallToActionAtomBlockElement"
},
"tempfootballcompetition": {
"type": "object",
"properties": {
"competitionId": {
"type": "string"
},
"componentType": {
"type": "string"
}
},
"required": [
"competitionId",
"componentType"
]
}
}
}
}
},
Expand Down
Loading
Loading