├── .nvmrc ├── .husky ├── pre-commit └── commit-msg ├── examples ├── storefront │ ├── public │ │ └── .gitkeep │ ├── app │ │ ├── routes │ │ │ ├── api.preview.ts │ │ │ ├── account._index.tsx │ │ │ ├── account_.authorize.tsx │ │ │ ├── account_.login.tsx │ │ │ ├── $.tsx │ │ │ ├── account.$.tsx │ │ │ ├── account_.logout.tsx │ │ │ ├── [sitemap.xml].tsx │ │ │ ├── api.$version.[graphql.json].tsx │ │ │ ├── sitemap.$type.$page[.xml].tsx │ │ │ ├── discount.$code.tsx │ │ │ ├── policies._index.tsx │ │ │ ├── cart.$lines.tsx │ │ │ ├── account.tsx │ │ │ └── pages.$handle.tsx │ │ ├── lib │ │ │ ├── sanity │ │ │ │ └── stega.ts │ │ │ ├── redirect.ts │ │ │ ├── variants.ts │ │ │ ├── session.ts │ │ │ └── search.ts │ │ ├── routes.ts │ │ ├── components │ │ │ ├── ProductImage.tsx │ │ │ ├── ProductPrice.tsx │ │ │ ├── AddToCartButton.tsx │ │ │ ├── ProductItem.tsx │ │ │ ├── PaginatedResourceSection.tsx │ │ │ ├── SearchForm.tsx │ │ │ ├── CartMain.tsx │ │ │ ├── SearchFormPredictive.tsx │ │ │ └── Aside.tsx │ │ ├── graphql │ │ │ └── customer-account │ │ │ │ ├── CustomerUpdateMutation.ts │ │ │ │ ├── CustomerDetailsQuery.ts │ │ │ │ ├── CustomerOrdersQuery.ts │ │ │ │ ├── CustomerAddressMutations.ts │ │ │ │ └── CustomerOrderQuery.ts │ │ ├── entry.client.tsx │ │ ├── assets │ │ │ └── favicon.svg │ │ ├── entry.server.tsx │ │ └── styles │ │ │ └── reset.css │ ├── guides │ │ ├── search │ │ │ └── search.jpg │ │ └── predictiveSearch │ │ │ └── predictiveSearch.jpg │ ├── .gitignore │ ├── sanity-typegen.json │ ├── .env.example │ ├── env.d.ts │ ├── react-router.config.ts │ ├── turbo.json │ ├── .graphqlrc.ts │ ├── tsconfig.json │ ├── vite.config.ts │ ├── README.md │ ├── server.ts │ ├── .cursor │ │ └── rules │ │ │ └── hydrogen-react-router.mdc │ └── package.json └── studio │ ├── .env.example │ ├── static │ └── .gitkeep │ ├── eslint.config.mjs │ ├── env.d.ts │ ├── turbo.json │ ├── sanity.config.ts │ ├── sanity.cli.ts │ ├── tsconfig.json │ ├── README.md │ ├── .gitignore │ └── package.json ├── README.md ├── packages ├── hydrogen-sanity │ ├── src │ │ ├── vite │ │ │ ├── index.ts │ │ │ └── plugin.ts │ │ ├── preview │ │ │ ├── index.ts │ │ │ ├── hooks.ts │ │ │ ├── utils.ts │ │ │ └── session.ts │ │ ├── visual-editing │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ ├── useQuery.tsx │ │ │ ├── Overlays.tsx │ │ │ ├── hooks │ │ │ │ └── history.ts │ │ │ ├── registry.tsx │ │ │ ├── LiveMode.tsx │ │ │ ├── LiveMode.client.test.tsx │ │ │ ├── VisualEditing.tsx │ │ │ ├── VisualEditing.client.tsx │ │ │ └── Overlays.client.test.tsx │ │ ├── types.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── fixtures.ts │ │ ├── image.ts │ │ └── Query.client.tsx │ ├── vitest.setup.ts │ ├── vitest.config.ts │ ├── lint-staged.config.js │ ├── tsconfig.json │ ├── TODO.md │ ├── tsconfig.dist.json │ ├── release.config.mjs │ ├── tsconfig.settings.json │ ├── LICENSE │ ├── eslint.config.js │ └── package.config.ts └── sanity-config │ ├── eslint.config.mjs │ ├── sanity.config.ts │ ├── tsconfig.json │ ├── src │ ├── structure │ │ ├── collectionStructure.ts │ │ ├── colorThemeStructure.ts │ │ ├── homeStructure.ts │ │ ├── settingStructure.ts │ │ ├── pageStructure.ts │ │ ├── index.ts │ │ └── productStructure.ts │ ├── schemaTypes │ │ ├── objects │ │ │ ├── shopify │ │ │ │ ├── proxyStringType.ts │ │ │ │ ├── placeholderStringType.ts │ │ │ │ ├── priceRangeType.ts │ │ │ │ ├── inventoryType.ts │ │ │ │ ├── optionType.tsx │ │ │ │ ├── collectionRuleType.tsx │ │ │ │ └── shopifyCollectionType.ts │ │ │ ├── global │ │ │ │ ├── menuType.ts │ │ │ │ ├── menuLinksType.ts │ │ │ │ ├── footerType.ts │ │ │ │ └── notFoundPageType.ts │ │ │ ├── collection │ │ │ │ ├── collectionLinksType.ts │ │ │ │ └── collectionGroupType.ts │ │ │ ├── hotspot │ │ │ │ ├── productHotspotsType.tsx │ │ │ │ ├── imageWithProductHotspotsType.ts │ │ │ │ └── spotType.tsx │ │ │ ├── module │ │ │ │ ├── imageCallToActionType.tsx │ │ │ │ ├── gridType.ts │ │ │ │ ├── accordionType.ts │ │ │ │ ├── accordionGroupType.ts │ │ │ │ ├── instagramType.ts │ │ │ │ ├── calloutType.ts │ │ │ │ ├── gridItemType.ts │ │ │ │ ├── heroType.tsx │ │ │ │ ├── productReferenceType.tsx │ │ │ │ ├── productFeaturesType.tsx │ │ │ │ ├── collectionReferenceType.tsx │ │ │ │ ├── imageFeaturesType.tsx │ │ │ │ ├── callToActionType.tsx │ │ │ │ └── imageFeatureType.ts │ │ │ ├── link │ │ │ │ ├── linkEmailType.tsx │ │ │ │ ├── linkInternalType.tsx │ │ │ │ ├── linkExternalType.tsx │ │ │ │ └── linkProductType.tsx │ │ │ ├── seoType.ts │ │ │ └── customProductOption │ │ │ │ ├── customProductOptionSizeObjectType.ts │ │ │ │ ├── customProductOptionColorObjectType.tsx │ │ │ │ ├── customProductOptionSizeType.ts │ │ │ │ └── customProductOptionColorType.tsx │ │ ├── portableText │ │ │ ├── portableTextSimpleType.tsx │ │ │ └── portableTextType.tsx │ │ ├── documents │ │ │ ├── colorTheme.tsx │ │ │ ├── page.ts │ │ │ └── productVariant.tsx │ │ └── singletons │ │ │ ├── homeType.ts │ │ │ └── settingsType.ts │ ├── utils │ │ ├── defineStructure.ts │ │ ├── validateSlug.ts │ │ ├── blocksToText.ts │ │ ├── getPriceRange.ts │ │ └── shopifyUrls.ts │ ├── plugins │ │ └── customDocumentActions │ │ │ ├── types.ts │ │ │ ├── shopifyLink.ts │ │ │ └── index.ts │ ├── components │ │ ├── inputs │ │ │ ├── PlaceholderString.tsx │ │ │ ├── ProductVariantHidden.tsx │ │ │ ├── CollectionHidden.tsx │ │ │ ├── ProxyString.tsx │ │ │ └── ProductHidden.tsx │ │ ├── media │ │ │ ├── ColorTheme.tsx │ │ │ └── ShopifyDocumentStatus.tsx │ │ ├── studio │ │ │ └── Navbar.tsx │ │ ├── hotspots │ │ │ └── ProductTooltip.tsx │ │ └── icons │ │ │ └── Shopify.tsx │ ├── index.ts │ └── constants.ts │ ├── tsconfig.dist.json │ ├── package.config.ts │ ├── tsconfig.settings.json │ ├── .gitignore │ ├── LICENSE │ └── package.json ├── pnpm-workspace.yaml ├── .prettierignore ├── commitlint.config.js ├── lint-staged.config.js ├── .prettierrc ├── lint-staged.base.js ├── .github ├── renovate.json └── workflows │ ├── oxygen-deployment-1000036147.yml │ └── manual-publish.yml ├── .editorconfig ├── vitest.config.ts ├── .npmrc ├── package.json ├── .gitignore ├── turbo.json └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpx lint-staged -------------------------------------------------------------------------------- /examples/storefront/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpx commitlint --edit $1 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/hydrogen-sanity/README.md -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/vite/index.ts: -------------------------------------------------------------------------------- 1 | export {sanity} from './plugin' 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**' 3 | - 'examples/**' 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | yarn.lock 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /examples/studio/.env.example: -------------------------------------------------------------------------------- 1 | SANITY_STUDIO_PROJECT_ID= 2 | SANITY_STUDIO_PREVIEW_ORIGIN= -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/api.preview.ts: -------------------------------------------------------------------------------- 1 | export {action, loader} from 'hydrogen-sanity/preview/route'; 2 | -------------------------------------------------------------------------------- /examples/studio/static/.gitkeep: -------------------------------------------------------------------------------- 1 | Files placed here will be served by the Sanity server under the `/static`-prefix 2 | -------------------------------------------------------------------------------- /examples/studio/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import studio from '@sanity/eslint-config-studio' 2 | 3 | export default [...studio] 4 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | import {format} from './lint-staged.base.js' 2 | 3 | export default { 4 | '*': format, 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/preview/index.ts: -------------------------------------------------------------------------------- 1 | export {usePreviewMode} from './hooks' 2 | export {isPreviewEnabled} from './utils' 3 | -------------------------------------------------------------------------------- /packages/sanity-config/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import studio from '@sanity/eslint-config-studio' 2 | 3 | export default [...studio] 4 | -------------------------------------------------------------------------------- /lint-staged.base.js: -------------------------------------------------------------------------------- 1 | export const format = 'prettier --cache --write --ignore-unknown' 2 | 3 | export const lint = 'eslint --cache --fix' 4 | -------------------------------------------------------------------------------- /examples/storefront/guides/search/search.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/hydrogen-sanity/HEAD/examples/storefront/guides/search/search.jpg -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>sanity-io/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import {webcrypto} from 'node:crypto' 2 | 3 | Object.defineProperty(globalThis, 'crypto', { 4 | value: webcrypto, 5 | }) 6 | -------------------------------------------------------------------------------- /examples/storefront/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /.cache 3 | /build 4 | /dist 5 | /public/build 6 | /.mf 7 | .env 8 | .shopify 9 | .react-router 10 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/types.ts: -------------------------------------------------------------------------------- 1 | import type {useRevalidator} from 'react-router' 2 | 3 | export type Revalidator = ReturnType 4 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/account._index.tsx: -------------------------------------------------------------------------------- 1 | import {redirect} from 'react-router'; 2 | 3 | export async function loader() { 4 | return redirect('/account/orders'); 5 | } 6 | -------------------------------------------------------------------------------- /examples/storefront/guides/predictiveSearch/predictiveSearch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/hydrogen-sanity/HEAD/examples/storefront/guides/predictiveSearch/predictiveSearch.jpg -------------------------------------------------------------------------------- /examples/storefront/sanity-typegen.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": "../../packages/sanity-config/schema.json", 3 | "path": "./**/*.{ts,tsx,js,jsx}", 4 | "generates": "./sanity.generated.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import {defineProject} from 'vitest/config' 2 | 3 | export default defineProject({ 4 | test: { 5 | setupFiles: ['./vitest.setup.ts'], 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/lint-staged.config.js: -------------------------------------------------------------------------------- 1 | import {format, lint} from '../lint-staged.base.js' 2 | 3 | export default { 4 | '*.{js,jsx,ts,tsx}': [format, lint], 5 | '!(*.{js,jsx,ts,tsx})': format, 6 | } 7 | -------------------------------------------------------------------------------- /examples/studio/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | SANITY_STUDIO_PROJECT_ID?: string 4 | SANITY_STUDIO_HOSTNAME?: string 5 | SANITY_STUDIO_PREVIEW_ORIGIN?: string 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/sanity-config/sanity.config.ts: -------------------------------------------------------------------------------- 1 | import {defineStudioConfig} from '@repo/sanity-config' 2 | 3 | const projectId = process.env.SANITY_STUDIO_PROJECT_ID! 4 | 5 | export default defineStudioConfig({ 6 | projectId, 7 | }) 8 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/account_.authorize.tsx: -------------------------------------------------------------------------------- 1 | import type {Route} from './+types/account_.authorize'; 2 | 3 | export async function loader({context}: Route.LoaderArgs) { 4 | return context.customerAccount.authorize(); 5 | } 6 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/types.ts: -------------------------------------------------------------------------------- 1 | import type {WithCache} from '@shopify/hydrogen' 2 | 3 | export type CacheActionFunctionParam = Parameters[1]>[0] 4 | 5 | export type WaitUntil = (promise: Promise) => void 6 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./package.config.ts"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "jsx": "react-jsx", 7 | "noEmit": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/sanity-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./package.config.ts"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "jsx": "react-jsx", 7 | "noEmit": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/account_.login.tsx: -------------------------------------------------------------------------------- 1 | import type {Route} from './+types/account_.login'; 2 | 3 | export async function loader({request, context}: Route.LoaderArgs) { 4 | return context.customerAccount.login({ 5 | countryCode: context.storefront.i18n.country, 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /examples/storefront/app/lib/sanity/stega.ts: -------------------------------------------------------------------------------- 1 | import type {FilterDefault, ResolveStudioUrl} from '@sanity/client'; 2 | 3 | export const filter: FilterDefault = (props) => { 4 | return props.filterDefault(props); 5 | }; 6 | 7 | // export const studioUrl: ResolveStudioUrl = (props) => { 8 | // } 9 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/$.tsx: -------------------------------------------------------------------------------- 1 | import type {Route} from './+types/$'; 2 | 3 | export async function loader({request}: Route.LoaderArgs) { 4 | throw new Response(`${new URL(request.url).pathname} not found`, { 5 | status: 404, 6 | }); 7 | } 8 | 9 | export default function CatchAllPage() { 10 | return null; 11 | } 12 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/TODO.md: -------------------------------------------------------------------------------- 1 | [x] - Update dependencies to RR 2 | [ ] - Add Vite plugin for embedding Studio 3 | [ ] - Add `SanityLive` 4 | [x] - Update `VisualEditing` 5 | [x] - Remove deprecated code 6 | [x] - Test TypeGen 7 | [x] - Add migration documentation 8 | [x] - Update README 9 | [x] - Adjust preview session handling 10 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/preview/hooks.ts: -------------------------------------------------------------------------------- 1 | import {useSanityProviderValue} from '../provider' 2 | 3 | /** 4 | * Returns whether Sanity preview mode is currently enabled. 5 | */ 6 | export function usePreviewMode(): boolean { 7 | const providerValue = useSanityProviderValue() 8 | return Boolean(providerValue.previewEnabled) 9 | } 10 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/constants.ts: -------------------------------------------------------------------------------- 1 | import {CacheLong, type CachingStrategy} from '@shopify/hydrogen' 2 | 3 | /** Default Sanity API version with perspective stack support */ 4 | export const DEFAULT_API_VERSION = 'v2025-02-19' 5 | 6 | /** Default Hydrogen caching strategy for Sanity queries */ 7 | export const DEFAULT_CACHE_STRATEGY: CachingStrategy = CacheLong() 8 | -------------------------------------------------------------------------------- /packages/sanity-config/src/structure/collectionStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/structure' 2 | import defineStructure from '../utils/defineStructure' 3 | 4 | export default defineStructure((S) => 5 | S.listItem() 6 | .title('Collections') 7 | .schemaType('collection') 8 | .child(S.documentTypeList('collection')), 9 | ) 10 | -------------------------------------------------------------------------------- /packages/sanity-config/src/structure/colorThemeStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/structure' 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 | -------------------------------------------------------------------------------- /examples/storefront/.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_STOREFRONT_ID= 2 | PUBLIC_STOREFRONT_API_TOKEN= 3 | PUBLIC_STORE_DOMAIN= 4 | PRIVATE_STOREFRONT_API_TOKEN= 5 | PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID= 6 | PUBLIC_CUSTOMER_ACCOUNT_API_URL= 7 | SHOP_ID= 8 | SESSION_SECRET= 9 | SANITY_PROJECT_ID= 10 | SANITY_DATASET= 11 | SANITY_STUDIO_ORIGIN= 12 | SANITY_PREVIEW_TOKEN= 13 | SANITY_STUDIO_ORIGIN= -------------------------------------------------------------------------------- /examples/storefront/app/routes/account.$.tsx: -------------------------------------------------------------------------------- 1 | import {redirect} from 'react-router'; 2 | import type {Route} from './+types/account.$'; 3 | 4 | // fallback wild card for all unauthenticated routes in account section 5 | export async function loader({context}: Route.LoaderArgs) { 6 | context.customerAccount.handleAuthStatus(); 7 | 8 | return redirect('/account'); 9 | } 10 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/shopify/proxyStringType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | import ProxyStringInput from '../../../components/inputs/ProxyString' 4 | 5 | export const proxyStringType = defineField({ 6 | name: 'proxyString', 7 | title: 'Title', 8 | type: 'string', 9 | components: { 10 | input: ProxyStringInput, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /packages/sanity-config/src/structure/homeStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/structure' 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 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/index.ts: -------------------------------------------------------------------------------- 1 | export {LiveMode} from './LiveMode' 2 | export type {LiveModeProps} from './LiveMode.client' 3 | export {Overlays} from './Overlays' 4 | export type {OverlaysProps} from './Overlays.client' 5 | export type {Revalidator} from './types' 6 | export {VisualEditing} from './VisualEditing' 7 | export type {VisualEditingProps} from './VisualEditing.client' 8 | -------------------------------------------------------------------------------- /packages/sanity-config/src/structure/settingStructure.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder} from 'sanity/structure' 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 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config' 2 | import GithubActionsReporter from 'vitest-github-actions-reporter' 3 | 4 | export default defineConfig({ 5 | test: { 6 | projects: ['packages/hydrogen-sanity'], 7 | // Enable rich PR failed test annotation on the CI 8 | reporters: process.env.GITHUB_ACTIONS ? ['default', new GithubActionsReporter()] : 'default', 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/shopify/placeholderStringType.ts: -------------------------------------------------------------------------------- 1 | import {defineType} from 'sanity' 2 | import PlaceholderStringInput from '../../../components/inputs/PlaceholderString' 3 | 4 | export const placeholderStringType = defineType({ 5 | name: 'placeholderString', 6 | title: 'Title', 7 | type: 'string', 8 | components: { 9 | input: PlaceholderStringInput, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/global/menuType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const menuType = defineField({ 4 | name: 'menu', 5 | title: 'Menu', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | fields: [ 12 | defineField({ 13 | name: 'links', 14 | type: 'menuLinks', 15 | }), 16 | ], 17 | }) 18 | -------------------------------------------------------------------------------- /packages/sanity-config/src/utils/defineStructure.ts: -------------------------------------------------------------------------------- 1 | import type {ConfigContext} from 'sanity' 2 | import type {StructureBuilder} from 'sanity/structure' 3 | 4 | /** 5 | * Helper for creating and typing composable structure parts. 6 | */ 7 | export default function defineStructure( 8 | factory: (S: StructureBuilder, context: ConfigContext) => StructureType, 9 | ) { 10 | return factory 11 | } 12 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": [ 5 | "./src/**/__fixtures__", 6 | "./src/**/__mocks__", 7 | "./src/**/*.test.ts", 8 | "./src/**/*.test.tsx" 9 | ], 10 | "compilerOptions": { 11 | "rootDir": ".", 12 | "outDir": "./dist", 13 | "jsx": "react-jsx", 14 | "emitDeclarationOnly": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/sanity-config/src/structure/pageStructure.ts: -------------------------------------------------------------------------------- 1 | import {DocumentsIcon} from '@sanity/icons' 2 | import {ListItemBuilder} from 'sanity/structure' 3 | import defineStructure from '../utils/defineStructure' 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 | -------------------------------------------------------------------------------- /packages/sanity-config/tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": [ 5 | "./src/**/__fixtures__", 6 | "./src/**/__mocks__", 7 | "./src/**/*.test.ts", 8 | "./src/**/*.test.tsx" 9 | ], 10 | "compilerOptions": { 11 | "rootDir": ".", 12 | "outDir": "./dist", 13 | "jsx": "react-jsx", 14 | "emitDeclarationOnly": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/account_.logout.tsx: -------------------------------------------------------------------------------- 1 | import {redirect} from 'react-router'; 2 | import type {Route} from './+types/account_.logout'; 3 | 4 | // if we don't implement this, /account/logout will get caught by account.$.tsx to do login 5 | export async function loader() { 6 | return redirect('/'); 7 | } 8 | 9 | export async function action({context}: Route.ActionArgs) { 10 | return context.customerAccount.logout(); 11 | } 12 | -------------------------------------------------------------------------------- /packages/sanity-config/src/plugins/customDocumentActions/types.ts: -------------------------------------------------------------------------------- 1 | import type {DocumentActionProps, 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 | -------------------------------------------------------------------------------- /examples/studio/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.com/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["@repo/sanity-config#build"], 7 | "outputs": [".sanity/**", "dist/**"] 8 | }, 9 | "deploy": { 10 | "env": ["SANITY_STUDIO_*", "SANITY_AUTH_TOKEN"], 11 | "dependsOn": ["@repo/sanity-config#build"], 12 | "outputs": [".sanity/**", "dist/**"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/release.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: '@sanity/semantic-release-preset', 3 | branches: [ 4 | { 5 | name: 'main', 6 | channel: 'latest', 7 | }, 8 | { 9 | name: 'beta', 10 | prerelease: true, 11 | }, 12 | { 13 | name: 'next', 14 | prerelease: true, 15 | }, 16 | { 17 | name: 'v4', 18 | channel: 'v4', 19 | range: '4.x.x', 20 | }, 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/[sitemap.xml].tsx: -------------------------------------------------------------------------------- 1 | import type {Route} from './+types/[sitemap.xml]'; 2 | import {getSitemapIndex} from '@shopify/hydrogen'; 3 | 4 | export async function loader({ 5 | request, 6 | context: {storefront}, 7 | }: Route.LoaderArgs) { 8 | const response = await getSitemapIndex({ 9 | storefront, 10 | request, 11 | }); 12 | 13 | response.headers.set('Cache-Control', `max-age=${60 * 60 * 24}`); 14 | 15 | return response; 16 | } 17 | -------------------------------------------------------------------------------- /packages/sanity-config/package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | dist: 'dist', 5 | tsconfig: 'tsconfig.dist.json', 6 | minify: false, 7 | 8 | // Remove this block to enable strict export validation 9 | extract: { 10 | rules: { 11 | 'ae-incompatible-release-tags': 'off', 12 | 'ae-internal-missing-underscore': 'off', 13 | 'ae-missing-release-tag': 'off', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /packages/sanity-config/src/utils/validateSlug.ts: -------------------------------------------------------------------------------- 1 | import type {SlugRule} from 'sanity' 2 | 3 | const MAX_LENGTH = 96 4 | 5 | export const validateSlug = (Rule: SlugRule) => { 6 | return Rule.required().custom((value) => { 7 | const currentSlug = value && value.current 8 | if (!currentSlug) { 9 | return true 10 | } 11 | 12 | if (currentSlug.length >= MAX_LENGTH) { 13 | return `Must be less than ${MAX_LENGTH} characters` 14 | } 15 | 16 | return true 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /packages/sanity-config/tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "downlevelIteration": true, 10 | "declaration": true, 11 | "allowSyntheticDefaultImports": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "verbatimModuleSyntax": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "bundler", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "downlevelIteration": true, 10 | "declaration": true, 11 | "allowSyntheticDefaultImports": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "verbatimModuleSyntax": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/shopify/priceRangeType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const priceRangeType = defineField({ 4 | name: 'priceRange', 5 | title: 'Price range', 6 | type: 'object', 7 | options: { 8 | columns: 2, 9 | }, 10 | fields: [ 11 | defineField({ 12 | name: 'minVariantPrice', 13 | type: 'number', 14 | }), 15 | defineField({ 16 | name: 'maxVariantPrice', 17 | type: 'number', 18 | }), 19 | ], 20 | }) 21 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/collection/collectionLinksType.ts: -------------------------------------------------------------------------------- 1 | import {defineArrayMember, defineField} from 'sanity' 2 | 3 | export const collectionLinksType = defineField({ 4 | name: 'collectionLinks', 5 | title: 'Collection links', 6 | type: 'array', 7 | validation: (Rule) => Rule.unique().max(4), 8 | of: [ 9 | defineArrayMember({ 10 | name: 'collection', 11 | type: 'reference', 12 | weak: true, 13 | to: [{type: 'collection'}], 14 | }), 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /examples/storefront/app/routes.ts: -------------------------------------------------------------------------------- 1 | import {flatRoutes} from '@react-router/fs-routes'; 2 | import {type RouteConfig} from '@react-router/dev/routes'; 3 | import {hydrogenRoutes} from '@shopify/hydrogen'; 4 | 5 | export default hydrogenRoutes([ 6 | ...(await flatRoutes()), 7 | // Manual route definitions can be added to this array, in addition to or instead of using the `flatRoutes` file-based routing convention. 8 | // See https://reactrouter.com/api/framework-conventions/routes.ts#routests 9 | ]) satisfies RouteConfig; 10 | -------------------------------------------------------------------------------- /examples/studio/sanity.config.ts: -------------------------------------------------------------------------------- 1 | import {defineStudioConfig} from '@repo/sanity-config' 2 | 3 | const projectId = process.env.SANITY_STUDIO_PROJECT_ID! 4 | 5 | export default defineStudioConfig({ 6 | projectId, 7 | presentation: { 8 | previewUrl: { 9 | initial: process.env.SANITY_STUDIO_PREVIEW_ORIGIN!, 10 | previewMode: { 11 | enable: 'api/preview', 12 | disable: 'api/preview', 13 | }, 14 | }, 15 | allowOrigins: [process.env.SANITY_STUDIO_PREVIEW_ORIGIN!], 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /examples/storefront/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | 6 | // Enhance TypeScript's built-in typings. 7 | import '@total-typescript/ts-reset'; 8 | 9 | declare global { 10 | interface Env { 11 | SANITY_PROJECT_ID: string; 12 | SANITY_DATASET: string; 13 | SANITY_STUDIO_ORIGIN: string; 14 | SANITY_PREVIEW_TOKEN: string; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/studio/sanity.cli.ts: -------------------------------------------------------------------------------- 1 | import {defineCliConfig} from 'sanity/cli' 2 | 3 | const projectId = process.env.SANITY_STUDIO_PROJECT_ID 4 | 5 | export default defineCliConfig({ 6 | api: { 7 | projectId, 8 | dataset: 'production', 9 | }, 10 | 11 | reactStrictMode: true, 12 | 13 | /** 14 | * Enable auto-updates for studios. 15 | * Learn more at https://www.sanity.io/docs/cli#auto-updates 16 | */ 17 | deployment: { 18 | autoUpdates: false, 19 | }, 20 | 21 | studioHost: process.env.SANITY_STUDIO_HOSTNAME, 22 | }) 23 | -------------------------------------------------------------------------------- /examples/studio/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 | "module": "Preserve", 10 | "moduleDetection": "force", 11 | "isolatedModules": true, 12 | "jsx": "preserve", 13 | "incremental": true, 14 | "verbatimModuleSyntax": true 15 | }, 16 | "include": ["**/*.ts", "**/*.tsx"], 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/api.$version.[graphql.json].tsx: -------------------------------------------------------------------------------- 1 | import type {Route} from './+types/api.$version.[graphql.json]'; 2 | 3 | export async function action({params, context, request}: Route.ActionArgs) { 4 | const response = await fetch( 5 | `https://${context.env.PUBLIC_CHECKOUT_DOMAIN}/api/${params.version}/graphql.json`, 6 | { 7 | method: 'POST', 8 | body: request.body, 9 | headers: request.headers, 10 | }, 11 | ); 12 | 13 | return new Response(response.body, {headers: new Headers(response.headers)}); 14 | } 15 | -------------------------------------------------------------------------------- /examples/storefront/react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type {Config} from '@react-router/dev/config'; 2 | import {hydrogenPreset} from '@shopify/hydrogen/react-router-preset'; 3 | 4 | /** 5 | * React Router 7.9.x Configuration for Hydrogen 6 | * 7 | * This configuration uses the official Hydrogen preset to provide optimal 8 | * React Router settings for Shopify Oxygen deployment. The preset enables 9 | * validated performance optimizations while ensuring compatibility. 10 | */ 11 | export default { 12 | presets: [hydrogenPreset()], 13 | } satisfies Config; 14 | -------------------------------------------------------------------------------- /examples/studio/README.md: -------------------------------------------------------------------------------- 1 | # Sanity Clean Content Studio 2 | 3 | Congratulations, you have now installed the Sanity Content Studio, an open-source real-time content editing environment connected to the Sanity backend. 4 | 5 | Now you can do the following things: 6 | 7 | - [Read “getting started” in the docs](https://www.sanity.io/docs/introduction/getting-started?utm_source=readme) 8 | - [Join the community Slack](https://slack.sanity.io/?utm_source=readme) 9 | - [Extend and build plugins](https://www.sanity.io/docs/content-studio/extending?utm_source=readme) 10 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/hotspot/productHotspotsType.tsx: -------------------------------------------------------------------------------- 1 | import {defineArrayMember, defineField} from 'sanity' 2 | 3 | import ProductTooltip from '../../../components/hotspots/ProductTooltip' 4 | 5 | export const productHotspotsType = defineField({ 6 | name: 'productHotspots', 7 | title: 'Hotspots', 8 | type: 'array', 9 | of: [defineArrayMember({type: 'spot'})], 10 | options: { 11 | imageHotspot: { 12 | imagePath: 'image', 13 | tooltip: ProductTooltip, 14 | pathRoot: 'parent', 15 | }, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/global/menuLinksType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const menuLinksType = defineField({ 4 | name: 'menuLinks', 5 | title: 'Menu Links', 6 | type: 'array', 7 | of: [ 8 | defineField({ 9 | name: 'collectionGroup', 10 | type: 'collectionGroup', 11 | }), 12 | defineField({ 13 | name: 'linkInternal', 14 | type: 'linkInternal', 15 | }), 16 | defineField({ 17 | name: 'linkExternal', 18 | type: 'linkExternal', 19 | }), 20 | ], 21 | }) 22 | -------------------------------------------------------------------------------- /examples/studio/.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 | 25 | # Typescript 26 | *.tsbuildinfo 27 | 28 | # Dotenv and similar local-only files 29 | *.local 30 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/imageCallToActionType.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const imageCallToActionType = defineField({ 4 | name: 'imageCallToAction', 5 | title: 'Call to action', 6 | type: 'object', 7 | fields: [ 8 | defineField({ 9 | name: 'title', 10 | type: 'string', 11 | }), 12 | defineField({ 13 | name: 'link', 14 | type: 'array', 15 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 16 | validation: (Rule) => Rule.max(1), 17 | }), 18 | ], 19 | }) 20 | -------------------------------------------------------------------------------- /packages/sanity-config/.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 | 25 | # Typescript 26 | *.tsbuildinfo 27 | 28 | # Dotenv and similar local-only files 29 | *.local 30 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/global/footerType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const footerType = defineField({ 4 | name: 'footerSettings', 5 | title: 'Footer', 6 | type: 'object', 7 | options: { 8 | collapsed: false, 9 | collapsible: true, 10 | }, 11 | fields: [ 12 | defineField({ 13 | name: 'links', 14 | type: 'array', 15 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 16 | }), 17 | defineField({ 18 | name: 'text', 19 | type: 'portableTextSimple', 20 | }), 21 | ], 22 | }) 23 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/shopify/inventoryType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const inventoryType = defineField({ 4 | name: 'inventory', 5 | title: 'Inventory', 6 | type: 'object', 7 | options: { 8 | columns: 3, 9 | }, 10 | fields: [ 11 | defineField({ 12 | name: 'isAvailable', 13 | title: 'Available', 14 | type: 'boolean', 15 | }), 16 | defineField({ 17 | name: 'management', 18 | type: 'string', 19 | }), 20 | defineField({ 21 | name: 'policy', 22 | type: 'string', 23 | }), 24 | ], 25 | }) 26 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/index.ts: -------------------------------------------------------------------------------- 1 | export {DEFAULT_API_VERSION, DEFAULT_CACHE_STRATEGY} from './constants' 2 | export {createSanityContext, type SanityContext} from './context' 3 | export {useImageUrl, useImageUrlBuilder} from './image' 4 | export {Sanity, useSanityProviderValue} from './provider' 5 | export {Query, type QueryProps} from './Query' 6 | export {useQuery} from './visual-editing/useQuery' 7 | export type {EncodeDataAttributeFunction} from '@sanity/core-loader/encode-data-attribute' 8 | export type * from '@sanity/react-loader' 9 | export {createDataAttribute, useEncodeDataAttribute} from '@sanity/react-loader' 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Adjust log level 2 | # https://docs.npmjs.com/cli/v8/using-npm/config#loglevel 3 | loglevel="warn" 4 | 5 | # Disable audit reports 6 | # https://docs.npmjs.com/cli/v8/using-npm/config#audit 7 | audit=false 8 | 9 | # Disable funding message 10 | # https://docs.npmjs.com/cli/v8/using-npm/config#fund 11 | fund=false 12 | 13 | # Disable progress bar 14 | progress=false 15 | 16 | strict-peer-deps=true 17 | 18 | # Ensure Vite can optimize these deps in PNPM 19 | public-hoist-pattern[]=cookie 20 | public-hoist-pattern[]=set-cookie-parser 21 | public-hoist-pattern[]=content-security-policy-builder 22 | 23 | workspaces-update=false -------------------------------------------------------------------------------- /examples/storefront/app/components/ProductImage.tsx: -------------------------------------------------------------------------------- 1 | import type {ProductVariantFragment} from 'storefrontapi.generated'; 2 | import {Image} from '@shopify/hydrogen'; 3 | 4 | export function ProductImage({ 5 | image, 6 | }: { 7 | image: ProductVariantFragment['image']; 8 | }) { 9 | if (!image) { 10 | return
; 11 | } 12 | return ( 13 |
14 | {image.altText 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /examples/storefront/app/lib/redirect.ts: -------------------------------------------------------------------------------- 1 | import {redirect} from 'react-router'; 2 | 3 | export function redirectIfHandleIsLocalized( 4 | request: Request, 5 | ...localizedResources: Array<{ 6 | handle: string; 7 | data: {handle: string} & unknown; 8 | }> 9 | ) { 10 | const url = new URL(request.url); 11 | let shouldRedirect = false; 12 | 13 | localizedResources.forEach(({handle, data}) => { 14 | if (handle !== data.handle) { 15 | url.pathname = url.pathname.replace(handle, data.handle); 16 | shouldRedirect = true; 17 | } 18 | }); 19 | 20 | if (shouldRedirect) { 21 | throw redirect(url.toString()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/storefront/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.com/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "codegen": { 6 | "outputs": ["*.generated.d.ts"] 7 | }, 8 | "build": { 9 | "dependsOn": ["hydrogen-sanity#build"], 10 | "outputs": [".react-router/**", "dist/**", "*.generated.d.ts"] 11 | }, 12 | "preview": { 13 | "dependsOn": ["hydrogen-sanity#build"], 14 | "outputs": [".react-router/**", "dist/**", "*.generated.d.ts"] 15 | }, 16 | "deploy": { 17 | "dependsOn": ["hydrogen-sanity#build"], 18 | "outputs": [".react-router/**", "dist/**", "*.generated.d.ts"] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/sanity-config/src/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 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/shopify/optionType.tsx: -------------------------------------------------------------------------------- 1 | import {SunIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const optionType = defineField({ 5 | title: 'Product option', 6 | name: 'option', 7 | type: 'object', 8 | icon: SunIcon, 9 | readOnly: true, 10 | fields: [ 11 | defineField({ 12 | name: 'name', 13 | type: 'string', 14 | }), 15 | defineField({ 16 | name: 'values', 17 | type: 'array', 18 | of: [{type: 'string'}], 19 | }), 20 | ], 21 | preview: { 22 | select: { 23 | name: 'name', 24 | }, 25 | prepare({name}) { 26 | return { 27 | title: name, 28 | } 29 | }, 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /examples/storefront/app/graphql/customer-account/CustomerUpdateMutation.ts: -------------------------------------------------------------------------------- 1 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerUpdate 2 | export const CUSTOMER_UPDATE_MUTATION = `#graphql 3 | mutation customerUpdate( 4 | $customer: CustomerUpdateInput! 5 | $language: LanguageCode 6 | ) @inContext(language: $language) { 7 | customerUpdate(input: $customer) { 8 | customer { 9 | firstName 10 | lastName 11 | emailAddress { 12 | emailAddress 13 | } 14 | phoneNumber { 15 | phoneNumber 16 | } 17 | } 18 | userErrors { 19 | code 20 | field 21 | message 22 | } 23 | } 24 | } 25 | ` as const; 26 | -------------------------------------------------------------------------------- /examples/storefront/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import {HydratedRouter} from 'react-router/dom'; 2 | import {startTransition, StrictMode} from 'react'; 3 | import {hydrateRoot} from 'react-dom/client'; 4 | import {NonceProvider} from '@shopify/hydrogen'; 5 | 6 | if (!window.location.origin.includes('webcache.googleusercontent.com')) { 7 | startTransition(() => { 8 | // Extract nonce from existing script tags 9 | const existingNonce = 10 | document.querySelector('script[nonce]')?.nonce; 11 | 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | 18 | , 19 | ); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/sitemap.$type.$page[.xml].tsx: -------------------------------------------------------------------------------- 1 | import type {Route} from './+types/sitemap.$type.$page[.xml]'; 2 | import {getSitemap} from '@shopify/hydrogen'; 3 | 4 | export async function loader({ 5 | request, 6 | params, 7 | context: {storefront}, 8 | }: Route.LoaderArgs) { 9 | const response = await getSitemap({ 10 | storefront, 11 | request, 12 | params, 13 | locales: ['EN-US', 'EN-CA', 'FR-CA'], 14 | getLink: ({type, baseUrl, handle, locale}) => { 15 | if (!locale) return `${baseUrl}/${type}/${handle}`; 16 | return `${baseUrl}/${locale}/${type}/${handle}`; 17 | }, 18 | }); 19 | 20 | response.headers.set('Cache-Control', `max-age=${60 * 60 * 24}`); 21 | 22 | return response; 23 | } 24 | -------------------------------------------------------------------------------- /examples/storefront/app/components/ProductPrice.tsx: -------------------------------------------------------------------------------- 1 | import {Money} from '@shopify/hydrogen'; 2 | import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types'; 3 | 4 | export function ProductPrice({ 5 | price, 6 | compareAtPrice, 7 | }: { 8 | price?: MoneyV2; 9 | compareAtPrice?: MoneyV2 | null; 10 | }) { 11 | return ( 12 |
13 | {compareAtPrice ? ( 14 |
15 | {price ? : null} 16 | 17 | 18 | 19 |
20 | ) : price ? ( 21 | 22 | ) : ( 23 |   24 | )} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /examples/studio/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "studio", 3 | "private": true, 4 | "version": "1.0.0", 5 | "sideEffects": false, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "sanity dev", 9 | "start": "sanity start", 10 | "build": "sanity build", 11 | "deploy": "sanity deploy", 12 | "lint": "eslint .", 13 | "typecheck": "tsc --noEmit" 14 | }, 15 | "dependencies": { 16 | "@repo/sanity-config": "workspace:*", 17 | "react": "^19.2", 18 | "react-dom": "^19.2", 19 | "sanity": "^4.21.1", 20 | "styled-components": "^6.1.19" 21 | }, 22 | "devDependencies": { 23 | "@sanity/eslint-config-studio": "^5.0.2", 24 | "@types/react": "^19.2", 25 | "eslint": "^9.39.1", 26 | "typescript": "^5.9.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/inputs/PlaceholderString.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type StringInputProps, 3 | useFormValue, 4 | type SanityDocument, 5 | type StringSchemaType, 6 | } from 'sanity' 7 | import get from 'lodash.get' 8 | 9 | type Props = StringInputProps 10 | 11 | const PlaceholderStringInput = (props: Props) => { 12 | const {schemaType} = props 13 | 14 | const path = schemaType?.options?.field 15 | const doc = useFormValue([]) as SanityDocument 16 | 17 | const proxyValue = path ? (get(doc, path) as string) : '' 18 | 19 | return props.renderDefault({ 20 | ...props, 21 | elementProps: {...props.elementProps, placeholder: proxyValue}, 22 | }) 23 | } 24 | 25 | export default PlaceholderStringInput 26 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/gridType.ts: -------------------------------------------------------------------------------- 1 | import {ThLargeIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineArrayMember, defineField} from 'sanity' 4 | 5 | export const gridType = defineField({ 6 | name: 'grid', 7 | title: 'Grid', 8 | type: 'object', 9 | icon: ThLargeIcon, 10 | fields: [ 11 | defineField({ 12 | name: 'items', 13 | type: 'array', 14 | of: [defineArrayMember({type: 'gridItem'})], 15 | }), 16 | ], 17 | preview: { 18 | select: { 19 | items: 'items', 20 | }, 21 | prepare({items}) { 22 | return { 23 | subtitle: 'Grid', 24 | title: items?.length > 0 ? pluralize('item', items.length, true) : 'No items', 25 | } 26 | }, 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/accordionType.ts: -------------------------------------------------------------------------------- 1 | import {StackCompactIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export const accordionType = defineField({ 6 | name: 'accordion', 7 | title: 'Accordion', 8 | type: 'object', 9 | icon: StackCompactIcon, 10 | fields: [ 11 | defineField({ 12 | name: 'groups', 13 | type: 'array', 14 | of: [{type: 'accordionGroup'}], 15 | }), 16 | ], 17 | preview: { 18 | select: { 19 | groups: 'groups', 20 | }, 21 | prepare({groups}) { 22 | return { 23 | subtitle: 'Accordion', 24 | title: groups?.length > 0 ? pluralize('group', groups.length, true) : 'No groups', 25 | } 26 | }, 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /packages/sanity-config/src/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 | -------------------------------------------------------------------------------- /packages/sanity-config/src/utils/shopifyUrls.ts: -------------------------------------------------------------------------------- 1 | import {SHOPIFY_STORE_ID} from '../constants' 2 | 3 | export const collectionUrl = (collectionId: number) => { 4 | if (!SHOPIFY_STORE_ID) { 5 | return null 6 | } 7 | return `https://admin.shopify.com/store/${SHOPIFY_STORE_ID}/collections/${collectionId}` 8 | } 9 | 10 | export const productUrl = (productId: number) => { 11 | if (!SHOPIFY_STORE_ID) { 12 | return null 13 | } 14 | return `https://admin.shopify.com/store/${SHOPIFY_STORE_ID}/products/${productId}` 15 | } 16 | 17 | export const productVariantUrl = (productId: number, productVariantId: number) => { 18 | if (!SHOPIFY_STORE_ID) { 19 | return null 20 | } 21 | return `https://admin.shopify.com/store/${SHOPIFY_STORE_ID}/products/${productId}/variants/${productVariantId}` 22 | } 23 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/preview/utils.ts: -------------------------------------------------------------------------------- 1 | import type {HydrogenSession} from '@shopify/hydrogen' 2 | 3 | import type {SanityPreviewSession} from './session' 4 | 5 | /** 6 | * Utility to check if preview mode is enabled based on session detection. 7 | * 8 | * @param projectId - Project ID to check against 9 | * @param session - Preview session to check 10 | * @returns true if preview mode is enabled, false otherwise 11 | */ 12 | export function isPreviewEnabled( 13 | projectId: string, 14 | session: SanityPreviewSession | HydrogenSession | undefined, 15 | ): boolean { 16 | if (!(session && 'get' in session && typeof session.get === 'function')) { 17 | return false 18 | } 19 | 20 | const sessionProjectId = session.get('projectId') 21 | return Boolean(sessionProjectId && sessionProjectId === projectId) 22 | } 23 | -------------------------------------------------------------------------------- /examples/storefront/.graphqlrc.ts: -------------------------------------------------------------------------------- 1 | import type {IGraphQLConfig} from 'graphql-config'; 2 | import {getSchema} from '@shopify/hydrogen-codegen'; 3 | 4 | /** 5 | * GraphQL Config 6 | * @see https://the-guild.dev/graphql/config/docs/user/usage 7 | * @type {IGraphQLConfig} 8 | */ 9 | export default { 10 | projects: { 11 | default: { 12 | schema: getSchema('storefront'), 13 | documents: [ 14 | './*.{ts,tsx,js,jsx}', 15 | './app/**/*.{ts,tsx,js,jsx}', 16 | '!./app/graphql/**/*.{ts,tsx,js,jsx}', 17 | ], 18 | }, 19 | 20 | customer: { 21 | schema: getSchema('customer-account'), 22 | documents: ['./app/graphql/customer-account/*.{ts,tsx,js,jsx}'], 23 | }, 24 | 25 | // Add your own GraphQL projects here for CMS, Shopify Admin API, etc. 26 | }, 27 | } as IGraphQLConfig; 28 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/collection/collectionGroupType.ts: -------------------------------------------------------------------------------- 1 | import {PackageIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const collectionGroupType = defineField({ 5 | name: 'collectionGroup', 6 | title: 'Collection group', 7 | type: 'object', 8 | icon: PackageIcon, 9 | fields: [ 10 | defineField({ 11 | name: 'title', 12 | type: 'string', 13 | validation: (Rule) => Rule.required(), 14 | }), 15 | defineField({ 16 | name: 'collectionLinks', 17 | type: 'collectionLinks', 18 | }), 19 | defineField({ 20 | name: 'collectionProducts', 21 | type: 'reference', 22 | description: 'Products from this collection will be listed', 23 | weak: true, 24 | to: [{type: 'collection'}], 25 | }), 26 | ], 27 | }) 28 | -------------------------------------------------------------------------------- /examples/storefront/app/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 23 | 28 | 29 | -------------------------------------------------------------------------------- /packages/sanity-config/src/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 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/link/linkEmailType.tsx: -------------------------------------------------------------------------------- 1 | import {EnvelopeIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const linkEmailType = defineField({ 5 | title: 'Email link', 6 | name: 'linkEmail', 7 | type: 'object', 8 | icon: EnvelopeIcon, 9 | components: { 10 | annotation: (props) => ( 11 | 12 | 19 | {props.renderDefault(props)} 20 | 21 | ), 22 | }, 23 | fields: [ 24 | defineField({ 25 | name: 'email', 26 | type: 'email', 27 | }), 28 | ], 29 | preview: { 30 | select: { 31 | title: 'email', 32 | }, 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/seoType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const seoType = defineField({ 4 | name: 'seo', 5 | title: 'SEO', 6 | type: 'object', 7 | group: 'seo', 8 | options: { 9 | collapsed: false, 10 | collapsible: true, 11 | }, 12 | fields: [ 13 | defineField({ 14 | name: '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 | type: 'text', 22 | rows: 2, 23 | validation: (Rule) => 24 | Rule.max(150).warning('Longer descriptions may be truncated by search engines'), 25 | }), 26 | defineField({ 27 | name: 'image', 28 | type: 'image', 29 | }), 30 | ], 31 | }) 32 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/inputs/CollectionHidden.tsx: -------------------------------------------------------------------------------- 1 | import {WarningOutlineIcon} from '@sanity/icons' 2 | import type {StringFieldProps} from 'sanity' 3 | import {Box, Card, Flex, Stack, Text} from '@sanity/ui' 4 | 5 | export default function CollectionHiddenInput(props: StringFieldProps) { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | This collection is hidden 15 | 16 | 17 | It has been deleted from Shopify. 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/accordionGroupType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | import blocksToText from '../../../utils/blocksToText' 3 | 4 | export const accordionGroupType = defineField({ 5 | name: 'accordionGroup', 6 | title: 'Accordion Group', 7 | type: 'object', 8 | icon: false, 9 | fields: [ 10 | defineField({ 11 | name: 'title', 12 | type: 'string', 13 | validation: (Rule) => Rule.required(), 14 | }), 15 | defineField({ 16 | name: 'body', 17 | type: 'portableTextSimple', 18 | validation: (Rule) => Rule.required(), 19 | }), 20 | ], 21 | preview: { 22 | select: { 23 | title: 'title', 24 | body: 'body', 25 | }, 26 | prepare({title, body}) { 27 | return { 28 | title, 29 | subtitle: body && blocksToText(body), 30 | } 31 | }, 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/link/linkInternalType.tsx: -------------------------------------------------------------------------------- 1 | import {LinkIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | import {PAGE_REFERENCES} from '../../../constants' 4 | 5 | export const linkInternalType = defineField({ 6 | title: 'Internal Link', 7 | name: 'linkInternal', 8 | type: 'object', 9 | icon: LinkIcon, 10 | components: { 11 | annotation: (props) => ( 12 | 13 | 20 | {props.renderDefault(props)} 21 | 22 | ), 23 | }, 24 | fields: [ 25 | defineField({ 26 | name: 'reference', 27 | type: 'reference', 28 | weak: true, 29 | validation: (Rule) => Rule.required(), 30 | to: PAGE_REFERENCES, 31 | }), 32 | ], 33 | }) 34 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/shopify/collectionRuleType.tsx: -------------------------------------------------------------------------------- 1 | import {FilterIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const collectionRuleType = defineField({ 5 | title: 'Collection rule', 6 | name: 'collectionRule', 7 | type: 'object', 8 | icon: FilterIcon, 9 | readOnly: true, 10 | fields: [ 11 | defineField({ 12 | name: 'column', 13 | type: 'string', 14 | }), 15 | defineField({ 16 | name: 'relation', 17 | type: 'string', 18 | }), 19 | defineField({ 20 | name: 'condition', 21 | type: 'string', 22 | }), 23 | ], 24 | preview: { 25 | select: { 26 | condition: 'condition', 27 | name: 'column', 28 | relation: 'relation', 29 | }, 30 | prepare({condition, name, relation}) { 31 | return { 32 | subtitle: `${relation} ${condition}`, 33 | title: name, 34 | } 35 | }, 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/media/ColorTheme.tsx: -------------------------------------------------------------------------------- 1 | import {css, styled} 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 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/studio/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Button, Card, Flex} from '@sanity/ui' 2 | import type {NavbarProps} from 'sanity' 3 | 4 | import {SHOPIFY_STORE_ID} from '../../constants' 5 | import ShopifyIcon from '../icons/Shopify' 6 | 7 | export default function Navbar(props: NavbarProps) { 8 | if (!SHOPIFY_STORE_ID) return props.renderDefault(props) 9 | 10 | return ( 11 | 12 | 13 | {props.renderDefault(props)} 14 | 15 | 25 | 26 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/global/notFoundPageType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const notFoundPageType = defineField({ 4 | name: 'notFoundPage', 5 | title: '404 page', 6 | type: 'object', 7 | group: 'notFoundPage', 8 | fields: [ 9 | defineField({ 10 | name: 'title', 11 | type: 'string', 12 | validation: (Rule) => Rule.required(), 13 | }), 14 | defineField({ 15 | name: 'body', 16 | type: 'text', 17 | rows: 2, 18 | }), 19 | defineField({ 20 | name: 'collection', 21 | type: 'reference', 22 | description: 'Collection products displayed on this page', 23 | weak: true, 24 | to: [ 25 | { 26 | name: 'collection', 27 | type: 'collection', 28 | }, 29 | ], 30 | }), 31 | defineField({ 32 | name: 'colorTheme', 33 | type: 'reference', 34 | to: [{type: 'colorTheme'}], 35 | }), 36 | ], 37 | }) 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "turbo --ui tui dev", 7 | "watch:apps": "turbo --ui tui dev --filter=\"./examples/**\"", 8 | "watch:package": "turbo --ui tui dev --filter=\"hydrogen-sanity\"", 9 | "watch:sanity-config": "turbo --ui tui dev --filter=\"@repo/sanity-config\"", 10 | "build": "turbo build", 11 | "typecheck": "turbo typecheck", 12 | "lint": "turbo lint", 13 | "test": "vitest", 14 | "format": "prettier --cache --write --ignore-unknown ." 15 | }, 16 | "devDependencies": { 17 | "@commitlint/cli": "^20.2.0", 18 | "@commitlint/config-conventional": "^20.2.0", 19 | "husky": "^9.1.7", 20 | "lint-staged": "^16.2.7", 21 | "prettier": "^3.7.4", 22 | "prettier-plugin-packagejson": "^2.5.20", 23 | "turbo": "^2.6.3", 24 | "vitest": "^4.0.15", 25 | "vitest-github-actions-reporter": "^0.11.1" 26 | }, 27 | "packageManager": "pnpm@10.25.0" 28 | } 29 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/instagramType.ts: -------------------------------------------------------------------------------- 1 | import {UserIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const instagramType = defineField({ 5 | name: '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 | -------------------------------------------------------------------------------- /examples/storefront/app/graphql/customer-account/CustomerDetailsQuery.ts: -------------------------------------------------------------------------------- 1 | // NOTE: https://shopify.dev/docs/api/customer/latest/objects/Customer 2 | export const CUSTOMER_FRAGMENT = `#graphql 3 | fragment Customer on Customer { 4 | id 5 | firstName 6 | lastName 7 | defaultAddress { 8 | ...Address 9 | } 10 | addresses(first: 6) { 11 | nodes { 12 | ...Address 13 | } 14 | } 15 | } 16 | fragment Address on CustomerAddress { 17 | id 18 | formatted 19 | firstName 20 | lastName 21 | company 22 | address1 23 | address2 24 | territoryCode 25 | zoneCode 26 | city 27 | zip 28 | phoneNumber 29 | } 30 | ` as const; 31 | 32 | // NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer 33 | export const CUSTOMER_DETAILS_QUERY = `#graphql 34 | query CustomerDetails($language: LanguageCode) @inContext(language: $language) { 35 | customer { 36 | ...Customer 37 | } 38 | } 39 | ${CUSTOMER_FRAGMENT} 40 | ` as const; 41 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/calloutType.ts: -------------------------------------------------------------------------------- 1 | import {BulbOutlineIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const calloutType = defineField({ 5 | name: 'callout', 6 | title: 'Callout', 7 | type: 'object', 8 | icon: BulbOutlineIcon, 9 | fields: [ 10 | defineField({ 11 | name: 'text', 12 | type: 'text', 13 | rows: 2, 14 | validation: (Rule) => [ 15 | Rule.required(), 16 | Rule.max(70).warning(`Callout length shouldn't be more than 70 characters.`), 17 | ], 18 | }), 19 | defineField({ 20 | name: 'link', 21 | type: 'array', 22 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 23 | validation: (Rule) => Rule.max(1), 24 | }), 25 | ], 26 | preview: { 27 | select: { 28 | text: 'text', 29 | }, 30 | prepare({text}) { 31 | return { 32 | subtitle: 'Callout', 33 | title: text, 34 | media: BulbOutlineIcon, 35 | } 36 | }, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/link/linkExternalType.tsx: -------------------------------------------------------------------------------- 1 | import {EarthGlobeIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const linkExternalType = defineField({ 5 | title: 'External Link', 6 | name: 'linkExternal', 7 | type: 'object', 8 | icon: EarthGlobeIcon, 9 | components: { 10 | annotation: (props) => ( 11 | 12 | 19 | {props.renderDefault(props)} 20 | 21 | ), 22 | }, 23 | fields: [ 24 | defineField({ 25 | name: 'url', 26 | title: 'URL', 27 | type: 'url', 28 | validation: (Rule) => Rule.required().uri({scheme: ['http', 'https']}), 29 | }), 30 | defineField({ 31 | title: 'Open in a new window?', 32 | name: 'newWindow', 33 | type: 'boolean', 34 | initialValue: true, 35 | }), 36 | ], 37 | }) 38 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/useQuery.tsx: -------------------------------------------------------------------------------- 1 | import {useQuery as _useQuery, type UseQueryOptionsDefinedInitial} from '@sanity/react-loader' 2 | import {useEffect, useId} from 'react' 3 | 4 | import {registerQuery} from './registry' 5 | 6 | /** 7 | * Automatically registers with the query detection system. 8 | * This enables automatic live mode detection in `VisualEditing` components. 9 | */ 10 | export function useQuery( 11 | query: string, 12 | params?: Record, 13 | options?: UseQueryOptionsDefinedInitial, 14 | ): ReturnType> { 15 | // Generate stable ID for this `useQuery` instance 16 | const id = useId() 17 | 18 | // Register this `useQuery` instance with the detection system 19 | useEffect(() => { 20 | const unregister = registerQuery(id) 21 | return unregister 22 | }, [id]) 23 | 24 | // Call the original `useQuery` with all the same arguments 25 | return _useQuery(query, params, options) 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # IntelliJ 46 | .idea 47 | *.iml 48 | 49 | # Cache 50 | .cache 51 | .eslintcache 52 | 53 | # Yalc 54 | .yalc 55 | yalc.lock 56 | 57 | # npm package zips 58 | *.tgz 59 | 60 | # Compiled plugin 61 | dist 62 | 63 | # Turbo 64 | .turbo -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "outputs": ["dist"] 6 | }, 7 | "lint": { 8 | "cache": false, 9 | "outputs": [".eslintcache"] 10 | }, 11 | "typecheck": { 12 | "cache": false, 13 | "outputs": [] 14 | }, 15 | "dev": { 16 | "dependsOn": ["@repo/sanity-config#extract"], 17 | "with": [ 18 | "hydrogen-sanity#watch", 19 | "@repo/sanity-config#watch", 20 | "@repo/sanity-config#extract:watch" 21 | ], 22 | "cache": false, 23 | "persistent": true 24 | }, 25 | "hydrogen-sanity#watch": { 26 | "cache": false, 27 | "persistent": true 28 | }, 29 | "@repo/sanity-config#watch": { 30 | "cache": false, 31 | "persistent": true 32 | }, 33 | "@repo/sanity-config#extract": { 34 | "inputs": ["src/**"], 35 | "outputs": ["schema.json"] 36 | }, 37 | "@repo/sanity-config#extract:watch": { 38 | "cache": false, 39 | "persistent": true 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/gridItemType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | import blocksToText from '../../../utils/blocksToText' 4 | 5 | export const gridItemType = defineField({ 6 | name: 'gridItem', 7 | title: 'Grid Item', 8 | type: 'object', 9 | fields: [ 10 | defineField({ 11 | name: 'title', 12 | type: 'string', 13 | validation: (Rule) => Rule.required(), 14 | }), 15 | defineField({ 16 | name: 'image', 17 | type: 'image', 18 | options: {hotspot: true}, 19 | validation: (Rule) => Rule.required(), 20 | }), 21 | defineField({ 22 | name: 'body', 23 | type: 'portableTextSimple', 24 | validation: (Rule) => Rule.required(), 25 | }), 26 | ], 27 | preview: { 28 | select: { 29 | body: 'body', 30 | image: 'image', 31 | title: 'title', 32 | }, 33 | prepare({body, image, title}) { 34 | return { 35 | media: image, 36 | subtitle: body && blocksToText(body), 37 | title, 38 | } 39 | }, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /examples/storefront/app/components/AddToCartButton.tsx: -------------------------------------------------------------------------------- 1 | import {type FetcherWithComponents} from 'react-router'; 2 | import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen'; 3 | 4 | export function AddToCartButton({ 5 | analytics, 6 | children, 7 | disabled, 8 | lines, 9 | onClick, 10 | }: { 11 | analytics?: unknown; 12 | children: React.ReactNode; 13 | disabled?: boolean; 14 | lines: Array; 15 | onClick?: () => void; 16 | }) { 17 | return ( 18 | 19 | {(fetcher: FetcherWithComponents) => ( 20 | <> 21 | 26 | 33 | 34 | )} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/inputs/ProxyString.tsx: -------------------------------------------------------------------------------- 1 | import {LockIcon} from '@sanity/icons' 2 | import {Box, Text, TextInput, Tooltip} from '@sanity/ui' 3 | import { 4 | type StringInputProps, 5 | useFormValue, 6 | type SanityDocument, 7 | type StringSchemaType, 8 | } from 'sanity' 9 | import get from 'lodash.get' 10 | 11 | type Props = StringInputProps 12 | 13 | const ProxyString = (props: Props) => { 14 | const {schemaType} = props 15 | 16 | const path = schemaType?.options?.field 17 | const doc = useFormValue([]) as SanityDocument 18 | 19 | const proxyValue = path ? (get(doc, path) as string) : '' 20 | 21 | return ( 22 | 25 | 26 | This value is set in Shopify ({path}) 27 | 28 | 29 | } 30 | portal 31 | > 32 | 33 | 34 | ) 35 | } 36 | 37 | export default ProxyString 38 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/fixtures.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | import type {HydrogenSession} from '@shopify/hydrogen' 3 | 4 | import type {SanityContext} from './context' 5 | import type {SanityPreviewSession} from './preview/session' 6 | 7 | export class Session implements HydrogenSession { 8 | protected data = new Map() 9 | 10 | get(key: string) { 11 | return this.data.get(key) 12 | } 13 | 14 | set(key: string, value: unknown) { 15 | this.data.set(key, value) 16 | } 17 | 18 | unset(key: string) { 19 | this.data.delete(key) 20 | } 21 | 22 | async commit() { 23 | return `cookie-${JSON.stringify(Object.fromEntries(this.data))}` 24 | } 25 | } 26 | 27 | export class PreviewSession extends Session implements SanityPreviewSession { 28 | has(key: string) { 29 | return this.get(key) !== undefined 30 | } 31 | 32 | async destroy() { 33 | this.data.clear() 34 | return 'destroyed-cookie' 35 | } 36 | } 37 | 38 | export type AppLoadContext = { 39 | session: HydrogenSession 40 | sanity: SanityContext 41 | } 42 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/heroType.tsx: -------------------------------------------------------------------------------- 1 | import {defineArrayMember, defineField} from 'sanity' 2 | 3 | export const heroType = defineField({ 4 | name: 'hero', 5 | title: 'Hero', 6 | type: 'object', 7 | fields: [ 8 | defineField({ 9 | name: 'title', 10 | type: 'text', 11 | rows: 3, 12 | }), 13 | defineField({ 14 | name: 'description', 15 | type: 'text', 16 | rows: 3, 17 | }), 18 | defineField({ 19 | name: 'link', 20 | type: 'array', 21 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 22 | validation: (Rule) => Rule.max(1), 23 | }), 24 | defineField({ 25 | name: 'content', 26 | type: 'array', 27 | validation: (Rule) => Rule.max(1), 28 | of: [ 29 | defineArrayMember({ 30 | name: 'productWithVariant', 31 | type: 'productWithVariant', 32 | }), 33 | defineArrayMember({ 34 | name: 'imageWithProductHotspots', 35 | type: 'imageWithProductHotspots', 36 | }), 37 | ], 38 | }), 39 | ], 40 | }) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/sanity-config/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 - 2024 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/image.ts: -------------------------------------------------------------------------------- 1 | import type {ImageUrlBuilder, SanityImageSource, SanityModernClientLike} from '@sanity/image-url' 2 | import {createImageUrlBuilder} from '@sanity/image-url' 3 | import {useMemo} from 'react' 4 | 5 | import {useSanityProviderValue} from './provider' 6 | 7 | /** 8 | * Hook that returns a Sanity image URL builder configured with current provider settings. 9 | * Use this to create custom image transformations beyond `useImageUrl`. 10 | */ 11 | export function useImageUrlBuilder(): ImageUrlBuilder { 12 | const {projectId, dataset, apiHost} = useSanityProviderValue() 13 | return useMemo(() => { 14 | return createImageUrlBuilder({ 15 | config: () => ({projectId, dataset, apiHost}), 16 | } as SanityModernClientLike) 17 | }, [apiHost, dataset, projectId]) 18 | } 19 | 20 | /** 21 | * Hook that generates image URLs from Sanity image assets. 22 | * Returns a configured image URL builder for the given source. 23 | */ 24 | export function useImageUrl(source: SanityImageSource): ImageUrlBuilder { 25 | const builder = useImageUrlBuilder() 26 | return builder.image(source) 27 | } 28 | 29 | export type * from '@sanity/image-url' 30 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/eslint.config.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import {fileURLToPath} from 'node:url' 3 | 4 | import {FlatCompat} from '@eslint/eslintrc' 5 | import js from '@eslint/js' 6 | import prettier from 'eslint-plugin-prettier' 7 | import simpleImportSort from 'eslint-plugin-simple-import-sort' 8 | 9 | const __filename = fileURLToPath(import.meta.url) 10 | const __dirname = path.dirname(__filename) 11 | 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | }) 16 | 17 | export default [ 18 | { 19 | ignores: ['**/node_modules/**', '**/dist/**', '**/*.d.ts', '**/.eslintcache'], 20 | }, 21 | ...compat.extends( 22 | 'sanity/react', 23 | 'sanity/typescript', 24 | 'plugin:react-hooks/recommended', 25 | 'plugin:prettier/recommended', 26 | 'plugin:react/jsx-runtime', 27 | ), 28 | { 29 | plugins: { 30 | prettier, 31 | 'simple-import-sort': simpleImportSort, 32 | }, 33 | rules: { 34 | 'simple-import-sort/imports': 'warn', 35 | 'simple-import-sort/exports': 'warn', 36 | 'react-hooks/exhaustive-deps': 'error', 37 | }, 38 | }, 39 | ] 40 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/customProductOption/customProductOptionSizeObjectType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const customProductOptionSizeObjectType = defineField({ 4 | name: 'customProductOption.sizeObject', 5 | title: 'Size', 6 | type: 'object', 7 | fields: [ 8 | defineField({ 9 | name: 'title', 10 | type: 'string', 11 | description: 'Shopify product option value (case sensitive)', 12 | validation: (Rule) => Rule.required(), 13 | }), 14 | defineField({ 15 | name: 'width', 16 | type: 'number', 17 | description: 'In mm', 18 | validation: (Rule) => Rule.required().precision(2), 19 | }), 20 | defineField({ 21 | name: 'height', 22 | type: 'number', 23 | description: 'In mm', 24 | validation: (Rule) => Rule.required().precision(2), 25 | }), 26 | ], 27 | preview: { 28 | select: { 29 | height: 'height', 30 | title: 'title', 31 | width: 'width', 32 | }, 33 | prepare({height, title, width}) { 34 | return { 35 | subtitle: `${width || '??'}mm x ${height || '??'}mm`, 36 | title, 37 | } 38 | }, 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/package.config.ts: -------------------------------------------------------------------------------- 1 | // import {nodeResolve} from '@rollup/plugin-node-resolve' 2 | import {defineConfig} from '@sanity/pkg-utils' 3 | 4 | export default defineConfig({ 5 | dist: 'dist', 6 | tsconfig: 'tsconfig.dist.json', 7 | minify: false, 8 | 9 | // rollup: { 10 | // output: { 11 | // format: 'es', 12 | // }, 13 | // plugins(prev) { 14 | // return prev.map((plugin) => { 15 | // if (plugin.name === 'node-resolve') { 16 | // return nodeResolve({ 17 | // browser: true, 18 | // modulesOnly: true, 19 | // extensions: ['.cjs', '.mjs', '.js', '.jsx', '.json', '.node'], 20 | // preferBuiltins: true, 21 | // // RXJS 22 | // exportConditions: ['es2015'], 23 | // }) 24 | // } 25 | 26 | // return plugin 27 | // }) 28 | // }, 29 | // }, 30 | 31 | // external: ['rxjs'], 32 | 33 | // Remove this block to enable strict export validation 34 | extract: { 35 | rules: { 36 | 'ae-incompatible-release-tags': 'off', 37 | 'ae-internal-missing-underscore': 'off', 38 | 'ae-missing-release-tag': 'off', 39 | }, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/portableText/portableTextSimpleType.tsx: -------------------------------------------------------------------------------- 1 | import {defineArrayMember, defineField} from 'sanity' 2 | 3 | export const portableTextSimpleType = defineField({ 4 | name: 'portableTextSimple', 5 | type: 'array', 6 | of: [ 7 | defineArrayMember({ 8 | lists: [ 9 | {title: 'Bullet', value: 'bullet'}, 10 | {title: 'Numbered', value: 'number'}, 11 | ], 12 | marks: { 13 | decorators: [ 14 | { 15 | title: 'Italic', 16 | value: 'em', 17 | }, 18 | { 19 | title: 'Strong', 20 | value: 'strong', 21 | }, 22 | ], 23 | annotations: [ 24 | { 25 | name: 'linkProduct', 26 | type: 'linkProduct', 27 | }, 28 | { 29 | name: 'linkEmail', 30 | type: 'linkEmail', 31 | }, 32 | { 33 | name: 'linkInternal', 34 | type: 'linkInternal', 35 | }, 36 | { 37 | name: 'linkExternal', 38 | type: 'linkExternal', 39 | }, 40 | ], 41 | }, 42 | type: 'block', 43 | }), 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /examples/storefront/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "env.d.ts", 4 | "app/**/*.ts", 5 | "app/**/*.tsx", 6 | "app/**/*.d.ts", 7 | "*.ts", 8 | "*.tsx", 9 | "*.d.ts", 10 | ".graphqlrc.ts", 11 | ".react-router/types/**/*" 12 | ], 13 | "exclude": ["node_modules", "dist", "build", "packages/**/dist/**/*"], 14 | "compilerOptions": { 15 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 16 | "isolatedModules": true, 17 | "esModuleInterop": true, 18 | "jsx": "react-jsx", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "module": "ES2022", 22 | "target": "ES2022", 23 | "strict": true, 24 | "allowJs": true, 25 | "forceConsistentCasingInFileNames": true, 26 | "skipLibCheck": true, 27 | "baseUrl": ".", 28 | "types": [ 29 | "@shopify/oxygen-workers-types", 30 | "react-router", 31 | "@shopify/hydrogen/react-router-types", 32 | "vite/client" 33 | ], 34 | "paths": { 35 | "~/*": ["app/*"] 36 | }, 37 | "noEmit": true, 38 | "rootDirs": [".", "./.react-router/types"], 39 | "incremental": true, 40 | "composite": false, 41 | "verbatimModuleSyntax": true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/storefront/vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vite'; 2 | import {hydrogen} from '@shopify/hydrogen/vite'; 3 | import {oxygen} from '@shopify/mini-oxygen/vite'; 4 | import {reactRouter} from '@react-router/dev/vite'; 5 | import tsconfigPaths from 'vite-tsconfig-paths'; 6 | import {sanity} from 'hydrogen-sanity/vite'; 7 | 8 | export default defineConfig({ 9 | plugins: [hydrogen(), oxygen(), reactRouter(), tsconfigPaths(), sanity()], 10 | build: { 11 | // Allow a strict Content-Security-Policy 12 | // withtout inlining assets as base64: 13 | assetsInlineLimit: 0, 14 | }, 15 | ssr: { 16 | optimizeDeps: { 17 | /** 18 | * Include dependencies here if they throw CJS<>ESM errors. 19 | * For example, for the following error: 20 | * 21 | * > ReferenceError: module is not defined 22 | * > at /Users/.../node_modules/example-dep/index.js:1:1 23 | * 24 | * Include 'example-dep' in the array below. 25 | * @see https://vitejs.dev/config/dep-optimization-options 26 | */ 27 | include: ['set-cookie-parser', 'cookie', 'react-router'], 28 | }, 29 | }, 30 | server: { 31 | allowedHosts: ['.tryhydrogen.dev'], 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /examples/storefront/app/components/ProductItem.tsx: -------------------------------------------------------------------------------- 1 | import {Link} from 'react-router'; 2 | import {Image, Money} from '@shopify/hydrogen'; 3 | import type { 4 | ProductItemFragment, 5 | CollectionItemFragment, 6 | RecommendedProductFragment, 7 | } from 'storefrontapi.generated'; 8 | import {useVariantUrl} from '~/lib/variants'; 9 | 10 | export function ProductItem({ 11 | product, 12 | loading, 13 | }: { 14 | product: 15 | | CollectionItemFragment 16 | | ProductItemFragment 17 | | RecommendedProductFragment; 18 | loading?: 'eager' | 'lazy'; 19 | }) { 20 | const variantUrl = useVariantUrl(product.handle); 21 | const image = product.featuredImage; 22 | return ( 23 | 29 | {image && ( 30 | {image.altText 37 | )} 38 |

{product.title}

39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /examples/storefront/README.md: -------------------------------------------------------------------------------- 1 | # Hydrogen template: Skeleton 2 | 3 | Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. 4 | 5 | [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) 6 | [Get familiar with Remix](https://remix.run/docs/en/v1) 7 | 8 | ## What's included 9 | 10 | - Remix 11 | - Hydrogen 12 | - Oxygen 13 | - Vite 14 | - Shopify CLI 15 | - ESLint 16 | - Prettier 17 | - GraphQL generator 18 | - TypeScript and JavaScript flavors 19 | - Minimal setup of components and routes 20 | 21 | ## Getting started 22 | 23 | **Requirements:** 24 | 25 | - Node.js version 18.0.0 or higher 26 | 27 | ```bash 28 | npm create @shopify/hydrogen@latest 29 | ``` 30 | 31 | ## Building for production 32 | 33 | ```bash 34 | npm run build 35 | ``` 36 | 37 | ## Local development 38 | 39 | ```bash 40 | npm run dev 41 | ``` 42 | 43 | ## Setup for using Customer Account API (`/account` section) 44 | 45 | Follow step 1 and 2 of 46 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/hotspots/ProductTooltip.tsx: -------------------------------------------------------------------------------- 1 | import {styled} from 'styled-components' 2 | import {type PreviewLayoutKey, type SchemaType, useSchema} from 'sanity' 3 | import {Box} from '@sanity/ui' 4 | import type {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 | -------------------------------------------------------------------------------- /packages/sanity-config/src/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 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/customProductOption/customProductOptionColorObjectType.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | const ColorPreview = ({color}: {color: string}) => { 4 | return ( 5 |
14 | ) 15 | } 16 | 17 | export const customProductOptionColorObjectType = defineField({ 18 | name: 'customProductOption.colorObject', 19 | title: 'Color', 20 | type: 'object', 21 | fields: [ 22 | defineField({ 23 | name: 'title', 24 | type: 'string', 25 | description: 'Shopify product option value (case sensitive)', 26 | validation: (Rule) => Rule.required(), 27 | }), 28 | defineField({ 29 | name: 'color', 30 | type: 'color', 31 | options: {disableAlpha: true}, 32 | validation: (Rule) => Rule.required(), 33 | }), 34 | ], 35 | preview: { 36 | select: { 37 | color: 'color.hex', 38 | title: 'title', 39 | }, 40 | prepare({color, title}) { 41 | return { 42 | media: , 43 | subtitle: color, 44 | title, 45 | } 46 | }, 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /.github/workflows/oxygen-deployment-1000036147.yml: -------------------------------------------------------------------------------- 1 | # Don't change the line below! 2 | #! oxygen_storefront_id: 1000036147 3 | 4 | name: Storefront 1000036147 5 | on: 6 | push: 7 | paths: 8 | - 'packages/hydrogen-sanity/**' 9 | - 'examples/storefront/**' 10 | pull_request: 11 | paths: 12 | - 'packages/hydrogen-sanity/**' 13 | - 'examples/storefront/**' 14 | 15 | permissions: 16 | contents: read 17 | deployments: write 18 | 19 | jobs: 20 | deploy: 21 | name: Deploy `storefront` 22 | timeout-minutes: 30 23 | runs-on: ubuntu-latest 24 | env: 25 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 26 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Install PNPM 31 | uses: pnpm/action-setup@v4 32 | with: 33 | run_install: false 34 | 35 | - name: Install Node.js 36 | uses: actions/setup-node@v5 37 | with: 38 | node-version: 'lts/*' 39 | 40 | - name: Install Dependencies 41 | run: pnpm install --filter="@workspace:." --filter="hydrogen-sanity" --filter="storefront" 42 | 43 | - name: Deploy to Oxygen 44 | id: deploy 45 | run: pnpx turbo run deploy --filter storefront -- --token="${{ secrets.OXYGEN_DEPLOYMENT_TOKEN_1000036147 }}" 46 | -------------------------------------------------------------------------------- /examples/storefront/app/lib/variants.ts: -------------------------------------------------------------------------------- 1 | import {useLocation} from 'react-router'; 2 | import type {SelectedOption} from '@shopify/hydrogen/storefront-api-types'; 3 | import {useMemo} from 'react'; 4 | 5 | export function useVariantUrl( 6 | handle: string, 7 | selectedOptions?: SelectedOption[], 8 | ) { 9 | const {pathname} = useLocation(); 10 | 11 | return useMemo(() => { 12 | return getVariantUrl({ 13 | handle, 14 | pathname, 15 | searchParams: new URLSearchParams(), 16 | selectedOptions, 17 | }); 18 | }, [handle, selectedOptions, pathname]); 19 | } 20 | 21 | export function getVariantUrl({ 22 | handle, 23 | pathname, 24 | searchParams, 25 | selectedOptions, 26 | }: { 27 | handle: string; 28 | pathname: string; 29 | searchParams: URLSearchParams; 30 | selectedOptions?: SelectedOption[]; 31 | }) { 32 | const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname); 33 | const isLocalePathname = match && match.length > 0; 34 | 35 | const path = isLocalePathname 36 | ? `${match![0]}products/${handle}` 37 | : `/products/${handle}`; 38 | 39 | selectedOptions?.forEach((option) => { 40 | searchParams.set(option.name, option.value); 41 | }); 42 | 43 | const searchString = searchParams.toString(); 44 | 45 | return path + (searchString ? '?' + searchParams.toString() : ''); 46 | } 47 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/documents/colorTheme.tsx: -------------------------------------------------------------------------------- 1 | import {IceCreamIcon} from '@sanity/icons' 2 | import {defineField, defineType} from 'sanity' 3 | 4 | import ColorTheme from '../../components/media/ColorTheme' 5 | 6 | export const colorThemeType = defineType({ 7 | name: 'colorTheme', 8 | title: 'Color theme', 9 | type: 'document', 10 | icon: IceCreamIcon, 11 | fields: [ 12 | defineField({ 13 | name: 'title', 14 | type: 'string', 15 | validation: (Rule) => Rule.required(), 16 | }), 17 | defineField({ 18 | name: 'text', 19 | type: 'color', 20 | options: {disableAlpha: true}, 21 | validation: (Rule) => Rule.required(), 22 | }), 23 | defineField({ 24 | name: 'background', 25 | type: 'color', 26 | options: {disableAlpha: true}, 27 | validation: (Rule) => Rule.required(), 28 | }), 29 | ], 30 | preview: { 31 | select: { 32 | backgroundColor: 'background.hex', 33 | textColor: 'text.hex', 34 | title: 'title', 35 | }, 36 | prepare({backgroundColor, textColor, title}) { 37 | return { 38 | media: , 39 | subtitle: `${textColor || '(No color)'} / ${backgroundColor || '(No color)'}`, 40 | title, 41 | } 42 | }, 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/productReferenceType.tsx: -------------------------------------------------------------------------------- 1 | import {TagIcon} from '@sanity/icons' 2 | 3 | import {defineField} from 'sanity' 4 | 5 | import ShopifyDocumentStatus from '../../../components/media/ShopifyDocumentStatus' 6 | 7 | export const productReferenceType = defineField({ 8 | name: 'productReference', 9 | title: 'Product', 10 | type: 'object', 11 | icon: TagIcon, 12 | fields: [ 13 | defineField({ 14 | name: 'productWithVariant', 15 | type: 'productWithVariant', 16 | validation: (Rule) => Rule.required(), 17 | }), 18 | ], 19 | preview: { 20 | select: { 21 | isDeleted: 'productWithVariant.product.store.isDeleted', 22 | previewImageUrl: 'productWithVariant.product.store.previewImageUrl', 23 | status: 'productWithVariant.product.store.status', 24 | title: 'productWithVariant.product.store.title', 25 | }, 26 | prepare({isDeleted, previewImageUrl, status, title}) { 27 | return { 28 | media: ( 29 | 36 | ), 37 | subtitle: 'Product', 38 | title, 39 | } 40 | }, 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/singletons/homeType.ts: -------------------------------------------------------------------------------- 1 | import {HomeIcon} from '@sanity/icons' 2 | import {defineArrayMember, defineField} from 'sanity' 3 | import {GROUPS} from '../../constants' 4 | 5 | const TITLE = 'Home' 6 | 7 | export const homeType = defineField({ 8 | name: 'home', 9 | title: TITLE, 10 | type: 'document', 11 | icon: HomeIcon, 12 | groups: GROUPS, 13 | fields: [ 14 | defineField({ 15 | name: 'hero', 16 | type: 'hero', 17 | group: 'editorial', 18 | }), 19 | defineField({ 20 | name: 'modules', 21 | type: 'array', 22 | of: [ 23 | defineArrayMember({type: 'accordion'}), 24 | defineArrayMember({type: 'callout'}), 25 | defineArrayMember({type: 'grid'}), 26 | defineArrayMember({type: 'images'}), 27 | defineArrayMember({type: 'imageWithProductHotspots', title: 'Image with Hotspots'}), 28 | defineArrayMember({type: 'instagram'}), 29 | defineArrayMember({type: 'products'}), 30 | ], 31 | group: 'editorial', 32 | }), 33 | defineField({ 34 | name: 'seo', 35 | title: 'SEO', 36 | type: 'seo', 37 | group: 'seo', 38 | }), 39 | ], 40 | preview: { 41 | prepare() { 42 | return { 43 | media: HomeIcon, 44 | subtitle: 'Index', 45 | title: TITLE, 46 | } 47 | }, 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/productFeaturesType.tsx: -------------------------------------------------------------------------------- 1 | import {TagIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export const productFeaturesType = defineField({ 6 | name: 'products', 7 | title: 'Products', 8 | type: 'object', 9 | icon: TagIcon, 10 | fields: [ 11 | defineField({ 12 | name: 'products', 13 | type: 'array', 14 | of: [{type: 'productReference'}], 15 | validation: (Rule) => Rule.required().max(2), 16 | }), 17 | defineField({ 18 | name: 'layout', 19 | type: 'string', 20 | initialValue: 'card', 21 | options: { 22 | direction: 'horizontal', 23 | layout: 'radio', 24 | list: [ 25 | { 26 | title: 'Cards (large)', 27 | value: 'card', 28 | }, 29 | { 30 | title: 'Pills (small)', 31 | value: 'pill', 32 | }, 33 | ], 34 | }, 35 | validation: (Rule) => Rule.required(), 36 | }), 37 | ], 38 | preview: { 39 | select: { 40 | products: 'products', 41 | }, 42 | prepare({products}) { 43 | return { 44 | subtitle: 'Products', 45 | title: products.length > 0 ? pluralize('product', products.length, true) : 'No products', 46 | media: TagIcon, 47 | } 48 | }, 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /.github/workflows/manual-publish.yml: -------------------------------------------------------------------------------- 1 | name: Manual NPM Publish 2 | 3 | # Manually publish the current package version to NPM if it doesn't already exist 4 | run-name: Manually publishing `hydrogen-sanity` to NPM 5 | 6 | on: 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read # for checkout 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | env: 16 | HUSKY: 0 17 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 18 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 19 | name: Publish to NPM 20 | permissions: 21 | contents: read 22 | id-token: write # to enable use of OIDC for npm provenance 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | run_install: false 28 | - uses: actions/setup-node@v5 29 | with: 30 | node-version: lts/* 31 | registry-url: 'https://registry.npmjs.org' 32 | - run: pnpm i --filter="@workspace:." --filter="hydrogen-sanity" 33 | 34 | # Build the package 35 | - name: Build 36 | run: pnpm --filter="hydrogen-sanity" run prepublishOnly 37 | 38 | # Publish to NPM with provenance 39 | - name: Publish to NPM 40 | run: cd packages/hydrogen-sanity && npm publish --provenance --access public 41 | env: 42 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 43 | NPM_CONFIG_PROVENANCE: true 44 | -------------------------------------------------------------------------------- /examples/storefront/app/components/PaginatedResourceSection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Pagination} from '@shopify/hydrogen'; 3 | 4 | /** 5 | * is a component that encapsulate how the previous and next behaviors throughout your application. 6 | */ 7 | export function PaginatedResourceSection({ 8 | connection, 9 | children, 10 | resourcesClassName, 11 | }: { 12 | connection: React.ComponentProps>['connection']; 13 | children: React.FunctionComponent<{node: NodesType; index: number}>; 14 | resourcesClassName?: string; 15 | }) { 16 | return ( 17 | 18 | {({nodes, isLoading, PreviousLink, NextLink}) => { 19 | const resourcesMarkup = nodes.map((node, index) => 20 | children({node, index}), 21 | ); 22 | 23 | return ( 24 |
25 | 26 | {isLoading ? 'Loading...' : ↑ Load previous} 27 | 28 | {resourcesClassName ? ( 29 |
{resourcesMarkup}
30 | ) : ( 31 | resourcesMarkup 32 | )} 33 | 34 | {isLoading ? 'Loading...' : Load more ↓} 35 | 36 |
37 | ); 38 | }} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/collectionReferenceType.tsx: -------------------------------------------------------------------------------- 1 | import {PackageIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | import ShopifyDocumentStatus from '../../../components/media/ShopifyDocumentStatus' 5 | 6 | export const collectionReferenceType = defineField({ 7 | name: 'collectionReference', 8 | title: 'Collection', 9 | type: 'object', 10 | icon: PackageIcon, 11 | fields: [ 12 | defineField({ 13 | name: 'collection', 14 | type: 'reference', 15 | weak: true, 16 | to: [{type: 'collection'}], 17 | validation: (Rule) => Rule.required(), 18 | }), 19 | defineField({ 20 | name: 'showBackground', 21 | type: 'boolean', 22 | description: 'Use Shopify collection image as background (if available)', 23 | initialValue: false, 24 | }), 25 | ], 26 | preview: { 27 | select: { 28 | collectionTitle: 'collection.store.title', 29 | imageUrl: 'collection.store.imageUrl', 30 | isDeleted: 'collection.store.isDeleted', 31 | }, 32 | prepare({collectionTitle, imageUrl, isDeleted}) { 33 | return { 34 | media: ( 35 | 41 | ), 42 | subtitle: 'Collection', 43 | title: collectionTitle, 44 | } 45 | }, 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/customProductOption/customProductOptionSizeType.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize-esm' 2 | import {defineField} from 'sanity' 3 | 4 | interface SizeOption { 5 | title: string 6 | } 7 | 8 | export const customProductOptionSizeType = defineField({ 9 | name: 'customProductOption.size', 10 | title: 'Size', 11 | type: 'object', 12 | icon: false, 13 | fields: [ 14 | defineField({ 15 | name: 'title', 16 | type: 'string', 17 | description: 'Shopify product option name (case sensitive)', 18 | validation: (Rule) => Rule.required(), 19 | }), 20 | defineField({ 21 | name: 'sizes', 22 | type: 'array', 23 | of: [{type: 'customProductOption.sizeObject'}], 24 | validation: (Rule) => 25 | Rule.custom((options: SizeOption[] | undefined) => { 26 | // Each size must have a unique title 27 | if (options) { 28 | const uniqueTitles = new Set(options.map((option) => option.title)) 29 | if (options.length > uniqueTitles.size) { 30 | return 'Each product option must have a unique title' 31 | } 32 | } 33 | return true 34 | }), 35 | }), 36 | ], 37 | preview: { 38 | select: { 39 | sizes: 'sizes', 40 | title: 'title', 41 | }, 42 | prepare({sizes, title}) { 43 | return { 44 | subtitle: sizes.length > 0 ? pluralize('size', sizes.length, true) : 'No sizes', 45 | title, 46 | } 47 | }, 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/hotspot/imageWithProductHotspotsType.ts: -------------------------------------------------------------------------------- 1 | import {ImageIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export const imageWithProductHotspotsType = defineField({ 6 | icon: ImageIcon, 7 | name: 'imageWithProductHotspots', 8 | title: 'Image', 9 | type: 'object', 10 | fields: [ 11 | defineField({ 12 | name: 'image', 13 | options: {hotspot: true}, 14 | type: 'image', 15 | validation: (Rule) => Rule.required(), 16 | // Hide original image when showHotspots is true and an image is set 17 | hidden: ({value, parent}) => parent.showHotspots && value, 18 | }), 19 | defineField({ 20 | name: 'showHotspots', 21 | type: 'boolean', 22 | initialValue: false, 23 | }), 24 | defineField({ 25 | name: 'productHotspots', 26 | type: 'productHotspots', 27 | hidden: ({parent}) => !parent.showHotspots, 28 | }), 29 | ], 30 | preview: { 31 | select: { 32 | fileName: 'image.asset.originalFilename', 33 | hotspots: 'productHotspots', 34 | image: 'image', 35 | showHotspots: 'showHotspots', 36 | }, 37 | prepare({fileName, hotspots, image, showHotspots}) { 38 | return { 39 | media: image, 40 | subtitle: 41 | showHotspots && hotspots.length > 0 42 | ? `${pluralize('hotspot', hotspots.length, true)}` 43 | : undefined, 44 | title: fileName, 45 | } 46 | }, 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/portableText/portableTextType.tsx: -------------------------------------------------------------------------------- 1 | import {defineArrayMember, defineField} from 'sanity' 2 | 3 | export const portableTextType = defineField({ 4 | name: 'portableText', 5 | type: 'array', 6 | of: [ 7 | defineArrayMember({ 8 | lists: [ 9 | {title: 'Bullet', value: 'bullet'}, 10 | {title: 'Numbered', value: 'number'}, 11 | ], 12 | marks: { 13 | decorators: [ 14 | { 15 | title: 'Italic', 16 | value: 'em', 17 | }, 18 | { 19 | title: 'Strong', 20 | value: 'strong', 21 | }, 22 | ], 23 | annotations: [ 24 | { 25 | name: 'linkProduct', 26 | type: 'linkProduct', 27 | }, 28 | { 29 | name: 'linkEmail', 30 | type: 'linkEmail', 31 | }, 32 | { 33 | name: 'linkInternal', 34 | type: 'linkInternal', 35 | }, 36 | { 37 | name: 'linkExternal', 38 | type: 'linkExternal', 39 | }, 40 | ], 41 | }, 42 | type: 'block', 43 | }), 44 | defineArrayMember({type: 'accordion'}), 45 | defineArrayMember({type: 'callout'}), 46 | defineArrayMember({type: 'grid'}), 47 | defineArrayMember({type: 'images'}), 48 | defineArrayMember({type: 'imageWithProductHotspots', title: 'Image with Hotspots'}), 49 | defineArrayMember({type: 'instagram'}), 50 | defineArrayMember({type: 'products'}), 51 | ], 52 | }) 53 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/customProductOption/customProductOptionColorType.tsx: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize-esm' 2 | import {defineField} from 'sanity' 3 | 4 | interface ColorOption { 5 | title: string 6 | } 7 | 8 | export const customProductOptionColorType = defineField({ 9 | name: 'customProductOption.color', 10 | title: 'Color', 11 | type: 'object', 12 | icon: false, 13 | fields: [ 14 | defineField({ 15 | name: 'title', 16 | type: 'string', 17 | description: 'Shopify product option name (case sensitive)', 18 | validation: (Rule) => Rule.required(), 19 | }), 20 | defineField({ 21 | name: 'colors', 22 | type: 'array', 23 | of: [{type: 'customProductOption.colorObject'}], 24 | validation: (Rule) => 25 | Rule.custom((options: ColorOption[] | undefined) => { 26 | // Each size must have a unique title 27 | if (options) { 28 | const uniqueTitles = new Set(options.map((option) => option?.title)) 29 | if (options.length > uniqueTitles.size) { 30 | return 'Each product option must have a unique title' 31 | } 32 | } 33 | return true 34 | }), 35 | }), 36 | ], 37 | preview: { 38 | select: { 39 | colors: 'colors', 40 | title: 'title', 41 | }, 42 | prepare(selection) { 43 | const {colors, title} = selection 44 | return { 45 | subtitle: colors.length ? pluralize('color', colors.length, true) : 'No colors', 46 | title, 47 | } 48 | }, 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/imageFeaturesType.tsx: -------------------------------------------------------------------------------- 1 | import {ImageIcon} from '@sanity/icons' 2 | import pluralize from 'pluralize-esm' 3 | import {defineField} from 'sanity' 4 | 5 | export const imageFeaturesType = defineField({ 6 | name: 'images', 7 | title: 'Images', 8 | type: 'object', 9 | icon: ImageIcon, 10 | fields: [ 11 | defineField({ 12 | name: 'imageFeatures', 13 | title: 'Images', 14 | type: 'array', 15 | of: [{type: 'imageFeature'}], 16 | validation: (Rule) => Rule.required().max(2), 17 | }), 18 | defineField({ 19 | name: 'fullWidth', 20 | type: 'boolean', 21 | description: 'Display single image at full width (on larger breakpoints)', 22 | initialValue: false, 23 | hidden: ({parent}) => parent?.modules?.length > 1, 24 | }), 25 | defineField({ 26 | name: 'verticalAlign', 27 | title: 'Vertical alignment', 28 | type: 'string', 29 | initialValue: 'top', 30 | options: { 31 | direction: 'horizontal', 32 | layout: 'radio', 33 | list: ['top', 'center', 'bottom'], 34 | }, 35 | hidden: ({parent}) => !parent?.modules || parent?.modules?.length < 2, 36 | validation: (Rule) => Rule.required(), 37 | }), 38 | ], 39 | preview: { 40 | select: { 41 | images: 'imageFeatures', 42 | }, 43 | prepare({images}) { 44 | return { 45 | subtitle: 'Images', 46 | title: images?.length > 0 ? pluralize('image', images.length, true) : 'No images', 47 | media: ImageIcon, 48 | } 49 | }, 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/link/linkProductType.tsx: -------------------------------------------------------------------------------- 1 | import {TagIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | export const linkProductType = defineField({ 5 | title: 'Product', 6 | name: 'linkProduct', 7 | type: 'object', 8 | icon: TagIcon, 9 | components: { 10 | annotation: (props) => ( 11 | 12 | 19 | {props.renderDefault(props)} 20 | 21 | ), 22 | }, 23 | fields: [ 24 | defineField({ 25 | name: 'productWithVariant', 26 | type: 'productWithVariant', 27 | validation: (Rule) => Rule.required(), 28 | }), 29 | defineField({ 30 | name: 'linkAction', 31 | type: 'string', 32 | initialValue: 'link', 33 | options: { 34 | layout: 'radio', 35 | list: [ 36 | { 37 | title: 'Navigate to product', 38 | value: 'link', 39 | }, 40 | { 41 | title: 'Add to cart', 42 | value: 'addToCart', 43 | }, 44 | { 45 | title: 'Buy now', 46 | value: 'buyNow', 47 | }, 48 | ], 49 | }, 50 | validation: (Rule) => Rule.required(), 51 | }), 52 | defineField({ 53 | name: 'quantity', 54 | type: 'number', 55 | initialValue: 1, 56 | hidden: ({parent}) => parent.linkAction === 'link', 57 | validation: (Rule) => Rule.required().min(1).max(10), 58 | }), 59 | ], 60 | }) 61 | -------------------------------------------------------------------------------- /examples/storefront/app/graphql/customer-account/CustomerOrdersQuery.ts: -------------------------------------------------------------------------------- 1 | // NOTE: https://shopify.dev/docs/api/customer/latest/objects/Order 2 | export const ORDER_ITEM_FRAGMENT = `#graphql 3 | fragment OrderItem on Order { 4 | totalPrice { 5 | amount 6 | currencyCode 7 | } 8 | financialStatus 9 | fulfillmentStatus 10 | fulfillments(first: 1) { 11 | nodes { 12 | status 13 | } 14 | } 15 | id 16 | number 17 | confirmationNumber 18 | processedAt 19 | } 20 | ` as const; 21 | 22 | // NOTE: https://shopify.dev/docs/api/customer/latest/objects/Customer 23 | export const CUSTOMER_ORDERS_FRAGMENT = `#graphql 24 | fragment CustomerOrders on Customer { 25 | orders( 26 | sortKey: PROCESSED_AT, 27 | reverse: true, 28 | first: $first, 29 | last: $last, 30 | before: $startCursor, 31 | after: $endCursor, 32 | query: $query 33 | ) { 34 | nodes { 35 | ...OrderItem 36 | } 37 | pageInfo { 38 | hasPreviousPage 39 | hasNextPage 40 | endCursor 41 | startCursor 42 | } 43 | } 44 | } 45 | ${ORDER_ITEM_FRAGMENT} 46 | ` as const; 47 | 48 | // NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer 49 | export const CUSTOMER_ORDERS_QUERY = `#graphql 50 | ${CUSTOMER_ORDERS_FRAGMENT} 51 | query CustomerOrders( 52 | $endCursor: String 53 | $first: Int 54 | $last: Int 55 | $startCursor: String 56 | $query: String 57 | $language: LanguageCode 58 | ) @inContext(language: $language) { 59 | customer { 60 | ...CustomerOrders 61 | } 62 | } 63 | ` as const; 64 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/Overlays.tsx: -------------------------------------------------------------------------------- 1 | import {lazy, type ReactElement, Suspense} from 'react' 2 | 3 | import {isServer} from '../utils' 4 | import type {OverlaysProps} from './Overlays.client' 5 | 6 | /** 7 | * Provide a consistent fallback to prevent hydration mismatch errors. 8 | */ 9 | function OverlaysFallback(): ReactElement { 10 | return <> 11 | } 12 | 13 | /** 14 | * If server-side rendering, then return the fallback instead of the heavy dependency. 15 | */ 16 | const OverlaysComponent = isServer() 17 | ? OverlaysFallback 18 | : lazy( 19 | () => 20 | /** 21 | * `lazy` expects the component as the default export 22 | * @see https://react.dev/reference/react/lazy 23 | */ 24 | import('./Overlays.client'), 25 | ) 26 | 27 | /** 28 | * Visual editing overlays component for click-to-edit functionality. 29 | * 30 | * Provides interactive overlays that highlight content elements and enable 31 | * click-to-edit functionality. Does not include live data synchronization. 32 | * 33 | * @param props.components - Custom overlay components via OverlayComponentResolver 34 | * @param props.zIndex - CSS z-index for overlay positioning 35 | * @param props.refresh - Custom refresh logic for content changes 36 | * 37 | * @example 38 | * ```tsx 39 | * // Basic overlays 40 | * 41 | * 42 | * // With custom components 43 | * 44 | * ``` 45 | */ 46 | export function Overlays(props: OverlaysProps): ReactElement { 47 | return ( 48 | }> 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/discount.$code.tsx: -------------------------------------------------------------------------------- 1 | import {redirect} from 'react-router'; 2 | import type {Route} from './+types/discount.$code'; 3 | 4 | /** 5 | * Automatically applies a discount found on the url 6 | * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied 7 | * 8 | * @example 9 | * Example path applying a discount and optional redirecting (defaults to the home page) 10 | * ```js 11 | * /discount/FREESHIPPING?redirect=/products 12 | * 13 | * ``` 14 | */ 15 | export async function loader({request, context, params}: Route.LoaderArgs) { 16 | const {cart} = context; 17 | const {code} = params; 18 | 19 | const url = new URL(request.url); 20 | const searchParams = new URLSearchParams(url.search); 21 | let redirectParam = 22 | searchParams.get('redirect') || searchParams.get('return_to') || '/'; 23 | 24 | if (redirectParam.includes('//')) { 25 | // Avoid redirecting to external URLs to prevent phishing attacks 26 | redirectParam = '/'; 27 | } 28 | 29 | searchParams.delete('redirect'); 30 | searchParams.delete('return_to'); 31 | 32 | const redirectUrl = `${redirectParam}?${searchParams}`; 33 | 34 | if (!code) { 35 | return redirect(redirectUrl); 36 | } 37 | 38 | const result = await cart.updateDiscountCodes([code]); 39 | const headers = cart.setCartId(result.cart.id); 40 | 41 | // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000) 42 | // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie 43 | // on localhost:3000 44 | return redirect(redirectUrl, { 45 | status: 303, 46 | headers, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/hooks/history.ts: -------------------------------------------------------------------------------- 1 | import type {HistoryAdapter, HistoryAdapterNavigate, HistoryUpdate} from '@sanity/visual-editing' 2 | import {useEffect, useMemo, useRef, useState} from 'react' 3 | import {useLocation, useNavigate} from 'react-router' 4 | 5 | /** 6 | * Hook that provides history management for visual editing. 7 | * Integrates with React Router's navigation for Studio-storefront communication. 8 | */ 9 | export function useHistory(): HistoryAdapter { 10 | const navigateRemix = useNavigate() 11 | const navigateRemixRef = useRef(navigateRemix) 12 | const [navigate, setNavigate] = useState() 13 | const location = useLocation() 14 | 15 | useEffect(() => { 16 | navigateRemixRef.current = navigateRemix 17 | }, [navigateRemix]) 18 | 19 | useEffect(() => { 20 | if (navigate) { 21 | navigate({ 22 | type: 'push', 23 | url: `${location.pathname}${location.search}${location.hash}`, 24 | }) 25 | } 26 | }, [location.hash, location.pathname, location.search, navigate]) 27 | 28 | const historyAdapter: HistoryAdapter = useMemo( 29 | () => ({ 30 | subscribe(_navigate: HistoryAdapterNavigate) { 31 | setNavigate(() => _navigate) 32 | return () => setNavigate(undefined) 33 | }, 34 | update(update: HistoryUpdate) { 35 | if (update.type === 'push' || update.type === 'replace') { 36 | navigateRemixRef.current(update.url, { 37 | replace: update.type === 'replace', 38 | }) 39 | } else if (update.type === 'pop') { 40 | navigateRemixRef.current(-1) 41 | } 42 | }, 43 | }), 44 | [], 45 | ) 46 | 47 | return historyAdapter 48 | } 49 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/registry.tsx: -------------------------------------------------------------------------------- 1 | import {useSyncExternalStore} from 'react' 2 | 3 | // Subscribers for the external store pattern 4 | const listeners = new Set<() => void>() 5 | // Track active Query components and useQuery hooks using stable IDs 6 | const activeQueries = new Set() 7 | 8 | /** 9 | * Adds a query ID to the active queries set and notifies subscribers. 10 | * Returns a cleanup function to remove the query when it unmounts. 11 | */ 12 | export function registerQuery(id: string): () => void { 13 | activeQueries.add(id) 14 | 15 | // Notify all subscribers that the query state has changed 16 | for (const listener of listeners) { 17 | listener() 18 | } 19 | 20 | // Return cleanup function 21 | return function unregisterQuery() { 22 | activeQueries.delete(id) 23 | 24 | // Notify all subscribers that the query state has changed 25 | for (const listener of listeners) { 26 | listener() 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * Subscribe function for the external store pattern. 33 | */ 34 | function subscribe(onStoreChange: () => void): () => void { 35 | listeners.add(onStoreChange) 36 | 37 | return function unsubscribe() { 38 | listeners.delete(onStoreChange) 39 | } 40 | } 41 | 42 | /** 43 | * Get the current snapshot of whether any queries are active. 44 | */ 45 | function getSnapshot(): boolean { 46 | return activeQueries.size > 0 47 | } 48 | 49 | /** 50 | * Get the server snapshot (always false since queries only matter client-side). 51 | */ 52 | function getServerSnapshot(): boolean { 53 | return false 54 | } 55 | 56 | /** 57 | * Hook that checks if any loaders are active 58 | */ 59 | export function useHasActiveLoaders(): boolean { 60 | return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) 61 | } 62 | -------------------------------------------------------------------------------- /packages/sanity-config/src/plugins/customDocumentActions/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | definePlugin, 3 | type DocumentActionComponent, 4 | type DocumentActionsResolver, 5 | type 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 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/documents/page.ts: -------------------------------------------------------------------------------- 1 | import {DocumentIcon} from '@sanity/icons' 2 | import {defineField} from 'sanity' 3 | 4 | import {validateSlug} from '../../utils/validateSlug' 5 | import {GROUPS} from '../../constants' 6 | 7 | export const pageType = defineField({ 8 | name: 'page', 9 | title: 'Page', 10 | type: 'document', 11 | icon: DocumentIcon, 12 | groups: GROUPS, 13 | fields: [ 14 | defineField({ 15 | name: 'title', 16 | group: 'editorial', 17 | type: 'string', 18 | validation: (Rule) => Rule.required(), 19 | }), 20 | defineField({ 21 | name: 'slug', 22 | group: 'editorial', 23 | type: 'slug', 24 | options: {source: 'title'}, 25 | validation: validateSlug, 26 | }), 27 | defineField({ 28 | name: 'colorTheme', 29 | type: 'reference', 30 | to: [{type: 'colorTheme'}], 31 | group: 'theme', 32 | }), 33 | defineField({ 34 | name: 'showHero', 35 | type: 'boolean', 36 | description: 'If disabled, page title will be displayed instead', 37 | initialValue: false, 38 | group: 'editorial', 39 | }), 40 | defineField({ 41 | name: 'hero', 42 | type: 'hero', 43 | hidden: ({document}) => !document?.showHero, 44 | group: 'editorial', 45 | }), 46 | defineField({ 47 | name: 'body', 48 | type: 'portableText', 49 | group: 'editorial', 50 | }), 51 | defineField({ 52 | name: 'seo', 53 | title: 'SEO', 54 | type: 'seo', 55 | group: 'seo', 56 | }), 57 | ], 58 | preview: { 59 | select: { 60 | seoImage: 'seo.image', 61 | title: 'title', 62 | }, 63 | prepare({seoImage, title}) { 64 | return { 65 | media: seoImage ?? DocumentIcon, 66 | title, 67 | } 68 | }, 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /examples/storefront/app/graphql/customer-account/CustomerAddressMutations.ts: -------------------------------------------------------------------------------- 1 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressUpdate 2 | export const UPDATE_ADDRESS_MUTATION = `#graphql 3 | mutation customerAddressUpdate( 4 | $address: CustomerAddressInput! 5 | $addressId: ID! 6 | $defaultAddress: Boolean 7 | $language: LanguageCode 8 | ) @inContext(language: $language) { 9 | customerAddressUpdate( 10 | address: $address 11 | addressId: $addressId 12 | defaultAddress: $defaultAddress 13 | ) { 14 | customerAddress { 15 | id 16 | } 17 | userErrors { 18 | code 19 | field 20 | message 21 | } 22 | } 23 | } 24 | ` as const; 25 | 26 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressDelete 27 | export const DELETE_ADDRESS_MUTATION = `#graphql 28 | mutation customerAddressDelete( 29 | $addressId: ID! 30 | $language: LanguageCode 31 | ) @inContext(language: $language) { 32 | customerAddressDelete(addressId: $addressId) { 33 | deletedAddressId 34 | userErrors { 35 | code 36 | field 37 | message 38 | } 39 | } 40 | } 41 | ` as const; 42 | 43 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressCreate 44 | export const CREATE_ADDRESS_MUTATION = `#graphql 45 | mutation customerAddressCreate( 46 | $address: CustomerAddressInput! 47 | $defaultAddress: Boolean 48 | $language: LanguageCode 49 | ) @inContext(language: $language) { 50 | customerAddressCreate( 51 | address: $address 52 | defaultAddress: $defaultAddress 53 | ) { 54 | customerAddress { 55 | id 56 | } 57 | userErrors { 58 | code 59 | field 60 | message 61 | } 62 | } 63 | } 64 | ` as const; 65 | -------------------------------------------------------------------------------- /examples/storefront/app/lib/session.ts: -------------------------------------------------------------------------------- 1 | import type {HydrogenSession} from '@shopify/hydrogen'; 2 | import { 3 | createCookieSessionStorage, 4 | type SessionStorage, 5 | type Session, 6 | } from 'react-router'; 7 | 8 | /** 9 | * This is a custom session implementation for your Hydrogen shop. 10 | * Feel free to customize it to your needs, add helper methods, or 11 | * swap out the cookie-based implementation with something else! 12 | */ 13 | export class AppSession implements HydrogenSession { 14 | public isPending = false; 15 | 16 | #sessionStorage; 17 | #session; 18 | 19 | constructor(sessionStorage: SessionStorage, session: Session) { 20 | this.#sessionStorage = sessionStorage; 21 | this.#session = session; 22 | } 23 | 24 | static async init(request: Request, secrets: string[]) { 25 | const storage = createCookieSessionStorage({ 26 | cookie: { 27 | name: 'session', 28 | httpOnly: true, 29 | path: '/', 30 | sameSite: 'lax', 31 | secrets, 32 | }, 33 | }); 34 | 35 | const session = await storage 36 | .getSession(request.headers.get('Cookie')) 37 | .catch(() => storage.getSession()); 38 | 39 | return new this(storage, session); 40 | } 41 | 42 | get has() { 43 | return this.#session.has; 44 | } 45 | 46 | get get() { 47 | return this.#session.get; 48 | } 49 | 50 | get flash() { 51 | return this.#session.flash; 52 | } 53 | 54 | get unset() { 55 | this.isPending = true; 56 | return this.#session.unset; 57 | } 58 | 59 | get set() { 60 | this.isPending = true; 61 | return this.#session.set; 62 | } 63 | 64 | destroy() { 65 | return this.#sessionStorage.destroySession(this.#session); 66 | } 67 | 68 | commit() { 69 | this.isPending = false; 70 | return this.#sessionStorage.commitSession(this.#session); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /examples/storefront/app/components/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import {useRef, useEffect} from 'react'; 2 | import {Form, type FormProps} from 'react-router'; 3 | 4 | type SearchFormProps = Omit & { 5 | children: (args: { 6 | inputRef: React.RefObject; 7 | }) => React.ReactNode; 8 | }; 9 | 10 | /** 11 | * Search form component that sends search requests to the `/search` route. 12 | * @example 13 | * ```tsx 14 | * 15 | * {({inputRef}) => ( 16 | * <> 17 | * 24 | * 25 | * 26 | * )} 27 | * 28 | */ 29 | export function SearchForm({children, ...props}: SearchFormProps) { 30 | const inputRef = useRef(null); 31 | 32 | useFocusOnCmdK(inputRef); 33 | 34 | if (typeof children !== 'function') { 35 | return null; 36 | } 37 | 38 | return ( 39 |
40 | {children({inputRef})} 41 |
42 | ); 43 | } 44 | 45 | /** 46 | * Focuses the input when cmd+k is pressed 47 | */ 48 | function useFocusOnCmdK(inputRef: React.RefObject) { 49 | // focus the input when cmd+k is pressed 50 | useEffect(() => { 51 | function handleKeyDown(event: KeyboardEvent) { 52 | if (event.key === 'k' && event.metaKey) { 53 | event.preventDefault(); 54 | inputRef.current?.focus(); 55 | } 56 | 57 | if (event.key === 'Escape') { 58 | inputRef.current?.blur(); 59 | } 60 | } 61 | 62 | document.addEventListener('keydown', handleKeyDown); 63 | 64 | return () => { 65 | document.removeEventListener('keydown', handleKeyDown); 66 | }; 67 | }, [inputRef]); 68 | } 69 | -------------------------------------------------------------------------------- /packages/sanity-config/src/structure/index.ts: -------------------------------------------------------------------------------- 1 | import {ListItemBuilder, type StructureResolver} from 'sanity/structure' 2 | import collections from './collectionStructure' 3 | import colorThemes from './colorThemeStructure' 4 | import home from './homeStructure' 5 | import pages from './pageStructure' 6 | import products from './productStructure' 7 | import settings from './settingStructure' 8 | 9 | /** 10 | * Structure overrides 11 | * 12 | * Sanity Studio automatically lists document types out of the box. 13 | * With this custom structure we achieve things like showing the `home` 14 | * and `settings` document types as singletons, and grouping product details 15 | * and variants for easy editorial access. 16 | * 17 | * You can customize this even further as your schema types progress. 18 | * To learn more about structure builder, visit our docs: 19 | * https://www.sanity.io/docs/overview-structure-builder 20 | */ 21 | 22 | // If you add document types to structure manually, you can add them to this function to prevent duplicates in the root pane 23 | const hiddenDocTypes = (listItem: ListItemBuilder) => { 24 | const id = listItem.getId() 25 | 26 | if (!id) { 27 | return false 28 | } 29 | 30 | return ![ 31 | 'collection', 32 | 'colorTheme', 33 | 'home', 34 | 'media.tag', 35 | 'page', 36 | 'product', 37 | 'productVariant', 38 | 'settings', 39 | ].includes(id) 40 | } 41 | 42 | export const structure: StructureResolver = (S, context) => 43 | S.list() 44 | .title('Content') 45 | .items([ 46 | home(S, context), 47 | pages(S, context), 48 | S.divider(), 49 | collections(S, context), 50 | products(S, context), 51 | S.divider(), 52 | colorThemes(S, context), 53 | S.divider(), 54 | settings(S, context), 55 | S.divider(), 56 | ...S.documentTypeListItems().filter(hiddenDocTypes), 57 | ]) 58 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/LiveMode.tsx: -------------------------------------------------------------------------------- 1 | import {lazy, type ReactElement, Suspense} from 'react' 2 | 3 | import {isServer} from '../utils' 4 | import type {LiveModeProps} from './LiveMode.client' 5 | 6 | /** 7 | * Provide a consistent fallback to prevent hydration mismatch errors. 8 | */ 9 | function LiveModeFallback(): ReactElement { 10 | return <> 11 | } 12 | 13 | /** 14 | * If server-side rendering, then return the fallback instead of the heavy dependency. 15 | */ 16 | const LiveModeComponent = isServer() 17 | ? LiveModeFallback 18 | : lazy( 19 | () => 20 | /** 21 | * `lazy` expects the component as the default export 22 | * @see https://react.dev/reference/react/lazy 23 | */ 24 | import('./LiveMode.client'), 25 | ) 26 | 27 | /** 28 | * Live data synchronization component for real-time Studio updates. 29 | * 30 | * Enables real-time data updates and perspective changes between Sanity Studio 31 | * and your application. Only use when you have client-side loaders (`useQuery`). 32 | * 33 | * @param props.action - URL path for perspective change submissions 34 | * @param props.onConnect - Callback when Studio connection established 35 | * @param props.onDisconnect - Callback when Studio connection lost 36 | * @param props.filter - Stega filter for content encoding 37 | * @param props.studioUrl - Studio URL for stega configuration 38 | * 39 | * @example 40 | * ```tsx 41 | * // Basic live mode 42 | * 43 | * 44 | * // With callbacks and custom action 45 | * console.log('Connected')} 48 | * onDisconnect={() => console.log('Disconnected')} 49 | * /> 50 | * ``` 51 | */ 52 | export function LiveMode(props: LiveModeProps): ReactElement { 53 | return ( 54 | }> 55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /packages/sanity-config/src/structure/productStructure.ts: -------------------------------------------------------------------------------- 1 | import {InfoOutlineIcon} from '@sanity/icons' 2 | import {ListItemBuilder} from 'sanity/structure' 3 | import defineStructure from '../utils/defineStructure' 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 | -------------------------------------------------------------------------------- /examples/storefront/server.ts: -------------------------------------------------------------------------------- 1 | // Virtual entry point for the app 2 | import {storefrontRedirect} from '@shopify/hydrogen'; 3 | import {createRequestHandler} from '@shopify/hydrogen/oxygen'; 4 | import {createHydrogenRouterContext} from '~/lib/context'; 5 | 6 | /** 7 | * Export a fetch handler in module format. 8 | */ 9 | export default { 10 | async fetch( 11 | request: Request, 12 | env: Env, 13 | executionContext: ExecutionContext, 14 | ): Promise { 15 | try { 16 | const hydrogenContext = await createHydrogenRouterContext( 17 | request, 18 | env, 19 | executionContext, 20 | ); 21 | 22 | /** 23 | * Create a Remix request handler and pass 24 | * Hydrogen's Storefront client to the loader context. 25 | */ 26 | const handleRequest = createRequestHandler({ 27 | // eslint-disable-next-line import/no-unresolved 28 | build: await import('virtual:react-router/server-build'), 29 | mode: process.env.NODE_ENV, 30 | getLoadContext: () => hydrogenContext, 31 | }); 32 | 33 | const response = await handleRequest(request); 34 | 35 | if (hydrogenContext.session.isPending) { 36 | response.headers.set( 37 | 'Set-Cookie', 38 | await hydrogenContext.session.commit(), 39 | ); 40 | } 41 | 42 | if (response.status === 404) { 43 | /** 44 | * Check for redirects only when there's a 404 from the app. 45 | * If the redirect doesn't exist, then `storefrontRedirect` 46 | * will pass through the 404 response. 47 | */ 48 | return storefrontRedirect({ 49 | request, 50 | response, 51 | storefront: hydrogenContext.storefront, 52 | }); 53 | } 54 | 55 | return response; 56 | } catch (error) { 57 | console.error(error); 58 | return new Response('An unexpected error occurred', {status: 500}); 59 | } 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/policies._index.tsx: -------------------------------------------------------------------------------- 1 | import {useLoaderData, Link} from 'react-router'; 2 | import type {Route} from './+types/policies._index'; 3 | import type {PoliciesQuery, PolicyItemFragment} from 'storefrontapi.generated'; 4 | 5 | export async function loader({context}: Route.LoaderArgs) { 6 | const data: PoliciesQuery = await context.storefront.query(POLICIES_QUERY); 7 | 8 | const shopPolicies = data.shop; 9 | const policies: PolicyItemFragment[] = [ 10 | shopPolicies?.privacyPolicy, 11 | shopPolicies?.shippingPolicy, 12 | shopPolicies?.termsOfService, 13 | shopPolicies?.refundPolicy, 14 | shopPolicies?.subscriptionPolicy, 15 | ].filter((policy): policy is PolicyItemFragment => policy != null); 16 | 17 | if (!policies.length) { 18 | throw new Response('No policies found', {status: 404}); 19 | } 20 | 21 | return {policies}; 22 | } 23 | 24 | export default function Policies() { 25 | const {policies} = useLoaderData(); 26 | 27 | return ( 28 |
29 |

Policies

30 |
31 | {policies.map((policy) => ( 32 |
33 | {policy.title} 34 |
35 | ))} 36 |
37 |
38 | ); 39 | } 40 | 41 | const POLICIES_QUERY = `#graphql 42 | fragment PolicyItem on ShopPolicy { 43 | id 44 | title 45 | handle 46 | } 47 | query Policies ($country: CountryCode, $language: LanguageCode) 48 | @inContext(country: $country, language: $language) { 49 | shop { 50 | privacyPolicy { 51 | ...PolicyItem 52 | } 53 | shippingPolicy { 54 | ...PolicyItem 55 | } 56 | termsOfService { 57 | ...PolicyItem 58 | } 59 | refundPolicy { 60 | ...PolicyItem 61 | } 62 | subscriptionPolicy { 63 | id 64 | title 65 | handle 66 | } 67 | } 68 | } 69 | ` as const; 70 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/documents/productVariant.tsx: -------------------------------------------------------------------------------- 1 | import {CopyIcon} from '@sanity/icons' 2 | import {defineField, defineType} from 'sanity' 3 | 4 | import ProductVariantHiddenInput from '../../components/inputs/ProductVariantHidden' 5 | import ShopifyDocumentStatus from '../../components/media/ShopifyDocumentStatus' 6 | import {GROUPS} from '../../constants' 7 | 8 | export const productVariantType = defineType({ 9 | name: 'productVariant', 10 | title: 'Product variant', 11 | type: 'document', 12 | icon: CopyIcon, 13 | groups: GROUPS, 14 | fields: [ 15 | defineField({ 16 | name: 'hidden', 17 | type: 'string', 18 | components: { 19 | field: ProductVariantHiddenInput, 20 | }, 21 | hidden: ({parent}) => { 22 | const isDeleted = parent?.store?.isDeleted 23 | 24 | return !isDeleted 25 | }, 26 | }), 27 | defineField({ 28 | title: 'Title', 29 | name: 'titleProxy', 30 | type: 'proxyString', 31 | options: {field: 'store.title'}, 32 | }), 33 | defineField({ 34 | name: 'store', 35 | title: 'Shopify', 36 | description: 'Variant data from Shopify (read-only)', 37 | type: 'shopifyProductVariant', 38 | group: 'shopifySync', 39 | }), 40 | ], 41 | preview: { 42 | select: { 43 | isDeleted: 'store.isDeleted', 44 | previewImageUrl: 'store.previewImageUrl', 45 | sku: 'store.sku', 46 | status: 'store.status', 47 | title: 'store.title', 48 | }, 49 | prepare(selection) { 50 | const {isDeleted, previewImageUrl, sku, status, title} = selection 51 | 52 | return { 53 | media: ( 54 | 61 | ), 62 | subtitle: sku, 63 | title, 64 | } 65 | }, 66 | }, 67 | }) 68 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/hotspot/spotType.tsx: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | import ShopifyDocumentStatus from '../../../components/media/ShopifyDocumentStatus' 4 | 5 | export const spotType = defineField({ 6 | name: 'spot', 7 | title: 'Spot', 8 | type: 'object', 9 | fieldsets: [{name: 'position', options: {columns: 2}}], 10 | fields: [ 11 | defineField({ 12 | name: 'productWithVariant', 13 | type: 'productWithVariant', 14 | }), 15 | defineField({ 16 | name: 'x', 17 | type: 'number', 18 | readOnly: true, 19 | fieldset: 'position', 20 | initialValue: 50, 21 | validation: (Rule) => Rule.required().min(0).max(100), 22 | }), 23 | defineField({ 24 | name: 'y', 25 | type: 'number', 26 | readOnly: true, 27 | fieldset: 'position', 28 | initialValue: 50, 29 | validation: (Rule) => Rule.required().min(0).max(100), 30 | }), 31 | ], 32 | preview: { 33 | select: { 34 | isDeleted: 'productWithVariant.product.store.isDeleted', 35 | previewImageUrl: 'productWithVariant.product.store.previewImageUrl', 36 | productTitle: 'productWithVariant.product.store.title', 37 | status: 'productWithVariant.product.store.status', 38 | variantPreviewImageUrl: 'productWithVariant.variant.store.previewImageUrl', 39 | x: 'x', 40 | y: 'y', 41 | }, 42 | prepare(selection) { 43 | const {isDeleted, previewImageUrl, productTitle, status, variantPreviewImageUrl, x, y} = 44 | selection 45 | return { 46 | media: ( 47 | 54 | ), 55 | title: productTitle, 56 | subtitle: x && y ? `[${x}%, ${y}%]` : `No position set`, 57 | } 58 | }, 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /examples/storefront/app/graphql/customer-account/CustomerOrderQuery.ts: -------------------------------------------------------------------------------- 1 | // NOTE: https://shopify.dev/docs/api/customer/latest/queries/order 2 | export const CUSTOMER_ORDER_QUERY = `#graphql 3 | fragment OrderMoney on MoneyV2 { 4 | amount 5 | currencyCode 6 | } 7 | fragment DiscountApplication on DiscountApplication { 8 | value { 9 | __typename 10 | ... on MoneyV2 { 11 | ...OrderMoney 12 | } 13 | ... on PricingPercentageValue { 14 | percentage 15 | } 16 | } 17 | } 18 | fragment OrderLineItemFull on LineItem { 19 | id 20 | title 21 | quantity 22 | price { 23 | ...OrderMoney 24 | } 25 | discountAllocations { 26 | allocatedAmount { 27 | ...OrderMoney 28 | } 29 | discountApplication { 30 | ...DiscountApplication 31 | } 32 | } 33 | totalDiscount { 34 | ...OrderMoney 35 | } 36 | image { 37 | altText 38 | height 39 | url 40 | id 41 | width 42 | } 43 | variantTitle 44 | } 45 | fragment Order on Order { 46 | id 47 | name 48 | confirmationNumber 49 | statusPageUrl 50 | fulfillmentStatus 51 | processedAt 52 | fulfillments(first: 1) { 53 | nodes { 54 | status 55 | } 56 | } 57 | totalTax { 58 | ...OrderMoney 59 | } 60 | totalPrice { 61 | ...OrderMoney 62 | } 63 | subtotal { 64 | ...OrderMoney 65 | } 66 | shippingAddress { 67 | name 68 | formatted(withName: true) 69 | formattedArea 70 | } 71 | discountApplications(first: 100) { 72 | nodes { 73 | ...DiscountApplication 74 | } 75 | } 76 | lineItems(first: 100) { 77 | nodes { 78 | ...OrderLineItemFull 79 | } 80 | } 81 | } 82 | query Order($orderId: ID!, $language: LanguageCode) 83 | @inContext(language: $language) { 84 | order(id: $orderId) { 85 | ... on Order { 86 | ...Order 87 | } 88 | } 89 | } 90 | ` as const; 91 | -------------------------------------------------------------------------------- /packages/sanity-config/src/index.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, isDev, type Config, type PluginOptions} from 'sanity' 2 | import {presentationTool, type PresentationPluginOptions} from 'sanity/presentation' 3 | import {structureTool} from 'sanity/structure' 4 | import {visionTool} from '@sanity/vision' 5 | import {colorInput} from '@sanity/color-input' 6 | import {imageHotspotArrayPlugin} from 'sanity-plugin-hotspot-array' 7 | import {media, mediaAssetSource} from 'sanity-plugin-media' 8 | 9 | import {schemaTypes} from './schemaTypes' 10 | import {structure} from './structure' 11 | import {customDocumentActions} from './plugins/customDocumentActions' 12 | import Navbar from './components/studio/Navbar' 13 | 14 | const devOnlyPlugins = [visionTool()] as PluginOptions[] 15 | 16 | type StudioConfig = { 17 | projectId: string 18 | basePath?: string 19 | presentation?: PresentationPluginOptions 20 | } 21 | 22 | export function defineStudioConfig(config: StudioConfig): Config { 23 | return defineConfig({ 24 | ...config, 25 | dataset: 'production', 26 | 27 | name: 'default', 28 | title: 'Hydrogen Sanity', 29 | 30 | plugins: [ 31 | ...(config.presentation ? [presentationTool(config.presentation)] : []), 32 | structureTool({structure}), 33 | colorInput(), 34 | imageHotspotArrayPlugin(), 35 | customDocumentActions(), 36 | media(), 37 | ...(isDev ? devOnlyPlugins : []), 38 | ], 39 | 40 | schema: { 41 | types: schemaTypes, 42 | }, 43 | 44 | form: { 45 | file: { 46 | assetSources: (previousAssetSources) => { 47 | return previousAssetSources.filter((assetSource) => assetSource !== mediaAssetSource) 48 | }, 49 | }, 50 | image: { 51 | assetSources: (previousAssetSources) => { 52 | return previousAssetSources.filter((assetSource) => assetSource === mediaAssetSource) 53 | }, 54 | }, 55 | }, 56 | 57 | studio: { 58 | components: { 59 | navbar: Navbar, 60 | }, 61 | }, 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/inputs/ProductHidden.tsx: -------------------------------------------------------------------------------- 1 | import {WarningOutlineIcon} from '@sanity/icons' 2 | import {type 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 | -------------------------------------------------------------------------------- /packages/sanity-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/sanity-config", 3 | "private": true, 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "sideEffects": false, 8 | "main": "./dist/index.js", 9 | "module": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "exports": { 12 | ".": { 13 | "source": "./src/index.ts", 14 | "import": "./dist/index.js", 15 | "default": "./dist/index.js" 16 | }, 17 | "./schema.json": "./schema.json", 18 | "./package.json": "./package.json" 19 | }, 20 | "typesVersions": { 21 | "*": { 22 | ".": [ 23 | "./dist/index.d.ts" 24 | ] 25 | } 26 | }, 27 | "files": [ 28 | "dist", 29 | "src" 30 | ], 31 | "scripts": { 32 | "build": "pkg-utils build --strict --clean --check", 33 | "typecheck": "tsc", 34 | "lint": "eslint --cache --config eslint.config.mjs .", 35 | "watch": "pkg-utils watch --strict", 36 | "extract": "sanity schema extract", 37 | "extract:watch": "turbo watch --filter=\"@repo/sanity-config\" extract" 38 | }, 39 | "dependencies": { 40 | "@sanity/asset-utils": "^2.3.0", 41 | "@sanity/color-input": "^5.0.4", 42 | "@sanity/icons": "^3.7.4", 43 | "@sanity/ui": "^3.1.11", 44 | "@sanity/vision": "^4.21.1", 45 | "lodash.get": "^4.4.2", 46 | "pluralize-esm": "^9.0.5", 47 | "sanity-plugin-hotspot-array": "^3.0.1", 48 | "sanity-plugin-media": "^4.1.0", 49 | "slug": "^11.0.1" 50 | }, 51 | "devDependencies": { 52 | "@portabletext/types": "^4.0.0", 53 | "@sanity/eslint-config-studio": "^5.0.2", 54 | "@sanity/pkg-utils": "^10.1.2", 55 | "@types/lodash.get": "^4.4.9", 56 | "@types/react": "^19.2", 57 | "@types/slug": "^5.0.9", 58 | "eslint": "^9.39.1", 59 | "react": "^19.2", 60 | "react-dom": "^19.2", 61 | "sanity": "^4.21.1", 62 | "styled-components": "^6.1.19", 63 | "typescript": "^5.9.3" 64 | }, 65 | "peerDependencies": { 66 | "react": "^19.2", 67 | "react-dom": "^19.2", 68 | "sanity": "^4", 69 | "styled-components": "^6.1" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/LiveMode.client.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | import {render} from '@testing-library/react' 5 | import {BrowserRouter} from 'react-router' 6 | import {beforeEach, expect, it, vi} from 'vitest' 7 | 8 | import LiveModeClient from './LiveMode.client' 9 | 10 | // Mock external dependencies 11 | vi.mock('@sanity/react-loader', () => ({ 12 | useLiveMode: vi.fn(), 13 | })) 14 | 15 | vi.mock('@sanity/client', () => ({ 16 | createClient: vi.fn(() => ({ 17 | config: vi.fn(() => ({})), 18 | withConfig: vi.fn().mockReturnThis(), 19 | })), 20 | })) 21 | 22 | vi.mock('react-router', async () => { 23 | const actual = await vi.importActual('react-router') 24 | return { 25 | ...actual, 26 | useSubmit: vi.fn(), 27 | useRevalidator: vi.fn(() => ({ 28 | state: 'idle', 29 | revalidate: vi.fn(), 30 | })), 31 | } 32 | }) 33 | 34 | vi.mock('../provider', () => ({ 35 | useSanityProviderValue: vi.fn(() => ({ 36 | projectId: 'test-project', 37 | dataset: 'production', 38 | perspective: 'published', 39 | apiVersion: '2023-01-01', 40 | stegaEnabled: false, 41 | })), 42 | })) 43 | 44 | vi.mock('./hooks/refresh', () => ({ 45 | useRefresh: vi.fn(() => ({ 46 | refreshHandler: vi.fn(() => vi.fn()), 47 | handleRevalidatorState: vi.fn(), 48 | revalidatorState: 'idle', 49 | })), 50 | })) 51 | 52 | const {useLiveMode} = await import('@sanity/react-loader') 53 | const mockUseLiveMode = vi.mocked(useLiveMode) 54 | 55 | beforeEach(() => { 56 | vi.clearAllMocks() 57 | }) 58 | 59 | it('should enable live mode with client', () => { 60 | render( 61 | 62 | 63 | , 64 | ) 65 | 66 | expect(mockUseLiveMode).toHaveBeenCalledWith({ 67 | client: expect.any(Object), 68 | onConnect: undefined, 69 | onDisconnect: undefined, 70 | }) 71 | }) 72 | 73 | it('should return null (no visual output)', () => { 74 | const {container} = render( 75 | 76 | 77 | , 78 | ) 79 | 80 | expect(container.firstChild).toBeNull() 81 | }) 82 | -------------------------------------------------------------------------------- /examples/storefront/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import {ServerRouter} from 'react-router'; 2 | import {isbot} from 'isbot'; 3 | import {renderToReadableStream} from 'react-dom/server'; 4 | import { 5 | createContentSecurityPolicy, 6 | type HydrogenRouterContextProvider, 7 | } from '@shopify/hydrogen'; 8 | import type {EntryContext} from 'react-router'; 9 | 10 | export default async function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | reactRouterContext: EntryContext, 15 | context: HydrogenRouterContextProvider, 16 | ) { 17 | const {env, sanity} = context; 18 | const {preview, SanityProvider} = sanity; 19 | const isPreviewEnabled = preview?.enabled; 20 | 21 | const {nonce, header, NonceProvider} = createContentSecurityPolicy({ 22 | defaultSrc: ['https://cdn.sanity.io'], 23 | shop: { 24 | checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN, 25 | storeDomain: env.PUBLIC_STORE_DOMAIN, 26 | }, 27 | 28 | // When preview is enabled for the current session, allow the Studio to embed the storefront in the Presentation tool 29 | frameAncestors: [ 30 | // Allow Dashboard to embed the Studio and Presentation tool 31 | 'https://www.sanity.io', 32 | ...(isPreviewEnabled ? [env.SANITY_STUDIO_ORIGIN] : []), 33 | ], 34 | }); 35 | 36 | const body = await renderToReadableStream( 37 | 38 | 39 | 44 | 45 | , 46 | { 47 | nonce, 48 | signal: request.signal, 49 | onError(error) { 50 | console.error(error); 51 | responseStatusCode = 500; 52 | }, 53 | }, 54 | ); 55 | 56 | if (isbot(request.headers.get('user-agent'))) { 57 | await body.allReady; 58 | } 59 | 60 | responseHeaders.set('Content-Type', 'text/html'); 61 | responseHeaders.set('Content-Security-Policy', header); 62 | 63 | return new Response(body, { 64 | headers: responseHeaders, 65 | status: responseStatusCode, 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /packages/sanity-config/src/constants.ts: -------------------------------------------------------------------------------- 1 | // Currency code (ISO 4217) to use when displaying prices in the studio 2 | 3 | import type {FieldGroupDefinition} from 'sanity' 4 | import ShopifyIcon from './components/icons/Shopify' 5 | import {ColorWheelIcon, ComposeIcon, SearchIcon} from '@sanity/icons' 6 | 7 | // https://en.wikipedia.org/wiki/ISO_4217 8 | export const DEFAULT_CURRENCY_CODE = 'USD' 9 | 10 | // Document types which: 11 | // - cannot be created in the 'new document' menu 12 | // - cannot be duplicated, unpublished or deleted 13 | export const LOCKED_DOCUMENT_TYPES = ['settings', 'home', 'media.tag'] 14 | 15 | // Document types which: 16 | // - cannot be created in the 'new document' menu 17 | // - cannot be duplicated, unpublished or deleted 18 | // - are from the Sanity Connect Shopify app - and can be linked to on Shopify 19 | export const SHOPIFY_DOCUMENT_TYPES = ['product', 'productVariant', 'collection'] 20 | 21 | // References to include in 'internal' links 22 | export const PAGE_REFERENCES = [ 23 | {type: 'collection'}, 24 | {type: 'home'}, 25 | {type: 'page'}, 26 | {type: 'product'}, 27 | ] 28 | 29 | // API version to use when using the Sanity client within the studio 30 | // https://www.sanity.io/help/studio-client-specify-api-version 31 | export const SANITY_API_VERSION = '2022-10-25' 32 | 33 | // Your Shopify store ID. 34 | // This is the ID in your Shopify admin URL (e.g. 'my-store-name' in https://admin.shopify.com/store/my-store-name). 35 | // You only need to provide the ID, not the full URL. 36 | // Set this to enable helper links in document status banners and shortcut links on products and collections. 37 | export const SHOPIFY_STORE_ID = '' 38 | 39 | // Field groups used through schema types 40 | export const GROUPS: FieldGroupDefinition[] = [ 41 | { 42 | name: 'theme', 43 | title: 'Theme', 44 | icon: ColorWheelIcon, 45 | }, 46 | { 47 | default: true, 48 | name: 'editorial', 49 | title: 'Editorial', 50 | icon: ComposeIcon, 51 | }, 52 | { 53 | name: 'shopifySync', 54 | title: 'Shopify sync', 55 | icon: ShopifyIcon, 56 | }, 57 | { 58 | name: 'seo', 59 | title: 'SEO', 60 | icon: SearchIcon, 61 | }, 62 | ] 63 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/shopify/shopifyCollectionType.ts: -------------------------------------------------------------------------------- 1 | import {defineField} from 'sanity' 2 | 3 | export const shopifyCollectionType = 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 | defineField({ 20 | fieldset: 'status', 21 | name: 'createdAt', 22 | type: 'string', 23 | }), 24 | defineField({ 25 | fieldset: 'status', 26 | name: 'updatedAt', 27 | type: 'string', 28 | }), 29 | defineField({ 30 | fieldset: 'status', 31 | name: 'isDeleted', 32 | title: 'Deleted from Shopify?', 33 | type: 'boolean', 34 | }), 35 | defineField({ 36 | name: 'title', 37 | type: 'string', 38 | }), 39 | defineField({ 40 | name: 'id', 41 | title: 'ID', 42 | type: 'number', 43 | description: 'Shopify Collection ID', 44 | }), 45 | defineField({ 46 | name: 'gid', 47 | title: 'GID', 48 | type: 'string', 49 | description: 'Shopify Collection GID', 50 | }), 51 | defineField({ 52 | name: 'slug', 53 | description: 'Shopify Collection handle', 54 | type: 'slug', 55 | }), 56 | defineField({ 57 | name: 'descriptionHtml', 58 | title: 'HTML Description', 59 | type: 'text', 60 | rows: 5, 61 | }), 62 | defineField({ 63 | name: 'imageUrl', 64 | title: 'Image URL', 65 | type: 'string', 66 | }), 67 | defineField({ 68 | name: 'rules', 69 | type: 'array', 70 | description: 'Include Shopify products that satisfy these conditions', 71 | of: [{type: 'collectionRule'}], 72 | }), 73 | defineField({ 74 | name: 'disjunctive', 75 | title: 'Disjunctive rules?', 76 | description: 'Require any condition if true, otherwise require all conditions', 77 | type: 'boolean', 78 | }), 79 | defineField({ 80 | name: 'sortOrder', 81 | type: 'string', 82 | }), 83 | ], 84 | }) 85 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/cart.$lines.tsx: -------------------------------------------------------------------------------- 1 | import {redirect} from 'react-router'; 2 | import type {Route} from './+types/cart.$lines'; 3 | 4 | /** 5 | * Automatically creates a new cart based on the URL and redirects straight to checkout. 6 | * Expected URL structure: 7 | * ```js 8 | * /cart/: 9 | * 10 | * ``` 11 | * 12 | * More than one `:` separated by a comma, can be supplied in the URL, for 13 | * carts with more than one product variant. 14 | * 15 | * @example 16 | * Example path creating a cart with two product variants, different quantities, and a discount code in the querystring: 17 | * ```js 18 | * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD 19 | * 20 | * ``` 21 | */ 22 | export async function loader({request, context, params}: Route.LoaderArgs) { 23 | const {cart} = context; 24 | const {lines} = params; 25 | if (!lines) return redirect('/cart'); 26 | const linesMap = lines.split(',').map((line) => { 27 | const lineDetails = line.split(':'); 28 | const variantId = lineDetails[0]; 29 | const quantity = parseInt(lineDetails[1], 10); 30 | 31 | return { 32 | merchandiseId: `gid://shopify/ProductVariant/${variantId}`, 33 | quantity, 34 | }; 35 | }); 36 | 37 | const url = new URL(request.url); 38 | const searchParams = new URLSearchParams(url.search); 39 | 40 | const discount = searchParams.get('discount'); 41 | const discountArray = discount ? [discount] : []; 42 | 43 | // create a cart 44 | const result = await cart.create({ 45 | lines: linesMap, 46 | discountCodes: discountArray, 47 | }); 48 | 49 | const cartResult = result.cart; 50 | 51 | if (result.errors?.length || !cartResult) { 52 | throw new Response('Link may be expired. Try checking the URL.', { 53 | status: 410, 54 | }); 55 | } 56 | 57 | // Update cart id in cookie 58 | const headers = cart.setCartId(cartResult.id); 59 | 60 | // redirect to checkout 61 | if (cartResult.checkoutUrl) { 62 | return redirect(cartResult.checkoutUrl, {headers}); 63 | } else { 64 | throw new Error('No checkout URL found'); 65 | } 66 | } 67 | 68 | export default function Component() { 69 | return null; 70 | } 71 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/callToActionType.tsx: -------------------------------------------------------------------------------- 1 | import {BlockElementIcon, ImageIcon} from '@sanity/icons' 2 | import {defineArrayMember, defineField} from 'sanity' 3 | 4 | export const callToActionType = defineField({ 5 | name: '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 | defineField({ 17 | name: 'layout', 18 | type: 'string', 19 | initialValue: 'left', 20 | options: { 21 | direction: 'horizontal', 22 | layout: 'radio', 23 | list: [ 24 | { 25 | title: 'Content / Copy', 26 | value: 'left', 27 | }, 28 | { 29 | title: 'Copy / Content', 30 | value: 'right', 31 | }, 32 | ], 33 | }, 34 | validation: (Rule) => Rule.required(), 35 | }), 36 | defineField({ 37 | name: 'title', 38 | type: 'string', 39 | validation: (Rule) => Rule.required(), 40 | fieldset: 'copy', 41 | }), 42 | defineField({ 43 | name: 'portableText', 44 | type: 'text', 45 | rows: 2, 46 | fieldset: 'copy', 47 | }), 48 | defineField({ 49 | name: 'link', 50 | type: 'array', 51 | of: [{type: 'linkInternal'}, {type: 'linkExternal'}], 52 | validation: (Rule) => Rule.max(1), 53 | fieldset: 'copy', 54 | }), 55 | defineField({ 56 | name: 'content', 57 | type: 'array', 58 | validation: (Rule) => Rule.required().max(1), 59 | of: [ 60 | defineArrayMember({ 61 | icon: ImageIcon, 62 | type: 'image', 63 | options: {hotspot: true}, 64 | }), 65 | defineArrayMember({ 66 | name: 'productWithVariant', 67 | type: 'productWithVariant', 68 | validation: (Rule) => Rule.required(), 69 | }), 70 | ], 71 | }), 72 | ], 73 | preview: { 74 | select: { 75 | title: 'title', 76 | }, 77 | prepare({title}) { 78 | return { 79 | subtitle: 'Call to action', 80 | title, 81 | media: BlockElementIcon, 82 | } 83 | }, 84 | }, 85 | }) 86 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/VisualEditing.tsx: -------------------------------------------------------------------------------- 1 | import {lazy, type ReactElement, Suspense} from 'react' 2 | 3 | import {isServer} from '../utils' 4 | import type {VisualEditingProps} from './VisualEditing.client' 5 | 6 | /** 7 | * Provide a consistent fallback to prevent hydration mismatch errors. 8 | */ 9 | function VisualEditingFallback(): ReactElement { 10 | return <> 11 | } 12 | 13 | /** 14 | * If server-side rendering, then return the fallback instead of the heavy dependency. 15 | */ 16 | const VisualEditingComponent = isServer() 17 | ? VisualEditingFallback 18 | : lazy( 19 | () => 20 | /** 21 | * `lazy` expects the component as the default export 22 | * @see https://react.dev/reference/react/lazy 23 | */ 24 | import('./VisualEditing.client'), 25 | ) 26 | 27 | /** 28 | * Combined visual editing component that provides both overlays and automatic live mode detection. 29 | * 30 | * **Default Usage** (automatic detection): 31 | * ```tsx 32 | * // Automatically enables live mode when `Query` components or `useQuery` hooks are present 33 | * ``` 34 | * 35 | * **Individual Composition** (advanced usage): 36 | * ```tsx 37 | * 38 | * // When you need fine-grained control 39 | * ``` 40 | * 41 | * Live mode automatically activates when `Query` components or `useQuery` hooks are detected, 42 | * providing zero-configuration setup for most users while offering advanced control when needed. 43 | * 44 | * @param props.action - The action URL path used to submit perspective changes 45 | * @param props.components - Custom overlay components for visual editing 46 | * @param props.zIndex - The CSS z-index for visual editing overlays 47 | * @param props.refresh - Custom refresh logic. Called when content changes 48 | * @param props.onConnect - Fires when a connection is established to the Studio 49 | * @param props.onDisconnect - Fires when a connection to the Studio is lost 50 | * 51 | * @see https://www.sanity.io/docs/introduction-to-visual-editing 52 | */ 53 | export function VisualEditing(props: VisualEditingProps): ReactElement { 54 | return ( 55 | }> 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/vite/plugin.ts: -------------------------------------------------------------------------------- 1 | import type {Plugin, ResolvedConfig} from 'vite' 2 | 3 | /** 4 | * Vite plugin for optimizing Sanity integration in Hydrogen applications. 5 | * Configures SSR optimization, dependency bundling, and ESM resolution for Sanity packages. 6 | */ 7 | export function sanity(): Plugin { 8 | return { 9 | name: 'sanity', 10 | 11 | async config() { 12 | return { 13 | envPrefix: ['SANITY_STUDIO_'], 14 | ssr: { 15 | optimizeDeps: { 16 | // Pre-bundle Sanity dependencies for better SSR performance 17 | include: ['@sanity/client'], 18 | }, 19 | // Prevent externalization of Sanity dependencies to ensure proper ESM resolution 20 | noExternal: ['@sanity/client'], 21 | }, 22 | } 23 | }, 24 | 25 | configResolved(resolvedConfig: ResolvedConfig) { 26 | // Force ESM resolution for transitive dependencies (specifically `rxjs`) in SSR builds 27 | // The Hydrogen/Oxygen plugins add 'node' conditions which cause packages like rxjs 28 | // to resolve to their CJS versions (dist/cjs/index.js) instead of ESM versions 29 | // We prepend 'es2015' and filter out 'node'/'require' to force ESM resolution 30 | 31 | // Prepend es2015 and remove node/require to force ESM resolution in SSR 32 | const ssrConditions = [ 33 | 'es2015', 34 | ...(resolvedConfig.ssr?.resolve?.conditions || []).filter( 35 | (condition) => condition !== 'es2015' && condition !== 'node' && condition !== 'require', 36 | ), 37 | ] 38 | 39 | // Override SSR resolve conditions 40 | if (resolvedConfig.ssr?.resolve) { 41 | resolvedConfig.ssr.resolve.conditions = ssrConditions 42 | } 43 | 44 | // Handle SSR environment (modern environments API) 45 | if (resolvedConfig.environments?.ssr?.resolve?.conditions) { 46 | const envConditions = [ 47 | 'es2015', 48 | ...resolvedConfig.environments.ssr.resolve.conditions.filter( 49 | (condition) => 50 | condition !== 'es2015' && condition !== 'node' && condition !== 'require', 51 | ), 52 | ] 53 | resolvedConfig.environments.ssr.resolve.conditions = envConditions 54 | } 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/Query.client.tsx: -------------------------------------------------------------------------------- 1 | import type {Any, ClientReturn, QueryParams, QueryWithoutParams} from '@sanity/client' 2 | import type {EncodeDataAttributeFunction} from '@sanity/core-loader/encode-data-attribute' 3 | import type {UseQueryOptionsDefinedInitial} from '@sanity/react-loader' 4 | import type {ReactNode} from 'react' 5 | 6 | import type {LoadQueryOptions} from './context' 7 | import {isServer} from './utils' 8 | import {useQuery} from './visual-editing/useQuery' 9 | 10 | /** 11 | * Prevent a consumer from importing into a worker/server bundle. 12 | */ 13 | if (isServer()) { 14 | throw new Error( 15 | '`QueryClient` should only run client-side. Please check that this file is not being imported into a worker or server bundle.', 16 | ) 17 | } 18 | 19 | export interface QueryClientProps { 20 | query: Query 21 | params?: QueryParams | QueryWithoutParams 22 | options: UseQueryOptionsDefinedInitial> & 23 | LoadQueryOptions> 24 | children: ( 25 | data: ClientReturn, 26 | encodeDataAttribute: EncodeDataAttributeFunction, 27 | ) => ReactNode 28 | } 29 | 30 | /** 31 | * Client-side query component that provides live updates via useQuery. 32 | * 33 | * Lazy loaded only when preview mode is enabled to avoid bundle bloat. 34 | * Handles loading states and provides initial data fallback during query updates. 35 | */ 36 | function QueryClient({ 37 | query, 38 | params, 39 | options, 40 | children, 41 | }: QueryClientProps): ReactNode { 42 | const {data, error, loading, encodeDataAttribute} = useQuery>( 43 | query, 44 | params, 45 | options, 46 | ) 47 | 48 | if (error) { 49 | throw error 50 | } 51 | 52 | // During loading, show initial server data to prevent flash of missing content 53 | if (loading) { 54 | const initialData = 55 | options.initial && typeof options.initial === 'object' && 'data' in options.initial 56 | ? options.initial.data 57 | : options.initial 58 | return children(initialData, encodeDataAttribute) 59 | } 60 | 61 | return children(data, encodeDataAttribute) 62 | } 63 | 64 | export default QueryClient 65 | -------------------------------------------------------------------------------- /examples/storefront/app/components/CartMain.tsx: -------------------------------------------------------------------------------- 1 | import {useOptimisticCart} from '@shopify/hydrogen'; 2 | import {Link} from 'react-router'; 3 | import type {CartApiQueryFragment} from 'storefrontapi.generated'; 4 | import {useAside} from '~/components/Aside'; 5 | import {CartLineItem} from '~/components/CartLineItem'; 6 | import {CartSummary} from './CartSummary'; 7 | 8 | export type CartLayout = 'page' | 'aside'; 9 | 10 | export type CartMainProps = { 11 | cart: CartApiQueryFragment | null; 12 | layout: CartLayout; 13 | }; 14 | 15 | /** 16 | * The main cart component that displays the cart items and summary. 17 | * It is used by both the /cart route and the cart aside dialog. 18 | */ 19 | export function CartMain({layout, cart: originalCart}: CartMainProps) { 20 | // The useOptimisticCart hook applies pending actions to the cart 21 | // so the user immediately sees feedback when they modify the cart. 22 | const cart = useOptimisticCart(originalCart); 23 | 24 | const linesCount = Boolean(cart?.lines?.nodes?.length || 0); 25 | const withDiscount = 26 | cart && 27 | Boolean(cart?.discountCodes?.filter((code) => code.applicable)?.length); 28 | const className = `cart-main ${withDiscount ? 'with-discount' : ''}`; 29 | const cartHasItems = cart?.totalQuantity ? cart.totalQuantity > 0 : false; 30 | 31 | return ( 32 |
33 |
45 | ); 46 | } 47 | 48 | function CartEmpty({ 49 | hidden = false, 50 | }: { 51 | hidden: boolean; 52 | layout?: CartMainProps['layout']; 53 | }) { 54 | const {close} = useAside(); 55 | return ( 56 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /examples/storefront/.cursor/rules/hydrogen-react-router.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | # React Router Import Rule for Hydrogen 8 | 9 | ## Overview 10 | 11 | This Hydrogen project is based on React Router, not Remix. When working with documentation or code examples, you should always use imports from the appropriate React Router packages instead of Remix packages. 12 | 13 | ## Import Replacements 14 | 15 | When you see imports from Remix packages, replace them with their equivalent React Router v7 packages. Here are the common replacements: 16 | 17 | | Remix v2 Package | React Router v7 Package | 18 | |------------------|-------------------------| 19 | | `@remix-run/react` | `react-router` | 20 | | `@remix-run/dev` | `@react-router/dev` | 21 | | `@remix-run/architect` | `@react-router/architect` | 22 | | `@remix-run/cloudflare` | `@react-router/cloudflare` | 23 | | `@remix-run/express` | `@react-router/express` | 24 | | `@remix-run/fs-routes` | `@react-router/fs-routes` | 25 | | `@remix-run/node` | `@react-router/node` | 26 | | `@remix-run/route-config` | `@react-router/dev` | 27 | | `@remix-run/routes-option-adapter` | `@react-router/remix-routes-option-adapter` | 28 | | `@remix-run/serve` | `@react-router/serve` | 29 | | `@remix-run/server-runtime` | `react-router` | 30 | | `@remix-run/testing` | `react-router` | 31 | 32 | NEVER USE 'react-router-dom' imports! 33 | 34 | ## Common Import Examples 35 | 36 | ```js 37 | // INCORRECT (Remix style) 38 | import { useLoaderData, Link, Form, useActionData, useNavigation, useSubmit } from '@remix-run/react'; 39 | 40 | // CORRECT (React Router style) 41 | import { useLoaderData, Link, Form, useActionData, useNavigation, useSubmit } from 'react-router'; 42 | ``` 43 | 44 | ## Development Guidelines 45 | 46 | 1. Always check existing code in the project to understand which specific React Router hooks and components are being used 47 | 2. When generating new code or modifying existing code, ensure all routing-related imports come from the correct React Router packages 48 | 3. If following documentation or examples based on Remix, adapt the code to use React Router equivalents 49 | 50 | When working in this codebase, always follow the React Router patterns that are already established in the existing code. 51 | 52 | For more information, consult the official Remix to React Router upgrade guide: https://reactrouter.com/upgrading/remix -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/objects/module/imageFeatureType.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 const imageFeatureType = defineField({ 13 | name: 'imageFeature', 14 | title: 'Image Feature', 15 | type: 'object', 16 | icon: ImageIcon, 17 | fields: [ 18 | defineField({ 19 | name: 'image', 20 | type: 'image', 21 | options: {hotspot: true}, 22 | validation: (Rule) => Rule.required(), 23 | }), 24 | defineField({ 25 | name: 'variant', 26 | type: 'string', 27 | options: { 28 | direction: 'horizontal', 29 | layout: 'radio', 30 | list: VARIANTS, 31 | }, 32 | initialValue: undefined, 33 | }), 34 | defineField({ 35 | name: 'caption', 36 | type: 'text', 37 | rows: 2, 38 | hidden: ({parent}) => parent.variant !== 'caption', 39 | }), 40 | defineField({ 41 | name: 'callToAction', 42 | type: 'imageCallToAction', 43 | hidden: ({parent}) => parent.variant !== 'callToAction', 44 | }), 45 | defineField({ 46 | name: 'productHotspots', 47 | title: 'Hotspots', 48 | type: 'productHotspots', 49 | hidden: ({parent}) => parent.variant !== 'productHotspots', 50 | }), 51 | defineField({ 52 | name: 'productTags', 53 | title: 'Products', 54 | type: 'array', 55 | hidden: ({parent}) => parent.variant !== 'productTags', 56 | of: [ 57 | defineField({ 58 | name: 'productWithVariant', 59 | type: 'productWithVariant', 60 | }), 61 | ], 62 | }), 63 | ], 64 | preview: { 65 | select: { 66 | fileName: 'image.asset.originalFilename', 67 | image: 'image', 68 | variant: 'variant', 69 | }, 70 | prepare({fileName, image, variant}) { 71 | const currentVariant = VARIANTS.find((v) => v.value === variant) 72 | 73 | return { 74 | media: image, 75 | subtitle: 'Image' + (currentVariant ? ` [${currentVariant.title}]` : ''), 76 | title: fileName, 77 | } 78 | }, 79 | }, 80 | }) 81 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/VisualEditing.client.tsx: -------------------------------------------------------------------------------- 1 | import type {StegaConfig} from '@sanity/client' 2 | import type {HistoryRefresh, OverlayComponentResolver} from '@sanity/visual-editing' 3 | import type {ReactNode} from 'react' 4 | 5 | import {isServer} from '../utils' 6 | import LiveModeClient from './LiveMode.client' 7 | import OverlaysClient from './Overlays.client' 8 | import {useHasActiveLoaders} from './registry' 9 | import type {Revalidator} from './types' 10 | 11 | export interface VisualEditingProps extends Omit { 12 | /** 13 | * The action URL path used to submit perspective changes. 14 | */ 15 | action?: string 16 | /** 17 | * Custom overlay components for visual editing. 18 | */ 19 | components?: OverlayComponentResolver 20 | /** 21 | * The CSS z-index for visual editing overlays. 22 | */ 23 | zIndex?: string | number 24 | /** 25 | * Custom refresh logic. Called when content changes. 26 | */ 27 | refresh?: ( 28 | payload: HistoryRefresh, 29 | refreshDefault: () => false | Promise, 30 | revalidator: Revalidator, 31 | ) => false | Promise 32 | /** 33 | * Fires when a connection is established to the Studio. 34 | */ 35 | onConnect?: () => void 36 | /** 37 | * Fires when a connection to the Studio is lost. 38 | */ 39 | onDisconnect?: () => void 40 | } 41 | 42 | /** 43 | * Prevent a consumer from importing into a worker/server bundle. 44 | */ 45 | if (isServer()) { 46 | throw new Error( 47 | 'Visual editing should only run client-side. Please check that this file is not being imported into a worker or server bundle.', 48 | ) 49 | } 50 | 51 | /** 52 | * Client-side visual editing component. 53 | * Automatically enables live mode when `Query` components or `useQuery` hooks are detected. 54 | */ 55 | function VisualEditingClient(props: VisualEditingProps): ReactNode { 56 | const {action, components, zIndex, refresh, onConnect, onDisconnect, ...stegaProps} = props 57 | 58 | // Get current loader detection state 59 | const hasActiveLoaders = useHasActiveLoaders() 60 | 61 | return ( 62 | <> 63 | 64 | {hasActiveLoaders && ( 65 | 66 | )} 67 | 68 | ) 69 | } 70 | 71 | export default VisualEditingClient 72 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/media/ShopifyDocumentStatus.tsx: -------------------------------------------------------------------------------- 1 | import {CloseIcon, ImageIcon, LinkRemovedIcon} from '@sanity/icons' 2 | import {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 | -------------------------------------------------------------------------------- /examples/storefront/app/styles/reset.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 3 | system-ui, 4 | -apple-system, 5 | BlinkMacSystemFont, 6 | 'Segoe UI', 7 | Roboto, 8 | Oxygen, 9 | Ubuntu, 10 | Cantarell, 11 | 'Open Sans', 12 | 'Helvetica Neue', 13 | sans-serif; 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | h1, 19 | h2, 20 | p { 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | h1 { 26 | font-size: 1.6rem; 27 | font-weight: 700; 28 | line-height: 1.4; 29 | margin-bottom: 2rem; 30 | margin-top: 2rem; 31 | } 32 | 33 | h2 { 34 | font-size: 1.2rem; 35 | font-weight: 700; 36 | line-height: 1.4; 37 | margin-bottom: 1rem; 38 | } 39 | 40 | h4 { 41 | margin-top: 0.5rem; 42 | margin-bottom: 0.5rem; 43 | } 44 | 45 | h5 { 46 | margin-bottom: 1rem; 47 | margin-top: 0.5rem; 48 | } 49 | 50 | p { 51 | font-size: 1rem; 52 | line-height: 1.4; 53 | } 54 | 55 | a { 56 | color: #000; 57 | text-decoration: none; 58 | } 59 | 60 | a:hover { 61 | text-decoration: underline; 62 | cursor: pointer; 63 | } 64 | 65 | hr { 66 | border-bottom: none; 67 | border-top: 1px solid #000; 68 | margin: 0; 69 | } 70 | 71 | pre { 72 | white-space: pre-wrap; 73 | } 74 | 75 | body { 76 | display: flex; 77 | flex-direction: column; 78 | min-height: 100vh; 79 | } 80 | 81 | body > main { 82 | margin: 0 1rem 1rem 1rem; 83 | } 84 | 85 | section { 86 | padding: 1rem 0; 87 | @media (min-width: 768px) { 88 | padding: 2rem 0; 89 | } 90 | } 91 | 92 | fieldset { 93 | display: flex; 94 | flex-direction: column; 95 | margin-bottom: 0.5rem; 96 | padding: 1rem; 97 | } 98 | 99 | form { 100 | max-width: 100%; 101 | @media (min-width: 768px) { 102 | max-width: 400px; 103 | } 104 | } 105 | 106 | input { 107 | border-radius: 4px; 108 | border: 1px solid #000; 109 | font-size: 1rem; 110 | margin-bottom: 0.5rem; 111 | margin-top: 0.25rem; 112 | padding: 0.5rem; 113 | } 114 | 115 | legend { 116 | font-weight: 600; 117 | margin-bottom: 0.5rem; 118 | } 119 | 120 | ul { 121 | list-style: none; 122 | margin: 0; 123 | padding: 0; 124 | } 125 | 126 | li { 127 | margin-bottom: 0.5rem; 128 | } 129 | 130 | dl { 131 | margin: 0.5rem 0; 132 | } 133 | 134 | code { 135 | background: #ddd; 136 | border-radius: 4px; 137 | font-family: monospace; 138 | padding: 0.25rem; 139 | } 140 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/preview/session.ts: -------------------------------------------------------------------------------- 1 | import {perspectiveCookieName} from '@sanity/preview-url-secret/constants' 2 | import {createCookieSessionStorage, type Session, type SessionStorage} from 'react-router' 3 | 4 | interface PreviewSessionData { 5 | perspective: string 6 | } 7 | 8 | /** 9 | * Interface for Sanity preview session management. 10 | */ 11 | export interface SanityPreviewSession { 12 | has: Session['has'] 13 | get: Session['get'] 14 | set: Session['set'] 15 | unset: Session['unset'] 16 | commit: () => ReturnType['commitSession']> 17 | destroy: () => ReturnType['destroySession']> 18 | } 19 | 20 | /** 21 | * Cookie-based session storage for Sanity preview mode. 22 | * Manages perspective state and authentication for preview mode. 23 | */ 24 | export class PreviewSession implements SanityPreviewSession { 25 | #sessionStorage 26 | #session 27 | 28 | constructor(sessionStorage: SessionStorage, session: Session) { 29 | this.#sessionStorage = sessionStorage 30 | this.#session = session 31 | } 32 | 33 | static async init(request: Request, secrets: string[]): Promise { 34 | const storage = createCookieSessionStorage({ 35 | cookie: { 36 | name: perspectiveCookieName, 37 | httpOnly: true, 38 | path: '/', 39 | sameSite: 'none', 40 | secure: true, 41 | secrets, 42 | }, 43 | }) 44 | 45 | const session = await storage 46 | .getSession(request.headers.get('Cookie')) 47 | .catch(() => storage.getSession()) 48 | 49 | return new this(storage, session) 50 | } 51 | 52 | get has(): SanityPreviewSession['has'] { 53 | return this.#session.has 54 | } 55 | 56 | get get(): SanityPreviewSession['get'] { 57 | return this.#session.get 58 | } 59 | 60 | get unset(): SanityPreviewSession['unset'] { 61 | return this.#session.unset 62 | } 63 | 64 | get set(): SanityPreviewSession['set'] { 65 | return this.#session.set 66 | } 67 | 68 | destroy(): ReturnType { 69 | return this.#sessionStorage.destroySession(this.#session) 70 | } 71 | 72 | commit(): ReturnType { 73 | return this.#sessionStorage.commitSession(this.#session) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /examples/storefront/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storefront", 3 | "private": true, 4 | "sideEffects": false, 5 | "version": "2025.7.0", 6 | "type": "module", 7 | "scripts": { 8 | "build": "shopify hydrogen build --codegen", 9 | "dev": "shopify hydrogen dev --codegen", 10 | "preview": "shopify hydrogen preview --build", 11 | "lint": "eslint --no-error-on-unmatched-pattern .", 12 | "deploy": "shopify hydrogen deploy --no-lockfile-check", 13 | "typecheck": "npm run codegen && tsc --noEmit", 14 | "codegen": "shopify hydrogen codegen && sanity typegen generate && react-router typegen" 15 | }, 16 | "prettier": "@shopify/prettier-config", 17 | "dependencies": { 18 | "@sanity/client": "^7.8.2", 19 | "@shopify/hydrogen": "2025.7.0", 20 | "graphql": "^16.10.0", 21 | "graphql-tag": "^2.12.6", 22 | "groq": "^4.4.1", 23 | "hydrogen-sanity": "workspace:*", 24 | "isbot": "^5.1.22", 25 | "react": "18.3.1", 26 | "react-dom": "18.3.1", 27 | "react-router": "7.9.2", 28 | "react-router-dom": "7.9.2", 29 | "sanity": "^4.6.1" 30 | }, 31 | "devDependencies": { 32 | "@eslint/compat": "^1.2.5", 33 | "@eslint/eslintrc": "^3.2.0", 34 | "@eslint/js": "^9.18.0", 35 | "@graphql-codegen/cli": "5.0.2", 36 | "@react-router/dev": "7.9.2", 37 | "@react-router/fs-routes": "7.9.2", 38 | "@shopify/cli": "3.85.4", 39 | "@shopify/hydrogen-codegen": "^0.3.3", 40 | "@shopify/mini-oxygen": "^4.0.0", 41 | "@shopify/oxygen-workers-types": "^4.1.6", 42 | "@shopify/prettier-config": "^1.1.2", 43 | "@total-typescript/ts-reset": "^0.6.1", 44 | "@types/eslint": "^9.6.1", 45 | "@types/react": "^18.2.22", 46 | "@types/react-dom": "^18.2.7", 47 | "@typescript-eslint/eslint-plugin": "^8.21.0", 48 | "@typescript-eslint/parser": "^8.21.0", 49 | "eslint": "^9.18.0", 50 | "eslint-config-prettier": "^10.0.1", 51 | "eslint-import-resolver-typescript": "^3.7.0", 52 | "eslint-plugin-eslint-comments": "^3.2.0", 53 | "eslint-plugin-import": "^2.31.0", 54 | "eslint-plugin-jest": "^28.11.0", 55 | "eslint-plugin-jsx-a11y": "^6.10.2", 56 | "eslint-plugin-react": "^7.37.4", 57 | "eslint-plugin-react-hooks": "^5.1.0", 58 | "globals": "^15.14.0", 59 | "graphql-config": "^5.1.5", 60 | "prettier": "^3.4.2", 61 | "typescript": "^5.9.2", 62 | "vite": "^6.2.4", 63 | "vite-tsconfig-paths": "^4.3.1" 64 | }, 65 | "engines": { 66 | "node": ">=18.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/storefront/app/lib/search.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PredictiveSearchQuery, 3 | RegularSearchQuery, 4 | } from 'storefrontapi.generated'; 5 | 6 | type ResultWithItems = { 7 | type: Type; 8 | term: string; 9 | error?: string; 10 | result: {total: number; items: Items}; 11 | }; 12 | 13 | export type RegularSearchReturn = ResultWithItems< 14 | 'regular', 15 | RegularSearchQuery 16 | >; 17 | export type PredictiveSearchReturn = ResultWithItems< 18 | 'predictive', 19 | NonNullable 20 | >; 21 | 22 | /** 23 | * Returns the empty state of a predictive search result to reset the search state. 24 | */ 25 | export function getEmptyPredictiveSearchResult(): PredictiveSearchReturn['result'] { 26 | return { 27 | total: 0, 28 | items: { 29 | articles: [], 30 | collections: [], 31 | products: [], 32 | pages: [], 33 | queries: [], 34 | }, 35 | }; 36 | } 37 | 38 | interface UrlWithTrackingParams { 39 | /** The base URL to which the tracking parameters will be appended. */ 40 | baseUrl: string; 41 | /** The trackingParams returned by the Storefront API. */ 42 | trackingParams?: string | null; 43 | /** Any additional query parameters to be appended to the URL. */ 44 | params?: Record; 45 | /** The search term to be appended to the URL. */ 46 | term: string; 47 | } 48 | 49 | /** 50 | * A utility function that appends tracking parameters to a URL. Tracking parameters are 51 | * used internally by Shopify to enhance search results and admin dashboards. 52 | * @example 53 | * ```ts 54 | * const baseUrl = 'www.example.com'; 55 | * const trackingParams = 'utm_source=shopify&utm_medium=shopify_app&utm_campaign=storefront'; 56 | * const params = { foo: 'bar' }; 57 | * const term = 'search term'; 58 | * const url = urlWithTrackingParams({ baseUrl, trackingParams, params, term }); 59 | * console.log(url); 60 | * // Output: 'https://www.example.com?foo=bar&q=search%20term&utm_source=shopify&utm_medium=shopify_app&utm_campaign=storefront' 61 | * ``` 62 | */ 63 | export function urlWithTrackingParams({ 64 | baseUrl, 65 | trackingParams, 66 | params: extraParams, 67 | term, 68 | }: UrlWithTrackingParams) { 69 | let search = new URLSearchParams({ 70 | ...extraParams, 71 | q: encodeURIComponent(term), 72 | }).toString(); 73 | 74 | if (trackingParams) { 75 | search = `${search}&${trackingParams}`; 76 | } 77 | 78 | return `${baseUrl}?${search}`; 79 | } 80 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/account.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | data as remixData, 3 | Form, 4 | NavLink, 5 | Outlet, 6 | useLoaderData, 7 | } from 'react-router'; 8 | import type {Route} from './+types/account'; 9 | import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; 10 | 11 | export function shouldRevalidate() { 12 | return true; 13 | } 14 | 15 | export async function loader({context}: Route.LoaderArgs) { 16 | const {customerAccount} = context; 17 | const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { 18 | variables: { 19 | language: customerAccount.i18n.language, 20 | }, 21 | }); 22 | 23 | if (errors?.length || !data?.customer) { 24 | throw new Error('Customer not found'); 25 | } 26 | 27 | return remixData( 28 | {customer: data.customer}, 29 | { 30 | headers: { 31 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 32 | }, 33 | }, 34 | ); 35 | } 36 | 37 | export default function AccountLayout() { 38 | const {customer} = useLoaderData(); 39 | 40 | const heading = customer 41 | ? customer.firstName 42 | ? `Welcome, ${customer.firstName}` 43 | : `Welcome to your account.` 44 | : 'Account Details'; 45 | 46 | return ( 47 |
48 |

{heading}

49 |
50 | 51 |
52 |
53 | 54 |
55 | ); 56 | } 57 | 58 | function AccountMenu() { 59 | function isActiveStyle({ 60 | isActive, 61 | isPending, 62 | }: { 63 | isActive: boolean; 64 | isPending: boolean; 65 | }) { 66 | return { 67 | fontWeight: isActive ? 'bold' : undefined, 68 | color: isPending ? 'grey' : 'black', 69 | }; 70 | } 71 | 72 | return ( 73 | 88 | ); 89 | } 90 | 91 | function Logout() { 92 | return ( 93 |
94 |   95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /packages/sanity-config/src/schemaTypes/singletons/settingsType.ts: -------------------------------------------------------------------------------- 1 | import {CogIcon, ControlsIcon, ErrorOutlineIcon, MenuIcon, SearchIcon} from '@sanity/icons' 2 | import {defineType, defineField} from 'sanity' 3 | 4 | const TITLE = 'Settings' 5 | interface ProductOptions { 6 | title: string 7 | } 8 | 9 | export const settingsType = 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 | icon: MenuIcon, 20 | }, 21 | { 22 | name: 'productOptions', 23 | title: 'Product options', 24 | icon: ControlsIcon, 25 | }, 26 | { 27 | name: 'notFoundPage', 28 | title: '404 page', 29 | icon: ErrorOutlineIcon, 30 | }, 31 | { 32 | name: 'seo', 33 | title: 'SEO', 34 | icon: SearchIcon, 35 | }, 36 | ], 37 | fields: [ 38 | defineField({ 39 | name: 'menu', 40 | type: 'menu', 41 | group: 'navigation', 42 | }), 43 | defineField({ 44 | name: 'footer', 45 | type: 'footerSettings', 46 | group: 'navigation', 47 | }), 48 | defineField({ 49 | name: 'customProductOptions', 50 | type: 'array', 51 | group: 'productOptions', 52 | of: [ 53 | { 54 | name: 'customProductOption.color', 55 | type: 'customProductOption.color', 56 | }, 57 | { 58 | name: 'customProductOption.size', 59 | type: 'customProductOption.size', 60 | }, 61 | ], 62 | validation: (Rule) => 63 | Rule.custom((options: ProductOptions[] | undefined) => { 64 | // Each product option type must have a unique title 65 | if (options) { 66 | const uniqueTitles = new Set(options.map((option) => option.title)) 67 | if (options.length > uniqueTitles.size) { 68 | return 'Each product option type must have a unique title' 69 | } 70 | } 71 | return true 72 | }), 73 | }), 74 | // Not found page 75 | defineField({ 76 | name: 'notFoundPage', 77 | title: '404 page', 78 | type: 'notFoundPage', 79 | group: 'notFoundPage', 80 | }), 81 | // SEO 82 | defineField({ 83 | name: 'seo', 84 | title: 'SEO', 85 | type: 'seo', 86 | group: 'seo', 87 | }), 88 | ], 89 | preview: { 90 | prepare() { 91 | return { 92 | title: TITLE, 93 | } 94 | }, 95 | }, 96 | }) 97 | -------------------------------------------------------------------------------- /examples/storefront/app/components/SearchFormPredictive.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useFetcher, 3 | useNavigate, 4 | type FormProps, 5 | type Fetcher, 6 | } from 'react-router'; 7 | import React, {useRef, useEffect} from 'react'; 8 | import type {PredictiveSearchReturn} from '~/lib/search'; 9 | import {useAside} from './Aside'; 10 | 11 | type SearchFormPredictiveChildren = (args: { 12 | fetchResults: (event: React.ChangeEvent) => void; 13 | goToSearch: () => void; 14 | inputRef: React.MutableRefObject; 15 | fetcher: Fetcher; 16 | }) => React.ReactNode; 17 | 18 | type SearchFormPredictiveProps = Omit & { 19 | children: SearchFormPredictiveChildren | null; 20 | }; 21 | 22 | export const SEARCH_ENDPOINT = '/search'; 23 | 24 | /** 25 | * Search form component that sends search requests to the `/search` route 26 | **/ 27 | export function SearchFormPredictive({ 28 | children, 29 | className = 'predictive-search-form', 30 | ...props 31 | }: SearchFormPredictiveProps) { 32 | const fetcher = useFetcher({key: 'search'}); 33 | const inputRef = useRef(null); 34 | const navigate = useNavigate(); 35 | const aside = useAside(); 36 | 37 | /** Reset the input value and blur the input */ 38 | function resetInput(event: React.FormEvent) { 39 | event.preventDefault(); 40 | event.stopPropagation(); 41 | if (inputRef?.current?.value) { 42 | inputRef.current.blur(); 43 | } 44 | } 45 | 46 | /** Navigate to the search page with the current input value */ 47 | function goToSearch() { 48 | const term = inputRef?.current?.value; 49 | void navigate(SEARCH_ENDPOINT + (term ? `?q=${term}` : '')); 50 | aside.close(); 51 | } 52 | 53 | /** Fetch search results based on the input value */ 54 | function fetchResults(event: React.ChangeEvent) { 55 | void fetcher.submit( 56 | {q: event.target.value || '', limit: 5, predictive: true}, 57 | {method: 'GET', action: SEARCH_ENDPOINT}, 58 | ); 59 | } 60 | 61 | // ensure the passed input has a type of search, because SearchResults 62 | // will select the element based on the input 63 | useEffect(() => { 64 | inputRef?.current?.setAttribute('type', 'search'); 65 | }, []); 66 | 67 | if (typeof children !== 'function') { 68 | return null; 69 | } 70 | 71 | return ( 72 | 73 | {children({inputRef, fetcher, fetchResults, goToSearch})} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /packages/hydrogen-sanity/src/visual-editing/Overlays.client.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | import {render} from '@testing-library/react' 5 | import {BrowserRouter} from 'react-router' 6 | import {beforeEach, expect, it, vi} from 'vitest' 7 | 8 | import OverlaysClient from './Overlays.client' 9 | 10 | // Mock external dependencies 11 | vi.mock('@sanity/visual-editing', () => ({ 12 | enableVisualEditing: vi.fn(() => vi.fn()), 13 | })) 14 | 15 | vi.mock('@sanity/presentation-comlink', () => ({ 16 | isMaybePresentation: vi.fn(() => false), // Default to standalone context in tests 17 | })) 18 | 19 | vi.mock('react-router', async () => { 20 | const actual = await vi.importActual('react-router') 21 | return { 22 | ...actual, 23 | useSubmit: vi.fn(), 24 | useRevalidator: vi.fn(() => ({ 25 | state: 'idle', 26 | revalidate: vi.fn(), 27 | })), 28 | } 29 | }) 30 | 31 | vi.mock('../provider', () => ({ 32 | useSanityProviderValue: vi.fn(() => ({ 33 | projectId: 'test-project', 34 | dataset: 'production', 35 | perspective: 'published', 36 | apiVersion: '2023-01-01', 37 | stegaEnabled: false, 38 | })), 39 | })) 40 | 41 | vi.mock('./hooks/refresh', () => ({ 42 | useRefresh: vi.fn(() => ({ 43 | refreshHandler: vi.fn(() => vi.fn()), 44 | handleRevalidatorState: vi.fn(), 45 | revalidatorState: 'idle', 46 | })), 47 | })) 48 | 49 | vi.mock('./hooks/history', () => ({ 50 | useHistory: vi.fn(() => ({})), 51 | })) 52 | 53 | const {enableVisualEditing} = await import('@sanity/visual-editing') 54 | const mockEnableVisualEditing = vi.mocked(enableVisualEditing) 55 | 56 | beforeEach(() => { 57 | vi.clearAllMocks() 58 | }) 59 | 60 | it('should enable visual editing overlays', () => { 61 | render( 62 | 63 | 64 | , 65 | ) 66 | 67 | expect(mockEnableVisualEditing).toHaveBeenCalled() 68 | }) 69 | 70 | it('should pass components and zIndex to enableVisualEditing', () => { 71 | const components = vi.fn() 72 | 73 | render( 74 | 75 | 76 | , 77 | ) 78 | 79 | expect(mockEnableVisualEditing).toHaveBeenCalledWith({ 80 | components, 81 | zIndex: 999, 82 | refresh: expect.any(Function), 83 | history: expect.any(Object), 84 | }) 85 | }) 86 | 87 | it('should return null (no visual output)', () => { 88 | const {container} = render( 89 | 90 | 91 | , 92 | ) 93 | 94 | expect(container.firstChild).toBeNull() 95 | }) 96 | -------------------------------------------------------------------------------- /examples/storefront/app/components/Aside.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | type ReactNode, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from 'react'; 8 | 9 | type AsideType = 'search' | 'cart' | 'mobile' | 'closed'; 10 | type AsideContextValue = { 11 | type: AsideType; 12 | open: (mode: AsideType) => void; 13 | close: () => void; 14 | }; 15 | 16 | /** 17 | * A side bar component with Overlay 18 | * @example 19 | * ```jsx 20 | * 24 | * ``` 25 | */ 26 | export function Aside({ 27 | children, 28 | heading, 29 | type, 30 | }: { 31 | children?: React.ReactNode; 32 | type: AsideType; 33 | heading: React.ReactNode; 34 | }) { 35 | const {type: activeType, close} = useAside(); 36 | const expanded = type === activeType; 37 | 38 | useEffect(() => { 39 | const abortController = new AbortController(); 40 | 41 | if (expanded) { 42 | document.addEventListener( 43 | 'keydown', 44 | function handler(event: KeyboardEvent) { 45 | if (event.key === 'Escape') { 46 | close(); 47 | } 48 | }, 49 | {signal: abortController.signal}, 50 | ); 51 | } 52 | return () => abortController.abort(); 53 | }, [close, expanded]); 54 | 55 | return ( 56 |
61 | 68 | 69 |
{children}
70 | 71 |
72 | ); 73 | } 74 | 75 | const AsideContext = createContext(null); 76 | 77 | Aside.Provider = function AsideProvider({children}: {children: ReactNode}) { 78 | const [type, setType] = useState('closed'); 79 | 80 | return ( 81 | setType('closed'), 86 | }} 87 | > 88 | {children} 89 | 90 | ); 91 | }; 92 | 93 | export function useAside() { 94 | const aside = useContext(AsideContext); 95 | if (!aside) { 96 | throw new Error('useAside must be used within an AsideProvider'); 97 | } 98 | return aside; 99 | } 100 | -------------------------------------------------------------------------------- /examples/storefront/app/routes/pages.$handle.tsx: -------------------------------------------------------------------------------- 1 | import {useLoaderData} from 'react-router'; 2 | import type {Route} from './+types/pages.$handle'; 3 | import {redirectIfHandleIsLocalized} from '~/lib/redirect'; 4 | 5 | export const meta: Route.MetaFunction = ({data}) => { 6 | return [{title: `Hydrogen | ${data?.page.title ?? ''}`}]; 7 | }; 8 | 9 | export async function loader(args: Route.LoaderArgs) { 10 | // Start fetching non-critical data without blocking time to first byte 11 | const deferredData = loadDeferredData(args); 12 | 13 | // Await the critical data required to render initial state of the page 14 | const criticalData = await loadCriticalData(args); 15 | 16 | return {...deferredData, ...criticalData}; 17 | } 18 | 19 | /** 20 | * Load data necessary for rendering content above the fold. This is the critical data 21 | * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. 22 | */ 23 | async function loadCriticalData({context, request, params}: Route.LoaderArgs) { 24 | if (!params.handle) { 25 | throw new Error('Missing page handle'); 26 | } 27 | 28 | const [{page}] = await Promise.all([ 29 | context.storefront.query(PAGE_QUERY, { 30 | variables: { 31 | handle: params.handle, 32 | }, 33 | }), 34 | // Add other queries here, so that they are loaded in parallel 35 | ]); 36 | 37 | if (!page) { 38 | throw new Response('Not Found', {status: 404}); 39 | } 40 | 41 | redirectIfHandleIsLocalized(request, {handle: params.handle, data: page}); 42 | 43 | return { 44 | page, 45 | }; 46 | } 47 | 48 | /** 49 | * Load data for rendering content below the fold. This data is deferred and will be 50 | * fetched after the initial page load. If it's unavailable, the page should still 200. 51 | * Make sure to not throw any errors here, as it will cause the page to 500. 52 | */ 53 | function loadDeferredData({context}: Route.LoaderArgs) { 54 | return {}; 55 | } 56 | 57 | export default function Page() { 58 | const {page} = useLoaderData(); 59 | 60 | return ( 61 |
62 |
63 |

{page.title}

64 |
65 |
66 |
67 | ); 68 | } 69 | 70 | const PAGE_QUERY = `#graphql 71 | query Page( 72 | $language: LanguageCode, 73 | $country: CountryCode, 74 | $handle: String! 75 | ) 76 | @inContext(language: $language, country: $country) { 77 | page(handle: $handle) { 78 | handle 79 | id 80 | title 81 | body 82 | seo { 83 | description 84 | title 85 | } 86 | } 87 | } 88 | ` as const; 89 | -------------------------------------------------------------------------------- /packages/sanity-config/src/components/icons/Shopify.tsx: -------------------------------------------------------------------------------- 1 | const ShopifyIcon = () => { 2 | return ( 3 | 4 | 8 | 12 | 16 | 17 | ) 18 | } 19 | 20 | export default ShopifyIcon 21 | --------------------------------------------------------------------------------