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,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 &ldquo;{query}&rdquo;
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
+ }