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,69 @@
1
+ import {useLoaderData, Link} from 'react-router';
2
+ import type {Route} from './+types/policies._index';
3
+ import type {PoliciesQuery, PolicyItemFragment} from 'storefrontapi.generated';
4
+
5
+ export async function loader({context}: Route.LoaderArgs) {
6
+ const data: PoliciesQuery = await context.storefront.query(POLICIES_QUERY);
7
+
8
+ const shopPolicies = data.shop;
9
+ const policies: PolicyItemFragment[] = [
10
+ shopPolicies?.privacyPolicy,
11
+ shopPolicies?.shippingPolicy,
12
+ shopPolicies?.termsOfService,
13
+ shopPolicies?.refundPolicy,
14
+ shopPolicies?.subscriptionPolicy,
15
+ ].filter((policy): policy is PolicyItemFragment => policy != null);
16
+
17
+ if (!policies.length) {
18
+ throw new Response('No policies found', {status: 404});
19
+ }
20
+
21
+ return {policies};
22
+ }
23
+
24
+ export default function Policies() {
25
+ const {policies} = useLoaderData<typeof loader>();
26
+
27
+ return (
28
+ <div className="policies">
29
+ <h1>Policies</h1>
30
+ <div>
31
+ {policies.map((policy) => (
32
+ <fieldset key={policy.id}>
33
+ <Link to={`/policies/${policy.handle}`}>{policy.title}</Link>
34
+ </fieldset>
35
+ ))}
36
+ </div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ const POLICIES_QUERY = `#graphql
42
+ fragment PolicyItem on ShopPolicy {
43
+ id
44
+ title
45
+ handle
46
+ }
47
+ query Policies ($country: CountryCode, $language: LanguageCode)
48
+ @inContext(country: $country, language: $language) {
49
+ shop {
50
+ privacyPolicy {
51
+ ...PolicyItem
52
+ }
53
+ shippingPolicy {
54
+ ...PolicyItem
55
+ }
56
+ termsOfService {
57
+ ...PolicyItem
58
+ }
59
+ refundPolicy {
60
+ ...PolicyItem
61
+ }
62
+ subscriptionPolicy {
63
+ id
64
+ title
65
+ handle
66
+ }
67
+ }
68
+ }
69
+ ` as const;
@@ -0,0 +1,232 @@
1
+ import {redirect, useLoaderData} from 'react-router';
2
+ import type {Route} from './+types/products.$handle';
3
+ import {
4
+ getSelectedProductOptions,
5
+ Analytics,
6
+ useOptimisticVariant,
7
+ getProductOptions,
8
+ getAdjacentAndFirstAvailableVariants,
9
+ useSelectedOptionInUrlParam,
10
+ } from '@shopify/hydrogen';
11
+ import {ProductPrice} from '~/components/ProductPrice';
12
+ import {ProductImage} from '~/components/ProductImage';
13
+ import {ProductForm} from '~/components/ProductForm';
14
+ import {redirectIfHandleIsLocalized} from '~/lib/redirect';
15
+
16
+ export const meta: Route.MetaFunction = ({data}) => {
17
+ return [
18
+ {title: `Hydrogen | ${data?.product.title ?? ''}`},
19
+ {
20
+ rel: 'canonical',
21
+ href: `/products/${data?.product.handle}`,
22
+ },
23
+ ];
24
+ };
25
+
26
+ export async function loader(args: Route.LoaderArgs) {
27
+ // Start fetching non-critical data without blocking time to first byte
28
+ const deferredData = loadDeferredData(args);
29
+
30
+ // Await the critical data required to render initial state of the page
31
+ const criticalData = await loadCriticalData(args);
32
+
33
+ return {...deferredData, ...criticalData};
34
+ }
35
+
36
+ /**
37
+ * Load data necessary for rendering content above the fold. This is the critical data
38
+ * needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
39
+ */
40
+ async function loadCriticalData({context, params, request}: Route.LoaderArgs) {
41
+ const {handle} = params;
42
+ const {storefront} = context;
43
+
44
+ if (!handle) {
45
+ throw new Error('Expected product handle to be defined');
46
+ }
47
+
48
+ const [{product}] = await Promise.all([
49
+ storefront.query(PRODUCT_QUERY, {
50
+ variables: {handle, selectedOptions: getSelectedProductOptions(request)},
51
+ }),
52
+ // Add other queries here, so that they are loaded in parallel
53
+ ]);
54
+
55
+ if (!product?.id) {
56
+ throw new Response(null, {status: 404});
57
+ }
58
+
59
+ // The API handle might be localized, so redirect to the localized handle
60
+ redirectIfHandleIsLocalized(request, {handle, data: product});
61
+
62
+ return {
63
+ product,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Load data for rendering content below the fold. This data is deferred and will be
69
+ * fetched after the initial page load. If it's unavailable, the page should still 200.
70
+ * Make sure to not throw any errors here, as it will cause the page to 500.
71
+ */
72
+ function loadDeferredData({context, params}: Route.LoaderArgs) {
73
+ // Put any API calls that is not critical to be available on first page render
74
+ // For example: product reviews, product recommendations, social feeds.
75
+
76
+ return {};
77
+ }
78
+
79
+ export default function Product() {
80
+ const {product} = useLoaderData<typeof loader>();
81
+
82
+ // Optimistically selects a variant with given available variant information
83
+ const selectedVariant = useOptimisticVariant(
84
+ product.selectedOrFirstAvailableVariant,
85
+ getAdjacentAndFirstAvailableVariants(product),
86
+ );
87
+
88
+ // Sets the search param to the selected variant without navigation
89
+ // only when no search params are set in the url
90
+ useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
91
+
92
+ // Get the product options array
93
+ const productOptions = getProductOptions({
94
+ ...product,
95
+ selectedOrFirstAvailableVariant: selectedVariant,
96
+ });
97
+
98
+ const {title, descriptionHtml} = product;
99
+
100
+ return (
101
+ <div className="product">
102
+ <ProductImage image={selectedVariant?.image} />
103
+ <div className="product-main">
104
+ <h1>{title}</h1>
105
+ <ProductPrice
106
+ price={selectedVariant?.price}
107
+ compareAtPrice={selectedVariant?.compareAtPrice}
108
+ />
109
+ <br />
110
+ <ProductForm
111
+ productOptions={productOptions}
112
+ selectedVariant={selectedVariant}
113
+ />
114
+ <br />
115
+ <br />
116
+ <p>
117
+ <strong>Description</strong>
118
+ </p>
119
+ <br />
120
+ <div dangerouslySetInnerHTML={{__html: descriptionHtml}} />
121
+ <br />
122
+ </div>
123
+ <Analytics.ProductView
124
+ data={{
125
+ products: [
126
+ {
127
+ id: product.id,
128
+ title: product.title,
129
+ price: selectedVariant?.price.amount || '0',
130
+ vendor: product.vendor,
131
+ variantId: selectedVariant?.id || '',
132
+ variantTitle: selectedVariant?.title || '',
133
+ quantity: 1,
134
+ },
135
+ ],
136
+ }}
137
+ />
138
+ </div>
139
+ );
140
+ }
141
+
142
+ const PRODUCT_VARIANT_FRAGMENT = `#graphql
143
+ fragment ProductVariant on ProductVariant {
144
+ availableForSale
145
+ compareAtPrice {
146
+ amount
147
+ currencyCode
148
+ }
149
+ id
150
+ image {
151
+ __typename
152
+ id
153
+ url
154
+ altText
155
+ width
156
+ height
157
+ }
158
+ price {
159
+ amount
160
+ currencyCode
161
+ }
162
+ product {
163
+ title
164
+ handle
165
+ }
166
+ selectedOptions {
167
+ name
168
+ value
169
+ }
170
+ sku
171
+ title
172
+ unitPrice {
173
+ amount
174
+ currencyCode
175
+ }
176
+ }
177
+ ` as const;
178
+
179
+ const PRODUCT_FRAGMENT = `#graphql
180
+ fragment Product on Product {
181
+ id
182
+ title
183
+ vendor
184
+ handle
185
+ descriptionHtml
186
+ description
187
+ encodedVariantExistence
188
+ encodedVariantAvailability
189
+ options {
190
+ name
191
+ optionValues {
192
+ name
193
+ firstSelectableVariant {
194
+ ...ProductVariant
195
+ }
196
+ swatch {
197
+ color
198
+ image {
199
+ previewImage {
200
+ url
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
207
+ ...ProductVariant
208
+ }
209
+ adjacentVariants (selectedOptions: $selectedOptions) {
210
+ ...ProductVariant
211
+ }
212
+ seo {
213
+ description
214
+ title
215
+ }
216
+ }
217
+ ${PRODUCT_VARIANT_FRAGMENT}
218
+ ` as const;
219
+
220
+ const PRODUCT_QUERY = `#graphql
221
+ query Product(
222
+ $country: CountryCode
223
+ $handle: String!
224
+ $language: LanguageCode
225
+ $selectedOptions: [SelectedOptionInput!]!
226
+ ) @inContext(country: $country, language: $language) {
227
+ product(handle: $handle) {
228
+ ...Product
229
+ }
230
+ }
231
+ ${PRODUCT_FRAGMENT}
232
+ ` as const;
@@ -0,0 +1,426 @@
1
+ import {useLoaderData} from 'react-router';
2
+ import type {Route} from './+types/search';
3
+ import {getPaginationVariables, Analytics} from '@shopify/hydrogen';
4
+ import {SearchForm} from '~/components/SearchForm';
5
+ import {SearchResults} from '~/components/SearchResults';
6
+ import {
7
+ type RegularSearchReturn,
8
+ type PredictiveSearchReturn,
9
+ getEmptyPredictiveSearchResult,
10
+ } from '~/lib/search';
11
+ import type {
12
+ RegularSearchQuery,
13
+ PredictiveSearchQuery,
14
+ } from 'storefrontapi.generated';
15
+
16
+ export const meta: Route.MetaFunction = () => {
17
+ return [{title: `Hydrogen | Search`}];
18
+ };
19
+
20
+ export async function loader({request, context}: Route.LoaderArgs) {
21
+ const url = new URL(request.url);
22
+ const isPredictive = url.searchParams.has('predictive');
23
+ const searchPromise: Promise<PredictiveSearchReturn | RegularSearchReturn> =
24
+ isPredictive
25
+ ? predictiveSearch({request, context})
26
+ : regularSearch({request, context});
27
+
28
+ searchPromise.catch((error: Error) => {
29
+ console.error(error);
30
+ return {term: '', result: null, error: error.message};
31
+ });
32
+
33
+ return await searchPromise;
34
+ }
35
+
36
+ /**
37
+ * Renders the /search route
38
+ */
39
+ export default function SearchPage() {
40
+ const {type, term, result, error} = useLoaderData<typeof loader>();
41
+ if (type === 'predictive') return null;
42
+
43
+ return (
44
+ <div className="search">
45
+ <h1>Search</h1>
46
+ <SearchForm>
47
+ {({inputRef}) => (
48
+ <>
49
+ <input
50
+ defaultValue={term}
51
+ name="q"
52
+ placeholder="Search…"
53
+ ref={inputRef}
54
+ type="search"
55
+ />
56
+ &nbsp;
57
+ <button type="submit">Search</button>
58
+ </>
59
+ )}
60
+ </SearchForm>
61
+ {error && <p style={{color: 'red'}}>{error}</p>}
62
+ {!term || !result?.total ? (
63
+ <SearchResults.Empty />
64
+ ) : (
65
+ <SearchResults result={result} term={term}>
66
+ {({articles, pages, products, term}) => (
67
+ <div>
68
+ <SearchResults.Products products={products} term={term} />
69
+ <SearchResults.Pages pages={pages} term={term} />
70
+ <SearchResults.Articles articles={articles} term={term} />
71
+ </div>
72
+ )}
73
+ </SearchResults>
74
+ )}
75
+ <Analytics.SearchView data={{searchTerm: term, searchResults: result}} />
76
+ </div>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Regular search query and fragments
82
+ * (adjust as needed)
83
+ */
84
+ const SEARCH_PRODUCT_FRAGMENT = `#graphql
85
+ fragment SearchProduct on Product {
86
+ __typename
87
+ handle
88
+ id
89
+ publishedAt
90
+ title
91
+ trackingParameters
92
+ vendor
93
+ selectedOrFirstAvailableVariant(
94
+ selectedOptions: []
95
+ ignoreUnknownOptions: true
96
+ caseInsensitiveMatch: true
97
+ ) {
98
+ id
99
+ image {
100
+ url
101
+ altText
102
+ width
103
+ height
104
+ }
105
+ price {
106
+ amount
107
+ currencyCode
108
+ }
109
+ compareAtPrice {
110
+ amount
111
+ currencyCode
112
+ }
113
+ selectedOptions {
114
+ name
115
+ value
116
+ }
117
+ product {
118
+ handle
119
+ title
120
+ }
121
+ }
122
+ }
123
+ ` as const;
124
+
125
+ const SEARCH_PAGE_FRAGMENT = `#graphql
126
+ fragment SearchPage on Page {
127
+ __typename
128
+ handle
129
+ id
130
+ title
131
+ trackingParameters
132
+ }
133
+ ` as const;
134
+
135
+ const SEARCH_ARTICLE_FRAGMENT = `#graphql
136
+ fragment SearchArticle on Article {
137
+ __typename
138
+ handle
139
+ id
140
+ title
141
+ trackingParameters
142
+ }
143
+ ` as const;
144
+
145
+ const PAGE_INFO_FRAGMENT = `#graphql
146
+ fragment PageInfoFragment on PageInfo {
147
+ hasNextPage
148
+ hasPreviousPage
149
+ startCursor
150
+ endCursor
151
+ }
152
+ ` as const;
153
+
154
+ // NOTE: https://shopify.dev/docs/api/storefront/latest/queries/search
155
+ export const SEARCH_QUERY = `#graphql
156
+ query RegularSearch(
157
+ $country: CountryCode
158
+ $endCursor: String
159
+ $first: Int
160
+ $language: LanguageCode
161
+ $last: Int
162
+ $term: String!
163
+ $startCursor: String
164
+ ) @inContext(country: $country, language: $language) {
165
+ articles: search(
166
+ query: $term,
167
+ types: [ARTICLE],
168
+ first: $first,
169
+ ) {
170
+ nodes {
171
+ ...on Article {
172
+ ...SearchArticle
173
+ }
174
+ }
175
+ }
176
+ pages: search(
177
+ query: $term,
178
+ types: [PAGE],
179
+ first: $first,
180
+ ) {
181
+ nodes {
182
+ ...on Page {
183
+ ...SearchPage
184
+ }
185
+ }
186
+ }
187
+ products: search(
188
+ after: $endCursor,
189
+ before: $startCursor,
190
+ first: $first,
191
+ last: $last,
192
+ query: $term,
193
+ sortKey: RELEVANCE,
194
+ types: [PRODUCT],
195
+ unavailableProducts: HIDE,
196
+ ) {
197
+ nodes {
198
+ ...on Product {
199
+ ...SearchProduct
200
+ }
201
+ }
202
+ pageInfo {
203
+ ...PageInfoFragment
204
+ }
205
+ }
206
+ }
207
+ ${SEARCH_PRODUCT_FRAGMENT}
208
+ ${SEARCH_PAGE_FRAGMENT}
209
+ ${SEARCH_ARTICLE_FRAGMENT}
210
+ ${PAGE_INFO_FRAGMENT}
211
+ ` as const;
212
+
213
+ /**
214
+ * Regular search fetcher
215
+ */
216
+ async function regularSearch({
217
+ request,
218
+ context,
219
+ }: Pick<
220
+ Route.LoaderArgs,
221
+ 'request' | 'context'
222
+ >): Promise<RegularSearchReturn> {
223
+ const {storefront} = context;
224
+ const url = new URL(request.url);
225
+ const variables = getPaginationVariables(request, {pageBy: 8});
226
+ const term = String(url.searchParams.get('q') || '');
227
+
228
+ // Search articles, pages, and products for the `q` term
229
+ const {
230
+ errors,
231
+ ...items
232
+ }: {errors?: Array<{message: string}>} & RegularSearchQuery =
233
+ await storefront.query(SEARCH_QUERY, {
234
+ variables: {...variables, term},
235
+ });
236
+
237
+ if (!items) {
238
+ throw new Error('No search data returned from Shopify API');
239
+ }
240
+
241
+ const total = Object.values(items).reduce(
242
+ (acc: number, {nodes}: {nodes: Array<unknown>}) => acc + nodes.length,
243
+ 0,
244
+ );
245
+
246
+ const error = errors
247
+ ? errors.map(({message}: {message: string}) => message).join(', ')
248
+ : undefined;
249
+
250
+ return {type: 'regular', term, error, result: {total, items}};
251
+ }
252
+
253
+ /**
254
+ * Predictive search query and fragments
255
+ * (adjust as needed)
256
+ */
257
+ const PREDICTIVE_SEARCH_ARTICLE_FRAGMENT = `#graphql
258
+ fragment PredictiveArticle on Article {
259
+ __typename
260
+ id
261
+ title
262
+ handle
263
+ blog {
264
+ handle
265
+ }
266
+ image {
267
+ url
268
+ altText
269
+ width
270
+ height
271
+ }
272
+ trackingParameters
273
+ }
274
+ ` as const;
275
+
276
+ const PREDICTIVE_SEARCH_COLLECTION_FRAGMENT = `#graphql
277
+ fragment PredictiveCollection on Collection {
278
+ __typename
279
+ id
280
+ title
281
+ handle
282
+ image {
283
+ url
284
+ altText
285
+ width
286
+ height
287
+ }
288
+ trackingParameters
289
+ }
290
+ ` as const;
291
+
292
+ const PREDICTIVE_SEARCH_PAGE_FRAGMENT = `#graphql
293
+ fragment PredictivePage on Page {
294
+ __typename
295
+ id
296
+ title
297
+ handle
298
+ trackingParameters
299
+ }
300
+ ` as const;
301
+
302
+ const PREDICTIVE_SEARCH_PRODUCT_FRAGMENT = `#graphql
303
+ fragment PredictiveProduct on Product {
304
+ __typename
305
+ id
306
+ title
307
+ handle
308
+ trackingParameters
309
+ selectedOrFirstAvailableVariant(
310
+ selectedOptions: []
311
+ ignoreUnknownOptions: true
312
+ caseInsensitiveMatch: true
313
+ ) {
314
+ id
315
+ image {
316
+ url
317
+ altText
318
+ width
319
+ height
320
+ }
321
+ price {
322
+ amount
323
+ currencyCode
324
+ }
325
+ }
326
+ }
327
+ ` as const;
328
+
329
+ const PREDICTIVE_SEARCH_QUERY_FRAGMENT = `#graphql
330
+ fragment PredictiveQuery on SearchQuerySuggestion {
331
+ __typename
332
+ text
333
+ styledText
334
+ trackingParameters
335
+ }
336
+ ` as const;
337
+
338
+ // NOTE: https://shopify.dev/docs/api/storefront/latest/queries/predictiveSearch
339
+ const PREDICTIVE_SEARCH_QUERY = `#graphql
340
+ query PredictiveSearch(
341
+ $country: CountryCode
342
+ $language: LanguageCode
343
+ $limit: Int!
344
+ $limitScope: PredictiveSearchLimitScope!
345
+ $term: String!
346
+ $types: [PredictiveSearchType!]
347
+ ) @inContext(country: $country, language: $language) {
348
+ predictiveSearch(
349
+ limit: $limit,
350
+ limitScope: $limitScope,
351
+ query: $term,
352
+ types: $types,
353
+ ) {
354
+ articles {
355
+ ...PredictiveArticle
356
+ }
357
+ collections {
358
+ ...PredictiveCollection
359
+ }
360
+ pages {
361
+ ...PredictivePage
362
+ }
363
+ products {
364
+ ...PredictiveProduct
365
+ }
366
+ queries {
367
+ ...PredictiveQuery
368
+ }
369
+ }
370
+ }
371
+ ${PREDICTIVE_SEARCH_ARTICLE_FRAGMENT}
372
+ ${PREDICTIVE_SEARCH_COLLECTION_FRAGMENT}
373
+ ${PREDICTIVE_SEARCH_PAGE_FRAGMENT}
374
+ ${PREDICTIVE_SEARCH_PRODUCT_FRAGMENT}
375
+ ${PREDICTIVE_SEARCH_QUERY_FRAGMENT}
376
+ ` as const;
377
+
378
+ /**
379
+ * Predictive search fetcher
380
+ */
381
+ async function predictiveSearch({
382
+ request,
383
+ context,
384
+ }: Pick<
385
+ Route.ActionArgs,
386
+ 'request' | 'context'
387
+ >): Promise<PredictiveSearchReturn> {
388
+ const {storefront} = context;
389
+ const url = new URL(request.url);
390
+ const term = String(url.searchParams.get('q') || '').trim();
391
+ const limit = Number(url.searchParams.get('limit') || 10);
392
+ const type = 'predictive';
393
+
394
+ if (!term) return {type, term, result: getEmptyPredictiveSearchResult()};
395
+
396
+ // Predictively search articles, collections, pages, products, and queries (suggestions)
397
+ const {
398
+ predictiveSearch: items,
399
+ errors,
400
+ }: PredictiveSearchQuery & {errors?: Array<{message: string}>} =
401
+ await storefront.query(PREDICTIVE_SEARCH_QUERY, {
402
+ variables: {
403
+ // customize search options as needed
404
+ limit,
405
+ limitScope: 'EACH',
406
+ term,
407
+ },
408
+ });
409
+
410
+ if (errors) {
411
+ throw new Error(
412
+ `Shopify API errors: ${errors.map(({message}: {message: string}) => message).join(', ')}`,
413
+ );
414
+ }
415
+
416
+ if (!items) {
417
+ throw new Error('No predictive search data returned from Shopify API');
418
+ }
419
+
420
+ const total = Object.values(items).reduce(
421
+ (acc: number, item: Array<unknown>) => acc + item.length,
422
+ 0,
423
+ );
424
+
425
+ return {type, term, result: {items, total}};
426
+ }