hydrogen-forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +212 -0
  2. package/dist/commands/add.d.ts +7 -0
  3. package/dist/commands/add.d.ts.map +1 -0
  4. package/dist/commands/add.js +123 -0
  5. package/dist/commands/add.js.map +1 -0
  6. package/dist/commands/create.d.ts +8 -0
  7. package/dist/commands/create.d.ts.map +1 -0
  8. package/dist/commands/create.js +160 -0
  9. package/dist/commands/create.js.map +1 -0
  10. package/dist/commands/setup-mcp.d.ts +7 -0
  11. package/dist/commands/setup-mcp.d.ts.map +1 -0
  12. package/dist/commands/setup-mcp.js +179 -0
  13. package/dist/commands/setup-mcp.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +50 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lib/generators.d.ts +6 -0
  19. package/dist/lib/generators.d.ts.map +1 -0
  20. package/dist/lib/generators.js +470 -0
  21. package/dist/lib/generators.js.map +1 -0
  22. package/dist/lib/utils.d.ts +17 -0
  23. package/dist/lib/utils.d.ts.map +1 -0
  24. package/dist/lib/utils.js +101 -0
  25. package/dist/lib/utils.js.map +1 -0
  26. package/package.json +54 -0
  27. package/templates/starter/.env.example +21 -0
  28. package/templates/starter/.graphqlrc.ts +27 -0
  29. package/templates/starter/README.md +117 -0
  30. package/templates/starter/app/assets/favicon.svg +28 -0
  31. package/templates/starter/app/components/AddToCartButton.tsx +102 -0
  32. package/templates/starter/app/components/Aside.tsx +136 -0
  33. package/templates/starter/app/components/CartLineItem.tsx +229 -0
  34. package/templates/starter/app/components/CartMain.tsx +131 -0
  35. package/templates/starter/app/components/CartSummary.tsx +315 -0
  36. package/templates/starter/app/components/CollectionFilters.tsx +330 -0
  37. package/templates/starter/app/components/CollectionGrid.tsx +141 -0
  38. package/templates/starter/app/components/Footer.tsx +218 -0
  39. package/templates/starter/app/components/Header.tsx +296 -0
  40. package/templates/starter/app/components/PageLayout.tsx +174 -0
  41. package/templates/starter/app/components/PaginatedResourceSection.tsx +41 -0
  42. package/templates/starter/app/components/ProductCard.tsx +151 -0
  43. package/templates/starter/app/components/ProductForm.tsx +156 -0
  44. package/templates/starter/app/components/ProductGallery.tsx +164 -0
  45. package/templates/starter/app/components/ProductGrid.tsx +64 -0
  46. package/templates/starter/app/components/ProductImage.tsx +23 -0
  47. package/templates/starter/app/components/ProductItem.tsx +44 -0
  48. package/templates/starter/app/components/ProductPrice.tsx +97 -0
  49. package/templates/starter/app/components/SearchDialog.tsx +599 -0
  50. package/templates/starter/app/components/SearchForm.tsx +68 -0
  51. package/templates/starter/app/components/SearchFormPredictive.tsx +76 -0
  52. package/templates/starter/app/components/SearchResults.tsx +161 -0
  53. package/templates/starter/app/components/SearchResultsPredictive.tsx +461 -0
  54. package/templates/starter/app/entry.client.tsx +21 -0
  55. package/templates/starter/app/entry.server.tsx +53 -0
  56. package/templates/starter/app/graphql/customer-account/CustomerAddressMutations.ts +64 -0
  57. package/templates/starter/app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
  58. package/templates/starter/app/graphql/customer-account/CustomerOrderQuery.ts +90 -0
  59. package/templates/starter/app/graphql/customer-account/CustomerOrdersQuery.ts +63 -0
  60. package/templates/starter/app/graphql/customer-account/CustomerUpdateMutation.ts +25 -0
  61. package/templates/starter/app/lib/context.ts +60 -0
  62. package/templates/starter/app/lib/fragments.ts +234 -0
  63. package/templates/starter/app/lib/orderFilters.ts +90 -0
  64. package/templates/starter/app/lib/redirect.ts +23 -0
  65. package/templates/starter/app/lib/search.ts +79 -0
  66. package/templates/starter/app/lib/session.ts +72 -0
  67. package/templates/starter/app/lib/variants.ts +46 -0
  68. package/templates/starter/app/root.tsx +209 -0
  69. package/templates/starter/app/routes/$.tsx +11 -0
  70. package/templates/starter/app/routes/[robots.txt].tsx +117 -0
  71. package/templates/starter/app/routes/[sitemap.xml].tsx +16 -0
  72. package/templates/starter/app/routes/_index.tsx +167 -0
  73. package/templates/starter/app/routes/account.$.tsx +9 -0
  74. package/templates/starter/app/routes/account._index.tsx +5 -0
  75. package/templates/starter/app/routes/account.addresses.tsx +516 -0
  76. package/templates/starter/app/routes/account.orders.$id.tsx +222 -0
  77. package/templates/starter/app/routes/account.orders._index.tsx +222 -0
  78. package/templates/starter/app/routes/account.profile.tsx +133 -0
  79. package/templates/starter/app/routes/account.tsx +97 -0
  80. package/templates/starter/app/routes/account_.authorize.tsx +5 -0
  81. package/templates/starter/app/routes/account_.login.tsx +7 -0
  82. package/templates/starter/app/routes/account_.logout.tsx +11 -0
  83. package/templates/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
  84. package/templates/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +129 -0
  85. package/templates/starter/app/routes/blogs.$blogHandle._index.tsx +175 -0
  86. package/templates/starter/app/routes/blogs._index.tsx +109 -0
  87. package/templates/starter/app/routes/cart.$lines.tsx +70 -0
  88. package/templates/starter/app/routes/cart.tsx +117 -0
  89. package/templates/starter/app/routes/collections.$handle.tsx +161 -0
  90. package/templates/starter/app/routes/collections._index.tsx +133 -0
  91. package/templates/starter/app/routes/collections.all.tsx +122 -0
  92. package/templates/starter/app/routes/discount.$code.tsx +48 -0
  93. package/templates/starter/app/routes/pages.$handle.tsx +88 -0
  94. package/templates/starter/app/routes/policies.$handle.tsx +93 -0
  95. package/templates/starter/app/routes/policies._index.tsx +69 -0
  96. package/templates/starter/app/routes/products.$handle.tsx +232 -0
  97. package/templates/starter/app/routes/search.tsx +426 -0
  98. package/templates/starter/app/routes/sitemap.$type.$page[.xml].tsx +23 -0
  99. package/templates/starter/app/routes.ts +9 -0
  100. package/templates/starter/app/styles/app.css +574 -0
  101. package/templates/starter/app/styles/reset.css +139 -0
  102. package/templates/starter/app/styles/tailwind.css +116 -0
  103. package/templates/starter/customer-accountapi.generated.d.ts +543 -0
  104. package/templates/starter/env.d.ts +7 -0
  105. package/templates/starter/eslint.config.js +247 -0
  106. package/templates/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
  107. package/templates/starter/guides/predictiveSearch/predictiveSearch.md +394 -0
  108. package/templates/starter/guides/search/search.jpg +0 -0
  109. package/templates/starter/guides/search/search.md +335 -0
  110. package/templates/starter/package.json +71 -0
  111. package/templates/starter/postcss.config.js +6 -0
  112. package/templates/starter/public/.gitkeep +0 -0
  113. package/templates/starter/react-router.config.ts +13 -0
  114. package/templates/starter/server.ts +59 -0
  115. package/templates/starter/storefrontapi.generated.d.ts +1264 -0
  116. package/templates/starter/tailwind.config.js +83 -0
  117. package/templates/starter/tsconfig.json +67 -0
  118. package/templates/starter/vite.config.ts +32 -0
@@ -0,0 +1,27 @@
1
+ import type {IGraphQLConfig} from 'graphql-config';
2
+ import {getSchema} from '@shopify/hydrogen-codegen';
3
+
4
+ /**
5
+ * GraphQL Config
6
+ * @see https://the-guild.dev/graphql/config/docs/user/usage
7
+ * @type {IGraphQLConfig}
8
+ */
9
+ export default {
10
+ projects: {
11
+ default: {
12
+ schema: getSchema('storefront'),
13
+ documents: [
14
+ './*.{ts,tsx,js,jsx}',
15
+ './app/**/*.{ts,tsx,js,jsx}',
16
+ '!./app/graphql/**/*.{ts,tsx,js,jsx}',
17
+ ],
18
+ },
19
+
20
+ customer: {
21
+ schema: getSchema('customer-account'),
22
+ documents: ['./app/graphql/customer-account/*.{ts,tsx,js,jsx}'],
23
+ },
24
+
25
+ // Add your own GraphQL projects here for CMS, Shopify Admin API, etc.
26
+ },
27
+ } as IGraphQLConfig;
@@ -0,0 +1,117 @@
1
+ # Hydrogen Forge Starter
2
+
3
+ A developer-focused Shopify Hydrogen starter template with TypeScript strict mode and Tailwind CSS.
4
+
5
+ ## Features
6
+
7
+ - **TypeScript Strict Mode** - Maximum type safety with all strict checks enabled
8
+ - **Tailwind CSS** - Utility-first CSS with custom component classes
9
+ - **React Router 7** - Latest routing with loaders and actions
10
+ - **Shopify Hydrogen** - Official Shopify headless commerce framework
11
+ - **Performance Optimized** - Built for 95+ PageSpeed scores
12
+
13
+ ## Quick Start
14
+
15
+ ### Prerequisites
16
+
17
+ - Node.js 20+
18
+ - pnpm (recommended) or npm
19
+ - Shopify Partner account with a development store
20
+
21
+ ### Setup
22
+
23
+ 1. **Clone and install:**
24
+
25
+ ```bash
26
+ git clone https://github.com/nathanmcmullendev/hydrogen-forge.git
27
+ cd hydrogen-forge/templates/starter
28
+ pnpm install
29
+ ```
30
+
31
+ 2. **Configure environment:**
32
+
33
+ ```bash
34
+ cp .env.example .env
35
+ # Edit .env with your Shopify store credentials
36
+ ```
37
+
38
+ 3. **Start development:**
39
+
40
+ ```bash
41
+ pnpm dev
42
+ ```
43
+
44
+ 4. **Open browser:**
45
+
46
+ Navigate to `http://localhost:3000`
47
+
48
+ ## Project Structure
49
+
50
+ ```
51
+ app/
52
+ ├── components/ # Reusable UI components
53
+ ├── graphql/ # GraphQL fragments and queries
54
+ ├── lib/ # Utilities and helpers
55
+ ├── routes/ # Page routes (React Router 7)
56
+ ├── styles/ # CSS files
57
+ │ ├── reset.css # CSS reset
58
+ │ └── tailwind.css # Tailwind directives + custom styles
59
+ └── root.tsx # App root with layout
60
+ ```
61
+
62
+ ## Scripts
63
+
64
+ | Command | Description |
65
+ | ---------------- | ---------------------------- |
66
+ | `pnpm dev` | Start development server |
67
+ | `pnpm build` | Build for production |
68
+ | `pnpm preview` | Preview production build |
69
+ | `pnpm typecheck` | Run TypeScript type checking |
70
+ | `pnpm lint` | Run ESLint |
71
+ | `pnpm format` | Format code with Prettier |
72
+
73
+ ## TypeScript Configuration
74
+
75
+ This starter uses TypeScript strict mode with additional checks:
76
+
77
+ - `strict: true` - Enable all strict type-checking options
78
+ - `noUncheckedIndexedAccess: true` - Add undefined to index signatures
79
+ - `exactOptionalPropertyTypes: true` - Exact optional property types
80
+ - `noImplicitReturns: true` - Ensure all code paths return
81
+
82
+ ## Tailwind CSS
83
+
84
+ Custom component classes are defined in `app/styles/tailwind.css`:
85
+
86
+ - `.btn`, `.btn-primary`, `.btn-secondary` - Button variants
87
+ - `.input` - Form input styling
88
+ - `.card` - Card component
89
+ - `.badge`, `.badge-sale` - Badge variants
90
+ - `.product-grid` - Product grid layout
91
+ - `.container-narrow` - Centered container
92
+
93
+ ## Deployment
94
+
95
+ Deploy to Shopify Oxygen:
96
+
97
+ ```bash
98
+ pnpm build
99
+ shopify hydrogen deploy
100
+ ```
101
+
102
+ Or deploy to Vercel, Netlify, or any Node.js hosting.
103
+
104
+ ## Documentation
105
+
106
+ - [Hydrogen Docs](https://hydrogen.shopify.dev)
107
+ - [React Router 7 Docs](https://reactrouter.com)
108
+ - [Tailwind CSS Docs](https://tailwindcss.com)
109
+ - [Shopify Storefront API](https://shopify.dev/docs/api/storefront)
110
+
111
+ ## License
112
+
113
+ MIT
114
+
115
+ ---
116
+
117
+ Part of the [Hydrogen Forge](https://github.com/nathanmcmullendev/hydrogen-forge) ecosystem.
@@ -0,0 +1,28 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none">
2
+ <style>
3
+ .stroke {
4
+ stroke: #000;
5
+ }
6
+ .fill {
7
+ fill: #000;
8
+ }
9
+ @media (prefers-color-scheme: dark) {
10
+ .stroke {
11
+ stroke: #fff;
12
+ }
13
+ .fill {
14
+ fill: #fff;
15
+ }
16
+ }
17
+ </style>
18
+ <path
19
+ class="stroke"
20
+ fill-rule="evenodd"
21
+ d="M16.1 16.04 1 8.02 6.16 5.3l5.82 3.09 4.88-2.57-5.82-3.1L16.21 0l15.1 8.02-5.17 2.72-5.5-2.91-4.88 2.57 5.5 2.92-5.16 2.72Z"
22
+ />
23
+ <path
24
+ class="fill"
25
+ fill-rule="evenodd"
26
+ d="M16.1 32 1 23.98l5.16-2.72 5.82 3.08 4.88-2.57-5.82-3.08 5.17-2.73 15.1 8.02-5.17 2.72-5.5-2.92-4.88 2.58 5.5 2.92L16.1 32Z"
27
+ />
28
+ </svg>
@@ -0,0 +1,102 @@
1
+ import {type FetcherWithComponents} from 'react-router';
2
+ import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen';
3
+
4
+ export interface AddToCartButtonProps {
5
+ analytics?: unknown;
6
+ children: React.ReactNode;
7
+ disabled?: boolean;
8
+ lines: Array<OptimisticCartLineInput>;
9
+ onClick?: () => void;
10
+ variant?: 'primary' | 'secondary' | 'outline';
11
+ size?: 'sm' | 'md' | 'lg';
12
+ fullWidth?: boolean;
13
+ }
14
+
15
+ export function AddToCartButton({
16
+ analytics,
17
+ children,
18
+ disabled,
19
+ lines,
20
+ onClick,
21
+ variant = 'primary',
22
+ size = 'lg',
23
+ fullWidth = true,
24
+ }: AddToCartButtonProps) {
25
+ const baseClasses =
26
+ 'inline-flex items-center justify-center font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed';
27
+
28
+ const variantClasses = {
29
+ primary:
30
+ 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 disabled:bg-secondary-300 disabled:text-secondary-500',
31
+ secondary:
32
+ 'bg-secondary-900 text-white hover:bg-secondary-800 focus:ring-secondary-500 disabled:bg-secondary-300 disabled:text-secondary-500',
33
+ outline:
34
+ 'border-2 border-secondary-900 bg-transparent text-secondary-900 hover:bg-secondary-900 hover:text-white focus:ring-secondary-500 disabled:border-secondary-300 disabled:text-secondary-400 disabled:hover:bg-transparent',
35
+ };
36
+
37
+ const sizeClasses = {
38
+ sm: 'rounded px-3 py-1.5 text-sm',
39
+ md: 'rounded-md px-4 py-2 text-base',
40
+ lg: 'rounded-md px-6 py-3 text-base',
41
+ };
42
+
43
+ const widthClass = fullWidth ? 'w-full' : '';
44
+
45
+ return (
46
+ <CartForm route="/cart" inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
47
+ {(fetcher: FetcherWithComponents<unknown>) => {
48
+ const isLoading = fetcher.state !== 'idle';
49
+
50
+ return (
51
+ <>
52
+ <input
53
+ name="analytics"
54
+ type="hidden"
55
+ value={JSON.stringify(analytics)}
56
+ />
57
+ <button
58
+ type="submit"
59
+ onClick={onClick}
60
+ disabled={disabled || isLoading}
61
+ className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass}`}
62
+ >
63
+ {isLoading ? (
64
+ <span className="flex items-center gap-2">
65
+ <LoadingSpinner />
66
+ Adding...
67
+ </span>
68
+ ) : (
69
+ children
70
+ )}
71
+ </button>
72
+ </>
73
+ );
74
+ }}
75
+ </CartForm>
76
+ );
77
+ }
78
+
79
+ function LoadingSpinner() {
80
+ return (
81
+ <svg
82
+ className="h-4 w-4 animate-spin"
83
+ fill="none"
84
+ viewBox="0 0 24 24"
85
+ aria-hidden="true"
86
+ >
87
+ <circle
88
+ className="opacity-25"
89
+ cx="12"
90
+ cy="12"
91
+ r="10"
92
+ stroke="currentColor"
93
+ strokeWidth="4"
94
+ />
95
+ <path
96
+ className="opacity-75"
97
+ fill="currentColor"
98
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
99
+ />
100
+ </svg>
101
+ );
102
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useState,
7
+ } from 'react';
8
+
9
+ type AsideType = 'search' | 'cart' | 'mobile' | 'closed';
10
+ type AsideContextValue = {
11
+ type: AsideType;
12
+ open: (mode: AsideType) => void;
13
+ close: () => void;
14
+ };
15
+
16
+ /**
17
+ * A side bar component with Overlay
18
+ * @example
19
+ * ```jsx
20
+ * <Aside type="search" heading="SEARCH">
21
+ * <input type="search" />
22
+ * ...
23
+ * </Aside>
24
+ * ```
25
+ */
26
+ export function Aside({
27
+ children,
28
+ heading,
29
+ type,
30
+ }: {
31
+ children?: React.ReactNode;
32
+ type: AsideType;
33
+ heading: React.ReactNode;
34
+ }) {
35
+ const {type: activeType, close} = useAside();
36
+ const expanded = type === activeType;
37
+
38
+ useEffect(() => {
39
+ const abortController = new AbortController();
40
+
41
+ if (expanded) {
42
+ document.addEventListener(
43
+ 'keydown',
44
+ function handler(event: KeyboardEvent) {
45
+ if (event.key === 'Escape') {
46
+ close();
47
+ }
48
+ },
49
+ {signal: abortController.signal},
50
+ );
51
+ }
52
+ return () => abortController.abort();
53
+ }, [close, expanded]);
54
+
55
+ return (
56
+ <div
57
+ aria-modal
58
+ className={`fixed inset-0 z-50 ${expanded ? 'pointer-events-auto' : 'pointer-events-none'}`}
59
+ role="dialog"
60
+ >
61
+ {/* Overlay backdrop */}
62
+ <button
63
+ type="button"
64
+ className={`absolute inset-0 bg-black/50 transition-opacity duration-300 ${
65
+ expanded ? 'opacity-100' : 'opacity-0'
66
+ }`}
67
+ onClick={close}
68
+ onKeyDown={(e) => e.key === 'Enter' && close()}
69
+ aria-label="Close menu"
70
+ tabIndex={expanded ? 0 : -1}
71
+ />
72
+
73
+ {/* Slide-in panel */}
74
+ <aside
75
+ className={`absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-300 ease-out ${
76
+ expanded ? 'translate-x-0' : 'translate-x-full'
77
+ }`}
78
+ >
79
+ {/* Header */}
80
+ <header className="flex items-center justify-between border-b border-secondary-200 px-6 py-4">
81
+ <h3 className="text-lg font-semibold text-secondary-900">
82
+ {heading}
83
+ </h3>
84
+ <button
85
+ className="inline-flex h-10 w-10 items-center justify-center rounded-md text-secondary-500 transition-colors hover:bg-secondary-100 hover:text-secondary-700"
86
+ onClick={close}
87
+ aria-label="Close"
88
+ >
89
+ <svg
90
+ className="h-6 w-6"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ viewBox="0 0 24 24"
94
+ >
95
+ <path
96
+ strokeLinecap="round"
97
+ strokeLinejoin="round"
98
+ strokeWidth={2}
99
+ d="M6 18L18 6M6 6l12 12"
100
+ />
101
+ </svg>
102
+ </button>
103
+ </header>
104
+
105
+ {/* Content */}
106
+ <main className="flex-1 overflow-y-auto px-6 py-4">{children}</main>
107
+ </aside>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ const AsideContext = createContext<AsideContextValue | null>(null);
113
+
114
+ Aside.Provider = function AsideProvider({children}: {children: ReactNode}) {
115
+ const [type, setType] = useState<AsideType>('closed');
116
+
117
+ return (
118
+ <AsideContext.Provider
119
+ value={{
120
+ type,
121
+ open: setType,
122
+ close: () => setType('closed'),
123
+ }}
124
+ >
125
+ {children}
126
+ </AsideContext.Provider>
127
+ );
128
+ };
129
+
130
+ export function useAside() {
131
+ const aside = useContext(AsideContext);
132
+ if (!aside) {
133
+ throw new Error('useAside must be used within an AsideProvider');
134
+ }
135
+ return aside;
136
+ }
@@ -0,0 +1,229 @@
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 'react-router';
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
30
+ key={id}
31
+ className={`flex gap-4 py-4 ${line.isOptimistic ? 'opacity-60' : ''}`}
32
+ >
33
+ {/* Product image */}
34
+ {image && (
35
+ <Link
36
+ prefetch="intent"
37
+ to={lineItemUrl}
38
+ onClick={() => layout === 'aside' && close()}
39
+ className="flex-shrink-0"
40
+ >
41
+ <div className="h-24 w-24 overflow-hidden rounded-lg border border-secondary-200 bg-secondary-50">
42
+ <Image
43
+ alt={title}
44
+ aspectRatio="1/1"
45
+ data={image}
46
+ height={96}
47
+ loading="lazy"
48
+ width={96}
49
+ className="h-full w-full object-cover object-center"
50
+ />
51
+ </div>
52
+ </Link>
53
+ )}
54
+
55
+ {/* Product details */}
56
+ <div className="flex flex-1 flex-col">
57
+ <div className="flex justify-between">
58
+ <div>
59
+ <Link
60
+ prefetch="intent"
61
+ to={lineItemUrl}
62
+ onClick={() => layout === 'aside' && close()}
63
+ className="text-sm font-medium text-secondary-900 hover:text-primary-600 transition-colors"
64
+ >
65
+ {product.title}
66
+ </Link>
67
+
68
+ {/* Selected options */}
69
+ {selectedOptions.length > 0 && (
70
+ <div className="mt-1 flex flex-wrap gap-x-2 text-xs text-secondary-500">
71
+ {selectedOptions.map((option) => (
72
+ <span key={option.name}>
73
+ {option.name}: {option.value}
74
+ </span>
75
+ ))}
76
+ </div>
77
+ )}
78
+ </div>
79
+
80
+ {/* Price */}
81
+ <div className="ml-4 flex-shrink-0">
82
+ <ProductPrice price={line?.cost?.totalAmount} size="sm" />
83
+ </div>
84
+ </div>
85
+
86
+ {/* Quantity controls */}
87
+ <div className="mt-auto pt-2">
88
+ <CartLineQuantity line={line} />
89
+ </div>
90
+ </div>
91
+ </li>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Provides the controls to update the quantity of a line item in the cart.
97
+ * These controls are disabled when the line item is new, and the server
98
+ * hasn't yet responded that it was successfully added to the cart.
99
+ */
100
+ function CartLineQuantity({line}: {line: CartLine}) {
101
+ if (!line || typeof line?.quantity === 'undefined') return null;
102
+ const {id: lineId, quantity, isOptimistic} = line;
103
+ const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
104
+ const nextQuantity = Number((quantity + 1).toFixed(0));
105
+
106
+ return (
107
+ <div className="flex items-center justify-between">
108
+ {/* Quantity controls */}
109
+ <div className="flex items-center rounded-lg border border-secondary-200">
110
+ <CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
111
+ <button
112
+ aria-label="Decrease quantity"
113
+ disabled={quantity <= 1 || !!isOptimistic}
114
+ name="decrease-quantity"
115
+ value={prevQuantity}
116
+ className="flex h-8 w-8 items-center justify-center text-secondary-600 transition-colors hover:bg-secondary-100 disabled:cursor-not-allowed disabled:opacity-50"
117
+ >
118
+ <svg
119
+ className="h-4 w-4"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ viewBox="0 0 24 24"
123
+ >
124
+ <path
125
+ strokeLinecap="round"
126
+ strokeLinejoin="round"
127
+ strokeWidth={2}
128
+ d="M20 12H4"
129
+ />
130
+ </svg>
131
+ </button>
132
+ </CartLineUpdateButton>
133
+
134
+ <span className="w-10 text-center text-sm font-medium text-secondary-900">
135
+ {quantity}
136
+ </span>
137
+
138
+ <CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
139
+ <button
140
+ aria-label="Increase quantity"
141
+ name="increase-quantity"
142
+ value={nextQuantity}
143
+ disabled={!!isOptimistic}
144
+ className="flex h-8 w-8 items-center justify-center text-secondary-600 transition-colors hover:bg-secondary-100 disabled:cursor-not-allowed disabled:opacity-50"
145
+ >
146
+ <svg
147
+ className="h-4 w-4"
148
+ fill="none"
149
+ stroke="currentColor"
150
+ viewBox="0 0 24 24"
151
+ >
152
+ <path
153
+ strokeLinecap="round"
154
+ strokeLinejoin="round"
155
+ strokeWidth={2}
156
+ d="M12 4v16m8-8H4"
157
+ />
158
+ </svg>
159
+ </button>
160
+ </CartLineUpdateButton>
161
+ </div>
162
+
163
+ {/* Remove button */}
164
+ <CartLineRemoveButton lineIds={[lineId]} disabled={!!isOptimistic} />
165
+ </div>
166
+ );
167
+ }
168
+
169
+ /**
170
+ * A button that removes a line item from the cart. It is disabled
171
+ * when the line item is new, and the server hasn't yet responded
172
+ * that it was successfully added to the cart.
173
+ */
174
+ function CartLineRemoveButton({
175
+ lineIds,
176
+ disabled,
177
+ }: {
178
+ lineIds: string[];
179
+ disabled: boolean;
180
+ }) {
181
+ return (
182
+ <CartForm
183
+ fetcherKey={getUpdateKey(lineIds)}
184
+ route="/cart"
185
+ action={CartForm.ACTIONS.LinesRemove}
186
+ inputs={{lineIds}}
187
+ >
188
+ <button
189
+ disabled={disabled}
190
+ type="submit"
191
+ className="text-xs font-medium text-secondary-500 underline-offset-2 transition-colors hover:text-red-600 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
192
+ >
193
+ Remove
194
+ </button>
195
+ </CartForm>
196
+ );
197
+ }
198
+
199
+ function CartLineUpdateButton({
200
+ children,
201
+ lines,
202
+ }: {
203
+ children: React.ReactNode;
204
+ lines: CartLineUpdateInput[];
205
+ }) {
206
+ const lineIds = lines.map((line) => line.id);
207
+
208
+ return (
209
+ <CartForm
210
+ fetcherKey={getUpdateKey(lineIds)}
211
+ route="/cart"
212
+ action={CartForm.ACTIONS.LinesUpdate}
213
+ inputs={{lines}}
214
+ >
215
+ {children}
216
+ </CartForm>
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Returns a unique key for the update action. This is used to make sure actions modifying the same line
222
+ * items are not run concurrently, but cancel each other. For example, if the user clicks "Increase quantity"
223
+ * and "Decrease quantity" in rapid succession, the actions will cancel each other and only the last one will run.
224
+ * @param lineIds - line ids affected by the update
225
+ * @returns
226
+ */
227
+ function getUpdateKey(lineIds: string[]) {
228
+ return [CartForm.ACTIONS.LinesUpdate, ...lineIds].join('-');
229
+ }