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,296 @@
1
+ import {Suspense} from 'react';
2
+ import {Await, NavLink, useAsyncValue} from 'react-router';
3
+ import {
4
+ type CartViewPayload,
5
+ useAnalytics,
6
+ useOptimisticCart,
7
+ } from '@shopify/hydrogen';
8
+ import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated';
9
+ import {useAside} from '~/components/Aside';
10
+
11
+ export interface HeaderProps {
12
+ header: HeaderQuery;
13
+ cart: Promise<CartApiQueryFragment | null>;
14
+ isLoggedIn: Promise<boolean>;
15
+ publicStoreDomain: string;
16
+ }
17
+
18
+ type Viewport = 'desktop' | 'mobile';
19
+
20
+ export function Header({
21
+ header,
22
+ isLoggedIn,
23
+ cart,
24
+ publicStoreDomain,
25
+ }: HeaderProps) {
26
+ const {shop, menu} = header;
27
+ return (
28
+ <header className="sticky top-0 z-40 w-full border-b border-secondary-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60">
29
+ <div className="container-narrow flex h-16 items-center justify-between">
30
+ {/* Logo */}
31
+ <NavLink
32
+ prefetch="intent"
33
+ to="/"
34
+ className="flex items-center space-x-2 text-xl font-bold text-secondary-900 transition-colors hover:text-primary-600"
35
+ end
36
+ >
37
+ {shop.name}
38
+ </NavLink>
39
+
40
+ {/* Desktop Navigation */}
41
+ <Navigation
42
+ menu={menu}
43
+ viewport="desktop"
44
+ primaryDomainUrl={header.shop.primaryDomain.url}
45
+ publicStoreDomain={publicStoreDomain}
46
+ />
47
+
48
+ {/* Header Actions */}
49
+ <HeaderActions isLoggedIn={isLoggedIn} cart={cart} />
50
+ </div>
51
+ </header>
52
+ );
53
+ }
54
+
55
+ export function Navigation({
56
+ menu,
57
+ primaryDomainUrl,
58
+ viewport,
59
+ publicStoreDomain,
60
+ }: {
61
+ menu: HeaderProps['header']['menu'];
62
+ primaryDomainUrl: HeaderProps['header']['shop']['primaryDomain']['url'];
63
+ viewport: Viewport;
64
+ publicStoreDomain: HeaderProps['publicStoreDomain'];
65
+ }) {
66
+ const {close} = useAside();
67
+
68
+ const baseStyles =
69
+ viewport === 'desktop'
70
+ ? 'hidden md:flex items-center space-x-6'
71
+ : 'flex flex-col space-y-4';
72
+
73
+ const linkStyles =
74
+ viewport === 'desktop'
75
+ ? 'text-sm font-medium text-secondary-600 transition-colors hover:text-secondary-900'
76
+ : 'text-lg font-medium text-secondary-900 hover:text-primary-600';
77
+
78
+ const activeLinkStyles =
79
+ viewport === 'desktop'
80
+ ? 'text-sm font-medium text-secondary-900'
81
+ : 'text-lg font-medium text-primary-600';
82
+
83
+ return (
84
+ <nav className={baseStyles} role="navigation">
85
+ {viewport === 'mobile' && (
86
+ <NavLink
87
+ end
88
+ onClick={close}
89
+ prefetch="intent"
90
+ to="/"
91
+ className={({isActive}) => (isActive ? activeLinkStyles : linkStyles)}
92
+ >
93
+ Home
94
+ </NavLink>
95
+ )}
96
+ {(menu || FALLBACK_HEADER_MENU).items.map((item) => {
97
+ if (!item.url) return null;
98
+
99
+ // if the url is internal, we strip the domain
100
+ const url =
101
+ item.url.includes('myshopify.com') ||
102
+ item.url.includes(publicStoreDomain) ||
103
+ item.url.includes(primaryDomainUrl)
104
+ ? new URL(item.url).pathname
105
+ : item.url;
106
+
107
+ return (
108
+ <NavLink
109
+ end
110
+ key={item.id}
111
+ onClick={close}
112
+ prefetch="intent"
113
+ to={url}
114
+ className={({isActive}) =>
115
+ isActive ? activeLinkStyles : linkStyles
116
+ }
117
+ >
118
+ {item.title}
119
+ </NavLink>
120
+ );
121
+ })}
122
+ </nav>
123
+ );
124
+ }
125
+
126
+ function HeaderActions({
127
+ isLoggedIn,
128
+ cart,
129
+ }: Pick<HeaderProps, 'isLoggedIn' | 'cart'>) {
130
+ return (
131
+ <nav className="flex items-center space-x-4" role="navigation">
132
+ <MobileMenuToggle />
133
+ <NavLink
134
+ prefetch="intent"
135
+ to="/account"
136
+ className="hidden text-sm font-medium text-secondary-600 transition-colors hover:text-secondary-900 sm:block"
137
+ >
138
+ <Suspense fallback="Sign in">
139
+ <Await resolve={isLoggedIn} errorElement="Sign in">
140
+ {(isLoggedIn) => (isLoggedIn ? 'Account' : 'Sign in')}
141
+ </Await>
142
+ </Suspense>
143
+ </NavLink>
144
+ <SearchToggle />
145
+ <CartToggle cart={cart} />
146
+ </nav>
147
+ );
148
+ }
149
+
150
+ function MobileMenuToggle() {
151
+ const {open} = useAside();
152
+ return (
153
+ <button
154
+ className="inline-flex h-10 w-10 items-center justify-center rounded-md text-secondary-600 transition-colors hover:bg-secondary-100 hover:text-secondary-900 md:hidden"
155
+ onClick={() => open('mobile')}
156
+ aria-label="Open menu"
157
+ >
158
+ <svg
159
+ className="h-6 w-6"
160
+ fill="none"
161
+ stroke="currentColor"
162
+ viewBox="0 0 24 24"
163
+ >
164
+ <path
165
+ strokeLinecap="round"
166
+ strokeLinejoin="round"
167
+ strokeWidth={2}
168
+ d="M4 6h16M4 12h16M4 18h16"
169
+ />
170
+ </svg>
171
+ </button>
172
+ );
173
+ }
174
+
175
+ function SearchToggle() {
176
+ const {open} = useAside();
177
+ return (
178
+ <button
179
+ className="inline-flex h-10 w-10 items-center justify-center rounded-md text-secondary-600 transition-colors hover:bg-secondary-100 hover:text-secondary-900"
180
+ onClick={() => open('search')}
181
+ aria-label="Search"
182
+ >
183
+ <svg
184
+ className="h-5 w-5"
185
+ fill="none"
186
+ stroke="currentColor"
187
+ viewBox="0 0 24 24"
188
+ >
189
+ <path
190
+ strokeLinecap="round"
191
+ strokeLinejoin="round"
192
+ strokeWidth={2}
193
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
194
+ />
195
+ </svg>
196
+ </button>
197
+ );
198
+ }
199
+
200
+ function CartBadge({count}: {count: number | null}) {
201
+ const {open} = useAside();
202
+ const {publish, shop, cart, prevCart} = useAnalytics();
203
+
204
+ return (
205
+ <button
206
+ className="relative inline-flex h-10 w-10 items-center justify-center rounded-md text-secondary-600 transition-colors hover:bg-secondary-100 hover:text-secondary-900"
207
+ onClick={() => {
208
+ open('cart');
209
+ publish('cart_viewed', {
210
+ cart,
211
+ prevCart,
212
+ shop,
213
+ url: window.location.href || '',
214
+ } as CartViewPayload);
215
+ }}
216
+ aria-label={`Cart${count ? ` (${count} items)` : ''}`}
217
+ >
218
+ <svg
219
+ className="h-5 w-5"
220
+ fill="none"
221
+ stroke="currentColor"
222
+ viewBox="0 0 24 24"
223
+ >
224
+ <path
225
+ strokeLinecap="round"
226
+ strokeLinejoin="round"
227
+ strokeWidth={2}
228
+ d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
229
+ />
230
+ </svg>
231
+ {count !== null && count > 0 && (
232
+ <span className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary-600 text-xs font-medium text-white">
233
+ {count > 99 ? '99+' : count}
234
+ </span>
235
+ )}
236
+ </button>
237
+ );
238
+ }
239
+
240
+ function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
241
+ return (
242
+ <Suspense fallback={<CartBadge count={null} />}>
243
+ <Await resolve={cart}>
244
+ <CartBanner />
245
+ </Await>
246
+ </Suspense>
247
+ );
248
+ }
249
+
250
+ function CartBanner() {
251
+ const originalCart = useAsyncValue() as CartApiQueryFragment | null;
252
+ const cart = useOptimisticCart(originalCart);
253
+ return <CartBadge count={cart?.totalQuantity ?? 0} />;
254
+ }
255
+
256
+ const FALLBACK_HEADER_MENU = {
257
+ id: 'gid://shopify/Menu/199655587896',
258
+ items: [
259
+ {
260
+ id: 'gid://shopify/MenuItem/461609500728',
261
+ resourceId: null,
262
+ tags: [],
263
+ title: 'Collections',
264
+ type: 'HTTP',
265
+ url: '/collections',
266
+ items: [],
267
+ },
268
+ {
269
+ id: 'gid://shopify/MenuItem/461609533496',
270
+ resourceId: null,
271
+ tags: [],
272
+ title: 'Blog',
273
+ type: 'HTTP',
274
+ url: '/blogs/journal',
275
+ items: [],
276
+ },
277
+ {
278
+ id: 'gid://shopify/MenuItem/461609566264',
279
+ resourceId: null,
280
+ tags: [],
281
+ title: 'Policies',
282
+ type: 'HTTP',
283
+ url: '/policies',
284
+ items: [],
285
+ },
286
+ {
287
+ id: 'gid://shopify/MenuItem/461609599032',
288
+ resourceId: 'gid://shopify/Page/92591030328',
289
+ tags: [],
290
+ title: 'About',
291
+ type: 'PAGE',
292
+ url: '/pages/about',
293
+ items: [],
294
+ },
295
+ ],
296
+ };
@@ -0,0 +1,174 @@
1
+ import {Await, Link} from 'react-router';
2
+ import {Suspense, useId} 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, Navigation} 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
+ const queriesDatalistId = useId();
74
+ return (
75
+ <Aside type="search" heading="SEARCH">
76
+ <div className="predictive-search">
77
+ <br />
78
+ <SearchFormPredictive>
79
+ {({fetchResults, goToSearch, inputRef}) => (
80
+ <>
81
+ <input
82
+ name="q"
83
+ onChange={fetchResults}
84
+ onFocus={fetchResults}
85
+ placeholder="Search"
86
+ ref={inputRef}
87
+ type="search"
88
+ list={queriesDatalistId}
89
+ />
90
+ &nbsp;
91
+ <button onClick={goToSearch}>Search</button>
92
+ </>
93
+ )}
94
+ </SearchFormPredictive>
95
+
96
+ <SearchResultsPredictive>
97
+ {({items, total, term, state, closeSearch}) => {
98
+ const {articles, collections, pages, products, queries} = items;
99
+
100
+ if (state === 'loading' && term.current) {
101
+ return <div>Loading...</div>;
102
+ }
103
+
104
+ if (!total) {
105
+ return <SearchResultsPredictive.Empty term={term} />;
106
+ }
107
+
108
+ return (
109
+ <>
110
+ <SearchResultsPredictive.Queries
111
+ queries={queries}
112
+ queriesDatalistId={queriesDatalistId}
113
+ />
114
+ <SearchResultsPredictive.Products
115
+ products={products}
116
+ closeSearch={closeSearch}
117
+ term={term}
118
+ />
119
+ <SearchResultsPredictive.Collections
120
+ collections={collections}
121
+ closeSearch={closeSearch}
122
+ term={term}
123
+ />
124
+ <SearchResultsPredictive.Pages
125
+ pages={pages}
126
+ closeSearch={closeSearch}
127
+ term={term}
128
+ />
129
+ <SearchResultsPredictive.Articles
130
+ articles={articles}
131
+ closeSearch={closeSearch}
132
+ term={term}
133
+ />
134
+ {term.current && total ? (
135
+ <Link
136
+ onClick={closeSearch}
137
+ to={`${SEARCH_ENDPOINT}?q=${term.current}`}
138
+ >
139
+ <p>
140
+ View all results for <q>{term.current}</q>
141
+ &nbsp; →
142
+ </p>
143
+ </Link>
144
+ ) : null}
145
+ </>
146
+ );
147
+ }}
148
+ </SearchResultsPredictive>
149
+ </div>
150
+ </Aside>
151
+ );
152
+ }
153
+
154
+ function MobileMenuAside({
155
+ header,
156
+ publicStoreDomain,
157
+ }: {
158
+ header: PageLayoutProps['header'];
159
+ publicStoreDomain: PageLayoutProps['publicStoreDomain'];
160
+ }) {
161
+ return (
162
+ header.menu &&
163
+ header.shop.primaryDomain?.url && (
164
+ <Aside type="mobile" heading="MENU">
165
+ <Navigation
166
+ menu={header.menu}
167
+ viewport="mobile"
168
+ primaryDomainUrl={header.shop.primaryDomain.url}
169
+ publicStoreDomain={publicStoreDomain}
170
+ />
171
+ </Aside>
172
+ )
173
+ );
174
+ }
@@ -0,0 +1,41 @@
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
+ export function PaginatedResourceSection<NodesType>({
8
+ connection,
9
+ children,
10
+ resourcesClassName,
11
+ }: {
12
+ connection: React.ComponentProps<typeof Pagination<NodesType>>['connection'];
13
+ children: React.FunctionComponent<{node: NodesType; index: number}>;
14
+ resourcesClassName?: string;
15
+ }) {
16
+ return (
17
+ <Pagination connection={connection}>
18
+ {({nodes, isLoading, PreviousLink, NextLink}) => {
19
+ const resourcesMarkup = nodes.map((node, index) =>
20
+ children({node, index}),
21
+ );
22
+
23
+ return (
24
+ <div>
25
+ <PreviousLink>
26
+ {isLoading ? 'Loading...' : <span>↑ Load previous</span>}
27
+ </PreviousLink>
28
+ {resourcesClassName ? (
29
+ <div className={resourcesClassName}>{resourcesMarkup}</div>
30
+ ) : (
31
+ resourcesMarkup
32
+ )}
33
+ <NextLink>
34
+ {isLoading ? 'Loading...' : <span>Load more ↓</span>}
35
+ </NextLink>
36
+ </div>
37
+ );
38
+ }}
39
+ </Pagination>
40
+ );
41
+ }
@@ -0,0 +1,151 @@
1
+ import {Link} from 'react-router';
2
+ import {Image, Money} from '@shopify/hydrogen';
3
+ import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types';
4
+ import {useVariantUrl} from '~/lib/variants';
5
+
6
+ export interface ProductCardProps {
7
+ product: {
8
+ id: string;
9
+ title: string;
10
+ handle: string;
11
+ featuredImage?: {
12
+ id?: string;
13
+ url: string;
14
+ altText?: string | null;
15
+ width?: number;
16
+ height?: number;
17
+ } | null;
18
+ priceRange: {
19
+ minVariantPrice: MoneyV2;
20
+ maxVariantPrice: MoneyV2;
21
+ };
22
+ compareAtPriceRange?: {
23
+ minVariantPrice: MoneyV2;
24
+ };
25
+ availableForSale?: boolean;
26
+ vendor?: string;
27
+ };
28
+ loading?: 'eager' | 'lazy';
29
+ showVendor?: boolean;
30
+ aspectRatio?: '1/1' | '4/5' | '3/4' | '16/9';
31
+ }
32
+
33
+ export function ProductCard({
34
+ product,
35
+ loading = 'lazy',
36
+ showVendor = false,
37
+ aspectRatio = '1/1',
38
+ }: ProductCardProps) {
39
+ const variantUrl = useVariantUrl(product.handle);
40
+ const image = product.featuredImage;
41
+ const isOnSale =
42
+ product.compareAtPriceRange?.minVariantPrice?.amount &&
43
+ parseFloat(product.compareAtPriceRange.minVariantPrice.amount) >
44
+ parseFloat(product.priceRange.minVariantPrice.amount);
45
+ const isSoldOut = product.availableForSale === false;
46
+
47
+ return (
48
+ <Link
49
+ className="group block"
50
+ key={product.id}
51
+ prefetch="intent"
52
+ to={variantUrl}
53
+ >
54
+ {/* Image Container */}
55
+ <div className="relative aspect-square overflow-hidden rounded-lg bg-secondary-100">
56
+ {image ? (
57
+ <Image
58
+ alt={image.altText || product.title}
59
+ aspectRatio={aspectRatio}
60
+ data={image}
61
+ loading={loading}
62
+ sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
63
+ className="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
64
+ />
65
+ ) : (
66
+ <div className="flex h-full w-full items-center justify-center">
67
+ <svg
68
+ className="h-12 w-12 text-secondary-300"
69
+ fill="none"
70
+ stroke="currentColor"
71
+ viewBox="0 0 24 24"
72
+ >
73
+ <path
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ strokeWidth={1}
77
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
78
+ />
79
+ </svg>
80
+ </div>
81
+ )}
82
+
83
+ {/* Badges */}
84
+ <div className="absolute left-2 top-2 flex flex-col gap-1">
85
+ {isOnSale && (
86
+ <span className="rounded bg-red-500 px-2 py-0.5 text-xs font-medium text-white">
87
+ Sale
88
+ </span>
89
+ )}
90
+ {isSoldOut && (
91
+ <span className="rounded bg-secondary-900 px-2 py-0.5 text-xs font-medium text-white">
92
+ Sold out
93
+ </span>
94
+ )}
95
+ </div>
96
+
97
+ {/* Quick Add Button (optional hover state) */}
98
+ <div className="absolute bottom-0 left-0 right-0 translate-y-full opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100">
99
+ <div className="bg-white/90 p-2 backdrop-blur-sm">
100
+ <span className="block text-center text-sm font-medium text-secondary-900">
101
+ View Product
102
+ </span>
103
+ </div>
104
+ </div>
105
+ </div>
106
+
107
+ {/* Product Info */}
108
+ <div className="mt-3 space-y-1">
109
+ {showVendor && product.vendor && (
110
+ <p className="text-xs uppercase tracking-wide text-secondary-500">
111
+ {product.vendor}
112
+ </p>
113
+ )}
114
+ <h3 className="text-sm font-medium text-secondary-900 transition-colors group-hover:text-primary-600">
115
+ {product.title}
116
+ </h3>
117
+ <ProductCardPrice
118
+ price={product.priceRange.minVariantPrice}
119
+ compareAtPrice={product.compareAtPriceRange?.minVariantPrice}
120
+ />
121
+ </div>
122
+ </Link>
123
+ );
124
+ }
125
+
126
+ function ProductCardPrice({
127
+ price,
128
+ compareAtPrice,
129
+ }: {
130
+ price: MoneyV2;
131
+ compareAtPrice?: MoneyV2;
132
+ }) {
133
+ const isOnSale =
134
+ compareAtPrice?.amount &&
135
+ parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
136
+
137
+ return (
138
+ <div className="flex items-center gap-2">
139
+ <span
140
+ className={`text-sm font-medium ${isOnSale ? 'text-red-600' : 'text-secondary-900'}`}
141
+ >
142
+ <Money data={price} />
143
+ </span>
144
+ {isOnSale && compareAtPrice && (
145
+ <span className="text-sm text-secondary-500 line-through">
146
+ <Money data={compareAtPrice} />
147
+ </span>
148
+ )}
149
+ </div>
150
+ );
151
+ }