diff --git a/.eslintrc.json b/.eslintrc.json index fa4ec3b..c587478 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,9 +1,26 @@ { - "extends": ["next/core-web-vitals", "plugin:storybook/recommended", "plugin:prettier/recommended", "prettier"], + "extends": [ + "plugin:tailwindcss/recommended", + "next/core-web-vitals", + "plugin:storybook/recommended", + "plugin:prettier/recommended", + "prettier" + ], "plugins": ["@typescript-eslint"], "rules": { "prettier/prettier": "error", "@typescript-eslint/no-unused-vars": "error" }, + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js"], + "parser": "@typescript-eslint/parser" + } + ], + "settings": { + "tailwindcss": { + "callees": ["cn"] + } + }, "ignorePatterns": ["storybook-static/**"] } diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 8b0c9a7..e5cdfdf 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -6,9 +6,15 @@ jobs: chromatic-deployment: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '>=20.18.0' - name: Install dependencies - run: yarn + run: yarn install --frozen-lockfile - name: Publish to Chromatic uses: chromaui/action@v1 with: diff --git a/.gitignore b/.gitignore index d8a03cc..50a7aef 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ /.next/ /out/ +# storybook +/storybook-static +/build-storybook.log + # production /build diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..70bd3dd --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no-install commitlint --edit "$1" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..67e145b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.18.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f5c726b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Ignore gql +gql/* + diff --git a/.prettierrc.json b/.prettierrc.json index 1a9c43b..eaa1b75 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -22,9 +22,11 @@ "printWidth": 120, "semi": true, "singleQuote": true, - "tailwindFunctions": ["cn"], "trailingComma": "es5", "tabWidth": 2, "useTabs": false, - "plugins": ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-curly", "prettier-plugin-tailwindcss"] + "plugins": [ + "@ianvs/prettier-plugin-sort-imports", + "prettier-plugin-curly" + ] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b713c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 JAKALA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0ecccb1..81a10ed 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,54 @@ -# Next Contentful Starterkit +# Next.js Contentful Starterkit -This starterkit is meant to provide a good mix of best practices for working with Contentful in NextJS. It doesn't pretend to be the only way, but it is meant to address the main painpoints concerning data-fetching, component resolution, live previews, configuration management and App development. We believe a good starterkit is use-case based, but there is no good way to model all use-cases in same starter, so our use-case is a marketing or corporate site, it fact, it is modeled around Contentful's own [Marketing Template](https://www.contentful.com/starter-templates/marketing-website/) content model. +This starterkit is meant to provide a good mix of best practices for working with Contentful in Next.js. It doesn't pretend to be the only way, but it is meant to address the main painpoints concerning data-fetching, component resolution, live previews, configuration management and App development. We believe a good starterkit is use-case based, but there is no good way to model all use-cases in same starter, so while we're starting as a marketing site, it may evolve later. In fact, it is modeled around Contentful's own [Marketing Template](https://www.contentful.com/starter-templates/marketing-website/) content model, with a slight twist on the content part. -The starterkit's cornerstone is our data-fetching solution and it's typesafety. The goal is to have best-in-class Typescript-enabled, graphql-powered data fetching to eliminate guessing games in our components and provide full type definitions for free. The full list of current features with descriptions can be found below. +The starterkit's cornerstone is our data-fetching solution and it's typesafety. The goal is to have best-in-class Typescript-enabled, graphql-powered data fetching to eliminate guessing games in our components and provide full type definitions for free. The list of current features with descriptions can be found below. ## Features -- **NextJS App Router** usage for modern **React Server Components** approach - we think this is the way the industry will move and it is a huge benefit for traditional websites to do data fetching only on the server and keep client-bundle lean -- **GraphQL** + **GraphQL Codegen** with **client-preset** plugin + Typescript for data fetching - we believe the best way to benefit from a GraphQL backend on the frontend is to use Typescript and be informed of what is available to you when you do data fetching, and GraphQL Codegen ensures you are not “lying to yourself” in your types by generating them from the API. +- **Next.js App Router** usage for modern **React Server Components** approach - we think this is the way the industry will move to and it is a huge benefit for traditional websites to do data fetching only on the server and keep client bundle lean +- **GraphQL** + **gql.tada** plugin + Typescript for data fetching - we believe the best way to benefit from a GraphQL backend on the frontend is to use Typescript and be informed of what is available to you when you do data fetching, and gql.tada ensures you are not “lying to yourself” in your types by inferring them from the API. - **Cypress testing** - we include a configured Cypress.io testing suite with ability to do both Component and E2E testing using Cucumber/BDD style syntax (optionally you can use traditional spec files too) +- **Reference Component Architecture** - we use Tailwind and shadcn/ui for the UI layer as a base, but more importantly the starter ships with reference component architecture that promotes UI component best practices. We focus on ability to reuse UI across projects, use of React Server Components for data-fetching and we demostrate how Contentful data can be integrated with a UI design system while preserving all Next.js features and Contentful features as part of the integration. - **Component Renderer** - example of how to take a full tree of components and render them using a mapping of contentTypes to React components - **Draft Mode** - preview mode for your application for Contentful Preview API usage -- Use of **Contentful Live Preview** - contentful live previews let you edit components side by side with a visual representation and Live Preview SDK also lets you annotate specific fields you are editing to get to the editor screen by just clicking “Edit” button on the frontend. We also integrated live updates, which will show result of content changes immediately as opposed to waiting for content to auto-save in Contentful +- **Contentful Live Preview** - Contentful live previews let you edit components side by side with a visual representation and Live Preview SDK also lets you annotate specific fields you are editing to get to the editor screen by just clicking “Edit” button on the frontend. We also integrated live updates, which will show result of content changes immediately as opposed to waiting for content to auto-save in Contentful. +- **Contentful Content Source Maps** - Optionally use Contentful Content Source Maps feature to get Live Preview SDK annotations without manually annotating your components, as well as get Vercel Content Link for Vercel customers. ## Getting Started -Clone the repo of course ;) +1. This project is a template, feel free to either clone it (to preserve project history) or use click "Use this template" to create a repository with a single init commit. + As soon as you have a repository, clone it locally +2. Install dependencies with: -### Contentful access +``` +yarn install +``` -To develop locally, you will want to connect to a Contentful instance that has the same data model as we use to develop, there are 2 ways to do that: +3. Setup Contenful access + To develop locally, you will want to connect to a Contentful instance that has the same data model as we use to develop, there are 3 ways to do that: -1. You could get access to an existing space that follows Contentful Marketing Template content model, for example a collegue could share his space with you -2. You could create your own space with https://www.contentful.com/starter-templates/marketing-website/. Keep in mind, new templates today can only be deployed on brand new Contentful accounts, so you might have to create a new account with a new email to do that, but this shouldn't be a problem, as it's free. +- You could get access to an existing space that follows Contentful Marketing Template content model, for example a colleague could share his space with you +- You could create your own space with https://www.contentful.com/starter-templates/marketing-website/. Keep in mind, new templates today can only be deployed on brand new Contentful accounts, so you might have to create a new account with a new email to do that, but this shouldn't be a problem, as it's free. +- You can start from scratch and use the contentful CLI and `/migrations/ctf-seed.json` to [import](https://www.contentful.com/developers/docs/tutorials/cli/import-and-export/) our demo content into your own contentful instance: `yarn seed --environment-id=[ENVIRONMENT_ID] --space-id=[SPACE_ID]` -### Configure environment +You will want to get a CDA and CPA API keys by using this [guide](https://www.contentful.com/developers/docs/references/authentication/#api-keys-in-the-contentful-web-app) + +4. Configure environment Create .env.local in root directory of the repo with the following contents: ``` CONTENTFUL_SPACE= CONTENTFUL_DELIVERY_API= -CONTENTFUL_PREVIEW_API=**** +CONTENTFUL_PREVIEW_API= CONTENTFUL_ENVIRONMENT=master CONTENTFUL_PREVIEW_SECRET=secret +# Enable or disable Content Source Maps, see https://www.contentful.com/developers/docs/tools/vercel/content-source-maps-with-vercel/ +CONTENTFUL_USE_CONTENT_SOURCE_MAPS=true ``` -### Dev Server +5. Run Dev Server ```bash yarn dev @@ -44,47 +56,39 @@ yarn dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -### GraphQL Docs - -#### Regenerate graphql schema - -If you're adding new content types or making changes to the content model, you will need to generate a new graphql schema to get type inference in Typescript working and to get autocomplete in IDE. This can be done by running: - -```bash -yarn generate:schema -``` +## Documentation -After new types are generated, you will get changes in `./gql/` folder that you'll have to commit after you are done developing the feature. -Keep in mind, schema generation will take your `.env.local` and read the CONTENTFUL_ENVIRONMENT you are pointing to, so if you create a new content type on a different environment, it will not be pulled, or the opposite, if you have unwanted content types in your sandbox environment, they will all appear in the schema. Make sure you commit changes you intend to commit! +- [Component Architecture](./docs/components.md) +- [Components UI Folder](./components/ui/README.md) +- [Data-fetching guide](./docs/data-fetching.md) +- [Features guide](./docs/features.md) +- [Analytics guide](./components/analytics/README.md) +- [Architecture Decision Records](./docs/decisions/) +- [Next.js docs](https://nextjs.org/docs) -### Configure editor to use gql.tada +## Deploy to Vercel -In order for gql.tada's GraphQLSP plugin to work (and infer types), you need to have local Typescript server making the type inference (`node_modules/typescript/lib`) +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. -If you are using VSCode, the .vscode/settings.json in the repo is already configured to use the local Typescript server. +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. -If using Webstorm, make sure you configure the Typescript interpretter from node_modules/typescript/lib as well under Settings -> Language & Frameworks -> Typescript +## Support -If you can't use a Typescript server in your IDE, you can optionally generate a gql/graphql-env.d.ts by running this command. +While this starterkit is in active development, fixing some UI bugs or unfinished components may not be a priority. -```bash -yarn generate:output -``` +This starterkit is meant to serve two purposes: -This command will also run `gql.tada turbo` which will generate a cache file that should also be commited. This cache file will speed up inference for new users who just checked out a new branch. -More info [here](https://gql-tada.0no.co/devlog/2024-04-15) +1. Provide best practices for starting new projects with Contentful and Next.js +2. Provide a demo site and a playground to test new features released in Next.js and Contenful -## NextJS Docs +You are free to use the starterkit as a template to start new projects, but you can also pick and choose which reference implementations you like and copy them into your own project. -To learn more about Next.js, take a look at the following resources: +The demo site will evolve as we strive to demostrate more Next.js capabilities, but so will the Contentful part as Contentful adds more features (Contentful Studio, Taxonomy, etc) -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +Some features are going to be implemented just enough to demonstrate the concept in a demo, other features may be relying on particular infrastructure like on Vercel, it will be a balancing act. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! +Our commitment is to support majority of users and let them opt in, remove or modify certain functionality if they don't share the same infrastructure or feature set. We will provide documentation and guidance how to do that -## Deploy on Vercel +## Contributors -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +This project is open-source under MIT license and accepts contributions, if you have any feedback, please create a discussion or an issue on Github. diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx deleted file mode 100644 index 51e7927..0000000 --- a/app/[[...slug]]/page.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { draftMode } from 'next/headers'; - -import { encodeGraphQLResponse } from '@contentful/content-source-maps'; -import { graphql } from 'gql.tada'; - -import { ComponentRenderer } from '#/components/component-renderer'; -import DebugMode from '#/components/debug-mode/debug-mode'; -import { ComponentDuplexFieldsFragment } from '#/components/duplex-ctf/duplex-ctf'; -import { ComponentHeroBannerFieldsFragment } from '#/components/hero-banner-ctf/hero-banner-ctf'; -import { ComponentTopicBusinessInfoFieldsFragment } from '#/components/topic-business-info/topic-business-info'; -import { graphqlClient } from '#/lib/graphqlClient'; - -const getPage = async (slug: string, locale: string, preview = false) => { - const pageQuery = graphql( - ` - query PageQuery($slug: String, $locale: String, $preview: Boolean) @contentSourceMaps { - pageCollection(locale: $locale, preview: $preview, limit: 1, where: { slug: $slug }) { - items { - topSectionCollection(limit: 10) { - items { - ...ComponentHeroBannerFields - ...ComponentDuplexFields - } - } - pageContent { - ...ComponentTopicBusinessInfo - } - } - } - } - `, - [ComponentHeroBannerFieldsFragment, ComponentDuplexFieldsFragment, ComponentTopicBusinessInfoFieldsFragment] - ); - - const response = await graphqlClient(preview).query(pageQuery, { - locale, - preview, - slug, - }); - - const formattedData = preview - ? encodeGraphQLResponse({ - data: response.data, - extensions: response.extensions, - }) - : response; - - return formattedData?.data?.pageCollection?.items?.[0]; -}; - -const getPageSlugs = async () => { - const pageQuery = graphql(` - query PageSlugs($locale: String) { - # Fetch 50 pages. Ideally we would fetch a good sample of most popular pages for pre-rendering, - # but for the sake of this example we'll just fetch the first 50. - pageCollection(locale: $locale, limit: 50) { - items { - slug - } - } - } - `); - - const pages = await graphqlClient(false).query(pageQuery, { - locale: 'en-US', - }); - - return ( - pages?.data?.pageCollection?.items - .filter((page) => page?.slug) - .map((page) => ({ - slug: page?.slug === 'home' ? '/' : page?.slug, - })) || [] - ); -}; - -export default async function LandingPage({ params }: { params: { slug: string[] } }) { - const slug = params.slug?.join('/') ?? 'home'; - - const { isEnabled: isDraftMode } = draftMode(); - - const pageData = await getPage(slug, 'en-US', isDraftMode); - - const topComponents = pageData?.topSectionCollection?.items; - const pageContent = pageData?.pageContent; - - return ( -
- - {topComponents ? : null} - {pageContent ? : null} -
- ); -} - -export const revalidate = 120; - -export async function generateStaticParams() { - return (await getPageSlugs()).map((page) => ({ - slug: page?.slug?.split('/'), - })); -} diff --git a/app/[locale]/[[...slug]]/page.tsx b/app/[locale]/[[...slug]]/page.tsx new file mode 100644 index 0000000..82e579a --- /dev/null +++ b/app/[locale]/[[...slug]]/page.tsx @@ -0,0 +1,180 @@ +import { Metadata } from 'next'; +import { setStaticParamsLocale } from 'next-international/server'; +import { draftMode } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import { graphql } from 'gql.tada'; + +import { ComponentRenderer } from '#/components/component-renderer'; +import DebugMode from '#/components/debug-mode/debug-mode'; +import { ComponentDuplexFieldsFragment } from '#/components/duplex-ctf/duplex-ctf'; +import { ComponentHeroBannerFieldsFragment } from '#/components/hero-banner-ctf/hero-banner-ctf'; +import { LanguageDataSetter } from '#/components/language-data-provider/language-data-provider'; +import { ComponentSEOFieldsFragment, getSeoMetadata } from '#/components/seo/seo-ctf'; +import { TopicBusinessInfoFieldsFragment } from '#/components/topic-business-info/topic-business-info'; +import { addContentSourceMaps } from '#/lib/contentSourceMaps'; +import { graphqlClient } from '#/lib/graphqlClient'; +import { getLocaleFromPath } from '#/locales/get-locale-from-path'; +import { getStaticParams } from '#/locales/server'; + +type PageProps = { + params: { slug: string[]; locale: string }; +}; + +const getPage = async (slug: string, locale: string, preview = false) => { + const pageQuery = graphql( + ` + query PageQuery($slug: String, $locale: String, $preview: Boolean) @contentSourceMaps { + pageCollection(locale: $locale, preview: $preview, limit: 1, where: { slug: $slug }) { + items { + topSectionCollection(limit: 10) { + items { + __typename + ...ComponentHeroBannerFields + ...ComponentDuplexFields + } + } + pageContent { + __typename + ...TopicBusinessInfo + } + slugEn: slug(locale: "en-US") + slugDe: slug(locale: "de-DE") + } + } + } + `, + [ComponentHeroBannerFieldsFragment, ComponentDuplexFieldsFragment, TopicBusinessInfoFieldsFragment] + ); + + const response = await graphqlClient(preview).query(pageQuery, { + locale, + preview, + slug, + }); + + const processedResponse = addContentSourceMaps(response); + return processedResponse?.data?.pageCollection?.items?.[0]; +}; + +const getPageSlugs = async (locale: string) => { + const pageQuery = graphql(` + query PageSlugs($locale: String) { + # Fetch 50 pages. Ideally we would fetch a good sample of most popular pages for pre-rendering, + # but for the sake of this example we'll just fetch the first 50. + pageCollection(locale: $locale, limit: 50) { + items { + slug + } + } + } + `); + + const pages = await graphqlClient(false).query(pageQuery, { + locale: locale, + }); + + return ( + pages?.data?.pageCollection?.items + .filter((page) => page?.slug) + .map((page) => ({ + slug: page?.slug === 'home' ? '/' : page?.slug, + })) || [] + ); +}; + +const getPageMetadata = async (slug: string, locale: string, preview = false): Promise => { + const pageQuery = graphql( + ` + query PageQuery($slug: String, $locale: String, $preview: Boolean) { + pageCollection(locale: $locale, preview: $preview, limit: 1, where: { slug: $slug }) { + items { + seo { + ...SEOFields + } + slugEn: slug(locale: "en-US") + slugDe: slug(locale: "de-DE") + } + } + } + `, + [ComponentSEOFieldsFragment] + ); + + const response = await graphqlClient(preview).query(pageQuery, { + locale, + preview, + slug, + }); + + const pageMetadata = response?.data?.pageCollection?.items?.[0]; + + if (!pageMetadata) { + notFound(); + } + + return { + ...getSeoMetadata(pageMetadata?.seo), + // TODO: Extract this into i18n fragment and helper. + alternates: { + languages: { + en: `/en/${pageMetadata?.slugEn === 'home' ? '' : pageMetadata?.slugEn}`, + de: `/de/${pageMetadata?.slugDe === 'home' ? '' : pageMetadata?.slugDe}`, + }, + }, + }; +}; + +export default async function LandingPage({ params }: PageProps) { + const { locale } = params; + setStaticParamsLocale(locale); + const slug = params.slug?.join('/') ?? 'home'; + + const { isEnabled: isDraftMode } = draftMode(); + + const pageData = await getPage(slug, getLocaleFromPath(locale), isDraftMode); + + if (!pageData) { + notFound(); + } + + const topComponents = pageData?.topSectionCollection?.items; + const pageContent = pageData?.pageContent; + + return ( +
+ + + {topComponents ? : null} + {pageContent ? : null} +
+ ); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = params; + const slug = params.slug?.join('/') ?? 'home'; + const { isEnabled: isDraftMode } = draftMode(); + return getPageMetadata(slug, getLocaleFromPath(locale), isDraftMode); +} + +export async function generateStaticParams() { + const params = getStaticParams(); + const returnData: Array<{ slug?: string[]; locale: string }> = []; + for await (const locale of params) { + const slugs = (await getPageSlugs(getLocaleFromPath(locale.locale))).map((page) => ({ + slug: page?.slug?.split('/'), + })); + for (const slug of slugs) { + returnData.push({ slug: slug.slug, locale: locale.locale }); + } + } + return returnData; +} + +export const revalidate = 120; diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..40615a1 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,71 @@ +import { draftMode } from 'next/headers'; + +import { VercelToolbar } from '@vercel/toolbar/next'; +import { graphql } from 'gql.tada'; + +import { AnalyticsComponent } from '#/components/analytics'; +import { ContentfulPreviewProvider } from '#/components/contentful-preview-provider'; + +import { graphqlClient } from '../../lib/graphqlClient'; + +import '../globals.css'; + +import { AnnouncementBannerComponent } from '#/components/announcement-banner'; +import { LanguageDataProvider } from '#/components/language-data-provider/language-data-provider'; +import { NavigationFieldsFragment } from '#/components/navigation'; +import { SiteHeader } from '#/components/site-header'; +import { isContentSourceMapsEnabled } from '#/lib/contentSourceMaps'; +import { fontSans } from '#/lib/fonts'; +import { cn } from '#/lib/utils'; +import { getLocaleFromPath } from '#/locales/get-locale-from-path'; + +export default async function RootLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { locale: string }; +}) { + const shouldInjectToolbar = process.env.NODE_ENV === 'development'; + const { locale } = params; + const { isEnabled: isDraftMode } = draftMode(); + + const layoutQuery = graphql( + ` + query Layout($locale: String, $preview: Boolean) { + navigationMenuCollection(locale: $locale, preview: $preview, limit: 1) { + ...NavigationFields + } + } + `, + [NavigationFieldsFragment] + ); + + const layoutData = await graphqlClient(isDraftMode).query( + layoutQuery, + { + locale: getLocaleFromPath(locale), + preview: isDraftMode, + }, + { fetchOptions: { next: { revalidate: 60, tags: ['menu'] } } } + ); + + return ( + + + + + +
+ + +
{children}
+ {shouldInjectToolbar && } +
+
+
+
+ + + ); +} diff --git a/app/[locale]/not-found.tsx b/app/[locale]/not-found.tsx new file mode 100644 index 0000000..c2a30f3 --- /dev/null +++ b/app/[locale]/not-found.tsx @@ -0,0 +1,5 @@ +import { PageNotFound } from '#/components/404'; + +export default function NotFound() { + return ; +} diff --git a/app/globals.css b/app/globals.css index c41b859..fd0b1af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -74,3 +74,7 @@ @apply bg-background text-foreground; } } + +.wysiwyg p { + margin-block: 16px; +} \ No newline at end of file diff --git a/app/head.tsx b/app/head.tsx deleted file mode 100644 index f11b259..0000000 --- a/app/head.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function Head() { - return ( - <> - Create Next App - - - - - ); -} diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index c5e8bc6..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { draftMode } from 'next/headers'; - -import { VercelToolbar } from '@vercel/toolbar/next'; - -import { graphqlClient } from '#/lib/graphqlClient'; - -import './globals.css'; - -import { graphql } from 'gql.tada'; - -import { ContentfulPreviewProvider } from '#/components/contentful-preview-provider'; -import { NavigationFieldsFragment } from '#/components/navigation'; -import { SiteHeader } from '#/components/site-header'; -import { fontSans } from '#/lib/fonts'; -import { cn } from '#/lib/utils'; - -export default async function RootLayout({ children }: { children: React.ReactNode }) { - const shouldInjectToolbar = process.env.NODE_ENV === 'development'; - const { isEnabled: isDraftMode } = draftMode(); - - const layoutQuery = graphql( - ` - query Layout($locale: String, $preview: Boolean) { - navigationMenuCollection(locale: $locale, preview: $preview, limit: 1) { - ...NavigationFields - } - } - `, - [NavigationFieldsFragment] - ); - - const layoutData = await graphqlClient(isDraftMode).query( - layoutQuery, - { - locale: 'en-US', - preview: isDraftMode, - }, - { fetchOptions: { next: { revalidate: 60, tags: ['menu'] } } } - ); - - return ( - - {/* - will contain the components returned by the nearest parent - head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head - */} - - - -
- -
{children}
- {shouldInjectToolbar && } -
-
- - - ); -} diff --git a/cli/generate-component.mjs b/cli/generate-component.mjs new file mode 100644 index 0000000..60bc89f --- /dev/null +++ b/cli/generate-component.mjs @@ -0,0 +1,34 @@ +import chalk from 'chalk'; +import { select, input, confirm } from '@inquirer/prompts'; + +import prompts from './prompts.mjs'; +import { fetchCTFContentTypes, scaffoldComponentFiles } from './utils.mjs'; + +if (!process.env.CONTENTFUL_SPACE || !process.env.CONTENTFUL_ENVIRONMENT || !process.env.CONTENTFUL_DELIVERY_API) { + console.log( + chalk.whiteBright.bgRed.bold( + "You'll need to provide CONTENTFUL_SPACE, CONTENTFUL_ENVIRONMENT and CONTENTFUL_DELIVERY_API in your .env.local file for the CLI to be functional." + ) + ); + process.exit(); +} + +const contentTypesList = await fetchCTFContentTypes( + process.env.CONTENTFUL_SPACE, + process.env.CONTENTFUL_ENVIRONMENT, + process.env.CONTENTFUL_DELIVERY_API, + prompts.promptContentType.choices +); + +const existentContentType = await select({ + ...prompts.promptContentType, + choices: contentTypesList, +}); +const confirmNewUIComponent = await confirm(prompts.promptNewUIComponent); + +if (existentContentType === prompts.promptContentType.choices[0]) { + const newContentType = await input(prompts.promptComponentName); + scaffoldComponentFiles(newContentType, false, confirmNewUIComponent); +} else { + scaffoldComponentFiles(existentContentType, true, confirmNewUIComponent); +} diff --git a/cli/prompts.mjs b/cli/prompts.mjs new file mode 100644 index 0000000..c877006 --- /dev/null +++ b/cli/prompts.mjs @@ -0,0 +1,17 @@ +const prompts = { + promptContentType: { + message: 'Please choose a CTF content type to generate the component for:', + choices: ['---No content type yet---'], + pageSize: 10, + }, + promptComponentName: { + message: 'Please input the component name (use kebab case):', + required: true, + }, + promptNewUIComponent: { + default: true, + message: 'Would you like to generate a matching UI component for your content type?', + }, +}; + +export default prompts; diff --git a/cli/scaffolds/components/index.{{ext}} b/cli/scaffolds/components/index.{{ext}} new file mode 100644 index 0000000..2c1def7 --- /dev/null +++ b/cli/scaffolds/components/index.{{ext}} @@ -0,0 +1 @@ +export { {{pascalCase name}} } from './{{hyphenCase name}}'; diff --git a/cli/scaffolds/components/{{hyphenCase name}}-client.{{ext}} b/cli/scaffolds/components/{{hyphenCase name}}-client.{{ext}} new file mode 100644 index 0000000..21a2963 --- /dev/null +++ b/cli/scaffolds/components/{{hyphenCase name}}-client.{{ext}} @@ -0,0 +1,17 @@ +'use client'; + +import { ResultOf } from 'gql.tada'; + +import { {{pascalCase name}}Fragment } from '#/components/{{hyphenCase name}}/{{hyphenCase name}}'; +{{#if includeUI}}import { {{pascalCase name}} } from '#/components/ui/{{hyphenCase name}}';{{/if}} +import { useComponentPreview } from '../hooks/use-component-preview'; + +export const {{pascalCase name}}Client: React.FC<{ + data: ResultOf; +}> = (props) => { + const { data: originalData } = props; + const { data{{#if includeUI}}, addAttributes{{/if}} } = useComponentPreview(originalData); + +{{#if includeUI}} return <{{pascalCase name}} id={data.sys.id} addAttributes={addAttributes} />;{{/if~}} +{{~#unless includeUI}} return
{data.sys.id}
;{{/unless}} +}; diff --git a/cli/scaffolds/components/{{hyphenCase name}}.{{ext}} b/cli/scaffolds/components/{{hyphenCase name}}.{{ext}} new file mode 100644 index 0000000..56c84f4 --- /dev/null +++ b/cli/scaffolds/components/{{hyphenCase name}}.{{ext}} @@ -0,0 +1,21 @@ +import { FragmentOf, graphql, readFragment } from 'gql.tada'; + +import { {{pascalCase name}}Client } from './{{hyphenCase name}}-client'; + +export const {{pascalCase name}}Fragment = graphql(` + fragment {{pascalCase name}} on {{pascalCase name}} { + __typename + sys { + id + } + } +`); + +export type {{pascalCase name}}Props = { + data: FragmentOf & Record; +}; + +export const {{pascalCase name}}: React.FC<{{pascalCase name}}Props> = (props) => { + const data = readFragment({{pascalCase name}}Fragment, props.data); + return <{{pascalCase name}}Client data={data} />; +}; diff --git a/cli/scaffolds/ui/index.{{ext}} b/cli/scaffolds/ui/index.{{ext}} new file mode 100644 index 0000000..6f77a7d --- /dev/null +++ b/cli/scaffolds/ui/index.{{ext}} @@ -0,0 +1 @@ +export * from './{{hyphenCase name}}'; diff --git a/cli/scaffolds/ui/{{hyphenCase name}}.stories.{{ext}} b/cli/scaffolds/ui/{{hyphenCase name}}.stories.{{ext}} new file mode 100644 index 0000000..c4ec038 --- /dev/null +++ b/cli/scaffolds/ui/{{hyphenCase name}}.stories.{{ext}} @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { {{pascalCase name}} } from './{{hyphenCase name}}'; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta = { + title: 'UI/{{startCase name}}', + component: {{pascalCase name}}, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: { + id: {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Default: Story = { + args: { + id: '{{ snakeCase name }}_id', + }, +}; diff --git a/cli/scaffolds/ui/{{hyphenCase name}}.{{ext}} b/cli/scaffolds/ui/{{hyphenCase name}}.{{ext}} new file mode 100644 index 0000000..eadf2db --- /dev/null +++ b/cli/scaffolds/ui/{{hyphenCase name}}.{{ext}} @@ -0,0 +1,13 @@ +export interface {{pascalCase name}}Props { + id: string; + addAttributes?: (name: string) => object | null; +} + +export const {{pascalCase name}} = ({ id, addAttributes = () => ({}) }: {{pascalCase name}}Props) => { + return ( +
+
{{pascalCase name}}
+
{id}
+
+ ); +}; diff --git a/cli/utils.mjs b/cli/utils.mjs new file mode 100644 index 0000000..40c731e --- /dev/null +++ b/cli/utils.mjs @@ -0,0 +1,102 @@ +import * as fs from 'node:fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import chalk from 'chalk'; +import * as contentful from 'contentful'; +import ora from 'ora'; +import { Scaffold } from 'simple-scaffold'; + +export const pascalToHyphen = (str) => { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +}; + +function hyphenToPascal(str) { + return str + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +} + +/* + Fetches all available CTF content types and returns an array with formatted IDs. + */ +export const fetchCTFContentTypes = async (spaceID, envID, token, contentTypesPlaceholder) => { + const ctfClient = contentful.createClient({ + space: spaceID, + environment: envID, + accessToken: token, + }); + const contentTypes = [...contentTypesPlaceholder]; + + const spinner = ora(`Fetching content types for space ${spaceID}...`).start(); + + await ctfClient + .getContentTypes() + .then((response) => { + response.items.forEach((item) => { + contentTypes.push(pascalToHyphen(item.sys.id)); + }); + spinner.succeed(chalk.green('Done!')); + }) + .catch(() => { + console.error(); + spinner.fail('Not able to fetch the content types.'); + }); + + return contentTypes; +}; + +/* + Generates component files using content type or custom input string. + Updates mappings.ts with the new component. + */ +export const scaffoldComponentFiles = (contentType, updateMappings, includeUI) => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const templates = [path.join(__dirname, 'scaffolds', 'components')]; + + if (includeUI) { + templates.push(path.join(__dirname, 'scaffolds', 'ui')); + } + + // Scaffold component files. + const config = { + name: contentType, + data: { + includeUI, + ext: 'tsx', + }, + templates, + createSubFolder: true, + output: (fullPath, baseDir, baseName) => { + let outputPath = '../components'; + + if (baseDir.includes('scaffolds/ui')) { + outputPath = '../components/ui'; + } + + return path.join(__dirname, outputPath, contentType); + }, + }; + + const scaffold = Scaffold(config); + + // Update mappings.ts with our new component after a successful scaffold. + if (updateMappings) { + scaffold + .then(() => { + const mappingFilePath = path.join(__dirname, '../components/component-renderer/mappings.ts'); + const mappings = fs.readFileSync(mappingFilePath, 'utf-8').split('\n'); + // Add component import to the top of the file. + mappings.unshift( + `import { ${hyphenToPascal(contentType)} } from '#/components/${contentType}/${contentType}';` + ); + // Add the new component to the mappings file. + mappings.splice(-2, 0, ` ${hyphenToPascal(contentType)}: ${hyphenToPascal(contentType)},`); + const updatedMappings = mappings.join('\n'); + fs.writeFileSync(mappingFilePath, updatedMappings, { encoding: 'utf-8' }); + }) + .catch(() => console.error); + } +}; diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..556eef0 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,12 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + // TODO Add Scope Enum Here + // 'scope-enum': [2, 'always', ['yourscope', 'yourscope']], + 'type-enum': [ + 2, + 'always', + ['feat', 'fix', 'docs', 'chore', 'style', 'refactor', 'ci', 'test', 'revert', 'perf', 'vercel'], + ], + }, +}; diff --git a/components.json b/components.json index 1a7e143..ae049f9 100644 --- a/components.json +++ b/components.json @@ -10,7 +10,7 @@ "cssVariables": true }, "aliases": { - "components": "#/components", + "components": "#/components/ui", "utils": "#/lib/utils" } } diff --git a/components/404.tsx b/components/404.tsx new file mode 100644 index 0000000..848c107 --- /dev/null +++ b/components/404.tsx @@ -0,0 +1,45 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +import { Button } from '#/components/ui/button'; +import { I18nProviderClient, useScopedI18n } from '#/locales/client'; + +// THis is purely a demonstration of the pattern, how a client component can use the provider to get translated strings. +// In real-world application we will probably have a helper like `withi18n` and pass any client component to it. +export function PageNotFound() { + const params = useParams(); + return ( + + + + ); +} + +export function PageNotFoundInternal() { + const t = useScopedI18n('notFound'); + return ( +
+ {/* Background blur effect */} +
+ + {/* Content overlay */} +
+ {/* Text overlay with gradient background */} +
+

404

+

{t('heading')}

+

{t('text')}

+ +
+
+
+ ); +} diff --git a/components/analytics/README.md b/components/analytics/README.md new file mode 100644 index 0000000..8e26c51 --- /dev/null +++ b/components/analytics/README.md @@ -0,0 +1,45 @@ +# Analytics + +Analytics solution is based on the [getanalytics.io](https://getanalytics.io/) library. +The library provides three base API interfaces to sent tracking information: + +- `page()` - trigger page view. This will trigger page calls in any installed plugins +- `identify()` - this will trigger identify calls in any installed plugins and will set user data in localStorage +- `track()` - Track an analytics event. This will trigger track calls in any installed plugins + +## Next.js Integration + +We will provide integration with Next.js for three different cases: page view, +component in view, click on target. See the `components/analytics/analytics.tsx` +file where we convey the global Analytics context and define a hook to track +page view. Then go to the `app/layout.tsx` where we wrap all children components +inside the analytics context. + +### Page view tracking + +Page view will automatically triggered after the page will be loaded depending +on the Next.js router `pathname` changes. + +### Component InView tracking + +We introduced the wrapper component `TrackInView` that can be used to wrap any +client component and send analytics tracking event when the component is fully viewed. + +### On Click analytics + +Here's the place where we should communicate with the UI component and current +implementation suppose just passing the click tracking event callback to the UI +component and then the passed callback can be attached to any of the elements +inside UI components on demand. + +## Type Safe Events + +We provide interfaces for each event in the `components/analytics/tracking-events.ts`. +Each event should be registered in the `EventsMap` interface and for each event +data should be provided own interface that describes the event data modal and +will be the value of type the registered event in the `EventsMap` +Also that file provides a helper function `createAnalyticsEvent()` that should be +used to create any analytics event on the client level. This function will accept +the event name string as the first argument and the event data object as the +second argument and map given event name to the corresponding event data type. +It will guarantee that all events will have correct data. diff --git a/components/analytics/analytics.tsx b/components/analytics/analytics.tsx new file mode 100644 index 0000000..10f9c01 --- /dev/null +++ b/components/analytics/analytics.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { PropsWithChildren, useEffect } from 'react'; +import { usePathname } from 'next/navigation'; + +import Analytics from 'analytics'; +import { AnalyticsProvider } from 'use-analytics'; + +const analyticsInstance = Analytics({ + app: 'starterkit', + debug: true, +}); + +export function AnalyticsComponent({ children }: PropsWithChildren) { + const pathname = usePathname(); + + useEffect(() => { + analyticsInstance.page(); + }, [pathname]); + + return {children}; +} diff --git a/components/analytics/index.ts b/components/analytics/index.ts new file mode 100644 index 0000000..77dc4f5 --- /dev/null +++ b/components/analytics/index.ts @@ -0,0 +1 @@ +export * from './analytics'; diff --git a/components/analytics/trackInView.tsx b/components/analytics/trackInView.tsx new file mode 100644 index 0000000..839c360 --- /dev/null +++ b/components/analytics/trackInView.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { InView } from 'react-intersection-observer'; +import { useAnalytics } from 'use-analytics'; + +import { EventData, EventName } from './tracking-events'; + +interface TrackInViewProps { + eventName: EventName; + eventData: EventData; + children: React.ReactNode; +} + +export const TrackInView = ({ eventName, eventData, children }: TrackInViewProps) => { + const { track } = useAnalytics(); + const onComponentIntersection = (inView: boolean) => { + if (inView) { + track(eventName, eventData); + } + }; + return ( + onComponentIntersection(inView)}> + {children} + + ); +}; diff --git a/components/analytics/tracking-events.ts b/components/analytics/tracking-events.ts new file mode 100644 index 0000000..a5beced --- /dev/null +++ b/components/analytics/tracking-events.ts @@ -0,0 +1,26 @@ +interface EventsMap { + heroBannerViewed: heroBannerViewedProps; + duplexViewed: duplexViewedProps; + duplexClicked: duplexClickedProps; +} + +interface heroBannerViewedProps { + category: string; +} + +interface duplexViewedProps { + category: string; + type: string; +} + +interface duplexClickedProps extends duplexViewedProps {} + +export type EventName = keyof EventsMap; +export type EventData = EventsMap[T]; + +export function createAnalyticsEvent(eventName: T, eventData: EventData) { + return { + eventName, + eventData, + }; +} diff --git a/components/announcement-banner.tsx b/components/announcement-banner.tsx new file mode 100644 index 0000000..7b57493 --- /dev/null +++ b/components/announcement-banner.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; + +import { X } from 'lucide-react'; + +export function AnnouncementBannerComponent() { + const [isVisible, setIsVisible] = useState(true); + + if (!isVisible) { + return null; + } + + return ( +
+

+ This site is a demo of Next.js Contentful Starterkit. You can find it on{' '} + + GitHub + {' '} + as a template +

+ +
+ ); +} diff --git a/components/component-renderer/component-renderer.tsx b/components/component-renderer/component-renderer.tsx index dcf1584..c9dccc3 100644 --- a/components/component-renderer/component-renderer.tsx +++ b/components/component-renderer/component-renderer.tsx @@ -1,43 +1,54 @@ -import ErrorBoundary from '#/components/error-boundary/error-boundary'; +/** + * ComponentRenderer is a component that renders components based on the data supplied. + * + * A few ground principles for ComponentRenderer + * + * 1. It should accept data and decide which component(s) to render + * 2. It should allow arrays of data to be passed and handle null/undefined + * 3. As a proxy, it should let you know if it can't render the component you're asking because you didn't provide enough data. + * 4. It should be able to render the component(s) with the data you provided. + * 5. It should skip rendering if component is not found in the componentMap. + */ import { componentMap } from './mappings'; +import { ComponentProps } from 'react'; +import ErrorBoundary from '#/components/error-boundary/error-boundary'; -export default function ComponentRenderer({ - data, -}: { - data: any; // @TODO: Fixme -}) { +type ComponentMapType = typeof componentMap; +type Data = ComponentProps['data']; +type ComponentKey = keyof ComponentMapType; +type DataWithTypename = (Data & { __typename: string }) | { __typename: string } | null; + +function isComponentKey(key: string): key is ComponentKey { + return key in componentMap; +} + +export default function ComponentRenderer({ data }: { data: T }) { + if (data === null) { + return null; + } + + // If we have an array, we render each item in the array if (Array.isArray(data)) { return ( <> - {data.map((item) => { - if (!item?.sys?.id) { - return null; - } - - return ; + {data.map((item, index) => { + return ; })} ); } - if (!data?.__typename) { - return null; - } - - // @TODO: Fix typings for componentMap. - // @ts-ignore - - // @TODO: Fix typings for componentMap. - // @ts-ignore - const Component = componentMap[data.__typename]; - if (Component) { + if (isComponentKey(data.__typename)) { + const Component = componentMap[data.__typename]; + // At this point we know data is one of the accepted props for the component return ( - + ); } + // If we don't know the component, we don't render anything return null; } diff --git a/components/component-renderer/mappings.ts b/components/component-renderer/mappings.ts index 55919ce..d9d5640 100644 --- a/components/component-renderer/mappings.ts +++ b/components/component-renderer/mappings.ts @@ -1,8 +1,11 @@ -import dynamic from 'next/dynamic'; +import { HeroBannerCtf } from '#/components/hero-banner-ctf/hero-banner-ctf'; +import { DuplexCtf } from '#/components/duplex-ctf/duplex-ctf'; +import { TopicBusinessInfo } from '#/components/topic-business-info/topic-business-info'; +import { TopicPerson } from '#/components/topic-person/topic-person'; export const componentMap = { - ComponentHeroBanner: dynamic(() => import('#/components/hero-banner-ctf').then((mod) => mod.HeroBannerCtf)), - ComponentDuplex: dynamic(() => import('#/components/duplex-ctf').then((mod) => mod.DuplexCtf)), - TopicBusinessInfo: dynamic(() => import('#/components/topic-business-info').then((mod) => mod.TopicBusinessInfo)), - TopicPersons: dynamic(() => import('#/components/topic-person').then((mod) => mod.TopicPerson)), -}; + ComponentHeroBanner: HeroBannerCtf, + ComponentDuplex: DuplexCtf, + TopicBusinessInfo: TopicBusinessInfo, + TopicPersons: TopicPerson, +} as const; diff --git a/components/contentful-preview-provider/contentful-preview-provider.tsx b/components/contentful-preview-provider/contentful-preview-provider.tsx index 143396c..503b97f 100644 --- a/components/contentful-preview-provider/contentful-preview-provider.tsx +++ b/components/contentful-preview-provider/contentful-preview-provider.tsx @@ -2,14 +2,22 @@ import { ContentfulLivePreviewProvider } from '@contentful/live-preview/react'; -const ContentfulPreviewProvider = ({ isDraftMode, children }: { isDraftMode: boolean; children: any }) => { +const ContentfulPreviewProvider = ({ + isDraftMode, + isContentSourceMapsEnabled, + children, +}: { + isDraftMode: boolean; + isContentSourceMapsEnabled: boolean; + children: any; +}) => { const previewActive = isDraftMode; return ( {children} diff --git a/components/duplex-ctf/duplex-ctf-client.tsx b/components/duplex-ctf/duplex-ctf-client.tsx index bb2ec7d..63fc0fe 100644 --- a/components/duplex-ctf/duplex-ctf-client.tsx +++ b/components/duplex-ctf/duplex-ctf-client.tsx @@ -1,7 +1,10 @@ 'use client'; import { ResultOf } from 'gql.tada'; +import { useAnalytics } from 'use-analytics'; +import { createAnalyticsEvent } from '#/components/analytics/tracking-events'; +import { TrackInView } from '#/components/analytics/trackInView'; import { RichTextCtf } from '#/components/rich-text-ctf'; import { useComponentPreview } from '../hooks/use-component-preview'; @@ -10,41 +13,62 @@ import { getPageLinkChildProps } from '../page'; import { Duplex } from '../ui/duplex'; import { ComponentDuplexFieldsFragment } from './duplex-ctf'; +// We can create analytics event typed data on top level +// using createAnalyticsEvent helper. +// It will us type-safely create event name and event data. +const analyticsInViewEvent = createAnalyticsEvent('duplexViewed', { + category: 'duplexViewed', + type: 'ctf', +}); + +// For the direct track() usage from hook we can destructure the object +// returned from createAnalyticsEvent helper to have separate values as props. +const { eventName: analyticsClickEventName, eventData: analyticsClickEventData } = createAnalyticsEvent( + 'duplexClicked', + { + category: 'duplexClicked', + type: 'ctf', + } +); + export const DuplexCtfClient: React.FC<{ data: ResultOf; }> = (props) => { const { data: originalData } = props; - const { data, addAttributes } = useComponentPreview(originalData); - + const { data, addAttributes } = useComponentPreview(originalData); + const { track } = useAnalytics(); return ( - - -
- ) - } - image={ - data.image && - getImageChildProps({ - data: data.image, - priority: true, - sizes: '100vw', - }) - } - imageAlignment={data.containerLayout ? 'left' : 'right'} - imageHeight={data.imageStyle ? 'fixed' : 'full'} - addAttributes={addAttributes} - cta={ - data.targetPage && - getPageLinkChildProps({ - data: data.targetPage, - children: data.ctaText, - }) - } - colorPalette={data.colorPalette} - /> + + + + + ) + } + image={ + data.image && + getImageChildProps({ + data: data.image, + priority: true, + sizes: '100vw', + }) + } + imageAlignment={data.containerLayout ? 'left' : 'right'} + imageHeight={data.imageStyle ? 'fixed' : 'full'} + addAttributes={addAttributes} + cta={ + data.targetPage && + getPageLinkChildProps({ + data: data.targetPage, + children: data.ctaText, + }) + } + colorPalette={data.colorPalette} + onClickAnalyticsEvent={() => track(analyticsClickEventName, analyticsClickEventData)} + /> + ); }; diff --git a/components/duplex-ctf/duplex-ctf.tsx b/components/duplex-ctf/duplex-ctf.tsx index 62daefa..b75205b 100644 --- a/components/duplex-ctf/duplex-ctf.tsx +++ b/components/duplex-ctf/duplex-ctf.tsx @@ -31,7 +31,7 @@ export const ComponentDuplexFieldsFragment = graphql( ); export type DuplexProps = { - data: FragmentOf; + data: FragmentOf & Record; }; export const DuplexCtf: React.FC = (props) => { diff --git a/components/hero-banner-ctf/hero-banner-ctf-client.tsx b/components/hero-banner-ctf/hero-banner-ctf-client.tsx index b56a487..2b8bf4a 100644 --- a/components/hero-banner-ctf/hero-banner-ctf-client.tsx +++ b/components/hero-banner-ctf/hero-banner-ctf-client.tsx @@ -2,40 +2,49 @@ import { ResultOf } from 'gql.tada'; +import { createAnalyticsEvent } from '#/components/analytics/tracking-events'; +import { TrackInView } from '#/components/analytics/trackInView'; +import { ComponentHeroBannerFieldsFragment } from '#/components/hero-banner-ctf/hero-banner-ctf'; import { RichTextCtf } from '#/components/rich-text-ctf'; import { useComponentPreview } from '../hooks/use-component-preview'; import { getImageChildProps } from '../image-ctf'; import { getPageLinkChildProps } from '../page'; import { HeroBanner } from '../ui/hero-banner'; -import { ComponentHeroBannerFieldsFragment } from './hero-banner-ctf'; export const HeroBannerCtfClient: React.FC<{ data: ResultOf; }> = (props) => { const { data: originalData } = props; - const { data, addAttributes } = useComponentPreview(originalData); - + const { data, addAttributes } = useComponentPreview(originalData); + // We use createAnalyticsEvent helper to create typed event. + const analyticsInViewEvent = createAnalyticsEvent('heroBannerViewed', { + category: 'duplexViewed', + }); return ( - } - cta={ - data.targetPage && - getPageLinkChildProps({ - data: data.targetPage, - children: data.ctaText, - }) - } - image={ - data.image && - getImageChildProps({ - data: data.image, - sizes: '100vw', - priority: true, - }) - } - addAttributes={addAttributes} - /> + + } + cta={ + data.targetPage && + getPageLinkChildProps({ + data: data.targetPage, + children: data.ctaText, + }) + } + image={ + data.image && + getImageChildProps({ + data: data.image, + sizes: '100vw', + priority: true, + }) + } + size={data.heroSize} + colorPalette={data.colorPalette} + addAttributes={addAttributes} + /> + ); }; diff --git a/components/hero-banner-ctf/hero-banner-ctf.tsx b/components/hero-banner-ctf/hero-banner-ctf.tsx index babbf3e..11c1ca3 100644 --- a/components/hero-banner-ctf/hero-banner-ctf.tsx +++ b/components/hero-banner-ctf/hero-banner-ctf.tsx @@ -38,7 +38,7 @@ export const ComponentHeroBannerFieldsFragment = graphql( ); export type HeroBannerProps = { - data: FragmentOf; + data: FragmentOf & Record; }; export const HeroBannerCtf: React.FC = (props) => { diff --git a/components/hooks/use-component-preview.ts b/components/hooks/use-component-preview.ts index 7e23bbf..9c346ff 100644 --- a/components/hooks/use-component-preview.ts +++ b/components/hooks/use-component-preview.ts @@ -1,9 +1,11 @@ 'use client'; -import { Argument } from '@contentful/live-preview/dist/types'; +import { Entity } from '@contentful/live-preview/dist/types'; import { useContentfulInspectorMode, useContentfulLiveUpdates } from '@contentful/live-preview/react'; -export const useComponentPreview = (data: (typeof useContentfulLiveUpdates)['arguments'][0]) => { +type Argument = Entity & { sys: { id: string } }; + +export const useComponentPreview = (data: T) => { const previewData = useContentfulLiveUpdates(data); const inspectorProps = useContentfulInspectorMode({ entryId: data.sys.id, diff --git a/components/icons/icons.tsx b/components/icons/icons.tsx index d1a7848..2d8a940 100644 --- a/components/icons/icons.tsx +++ b/components/icons/icons.tsx @@ -54,4 +54,12 @@ export const Icons = { ), + altLogo: (props: React.SVGProps) => ( + + + + + + + ), }; diff --git a/components/language-data-provider/language-data-provider.tsx b/components/language-data-provider/language-data-provider.tsx new file mode 100644 index 0000000..a05a0e7 --- /dev/null +++ b/components/language-data-provider/language-data-provider.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useEffect, useState } from 'react'; + +type LanguageData = { slugsState: [Record, Dispatch>>] }; +const LanguageDataContext = createContext(undefined); + +interface LanguageDataProviderProps { + children: ReactNode; +} + +export const LanguageDataProvider = ({ children }: LanguageDataProviderProps) => { + const slugsState = useState>({}); + return {children}; +}; + +export const LanguageDataSetter = ({ data }: { data?: Record }) => { + const [, setSlugs] = useLanguageDataContext()!.slugsState; + useEffect(() => { + if (data) { + setSlugs(data); + } + }, [data]); + return null; +}; + +export function useLanguageDataContext() { + const context = useContext(LanguageDataContext); + return context ?? null; +} diff --git a/components/language-selector/index.ts b/components/language-selector/index.ts new file mode 100644 index 0000000..d38957b --- /dev/null +++ b/components/language-selector/index.ts @@ -0,0 +1 @@ +export * from './language-selector'; diff --git a/components/language-selector/language-selector.tsx b/components/language-selector/language-selector.tsx new file mode 100644 index 0000000..6aa3062 --- /dev/null +++ b/components/language-selector/language-selector.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { ChangeEvent, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; + +import { useLanguageDataContext } from '#/components/language-data-provider/language-data-provider'; +import { useCurrentLocale } from '#/locales/client'; + +export const LanguageSelector = () => { + const locale = useCurrentLocale(); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const languageData = useLanguageDataContext(); + + const handleLanguageChange = (e: ChangeEvent) => { + startTransition(() => { + const selectedLocale = e.target.value; + const [slugs] = languageData?.slugsState ?? []; + if (slugs?.[selectedLocale]) { + router.push(`/${selectedLocale}/${slugs[selectedLocale]}`); + } else { + router.push(`/${selectedLocale}`); + } + }); + }; + + return ( + <> + + + ); +}; diff --git a/components/navigation/navigation.tsx b/components/navigation/navigation.tsx index 77e6801..d5100c2 100644 --- a/components/navigation/navigation.tsx +++ b/components/navigation/navigation.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { FragmentOf, graphql, readFragment } from 'gql.tada'; +import { LanguageSelector } from '#/components/language-selector'; import { Button } from '#/components/ui/button'; import { NavigationMenu, @@ -12,6 +13,7 @@ import { } from '#/components/ui/navigation-menu'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '#/components/ui/sheet'; import { cn } from '#/lib/utils'; +import { getI18n } from '#/locales/server'; import { Icons } from '../icons'; import { PageLinkFieldsFragment } from '../page'; @@ -56,9 +58,10 @@ export type NavigationProps = { data: FragmentOf; }; -export const Navigation = (props: NavigationProps) => { +export const Navigation = async (props: NavigationProps) => { const data = readFragment(NavigationFieldsFragment, props.data); const items = data.items[0]?.menuItemsCollection?.items; + const t = await getI18n(); // Fragment Masking is forcing us to split fragments to match our components or our helper functions. // https://github.com/dotansimha/graphql-code-generator/discussions/8554#discussioncomment-4131776 @@ -111,10 +114,7 @@ export const Navigation = (props: NavigationProps) => { {groupLinks && ( {groupLinks.map((subMenuItem) => ( -
+
{subMenuItem?.slug && {subMenuItem?.name}}
))} @@ -169,9 +169,9 @@ export const Navigation = (props: NavigationProps) => { ); const Search = () => ( -
+
{ - +
); return ( -
+
- +
@@ -206,11 +202,12 @@ export const Navigation = (props: NavigationProps) => {
+
diff --git a/components/ui/hero-banner/hero-banner.tsx b/components/ui/hero-banner/hero-banner.tsx index e0f4769..9c897c3 100644 --- a/components/ui/hero-banner/hero-banner.tsx +++ b/components/ui/hero-banner/hero-banner.tsx @@ -1,5 +1,8 @@ import { ReactNode } from 'react'; +import { cn } from '#/lib/utils'; +import { getColorConfigFromPalette } from '#/theme'; + import { Button } from '../button/button'; import { Image, ImageProps } from '../image'; import { Link, LinkProps } from '../link'; @@ -9,6 +12,8 @@ interface HeroBannerProps { bodyText?: ReactNode; image?: ImageProps | null; cta?: LinkProps | null; + size?: boolean | null; + colorPalette?: string | null; addAttributes?: (name: string) => object | null; } @@ -18,11 +23,19 @@ export function HeroBanner(props: HeroBannerProps) { headline, bodyText, cta, + size, + colorPalette, addAttributes = () => ({}), // Default to no-op. } = props; + const colorConfig = getColorConfigFromPalette(colorPalette || ''); + return ( -
+
{image && ( {image.alt} )} -
+
+
{headline && (

{headline}

)} - {bodyText &&
{bodyText}
} + {bodyText && ( +
+ {bodyText} +
+ )} {cta?.href && cta?.children && (