AbdulElahGwaith commited on
Commit
5ee3099
·
verified ·
1 Parent(s): 5a291b3

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .eslintignore +5 -0
  2. .eslintrc.cjs +16 -0
  3. .gitattributes +2 -0
  4. .github/CODEOWNERS +1 -0
  5. .gitignore +12 -0
  6. .graphqlrc.js +28 -0
  7. CHANGELOG.md +143 -0
  8. README.md +60 -0
  9. app/assets/favicon.svg +28 -0
  10. app/assets/hero-banner.jpg +3 -0
  11. app/assets/product-placeholder.jpg +3 -0
  12. app/components/AddToCartButton.tsx +37 -0
  13. app/components/Aside.tsx +76 -0
  14. app/components/CartLineItem.tsx +153 -0
  15. app/components/CartMain.tsx +68 -0
  16. app/components/CartSummary.tsx +101 -0
  17. app/components/Footer.tsx +129 -0
  18. app/components/Header.tsx +224 -0
  19. app/components/PageLayout.tsx +172 -0
  20. app/components/PaginatedResourceSection.tsx +42 -0
  21. app/components/ProductForm.tsx +80 -0
  22. app/components/ProductImage.tsx +23 -0
  23. app/components/ProductPrice.tsx +27 -0
  24. app/components/SearchForm.tsx +68 -0
  25. app/components/SearchFormPredictive.tsx +76 -0
  26. app/components/SearchResults.tsx +164 -0
  27. app/components/SearchResultsPredictive.tsx +322 -0
  28. app/entry.client.tsx +14 -0
  29. app/entry.server.tsx +2 -0
  30. app/graphql/customer-account/CustomerAddressMutations.ts +61 -0
  31. app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
  32. app/graphql/customer-account/CustomerOrderQuery.ts +87 -0
  33. app/graphql/customer-account/CustomerOrdersQuery.ts +58 -0
  34. app/graphql/customer-account/CustomerUpdateMutation.ts +24 -0
  35. app/lib/context.ts +46 -0
  36. app/lib/fragments.ts +227 -0
  37. app/lib/search.ts +79 -0
  38. app/lib/session.ts +72 -0
  39. app/lib/variants.ts +46 -0
  40. app/root.tsx +188 -0
  41. app/routes/$.tsx +11 -0
  42. app/routes/[robots.txt].tsx +118 -0
  43. app/routes/[sitemap.xml].tsx +177 -0
  44. app/routes/_index.tsx +233 -0
  45. app/routes/account.$.tsx +8 -0
  46. app/routes/account._index.tsx +5 -0
  47. app/routes/account.addresses.tsx +512 -0
  48. app/routes/account.orders.$id.tsx +195 -0
  49. app/routes/account.orders._index.tsx +93 -0
  50. app/routes/account.profile.tsx +136 -0
.eslintignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ build
2
+ node_modules
3
+ bin
4
+ *.d.ts
5
+ dist
.eslintrc.cjs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @type {import("@types/eslint").Linter.BaseConfig}
3
+ */
4
+ module.exports = {
5
+ extends: [
6
+ '@remix-run/eslint-config',
7
+ 'plugin:hydrogen/recommended',
8
+ 'plugin:hydrogen/typescript',
9
+ ],
10
+ rules: {
11
+ '@typescript-eslint/ban-ts-comment': 'off',
12
+ '@typescript-eslint/naming-convention': 'off',
13
+ 'hydrogen/prefer-image-component': 'off',
14
+ 'no-case-declarations': 'off',
15
+ },
16
+ };
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ app/assets/hero-banner.jpg filter=lfs diff=lfs merge=lfs -text
37
+ app/assets/product-placeholder.jpg filter=lfs diff=lfs merge=lfs -text
.github/CODEOWNERS ADDED
@@ -0,0 +1 @@
 
 
1
+ * @netlify/ecosystem-pod-frameworks
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ /.cache
3
+ /build
4
+ /dist
5
+ /public/build
6
+ /.mf
7
+ .env
8
+ .env.*
9
+ .shopify
10
+
11
+ # Local Netlify folder
12
+ .netlify
.graphqlrc.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getSchema } from '@shopify/hydrogen-codegen';
2
+
3
+ /**
4
+ * GraphQL Config
5
+ * @see https://the-guild.dev/graphql/config/docs/user/usage
6
+ * @type {IGraphQLConfig}
7
+ */
8
+ export default {
9
+ projects: {
10
+ default: {
11
+ schema: getSchema('storefront'),
12
+ documents: [
13
+ './*.{ts,tsx,js,jsx}',
14
+ './app/**/*.{ts,tsx,js,jsx}',
15
+ '!./app/graphql/**/*.{ts,tsx,js,jsx}',
16
+ ],
17
+ },
18
+
19
+ customer: {
20
+ schema: getSchema('customer-account'),
21
+ documents: ['./app/graphql/customer-account/*.{ts,tsx,js,jsx}'],
22
+ },
23
+
24
+ // Add your own GraphQL projects here for CMS, Shopify Admin API, etc.
25
+ },
26
+ };
27
+
28
+ /** @typedef {import('graphql-config').IGraphQLConfig} IGraphQLConfig */
CHANGELOG.md ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # skeleton
2
+
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - The Storefront API 2023-10 now returns menu item URLs that include the `primaryDomainUrl`, instead of defaulting to the Shopify store ID URL (example.myshopify.com). The skeleton template requires changes to check for the `primaryDomainUrl`: by [@blittle](https://github.com/blittle)
8
+
9
+ 1. Update the `HeaderMenu` component to accept a `primaryDomainUrl` and include
10
+ it in the internal url check
11
+
12
+ ```diff
13
+ // app/components/Header.tsx
14
+
15
+ + import type {HeaderQuery} from 'storefrontapi.generated';
16
+
17
+ export function HeaderMenu({
18
+ menu,
19
+ + primaryDomainUrl,
20
+ viewport,
21
+ }: {
22
+ menu: HeaderProps['header']['menu'];
23
+ + primaryDomainUrl: HeaderQuery['shop']['primaryDomain']['url'];
24
+ viewport: Viewport;
25
+ }) {
26
+
27
+ // ...code
28
+
29
+ // if the url is internal, we strip the domain
30
+ const url =
31
+ item.url.includes('myshopify.com') ||
32
+ item.url.includes(publicStoreDomain) ||
33
+ + item.url.includes(primaryDomainUrl)
34
+ ? new URL(item.url).pathname
35
+ : item.url;
36
+
37
+ // ...code
38
+
39
+ }
40
+ ```
41
+
42
+ 2. Update the `FooterMenu` component to accept a `primaryDomainUrl` prop and include
43
+ it in the internal url check
44
+
45
+ ```diff
46
+ // app/components/Footer.tsx
47
+
48
+ - import type {FooterQuery} from 'storefrontapi.generated';
49
+ + import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
50
+
51
+ function FooterMenu({
52
+ menu,
53
+ + primaryDomainUrl,
54
+ }: {
55
+ menu: FooterQuery['menu'];
56
+ + primaryDomainUrl: HeaderQuery['shop']['primaryDomain']['url'];
57
+ }) {
58
+ // code...
59
+
60
+ // if the url is internal, we strip the domain
61
+ const url =
62
+ item.url.includes('myshopify.com') ||
63
+ item.url.includes(publicStoreDomain) ||
64
+ + item.url.includes(primaryDomainUrl)
65
+ ? new URL(item.url).pathname
66
+ : item.url;
67
+
68
+ // ...code
69
+
70
+ );
71
+ }
72
+ ```
73
+
74
+ 3. Update the `Footer` component to accept a `shop` prop
75
+
76
+ ```diff
77
+ export function Footer({
78
+ menu,
79
+ + shop,
80
+ }: FooterQuery & {shop: HeaderQuery['shop']}) {
81
+ return (
82
+ <footer className="footer">
83
+ - <FooterMenu menu={menu} />
84
+ + <FooterMenu menu={menu} primaryDomainUrl={shop.primaryDomain.url} />
85
+ </footer>
86
+ );
87
+ }
88
+ ```
89
+
90
+ 4. Update `Layout.tsx` to pass the `shop` prop
91
+
92
+ ```diff
93
+ export function Layout({
94
+ cart,
95
+ children = null,
96
+ footer,
97
+ header,
98
+ isLoggedIn,
99
+ }: LayoutProps) {
100
+ return (
101
+ <>
102
+ <CartAside cart={cart} />
103
+ <SearchAside />
104
+ <MobileMenuAside menu={header.menu} shop={header.shop} />
105
+ <Header header={header} cart={cart} isLoggedIn={isLoggedIn} />
106
+ <main>{children}</main>
107
+ <Suspense>
108
+ <Await resolve={footer}>
109
+ - {(footer) => <Footer menu={footer.menu} />}
110
+ + {(footer) => <Footer menu={footer.menu} shop={header.shop} />}
111
+ </Await>
112
+ </Suspense>
113
+ </>
114
+ );
115
+ }
116
+ ```
117
+
118
+ ### Patch Changes
119
+
120
+ - If you are calling `useMatches()` in different places of your app to access the data returned by the root loader, you may want to update it to the following pattern to enhance types: ([#1289](https://github.com/Shopify/hydrogen/pull/1289)) by [@frandiox](https://github.com/frandiox)
121
+
122
+ ```ts
123
+ // root.tsx
124
+
125
+ import {useMatches} from '@remix-run/react';
126
+ import {type SerializeFrom} from '@netlify/remix-runtime';
127
+
128
+ export const useRootLoaderData = () => {
129
+ const [root] = useMatches();
130
+ return root?.data as SerializeFrom<typeof loader>;
131
+ };
132
+
133
+ export function loader(context) {
134
+ // ...
135
+ }
136
+ ```
137
+
138
+ This way, you can import `useRootLoaderData()` anywhere in your app and get the correct type for the data returned by the root loader.
139
+
140
+ - Updated dependencies [[`81400439`](https://github.com/Shopify/hydrogen/commit/814004397c1d17ef0a53a425ed28a42cf67765cf), [`a6f397b6`](https://github.com/Shopify/hydrogen/commit/a6f397b64dc6a0d856cb7961731ee1f86bf80292), [`3464ec04`](https://github.com/Shopify/hydrogen/commit/3464ec04a084e1ceb30ee19874dc1b9171ce2b34), [`7fc088e2`](https://github.com/Shopify/hydrogen/commit/7fc088e21bea47840788cb7c60f873ce1f253128), [`867e0b03`](https://github.com/Shopify/hydrogen/commit/867e0b033fc9eb04b7250baea97d8fd49d26ccca), [`ad45656c`](https://github.com/Shopify/hydrogen/commit/ad45656c5f663cc1a60eab5daab4da1dfd0e6cc3), [`f24e3424`](https://github.com/Shopify/hydrogen/commit/f24e3424c8e2b363b181b71fcbd3e45f696fdd3f), [`66a48573`](https://github.com/Shopify/hydrogen/commit/66a4857387148b6a104df5783314c74aca8aada0), [`0ae7cbe2`](https://github.com/Shopify/hydrogen/commit/0ae7cbe280d8351126e11dc13f35d7277d9b2d86), [`8198c1be`](https://github.com/Shopify/hydrogen/commit/8198c1befdfafb39fbcc88d71f91d21eae252973), [`ad45656c`](https://github.com/Shopify/hydrogen/commit/ad45656c5f663cc1a60eab5daab4da1dfd0e6cc3)]:
141
+ - @shopify/hydrogen@2023.10.0
142
+ - @shopify/remix-oxygen@2.0.0
143
+ - @shopify/cli-hydrogen@6.0.0
README.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Hydrogen Template: Skeleton (Enhanced)
2
+
3
+ [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/AbdulElahOthmanGwaith/hydrogen-template)
4
+
5
+ هذا المستودع عبارة عن قالب متطور لمتاجر **Shopify** باستخدام إطار عمل **Hydrogen** و **Remix**. تم تحديثه ليتوافق مع أحدث معايير الأداء والأمان لعام 2026.
6
+
7
+ ---
8
+
9
+ ## 🌟 الميزات الرئيسية | Key Features
10
+
11
+ - **Remix 2.x**: إطار عمل كامل للمواقع السريعة والمتجاوبة.
12
+ - **Hydrogen**: مكتبة Shopify المخصصة للتجارة الإلكترونية "Headless".
13
+ - **Vite Optimized**: إعدادات متقدمة لسرعة بناء وتطوير فائقة.
14
+ - **Netlify Ready**: مهيأ للنشر المباشر على Netlify مع دعم Edge Functions.
15
+ - **TypeScript**: دعم كامل للأنماط لضمان كود نظيف وخالٍ من الأخطاء.
16
+
17
+ ---
18
+
19
+ ## 🛠️ البدء في العمل | Getting Started
20
+
21
+ ### المتطلبات | Prerequisites
22
+ - Node.js (إصدار 18 أو أحدث)
23
+ - Netlify CLI
24
+
25
+ ### التثبيت | Installation
26
+ ```bash
27
+ # استنساخ المستودع
28
+ git clone https://github.com/AbdulElahOthmanGwaith/hydrogen-template.git
29
+
30
+ # الدخول للمجلد
31
+ cd hydrogen-template
32
+
33
+ # تثبيت التبعيات
34
+ npm install
35
+ ```
36
+
37
+ ### التشغيل | Development
38
+ ```bash
39
+ npm run dev
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 📂 هيكل المشروع | Project Structure
45
+
46
+ - `/app`: يحتوي على منطق التطبيق، المكونات، والمسارات (Routes).
47
+ - `/public`: الملفات الثابتة مثل الصور والأيقونات.
48
+ - `server.ts`: إعدادات الخادم المخصصة لـ Netlify Edge.
49
+
50
+ ---
51
+
52
+ ## 🤝 المساهمة | Contributing
53
+
54
+ نرحب بمساهماتكم! يرجى فتح "Issue" أو تقديم "Pull Request" لأي تحسينات تقترحونها.
55
+
56
+ ---
57
+
58
+ ## 📄 الترخيص | License
59
+
60
+ هذا المشروع مرخص تحت رخصة MIT.
app/assets/favicon.svg ADDED
app/assets/hero-banner.jpg ADDED

Git LFS Details

  • SHA256: d86ab3161972d089bfcb76d893bb8796b1d2942ecbd602689d09d6ff224145b2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.92 MB
app/assets/product-placeholder.jpg ADDED

Git LFS Details

  • SHA256: 6cf9993c892d272b64f38fbd93cd47ad9c814ad78ebf312065d489684e0c9cb8
  • Pointer size: 131 Bytes
  • Size of remote file: 976 kB
app/components/AddToCartButton.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {type FetcherWithComponents} from '@remix-run/react';
2
+ import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen';
3
+
4
+ export function AddToCartButton({
5
+ analytics,
6
+ children,
7
+ disabled,
8
+ lines,
9
+ onClick,
10
+ }: {
11
+ analytics?: unknown;
12
+ children: React.ReactNode;
13
+ disabled?: boolean;
14
+ lines: Array<OptimisticCartLineInput>;
15
+ onClick?: () => void;
16
+ }) {
17
+ return (
18
+ <CartForm route="/cart" inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
19
+ {(fetcher: FetcherWithComponents<any>) => (
20
+ <>
21
+ <input
22
+ name="analytics"
23
+ type="hidden"
24
+ value={JSON.stringify(analytics)}
25
+ />
26
+ <button
27
+ type="submit"
28
+ onClick={onClick}
29
+ disabled={disabled ?? fetcher.state !== 'idle'}
30
+ >
31
+ {children}
32
+ </button>
33
+ </>
34
+ )}
35
+ </CartForm>
36
+ );
37
+ }
app/components/Aside.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {createContext, type ReactNode, useContext, useState} from 'react';
2
+
3
+ type AsideType = 'search' | 'cart' | 'mobile' | 'closed';
4
+ type AsideContextValue = {
5
+ type: AsideType;
6
+ open: (mode: AsideType) => void;
7
+ close: () => void;
8
+ };
9
+
10
+ /**
11
+ * A side bar component with Overlay
12
+ * @example
13
+ * ```jsx
14
+ * <Aside type="search" heading="SEARCH">
15
+ * <input type="search" />
16
+ * ...
17
+ * </Aside>
18
+ * ```
19
+ */
20
+ export function Aside({
21
+ children,
22
+ heading,
23
+ type,
24
+ }: {
25
+ children?: React.ReactNode;
26
+ type: AsideType;
27
+ heading: React.ReactNode;
28
+ }) {
29
+ const {type: activeType, close} = useAside();
30
+ const expanded = type === activeType;
31
+
32
+ return (
33
+ <div
34
+ aria-modal
35
+ className={`overlay ${expanded ? 'expanded' : ''}`}
36
+ role="dialog"
37
+ >
38
+ <button className="close-outside" onClick={close} />
39
+ <aside>
40
+ <header>
41
+ <h3>{heading}</h3>
42
+ <button className="close reset" onClick={close}>
43
+ &times;
44
+ </button>
45
+ </header>
46
+ <main>{children}</main>
47
+ </aside>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const AsideContext = createContext<AsideContextValue | null>(null);
53
+
54
+ Aside.Provider = function AsideProvider({children}: {children: ReactNode}) {
55
+ const [type, setType] = useState<AsideType>('closed');
56
+
57
+ return (
58
+ <AsideContext.Provider
59
+ value={{
60
+ type,
61
+ open: setType,
62
+ close: () => setType('closed'),
63
+ }}
64
+ >
65
+ {children}
66
+ </AsideContext.Provider>
67
+ );
68
+ };
69
+
70
+ export function useAside() {
71
+ const aside = useContext(AsideContext);
72
+ if (!aside) {
73
+ throw new Error('useAside must be used within an AsideProvider');
74
+ }
75
+ return aside;
76
+ }
app/components/CartLineItem.tsx ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types';
2
+ import type {CartLayout} from '~/components/CartMain';
3
+ import {CartForm, Image, type OptimisticCartLine} from '@shopify/hydrogen';
4
+ import {useVariantUrl} from '~/lib/variants';
5
+ import {Link} from '@remix-run/react';
6
+ import {ProductPrice} from './ProductPrice';
7
+ import {useAside} from './Aside';
8
+ import type {CartApiQueryFragment} from 'storefrontapi.generated';
9
+
10
+ type CartLine = OptimisticCartLine<CartApiQueryFragment>;
11
+
12
+ /**
13
+ * A single line item in the cart. It displays the product image, title, price.
14
+ * It also provides controls to update the quantity or remove the line item.
15
+ */
16
+ export function CartLineItem({
17
+ layout,
18
+ line,
19
+ }: {
20
+ layout: CartLayout;
21
+ line: CartLine;
22
+ }) {
23
+ const {id, merchandise} = line;
24
+ const {product, title, image, selectedOptions} = merchandise;
25
+ const lineItemUrl = useVariantUrl(product.handle, selectedOptions);
26
+ const {close} = useAside();
27
+
28
+ return (
29
+ <li key={id} className="cart-line">
30
+ {image && (
31
+ <Image
32
+ alt={title}
33
+ aspectRatio="1/1"
34
+ data={image}
35
+ height={100}
36
+ loading="lazy"
37
+ width={100}
38
+ />
39
+ )}
40
+
41
+ <div>
42
+ <Link
43
+ prefetch="intent"
44
+ to={lineItemUrl}
45
+ onClick={() => {
46
+ if (layout === 'aside') {
47
+ close();
48
+ }
49
+ }}
50
+ >
51
+ <p>
52
+ <strong>{product.title}</strong>
53
+ </p>
54
+ </Link>
55
+ <ProductPrice price={line?.cost?.totalAmount} />
56
+ <ul>
57
+ {selectedOptions.map((option) => (
58
+ <li key={option.name}>
59
+ <small>
60
+ {option.name}: {option.value}
61
+ </small>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ <CartLineQuantity line={line} />
66
+ </div>
67
+ </li>
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Provides the controls to update the quantity of a line item in the cart.
73
+ * These controls are disabled when the line item is new, and the server
74
+ * hasn't yet responded that it was successfully added to the cart.
75
+ */
76
+ function CartLineQuantity({line}: {line: CartLine}) {
77
+ if (!line || typeof line?.quantity === 'undefined') return null;
78
+ const {id: lineId, quantity, isOptimistic} = line;
79
+ const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
80
+ const nextQuantity = Number((quantity + 1).toFixed(0));
81
+
82
+ return (
83
+ <div className="cart-line-quantity">
84
+ <small>Quantity: {quantity} &nbsp;&nbsp;</small>
85
+ <CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
86
+ <button
87
+ aria-label="Decrease quantity"
88
+ disabled={quantity <= 1 || !!isOptimistic}
89
+ name="decrease-quantity"
90
+ value={prevQuantity}
91
+ >
92
+ <span>&#8722; </span>
93
+ </button>
94
+ </CartLineUpdateButton>
95
+ &nbsp;
96
+ <CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
97
+ <button
98
+ aria-label="Increase quantity"
99
+ name="increase-quantity"
100
+ value={nextQuantity}
101
+ disabled={!!isOptimistic}
102
+ >
103
+ <span>&#43;</span>
104
+ </button>
105
+ </CartLineUpdateButton>
106
+ &nbsp;
107
+ <CartLineRemoveButton lineIds={[lineId]} disabled={!!isOptimistic} />
108
+ </div>
109
+ );
110
+ }
111
+
112
+ /**
113
+ * A button that removes a line item from the cart. It is disabled
114
+ * when the line item is new, and the server hasn't yet responded
115
+ * that it was successfully added to the cart.
116
+ */
117
+ function CartLineRemoveButton({
118
+ lineIds,
119
+ disabled,
120
+ }: {
121
+ lineIds: string[];
122
+ disabled: boolean;
123
+ }) {
124
+ return (
125
+ <CartForm
126
+ route="/cart"
127
+ action={CartForm.ACTIONS.LinesRemove}
128
+ inputs={{lineIds}}
129
+ >
130
+ <button disabled={disabled} type="submit">
131
+ Remove
132
+ </button>
133
+ </CartForm>
134
+ );
135
+ }
136
+
137
+ function CartLineUpdateButton({
138
+ children,
139
+ lines,
140
+ }: {
141
+ children: React.ReactNode;
142
+ lines: CartLineUpdateInput[];
143
+ }) {
144
+ return (
145
+ <CartForm
146
+ route="/cart"
147
+ action={CartForm.ACTIONS.LinesUpdate}
148
+ inputs={{lines}}
149
+ >
150
+ {children}
151
+ </CartForm>
152
+ );
153
+ }
app/components/CartMain.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {useOptimisticCart} from '@shopify/hydrogen';
2
+ import {Link} from '@remix-run/react';
3
+ import type {CartApiQueryFragment} from 'storefrontapi.generated';
4
+ import {useAside} from '~/components/Aside';
5
+ import {CartLineItem} from '~/components/CartLineItem';
6
+ import {CartSummary} from './CartSummary';
7
+
8
+ export type CartLayout = 'page' | 'aside';
9
+
10
+ export type CartMainProps = {
11
+ cart: CartApiQueryFragment | null;
12
+ layout: CartLayout;
13
+ };
14
+
15
+ /**
16
+ * The main cart component that displays the cart items and summary.
17
+ * It is used by both the /cart route and the cart aside dialog.
18
+ */
19
+ export function CartMain({layout, cart: originalCart}: CartMainProps) {
20
+ // The useOptimisticCart hook applies pending actions to the cart
21
+ // so the user immediately sees feedback when they modify the cart.
22
+ const cart = useOptimisticCart(originalCart);
23
+
24
+ const linesCount = Boolean(cart?.lines?.nodes?.length || 0);
25
+ const withDiscount =
26
+ cart &&
27
+ Boolean(cart?.discountCodes?.filter((code) => code.applicable)?.length);
28
+ const className = `cart-main ${withDiscount ? 'with-discount' : ''}`;
29
+ const cartHasItems = (cart?.totalQuantity ?? 0) > 0;
30
+
31
+ return (
32
+ <div className={className}>
33
+ <CartEmpty hidden={linesCount} layout={layout} />
34
+ <div className="cart-details">
35
+ <div aria-labelledby="cart-lines">
36
+ <ul>
37
+ {(cart?.lines?.nodes ?? []).map((line) => (
38
+ <CartLineItem key={line.id} line={line} layout={layout} />
39
+ ))}
40
+ </ul>
41
+ </div>
42
+ {cartHasItems && <CartSummary cart={cart} layout={layout} />}
43
+ </div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ function CartEmpty({
49
+ hidden = false,
50
+ }: {
51
+ hidden: boolean;
52
+ layout?: CartMainProps['layout'];
53
+ }) {
54
+ const {close} = useAside();
55
+ return (
56
+ <div hidden={hidden}>
57
+ <br />
58
+ <p>
59
+ Looks like you haven&rsquo;t added anything yet, let&rsquo;s get you
60
+ started!
61
+ </p>
62
+ <br />
63
+ <Link to="/collections" onClick={close} prefetch="viewport">
64
+ Continue shopping →
65
+ </Link>
66
+ </div>
67
+ );
68
+ }
app/components/CartSummary.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {CartApiQueryFragment} from 'storefrontapi.generated';
2
+ import type {CartLayout} from '~/components/CartMain';
3
+ import {CartForm, Money, type OptimisticCart} from '@shopify/hydrogen';
4
+
5
+ type CartSummaryProps = {
6
+ cart: OptimisticCart<CartApiQueryFragment | null>;
7
+ layout: CartLayout;
8
+ };
9
+
10
+ export function CartSummary({cart, layout}: CartSummaryProps) {
11
+ const className =
12
+ layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside';
13
+
14
+ return (
15
+ <div aria-labelledby="cart-summary" className={className}>
16
+ <h4>Totals</h4>
17
+ <dl className="cart-subtotal">
18
+ <dt>Subtotal</dt>
19
+ <dd>
20
+ {cart.cost?.subtotalAmount?.amount ? (
21
+ <Money data={cart.cost?.subtotalAmount} />
22
+ ) : (
23
+ '-'
24
+ )}
25
+ </dd>
26
+ </dl>
27
+ <CartDiscounts discountCodes={cart.discountCodes} />
28
+ <CartCheckoutActions checkoutUrl={cart.checkoutUrl} />
29
+ </div>
30
+ );
31
+ }
32
+ function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) {
33
+ if (!checkoutUrl) return null;
34
+
35
+ return (
36
+ <div>
37
+ <a href={checkoutUrl} target="_self">
38
+ <p>Continue to Checkout &rarr;</p>
39
+ </a>
40
+ <br />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function CartDiscounts({
46
+ discountCodes,
47
+ }: {
48
+ discountCodes?: CartApiQueryFragment['discountCodes'];
49
+ }) {
50
+ const codes: string[] =
51
+ discountCodes
52
+ ?.filter((discount) => discount.applicable)
53
+ ?.map(({code}) => code) || [];
54
+
55
+ return (
56
+ <div>
57
+ {/* Have existing discount, display it with a remove option */}
58
+ <dl hidden={!codes.length}>
59
+ <div>
60
+ <dt>Discount(s)</dt>
61
+ <UpdateDiscountForm>
62
+ <div className="cart-discount">
63
+ <code>{codes?.join(', ')}</code>
64
+ &nbsp;
65
+ <button>Remove</button>
66
+ </div>
67
+ </UpdateDiscountForm>
68
+ </div>
69
+ </dl>
70
+
71
+ {/* Show an input to apply a discount */}
72
+ <UpdateDiscountForm discountCodes={codes}>
73
+ <div>
74
+ <input type="text" name="discountCode" placeholder="Discount code" />
75
+ &nbsp;
76
+ <button type="submit">Apply</button>
77
+ </div>
78
+ </UpdateDiscountForm>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ function UpdateDiscountForm({
84
+ discountCodes,
85
+ children,
86
+ }: {
87
+ discountCodes?: string[];
88
+ children: React.ReactNode;
89
+ }) {
90
+ return (
91
+ <CartForm
92
+ route="/cart"
93
+ action={CartForm.ACTIONS.DiscountCodesUpdate}
94
+ inputs={{
95
+ discountCodes: discountCodes || [],
96
+ }}
97
+ >
98
+ {children}
99
+ </CartForm>
100
+ );
101
+ }
app/components/Footer.tsx ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Suspense} from 'react';
2
+ import {Await, NavLink} from '@remix-run/react';
3
+ import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
4
+
5
+ interface FooterProps {
6
+ footer: Promise<FooterQuery | null>;
7
+ header: HeaderQuery;
8
+ publicStoreDomain: string;
9
+ }
10
+
11
+ export function Footer({
12
+ footer: footerPromise,
13
+ header,
14
+ publicStoreDomain,
15
+ }: FooterProps) {
16
+ return (
17
+ <Suspense>
18
+ <Await resolve={footerPromise}>
19
+ {(footer) => (
20
+ <footer className="footer">
21
+ {footer?.menu && header.shop.primaryDomain?.url && (
22
+ <FooterMenu
23
+ menu={footer.menu}
24
+ primaryDomainUrl={header.shop.primaryDomain.url}
25
+ publicStoreDomain={publicStoreDomain}
26
+ />
27
+ )}
28
+ </footer>
29
+ )}
30
+ </Await>
31
+ </Suspense>
32
+ );
33
+ }
34
+
35
+ function FooterMenu({
36
+ menu,
37
+ primaryDomainUrl,
38
+ publicStoreDomain,
39
+ }: {
40
+ menu: FooterQuery['menu'];
41
+ primaryDomainUrl: FooterProps['header']['shop']['primaryDomain']['url'];
42
+ publicStoreDomain: string;
43
+ }) {
44
+ return (
45
+ <nav className="footer-menu" role="navigation">
46
+ {(menu || FALLBACK_FOOTER_MENU).items.map((item) => {
47
+ if (!item.url) return null;
48
+ // if the url is internal, we strip the domain
49
+ const url =
50
+ item.url.includes('myshopify.com') ||
51
+ item.url.includes(publicStoreDomain) ||
52
+ item.url.includes(primaryDomainUrl)
53
+ ? new URL(item.url).pathname
54
+ : item.url;
55
+ const isExternal = !url.startsWith('/');
56
+ return isExternal ? (
57
+ <a href={url} key={item.id} rel="noopener noreferrer" target="_blank">
58
+ {item.title}
59
+ </a>
60
+ ) : (
61
+ <NavLink
62
+ end
63
+ key={item.id}
64
+ prefetch="intent"
65
+ style={activeLinkStyle}
66
+ to={url}
67
+ >
68
+ {item.title}
69
+ </NavLink>
70
+ );
71
+ })}
72
+ </nav>
73
+ );
74
+ }
75
+
76
+ const FALLBACK_FOOTER_MENU = {
77
+ id: 'gid://shopify/Menu/199655620664',
78
+ items: [
79
+ {
80
+ id: 'gid://shopify/MenuItem/461633060920',
81
+ resourceId: 'gid://shopify/ShopPolicy/23358046264',
82
+ tags: [],
83
+ title: 'Privacy Policy',
84
+ type: 'SHOP_POLICY',
85
+ url: '/policies/privacy-policy',
86
+ items: [],
87
+ },
88
+ {
89
+ id: 'gid://shopify/MenuItem/461633093688',
90
+ resourceId: 'gid://shopify/ShopPolicy/23358013496',
91
+ tags: [],
92
+ title: 'Refund Policy',
93
+ type: 'SHOP_POLICY',
94
+ url: '/policies/refund-policy',
95
+ items: [],
96
+ },
97
+ {
98
+ id: 'gid://shopify/MenuItem/461633126456',
99
+ resourceId: 'gid://shopify/ShopPolicy/23358111800',
100
+ tags: [],
101
+ title: 'Shipping Policy',
102
+ type: 'SHOP_POLICY',
103
+ url: '/policies/shipping-policy',
104
+ items: [],
105
+ },
106
+ {
107
+ id: 'gid://shopify/MenuItem/461633159224',
108
+ resourceId: 'gid://shopify/ShopPolicy/23358079032',
109
+ tags: [],
110
+ title: 'Terms of Service',
111
+ type: 'SHOP_POLICY',
112
+ url: '/policies/terms-of-service',
113
+ items: [],
114
+ },
115
+ ],
116
+ };
117
+
118
+ function activeLinkStyle({
119
+ isActive,
120
+ isPending,
121
+ }: {
122
+ isActive: boolean;
123
+ isPending: boolean;
124
+ }) {
125
+ return {
126
+ fontWeight: isActive ? 'bold' : undefined,
127
+ color: isPending ? 'grey' : 'white',
128
+ };
129
+ }
app/components/Header.tsx ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Suspense} from 'react';
2
+ import {Await, NavLink} from '@remix-run/react';
3
+ import {type CartViewPayload, useAnalytics} from '@shopify/hydrogen';
4
+ import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated';
5
+ import {useAside} from '~/components/Aside';
6
+
7
+ interface HeaderProps {
8
+ header: HeaderQuery;
9
+ cart: Promise<CartApiQueryFragment | null>;
10
+ isLoggedIn: Promise<boolean>;
11
+ publicStoreDomain: string;
12
+ }
13
+
14
+ type Viewport = 'desktop' | 'mobile';
15
+
16
+ export function Header({
17
+ header,
18
+ isLoggedIn,
19
+ cart,
20
+ publicStoreDomain,
21
+ }: HeaderProps) {
22
+ const {shop, menu} = header;
23
+ return (
24
+ <header className="header">
25
+ <NavLink prefetch="intent" to="/" style={activeLinkStyle} end>
26
+ <strong>{shop.name}</strong>
27
+ </NavLink>
28
+ <HeaderMenu
29
+ menu={menu}
30
+ viewport="desktop"
31
+ primaryDomainUrl={header.shop.primaryDomain.url}
32
+ publicStoreDomain={publicStoreDomain}
33
+ />
34
+ <HeaderCtas isLoggedIn={isLoggedIn} cart={cart} />
35
+ </header>
36
+ );
37
+ }
38
+
39
+ export function HeaderMenu({
40
+ menu,
41
+ primaryDomainUrl,
42
+ viewport,
43
+ publicStoreDomain,
44
+ }: {
45
+ menu: HeaderProps['header']['menu'];
46
+ primaryDomainUrl: HeaderProps['header']['shop']['primaryDomain']['url'];
47
+ viewport: Viewport;
48
+ publicStoreDomain: HeaderProps['publicStoreDomain'];
49
+ }) {
50
+ const className = `header-menu-${viewport}`;
51
+ const {close} = useAside();
52
+
53
+ return (
54
+ <nav className={className} role="navigation">
55
+ {viewport === 'mobile' && (
56
+ <NavLink
57
+ end
58
+ onClick={close}
59
+ prefetch="intent"
60
+ style={activeLinkStyle}
61
+ to="/"
62
+ >
63
+ Home
64
+ </NavLink>
65
+ )}
66
+ {(menu || FALLBACK_HEADER_MENU).items.map((item) => {
67
+ if (!item.url) return null;
68
+
69
+ // if the url is internal, we strip the domain
70
+ const url =
71
+ item.url.includes('myshopify.com') ||
72
+ item.url.includes(publicStoreDomain) ||
73
+ item.url.includes(primaryDomainUrl)
74
+ ? new URL(item.url).pathname
75
+ : item.url;
76
+ return (
77
+ <NavLink
78
+ className="header-menu-item"
79
+ end
80
+ key={item.id}
81
+ onClick={close}
82
+ prefetch="intent"
83
+ style={activeLinkStyle}
84
+ to={url}
85
+ >
86
+ {item.title}
87
+ </NavLink>
88
+ );
89
+ })}
90
+ </nav>
91
+ );
92
+ }
93
+
94
+ function HeaderCtas({
95
+ isLoggedIn,
96
+ cart,
97
+ }: Pick<HeaderProps, 'isLoggedIn' | 'cart'>) {
98
+ return (
99
+ <nav className="header-ctas" role="navigation">
100
+ <HeaderMenuMobileToggle />
101
+ <NavLink prefetch="intent" to="/account" style={activeLinkStyle}>
102
+ <Suspense fallback="Sign in">
103
+ <Await resolve={isLoggedIn} errorElement="Sign in">
104
+ {(isLoggedIn) => (isLoggedIn ? 'Account' : 'Sign in')}
105
+ </Await>
106
+ </Suspense>
107
+ </NavLink>
108
+ <SearchToggle />
109
+ <CartToggle cart={cart} />
110
+ </nav>
111
+ );
112
+ }
113
+
114
+ function HeaderMenuMobileToggle() {
115
+ const {open} = useAside();
116
+ return (
117
+ <button
118
+ className="header-menu-mobile-toggle reset"
119
+ onClick={() => open('mobile')}
120
+ >
121
+ <h3>☰</h3>
122
+ </button>
123
+ );
124
+ }
125
+
126
+ function SearchToggle() {
127
+ const {open} = useAside();
128
+ return (
129
+ <button className="reset" onClick={() => open('search')}>
130
+ Search
131
+ </button>
132
+ );
133
+ }
134
+
135
+ function CartBadge({count}: {count: number | null}) {
136
+ const {open} = useAside();
137
+ const {publish, shop, cart, prevCart} = useAnalytics();
138
+
139
+ return (
140
+ <a
141
+ href="/cart"
142
+ onClick={(e) => {
143
+ e.preventDefault();
144
+ open('cart');
145
+ publish('cart_viewed', {
146
+ cart,
147
+ prevCart,
148
+ shop,
149
+ url: window.location.href || '',
150
+ } as CartViewPayload);
151
+ }}
152
+ >
153
+ Cart {count === null ? <span>&nbsp;</span> : count}
154
+ </a>
155
+ );
156
+ }
157
+
158
+ function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
159
+ return (
160
+ <Suspense fallback={<CartBadge count={null} />}>
161
+ <Await resolve={cart}>
162
+ {(cart) => {
163
+ if (!cart) return <CartBadge count={0} />;
164
+ return <CartBadge count={cart.totalQuantity || 0} />;
165
+ }}
166
+ </Await>
167
+ </Suspense>
168
+ );
169
+ }
170
+
171
+ const FALLBACK_HEADER_MENU = {
172
+ id: 'gid://shopify/Menu/199655587896',
173
+ items: [
174
+ {
175
+ id: 'gid://shopify/MenuItem/461609500728',
176
+ resourceId: null,
177
+ tags: [],
178
+ title: 'Collections',
179
+ type: 'HTTP',
180
+ url: '/collections',
181
+ items: [],
182
+ },
183
+ {
184
+ id: 'gid://shopify/MenuItem/461609533496',
185
+ resourceId: null,
186
+ tags: [],
187
+ title: 'Blog',
188
+ type: 'HTTP',
189
+ url: '/blogs/journal',
190
+ items: [],
191
+ },
192
+ {
193
+ id: 'gid://shopify/MenuItem/461609566264',
194
+ resourceId: null,
195
+ tags: [],
196
+ title: 'Policies',
197
+ type: 'HTTP',
198
+ url: '/policies',
199
+ items: [],
200
+ },
201
+ {
202
+ id: 'gid://shopify/MenuItem/461609599032',
203
+ resourceId: 'gid://shopify/Page/92591030328',
204
+ tags: [],
205
+ title: 'About',
206
+ type: 'PAGE',
207
+ url: '/pages/about',
208
+ items: [],
209
+ },
210
+ ],
211
+ };
212
+
213
+ function activeLinkStyle({
214
+ isActive,
215
+ isPending,
216
+ }: {
217
+ isActive: boolean;
218
+ isPending: boolean;
219
+ }) {
220
+ return {
221
+ fontWeight: isActive ? 'bold' : undefined,
222
+ color: isPending ? 'grey' : 'black',
223
+ };
224
+ }
app/components/PageLayout.tsx ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Await, Link} from '@remix-run/react';
2
+ import {Suspense} from 'react';
3
+ import type {
4
+ CartApiQueryFragment,
5
+ FooterQuery,
6
+ HeaderQuery,
7
+ } from 'storefrontapi.generated';
8
+ import {Aside} from '~/components/Aside';
9
+ import {Footer} from '~/components/Footer';
10
+ import {Header, HeaderMenu} from '~/components/Header';
11
+ import {CartMain} from '~/components/CartMain';
12
+ import {
13
+ SEARCH_ENDPOINT,
14
+ SearchFormPredictive,
15
+ } from '~/components/SearchFormPredictive';
16
+ import {SearchResultsPredictive} from '~/components/SearchResultsPredictive';
17
+
18
+ interface PageLayoutProps {
19
+ cart: Promise<CartApiQueryFragment | null>;
20
+ footer: Promise<FooterQuery | null>;
21
+ header: HeaderQuery;
22
+ isLoggedIn: Promise<boolean>;
23
+ publicStoreDomain: string;
24
+ children?: React.ReactNode;
25
+ }
26
+
27
+ export function PageLayout({
28
+ cart,
29
+ children = null,
30
+ footer,
31
+ header,
32
+ isLoggedIn,
33
+ publicStoreDomain,
34
+ }: PageLayoutProps) {
35
+ return (
36
+ <Aside.Provider>
37
+ <CartAside cart={cart} />
38
+ <SearchAside />
39
+ <MobileMenuAside header={header} publicStoreDomain={publicStoreDomain} />
40
+ {header && (
41
+ <Header
42
+ header={header}
43
+ cart={cart}
44
+ isLoggedIn={isLoggedIn}
45
+ publicStoreDomain={publicStoreDomain}
46
+ />
47
+ )}
48
+ <main>{children}</main>
49
+ <Footer
50
+ footer={footer}
51
+ header={header}
52
+ publicStoreDomain={publicStoreDomain}
53
+ />
54
+ </Aside.Provider>
55
+ );
56
+ }
57
+
58
+ function CartAside({cart}: {cart: PageLayoutProps['cart']}) {
59
+ return (
60
+ <Aside type="cart" heading="CART">
61
+ <Suspense fallback={<p>Loading cart ...</p>}>
62
+ <Await resolve={cart}>
63
+ {(cart) => {
64
+ return <CartMain cart={cart} layout="aside" />;
65
+ }}
66
+ </Await>
67
+ </Suspense>
68
+ </Aside>
69
+ );
70
+ }
71
+
72
+ function SearchAside() {
73
+ return (
74
+ <Aside type="search" heading="SEARCH">
75
+ <div className="predictive-search">
76
+ <br />
77
+ <SearchFormPredictive>
78
+ {({fetchResults, goToSearch, inputRef}) => (
79
+ <>
80
+ <input
81
+ name="q"
82
+ onChange={fetchResults}
83
+ onFocus={fetchResults}
84
+ placeholder="Search"
85
+ ref={inputRef}
86
+ type="search"
87
+ />
88
+ &nbsp;
89
+ <button onClick={goToSearch}>Search</button>
90
+ </>
91
+ )}
92
+ </SearchFormPredictive>
93
+
94
+ <SearchResultsPredictive>
95
+ {({items, total, term, state, inputRef, closeSearch}) => {
96
+ const {articles, collections, pages, products, queries} = items;
97
+
98
+ if (state === 'loading' && term.current) {
99
+ return <div>Loading...</div>;
100
+ }
101
+
102
+ if (!total) {
103
+ return <SearchResultsPredictive.Empty term={term} />;
104
+ }
105
+
106
+ return (
107
+ <>
108
+ <SearchResultsPredictive.Queries
109
+ queries={queries}
110
+ inputRef={inputRef}
111
+ />
112
+ <SearchResultsPredictive.Products
113
+ products={products}
114
+ closeSearch={closeSearch}
115
+ term={term}
116
+ />
117
+ <SearchResultsPredictive.Collections
118
+ collections={collections}
119
+ closeSearch={closeSearch}
120
+ term={term}
121
+ />
122
+ <SearchResultsPredictive.Pages
123
+ pages={pages}
124
+ closeSearch={closeSearch}
125
+ term={term}
126
+ />
127
+ <SearchResultsPredictive.Articles
128
+ articles={articles}
129
+ closeSearch={closeSearch}
130
+ term={term}
131
+ />
132
+ {term.current && total ? (
133
+ <Link
134
+ onClick={closeSearch}
135
+ to={`${SEARCH_ENDPOINT}?q=${term.current}`}
136
+ >
137
+ <p>
138
+ View all results for <q>{term.current}</q>
139
+ &nbsp; →
140
+ </p>
141
+ </Link>
142
+ ) : null}
143
+ </>
144
+ );
145
+ }}
146
+ </SearchResultsPredictive>
147
+ </div>
148
+ </Aside>
149
+ );
150
+ }
151
+
152
+ function MobileMenuAside({
153
+ header,
154
+ publicStoreDomain,
155
+ }: {
156
+ header: PageLayoutProps['header'];
157
+ publicStoreDomain: PageLayoutProps['publicStoreDomain'];
158
+ }) {
159
+ return (
160
+ header.menu &&
161
+ header.shop.primaryDomain?.url && (
162
+ <Aside type="mobile" heading="MENU">
163
+ <HeaderMenu
164
+ menu={header.menu}
165
+ viewport="mobile"
166
+ primaryDomainUrl={header.shop.primaryDomain.url}
167
+ publicStoreDomain={publicStoreDomain}
168
+ />
169
+ </Aside>
170
+ )
171
+ );
172
+ }
app/components/PaginatedResourceSection.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react';
2
+ import {Pagination} from '@shopify/hydrogen';
3
+
4
+ /**
5
+ * <PaginatedResourceSection > is a component that encapsulate how the previous and next behaviors throughout your application.
6
+ */
7
+
8
+ export function PaginatedResourceSection<NodesType>({
9
+ connection,
10
+ children,
11
+ resourcesClassName,
12
+ }: {
13
+ connection: React.ComponentProps<typeof Pagination<NodesType>>['connection'];
14
+ children: React.FunctionComponent<{node: NodesType; index: number}>;
15
+ resourcesClassName?: string;
16
+ }) {
17
+ return (
18
+ <Pagination connection={connection}>
19
+ {({nodes, isLoading, PreviousLink, NextLink}) => {
20
+ const resoucesMarkup = nodes.map((node, index) =>
21
+ children({node, index}),
22
+ );
23
+
24
+ return (
25
+ <div>
26
+ <PreviousLink>
27
+ {isLoading ? 'Loading...' : <span>↑ Load previous</span>}
28
+ </PreviousLink>
29
+ {resourcesClassName ? (
30
+ <div className={resourcesClassName}>{resoucesMarkup}</div>
31
+ ) : (
32
+ resoucesMarkup
33
+ )}
34
+ <NextLink>
35
+ {isLoading ? 'Loading...' : <span>Load more ↓</span>}
36
+ </NextLink>
37
+ </div>
38
+ );
39
+ }}
40
+ </Pagination>
41
+ );
42
+ }
app/components/ProductForm.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Link} from '@remix-run/react';
2
+ import {type VariantOption, VariantSelector} from '@shopify/hydrogen';
3
+ import type {
4
+ ProductFragment,
5
+ ProductVariantFragment,
6
+ } from 'storefrontapi.generated';
7
+ import {AddToCartButton} from '~/components/AddToCartButton';
8
+ import {useAside} from '~/components/Aside';
9
+
10
+ export function ProductForm({
11
+ product,
12
+ selectedVariant,
13
+ variants,
14
+ }: {
15
+ product: ProductFragment;
16
+ selectedVariant: ProductFragment['selectedVariant'];
17
+ variants: Array<ProductVariantFragment>;
18
+ }) {
19
+ const {open} = useAside();
20
+ return (
21
+ <div className="product-form">
22
+ <VariantSelector
23
+ handle={product.handle}
24
+ options={product.options.filter((option) => option.values.length > 1)}
25
+ variants={variants}
26
+ >
27
+ {({option}) => <ProductOptions key={option.name} option={option} />}
28
+ </VariantSelector>
29
+ <br />
30
+ <AddToCartButton
31
+ disabled={!selectedVariant || !selectedVariant.availableForSale}
32
+ onClick={() => {
33
+ open('cart');
34
+ }}
35
+ lines={
36
+ selectedVariant
37
+ ? [
38
+ {
39
+ merchandiseId: selectedVariant.id,
40
+ quantity: 1,
41
+ selectedVariant,
42
+ },
43
+ ]
44
+ : []
45
+ }
46
+ >
47
+ {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
48
+ </AddToCartButton>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ function ProductOptions({option}: {option: VariantOption}) {
54
+ return (
55
+ <div className="product-options" key={option.name}>
56
+ <h5>{option.name}</h5>
57
+ <div className="product-options-grid">
58
+ {option.values.map(({value, isAvailable, isActive, to}) => {
59
+ return (
60
+ <Link
61
+ className="product-options-item"
62
+ key={option.name + value}
63
+ prefetch="intent"
64
+ preventScrollReset
65
+ replace
66
+ to={to}
67
+ style={{
68
+ border: isActive ? '1px solid black' : '1px solid transparent',
69
+ opacity: isAvailable ? 1 : 0.3,
70
+ }}
71
+ >
72
+ {value}
73
+ </Link>
74
+ );
75
+ })}
76
+ </div>
77
+ <br />
78
+ </div>
79
+ );
80
+ }
app/components/ProductImage.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {ProductVariantFragment} from 'storefrontapi.generated';
2
+ import {Image} from '@shopify/hydrogen';
3
+
4
+ export function ProductImage({
5
+ image,
6
+ }: {
7
+ image: ProductVariantFragment['image'];
8
+ }) {
9
+ if (!image) {
10
+ return <div className="product-image" />;
11
+ }
12
+ return (
13
+ <div className="product-image">
14
+ <Image
15
+ alt={image.altText || 'Product Image'}
16
+ aspectRatio="1/1"
17
+ data={image}
18
+ key={image.id}
19
+ sizes="(min-width: 45em) 50vw, 100vw"
20
+ />
21
+ </div>
22
+ );
23
+ }
app/components/ProductPrice.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Money} from '@shopify/hydrogen';
2
+ import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types';
3
+
4
+ export function ProductPrice({
5
+ price,
6
+ compareAtPrice,
7
+ }: {
8
+ price?: MoneyV2;
9
+ compareAtPrice?: MoneyV2 | null;
10
+ }) {
11
+ return (
12
+ <div className="product-price">
13
+ {compareAtPrice ? (
14
+ <div className="product-price-on-sale">
15
+ {price ? <Money data={price} /> : null}
16
+ <s>
17
+ <Money data={compareAtPrice} />
18
+ </s>
19
+ </div>
20
+ ) : price ? (
21
+ <Money data={price} />
22
+ ) : (
23
+ <span>&nbsp;</span>
24
+ )}
25
+ </div>
26
+ );
27
+ }
app/components/SearchForm.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {useRef, useEffect} from 'react';
2
+ import {Form, type FormProps} from '@remix-run/react';
3
+
4
+ type SearchFormProps = Omit<FormProps, 'children'> & {
5
+ children: (args: {
6
+ inputRef: React.RefObject<HTMLInputElement>;
7
+ }) => React.ReactNode;
8
+ };
9
+
10
+ /**
11
+ * Search form component that sends search requests to the `/search` route.
12
+ * @example
13
+ * ```tsx
14
+ * <SearchForm>
15
+ * {({inputRef}) => (
16
+ * <>
17
+ * <input
18
+ * ref={inputRef}
19
+ * type="search"
20
+ * defaultValue={term}
21
+ * name="q"
22
+ * placeholder="Search…"
23
+ * />
24
+ * <button type="submit">Search</button>
25
+ * </>
26
+ * )}
27
+ * </SearchForm>
28
+ */
29
+ export function SearchForm({children, ...props}: SearchFormProps) {
30
+ const inputRef = useRef<HTMLInputElement | null>(null);
31
+
32
+ useFocusOnCmdK(inputRef);
33
+
34
+ if (typeof children !== 'function') {
35
+ return null;
36
+ }
37
+
38
+ return (
39
+ <Form method="get" {...props}>
40
+ {children({inputRef})}
41
+ </Form>
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Focuses the input when cmd+k is pressed
47
+ */
48
+ function useFocusOnCmdK(inputRef: React.RefObject<HTMLInputElement>) {
49
+ // focus the input when cmd+k is pressed
50
+ useEffect(() => {
51
+ function handleKeyDown(event: KeyboardEvent) {
52
+ if (event.key === 'k' && event.metaKey) {
53
+ event.preventDefault();
54
+ inputRef.current?.focus();
55
+ }
56
+
57
+ if (event.key === 'Escape') {
58
+ inputRef.current?.blur();
59
+ }
60
+ }
61
+
62
+ document.addEventListener('keydown', handleKeyDown);
63
+
64
+ return () => {
65
+ document.removeEventListener('keydown', handleKeyDown);
66
+ };
67
+ }, [inputRef]);
68
+ }
app/components/SearchFormPredictive.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ useFetcher,
3
+ useNavigate,
4
+ type FormProps,
5
+ type Fetcher,
6
+ } from '@remix-run/react';
7
+ import React, {useRef, useEffect} from 'react';
8
+ import type {PredictiveSearchReturn} from '~/lib/search';
9
+ import {useAside} from './Aside';
10
+
11
+ type SearchFormPredictiveChildren = (args: {
12
+ fetchResults: (event: React.ChangeEvent<HTMLInputElement>) => void;
13
+ goToSearch: () => void;
14
+ inputRef: React.MutableRefObject<HTMLInputElement | null>;
15
+ fetcher: Fetcher<PredictiveSearchReturn>;
16
+ }) => React.ReactNode;
17
+
18
+ type SearchFormPredictiveProps = Omit<FormProps, 'children'> & {
19
+ children: SearchFormPredictiveChildren | null;
20
+ };
21
+
22
+ export const SEARCH_ENDPOINT = '/search';
23
+
24
+ /**
25
+ * Search form component that sends search requests to the `/search` route
26
+ **/
27
+ export function SearchFormPredictive({
28
+ children,
29
+ className = 'predictive-search-form',
30
+ ...props
31
+ }: SearchFormPredictiveProps) {
32
+ const fetcher = useFetcher<PredictiveSearchReturn>({key: 'search'});
33
+ const inputRef = useRef<HTMLInputElement | null>(null);
34
+ const navigate = useNavigate();
35
+ const aside = useAside();
36
+
37
+ /** Reset the input value and blur the input */
38
+ function resetInput(event: React.FormEvent<HTMLFormElement>) {
39
+ event.preventDefault();
40
+ event.stopPropagation();
41
+ if (inputRef?.current?.value) {
42
+ inputRef.current.blur();
43
+ }
44
+ }
45
+
46
+ /** Navigate to the search page with the current input value */
47
+ function goToSearch() {
48
+ const term = inputRef?.current?.value;
49
+ navigate(SEARCH_ENDPOINT + (term ? `?q=${term}` : ''));
50
+ aside.close();
51
+ }
52
+
53
+ /** Fetch search results based on the input value */
54
+ function fetchResults(event: React.ChangeEvent<HTMLInputElement>) {
55
+ fetcher.submit(
56
+ {q: event.target.value || '', limit: 5, predictive: true},
57
+ {method: 'GET', action: SEARCH_ENDPOINT},
58
+ );
59
+ }
60
+
61
+ // ensure the passed input has a type of search, because SearchResults
62
+ // will select the element based on the input
63
+ useEffect(() => {
64
+ inputRef?.current?.setAttribute('type', 'search');
65
+ }, []);
66
+
67
+ if (typeof children !== 'function') {
68
+ return null;
69
+ }
70
+
71
+ return (
72
+ <fetcher.Form {...props} className={className} onSubmit={resetInput}>
73
+ {children({inputRef, fetcher, fetchResults, goToSearch})}
74
+ </fetcher.Form>
75
+ );
76
+ }
app/components/SearchResults.tsx ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Link} from '@remix-run/react';
2
+ import {Image, Money, Pagination} from '@shopify/hydrogen';
3
+ import {urlWithTrackingParams, type RegularSearchReturn} from '~/lib/search';
4
+
5
+ type SearchItems = RegularSearchReturn['result']['items'];
6
+ type PartialSearchResult<ItemType extends keyof SearchItems> = Pick<
7
+ SearchItems,
8
+ ItemType
9
+ > &
10
+ Pick<RegularSearchReturn, 'term'>;
11
+
12
+ type SearchResultsProps = RegularSearchReturn & {
13
+ children: (args: SearchItems & {term: string}) => React.ReactNode;
14
+ };
15
+
16
+ export function SearchResults({
17
+ term,
18
+ result,
19
+ children,
20
+ }: Omit<SearchResultsProps, 'error' | 'type'>) {
21
+ if (!result?.total) {
22
+ return null;
23
+ }
24
+
25
+ return children({...result.items, term});
26
+ }
27
+
28
+ SearchResults.Articles = SearchResultsArticles;
29
+ SearchResults.Pages = SearchResultsPages;
30
+ SearchResults.Products = SearchResultsProducts;
31
+ SearchResults.Empty = SearchResultsEmpty;
32
+
33
+ function SearchResultsArticles({
34
+ term,
35
+ articles,
36
+ }: PartialSearchResult<'articles'>) {
37
+ if (!articles?.nodes.length) {
38
+ return null;
39
+ }
40
+
41
+ return (
42
+ <div className="search-result">
43
+ <h2>Articles</h2>
44
+ <div>
45
+ {articles?.nodes?.map((article) => {
46
+ const articleUrl = urlWithTrackingParams({
47
+ baseUrl: `/blogs/${article.handle}`,
48
+ trackingParams: article.trackingParameters,
49
+ term,
50
+ });
51
+
52
+ return (
53
+ <div className="search-results-item" key={article.id}>
54
+ <Link prefetch="intent" to={articleUrl}>
55
+ {article.title}
56
+ </Link>
57
+ </div>
58
+ );
59
+ })}
60
+ </div>
61
+ <br />
62
+ </div>
63
+ );
64
+ }
65
+
66
+ function SearchResultsPages({term, pages}: PartialSearchResult<'pages'>) {
67
+ if (!pages?.nodes.length) {
68
+ return null;
69
+ }
70
+
71
+ return (
72
+ <div className="search-result">
73
+ <h2>Pages</h2>
74
+ <div>
75
+ {pages?.nodes?.map((page) => {
76
+ const pageUrl = urlWithTrackingParams({
77
+ baseUrl: `/pages/${page.handle}`,
78
+ trackingParams: page.trackingParameters,
79
+ term,
80
+ });
81
+
82
+ return (
83
+ <div className="search-results-item" key={page.id}>
84
+ <Link prefetch="intent" to={pageUrl}>
85
+ {page.title}
86
+ </Link>
87
+ </div>
88
+ );
89
+ })}
90
+ </div>
91
+ <br />
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function SearchResultsProducts({
97
+ term,
98
+ products,
99
+ }: PartialSearchResult<'products'>) {
100
+ if (!products?.nodes.length) {
101
+ return null;
102
+ }
103
+
104
+ return (
105
+ <div className="search-result">
106
+ <h2>Products</h2>
107
+ <Pagination connection={products}>
108
+ {({nodes, isLoading, NextLink, PreviousLink}) => {
109
+ const ItemsMarkup = nodes.map((product) => {
110
+ const productUrl = urlWithTrackingParams({
111
+ baseUrl: `/products/${product.handle}`,
112
+ trackingParams: product.trackingParameters,
113
+ term,
114
+ });
115
+
116
+ return (
117
+ <div className="search-results-item" key={product.id}>
118
+ <Link prefetch="intent" to={productUrl}>
119
+ {product.variants.nodes[0].image && (
120
+ <Image
121
+ data={product.variants.nodes[0].image}
122
+ alt={product.title}
123
+ width={50}
124
+ />
125
+ )}
126
+ <div>
127
+ <p>{product.title}</p>
128
+ <small>
129
+ <Money data={product.variants.nodes[0].price} />
130
+ </small>
131
+ </div>
132
+ </Link>
133
+ </div>
134
+ );
135
+ });
136
+
137
+ return (
138
+ <div>
139
+ <div>
140
+ <PreviousLink>
141
+ {isLoading ? 'Loading...' : <span>↑ Load previous</span>}
142
+ </PreviousLink>
143
+ </div>
144
+ <div>
145
+ {ItemsMarkup}
146
+ <br />
147
+ </div>
148
+ <div>
149
+ <NextLink>
150
+ {isLoading ? 'Loading...' : <span>Load more ↓</span>}
151
+ </NextLink>
152
+ </div>
153
+ </div>
154
+ );
155
+ }}
156
+ </Pagination>
157
+ <br />
158
+ </div>
159
+ );
160
+ }
161
+
162
+ function SearchResultsEmpty() {
163
+ return <p>No results, try a different search.</p>;
164
+ }
app/components/SearchResultsPredictive.tsx ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Link, useFetcher, type Fetcher} from '@remix-run/react';
2
+ import {Image, Money} from '@shopify/hydrogen';
3
+ import React, {useRef, useEffect} from 'react';
4
+ import {
5
+ getEmptyPredictiveSearchResult,
6
+ urlWithTrackingParams,
7
+ type PredictiveSearchReturn,
8
+ } from '~/lib/search';
9
+ import {useAside} from './Aside';
10
+
11
+ type PredictiveSearchItems = PredictiveSearchReturn['result']['items'];
12
+
13
+ type UsePredictiveSearchReturn = {
14
+ term: React.MutableRefObject<string>;
15
+ total: number;
16
+ inputRef: React.MutableRefObject<HTMLInputElement | null>;
17
+ items: PredictiveSearchItems;
18
+ fetcher: Fetcher<PredictiveSearchReturn>;
19
+ };
20
+
21
+ type SearchResultsPredictiveArgs = Pick<
22
+ UsePredictiveSearchReturn,
23
+ 'term' | 'total' | 'inputRef' | 'items'
24
+ > & {
25
+ state: Fetcher['state'];
26
+ closeSearch: () => void;
27
+ };
28
+
29
+ type PartialPredictiveSearchResult<
30
+ ItemType extends keyof PredictiveSearchItems,
31
+ ExtraProps extends keyof SearchResultsPredictiveArgs = 'term' | 'closeSearch',
32
+ > = Pick<PredictiveSearchItems, ItemType> &
33
+ Pick<SearchResultsPredictiveArgs, ExtraProps>;
34
+
35
+ type SearchResultsPredictiveProps = {
36
+ children: (args: SearchResultsPredictiveArgs) => React.ReactNode;
37
+ };
38
+
39
+ /**
40
+ * Component that renders predictive search results
41
+ */
42
+ export function SearchResultsPredictive({
43
+ children,
44
+ }: SearchResultsPredictiveProps) {
45
+ const aside = useAside();
46
+ const {term, inputRef, fetcher, total, items} = usePredictiveSearch();
47
+
48
+ /*
49
+ * Utility that resets the search input
50
+ */
51
+ function resetInput() {
52
+ if (inputRef.current) {
53
+ inputRef.current.blur();
54
+ inputRef.current.value = '';
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Utility that resets the search input and closes the search aside
60
+ */
61
+ function closeSearch() {
62
+ resetInput();
63
+ aside.close();
64
+ }
65
+
66
+ return children({
67
+ items,
68
+ closeSearch,
69
+ inputRef,
70
+ state: fetcher.state,
71
+ term,
72
+ total,
73
+ });
74
+ }
75
+
76
+ SearchResultsPredictive.Articles = SearchResultsPredictiveArticles;
77
+ SearchResultsPredictive.Collections = SearchResultsPredictiveCollections;
78
+ SearchResultsPredictive.Pages = SearchResultsPredictivePages;
79
+ SearchResultsPredictive.Products = SearchResultsPredictiveProducts;
80
+ SearchResultsPredictive.Queries = SearchResultsPredictiveQueries;
81
+ SearchResultsPredictive.Empty = SearchResultsPredictiveEmpty;
82
+
83
+ function SearchResultsPredictiveArticles({
84
+ term,
85
+ articles,
86
+ closeSearch,
87
+ }: PartialPredictiveSearchResult<'articles'>) {
88
+ if (!articles.length) return null;
89
+
90
+ return (
91
+ <div className="predictive-search-result" key="articles">
92
+ <h5>Articles</h5>
93
+ <ul>
94
+ {articles.map((article) => {
95
+ const articleUrl = urlWithTrackingParams({
96
+ baseUrl: `/blogs/${article.blog.handle}/${article.handle}`,
97
+ trackingParams: article.trackingParameters,
98
+ term: term.current ?? '',
99
+ });
100
+
101
+ return (
102
+ <li className="predictive-search-result-item" key={article.id}>
103
+ <Link onClick={closeSearch} to={articleUrl}>
104
+ {article.image?.url && (
105
+ <Image
106
+ alt={article.image.altText ?? ''}
107
+ src={article.image.url}
108
+ width={50}
109
+ height={50}
110
+ />
111
+ )}
112
+ <div>
113
+ <span>{article.title}</span>
114
+ </div>
115
+ </Link>
116
+ </li>
117
+ );
118
+ })}
119
+ </ul>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function SearchResultsPredictiveCollections({
125
+ term,
126
+ collections,
127
+ closeSearch,
128
+ }: PartialPredictiveSearchResult<'collections'>) {
129
+ if (!collections.length) return null;
130
+
131
+ return (
132
+ <div className="predictive-search-result" key="collections">
133
+ <h5>Collections</h5>
134
+ <ul>
135
+ {collections.map((collection) => {
136
+ const colllectionUrl = urlWithTrackingParams({
137
+ baseUrl: `/collections/${collection.handle}`,
138
+ trackingParams: collection.trackingParameters,
139
+ term: term.current,
140
+ });
141
+
142
+ return (
143
+ <li className="predictive-search-result-item" key={collection.id}>
144
+ <Link onClick={closeSearch} to={colllectionUrl}>
145
+ {collection.image?.url && (
146
+ <Image
147
+ alt={collection.image.altText ?? ''}
148
+ src={collection.image.url}
149
+ width={50}
150
+ height={50}
151
+ />
152
+ )}
153
+ <div>
154
+ <span>{collection.title}</span>
155
+ </div>
156
+ </Link>
157
+ </li>
158
+ );
159
+ })}
160
+ </ul>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ function SearchResultsPredictivePages({
166
+ term,
167
+ pages,
168
+ closeSearch,
169
+ }: PartialPredictiveSearchResult<'pages'>) {
170
+ if (!pages.length) return null;
171
+
172
+ return (
173
+ <div className="predictive-search-result" key="pages">
174
+ <h5>Pages</h5>
175
+ <ul>
176
+ {pages.map((page) => {
177
+ const pageUrl = urlWithTrackingParams({
178
+ baseUrl: `/pages/${page.handle}`,
179
+ trackingParams: page.trackingParameters,
180
+ term: term.current,
181
+ });
182
+
183
+ return (
184
+ <li className="predictive-search-result-item" key={page.id}>
185
+ <Link onClick={closeSearch} to={pageUrl}>
186
+ <div>
187
+ <span>{page.title}</span>
188
+ </div>
189
+ </Link>
190
+ </li>
191
+ );
192
+ })}
193
+ </ul>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ function SearchResultsPredictiveProducts({
199
+ term,
200
+ products,
201
+ closeSearch,
202
+ }: PartialPredictiveSearchResult<'products'>) {
203
+ if (!products.length) return null;
204
+
205
+ return (
206
+ <div className="predictive-search-result" key="products">
207
+ <h5>Products</h5>
208
+ <ul>
209
+ {products.map((product) => {
210
+ const productUrl = urlWithTrackingParams({
211
+ baseUrl: `/products/${product.handle}`,
212
+ trackingParams: product.trackingParameters,
213
+ term: term.current,
214
+ });
215
+
216
+ const image = product?.variants?.nodes?.[0].image;
217
+ return (
218
+ <li className="predictive-search-result-item" key={product.id}>
219
+ <Link to={productUrl} onClick={closeSearch}>
220
+ {image && (
221
+ <Image
222
+ alt={image.altText ?? ''}
223
+ src={image.url}
224
+ width={50}
225
+ height={50}
226
+ />
227
+ )}
228
+ <div>
229
+ <p>{product.title}</p>
230
+ <small>
231
+ {product?.variants?.nodes?.[0].price && (
232
+ <Money data={product.variants.nodes[0].price} />
233
+ )}
234
+ </small>
235
+ </div>
236
+ </Link>
237
+ </li>
238
+ );
239
+ })}
240
+ </ul>
241
+ </div>
242
+ );
243
+ }
244
+
245
+ function SearchResultsPredictiveQueries({
246
+ queries,
247
+ inputRef,
248
+ }: PartialPredictiveSearchResult<'queries', 'inputRef'>) {
249
+ if (!queries.length) return null;
250
+
251
+ return (
252
+ <div className="predictive-search-result" key="queries">
253
+ <h5>Queries</h5>
254
+ <ul>
255
+ {queries.map((suggestion) => {
256
+ if (!suggestion) return null;
257
+
258
+ return (
259
+ <li className="predictive-search-result-item" key={suggestion.text}>
260
+ <div
261
+ role="presentation"
262
+ onClick={() => {
263
+ if (!inputRef.current) return;
264
+ inputRef.current.value = suggestion.text;
265
+ inputRef.current.focus();
266
+ }}
267
+ dangerouslySetInnerHTML={{
268
+ __html: suggestion?.styledText,
269
+ }}
270
+ />
271
+ </li>
272
+ );
273
+ })}
274
+ </ul>
275
+ </div>
276
+ );
277
+ }
278
+
279
+ function SearchResultsPredictiveEmpty({
280
+ term,
281
+ }: {
282
+ term: React.MutableRefObject<string>;
283
+ }) {
284
+ if (!term.current) {
285
+ return null;
286
+ }
287
+
288
+ return (
289
+ <p>
290
+ No results found for <q>{term.current}</q>
291
+ </p>
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Hook that returns the predictive search results and fetcher and input ref.
297
+ * @example
298
+ * '''ts
299
+ * const { items, total, inputRef, term, fetcher } = usePredictiveSearch();
300
+ * '''
301
+ **/
302
+ function usePredictiveSearch(): UsePredictiveSearchReturn {
303
+ const fetcher = useFetcher<PredictiveSearchReturn>({key: 'search'});
304
+ const term = useRef<string>('');
305
+ const inputRef = useRef<HTMLInputElement | null>(null);
306
+
307
+ if (fetcher?.state === 'loading') {
308
+ term.current = String(fetcher.formData?.get('q') || '');
309
+ }
310
+
311
+ // capture the search input element as a ref
312
+ useEffect(() => {
313
+ if (!inputRef.current) {
314
+ inputRef.current = document.querySelector('input[type="search"]');
315
+ }
316
+ }, []);
317
+
318
+ const {items, total} =
319
+ fetcher?.data?.result ?? getEmptyPredictiveSearchResult();
320
+
321
+ return {items, total, inputRef, term, fetcher};
322
+ }
app/entry.client.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {RemixBrowser} from '@remix-run/react';
2
+ import {startTransition, StrictMode} from 'react';
3
+ import {hydrateRoot} from 'react-dom/client';
4
+
5
+ if (!window.location.origin.includes('webcache.googleusercontent.com')) {
6
+ startTransition(() => {
7
+ hydrateRoot(
8
+ document,
9
+ <StrictMode>
10
+ <RemixBrowser />
11
+ </StrictMode>,
12
+ );
13
+ });
14
+ }
app/entry.server.tsx ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ // @ts-ignore -- This is a Vite virtual module. It will be resolved at build time.
2
+ export {default} from 'virtual:netlify-server-entry';
app/graphql/customer-account/CustomerAddressMutations.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressUpdate
2
+ export const UPDATE_ADDRESS_MUTATION = `#graphql
3
+ mutation customerAddressUpdate(
4
+ $address: CustomerAddressInput!
5
+ $addressId: ID!
6
+ $defaultAddress: Boolean
7
+ ) {
8
+ customerAddressUpdate(
9
+ address: $address
10
+ addressId: $addressId
11
+ defaultAddress: $defaultAddress
12
+ ) {
13
+ customerAddress {
14
+ id
15
+ }
16
+ userErrors {
17
+ code
18
+ field
19
+ message
20
+ }
21
+ }
22
+ }
23
+ ` as const;
24
+
25
+ // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressDelete
26
+ export const DELETE_ADDRESS_MUTATION = `#graphql
27
+ mutation customerAddressDelete(
28
+ $addressId: ID!,
29
+ ) {
30
+ customerAddressDelete(addressId: $addressId) {
31
+ deletedAddressId
32
+ userErrors {
33
+ code
34
+ field
35
+ message
36
+ }
37
+ }
38
+ }
39
+ ` as const;
40
+
41
+ // NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressCreate
42
+ export const CREATE_ADDRESS_MUTATION = `#graphql
43
+ mutation customerAddressCreate(
44
+ $address: CustomerAddressInput!
45
+ $defaultAddress: Boolean
46
+ ) {
47
+ customerAddressCreate(
48
+ address: $address
49
+ defaultAddress: $defaultAddress
50
+ ) {
51
+ customerAddress {
52
+ id
53
+ }
54
+ userErrors {
55
+ code
56
+ field
57
+ message
58
+ }
59
+ }
60
+ }
61
+ ` as const;
app/graphql/customer-account/CustomerDetailsQuery.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NOTE: https://shopify.dev/docs/api/customer/latest/objects/Customer
2
+ export const CUSTOMER_FRAGMENT = `#graphql
3
+ fragment Customer on Customer {
4
+ id
5
+ firstName
6
+ lastName
7
+ defaultAddress {
8
+ ...Address
9
+ }
10
+ addresses(first: 6) {
11
+ nodes {
12
+ ...Address
13
+ }
14
+ }
15
+ }
16
+ fragment Address on CustomerAddress {
17
+ id
18
+ formatted
19
+ firstName
20
+ lastName
21
+ company
22
+ address1
23
+ address2
24
+ territoryCode
25
+ zoneCode
26
+ city
27
+ zip
28
+ phoneNumber
29
+ }
30
+ ` as const;
31
+
32
+ // NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
33
+ export const CUSTOMER_DETAILS_QUERY = `#graphql
34
+ query CustomerDetails {
35
+ customer {
36
+ ...Customer
37
+ }
38
+ }
39
+ ${CUSTOMER_FRAGMENT}
40
+ ` as const;
app/graphql/customer-account/CustomerOrderQuery.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NOTE: https://shopify.dev/docs/api/customer/latest/queries/order
2
+ export const CUSTOMER_ORDER_QUERY = `#graphql
3
+ fragment OrderMoney on MoneyV2 {
4
+ amount
5
+ currencyCode
6
+ }
7
+ fragment DiscountApplication on DiscountApplication {
8
+ value {
9
+ __typename
10
+ ... on MoneyV2 {
11
+ ...OrderMoney
12
+ }
13
+ ... on PricingPercentageValue {
14
+ percentage
15
+ }
16
+ }
17
+ }
18
+ fragment OrderLineItemFull on LineItem {
19
+ id
20
+ title
21
+ quantity
22
+ price {
23
+ ...OrderMoney
24
+ }
25
+ discountAllocations {
26
+ allocatedAmount {
27
+ ...OrderMoney
28
+ }
29
+ discountApplication {
30
+ ...DiscountApplication
31
+ }
32
+ }
33
+ totalDiscount {
34
+ ...OrderMoney
35
+ }
36
+ image {
37
+ altText
38
+ height
39
+ url
40
+ id
41
+ width
42
+ }
43
+ variantTitle
44
+ }
45
+ fragment Order on Order {
46
+ id
47
+ name
48
+ statusPageUrl
49
+ processedAt
50
+ fulfillments(first: 1) {
51
+ nodes {
52
+ status
53
+ }
54
+ }
55
+ totalTax {
56
+ ...OrderMoney
57
+ }
58
+ totalPrice {
59
+ ...OrderMoney
60
+ }
61
+ subtotal {
62
+ ...OrderMoney
63
+ }
64
+ shippingAddress {
65
+ name
66
+ formatted(withName: true)
67
+ formattedArea
68
+ }
69
+ discountApplications(first: 100) {
70
+ nodes {
71
+ ...DiscountApplication
72
+ }
73
+ }
74
+ lineItems(first: 100) {
75
+ nodes {
76
+ ...OrderLineItemFull
77
+ }
78
+ }
79
+ }
80
+ query Order($orderId: ID!) {
81
+ order(id: $orderId) {
82
+ ... on Order {
83
+ ...Order
84
+ }
85
+ }
86
+ }
87
+ ` as const;
app/graphql/customer-account/CustomerOrdersQuery.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // https://shopify.dev/docs/api/customer/latest/objects/Order
2
+ export const ORDER_ITEM_FRAGMENT = `#graphql
3
+ fragment OrderItem on Order {
4
+ totalPrice {
5
+ amount
6
+ currencyCode
7
+ }
8
+ financialStatus
9
+ fulfillments(first: 1) {
10
+ nodes {
11
+ status
12
+ }
13
+ }
14
+ id
15
+ number
16
+ processedAt
17
+ }
18
+ ` as const;
19
+
20
+ // https://shopify.dev/docs/api/customer/latest/objects/Customer
21
+ export const CUSTOMER_ORDERS_FRAGMENT = `#graphql
22
+ fragment CustomerOrders on Customer {
23
+ orders(
24
+ sortKey: PROCESSED_AT,
25
+ reverse: true,
26
+ first: $first,
27
+ last: $last,
28
+ before: $startCursor,
29
+ after: $endCursor
30
+ ) {
31
+ nodes {
32
+ ...OrderItem
33
+ }
34
+ pageInfo {
35
+ hasPreviousPage
36
+ hasNextPage
37
+ endCursor
38
+ startCursor
39
+ }
40
+ }
41
+ }
42
+ ${ORDER_ITEM_FRAGMENT}
43
+ ` as const;
44
+
45
+ // https://shopify.dev/docs/api/customer/latest/queries/customer
46
+ export const CUSTOMER_ORDERS_QUERY = `#graphql
47
+ ${CUSTOMER_ORDERS_FRAGMENT}
48
+ query CustomerOrders(
49
+ $endCursor: String
50
+ $first: Int
51
+ $last: Int
52
+ $startCursor: String
53
+ ) {
54
+ customer {
55
+ ...CustomerOrders
56
+ }
57
+ }
58
+ ` as const;
app/graphql/customer-account/CustomerUpdateMutation.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const CUSTOMER_UPDATE_MUTATION = `#graphql
2
+ # https://shopify.dev/docs/api/customer/latest/mutations/customerUpdate
3
+ mutation customerUpdate(
4
+ $customer: CustomerUpdateInput!
5
+ ){
6
+ customerUpdate(input: $customer) {
7
+ customer {
8
+ firstName
9
+ lastName
10
+ emailAddress {
11
+ emailAddress
12
+ }
13
+ phoneNumber {
14
+ phoneNumber
15
+ }
16
+ }
17
+ userErrors {
18
+ code
19
+ field
20
+ message
21
+ }
22
+ }
23
+ }
24
+ ` as const;
app/lib/context.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ createHydrogenContext,
3
+ type HydrogenContext,
4
+ InMemoryCache,
5
+ } from '@shopify/hydrogen';
6
+ import {AppSession} from '~/lib/session';
7
+ import {CART_QUERY_FRAGMENT} from '~/lib/fragments';
8
+
9
+ /**
10
+ * The context implementation is separate from server.ts
11
+ * so that type can be extracted for AppLoadContext
12
+ */
13
+ export async function createAppLoadContext(
14
+ request: Request,
15
+ env: Env,
16
+ executionContext: ExecutionContext,
17
+ ): Promise<HydrogenContext> {
18
+ /**
19
+ * Open a cache instance in the worker and a custom session instance.
20
+ */
21
+ if (!env?.SESSION_SECRET) {
22
+ throw new Error('SESSION_SECRET environment variable is not set');
23
+ }
24
+
25
+ const session = await AppSession.init(request, [env.SESSION_SECRET]);
26
+
27
+ const hydrogenContext = createHydrogenContext({
28
+ env,
29
+ request,
30
+ cache: new InMemoryCache(),
31
+ waitUntil: executionContext.waitUntil,
32
+ session,
33
+ i18n: {
34
+ language: 'EN',
35
+ country: 'US',
36
+ },
37
+ cart: {
38
+ queryFragment: CART_QUERY_FRAGMENT,
39
+ },
40
+ });
41
+
42
+ return {
43
+ ...hydrogenContext,
44
+ // add your custom context here
45
+ };
46
+ }
app/lib/fragments.ts ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
2
+ export const CART_QUERY_FRAGMENT = `#graphql
3
+ fragment Money on MoneyV2 {
4
+ currencyCode
5
+ amount
6
+ }
7
+ fragment CartLine on CartLine {
8
+ id
9
+ quantity
10
+ attributes {
11
+ key
12
+ value
13
+ }
14
+ cost {
15
+ totalAmount {
16
+ ...Money
17
+ }
18
+ amountPerQuantity {
19
+ ...Money
20
+ }
21
+ compareAtAmountPerQuantity {
22
+ ...Money
23
+ }
24
+ }
25
+ merchandise {
26
+ ... on ProductVariant {
27
+ id
28
+ availableForSale
29
+ compareAtPrice {
30
+ ...Money
31
+ }
32
+ price {
33
+ ...Money
34
+ }
35
+ requiresShipping
36
+ title
37
+ image {
38
+ id
39
+ url
40
+ altText
41
+ width
42
+ height
43
+
44
+ }
45
+ product {
46
+ handle
47
+ title
48
+ id
49
+ vendor
50
+ }
51
+ selectedOptions {
52
+ name
53
+ value
54
+ }
55
+ }
56
+ }
57
+ }
58
+ fragment CartLineComponent on ComponentizableCartLine {
59
+ id
60
+ quantity
61
+ attributes {
62
+ key
63
+ value
64
+ }
65
+ cost {
66
+ totalAmount {
67
+ ...Money
68
+ }
69
+ amountPerQuantity {
70
+ ...Money
71
+ }
72
+ compareAtAmountPerQuantity {
73
+ ...Money
74
+ }
75
+ }
76
+ merchandise {
77
+ ... on ProductVariant {
78
+ id
79
+ availableForSale
80
+ compareAtPrice {
81
+ ...Money
82
+ }
83
+ price {
84
+ ...Money
85
+ }
86
+ requiresShipping
87
+ title
88
+ image {
89
+ id
90
+ url
91
+ altText
92
+ width
93
+ height
94
+ }
95
+ product {
96
+ handle
97
+ title
98
+ id
99
+ vendor
100
+ }
101
+ selectedOptions {
102
+ name
103
+ value
104
+ }
105
+ }
106
+ }
107
+ }
108
+ fragment CartApiQuery on Cart {
109
+ updatedAt
110
+ id
111
+ checkoutUrl
112
+ totalQuantity
113
+ buyerIdentity {
114
+ countryCode
115
+ customer {
116
+ id
117
+ email
118
+ firstName
119
+ lastName
120
+ displayName
121
+ }
122
+ email
123
+ phone
124
+ }
125
+ lines(first: $numCartLines) {
126
+ nodes {
127
+ ...CartLine
128
+ }
129
+ nodes {
130
+ ...CartLineComponent
131
+ }
132
+ }
133
+ cost {
134
+ subtotalAmount {
135
+ ...Money
136
+ }
137
+ totalAmount {
138
+ ...Money
139
+ }
140
+ totalDutyAmount {
141
+ ...Money
142
+ }
143
+ totalTaxAmount {
144
+ ...Money
145
+ }
146
+ }
147
+ note
148
+ attributes {
149
+ key
150
+ value
151
+ }
152
+ discountCodes {
153
+ code
154
+ applicable
155
+ }
156
+ }
157
+ ` as const;
158
+
159
+ const MENU_FRAGMENT = `#graphql
160
+ fragment MenuItem on MenuItem {
161
+ id
162
+ resourceId
163
+ tags
164
+ title
165
+ type
166
+ url
167
+ }
168
+ fragment ChildMenuItem on MenuItem {
169
+ ...MenuItem
170
+ }
171
+ fragment ParentMenuItem on MenuItem {
172
+ ...MenuItem
173
+ items {
174
+ ...ChildMenuItem
175
+ }
176
+ }
177
+ fragment Menu on Menu {
178
+ id
179
+ items {
180
+ ...ParentMenuItem
181
+ }
182
+ }
183
+ ` as const;
184
+
185
+ export const HEADER_QUERY = `#graphql
186
+ fragment Shop on Shop {
187
+ id
188
+ name
189
+ description
190
+ primaryDomain {
191
+ url
192
+ }
193
+ brand {
194
+ logo {
195
+ image {
196
+ url
197
+ }
198
+ }
199
+ }
200
+ }
201
+ query Header(
202
+ $country: CountryCode
203
+ $headerMenuHandle: String!
204
+ $language: LanguageCode
205
+ ) @inContext(language: $language, country: $country) {
206
+ shop {
207
+ ...Shop
208
+ }
209
+ menu(handle: $headerMenuHandle) {
210
+ ...Menu
211
+ }
212
+ }
213
+ ${MENU_FRAGMENT}
214
+ ` as const;
215
+
216
+ export const FOOTER_QUERY = `#graphql
217
+ query Footer(
218
+ $country: CountryCode
219
+ $footerMenuHandle: String!
220
+ $language: LanguageCode
221
+ ) @inContext(language: $language, country: $country) {
222
+ menu(handle: $footerMenuHandle) {
223
+ ...Menu
224
+ }
225
+ }
226
+ ${MENU_FRAGMENT}
227
+ ` as const;
app/lib/search.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ PredictiveSearchQuery,
3
+ RegularSearchQuery,
4
+ } from 'storefrontapi.generated';
5
+
6
+ type ResultWithItems<Type extends 'predictive' | 'regular', Items> = {
7
+ type: Type;
8
+ term: string;
9
+ error?: string;
10
+ result: {total: number; items: Items};
11
+ };
12
+
13
+ export type RegularSearchReturn = ResultWithItems<
14
+ 'regular',
15
+ RegularSearchQuery
16
+ >;
17
+ export type PredictiveSearchReturn = ResultWithItems<
18
+ 'predictive',
19
+ NonNullable<PredictiveSearchQuery['predictiveSearch']>
20
+ >;
21
+
22
+ /**
23
+ * Returns the empty state of a predictive search result to reset the search state.
24
+ */
25
+ export function getEmptyPredictiveSearchResult(): PredictiveSearchReturn['result'] {
26
+ return {
27
+ total: 0,
28
+ items: {
29
+ articles: [],
30
+ collections: [],
31
+ products: [],
32
+ pages: [],
33
+ queries: [],
34
+ },
35
+ };
36
+ }
37
+
38
+ interface UrlWithTrackingParams {
39
+ /** The base URL to which the tracking parameters will be appended. */
40
+ baseUrl: string;
41
+ /** The trackingParams returned by the Storefront API. */
42
+ trackingParams?: string | null;
43
+ /** Any additional query parameters to be appended to the URL. */
44
+ params?: Record<string, string>;
45
+ /** The search term to be appended to the URL. */
46
+ term: string;
47
+ }
48
+
49
+ /**
50
+ * A utility function that appends tracking parameters to a URL. Tracking parameters are
51
+ * used internally by shopify to enhance search results and admin dashboards.
52
+ * @example
53
+ * ```ts
54
+ * const url = 'www.example.com';
55
+ * const trackingParams = 'utm_source=shopify&utm_medium=shopify_app&utm_campaign=storefront';
56
+ * const params = { foo: 'bar' };
57
+ * const term = 'search term';
58
+ * const url = urlWithTrackingParams({ baseUrl, trackingParams, params, term });
59
+ * console.log(url);
60
+ * // Output: 'https://www.example.com?foo=bar&q=search%20term&utm_source=shopify&utm_medium=shopify_app&utm_campaign=storefront'
61
+ * ```
62
+ */
63
+ export function urlWithTrackingParams({
64
+ baseUrl,
65
+ trackingParams,
66
+ params: extraParams,
67
+ term,
68
+ }: UrlWithTrackingParams) {
69
+ let search = new URLSearchParams({
70
+ ...extraParams,
71
+ q: encodeURIComponent(term),
72
+ }).toString();
73
+
74
+ if (trackingParams) {
75
+ search = `${search}&${trackingParams}`;
76
+ }
77
+
78
+ return `${baseUrl}?${search}`;
79
+ }
app/lib/session.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {HydrogenSession} from '@shopify/hydrogen';
2
+ import {
3
+ createCookieSessionStorage,
4
+ type SessionStorage,
5
+ type Session,
6
+ } from '@netlify/remix-runtime';
7
+
8
+ /**
9
+ * This is a custom session implementation for your Hydrogen shop.
10
+ * Feel free to customize it to your needs, add helper methods, or
11
+ * swap out the cookie-based implementation with something else!
12
+ */
13
+ export class AppSession implements HydrogenSession {
14
+ public isPending = false;
15
+
16
+ #sessionStorage;
17
+ #session;
18
+
19
+ constructor(sessionStorage: SessionStorage, session: Session) {
20
+ this.#sessionStorage = sessionStorage;
21
+ this.#session = session;
22
+ }
23
+
24
+ static async init(request: Request, secrets: string[]) {
25
+ const storage = createCookieSessionStorage({
26
+ cookie: {
27
+ name: 'session',
28
+ httpOnly: true,
29
+ path: '/',
30
+ sameSite: 'lax',
31
+ secrets,
32
+ },
33
+ });
34
+
35
+ const session = await storage
36
+ .getSession(request.headers.get('Cookie'))
37
+ .catch(() => storage.getSession());
38
+
39
+ return new this(storage, session);
40
+ }
41
+
42
+ get has() {
43
+ return this.#session.has;
44
+ }
45
+
46
+ get get() {
47
+ return this.#session.get;
48
+ }
49
+
50
+ get flash() {
51
+ return this.#session.flash;
52
+ }
53
+
54
+ get unset() {
55
+ this.isPending = true;
56
+ return this.#session.unset;
57
+ }
58
+
59
+ get set() {
60
+ this.isPending = true;
61
+ return this.#session.set;
62
+ }
63
+
64
+ destroy() {
65
+ return this.#sessionStorage.destroySession(this.#session);
66
+ }
67
+
68
+ commit() {
69
+ this.isPending = false;
70
+ return this.#sessionStorage.commitSession(this.#session);
71
+ }
72
+ }
app/lib/variants.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {useLocation} from '@remix-run/react';
2
+ import type {SelectedOption} from '@shopify/hydrogen/storefront-api-types';
3
+ import {useMemo} from 'react';
4
+
5
+ export function useVariantUrl(
6
+ handle: string,
7
+ selectedOptions: SelectedOption[],
8
+ ) {
9
+ const {pathname} = useLocation();
10
+
11
+ return useMemo(() => {
12
+ return getVariantUrl({
13
+ handle,
14
+ pathname,
15
+ searchParams: new URLSearchParams(),
16
+ selectedOptions,
17
+ });
18
+ }, [handle, selectedOptions, pathname]);
19
+ }
20
+
21
+ export function getVariantUrl({
22
+ handle,
23
+ pathname,
24
+ searchParams,
25
+ selectedOptions,
26
+ }: {
27
+ handle: string;
28
+ pathname: string;
29
+ searchParams: URLSearchParams;
30
+ selectedOptions: SelectedOption[];
31
+ }) {
32
+ const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
33
+ const isLocalePathname = match && match.length > 0;
34
+
35
+ const path = isLocalePathname
36
+ ? `${match![0]}products/${handle}`
37
+ : `/products/${handle}`;
38
+
39
+ selectedOptions.forEach((option) => {
40
+ searchParams.set(option.name, option.value);
41
+ });
42
+
43
+ const searchString = searchParams.toString();
44
+
45
+ return path + (searchString ? '?' + searchParams.toString() : '');
46
+ }
app/root.tsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {useNonce, getShopAnalytics, Analytics} from '@shopify/hydrogen';
2
+ import {defer, type LoaderFunctionArgs} from '@netlify/remix-runtime';
3
+ import {
4
+ Links,
5
+ Meta,
6
+ Outlet,
7
+ Scripts,
8
+ useRouteError,
9
+ useRouteLoaderData,
10
+ ScrollRestoration,
11
+ isRouteErrorResponse,
12
+ type ShouldRevalidateFunction,
13
+ } from '@remix-run/react';
14
+ import favicon from '~/assets/favicon.svg';
15
+ import resetStyles from '~/styles/reset.css?url';
16
+ import appStyles from '~/styles/app.css?url';
17
+ import {PageLayout} from '~/components/PageLayout';
18
+ import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments';
19
+
20
+ export type RootLoader = typeof loader;
21
+
22
+ /**
23
+ * This is important to avoid re-fetching root queries on sub-navigations
24
+ */
25
+ export const shouldRevalidate: ShouldRevalidateFunction = ({
26
+ formMethod,
27
+ currentUrl,
28
+ nextUrl,
29
+ defaultShouldRevalidate,
30
+ }) => {
31
+ // revalidate when a mutation is performed e.g add to cart, login...
32
+ if (formMethod && formMethod !== 'GET') return true;
33
+
34
+ // revalidate when manually revalidating via useRevalidator
35
+ if (currentUrl.toString() === nextUrl.toString()) return true;
36
+
37
+ return defaultShouldRevalidate;
38
+ };
39
+
40
+ export function links() {
41
+ return [
42
+ {rel: 'stylesheet', href: resetStyles},
43
+ {rel: 'stylesheet', href: appStyles},
44
+ {
45
+ rel: 'preconnect',
46
+ href: 'https://cdn.shopify.com',
47
+ },
48
+ {
49
+ rel: 'preconnect',
50
+ href: 'https://shop.app',
51
+ },
52
+ {rel: 'icon', type: 'image/svg+xml', href: favicon},
53
+ ];
54
+ }
55
+
56
+ export async function loader(args: LoaderFunctionArgs) {
57
+ // Start fetching non-critical data without blocking time to first byte
58
+ const deferredData = loadDeferredData(args);
59
+
60
+ // Await the critical data required to render initial state of the page
61
+ const criticalData = await loadCriticalData(args);
62
+
63
+ const {storefront, env} = args.context;
64
+
65
+ return defer({
66
+ ...deferredData,
67
+ ...criticalData,
68
+ publicStoreDomain: env.PUBLIC_STORE_DOMAIN,
69
+ shop: getShopAnalytics({
70
+ storefront,
71
+ publicStorefrontId: env.PUBLIC_STOREFRONT_ID,
72
+ }),
73
+ consent: {
74
+ checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
75
+ storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN,
76
+ },
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Load data necessary for rendering content above the fold. This is the critical data
82
+ * needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
83
+ */
84
+ async function loadCriticalData({context}: LoaderFunctionArgs) {
85
+ const {storefront} = context;
86
+
87
+ const [header] = await Promise.all([
88
+ storefront.query(HEADER_QUERY, {
89
+ cache: storefront.CacheLong(),
90
+ variables: {
91
+ headerMenuHandle: 'main-menu', // Adjust to your header menu handle
92
+ },
93
+ }),
94
+ // Add other queries here, so that they are loaded in parallel
95
+ ]);
96
+
97
+ return {
98
+ header,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Load data for rendering content below the fold. This data is deferred and will be
104
+ * fetched after the initial page load. If it's unavailable, the page should still 200.
105
+ * Make sure to not throw any errors here, as it will cause the page to 500.
106
+ */
107
+ function loadDeferredData({context}: LoaderFunctionArgs) {
108
+ const {storefront, customerAccount, cart} = context;
109
+
110
+ // defer the footer query (below the fold)
111
+ const footer = storefront
112
+ .query(FOOTER_QUERY, {
113
+ cache: storefront.CacheLong(),
114
+ variables: {
115
+ footerMenuHandle: 'footer', // Adjust to your footer menu handle
116
+ },
117
+ })
118
+ .catch((error) => {
119
+ // Log query errors, but don't throw them so the page can still render
120
+ console.error(error);
121
+ return null;
122
+ });
123
+ return {
124
+ cart: cart.get(),
125
+ isLoggedIn: customerAccount.isLoggedIn(),
126
+ footer,
127
+ };
128
+ }
129
+
130
+ export function Layout({children}: {children?: React.ReactNode}) {
131
+ const nonce = useNonce();
132
+ const data = useRouteLoaderData<RootLoader>('root');
133
+
134
+ return (
135
+ <html lang="en">
136
+ <head>
137
+ <meta charSet="utf-8" />
138
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
139
+ <Meta />
140
+ <Links />
141
+ </head>
142
+ <body>
143
+ {data ? (
144
+ <Analytics.Provider
145
+ cart={data.cart}
146
+ shop={data.shop}
147
+ consent={data.consent}
148
+ >
149
+ <PageLayout {...data}>{children}</PageLayout>
150
+ </Analytics.Provider>
151
+ ) : (
152
+ children
153
+ )}
154
+ <ScrollRestoration nonce={nonce} />
155
+ <Scripts nonce={nonce} />
156
+ </body>
157
+ </html>
158
+ );
159
+ }
160
+
161
+ export default function App() {
162
+ return <Outlet />;
163
+ }
164
+
165
+ export function ErrorBoundary() {
166
+ const error = useRouteError();
167
+ let errorMessage = 'Unknown error';
168
+ let errorStatus = 500;
169
+
170
+ if (isRouteErrorResponse(error)) {
171
+ errorMessage = error?.data?.message ?? error.data;
172
+ errorStatus = error.status;
173
+ } else if (error instanceof Error) {
174
+ errorMessage = error.message;
175
+ }
176
+
177
+ return (
178
+ <div className="route-error">
179
+ <h1>Oops</h1>
180
+ <h2>{errorStatus}</h2>
181
+ {errorMessage && (
182
+ <fieldset>
183
+ <pre>{errorMessage}</pre>
184
+ </fieldset>
185
+ )}
186
+ </div>
187
+ );
188
+ }
app/routes/$.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {LoaderFunctionArgs} from '@netlify/remix-runtime';
2
+
3
+ export async function loader({request}: LoaderFunctionArgs) {
4
+ throw new Response(`${new URL(request.url).pathname} not found`, {
5
+ status: 404,
6
+ });
7
+ }
8
+
9
+ export default function CatchAllPage() {
10
+ return null;
11
+ }
app/routes/[robots.txt].tsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {type LoaderFunctionArgs} from '@netlify/remix-runtime';
2
+ import {useRouteError, isRouteErrorResponse} from '@remix-run/react';
3
+ import {parseGid} from '@shopify/hydrogen';
4
+
5
+ export async function loader({request, context}: LoaderFunctionArgs) {
6
+ const url = new URL(request.url);
7
+
8
+ const {shop} = await context.storefront.query(ROBOTS_QUERY);
9
+
10
+ const shopId = parseGid(shop.id).id;
11
+ const body = robotsTxtData({url: url.origin, shopId});
12
+
13
+ return new Response(body, {
14
+ status: 200,
15
+ headers: {
16
+ 'Content-Type': 'text/plain',
17
+
18
+ 'Cache-Control': `max-age=${60 * 60 * 24}`,
19
+ },
20
+ });
21
+ }
22
+
23
+ function robotsTxtData({url, shopId}: {shopId?: string; url?: string}) {
24
+ const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
25
+
26
+ return `
27
+ User-agent: *
28
+ ${generalDisallowRules({sitemapUrl, shopId})}
29
+
30
+ # Google adsbot ignores robots.txt unless specifically named!
31
+ User-agent: adsbot-google
32
+ Disallow: /checkouts/
33
+ Disallow: /checkout
34
+ Disallow: /carts
35
+ Disallow: /orders
36
+ ${shopId ? `Disallow: /${shopId}/checkouts` : ''}
37
+ ${shopId ? `Disallow: /${shopId}/orders` : ''}
38
+ Disallow: /*?*oseid=*
39
+ Disallow: /*preview_theme_id*
40
+ Disallow: /*preview_script_id*
41
+
42
+ User-agent: Nutch
43
+ Disallow: /
44
+
45
+ User-agent: AhrefsBot
46
+ Crawl-delay: 10
47
+ ${generalDisallowRules({sitemapUrl, shopId})}
48
+
49
+ User-agent: AhrefsSiteAudit
50
+ Crawl-delay: 10
51
+ ${generalDisallowRules({sitemapUrl, shopId})}
52
+
53
+ User-agent: MJ12bot
54
+ Crawl-Delay: 10
55
+
56
+ User-agent: Pinterest
57
+ Crawl-delay: 1
58
+ `.trim();
59
+ }
60
+
61
+ /**
62
+ * This function generates disallow rules that generally follow what Shopify's
63
+ * Online Store has as defaults for their robots.txt
64
+ */
65
+ function generalDisallowRules({
66
+ shopId,
67
+ sitemapUrl,
68
+ }: {
69
+ shopId?: string;
70
+ sitemapUrl?: string;
71
+ }) {
72
+ return `Disallow: /admin
73
+ Disallow: /cart
74
+ Disallow: /orders
75
+ Disallow: /checkouts/
76
+ Disallow: /checkout
77
+ ${shopId ? `Disallow: /${shopId}/checkouts` : ''}
78
+ ${shopId ? `Disallow: /${shopId}/orders` : ''}
79
+ Disallow: /carts
80
+ Disallow: /account
81
+ Disallow: /collections/*sort_by*
82
+ Disallow: /*/collections/*sort_by*
83
+ Disallow: /collections/*+*
84
+ Disallow: /collections/*%2B*
85
+ Disallow: /collections/*%2b*
86
+ Disallow: /*/collections/*+*
87
+ Disallow: /*/collections/*%2B*
88
+ Disallow: /*/collections/*%2b*
89
+ Disallow: */collections/*filter*&*filter*
90
+ Disallow: /blogs/*+*
91
+ Disallow: /blogs/*%2B*
92
+ Disallow: /blogs/*%2b*
93
+ Disallow: /*/blogs/*+*
94
+ Disallow: /*/blogs/*%2B*
95
+ Disallow: /*/blogs/*%2b*
96
+ Disallow: /*?*oseid=*
97
+ Disallow: /*preview_theme_id*
98
+ Disallow: /*preview_script_id*
99
+ Disallow: /policies/
100
+ Disallow: /*/*?*ls=*&ls=*
101
+ Disallow: /*/*?*ls%3D*%3Fls%3D*
102
+ Disallow: /*/*?*ls%3d*%3fls%3d*
103
+ Disallow: /search
104
+ Allow: /search/
105
+ Disallow: /search/?*
106
+ Disallow: /apple-app-site-association
107
+ Disallow: /.well-known/shopify/monorail
108
+ ${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}`;
109
+ }
110
+
111
+ const ROBOTS_QUERY = `#graphql
112
+ query StoreRobots($country: CountryCode, $language: LanguageCode)
113
+ @inContext(country: $country, language: $language) {
114
+ shop {
115
+ id
116
+ }
117
+ }
118
+ ` as const;
app/routes/[sitemap.xml].tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {flattenConnection} from '@shopify/hydrogen';
2
+ import type {LoaderFunctionArgs} from '@netlify/remix-runtime';
3
+ import type {SitemapQuery} from 'storefrontapi.generated';
4
+
5
+ /**
6
+ * the google limit is 50K, however, the storefront API
7
+ * allows querying only 250 resources per pagination page
8
+ */
9
+ const MAX_URLS = 250;
10
+
11
+ type Entry = {
12
+ url: string;
13
+ lastMod?: string;
14
+ changeFreq?: string;
15
+ image?: {
16
+ url: string;
17
+ title?: string;
18
+ caption?: string;
19
+ };
20
+ };
21
+
22
+ export async function loader({
23
+ request,
24
+ context: {storefront},
25
+ }: LoaderFunctionArgs) {
26
+ const data = await storefront.query(SITEMAP_QUERY, {
27
+ variables: {
28
+ urlLimits: MAX_URLS,
29
+ language: storefront.i18n.language,
30
+ },
31
+ });
32
+
33
+ if (!data) {
34
+ throw new Response('No data found', {status: 404});
35
+ }
36
+
37
+ const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin});
38
+
39
+ return new Response(sitemap, {
40
+ headers: {
41
+ 'Content-Type': 'application/xml',
42
+
43
+ 'Cache-Control': `max-age=${60 * 60 * 24}`,
44
+ },
45
+ });
46
+ }
47
+
48
+ function xmlEncode(string: string) {
49
+ return string.replace(/[&<>'"]/g, (char) => `&#${char.charCodeAt(0)};`);
50
+ }
51
+
52
+ function generateSitemap({
53
+ data,
54
+ baseUrl,
55
+ }: {
56
+ data: SitemapQuery;
57
+ baseUrl: string;
58
+ }) {
59
+ const products = flattenConnection(data.products)
60
+ .filter((product) => product.onlineStoreUrl)
61
+ .map((product) => {
62
+ const url = `${baseUrl}/products/${xmlEncode(product.handle)}`;
63
+
64
+ const productEntry: Entry = {
65
+ url,
66
+ lastMod: product.updatedAt,
67
+ changeFreq: 'daily',
68
+ };
69
+
70
+ if (product.featuredImage?.url) {
71
+ productEntry.image = {
72
+ url: xmlEncode(product.featuredImage.url),
73
+ };
74
+
75
+ if (product.title) {
76
+ productEntry.image.title = xmlEncode(product.title);
77
+ }
78
+
79
+ if (product.featuredImage.altText) {
80
+ productEntry.image.caption = xmlEncode(product.featuredImage.altText);
81
+ }
82
+ }
83
+
84
+ return productEntry;
85
+ });
86
+
87
+ const collections = flattenConnection(data.collections)
88
+ .filter((collection) => collection.onlineStoreUrl)
89
+ .map((collection) => {
90
+ const url = `${baseUrl}/collections/${collection.handle}`;
91
+
92
+ return {
93
+ url,
94
+ lastMod: collection.updatedAt,
95
+ changeFreq: 'daily',
96
+ };
97
+ });
98
+
99
+ const pages = flattenConnection(data.pages)
100
+ .filter((page) => page.onlineStoreUrl)
101
+ .map((page) => {
102
+ const url = `${baseUrl}/pages/${page.handle}`;
103
+
104
+ return {
105
+ url,
106
+ lastMod: page.updatedAt,
107
+ changeFreq: 'weekly',
108
+ };
109
+ });
110
+
111
+ const urls = [...products, ...collections, ...pages];
112
+
113
+ return `
114
+ <urlset
115
+ xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
116
+ xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
117
+ >
118
+ ${urls.map(renderUrlTag).join('')}
119
+ </urlset>`;
120
+ }
121
+
122
+ function renderUrlTag({url, lastMod, changeFreq, image}: Entry) {
123
+ const imageTag = image
124
+ ? `<image:image>
125
+ <image:loc>${image.url}</image:loc>
126
+ <image:title>${image.title ?? ''}</image:title>
127
+ <image:caption>${image.caption ?? ''}</image:caption>
128
+ </image:image>`.trim()
129
+ : '';
130
+
131
+ return `
132
+ <url>
133
+ <loc>${url}</loc>
134
+ <lastmod>${lastMod}</lastmod>
135
+ <changefreq>${changeFreq}</changefreq>
136
+ ${imageTag}
137
+ </url>
138
+ `.trim();
139
+ }
140
+
141
+ const SITEMAP_QUERY = `#graphql
142
+ query Sitemap($urlLimits: Int, $language: LanguageCode)
143
+ @inContext(language: $language) {
144
+ products(
145
+ first: $urlLimits
146
+ query: "published_status:'online_store:visible'"
147
+ ) {
148
+ nodes {
149
+ updatedAt
150
+ handle
151
+ onlineStoreUrl
152
+ title
153
+ featuredImage {
154
+ url
155
+ altText
156
+ }
157
+ }
158
+ }
159
+ collections(
160
+ first: $urlLimits
161
+ query: "published_status:'online_store:visible'"
162
+ ) {
163
+ nodes {
164
+ updatedAt
165
+ handle
166
+ onlineStoreUrl
167
+ }
168
+ }
169
+ pages(first: $urlLimits, query: "published_status:'published'") {
170
+ nodes {
171
+ updatedAt
172
+ handle
173
+ onlineStoreUrl
174
+ }
175
+ }
176
+ }
177
+ ` as const;
app/routes/_index.tsx ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {defer, type LoaderFunctionArgs} from '@netlify/remix-runtime';
2
+ import {Await, useLoaderData, Link, type MetaFunction} from '@remix-run/react';
3
+ import {Suspense} from 'react';
4
+ import {Image, Money} from '@shopify/hydrogen';
5
+ import type {
6
+ FeaturedCollectionFragment,
7
+ RecommendedProductsQuery,
8
+ } from 'storefrontapi.generated';
9
+
10
+ export const meta: MetaFunction = () => {
11
+ return [{title: 'Hydrogen | Home'}];
12
+ };
13
+
14
+ export async function loader(args: LoaderFunctionArgs) {
15
+ // Start fetching non-critical data without blocking time to first byte
16
+ const deferredData = loadDeferredData(args);
17
+
18
+ // Await the critical data required to render initial state of the page
19
+ const criticalData = await loadCriticalData(args);
20
+
21
+ return defer({...deferredData, ...criticalData});
22
+ }
23
+
24
+ /**
25
+ * Load data necessary for rendering content above the fold. This is the critical data
26
+ * needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
27
+ */
28
+ async function loadCriticalData({context}: LoaderFunctionArgs) {
29
+ const [{collections}] = await Promise.all([
30
+ context.storefront.query(FEATURED_COLLECTION_QUERY),
31
+ // Add other queries here, so that they are loaded in parallel
32
+ ]);
33
+
34
+ return {
35
+ featuredCollection: collections.nodes[0],
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Load data for rendering content below the fold. This data is deferred and will be
41
+ * fetched after the initial page load. If it's unavailable, the page should still 200.
42
+ * Make sure to not throw any errors here, as it will cause the page to 500.
43
+ */
44
+ function loadDeferredData({context}: LoaderFunctionArgs) {
45
+ const recommendedProducts = context.storefront
46
+ .query(RECOMMENDED_PRODUCTS_QUERY)
47
+ .catch((error) => {
48
+ // Log query errors, but don't throw them so the page can still render
49
+ console.error(error);
50
+ return null;
51
+ });
52
+
53
+ return {
54
+ recommendedProducts,
55
+ };
56
+ }
57
+
58
+ export default function Homepage() {
59
+ const data = useLoaderData<typeof loader>();
60
+ return (
61
+ <div className="home">
62
+ <HeroSection />
63
+ <FeaturedCollection collection={data.featuredCollection} />
64
+ <RecommendedProducts products={data.recommendedProducts} />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function HeroSection() {
70
+ return (
71
+ <div className="hero-section" style={{
72
+ position: 'relative',
73
+ height: '60vh',
74
+ minHeight: '400px',
75
+ display: 'flex',
76
+ alignItems: 'center',
77
+ justifyContent: 'center',
78
+ overflow: 'hidden',
79
+ marginBottom: '4rem',
80
+ borderRadius: 'var(--border-radius)',
81
+ margin: '1rem 2rem 4rem 2rem'
82
+ }}>
83
+ <img
84
+ src="/app/assets/hero-banner.jpg"
85
+ alt="Future Tech Store"
86
+ style={{
87
+ position: 'absolute',
88
+ top: 0,
89
+ left: 0,
90
+ width: '100%',
91
+ height: '100%',
92
+ objectFit: 'cover',
93
+ zIndex: -1
94
+ }}
95
+ />
96
+ <div className="hero-content" style={{
97
+ textAlign: 'center',
98
+ color: 'white',
99
+ background: 'rgba(0, 0, 0, 0.3)',
100
+ padding: '2rem 4rem',
101
+ borderRadius: 'var(--border-radius)',
102
+ backdropFilter: 'blur(8px)'
103
+ }}>
104
+ <h1 style={{ fontSize: '3.5rem', marginBottom: '1rem', fontWeight: '800' }}>مستقبل التسوق هنا</h1>
105
+ <p style={{ fontSize: '1.25rem', marginBottom: '2rem' }}>اكتشف أحدث التقنيات والأجهزة الذكية لعام 2026</p>
106
+ <Link to="/collections/all" className="header-menu-item" style={{
107
+ background: 'var(--color-accent)',
108
+ color: 'white',
109
+ padding: '1rem 2.5rem',
110
+ fontSize: '1.1rem'
111
+ }}>
112
+ تسوق الآن
113
+ </Link>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ function FeaturedCollection({
120
+ collection,
121
+ }: {
122
+ collection: FeaturedCollectionFragment;
123
+ }) {
124
+ if (!collection) return null;
125
+ const image = collection?.image;
126
+ return (
127
+ <Link
128
+ className="featured-collection"
129
+ to={`/collections/${collection.handle}`}
130
+ >
131
+ {image && (
132
+ <div className="featured-collection-image">
133
+ <Image data={image} sizes="100vw" />
134
+ </div>
135
+ )}
136
+ <h1>{collection.title}</h1>
137
+ </Link>
138
+ );
139
+ }
140
+
141
+ function RecommendedProducts({
142
+ products,
143
+ }: {
144
+ products: Promise<RecommendedProductsQuery | null>;
145
+ }) {
146
+ return (
147
+ <div className="recommended-products">
148
+ <h2>Recommended Products</h2>
149
+ <Suspense fallback={<div>Loading...</div>}>
150
+ <Await resolve={products}>
151
+ {(response) => (
152
+ <div className="recommended-products-grid">
153
+ {response
154
+ ? response.products.nodes.map((product) => (
155
+ <Link
156
+ key={product.id}
157
+ className="recommended-product"
158
+ to={`/products/${product.handle}`}
159
+ >
160
+ <Image
161
+ data={product.images.nodes[0]}
162
+ aspectRatio="1/1"
163
+ sizes="(min-width: 45em) 20vw, 50vw"
164
+ />
165
+ <h4>{product.title}</h4>
166
+ <small>
167
+ <Money data={product.priceRange.minVariantPrice} />
168
+ </small>
169
+ </Link>
170
+ ))
171
+ : null}
172
+ </div>
173
+ )}
174
+ </Await>
175
+ </Suspense>
176
+ <br />
177
+ </div>
178
+ );
179
+ }
180
+
181
+ const FEATURED_COLLECTION_QUERY = `#graphql
182
+ fragment FeaturedCollection on Collection {
183
+ id
184
+ title
185
+ image {
186
+ id
187
+ url
188
+ altText
189
+ width
190
+ height
191
+ }
192
+ handle
193
+ }
194
+ query FeaturedCollection($country: CountryCode, $language: LanguageCode)
195
+ @inContext(country: $country, language: $language) {
196
+ collections(first: 1, sortKey: UPDATED_AT, reverse: true) {
197
+ nodes {
198
+ ...FeaturedCollection
199
+ }
200
+ }
201
+ }
202
+ ` as const;
203
+
204
+ const RECOMMENDED_PRODUCTS_QUERY = `#graphql
205
+ fragment RecommendedProduct on Product {
206
+ id
207
+ title
208
+ handle
209
+ priceRange {
210
+ minVariantPrice {
211
+ amount
212
+ currencyCode
213
+ }
214
+ }
215
+ images(first: 1) {
216
+ nodes {
217
+ id
218
+ url
219
+ altText
220
+ width
221
+ height
222
+ }
223
+ }
224
+ }
225
+ query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
226
+ @inContext(country: $country, language: $language) {
227
+ products(first: 4, sortKey: UPDATED_AT, reverse: true) {
228
+ nodes {
229
+ ...RecommendedProduct
230
+ }
231
+ }
232
+ }
233
+ ` as const;
app/routes/account.$.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import {redirect, type LoaderFunctionArgs} from '@netlify/remix-runtime';
2
+
3
+ // fallback wild card for all unauthenticated routes in account section
4
+ export async function loader({context}: LoaderFunctionArgs) {
5
+ await context.customerAccount.handleAuthStatus();
6
+
7
+ return redirect('/account');
8
+ }
app/routes/account._index.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import {redirect} from '@netlify/remix-runtime';
2
+
3
+ export async function loader() {
4
+ return redirect('/account/orders');
5
+ }
app/routes/account.addresses.tsx ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types';
2
+ import type {
3
+ AddressFragment,
4
+ CustomerFragment,
5
+ } from 'customer-accountapi.generated';
6
+ import {
7
+ json,
8
+ type ActionFunctionArgs,
9
+ type LoaderFunctionArgs,
10
+ } from '@netlify/remix-runtime';
11
+ import {
12
+ Form,
13
+ useActionData,
14
+ useNavigation,
15
+ useOutletContext,
16
+ type MetaFunction,
17
+ type Fetcher,
18
+ } from '@remix-run/react';
19
+ import {
20
+ UPDATE_ADDRESS_MUTATION,
21
+ DELETE_ADDRESS_MUTATION,
22
+ CREATE_ADDRESS_MUTATION,
23
+ } from '~/graphql/customer-account/CustomerAddressMutations';
24
+
25
+ export type ActionResponse = {
26
+ addressId?: string | null;
27
+ createdAddress?: AddressFragment;
28
+ defaultAddress?: string | null;
29
+ deletedAddress?: string | null;
30
+ error: Record<AddressFragment['id'], string> | null;
31
+ updatedAddress?: AddressFragment;
32
+ };
33
+
34
+ export const meta: MetaFunction = () => {
35
+ return [{title: 'Addresses'}];
36
+ };
37
+
38
+ export async function loader({context}: LoaderFunctionArgs) {
39
+ await context.customerAccount.handleAuthStatus();
40
+
41
+ return json({});
42
+ }
43
+
44
+ export async function action({request, context}: ActionFunctionArgs) {
45
+ const {customerAccount} = context;
46
+
47
+ try {
48
+ const form = await request.formData();
49
+
50
+ const addressId = form.has('addressId')
51
+ ? String(form.get('addressId'))
52
+ : null;
53
+ if (!addressId) {
54
+ throw new Error('You must provide an address id.');
55
+ }
56
+
57
+ // this will ensure redirecting to login never happen for mutatation
58
+ const isLoggedIn = await customerAccount.isLoggedIn();
59
+ if (!isLoggedIn) {
60
+ return json(
61
+ {error: {[addressId]: 'Unauthorized'}},
62
+ {
63
+ status: 401,
64
+ },
65
+ );
66
+ }
67
+
68
+ const defaultAddress = form.has('defaultAddress')
69
+ ? String(form.get('defaultAddress')) === 'on'
70
+ : false;
71
+ const address: CustomerAddressInput = {};
72
+ const keys: (keyof CustomerAddressInput)[] = [
73
+ 'address1',
74
+ 'address2',
75
+ 'city',
76
+ 'company',
77
+ 'territoryCode',
78
+ 'firstName',
79
+ 'lastName',
80
+ 'phoneNumber',
81
+ 'zoneCode',
82
+ 'zip',
83
+ ];
84
+
85
+ for (const key of keys) {
86
+ const value = form.get(key);
87
+ if (typeof value === 'string') {
88
+ address[key] = value;
89
+ }
90
+ }
91
+
92
+ switch (request.method) {
93
+ case 'POST': {
94
+ // handle new address creation
95
+ try {
96
+ const {data, errors} = await customerAccount.mutate(
97
+ CREATE_ADDRESS_MUTATION,
98
+ {
99
+ variables: {address, defaultAddress},
100
+ },
101
+ );
102
+
103
+ if (errors?.length) {
104
+ throw new Error(errors[0].message);
105
+ }
106
+
107
+ if (data?.customerAddressCreate?.userErrors?.length) {
108
+ throw new Error(data?.customerAddressCreate?.userErrors[0].message);
109
+ }
110
+
111
+ if (!data?.customerAddressCreate?.customerAddress) {
112
+ throw new Error('Customer address create failed.');
113
+ }
114
+
115
+ return json({
116
+ error: null,
117
+ createdAddress: data?.customerAddressCreate?.customerAddress,
118
+ defaultAddress,
119
+ });
120
+ } catch (error: unknown) {
121
+ if (error instanceof Error) {
122
+ return json(
123
+ {error: {[addressId]: error.message}},
124
+ {
125
+ status: 400,
126
+ },
127
+ );
128
+ }
129
+ return json(
130
+ {error: {[addressId]: error}},
131
+ {
132
+ status: 400,
133
+ },
134
+ );
135
+ }
136
+ }
137
+
138
+ case 'PUT': {
139
+ // handle address updates
140
+ try {
141
+ const {data, errors} = await customerAccount.mutate(
142
+ UPDATE_ADDRESS_MUTATION,
143
+ {
144
+ variables: {
145
+ address,
146
+ addressId: decodeURIComponent(addressId),
147
+ defaultAddress,
148
+ },
149
+ },
150
+ );
151
+
152
+ if (errors?.length) {
153
+ throw new Error(errors[0].message);
154
+ }
155
+
156
+ if (data?.customerAddressUpdate?.userErrors?.length) {
157
+ throw new Error(data?.customerAddressUpdate?.userErrors[0].message);
158
+ }
159
+
160
+ if (!data?.customerAddressUpdate?.customerAddress) {
161
+ throw new Error('Customer address update failed.');
162
+ }
163
+
164
+ return json({
165
+ error: null,
166
+ updatedAddress: address,
167
+ defaultAddress,
168
+ });
169
+ } catch (error: unknown) {
170
+ if (error instanceof Error) {
171
+ return json(
172
+ {error: {[addressId]: error.message}},
173
+ {
174
+ status: 400,
175
+ },
176
+ );
177
+ }
178
+ return json(
179
+ {error: {[addressId]: error}},
180
+ {
181
+ status: 400,
182
+ },
183
+ );
184
+ }
185
+ }
186
+
187
+ case 'DELETE': {
188
+ // handles address deletion
189
+ try {
190
+ const {data, errors} = await customerAccount.mutate(
191
+ DELETE_ADDRESS_MUTATION,
192
+ {
193
+ variables: {addressId: decodeURIComponent(addressId)},
194
+ },
195
+ );
196
+
197
+ if (errors?.length) {
198
+ throw new Error(errors[0].message);
199
+ }
200
+
201
+ if (data?.customerAddressDelete?.userErrors?.length) {
202
+ throw new Error(data?.customerAddressDelete?.userErrors[0].message);
203
+ }
204
+
205
+ if (!data?.customerAddressDelete?.deletedAddressId) {
206
+ throw new Error('Customer address delete failed.');
207
+ }
208
+
209
+ return json({error: null, deletedAddress: addressId});
210
+ } catch (error: unknown) {
211
+ if (error instanceof Error) {
212
+ return json(
213
+ {error: {[addressId]: error.message}},
214
+ {
215
+ status: 400,
216
+ },
217
+ );
218
+ }
219
+ return json(
220
+ {error: {[addressId]: error}},
221
+ {
222
+ status: 400,
223
+ },
224
+ );
225
+ }
226
+ }
227
+
228
+ default: {
229
+ return json(
230
+ {error: {[addressId]: 'Method not allowed'}},
231
+ {
232
+ status: 405,
233
+ },
234
+ );
235
+ }
236
+ }
237
+ } catch (error: unknown) {
238
+ if (error instanceof Error) {
239
+ return json(
240
+ {error: error.message},
241
+ {
242
+ status: 400,
243
+ },
244
+ );
245
+ }
246
+ return json(
247
+ {error},
248
+ {
249
+ status: 400,
250
+ },
251
+ );
252
+ }
253
+ }
254
+
255
+ export default function Addresses() {
256
+ const {customer} = useOutletContext<{customer: CustomerFragment}>();
257
+ const {defaultAddress, addresses} = customer;
258
+
259
+ return (
260
+ <div className="account-addresses">
261
+ <h2>Addresses</h2>
262
+ <br />
263
+ {!addresses.nodes.length ? (
264
+ <p>You have no addresses saved.</p>
265
+ ) : (
266
+ <div>
267
+ <div>
268
+ <legend>Create address</legend>
269
+ <NewAddressForm />
270
+ </div>
271
+ <br />
272
+ <hr />
273
+ <br />
274
+ <ExistingAddresses
275
+ addresses={addresses}
276
+ defaultAddress={defaultAddress}
277
+ />
278
+ </div>
279
+ )}
280
+ </div>
281
+ );
282
+ }
283
+
284
+ function NewAddressForm() {
285
+ const newAddress = {
286
+ address1: '',
287
+ address2: '',
288
+ city: '',
289
+ company: '',
290
+ territoryCode: '',
291
+ firstName: '',
292
+ id: 'new',
293
+ lastName: '',
294
+ phoneNumber: '',
295
+ zoneCode: '',
296
+ zip: '',
297
+ } as CustomerAddressInput;
298
+
299
+ return (
300
+ <AddressForm
301
+ addressId={'NEW_ADDRESS_ID'}
302
+ address={newAddress}
303
+ defaultAddress={null}
304
+ >
305
+ {({stateForMethod}) => (
306
+ <div>
307
+ <button
308
+ disabled={stateForMethod('POST') !== 'idle'}
309
+ formMethod="POST"
310
+ type="submit"
311
+ >
312
+ {stateForMethod('POST') !== 'idle' ? 'Creating' : 'Create'}
313
+ </button>
314
+ </div>
315
+ )}
316
+ </AddressForm>
317
+ );
318
+ }
319
+
320
+ function ExistingAddresses({
321
+ addresses,
322
+ defaultAddress,
323
+ }: Pick<CustomerFragment, 'addresses' | 'defaultAddress'>) {
324
+ return (
325
+ <div>
326
+ <legend>Existing addresses</legend>
327
+ {addresses.nodes.map((address) => (
328
+ <AddressForm
329
+ key={address.id}
330
+ addressId={address.id}
331
+ address={address}
332
+ defaultAddress={defaultAddress}
333
+ >
334
+ {({stateForMethod}) => (
335
+ <div>
336
+ <button
337
+ disabled={stateForMethod('PUT') !== 'idle'}
338
+ formMethod="PUT"
339
+ type="submit"
340
+ >
341
+ {stateForMethod('PUT') !== 'idle' ? 'Saving' : 'Save'}
342
+ </button>
343
+ <button
344
+ disabled={stateForMethod('DELETE') !== 'idle'}
345
+ formMethod="DELETE"
346
+ type="submit"
347
+ >
348
+ {stateForMethod('DELETE') !== 'idle' ? 'Deleting' : 'Delete'}
349
+ </button>
350
+ </div>
351
+ )}
352
+ </AddressForm>
353
+ ))}
354
+ </div>
355
+ );
356
+ }
357
+
358
+ export function AddressForm({
359
+ addressId,
360
+ address,
361
+ defaultAddress,
362
+ children,
363
+ }: {
364
+ addressId: AddressFragment['id'];
365
+ address: CustomerAddressInput;
366
+ defaultAddress: CustomerFragment['defaultAddress'];
367
+ children: (props: {
368
+ stateForMethod: (method: 'PUT' | 'POST' | 'DELETE') => Fetcher['state'];
369
+ }) => React.ReactNode;
370
+ }) {
371
+ const {state, formMethod} = useNavigation();
372
+ const action = useActionData<ActionResponse>();
373
+ const error = action?.error?.[addressId];
374
+ const isDefaultAddress = defaultAddress?.id === addressId;
375
+ return (
376
+ <Form id={addressId}>
377
+ <fieldset>
378
+ <input type="hidden" name="addressId" defaultValue={addressId} />
379
+ <label htmlFor="firstName">First name*</label>
380
+ <input
381
+ aria-label="First name"
382
+ autoComplete="given-name"
383
+ defaultValue={address?.firstName ?? ''}
384
+ id="firstName"
385
+ name="firstName"
386
+ placeholder="First name"
387
+ required
388
+ type="text"
389
+ />
390
+ <label htmlFor="lastName">Last name*</label>
391
+ <input
392
+ aria-label="Last name"
393
+ autoComplete="family-name"
394
+ defaultValue={address?.lastName ?? ''}
395
+ id="lastName"
396
+ name="lastName"
397
+ placeholder="Last name"
398
+ required
399
+ type="text"
400
+ />
401
+ <label htmlFor="company">Company</label>
402
+ <input
403
+ aria-label="Company"
404
+ autoComplete="organization"
405
+ defaultValue={address?.company ?? ''}
406
+ id="company"
407
+ name="company"
408
+ placeholder="Company"
409
+ type="text"
410
+ />
411
+ <label htmlFor="address1">Address line*</label>
412
+ <input
413
+ aria-label="Address line 1"
414
+ autoComplete="address-line1"
415
+ defaultValue={address?.address1 ?? ''}
416
+ id="address1"
417
+ name="address1"
418
+ placeholder="Address line 1*"
419
+ required
420
+ type="text"
421
+ />
422
+ <label htmlFor="address2">Address line 2</label>
423
+ <input
424
+ aria-label="Address line 2"
425
+ autoComplete="address-line2"
426
+ defaultValue={address?.address2 ?? ''}
427
+ id="address2"
428
+ name="address2"
429
+ placeholder="Address line 2"
430
+ type="text"
431
+ />
432
+ <label htmlFor="city">City*</label>
433
+ <input
434
+ aria-label="City"
435
+ autoComplete="address-level2"
436
+ defaultValue={address?.city ?? ''}
437
+ id="city"
438
+ name="city"
439
+ placeholder="City"
440
+ required
441
+ type="text"
442
+ />
443
+ <label htmlFor="zoneCode">State / Province*</label>
444
+ <input
445
+ aria-label="State/Province"
446
+ autoComplete="address-level1"
447
+ defaultValue={address?.zoneCode ?? ''}
448
+ id="zoneCode"
449
+ name="zoneCode"
450
+ placeholder="State / Province"
451
+ required
452
+ type="text"
453
+ />
454
+ <label htmlFor="zip">Zip / Postal Code*</label>
455
+ <input
456
+ aria-label="Zip"
457
+ autoComplete="postal-code"
458
+ defaultValue={address?.zip ?? ''}
459
+ id="zip"
460
+ name="zip"
461
+ placeholder="Zip / Postal Code"
462
+ required
463
+ type="text"
464
+ />
465
+ <label htmlFor="territoryCode">Country Code*</label>
466
+ <input
467
+ aria-label="territoryCode"
468
+ autoComplete="country"
469
+ defaultValue={address?.territoryCode ?? ''}
470
+ id="territoryCode"
471
+ name="territoryCode"
472
+ placeholder="Country"
473
+ required
474
+ type="text"
475
+ maxLength={2}
476
+ />
477
+ <label htmlFor="phoneNumber">Phone</label>
478
+ <input
479
+ aria-label="Phone Number"
480
+ autoComplete="tel"
481
+ defaultValue={address?.phoneNumber ?? ''}
482
+ id="phoneNumber"
483
+ name="phoneNumber"
484
+ placeholder="+16135551111"
485
+ pattern="^\+?[1-9]\d{3,14}$"
486
+ type="tel"
487
+ />
488
+ <div>
489
+ <input
490
+ defaultChecked={isDefaultAddress}
491
+ id="defaultAddress"
492
+ name="defaultAddress"
493
+ type="checkbox"
494
+ />
495
+ <label htmlFor="defaultAddress">Set as default address</label>
496
+ </div>
497
+ {error ? (
498
+ <p>
499
+ <mark>
500
+ <small>{error}</small>
501
+ </mark>
502
+ </p>
503
+ ) : (
504
+ <br />
505
+ )}
506
+ {children({
507
+ stateForMethod: (method) => (formMethod === method ? state : 'idle'),
508
+ })}
509
+ </fieldset>
510
+ </Form>
511
+ );
512
+ }
app/routes/account.orders.$id.tsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {json, redirect, type LoaderFunctionArgs} from '@netlify/remix-runtime';
2
+ import {useLoaderData, type MetaFunction} from '@remix-run/react';
3
+ import {Money, Image, flattenConnection} from '@shopify/hydrogen';
4
+ import type {OrderLineItemFullFragment} from 'customer-accountapi.generated';
5
+ import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery';
6
+
7
+ export const meta: MetaFunction<typeof loader> = ({data}) => {
8
+ return [{title: `Order ${data?.order?.name}`}];
9
+ };
10
+
11
+ export async function loader({params, context}: LoaderFunctionArgs) {
12
+ if (!params.id) {
13
+ return redirect('/account/orders');
14
+ }
15
+
16
+ const orderId = atob(params.id);
17
+ const {data, errors} = await context.customerAccount.query(
18
+ CUSTOMER_ORDER_QUERY,
19
+ {
20
+ variables: {orderId},
21
+ },
22
+ );
23
+
24
+ if (errors?.length || !data?.order) {
25
+ throw new Error('Order not found');
26
+ }
27
+
28
+ const {order} = data;
29
+
30
+ const lineItems = flattenConnection(order.lineItems);
31
+ const discountApplications = flattenConnection(order.discountApplications);
32
+ const fulfillmentStatus = flattenConnection(order.fulfillments)[0].status;
33
+
34
+ const firstDiscount = discountApplications[0]?.value;
35
+
36
+ const discountValue =
37
+ firstDiscount?.__typename === 'MoneyV2' && firstDiscount;
38
+
39
+ const discountPercentage =
40
+ firstDiscount?.__typename === 'PricingPercentageValue' &&
41
+ firstDiscount?.percentage;
42
+
43
+ return json({
44
+ order,
45
+ lineItems,
46
+ discountValue,
47
+ discountPercentage,
48
+ fulfillmentStatus,
49
+ });
50
+ }
51
+
52
+ export default function OrderRoute() {
53
+ const {
54
+ order,
55
+ lineItems,
56
+ discountValue,
57
+ discountPercentage,
58
+ fulfillmentStatus,
59
+ } = useLoaderData<typeof loader>();
60
+ return (
61
+ <div className="account-order">
62
+ <h2>Order {order.name}</h2>
63
+ <p>Placed on {new Date(order.processedAt!).toDateString()}</p>
64
+ <br />
65
+ <div>
66
+ <table>
67
+ <thead>
68
+ <tr>
69
+ <th scope="col">Product</th>
70
+ <th scope="col">Price</th>
71
+ <th scope="col">Quantity</th>
72
+ <th scope="col">Total</th>
73
+ </tr>
74
+ </thead>
75
+ <tbody>
76
+ {lineItems.map((lineItem, lineItemIndex) => (
77
+ // eslint-disable-next-line react/no-array-index-key
78
+ <OrderLineRow key={lineItemIndex} lineItem={lineItem} />
79
+ ))}
80
+ </tbody>
81
+ <tfoot>
82
+ {((discountValue && discountValue.amount) ||
83
+ discountPercentage) && (
84
+ <tr>
85
+ <th scope="row" colSpan={3}>
86
+ <p>Discounts</p>
87
+ </th>
88
+ <th scope="row">
89
+ <p>Discounts</p>
90
+ </th>
91
+ <td>
92
+ {discountPercentage ? (
93
+ <span>-{discountPercentage}% OFF</span>
94
+ ) : (
95
+ discountValue && <Money data={discountValue!} />
96
+ )}
97
+ </td>
98
+ </tr>
99
+ )}
100
+ <tr>
101
+ <th scope="row" colSpan={3}>
102
+ <p>Subtotal</p>
103
+ </th>
104
+ <th scope="row">
105
+ <p>Subtotal</p>
106
+ </th>
107
+ <td>
108
+ <Money data={order.subtotal!} />
109
+ </td>
110
+ </tr>
111
+ <tr>
112
+ <th scope="row" colSpan={3}>
113
+ Tax
114
+ </th>
115
+ <th scope="row">
116
+ <p>Tax</p>
117
+ </th>
118
+ <td>
119
+ <Money data={order.totalTax!} />
120
+ </td>
121
+ </tr>
122
+ <tr>
123
+ <th scope="row" colSpan={3}>
124
+ Total
125
+ </th>
126
+ <th scope="row">
127
+ <p>Total</p>
128
+ </th>
129
+ <td>
130
+ <Money data={order.totalPrice!} />
131
+ </td>
132
+ </tr>
133
+ </tfoot>
134
+ </table>
135
+ <div>
136
+ <h3>Shipping Address</h3>
137
+ {order?.shippingAddress ? (
138
+ <address>
139
+ <p>{order.shippingAddress.name}</p>
140
+ {order.shippingAddress.formatted ? (
141
+ <p>{order.shippingAddress.formatted}</p>
142
+ ) : (
143
+ ''
144
+ )}
145
+ {order.shippingAddress.formattedArea ? (
146
+ <p>{order.shippingAddress.formattedArea}</p>
147
+ ) : (
148
+ ''
149
+ )}
150
+ </address>
151
+ ) : (
152
+ <p>No shipping address defined</p>
153
+ )}
154
+ <h3>Status</h3>
155
+ <div>
156
+ <p>{fulfillmentStatus}</p>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ <br />
161
+ <p>
162
+ <a target="_blank" href={order.statusPageUrl} rel="noreferrer">
163
+ View Order Status →
164
+ </a>
165
+ </p>
166
+ </div>
167
+ );
168
+ }
169
+
170
+ function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) {
171
+ return (
172
+ <tr key={lineItem.id}>
173
+ <td>
174
+ <div>
175
+ {lineItem?.image && (
176
+ <div>
177
+ <Image data={lineItem.image} width={96} height={96} />
178
+ </div>
179
+ )}
180
+ <div>
181
+ <p>{lineItem.title}</p>
182
+ <small>{lineItem.variantTitle}</small>
183
+ </div>
184
+ </div>
185
+ </td>
186
+ <td>
187
+ <Money data={lineItem.price!} />
188
+ </td>
189
+ <td>{lineItem.quantity}</td>
190
+ <td>
191
+ <Money data={lineItem.totalDiscount!} />
192
+ </td>
193
+ </tr>
194
+ );
195
+ }
app/routes/account.orders._index.tsx ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
2
+ import {
3
+ Money,
4
+ getPaginationVariables,
5
+ flattenConnection,
6
+ } from '@shopify/hydrogen';
7
+ import {json, type LoaderFunctionArgs} from '@netlify/remix-runtime';
8
+ import {CUSTOMER_ORDERS_QUERY} from '~/graphql/customer-account/CustomerOrdersQuery';
9
+ import type {
10
+ CustomerOrdersFragment,
11
+ OrderItemFragment,
12
+ } from 'customer-accountapi.generated';
13
+ import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
14
+
15
+ export const meta: MetaFunction = () => {
16
+ return [{title: 'Orders'}];
17
+ };
18
+
19
+ export async function loader({request, context}: LoaderFunctionArgs) {
20
+ const paginationVariables = getPaginationVariables(request, {
21
+ pageBy: 20,
22
+ });
23
+
24
+ const {data, errors} = await context.customerAccount.query(
25
+ CUSTOMER_ORDERS_QUERY,
26
+ {
27
+ variables: {
28
+ ...paginationVariables,
29
+ },
30
+ },
31
+ );
32
+
33
+ if (errors?.length || !data?.customer) {
34
+ throw Error('Customer orders not found');
35
+ }
36
+
37
+ return json({customer: data.customer});
38
+ }
39
+
40
+ export default function Orders() {
41
+ const {customer} = useLoaderData<{customer: CustomerOrdersFragment}>();
42
+ const {orders} = customer;
43
+ return (
44
+ <div className="orders">
45
+ {orders.nodes.length ? <OrdersTable orders={orders} /> : <EmptyOrders />}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ function OrdersTable({orders}: Pick<CustomerOrdersFragment, 'orders'>) {
51
+ return (
52
+ <div className="acccount-orders">
53
+ {orders?.nodes.length ? (
54
+ <PaginatedResourceSection connection={orders}>
55
+ {({node: order}) => <OrderItem key={order.id} order={order} />}
56
+ </PaginatedResourceSection>
57
+ ) : (
58
+ <EmptyOrders />
59
+ )}
60
+ </div>
61
+ );
62
+ }
63
+
64
+ function EmptyOrders() {
65
+ return (
66
+ <div>
67
+ <p>You haven&apos;t placed any orders yet.</p>
68
+ <br />
69
+ <p>
70
+ <Link to="/collections">Start Shopping →</Link>
71
+ </p>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ function OrderItem({order}: {order: OrderItemFragment}) {
77
+ const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status;
78
+ return (
79
+ <>
80
+ <fieldset>
81
+ <Link to={`/account/orders/${btoa(order.id)}`}>
82
+ <strong>#{order.number}</strong>
83
+ </Link>
84
+ <p>{new Date(order.processedAt).toDateString()}</p>
85
+ <p>{order.financialStatus}</p>
86
+ {fulfillmentStatus && <p>{fulfillmentStatus}</p>}
87
+ <Money data={order.totalPrice} />
88
+ <Link to={`/account/orders/${btoa(order.id)}`}>View Order →</Link>
89
+ </fieldset>
90
+ <br />
91
+ </>
92
+ );
93
+ }
app/routes/account.profile.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {CustomerFragment} from 'customer-accountapi.generated';
2
+ import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types';
3
+ import {CUSTOMER_UPDATE_MUTATION} from '~/graphql/customer-account/CustomerUpdateMutation';
4
+ import {
5
+ json,
6
+ type ActionFunctionArgs,
7
+ type LoaderFunctionArgs,
8
+ } from '@netlify/remix-runtime';
9
+ import {
10
+ Form,
11
+ useActionData,
12
+ useNavigation,
13
+ useOutletContext,
14
+ type MetaFunction,
15
+ } from '@remix-run/react';
16
+
17
+ export type ActionResponse = {
18
+ error: string | null;
19
+ customer: CustomerFragment | null;
20
+ };
21
+
22
+ export const meta: MetaFunction = () => {
23
+ return [{title: 'Profile'}];
24
+ };
25
+
26
+ export async function loader({context}: LoaderFunctionArgs) {
27
+ await context.customerAccount.handleAuthStatus();
28
+
29
+ return json({});
30
+ }
31
+
32
+ export async function action({request, context}: ActionFunctionArgs) {
33
+ const {customerAccount} = context;
34
+
35
+ if (request.method !== 'PUT') {
36
+ return json({error: 'Method not allowed'}, {status: 405});
37
+ }
38
+
39
+ const form = await request.formData();
40
+
41
+ try {
42
+ const customer: CustomerUpdateInput = {};
43
+ const validInputKeys = ['firstName', 'lastName'] as const;
44
+ for (const [key, value] of form.entries()) {
45
+ if (!validInputKeys.includes(key as any)) {
46
+ continue;
47
+ }
48
+ if (typeof value === 'string' && value.length) {
49
+ customer[key as (typeof validInputKeys)[number]] = value;
50
+ }
51
+ }
52
+
53
+ // update customer and possibly password
54
+ const {data, errors} = await customerAccount.mutate(
55
+ CUSTOMER_UPDATE_MUTATION,
56
+ {
57
+ variables: {
58
+ customer,
59
+ },
60
+ },
61
+ );
62
+
63
+ if (errors?.length) {
64
+ throw new Error(errors[0].message);
65
+ }
66
+
67
+ if (!data?.customerUpdate?.customer) {
68
+ throw new Error('Customer profile update failed.');
69
+ }
70
+
71
+ return json({
72
+ error: null,
73
+ customer: data?.customerUpdate?.customer,
74
+ });
75
+ } catch (error: any) {
76
+ return json(
77
+ {error: error.message, customer: null},
78
+ {
79
+ status: 400,
80
+ },
81
+ );
82
+ }
83
+ }
84
+
85
+ export default function AccountProfile() {
86
+ const account = useOutletContext<{customer: CustomerFragment}>();
87
+ const {state} = useNavigation();
88
+ const action = useActionData<ActionResponse>();
89
+ const customer = action?.customer ?? account?.customer;
90
+
91
+ return (
92
+ <div className="account-profile">
93
+ <h2>My profile</h2>
94
+ <br />
95
+ <Form method="PUT">
96
+ <legend>Personal information</legend>
97
+ <fieldset>
98
+ <label htmlFor="firstName">First name</label>
99
+ <input
100
+ id="firstName"
101
+ name="firstName"
102
+ type="text"
103
+ autoComplete="given-name"
104
+ placeholder="First name"
105
+ aria-label="First name"
106
+ defaultValue={customer.firstName ?? ''}
107
+ minLength={2}
108
+ />
109
+ <label htmlFor="lastName">Last name</label>
110
+ <input
111
+ id="lastName"
112
+ name="lastName"
113
+ type="text"
114
+ autoComplete="family-name"
115
+ placeholder="Last name"
116
+ aria-label="Last name"
117
+ defaultValue={customer.lastName ?? ''}
118
+ minLength={2}
119
+ />
120
+ </fieldset>
121
+ {action?.error ? (
122
+ <p>
123
+ <mark>
124
+ <small>{action.error}</small>
125
+ </mark>
126
+ </p>
127
+ ) : (
128
+ <br />
129
+ )}
130
+ <button type="submit" disabled={state !== 'idle'}>
131
+ {state !== 'idle' ? 'Updating' : 'Update'}
132
+ </button>
133
+ </Form>
134
+ </div>
135
+ );
136
+ }