Ported sanity studio to Next js app

This commit is contained in:
Henrik Larsson 2023-08-14 12:06:46 +02:00
parent de85d266bd
commit d32baa7782
67 changed files with 3755 additions and 46 deletions

View File

@ -4,8 +4,8 @@ import { NextIntlClientProvider } from 'next-intl';
import { Inter } from 'next/font/google';
import { notFound } from 'next/navigation';
import { ReactNode, Suspense } from 'react';
import { supportedLanguages } from '../../i18n-config';
import './globals.css';
import { supportedLanguages } from '../../../i18n-config';
import './../../globals.css';
export const metadata = {
title: {
@ -48,7 +48,7 @@ export default async function LocaleLayout({ children, params: { locale } }: Loc
let messages;
try {
messages = (await import(`../../messages/${locale}.json`)).default;
messages = (await import(`../../../messages/${locale}.json`)).default;
} catch (error) {
notFound();
}

View File

@ -0,0 +1,8 @@
'use client';
import { NextStudio } from 'next-sanity/studio';
import config from '@/sanity.config';
export default function AdminPage() {
return <NextStudio config={config} />;
}

View File

@ -0,0 +1,14 @@
import './../../globals.css';
export const metadata = {
title: `Studio | ${process.env.SITE_NAME}`,
description: 'KM Storefront studio admin interface.'
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@ -1,39 +0,0 @@
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
const SANITY_WEBHOOK_SECRET = `${process.env.SANITY_WEBHOOK_SECRET}`;
export async function POST(request: NextRequest) {
// Await the response from our request.
const requestData = await request.json();
// Get headers.
const headersList = headers();
// Get Sanity webhook signature header name.
const signature = `${headersList.get(SIGNATURE_HEADER_NAME)}`;
const isValid = isValidSignature(JSON.stringify(requestData), signature, SANITY_WEBHOOK_SECRET);
// Log out validity of request.
console.log(`Webhook request valid? ${isValid}`);
// If not valid, return.
if (!isValid) {
NextResponse.json({ success: false, message: 'Invalid signature' });
return;
}
const slug = requestData.slug;
const type = requestData.type;
if (type === 'home') {
revalidatePath(`${slug}`)
} else {
revalidatePath(`${slug}`)
}
console.log(`Revalidated path: ${slug}`);
return NextResponse.json({ revalidated: true, now: Date.now() });
}

View File

@ -0,0 +1,38 @@
import styled from 'styled-components'
import {PreviewLayoutKey, SchemaType, useSchema} from 'sanity'
import {Box} from '@sanity/ui'
import {HotspotTooltipProps} from 'sanity-plugin-hotspot-array'
import {useMemo} from 'react'
interface HotspotFields {
productWithVariant?: {
product: {
_ref: string
}
}
}
const StyledBox = styled(Box)`
width: 200px;
`
export default function ProductPreview(props: HotspotTooltipProps<HotspotFields>) {
const {value, renderPreview} = props
const productSchemaType = useSchema().get('product')
const hasProduct = value?.productWithVariant?.product?._ref && productSchemaType
const previewProps = useMemo(
() => ({
value: value?.productWithVariant?.product,
schemaType: productSchemaType as SchemaType,
layout: 'default' as PreviewLayoutKey,
}),
[productSchemaType, value?.productWithVariant?.product]
)
return (
<StyledBox padding={2}>
{hasProduct && previewProps ? renderPreview(previewProps) : `No product selected`}
</StyledBox>
)
}

View File

@ -0,0 +1,27 @@
const Kodamera = () => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px',
fontWeight: 'bold'
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 109 80"
style={{
display: 'inline-block',
width: '36px',
height: 'auto',
}}
>
<path fill="currentColor" d="M54.6,0C32.8,0,15.1,17.9,15.1,40c0,10.6,4.3,18.1,4.6,18.8h20.6c-0.7-0.5-9.1-6.9-9.1-18.8 c0-13.1,10.5-23.7,23.4-23.7S78,26.9,78,40S67.5,63.7,54.6,63.7H0V80h54.6c21.8,0,39.5-17.9,39.5-40S76.5,0,54.6,0z"/>
<path fill="currentColor" d="M109,63.7V80H75.3c7.2-3.7,13.2-9.4,17.4-16.3H109z"/>
</svg>
</div>
)
}
export default Kodamera

View File

@ -0,0 +1,20 @@
import {StringInputProps, useFormValue, SanityDocument, StringSchemaType} from 'sanity'
import get from 'lodash.get'
type Props = StringInputProps<StringSchemaType & {options?: {field?: string}}>
const PlaceholderStringInput = (props: Props) => {
const {schemaType} = props
const path = schemaType?.options?.field
const doc = useFormValue([]) as SanityDocument
const proxyValue = path ? (get(doc, path) as string) : ''
return props.renderDefault({
...props,
elementProps: {placeholder: proxyValue, ...props.elementProps},
})
}
export default PlaceholderStringInput

View File

@ -0,0 +1,32 @@
import {LockIcon} from '@sanity/icons'
import {Box, Text, TextInput, Tooltip} from '@sanity/ui'
import {StringInputProps, useFormValue, SanityDocument, StringSchemaType} from 'sanity'
import get from 'lodash.get'
type Props = StringInputProps<StringSchemaType & {options?: {field?: string}}>
const ProxyString = (props: Props) => {
const {schemaType} = props
const path = schemaType?.options?.field
const doc = useFormValue([]) as SanityDocument
const proxyValue = path ? (get(doc, path) as string) : ''
return (
<Tooltip
content={
<Box padding={2}>
<Text muted size={1}>
This value is set in Shopify (<code>{path}</code>)
</Text>
</Box>
}
portal
>
<TextInput iconRight={LockIcon} readOnly={true} value={proxyValue} />
</Tooltip>
)
}
export default ProxyString

48
lib/sanity/constants.ts Normal file
View File

@ -0,0 +1,48 @@
// Currency code (ISO 4217) to use when displaying prices in the studio
// https://en.wikipedia.org/wiki/ISO_4217
export const DEFAULT_CURRENCY_CODE = 'SEK'
// Document types which:
// - cannot be created in the 'new document' menu
// - cannot be duplicated, unpublished or deleted
export const LOCKED_DOCUMENT_TYPES = ['settings', 'home', 'media.tag']
// Document types which:
// - cannot be created in the 'new document' menu
// - cannot be duplicated, unpublished or deleted
// - are from the KM-COMMERCE connect app.
export const STORM_DOCUMENT_TYPES = ['product', 'productVariant', 'category']
// References to include in 'internal' links
export const PAGE_REFERENCES = [
{type: 'category'},
{type: 'home'},
{type: 'page'},
{type: 'product'},
{type: 'productVariant'},
]
// Objects to include in page building arrays.
export const COMPONENT_REFERENCES = [
{type: 'hero'},
{type: 'slider'},
{type: 'filteredProductList'},
{type: 'blurbSection'},
{type: 'uspSection'},
{type: 'reusableSection'},
]
// API version to use when using the Sanity client within the studio
// https://www.sanity.io/help/studio-client-specify-api-version
export const SANITY_API_VERSION = '2022-10-25'
// Your Shopify store ID.
// This is your unique store URL (e.g. 'my-store-name.myshopify.com').
// Set this to enable helper links in document status banners and shortcut links on products and collections.
export const STORM_STORE_ID = ''
// Project preview URLs
export const localStorefrontUrl = 'http://localhost:3000';
export const localStorefrontPreviewUrl = 'http://localhost:3000/api/draft';
export const publicStorefrontUrl = 'https://km-storefront.vercel.app';
export const publicStorefrontPreviewUrl = 'https://km-storefront.vercel.app/api/draft';

View File

@ -0,0 +1,15 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Blurbs')
.schemaType('blurb')
.child (
S.documentTypeList('blurb')
.child (
S.document()
.schemaType("blurb")
)
)
)

View File

@ -0,0 +1,33 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
import Iframe from 'sanity-plugin-iframe-pane'
import {SanityDocument} from 'sanity'
import {EyeOpenIcon, MasterDetailIcon} from '@sanity/icons'
import getPreviewUrl from '../utils/getPreviewUrl'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Categories')
.schemaType('category')
.child (
S.documentTypeList('category')
.child (id =>
S.document()
.schemaType("category")
.id(id)
.views([
S.view
.form()
.icon(MasterDetailIcon),
S.view
.component(Iframe)
.icon(EyeOpenIcon)
.options({
url: (doc: SanityDocument) => getPreviewUrl(doc),
})
.title('Preview')
])
)
)
)

View File

@ -0,0 +1,34 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
import Iframe from 'sanity-plugin-iframe-pane'
import {SanityDocument} from 'sanity'
import {EyeOpenIcon, MasterDetailIcon} from '@sanity/icons'
import getPreviewUrl from '../utils/getPreviewUrl'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Home')
.schemaType('home')
.child (
S.documentList()
.title('Home pages')
.filter('_type == "home"')
.child(id =>
S.document()
.schemaType("home")
.id(id)
.views([
S.view
.form()
.icon(MasterDetailIcon),
S.view
.component(Iframe)
.icon(EyeOpenIcon)
.options({
url: (doc: SanityDocument) => getPreviewUrl(doc),
})
.title('Preview')
])
)
)
)

73
lib/sanity/desk/index.ts Normal file
View File

@ -0,0 +1,73 @@
/**
* Desk structure overrides
*/
import {ListItemBuilder, StructureResolver} from 'sanity/desk'
import categories from './categoryStructure'
import home from './homeStructure'
import pages from './pageStructure'
import products from './productStructure'
import settings from './settingStructure'
import blurbs from './blurbStructure'
import sections from './sectionStructure'
import usps from './uspStructure'
import navigation from './navigationStructure'
/**
* Desk structure overrides
*
* Sanity Studio automatically lists document types out of the box.
* With this custom desk structure we achieve things like showing the `home`
* and `settings` document types as singletons, and grouping product details
* and variants for easy editorial access.
*
* You can customize this even further as your schemas progress.
* To learn more about structure builder, visit our docs:
* https://www.sanity.io/docs/overview-structure-builder
*/
// If you add document types to desk structure manually, you can add them to this function to prevent duplicates in the root pane
const hiddenDocTypes = (listItem: ListItemBuilder) => {
const id = listItem.getId()
if (!id) {
return false
}
return ![
'category',
'home',
'media.tag',
'page',
'product',
'productVariant',
'settings',
'blurb',
'section',
'usp',
'navigation',
'footerMenu',
'utilityMenu'
].includes(id)
}
export const structure: StructureResolver = (S, context) =>
S.list()
.title('Content')
.items([
home(S, context),
pages(S, context),
S.divider(),
products(S, context),
categories(S, context),
S.divider(),
blurbs(S, context),
usps(S, context),
sections(S, context),
S.divider(),
navigation(S, context),
S.divider(),
settings(S, context),
S.divider(),
...S.documentTypeListItems().filter(hiddenDocTypes),
S.divider(),
])

View File

@ -0,0 +1,31 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
import {MenuIcon} from '@sanity/icons'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Navigation')
.icon(MenuIcon)
.child(
S.list()
// Sets a title for our new list
.title('Navigation Documents')
// Add items to the array
// Each will pull one of our new documents/singletons
.items([
S.listItem()
.title('Utility menu')
.child(S.document().schemaType('utilityMenu').documentId('utilityMenu')),
S.listItem()
.title('Footer menus')
.child(
S.documentTypeList('footerMenu')
.title('Footer menus')
.child (
S.document()
.schemaType("footerMenu")
)
),
])
),
)

View File

@ -0,0 +1,35 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
import {DocumentsIcon} from '@sanity/icons'
import Iframe from 'sanity-plugin-iframe-pane'
import {SanityDocument} from 'sanity'
import {EyeOpenIcon, MasterDetailIcon} from '@sanity/icons'
import getPreviewUrl from '../utils/getPreviewUrl'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Pages')
.schemaType('page')
.icon(DocumentsIcon)
.child (
S.documentTypeList('page')
.child (id =>
S.document()
.schemaType("page")
.id(id)
.views([
S.view
.form()
.icon(MasterDetailIcon),
S.view
.component(Iframe)
.icon(EyeOpenIcon)
.options({
url: (doc: SanityDocument) => getPreviewUrl(doc),
})
.title('Preview')
])
)
)
)

View File

@ -0,0 +1,73 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
import Iframe from 'sanity-plugin-iframe-pane'
import {SanityDocument} from 'sanity'
import {EyeOpenIcon, MasterDetailIcon} from '@sanity/icons'
import getPreviewUrl from '../utils/getPreviewUrl'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Products')
.schemaType('product')
.child (
S.documentTypeList('product')
.child (id =>
S.document()
.schemaType("product")
.id(id)
.views([
S.view
.form()
.icon(MasterDetailIcon),
S.view
.component(Iframe)
.icon(EyeOpenIcon)
.options({
url: (doc: SanityDocument) => getPreviewUrl(doc),
})
.title('Preview')
])
)
)
)
// @TODO - FIX THIS STRUCTURE.
// export default defineStructure<ListItemBuilder>((S) =>
// S.listItem()
// .title('Products')
// .schemaType('product')
// .child(
// S.documentTypeList('product')
// // .defaultLayout('detail')
// .child(async (id) =>
// S.list()
// .title('Product')
// .items([
// // Details
// S.listItem()
// .title('Details')
// .icon(InfoOutlineIcon)
// .child(S.document().schemaType('product').documentId(id)),
// // Product variants
// S.listItem()
// .title('Variants')
// .schemaType('productVariant')
// .child(
// S.documentList()
// .title('Variants')
// .schemaType('productVariant')
// .filter(
// `
// _type == "productVariant"
// && store.productId == $productId
// `
// )
// .params({
// productId: Number(id.replace('shopifyProduct-', '')),
// })
// ),
// ])
// )
// )
// )

View File

@ -0,0 +1,15 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Sections')
.schemaType('section')
.child (
S.documentTypeList('section')
.child (
S.document()
.schemaType("section")
)
)
)

View File

@ -0,0 +1,15 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('Settings')
.schemaType('settings')
.child (
S.documentTypeList('settings')
.child (
S.document()
.schemaType("settings")
)
)
)

View File

@ -0,0 +1,15 @@
import {ListItemBuilder} from 'sanity/desk'
import defineStructure from '../utils/defineStructure'
export default defineStructure<ListItemBuilder>((S) =>
S.listItem()
.title('USPs')
.schemaType('usp')
.child (
S.documentTypeList('usp')
.child (
S.document()
.schemaType("usp")
)
)
)

21
lib/sanity/languages.ts Normal file
View File

@ -0,0 +1,21 @@
export const languages = [
{
id: 'sv',
title: 'Swedish',
flag: '🇸🇪'
},
{
id: 'en',
title: 'English',
flag: '🇬🇧'
},
]
const i18n = {
languages: languages,
base: 'sv'
}
// For v3 studio
export {i18n}

View File

@ -0,0 +1,23 @@
const product = {
type: 'product',
sv: 'produkt',
en: 'product'
}
const category = {
type: 'category',
'sv': 'kategori',
'en': 'category'
}
const page = {
type: 'page',
'sv': '',
'en': ''
}
export const localizedTypes = [
page,
product,
category,
]

View File

@ -0,0 +1,54 @@
import {defineField} from 'sanity'
export default defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
{
lists: [
{title: 'Bullet', value: 'bullet'},
{title: 'Numbered', value: 'number'},
],
marks: {
decorators: [
{
title: 'Italic',
value: 'em',
},
{
title: 'Strong',
value: 'strong',
},
],
},
// Paragraphs
type: 'block',
},
// Custom blocks
// {
// name: 'blockAccordion',
// type: 'module.accordion',
// },
// {
// name: 'blockCallout',
// type: 'module.callout',
// },
// {
// name: 'blockGrid',
// type: 'module.grid',
// },
// {
// name: 'blockImages',
// type: 'module.images',
// },
// {
// name: 'blockInstagram',
// type: 'module.instagram',
// },
// {
// name: 'blockProducts',
// type: 'module.products',
// },
],
})

View File

@ -0,0 +1,122 @@
import {CommentIcon} from '@sanity/icons'
import {defineField} from 'sanity'
import {languages} from '../../languages'
import {validateImage} from '../../utils/validation'
export default defineField({
name: 'blurb',
title: 'Blurb',
type: 'document',
icon: CommentIcon,
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
// Title
defineField({
name: 'title',
title: 'Title',
type: 'string',
description: 'What do you want to convey?',
validation: (Rule) => Rule.required(),
}),
// Image
defineField({
name: 'image',
title: 'Image',
type: 'mainImage',
validation: (Rule) => validateImage(Rule, true),
}),
// Text
defineField({
name: 'text',
title: 'Text',
type: 'text',
description: 'Small text displayed below title.',
rows: 5,
}),
// Link
{
name: 'link',
title: 'Link',
type: 'object',
fields: [
{
name: 'linkType',
type: 'string',
title: 'Link type',
initialValue: 'internal',
description: 'Link to internal or external content.',
validation: (Rule) => Rule.required(),
options: {
list: ['internal', 'external'],
layout: 'radio',
},
},
{
name: 'internalLink',
type: 'linkInternal',
title: 'Internal link',
hidden: ({parent}) => parent?.linkType !== 'internal',
options: {
collapsible: false,
},
validation: (Rule) =>
Rule.custom((value: any, context: any) => {
if (context.parent.linkType == 'internal') {
const currentLink = value && value.reference
if (!currentLink) {
return 'Reference is required'
}
}
return true
}),
},
{
name: 'externalLink',
type: 'linkExternal',
title: 'External link',
hidden: ({parent}) => parent?.linkType !== 'external',
options: {
collapsible: false,
},
validation: (Rule) =>
Rule.custom((value: any, context: any) => {
if (context.parent.linkType == 'external') {
const currentTitle = value?.title
const currentUrl = value?.url
if (!currentTitle) {
return 'Title is required'
} else if (!currentUrl) {
return 'URL is required'
}
}
return true
}),
},
],
},
],
preview: {
select: {
title: 'title',
image: 'image',
language: 'language',
},
prepare(selection) {
const {image, title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
return {
media: image,
title,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,126 @@
import {TagIcon} from '@sanity/icons'
import {defineField, defineType} from 'sanity'
import {slugWithLocalizedType} from './slugWithLocalizedType'
import {languages} from '../../languages'
import {validateImage} from '../../utils/validation'
const GROUPS = [
{
name: 'editorial',
title: 'Editorial',
},
{
name: 'seo',
title: 'SEO',
},
]
export default defineType({
name: 'category',
title: 'Category',
type: 'document',
icon: TagIcon,
groups: GROUPS,
fields: [
// Language
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
// Title
defineField({
name: 'title',
title: 'Title',
type: 'string',
description: 'Category title.',
}),
// Slug
slugWithLocalizedType('category', 'title'),
// // Show banner
// defineField({
// name: 'showBanner',
// title: 'Show banner',
// type: 'boolean',
// description: 'If disabled, category title will be displayed instead.',
// group: 'editorial',
// }),
// // Banner
// defineField({
// name: 'banner',
// title: 'Banner',
// type: 'banner',
// hidden: ({document}) => !document?.showBanner,
// group: 'editorial',
// }),
// Image
defineField({
name: 'image',
type: 'mainImage',
title: 'Image',
validation: (Rule) => validateImage(Rule, true),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
rows: 5,
description: 'Description of this category.',
}),
defineField({
name: 'id',
title: 'ID',
type: 'number',
description: 'Unique ID.',
}),
defineField({
name: 'categoryId',
title: 'Category ID',
type: 'number',
description: 'Unique category ID.',
}),
defineField({
name: 'parentId',
title: 'Parent ID',
type: 'number',
description: 'Unique parent category ID.',
}),
// SEO
defineField({
name: 'seo',
title: 'SEO',
type: 'seo',
group: 'seo',
}),
],
orderings: [
{
name: 'titleAsc',
title: 'Title (A-Z)',
by: [{field: 'title', direction: 'asc'}],
},
{
name: 'titleDesc',
title: 'Title (Z-A)',
by: [{field: 'title', direction: 'desc'}],
},
],
preview: {
select: {
title: 'title',
language: 'language',
},
prepare(selection) {
const {title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
return {
title,
subtitle: `${currentLang ? currentLang.title : ''}`,
media: TagIcon,
}
},
},
})

View File

@ -0,0 +1,48 @@
import {MenuIcon} from '@sanity/icons'
import {defineType, defineField} from 'sanity'
import {languages} from '../../languages'
export default defineType({
name: 'footerMenu',
title: 'Footer menu',
type: 'document',
icon: MenuIcon,
groups: [],
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
defineField({
name: 'title',
type: 'string',
title: 'Title',
description: 'Menu title or designation for menu.',
}),
// Menu
defineField({
name: 'menu',
title: 'Menu',
type: 'menu',
}),
],
preview: {
select: {
title: 'title',
language: 'language',
},
prepare(selection) {
const {title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
return {
title: `${title}`,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,75 @@
import {DocumentIcon} from '@sanity/icons'
import {defineField} from 'sanity'
import {languages} from '../../languages'
import {COMPONENT_REFERENCES} from '../../constants'
import {slugWithLocalizedType} from './slugWithLocalizedType'
export default defineField({
name: 'page',
title: 'Page',
type: 'document',
icon: DocumentIcon,
groups: [
{
name: 'editorial',
title: 'Editorial',
},
{
name: 'seo',
title: 'SEO',
},
],
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
// Title
defineField({
name: 'title',
title: 'Title',
type: 'string',
description: 'Page title.',
validation: (Rule) => Rule.required(),
}),
// Slug
slugWithLocalizedType('page', 'title'),
// Content
defineField({
name: 'content',
title: 'Page sections',
type: 'array',
group: 'editorial',
description: 'Add, reorder, edit or delete page sections.',
of: COMPONENT_REFERENCES,
}),
// SEO
defineField({
name: 'seo',
title: 'SEO',
type: 'seo',
group: 'seo',
}),
],
preview: {
select: {
seoImage: 'seo.image',
title: 'title',
language: 'language',
},
prepare(selection) {
const {seoImage, title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
return {
media: seoImage,
title,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,167 @@
import {PackageIcon} from '@sanity/icons'
import {defineField, defineType} from 'sanity'
import {slugWithLocalizedType} from './slugWithLocalizedType'
import {languages} from '../../languages'
import {validateImage} from '../../utils/validation'
const GROUPS = [
{
name: 'editorial',
title: 'Editorial',
},
{
name: 'seo',
title: 'SEO',
},
]
export default defineType({
name: 'product',
title: 'Product',
type: 'document',
icon: PackageIcon,
groups: GROUPS,
fields: [
// Language
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
// ID
defineField({
name: 'id',
title: 'ID',
type: 'number',
description: 'Unique product ID.',
}),
// Title
defineField({
name: 'title',
title: 'Title',
type: 'string',
description: 'Product title/name.',
validation: (Rule) => Rule.required(),
}),
// Slug
slugWithLocalizedType('product', 'title'),
defineField({
name: 'images',
title: 'Images',
description: 'Images of this product, the first image will be used as main image.',
type: 'array',
of: [
{
title: 'Image',
type: 'mainImage',
validation: (Rule) => validateImage(Rule, true),
},
],
validation: (Rule) => Rule.required().min(1).max(5),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
description: 'Product description.',
}),
defineField({
name: 'price',
title: 'Price',
type: 'object',
description: 'Product price information.',
fields: [
defineField({
name: 'value',
title: 'Value',
type: 'number',
description: 'Product price.',
}),
defineField({
name: 'currencyCode',
title: 'Currency code',
type: 'string',
description: 'Product currency code.',
options: {
list: [
{title: 'SEK', value: 'SEK'},
{title: 'GBP', value: 'GBP'},
{title: 'EUR', value: 'EUR'},
],
layout: 'radio',
},
initialValue: 'SEK',
}),
defineField({
name: 'retailPrice',
title: 'Retail price',
type: 'number',
description: 'Product retail price.',
}),
],
}),
defineField({
name: 'options',
title: 'Product options',
type: 'array',
description: 'What product options are available?',
of: [{type: 'productOptions'}],
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
description: 'What category/categories does this product belong to?',
of: [{type: 'reference', to: {type: 'category'}}],
}),
defineField({
name: 'seo',
title: 'SEO',
type: 'seo',
group: 'seo',
}),
],
orderings: [
{
name: 'titleAsc',
title: 'Title (A-Z)',
by: [{field: 'title', direction: 'asc'}],
},
{
name: 'titleDesc',
title: 'Title (Z-A)',
by: [{field: 'title', direction: 'desc'}],
},
{
name: 'priceDesc',
title: 'Price (Highest first)',
by: [{field: 'price', direction: 'desc'}],
},
{
name: 'priceAsc',
title: 'Title (Lowest first)',
by: [{field: 'price', direction: 'asc'}],
},
],
preview: {
select: {
images: 'images',
title: 'title',
language: 'language',
},
prepare(selection) {
const {images, title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
const firstImage = images[0]
return {
title,
subtitle: `${currentLang ? currentLang.title : ''}`,
media: firstImage ? firstImage : PackageIcon,
}
},
},
})

View File

@ -0,0 +1,30 @@
import {CopyIcon} from '@sanity/icons'
import {defineField, defineType} from 'sanity'
export default defineType({
name: 'productVariant',
title: 'Product variant',
type: 'document',
icon: CopyIcon,
fields: [
// Title
defineField({
title: 'Title',
name: 'title',
type: 'string',
description: 'Product variant title/name.'
}),
],
preview: {
select: {
title: 'title',
},
prepare(selection) {
const {title} = selection
return {
title,
}
},
},
})

View File

@ -0,0 +1,64 @@
import {defineField} from 'sanity'
import {BlockElementIcon} from '@sanity/icons'
import {languages} from '../../languages'
export default defineField({
name: 'section',
type: 'document',
title: 'Reusable section',
icon: BlockElementIcon,
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
// Title
defineField({
name: 'title',
title: 'Title',
type: 'string',
}),
defineField({
name: 'section',
title: 'Section',
type: 'object',
description: 'Reusable section to refer to from other pages.',
fields: [
defineField({
name: 'sectionType',
type: 'array',
title: 'Section type',
description: 'Select reusable component (only 1 allowed).',
of: [
{type: 'hero'},
{type: 'filteredProductList'},
{type: 'slider'},
{type: 'blurbSection'},
{type: 'uspSection'},
],
validation: (Rule) => Rule.length(1),
}),
],
}),
],
preview: {
select: {
title: 'title',
language: 'language',
},
prepare(selection) {
const {title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
return {
title: `${title}`,
media: BlockElementIcon,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,56 @@
import {Rule, Slug} from 'sanity'
import slugify from "slugify";
import { i18n } from "../../languages";
import { localizedTypes } from "../../localizedTypes";
const MAX_LENGTH = 96
function formatSlug(input: string, docType: string, context: any, schemaType: object | any) {
const locale = schemaType?.parent?.language ? schemaType?.parent?.language : i18n.base;
let currentDocType: any;
currentDocType = localizedTypes.find(item => item.type === docType);
const currentDocTypeLocalized = currentDocType[locale];
const slugStart = currentDocTypeLocalized ? `/${currentDocTypeLocalized}/` : `/`;
const slug = slugify(input, { lower: true });
return slugStart + slug;
}
export function slugWithLocalizedType(documentType = '', source = `title`) {
const docType = documentType;
return {
name: `slug`,
type: `slug`,
options: {
source,
slugify: (value: any, context: any, schemaType: object | any) => formatSlug(value, docType, context, schemaType),
},
validation: (Rule: Rule) => {
return Rule.required().custom(async (value: Slug) => {
const currentSlug = value && value.current
if (!currentSlug) {
return true
}
if (currentSlug.length >= MAX_LENGTH) {
return `Must be less than ${MAX_LENGTH} characters`
}
if (currentSlug.length === 0) {
return 'Slug cannot be empty'
}
if (currentSlug.endsWith("/")) {
return 'Slug cannot end with "/"'
}
return true
})
}
};
}

View File

@ -0,0 +1,62 @@
import {StarIcon} from '@sanity/icons'
import {defineField} from 'sanity'
import {languages} from '../../languages'
import {validateImage} from '../../utils/validation'
export default defineField({
name: 'usp',
title: 'USPs',
type: 'document',
icon: StarIcon,
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
// Title
defineField({
name: 'title',
title: 'Title',
type: 'string',
description: 'USP title',
validation: (Rule) => Rule.required(),
}),
// Image
defineField({
name: 'image',
title: 'Image',
type: 'mainImage',
description: 'USP icon',
validation: (Rule) => validateImage(Rule, true),
}),
// Text
defineField({
name: 'text',
title: 'Text',
type: 'text',
description: 'Small text displayed below title.',
rows: 5,
}),
],
preview: {
select: {
title: 'title',
image: 'image',
language: 'language',
},
prepare(selection) {
const {image, title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
return {
media: image,
title,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,91 @@
// Rich text annotations used in the block content editor
// import annotationLinkEmail from './annotations/linkEmail'
// import annotationLinkExternal from './annotations/linkExternal'
// import annotationLinkInternal from './annotations/linkInternal'
// import annotationProduct from './annotations/product'
// const annotations = [
// annotationLinkEmail,
// annotationLinkExternal,
// annotationLinkInternal,
// annotationProduct,
// ]
// Document types
import category from './documents/category'
import page from './documents/page'
import product from './documents/product'
import productVariant from './documents/productVariant'
import blurb from './documents/blurb'
import section from './documents/section'
import usp from './documents/usp'
import footerMenu from './documents/footerMenu'
const documents = [
category,
page,
product,
productVariant,
blurb,
section,
usp,
footerMenu
]
// Singleton document types
import home from './singletons/home'
import settings from './singletons/settings'
import utilityMenu from './singletons/utilityMenu'
// import navigation from './singletons/navigation'
const singletons = [home, settings, utilityMenu]
// Block content
import body from './blocks/body'
const blocks = [body]
// Object types
import banner from './objects/banner'
import linkExternal from './objects/linkExternal'
import linkInternal from './objects/linkInternal'
import hero from './objects/hero'
import placeholderString from './objects/placeholderString'
import proxyString from './objects/proxyString'
import seo from './objects/seo'
import mainImage from './objects/mainImage'
import slider from './objects/slider'
import productOption from './objects/productOption'
import productOptions from './objects/productOptions'
import blurbSection from './objects/blurbSection'
import filteredProductList from './objects/filteredProductList'
import uspSection from './objects/uspSection'
import reusableSection from './objects/reusableSection'
import menu from './objects/menu'
const objects = [
linkExternal,
linkInternal,
hero,
placeholderString,
proxyString,
seo,
mainImage,
slider,
productOption,
productOptions,
filteredProductList,
banner,
blurbSection,
uspSection,
reusableSection,
menu
]
export const schemaTypes = [
// ...annotations,
...documents,
...singletons,
...objects,
...blocks
]

View File

@ -0,0 +1,50 @@
import {defineField} from 'sanity'
import { validateImage } from '../../utils/validation'
export default defineField({
name: 'banner',
type: 'object',
title: 'Banner',
description: 'Normally used in the top of a page to display current page information.',
fields: [
{
name: 'title',
type: 'string',
title: 'Title',
description: 'What do you want to convey?',
validation: Rule => [
Rule.required(),
Rule.max(50).warning('Shorter titles are usually better.')
]
},
{
name: 'text',
type: 'text',
title: 'Text',
rows: 5,
description: 'Small text below title.'
},
{
name: 'image',
type: 'mainImage',
title: 'Image',
validation: (Rule) => validateImage(Rule, true)
},
],
preview: {
select: {
title: 'title',
image: 'image',
text: 'text'
},
prepare(selection) {
const {title, image, text} = selection
return {
title: `${title}`,
subtitle: `Banner`,
media: image
}
},
},
})

View File

@ -0,0 +1,132 @@
import {defineField} from 'sanity'
import {CommentIcon} from '@sanity/icons'
export default defineField({
name: 'blurbSection',
type: 'object',
title: 'Blurb section',
icon: CommentIcon,
fieldsets: [
{
name: 'layoutSettings',
title: 'Layout settings',
},
],
fields: [
{
name: 'disabled',
type: 'boolean',
title: 'Disabled?',
description: 'Set to true to disable this section.',
initialValue: 'false',
validation: (Rule) => Rule.required(),
},
{
name: 'title',
type: 'string',
title: 'Title',
description: 'Text displayed above blurbs.',
validation: (Rule) => Rule.required(),
},
{
name: 'mobileLayout',
type: 'string',
title: 'Mobile layout',
initialValue: 'stacked',
fieldset: 'layoutSettings',
description: 'Display blurbs stacked on top of each other or in a slider.',
validation: (Rule) => Rule.required(),
options: {
list: [
{
title: 'Vertical (1 column)',
value: 'vertical',
},
{
title: 'Horizontal (1 row with scroll)',
value: 'horizontal',
},
],
layout: 'radio',
direction: 'horizontal',
},
},
{
name: 'desktopLayout',
type: 'string',
title: 'Desktop layout',
initialValue: '3-column',
fieldset: 'layoutSettings',
description: 'Display blurbs in a 2- 3- or 4-column layout.',
validation: (Rule) => Rule.required(),
options: {
list: [
{
title: '2 columns',
value: '2-column',
},
{
title: '3 columns',
value: '3-column',
},
{
title: '4 columns',
value: '4-column',
},
],
layout: 'radio',
direction: 'horizontal',
},
},
{
name: 'imageFormat',
type: 'string',
title: 'Blurb image format',
initialValue: 'square',
description: 'Choose format to display blurb images in.',
validation: (Rule) => Rule.required(),
fieldset: 'layoutSettings',
options: {
list: [
{title: 'Square (1:1)', value: 'square'},
{title: 'Portrait (3:4)', value: 'portrait'},
{title: 'Landscape (16:9)', value: 'landscape'},
],
layout: 'radio',
},
},
{
name: 'blurbs',
type: 'array',
title: 'Blurbs',
description: 'Create blurbs or refer to existing blurbs.',
of: [
{
type: 'reference',
to: [
{
type: 'blurb',
},
],
},
],
validation: (Rule) => Rule.required(),
},
],
preview: {
select: {
title: 'title',
disabled: 'disabled',
},
prepare(selection) {
const {title, disabled} = selection
return {
title: `${title}`,
subtitle: `Blurb section ${disabled ? '(⚠️ Disabled)' : ''}`,
media: CommentIcon,
}
},
},
})

View File

@ -0,0 +1,70 @@
import {defineField} from 'sanity'
import {FilterIcon} from '@sanity/icons'
export default defineField({
name: 'filteredProductList',
type: 'object',
title: 'Filtered product list',
icon: FilterIcon,
fields: [
{
name: 'disabled',
type: 'boolean',
title: 'Disabled?',
description: 'Set to true to disable this section.',
initialValue: 'false',
validation: (Rule) => Rule.required(),
},
{
name: 'title',
type: 'string',
title: 'Title',
description: 'Text displayed above product list.'
},
{
name: 'productCategories',
type: 'array',
title: 'Product categories',
description: 'Select category/categories to display products from.',
of: [
{
type: 'reference',
to: [{
type: 'category',
}],
options: {
disableNew: true,
},
},
],
validation: (Rule) => Rule.required(),
},
{
name: 'itemsToShow',
type: 'number',
title: 'Number of products',
initialValue: 4,
description: 'Amount of products to be displayed.',
validation: (Rule) => Rule.required(),
options: {
list: [4, 8, 12],
layout: 'radio',
},
},
],
preview: {
select: {
title: 'title',
disabled: 'disabled'
},
prepare(selection) {
const {title, disabled} = selection
return {
title: `${title}`,
subtitle: `Filtered product list ${disabled ? '(⚠️ Disabled)' : ''}`,
media: FilterIcon
}
},
},
})

View File

@ -0,0 +1,125 @@
import {defineField} from 'sanity'
import {StarIcon} from '@sanity/icons'
import { validateImage } from '../../utils/validation'
export default defineField({
name: 'hero',
type: 'object',
title: 'Hero',
icon: StarIcon,
fieldsets: [
{
name: 'settings',
title: 'Hero settings',
description: 'Hero layout and semantic settings.',
options: {
collapsed: true,
collapsible: true,
},
}
],
fields: [
{
name: 'disabled',
type: 'boolean',
title: 'Disabled?',
description: 'Set to true to disable this section.',
initialValue: false,
validation: (Rule) => Rule.required(),
},
{
name: 'variant',
type: 'string',
title: 'Hero variant',
initialValue: 'fullScreen',
description: 'Choose display for larger screens: Full or half screen height.',
validation: Rule => Rule.required(),
fieldset: 'settings',
options: {
list: [
{title: 'Full screen', value: 'fullScreen'},
{title: '50% height', value: 'halfScreen'},
],
layout: 'radio',
}
},
{
name: 'headingLevel',
type: 'string',
title: 'Heading level',
initialValue: 'h1',
fieldset: 'settings',
description: 'Set appropriate heading level depending on the current document structure.',
options: {
list: [
{title: 'H1', value: 'h1'},
{title: 'H2', value: 'h2'},
],
layout: 'radio',
},
},
{
name: 'label',
type: 'string',
title: 'Label',
description: 'Small text displayed above title.'
},
{
name: 'title',
type: 'string',
title: 'Title',
description: 'What you want to convey.',
validation: Rule => [
Rule.required(),
Rule.max(50).warning('Shorter titles are usually better.')
]
},
{
name: 'text',
type: 'text',
title: 'Text',
rows: 5,
description: 'Short text displayed below title.',
validation: Rule => [
Rule.max(100).warning('Strive to be short, precise and on point.')
]
},
{
name: 'image',
type: 'mainImage',
title: 'Image',
validation: Rule => validateImage(Rule, true),
options: {
hotspot: true,
collapsed: false,
collapsible: true,
},
},
{
name: 'link',
type: 'linkInternal',
title: 'Link',
description: 'Link to internal page.',
options: {
collapsed: true,
collapsible: true,
},
},
],
preview: {
select: {
title: 'title',
image: 'image',
disabled: 'disabled'
},
prepare(selection) {
const {title, image, disabled} = selection
return {
title: `${title}`,
media: image?.asset ? image : StarIcon,
subtitle: `Hero ${disabled ? '(⚠️ Disabled)' : ''}`,
}
},
},
})

View File

@ -0,0 +1,54 @@
import {EarthGlobeIcon} from '@sanity/icons'
import {defineField} from 'sanity'
export default defineField({
title: 'External Link',
name: 'linkExternal',
type: 'object',
icon: EarthGlobeIcon,
description: 'Link to content on external site.',
fields: [
// Title
defineField({
title: 'Title',
name: 'title',
type: 'string',
description: 'Descriptive text for the content on this link.'
}),
// URL
defineField({
name: 'url',
title: 'URL',
type: 'url',
description: 'Link to websites, e-mail address or phone number.',
validation: (Rule) => Rule.required().uri({scheme: ['http', 'https', 'mailto', 'tel']})
}),
// Open in a new window?
defineField({
title: 'Open in a new window?',
name: 'newWindow',
type: 'boolean',
description: 'If set to true, opens the link in a new window.',
initialValue: false,
})
],
preview: {
select: {
title: 'title',
url: 'url',
},
prepare(selection) {
const {title, url} = selection
let subtitle = []
if (url) {
subtitle.push(`${url}`)
}
return {
title,
subtitle: subtitle.join(' '),
}
},
},
})

View File

@ -0,0 +1,56 @@
import {LinkIcon} from '@sanity/icons'
import {defineField} from 'sanity'
import {PAGE_REFERENCES} from '../../constants'
export default defineField({
title: 'Internal Link',
name: 'linkInternal',
type: 'object',
description: 'Link to content on this site.',
icon: LinkIcon,
fields: [
// Title
defineField({
title: 'Title',
name: 'title',
type: 'string',
description: 'If empty, displays the current reference title.'
}),
// Reference
defineField({
name: 'reference',
type: 'reference',
title: 'Content reference',
description: 'Link to already created, internal content.',
weak: true,
to: PAGE_REFERENCES,
}),
],
preview: {
select: {
reference: 'reference',
referenceTitle: 'reference.title',
referenceType: 'reference._type',
title: 'title',
},
prepare(selection) {
const {
reference,
referenceTitle,
title,
} = selection
let subtitle = []
if (reference) {
subtitle.push([`${referenceTitle || reference?._id}`])
} else {
subtitle.push('(Nonexistent document reference)')
}
return {
title,
subtitle: subtitle.join(' '),
}
},
},
})

View File

@ -0,0 +1,26 @@
import { defineField, defineType } from "sanity"
export default defineType({
name: 'mainImage',
type: 'image',
title: 'Main image',
description: 'Select or upload image. Edit by using the `menu` icon. Modify by using the `crop` icon.',
options: {
hotspot: true,
metadata: [
'blurhash', // Default: included
'lqip', // Default: included
'palette', // Default: included
'exif', // Default: not included
'location', // Default: not included
],
},
fields: [
defineField({
name: 'alt',
type: 'string',
title: 'Alternative text',
description: 'Note: Important for SEO and accessibility.',
}),
],
})

View File

@ -0,0 +1,34 @@
import {MenuIcon} from '@sanity/icons'
import {defineType, defineField} from 'sanity'
export default defineType({
name: 'menu',
title: 'Menu',
type: 'object',
icon: MenuIcon,
groups: [],
fields: [
// Links
defineField({
name: 'links',
title: 'Links',
type: 'array',
of: [
{type: 'linkInternal'},
{type: 'linkExternal'},
],
})
],
preview: {
select: {
title: 'title',
},
prepare(selection) {
const {title} = selection;
return {
title: `${title}`,
}
},
},
})

View File

@ -0,0 +1,10 @@
import PlaceholderStringInput from '../../components/inputs/PlaceholderString'
export default {
name: 'placeholderString',
title: 'Title',
type: 'string',
components: {
input: PlaceholderStringInput,
},
}

View File

@ -0,0 +1,25 @@
import {defineField} from 'sanity'
export default defineField({
name: 'productOption',
title: 'Product option',
type: 'object',
fields: [
defineField({
name: 'label',
title: 'Label',
type: 'string',
validation: Rule => Rule.required(),
description: 'Product option label.'
}),
defineField({
name: 'hexColors',
title: 'Color hex code',
type: 'color',
description: 'Hex color code for product option.',
options: {
disableAlpha: true
}
})
],
})

View File

@ -0,0 +1,32 @@
import {defineField} from 'sanity'
export default defineField({
name: 'productOptions',
title: 'Product options',
type: 'object',
fields: [
defineField({
name: 'id',
title: 'ID (string)',
type: 'string',
validation: Rule => Rule.required(),
description: 'Unique product option ID.'
}),
defineField({
name: 'displayName',
title: 'Display name',
type: 'string',
description: 'Name displayed for this collection of product options.',
validation: Rule => Rule.required(),
}),
defineField({
name: 'values',
title: 'Values',
type: 'array',
description: 'What kind of values are available?',
of: [{type: 'productOption'}],
options: {},
validation: Rule => Rule.required(),
}),
],
})

View File

@ -0,0 +1,11 @@
import {defineField} from 'sanity'
import ProxyStringInput from '../../components/inputs/ProxyString'
export default defineField({
name: 'proxyString',
title: 'Title',
type: 'string',
components: {
input: ProxyStringInput,
},
})

View File

@ -0,0 +1,54 @@
import {defineField} from 'sanity'
import {BlockElementIcon} from '@sanity/icons'
export default defineField({
name: 'reusableSection',
type: 'object',
title: 'Reusable section',
icon: BlockElementIcon,
fields: [
{
name: 'disabled',
type: 'boolean',
title: 'Disabled?',
description: 'Set to true to disable this section.',
initialValue: 'false',
validation: (Rule) => Rule.required(),
},
{
name: 'title',
type: 'string',
title: 'Title',
},
{
name: 'section',
type: 'object',
title: 'Section',
description: 'Reference to an existing section (only 1 allowed).',
fields: [
{
title: 'Existing section',
name: 'existingSection',
type: 'reference',
to: [{type: 'section'}],
},
],
},
],
preview: {
select: {
title: 'title',
disabled: 'disabled',
},
prepare(selection) {
const {title, disabled} = selection
return {
title: `${title}`,
subtitle: `Reusable section ${disabled ? '(⚠️ Disabled)' : ''}`,
media: BlockElementIcon,
}
},
},
})

View File

@ -0,0 +1,45 @@
import {defineField} from 'sanity'
import { validateImage } from '../../utils/validation'
export default defineField({
name: 'seo',
title: 'SEO',
type: 'object',
description: 'Optimise content for search engines.',
options: {
collapsed: false,
collapsible: true,
},
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) =>
Rule.max(50).warning('Longer titles may be truncated by search engines'),
description: (
<>
A short and accurate title representative of the content on this page.<br />
If empty, displays the current document title (<i>title</i>).
</>
),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
rows: 2,
validation: (Rule) =>
Rule.max(150).warning('Longer descriptions may be truncated by search engines'),
description: 'A brief description of the content on this page.'
}),
defineField({
name: 'image',
title: 'Image',
type: 'mainImage',
validation: Rule => validateImage(Rule, false),
description: 'A representative image of the content on this page.'
}),
],
validation: (Rule) => Rule.required(),
})

View File

@ -0,0 +1,97 @@
import {defineField} from 'sanity'
import {PackageIcon} from '@sanity/icons'
import {TagIcon, NumberIcon} from '@sanity/icons'
export default defineField({
name: 'slider',
type: 'object',
title: 'Slider',
icon: NumberIcon,
fields: [
{
name: 'disabled',
type: 'boolean',
title: 'Disabled?',
description: 'Set to true to disable this section.',
initialValue: 'false',
validation: (Rule) => Rule.required(),
},
{
name: 'title',
type: 'string',
title: 'Title',
description: 'Title displayed above slider items.',
validation: Rule => Rule.required(),
},
{
name: 'sliderType',
type: 'string',
title: 'Slider type',
initialValue: 'products',
description: 'Select content type to display.',
validation: Rule => Rule.required(),
options: {
list: [
{title: 'Products', value: 'products'},
{title: 'Categories', value: 'categories'},
],
layout: 'radio'
}
},
{
title: 'Products',
name: 'products',
type: 'array',
description: 'Select products to display.',
of: [
{
type: 'reference',
to: [{type: 'product'}],
},
],
validation: Rule => Rule.custom((x:any, context:any) => {
if (context.parent.sliderType == 'products' && context?.parent?.products?.length < 3 || context?.parent?.products?.length > 8) {
return 'Must have between 3 and 8 items'
}
return true
}),
hidden: ({ parent }) => parent?.sliderType !== "products"
},
{
title: 'Categories',
name: 'categories',
type: 'array',
description: 'Select categories to display.',
of: [
{
type: 'reference',
to: [{type: 'category'}],
},
],
validation: Rule => Rule.custom((x:any, context:any) => {
if (context.parent.sliderType == 'categories' && context?.parent?.categories?.length < 3 || context?.parent?.categories?.length > 8) {
return 'Must have between 3 and 8 items'
}
return true
}),
hidden: ({ parent }) => parent?.sliderType !== "categories"
},
],
preview: {
select: {
title: 'title',
sliderType: 'sliderType',
disabled: 'disabled'
},
prepare(selection) {
const {title, sliderType, disabled} = selection
return {
title: `${title}`,
media: sliderType === 'products' ? PackageIcon : TagIcon,
subtitle: `${sliderType === 'products' ? 'Product' : 'Category'} slider ${disabled ? '(⚠️ Disabled)' : ''}`,
}
},
},
})

View File

@ -0,0 +1,63 @@
import {defineField} from 'sanity'
import {StarIcon} from '@sanity/icons'
export default defineField({
name: 'uspSection',
type: 'object',
title: 'USP section',
icon: StarIcon,
fieldsets: [
{
name: 'layoutSettings',
title: 'Layout settings'
},
],
fields: [
{
name: 'disabled',
type: 'boolean',
title: 'Disabled?',
description: 'Set to true to disable this section.',
initialValue: 'false',
validation: (Rule) => Rule.required(),
},
{
name: 'title',
type: 'string',
title: 'Title',
description: 'Title will only be used internally.',
validation: (Rule) => Rule.required(),
},
{
name: 'usps',
type: 'array',
title: 'USPs',
description: 'Create USPs or refer to existing USP.',
of: [
{
type: 'reference',
to: [{
type: 'usp',
}],
},
],
validation: (Rule) => Rule.required().min(2).max(4),
},
],
preview: {
select: {
title: 'title',
disabled: 'disabled'
},
prepare(selection) {
const {title, disabled} = selection
return {
title: `${title}`,
subtitle: `USP section ${disabled ? '(⚠️ Disabled)' : ''}`,
media: StarIcon,
}
},
},
})

View File

@ -0,0 +1,69 @@
import {HomeIcon} from '@sanity/icons'
import {defineField} from 'sanity'
import {languages} from '../../languages'
import {COMPONENT_REFERENCES} from '../../constants'
export default defineField({
name: 'home',
title: 'Home',
type: 'document',
icon: HomeIcon,
groups: [
{
name: 'editorial',
title: 'Editorial',
},
{
name: 'seo',
title: 'SEO',
},
],
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.',
// hidden: true,
}),
// Title
{
name: 'title',
title: 'Title',
type: 'string',
description: 'Page title.',
validation: (Rule) => Rule.required(),
},
defineField({
name: 'content',
title: 'Page sections',
type: 'array',
group: 'editorial',
description: 'Add, reorder, edit or delete page sections.',
of: COMPONENT_REFERENCES,
}),
// SEO
defineField({
name: 'seo',
title: 'SEO',
type: 'seo',
group: 'seo',
}),
],
preview: {
select: {
title: 'title',
language: 'language',
},
prepare(selection) {
const {title, language} = selection
const currentLang = languages.find((lang) => lang.id === language)
return {
title: `${title}`,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,158 @@
import {CogIcon} from '@sanity/icons'
import {defineType, defineField} from 'sanity'
import { languages } from '../../languages'
const TITLE = 'Settings'
export default defineType({
name: 'settings',
title: TITLE,
type: 'document',
icon: CogIcon,
groups: [
{
name: 'notFoundPage',
title: '404 page',
},
{
name: 'socialMedia',
title: 'Social media',
},
{
name: 'usps',
title: 'USPs',
},
{
name: 'contact',
title: 'Contact',
},
{
name: 'seo',
title: 'SEO',
},
],
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.'
// hidden: true,
}),
defineField({
name: 'title',
type: 'string',
title: 'Title',
description: 'Document title.',
}),
// Not found page
defineField({
name: 'notFoundPage',
title: '404 page',
type: 'object',
group: 'notFoundPage',
description: 'Information displayed on 404 page.',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
description: 'Page title displayed on 404 error page.',
}),
defineField({
name: 'body',
title: 'Body',
type: 'text',
rows: 5,
description: 'Text displayed adjacent to the title on 404 error page.'
}),
defineField({
name: 'category',
title: 'Category',
type: 'reference',
description: 'Category of products displayed on 404 error page.',
weak: true,
to: [
{
name: 'category',
type: 'category',
},
],
}),
]
}),
// Contact
defineField({
name: 'contact',
title: 'Contact options',
type: 'object',
group: 'contact',
description: 'Contact options for your business.',
options: {
collapsed: false,
collapsible: true,
},
fields: [
// Selling points
defineField({
name: 'contactOption',
title: 'Options (links)',
description: 'Links, e-mail address and phone numbers.',
type: 'array',
of: [
{type: 'linkExternal'},
],
}),
],
}),
// Social media links
defineField({
name: 'socialMedia',
title: 'Social Media',
type: 'object',
group: 'socialMedia',
description: "Links to your business's social media accounts",
options: {
collapsed: false,
collapsible: true,
},
fields: [
// Links
defineField({
name: 'links',
title: 'Links',
type: 'array',
description: 'Facebook, Twitter and Instgram etc.',
of: [
{type: 'linkExternal'},
],
}),
],
}),
// SEO
defineField({
name: 'seo',
title: 'SEO',
type: 'seo',
group: 'seo',
description: 'Default SEO displayed for every page unless overwritten on page/document level.',
}),
],
preview: {
select: {
title: 'title',
language: 'language'
},
prepare(selection) {
const {title, language} = selection;
const currentLang = languages.find(lang => lang.id === language);
return {
title: `${title}`,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,48 @@
import {MenuIcon} from '@sanity/icons'
import {defineType, defineField} from 'sanity'
import { languages } from '../../languages'
export default defineType({
name: 'utilityMenu',
title: 'Utility menu',
type: 'document',
icon: MenuIcon,
groups: [],
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
description: 'Language of this document.'
// hidden: true,
}),
defineField({
name: 'title',
type: 'string',
title: 'Title',
description: 'Menu title or designation for menu.',
}),
// Menu
defineField({
name: 'menu',
title: 'Menu',
type: 'menu',
})
],
preview: {
select: {
title: 'title',
language: 'language'
},
prepare(selection) {
const {title, language} = selection;
const currentLang = languages.find(lang => lang.id === language);
return {
title: `${title}`,
subtitle: `${currentLang ? currentLang.title : ''}`,
}
},
},
})

View File

@ -0,0 +1,11 @@
import {ConfigContext} from 'sanity'
import {StructureBuilder} from 'sanity/desk'
/**
* Helper for creating and typing composable desk structure parts.
*/
export default function defineStructure<StructureType>(
factory: (S: StructureBuilder, context: ConfigContext) => StructureType
) {
return factory
}

View File

@ -0,0 +1,19 @@
import {isDev, SanityDocument} from 'sanity'
import { localStorefrontPreviewUrl, publicStorefrontPreviewUrl } from '../constants'
// Customise this function to show the correct URL based on the current document
export default function getPreviewUrl(doc: SanityDocument) {
if (isDev) {
if (!doc.slug) {
return
}
return `${localStorefrontPreviewUrl}?slug=${doc.slug.current}&locale=${doc.language}&secret=secret&type=${doc._type}`
} else {
if (!doc.slug) {
return
}
return `${publicStorefrontPreviewUrl}?slug=${doc.slug.current}&locale=${doc.language}&secret=secret&type=${doc._type}`
}
}

View File

@ -0,0 +1,59 @@
import {Rule, Slug} from 'sanity'
import slug from 'slug'
// SLUG VALIDATION
export const validateSlug = (Rule: Rule) => {
const MAX_LENGTH = 96
return Rule.required().custom(async(value: Slug) => {
const currentSlug = value && value.current
if (!currentSlug) {
return true
}
if (currentSlug.length >= MAX_LENGTH) {
return `Must be less than ${MAX_LENGTH} characters.`
}
if (currentSlug !== slug(currentSlug, {lower: true})) {
return 'Must be a valid slug.'
}
return true
})
}
// IMAGE VALIDATION
export const validateImage = (Rule: Rule, isRequired: boolean = false) => {
if (isRequired) {
return Rule.required().custom((value: object | any) => {
const currentImage = value && value.asset;
const currentImageAlt = value && value.alt;
if (!currentImage) {
return true
}
if (!currentImageAlt) {
return "Image and alt text is required."
}
return true
})
} else {
return Rule.custom((value: object | any) => {
const currentImage = value && value.asset;
const currentImageAlt = value && value.alt;
if (!currentImage) {
return true
}
if (currentImage && !currentImageAlt) {
return "Alt text is required."
}
return true
})
}
}

View File

@ -12,5 +12,5 @@ export default createMiddleware({
export const config = {
// Skip all paths that should not be internationalized
matcher: ['/((?!api|_next|.*\\..*).*)']
matcher: ['/((?!api|studio|_next|.*\\..*).*)']
};

View File

@ -31,6 +31,9 @@
"@sanity/types": "^3.11.1",
"@sanity/ui": "^1.3.3",
"@sanity/webhook": "^2.0.0",
"@sanity/color-input": "^3.0.2",
"@sanity/document-internationalization": "^2.0.1",
"@sanity/vision": "^3.0.0",
"@types/styled-components": "^5.1.26",
"@vercel/og": "^0.1.0",
"algoliasearch": "^4.19.1",
@ -47,7 +50,11 @@
"react-glider": "^4.0.2",
"react-instantsearch": "^7.0.1",
"sanity": "^3.11.1",
"sanity-plugin-iframe-pane": "^2.3.0",
"sanity-plugin-media": "^2.0.4",
"sharp": "^0.32.1",
"slug": "^8.2.2",
"slugify": "^1.6.5",
"styled-components": "^5.3.10",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"

959
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

75
sanity.config.ts Normal file
View File

@ -0,0 +1,75 @@
import {defineConfig, isDev} from 'sanity'
import {deskTool} from 'sanity/desk'
import {visionTool} from '@sanity/vision'
import { colorInput } from "@sanity/color-input";
import {media} from 'sanity-plugin-media'
import {schemaTypes} from '@/lib/sanity/schemas'
import {structure} from '@/lib/sanity/desk'
import Kodamera from '@/lib/sanity/components/icons/kodamera'
import {documentInternationalization} from '@sanity/document-internationalization'
const devOnlyPlugins = [visionTool()]
// Define the actions that should be available for singleton documents
const singletonActions = new Set(["publish", "discardChanges", "restore"])
// Define the singleton document types
const singletonTypes = new Set(["settings", "home", "utilityMenu", "media.tag"])
export default defineConfig({
name: 'default',
title: 'KM Storefront CMS',
projectId: 'opfmivlh',
basePath: '/studio',
dataset: 'production',
plugins: [
deskTool({structure}),
media(),
...(isDev ? devOnlyPlugins : []),
documentInternationalization({
// Required, either:
// An array of supported languages
supportedLanguages: [
{id: 'sv', title: 'Swedish'},
{id: 'en', title: 'English'}
],
// Required
schemaTypes: [
'home',
'page',
'product',
'category',
'settings',
'blurb',
'section',
'usp',
'footerMenu',
'utilityMenu'
],
// Optional
// languageField: `language`, // defauts to "language"
// Optional, requires access to the Publishing API
// bulkPublish: true // defaults to false
}),
colorInput(),
],
schema: {
types: schemaTypes,
// Filter out singleton types from the global “New document” menu options
templates: (templates) =>
templates.filter(({ schemaType }) => !singletonTypes.has(schemaType)),
},
document: {
// For singleton types, filter out actions that are not explicitly included
// in the `singletonActions` list defined above
actions: (input, context) =>
singletonTypes.has(context.schemaType)
? input.filter(({ action }) => action && singletonActions.has(action))
: input,
},
studio: {
components: {
logo: Kodamera,
}
},
})