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,599 @@
|
|
|
1
|
+
import {useEffect, useRef, useState, useCallback} from 'react';
|
|
2
|
+
import {Link, useFetcher, useNavigate} from 'react-router';
|
|
3
|
+
import {Image, Money} from '@shopify/hydrogen';
|
|
4
|
+
import {
|
|
5
|
+
getEmptyPredictiveSearchResult,
|
|
6
|
+
urlWithTrackingParams,
|
|
7
|
+
type PredictiveSearchReturn,
|
|
8
|
+
} from '~/lib/search';
|
|
9
|
+
|
|
10
|
+
type SearchDialogProps = {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A modal search dialog with predictive search results.
|
|
17
|
+
* Opens with Cmd+K keyboard shortcut.
|
|
18
|
+
*/
|
|
19
|
+
export function SearchDialog({isOpen, onClose}: SearchDialogProps) {
|
|
20
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
21
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const fetcher = useFetcher<PredictiveSearchReturn>({key: 'search-dialog'});
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
const [query, setQuery] = useState('');
|
|
25
|
+
|
|
26
|
+
const results = fetcher.data?.result ?? getEmptyPredictiveSearchResult();
|
|
27
|
+
const isLoading = fetcher.state === 'loading';
|
|
28
|
+
const hasResults = results.total > 0;
|
|
29
|
+
const showResults = query.length > 0;
|
|
30
|
+
|
|
31
|
+
// Focus input when dialog opens
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (isOpen) {
|
|
34
|
+
// Small delay to ensure dialog is mounted
|
|
35
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
36
|
+
} else {
|
|
37
|
+
setQuery('');
|
|
38
|
+
}
|
|
39
|
+
}, [isOpen]);
|
|
40
|
+
|
|
41
|
+
// Handle escape key
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
44
|
+
if (e.key === 'Escape' && isOpen) {
|
|
45
|
+
onClose();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
49
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
50
|
+
}, [isOpen, onClose]);
|
|
51
|
+
|
|
52
|
+
// Prevent body scroll when open
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (isOpen) {
|
|
55
|
+
document.body.style.overflow = 'hidden';
|
|
56
|
+
} else {
|
|
57
|
+
document.body.style.overflow = '';
|
|
58
|
+
}
|
|
59
|
+
return () => {
|
|
60
|
+
document.body.style.overflow = '';
|
|
61
|
+
};
|
|
62
|
+
}, [isOpen]);
|
|
63
|
+
|
|
64
|
+
const handleInputChange = useCallback(
|
|
65
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
66
|
+
const value = e.target.value;
|
|
67
|
+
setQuery(value);
|
|
68
|
+
|
|
69
|
+
if (value.length > 0) {
|
|
70
|
+
void fetcher.submit(
|
|
71
|
+
{q: value, limit: '6', predictive: 'true'},
|
|
72
|
+
{method: 'GET', action: '/search'},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[fetcher],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const handleSubmit = useCallback(
|
|
80
|
+
(e: React.FormEvent) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
if (query.length > 0) {
|
|
83
|
+
void navigate(`/search?q=${encodeURIComponent(query)}`);
|
|
84
|
+
onClose();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
[query, navigate, onClose],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const handleResultClick = useCallback(() => {
|
|
91
|
+
onClose();
|
|
92
|
+
setQuery('');
|
|
93
|
+
}, [onClose]);
|
|
94
|
+
|
|
95
|
+
if (!isOpen) return null;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
|
99
|
+
{/* Backdrop */}
|
|
100
|
+
<div
|
|
101
|
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
102
|
+
onClick={onClose}
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
{/* Dialog */}
|
|
107
|
+
<div className="relative flex min-h-full items-start justify-center px-4 pt-[15vh]">
|
|
108
|
+
<div
|
|
109
|
+
ref={dialogRef}
|
|
110
|
+
className="w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5"
|
|
111
|
+
>
|
|
112
|
+
{/* Search input */}
|
|
113
|
+
<form onSubmit={handleSubmit} className="relative">
|
|
114
|
+
<div className="flex items-center border-b border-secondary-200">
|
|
115
|
+
{/* Search icon */}
|
|
116
|
+
<div className="pointer-events-none pl-4">
|
|
117
|
+
<svg
|
|
118
|
+
className="h-5 w-5 text-secondary-400"
|
|
119
|
+
fill="none"
|
|
120
|
+
stroke="currentColor"
|
|
121
|
+
viewBox="0 0 24 24"
|
|
122
|
+
>
|
|
123
|
+
<path
|
|
124
|
+
strokeLinecap="round"
|
|
125
|
+
strokeLinejoin="round"
|
|
126
|
+
strokeWidth={2}
|
|
127
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
128
|
+
/>
|
|
129
|
+
</svg>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<input
|
|
133
|
+
ref={inputRef}
|
|
134
|
+
type="search"
|
|
135
|
+
name="q"
|
|
136
|
+
value={query}
|
|
137
|
+
onChange={handleInputChange}
|
|
138
|
+
placeholder="Search products, collections..."
|
|
139
|
+
className="h-14 w-full border-0 bg-transparent px-4 text-secondary-900 placeholder:text-secondary-400 focus:outline-none focus:ring-0"
|
|
140
|
+
autoComplete="off"
|
|
141
|
+
/>
|
|
142
|
+
|
|
143
|
+
{/* Loading indicator */}
|
|
144
|
+
{isLoading && (
|
|
145
|
+
<div className="pr-4">
|
|
146
|
+
<svg
|
|
147
|
+
className="h-5 w-5 animate-spin text-primary-600"
|
|
148
|
+
fill="none"
|
|
149
|
+
viewBox="0 0 24 24"
|
|
150
|
+
>
|
|
151
|
+
<circle
|
|
152
|
+
className="opacity-25"
|
|
153
|
+
cx="12"
|
|
154
|
+
cy="12"
|
|
155
|
+
r="10"
|
|
156
|
+
stroke="currentColor"
|
|
157
|
+
strokeWidth="4"
|
|
158
|
+
/>
|
|
159
|
+
<path
|
|
160
|
+
className="opacity-75"
|
|
161
|
+
fill="currentColor"
|
|
162
|
+
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"
|
|
163
|
+
/>
|
|
164
|
+
</svg>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Keyboard shortcut hint */}
|
|
169
|
+
{!query && (
|
|
170
|
+
<div className="hidden pr-4 sm:block">
|
|
171
|
+
<kbd className="rounded bg-secondary-100 px-2 py-1 text-xs font-medium text-secondary-500">
|
|
172
|
+
ESC
|
|
173
|
+
</kbd>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{/* Clear button */}
|
|
178
|
+
{query && !isLoading && (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={() => setQuery('')}
|
|
182
|
+
className="pr-4 text-secondary-400 hover:text-secondary-600"
|
|
183
|
+
>
|
|
184
|
+
<svg
|
|
185
|
+
className="h-5 w-5"
|
|
186
|
+
fill="none"
|
|
187
|
+
stroke="currentColor"
|
|
188
|
+
viewBox="0 0 24 24"
|
|
189
|
+
>
|
|
190
|
+
<path
|
|
191
|
+
strokeLinecap="round"
|
|
192
|
+
strokeLinejoin="round"
|
|
193
|
+
strokeWidth={2}
|
|
194
|
+
d="M6 18L18 6M6 6l12 12"
|
|
195
|
+
/>
|
|
196
|
+
</svg>
|
|
197
|
+
</button>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</form>
|
|
201
|
+
|
|
202
|
+
{/* Results */}
|
|
203
|
+
{showResults && (
|
|
204
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
205
|
+
{hasResults ? (
|
|
206
|
+
<div className="divide-y divide-secondary-100">
|
|
207
|
+
{/* Products */}
|
|
208
|
+
{results.items.products.length > 0 && (
|
|
209
|
+
<SearchResultSection
|
|
210
|
+
title="Products"
|
|
211
|
+
icon={
|
|
212
|
+
<svg
|
|
213
|
+
className="h-4 w-4"
|
|
214
|
+
fill="none"
|
|
215
|
+
stroke="currentColor"
|
|
216
|
+
viewBox="0 0 24 24"
|
|
217
|
+
>
|
|
218
|
+
<path
|
|
219
|
+
strokeLinecap="round"
|
|
220
|
+
strokeLinejoin="round"
|
|
221
|
+
strokeWidth={2}
|
|
222
|
+
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
223
|
+
/>
|
|
224
|
+
</svg>
|
|
225
|
+
}
|
|
226
|
+
>
|
|
227
|
+
{results.items.products.map((product) => (
|
|
228
|
+
<ProductResult
|
|
229
|
+
key={product.id}
|
|
230
|
+
product={product}
|
|
231
|
+
term={query}
|
|
232
|
+
onClick={handleResultClick}
|
|
233
|
+
/>
|
|
234
|
+
))}
|
|
235
|
+
</SearchResultSection>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Collections */}
|
|
239
|
+
{results.items.collections.length > 0 && (
|
|
240
|
+
<SearchResultSection
|
|
241
|
+
title="Collections"
|
|
242
|
+
icon={
|
|
243
|
+
<svg
|
|
244
|
+
className="h-4 w-4"
|
|
245
|
+
fill="none"
|
|
246
|
+
stroke="currentColor"
|
|
247
|
+
viewBox="0 0 24 24"
|
|
248
|
+
>
|
|
249
|
+
<path
|
|
250
|
+
strokeLinecap="round"
|
|
251
|
+
strokeLinejoin="round"
|
|
252
|
+
strokeWidth={2}
|
|
253
|
+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
254
|
+
/>
|
|
255
|
+
</svg>
|
|
256
|
+
}
|
|
257
|
+
>
|
|
258
|
+
{results.items.collections.map((collection) => (
|
|
259
|
+
<CollectionResult
|
|
260
|
+
key={collection.id}
|
|
261
|
+
collection={collection}
|
|
262
|
+
term={query}
|
|
263
|
+
onClick={handleResultClick}
|
|
264
|
+
/>
|
|
265
|
+
))}
|
|
266
|
+
</SearchResultSection>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{/* Pages */}
|
|
270
|
+
{results.items.pages.length > 0 && (
|
|
271
|
+
<SearchResultSection
|
|
272
|
+
title="Pages"
|
|
273
|
+
icon={
|
|
274
|
+
<svg
|
|
275
|
+
className="h-4 w-4"
|
|
276
|
+
fill="none"
|
|
277
|
+
stroke="currentColor"
|
|
278
|
+
viewBox="0 0 24 24"
|
|
279
|
+
>
|
|
280
|
+
<path
|
|
281
|
+
strokeLinecap="round"
|
|
282
|
+
strokeLinejoin="round"
|
|
283
|
+
strokeWidth={2}
|
|
284
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
285
|
+
/>
|
|
286
|
+
</svg>
|
|
287
|
+
}
|
|
288
|
+
>
|
|
289
|
+
{results.items.pages.map((page) => (
|
|
290
|
+
<PageResult
|
|
291
|
+
key={page.id}
|
|
292
|
+
page={page}
|
|
293
|
+
term={query}
|
|
294
|
+
onClick={handleResultClick}
|
|
295
|
+
/>
|
|
296
|
+
))}
|
|
297
|
+
</SearchResultSection>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
) : (
|
|
301
|
+
<div className="px-6 py-12 text-center">
|
|
302
|
+
<svg
|
|
303
|
+
className="mx-auto h-12 w-12 text-secondary-300"
|
|
304
|
+
fill="none"
|
|
305
|
+
stroke="currentColor"
|
|
306
|
+
viewBox="0 0 24 24"
|
|
307
|
+
>
|
|
308
|
+
<path
|
|
309
|
+
strokeLinecap="round"
|
|
310
|
+
strokeLinejoin="round"
|
|
311
|
+
strokeWidth={1.5}
|
|
312
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
313
|
+
/>
|
|
314
|
+
</svg>
|
|
315
|
+
<p className="mt-4 text-sm text-secondary-500">
|
|
316
|
+
No results found for “{query}”
|
|
317
|
+
</p>
|
|
318
|
+
<p className="mt-1 text-xs text-secondary-400">
|
|
319
|
+
Try searching for something else
|
|
320
|
+
</p>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{/* Footer with keyboard hints */}
|
|
327
|
+
{showResults && hasResults && (
|
|
328
|
+
<div className="border-t border-secondary-200 bg-secondary-50 px-4 py-3">
|
|
329
|
+
<div className="flex items-center justify-between text-xs text-secondary-500">
|
|
330
|
+
<span>{results.total} results</span>
|
|
331
|
+
<div className="flex items-center gap-4">
|
|
332
|
+
<span className="flex items-center gap-1">
|
|
333
|
+
<kbd className="rounded bg-white px-1.5 py-0.5 font-medium shadow-sm ring-1 ring-secondary-200">
|
|
334
|
+
Enter
|
|
335
|
+
</kbd>
|
|
336
|
+
to search
|
|
337
|
+
</span>
|
|
338
|
+
<span className="flex items-center gap-1">
|
|
339
|
+
<kbd className="rounded bg-white px-1.5 py-0.5 font-medium shadow-sm ring-1 ring-secondary-200">
|
|
340
|
+
Esc
|
|
341
|
+
</kbd>
|
|
342
|
+
to close
|
|
343
|
+
</span>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function SearchResultSection({
|
|
355
|
+
title,
|
|
356
|
+
icon,
|
|
357
|
+
children,
|
|
358
|
+
}: {
|
|
359
|
+
title: string;
|
|
360
|
+
icon: React.ReactNode;
|
|
361
|
+
children: React.ReactNode;
|
|
362
|
+
}) {
|
|
363
|
+
return (
|
|
364
|
+
<div className="py-3">
|
|
365
|
+
<div className="mb-2 flex items-center gap-2 px-4 text-xs font-semibold uppercase tracking-wide text-secondary-500">
|
|
366
|
+
{icon}
|
|
367
|
+
{title}
|
|
368
|
+
</div>
|
|
369
|
+
<div>{children}</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
type ProductResultProps = {
|
|
375
|
+
product: PredictiveSearchReturn['result']['items']['products'][0];
|
|
376
|
+
term: string;
|
|
377
|
+
onClick: () => void;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
function ProductResult({product, term, onClick}: ProductResultProps) {
|
|
381
|
+
const url = urlWithTrackingParams({
|
|
382
|
+
baseUrl: `/products/${product.handle}`,
|
|
383
|
+
trackingParams: product.trackingParameters,
|
|
384
|
+
term,
|
|
385
|
+
});
|
|
386
|
+
const price = product?.selectedOrFirstAvailableVariant?.price;
|
|
387
|
+
const image = product?.selectedOrFirstAvailableVariant?.image;
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<Link
|
|
391
|
+
to={url}
|
|
392
|
+
onClick={onClick}
|
|
393
|
+
className="flex items-center gap-4 px-4 py-2 transition-colors hover:bg-secondary-50"
|
|
394
|
+
>
|
|
395
|
+
<div className="h-12 w-12 flex-shrink-0 overflow-hidden rounded-lg bg-secondary-100">
|
|
396
|
+
{image ? (
|
|
397
|
+
<Image
|
|
398
|
+
alt={image.altText ?? product.title}
|
|
399
|
+
src={image.url}
|
|
400
|
+
width={48}
|
|
401
|
+
height={48}
|
|
402
|
+
className="h-full w-full object-cover"
|
|
403
|
+
/>
|
|
404
|
+
) : (
|
|
405
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
406
|
+
<svg
|
|
407
|
+
className="h-6 w-6 text-secondary-300"
|
|
408
|
+
fill="none"
|
|
409
|
+
stroke="currentColor"
|
|
410
|
+
viewBox="0 0 24 24"
|
|
411
|
+
>
|
|
412
|
+
<path
|
|
413
|
+
strokeLinecap="round"
|
|
414
|
+
strokeLinejoin="round"
|
|
415
|
+
strokeWidth={1.5}
|
|
416
|
+
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
417
|
+
/>
|
|
418
|
+
</svg>
|
|
419
|
+
</div>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
<div className="min-w-0 flex-1">
|
|
423
|
+
<p className="truncate text-sm font-medium text-secondary-900">
|
|
424
|
+
{product.title}
|
|
425
|
+
</p>
|
|
426
|
+
{price && (
|
|
427
|
+
<p className="text-sm text-secondary-500">
|
|
428
|
+
<Money data={price} />
|
|
429
|
+
</p>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
<svg
|
|
433
|
+
className="h-4 w-4 flex-shrink-0 text-secondary-400"
|
|
434
|
+
fill="none"
|
|
435
|
+
stroke="currentColor"
|
|
436
|
+
viewBox="0 0 24 24"
|
|
437
|
+
>
|
|
438
|
+
<path
|
|
439
|
+
strokeLinecap="round"
|
|
440
|
+
strokeLinejoin="round"
|
|
441
|
+
strokeWidth={2}
|
|
442
|
+
d="M9 5l7 7-7 7"
|
|
443
|
+
/>
|
|
444
|
+
</svg>
|
|
445
|
+
</Link>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
type CollectionResultProps = {
|
|
450
|
+
collection: PredictiveSearchReturn['result']['items']['collections'][0];
|
|
451
|
+
term: string;
|
|
452
|
+
onClick: () => void;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
function CollectionResult({collection, term, onClick}: CollectionResultProps) {
|
|
456
|
+
const url = urlWithTrackingParams({
|
|
457
|
+
baseUrl: `/collections/${collection.handle}`,
|
|
458
|
+
trackingParams: collection.trackingParameters,
|
|
459
|
+
term,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<Link
|
|
464
|
+
to={url}
|
|
465
|
+
onClick={onClick}
|
|
466
|
+
className="flex items-center gap-4 px-4 py-2 transition-colors hover:bg-secondary-50"
|
|
467
|
+
>
|
|
468
|
+
<div className="h-12 w-12 flex-shrink-0 overflow-hidden rounded-lg bg-secondary-100">
|
|
469
|
+
{collection.image?.url ? (
|
|
470
|
+
<Image
|
|
471
|
+
alt={collection.image.altText ?? collection.title}
|
|
472
|
+
src={collection.image.url}
|
|
473
|
+
width={48}
|
|
474
|
+
height={48}
|
|
475
|
+
className="h-full w-full object-cover"
|
|
476
|
+
/>
|
|
477
|
+
) : (
|
|
478
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
479
|
+
<svg
|
|
480
|
+
className="h-6 w-6 text-secondary-300"
|
|
481
|
+
fill="none"
|
|
482
|
+
stroke="currentColor"
|
|
483
|
+
viewBox="0 0 24 24"
|
|
484
|
+
>
|
|
485
|
+
<path
|
|
486
|
+
strokeLinecap="round"
|
|
487
|
+
strokeLinejoin="round"
|
|
488
|
+
strokeWidth={1.5}
|
|
489
|
+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
490
|
+
/>
|
|
491
|
+
</svg>
|
|
492
|
+
</div>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
<div className="min-w-0 flex-1">
|
|
496
|
+
<p className="truncate text-sm font-medium text-secondary-900">
|
|
497
|
+
{collection.title}
|
|
498
|
+
</p>
|
|
499
|
+
<p className="text-sm text-secondary-500">Collection</p>
|
|
500
|
+
</div>
|
|
501
|
+
<svg
|
|
502
|
+
className="h-4 w-4 flex-shrink-0 text-secondary-400"
|
|
503
|
+
fill="none"
|
|
504
|
+
stroke="currentColor"
|
|
505
|
+
viewBox="0 0 24 24"
|
|
506
|
+
>
|
|
507
|
+
<path
|
|
508
|
+
strokeLinecap="round"
|
|
509
|
+
strokeLinejoin="round"
|
|
510
|
+
strokeWidth={2}
|
|
511
|
+
d="M9 5l7 7-7 7"
|
|
512
|
+
/>
|
|
513
|
+
</svg>
|
|
514
|
+
</Link>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
type PageResultProps = {
|
|
519
|
+
page: PredictiveSearchReturn['result']['items']['pages'][0];
|
|
520
|
+
term: string;
|
|
521
|
+
onClick: () => void;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
function PageResult({page, term, onClick}: PageResultProps) {
|
|
525
|
+
const url = urlWithTrackingParams({
|
|
526
|
+
baseUrl: `/pages/${page.handle}`,
|
|
527
|
+
trackingParams: page.trackingParameters,
|
|
528
|
+
term,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
<Link
|
|
533
|
+
to={url}
|
|
534
|
+
onClick={onClick}
|
|
535
|
+
className="flex items-center gap-4 px-4 py-2 transition-colors hover:bg-secondary-50"
|
|
536
|
+
>
|
|
537
|
+
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-secondary-100">
|
|
538
|
+
<svg
|
|
539
|
+
className="h-6 w-6 text-secondary-400"
|
|
540
|
+
fill="none"
|
|
541
|
+
stroke="currentColor"
|
|
542
|
+
viewBox="0 0 24 24"
|
|
543
|
+
>
|
|
544
|
+
<path
|
|
545
|
+
strokeLinecap="round"
|
|
546
|
+
strokeLinejoin="round"
|
|
547
|
+
strokeWidth={1.5}
|
|
548
|
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
549
|
+
/>
|
|
550
|
+
</svg>
|
|
551
|
+
</div>
|
|
552
|
+
<div className="min-w-0 flex-1">
|
|
553
|
+
<p className="truncate text-sm font-medium text-secondary-900">
|
|
554
|
+
{page.title}
|
|
555
|
+
</p>
|
|
556
|
+
<p className="text-sm text-secondary-500">Page</p>
|
|
557
|
+
</div>
|
|
558
|
+
<svg
|
|
559
|
+
className="h-4 w-4 flex-shrink-0 text-secondary-400"
|
|
560
|
+
fill="none"
|
|
561
|
+
stroke="currentColor"
|
|
562
|
+
viewBox="0 0 24 24"
|
|
563
|
+
>
|
|
564
|
+
<path
|
|
565
|
+
strokeLinecap="round"
|
|
566
|
+
strokeLinejoin="round"
|
|
567
|
+
strokeWidth={2}
|
|
568
|
+
d="M9 5l7 7-7 7"
|
|
569
|
+
/>
|
|
570
|
+
</svg>
|
|
571
|
+
</Link>
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Hook to manage search dialog state with Cmd+K shortcut
|
|
577
|
+
*/
|
|
578
|
+
export function useSearchDialog() {
|
|
579
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
580
|
+
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
583
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
584
|
+
e.preventDefault();
|
|
585
|
+
setIsOpen((prev) => !prev);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
590
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
591
|
+
}, []);
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
isOpen,
|
|
595
|
+
open: () => setIsOpen(true),
|
|
596
|
+
close: () => setIsOpen(false),
|
|
597
|
+
toggle: () => setIsOpen((prev) => !prev),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {useRef, useEffect} from 'react';
|
|
2
|
+
import {Form, type FormProps} from 'react-router';
|
|
3
|
+
|
|
4
|
+
type SearchFormProps = Omit<FormProps, 'children'> & {
|
|
5
|
+
children: (args: {
|
|
6
|
+
inputRef: React.RefObject<HTMLInputElement>;
|
|
7
|
+
}) => React.ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Search form component that sends search requests to the `/search` route.
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <SearchForm>
|
|
15
|
+
* {({inputRef}) => (
|
|
16
|
+
* <>
|
|
17
|
+
* <input
|
|
18
|
+
* ref={inputRef}
|
|
19
|
+
* type="search"
|
|
20
|
+
* defaultValue={term}
|
|
21
|
+
* name="q"
|
|
22
|
+
* placeholder="Search…"
|
|
23
|
+
* />
|
|
24
|
+
* <button type="submit">Search</button>
|
|
25
|
+
* </>
|
|
26
|
+
* )}
|
|
27
|
+
* </SearchForm>
|
|
28
|
+
*/
|
|
29
|
+
export function SearchForm({children, ...props}: SearchFormProps) {
|
|
30
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
31
|
+
|
|
32
|
+
useFocusOnCmdK(inputRef);
|
|
33
|
+
|
|
34
|
+
if (typeof children !== 'function') {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Form method="get" {...props}>
|
|
40
|
+
{children({inputRef})}
|
|
41
|
+
</Form>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Focuses the input when cmd+k is pressed
|
|
47
|
+
*/
|
|
48
|
+
function useFocusOnCmdK(inputRef: React.RefObject<HTMLInputElement>) {
|
|
49
|
+
// focus the input when cmd+k is pressed
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
52
|
+
if (event.key === 'k' && event.metaKey) {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
inputRef.current?.focus();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (event.key === 'Escape') {
|
|
58
|
+
inputRef.current?.blur();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
66
|
+
};
|
|
67
|
+
}, [inputRef]);
|
|
68
|
+
}
|