├── .env.example ├── .env.local.example ├── .eslintrc.json ├── .git-blame-ignore-revs ├── .github ├── renovate.json └── workflows │ ├── ci.yml │ └── prettier.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierrc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── Inter-Bold.woff ├── Inter-BoldItalic.woff ├── Inter-Italic.woff ├── Inter-Regular.woff └── chip.png ├── commitlint.config.js ├── components ├── Alert.tsx ├── Button.tsx ├── Container.tsx ├── Debug │ ├── DebugProvider.tsx │ └── Grid.tsx ├── Footer.tsx ├── Header.tsx ├── Layout.tsx ├── Links.tsx ├── Loading.tsx ├── Logo.tsx ├── Meta.tsx ├── Page.tsx ├── PageBuilder │ ├── Article.tsx │ ├── Bento │ │ ├── Bento1 │ │ │ ├── BentoSubtitle.tsx │ │ │ ├── BentoSummary.tsx │ │ │ ├── BentoTitle.tsx │ │ │ └── index.tsx │ │ ├── Bento3.tsx │ │ ├── Bento3Wide.tsx │ │ ├── BentoEven.tsx │ │ ├── BentoNumberCallout.tsx │ │ └── BentoResolver.tsx │ ├── Hero │ │ ├── HeroH1.tsx │ │ ├── HeroH1WithImage.tsx │ │ └── index.tsx │ ├── HeroSubtitle.tsx │ ├── HeroSummary.tsx │ ├── HeroTitle.tsx │ ├── Logos.tsx │ ├── PortableText │ │ └── StyledPortableText.tsx │ ├── Quote.tsx │ └── index.tsx ├── PreviewPage.tsx └── Title.tsx ├── images └── introTemplateImg.png ├── lib ├── config.ts ├── constants.tsx └── markets.js ├── lint-staged.config.js ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── 404.tsx ├── [slug].tsx ├── _app.tsx ├── _document.tsx ├── api │ ├── exit-preview.tsx │ ├── og.tsx │ ├── preview.tsx │ ├── revalidate.tsx │ └── social-share.tsx ├── articles │ └── [slug].tsx ├── index.tsx └── studio │ └── [[...index]].tsx ├── postcss.config.js ├── public └── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── sanity.cli.ts ├── sanity.config.ts ├── sanity ├── badges │ └── market-badge.tsx ├── components │ ├── ArrayAutocompleteAddItem.tsx │ ├── CustomNavBar.tsx │ ├── Icon.tsx │ ├── IconSelector.tsx │ ├── OGPreview.tsx │ └── SocialSharePreview.tsx ├── config.tsx ├── queries.tsx ├── sanity.server.tsx ├── sanity.tsx ├── schemaTemplates.tsx └── structure │ ├── defaultDocumentNode.ts │ ├── getOgUrl.ts │ ├── getPreviewUrl.ts │ ├── getSocialShareUrl.ts │ └── index.tsx ├── schemas ├── cells │ ├── pageBuilderExperimentCell.ts │ └── pageBuilderLogosCell.ts ├── components │ └── RowDisplay.tsx ├── documents │ ├── article.ts │ ├── company.ts │ ├── menu.ts │ ├── page.ts │ ├── person.ts │ ├── quote.ts │ ├── redirect.ts │ └── settings.ts ├── index.ts └── objects │ ├── icon.ts │ ├── language.ts │ ├── link.ts │ ├── market.ts │ ├── pageBuilder.ts │ ├── portableText.ts │ ├── portableTextSimple.ts │ ├── seo.ts │ └── visibility.ts ├── styles └── index.css ├── tailwind.config.js ├── tsconfig.json └── types └── index.tsx /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/.env.example -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER="sanity-io" 2 | NEXT_PUBLIC_VERCEL_GIT_PROVIDER="github" 3 | NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG="nextjs-marketing-site-cms-sanity-v3" 4 | NEXT_PUBLIC_SANITY_PROJECT_TITLE= 5 | 6 | NEXT_PUBLIC_SANITY_PROJECT_ID= 7 | NEXT_PUBLIC_SANITY_DATASET= 8 | SANITY_API_READ_TOKEN= 9 | SANITY_REVALIDATE_SECRET= 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "sanity/react", "sanity/typescript", "prettier"], 3 | "plugins": ["simple-import-sort"], 4 | "rules": { 5 | "simple-import-sort/imports": "warn", 6 | "simple-import-sort/exports": "warn", 7 | "react-hooks/exhaustive-deps": "error", 8 | "no-process-env": "off", 9 | "no-warning-comments": "off" 10 | }, 11 | "globals": { 12 | "JSX": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # .git-blame-ignore-revs 2 | # initial de-lint, format by Rune 3 | ab69d51a734f480089b0bc8b6e314945694876dd 4 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>sanity-io/renovate-config:demo-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: lts/* 21 | cache: npm 22 | - run: npm ci 23 | - run: npm run type-check 24 | - run: npm run lint -- --max-warnings 0 25 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Prettier 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | run: 15 | name: 🤔 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | cache: npm 22 | node-version: lts/* 23 | - run: npm ci --ignore-scripts --only-dev 24 | - uses: actions/cache@9b0c1fce7a93df8e3bb8926b0d6e9d89e92f20a7 # v3 25 | with: 26 | path: node_modules/.cache/prettier/.prettier-cache 27 | key: prettier-${{ hashFiles('package-lock.json') }}-${{ hashFiles('.gitignore') }} 28 | - name: check if workflows needs prettier 29 | run: npx prettier --cache --check ".github/workflows/**/*.yml" || (echo "An action can't make changes to actions, you'll have to run prettier manually" && exit 1) 30 | - run: npx prettier --ignore-path .gitignore --cache --write . 31 | - uses: EndBug/add-and-commit@61a88be553afe4206585b31aa72387c64295d08b # tag=v9 32 | with: 33 | default_author: github_actions 34 | commit: --no-verify 35 | message: 'chore(prettier): 🤖 ✨' 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /studio/node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | /studio/dist 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | .pnpm-debug.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | # Env files created by scripts for working locally 40 | .env 41 | studio/.env.development 42 | 43 | .yalc 44 | yalc.lock 45 | 46 | #Intellij 47 | .idea 48 | *.iml 49 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "useTabs": false, 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": false 9 | } 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/CONTRIBUTING.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 - 2022 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multi-tenant, multi-lingual Marketing Next.js website with Page Builder powered by Sanity.io 2 | 3 | This project was created for demonstration purposes and is not intended as a feature-complete, production-ready website. 4 | 5 | ## Multi-tenant 6 | 7 | Throughout the schema is a required `market` field which is hidden once it has a value, and should be populated by Initial Value Templates. 8 | 9 | This allows authors to create content specifically relevant to only their market, within the constraints of globally-relevant schema. 10 | 11 | Reference fields rely on this market field to scope references. So for example a `company` document with the `market` field set to `US` can only be linked to a `person` document with the same value. 12 | 13 | For every "Market" in `./lib/markets.js` there is a corresponding Studio Config, plus a global config that shows documents from all Markets. With Custom Access Controls users could be limited to view only their Market, while Administrators could see the global view. 14 | 15 | Idea: This setup for "Markets" could instead be adapted to "Brands". 16 | 17 | ## Multi-lingual 18 | 19 | The default Market (US) only has one language (English). But some other markets have multiple languages. 20 | 21 | For these Markets, the Document Internationalization and Language Filter plugins improve the Sanity Studio experience and allow authors to create content in multiple languages. 22 | 23 | Document-level localization requires a hidden `language` field to be filled in by the plugin. 24 | 25 | Languages are defined in `./lib/markets.js` and are also used in `next.config.js` to create market and locale-specific routes. Local development runs at `http://localhost:80` to ensure localized routes render correctly. 26 | 27 | ## Next.js 28 | 29 | The Sanity Studio installed in this application can be accessed at `/studio` and is set up with Live Preview. 30 | 31 | ## Page Builder 32 | 33 | The Page Builder in this Studio is set up as a demonstration of how content can drive design. It deliberately excludes fields for design choices like colors, fonts, spacing and columns. It allows content editors to focus on content creation while the website's design takes care of itself. 34 | 35 | For example, the first "Article" will render as a Hero with the heading in a H1. Two Articles in succession may render in columns. "Quote" blocks can break up Articles. 36 | 37 | It also heavily relies on References instead of objects to promote the reuse of content in multiple locations. 38 | 39 | Blocks are "Articles" which may render into pages in their own right. So what starts as a small block on one Page could grow into a full Article. 40 | 41 | An "Experiment" block allows you to A/B test different "Articles". The logic for this is deliberately kept basic, a more production-ready rollout would likely be integrated with some service that keeps track of the success of the experiment. 42 | 43 | An "Article" can also have "Display from" and "Display until" settings. This implementation is only secure if your dataset is set to Private. 44 | -------------------------------------------------------------------------------- /assets/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/assets/Inter-Bold.woff -------------------------------------------------------------------------------- /assets/Inter-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/assets/Inter-BoldItalic.woff -------------------------------------------------------------------------------- /assets/Inter-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/assets/Inter-Italic.woff -------------------------------------------------------------------------------- /assets/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/assets/Inter-Regular.woff -------------------------------------------------------------------------------- /assets/chip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/assets/chip.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import * as React from 'react' 3 | 4 | import {LayoutProps} from './Layout' 5 | 6 | const AUDIENCES = { 7 | 0: 'A', 8 | 1: 'B', 9 | } 10 | 11 | type AlertProps = LayoutProps 12 | 13 | export default function Alert(props: AlertProps) { 14 | const {preview, queryParams} = props 15 | 16 | const toggleAudienceUrl = new URLSearchParams() 17 | toggleAudienceUrl.set('slug', String(queryParams.slug)) 18 | toggleAudienceUrl.set('audience', String(queryParams?.audience === 0 ? 1 : 0)) 19 | toggleAudienceUrl.set('date', String(queryParams.date ?? ``)) 20 | 21 | const [targetDate, setTargetDate] = React.useState( 22 | queryParams.date ?? new Date().toISOString() 23 | ) 24 | const [labelDate, setLabelDate] = React.useState(`Now`) 25 | const handleDateChange = React.useCallback( 26 | (e) => { 27 | setTargetDate( 28 | e.target.value 29 | ? new Date(e.target.value).toISOString() 30 | : queryParams.date 31 | ) 32 | setLabelDate(`Update`) 33 | }, 34 | [queryParams.date] 35 | ) 36 | 37 | const updateTimeUrl = new URLSearchParams() 38 | updateTimeUrl.set('slug', String(queryParams.slug)) 39 | updateTimeUrl.set('audience', String(queryParams?.audience)) 40 | updateTimeUrl.set('date', targetDate) 41 | 42 | const nowTimeUrl = new URLSearchParams() 43 | nowTimeUrl.set('slug', String(queryParams.slug)) 44 | nowTimeUrl.set('audience', String(queryParams?.audience)) 45 | nowTimeUrl.set('date', ``) 46 | 47 | if (!preview) { 48 | return null 49 | } 50 | 51 | return ( 52 |
53 |
54 | 58 | Preview{` `} 59 | On 60 | 61 | 65 | Audience{' '} 66 | {AUDIENCES[queryParams.audience] ?? `Unknown`} 67 | 68 | 69 | 77 | Time{` `} 78 | 79 | {queryParams.date ? targetDate.split(`T`).shift() : labelDate} 80 | 81 | 82 |
83 | 89 |
90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import {clsx} from 'clsx' 2 | import {ArrowRight} from 'lucide-react' 3 | import Link from 'next/link' 4 | import React from 'react' 5 | 6 | import {Link as LinkProps} from '../types' 7 | 8 | type ButtonMode = 'default' | 'ghost' | 'bleed' 9 | type ButtonProps = LinkProps & { 10 | mode?: ButtonMode 11 | icon?: boolean 12 | locale?: string 13 | href?: string 14 | disabled?: boolean 15 | } 16 | 17 | const buttonClasses = { 18 | base: `inline-flex gap-2 items-center border px-3 py-2 leading-none rounded transition-colors duration-200 ease-in-out`, 19 | default: `border-black bg-black text-white hover:border-magenta-400 hover:bg-magenta-400 hover:text-black dark:bg-white dark:text-black dark:text-black`, 20 | ghost: `border-gray-200 bg-transparent text-black hover:border-magenta-400 hover:bg-magenta-400 hover:text-black dark:border-gray-800 dark:text-gray-200`, 21 | bleed: `border-transparent text-gray-700 hover:bg-magenta-400 hover:text-black dark:text-gray-200`, 22 | disabled: `pointer-events-none opacity-40`, 23 | } 24 | 25 | export default function Button(props: ButtonProps) { 26 | const { 27 | text, 28 | url, 29 | reference, 30 | mode = `default`, 31 | icon = false, 32 | locale, 33 | href, 34 | disabled = false, 35 | } = props 36 | 37 | const classes = React.useMemo( 38 | () => 39 | clsx( 40 | buttonClasses.base, 41 | buttonClasses[mode], 42 | disabled && buttonClasses.disabled 43 | ), 44 | [mode, disabled] 45 | ) 46 | 47 | if (href) { 48 | return ( 49 | 50 | {text ?? reference?.title} 51 | {icon ? : null} 52 | 53 | ) 54 | } 55 | if (reference?.slug && (reference?.title || text)) { 56 | return ( 57 | 58 | {text ?? reference?.title} 59 | {icon ? : null} 60 | 61 | ) 62 | } else if (url && text) { 63 | return ( 64 | 65 | {text} 66 | {icon ? : null} 67 | 68 | ) 69 | } else if (text) { 70 | return ( 71 | 72 | {text} 73 | {icon ? : null} 74 | 75 | ) 76 | } 77 | 78 | return null 79 | } 80 | -------------------------------------------------------------------------------- /components/Container.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import {HTMLProps} from 'react' 3 | import * as React from 'react' 4 | 5 | import {useDebug} from './Debug/DebugProvider' 6 | 7 | export default function Container(props: HTMLProps) { 8 | const {className, children, ...restProps} = props 9 | const {grid} = useDebug() 10 | 11 | return ( 12 |
20 | {children} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/Debug/DebugProvider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | ReactNode, 4 | useContext, 5 | useEffect, 6 | useMemo, 7 | useState, 8 | } from 'react' 9 | import * as React from 'react' 10 | 11 | const DebugContext = createContext({animation: false, grid: false}) 12 | 13 | export function useDebug() { 14 | return useContext(DebugContext) 15 | } 16 | 17 | export function DebugProvider(props: {children?: ReactNode}) { 18 | const {children} = props 19 | const [animation, setAnimation] = useState(false) 20 | const [grid, setGrid] = useState(false) 21 | 22 | const debug = useMemo(() => ({animation, grid}), [animation, grid]) 23 | 24 | useEffect(() => { 25 | const handleKeyDown = (event: KeyboardEvent) => { 26 | if (event.key === 'a') { 27 | setAnimation((flag) => !flag) 28 | } 29 | 30 | if (event.key === 'g') { 31 | setGrid((flag) => !flag) 32 | } 33 | } 34 | 35 | window.addEventListener('keydown', handleKeyDown) 36 | 37 | return () => { 38 | window.removeEventListener('keydown', handleKeyDown) 39 | } 40 | }, []) 41 | 42 | return {children} 43 | } 44 | -------------------------------------------------------------------------------- /components/Debug/Grid.tsx: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react' 2 | import * as React from 'react' 3 | 4 | import {useDebug} from './DebugProvider' 5 | 6 | export function DebugGrid(props: {columns?: number}) { 7 | const {grid} = useDebug() 8 | 9 | const columns = useMemo( 10 | () => Array.from(new Array(props.columns || 5)).map((_, i) => i), 11 | [props.columns] 12 | ) 13 | 14 | if (!grid) return null 15 | 16 | return ( 17 |
18 | {columns.map((col) => ( 19 |
20 | ))} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import {useRouter} from 'next/router' 2 | import * as React from 'react' 3 | 4 | import {getMarketFromNextLocale} from '../pages' 5 | import Button from './Button' 6 | import Container from './Container' 7 | import Logo from './Logo' 8 | 9 | type FooterProps = { 10 | title: string 11 | } 12 | 13 | export default function Footer(props: FooterProps) { 14 | const {title} = props 15 | const {domainLocales, locale} = useRouter() 16 | 17 | return ( 18 |
19 | 20 |
21 | {title} 22 | 23 | {domainLocales && domainLocales.length > 0 ? ( 24 |
25 | Global sites 26 | {domainLocales.map((domainLocale) => ( 27 |
41 | ) : null} 42 |
43 |
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import {Menu} from 'lucide-react' 2 | import Link from 'next/link' 3 | import {useRouter} from 'next/router' 4 | import * as React from 'react' 5 | 6 | import {GlobalDataProps} from '../types' 7 | import Button from './Button' 8 | import Container from './Container' 9 | import Logo from './Logo' 10 | 11 | type HeaderProps = { 12 | title: string 13 | headerPrimary?: GlobalDataProps['menus']['headerPrimary'] 14 | } 15 | 16 | export default function Header(props: HeaderProps) { 17 | const {title, headerPrimary} = props 18 | const {domainLocales, locale} = useRouter() 19 | 20 | const domainLocale = 21 | domainLocales && domainLocales.find((l) => l.locales.includes(locale)) 22 | 23 | return ( 24 |
25 | 26 |
27 | {title} 28 | {headerPrimary && headerPrimary?.length > 0 ? ( 29 |
    30 | {headerPrimary.map((item) => ( 31 |
  • 32 |
  • 34 | ))} 35 |
  • 36 |
  • 38 |
39 | ) : null} 40 | 41 | {domainLocale?.locales && domainLocale.locales.length > 1 ? ( 42 |
43 | {domainLocale.locales.map((language) => ( 44 | 50 | {language.split(`-`)[0]} 51 | 52 | ))} 53 |
54 | ) : null} 55 | 56 |
57 | 58 |
59 |
60 |
61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type {PropsWithChildren} from 'react' 2 | import * as React from 'react' 3 | 4 | import {GlobalDataProps, PageQueryParams} from '../types' 5 | import Alert from './Alert' 6 | import {DebugProvider} from './Debug/DebugProvider' 7 | import Footer from './Footer' 8 | import Header from './Header' 9 | import Meta from './Meta' 10 | 11 | export type LayoutProps = { 12 | preview: boolean 13 | queryParams?: PageQueryParams 14 | globalData?: GlobalDataProps 15 | } 16 | 17 | export default function Layout(props: PropsWithChildren) { 18 | const {preview, queryParams, children} = props 19 | const {settings, menus} = props.globalData || {} 20 | 21 | return ( 22 | 23 | 24 |
25 |
26 | {preview && queryParams?.slug ? ( 27 | 28 | ) : null} 29 |
{children}
30 |
31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/Links.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import React from 'react' 3 | import {KeyedObject} from 'sanity' 4 | 5 | import {Link} from '../types' 6 | import Button from './Button' 7 | 8 | type LinksProps = { 9 | links: (KeyedObject & Link)[] 10 | className?: string 11 | } 12 | 13 | export default function Links(props: LinksProps) { 14 | const {links, className} = props 15 | 16 | if (!links?.length) return null 17 | 18 | return ( 19 |
20 | {links.map((link, linkIndex) => ( 21 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Title from './Title' 4 | 5 | export default function Loading() { 6 | return ( 7 |
8 | 9 | </div> 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import React, {PropsWithChildren} from 'react' 3 | 4 | /* TODO: use href from domainLocale */ 5 | export default function Logo(props: PropsWithChildren) { 6 | return ( 7 | <Link 8 | href="/" 9 | className="flex items-center gap-1 text-lg font-extrabold leading-tight tracking-tight md:text-xl" 10 | > 11 | <div className="dark-magenta-400 h-4 w-4 rounded-full bg-magenta-400" /> 12 | {props.children} 13 | </Link> 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /components/Meta.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import * as React from 'react' 3 | 4 | import {CMS_NAME} from '../lib/constants' 5 | 6 | export default function Meta() { 7 | return ( 8 | <Head> 9 | <link 10 | rel="apple-touch-icon" 11 | sizes="180x180" 12 | href="/favicon/apple-touch-icon.png" 13 | /> 14 | <link 15 | rel="icon" 16 | type="image/png" 17 | sizes="32x32" 18 | href="/favicon/favicon-32x32.png" 19 | /> 20 | <link 21 | rel="icon" 22 | type="image/png" 23 | sizes="16x16" 24 | href="/favicon/favicon-16x16.png" 25 | /> 26 | <link rel="manifest" href="/favicon/site.webmanifest" /> 27 | <link 28 | rel="mask-icon" 29 | href="/favicon/safari-pinned-tab.svg" 30 | color="#000000" 31 | /> 32 | <link rel="shortcut icon" href="/favicon/favicon.ico" /> 33 | <meta name="msapplication-TileColor" content="#000000" /> 34 | <meta name="msapplication-config" content="/favicon/browserconfig.xml" /> 35 | <meta name="theme-color" content="#000" /> 36 | <link rel="alternate" type="application/rss+xml" href="/feed.xml" /> 37 | <meta 38 | name="description" 39 | content={`A statically generated marketing site example using Next.js and ${CMS_NAME}.`} 40 | /> 41 | {/* <meta property="og:image" content={HOME_OG_IMAGE_URL} key="ogImage" /> */} 42 | </Head> 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/Page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import React from 'react' 3 | 4 | import {PageProps} from '../types' 5 | import Container from './Container' 6 | import PageBuilder from './PageBuilder' 7 | 8 | export default function Page(props: PageProps) { 9 | const {slug, market, translations, content} = props 10 | 11 | return ( 12 | <article className="flex flex-col"> 13 | <Container> 14 | {translations.length > 1 ? ( 15 | <ul className="flex items-center justify-end gap-4 border-b border-gray-200 py-3 dark:border-gray-800"> 16 | {translations 17 | .filter((i) => i) 18 | .map((translation) => { 19 | return ( 20 | <li 21 | key={translation.slug} 22 | className={ 23 | translation.slug === slug ? `opacity-50` : undefined 24 | } 25 | > 26 | <Link 27 | href={`/${translation.slug}`} 28 | locale={[translation.language, market].join(`-`)} 29 | > 30 | {translation.title}{' '} 31 | <span className="inline-block -translate-y-0.5 text-xs tracking-tight"> 32 | ({translation.language.toUpperCase()}) 33 | </span> 34 | </Link> 35 | </li> 36 | ) 37 | })} 38 | </ul> 39 | ) : null} 40 | </Container> 41 | {content && content.length > 0 ? <PageBuilder rows={content} /> : null} 42 | </article> 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/PageBuilder/Article.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {KeyedObject, TypedObject} from 'sanity' 3 | 4 | import {ArticleStub} from '../../types' 5 | import BentoResolver from './Bento/BentoResolver' 6 | import Hero from './Hero' 7 | 8 | type PageBuilderArticleProps = KeyedObject & 9 | TypedObject & { 10 | articles: (KeyedObject & ArticleStub)[] 11 | index: number 12 | } 13 | 14 | export default function PageBuilderArticle(props: PageBuilderArticleProps) { 15 | const {isHero, articles = [], index} = props 16 | 17 | if (!articles?.length) { 18 | return null 19 | } 20 | 21 | if (isHero && articles.length === 1) { 22 | const [article] = articles 23 | 24 | return <Hero {...article} /> 25 | } 26 | 27 | return <BentoResolver articles={articles} index={index} /> 28 | } 29 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/Bento1/BentoSubtitle.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import React from 'react' 3 | 4 | export function BentoSubtitle({ 5 | subtitle, 6 | className, 7 | }: { 8 | subtitle?: string 9 | className?: string 10 | }) { 11 | if (!subtitle) { 12 | return null 13 | } 14 | 15 | return ( 16 | <p 17 | className={clsx( 18 | `text-xl text-magenta-500 lg:text-2xl dark:text-magenta-400`, 19 | className 20 | )} 21 | > 22 | {subtitle} 23 | </p> 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/Bento1/BentoSummary.tsx: -------------------------------------------------------------------------------- 1 | import {PortableTextBlock} from '@portabletext/types' 2 | import React from 'react' 3 | 4 | import {StyledPortableText} from '../../PortableText/StyledPortableText' 5 | 6 | export function BentoSummary({summary}: {summary?: PortableTextBlock[]}) { 7 | if (!summary?.length) { 8 | return null 9 | } 10 | 11 | return ( 12 | <div className="text-2xl text-gray-600 dark:text-gray-200"> 13 | <StyledPortableText value={summary} /> 14 | </div> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/Bento1/BentoTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function BentoTitle({title}: {title?: string}) { 4 | if (!title) { 5 | return null 6 | } 7 | 8 | return ( 9 | <h2 className="text-4xl font-extrabold leading-tight tracking-tight lg:text-6xl"> 10 | {title} 11 | </h2> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/Bento1/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Image from 'next/image' 3 | import React from 'react' 4 | 5 | import {urlForImage} from '../../../../sanity/sanity' 6 | import {ArticleStub} from '../../../../types' 7 | import Container from '../../../Container' 8 | import Links from '../../../Links' 9 | import {BentoNumberCallout, isBentoNumberCallout} from '../BentoNumberCallout' 10 | import {BentoSubtitle} from './BentoSubtitle' 11 | import {BentoSummary} from './BentoSummary' 12 | import {BentoTitle} from './BentoTitle' 13 | 14 | export default function Index(props: {article: ArticleStub; index: number}) { 15 | const {article, index} = props 16 | const {image} = article 17 | const even = index % 2 === 0 18 | 19 | if (isBentoNumberCallout(article)) { 20 | return ( 21 | <div className="border-b border-t border-gray-200 dark:border-gray-800"> 22 | <BentoNumberCallout article={article} /> 23 | </div> 24 | ) 25 | } 26 | 27 | const {subtitle, title, summary = [], links = []} = article 28 | 29 | return ( 30 | <div> 31 | <Container> 32 | <div className="grid grid-cols-5 gap-4 py-6 lg:gap-6 lg:py-24"> 33 | {image ? ( 34 | <> 35 | <div 36 | className={clsx( 37 | `relative col-span-1 row-start-1 flex items-center gap-4 lg:col-span-1 lg:gap-6`, 38 | even ? `col-start-1` : `col-start-5` 39 | )} 40 | > 41 | <div className="absolute aspect-square w-full rounded-full bg-magenta-400 opacity-50 blur-3xl" /> 42 | <Image 43 | src={urlForImage(image).width(300).height(300).url()} 44 | width={300} 45 | height={300} 46 | alt={article.title ?? ``} 47 | className="relative aspect-square w-full rounded-full object-cover" 48 | /> 49 | </div> 50 | <div 51 | className={clsx( 52 | `col-span-4 row-start-1 flex flex-col justify-center`, 53 | even 54 | ? `col-start-2 items-start` 55 | : `col-start-1 text-left lg:items-end lg:text-right` 56 | )} 57 | > 58 | {subtitle ? <BentoSubtitle subtitle={subtitle} /> : null} 59 | {title ? <BentoTitle title={title} /> : null} 60 | </div> 61 | </> 62 | ) : null} 63 | {!image && subtitle ? <BentoSubtitle subtitle={subtitle} /> : null} 64 | {!image && title ? <BentoTitle title={title} /> : null} 65 | {summary?.length > 0 ? ( 66 | <div 67 | className={clsx( 68 | `col-span-5 row-start-2 lg:col-span-3 lg:col-start-2`, 69 | !even && `lg:text-right` 70 | )} 71 | > 72 | <BentoSummary summary={summary} /> 73 | </div> 74 | ) : null} 75 | {links?.length > 0 ? ( 76 | <div className="col-span-5 row-start-3 lg:col-span-3 lg:col-start-2"> 77 | <Links 78 | links={article.links} 79 | className={!even && `lg:justify-end`} 80 | /> 81 | </div> 82 | ) : null} 83 | </div> 84 | </Container> 85 | </div> 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/Bento3.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Image from 'next/image' 3 | import React, {PropsWithChildren} from 'react' 4 | import {KeyedObject} from 'sanity' 5 | 6 | import {urlForImage} from '../../../sanity/sanity' 7 | import {ArticleStub} from '../../../types' 8 | import Container from '../../Container' 9 | import {StyledPortableText} from '../PortableText/StyledPortableText' 10 | import {BentoSubtitle} from './Bento1/BentoSubtitle' 11 | import {BentoTitle} from './Bento1/BentoTitle' 12 | import {BentoNumberCallout, isBentoNumberCallout} from './BentoNumberCallout' 13 | 14 | export default function Bento3(props: { 15 | articles: (KeyedObject & ArticleStub)[] 16 | index: number 17 | }) { 18 | const {articles, index} = props 19 | const [first, ...rest] = articles 20 | const reverse = index % 4 == 0 21 | const high = <High first={first} /> 22 | const cells = ( 23 | <div className="flex flex-col"> 24 | {rest.map((article, articleIndex) => { 25 | const Component = isBentoNumberCallout(article) 26 | ? BentoNumberCallout 27 | : Small 28 | return ( 29 | <CellWrapper key={article._key} articleIndex={articleIndex}> 30 | <Container> 31 | <Component article={article} /> 32 | </Container> 33 | </CellWrapper> 34 | ) 35 | })} 36 | </div> 37 | ) 38 | return ( 39 | <div> 40 | <div className="max-h-xl grid divide-y divide-gray-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0 dark:divide-gray-800"> 41 | {reverse ? high : cells} 42 | {reverse ? cells : high} 43 | </div> 44 | </div> 45 | ) 46 | } 47 | 48 | function CellWrapper({ 49 | articleIndex, 50 | children, 51 | }: PropsWithChildren<{articleIndex: number}>) { 52 | return ( 53 | <div 54 | className={clsx( 55 | `flex items-center justify-center text-left lg:h-1/2 lg:flex-col lg:py-12`, 56 | articleIndex > 0 && `border-t border-gray-200 dark:border-gray-800` 57 | )} 58 | > 59 | {children} 60 | </div> 61 | ) 62 | } 63 | 64 | export function Small({article}: {article: ArticleStub}) { 65 | const image = article.image 66 | const hasText = 67 | article.title || article.subtitle || article?.summary?.length > 0 68 | return ( 69 | <> 70 | {image && !hasText ? ( 71 | <div className="relative h-full w-full"> 72 | <Image 73 | src={urlForImage(image).width(276).height(227).url()} 74 | width={276} 75 | height={227} 76 | alt={article.title ?? ``} 77 | className="rounded object-cover" 78 | /> 79 | </div> 80 | ) : ( 81 | <div className="relative flex h-full flex-col gap-3 py-12"> 82 | <BentoSubtitle subtitle={article.subtitle} /> 83 | <BentoTitle title={article.title} /> 84 | {article?.summary?.length > 0 ? ( 85 | <div className="max-w-xl text-xl text-gray-700 lg:text-2xl dark:text-gray-200"> 86 | <StyledPortableText value={article?.summary} /> 87 | </div> 88 | ) : null} 89 | </div> 90 | )} 91 | </> 92 | ) 93 | } 94 | 95 | function High({first}: {first: ArticleStub}) { 96 | const hasText = first.title || first.subtitle || first?.summary?.length > 0 97 | return ( 98 | <Container className="relative flex content-center justify-center bg-gradient-to-tl from-black via-magenta-900"> 99 | <div className="flex w-full items-center justify-center"> 100 | <div className="flex w-full gap-5 py-12 lg:flex-col lg:justify-center lg:py-24"> 101 | {first?.image ? ( 102 | <div 103 | className={clsx( 104 | `flex items-stretch justify-items-stretch self-stretch`, 105 | hasText ? '' : 'w-1/2 flex-shrink-0 lg:w-full' 106 | )} 107 | > 108 | <Image 109 | src={urlForImage(first.image).width(960).height(540).url()} 110 | width={960} 111 | height={540} 112 | alt={first.title ?? ``} 113 | className="h-48 w-full rounded-lg object-cover" 114 | /> 115 | </div> 116 | ) : null} 117 | 118 | {hasText ? ( 119 | <div className="flex flex-col gap-4"> 120 | <BentoSubtitle subtitle={first.subtitle} /> 121 | <BentoTitle title={first.title} /> 122 | 123 | {first?.summary?.length > 0 ? ( 124 | <div className="flex flex-col gap-5 text-xl text-gray-700 lg:text-2xl dark:text-gray-200"> 125 | <StyledPortableText value={first?.summary} /> 126 | </div> 127 | ) : null} 128 | </div> 129 | ) : null} 130 | </div> 131 | </div> 132 | </Container> 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/Bento3Wide.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import React, {PropsWithChildren} from 'react' 3 | import {KeyedObject} from 'sanity' 4 | 5 | import {ArticleStub} from '../../../types' 6 | import Container from '../../Container' 7 | import Bento1 from './Bento1' 8 | import {Small} from './Bento3' 9 | import {BentoNumberCallout, isBentoNumberCallout} from './BentoNumberCallout' 10 | 11 | export default function Bento3Wide(props: { 12 | articles: (KeyedObject & ArticleStub)[] 13 | index: number 14 | }) { 15 | const {articles, index} = props 16 | const [first, ...rest] = articles 17 | const even = index % 2 == 0 18 | const high = <Wide first={first} /> 19 | const cells = ( 20 | <div className="flex flex-col divide-y divide-gray-200 lg:flex-row lg:items-stretch lg:divide-x lg:divide-y-0 dark:divide-gray-800"> 21 | {rest.map((article, articleIndex) => { 22 | const Component = isBentoNumberCallout(article) 23 | ? BentoNumberCallout 24 | : Small 25 | return ( 26 | <CellWrapper key={article._key} articleIndex={articleIndex}> 27 | <Component article={article} /> 28 | </CellWrapper> 29 | ) 30 | })} 31 | </div> 32 | ) 33 | return ( 34 | <div className="max-h-xl"> 35 | {even ? cells : high} 36 | <div className="border-t border-gray-200 dark:border-gray-800"> 37 | {even ? high : cells} 38 | </div> 39 | </div> 40 | ) 41 | } 42 | 43 | function CellWrapper({ 44 | articleIndex, 45 | children, 46 | }: PropsWithChildren<{articleIndex: number}>) { 47 | return ( 48 | <Container 49 | className={clsx( 50 | `flex items-center justify-center text-left lg:flex-col lg:items-start`, 51 | articleIndex > 0 && 52 | `border-gray-200 sm:max-lg:border-t lg:border-l dark:border-gray-800` 53 | )} 54 | > 55 | {children} 56 | </Container> 57 | ) 58 | } 59 | 60 | function Wide({first}: {first: ArticleStub}) { 61 | return <Bento1 article={first} index={0} /> 62 | } 63 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/BentoEven.tsx: -------------------------------------------------------------------------------- 1 | import {Icon, IconSymbol} from '@sanity/icons' 2 | import clsx from 'clsx' 3 | import Image from 'next/image' 4 | import React, {PropsWithChildren} from 'react' 5 | import {KeyedObject} from 'sanity' 6 | 7 | import {urlForImage} from '../../../sanity/sanity' 8 | import {ArticleStub} from '../../../types' 9 | import Container from '../../Container' 10 | import {BentoNumberCallout, isBentoNumberCallout} from './BentoNumberCallout' 11 | 12 | export default function BentoEven(props: { 13 | articles: (KeyedObject & ArticleStub)[] 14 | }) { 15 | const {articles} = props 16 | 17 | return ( 18 | <div> 19 | <div className="flex flex-col lg:flex-row lg:flex-wrap dark:divide-gray-800"> 20 | {articles.map((article, articleIndex) => { 21 | const Component = isBentoNumberCallout(article) 22 | ? BentoNumberCallout 23 | : ArticleEven 24 | return ( 25 | <CellWrapper 26 | key={article._key} 27 | articleIndex={articleIndex} 28 | articles={articles} 29 | > 30 | <Component article={article} /> 31 | </CellWrapper> 32 | ) 33 | })} 34 | </div> 35 | </div> 36 | ) 37 | } 38 | 39 | function CellWrapper({ 40 | articleIndex, 41 | articles, 42 | children, 43 | }: PropsWithChildren<{articleIndex: number; articles: ArticleStub[]}>) { 44 | return ( 45 | <div 46 | className={clsx( 47 | `border-gray-200 py-4 text-left sm:py-5 lg:w-1/2 lg:flex-col dark:border-gray-800`, 48 | { 49 | 'xl:w-1/4': articles.length === 4, 50 | 'border-t': articleIndex !== 0, 51 | 'lg:border-l': articleIndex % 4 !== 0, 52 | }, 53 | articleIndex > 1 ? 'lg:border-t' : 'lg:border-t-0' 54 | )} 55 | > 56 | <div>{children}</div> 57 | </div> 58 | ) 59 | } 60 | 61 | function ArticleEven(props: {article: ArticleStub & KeyedObject}) { 62 | const {article} = props 63 | const hasText = !!(article.title || article.subtitle) 64 | 65 | return ( 66 | <Container className="relative flex gap-3 py-12 lg:px-5 lg:py-24"> 67 | {hasText ? ( 68 | <div> 69 | {article?.icon ? ( 70 | <div className="text-4xl"> 71 | <Icon symbol={article.icon as IconSymbol} /> 72 | </div> 73 | ) : null} 74 | <div className="flex flex-col gap-3"> 75 | <h2 className="text-xl font-extrabold leading-tight tracking-tight"> 76 | {article.title} 77 | </h2> 78 | <p className="text-gray-600 lg:pr-12 dark:text-gray-200"> 79 | {article.subtitle} 80 | </p> 81 | </div> 82 | </div> 83 | ) : null} 84 | {article.image && !hasText ? ( 85 | <div 86 | className={ 87 | 'flex w-full items-stretch justify-items-stretch self-stretch' 88 | } 89 | > 90 | <div 91 | className="m-auto flex items-stretch justify-items-stretch self-stretch" 92 | style={{height: 276}} 93 | > 94 | <Image 95 | src={urlForImage(article.image).width(276).height(227).url()} 96 | width={276} 97 | height={227} 98 | alt={article.title ?? ``} 99 | className="h-full w-full rounded object-cover" 100 | /> 101 | </div> 102 | </div> 103 | ) : null} 104 | </Container> 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/BentoNumberCallout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import {ArticleStub} from '../../../types' 4 | import Container from '../../Container' 5 | import {BentoSubtitle} from './Bento1/BentoSubtitle' 6 | import {BentoSummary} from './Bento1/BentoSummary' 7 | 8 | export function isBentoNumberCallout(article: ArticleStub) { 9 | const title = article.title 10 | if (!title) { 11 | return false 12 | } 13 | const numbers = title?.replace(/[^0-9]/g, '').length 14 | const other = title?.replace(/[0-9]/g, '').length 15 | return numbers >= other 16 | } 17 | 18 | export function BentoNumberCallout(props: {article: ArticleStub}) { 19 | const {article} = props 20 | return ( 21 | <Container className="relative flex items-center justify-center"> 22 | <div className="flex flex-col items-center justify-center py-6 lg:gap-3 lg:px-5 lg:py-12"> 23 | <BentoSubtitle subtitle={article.subtitle} /> 24 | <h2 className="text-6xl font-extrabold leading-tight tracking-tight lg:text-8xl"> 25 | {article.title} 26 | </h2> 27 | <BentoSummary summary={article.summary} /> 28 | </div> 29 | </Container> 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/PageBuilder/Bento/BentoResolver.tsx: -------------------------------------------------------------------------------- 1 | import {Icon, IconSymbol} from '@sanity/icons' 2 | import React from 'react' 3 | import {KeyedObject} from 'sanity' 4 | 5 | import {ArticleStub} from '../../../types' 6 | import Bento1 from './Bento1' 7 | import Bento3 from './Bento3' 8 | import Bento3Wide from './Bento3Wide' 9 | import BentoEven from './BentoEven' 10 | 11 | export interface BentoBoxProps { 12 | articles: (KeyedObject & ArticleStub)[] 13 | index: number 14 | } 15 | 16 | export default function BentoResolver(props: BentoBoxProps) { 17 | const {articles, index} = props 18 | 19 | if (articles.length === 1) { 20 | return <Bento1 article={articles[0]} index={index} /> 21 | } else if (articles.length === 2 || articles.length === 4) { 22 | return <BentoEven articles={articles} /> 23 | } else if (articles.length === 3) { 24 | return index % 2 === 0 ? ( 25 | <Bento3 articles={articles} index={index} /> 26 | ) : ( 27 | <Bento3Wide articles={articles} index={index} /> 28 | ) 29 | } 30 | 31 | return ( 32 | <div className="py-24"> 33 | {articles.map((article) => ( 34 | <div key={article._key} className="flex flex-col items-center gap-5"> 35 | {article?.icon ? ( 36 | <div className="text-4xl"> 37 | <Icon symbol={article.icon as IconSymbol} /> 38 | </div> 39 | ) : null} 40 | <h2 className="px-5 text-xl font-extrabold leading-tight tracking-tight"> 41 | {article.title} 42 | </h2> 43 | <p className="px-10 text-xs text-gray-600 dark:text-gray-200"> 44 | {article.subtitle} 45 | </p> 46 | </div> 47 | ))} 48 | </div> 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /components/PageBuilder/Hero/HeroH1.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import Container from '../../Container' 4 | import Links from '../../Links' 5 | import {HeroSubtitle} from '../HeroSubtitle' 6 | import {HeroSummary} from '../HeroSummary' 7 | import {HeroTitle} from '../HeroTitle' 8 | import {HeroProps} from '.' 9 | 10 | export default function HeroH1(props: HeroProps) { 11 | const {title, subtitle, summary, links} = props 12 | 13 | return ( 14 | <Container className="flex flex-col items-center justify-center gap-4 py-5 text-center md:py-8"> 15 | {subtitle ? <HeroSubtitle subtitle={subtitle} /> : null} 16 | {title ? <HeroTitle title={title} /> : null} 17 | {summary?.length > 0 ? <HeroSummary summary={summary} /> : null} 18 | {links?.length > 0 ? <Links links={links} /> : null} 19 | </Container> 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/PageBuilder/Hero/HeroH1WithImage.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import React from 'react' 3 | 4 | import {urlForImage} from '../../../sanity/sanity' 5 | import Container from '../../Container' 6 | import Links from '../../Links' 7 | import {HeroSubtitle} from '../HeroSubtitle' 8 | import {HeroSummary} from '../HeroSummary' 9 | import {HeroTitle} from '../HeroTitle' 10 | import {HeroProps} from '.' 11 | 12 | export default function HeroH1WithImage(props: HeroProps) { 13 | const {title, subtitle, summary, image, links} = props 14 | 15 | return ( 16 | <div> 17 | <Container className="relative"> 18 | <div className="flex flex-col-reverse items-stretch justify-items-stretch py-4 sm:py-5 lg:flex-row lg:items-center lg:py-5"> 19 | <div className="relative flex w-full flex-col gap-4 py-5 sm:py-6 lg:w-3/5 lg:py-8"> 20 | {subtitle ? <HeroSubtitle subtitle={subtitle} /> : null} 21 | {title ? <HeroTitle title={title} /> : null} 22 | {summary?.length > 0 ? <HeroSummary summary={summary} /> : null} 23 | {links?.length > 0 ? <Links links={links} /> : null} 24 | </div> 25 | 26 | {image ? ( 27 | <div className="flex w-full lg:w-2/5 lg:py-8"> 28 | <Image 29 | src={urlForImage(image).width(960).height(540).url()} 30 | width={960} 31 | height={540} 32 | alt={title ?? ``} 33 | className="aspect-video w-full rounded-lg object-cover" 34 | /> 35 | </div> 36 | ) : null} 37 | </div> 38 | </Container> 39 | </div> 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /components/PageBuilder/Hero/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import {ArticleStub} from '../../../types' 4 | import HeroH1 from './HeroH1' 5 | import HeroH1WithImage from './HeroH1WithImage' 6 | 7 | export type HeroProps = ArticleStub 8 | 9 | export default function PageBuilderHero(props: HeroProps) { 10 | const {image} = props 11 | 12 | return image ? <HeroH1WithImage {...props} /> : <HeroH1 {...props} /> 13 | } 14 | -------------------------------------------------------------------------------- /components/PageBuilder/HeroSubtitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function HeroSubtitle({subtitle}: {subtitle?: string}) { 4 | if (!subtitle) { 5 | return null 6 | } 7 | 8 | return ( 9 | <p className="text-xl text-magenta-500 lg:text-2xl dark:text-magenta-400"> 10 | {subtitle} 11 | </p> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/PageBuilder/HeroSummary.tsx: -------------------------------------------------------------------------------- 1 | import {PortableTextBlock} from '@portabletext/types' 2 | import React from 'react' 3 | 4 | import {StyledPortableText} from './PortableText/StyledPortableText' 5 | 6 | export function HeroSummary({summary}: {summary?: PortableTextBlock[]}) { 7 | if (!summary?.length) { 8 | return null 9 | } 10 | 11 | return ( 12 | <div className="max-w-xl text-xl text-gray-700 md:text-2xl dark:text-gray-200"> 13 | <StyledPortableText value={summary} /> 14 | </div> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/PageBuilder/HeroTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function HeroTitle({title}: {title?: string}) { 4 | if (!title) { 5 | return null 6 | } 7 | 8 | return ( 9 | <h1 className="text-6xl font-extrabold leading-none tracking-tight lg:text-8xl"> 10 | {title} 11 | </h1> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/PageBuilder/Logos.tsx: -------------------------------------------------------------------------------- 1 | import {getImageDimensions} from '@sanity/asset-utils' 2 | import {SanityImageSource} from '@sanity/image-url/lib/types/types' 3 | import delve from 'dlv' 4 | import React from 'react' 5 | import {KeyedObject, TypedObject} from 'sanity' 6 | 7 | import {urlForImage} from '../../sanity/sanity' 8 | import Container from '../Container' 9 | 10 | interface LogoType { 11 | _id: string 12 | name?: string 13 | logo?: { 14 | asset: SanityImageSource 15 | } 16 | } 17 | type PageBuilderLogosProps = KeyedObject & 18 | TypedObject & { 19 | logos?: LogoType[] 20 | } 21 | 22 | export default function PageBuilderLogos(props: PageBuilderLogosProps) { 23 | const {logos = []} = props 24 | 25 | if (!logos.length) { 26 | return null 27 | } 28 | 29 | return ( 30 | <div> 31 | <Container className="relative w-full py-5 lg:py-7"> 32 | <div className="mb-4 text-center text-gray-700 lg:mb-5 dark:text-gray-200"> 33 | Trusted by industry leaders 34 | </div> 35 | 36 | <div className="flex flex-wrap items-center justify-center gap-3 lg:gap-5"> 37 | {logos.map((company) => ( 38 | <Logo key={company?._id} company={company} /> 39 | ))} 40 | </div> 41 | </Container> 42 | </div> 43 | ) 44 | } 45 | 46 | function Logo({company}: {company: LogoType}) { 47 | const ref = delve(company, 'logo.asset._ref') 48 | 49 | if (!ref) { 50 | return null 51 | } 52 | 53 | // TODO: adjust width/height based on vertical/landscape logos 54 | const {width, height} = getImageDimensions(ref) 55 | 56 | return ( 57 | <div> 58 | {/* eslint-disable-next-line @next/next/no-img-element */} 59 | <img 60 | className="h-auto w-[50px] flex-shrink-0 lg:w-[100px]" 61 | // TODO: Adjust if the file is not an SVG 62 | src={urlForImage(company.logo).url()} 63 | alt={company?.name} 64 | width={width} 65 | height={height} 66 | /> 67 | </div> 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /components/PageBuilder/PortableText/StyledPortableText.tsx: -------------------------------------------------------------------------------- 1 | import {PortableText, PortableTextProps} from '@portabletext/react' 2 | import {PortableTextBlock, TypedObject} from '@portabletext/types' 3 | import * as React from 'react' 4 | 5 | export function StyledPortableText<B extends TypedObject = PortableTextBlock>({ 6 | value, 7 | }: PortableTextProps<B>) { 8 | return ( 9 | <div className="[&_strong]:text-magenta-500 [&_strong]:dark:text-magenta-400"> 10 | <PortableText value={value} /> 11 | </div> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/PageBuilder/Quote.tsx: -------------------------------------------------------------------------------- 1 | import {SanityImageSource} from '@sanity/image-url/lib/types/types' 2 | import Image from 'next/image' 3 | import React from 'react' 4 | import {KeyedObject, TypedObject} from 'sanity' 5 | 6 | import {urlForImage} from '../../sanity/sanity' 7 | import Container from '../Container' 8 | 9 | type QuoteProps = KeyedObject & 10 | TypedObject & { 11 | quote?: string 12 | person?: { 13 | name?: string 14 | title?: string 15 | picture?: SanityImageSource 16 | company?: { 17 | name?: string 18 | logo?: SanityImageSource 19 | } 20 | } 21 | } 22 | 23 | export default function PageBuilderQuote(props: QuoteProps) { 24 | const {quote, person} = props 25 | 26 | if (!person) { 27 | return null 28 | } 29 | 30 | return ( 31 | <div> 32 | <Container className="relative flex max-w-4xl flex-col-reverse p-4 lg:flex-row lg:items-center"> 33 | <div className="mb-5 mt-5 flex flex-row gap-5 md:px-5"> 34 | <div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border border-gray-200 dark:border-gray-800"> 35 | <span 36 | className="font-serif text-5xl" 37 | style={{transform: 'translate3d(3%, 20%, 0)'}} 38 | > 39 | ” 40 | </span> 41 | </div> 42 | 43 | <div className="flex flex-col gap-4"> 44 | <h2 className="text-2xl font-extrabold leading-tight tracking-tight lg:text-3xl"> 45 | {quote} 46 | </h2> 47 | 48 | <div className="h-25"> 49 | <div className="flex items-center gap-5"> 50 | {person?.picture ? ( 51 | <Image 52 | className="h-16 w-16 flex-shrink-0 rounded bg-gray-200" 53 | src={urlForImage(person.picture) 54 | .width(64) 55 | .height(64) 56 | .dpr(2) 57 | .auto('format') 58 | .url()} 59 | alt={person?.name} 60 | width={64} 61 | height={64} 62 | /> 63 | ) : null} 64 | <div> 65 | {person?.name ? ( 66 | <p className="text-xl font-extrabold">{person.name}</p> 67 | ) : null} 68 | <p className="text-gray-600 dark:text-gray-400"> 69 | {person.title} 70 | {person?.company?.name ? ( 71 | <> 72 | <br /> 73 | <em>{person.company.name}</em> 74 | </> 75 | ) : null} 76 | </p> 77 | </div> 78 | </div> 79 | </div> 80 | </div> 81 | </div> 82 | </Container> 83 | </div> 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /components/PageBuilder/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {KeyedObject, TypedObject} from 'sanity' 3 | 4 | import Container from '../Container' 5 | 6 | type PageBuilderProps = { 7 | rows: (KeyedObject & TypedObject)[] 8 | } 9 | 10 | const ROWS = { 11 | // Normal rows 12 | logos: React.lazy(() => import('./Logos')), 13 | quote: React.lazy(() => import('./Quote')), 14 | // Experiment block displays whichever hero was returned in the query 15 | experiment: React.lazy(() => import('./Article')), 16 | // Promotion component takes a grouped set of contiguous `promotion` rows 17 | article: React.lazy(() => import('./Article')), 18 | } 19 | 20 | export default function PageBuilder(props: PageBuilderProps) { 21 | const {rows} = props 22 | // We scoop all `feature` type blocks into a single block 23 | // This creates ✨magic✨ layout opportunities 24 | const rowsGrouped = React.useMemo( 25 | () => 26 | rows.reduce((acc, cur) => { 27 | const prev = acc[acc.length - 1] 28 | 29 | if (cur._type === 'infoBreak') { 30 | if (prev) { 31 | prev.breakAfter = true 32 | } 33 | 34 | // We don't want to render the info break 35 | return acc 36 | } 37 | 38 | // Not an experiment or an article? Just add it to the array 39 | if (![`experiment`, `article`].includes(cur._type)) { 40 | return [...acc, cur] 41 | } 42 | 43 | // Is this the first `article` _type in the array? Make it the hero! 44 | if ( 45 | (cur._type === `article` || cur._type === `experiment`) && 46 | !acc.find((a) => a.isHero) 47 | ) { 48 | return [ 49 | ...acc, 50 | {_key: cur._key, _type: cur._type, isHero: true, articles: [cur]}, 51 | ] 52 | } 53 | 54 | if ( 55 | // Start a new `articles` array 56 | !prev || 57 | // If the previous block was followed by a `infoBreak` block 58 | prev.breakAfter || 59 | // If the previous group was not a `article` group 60 | prev._type !== `article` || 61 | // Or if the previous `article` group is full 62 | prev.articles.length === 4 || 63 | // Or if the previous `article` group is a hero 64 | prev.isHero 65 | ) { 66 | return [ 67 | ...acc, 68 | { 69 | _key: cur._key, 70 | _type: cur._type, 71 | articles: [cur], 72 | }, 73 | ] 74 | } 75 | 76 | // Add to the existing `articles` array 77 | return [ 78 | ...acc.slice(0, -1), 79 | { 80 | ...prev, 81 | articles: [...prev.articles, cur], 82 | }, 83 | ] 84 | }, []), 85 | [rows] 86 | ) 87 | 88 | if (!rows?.length || !rowsGrouped.length) { 89 | return null 90 | } 91 | 92 | return ( 93 | <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> 94 | {rowsGrouped.map((row, rowIndex) => { 95 | if (row._type && ROWS[row._type]) { 96 | const Row = ROWS[row._type] 97 | return <Row key={row._key} index={rowIndex} {...row} /> 98 | } 99 | return ( 100 | <div key={row._key}> 101 | <Container className="py-5"> 102 | <p className="text-center text-red-500"> 103 | No component found for <code>{row._type}</code> 104 | </p> 105 | </Container> 106 | </div> 107 | ) 108 | })} 109 | </div> 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /components/PreviewPage.tsx: -------------------------------------------------------------------------------- 1 | import ErrorPage from 'next/error' 2 | import Head from 'next/head' 3 | import {useRouter} from 'next/router' 4 | import * as React from 'react' 5 | 6 | import {usePreview} from '../sanity/sanity' 7 | import {GlobalDataProps, PageProps, PageQueryParams} from '../types' 8 | import Layout from './Layout' 9 | import Loading from './Loading' 10 | import Page from './Page' 11 | 12 | interface Props { 13 | data: PageProps 14 | query: string | null 15 | queryParams: PageQueryParams 16 | globalData: GlobalDataProps 17 | } 18 | 19 | export default function PreviewPage(props: Props) { 20 | const {query, queryParams, globalData} = props 21 | const router = useRouter() 22 | 23 | const data = usePreview(null, query, queryParams) || props.data 24 | const {title = 'Marketing.'} = globalData?.settings || {} 25 | 26 | if (!router.isFallback && !data) { 27 | return <ErrorPage statusCode={404} /> 28 | } 29 | 30 | return ( 31 | <Layout preview queryParams={queryParams} globalData={globalData}> 32 | {router.isFallback ? ( 33 | <Loading /> 34 | ) : ( 35 | <> 36 | <Head> 37 | <title>{`${data.title} | ${title}`} 38 | 39 | 40 | 41 | )} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/Title.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default function Title({title}) { 4 | return ( 5 |

6 | {title} 7 |

8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /images/introTemplateImg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/images/introTemplateImg.png -------------------------------------------------------------------------------- /lib/config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | 3 | type Config = { 4 | env: string 5 | sanity: { 6 | projectId?: string 7 | projectTitle?: string 8 | dataset?: string 9 | // useCdn == true gives fast, cheap responses using a globally distributed cache. 10 | // When in production the Sanity API is only queried on build-time, and on-demand when responding to webhooks. 11 | // Thus the data need to be fresh and API response time is less important. 12 | // When in development/working locally, it's more important to keep costs down as hot reloading can incur a lot of API calls 13 | // And every page load calls getStaticProps. 14 | // To get the lowest latency, lowest cost, and latest data, use the Instant Preview mode 15 | useCdn?: boolean 16 | // see https://www.sanity.io/docs/api-versioning for how versioning works 17 | apiVersion: string 18 | } 19 | revalidateSecret?: string 20 | previewSecret?: string 21 | // Keeping these out of the sanity object so we don't inadvertently configure 22 | // clients with tokens by passing the whole object to a client constructor 23 | readToken?: string 24 | writeToken?: string 25 | } 26 | 27 | export const config: Config = { 28 | env: process.env.NODE_ENV || 'development', 29 | sanity: { 30 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 31 | projectTitle: process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE, 32 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 33 | useCdn: 34 | typeof document !== 'undefined' && process.env.NODE_ENV === 'production', 35 | apiVersion: '2022-08-08', 36 | }, 37 | readToken: process.env.SANITY_API_READ_TOKEN, 38 | writeToken: process.env.SANITY_API_WRITE_TOKEN, 39 | revalidateSecret: process.env.SANITY_REVALIDATE_SECRET, 40 | previewSecret: process.env.NEXT_PUBLIC_PREVIEW_SECRET, 41 | } 42 | -------------------------------------------------------------------------------- /lib/constants.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Building, 3 | ChevronRight, 4 | Cog, 5 | File, 6 | Home, 7 | Menu, 8 | Puzzle, 9 | Quote, 10 | User, 11 | } from 'lucide-react' 12 | 13 | import {markets, uniqueLanguages} from './markets' 14 | 15 | export const CMS_NAME = 'Sanity.io' 16 | export const CMS_URL = 'https://sanity.io/' 17 | 18 | export const SHOW_GLOBAL = true 19 | 20 | export type Language = { 21 | id: string 22 | title: string 23 | } 24 | 25 | export type Market = { 26 | name: string 27 | flag: string 28 | title: string 29 | languages: Language[] 30 | } 31 | 32 | export const MARKETS: Market[] = markets 33 | 34 | export const UNIQUE_LANGUAGES = uniqueLanguages 35 | 36 | export type SchemaItem = { 37 | kind: 'list' 38 | schemaType: string 39 | title: string 40 | icon: (props) => JSX.Element 41 | } 42 | 43 | export type SchemaSingleton = { 44 | kind: 'singleton' 45 | schemaType: string 46 | title: string 47 | icon: (props) => JSX.Element 48 | } 49 | 50 | export type SchemaDivider = { 51 | kind: 'divider' 52 | } 53 | 54 | // This studio uses helper function to loop over these objects 55 | // As they're used to dynamically generate per-market schema items 56 | // With the helper functions defined in lib/structure.tsx 57 | export const SCHEMA_ITEMS: (SchemaItem | SchemaSingleton | SchemaDivider)[] = [ 58 | {kind: 'singleton', schemaType: `page`, title: 'Home', icon: Home}, 59 | {kind: 'list', schemaType: `page`, title: 'Pages', icon: File}, 60 | {kind: 'divider'}, 61 | {kind: 'list', schemaType: `article`, title: 'Articles', icon: Puzzle}, 62 | {kind: 'divider'}, 63 | {kind: 'list', schemaType: `person`, title: 'People', icon: User}, 64 | {kind: 'list', schemaType: `company`, title: 'Companies', icon: Building}, 65 | {kind: 'list', schemaType: `quote`, title: 'Quotes', icon: Quote}, 66 | {kind: 'divider'}, 67 | {kind: 'singleton', schemaType: `settings`, title: 'Settings', icon: Cog}, 68 | {kind: 'singleton', schemaType: `menu`, title: 'Menus', icon: Menu}, 69 | { 70 | kind: 'list', 71 | schemaType: `redirect`, 72 | title: 'Redirects', 73 | icon: ChevronRight, 74 | }, 75 | ] 76 | -------------------------------------------------------------------------------- /lib/markets.js: -------------------------------------------------------------------------------- 1 | // In this format so ./next.config.js can consume them 2 | const markets = [ 3 | { 4 | flag: `🇺🇸`, 5 | name: `US`, 6 | title: `USA`, 7 | languages: [{id: `en`, title: `English`}], 8 | }, 9 | { 10 | flag: `🇨🇦`, 11 | name: `CA`, 12 | title: `Canada`, 13 | languages: [ 14 | {id: `en`, title: `English`}, 15 | {id: `fr`, title: `French`}, 16 | ], 17 | }, 18 | { 19 | flag: `🇬🇧`, 20 | name: `UK`, 21 | title: `United Kingdom`, 22 | languages: [{id: `en`, title: `English`}], 23 | }, 24 | { 25 | flag: `🇮🇳`, 26 | name: `IN`, 27 | title: `India`, 28 | languages: [ 29 | {id: `en`, title: `English`}, 30 | {id: `hi`, title: `Hindi`}, 31 | ], 32 | }, 33 | { 34 | flag: `🇯🇵`, 35 | name: `JP`, 36 | title: `Japan`, 37 | languages: [ 38 | {id: `jp`, title: `Japanese`}, 39 | {id: `en`, title: `English`}, 40 | ], 41 | }, 42 | ] 43 | 44 | exports.markets = markets 45 | 46 | exports.uniqueLanguages = Array.from( 47 | new Set( 48 | markets 49 | .map((market) => 50 | market.languages.map((language) => [language.id, market.name].join(`-`)) 51 | ) 52 | .flat() 53 | ) 54 | ) 55 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,jsx}': ['eslint'], 3 | '**/*.{ts,tsx}': ['eslint', () => 'tsc --noEmit'], 4 | } 5 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | /** @type {import('next').NextConfig} */ 3 | 4 | const {markets} = require('./lib/markets') 5 | 6 | function createLocalesFromSingleMarket(market) { 7 | return market.languages.map((language) => 8 | [language.id, market.name].join(`-`) 9 | ) 10 | } 11 | 12 | function createAllLocalesFromMarkets(fromMarkets) { 13 | return fromMarkets 14 | .map((market) => createLocalesFromSingleMarket(market)) 15 | .flat() 16 | } 17 | 18 | const domainBase = process.env.VERCEL ? process.env.VERCEL_URL : `localhost` 19 | 20 | const i18n = { 21 | localeDetection: false, 22 | locales: createAllLocalesFromMarkets(markets), 23 | defaultLocale: createAllLocalesFromMarkets(markets)[0], 24 | domains: markets.map((market) => ({ 25 | domain: 26 | // We run the app on localhost:80 (http://localhost) for development 27 | // Requiring a port number creates a redirect loop in the /studio route 28 | // It's an issue with Next.js + i18n Routing + Catch all routes 29 | market.name === `US` 30 | ? domainBase 31 | : `${market.name.toLowerCase()}.${domainBase}`, 32 | defaultLocale: createLocalesFromSingleMarket(market)[0], 33 | // Locales here have to be *globally* unique, so 34 | // these functions create ISO 639-1 like language-market pairs 35 | // For example: `en-CA` and `fr-CA` 36 | locales: createLocalesFromSingleMarket(market), 37 | http: process.env.NODE_ENV === `development`, 38 | })), 39 | } 40 | 41 | module.exports = { 42 | images: { 43 | remotePatterns: [ 44 | {hostname: 'cdn.sanity.io'}, 45 | {hostname: 'source.unsplash.com'}, 46 | {hostname: 'img.logoipsum.com'}, 47 | ], 48 | }, 49 | i18n, 50 | typescript: { 51 | // Set this to false if you want production builds to abort if there's type errors 52 | ignoreBuildErrors: process.env.VERCEL_ENV === 'production', 53 | }, 54 | eslint: { 55 | /// Set this to false if you want production builds to abort if there's lint errors 56 | ignoreDuringBuilds: process.env.VERCEL_ENV === 'production', 57 | }, 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "description": "Sanity.io starter template for Next.js", 4 | "homepage": "https://github.com/sanity-io/demo-marketing-site-nextjs#readme", 5 | "bugs": "https://github.com/sanity-io/demo-marketing-site-nextjs/issues", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/sanity-io/demo-marketing-site-nextjs.git" 9 | }, 10 | "license": "MIT", 11 | "author": "Sanity.io ", 12 | "scripts": { 13 | "build": "next build", 14 | "dev": "next dev -p 80", 15 | "format": "npx prettier --write . --ignore-path .gitignore", 16 | "lint": "eslint . --ext .js,.ts,.jsx,.tsx --ignore-path .gitignore", 17 | "lint:fix": "npm run format && npm run lint -- --fix", 18 | "lint:next": "next lint -- --ignore-path .gitignore", 19 | "prepare": "husky install", 20 | "start": "next start", 21 | "type-check": "tsc --noEmit" 22 | }, 23 | "dependencies": { 24 | "@portabletext/react": "^2.0.3", 25 | "@sanity/demo": "^1.0.2", 26 | "@sanity/document-internationalization": "^2.1.1", 27 | "@sanity/image-url": "^1.0.2", 28 | "@sanity/vision": "^3.36.4", 29 | "@tailwindcss/typography": "^0.5.12", 30 | "@vercel/og": "^0.0.27", 31 | "clsx": "^1.2.1", 32 | "dlv": "^1.1.3", 33 | "lucide-react": "^0.105.0", 34 | "next": "^13.5.6", 35 | "next-sanity": "^4.3.4", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "react-is": "^18.2.0", 39 | "react-twemoji": "^0.5.0", 40 | "rxjs": "^7.8.1", 41 | "sanity": "^3.36.4", 42 | "sanity-plugin-asset-source-unsplash": "^3.0.0", 43 | "sanity-plugin-documents-pane": "^2.2.1", 44 | "sanity-plugin-iframe-pane": "^3.1.6", 45 | "sanity-plugin-media": "^2.2.5", 46 | "styled-components": "^6.1.8", 47 | "usehooks-ts": "^2.16.0" 48 | }, 49 | "devDependencies": { 50 | "@commitlint/cli": "^17.8.1", 51 | "@commitlint/config-conventional": "^17.8.1", 52 | "@types/react": "^18.2.74", 53 | "@typescript-eslint/eslint-plugin": "^5.62.0", 54 | "autoprefixer": "^10.4.19", 55 | "eslint": "^8.57.0", 56 | "eslint-config-next": "^13.5.6", 57 | "eslint-config-prettier": "^8.10.0", 58 | "eslint-config-sanity": "^6.0.0", 59 | "eslint-plugin-react": "^7.34.1", 60 | "eslint-plugin-simple-import-sort": "^8.0.0", 61 | "husky": "^8.0.3", 62 | "lint-staged": "^13.3.0", 63 | "postcss": "^8.4.38", 64 | "prettier": "^2.8.8", 65 | "prettier-plugin-packagejson": "^2.4.14", 66 | "prettier-plugin-tailwindcss": "^0.2.8", 67 | "tailwindcss": "^3.4.3", 68 | "typescript": "^5.4.4" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | export default function Custom404() { 3 | return

404 - Page Not Found

4 | } 5 | -------------------------------------------------------------------------------- /pages/[slug].tsx: -------------------------------------------------------------------------------- 1 | import ErrorPage from 'next/error' 2 | import Head from 'next/head' 3 | import {useRouter} from 'next/router' 4 | import {PreviewSuspense} from 'next-sanity/preview' 5 | import {lazy} from 'react' 6 | import * as React from 'react' 7 | 8 | import Layout from '../components/Layout' 9 | import Loading from '../components/Loading' 10 | import Page from '../components/Page' 11 | import {config} from '../lib/config' 12 | import {globalDataQuery, pageQuery, pageSlugsQuery} from '../sanity/queries' 13 | import {getClient} from '../sanity/sanity.server' 14 | import {GlobalDataProps, PageProps, PageQueryParams} from '../types' 15 | import {getLanguageFromNextLocale, getMarketFromNextLocale} from '.' 16 | 17 | const PreviewPage = lazy(() => import('../components/PreviewPage')) 18 | 19 | interface Props { 20 | data: PageProps 21 | preview: boolean 22 | query: string | null 23 | queryParams: PageQueryParams 24 | globalData: GlobalDataProps 25 | } 26 | 27 | export default function Slug(props: Props) { 28 | const {data, preview, query, queryParams, globalData} = props 29 | const router = useRouter() 30 | 31 | if (preview) { 32 | return ( 33 | }> 34 | 40 | 41 | ) 42 | } 43 | 44 | const {title = 'Marketing.'} = globalData?.settings || {} 45 | 46 | if (!router.isFallback && !data) { 47 | return 48 | } 49 | 50 | return ( 51 | 52 | {router.isFallback ? ( 53 | 54 | ) : ( 55 | <> 56 | 57 | {`${data.title} | ${title}`} 58 | 59 | 60 | 61 | )} 62 | 63 | ) 64 | } 65 | 66 | export async function getStaticProps({ 67 | params, 68 | locale, 69 | preview = false, 70 | previewData, 71 | }) { 72 | // These query params are used to power this preview 73 | // And fed into to create ✨ DYNAMIC ✨ params! 74 | const queryParams: PageQueryParams = { 75 | // Necessary to query for the right page 76 | // And used by the preview route to redirect back to it 77 | slug: params.slug, 78 | // This demo uses a "market" field to separate documents 79 | // So that content does not leak between markets, we always include it in the query 80 | market: getMarketFromNextLocale(locale) ?? `US`, 81 | // Only markets with more than one language are likely to have a language field value 82 | language: getLanguageFromNextLocale(locale) ?? null, 83 | // In preview mode we can set the audience 84 | // In production this should be set in a session cookie 85 | audience: 86 | preview && previewData?.audience 87 | ? previewData?.audience 88 | : Math.round(Math.random()), 89 | // Some Page Builder blocks are set to display only on specific times 90 | // In preview mode, we can set this to preview the page as if it were a different time 91 | // By default, set `null` here and the query will use GROQ's cache-friendly `now()` function 92 | // Do not pass a dynamic value like `new Date()` as it will uniquely cache every request! 93 | date: preview && previewData?.date ? previewData.date : null, 94 | } 95 | 96 | const page = await getClient(preview).fetch(pageQuery, queryParams) 97 | const globalData = await getClient(preview).fetch(globalDataQuery, { 98 | settingsId: `${queryParams.market}-settings`.toLowerCase(), 99 | menuId: `${queryParams.market}-menu`.toLowerCase(), 100 | language: queryParams.language, 101 | }) 102 | 103 | return { 104 | props: { 105 | preview, 106 | data: page, 107 | query: preview ? pageQuery : null, 108 | queryParams: preview ? queryParams : null, 109 | globalData, 110 | }, 111 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals 112 | revalidate: config.revalidateSecret ? undefined : 60, 113 | } 114 | } 115 | 116 | export async function getStaticPaths() { 117 | // The context here only has access to ALL locales 118 | // Not the current one we're looking at 119 | // So sadly, we have to fetch all slugs for all locales 120 | const paths = await getClient(false).fetch(pageSlugsQuery) 121 | return { 122 | paths: paths.map((slug) => ({params: {slug}})), 123 | fallback: true, 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/index.css' 2 | 3 | import * as React from 'react' 4 | 5 | function MyApp({Component, pageProps}) { 6 | return 7 | } 8 | 9 | export default MyApp 10 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import {Head, Html, Main, NextScript} from 'next/document' 2 | import * as React from 'react' 3 | 4 | export default function Document() { 5 | return ( 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /pages/api/exit-preview.tsx: -------------------------------------------------------------------------------- 1 | import {NextApiResponse} from 'next' 2 | 3 | export default function exit(_, res: NextApiResponse) { 4 | // Exit the current user from "Preview Mode". This function accepts no args. 5 | res.clearPreviewData() 6 | 7 | // Redirect the user back to the index page. 8 | res.writeHead(307, {Location: '/'}) 9 | return res.end() 10 | } 11 | -------------------------------------------------------------------------------- /pages/api/og.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | import {ImageResponse} from '@vercel/og' 3 | import {NextRequest} from 'next/server' 4 | import * as React from 'react' 5 | 6 | import {urlForImage} from '../../sanity/sanity' 7 | 8 | export const config = {runtime: 'edge'} 9 | 10 | const WIDTH = 1200 11 | const HEIGHT = 630 12 | 13 | const CREDIT_CARD_WIDTH = 856 14 | const CREDIT_CARD_HEIGHT = 539.8 15 | 16 | // Make sure the font exists in the specified path: 17 | const font = fetch( 18 | new URL('../../assets/Inter-Bold.woff', import.meta.url) 19 | ).then((res) => res.arrayBuffer()) 20 | 21 | export default async function (req: NextRequest) { 22 | const fontData = await font 23 | 24 | const {searchParams} = new URL(req.url) 25 | 26 | const siteTitle = searchParams.get('siteTitle') 27 | const title = searchParams.get('title') 28 | const image = JSON.parse(searchParams.get('image')) 29 | const imageUrl = urlForImage(image) 30 | .width(WIDTH) 31 | .height(HEIGHT) 32 | .fit('crop') 33 | .url() 34 | 35 | return new ImageResponse( 36 | ( 37 |
48 | {/* eslint-disable-next-line @next/next/no-img-element */} 49 | 56 |
75 |
90 |
100 | {siteTitle} 101 |
102 |
111 |
120 | {title} 121 |
122 |
123 |
124 |
125 | ), 126 | { 127 | width: WIDTH, 128 | height: HEIGHT, 129 | fonts: [ 130 | { 131 | name: 'Inter', 132 | data: fontData, 133 | style: 'normal', 134 | weight: 700, 135 | }, 136 | ], 137 | } 138 | ) 139 | } 140 | -------------------------------------------------------------------------------- /pages/api/preview.tsx: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest, NextApiResponse} from 'next' 2 | 3 | import {previewBySlugQuery} from '../../sanity/queries' 4 | import {getClient} from '../../sanity/sanity.server' 5 | 6 | function redirectToPreview(res: NextApiResponse, Location: string, data = {}) { 7 | // Enable Preview Mode by setting the cookies 8 | res.setPreviewData(data) 9 | // Redirect to a preview capable route 10 | res.writeHead(307, {Location}) 11 | return res.end() 12 | } 13 | 14 | type PreviewData = { 15 | date?: string | null 16 | audience?: 0 | 1 17 | } 18 | 19 | // In this preview route we direct to a full-path URL 20 | // This is so market and language-specific routes work from a single endpoint 21 | export default async function preview( 22 | req: NextApiRequest, 23 | res: NextApiResponse 24 | ) { 25 | // Check the secret if it's provided, enables running preview mode locally before the env var is setup 26 | // Skip if preview is already enabled (TODO: check if this is okay) 27 | const secret = process.env.NEXT_PUBLIC_PREVIEW_SECRET 28 | if (!req.preview && secret && req.query.secret !== secret) { 29 | return res.status(401).json({message: 'Invalid secret'}) 30 | } 31 | 32 | // Get existing previewData values 33 | const existingPreviewData = req.previewData as PreviewData 34 | 35 | // Find any query params passed-in to setup or overwrite preview data 36 | const queryDate = Array.isArray(req.query.date) 37 | ? req.query.date[0] 38 | : req.query.date 39 | // I refactored this to pass linting, but I don't quite know what this is doing 40 | const queryAudience = ['string', 'boolean'].includes(typeof req.previewData) 41 | ? null 42 | : Number(req.query.audience) 43 | 44 | // Control some of the query parameters in Preview mode 45 | // These should typically be set by a cookie or session 46 | const previewData = { 47 | // Either use the date passed-in or reset to null 48 | date: req.query.date ? new Date(queryDate).toISOString() : null, 49 | // Use the existing audience or create a new one 50 | audience: [0, 1].includes(existingPreviewData.audience) 51 | ? existingPreviewData.audience 52 | : Math.round(Math.random()), 53 | } 54 | 55 | // Overwrite audience to whatever was passed-in as a query param, if valid 56 | if ([0, 1].includes(queryAudience)) { 57 | previewData.audience = Number(queryAudience) 58 | } 59 | 60 | // If no slug is provided open preview mode on the frontpage 61 | if (!req.query.slug) { 62 | return redirectToPreview(res, '/', previewData) 63 | } 64 | 65 | // Check if the post with the given `slug` exists 66 | const pageSlug = await getClient(true).fetch(previewBySlugQuery, { 67 | slug: req.query.slug, 68 | }) 69 | 70 | // If the slug doesn't exist prevent preview mode from being enabled 71 | if (!pageSlug) { 72 | return res.status(401).json({message: 'Invalid slug'}) 73 | } 74 | 75 | // Redirect to the path from the fetched post 76 | // We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities 77 | switch (req.query.type) { 78 | case 'page': 79 | return redirectToPreview(res, `/${pageSlug}`, previewData) 80 | case 'article': 81 | return redirectToPreview(res, `/articles/${pageSlug}`, previewData) 82 | default: 83 | return redirectToPreview(res, `/${pageSlug}`, previewData) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pages/api/revalidate.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This code is responsible for revalidating the cache when a post or author is updated. 3 | * 4 | * It is set up to receive a validated GROQ-powered Webhook from Sanity.io: 5 | * https://www.sanity.io/docs/webhooks 6 | * 7 | * You can quickly add the webhook configuration to your Sanity project with this URL: 8 | 9 | https://www.sanity.io/manage/webhooks/share?name=On-demand+Revalidation&description=Webhook+configuration+for+a+Next.js+blog+with+an+Incremental+Static+Revalidation+serverless+route+set+up.+Remember+to+update+the+URL+for+your+hosted+site%2C+as+well+as+a+secret+that+you+copy+to+the+environment+variables+where+your+Next.js+site+is+hosted+%28SANITY_REVALIDATE_SECRET%29.&url=https%3A%2F%2FYOUR_NEXTJS_SITE_URL%2Fapi%2Frevalidate&on=create&on=update&on=delete&filter=%5B%22post%22%2C+%22author%22%2C+%22settings%22%5D+in+_type&projection=&httpMethod=POST&apiVersion=v2021-03-25&includeDrafts=&headers=%7B%7D 10 | 11 | * MANUAL SETUP: 12 | * 1. Go to the API section of your Sanity project on sanity.io/manage 13 | * 2. Click "Create webhook" 14 | * 3. Set the URL to https://YOUR_NEXTJS_SITE_URL/api/revalidate 15 | * 4. Trigger on: "Create", "Update", and "Delete" 16 | * 5. Filter: ["post", "author", "settings"] in _type 17 | * 6. Projection: Leave empty 18 | * 7. HTTP method: POST 19 | * 8. API version: v2021-03-25 20 | * 9. Include drafts: No 21 | * 10. HTTP Headers: Leave empty 22 | * 11. Secret: Set to the same value as SANITY_REVALIDATE_SECRET (create a random one if you haven't) 23 | * 12. Save the cofiguration 24 | 25 | */ 26 | 27 | import type {NextApiRequest, NextApiResponse} from 'next' 28 | import {parseBody} from 'next-sanity/webhook' 29 | 30 | import {getClient} from '../../sanity/sanity.server' 31 | 32 | // Next.js will by default parse the body, which can lead to invalid signatures 33 | export const config = { 34 | api: { 35 | bodyParser: false, 36 | }, 37 | } 38 | 39 | const AUTHOR_UPDATED_QUERY = /* groq */ ` 40 | *[_type == "author" && _id == $id] { 41 | "slug": *[_type == "post" && references(^._id)].slug.current 42 | }["slug"][]` 43 | const POST_UPDATED_QUERY = /* groq */ `*[_type == "post" && _id == $id].slug.current` 44 | const SETTINGS_UPDATED_QUERY = /* groq */ `*[_type == "post"].slug.current` 45 | 46 | const getQueryForType = (type) => { 47 | switch (type) { 48 | case 'author': 49 | return AUTHOR_UPDATED_QUERY 50 | case 'post': 51 | return POST_UPDATED_QUERY 52 | case 'settings': 53 | return SETTINGS_UPDATED_QUERY 54 | default: 55 | throw new TypeError(`Unknown type: ${type}`) 56 | } 57 | } 58 | 59 | const log = (msg, error?) => 60 | // eslint-disable-next-line no-console 61 | console[error ? 'error' : 'log'](`[revalidate] ${msg}`) 62 | 63 | export default async function revalidate( 64 | req: NextApiRequest, 65 | res: NextApiResponse 66 | ) { 67 | const {body, isValidSignature} = await parseBody( 68 | req, 69 | process.env.SANITY_REVALIDATE_SECRET 70 | ) 71 | if (!isValidSignature) { 72 | const invalidSignature = 'Invalid signature' 73 | log(invalidSignature, true) 74 | return res.status(401).json({success: false, message: invalidSignature}) 75 | } 76 | 77 | const {_id: id, _type} = body 78 | if (typeof id !== 'string' || !id) { 79 | const invalidId = 'Invalid _id' 80 | log(invalidId, true) 81 | return res.status(400).json({message: invalidId}) 82 | } 83 | 84 | log(`Querying post slug for _id '${id}', type '${_type}' ..`) 85 | const slug = await getClient(false).fetch(getQueryForType(_type), {id}) 86 | const slugs = (Array.isArray(slug) ? slug : [slug]).map( 87 | (_slug) => `/posts/${_slug}` 88 | ) 89 | const staleRoutes = ['/', ...slugs] 90 | 91 | try { 92 | await Promise.all(staleRoutes.map((route) => res.revalidate(route))) 93 | const updatedRoutes = `Updated routes: ${staleRoutes.join(', ')}` 94 | log(updatedRoutes) 95 | return res.status(200).json({message: updatedRoutes}) 96 | } catch (err) { 97 | log(err.message, true) 98 | return res.status(500).json({message: err.message}) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pages/api/social-share.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | /* eslint-disable import/no-anonymous-default-export */ 3 | import {ImageResponse} from '@vercel/og' 4 | import {NextRequest} from 'next/server' 5 | import * as React from 'react' 6 | 7 | import {urlForImage} from '../../sanity/sanity' 8 | 9 | export const config = {runtime: 'edge'} 10 | 11 | const WIDTH = 1200 12 | const HEIGHT = 1200 13 | 14 | // Make sure the font exists in the specified path: 15 | const font700 = fetch( 16 | new URL('../../assets/Inter-Bold.woff', import.meta.url) 17 | ).then((res) => res.arrayBuffer()) 18 | const font700Italic = fetch( 19 | new URL('../../assets/Inter-BoldItalic.woff', import.meta.url) 20 | ).then((res) => res.arrayBuffer()) 21 | const font400 = fetch( 22 | new URL('../../assets/Inter-Regular.woff', import.meta.url) 23 | ).then((res) => res.arrayBuffer()) 24 | 25 | export default async function (req: NextRequest) { 26 | const fontData400 = await font400 27 | const fontData700 = await font700 28 | const fontData700Italic = await font700Italic 29 | 30 | const {searchParams} = new URL(req.url) 31 | 32 | const quote = searchParams.get('quote') 33 | const name = searchParams.get('name') 34 | const title = searchParams.get('title') 35 | const picture = searchParams.get('picture') 36 | const pictureUrl = urlForImage(JSON.parse(picture)) 37 | .width(300) 38 | .height(300) 39 | .fit('crop') 40 | .url() 41 | 42 | const companyName = searchParams.get('company.name') 43 | const logo = searchParams.get('logo') 44 | const siteTitle = searchParams.get('siteTitle') 45 | 46 | const logoUrl = urlForImage(JSON.parse(logo)).width(300).url() 47 | 48 | return new ImageResponse( 49 | ( 50 |
60 |
67 |
81 | {siteTitle} 82 |
83 |
95 | {quote} 96 |
97 |
103 | 112 |
122 |
131 | {name} 132 |
133 |
144 | {title}, 145 |
146 |
157 | {companyName} 158 |
159 |
160 |
161 |
162 | 170 |
171 |
172 |
173 | ), 174 | { 175 | width: WIDTH, 176 | height: HEIGHT, 177 | fonts: [ 178 | { 179 | name: 'Inter', 180 | data: fontData400, 181 | style: 'normal', 182 | weight: 400, 183 | }, 184 | { 185 | name: 'Inter', 186 | data: fontData700, 187 | style: 'normal', 188 | weight: 700, 189 | }, 190 | { 191 | name: 'Inter', 192 | data: fontData700Italic, 193 | style: 'italic', 194 | weight: 700, 195 | }, 196 | ], 197 | } 198 | ) 199 | } 200 | -------------------------------------------------------------------------------- /pages/articles/[slug].tsx: -------------------------------------------------------------------------------- 1 | import {PortableText} from '@portabletext/react' 2 | import ErrorPage from 'next/error' 3 | import Head from 'next/head' 4 | import Image from 'next/image' 5 | import {useRouter} from 'next/router' 6 | import * as React from 'react' 7 | 8 | import Container from '../../components/Container' 9 | import Layout from '../../components/Layout' 10 | import Loading from '../../components/Loading' 11 | import { 12 | articleQuery, 13 | articleSlugsQuery, 14 | globalDataQuery, 15 | } from '../../sanity/queries' 16 | import {urlForImage} from '../../sanity/sanity' 17 | import {getClient} from '../../sanity/sanity.server' 18 | import {GlobalDataProps, PageQueryParams} from '../../types' 19 | import {getLanguageFromNextLocale, getMarketFromNextLocale} from '../' 20 | 21 | interface Props { 22 | data: any 23 | preview: boolean 24 | queryParams: PageQueryParams 25 | globalData: GlobalDataProps 26 | } 27 | 28 | export default function Slug(props: Props) { 29 | const {data, preview, queryParams, globalData} = props 30 | const router = useRouter() 31 | 32 | const {title = 'Marketing.'} = globalData?.settings || {} 33 | 34 | if (!router.isFallback && !data) { 35 | return 36 | } 37 | 38 | return ( 39 | 40 | {router.isFallback ? ( 41 | 42 | ) : ( 43 | <> 44 | 45 | {`${data.title} | ${title}`} 46 | 47 |
48 | {data.image ? ( 49 |
50 |
51 | {data?.title 63 |
64 | ) : null} 65 |
66 | 67 |
68 | {data.subtitle ? ( 69 |

{data.subtitle}

70 | ) : null} 71 | {data.title ?

{data.title}

: null} 72 | 73 |
74 |
75 | 76 | )} 77 | 78 | ) 79 | } 80 | 81 | export async function getStaticProps({ 82 | params, 83 | locale, 84 | preview = false, 85 | previewData, 86 | }) { 87 | // These query params are used to power this preview 88 | // And fed into to create ✨ DYNAMIC ✨ params! 89 | const queryParams: PageQueryParams = { 90 | // Necessary to query for the right page 91 | // And used by the preview route to redirect back to it 92 | slug: params.slug, 93 | // This demo uses a "market" field to separate documents 94 | // So that content does not leak between markets, we always include it in the query 95 | market: getMarketFromNextLocale(locale) ?? `US`, 96 | // Only markets with more than one language are likely to have a language field value 97 | language: getLanguageFromNextLocale(locale) ?? null, 98 | // In preview mode we can set the audience 99 | // In production this should be set in a session cookie 100 | audience: 101 | preview && previewData?.audience 102 | ? previewData?.audience 103 | : Math.round(Math.random()), 104 | // Some Page Builder blocks are set to display only on specific times 105 | // In preview mode, we can set this to preview the page as if it were a different time 106 | // By default, set `null` here and the query will use GROQ's cache-friendly `now()` function 107 | // Do not pass a dynamic value like `new Date()` as it will uniquely cache every request! 108 | date: preview && previewData?.date ? previewData.date : null, 109 | } 110 | 111 | const article = await getClient(preview).fetch(articleQuery, queryParams) 112 | const globalData = await getClient(preview).fetch(globalDataQuery, { 113 | settingsId: `${queryParams.market}-settings`.toLowerCase(), 114 | menuId: `${queryParams.market}-menu`.toLowerCase(), 115 | language: queryParams.language, 116 | }) 117 | 118 | return { 119 | props: { 120 | preview, 121 | data: article, 122 | query: preview ? articleQuery : null, 123 | queryParams: preview ? queryParams : null, 124 | globalData, 125 | }, 126 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals 127 | revalidate: process.env.SANITY_REVALIDATE_SECRET ? undefined : 60, 128 | } 129 | } 130 | 131 | export async function getStaticPaths() { 132 | // The context here only has access to ALL locales 133 | // Not the current one we're looking at 134 | // So sadly, we have to fetch all slugs for all locales 135 | const paths = await getClient(false).fetch(articleSlugsQuery) 136 | return { 137 | paths: paths.map((slug) => ({params: {slug}})), 138 | fallback: true, 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import ErrorPage from 'next/error' 2 | import Head from 'next/head' 3 | import {useRouter} from 'next/router' 4 | import {PreviewSuspense} from 'next-sanity/preview' 5 | import {lazy} from 'react' 6 | import * as React from 'react' 7 | 8 | import Layout from '../components/Layout' 9 | import Loading from '../components/Loading' 10 | import Page from '../components/Page' 11 | import {config} from '../lib/config' 12 | import {globalDataQuery, homeQuery} from '../sanity/queries' 13 | import {getClient} from '../sanity/sanity.server' 14 | import {GlobalDataProps, PageProps, PageQueryParams} from '../types' 15 | 16 | const PreviewPage = lazy(() => import('../components/PreviewPage')) 17 | 18 | interface Props { 19 | data: PageProps 20 | preview: boolean 21 | query: string | null 22 | queryParams: PageQueryParams & {homeId: string} 23 | globalData: GlobalDataProps 24 | } 25 | 26 | export default function Home(props: Props) { 27 | const {data, preview, query, queryParams, globalData} = props 28 | const router = useRouter() 29 | 30 | if (preview) { 31 | return ( 32 | }> 33 | 39 | 40 | ) 41 | } 42 | 43 | const {title = 'Marketing.'} = globalData?.settings || {} 44 | 45 | if (!router.isFallback && !data) { 46 | return 47 | } 48 | 49 | return ( 50 | 51 | {router.isFallback ? ( 52 | 53 | ) : ( 54 | <> 55 | 56 | {`${data.title} | ${title}`} 57 | 58 | 59 | 60 | )} 61 | 62 | ) 63 | } 64 | 65 | // Takes `en-US` and returns `US` 66 | export function getMarketFromNextLocale(locale: string) { 67 | return locale.split(`-`).pop().toUpperCase() 68 | } 69 | 70 | // Takes `en-US` and returns `en` 71 | export function getLanguageFromNextLocale(locale: string) { 72 | return locale.split(`-`).shift() 73 | } 74 | 75 | export async function getStaticProps({locale, preview = false, previewData}) { 76 | /* check if the project id has been defined by fetching the vercel envs */ 77 | 78 | // TODO: Don't repeat this here and in [slug].tst 79 | if (config.sanity.projectId) { 80 | // These query params are used to power this preview 81 | // And fed into to create ✨ DYNAMIC ✨ params! 82 | const queryParams: PageQueryParams = { 83 | // Necessary to query for the right page 84 | // And used by the preview route to redirect back to it 85 | slug: ``, 86 | // This demo uses a "market" field to separate documents 87 | // So that content does not leak between markets, we always include it in the query 88 | market: getMarketFromNextLocale(locale) ?? `US`, 89 | // Only markets with more than one language are likely to have a language field value 90 | language: getLanguageFromNextLocale(locale) ?? null, 91 | // In preview mode we can set the audience 92 | // In production this should be set in a session cookie 93 | audience: 94 | preview && previewData?.audience 95 | ? previewData?.audience 96 | : Math.round(Math.random()), 97 | // Some Page Builder blocks are set to display only on specific times 98 | // In preview mode, we can set this to preview the page as if it were a different time 99 | // By default, set `null` here and the query will use GROQ's cache-friendly `now()` function 100 | // Do not pass a dynamic value like `new Date()` as it will uniquely cache every request! 101 | date: preview && previewData?.date ? previewData.date : null, 102 | } 103 | 104 | const homeQueryParams = { 105 | ...queryParams, 106 | homeId: `${queryParams.market}-page`.toLowerCase(), 107 | } 108 | const page = await getClient(preview).fetch(homeQuery, homeQueryParams) 109 | const globalData = await getClient(preview).fetch(globalDataQuery, { 110 | settingsId: `${queryParams.market}-settings`.toLowerCase(), 111 | menuId: `${queryParams.market}-menu`.toLowerCase(), 112 | language: queryParams.language, 113 | }) 114 | 115 | return { 116 | props: { 117 | preview, 118 | data: page, 119 | query: preview ? homeQuery : null, 120 | queryParams: preview ? homeQueryParams : null, 121 | globalData, 122 | }, 123 | // If webhooks isn't setup then attempt to re-generate in 1 minute intervals 124 | revalidate: config.revalidateSecret ? undefined : 60, 125 | } 126 | } 127 | 128 | /* when the client isn't set up */ 129 | return { 130 | props: {}, 131 | revalidate: undefined, 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /pages/studio/[[...index]].tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This route is responsible for the built-in authoring environment using Sanity Studio v3. 3 | * All routes under /studio will be handled by this file using Next.js' catch-all routes: 4 | * https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes 5 | * 6 | * You can learn more about the next-sanity package here: 7 | * https://github.com/sanity-io/next-sanity 8 | */ 9 | 10 | import Head from 'next/head' 11 | import {NextStudio} from 'next-sanity/studio' 12 | import {NextStudioHead} from 'next-sanity/studio/head' 13 | import React from 'react' 14 | 15 | import config from '../../sanity.config' 16 | 17 | // TODO: Re-route to root domain if accessed from subdomain 18 | // Not that it doesn't work, it's just likely to cause other bugs/confusion 19 | export default function StudioPage() { 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // If you want to use other PostCSS plugins, see the following: 2 | // https://tailwindcss.com/docs/using-with-preprocessors 3 | module.exports = { 4 | plugins: { 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #000000 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/demo-marketing-site-nextjs/43c868a80561114c8fba805645a4f14806471ffe/public/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /public/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Next.js", 3 | "short_name": "Next.js", 4 | "icons": [ 5 | { 6 | "src": "/favicons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/favicons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#000000", 17 | "background_color": "#000000", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /sanity.cli.ts: -------------------------------------------------------------------------------- 1 | import {loadEnvConfig} from '@next/env' 2 | import {defineCliConfig} from 'sanity/cli' 3 | 4 | const dev = process.env.NODE_ENV !== 'production' 5 | loadEnvConfig(__dirname, dev, {info: () => null, error: console.error}) 6 | 7 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 8 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET 9 | 10 | export default defineCliConfig({api: {projectId, dataset}}) 11 | -------------------------------------------------------------------------------- /sanity.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This config is used to set up Sanity Studio that's mounted on the `/pages/studio/[[...index]].tsx` route 3 | */ 4 | 5 | import {documentInternationalization} from '@sanity/document-internationalization' 6 | import {visionTool} from '@sanity/vision' 7 | import {defineConfig} from 'sanity' 8 | import {structureTool} from 'sanity/structure' 9 | import {unsplashImageAsset} from 'sanity-plugin-asset-source-unsplash' 10 | import {internationalizedArray} from 'sanity-plugin-internationalized-array' 11 | import {media} from 'sanity-plugin-media' 12 | 13 | import {MARKETS, SCHEMA_ITEMS} from './lib/constants' 14 | import {marketBadge} from './sanity/badges/market-badge' 15 | import CustomNavBar from './sanity/components/CustomNavBar' 16 | import Icon from './sanity/components/Icon' 17 | import {schemaTemplates} from './sanity/schemaTemplates' 18 | import {structure} from './sanity/structure' 19 | import {defaultDocumentNode} from './sanity/structure/defaultDocumentNode' 20 | import {schemaTypes} from './schemas' 21 | import settingsType from './schemas/documents/settings' 22 | 23 | const BASE_PATH = '/studio' 24 | 25 | const pluginsBase = (marketName?: string) => { 26 | const market = MARKETS.find((m) => m.name === marketName) 27 | 28 | // Shared plugins across all "market" configs 29 | const base = [ 30 | structureTool({ 31 | structure: (S, context) => structure(S, context, marketName), 32 | defaultDocumentNode, 33 | }), 34 | unsplashImageAsset(), 35 | visionTool({defaultApiVersion: '2022-08-08'}), 36 | media(), 37 | // Used for field-level translation in some schemas 38 | internationalizedArray({ 39 | languages: market ? market.languages : [], 40 | fieldTypes: ['string'], 41 | }), 42 | ] 43 | 44 | const i18nSchemaTypes = SCHEMA_ITEMS.map((item) => 45 | item.kind === 'list' ? item.schemaType : null 46 | ).filter(Boolean) 47 | 48 | if (market && market.languages.length > 1) { 49 | // Used for document-level translation on some schema types 50 | // If there is more than 1 language 51 | base.push( 52 | documentInternationalization({ 53 | supportedLanguages: market.languages, 54 | schemaTypes: i18nSchemaTypes, 55 | }) 56 | ) 57 | } else if (!market) { 58 | const uniqueLanguages = MARKETS.reduce((acc, cur) => { 59 | const newLanguages = cur.languages.filter( 60 | (lang) => !acc.find((a) => a.id === lang.id) 61 | ) 62 | 63 | return [...acc, ...newLanguages] 64 | }, []) 65 | 66 | base.push( 67 | documentInternationalization({ 68 | supportedLanguages: uniqueLanguages, 69 | schemaTypes: i18nSchemaTypes, 70 | }) 71 | ) 72 | } 73 | 74 | return base 75 | } 76 | 77 | // Shared config across all "market" configs 78 | // Some elements are overwritten in the market-specific configs 79 | const configBase = defineConfig({ 80 | basePath: `${BASE_PATH}/global`, 81 | name: 'global', 82 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 83 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 84 | title: process.env.NEXT_PUBLIC_SANITY_PROJECT_TITLE || 'Marketing.', 85 | icon: Icon, 86 | schema: { 87 | types: schemaTypes, 88 | templates: (prev) => schemaTemplates(prev), 89 | }, 90 | plugins: pluginsBase(), 91 | document: { 92 | badges: (prev) => [ 93 | ...prev, 94 | (props) => marketBadge(props, MARKETS, `market`), 95 | ], 96 | // Hide 'Settings' from new document options 97 | // https://user-images.githubusercontent.com/81981/195728798-e0c6cf7e-d442-4e58-af3a-8cd99d7fcc28.png 98 | newDocumentOptions: (prev, {creationContext}) => { 99 | if (creationContext.type === 'global') { 100 | return prev.filter( 101 | (templateItem) => templateItem.templateId !== settingsType.name 102 | ) 103 | } 104 | return prev 105 | }, 106 | }, 107 | studio: { 108 | components: { 109 | navbar: CustomNavBar, 110 | }, 111 | }, 112 | }) 113 | 114 | export default defineConfig([ 115 | ...MARKETS.map((market) => ({ 116 | ...configBase, 117 | basePath: `${BASE_PATH}/${market.name.toLowerCase()}`, 118 | name: market.name, 119 | title: [configBase.title, market.title].join(` `), 120 | plugins: pluginsBase(market.name), 121 | icon: () => Icon(market), 122 | })), 123 | configBase, 124 | ]) 125 | -------------------------------------------------------------------------------- /sanity/badges/market-badge.tsx: -------------------------------------------------------------------------------- 1 | import {DocumentBadgeDescription, DocumentBadgeProps} from 'sanity' 2 | 3 | import {Market} from '../../lib/constants' 4 | 5 | export function marketBadge( 6 | props: DocumentBadgeProps, 7 | supportedMarkets: Market[], 8 | marketField: string 9 | ): DocumentBadgeDescription | null { 10 | const source = props?.draft || props?.published 11 | const marketName = source?.[marketField] 12 | const market = supportedMarkets?.find((l) => l.name === marketName) 13 | 14 | if (!market) { 15 | return null 16 | } 17 | 18 | return { 19 | label: market.name, 20 | title: market.title, 21 | color: `primary`, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sanity/components/ArrayAutocompleteAddItem.tsx: -------------------------------------------------------------------------------- 1 | import {randomKey} from '@sanity/block-tools' 2 | import {AddIcon} from '@sanity/icons' 3 | import {Autocomplete, Box, Button, Grid, Stack} from '@sanity/ui' 4 | import React from 'react' 5 | import {ArrayOfObjectsInputProps, insert} from 'sanity' 6 | 7 | const renderOption = (option) => ( 8 | 9 |