diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index c85c10761d2..011fcd7400c 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -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'; @@ -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 ; + } + // Determine if the card is used within a gallery article const isInGalleryContext = contextFormat?.design === ArticleDesign.Gallery; diff --git a/dotcom-rendering/src/components/CardSnapAtom.tsx b/dotcom-rendering/src/components/CardSnapAtom.tsx new file mode 100644 index 00000000000..ede50d9b74a --- /dev/null +++ b/dotcom-rendering/src/components/CardSnapAtom.tsx @@ -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 ( + + + + ); + } + + if (atoms.qanda) { + return ( + + + + ); + } + + if (atoms.profile) { + return ( + + + + ); + } + + if (atoms.timeline) { + return ( + + + + ); + } + + if (atoms.audio) { + return ( + + + + ); + } + + if (atoms.explainer) { + return ( + + ); + } + + if (atoms.cta) { + return ( + + ); + } + + if (atoms.tempfootballcompetition) { + return ( + + + + ); + } + + return null; +}; diff --git a/dotcom-rendering/src/components/FootballCompetitionAtom.island.tsx b/dotcom-rendering/src/components/FootballCompetitionAtom.island.tsx new file mode 100644 index 00000000000..e21061327ce --- /dev/null +++ b/dotcom-rendering/src/components/FootballCompetitionAtom.island.tsx @@ -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 => + 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 = { + 700: 'world-cup-2026', + 100: 'premier-league', + 501: 'champions-league-qualifying', + }; + + const competitionName = + competitionIdToNameMap[footballCompetitionData.competitionId]; + + const { data, error } = useSWR( + /// + // `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
Failed to load football data
; + } + + if (data === undefined) { + return
Loading…
; + } + + const validated = safeParse(matchDayResponseSchema, data); + if (!validated.success) { + return
Unexpected football data shape
; + } + + const { matchesList, editionId, guardianBaseURL } = validated.output; + + const parsedMatches = parseFootballMatches(matchesList); + if (!parsedMatches.ok) { + return
Could not parse football matches
; + } + + // 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 ( + + ); +}; diff --git a/dotcom-rendering/src/frontend/feFront.ts b/dotcom-rendering/src/frontend/feFront.ts index edcf604da20..40448084160 100644 --- a/dotcom-rendering/src/frontend/feFront.ts +++ b/dotcom-rendering/src/frontend/feFront.ts @@ -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'; @@ -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'; diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 5bebd7acf39..71081f51e2a 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -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" + ] + } + } } } }, diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json index 08703e95381..2d58b68cea4 100644 --- a/dotcom-rendering/src/frontend/schemas/feFront.json +++ b/dotcom-rendering/src/frontend/schemas/feFront.json @@ -880,6 +880,43 @@ }, "embedJs": { "type": "string" + }, + "GuideAtom": { + "$ref": "#/definitions/GuideAtomBlockElement", + "description": "Atom block elements pre-fetched by facia-press so that atoms placed on\nfronts (as snaps) can be rendered server-side. Each field mirrors the\nequivalent DCR block element for that atom type." + }, + "QandaAtom": { + "$ref": "#/definitions/QABlockElement" + }, + "ProfileAtom": { + "$ref": "#/definitions/ProfileAtomBlockElement" + }, + "TimelineAtom": { + "$ref": "#/definitions/TimelineAtomBlockElement" + }, + "AudioAtom": { + "$ref": "#/definitions/AudioAtomBlockElement" + }, + "ExplainerAtom": { + "$ref": "#/definitions/ExplainerAtomBlockElement" + }, + "CtaAtom": { + "$ref": "#/definitions/CallToActionAtomBlockElement" + }, + "TempFootballCompetitionAtom": { + "type": "object", + "properties": { + "competitionId": { + "type": "string" + }, + "componentType": { + "type": "string" + } + }, + "required": [ + "competitionId", + "componentType" + ] } } }, @@ -1715,6 +1752,43 @@ }, "embedJs": { "type": "string" + }, + "GuideAtom": { + "$ref": "#/definitions/GuideAtomBlockElement", + "description": "Atom block elements pre-fetched by facia-press so that atoms placed on\nfronts (as snaps) can be rendered server-side. Each field mirrors the\nequivalent DCR block element for that atom type." + }, + "QandaAtom": { + "$ref": "#/definitions/QABlockElement" + }, + "ProfileAtom": { + "$ref": "#/definitions/ProfileAtomBlockElement" + }, + "TimelineAtom": { + "$ref": "#/definitions/TimelineAtomBlockElement" + }, + "AudioAtom": { + "$ref": "#/definitions/AudioAtomBlockElement" + }, + "ExplainerAtom": { + "$ref": "#/definitions/ExplainerAtomBlockElement" + }, + "CtaAtom": { + "$ref": "#/definitions/CallToActionAtomBlockElement" + }, + "TempFootballCompetitionAtom": { + "type": "object", + "properties": { + "competitionId": { + "type": "string" + }, + "componentType": { + "type": "string" + } + }, + "required": [ + "competitionId", + "componentType" + ] } } }, @@ -2550,6 +2624,43 @@ }, "embedJs": { "type": "string" + }, + "GuideAtom": { + "$ref": "#/definitions/GuideAtomBlockElement", + "description": "Atom block elements pre-fetched by facia-press so that atoms placed on\nfronts (as snaps) can be rendered server-side. Each field mirrors the\nequivalent DCR block element for that atom type." + }, + "QandaAtom": { + "$ref": "#/definitions/QABlockElement" + }, + "ProfileAtom": { + "$ref": "#/definitions/ProfileAtomBlockElement" + }, + "TimelineAtom": { + "$ref": "#/definitions/TimelineAtomBlockElement" + }, + "AudioAtom": { + "$ref": "#/definitions/AudioAtomBlockElement" + }, + "ExplainerAtom": { + "$ref": "#/definitions/ExplainerAtomBlockElement" + }, + "CtaAtom": { + "$ref": "#/definitions/CallToActionAtomBlockElement" + }, + "TempFootballCompetitionAtom": { + "type": "object", + "properties": { + "competitionId": { + "type": "string" + }, + "componentType": { + "type": "string" + } + }, + "required": [ + "competitionId", + "componentType" + ] } } }, @@ -3239,6 +3350,318 @@ ], "type": "string" }, + "GuideAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.GuideAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "title": { + "type": "string" + }, + "img": { + "type": "string" + }, + "html": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "credit", + "elementId", + "html", + "id", + "label", + "title" + ] + }, + "RoleType": { + "description": "Affects how an image is placed.\n\nAlso known as “weighting” in Composer, but we respect the CAPI naming.", + "enum": [ + "halfWidth", + "immersive", + "inline", + "showcase", + "supporting", + "thumbnail" + ], + "type": "string" + }, + "QABlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.QABlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "img": { + "type": "string" + }, + "html": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "credit", + "elementId", + "html", + "id", + "title" + ] + }, + "ProfileAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.ProfileAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "title": { + "type": "string" + }, + "img": { + "type": "string" + }, + "html": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "credit", + "elementId", + "html", + "id", + "label", + "title" + ] + }, + "TimelineAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.TimelineAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/definitions/TimelineAtomEvent" + } + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "elementId", + "events", + "id", + "title" + ] + }, + "TimelineAtomEvent": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "date": { + "type": "string" + }, + "unixDate": { + "type": "number" + }, + "body": { + "type": "string" + }, + "toDate": { + "type": "string" + }, + "toUnixDate": { + "type": "number" + } + }, + "required": [ + "date", + "title", + "unixDate" + ] + }, + "AudioAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.AudioAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kicker": { + "type": "string" + }, + "title": { + "type": "string" + }, + "trackUrl": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "coverUrl": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "coverUrl", + "duration", + "elementId", + "id", + "kicker", + "trackUrl" + ] + }, + "ExplainerAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.ExplainerAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "body", + "elementId", + "id", + "title" + ] + }, + "CallToActionAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.CallToActionAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "image": { + "type": "string" + }, + "label": { + "type": "string" + }, + "trackingCode": { + "type": "string" + }, + "btnText": { + "type": "string" + } + }, + "required": [ + "_type", + "elementId", + "id", + "title", + "url" + ] + }, "FEContainer": { "enum": [ "fixed/thrasher", @@ -3966,6 +4389,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" + ] + } + } } } }, diff --git a/dotcom-rendering/src/frontend/schemas/feTagPage.json b/dotcom-rendering/src/frontend/schemas/feTagPage.json index b1e8e5a3b35..87f9a4f6323 100644 --- a/dotcom-rendering/src/frontend/schemas/feTagPage.json +++ b/dotcom-rendering/src/frontend/schemas/feTagPage.json @@ -723,6 +723,43 @@ }, "embedJs": { "type": "string" + }, + "GuideAtom": { + "$ref": "#/definitions/GuideAtomBlockElement", + "description": "Atom block elements pre-fetched by facia-press so that atoms placed on\nfronts (as snaps) can be rendered server-side. Each field mirrors the\nequivalent DCR block element for that atom type." + }, + "QandaAtom": { + "$ref": "#/definitions/QABlockElement" + }, + "ProfileAtom": { + "$ref": "#/definitions/ProfileAtomBlockElement" + }, + "TimelineAtom": { + "$ref": "#/definitions/TimelineAtomBlockElement" + }, + "AudioAtom": { + "$ref": "#/definitions/AudioAtomBlockElement" + }, + "ExplainerAtom": { + "$ref": "#/definitions/ExplainerAtomBlockElement" + }, + "CtaAtom": { + "$ref": "#/definitions/CallToActionAtomBlockElement" + }, + "TempFootballCompetitionAtom": { + "type": "object", + "properties": { + "competitionId": { + "type": "string" + }, + "componentType": { + "type": "string" + } + }, + "required": [ + "competitionId", + "componentType" + ] } } }, @@ -1467,6 +1504,318 @@ ], "type": "string" }, + "GuideAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.GuideAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "title": { + "type": "string" + }, + "img": { + "type": "string" + }, + "html": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "credit", + "elementId", + "html", + "id", + "label", + "title" + ] + }, + "RoleType": { + "description": "Affects how an image is placed.\n\nAlso known as “weighting” in Composer, but we respect the CAPI naming.", + "enum": [ + "halfWidth", + "immersive", + "inline", + "showcase", + "supporting", + "thumbnail" + ], + "type": "string" + }, + "QABlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.QABlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "img": { + "type": "string" + }, + "html": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "credit", + "elementId", + "html", + "id", + "title" + ] + }, + "ProfileAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.ProfileAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "title": { + "type": "string" + }, + "img": { + "type": "string" + }, + "html": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "credit", + "elementId", + "html", + "id", + "label", + "title" + ] + }, + "TimelineAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.TimelineAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/definitions/TimelineAtomEvent" + } + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "elementId", + "events", + "id", + "title" + ] + }, + "TimelineAtomEvent": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "date": { + "type": "string" + }, + "unixDate": { + "type": "number" + }, + "body": { + "type": "string" + }, + "toDate": { + "type": "string" + }, + "toUnixDate": { + "type": "number" + } + }, + "required": [ + "date", + "title", + "unixDate" + ] + }, + "AudioAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.AudioAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kicker": { + "type": "string" + }, + "title": { + "type": "string" + }, + "trackUrl": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "coverUrl": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "coverUrl", + "duration", + "elementId", + "id", + "kicker", + "trackUrl" + ] + }, + "ExplainerAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.ExplainerAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/RoleType" + } + }, + "required": [ + "_type", + "body", + "elementId", + "id", + "title" + ] + }, + "CallToActionAtomBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.CallToActionAtomBlockElement" + }, + "elementId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "image": { + "type": "string" + }, + "label": { + "type": "string" + }, + "trackingCode": { + "type": "string" + }, + "btnText": { + "type": "string" + } + }, + "required": [ + "_type", + "elementId", + "id", + "title", + "url" + ] + }, "FENavType": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/model/enhanceSnaps.ts b/dotcom-rendering/src/model/enhanceSnaps.ts index 6ee8e63462a..7801465d13e 100644 --- a/dotcom-rendering/src/model/enhanceSnaps.ts +++ b/dotcom-rendering/src/model/enhanceSnaps.ts @@ -1,5 +1,26 @@ import type { FESnap } from '../frontend/feFront'; -import type { DCRSnapType } from '../types/front'; +import type { DCRSnapType, SnapAtoms } from '../types/front'; + +/** + * Maps the atom block elements sent on a snap (each as its own optional field) + * into a single `SnapAtoms` object, if any are present. + */ +const enhanceSnapAtoms = (snap: FESnap): SnapAtoms | undefined => { + const atoms: SnapAtoms = { + guide: snap.GuideAtom, + qanda: snap.QandaAtom, + profile: snap.ProfileAtom, + timeline: snap.TimelineAtom, + audio: snap.AudioAtom, + explainer: snap.ExplainerAtom, + cta: snap.CtaAtom, + tempfootballcompetition: snap.TempFootballCompetitionAtom, + }; + + const hasAtom = Object.values(atoms).some((atom) => atom !== undefined); + + return hasAtom ? atoms : undefined; +}; /** * @@ -23,5 +44,12 @@ export const enhanceSnaps = ( ); } + if (dcrSnap) { + return { + ...dcrSnap, + atoms: enhanceSnapAtoms(dcrSnap), + }; + } + return dcrSnap; }; diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 6c5f9bd858d..df6cabaf87e 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -251,7 +251,7 @@ export interface EmbedBlockElement extends ThirdPartyEmbeddedContent { caption?: string; } -interface ExplainerAtomBlockElement { +export interface ExplainerAtomBlockElement { _type: 'model.dotcomrendering.pageElements.ExplainerAtomBlockElement'; elementId: string; id: string; @@ -271,7 +271,7 @@ interface GenericAtomBlockElement { elementId: string; } -interface GuideAtomBlockElement { +export interface GuideAtomBlockElement { _type: 'model.dotcomrendering.pageElements.GuideAtomBlockElement'; elementId: string; id: string; @@ -554,7 +554,7 @@ export interface RecipeBlockElement { featuredImage?: RecipeFeaturedImage; } -interface ProfileAtomBlockElement { +export interface ProfileAtomBlockElement { _type: 'model.dotcomrendering.pageElements.ProfileAtomBlockElement'; elementId: string; id: string; @@ -575,7 +575,7 @@ interface PullquoteBlockElement { isThirdPartyTracking?: boolean; } -interface QABlockElement { +export interface QABlockElement { _type: 'model.dotcomrendering.pageElements.QABlockElement'; elementId: string; id: string; diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index d1763598525..a9347c64879 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -13,7 +13,18 @@ import type { } from '../lib/articleFormat'; import type { EditionId } from '../lib/edition'; import type { Branding, CollectionBranding } from './branding'; -import type { BoostLevel, Newsletter, StarRating } from './content'; +import type { + AudioAtomBlockElement, + BoostLevel, + CallToActionAtomBlockElement, + ExplainerAtomBlockElement, + GuideAtomBlockElement, + Newsletter, + ProfileAtomBlockElement, + QABlockElement, + StarRating, + TimelineAtomBlockElement, +} from './content'; import type { FooterType } from './footer'; import type { FENavType } from './frontend'; import type { ArticleMedia, MainMedia } from './mainMedia'; @@ -113,10 +124,30 @@ export type DCRSlideshowImage = { imageCaption?: string; }; +/** + * Atom block elements that have been placed on a front as a snap and + * pre-fetched by facia-press so they can be rendered server-side. Each field + * mirrors the equivalent DCR block element for that atom type. + */ +export type SnapAtoms = { + guide?: GuideAtomBlockElement; + qanda?: QABlockElement; + profile?: ProfileAtomBlockElement; + timeline?: TimelineAtomBlockElement; + audio?: AudioAtomBlockElement; + explainer?: ExplainerAtomBlockElement; + cta?: CallToActionAtomBlockElement; + tempfootballcompetition?: { + competitionId: string; + componentType: string; + }; +}; + export type DCRSnapType = { embedHtml?: string; embedCss?: string; embedJs?: string; + atoms?: SnapAtoms; }; export type AspectRatio = FEAspectRatio;