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,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
|
+
|
|
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
|
+
→
|
|
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
|
+
}
|