├── .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 |
39 | ))}
40 |
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 |
33 |
34 | ))}
35 | -
36 |
37 |
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 |
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 |
11 |
12 | {props.children}
13 |
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 |
9 |
14 |
20 |
26 |
27 |
32 |
33 |
34 |
35 |
36 |
37 |
41 | {/* */}
42 |
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 |
13 |
14 | {translations.length > 1 ? (
15 |
16 | {translations
17 | .filter((i) => i)
18 | .map((translation) => {
19 | return (
20 | -
26 |
30 | {translation.title}{' '}
31 |
32 | ({translation.language.toUpperCase()})
33 |
34 |
35 |
36 | )
37 | })}
38 |
39 | ) : null}
40 |
41 | {content && content.length > 0 ? : null}
42 |
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
25 | }
26 |
27 | return
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 |
22 | {subtitle}
23 |
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 |
13 |
14 |
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 |
10 | {title}
11 |
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 |
22 |
23 |
24 | )
25 | }
26 |
27 | const {subtitle, title, summary = [], links = []} = article
28 |
29 | return (
30 |
31 |
32 |
33 | {image ? (
34 | <>
35 |
50 |
58 | {subtitle ? : null}
59 | {title ? : null}
60 |
61 | >
62 | ) : null}
63 | {!image && subtitle ?
: null}
64 | {!image && title ?
: null}
65 | {summary?.length > 0 ? (
66 |
72 |
73 |
74 | ) : null}
75 | {links?.length > 0 ? (
76 |
77 |
81 |
82 | ) : null}
83 |
84 |
85 |
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 =
22 | const cells = (
23 |
24 | {rest.map((article, articleIndex) => {
25 | const Component = isBentoNumberCallout(article)
26 | ? BentoNumberCallout
27 | : Small
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | )
35 | })}
36 |
37 | )
38 | return (
39 |
40 |
41 | {reverse ? high : cells}
42 | {reverse ? cells : high}
43 |
44 |
45 | )
46 | }
47 |
48 | function CellWrapper({
49 | articleIndex,
50 | children,
51 | }: PropsWithChildren<{articleIndex: number}>) {
52 | return (
53 | 0 && `border-t border-gray-200 dark:border-gray-800`
57 | )}
58 | >
59 | {children}
60 |
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 |
72 |
79 |
80 | ) : (
81 |
82 |
83 |
84 | {article?.summary?.length > 0 ? (
85 |
86 |
87 |
88 | ) : null}
89 |
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 |
99 |
100 |
101 | {first?.image ? (
102 |
108 |
115 |
116 | ) : null}
117 |
118 | {hasText ? (
119 |
120 |
121 |
122 |
123 | {first?.summary?.length > 0 ? (
124 |
125 |
126 |
127 | ) : null}
128 |
129 | ) : null}
130 |
131 |
132 |
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 =
19 | const cells = (
20 |
21 | {rest.map((article, articleIndex) => {
22 | const Component = isBentoNumberCallout(article)
23 | ? BentoNumberCallout
24 | : Small
25 | return (
26 |
27 |
28 |
29 | )
30 | })}
31 |
32 | )
33 | return (
34 |
35 | {even ? cells : high}
36 |
37 | {even ? high : cells}
38 |
39 |
40 | )
41 | }
42 |
43 | function CellWrapper({
44 | articleIndex,
45 | children,
46 | }: PropsWithChildren<{articleIndex: number}>) {
47 | return (
48 | 0 &&
52 | `border-gray-200 sm:max-lg:border-t lg:border-l dark:border-gray-800`
53 | )}
54 | >
55 | {children}
56 |
57 | )
58 | }
59 |
60 | function Wide({first}: {first: ArticleStub}) {
61 | return
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 |
19 |
20 | {articles.map((article, articleIndex) => {
21 | const Component = isBentoNumberCallout(article)
22 | ? BentoNumberCallout
23 | : ArticleEven
24 | return (
25 |
30 |
31 |
32 | )
33 | })}
34 |
35 |
36 | )
37 | }
38 |
39 | function CellWrapper({
40 | articleIndex,
41 | articles,
42 | children,
43 | }: PropsWithChildren<{articleIndex: number; articles: ArticleStub[]}>) {
44 | return (
45 | 1 ? 'lg:border-t' : 'lg:border-t-0'
54 | )}
55 | >
56 |
{children}
57 |
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 |
67 | {hasText ? (
68 |
69 | {article?.icon ? (
70 |
71 |
72 |
73 | ) : null}
74 |
75 |
76 | {article.title}
77 |
78 |
79 | {article.subtitle}
80 |
81 |
82 |
83 | ) : null}
84 | {article.image && !hasText ? (
85 |
103 | ) : null}
104 |
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 |
22 |
23 |
24 |
25 | {article.title}
26 |
27 |
28 |
29 |
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
21 | } else if (articles.length === 2 || articles.length === 4) {
22 | return
23 | } else if (articles.length === 3) {
24 | return index % 2 === 0 ? (
25 |
26 | ) : (
27 |
28 | )
29 | }
30 |
31 | return (
32 |
33 | {articles.map((article) => (
34 |
35 | {article?.icon ? (
36 |
37 |
38 |
39 | ) : null}
40 |
41 | {article.title}
42 |
43 |
44 | {article.subtitle}
45 |
46 |
47 | ))}
48 |
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 |
15 | {subtitle ? : null}
16 | {title ? : null}
17 | {summary?.length > 0 ? : null}
18 | {links?.length > 0 ? : null}
19 |
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 |
17 |
18 |
19 |
20 | {subtitle ? : null}
21 | {title ? : null}
22 | {summary?.length > 0 ? : null}
23 | {links?.length > 0 ? : null}
24 |
25 |
26 | {image ? (
27 |
28 |
35 |
36 | ) : null}
37 |
38 |
39 |
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 ? :
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 |
10 | {subtitle}
11 |
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 |
13 |
14 |
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 |
10 | {title}
11 |
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 |
31 |
32 |
33 | Trusted by industry leaders
34 |
35 |
36 |
37 | {logos.map((company) => (
38 |
39 | ))}
40 |
41 |
42 |
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 |
58 | {/* eslint-disable-next-line @next/next/no-img-element */}
59 |
.url()})
67 |
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({
6 | value,
7 | }: PortableTextProps) {
8 | return (
9 |
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 |
32 |
33 |
34 |
35 |
39 | ”
40 |
41 |
42 |
43 |
44 |
45 | {quote}
46 |
47 |
48 |
49 |
50 | {person?.picture ? (
51 |
63 | ) : null}
64 |
65 | {person?.name ? (
66 |
{person.name}
67 | ) : null}
68 |
69 | {person.title}
70 | {person?.company?.name ? (
71 | <>
72 |
73 | {person.company.name}
74 | >
75 | ) : null}
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
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 |
94 | {rowsGrouped.map((row, rowIndex) => {
95 | if (row._type && ROWS[row._type]) {
96 | const Row = ROWS[row._type]
97 | return
|
98 | }
99 | return (
100 |
101 |
102 |
103 | No component found for {row._type}
104 |
105 |
106 |
107 | )
108 | })}
109 |
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
28 | }
29 |
30 | return (
31 |
32 | {router.isFallback ? (
33 |
34 | ) : (
35 | <>
36 |
37 | {`${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 |
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 |
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 |
14 |
15 | )
16 |
17 | const filterOption = (query: string, option: any) =>
18 | option.payload.name.toLowerCase().indexOf(query.toLowerCase()) > -1
19 |
20 | const renderValue = (value: string, option: any) =>
21 | option?.payload.name || value
22 |
23 | export default function ArrayAutocompleteAddItem(
24 | props: ArrayOfObjectsInputProps
25 | ) {
26 | const {onChange} = props
27 |
28 | const options = React.useMemo(
29 | () =>
30 | props.schemaType.of.map((item) => ({value: item.name, payload: item})),
31 | [props.schemaType]
32 | )
33 |
34 | const handleSelect = React.useCallback(
35 | (typeName: string) => {
36 | const newValue = {
37 | _key: randomKey(12),
38 | _type: typeName,
39 | }
40 |
41 | onChange(insert([newValue], 'after', props.path))
42 | },
43 | [onChange, props.path]
44 | )
45 |
46 | return (
47 |
48 | {props.renderDefault(props)}
49 |
50 |
62 |
63 |
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/sanity/components/CustomNavBar.tsx:
--------------------------------------------------------------------------------
1 | import {HomeIcon} from '@sanity/icons'
2 | import {Box, Button, Card, Flex} from '@sanity/ui'
3 | import Link from 'next/link'
4 | import * as React from 'react'
5 | import type {NavbarProps} from 'sanity'
6 |
7 | export default function CustomNavBar(props: NavbarProps) {
8 | return (
9 |
10 | {props.renderDefault(props)}
11 |
12 |
13 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/sanity/components/Icon.tsx:
--------------------------------------------------------------------------------
1 | import {Card, Flex} from '@sanity/ui'
2 | import React from 'react'
3 | import Twemoji from 'react-twemoji'
4 |
5 | import {Market} from '../../lib/constants'
6 |
7 | export default function Icon(props: Market) {
8 | return (
9 |
15 |
16 | {props.flag ?? `🌐`}
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/sanity/components/IconSelector.tsx:
--------------------------------------------------------------------------------
1 | import {Icon, icons, IconSymbol} from '@sanity/icons'
2 | import {Autocomplete, Box, Flex, Label, Text} from '@sanity/ui'
3 | import {useCallback} from 'react'
4 | import * as React from 'react'
5 | import {set, StringInputProps, unset} from 'sanity'
6 |
7 | const options = Object.keys(icons)
8 | .filter((key) => key !== 'unknown')
9 | .map((key) => ({value: key}))
10 |
11 | const renderOption = (props) => (
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 |
20 | export default function IconSelector(props: StringInputProps) {
21 | const {onChange, readOnly, value} = props
22 |
23 | const handleChange = useCallback(
24 | (newValue) => {
25 | onChange(newValue ? set(newValue) : unset())
26 | },
27 | [onChange]
28 | )
29 |
30 | return (
31 |
32 |
33 |
41 | ) : (
42 |
43 | )
44 | }
45 | value={value}
46 | readOnly={readOnly}
47 | onChange={handleChange}
48 | renderOption={renderOption}
49 | />
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/sanity/components/OGPreview.tsx:
--------------------------------------------------------------------------------
1 | import {CopyIcon} from '@sanity/icons'
2 | import {Button, Card, Flex, Stack, Text} from '@sanity/ui'
3 | import React from 'react'
4 | import {useCopyToClipboard} from 'usehooks-ts'
5 |
6 | export default function OGPreview(props) {
7 | const document = props.document.displayed
8 |
9 | const imageUrl = React.useMemo(
10 | () => props.options.url(document),
11 | [document, props.options]
12 | )
13 |
14 | const [, setCopiedText] = useCopyToClipboard()
15 |
16 | const handleCopy = React.useCallback(() => {
17 | setCopiedText(imageUrl)
18 | }, [imageUrl, setCopiedText])
19 |
20 | return (
21 |
22 |
23 |
24 | {/* eslint-disable-next-line @next/next/no-img-element */}
25 |
26 |
27 |
28 | {document?.seo?.title ?? document?.title}
29 |
30 | {document?.seo?.description ? (
31 | {document?.seo?.description}
32 | ) : null}
33 |
34 |
35 |
36 |
37 |
43 |
44 | {imageUrl}
45 |
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/sanity/components/SocialSharePreview.tsx:
--------------------------------------------------------------------------------
1 | import {CopyIcon} from '@sanity/icons'
2 | import {Button, Card, Flex, Stack} from '@sanity/ui'
3 | import React, {useEffect} from 'react'
4 | import {useCopyToClipboard} from 'usehooks-ts'
5 |
6 | export default function OGPreview(props) {
7 | const document = props.document.displayed
8 | const [imageUrl, setImageUrl] = React.useState(null)
9 | useEffect(() => {
10 | async function fetchImageUrl() {
11 | const result = await props.options.url(document)
12 | setImageUrl(result)
13 | }
14 | if (document.quote) {
15 | fetchImageUrl()
16 | }
17 | }, [document, imageUrl, props.options])
18 |
19 | const [, setCopiedText] = useCopyToClipboard()
20 |
21 | const handleCopy = React.useCallback(() => {
22 | setCopiedText(imageUrl)
23 | }, [imageUrl, setCopiedText])
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | {imageUrl ? (
31 | // eslint-disable-next-line @next/next/no-img-element
32 |
33 | ) : null}
34 |
35 |
36 |
37 |
38 |
45 |
46 | {imageUrl ? {imageUrl}
: null}
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/sanity/config.tsx:
--------------------------------------------------------------------------------
1 | export const sanityConfig = {
2 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
3 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, // `i1wffqi8`
4 | useCdn:
5 | typeof document !== 'undefined' && process.env.NODE_ENV === 'production',
6 | // useCdn == true gives fast, cheap responses using a globally distributed cache.
7 | // When in production the Sanity API is only queried on build-time, and on-demand when responding to webhooks.
8 | // Thus the data need to be fresh and API response time is less important.
9 | // When in development/working locally, it's more important to keep costs down as hot reloading can incur a lot of API calls
10 | // And every page load calls getStaticProps.
11 | // To get the lowest latency, lowest cost, and latest data, use the Instant Preview mode
12 | apiVersion: '2022-08-08',
13 | // see https://www.sanity.io/docs/api-versioning for how versioning works
14 | }
15 |
--------------------------------------------------------------------------------
/sanity/queries.tsx:
--------------------------------------------------------------------------------
1 | import groq from 'groq'
2 |
3 | export const globalDataQuery = groq`{
4 | "settings": *[_id == $settingsId][0]{
5 | // Get the first item if no language was specified
6 | "title": select($language == null => title[0].value, title[_key == $language][0].value),
7 | },
8 | "menus": *[_id == $menuId][0]{
9 | "headerPrimary": headerPrimary[(defined(link.text) && defined(link.url)) || defined(link.reference)]{
10 | _key,
11 | "link": link{
12 | url,
13 | text,
14 | reference->{
15 | title,
16 | "slug": slug.current
17 | }
18 | },
19 | children
20 | }
21 | }
22 | }`
23 |
24 | const pageFields = groq`
25 | title,
26 | "slug": slug.current,
27 | market,
28 | content[]{
29 | _key,
30 | _type,
31 | _type == "article" => {
32 | ...(@->{
33 | icon,
34 | title,
35 | subtitle,
36 | image,
37 | summary,
38 | links[]{
39 | _key,
40 | url,
41 | text,
42 | reference->{
43 | title,
44 | "slug": slug.current
45 | }
46 | },
47 | visibility
48 | })
49 | },
50 | _type == "quote" => {
51 | ...(@->{
52 | quote,
53 | person->{
54 | name,
55 | title,
56 | picture,
57 | company->{
58 | name,
59 | logo
60 | }
61 | },
62 | visibility
63 | })
64 | },
65 | _type == "experiment" => {
66 | ...(experiments[$audience]->{
67 | title,
68 | subtitle,
69 | image,
70 | summary,
71 | links[]{
72 | _key,
73 | url,
74 | text,
75 | reference->{
76 | title,
77 | "slug": slug.current
78 | }
79 | },
80 | visibility
81 | })
82 | },
83 | _type == "logos" => {
84 | "logos": select(
85 | count(logos) > 0 => logos[]->,
86 | *[_type == "company" && market == $market]{
87 | _id,
88 | name,
89 | logo
90 | }
91 | )
92 | }
93 | }[
94 | // Filter out elements where "visibility" is not valid:
95 | // Neither start or end dates are set
96 | (!defined(visibility.displayTo) && !defined(visibility.displayFrom))
97 | // Only the end is set, check if it has expired
98 | || (!defined(visibility.displayTo) && dateTime(visibility.displayFrom) < dateTime(coalesce($date, now())))
99 | // Only the start is set, check if it has begun
100 | || (dateTime(visibility.displayTo) > dateTime(coalesce($date, now())) && !defined(visibility.displayFrom))
101 | // Both end and start are set, check if the current time is between them
102 | || (dateTime(coalesce($date, now())) in dateTime(visibility.displayFrom) .. dateTime(visibility.displayTo))
103 | || (
104 | dateTime(visibility.displayTo) > dateTime(coalesce($date, now()))
105 | && dateTime(visibility.displayFrom) < dateTime(coalesce($date, now()))
106 | )
107 | ],
108 | "translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
109 | title,
110 | "slug": slug.current,
111 | language
112 | },
113 | `
114 |
115 | export const homeQuery = groq`
116 | *[_id == $homeId]|order(_updatedAt desc)[0]{
117 | ${pageFields}
118 | }`
119 |
120 | export const pageQuery = groq`
121 | *[_type == "page" && slug.current == $slug && upper(market) == upper($market)] | order(_updatedAt desc) [0] {
122 | ${pageFields}
123 | }`
124 |
125 | export const articleQuery = groq`
126 | *[_type == "article" && slug.current == $slug && upper(market) == upper($market)] | order(_updatedAt desc) [0]`
127 |
128 | export const pageSlugsQuery = groq`
129 | *[_type == "page" && defined(slug.current) && defined(market)].slug.current
130 | `
131 |
132 | export const articleSlugsQuery = groq`
133 | *[_type == "article" && defined(slug.current) && defined(market)].slug.current
134 | `
135 |
136 | export const previewBySlugQuery = groq`
137 | *[_type in ["page", "article"] && slug.current == $slug][0].slug.current
138 | `
139 |
--------------------------------------------------------------------------------
/sanity/sanity.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Server-side Sanity utilities. By having these in a separate file from the
3 | * utilities we use on the client side, we are able to tree-shake (remove)
4 | * code that is not used on the client side.
5 | */
6 | import {createClient} from 'next-sanity'
7 |
8 | import {sanityConfig} from './config'
9 |
10 | export const getClient = (preview) =>
11 | preview
12 | ? createClient({
13 | projectId: sanityConfig.projectId,
14 | dataset: sanityConfig.dataset,
15 | useCdn: false,
16 | apiVersion: sanityConfig.apiVersion,
17 | // Fallback to using the WRITE token until https://www.sanity.io/docs/vercel-integration starts shipping a READ token.
18 | // As this client only exists on the server and the token is never shared with the browser, we don't risk escalating permissions to untrustworthy users
19 | token:
20 | process.env.SANITY_API_READ_TOKEN ||
21 | process.env.SANITY_API_WRITE_TOKEN,
22 | })
23 | : createClient({
24 | projectId: sanityConfig.projectId,
25 | dataset: sanityConfig.dataset,
26 | apiVersion: sanityConfig.apiVersion,
27 | useCdn: true,
28 | })
29 |
30 | export function overlayDrafts(docs) {
31 | const documents = docs || []
32 | const overlayed = documents.reduce((map, doc) => {
33 | if (!doc._id) {
34 | throw new Error('Ensure that `_id` is included in query projection')
35 | }
36 |
37 | const isDraft = doc._id.startsWith('drafts.')
38 | const id = isDraft ? doc._id.slice(7) : doc._id
39 | return isDraft || !map.has(id) ? map.set(id, doc) : map
40 | }, new Map())
41 |
42 | return Array.from(overlayed.values())
43 | }
44 |
--------------------------------------------------------------------------------
/sanity/sanity.tsx:
--------------------------------------------------------------------------------
1 | import createImageUrlBuilder from '@sanity/image-url'
2 | import {definePreview} from 'next-sanity/preview'
3 |
4 | import {sanityConfig} from './config'
5 |
6 | const {projectId, dataset} = sanityConfig
7 |
8 | export const imageBuilder = createImageUrlBuilder({projectId, dataset})
9 |
10 | export const urlForImage = (source) =>
11 | imageBuilder
12 | .image({
13 | /* while uploading images, in preview mode, this field is missing*/
14 | asset: {_ref: 'image-dummy-1x1-jpg'},
15 | ...source,
16 | })
17 | .auto('format')
18 | .fit('max')
19 |
20 | export const usePreview = definePreview({projectId, dataset})
21 |
--------------------------------------------------------------------------------
/sanity/schemaTemplates.tsx:
--------------------------------------------------------------------------------
1 | import {SCHEMA_ITEMS, SchemaItem} from '../lib/constants'
2 |
3 | const onlySchemaItems = SCHEMA_ITEMS.filter((item) => item.kind === 'list')
4 |
5 | export const schemaTemplates = (prev) => [
6 | ...prev,
7 | ...onlySchemaItems.map((schemaItem: SchemaItem) => ({
8 | id: [schemaItem.schemaType, `market`].join(`-`),
9 | title: `${schemaItem.title} with Market`,
10 | type: 'initialValueTemplateItem',
11 | schemaType: schemaItem.schemaType,
12 | parameters: [{name: `market`, title: `Market`, type: `string`}],
13 | value: ({market}) => ({market}),
14 | })),
15 | ]
16 |
--------------------------------------------------------------------------------
/sanity/structure/defaultDocumentNode.ts:
--------------------------------------------------------------------------------
1 | import {DefaultDocumentNodeResolver} from 'sanity/structure'
2 | import DocumentsPane from 'sanity-plugin-documents-pane'
3 | import {Iframe,IframeOptions} from 'sanity-plugin-iframe-pane'
4 |
5 | import OGPreview from '../components/OGPreview'
6 | import SocialSharePreview from '../components/SocialSharePreview'
7 | import {getOgUrl} from './getOgUrl'
8 | import {getPreviewUrl} from './getPreviewUrl'
9 | import {getSocialShareUrl} from './getSocialShareUrl'
10 |
11 | const pagesUsed = (S) =>
12 | S.view
13 | .component(DocumentsPane)
14 | .options({
15 | query: `*[!(_id in path("drafts.**")) && _type == "page" && references($id)]`,
16 | params: {id: `_id`},
17 | })
18 | .title('Pages Used')
19 |
20 | // `defaultDocumentNode is responsible for adding a “Preview” tab to the document pane
21 | // You can add any React component to `S.view.component` and it will be rendered in the pane
22 | // and have access to content in the form in real-time.
23 | // It's part of the Studio's “Structure Builder API” and is documented here:
24 | // https://www.sanity.io/docs/structure-builder-reference
25 | export const defaultDocumentNode: DefaultDocumentNodeResolver = (
26 | S,
27 | {schemaType, getClient}
28 | ) => {
29 | const client = getClient({apiVersion: `2022-11-24`})
30 |
31 | switch (schemaType) {
32 | case `page`:
33 | return S.document().views([
34 | S.view.form(),
35 | S.view
36 | .component(Iframe)
37 | .options({
38 | url: (doc) => getPreviewUrl(doc),
39 | reload: {button: true},
40 | } satisfies IframeOptions)
41 | .title('Preview'),
42 | S.view
43 | .component(OGPreview)
44 | .options({
45 | url: (doc) => getOgUrl(doc),
46 | })
47 | .title('Open Graph'),
48 | ])
49 | case `quote`:
50 | return S.document().views([
51 | S.view.form(),
52 | pagesUsed(S),
53 | S.view
54 | .component(SocialSharePreview)
55 | .options({
56 | url: (doc) => getSocialShareUrl(doc, client),
57 | })
58 | .title('Social Share'),
59 | ])
60 | case `article`:
61 | return S.document().views([
62 | S.view.form(),
63 | pagesUsed(S),
64 | S.view
65 | .component(Iframe)
66 | .options({
67 | url: (doc) => getPreviewUrl(doc),
68 | reload: {button: true},
69 | })
70 | .title('Preview'),
71 | ])
72 | default:
73 | return S.document().views([S.view.form()])
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/sanity/structure/getOgUrl.ts:
--------------------------------------------------------------------------------
1 | import {SanityDocument} from '@sanity/client'
2 |
3 | export function getOgUrl(document: SanityDocument): string {
4 | const url = new URL('/api/og', location.origin)
5 | url.searchParams.set('title', document?.seo?.title ?? document?.title)
6 | url.searchParams.set('image', JSON.stringify(document?.seo?.image))
7 | // TODO: Get from 'settings' document
8 | url.searchParams.set('siteTitle', `Enigma`)
9 |
10 | return url.toString()
11 | }
12 |
--------------------------------------------------------------------------------
/sanity/structure/getPreviewUrl.ts:
--------------------------------------------------------------------------------
1 | import {SanityDocument, Slug} from 'sanity'
2 |
3 | import {config} from '../../lib/config'
4 | import articleType from '../../schemas/documents/article'
5 | import pageType from '../../schemas/documents/page'
6 |
7 | type PreviewDocument = SanityDocument & {
8 | market?: string
9 | slug?: Slug
10 | }
11 |
12 | export function getPreviewUrl(document: PreviewDocument): string {
13 | // Ensure URL origin matches the document's market
14 | // We need to load the Preview API route
15 | // In the same subdomain as the document's market
16 | // So that the cookie is set on the right domain
17 | const market = document?.market?.toLowerCase()
18 |
19 | if (!market) {
20 | return ``
21 | }
22 |
23 | // Market is the US = must use root domain
24 | // Market is not the US = must use market subdomain
25 | // TODO: Fix in Production on Vercel's URL
26 | // const rootLocation = window.location.host.split(`.`).pop()
27 | const rootLocation = window.location.host
28 | const marketOrigin =
29 | market.toUpperCase() === 'US'
30 | ? rootLocation
31 | : `${market.toLowerCase()}.${rootLocation}`
32 |
33 | const url = new URL(
34 | '/api/preview',
35 | `${window.location.protocol}//${marketOrigin}`
36 | )
37 | const secret = config.previewSecret
38 | if (secret) {
39 | url.searchParams.set('secret', secret)
40 | }
41 |
42 | url.searchParams.set('type', document._type)
43 |
44 | try {
45 | switch (document._type) {
46 | case pageType.name:
47 | url.searchParams.set('slug', document.slug.current!)
48 | break
49 | case articleType.name:
50 | url.searchParams.set('slug', document.slug.current!)
51 | break
52 | default:
53 | return ``
54 | }
55 |
56 | return url.toString()
57 | } catch {
58 | return ``
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/sanity/structure/getSocialShareUrl.ts:
--------------------------------------------------------------------------------
1 | import {SanityReference} from '@sanity/image-url/lib/types/types'
2 | import {SanityClient, SanityDocument} from 'sanity'
3 |
4 | type Document = SanityDocument & {
5 | quote: string
6 | person: SanityReference
7 | }
8 | export async function getSocialShareUrl(
9 | document: Document,
10 | client: SanityClient
11 | ): Promise {
12 | const url = new URL('/api/social-share', location.origin)
13 |
14 | const result = await client.fetch(
15 | `*[_type == "person" && _id == $id][0]{
16 | name,
17 | title,
18 | picture,
19 | company->{
20 | name,
21 | logo
22 | },
23 | "siteTitle": *[_type == "settings" && market == ^.market][0]{
24 | "title": title[0].value
25 | }.title
26 | }`,
27 | {
28 | id: document?.person?._ref ?? ``,
29 | market: document?.market ?? ``,
30 | }
31 | )
32 |
33 | url.searchParams.set('quote', document.quote)
34 |
35 | if (!result) {
36 | return url.toString()
37 | }
38 |
39 | url.searchParams.set('name', result.name)
40 | url.searchParams.set('title', result.title)
41 | url.searchParams.set('picture', JSON.stringify(result.picture))
42 | url.searchParams.set('company.name', result.company.name)
43 | url.searchParams.set('logo', JSON.stringify(result.company.logo))
44 | url.searchParams.set('siteTitle', result.siteTitle)
45 |
46 | return url.toString()
47 | }
48 |
--------------------------------------------------------------------------------
/sanity/structure/index.tsx:
--------------------------------------------------------------------------------
1 | import {ConfigContext} from 'sanity'
2 | import {StructureBuilder} from 'sanity/structure'
3 |
4 | import {
5 | Language,
6 | Market,
7 | MARKETS,
8 | SCHEMA_ITEMS,
9 | SchemaDivider,
10 | SchemaItem,
11 | SchemaSingleton,
12 | } from '../../lib/constants'
13 | import Icon from '../components/Icon'
14 | import {sanityConfig} from '../config'
15 |
16 | // Create Items for all Markets
17 | const createAllMarketItems = (S: StructureBuilder, config: ConfigContext) =>
18 | MARKETS.map((market) => createMarketItem(S, config, market))
19 |
20 | // Create an Item for a market
21 | const createMarketItem = (
22 | S: StructureBuilder,
23 | config: ConfigContext,
24 | market: Market
25 | ) =>
26 | S.listItem()
27 | .id(`${market.name.toLowerCase()}-market`)
28 | .title(market.title)
29 | .icon(() => Icon(market))
30 | .child(
31 | S.list()
32 | .title(`${market.name} Market Content`)
33 | .items(createAllSchemaItems(S, config, market))
34 | )
35 |
36 | // Create a list for each Schema in the Market
37 | const createAllSchemaItems = (
38 | S: StructureBuilder,
39 | config: ConfigContext,
40 | market: Market
41 | ) => SCHEMA_ITEMS.map((schemaItem) => createSchemaItem(S, schemaItem, market))
42 |
43 | // Create a schema item for this market for this schema type
44 | const createSchemaItem = (
45 | S: StructureBuilder,
46 | schemaItem: SchemaItem | SchemaSingleton | SchemaDivider,
47 | market: Market
48 | ) => {
49 | switch (schemaItem.kind) {
50 | case 'divider':
51 | return S.divider()
52 | case 'list':
53 | return S.listItem()
54 | .id(
55 | [
56 | market.name.toLowerCase(),
57 | schemaItem.title.toLowerCase().replaceAll(` `, `-`),
58 | ].join(`-`)
59 | )
60 | .title(schemaItem.title)
61 | .icon(schemaItem.icon)
62 | .child(createSchemaItemChildren(S, schemaItem, market))
63 | case 'singleton':
64 | return S.listItem()
65 | .icon(schemaItem.icon)
66 | .id([market.name.toLowerCase(), schemaItem.schemaType].join(`-`))
67 | .schemaType(schemaItem.schemaType)
68 | .title([market.name, schemaItem.title].join(` `))
69 | .child(
70 | S.defaultDocument({
71 | documentId: [market.name, schemaItem.schemaType].join(`-`),
72 | schemaType: schemaItem.schemaType,
73 | }).documentId(
74 | [market.name.toLowerCase(), schemaItem.schemaType].join(`-`)
75 | )
76 | )
77 | default:
78 | return null
79 | }
80 | }
81 |
82 | const createSchemaItemChildren = (
83 | S: StructureBuilder,
84 | schemaItem: SchemaItem,
85 | market: Market
86 | ) => {
87 | const itemTitle = [market.name, schemaItem.title].filter(Boolean).join(` `)
88 |
89 | return market.languages.length > 1
90 | ? S.list()
91 | .title(itemTitle)
92 | .items([
93 | // Create an item for every language
94 | ...market.languages.map((language) =>
95 | createSchemaItemList(S, schemaItem, market, language)
96 | ),
97 | // And one for no set language
98 | createSchemaItemList(S, schemaItem, market, null),
99 | ])
100 | : createSchemaItemChild(S, schemaItem, market, null, itemTitle)
101 | }
102 |
103 | const createSchemaItemChild = (
104 | S: StructureBuilder,
105 | schemaItem: SchemaItem,
106 | market: Market,
107 | language: Language | null,
108 | itemTitle: string
109 | ) => {
110 | let languageQuery = ``
111 | if (language) {
112 | // If language was supplied, use it
113 | languageQuery = `language == $language`
114 | } else if (!language && market.languages.length > 1) {
115 | // If no language, but the market has multiple, show documents with no set language
116 | languageQuery = `!defined(language)`
117 | } else {
118 | // No language in a single language market, don't filter by language
119 | languageQuery = ``
120 | }
121 |
122 | return S.documentTypeList(schemaItem.schemaType)
123 | .title(itemTitle)
124 | .filter(
125 | [
126 | `_type == $schemaType`,
127 | // TODO: Replace when market is added by initial value template
128 | `market == $market`,
129 | // `(!defined(market) || market == $market)`,
130 | languageQuery,
131 | ]
132 | .filter(Boolean)
133 | .join(` && `)
134 | )
135 | .apiVersion(sanityConfig.apiVersion || '2022-08-08')
136 | .params({
137 | schemaType: schemaItem.schemaType,
138 | market: market.name,
139 | language: language?.id ?? null,
140 | })
141 | .initialValueTemplates([
142 | S.initialValueTemplateItem([schemaItem.schemaType, `market`].join(`-`), {
143 | market: market.name,
144 | }),
145 | ])
146 | }
147 |
148 | const createSchemaItemList = (
149 | S: StructureBuilder,
150 | schemaItem: SchemaItem,
151 | market: Market,
152 | language: Language | null
153 | ) => {
154 | let languageTitle = null
155 | if (language?.id) {
156 | languageTitle = `(${language.id.toUpperCase()})`
157 | } else if (market.languages.length > 1) {
158 | languageTitle = `(No Language)`
159 | }
160 |
161 | const itemTitle = [market.name, schemaItem.title, languageTitle]
162 | .filter(Boolean)
163 | .join(` `)
164 |
165 | return S.listItem()
166 | .title(itemTitle)
167 | .icon(schemaItem.icon)
168 | .child(createSchemaItemChild(S, schemaItem, market, language, itemTitle))
169 | }
170 |
171 | export const structure = (
172 | S: StructureBuilder,
173 | context: ConfigContext,
174 | marketName?: string
175 | ) => {
176 | const market = marketName ? MARKETS.find((m) => m.name === marketName) : null
177 |
178 | if (market) {
179 | return S.list()
180 | .id(`${market.name.toLowerCase()}-root`)
181 | .title(`${market.name} Market`)
182 | .items(createAllSchemaItems(S, context, market))
183 | }
184 |
185 | return S.list()
186 | .id('root')
187 | .title('Markets')
188 | .items(createAllMarketItems(S, context))
189 | }
190 |
--------------------------------------------------------------------------------
/schemas/cells/pageBuilderExperimentCell.ts:
--------------------------------------------------------------------------------
1 | import {Wand2} from 'lucide-react'
2 | import {defineField} from 'sanity'
3 |
4 | export default defineField({
5 | name: 'pageBuilderExperimentCell',
6 | title: 'Experiment',
7 | type: 'object',
8 | icon: Wand2,
9 | fields: [
10 | defineField({
11 | name: 'experiments',
12 | description: 'Choose 1-2 blocks to be split between A/B audiences',
13 | type: 'array',
14 | of: [
15 | defineField({
16 | name: 'experiment',
17 | type: 'reference',
18 | to: [{type: 'article'}],
19 | options: {
20 | // Scope references to only those in the same Market
21 | filter: ({document}) => {
22 | if (!document.market) {
23 | return {
24 | filter: '!defined(market)',
25 | }
26 | }
27 |
28 | return {
29 | filter: `market == $market`,
30 | params: {market: document.market},
31 | }
32 | },
33 | },
34 | }),
35 | ],
36 | validation: (rule) => rule.max(2).required(),
37 | }),
38 | ],
39 | preview: {
40 | select: {
41 | title0: 'experiments.0.title',
42 | title1: 'experiments.1.title',
43 | },
44 | prepare: ({title0, title1}) => ({
45 | title:
46 | title0 || title1
47 | ? [title0, title1]
48 | .filter(Boolean)
49 | .map((title) => `"${title}"`)
50 | .join(` vs `)
51 | : `No "Articles" selected`,
52 | subtitle: 'Experiment',
53 | media: Wand2,
54 | }),
55 | },
56 | })
57 |
--------------------------------------------------------------------------------
/schemas/cells/pageBuilderLogosCell.ts:
--------------------------------------------------------------------------------
1 | import {Building} from 'lucide-react'
2 | import {defineField} from 'sanity'
3 |
4 | import RowDisplay from '../components/RowDisplay'
5 |
6 | export default defineField({
7 | name: 'pageBuilderLogosCell',
8 | title: 'Logos',
9 | type: 'object',
10 | icon: Building,
11 | // @ts-ignore
12 | components: {
13 | preview: RowDisplay,
14 | },
15 | fields: [
16 | defineField({
17 | name: 'logos',
18 | description:
19 | 'If no logos are selected, the logos of all companies in this Market will be displayed',
20 | type: 'array',
21 | of: [
22 | defineField({
23 | name: 'logo',
24 | type: 'reference',
25 | to: [{type: 'company'}],
26 | options: {
27 | // Scope references to only those in the same Market
28 | filter: ({document}) => {
29 | if (!document.market) {
30 | return {
31 | filter: '!defined(market)',
32 | }
33 | }
34 |
35 | return {
36 | filter: `market == $market`,
37 | params: {market: document.market},
38 | }
39 | },
40 | },
41 | }),
42 | ],
43 | }),
44 | defineField({
45 | name: 'visibility',
46 | type: 'visibility',
47 | }),
48 | ],
49 | preview: {
50 | select: {
51 | logos: 'logos',
52 | visibility: 'visibility',
53 | },
54 | prepare: ({logos}) => {
55 | let title = 'All Logos'
56 | if (logos?.length) {
57 | if (logos.length === 1) {
58 | title = `1 Logo`
59 | } else {
60 | title = `${logos.length} Logos`
61 | }
62 | }
63 | return {
64 | title,
65 | subtitle: 'Logos',
66 | media: Building,
67 | }
68 | },
69 | },
70 | })
71 |
--------------------------------------------------------------------------------
/schemas/components/RowDisplay.tsx:
--------------------------------------------------------------------------------
1 | import {Badge, Box, Flex} from '@sanity/ui'
2 | import React from 'react'
3 | import {PreviewProps} from 'sanity'
4 |
5 | type Status = 'EXPIRED' | 'CURRENT' | 'FUTURE'
6 |
7 | function renderStatus(status: Status) {
8 | switch (status) {
9 | case 'CURRENT':
10 | return (
11 |
12 | Current
13 |
14 | )
15 | case 'EXPIRED':
16 | return (
17 |
18 | Expired
19 |
20 | )
21 | case 'FUTURE':
22 | return (
23 |
24 | Future
25 |
26 | )
27 | default:
28 | return null
29 | }
30 | }
31 |
32 | export default function RowDisplay(props: PreviewProps) {
33 | // TODO: Why does this component receive the document value
34 | // When the type disagrees?
35 | // @ts-ignore
36 | const {displayFrom, displayTo} = props?.visibility || {}
37 | let status
38 | const now = new Date()
39 | const from = new Date(displayFrom)
40 | const to = new Date(displayTo)
41 |
42 | if (displayFrom && displayTo) {
43 | if (now > from && now < to) {
44 | status = 'CURRENT'
45 | } else if (now > to) {
46 | status = 'EXPIRED'
47 | } else {
48 | status = 'FUTURE'
49 | }
50 | } else if (displayFrom && !displayTo) {
51 | status = now > from ? 'CURRENT' : 'FUTURE'
52 | } else if (!displayFrom && displayTo) {
53 | status = now < to ? 'CURRENT' : 'EXPIRED'
54 | }
55 |
56 | if (status) {
57 | return (
58 |
59 |
60 | {props.renderDefault({
61 | ...props,
62 | subtitle: props.schemaType.title,
63 | // @ts-ignore
64 | media: props.image ?? props.schemaType.icon,
65 | })}
66 |
67 | {renderStatus(status)}
68 |
69 | )
70 | }
71 |
72 | return props.renderDefault({
73 | ...props,
74 | subtitle: props.schemaType.title,
75 | // @ts-ignore
76 | media: props.image ?? props.schemaType.icon,
77 | })
78 | }
79 |
--------------------------------------------------------------------------------
/schemas/documents/article.ts:
--------------------------------------------------------------------------------
1 | import {PortableTextBlock} from '@portabletext/types'
2 | import {CalendarIcon, ComposeIcon, LinkIcon, SearchIcon} from '@sanity/icons'
3 | import delve from 'dlv'
4 | import {Puzzle} from 'lucide-react'
5 | import {defineField, defineType} from 'sanity'
6 |
7 | import RowDisplay from '../components/RowDisplay'
8 |
9 | export default defineType({
10 | name: 'article',
11 | title: 'Article',
12 | type: 'document',
13 | icon: Puzzle,
14 | components: {
15 | preview: RowDisplay,
16 | },
17 | groups: [
18 | {name: 'content', title: 'Content', icon: ComposeIcon, default: true},
19 | {name: 'links', title: 'Links', icon: LinkIcon},
20 | {name: 'seo', title: 'SEO', icon: SearchIcon},
21 | {name: 'visibility', title: 'Visibility', icon: CalendarIcon},
22 | ],
23 | fields: [
24 | defineField({
25 | name: 'market',
26 | type: 'market',
27 | group: ['content'],
28 | }),
29 | defineField({
30 | name: 'icon',
31 | description: 'Displays when Articles are grouped into columns on Pages',
32 | type: 'icon',
33 | group: ['content'],
34 | }),
35 | defineField({
36 | name: 'language',
37 | type: 'language',
38 | group: ['content'],
39 | }),
40 | defineField({
41 | name: 'title',
42 | type: 'string',
43 | group: ['content'],
44 | }),
45 | defineField({
46 | name: 'subtitle',
47 | type: 'string',
48 | group: ['content'],
49 | }),
50 | defineField({
51 | name: 'summary',
52 | description:
53 | 'Short text displayed when this Article is used as a Block in a Page. Give this Article a slug to add longer form content.',
54 | validation: (rule) =>
55 | rule.custom((value: PortableTextBlock[]) => {
56 | if (value && value?.length > 1) {
57 | return 'Summary should be a single paragraph'
58 | }
59 |
60 | return true
61 | }),
62 | type: 'portableTextSimple',
63 | group: ['content'],
64 | }),
65 | defineField({
66 | name: 'slug',
67 | description:
68 | 'If given, this article will become a standalone page at /articles/{slug}',
69 | type: 'slug',
70 | group: ['seo'],
71 | options: {
72 | source: 'title',
73 | },
74 | }),
75 | defineField({
76 | name: 'seo',
77 | type: 'seo',
78 | hidden: ({document}) => !delve(document, 'slug.current'),
79 | group: ['seo'],
80 | }),
81 | defineField({
82 | name: 'content',
83 | description: 'Used if this Article is a standalone page',
84 | type: 'portableText',
85 | hidden: ({document}) => !delve(document, 'slug.current'),
86 | group: ['content'],
87 | }),
88 | defineField({
89 | name: 'links',
90 | type: 'array',
91 | of: [
92 | defineField({
93 | name: 'link',
94 | type: 'link',
95 | }),
96 | ],
97 | group: ['content'],
98 | }),
99 | defineField({
100 | name: 'image',
101 | type: 'image',
102 | options: {hotspot: true},
103 | group: ['content'],
104 | }),
105 |
106 | defineField({
107 | name: 'visibility',
108 | type: 'visibility',
109 | group: ['visibility'],
110 | }),
111 | ],
112 | preview: {
113 | select: {
114 | title: 'title',
115 | image: 'image',
116 | visibility: 'visibility',
117 | media: 'image',
118 | },
119 | // Using prepare will override the custom preview component
120 | // prepare({ title, image }) {
121 | // return {
122 | // title,
123 | // subtitle: 'Hero',
124 | // media: image ?? Type
125 | // }
126 | // }
127 | },
128 | })
129 |
--------------------------------------------------------------------------------
/schemas/documents/company.ts:
--------------------------------------------------------------------------------
1 | import {Building} from 'lucide-react'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'company',
6 | title: 'Company',
7 | type: 'document',
8 | icon: Building,
9 | fields: [
10 | defineField({
11 | name: 'market',
12 | type: 'market',
13 | }),
14 | defineField({
15 | name: 'name',
16 | type: 'string',
17 | }),
18 | defineField({
19 | name: 'logo',
20 | type: 'image',
21 | options: {
22 | hotspot: true,
23 | },
24 | }),
25 | ],
26 | preview: {
27 | select: {
28 | title: 'name',
29 | logo: 'logo',
30 | },
31 | prepare: ({title, logo}) => ({
32 | title,
33 | media: logo ?? Building,
34 | }),
35 | },
36 | })
37 |
--------------------------------------------------------------------------------
/schemas/documents/menu.ts:
--------------------------------------------------------------------------------
1 | import {Menu} from 'lucide-react'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'menu',
6 | type: 'document',
7 | icon: Menu,
8 | title: 'Menu',
9 | fields: [
10 | defineField({
11 | name: 'market',
12 | type: 'market',
13 | }),
14 | defineField({
15 | name: 'headerPrimary',
16 | description: '"Mega menu" items with child links',
17 | type: 'array',
18 | of: [
19 | defineField({
20 | name: 'item',
21 | type: 'object',
22 | fields: [
23 | defineField({
24 | name: 'link',
25 | type: 'link',
26 | }),
27 | defineField({
28 | name: 'children',
29 | type: 'array',
30 | of: [
31 | defineField({
32 | name: 'item',
33 | type: 'object',
34 | fields: [{name: 'link', type: 'link'}],
35 | preview: {
36 | select: {
37 | title: 'link.text',
38 | url: 'link.url',
39 | ref: 'link.reference.slug.current',
40 | },
41 | prepare({title, url, ref}) {
42 | return {
43 | title,
44 | subtitle: ref ?? url,
45 | }
46 | },
47 | },
48 | }),
49 | ],
50 | }),
51 | ],
52 | preview: {
53 | select: {
54 | children: 'children',
55 | refSlug: 'link.reference.slug.current',
56 | refTitle: 'link.reference.title',
57 | text: 'link.text',
58 | url: 'link.url',
59 | },
60 | prepare(selection) {
61 | const {children, refSlug, refTitle, text, url} = selection
62 |
63 | let subtitle
64 | if (children) {
65 | subtitle =
66 | children.length === 1
67 | ? `${children.length} Child Link`
68 | : `${children.length} Child Links`
69 | } else if (refSlug) {
70 | subtitle = refSlug
71 | } else if (url) {
72 | subtitle = url
73 | }
74 |
75 | return {
76 | title: !text && !refTitle ? `Empty Text` : text ?? refTitle,
77 | subtitle: subtitle ?? `No link`,
78 | }
79 | },
80 | },
81 | }),
82 | ],
83 | }),
84 | defineField({
85 | name: 'headerSecondary',
86 | description: 'Additional links in the website Header',
87 | type: 'array',
88 | of: [{name: 'link', type: 'link'}],
89 | }),
90 | defineField({
91 | name: 'footer',
92 | description: 'Additional links in the website Footer',
93 | type: 'array',
94 | of: [{name: 'link', type: 'link'}],
95 | }),
96 | ],
97 | preview: {
98 | prepare() {
99 | return {
100 | title: 'Menus',
101 | }
102 | },
103 | },
104 | })
105 |
--------------------------------------------------------------------------------
/schemas/documents/page.ts:
--------------------------------------------------------------------------------
1 | import {ComposeIcon, SearchIcon} from '@sanity/icons'
2 | import {File} from 'lucide-react'
3 | import {defineField, defineType} from 'sanity'
4 |
5 | export default defineType({
6 | name: 'page',
7 | title: 'Page',
8 | icon: File,
9 | type: 'document',
10 | groups: [
11 | {name: 'content', title: 'Content', icon: ComposeIcon, default: true},
12 | {name: 'seo', title: 'SEO', icon: SearchIcon},
13 | // { name: 'options', title: 'Options', icon: CogIcon },
14 | ],
15 | fields: [
16 | defineField({
17 | name: 'market',
18 | type: 'market',
19 | group: 'content',
20 | }),
21 | defineField({
22 | name: 'language',
23 | type: 'language',
24 | group: 'content',
25 | }),
26 | defineField({
27 | name: 'title',
28 | type: 'string',
29 | validation: (Rule) => Rule.required(),
30 | group: 'content',
31 | }),
32 | defineField({
33 | name: 'slug',
34 | type: 'slug',
35 | options: {
36 | source: 'title',
37 | maxLength: 96,
38 | },
39 | validation: (Rule) => Rule.required(),
40 | group: 'seo',
41 | }),
42 | defineField({
43 | name: 'seo',
44 | title: 'SEO',
45 | type: 'seo',
46 | group: 'seo',
47 | }),
48 | defineField({
49 | name: 'content',
50 | type: 'pageBuilder',
51 | group: 'content',
52 | }),
53 | ],
54 | preview: {
55 | select: {
56 | title: 'title',
57 | market: 'market',
58 | language: 'language',
59 | },
60 | prepare: ({title, market, language}) => {
61 | const subtitle = [language?.toLowerCase(), market?.toUpperCase()]
62 | .filter(Boolean)
63 | .join('-')
64 |
65 | return {
66 | title,
67 | subtitle,
68 | media: File,
69 | }
70 | },
71 | },
72 | })
73 |
--------------------------------------------------------------------------------
/schemas/documents/person.ts:
--------------------------------------------------------------------------------
1 | import {User} from 'lucide-react'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'person',
6 | title: 'Person',
7 | icon: User,
8 | type: 'document',
9 | fields: [
10 | defineField({
11 | name: 'market',
12 | type: 'market',
13 | }),
14 | defineField({
15 | name: 'name',
16 | type: 'string',
17 | validation: (Rule) => Rule.required(),
18 | }),
19 | defineField({
20 | name: 'title',
21 | type: 'string',
22 | }),
23 | defineField({
24 | name: 'picture',
25 | type: 'image',
26 | options: {hotspot: true},
27 | validation: (Rule) => Rule.required(),
28 | }),
29 | defineField({
30 | name: 'company',
31 | type: 'reference',
32 | to: [{type: 'company'}],
33 | options: {
34 | // Scope references to only those in the same Market
35 | filter: ({document}) => {
36 | if (!document.market) {
37 | return {
38 | filter: '!defined(market)',
39 | }
40 | }
41 |
42 | return {
43 | filter: `market == $market`,
44 | params: {market: document.market},
45 | }
46 | },
47 | },
48 | }),
49 | ],
50 | preview: {
51 | select: {
52 | name: 'name',
53 | title: 'title',
54 | media: 'picture',
55 | company: 'company.name',
56 | },
57 | prepare: ({name, title, media, company}) => ({
58 | title: name,
59 | subtitle: title ? `${title} at ${company}` : company,
60 | media: media ?? User,
61 | }),
62 | },
63 | })
64 |
--------------------------------------------------------------------------------
/schemas/documents/quote.ts:
--------------------------------------------------------------------------------
1 | import {CalendarIcon, ComposeIcon} from '@sanity/icons'
2 | import {Quote} from 'lucide-react'
3 | import {defineField, defineType} from 'sanity'
4 |
5 | import RowDisplay from '../components/RowDisplay'
6 |
7 | export default defineType({
8 | name: 'quote',
9 | title: 'Quote',
10 | icon: Quote,
11 | type: 'document',
12 | components: {
13 | preview: RowDisplay,
14 | },
15 | groups: [
16 | {name: 'content', title: 'Content', icon: ComposeIcon, default: true},
17 | {name: 'visibility', title: 'Visibility', icon: CalendarIcon},
18 | ],
19 | fields: [
20 | defineField({
21 | name: 'market',
22 | type: 'market',
23 | group: ['content'],
24 | }),
25 | defineField({
26 | name: 'quote',
27 | type: 'text',
28 | validation: (Rule) => Rule.required(),
29 | group: ['content'],
30 | }),
31 | defineField({
32 | name: 'person',
33 | type: 'reference',
34 | to: [{type: 'person'}],
35 | group: ['content'],
36 | options: {
37 | // Scope references to only those in the same Market
38 | filter: ({document}) => {
39 | if (!document.market) {
40 | return {
41 | filter: '!defined(market)',
42 | }
43 | }
44 |
45 | return {
46 | filter: `market == $market`,
47 | params: {market: document.market},
48 | }
49 | },
50 | },
51 | }),
52 | defineField({
53 | name: 'visibility',
54 | type: 'visibility',
55 | group: ['visibility'],
56 | }),
57 | ],
58 | preview: {
59 | select: {
60 | title: 'quote',
61 | person: 'person.name',
62 | company: 'person.company.name',
63 | media: 'person.picture',
64 | visibility: 'visibility',
65 | },
66 | // prepare: ({ quote, person, company, media }) => ({
67 | // title: quote,
68 | // subtitle: person ? `— ${person} at ${company}` : `Unknown`,
69 | // media: media ?? Quote,
70 | // }),
71 | },
72 | })
73 |
--------------------------------------------------------------------------------
/schemas/documents/redirect.ts:
--------------------------------------------------------------------------------
1 | import {ChevronRight} from 'lucide-react'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'redirect',
6 | type: 'document',
7 | icon: ChevronRight,
8 | title: 'Redirect',
9 | fields: [
10 | defineField({
11 | name: 'market',
12 | type: 'market',
13 | }),
14 | defineField({
15 | name: `from`,
16 | type: `string`,
17 | description: `Redirect from a "/path"`,
18 | validation: (Rule) =>
19 | Rule.required().custom((value) => {
20 | if (typeof value === 'undefined') {
21 | return true
22 | }
23 |
24 | if (value) {
25 | if (!value.startsWith(`/`)) {
26 | return `"From" path must start with "/"`
27 | }
28 | }
29 |
30 | return true
31 | }),
32 | }),
33 | defineField({
34 | name: `to`,
35 | type: `string`,
36 | description: `Redirect to a "/path" or "https://www.different-website.com"`,
37 | validation: (Rule) =>
38 | Rule.required().custom((value) => {
39 | if (typeof value === 'undefined') {
40 | return true
41 | }
42 |
43 | if (value) {
44 | if (!value.startsWith(`/`) && !value.startsWith(`https://`)) {
45 | return `"From" path must start with "/" or "https://"`
46 | }
47 | }
48 |
49 | return true
50 | }),
51 | }),
52 | ],
53 | preview: {
54 | select: {
55 | title: `from`,
56 | subtitle: `to`,
57 | },
58 | },
59 | })
60 |
--------------------------------------------------------------------------------
/schemas/documents/settings.ts:
--------------------------------------------------------------------------------
1 | import {CogIcon} from '@sanity/icons'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'settings',
6 | title: 'Settings',
7 | type: 'document',
8 | icon: CogIcon,
9 | fields: [
10 | defineField({
11 | name: 'market',
12 | type: 'market',
13 | }),
14 | defineField({
15 | name: 'language',
16 | type: 'language',
17 | }),
18 | defineField({
19 | name: 'title',
20 | description: 'This field is the title of your site.',
21 | type: 'internationalizedArrayString',
22 | }),
23 | // defineField({
24 | // name: 'reference',
25 | // type: 'reference',
26 | // to: [{ type: 'page' }],
27 | // options: {
28 | // filter: ({ document }) => {
29 | // if (!document.market) {
30 | // return {
31 | // filter: '!defined(market)',
32 | // }
33 | // }
34 |
35 | // return {
36 | // filter: `market == $market`,
37 | // params: { market: document.market },
38 | // }
39 | // },
40 | // },
41 | // }),
42 | // defineField({
43 | // name: 'home',
44 | // description: 'Which page is the home page',
45 | // type: 'internationalizedArrayString',
46 | // }),
47 | ],
48 | preview: {
49 | select: {
50 | market: 'market',
51 | language: 'language',
52 | },
53 | prepare({market}) {
54 | return {
55 | title: `${market} Settings`,
56 | subtitle: market,
57 | }
58 | },
59 | },
60 | })
61 |
--------------------------------------------------------------------------------
/schemas/index.ts:
--------------------------------------------------------------------------------
1 | import pageBuilderExperimentCell from './cells/pageBuilderExperimentCell'
2 | import pageBuilderLogosCell from './cells/pageBuilderLogosCell'
3 | import article from './documents/article'
4 | import company from './documents/company'
5 | import menu from './documents/menu'
6 | import page from './documents/page'
7 | import person from './documents/person'
8 | import quote from './documents/quote'
9 | import redirect from './documents/redirect'
10 | import settings from './documents/settings'
11 | import icon from './objects/icon'
12 | import language from './objects/language'
13 | import link from './objects/link'
14 | import market from './objects/market'
15 | import pageBuilder from './objects/pageBuilder'
16 | import portableText from './objects/portableText'
17 | import portableTextSimple from './objects/portableTextSimple'
18 | import seo from './objects/seo'
19 | import visibility from './objects/visibility'
20 |
21 | export const schemaTypes = [
22 | company,
23 | article,
24 | language,
25 | link,
26 | market,
27 | menu,
28 | page,
29 | pageBuilder,
30 | pageBuilderExperimentCell,
31 | pageBuilderLogosCell,
32 | person,
33 | quote,
34 | redirect,
35 | seo,
36 | settings,
37 | visibility,
38 | portableText,
39 | portableTextSimple,
40 | icon,
41 | ]
42 |
--------------------------------------------------------------------------------
/schemas/objects/icon.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | import IconSelector from '../../sanity/components/IconSelector'
4 |
5 | export default defineType({
6 | name: 'icon',
7 | title: 'Icon',
8 | type: 'string',
9 | components: {input: IconSelector},
10 | })
11 |
--------------------------------------------------------------------------------
/schemas/objects/language.ts:
--------------------------------------------------------------------------------
1 | import {defineField} from 'sanity'
2 |
3 | import {MARKETS, SCHEMA_ITEMS} from '../../lib/constants'
4 | import {uniqueLanguages} from '../../lib/markets'
5 |
6 | export default defineField({
7 | name: 'language',
8 | title: 'Language',
9 | type: 'string',
10 | // TODO: Hide field completely once initial value templates are configured
11 | hidden: ({document, value}) => {
12 | const market = MARKETS.find((m) => m.name === document?.market)
13 |
14 | // Value is invalid for this market, show the field
15 | if (value && !market.languages.find((l) => l.id === value)) {
16 | return false
17 | }
18 |
19 | // Hide on singleton documents
20 | const schemaIsSingleton = SCHEMA_ITEMS.find(
21 | (s) => s.kind === 'singleton' && s.schemaType === document._type
22 | )
23 |
24 | if (schemaIsSingleton) {
25 | return true
26 | }
27 |
28 | // Hide on *published* documents that have a language
29 | // In markets with more than one language
30 | if (market && market.languages.length > 1) {
31 | return !document._id.startsWith(`drafts.`) && Boolean(value)
32 | }
33 |
34 | return true
35 | },
36 | // This field should be populated by @sanity/document-internationalization
37 | // But unlock if it's invalid
38 | readOnly: ({value, document}) => {
39 | const market = MARKETS.find((m) => m.name === document?.market)
40 |
41 | // Value is invalid for this market, show the field
42 | if (value && market && !market.languages.find((l) => l.id === value)) {
43 | return false
44 | }
45 |
46 | return true
47 | },
48 | // Only allow language selection from the unique language codes from *all* the unique market-language combinations
49 | options: {
50 | list: Array.from(
51 | new Set(uniqueLanguages.map((lang) => lang.split(`-`)[0]))
52 | ).map((lang) => ({
53 | value: lang,
54 | title: lang.toUpperCase(),
55 | })),
56 | },
57 | // Only required if this market has more than one language
58 | validation: (Rule) =>
59 | Rule.custom((value, {document}) => {
60 | const market = MARKETS.find((m) => m.name === document?.market)
61 |
62 | // Not required on singleton documents
63 | const schemaIsSingleton = SCHEMA_ITEMS.find(
64 | (s) => s.kind === 'singleton' && s.schemaType === document._type
65 | )
66 |
67 | if (
68 | !value &&
69 | !schemaIsSingleton &&
70 | market &&
71 | market.languages.length > 1
72 | ) {
73 | return `Documents in the "${market.title}" Market require a language field`
74 | }
75 |
76 | if (value && !market.languages.find((l) => l.id === value)) {
77 | const marketLanguages = market.languages
78 | .map((l) => `"${l.id}"`)
79 | .join(', ')
80 | return `Invalid language "${value}", must be one of ${marketLanguages}`
81 | }
82 |
83 | return true
84 | }),
85 | })
86 |
--------------------------------------------------------------------------------
/schemas/objects/link.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'link',
5 | title: 'Link',
6 | type: 'object',
7 | fields: [
8 | {
9 | name: `reference`,
10 | type: `reference`,
11 | description: `If this link has a reference and a URL, the reference will be used`,
12 | to: [{type: 'page'}],
13 | // Read-only if a URL is used
14 | readOnly: ({value, parent}) => !value && Boolean(parent?.url),
15 | options: {
16 | filter: ({document}) => {
17 | // Filter to the same market
18 | if (document.market) {
19 | return {
20 | filter: `market == $market`,
21 | params: {market: document.market},
22 | }
23 | }
24 |
25 | return null
26 | },
27 | },
28 | },
29 | {
30 | name: `text`,
31 | description: `Can be used to overwrite the title of the referenced page`,
32 | type: `string`,
33 | // Text is required if a Reference was not used
34 | validation: (Rule) =>
35 | Rule.custom((value, context) => {
36 | // @ts-ignore
37 | return context.parent?.url && !value
38 | ? `Link text is required if a URL is provided`
39 | : true
40 | }),
41 | },
42 | {
43 | name: `url`,
44 | title: 'URL',
45 | type: `url`,
46 | // Read-only if a reference is used
47 | readOnly: ({value, parent}) => !value && Boolean(parent?.reference),
48 | validation: (Rule) =>
49 | Rule.uri({
50 | scheme: ['https', 'mailto', 'tel'],
51 | }),
52 | },
53 | ],
54 | preview: {
55 | select: {
56 | refSlug: 'reference.slug.current',
57 | refTitle: 'reference.title',
58 | text: 'text',
59 | url: 'url',
60 | },
61 | prepare(selection) {
62 | const {refSlug, refTitle, text, url} = selection
63 |
64 | return {
65 | title: !text && !refTitle ? `Empty Text` : text ?? refTitle,
66 | subtitle: !url && !refSlug ? `Empty Link` : refSlug ?? url,
67 | }
68 | },
69 | },
70 | })
71 |
--------------------------------------------------------------------------------
/schemas/objects/market.ts:
--------------------------------------------------------------------------------
1 | import {defineField} from 'sanity'
2 |
3 | import {MARKETS} from '../../lib/constants'
4 |
5 | export default defineField({
6 | name: 'market',
7 | title: 'Market',
8 | description:
9 | 'Used to colocate documents to only those in the same "Market", not to be confused with "Language".',
10 | type: 'string',
11 | hidden: ({value}) => Boolean(value),
12 | validation: (Rule) => Rule.required(),
13 | options: {
14 | list: MARKETS.map((market) => ({
15 | value: market.name,
16 | title: market.title,
17 | })),
18 | },
19 | })
20 |
--------------------------------------------------------------------------------
/schemas/objects/pageBuilder.ts:
--------------------------------------------------------------------------------
1 | import {RemoveIcon} from '@sanity/icons'
2 | import {defineArrayMember, defineField, defineType} from 'sanity'
3 |
4 | export default defineType(
5 | {
6 | name: 'pageBuilder',
7 | title: 'Page Builder',
8 | type: 'array',
9 | validation: (rule) => rule.unique(),
10 | of: [
11 | defineArrayMember({
12 | name: 'article',
13 | title: 'Article',
14 | type: 'reference',
15 | to: [{type: 'article'}],
16 | options: {
17 | filter: ({document}) => {
18 | if (!document.market) {
19 | return {
20 | filter: '!defined(market)',
21 | }
22 | }
23 |
24 | return {
25 | filter: `market == $market`,
26 | params: {market: document.market},
27 | }
28 | },
29 | },
30 | }),
31 | defineArrayMember({
32 | name: 'experiment',
33 | title: 'Experiment',
34 | type: 'pageBuilderExperimentCell',
35 | }),
36 | defineArrayMember({
37 | name: 'logos',
38 | title: 'Logos',
39 | type: 'pageBuilderLogosCell',
40 | }),
41 | defineArrayMember({
42 | name: 'quote',
43 | title: 'Quote',
44 | type: 'reference',
45 | to: [{type: 'quote'}],
46 | options: {
47 | filter: ({document}) => {
48 | if (!document.market) {
49 | return {
50 | filter: '!defined(market)',
51 | }
52 | }
53 |
54 | return {
55 | filter: `market == $market`,
56 | params: {market: document.market},
57 | }
58 | },
59 | },
60 | }),
61 | defineArrayMember({
62 | name: 'infoBreak',
63 | title: 'Info break',
64 | icon: RemoveIcon,
65 | description: 'Added to make it easier to test bento groups',
66 | type: 'object',
67 | fields: [
68 | defineField({
69 | type: 'string',
70 | name: 'text',
71 | }),
72 | ],
73 | preview: {
74 | select: {
75 | title: 'text',
76 | },
77 | prepare({title}) {
78 | return {
79 | title: title || 'Break',
80 | media: RemoveIcon,
81 | }
82 | },
83 | },
84 | }),
85 | ],
86 | },
87 | {strict: false}
88 | )
89 |
--------------------------------------------------------------------------------
/schemas/objects/portableText.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'portableText',
5 | title: 'Portable Text',
6 | type: 'array',
7 | of: [{type: 'block'}],
8 | })
9 |
--------------------------------------------------------------------------------
/schemas/objects/portableTextSimple.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'portableTextSimple',
5 | title: 'Portable Text',
6 | type: 'array',
7 | of: [
8 | {
9 | type: 'block',
10 | styles: [],
11 | lists: [],
12 | marks: {
13 | decorators: [
14 | {title: 'Strong', value: 'strong'},
15 | {title: 'Emphasis', value: 'em'},
16 | ],
17 | annotations: [],
18 | },
19 | },
20 | ],
21 | })
22 |
--------------------------------------------------------------------------------
/schemas/objects/seo.ts:
--------------------------------------------------------------------------------
1 | import {getExtension, getImageDimensions} from '@sanity/asset-utils'
2 | import {defineField, defineType} from 'sanity'
3 |
4 | export default defineType({
5 | name: 'seo',
6 | title: 'SEO',
7 | type: 'object',
8 | options: {
9 | collapsible: true,
10 | collapsed: false,
11 | },
12 | fields: [
13 | defineField({
14 | name: 'noIndex',
15 | description: `Hide this page from search engines and the sitemap`,
16 | type: 'boolean',
17 | initialValue: false,
18 | }),
19 | defineField({
20 | name: `title`,
21 | type: `string`,
22 | description: `Override the page title`,
23 | }),
24 | // defineField({
25 | // name: `keywords`,
26 | // type: `string`,
27 | // description: `Separate, with, commas`,
28 | // }),
29 | // defineField({
30 | // name: `synonyms`,
31 | // type: `string`,
32 | // description: `Similar words to inform the SEO review`,
33 | // }),
34 | defineField({
35 | name: `description`,
36 | type: `text`,
37 | rows: 3,
38 | validation: (Rule) => [
39 | Rule.max(180).warning('Description should be less than 180 characters'),
40 | Rule.min(120).warning('Description should be at least 120 characters'),
41 | ],
42 | }),
43 | defineField({
44 | name: `image`,
45 | type: `image`,
46 | options: {hotspot: true},
47 | validation: (rule) =>
48 | rule.custom((value) => {
49 | if (!value?.asset?._ref) {
50 | return true
51 | }
52 |
53 | const filetype = getExtension(value.asset._ref)
54 |
55 | if (filetype !== 'jpg' && filetype !== 'png') {
56 | return 'Image must be a JPG or PNG'
57 | }
58 |
59 | const {width, height} = getImageDimensions(value.asset._ref)
60 |
61 | if (width < 1200 || height < 630) {
62 | return 'Image must be at least 1200x630 pixels'
63 | }
64 |
65 | return true
66 | }),
67 | }),
68 | ],
69 | })
70 |
--------------------------------------------------------------------------------
/schemas/objects/visibility.ts:
--------------------------------------------------------------------------------
1 | import {defineField} from 'sanity'
2 |
3 | export type Visibility = {
4 | _type: 'visibility'
5 | displayFrom: 'string'
6 | displayTo: 'string'
7 | }
8 |
9 | export default defineField({
10 | name: 'visibility',
11 | title: 'Visibility',
12 | description:
13 | 'This document may be displayed as a Cell in a page, you can control when it is displayed here.',
14 | type: 'object',
15 | options: {columns: 2},
16 | fields: [
17 | defineField({
18 | name: 'displayFrom',
19 | type: 'datetime',
20 | validation: (rule) =>
21 | rule.custom((value, {parent}) => {
22 | const {displayTo} = parent as Visibility
23 | return value && displayTo && new Date(value) > new Date(displayTo)
24 | ? `"Display from" must be before "display to"`
25 | : true
26 | }),
27 | }),
28 | defineField({
29 | name: 'displayTo',
30 | type: 'datetime',
31 | validation: (rule) =>
32 | rule.custom((value, {parent}) => {
33 | const {displayFrom} = parent as Visibility
34 | return value && displayFrom && new Date(value) < new Date(displayFrom)
35 | ? `"Display to" must be after "display from"`
36 | : true
37 | }),
38 | }),
39 | ],
40 | })
41 |
--------------------------------------------------------------------------------
/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* * {
6 | outline: 1px solid red;
7 | } */
8 |
9 | body {
10 | -webkit-font-smoothing: antialiased;
11 | }
12 |
13 | html {
14 | overflow-x: hidden;
15 | }
16 |
17 | /* Make scroll bars in preview mode dark */
18 | @media (prefers-color-scheme: dark) {
19 | html {
20 | color-scheme: dark;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const {theme} = require('@sanity/demo/tailwind')
2 |
3 | const screen = [360, 600, 900, 1200, 1800, 2400]
4 |
5 | /** @type {import('tailwindcss').Config} */
6 | module.exports = {
7 | content: [
8 | './pages/**/*.{js,ts,jsx,tsx}',
9 | './components/**/*.{js,ts,jsx,tsx}',
10 | ],
11 | theme: {
12 | ...theme,
13 | screens: {
14 | sm: `${screen[0]}px`,
15 | md: `${screen[1]}px`,
16 | lg: `${screen[2]}px`,
17 | xl: `${screen[3]}px`,
18 | '2xl': `${screen[4]}px`,
19 | '3xl': `${screen[5]}px`,
20 | },
21 | },
22 | plugins: [
23 | require('prettier-plugin-tailwindcss'),
24 | require('@tailwindcss/typography'),
25 | ],
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "incremental": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve"
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/types/index.tsx:
--------------------------------------------------------------------------------
1 | import {PortableTextBlock} from '@portabletext/types'
2 | import {SanityImageSource} from '@sanity/image-url/lib/types/types'
3 | import {KeyedObject} from 'sanity'
4 | export interface AuthorProps {
5 | name: string
6 | picture: any
7 | }
8 |
9 | export type PageBuilderArticle = KeyedObject & {
10 | _type: 'article'
11 | title: string
12 | }
13 |
14 | export type PageBuilderExperiment = KeyedObject & {
15 | _type: 'experiment'
16 | title: string
17 | }
18 |
19 | export type PageBuilderQuote = KeyedObject & {
20 | _type: 'quote'
21 | quote: string
22 | }
23 |
24 | export interface PageStubProps {
25 | _id: string
26 | title: string
27 | slug: string
28 | }
29 | export interface PageProps {
30 | title: string
31 | market?: string
32 | slug?: string
33 | content?: (PageBuilderArticle | PageBuilderExperiment | PageBuilderQuote)[]
34 | translations?: {
35 | title: string
36 | slug: string
37 | language: string
38 | }[]
39 | }
40 |
41 | export type ArticleStub = {
42 | _type: 'article'
43 | title?: string
44 | subtitle?: string
45 | content?: PortableTextBlock[]
46 | summary?: PortableTextBlock[]
47 | image?: SanityImageSource
48 | links?: (KeyedObject & Link)[]
49 | }
50 |
51 | export type Link = {
52 | text?: string
53 | url?: string
54 | reference?: {
55 | slug?: string
56 | title?: string
57 | }
58 | }
59 | export interface GlobalDataProps {
60 | settings: {
61 | title: string
62 | }
63 | menus: {
64 | headerPrimary: {
65 | _key: string
66 | link: Link
67 | children: Link[]
68 | }[]
69 | }
70 | }
71 |
72 | export type PageQueryParams = {
73 | slug: string
74 | market: string
75 | language: string | null
76 | audience: 0 | 1
77 | date: string | null
78 | }
79 |
--------------------------------------------------------------------------------