diff --git a/dotcom-rendering/index.d.ts b/dotcom-rendering/index.d.ts index 4a609e3acfb..6646109f693 100644 --- a/dotcom-rendering/index.d.ts +++ b/dotcom-rendering/index.d.ts @@ -2,6 +2,9 @@ // 3rd party type declarations // // ------------------------------ +type GuardianCrossword = + import('@guardian/react-crossword-next').CrosswordProps['data']; + declare module 'chromatic/isChromatic'; declare module 'dynamic-import-polyfill' { diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index f23dd22b3a4..52e6d61ec31 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -49,7 +49,7 @@ "@guardian/identity-auth-frontend": "4.0.0", "@guardian/libs": "19.2.1", "@guardian/ophan-tracker-js": "2.2.5", - "@guardian/react-crossword-next": "npm:@guardian/react-crossword@0.0.0-canary-20241128162754", + "@guardian/react-crossword-next": "npm:@guardian/react-crossword@0.0.0-canary-20241209150926", "@guardian/shimport": "1.0.2", "@guardian/source": "8.0.0", "@guardian/source-development-kitchen": "12.0.0", diff --git a/dotcom-rendering/src/components/ArticleContainer.tsx b/dotcom-rendering/src/components/ArticleContainer.tsx index 94e02c55792..5a1e46222e4 100644 --- a/dotcom-rendering/src/components/ArticleContainer.tsx +++ b/dotcom-rendering/src/components/ArticleContainer.tsx @@ -24,6 +24,9 @@ const articleWidth = (format: ArticleFormat) => { } `; } + case ArticleDesign.Crossword: + /* The crossword player manages its own width; */ + return null; case ArticleDesign.Video: case ArticleDesign.Audio: return css` diff --git a/dotcom-rendering/src/components/BylineLink.tsx b/dotcom-rendering/src/components/BylineLink.tsx index 3c9c4beca07..e7c1eb745d0 100644 --- a/dotcom-rendering/src/components/BylineLink.tsx +++ b/dotcom-rendering/src/components/BylineLink.tsx @@ -1,6 +1,7 @@ import { isString } from '@guardian/libs'; import { Hide } from '@guardian/source/react-components'; import { DottedLines } from '@guardian/source-development-kitchen/react-components'; +import { Fragment } from 'react'; import { ArticleDesign, type ArticleFormat } from '../lib/articleFormat'; import { getBylineComponentsFromTokens, @@ -160,6 +161,10 @@ const getRenderedTokens = ( ); } + if (design === ArticleDesign.Crossword && renderedTokens.length > 0) { + return [Set by: , ...renderedTokens]; + } + return renderedTokens; }; diff --git a/dotcom-rendering/src/components/Crossword.importable.tsx b/dotcom-rendering/src/components/Crossword.importable.tsx new file mode 100644 index 00000000000..87ad0d53796 --- /dev/null +++ b/dotcom-rendering/src/components/Crossword.importable.tsx @@ -0,0 +1,6 @@ +import { Crossword as ReactCrossword } from '@guardian/react-crossword-next'; +import type { CrosswordProps } from '@guardian/react-crossword-next'; + +export const Crossword = ({ data }: CrosswordProps) => ( + +); diff --git a/dotcom-rendering/src/components/CrosswordInstructions.tsx b/dotcom-rendering/src/components/CrosswordInstructions.tsx new file mode 100644 index 00000000000..1d59bdb966b --- /dev/null +++ b/dotcom-rendering/src/components/CrosswordInstructions.tsx @@ -0,0 +1,28 @@ +import { css } from '@emotion/react'; +import { + textEgyptian17, + textEgyptianBold17, +} from '@guardian/source/foundations'; + +const instructionsStyles = css` + ${textEgyptian17}; + white-space: pre-line; +`; + +const headerStyles = css` + ${textEgyptianBold17}; + white-space: pre-line; +`; + +export const CrosswordInstructions = ({ + instructions, + className, +}: { + instructions: string; + className?: string; +}) => ( +
+ Special instructions: + {instructions} +
+); diff --git a/dotcom-rendering/src/components/CrosswordLinks.tsx b/dotcom-rendering/src/components/CrosswordLinks.tsx new file mode 100644 index 00000000000..363a70a4113 --- /dev/null +++ b/dotcom-rendering/src/components/CrosswordLinks.tsx @@ -0,0 +1,34 @@ +import { css } from '@emotion/react'; +import { isUndefined } from '@guardian/libs'; +import { textSans15 } from '@guardian/source/foundations'; +import { palette } from '../palette'; + +const crosswordLinkStyles = css` + ${textSans15}; + + a { + color: ${palette('--standfirst-link-text')}; + text-decoration: none; + :hover { + border-bottom: 1px solid ${palette('--standfirst-link-border')}; + } + } +`; + +export const CrosswordLinks = ({ + crossword, + className, +}: { + crossword: GuardianCrossword; + className?: string; +}) => { + return ( + isUndefined(crossword.pdf) || ( + + + PDF version + + + ) + ); +}; diff --git a/dotcom-rendering/src/layouts/CrosswordLayout.tsx b/dotcom-rendering/src/layouts/CrosswordLayout.tsx new file mode 100644 index 00000000000..072420a8eb3 --- /dev/null +++ b/dotcom-rendering/src/layouts/CrosswordLayout.tsx @@ -0,0 +1,790 @@ +import { css } from '@emotion/react'; +import { + from, + palette as sourcePalette, + until, +} from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import React from 'react'; +import { AdPortals } from '../components/AdPortals.importable'; +import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; +import { AppsFooter } from '../components/AppsFooter.importable'; +import { ArticleBody } from '../components/ArticleBody'; +import { ArticleContainer } from '../components/ArticleContainer'; +import { ArticleHeadline } from '../components/ArticleHeadline'; +import { ArticleMetaApps } from '../components/ArticleMeta.apps'; +import { ArticleMeta } from '../components/ArticleMeta.web'; +import { ArticleTitle } from '../components/ArticleTitle'; +import { Carousel } from '../components/Carousel.importable'; +import { CrosswordInstructions } from '../components/CrosswordInstructions'; +import { CrosswordLinks } from '../components/CrosswordLinks'; +import { DecideLines } from '../components/DecideLines'; +import { DiscussionLayout } from '../components/DiscussionLayout'; +import { Footer } from '../components/Footer'; +import { GridItem } from '../components/GridItem'; +import { HeaderAdSlot } from '../components/HeaderAdSlot'; +import { Island } from '../components/Island'; +import { Masthead } from '../components/Masthead/Masthead'; +import { MostViewedFooterData } from '../components/MostViewedFooterData.importable'; +import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; +import { OnwardsUpper } from '../components/OnwardsUpper.importable'; +import { RightColumn } from '../components/RightColumn'; +import { Section } from '../components/Section'; +import { SlotBodyEnd } from '../components/SlotBodyEnd.importable'; +import { Standfirst } from '../components/Standfirst'; +import { StickyBottomBanner } from '../components/StickyBottomBanner.importable'; +import { SubMeta } from '../components/SubMeta'; +import { SubNav } from '../components/SubNav.importable'; +import { type ArticleFormat, ArticleSpecial } from '../lib/articleFormat'; +import { canRenderAds } from '../lib/canRenderAds'; +import { getContributionsServiceUrl } from '../lib/contributions'; +import { decideTrail } from '../lib/decideTrail'; +import type { NavType } from '../model/extract-nav'; +import { palette as themePalette } from '../palette'; +import type { ArticleDeprecated } from '../types/article'; +import type { RenderingTarget } from '../types/renderingTarget'; +import { BannerWrapper, Stuck } from './lib/stickiness'; + +const CrosswordGrid = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const maxWidth = css` + ${from.desktop} { + max-width: 620px; + } +`; + +const stretchLines = css` + ${until.phablet} { + margin-left: -20px; + margin-right: -20px; + } + ${until.mobileLandscape} { + margin-left: -10px; + margin-right: -10px; + } +`; + +interface CommonProps { + article: ArticleDeprecated; + format: ArticleFormat; + renderingTarget: RenderingTarget; +} + +interface WebProps extends CommonProps { + NAV: NavType; + renderingTarget: 'Web'; +} + +interface AppsProps extends CommonProps { + renderingTarget: 'Apps'; +} + +export const CrosswordLayout = (props: WebProps | AppsProps) => { + const { article, format, renderingTarget } = props; + const { + config: { isPaidContent, host, hasSurveyAd }, + } = article; + + const isApps = renderingTarget === 'Apps'; + const isWeb = renderingTarget === 'Web'; + + const showComments = article.isCommentable && !isPaidContent; + + const { branding } = article.commercialProperties[article.editionId]; + + const contributionsServiceUrl = getContributionsServiceUrl(article); + + const { absoluteServerTimes = false } = article.config.switches; + + /** + * This property currently only applies to the header and merchandising slots + */ + const renderAds = isWeb && canRenderAds(article); + return ( + <> + {isWeb && ( + <> +
+ {renderAds && ( + +
+
+ +
+
+
+ )} + + +
+ + {renderAds && hasSurveyAd && ( + + )} + + )} +
+ {isApps && ( + <> + + + + + )} +
+
+ + +
+ +
+
+ +
+ +
+
+ {article.crossword && ( + + + + )} + + + + + +
+
+ +
+
+
+ +
+ {isApps ? ( + <> + + + + + + + + ) : ( + + )} +
+
+ {!!article.crossword?.instructions && ( + + + + )} + + + + + + + + {renderAds ? ( + + ) : null} + + +
+
+
+ +
+
+ + + +
+
+ +
+ +
+ +
+ +
+ {renderAds && ( +
+ +
+ )} + + {article.storyPackage && ( +
+ + + +
+ )} + + + + + + {showComments && ( +
+ +
+ )} + + {!isPaidContent && ( +
+ + + + + +
+ )} + + {renderAds && ( +
+ +
+ )} +
+ + {isWeb && props.NAV.subNavSections && ( +
+ + + +
+ )} + + {isWeb && ( + <> +
+
+
+ + + + + + + + + )} + {isApps && ( +
+ + + +
+ )} + + ); +}; diff --git a/dotcom-rendering/src/layouts/DecideLayout.tsx b/dotcom-rendering/src/layouts/DecideLayout.tsx index b55f4973791..9920cb9d19c 100644 --- a/dotcom-rendering/src/layouts/DecideLayout.tsx +++ b/dotcom-rendering/src/layouts/DecideLayout.tsx @@ -8,6 +8,7 @@ import type { ArticleDeprecated } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; import { AudioLayout } from './AudioLayout'; import { CommentLayout } from './CommentLayout'; +import { CrosswordLayout } from './CrosswordLayout'; import { FullPageInteractiveLayout } from './FullPageInteractiveLayout'; import { ImmersiveLayout } from './ImmersiveLayout'; import { InteractiveLayout } from './InteractiveLayout'; @@ -273,6 +274,15 @@ const DecideLayoutWeb = ({ NAV={NAV} /> ); + case ArticleDesign.Crossword: + return ( + + ); default: return ( ): ArticleDesign => { return ArticleDesign.Timeline; case 'ProfileDesign': return ArticleDesign.Profile; + case 'CrosswordDesign': + return ArticleDesign.Crossword; default: return ArticleDesign.Standard; } @@ -270,6 +273,8 @@ const designToFEDesign = (design: ArticleDesign): FEDesign => { return 'TimelineDesign'; case ArticleDesign.Profile: return 'ProfileDesign'; + case ArticleDesign.Crossword: + return 'CrosswordDesign'; } }; diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index a220303bfc8..8bfe959409e 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -9,6 +9,7 @@ import { CartoonComponent } from '../components/CartoonComponent'; import { ChartAtom } from '../components/ChartAtom.importable'; import { CodeBlockComponent } from '../components/CodeBlockComponent'; import { CommentBlockComponent } from '../components/CommentBlockComponent'; +import { Crossword } from '../components/Crossword.importable'; import { DividerBlockComponent } from '../components/DividerBlockComponent'; import { DocumentBlockComponent } from '../components/DocumentBlockComponent.importable'; import { EmailSignUpWrapper } from '../components/EmailSignUpWrapper'; @@ -844,6 +845,14 @@ export const renderElement = ({ case 'model.dotcomrendering.pageElements.DisclaimerBlockElement': { return ; } + case 'model.dotcomrendering.pageElements.CrosswordElement': + return ( +
+ + + +
+ ); case 'model.dotcomrendering.pageElements.AudioBlockElement': case 'model.dotcomrendering.pageElements.ContentAtomBlockElement': case 'model.dotcomrendering.pageElements.GenericAtomBlockElement': diff --git a/dotcom-rendering/src/server/handler.article.web.ts b/dotcom-rendering/src/server/handler.article.web.ts index f98084f570a..25d071303f1 100644 --- a/dotcom-rendering/src/server/handler.article.web.ts +++ b/dotcom-rendering/src/server/handler.article.web.ts @@ -3,7 +3,7 @@ import { Standard as ExampleArticle } from '../../fixtures/generated/fe-articles import { decideFormat } from '../lib/articleFormat'; import { enhanceBlocks } from '../model/enhanceBlocks'; import { validateAsArticleType, validateAsBlock } from '../model/validate'; -import { enhanceArticleType } from '../types/article'; +import { enhanceArticleType, enhanceCrossword } from '../types/article'; import type { FEBlocksRequest } from '../types/frontend'; import { makePrefetchHeader } from './lib/header'; import { recordTypeAndPlatform } from './lib/logging-store'; @@ -54,6 +54,18 @@ export const handleInteractive: RequestHandler = ({ body }, res) => { res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html); }; +export const handleCrossword: RequestHandler = ({ body }, res) => { + recordTypeAndPlatform('crossword', 'web'); + + const article = enhanceArticleType(body, 'Web'); + const crosswordArticle = enhanceCrossword(article); + const { html, prefetchScripts } = renderHtml({ + article: crosswordArticle, + }); + + res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html); +}; + export const handleBlocks: RequestHandler = ({ body }, res) => { recordTypeAndPlatform('blocks'); const { diff --git a/dotcom-rendering/src/server/server.dev.ts b/dotcom-rendering/src/server/server.dev.ts index 0e9c0fb3762..1df15031170 100644 --- a/dotcom-rendering/src/server/server.dev.ts +++ b/dotcom-rendering/src/server/server.dev.ts @@ -10,6 +10,7 @@ import { handleArticle, handleArticleJson, handleBlocks, + handleCrossword, handleInteractive, } from './handler.article.web'; import { handleEditionsCrossword } from './handler.editionsCrossword'; @@ -71,6 +72,8 @@ export const devServer = (): Handler => { return handleInteractive(req, res, next); case 'AMPInteractive': return handleAMPArticle(req, res, next); + case 'Crossword': + return handleCrossword(req, res, next); case 'Blocks': return handleBlocks(req, res, next); case 'Front': diff --git a/dotcom-rendering/src/server/server.prod.ts b/dotcom-rendering/src/server/server.prod.ts index 7704baaeed3..27cc8e39fa6 100644 --- a/dotcom-rendering/src/server/server.prod.ts +++ b/dotcom-rendering/src/server/server.prod.ts @@ -18,6 +18,7 @@ import { handleArticleJson, handleArticlePerfTest, handleBlocks, + handleCrossword, handleInteractive, } from './handler.article.web'; import { handleEditionsCrossword } from './handler.editionsCrossword'; @@ -67,6 +68,7 @@ export const prodServer = (): void => { app.post('/AMPArticle', logRenderTime, handleAMPArticle); app.post('/Interactive', logRenderTime, handleInteractive); app.post('/AMPInteractive', logRenderTime, handleAMPArticle); + app.post('/Crossword', logRenderTime, handleCrossword); app.post('/Blocks', logRenderTime, handleBlocks); app.post('/Front', logRenderTime, handleFront); app.post('/FrontJSON', logRenderTime, handleFrontJson); diff --git a/dotcom-rendering/src/types/article.ts b/dotcom-rendering/src/types/article.ts index e1875c1f123..a15e90c8398 100644 --- a/dotcom-rendering/src/types/article.ts +++ b/dotcom-rendering/src/types/article.ts @@ -1,4 +1,9 @@ -import { type ArticleFormat, decideFormat } from '../lib/articleFormat'; +import { randomUUID } from 'node:crypto'; +import { + ArticleDesign, + type ArticleFormat, + decideFormat, +} from '../lib/articleFormat'; import type { ImageForAppsLightbox } from '../model/appsLightboxImages'; import { appsLightboxImages } from '../model/appsLightboxImages'; import { buildLightboxImages } from '../model/buildLightboxImages'; @@ -30,6 +35,40 @@ export type Article = { frontendData: ArticleDeprecated; }; +export const enhanceCrossword = (article: Article): Article => { + if (article.frontendData.crossword) { + const element = { + _type: 'model.dotcomrendering.pageElements.CrosswordElement' as const, + crossword: article.frontendData.crossword, + }; + return { + ...article, + format: { ...article.format, design: ArticleDesign.Crossword }, + frontendData: { + ...article.frontendData, + blocks: [ + { + id: randomUUID(), + elements: [element], + attributes: { + pinned: false, + keyEvent: false, + summary: false, + }, + primaryDateLine: + article.frontendData.webPublicationDateDisplay, + secondaryDateLine: + article.frontendData + .webPublicationSecondaryDateDisplay, + }, + ], + }, + }; + } + + throw new TypeError('article did not contain a crossword'); +}; + export const enhanceArticleType = ( data: FEArticleType, renderingTarget: RenderingTarget, diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index d481eacf9c8..c809556a4bf 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -732,6 +732,12 @@ interface WitnessTypeBlockElement extends ThirdPartyEmbeddedContent { | WitnessTypeDataVideo | WitnessTypeDataText; } + +export interface CrosswordElement { + _type: 'model.dotcomrendering.pageElements.CrosswordElement'; + crossword: GuardianCrossword & { instructions?: string }; +} + export type FEElement = | AdPlaceholderBlockElement | AudioAtomBlockElement @@ -791,7 +797,8 @@ export type FEElement = | VideoYoutubeBlockElement | VineBlockElement | YoutubeBlockElement - | WitnessTypeBlockElement; + | WitnessTypeBlockElement + | CrosswordElement; // ------------------------------------- // Misc diff --git a/dotcom-rendering/src/types/frontend.ts b/dotcom-rendering/src/types/frontend.ts index 2f91fe527d7..42b66f9b773 100644 --- a/dotcom-rendering/src/types/frontend.ts +++ b/dotcom-rendering/src/types/frontend.ts @@ -126,6 +126,7 @@ export interface FEArticleType { showTableOfContents: boolean; lang?: string; isRightToLeftLang?: boolean; + crossword?: GuardianCrossword & { instructions: string }; } type PageTypeType = { @@ -202,7 +203,8 @@ export type FEDesign = | 'FullPageInteractiveDesign' | 'NewsletterSignupDesign' | 'TimelineDesign' - | 'ProfileDesign'; // FEDisplay is the display information passed through from frontend (originating in the capi scala client) and dictates the displaystyle of the content e.g. Immersive + | 'ProfileDesign' + | 'CrosswordDesign'; // FEDisplay is the display information passed through from frontend (originating in the capi scala client) and dictates the displaystyle of the content e.g. Immersive // https://github.com/guardian/content-api-scala-client/blob/master/client/src/main/scala/com.gu.contentapi.client/utils/format/Display.scala export type FEDisplay = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 100ac29439e..0e04b991a3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,8 +362,8 @@ importers: specifier: 2.2.5 version: 2.2.5 '@guardian/react-crossword-next': - specifier: npm:@guardian/react-crossword@0.0.0-canary-20241128162754 - version: /@guardian/react-crossword@0.0.0-canary-20241128162754(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3) + specifier: npm:@guardian/react-crossword@0.0.0-canary-20241209150926 + version: /@guardian/react-crossword@0.0.0-canary-20241209150926(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3) '@guardian/shimport': specifier: 1.0.2 version: 1.0.2 @@ -4256,8 +4256,8 @@ packages: tslib: 2.6.2 dev: false - /@guardian/react-crossword@0.0.0-canary-20241128162754(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3): - resolution: {integrity: sha512-RLmjgGUV6V1DCOKfprUi2SZhpTLw1TSxH7DDFP2maAZUmq6NpYgWZDbezY+m9+IHTuEQfLQYbyyJl0dCcj8bZQ==} + /@guardian/react-crossword@0.0.0-canary-20241209150926(@emotion/react@11.11.3)(@guardian/libs@19.2.1)(@guardian/source@8.0.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1)(typescript@5.5.3): + resolution: {integrity: sha512-nJ9vi454SqMynQ0UDz+jBmO/l7YxgVIq6Gvfyy4p/b5cnBNsxh8n4OZtIexhJx/dOhKYsMSPw/5KU8YOnVhF9A==} peerDependencies: '@emotion/react': ^11.11.3 '@guardian/libs': ^19.1.0