├── .editorconfig
├── .github
├── renovate.json
└── workflows
│ └── main.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── commitlint.config.js
├── examples
├── embedded-studio
│ ├── .env.example
│ ├── .eslintignore
│ ├── .eslintrc.cjs
│ ├── .gitignore
│ ├── .graphqlrc.yml
│ ├── .npmrc
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── app
│ │ ├── assets
│ │ │ └── favicon.svg
│ │ ├── components
│ │ │ ├── Aside.tsx
│ │ │ ├── Cart.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── Layout.tsx
│ │ │ └── Search.tsx
│ │ ├── entry.client.tsx
│ │ ├── entry.server.tsx
│ │ ├── graphql
│ │ │ └── customer-account
│ │ │ │ ├── CustomerAddressMutations.ts
│ │ │ │ ├── CustomerDetailsQuery.ts
│ │ │ │ ├── CustomerOrderQuery.ts
│ │ │ │ ├── CustomerOrdersQuery.ts
│ │ │ │ └── CustomerUpdateMutation.ts
│ │ ├── lib
│ │ │ ├── fragments.ts
│ │ │ ├── root-data.ts
│ │ │ ├── search.ts
│ │ │ ├── session.ts
│ │ │ └── variants.ts
│ │ ├── root.tsx
│ │ ├── routes
│ │ │ ├── $.tsx
│ │ │ ├── [robots.txt].tsx
│ │ │ ├── [sitemap.xml].tsx
│ │ │ ├── _index.tsx
│ │ │ ├── account.$.tsx
│ │ │ ├── account._index.tsx
│ │ │ ├── account.addresses.tsx
│ │ │ ├── account.orders.$id.tsx
│ │ │ ├── account.orders._index.tsx
│ │ │ ├── account.profile.tsx
│ │ │ ├── account.tsx
│ │ │ ├── account_.authorize.tsx
│ │ │ ├── account_.login.tsx
│ │ │ ├── account_.logout.tsx
│ │ │ ├── api.predictive-search.tsx
│ │ │ ├── api.preview.tsx
│ │ │ ├── blogs.$blogHandle.$articleHandle.tsx
│ │ │ ├── blogs.$blogHandle._index.tsx
│ │ │ ├── blogs._index.tsx
│ │ │ ├── cart.$lines.tsx
│ │ │ ├── cart.tsx
│ │ │ ├── collections.$handle.tsx
│ │ │ ├── collections._index.tsx
│ │ │ ├── collections.all.tsx
│ │ │ ├── discount.$code.tsx
│ │ │ ├── pages.$handle.tsx
│ │ │ ├── policies.$handle.tsx
│ │ │ ├── policies._index.tsx
│ │ │ ├── products.$handle.tsx
│ │ │ ├── search.tsx
│ │ │ └── studio.$
│ │ │ │ ├── ClientOnly.tsx
│ │ │ │ ├── SanityStudio.client.tsx
│ │ │ │ ├── route.tsx
│ │ │ │ ├── studio.css
│ │ │ │ └── useHydrated.tsx
│ │ ├── sanity
│ │ │ ├── config.ts
│ │ │ └── schema
│ │ │ │ └── index.ts
│ │ └── styles
│ │ │ ├── app.css
│ │ │ └── reset.css
│ ├── customer-accountapi.generated.d.ts
│ ├── env.d.ts
│ ├── package.json
│ ├── public
│ │ └── .gitkeep
│ ├── sanity.cli.ts
│ ├── sanity.config.ts
│ ├── server.ts
│ ├── storefrontapi.generated.d.ts
│ ├── tsconfig.json
│ └── vite.config.ts
├── remix-storefront
│ ├── .env.example
│ ├── .eslintignore
│ ├── .eslintrc.cjs
│ ├── .gitignore
│ ├── .graphqlrc.yml
│ ├── .npmrc
│ ├── README.md
│ ├── app
│ │ ├── assets
│ │ │ └── favicon.svg
│ │ ├── components
│ │ │ ├── Aside.tsx
│ │ │ ├── Cart.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── Layout.tsx
│ │ │ └── Search.tsx
│ │ ├── entry.client.tsx
│ │ ├── entry.server.tsx
│ │ ├── graphql
│ │ │ └── customer-account
│ │ │ │ ├── CustomerAddressMutations.ts
│ │ │ │ ├── CustomerDetailsQuery.ts
│ │ │ │ ├── CustomerOrderQuery.ts
│ │ │ │ ├── CustomerOrdersQuery.ts
│ │ │ │ └── CustomerUpdateMutation.ts
│ │ ├── lib
│ │ │ ├── fragments.ts
│ │ │ ├── root-data.ts
│ │ │ ├── search.ts
│ │ │ ├── session.ts
│ │ │ └── variants.ts
│ │ ├── root.tsx
│ │ ├── routes
│ │ │ ├── $.tsx
│ │ │ ├── [robots.txt].tsx
│ │ │ ├── [sitemap.xml].tsx
│ │ │ ├── _index.tsx
│ │ │ ├── account.$.tsx
│ │ │ ├── account._index.tsx
│ │ │ ├── account.addresses.tsx
│ │ │ ├── account.orders.$id.tsx
│ │ │ ├── account.orders._index.tsx
│ │ │ ├── account.profile.tsx
│ │ │ ├── account.tsx
│ │ │ ├── account_.authorize.tsx
│ │ │ ├── account_.login.tsx
│ │ │ ├── account_.logout.tsx
│ │ │ ├── api.predictive-search.tsx
│ │ │ ├── blogs.$blogHandle.$articleHandle.tsx
│ │ │ ├── blogs.$blogHandle._index.tsx
│ │ │ ├── blogs._index.tsx
│ │ │ ├── cart.$lines.tsx
│ │ │ ├── cart.tsx
│ │ │ ├── collections.$handle.tsx
│ │ │ ├── collections._index.tsx
│ │ │ ├── collections.all.tsx
│ │ │ ├── discount.$code.tsx
│ │ │ ├── pages.$handle.tsx
│ │ │ ├── policies.$handle.tsx
│ │ │ ├── policies._index.tsx
│ │ │ ├── products.$handle.tsx
│ │ │ └── search.tsx
│ │ └── styles
│ │ │ ├── app.css
│ │ │ └── reset.css
│ ├── customer-accountapi.generated.d.ts
│ ├── package.json
│ ├── public
│ │ └── .gitkeep
│ ├── remix.config.js
│ ├── remix.env.d.ts
│ ├── server.ts
│ ├── storefrontapi.generated.d.ts
│ └── tsconfig.json
└── vite-storefront
│ ├── .env.example
│ ├── .eslintignore
│ ├── .eslintrc.cjs
│ ├── .gitignore
│ ├── .graphqlrc.yml
│ ├── .npmrc
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── app
│ ├── assets
│ │ └── favicon.svg
│ ├── components
│ │ ├── Aside.tsx
│ │ ├── Cart.tsx
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ ├── Layout.tsx
│ │ └── Search.tsx
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── graphql
│ │ └── customer-account
│ │ │ ├── CustomerAddressMutations.ts
│ │ │ ├── CustomerDetailsQuery.ts
│ │ │ ├── CustomerOrderQuery.ts
│ │ │ ├── CustomerOrdersQuery.ts
│ │ │ └── CustomerUpdateMutation.ts
│ ├── lib
│ │ ├── fragments.ts
│ │ ├── root-data.ts
│ │ ├── search.ts
│ │ ├── session.ts
│ │ └── variants.ts
│ ├── root.tsx
│ ├── routes
│ │ ├── $.tsx
│ │ ├── [robots.txt].tsx
│ │ ├── [sitemap.xml].tsx
│ │ ├── _index.tsx
│ │ ├── account.$.tsx
│ │ ├── account._index.tsx
│ │ ├── account.addresses.tsx
│ │ ├── account.orders.$id.tsx
│ │ ├── account.orders._index.tsx
│ │ ├── account.profile.tsx
│ │ ├── account.tsx
│ │ ├── account_.authorize.tsx
│ │ ├── account_.login.tsx
│ │ ├── account_.logout.tsx
│ │ ├── api.predictive-search.tsx
│ │ ├── blogs.$blogHandle.$articleHandle.tsx
│ │ ├── blogs.$blogHandle._index.tsx
│ │ ├── blogs._index.tsx
│ │ ├── cart.$lines.tsx
│ │ ├── cart.tsx
│ │ ├── collections.$handle.tsx
│ │ ├── collections._index.tsx
│ │ ├── collections.all.tsx
│ │ ├── discount.$code.tsx
│ │ ├── pages.$handle.tsx
│ │ ├── policies.$handle.tsx
│ │ ├── policies._index.tsx
│ │ ├── products.$handle.tsx
│ │ └── search.tsx
│ └── styles
│ │ ├── app.css
│ │ └── reset.css
│ ├── customer-accountapi.generated.d.ts
│ ├── env.d.ts
│ ├── package.json
│ ├── public
│ └── .gitkeep
│ ├── server.ts
│ ├── storefrontapi.generated.d.ts
│ ├── tsconfig.json
│ └── vite.config.ts
├── lint-staged.base.js
├── lint-staged.config.js
├── package.json
├── package
├── .eslintignore
├── .eslintrc
├── .releaserc.json
├── CHANGELOG.md
├── LICENSE
├── MIGRATE-v3-to-v4.md
├── README.md
├── lint-staged.config.js
├── package.config.ts
├── package.json
├── src
│ ├── client.ts
│ ├── context.test.ts
│ ├── context.ts
│ ├── groq.ts
│ ├── index.ts
│ ├── loader.test.ts
│ ├── loader.ts
│ ├── preview
│ │ ├── route.test.ts
│ │ └── route.ts
│ ├── utils.ts
│ └── visual-editing
│ │ ├── VisualEditing.client.tsx
│ │ ├── VisualEditing.tsx
│ │ └── index.ts
├── tsconfig.dist.json
├── tsconfig.json
├── tsconfig.settings.json
├── vitest.config.ts
└── vitest.setup.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── turbo.json
└── vitest.workspace.ts
/.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 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["local>sanity-io/renovate-config"]
4 | }
5 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | pnpx commitlint --edit $1
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | pnpx lint-staged
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/*
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | pnpm-lock.yaml
3 | yarn.lock
4 | package-lock.json
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "printWidth": 100,
4 | "bracketSpacing": false,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ./package/README.md
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | extends: ['@commitlint/config-conventional'],
3 | }
4 |
--------------------------------------------------------------------------------
/examples/embedded-studio/.env.example:
--------------------------------------------------------------------------------
1 | # These variables are only available locally in MiniOxygen
2 | # https://shopify.dev/docs/custom-storefronts/hydrogen/environment-variables
3 |
4 | # Shopify environment variables...
5 | SESSION_SECRET=
6 | PUBLIC_STOREFRONT_ID=
7 | PUBLIC_STOREFRONT_API_TOKEN=
8 | PUBLIC_STORE_DOMAIN=
9 | PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID=
10 | PUBLIC_CUSTOMER_ACCOUNT_API_URL=
11 | PRIVATE_STOREFRONT_API_TOKEN=
12 |
13 | # Sanity environment variables...
14 |
15 | # Project ID
16 | PUBLIC_SANITY_PROJECT_ID=
17 |
18 | # (Optional) Dataset name
19 | # Defaults to `production`
20 | # PUBLIC_SANITY_DATASET=
21 |
22 | # (Optional) Sanity API version
23 | # Defaults to `v2022-03-07`
24 | # PUBLIC_SANITY_API_VERSION=
25 |
26 | # Sanity token to authenticate requests in "preview" mode
27 | # Only requires 'viewer' role
28 | # https://www.sanity.io/docs/http-auth
29 | SANITY_PREVIEW_TOKEN=
--------------------------------------------------------------------------------
/examples/embedded-studio/.eslintignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | bin
4 | *.d.ts
5 | dist
6 |
--------------------------------------------------------------------------------
/examples/embedded-studio/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import("@types/eslint").Linter.BaseConfig}
3 | */
4 | module.exports = {
5 | extends: [
6 | '@remix-run/eslint-config',
7 | 'plugin:hydrogen/recommended',
8 | 'plugin:hydrogen/typescript',
9 | ],
10 | rules: {
11 | '@typescript-eslint/ban-ts-comment': 'off',
12 | '@typescript-eslint/naming-convention': 'off',
13 | 'hydrogen/prefer-image-component': 'off',
14 | 'no-useless-escape': 'off',
15 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
16 | 'no-case-declarations': 'off',
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/examples/embedded-studio/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /.cache
3 | /build
4 | /dist
5 | /public/build
6 | /.mf
7 | .env
8 | .shopify
9 |
--------------------------------------------------------------------------------
/examples/embedded-studio/.graphqlrc.yml:
--------------------------------------------------------------------------------
1 | projects:
2 | default:
3 | schema: 'node_modules/@shopify/hydrogen/storefront.schema.json'
4 | documents:
5 | - '!*.d.ts'
6 | - '*.{ts,tsx,js,jsx}'
7 | - 'app/**/*.{ts,tsx,js,jsx}'
8 | - '!app/graphql/**/*.{ts,tsx,js,jsx}'
9 | customer-account:
10 | schema: 'node_modules/@shopify/hydrogen/customer-account.schema.json'
11 | documents:
12 | - 'app/graphql/customer-account/**/*.{ts,tsx,js,jsx}'
13 |
--------------------------------------------------------------------------------
/examples/embedded-studio/.npmrc:
--------------------------------------------------------------------------------
1 | @shopify:registry=https://registry.npmjs.com
2 | progress=false
3 |
4 | # Ensure Vite can optimize these deps in PNPM
5 | public-hoist-pattern[]=cookie
6 | public-hoist-pattern[]=set-cookie-parser
7 | public-hoist-pattern[]=content-security-policy-builder
8 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
29 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/components/Aside.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * A side bar component with Overlay that works without JavaScript.
3 | * @example
4 | * ```jsx
5 | *
9 | * ```
10 | */
11 | export function Aside({
12 | children,
13 | heading,
14 | id = 'aside',
15 | }: {
16 | children?: React.ReactNode;
17 | heading: React.ReactNode;
18 | id?: string;
19 | }) {
20 | return (
21 |
22 |
37 | );
38 | }
39 |
40 | function CloseAside() {
41 | return (
42 | /* eslint-disable-next-line jsx-a11y/anchor-is-valid */
43 | history.go(-1)}>
44 | ×
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import {NavLink} from '@remix-run/react';
2 | import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
3 | import {useRootLoaderData} from '~/lib/root-data';
4 |
5 | export function Footer({
6 | menu,
7 | shop,
8 | }: FooterQuery & {shop: HeaderQuery['shop']}) {
9 | return (
10 |
15 | );
16 | }
17 |
18 | function FooterMenu({
19 | menu,
20 | primaryDomainUrl,
21 | }: {
22 | menu: FooterQuery['menu'];
23 | primaryDomainUrl: HeaderQuery['shop']['primaryDomain']['url'];
24 | }) {
25 | const {publicStoreDomain} = useRootLoaderData();
26 |
27 | return (
28 |
56 | );
57 | }
58 |
59 | const FALLBACK_FOOTER_MENU = {
60 | id: 'gid://shopify/Menu/199655620664',
61 | items: [
62 | {
63 | id: 'gid://shopify/MenuItem/461633060920',
64 | resourceId: 'gid://shopify/ShopPolicy/23358046264',
65 | tags: [],
66 | title: 'Privacy Policy',
67 | type: 'SHOP_POLICY',
68 | url: '/policies/privacy-policy',
69 | items: [],
70 | },
71 | {
72 | id: 'gid://shopify/MenuItem/461633093688',
73 | resourceId: 'gid://shopify/ShopPolicy/23358013496',
74 | tags: [],
75 | title: 'Refund Policy',
76 | type: 'SHOP_POLICY',
77 | url: '/policies/refund-policy',
78 | items: [],
79 | },
80 | {
81 | id: 'gid://shopify/MenuItem/461633126456',
82 | resourceId: 'gid://shopify/ShopPolicy/23358111800',
83 | tags: [],
84 | title: 'Shipping Policy',
85 | type: 'SHOP_POLICY',
86 | url: '/policies/shipping-policy',
87 | items: [],
88 | },
89 | {
90 | id: 'gid://shopify/MenuItem/461633159224',
91 | resourceId: 'gid://shopify/ShopPolicy/23358079032',
92 | tags: [],
93 | title: 'Terms of Service',
94 | type: 'SHOP_POLICY',
95 | url: '/policies/terms-of-service',
96 | items: [],
97 | },
98 | ],
99 | };
100 |
101 | function activeLinkStyle({
102 | isActive,
103 | isPending,
104 | }: {
105 | isActive: boolean;
106 | isPending: boolean;
107 | }) {
108 | return {
109 | fontWeight: isActive ? 'bold' : undefined,
110 | color: isPending ? 'grey' : 'white',
111 | };
112 | }
113 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import {Await} from '@remix-run/react';
2 | import {Suspense} from 'react';
3 | import type {
4 | CartApiQueryFragment,
5 | FooterQuery,
6 | HeaderQuery,
7 | } from 'storefrontapi.generated';
8 | import {Aside} from '~/components/Aside';
9 | import {Footer} from '~/components/Footer';
10 | import {Header, HeaderMenu} from '~/components/Header';
11 | import {CartMain} from '~/components/Cart';
12 | import {
13 | PredictiveSearchForm,
14 | PredictiveSearchResults,
15 | } from '~/components/Search';
16 |
17 | export type LayoutProps = {
18 | cart: Promise;
19 | children?: React.ReactNode;
20 | footer: Promise;
21 | header: HeaderQuery;
22 | isLoggedIn: Promise;
23 | };
24 |
25 | export function Layout({
26 | cart,
27 | children = null,
28 | footer,
29 | header,
30 | isLoggedIn,
31 | }: LayoutProps) {
32 | return (
33 | <>
34 |
35 |
36 |
37 | {header && }
38 | {children}
39 |
40 |
41 | {(footer) => }
42 |
43 |
44 | >
45 | );
46 | }
47 |
48 | function CartAside({cart}: {cart: LayoutProps['cart']}) {
49 | return (
50 |
}>
52 |
53 | {(cart) => {
54 | return ;
55 | }}
56 |
57 |
58 |
59 | );
60 | }
61 |
62 | function SearchAside() {
63 | return (
64 |
94 | );
95 | }
96 |
97 | function MobileMenuAside({
98 | menu,
99 | shop,
100 | }: {
101 | menu: HeaderQuery['menu'];
102 | shop: HeaderQuery['shop'];
103 | }) {
104 | return (
105 | menu &&
106 | shop?.primaryDomain?.url && (
107 |
114 | )
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import {RemixBrowser} from '@remix-run/react';
2 | import {startTransition, StrictMode} from 'react';
3 | import {hydrateRoot} from 'react-dom/client';
4 |
5 | startTransition(() => {
6 | hydrateRoot(
7 | document,
8 |
9 |
10 | ,
11 | );
12 | });
13 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type {AppLoadContext, EntryContext} from '@shopify/remix-oxygen';
2 | import {RemixServer} from '@remix-run/react';
3 | import isbot from 'isbot';
4 | import {renderToReadableStream} from 'react-dom/server';
5 | import {createContentSecurityPolicy} from '@shopify/hydrogen';
6 |
7 | export default async function handleRequest(
8 | request: Request,
9 | responseStatusCode: number,
10 | responseHeaders: Headers,
11 | remixContext: EntryContext,
12 | loadContext: AppLoadContext,
13 | ) {
14 | const projectId = loadContext.env.PUBLIC_SANITY_PROJECT_ID;
15 |
16 | const {nonce, header, NonceProvider} = createContentSecurityPolicy({
17 | // Include Sanity domains in the CSP
18 | defaultSrc: ['https://cdn.sanity.io', 'https://lh3.googleusercontent.com'],
19 | connectSrc: [
20 | `https://${projectId}.api.sanity.io`,
21 | `wss://${projectId}.api.sanity.io`,
22 | ],
23 | frameAncestors: [`'self'`],
24 | });
25 |
26 | const body = await renderToReadableStream(
27 |
28 |
29 | ,
30 | {
31 | nonce,
32 | signal: request.signal,
33 | onError(error) {
34 | // eslint-disable-next-line no-console
35 | console.error(error);
36 | responseStatusCode = 500;
37 | },
38 | },
39 | );
40 |
41 | if (isbot(request.headers.get('user-agent'))) {
42 | await body.allReady;
43 | }
44 |
45 | responseHeaders.set('Content-Type', 'text/html');
46 | responseHeaders.set('Content-Security-Policy', header);
47 |
48 | return new Response(body, {
49 | headers: responseHeaders,
50 | status: responseStatusCode,
51 | });
52 | }
53 |
--------------------------------------------------------------------------------
/examples/embedded-studio/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 | ) {
8 | customerAddressUpdate(
9 | address: $address
10 | addressId: $addressId
11 | defaultAddress: $defaultAddress
12 | ) {
13 | customerAddress {
14 | id
15 | }
16 | userErrors {
17 | code
18 | field
19 | message
20 | }
21 | }
22 | }
23 | ` as const;
24 |
25 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressDelete
26 | export const DELETE_ADDRESS_MUTATION = `#graphql
27 | mutation customerAddressDelete(
28 | $addressId: ID!,
29 | ) {
30 | customerAddressDelete(addressId: $addressId) {
31 | deletedAddressId
32 | userErrors {
33 | code
34 | field
35 | message
36 | }
37 | }
38 | }
39 | ` as const;
40 |
41 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressCreate
42 | export const CREATE_ADDRESS_MUTATION = `#graphql
43 | mutation customerAddressCreate(
44 | $address: CustomerAddressInput!
45 | $defaultAddress: Boolean
46 | ) {
47 | customerAddressCreate(
48 | address: $address
49 | defaultAddress: $defaultAddress
50 | ) {
51 | customerAddress {
52 | id
53 | }
54 | userErrors {
55 | code
56 | field
57 | message
58 | }
59 | }
60 | }
61 | ` as const;
62 |
--------------------------------------------------------------------------------
/examples/embedded-studio/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 {
35 | customer {
36 | ...Customer
37 | }
38 | }
39 | ${CUSTOMER_FRAGMENT}
40 | ` as const;
41 |
--------------------------------------------------------------------------------
/examples/embedded-studio/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 | statusPageUrl
49 | processedAt
50 | fulfillments(first: 1) {
51 | nodes {
52 | status
53 | }
54 | }
55 | totalTax {
56 | ...OrderMoney
57 | }
58 | totalPrice {
59 | ...OrderMoney
60 | }
61 | subtotal {
62 | ...OrderMoney
63 | }
64 | shippingAddress {
65 | name
66 | formatted(withName: true)
67 | formattedArea
68 | }
69 | discountApplications(first: 100) {
70 | nodes {
71 | ...DiscountApplication
72 | }
73 | }
74 | lineItems(first: 100) {
75 | nodes {
76 | ...OrderLineItemFull
77 | }
78 | }
79 | }
80 | query Order($orderId: ID!) {
81 | order(id: $orderId) {
82 | ... on Order {
83 | ...Order
84 | }
85 | }
86 | }
87 | ` as const;
88 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/graphql/customer-account/CustomerOrdersQuery.ts:
--------------------------------------------------------------------------------
1 | // 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 | fulfillments(first: 1) {
10 | nodes {
11 | status
12 | }
13 | }
14 | id
15 | number
16 | processedAt
17 | }
18 | ` as const;
19 |
20 | // https://shopify.dev/docs/api/customer/latest/objects/Customer
21 | export const CUSTOMER_ORDERS_FRAGMENT = `#graphql
22 | fragment CustomerOrders on Customer {
23 | orders(
24 | sortKey: PROCESSED_AT,
25 | reverse: true,
26 | first: $first,
27 | last: $last,
28 | before: $startCursor,
29 | after: $endCursor
30 | ) {
31 | nodes {
32 | ...OrderItem
33 | }
34 | pageInfo {
35 | hasPreviousPage
36 | hasNextPage
37 | endCursor
38 | startCursor
39 | }
40 | }
41 | }
42 | ${ORDER_ITEM_FRAGMENT}
43 | ` as const;
44 |
45 | // https://shopify.dev/docs/api/customer/latest/queries/customer
46 | export const CUSTOMER_ORDERS_QUERY = `#graphql
47 | ${CUSTOMER_ORDERS_FRAGMENT}
48 | query CustomerOrders(
49 | $endCursor: String
50 | $first: Int
51 | $last: Int
52 | $startCursor: String
53 | ) {
54 | customer {
55 | ...CustomerOrders
56 | }
57 | }
58 | ` as const;
59 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/graphql/customer-account/CustomerUpdateMutation.ts:
--------------------------------------------------------------------------------
1 | export const CUSTOMER_UPDATE_MUTATION = `#graphql
2 | # https://shopify.dev/docs/api/customer/latest/mutations/customerUpdate
3 | mutation customerUpdate(
4 | $customer: CustomerUpdateInput!
5 | ){
6 | customerUpdate(input: $customer) {
7 | customer {
8 | firstName
9 | lastName
10 | emailAddress {
11 | emailAddress
12 | }
13 | phoneNumber {
14 | phoneNumber
15 | }
16 | }
17 | userErrors {
18 | code
19 | field
20 | message
21 | }
22 | }
23 | }
24 | ` as const;
25 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/lib/fragments.ts:
--------------------------------------------------------------------------------
1 | // NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
2 | export const CART_QUERY_FRAGMENT = `#graphql
3 | fragment Money on MoneyV2 {
4 | currencyCode
5 | amount
6 | }
7 | fragment CartLine on CartLine {
8 | id
9 | quantity
10 | attributes {
11 | key
12 | value
13 | }
14 | cost {
15 | totalAmount {
16 | ...Money
17 | }
18 | amountPerQuantity {
19 | ...Money
20 | }
21 | compareAtAmountPerQuantity {
22 | ...Money
23 | }
24 | }
25 | merchandise {
26 | ... on ProductVariant {
27 | id
28 | availableForSale
29 | compareAtPrice {
30 | ...Money
31 | }
32 | price {
33 | ...Money
34 | }
35 | requiresShipping
36 | title
37 | image {
38 | id
39 | url
40 | altText
41 | width
42 | height
43 |
44 | }
45 | product {
46 | handle
47 | title
48 | id
49 | vendor
50 | }
51 | selectedOptions {
52 | name
53 | value
54 | }
55 | }
56 | }
57 | }
58 | fragment CartApiQuery on Cart {
59 | updatedAt
60 | id
61 | checkoutUrl
62 | totalQuantity
63 | buyerIdentity {
64 | countryCode
65 | customer {
66 | id
67 | email
68 | firstName
69 | lastName
70 | displayName
71 | }
72 | email
73 | phone
74 | }
75 | lines(first: $numCartLines) {
76 | nodes {
77 | ...CartLine
78 | }
79 | }
80 | cost {
81 | subtotalAmount {
82 | ...Money
83 | }
84 | totalAmount {
85 | ...Money
86 | }
87 | totalDutyAmount {
88 | ...Money
89 | }
90 | totalTaxAmount {
91 | ...Money
92 | }
93 | }
94 | note
95 | attributes {
96 | key
97 | value
98 | }
99 | discountCodes {
100 | code
101 | applicable
102 | }
103 | }
104 | ` as const;
105 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/lib/root-data.ts:
--------------------------------------------------------------------------------
1 | import {useMatches} from '@remix-run/react';
2 | import type {SerializeFrom} from '@shopify/remix-oxygen';
3 | import type {loader} from '~/root';
4 |
5 | /**
6 | * Access the result of the root loader from a React component.
7 | */
8 | export function useRootLoaderData() {
9 | const [root] = useMatches();
10 | return root?.data as SerializeFrom;
11 | }
12 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/lib/search.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | PredictiveQueryFragment,
3 | SearchProductFragment,
4 | PredictiveProductFragment,
5 | PredictiveCollectionFragment,
6 | PredictivePageFragment,
7 | PredictiveArticleFragment,
8 | } from 'storefrontapi.generated';
9 |
10 | export function applyTrackingParams(
11 | resource:
12 | | PredictiveQueryFragment
13 | | SearchProductFragment
14 | | PredictiveProductFragment
15 | | PredictiveCollectionFragment
16 | | PredictiveArticleFragment
17 | | PredictivePageFragment,
18 | params?: string,
19 | ) {
20 | if (params) {
21 | return resource?.trackingParameters
22 | ? `?${params}&${resource.trackingParameters}`
23 | : `?${params}`;
24 | } else {
25 | return resource?.trackingParameters
26 | ? `?${resource.trackingParameters}`
27 | : '';
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/lib/session.ts:
--------------------------------------------------------------------------------
1 | import type {HydrogenSession} from '@shopify/hydrogen';
2 | import {
3 | createCookieSessionStorage,
4 | type SessionStorage,
5 | type Session,
6 | } from '@shopify/remix-oxygen';
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 | #sessionStorage;
15 | #session;
16 |
17 | constructor(sessionStorage: SessionStorage, session: Session) {
18 | this.#sessionStorage = sessionStorage;
19 | this.#session = session;
20 | }
21 |
22 | static async init(request: Request, secrets: string[]) {
23 | const storage = createCookieSessionStorage({
24 | cookie: {
25 | name: 'session',
26 | httpOnly: true,
27 | path: '/',
28 | sameSite: 'lax',
29 | secrets,
30 | },
31 | });
32 |
33 | const session = await storage
34 | .getSession(request.headers.get('Cookie'))
35 | .catch(() => storage.getSession());
36 |
37 | return new this(storage, session);
38 | }
39 |
40 | get has() {
41 | return this.#session.has;
42 | }
43 |
44 | get get() {
45 | return this.#session.get;
46 | }
47 |
48 | get flash() {
49 | return this.#session.flash;
50 | }
51 |
52 | get unset() {
53 | return this.#session.unset;
54 | }
55 |
56 | get set() {
57 | return this.#session.set;
58 | }
59 |
60 | destroy() {
61 | return this.#sessionStorage.destroySession(this.#session);
62 | }
63 |
64 | commit() {
65 | return this.#sessionStorage.commitSession(this.#session);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/lib/variants.ts:
--------------------------------------------------------------------------------
1 | import {useLocation} from '@remix-run/react';
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 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/$.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({request}: LoaderFunctionArgs) {
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 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/account.$.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | // fallback wild card for all unauthenticated routes in account section
4 | export async function loader({context}: LoaderFunctionArgs) {
5 | await context.customerAccount.handleAuthStatus();
6 |
7 | return redirect('/account', {
8 | headers: {
9 | 'Set-Cookie': await context.session.commit(),
10 | },
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/account._index.tsx:
--------------------------------------------------------------------------------
1 | import {redirect} from '@shopify/remix-oxygen';
2 |
3 | export async function loader() {
4 | return redirect('/account/orders');
5 | }
6 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/account.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Form, NavLink, Outlet, useLoaderData} from '@remix-run/react';
3 | import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery';
4 |
5 | export function shouldRevalidate() {
6 | return true;
7 | }
8 |
9 | export async function loader({context}: LoaderFunctionArgs) {
10 | const {data, errors} = await context.customerAccount.query(
11 | CUSTOMER_DETAILS_QUERY,
12 | );
13 |
14 | if (errors?.length || !data?.customer) {
15 | throw new Error('Customer not found');
16 | }
17 |
18 | return json(
19 | {customer: data.customer},
20 | {
21 | headers: {
22 | 'Cache-Control': 'no-cache, no-store, must-revalidate',
23 | 'Set-Cookie': await context.session.commit(),
24 | },
25 | },
26 | );
27 | }
28 |
29 | export default function AccountLayout() {
30 | const {customer} = useLoaderData();
31 |
32 | const heading = customer
33 | ? customer.firstName
34 | ? `Welcome, ${customer.firstName}`
35 | : `Welcome to your account.`
36 | : 'Account Details';
37 |
38 | return (
39 |
40 |
{heading}
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | function AccountMenu() {
51 | function isActiveStyle({
52 | isActive,
53 | isPending,
54 | }: {
55 | isActive: boolean;
56 | isPending: boolean;
57 | }) {
58 | return {
59 | fontWeight: isActive ? 'bold' : undefined,
60 | color: isPending ? 'grey' : 'black',
61 | };
62 | }
63 |
64 | return (
65 |
80 | );
81 | }
82 |
83 | function Logout() {
84 | return (
85 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/account_.authorize.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({context}: LoaderFunctionArgs) {
4 | return context.customerAccount.authorize();
5 | }
6 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/account_.login.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({request, context}: LoaderFunctionArgs) {
4 | return context.customerAccount.login();
5 | }
6 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/account_.logout.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type ActionFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | // if we dont implement this, /account/logout will get caught by account.$.tsx to do login
4 | export async function loader() {
5 | return redirect('/');
6 | }
7 |
8 | export async function action({context}: ActionFunctionArgs) {
9 | return context.customerAccount.logout();
10 | }
11 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/api.preview.tsx:
--------------------------------------------------------------------------------
1 | export {action, loader} from 'hydrogen-sanity/preview/route';
2 |
3 | // If you'd like errors to be handled by the root `ErrorBoundary`,
4 | // provide a default export.
5 | export default function Preview() {
6 | return null;
7 | }
8 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/blogs.$blogHandle.$articleHandle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {Image} from '@shopify/hydrogen';
4 |
5 | export const meta: MetaFunction = ({data}) => {
6 | return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}];
7 | };
8 |
9 | export async function loader({params, context}: LoaderFunctionArgs) {
10 | const {blogHandle, articleHandle} = params;
11 |
12 | if (!articleHandle || !blogHandle) {
13 | throw new Response('Not found', {status: 404});
14 | }
15 |
16 | const {blog} = await context.storefront.query(ARTICLE_QUERY, {
17 | variables: {blogHandle, articleHandle},
18 | });
19 |
20 | if (!blog?.articleByHandle) {
21 | throw new Response(null, {status: 404});
22 | }
23 |
24 | const article = blog.articleByHandle;
25 |
26 | return json({article});
27 | }
28 |
29 | export default function Article() {
30 | const {article} = useLoaderData();
31 | const {title, image, contentHtml, author} = article;
32 |
33 | const publishedDate = new Intl.DateTimeFormat('en-US', {
34 | year: 'numeric',
35 | month: 'long',
36 | day: 'numeric',
37 | }).format(new Date(article.publishedAt));
38 |
39 | return (
40 |
41 |
42 | {title}
43 |
44 | {publishedDate} · {author?.name}
45 |
46 |
47 |
48 | {image &&
}
49 |
53 |
54 | );
55 | }
56 |
57 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog#field-blog-articlebyhandle
58 | const ARTICLE_QUERY = `#graphql
59 | query Article(
60 | $articleHandle: String!
61 | $blogHandle: String!
62 | $country: CountryCode
63 | $language: LanguageCode
64 | ) @inContext(language: $language, country: $country) {
65 | blog(handle: $blogHandle) {
66 | articleByHandle(handle: $articleHandle) {
67 | title
68 | contentHtml
69 | publishedAt
70 | author: authorV2 {
71 | name
72 | }
73 | image {
74 | id
75 | altText
76 | url
77 | width
78 | height
79 | }
80 | seo {
81 | description
82 | title
83 | }
84 | }
85 | }
86 | }
87 | ` as const;
88 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/blogs._index.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {Pagination, getPaginationVariables} from '@shopify/hydrogen';
4 |
5 | export const meta: MetaFunction = () => {
6 | return [{title: `Hydrogen | Blogs`}];
7 | };
8 |
9 | export const loader = async ({
10 | request,
11 | context: {storefront},
12 | }: LoaderFunctionArgs) => {
13 | const paginationVariables = getPaginationVariables(request, {
14 | pageBy: 10,
15 | });
16 |
17 | const {blogs} = await storefront.query(BLOGS_QUERY, {
18 | variables: {
19 | ...paginationVariables,
20 | },
21 | });
22 |
23 | return json({blogs});
24 | };
25 |
26 | export default function Blogs() {
27 | const {blogs} = useLoaderData();
28 |
29 | return (
30 |
31 |
Blogs
32 |
33 |
34 | {({nodes, isLoading, PreviousLink, NextLink}) => {
35 | return (
36 | <>
37 |
38 | {isLoading ? 'Loading...' : ↑ Load previous}
39 |
40 | {nodes.map((blog) => {
41 | return (
42 |
48 | {blog.title}
49 |
50 | );
51 | })}
52 |
53 | {isLoading ? 'Loading...' : Load more ↓}
54 |
55 | >
56 | );
57 | }}
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
65 | const BLOGS_QUERY = `#graphql
66 | query Blogs(
67 | $country: CountryCode
68 | $endCursor: String
69 | $first: Int
70 | $language: LanguageCode
71 | $last: Int
72 | $startCursor: String
73 | ) @inContext(country: $country, language: $language) {
74 | blogs(
75 | first: $first,
76 | last: $last,
77 | before: $startCursor,
78 | after: $endCursor
79 | ) {
80 | pageInfo {
81 | hasNextPage
82 | hasPreviousPage
83 | startCursor
84 | endCursor
85 | }
86 | nodes {
87 | title
88 | handle
89 | seo {
90 | title
91 | description
92 | }
93 | }
94 | }
95 | }
96 | ` as const;
97 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/cart.$lines.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | /**
4 | * Automatically creates a new cart based on the URL and redirects straight to checkout.
5 | * Expected URL structure:
6 | * ```js
7 | * /cart/:
8 | *
9 | * ```
10 | *
11 | * More than one `:` separated by a comma, can be supplied in the URL, for
12 | * carts with more than one product variant.
13 | *
14 | * @example
15 | * Example path creating a cart with two product variants, different quantities, and a discount code in the querystring:
16 | * ```js
17 | * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
18 | *
19 | * ```
20 | */
21 | export async function loader({request, context, params}: LoaderFunctionArgs) {
22 | const {cart} = context;
23 | const {lines} = params;
24 | if (!lines) return redirect('/cart');
25 | const linesMap = lines.split(',').map((line) => {
26 | const lineDetails = line.split(':');
27 | const variantId = lineDetails[0];
28 | const quantity = parseInt(lineDetails[1], 10);
29 |
30 | return {
31 | merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
32 | quantity,
33 | };
34 | });
35 |
36 | const url = new URL(request.url);
37 | const searchParams = new URLSearchParams(url.search);
38 |
39 | const discount = searchParams.get('discount');
40 | const discountArray = discount ? [discount] : [];
41 |
42 | // create a cart
43 | const result = await cart.create({
44 | lines: linesMap,
45 | discountCodes: discountArray,
46 | });
47 |
48 | const cartResult = result.cart;
49 |
50 | if (result.errors?.length || !cartResult) {
51 | throw new Response('Link may be expired. Try checking the URL.', {
52 | status: 410,
53 | });
54 | }
55 |
56 | // Update cart id in cookie
57 | const headers = cart.setCartId(cartResult.id);
58 |
59 | // redirect to checkout
60 | if (cartResult.checkoutUrl) {
61 | return redirect(cartResult.checkoutUrl, {headers});
62 | } else {
63 | throw new Error('No checkout URL found');
64 | }
65 | }
66 |
67 | export default function Component() {
68 | return null;
69 | }
70 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/cart.tsx:
--------------------------------------------------------------------------------
1 | import {Await, type MetaFunction} from '@remix-run/react';
2 | import {Suspense} from 'react';
3 | import type {CartQueryDataReturn} from '@shopify/hydrogen';
4 | import {CartForm} from '@shopify/hydrogen';
5 | import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen';
6 | import {CartMain} from '~/components/Cart';
7 | import {useRootLoaderData} from '~/lib/root-data';
8 |
9 | export const meta: MetaFunction = () => {
10 | return [{title: `Hydrogen | Cart`}];
11 | };
12 |
13 | export async function action({request, context}: ActionFunctionArgs) {
14 | const {cart} = context;
15 |
16 | const formData = await request.formData();
17 |
18 | const {action, inputs} = CartForm.getFormInput(formData);
19 |
20 | if (!action) {
21 | throw new Error('No action provided');
22 | }
23 |
24 | let status = 200;
25 | let result: CartQueryDataReturn;
26 |
27 | switch (action) {
28 | case CartForm.ACTIONS.LinesAdd:
29 | result = await cart.addLines(inputs.lines);
30 | break;
31 | case CartForm.ACTIONS.LinesUpdate:
32 | result = await cart.updateLines(inputs.lines);
33 | break;
34 | case CartForm.ACTIONS.LinesRemove:
35 | result = await cart.removeLines(inputs.lineIds);
36 | break;
37 | case CartForm.ACTIONS.DiscountCodesUpdate: {
38 | const formDiscountCode = inputs.discountCode;
39 |
40 | // User inputted discount code
41 | const discountCodes = (
42 | formDiscountCode ? [formDiscountCode] : []
43 | ) as string[];
44 |
45 | // Combine discount codes already applied on cart
46 | discountCodes.push(...inputs.discountCodes);
47 |
48 | result = await cart.updateDiscountCodes(discountCodes);
49 | break;
50 | }
51 | case CartForm.ACTIONS.BuyerIdentityUpdate: {
52 | result = await cart.updateBuyerIdentity({
53 | ...inputs.buyerIdentity,
54 | });
55 | break;
56 | }
57 | default:
58 | throw new Error(`${action} cart action is not defined`);
59 | }
60 |
61 | const cartId = result.cart.id;
62 | const headers = cart.setCartId(result.cart.id);
63 | const {cart: cartResult, errors} = result;
64 |
65 | const redirectTo = formData.get('redirectTo') ?? null;
66 | if (typeof redirectTo === 'string') {
67 | status = 303;
68 | headers.set('Location', redirectTo);
69 | }
70 |
71 | headers.append('Set-Cookie', await context.session.commit());
72 |
73 | return json(
74 | {
75 | cart: cartResult,
76 | errors,
77 | analytics: {
78 | cartId,
79 | },
80 | },
81 | {status, headers},
82 | );
83 | }
84 |
85 | export default function Cart() {
86 | const rootData = useRootLoaderData();
87 | const cartPromise = rootData.cart;
88 |
89 | return (
90 |
91 |
Cart
92 |
Loading cart ...}>
93 | An error occurred }
96 | >
97 | {(cart) => {
98 | return ;
99 | }}
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/collections._index.tsx:
--------------------------------------------------------------------------------
1 | import {useLoaderData, Link} from '@remix-run/react';
2 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
3 | import {Pagination, getPaginationVariables, Image} from '@shopify/hydrogen';
4 | import type {CollectionFragment} from 'storefrontapi.generated';
5 |
6 | export async function loader({context, request}: LoaderFunctionArgs) {
7 | const paginationVariables = getPaginationVariables(request, {
8 | pageBy: 4,
9 | });
10 |
11 | const {collections} = await context.storefront.query(COLLECTIONS_QUERY, {
12 | variables: paginationVariables,
13 | });
14 |
15 | return json({collections});
16 | }
17 |
18 | export default function Collections() {
19 | const {collections} = useLoaderData();
20 |
21 | return (
22 |
23 |
Collections
24 |
25 | {({nodes, isLoading, PreviousLink, NextLink}) => (
26 |
27 |
28 | {isLoading ? 'Loading...' : ↑ Load previous}
29 |
30 |
31 |
32 | {isLoading ? 'Loading...' : Load more ↓}
33 |
34 |
35 | )}
36 |
37 |
38 | );
39 | }
40 |
41 | function CollectionsGrid({collections}: {collections: CollectionFragment[]}) {
42 | return (
43 |
44 | {collections.map((collection, index) => (
45 |
50 | ))}
51 |
52 | );
53 | }
54 |
55 | function CollectionItem({
56 | collection,
57 | index,
58 | }: {
59 | collection: CollectionFragment;
60 | index: number;
61 | }) {
62 | return (
63 |
69 | {collection?.image && (
70 |
76 | )}
77 | {collection.title}
78 |
79 | );
80 | }
81 |
82 | const COLLECTIONS_QUERY = `#graphql
83 | fragment Collection on Collection {
84 | id
85 | title
86 | handle
87 | image {
88 | id
89 | url
90 | altText
91 | width
92 | height
93 | }
94 | }
95 | query StoreCollections(
96 | $country: CountryCode
97 | $endCursor: String
98 | $first: Int
99 | $language: LanguageCode
100 | $last: Int
101 | $startCursor: String
102 | ) @inContext(country: $country, language: $language) {
103 | collections(
104 | first: $first,
105 | last: $last,
106 | before: $startCursor,
107 | after: $endCursor
108 | ) {
109 | nodes {
110 | ...Collection
111 | }
112 | pageInfo {
113 | hasNextPage
114 | hasPreviousPage
115 | startCursor
116 | endCursor
117 | }
118 | }
119 | }
120 | ` as const;
121 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/discount.$code.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | /**
4 | * Automatically applies a discount found on the url
5 | * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
6 | *
7 | * @example
8 | * Example path applying a discount and optional redirecting (defaults to the home page)
9 | * ```js
10 | * /discount/FREESHIPPING?redirect=/products
11 | *
12 | * ```
13 | */
14 | export async function loader({request, context, params}: LoaderFunctionArgs) {
15 | const {cart} = context;
16 | const {code} = params;
17 |
18 | const url = new URL(request.url);
19 | const searchParams = new URLSearchParams(url.search);
20 | let redirectParam =
21 | searchParams.get('redirect') || searchParams.get('return_to') || '/';
22 |
23 | if (redirectParam.includes('//')) {
24 | // Avoid redirecting to external URLs to prevent phishing attacks
25 | redirectParam = '/';
26 | }
27 |
28 | searchParams.delete('redirect');
29 | searchParams.delete('return_to');
30 |
31 | const redirectUrl = `${redirectParam}?${searchParams}`;
32 |
33 | if (!code) {
34 | return redirect(redirectUrl);
35 | }
36 |
37 | const result = await cart.updateDiscountCodes([code]);
38 | const headers = cart.setCartId(result.cart.id);
39 |
40 | // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
41 | // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
42 | // on localhost:3000
43 | return redirect(redirectUrl, {
44 | status: 303,
45 | headers,
46 | });
47 | }
48 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/pages.$handle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, type MetaFunction} from '@remix-run/react';
3 |
4 | export const meta: MetaFunction = ({data}) => {
5 | return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
6 | };
7 |
8 | export async function loader({params, context}: LoaderFunctionArgs) {
9 | if (!params.handle) {
10 | throw new Error('Missing page handle');
11 | }
12 |
13 | const {page} = await context.storefront.query(PAGE_QUERY, {
14 | variables: {
15 | handle: params.handle,
16 | },
17 | });
18 |
19 | if (!page) {
20 | throw new Response('Not Found', {status: 404});
21 | }
22 |
23 | return json({page});
24 | }
25 |
26 | export default function Page() {
27 | const {page} = useLoaderData();
28 |
29 | return (
30 |
31 |
34 |
35 |
36 | );
37 | }
38 |
39 | const PAGE_QUERY = `#graphql
40 | query Page(
41 | $language: LanguageCode,
42 | $country: CountryCode,
43 | $handle: String!
44 | )
45 | @inContext(language: $language, country: $country) {
46 | page(handle: $handle) {
47 | id
48 | title
49 | body
50 | seo {
51 | description
52 | title
53 | }
54 | }
55 | }
56 | ` as const;
57 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/policies.$handle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {type Shop} from '@shopify/hydrogen/storefront-api-types';
4 |
5 | type SelectedPolicies = keyof Pick<
6 | Shop,
7 | 'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy'
8 | >;
9 |
10 | export const meta: MetaFunction = ({data}) => {
11 | return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}];
12 | };
13 |
14 | export async function loader({params, context}: LoaderFunctionArgs) {
15 | if (!params.handle) {
16 | throw new Response('No handle was passed in', {status: 404});
17 | }
18 |
19 | const policyName = params.handle.replace(
20 | /-([a-z])/g,
21 | (_: unknown, m1: string) => m1.toUpperCase(),
22 | ) as SelectedPolicies;
23 |
24 | const data = await context.storefront.query(POLICY_CONTENT_QUERY, {
25 | variables: {
26 | privacyPolicy: false,
27 | shippingPolicy: false,
28 | termsOfService: false,
29 | refundPolicy: false,
30 | [policyName]: true,
31 | language: context.storefront.i18n?.language,
32 | },
33 | });
34 |
35 | const policy = data.shop?.[policyName];
36 |
37 | if (!policy) {
38 | throw new Response('Could not find the policy', {status: 404});
39 | }
40 |
41 | return json({policy});
42 | }
43 |
44 | export default function Policy() {
45 | const {policy} = useLoaderData();
46 |
47 | return (
48 |
49 |
50 |
51 |
52 | ← Back to Policies
53 |
54 |
55 |
{policy.title}
56 |
57 |
58 | );
59 | }
60 |
61 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Shop
62 | const POLICY_CONTENT_QUERY = `#graphql
63 | fragment Policy on ShopPolicy {
64 | body
65 | handle
66 | id
67 | title
68 | url
69 | }
70 | query Policy(
71 | $country: CountryCode
72 | $language: LanguageCode
73 | $privacyPolicy: Boolean!
74 | $refundPolicy: Boolean!
75 | $shippingPolicy: Boolean!
76 | $termsOfService: Boolean!
77 | ) @inContext(language: $language, country: $country) {
78 | shop {
79 | privacyPolicy @include(if: $privacyPolicy) {
80 | ...Policy
81 | }
82 | shippingPolicy @include(if: $shippingPolicy) {
83 | ...Policy
84 | }
85 | termsOfService @include(if: $termsOfService) {
86 | ...Policy
87 | }
88 | refundPolicy @include(if: $refundPolicy) {
89 | ...Policy
90 | }
91 | }
92 | }
93 | ` as const;
94 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/policies._index.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, Link} from '@remix-run/react';
3 |
4 | export async function loader({context}: LoaderFunctionArgs) {
5 | const data = await context.storefront.query(POLICIES_QUERY);
6 | const policies = Object.values(data.shop || {});
7 |
8 | if (!policies.length) {
9 | throw new Response('No policies found', {status: 404});
10 | }
11 |
12 | return json({policies});
13 | }
14 |
15 | export default function Policies() {
16 | const {policies} = useLoaderData();
17 |
18 | return (
19 |
20 |
Policies
21 |
22 | {policies.map((policy) => {
23 | if (!policy) return null;
24 | return (
25 |
28 | );
29 | })}
30 |
31 |
32 | );
33 | }
34 |
35 | const POLICIES_QUERY = `#graphql
36 | fragment PolicyItem on ShopPolicy {
37 | id
38 | title
39 | handle
40 | }
41 | query Policies ($country: CountryCode, $language: LanguageCode)
42 | @inContext(country: $country, language: $language) {
43 | shop {
44 | privacyPolicy {
45 | ...PolicyItem
46 | }
47 | shippingPolicy {
48 | ...PolicyItem
49 | }
50 | termsOfService {
51 | ...PolicyItem
52 | }
53 | refundPolicy {
54 | ...PolicyItem
55 | }
56 | subscriptionPolicy {
57 | id
58 | title
59 | handle
60 | }
61 | }
62 | }
63 | ` as const;
64 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/studio.$/ClientOnly.tsx:
--------------------------------------------------------------------------------
1 | // https://github.com/sergiodxa/remix-utils/blob/main/src/react/client-only.tsx
2 | import {useHydrated} from './useHydrated';
3 | import type {ReactNode} from 'react';
4 |
5 | type Props = {
6 | /**
7 | * You are encouraged to add a fallback that is the same dimensions
8 | * as the client rendered children. This will avoid content layout
9 | * shift which is disgusting
10 | */
11 | children(): ReactNode;
12 | fallback?: ReactNode;
13 | };
14 |
15 | /**
16 | * Render the children only after the JS has loaded client-side. Use an optional
17 | * fallback component if the JS is not yet loaded.
18 | *
19 | * Example: Render a Chart component if JS loads, renders a simple FakeChart
20 | * component server-side or if there is no JS. The FakeChart can have only the
21 | * UI without the behavior or be a loading spinner or skeleton.
22 | * ```tsx
23 | * return (
24 | * }>
25 | * {() => }
26 | *
27 | * );
28 | * ```
29 | */
30 | export function ClientOnly({children, fallback = null}: Props) {
31 | return useHydrated() ? <>{children()}> : <>{fallback}>;
32 | }
33 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/studio.$/SanityStudio.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * To keep the worker bundle size small, only load
3 | * the Studio and its configuration in the client
4 | */
5 | import {type StudioProps, Studio, type SingleWorkspace} from 'sanity';
6 | import {defineSanityConfig} from '~/sanity/config';
7 |
8 | /**
9 | * Prevent a consumer from importing into a worker/server bundle.
10 | */
11 | if (typeof document === 'undefined') {
12 | throw new Error(
13 | 'Sanity Studio can only run in the browser. Please check that this file is not being imported into a worker or server bundle.',
14 | );
15 | }
16 |
17 | type SanityStudioProps = Omit &
18 | Pick;
19 |
20 | function SanityStudio(props: SanityStudioProps) {
21 | const {projectId, dataset, basePath, ...rest} = props;
22 |
23 | const config = defineSanityConfig({
24 | projectId,
25 | dataset,
26 | basePath,
27 | });
28 |
29 | return (
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | // `React.lazy` expects the component as the default export
37 | // @see https://react.dev/reference/react/lazy
38 | export default SanityStudio;
39 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/studio.$/route.tsx:
--------------------------------------------------------------------------------
1 | import {useLoaderData} from '@remix-run/react';
2 | import {
3 | type MetaFunction,
4 | type LinksFunction,
5 | type LoaderFunction,
6 | type SerializeFrom,
7 | json,
8 | } from '@shopify/remix-oxygen';
9 | import {lazy, type ReactElement, Suspense} from 'react';
10 | import studioStyles from './studio.css?url';
11 | import {ClientOnly} from './ClientOnly';
12 |
13 | /**
14 | * Provide a consistent fallback to prevent hydration mismatch errors.
15 | */
16 | function SanityStudioFallback(): ReactElement {
17 | return <>>;
18 | }
19 |
20 | /**
21 | * If server-side rendering, then return the fallback instead of the heavy dependency.
22 | * @see https://remix.run/docs/en/1.14.3/guides/constraints#browser-only-code-on-the-server
23 | */
24 | const SanityStudio =
25 | typeof document === 'undefined'
26 | ? SanityStudioFallback
27 | : lazy(
28 | () =>
29 | /**
30 | * `lazy` expects the component as the default export
31 | * @see https://react.dev/reference/react/lazy
32 | */
33 | import('./SanityStudio.client'),
34 | );
35 |
36 | export const meta: MetaFunction = () => [
37 | {
38 | name: 'viewport',
39 | content: 'width=device-width,initial-scale=1,viewport-fit=cover',
40 | },
41 | {
42 | name: 'referrer',
43 | content: 'same-origin',
44 | },
45 | {
46 | name: 'robots',
47 | content: 'noindex',
48 | },
49 | ];
50 |
51 | /**
52 | * (Optional) Prevent Studio from being cached
53 | */
54 | export function headers(): HeadersInit {
55 | return {
56 | 'Cache-Control': 'no-store',
57 | };
58 | }
59 |
60 | export const links: LinksFunction = () => {
61 | return [{rel: 'stylesheet', href: studioStyles}];
62 | };
63 |
64 | export const loader: LoaderFunction = ({context}) => {
65 | const {env} = context;
66 | const projectId = env.PUBLIC_SANITY_PROJECT_ID;
67 | if (!projectId) {
68 | throw new Error('PUBLIC_SANITY_PROJECT_ID environment variable is not set');
69 | }
70 |
71 | const dataset = env.PUBLIC_SANITY_DATASET;
72 | if (!dataset) {
73 | throw new Error('PUBLIC_SANITY_DATASET environment variable is not set');
74 | }
75 |
76 | return json({
77 | projectId,
78 | dataset,
79 | basePath: '/studio',
80 | });
81 | };
82 |
83 | export default function Studio() {
84 | // @ts-expect-error
85 | const {projectId, dataset, basePath} =
86 | useLoaderData>();
87 |
88 | return (
89 |
90 | {() => (
91 |
92 |
97 |
98 | )}
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/studio.$/studio.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | }
4 |
5 | #sanity {
6 | height: 100vh;
7 | max-height: 100dvh;
8 | overscroll-behavior: none;
9 | -webkit-font-smoothing: antialiased;
10 | overflow: hidden;
11 | }
12 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/routes/studio.$/useHydrated.tsx:
--------------------------------------------------------------------------------
1 | // https://raw.githubusercontent.com/sergiodxa/remix-utils/main/src/react/use-hydrated.ts
2 | import {useSyncExternalStore} from 'react';
3 |
4 | function subscribe() {
5 | return () => {};
6 | }
7 |
8 | /**
9 | * Return a boolean indicating if the JS has been hydrated already.
10 | * When doing Server-Side Rendering, the result will always be false.
11 | * When doing Client-Side Rendering, the result will always be false on the
12 | * first render and true from then on. Even if a new component renders it will
13 | * always start with true.
14 | *
15 | * Example: Disable a button that needs JS to work.
16 | * ```tsx
17 | * let hydrated = useHydrated();
18 | * return (
19 | *
22 | * );
23 | * ```
24 | */
25 | export function useHydrated() {
26 | return useSyncExternalStore(
27 | subscribe,
28 | () => true,
29 | () => false,
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/sanity/config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig, type SingleWorkspace} from 'sanity';
2 | import {structureTool} from 'sanity/structure';
3 | import {presentationTool} from 'sanity/presentation';
4 | import {schema} from './schema';
5 |
6 | /**
7 | * Configuration options that will be passed in
8 | * from the environment or application
9 | */
10 | type SanityConfig = Pick<
11 | SingleWorkspace,
12 | 'projectId' | 'dataset' | 'title' | 'basePath'
13 | >;
14 |
15 | /**
16 | * Prevent a consumer from importing into a worker/server bundle.
17 | */
18 | if (typeof document === 'undefined') {
19 | throw new Error(
20 | 'Sanity Studio can only run in the browser. Please check that this file is not being imported into a worker or server bundle.',
21 | );
22 | }
23 |
24 | /**
25 | * Wrap whatever Sanity Studio configuration your project requires.
26 | *
27 | * In this example, it's a single workspace but adjust as necessary.
28 | */
29 | export function defineSanityConfig(config: SanityConfig) {
30 | return defineConfig({
31 | ...config,
32 | plugins: [
33 | presentationTool({
34 | previewUrl: {
35 | previewMode: {enable: '/api/preview'},
36 | },
37 | }),
38 | structureTool(),
39 | ],
40 | schema,
41 | });
42 | }
43 |
--------------------------------------------------------------------------------
/examples/embedded-studio/app/sanity/schema/index.ts:
--------------------------------------------------------------------------------
1 | import type {SchemaPluginOptions} from 'sanity';
2 |
3 | export const schema: SchemaPluginOptions = {
4 | types: [],
5 | };
6 |
--------------------------------------------------------------------------------
/examples/embedded-studio/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 |
--------------------------------------------------------------------------------
/examples/embedded-studio/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // Enhance TypeScript's built-in typings.
6 | import '@total-typescript/ts-reset';
7 |
8 | import type {
9 | Storefront,
10 | CustomerAccount,
11 | HydrogenCart,
12 | HydrogenSessionData,
13 | } from '@shopify/hydrogen';
14 | import type {AppSession} from '~/lib/session';
15 |
16 | declare global {
17 | /**
18 | * A global `process` object is only available during build to access NODE_ENV.
19 | */
20 | const process: {env: {NODE_ENV: 'production' | 'development'}};
21 |
22 | /**
23 | * Declare expected Env parameter in fetch handler.
24 | */
25 | interface Env {
26 | SESSION_SECRET: string;
27 | PUBLIC_STOREFRONT_API_TOKEN: string;
28 | PRIVATE_STOREFRONT_API_TOKEN: string;
29 | PUBLIC_STORE_DOMAIN: string;
30 | PUBLIC_STOREFRONT_ID: string;
31 | PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string;
32 | PUBLIC_CUSTOMER_ACCOUNT_API_URL: string;
33 |
34 | PUBLIC_SANITY_PROJECT_ID: string;
35 | PUBLIC_SANITY_DATASET?: string;
36 | PUBLIC_SANITY_API_VERSION?: string;
37 | SANITY_PREVIEW_TOKEN: string;
38 | }
39 | }
40 |
41 | declare module '@shopify/remix-oxygen' {
42 | /**
43 | * Declare local additions to the Remix loader context.
44 | */
45 | interface AppLoadContext {
46 | env: Env;
47 | cart: HydrogenCart;
48 | storefront: Storefront;
49 | customerAccount: CustomerAccount;
50 | session: AppSession;
51 | waitUntil: ExecutionContext['waitUntil'];
52 | }
53 |
54 | /**
55 | * Declare local additions to the Remix session data.
56 | */
57 | interface SessionData extends HydrogenSessionData {}
58 | }
59 |
--------------------------------------------------------------------------------
/examples/embedded-studio/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "embedded-studio",
3 | "private": true,
4 | "sideEffects": false,
5 | "version": "2024.4.4",
6 | "type": "module",
7 | "scripts": {
8 | "build": "shopify hydrogen build --codegen",
9 | "dev": "shopify hydrogen dev --codegen",
10 | "preview": "npm run build && shopify hydrogen preview",
11 | "lint": "eslint --cache --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
12 | "typecheck": "tsc --noEmit",
13 | "codegen": "shopify hydrogen codegen"
14 | },
15 | "prettier": "@shopify/prettier-config",
16 | "dependencies": {
17 | "@remix-run/react": "^2.8.0",
18 | "@remix-run/server-runtime": "^2.8.0",
19 | "@shopify/cli": "3.59.2",
20 | "@shopify/cli-hydrogen": "^8.0.4",
21 | "@shopify/hydrogen": "2024.4.2",
22 | "@shopify/remix-oxygen": "^2.0.4",
23 | "graphql": "^16.6.0",
24 | "graphql-tag": "^2.12.6",
25 | "hydrogen-sanity": "^4.0.5",
26 | "isbot": "^3.8.0",
27 | "react": "^18.2.0",
28 | "react-dom": "^18.2.0",
29 | "sanity": "^3.52.4"
30 | },
31 | "devDependencies": {
32 | "@graphql-codegen/cli": "5.0.2",
33 | "@remix-run/dev": "^2.8.0",
34 | "@remix-run/eslint-config": "^2.8.0",
35 | "@shopify/hydrogen-codegen": "^0.3.1",
36 | "@shopify/mini-oxygen": "^3.0.2",
37 | "@shopify/oxygen-workers-types": "^4.0.0",
38 | "@shopify/prettier-config": "^1.1.2",
39 | "@total-typescript/ts-reset": "^0.4.2",
40 | "@types/eslint": "^8.4.10",
41 | "@types/react": "^18.2.22",
42 | "@types/react-dom": "^18.2.7",
43 | "eslint": "^8.20.0",
44 | "eslint-plugin-hydrogen": "0.12.2",
45 | "prettier": "^2.8.4",
46 | "typescript": "^5.2.2",
47 | "vite": "^5.1.0",
48 | "vite-tsconfig-paths": "^4.3.1"
49 | },
50 | "engines": {
51 | "node": ">=18.0.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/examples/embedded-studio/public/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/hydrogen-sanity/b6d75fb49e23ea5646e3fd2b6c72001de9ff592c/examples/embedded-studio/public/.gitkeep
--------------------------------------------------------------------------------
/examples/embedded-studio/sanity.cli.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file is used by the Sanity CLI to run commands on the project.
3 | * @example `sanity dataset list`
4 | *
5 | * @see https://www.sanity.io/docs/cli
6 | *
7 | * NOTE: Sanity CLI will load environment variables
8 | */
9 | import {defineCliConfig} from 'sanity/cli';
10 |
11 | export default defineCliConfig({
12 | api: {
13 | // @ts-expect-error
14 | projectId: process.env.PUBLIC_SANITY_PROJECT_ID,
15 | // @ts-expect-error
16 | dataset: process.env.PUBLIC_SANITY_DATASET,
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/examples/embedded-studio/sanity.config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file is used by the Sanity CLI to load the project configuration.
3 | * @example `sanity schema extract`
4 | *
5 | * @see https://www.sanity.io/docs/cli
6 | *
7 | * NOTE: Sanity CLI will load environment variables
8 | */
9 | import {defineSanityConfig} from '~/sanity/config';
10 |
11 | export default defineSanityConfig({
12 | // @ts-expect-error
13 | projectId: process.env.PUBLIC_SANITY_PROJECT_ID,
14 | // @ts-expect-error
15 | dataset: process.env.PUBLIC_SANITY_DATASET,
16 | });
17 |
--------------------------------------------------------------------------------
/examples/embedded-studio/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "Bundler",
9 | "resolveJsonModule": true,
10 | "module": "ES2022",
11 | "target": "ES2022",
12 | "strict": true,
13 | "allowJs": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "skipLibCheck": true,
16 | "baseUrl": ".",
17 | "types": ["@shopify/oxygen-workers-types"],
18 | "paths": {
19 | "~/*": ["app/*"]
20 | },
21 | "noEmit": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/examples/embedded-studio/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 {vitePlugin as remix} from '@remix-run/dev';
5 | import tsconfigPaths from 'vite-tsconfig-paths';
6 |
7 | export default defineConfig({
8 | plugins: [
9 | hydrogen(),
10 | oxygen(),
11 | remix({
12 | presets: [hydrogen.preset()],
13 | future: {
14 | v3_fetcherPersist: true,
15 | v3_relativeSplatPath: true,
16 | v3_throwAbortReason: true,
17 | },
18 | }),
19 | tsconfigPaths(),
20 | ],
21 | build: {
22 | // Allow a strict Content-Security-Policy
23 | // withtout inlining assets as base64:
24 | assetsInlineLimit: 0,
25 | },
26 | });
27 |
--------------------------------------------------------------------------------
/examples/remix-storefront/.env.example:
--------------------------------------------------------------------------------
1 | # These variables are only available locally in MiniOxygen
2 | # https://shopify.dev/docs/custom-storefronts/hydrogen/environment-variables
3 |
4 | # Shopify environment variables...
5 | SESSION_SECRET=
6 | PUBLIC_STOREFRONT_ID=
7 | PUBLIC_STOREFRONT_API_TOKEN=
8 | PUBLIC_STORE_DOMAIN=
9 | PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID=
10 | PUBLIC_CUSTOMER_ACCOUNT_API_URL=
11 | PRIVATE_STOREFRONT_API_TOKEN=
12 |
13 | # Sanity environment variables...
14 |
15 | # Project ID
16 | PUBLIC_SANITY_PROJECT_ID=
17 |
18 | # (Optional) Dataset name
19 | # Defaults to `production`
20 | # PUBLIC_SANITY_DATASET=
21 |
22 | # (Optional) Sanity API version
23 | # Defaults to `v2022-03-07`
24 | # PUBLIC_SANITY_API_VERSION=
25 |
26 | # Sanity token to authenticate requests in "preview" mode
27 | # Only requires 'viewer' role
28 | # https://www.sanity.io/docs/http-auth
29 | SANITY_PREVIEW_TOKEN=
--------------------------------------------------------------------------------
/examples/remix-storefront/.eslintignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | bin
4 | *.d.ts
5 | dist
6 |
--------------------------------------------------------------------------------
/examples/remix-storefront/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import("@types/eslint").Linter.BaseConfig}
3 | */
4 | module.exports = {
5 | extends: [
6 | '@remix-run/eslint-config',
7 | 'plugin:hydrogen/recommended',
8 | 'plugin:hydrogen/typescript',
9 | ],
10 | rules: {
11 | '@typescript-eslint/ban-ts-comment': 'off',
12 | '@typescript-eslint/naming-convention': 'off',
13 | 'hydrogen/prefer-image-component': 'off',
14 | 'no-useless-escape': 'off',
15 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
16 | 'no-case-declarations': 'off',
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/examples/remix-storefront/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /.cache
3 | /build
4 | /dist
5 | /public/build
6 | /.mf
7 | .env
8 | .shopify
9 |
--------------------------------------------------------------------------------
/examples/remix-storefront/.graphqlrc.yml:
--------------------------------------------------------------------------------
1 | projects:
2 | default:
3 | schema: 'node_modules/@shopify/hydrogen/storefront.schema.json'
4 | documents:
5 | - '!*.d.ts'
6 | - '*.{ts,tsx,js,jsx}'
7 | - 'app/**/*.{ts,tsx,js,jsx}'
8 | - '!app/graphql/**/*.{ts,tsx,js,jsx}'
9 | customer-account:
10 | schema: 'node_modules/@shopify/hydrogen/customer-account.schema.json'
11 | documents:
12 | - 'app/graphql/customer-account/**/*.{ts,tsx,js,jsx}'
13 |
--------------------------------------------------------------------------------
/examples/remix-storefront/.npmrc:
--------------------------------------------------------------------------------
1 | @shopify:registry=https://registry.npmjs.com
2 | progress=false
3 |
4 | # Ensure Vite can optimize these deps in PNPM
5 | public-hoist-pattern[]=cookie
6 | public-hoist-pattern[]=set-cookie-parser
7 | public-hoist-pattern[]=content-security-policy-builder
8 |
--------------------------------------------------------------------------------
/examples/remix-storefront/README.md:
--------------------------------------------------------------------------------
1 | # Hydrogen template: Classic Remix
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 and the [Classic Remix Compiler](https://remix.run/docs/en/main/future/vite#classic-remix-compiler-vs-remix-vite) (i.e. the compiler that uses ESBuild via `@remix-run/dev`, used before Remix Vite).
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 | - Shopify CLI
14 | - ESLint
15 | - Prettier
16 | - GraphQL generator
17 | - TypeScript and JavaScript flavors
18 | - Minimal setup of components and routes
19 |
20 | ## Getting started
21 |
22 | **Requirements:**
23 |
24 | - Node.js version 18.0.0 or higher
25 |
26 | ```sh
27 | npm create @shopify/hydrogen@latest -- --template classic-remix
28 | ```
29 |
30 | ## Building for production
31 |
32 | ```sh
33 | npm run build
34 | ```
35 |
36 | ## Local development
37 |
38 | ```sh
39 | npm run dev
40 | ```
41 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
29 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/components/Aside.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * A side bar component with Overlay that works without JavaScript.
3 | * @example
4 | * ```jsx
5 | *
9 | * ```
10 | */
11 | export function Aside({
12 | children,
13 | heading,
14 | id = 'aside',
15 | }: {
16 | children?: React.ReactNode;
17 | heading: React.ReactNode;
18 | id?: string;
19 | }) {
20 | return (
21 |
22 |
37 | );
38 | }
39 |
40 | function CloseAside() {
41 | return (
42 | /* eslint-disable-next-line jsx-a11y/anchor-is-valid */
43 | history.go(-1)}>
44 | ×
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import {NavLink} from '@remix-run/react';
2 | import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
3 | import {useRootLoaderData} from '~/lib/root-data';
4 |
5 | export function Footer({
6 | menu,
7 | shop,
8 | }: FooterQuery & {shop: HeaderQuery['shop']}) {
9 | return (
10 |
15 | );
16 | }
17 |
18 | function FooterMenu({
19 | menu,
20 | primaryDomainUrl,
21 | }: {
22 | menu: FooterQuery['menu'];
23 | primaryDomainUrl: HeaderQuery['shop']['primaryDomain']['url'];
24 | }) {
25 | const {publicStoreDomain} = useRootLoaderData();
26 |
27 | return (
28 |
56 | );
57 | }
58 |
59 | const FALLBACK_FOOTER_MENU = {
60 | id: 'gid://shopify/Menu/199655620664',
61 | items: [
62 | {
63 | id: 'gid://shopify/MenuItem/461633060920',
64 | resourceId: 'gid://shopify/ShopPolicy/23358046264',
65 | tags: [],
66 | title: 'Privacy Policy',
67 | type: 'SHOP_POLICY',
68 | url: '/policies/privacy-policy',
69 | items: [],
70 | },
71 | {
72 | id: 'gid://shopify/MenuItem/461633093688',
73 | resourceId: 'gid://shopify/ShopPolicy/23358013496',
74 | tags: [],
75 | title: 'Refund Policy',
76 | type: 'SHOP_POLICY',
77 | url: '/policies/refund-policy',
78 | items: [],
79 | },
80 | {
81 | id: 'gid://shopify/MenuItem/461633126456',
82 | resourceId: 'gid://shopify/ShopPolicy/23358111800',
83 | tags: [],
84 | title: 'Shipping Policy',
85 | type: 'SHOP_POLICY',
86 | url: '/policies/shipping-policy',
87 | items: [],
88 | },
89 | {
90 | id: 'gid://shopify/MenuItem/461633159224',
91 | resourceId: 'gid://shopify/ShopPolicy/23358079032',
92 | tags: [],
93 | title: 'Terms of Service',
94 | type: 'SHOP_POLICY',
95 | url: '/policies/terms-of-service',
96 | items: [],
97 | },
98 | ],
99 | };
100 |
101 | function activeLinkStyle({
102 | isActive,
103 | isPending,
104 | }: {
105 | isActive: boolean;
106 | isPending: boolean;
107 | }) {
108 | return {
109 | fontWeight: isActive ? 'bold' : undefined,
110 | color: isPending ? 'grey' : 'white',
111 | };
112 | }
113 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import {Await} from '@remix-run/react';
2 | import {Suspense} from 'react';
3 | import type {
4 | CartApiQueryFragment,
5 | FooterQuery,
6 | HeaderQuery,
7 | } from 'storefrontapi.generated';
8 | import {Aside} from '~/components/Aside';
9 | import {Footer} from '~/components/Footer';
10 | import {Header, HeaderMenu} from '~/components/Header';
11 | import {CartMain} from '~/components/Cart';
12 | import {
13 | PredictiveSearchForm,
14 | PredictiveSearchResults,
15 | } from '~/components/Search';
16 |
17 | export type LayoutProps = {
18 | cart: Promise;
19 | children?: React.ReactNode;
20 | footer: Promise;
21 | header: HeaderQuery;
22 | isLoggedIn: Promise;
23 | };
24 |
25 | export function Layout({
26 | cart,
27 | children = null,
28 | footer,
29 | header,
30 | isLoggedIn,
31 | }: LayoutProps) {
32 | return (
33 | <>
34 |
35 |
36 |
37 | {header && }
38 | {children}
39 |
40 |
41 | {(footer) => }
42 |
43 |
44 | >
45 | );
46 | }
47 |
48 | function CartAside({cart}: {cart: LayoutProps['cart']}) {
49 | return (
50 |
59 | );
60 | }
61 |
62 | function SearchAside() {
63 | return (
64 |
94 | );
95 | }
96 |
97 | function MobileMenuAside({
98 | menu,
99 | shop,
100 | }: {
101 | menu: HeaderQuery['menu'];
102 | shop: HeaderQuery['shop'];
103 | }) {
104 | return (
105 | menu &&
106 | shop?.primaryDomain?.url && (
107 |
114 | )
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import {RemixBrowser} from '@remix-run/react';
2 | import {startTransition, StrictMode} from 'react';
3 | import {hydrateRoot} from 'react-dom/client';
4 |
5 | startTransition(() => {
6 | hydrateRoot(
7 | document,
8 |
9 |
10 | ,
11 | );
12 | });
13 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type {EntryContext} from '@shopify/remix-oxygen';
2 | import {RemixServer} from '@remix-run/react';
3 | import isbot from 'isbot';
4 | import {renderToReadableStream} from 'react-dom/server';
5 | import {createContentSecurityPolicy} from '@shopify/hydrogen';
6 |
7 | export default async function handleRequest(
8 | request: Request,
9 | responseStatusCode: number,
10 | responseHeaders: Headers,
11 | remixContext: EntryContext,
12 | ) {
13 | const {nonce, header, NonceProvider} = createContentSecurityPolicy({
14 | // Include Sanity domains in the CSP
15 | defaultSrc: ['https://cdn.sanity.io'],
16 | });
17 |
18 | const body = await renderToReadableStream(
19 |
20 |
21 | ,
22 | {
23 | nonce,
24 | signal: request.signal,
25 | onError(error) {
26 | // eslint-disable-next-line no-console
27 | console.error(error);
28 | responseStatusCode = 500;
29 | },
30 | },
31 | );
32 |
33 | if (isbot(request.headers.get('user-agent'))) {
34 | await body.allReady;
35 | }
36 |
37 | responseHeaders.set('Content-Type', 'text/html');
38 | responseHeaders.set('Content-Security-Policy', header);
39 |
40 | return new Response(body, {
41 | headers: responseHeaders,
42 | status: responseStatusCode,
43 | });
44 | }
45 |
--------------------------------------------------------------------------------
/examples/remix-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 | ) {
8 | customerAddressUpdate(
9 | address: $address
10 | addressId: $addressId
11 | defaultAddress: $defaultAddress
12 | ) {
13 | customerAddress {
14 | id
15 | }
16 | userErrors {
17 | code
18 | field
19 | message
20 | }
21 | }
22 | }
23 | ` as const;
24 |
25 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressDelete
26 | export const DELETE_ADDRESS_MUTATION = `#graphql
27 | mutation customerAddressDelete(
28 | $addressId: ID!,
29 | ) {
30 | customerAddressDelete(addressId: $addressId) {
31 | deletedAddressId
32 | userErrors {
33 | code
34 | field
35 | message
36 | }
37 | }
38 | }
39 | ` as const;
40 |
41 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressCreate
42 | export const CREATE_ADDRESS_MUTATION = `#graphql
43 | mutation customerAddressCreate(
44 | $address: CustomerAddressInput!
45 | $defaultAddress: Boolean
46 | ) {
47 | customerAddressCreate(
48 | address: $address
49 | defaultAddress: $defaultAddress
50 | ) {
51 | customerAddress {
52 | id
53 | }
54 | userErrors {
55 | code
56 | field
57 | message
58 | }
59 | }
60 | }
61 | ` as const;
62 |
--------------------------------------------------------------------------------
/examples/remix-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 {
35 | customer {
36 | ...Customer
37 | }
38 | }
39 | ${CUSTOMER_FRAGMENT}
40 | ` as const;
41 |
--------------------------------------------------------------------------------
/examples/remix-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 | statusPageUrl
49 | processedAt
50 | fulfillments(first: 1) {
51 | nodes {
52 | status
53 | }
54 | }
55 | totalTax {
56 | ...OrderMoney
57 | }
58 | totalPrice {
59 | ...OrderMoney
60 | }
61 | subtotal {
62 | ...OrderMoney
63 | }
64 | shippingAddress {
65 | name
66 | formatted(withName: true)
67 | formattedArea
68 | }
69 | discountApplications(first: 100) {
70 | nodes {
71 | ...DiscountApplication
72 | }
73 | }
74 | lineItems(first: 100) {
75 | nodes {
76 | ...OrderLineItemFull
77 | }
78 | }
79 | }
80 | query Order($orderId: ID!) {
81 | order(id: $orderId) {
82 | ... on Order {
83 | ...Order
84 | }
85 | }
86 | }
87 | ` as const;
88 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/graphql/customer-account/CustomerOrdersQuery.ts:
--------------------------------------------------------------------------------
1 | // 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 | fulfillments(first: 1) {
10 | nodes {
11 | status
12 | }
13 | }
14 | id
15 | number
16 | processedAt
17 | }
18 | ` as const;
19 |
20 | // https://shopify.dev/docs/api/customer/latest/objects/Customer
21 | export const CUSTOMER_ORDERS_FRAGMENT = `#graphql
22 | fragment CustomerOrders on Customer {
23 | orders(
24 | sortKey: PROCESSED_AT,
25 | reverse: true,
26 | first: $first,
27 | last: $last,
28 | before: $startCursor,
29 | after: $endCursor
30 | ) {
31 | nodes {
32 | ...OrderItem
33 | }
34 | pageInfo {
35 | hasPreviousPage
36 | hasNextPage
37 | endCursor
38 | startCursor
39 | }
40 | }
41 | }
42 | ${ORDER_ITEM_FRAGMENT}
43 | ` as const;
44 |
45 | // https://shopify.dev/docs/api/customer/latest/queries/customer
46 | export const CUSTOMER_ORDERS_QUERY = `#graphql
47 | ${CUSTOMER_ORDERS_FRAGMENT}
48 | query CustomerOrders(
49 | $endCursor: String
50 | $first: Int
51 | $last: Int
52 | $startCursor: String
53 | ) {
54 | customer {
55 | ...CustomerOrders
56 | }
57 | }
58 | ` as const;
59 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/graphql/customer-account/CustomerUpdateMutation.ts:
--------------------------------------------------------------------------------
1 | export const CUSTOMER_UPDATE_MUTATION = `#graphql
2 | # https://shopify.dev/docs/api/customer/latest/mutations/customerUpdate
3 | mutation customerUpdate(
4 | $customer: CustomerUpdateInput!
5 | ){
6 | customerUpdate(input: $customer) {
7 | customer {
8 | firstName
9 | lastName
10 | emailAddress {
11 | emailAddress
12 | }
13 | phoneNumber {
14 | phoneNumber
15 | }
16 | }
17 | userErrors {
18 | code
19 | field
20 | message
21 | }
22 | }
23 | }
24 | ` as const;
25 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/lib/fragments.ts:
--------------------------------------------------------------------------------
1 | // NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
2 | export const CART_QUERY_FRAGMENT = `#graphql
3 | fragment Money on MoneyV2 {
4 | currencyCode
5 | amount
6 | }
7 | fragment CartLine on CartLine {
8 | id
9 | quantity
10 | attributes {
11 | key
12 | value
13 | }
14 | cost {
15 | totalAmount {
16 | ...Money
17 | }
18 | amountPerQuantity {
19 | ...Money
20 | }
21 | compareAtAmountPerQuantity {
22 | ...Money
23 | }
24 | }
25 | merchandise {
26 | ... on ProductVariant {
27 | id
28 | availableForSale
29 | compareAtPrice {
30 | ...Money
31 | }
32 | price {
33 | ...Money
34 | }
35 | requiresShipping
36 | title
37 | image {
38 | id
39 | url
40 | altText
41 | width
42 | height
43 |
44 | }
45 | product {
46 | handle
47 | title
48 | id
49 | vendor
50 | }
51 | selectedOptions {
52 | name
53 | value
54 | }
55 | }
56 | }
57 | }
58 | fragment CartApiQuery on Cart {
59 | updatedAt
60 | id
61 | checkoutUrl
62 | totalQuantity
63 | buyerIdentity {
64 | countryCode
65 | customer {
66 | id
67 | email
68 | firstName
69 | lastName
70 | displayName
71 | }
72 | email
73 | phone
74 | }
75 | lines(first: $numCartLines) {
76 | nodes {
77 | ...CartLine
78 | }
79 | }
80 | cost {
81 | subtotalAmount {
82 | ...Money
83 | }
84 | totalAmount {
85 | ...Money
86 | }
87 | totalDutyAmount {
88 | ...Money
89 | }
90 | totalTaxAmount {
91 | ...Money
92 | }
93 | }
94 | note
95 | attributes {
96 | key
97 | value
98 | }
99 | discountCodes {
100 | code
101 | applicable
102 | }
103 | }
104 | ` as const;
105 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/lib/root-data.ts:
--------------------------------------------------------------------------------
1 | import {useMatches} from '@remix-run/react';
2 | import type {SerializeFrom} from '@shopify/remix-oxygen';
3 | import type {loader} from '~/root';
4 |
5 | /**
6 | * Access the result of the root loader from a React component.
7 | */
8 | export function useRootLoaderData() {
9 | const [root] = useMatches();
10 | return root?.data as SerializeFrom;
11 | }
12 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/lib/search.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | PredictiveQueryFragment,
3 | SearchProductFragment,
4 | PredictiveProductFragment,
5 | PredictiveCollectionFragment,
6 | PredictivePageFragment,
7 | PredictiveArticleFragment,
8 | } from 'storefrontapi.generated';
9 |
10 | export function applyTrackingParams(
11 | resource:
12 | | PredictiveQueryFragment
13 | | SearchProductFragment
14 | | PredictiveProductFragment
15 | | PredictiveCollectionFragment
16 | | PredictiveArticleFragment
17 | | PredictivePageFragment,
18 | params?: string,
19 | ) {
20 | if (params) {
21 | return resource?.trackingParameters
22 | ? `?${params}&${resource.trackingParameters}`
23 | : `?${params}`;
24 | } else {
25 | return resource?.trackingParameters
26 | ? `?${resource.trackingParameters}`
27 | : '';
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/lib/session.ts:
--------------------------------------------------------------------------------
1 | import type {HydrogenSession} from '@shopify/hydrogen';
2 | import {
3 | createCookieSessionStorage,
4 | type SessionStorage,
5 | type Session,
6 | } from '@shopify/remix-oxygen';
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 | #sessionStorage;
15 | #session;
16 |
17 | constructor(sessionStorage: SessionStorage, session: Session) {
18 | this.#sessionStorage = sessionStorage;
19 | this.#session = session;
20 | }
21 |
22 | static async init(request: Request, secrets: string[]) {
23 | const storage = createCookieSessionStorage({
24 | cookie: {
25 | name: 'session',
26 | httpOnly: true,
27 | path: '/',
28 | sameSite: 'lax',
29 | secrets,
30 | },
31 | });
32 |
33 | const session = await storage
34 | .getSession(request.headers.get('Cookie'))
35 | .catch(() => storage.getSession());
36 |
37 | return new this(storage, session);
38 | }
39 |
40 | get has() {
41 | return this.#session.has;
42 | }
43 |
44 | get get() {
45 | return this.#session.get;
46 | }
47 |
48 | get flash() {
49 | return this.#session.flash;
50 | }
51 |
52 | get unset() {
53 | return this.#session.unset;
54 | }
55 |
56 | get set() {
57 | return this.#session.set;
58 | }
59 |
60 | destroy() {
61 | return this.#sessionStorage.destroySession(this.#session);
62 | }
63 |
64 | commit() {
65 | return this.#sessionStorage.commitSession(this.#session);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/lib/variants.ts:
--------------------------------------------------------------------------------
1 | import {useLocation} from '@remix-run/react';
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 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/$.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({request}: LoaderFunctionArgs) {
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 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/account.$.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | // fallback wild card for all unauthenticated routes in account section
4 | export async function loader({context}: LoaderFunctionArgs) {
5 | await context.customerAccount.handleAuthStatus();
6 |
7 | return redirect('/account', {
8 | headers: {
9 | 'Set-Cookie': await context.session.commit(),
10 | },
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/account._index.tsx:
--------------------------------------------------------------------------------
1 | import {redirect} from '@shopify/remix-oxygen';
2 |
3 | export async function loader() {
4 | return redirect('/account/orders');
5 | }
6 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/account.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Form, NavLink, Outlet, useLoaderData} from '@remix-run/react';
3 | import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery';
4 |
5 | export function shouldRevalidate() {
6 | return true;
7 | }
8 |
9 | export async function loader({context}: LoaderFunctionArgs) {
10 | const {data, errors} = await context.customerAccount.query(
11 | CUSTOMER_DETAILS_QUERY,
12 | );
13 |
14 | if (errors?.length || !data?.customer) {
15 | throw new Error('Customer not found');
16 | }
17 |
18 | return json(
19 | {customer: data.customer},
20 | {
21 | headers: {
22 | 'Cache-Control': 'no-cache, no-store, must-revalidate',
23 | 'Set-Cookie': await context.session.commit(),
24 | },
25 | },
26 | );
27 | }
28 |
29 | export default function AccountLayout() {
30 | const {customer} = useLoaderData();
31 |
32 | const heading = customer
33 | ? customer.firstName
34 | ? `Welcome, ${customer.firstName}`
35 | : `Welcome to your account.`
36 | : 'Account Details';
37 |
38 | return (
39 |
40 |
{heading}
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | function AccountMenu() {
51 | function isActiveStyle({
52 | isActive,
53 | isPending,
54 | }: {
55 | isActive: boolean;
56 | isPending: boolean;
57 | }) {
58 | return {
59 | fontWeight: isActive ? 'bold' : undefined,
60 | color: isPending ? 'grey' : 'black',
61 | };
62 | }
63 |
64 | return (
65 |
80 | );
81 | }
82 |
83 | function Logout() {
84 | return (
85 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/account_.authorize.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({context}: LoaderFunctionArgs) {
4 | return context.customerAccount.authorize();
5 | }
6 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/account_.login.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({request, context}: LoaderFunctionArgs) {
4 | return context.customerAccount.login();
5 | }
6 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/account_.logout.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type ActionFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | // if we dont implement this, /account/logout will get caught by account.$.tsx to do login
4 | export async function loader() {
5 | return redirect('/');
6 | }
7 |
8 | export async function action({context}: ActionFunctionArgs) {
9 | return context.customerAccount.logout();
10 | }
11 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/blogs.$blogHandle.$articleHandle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {Image} from '@shopify/hydrogen';
4 |
5 | export const meta: MetaFunction = ({data}) => {
6 | return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}];
7 | };
8 |
9 | export async function loader({params, context}: LoaderFunctionArgs) {
10 | const {blogHandle, articleHandle} = params;
11 |
12 | if (!articleHandle || !blogHandle) {
13 | throw new Response('Not found', {status: 404});
14 | }
15 |
16 | const {blog} = await context.storefront.query(ARTICLE_QUERY, {
17 | variables: {blogHandle, articleHandle},
18 | });
19 |
20 | if (!blog?.articleByHandle) {
21 | throw new Response(null, {status: 404});
22 | }
23 |
24 | const article = blog.articleByHandle;
25 |
26 | return json({article});
27 | }
28 |
29 | export default function Article() {
30 | const {article} = useLoaderData();
31 | const {title, image, contentHtml, author} = article;
32 |
33 | const publishedDate = new Intl.DateTimeFormat('en-US', {
34 | year: 'numeric',
35 | month: 'long',
36 | day: 'numeric',
37 | }).format(new Date(article.publishedAt));
38 |
39 | return (
40 |
41 |
42 | {title}
43 |
44 | {publishedDate} · {author?.name}
45 |
46 |
47 |
48 | {image &&
}
49 |
53 |
54 | );
55 | }
56 |
57 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog#field-blog-articlebyhandle
58 | const ARTICLE_QUERY = `#graphql
59 | query Article(
60 | $articleHandle: String!
61 | $blogHandle: String!
62 | $country: CountryCode
63 | $language: LanguageCode
64 | ) @inContext(language: $language, country: $country) {
65 | blog(handle: $blogHandle) {
66 | articleByHandle(handle: $articleHandle) {
67 | title
68 | contentHtml
69 | publishedAt
70 | author: authorV2 {
71 | name
72 | }
73 | image {
74 | id
75 | altText
76 | url
77 | width
78 | height
79 | }
80 | seo {
81 | description
82 | title
83 | }
84 | }
85 | }
86 | }
87 | ` as const;
88 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/blogs._index.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {Pagination, getPaginationVariables} from '@shopify/hydrogen';
4 |
5 | export const meta: MetaFunction = () => {
6 | return [{title: `Hydrogen | Blogs`}];
7 | };
8 |
9 | export const loader = async ({
10 | request,
11 | context: {storefront},
12 | }: LoaderFunctionArgs) => {
13 | const paginationVariables = getPaginationVariables(request, {
14 | pageBy: 10,
15 | });
16 |
17 | const {blogs} = await storefront.query(BLOGS_QUERY, {
18 | variables: {
19 | ...paginationVariables,
20 | },
21 | });
22 |
23 | return json({blogs});
24 | };
25 |
26 | export default function Blogs() {
27 | const {blogs} = useLoaderData();
28 |
29 | return (
30 |
31 |
Blogs
32 |
33 |
34 | {({nodes, isLoading, PreviousLink, NextLink}) => {
35 | return (
36 | <>
37 |
38 | {isLoading ? 'Loading...' : ↑ Load previous}
39 |
40 | {nodes.map((blog) => {
41 | return (
42 |
48 | {blog.title}
49 |
50 | );
51 | })}
52 |
53 | {isLoading ? 'Loading...' : Load more ↓}
54 |
55 | >
56 | );
57 | }}
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
65 | const BLOGS_QUERY = `#graphql
66 | query Blogs(
67 | $country: CountryCode
68 | $endCursor: String
69 | $first: Int
70 | $language: LanguageCode
71 | $last: Int
72 | $startCursor: String
73 | ) @inContext(country: $country, language: $language) {
74 | blogs(
75 | first: $first,
76 | last: $last,
77 | before: $startCursor,
78 | after: $endCursor
79 | ) {
80 | pageInfo {
81 | hasNextPage
82 | hasPreviousPage
83 | startCursor
84 | endCursor
85 | }
86 | nodes {
87 | title
88 | handle
89 | seo {
90 | title
91 | description
92 | }
93 | }
94 | }
95 | }
96 | ` as const;
97 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/cart.$lines.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | /**
4 | * Automatically creates a new cart based on the URL and redirects straight to checkout.
5 | * Expected URL structure:
6 | * ```js
7 | * /cart/:
8 | *
9 | * ```
10 | *
11 | * More than one `:` separated by a comma, can be supplied in the URL, for
12 | * carts with more than one product variant.
13 | *
14 | * @example
15 | * Example path creating a cart with two product variants, different quantities, and a discount code in the querystring:
16 | * ```js
17 | * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
18 | *
19 | * ```
20 | */
21 | export async function loader({request, context, params}: LoaderFunctionArgs) {
22 | const {cart} = context;
23 | const {lines} = params;
24 | if (!lines) return redirect('/cart');
25 | const linesMap = lines.split(',').map((line) => {
26 | const lineDetails = line.split(':');
27 | const variantId = lineDetails[0];
28 | const quantity = parseInt(lineDetails[1], 10);
29 |
30 | return {
31 | merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
32 | quantity,
33 | };
34 | });
35 |
36 | const url = new URL(request.url);
37 | const searchParams = new URLSearchParams(url.search);
38 |
39 | const discount = searchParams.get('discount');
40 | const discountArray = discount ? [discount] : [];
41 |
42 | // create a cart
43 | const result = await cart.create({
44 | lines: linesMap,
45 | discountCodes: discountArray,
46 | });
47 |
48 | const cartResult = result.cart;
49 |
50 | if (result.errors?.length || !cartResult) {
51 | throw new Response('Link may be expired. Try checking the URL.', {
52 | status: 410,
53 | });
54 | }
55 |
56 | // Update cart id in cookie
57 | const headers = cart.setCartId(cartResult.id);
58 |
59 | // redirect to checkout
60 | if (cartResult.checkoutUrl) {
61 | return redirect(cartResult.checkoutUrl, {headers});
62 | } else {
63 | throw new Error('No checkout URL found');
64 | }
65 | }
66 |
67 | export default function Component() {
68 | return null;
69 | }
70 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/cart.tsx:
--------------------------------------------------------------------------------
1 | import {Await, type MetaFunction} from '@remix-run/react';
2 | import {Suspense} from 'react';
3 | import type {CartQueryDataReturn} from '@shopify/hydrogen';
4 | import {CartForm} from '@shopify/hydrogen';
5 | import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen';
6 | import {CartMain} from '~/components/Cart';
7 | import {useRootLoaderData} from '~/lib/root-data';
8 |
9 | export const meta: MetaFunction = () => {
10 | return [{title: `Hydrogen | Cart`}];
11 | };
12 |
13 | export async function action({request, context}: ActionFunctionArgs) {
14 | const {cart} = context;
15 |
16 | const formData = await request.formData();
17 |
18 | const {action, inputs} = CartForm.getFormInput(formData);
19 |
20 | if (!action) {
21 | throw new Error('No action provided');
22 | }
23 |
24 | let status = 200;
25 | let result: CartQueryDataReturn;
26 |
27 | switch (action) {
28 | case CartForm.ACTIONS.LinesAdd:
29 | result = await cart.addLines(inputs.lines);
30 | break;
31 | case CartForm.ACTIONS.LinesUpdate:
32 | result = await cart.updateLines(inputs.lines);
33 | break;
34 | case CartForm.ACTIONS.LinesRemove:
35 | result = await cart.removeLines(inputs.lineIds);
36 | break;
37 | case CartForm.ACTIONS.DiscountCodesUpdate: {
38 | const formDiscountCode = inputs.discountCode;
39 |
40 | // User inputted discount code
41 | const discountCodes = (
42 | formDiscountCode ? [formDiscountCode] : []
43 | ) as string[];
44 |
45 | // Combine discount codes already applied on cart
46 | discountCodes.push(...inputs.discountCodes);
47 |
48 | result = await cart.updateDiscountCodes(discountCodes);
49 | break;
50 | }
51 | case CartForm.ACTIONS.BuyerIdentityUpdate: {
52 | result = await cart.updateBuyerIdentity({
53 | ...inputs.buyerIdentity,
54 | });
55 | break;
56 | }
57 | default:
58 | throw new Error(`${action} cart action is not defined`);
59 | }
60 |
61 | const cartId = result.cart.id;
62 | const headers = cart.setCartId(result.cart.id);
63 | const {cart: cartResult, errors} = result;
64 |
65 | const redirectTo = formData.get('redirectTo') ?? null;
66 | if (typeof redirectTo === 'string') {
67 | status = 303;
68 | headers.set('Location', redirectTo);
69 | }
70 |
71 | headers.append('Set-Cookie', await context.session.commit());
72 |
73 | return json(
74 | {
75 | cart: cartResult,
76 | errors,
77 | analytics: {
78 | cartId,
79 | },
80 | },
81 | {status, headers},
82 | );
83 | }
84 |
85 | export default function Cart() {
86 | const rootData = useRootLoaderData();
87 | const cartPromise = rootData.cart;
88 |
89 | return (
90 |
91 |
Cart
92 |
Loading cart ...}>
93 | An error occurred }
96 | >
97 | {(cart) => {
98 | return ;
99 | }}
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/collections._index.tsx:
--------------------------------------------------------------------------------
1 | import {useLoaderData, Link} from '@remix-run/react';
2 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
3 | import {Pagination, getPaginationVariables, Image} from '@shopify/hydrogen';
4 | import type {CollectionFragment} from 'storefrontapi.generated';
5 |
6 | export async function loader({context, request}: LoaderFunctionArgs) {
7 | const paginationVariables = getPaginationVariables(request, {
8 | pageBy: 4,
9 | });
10 |
11 | const {collections} = await context.storefront.query(COLLECTIONS_QUERY, {
12 | variables: paginationVariables,
13 | });
14 |
15 | return json({collections});
16 | }
17 |
18 | export default function Collections() {
19 | const {collections} = useLoaderData();
20 |
21 | return (
22 |
23 |
Collections
24 |
25 | {({nodes, isLoading, PreviousLink, NextLink}) => (
26 |
27 |
28 | {isLoading ? 'Loading...' : ↑ Load previous}
29 |
30 |
31 |
32 | {isLoading ? 'Loading...' : Load more ↓}
33 |
34 |
35 | )}
36 |
37 |
38 | );
39 | }
40 |
41 | function CollectionsGrid({collections}: {collections: CollectionFragment[]}) {
42 | return (
43 |
44 | {collections.map((collection, index) => (
45 |
50 | ))}
51 |
52 | );
53 | }
54 |
55 | function CollectionItem({
56 | collection,
57 | index,
58 | }: {
59 | collection: CollectionFragment;
60 | index: number;
61 | }) {
62 | return (
63 |
69 | {collection?.image && (
70 |
76 | )}
77 | {collection.title}
78 |
79 | );
80 | }
81 |
82 | const COLLECTIONS_QUERY = `#graphql
83 | fragment Collection on Collection {
84 | id
85 | title
86 | handle
87 | image {
88 | id
89 | url
90 | altText
91 | width
92 | height
93 | }
94 | }
95 | query StoreCollections(
96 | $country: CountryCode
97 | $endCursor: String
98 | $first: Int
99 | $language: LanguageCode
100 | $last: Int
101 | $startCursor: String
102 | ) @inContext(country: $country, language: $language) {
103 | collections(
104 | first: $first,
105 | last: $last,
106 | before: $startCursor,
107 | after: $endCursor
108 | ) {
109 | nodes {
110 | ...Collection
111 | }
112 | pageInfo {
113 | hasNextPage
114 | hasPreviousPage
115 | startCursor
116 | endCursor
117 | }
118 | }
119 | }
120 | ` as const;
121 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/discount.$code.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | /**
4 | * Automatically applies a discount found on the url
5 | * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
6 | *
7 | * @example
8 | * Example path applying a discount and optional redirecting (defaults to the home page)
9 | * ```js
10 | * /discount/FREESHIPPING?redirect=/products
11 | *
12 | * ```
13 | */
14 | export async function loader({request, context, params}: LoaderFunctionArgs) {
15 | const {cart} = context;
16 | const {code} = params;
17 |
18 | const url = new URL(request.url);
19 | const searchParams = new URLSearchParams(url.search);
20 | let redirectParam =
21 | searchParams.get('redirect') || searchParams.get('return_to') || '/';
22 |
23 | if (redirectParam.includes('//')) {
24 | // Avoid redirecting to external URLs to prevent phishing attacks
25 | redirectParam = '/';
26 | }
27 |
28 | searchParams.delete('redirect');
29 | searchParams.delete('return_to');
30 |
31 | const redirectUrl = `${redirectParam}?${searchParams}`;
32 |
33 | if (!code) {
34 | return redirect(redirectUrl);
35 | }
36 |
37 | const result = await cart.updateDiscountCodes([code]);
38 | const headers = cart.setCartId(result.cart.id);
39 |
40 | // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
41 | // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
42 | // on localhost:3000
43 | return redirect(redirectUrl, {
44 | status: 303,
45 | headers,
46 | });
47 | }
48 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/pages.$handle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, type MetaFunction} from '@remix-run/react';
3 |
4 | export const meta: MetaFunction = ({data}) => {
5 | return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
6 | };
7 |
8 | export async function loader({params, context}: LoaderFunctionArgs) {
9 | if (!params.handle) {
10 | throw new Error('Missing page handle');
11 | }
12 |
13 | const {page} = await context.storefront.query(PAGE_QUERY, {
14 | variables: {
15 | handle: params.handle,
16 | },
17 | });
18 |
19 | if (!page) {
20 | throw new Response('Not Found', {status: 404});
21 | }
22 |
23 | return json({page});
24 | }
25 |
26 | export default function Page() {
27 | const {page} = useLoaderData();
28 |
29 | return (
30 |
31 |
34 |
35 |
36 | );
37 | }
38 |
39 | const PAGE_QUERY = `#graphql
40 | query Page(
41 | $language: LanguageCode,
42 | $country: CountryCode,
43 | $handle: String!
44 | )
45 | @inContext(language: $language, country: $country) {
46 | page(handle: $handle) {
47 | id
48 | title
49 | body
50 | seo {
51 | description
52 | title
53 | }
54 | }
55 | }
56 | ` as const;
57 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/policies.$handle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {type Shop} from '@shopify/hydrogen/storefront-api-types';
4 |
5 | type SelectedPolicies = keyof Pick<
6 | Shop,
7 | 'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy'
8 | >;
9 |
10 | export const meta: MetaFunction = ({data}) => {
11 | return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}];
12 | };
13 |
14 | export async function loader({params, context}: LoaderFunctionArgs) {
15 | if (!params.handle) {
16 | throw new Response('No handle was passed in', {status: 404});
17 | }
18 |
19 | const policyName = params.handle.replace(
20 | /-([a-z])/g,
21 | (_: unknown, m1: string) => m1.toUpperCase(),
22 | ) as SelectedPolicies;
23 |
24 | const data = await context.storefront.query(POLICY_CONTENT_QUERY, {
25 | variables: {
26 | privacyPolicy: false,
27 | shippingPolicy: false,
28 | termsOfService: false,
29 | refundPolicy: false,
30 | [policyName]: true,
31 | language: context.storefront.i18n?.language,
32 | },
33 | });
34 |
35 | const policy = data.shop?.[policyName];
36 |
37 | if (!policy) {
38 | throw new Response('Could not find the policy', {status: 404});
39 | }
40 |
41 | return json({policy});
42 | }
43 |
44 | export default function Policy() {
45 | const {policy} = useLoaderData();
46 |
47 | return (
48 |
49 |
50 |
51 |
52 | ← Back to Policies
53 |
54 |
55 |
{policy.title}
56 |
57 |
58 | );
59 | }
60 |
61 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Shop
62 | const POLICY_CONTENT_QUERY = `#graphql
63 | fragment Policy on ShopPolicy {
64 | body
65 | handle
66 | id
67 | title
68 | url
69 | }
70 | query Policy(
71 | $country: CountryCode
72 | $language: LanguageCode
73 | $privacyPolicy: Boolean!
74 | $refundPolicy: Boolean!
75 | $shippingPolicy: Boolean!
76 | $termsOfService: Boolean!
77 | ) @inContext(language: $language, country: $country) {
78 | shop {
79 | privacyPolicy @include(if: $privacyPolicy) {
80 | ...Policy
81 | }
82 | shippingPolicy @include(if: $shippingPolicy) {
83 | ...Policy
84 | }
85 | termsOfService @include(if: $termsOfService) {
86 | ...Policy
87 | }
88 | refundPolicy @include(if: $refundPolicy) {
89 | ...Policy
90 | }
91 | }
92 | }
93 | ` as const;
94 |
--------------------------------------------------------------------------------
/examples/remix-storefront/app/routes/policies._index.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, Link} from '@remix-run/react';
3 |
4 | export async function loader({context}: LoaderFunctionArgs) {
5 | const data = await context.storefront.query(POLICIES_QUERY);
6 | const policies = Object.values(data.shop || {});
7 |
8 | if (!policies.length) {
9 | throw new Response('No policies found', {status: 404});
10 | }
11 |
12 | return json({policies});
13 | }
14 |
15 | export default function Policies() {
16 | const {policies} = useLoaderData();
17 |
18 | return (
19 |
20 |
Policies
21 |
22 | {policies.map((policy) => {
23 | if (!policy) return null;
24 | return (
25 |
28 | );
29 | })}
30 |
31 |
32 | );
33 | }
34 |
35 | const POLICIES_QUERY = `#graphql
36 | fragment PolicyItem on ShopPolicy {
37 | id
38 | title
39 | handle
40 | }
41 | query Policies ($country: CountryCode, $language: LanguageCode)
42 | @inContext(country: $country, language: $language) {
43 | shop {
44 | privacyPolicy {
45 | ...PolicyItem
46 | }
47 | shippingPolicy {
48 | ...PolicyItem
49 | }
50 | termsOfService {
51 | ...PolicyItem
52 | }
53 | refundPolicy {
54 | ...PolicyItem
55 | }
56 | subscriptionPolicy {
57 | id
58 | title
59 | handle
60 | }
61 | }
62 | }
63 | ` as const;
64 |
--------------------------------------------------------------------------------
/examples/remix-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 |
--------------------------------------------------------------------------------
/examples/remix-storefront/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example-classic-remix",
3 | "private": true,
4 | "sideEffects": false,
5 | "version": "2024.4.4",
6 | "type": "commonjs",
7 | "scripts": {
8 | "build": "shopify hydrogen build",
9 | "dev": "shopify hydrogen dev --codegen",
10 | "preview": "npm run build && shopify hydrogen preview",
11 | "lint": "eslint --cache --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
12 | "typecheck": "tsc --noEmit",
13 | "codegen": "shopify hydrogen codegen"
14 | },
15 | "prettier": "@shopify/prettier-config",
16 | "dependencies": {
17 | "@remix-run/react": "^2.8.0",
18 | "@remix-run/server-runtime": "^2.8.0",
19 | "@shopify/cli": "3.59.2",
20 | "@shopify/cli-hydrogen": "^8.0.4",
21 | "@shopify/hydrogen": "2024.4.2",
22 | "@shopify/remix-oxygen": "^2.0.4",
23 | "graphql": "^16.6.0",
24 | "graphql-tag": "^2.12.6",
25 | "hydrogen-sanity": "^4.0.5",
26 | "isbot": "^3.8.0",
27 | "react": "^18.2.0",
28 | "react-dom": "^18.2.0"
29 | },
30 | "devDependencies": {
31 | "@graphql-codegen/cli": "5.0.2",
32 | "@remix-run/dev": "^2.8.0",
33 | "@remix-run/eslint-config": "^2.8.0",
34 | "@shopify/hydrogen-codegen": "^0.3.1",
35 | "@shopify/mini-oxygen": "^3.0.2",
36 | "@shopify/oxygen-workers-types": "^4.0.0",
37 | "@shopify/prettier-config": "^1.1.2",
38 | "@total-typescript/ts-reset": "^0.4.2",
39 | "@types/eslint": "^8.4.10",
40 | "@types/react": "^18.2.22",
41 | "@types/react-dom": "^18.2.7",
42 | "eslint": "^8.20.0",
43 | "eslint-plugin-hydrogen": "0.12.2",
44 | "prettier": "^2.8.4",
45 | "typescript": "^5.2.2"
46 | },
47 | "engines": {
48 | "node": ">=18.0.0"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/examples/remix-storefront/public/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/hydrogen-sanity/b6d75fb49e23ea5646e3fd2b6c72001de9ff592c/examples/remix-storefront/public/.gitkeep
--------------------------------------------------------------------------------
/examples/remix-storefront/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | module.exports = {
3 | appDirectory: 'app',
4 | ignoredRouteFiles: ['**/.*'],
5 | watchPaths: ['./public', './.env'],
6 | server: './server.ts',
7 | /**
8 | * The following settings are required to deploy Hydrogen apps to Oxygen:
9 | */
10 | publicPath: (process.env.HYDROGEN_ASSET_BASE_URL ?? '/') + 'build/',
11 | assetsBuildDirectory: 'dist/client/build',
12 | serverBuildPath: 'dist/worker/index.js',
13 | serverMainFields: ['browser', 'module', 'main'],
14 | serverConditions: ['worker', process.env.NODE_ENV],
15 | serverDependenciesToBundle: 'all',
16 | serverModuleFormat: 'esm',
17 | serverPlatform: 'neutral',
18 | serverMinify: process.env.NODE_ENV === 'production',
19 | future: {
20 | v3_fetcherPersist: true,
21 | v3_relativeSplatpath: true,
22 | v3_throwAbortReason: true,
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/examples/remix-storefront/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // Enhance TypeScript's built-in typings.
6 | import '@total-typescript/ts-reset';
7 |
8 | import type {
9 | Storefront,
10 | CustomerAccount,
11 | HydrogenCart,
12 | HydrogenSessionData,
13 | } from '@shopify/hydrogen';
14 | import type {AppSession} from '~/lib/session';
15 |
16 | declare global {
17 | /**
18 | * A global `process` object is only available during build to access NODE_ENV.
19 | */
20 | const process: {env: {NODE_ENV: 'production' | 'development'}};
21 |
22 | /**
23 | * Declare expected Env parameter in fetch handler.
24 | */
25 | interface Env {
26 | SESSION_SECRET: string;
27 | PUBLIC_STOREFRONT_API_TOKEN: string;
28 | PRIVATE_STOREFRONT_API_TOKEN: string;
29 | PUBLIC_STORE_DOMAIN: string;
30 | PUBLIC_STOREFRONT_ID: string;
31 | PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string;
32 | PUBLIC_CUSTOMER_ACCOUNT_API_URL: string;
33 | PUBLIC_CHECKOUT_DOMAIN: string;
34 |
35 | PUBLIC_SANITY_PROJECT_ID: string;
36 | PUBLIC_SANITY_DATASET?: string;
37 | PUBLIC_SANITY_API_VERSION?: string;
38 | SANITY_PREVIEW_TOKEN: string;
39 | }
40 | }
41 |
42 | declare module '@shopify/remix-oxygen' {
43 | /**
44 | * Declare local additions to the Remix loader context.
45 | */
46 | interface AppLoadContext {
47 | env: Env;
48 | cart: HydrogenCart;
49 | storefront: Storefront;
50 | customerAccount: CustomerAccount;
51 | session: AppSession;
52 | waitUntil: ExecutionContext['waitUntil'];
53 | }
54 |
55 | /**
56 | * Declare local additions to the Remix session data.
57 | */
58 | interface SessionData extends HydrogenSessionData {}
59 | }
60 |
--------------------------------------------------------------------------------
/examples/remix-storefront/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "Bundler",
9 | "resolveJsonModule": true,
10 | "module": "ES2022",
11 | "target": "ES2022",
12 | "strict": true,
13 | "allowJs": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "skipLibCheck": true,
16 | "baseUrl": ".",
17 | "types": ["@shopify/oxygen-workers-types"],
18 | "paths": {
19 | "~/*": ["app/*"]
20 | },
21 | "noEmit": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/examples/vite-storefront/.env.example:
--------------------------------------------------------------------------------
1 | # These variables are only available locally in MiniOxygen
2 | # https://shopify.dev/docs/custom-storefronts/hydrogen/environment-variables
3 |
4 | # Shopify environment variables...
5 | SESSION_SECRET=
6 | PUBLIC_STOREFRONT_ID=
7 | PUBLIC_STOREFRONT_API_TOKEN=
8 | PUBLIC_STORE_DOMAIN=
9 | PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID=
10 | PUBLIC_CUSTOMER_ACCOUNT_API_URL=
11 | PRIVATE_STOREFRONT_API_TOKEN=
12 |
13 | # Sanity environment variables...
14 |
15 | # Project ID
16 | PUBLIC_SANITY_PROJECT_ID=
17 |
18 | # (Optional) Dataset name
19 | # Defaults to `production`
20 | # PUBLIC_SANITY_DATASET=
21 |
22 | # (Optional) Sanity API version
23 | # Defaults to `v2022-03-07`
24 | # PUBLIC_SANITY_API_VERSION=
25 |
26 | # Sanity token to authenticate requests in "preview" mode
27 | # Only requires 'viewer' role
28 | # https://www.sanity.io/docs/http-auth
29 | SANITY_PREVIEW_TOKEN=
--------------------------------------------------------------------------------
/examples/vite-storefront/.eslintignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | bin
4 | *.d.ts
5 | dist
6 |
--------------------------------------------------------------------------------
/examples/vite-storefront/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import("@types/eslint").Linter.BaseConfig}
3 | */
4 | module.exports = {
5 | extends: [
6 | '@remix-run/eslint-config',
7 | 'plugin:hydrogen/recommended',
8 | 'plugin:hydrogen/typescript',
9 | ],
10 | rules: {
11 | '@typescript-eslint/ban-ts-comment': 'off',
12 | '@typescript-eslint/naming-convention': 'off',
13 | 'hydrogen/prefer-image-component': 'off',
14 | 'no-useless-escape': 'off',
15 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
16 | 'no-case-declarations': 'off',
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/examples/vite-storefront/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /.cache
3 | /build
4 | /dist
5 | /public/build
6 | /.mf
7 | .env
8 | .shopify
9 |
--------------------------------------------------------------------------------
/examples/vite-storefront/.graphqlrc.yml:
--------------------------------------------------------------------------------
1 | projects:
2 | default:
3 | schema: 'node_modules/@shopify/hydrogen/storefront.schema.json'
4 | documents:
5 | - '!*.d.ts'
6 | - '*.{ts,tsx,js,jsx}'
7 | - 'app/**/*.{ts,tsx,js,jsx}'
8 | - '!app/graphql/**/*.{ts,tsx,js,jsx}'
9 | customer-account:
10 | schema: 'node_modules/@shopify/hydrogen/customer-account.schema.json'
11 | documents:
12 | - 'app/graphql/customer-account/**/*.{ts,tsx,js,jsx}'
13 |
--------------------------------------------------------------------------------
/examples/vite-storefront/.npmrc:
--------------------------------------------------------------------------------
1 | @shopify:registry=https://registry.npmjs.com
2 | progress=false
3 |
4 | # Ensure Vite can optimize these deps in PNPM
5 | public-hoist-pattern[]=cookie
6 | public-hoist-pattern[]=set-cookie-parser
7 | public-hoist-pattern[]=content-security-policy-builder
8 |
--------------------------------------------------------------------------------
/examples/vite-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 | ```sh
28 | npm create @shopify/hydrogen@latest
29 | ```
30 |
31 | ## Building for production
32 |
33 | ```sh
34 | npm run build
35 | ```
36 |
37 | ## Local development
38 |
39 | ```sh
40 | npm run dev
41 | ```
42 |
43 | ## Setup for using Customer Account API (`/account` section)
44 |
45 | Follow step 1 and 2 of
46 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
29 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/components/Aside.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * A side bar component with Overlay that works without JavaScript.
3 | * @example
4 | * ```jsx
5 | *
9 | * ```
10 | */
11 | export function Aside({
12 | children,
13 | heading,
14 | id = 'aside',
15 | }: {
16 | children?: React.ReactNode;
17 | heading: React.ReactNode;
18 | id?: string;
19 | }) {
20 | return (
21 |
22 |
37 | );
38 | }
39 |
40 | function CloseAside() {
41 | return (
42 | /* eslint-disable-next-line jsx-a11y/anchor-is-valid */
43 | history.go(-1)}>
44 | ×
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import {Await} from '@remix-run/react';
2 | import {Suspense} from 'react';
3 | import type {
4 | CartApiQueryFragment,
5 | FooterQuery,
6 | HeaderQuery,
7 | } from 'storefrontapi.generated';
8 | import {Aside} from '~/components/Aside';
9 | import {Footer} from '~/components/Footer';
10 | import {Header, HeaderMenu} from '~/components/Header';
11 | import {CartMain} from '~/components/Cart';
12 | import {
13 | PredictiveSearchForm,
14 | PredictiveSearchResults,
15 | } from '~/components/Search';
16 |
17 | export type LayoutProps = {
18 | cart: Promise;
19 | children?: React.ReactNode;
20 | footer: Promise;
21 | header: HeaderQuery;
22 | isLoggedIn: Promise;
23 | };
24 |
25 | export function Layout({
26 | cart,
27 | children = null,
28 | footer,
29 | header,
30 | isLoggedIn,
31 | }: LayoutProps) {
32 | return (
33 | <>
34 |
35 |
36 |
37 | {header && }
38 | {children}
39 |
40 |
41 | {(footer) => }
42 |
43 |
44 | >
45 | );
46 | }
47 |
48 | function CartAside({cart}: {cart: LayoutProps['cart']}) {
49 | return (
50 |
59 | );
60 | }
61 |
62 | function SearchAside() {
63 | return (
64 |
94 | );
95 | }
96 |
97 | function MobileMenuAside({
98 | menu,
99 | shop,
100 | }: {
101 | menu: HeaderQuery['menu'];
102 | shop: HeaderQuery['shop'];
103 | }) {
104 | return (
105 | menu &&
106 | shop?.primaryDomain?.url && (
107 |
114 | )
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import {RemixBrowser} from '@remix-run/react';
2 | import {startTransition, StrictMode} from 'react';
3 | import {hydrateRoot} from 'react-dom/client';
4 |
5 | startTransition(() => {
6 | hydrateRoot(
7 | document,
8 |
9 |
10 | ,
11 | );
12 | });
13 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type {EntryContext} from '@shopify/remix-oxygen';
2 | import {RemixServer} from '@remix-run/react';
3 | import isbot from 'isbot';
4 | import {renderToReadableStream} from 'react-dom/server';
5 | import {createContentSecurityPolicy} from '@shopify/hydrogen';
6 |
7 | export default async function handleRequest(
8 | request: Request,
9 | responseStatusCode: number,
10 | responseHeaders: Headers,
11 | remixContext: EntryContext,
12 | ) {
13 | const {nonce, header, NonceProvider} = createContentSecurityPolicy({
14 | // Include Sanity domains in the CSP
15 | defaultSrc: ['https://cdn.sanity.io'],
16 | });
17 |
18 | const body = await renderToReadableStream(
19 |
20 |
21 | ,
22 | {
23 | nonce,
24 | signal: request.signal,
25 | onError(error) {
26 | // eslint-disable-next-line no-console
27 | console.error(error);
28 | responseStatusCode = 500;
29 | },
30 | },
31 | );
32 |
33 | if (isbot(request.headers.get('user-agent'))) {
34 | await body.allReady;
35 | }
36 |
37 | responseHeaders.set('Content-Type', 'text/html');
38 | responseHeaders.set('Content-Security-Policy', header);
39 |
40 | return new Response(body, {
41 | headers: responseHeaders,
42 | status: responseStatusCode,
43 | });
44 | }
45 |
--------------------------------------------------------------------------------
/examples/vite-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 | ) {
8 | customerAddressUpdate(
9 | address: $address
10 | addressId: $addressId
11 | defaultAddress: $defaultAddress
12 | ) {
13 | customerAddress {
14 | id
15 | }
16 | userErrors {
17 | code
18 | field
19 | message
20 | }
21 | }
22 | }
23 | ` as const;
24 |
25 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressDelete
26 | export const DELETE_ADDRESS_MUTATION = `#graphql
27 | mutation customerAddressDelete(
28 | $addressId: ID!,
29 | ) {
30 | customerAddressDelete(addressId: $addressId) {
31 | deletedAddressId
32 | userErrors {
33 | code
34 | field
35 | message
36 | }
37 | }
38 | }
39 | ` as const;
40 |
41 | // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressCreate
42 | export const CREATE_ADDRESS_MUTATION = `#graphql
43 | mutation customerAddressCreate(
44 | $address: CustomerAddressInput!
45 | $defaultAddress: Boolean
46 | ) {
47 | customerAddressCreate(
48 | address: $address
49 | defaultAddress: $defaultAddress
50 | ) {
51 | customerAddress {
52 | id
53 | }
54 | userErrors {
55 | code
56 | field
57 | message
58 | }
59 | }
60 | }
61 | ` as const;
62 |
--------------------------------------------------------------------------------
/examples/vite-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 {
35 | customer {
36 | ...Customer
37 | }
38 | }
39 | ${CUSTOMER_FRAGMENT}
40 | ` as const;
41 |
--------------------------------------------------------------------------------
/examples/vite-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 | statusPageUrl
49 | processedAt
50 | fulfillments(first: 1) {
51 | nodes {
52 | status
53 | }
54 | }
55 | totalTax {
56 | ...OrderMoney
57 | }
58 | totalPrice {
59 | ...OrderMoney
60 | }
61 | subtotal {
62 | ...OrderMoney
63 | }
64 | shippingAddress {
65 | name
66 | formatted(withName: true)
67 | formattedArea
68 | }
69 | discountApplications(first: 100) {
70 | nodes {
71 | ...DiscountApplication
72 | }
73 | }
74 | lineItems(first: 100) {
75 | nodes {
76 | ...OrderLineItemFull
77 | }
78 | }
79 | }
80 | query Order($orderId: ID!) {
81 | order(id: $orderId) {
82 | ... on Order {
83 | ...Order
84 | }
85 | }
86 | }
87 | ` as const;
88 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/graphql/customer-account/CustomerOrdersQuery.ts:
--------------------------------------------------------------------------------
1 | // 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 | fulfillments(first: 1) {
10 | nodes {
11 | status
12 | }
13 | }
14 | id
15 | number
16 | processedAt
17 | }
18 | ` as const;
19 |
20 | // https://shopify.dev/docs/api/customer/latest/objects/Customer
21 | export const CUSTOMER_ORDERS_FRAGMENT = `#graphql
22 | fragment CustomerOrders on Customer {
23 | orders(
24 | sortKey: PROCESSED_AT,
25 | reverse: true,
26 | first: $first,
27 | last: $last,
28 | before: $startCursor,
29 | after: $endCursor
30 | ) {
31 | nodes {
32 | ...OrderItem
33 | }
34 | pageInfo {
35 | hasPreviousPage
36 | hasNextPage
37 | endCursor
38 | startCursor
39 | }
40 | }
41 | }
42 | ${ORDER_ITEM_FRAGMENT}
43 | ` as const;
44 |
45 | // https://shopify.dev/docs/api/customer/latest/queries/customer
46 | export const CUSTOMER_ORDERS_QUERY = `#graphql
47 | ${CUSTOMER_ORDERS_FRAGMENT}
48 | query CustomerOrders(
49 | $endCursor: String
50 | $first: Int
51 | $last: Int
52 | $startCursor: String
53 | ) {
54 | customer {
55 | ...CustomerOrders
56 | }
57 | }
58 | ` as const;
59 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/graphql/customer-account/CustomerUpdateMutation.ts:
--------------------------------------------------------------------------------
1 | export const CUSTOMER_UPDATE_MUTATION = `#graphql
2 | # https://shopify.dev/docs/api/customer/latest/mutations/customerUpdate
3 | mutation customerUpdate(
4 | $customer: CustomerUpdateInput!
5 | ){
6 | customerUpdate(input: $customer) {
7 | customer {
8 | firstName
9 | lastName
10 | emailAddress {
11 | emailAddress
12 | }
13 | phoneNumber {
14 | phoneNumber
15 | }
16 | }
17 | userErrors {
18 | code
19 | field
20 | message
21 | }
22 | }
23 | }
24 | ` as const;
25 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/lib/fragments.ts:
--------------------------------------------------------------------------------
1 | // NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
2 | export const CART_QUERY_FRAGMENT = `#graphql
3 | fragment Money on MoneyV2 {
4 | currencyCode
5 | amount
6 | }
7 | fragment CartLine on CartLine {
8 | id
9 | quantity
10 | attributes {
11 | key
12 | value
13 | }
14 | cost {
15 | totalAmount {
16 | ...Money
17 | }
18 | amountPerQuantity {
19 | ...Money
20 | }
21 | compareAtAmountPerQuantity {
22 | ...Money
23 | }
24 | }
25 | merchandise {
26 | ... on ProductVariant {
27 | id
28 | availableForSale
29 | compareAtPrice {
30 | ...Money
31 | }
32 | price {
33 | ...Money
34 | }
35 | requiresShipping
36 | title
37 | image {
38 | id
39 | url
40 | altText
41 | width
42 | height
43 |
44 | }
45 | product {
46 | handle
47 | title
48 | id
49 | vendor
50 | }
51 | selectedOptions {
52 | name
53 | value
54 | }
55 | }
56 | }
57 | }
58 | fragment CartApiQuery on Cart {
59 | updatedAt
60 | id
61 | checkoutUrl
62 | totalQuantity
63 | buyerIdentity {
64 | countryCode
65 | customer {
66 | id
67 | email
68 | firstName
69 | lastName
70 | displayName
71 | }
72 | email
73 | phone
74 | }
75 | lines(first: $numCartLines) {
76 | nodes {
77 | ...CartLine
78 | }
79 | }
80 | cost {
81 | subtotalAmount {
82 | ...Money
83 | }
84 | totalAmount {
85 | ...Money
86 | }
87 | totalDutyAmount {
88 | ...Money
89 | }
90 | totalTaxAmount {
91 | ...Money
92 | }
93 | }
94 | note
95 | attributes {
96 | key
97 | value
98 | }
99 | discountCodes {
100 | code
101 | applicable
102 | }
103 | }
104 | ` as const;
105 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/lib/root-data.ts:
--------------------------------------------------------------------------------
1 | import {useMatches} from '@remix-run/react';
2 | import type {SerializeFrom} from '@shopify/remix-oxygen';
3 | import type {loader} from '~/root';
4 |
5 | /**
6 | * Access the result of the root loader from a React component.
7 | */
8 | export function useRootLoaderData() {
9 | const [root] = useMatches();
10 | return root?.data as SerializeFrom;
11 | }
12 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/lib/search.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | PredictiveQueryFragment,
3 | SearchProductFragment,
4 | PredictiveProductFragment,
5 | PredictiveCollectionFragment,
6 | PredictivePageFragment,
7 | PredictiveArticleFragment,
8 | } from 'storefrontapi.generated';
9 |
10 | export function applyTrackingParams(
11 | resource:
12 | | PredictiveQueryFragment
13 | | SearchProductFragment
14 | | PredictiveProductFragment
15 | | PredictiveCollectionFragment
16 | | PredictiveArticleFragment
17 | | PredictivePageFragment,
18 | params?: string,
19 | ) {
20 | if (params) {
21 | return resource?.trackingParameters
22 | ? `?${params}&${resource.trackingParameters}`
23 | : `?${params}`;
24 | } else {
25 | return resource?.trackingParameters
26 | ? `?${resource.trackingParameters}`
27 | : '';
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/lib/session.ts:
--------------------------------------------------------------------------------
1 | import type {HydrogenSession} from '@shopify/hydrogen';
2 | import {
3 | createCookieSessionStorage,
4 | type SessionStorage,
5 | type Session,
6 | } from '@shopify/remix-oxygen';
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 | #sessionStorage;
15 | #session;
16 |
17 | constructor(sessionStorage: SessionStorage, session: Session) {
18 | this.#sessionStorage = sessionStorage;
19 | this.#session = session;
20 | }
21 |
22 | static async init(request: Request, secrets: string[]) {
23 | const storage = createCookieSessionStorage({
24 | cookie: {
25 | name: 'session',
26 | httpOnly: true,
27 | path: '/',
28 | sameSite: 'lax',
29 | secrets,
30 | },
31 | });
32 |
33 | const session = await storage
34 | .getSession(request.headers.get('Cookie'))
35 | .catch(() => storage.getSession());
36 |
37 | return new this(storage, session);
38 | }
39 |
40 | get has() {
41 | return this.#session.has;
42 | }
43 |
44 | get get() {
45 | return this.#session.get;
46 | }
47 |
48 | get flash() {
49 | return this.#session.flash;
50 | }
51 |
52 | get unset() {
53 | return this.#session.unset;
54 | }
55 |
56 | get set() {
57 | return this.#session.set;
58 | }
59 |
60 | destroy() {
61 | return this.#sessionStorage.destroySession(this.#session);
62 | }
63 |
64 | commit() {
65 | return this.#sessionStorage.commitSession(this.#session);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/lib/variants.ts:
--------------------------------------------------------------------------------
1 | import {useLocation} from '@remix-run/react';
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 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/$.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({request}: LoaderFunctionArgs) {
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 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/account.$.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | // fallback wild card for all unauthenticated routes in account section
4 | export async function loader({context}: LoaderFunctionArgs) {
5 | await context.customerAccount.handleAuthStatus();
6 |
7 | return redirect('/account', {
8 | headers: {
9 | 'Set-Cookie': await context.session.commit(),
10 | },
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/account._index.tsx:
--------------------------------------------------------------------------------
1 | import {redirect} from '@shopify/remix-oxygen';
2 |
3 | export async function loader() {
4 | return redirect('/account/orders');
5 | }
6 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/account.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Form, NavLink, Outlet, useLoaderData} from '@remix-run/react';
3 | import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery';
4 |
5 | export function shouldRevalidate() {
6 | return true;
7 | }
8 |
9 | export async function loader({context}: LoaderFunctionArgs) {
10 | const {data, errors} = await context.customerAccount.query(
11 | CUSTOMER_DETAILS_QUERY,
12 | );
13 |
14 | if (errors?.length || !data?.customer) {
15 | throw new Error('Customer not found');
16 | }
17 |
18 | return json(
19 | {customer: data.customer},
20 | {
21 | headers: {
22 | 'Cache-Control': 'no-cache, no-store, must-revalidate',
23 | 'Set-Cookie': await context.session.commit(),
24 | },
25 | },
26 | );
27 | }
28 |
29 | export default function AccountLayout() {
30 | const {customer} = useLoaderData();
31 |
32 | const heading = customer
33 | ? customer.firstName
34 | ? `Welcome, ${customer.firstName}`
35 | : `Welcome to your account.`
36 | : 'Account Details';
37 |
38 | return (
39 |
40 |
{heading}
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | function AccountMenu() {
51 | function isActiveStyle({
52 | isActive,
53 | isPending,
54 | }: {
55 | isActive: boolean;
56 | isPending: boolean;
57 | }) {
58 | return {
59 | fontWeight: isActive ? 'bold' : undefined,
60 | color: isPending ? 'grey' : 'black',
61 | };
62 | }
63 |
64 | return (
65 |
80 | );
81 | }
82 |
83 | function Logout() {
84 | return (
85 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/account_.authorize.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({context}: LoaderFunctionArgs) {
4 | return context.customerAccount.authorize();
5 | }
6 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/account_.login.tsx:
--------------------------------------------------------------------------------
1 | import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | export async function loader({request, context}: LoaderFunctionArgs) {
4 | return context.customerAccount.login();
5 | }
6 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/account_.logout.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type ActionFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | // if we dont implement this, /account/logout will get caught by account.$.tsx to do login
4 | export async function loader() {
5 | return redirect('/');
6 | }
7 |
8 | export async function action({context}: ActionFunctionArgs) {
9 | return context.customerAccount.logout();
10 | }
11 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/blogs.$blogHandle.$articleHandle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {Image} from '@shopify/hydrogen';
4 |
5 | export const meta: MetaFunction = ({data}) => {
6 | return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}];
7 | };
8 |
9 | export async function loader({params, context}: LoaderFunctionArgs) {
10 | const {blogHandle, articleHandle} = params;
11 |
12 | if (!articleHandle || !blogHandle) {
13 | throw new Response('Not found', {status: 404});
14 | }
15 |
16 | const {blog} = await context.storefront.query(ARTICLE_QUERY, {
17 | variables: {blogHandle, articleHandle},
18 | });
19 |
20 | if (!blog?.articleByHandle) {
21 | throw new Response(null, {status: 404});
22 | }
23 |
24 | const article = blog.articleByHandle;
25 |
26 | return json({article});
27 | }
28 |
29 | export default function Article() {
30 | const {article} = useLoaderData();
31 | const {title, image, contentHtml, author} = article;
32 |
33 | const publishedDate = new Intl.DateTimeFormat('en-US', {
34 | year: 'numeric',
35 | month: 'long',
36 | day: 'numeric',
37 | }).format(new Date(article.publishedAt));
38 |
39 | return (
40 |
41 |
42 | {title}
43 |
44 | {publishedDate} · {author?.name}
45 |
46 |
47 |
48 | {image &&
}
49 |
53 |
54 | );
55 | }
56 |
57 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog#field-blog-articlebyhandle
58 | const ARTICLE_QUERY = `#graphql
59 | query Article(
60 | $articleHandle: String!
61 | $blogHandle: String!
62 | $country: CountryCode
63 | $language: LanguageCode
64 | ) @inContext(language: $language, country: $country) {
65 | blog(handle: $blogHandle) {
66 | articleByHandle(handle: $articleHandle) {
67 | title
68 | contentHtml
69 | publishedAt
70 | author: authorV2 {
71 | name
72 | }
73 | image {
74 | id
75 | altText
76 | url
77 | width
78 | height
79 | }
80 | seo {
81 | description
82 | title
83 | }
84 | }
85 | }
86 | }
87 | ` as const;
88 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/blogs._index.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {Pagination, getPaginationVariables} from '@shopify/hydrogen';
4 |
5 | export const meta: MetaFunction = () => {
6 | return [{title: `Hydrogen | Blogs`}];
7 | };
8 |
9 | export const loader = async ({
10 | request,
11 | context: {storefront},
12 | }: LoaderFunctionArgs) => {
13 | const paginationVariables = getPaginationVariables(request, {
14 | pageBy: 10,
15 | });
16 |
17 | const {blogs} = await storefront.query(BLOGS_QUERY, {
18 | variables: {
19 | ...paginationVariables,
20 | },
21 | });
22 |
23 | return json({blogs});
24 | };
25 |
26 | export default function Blogs() {
27 | const {blogs} = useLoaderData();
28 |
29 | return (
30 |
31 |
Blogs
32 |
33 |
34 | {({nodes, isLoading, PreviousLink, NextLink}) => {
35 | return (
36 | <>
37 |
38 | {isLoading ? 'Loading...' : ↑ Load previous}
39 |
40 | {nodes.map((blog) => {
41 | return (
42 |
48 | {blog.title}
49 |
50 | );
51 | })}
52 |
53 | {isLoading ? 'Loading...' : Load more ↓}
54 |
55 | >
56 | );
57 | }}
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
65 | const BLOGS_QUERY = `#graphql
66 | query Blogs(
67 | $country: CountryCode
68 | $endCursor: String
69 | $first: Int
70 | $language: LanguageCode
71 | $last: Int
72 | $startCursor: String
73 | ) @inContext(country: $country, language: $language) {
74 | blogs(
75 | first: $first,
76 | last: $last,
77 | before: $startCursor,
78 | after: $endCursor
79 | ) {
80 | pageInfo {
81 | hasNextPage
82 | hasPreviousPage
83 | startCursor
84 | endCursor
85 | }
86 | nodes {
87 | title
88 | handle
89 | seo {
90 | title
91 | description
92 | }
93 | }
94 | }
95 | }
96 | ` as const;
97 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/cart.$lines.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | /**
4 | * Automatically creates a new cart based on the URL and redirects straight to checkout.
5 | * Expected URL structure:
6 | * ```js
7 | * /cart/:
8 | *
9 | * ```
10 | *
11 | * More than one `:` separated by a comma, can be supplied in the URL, for
12 | * carts with more than one product variant.
13 | *
14 | * @example
15 | * Example path creating a cart with two product variants, different quantities, and a discount code in the querystring:
16 | * ```js
17 | * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
18 | *
19 | * ```
20 | */
21 | export async function loader({request, context, params}: LoaderFunctionArgs) {
22 | const {cart} = context;
23 | const {lines} = params;
24 | if (!lines) return redirect('/cart');
25 | const linesMap = lines.split(',').map((line) => {
26 | const lineDetails = line.split(':');
27 | const variantId = lineDetails[0];
28 | const quantity = parseInt(lineDetails[1], 10);
29 |
30 | return {
31 | merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
32 | quantity,
33 | };
34 | });
35 |
36 | const url = new URL(request.url);
37 | const searchParams = new URLSearchParams(url.search);
38 |
39 | const discount = searchParams.get('discount');
40 | const discountArray = discount ? [discount] : [];
41 |
42 | // create a cart
43 | const result = await cart.create({
44 | lines: linesMap,
45 | discountCodes: discountArray,
46 | });
47 |
48 | const cartResult = result.cart;
49 |
50 | if (result.errors?.length || !cartResult) {
51 | throw new Response('Link may be expired. Try checking the URL.', {
52 | status: 410,
53 | });
54 | }
55 |
56 | // Update cart id in cookie
57 | const headers = cart.setCartId(cartResult.id);
58 |
59 | // redirect to checkout
60 | if (cartResult.checkoutUrl) {
61 | return redirect(cartResult.checkoutUrl, {headers});
62 | } else {
63 | throw new Error('No checkout URL found');
64 | }
65 | }
66 |
67 | export default function Component() {
68 | return null;
69 | }
70 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/cart.tsx:
--------------------------------------------------------------------------------
1 | import {Await, type MetaFunction} from '@remix-run/react';
2 | import {Suspense} from 'react';
3 | import type {CartQueryDataReturn} from '@shopify/hydrogen';
4 | import {CartForm} from '@shopify/hydrogen';
5 | import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen';
6 | import {CartMain} from '~/components/Cart';
7 | import {useRootLoaderData} from '~/lib/root-data';
8 |
9 | export const meta: MetaFunction = () => {
10 | return [{title: `Hydrogen | Cart`}];
11 | };
12 |
13 | export async function action({request, context}: ActionFunctionArgs) {
14 | const {cart} = context;
15 |
16 | const formData = await request.formData();
17 |
18 | const {action, inputs} = CartForm.getFormInput(formData);
19 |
20 | if (!action) {
21 | throw new Error('No action provided');
22 | }
23 |
24 | let status = 200;
25 | let result: CartQueryDataReturn;
26 |
27 | switch (action) {
28 | case CartForm.ACTIONS.LinesAdd:
29 | result = await cart.addLines(inputs.lines);
30 | break;
31 | case CartForm.ACTIONS.LinesUpdate:
32 | result = await cart.updateLines(inputs.lines);
33 | break;
34 | case CartForm.ACTIONS.LinesRemove:
35 | result = await cart.removeLines(inputs.lineIds);
36 | break;
37 | case CartForm.ACTIONS.DiscountCodesUpdate: {
38 | const formDiscountCode = inputs.discountCode;
39 |
40 | // User inputted discount code
41 | const discountCodes = (
42 | formDiscountCode ? [formDiscountCode] : []
43 | ) as string[];
44 |
45 | // Combine discount codes already applied on cart
46 | discountCodes.push(...inputs.discountCodes);
47 |
48 | result = await cart.updateDiscountCodes(discountCodes);
49 | break;
50 | }
51 | case CartForm.ACTIONS.BuyerIdentityUpdate: {
52 | result = await cart.updateBuyerIdentity({
53 | ...inputs.buyerIdentity,
54 | });
55 | break;
56 | }
57 | default:
58 | throw new Error(`${action} cart action is not defined`);
59 | }
60 |
61 | const cartId = result.cart.id;
62 | const headers = cart.setCartId(result.cart.id);
63 | const {cart: cartResult, errors} = result;
64 |
65 | const redirectTo = formData.get('redirectTo') ?? null;
66 | if (typeof redirectTo === 'string') {
67 | status = 303;
68 | headers.set('Location', redirectTo);
69 | }
70 |
71 | headers.append('Set-Cookie', await context.session.commit());
72 |
73 | return json(
74 | {
75 | cart: cartResult,
76 | errors,
77 | analytics: {
78 | cartId,
79 | },
80 | },
81 | {status, headers},
82 | );
83 | }
84 |
85 | export default function Cart() {
86 | const rootData = useRootLoaderData();
87 | const cartPromise = rootData.cart;
88 |
89 | return (
90 |
91 |
Cart
92 |
Loading cart ...}>
93 | An error occurred }
96 | >
97 | {(cart) => {
98 | return ;
99 | }}
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/collections._index.tsx:
--------------------------------------------------------------------------------
1 | import {useLoaderData, Link} from '@remix-run/react';
2 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
3 | import {Pagination, getPaginationVariables, Image} from '@shopify/hydrogen';
4 | import type {CollectionFragment} from 'storefrontapi.generated';
5 |
6 | export async function loader({context, request}: LoaderFunctionArgs) {
7 | const paginationVariables = getPaginationVariables(request, {
8 | pageBy: 4,
9 | });
10 |
11 | const {collections} = await context.storefront.query(COLLECTIONS_QUERY, {
12 | variables: paginationVariables,
13 | });
14 |
15 | return json({collections});
16 | }
17 |
18 | export default function Collections() {
19 | const {collections} = useLoaderData();
20 |
21 | return (
22 |
23 |
Collections
24 |
25 | {({nodes, isLoading, PreviousLink, NextLink}) => (
26 |
27 |
28 | {isLoading ? 'Loading...' : ↑ Load previous}
29 |
30 |
31 |
32 | {isLoading ? 'Loading...' : Load more ↓}
33 |
34 |
35 | )}
36 |
37 |
38 | );
39 | }
40 |
41 | function CollectionsGrid({collections}: {collections: CollectionFragment[]}) {
42 | return (
43 |
44 | {collections.map((collection, index) => (
45 |
50 | ))}
51 |
52 | );
53 | }
54 |
55 | function CollectionItem({
56 | collection,
57 | index,
58 | }: {
59 | collection: CollectionFragment;
60 | index: number;
61 | }) {
62 | return (
63 |
69 | {collection?.image && (
70 |
76 | )}
77 | {collection.title}
78 |
79 | );
80 | }
81 |
82 | const COLLECTIONS_QUERY = `#graphql
83 | fragment Collection on Collection {
84 | id
85 | title
86 | handle
87 | image {
88 | id
89 | url
90 | altText
91 | width
92 | height
93 | }
94 | }
95 | query StoreCollections(
96 | $country: CountryCode
97 | $endCursor: String
98 | $first: Int
99 | $language: LanguageCode
100 | $last: Int
101 | $startCursor: String
102 | ) @inContext(country: $country, language: $language) {
103 | collections(
104 | first: $first,
105 | last: $last,
106 | before: $startCursor,
107 | after: $endCursor
108 | ) {
109 | nodes {
110 | ...Collection
111 | }
112 | pageInfo {
113 | hasNextPage
114 | hasPreviousPage
115 | startCursor
116 | endCursor
117 | }
118 | }
119 | }
120 | ` as const;
121 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/discount.$code.tsx:
--------------------------------------------------------------------------------
1 | import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 |
3 | /**
4 | * Automatically applies a discount found on the url
5 | * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
6 | *
7 | * @example
8 | * Example path applying a discount and optional redirecting (defaults to the home page)
9 | * ```js
10 | * /discount/FREESHIPPING?redirect=/products
11 | *
12 | * ```
13 | */
14 | export async function loader({request, context, params}: LoaderFunctionArgs) {
15 | const {cart} = context;
16 | const {code} = params;
17 |
18 | const url = new URL(request.url);
19 | const searchParams = new URLSearchParams(url.search);
20 | let redirectParam =
21 | searchParams.get('redirect') || searchParams.get('return_to') || '/';
22 |
23 | if (redirectParam.includes('//')) {
24 | // Avoid redirecting to external URLs to prevent phishing attacks
25 | redirectParam = '/';
26 | }
27 |
28 | searchParams.delete('redirect');
29 | searchParams.delete('return_to');
30 |
31 | const redirectUrl = `${redirectParam}?${searchParams}`;
32 |
33 | if (!code) {
34 | return redirect(redirectUrl);
35 | }
36 |
37 | const result = await cart.updateDiscountCodes([code]);
38 | const headers = cart.setCartId(result.cart.id);
39 |
40 | // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
41 | // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
42 | // on localhost:3000
43 | return redirect(redirectUrl, {
44 | status: 303,
45 | headers,
46 | });
47 | }
48 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/pages.$handle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, type MetaFunction} from '@remix-run/react';
3 |
4 | export const meta: MetaFunction = ({data}) => {
5 | return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
6 | };
7 |
8 | export async function loader({params, context}: LoaderFunctionArgs) {
9 | if (!params.handle) {
10 | throw new Error('Missing page handle');
11 | }
12 |
13 | const {page} = await context.storefront.query(PAGE_QUERY, {
14 | variables: {
15 | handle: params.handle,
16 | },
17 | });
18 |
19 | if (!page) {
20 | throw new Response('Not Found', {status: 404});
21 | }
22 |
23 | return json({page});
24 | }
25 |
26 | export default function Page() {
27 | const {page} = useLoaderData();
28 |
29 | return (
30 |
31 |
34 |
35 |
36 | );
37 | }
38 |
39 | const PAGE_QUERY = `#graphql
40 | query Page(
41 | $language: LanguageCode,
42 | $country: CountryCode,
43 | $handle: String!
44 | )
45 | @inContext(language: $language, country: $country) {
46 | page(handle: $handle) {
47 | id
48 | title
49 | body
50 | seo {
51 | description
52 | title
53 | }
54 | }
55 | }
56 | ` as const;
57 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/policies.$handle.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
3 | import {type Shop} from '@shopify/hydrogen/storefront-api-types';
4 |
5 | type SelectedPolicies = keyof Pick<
6 | Shop,
7 | 'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy'
8 | >;
9 |
10 | export const meta: MetaFunction = ({data}) => {
11 | return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}];
12 | };
13 |
14 | export async function loader({params, context}: LoaderFunctionArgs) {
15 | if (!params.handle) {
16 | throw new Response('No handle was passed in', {status: 404});
17 | }
18 |
19 | const policyName = params.handle.replace(
20 | /-([a-z])/g,
21 | (_: unknown, m1: string) => m1.toUpperCase(),
22 | ) as SelectedPolicies;
23 |
24 | const data = await context.storefront.query(POLICY_CONTENT_QUERY, {
25 | variables: {
26 | privacyPolicy: false,
27 | shippingPolicy: false,
28 | termsOfService: false,
29 | refundPolicy: false,
30 | [policyName]: true,
31 | language: context.storefront.i18n?.language,
32 | },
33 | });
34 |
35 | const policy = data.shop?.[policyName];
36 |
37 | if (!policy) {
38 | throw new Response('Could not find the policy', {status: 404});
39 | }
40 |
41 | return json({policy});
42 | }
43 |
44 | export default function Policy() {
45 | const {policy} = useLoaderData();
46 |
47 | return (
48 |
49 |
50 |
51 |
52 | ← Back to Policies
53 |
54 |
55 |
{policy.title}
56 |
57 |
58 | );
59 | }
60 |
61 | // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Shop
62 | const POLICY_CONTENT_QUERY = `#graphql
63 | fragment Policy on ShopPolicy {
64 | body
65 | handle
66 | id
67 | title
68 | url
69 | }
70 | query Policy(
71 | $country: CountryCode
72 | $language: LanguageCode
73 | $privacyPolicy: Boolean!
74 | $refundPolicy: Boolean!
75 | $shippingPolicy: Boolean!
76 | $termsOfService: Boolean!
77 | ) @inContext(language: $language, country: $country) {
78 | shop {
79 | privacyPolicy @include(if: $privacyPolicy) {
80 | ...Policy
81 | }
82 | shippingPolicy @include(if: $shippingPolicy) {
83 | ...Policy
84 | }
85 | termsOfService @include(if: $termsOfService) {
86 | ...Policy
87 | }
88 | refundPolicy @include(if: $refundPolicy) {
89 | ...Policy
90 | }
91 | }
92 | }
93 | ` as const;
94 |
--------------------------------------------------------------------------------
/examples/vite-storefront/app/routes/policies._index.tsx:
--------------------------------------------------------------------------------
1 | import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2 | import {useLoaderData, Link} from '@remix-run/react';
3 |
4 | export async function loader({context}: LoaderFunctionArgs) {
5 | const data = await context.storefront.query(POLICIES_QUERY);
6 | const policies = Object.values(data.shop || {});
7 |
8 | if (!policies.length) {
9 | throw new Response('No policies found', {status: 404});
10 | }
11 |
12 | return json({policies});
13 | }
14 |
15 | export default function Policies() {
16 | const {policies} = useLoaderData();
17 |
18 | return (
19 |
20 |
Policies
21 |
22 | {policies.map((policy) => {
23 | if (!policy) return null;
24 | return (
25 |
28 | );
29 | })}
30 |
31 |
32 | );
33 | }
34 |
35 | const POLICIES_QUERY = `#graphql
36 | fragment PolicyItem on ShopPolicy {
37 | id
38 | title
39 | handle
40 | }
41 | query Policies ($country: CountryCode, $language: LanguageCode)
42 | @inContext(country: $country, language: $language) {
43 | shop {
44 | privacyPolicy {
45 | ...PolicyItem
46 | }
47 | shippingPolicy {
48 | ...PolicyItem
49 | }
50 | termsOfService {
51 | ...PolicyItem
52 | }
53 | refundPolicy {
54 | ...PolicyItem
55 | }
56 | subscriptionPolicy {
57 | id
58 | title
59 | handle
60 | }
61 | }
62 | }
63 | ` as const;
64 |
--------------------------------------------------------------------------------
/examples/vite-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 |
--------------------------------------------------------------------------------
/examples/vite-storefront/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // Enhance TypeScript's built-in typings.
6 | import '@total-typescript/ts-reset';
7 |
8 | import type {
9 | Storefront,
10 | CustomerAccount,
11 | HydrogenCart,
12 | HydrogenSessionData,
13 | } from '@shopify/hydrogen';
14 | import type {AppSession} from '~/lib/session';
15 |
16 | declare global {
17 | /**
18 | * A global `process` object is only available during build to access NODE_ENV.
19 | */
20 | const process: {env: {NODE_ENV: 'production' | 'development'}};
21 |
22 | /**
23 | * Declare expected Env parameter in fetch handler.
24 | */
25 | interface Env {
26 | SESSION_SECRET: string;
27 | PUBLIC_STOREFRONT_API_TOKEN: string;
28 | PRIVATE_STOREFRONT_API_TOKEN: string;
29 | PUBLIC_STORE_DOMAIN: string;
30 | PUBLIC_STOREFRONT_ID: string;
31 | PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string;
32 | PUBLIC_CUSTOMER_ACCOUNT_API_URL: string;
33 |
34 | PUBLIC_SANITY_PROJECT_ID: string;
35 | PUBLIC_SANITY_DATASET?: string;
36 | PUBLIC_SANITY_API_VERSION?: string;
37 | SANITY_PREVIEW_TOKEN: string;
38 | }
39 | }
40 |
41 | declare module '@shopify/remix-oxygen' {
42 | /**
43 | * Declare local additions to the Remix loader context.
44 | */
45 | interface AppLoadContext {
46 | env: Env;
47 | cart: HydrogenCart;
48 | storefront: Storefront;
49 | customerAccount: CustomerAccount;
50 | session: AppSession;
51 | waitUntil: ExecutionContext['waitUntil'];
52 | }
53 |
54 | /**
55 | * Declare local additions to the Remix session data.
56 | */
57 | interface SessionData extends HydrogenSessionData {}
58 | }
59 |
--------------------------------------------------------------------------------
/examples/vite-storefront/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-storefront",
3 | "private": true,
4 | "sideEffects": false,
5 | "version": "2024.4.4",
6 | "type": "module",
7 | "scripts": {
8 | "build": "shopify hydrogen build --codegen",
9 | "dev": "shopify hydrogen dev --codegen",
10 | "preview": "npm run build && shopify hydrogen preview",
11 | "lint": "eslint --cache --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
12 | "typecheck": "tsc --noEmit",
13 | "codegen": "shopify hydrogen codegen"
14 | },
15 | "prettier": "@shopify/prettier-config",
16 | "dependencies": {
17 | "@remix-run/react": "^2.8.0",
18 | "@remix-run/server-runtime": "^2.8.0",
19 | "@shopify/cli": "3.59.2",
20 | "@shopify/cli-hydrogen": "^8.0.4",
21 | "@shopify/hydrogen": "2024.4.2",
22 | "@shopify/remix-oxygen": "^2.0.4",
23 | "graphql": "^16.6.0",
24 | "graphql-tag": "^2.12.6",
25 | "hydrogen-sanity": "^4.0.5",
26 | "isbot": "^3.8.0",
27 | "react": "^18.2.0",
28 | "react-dom": "^18.2.0"
29 | },
30 | "devDependencies": {
31 | "@graphql-codegen/cli": "5.0.2",
32 | "@remix-run/dev": "^2.8.0",
33 | "@remix-run/eslint-config": "^2.8.0",
34 | "@shopify/hydrogen-codegen": "^0.3.1",
35 | "@shopify/mini-oxygen": "^3.0.2",
36 | "@shopify/oxygen-workers-types": "^4.0.0",
37 | "@shopify/prettier-config": "^1.1.2",
38 | "@total-typescript/ts-reset": "^0.4.2",
39 | "@types/eslint": "^8.4.10",
40 | "@types/react": "^18.2.22",
41 | "@types/react-dom": "^18.2.7",
42 | "eslint": "^8.20.0",
43 | "eslint-plugin-hydrogen": "0.12.2",
44 | "prettier": "^2.8.4",
45 | "typescript": "^5.2.2",
46 | "vite": "^5.1.0",
47 | "vite-tsconfig-paths": "^4.3.1"
48 | },
49 | "engines": {
50 | "node": ">=18.0.0"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/examples/vite-storefront/public/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/hydrogen-sanity/b6d75fb49e23ea5646e3fd2b6c72001de9ff592c/examples/vite-storefront/public/.gitkeep
--------------------------------------------------------------------------------
/examples/vite-storefront/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "Bundler",
9 | "resolveJsonModule": true,
10 | "module": "ES2022",
11 | "target": "ES2022",
12 | "strict": true,
13 | "allowJs": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "skipLibCheck": true,
16 | "baseUrl": ".",
17 | "types": ["@shopify/oxygen-workers-types"],
18 | "paths": {
19 | "~/*": ["app/*"]
20 | },
21 | "noEmit": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/examples/vite-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 {vitePlugin as remix} from '@remix-run/dev';
5 | import tsconfigPaths from 'vite-tsconfig-paths';
6 |
7 | export default defineConfig({
8 | plugins: [
9 | hydrogen(),
10 | oxygen(),
11 | remix({
12 | presets: [hydrogen.preset()],
13 | future: {
14 | v3_fetcherPersist: true,
15 | v3_relativeSplatPath: true,
16 | v3_throwAbortReason: true,
17 | },
18 | }),
19 | tsconfigPaths(),
20 | ],
21 | build: {
22 | // Allow a strict Content-Security-Policy
23 | // withtout inlining assets as base64:
24 | assetsInlineLimit: 0,
25 | },
26 | });
27 |
--------------------------------------------------------------------------------
/lint-staged.base.js:
--------------------------------------------------------------------------------
1 | export const format = 'prettier --cache --write --ignore-unknown'
2 |
3 | export const lint = 'eslint --cache --fix'
4 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | import {format} from './lint-staged.base.js'
2 |
3 | export default {
4 | '*': format,
5 | }
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "root",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "build": "turbo build --concurrency 1",
7 | "typecheck": "turbo lint --concurrency 1",
8 | "lint": "turbo lint --concurrency 1",
9 | "test": "vitest",
10 | "format": "prettier --cache --write --ignore-unknown ."
11 | },
12 | "devDependencies": {
13 | "@commitlint/cli": "^19.5.0",
14 | "@commitlint/config-conventional": "^19.5.0",
15 | "husky": "^9.1.6",
16 | "lint-staged": "^15.2.10",
17 | "prettier": "^3.3.3",
18 | "prettier-plugin-packagejson": "^2.5.3",
19 | "turbo": "^2.3.0",
20 | "vitest": "^2.1.5"
21 | },
22 | "packageManager": "pnpm@10.2.0+sha512.0d27364e0139c6aadeed65ada153135e0ca96c8da42123bd50047f961339dc7a758fc2e944b428f52be570d1bd3372455c1c65fa2e7aa0bfbf931190f9552001",
23 | "pnpm": {
24 | "overrides": {
25 | "hydrogen-sanity": "workspace:*"
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/package/.eslintignore:
--------------------------------------------------------------------------------
1 | .eslintrc.js
2 | commitlint.config.js
3 | dist
4 | lint-staged.config.js
5 | package.config.ts
6 |
--------------------------------------------------------------------------------
/package/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "plugins": ["prettier", "simple-import-sort"],
4 | "extends": [
5 | "sanity",
6 | "sanity/typescript",
7 | "sanity/react",
8 | "plugin:react-hooks/recommended",
9 | "plugin:prettier/recommended",
10 | "plugin:react/jsx-runtime"
11 | ],
12 | "rules": {
13 | "simple-import-sort/imports": "warn",
14 | "simple-import-sort/exports": "warn",
15 | "react-hooks/exhaustive-deps": "error"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/package/.releaserc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@sanity/semantic-release-preset",
3 | "branches": [
4 | "main",
5 | {
6 | "name": "beta",
7 | "prerelease": true
8 | },
9 | {
10 | "name": "next",
11 | "prerelease": true
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/package/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 |
--------------------------------------------------------------------------------
/package/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 |
--------------------------------------------------------------------------------
/package/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: true,
7 |
8 | // Remove this block to enable strict export validation
9 | extract: {
10 | rules: {
11 | 'ae-forgotten-export': 'off',
12 | 'ae-incompatible-release-tags': 'off',
13 | 'ae-internal-missing-underscore': 'off',
14 | 'ae-missing-release-tag': 'off',
15 | },
16 | },
17 | })
18 |
--------------------------------------------------------------------------------
/package/src/client.ts:
--------------------------------------------------------------------------------
1 | export type * from '@sanity/client'
2 | // TODO: just pass a client, and don't re-export?
3 | export {createClient, SanityClient} from '@sanity/client'
4 |
--------------------------------------------------------------------------------
/package/src/groq.ts:
--------------------------------------------------------------------------------
1 | // TODO: does this need to be re-exported?
2 | export {defineQuery, default as groq} from 'groq'
3 |
--------------------------------------------------------------------------------
/package/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './client'
2 | export {createSanityContext, type SanityContext} from './context'
3 | export {createSanityLoader, type SanityLoader} from './loader'
4 | // TODO: add default session?
5 |
--------------------------------------------------------------------------------
/package/src/preview/route.test.ts:
--------------------------------------------------------------------------------
1 | import type {HydrogenSession} from '@shopify/hydrogen'
2 | import {describe, expect, it, vi} from 'vitest'
3 |
4 | import {createSanityContext, type SanityContext} from '../context'
5 | import {action, loader} from './route'
6 |
7 | vi.mock('./client', {spy: true})
8 |
9 | type AppLoadContext = {
10 | session: HydrogenSession
11 | sanity: SanityContext
12 | }
13 |
14 | class Session implements HydrogenSession {
15 | #data = new Map()
16 |
17 | get(key: string) {
18 | return this.#data.get(key)
19 | }
20 |
21 | set(key: string, value: unknown) {
22 | this.#data.set(key, value)
23 | }
24 |
25 | unset(key: string) {
26 | this.#data.delete(key)
27 | }
28 |
29 | async commit() {
30 | return JSON.stringify(this.#data)
31 | }
32 | }
33 |
34 | describe('the preview route', () => {
35 | const request = new Request('https://example.com')
36 |
37 | const sanity = createSanityContext({
38 | request,
39 | client: {projectId: 'my-project-id', dataset: 'my-dataset'},
40 | preview: {
41 | enabled: true,
42 | token: 'my-token',
43 | studioUrl: 'https://example.com',
44 | },
45 | })
46 |
47 | const context: AppLoadContext = {
48 | session: new Session(),
49 | sanity,
50 | }
51 |
52 | it('should enable preview mode', async () => {
53 | const response = await loader({context, request, params: {}})
54 | expect(response).toBeInstanceOf(Response)
55 | })
56 |
57 | it('should disable preview mode', async () => {
58 | const response = await action({context, request, params: {}})
59 | expect(response).toBeInstanceOf(Response)
60 | })
61 | })
62 |
--------------------------------------------------------------------------------
/package/src/preview/route.ts:
--------------------------------------------------------------------------------
1 | import {validatePreviewUrl} from '@sanity/preview-url-secret'
2 | import type {HydrogenSession} from '@shopify/hydrogen'
3 | import {type ActionFunction, json, type LoaderFunction, redirect} from '@shopify/remix-oxygen'
4 |
5 | import type {SanityContext} from '../context'
6 | import {assertSession} from '../utils'
7 |
8 | /**
9 | * A `POST` request to this route will exit preview mode
10 | */
11 | export const action: ActionFunction = async ({context, request}) => {
12 | if (request.method !== 'POST') {
13 | return json({message: 'Method not allowed'}, 405)
14 | }
15 |
16 | try {
17 | const {session} = context
18 | if (!assertSession(session)) {
19 | throw new Error('Session is not an instance of HydrogenSession')
20 | }
21 |
22 | // TODO: make this a callback? `onExitPreview`?
23 | await session.unset('projectId')
24 |
25 | // TODO: confirm that the redirect and setting cookie has to happen here?
26 | return redirect('/')
27 | } catch (error) {
28 | console.error(error)
29 | throw new Response('Unable to disable preview mode. Please check your preview configuration', {
30 | status: 500,
31 | })
32 | }
33 | }
34 |
35 | /**
36 | * A `GET` request to this route will enter preview mode
37 | */
38 | export const loader: LoaderFunction = async ({context, request}) => {
39 | try {
40 | // TODO: to remove
41 | const {sanity, session} = context as {sanity: SanityContext; session: HydrogenSession}
42 | const projectId = sanity.client.config().projectId
43 |
44 | if (!sanity.preview) {
45 | return new Response('Preview mode is not enabled in this environment.', {status: 403})
46 | }
47 |
48 | if (!sanity.preview.token) {
49 | throw new Error('Enabling preview mode requires a token.')
50 | }
51 |
52 | if (!projectId) {
53 | throw new Error('No `projectId` found in the client config.')
54 | }
55 |
56 | if (!assertSession(session)) {
57 | throw new Error('Session is not an instance of HydrogenSession')
58 | }
59 |
60 | const clientWithToken = sanity.client.withConfig({
61 | useCdn: false,
62 | token: sanity.preview.token,
63 | })
64 |
65 | const {isValid, redirectTo = '/'} = await validatePreviewUrl(clientWithToken, request.url)
66 |
67 | if (!isValid) {
68 | return new Response('Invalid secret', {status: 401})
69 | }
70 |
71 | // TODO: make this a callback? `onEnterPreview`?
72 | await session.set('projectId', projectId)
73 |
74 | // TODO: confirm that the redirect and setting cookie has to happen here?
75 | return redirect(redirectTo)
76 | } catch (error) {
77 | console.error(error)
78 | throw new Response('Unable to enable preview mode. Please check your preview configuration', {
79 | status: 500,
80 | })
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/package/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type {HydrogenSession} from '@shopify/hydrogen'
2 |
3 | import type {QueryParams, QueryWithoutParams} from './client'
4 |
5 | /**
6 | * Create an SHA-256 hash as a hex string
7 | * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
8 | */
9 | export async function sha256(message: string): Promise {
10 | // encode as UTF-8
11 | const messageBuffer = await new TextEncoder().encode(message)
12 | // hash the message
13 | const hashBuffer = await crypto.subtle.digest('SHA-256', messageBuffer)
14 | // convert bytes to hex string
15 | return Array.from(new Uint8Array(hashBuffer))
16 | .map((b) => b.toString(16).padStart(2, '0'))
17 | .join('')
18 | }
19 |
20 | /**
21 | * Hash query and its parameters for use as cache key
22 | * NOTE: Oxygen deployment will break if the cache key is long or contains `\n`
23 | */
24 | export function hashQuery(
25 | query: string,
26 | params: QueryParams | QueryWithoutParams,
27 | ): Promise {
28 | let hash = query
29 |
30 | if (params) {
31 | hash += JSON.stringify(params)
32 | }
33 |
34 | return sha256(hash)
35 | }
36 |
37 | export function assertSession(session: unknown): session is HydrogenSession {
38 | return (
39 | !!session &&
40 | typeof session === 'object' &&
41 | 'get' in session &&
42 | typeof session.get === 'function' &&
43 | 'set' in session &&
44 | typeof session.set === 'function' &&
45 | 'unset' in session &&
46 | typeof session.unset === 'function' &&
47 | 'commit' in session &&
48 | typeof session.commit === 'function'
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/package/src/visual-editing/VisualEditing.client.tsx:
--------------------------------------------------------------------------------
1 | import {VisualEditing} from '@sanity/visual-editing/remix'
2 |
3 | /**
4 | * Prevent a consumer from importing into a worker/server bundle.
5 | */
6 | if (typeof document === 'undefined') {
7 | throw new Error(
8 | 'Visual editing should only run client-side. Please check that this file is not being imported into a worker or server bundle.',
9 | )
10 | }
11 |
12 | /**
13 | * Enables visual editing on the front-end
14 | * @see https://www.sanity.io/docs/introduction-to-visual-editing
15 | */
16 | export default VisualEditing
17 |
--------------------------------------------------------------------------------
/package/src/visual-editing/VisualEditing.tsx:
--------------------------------------------------------------------------------
1 | import type {VisualEditingProps} from '@sanity/visual-editing/remix'
2 | import {lazy, type ReactElement, Suspense} from 'react'
3 |
4 | /**
5 | * Provide a consistent fallback to prevent hydration mismatch errors.
6 | */
7 | function VisualEditingFallback(): ReactElement {
8 | return <>>
9 | }
10 |
11 | /**
12 | * If server-side rendering, then return the fallback instead of the heavy dependency.
13 | * @see https://remix.run/docs/en/1.14.3/guides/constraints#browser-only-code-on-the-server
14 | */
15 | const VisualEditingComponent =
16 | typeof document === 'undefined'
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 | export function VisualEditing(props: VisualEditingProps): ReactElement {
28 | return (
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/package/src/visual-editing/index.ts:
--------------------------------------------------------------------------------
1 | export {VisualEditing} from './VisualEditing'
2 | export {
3 | type CreateDataAttribute,
4 | createDataAttribute,
5 | type CreateDataAttributeProps,
6 | } from '@sanity/visual-editing/create-data-attribute'
7 |
--------------------------------------------------------------------------------
/package/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 |
--------------------------------------------------------------------------------
/package/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 |
--------------------------------------------------------------------------------
/package/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 | }
15 | }
16 |
--------------------------------------------------------------------------------
/package/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import {defineProject} from 'vitest/config'
3 | import GithubActionsReporter from 'vitest-github-actions-reporter'
4 |
5 | export default defineProject({
6 | test: {
7 | isolate: false,
8 | setupFiles: ['./vitest.setup.ts'],
9 | // Enable rich PR failed test annotation on the CI
10 | reporters: process.env.GITHUB_ACTIONS ? ['default', new GithubActionsReporter()] : 'default',
11 | },
12 | })
13 |
--------------------------------------------------------------------------------
/package/vitest.setup.ts:
--------------------------------------------------------------------------------
1 | import {webcrypto} from 'node:crypto'
2 |
3 | // eslint-disable-next-line no-undef
4 | Object.defineProperty(globalThis, 'crypto', {
5 | value: webcrypto,
6 | })
7 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'package'
3 | - 'examples/**'
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "tasks": {
4 | "build": {
5 | "outputs": ["dist"]
6 | },
7 | "lint": {
8 | "outputs": [".eslintcache"]
9 | },
10 | "typecheck": {
11 | "outputs": [".tsbuildinfo"]
12 | },
13 | "dev": {
14 | "cache": false,
15 | "persistent": true
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/vitest.workspace.ts:
--------------------------------------------------------------------------------
1 | import {defineWorkspace} from 'vitest/config'
2 |
3 | export default defineWorkspace(['package'])
4 |
--------------------------------------------------------------------------------