├── .gitignore
├── .tool-versions
├── .yarn
└── releases
│ └── yarn-3.2.1.cjs
├── .yarnrc
├── .yarnrc.yml
├── README.md
├── _gitignore
├── hydrogen.config.ts
├── index.html
├── package.json
├── post-install.js
├── postcss.config.js
├── public
└── fonts
│ ├── IBMPlexSerif-Text.woff2
│ └── IBMPlexSerif-TextItalic.woff2
├── src
├── App.server.tsx
├── assets
│ └── favicon.svg
├── components
│ ├── BuilderComponent.client.tsx
│ ├── CountrySelector.client.tsx
│ ├── CustomFont.client.tsx
│ ├── DefaultSeo.server.tsx
│ ├── HeaderFallback.tsx
│ ├── account
│ │ ├── AccountActivateForm.client.tsx
│ │ ├── AccountAddressBook.client.tsx
│ │ ├── AccountAddressEdit.client.tsx
│ │ ├── AccountCreateForm.client.tsx
│ │ ├── AccountDeleteAddress.client.tsx
│ │ ├── AccountDetails.client.tsx
│ │ ├── AccountDetailsEdit.client.tsx
│ │ ├── AccountLoginForm.client.tsx
│ │ ├── AccountOrderHistory.client.tsx
│ │ ├── AccountPasswordResetForm.client.tsx
│ │ ├── AccountRecoverForm.client.tsx
│ │ └── index.ts
│ ├── cards
│ │ ├── ArticleCard.tsx
│ │ ├── CollectionCard.server.tsx
│ │ ├── OrderCard.client.tsx
│ │ ├── ProductCard.client.tsx
│ │ ├── index.server.ts
│ │ └── index.ts
│ ├── cart
│ │ ├── CartDetails.client.tsx
│ │ ├── CartEmpty.client.tsx
│ │ ├── CartLineItem.client.tsx
│ │ └── index.ts
│ ├── elements
│ │ ├── Button.tsx
│ │ ├── Grid.tsx
│ │ ├── Heading.tsx
│ │ ├── Icon.tsx
│ │ ├── Input.tsx
│ │ ├── LogoutButton.client.tsx
│ │ ├── Section.tsx
│ │ ├── Skeleton.tsx
│ │ ├── Text.tsx
│ │ └── index.ts
│ ├── global
│ │ ├── CartDrawer.client.tsx
│ │ ├── Drawer.client.tsx
│ │ ├── Footer.server.tsx
│ │ ├── FooterMenu.client.tsx
│ │ ├── Header.client.tsx
│ │ ├── Layout.server.tsx
│ │ ├── MenuDrawer.client.tsx
│ │ ├── Modal.client.tsx
│ │ ├── NotFound.server.tsx
│ │ ├── PageHeader.tsx
│ │ ├── index.server.ts
│ │ └── index.ts
│ ├── index.server.ts
│ ├── index.ts
│ ├── product
│ │ ├── ProductDetail.client.tsx
│ │ ├── ProductForm.client.tsx
│ │ ├── ProductGallery.client.tsx
│ │ ├── ProductGrid.client.tsx
│ │ ├── ProductOptions.client.tsx
│ │ └── index.ts
│ ├── search
│ │ ├── NoResultRecommendations.server.tsx
│ │ ├── SearchPage.server.tsx
│ │ └── index.server.ts
│ └── sections
│ │ ├── FeaturedCollections.tsx
│ │ ├── Hero.tsx
│ │ ├── ProductCards.tsx
│ │ ├── ProductSwimlane.server.tsx
│ │ ├── index.server.ts
│ │ └── index.ts
├── lib
│ ├── const.ts
│ ├── fragments.ts
│ ├── index.ts
│ ├── placeholders.ts
│ ├── styleUtils.tsx
│ └── utils.ts
├── routes
│ ├── account
│ │ ├── activate
│ │ │ ├── [id]
│ │ │ │ └── [activationToken].server.tsx
│ │ │ └── index.server.ts
│ │ ├── address
│ │ │ ├── [addressId].server.ts
│ │ │ └── index.server.ts
│ │ ├── index.server.tsx
│ │ ├── login.server.tsx
│ │ ├── logout.server.ts
│ │ ├── orders
│ │ │ └── [id].server.tsx
│ │ ├── recover.server.tsx
│ │ ├── register.server.tsx
│ │ └── reset
│ │ │ ├── [id]
│ │ │ └── [resetToken].server.tsx
│ │ │ └── index.server.ts
│ ├── admin.server.tsx
│ ├── api
│ │ ├── bestSellers.server.ts
│ │ └── countries.server.ts
│ ├── builder
│ │ └── [handle].server.tsx
│ ├── cart.server.tsx
│ ├── collections
│ │ ├── [handle].server.tsx
│ │ ├── all.server.tsx
│ │ └── index.server.tsx
│ ├── components
│ │ └── BuilderComponent.client.tsx
│ ├── index.server.tsx
│ ├── journal
│ │ ├── [handle].server.tsx
│ │ └── index.server.tsx
│ ├── pages
│ │ └── [handle].server.tsx
│ ├── policies
│ │ ├── [handle].server.tsx
│ │ └── index.server.tsx
│ ├── products
│ │ ├── [handle].server.tsx
│ │ └── index.server.tsx
│ ├── robots.txt.server.ts
│ ├── search.server.tsx
│ └── sitemap.xml.server.ts
└── styles
│ ├── custom-font.css
│ └── index.css
├── tailwind.config.js
├── tests
├── e2e
│ ├── collection.test.ts
│ ├── index.test.ts
│ └── product.test.ts
└── utils.ts
├── tsconfig.json
├── vite.config.ts
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | __unconfig_vite.config.js
2 | node_modules
3 | .DS_Store
4 | dist
5 | dist-ssr
6 | *.local
7 | .yarn
8 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 18.6.0
2 |
--------------------------------------------------------------------------------
/.yarnrc:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | yarn-path ".yarn/releases/yarn-1.22.19.cjs"
6 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
3 | yarnPath: .yarn/releases/yarn-3.2.1.cjs
4 |
--------------------------------------------------------------------------------
/_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 |
--------------------------------------------------------------------------------
/hydrogen.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig, CookieSessionStorage} from '@shopify/hydrogen/config';
2 |
3 | export default defineConfig({
4 | shopify: {
5 | defaultCountryCode: 'US',
6 | defaultLanguageCode: 'EN',
7 | storeDomain: 'hydrogen-preview.myshopify.com',
8 | storefrontToken: '3b580e70970c4528da70c98e097c2fa0',
9 | storefrontApiVersion: '2022-07',
10 | },
11 | session: CookieSessionStorage('__session', {
12 | path: '/',
13 | httpOnly: true,
14 | secure: import.meta.env.PROD,
15 | sameSite: 'Strict',
16 | maxAge: 60 * 60 * 24 * 30,
17 | }),
18 | });
19 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Hydrogen
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "builder-shopify-hydrogen",
3 | "description": "Demo store template for @shopify/hydrogen",
4 | "version": "0.0.0",
5 | "license": "MIT",
6 | "private": true,
7 | "engines": {
8 | "node": ">=16.4.0",
9 | "yarn": ">=1.0.0",
10 | "npm": ">=0.0.0"
11 | },
12 | "scripts": {
13 | "postinstall": "node ./post-install.js",
14 | "dev": "shopify hydrogen dev",
15 | "build": "shopify hydrogen build",
16 | "preview": "shopify hydrogen preview",
17 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src",
18 | "lint-ts": "tsc --noEmit",
19 | "test": "WATCH=true vitest",
20 | "test:ci": "yarn build -t node && vitest run"
21 | },
22 | "devDependencies": {
23 | "@shopify/cli": "3.3.1",
24 | "@shopify/cli-hydrogen": "3.3.1",
25 | "@shopify/prettier-config": "^1.1.2",
26 | "@tailwindcss/forms": "^0.5.2",
27 | "@tailwindcss/typography": "^0.5.2",
28 | "@types/react": "^18.0.14",
29 | "eslint": "^8.18.0",
30 | "eslint-plugin-hydrogen": "^0.12.2",
31 | "playwright": "^1.22.2",
32 | "postcss": "^8.4.14",
33 | "postcss-import": "^14.1.0",
34 | "postcss-preset-env": "^7.6.0",
35 | "prettier": "^2.3.2",
36 | "tailwindcss": "^3.0.24",
37 | "typescript": "^4.7.2",
38 | "vite": "^2.9.0",
39 | "vitest": "^0.15.2"
40 | },
41 | "prettier": "@shopify/prettier-config",
42 | "dependencies": {
43 | "@builder.io/react": "2.0.10",
44 | "@builder.io/widgets": "^1.2.22",
45 | "@headlessui/react": "^1.6.4",
46 | "@heroicons/react": "^1.0.6",
47 | "@shopify/hydrogen": "^1.1.0",
48 | "clsx": "^1.1.1",
49 | "graphql-tag": "^2.12.6",
50 | "react": "^18.2.0",
51 | "react-dom": "^18.2.0",
52 | "react-use": "^17.4.0",
53 | "title": "^3.4.4",
54 | "typographic-base": "^1.0.4"
55 | },
56 | "author": "Dylan Kendal",
57 | "packageManager": "yarn@3.2.1"
58 | }
59 |
--------------------------------------------------------------------------------
/post-install.js:
--------------------------------------------------------------------------------
1 | /*
2 | https://github.com/emotion-js/emotion/issues/1246#issuecomment-468381891
3 | Monkey patch called postinstall to rename the browser property in ever @emotion/* package so it doesn't get used.
4 | */
5 |
6 | const fs = require('fs')
7 | const { sync } = require('glob')
8 |
9 | sync('./node_modules/@emotion/*/package.json').forEach(src => {
10 | const package = JSON.parse(fs.readFileSync(src, 'utf-8'))
11 | const browser = package.browser
12 | delete package.browser
13 | if (browser) {
14 | package._browser = browser
15 | }
16 | fs.writeFileSync(src, JSON.stringify(package, null, 2))
17 | })
18 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/fonts/IBMPlexSerif-Text.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BuilderIO/builder-shopify-hydrogen/ce85ed07c51af50d34a00b8ce9c8994a43b3e54b/public/fonts/IBMPlexSerif-Text.woff2
--------------------------------------------------------------------------------
/public/fonts/IBMPlexSerif-TextItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BuilderIO/builder-shopify-hydrogen/ce85ed07c51af50d34a00b8ce9c8994a43b3e54b/public/fonts/IBMPlexSerif-TextItalic.woff2
--------------------------------------------------------------------------------
/src/App.server.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import renderHydrogen from '@shopify/hydrogen/entry-server';
3 | import {
4 | FileRoutes,
5 | type HydrogenRouteProps,
6 | PerformanceMetrics,
7 | PerformanceMetricsDebug,
8 | Route,
9 | Router,
10 | ShopifyAnalytics,
11 | ShopifyProvider,
12 | CartProvider,
13 | } from '@shopify/hydrogen';
14 |
15 | import {HeaderFallback} from '~/components';
16 | import type {CountryCode} from '@shopify/hydrogen/storefront-api-types';
17 | import {DefaultSeo, NotFound} from '~/components/index.server';
18 |
19 | function App({request}: HydrogenRouteProps) {
20 | const pathname = new URL(request.normalizedUrl).pathname;
21 | const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
22 | const countryCode = localeMatch ? (localeMatch[1] as CountryCode) : undefined;
23 |
24 | const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
25 |
26 | return (
27 | }>
28 |
29 |
30 |
31 |
32 |
33 |
34 |
37 | } />
38 |
39 |
40 |
41 | {import.meta.env.DEV && }
42 |
43 |
44 |
45 | );
46 | }
47 |
48 | export default renderHydrogen(App);
49 |
--------------------------------------------------------------------------------
/src/assets/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
18 |
23 |
28 |
29 |
--------------------------------------------------------------------------------
/src/components/BuilderComponent.client.tsx:
--------------------------------------------------------------------------------
1 | import {builder, BuilderComponent} from '@builder.io/react';
2 | import "@builder.io/widgets";
3 |
4 | // API key for your Builder space: https://www.builder.io/c/docs/using-your-api-key#finding-your-public-api-key
5 | builder.init('cda38653c81344cf8859bd15e4d8e30d');
6 |
7 | export { BuilderComponent };
8 |
--------------------------------------------------------------------------------
/src/components/CountrySelector.client.tsx:
--------------------------------------------------------------------------------
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 | import type {
9 | Country,
10 | CountryCode,
11 | } from '@shopify/hydrogen/storefront-api-types';
12 |
13 | /**
14 | * A client component that selects the appropriate country to display for products on a website
15 | */
16 | export function CountrySelector() {
17 | const [listboxOpen, setListboxOpen] = useState(false);
18 | const {
19 | country: {isoCode},
20 | } = useLocalization();
21 | const currentCountry = useMemo<{name: string; isoCode: CountryCode}>(() => {
22 | const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
23 | type: 'region',
24 | });
25 |
26 | return {
27 | name: regionNamesInEnglish.of(isoCode)!,
28 | isoCode: isoCode as CountryCode,
29 | };
30 | }, [isoCode]);
31 |
32 | const setCountry = useCallback<(country: Country) => void>(
33 | ({isoCode: newIsoCode}) => {
34 | const currentPath = window.location.pathname;
35 | let redirectPath;
36 |
37 | if (newIsoCode !== 'US') {
38 | if (currentCountry.isoCode === 'US') {
39 | redirectPath = `/${newIsoCode.toLowerCase()}${currentPath}`;
40 | } else {
41 | redirectPath = `/${newIsoCode.toLowerCase()}${currentPath.substring(
42 | currentPath.indexOf('/', 1),
43 | )}`;
44 | }
45 | } else {
46 | redirectPath = `${currentPath.substring(currentPath.indexOf('/', 1))}`;
47 | }
48 |
49 | window.location.href = redirectPath;
50 | },
51 | [currentCountry],
52 | );
53 |
54 | return (
55 |
56 |
57 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
58 | {({open}) => {
59 | setTimeout(() => setListboxOpen(open));
60 | return (
61 | <>
62 |
67 | {currentCountry.name}
68 |
69 |
70 |
71 |
79 | {listboxOpen && (
80 | Loading…
}>
81 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
82 | {
85 | return `text-contrast dark:text-primary bg-primary
86 | dark:bg-contrast w-full p-2 transition rounded
87 | flex justify-start items-center text-left cursor-pointer ${
88 | active ? 'bg-primary/10' : null
89 | }`;
90 | }}
91 | />
92 |
93 | )}
94 |
95 | >
96 | );
97 | }}
98 |
99 |
100 | );
101 | }
102 |
103 | export function Countries({
104 | selectedCountry,
105 | getClassName,
106 | }: {
107 | selectedCountry: Pick;
108 | getClassName: (active: boolean) => string;
109 | }) {
110 | const countries: Country[] = fetchSync('/api/countries').json();
111 |
112 | return (countries || []).map((country) => {
113 | const isSelected = country.isoCode === selectedCountry.isoCode;
114 |
115 | return (
116 |
117 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
118 | {({active}) => (
119 |
124 | {country.name}
125 | {isSelected ? (
126 |
127 |
128 |
129 | ) : null}
130 |
131 | )}
132 |
133 | );
134 | });
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/CustomFont.client.tsx:
--------------------------------------------------------------------------------
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.tsx:
--------------------------------------------------------------------------------
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/HeaderFallback.tsx:
--------------------------------------------------------------------------------
1 | export function HeaderFallback({isHome}: {isHome?: boolean}) {
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}: {wide?: boolean; isHome?: boolean}) {
23 | return (
24 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/account/AccountActivateForm.client.tsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react';
2 | import {useNavigate} from '@shopify/hydrogen/client';
3 | import {getInputStyleClasses} from '../../lib/styleUtils';
4 |
5 | export function AccountActivateForm({
6 | id,
7 | activationToken,
8 | }: {
9 | id: string;
10 | activationToken: string;
11 | }) {
12 | const navigate = useNavigate();
13 |
14 | const [submitError, setSubmitError] = useState(null);
15 | const [password, setPassword] = useState('');
16 | const [passwordError, setPasswordError] = useState(null);
17 | const [passwordConfirm, setPasswordConfirm] = useState('');
18 | const [passwordConfirmError, setPasswordConfirmError] = useState<
19 | null | string
20 | >(null);
21 |
22 | function passwordValidation(
23 | form: HTMLFormElement & {password: HTMLInputElement},
24 | ) {
25 | setPasswordError(null);
26 | setPasswordConfirmError(null);
27 |
28 | let hasError = false;
29 |
30 | if (!form.password.validity.valid) {
31 | hasError = true;
32 | setPasswordError(
33 | form.password.validity.valueMissing
34 | ? 'Please enter a password'
35 | : 'Passwords must be at least 6 characters',
36 | );
37 | }
38 |
39 | if (!form.passwordConfirm.validity.valid) {
40 | hasError = true;
41 | setPasswordConfirmError(
42 | form.password.validity.valueMissing
43 | ? 'Please re-enter a password'
44 | : 'Passwords must be at least 6 characters',
45 | );
46 | }
47 |
48 | if (password !== passwordConfirm) {
49 | hasError = true;
50 | setPasswordConfirmError('The two passwords entered did not match.');
51 | }
52 |
53 | return hasError;
54 | }
55 |
56 | async function onSubmit(
57 | event: React.FormEvent,
58 | ) {
59 | event.preventDefault();
60 |
61 | if (passwordValidation(event.currentTarget)) {
62 | return;
63 | }
64 |
65 | const response = await callActivateApi({
66 | id,
67 | activationToken,
68 | password,
69 | });
70 |
71 | if (response.error) {
72 | setSubmitError(response.error);
73 | return;
74 | }
75 |
76 | navigate('/account');
77 | }
78 |
79 | return (
80 |
81 |
82 |
Activate Account.
83 |
Create your password to activate your account.
84 |
147 |
148 |
149 | );
150 | }
151 |
152 | async function callActivateApi({
153 | id,
154 | activationToken,
155 | password,
156 | }: {
157 | id: string;
158 | activationToken: string;
159 | password: string;
160 | }) {
161 | try {
162 | const res = await fetch(`/account/activate`, {
163 | method: 'POST',
164 | headers: {
165 | Accept: 'application/json',
166 | 'Content-Type': 'application/json',
167 | },
168 | body: JSON.stringify({id, activationToken, password}),
169 | });
170 | if (res.ok) {
171 | return {};
172 | } else {
173 | return res.json();
174 | }
175 | } catch (error: any) {
176 | return {
177 | error: error.toString(),
178 | };
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/src/components/account/AccountAddressBook.client.tsx:
--------------------------------------------------------------------------------
1 | import {useState, useMemo, MouseEventHandler} 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({
8 | addresses,
9 | defaultAddress,
10 | }: {
11 | addresses: any[];
12 | defaultAddress: any;
13 | }) {
14 | const [editingAddress, setEditingAddress] = useState(null);
15 | const [deletingAddress, setDeletingAddress] = useState(null);
16 |
17 | const {fullDefaultAddress, addressesWithoutDefault} = useMemo(() => {
18 | const defaultAddressIndex = addresses.findIndex(
19 | (address) => address.id === defaultAddress,
20 | );
21 | return {
22 | addressesWithoutDefault: [
23 | ...addresses.slice(0, defaultAddressIndex),
24 | ...addresses.slice(defaultAddressIndex + 1, addresses.length),
25 | ],
26 | fullDefaultAddress: addresses[defaultAddressIndex],
27 | };
28 | }, [addresses, defaultAddress]);
29 |
30 | function close() {
31 | setEditingAddress(null);
32 | setDeletingAddress(null);
33 | }
34 |
35 | function editAddress(address: any) {
36 | setEditingAddress(address);
37 | }
38 |
39 | return (
40 | <>
41 | {deletingAddress ? (
42 |
43 |
44 |
45 | ) : null}
46 | {editingAddress ? (
47 |
48 |
53 |
54 | ) : null}
55 |
56 |
Address Book
57 |
58 | {!addresses?.length ? (
59 |
60 | You haven't saved any addresses yet.
61 |
62 | ) : null}
63 |
64 | {
67 | editAddress({
68 | /** empty address */
69 | });
70 | }}
71 | variant="secondary"
72 | >
73 | Add an Address
74 |
75 |
76 | {addresses?.length ? (
77 |
78 | {fullDefaultAddress ? (
79 |
88 | ) : null}
89 | {addressesWithoutDefault.map((address) => (
90 |
99 | ))}
100 |
101 | ) : null}
102 |
103 |
104 | >
105 | );
106 | }
107 |
108 | function Address({
109 | address,
110 | defaultAddress,
111 | editAddress,
112 | setDeletingAddress,
113 | }: {
114 | address: any;
115 | defaultAddress?: boolean;
116 | editAddress: (address: any) => void;
117 | setDeletingAddress: MouseEventHandler;
118 | }) {
119 | return (
120 |
121 | {defaultAddress ? (
122 |
123 |
124 | Default
125 |
126 |
127 | ) : null}
128 |
129 | {address.firstName || address.lastName ? (
130 |
131 | {(address.firstName && address.firstName + ' ') + address.lastName}
132 |
133 | ) : (
134 | <>>
135 | )}
136 | {address.formatted ? (
137 | address.formatted.map((line: string) => {line} )
138 | ) : (
139 | <>>
140 | )}
141 |
142 |
143 |
144 | {
146 | editAddress(address);
147 | }}
148 | className="text-left underline text-sm"
149 | >
150 | Edit
151 |
152 |
156 | Remove
157 |
158 |
159 |
160 | );
161 | }
162 |
--------------------------------------------------------------------------------
/src/components/account/AccountCreateForm.client.tsx:
--------------------------------------------------------------------------------
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 | interface FormElements {
10 | email: HTMLInputElement;
11 | password: HTMLInputElement;
12 | }
13 |
14 | export function AccountCreateForm() {
15 | const navigate = useNavigate();
16 |
17 | const [submitError, setSubmitError] = useState(null);
18 | const [email, setEmail] = useState('');
19 | const [emailError, setEmailError] = useState(null);
20 | const [password, setPassword] = useState('');
21 | const [passwordError, setPasswordError] = useState(null);
22 |
23 | async function onSubmit(
24 | event: React.FormEvent,
25 | ) {
26 | event.preventDefault();
27 |
28 | setEmailError(null);
29 | setPasswordError(null);
30 | setSubmitError(null);
31 |
32 | const newEmailError = emailValidation(event.currentTarget.email);
33 | if (newEmailError) {
34 | setEmailError(newEmailError);
35 | }
36 |
37 | const newPasswordError = passwordValidation(event.currentTarget.password);
38 | if (newPasswordError) {
39 | setPasswordError(newPasswordError);
40 | }
41 |
42 | if (newEmailError || newPasswordError) {
43 | return;
44 | }
45 |
46 | const accountCreateResponse = await callAccountCreateApi({
47 | email,
48 | password,
49 | });
50 |
51 | if (accountCreateResponse.error) {
52 | setSubmitError(accountCreateResponse.error);
53 | return;
54 | }
55 |
56 | // this can be avoided if customerCreate mutation returns customerAccessToken
57 | await callLoginApi({
58 | email,
59 | password,
60 | });
61 |
62 | navigate('/account');
63 | }
64 |
65 | return (
66 |
67 |
68 |
Create an Account.
69 |
137 |
138 |
139 | );
140 | }
141 |
142 | export async function callAccountCreateApi({
143 | email,
144 | password,
145 | firstName,
146 | lastName,
147 | }: {
148 | email: string;
149 | password: string;
150 | firstName?: string;
151 | lastName?: string;
152 | }) {
153 | try {
154 | const res = await fetch(`/account/register`, {
155 | method: 'POST',
156 | headers: {
157 | Accept: 'application/json',
158 | 'Content-Type': 'application/json',
159 | },
160 | body: JSON.stringify({email, password, firstName, lastName}),
161 | });
162 | if (res.status === 200) {
163 | return {};
164 | } else {
165 | return res.json();
166 | }
167 | } catch (error: any) {
168 | return {
169 | error: error.toString(),
170 | };
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/src/components/account/AccountDeleteAddress.client.tsx:
--------------------------------------------------------------------------------
1 | import {Text, Button} from '~/components/elements';
2 | import {useRenderServerComponents} from '~/lib/utils';
3 |
4 | export function AccountDeleteAddress({
5 | addressId,
6 | close,
7 | }: {
8 | addressId: string;
9 | close: () => void;
10 | }) {
11 | // Necessary for edits to show up on the main page
12 | const renderServerComponents = useRenderServerComponents();
13 |
14 | async function deleteAddress(id: string) {
15 | const response = await callDeleteAddressApi(id);
16 | if (response.error) {
17 | alert(response.error);
18 | return;
19 | }
20 | renderServerComponents();
21 | close();
22 | }
23 |
24 | return (
25 | <>
26 |
27 | Confirm removal
28 |
29 | Are you sure you wish to remove this address?
30 |
31 | {
34 | deleteAddress(addressId);
35 | }}
36 | variant="primary"
37 | width="full"
38 | >
39 | Confirm
40 |
41 |
47 | Cancel
48 |
49 |
50 | >
51 | );
52 | }
53 |
54 | export async function callDeleteAddressApi(id: string) {
55 | try {
56 | const res = await fetch(`/account/address/${encodeURIComponent(id)}`, {
57 | method: 'DELETE',
58 | headers: {
59 | Accept: 'application/json',
60 | },
61 | });
62 | if (res.ok) {
63 | return {};
64 | } else {
65 | return res.json();
66 | }
67 | } catch (_e) {
68 | return {
69 | error: 'Error removing address. Please try again.',
70 | };
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/account/AccountDetails.client.tsx:
--------------------------------------------------------------------------------
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({
7 | firstName,
8 | lastName,
9 | phone,
10 | email,
11 | }: {
12 | firstName?: string;
13 | lastName?: string;
14 | phone?: string;
15 | email?: string;
16 | }) {
17 | const [isEditing, setIsEditing] = useState(false);
18 |
19 | const close = () => setIsEditing(false);
20 |
21 | return (
22 | <>
23 | {isEditing ? (
24 |
25 |
26 |
33 |
34 | ) : null}
35 |
36 |
Account Details
37 |
38 |
39 |
Profile & Security
40 | setIsEditing(true)}
43 | >
44 | Edit
45 |
46 |
47 |
Name
48 |
49 | {firstName || lastName
50 | ? (firstName ? firstName + ' ' : '') + lastName
51 | : 'Add name'}{' '}
52 |
53 |
54 |
Contact
55 |
{phone ?? 'Add mobile'}
56 |
57 |
Email address
58 |
{email}
59 |
60 |
Password
61 |
**************
62 |
63 |
64 | >
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/account/AccountOrderHistory.client.tsx:
--------------------------------------------------------------------------------
1 | import type {Order} from '@shopify/hydrogen/storefront-api-types';
2 | import {Button, Text, OrderCard} from '~/components';
3 |
4 | export function AccountOrderHistory({orders}: {orders: Order[]}) {
5 | return (
6 |
7 |
8 |
Order History
9 | {orders?.length ? : }
10 |
11 |
12 | );
13 | }
14 |
15 | function EmptyOrders() {
16 | return (
17 |
18 |
19 | You haven't placed any orders yet.
20 |
21 |
22 |
23 | Start Shopping
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | function Orders({orders}: {orders: Order[]}) {
31 | return (
32 |
33 | {orders.map((order) => (
34 |
35 | ))}
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/account/AccountRecoverForm.client.tsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react';
2 |
3 | import {emailValidation} from '~/lib/utils';
4 | import {getInputStyleClasses} from '../../lib/styleUtils';
5 |
6 | interface FormElements {
7 | email: HTMLInputElement;
8 | }
9 |
10 | export function AccountRecoverForm() {
11 | const [submitSuccess, setSubmitSuccess] = useState(false);
12 | const [submitError, setSubmitError] = useState(null);
13 | const [email, setEmail] = useState('');
14 | const [emailError, setEmailError] = useState(null);
15 |
16 | async function onSubmit(
17 | event: React.FormEvent,
18 | ) {
19 | event.preventDefault();
20 |
21 | setEmailError(null);
22 | setSubmitError(null);
23 |
24 | const newEmailError = emailValidation(event.currentTarget.email);
25 |
26 | if (newEmailError) {
27 | setEmailError(newEmailError);
28 | return;
29 | }
30 |
31 | await callAccountRecoverApi({
32 | email,
33 | });
34 |
35 | setEmail('');
36 | setSubmitSuccess(true);
37 | }
38 |
39 | return (
40 |
41 |
42 | {submitSuccess ? (
43 | <>
44 |
Request Sent.
45 |
46 | If that email address is in our system, you will receive an email
47 | with instructions about how to reset your password in a few
48 | minutes.
49 |
50 | >
51 | ) : (
52 | <>
53 |
Forgot Password.
54 |
55 | Enter the email address associated with your account to receive a
56 | link to reset your password.
57 |
58 | >
59 | )}
60 |
98 |
99 |
100 | );
101 | }
102 |
103 | export async function callAccountRecoverApi({
104 | email,
105 | password,
106 | firstName,
107 | lastName,
108 | }: {
109 | email: string;
110 | password?: string;
111 | firstName?: string;
112 | lastName?: string;
113 | }) {
114 | try {
115 | const res = await fetch(`/account/recover`, {
116 | method: 'POST',
117 | headers: {
118 | Accept: 'application/json',
119 | 'Content-Type': 'application/json',
120 | },
121 | body: JSON.stringify({email, password, firstName, lastName}),
122 | });
123 | if (res.status === 200) {
124 | return {};
125 | } else {
126 | return res.json();
127 | }
128 | } catch (error: any) {
129 | return {
130 | error: error.toString(),
131 | };
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/components/account/index.ts:
--------------------------------------------------------------------------------
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.tsx:
--------------------------------------------------------------------------------
1 | import {Image, Link} from '@shopify/hydrogen';
2 | import type {Article} from '@shopify/hydrogen/storefront-api-types';
3 |
4 | export function ArticleCard({
5 | blogHandle,
6 | article,
7 | loading,
8 | }: {
9 | blogHandle: string;
10 | article: Article;
11 | loading?: HTMLImageElement['loading'];
12 | }) {
13 | return (
14 |
15 |
16 | {article.image && (
17 |
18 |
31 |
32 | )}
33 | {article.title}
34 | {article.publishedAt}
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/cards/CollectionCard.server.tsx:
--------------------------------------------------------------------------------
1 | import {Image, Link} from '@shopify/hydrogen';
2 | import type {Collection} from '@shopify/hydrogen/storefront-api-types';
3 |
4 | import {Heading} from '~/components';
5 |
6 | export function CollectionCard({
7 | collection,
8 | loading,
9 | }: {
10 | collection: Collection;
11 | loading?: HTMLImageElement['loading'];
12 | }) {
13 | return (
14 |
15 |
16 | {collection?.image && (
17 |
29 | )}
30 |
31 |
32 | {collection.title}
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/cards/OrderCard.client.tsx:
--------------------------------------------------------------------------------
1 | import {Image, Link, flattenConnection} from '@shopify/hydrogen';
2 | import type {
3 | Order,
4 | OrderLineItem,
5 | } from '@shopify/hydrogen/storefront-api-types';
6 |
7 | import {Heading, Text} from '~/components';
8 | import {statusMessage} from '~/lib/utils';
9 |
10 | export function OrderCard({order}: {order: Order}) {
11 | if (!order?.id) return null;
12 | const legacyOrderId = order!.id!.split('/').pop()!.split('?')[0];
13 | const lineItems = flattenConnection(order?.lineItems);
14 |
15 | return (
16 |
17 |
21 | {lineItems[0].variant?.image && (
22 |
23 |
33 |
34 | )}
35 |
40 |
41 | {lineItems.length > 1
42 | ? `${lineItems[0].title} +${lineItems.length - 1} more`
43 | : lineItems[0].title}
44 |
45 |
46 | Order ID
47 |
48 |
49 | Order No. {order.orderNumber}
50 |
51 |
52 | Order Date
53 |
54 |
55 | {new Date(order.processedAt).toDateString()}
56 |
57 |
58 | Fulfillment Status
59 |
60 |
67 |
68 | {statusMessage(order.fulfillmentStatus)}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
80 |
81 | View Details
82 |
83 |
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/cards/ProductCard.client.tsx:
--------------------------------------------------------------------------------
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 | import type {
14 | MoneyV2,
15 | Product,
16 | ProductVariant,
17 | ProductVariantConnection,
18 | } from '@shopify/hydrogen/storefront-api-types';
19 |
20 | export function ProductCard({
21 | product,
22 | label,
23 | className,
24 | loading,
25 | onClick,
26 | }: {
27 | product: Product;
28 | label?: string;
29 | className?: string;
30 | loading?: HTMLImageElement['loading'];
31 | onClick?: () => void;
32 | }) {
33 | let cardLabel;
34 |
35 | const cardData = product?.variants ? product : getProductPlaceholder();
36 |
37 | const {
38 | image,
39 | priceV2: price,
40 | compareAtPriceV2: compareAtPrice,
41 | } = flattenConnection(
42 | cardData?.variants as ProductVariantConnection,
43 | )[0] || {};
44 |
45 | if (label) {
46 | cardLabel = label;
47 | } else if (isDiscounted(price as MoneyV2, compareAtPrice as MoneyV2)) {
48 | cardLabel = 'Sale';
49 | } else if (isNewArrival(product.publishedAt)) {
50 | cardLabel = 'New';
51 | }
52 |
53 | const styles = clsx('grid gap-6', className);
54 |
55 | return (
56 |
57 |
58 |
59 |
64 | {cardLabel}
65 |
66 | {image && (
67 |
82 | )}
83 |
84 |
85 |
89 | {product.title}
90 |
91 |
92 |
93 |
94 | {isDiscounted(price as MoneyV2, compareAtPrice as MoneyV2) && (
95 |
99 | )}
100 |
101 |
102 |
103 |
104 |
105 | );
106 | }
107 |
108 | function CompareAtPrice({
109 | data,
110 | className,
111 | }: {
112 | data: MoneyV2;
113 | className?: string;
114 | }) {
115 | const {currencyNarrowSymbol, withoutTrailingZerosAndCurrency} =
116 | useMoney(data);
117 |
118 | const styles = clsx('strike', className);
119 |
120 | return (
121 |
122 | {currencyNarrowSymbol}
123 | {withoutTrailingZerosAndCurrency}
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/src/components/cards/index.server.ts:
--------------------------------------------------------------------------------
1 | export {CollectionCard} from './CollectionCard.server';
2 |
--------------------------------------------------------------------------------
/src/components/cards/index.ts:
--------------------------------------------------------------------------------
1 | export {ArticleCard} from './ArticleCard';
2 | export {OrderCard} from './OrderCard.client';
3 | export {ProductCard} from './ProductCard.client';
4 |
--------------------------------------------------------------------------------
/src/components/cart/CartDetails.client.tsx:
--------------------------------------------------------------------------------
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({
14 | layout,
15 | onClose,
16 | }: {
17 | layout: 'drawer' | 'page';
18 | onClose?: () => void;
19 | }) {
20 | const {lines} = useCart();
21 | const scrollRef = useRef(null);
22 | const {y} = useScroll(scrollRef);
23 |
24 | if (lines.length === 0) {
25 | return ;
26 | }
27 |
28 | const container = {
29 | drawer: 'grid grid-cols-1 h-screen-no-nav grid-rows-[1fr_auto]',
30 | page: 'pb-12 grid md:grid-cols-2 md:items-start gap-8 md:gap-8 lg:gap-12',
31 | };
32 |
33 | const content = {
34 | drawer: 'px-6 pb-6 sm-max:pt-2 overflow-auto transition md:px-12',
35 | page: 'flex-grow md:translate-y-4',
36 | };
37 |
38 | const summary = {
39 | drawer: 'grid gap-6 p-6 border-t md:px-12',
40 | page: 'sticky top-nav grid gap-6 p-4 md:px-6 md:translate-y-4 bg-primary/5 rounded w-full',
41 | };
42 |
43 | return (
44 |
68 | );
69 | }
70 |
71 | function CartCheckoutActions() {
72 | const {checkoutUrl} = useCart();
73 | return (
74 | <>
75 |
76 | {checkoutUrl ? (
77 |
78 |
79 | Continue to Checkout
80 |
81 |
82 | ) : null}
83 |
84 |
85 | >
86 | );
87 | }
88 |
89 | function OrderSummary() {
90 | const {cost} = useCart();
91 | return (
92 | <>
93 |
94 |
95 | Subtotal
96 |
97 | {cost?.subtotalAmount?.amount ? (
98 |
99 | ) : (
100 | '-'
101 | )}
102 |
103 |
104 |
105 | >
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/components/cart/CartEmpty.client.tsx:
--------------------------------------------------------------------------------
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 type {Product} from '@shopify/hydrogen/storefront-api-types';
6 | import {Suspense} from 'react';
7 |
8 | export function CartEmpty({
9 | onClose,
10 | layout = 'drawer',
11 | }: {
12 | onClose?: () => void;
13 | layout?: 'page' | 'drawer';
14 | }) {
15 | const scrollRef = useRef(null);
16 | const {y} = useScroll(scrollRef);
17 |
18 | const container = {
19 | 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 ${
20 | y > 0 ? 'border-t' : ''
21 | }`,
22 | page: `grid pb-12 w-full md:items-start gap-4 md:gap-8 lg:gap-12`,
23 | };
24 |
25 | const topProductsContainer = {
26 | drawer: '',
27 | page: 'md:grid-cols-4 sm:grid-col-4',
28 | };
29 |
30 | return (
31 |
32 |
33 |
34 | Looks like you haven’t added anything yet, let’s get you
35 | started!
36 |
37 |
38 | Continue shopping
39 |
40 |
41 |
42 |
43 | Shop Best Sellers
44 |
45 |
48 | }>
49 |
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | function TopProducts({onClose}: {onClose?: () => void}) {
58 | const products: Product[] = fetchSync('/api/bestSellers').json();
59 |
60 | if (products.length === 0) {
61 | return No products found. ;
62 | }
63 |
64 | return (
65 | <>
66 | {products.map((product) => (
67 |
68 | ))}
69 | >
70 | );
71 | }
72 |
73 | function Loading() {
74 | return (
75 | <>
76 | {[...new Array(4)].map((_, i) => (
77 | // eslint-disable-next-line react/no-array-index-key
78 |
79 |
80 |
81 |
82 | ))}
83 | >
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/cart/CartLineItem.client.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useCart,
3 | useCartLine,
4 | CartLineQuantityAdjustButton,
5 | CartLinePrice,
6 | CartLineQuantity,
7 | Image,
8 | Link,
9 | } from '@shopify/hydrogen';
10 | import type {Image as ImageType} from '@shopify/hydrogen/storefront-api-types';
11 |
12 | import {Heading, IconRemove, Text} from '~/components';
13 |
14 | export function CartLineItem() {
15 | const {linesRemove} = useCart();
16 | const {id: lineId, quantity, merchandise} = useCartLine();
17 |
18 | return (
19 |
20 |
21 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {merchandise.product.title}
39 |
40 |
41 |
42 |
43 | {(merchandise?.selectedOptions || []).map((option) => (
44 |
45 | {option.name}: {option.value}
46 |
47 | ))}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
linesRemove([lineId])}
57 | className="flex items-center justify-center w-10 h-10 border rounded"
58 | >
59 | Remove
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
72 | function CartLineQuantityAdjust({
73 | lineId,
74 | quantity,
75 | }: {
76 | lineId: string;
77 | quantity: number;
78 | }) {
79 | return (
80 | <>
81 |
82 | Quantity, {quantity}
83 |
84 |
85 |
90 | −
91 |
92 |
93 |
98 | +
99 |
100 |
101 | >
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/cart/index.ts:
--------------------------------------------------------------------------------
1 | export {CartDetails} from './CartDetails.client';
2 | export {CartEmpty} from './CartEmpty.client';
3 | export {CartLineItem} from './CartLineItem.client';
4 |
--------------------------------------------------------------------------------
/src/components/elements/Button.tsx:
--------------------------------------------------------------------------------
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 | as?: React.ElementType;
14 | className?: string;
15 | variant?: 'primary' | 'secondary' | 'inline';
16 | width?: 'auto' | 'full';
17 | [key: string]: any;
18 | }) {
19 | const Component = props?.to ? Link : as;
20 |
21 | const baseButtonClasses =
22 | 'inline-block rounded font-medium text-center py-3 px-6';
23 |
24 | const variants = {
25 | primary: `${baseButtonClasses} bg-primary text-contrast`,
26 | secondary: `${baseButtonClasses} border border-primary/10 bg-contrast text-primary`,
27 | inline: 'border-b border-primary/10 leading-none pb-1',
28 | };
29 |
30 | const widths = {
31 | auto: 'w-auto',
32 | full: 'w-full',
33 | };
34 |
35 | const styles = clsx(
36 | missingClass(className, 'bg-') && variants[variant],
37 | missingClass(className, 'w-') && widths[width],
38 | className,
39 | );
40 |
41 | return ;
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/elements/Grid.tsx:
--------------------------------------------------------------------------------
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 | as?: React.ElementType;
13 | className?: string;
14 | flow?: 'row' | 'col';
15 | gap?: 'default' | 'blog';
16 | items?: number;
17 | layout?: 'default' | 'products' | 'auto' | 'blog';
18 | [key: string]: any;
19 | }) {
20 | const layouts = {
21 | default: `grid-cols-1 ${items === 2 && 'md:grid-cols-2'} ${
22 | items === 3 && 'sm:grid-cols-3'
23 | } ${items > 3 && 'md:grid-cols-3'} ${items >= 4 && 'lg:grid-cols-4'}`,
24 | products: `grid-cols-2 ${items >= 3 && 'md:grid-cols-3'} ${
25 | items >= 4 && 'lg:grid-cols-4'
26 | }`,
27 | auto: 'auto-cols-auto',
28 | blog: `grid-cols-2 pt-24`,
29 | };
30 |
31 | const gaps = {
32 | default: 'grid gap-2 gap-y-6 md:gap-4 lg:gap-6',
33 | blog: 'grid gap-6',
34 | };
35 |
36 | const flows = {
37 | row: 'grid-flow-row',
38 | col: 'grid-flow-col',
39 | };
40 |
41 | const styles = clsx(flows[flow], gaps[gap], layouts[layout], className);
42 |
43 | return ;
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/elements/Heading.tsx:
--------------------------------------------------------------------------------
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 | as?: React.ElementType;
15 | children: React.ReactNode;
16 | format?: boolean;
17 | size?: 'display' | 'heading' | 'lead' | 'copy';
18 | width?: 'default' | 'narrow' | 'wide';
19 | } & React.HTMLAttributes) {
20 | const sizes = {
21 | display: 'font-bold text-display',
22 | heading: 'font-bold text-heading',
23 | lead: 'font-bold text-lead',
24 | copy: 'font-medium text-copy',
25 | };
26 |
27 | const widths = {
28 | default: 'max-w-prose',
29 | narrow: 'max-w-prose-narrow',
30 | wide: 'max-w-prose-wide',
31 | };
32 |
33 | const styles = clsx(
34 | missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
35 | missingClass(className, 'max-w-') && widths[width],
36 | missingClass(className, 'font-') && sizes[size],
37 | className,
38 | );
39 |
40 | return (
41 |
42 | {format ? formatText(children) : children}
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/elements/Input.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export function Input({
4 | className = '',
5 | type,
6 | variant,
7 | ...props
8 | }: {
9 | className?: string;
10 | type?: string;
11 | variant: 'search' | 'minisearch';
12 | [key: string]: any;
13 | }) {
14 | const variants = {
15 | search:
16 | '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',
17 | minisearch:
18 | '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',
19 | };
20 |
21 | const styles = clsx(variants[variant], className);
22 |
23 | return ;
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/elements/LogoutButton.client.tsx:
--------------------------------------------------------------------------------
1 | interface ButtonProps extends React.ButtonHTMLAttributes {
2 | onClick?: () => void;
3 | }
4 |
5 | export function LogoutButton(props: ButtonProps) {
6 | const logout = () => {
7 | fetch('/account/logout', {method: 'POST'}).then(() => {
8 | if (typeof props?.onClick === 'function') {
9 | props.onClick();
10 | }
11 | window.location.href = '/';
12 | });
13 | };
14 |
15 | return (
16 |
17 | Logout
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/elements/Section.tsx:
--------------------------------------------------------------------------------
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 | as?: React.ElementType;
17 | children?: React.ReactNode;
18 | className?: string;
19 | divider?: 'none' | 'top' | 'bottom' | 'both';
20 | display?: 'grid' | 'flex';
21 | heading?: string;
22 | padding?: 'x' | 'y' | 'swimlane' | 'all';
23 | [key: string]: any;
24 | }) {
25 | const paddings = {
26 | x: 'px-6 md:px-8 lg:px-12',
27 | y: 'py-6 md:py-8 lg:py-12',
28 | swimlane: 'pt-4 md:pt-8 lg:pt-12 md:pb-4 lg:pb-8',
29 | all: 'p-6 md:p-8 lg:p-12',
30 | };
31 |
32 | const dividers = {
33 | none: 'border-none',
34 | top: 'border-t border-primary/05',
35 | bottom: 'border-b border-primary/05',
36 | both: 'border-y border-primary/05',
37 | };
38 |
39 | const displays = {
40 | flex: 'flex',
41 | grid: 'grid',
42 | };
43 |
44 | const styles = clsx(
45 | 'w-full gap-4 md:gap-8',
46 | displays[display],
47 | missingClass(className, '\\mp[xy]?-') && paddings[padding],
48 | dividers[divider],
49 | className,
50 | );
51 |
52 | return (
53 |
54 | {heading && (
55 |
56 | {heading}
57 |
58 | )}
59 | {children}
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/elements/Skeleton.tsx:
--------------------------------------------------------------------------------
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 | as?: React.ElementType;
14 | width?: string;
15 | height?: string;
16 | className?: string;
17 | [key: string]: any;
18 | }) {
19 | const styles = clsx('rounded bg-primary/10', className);
20 |
21 | return (
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/elements/Text.tsx:
--------------------------------------------------------------------------------
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 | as?: React.ElementType;
16 | className?: string;
17 | color?: 'default' | 'primary' | 'subtle' | 'notice' | 'contrast';
18 | format?: boolean;
19 | size?: 'lead' | 'copy' | 'fine';
20 | width?: 'default' | 'narrow' | 'wide';
21 | children: React.ReactNode;
22 | [key: string]: any;
23 | }) {
24 | const colors: Record = {
25 | default: 'inherit',
26 | primary: 'text-primary/90',
27 | subtle: 'text-primary/50',
28 | notice: 'text-notice',
29 | contrast: 'text-contrast/90',
30 | };
31 |
32 | const sizes: Record = {
33 | lead: 'text-lead font-medium',
34 | copy: 'text-copy',
35 | fine: 'text-fine subpixel-antialiased',
36 | };
37 |
38 | const widths: Record = {
39 | default: 'max-w-prose',
40 | narrow: 'max-w-prose-narrow',
41 | wide: 'max-w-prose-wide',
42 | };
43 |
44 | const styles = clsx(
45 | missingClass(className, 'max-w-') && widths[width],
46 | missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
47 | missingClass(className, 'text-') && colors[color],
48 | sizes[size],
49 | className,
50 | );
51 |
52 | return (
53 |
54 | {format ? formatText(children) : children}
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/elements/index.ts:
--------------------------------------------------------------------------------
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.tsx:
--------------------------------------------------------------------------------
1 | import {CartDetails} from '~/components/cart';
2 | import {Drawer} from './Drawer.client';
3 |
4 | export function CartDrawer({
5 | isOpen,
6 | onClose,
7 | }: {
8 | isOpen: boolean;
9 | onClose: () => void;
10 | }) {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/global/Drawer.client.tsx:
--------------------------------------------------------------------------------
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({
16 | heading,
17 | open,
18 | onClose,
19 | openFrom = 'right',
20 | children,
21 | }: {
22 | heading?: string;
23 | open: boolean;
24 | onClose: () => void;
25 | openFrom: 'right' | 'left';
26 | children: React.ReactNode;
27 | }) {
28 | const offScreen = {
29 | right: 'translate-x-full',
30 | left: '-translate-x-full',
31 | };
32 |
33 | return (
34 |
35 |
36 |
45 |
46 |
47 |
48 |
49 |
50 |
55 |
64 |
65 |
70 | {heading !== null && (
71 |
72 |
73 | {heading}
74 |
75 |
76 | )}
77 |
82 |
83 |
84 |
85 | {children}
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
96 | /* Use for associating arialabelledby with the title*/
97 | Drawer.Title = Dialog.Title;
98 |
99 | export {Drawer};
100 |
101 | export function useDrawer(openDefault = false) {
102 | const [isOpen, setIsOpen] = useState(openDefault);
103 |
104 | function openDrawer() {
105 | setIsOpen(true);
106 | }
107 |
108 | function closeDrawer() {
109 | setIsOpen(false);
110 | }
111 |
112 | return {
113 | isOpen,
114 | openDrawer,
115 | closeDrawer,
116 | };
117 | }
118 |
--------------------------------------------------------------------------------
/src/components/global/Footer.server.tsx:
--------------------------------------------------------------------------------
1 | import {useUrl} from '@shopify/hydrogen';
2 |
3 | import {Section, Heading, FooterMenu, CountrySelector} from '~/components';
4 | import type {EnhancedMenu} from '~/lib/utils';
5 |
6 | /**
7 | * A server component that specifies the content of the footer on the website
8 | */
9 | export function Footer({menu}: {menu?: EnhancedMenu}) {
10 | const {pathname} = useUrl();
11 |
12 | const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
13 | const countryCode = localeMatch ? localeMatch[1] : null;
14 |
15 | const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
16 | const itemsCount = menu
17 | ? menu?.items?.length + 1 > 4
18 | ? 4
19 | : menu?.items?.length + 1
20 | : [];
21 |
22 | return (
23 |
31 |
32 |
33 |
34 | Country
35 |
36 |
37 |
38 |
41 | © {new Date().getFullYear()} / Shopify, Inc. Hydrogen is an MIT
42 | Licensed Open Source project. This website is carbon neutral.
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/global/FooterMenu.client.tsx:
--------------------------------------------------------------------------------
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 | import type {EnhancedMenu, EnhancedMenuItem} from '~/lib/utils';
7 |
8 | /**
9 | * A server component that specifies the content of the footer on the website
10 | */
11 | export function FooterMenu({menu}: {menu?: EnhancedMenu}) {
12 | const styles = {
13 | section: 'grid gap-4',
14 | nav: 'grid gap-2 pb-6',
15 | };
16 |
17 | return (
18 | <>
19 | {(menu?.items || []).map((item: EnhancedMenuItem) => (
20 |
21 |
22 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
23 | {({open}) => (
24 | <>
25 |
26 |
27 | {item.title}
28 | {item?.items?.length > 0 && (
29 |
30 |
31 |
32 | )}
33 |
34 |
35 | {item?.items?.length > 0 && (
36 |
41 |
42 |
43 | {item.items.map((subItem) => (
44 |
49 | {subItem.title}
50 |
51 | ))}
52 |
53 |
54 |
55 | )}
56 | >
57 | )}
58 |
59 |
60 | ))}{' '}
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/global/Layout.server.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useLocalization, useShopQuery, CacheLong, gql} from '@shopify/hydrogen';
3 | import type {Menu, Shop} from '@shopify/hydrogen/storefront-api-types';
4 |
5 | import {Header} from '~/components';
6 | import {Footer} from '~/components/index.server';
7 | import {parseMenu} from '~/lib/utils';
8 |
9 | const HEADER_MENU_HANDLE = 'main-menu';
10 | const FOOTER_MENU_HANDLE = 'footer';
11 |
12 | const SHOP_NAME_FALLBACK = 'Hydrogen';
13 |
14 | /**
15 | * A server component that defines a structure and organization of a page that can be used in different parts of the Hydrogen app
16 | */
17 | export function Layout({children}: {children: React.ReactNode}) {
18 | return (
19 | <>
20 |
21 |
26 |
}>
27 |
28 |
29 |
30 | {children}
31 |
32 |
33 | }>
34 |
35 |
36 | >
37 | );
38 | }
39 |
40 | function HeaderWithMenu() {
41 | const {shopName, headerMenu} = useLayoutQuery();
42 | return ;
43 | }
44 |
45 | function FooterWithMenu() {
46 | const {footerMenu} = useLayoutQuery();
47 | return ;
48 | }
49 |
50 | function useLayoutQuery() {
51 | const {
52 | language: {isoCode: languageCode},
53 | } = useLocalization();
54 |
55 | const {data} = useShopQuery<{
56 | shop: Shop;
57 | headerMenu: Menu;
58 | footerMenu: Menu;
59 | }>({
60 | query: SHOP_QUERY,
61 | variables: {
62 | language: languageCode,
63 | headerMenuHandle: HEADER_MENU_HANDLE,
64 | footerMenuHandle: FOOTER_MENU_HANDLE,
65 | },
66 | cache: CacheLong(),
67 | preload: '*',
68 | });
69 |
70 | const shopName = data ? data.shop.name : SHOP_NAME_FALLBACK;
71 |
72 | /*
73 | Modify specific links/routes (optional)
74 | @see: https://shopify.dev/api/storefront/unstable/enums/MenuItemType
75 | e.g here we map:
76 | - /blogs/news -> /news
77 | - /blog/news/blog-post -> /news/blog-post
78 | - /collections/all -> /products
79 | */
80 | const customPrefixes = {BLOG: '', CATALOG: 'products'};
81 |
82 | const headerMenu = data?.headerMenu
83 | ? parseMenu(data.headerMenu, customPrefixes)
84 | : undefined;
85 |
86 | const footerMenu = data?.footerMenu
87 | ? parseMenu(data.footerMenu, customPrefixes)
88 | : undefined;
89 |
90 | return {footerMenu, headerMenu, shopName};
91 | }
92 |
93 | const SHOP_QUERY = gql`
94 | fragment MenuItem on MenuItem {
95 | id
96 | resourceId
97 | tags
98 | title
99 | type
100 | url
101 | }
102 | query layoutMenus(
103 | $language: LanguageCode
104 | $headerMenuHandle: String!
105 | $footerMenuHandle: String!
106 | ) @inContext(language: $language) {
107 | shop {
108 | name
109 | }
110 | headerMenu: menu(handle: $headerMenuHandle) {
111 | id
112 | items {
113 | ...MenuItem
114 | items {
115 | ...MenuItem
116 | }
117 | }
118 | }
119 | footerMenu: menu(handle: $footerMenuHandle) {
120 | id
121 | items {
122 | ...MenuItem
123 | items {
124 | ...MenuItem
125 | }
126 | }
127 | }
128 | }
129 | `;
130 |
--------------------------------------------------------------------------------
/src/components/global/MenuDrawer.client.tsx:
--------------------------------------------------------------------------------
1 | import {EnhancedMenu} from '~/lib/utils';
2 | import {Text} from '~/components';
3 | import {Drawer} from './Drawer.client';
4 | import {Link} from '@shopify/hydrogen';
5 |
6 | export function MenuDrawer({
7 | isOpen,
8 | onClose,
9 | menu,
10 | }: {
11 | isOpen: boolean;
12 | onClose: () => void;
13 | menu: EnhancedMenu;
14 | }) {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | function MenuMobileNav({
25 | menu,
26 | onClose,
27 | }: {
28 | menu: EnhancedMenu;
29 | onClose: () => void;
30 | }) {
31 | return (
32 |
33 | {/* Top level menu items */}
34 | {(menu?.items || []).map((item) => (
35 |
36 |
37 | {item.title}
38 |
39 |
40 | ))}
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/global/Modal.client.tsx:
--------------------------------------------------------------------------------
1 | import {IconClose} from '~/components';
2 |
3 | export function Modal({
4 | children,
5 | close,
6 | }: {
7 | children: React.ReactNode;
8 | close: () => void;
9 | }) {
10 | return (
11 |
18 |
19 |
20 |
21 |
e.stopPropagation()}
25 | onKeyPress={(e) => e.stopPropagation()}
26 | tabIndex={0}
27 | >
28 |
29 |
34 |
35 |
36 |
37 | {children}
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/global/NotFound.server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | gql,
3 | HydrogenResponse,
4 | useLocalization,
5 | useShopQuery,
6 | } from '@shopify/hydrogen';
7 |
8 | import {Suspense} from 'react';
9 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
10 | import {Button, FeaturedCollections, PageHeader, Text} from '~/components';
11 | import {ProductSwimlane, Layout} from '~/components/index.server';
12 | import type {
13 | CollectionConnection,
14 | ProductConnection,
15 | } from '@shopify/hydrogen/storefront-api-types';
16 |
17 | export function NotFound({
18 | response,
19 | type = 'page',
20 | }: {
21 | response?: HydrogenResponse;
22 | type?: string;
23 | }) {
24 | if (response) {
25 | response.status = 404;
26 | response.statusText = 'Not found';
27 | }
28 |
29 | const heading = `We’ve lost this ${type}`;
30 | const description = `We couldn’t find the ${type} you’re looking for. Try checking the URL or heading back to the home page.`;
31 |
32 | return (
33 |
34 |
35 |
36 | {description}
37 |
38 |
39 | Take me to the home page
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
49 | function FeaturedSection() {
50 | const {
51 | language: {isoCode: languageCode},
52 | country: {isoCode: countryCode},
53 | } = useLocalization();
54 |
55 | const {data} = useShopQuery<{
56 | featuredCollections: CollectionConnection;
57 | featuredProducts: ProductConnection;
58 | }>({
59 | query: NOT_FOUND_QUERY,
60 | variables: {
61 | language: languageCode,
62 | country: countryCode,
63 | },
64 | preload: true,
65 | });
66 |
67 | const {featuredCollections, featuredProducts} = data;
68 |
69 | return (
70 | <>
71 | {featuredCollections.nodes.length < 2 && (
72 |
76 | )}
77 |
78 | >
79 | );
80 | }
81 |
82 | const NOT_FOUND_QUERY = gql`
83 | ${PRODUCT_CARD_FRAGMENT}
84 | query homepage($country: CountryCode, $language: LanguageCode)
85 | @inContext(country: $country, language: $language) {
86 | featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
87 | nodes {
88 | id
89 | title
90 | handle
91 | image {
92 | altText
93 | width
94 | height
95 | url
96 | }
97 | }
98 | }
99 | featuredProducts: products(first: 12) {
100 | nodes {
101 | ...ProductCard
102 | }
103 | }
104 | }
105 | `;
106 |
--------------------------------------------------------------------------------
/src/components/global/PageHeader.tsx:
--------------------------------------------------------------------------------
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 | children?: React.ReactNode;
13 | className?: string;
14 | heading?: string;
15 | variant?: 'default' | 'blogPost' | 'allCollections';
16 | [key: string]: any;
17 | }) {
18 | const variants: Record = {
19 | default: 'grid w-full gap-8 p-6 py-8 md:p-8 lg:p-12 justify-items-start',
20 | blogPost:
21 | 'grid md:text-center w-full gap-4 p-6 py-8 md:p-8 lg:p-12 md:justify-items-center',
22 | allCollections:
23 | 'flex justify-between items-baseline gap-8 p-6 md:p-8 lg:p-12',
24 | };
25 |
26 | const styles = clsx(variants[variant], className);
27 |
28 | return (
29 |
30 | {heading && (
31 |
32 | {heading}
33 |
34 | )}
35 | {children}
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/global/index.server.ts:
--------------------------------------------------------------------------------
1 | export {Footer} from './Footer.server';
2 | export {Layout} from './Layout.server';
3 | export {NotFound} from './NotFound.server';
4 |
--------------------------------------------------------------------------------
/src/components/global/index.ts:
--------------------------------------------------------------------------------
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/index.server.ts:
--------------------------------------------------------------------------------
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/index.ts:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------
/src/components/product/ProductDetail.client.tsx:
--------------------------------------------------------------------------------
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({
8 | title,
9 | content,
10 | learnMore,
11 | }: {
12 | title: string;
13 | content: string;
14 | learnMore?: string;
15 | }) {
16 | return (
17 |
18 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
19 | {({open}) => (
20 | <>
21 |
22 |
23 |
24 | {title}
25 |
26 |
31 |
32 |
33 |
34 |
35 |
39 | {learnMore && (
40 |
41 |
45 | Learn more
46 |
47 |
48 | )}
49 |
50 | >
51 | )}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/product/ProductForm.client.tsx:
--------------------------------------------------------------------------------
1 | import {useEffect, useCallback, useState} from 'react';
2 |
3 | import {
4 | useProductOptions,
5 | isBrowser,
6 | useUrl,
7 | AddToCartButton,
8 | Money,
9 | OptionWithValues,
10 | ShopPayButton,
11 | } from '@shopify/hydrogen';
12 |
13 | import {Heading, Text, Button, ProductOptions} from '~/components';
14 |
15 | export function ProductForm() {
16 | const {pathname, search} = useUrl();
17 | const [params, setParams] = useState(new URLSearchParams(search));
18 |
19 | const {options, setSelectedOption, selectedOptions, selectedVariant} =
20 | useProductOptions();
21 |
22 | const isOutOfStock = !selectedVariant?.availableForSale || false;
23 | const isOnSale =
24 | selectedVariant?.priceV2?.amount <
25 | selectedVariant?.compareAtPriceV2?.amount || false;
26 |
27 | useEffect(() => {
28 | if (params || !search) return;
29 | setParams(new URLSearchParams(search));
30 | }, [params, search]);
31 |
32 | useEffect(() => {
33 | (options as OptionWithValues[]).map(({name, values}) => {
34 | if (!params) return;
35 | const currentValue = params.get(name.toLowerCase()) || null;
36 | if (currentValue) {
37 | const matchedValue = values.filter(
38 | (value) => encodeURIComponent(value.toLowerCase()) === currentValue,
39 | );
40 | setSelectedOption(name, matchedValue[0]);
41 | } else {
42 | params.set(
43 | encodeURIComponent(name.toLowerCase()),
44 | encodeURIComponent(selectedOptions![name]!.toLowerCase()),
45 | ),
46 | window.history.replaceState(
47 | null,
48 | '',
49 | `${pathname}?${params.toString()}`,
50 | );
51 | }
52 | });
53 | // eslint-disable-next-line react-hooks/exhaustive-deps
54 | }, []);
55 |
56 | const handleChange = useCallback(
57 | (name: string, value: string) => {
58 | setSelectedOption(name, value);
59 | if (!params) return;
60 | params.set(
61 | encodeURIComponent(name.toLowerCase()),
62 | encodeURIComponent(value.toLowerCase()),
63 | );
64 | if (isBrowser()) {
65 | window.history.replaceState(
66 | null,
67 | '',
68 | `${pathname}?${params.toString()}`,
69 | );
70 | }
71 | },
72 | [setSelectedOption, params, pathname],
73 | );
74 |
75 | return (
76 |
144 | );
145 | }
146 |
--------------------------------------------------------------------------------
/src/components/product/ProductGallery.client.tsx:
--------------------------------------------------------------------------------
1 | import {MediaFile} from '@shopify/hydrogen/client';
2 | import type {MediaEdge} from '@shopify/hydrogen/storefront-api-types';
3 | import {ATTR_LOADING_EAGER} from '~/lib/const';
4 |
5 | /**
6 | * A client component that defines a media gallery for hosting images, 3D models, and videos of products
7 | */
8 | export function ProductGallery({
9 | media,
10 | className,
11 | }: {
12 | media: MediaEdge['node'][];
13 | className?: string;
14 | }) {
15 | if (!media.length) {
16 | return null;
17 | }
18 |
19 | return (
20 |
23 | {media.map((med, i) => {
24 | let mediaProps: Record
= {};
25 | const isFirst = i === 0;
26 | const isFourth = i === 3;
27 | const isFullWidth = i % 3 === 0;
28 |
29 | const data = {
30 | ...med,
31 | image: {
32 | // @ts-ignore
33 | ...med.image,
34 | altText: med.alt || 'Product image',
35 | },
36 | };
37 |
38 | switch (med.mediaContentType) {
39 | case 'IMAGE':
40 | mediaProps = {
41 | width: 800,
42 | widths: [400, 800, 1200, 1600, 2000, 2400],
43 | };
44 | break;
45 | case 'VIDEO':
46 | mediaProps = {
47 | width: '100%',
48 | autoPlay: true,
49 | controls: false,
50 | muted: true,
51 | loop: true,
52 | preload: 'auto',
53 | };
54 | break;
55 | case 'EXTERNAL_VIDEO':
56 | mediaProps = {width: '100%'};
57 | break;
58 | case 'MODEL_3D':
59 | mediaProps = {
60 | width: '100%',
61 | interactionPromptThreshold: '0',
62 | ar: true,
63 | loading: ATTR_LOADING_EAGER,
64 | disableZoom: true,
65 | };
66 | break;
67 | }
68 |
69 | if (i === 0 && med.mediaContentType === 'IMAGE') {
70 | mediaProps.loading = ATTR_LOADING_EAGER;
71 | }
72 |
73 | const style = [
74 | isFullWidth ? 'md:col-span-2' : 'md:col-span-1',
75 | isFirst || isFourth ? '' : 'md:aspect-[4/5]',
76 | 'aspect-square snap-center card-image bg-white dark:bg-contrast/10 w-mobileGallery md:w-full',
77 | ].join(' ');
78 |
79 | return (
80 |
85 |
101 |
102 | );
103 | })}
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/components/product/ProductGrid.client.tsx:
--------------------------------------------------------------------------------
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 | import type {Collection, Product} from '@shopify/hydrogen/storefront-api-types';
7 |
8 | export function ProductGrid({
9 | url,
10 | collection,
11 | }: {
12 | url: string;
13 | collection: Collection;
14 | }) {
15 | const nextButtonRef = useRef(null);
16 | const initialProducts = collection?.products?.nodes || [];
17 | const {hasNextPage, endCursor} = collection?.products?.pageInfo ?? {};
18 | const [products, setProducts] = useState(initialProducts);
19 | const [cursor, setCursor] = useState(endCursor ?? '');
20 | const [nextPage, setNextPage] = useState(hasNextPage);
21 | const [pending, setPending] = useState(false);
22 | const haveProducts = initialProducts.length > 0;
23 |
24 | const fetchProducts = useCallback(async () => {
25 | setPending(true);
26 | const postUrl = new URL(window.location.origin + url);
27 | postUrl.searchParams.set('cursor', cursor);
28 |
29 | const response = await fetch(postUrl, {
30 | method: 'POST',
31 | });
32 | const {data} = await response.json();
33 |
34 | // ProductGrid can paginate collection, products and search routes
35 | // @ts-ignore TODO: Fix types
36 | const newProducts: Product[] = flattenConnection(
37 | data?.collection?.products || data?.products || [],
38 | );
39 | const {endCursor, hasNextPage} = data?.collection?.products?.pageInfo ||
40 | data?.products?.pageInfo || {endCursor: '', hasNextPage: false};
41 |
42 | setProducts([...products, ...newProducts]);
43 | setCursor(endCursor);
44 | setNextPage(hasNextPage);
45 | setPending(false);
46 | }, [cursor, url, products]);
47 |
48 | const handleIntersect = useCallback(
49 | (entries: IntersectionObserverEntry[]) => {
50 | entries.forEach((entry) => {
51 | if (entry.isIntersecting) {
52 | fetchProducts();
53 | }
54 | });
55 | },
56 | [fetchProducts],
57 | );
58 |
59 | useEffect(() => {
60 | const observer = new IntersectionObserver(handleIntersect, {
61 | rootMargin: '100%',
62 | });
63 |
64 | const nextButton = nextButtonRef.current;
65 |
66 | if (nextButton) observer.observe(nextButton);
67 |
68 | return () => {
69 | if (nextButton) observer.unobserve(nextButton);
70 | };
71 | }, [nextButtonRef, cursor, handleIntersect]);
72 |
73 | if (!haveProducts) {
74 | return (
75 | <>
76 | No products found on this collection
77 |
78 | Browse catalog
79 |
80 | >
81 | );
82 | }
83 |
84 | return (
85 | <>
86 |
87 | {products.map((product, i) => (
88 |
93 | ))}
94 |
95 |
96 | {nextPage && (
97 |
101 |
107 | {pending ? 'Loading...' : 'Load more products'}
108 |
109 |
110 | )}
111 | >
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/product/ProductOptions.client.tsx:
--------------------------------------------------------------------------------
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({
9 | values,
10 | ...props
11 | }: {
12 | values: any[];
13 | [key: string]: any;
14 | } & React.ComponentProps) {
15 | const asDropdown = values.length > 7;
16 |
17 | return asDropdown ? (
18 |
19 | ) : (
20 |
21 | );
22 | }
23 |
24 | function OptionsGrid({
25 | values,
26 | name,
27 | handleChange,
28 | }: {
29 | values: string[];
30 | name: string;
31 | handleChange: (name: string, value: string) => void;
32 | }) {
33 | const {selectedOptions} = useProductOptions();
34 |
35 | return (
36 | <>
37 | {values.map((value) => {
38 | const checked = selectedOptions![name] === value;
39 | const id = `option-${name}-${value}`;
40 |
41 | return (
42 |
43 | handleChange(name, value)}
51 | />
52 |
57 | {value}
58 |
59 |
60 | );
61 | })}
62 | >
63 | );
64 | }
65 |
66 | // TODO: De-dupe UI with CountrySelector
67 | function OptionsDropdown({
68 | values,
69 | name,
70 | handleChange,
71 | }: {
72 | values: string[];
73 | name: string;
74 | handleChange: (name: string, value: string) => void;
75 | }) {
76 | const [listboxOpen, setListboxOpen] = useState(false);
77 | const {selectedOptions} = useProductOptions();
78 |
79 | const updateSelectedOption = useCallback(
80 | (value: string) => {
81 | handleChange(name, value);
82 | },
83 | [name, handleChange],
84 | );
85 |
86 | return (
87 |
88 |
89 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
90 | {({open}) => {
91 | setTimeout(() => setListboxOpen(open));
92 | return (
93 | <>
94 |
99 | {selectedOptions![name]}
100 |
101 |
102 |
103 |
110 | {values.map((value) => {
111 | const isSelected = selectedOptions![name] === value;
112 | const id = `option-${name}-${value}`;
113 |
114 | return (
115 |
116 | {/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
117 | {({active}) => (
118 |
123 | {value}
124 | {isSelected ? (
125 |
126 |
127 |
128 | ) : null}
129 |
130 | )}
131 |
132 | );
133 | })}
134 |
135 | >
136 | );
137 | }}
138 |
139 |
140 | );
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/product/index.ts:
--------------------------------------------------------------------------------
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.tsx:
--------------------------------------------------------------------------------
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({
9 | country,
10 | language,
11 | }: {
12 | country: string;
13 | language: string;
14 | }) {
15 | const {data} = useShopQuery({
16 | query: SEARCH_NO_RESULTS_QUERY,
17 | variables: {
18 | country,
19 | language,
20 | pageBy: PAGINATION_SIZE,
21 | },
22 | preload: false,
23 | });
24 |
25 | return (
26 | <>
27 |
31 |
35 | >
36 | );
37 | }
38 |
39 | const SEARCH_NO_RESULTS_QUERY = gql`
40 | ${PRODUCT_CARD_FRAGMENT}
41 | query searchNoResult(
42 | $country: CountryCode
43 | $language: LanguageCode
44 | $pageBy: Int!
45 | ) @inContext(country: $country, language: $language) {
46 | featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
47 | nodes {
48 | id
49 | title
50 | handle
51 | image {
52 | altText
53 | width
54 | height
55 | url
56 | }
57 | }
58 | }
59 | featuredProducts: products(first: $pageBy) {
60 | nodes {
61 | ...ProductCard
62 | }
63 | }
64 | }
65 | `;
66 |
--------------------------------------------------------------------------------
/src/components/search/SearchPage.server.tsx:
--------------------------------------------------------------------------------
1 | import {Heading, Input, PageHeader} from '~/components';
2 | import {Layout} from '~/components/index.server';
3 |
4 | export function SearchPage({
5 | searchTerm,
6 | children,
7 | }: {
8 | searchTerm?: string | null;
9 | children: React.ReactNode;
10 | }) {
11 | return (
12 |
13 |
14 |
15 | Search
16 |
17 |
29 |
30 | {children}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/search/index.server.ts:
--------------------------------------------------------------------------------
1 | export {NoResultRecommendations} from './NoResultRecommendations.server';
2 | export {SearchPage} from './SearchPage.server';
3 |
--------------------------------------------------------------------------------
/src/components/sections/FeaturedCollections.tsx:
--------------------------------------------------------------------------------
1 | import {Link, Image} from '@shopify/hydrogen';
2 | import type {Collection} from '@shopify/hydrogen/storefront-api-types';
3 |
4 | import {Heading, Section, Grid} from '~/components';
5 |
6 | export function FeaturedCollections({
7 | data,
8 | title = 'Collections',
9 | ...props
10 | }: {
11 | data: Collection[];
12 | title?: string;
13 | [key: string]: any;
14 | }) {
15 | const items = data.filter((item) => item.image).length;
16 | const haveCollections = data.length > 0;
17 |
18 | if (!haveCollections) return null;
19 |
20 | return (
21 |
22 |
23 | {data.map((collection) => {
24 | if (!collection?.image) {
25 | return null;
26 | }
27 | // TODO: Refactor to use CollectionCard
28 | return (
29 |
30 |
31 |
32 | {collection?.image && (
33 |
45 | )}
46 |
47 |
{collection.title}
48 |
49 |
50 | );
51 | })}
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/sections/Hero.tsx:
--------------------------------------------------------------------------------
1 | import {Image, Link, Video} from '@shopify/hydrogen';
2 | import type {Media} from '@shopify/hydrogen/storefront-api-types';
3 |
4 | import {Heading, Text} from '~/components';
5 |
6 | interface Metafield {
7 | value: string;
8 | reference?: object;
9 | }
10 |
11 | export function Hero({
12 | byline,
13 | cta,
14 | handle,
15 | heading,
16 | height,
17 | loading,
18 | spread,
19 | spreadSecondary,
20 | top,
21 | }: {
22 | byline: Metafield;
23 | cta: Metafield;
24 | handle: string;
25 | heading: Metafield;
26 | height?: 'full';
27 | loading?: 'eager' | 'lazy';
28 | spread: Metafield;
29 | spreadSecondary: Metafield;
30 | top?: boolean;
31 | }) {
32 | return (
33 |
34 |
43 |
44 | {spread?.reference && (
45 |
46 |
62 |
63 | )}
64 | {spreadSecondary?.reference && (
65 |
66 |
72 |
73 | )}
74 |
75 |
76 | {heading?.value && (
77 |
78 | {heading.value}
79 |
80 | )}
81 | {byline?.value && (
82 |
83 | {byline.value}
84 |
85 | )}
86 | {cta?.value && {cta.value} }
87 |
88 |
89 |
90 | );
91 | }
92 |
93 | interface SpreadMediaProps {
94 | data: Media;
95 | loading?: HTMLImageElement['loading'];
96 | scale?: 2 | 3;
97 | sizes: string;
98 | width: number;
99 | widths: number[];
100 | }
101 |
102 | function SpreadMedia({
103 | data,
104 | loading,
105 | scale,
106 | sizes,
107 | width,
108 | widths,
109 | }: SpreadMediaProps) {
110 | if (data.mediaContentType === 'VIDEO') {
111 | return (
112 |
123 | );
124 | }
125 |
126 | if (data.mediaContentType === 'IMAGE') {
127 | return (
128 |
139 | );
140 | }
141 |
142 | return null;
143 | }
144 |
--------------------------------------------------------------------------------
/src/components/sections/ProductCards.tsx:
--------------------------------------------------------------------------------
1 | import {Product} from '@shopify/hydrogen/storefront-api-types';
2 | import {ProductCard} from '../cards/ProductCard.client';
3 |
4 | export function ProductCards({products}: {products: Product[]}) {
5 | return (
6 | <>
7 | {products.map((product) => (
8 |
13 | ))}
14 | >
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/sections/ProductSwimlane.server.tsx:
--------------------------------------------------------------------------------
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 | import type {
6 | Product,
7 | ProductConnection,
8 | } from '@shopify/hydrogen/storefront-api-types';
9 |
10 | const mockProducts = new Array(12).fill('');
11 |
12 | export function ProductSwimlane({
13 | title = 'Featured Products',
14 | data = mockProducts,
15 | count = 12,
16 | ...props
17 | }) {
18 | const productCardsMarkup = useMemo(() => {
19 | // If the data is already provided, there's no need to query it, so we'll just return the data
20 | if (typeof data === 'object') {
21 | return ;
22 | }
23 |
24 | // If the data provided is a productId, we will query the productRecommendations API.
25 | // To make sure we have enough products for the swimlane, we'll combine the results with our top selling products.
26 | if (typeof data === 'string') {
27 | return (
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | // If no data is provided, we'll go and query the top products
35 | return ;
36 | }, [count, data]);
37 |
38 | return (
39 |
40 |
41 | {productCardsMarkup}
42 |
43 |
44 | );
45 | }
46 |
47 | function ProductCards({products}: {products: Product[]}) {
48 | return (
49 | <>
50 | {products.map((product) => (
51 |
56 | ))}
57 | >
58 | );
59 | }
60 |
61 | function RecommendedProducts({
62 | productId,
63 | count,
64 | }: {
65 | productId: string;
66 | count: number;
67 | }) {
68 | const {
69 | language: {isoCode: languageCode},
70 | country: {isoCode: countryCode},
71 | } = useLocalization();
72 |
73 | const {data: products} = useShopQuery<{
74 | recommended: Product[];
75 | additional: ProductConnection;
76 | }>({
77 | query: RECOMMENDED_PRODUCTS_QUERY,
78 | variables: {
79 | count,
80 | productId,
81 | languageCode,
82 | countryCode,
83 | },
84 | });
85 |
86 | const mergedProducts = products.recommended
87 | .concat(products.additional.nodes)
88 | .filter(
89 | (value, index, array) =>
90 | array.findIndex((value2) => value2.id === value.id) === index,
91 | );
92 |
93 | const originalProduct = mergedProducts
94 | .map((item) => item.id)
95 | .indexOf(productId);
96 |
97 | mergedProducts.splice(originalProduct, 1);
98 |
99 | return ;
100 | }
101 |
102 | function TopProducts({count}: {count: number}) {
103 | const {
104 | data: {products},
105 | } = useShopQuery({
106 | query: TOP_PRODUCTS_QUERY,
107 | variables: {
108 | count,
109 | },
110 | });
111 |
112 | return ;
113 | }
114 |
115 | const RECOMMENDED_PRODUCTS_QUERY = gql`
116 | ${PRODUCT_CARD_FRAGMENT}
117 | query productRecommendations(
118 | $productId: ID!
119 | $count: Int
120 | $countryCode: CountryCode
121 | $languageCode: LanguageCode
122 | ) @inContext(country: $countryCode, language: $languageCode) {
123 | recommended: productRecommendations(productId: $productId) {
124 | ...ProductCard
125 | }
126 | additional: products(first: $count, sortKey: BEST_SELLING) {
127 | nodes {
128 | ...ProductCard
129 | }
130 | }
131 | }
132 | `;
133 |
134 | const TOP_PRODUCTS_QUERY = gql`
135 | ${PRODUCT_CARD_FRAGMENT}
136 | query topProducts(
137 | $count: Int
138 | $countryCode: CountryCode
139 | $languageCode: LanguageCode
140 | ) @inContext(country: $countryCode, language: $languageCode) {
141 | products(first: $count, sortKey: BEST_SELLING) {
142 | nodes {
143 | ...ProductCard
144 | }
145 | }
146 | }
147 | `;
148 |
--------------------------------------------------------------------------------
/src/components/sections/index.server.ts:
--------------------------------------------------------------------------------
1 | export {ProductSwimlane} from './ProductSwimlane.server';
2 |
--------------------------------------------------------------------------------
/src/components/sections/index.ts:
--------------------------------------------------------------------------------
1 | export {FeaturedCollections} from './FeaturedCollections';
2 | export {Hero} from './Hero';
3 |
--------------------------------------------------------------------------------
/src/lib/const.ts:
--------------------------------------------------------------------------------
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: number,
7 | maxEagerLoadCount = DEFAULT_GRID_IMG_LOAD_EAGER_COUNT,
8 | ) {
9 | return index < maxEagerLoadCount ? ATTR_LOADING_EAGER : undefined;
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/fragments.ts:
--------------------------------------------------------------------------------
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.ts:
--------------------------------------------------------------------------------
1 | export * from './fragments';
2 | export * from './placeholders';
3 | export * from './utils';
4 |
--------------------------------------------------------------------------------
/src/lib/styleUtils.tsx:
--------------------------------------------------------------------------------
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?: string | null) => {
5 | return `${INPUT_STYLE_CLASSES} ${
6 | isError ? 'border-red-500' : 'border-primary/20'
7 | }`;
8 | };
9 |
--------------------------------------------------------------------------------
/src/routes/account/activate/[id]/[activationToken].server.tsx:
--------------------------------------------------------------------------------
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.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CacheNone,
3 | gql,
4 | type HydrogenApiRouteOptions,
5 | type HydrogenRequest,
6 | } from '@shopify/hydrogen';
7 |
8 | import {getApiErrorMessage} from '~/lib/utils';
9 |
10 | /**
11 | * This API route is used by the form on `/account/activate/[id]/[activationToken]`
12 | * complete the reset of the user's password.
13 | */
14 | export async function api(
15 | request: HydrogenRequest,
16 | {session, queryShop}: HydrogenApiRouteOptions,
17 | ) {
18 | if (!session) {
19 | return new Response('Session storage not available.', {
20 | status: 400,
21 | });
22 | }
23 |
24 | const jsonBody = await request.json();
25 |
26 | if (!jsonBody?.id || !jsonBody?.password || !jsonBody?.activationToken) {
27 | return new Response(
28 | JSON.stringify({error: 'Incorrect password or activation token.'}),
29 | {
30 | status: 400,
31 | },
32 | );
33 | }
34 |
35 | const {data, errors} = await queryShop<{
36 | customerActivate: any;
37 | }>({
38 | query: CUSTOMER_ACTIVATE_MUTATION,
39 | variables: {
40 | id: `gid://shopify/Customer/${jsonBody.id}`,
41 | input: {
42 | password: jsonBody.password,
43 | activationToken: jsonBody.activationToken,
44 | },
45 | },
46 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
47 | cache: CacheNone(),
48 | });
49 |
50 | if (data?.customerActivate?.customerAccessToken?.accessToken) {
51 | await session.set(
52 | 'customerAccessToken',
53 | data.customerActivate.customerAccessToken.accessToken,
54 | );
55 |
56 | return new Response(null, {
57 | status: 200,
58 | });
59 | } else {
60 | return new Response(
61 | JSON.stringify({
62 | error: getApiErrorMessage('customerActivate', data, errors),
63 | }),
64 | {status: 401},
65 | );
66 | }
67 | }
68 |
69 | const CUSTOMER_ACTIVATE_MUTATION = gql`
70 | mutation customerActivate($id: ID!, $input: CustomerActivateInput!) {
71 | customerActivate(id: $id, input: $input) {
72 | customerAccessToken {
73 | accessToken
74 | expiresAt
75 | }
76 | customerUserErrors {
77 | code
78 | field
79 | message
80 | }
81 | }
82 | }
83 | `;
84 |
--------------------------------------------------------------------------------
/src/routes/account/address/[addressId].server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CacheNone,
3 | gql,
4 | type HydrogenApiRouteOptions,
5 | type HydrogenRequest,
6 | } from '@shopify/hydrogen';
7 |
8 | import {getApiErrorMessage} from '~/lib/utils';
9 | import type {Address} from './index.server';
10 |
11 | export async function api(
12 | request: HydrogenRequest,
13 | {params, session, queryShop}: HydrogenApiRouteOptions,
14 | ) {
15 | if (!session) {
16 | return new Response('Session storage not available.', {
17 | status: 400,
18 | });
19 | }
20 |
21 | const {customerAccessToken} = await session.get();
22 |
23 | if (!customerAccessToken) return new Response(null, {status: 401});
24 |
25 | if (request.method === 'PATCH')
26 | return updateAddress(customerAccessToken, request, params, queryShop);
27 | if (request.method === 'DELETE')
28 | return deleteAddress(customerAccessToken, params, queryShop);
29 |
30 | return new Response(null, {
31 | status: 405,
32 | headers: {
33 | Allow: 'PATCH,DELETE',
34 | },
35 | });
36 | }
37 |
38 | async function deleteAddress(
39 | customerAccessToken: string,
40 | params: HydrogenApiRouteOptions['params'],
41 | queryShop: HydrogenApiRouteOptions['queryShop'],
42 | ) {
43 | const {data, errors} = await queryShop<{
44 | customerAddressDelete: any;
45 | }>({
46 | query: DELETE_ADDRESS_MUTATION,
47 | variables: {
48 | customerAccessToken,
49 | id: decodeURIComponent(params.addressId),
50 | },
51 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
52 | cache: CacheNone(),
53 | });
54 |
55 | const error = getApiErrorMessage('customerAddressDelete', data, errors);
56 |
57 | if (error) return new Response(JSON.stringify({error}), {status: 400});
58 |
59 | return new Response(null);
60 | }
61 |
62 | async function updateAddress(
63 | customerAccessToken: string,
64 | request: HydrogenRequest,
65 | params: HydrogenApiRouteOptions['params'],
66 | queryShop: HydrogenApiRouteOptions['queryShop'],
67 | ) {
68 | const {
69 | firstName,
70 | lastName,
71 | company,
72 | address1,
73 | address2,
74 | country,
75 | province,
76 | city,
77 | zip,
78 | phone,
79 | isDefaultAddress,
80 | } = await request.json();
81 |
82 | const address: Address = {};
83 |
84 | if (firstName) address.firstName = firstName;
85 | if (lastName) address.lastName = lastName;
86 | if (company) address.company = company;
87 | if (address1) address.address1 = address1;
88 | if (address2) address.address2 = address2;
89 | if (country) address.country = country;
90 | if (province) address.province = province;
91 | if (city) address.city = city;
92 | if (zip) address.zip = zip;
93 | if (phone) address.phone = phone;
94 |
95 | const {data, errors} = await queryShop<{
96 | customerAddressUpdate: any;
97 | }>({
98 | query: UPDATE_ADDRESS_MUTATION,
99 | variables: {
100 | address,
101 | customerAccessToken,
102 | id: decodeURIComponent(params.addressId),
103 | },
104 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
105 | cache: CacheNone(),
106 | });
107 |
108 | const error = getApiErrorMessage('customerAddressUpdate', data, errors);
109 |
110 | if (error) return new Response(JSON.stringify({error}), {status: 400});
111 |
112 | if (isDefaultAddress) {
113 | const {data, errors} = await setDefaultAddress(
114 | queryShop,
115 | decodeURIComponent(params.addressId),
116 | customerAccessToken,
117 | );
118 |
119 | const error = getApiErrorMessage(
120 | 'customerDefaultAddressUpdate',
121 | data,
122 | errors,
123 | );
124 |
125 | if (error) return new Response(JSON.stringify({error}), {status: 400});
126 | }
127 |
128 | return new Response(null);
129 | }
130 |
131 | export function setDefaultAddress(
132 | queryShop: HydrogenApiRouteOptions['queryShop'],
133 | addressId: string,
134 | customerAccessToken: string,
135 | ) {
136 | return queryShop<{
137 | customerDefaultAddressUpdate: any;
138 | }>({
139 | query: UPDATE_DEFAULT_ADDRESS_MUTATION,
140 | variables: {
141 | customerAccessToken,
142 | addressId,
143 | },
144 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
145 | cache: CacheNone(),
146 | });
147 | }
148 |
149 | const UPDATE_ADDRESS_MUTATION = gql`
150 | mutation customerAddressUpdate(
151 | $address: MailingAddressInput!
152 | $customerAccessToken: String!
153 | $id: ID!
154 | ) {
155 | customerAddressUpdate(
156 | address: $address
157 | customerAccessToken: $customerAccessToken
158 | id: $id
159 | ) {
160 | customerUserErrors {
161 | code
162 | field
163 | message
164 | }
165 | }
166 | }
167 | `;
168 |
169 | const UPDATE_DEFAULT_ADDRESS_MUTATION = gql`
170 | mutation customerDefaultAddressUpdate(
171 | $addressId: ID!
172 | $customerAccessToken: String!
173 | ) {
174 | customerDefaultAddressUpdate(
175 | addressId: $addressId
176 | customerAccessToken: $customerAccessToken
177 | ) {
178 | customerUserErrors {
179 | code
180 | field
181 | message
182 | }
183 | }
184 | }
185 | `;
186 |
187 | const DELETE_ADDRESS_MUTATION = gql`
188 | mutation customerAddressDelete($customerAccessToken: String!, $id: ID!) {
189 | customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {
190 | customerUserErrors {
191 | code
192 | field
193 | message
194 | }
195 | deletedCustomerAddressId
196 | }
197 | }
198 | `;
199 |
--------------------------------------------------------------------------------
/src/routes/account/address/index.server.ts:
--------------------------------------------------------------------------------
1 | import {setDefaultAddress} from './[addressId].server';
2 | import {
3 | CacheNone,
4 | gql,
5 | type HydrogenApiRouteOptions,
6 | type HydrogenRequest,
7 | } from '@shopify/hydrogen';
8 |
9 | import {getApiErrorMessage} from '~/lib/utils';
10 |
11 | export interface Address {
12 | firstName?: string;
13 | lastName?: string;
14 | company?: string;
15 | address1?: string;
16 | address2?: string;
17 | country?: string;
18 | province?: string;
19 | city?: string;
20 | zip?: string;
21 | phone?: string;
22 | }
23 |
24 | export async function api(
25 | request: HydrogenRequest,
26 | {session, queryShop}: HydrogenApiRouteOptions,
27 | ) {
28 | if (request.method !== 'POST') {
29 | return new Response(null, {
30 | status: 405,
31 | headers: {
32 | Allow: 'POST',
33 | },
34 | });
35 | }
36 |
37 | if (!session) {
38 | return new Response('Session storage not available.', {
39 | status: 400,
40 | });
41 | }
42 |
43 | const {customerAccessToken} = await session.get();
44 |
45 | if (!customerAccessToken) return new Response(null, {status: 401});
46 |
47 | const {
48 | firstName,
49 | lastName,
50 | company,
51 | address1,
52 | address2,
53 | country,
54 | province,
55 | city,
56 | zip,
57 | phone,
58 | isDefaultAddress,
59 | } = await request.json();
60 |
61 | const address: Address = {};
62 |
63 | if (firstName) address.firstName = firstName;
64 | if (lastName) address.lastName = lastName;
65 | if (company) address.company = company;
66 | if (address1) address.address1 = address1;
67 | if (address2) address.address2 = address2;
68 | if (country) address.country = country;
69 | if (province) address.province = province;
70 | if (city) address.city = city;
71 | if (zip) address.zip = zip;
72 | if (phone) address.phone = phone;
73 |
74 | const {data, errors} = await queryShop<{
75 | customerAddressCreate: any;
76 | }>({
77 | query: CREATE_ADDRESS_MUTATION,
78 | variables: {
79 | address,
80 | customerAccessToken,
81 | },
82 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
83 | cache: CacheNone(),
84 | });
85 |
86 | const error = getApiErrorMessage('customerAddressCreate', data, errors);
87 |
88 | if (error) return new Response(JSON.stringify({error}), {status: 400});
89 |
90 | if (isDefaultAddress) {
91 | const {data: defaultDataResponse, errors} = await setDefaultAddress(
92 | queryShop,
93 | data.customerAddressCreate.customerAddress.id,
94 | customerAccessToken,
95 | );
96 |
97 | const error = getApiErrorMessage(
98 | 'customerDefaultAddressUpdate',
99 | defaultDataResponse,
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 | const CREATE_ADDRESS_MUTATION = gql`
110 | mutation customerAddressCreate(
111 | $address: MailingAddressInput!
112 | $customerAccessToken: String!
113 | ) {
114 | customerAddressCreate(
115 | address: $address
116 | customerAccessToken: $customerAccessToken
117 | ) {
118 | customerAddress {
119 | id
120 | }
121 | customerUserErrors {
122 | code
123 | field
124 | message
125 | }
126 | }
127 | }
128 | `;
129 |
--------------------------------------------------------------------------------
/src/routes/account/login.server.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | useShopQuery,
4 | CacheLong,
5 | CacheNone,
6 | Seo,
7 | gql,
8 | type HydrogenRouteProps,
9 | HydrogenRequest,
10 | HydrogenApiRouteOptions,
11 | } from '@shopify/hydrogen';
12 |
13 | import {AccountLoginForm} from '~/components';
14 | import {Layout} from '~/components/index.server';
15 |
16 | export default function Login({response}: HydrogenRouteProps) {
17 | response.cache(CacheNone());
18 |
19 | const {
20 | data: {
21 | shop: {name},
22 | },
23 | } = useShopQuery({
24 | query: SHOP_QUERY,
25 | cache: CacheLong(),
26 | preload: '*',
27 | });
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
39 | const SHOP_QUERY = gql`
40 | query shopInfo {
41 | shop {
42 | name
43 | }
44 | }
45 | `;
46 |
47 | export async function api(
48 | request: HydrogenRequest,
49 | {session, queryShop}: HydrogenApiRouteOptions,
50 | ) {
51 | if (!session) {
52 | return new Response('Session storage not available.', {status: 400});
53 | }
54 |
55 | const jsonBody = await request.json();
56 |
57 | if (
58 | !jsonBody.email ||
59 | jsonBody.email === '' ||
60 | !jsonBody.password ||
61 | jsonBody.password === ''
62 | ) {
63 | return new Response(
64 | JSON.stringify({error: 'Incorrect email or password.'}),
65 | {status: 400},
66 | );
67 | }
68 |
69 | const {data, errors} = await queryShop<{customerAccessTokenCreate: any}>({
70 | query: LOGIN_MUTATION,
71 | variables: {
72 | input: {
73 | email: jsonBody.email,
74 | password: jsonBody.password,
75 | },
76 | },
77 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
78 | cache: CacheNone(),
79 | });
80 |
81 | if (data?.customerAccessTokenCreate?.customerAccessToken?.accessToken) {
82 | await session.set(
83 | 'customerAccessToken',
84 | data.customerAccessTokenCreate.customerAccessToken.accessToken,
85 | );
86 |
87 | return new Response(null, {
88 | status: 200,
89 | });
90 | } else {
91 | return new Response(
92 | JSON.stringify({
93 | error: data?.customerAccessTokenCreate?.customerUserErrors ?? errors,
94 | }),
95 | {status: 401},
96 | );
97 | }
98 | }
99 |
100 | const LOGIN_MUTATION = gql`
101 | mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
102 | customerAccessTokenCreate(input: $input) {
103 | customerUserErrors {
104 | code
105 | field
106 | message
107 | }
108 | customerAccessToken {
109 | accessToken
110 | expiresAt
111 | }
112 | }
113 | }
114 | `;
115 |
--------------------------------------------------------------------------------
/src/routes/account/logout.server.ts:
--------------------------------------------------------------------------------
1 | import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
2 |
3 | export async function api(
4 | request: HydrogenRequest,
5 | {session}: HydrogenApiRouteOptions,
6 | ) {
7 | if (request.method !== 'POST') {
8 | return new Response('Post required to logout', {
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 | await session.set('customerAccessToken', '');
23 |
24 | return new Response();
25 | }
26 |
--------------------------------------------------------------------------------
/src/routes/account/recover.server.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | CacheNone,
4 | Seo,
5 | gql,
6 | type HydrogenRequest,
7 | type HydrogenApiRouteOptions,
8 | type HydrogenRouteProps,
9 | } from '@shopify/hydrogen';
10 |
11 | import {AccountRecoverForm} from '~/components';
12 | import {Layout} from '~/components/index.server';
13 |
14 | /**
15 | * A form for the user to fill out to _initiate_ a password reset.
16 | * If the form succeeds, an email will be sent to the user with a link
17 | * to reset their password. Clicking the link leads the user to the
18 | * page `/account/reset/[resetToken]`.
19 | */
20 | export default function AccountRecover({response}: HydrogenRouteProps) {
21 | response.cache(CacheNone());
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | export async function api(
34 | request: HydrogenRequest,
35 | {queryShop}: HydrogenApiRouteOptions,
36 | ) {
37 | const jsonBody = await request.json();
38 |
39 | if (!jsonBody.email || jsonBody.email === '') {
40 | return new Response(JSON.stringify({error: 'Email required'}), {
41 | status: 400,
42 | });
43 | }
44 |
45 | await queryShop({
46 | query: CUSTOMER_RECOVER_MUTATION,
47 | variables: {
48 | email: jsonBody.email,
49 | },
50 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
51 | cache: CacheNone(),
52 | });
53 |
54 | // Ignore errors, we don't want to tell the user if the email was
55 | // valid or not, thereby allowing them to determine who uses the site
56 | return new Response(null, {
57 | status: 200,
58 | });
59 | }
60 |
61 | const CUSTOMER_RECOVER_MUTATION = gql`
62 | mutation customerRecover($email: String!) {
63 | customerRecover(email: $email) {
64 | customerUserErrors {
65 | code
66 | field
67 | message
68 | }
69 | }
70 | }
71 | `;
72 |
--------------------------------------------------------------------------------
/src/routes/account/register.server.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | CacheNone,
4 | Seo,
5 | gql,
6 | type HydrogenRequest,
7 | type HydrogenApiRouteOptions,
8 | type HydrogenRouteProps,
9 | } from '@shopify/hydrogen';
10 |
11 | import {AccountCreateForm} from '~/components';
12 | import {Layout} from '~/components/index.server';
13 | import {getApiErrorMessage} from '~/lib/utils';
14 |
15 | export default function Register({response}: HydrogenRouteProps) {
16 | response.cache(CacheNone());
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | export async function api(
29 | request: HydrogenRequest,
30 | {queryShop}: HydrogenApiRouteOptions,
31 | ) {
32 | const jsonBody = await request.json();
33 |
34 | if (
35 | !jsonBody.email ||
36 | jsonBody.email === '' ||
37 | !jsonBody.password ||
38 | jsonBody.password === ''
39 | ) {
40 | return new Response(
41 | JSON.stringify({error: 'Email and password are required'}),
42 | {status: 400},
43 | );
44 | }
45 |
46 | const {data, errors} = await queryShop<{customerCreate: any}>({
47 | query: CUSTOMER_CREATE_MUTATION,
48 | variables: {
49 | input: {
50 | email: jsonBody.email,
51 | password: jsonBody.password,
52 | firstName: jsonBody.firstName,
53 | lastName: jsonBody.lastName,
54 | },
55 | },
56 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
57 | cache: CacheNone(),
58 | });
59 |
60 | const errorMessage = getApiErrorMessage('customerCreate', data, errors);
61 |
62 | if (
63 | !errorMessage &&
64 | data &&
65 | data.customerCreate &&
66 | data.customerCreate.customer &&
67 | data.customerCreate.customer.id
68 | ) {
69 | return new Response(null, {
70 | status: 200,
71 | });
72 | } else {
73 | return new Response(
74 | JSON.stringify({
75 | error: errorMessage ?? 'Unknown error',
76 | }),
77 | {status: 401},
78 | );
79 | }
80 | }
81 |
82 | const CUSTOMER_CREATE_MUTATION = gql`
83 | mutation customerCreate($input: CustomerCreateInput!) {
84 | customerCreate(input: $input) {
85 | customer {
86 | id
87 | }
88 | customerUserErrors {
89 | code
90 | field
91 | message
92 | }
93 | }
94 | }
95 | `;
96 |
--------------------------------------------------------------------------------
/src/routes/account/reset/[id]/[resetToken].server.tsx:
--------------------------------------------------------------------------------
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.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CacheNone,
3 | gql,
4 | type HydrogenApiRouteOptions,
5 | type HydrogenRequest,
6 | } from '@shopify/hydrogen';
7 | import {getApiErrorMessage} from '~/lib/utils';
8 |
9 | /**
10 | * This API route is used by the form on `/account/reset/[id]/[resetToken]`
11 | * complete the reset of the user's password.
12 | */
13 | export async function api(
14 | request: HydrogenRequest,
15 | {session, queryShop}: HydrogenApiRouteOptions,
16 | ) {
17 | if (!session) {
18 | return new Response('Session storage not available.', {
19 | status: 400,
20 | });
21 | }
22 |
23 | const jsonBody = await request.json();
24 |
25 | if (
26 | !jsonBody.id ||
27 | jsonBody.id === '' ||
28 | !jsonBody.password ||
29 | jsonBody.password === '' ||
30 | !jsonBody.resetToken ||
31 | jsonBody.resetToken === ''
32 | ) {
33 | return new Response(
34 | JSON.stringify({error: 'Incorrect password or reset token.'}),
35 | {
36 | status: 400,
37 | },
38 | );
39 | }
40 |
41 | const {data, errors} = await queryShop<{customerReset: any}>({
42 | query: CUSTOMER_RESET_MUTATION,
43 | variables: {
44 | id: `gid://shopify/Customer/${jsonBody.id}`,
45 | input: {
46 | password: jsonBody.password,
47 | resetToken: jsonBody.resetToken,
48 | },
49 | },
50 | // @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
51 | cache: CacheNone(),
52 | });
53 |
54 | if (data?.customerReset?.customerAccessToken?.accessToken) {
55 | await session.set(
56 | 'customerAccessToken',
57 | data.customerReset.customerAccessToken.accessToken,
58 | );
59 |
60 | return new Response(null, {
61 | status: 200,
62 | });
63 | } else {
64 | return new Response(
65 | JSON.stringify({
66 | error: getApiErrorMessage('customerReset', data, errors),
67 | }),
68 | {status: 401},
69 | );
70 | }
71 | }
72 |
73 | const CUSTOMER_RESET_MUTATION = gql`
74 | mutation customerReset($id: ID!, $input: CustomerResetInput!) {
75 | customerReset(id: $id, input: $input) {
76 | customerAccessToken {
77 | accessToken
78 | expiresAt
79 | }
80 | customerUserErrors {
81 | code
82 | field
83 | message
84 | }
85 | }
86 | }
87 | `;
88 |
--------------------------------------------------------------------------------
/src/routes/admin.server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useShopQuery,
3 | gql,
4 | CacheLong,
5 | type HydrogenRouteProps,
6 | } from '@shopify/hydrogen';
7 | import type {Shop} from '@shopify/hydrogen/storefront-api-types';
8 |
9 | /*
10 | This route redirects you to your Shopify Admin
11 | by querying for your myshopify.com domain.
12 | Learn more about the redirect method here:
13 | https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect
14 | */
15 |
16 | export default function AdminRedirect({response}: HydrogenRouteProps) {
17 | const {data} = useShopQuery<{
18 | shop: Shop;
19 | }>({
20 | query: SHOP_QUERY,
21 | cache: CacheLong(),
22 | });
23 |
24 | const {url} = data.shop.primaryDomain;
25 | return response.redirect(`${url}/admin`);
26 | }
27 |
28 | const SHOP_QUERY = gql`
29 | query {
30 | shop {
31 | primaryDomain {
32 | url
33 | }
34 | }
35 | }
36 | `;
37 |
--------------------------------------------------------------------------------
/src/routes/api/bestSellers.server.ts:
--------------------------------------------------------------------------------
1 | import {gql} from '@shopify/hydrogen';
2 | import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
3 | import {ProductConnection} from '@shopify/hydrogen/storefront-api-types';
4 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
5 |
6 | export async function api(
7 | _request: HydrogenRequest,
8 | {queryShop}: HydrogenApiRouteOptions,
9 | ) {
10 | const {
11 | data: {products},
12 | } = await queryShop<{
13 | products: ProductConnection;
14 | }>({
15 | query: TOP_PRODUCTS_QUERY,
16 | variables: {
17 | count: 4,
18 | },
19 | });
20 |
21 | return products.nodes;
22 | }
23 |
24 | const TOP_PRODUCTS_QUERY = gql`
25 | ${PRODUCT_CARD_FRAGMENT}
26 | query topProducts(
27 | $count: Int
28 | $countryCode: CountryCode
29 | $languageCode: LanguageCode
30 | ) @inContext(country: $countryCode, language: $languageCode) {
31 | products(first: $count, sortKey: BEST_SELLING) {
32 | nodes {
33 | ...ProductCard
34 | }
35 | }
36 | }
37 | `;
38 |
--------------------------------------------------------------------------------
/src/routes/api/countries.server.ts:
--------------------------------------------------------------------------------
1 | import {gql} from '@shopify/hydrogen';
2 | import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
3 | import type {Localization} from '@shopify/hydrogen/storefront-api-types';
4 |
5 | export async function api(
6 | _request: HydrogenRequest,
7 | {queryShop}: HydrogenApiRouteOptions,
8 | ) {
9 | const {
10 | data: {
11 | localization: {availableCountries},
12 | },
13 | } = await queryShop<{
14 | localization: Localization;
15 | }>({
16 | query: COUNTRIES_QUERY,
17 | });
18 |
19 | return availableCountries.sort((a, b) => a.name.localeCompare(b.name));
20 | }
21 |
22 | const COUNTRIES_QUERY = gql`
23 | query Localization {
24 | localization {
25 | availableCountries {
26 | isoCode
27 | name
28 | currency {
29 | isoCode
30 | }
31 | }
32 | }
33 | }
34 | `;
35 |
--------------------------------------------------------------------------------
/src/routes/builder/[handle].server.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 |
3 | import {PageHeader} from '~/components';
4 | import {NotFound, Layout} from '~/components/index.server';
5 | import {BuilderComponent} from '~/components/BuilderComponent.client';
6 |
7 | import {useQuery} from '@shopify/hydrogen';
8 | import {builder} from '@builder.io/react';
9 |
10 | // Make sure to replace this placeholder API key with the
11 | // API key for your Builder space: https://www.builder.io/c/docs/using-your-api-key#finding-your-public-api-key
12 | builder.init('cda38653c81344cf8859bd15e4d8e30d');
13 |
14 | const MODEL_NAME = 'page';
15 |
16 | export default function Page(props: any) {
17 | const content = useQuery([MODEL_NAME, props.pathname], async () => {
18 | return await builder
19 | .get(MODEL_NAME, {
20 | userAttributes: {
21 | urlPath: props.pathname,
22 | },
23 | })
24 | .promise();
25 | });
26 |
27 | const params = new URLSearchParams(props.search);
28 | const isPreviewing = params.has('builder.preview');
29 | console.log(content);
30 |
31 | return (
32 |
33 | {!content.data && !isPreviewing ? (
34 |
35 | ) : (
36 |
37 |
38 |
39 |
40 |
41 |
42 | )}
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/routes/cart.server.tsx:
--------------------------------------------------------------------------------
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.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | gql,
4 | type HydrogenRouteProps,
5 | Seo,
6 | ShopifyAnalyticsConstants,
7 | useServerAnalytics,
8 | useLocalization,
9 | useShopQuery,
10 | type HydrogenRequest,
11 | type HydrogenApiRouteOptions,
12 | } from '@shopify/hydrogen';
13 |
14 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
15 | import {PageHeader, ProductGrid, Section, Text} from '~/components';
16 | import {NotFound, Layout} from '~/components/index.server';
17 |
18 | const pageBy = 48;
19 |
20 | export default function Collection({params}: HydrogenRouteProps) {
21 | const {handle} = params;
22 | const {
23 | language: {isoCode: language},
24 | country: {isoCode: country},
25 | } = useLocalization();
26 |
27 | const {
28 | data: {collection},
29 | } = useShopQuery({
30 | query: COLLECTION_QUERY,
31 | variables: {
32 | handle,
33 | language,
34 | country,
35 | pageBy,
36 | },
37 | preload: true,
38 | });
39 |
40 | if (!collection) {
41 | return ;
42 | }
43 |
44 | useServerAnalytics({
45 | shopify: {
46 | pageType: ShopifyAnalyticsConstants.pageType.collection,
47 | resourceId: collection.id,
48 | },
49 | });
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 | {collection?.description && (
58 |
59 |
60 |
61 | {collection.description}
62 |
63 |
64 |
65 | )}
66 |
67 |
74 |
75 | );
76 | }
77 |
78 | // API endpoint that returns paginated products for this collection
79 | // @see templates/demo-store/src/components/product/ProductGrid.client.tsx
80 | export async function api(
81 | request: HydrogenRequest,
82 | {params, queryShop}: HydrogenApiRouteOptions,
83 | ) {
84 | if (request.method !== 'POST') {
85 | return new Response('Method not allowed', {
86 | status: 405,
87 | headers: {Allow: 'POST'},
88 | });
89 | }
90 | const url = new URL(request.url);
91 |
92 | const cursor = url.searchParams.get('cursor');
93 | const country = url.searchParams.get('country');
94 | const {handle} = params;
95 |
96 | return await queryShop({
97 | query: PAGINATE_COLLECTION_QUERY,
98 | variables: {
99 | handle,
100 | cursor,
101 | pageBy,
102 | country,
103 | },
104 | });
105 | }
106 |
107 | const COLLECTION_QUERY = gql`
108 | ${PRODUCT_CARD_FRAGMENT}
109 | query CollectionDetails(
110 | $handle: String!
111 | $country: CountryCode
112 | $language: LanguageCode
113 | $pageBy: Int!
114 | $cursor: String
115 | ) @inContext(country: $country, language: $language) {
116 | collection(handle: $handle) {
117 | id
118 | title
119 | description
120 | seo {
121 | description
122 | title
123 | }
124 | image {
125 | id
126 | url
127 | width
128 | height
129 | altText
130 | }
131 | products(first: $pageBy, after: $cursor) {
132 | nodes {
133 | ...ProductCard
134 | }
135 | pageInfo {
136 | hasNextPage
137 | endCursor
138 | }
139 | }
140 | }
141 | }
142 | `;
143 |
144 | const PAGINATE_COLLECTION_QUERY = gql`
145 | ${PRODUCT_CARD_FRAGMENT}
146 | query CollectionPage(
147 | $handle: String!
148 | $pageBy: Int!
149 | $cursor: String
150 | $country: CountryCode
151 | $language: LanguageCode
152 | ) @inContext(country: $country, language: $language) {
153 | collection(handle: $handle) {
154 | products(first: $pageBy, after: $cursor) {
155 | nodes {
156 | ...ProductCard
157 | }
158 | pageInfo {
159 | hasNextPage
160 | endCursor
161 | }
162 | }
163 | }
164 | }
165 | `;
166 |
--------------------------------------------------------------------------------
/src/routes/collections/all.server.tsx:
--------------------------------------------------------------------------------
1 | import {type HydrogenRouteProps} from '@shopify/hydrogen';
2 |
3 | export default function Redirect({response}: HydrogenRouteProps) {
4 | return response.redirect('/products');
5 | }
6 |
--------------------------------------------------------------------------------
/src/routes/collections/index.server.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {useShopQuery, useLocalization, gql, Seo} from '@shopify/hydrogen';
3 | import type {Collection} from '@shopify/hydrogen/storefront-api-types';
4 |
5 | import {PageHeader, Section, Grid} from '~/components';
6 | import {Layout, CollectionCard} from '~/components/index.server';
7 | import {getImageLoadingPriority, PAGINATION_SIZE} from '~/lib/const';
8 |
9 | export default function Collections() {
10 | return (
11 |
12 |
13 |
14 |
19 |
20 | );
21 | }
22 |
23 | function CollectionGrid() {
24 | const {
25 | language: {isoCode: languageCode},
26 | country: {isoCode: countryCode},
27 | } = useLocalization();
28 |
29 | const {data} = useShopQuery({
30 | query: COLLECTIONS_QUERY,
31 | variables: {
32 | pageBy: PAGINATION_SIZE,
33 | country: countryCode,
34 | language: languageCode,
35 | },
36 | preload: true,
37 | });
38 |
39 | const collections: Collection[] = data.collections.nodes;
40 |
41 | return (
42 |
43 | {collections.map((collection, i) => (
44 |
49 | ))}
50 |
51 | );
52 | }
53 |
54 | const COLLECTIONS_QUERY = gql`
55 | query Collections(
56 | $country: CountryCode
57 | $language: LanguageCode
58 | $pageBy: Int!
59 | ) @inContext(country: $country, language: $language) {
60 | collections(first: $pageBy) {
61 | nodes {
62 | id
63 | title
64 | description
65 | handle
66 | seo {
67 | description
68 | title
69 | }
70 | image {
71 | id
72 | url
73 | width
74 | height
75 | altText
76 | }
77 | }
78 | }
79 | }
80 | `;
81 |
--------------------------------------------------------------------------------
/src/routes/components/BuilderComponent.client.tsx:
--------------------------------------------------------------------------------
1 | // Do stuff here
2 | import {BuilderComponent, builder, Builder} from '@builder.io/react';
3 |
4 | // API key for your Builder space: https://www.builder.io/c/docs/using-your-api-key#finding-your-public-api-key
5 | builder.init('cda38653c81344cf8859bd15e4d8e30d');
6 |
7 | // Add custom components
8 | //
9 | // Builder.registerComponent(Welcome, {
10 | // name: 'Welcome',
11 | // });
12 |
13 | export default BuilderComponent;
14 |
--------------------------------------------------------------------------------
/src/routes/index.server.tsx:
--------------------------------------------------------------------------------
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 | import {
17 | CollectionConnection,
18 | ProductConnection,
19 | } from '@shopify/hydrogen/storefront-api-types';
20 |
21 | export default function Homepage() {
22 | useServerAnalytics({
23 | shopify: {
24 | pageType: ShopifyAnalyticsConstants.pageType.home,
25 | },
26 | });
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | function HomepageContent() {
41 | const {
42 | language: {isoCode: languageCode},
43 | country: {isoCode: countryCode},
44 | } = useLocalization();
45 |
46 | const {data} = useShopQuery<{
47 | heroBanners: CollectionConnection;
48 | featuredCollections: CollectionConnection;
49 | featuredProducts: ProductConnection;
50 | }>({
51 | query: HOMEPAGE_CONTENT_QUERY,
52 | variables: {
53 | language: languageCode,
54 | country: countryCode,
55 | },
56 | preload: true,
57 | });
58 |
59 | const {heroBanners, featuredCollections, featuredProducts} = data;
60 |
61 | // fill in the hero banners with placeholders if they're missing
62 | const [primaryHero, secondaryHero, tertiaryHero] = getHeroPlaceholder(
63 | heroBanners.nodes,
64 | );
65 |
66 | return (
67 | <>
68 | {primaryHero && (
69 |
70 | )}
71 |
76 | {secondaryHero && }
77 |
81 | {tertiaryHero && }
82 | >
83 | );
84 | }
85 |
86 | function SeoForHomepage() {
87 | const {
88 | data: {
89 | shop: {title, description},
90 | },
91 | } = useShopQuery({
92 | query: HOMEPAGE_SEO_QUERY,
93 | cache: CacheLong(),
94 | preload: true,
95 | });
96 |
97 | return (
98 |
106 | );
107 | }
108 |
109 | const HOMEPAGE_CONTENT_QUERY = gql`
110 | ${MEDIA_FRAGMENT}
111 | ${PRODUCT_CARD_FRAGMENT}
112 | query homepage($country: CountryCode, $language: LanguageCode)
113 | @inContext(country: $country, language: $language) {
114 | heroBanners: collections(
115 | first: 3
116 | query: "collection_type:custom"
117 | sortKey: UPDATED_AT
118 | ) {
119 | nodes {
120 | id
121 | handle
122 | title
123 | descriptionHtml
124 | heading: metafield(namespace: "hero", key: "title") {
125 | value
126 | }
127 | byline: metafield(namespace: "hero", key: "byline") {
128 | value
129 | }
130 | cta: metafield(namespace: "hero", key: "cta") {
131 | value
132 | }
133 | spread: metafield(namespace: "hero", key: "spread") {
134 | reference {
135 | ...Media
136 | }
137 | }
138 | spreadSecondary: metafield(namespace: "hero", key: "spread_secondary") {
139 | reference {
140 | ...Media
141 | }
142 | }
143 | }
144 | }
145 | featuredCollections: collections(
146 | first: 3
147 | query: "collection_type:smart"
148 | sortKey: UPDATED_AT
149 | ) {
150 | nodes {
151 | id
152 | title
153 | handle
154 | image {
155 | altText
156 | width
157 | height
158 | url
159 | }
160 | }
161 | }
162 | featuredProducts: products(first: 12) {
163 | nodes {
164 | ...ProductCard
165 | }
166 | }
167 | }
168 | `;
169 |
170 | const HOMEPAGE_SEO_QUERY = gql`
171 | query homeShopInfo {
172 | shop {
173 | title: name
174 | description
175 | }
176 | }
177 | `;
178 |
--------------------------------------------------------------------------------
/src/routes/journal/[handle].server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | Seo,
5 | gql,
6 | Image,
7 | CacheLong,
8 | type HydrogenRouteProps,
9 | } from '@shopify/hydrogen';
10 | import type {Blog} from '@shopify/hydrogen/storefront-api-types';
11 | import {Suspense} from 'react';
12 |
13 | import {CustomFont, PageHeader, Section} from '~/components';
14 | import {Layout, NotFound} from '~/components/index.server';
15 | import {ATTR_LOADING_EAGER} from '~/lib/const';
16 |
17 | const BLOG_HANDLE = 'journal';
18 |
19 | export default function Post({params, response}: HydrogenRouteProps) {
20 | response.cache(CacheLong());
21 | const {
22 | language: {isoCode: languageCode},
23 | country: {isoCode: countryCode},
24 | } = useLocalization();
25 |
26 | const {handle} = params;
27 | const {data} = useShopQuery<{
28 | blog: Blog;
29 | }>({
30 | query: ARTICLE_QUERY,
31 | variables: {
32 | language: languageCode,
33 | blogHandle: BLOG_HANDLE,
34 | articleHandle: handle,
35 | },
36 | });
37 |
38 | if (!data?.blog?.articleByHandle) {
39 | return ;
40 | }
41 |
42 | const {title, publishedAt, contentHtml, author} = data.blog.articleByHandle;
43 | const formattedDate = new Intl.DateTimeFormat(
44 | `${languageCode}-${countryCode}`,
45 | {
46 | year: 'numeric',
47 | month: 'long',
48 | day: 'numeric',
49 | },
50 | ).format(new Date(publishedAt));
51 |
52 | return (
53 |
54 | {/* Loads Fraunces custom font only on articles */}
55 |
56 |
57 | {/* @ts-expect-error Blog article types are not supported in TS */}
58 |
59 |
60 |
61 |
62 | {formattedDate} · {author.name}
63 |
64 |
65 |
66 | {data.blog.articleByHandle.image && (
67 |
79 | )}
80 |
84 |
85 |
86 | );
87 | }
88 |
89 | const ARTICLE_QUERY = gql`
90 | query ArticleDetails(
91 | $language: LanguageCode
92 | $blogHandle: String!
93 | $articleHandle: String!
94 | ) @inContext(language: $language) {
95 | blog(handle: $blogHandle) {
96 | articleByHandle(handle: $articleHandle) {
97 | title
98 | contentHtml
99 | publishedAt
100 | author: authorV2 {
101 | name
102 | }
103 | image {
104 | id
105 | altText
106 | url
107 | width
108 | height
109 | }
110 | }
111 | }
112 | }
113 | `;
114 |
--------------------------------------------------------------------------------
/src/routes/journal/index.server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CacheLong,
3 | flattenConnection,
4 | gql,
5 | type HydrogenRouteProps,
6 | Seo,
7 | useLocalization,
8 | useShopQuery,
9 | } from '@shopify/hydrogen';
10 | import type {
11 | Article,
12 | Blog as BlogType,
13 | } from '@shopify/hydrogen/storefront-api-types';
14 | import {Suspense} from 'react';
15 |
16 | import {ArticleCard, Grid, PageHeader} from '~/components';
17 | import {Layout} from '~/components/index.server';
18 | import {getImageLoadingPriority, PAGINATION_SIZE} from '~/lib/const';
19 |
20 | const BLOG_HANDLE = 'Journal';
21 |
22 | export default function Blog({
23 | pageBy = PAGINATION_SIZE,
24 | response,
25 | }: HydrogenRouteProps) {
26 | response.cache(CacheLong());
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | function JournalsGrid({pageBy}: {pageBy: number}) {
41 | const {
42 | language: {isoCode: languageCode},
43 | country: {isoCode: countryCode},
44 | } = useLocalization();
45 |
46 | const {data} = useShopQuery<{
47 | blog: BlogType;
48 | }>({
49 | query: BLOG_QUERY,
50 | variables: {
51 | language: languageCode,
52 | blogHandle: BLOG_HANDLE,
53 | pageBy,
54 | },
55 | });
56 |
57 | // TODO: How to fix this type?
58 | const rawArticles = flattenConnection(data.blog.articles);
59 |
60 | const articles = rawArticles.map((article) => {
61 | const {publishedAt} = article;
62 | return {
63 | ...article,
64 | publishedAt: new Intl.DateTimeFormat(`${languageCode}-${countryCode}`, {
65 | year: 'numeric',
66 | month: 'long',
67 | day: 'numeric',
68 | }).format(new Date(publishedAt!)),
69 | };
70 | });
71 |
72 | if (articles.length === 0) {
73 | return No articles found
;
74 | }
75 |
76 | return (
77 |
78 | {articles.map((article, i) => {
79 | return (
80 |
86 | );
87 | })}
88 |
89 | );
90 | }
91 |
92 | const BLOG_QUERY = gql`
93 | query Blog(
94 | $language: LanguageCode
95 | $blogHandle: String!
96 | $pageBy: Int!
97 | $cursor: String
98 | ) @inContext(language: $language) {
99 | blog(handle: $blogHandle) {
100 | articles(first: $pageBy, after: $cursor) {
101 | edges {
102 | node {
103 | author: authorV2 {
104 | name
105 | }
106 | contentHtml
107 | handle
108 | id
109 | image {
110 | id
111 | altText
112 | url
113 | width
114 | height
115 | }
116 | publishedAt
117 | title
118 | }
119 | }
120 | }
121 | }
122 | }
123 | `;
124 |
--------------------------------------------------------------------------------
/src/routes/pages/[handle].server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | Seo,
5 | useServerAnalytics,
6 | ShopifyAnalyticsConstants,
7 | gql,
8 | type HydrogenRouteProps,
9 | } from '@shopify/hydrogen';
10 | import {Suspense} from 'react';
11 |
12 | import {PageHeader} from '~/components';
13 | import {NotFound, Layout} from '~/components/index.server';
14 |
15 | export default function Page({params}: HydrogenRouteProps) {
16 | const {
17 | language: {isoCode: languageCode},
18 | } = useLocalization();
19 |
20 | const {handle} = params;
21 | const {
22 | data: {page},
23 | } = useShopQuery({
24 | query: PAGE_QUERY,
25 | variables: {languageCode, handle},
26 | });
27 |
28 | if (!page) {
29 | return ;
30 | }
31 |
32 | useServerAnalytics({
33 | shopify: {
34 | pageType: ShopifyAnalyticsConstants.pageType.page,
35 | resourceId: page.id,
36 | },
37 | });
38 |
39 | return (
40 |
41 |
42 |
43 |
44 |
45 |
49 |
50 |
51 | );
52 | }
53 |
54 | const PAGE_QUERY = gql`
55 | query PageDetails($languageCode: LanguageCode, $handle: String!)
56 | @inContext(language: $languageCode) {
57 | page(handle: $handle) {
58 | id
59 | title
60 | body
61 | seo {
62 | description
63 | title
64 | }
65 | }
66 | }
67 | `;
68 |
--------------------------------------------------------------------------------
/src/routes/policies/[handle].server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | Seo,
5 | useServerAnalytics,
6 | ShopifyAnalyticsConstants,
7 | gql,
8 | type HydrogenRouteProps,
9 | } from '@shopify/hydrogen';
10 | import {Suspense} from 'react';
11 |
12 | import {Button, PageHeader, Section} from '~/components';
13 | import {NotFound, Layout} from '~/components/index.server';
14 |
15 | export default function Policy({params}: HydrogenRouteProps) {
16 | const {
17 | language: {isoCode: languageCode},
18 | } = useLocalization();
19 | const {handle} = params;
20 |
21 | // standard policy pages
22 | const policy: Record = {
23 | privacyPolicy: handle === 'privacy-policy',
24 | shippingPolicy: handle === 'shipping-policy',
25 | termsOfService: handle === 'terms-of-service',
26 | refundPolicy: handle === 'refund-policy',
27 | };
28 |
29 | // if not a valid policy route, return not found
30 | if (
31 | !policy.privacyPolicy &&
32 | !policy.shippingPolicy &&
33 | !policy.termsOfService &&
34 | !policy.refundPolicy
35 | ) {
36 | return ;
37 | }
38 |
39 | // The currently visited policy page key
40 | const activePolicy = Object.keys(policy).find((key) => policy[key])!;
41 |
42 | const {
43 | data: {shop},
44 | } = useShopQuery({
45 | query: POLICIES_QUERY,
46 | variables: {
47 | languageCode,
48 | ...policy,
49 | },
50 | });
51 |
52 | const page = shop?.[activePolicy];
53 |
54 | // If the policy page is empty, return not found
55 | if (!page) {
56 | return ;
57 | }
58 |
59 | useServerAnalytics({
60 | shopify: {
61 | pageType: ShopifyAnalyticsConstants.pageType.page,
62 | resourceId: page.id,
63 | },
64 | });
65 |
66 | return (
67 |
68 |
69 |
70 |
71 |
76 |
80 |
85 | ← Back to Policies
86 |
87 |
88 |
94 |
95 |
96 | );
97 | }
98 |
99 | const POLICIES_QUERY = gql`
100 | fragment Policy on ShopPolicy {
101 | body
102 | handle
103 | id
104 | title
105 | url
106 | }
107 |
108 | query PoliciesQuery(
109 | $languageCode: LanguageCode
110 | $privacyPolicy: Boolean!
111 | $shippingPolicy: Boolean!
112 | $termsOfService: Boolean!
113 | $refundPolicy: Boolean!
114 | ) @inContext(language: $languageCode) {
115 | shop {
116 | privacyPolicy @include(if: $privacyPolicy) {
117 | ...Policy
118 | }
119 | shippingPolicy @include(if: $shippingPolicy) {
120 | ...Policy
121 | }
122 | termsOfService @include(if: $termsOfService) {
123 | ...Policy
124 | }
125 | refundPolicy @include(if: $refundPolicy) {
126 | ...Policy
127 | }
128 | }
129 | }
130 | `;
131 |
--------------------------------------------------------------------------------
/src/routes/policies/index.server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useLocalization,
3 | useShopQuery,
4 | useServerAnalytics,
5 | ShopifyAnalyticsConstants,
6 | gql,
7 | Link,
8 | } from '@shopify/hydrogen';
9 | import type {Shop} from '@shopify/hydrogen/storefront-api-types';
10 |
11 | import {PageHeader, Section, Heading} from '~/components';
12 | import {Layout, NotFound} from '~/components/index.server';
13 |
14 | export default function Policies() {
15 | const {
16 | language: {isoCode: languageCode},
17 | } = useLocalization();
18 |
19 | const {data} = useShopQuery<{
20 | shop: Shop;
21 | }>({
22 | query: POLICIES_QUERY,
23 | variables: {
24 | languageCode,
25 | },
26 | });
27 |
28 | useServerAnalytics({
29 | shopify: {
30 | pageType: ShopifyAnalyticsConstants.pageType.page,
31 | },
32 | });
33 |
34 | const {
35 | privacyPolicy,
36 | shippingPolicy,
37 | termsOfService,
38 | refundPolicy,
39 | subscriptionPolicy,
40 | } = data.shop;
41 |
42 | const policies = [
43 | privacyPolicy,
44 | shippingPolicy,
45 | termsOfService,
46 | refundPolicy,
47 | subscriptionPolicy,
48 | ];
49 |
50 | if (policies.every((element) => element === null)) {
51 | return ;
52 | }
53 |
54 | return (
55 |
56 |
57 |
58 | {policies.map((policy) => {
59 | if (!policy) {
60 | return;
61 | }
62 | return (
63 |
64 | {policy.title}
65 |
66 | );
67 | })}
68 |
69 |
70 | );
71 | }
72 |
73 | const POLICIES_QUERY = gql`
74 | fragment Policy on ShopPolicy {
75 | id
76 | title
77 | handle
78 | }
79 |
80 | query PoliciesQuery {
81 | shop {
82 | privacyPolicy {
83 | ...Policy
84 | }
85 | shippingPolicy {
86 | ...Policy
87 | }
88 | termsOfService {
89 | ...Policy
90 | }
91 | refundPolicy {
92 | ...Policy
93 | }
94 | subscriptionPolicy {
95 | id
96 | title
97 | handle
98 | }
99 | }
100 | }
101 | `;
102 |
--------------------------------------------------------------------------------
/src/routes/products/[handle].server.tsx:
--------------------------------------------------------------------------------
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.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react';
2 | import {
3 | useShopQuery,
4 | gql,
5 | useLocalization,
6 | type HydrogenRequest,
7 | type HydrogenApiRouteOptions,
8 | Seo,
9 | } from '@shopify/hydrogen';
10 |
11 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
12 | import {PAGINATION_SIZE} from '~/lib/const';
13 | import {ProductGrid, PageHeader, Section} from '~/components';
14 | import {Layout} from '~/components/index.server';
15 | import type {Collection} from '@shopify/hydrogen/storefront-api-types';
16 |
17 | export default function AllProducts() {
18 | return (
19 |
20 |
21 |
22 |
27 |
28 | );
29 | }
30 |
31 | function AllProductsGrid() {
32 | const {
33 | language: {isoCode: languageCode},
34 | country: {isoCode: countryCode},
35 | } = useLocalization();
36 |
37 | const {data} = useShopQuery({
38 | query: ALL_PRODUCTS_QUERY,
39 | variables: {
40 | country: countryCode,
41 | language: languageCode,
42 | pageBy: PAGINATION_SIZE,
43 | },
44 | preload: true,
45 | });
46 |
47 | const products = data.products;
48 |
49 | return (
50 |
55 | );
56 | }
57 |
58 | // API to paginate products
59 | // @see templates/demo-store/src/components/product/ProductGrid.client.tsx
60 | export async function api(
61 | request: HydrogenRequest,
62 | {params, queryShop}: HydrogenApiRouteOptions,
63 | ) {
64 | if (request.method !== 'POST') {
65 | return new Response('Method not allowed', {
66 | status: 405,
67 | headers: {Allow: 'POST'},
68 | });
69 | }
70 |
71 | const url = new URL(request.url);
72 | const cursor = url.searchParams.get('cursor');
73 | const country = url.searchParams.get('country');
74 | const {handle} = params;
75 |
76 | return await queryShop({
77 | query: PAGINATE_ALL_PRODUCTS_QUERY,
78 | variables: {
79 | handle,
80 | cursor,
81 | pageBy: PAGINATION_SIZE,
82 | country,
83 | },
84 | });
85 | }
86 |
87 | const ALL_PRODUCTS_QUERY = gql`
88 | ${PRODUCT_CARD_FRAGMENT}
89 | query AllProducts(
90 | $country: CountryCode
91 | $language: LanguageCode
92 | $pageBy: Int!
93 | $cursor: String
94 | ) @inContext(country: $country, language: $language) {
95 | products(first: $pageBy, after: $cursor) {
96 | nodes {
97 | ...ProductCard
98 | }
99 | pageInfo {
100 | hasNextPage
101 | startCursor
102 | endCursor
103 | }
104 | }
105 | }
106 | `;
107 |
108 | const PAGINATE_ALL_PRODUCTS_QUERY = gql`
109 | ${PRODUCT_CARD_FRAGMENT}
110 | query ProductsPage(
111 | $pageBy: Int!
112 | $cursor: String
113 | $country: CountryCode
114 | $language: LanguageCode
115 | ) @inContext(country: $country, language: $language) {
116 | products(first: $pageBy, after: $cursor) {
117 | nodes {
118 | ...ProductCard
119 | }
120 | pageInfo {
121 | hasNextPage
122 | endCursor
123 | }
124 | }
125 | }
126 | `;
127 |
--------------------------------------------------------------------------------
/src/routes/robots.txt.server.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This API endpoint generates a robots.txt file. Use this to control
3 | * access to your resources from SEO crawlers.
4 | * Learn more: https://developers.google.com/search/docs/advanced/robots/create-robots-txt
5 | */
6 |
7 | import type {HydrogenRequest} from '@shopify/hydrogen';
8 |
9 | export async function api(request: HydrogenRequest) {
10 | const url = new URL(request.url);
11 |
12 | return new Response(robotsTxtData({url: url.origin}), {
13 | headers: {
14 | 'content-type': 'text/plain',
15 | // Cache for 24 hours
16 | 'cache-control': `max-age=${60 * 60 * 24}`,
17 | },
18 | });
19 | }
20 |
21 | function robotsTxtData({url}: {url: string}) {
22 | const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
23 |
24 | return `
25 | User-agent: *
26 | Disallow: /admin
27 | Disallow: /cart
28 | Disallow: /orders
29 | Disallow: /checkouts/
30 | Disallow: /checkout
31 | Disallow: /carts
32 | Disallow: /account
33 | ${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}
34 |
35 | # Google adsbot ignores robots.txt unless specifically named!
36 | User-agent: adsbot-google
37 | Disallow: /checkouts/
38 | Disallow: /checkout
39 | Disallow: /carts
40 | Disallow: /orders
41 |
42 | User-agent: Pinterest
43 | Crawl-delay: 1
44 | `.trim();
45 | }
46 |
--------------------------------------------------------------------------------
/src/routes/search.server.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | gql,
3 | HydrogenRouteProps,
4 | type HydrogenApiRouteOptions,
5 | type HydrogenRequest,
6 | useLocalization,
7 | useShopQuery,
8 | useUrl,
9 | } from '@shopify/hydrogen';
10 |
11 | import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
12 | import {ProductGrid, Section, Text} from '~/components';
13 | import {NoResultRecommendations, SearchPage} from '~/components/index.server';
14 | import {PAGINATION_SIZE} from '~/lib/const';
15 | import type {Collection} from '@shopify/hydrogen/storefront-api-types';
16 | import {Suspense} from 'react';
17 |
18 | export default function Search({
19 | pageBy = PAGINATION_SIZE,
20 | params,
21 | }: {
22 | pageBy?: number;
23 | params: HydrogenRouteProps['params'];
24 | }) {
25 | const {
26 | language: {isoCode: languageCode},
27 | country: {isoCode: countryCode},
28 | } = useLocalization();
29 |
30 | const {handle} = params;
31 | const {searchParams} = useUrl();
32 |
33 | const searchTerm = searchParams.get('q');
34 |
35 | const {data} = useShopQuery({
36 | query: SEARCH_QUERY,
37 | variables: {
38 | handle,
39 | country: countryCode,
40 | language: languageCode,
41 | pageBy,
42 | searchTerm,
43 | },
44 | preload: true,
45 | });
46 |
47 | const products = data?.products;
48 | const noResults = products?.nodes?.length === 0;
49 |
50 | if (!searchTerm || noResults) {
51 | return (
52 |
53 | {noResults && (
54 |
55 | No results, try something else.
56 |
57 | )}
58 |
59 |
63 |
64 |
65 | );
66 | }
67 |
68 | return (
69 |
70 |
77 |
78 | );
79 | }
80 |
81 | // API to paginate the results of the search query.
82 | // @see templates/demo-store/src/components/product/ProductGrid.client.tsx
83 | export async function api(
84 | request: HydrogenRequest,
85 | {params, queryShop}: HydrogenApiRouteOptions,
86 | ) {
87 | if (request.method !== 'POST') {
88 | return new Response('Method not allowed', {
89 | status: 405,
90 | headers: {Allow: 'POST'},
91 | });
92 | }
93 |
94 | const url = new URL(request.url);
95 | const cursor = url.searchParams.get('cursor');
96 | const country = url.searchParams.get('country');
97 | const searchTerm = url.searchParams.get('q');
98 | const {handle} = params;
99 |
100 | return await queryShop({
101 | query: PAGINATE_SEARCH_QUERY,
102 | variables: {
103 | handle,
104 | cursor,
105 | pageBy: PAGINATION_SIZE,
106 | country,
107 | searchTerm,
108 | },
109 | });
110 | }
111 |
112 | const SEARCH_QUERY = gql`
113 | ${PRODUCT_CARD_FRAGMENT}
114 | query search(
115 | $searchTerm: String
116 | $country: CountryCode
117 | $language: LanguageCode
118 | $pageBy: Int!
119 | $after: String
120 | ) @inContext(country: $country, language: $language) {
121 | products(
122 | first: $pageBy
123 | sortKey: RELEVANCE
124 | query: $searchTerm
125 | after: $after
126 | ) {
127 | nodes {
128 | ...ProductCard
129 | }
130 | pageInfo {
131 | startCursor
132 | endCursor
133 | hasNextPage
134 | hasPreviousPage
135 | }
136 | }
137 | }
138 | `;
139 |
140 | const PAGINATE_SEARCH_QUERY = gql`
141 | ${PRODUCT_CARD_FRAGMENT}
142 | query ProductsPage(
143 | $searchTerm: String
144 | $pageBy: Int!
145 | $cursor: String
146 | $country: CountryCode
147 | $language: LanguageCode
148 | ) @inContext(country: $country, language: $language) {
149 | products(
150 | sortKey: RELEVANCE
151 | query: $searchTerm
152 | first: $pageBy
153 | after: $cursor
154 | ) {
155 | nodes {
156 | ...ProductCard
157 | }
158 | pageInfo {
159 | hasNextPage
160 | endCursor
161 | }
162 | }
163 | }
164 | `;
165 |
--------------------------------------------------------------------------------
/src/routes/sitemap.xml.server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | flattenConnection,
3 | gql,
4 | type HydrogenApiRouteOptions,
5 | type HydrogenRequest,
6 | } from '@shopify/hydrogen';
7 | import type {
8 | CollectionConnection,
9 | PageConnection,
10 | ProductConnection,
11 | } from '@shopify/hydrogen/storefront-api-types';
12 |
13 | const MAX_URLS = 250; // the google limit is 50K, however, SF API only allow querying for 250 resources each time
14 |
15 | interface SitemapQueryData {
16 | products: ProductConnection;
17 | collections: CollectionConnection;
18 | pages: PageConnection;
19 | }
20 |
21 | export async function api(
22 | request: HydrogenRequest,
23 | {queryShop}: HydrogenApiRouteOptions,
24 | ) {
25 | const {data} = await queryShop({
26 | query: QUERY,
27 | variables: {
28 | language: 'EN',
29 | urlLimits: MAX_URLS,
30 | },
31 | });
32 |
33 | return new Response(shopSitemap(data, new URL(request.url).origin), {
34 | headers: {
35 | 'content-type': 'application/xml',
36 | // Cache for 24 hours
37 | 'cache-control': `max-age=${60 * 60 * 24}`,
38 | },
39 | });
40 | }
41 |
42 | interface ProductEntry {
43 | url: string;
44 | lastMod: string;
45 | changeFreq: string;
46 | image?: {
47 | url: string;
48 | title?: string;
49 | caption?: string;
50 | };
51 | }
52 |
53 | function shopSitemap(data: SitemapQueryData, baseUrl: string) {
54 | const productsData = flattenConnection(data.products)
55 | .filter((product) => product.onlineStoreUrl)
56 | .map((product) => {
57 | const url = `${baseUrl}/products/${product.handle}`;
58 |
59 | const finalObject: ProductEntry = {
60 | url,
61 | lastMod: product.updatedAt!,
62 | changeFreq: 'daily',
63 | };
64 |
65 | if (product.featuredImage!.url) {
66 | finalObject.image = {
67 | url: product.featuredImage!.url,
68 | };
69 |
70 | if (product.title) {
71 | finalObject.image.title = product.title;
72 | }
73 |
74 | if (product.featuredImage!.altText) {
75 | finalObject.image.caption = product.featuredImage!.altText;
76 | }
77 |
78 | return finalObject;
79 | }
80 | });
81 |
82 | const collectionsData = flattenConnection(data.collections)
83 | .filter((collection) => collection.onlineStoreUrl)
84 | .map((collection) => {
85 | const url = `${baseUrl}/collections/${collection.handle}`;
86 |
87 | return {
88 | url,
89 | lastMod: collection.updatedAt,
90 | changeFreq: 'daily',
91 | };
92 | });
93 |
94 | const pagesData = flattenConnection(data.pages)
95 | .filter((page) => page.onlineStoreUrl)
96 | .map((page) => {
97 | const url = `${baseUrl}/pages/${page.handle}`;
98 |
99 | return {
100 | url,
101 | lastMod: page.updatedAt,
102 | changeFreq: 'weekly',
103 | };
104 | });
105 |
106 | const urlsDatas = [...productsData, ...collectionsData, ...pagesData];
107 |
108 | return `
109 |
113 | ${urlsDatas.map((url) => renderUrlTag(url!)).join('')}
114 | `;
115 | }
116 |
117 | function renderUrlTag({
118 | url,
119 | lastMod,
120 | changeFreq,
121 | image,
122 | }: {
123 | url: string;
124 | lastMod?: string;
125 | changeFreq?: string;
126 | image?: {
127 | url: string;
128 | title?: string;
129 | caption?: string;
130 | };
131 | }) {
132 | return `
133 |
134 | ${url}
135 | ${lastMod}
136 | ${changeFreq}
137 | ${
138 | image
139 | ? `
140 |
141 | ${image.url}
142 | ${image.title ?? ''}
143 | ${image.caption ?? ''}
144 | `
145 | : ''
146 | }
147 |
148 |
149 | `;
150 | }
151 |
152 | const QUERY = gql`
153 | query sitemaps($urlLimits: Int, $language: LanguageCode)
154 | @inContext(language: $language) {
155 | products(
156 | first: $urlLimits
157 | query: "published_status:'online_store:visible'"
158 | ) {
159 | edges {
160 | node {
161 | updatedAt
162 | handle
163 | onlineStoreUrl
164 | title
165 | featuredImage {
166 | url
167 | altText
168 | }
169 | }
170 | }
171 | }
172 | collections(
173 | first: $urlLimits
174 | query: "published_status:'online_store:visible'"
175 | ) {
176 | edges {
177 | node {
178 | updatedAt
179 | handle
180 | onlineStoreUrl
181 | }
182 | }
183 | }
184 | pages(first: $urlLimits, query: "published_status:'published'") {
185 | edges {
186 | node {
187 | updatedAt
188 | handle
189 | onlineStoreUrl
190 | }
191 | }
192 | }
193 | }
194 | `;
195 |
--------------------------------------------------------------------------------
/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 |
20 | @media (min-width: 32em) {
21 | --height-nav: 4rem;
22 | }
23 | @media (min-width: 48em) {
24 | --height-nav: 6rem;
25 | --font-size-heading: 2.25rem; /* text-4xl */
26 | --font-size-display: 3.75rem; /* text-6xl */
27 | }
28 | @supports (height: 100lvh) {
29 | --screen-height: 100lvh;
30 | }
31 | }
32 |
33 | @media (prefers-color-scheme: dark) {
34 | :root {
35 | --color-primary: 250 250 250;
36 | --color-contrast: 32 33 36;
37 | --color-accent: 235 86 40;
38 | }
39 | }
40 |
41 | @keyframes fadeInAnimation {
42 | 0% {
43 | opacity: 0;
44 | }
45 | 100% {
46 | opacity: 1;
47 | }
48 | }
49 |
50 | shop-pay-button {
51 | width: 100%;
52 | height: 3rem;
53 | display: table;
54 | }
55 |
56 | @layer base {
57 | * {
58 | font-variant-ligatures: none;
59 | }
60 |
61 | body {
62 | @apply antialiased text-primary/90 bg-contrast border-primary/10;
63 | }
64 |
65 | html {
66 | scroll-padding-top: 10rem;
67 | }
68 |
69 | model-viewer::part(default-progress-mask) {
70 | display: none;
71 | }
72 |
73 | model-viewer::part(default-progress-bar) {
74 | display: none;
75 | }
76 |
77 | input[type='search']::-webkit-search-decoration,
78 | input[type='search']::-webkit-search-cancel-button,
79 | input[type='search']::-webkit-search-results-button,
80 | input[type='search']::-webkit-search-results-decoration {
81 | -webkit-appearance: none;
82 | }
83 |
84 | .prose {
85 | h1,
86 | h2,
87 | h3,
88 | h4,
89 | h5,
90 | h6 {
91 | &:first-child {
92 | @apply mt-0;
93 | }
94 | }
95 | }
96 | }
97 |
98 | @layer components {
99 | .article {
100 | h2,
101 | h3,
102 | h4,
103 | h5,
104 | h6 {
105 | @apply font-sans text-primary;
106 | }
107 | @apply mb-12 font-serif prose mx-auto grid justify-center text-primary;
108 | p,
109 | ul,
110 | li {
111 | @apply mb-4 text-lg;
112 | }
113 | img {
114 | @apply md:-mx-8 lg:-mx-16;
115 |
116 | @media (min-width: 48em) {
117 | width: calc(100% + 4rem);
118 | max-width: 100vw;
119 | }
120 | @media (min-width: 64em) {
121 | width: calc(100% + 8rem);
122 | }
123 | }
124 | }
125 |
126 | .swimlane {
127 | @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;
128 | }
129 | }
130 |
131 | @layer utilities {
132 | .fadeIn {
133 | opacity: 0;
134 | animation: fadeInAnimation ease 500ms forwards;
135 | }
136 |
137 | .hiddenScroll {
138 | scrollbar-width: none;
139 | &::-webkit-scrollbar {
140 | display: none;
141 | }
142 | }
143 |
144 | .absolute-center {
145 | @apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
146 | }
147 |
148 | .strike {
149 | position: relative;
150 | &::before {
151 | content: '';
152 | display: block;
153 | position: absolute;
154 | width: 108%;
155 | height: 1.5px;
156 | left: -4%;
157 | top: 50%;
158 | transform: translateY(-50%);
159 | background: rgb(var(--color-primary));
160 | box-shadow: 0.5px 0.5px 0px 0.5px rgb(var(--color-contrast));
161 | }
162 | }
163 |
164 | .card-image {
165 | @apply relative rounded overflow-clip flex justify-center items-center;
166 | &::before {
167 | content: ' ';
168 | @apply z-10 absolute block top-0 left-0 w-full h-full shadow-border rounded;
169 | }
170 | img {
171 | @apply object-cover w-full aspect-[inherit];
172 | }
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/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 | },
45 | width: {
46 | mobileGallery: 'calc(100vw - 3rem)',
47 | },
48 | fontFamily: {
49 | sans: ['Helvetica Neue', 'ui-sans-serif', 'system-ui', 'sans-serif'],
50 | serif: ['"IBMPlexSerif"', 'Palatino', 'ui-serif'],
51 | },
52 | fontSize: {
53 | display: ['var(--font-size-display)', '1.1'],
54 | heading: ['var(--font-size-heading)', '1.25'],
55 | lead: ['var(--font-size-lead)', '1.333'],
56 | copy: ['var(--font-size-copy)', '1.5'],
57 | fine: ['var(--font-size-fine)', '1.333'],
58 | },
59 | maxWidth: {
60 | 'prose-narrow': '45ch',
61 | 'prose-wide': '80ch',
62 | },
63 | boxShadow: {
64 | border: 'inset 0px 0px 0px 1px rgb(var(--color-primary) / 0.08)',
65 | darkHeader: 'inset 0px -1px 0px 0px rgba(21, 21, 21, 0.4)',
66 | lightHeader: 'inset 0px -1px 0px 0px rgba(21, 21, 21, 0.05)',
67 | },
68 | },
69 | },
70 | // eslint-disable-next-line node/no-unpublished-require
71 | plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
72 | };
73 |
--------------------------------------------------------------------------------
/tests/e2e/collection.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | startHydrogenServer,
3 | type HydrogenServer,
4 | type HydrogenSession,
5 | } from '../utils';
6 | import Collections from '../../src/routes/collections/[handle].server';
7 |
8 | describe('collections', () => {
9 | let hydrogen: HydrogenServer;
10 | let session: HydrogenSession;
11 | let collectionUrl: string;
12 |
13 | beforeAll(async () => {
14 | hydrogen = await startHydrogenServer();
15 | hydrogen.watchForUpdates(Collections);
16 |
17 | // Find a collection url from home page
18 | session = await hydrogen.newPage();
19 | await session.visit('/');
20 | const link = await session.page.locator('a[href^="/collections/"]').first();
21 | collectionUrl = await link.getAttribute('href');
22 | });
23 |
24 | beforeEach(async () => {
25 | session = await hydrogen.newPage();
26 | });
27 |
28 | afterAll(async () => {
29 | await hydrogen.cleanUp();
30 | });
31 |
32 | it('should have collection title', async () => {
33 | await session.visit(collectionUrl);
34 |
35 | const heading = await session.page.locator('h1').first();
36 | expect(await heading.textContent()).not.toBeNull();
37 | });
38 |
39 | it('should have collection product tiles', async () => {
40 | await session.visit(collectionUrl);
41 |
42 | const products = await session.page.locator('#mainContent section a');
43 | expect(await products.count()).not.toEqual(0);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/tests/e2e/index.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | startHydrogenServer,
3 | type HydrogenServer,
4 | type HydrogenSession,
5 | } from '../utils';
6 | import Index from '../../src/routes/index.server';
7 |
8 | describe('index', () => {
9 | let hydrogen: HydrogenServer;
10 | let session: HydrogenSession;
11 |
12 | beforeAll(async () => {
13 | hydrogen = await startHydrogenServer();
14 | hydrogen.watchForUpdates(Index);
15 | });
16 |
17 | beforeEach(async () => {
18 | session = await hydrogen.newPage();
19 | });
20 |
21 | afterAll(async () => {
22 | await hydrogen.cleanUp();
23 | });
24 |
25 | it('should be a 200 response', async () => {
26 | const response = await session.visit('/');
27 | expect(response!.status()).toBe(200);
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/tests/e2e/product.test.ts:
--------------------------------------------------------------------------------
1 | import {type Response as PlaywrightResponse} from 'playwright';
2 | import {
3 | startHydrogenServer,
4 | type HydrogenServer,
5 | type HydrogenSession,
6 | } from '../utils';
7 | import Product from '../../src/routes/products/[handle].server';
8 |
9 | describe('products', () => {
10 | let hydrogen: HydrogenServer;
11 | let session: HydrogenSession;
12 | let productUrl: string;
13 |
14 | beforeAll(async () => {
15 | hydrogen = await startHydrogenServer();
16 | hydrogen.watchForUpdates(Product);
17 |
18 | // Find a product url from home page
19 | session = await hydrogen.newPage();
20 | await session.visit('/');
21 | const link = await session.page.locator('a[href^="/products/"]').first();
22 | productUrl = await link.getAttribute('href');
23 | });
24 |
25 | beforeEach(async () => {
26 | session = await hydrogen.newPage();
27 | });
28 |
29 | afterAll(async () => {
30 | await hydrogen.cleanUp();
31 | });
32 |
33 | it('should have product title', async () => {
34 | await session.visit(productUrl);
35 | const heading = await session.page.locator('h1').first();
36 | expect(await heading.textContent()).not.toBeNull();
37 | });
38 |
39 | it('can be added to cart', async () => {
40 | // Make sure cart script loads
41 | await Promise.all([
42 | session.page.waitForResponse(
43 | 'https://cdn.shopify.com/shopifycloud/shop-js/v1.0/client.js',
44 | ),
45 | session.visit(productUrl),
46 | ]);
47 |
48 | const addToCartButton = await session.page.locator('text=Add to bag');
49 |
50 | // Click on add to cart button
51 | const [cartResponse] = await Promise.all([
52 | session.page.waitForResponse((response: PlaywrightResponse) =>
53 | /graphql\.json/.test(response.url()),
54 | ),
55 | addToCartButton.click(),
56 | ]);
57 |
58 | const cartEvent = await cartResponse.json();
59 | expect(cartEvent.data).not.toBeNull();
60 | }, 60000);
61 | });
62 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | chromium,
3 | type Page,
4 | type Response as PlaywrightResponse,
5 | } from 'playwright';
6 | import '@shopify/hydrogen/web-polyfills';
7 | import type {Server} from 'http';
8 | import {createServer as createViteDevServer} from 'vite';
9 |
10 | export interface HydrogenSession {
11 | page: Page;
12 | visit: (pathname: string) => Promise;
13 | }
14 |
15 | export interface HydrogenServer {
16 | url: (pathname: string) => string;
17 | newPage: () => Promise;
18 | cleanUp: () => Promise;
19 | watchForUpdates: (_module: any) => void;
20 | }
21 |
22 | export async function startHydrogenServer(): Promise {
23 | const app = import.meta.env.WATCH
24 | ? await createDevServer()
25 | : await createNodeServer();
26 |
27 | const browser = await chromium.launch();
28 | const url = (pathname: string) => `http://localhost:${app.port}${pathname}`;
29 |
30 | const newPage = async () => {
31 | const page = await browser.newPage();
32 | return {
33 | page,
34 | visit: async (pathname: string) => page.goto(url(pathname)),
35 | };
36 | };
37 |
38 | const cleanUp = async () => {
39 | await browser.close();
40 | await app.server?.close();
41 | };
42 |
43 | return {url, newPage, cleanUp, watchForUpdates: () => {}};
44 | }
45 |
46 | async function createNodeServer() {
47 | // @ts-ignore
48 | const {createServer} = await import('../dist/node');
49 | const app = (await createServer()).app;
50 | const server = app.listen(0) as Server;
51 | const port: number = await new Promise((resolve) => {
52 | server.on('listening', () => {
53 | resolve(getPortFromAddress(server.address()));
54 | });
55 | });
56 |
57 | return {server, port};
58 | }
59 |
60 | async function createDevServer() {
61 | const app = await createViteDevServer({
62 | server: {force: true},
63 | logLevel: 'silent',
64 | });
65 | const server = await app.listen(0);
66 |
67 | return {
68 | server: server.httpServer,
69 | port: getPortFromAddress(server.httpServer!.address()),
70 | };
71 | }
72 |
73 | function getPortFromAddress(address: string | any): number {
74 | if (typeof address === 'string') {
75 | return parseInt(address.split(':').pop()!);
76 | } else {
77 | return address.port;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/tsconfig.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 | "strict": true,
10 | "esModuleInterop": true,
11 | "isolatedModules": true,
12 | "resolveJsonModule": true,
13 | "skipLibCheck": true,
14 | "paths": {
15 | "~/*": ["./src/*"]
16 | }
17 | },
18 | "exclude": ["node_modules", "dist"],
19 | "include": ["**/*.ts", "**/*.tsx"]
20 | }
21 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import {defineConfig} from 'vite';
3 | import hydrogen from '@shopify/hydrogen/plugin';
4 |
5 | export default defineConfig({
6 | plugins: [hydrogen()],
7 | resolve: {
8 | alias: [{find: /^~\/(.*)/, replacement: '/src/$1'}],
9 | },
10 | optimizeDeps: {
11 | include: ['@headlessui/react', 'clsx', 'react-use', 'typographic-base'],
12 | },
13 | test: {
14 | globals: true,
15 | testTimeout: 10000,
16 | hookTimeout: 10000,
17 | },
18 | });
19 |
--------------------------------------------------------------------------------