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,76 @@
1
+ import {
2
+ useFetcher,
3
+ useNavigate,
4
+ type FormProps,
5
+ type Fetcher,
6
+ } from 'react-router';
7
+ import React, {useRef, useEffect} from 'react';
8
+ import type {PredictiveSearchReturn} from '~/lib/search';
9
+ import {useAside} from './Aside';
10
+
11
+ type SearchFormPredictiveChildren = (args: {
12
+ fetchResults: (event: React.ChangeEvent<HTMLInputElement>) => void;
13
+ goToSearch: () => void;
14
+ inputRef: React.MutableRefObject<HTMLInputElement | null>;
15
+ fetcher: Fetcher<PredictiveSearchReturn>;
16
+ }) => React.ReactNode;
17
+
18
+ type SearchFormPredictiveProps = Omit<FormProps, 'children'> & {
19
+ children: SearchFormPredictiveChildren | null;
20
+ };
21
+
22
+ export const SEARCH_ENDPOINT = '/search';
23
+
24
+ /**
25
+ * Search form component that sends search requests to the `/search` route
26
+ **/
27
+ export function SearchFormPredictive({
28
+ children,
29
+ className = 'predictive-search-form',
30
+ ...props
31
+ }: SearchFormPredictiveProps) {
32
+ const fetcher = useFetcher<PredictiveSearchReturn>({key: 'search'});
33
+ const inputRef = useRef<HTMLInputElement | null>(null);
34
+ const navigate = useNavigate();
35
+ const aside = useAside();
36
+
37
+ /** Reset the input value and blur the input */
38
+ function resetInput(event: React.FormEvent<HTMLFormElement>) {
39
+ event.preventDefault();
40
+ event.stopPropagation();
41
+ if (inputRef?.current?.value) {
42
+ inputRef.current.blur();
43
+ }
44
+ }
45
+
46
+ /** Navigate to the search page with the current input value */
47
+ function goToSearch() {
48
+ const term = inputRef?.current?.value;
49
+ void navigate(SEARCH_ENDPOINT + (term ? `?q=${term}` : ''));
50
+ aside.close();
51
+ }
52
+
53
+ /** Fetch search results based on the input value */
54
+ function fetchResults(event: React.ChangeEvent<HTMLInputElement>) {
55
+ void fetcher.submit(
56
+ {q: event.target.value || '', limit: 5, predictive: true},
57
+ {method: 'GET', action: SEARCH_ENDPOINT},
58
+ );
59
+ }
60
+
61
+ // ensure the passed input has a type of search, because SearchResults
62
+ // will select the element based on the input
63
+ useEffect(() => {
64
+ inputRef?.current?.setAttribute('type', 'search');
65
+ }, []);
66
+
67
+ if (typeof children !== 'function') {
68
+ return null;
69
+ }
70
+
71
+ return (
72
+ <fetcher.Form {...props} className={className} onSubmit={resetInput}>
73
+ {children({inputRef, fetcher, fetchResults, goToSearch})}
74
+ </fetcher.Form>
75
+ );
76
+ }
@@ -0,0 +1,161 @@
1
+ import {Link} from 'react-router';
2
+ import {Image, Money, Pagination} from '@shopify/hydrogen';
3
+ import {urlWithTrackingParams, type RegularSearchReturn} from '~/lib/search';
4
+
5
+ type SearchItems = RegularSearchReturn['result']['items'];
6
+ type PartialSearchResult<ItemType extends keyof SearchItems> = Pick<
7
+ SearchItems,
8
+ ItemType
9
+ > &
10
+ Pick<RegularSearchReturn, 'term'>;
11
+
12
+ type SearchResultsProps = RegularSearchReturn & {
13
+ children: (args: SearchItems & {term: string}) => React.ReactNode;
14
+ };
15
+
16
+ export function SearchResults({
17
+ term,
18
+ result,
19
+ children,
20
+ }: Omit<SearchResultsProps, 'error' | 'type'>) {
21
+ if (!result?.total) {
22
+ return null;
23
+ }
24
+
25
+ return children({...result.items, term});
26
+ }
27
+
28
+ SearchResults.Articles = SearchResultsArticles;
29
+ SearchResults.Pages = SearchResultsPages;
30
+ SearchResults.Products = SearchResultsProducts;
31
+ SearchResults.Empty = SearchResultsEmpty;
32
+
33
+ function SearchResultsArticles({
34
+ term,
35
+ articles,
36
+ }: PartialSearchResult<'articles'>) {
37
+ if (!articles?.nodes.length) {
38
+ return null;
39
+ }
40
+
41
+ return (
42
+ <div className="search-result">
43
+ <h2>Articles</h2>
44
+ <div>
45
+ {articles?.nodes?.map((article) => {
46
+ const articleUrl = urlWithTrackingParams({
47
+ baseUrl: `/blogs/${article.handle}`,
48
+ trackingParams: article.trackingParameters,
49
+ term,
50
+ });
51
+
52
+ return (
53
+ <div className="search-results-item" key={article.id}>
54
+ <Link prefetch="intent" to={articleUrl}>
55
+ {article.title}
56
+ </Link>
57
+ </div>
58
+ );
59
+ })}
60
+ </div>
61
+ <br />
62
+ </div>
63
+ );
64
+ }
65
+
66
+ function SearchResultsPages({term, pages}: PartialSearchResult<'pages'>) {
67
+ if (!pages?.nodes.length) {
68
+ return null;
69
+ }
70
+
71
+ return (
72
+ <div className="search-result">
73
+ <h2>Pages</h2>
74
+ <div>
75
+ {pages?.nodes?.map((page) => {
76
+ const pageUrl = urlWithTrackingParams({
77
+ baseUrl: `/pages/${page.handle}`,
78
+ trackingParams: page.trackingParameters,
79
+ term,
80
+ });
81
+
82
+ return (
83
+ <div className="search-results-item" key={page.id}>
84
+ <Link prefetch="intent" to={pageUrl}>
85
+ {page.title}
86
+ </Link>
87
+ </div>
88
+ );
89
+ })}
90
+ </div>
91
+ <br />
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function SearchResultsProducts({
97
+ term,
98
+ products,
99
+ }: PartialSearchResult<'products'>) {
100
+ if (!products?.nodes.length) {
101
+ return null;
102
+ }
103
+
104
+ return (
105
+ <div className="search-result">
106
+ <h2>Products</h2>
107
+ <Pagination connection={products}>
108
+ {({nodes, isLoading, NextLink, PreviousLink}) => {
109
+ const ItemsMarkup = nodes.map((product) => {
110
+ const productUrl = urlWithTrackingParams({
111
+ baseUrl: `/products/${product.handle}`,
112
+ trackingParams: product.trackingParameters,
113
+ term,
114
+ });
115
+
116
+ const price = product?.selectedOrFirstAvailableVariant?.price;
117
+ const image = product?.selectedOrFirstAvailableVariant?.image;
118
+
119
+ return (
120
+ <div className="search-results-item" key={product.id}>
121
+ <Link prefetch="intent" to={productUrl}>
122
+ {image && (
123
+ <Image data={image} alt={product.title} width={50} />
124
+ )}
125
+ <div>
126
+ <p>{product.title}</p>
127
+ <small>{price && <Money data={price} />}</small>
128
+ </div>
129
+ </Link>
130
+ </div>
131
+ );
132
+ });
133
+
134
+ return (
135
+ <div>
136
+ <div>
137
+ <PreviousLink>
138
+ {isLoading ? 'Loading...' : <span>↑ Load previous</span>}
139
+ </PreviousLink>
140
+ </div>
141
+ <div>
142
+ {ItemsMarkup}
143
+ <br />
144
+ </div>
145
+ <div>
146
+ <NextLink>
147
+ {isLoading ? 'Loading...' : <span>Load more ↓</span>}
148
+ </NextLink>
149
+ </div>
150
+ </div>
151
+ );
152
+ }}
153
+ </Pagination>
154
+ <br />
155
+ </div>
156
+ );
157
+ }
158
+
159
+ function SearchResultsEmpty() {
160
+ return <p>No results, try a different search.</p>;
161
+ }
@@ -0,0 +1,461 @@
1
+ import {Link, useFetcher, type Fetcher} from 'react-router';
2
+ import {Image, Money} from '@shopify/hydrogen';
3
+ import React, {useRef, useEffect} from 'react';
4
+ import {
5
+ getEmptyPredictiveSearchResult,
6
+ urlWithTrackingParams,
7
+ type PredictiveSearchReturn,
8
+ } from '~/lib/search';
9
+ import {useAside} from './Aside';
10
+
11
+ type PredictiveSearchItems = PredictiveSearchReturn['result']['items'];
12
+
13
+ type UsePredictiveSearchReturn = {
14
+ term: React.MutableRefObject<string>;
15
+ total: number;
16
+ inputRef: React.MutableRefObject<HTMLInputElement | null>;
17
+ items: PredictiveSearchItems;
18
+ fetcher: Fetcher<PredictiveSearchReturn>;
19
+ };
20
+
21
+ type SearchResultsPredictiveArgs = Pick<
22
+ UsePredictiveSearchReturn,
23
+ 'term' | 'total' | 'inputRef' | 'items'
24
+ > & {
25
+ state: Fetcher['state'];
26
+ closeSearch: () => void;
27
+ };
28
+
29
+ type PartialPredictiveSearchResult<
30
+ ItemType extends keyof PredictiveSearchItems,
31
+ ExtraProps extends keyof SearchResultsPredictiveArgs = 'term' | 'closeSearch',
32
+ > = Pick<PredictiveSearchItems, ItemType> &
33
+ Pick<SearchResultsPredictiveArgs, ExtraProps>;
34
+
35
+ type SearchResultsPredictiveProps = {
36
+ children: (args: SearchResultsPredictiveArgs) => React.ReactNode;
37
+ };
38
+
39
+ /**
40
+ * Component that renders predictive search results
41
+ */
42
+ export function SearchResultsPredictive({
43
+ children,
44
+ }: SearchResultsPredictiveProps) {
45
+ const aside = useAside();
46
+ const {term, inputRef, fetcher, total, items} = usePredictiveSearch();
47
+
48
+ /*
49
+ * Utility that resets the search input
50
+ */
51
+ function resetInput() {
52
+ if (inputRef.current) {
53
+ inputRef.current.blur();
54
+ inputRef.current.value = '';
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Utility that resets the search input and closes the search aside
60
+ */
61
+ function closeSearch() {
62
+ resetInput();
63
+ aside.close();
64
+ }
65
+
66
+ return children({
67
+ items,
68
+ closeSearch,
69
+ inputRef,
70
+ state: fetcher.state,
71
+ term,
72
+ total,
73
+ });
74
+ }
75
+
76
+ SearchResultsPredictive.Articles = SearchResultsPredictiveArticles;
77
+ SearchResultsPredictive.Collections = SearchResultsPredictiveCollections;
78
+ SearchResultsPredictive.Pages = SearchResultsPredictivePages;
79
+ SearchResultsPredictive.Products = SearchResultsPredictiveProducts;
80
+ SearchResultsPredictive.Queries = SearchResultsPredictiveQueries;
81
+ SearchResultsPredictive.Empty = SearchResultsPredictiveEmpty;
82
+
83
+ function SearchResultsPredictiveArticles({
84
+ term,
85
+ articles,
86
+ closeSearch,
87
+ }: PartialPredictiveSearchResult<'articles'>) {
88
+ if (!articles.length) return null;
89
+
90
+ return (
91
+ <div className="py-3" key="articles">
92
+ <h5 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wide text-secondary-500">
93
+ <svg
94
+ className="h-4 w-4"
95
+ fill="none"
96
+ stroke="currentColor"
97
+ viewBox="0 0 24 24"
98
+ >
99
+ <path
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ strokeWidth={2}
103
+ d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
104
+ />
105
+ </svg>
106
+ Articles
107
+ </h5>
108
+ <ul className="space-y-1">
109
+ {articles.map((article) => {
110
+ const articleUrl = urlWithTrackingParams({
111
+ baseUrl: `/blogs/${article.blog.handle}/${article.handle}`,
112
+ trackingParams: article.trackingParameters,
113
+ term: term.current ?? '',
114
+ });
115
+
116
+ return (
117
+ <li key={article.id}>
118
+ <Link
119
+ onClick={closeSearch}
120
+ to={articleUrl}
121
+ className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary-100"
122
+ >
123
+ {article.image?.url && (
124
+ <div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded-md bg-secondary-100">
125
+ <Image
126
+ alt={article.image.altText ?? ''}
127
+ src={article.image.url}
128
+ width={40}
129
+ height={40}
130
+ className="h-full w-full object-cover"
131
+ />
132
+ </div>
133
+ )}
134
+ <span className="truncate text-sm font-medium text-secondary-900">
135
+ {article.title}
136
+ </span>
137
+ </Link>
138
+ </li>
139
+ );
140
+ })}
141
+ </ul>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ function SearchResultsPredictiveCollections({
147
+ term,
148
+ collections,
149
+ closeSearch,
150
+ }: PartialPredictiveSearchResult<'collections'>) {
151
+ if (!collections.length) return null;
152
+
153
+ return (
154
+ <div className="py-3" key="collections">
155
+ <h5 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wide text-secondary-500">
156
+ <svg
157
+ className="h-4 w-4"
158
+ fill="none"
159
+ stroke="currentColor"
160
+ viewBox="0 0 24 24"
161
+ >
162
+ <path
163
+ strokeLinecap="round"
164
+ strokeLinejoin="round"
165
+ strokeWidth={2}
166
+ 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"
167
+ />
168
+ </svg>
169
+ Collections
170
+ </h5>
171
+ <ul className="space-y-1">
172
+ {collections.map((collection) => {
173
+ const collectionUrl = urlWithTrackingParams({
174
+ baseUrl: `/collections/${collection.handle}`,
175
+ trackingParams: collection.trackingParameters,
176
+ term: term.current,
177
+ });
178
+
179
+ return (
180
+ <li key={collection.id}>
181
+ <Link
182
+ onClick={closeSearch}
183
+ to={collectionUrl}
184
+ className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary-100"
185
+ >
186
+ {collection.image?.url ? (
187
+ <div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded-md bg-secondary-100">
188
+ <Image
189
+ alt={collection.image.altText ?? ''}
190
+ src={collection.image.url}
191
+ width={40}
192
+ height={40}
193
+ className="h-full w-full object-cover"
194
+ />
195
+ </div>
196
+ ) : (
197
+ <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-secondary-100">
198
+ <svg
199
+ className="h-5 w-5 text-secondary-400"
200
+ fill="none"
201
+ stroke="currentColor"
202
+ viewBox="0 0 24 24"
203
+ >
204
+ <path
205
+ strokeLinecap="round"
206
+ strokeLinejoin="round"
207
+ strokeWidth={1.5}
208
+ 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"
209
+ />
210
+ </svg>
211
+ </div>
212
+ )}
213
+ <span className="truncate text-sm font-medium text-secondary-900">
214
+ {collection.title}
215
+ </span>
216
+ </Link>
217
+ </li>
218
+ );
219
+ })}
220
+ </ul>
221
+ </div>
222
+ );
223
+ }
224
+
225
+ function SearchResultsPredictivePages({
226
+ term,
227
+ pages,
228
+ closeSearch,
229
+ }: PartialPredictiveSearchResult<'pages'>) {
230
+ if (!pages.length) return null;
231
+
232
+ return (
233
+ <div className="py-3" key="pages">
234
+ <h5 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wide text-secondary-500">
235
+ <svg
236
+ className="h-4 w-4"
237
+ fill="none"
238
+ stroke="currentColor"
239
+ viewBox="0 0 24 24"
240
+ >
241
+ <path
242
+ strokeLinecap="round"
243
+ strokeLinejoin="round"
244
+ strokeWidth={2}
245
+ 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"
246
+ />
247
+ </svg>
248
+ Pages
249
+ </h5>
250
+ <ul className="space-y-1">
251
+ {pages.map((page) => {
252
+ const pageUrl = urlWithTrackingParams({
253
+ baseUrl: `/pages/${page.handle}`,
254
+ trackingParams: page.trackingParameters,
255
+ term: term.current,
256
+ });
257
+
258
+ return (
259
+ <li key={page.id}>
260
+ <Link
261
+ onClick={closeSearch}
262
+ to={pageUrl}
263
+ className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary-100"
264
+ >
265
+ <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-secondary-100">
266
+ <svg
267
+ className="h-5 w-5 text-secondary-400"
268
+ fill="none"
269
+ stroke="currentColor"
270
+ viewBox="0 0 24 24"
271
+ >
272
+ <path
273
+ strokeLinecap="round"
274
+ strokeLinejoin="round"
275
+ strokeWidth={1.5}
276
+ 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"
277
+ />
278
+ </svg>
279
+ </div>
280
+ <span className="truncate text-sm font-medium text-secondary-900">
281
+ {page.title}
282
+ </span>
283
+ </Link>
284
+ </li>
285
+ );
286
+ })}
287
+ </ul>
288
+ </div>
289
+ );
290
+ }
291
+
292
+ function SearchResultsPredictiveProducts({
293
+ term,
294
+ products,
295
+ closeSearch,
296
+ }: PartialPredictiveSearchResult<'products'>) {
297
+ if (!products.length) return null;
298
+
299
+ return (
300
+ <div className="py-3" key="products">
301
+ <h5 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wide text-secondary-500">
302
+ <svg
303
+ className="h-4 w-4"
304
+ fill="none"
305
+ stroke="currentColor"
306
+ viewBox="0 0 24 24"
307
+ >
308
+ <path
309
+ strokeLinecap="round"
310
+ strokeLinejoin="round"
311
+ strokeWidth={2}
312
+ d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
313
+ />
314
+ </svg>
315
+ Products
316
+ </h5>
317
+ <ul className="space-y-1">
318
+ {products.map((product) => {
319
+ const productUrl = urlWithTrackingParams({
320
+ baseUrl: `/products/${product.handle}`,
321
+ trackingParams: product.trackingParameters,
322
+ term: term.current,
323
+ });
324
+
325
+ const price = product?.selectedOrFirstAvailableVariant?.price;
326
+ const image = product?.selectedOrFirstAvailableVariant?.image;
327
+
328
+ return (
329
+ <li key={product.id}>
330
+ <Link
331
+ to={productUrl}
332
+ onClick={closeSearch}
333
+ className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary-100"
334
+ >
335
+ {image ? (
336
+ <div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded-md bg-secondary-100">
337
+ <Image
338
+ alt={image.altText ?? ''}
339
+ src={image.url}
340
+ width={40}
341
+ height={40}
342
+ className="h-full w-full object-cover"
343
+ />
344
+ </div>
345
+ ) : (
346
+ <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-secondary-100">
347
+ <svg
348
+ className="h-5 w-5 text-secondary-400"
349
+ fill="none"
350
+ stroke="currentColor"
351
+ viewBox="0 0 24 24"
352
+ >
353
+ <path
354
+ strokeLinecap="round"
355
+ strokeLinejoin="round"
356
+ strokeWidth={1.5}
357
+ d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
358
+ />
359
+ </svg>
360
+ </div>
361
+ )}
362
+ <div className="min-w-0 flex-1">
363
+ <p className="truncate text-sm font-medium text-secondary-900">
364
+ {product.title}
365
+ </p>
366
+ {price && (
367
+ <p className="text-sm text-secondary-500">
368
+ <Money data={price} />
369
+ </p>
370
+ )}
371
+ </div>
372
+ </Link>
373
+ </li>
374
+ );
375
+ })}
376
+ </ul>
377
+ </div>
378
+ );
379
+ }
380
+
381
+ function SearchResultsPredictiveQueries({
382
+ queries,
383
+ queriesDatalistId,
384
+ }: PartialPredictiveSearchResult<'queries', never> & {
385
+ queriesDatalistId: string;
386
+ }) {
387
+ if (!queries.length) return null;
388
+
389
+ return (
390
+ <datalist id={queriesDatalistId}>
391
+ {queries.map((suggestion) => {
392
+ if (!suggestion) return null;
393
+
394
+ return <option key={suggestion.text} value={suggestion.text} />;
395
+ })}
396
+ </datalist>
397
+ );
398
+ }
399
+
400
+ function SearchResultsPredictiveEmpty({
401
+ term,
402
+ }: {
403
+ term: React.MutableRefObject<string>;
404
+ }) {
405
+ if (!term.current) {
406
+ return null;
407
+ }
408
+
409
+ return (
410
+ <div className="px-4 py-8 text-center">
411
+ <svg
412
+ className="mx-auto h-10 w-10 text-secondary-300"
413
+ fill="none"
414
+ stroke="currentColor"
415
+ viewBox="0 0 24 24"
416
+ >
417
+ <path
418
+ strokeLinecap="round"
419
+ strokeLinejoin="round"
420
+ strokeWidth={1.5}
421
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
422
+ />
423
+ </svg>
424
+ <p className="mt-3 text-sm text-secondary-500">
425
+ No results found for{' '}
426
+ <span className="font-medium text-secondary-700">
427
+ &ldquo;{term.current}&rdquo;
428
+ </span>
429
+ </p>
430
+ </div>
431
+ );
432
+ }
433
+
434
+ /**
435
+ * Hook that returns the predictive search results and fetcher and input ref.
436
+ * @example
437
+ * '''ts
438
+ * const { items, total, inputRef, term, fetcher } = usePredictiveSearch();
439
+ * '''
440
+ **/
441
+ function usePredictiveSearch(): UsePredictiveSearchReturn {
442
+ const fetcher = useFetcher<PredictiveSearchReturn>({key: 'search'});
443
+ const term = useRef<string>('');
444
+ const inputRef = useRef<HTMLInputElement | null>(null);
445
+
446
+ if (fetcher?.state === 'loading') {
447
+ term.current = String(fetcher.formData?.get('q') || '');
448
+ }
449
+
450
+ // capture the search input element as a ref
451
+ useEffect(() => {
452
+ if (!inputRef.current) {
453
+ inputRef.current = document.querySelector('input[type="search"]');
454
+ }
455
+ }, []);
456
+
457
+ const {items, total} =
458
+ fetcher?.data?.result ?? getEmptyPredictiveSearchResult();
459
+
460
+ return {items, total, inputRef, term, fetcher};
461
+ }