diff --git a/packages/app/src/components/not-found-pages/logic.ts b/packages/app/src/components/not-found-pages/logic.ts new file mode 100644 index 0000000000..ae3800bb39 --- /dev/null +++ b/packages/app/src/components/not-found-pages/logic.ts @@ -0,0 +1,31 @@ +import { getClient } from '~/lib/sanity'; +import { getNotFoundPageQuery } from '~/queries/get-not-found-page-query'; +import { NotFoundPageConfiguration } from './types'; + +const determinePageTypeFromUrl = (url: string) => { + const levelsToPageTypeMapping: { [key: string]: string } = { landelijk: 'nl', gemeente: 'gm', artikelen: 'article', general: 'general' }; + const pageType = levelsToPageTypeMapping[Object.keys(levelsToPageTypeMapping).find((key) => url.includes(`/${key}`)) ?? 'general']; + + return pageType; +}; + +/** + * @function getNotFoundPageData + * @description Fetches the data for a given page type from Sanity and adds additional properties to the page configuration. + * @param url - The current request URL. + * @param locale - The selected locale. + * @returns {Promise | null} - Returns the page configuration if found, otherwise null. + */ +export const getNotFoundPageData = async (url: string, locale: string) => { + const pageType = url ? determinePageTypeFromUrl(url) : 'general'; + const query = getNotFoundPageQuery(locale, pageType); + const client = await getClient(); + const notFoundPageConfiguration: NotFoundPageConfiguration = await client.fetch(query); + + if (!notFoundPageConfiguration) return null; + + notFoundPageConfiguration.isGmPage = pageType === 'gm'; + notFoundPageConfiguration.isGeneralPage = pageType === 'general'; + + return notFoundPageConfiguration; +}; diff --git a/packages/app/src/components/not-found-pages/not-found-link.tsx b/packages/app/src/components/not-found-pages/not-found-link.tsx new file mode 100644 index 0000000000..5f75a63835 --- /dev/null +++ b/packages/app/src/components/not-found-pages/not-found-link.tsx @@ -0,0 +1,39 @@ +import { colors } from '@corona-dashboard/common'; +import { ChevronRight } from '@corona-dashboard/icons'; +import styled from 'styled-components'; +import { space } from '~/style/theme'; +import { getFilenameToIconName } from '~/utils/get-filename-to-icon-name'; +import { Box } from '../base/box'; +import DynamicIcon, { IconName } from '../get-icon-by-name'; +import { Anchor } from '../typography'; +import { NotFoundLinkProps } from './types'; + +export const NotFoundLink = ({ link: { linkUrl, linkLabel, linkIcon }, hasChevron, isCTA, ...restProps }: NotFoundLinkProps) => { + const iconNameFromFileName = linkIcon ? (getFilenameToIconName(linkIcon) as IconName) : null; + const icon = iconNameFromFileName ? : undefined; + + return ( + + {icon} + + + {linkLabel} + + + {hasChevron && } + + ); +}; + +interface StyledAnchorProps { + hasIcon: boolean; + isCTA: boolean | undefined; +} + +const StyledAnchor = styled(Anchor)` + margin-inline: ${({ hasIcon, isCTA }) => (hasIcon || isCTA ? space[2] : !hasIcon ? `0 ${space[2]}` : 0)}; + + &:hover { + text-decoration: none; + } +`; diff --git a/packages/app/src/components/not-found-pages/not-found-page-fallback.tsx b/packages/app/src/components/not-found-pages/not-found-page-fallback.tsx new file mode 100644 index 0000000000..6d008d267a --- /dev/null +++ b/packages/app/src/components/not-found-pages/not-found-page-fallback.tsx @@ -0,0 +1,17 @@ +import { Content } from '~/domain/layout/content'; +import { Layout } from '~/domain/layout/layout'; +import { useIntl } from '~/intl'; +import { Heading, Text } from '../typography'; + +export const NotFoundFallback = ({ lastGenerated }: { lastGenerated: string }) => { + const { commonTexts } = useIntl(); + + return ( + + + {commonTexts.notfound_titel.text} + {commonTexts.notfound_beschrijving.text} + + + ); +}; diff --git a/packages/app/src/components/not-found-pages/not-found-page.tsx b/packages/app/src/components/not-found-pages/not-found-page.tsx new file mode 100644 index 0000000000..6404fb9642 --- /dev/null +++ b/packages/app/src/components/not-found-pages/not-found-page.tsx @@ -0,0 +1,108 @@ +import { colors } from '@corona-dashboard/common'; +import styled from 'styled-components'; +import { GmComboBox } from '~/domain/layout/components/gm-combo-box'; +import { Layout } from '~/domain/layout/layout'; +import { useIntl } from '~/intl'; +import { getImageProps } from '~/lib/sanity'; +import { mediaQueries, radii, sizes, space } from '~/style/theme'; +import { Box } from '../base/box'; +import { RichContent } from '../cms/rich-content'; +import { SanityImage } from '../cms/sanity-image'; +import { Heading } from '../typography'; +import { NotFoundLink } from './not-found-link'; +import { NotFoundProps } from './types'; + +export const NotFoundPage = ({ lastGenerated, notFoundPageConfiguration }: NotFoundProps) => { + const { + commonTexts: { notfound_metadata }, + } = useIntl(); + const { title, description, isGmPage = true, isGeneralPage = false, image, links = undefined, cta = undefined } = notFoundPageConfiguration; + + return ( + + + + + {title} + + + + {isGmPage && ( + // Compensating for padding on the combo-box element using negative margins. + + + + )} + + {links && ( + + {links.map((link, index) => ( + + ))} + + )} + + {cta && Object.values(cta).some((item) => item !== null) && ( + + )} + + + + + + + + ); +}; + +const NotFoundCTA = NotFoundLink; // Renaming for the sake of readability. + +const NotFoundLayout = styled.div` + display: flex; + flex-direction: column; + gap: ${space[4]}; + justify-content: space-between; + margin: ${space[5]} auto; + max-width: ${sizes.maxWidth}px; + padding: 0 ${space[3]}; + + @media ${mediaQueries.sm} { + flex-direction: row; + padding: 0 ${space[4]}; + } + + @media ${mediaQueries.md} { + align-items: flex-start; + } + + .not-found-content-cta { + transition: all 0.2s ease-in-out; + + svg rect { + fill: ${colors.transparent}; + } + + &:hover { + background-color: ${colors.gray1}; + } + } +`; diff --git a/packages/app/src/components/not-found-pages/types.ts b/packages/app/src/components/not-found-pages/types.ts new file mode 100644 index 0000000000..4b5adc0379 --- /dev/null +++ b/packages/app/src/components/not-found-pages/types.ts @@ -0,0 +1,43 @@ +import { PortableTextEntry } from '@sanity/block-content-to-react'; +import { ImageBlock } from '~/types/cms'; + +type Link = { + id?: string; + linkIcon?: string; + linkLabel: string; + linkUrl: string; +}; + +export type NotFoundPageConfiguration = { + description: PortableTextEntry[]; + image: ImageBlock; + isGeneralPage: boolean; + isGmPage: boolean; + title: string; + cta?: { + ctaIcon?: string; + ctaLabel: string; + ctaLink: string; + }; + links?: Link[]; +}; + +export interface NotFoundProps { + lastGenerated: string; + notFoundPageConfiguration: NotFoundPageConfiguration; +} + +export interface NotFoundLinkProps { + alignItems: string; + display: string; + link: Link; + border?: string; + borderRadius?: string; + className?: string; + hasChevron?: boolean; + isCTA?: boolean; + marginBottom?: string; + maxWidth?: string; + order?: number; + padding?: string; +} diff --git a/packages/app/src/next-config/rewrites.js b/packages/app/src/next-config/rewrites.js index bb40e6390c..fbf070cf5e 100644 --- a/packages/app/src/next-config/rewrites.js +++ b/packages/app/src/next-config/rewrites.js @@ -5,6 +5,44 @@ async function rewrites() { source: '/gemeente/(g|G)(m|M):nr(\\d{4})/:page*', destination: '/gemeente/GM:nr/:page*', }, + /** + * The rewrite below will match everything after /gemeente/ except for g/m or G/M or gm/GM. + * When matched, the destination is rewritten to /gemeente/code/404 so that it lands in the + * [...404].tsx catch-all route within packages/app/src/pages/gemeente/[code]/ The same applies + * to the two other gemeente rewrites below. This is a workaround for making sure all gemeente routes + * go to the correct 404 page ([...404].tsx). + * + * The regex states that if /gemeente/ is not followed by gm/GM/gM/Gm, then it should go to the 404 page. + * For example: + * 1. /gemeente/somethingWrong + * 2. /gemeente/blahblah + * 3. /gemeente/gblah + */ + { + source: '/gemeente/((?!gm|GM|gM|Gm).*):slug*', + destination: '/gemeente/code/404', + }, + /** + * The regex below states that if the URL contains GM after /gemeente/, it must be followed by 4 digits. + * This will catch: + * 1. /gemeente/GM123 + * 2. /gemeente/GM + * 3. /gemeente/GM123/rioolwater + */ + { + source: '/gemeente/(g|G)(m|M)((?!\\d{4}).*):slug*', + destination: '/gemeente/code/404', + }, + /** + * The regex below matches URLs which contain g|G m|M followed by more than 4 digits, optionally + * followed by a forward slash, optionally followed by a string. This will catch: + * 1. /gemeente/GM12345 + * 2. /gemeente/GM123456/rioolwater + */ + { + source: '/gemeente/(g|G)(m|M)(\\d{5,})(\\/?)(\\S*):slug*', + destination: '/gemeente/code/404', + }, ], }; } diff --git a/packages/app/src/pages/404.tsx b/packages/app/src/pages/404.tsx deleted file mode 100644 index 5db0ec4246..0000000000 --- a/packages/app/src/pages/404.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { colors } from '@corona-dashboard/common'; -import { ChevronRight } from '@corona-dashboard/icons'; -import { PortableTextEntry } from '@sanity/block-content-to-react'; -import { GetStaticProps } from 'next'; -import { useRouter } from 'next/router'; -import styled from 'styled-components'; -import { Box } from '~/components/base/box'; -import { RichContent } from '~/components/cms/rich-content'; -import { SanityImage } from '~/components/cms/sanity-image'; -import DynamicIcon, { IconName } from '~/components/get-icon-by-name'; -import { Anchor, Heading, Text } from '~/components/typography'; -import { GmComboBox } from '~/domain/layout/components/gm-combo-box'; -import { Content } from '~/domain/layout/content'; -import { Layout } from '~/domain/layout/layout'; -import { useIntl } from '~/intl'; -import { getClient, getImageProps } from '~/lib/sanity'; -import { getNotFoundPageQuery } from '~/queries/get-not-found-page-query'; -import { getLastGeneratedDate } from '~/static-props/get-data'; -import { mediaQueries, radii, sizes, space } from '~/style/theme'; -import { ImageBlock } from '~/types/cms'; -import { getFilenameToIconName } from '~/utils/get-filename-to-icon-name'; - -const determinePageType = (path: string) => { - const levelsToPageTypes: { [key: string]: string } = { landelijk: 'nl', gemeente: 'gm', artikelen: 'article' }; - - let pageType = 'general'; - Object.keys(levelsToPageTypes).forEach((key) => { - if (path.startsWith(`/${key}`)) { - pageType = levelsToPageTypes[key]; - } - }); - - return pageType; -}; - -export const getStaticProps: GetStaticProps = async ({ locale = 'nl' }) => { - const { lastGenerated } = getLastGeneratedDate(); - const query = getNotFoundPageQuery(locale); - const client = await getClient(); - const notFoundPagesConfiguration = await client.fetch(query); - - return { - props: { lastGenerated, notFoundPagesConfiguration }, - }; -}; - -type Link = { - id?: string; - linkIcon?: string; - linkLabel: string; - linkUrl: string; -}; - -type NotFoundPageConfiguration = { - description: PortableTextEntry[]; - image: ImageBlock; - isGeneralPage: boolean; - isGmPage: boolean; - pageType: string; - title: string; - cta?: { - ctaIcon?: string; - ctaLabel: string; - ctaLink: string; - }; - links?: Link[]; -}; - -interface NotFoundProps { - lastGenerated: string; - notFoundPagesConfiguration: NotFoundPageConfiguration[]; -} - -const NotFound = ({ lastGenerated, notFoundPagesConfiguration }: NotFoundProps) => { - const { commonTexts } = useIntl(); - const { asPath } = useRouter(); - const pageType = determinePageType(asPath); - const pageConfig = notFoundPagesConfiguration.find((page) => page.pageType === pageType); - - if (!pageConfig) { - return ( - - - {commonTexts.notfound_titel.text} - {commonTexts.notfound_beschrijving.text} - - - ); - } - - const { title, description, isGmPage = pageType === 'gm', isGeneralPage = pageType === 'general', image, links = undefined, cta = undefined } = pageConfig; - - return ( - - - - - {title} - - - - {isGmPage && ( - // Compensating for padding on the combo-box element using negative margins. - - - - )} - - {links && ( - - {links.map((link, index) => ( - - ))} - - )} - - {cta && Object.values(cta).some((item) => item !== null) && ( - - )} - - - - - - - - ); -}; - -export default NotFound; - -interface NotFoundLinkProps { - alignItems: string; - display: string; - link: Link; - border?: string; - borderRadius?: string; - className?: string; - hasChevron?: boolean; - isCTA?: boolean; - marginBottom?: string; - maxWidth?: string; - order?: number; - padding?: string; -} - -const NotFoundLink = ({ link: { linkUrl, linkLabel, linkIcon }, hasChevron, isCTA, ...restProps }: NotFoundLinkProps) => { - const iconNameFromFileName = linkIcon ? (getFilenameToIconName(linkIcon) as IconName) : null; - const icon = iconNameFromFileName ? : undefined; - - return ( - - {icon && icon} - - - {linkLabel} - - - {hasChevron && } - - ); -}; - -const NotFoundCTA = NotFoundLink; // Renaming for the sake of readability. - -const NotFoundLayout = styled.div` - display: flex; - flex-direction: column; - gap: ${space[4]}; - justify-content: space-between; - margin: ${space[5]} auto; - max-width: ${sizes.maxWidth}px; - padding: 0 ${space[3]}; - - @media ${mediaQueries.sm} { - flex-direction: row; - padding: 0 ${space[4]}; - } - - @media ${mediaQueries.md} { - align-items: flex-start; - } - - .not-found-content-cta { - transition: all 0.2s ease-in-out; - - svg rect { - fill: ${colors.transparent}; - } - - &:hover { - background-color: ${colors.gray1}; - } - } -`; - -interface StyledAnchorProps { - hasIcon: boolean; - isCTA: boolean | undefined; -} - -const StyledAnchor = styled(Anchor)` - margin-inline: ${({ hasIcon, isCTA }) => (hasIcon || isCTA ? space[2] : !hasIcon ? `0 ${space[2]}` : 0)}; - - &:hover { - text-decoration: none; - } -`; diff --git a/packages/app/src/pages/[...404].tsx b/packages/app/src/pages/[...404].tsx new file mode 100644 index 0000000000..1d4d7edf5b --- /dev/null +++ b/packages/app/src/pages/[...404].tsx @@ -0,0 +1,35 @@ +import { GetServerSideProps } from 'next'; +import { getNotFoundPageData } from '~/components/not-found-pages/logic'; +import { NotFoundPage } from '~/components/not-found-pages/not-found-page'; +import { NotFoundFallback } from '~/components/not-found-pages/not-found-page-fallback'; +import { NotFoundProps } from '~/components/not-found-pages/types'; +import { getLastGeneratedDate } from '~/static-props/get-data'; + +export const getServerSideProps: GetServerSideProps = async ({ req, res, locale = 'nl' }) => { + res.statusCode = 404; + const { lastGenerated } = getLastGeneratedDate(); + const notFoundPageConfiguration = await getNotFoundPageData(req.url || 'general', locale); + + if (notFoundPageConfiguration === null) { + return { props: { lastGenerated } }; + } + + return { + props: { lastGenerated, notFoundPageConfiguration }, + }; +}; + +/** + * This is a catch-all route which is used as the 404 page for all routes except /gemeente/*. + * This means that this route is rendered for 404's which happen on the landelijk, articles, + * and 'general' level. + */ +const NotFound = ({ lastGenerated, notFoundPageConfiguration }: NotFoundProps) => { + if (!notFoundPageConfiguration || !Object.keys(notFoundPageConfiguration).length) { + return ; + } + + return ; +}; + +export default NotFound; diff --git a/packages/app/src/pages/gemeente/[code]/[...404].tsx b/packages/app/src/pages/gemeente/[code]/[...404].tsx new file mode 100644 index 0000000000..c1f4e7ede4 --- /dev/null +++ b/packages/app/src/pages/gemeente/[code]/[...404].tsx @@ -0,0 +1,35 @@ +import { GetServerSideProps } from 'next'; +import { getNotFoundPageData } from '~/components/not-found-pages/logic'; +import { NotFoundPage } from '~/components/not-found-pages/not-found-page'; +import { NotFoundFallback } from '~/components/not-found-pages/not-found-page-fallback'; +import { NotFoundProps } from '~/components/not-found-pages/types'; +import { getLastGeneratedDate } from '~/static-props/get-data'; + +export const getServerSideProps: GetServerSideProps = async ({ req, res, locale = 'nl' }) => { + res.statusCode = 404; + const { lastGenerated } = getLastGeneratedDate(); + const notFoundPageConfiguration = await getNotFoundPageData(req.url || 'general', locale); + + if (notFoundPageConfiguration === null) { + return { props: { lastGenerated } }; + } + + return { + props: { lastGenerated, notFoundPageConfiguration }, + }; +}; + +/** + * This is a catch-all route which is used as the 404 page for all /gemeente/* routes. + * There is some logic in rewrites.js which ensures that any 404's for /gemeente/* routes + * end up rendering this route. + */ +const NotFoundGM = ({ lastGenerated, notFoundPageConfiguration }: NotFoundProps) => { + if (!notFoundPageConfiguration || !Object.keys(notFoundPageConfiguration).length) { + return ; + } + + return ; +}; + +export default NotFoundGM; diff --git a/packages/app/src/queries/get-not-found-page-query.ts b/packages/app/src/queries/get-not-found-page-query.ts index 3799ee51d4..1955f1002a 100644 --- a/packages/app/src/queries/get-not-found-page-query.ts +++ b/packages/app/src/queries/get-not-found-page-query.ts @@ -1,7 +1,6 @@ -export const getNotFoundPageQuery = (locale: string) => { +export const getNotFoundPageQuery = (locale: string, pageType: string) => { return `// groq - *[_type == 'notFoundPageItem' && !(_id in path('drafts.**'))]{ - 'pageType': pageType, + *[_type == 'notFoundPageItem' && pageType == '${pageType}' && !(_id in path('drafts.**'))]{ 'title': title.${locale}, 'description': description.${locale}, 'links': links[]->{ @@ -22,6 +21,6 @@ export const getNotFoundPageQuery = (locale: string) => { ...image.asset->, } } - } + }[0] `; };