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.
- package/README.md +212 -0
- package/dist/commands/add.d.ts +7 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +123 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +160 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/setup-mcp.d.ts +7 -0
- package/dist/commands/setup-mcp.d.ts.map +1 -0
- package/dist/commands/setup-mcp.js +179 -0
- package/dist/commands/setup-mcp.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/generators.d.ts +6 -0
- package/dist/lib/generators.d.ts.map +1 -0
- package/dist/lib/generators.js +470 -0
- package/dist/lib/generators.js.map +1 -0
- package/dist/lib/utils.d.ts +17 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +101 -0
- package/dist/lib/utils.js.map +1 -0
- package/package.json +54 -0
- package/templates/starter/.env.example +21 -0
- package/templates/starter/.graphqlrc.ts +27 -0
- package/templates/starter/README.md +117 -0
- package/templates/starter/app/assets/favicon.svg +28 -0
- package/templates/starter/app/components/AddToCartButton.tsx +102 -0
- package/templates/starter/app/components/Aside.tsx +136 -0
- package/templates/starter/app/components/CartLineItem.tsx +229 -0
- package/templates/starter/app/components/CartMain.tsx +131 -0
- package/templates/starter/app/components/CartSummary.tsx +315 -0
- package/templates/starter/app/components/CollectionFilters.tsx +330 -0
- package/templates/starter/app/components/CollectionGrid.tsx +141 -0
- package/templates/starter/app/components/Footer.tsx +218 -0
- package/templates/starter/app/components/Header.tsx +296 -0
- package/templates/starter/app/components/PageLayout.tsx +174 -0
- package/templates/starter/app/components/PaginatedResourceSection.tsx +41 -0
- package/templates/starter/app/components/ProductCard.tsx +151 -0
- package/templates/starter/app/components/ProductForm.tsx +156 -0
- package/templates/starter/app/components/ProductGallery.tsx +164 -0
- package/templates/starter/app/components/ProductGrid.tsx +64 -0
- package/templates/starter/app/components/ProductImage.tsx +23 -0
- package/templates/starter/app/components/ProductItem.tsx +44 -0
- package/templates/starter/app/components/ProductPrice.tsx +97 -0
- package/templates/starter/app/components/SearchDialog.tsx +599 -0
- package/templates/starter/app/components/SearchForm.tsx +68 -0
- package/templates/starter/app/components/SearchFormPredictive.tsx +76 -0
- package/templates/starter/app/components/SearchResults.tsx +161 -0
- package/templates/starter/app/components/SearchResultsPredictive.tsx +461 -0
- package/templates/starter/app/entry.client.tsx +21 -0
- package/templates/starter/app/entry.server.tsx +53 -0
- package/templates/starter/app/graphql/customer-account/CustomerAddressMutations.ts +64 -0
- package/templates/starter/app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
- package/templates/starter/app/graphql/customer-account/CustomerOrderQuery.ts +90 -0
- package/templates/starter/app/graphql/customer-account/CustomerOrdersQuery.ts +63 -0
- package/templates/starter/app/graphql/customer-account/CustomerUpdateMutation.ts +25 -0
- package/templates/starter/app/lib/context.ts +60 -0
- package/templates/starter/app/lib/fragments.ts +234 -0
- package/templates/starter/app/lib/orderFilters.ts +90 -0
- package/templates/starter/app/lib/redirect.ts +23 -0
- package/templates/starter/app/lib/search.ts +79 -0
- package/templates/starter/app/lib/session.ts +72 -0
- package/templates/starter/app/lib/variants.ts +46 -0
- package/templates/starter/app/root.tsx +209 -0
- package/templates/starter/app/routes/$.tsx +11 -0
- package/templates/starter/app/routes/[robots.txt].tsx +117 -0
- package/templates/starter/app/routes/[sitemap.xml].tsx +16 -0
- package/templates/starter/app/routes/_index.tsx +167 -0
- package/templates/starter/app/routes/account.$.tsx +9 -0
- package/templates/starter/app/routes/account._index.tsx +5 -0
- package/templates/starter/app/routes/account.addresses.tsx +516 -0
- package/templates/starter/app/routes/account.orders.$id.tsx +222 -0
- package/templates/starter/app/routes/account.orders._index.tsx +222 -0
- package/templates/starter/app/routes/account.profile.tsx +133 -0
- package/templates/starter/app/routes/account.tsx +97 -0
- package/templates/starter/app/routes/account_.authorize.tsx +5 -0
- package/templates/starter/app/routes/account_.login.tsx +7 -0
- package/templates/starter/app/routes/account_.logout.tsx +11 -0
- package/templates/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
- package/templates/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +129 -0
- package/templates/starter/app/routes/blogs.$blogHandle._index.tsx +175 -0
- package/templates/starter/app/routes/blogs._index.tsx +109 -0
- package/templates/starter/app/routes/cart.$lines.tsx +70 -0
- package/templates/starter/app/routes/cart.tsx +117 -0
- package/templates/starter/app/routes/collections.$handle.tsx +161 -0
- package/templates/starter/app/routes/collections._index.tsx +133 -0
- package/templates/starter/app/routes/collections.all.tsx +122 -0
- package/templates/starter/app/routes/discount.$code.tsx +48 -0
- package/templates/starter/app/routes/pages.$handle.tsx +88 -0
- package/templates/starter/app/routes/policies.$handle.tsx +93 -0
- package/templates/starter/app/routes/policies._index.tsx +69 -0
- package/templates/starter/app/routes/products.$handle.tsx +232 -0
- package/templates/starter/app/routes/search.tsx +426 -0
- package/templates/starter/app/routes/sitemap.$type.$page[.xml].tsx +23 -0
- package/templates/starter/app/routes.ts +9 -0
- package/templates/starter/app/styles/app.css +574 -0
- package/templates/starter/app/styles/reset.css +139 -0
- package/templates/starter/app/styles/tailwind.css +116 -0
- package/templates/starter/customer-accountapi.generated.d.ts +543 -0
- package/templates/starter/env.d.ts +7 -0
- package/templates/starter/eslint.config.js +247 -0
- package/templates/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
- package/templates/starter/guides/predictiveSearch/predictiveSearch.md +394 -0
- package/templates/starter/guides/search/search.jpg +0 -0
- package/templates/starter/guides/search/search.md +335 -0
- package/templates/starter/package.json +71 -0
- package/templates/starter/postcss.config.js +6 -0
- package/templates/starter/public/.gitkeep +0 -0
- package/templates/starter/react-router.config.ts +13 -0
- package/templates/starter/server.ts +59 -0
- package/templates/starter/storefrontapi.generated.d.ts +1264 -0
- package/templates/starter/tailwind.config.js +83 -0
- package/templates/starter/tsconfig.json +67 -0
- 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
|
+
}
|