diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a597049 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@contentful:registry=https://registry.yarnpkg.com diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 5e0828a..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v18.16.1 diff --git a/README.md b/README.md index cd8d286..1214b1d 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,7 @@ For custom components, you can find the instructions at our [guide](https://www. 1. Set a unique value for `process.env.CONTENTFUL_PREVIEW_SECRET` in your environment variables. This value should be kept secret and only known to the API route and the CMS. 2. Configure the entry preview URLs in Contentful to match the draft API route's URL structure. This can be done in the Contentful web interface under "Settings" for each content type. For more information see: https://www.contentful.com/help/setup-content-preview/#preview-content-in-your-online-environment -3. The draft mode API route is already written in the app and can be found in `pages/api/draft.page.tsx`. This route checks for a valid secret and slug before redirecting to the corresponding page\*. -4. To disable draft mode, navigate to the `/api/disable-draft` route. This route already exists in the app and can be found in `pages/api/disable-draft.page.tsx`. +3. The draft mode API route is already written in the app and can be found in `src/app/api/enable-draft/route.ts`. This route checks for a valid secret and slug before redirecting to the corresponding page\*. _\*The `slug` field is optional; When not passed we redirect the page to the root of the domain._ @@ -217,12 +216,9 @@ _\*The `slug` field is optional; When not passed we redirect the page to the roo 1. Next, you will need to configure your Contentful space to use the correct preview URLs. To do this, go to the "Settings" section of your space, and click on the "Content Preview" tab. From here, you can configure the preview URLs for each of your content models. 2. Edit all content models that need a preview url. We usually expect that to only be the models prefixed with `📄 page -`. -3. Add a new URL with the following format: `https:///api/draft?secret=&slug={entry.fields.slug}`. Make sure to replace `` with the URL of your Next.js site, and `` with the value of `process.env.CONTENTFUL_PREVIEW_SECRET`. Optionally, a `locale` parameter can be passed. +3. Add a new URL with the following format: `https:///api/enable-draft?path=%2F{locale}%2F{entry.fields.slug}&x-contentful-preview-secret=`. Make sure to replace `` with the URL of your Next.js site, and `` with the value of `process.env.CONTENTFUL_PREVIEW_SECRET`. 4. Now, when you view an unpublished entry in Contentful, you should see a "Preview" button that will take you to the preview URL for that entry. Clicking this button should show you a preview of the entry on your Next.js site, using the draft API route that we set up earlier. -### Exiting the Content Preview - -To disable draft mode, navigate to the `/api/disable-draft` route. This route already exists in the app and can be found in `pages/api/disable-draft.page.tsx`. $~$ diff --git a/codegen.ts b/codegen.ts index 8bb1365..ac9329f 100644 --- a/codegen.ts +++ b/codegen.ts @@ -2,7 +2,10 @@ import { CodegenConfig } from '@graphql-codegen/cli'; const endpointOverride = process.env.CONTENTFUL_GRAPHQL_ENDPOINT; const productionEndpoint = 'https://graphql.contentful.com/content/v1/spaces'; -export const endpoint = `${endpointOverride || productionEndpoint}/${process.env.CONTENTFUL_SPACE_ID}`; +export const endpoint = `${endpointOverride || productionEndpoint}/${ + process.env.CONTENTFUL_SPACE_ID +}/environments/${process.env.CONTENTFUL_SPACE_ENVIRONMENT || 'master'}`; + export const config: CodegenConfig = { overwrite: true, ignoreNoDocuments: true, diff --git a/config/plugins.js b/config/plugins.js index 882aa81..f5f62d1 100644 --- a/config/plugins.js +++ b/config/plugins.js @@ -1,19 +1,6 @@ const withBundleAnalyzer = require('@next/bundle-analyzer'); -const withPWA = require('next-pwa'); module.exports = [ - [ - withPWA, - { - pwa: { - disable: process.env.NODE_ENV !== 'production', - dest: `public`, - register: false, - swSrc: './service-worker.js', - publicExcludes: ['!favicon/**/*'], - }, - }, - ], [ withBundleAnalyzer, { diff --git a/next-i18next.config.js b/next-i18next.config.js deleted file mode 100644 index a5174f8..0000000 --- a/next-i18next.config.js +++ /dev/null @@ -1,10 +0,0 @@ -const path = require('path'); - -module.exports = { - i18n: { - defaultLocale: 'en-US', - locales: ['en-US', 'de-DE'], - localeDetection: false, - localePath: path.resolve('./public/locales'), - }, -}; diff --git a/next.config.js b/next.config.js index c0e44f7..6939ea3 100644 --- a/next.config.js +++ b/next.config.js @@ -3,7 +3,6 @@ const nextComposePlugins = require('next-compose-plugins'); const headers = require('./config/headers'); const plugins = require('./config/plugins'); -const { i18n } = require('./next-i18next.config.js'); /** * https://github.com/cyrilwanner/next-compose-plugins/issues/59 @@ -15,7 +14,6 @@ const { withPlugins } = nextComposePlugins.extend(() => ({})); * documentation: https://nextjs.org/docs/api-reference/next.config.js/introduction */ module.exports = withPlugins(plugins, { - i18n, /** * add the environment variables you would like exposed to the client here * documentation: https://nextjs.org/docs/api-reference/next.config.js/environment-variables @@ -42,7 +40,6 @@ module.exports = withPlugins(plugins, { // swcMinify: true, poweredByHeader: false, - reactStrictMode: false, compress: true, /** @@ -57,11 +54,18 @@ module.exports = withPlugins(plugins, { * Settings are the defaults */ images: { - domains: ['images.ctfassets.net','images.eu.ctfassets.net'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'images.ctfassets.net', + }, + { + protocol: 'https', + hostname: 'images.eu.ctfassets.net', + }, + ], }, - pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'], - webpack(config) { config.module.rules.push({ test: /\.svg$/, diff --git a/package.json b/package.json index c1078c1..25f52fa 100644 --- a/package.json +++ b/package.json @@ -27,32 +27,34 @@ }, "license": "MIT", "dependencies": { - "@contentful/f36-icons": "^4.23.2", - "@contentful/f36-tokens": "^4.0.1", + "@contentful/f36-icons": "^4.29.0", + "@contentful/f36-tokens": "^4.0.5", "@contentful/live-preview": "^4.5.6", "@contentful/rich-text-react-renderer": "^15.16.2", - "@next/bundle-analyzer": "^13.0.4", + "@next/bundle-analyzer": "^14.2.6", "dotenv": "^16.0.3", "graphql": "^16.6.0", - "next": "^13.4.1", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-resources-to-backend": "^1.2.1", + "next": "^14.2.6", "next-compose-plugins": "^2.2.1", - "next-i18next": "^12.1.0", - "next-pwa": "^5.6.0", - "next-seo": "^5.15.0", - "next-sitemap": "^3.1.32", - "react": "18.2.0", - "react-dom": "18.2.0", + "next-i18n-router": "^5.5.1", + "react": "18.3.1", + "react-dom": "18.3.1", "react-focus-lock": "^2.9.2", - "sharp": "^0.32.6" + "react-i18next": "^15.0.1", + "sharp": "^0.33.5" }, "devDependencies": { "@babel/eslint-parser": "^7.19.1", "@contentful/rich-text-types": "^16.0.2", - "@graphql-codegen/cli": "2.13.12", + "@graphql-codegen/cli": "5.0.2", "@graphql-codegen/client-preset": "1.1.4", "@graphql-codegen/introspection": "2.2.1", + "@graphql-codegen/typescript-graphql-request": "^6.2.0", "@svgr/webpack": "^6.5.1", "@tailwindcss/typography": "^0.5.8", + "@types/negotiator": "^0.6.3", "@types/node": "18.11.9", "@types/react": "18.0.25", "@types/react-dom": "18.0.9", @@ -60,7 +62,7 @@ "@typescript-eslint/parser": "^5.32.0", "autoprefixer": "^10.4.13", "eslint": "8.26.0", - "eslint-config-next": "13.0.1", + "eslint-config-next": "14.2.7", "eslint-config-prettier": "^8.3.0", "eslint-import-resolver-typescript": "^2.4.0", "eslint-plugin-import": "^2.23.4", @@ -68,7 +70,7 @@ "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "husky": "^8.0.2", - "i18next": "^21.9.2", + "i18next": "^23.14.0", "i18next-http-backend": "^1.4.4", "lint-staged": "^13.0.3", "postcss": "^8.4.19", @@ -76,7 +78,6 @@ "prettier-plugin-tailwindcss": "^0.2.1", "tailwind-merge": "^1.8.0", "tailwindcss": "^3.2.4", - "typescript": "4.9.3", - "typescript-graphql-request": "^4.4.6" + "typescript": "5.5.4" } } diff --git a/src/app/[locale]/[...notFound]/page.tsx b/src/app/[locale]/[...notFound]/page.tsx new file mode 100644 index 0000000..2cfe638 --- /dev/null +++ b/src/app/[locale]/[...notFound]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function NotFoundCatchAll() { + notFound(); +} diff --git a/src/app/[locale]/[slug]/page.tsx b/src/app/[locale]/[slug]/page.tsx new file mode 100644 index 0000000..cb114ce --- /dev/null +++ b/src/app/[locale]/[slug]/page.tsx @@ -0,0 +1,71 @@ +import { draftMode } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import { ArticleContent, ArticleHero, ArticleTileGrid } from '@src/components/features/article'; +import { Container } from '@src/components/shared/container'; +import initTranslations from '@src/i18n'; +import { client, previewClient } from '@src/lib/client'; + +export async function generateStaticParams({ + params: { locale }, +}: { + params: { locale: string }; +}): Promise { + const gqlClient = client; + const { pageBlogPostCollection } = await gqlClient.pageBlogPostCollection({ locale, limit: 100 }); + + if (!pageBlogPostCollection?.items) { + throw new Error('No blog posts found'); + } + + return pageBlogPostCollection.items + .filter((blogPost): blogPost is NonNullable => Boolean(blogPost?.slug)) + .map(blogPost => { + return { + locale, + slug: blogPost.slug!, + }; + }); +} + +interface BlogPageProps { + params: { + locale: string; + slug: string; + }; +} + +export default async function Page({ params: { locale, slug } }: BlogPageProps) { + const { isEnabled: preview } = draftMode(); + const gqlClient = preview ? previewClient : client; + const { t } = await initTranslations({ locale }); + const { pageBlogPostCollection } = await gqlClient.pageBlogPost({ locale, slug, preview }); + const { pageLandingCollection } = await gqlClient.pageLanding({ locale, preview }); + const landingPage = pageLandingCollection?.items[0]; + const blogPost = pageBlogPostCollection?.items[0]; + const relatedPosts = blogPost?.relatedBlogPostsCollection?.items; + const isFeatured = Boolean( + blogPost?.slug && landingPage?.featuredBlogPost?.slug === blogPost.slug, + ); + + if (!blogPost) { + notFound(); + } + + return ( + <> + + + + + + + {relatedPosts && ( + +

{t('article.relatedArticles')}

+ +
+ )} + + ); +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..f625edd --- /dev/null +++ b/src/app/[locale]/layout.tsx @@ -0,0 +1,68 @@ +import { dir } from 'i18next'; +import type { Metadata, Viewport } from 'next'; +import { Urbanist } from 'next/font/google'; +import { draftMode } from 'next/headers'; + +import { ContentfulPreviewProvider } from '@src/components/features/contentful'; +import TranslationsProvider from '@src/components/shared/i18n/TranslationProvider'; +import { Footer } from '@src/components/templates/footer'; +import { Header } from '@src/components/templates/header'; +import initTranslations from '@src/i18n'; +import { locales } from '@src/i18n/config'; + +export async function generateMetadata() { + const metatadata: Metadata = { + metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), + } as Metadata; + + return metatadata; +} + +export const viewport: Viewport = { + themeColor: '#ffffff', +}; + +export async function generateStaticParams(): Promise { + return locales.map(locale => ({ locale })); +} + +const urbanist = Urbanist({ subsets: ['latin'], variable: '--font-urbanist' }); + +const allowedOriginList = ['https://app.contentful.com', 'https://app.eu.contentful.com']; + +interface LayoutProps { + children: React.ReactNode; + params: { locale: string }; +} + +export default async function PageLayout({ children, params }: LayoutProps) { + const { isEnabled: preview } = draftMode(); + const { locale } = params; + const { resources } = await initTranslations({ locale }); + + return ( + + + + + + + + +
+
+ {children} +
+
+
+ + + + + ); +} diff --git a/src/app/[locale]/not-found.tsx b/src/app/[locale]/not-found.tsx new file mode 100644 index 0000000..21f2876 --- /dev/null +++ b/src/app/[locale]/not-found.tsx @@ -0,0 +1,24 @@ +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { Trans } from 'react-i18next/TransWithoutContext'; + +import { Container } from '@src/components/shared/container'; +import initTranslations from '@src/i18n'; +import { defaultLocale } from '@src/i18n/config'; + +export default async function NotFound() { + const headersList = headers(); + const locale = headersList.get('x-next-i18n-router-locale') || defaultLocale; + const { t } = await initTranslations({ locale }); + + return ( + +

{t('notFound.title')}

+

+ + + +

+
+ ); +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx new file mode 100644 index 0000000..dce7115 --- /dev/null +++ b/src/app/[locale]/page.tsx @@ -0,0 +1,104 @@ +import type { Metadata } from 'next'; +import { draftMode } from 'next/headers'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { ArticleHero, ArticleTileGrid } from '@src/components/features/article'; +import { Container } from '@src/components/shared/container'; +import TranslationsProvider from '@src/components/shared/i18n/TranslationProvider'; +import initTranslations from '@src/i18n'; +import { locales } from '@src/i18n/config'; +import { PageBlogPostOrder } from '@src/lib/__generated/sdk'; +import { client, previewClient } from '@src/lib/client'; + +interface LandingPageProps { + params: { + locale: string; + }; +} + +export async function generateMetadata({ params }: LandingPageProps): Promise { + const { isEnabled: preview } = draftMode(); + const gqlClient = preview ? previewClient : client; + const landingPageData = await gqlClient.pageLanding({ locale: params.locale, preview }); + const page = landingPageData.pageLandingCollection?.items[0]; + const languages = locales.length > 1 ? {} : undefined; + + if (languages) { + for (const locale of locales) { + languages[locale] = `/${locale}`; + } + } + + let metadata: Metadata = { + alternates: { + canonical: '/', + languages, + }, + twitter: { + card: 'summary_large_image', + }, + }; + + if (page?.seoFields) { + metadata = { + title: page.seoFields.pageTitle, + description: page.seoFields.pageDescription, + robots: { + follow: !page.seoFields.nofollow, + index: !page.seoFields.noindex, + }, + }; + } + + return metadata; +} + +export default async function Page({ params: { locale } }: LandingPageProps) { + const { isEnabled: preview } = draftMode(); + const { t, resources } = await initTranslations({ locale }); + const gqlClient = preview ? previewClient : client; + + const landingPageData = await gqlClient.pageLanding({ locale, preview }); + const page = landingPageData.pageLandingCollection?.items[0]; + + if (!page) { + notFound(); + } + + const blogPostsData = await gqlClient.pageBlogPostCollection({ + limit: 6, + locale, + order: PageBlogPostOrder.PublishedDateDesc, + where: { + slug_not: page?.featuredBlogPost?.slug, + }, + preview, + }); + const posts = blogPostsData.pageBlogPostCollection?.items; + + if (!page?.featuredBlogPost || !posts) { + return; + } + + return ( + + + + + + + + {/* Tutorial: contentful-and-the-starter-template.md */} + {/* Uncomment the line below to make the Greeting field available to render */} + {/**/} + {/*
{page.greeting}
*/} + {/*
*/} + + +

{t('landingPage.latestArticles')}

+ +
+
+ ); +} diff --git a/src/app/api/enable-draft/route.ts b/src/app/api/enable-draft/route.ts new file mode 100644 index 0000000..df6c10e --- /dev/null +++ b/src/app/api/enable-draft/route.ts @@ -0,0 +1,184 @@ +import { cookies, draftMode } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; + +interface VercelJwt { + bypass: string; + aud: string; + iat: number; + sub: string; +} + +const getVercelJwtCookie = (request: NextRequest): string | undefined => { + const vercelJwtCookie = request.cookies.get('_vercel_jwt'); + if (!vercelJwtCookie) return; + return vercelJwtCookie.value; +}; + +const parseVercelJwtCookie = (vercelJwtCookie: string): VercelJwt => { + const base64Payload = vercelJwtCookie.split('.')[1]; + if (!base64Payload) throw new Error('Malformed `_vercel_jwt` cookie value'); + + const base64 = base64Payload.replace('-', '+').replace('_', '/'); + const payload = atob(base64); + const vercelJwt = JSON.parse(payload); + + assertVercelJwt(vercelJwt); + + return vercelJwt; +}; + +function assertVercelJwt(value: object): asserts value is VercelJwt { + const vercelJwt = value as VercelJwt; + if (typeof vercelJwt.bypass !== 'string') + throw new TypeError("'bypass' property in VercelJwt is not a string"); + if (typeof vercelJwt.aud !== 'string') + throw new TypeError("'aud' property in VercelJwt is not a string"); + if (typeof vercelJwt.sub !== 'string') + throw new TypeError("'sub' property in VercelJwt is not a string"); + if (typeof vercelJwt.iat !== 'number') + throw new TypeError("'iat' property in VercelJwt is not a number"); +} + +interface ParsedRequestUrl { + origin: string; + host: string; + path: string; + bypassToken: string; + contentfulPreviewSecret: string; +} + +const parseRequestUrl = (requestUrl: string | undefined): ParsedRequestUrl => { + if (!requestUrl) throw new Error('missing `url` value in request'); + const { searchParams, origin, host } = new URL(requestUrl); + + const rawPath = searchParams.get('path') || ''; + const bypassToken = searchParams.get('x-vercel-protection-bypass') || ''; + const contentfulPreviewSecret = searchParams.get('x-contentful-preview-secret') || ''; + + // to allow query parameters to be passed through to the redirected URL, the original `path` should already be + // URI encoded, and thus must be decoded here + const path = decodeURIComponent(rawPath); + + return { origin, path, host, bypassToken, contentfulPreviewSecret }; +}; + +const buildRedirectUrl = ({ + path, + base, + bypassTokenFromQuery, +}: { + path: string; + base: string; + bypassTokenFromQuery?: string; +}): string => { + const redirectUrl = new URL(path, base); + + // if the bypass token is provided in the query, we assume Vercel has _not_ already set the actual + // token that bypasses authentication. thus we provided it here, on the redirect + if (bypassTokenFromQuery) { + redirectUrl.searchParams.set('x-vercel-protection-bypass', bypassTokenFromQuery); + redirectUrl.searchParams.set('x-vercel-set-bypass-cookie', 'samesitenone'); + } + + return redirectUrl.toString(); +}; + +function enableDraftMode() { + draftMode().enable(); + const cookieStore = cookies(); + const cookie = cookieStore.get('__prerender_bypass')!; + cookies().set({ + name: '__prerender_bypass', + value: cookie?.value, + httpOnly: true, + path: '/', + secure: true, + sameSite: 'none', + }); +} + +export async function GET(request: NextRequest): Promise { + const { + origin: base, + path, + host, + bypassToken: bypassTokenFromQuery, + contentfulPreviewSecret: contentfulPreviewSecretFromQuery, + } = parseRequestUrl(request.url); + // if we're in development, we don't need to check, we can just enable draft mode + if (process.env.NODE_ENV === 'development') { + enableDraftMode(); + const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery }); + return redirect(redirectUrl); + } + + const vercelJwtCookie = getVercelJwtCookie(request); + let bypassToken: string; + let aud: string; + let vercelJwt: VercelJwt | null = null; + + if (bypassTokenFromQuery) { + bypassToken = bypassTokenFromQuery; + aud = host; + } else if (contentfulPreviewSecretFromQuery) { + bypassToken = contentfulPreviewSecretFromQuery; + aud = host; + } else { + // if we don't have a bypass token from the query we fall back to the _vercel_jwt cookie to find + // the correct authorization bypass elements + if (!vercelJwtCookie) { + return new Response('Missing _vercel_jwt cookie required for authorization bypass', { + status: 401, + }); + } + try { + vercelJwt = parseVercelJwtCookie(vercelJwtCookie); + } catch (e) { + if (!(e instanceof Error)) throw e; + return new Response('Malformed bypass authorization token in _vercel_jwt cookie', { + status: 401, + }); + } + bypassToken = vercelJwt.bypass; + aud = vercelJwt.aud; + } + + // certain Vercel account tiers may not have a VERCEL_AUTOMATION_BYPASS_SECRET, so we fallback to checking the value against the CONTENTFUL_PREVIEW_SECRET + // env var, which is supported as a workaround for these accounts + if ( + bypassToken !== process.env.VERCEL_AUTOMATION_BYPASS_SECRET && + contentfulPreviewSecretFromQuery !== process.env.CONTENTFUL_PREVIEW_SECRET + ) { + return new Response( + 'The bypass token you are authorized with does not match the bypass secret for this deployment. You might need to redeploy or go back and try the link again.', + { status: 403 }, + ); + } + + if (aud !== host) { + return new Response( + `The bypass token you are authorized with is not valid for this host (${host}). You might need to redeploy or go back and try the link again.`, + { status: 403 }, + ); + } + + if (!path) { + return new Response('Missing required value for query parameter `path`', { + status: 400, + }); + } + + enableDraftMode(); + + // if a _vercel_jwt cookie was found, we do _not_ want to pass through the bypassToken to the redirect query. this + // is because Vercel will not "process" (and remove) the query parameter when a _vercel_jwt cookie is present. + const bypassTokenForRedirect = vercelJwtCookie ? undefined : bypassTokenFromQuery; + + const redirectUrl = buildRedirectUrl({ + path, + base, + bypassTokenFromQuery: bypassTokenForRedirect, + }); + redirect(redirectUrl); +} diff --git a/public/favicons/apple-touch-icon.png b/src/app/apple-icon.png similarity index 100% rename from public/favicons/apple-touch-icon.png rename to src/app/apple-icon.png diff --git a/public/favicons/favicon.ico b/src/app/favicon.ico similarity index 100% rename from public/favicons/favicon.ico rename to src/app/favicon.ico diff --git a/src/pages/utils/globals.css b/src/app/globals.css similarity index 100% rename from src/pages/utils/globals.css rename to src/app/globals.css diff --git a/public/favicons/favicon-16x16.png b/src/app/icon.png similarity index 100% rename from public/favicons/favicon-16x16.png rename to src/app/icon.png diff --git a/public/favicons/favicon-32x32.png b/src/app/icon2.png similarity index 100% rename from public/favicons/favicon-32x32.png rename to src/app/icon2.png diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..4662a75 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,10 @@ +import '@src/app/globals.css'; + +interface LayoutProps { + children: React.ReactNode; + params: { locale: string }; +} + +export default async function RootLayout({ children }: LayoutProps) { + return children; +} diff --git a/public/favicons/site.webmanifest b/src/app/manifest.webmanifest similarity index 100% rename from public/favicons/site.webmanifest rename to src/app/manifest.webmanifest diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..b6acebf --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,38 @@ +import path from 'node:path'; + +import type { MetadataRoute } from 'next'; + +import { defaultLocale, locales } from '@src/i18n/config'; +import type { SitemapPagesFieldsFragment } from '@src/lib/__generated/sdk'; +import { client } from '@src/lib/client'; + +type SitemapFieldsWithoutTypename = Omit; +type SitemapPageCollection = SitemapFieldsWithoutTypename[keyof SitemapFieldsWithoutTypename]; + +export default async function sitemap(): Promise { + const promises = + locales?.map(locale => client.sitemapPages({ locale })).filter(page => Boolean(page)) || []; + const dataPerLocale: SitemapFieldsWithoutTypename[] = await Promise.all(promises); + const fields = dataPerLocale + .flatMap((localeData, index) => + Object.values(localeData).flatMap((pageCollection: SitemapPageCollection) => + pageCollection?.items.map(item => { + const localeForUrl = locales?.[index] === defaultLocale ? undefined : locales?.[index]; + const url = new URL( + path.join(localeForUrl || '', item?.slug || ''), + process.env.NEXT_PUBLIC_BASE_URL!, + ).toString(); + + return item && !item.seoFields?.excludeFromSitemap + ? { + lastModified: item.sys.publishedAt, + url, + } + : undefined; + }), + ), + ) + .filter(field => field !== undefined); + + return fields; +} diff --git a/src/components/features/article/ArticleAuthor.tsx b/src/components/features/article/ArticleAuthor.tsx index 71adda5..d959934 100644 --- a/src/components/features/article/ArticleAuthor.tsx +++ b/src/components/features/article/ArticleAuthor.tsx @@ -1,4 +1,9 @@ -import { useContentfulInspectorMode } from '@contentful/live-preview/react'; +'use client'; + +import { + useContentfulInspectorMode, + useContentfulLiveUpdates, +} from '@contentful/live-preview/react'; import { CtfImage } from '@src/components/features/contentful'; import { PageBlogPostFieldsFragment } from '@src/lib/__generated/sdk'; @@ -8,14 +13,15 @@ interface ArticleAuthorProps { } export const ArticleAuthor = ({ article }: ArticleAuthorProps) => { - const { author } = article; + const { author } = useContentfulLiveUpdates(article); const inspectorProps = useContentfulInspectorMode({ entryId: author?.sys.id }); return (
+ {...inspectorProps({ fieldId: 'avatar' })} + > {author?.avatar && ( { - const { content } = article; + const { content } = useContentfulLiveUpdates(article); const inspectorProps = useContentfulInspectorMode({ entryId: article.sys.id }); return ( diff --git a/src/components/features/article/ArticleHero.tsx b/src/components/features/article/ArticleHero.tsx index e48003e..efa2621 100644 --- a/src/components/features/article/ArticleHero.tsx +++ b/src/components/features/article/ArticleHero.tsx @@ -1,5 +1,10 @@ -import { useContentfulInspectorMode } from '@contentful/live-preview/react'; -import { useTranslation } from 'next-i18next'; +'use client'; + +import { + useContentfulInspectorMode, + useContentfulLiveUpdates, +} from '@contentful/live-preview/react'; +import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; import { ArticleAuthor } from '@src/components/features/article/ArticleAuthor'; @@ -12,7 +17,9 @@ interface ArticleHeroProps { article: PageBlogPostFieldsFragment; isFeatured?: boolean; isReversedLayout?: boolean; + locale?: string; } + export const ArticleHero = ({ article, isFeatured, @@ -20,15 +27,15 @@ export const ArticleHero = ({ }: ArticleHeroProps) => { const { t } = useTranslation(); const inspectorProps = useContentfulInspectorMode({ entryId: article.sys.id }); - - const { title, shortDescription, publishedDate } = article; + const { title, shortDescription, publishedDate } = useContentfulLiveUpdates(article); return (
+ )} + >
{article.featuredImage && ( + )} + > {t('article.featured')} )} @@ -55,7 +63,8 @@ export const ArticleHero = ({ 'ml-auto hidden pl-2 text-xs text-gray600', isReversedLayout ? 'lg:block' : '', )} - {...inspectorProps({ fieldId: 'publishedDate' })}> + {...inspectorProps({ fieldId: 'publishedDate' })} + >
@@ -67,7 +76,8 @@ export const ArticleHero = ({ )}
+ {...inspectorProps({ fieldId: 'publishedDate' })} + >
diff --git a/src/components/features/article/ArticleImage.tsx b/src/components/features/article/ArticleImage.tsx index 2c2f33f..05b946c 100644 --- a/src/components/features/article/ArticleImage.tsx +++ b/src/components/features/article/ArticleImage.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useContentfulInspectorMode } from '@contentful/live-preview/react'; import { twMerge } from 'tailwind-merge'; diff --git a/src/components/features/article/ArticleTile.tsx b/src/components/features/article/ArticleTile.tsx index a66f22e..543b498 100644 --- a/src/components/features/article/ArticleTile.tsx +++ b/src/components/features/article/ArticleTile.tsx @@ -1,4 +1,9 @@ -import { useContentfulInspectorMode } from '@contentful/live-preview/react'; +'use client'; + +import { + useContentfulInspectorMode, + useContentfulLiveUpdates, +} from '@contentful/live-preview/react'; import Link from 'next/link'; import { HTMLProps } from 'react'; import { twMerge } from 'tailwind-merge'; @@ -13,21 +18,22 @@ interface ArticleTileProps extends HTMLProps { } export const ArticleTile = ({ article, className }: ArticleTileProps) => { - const { title, publishedDate } = article; + const { featuredImage, publishedDate, slug, title } = useContentfulLiveUpdates(article); const inspectorProps = useContentfulInspectorMode({ entryId: article.sys.id }); return ( - +
- {article.featuredImage && ( + )} + > + {featuredImage && (
)} @@ -42,7 +48,8 @@ export const ArticleTile = ({ article, className }: ArticleTileProps) => {
+ {...inspectorProps({ fieldId: 'publishedDate' })} + >
diff --git a/src/components/features/contentful/CtfPreviewProvider.tsx b/src/components/features/contentful/CtfPreviewProvider.tsx new file mode 100644 index 0000000..f502be7 --- /dev/null +++ b/src/components/features/contentful/CtfPreviewProvider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { ContentfulLivePreviewInitConfig } from '@contentful/live-preview'; +import { ContentfulLivePreviewProvider } from '@contentful/live-preview/react'; +import { PropsWithChildren } from 'react'; + +export const ContentfulPreviewProvider = ({ + children, + ...props +}: PropsWithChildren) => { + return {children}; +}; diff --git a/src/components/features/contentful/index.ts b/src/components/features/contentful/index.ts index c7ce9e1..9978c0e 100644 --- a/src/components/features/contentful/index.ts +++ b/src/components/features/contentful/index.ts @@ -1,2 +1,3 @@ export * from './CtfRichText'; export * from './CtfImage'; +export * from './CtfPreviewProvider'; diff --git a/src/components/features/language-selector/LanguageSelector.tsx b/src/components/features/language-selector/LanguageSelector.tsx index 78934e5..e78140c 100644 --- a/src/components/features/language-selector/LanguageSelector.tsx +++ b/src/components/features/language-selector/LanguageSelector.tsx @@ -1,7 +1,12 @@ -import { useRouter } from 'next/router'; +'use client'; -import { LanguageSelectorDesktop } from './LanguageSelectorDesktop'; -import { LanguageSelectorMobile } from './LanguageSelectorMobile'; +import { usePathname, useRouter } from 'next/navigation'; +import { SyntheticEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { LanguageSelectorDesktop } from '@src/components/features/language-selector/LanguageSelectorDesktop'; +import { LanguageSelectorMobile } from '@src/components/features/language-selector/LanguageSelectorMobile'; +import i18nConfig, { locales } from '@src/i18n/config'; const localeName = locale => locale.split('-')[0]; @@ -10,17 +15,55 @@ const displayName = locale => type: 'language', }); +const isChangeEvent = (event: SyntheticEvent): event is React.ChangeEvent => { + return event.type === 'change'; +}; + export const LanguageSelector = () => { - const { locales } = useRouter(); + const { i18n } = useTranslation(); + const currentLocale = i18n.language; + const router = useRouter(); + const currentPathname = usePathname(); + + const handleLocaleChange: React.EventHandler = e => { + let newLocale: string | undefined = undefined; + + if (isChangeEvent(e)) { + newLocale = e.target.value; + } + + // set cookie for next-i18n-router + const days = 30; + const date = new Date(); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = `NEXT_LOCALE=${newLocale};expires=${date.toUTCString()};path=/`; + + // redirect to the new locale path + if (currentLocale === i18nConfig.defaultLocale) { + router.push('/' + newLocale + currentPathname); + } else { + router.push(currentPathname.replace(`/${currentLocale}`, `/${newLocale}`)); + } + + router.refresh(); + }; return locales && locales.length > 1 ? ( <>
- +
- +
) : null; diff --git a/src/components/features/language-selector/LanguageSelectorDesktop.tsx b/src/components/features/language-selector/LanguageSelectorDesktop.tsx index 000a4c7..947b218 100644 --- a/src/components/features/language-selector/LanguageSelectorDesktop.tsx +++ b/src/components/features/language-selector/LanguageSelectorDesktop.tsx @@ -1,10 +1,13 @@ -import { LanguageIcon, ChevronDownTrimmedIcon, ChevronUpTrimmedIcon } from '@contentful/f36-icons'; +import { LanguageIcon, ChevronDownIcon, ChevronUpIcon } from '@contentful/f36-icons'; +import { useCurrentLocale } from 'next-i18n-router/client'; import Link from 'next/link'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import FocusLock from 'react-focus-lock'; import { twMerge } from 'tailwind-merge'; +import i18nConfig, { locales } from '@src/i18n/config'; + const useClickOutside = (ref, setIsOpen) => { useEffect(() => { const handleClickOutside = event => { @@ -20,13 +23,16 @@ const useClickOutside = (ref, setIsOpen) => { }, [ref, setIsOpen]); }; -export const LanguageSelectorDesktop = ({ localeName, displayName }) => { - const router = useRouter(); +export const LanguageSelectorDesktop = ({ localeName, onChange, displayName }) => { + const currentLocale = useCurrentLocale(i18nConfig); const menuRef = useRef(null); const containerRef = useRef(null); - const localesToShow = router.locales?.filter(locale => locale !== router.locale); - + const localesToShow = locales.filter(locale => locale !== currentLocale); const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); + // Try to extract and match a locale from a pattern of `/en-US/:slug` + const pathnameHasLocale = locales.includes(pathname.slice(1, 6)); + const pathnameWithoutLocale = pathname.slice(6); useClickOutside(containerRef, setIsOpen); @@ -91,11 +97,11 @@ export const LanguageSelectorDesktop = ({ localeName, displayName }) => { onClick={() => setIsOpen(currentState => !currentState)} > - {localeName(router.locale)} + {localeName(currentLocale)} {isOpen ? ( - + ) : ( - + )} @@ -109,24 +115,29 @@ export const LanguageSelectorDesktop = ({ localeName, displayName }) => { role="menu" onKeyDown={handleMenuKeyDown} > - {localesToShow?.map((availableLocale, index) => ( -
  • - handleMenuItemKeydown(e, index)} - role="menuitem" - className="block py-2" - href={{ - pathname: router.pathname, - query: router.query, - }} - as={router.asPath} - locale={availableLocale} - onClick={() => setIsOpen(false)} - > - {displayName(availableLocale).of(localeName(availableLocale))} - -
  • - ))} + {localesToShow?.map((availableLocale, index) => { + return ( +
  • + handleMenuItemKeydown(e, index)} + role="menuitem" + className="block py-2" + href={ + pathnameHasLocale + ? `/${availableLocale}${pathnameWithoutLocale}` + : `/${availableLocale}${pathname}` + } + locale={availableLocale} + onClick={event => { + onChange(event); + setIsOpen(false); + }} + > + {displayName(availableLocale).of(localeName(availableLocale))} + +
  • + ); + })}
    diff --git a/src/components/features/language-selector/LanguageSelectorMobile.tsx b/src/components/features/language-selector/LanguageSelectorMobile.tsx index 854f7b6..e4cf5a8 100644 --- a/src/components/features/language-selector/LanguageSelectorMobile.tsx +++ b/src/components/features/language-selector/LanguageSelectorMobile.tsx @@ -1,15 +1,17 @@ +'use client'; + import { LanguageIcon, CloseIcon } from '@contentful/f36-icons'; -import { useTranslation } from 'next-i18next'; -import { useRouter } from 'next/router'; +import { useCurrentLocale } from 'next-i18n-router/client'; import { useEffect, useState } from 'react'; import FocusLock from 'react-focus-lock'; +import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; import { Portal } from '@src/components/shared/portal'; +import i18nConfig, { locales } from '@src/i18n/config'; -export const LanguageSelectorMobile = ({ localeName, displayName }) => { - const { locale, locales } = useRouter(); - const router = useRouter(); +export const LanguageSelectorMobile = ({ localeName, onChange, displayName }) => { + const currentLocale = useCurrentLocale(i18nConfig); const { t } = useTranslation(); const [showDrawer, setShowDrawer] = useState(false); @@ -66,13 +68,8 @@ export const LanguageSelectorMobile = ({ localeName, displayName }) => {

    {t('common.language')}