├── .eslintrc ├── static └── .gitkeep ├── .env.md ├── sanity.cli.ts ├── desk ├── collectionStructure.ts ├── colorThemeStructure.ts ├── homeStructure.ts ├── settingStructure.ts ├── pageStructure.ts ├── index.ts └── productStructure.ts ├── schemas ├── objects │ ├── shopify │ │ ├── placeholderString.ts │ │ ├── proxyString.ts │ │ ├── priceRange.ts │ │ ├── inventory.ts │ │ ├── option.tsx │ │ ├── shopifyCollectionRule.tsx │ │ ├── shopifyCollection.ts │ │ ├── shopifyProductVariant.ts │ │ ├── shopifyProduct.ts │ │ └── productWithVariant.tsx │ ├── seo │ │ ├── description.tsx │ │ ├── home.tsx │ │ ├── seo.ts │ │ ├── page.tsx │ │ └── shopify.tsx │ ├── global │ │ ├── links.ts │ │ ├── menu.ts │ │ ├── notFoundPage.ts │ │ ├── linkExternal.ts │ │ ├── footer.ts │ │ └── linkInternal.ts │ ├── collection │ │ ├── links.ts │ │ └── group.ts │ ├── hotspot │ │ ├── productHotspots.tsx │ │ ├── imageWithProductHotspots.ts │ │ └── spot.tsx │ ├── module │ │ ├── imageCallToAction.tsx │ │ ├── grid.ts │ │ ├── accordion.ts │ │ ├── accordionGroup.ts │ │ ├── instagram.ts │ │ ├── accordionBody.ts │ │ ├── callout.ts │ │ ├── product.tsx │ │ ├── products.tsx │ │ ├── collection.tsx │ │ ├── images.tsx │ │ ├── gridItem.ts │ │ ├── callToAction.tsx │ │ └── image.ts │ ├── hero │ │ ├── page.tsx │ │ ├── collection.tsx │ │ └── home.tsx │ └── customProductOption │ │ ├── sizeObject.ts │ │ ├── colorObject.tsx │ │ ├── size.ts │ │ └── color.tsx ├── annotations │ ├── linkEmail.tsx │ ├── linkInternal.tsx │ ├── linkExternal.tsx │ └── product.tsx ├── singletons │ ├── home.ts │ └── settings.ts ├── documents │ ├── colorTheme.tsx │ ├── productVariant.tsx │ ├── page.ts │ ├── product.tsx │ └── collection.tsx ├── blocks │ └── body.tsx └── index.ts ├── utils ├── defineStructure.ts ├── validateSlug.ts ├── blocksToText.ts ├── shopifyUrls.ts └── getPriceRange.ts ├── plugins └── customDocumentActions │ ├── types.ts │ ├── shopifyLink.ts │ ├── index.ts │ └── shopifyDelete.tsx ├── .gitignore ├── tsconfig.json ├── components ├── inputs │ ├── PlaceholderString.tsx │ ├── ProductVariantHidden.tsx │ ├── CollectionHidden.tsx │ ├── ProxyString.tsx │ └── ProductHidden.tsx ├── media │ ├── ColorTheme.tsx │ └── ShopifyDocumentStatus.tsx ├── hotspots │ └── ProductTooltip.tsx └── icons │ └── Shopify.tsx ├── constants.ts ├── sanity.config.ts ├── package.json ├── README.md ├── docs └── features.md └── migrations └── strictSchema.ts /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/eslint-config-studio" 3 | } 4 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- 1 | Files placed here will be served by the Sanity server under the `/static`-prefix 2 | -------------------------------------------------------------------------------- /.env.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | SANITY_STUDIO_PROJECT_ID= 4 | SANITY_STUDIO_PROJECT_DATASET= 5 | -------------------------------------------------------------------------------- /sanity.cli.ts: -------------------------------------------------------------------------------- 1 | import {defineCliConfig} from 'sanity/cli' 2 | 3 | export default defineCliConfig({ 4 | api: { 5 | projectId: process.env.SANITY_STUDIO_PROJECT_ID || 'g2b4qblu', 6 | dataset: process.env.SANITY_STUDIO_PROJECT_DATASET || 'production', 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /desk/collectionStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/desk' 2 | import defineStructure from '../utils/defineStructure' 3 | 4 | export default defineStructure((S) => 5 | S.listItem().title('Collections').schemaType('collection').child(S.documentTypeList('collection')) 6 | ) 7 | -------------------------------------------------------------------------------- /schemas/objects/shopify/placeholderString.ts: -------------------------------------------------------------------------------- 1 | import PlaceholderStringInput from '../../../components/inputs/PlaceholderString' 2 | 3 | export default { 4 | name: 'placeholderString', 5 | title: 'Title', 6 | type: 'string', 7 | components: { 8 | input: PlaceholderStringInput, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /schemas/objects/seo/description.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'seo.description', 5 | title: 'Description', 6 | type: 'text', 7 | rows: 3, 8 | validation: (Rule) => 9 | Rule.max(150).warning('Longer descriptions may be truncated by search engines'), 10 | }) 11 | -------------------------------------------------------------------------------- /desk/colorThemeStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/desk' 2 | import defineStructure from '../utils/defineStructure' 3 | 4 | export default defineStructure((S) => 5 | S.listItem() 6 | .title('Color themes') 7 | .schemaType('colorTheme') 8 | .child(S.documentTypeList('colorTheme')) 9 | ) 10 | -------------------------------------------------------------------------------- /schemas/objects/shopify/proxyString.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | import ProxyStringInput from '../../../components/inputs/ProxyString' 4 | 5 | export default defineField({ 6 | name: 'proxyString', 7 | title: 'Title', 8 | type: 'string', 9 | components: { 10 | input: ProxyStringInput, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /desk/homeStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/desk' 2 | import defineStructure from '../utils/defineStructure' 3 | 4 | export default defineStructure((S) => 5 | S.listItem() 6 | .title('Home') 7 | .schemaType('home') 8 | .child(S.editor().title('Home').schemaType('home').documentId('home')) 9 | ) 10 | -------------------------------------------------------------------------------- /desk/settingStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/desk' 2 | import defineStructure from '../utils/defineStructure' 3 | 4 | export default defineStructure((S) => 5 | S.listItem() 6 | .title('Settings') 7 | .schemaType('settings') 8 | .child(S.editor().title('Settings').schemaType('settings').documentId('settings')) 9 | ) 10 | -------------------------------------------------------------------------------- /utils/defineStructure.ts: -------------------------------------------------------------------------------- 1 | import {ConfigContext} from 'sanity' 2 | import {StructureBuilder} from 'sanity/desk' 3 | 4 | /** 5 | * Helper for creating and typing composable desk structure parts. 6 | */ 7 | export default function defineStructure( 8 | factory: (S: StructureBuilder, context: ConfigContext) => StructureType 9 | ) { 10 | return factory 11 | } 12 | -------------------------------------------------------------------------------- /desk/pageStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/desk' 2 | import defineStructure from '../utils/defineStructure' 3 | import {DocumentsIcon} from '@sanity/icons' 4 | 5 | export default defineStructure((S) => 6 | S.listItem() 7 | .title('Pages') 8 | .icon(DocumentsIcon) 9 | .schemaType('page') 10 | .child(S.documentTypeList('page')) 11 | ) 12 | -------------------------------------------------------------------------------- /schemas/objects/global/links.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'menuLinks', 5 | title: 'menuLinks', 6 | type: 'array', 7 | of: [ 8 | { 9 | name: 'collectionGroup', 10 | title: 'Collection group', 11 | type: 'collectionGroup', 12 | }, 13 | {type: 'linkInternal'}, 14 | {type: 'linkExternal'}, 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /schemas/objects/collection/links.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'collectionLinks', 5 | title: 'Collection links', 6 | type: 'array', 7 | validation: (Rule) => Rule.unique().max(4), 8 | of: [ 9 | { 10 | name: 'collection', 11 | type: 'reference', 12 | weak: true, 13 | to: [{type: 'collection'}], 14 | }, 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /schemas/objects/global/menu.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'menuSettings', 5 | title: 'Menu', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | fields: [ 12 | // Links 13 | defineField({ 14 | name: 'links', 15 | title: 'Links', 16 | type: 'menuLinks', 17 | }), 18 | ], 19 | }) 20 | -------------------------------------------------------------------------------- /plugins/customDocumentActions/types.ts: -------------------------------------------------------------------------------- 1 | import {type DocumentActionProps, type DocumentActionDescription, type SanityDocument} from 'sanity' 2 | 3 | export type ShopifyDocument = SanityDocument & { 4 | store: { 5 | id: number 6 | productId: number 7 | isDeleted: boolean 8 | } 9 | } 10 | 11 | export interface ShopifyDocumentActionProps extends DocumentActionProps { 12 | published: ShopifyDocument 13 | draft: ShopifyDocument 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # Compiled Sanity Studio 9 | /dist 10 | 11 | # Temporary Sanity runtime, generated by the CLI on every dev server start 12 | /.sanity 13 | 14 | # Logs 15 | /logs 16 | *.log 17 | 18 | # Coverage directory used by testing tools 19 | /coverage 20 | 21 | # Misc 22 | .DS_Store 23 | *.pem 24 | .vscode 25 | 26 | # Typescript 27 | *.tsbuildinfo 28 | -------------------------------------------------------------------------------- /schemas/objects/hotspot/productHotspots.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | import ProductTooltip from '../../../components/hotspots/ProductTooltip' 4 | 5 | export default defineField({ 6 | name: 'productHotspots', 7 | title: 'Hotspots', 8 | type: 'array', 9 | of: [ 10 | { 11 | type: 'spot', 12 | }, 13 | ], 14 | options: { 15 | imageHotspot: { 16 | imagePath: 'image', 17 | tooltip: ProductTooltip, 18 | pathRoot: 'parent', 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /schemas/objects/shopify/priceRange.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'priceRange', 5 | title: 'Price range', 6 | type: 'object', 7 | options: { 8 | columns: 2, 9 | }, 10 | fields: [ 11 | { 12 | name: 'minVariantPrice', 13 | title: 'Min variant price', 14 | type: 'number', 15 | }, 16 | { 17 | name: 'maxVariantPrice', 18 | title: 'Max variant price', 19 | type: 'number', 20 | }, 21 | ], 22 | }) 23 | -------------------------------------------------------------------------------- /schemas/objects/module/imageCallToAction.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'imageCallToAction', 5 | title: 'Call to action', 6 | type: 'object', 7 | fields: [ 8 | // Title 9 | { 10 | name: 'title', 11 | title: 'Title', 12 | type: 'string', 13 | }, 14 | // Link 15 | { 16 | name: 'links', 17 | title: 'Link', 18 | type: 'array', 19 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 20 | validation: (Rule) => Rule.max(1), 21 | }, 22 | ], 23 | }) 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /utils/validateSlug.ts: -------------------------------------------------------------------------------- 1 | import {Rule, Slug} from 'sanity' 2 | import slug from 'slug' 3 | 4 | const MAX_LENGTH = 96 5 | 6 | export const validateSlug = (Rule: Rule) => { 7 | return Rule.required().custom(async (value: Slug) => { 8 | const currentSlug = value && value.current 9 | if (!currentSlug) { 10 | return true 11 | } 12 | 13 | if (currentSlug.length >= MAX_LENGTH) { 14 | return `Must be less than ${MAX_LENGTH} characters` 15 | } 16 | 17 | if (currentSlug !== slug(currentSlug, {lower: true})) { 18 | return 'Must be a valid slug' 19 | } 20 | return true 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /schemas/objects/shopify/inventory.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'inventory', 5 | title: 'Inventory', 6 | type: 'object', 7 | options: { 8 | columns: 3, 9 | }, 10 | fields: [ 11 | // Available 12 | { 13 | name: 'isAvailable', 14 | title: 'Available', 15 | type: 'boolean', 16 | }, 17 | // Management 18 | { 19 | name: 'management', 20 | title: 'Management', 21 | type: 'string', 22 | }, 23 | // Policy 24 | { 25 | name: 'policy', 26 | title: 'Policy', 27 | type: 'string', 28 | }, 29 | ], 30 | }) 31 | -------------------------------------------------------------------------------- /utils/blocksToText.ts: -------------------------------------------------------------------------------- 1 | import type {PortableTextBlock} from '@portabletext/types' 2 | 3 | const defaults = {nonTextBehavior: 'remove'} 4 | 5 | export default function (blocks: PortableTextBlock[] = [], opts = {}) { 6 | if (typeof blocks === 'string') { 7 | return blocks 8 | } 9 | 10 | const options = Object.assign({}, defaults, opts) 11 | return blocks 12 | .map((block) => { 13 | if (block._type !== 'block' || !block.children) { 14 | return options.nonTextBehavior === 'remove' ? '' : `[${block._type} block]` 15 | } 16 | 17 | return block.children.map((child) => child.text).join('') 18 | }) 19 | .join('\n\n') 20 | } 21 | -------------------------------------------------------------------------------- /components/inputs/PlaceholderString.tsx: -------------------------------------------------------------------------------- 1 | import {StringInputProps, useFormValue, SanityDocument, StringSchemaType} from 'sanity' 2 | import get from 'lodash.get' 3 | 4 | type Props = StringInputProps 5 | 6 | const PlaceholderStringInput = (props: Props) => { 7 | const {schemaType} = props 8 | 9 | const path = schemaType?.options?.field 10 | const doc = useFormValue([]) as SanityDocument 11 | 12 | const proxyValue = path ? (get(doc, path) as string) : '' 13 | 14 | return props.renderDefault({ 15 | ...props, 16 | elementProps: {...props.elementProps, placeholder: proxyValue}, 17 | }) 18 | } 19 | 20 | export default PlaceholderStringInput 21 | -------------------------------------------------------------------------------- /utils/shopifyUrls.ts: -------------------------------------------------------------------------------- 1 | import {SHOPIFY_STORE_ID} from '../constants' 2 | 3 | const storeUrl = `https://admin.shopify.com/store/${SHOPIFY_STORE_ID}` 4 | 5 | export const collectionUrl = (collectionId: number) => { 6 | if (!SHOPIFY_STORE_ID) { 7 | return null 8 | } 9 | return `${storeUrl}/collections/${collectionId}` 10 | } 11 | 12 | export const productUrl = (productId: number) => { 13 | if (!SHOPIFY_STORE_ID) { 14 | return null 15 | } 16 | return `${storeUrl}/products/${productId}` 17 | } 18 | 19 | export const productVariantUrl = (productId: number, productVariantId: number) => { 20 | if (!SHOPIFY_STORE_ID) { 21 | return null 22 | } 23 | return `${storeUrl}/products/${productId}/variants/${productVariantId}` 24 | } 25 | -------------------------------------------------------------------------------- /utils/getPriceRange.ts: -------------------------------------------------------------------------------- 1 | import {DEFAULT_CURRENCY_CODE} from '../constants' 2 | 3 | type PriceObject = { 4 | minVariantPrice: number 5 | maxVariantPrice: number 6 | } 7 | 8 | const formatNumber = (val: number) => { 9 | return new Intl.NumberFormat('en', { 10 | currency: DEFAULT_CURRENCY_CODE, 11 | style: 'currency', 12 | }).format(val) 13 | } 14 | 15 | export const getPriceRange = (price: PriceObject) => { 16 | if (!price || typeof price?.minVariantPrice === 'undefined') { 17 | return 'No price found' 18 | } 19 | if (price.maxVariantPrice && price.minVariantPrice !== price.maxVariantPrice) { 20 | return `${formatNumber(price.minVariantPrice)} – ${formatNumber(price.maxVariantPrice)}` 21 | } 22 | 23 | return formatNumber(price.minVariantPrice) 24 | } 25 | -------------------------------------------------------------------------------- /schemas/objects/shopify/option.tsx: -------------------------------------------------------------------------------- 1 | import {SunIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | title: 'Product option', 6 | name: 'option', 7 | type: 'object', 8 | icon: SunIcon, 9 | readOnly: true, 10 | fields: [ 11 | // Name 12 | { 13 | title: 'Name', 14 | name: 'name', 15 | type: 'string', 16 | }, 17 | // Values 18 | { 19 | title: 'Values', 20 | name: 'values', 21 | type: 'array', 22 | of: [{type: 'string'}], 23 | }, 24 | ], 25 | preview: { 26 | select: { 27 | name: 'name', 28 | }, 29 | prepare(selection) { 30 | const {name} = selection 31 | 32 | return { 33 | title: name, 34 | } 35 | }, 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /schemas/objects/seo/home.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'seo.home', 5 | title: 'SEO', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | fields: [ 12 | defineField({ 13 | name: 'title', 14 | title: 'Title', 15 | type: 'string', 16 | validation: (Rule) => 17 | Rule.max(50).warning('Longer titles may be truncated by search engines'), 18 | }), 19 | defineField({ 20 | name: 'description', 21 | title: 'Description', 22 | type: 'seo.description', 23 | }), 24 | defineField({ 25 | name: 'image', 26 | title: 'Image', 27 | type: 'image', 28 | }), 29 | ], 30 | validation: (Rule) => Rule.required(), 31 | }) 32 | -------------------------------------------------------------------------------- /schemas/objects/seo/seo.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'seo', 5 | title: 'SEO', 6 | type: 'object', 7 | group: 'seo', 8 | description: 'Defaults for every page', 9 | options: { 10 | collapsed: false, 11 | collapsible: true, 12 | }, 13 | fields: [ 14 | defineField({ 15 | name: 'title', 16 | title: 'Site title', 17 | type: 'string', 18 | validation: (Rule) => Rule.required(), 19 | }), 20 | defineField({ 21 | name: 'description', 22 | title: 'Description', 23 | type: 'text', 24 | rows: 2, 25 | validation: (Rule) => 26 | Rule.max(150).warning('Longer descriptions may be truncated by search engines'), 27 | }), 28 | ], 29 | validation: (Rule) => Rule.required(), 30 | }) 31 | -------------------------------------------------------------------------------- /components/inputs/ProductVariantHidden.tsx: -------------------------------------------------------------------------------- 1 | import {WarningOutlineIcon} from '@sanity/icons' 2 | import {Box, Card, Flex, Stack, Text} from '@sanity/ui' 3 | 4 | export default function ProductVariantHiddenInput() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | This variant is hidden 15 | 16 | 17 | 18 | It has been deleted from Shopify 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /schemas/objects/collection/group.ts: -------------------------------------------------------------------------------- 1 | import {PackageIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | name: 'collectionGroup', 6 | title: 'Collection group', 7 | type: 'object', 8 | icon: PackageIcon, 9 | fields: [ 10 | { 11 | name: 'title', 12 | title: 'Title', 13 | type: 'string', 14 | validation: (Rule) => Rule.required(), 15 | }, 16 | { 17 | name: 'collectionLinks', 18 | title: 'Collection links', 19 | type: 'collectionLinks', 20 | }, 21 | { 22 | name: 'collectionProducts', 23 | title: 'Collection products', 24 | type: 'reference', 25 | description: 'Products from this collection will be listed', 26 | weak: true, 27 | to: [{type: 'collection'}], 28 | }, 29 | ], 30 | }) 31 | -------------------------------------------------------------------------------- /components/inputs/CollectionHidden.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {WarningOutlineIcon} from '@sanity/icons' 3 | import {StringFieldProps} from 'sanity' 4 | import {Box, Card, Flex, Stack, Text} from '@sanity/ui' 5 | 6 | export default function CollectionHiddenInput(props: StringFieldProps) { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | This collection is hidden 16 | 17 | 18 | It has been deleted from Shopify. 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /schemas/objects/module/grid.ts: -------------------------------------------------------------------------------- 1 | import {ThLargeIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export default defineField({ 6 | name: 'module.grid', 7 | title: 'Grid', 8 | type: 'object', 9 | icon: ThLargeIcon, 10 | fields: [ 11 | // Items 12 | { 13 | name: 'items', 14 | title: 'Items', 15 | type: 'array', 16 | of: [ 17 | { 18 | type: 'gridItem', 19 | }, 20 | ], 21 | }, 22 | ], 23 | preview: { 24 | select: { 25 | items: 'items', 26 | url: 'url', 27 | }, 28 | prepare(selection) { 29 | const {items} = selection 30 | return { 31 | subtitle: 'Grid', 32 | title: items?.length > 0 ? pluralize('item', items.length, true) : 'No items', 33 | } 34 | }, 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /schemas/objects/hero/page.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'hero.page', 5 | title: 'Page hero', 6 | type: 'object', 7 | fields: [ 8 | // Title 9 | defineField({ 10 | name: 'title', 11 | title: 'Title', 12 | type: 'text', 13 | rows: 3, 14 | }), 15 | // Content 16 | defineField({ 17 | name: 'content', 18 | title: 'Content', 19 | type: 'array', 20 | validation: (Rule) => Rule.max(1), 21 | of: [ 22 | { 23 | name: 'productWithVariant', 24 | title: 'Product with variant', 25 | type: 'productWithVariant', 26 | }, 27 | { 28 | name: 'imageWithProductHotspots', 29 | title: 'Image', 30 | type: 'imageWithProductHotspots', 31 | }, 32 | ], 33 | }), 34 | ], 35 | }) 36 | -------------------------------------------------------------------------------- /schemas/objects/module/accordion.ts: -------------------------------------------------------------------------------- 1 | import {StackCompactIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export default defineField({ 6 | name: 'module.accordion', 7 | title: 'Accordion', 8 | type: 'object', 9 | icon: StackCompactIcon, 10 | fields: [ 11 | // Groups 12 | defineField({ 13 | name: 'groups', 14 | title: 'Groups', 15 | type: 'array', 16 | of: [ 17 | { 18 | type: 'accordionGroup', 19 | }, 20 | ], 21 | }), 22 | ], 23 | preview: { 24 | select: { 25 | groups: 'groups', 26 | }, 27 | prepare(selection) { 28 | const {groups} = selection 29 | return { 30 | subtitle: 'Accordion', 31 | title: groups?.length > 0 ? pluralize('group', groups.length, true) : 'No groups', 32 | } 33 | }, 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /schemas/objects/module/accordionGroup.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | import blocksToText from '../../../utils/blocksToText' 3 | 4 | export default defineField({ 5 | name: 'accordionGroup', 6 | title: 'Object', 7 | type: 'object', 8 | icon: false, 9 | fields: [ 10 | defineField({ 11 | name: 'title', 12 | title: 'Title', 13 | type: 'string', 14 | validation: (Rule) => Rule.required(), 15 | }), 16 | defineField({ 17 | name: 'body', 18 | title: 'Body', 19 | type: 'accordionBody', 20 | validation: (Rule) => Rule.required(), 21 | }), 22 | ], 23 | preview: { 24 | select: { 25 | body: 'body', 26 | title: 'title', 27 | }, 28 | prepare(selection) { 29 | const {body, title} = selection 30 | return { 31 | subtitle: body && blocksToText(body), 32 | title, 33 | } 34 | }, 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /components/media/ColorTheme.tsx: -------------------------------------------------------------------------------- 1 | import styled, {css} from 'styled-components' 2 | 3 | interface Props { 4 | background?: string 5 | text?: string 6 | } 7 | 8 | interface StyledSpanProps { 9 | background?: string 10 | } 11 | 12 | const StyledSpan = styled.span(({background}) => { 13 | const bg = background || 'white' 14 | 15 | return css` 16 | align-items: center; 17 | background-color: ${bg}; 18 | border-radius: inherit; 19 | display: flex; 20 | height: 100%; 21 | justify-content: center; 22 | overflow: hidden; 23 | width: 100%; 24 | ` 25 | }) 26 | 27 | const ColorThemePreview = (props: Props) => { 28 | const {background, text} = props 29 | 30 | return ( 31 | 32 | {text && T} 33 | 34 | ) 35 | } 36 | 37 | export default ColorThemePreview 38 | -------------------------------------------------------------------------------- /schemas/objects/module/instagram.ts: -------------------------------------------------------------------------------- 1 | import {UserIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | name: 'module.instagram', 6 | title: 'Instagram', 7 | type: 'object', 8 | icon: UserIcon, 9 | fields: [ 10 | defineField({ 11 | name: 'url', 12 | title: 'URL', 13 | type: 'string', 14 | validation: (Rule) => 15 | Rule.custom((url) => { 16 | const pattern = /(https?:\/\/(?:www\.)?instagram\.com\/p\/([^/?#&]+)).*/g 17 | const isValid = url?.match(pattern) 18 | return isValid ? true : 'Not a valid Instagram post URL' 19 | }), 20 | }), 21 | ], 22 | preview: { 23 | select: { 24 | url: 'url', 25 | }, 26 | prepare(selection) { 27 | const {url} = selection 28 | return { 29 | subtitle: 'Instagram', 30 | title: url, 31 | media: UserIcon, 32 | } 33 | }, 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /schemas/objects/seo/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | name: 'seo.page', 6 | title: 'SEO', 7 | type: 'object', 8 | options: { 9 | collapsed: false, 10 | collapsible: true, 11 | }, 12 | fields: [ 13 | defineField({ 14 | name: 'title', 15 | title: 'Title', 16 | type: 'placeholderString', 17 | description: ( 18 | <> 19 | If empty, displays the document title (title) 20 | 21 | ), 22 | options: {field: 'title'}, 23 | validation: (Rule) => 24 | Rule.max(50).warning('Longer titles may be truncated by search engines'), 25 | }), 26 | defineField({ 27 | name: 'description', 28 | title: 'Description', 29 | type: 'seo.description', 30 | }), 31 | defineField({ 32 | name: 'image', 33 | title: 'Image', 34 | type: 'image', 35 | }), 36 | ], 37 | }) 38 | -------------------------------------------------------------------------------- /components/inputs/ProxyString.tsx: -------------------------------------------------------------------------------- 1 | import {LockIcon} from '@sanity/icons' 2 | import {Box, Text, TextInput, Tooltip} from '@sanity/ui' 3 | import {StringInputProps, useFormValue, SanityDocument, StringSchemaType} from 'sanity' 4 | import get from 'lodash.get' 5 | 6 | type Props = StringInputProps 7 | 8 | const ProxyString = (props: Props) => { 9 | const {schemaType} = props 10 | 11 | const path = schemaType?.options?.field 12 | const doc = useFormValue([]) as SanityDocument 13 | 14 | const proxyValue = path ? (get(doc, path) as string) : '' 15 | 16 | return ( 17 | 20 | 21 | This value is set in Shopify ({path}) 22 | 23 | 24 | } 25 | portal 26 | > 27 | 28 | 29 | ) 30 | } 31 | 32 | export default ProxyString 33 | -------------------------------------------------------------------------------- /schemas/objects/seo/shopify.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | name: 'seo.shopify', 6 | title: 'SEO', 7 | type: 'object', 8 | description: <>, 9 | options: { 10 | collapsed: false, 11 | collapsible: true, 12 | }, 13 | fields: [ 14 | { 15 | name: 'title', 16 | title: 'Title', 17 | type: 'placeholderString', 18 | description: ( 19 | <> 20 | If empty, displays the default Shopify document title (store.title) 21 | 22 | ), 23 | options: { 24 | field: 'store.title', 25 | }, 26 | validation: (Rule) => 27 | Rule.max(50).warning('Longer titles may be truncated by search engines'), 28 | }, 29 | { 30 | name: 'description', 31 | title: 'Description', 32 | type: 'seo.description', 33 | }, 34 | { 35 | name: 'image', 36 | title: 'Image', 37 | type: 'image', 38 | }, 39 | ], 40 | }) 41 | -------------------------------------------------------------------------------- /schemas/annotations/linkEmail.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Annotations are ways of marking up text in the block content editor. 3 | * 4 | * Read more: https://www.sanity.io/docs/customization#f924645007e1 5 | */ 6 | import {EnvelopeIcon} from '@sanity/icons' 7 | import React from 'react' 8 | import {defineField} from 'sanity' 9 | 10 | export default defineField({ 11 | title: 'Email link', 12 | name: 'annotationLinkEmail', 13 | type: 'object', 14 | icon: EnvelopeIcon, 15 | components: { 16 | annotation: (props) => ( 17 | 18 | 25 | {props.renderDefault(props)} 26 | 27 | ), 28 | }, 29 | fields: [ 30 | // Email 31 | { 32 | title: 'Email', 33 | name: 'email', 34 | type: 'email', 35 | }, 36 | ], 37 | preview: { 38 | select: { 39 | email: 'email', 40 | }, 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /schemas/objects/hero/collection.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'hero.collection', 5 | title: 'Collection hero', 6 | type: 'object', 7 | fields: [ 8 | // Title 9 | defineField({ 10 | name: 'title', 11 | title: 'Title', 12 | type: 'text', 13 | rows: 3, 14 | }), 15 | // Description 16 | defineField({ 17 | name: 'description', 18 | title: 'Description', 19 | type: 'text', 20 | rows: 3, 21 | }), 22 | // Content 23 | defineField({ 24 | name: 'content', 25 | title: 'Content', 26 | type: 'array', 27 | validation: (Rule) => Rule.max(1), 28 | of: [ 29 | { 30 | name: 'productWithVariant', 31 | title: 'Product with variant', 32 | type: 'productWithVariant', 33 | }, 34 | { 35 | name: 'imageWithProductHotspots', 36 | title: 'Image', 37 | type: 'imageWithProductHotspots', 38 | }, 39 | ], 40 | }), 41 | ], 42 | }) 43 | -------------------------------------------------------------------------------- /schemas/objects/global/notFoundPage.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'notFoundPage', 5 | title: '404 page', 6 | type: 'object', 7 | group: 'notFoundPage', 8 | fields: [ 9 | defineField({ 10 | name: 'title', 11 | title: 'Title', 12 | type: 'string', 13 | validation: (Rule) => Rule.required(), 14 | }), 15 | defineField({ 16 | name: 'body', 17 | title: 'Body', 18 | type: 'text', 19 | rows: 2, 20 | }), 21 | defineField({ 22 | name: 'collection', 23 | title: 'Collection', 24 | type: 'reference', 25 | description: 'Collection products displayed on this page', 26 | weak: true, 27 | to: [ 28 | { 29 | name: 'collection', 30 | type: 'collection', 31 | }, 32 | ], 33 | }), 34 | // Color theme 35 | defineField({ 36 | name: 'colorTheme', 37 | title: 'Color theme', 38 | type: 'reference', 39 | to: [{type: 'colorTheme'}], 40 | }), 41 | ], 42 | }) 43 | -------------------------------------------------------------------------------- /schemas/annotations/linkInternal.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Annotations are ways of marking up text in the block content editor. 3 | * 4 | * Read more: https://www.sanity.io/docs/customization#f924645007e1 5 | */ 6 | import {LinkIcon} from '@sanity/icons' 7 | import React from 'react' 8 | import {defineField} from 'sanity' 9 | import {PAGE_REFERENCES} from '../../constants' 10 | 11 | export default defineField({ 12 | title: 'Internal Link', 13 | name: 'annotationLinkInternal', 14 | type: 'object', 15 | icon: LinkIcon, 16 | components: { 17 | annotation: (props) => ( 18 | 19 | 26 | {props.renderDefault(props)} 27 | 28 | ), 29 | }, 30 | fields: [ 31 | // Reference 32 | { 33 | name: 'reference', 34 | type: 'reference', 35 | weak: true, 36 | validation: (Rule) => Rule.required(), 37 | to: PAGE_REFERENCES, 38 | }, 39 | ], 40 | }) 41 | -------------------------------------------------------------------------------- /schemas/objects/shopify/shopifyCollectionRule.tsx: -------------------------------------------------------------------------------- 1 | import {FilterIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | title: 'Collection rule', 6 | name: 'collectionRule', 7 | type: 'object', 8 | icon: FilterIcon, 9 | readOnly: true, 10 | fields: [ 11 | // Column 12 | defineField({ 13 | title: 'Column', 14 | name: 'column', 15 | type: 'string', 16 | }), 17 | // Values 18 | defineField({ 19 | title: 'Relation', 20 | name: 'relation', 21 | type: 'string', 22 | }), 23 | // Condition 24 | defineField({ 25 | title: 'Condition', 26 | name: 'condition', 27 | type: 'string', 28 | }), 29 | ], 30 | preview: { 31 | select: { 32 | condition: 'condition', 33 | name: 'column', 34 | relation: 'relation', 35 | }, 36 | prepare(selection) { 37 | const {condition, name, relation} = selection 38 | 39 | return { 40 | subtitle: `${relation} ${condition}`, 41 | title: name, 42 | } 43 | }, 44 | }, 45 | }) 46 | -------------------------------------------------------------------------------- /schemas/objects/hero/home.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'hero.home', 5 | title: 'Home hero', 6 | type: 'object', 7 | fields: [ 8 | // Title 9 | defineField({ 10 | name: 'title', 11 | title: 'Title', 12 | type: 'text', 13 | rows: 3, 14 | }), 15 | // Link 16 | defineField({ 17 | name: 'links', 18 | title: 'Link', 19 | type: 'array', 20 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 21 | validation: (Rule) => Rule.max(1), 22 | }), 23 | // Content 24 | defineField({ 25 | name: 'content', 26 | title: 'Content', 27 | type: 'array', 28 | validation: (Rule) => Rule.max(1), 29 | of: [ 30 | { 31 | name: 'productWithVariant', 32 | title: 'Product with variant', 33 | type: 'productWithVariant', 34 | }, 35 | { 36 | name: 'imageWithProductHotspots', 37 | title: 'Image', 38 | type: 'imageWithProductHotspots', 39 | }, 40 | ], 41 | }), 42 | ], 43 | }) 44 | -------------------------------------------------------------------------------- /schemas/objects/module/accordionBody.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'accordionBody', 5 | title: 'Body', 6 | type: 'array', 7 | of: [ 8 | { 9 | lists: [], 10 | marks: { 11 | annotations: [ 12 | // Email 13 | { 14 | name: 'annotationLinkEmail', 15 | type: 'annotationLinkEmail', 16 | }, 17 | // Internal link 18 | { 19 | name: 'annotationLinkInternal', 20 | type: 'annotationLinkInternal', 21 | }, 22 | // URL 23 | { 24 | name: 'annotationLinkExternal', 25 | type: 'annotationLinkExternal', 26 | }, 27 | ], 28 | decorators: [ 29 | { 30 | title: 'Italic', 31 | value: 'em', 32 | }, 33 | { 34 | title: 'Strong', 35 | value: 'strong', 36 | }, 37 | ], 38 | }, 39 | // Regular styles 40 | styles: [], 41 | // Paragraphs 42 | type: 'block', 43 | }, 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /schemas/objects/module/callout.ts: -------------------------------------------------------------------------------- 1 | import {BulbOutlineIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | name: 'module.callout', 6 | title: 'Callout', 7 | type: 'object', 8 | icon: BulbOutlineIcon, 9 | fields: [ 10 | // Text 11 | defineField({ 12 | name: 'text', 13 | title: 'Text', 14 | type: 'text', 15 | rows: 2, 16 | validation: (Rule) => [ 17 | Rule.required(), 18 | Rule.max(70).warning(`Callout length shouldn't be more than 70 characters.`), 19 | ], 20 | }), 21 | // Link 22 | defineField({ 23 | name: 'links', 24 | title: 'Link', 25 | type: 'array', 26 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 27 | validation: (Rule) => Rule.max(1), 28 | }), 29 | ], 30 | preview: { 31 | select: { 32 | text: 'text', 33 | url: 'url', 34 | }, 35 | prepare(selection) { 36 | const {text, url} = selection 37 | return { 38 | subtitle: 'Callout', 39 | title: text, 40 | media: BulbOutlineIcon, 41 | } 42 | }, 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /schemas/annotations/linkExternal.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Annotations are ways of marking up text in the block content editor. 3 | * 4 | * Read more: https://www.sanity.io/docs/customization#f924645007e1 5 | */ 6 | import {EarthGlobeIcon} from '@sanity/icons' 7 | import React from 'react' 8 | import {defineField} from 'sanity' 9 | 10 | export default defineField({ 11 | title: 'External Link', 12 | name: 'annotationLinkExternal', 13 | type: 'object', 14 | icon: EarthGlobeIcon, 15 | components: { 16 | annotation: (props) => ( 17 | 18 | 25 | {props.renderDefault(props)} 26 | 27 | ), 28 | }, 29 | fields: [ 30 | { 31 | name: 'url', 32 | title: 'URL', 33 | type: 'url', 34 | validation: (Rule) => Rule.required().uri({scheme: ['http', 'https']}), 35 | }, 36 | // Open in a new window 37 | { 38 | title: 'Open in a new window?', 39 | name: 'newWindow', 40 | type: 'boolean', 41 | initialValue: true, 42 | }, 43 | ], 44 | }) 45 | -------------------------------------------------------------------------------- /schemas/objects/customProductOption/sizeObject.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'customProductOption.sizeObject', 5 | title: 'Size', 6 | type: 'object', 7 | fields: [ 8 | defineField({ 9 | name: 'title', 10 | title: 'Title', 11 | type: 'string', 12 | description: 'Shopify product option value (case sensitive)', 13 | validation: (Rule) => Rule.required(), 14 | }), 15 | defineField({ 16 | name: 'width', 17 | title: 'Width', 18 | type: 'number', 19 | description: 'In mm', 20 | validation: (Rule) => Rule.required().precision(2), 21 | }), 22 | defineField({ 23 | name: 'height', 24 | title: 'Height', 25 | type: 'number', 26 | description: 'In mm', 27 | validation: (Rule) => Rule.required().precision(2), 28 | }), 29 | ], 30 | preview: { 31 | select: { 32 | height: 'height', 33 | title: 'title', 34 | width: 'width', 35 | }, 36 | prepare(selection) { 37 | const {height, title, width} = selection 38 | return { 39 | subtitle: `${width || '??'}cm x ${height || '??'}cm`, 40 | title, 41 | } 42 | }, 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /components/hotspots/ProductTooltip.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import {PreviewLayoutKey, SchemaType, useSchema} from 'sanity' 3 | import {Box} from '@sanity/ui' 4 | import {HotspotTooltipProps} from 'sanity-plugin-hotspot-array' 5 | import {useMemo} from 'react' 6 | 7 | interface HotspotFields { 8 | productWithVariant?: { 9 | product: { 10 | _ref: string 11 | } 12 | } 13 | } 14 | 15 | const StyledBox = styled(Box)` 16 | width: 200px; 17 | ` 18 | 19 | export default function ProductPreview(props: HotspotTooltipProps) { 20 | const {value, renderPreview} = props 21 | const productSchemaType = useSchema().get('product') 22 | const hasProduct = value?.productWithVariant?.product?._ref && productSchemaType 23 | 24 | const previewProps = useMemo( 25 | () => ({ 26 | value: value?.productWithVariant?.product, 27 | schemaType: productSchemaType as SchemaType, 28 | layout: 'default' as PreviewLayoutKey, 29 | }), 30 | [productSchemaType, value?.productWithVariant?.product] 31 | ) 32 | 33 | return ( 34 | 35 | {hasProduct && previewProps ? renderPreview(previewProps) : `No product selected`} 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /schemas/objects/global/linkExternal.ts: -------------------------------------------------------------------------------- 1 | import {EarthGlobeIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | title: 'External Link', 6 | name: 'linkExternal', 7 | type: 'object', 8 | icon: EarthGlobeIcon, 9 | fields: [ 10 | // Title 11 | { 12 | title: 'Title', 13 | name: 'title', 14 | type: 'string', 15 | validation: (Rule) => Rule.required(), 16 | }, 17 | // URL 18 | { 19 | name: 'url', 20 | title: 'URL', 21 | type: 'url', 22 | validation: (Rule) => Rule.required().uri({scheme: ['http', 'https']}), 23 | }, 24 | // Open in a new window 25 | { 26 | title: 'Open in a new window?', 27 | name: 'newWindow', 28 | type: 'boolean', 29 | initialValue: true, 30 | }, 31 | ], 32 | preview: { 33 | select: { 34 | title: 'title', 35 | url: 'url', 36 | }, 37 | prepare(selection) { 38 | const {title, url} = selection 39 | 40 | let subtitle = [] 41 | if (url) { 42 | subtitle.push(`→ ${url}`) 43 | } 44 | 45 | return { 46 | // media: image, 47 | subtitle: subtitle.join(' '), 48 | title, 49 | } 50 | }, 51 | }, 52 | }) 53 | -------------------------------------------------------------------------------- /plugins/customDocumentActions/shopifyLink.ts: -------------------------------------------------------------------------------- 1 | import {EarthGlobeIcon} from '@sanity/icons' 2 | import {collectionUrl, productUrl, productVariantUrl} from '../../utils/shopifyUrls' 3 | import {type DocumentActionDescription} from 'sanity' 4 | import type {ShopifyDocument, ShopifyDocumentActionProps} from './types' 5 | 6 | export default (props: ShopifyDocumentActionProps): DocumentActionDescription | undefined => { 7 | const {published, type}: {published: ShopifyDocument; type: string} = props 8 | 9 | if (!published || published?.store?.isDeleted) { 10 | return 11 | } 12 | 13 | let url: string | null = null 14 | 15 | if (type === 'collection') { 16 | url = collectionUrl(published?.store?.id) 17 | } 18 | if (type === 'product') { 19 | url = productUrl(published?.store?.id) 20 | } 21 | if (type === 'productVariant') { 22 | url = productVariantUrl(published?.store?.productId, published?.store?.id) 23 | } 24 | 25 | if (!url) { 26 | return 27 | } 28 | 29 | if (published && !published?.store?.isDeleted) { 30 | return { 31 | label: 'Edit in Shopify', 32 | icon: EarthGlobeIcon, 33 | onHandle: () => { 34 | url ? window.open(url) : void 'No URL' 35 | }, 36 | shortcut: 'Ctrl+Alt+E', 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /schemas/objects/customProductOption/colorObject.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {defineField} from 'sanity' 3 | 4 | const ColorPreview = ({color}: {color: string}) => { 5 | return ( 6 |
15 | ) 16 | } 17 | 18 | export default defineField({ 19 | name: 'customProductOption.colorObject', 20 | title: 'Color', 21 | type: 'object', 22 | fields: [ 23 | defineField({ 24 | name: 'title', 25 | title: 'Title', 26 | type: 'string', 27 | description: 'Shopify product option value (case sensitive)', 28 | validation: (Rule) => Rule.required(), 29 | }), 30 | defineField({ 31 | name: 'color', 32 | title: 'Color', 33 | type: 'color', 34 | options: {disableAlpha: true}, 35 | validation: (Rule) => Rule.required(), 36 | }), 37 | ], 38 | preview: { 39 | select: { 40 | color: 'color.hex', 41 | title: 'title', 42 | }, 43 | prepare(selection) { 44 | const {color, title} = selection 45 | return { 46 | media: , 47 | subtitle: color, 48 | title, 49 | } 50 | }, 51 | }, 52 | }) 53 | -------------------------------------------------------------------------------- /schemas/objects/module/product.tsx: -------------------------------------------------------------------------------- 1 | import {TagIcon} from '@sanity/icons' 2 | import React from 'react' 3 | import {defineField} from 'sanity' 4 | 5 | import ShopifyDocumentStatus from '../../../components/media/ShopifyDocumentStatus' 6 | 7 | export default defineField({ 8 | name: 'module.product', 9 | title: 'Product', 10 | type: 'object', 11 | icon: TagIcon, 12 | fields: [ 13 | { 14 | name: 'productWithVariant', 15 | title: 'Product + Variant', 16 | type: 'productWithVariant', 17 | validation: (Rule) => Rule.required(), 18 | }, 19 | ], 20 | preview: { 21 | select: { 22 | isDeleted: 'productWithVariant.product.store.isDeleted', 23 | previewImageUrl: 'productWithVariant.product.store.previewImageUrl', 24 | status: 'productWithVariant.product.store.status', 25 | title: 'productWithVariant.product.store.title', 26 | }, 27 | prepare(selection) { 28 | const {isDeleted, previewImageUrl, status, title} = selection 29 | return { 30 | media: ( 31 | 38 | ), 39 | subtitle: 'Product', 40 | title, 41 | } 42 | }, 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /constants.ts: -------------------------------------------------------------------------------- 1 | // Currency code (ISO 4217) to use when displaying prices in the studio 2 | // https://en.wikipedia.org/wiki/ISO_4217 3 | export const DEFAULT_CURRENCY_CODE = 'USD' 4 | 5 | // Document types which: 6 | // - cannot be created in the 'new document' menu 7 | // - cannot be duplicated, unpublished or deleted 8 | export const LOCKED_DOCUMENT_TYPES = ['settings', 'home', 'media.tag'] 9 | 10 | // Document types which: 11 | // - cannot be created in the 'new document' menu 12 | // - cannot be duplicated, unpublished or deleted 13 | // - are from the Sanity Connect Shopify app - and can be linked to on Shopify 14 | export const SHOPIFY_DOCUMENT_TYPES = ['product', 'productVariant', 'collection'] 15 | 16 | // References to include in 'internal' links 17 | export const PAGE_REFERENCES = [ 18 | {type: 'collection'}, 19 | {type: 'home'}, 20 | {type: 'page'}, 21 | {type: 'product'}, 22 | ] 23 | 24 | // API version to use when using the Sanity client within the studio 25 | // https://www.sanity.io/help/studio-client-specify-api-version 26 | export const SANITY_API_VERSION = '2022-10-25' 27 | 28 | // Your Shopify store ID. 29 | // This is your unique store (e.g. 'my-store-name' in the complete URL of 'https://admin.shopify.com/store/my-store-name/'). 30 | // Set this to enable helper links in document status banners and shortcut links on products and collections. 31 | export const SHOPIFY_STORE_ID = '' 32 | -------------------------------------------------------------------------------- /schemas/singletons/home.ts: -------------------------------------------------------------------------------- 1 | import {HomeIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | const TITLE = 'Home' 5 | 6 | export default defineField({ 7 | name: 'home', 8 | title: TITLE, 9 | type: 'document', 10 | icon: HomeIcon, 11 | groups: [ 12 | { 13 | default: true, 14 | name: 'editorial', 15 | title: 'Editorial', 16 | }, 17 | { 18 | name: 'seo', 19 | title: 'SEO', 20 | }, 21 | ], 22 | fields: [ 23 | // Hero 24 | defineField({ 25 | name: 'hero', 26 | title: 'Hero', 27 | type: 'hero.home', 28 | group: 'editorial', 29 | }), 30 | // Modules 31 | defineField({ 32 | name: 'modules', 33 | title: 'Modules', 34 | type: 'array', 35 | of: [ 36 | {type: 'module.callout'}, 37 | {type: 'module.callToAction'}, 38 | {type: 'module.collection'}, 39 | {type: 'module.image'}, 40 | {type: 'module.instagram'}, 41 | {type: 'module.product'}, 42 | ], 43 | group: 'editorial', 44 | }), 45 | // SEO 46 | defineField({ 47 | name: 'seo', 48 | title: 'SEO', 49 | type: 'seo.home', 50 | group: 'seo', 51 | }), 52 | ], 53 | preview: { 54 | prepare() { 55 | return { 56 | // media: icon, 57 | subtitle: 'Index', 58 | title: TITLE, 59 | } 60 | }, 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /sanity.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, isDev} from 'sanity' 2 | 3 | import {deskTool} from 'sanity/desk' 4 | import {schemaTypes} from './schemas' 5 | import {structure} from './desk' 6 | 7 | import {visionTool} from '@sanity/vision' 8 | import {colorInput} from '@sanity/color-input' 9 | import {imageHotspotArrayPlugin} from 'sanity-plugin-hotspot-array' 10 | import {media, mediaAssetSource} from 'sanity-plugin-media' 11 | import {customDocumentActions} from './plugins/customDocumentActions' 12 | 13 | const devOnlyPlugins = [visionTool()] 14 | 15 | export default defineConfig({ 16 | name: 'default', 17 | title: 'Sanity + Shopify demo', 18 | 19 | projectId: process.env.SANITY_STUDIO_PROJECT_ID || 'g2b4qblu', 20 | dataset: process.env.SANITY_STUDIO_PROJECT_DATASET || 'production', 21 | 22 | plugins: [ 23 | deskTool({structure}), 24 | colorInput(), 25 | imageHotspotArrayPlugin(), 26 | customDocumentActions(), 27 | media(), 28 | ...(isDev ? devOnlyPlugins : []), 29 | ], 30 | 31 | schema: { 32 | types: schemaTypes, 33 | }, 34 | 35 | form: { 36 | file: { 37 | assetSources: (previousAssetSources) => { 38 | return previousAssetSources.filter((assetSource) => assetSource !== mediaAssetSource) 39 | }, 40 | }, 41 | image: { 42 | assetSources: (previousAssetSources) => { 43 | return previousAssetSources.filter((assetSource) => assetSource === mediaAssetSource) 44 | }, 45 | }, 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /schemas/objects/hotspot/imageWithProductHotspots.ts: -------------------------------------------------------------------------------- 1 | import {ImageIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export default defineField({ 6 | icon: ImageIcon, 7 | name: 'imageWithProductHotspots', 8 | title: 'Image', 9 | type: 'object', 10 | fields: [ 11 | defineField({ 12 | name: 'image', 13 | title: 'Image', 14 | options: {hotspot: true}, 15 | type: 'image', 16 | validation: (Rule) => Rule.required(), 17 | }), 18 | defineField({ 19 | name: 'showHotspots', 20 | title: 'Show product hotspots', 21 | type: 'boolean', 22 | initialValue: false, 23 | }), 24 | defineField({ 25 | name: 'productHotspots', 26 | title: 'Product hotspots', 27 | type: 'productHotspots', 28 | hidden: ({parent}) => !parent.showHotspots, 29 | }), 30 | ], 31 | preview: { 32 | select: { 33 | fileName: 'image.asset.originalFilename', 34 | hotspots: 'productHotspots', 35 | image: 'image', 36 | showHotspots: 'showHotspots', 37 | }, 38 | prepare(selection) { 39 | const {fileName, hotspots, image, showHotspots} = selection 40 | return { 41 | media: image, 42 | subtitle: 43 | showHotspots && hotspots.length > 0 44 | ? `${pluralize('hotspot', hotspots.length, true)}` 45 | : undefined, 46 | title: fileName, 47 | } 48 | }, 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /schemas/objects/module/products.tsx: -------------------------------------------------------------------------------- 1 | import {TagIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export default defineField({ 6 | name: 'module.products', 7 | title: 'Products', 8 | type: 'object', 9 | icon: TagIcon, 10 | fields: [ 11 | // Modules (products) 12 | defineField({ 13 | name: 'modules', 14 | title: 'Products', 15 | type: 'array', 16 | of: [{type: 'module.product'}], 17 | validation: (Rule) => Rule.required().max(2), 18 | }), 19 | // Layout 20 | defineField({ 21 | name: 'layout', 22 | title: 'Layout', 23 | type: 'string', 24 | initialValue: 'card', 25 | options: { 26 | direction: 'horizontal', 27 | layout: 'radio', 28 | list: [ 29 | { 30 | title: 'Cards (large)', 31 | value: 'card', 32 | }, 33 | { 34 | title: 'Pills (small)', 35 | value: 'pill', 36 | }, 37 | ], 38 | }, 39 | validation: (Rule) => Rule.required(), 40 | }), 41 | ], 42 | preview: { 43 | select: { 44 | products: 'modules', 45 | }, 46 | prepare(selection) { 47 | const {products} = selection 48 | return { 49 | subtitle: 'Products', 50 | title: products.length > 0 ? pluralize('product', products.length, true) : 'No products', 51 | media: TagIcon, 52 | } 53 | }, 54 | }, 55 | }) 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-shopify-studio-v3", 3 | "private": true, 4 | "version": "1.0.0", 5 | "main": "package.json", 6 | "license": "UNLICENSED", 7 | "scripts": { 8 | "dev": "sanity dev", 9 | "start": "sanity start", 10 | "build": "sanity build", 11 | "deploy": "sanity deploy", 12 | "deploy-graphql": "sanity graphql deploy" 13 | }, 14 | "keywords": [ 15 | "sanity", 16 | "shopify" 17 | ], 18 | "dependencies": { 19 | "@portabletext/types": "^2.0.2", 20 | "@sanity/asset-utils": "^1.3.0", 21 | "@sanity/color-input": "^3.0.2", 22 | "@sanity/icons": "^2.3.1", 23 | "@sanity/ui": "^1.3.2", 24 | "@sanity/vision": "^3.9.1", 25 | "lodash.get": "^4.4.2", 26 | "pluralize-esm": "^9.0.3", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "react-is": "^18.2.0", 30 | "sanity": "^3.12.1", 31 | "sanity-plugin-hotspot-array": "^1.0.1", 32 | "sanity-plugin-media": "^2.0.5", 33 | "slug": "^8.2.2", 34 | "styled-components": "^5.3.9" 35 | }, 36 | "devDependencies": { 37 | "@portabletext/types": "^2.0.2", 38 | "@sanity/eslint-config-studio": "^2.0.1", 39 | "@types/lodash.get": "^4.4.7", 40 | "@types/react": "^18.2.0", 41 | "@types/slug": "^5.0.3", 42 | "@types/styled-components": "^5.1.26", 43 | "eslint": "^8.6.0", 44 | "prettier": "^2.7.1", 45 | "typescript": "^4.0.0" 46 | }, 47 | "prettier": { 48 | "semi": false, 49 | "printWidth": 100, 50 | "bracketSpacing": false, 51 | "singleQuote": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /schemas/objects/global/footer.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'footerSettings', 5 | title: 'Footer', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | fields: [ 12 | // Links 13 | defineField({ 14 | name: 'links', 15 | title: 'Links', 16 | type: 'array', 17 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 18 | }), 19 | // Text 20 | defineField({ 21 | name: 'text', 22 | title: 'Text', 23 | type: 'array', 24 | of: [ 25 | { 26 | lists: [], 27 | marks: { 28 | annotations: [ 29 | // Email 30 | { 31 | title: 'Email', 32 | name: 'annotationLinkEmail', 33 | type: 'annotationLinkEmail', 34 | }, 35 | // Internal link 36 | { 37 | title: 'Internal page', 38 | name: 'annotationLinkInternal', 39 | type: 'annotationLinkInternal', 40 | }, 41 | // URL 42 | { 43 | title: 'URL', 44 | name: 'annotationLinkExternal', 45 | type: 'annotationLinkExternal', 46 | }, 47 | ], 48 | decorators: [], 49 | }, 50 | // Block styles 51 | styles: [{title: 'Normal', value: 'normal'}], 52 | type: 'block', 53 | }, 54 | ], 55 | }), 56 | ], 57 | }) 58 | -------------------------------------------------------------------------------- /schemas/objects/module/collection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {PackageIcon} from '@sanity/icons' 3 | import {defineField} from 'sanity' 4 | 5 | import ShopifyDocumentStatus from '../../../components/media/ShopifyDocumentStatus' 6 | 7 | export default defineField({ 8 | name: 'module.collection', 9 | title: 'Collection', 10 | type: 'object', 11 | icon: PackageIcon, 12 | fields: [ 13 | // Collection 14 | defineField({ 15 | name: 'collection', 16 | title: 'Collection', 17 | type: 'reference', 18 | weak: true, 19 | to: [{type: 'collection'}], 20 | validation: (Rule) => Rule.required(), 21 | }), 22 | // Show background 23 | defineField({ 24 | name: 'showBackground', 25 | title: 'Show background', 26 | type: 'boolean', 27 | description: 'Use Shopify collection image as background (if available)', 28 | initialValue: false, 29 | }), 30 | ], 31 | preview: { 32 | select: { 33 | collectionTitle: 'collection.store.title', 34 | imageUrl: 'collection.store.imageUrl', 35 | isDeleted: 'collection.store.isDeleted', 36 | }, 37 | prepare(selection) { 38 | const {collectionTitle, imageUrl, isDeleted} = selection 39 | return { 40 | media: ( 41 | 47 | ), 48 | subtitle: 'Collection', 49 | title: collectionTitle, 50 | } 51 | }, 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /schemas/documents/colorTheme.tsx: -------------------------------------------------------------------------------- 1 | import {IceCreamIcon} from '@sanity/icons' 2 | import React from 'react' 3 | import {defineField, defineType} from 'sanity' 4 | 5 | import ColorTheme from '../../components/media/ColorTheme' 6 | 7 | export default defineType({ 8 | name: 'colorTheme', 9 | title: 'Color theme', 10 | type: 'document', 11 | icon: IceCreamIcon, 12 | groups: [ 13 | { 14 | name: 'shopifySync', 15 | title: 'Shopify sync', 16 | }, 17 | ], 18 | fields: [ 19 | // Title 20 | defineField({ 21 | name: 'title', 22 | title: 'Title', 23 | type: 'string', 24 | validation: (Rule) => Rule.required(), 25 | }), 26 | // Text color 27 | defineField({ 28 | name: 'text', 29 | title: 'Text', 30 | type: 'color', 31 | options: {disableAlpha: true}, 32 | validation: (Rule) => Rule.required(), 33 | }), 34 | // Background color 35 | defineField({ 36 | name: 'background', 37 | title: 'Background', 38 | type: 'color', 39 | options: {disableAlpha: true}, 40 | validation: (Rule) => Rule.required(), 41 | }), 42 | ], 43 | preview: { 44 | select: { 45 | backgroundColor: 'background.hex', 46 | textColor: 'text.hex', 47 | title: 'title', 48 | }, 49 | prepare(selection) { 50 | const {backgroundColor, textColor, title} = selection 51 | 52 | return { 53 | media: , 54 | subtitle: `${textColor || '(No color)'} / ${backgroundColor || '(No color)'}`, 55 | title, 56 | } 57 | }, 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /schemas/objects/customProductOption/size.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize-esm' 2 | import {defineField} from 'sanity' 3 | 4 | interface SizeOption { 5 | title: string 6 | } 7 | 8 | export default defineField({ 9 | name: 'customProductOption.size', 10 | title: 'Size', 11 | type: 'object', 12 | icon: false, 13 | fields: [ 14 | // Title 15 | defineField({ 16 | name: 'title', 17 | title: 'Title', 18 | type: 'string', 19 | description: 'Shopify product option name (case sensitive)', 20 | validation: (Rule) => Rule.required(), 21 | }), 22 | // Sizes 23 | defineField({ 24 | name: 'sizes', 25 | title: 'Sizes', 26 | type: 'array', 27 | of: [ 28 | { 29 | type: 'customProductOption.sizeObject', 30 | }, 31 | ], 32 | validation: (Rule) => 33 | Rule.custom((options: SizeOption[] | undefined) => { 34 | // Each size must have a unique title 35 | if (options) { 36 | const uniqueTitles = new Set(options.map((option) => option.title)) 37 | if (options.length > uniqueTitles.size) { 38 | return 'Each product option must have a unique title' 39 | } 40 | } 41 | return true 42 | }), 43 | }), 44 | ], 45 | preview: { 46 | select: { 47 | sizes: 'sizes', 48 | title: 'title', 49 | }, 50 | prepare(selection) { 51 | const {sizes, title} = selection 52 | return { 53 | subtitle: sizes.length > 0 ? pluralize('size', sizes.length, true) : 'No sizes', 54 | title, 55 | } 56 | }, 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /schemas/objects/customProductOption/color.tsx: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize-esm' 2 | import {defineField} from 'sanity' 3 | 4 | interface ColorOption { 5 | title: string 6 | } 7 | 8 | export default defineField({ 9 | name: 'customProductOption.color', 10 | title: 'Color', 11 | type: 'object', 12 | icon: false, 13 | fields: [ 14 | // Title 15 | defineField({ 16 | name: 'title', 17 | title: 'Title', 18 | type: 'string', 19 | description: 'Shopify product option name (case sensitive)', 20 | validation: (Rule) => Rule.required(), 21 | }), 22 | // Colors 23 | defineField({ 24 | name: 'colors', 25 | title: 'Colors', 26 | type: 'array', 27 | of: [ 28 | { 29 | type: 'customProductOption.colorObject', 30 | }, 31 | ], 32 | validation: (Rule) => 33 | Rule.custom((options: ColorOption[] | undefined) => { 34 | // Each size must have a unique title 35 | if (options) { 36 | const uniqueTitles = new Set(options.map((option) => option?.title)) 37 | if (options.length > uniqueTitles.size) { 38 | return 'Each product option must have a unique title' 39 | } 40 | } 41 | return true 42 | }), 43 | }), 44 | ], 45 | preview: { 46 | select: { 47 | colors: 'colors', 48 | title: 'title', 49 | }, 50 | prepare(selection) { 51 | const {colors, title} = selection 52 | return { 53 | subtitle: colors.length ? pluralize('color', colors.length, true) : 'No colors', 54 | title, 55 | } 56 | }, 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /schemas/blocks/body.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'body', 5 | title: 'Body', 6 | type: 'array', 7 | of: [ 8 | { 9 | lists: [ 10 | {title: 'Bullet', value: 'bullet'}, 11 | {title: 'Numbered', value: 'number'}, 12 | ], 13 | marks: { 14 | decorators: [ 15 | { 16 | title: 'Italic', 17 | value: 'em', 18 | }, 19 | { 20 | title: 'Strong', 21 | value: 'strong', 22 | }, 23 | ], 24 | annotations: [ 25 | // product 26 | { 27 | name: 'annotationProduct', 28 | type: 'annotationProduct', 29 | }, 30 | // Email 31 | { 32 | name: 'annotationLinkEmail', 33 | type: 'annotationLinkEmail', 34 | }, 35 | // Internal link 36 | { 37 | name: 'annotationLinkInternal', 38 | type: 'annotationLinkInternal', 39 | }, 40 | // URL 41 | { 42 | name: 'annotationLinkExternal', 43 | type: 'annotationLinkExternal', 44 | }, 45 | ], 46 | }, 47 | // Paragraphs 48 | type: 'block', 49 | }, 50 | // Custom blocks 51 | { 52 | type: 'module.accordion', 53 | }, 54 | { 55 | type: 'module.callout', 56 | }, 57 | { 58 | type: 'module.grid', 59 | }, 60 | { 61 | type: 'module.images', 62 | }, 63 | { 64 | type: 'module.instagram', 65 | }, 66 | { 67 | type: 'module.products', 68 | }, 69 | ], 70 | }) 71 | -------------------------------------------------------------------------------- /plugins/customDocumentActions/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | definePlugin, 3 | DocumentActionComponent, 4 | DocumentActionsResolver, 5 | NewDocumentOptionsResolver, 6 | } from 'sanity' 7 | import shopifyDelete from './shopifyDelete' 8 | import shopifyLink from './shopifyLink' 9 | 10 | import {LOCKED_DOCUMENT_TYPES, SHOPIFY_DOCUMENT_TYPES} from '../../constants' 11 | 12 | export const resolveDocumentActions: DocumentActionsResolver = (prev, {schemaType}) => { 13 | if (LOCKED_DOCUMENT_TYPES.includes(schemaType)) { 14 | prev = prev.filter( 15 | (previousAction: DocumentActionComponent) => 16 | previousAction.action === 'publish' || previousAction.action === 'discardChanges' 17 | ) 18 | } 19 | 20 | if (SHOPIFY_DOCUMENT_TYPES.includes(schemaType)) { 21 | prev = prev.filter( 22 | (previousAction: DocumentActionComponent) => 23 | previousAction.action === 'publish' || 24 | previousAction.action === 'unpublish' || 25 | previousAction.action === 'discardChanges' 26 | ) 27 | 28 | return [ 29 | ...prev, 30 | shopifyDelete as DocumentActionComponent, 31 | shopifyLink as DocumentActionComponent, 32 | ] 33 | } 34 | 35 | return prev 36 | } 37 | 38 | export const resolveNewDocumentOptions: NewDocumentOptionsResolver = (prev) => { 39 | const options = prev.filter((previousOption) => { 40 | return ( 41 | !LOCKED_DOCUMENT_TYPES.includes(previousOption.templateId) && 42 | !SHOPIFY_DOCUMENT_TYPES.includes(previousOption.templateId) 43 | ) 44 | }) 45 | 46 | return options 47 | } 48 | 49 | export const customDocumentActions = definePlugin({ 50 | name: 'custom-document-actions', 51 | document: { 52 | actions: resolveDocumentActions, 53 | newDocumentOptions: resolveNewDocumentOptions, 54 | }, 55 | }) 56 | -------------------------------------------------------------------------------- /schemas/objects/global/linkInternal.ts: -------------------------------------------------------------------------------- 1 | import {LinkIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | import {PAGE_REFERENCES} from '../../../constants' 5 | import {getPriceRange} from '../../../utils/getPriceRange' 6 | 7 | export default defineField({ 8 | title: 'Internal Link', 9 | name: 'linkInternal', 10 | type: 'object', 11 | icon: LinkIcon, 12 | fields: [ 13 | // Title 14 | { 15 | title: 'Title', 16 | name: 'title', 17 | type: 'string', 18 | validation: (Rule) => Rule.required(), 19 | }, 20 | // Reference 21 | { 22 | name: 'reference', 23 | type: 'reference', 24 | weak: true, 25 | validation: (Rule) => Rule.required(), 26 | to: PAGE_REFERENCES, 27 | }, 28 | ], 29 | preview: { 30 | select: { 31 | reference: 'reference', 32 | referenceProductTitle: 'reference.store.title', 33 | referenceProductPriceRange: 'reference.store.priceRange', 34 | referenceTitle: 'reference.title', 35 | referenceType: 'reference._type', 36 | title: 'title', 37 | }, 38 | prepare(selection) { 39 | const { 40 | reference, 41 | referenceProductPriceRange, 42 | referenceProductTitle, 43 | referenceTitle, 44 | referenceType, 45 | title, 46 | } = selection 47 | 48 | let subtitle = [] 49 | if (reference) { 50 | subtitle.push([`→ ${referenceTitle || referenceProductTitle || reference?._id}`]) 51 | if (referenceType === 'product' && referenceProductPriceRange) { 52 | subtitle.push(`(${getPriceRange(referenceProductPriceRange)})`) 53 | } 54 | } else { 55 | subtitle.push('(Nonexistent document reference)') 56 | } 57 | 58 | return { 59 | // media: image, 60 | subtitle: subtitle.join(' '), 61 | title, 62 | } 63 | }, 64 | }, 65 | }) 66 | -------------------------------------------------------------------------------- /desk/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Desk structure overrides 3 | */ 4 | import {ListItemBuilder, StructureResolver} from 'sanity/desk' 5 | import collections from './collectionStructure' 6 | import colorThemes from './colorThemeStructure' 7 | import home from './homeStructure' 8 | import pages from './pageStructure' 9 | import products from './productStructure' 10 | import settings from './settingStructure' 11 | 12 | /** 13 | * Desk structure overrides 14 | * 15 | * Sanity Studio automatically lists document types out of the box. 16 | * With this custom desk structure we achieve things like showing the `home` 17 | * and `settings` document types as singletons, and grouping product details 18 | * and variants for easy editorial access. 19 | * 20 | * You can customize this even further as your schemas progress. 21 | * To learn more about structure builder, visit our docs: 22 | * https://www.sanity.io/docs/overview-structure-builder 23 | */ 24 | 25 | // If you add document types to desk structure manually, you can add them to this function to prevent duplicates in the root pane 26 | const hiddenDocTypes = (listItem: ListItemBuilder) => { 27 | const id = listItem.getId() 28 | 29 | if (!id) { 30 | return false 31 | } 32 | 33 | return ![ 34 | 'collection', 35 | 'colorTheme', 36 | 'home', 37 | 'media.tag', 38 | 'page', 39 | 'product', 40 | 'productVariant', 41 | 'settings', 42 | ].includes(id) 43 | } 44 | 45 | export const structure: StructureResolver = (S, context) => 46 | S.list() 47 | .title('Content') 48 | .items([ 49 | home(S, context), 50 | pages(S, context), 51 | S.divider(), 52 | collections(S, context), 53 | products(S, context), 54 | S.divider(), 55 | colorThemes(S, context), 56 | S.divider(), 57 | settings(S, context), 58 | S.divider(), 59 | ...S.documentTypeListItems().filter(hiddenDocTypes), 60 | ]) 61 | -------------------------------------------------------------------------------- /desk/productStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/desk' 2 | import defineStructure from '../utils/defineStructure' 3 | import {InfoOutlineIcon} from '@sanity/icons' 4 | 5 | export default defineStructure((S) => 6 | S.listItem() 7 | .title('Products') 8 | .schemaType('product') 9 | .child( 10 | S.documentTypeList('product') 11 | // .defaultLayout('detail') 12 | .child(async (id) => 13 | S.list() 14 | .title('Product') 15 | .canHandleIntent( 16 | (intentName, params) => intentName === 'edit' && params.type === 'product' 17 | ) 18 | .items([ 19 | // Details 20 | S.listItem() 21 | .title('Details') 22 | .icon(InfoOutlineIcon) 23 | .schemaType('product') 24 | .id(id) 25 | .child(S.document().schemaType('product').documentId(id)), 26 | // Product variants 27 | S.listItem() 28 | .title('Variants') 29 | .schemaType('productVariant') 30 | .child( 31 | S.documentList() 32 | .title('Variants') 33 | .schemaType('productVariant') 34 | .filter( 35 | ` 36 | _type == "productVariant" 37 | && store.productId == $productId 38 | ` 39 | ) 40 | .params({ 41 | productId: Number(id.replace('shopifyProduct-', '')), 42 | }) 43 | .canHandleIntent( 44 | (intentName, params) => 45 | intentName === 'edit' && params.type === 'productVariant' 46 | ) 47 | ), 48 | ]) 49 | ) 50 | ) 51 | ) 52 | -------------------------------------------------------------------------------- /schemas/annotations/product.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Annotations are ways of marking up text in the block content editor. 3 | * 4 | * Read more: https://www.sanity.io/docs/customization#f924645007e1 5 | */ 6 | import {TagIcon} from '@sanity/icons' 7 | import React from 'react' 8 | import {defineField} from 'sanity' 9 | 10 | export default defineField({ 11 | title: 'Product', 12 | name: 'annotationProduct', 13 | type: 'object', 14 | icon: TagIcon, 15 | components: { 16 | annotation: (props) => ( 17 | 18 | 25 | {props.renderDefault(props)} 26 | 27 | ), 28 | }, 29 | fields: [ 30 | // Product 31 | { 32 | name: 'productWithVariant', 33 | title: 'Product + Variant', 34 | type: 'productWithVariant', 35 | validation: (Rule) => Rule.required(), 36 | }, 37 | // Link action 38 | defineField({ 39 | name: 'linkAction', 40 | title: 'Link action', 41 | type: 'string', 42 | initialValue: 'link', 43 | options: { 44 | layout: 'radio', 45 | list: [ 46 | { 47 | title: 'Navigate to product', 48 | value: 'link', 49 | }, 50 | { 51 | title: 'Add to cart', 52 | value: 'addToCart', 53 | }, 54 | { 55 | title: 'Buy now', 56 | value: 'buyNow', 57 | }, 58 | ], 59 | }, 60 | validation: (Rule) => Rule.required(), 61 | }), 62 | // Quantity 63 | defineField({ 64 | name: 'quantity', 65 | title: 'Quantity', 66 | type: 'number', 67 | initialValue: 1, 68 | hidden: ({parent}) => parent.linkAction === 'link', 69 | validation: (Rule) => Rule.required().min(1).max(10), 70 | }), 71 | ], 72 | }) 73 | -------------------------------------------------------------------------------- /schemas/objects/module/images.tsx: -------------------------------------------------------------------------------- 1 | import {ImageIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export default defineField({ 6 | name: 'module.images', 7 | title: 'Images', 8 | type: 'object', 9 | icon: ImageIcon, 10 | fields: [ 11 | // Modules (Images) 12 | defineField({ 13 | name: 'modules', 14 | title: 'Images', 15 | type: 'array', 16 | of: [{type: 'module.image'}], 17 | validation: (Rule) => Rule.required().max(2), 18 | }), 19 | // Full width 20 | defineField({ 21 | name: 'fullWidth', 22 | title: 'Full width', 23 | type: 'boolean', 24 | description: 'Display single image at full width (on larger breakpoints)', 25 | initialValue: false, 26 | hidden: ({parent}) => parent?.modules?.length > 1, 27 | }), 28 | // Vertical alignment 29 | defineField({ 30 | name: 'verticalAlign', 31 | title: 'Vertical alignment', 32 | type: 'string', 33 | initialValue: 'top', 34 | options: { 35 | direction: 'horizontal', 36 | layout: 'radio', 37 | list: [ 38 | { 39 | title: 'Top', 40 | value: 'top', 41 | }, 42 | { 43 | title: 'Center', 44 | value: 'center', 45 | }, 46 | { 47 | title: 'Bottom', 48 | value: 'bottom', 49 | }, 50 | ], 51 | }, 52 | hidden: ({parent}) => !parent?.modules || parent?.modules?.length < 2, 53 | validation: (Rule) => Rule.required(), 54 | }), 55 | ], 56 | preview: { 57 | select: { 58 | images: 'modules', 59 | }, 60 | prepare(selection) { 61 | const {images} = selection 62 | return { 63 | subtitle: 'Images', 64 | title: images?.length > 0 ? pluralize('image', images.length, true) : 'No images', 65 | media: ImageIcon, 66 | } 67 | }, 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /components/inputs/ProductHidden.tsx: -------------------------------------------------------------------------------- 1 | import {WarningOutlineIcon} from '@sanity/icons' 2 | import {StringFieldProps, useFormValue} from 'sanity' 3 | import {Box, Card, Flex, Stack, Text} from '@sanity/ui' 4 | import {productUrl} from '../../utils/shopifyUrls' 5 | 6 | type Store = { 7 | id: number 8 | status: string 9 | isDeleted: boolean 10 | } 11 | 12 | export default function ProductHiddenInput(props: StringFieldProps) { 13 | const store: Store = useFormValue(['store']) as Store 14 | 15 | let message 16 | if (!store) { 17 | return <> 18 | } else { 19 | const shopifyProductUrl = productUrl(store?.id) 20 | const isActive = store?.status === 'active' 21 | const isDeleted = store?.isDeleted 22 | 23 | if (!isActive) { 24 | message = ( 25 | <> 26 | It does not have an active status in Shopify. 27 | 28 | ) 29 | } 30 | if (isDeleted) { 31 | message = 'It has been deleted from Shopify.' 32 | } 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | This product is hidden 44 | 45 | 46 | 47 | {message} 48 | 49 | {!isDeleted && shopifyProductUrl && ( 50 | 51 | 52 | →{' '} 53 | 54 | View this product on Shopify 55 | 56 | 57 | 58 | )} 59 | 60 | 61 | 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /schemas/objects/hotspot/spot.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {defineField} from 'sanity' 3 | 4 | import ShopifyDocumentStatus from '../../../components/media/ShopifyDocumentStatus' 5 | 6 | export default defineField({ 7 | name: 'spot', 8 | title: 'Spot', 9 | type: 'object', 10 | fieldsets: [{name: 'position', options: {columns: 2}}], 11 | fields: [ 12 | defineField({ 13 | name: 'productWithVariant', 14 | title: 'Product + Variant', 15 | type: 'productWithVariant', 16 | }), 17 | defineField({ 18 | name: 'x', 19 | type: 'number', 20 | readOnly: true, 21 | fieldset: 'position', 22 | initialValue: 50, 23 | validation: (Rule) => Rule.required().min(0).max(100), 24 | }), 25 | defineField({ 26 | name: 'y', 27 | type: 'number', 28 | readOnly: true, 29 | fieldset: 'position', 30 | initialValue: 50, 31 | validation: (Rule) => Rule.required().min(0).max(100), 32 | }), 33 | ], 34 | preview: { 35 | select: { 36 | isDeleted: 'productWithVariant.product.store.isDeleted', 37 | previewImageUrl: 'productWithVariant.product.store.previewImageUrl', 38 | productTitle: 'productWithVariant.product.store.title', 39 | status: 'productWithVariant.product.store.status', 40 | variantPreviewImageUrl: 'productWithVariant.variant.store.previewImageUrl', 41 | x: 'x', 42 | y: 'y', 43 | }, 44 | prepare(selection) { 45 | const {isDeleted, previewImageUrl, productTitle, status, variantPreviewImageUrl, x, y} = 46 | selection 47 | return { 48 | media: ( 49 | 56 | ), 57 | title: productTitle, 58 | subtitle: x && y ? `[${x}%, ${y}%]` : `No position set`, 59 | } 60 | }, 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /schemas/documents/productVariant.tsx: -------------------------------------------------------------------------------- 1 | import {CopyIcon} from '@sanity/icons' 2 | import {defineField, defineType} from 'sanity' 3 | 4 | import ShopifyIcon from '../../components/icons/Shopify' 5 | import ProductVariantHiddenInput from '../../components/inputs/ProductVariantHidden' 6 | import ShopifyDocumentStatus from '../../components/media/ShopifyDocumentStatus' 7 | 8 | export default defineType({ 9 | name: 'productVariant', 10 | title: 'Product variant', 11 | type: 'document', 12 | icon: CopyIcon, 13 | groups: [ 14 | { 15 | name: 'shopifySync', 16 | title: 'Shopify sync', 17 | icon: ShopifyIcon, 18 | }, 19 | ], 20 | fields: [ 21 | // Product variant hidden status 22 | defineField({ 23 | name: 'hidden', 24 | type: 'string', 25 | components: { 26 | field: ProductVariantHiddenInput, 27 | }, 28 | hidden: ({parent}) => { 29 | const isDeleted = parent?.store?.isDeleted 30 | 31 | return !isDeleted 32 | }, 33 | }), 34 | // Title (proxy) 35 | defineField({ 36 | title: 'Title', 37 | name: 'titleProxy', 38 | type: 'proxyString', 39 | options: {field: 'store.title'}, 40 | }), 41 | // Shopify product variant 42 | defineField({ 43 | name: 'store', 44 | title: 'Shopify', 45 | description: 'Variant data from Shopify (read-only)', 46 | type: 'shopifyProductVariant', 47 | group: 'shopifySync', 48 | }), 49 | ], 50 | preview: { 51 | select: { 52 | isDeleted: 'store.isDeleted', 53 | previewImageUrl: 'store.previewImageUrl', 54 | sku: 'store.sku', 55 | status: 'store.status', 56 | title: 'store.title', 57 | }, 58 | prepare(selection) { 59 | const {isDeleted, previewImageUrl, sku, status, title} = selection 60 | 61 | return { 62 | media: ( 63 | 70 | ), 71 | subtitle: sku, 72 | title, 73 | } 74 | }, 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /schemas/documents/page.ts: -------------------------------------------------------------------------------- 1 | import {DocumentIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | import {validateSlug} from '../../utils/validateSlug' 5 | 6 | export default defineField({ 7 | name: 'page', 8 | title: 'Page', 9 | type: 'document', 10 | icon: DocumentIcon, 11 | groups: [ 12 | { 13 | name: 'theme', 14 | title: 'Theme', 15 | }, 16 | { 17 | default: true, 18 | name: 'editorial', 19 | title: 'Editorial', 20 | }, 21 | { 22 | name: 'seo', 23 | title: 'SEO', 24 | }, 25 | ], 26 | fields: [ 27 | // Title 28 | defineField({ 29 | name: 'title', 30 | title: 'Title', 31 | type: 'string', 32 | validation: (Rule) => Rule.required(), 33 | }), 34 | // Slug 35 | defineField({ 36 | name: 'slug', 37 | type: 'slug', 38 | options: {source: 'title'}, 39 | // @ts-ignore - TODO - fix this TS error 40 | validation: validateSlug, 41 | }), 42 | // Color theme 43 | defineField({ 44 | name: 'colorTheme', 45 | title: 'Color theme', 46 | type: 'reference', 47 | to: [{type: 'colorTheme'}], 48 | group: 'theme', 49 | }), 50 | // Show hero 51 | defineField({ 52 | name: 'showHero', 53 | title: 'Show hero', 54 | type: 'boolean', 55 | description: 'If disabled, page title will be displayed instead', 56 | initialValue: false, 57 | group: 'editorial', 58 | }), 59 | // Hero 60 | defineField({ 61 | name: 'hero', 62 | title: 'Hero', 63 | type: 'hero.page', 64 | hidden: ({document}) => !document?.showHero, 65 | group: 'editorial', 66 | }), 67 | // Body 68 | defineField({ 69 | name: 'body', 70 | title: 'Body', 71 | type: 'body', 72 | group: 'editorial', 73 | }), 74 | // SEO 75 | defineField({ 76 | name: 'seo', 77 | title: 'SEO', 78 | type: 'seo.page', 79 | group: 'seo', 80 | }), 81 | ], 82 | preview: { 83 | select: { 84 | active: 'active', 85 | seoImage: 'seo.image', 86 | title: 'title', 87 | }, 88 | prepare(selection) { 89 | const {seoImage, title} = selection 90 | 91 | return { 92 | media: seoImage, 93 | title, 94 | } 95 | }, 96 | }, 97 | }) 98 | -------------------------------------------------------------------------------- /components/media/ShopifyDocumentStatus.tsx: -------------------------------------------------------------------------------- 1 | import {CloseIcon, ImageIcon, LinkRemovedIcon} from '@sanity/icons' 2 | import React, {forwardRef, useState} from 'react' 3 | 4 | type Props = { 5 | isActive?: boolean 6 | isDeleted: boolean 7 | type: 'collection' | 'product' | 'productVariant' 8 | url: string 9 | title: string 10 | } 11 | 12 | const ShopifyDocumentStatus = forwardRef((props, ref) => { 13 | const {isActive, isDeleted, type, url, title} = props 14 | 15 | const [imageVisible, setImageVisible] = useState(true) 16 | 17 | // Hide image on error / 404 18 | const handleImageError = () => setImageVisible(false) 19 | 20 | return ( 21 |
33 | {imageVisible && url ? ( 34 | {`${title} 47 | ) : ( 48 | 49 | )} 50 | 51 | {/* Item has been deleted */} 52 | {isDeleted ? ( 53 | 62 | ) : ( 63 | <> 64 | {/* Products only: item is no longer active */} 65 | {type === 'product' && !isActive && ( 66 | 75 | )} 76 | 77 | )} 78 |
79 | ) 80 | }) 81 | 82 | export default ShopifyDocumentStatus 83 | -------------------------------------------------------------------------------- /schemas/objects/module/gridItem.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | import blocksToText from '../../../utils/blocksToText' 4 | 5 | export default defineField({ 6 | name: 'gridItem', 7 | title: 'Item', 8 | type: 'object', 9 | fields: [ 10 | // Title 11 | defineField({ 12 | name: 'title', 13 | title: 'Title', 14 | type: 'string', 15 | validation: (Rule) => Rule.required(), 16 | }), 17 | // Image 18 | defineField({ 19 | name: 'image', 20 | title: 'Image', 21 | type: 'image', 22 | options: {hotspot: true}, 23 | validation: (Rule) => Rule.required(), 24 | }), 25 | // Body 26 | defineField({ 27 | name: 'body', 28 | title: 'Body', 29 | type: 'array', 30 | of: [ 31 | { 32 | lists: [], 33 | marks: { 34 | annotations: [ 35 | // Product 36 | { 37 | name: 'annotationProduct', 38 | type: 'annotationProduct', 39 | }, 40 | // Email 41 | { 42 | name: 'annotationLinkEmail', 43 | type: 'annotationLinkEmail', 44 | }, 45 | // Internal link 46 | { 47 | name: 'annotationLinkInternal', 48 | type: 'annotationLinkInternal', 49 | }, 50 | // URL 51 | { 52 | name: 'annotationLinkExternal', 53 | type: 'annotationLinkExternal', 54 | }, 55 | ], 56 | decorators: [ 57 | { 58 | title: 'Italic', 59 | value: 'em', 60 | }, 61 | { 62 | title: 'Strong', 63 | value: 'strong', 64 | }, 65 | ], 66 | }, 67 | // Regular styles 68 | styles: [], 69 | // Paragraphs 70 | type: 'block', 71 | }, 72 | ], 73 | validation: (Rule) => Rule.required(), 74 | }), 75 | ], 76 | preview: { 77 | select: { 78 | body: 'body', 79 | image: 'image', 80 | title: 'title', 81 | }, 82 | prepare(selection) { 83 | const {body, image, title} = selection 84 | return { 85 | media: image, 86 | subtitle: body && blocksToText(body), 87 | title, 88 | } 89 | }, 90 | }, 91 | }) 92 | -------------------------------------------------------------------------------- /schemas/objects/module/callToAction.tsx: -------------------------------------------------------------------------------- 1 | import {BlockElementIcon, ImageIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export default defineField({ 5 | name: 'module.callToAction', 6 | title: 'Call to action', 7 | type: 'object', 8 | icon: BlockElementIcon, 9 | fieldsets: [ 10 | { 11 | name: 'copy', 12 | title: 'Copy', 13 | }, 14 | ], 15 | fields: [ 16 | // Layout 17 | defineField({ 18 | name: 'layout', 19 | title: 'Layout direction', 20 | type: 'string', 21 | initialValue: 'left', 22 | options: { 23 | direction: 'horizontal', 24 | layout: 'radio', 25 | list: [ 26 | { 27 | title: 'Content / Copy', 28 | value: 'left', 29 | }, 30 | { 31 | title: 'Copy / Content', 32 | value: 'right', 33 | }, 34 | ], 35 | }, 36 | validation: (Rule) => Rule.required(), 37 | }), 38 | // Title 39 | defineField({ 40 | name: 'title', 41 | title: 'Title', 42 | type: 'string', 43 | validation: (Rule) => Rule.required(), 44 | fieldset: 'copy', 45 | }), 46 | // Body 47 | defineField({ 48 | name: 'body', 49 | title: 'Body', 50 | type: 'text', 51 | rows: 2, 52 | fieldset: 'copy', 53 | }), 54 | // Link 55 | defineField({ 56 | name: 'links', 57 | title: 'Link', 58 | type: 'array', 59 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 60 | validation: (Rule) => Rule.max(1), 61 | fieldset: 'copy', 62 | }), 63 | // Content 64 | defineField({ 65 | name: 'content', 66 | title: 'Content', 67 | type: 'array', 68 | validation: (Rule) => Rule.required().max(1), 69 | of: [ 70 | { 71 | icon: ImageIcon, 72 | type: 'image', 73 | title: 'Image', 74 | options: {hotspot: true}, 75 | }, 76 | { 77 | name: 'productWithVariant', 78 | title: 'Product + Variant', 79 | type: 'productWithVariant', 80 | validation: (Rule) => Rule.required(), 81 | }, 82 | ], 83 | }), 84 | ], 85 | preview: { 86 | select: { 87 | title: 'title', 88 | }, 89 | prepare(selection) { 90 | const {title} = selection 91 | return { 92 | subtitle: 'Call to action', 93 | title, 94 | media: BlockElementIcon, 95 | } 96 | }, 97 | }, 98 | }) 99 | -------------------------------------------------------------------------------- /schemas/singletons/settings.ts: -------------------------------------------------------------------------------- 1 | import {CogIcon} from '@sanity/icons' 2 | import {defineType, defineField} from 'sanity' 3 | 4 | const TITLE = 'Settings' 5 | interface ProductOptions { 6 | title: string 7 | } 8 | 9 | export default defineType({ 10 | name: 'settings', 11 | title: TITLE, 12 | type: 'document', 13 | icon: CogIcon, 14 | groups: [ 15 | { 16 | default: true, 17 | name: 'navigation', 18 | title: 'Navigation', 19 | }, 20 | { 21 | name: 'productOptions', 22 | title: 'Product options', 23 | }, 24 | { 25 | name: 'notFoundPage', 26 | title: '404 page', 27 | }, 28 | { 29 | name: 'seo', 30 | title: 'SEO', 31 | }, 32 | ], 33 | fields: [ 34 | // Menu 35 | defineField({ 36 | name: 'menu', 37 | title: 'Menu', 38 | type: 'menuSettings', 39 | group: 'navigation', 40 | }), 41 | // Footer 42 | defineField({ 43 | name: 'footer', 44 | title: 'Footer', 45 | type: 'footerSettings', 46 | group: 'navigation', 47 | }), 48 | // Custom product options 49 | defineField({ 50 | name: 'customProductOptions', 51 | title: 'Custom product options', 52 | type: 'array', 53 | group: 'productOptions', 54 | of: [ 55 | { 56 | name: 'customProductOption.color', 57 | type: 'customProductOption.color', 58 | }, 59 | { 60 | name: 'customProductOption.size', 61 | type: 'customProductOption.size', 62 | }, 63 | ], 64 | validation: (Rule) => 65 | Rule.custom((options: ProductOptions[] | undefined) => { 66 | // Each product option type must have a unique title 67 | if (options) { 68 | const uniqueTitles = new Set(options.map((option) => option.title)) 69 | if (options.length > uniqueTitles.size) { 70 | return 'Each product option type must have a unique title' 71 | } 72 | } 73 | return true 74 | }), 75 | }), 76 | // Not found page 77 | defineField({ 78 | name: 'notFoundPage', 79 | title: '404 page', 80 | type: 'notFoundPage', 81 | group: 'notFoundPage', 82 | }), 83 | // SEO 84 | defineField({ 85 | name: 'seo', 86 | title: 'SEO', 87 | type: 'seo', 88 | group: 'seo', 89 | }), 90 | ], 91 | preview: { 92 | prepare() { 93 | return { 94 | title: TITLE, 95 | } 96 | }, 97 | }, 98 | }) 99 | -------------------------------------------------------------------------------- /schemas/objects/module/image.ts: -------------------------------------------------------------------------------- 1 | import {ImageIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | const VARIANTS = [ 5 | {title: 'Simple', value: undefined}, 6 | {title: 'Caption', value: 'caption'}, 7 | {title: 'Call to action', value: 'callToAction'}, 8 | {title: 'Product hotspots', value: 'productHotspots'}, 9 | {title: 'Product tags', value: 'productTags'}, 10 | ] 11 | 12 | export default defineField({ 13 | name: 'module.image', 14 | title: 'Image', 15 | type: 'object', 16 | icon: ImageIcon, 17 | fields: [ 18 | // Image 19 | defineField({ 20 | name: 'image', 21 | title: 'Image', 22 | type: 'image', 23 | options: {hotspot: true}, 24 | validation: (Rule) => Rule.required(), 25 | }), 26 | // Variant 27 | defineField({ 28 | name: 'variant', 29 | title: 'Variant', 30 | type: 'string', 31 | options: { 32 | direction: 'horizontal', 33 | layout: 'radio', 34 | list: VARIANTS, 35 | }, 36 | initialValue: undefined, 37 | }), 38 | // Caption 39 | defineField({ 40 | name: 'caption', 41 | title: 'Caption', 42 | type: 'text', 43 | rows: 2, 44 | hidden: ({parent}) => parent.variant !== 'caption', 45 | }), 46 | // Call to action 47 | defineField({ 48 | name: 'callToAction', 49 | title: 'Call to action', 50 | type: 'imageCallToAction', 51 | hidden: ({parent}) => parent.variant !== 'callToAction', 52 | }), 53 | // Product hotspots 54 | defineField({ 55 | name: 'productHotspots', 56 | title: 'Hotspots', 57 | type: 'productHotspots', 58 | hidden: ({parent}) => parent.variant !== 'productHotspots', 59 | }), 60 | // Product tags 61 | defineField({ 62 | name: 'productTags', 63 | title: 'Products', 64 | type: 'array', 65 | hidden: ({parent}) => parent.variant !== 'productTags', 66 | of: [ 67 | { 68 | name: 'productWithVariant', 69 | title: 'Product + Variant', 70 | type: 'productWithVariant', 71 | }, 72 | ], 73 | }), 74 | ], 75 | preview: { 76 | select: { 77 | fileName: 'image.asset.originalFilename', 78 | image: 'image', 79 | variant: 'variant', 80 | }, 81 | prepare(selection) { 82 | const {fileName, image, variant} = selection 83 | const currentVariant = VARIANTS.find((v) => v.value === variant) 84 | 85 | return { 86 | media: image, 87 | subtitle: 'Image' + (currentVariant ? ` [${currentVariant.title}]` : ''), 88 | title: fileName, 89 | } 90 | }, 91 | }, 92 | }) 93 | -------------------------------------------------------------------------------- /components/icons/Shopify.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ShopifyIcon = () => { 4 | return ( 5 | 6 | 10 | 14 | 18 | 19 | ) 20 | } 21 | 22 | export default ShopifyIcon 23 | -------------------------------------------------------------------------------- /schemas/objects/shopify/shopifyCollection.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'shopifyCollection', 5 | title: 'Shopify', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | readOnly: true, 12 | fieldsets: [ 13 | { 14 | name: 'status', 15 | title: 'Status', 16 | }, 17 | ], 18 | fields: [ 19 | // Created at 20 | defineField({ 21 | fieldset: 'status', 22 | name: 'createdAt', 23 | title: 'Created at', 24 | type: 'string', 25 | }), 26 | // Updated at 27 | defineField({ 28 | fieldset: 'status', 29 | name: 'updatedAt', 30 | title: 'Updated at', 31 | type: 'string', 32 | }), 33 | // Deleted 34 | defineField({ 35 | fieldset: 'status', 36 | name: 'isDeleted', 37 | title: 'Deleted from Shopify?', 38 | type: 'boolean', 39 | }), 40 | // Title 41 | { 42 | name: 'title', 43 | title: 'Title', 44 | type: 'string', 45 | }, 46 | // Collection ID 47 | defineField({ 48 | name: 'id', 49 | title: 'ID', 50 | type: 'number', 51 | description: 'Shopify Collection ID', 52 | }), 53 | // GID 54 | defineField({ 55 | name: 'gid', 56 | title: 'GID', 57 | type: 'string', 58 | description: 'Shopify Collection GID', 59 | }), 60 | // Slug 61 | defineField({ 62 | name: 'slug', 63 | title: 'Slug', 64 | description: 'Shopify Collection handle', 65 | type: 'slug', 66 | }), 67 | // Description 68 | defineField({ 69 | name: 'descriptionHtml', 70 | title: 'HTML Description', 71 | type: 'text', 72 | rows: 5, 73 | }), 74 | // Image URL 75 | defineField({ 76 | name: 'imageUrl', 77 | title: 'Image URL', 78 | type: 'string', 79 | }), 80 | // Rules 81 | defineField({ 82 | name: 'rules', 83 | title: 'Rules', 84 | type: 'array', 85 | description: 'Include Shopify products that satisfy these conditions', 86 | of: [ 87 | { 88 | type: 'collectionRule', 89 | }, 90 | ], 91 | }), 92 | // Disjunctive rules 93 | defineField({ 94 | name: 'disjunctive', 95 | title: 'Disjunctive rules?', 96 | description: 'Require any condition if true, otherwise require all conditions', 97 | type: 'boolean', 98 | }), 99 | // Sort order 100 | defineField({ 101 | name: 'sortOrder', 102 | title: 'Sort order', 103 | type: 'string', 104 | }), 105 | ], 106 | }) 107 | -------------------------------------------------------------------------------- /schemas/objects/shopify/shopifyProductVariant.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'shopifyProductVariant', 5 | title: 'Shopify', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | fieldsets: [ 12 | { 13 | name: 'options', 14 | title: 'Options', 15 | options: { 16 | columns: 3, 17 | }, 18 | }, 19 | { 20 | name: 'status', 21 | title: 'Status', 22 | }, 23 | ], 24 | fields: [ 25 | // Created at 26 | defineField({ 27 | fieldset: 'status', 28 | name: 'createdAt', 29 | title: 'Created at', 30 | type: 'string', 31 | }), 32 | // Updated at 33 | defineField({ 34 | fieldset: 'status', 35 | name: 'updatedAt', 36 | title: 'Updated at', 37 | type: 'string', 38 | }), 39 | // Product status 40 | defineField({ 41 | fieldset: 'status', 42 | name: 'status', 43 | title: 'Product status', 44 | type: 'string', 45 | options: { 46 | layout: 'dropdown', 47 | list: ['active', 'archived', 'draft'], 48 | }, 49 | validation: (Rule) => Rule.required(), 50 | }), 51 | // Deleted 52 | defineField({ 53 | fieldset: 'status', 54 | name: 'isDeleted', 55 | title: 'Deleted from Shopify?', 56 | type: 'boolean', 57 | }), 58 | // Title 59 | defineField({ 60 | name: 'title', 61 | title: 'Title', 62 | type: 'string', 63 | }), 64 | // SKU 65 | defineField({ 66 | name: 'sku', 67 | title: 'SKU', 68 | type: 'string', 69 | }), 70 | // ID 71 | defineField({ 72 | name: 'id', 73 | title: 'ID', 74 | type: 'number', 75 | description: 'Shopify Product Variant ID', 76 | }), 77 | // GID 78 | defineField({ 79 | name: 'gid', 80 | title: 'GID', 81 | type: 'string', 82 | description: 'Shopify Product Variant GID', 83 | }), 84 | // Product ID 85 | defineField({ 86 | name: 'productId', 87 | title: 'Product ID', 88 | type: 'number', 89 | }), 90 | // Product GID 91 | defineField({ 92 | name: 'productGid', 93 | title: 'Product GID', 94 | type: 'string', 95 | }), 96 | // Price 97 | defineField({ 98 | name: 'price', 99 | title: 'Price', 100 | type: 'number', 101 | }), 102 | // Compare at price 103 | defineField({ 104 | name: 'compareAtPrice', 105 | title: 'Compare at price', 106 | type: 'number', 107 | }), 108 | // Inventory 109 | defineField({ 110 | name: 'inventory', 111 | title: 'Inventory', 112 | type: 'inventory', 113 | options: { 114 | columns: 3, 115 | }, 116 | }), 117 | // Option 1 118 | defineField({ 119 | fieldset: 'options', 120 | name: 'option1', 121 | title: 'Option 1', 122 | type: 'string', 123 | }), 124 | // Option 2 125 | defineField({ 126 | fieldset: 'options', 127 | name: 'option2', 128 | title: 'Option 2', 129 | type: 'string', 130 | }), 131 | // Option 3 132 | defineField({ 133 | fieldset: 'options', 134 | name: 'option3', 135 | title: 'Option 3', 136 | type: 'string', 137 | }), 138 | // Preview Image URL 139 | defineField({ 140 | name: 'previewImageUrl', 141 | title: 'Preview Image URL', 142 | type: 'string', 143 | description: 'Image displayed in both cart and checkout', 144 | }), 145 | ], 146 | readOnly: true, 147 | }) 148 | -------------------------------------------------------------------------------- /schemas/objects/shopify/shopifyProduct.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export default defineField({ 4 | name: 'shopifyProduct', 5 | title: 'Shopify', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | readOnly: true, 12 | fieldsets: [ 13 | { 14 | name: 'status', 15 | title: 'Status', 16 | }, 17 | { 18 | name: 'organization', 19 | title: 'Organization', 20 | options: { 21 | columns: 2, 22 | }, 23 | }, 24 | { 25 | name: 'variants', 26 | title: 'Variants', 27 | options: { 28 | collapsed: true, 29 | collapsible: true, 30 | }, 31 | }, 32 | ], 33 | fields: [ 34 | // Created at 35 | defineField({ 36 | fieldset: 'status', 37 | name: 'createdAt', 38 | title: 'Created at', 39 | type: 'string', 40 | }), 41 | // Updated at 42 | defineField({ 43 | fieldset: 'status', 44 | name: 'updatedAt', 45 | title: 'Updated at', 46 | type: 'string', 47 | }), 48 | // Product status 49 | defineField({ 50 | fieldset: 'status', 51 | name: 'status', 52 | title: 'Product status', 53 | type: 'string', 54 | options: { 55 | layout: 'dropdown', 56 | list: ['active', 'archived', 'draft'], 57 | }, 58 | }), 59 | // Deleted 60 | defineField({ 61 | fieldset: 'status', 62 | name: 'isDeleted', 63 | title: 'Deleted from Shopify?', 64 | type: 'boolean', 65 | }), 66 | // Title 67 | defineField({ 68 | name: 'title', 69 | title: 'Title', 70 | type: 'string', 71 | description: 'Title displayed in both cart and checkout', 72 | }), 73 | // Product ID 74 | defineField({ 75 | name: 'id', 76 | title: 'ID', 77 | type: 'number', 78 | description: 'Shopify Product ID', 79 | }), 80 | // Product ID 81 | defineField({ 82 | name: 'gid', 83 | title: 'GID', 84 | type: 'string', 85 | description: 'Shopify Product GID', 86 | }), 87 | // Slug 88 | defineField({ 89 | name: 'slug', 90 | title: 'Slug', 91 | type: 'slug', 92 | description: 'Shopify Product handle', 93 | }), 94 | // Description 95 | defineField({ 96 | name: 'descriptionHtml', 97 | title: 'HTML Description', 98 | type: 'text', 99 | rows: 5, 100 | }), 101 | // Product Type 102 | defineField({ 103 | fieldset: 'organization', 104 | name: 'productType', 105 | title: 'Product type', 106 | type: 'string', 107 | }), 108 | // Vendor 109 | defineField({ 110 | fieldset: 'organization', 111 | name: 'vendor', 112 | title: 'Vendor', 113 | type: 'string', 114 | }), 115 | // Tags 116 | defineField({ 117 | fieldset: 'organization', 118 | name: 'tags', 119 | title: 'Tags', 120 | type: 'string', 121 | }), 122 | // Price range 123 | defineField({ 124 | name: 'priceRange', 125 | title: 'Price range', 126 | type: 'priceRange', 127 | }), 128 | // Preview Image URL 129 | defineField({ 130 | name: 'previewImageUrl', 131 | title: 'Preview Image URL', 132 | type: 'string', 133 | description: 'Image displayed in both cart and checkout', 134 | }), 135 | // Options 136 | defineField({ 137 | name: 'options', 138 | title: 'Options', 139 | type: 'array', 140 | of: [ 141 | { 142 | type: 'option', 143 | }, 144 | ], 145 | }), 146 | // Variants 147 | defineField({ 148 | fieldset: 'variants', 149 | name: 'variants', 150 | title: 'Variants', 151 | type: 'array', 152 | of: [ 153 | { 154 | title: 'Variant', 155 | type: 'reference', 156 | weak: true, 157 | to: [{type: 'productVariant'}], 158 | }, 159 | ], 160 | }), 161 | ], 162 | }) 163 | -------------------------------------------------------------------------------- /schemas/documents/product.tsx: -------------------------------------------------------------------------------- 1 | import {TagIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import ShopifyIcon from '../../components/icons/Shopify' 4 | import ProductHiddenInput from '../../components/inputs/ProductHidden' 5 | import ShopifyDocumentStatus from '../../components/media/ShopifyDocumentStatus' 6 | import {defineField, defineType} from 'sanity' 7 | import {getPriceRange} from '../../utils/getPriceRange' 8 | 9 | const GROUPS = [ 10 | { 11 | name: 'editorial', 12 | title: 'Editorial', 13 | default: true, 14 | }, 15 | { 16 | name: 'shopifySync', 17 | title: 'Shopify sync', 18 | icon: ShopifyIcon, 19 | }, 20 | { 21 | name: 'seo', 22 | title: 'SEO', 23 | }, 24 | ] 25 | 26 | export default defineType({ 27 | name: 'product', 28 | title: 'Product', 29 | type: 'document', 30 | icon: TagIcon, 31 | groups: GROUPS, 32 | fields: [ 33 | defineField({ 34 | name: 'hidden', 35 | type: 'string', 36 | components: { 37 | field: ProductHiddenInput, 38 | }, 39 | group: GROUPS.map((group) => group.name), 40 | hidden: ({parent}) => { 41 | const isActive = parent?.store?.status === 'active' 42 | const isDeleted = parent?.store?.isDeleted 43 | return !parent?.store || (isActive && !isDeleted) 44 | }, 45 | }), 46 | // Title (proxy) 47 | defineField({ 48 | name: 'titleProxy', 49 | title: 'Title', 50 | type: 'proxyString', 51 | options: {field: 'store.title'}, 52 | }), 53 | // Slug (proxy) 54 | defineField({ 55 | name: 'slugProxy', 56 | title: 'Slug', 57 | type: 'proxyString', 58 | options: {field: 'store.slug.current'}, 59 | }), 60 | // Color theme 61 | defineField({ 62 | name: 'colorTheme', 63 | title: 'Color theme', 64 | type: 'reference', 65 | to: [{type: 'colorTheme'}], 66 | group: 'editorial', 67 | }), 68 | defineField({ 69 | name: 'body', 70 | title: 'Body', 71 | type: 'body', 72 | group: 'editorial', 73 | }), 74 | defineField({ 75 | name: 'store', 76 | title: 'Shopify', 77 | type: 'shopifyProduct', 78 | description: 'Product data from Shopify (read-only)', 79 | group: 'shopifySync', 80 | }), 81 | defineField({ 82 | name: 'seo', 83 | title: 'SEO', 84 | type: 'seo.shopify', 85 | group: 'seo', 86 | }), 87 | ], 88 | orderings: [ 89 | { 90 | name: 'titleAsc', 91 | title: 'Title (A-Z)', 92 | by: [{field: 'store.title', direction: 'asc'}], 93 | }, 94 | { 95 | name: 'titleDesc', 96 | title: 'Title (Z-A)', 97 | by: [{field: 'store.title', direction: 'desc'}], 98 | }, 99 | { 100 | name: 'priceDesc', 101 | title: 'Price (Highest first)', 102 | by: [{field: 'store.priceRange.minVariantPrice', direction: 'desc'}], 103 | }, 104 | { 105 | name: 'priceAsc', 106 | title: 'Price (Lowest first)', 107 | by: [{field: 'store.priceRange.minVariantPrice', direction: 'asc'}], 108 | }, 109 | ], 110 | preview: { 111 | select: { 112 | isDeleted: 'store.isDeleted', 113 | options: 'store.options', 114 | previewImageUrl: 'store.previewImageUrl', 115 | priceRange: 'store.priceRange', 116 | status: 'store.status', 117 | title: 'store.title', 118 | variants: 'store.variants', 119 | }, 120 | prepare(selection) { 121 | const {isDeleted, options, previewImageUrl, priceRange, status, title, variants} = selection 122 | 123 | const optionCount = options?.length 124 | const variantCount = variants?.length 125 | 126 | let description = [ 127 | variantCount ? pluralize('variant', variantCount, true) : 'No variants', 128 | optionCount ? pluralize('option', optionCount, true) : 'No options', 129 | ] 130 | 131 | let subtitle = getPriceRange(priceRange) 132 | if (status !== 'active') { 133 | subtitle = '(Unavailable in Shopify)' 134 | } 135 | if (isDeleted) { 136 | subtitle = '(Deleted from Shopify)' 137 | } 138 | 139 | return { 140 | description: description.join(' / '), 141 | subtitle, 142 | title, 143 | media: ( 144 | 151 | ), 152 | } 153 | }, 154 | }, 155 | }) 156 | -------------------------------------------------------------------------------- /plugins/customDocumentActions/shopifyDelete.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import {TrashIcon} from '@sanity/icons' 3 | import {Stack, Text, useToast} from '@sanity/ui' 4 | import { 5 | type DocumentActionDescription, 6 | type DocumentActionConfirmDialogProps, 7 | useClient, 8 | } from 'sanity' 9 | import {useRouter} from 'sanity/router' 10 | import type {ShopifyDocument, ShopifyDocumentActionProps} from './types' 11 | import {SANITY_API_VERSION} from '../../constants' 12 | 13 | export default (props: ShopifyDocumentActionProps): DocumentActionDescription | undefined => { 14 | const { 15 | draft, 16 | onComplete, 17 | type, 18 | published, 19 | }: { 20 | draft: ShopifyDocument 21 | published: ShopifyDocument 22 | type: string 23 | onComplete: () => void 24 | } = props 25 | 26 | const [dialogOpen, setDialogOpen] = useState(false) 27 | 28 | const router = useRouter() 29 | const toast = useToast() 30 | const client = useClient({apiVersion: SANITY_API_VERSION}) 31 | 32 | let dialog: DocumentActionConfirmDialogProps | null = null 33 | 34 | if (type === 'product') { 35 | dialog = { 36 | message: ( 37 | 38 | Delete the current product and all associated variants in your dataset. 39 | No content on Shopify will be deleted. 40 | 41 | ), 42 | onCancel: onComplete, 43 | onConfirm: async () => { 44 | const productId = published?.store?.id 45 | 46 | // Find product variant documents with matching Shopify Product ID 47 | let productVariantIds: string[] = [] 48 | if (productId) { 49 | productVariantIds = await client.fetch( 50 | `*[ 51 | _type == "productVariant" 52 | && store.productId == $productId 53 | ]._id`, 54 | {productId} 55 | ) 56 | } 57 | 58 | // Delete current document (including draft) 59 | const transaction = client.transaction() 60 | if (published?._id) { 61 | transaction.delete(published._id) 62 | } 63 | if (draft?._id) { 64 | transaction.delete(draft._id) 65 | } 66 | 67 | // Delete all product variants with matching IDs 68 | productVariantIds?.forEach((documentId) => { 69 | if (documentId) { 70 | transaction.delete(documentId) 71 | transaction.delete(`drafts.${documentId}`) 72 | } 73 | }) 74 | 75 | try { 76 | await transaction.commit() 77 | // Navigate back to products root 78 | router.navigateUrl({path: '/desk/products'}) 79 | } catch (err) { 80 | let message = 'Unknown Error' 81 | if (err instanceof Error) message = err.message 82 | 83 | toast.push({ 84 | status: 'error', 85 | title: message, 86 | }) 87 | } finally { 88 | // Signal that the action is complete 89 | onComplete() 90 | } 91 | }, 92 | type: 'confirm', 93 | } 94 | } 95 | 96 | if (type === 'collection') { 97 | dialog = { 98 | message: ( 99 | 100 | Delete the current collection in your dataset. 101 | No content on Shopify will be deleted. 102 | 103 | ), 104 | onCancel: onComplete, 105 | onConfirm: async () => { 106 | // Delete current document (including draft) 107 | const transaction = client.transaction() 108 | if (published?._id) { 109 | transaction.delete(published._id) 110 | } 111 | if (draft?._id) { 112 | transaction.delete(draft._id) 113 | } 114 | 115 | try { 116 | await transaction.commit() 117 | // Navigate back to collections root 118 | router.navigateUrl({path: '/desk/collections'}) 119 | } catch (err) { 120 | let message = 'Unknown Error' 121 | if (err instanceof Error) message = err.message 122 | 123 | toast.push({ 124 | status: 'error', 125 | title: message, 126 | }) 127 | } finally { 128 | // Signal that the action is complete 129 | onComplete() 130 | } 131 | }, 132 | type: 'confirm', 133 | } 134 | } 135 | 136 | if (!dialog) { 137 | return 138 | } 139 | 140 | return { 141 | tone: 'critical', 142 | dialog: dialogOpen && dialog, 143 | icon: TrashIcon, 144 | label: 'Delete', 145 | onHandle: () => setDialogOpen(true), 146 | shortcut: 'Ctrl+Alt+D', 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /schemas/objects/shopify/productWithVariant.tsx: -------------------------------------------------------------------------------- 1 | import {TagIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import React from 'react' 4 | import {defineField} from 'sanity' 5 | 6 | import ShopifyDocumentStatus from '../../../components/media/ShopifyDocumentStatus' 7 | import {SANITY_API_VERSION} from '../../../constants' 8 | import {getPriceRange} from '../../../utils/getPriceRange' 9 | 10 | export default defineField({ 11 | name: 'productWithVariant', 12 | title: 'Product with variant', 13 | type: 'object', 14 | icon: TagIcon, 15 | fields: [ 16 | defineField({ 17 | name: 'product', 18 | type: 'reference', 19 | to: [{type: 'product'}], 20 | weak: true, 21 | }), 22 | defineField({ 23 | name: 'variant', 24 | type: 'reference', 25 | to: [{type: 'productVariant'}], 26 | weak: true, 27 | description: 'First variant will be selected if left empty', 28 | options: { 29 | filter: ({parent}) => { 30 | // @ts-ignore 31 | const productId = parent?.product?._ref 32 | const shopifyProductId = Number(productId?.replace('shopifyProduct-', '')) 33 | 34 | if (!shopifyProductId) { 35 | return {filter: '', params: {}} 36 | } 37 | 38 | // TODO: once variants are correctly marked as deleted, this could be made a little more efficient 39 | // e.g. filter: 'store.productId == $shopifyProductId && !store.isDeleted', 40 | return { 41 | filter: `_id in *[_id == $shopifyProductId][0].store.variants[]._ref`, 42 | params: { 43 | shopifyProductId: productId, 44 | }, 45 | } 46 | }, 47 | }, 48 | hidden: ({parent}) => { 49 | const productSelected = parent?.product 50 | return !productSelected 51 | }, 52 | validation: (Rule) => 53 | Rule.custom(async (value, {parent, getClient}) => { 54 | // Selected product in adjacent `product` field 55 | // @ts-ignore 56 | const productId = parent?.product?._ref 57 | 58 | // Selected product variant 59 | const productVariantId = value?._ref 60 | 61 | if (!productId || !productVariantId) { 62 | return true 63 | } 64 | 65 | // If both product + product variant are specified, 66 | // check to see if `product` references this product variant. 67 | const result = await getClient({apiVersion: SANITY_API_VERSION}).fetch( 68 | `*[_id == $productId && references($productVariantId)][0]._id`, 69 | { 70 | productId, 71 | productVariantId, 72 | } 73 | ) 74 | 75 | return result ? true : 'Invalid product variant' 76 | }), 77 | }), 78 | ], 79 | preview: { 80 | select: { 81 | defaultVariantTitle: 'product.store.variants.0.store.title', 82 | isDeleted: 'product.store.isDeleted', 83 | optionCount: 'product.store.options.length', 84 | previewImageUrl: 'product.store.previewImageUrl', 85 | priceRange: 'product.store.priceRange', 86 | status: 'product.store.status', 87 | title: 'product.store.title', 88 | variantCount: 'product.store.variants.length', 89 | variantPreviewImageUrl: 'variant.store.previewImageUrl', 90 | variantTitle: 'variant.store.title', 91 | }, 92 | prepare(selection) { 93 | const { 94 | defaultVariantTitle, 95 | isDeleted, 96 | optionCount, 97 | previewImageUrl, 98 | priceRange, 99 | status, 100 | title, 101 | variantCount, 102 | variantPreviewImageUrl, 103 | variantTitle, 104 | } = selection 105 | 106 | const productVariantTitle = variantTitle || defaultVariantTitle 107 | 108 | let previewTitle = [title] 109 | if (productVariantTitle) { 110 | previewTitle.push(`[${productVariantTitle}]`) 111 | } 112 | 113 | let description = [ 114 | variantCount ? pluralize('variant', variantCount, true) : 'No variants', 115 | optionCount ? pluralize('option', optionCount, true) : 'No options', 116 | ] 117 | 118 | let subtitle = getPriceRange(priceRange) 119 | if (status !== 'active') { 120 | subtitle = '(Unavailable in Shopify)' 121 | } 122 | if (isDeleted) { 123 | subtitle = '(Deleted from Shopify)' 124 | } 125 | 126 | return { 127 | media: ( 128 | 135 | ), 136 | description: description.join(' / '), 137 | subtitle, 138 | title: previewTitle.join(' '), 139 | } 140 | }, 141 | }, 142 | }) 143 | -------------------------------------------------------------------------------- /schemas/documents/collection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {defineField, defineType} from 'sanity' 3 | import {PackageIcon} from '@sanity/icons' 4 | import {getExtension} from '@sanity/asset-utils' 5 | import pluralize from 'pluralize-esm' 6 | import CollectionHiddenInput from '../../components/inputs/CollectionHidden' 7 | import ShopifyIcon from '../../components/icons/Shopify' 8 | import ShopifyDocumentStatus from '../../components/media/ShopifyDocumentStatus' 9 | 10 | const GROUPS = [ 11 | { 12 | name: 'theme', 13 | title: 'Theme', 14 | }, 15 | { 16 | default: true, 17 | name: 'editorial', 18 | title: 'Editorial', 19 | }, 20 | { 21 | name: 'shopifySync', 22 | title: 'Shopify sync', 23 | icon: ShopifyIcon, 24 | }, 25 | { 26 | name: 'seo', 27 | title: 'SEO', 28 | }, 29 | ] 30 | 31 | export default defineType({ 32 | name: 'collection', 33 | title: 'Collection', 34 | type: 'document', 35 | icon: PackageIcon, 36 | groups: GROUPS, 37 | fields: [ 38 | // Product hidden status 39 | defineField({ 40 | name: 'hidden', 41 | type: 'string', 42 | components: { 43 | field: CollectionHiddenInput, 44 | }, 45 | hidden: ({parent}) => { 46 | const isDeleted = parent?.store?.isDeleted 47 | return !isDeleted 48 | }, 49 | }), 50 | // Title (proxy) 51 | defineField({ 52 | name: 'titleProxy', 53 | title: 'Title', 54 | type: 'proxyString', 55 | options: {field: 'store.title'}, 56 | }), 57 | // Slug (proxy) 58 | defineField({ 59 | name: 'slugProxy', 60 | title: 'Slug', 61 | type: 'proxyString', 62 | options: {field: 'store.slug.current'}, 63 | }), 64 | // Color theme 65 | defineField({ 66 | name: 'colorTheme', 67 | title: 'Color theme', 68 | type: 'reference', 69 | to: [{type: 'colorTheme'}], 70 | group: 'theme', 71 | }), 72 | // Vector 73 | defineField({ 74 | name: 'vector', 75 | title: 'Vector artwork', 76 | type: 'image', 77 | description: 'Displayed in collection links using color theme', 78 | options: { 79 | accept: 'image/svg+xml', 80 | }, 81 | group: 'theme', 82 | validation: (Rule) => 83 | Rule.custom((image) => { 84 | if (!image?.asset?._ref) { 85 | return true 86 | } 87 | 88 | const format = getExtension(image.asset._ref) 89 | 90 | if (format !== 'svg') { 91 | return 'Image must be an SVG' 92 | } 93 | return true 94 | }), 95 | }), 96 | // Show hero 97 | defineField({ 98 | name: 'showHero', 99 | title: 'Show hero', 100 | type: 'boolean', 101 | description: 'If disabled, page title will be displayed instead', 102 | group: 'editorial', 103 | }), 104 | // Hero 105 | defineField({ 106 | name: 'hero', 107 | title: 'Hero', 108 | type: 'hero.collection', 109 | hidden: ({document}) => !document?.showHero, 110 | group: 'editorial', 111 | }), 112 | // Modules 113 | defineField({ 114 | name: 'modules', 115 | title: 'Modules', 116 | type: 'array', 117 | description: 'Editorial modules to associate with this collection', 118 | of: [ 119 | {type: 'module.callout'}, 120 | {type: 'module.callToAction'}, 121 | {type: 'module.image'}, 122 | {type: 'module.instagram'}, 123 | ], 124 | group: 'editorial', 125 | }), 126 | // Shopify collection 127 | defineField({ 128 | name: 'store', 129 | title: 'Shopify', 130 | type: 'shopifyCollection', 131 | description: 'Collection data from Shopify (read-only)', 132 | group: 'shopifySync', 133 | }), 134 | // SEO 135 | defineField({ 136 | name: 'seo', 137 | title: 'SEO', 138 | type: 'seo.shopify', 139 | group: 'seo', 140 | }), 141 | ], 142 | orderings: [ 143 | { 144 | name: 'titleAsc', 145 | title: 'Title (A-Z)', 146 | by: [{field: 'store.title', direction: 'asc'}], 147 | }, 148 | { 149 | name: 'titleDesc', 150 | title: 'Title (Z-A)', 151 | by: [{field: 'store.title', direction: 'desc'}], 152 | }, 153 | ], 154 | preview: { 155 | select: { 156 | imageUrl: 'store.imageUrl', 157 | isDeleted: 'store.isDeleted', 158 | rules: 'store.rules', 159 | title: 'store.title', 160 | }, 161 | prepare(selection) { 162 | const {imageUrl, isDeleted, rules, title} = selection 163 | const ruleCount = rules?.length || 0 164 | 165 | return { 166 | media: ( 167 | 173 | ), 174 | subtitle: ruleCount > 0 ? `Automated (${pluralize('rule', ruleCount, true)})` : 'Manual', 175 | title, 176 | } 177 | }, 178 | }, 179 | }) 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > This repo has been archived, but the Sanity / Shopify Studio is still maintained and is available via our CLI. To install, run `npm create sanity@latest -- --template shopify` 3 | 4 | # Sanity Studio for Shopify Projects 5 | 6 |

7 | 8 | ## About 9 | 10 | This Sanity Studio is configured for headless Shopify projects that use the official [Sanity Connect app][sanity-shopify], allowing you to extend Shopify products and collections with your own rich editorial content. 11 | 12 | It contains examples of customizing your [desk structure][docs-desk-structure], [document actions][docs-document-actions] and [input components][docs-input-components]. 13 | 14 | This studio can be used with our [Hydrogen starter][hydrogen-demo], your frontend, or anywhere else you want your e-commerce content to go. 15 | 16 | ## Features 17 | 18 | This studio comes preconfigured with Shopify-friendly content schemas and a whole host of customizations to make managing Shopify data in your Sanity studio easier. 19 | 20 | It also comes with several convenient layout modules which can be re-used across various pages. 21 | 22 | **[View studio features][studio-features]** 23 | 24 | ## Assumptions 25 | 26 | No two custom storefronts are the same, and we've taken a few strong opinions on how we've approached this studio. 27 | 28 | - Synced Shopify data for `collection`, `product` and `productVariant` documents are stored in a read-only object, `store` 29 | - Shopify is the source of truth for both product titles, slugs (handles) and thumbnail images 30 | - Shopify is the source of truth for collections 31 | - Sanity is used as an additional presentational layer to add custom metadata to both Shopify collections and products 32 | - For products: this includes a portable text field with support for editorial modules 33 | - For collections: this includes a customizable array of editorial modules 34 | - Some images (such as product and cart line item thumbnails) are served by Shopify's CDN whilst other images (such as those served in editorial modules) are handled by Sanity's Image API 35 | - We only concern ourselves with incoming data from Shopify _collections_, _products_ and _product variants_ 36 | 37 | We believe these rules work well for simpler use cases, and keeping product titles, images and slugs handled by Shopify helps keep content consistent as you navigate from your product views to the cart and ultimately checkout. Managing collections in Shopify gives you the flexibility to take full advantage of manual and automated collections. 38 | 39 | You may have differing opinions on how content best be modeled to fit your particular needs – this is normal and encouraged! Fortunately, Sanity was built with this flexibility in mind, and we've written [a guide on structured content patterns of e-commerce][structured-content-patterns] which may help inform how to tackle this challenge. 40 | 41 | ## Setup 42 | 43 | If you're reading this on GitHub, chances are you haven't initialized the studio locally yet. To do so, run the following shell command: 44 | 45 | ```sh 46 | # run a one-off initializing script: 47 | npx @sanity/cli init --template shopify 48 | ``` 49 | 50 | Make sure to run the tagged release! (`@sanity/cli`) 51 | 52 | ## Local Development 53 | 54 | ### Starting development server 55 | 56 | ```sh 57 | npm run dev 58 | ``` 59 | 60 | ### Deploying the studio 61 | 62 | ```sh 63 | npm run deploy 64 | ``` 65 | 66 | ### Upgrading Sanity Studio 67 | 68 | ```sh 69 | npm install sanity@latest 70 | ``` 71 | 72 | If you have the [Sanity CLI][docs-cli] installed, you can also run this with `sanity start|deploy|upgrade`. It comes with additional useful functionality. 73 | 74 | ## Upgrading from an old version? 75 | 76 | We've updated some of the schema in this template to support deploying a GraphQL API, as well as making updates to remove schema warnings in v3 of the Sanity Studio. There are some breaking changes in these updates, which may mean you see schema warnings in the Studio. Please see [`migrations/strictSchema.ts`][strict-schema-migration] as a guide to migrate your data. Don't forget to take a backup before making any changes! 77 | 78 | ## License 79 | 80 | This repository is published under the [MIT](license) license. 81 | 82 | [docs-cli]: https://www.sanity.io/docs/cli 83 | [docs-custom-input-components]: https://www.sanity.io/docs/custom-input-components 84 | [docs-desk-structure]: https://www.sanity.io/docs/structure-builder 85 | [docs-document-actions]: https://www.sanity.io/docs/document-actions 86 | [docs-input-components]: https://www.sanity.io/docs/custom-input-widgets 87 | [docs-string-input]: https://www.sanity.io/docs/string-type 88 | [hydrogen-demo]: https://github.com/sanity-io/hydrogen-sanity-demo 89 | [license]: https://github.com/sanity-io/sanity/blob/next/LICENSE 90 | [sanity-shopify]: https://apps.shopify.com/sanity-connect 91 | [structured-content-patterns]: https://www.sanity.io/guides/structured-content-patterns-for-e-commerce 92 | [studio-features]: docs/features.md 93 | [strict-schema-migration]: migrations/strictSchema.ts 94 | -------------------------------------------------------------------------------- /schemas/index.ts: -------------------------------------------------------------------------------- 1 | // Rich text annotations used in the block content editor 2 | import annotationLinkEmail from './annotations/linkEmail' 3 | import annotationLinkExternal from './annotations/linkExternal' 4 | import annotationLinkInternal from './annotations/linkInternal' 5 | import annotationProduct from './annotations/product' 6 | 7 | const annotations = [ 8 | annotationLinkEmail, 9 | annotationLinkExternal, 10 | annotationLinkInternal, 11 | annotationProduct, 12 | ] 13 | 14 | // Document types 15 | import collection from './documents/collection' 16 | import colorTheme from './documents/colorTheme' 17 | import page from './documents/page' 18 | import product from './documents/product' 19 | import productVariant from './documents/productVariant' 20 | 21 | const documents = [collection, colorTheme, page, product, productVariant] 22 | 23 | // Singleton document types 24 | import home from './singletons/home' 25 | import settings from './singletons/settings' 26 | 27 | const singletons = [home, settings] 28 | 29 | // Block content 30 | import body from './blocks/body' 31 | 32 | const blocks = [body] 33 | 34 | // Object types 35 | import customProductOptionColor from './objects/customProductOption/color' 36 | import customProductOptionColorObject from './objects/customProductOption/colorObject' 37 | import customProductOptionSize from './objects/customProductOption/size' 38 | import customProductOptionSizeObject from './objects/customProductOption/sizeObject' 39 | import footer from './objects/global/footer' 40 | import imageWithProductHotspots from './objects/hotspot/imageWithProductHotspots' 41 | import inventory from './objects/shopify/inventory' 42 | import linkExternal from './objects/global/linkExternal' 43 | import linkInternal from './objects/global/linkInternal' 44 | import links from './objects/global/links' 45 | import notFoundPage from './objects/global/notFoundPage' 46 | import heroCollection from './objects/hero/collection' 47 | import heroHome from './objects/hero/home' 48 | import heroPage from './objects/hero/page' 49 | import moduleAccordion from './objects/module/accordion' 50 | import accordionBody from './objects/module/accordionBody' 51 | import accordionGroup from './objects/module/accordionGroup' 52 | import moduleCallout from './objects/module/callout' 53 | import moduleCallToAction from './objects/module/callToAction' 54 | import moduleCollection from './objects/module/collection' 55 | import moduleGrid from './objects/module/grid' 56 | import gridItems from './objects/module/gridItem' 57 | import menu from './objects/global/menu' 58 | import moduleImage from './objects/module/image' 59 | import moduleImageAction from './objects/module/imageCallToAction' 60 | import moduleImages from './objects/module/images' 61 | import moduleInstagram from './objects/module/instagram' 62 | import moduleProduct from './objects/module/product' 63 | import moduleProducts from './objects/module/products' 64 | import placeholderString from './objects/shopify/placeholderString' 65 | import priceRange from './objects/shopify/priceRange' 66 | import spot from './objects/hotspot/spot' 67 | import productHotspots from './objects/hotspot/productHotspots' 68 | import option from './objects/shopify/option' 69 | import productWithVariant from './objects/shopify/productWithVariant' 70 | import proxyString from './objects/shopify/proxyString' 71 | import seo from './objects/seo/seo' 72 | import seoHome from './objects/seo/home' 73 | import seoPage from './objects/seo/page' 74 | import seoDescription from './objects/seo/description' 75 | import seoShopify from './objects/seo/shopify' 76 | import shopifyCollection from './objects/shopify/shopifyCollection' 77 | import shopifyCollectionRule from './objects/shopify/shopifyCollectionRule' 78 | import shopifyProduct from './objects/shopify/shopifyProduct' 79 | import shopifyProductVariant from './objects/shopify/shopifyProductVariant' 80 | 81 | // Collections 82 | import collectionGroup from './objects/collection/group' 83 | import collectionLinks from './objects/collection/links' 84 | 85 | const objects = [ 86 | customProductOptionColor, 87 | customProductOptionColorObject, 88 | customProductOptionSize, 89 | customProductOptionSizeObject, 90 | footer, 91 | imageWithProductHotspots, 92 | inventory, 93 | links, 94 | linkExternal, 95 | linkInternal, 96 | notFoundPage, 97 | heroCollection, 98 | heroHome, 99 | heroPage, 100 | moduleAccordion, 101 | accordionBody, 102 | accordionGroup, 103 | menu, 104 | moduleCallout, 105 | moduleCallToAction, 106 | moduleCollection, 107 | moduleGrid, 108 | gridItems, 109 | moduleImage, 110 | moduleImageAction, 111 | moduleImages, 112 | moduleInstagram, 113 | moduleProduct, 114 | moduleProducts, 115 | placeholderString, 116 | priceRange, 117 | spot, 118 | productHotspots, 119 | option, 120 | productWithVariant, 121 | proxyString, 122 | seo, 123 | seoHome, 124 | seoPage, 125 | seoDescription, 126 | seoShopify, 127 | shopifyCollection, 128 | shopifyCollectionRule, 129 | shopifyProduct, 130 | shopifyProductVariant, 131 | collectionGroup, 132 | collectionLinks, 133 | ] 134 | 135 | export const schemaTypes = [...annotations, ...singletons, ...objects, ...blocks, ...documents] 136 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Studio features 2 | 3 | ## Shopify friendly content schemas 4 | 5 | This studio is built to accommodate both collections and products coming from a Shopify Store. 6 | 7 | You can use the official [Sanity Connect app on Shopify][sanity-shopify] to sync your Shopify collection and products with your dataset. All your data will be available over APIs that you can access with [`@sanity/client`][docs-js-client] or the [HTTP API][docs-http-api]. 8 | 9 | Inside `/schemas` you'll find schema definitions for all the content types. They are organized in folders: 10 | 11 | - `/schemas/annotations/`: Annotations let editors mark up inline text in the block content editor with rich objects. These can be used to augment editorial content with product information. 12 | - `/schemas/documents/`: Document types determines the shape of the JSON documents that's stored in your content lake. This is where you define the content forms for things like collections, products, product variants, as well as articles. 13 | - `/schemas/objects/`: General purpose & re-usable content structures, such as links, custom product options and modules. 14 | 15 | ## Desk structure 16 | 17 | Sanity Studio will automatically list all your [document types][docs-document-types] out of the box. Sometimes you want a more streamlined editor experience. That's why you'll find a custom [desk-structure][docs-desk-structure] that's defined in `/desk`. It does the following things: 18 | 19 | - Groups product information and variants by individual products for more convenient editing 20 | - Creates a singleton document for controlling a homepage with custom editorial modules. 21 | - Creates a singleton document for settings to control navigation and global content 22 | - Lists general purpose pages for other editorial content 23 | 24 | ## Custom modules 25 | 26 | ### Call to action 27 | 28 |

29 | 30 | `/schemas/objects/module/callToAction.tsx` 31 | 32 | ### Callout 33 | 34 |

35 | 36 | `/schemas/objects/module/callout.tsx` 37 | 38 | ### Collection 39 | 40 |

41 | 42 | `/schemas/objects/module/collection.tsx` 43 | 44 | ### Image 45 | 46 |

47 | 48 | `/schemas/objects/module/image.ts` 49 | 50 | ### Instagram 51 | 52 |

53 | 54 | `/schemas/objects/module/instagram.ts` 55 | 56 | ### Product 57 | 58 |

59 | 60 | `/schemas/objects/module/product.tsx` 61 | 62 | ## Custom document actions 63 | 64 | Custom document actions let you override the default behavior of the publish button. The included document actions adds to the menu that you can find by pushing the chevron right to a document's publish button. 65 | 66 | You can find these in `/plugins/customDocumentActions/`. 67 | 68 | Read more about [document actions][docs-document-actions]. 69 | 70 | ### Delete product and variants 71 | 72 |

73 | 74 | `/plugins/customDocumentActions/shopifyDelete.tsx` 75 | 76 | Delete a product document including all its associated variants in your Sanity Content Lake. Without this document action, one would have to delete all variant document one-by-one. 77 | 78 | ### Edit in Shopify shortcut 79 | 80 |

81 | 82 | `/plugins/customDocumentActions/shopifyLink.ts` 83 | 84 | A shortcut to edit the current product or product variant in Shopify in a new window. You'll need to set your Shopify admin domain in `constants.ts`. 85 | 86 | ## Custom input and preview components 87 | 88 | ### Placeholder string input 89 | 90 |

image

91 | 92 | `/components/inputs/PlaceholderString.tsx` 93 | 94 | A simple wrapper around a regular [string input](string-input) that uses the value of another field as a placeholder. 95 | 96 | **Usage:** 97 | 98 | ```javascript 99 | { 100 | name: 'title', 101 | title: 'Title', 102 | type: 'placeholderString', 103 | options: { field: 'store.title' } 104 | }, 105 | ``` 106 | 107 | ### Shopify document status (for collections, products and product variants) 108 | 109 |

110 | 111 | `/components/inputs/CollectionHidden.tsx` 112 | `/components/inputs/ProductHidden.tsx` 113 | `/components/inputs/ProductVariantHidden.tsx` 114 | 115 | Display-only input fields that show the corresponding document's status in Shopify. 116 | 117 | For instance, if a product has been deleted from Shopify or has its status set to `draft` or `active`. 118 | 119 | ### Proxy string input 120 | 121 |

122 | 123 | `/components/inputs/ProxyString.tsx` 124 | 125 | A simple wrapper around a regular [String input field](string-input) that displays the value of another field as a read-only input. 126 | 127 | Since we are using certain product fields from Shopify as the source of truth (such as product title, slug and preview images) and store these in a separate `store` object, these proxy string inputs are used to better surface deeply nested fields to editors. 128 | 129 | **Usage** 130 | 131 | ```javascript 132 | { 133 | title: 'Slug', 134 | name: 'slugProxy', 135 | type: 'proxyString', 136 | options: { field: 'store.slug.current' } 137 | } 138 | ``` 139 | 140 | ### Shopify document status (preview component) 141 | 142 |

image 5

143 | 144 | `/components/media/ShopifyDocumentStatus.tsx` 145 | 146 | A custom preview component that will display collection, product and product variant images defined in `store.previewImageUrl`. 147 | 148 | By default, Sanity Connect will populate these fields with the default image from Shopify. These images are not re-uploaded into your dataset and instead reference Shopify's CDN directly. 149 | 150 | This preview component also has visual states for when a product is _unavailable_ in Shopify (e.g. if it has a non-active status), or if it's been removed from Shopify altogether. 151 | 152 | Sanity Connect will never delete your collection, product and product variant documents. 153 | 154 | [docs-desk-structure]: https://www.sanity.io/docs/structure-builder 155 | [docs-document-actions]: https://www.sanity.io/docs/document-actions 156 | [docs-document-types]: https://www.sanity.io/docs/schema-types 157 | [docs-http-api]: https://www.sanity.io/docs/http-api 158 | [docs-js-client]: https://www.sanity.io/docs/js-client 159 | [sanity-shopify]: https://apps.shopify.com/sanity-connect 160 | -------------------------------------------------------------------------------- /migrations/strictSchema.ts: -------------------------------------------------------------------------------- 1 | import {getCliClient} from 'sanity/cli' 2 | import type {SanityDocumentLike, Path} from 'sanity' 3 | import type {Transaction, PatchBuilder, PatchOperations} from '@sanity/client' 4 | import {extractWithPath} from '@sanity/mutator' 5 | 6 | /* 7 | * This migration will do two things: 8 | * 9 | * Firstly, it will migrate the `_type` of certain blocks in Portable Text so that they're named in line with other usage of the modules. 10 | * For example, the `blockAccordion` type will be named `module.accordion` instead. This also adds GraphQL support for these types. 11 | * See more on strict schema types here: https://www.sanity.io/docs/graphql#33ec7103289a 12 | * 13 | * This is a breaking change for the schema, so you'll need to update your frontend to match. 14 | * For an example change to a GRQO query, please see: 15 | * https://github.com/sanity-io/hydrogen-sanity-demo/blob/main/app/queries/sanity/fragments/portableText/portableText.ts 16 | * 17 | * Secondly, it change the name of the custom product options from "color" and "size" to "colorObject" and "sizeObject". 18 | * As well as adding support for GraphQL, this fixes a schema warning error in v3 of the Sanity Studio. 19 | * 20 | * To run this migration: 21 | * 1. Take a backup of your dataset with: 22 | * `npx sanity@latest dataset export` 23 | * 24 | * 2. Copy this file to the root of your Sanity Studio project 25 | * 26 | * 3. If necessary, update the DOCUMENT_TYPES constant below to match the schema types you want to migrate. 27 | * This should be anywhere that the `body` portable text field is used - in the template this is the `page` and `product` types. 28 | * You can also update the BLOCK_TYPES constant to match the block types you want to migrate. 29 | * 30 | * 4. Run the script (replace with the name of your schema type): 31 | * npx sanity@latest exec ./migrations/strictSchema.ts --with-user-token 32 | * 33 | * 5. This script will exit if any of the mutations fail due to a revision mismatch (which means the document was 34 | * edited between fetch => update). It can safely be re-run multiple times until it eventually runs out of documents to migrate. 35 | * 36 | */ 37 | 38 | const DOCUMENT_TYPES = ['product', 'page'] 39 | const BLOCK_TYPES = [ 40 | {from: 'blockAccordion', to: 'module.accordion'}, 41 | {from: 'group', to: 'accordionGroup'}, 42 | {from: 'blockCallout', to: 'module.callout'}, 43 | {from: 'blockGrid', to: 'module.grid'}, 44 | {from: 'item', to: 'gridItem'}, 45 | {from: 'blockImages', to: 'module.images'}, 46 | {from: 'blockInstagram', to: 'module.instagram'}, 47 | {from: 'blockProducts', to: 'module.products'}, 48 | ] 49 | 50 | type Patch = { 51 | id: string 52 | patch: PatchBuilder | PatchOperations 53 | } 54 | 55 | // This will use the client configured in ./sanity.cli.ts 56 | const client = getCliClient() 57 | 58 | // Get the settings document(s) from the dataset. We might have a draft, which will need patching too. 59 | // We make the change to the structure in the GROQ query, so we don't need to do any transformation. 60 | // We also fetch the _id and _rev, so we can use that to patch the document(s) later. 61 | const fetchSettings = () => 62 | client.fetch( 63 | `*[_type == 'settings'] { 64 | _id, 65 | _rev, 66 | customProductOptions[] { 67 | ..., 68 | _type == 'customProductOption.color' => { 69 | ..., 70 | colors[] { 71 | ..., 72 | '_type': 'customProductOption.colorObject', 73 | } 74 | }, 75 | _type == 'customProductOption.size' => { 76 | ..., 77 | sizes[] { 78 | ..., 79 | '_type': 'customProductOption.sizeObject', 80 | } 81 | } 82 | } 83 | }` 84 | ) 85 | 86 | // Create a transaction from the patches 87 | const createTransaction = (patches: Patch[]) => 88 | patches.reduce((tx: Transaction, patch) => tx.patch(patch.id, patch.patch), client.transaction()) 89 | 90 | // Commit the transaction 91 | const commitTransaction = (tx: Transaction) => tx.commit() 92 | 93 | // Build the patches to apply to the settings document(s) 94 | const buildSettingsPatches = (docs: SanityDocumentLike[]) => 95 | docs.map((doc) => ({ 96 | id: doc._id, 97 | patch: { 98 | set: {customProductOptions: doc.customProductOptions}, 99 | // this will cause the migration to fail if any of the 100 | // documents have been modified since the original fetch. 101 | ifRevisionID: doc._rev, 102 | }, 103 | })) 104 | 105 | // Migrate the settings documents by getting the documents, building the patches, creating a transaction and committing it. 106 | const migrateSettings = async () => { 107 | const documents = await fetchSettings() 108 | const patches = buildSettingsPatches(documents) 109 | 110 | if (patches.length === 0) { 111 | // eslint-disable-next-line no-console 112 | console.debug('🚨 No settings documents to migrate!') 113 | return null 114 | } 115 | 116 | //eslint-disable-next-line no-console 117 | console.debug(`⏳ Migrating ${patches.length} settings documents...`) 118 | const transaction = createTransaction(patches) 119 | await commitTransaction(transaction) 120 | console.debug(`✅ Settings migrated`) 121 | 122 | return 123 | } 124 | 125 | // Get the documents with Portable Text fields from the dataset. We'll need to patch the body fields with the relevant updates. 126 | const fetchDocuments = () => 127 | client.fetch( 128 | `*[_type in $types] { 129 | _id, 130 | _rev, 131 | "blockTypes": array::unique(body[]._type), 132 | body 133 | } [ 134 | ${BLOCK_TYPES.map((block) => `"${block.from}" in blockTypes`).join(' || ')} 135 | ][0..100]`, 136 | {types: DOCUMENT_TYPES} 137 | ) 138 | 139 | function convertPath(pathArr: Path) { 140 | return pathArr 141 | .map((part) => { 142 | if (Number.isInteger(part)) { 143 | return `[${part}]` 144 | } 145 | return `.${part}` 146 | }) 147 | .join('') 148 | .substring(1) 149 | } 150 | 151 | // Build the patches to apply to the documents 152 | const buildPatches = (docs: SanityDocumentLike[]) => 153 | docs.flatMap((doc: SanityDocumentLike) => { 154 | return BLOCK_TYPES.flatMap((blockType) => { 155 | const matches = extractWithPath(`..[_type=="${blockType.from}"]`, doc) 156 | 157 | return matches.flatMap((match) => { 158 | const block = match.value 159 | if (typeof block !== 'object') { 160 | return 161 | } 162 | 163 | const path = convertPath(match.path) 164 | const newBlock = {...block, _type: blockType.to} 165 | 166 | return { 167 | id: doc._id, 168 | patch: { 169 | set: {[path]: newBlock}, 170 | // this will cause the migration to fail if any of the 171 | // documents have been modified since the original fetch. 172 | ifRevisionID: doc._rev, 173 | }, 174 | } 175 | }) 176 | }).filter((item) => item !== undefined) 177 | }) 178 | 179 | const migrateDocumentBatch = async (): Promise => { 180 | const documents = await fetchDocuments() 181 | const patches = buildPatches(documents) 182 | 183 | if (patches.length === 0) { 184 | // eslint-disable-next-line no-console 185 | console.debug('✅ No more documents to migrate') 186 | return null 187 | } 188 | // eslint-disable-next-line no-console 189 | console.debug( 190 | `⏳ Migrating batch:\n%s`, 191 | patches.map((patch) => `${patch.id} => ${JSON.stringify(patch.patch)}`).join('\n') 192 | ) 193 | 194 | const transaction = createTransaction(patches) 195 | await commitTransaction(transaction) 196 | return migrateDocumentBatch() 197 | } 198 | 199 | const runMigration = async () => { 200 | // eslint-disable-next-line no-console 201 | console.debug('🚀 Starting migration...') 202 | await migrateSettings() 203 | await migrateDocumentBatch() 204 | // eslint-disable-next-line no-console 205 | console.debug('👋 Migration complete!') 206 | } 207 | 208 | runMigration().catch((err) => { 209 | // eslint-disable-next-line no-console 210 | console.error(err) 211 | process.exit(1) 212 | }) 213 | --------------------------------------------------------------------------------