├── .devcontainer
└── devcontainer.json
├── .eslintrc.js
├── .gitignore
├── .vscode
└── extensions.json
├── README.md
├── hydrogen.config.js
├── index.html
├── jsconfig.json
├── netlify.toml
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
└── fonts
│ ├── IBMPlexSerif-Text.woff2
│ └── IBMPlexSerif-TextItalic.woff2
├── src
├── App.server.jsx
├── assets
│ └── favicon.svg
├── components
│ ├── CountrySelector.client.jsx
│ ├── CustomFont.client.jsx
│ ├── DefaultSeo.server.jsx
│ ├── EventsListener.client.jsx
│ ├── HeaderFallback.jsx
│ ├── account
│ │ ├── AccountActivateForm.client.jsx
│ │ ├── AccountAddressBook.client.jsx
│ │ ├── AccountAddressEdit.client.jsx
│ │ ├── AccountCreateForm.client.jsx
│ │ ├── AccountDeleteAddress.client.jsx
│ │ ├── AccountDetails.client.jsx
│ │ ├── AccountDetailsEdit.client.jsx
│ │ ├── AccountLoginForm.client.jsx
│ │ ├── AccountOrderHistory.client.jsx
│ │ ├── AccountPasswordResetForm.client.jsx
│ │ ├── AccountRecoverForm.client.jsx
│ │ └── index.js
│ ├── cards
│ │ ├── ArticleCard.jsx
│ │ ├── CollectionCard.server.jsx
│ │ ├── OrderCard.client.jsx
│ │ ├── ProductCard.client.jsx
│ │ ├── index.js
│ │ └── index.server.js
│ ├── cart
│ │ ├── CartDetails.client.jsx
│ │ ├── CartEmpty.client.jsx
│ │ ├── CartLineItem.client.jsx
│ │ └── index.js
│ ├── elements
│ │ ├── Button.jsx
│ │ ├── Grid.jsx
│ │ ├── Heading.jsx
│ │ ├── Icon.jsx
│ │ ├── Input.jsx
│ │ ├── LogoutButton.client.jsx
│ │ ├── Section.jsx
│ │ ├── Skeleton.jsx
│ │ ├── Text.jsx
│ │ └── index.js
│ ├── global
│ │ ├── CartDrawer.client.jsx
│ │ ├── Drawer.client.jsx
│ │ ├── Footer.server.jsx
│ │ ├── FooterMenu.client.jsx
│ │ ├── Header.client.jsx
│ │ ├── Layout.server.jsx
│ │ ├── MenuDrawer.client.jsx
│ │ ├── Modal.client.jsx
│ │ ├── NotFound.server.jsx
│ │ ├── PageHeader.jsx
│ │ ├── index.js
│ │ └── index.server.js
│ ├── index.js
│ ├── index.server.js
│ ├── product
│ │ ├── ProductDetail.client.jsx
│ │ ├── ProductForm.client.jsx
│ │ ├── ProductGallery.client.jsx
│ │ ├── ProductGrid.client.jsx
│ │ ├── ProductOptions.client.jsx
│ │ └── index.js
│ ├── search
│ │ ├── NoResultRecommendations.server.jsx
│ │ ├── SearchPage.server.jsx
│ │ └── index.server.js
│ └── sections
│ │ ├── FeaturedCollections.jsx
│ │ ├── Hero.jsx
│ │ ├── ProductCards.jsx
│ │ ├── ProductSwimlane.server.jsx
│ │ ├── index.js
│ │ └── index.server.js
├── lib
│ ├── const.js
│ ├── fragments.js
│ ├── index.js
│ ├── placeholders.js
│ ├── styleUtils.jsx
│ └── utils.js
├── routes
│ ├── account
│ │ ├── activate
│ │ │ ├── [id]
│ │ │ │ └── [activationToken].server.jsx
│ │ │ └── index.server.js
│ │ ├── address
│ │ │ ├── [addressId].server.js
│ │ │ └── index.server.js
│ │ ├── index.server.jsx
│ │ ├── login.server.jsx
│ │ ├── logout.server.js
│ │ ├── orders
│ │ │ └── [id].server.jsx
│ │ ├── recover.server.jsx
│ │ ├── register.server.jsx
│ │ └── reset
│ │ │ ├── [id]
│ │ │ └── [resetToken].server.jsx
│ │ │ └── index.server.js
│ ├── admin.server.jsx
│ ├── api
│ │ ├── bestSellers.server.js
│ │ └── countries.server.js
│ ├── cart.server.jsx
│ ├── collections
│ │ ├── [handle].server.jsx
│ │ ├── all.server.jsx
│ │ └── index.server.jsx
│ ├── index.server.jsx
│ ├── journal
│ │ ├── [handle].server.jsx
│ │ └── index.server.jsx
│ ├── pages
│ │ └── [handle].server.jsx
│ ├── policies
│ │ ├── [handle].server.jsx
│ │ └── index.server.jsx
│ ├── products
│ │ ├── [handle].server.jsx
│ │ └── index.server.jsx
│ ├── robots.txt.server.js
│ ├── search.server.jsx
│ └── sitemap.xml.server.js
└── styles
│ ├── custom-font.css
│ └── index.css
├── tailwind.config.js
├── tests
├── e2e
│ ├── collection.test.js
│ ├── index.test.js
│ └── product.test.js
└── utils.js
└── vite.config.js
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Shopify Hydrogen",
3 | "image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16",
4 | "settings": {},
5 | "extensions": [
6 | "graphql.vscode-graphql",
7 | "dbaeumer.vscode-eslint",
8 | "bradlc.vscode-tailwindcss",
9 | "esbenp.prettier-vscode"
10 | ],
11 | "forwardPorts": [3000],
12 | "postCreateCommand": "yarn install",
13 | "postStartCommand": "yarn dev",
14 | "remoteUser": "node",
15 | "features": {
16 | "git": "latest"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['plugin:hydrogen/recommended', 'plugin:hydrogen/typescript'],
3 | rules: {
4 | 'node/no-missing-import': 'off',
5 | '@typescript-eslint/ban-ts-comment': 'off',
6 | '@typescript-eslint/naming-convention': 'off',
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # THIS IS A STUB FOR NEW HYDROGEN APPS
2 | # THIS WILL EVENTUALLY MOVE TO A /TEMPLATE-* FOLDER
3 |
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 |
12 | # Diagnostic reports (https://nodejs.org/api/report.html)
13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
14 |
15 | # Runtime data
16 | pids
17 | *.pid
18 | *.seed
19 | *.pid.lock
20 |
21 | # Directory for instrumented libs generated by jscoverage/JSCover
22 | lib-cov
23 |
24 | # Coverage directory used by tools like istanbul
25 | coverage
26 | *.lcov
27 |
28 | # nyc test coverage
29 | .nyc_output
30 |
31 | # Compiled binary addons (https://nodejs.org/api/addons.html)
32 | build/Release
33 |
34 | # Dependency directories
35 | node_modules/
36 | jspm_packages/
37 |
38 | # TypeScript cache
39 | *.tsbuildinfo
40 |
41 | # Optional npm cache directory
42 | .npm
43 |
44 | # Optional eslint cache
45 | .eslintcache
46 |
47 | # Microbundle cache
48 | .rpt2_cache/
49 | .rts2_cache_cjs/
50 | .rts2_cache_es/
51 | .rts2_cache_umd/
52 |
53 | # Optional REPL history
54 | .node_repl_history
55 |
56 | # Output of 'npm pack'
57 | *.tgz
58 |
59 | # Yarn Integrity file
60 | .yarn-integrity
61 |
62 | # dotenv environment variables file
63 | .env
64 | .env.test
65 |
66 | # Serverless directories
67 | .serverless/
68 |
69 | # Stores VSCode versions used for testing VSCode extensions
70 | .vscode-test
71 |
72 | # yarn v2
73 | .yarn/cache
74 | .yarn/unplugged
75 | .yarn/build-state.yml
76 | .yarn/install-state.gz
77 | .pnp.*
78 |
79 | # Vite output
80 | dist
81 |
82 | .netlify
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "graphql.vscode-graphql",
4 | "dbaeumer.vscode-eslint",
5 | "esbenp.prettier-vscode",
6 | "bradlc.vscode-tailwindcss"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ⚠️ This template is deprecated. Please use [the new template](https://github.com/netlify/hydrogen-template)
2 |
3 | [Hydrogen](https://shopify.dev/custom-storefronts/hydrogen) is a React framework and Software Development Kit (SDK) that can be used to build fast and dynamic custom Shopify storefronts.
4 |
5 | This template will show you how to create a sample custom storefront that can be hosted on Netlify.
6 |
7 | ## Getting started
8 |
9 | **Requirements:**
10 |
11 | - [Node.js](https://nodejs.org/en/) version 16.5.0 or higher
12 |
13 | ### Running the dev server locally
14 |
15 | 1. Clone the repositoritory to your computer:
16 | ```bash
17 | git clone https://github.com/netlify/hydrogen-netlify-starter
18 | ```
19 |
20 | 2. Navigate to the repostitory folder:
21 | ```bash
22 | cd hydrogen-netlify-starter
23 | ```
24 |
25 | 3. Update [`hydrogen.config.js`](hydrogen.config.js) with your shop's domain (replace the `storeDomain` sample value) and [Storefront API token](https://shopify.dev/api/examples/storefront-api#step-2-generate-a-storefront-api-access-token) (replace the `storefrontToken` sample value).
26 |
27 | 4. Install the package dependencies:
28 | ```bash
29 | npm install
30 | ```
31 |
32 | 5. Start the application:
33 | ```bash
34 | npm run dev
35 | ```
36 |
37 | ### Building for production
38 |
39 | To build a production-ready instance of the application, run the following command:
40 |
41 | ```bash
42 | npm run build
43 | ```
44 |
45 | ### Previewing a production build
46 |
47 | To run a local preview of your Hydrogen app in an environment similar to Netlify:
48 |
49 | 1. Build your Hydrogen app:
50 | ```bash
51 | npm run build
52 | ```
53 |
54 | 2. Run the preview command:
55 | ```bash
56 | npm run preview
57 | ```
58 |
59 | ## Questions and troubleshooting
60 |
61 | If you found an issue with the code [in this repository](https://github.com/netlify/hydrogen-netlify-starter/), feel free to open an issue or let us know [in the Netlify Forums](https://answers.netlify.com/).
62 |
--------------------------------------------------------------------------------
/hydrogen.config.js:
--------------------------------------------------------------------------------
1 | import {defineConfig, CookieSessionStorage} from '@shopify/hydrogen/config';
2 |
3 | export default defineConfig({
4 | shopify: {
5 | defaultCountryCode: 'US',
6 | defaultLanguageCode: 'EN',
7 | storeDomain:
8 | // @ts-ignore
9 | Oxygen?.env?.PUBLIC_STORE_DOMAIN || 'hydrogen-preview.myshopify.com',
10 | storefrontToken:
11 | // @ts-ignore
12 | Oxygen?.env?.PUBLIC_STOREFRONT_API_TOKEN ||
13 | '3b580e70970c4528da70c98e097c2fa0',
14 | privateStorefrontToken:
15 | // @ts-ignore
16 | Oxygen?.env?.PRIVATE_STOREFRONT_API_TOKEN,
17 | storefrontApiVersion: '2022-07',
18 | // @ts-ignore
19 | storefrontId: Oxygen?.env?.PUBLIC_STOREFRONT_ID,
20 | },
21 | session: CookieSessionStorage('__session', {
22 | path: '/',
23 | httpOnly: true,
24 | secure: import.meta.env.PROD,
25 | sameSite: 'Strict',
26 | maxAge: 60 * 60 * 24 * 30,
27 | }),
28 | });
29 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Hydrogen
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "esnext",
5 | "moduleResolution": "node16",
6 | "lib": ["dom", "dom.iterable", "scripthost", "es2020"],
7 | "jsx": "react-jsx",
8 | "types": ["vite/client", "vitest/globals"]
9 | },
10 | "exclude": ["node_modules", "dist"],
11 | "include": ["**/*.js", "**/*.jsx"]
12 | }
13 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | publish = "dist/client"
3 | command = "npm run build"
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hydrogen-netlify-starter",
3 | "description": "Demo store template for @shopify/hydrogen",
4 | "version": "0.0.0",
5 | "license": "MIT",
6 | "private": true,
7 | "engines": {
8 | "node": ">=16.14.0"
9 | },
10 | "scripts": {
11 | "dev": "shopify hydrogen dev",
12 | "build": "shopify hydrogen build",
13 | "preview": "shopify hydrogen preview",
14 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src",
15 | "lint-ts": "tsc --noEmit",
16 | "test": "WATCH=true vitest",
17 | "test:ci": "yarn build -t node && vitest run"
18 | },
19 | "devDependencies": {
20 | "@netlify/hydrogen-platform": "^1.1.2",
21 | "@shopify/cli": "3.15.0",
22 | "@shopify/cli-hydrogen": "3.15.0",
23 | "@shopify/prettier-config": "^1.1.2",
24 | "@tailwindcss/forms": "^0.5.2",
25 | "@tailwindcss/typography": "^0.5.2",
26 | "eslint": "^8.18.0",
27 | "eslint-plugin-hydrogen": "^0.12.2",
28 | "playwright": "^1.22.2",
29 | "postcss": "^8.4.14",
30 | "postcss-import": "^14.1.0",
31 | "postcss-preset-env": "^7.6.0",
32 | "prettier": "^2.3.2",
33 | "tailwindcss": "^3.0.24",
34 | "vite": "^2.9.13",
35 | "vitest": "^0.15.2"
36 | },
37 | "prettier": "@shopify/prettier-config",
38 | "dependencies": {
39 | "@headlessui/react": "^1.7.0",
40 | "@shopify/hydrogen": "^1.4.3",
41 | "clsx": "^1.1.1",
42 | "graphql-tag": "^2.12.6",
43 | "react": "^18.2.0",
44 | "react-dom": "^18.2.0",
45 | "react-use": "^17.4.0",
46 | "typographic-base": "^1.0.4"
47 | },
48 | "author": "matt"
49 | }
50 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-import': {},
4 | 'tailwindcss/nesting': {},
5 | tailwindcss: {},
6 | 'postcss-preset-env': {
7 | features: {'nesting-rules': false},
8 | },
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/src/App.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import renderHydrogen from '@shopify/hydrogen/entry-server';
3 | import {
4 | FileRoutes,
5 | PerformanceMetrics,
6 | PerformanceMetricsDebug,
7 | Route,
8 | Router,
9 | ShopifyAnalytics,
10 | ShopifyProvider,
11 | CartProvider,
12 | } from '@shopify/hydrogen';
13 | import {HeaderFallback, EventsListener} from '~/components';
14 | import {DefaultSeo, NotFound} from '~/components/index.server';
15 |
16 | function App({request}) {
17 | const pathname = new URL(request.normalizedUrl).pathname;
18 | const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
19 | const countryCode = localeMatch ? localeMatch[1] : undefined;
20 |
21 | const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
22 |
23 | return (
24 | }>
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
35 | } />
36 |
37 |
38 |
39 | {import.meta.env.DEV && }
40 |
41 |
42 |
43 | );
44 | }
45 |
46 | export default renderHydrogen(App);
47 |
--------------------------------------------------------------------------------
/src/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
18 |
23 |
28 |
29 |
--------------------------------------------------------------------------------
/src/components/CountrySelector.client.jsx:
--------------------------------------------------------------------------------
1 | import {useCallback, useState, Suspense} from 'react';
2 | import {useLocalization, fetchSync} from '@shopify/hydrogen';
3 | // @ts-expect-error @headlessui/react incompatibility with node16 resolution
4 | import {Listbox} from '@headlessui/react';
5 |
6 | import {IconCheck, IconCaret} from '~/components';
7 | import {useMemo} from 'react';
8 |
9 | /**
10 | * A client component that selects the appropriate country to display for products on a website
11 | */
12 | export function CountrySelector() {
13 | const [listboxOpen, setListboxOpen] = useState(false);
14 | const {
15 | country: {isoCode},
16 | } = useLocalization();
17 | const currentCountry = useMemo(() => {
18 | const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
19 | type: 'region',
20 | });
21 |
22 | return {
23 | name: regionNamesInEnglish.of(isoCode),
24 | isoCode: isoCode,
25 | };
26 | }, [isoCode]);
27 |
28 | const setCountry = useCallback(
29 | ({isoCode: newIsoCode}) => {
30 | const currentPath = window.location.pathname;
31 | let redirectPath;
32 |
33 | if (newIsoCode !== 'US') {
34 | if (currentCountry.isoCode === 'US') {
35 | redirectPath = `/${newIsoCode.toLowerCase()}${currentPath}`;
36 | } else {
37 | redirectPath = `/${newIsoCode.toLowerCase()}${currentPath.substring(
38 | currentPath.indexOf('/', 1),
39 | )}`;
40 | }
41 | } else {
42 | redirectPath = `${currentPath.substring(currentPath.indexOf('/', 1))}`;
43 | }
44 |
45 | window.location.href = redirectPath;
46 | },
47 | [currentCountry],
48 | );
49 |
50 | return (
51 |
52 |
53 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
54 | {({open}) => {
55 | setTimeout(() => setListboxOpen(open));
56 | return (
57 | <>
58 |
63 | {currentCountry.name}
64 |
65 |
66 |
67 |
75 | {listboxOpen && (
76 | Loading…
}>
77 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
78 | {
81 | return `text-contrast dark:text-primary bg-primary
82 | dark:bg-contrast w-full p-2 transition rounded
83 | flex justify-start items-center text-left cursor-pointer ${
84 | active ? 'bg-primary/10' : null
85 | }`;
86 | }}
87 | />
88 |
89 | )}
90 |
91 | >
92 | );
93 | }}
94 |
95 |
96 | );
97 | }
98 |
99 | export function Countries({selectedCountry, getClassName}) {
100 | const response = fetchSync('/api/countries');
101 |
102 | let countries;
103 |
104 | if (response.ok) {
105 | countries = response.json();
106 | } else {
107 | console.error(
108 | `Unable to load available countries ${response.url} returned a ${response.status}`,
109 | );
110 | }
111 |
112 | return countries ? (
113 | countries.map((country) => {
114 | const isSelected = country.isoCode === selectedCountry.isoCode;
115 |
116 | return (
117 |
118 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
119 | {({active}) => (
120 |
125 | {country.name}
126 | {isSelected ? (
127 |
128 |
129 |
130 | ) : null}
131 |
132 | )}
133 |
134 | );
135 | })
136 | ) : (
137 |
138 |
139 |
Unable to load available countries.
140 |
Please try again.
141 |
142 |
143 | );
144 | }
145 |
--------------------------------------------------------------------------------
/src/components/CustomFont.client.jsx:
--------------------------------------------------------------------------------
1 | // When making building your custom storefront, you will most likely want to
2 | // use custom fonts as well. These are often implemented without critical
3 | // performance optimizations.
4 |
5 | // Below, you'll find the markup needed to optimally render a pair of web fonts
6 | // that we will use on our journal articles. This typeface, IBM Plex,
7 | // can be found at: https://www.ibm.com/plex/, as well as on
8 | // Google Fonts: https://fonts.google.com/specimen/IBM+Plex+Serif. We included
9 | // these locally since you’ll most likely be using commercially licensed fonts.
10 |
11 | // When implementing a custom font, specifying the Unicode range you need,
12 | // and using `font-display: swap` will help you improve your performance.
13 |
14 | // For fonts that appear in the critical rendering path, you can speed up
15 | // performance even more by including a tag in your HTML.
16 |
17 | // In a production environment, you will likely want to include the below
18 | // markup right in your index.html and index.css files.
19 |
20 | import '../styles/custom-font.css';
21 |
22 | export function CustomFont() {}
23 |
--------------------------------------------------------------------------------
/src/components/DefaultSeo.server.jsx:
--------------------------------------------------------------------------------
1 | import {CacheLong, gql, Seo, useShopQuery} from '@shopify/hydrogen';
2 |
3 | /**
4 | * A server component that fetches a `shop.name` and sets default values and templates for every page on a website
5 | */
6 | export function DefaultSeo() {
7 | const {
8 | data: {
9 | shop: {name, description},
10 | },
11 | } = useShopQuery({
12 | query: SHOP_QUERY,
13 | cache: CacheLong(),
14 | preload: '*',
15 | });
16 |
17 | return (
18 | // @ts-ignore TODO: Fix types
19 |
27 | );
28 | }
29 |
30 | const SHOP_QUERY = gql`
31 | query shopInfo {
32 | shop {
33 | name
34 | description
35 | }
36 | }
37 | `;
38 |
--------------------------------------------------------------------------------
/src/components/EventsListener.client.jsx:
--------------------------------------------------------------------------------
1 | import {ClientAnalytics} from '@shopify/hydrogen';
2 | import {useEffect} from 'react';
3 |
4 | let init = false;
5 |
6 | // Example client-side event listener
7 | export function EventsListener() {
8 | useEffect(() => {
9 | if (init) return;
10 | init = true;
11 |
12 | // cart events
13 | ClientAnalytics.subscribe(
14 | ClientAnalytics.eventNames.ADD_TO_CART,
15 | ({cart, prevCart}) => {
16 | // emit ADD_TO_CART event to server
17 | },
18 | );
19 | ClientAnalytics.subscribe(
20 | ClientAnalytics.eventNames.REMOVE_FROM_CART,
21 | ({cart, prevCart}) => {
22 | // emit REMOVE_FROM_CART event to server
23 | },
24 | );
25 | ClientAnalytics.subscribe(
26 | ClientAnalytics.eventNames.UPDATE_CART,
27 | ({cart, prevCart}) => {
28 | // emit UPDATE_CART event to server
29 | },
30 | );
31 |
32 | // other events...
33 | }, []);
34 |
35 | return null;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/HeaderFallback.jsx:
--------------------------------------------------------------------------------
1 | export function HeaderFallback({isHome}) {
2 | const styles = isHome
3 | ? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
4 | : 'bg-contrast/80 text-primary';
5 | return (
6 |
19 | );
20 | }
21 |
22 | function Box({wide, isHome}) {
23 | return (
24 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/account/AccountActivateForm.client.jsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react';
2 | import {useNavigate} from '@shopify/hydrogen/client';
3 | import {getInputStyleClasses} from '../../lib/styleUtils';
4 |
5 | export function AccountActivateForm({id, activationToken}) {
6 | const navigate = useNavigate();
7 |
8 | const [submitError, setSubmitError] = useState(null);
9 | const [password, setPassword] = useState('');
10 | const [passwordError, setPasswordError] = useState(null);
11 | const [passwordConfirm, setPasswordConfirm] = useState('');
12 | const [passwordConfirmError, setPasswordConfirmError] = useState(null);
13 |
14 | function passwordValidation(form) {
15 | setPasswordError(null);
16 | setPasswordConfirmError(null);
17 |
18 | let hasError = false;
19 |
20 | if (!form.password.validity.valid) {
21 | hasError = true;
22 | setPasswordError(
23 | form.password.validity.valueMissing
24 | ? 'Please enter a password'
25 | : 'Passwords must be at least 6 characters',
26 | );
27 | }
28 |
29 | if (!form.passwordConfirm.validity.valid) {
30 | hasError = true;
31 | setPasswordConfirmError(
32 | form.password.validity.valueMissing
33 | ? 'Please re-enter a password'
34 | : 'Passwords must be at least 6 characters',
35 | );
36 | }
37 |
38 | if (password !== passwordConfirm) {
39 | hasError = true;
40 | setPasswordConfirmError('The two passwords entered did not match.');
41 | }
42 |
43 | return hasError;
44 | }
45 |
46 | async function onSubmit(event) {
47 | event.preventDefault();
48 |
49 | if (passwordValidation(event.currentTarget)) {
50 | return;
51 | }
52 |
53 | const response = await callActivateApi({
54 | id,
55 | activationToken,
56 | password,
57 | });
58 |
59 | if (response.error) {
60 | setSubmitError(response.error);
61 | return;
62 | }
63 |
64 | navigate('/account');
65 | }
66 |
67 | return (
68 |
69 |
70 |
Activate Account.
71 |
Create your password to activate your account.
72 |
135 |
136 |
137 | );
138 | }
139 |
140 | async function callActivateApi({id, activationToken, password}) {
141 | try {
142 | const res = await fetch(`/account/activate`, {
143 | method: 'POST',
144 | headers: {
145 | Accept: 'application/json',
146 | 'Content-Type': 'application/json',
147 | },
148 | body: JSON.stringify({id, activationToken, password}),
149 | });
150 | if (res.ok) {
151 | return {};
152 | } else {
153 | return res.json();
154 | }
155 | } catch (error) {
156 | return {
157 | error: error.toString(),
158 | };
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/components/account/AccountAddressBook.client.jsx:
--------------------------------------------------------------------------------
1 | import {useState, useMemo} from 'react';
2 |
3 | import {Text, Button} from '~/components/elements';
4 | import {Modal} from '../index';
5 | import {AccountAddressEdit, AccountDeleteAddress} from '../index';
6 |
7 | export function AccountAddressBook({addresses, defaultAddress}) {
8 | const [editingAddress, setEditingAddress] = useState(null);
9 | const [deletingAddress, setDeletingAddress] = useState(null);
10 |
11 | const {fullDefaultAddress, addressesWithoutDefault} = useMemo(() => {
12 | const defaultAddressIndex = addresses.findIndex(
13 | (address) => address.id === defaultAddress,
14 | );
15 | return {
16 | addressesWithoutDefault: [
17 | ...addresses.slice(0, defaultAddressIndex),
18 | ...addresses.slice(defaultAddressIndex + 1, addresses.length),
19 | ],
20 | fullDefaultAddress: addresses[defaultAddressIndex],
21 | };
22 | }, [addresses, defaultAddress]);
23 |
24 | function close() {
25 | setEditingAddress(null);
26 | setDeletingAddress(null);
27 | }
28 |
29 | function editAddress(address) {
30 | setEditingAddress(address);
31 | }
32 |
33 | return (
34 | <>
35 | {deletingAddress ? (
36 |
37 |
38 |
39 | ) : null}
40 | {editingAddress ? (
41 |
42 |
47 |
48 | ) : null}
49 |
50 |
Address Book
51 |
52 | {!addresses?.length ? (
53 |
54 | You haven't saved any addresses yet.
55 |
56 | ) : null}
57 |
58 | {
61 | editAddress({
62 | /** empty address */
63 | });
64 | }}
65 | variant="secondary"
66 | >
67 | Add an Address
68 |
69 |
70 | {addresses?.length ? (
71 |
72 | {fullDefaultAddress ? (
73 |
82 | ) : null}
83 | {addressesWithoutDefault.map((address) => (
84 |
93 | ))}
94 |
95 | ) : null}
96 |
97 |
98 | >
99 | );
100 | }
101 |
102 | function Address({address, defaultAddress, editAddress, setDeletingAddress}) {
103 | return (
104 |
105 | {defaultAddress ? (
106 |
107 |
108 | Default
109 |
110 |
111 | ) : null}
112 |
113 | {address.firstName || address.lastName ? (
114 |
115 | {(address.firstName && address.firstName + ' ') + address.lastName}
116 |
117 | ) : (
118 | <>>
119 | )}
120 | {address.formatted ? (
121 | address.formatted.map((line) => {line} )
122 | ) : (
123 | <>>
124 | )}
125 |
126 |
127 |
128 | {
130 | editAddress(address);
131 | }}
132 | className="text-left underline text-sm"
133 | >
134 | Edit
135 |
136 |
140 | Remove
141 |
142 |
143 |
144 | );
145 | }
146 |
--------------------------------------------------------------------------------
/src/components/account/AccountCreateForm.client.jsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react';
2 | import {useNavigate, Link} from '@shopify/hydrogen/client';
3 |
4 | import {emailValidation, passwordValidation} from '~/lib/utils';
5 |
6 | import {callLoginApi} from './AccountLoginForm.client';
7 | import {getInputStyleClasses} from '../../lib/styleUtils';
8 |
9 | export function AccountCreateForm() {
10 | const navigate = useNavigate();
11 |
12 | const [submitError, setSubmitError] = useState(null);
13 | const [email, setEmail] = useState('');
14 | const [emailError, setEmailError] = useState(null);
15 | const [password, setPassword] = useState('');
16 | const [passwordError, setPasswordError] = useState(null);
17 |
18 | async function onSubmit(event) {
19 | event.preventDefault();
20 |
21 | setEmailError(null);
22 | setPasswordError(null);
23 | setSubmitError(null);
24 |
25 | const newEmailError = emailValidation(event.currentTarget.email);
26 | if (newEmailError) {
27 | setEmailError(newEmailError);
28 | }
29 |
30 | const newPasswordError = passwordValidation(event.currentTarget.password);
31 | if (newPasswordError) {
32 | setPasswordError(newPasswordError);
33 | }
34 |
35 | if (newEmailError || newPasswordError) {
36 | return;
37 | }
38 |
39 | const accountCreateResponse = await callAccountCreateApi({
40 | email,
41 | password,
42 | });
43 |
44 | if (accountCreateResponse.error) {
45 | setSubmitError(accountCreateResponse.error);
46 | return;
47 | }
48 |
49 | // this can be avoided if customerCreate mutation returns customerAccessToken
50 | await callLoginApi({
51 | email,
52 | password,
53 | });
54 |
55 | navigate('/account');
56 | }
57 |
58 | return (
59 |
60 |
61 |
Create an Account.
62 |
130 |
131 |
132 | );
133 | }
134 |
135 | export async function callAccountCreateApi({
136 | email,
137 | password,
138 | firstName,
139 | lastName,
140 | }) {
141 | try {
142 | const res = await fetch(`/account/register`, {
143 | method: 'POST',
144 | headers: {
145 | Accept: 'application/json',
146 | 'Content-Type': 'application/json',
147 | },
148 | body: JSON.stringify({email, password, firstName, lastName}),
149 | });
150 | if (res.status === 200) {
151 | return {};
152 | } else {
153 | return res.json();
154 | }
155 | } catch (error) {
156 | return {
157 | error: error.toString(),
158 | };
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/components/account/AccountDeleteAddress.client.jsx:
--------------------------------------------------------------------------------
1 | import {Text, Button} from '~/components/elements';
2 | import {useRenderServerComponents} from '~/lib/utils';
3 |
4 | export function AccountDeleteAddress({addressId, close}) {
5 | // Necessary for edits to show up on the main page
6 | const renderServerComponents = useRenderServerComponents();
7 |
8 | async function deleteAddress(id) {
9 | const response = await callDeleteAddressApi(id);
10 | if (response.error) {
11 | alert(response.error);
12 | return;
13 | }
14 | renderServerComponents();
15 | close();
16 | }
17 |
18 | return (
19 | <>
20 |
21 | Confirm removal
22 |
23 | Are you sure you wish to remove this address?
24 |
25 | {
28 | deleteAddress(addressId);
29 | }}
30 | variant="primary"
31 | width="full"
32 | >
33 | Confirm
34 |
35 |
41 | Cancel
42 |
43 |
44 | >
45 | );
46 | }
47 |
48 | export async function callDeleteAddressApi(id) {
49 | try {
50 | const res = await fetch(`/account/address/${encodeURIComponent(id)}`, {
51 | method: 'DELETE',
52 | headers: {
53 | Accept: 'application/json',
54 | },
55 | });
56 | if (res.ok) {
57 | return {};
58 | } else {
59 | return res.json();
60 | }
61 | } catch (_e) {
62 | return {
63 | error: 'Error removing address. Please try again.',
64 | };
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/account/AccountDetails.client.jsx:
--------------------------------------------------------------------------------
1 | import {Seo} from '@shopify/hydrogen';
2 | import {useState} from 'react';
3 | import {Modal} from '../index';
4 | import {AccountDetailsEdit} from './AccountDetailsEdit.client';
5 |
6 | export function AccountDetails({firstName, lastName, phone, email}) {
7 | const [isEditing, setIsEditing] = useState(false);
8 |
9 | const close = () => setIsEditing(false);
10 |
11 | return (
12 | <>
13 | {isEditing ? (
14 |
15 |
16 |
23 |
24 | ) : null}
25 |
26 |
Account Details
27 |
28 |
29 |
Profile & Security
30 | setIsEditing(true)}
33 | >
34 | Edit
35 |
36 |
37 |
Name
38 |
39 | {firstName || lastName
40 | ? (firstName ? firstName + ' ' : '') + lastName
41 | : 'Add name'}{' '}
42 |
43 |
44 |
Contact
45 |
{phone ?? 'Add mobile'}
46 |
47 |
Email address
48 |
{email}
49 |
50 |
Password
51 |
**************
52 |
53 |
54 | >
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/account/AccountOrderHistory.client.jsx:
--------------------------------------------------------------------------------
1 | import {Button, Text, OrderCard} from '~/components';
2 |
3 | export function AccountOrderHistory({orders}) {
4 | return (
5 |
6 |
7 |
Order History
8 | {orders?.length ? : }
9 |
10 |
11 | );
12 | }
13 |
14 | function EmptyOrders() {
15 | return (
16 |
17 |
18 | You haven't placed any orders yet.
19 |
20 |
21 |
22 | Start Shopping
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | function Orders({orders}) {
30 | return (
31 |
32 | {orders.map((order) => (
33 |
34 | ))}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/account/AccountPasswordResetForm.client.jsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react';
2 | import {useNavigate} from '@shopify/hydrogen/client';
3 | import {getInputStyleClasses} from '../../lib/styleUtils';
4 |
5 | export function AccountPasswordResetForm({id, resetToken}) {
6 | const navigate = useNavigate();
7 |
8 | const [submitError, setSubmitError] = useState(null);
9 | const [password, setPassword] = useState('');
10 | const [passwordError, setPasswordError] = useState(null);
11 | const [passwordConfirm, setPasswordConfirm] = useState('');
12 | const [passwordConfirmError, setPasswordConfirmError] = useState(null);
13 |
14 | function passwordValidation(form) {
15 | setPasswordError(null);
16 | setPasswordConfirmError(null);
17 |
18 | let hasError = false;
19 |
20 | if (!form.password.validity.valid) {
21 | hasError = true;
22 | setPasswordError(
23 | form.password.validity.valueMissing
24 | ? 'Please enter a password'
25 | : 'Passwords must be at least 6 characters',
26 | );
27 | }
28 |
29 | if (!form.passwordConfirm.validity.valid) {
30 | hasError = true;
31 | setPasswordConfirmError(
32 | form.password.validity.valueMissing
33 | ? 'Please re-enter a password'
34 | : 'Passwords must be at least 6 characters',
35 | );
36 | }
37 |
38 | if (password !== passwordConfirm) {
39 | hasError = true;
40 | setPasswordConfirmError('The two password entered did not match.');
41 | }
42 |
43 | return hasError;
44 | }
45 |
46 | async function onSubmit(event) {
47 | event.preventDefault();
48 |
49 | if (passwordValidation(event.currentTarget)) {
50 | return;
51 | }
52 |
53 | const response = await callPasswordResetApi({
54 | id,
55 | resetToken,
56 | password,
57 | });
58 |
59 | if (response.error) {
60 | setSubmitError(response.error);
61 | return;
62 | }
63 |
64 | navigate('/account');
65 | }
66 |
67 | return (
68 |
69 |
70 |
Reset Password.
71 |
Enter a new password for your account.
72 |
137 |
138 |
139 | );
140 | }
141 |
142 | export async function callPasswordResetApi({id, resetToken, password}) {
143 | try {
144 | const res = await fetch(`/account/reset`, {
145 | method: 'POST',
146 | headers: {
147 | Accept: 'application/json',
148 | 'Content-Type': 'application/json',
149 | },
150 | body: JSON.stringify({id, resetToken, password}),
151 | });
152 |
153 | if (res.ok) {
154 | return {};
155 | } else {
156 | return res.json();
157 | }
158 | } catch (error) {
159 | return {
160 | error: error.toString(),
161 | };
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/src/components/account/AccountRecoverForm.client.jsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react';
2 |
3 | import {emailValidation} from '~/lib/utils';
4 | import {getInputStyleClasses} from '../../lib/styleUtils';
5 |
6 | export function AccountRecoverForm() {
7 | const [submitSuccess, setSubmitSuccess] = useState(false);
8 | const [submitError, setSubmitError] = useState(null);
9 | const [email, setEmail] = useState('');
10 | const [emailError, setEmailError] = useState(null);
11 |
12 | async function onSubmit(event) {
13 | event.preventDefault();
14 |
15 | setEmailError(null);
16 | setSubmitError(null);
17 |
18 | const newEmailError = emailValidation(event.currentTarget.email);
19 |
20 | if (newEmailError) {
21 | setEmailError(newEmailError);
22 | return;
23 | }
24 |
25 | await callAccountRecoverApi({
26 | email,
27 | });
28 |
29 | setEmail('');
30 | setSubmitSuccess(true);
31 | }
32 |
33 | return (
34 |
35 |
36 | {submitSuccess ? (
37 | <>
38 |
Request Sent.
39 |
40 | If that email address is in our system, you will receive an email
41 | with instructions about how to reset your password in a few
42 | minutes.
43 |
44 | >
45 | ) : (
46 | <>
47 |
Forgot Password.
48 |
49 | Enter the email address associated with your account to receive a
50 | link to reset your password.
51 |
52 | >
53 | )}
54 |
92 |
93 |
94 | );
95 | }
96 |
97 | export async function callAccountRecoverApi({
98 | email,
99 | password,
100 | firstName,
101 | lastName,
102 | }) {
103 | try {
104 | const res = await fetch(`/account/recover`, {
105 | method: 'POST',
106 | headers: {
107 | Accept: 'application/json',
108 | 'Content-Type': 'application/json',
109 | },
110 | body: JSON.stringify({email, password, firstName, lastName}),
111 | });
112 | if (res.status === 200) {
113 | return {};
114 | } else {
115 | return res.json();
116 | }
117 | } catch (error) {
118 | return {
119 | error: error.toString(),
120 | };
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/account/index.js:
--------------------------------------------------------------------------------
1 | export {AccountActivateForm} from './AccountActivateForm.client';
2 | export {AccountAddressBook} from './AccountAddressBook.client';
3 | export {AccountAddressEdit} from './AccountAddressEdit.client';
4 | export {AccountCreateForm} from './AccountCreateForm.client';
5 | export {AccountDeleteAddress} from './AccountDeleteAddress.client';
6 | export {AccountDetails} from './AccountDetails.client';
7 | export {AccountDetailsEdit} from './AccountDetailsEdit.client';
8 | export {AccountLoginForm} from './AccountLoginForm.client';
9 | export {AccountOrderHistory} from './AccountOrderHistory.client';
10 | export {AccountPasswordResetForm} from './AccountPasswordResetForm.client';
11 | export {AccountRecoverForm} from './AccountRecoverForm.client';
12 |
--------------------------------------------------------------------------------
/src/components/cards/ArticleCard.jsx:
--------------------------------------------------------------------------------
1 | import {Image, Link} from '@shopify/hydrogen';
2 |
3 | export function ArticleCard({blogHandle, article, loading}) {
4 | return (
5 |
6 |
7 | {article.image && (
8 |
9 |
22 |
23 | )}
24 | {article.title}
25 | {article.publishedAt}
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/cards/CollectionCard.server.jsx:
--------------------------------------------------------------------------------
1 | import {Image, Link} from '@shopify/hydrogen';
2 |
3 | import {Heading} from '~/components';
4 |
5 | export function CollectionCard({collection, loading}) {
6 | return (
7 |
8 |
9 | {collection?.image && (
10 |
22 | )}
23 |
24 |
25 | {collection.title}
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/cards/OrderCard.client.jsx:
--------------------------------------------------------------------------------
1 | import {Image, Link, flattenConnection} from '@shopify/hydrogen';
2 |
3 | import {Heading, Text} from '~/components';
4 | import {statusMessage} from '~/lib/utils';
5 |
6 | export function OrderCard({order}) {
7 | if (!order?.id) return null;
8 | const legacyOrderId = order.id.split('/').pop().split('?')[0];
9 | const lineItems = flattenConnection(order?.lineItems);
10 |
11 | return (
12 |
13 |
17 | {lineItems[0].variant?.image && (
18 |
19 |
29 |
30 | )}
31 |
36 |
37 | {lineItems.length > 1
38 | ? `${lineItems[0].title} +${lineItems.length - 1} more`
39 | : lineItems[0].title}
40 |
41 |
42 | Order ID
43 |
44 |
45 | Order No. {order.orderNumber}
46 |
47 |
48 | Order Date
49 |
50 |
51 | {new Date(order.processedAt).toDateString()}
52 |
53 |
54 | Fulfillment Status
55 |
56 |
63 |
64 | {statusMessage(order.fulfillmentStatus)}
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
76 |
77 | View Details
78 |
79 |
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/cards/ProductCard.client.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import {
3 | flattenConnection,
4 | Image,
5 | Link,
6 | Money,
7 | useMoney,
8 | } from '@shopify/hydrogen';
9 |
10 | import {Text} from '~/components';
11 | import {isDiscounted, isNewArrival} from '~/lib/utils';
12 | import {getProductPlaceholder} from '~/lib/placeholders';
13 |
14 | export function ProductCard({product, label, className, loading, onClick}) {
15 | let cardLabel;
16 |
17 | const cardData = product?.variants ? product : getProductPlaceholder();
18 |
19 | const {
20 | image,
21 | priceV2: price,
22 | compareAtPriceV2: compareAtPrice,
23 | } = flattenConnection(cardData?.variants)[0] || {};
24 |
25 | if (label) {
26 | cardLabel = label;
27 | } else if (isDiscounted(price, compareAtPrice)) {
28 | cardLabel = 'Sale';
29 | } else if (isNewArrival(product.publishedAt)) {
30 | cardLabel = 'New';
31 | }
32 |
33 | const styles = clsx('grid gap-6', className);
34 |
35 | return (
36 |
37 |
38 |
39 |
44 | {cardLabel}
45 |
46 | {image && (
47 |
62 | )}
63 |
64 |
65 |
69 | {product.title}
70 |
71 |
72 |
73 |
74 | {isDiscounted(price, compareAtPrice) && (
75 |
79 | )}
80 |
81 |
82 |
83 |
84 |
85 | );
86 | }
87 |
88 | function CompareAtPrice({data, className}) {
89 | const {currencyNarrowSymbol, withoutTrailingZerosAndCurrency} =
90 | useMoney(data);
91 |
92 | const styles = clsx('strike', className);
93 |
94 | return (
95 |
96 | {currencyNarrowSymbol}
97 | {withoutTrailingZerosAndCurrency}
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/components/cards/index.js:
--------------------------------------------------------------------------------
1 | export {ArticleCard} from './ArticleCard';
2 | export {OrderCard} from './OrderCard.client';
3 | export {ProductCard} from './ProductCard.client';
4 |
--------------------------------------------------------------------------------
/src/components/cards/index.server.js:
--------------------------------------------------------------------------------
1 | export {CollectionCard} from './CollectionCard.server';
2 |
--------------------------------------------------------------------------------
/src/components/cart/CartDetails.client.jsx:
--------------------------------------------------------------------------------
1 | import {useRef} from 'react';
2 | import {useScroll} from 'react-use';
3 | import {
4 | Link,
5 | useCart,
6 | CartLineProvider,
7 | CartShopPayButton,
8 | Money,
9 | } from '@shopify/hydrogen';
10 |
11 | import {Button, Text, CartLineItem, CartEmpty} from '~/components';
12 |
13 | export function CartDetails({layout, onClose}) {
14 | const {lines} = useCart();
15 | const scrollRef = useRef(null);
16 | const {y} = useScroll(scrollRef);
17 |
18 | if (lines.length === 0) {
19 | return ;
20 | }
21 |
22 | const container = {
23 | drawer: 'grid grid-cols-1 h-screen-no-nav grid-rows-[1fr_auto]',
24 | page: 'pb-12 grid md:grid-cols-2 md:items-start gap-8 md:gap-8 lg:gap-12',
25 | };
26 |
27 | const content = {
28 | drawer: 'px-6 pb-6 sm-max:pt-2 overflow-auto transition md:px-12',
29 | page: 'flex-grow md:translate-y-4',
30 | };
31 |
32 | const summary = {
33 | drawer: 'grid gap-6 p-6 border-t md:px-12',
34 | page: 'sticky top-nav grid gap-6 p-4 md:px-6 md:translate-y-4 bg-primary/5 rounded w-full',
35 | };
36 |
37 | return (
38 |
62 | );
63 | }
64 |
65 | function CartCheckoutActions() {
66 | const {checkoutUrl} = useCart();
67 | return (
68 | <>
69 |
70 | {checkoutUrl ? (
71 |
72 |
73 | Continue to Checkout
74 |
75 |
76 | ) : null}
77 |
78 |
79 | >
80 | );
81 | }
82 |
83 | function OrderSummary() {
84 | const {cost} = useCart();
85 | return (
86 | <>
87 |
88 |
89 | Subtotal
90 |
91 | {cost?.subtotalAmount?.amount ? (
92 |
93 | ) : (
94 | '-'
95 | )}
96 |
97 |
98 |
99 | >
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/cart/CartEmpty.client.jsx:
--------------------------------------------------------------------------------
1 | import {useRef} from 'react';
2 | import {useScroll} from 'react-use';
3 | import {fetchSync} from '@shopify/hydrogen';
4 | import {Button, Text, ProductCard, Heading, Skeleton} from '~/components';
5 | import {Suspense} from 'react';
6 |
7 | export function CartEmpty({onClose, layout = 'drawer'}) {
8 | const scrollRef = useRef(null);
9 | const {y} = useScroll(scrollRef);
10 |
11 | const container = {
12 | drawer: `grid content-start gap-4 px-6 pb-8 transition overflow-y-scroll md:gap-12 md:px-12 h-screen-no-nav md:pb-12 ${
13 | y > 0 ? 'border-t' : ''
14 | }`,
15 | page: `grid pb-12 w-full md:items-start gap-4 md:gap-8 lg:gap-12`,
16 | };
17 |
18 | const topProductsContainer = {
19 | drawer: '',
20 | page: 'md:grid-cols-4 sm:grid-col-4',
21 | };
22 |
23 | return (
24 |
25 |
26 |
27 | Looks like you haven’t added anything yet, let’s get you
28 | started!
29 |
30 |
31 | Continue shopping
32 |
33 |
34 |
35 |
36 | Shop Best Sellers
37 |
38 |
41 | }>
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | function TopProducts({onClose}) {
51 | const response = fetchSync('/api/bestSellers');
52 |
53 | if (!response.ok) {
54 | console.error(
55 | `Unable to load top products ${response.url} returned a ${response.status}`,
56 | );
57 | return null;
58 | }
59 |
60 | const products = response.json();
61 |
62 | if (products.length === 0) {
63 | return No products found. ;
64 | }
65 |
66 | return (
67 | <>
68 | {products.map((product) => (
69 |
70 | ))}
71 | >
72 | );
73 | }
74 |
75 | function Loading() {
76 | return (
77 | <>
78 | {[...new Array(4)].map((_, i) => (
79 | // eslint-disable-next-line react/no-array-index-key
80 |
81 |
82 |
83 |
84 | ))}
85 | >
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/cart/CartLineItem.client.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | useCart,
3 | useCartLine,
4 | CartLineQuantityAdjustButton,
5 | CartLinePrice,
6 | CartLineQuantity,
7 | Image,
8 | Link,
9 | } from '@shopify/hydrogen';
10 |
11 | import {Heading, IconRemove, Text} from '~/components';
12 |
13 | export function CartLineItem() {
14 | const {linesRemove} = useCart();
15 | const {id: lineId, quantity, merchandise} = useCartLine();
16 |
17 | return (
18 |
19 |
20 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | {merchandise.product.title}
38 |
39 |
40 |
41 |
42 | {(merchandise?.selectedOptions || []).map((option) => (
43 |
44 | {option.name}: {option.value}
45 |
46 | ))}
47 |
48 |
49 |
50 |
51 |
52 |
53 |
linesRemove([lineId])}
56 | className="flex items-center justify-center w-10 h-10 border rounded"
57 | >
58 | Remove
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | function CartLineQuantityAdjust({lineId, quantity}) {
72 | return (
73 | <>
74 |
75 | Quantity, {quantity}
76 |
77 |
78 |
83 | −
84 |
85 |
86 |
91 | +
92 |
93 |
94 | >
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/cart/index.js:
--------------------------------------------------------------------------------
1 | export {CartDetails} from './CartDetails.client';
2 | export {CartEmpty} from './CartEmpty.client';
3 | export {CartLineItem} from './CartLineItem.client';
4 |
--------------------------------------------------------------------------------
/src/components/elements/Button.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import {Link} from '@shopify/hydrogen';
3 |
4 | import {missingClass} from '~/lib/utils';
5 |
6 | export function Button({
7 | as = 'button',
8 | className = '',
9 | variant = 'primary',
10 | width = 'auto',
11 | ...props
12 | }) {
13 | const Component = props?.to ? Link : as;
14 |
15 | const baseButtonClasses =
16 | 'inline-block rounded font-medium text-center py-3 px-6';
17 |
18 | const variants = {
19 | primary: `${baseButtonClasses} bg-primary text-contrast`,
20 | secondary: `${baseButtonClasses} border border-primary/10 bg-contrast text-primary`,
21 | inline: 'border-b border-primary/10 leading-none pb-1',
22 | };
23 |
24 | const widths = {
25 | auto: 'w-auto',
26 | full: 'w-full',
27 | };
28 |
29 | const styles = clsx(
30 | missingClass(className, 'bg-') && variants[variant],
31 | missingClass(className, 'w-') && widths[width],
32 | className,
33 | );
34 |
35 | return ;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/elements/Grid.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export function Grid({
4 | as: Component = 'div',
5 | className,
6 | flow = 'row',
7 | gap = 'default',
8 | items = 4,
9 | layout = 'default',
10 | ...props
11 | }) {
12 | const layouts = {
13 | default: `grid-cols-1 ${items === 2 && 'md:grid-cols-2'} ${
14 | items === 3 && 'sm:grid-cols-3'
15 | } ${items > 3 && 'md:grid-cols-3'} ${items >= 4 && 'lg:grid-cols-4'}`,
16 | products: `grid-cols-2 ${items >= 3 && 'md:grid-cols-3'} ${
17 | items >= 4 && 'lg:grid-cols-4'
18 | }`,
19 | auto: 'auto-cols-auto',
20 | blog: `grid-cols-2 pt-24`,
21 | };
22 |
23 | const gaps = {
24 | default: 'grid gap-2 gap-y-6 md:gap-4 lg:gap-6',
25 | blog: 'grid gap-6',
26 | };
27 |
28 | const flows = {
29 | row: 'grid-flow-row',
30 | col: 'grid-flow-col',
31 | };
32 |
33 | const styles = clsx(flows[flow], gaps[gap], layouts[layout], className);
34 |
35 | return ;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/elements/Heading.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | import {missingClass, formatText} from '~/lib/utils';
4 |
5 | export function Heading({
6 | as: Component = 'h2',
7 | children,
8 | className = '',
9 | format,
10 | size = 'heading',
11 | width = 'default',
12 | ...props
13 | }) {
14 | const sizes = {
15 | display: 'font-bold text-display',
16 | heading: 'font-bold text-heading',
17 | lead: 'font-bold text-lead',
18 | copy: 'font-medium text-copy',
19 | };
20 |
21 | const widths = {
22 | default: 'max-w-prose',
23 | narrow: 'max-w-prose-narrow',
24 | wide: 'max-w-prose-wide',
25 | };
26 |
27 | const styles = clsx(
28 | missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
29 | missingClass(className, 'max-w-') && widths[width],
30 | missingClass(className, 'font-') && sizes[size],
31 | className,
32 | );
33 |
34 | return (
35 |
36 | {format ? formatText(children) : children}
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/elements/Input.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export function Input({className = '', type, variant, ...props}) {
4 | const variants = {
5 | search:
6 | 'bg-transparent px-0 py-2 text-heading w-full focus:ring-0 border-x-0 border-t-0 transition border-b-2 border-primary/10 focus:border-primary/90',
7 | minisearch:
8 | 'bg-transparent hidden md:inline-block text-left lg:text-right border-b transition border-transparent -mb-px border-x-0 border-t-0 appearance-none px-0 py-1 focus:ring-transparent placeholder:opacity-20 placeholder:text-inherit',
9 | };
10 |
11 | const styles = clsx(variants[variant], className);
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/elements/LogoutButton.client.jsx:
--------------------------------------------------------------------------------
1 | export function LogoutButton(props) {
2 | const logout = () => {
3 | fetch('/account/logout', {method: 'POST'}).then(() => {
4 | if (typeof props?.onClick === 'function') {
5 | props.onClick();
6 | }
7 | window.location.href = '/';
8 | });
9 | };
10 |
11 | return (
12 |
13 | Logout
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/elements/Section.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | import {Heading} from '~/components';
4 | import {missingClass} from '~/lib/utils';
5 |
6 | export function Section({
7 | as: Component = 'section',
8 | children,
9 | className,
10 | divider = 'none',
11 | display = 'grid',
12 | heading,
13 | padding = 'all',
14 | ...props
15 | }) {
16 | const paddings = {
17 | x: 'px-6 md:px-8 lg:px-12',
18 | y: 'py-6 md:py-8 lg:py-12',
19 | swimlane: 'pt-4 md:pt-8 lg:pt-12 md:pb-4 lg:pb-8',
20 | all: 'p-6 md:p-8 lg:p-12',
21 | };
22 |
23 | const dividers = {
24 | none: 'border-none',
25 | top: 'border-t border-primary/05',
26 | bottom: 'border-b border-primary/05',
27 | both: 'border-y border-primary/05',
28 | };
29 |
30 | const displays = {
31 | flex: 'flex',
32 | grid: 'grid',
33 | };
34 |
35 | const styles = clsx(
36 | 'w-full gap-4 md:gap-8',
37 | displays[display],
38 | missingClass(className, '\\mp[xy]?-') && paddings[padding],
39 | dividers[divider],
40 | className,
41 | );
42 |
43 | return (
44 |
45 | {heading && (
46 |
47 | {heading}
48 |
49 | )}
50 | {children}
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/elements/Skeleton.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | /**
4 | * A shared component and Suspense call that's used in `App.server.jsx` to let your app wait for code to load while declaring a loading state
5 | */
6 | export function Skeleton({
7 | as: Component = 'div',
8 | width,
9 | height,
10 | className,
11 | ...props
12 | }) {
13 | const styles = clsx('rounded bg-primary/10', className);
14 |
15 | return (
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/elements/Text.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | import {missingClass, formatText} from '~/lib/utils';
4 |
5 | export function Text({
6 | as: Component = 'span',
7 | className,
8 | color = 'default',
9 | format,
10 | size = 'copy',
11 | width = 'default',
12 | children,
13 | ...props
14 | }) {
15 | const colors = {
16 | default: 'inherit',
17 | primary: 'text-primary/90',
18 | subtle: 'text-primary/50',
19 | notice: 'text-notice',
20 | contrast: 'text-contrast/90',
21 | };
22 |
23 | const sizes = {
24 | lead: 'text-lead font-medium',
25 | copy: 'text-copy',
26 | fine: 'text-fine subpixel-antialiased',
27 | };
28 |
29 | const widths = {
30 | default: 'max-w-prose',
31 | narrow: 'max-w-prose-narrow',
32 | wide: 'max-w-prose-wide',
33 | };
34 |
35 | const styles = clsx(
36 | missingClass(className, 'max-w-') && widths[width],
37 | missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
38 | missingClass(className, 'text-') && colors[color],
39 | sizes[size],
40 | className,
41 | );
42 |
43 | return (
44 |
45 | {format ? formatText(children) : children}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/elements/index.js:
--------------------------------------------------------------------------------
1 | export * from './Icon';
2 | export {Button} from './Button';
3 | export {Grid} from './Grid';
4 | export {Heading} from './Heading';
5 | export {Input} from './Input';
6 | export {LogoutButton} from './LogoutButton.client';
7 | export {Section} from './Section';
8 | export {Skeleton} from './Skeleton';
9 | export {Text} from './Text';
10 |
--------------------------------------------------------------------------------
/src/components/global/CartDrawer.client.jsx:
--------------------------------------------------------------------------------
1 | import {CartDetails} from '~/components/cart';
2 | import {Drawer} from './Drawer.client';
3 |
4 | export function CartDrawer({isOpen, onClose}) {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/global/Drawer.client.jsx:
--------------------------------------------------------------------------------
1 | import {Fragment, useState} from 'react';
2 | // @ts-expect-error @headlessui/react incompatibility with node16 resolution
3 | import {Dialog, Transition} from '@headlessui/react';
4 |
5 | import {Heading, IconClose} from '~/components';
6 |
7 | /**
8 | * Drawer component that opens on user click.
9 | * @param heading - string. Shown at the top of the drawer.
10 | * @param open - boolean state. if true opens the drawer.
11 | * @param onClose - function should set the open state.
12 | * @param openFrom - right, left
13 | * @param children - react children node.
14 | */
15 | function Drawer({heading, open, onClose, openFrom = 'right', children}) {
16 | const offScreen = {
17 | right: 'translate-x-full',
18 | left: '-translate-x-full',
19 | };
20 |
21 | return (
22 |
23 |
24 |
33 |
34 |
35 |
36 |
37 |
38 |
43 |
52 |
53 |
58 | {heading !== null && (
59 |
60 |
61 | {heading}
62 |
63 |
64 | )}
65 |
70 |
71 |
72 |
73 | {children}
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | /* Use for associating arialabelledby with the title*/
85 | Drawer.Title = Dialog.Title;
86 |
87 | export {Drawer};
88 |
89 | export function useDrawer(openDefault = false) {
90 | const [isOpen, setIsOpen] = useState(openDefault);
91 |
92 | function openDrawer() {
93 | setIsOpen(true);
94 | }
95 |
96 | function closeDrawer() {
97 | setIsOpen(false);
98 | }
99 |
100 | return {
101 | isOpen,
102 | openDrawer,
103 | closeDrawer,
104 | };
105 | }
106 |
--------------------------------------------------------------------------------
/src/components/global/Footer.server.jsx:
--------------------------------------------------------------------------------
1 | import {useUrl} from '@shopify/hydrogen';
2 |
3 | import {Section, Heading, FooterMenu, CountrySelector} from '~/components';
4 |
5 | /**
6 | * A server component that specifies the content of the footer on the website
7 | */
8 | export function Footer({menu}) {
9 | const {pathname} = useUrl();
10 |
11 | const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
12 | const countryCode = localeMatch ? localeMatch[1] : null;
13 |
14 | const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
15 | const itemsCount = menu
16 | ? menu?.items?.length + 1 > 4
17 | ? 4
18 | : menu?.items?.length + 1
19 | : [];
20 |
21 | return (
22 |
30 |
31 |
32 |
33 | Country
34 |
35 |
36 |
37 |
40 | © {new Date().getFullYear()} / Shopify, Inc. Hydrogen is an MIT
41 | Licensed Open Source project. This website is carbon neutral.
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/global/FooterMenu.client.jsx:
--------------------------------------------------------------------------------
1 | // @ts-expect-error @headlessui/react incompatibility with node16 resolution
2 | import {Disclosure} from '@headlessui/react';
3 | import {Link} from '@shopify/hydrogen';
4 |
5 | import {Heading, IconCaret} from '~/components';
6 |
7 | /**
8 | * A server component that specifies the content of the footer on the website
9 | */
10 | export function FooterMenu({menu}) {
11 | const styles = {
12 | section: 'grid gap-4',
13 | nav: 'grid gap-2 pb-6',
14 | };
15 |
16 | return (
17 | <>
18 | {(menu?.items || []).map((item) => (
19 |
20 |
21 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
22 | {({open}) => (
23 | <>
24 |
25 |
26 | {item.title}
27 | {item?.items?.length > 0 && (
28 |
29 |
30 |
31 | )}
32 |
33 |
34 | {item?.items?.length > 0 && (
35 |
40 |
41 |
42 | {item.items.map((subItem) => (
43 |
48 | {subItem.title}
49 |
50 | ))}
51 |
52 |
53 |
54 | )}
55 | >
56 | )}
57 |
58 |
59 | ))}{' '}
60 | >
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/global/Header.client.jsx:
--------------------------------------------------------------------------------
1 | import {Link, useUrl, useCart} from '@shopify/hydrogen';
2 | import {useWindowScroll} from 'react-use';
3 |
4 | import {
5 | Heading,
6 | IconAccount,
7 | IconBag,
8 | IconMenu,
9 | IconSearch,
10 | Input,
11 | } from '~/components';
12 |
13 | import {CartDrawer} from './CartDrawer.client';
14 | import {MenuDrawer} from './MenuDrawer.client';
15 | import {useDrawer} from './Drawer.client';
16 |
17 | /**
18 | * A client component that specifies the content of the header on the website
19 | */
20 | export function Header({title, menu}) {
21 | const {pathname} = useUrl();
22 |
23 | const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
24 | const countryCode = localeMatch ? localeMatch[1] : undefined;
25 |
26 | const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
27 |
28 | const {
29 | isOpen: isCartOpen,
30 | openDrawer: openCart,
31 | closeDrawer: closeCart,
32 | } = useDrawer();
33 |
34 | const {
35 | isOpen: isMenuOpen,
36 | openDrawer: openMenu,
37 | closeDrawer: closeMenu,
38 | } = useDrawer();
39 |
40 | return (
41 | <>
42 |
43 |
44 |
51 |
58 | >
59 | );
60 | }
61 |
62 | function MobileHeader({countryCode, title, isHome, openCart, openMenu}) {
63 | const {y} = useWindowScroll();
64 |
65 | const styles = {
66 | button: 'relative flex items-center justify-center w-8 h-8',
67 | container: `${
68 | isHome
69 | ? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
70 | : 'bg-contrast/80 text-primary'
71 | } ${
72 | y > 50 && !isHome ? 'shadow-lightHeader ' : ''
73 | }flex lg:hidden items-center h-nav sticky backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-4 px-4 md:px-8`,
74 | };
75 |
76 | return (
77 |
122 | );
123 | }
124 |
125 | function DesktopHeader({countryCode, isHome, menu, openCart, title}) {
126 | const {y} = useWindowScroll();
127 |
128 | const styles = {
129 | button:
130 | 'relative flex items-center justify-center w-8 h-8 focus:ring-primary/5',
131 | container: `${
132 | isHome
133 | ? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
134 | : 'bg-contrast/80 text-primary'
135 | } ${
136 | y > 50 && !isHome ? 'shadow-lightHeader ' : ''
137 | }hidden h-nav lg:flex items-center sticky transition duration-300 backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-8 px-12 py-8`,
138 | };
139 |
140 | return (
141 |
142 |
143 |
144 | {title}
145 |
146 |
147 | {/* Top level menu items */}
148 | {(menu?.items || []).map((item) => (
149 |
150 | {item.title}
151 |
152 | ))}
153 |
154 |
155 |
156 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 | );
185 | }
186 |
187 | function CartBadge({dark}) {
188 | const {totalQuantity} = useCart();
189 |
190 | if (totalQuantity < 1) {
191 | return null;
192 | }
193 | return (
194 |
201 | {totalQuantity}
202 |
203 | );
204 | }
205 |
--------------------------------------------------------------------------------
/src/components/global/Layout.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useLocalization, useShopQuery, CacheLong, gql} from '@shopify/hydrogen';
3 |
4 | import {Header} from '~/components';
5 | import {Footer} from '~/components/index.server';
6 | import {parseMenu} from '~/lib/utils';
7 |
8 | const HEADER_MENU_HANDLE = 'main-menu';
9 | const FOOTER_MENU_HANDLE = 'footer';
10 |
11 | const SHOP_NAME_FALLBACK = 'Hydrogen';
12 |
13 | /**
14 | * A server component that defines a structure and organization of a page that can be used in different parts of the Hydrogen app
15 | */
16 | export function Layout({children}) {
17 | return (
18 | <>
19 |
20 |
25 |
}>
26 |
27 |
28 |
29 | {children}
30 |
31 |
32 | }>
33 |
34 |
35 | >
36 | );
37 | }
38 |
39 | function HeaderWithMenu() {
40 | const {shopName, headerMenu} = useLayoutQuery();
41 | return ;
42 | }
43 |
44 | function FooterWithMenu() {
45 | const {footerMenu} = useLayoutQuery();
46 | return ;
47 | }
48 |
49 | function useLayoutQuery() {
50 | const {
51 | language: {isoCode: languageCode},
52 | } = useLocalization();
53 |
54 | const {data} = useShopQuery({
55 | query: SHOP_QUERY,
56 | variables: {
57 | language: languageCode,
58 | headerMenuHandle: HEADER_MENU_HANDLE,
59 | footerMenuHandle: FOOTER_MENU_HANDLE,
60 | },
61 | cache: CacheLong(),
62 | preload: '*',
63 | });
64 |
65 | const shopName = data ? data.shop.name : SHOP_NAME_FALLBACK;
66 |
67 | /*
68 | Modify specific links/routes (optional)
69 | @see: https://shopify.dev/api/storefront/unstable/enums/MenuItemType
70 | e.g here we map:
71 | - /blogs/news -> /news
72 | - /blog/news/blog-post -> /news/blog-post
73 | - /collections/all -> /products
74 | */
75 | const customPrefixes = {BLOG: '', CATALOG: 'products'};
76 |
77 | const headerMenu = data?.headerMenu
78 | ? parseMenu(data.headerMenu, customPrefixes)
79 | : undefined;
80 |
81 | const footerMenu = data?.footerMenu
82 | ? parseMenu(data.footerMenu, customPrefixes)
83 | : undefined;
84 |
85 | return {footerMenu, headerMenu, shopName};
86 | }
87 |
88 | const SHOP_QUERY = gql`
89 | fragment MenuItem on MenuItem {
90 | id
91 | resourceId
92 | tags
93 | title
94 | type
95 | url
96 | }
97 | query layoutMenus(
98 | $language: LanguageCode
99 | $headerMenuHandle: String!
100 | $footerMenuHandle: String!
101 | ) @inContext(language: $language) {
102 | shop {
103 | name
104 | }
105 | headerMenu: menu(handle: $headerMenuHandle) {
106 | id
107 | items {
108 | ...MenuItem
109 | items {
110 | ...MenuItem
111 | }
112 | }
113 | }
114 | footerMenu: menu(handle: $footerMenuHandle) {
115 | id
116 | items {
117 | ...MenuItem
118 | items {
119 | ...MenuItem
120 | }
121 | }
122 | }
123 | }
124 | `;
125 |
--------------------------------------------------------------------------------
/src/components/global/MenuDrawer.client.jsx:
--------------------------------------------------------------------------------
1 | import {Text} from '~/components';
2 | import {Drawer} from './Drawer.client';
3 | import {Link} from '@shopify/hydrogen';
4 |
5 | export function MenuDrawer({isOpen, onClose, menu}) {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
15 | function MenuMobileNav({menu, onClose}) {
16 | return (
17 |
18 | {/* Top level menu items */}
19 | {(menu?.items || []).map((item) => (
20 |
21 |
22 | {item.title}
23 |
24 |
25 | ))}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/global/Modal.client.jsx:
--------------------------------------------------------------------------------
1 | import {IconClose} from '~/components';
2 |
3 | export function Modal({children, close}) {
4 | return (
5 |
12 |
13 |
14 |
15 |
e.stopPropagation()}
19 | onKeyPress={(e) => e.stopPropagation()}
20 | tabIndex={0}
21 | >
22 |
23 |
28 |
29 |
30 |
31 | {children}
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/global/NotFound.server.jsx:
--------------------------------------------------------------------------------
1 | import {gql, useLocalization, useShopQuery} from '@shopify/hydrogen';
2 |
3 | import {Suspense} from 'react';
4 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
5 | import {Button, FeaturedCollections, PageHeader, Text} from '~/components';
6 | import {ProductSwimlane, Layout} from '~/components/index.server';
7 |
8 | export function NotFound({response, type = 'page'}) {
9 | if (response) {
10 | response.status = 404;
11 | response.statusText = 'Not found';
12 | }
13 |
14 | const heading = `We’ve lost this ${type}`;
15 | const description = `We couldn’t find the ${type} you’re looking for. Try checking the URL or heading back to the home page.`;
16 |
17 | return (
18 |
19 |
20 |
21 | {description}
22 |
23 |
24 | Take me to the home page
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | function FeaturedSection() {
35 | const {
36 | language: {isoCode: languageCode},
37 | country: {isoCode: countryCode},
38 | } = useLocalization();
39 |
40 | const {data} = useShopQuery({
41 | query: NOT_FOUND_QUERY,
42 | variables: {
43 | language: languageCode,
44 | country: countryCode,
45 | },
46 | preload: true,
47 | });
48 |
49 | const {featuredCollections, featuredProducts} = data;
50 |
51 | return (
52 | <>
53 | {featuredCollections.nodes.length < 2 && (
54 |
58 | )}
59 |
60 | >
61 | );
62 | }
63 |
64 | const NOT_FOUND_QUERY = gql`
65 | ${PRODUCT_CARD_FRAGMENT}
66 | query homepage($country: CountryCode, $language: LanguageCode)
67 | @inContext(country: $country, language: $language) {
68 | featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
69 | nodes {
70 | id
71 | title
72 | handle
73 | image {
74 | altText
75 | width
76 | height
77 | url
78 | }
79 | }
80 | }
81 | featuredProducts: products(first: 12) {
82 | nodes {
83 | ...ProductCard
84 | }
85 | }
86 | }
87 | `;
88 |
--------------------------------------------------------------------------------
/src/components/global/PageHeader.jsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | import {Heading} from '~/components';
4 |
5 | export function PageHeader({
6 | children,
7 | className,
8 | heading,
9 | variant = 'default',
10 | ...props
11 | }) {
12 | const variants = {
13 | default: 'grid w-full gap-8 p-6 py-8 md:p-8 lg:p-12 justify-items-start',
14 | blogPost:
15 | 'grid md:text-center w-full gap-4 p-6 py-8 md:p-8 lg:p-12 md:justify-items-center',
16 | allCollections:
17 | 'flex justify-between items-baseline gap-8 p-6 md:p-8 lg:p-12',
18 | };
19 |
20 | const styles = clsx(variants[variant], className);
21 |
22 | return (
23 |
24 | {heading && (
25 |
26 | {heading}
27 |
28 | )}
29 | {children}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/global/index.js:
--------------------------------------------------------------------------------
1 | export {Drawer, useDrawer} from './Drawer.client';
2 | export {FooterMenu} from './FooterMenu.client';
3 | export {Header} from './Header.client';
4 | export {Modal} from './Modal.client';
5 | export {PageHeader} from './PageHeader';
6 |
--------------------------------------------------------------------------------
/src/components/global/index.server.js:
--------------------------------------------------------------------------------
1 | export {Footer} from './Footer.server';
2 | export {Layout} from './Layout.server';
3 | export {NotFound} from './NotFound.server';
4 |
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | export * from './account/index';
2 | export * from './cards/index';
3 | export * from './cart/index';
4 | export * from './elements/index';
5 | export * from './global/index';
6 | export * from './product/index';
7 | export * from './sections/index';
8 | export {CountrySelector} from './CountrySelector.client';
9 | export {CustomFont} from './CustomFont.client';
10 | export {HeaderFallback} from './HeaderFallback';
11 | export {EventsListener} from './EventsListener.client';
12 |
--------------------------------------------------------------------------------
/src/components/index.server.js:
--------------------------------------------------------------------------------
1 | export * from './cards/index.server';
2 | export * from './global/index.server';
3 | export * from './sections/index.server';
4 | export * from './search/index.server';
5 | export {DefaultSeo} from './DefaultSeo.server';
6 |
--------------------------------------------------------------------------------
/src/components/product/ProductDetail.client.jsx:
--------------------------------------------------------------------------------
1 | // @ts-expect-error @headlessui/react incompatibility with node16 resolution
2 | import {Disclosure} from '@headlessui/react';
3 | import {Link} from '@shopify/hydrogen';
4 |
5 | import {Text, IconClose} from '~/components';
6 |
7 | export function ProductDetail({title, content, learnMore}) {
8 | return (
9 |
10 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
11 | {({open}) => (
12 | <>
13 |
14 |
15 |
16 | {title}
17 |
18 |
23 |
24 |
25 |
26 |
27 |
31 | {learnMore && (
32 |
33 |
37 | Learn more
38 |
39 |
40 | )}
41 |
42 | >
43 | )}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/product/ProductForm.client.jsx:
--------------------------------------------------------------------------------
1 | import {useEffect, useCallback, useState} from 'react';
2 |
3 | import {
4 | useProductOptions,
5 | isBrowser,
6 | useUrl,
7 | AddToCartButton,
8 | Money,
9 | ShopPayButton,
10 | } from '@shopify/hydrogen';
11 |
12 | import {Heading, Text, Button, ProductOptions} from '~/components';
13 |
14 | export function ProductForm() {
15 | const {pathname, search} = useUrl();
16 | const [params, setParams] = useState(new URLSearchParams(search));
17 |
18 | const {options, setSelectedOption, selectedOptions, selectedVariant} =
19 | useProductOptions();
20 |
21 | const isOutOfStock = !selectedVariant?.availableForSale || false;
22 | const isOnSale =
23 | selectedVariant?.priceV2?.amount <
24 | selectedVariant?.compareAtPriceV2?.amount || false;
25 |
26 | useEffect(() => {
27 | if (params || !search) return;
28 | setParams(new URLSearchParams(search));
29 | }, [params, search]);
30 |
31 | useEffect(() => {
32 | options.map(({name, values}) => {
33 | if (!params) return;
34 | const currentValue = params.get(name.toLowerCase()) || null;
35 | if (currentValue) {
36 | const matchedValue = values.filter(
37 | (value) => encodeURIComponent(value.toLowerCase()) === currentValue,
38 | );
39 | setSelectedOption(name, matchedValue[0]);
40 | } else {
41 | params.set(
42 | encodeURIComponent(name.toLowerCase()),
43 | encodeURIComponent(selectedOptions[name].toLowerCase()),
44 | ),
45 | window.history.replaceState(
46 | null,
47 | '',
48 | `${pathname}?${params.toString()}`,
49 | );
50 | }
51 | });
52 | }, []);
53 |
54 | const handleChange = useCallback(
55 | (name, value) => {
56 | setSelectedOption(name, value);
57 | if (!params) return;
58 | params.set(
59 | encodeURIComponent(name.toLowerCase()),
60 | encodeURIComponent(value.toLowerCase()),
61 | );
62 | if (isBrowser()) {
63 | window.history.replaceState(
64 | null,
65 | '',
66 | `${pathname}?${params.toString()}`,
67 | );
68 | }
69 | },
70 | [setSelectedOption, params, pathname],
71 | );
72 |
73 | return (
74 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/src/components/product/ProductGallery.client.jsx:
--------------------------------------------------------------------------------
1 | import {MediaFile} from '@shopify/hydrogen/client';
2 | import {ATTR_LOADING_EAGER} from '~/lib/const';
3 |
4 | /**
5 | * A client component that defines a media gallery for hosting images, 3D models, and videos of products
6 | */
7 | export function ProductGallery({media, className}) {
8 | if (!media.length) {
9 | return null;
10 | }
11 |
12 | return (
13 |
16 | {media.map((med, i) => {
17 | let mediaProps = {};
18 | const isFirst = i === 0;
19 | const isFourth = i === 3;
20 | const isFullWidth = i % 3 === 0;
21 |
22 | const data = {
23 | ...med,
24 | image: {
25 | // @ts-ignore
26 | ...med.image,
27 | altText: med.alt || 'Product image',
28 | },
29 | };
30 |
31 | switch (med.mediaContentType) {
32 | case 'IMAGE':
33 | mediaProps = {
34 | width: 800,
35 | widths: [400, 800, 1200, 1600, 2000, 2400],
36 | };
37 | break;
38 | case 'VIDEO':
39 | mediaProps = {
40 | width: '100%',
41 | autoPlay: true,
42 | controls: false,
43 | muted: true,
44 | loop: true,
45 | preload: 'auto',
46 | };
47 | break;
48 | case 'EXTERNAL_VIDEO':
49 | mediaProps = {width: '100%'};
50 | break;
51 | case 'MODEL_3D':
52 | mediaProps = {
53 | width: '100%',
54 | interactionPromptThreshold: '0',
55 | ar: true,
56 | loading: ATTR_LOADING_EAGER,
57 | disableZoom: true,
58 | };
59 | break;
60 | }
61 |
62 | if (i === 0 && med.mediaContentType === 'IMAGE') {
63 | mediaProps.loading = ATTR_LOADING_EAGER;
64 | }
65 |
66 | const style = [
67 | isFullWidth ? 'md:col-span-2' : 'md:col-span-1',
68 | isFirst || isFourth ? '' : 'md:aspect-[4/5]',
69 | 'aspect-square snap-center card-image bg-white dark:bg-contrast/10 w-mobileGallery md:w-full',
70 | ].join(' ');
71 |
72 | return (
73 |
78 |
94 |
95 | );
96 | })}
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/components/product/ProductGrid.client.jsx:
--------------------------------------------------------------------------------
1 | import {useState, useRef, useEffect, useCallback} from 'react';
2 | import {Link, flattenConnection} from '@shopify/hydrogen';
3 |
4 | import {Button, Grid, ProductCard} from '~/components';
5 | import {getImageLoadingPriority} from '~/lib/const';
6 |
7 | export function ProductGrid({url, collection}) {
8 | const nextButtonRef = useRef(null);
9 | const initialProducts = collection?.products?.nodes || [];
10 | const {hasNextPage, endCursor} = collection?.products?.pageInfo ?? {};
11 | const [products, setProducts] = useState(initialProducts);
12 | const [cursor, setCursor] = useState(endCursor ?? '');
13 | const [nextPage, setNextPage] = useState(hasNextPage);
14 | const [pending, setPending] = useState(false);
15 | const haveProducts = initialProducts.length > 0;
16 |
17 | const fetchProducts = useCallback(async () => {
18 | setPending(true);
19 | const postUrl = new URL(window.location.origin + url);
20 | postUrl.searchParams.set('cursor', cursor);
21 |
22 | const response = await fetch(postUrl, {
23 | method: 'POST',
24 | });
25 | const {data} = await response.json();
26 |
27 | // ProductGrid can paginate collection, products and search routes
28 | // @ts-ignore TODO: Fix types
29 | const newProducts = flattenConnection(
30 | data?.collection?.products || data?.products || [],
31 | );
32 | const {endCursor, hasNextPage} = data?.collection?.products?.pageInfo ||
33 | data?.products?.pageInfo || {endCursor: '', hasNextPage: false};
34 |
35 | setProducts([...products, ...newProducts]);
36 | setCursor(endCursor);
37 | setNextPage(hasNextPage);
38 | setPending(false);
39 | }, [cursor, url, products]);
40 |
41 | const handleIntersect = useCallback(
42 | (entries) => {
43 | entries.forEach((entry) => {
44 | if (entry.isIntersecting) {
45 | fetchProducts();
46 | }
47 | });
48 | },
49 | [fetchProducts],
50 | );
51 |
52 | useEffect(() => {
53 | const observer = new IntersectionObserver(handleIntersect, {
54 | rootMargin: '100%',
55 | });
56 |
57 | const nextButton = nextButtonRef.current;
58 |
59 | if (nextButton) observer.observe(nextButton);
60 |
61 | return () => {
62 | if (nextButton) observer.unobserve(nextButton);
63 | };
64 | }, [nextButtonRef, cursor, handleIntersect]);
65 |
66 | if (!haveProducts) {
67 | return (
68 | <>
69 | No products found on this collection
70 |
71 | Browse catalog
72 |
73 | >
74 | );
75 | }
76 |
77 | return (
78 | <>
79 |
80 | {products.map((product, i) => (
81 |
86 | ))}
87 |
88 |
89 | {nextPage && (
90 |
94 |
100 | {pending ? 'Loading...' : 'Load more products'}
101 |
102 |
103 | )}
104 | >
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/components/product/ProductOptions.client.jsx:
--------------------------------------------------------------------------------
1 | import {useCallback, useState} from 'react';
2 | // @ts-expect-error @headlessui/react incompatibility with node16 resolution
3 | import {Listbox} from '@headlessui/react';
4 | import {useProductOptions} from '@shopify/hydrogen';
5 |
6 | import {Text, IconCheck, IconCaret} from '~/components';
7 |
8 | export function ProductOptions({values, ...props}) {
9 | const asDropdown = values.length > 7;
10 |
11 | return asDropdown ? (
12 |
13 | ) : (
14 |
15 | );
16 | }
17 |
18 | function OptionsGrid({values, name, handleChange}) {
19 | const {selectedOptions} = useProductOptions();
20 |
21 | return (
22 | <>
23 | {values.map((value) => {
24 | const checked = selectedOptions[name] === value;
25 | const id = `option-${name}-${value}`;
26 |
27 | return (
28 |
29 | handleChange(name, value)}
37 | />
38 |
43 | {value}
44 |
45 |
46 | );
47 | })}
48 | >
49 | );
50 | }
51 |
52 | // TODO: De-dupe UI with CountrySelector
53 | function OptionsDropdown({values, name, handleChange}) {
54 | const [listboxOpen, setListboxOpen] = useState(false);
55 | const {selectedOptions} = useProductOptions();
56 |
57 | const updateSelectedOption = useCallback(
58 | (value) => {
59 | handleChange(name, value);
60 | },
61 | [name, handleChange],
62 | );
63 |
64 | return (
65 |
66 |
67 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
68 | {({open}) => {
69 | setTimeout(() => setListboxOpen(open));
70 | return (
71 | <>
72 |
77 | {selectedOptions[name]}
78 |
79 |
80 |
81 |
88 | {values.map((value) => {
89 | const isSelected = selectedOptions[name] === value;
90 | const id = `option-${name}-${value}`;
91 |
92 | return (
93 |
94 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
95 | {({active}) => (
96 |
101 | {value}
102 | {isSelected ? (
103 |
104 |
105 |
106 | ) : null}
107 |
108 | )}
109 |
110 | );
111 | })}
112 |
113 | >
114 | );
115 | }}
116 |
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/src/components/product/index.js:
--------------------------------------------------------------------------------
1 | export {ProductForm} from './ProductForm.client';
2 | export {ProductGallery} from './ProductGallery.client';
3 | export {ProductGrid} from './ProductGrid.client';
4 | export {ProductDetail} from './ProductDetail.client';
5 | export {ProductOptions} from './ProductOptions.client';
6 |
--------------------------------------------------------------------------------
/src/components/search/NoResultRecommendations.server.jsx:
--------------------------------------------------------------------------------
1 | import {gql, useShopQuery} from '@shopify/hydrogen';
2 |
3 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
4 | import {FeaturedCollections} from '~/components';
5 | import {ProductSwimlane} from '~/components/index.server';
6 | import {PAGINATION_SIZE} from '~/lib/const';
7 |
8 | export function NoResultRecommendations({country, language}) {
9 | const {data} = useShopQuery({
10 | query: SEARCH_NO_RESULTS_QUERY,
11 | variables: {
12 | country,
13 | language,
14 | pageBy: PAGINATION_SIZE,
15 | },
16 | preload: false,
17 | });
18 |
19 | return (
20 | <>
21 |
25 |
29 | >
30 | );
31 | }
32 |
33 | const SEARCH_NO_RESULTS_QUERY = gql`
34 | ${PRODUCT_CARD_FRAGMENT}
35 | query searchNoResult(
36 | $country: CountryCode
37 | $language: LanguageCode
38 | $pageBy: Int!
39 | ) @inContext(country: $country, language: $language) {
40 | featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
41 | nodes {
42 | id
43 | title
44 | handle
45 | image {
46 | altText
47 | width
48 | height
49 | url
50 | }
51 | }
52 | }
53 | featuredProducts: products(first: $pageBy) {
54 | nodes {
55 | ...ProductCard
56 | }
57 | }
58 | }
59 | `;
60 |
--------------------------------------------------------------------------------
/src/components/search/SearchPage.server.jsx:
--------------------------------------------------------------------------------
1 | import {Heading, Input, PageHeader} from '~/components';
2 | import {Layout} from '~/components/index.server';
3 |
4 | export function SearchPage({searchTerm, children}) {
5 | return (
6 |
7 |
8 |
9 | Search
10 |
11 |
23 |
24 | {children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/search/index.server.js:
--------------------------------------------------------------------------------
1 | export {NoResultRecommendations} from './NoResultRecommendations.server';
2 | export {SearchPage} from './SearchPage.server';
3 |
--------------------------------------------------------------------------------
/src/components/sections/FeaturedCollections.jsx:
--------------------------------------------------------------------------------
1 | import {Link, Image} from '@shopify/hydrogen';
2 |
3 | import {Heading, Section, Grid} from '~/components';
4 |
5 | export function FeaturedCollections({data, title = 'Collections', ...props}) {
6 | const items = data.filter((item) => item.image).length;
7 | const haveCollections = data.length > 0;
8 |
9 | if (!haveCollections) return null;
10 |
11 | return (
12 |
13 |
14 | {data.map((collection) => {
15 | if (!collection?.image) {
16 | return null;
17 | }
18 | // TODO: Refactor to use CollectionCard
19 | return (
20 |
21 |
22 |
23 | {collection?.image && (
24 |
36 | )}
37 |
38 |
{collection.title}
39 |
40 |
41 | );
42 | })}
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/sections/Hero.jsx:
--------------------------------------------------------------------------------
1 | import {Image, Link, Video} from '@shopify/hydrogen';
2 |
3 | import {Heading, Text} from '~/components';
4 |
5 | export function Hero({
6 | byline,
7 | cta,
8 | handle,
9 | heading,
10 | height,
11 | loading,
12 | spread,
13 | spreadSecondary,
14 | top,
15 | }) {
16 | return (
17 |
18 |
27 |
28 | {spread?.reference && (
29 |
30 |
46 |
47 | )}
48 | {spreadSecondary?.reference && (
49 |
50 |
56 |
57 | )}
58 |
59 |
60 | {heading?.value && (
61 |
62 | {heading.value}
63 |
64 | )}
65 | {byline?.value && (
66 |
67 | {byline.value}
68 |
69 | )}
70 | {cta?.value && {cta.value} }
71 |
72 |
73 |
74 | );
75 | }
76 |
77 | function SpreadMedia({data, loading, scale, sizes, width, widths}) {
78 | if (data.mediaContentType === 'VIDEO') {
79 | return (
80 |
91 | );
92 | }
93 |
94 | if (data.mediaContentType === 'IMAGE') {
95 | return (
96 |
107 | );
108 | }
109 |
110 | return null;
111 | }
112 |
--------------------------------------------------------------------------------
/src/components/sections/ProductCards.jsx:
--------------------------------------------------------------------------------
1 | import {ProductCard} from '../cards/ProductCard.client';
2 |
3 | export function ProductCards({products}) {
4 | return (
5 | <>
6 | {products.map((product) => (
7 |
12 | ))}
13 | >
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/sections/ProductSwimlane.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense, useMemo} from 'react';
2 | import {gql, useShopQuery, useLocalization} from '@shopify/hydrogen';
3 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
4 | import {ProductCard, Section} from '~/components';
5 |
6 | const mockProducts = new Array(12).fill('');
7 |
8 | export function ProductSwimlane({
9 | title = 'Featured Products',
10 | data = mockProducts,
11 | count = 12,
12 | ...props
13 | }) {
14 | const productCardsMarkup = useMemo(() => {
15 | // If the data is already provided, there's no need to query it, so we'll just return the data
16 | if (typeof data === 'object') {
17 | return ;
18 | }
19 |
20 | // If the data provided is a productId, we will query the productRecommendations API.
21 | // To make sure we have enough products for the swimlane, we'll combine the results with our top selling products.
22 | if (typeof data === 'string') {
23 | return (
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | // If no data is provided, we'll go and query the top products
31 | return ;
32 | }, [count, data]);
33 |
34 | return (
35 |
36 |
37 | {productCardsMarkup}
38 |
39 |
40 | );
41 | }
42 |
43 | function ProductCards({products}) {
44 | return (
45 | <>
46 | {products.map((product) => (
47 |
52 | ))}
53 | >
54 | );
55 | }
56 |
57 | function RecommendedProducts({productId, count}) {
58 | const {
59 | language: {isoCode: languageCode},
60 | country: {isoCode: countryCode},
61 | } = useLocalization();
62 |
63 | const {data: products} = useShopQuery({
64 | query: RECOMMENDED_PRODUCTS_QUERY,
65 | variables: {
66 | count,
67 | productId,
68 | languageCode,
69 | countryCode,
70 | },
71 | });
72 |
73 | const mergedProducts = products.recommended
74 | .concat(products.additional.nodes)
75 | .filter(
76 | (value, index, array) =>
77 | array.findIndex((value2) => value2.id === value.id) === index,
78 | );
79 |
80 | const originalProduct = mergedProducts
81 | .map((item) => item.id)
82 | .indexOf(productId);
83 |
84 | mergedProducts.splice(originalProduct, 1);
85 |
86 | return ;
87 | }
88 |
89 | function TopProducts({count}) {
90 | const {
91 | data: {products},
92 | } = useShopQuery({
93 | query: TOP_PRODUCTS_QUERY,
94 | variables: {
95 | count,
96 | },
97 | });
98 |
99 | return ;
100 | }
101 |
102 | const RECOMMENDED_PRODUCTS_QUERY = gql`
103 | ${PRODUCT_CARD_FRAGMENT}
104 | query productRecommendations(
105 | $productId: ID!
106 | $count: Int
107 | $countryCode: CountryCode
108 | $languageCode: LanguageCode
109 | ) @inContext(country: $countryCode, language: $languageCode) {
110 | recommended: productRecommendations(productId: $productId) {
111 | ...ProductCard
112 | }
113 | additional: products(first: $count, sortKey: BEST_SELLING) {
114 | nodes {
115 | ...ProductCard
116 | }
117 | }
118 | }
119 | `;
120 |
121 | const TOP_PRODUCTS_QUERY = gql`
122 | ${PRODUCT_CARD_FRAGMENT}
123 | query topProducts(
124 | $count: Int
125 | $countryCode: CountryCode
126 | $languageCode: LanguageCode
127 | ) @inContext(country: $countryCode, language: $languageCode) {
128 | products(first: $count, sortKey: BEST_SELLING) {
129 | nodes {
130 | ...ProductCard
131 | }
132 | }
133 | }
134 | `;
135 |
--------------------------------------------------------------------------------
/src/components/sections/index.js:
--------------------------------------------------------------------------------
1 | export {FeaturedCollections} from './FeaturedCollections';
2 | export {Hero} from './Hero';
3 |
--------------------------------------------------------------------------------
/src/components/sections/index.server.js:
--------------------------------------------------------------------------------
1 | export {ProductSwimlane} from './ProductSwimlane.server';
2 |
--------------------------------------------------------------------------------
/src/lib/const.js:
--------------------------------------------------------------------------------
1 | export const PAGINATION_SIZE = 8;
2 | export const DEFAULT_GRID_IMG_LOAD_EAGER_COUNT = 4;
3 | export const ATTR_LOADING_EAGER = 'eager';
4 |
5 | export function getImageLoadingPriority(
6 | index,
7 | maxEagerLoadCount = DEFAULT_GRID_IMG_LOAD_EAGER_COUNT,
8 | ) {
9 | return index < maxEagerLoadCount ? ATTR_LOADING_EAGER : undefined;
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/fragments.js:
--------------------------------------------------------------------------------
1 | import {gql} from '@shopify/hydrogen';
2 |
3 | export const MEDIA_FRAGMENT = gql`
4 | fragment Media on Media {
5 | mediaContentType
6 | alt
7 | previewImage {
8 | url
9 | }
10 | ... on MediaImage {
11 | id
12 | image {
13 | url
14 | width
15 | height
16 | }
17 | }
18 | ... on Video {
19 | id
20 | sources {
21 | mimeType
22 | url
23 | }
24 | }
25 | ... on Model3d {
26 | id
27 | sources {
28 | mimeType
29 | url
30 | }
31 | }
32 | ... on ExternalVideo {
33 | id
34 | embedUrl
35 | host
36 | }
37 | }
38 | `;
39 |
40 | export const PRODUCT_CARD_FRAGMENT = gql`
41 | fragment ProductCard on Product {
42 | id
43 | title
44 | publishedAt
45 | handle
46 | variants(first: 1) {
47 | nodes {
48 | id
49 | image {
50 | url
51 | altText
52 | width
53 | height
54 | }
55 | priceV2 {
56 | amount
57 | currencyCode
58 | }
59 | compareAtPriceV2 {
60 | amount
61 | currencyCode
62 | }
63 | }
64 | }
65 | }
66 | `;
67 |
--------------------------------------------------------------------------------
/src/lib/index.js:
--------------------------------------------------------------------------------
1 | export * from './fragments';
2 | export * from './placeholders';
3 | export * from './utils';
4 |
--------------------------------------------------------------------------------
/src/lib/styleUtils.jsx:
--------------------------------------------------------------------------------
1 | export const INPUT_STYLE_CLASSES =
2 | 'appearance-none rounded dark:bg-transparent border focus:border-primary/50 focus:ring-0 w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline';
3 |
4 | export const getInputStyleClasses = (isError) => {
5 | return `${INPUT_STYLE_CLASSES} ${
6 | isError ? 'border-red-500' : 'border-primary/20'
7 | }`;
8 | };
9 |
--------------------------------------------------------------------------------
/src/routes/account/activate/[id]/[activationToken].server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useRouteParams, Seo} from '@shopify/hydrogen';
3 |
4 | import {AccountActivateForm} from '~/components';
5 | import {Layout} from '~/components/index.server';
6 |
7 | /**
8 | * This page shows a form for the user to activate an account.
9 | * It should only be accessed by a link emailed to the user.
10 | */
11 | export default function ActivateAccount() {
12 | const {id, activationToken} = useRouteParams();
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/routes/account/activate/index.server.js:
--------------------------------------------------------------------------------
1 | import {CacheNone, gql} from '@shopify/hydrogen';
2 |
3 | import {getApiErrorMessage} from '~/lib/utils';
4 |
5 | /**
6 | * This API route is used by the form on `/account/activate/[id]/[activationToken]`
7 | * complete the reset of the user's password.
8 | */
9 | export async function api(request, {session, queryShop}) {
10 | if (!session) {
11 | return new Response('Session storage not available.', {
12 | status: 400,
13 | });
14 | }
15 |
16 | const jsonBody = await request.json();
17 |
18 | if (!jsonBody?.id || !jsonBody?.password || !jsonBody?.activationToken) {
19 | return new Response(
20 | JSON.stringify({error: 'Incorrect password or activation token.'}),
21 | {
22 | status: 400,
23 | },
24 | );
25 | }
26 |
27 | const {data, errors} = await queryShop({
28 | query: CUSTOMER_ACTIVATE_MUTATION,
29 | variables: {
30 | id: `gid://shopify/Customer/${jsonBody.id}`,
31 | input: {
32 | password: jsonBody.password,
33 | activationToken: jsonBody.activationToken,
34 | },
35 | },
36 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
37 | cache: CacheNone(),
38 | });
39 |
40 | if (data?.customerActivate?.customerAccessToken?.accessToken) {
41 | await session.set(
42 | 'customerAccessToken',
43 | data.customerActivate.customerAccessToken.accessToken,
44 | );
45 |
46 | return new Response(null, {
47 | status: 200,
48 | });
49 | } else {
50 | return new Response(
51 | JSON.stringify({
52 | error: getApiErrorMessage('customerActivate', data, errors),
53 | }),
54 | {status: 401},
55 | );
56 | }
57 | }
58 |
59 | const CUSTOMER_ACTIVATE_MUTATION = gql`
60 | mutation customerActivate($id: ID!, $input: CustomerActivateInput!) {
61 | customerActivate(id: $id, input: $input) {
62 | customerAccessToken {
63 | accessToken
64 | expiresAt
65 | }
66 | customerUserErrors {
67 | code
68 | field
69 | message
70 | }
71 | }
72 | }
73 | `;
74 |
--------------------------------------------------------------------------------
/src/routes/account/address/[addressId].server.js:
--------------------------------------------------------------------------------
1 | import {CacheNone, gql} from '@shopify/hydrogen';
2 |
3 | import {getApiErrorMessage} from '~/lib/utils';
4 |
5 | export async function api(request, {params, session, queryShop}) {
6 | if (!session) {
7 | return new Response('Session storage not available.', {
8 | status: 400,
9 | });
10 | }
11 |
12 | const {customerAccessToken} = await session.get();
13 |
14 | if (!customerAccessToken) return new Response(null, {status: 401});
15 |
16 | if (request.method === 'PATCH')
17 | return updateAddress(customerAccessToken, request, params, queryShop);
18 | if (request.method === 'DELETE')
19 | return deleteAddress(customerAccessToken, params, queryShop);
20 |
21 | return new Response(null, {
22 | status: 405,
23 | headers: {
24 | Allow: 'PATCH,DELETE',
25 | },
26 | });
27 | }
28 |
29 | async function deleteAddress(customerAccessToken, params, queryShop) {
30 | const {data, errors} = await queryShop({
31 | query: DELETE_ADDRESS_MUTATION,
32 | variables: {
33 | customerAccessToken,
34 | id: decodeURIComponent(params.addressId),
35 | },
36 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
37 | cache: CacheNone(),
38 | });
39 |
40 | const error = getApiErrorMessage('customerAddressDelete', data, errors);
41 |
42 | if (error) return new Response(JSON.stringify({error}), {status: 400});
43 |
44 | return new Response(null);
45 | }
46 |
47 | async function updateAddress(customerAccessToken, request, params, queryShop) {
48 | const {
49 | firstName,
50 | lastName,
51 | company,
52 | address1,
53 | address2,
54 | country,
55 | province,
56 | city,
57 | zip,
58 | phone,
59 | isDefaultAddress,
60 | } = await request.json();
61 |
62 | const address = {};
63 |
64 | if (firstName) address.firstName = firstName;
65 | if (lastName) address.lastName = lastName;
66 | if (company) address.company = company;
67 | if (address1) address.address1 = address1;
68 | if (address2) address.address2 = address2;
69 | if (country) address.country = country;
70 | if (province) address.province = province;
71 | if (city) address.city = city;
72 | if (zip) address.zip = zip;
73 | if (phone) address.phone = phone;
74 |
75 | const {data, errors} = await queryShop({
76 | query: UPDATE_ADDRESS_MUTATION,
77 | variables: {
78 | address,
79 | customerAccessToken,
80 | id: decodeURIComponent(params.addressId),
81 | },
82 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
83 | cache: CacheNone(),
84 | });
85 |
86 | const error = getApiErrorMessage('customerAddressUpdate', data, errors);
87 |
88 | if (error) return new Response(JSON.stringify({error}), {status: 400});
89 |
90 | if (isDefaultAddress) {
91 | const {data, errors} = await setDefaultAddress(
92 | queryShop,
93 | decodeURIComponent(params.addressId),
94 | customerAccessToken,
95 | );
96 |
97 | const error = getApiErrorMessage(
98 | 'customerDefaultAddressUpdate',
99 | data,
100 | errors,
101 | );
102 |
103 | if (error) return new Response(JSON.stringify({error}), {status: 400});
104 | }
105 |
106 | return new Response(null);
107 | }
108 |
109 | export function setDefaultAddress(queryShop, addressId, customerAccessToken) {
110 | return queryShop({
111 | query: UPDATE_DEFAULT_ADDRESS_MUTATION,
112 | variables: {
113 | customerAccessToken,
114 | addressId,
115 | },
116 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
117 | cache: CacheNone(),
118 | });
119 | }
120 |
121 | const UPDATE_ADDRESS_MUTATION = gql`
122 | mutation customerAddressUpdate(
123 | $address: MailingAddressInput!
124 | $customerAccessToken: String!
125 | $id: ID!
126 | ) {
127 | customerAddressUpdate(
128 | address: $address
129 | customerAccessToken: $customerAccessToken
130 | id: $id
131 | ) {
132 | customerUserErrors {
133 | code
134 | field
135 | message
136 | }
137 | }
138 | }
139 | `;
140 |
141 | const UPDATE_DEFAULT_ADDRESS_MUTATION = gql`
142 | mutation customerDefaultAddressUpdate(
143 | $addressId: ID!
144 | $customerAccessToken: String!
145 | ) {
146 | customerDefaultAddressUpdate(
147 | addressId: $addressId
148 | customerAccessToken: $customerAccessToken
149 | ) {
150 | customerUserErrors {
151 | code
152 | field
153 | message
154 | }
155 | }
156 | }
157 | `;
158 |
159 | const DELETE_ADDRESS_MUTATION = gql`
160 | mutation customerAddressDelete($customerAccessToken: String!, $id: ID!) {
161 | customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {
162 | customerUserErrors {
163 | code
164 | field
165 | message
166 | }
167 | deletedCustomerAddressId
168 | }
169 | }
170 | `;
171 |
--------------------------------------------------------------------------------
/src/routes/account/address/index.server.js:
--------------------------------------------------------------------------------
1 | import {setDefaultAddress} from './[addressId].server';
2 | import {CacheNone, gql} from '@shopify/hydrogen';
3 |
4 | import {getApiErrorMessage} from '~/lib/utils';
5 |
6 | export async function api(request, {session, queryShop}) {
7 | if (request.method !== 'POST') {
8 | return new Response(null, {
9 | status: 405,
10 | headers: {
11 | Allow: 'POST',
12 | },
13 | });
14 | }
15 |
16 | if (!session) {
17 | return new Response('Session storage not available.', {
18 | status: 400,
19 | });
20 | }
21 |
22 | const {customerAccessToken} = await session.get();
23 |
24 | if (!customerAccessToken) return new Response(null, {status: 401});
25 |
26 | const {
27 | firstName,
28 | lastName,
29 | company,
30 | address1,
31 | address2,
32 | country,
33 | province,
34 | city,
35 | zip,
36 | phone,
37 | isDefaultAddress,
38 | } = await request.json();
39 |
40 | const address = {};
41 |
42 | if (firstName) address.firstName = firstName;
43 | if (lastName) address.lastName = lastName;
44 | if (company) address.company = company;
45 | if (address1) address.address1 = address1;
46 | if (address2) address.address2 = address2;
47 | if (country) address.country = country;
48 | if (province) address.province = province;
49 | if (city) address.city = city;
50 | if (zip) address.zip = zip;
51 | if (phone) address.phone = phone;
52 |
53 | const {data, errors} = await queryShop({
54 | query: CREATE_ADDRESS_MUTATION,
55 | variables: {
56 | address,
57 | customerAccessToken,
58 | },
59 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
60 | cache: CacheNone(),
61 | });
62 |
63 | const error = getApiErrorMessage('customerAddressCreate', data, errors);
64 |
65 | if (error) return new Response(JSON.stringify({error}), {status: 400});
66 |
67 | if (isDefaultAddress) {
68 | const {data: defaultDataResponse, errors} = await setDefaultAddress(
69 | queryShop,
70 | data.customerAddressCreate.customerAddress.id,
71 | customerAccessToken,
72 | );
73 |
74 | const error = getApiErrorMessage(
75 | 'customerDefaultAddressUpdate',
76 | defaultDataResponse,
77 | errors,
78 | );
79 |
80 | if (error) return new Response(JSON.stringify({error}), {status: 400});
81 | }
82 |
83 | return new Response(null);
84 | }
85 |
86 | const CREATE_ADDRESS_MUTATION = gql`
87 | mutation customerAddressCreate(
88 | $address: MailingAddressInput!
89 | $customerAccessToken: String!
90 | ) {
91 | customerAddressCreate(
92 | address: $address
93 | customerAccessToken: $customerAccessToken
94 | ) {
95 | customerAddress {
96 | id
97 | }
98 | customerUserErrors {
99 | code
100 | field
101 | message
102 | }
103 | }
104 | }
105 | `;
106 |
--------------------------------------------------------------------------------
/src/routes/account/login.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useShopQuery, CacheLong, CacheNone, Seo, gql} from '@shopify/hydrogen';
3 |
4 | import {AccountLoginForm} from '~/components';
5 | import {Layout} from '~/components/index.server';
6 |
7 | export default function Login({response}) {
8 | response.cache(CacheNone());
9 |
10 | const {
11 | data: {
12 | shop: {name},
13 | },
14 | } = useShopQuery({
15 | query: SHOP_QUERY,
16 | cache: CacheLong(),
17 | preload: '*',
18 | });
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | const SHOP_QUERY = gql`
31 | query shopInfo {
32 | shop {
33 | name
34 | }
35 | }
36 | `;
37 |
38 | export async function api(request, {session, queryShop}) {
39 | if (!session) {
40 | return new Response('Session storage not available.', {status: 400});
41 | }
42 |
43 | const jsonBody = await request.json();
44 |
45 | if (!jsonBody.email || !jsonBody.password) {
46 | return new Response(
47 | JSON.stringify({error: 'Incorrect email or password.'}),
48 | {status: 400},
49 | );
50 | }
51 |
52 | const {data, errors} = await queryShop({
53 | query: LOGIN_MUTATION,
54 | variables: {
55 | input: {
56 | email: jsonBody.email,
57 | password: jsonBody.password,
58 | },
59 | },
60 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
61 | cache: CacheNone(),
62 | });
63 |
64 | if (data?.customerAccessTokenCreate?.customerAccessToken?.accessToken) {
65 | await session.set(
66 | 'customerAccessToken',
67 | data.customerAccessTokenCreate.customerAccessToken.accessToken,
68 | );
69 |
70 | return new Response(null, {
71 | status: 200,
72 | });
73 | } else {
74 | return new Response(
75 | JSON.stringify({
76 | error: data?.customerAccessTokenCreate?.customerUserErrors ?? errors,
77 | }),
78 | {status: 401},
79 | );
80 | }
81 | }
82 |
83 | const LOGIN_MUTATION = gql`
84 | mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
85 | customerAccessTokenCreate(input: $input) {
86 | customerUserErrors {
87 | code
88 | field
89 | message
90 | }
91 | customerAccessToken {
92 | accessToken
93 | expiresAt
94 | }
95 | }
96 | }
97 | `;
98 |
--------------------------------------------------------------------------------
/src/routes/account/logout.server.js:
--------------------------------------------------------------------------------
1 | export async function api(request, {session}) {
2 | if (request.method !== 'POST') {
3 | return new Response('Post required to logout', {
4 | status: 405,
5 | headers: {
6 | Allow: 'POST',
7 | },
8 | });
9 | }
10 |
11 | if (!session) {
12 | return new Response('Session storage not available.', {
13 | status: 400,
14 | });
15 | }
16 |
17 | await session.set('customerAccessToken', '');
18 |
19 | return new Response();
20 | }
21 |
--------------------------------------------------------------------------------
/src/routes/account/recover.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {CacheNone, Seo, gql} from '@shopify/hydrogen';
3 |
4 | import {AccountRecoverForm} from '~/components';
5 | import {Layout} from '~/components/index.server';
6 |
7 | /**
8 | * A form for the user to fill out to _initiate_ a password reset.
9 | * If the form succeeds, an email will be sent to the user with a link
10 | * to reset their password. Clicking the link leads the user to the
11 | * page `/account/reset/[resetToken]`.
12 | */
13 | export default function AccountRecover({response}) {
14 | response.cache(CacheNone());
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export async function api(request, {queryShop}) {
27 | const jsonBody = await request.json();
28 |
29 | if (!jsonBody.email) {
30 | return new Response(JSON.stringify({error: 'Email required'}), {
31 | status: 400,
32 | });
33 | }
34 |
35 | await queryShop({
36 | query: CUSTOMER_RECOVER_MUTATION,
37 | variables: {
38 | email: jsonBody.email,
39 | },
40 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
41 | cache: CacheNone(),
42 | });
43 |
44 | // Ignore errors, we don't want to tell the user if the email was
45 | // valid or not, thereby allowing them to determine who uses the site
46 | return new Response(null, {
47 | status: 200,
48 | });
49 | }
50 |
51 | const CUSTOMER_RECOVER_MUTATION = gql`
52 | mutation customerRecover($email: String!) {
53 | customerRecover(email: $email) {
54 | customerUserErrors {
55 | code
56 | field
57 | message
58 | }
59 | }
60 | }
61 | `;
62 |
--------------------------------------------------------------------------------
/src/routes/account/register.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {CacheNone, Seo, gql} from '@shopify/hydrogen';
3 |
4 | import {AccountCreateForm} from '~/components';
5 | import {Layout} from '~/components/index.server';
6 | import {getApiErrorMessage} from '~/lib/utils';
7 |
8 | export default function Register({response}) {
9 | response.cache(CacheNone());
10 |
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | export async function api(request, {queryShop}) {
22 | const jsonBody = await request.json();
23 |
24 | if (!jsonBody.email || !jsonBody.password) {
25 | return new Response(
26 | JSON.stringify({error: 'Email and password are required'}),
27 | {status: 400},
28 | );
29 | }
30 |
31 | const {data, errors} = await queryShop({
32 | query: CUSTOMER_CREATE_MUTATION,
33 | variables: {
34 | input: {
35 | email: jsonBody.email,
36 | password: jsonBody.password,
37 | firstName: jsonBody.firstName,
38 | lastName: jsonBody.lastName,
39 | },
40 | },
41 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
42 | cache: CacheNone(),
43 | });
44 |
45 | const errorMessage = getApiErrorMessage('customerCreate', data, errors);
46 |
47 | if (
48 | !errorMessage &&
49 | data &&
50 | data.customerCreate &&
51 | data.customerCreate.customer &&
52 | data.customerCreate.customer.id
53 | ) {
54 | return new Response(null, {
55 | status: 200,
56 | });
57 | } else {
58 | return new Response(
59 | JSON.stringify({
60 | error: errorMessage ?? 'Unknown error',
61 | }),
62 | {status: 401},
63 | );
64 | }
65 | }
66 |
67 | const CUSTOMER_CREATE_MUTATION = gql`
68 | mutation customerCreate($input: CustomerCreateInput!) {
69 | customerCreate(input: $input) {
70 | customer {
71 | id
72 | }
73 | customerUserErrors {
74 | code
75 | field
76 | message
77 | }
78 | }
79 | }
80 | `;
81 |
--------------------------------------------------------------------------------
/src/routes/account/reset/[id]/[resetToken].server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useRouteParams, Seo} from '@shopify/hydrogen';
3 |
4 | import {AccountPasswordResetForm} from '~/components';
5 | import {Layout} from '~/components/index.server';
6 |
7 | /**
8 | * This page shows a form for the user to enter a new password.
9 | * It should only be accessed by a link emailed to the user after
10 | * they initiate a password reset from `/account/recover`.
11 | */
12 | export default function ResetPassword() {
13 | const {id, resetToken} = useRouteParams();
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/routes/account/reset/index.server.js:
--------------------------------------------------------------------------------
1 | import {CacheNone, gql} from '@shopify/hydrogen';
2 | import {getApiErrorMessage} from '~/lib/utils';
3 |
4 | /**
5 | * This API route is used by the form on `/account/reset/[id]/[resetToken]`
6 | * complete the reset of the user's password.
7 | */
8 | export async function api(request, {session, queryShop}) {
9 | if (!session) {
10 | return new Response('Session storage not available.', {
11 | status: 400,
12 | });
13 | }
14 |
15 | const jsonBody = await request.json();
16 |
17 | if (!jsonBody.id || !jsonBody.password || !jsonBody.resetToken) {
18 | return new Response(
19 | JSON.stringify({error: 'Incorrect password or reset token.'}),
20 | {
21 | status: 400,
22 | },
23 | );
24 | }
25 |
26 | const {data, errors} = await queryShop({
27 | query: CUSTOMER_RESET_MUTATION,
28 | variables: {
29 | id: `gid://shopify/Customer/${jsonBody.id}`,
30 | input: {
31 | password: jsonBody.password,
32 | resetToken: jsonBody.resetToken,
33 | },
34 | },
35 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
36 | cache: CacheNone(),
37 | });
38 |
39 | if (data?.customerReset?.customerAccessToken?.accessToken) {
40 | await session.set(
41 | 'customerAccessToken',
42 | data.customerReset.customerAccessToken.accessToken,
43 | );
44 |
45 | return new Response(null, {
46 | status: 200,
47 | });
48 | } else {
49 | return new Response(
50 | JSON.stringify({
51 | error: getApiErrorMessage('customerReset', data, errors),
52 | }),
53 | {status: 401},
54 | );
55 | }
56 | }
57 |
58 | const CUSTOMER_RESET_MUTATION = gql`
59 | mutation customerReset($id: ID!, $input: CustomerResetInput!) {
60 | customerReset(id: $id, input: $input) {
61 | customerAccessToken {
62 | accessToken
63 | expiresAt
64 | }
65 | customerUserErrors {
66 | code
67 | field
68 | message
69 | }
70 | }
71 | }
72 | `;
73 |
--------------------------------------------------------------------------------
/src/routes/admin.server.jsx:
--------------------------------------------------------------------------------
1 | import {useShopQuery, gql, CacheLong} from '@shopify/hydrogen';
2 |
3 | /*
4 | This route redirects you to your Shopify Admin
5 | by querying for your myshopify.com domain.
6 | Learn more about the redirect method here:
7 | https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect
8 | */
9 |
10 | export default function AdminRedirect({response}) {
11 | const {data} = useShopQuery({
12 | query: SHOP_QUERY,
13 | cache: CacheLong(),
14 | });
15 |
16 | const {url} = data.shop.primaryDomain;
17 | return response.redirect(`${url}/admin`);
18 | }
19 |
20 | const SHOP_QUERY = gql`
21 | query {
22 | shop {
23 | primaryDomain {
24 | url
25 | }
26 | }
27 | }
28 | `;
29 |
--------------------------------------------------------------------------------
/src/routes/api/bestSellers.server.js:
--------------------------------------------------------------------------------
1 | import {gql} from '@shopify/hydrogen';
2 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
3 |
4 | export async function api(_request, {queryShop}) {
5 | const {
6 | data: {products},
7 | } = await queryShop({
8 | query: TOP_PRODUCTS_QUERY,
9 | variables: {
10 | count: 4,
11 | },
12 | });
13 |
14 | return products.nodes;
15 | }
16 |
17 | const TOP_PRODUCTS_QUERY = gql`
18 | ${PRODUCT_CARD_FRAGMENT}
19 | query topProducts(
20 | $count: Int
21 | $countryCode: CountryCode
22 | $languageCode: LanguageCode
23 | ) @inContext(country: $countryCode, language: $languageCode) {
24 | products(first: $count, sortKey: BEST_SELLING) {
25 | nodes {
26 | ...ProductCard
27 | }
28 | }
29 | }
30 | `;
31 |
--------------------------------------------------------------------------------
/src/routes/api/countries.server.js:
--------------------------------------------------------------------------------
1 | import {gql} from '@shopify/hydrogen';
2 |
3 | export async function api(_request, {queryShop}) {
4 | const {
5 | data: {
6 | localization: {availableCountries},
7 | },
8 | } = await queryShop({
9 | query: COUNTRIES_QUERY,
10 | });
11 |
12 | return availableCountries.sort((a, b) => a.name.localeCompare(b.name));
13 | }
14 |
15 | const COUNTRIES_QUERY = gql`
16 | query Localization {
17 | localization {
18 | availableCountries {
19 | isoCode
20 | name
21 | currency {
22 | isoCode
23 | }
24 | }
25 | }
26 | }
27 | `;
28 |
--------------------------------------------------------------------------------
/src/routes/cart.server.jsx:
--------------------------------------------------------------------------------
1 | import {Seo} from '@shopify/hydrogen';
2 | import {PageHeader, Section, CartDetails} from '~/components';
3 | import {Layout} from '~/components/index.server';
4 |
5 | export default function Cart() {
6 | return (
7 |
8 |
9 |
10 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/routes/collections/[handle].server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | gql,
4 | Seo,
5 | ShopifyAnalyticsConstants,
6 | useServerAnalytics,
7 | useLocalization,
8 | useShopQuery,
9 | } from '@shopify/hydrogen';
10 |
11 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
12 | import {PageHeader, ProductGrid, Section, Text} from '~/components';
13 | import {NotFound, Layout} from '~/components/index.server';
14 |
15 | const pageBy = 48;
16 |
17 | export default function Collection({params}) {
18 | const {handle} = params;
19 | const {
20 | language: {isoCode: language},
21 | country: {isoCode: country},
22 | } = useLocalization();
23 |
24 | const {
25 | data: {collection},
26 | } = useShopQuery({
27 | query: COLLECTION_QUERY,
28 | variables: {
29 | handle,
30 | language,
31 | country,
32 | pageBy,
33 | },
34 | preload: true,
35 | });
36 |
37 | if (!collection) {
38 | return ;
39 | }
40 |
41 | useServerAnalytics({
42 | shopify: {
43 | pageType: ShopifyAnalyticsConstants.pageType.collection,
44 | resourceId: collection.id,
45 | },
46 | });
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
54 | {collection?.description && (
55 |
56 |
57 |
58 | {collection.description}
59 |
60 |
61 |
62 | )}
63 |
64 |
71 |
72 | );
73 | }
74 |
75 | // API endpoint that returns paginated products for this collection
76 | // @see templates/demo-store/src/components/product/ProductGrid.client.tsx
77 | export async function api(request, {params, queryShop}) {
78 | if (request.method !== 'POST') {
79 | return new Response('Method not allowed', {
80 | status: 405,
81 | headers: {Allow: 'POST'},
82 | });
83 | }
84 | const url = new URL(request.url);
85 |
86 | const cursor = url.searchParams.get('cursor');
87 | const country = url.searchParams.get('country');
88 | const {handle} = params;
89 |
90 | return await queryShop({
91 | query: PAGINATE_COLLECTION_QUERY,
92 | variables: {
93 | handle,
94 | cursor,
95 | pageBy,
96 | country,
97 | },
98 | });
99 | }
100 |
101 | const COLLECTION_QUERY = gql`
102 | ${PRODUCT_CARD_FRAGMENT}
103 | query CollectionDetails(
104 | $handle: String!
105 | $country: CountryCode
106 | $language: LanguageCode
107 | $pageBy: Int!
108 | $cursor: String
109 | ) @inContext(country: $country, language: $language) {
110 | collection(handle: $handle) {
111 | id
112 | title
113 | description
114 | seo {
115 | description
116 | title
117 | }
118 | image {
119 | id
120 | url
121 | width
122 | height
123 | altText
124 | }
125 | products(first: $pageBy, after: $cursor) {
126 | nodes {
127 | ...ProductCard
128 | }
129 | pageInfo {
130 | hasNextPage
131 | endCursor
132 | }
133 | }
134 | }
135 | }
136 | `;
137 |
138 | const PAGINATE_COLLECTION_QUERY = gql`
139 | ${PRODUCT_CARD_FRAGMENT}
140 | query CollectionPage(
141 | $handle: String!
142 | $pageBy: Int!
143 | $cursor: String
144 | $country: CountryCode
145 | $language: LanguageCode
146 | ) @inContext(country: $country, language: $language) {
147 | collection(handle: $handle) {
148 | products(first: $pageBy, after: $cursor) {
149 | nodes {
150 | ...ProductCard
151 | }
152 | pageInfo {
153 | hasNextPage
154 | endCursor
155 | }
156 | }
157 | }
158 | }
159 | `;
160 |
--------------------------------------------------------------------------------
/src/routes/collections/all.server.jsx:
--------------------------------------------------------------------------------
1 | export default function Redirect({response}) {
2 | return response.redirect('/products');
3 | }
4 |
--------------------------------------------------------------------------------
/src/routes/collections/index.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useShopQuery, useLocalization, gql, Seo} from '@shopify/hydrogen';
3 |
4 | import {PageHeader, Section, Grid} from '~/components';
5 | import {Layout, CollectionCard} from '~/components/index.server';
6 | import {getImageLoadingPriority, PAGINATION_SIZE} from '~/lib/const';
7 |
8 | export default function Collections() {
9 | return (
10 |
11 |
12 |
13 |
18 |
19 | );
20 | }
21 |
22 | function CollectionGrid() {
23 | const {
24 | language: {isoCode: languageCode},
25 | country: {isoCode: countryCode},
26 | } = useLocalization();
27 |
28 | const {data} = useShopQuery({
29 | query: COLLECTIONS_QUERY,
30 | variables: {
31 | pageBy: PAGINATION_SIZE,
32 | country: countryCode,
33 | language: languageCode,
34 | },
35 | preload: true,
36 | });
37 |
38 | const collections = data.collections.nodes;
39 |
40 | return (
41 |
42 | {collections.map((collection, i) => (
43 |
48 | ))}
49 |
50 | );
51 | }
52 |
53 | const COLLECTIONS_QUERY = gql`
54 | query Collections(
55 | $country: CountryCode
56 | $language: LanguageCode
57 | $pageBy: Int!
58 | ) @inContext(country: $country, language: $language) {
59 | collections(first: $pageBy) {
60 | nodes {
61 | id
62 | title
63 | description
64 | handle
65 | seo {
66 | description
67 | title
68 | }
69 | image {
70 | id
71 | url
72 | width
73 | height
74 | altText
75 | }
76 | }
77 | }
78 | }
79 | `;
80 |
--------------------------------------------------------------------------------
/src/routes/index.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | CacheLong,
4 | gql,
5 | Seo,
6 | ShopifyAnalyticsConstants,
7 | useServerAnalytics,
8 | useLocalization,
9 | useShopQuery,
10 | } from '@shopify/hydrogen';
11 |
12 | import {MEDIA_FRAGMENT, PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
13 | import {getHeroPlaceholder} from '~/lib/placeholders';
14 | import {FeaturedCollections, Hero} from '~/components';
15 | import {Layout, ProductSwimlane} from '~/components/index.server';
16 |
17 | export default function Homepage() {
18 | useServerAnalytics({
19 | shopify: {
20 | pageType: ShopifyAnalyticsConstants.pageType.home,
21 | },
22 | });
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | function HomepageContent() {
37 | const {
38 | language: {isoCode: languageCode},
39 | country: {isoCode: countryCode},
40 | } = useLocalization();
41 |
42 | const {data} = useShopQuery({
43 | query: HOMEPAGE_CONTENT_QUERY,
44 | variables: {
45 | language: languageCode,
46 | country: countryCode,
47 | },
48 | preload: true,
49 | });
50 |
51 | const {heroBanners, featuredCollections, featuredProducts} = data;
52 |
53 | // fill in the hero banners with placeholders if they're missing
54 | const [primaryHero, secondaryHero, tertiaryHero] = getHeroPlaceholder(
55 | heroBanners.nodes,
56 | );
57 |
58 | return (
59 | <>
60 | {primaryHero && (
61 |
62 | )}
63 |
68 | {secondaryHero && }
69 |
73 | {tertiaryHero && }
74 | >
75 | );
76 | }
77 |
78 | function SeoForHomepage() {
79 | const {
80 | data: {
81 | shop: {name, description},
82 | },
83 | } = useShopQuery({
84 | query: HOMEPAGE_SEO_QUERY,
85 | cache: CacheLong(),
86 | preload: true,
87 | });
88 |
89 | return (
90 |
98 | );
99 | }
100 |
101 | /**
102 | * The homepage content query includes a request for custom metafields inside the alias
103 | * `heroBanners`. The template loads placeholder content if these metafields don't
104 | * exist. Define the following five custom metafields on your Shopify store to override placeholders:
105 | * - hero.title Single line text
106 | * - hero.byline Single line text
107 | * - hero.cta Single line text
108 | * - hero.spread File
109 | * - hero.spread_seconary File
110 | *
111 | * @see https://help.shopify.com/manual/metafields/metafield-definitions/creating-custom-metafield-definitions
112 | * @see https://github.com/Shopify/hydrogen/discussions/1790
113 | */
114 |
115 | const HOMEPAGE_CONTENT_QUERY = gql`
116 | ${MEDIA_FRAGMENT}
117 | ${PRODUCT_CARD_FRAGMENT}
118 | query homepage($country: CountryCode, $language: LanguageCode)
119 | @inContext(country: $country, language: $language) {
120 | heroBanners: collections(
121 | first: 3
122 | query: "collection_type:custom"
123 | sortKey: UPDATED_AT
124 | ) {
125 | nodes {
126 | id
127 | handle
128 | title
129 | descriptionHtml
130 | heading: metafield(namespace: "hero", key: "title") {
131 | value
132 | }
133 | byline: metafield(namespace: "hero", key: "byline") {
134 | value
135 | }
136 | cta: metafield(namespace: "hero", key: "cta") {
137 | value
138 | }
139 | spread: metafield(namespace: "hero", key: "spread") {
140 | reference {
141 | ...Media
142 | }
143 | }
144 | spreadSecondary: metafield(namespace: "hero", key: "spread_secondary") {
145 | reference {
146 | ...Media
147 | }
148 | }
149 | }
150 | }
151 | featuredCollections: collections(
152 | first: 3
153 | query: "collection_type:smart"
154 | sortKey: UPDATED_AT
155 | ) {
156 | nodes {
157 | id
158 | title
159 | handle
160 | image {
161 | altText
162 | width
163 | height
164 | url
165 | }
166 | }
167 | }
168 | featuredProducts: products(first: 12) {
169 | nodes {
170 | ...ProductCard
171 | }
172 | }
173 | }
174 | `;
175 |
176 | const HOMEPAGE_SEO_QUERY = gql`
177 | query shopInfo {
178 | shop {
179 | name
180 | description
181 | }
182 | }
183 | `;
184 |
--------------------------------------------------------------------------------
/src/routes/journal/[handle].server.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | Seo,
5 | gql,
6 | Image,
7 | CacheLong,
8 | } from '@shopify/hydrogen';
9 | import {Suspense} from 'react';
10 |
11 | import {CustomFont, PageHeader, Section} from '~/components';
12 | import {Layout, NotFound} from '~/components/index.server';
13 | import {ATTR_LOADING_EAGER} from '~/lib/const';
14 |
15 | const BLOG_HANDLE = 'journal';
16 |
17 | export default function Post({params, response}) {
18 | response.cache(CacheLong());
19 | const {
20 | language: {isoCode: languageCode},
21 | country: {isoCode: countryCode},
22 | } = useLocalization();
23 |
24 | const {handle} = params;
25 | const {data} = useShopQuery({
26 | query: ARTICLE_QUERY,
27 | variables: {
28 | language: languageCode,
29 | blogHandle: BLOG_HANDLE,
30 | articleHandle: handle,
31 | },
32 | });
33 |
34 | if (!data?.blog?.articleByHandle) {
35 | return ;
36 | }
37 |
38 | const {title, publishedAt, contentHtml, author} = data.blog.articleByHandle;
39 | const formattedDate = new Intl.DateTimeFormat(
40 | `${languageCode}-${countryCode}`,
41 | {
42 | year: 'numeric',
43 | month: 'long',
44 | day: 'numeric',
45 | },
46 | ).format(new Date(publishedAt));
47 |
48 | return (
49 |
50 | {/* Loads Fraunces custom font only on articles */}
51 |
52 |
53 | {/* @ts-expect-error Blog article types are not supported in TS */}
54 |
55 |
56 |
57 |
58 | {formattedDate} · {author.name}
59 |
60 |
61 |
62 | {data.blog.articleByHandle.image && (
63 |
75 | )}
76 |
80 |
81 |
82 | );
83 | }
84 |
85 | const ARTICLE_QUERY = gql`
86 | query ArticleDetails(
87 | $language: LanguageCode
88 | $blogHandle: String!
89 | $articleHandle: String!
90 | ) @inContext(language: $language) {
91 | blog(handle: $blogHandle) {
92 | articleByHandle(handle: $articleHandle) {
93 | title
94 | contentHtml
95 | publishedAt
96 | author: authorV2 {
97 | name
98 | }
99 | image {
100 | id
101 | altText
102 | url
103 | width
104 | height
105 | }
106 | }
107 | }
108 | }
109 | `;
110 |
--------------------------------------------------------------------------------
/src/routes/journal/index.server.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | CacheLong,
3 | flattenConnection,
4 | gql,
5 | Seo,
6 | useLocalization,
7 | useShopQuery,
8 | } from '@shopify/hydrogen';
9 | import {Suspense} from 'react';
10 |
11 | import {ArticleCard, Grid, PageHeader} from '~/components';
12 | import {Layout} from '~/components/index.server';
13 | import {getImageLoadingPriority, PAGINATION_SIZE} from '~/lib/const';
14 |
15 | const BLOG_HANDLE = 'Journal';
16 |
17 | export default function Blog({pageBy = PAGINATION_SIZE, response}) {
18 | response.cache(CacheLong());
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | function JournalsGrid({pageBy}) {
33 | const {
34 | language: {isoCode: languageCode},
35 | country: {isoCode: countryCode},
36 | } = useLocalization();
37 |
38 | const {data} = useShopQuery({
39 | query: BLOG_QUERY,
40 | variables: {
41 | language: languageCode,
42 | blogHandle: BLOG_HANDLE,
43 | pageBy,
44 | },
45 | });
46 |
47 | // TODO: How to fix this type?
48 | const rawArticles = flattenConnection(data.blog.articles);
49 |
50 | const articles = rawArticles.map((article) => {
51 | const {publishedAt} = article;
52 | return {
53 | ...article,
54 | publishedAt: new Intl.DateTimeFormat(`${languageCode}-${countryCode}`, {
55 | year: 'numeric',
56 | month: 'long',
57 | day: 'numeric',
58 | }).format(new Date(publishedAt)),
59 | };
60 | });
61 |
62 | if (articles.length === 0) {
63 | return No articles found
;
64 | }
65 |
66 | return (
67 |
68 | {articles.map((article, i) => {
69 | return (
70 |
76 | );
77 | })}
78 |
79 | );
80 | }
81 |
82 | const BLOG_QUERY = gql`
83 | query Blog(
84 | $language: LanguageCode
85 | $blogHandle: String!
86 | $pageBy: Int!
87 | $cursor: String
88 | ) @inContext(language: $language) {
89 | blog(handle: $blogHandle) {
90 | articles(first: $pageBy, after: $cursor) {
91 | edges {
92 | node {
93 | author: authorV2 {
94 | name
95 | }
96 | contentHtml
97 | handle
98 | id
99 | image {
100 | id
101 | altText
102 | url
103 | width
104 | height
105 | }
106 | publishedAt
107 | title
108 | }
109 | }
110 | }
111 | }
112 | }
113 | `;
114 |
--------------------------------------------------------------------------------
/src/routes/pages/[handle].server.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | Seo,
5 | useServerAnalytics,
6 | ShopifyAnalyticsConstants,
7 | gql,
8 | } from '@shopify/hydrogen';
9 | import {Suspense} from 'react';
10 |
11 | import {PageHeader} from '~/components';
12 | import {NotFound, Layout} from '~/components/index.server';
13 |
14 | export default function Page({params}) {
15 | const {
16 | language: {isoCode: languageCode},
17 | } = useLocalization();
18 |
19 | const {handle} = params;
20 | const {
21 | data: {page},
22 | } = useShopQuery({
23 | query: PAGE_QUERY,
24 | variables: {languageCode, handle},
25 | });
26 |
27 | if (!page) {
28 | return ;
29 | }
30 |
31 | useServerAnalytics({
32 | shopify: {
33 | pageType: ShopifyAnalyticsConstants.pageType.page,
34 | resourceId: page.id,
35 | },
36 | });
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
48 |
49 |
50 | );
51 | }
52 |
53 | const PAGE_QUERY = gql`
54 | query PageDetails($languageCode: LanguageCode, $handle: String!)
55 | @inContext(language: $languageCode) {
56 | page(handle: $handle) {
57 | id
58 | title
59 | body
60 | seo {
61 | description
62 | title
63 | }
64 | }
65 | }
66 | `;
67 |
--------------------------------------------------------------------------------
/src/routes/policies/[handle].server.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | Seo,
5 | useServerAnalytics,
6 | ShopifyAnalyticsConstants,
7 | gql,
8 | } from '@shopify/hydrogen';
9 | import {Suspense} from 'react';
10 |
11 | import {Button, PageHeader, Section} from '~/components';
12 | import {NotFound, Layout} from '~/components/index.server';
13 |
14 | export default function Policy({params}) {
15 | const {
16 | language: {isoCode: languageCode},
17 | } = useLocalization();
18 | const {handle} = params;
19 |
20 | // standard policy pages
21 | const policy = {
22 | privacyPolicy: handle === 'privacy-policy',
23 | shippingPolicy: handle === 'shipping-policy',
24 | termsOfService: handle === 'terms-of-service',
25 | refundPolicy: handle === 'refund-policy',
26 | };
27 |
28 | // if not a valid policy route, return not found
29 | if (
30 | !policy.privacyPolicy &&
31 | !policy.shippingPolicy &&
32 | !policy.termsOfService &&
33 | !policy.refundPolicy
34 | ) {
35 | return ;
36 | }
37 |
38 | // The currently visited policy page key
39 | const activePolicy = Object.keys(policy).find((key) => policy[key]);
40 |
41 | const {
42 | data: {shop},
43 | } = useShopQuery({
44 | query: POLICIES_QUERY,
45 | variables: {
46 | languageCode,
47 | ...policy,
48 | },
49 | });
50 |
51 | const page = shop?.[activePolicy];
52 |
53 | // If the policy page is empty, return not found
54 | if (!page) {
55 | return ;
56 | }
57 |
58 | useServerAnalytics({
59 | shopify: {
60 | pageType: ShopifyAnalyticsConstants.pageType.page,
61 | resourceId: page.id,
62 | },
63 | });
64 |
65 | return (
66 |
67 |
68 |
69 |
70 |
75 |
79 |
84 | ← Back to Policies
85 |
86 |
87 |
93 |
94 |
95 | );
96 | }
97 |
98 | const POLICIES_QUERY = gql`
99 | fragment Policy on ShopPolicy {
100 | body
101 | handle
102 | id
103 | title
104 | url
105 | }
106 |
107 | query PoliciesQuery(
108 | $languageCode: LanguageCode
109 | $privacyPolicy: Boolean!
110 | $shippingPolicy: Boolean!
111 | $termsOfService: Boolean!
112 | $refundPolicy: Boolean!
113 | ) @inContext(language: $languageCode) {
114 | shop {
115 | privacyPolicy @include(if: $privacyPolicy) {
116 | ...Policy
117 | }
118 | shippingPolicy @include(if: $shippingPolicy) {
119 | ...Policy
120 | }
121 | termsOfService @include(if: $termsOfService) {
122 | ...Policy
123 | }
124 | refundPolicy @include(if: $refundPolicy) {
125 | ...Policy
126 | }
127 | }
128 | }
129 | `;
130 |
--------------------------------------------------------------------------------
/src/routes/policies/index.server.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | useServerAnalytics,
5 | ShopifyAnalyticsConstants,
6 | gql,
7 | Link,
8 | } from '@shopify/hydrogen';
9 |
10 | import {PageHeader, Section, Heading} from '~/components';
11 | import {Layout, NotFound} from '~/components/index.server';
12 |
13 | export default function Policies() {
14 | const {
15 | language: {isoCode: languageCode},
16 | } = useLocalization();
17 |
18 | const {data} = useShopQuery({
19 | query: POLICIES_QUERY,
20 | variables: {
21 | languageCode,
22 | },
23 | });
24 |
25 | useServerAnalytics({
26 | shopify: {
27 | pageType: ShopifyAnalyticsConstants.pageType.page,
28 | },
29 | });
30 |
31 | const {
32 | privacyPolicy,
33 | shippingPolicy,
34 | termsOfService,
35 | refundPolicy,
36 | subscriptionPolicy,
37 | } = data.shop;
38 |
39 | const policies = [
40 | privacyPolicy,
41 | shippingPolicy,
42 | termsOfService,
43 | refundPolicy,
44 | subscriptionPolicy,
45 | ];
46 |
47 | if (policies.every((element) => element === null)) {
48 | return ;
49 | }
50 |
51 | return (
52 |
53 |
54 |
55 | {policies.map((policy) => {
56 | if (!policy) {
57 | return;
58 | }
59 | return (
60 |
61 | {policy.title}
62 |
63 | );
64 | })}
65 |
66 |
67 | );
68 | }
69 |
70 | const POLICIES_QUERY = gql`
71 | fragment Policy on ShopPolicy {
72 | id
73 | title
74 | handle
75 | }
76 |
77 | query PoliciesQuery {
78 | shop {
79 | privacyPolicy {
80 | ...Policy
81 | }
82 | shippingPolicy {
83 | ...Policy
84 | }
85 | termsOfService {
86 | ...Policy
87 | }
88 | refundPolicy {
89 | ...Policy
90 | }
91 | subscriptionPolicy {
92 | id
93 | title
94 | handle
95 | }
96 | }
97 | }
98 | `;
99 |
--------------------------------------------------------------------------------
/src/routes/products/[handle].server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | gql,
4 | ProductOptionsProvider,
5 | Seo,
6 | ShopifyAnalyticsConstants,
7 | useLocalization,
8 | useRouteParams,
9 | useServerAnalytics,
10 | useShopQuery,
11 | } from '@shopify/hydrogen';
12 |
13 | import {MEDIA_FRAGMENT} from '~/lib/fragments';
14 | import {getExcerpt} from '~/lib/utils';
15 | import {NotFound, Layout, ProductSwimlane} from '~/components/index.server';
16 | import {
17 | Heading,
18 | ProductDetail,
19 | ProductForm,
20 | ProductGallery,
21 | Section,
22 | Text,
23 | } from '~/components';
24 |
25 | export default function Product() {
26 | const {handle} = useRouteParams();
27 | const {
28 | language: {isoCode: languageCode},
29 | country: {isoCode: countryCode},
30 | } = useLocalization();
31 |
32 | const {
33 | data: {product, shop},
34 | } = useShopQuery({
35 | query: PRODUCT_QUERY,
36 | variables: {
37 | country: countryCode,
38 | language: languageCode,
39 | handle,
40 | },
41 | preload: true,
42 | });
43 |
44 | if (!product) {
45 | return ;
46 | }
47 |
48 | useServerAnalytics({
49 | shopify: {
50 | pageType: ShopifyAnalyticsConstants.pageType.product,
51 | resourceId: product.id,
52 | },
53 | });
54 |
55 | const {media, title, vendor, descriptionHtml, id} = product;
56 | const {shippingPolicy, refundPolicy} = shop;
57 |
58 | return (
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
70 |
71 |
72 |
73 |
74 | {title}
75 |
76 | {vendor && (
77 | {vendor}
78 | )}
79 |
80 |
81 |
82 | {descriptionHtml && (
83 |
87 | )}
88 | {shippingPolicy?.body && (
89 |
94 | )}
95 | {refundPolicy?.body && (
96 |
101 | )}
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | );
113 | }
114 |
115 | const PRODUCT_QUERY = gql`
116 | ${MEDIA_FRAGMENT}
117 | query Product(
118 | $country: CountryCode
119 | $language: LanguageCode
120 | $handle: String!
121 | ) @inContext(country: $country, language: $language) {
122 | product(handle: $handle) {
123 | id
124 | title
125 | vendor
126 | descriptionHtml
127 | media(first: 7) {
128 | nodes {
129 | ...Media
130 | }
131 | }
132 | variants(first: 100) {
133 | nodes {
134 | id
135 | availableForSale
136 | selectedOptions {
137 | name
138 | value
139 | }
140 | image {
141 | id
142 | url
143 | altText
144 | width
145 | height
146 | }
147 | priceV2 {
148 | amount
149 | currencyCode
150 | }
151 | compareAtPriceV2 {
152 | amount
153 | currencyCode
154 | }
155 | sku
156 | title
157 | unitPrice {
158 | amount
159 | currencyCode
160 | }
161 | }
162 | }
163 | seo {
164 | description
165 | title
166 | }
167 | }
168 | shop {
169 | shippingPolicy {
170 | body
171 | handle
172 | }
173 | refundPolicy {
174 | body
175 | handle
176 | }
177 | }
178 | }
179 | `;
180 |
--------------------------------------------------------------------------------
/src/routes/products/index.server.jsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useShopQuery, gql, useLocalization, Seo} from '@shopify/hydrogen';
3 |
4 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
5 | import {PAGINATION_SIZE} from '~/lib/const';
6 | import {ProductGrid, PageHeader, Section} from '~/components';
7 | import {Layout} from '~/components/index.server';
8 |
9 | export default function AllProducts() {
10 | return (
11 |
12 |
13 |
14 |
19 |
20 | );
21 | }
22 |
23 | function AllProductsGrid() {
24 | const {
25 | language: {isoCode: languageCode},
26 | country: {isoCode: countryCode},
27 | } = useLocalization();
28 |
29 | const {data} = useShopQuery({
30 | query: ALL_PRODUCTS_QUERY,
31 | variables: {
32 | country: countryCode,
33 | language: languageCode,
34 | pageBy: PAGINATION_SIZE,
35 | },
36 | preload: true,
37 | });
38 |
39 | const products = data.products;
40 |
41 | return (
42 |
47 | );
48 | }
49 |
50 | // API to paginate products
51 | // @see templates/demo-store/src/components/product/ProductGrid.client.tsx
52 | export async function api(request, {params, queryShop}) {
53 | if (request.method !== 'POST') {
54 | return new Response('Method not allowed', {
55 | status: 405,
56 | headers: {Allow: 'POST'},
57 | });
58 | }
59 |
60 | const url = new URL(request.url);
61 | const cursor = url.searchParams.get('cursor');
62 | const country = url.searchParams.get('country');
63 | const {handle} = params;
64 |
65 | return await queryShop({
66 | query: PAGINATE_ALL_PRODUCTS_QUERY,
67 | variables: {
68 | handle,
69 | cursor,
70 | pageBy: PAGINATION_SIZE,
71 | country,
72 | },
73 | });
74 | }
75 |
76 | const ALL_PRODUCTS_QUERY = gql`
77 | ${PRODUCT_CARD_FRAGMENT}
78 | query AllProducts(
79 | $country: CountryCode
80 | $language: LanguageCode
81 | $pageBy: Int!
82 | $cursor: String
83 | ) @inContext(country: $country, language: $language) {
84 | products(first: $pageBy, after: $cursor) {
85 | nodes {
86 | ...ProductCard
87 | }
88 | pageInfo {
89 | hasNextPage
90 | startCursor
91 | endCursor
92 | }
93 | }
94 | }
95 | `;
96 |
97 | const PAGINATE_ALL_PRODUCTS_QUERY = gql`
98 | ${PRODUCT_CARD_FRAGMENT}
99 | query ProductsPage(
100 | $pageBy: Int!
101 | $cursor: String
102 | $country: CountryCode
103 | $language: LanguageCode
104 | ) @inContext(country: $country, language: $language) {
105 | products(first: $pageBy, after: $cursor) {
106 | nodes {
107 | ...ProductCard
108 | }
109 | pageInfo {
110 | hasNextPage
111 | endCursor
112 | }
113 | }
114 | }
115 | `;
116 |
--------------------------------------------------------------------------------
/src/routes/robots.txt.server.js:
--------------------------------------------------------------------------------
1 | export async function api(request) {
2 | const url = new URL(request.url);
3 |
4 | return new Response(robotsTxtData({url: url.origin}), {
5 | headers: {
6 | 'content-type': 'text/plain',
7 | // Cache for 24 hours
8 | 'cache-control': `max-age=${60 * 60 * 24}`,
9 | },
10 | });
11 | }
12 |
13 | function robotsTxtData({url}) {
14 | const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
15 |
16 | return `
17 | User-agent: *
18 | Disallow: /admin
19 | Disallow: /cart
20 | Disallow: /orders
21 | Disallow: /checkouts/
22 | Disallow: /checkout
23 | Disallow: /carts
24 | Disallow: /account
25 | ${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}
26 |
27 | # Google adsbot ignores robots.txt unless specifically named!
28 | User-agent: adsbot-google
29 | Disallow: /checkouts/
30 | Disallow: /checkout
31 | Disallow: /carts
32 | Disallow: /orders
33 |
34 | User-agent: Pinterest
35 | Crawl-delay: 1
36 | `.trim();
37 | }
38 |
--------------------------------------------------------------------------------
/src/routes/search.server.jsx:
--------------------------------------------------------------------------------
1 | import {gql, useLocalization, useShopQuery, useUrl} from '@shopify/hydrogen';
2 |
3 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
4 | import {ProductGrid, Section, Text} from '~/components';
5 | import {NoResultRecommendations, SearchPage} from '~/components/index.server';
6 | import {PAGINATION_SIZE} from '~/lib/const';
7 | import {Suspense} from 'react';
8 |
9 | export default function Search({pageBy = PAGINATION_SIZE, params}) {
10 | const {
11 | language: {isoCode: languageCode},
12 | country: {isoCode: countryCode},
13 | } = useLocalization();
14 |
15 | const {handle} = params;
16 | const {searchParams} = useUrl();
17 |
18 | const searchTerm = searchParams.get('q');
19 |
20 | const {data} = useShopQuery({
21 | query: SEARCH_QUERY,
22 | variables: {
23 | handle,
24 | country: countryCode,
25 | language: languageCode,
26 | pageBy,
27 | searchTerm,
28 | },
29 | preload: true,
30 | });
31 |
32 | const products = data?.products;
33 | const noResults = products?.nodes?.length === 0;
34 |
35 | if (!searchTerm || noResults) {
36 | return (
37 |
38 | {noResults && (
39 |
40 | No results, try something else.
41 |
42 | )}
43 |
44 |
48 |
49 |
50 | );
51 | }
52 |
53 | return (
54 |
55 |
62 |
63 | );
64 | }
65 |
66 | // API to paginate the results of the search query.
67 | // @see templates/demo-store/src/components/product/ProductGrid.client.tsx
68 | export async function api(request, {params, queryShop}) {
69 | if (request.method !== 'POST') {
70 | return new Response('Method not allowed', {
71 | status: 405,
72 | headers: {Allow: 'POST'},
73 | });
74 | }
75 |
76 | const url = new URL(request.url);
77 | const cursor = url.searchParams.get('cursor');
78 | const country = url.searchParams.get('country');
79 | const searchTerm = url.searchParams.get('q');
80 | const {handle} = params;
81 |
82 | return await queryShop({
83 | query: PAGINATE_SEARCH_QUERY,
84 | variables: {
85 | handle,
86 | cursor,
87 | pageBy: PAGINATION_SIZE,
88 | country,
89 | searchTerm,
90 | },
91 | });
92 | }
93 |
94 | const SEARCH_QUERY = gql`
95 | ${PRODUCT_CARD_FRAGMENT}
96 | query search(
97 | $searchTerm: String
98 | $country: CountryCode
99 | $language: LanguageCode
100 | $pageBy: Int!
101 | $after: String
102 | ) @inContext(country: $country, language: $language) {
103 | products(
104 | first: $pageBy
105 | sortKey: RELEVANCE
106 | query: $searchTerm
107 | after: $after
108 | ) {
109 | nodes {
110 | ...ProductCard
111 | }
112 | pageInfo {
113 | startCursor
114 | endCursor
115 | hasNextPage
116 | hasPreviousPage
117 | }
118 | }
119 | }
120 | `;
121 |
122 | const PAGINATE_SEARCH_QUERY = gql`
123 | ${PRODUCT_CARD_FRAGMENT}
124 | query ProductsPage(
125 | $searchTerm: String
126 | $pageBy: Int!
127 | $cursor: String
128 | $country: CountryCode
129 | $language: LanguageCode
130 | ) @inContext(country: $country, language: $language) {
131 | products(
132 | sortKey: RELEVANCE
133 | query: $searchTerm
134 | first: $pageBy
135 | after: $cursor
136 | ) {
137 | nodes {
138 | ...ProductCard
139 | }
140 | pageInfo {
141 | hasNextPage
142 | endCursor
143 | }
144 | }
145 | }
146 | `;
147 |
--------------------------------------------------------------------------------
/src/routes/sitemap.xml.server.js:
--------------------------------------------------------------------------------
1 | import {flattenConnection, gql} from '@shopify/hydrogen';
2 |
3 | const MAX_URLS = 250; // the google limit is 50K, however, SF API only allow querying for 250 resources each time
4 |
5 | export async function api(request, {queryShop}) {
6 | const {data} = await queryShop({
7 | query: QUERY,
8 | variables: {
9 | language: 'EN',
10 | urlLimits: MAX_URLS,
11 | },
12 | });
13 |
14 | return new Response(shopSitemap(data, new URL(request.url).origin), {
15 | headers: {
16 | 'content-type': 'application/xml',
17 | // Cache for 24 hours
18 | 'cache-control': `max-age=${60 * 60 * 24}`,
19 | },
20 | });
21 | }
22 |
23 | function shopSitemap(data, baseUrl) {
24 | const productsData = flattenConnection(data.products)
25 | .filter((product) => product.onlineStoreUrl)
26 | .map((product) => {
27 | const url = `${baseUrl}/products/${product.handle}`;
28 |
29 | const finalObject = {
30 | url,
31 | lastMod: product.updatedAt,
32 | changeFreq: 'daily',
33 | };
34 |
35 | if (product.featuredImage?.url) {
36 | finalObject.image = {
37 | url: product.featuredImage.url,
38 | };
39 |
40 | if (product.title) {
41 | finalObject.image.title = product.title;
42 | }
43 |
44 | if (product.featuredImage.altText) {
45 | finalObject.image.caption = product.featuredImage.altText;
46 | }
47 | }
48 |
49 | return finalObject;
50 | });
51 |
52 | const collectionsData = flattenConnection(data.collections)
53 | .filter((collection) => collection.onlineStoreUrl)
54 | .map((collection) => {
55 | const url = `${baseUrl}/collections/${collection.handle}`;
56 |
57 | return {
58 | url,
59 | lastMod: collection.updatedAt,
60 | changeFreq: 'daily',
61 | };
62 | });
63 |
64 | const pagesData = flattenConnection(data.pages)
65 | .filter((page) => page.onlineStoreUrl)
66 | .map((page) => {
67 | const url = `${baseUrl}/pages/${page.handle}`;
68 |
69 | return {
70 | url,
71 | lastMod: page.updatedAt,
72 | changeFreq: 'weekly',
73 | };
74 | });
75 |
76 | const urlsDatas = [...productsData, ...collectionsData, ...pagesData];
77 |
78 | return `
79 |
83 | ${urlsDatas.map((url) => renderUrlTag(url)).join('')}
84 | `;
85 | }
86 |
87 | function renderUrlTag({url, lastMod, changeFreq, image}) {
88 | return `
89 |
90 | ${url}
91 | ${lastMod}
92 | ${changeFreq}
93 | ${
94 | image
95 | ? `
96 |
97 | ${image.url}
98 | ${image.title ?? ''}
99 | ${image.caption ?? ''}
100 | `
101 | : ''
102 | }
103 |
104 |
105 | `;
106 | }
107 |
108 | const QUERY = gql`
109 | query sitemaps($urlLimits: Int, $language: LanguageCode)
110 | @inContext(language: $language) {
111 | products(
112 | first: $urlLimits
113 | query: "published_status:'online_store:visible'"
114 | ) {
115 | edges {
116 | node {
117 | updatedAt
118 | handle
119 | onlineStoreUrl
120 | title
121 | featuredImage {
122 | url
123 | altText
124 | }
125 | }
126 | }
127 | }
128 | collections(
129 | first: $urlLimits
130 | query: "published_status:'online_store:visible'"
131 | ) {
132 | edges {
133 | node {
134 | updatedAt
135 | handle
136 | onlineStoreUrl
137 | }
138 | }
139 | }
140 | pages(first: $urlLimits, query: "published_status:'published'") {
141 | edges {
142 | node {
143 | updatedAt
144 | handle
145 | onlineStoreUrl
146 | }
147 | }
148 | }
149 | }
150 | `;
151 |
--------------------------------------------------------------------------------
/src/styles/custom-font.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'IBMPlexSerif';
3 | font-display: swap;
4 | font-weight: 400;
5 | src: url('/fonts/IBMPlexSerif-Text.woff2') format('woff2');
6 | }
7 | @font-face {
8 | font-family: 'IBMPlexSerif';
9 | font-display: swap;
10 | font-weight: 400;
11 | font-style: italic;
12 | src: url('/fonts/IBMPlexSerif-TextItalic.woff2') format('woff2');
13 | }
14 |
--------------------------------------------------------------------------------
/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* Tokens */
6 | :root {
7 | --color-primary: 20 20 20; /* Text, buttons, etc. */
8 | --color-contrast: 250 250 249; /* Backgrounds, borders, etc. */
9 | --color-accent: 191 72 0; /* Labels like "On sale" */
10 | --font-size-fine: 0.75rem; /* text-xs */
11 | --font-size-copy: 1rem; /* text-base */
12 | --font-size-lead: 1.125rem; /* text-lg */
13 | --font-size-heading: 2rem; /* text-2xl */
14 | --font-size-display: 3rem; /* text-4xl */
15 | --color-shop-pay: #5a31f4;
16 | --shop-pay-button--width: 100%; /* Sets the width for the shop-pay-button web component */
17 | --height-nav: 3rem;
18 | --screen-height: 100vh;
19 | --screen-height-dynamic: 100vh;
20 |
21 | @media (min-width: 32em) {
22 | --height-nav: 4rem;
23 | }
24 | @media (min-width: 48em) {
25 | --height-nav: 6rem;
26 | --font-size-heading: 2.25rem; /* text-4xl */
27 | --font-size-display: 3.75rem; /* text-6xl */
28 | }
29 | @supports (height: 100svh) {
30 | --screen-height: 100svh;
31 | }
32 | @supports (height: 100dvh) {
33 | --screen-height-dynamic: 100dvh;
34 | }
35 | }
36 |
37 | @media (prefers-color-scheme: dark) {
38 | :root {
39 | --color-primary: 250 250 250;
40 | --color-contrast: 32 33 36;
41 | --color-accent: 235 86 40;
42 | }
43 | }
44 |
45 | @keyframes fadeInAnimation {
46 | 0% {
47 | opacity: 0;
48 | }
49 | 100% {
50 | opacity: 1;
51 | }
52 | }
53 |
54 | shop-pay-button {
55 | width: 100%;
56 | height: 3rem;
57 | display: table;
58 | }
59 |
60 | @layer base {
61 | * {
62 | font-variant-ligatures: none;
63 | }
64 |
65 | body {
66 | @apply antialiased text-primary/90 bg-contrast border-primary/10;
67 | }
68 |
69 | html {
70 | scroll-padding-top: 10rem;
71 | }
72 |
73 | model-viewer::part(default-progress-mask) {
74 | display: none;
75 | }
76 |
77 | model-viewer::part(default-progress-bar) {
78 | display: none;
79 | }
80 |
81 | input[type='search']::-webkit-search-decoration,
82 | input[type='search']::-webkit-search-cancel-button,
83 | input[type='search']::-webkit-search-results-button,
84 | input[type='search']::-webkit-search-results-decoration {
85 | -webkit-appearance: none;
86 | }
87 |
88 | .prose {
89 | h1,
90 | h2,
91 | h3,
92 | h4,
93 | h5,
94 | h6 {
95 | &:first-child {
96 | @apply mt-0;
97 | }
98 | }
99 | }
100 | }
101 |
102 | @layer components {
103 | .article {
104 | h2,
105 | h3,
106 | h4,
107 | h5,
108 | h6 {
109 | @apply font-sans text-primary;
110 | }
111 | @apply mb-12 font-serif prose mx-auto grid justify-center text-primary;
112 | p,
113 | ul,
114 | li {
115 | @apply mb-4 text-lg;
116 | }
117 | img {
118 | @apply md:-mx-8 lg:-mx-16;
119 |
120 | @media (min-width: 48em) {
121 | width: calc(100% + 4rem);
122 | max-width: 100vw;
123 | }
124 | @media (min-width: 64em) {
125 | width: calc(100% + 8rem);
126 | }
127 | }
128 | }
129 |
130 | .swimlane {
131 | @apply grid snap-mandatory grid-flow-col snap-x w-full gap-4 scroll-px-6 px-6 pb-4 overflow-x-scroll justify-start;
132 | }
133 | }
134 |
135 | @layer utilities {
136 | .fadeIn {
137 | opacity: 0;
138 | animation: fadeInAnimation ease 500ms forwards;
139 | }
140 |
141 | .hiddenScroll {
142 | scrollbar-width: none;
143 | &::-webkit-scrollbar {
144 | display: none;
145 | }
146 | }
147 |
148 | .absolute-center {
149 | @apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
150 | }
151 |
152 | .strike {
153 | position: relative;
154 | &::before {
155 | content: '';
156 | display: block;
157 | position: absolute;
158 | width: 108%;
159 | height: 1.5px;
160 | left: -4%;
161 | top: 50%;
162 | transform: translateY(-50%);
163 | background: rgb(var(--color-primary));
164 | box-shadow: 0.5px 0.5px 0px 0.5px rgb(var(--color-contrast));
165 | }
166 | }
167 |
168 | .card-image {
169 | @apply relative rounded overflow-clip flex justify-center items-center;
170 | &::before {
171 | content: ' ';
172 | @apply z-10 absolute block top-0 left-0 w-full h-full shadow-border rounded;
173 | }
174 | img {
175 | @apply object-cover w-full aspect-[inherit];
176 | }
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /* We */
2 | /* Tailwind Configuration Docs: https://tailwindcss.com/docs/configuration */
3 |
4 | function withOpacityValue(variable) {
5 | return ({opacityValue}) => {
6 | if (opacityValue === undefined) {
7 | return `rgb(var(${variable}))`;
8 | }
9 | return `rgb(var(${variable}) / ${opacityValue})`;
10 | };
11 | }
12 |
13 | module.exports = {
14 | content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
15 | theme: {
16 | extend: {
17 | colors: {
18 | primary: withOpacityValue('--color-primary'),
19 | contrast: withOpacityValue('--color-contrast'),
20 | notice: withOpacityValue('--color-accent'),
21 | shopPay: 'var(--color-shop-pay)',
22 | },
23 | screens: {
24 | sm: '32em',
25 | md: '48em',
26 | lg: '64em',
27 | xl: '80em',
28 | '2xl': '96em',
29 | 'sm-max': {max: '48em'},
30 | 'sm-only': {min: '32em', max: '48em'},
31 | 'md-only': {min: '48em', max: '64em'},
32 | 'lg-only': {min: '64em', max: '80em'},
33 | 'xl-only': {min: '80em', max: '96em'},
34 | '2xl-only': {min: '96em'},
35 | },
36 | spacing: {
37 | nav: 'var(--height-nav)',
38 | screen: 'var(--screen-height, 100vh)',
39 | },
40 | height: {
41 | screen: 'var(--screen-height, 100vh)',
42 | 'screen-no-nav':
43 | 'calc(var(--screen-height, 100vh) - var(--height-nav))',
44 | 'screen-dynamic': 'var(--screen-height-dynamic, 100vh)',
45 | },
46 | width: {
47 | mobileGallery: 'calc(100vw - 3rem)',
48 | },
49 | fontFamily: {
50 | sans: ['Helvetica Neue', 'ui-sans-serif', 'system-ui', 'sans-serif'],
51 | serif: ['"IBMPlexSerif"', 'Palatino', 'ui-serif'],
52 | },
53 | fontSize: {
54 | display: ['var(--font-size-display)', '1.1'],
55 | heading: ['var(--font-size-heading)', '1.25'],
56 | lead: ['var(--font-size-lead)', '1.333'],
57 | copy: ['var(--font-size-copy)', '1.5'],
58 | fine: ['var(--font-size-fine)', '1.333'],
59 | },
60 | maxWidth: {
61 | 'prose-narrow': '45ch',
62 | 'prose-wide': '80ch',
63 | },
64 | boxShadow: {
65 | border: 'inset 0px 0px 0px 1px rgb(var(--color-primary) / 0.08)',
66 | darkHeader: 'inset 0px -1px 0px 0px rgba(21, 21, 21, 0.4)',
67 | lightHeader: 'inset 0px -1px 0px 0px rgba(21, 21, 21, 0.05)',
68 | },
69 | },
70 | },
71 | // eslint-disable-next-line node/no-unpublished-require
72 | plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
73 | };
74 |
--------------------------------------------------------------------------------
/tests/e2e/collection.test.js:
--------------------------------------------------------------------------------
1 | import {startHydrogenServer} from '../utils';
2 | import Collections from '../../src/routes/collections/[handle].server';
3 |
4 | describe('collections', () => {
5 | let hydrogen;
6 | let session;
7 | let collectionUrl;
8 |
9 | beforeAll(async () => {
10 | hydrogen = await startHydrogenServer();
11 | hydrogen.watchForUpdates(Collections);
12 |
13 | // Find a collection url from home page
14 | session = await hydrogen.newPage();
15 | await session.visit('/');
16 | const link = await session.page.locator('a[href^="/collections/"]').first();
17 | collectionUrl = await link.getAttribute('href');
18 | });
19 |
20 | beforeEach(async () => {
21 | session = await hydrogen.newPage();
22 | });
23 |
24 | afterAll(async () => {
25 | await hydrogen.cleanUp();
26 | });
27 |
28 | it('should have collection title', async () => {
29 | await session.visit(collectionUrl);
30 |
31 | const heading = await session.page.locator('h1').first();
32 | expect(await heading.textContent()).not.toBeNull();
33 | });
34 |
35 | it('should have collection product tiles', async () => {
36 | await session.visit(collectionUrl);
37 |
38 | const products = await session.page.locator('#mainContent section a');
39 | expect(await products.count()).not.toEqual(0);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/tests/e2e/index.test.js:
--------------------------------------------------------------------------------
1 | import {startHydrogenServer} from '../utils';
2 | import Index from '../../src/routes/index.server';
3 |
4 | describe('index', () => {
5 | let hydrogen;
6 | let session;
7 |
8 | beforeAll(async () => {
9 | hydrogen = await startHydrogenServer();
10 | hydrogen.watchForUpdates(Index);
11 | });
12 |
13 | beforeEach(async () => {
14 | session = await hydrogen.newPage();
15 | });
16 |
17 | afterAll(async () => {
18 | await hydrogen.cleanUp();
19 | });
20 |
21 | it('should be a 200 response', async () => {
22 | const response = await session.visit('/');
23 | expect(response.status()).toBe(200);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/tests/e2e/product.test.js:
--------------------------------------------------------------------------------
1 | import {startHydrogenServer} from '../utils';
2 | import Product from '../../src/routes/products/[handle].server';
3 |
4 | describe('products', () => {
5 | let hydrogen;
6 | let session;
7 | let productUrl;
8 |
9 | beforeAll(async () => {
10 | hydrogen = await startHydrogenServer();
11 | hydrogen.watchForUpdates(Product);
12 |
13 | // Find a product url from home page
14 | session = await hydrogen.newPage();
15 | await session.visit('/');
16 | const link = await session.page.locator('a[href^="/products/"]').first();
17 | productUrl = await link.getAttribute('href');
18 | });
19 |
20 | beforeEach(async () => {
21 | session = await hydrogen.newPage();
22 | });
23 |
24 | afterAll(async () => {
25 | await hydrogen.cleanUp();
26 | });
27 |
28 | it('should have product title', async () => {
29 | await session.visit(productUrl);
30 | const heading = await session.page.locator('h1').first();
31 | expect(await heading.textContent()).not.toBeNull();
32 | });
33 |
34 | it('can be added to cart', async () => {
35 | // Make sure cart script loads
36 | await Promise.all([
37 | session.page.waitForResponse(
38 | 'https://cdn.shopify.com/shopifycloud/shop-js/v1.0/client.js',
39 | ),
40 | session.visit(productUrl),
41 | ]);
42 |
43 | const addToCartButton = await session.page.locator('text=Add to bag');
44 |
45 | // Click on add to cart button
46 | const [cartResponse] = await Promise.all([
47 | session.page.waitForResponse((response) =>
48 | /graphql\.json/.test(response.url()),
49 | ),
50 | addToCartButton.click(),
51 | ]);
52 |
53 | const cartEvent = await cartResponse.json();
54 | expect(cartEvent.data).not.toBeNull();
55 | }, 60000);
56 | });
57 |
--------------------------------------------------------------------------------
/tests/utils.js:
--------------------------------------------------------------------------------
1 | import {chromium} from 'playwright';
2 | import '@shopify/hydrogen/web-polyfills';
3 |
4 | // `version` is only exported in Vite 3
5 | import * as vite from 'vite';
6 | const {createServer: createViteDevServer, version} = vite;
7 |
8 | export async function startHydrogenServer() {
9 | const app = import.meta.env.WATCH
10 | ? await createDevServer()
11 | : await createNodeServer();
12 |
13 | const browser = await chromium.launch();
14 | const url = (pathname) => `http://localhost:${app.port}${pathname}`;
15 |
16 | const newPage = async () => {
17 | const page = await browser.newPage();
18 | return {
19 | page,
20 | visit: async (pathname) => page.goto(url(pathname)),
21 | };
22 | };
23 |
24 | const cleanUp = async () => {
25 | await browser.close();
26 | await app.server?.close();
27 | };
28 |
29 | return {url, newPage, cleanUp, watchForUpdates: () => {}};
30 | }
31 |
32 | async function createNodeServer() {
33 | // @ts-ignore
34 | const {createServer} = await import('../dist/node');
35 | const app = (await createServer()).app;
36 | const server = app.listen(0);
37 | const port = await new Promise((resolve) => {
38 | server.on('listening', () => {
39 | resolve(getPortFromAddress(server.address()));
40 | });
41 | });
42 |
43 | return {server, port};
44 | }
45 |
46 | async function createDevServer() {
47 | const isVite3 = version?.startsWith('3.');
48 | const app = await createViteDevServer({
49 | [isVite3 ? 'optimizeDeps' : 'server']: {force: true},
50 | logLevel: 'silent',
51 | });
52 | const server = await app.listen(0);
53 |
54 | return {
55 | server: server.httpServer,
56 | port: getPortFromAddress(server.httpServer.address()),
57 | };
58 | }
59 |
60 | function getPortFromAddress(address) {
61 | if (typeof address === 'string') {
62 | return parseInt(address.split(':').pop());
63 | } else {
64 | return address.port;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | ///
2 | import {defineConfig} from 'vite';
3 | import hydrogen from '@shopify/hydrogen/plugin';
4 | import netlifyPlugin from '@netlify/hydrogen-platform/plugin';
5 |
6 | export default defineConfig({
7 | plugins: [hydrogen(), netlifyPlugin()],
8 | resolve: {
9 | alias: [{find: /^~\/(.*)/, replacement: '/src/$1'}],
10 | },
11 | optimizeDeps: {
12 | include: ['@headlessui/react', 'clsx', 'react-use', 'typographic-base'],
13 | },
14 | test: {
15 | globals: true,
16 | testTimeout: 10000,
17 | hookTimeout: 10000,
18 | },
19 | });
20 |
--------------------------------------------------------------------------------