@shopify/cli 3.65.1 → 3.65.3
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/dist/assets/hydrogen/i18n/domains.ts +4 -11
- package/dist/assets/hydrogen/i18n/mock-i18n-types.ts +4 -2
- package/dist/assets/hydrogen/i18n/subdomains.ts +4 -11
- package/dist/assets/hydrogen/i18n/subfolders.ts +4 -11
- package/dist/assets/hydrogen/starter/CHANGELOG.md +165 -0
- package/dist/assets/hydrogen/starter/app/components/CartLineItem.tsx +5 -2
- package/dist/assets/hydrogen/starter/app/components/CartMain.tsx +2 -2
- package/dist/assets/hydrogen/starter/app/components/PageLayout.tsx +65 -19
- package/dist/assets/hydrogen/starter/app/components/PaginatedResourceSection.tsx +42 -0
- package/dist/assets/hydrogen/starter/app/components/SearchForm.tsx +68 -0
- package/dist/assets/hydrogen/starter/app/components/SearchFormPredictive.tsx +76 -0
- package/dist/assets/hydrogen/starter/app/components/SearchResults.tsx +164 -0
- package/dist/assets/hydrogen/starter/app/components/SearchResultsPredictive.tsx +322 -0
- package/dist/assets/hydrogen/starter/app/entry.client.tsx +10 -8
- package/dist/assets/hydrogen/starter/app/entry.server.tsx +1 -1
- package/dist/assets/hydrogen/starter/app/lib/context.ts +43 -0
- package/dist/assets/hydrogen/starter/app/lib/fragments.ts +53 -0
- package/dist/assets/hydrogen/starter/app/lib/search.ts +74 -24
- package/dist/assets/hydrogen/starter/app/root.tsx +4 -7
- package/dist/assets/hydrogen/starter/app/routes/account.addresses.tsx +2 -3
- package/dist/assets/hydrogen/starter/app/routes/account.orders._index.tsx +5 -19
- package/dist/assets/hydrogen/starter/app/routes/blogs.$blogHandle._index.tsx +11 -24
- package/dist/assets/hydrogen/starter/app/routes/blogs._index.tsx +14 -27
- package/dist/assets/hydrogen/starter/app/routes/collections.$handle.tsx +12 -30
- package/dist/assets/hydrogen/starter/app/routes/collections._index.tsx +13 -27
- package/dist/assets/hydrogen/starter/app/routes/collections.all.tsx +9 -31
- package/dist/assets/hydrogen/starter/app/routes/search.tsx +312 -73
- package/dist/assets/hydrogen/starter/app/styles/reset.css +12 -2
- package/dist/assets/hydrogen/starter/env.d.ts +11 -30
- package/dist/assets/hydrogen/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
- package/dist/assets/hydrogen/starter/guides/predictiveSearch/predictiveSearch.md +391 -0
- package/dist/assets/hydrogen/starter/guides/search/search.jpg +0 -0
- package/dist/assets/hydrogen/starter/guides/search/search.md +333 -0
- package/dist/assets/hydrogen/starter/package.json +4 -4
- package/dist/assets/hydrogen/starter/server.ts +18 -74
- package/dist/assets/hydrogen/starter/storefrontapi.generated.d.ts +242 -172
- package/dist/assets/hydrogen/virtual-routes/components/{PageLayout.jsx → Layout.jsx} +2 -2
- package/dist/assets/hydrogen/virtual-routes/virtual-root.jsx +7 -6
- package/dist/{chunk-H42RFZDD.js → chunk-3ABSSTBQ.js} +4 -4
- package/dist/{chunk-M7WMYV4S.js → chunk-3D4VZQOH.js} +2 -2
- package/dist/{chunk-J7BYFGNJ.js → chunk-3GSKXZGY.js} +2 -2
- package/dist/{chunk-TDWX3KIR.js → chunk-3LDWVYMD.js} +2 -2
- package/dist/{chunk-N2BXKOJG.js → chunk-646BIVHE.js} +4 -4
- package/dist/{chunk-CZ3SHYYH.js → chunk-7WAEFADN.js} +4 -4
- package/dist/{chunk-EKT2GUGH.js → chunk-7WGBIPDW.js} +2 -2
- package/dist/{chunk-M6KGRVDD.js → chunk-AX77SAMU.js} +3 -3
- package/dist/{chunk-4HAEQQTQ.js → chunk-BQBBVYYU.js} +4 -4
- package/dist/{chunk-5YD4FDOS.js → chunk-BZLNTDGG.js} +3 -3
- package/dist/{chunk-VWALMO2Z.js → chunk-CSCEGIBZ.js} +3 -3
- package/dist/{chunk-F2Y7KYHZ.js → chunk-EIUQV76I.js} +5 -5
- package/dist/{chunk-MODBIZ4R.js → chunk-GN74L7IW.js} +2 -2
- package/dist/{chunk-5EAVIJTQ.js → chunk-HYCRESCR.js} +2 -2
- package/dist/{chunk-GDARYUPU.js → chunk-K7KD247K.js} +188 -243
- package/dist/{chunk-PZM45AUI.js → chunk-KIUXMPTX.js} +3 -3
- package/dist/{chunk-PYMSCBPA.js → chunk-LAJ4OEME.js} +2 -2
- package/dist/{chunk-YVHV3H5H.js → chunk-MIQBXNSN.js} +4 -4
- package/dist/{chunk-BLKDGMHM.js → chunk-MV6A3QHA.js} +4 -4
- package/dist/{chunk-CFFAWVDL.js → chunk-N3YORLAS.js} +2 -2
- package/dist/{chunk-EU5ZOEUT.js → chunk-NBTEOGQW.js} +2 -2
- package/dist/{chunk-ZXJU6UP4.js → chunk-O3JOUAA5.js} +4 -4
- package/dist/{chunk-EZ5DG73H.js → chunk-PEAIOYXD.js} +4 -4
- package/dist/{chunk-YDS7NZBQ.js → chunk-R5GT4GBL.js} +4 -4
- package/dist/{chunk-6M65VRAT.js → chunk-S7FJTFYR.js} +5 -5
- package/dist/{chunk-DX2RXOQ5.js → chunk-S7RH664J.js} +3 -3
- package/dist/{chunk-WMECC32P.js → chunk-SKF2SKWO.js} +3 -3
- package/dist/{chunk-27HGZPUX.js → chunk-SMKCVFDT.js} +3 -3
- package/dist/{chunk-EID6L4PR.js → chunk-T4Y7NDNJ.js} +2 -2
- package/dist/{chunk-PY33KMCK.js → chunk-TWWJNMTO.js} +2 -2
- package/dist/{chunk-YXPGPWR2.js → chunk-U2PN6QZ2.js} +5 -5
- package/dist/{chunk-3REVOIEW.js → chunk-UBCH575K.js} +5 -5
- package/dist/{chunk-A4NQWDPT.js → chunk-XLURAR5E.js} +3 -3
- package/dist/{chunk-ZZKUI3DP.js → chunk-YPG7LXPN.js} +3 -3
- package/dist/cli/commands/auth/logout.js +10 -10
- package/dist/cli/commands/auth/logout.test.js +11 -11
- package/dist/cli/commands/debug/command-flags.js +9 -9
- package/dist/cli/commands/demo/catalog.js +10 -10
- package/dist/cli/commands/demo/generate-file.js +10 -10
- package/dist/cli/commands/demo/index.js +10 -10
- package/dist/cli/commands/demo/print-ai-prompt.js +10 -10
- package/dist/cli/commands/docs/generate.js +9 -9
- package/dist/cli/commands/docs/generate.test.js +9 -9
- package/dist/cli/commands/help.js +9 -9
- package/dist/cli/commands/kitchen-sink/async.js +10 -10
- package/dist/cli/commands/kitchen-sink/async.test.js +10 -10
- package/dist/cli/commands/kitchen-sink/index.js +12 -12
- package/dist/cli/commands/kitchen-sink/index.test.js +12 -12
- package/dist/cli/commands/kitchen-sink/prompts.js +10 -10
- package/dist/cli/commands/kitchen-sink/prompts.test.js +10 -10
- package/dist/cli/commands/kitchen-sink/static.js +10 -10
- package/dist/cli/commands/kitchen-sink/static.test.js +10 -10
- package/dist/cli/commands/search.js +10 -10
- package/dist/cli/commands/upgrade.js +9 -9
- package/dist/cli/commands/version.js +10 -10
- package/dist/cli/commands/version.test.js +10 -10
- package/dist/cli/services/commands/search.js +2 -2
- package/dist/cli/services/commands/search.test.js +2 -2
- package/dist/cli/services/commands/version.js +4 -4
- package/dist/cli/services/commands/version.test.js +5 -5
- package/dist/cli/services/demo.js +2 -2
- package/dist/cli/services/demo.test.js +2 -2
- package/dist/cli/services/kitchen-sink/async.js +2 -2
- package/dist/cli/services/kitchen-sink/prompts.js +2 -2
- package/dist/cli/services/kitchen-sink/static.js +2 -2
- package/dist/cli/services/upgrade.js +3 -3
- package/dist/cli/services/upgrade.test.js +5 -5
- package/dist/{custom-oclif-loader-JHNX2EGV.js → custom-oclif-loader-BT7EH2NN.js} +3 -3
- package/dist/{error-handler-4UJ6363X.js → error-handler-OSEY6KVA.js} +8 -8
- package/dist/hooks/postrun.js +6 -6
- package/dist/hooks/prerun.js +4 -4
- package/dist/index.js +1333 -1279
- package/dist/{local-V7RONWNU.js → local-OQXN5NM2.js} +2 -2
- package/dist/{morph-DN4AZJZW.js → morph-IQTWRBBT.js} +16 -12
- package/dist/{node-3H4OKRLA.js → node-YQVH3Y7J.js} +13 -13
- package/dist/{node-package-manager-XM7EXHQA.js → node-package-manager-VW2DN7R4.js} +3 -3
- package/dist/{system-F63VIZ5U.js → system-347PZWVP.js} +2 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/{ui-BXWWRIFS.js → ui-S7L55PBH.js} +2 -2
- package/dist/{workerd-A5NCF6UA.js → workerd-OLKE7G4X.js} +12 -12
- package/oclif.manifest.json +39 -2
- package/package.json +7 -7
- package/dist/assets/hydrogen/starter/app/components/Search.tsx +0 -514
- package/dist/assets/hydrogen/starter/app/routes/api.predictive-search.tsx +0 -318
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# Hydrogen Predictive Search
|
|
2
|
+
|
|
3
|
+
Our skeleton template ships with predictive search functionality. While [regular search](../search/search.md)
|
|
4
|
+
provides paginated search of `pages`, `articles` and `products` inside the `/search` route,
|
|
5
|
+
predictive provides real-time results in a aside drawer for `pages`, `articles`, `products`, `collections` and
|
|
6
|
+
recommended `queries/suggestions`.
|
|
7
|
+
|
|
8
|
+
This integration uses the storefront API (SFAPI) [predictiveSearch](https://shopify.dev/docs/api/storefront/latest/queries/vpredictiveSearch) endpoint to retrieve predictive search results based on a search term.
|
|
9
|
+
|
|
10
|
+
## Components Architecture
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
## Components
|
|
15
|
+
|
|
16
|
+
| File | Description |
|
|
17
|
+
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
18
|
+
| [`app/components/SearchFormPredictive.tsx`](../../app/components/SearchFormPredictive.tsx) | A fully customizable form component configured to make form `GET` requests to the `/search` route. |
|
|
19
|
+
| [`app/components/SearchResultsPredictive.tsx`](../../app/components/SearchResultsPredictive.tsx) | A fully customizable search results wrapper, that provides compound components to render `articles`, `pages`, `products`, `collections` and `queries`. |
|
|
20
|
+
|
|
21
|
+
## Instructions
|
|
22
|
+
|
|
23
|
+
### 1. Create the search route
|
|
24
|
+
|
|
25
|
+
Create a new file at `/routes/search.tsx` (if not already created)
|
|
26
|
+
|
|
27
|
+
### 3. Add `predictiveSearch` query and fetcher
|
|
28
|
+
|
|
29
|
+
The predictiveSearch fetcher parses the `q` and `limit` formData properties sent
|
|
30
|
+
by the `<SearchFormPredictive />` component and performs the predictive search
|
|
31
|
+
SFAPI request.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
/**
|
|
35
|
+
* Predictive search query and fragments
|
|
36
|
+
* (adjust as needed)
|
|
37
|
+
*/
|
|
38
|
+
const PREDICTIVE_SEARCH_ARTICLE_FRAGMENT = `#graphql
|
|
39
|
+
fragment PredictiveArticle on Article {
|
|
40
|
+
__typename
|
|
41
|
+
id
|
|
42
|
+
title
|
|
43
|
+
handle
|
|
44
|
+
blog {
|
|
45
|
+
handle
|
|
46
|
+
}
|
|
47
|
+
image {
|
|
48
|
+
url
|
|
49
|
+
altText
|
|
50
|
+
width
|
|
51
|
+
height
|
|
52
|
+
}
|
|
53
|
+
trackingParameters
|
|
54
|
+
}
|
|
55
|
+
` as const;
|
|
56
|
+
|
|
57
|
+
const PREDICTIVE_SEARCH_COLLECTION_FRAGMENT = `#graphql
|
|
58
|
+
fragment PredictiveCollection on Collection {
|
|
59
|
+
__typename
|
|
60
|
+
id
|
|
61
|
+
title
|
|
62
|
+
handle
|
|
63
|
+
image {
|
|
64
|
+
url
|
|
65
|
+
altText
|
|
66
|
+
width
|
|
67
|
+
height
|
|
68
|
+
}
|
|
69
|
+
trackingParameters
|
|
70
|
+
}
|
|
71
|
+
` as const;
|
|
72
|
+
|
|
73
|
+
const PREDICTIVE_SEARCH_PAGE_FRAGMENT = `#graphql
|
|
74
|
+
fragment PredictivePage on Page {
|
|
75
|
+
__typename
|
|
76
|
+
id
|
|
77
|
+
title
|
|
78
|
+
handle
|
|
79
|
+
trackingParameters
|
|
80
|
+
}
|
|
81
|
+
` as const;
|
|
82
|
+
|
|
83
|
+
const PREDICTIVE_SEARCH_PRODUCT_FRAGMENT = `#graphql
|
|
84
|
+
fragment PredictiveProduct on Product {
|
|
85
|
+
__typename
|
|
86
|
+
id
|
|
87
|
+
title
|
|
88
|
+
handle
|
|
89
|
+
trackingParameters
|
|
90
|
+
variants(first: 1) {
|
|
91
|
+
nodes {
|
|
92
|
+
id
|
|
93
|
+
image {
|
|
94
|
+
url
|
|
95
|
+
altText
|
|
96
|
+
width
|
|
97
|
+
height
|
|
98
|
+
}
|
|
99
|
+
price {
|
|
100
|
+
amount
|
|
101
|
+
currencyCode
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
` as const;
|
|
107
|
+
|
|
108
|
+
const PREDICTIVE_SEARCH_QUERY_FRAGMENT = `#graphql
|
|
109
|
+
fragment PredictiveQuery on SearchQuerySuggestion {
|
|
110
|
+
__typename
|
|
111
|
+
text
|
|
112
|
+
styledText
|
|
113
|
+
trackingParameters
|
|
114
|
+
}
|
|
115
|
+
` as const;
|
|
116
|
+
|
|
117
|
+
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/predictiveSearch
|
|
118
|
+
const PREDICTIVE_SEARCH_QUERY = `#graphql
|
|
119
|
+
query predictiveSearch(
|
|
120
|
+
$country: CountryCode
|
|
121
|
+
$language: LanguageCode
|
|
122
|
+
$limit: Int!
|
|
123
|
+
$limitScope: PredictiveSearchLimitScope!
|
|
124
|
+
$term: String!
|
|
125
|
+
$types: [PredictiveSearchType!]
|
|
126
|
+
) @inContext(country: $country, language: $language) {
|
|
127
|
+
predictiveSearch(
|
|
128
|
+
limit: $limit,
|
|
129
|
+
limitScope: $limitScope,
|
|
130
|
+
query: $term,
|
|
131
|
+
types: $types,
|
|
132
|
+
) {
|
|
133
|
+
articles {
|
|
134
|
+
...PredictiveArticle
|
|
135
|
+
}
|
|
136
|
+
collections {
|
|
137
|
+
...PredictiveCollection
|
|
138
|
+
}
|
|
139
|
+
pages {
|
|
140
|
+
...PredictivePage
|
|
141
|
+
}
|
|
142
|
+
products {
|
|
143
|
+
...PredictiveProduct
|
|
144
|
+
}
|
|
145
|
+
queries {
|
|
146
|
+
...PredictiveQuery
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
${PREDICTIVE_SEARCH_ARTICLE_FRAGMENT}
|
|
151
|
+
${PREDICTIVE_SEARCH_COLLECTION_FRAGMENT}
|
|
152
|
+
${PREDICTIVE_SEARCH_PAGE_FRAGMENT}
|
|
153
|
+
${PREDICTIVE_SEARCH_PRODUCT_FRAGMENT}
|
|
154
|
+
${PREDICTIVE_SEARCH_QUERY_FRAGMENT}
|
|
155
|
+
` as const;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Predictive search fetcher
|
|
159
|
+
*/
|
|
160
|
+
async function predictiveSeach({
|
|
161
|
+
request,
|
|
162
|
+
context,
|
|
163
|
+
}: Pick<ActionFunctionArgs, 'request' | 'context'>) {
|
|
164
|
+
const {storefront} = context;
|
|
165
|
+
const formData = await request.formData();
|
|
166
|
+
const term = String(formData.get('q') || '');
|
|
167
|
+
|
|
168
|
+
const limit = Number(formData.get('limit') || 10);
|
|
169
|
+
|
|
170
|
+
// Predictively search articles, collections, pages, products, and queries (suggestions)
|
|
171
|
+
const {predictiveSearch: items, errors} = await storefront.query(
|
|
172
|
+
PREDICTIVE_SEARCH_QUERY,
|
|
173
|
+
{
|
|
174
|
+
variables: {
|
|
175
|
+
// customize search options as needed
|
|
176
|
+
limit,
|
|
177
|
+
limitScope: 'EACH',
|
|
178
|
+
term,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (errors) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Shopify API errors: ${errors.map(({message}) => message).join(', ')}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!items) {
|
|
190
|
+
throw new Error('No predictive search data returned');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const total = Object.values(items).reduce((acc, {length}) => acc + length, 0);
|
|
194
|
+
|
|
195
|
+
return json({term, result: {items, total}, error: null});
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### 3. Add a `loader` export to the route
|
|
200
|
+
|
|
201
|
+
This action receives and processes `GET` requests from the `<SearchFormPredictive />`
|
|
202
|
+
component. These request include the search parameter `predictive` to identify them over
|
|
203
|
+
regular search requests.
|
|
204
|
+
|
|
205
|
+
A `q` URL parameter will be used as the search term and appended automatically by
|
|
206
|
+
the form if present in it's children prop.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
/**
|
|
210
|
+
* Handles predictive search GET requests
|
|
211
|
+
* requested by the SearchFormPredictive component
|
|
212
|
+
*/
|
|
213
|
+
export async function loader({request, context}: LoaderFunctionArgs) {
|
|
214
|
+
const url = new URL(request.url);
|
|
215
|
+
const isPredictive = url.searchParams.has('predictive');
|
|
216
|
+
|
|
217
|
+
if (!isPredictive) {
|
|
218
|
+
return json({})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const searchPromise = predictiveSearch({request, context})
|
|
222
|
+
|
|
223
|
+
searchPromise.catch((error: Error) => {
|
|
224
|
+
console.error(error);
|
|
225
|
+
return {term: '', result: null, error: error.message};
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return json(await searchPromise);
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### 4. Render the predictive search form and results
|
|
233
|
+
|
|
234
|
+
Create a SearchAside or similar component to render the form and results.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { SearchFormPredictive } from '~/components/SearchFormPredictive';
|
|
238
|
+
import { SearchResultsPredictive } from '~/components/SearchResultsPredictive';
|
|
239
|
+
|
|
240
|
+
function SearchAside() {
|
|
241
|
+
return (
|
|
242
|
+
<Aside type="search" heading="SEARCH">
|
|
243
|
+
<div className="predictive-search">
|
|
244
|
+
<br />
|
|
245
|
+
<SearchFormPredictive>
|
|
246
|
+
{({ fetchResults, goToSearch, inputRef }) => (
|
|
247
|
+
<>
|
|
248
|
+
<input
|
|
249
|
+
name="q"
|
|
250
|
+
onChange={fetchResults}
|
|
251
|
+
onFocus={fetchResults}
|
|
252
|
+
placeholder="Search"
|
|
253
|
+
ref={inputRef}
|
|
254
|
+
type="search"
|
|
255
|
+
/>
|
|
256
|
+
|
|
257
|
+
<button onClick={goToSearch}>
|
|
258
|
+
Search
|
|
259
|
+
</button>
|
|
260
|
+
</>
|
|
261
|
+
)}
|
|
262
|
+
</SearchFormPredictive>
|
|
263
|
+
|
|
264
|
+
<SearchResultsPredictive>
|
|
265
|
+
{({ items, total, term, state, inputRef, closeSearch }) => {
|
|
266
|
+
const { articles, collections, pages, products, queries } = items;
|
|
267
|
+
|
|
268
|
+
if (state === 'loading' && term.current) {
|
|
269
|
+
return <div>Loading...</div>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!total) {
|
|
273
|
+
return <SearchResultsPredictive.Empty term={term} />;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<>
|
|
278
|
+
<SearchResultsPredictive.Queries
|
|
279
|
+
queries={queries}
|
|
280
|
+
term={term}
|
|
281
|
+
inputRef={inputRef}
|
|
282
|
+
/>
|
|
283
|
+
<SearchResultsPredictive.Products
|
|
284
|
+
products={products}
|
|
285
|
+
closeSearch={closeSearch}
|
|
286
|
+
term={term}
|
|
287
|
+
/>
|
|
288
|
+
<SearchResultsPredictive.Collections
|
|
289
|
+
collections={collections}
|
|
290
|
+
closeSearch={closeSearch}
|
|
291
|
+
term={term}
|
|
292
|
+
/>
|
|
293
|
+
<SearchResultsPredictive.Pages
|
|
294
|
+
pages={pages}
|
|
295
|
+
closeSearch={closeSearch}
|
|
296
|
+
term={term}
|
|
297
|
+
/>
|
|
298
|
+
<SearchResultsPredictive.Articles
|
|
299
|
+
articles={articles}
|
|
300
|
+
closeSearch={closeSearch}
|
|
301
|
+
term={term}
|
|
302
|
+
/>
|
|
303
|
+
{term.current && total && (
|
|
304
|
+
<Link onClick={closeSearch} to={`/search?q=${term.current}`}>
|
|
305
|
+
<p>
|
|
306
|
+
View all results for <q>{term.current}</q> →
|
|
307
|
+
</p>
|
|
308
|
+
</Link>
|
|
309
|
+
)}
|
|
310
|
+
</>
|
|
311
|
+
);
|
|
312
|
+
}}
|
|
313
|
+
</SearchResultsPredictive>
|
|
314
|
+
</div>
|
|
315
|
+
</Aside>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Additional Notes
|
|
321
|
+
|
|
322
|
+
### How to use a different URL search parameter?
|
|
323
|
+
|
|
324
|
+
- Modify the `name` attribute in the forms input element. e.g
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
<input name="query" />`.
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
- Modify the fetchers term variable to parse the new name. e.g
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
const term = String(searchParams.get('query') || '');
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### How to customize the way the results look?
|
|
337
|
+
|
|
338
|
+
Simply go to `/app/components/SearchResultsPredictive.tsx` and look for the compound
|
|
339
|
+
component you would like to modify.
|
|
340
|
+
|
|
341
|
+
Here we add images to each predictive product result item
|
|
342
|
+
|
|
343
|
+
```diff
|
|
344
|
+
SearchResultsPredictive.Products = function ({
|
|
345
|
+
products,
|
|
346
|
+
closeSearch,
|
|
347
|
+
term,
|
|
348
|
+
}: SearchResultsPredictiveProductsProps) {
|
|
349
|
+
if (!products.length) return null;
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div className="predictive-search-result" key="products">
|
|
353
|
+
<h5>Products</h5>
|
|
354
|
+
<ul>
|
|
355
|
+
{products.map((product) => {
|
|
356
|
+
const productUrl = urlWithTrackingParams({
|
|
357
|
+
baseUrl: `/products/${product.handle}`,
|
|
358
|
+
trackingParams: product.trackingParameters,
|
|
359
|
+
term: term.current,
|
|
360
|
+
});
|
|
361
|
+
+ const image = product?.variants?.nodes?.[0].image;
|
|
362
|
+
return (
|
|
363
|
+
<li className="predictive-search-result-item" key={product.id}>
|
|
364
|
+
<Link to={productUrl} onClick={closeSearch}>
|
|
365
|
+
+ {image && (
|
|
366
|
+
+ <Image
|
|
367
|
+
+ alt={image.altText ?? ''}
|
|
368
|
+
+ src={image.url}
|
|
369
|
+
+ width={50}
|
|
370
|
+
+ height={50}
|
|
371
|
+
+ />
|
|
372
|
+
+ )}
|
|
373
|
+
<div>
|
|
374
|
+
<p>{product.title}</p>
|
|
375
|
+
<small>
|
|
376
|
+
{product?.variants?.nodes?.[0].price && (
|
|
377
|
+
<Money
|
|
378
|
+
data={product.variants.nodes[0].price}
|
|
379
|
+
/>
|
|
380
|
+
)}
|
|
381
|
+
</small>
|
|
382
|
+
</div>
|
|
383
|
+
</Link>
|
|
384
|
+
</li>
|
|
385
|
+
);
|
|
386
|
+
})}
|
|
387
|
+
</ul>
|
|
388
|
+
</div>
|
|
389
|
+
)
|
|
390
|
+
};
|
|
391
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Hydrogen Search
|
|
2
|
+
|
|
3
|
+
Our skeleton template ships with a `/search` route and a set of components to easily
|
|
4
|
+
implement a traditional search flow.
|
|
5
|
+
|
|
6
|
+
This integration uses the storefront API (SFAPI) [search](https://shopify.dev/docs/api/storefront/latest/queries/search)
|
|
7
|
+
endpoint to retrieve search results based on a search term.
|
|
8
|
+
|
|
9
|
+
## Components Architecture
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Components
|
|
14
|
+
|
|
15
|
+
| File | Description |
|
|
16
|
+
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| [`app/components/SearchForm.tsx`](app/components/SearchForm.tsx) | A fully customizable form component configured to make (server-side) form `GET` requests to the `/search` route. |
|
|
18
|
+
| [`app/components/SearchResults.tsx`](app/components/SearchResults.tsx) | A fully customizable search results wrapper, that provides compound components to render `articles`, `pages` and `products` |
|
|
19
|
+
|
|
20
|
+
## Instructions
|
|
21
|
+
|
|
22
|
+
### 1. Create the search route
|
|
23
|
+
|
|
24
|
+
Create a new file at `/routes/search.tsx`
|
|
25
|
+
|
|
26
|
+
### 3. Add `search` query and fetcher
|
|
27
|
+
|
|
28
|
+
The search fetcher parses the `q` parameter and performs the search SFAPI request.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
/**
|
|
32
|
+
* Regular search query and fragments
|
|
33
|
+
* (adjust as needed)
|
|
34
|
+
*/
|
|
35
|
+
const SEARCH_PRODUCT_FRAGMENT = `#graphql
|
|
36
|
+
fragment SearchProduct on Product {
|
|
37
|
+
__typename
|
|
38
|
+
handle
|
|
39
|
+
id
|
|
40
|
+
publishedAt
|
|
41
|
+
title
|
|
42
|
+
trackingParameters
|
|
43
|
+
vendor
|
|
44
|
+
variants(first: 1) {
|
|
45
|
+
nodes {
|
|
46
|
+
id
|
|
47
|
+
image {
|
|
48
|
+
url
|
|
49
|
+
altText
|
|
50
|
+
width
|
|
51
|
+
height
|
|
52
|
+
}
|
|
53
|
+
price {
|
|
54
|
+
amount
|
|
55
|
+
currencyCode
|
|
56
|
+
}
|
|
57
|
+
compareAtPrice {
|
|
58
|
+
amount
|
|
59
|
+
currencyCode
|
|
60
|
+
}
|
|
61
|
+
selectedOptions {
|
|
62
|
+
name
|
|
63
|
+
value
|
|
64
|
+
}
|
|
65
|
+
product {
|
|
66
|
+
handle
|
|
67
|
+
title
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
` as const;
|
|
73
|
+
|
|
74
|
+
const SEARCH_PAGE_FRAGMENT = `#graphql
|
|
75
|
+
fragment SearchPage on Page {
|
|
76
|
+
__typename
|
|
77
|
+
handle
|
|
78
|
+
id
|
|
79
|
+
title
|
|
80
|
+
trackingParameters
|
|
81
|
+
}
|
|
82
|
+
` as const;
|
|
83
|
+
|
|
84
|
+
const SEARCH_ARTICLE_FRAGMENT = `#graphql
|
|
85
|
+
fragment SearchArticle on Article {
|
|
86
|
+
__typename
|
|
87
|
+
handle
|
|
88
|
+
id
|
|
89
|
+
title
|
|
90
|
+
trackingParameters
|
|
91
|
+
}
|
|
92
|
+
` as const;
|
|
93
|
+
|
|
94
|
+
const PAGE_INFO_FRAGMENT = `#graphql
|
|
95
|
+
fragment PageInfoFragment on PageInfo {
|
|
96
|
+
hasNextPage
|
|
97
|
+
hasPreviousPage
|
|
98
|
+
startCursor
|
|
99
|
+
endCursor
|
|
100
|
+
}
|
|
101
|
+
` as const;
|
|
102
|
+
|
|
103
|
+
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/search
|
|
104
|
+
export const SEARCH_QUERY = `#graphql
|
|
105
|
+
query Search(
|
|
106
|
+
$country: CountryCode
|
|
107
|
+
$endCursor: String
|
|
108
|
+
$first: Int
|
|
109
|
+
$language: LanguageCode
|
|
110
|
+
$last: Int
|
|
111
|
+
$term: String!
|
|
112
|
+
$startCursor: String
|
|
113
|
+
) @inContext(country: $country, language: $language) {
|
|
114
|
+
articles: search(
|
|
115
|
+
query: $term,
|
|
116
|
+
types: [ARTICLE],
|
|
117
|
+
first: $first,
|
|
118
|
+
) {
|
|
119
|
+
nodes {
|
|
120
|
+
...on Article {
|
|
121
|
+
...SearchArticle
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
pages: search(
|
|
126
|
+
query: $term,
|
|
127
|
+
types: [PAGE],
|
|
128
|
+
first: $first,
|
|
129
|
+
) {
|
|
130
|
+
nodes {
|
|
131
|
+
...on Page {
|
|
132
|
+
...SearchPage
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
products: search(
|
|
137
|
+
after: $endCursor,
|
|
138
|
+
before: $startCursor,
|
|
139
|
+
first: $first,
|
|
140
|
+
last: $last,
|
|
141
|
+
query: $term,
|
|
142
|
+
sortKey: RELEVANCE,
|
|
143
|
+
types: [PRODUCT],
|
|
144
|
+
unavailableProducts: HIDE,
|
|
145
|
+
) {
|
|
146
|
+
nodes {
|
|
147
|
+
...on Product {
|
|
148
|
+
...SearchProduct
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
pageInfo {
|
|
152
|
+
...PageInfoFragment
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
${SEARCH_PRODUCT_FRAGMENT}
|
|
157
|
+
${SEARCH_PAGE_FRAGMENT}
|
|
158
|
+
${SEARCH_ARTICLE_FRAGMENT}
|
|
159
|
+
${PAGE_INFO_FRAGMENT}
|
|
160
|
+
` as const;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Regular search fetcher
|
|
164
|
+
*/
|
|
165
|
+
async function search({
|
|
166
|
+
request,
|
|
167
|
+
context,
|
|
168
|
+
}: Pick<LoaderFunctionArgs, 'request' | 'context'>) {
|
|
169
|
+
const {storefront} = context;
|
|
170
|
+
const url = new URL(request.url);
|
|
171
|
+
const searchParams = new URLSearchParams(url.search);
|
|
172
|
+
const variables = getPaginationVariables(request, {pageBy: 8});
|
|
173
|
+
const term = String(searchParams.get('q') || '');
|
|
174
|
+
|
|
175
|
+
// Search articles, pages, and products for the `q` term
|
|
176
|
+
const {errors, ...items} = await storefront.query(SEARCH_QUERY, {
|
|
177
|
+
variables: {...variables, term},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!items) {
|
|
181
|
+
throw new Error('No search data returned from Shopify API');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (errors) {
|
|
185
|
+
throw new Error(errors[0].message);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const total = Object.values(items).reduce((acc, {nodes}) => {
|
|
189
|
+
return acc + nodes.length;
|
|
190
|
+
}, 0);
|
|
191
|
+
|
|
192
|
+
return json({term, result: {total, items}});
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 3. Add a `loader` export to the route
|
|
197
|
+
|
|
198
|
+
This loader receives and processes `GET` requests from the `<SearchForm />` component.
|
|
199
|
+
|
|
200
|
+
A `q` URL parameter will be used as the search term and appended automatically by
|
|
201
|
+
the form if present in it's children prop
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
/**
|
|
205
|
+
* Handles regular search GET requests
|
|
206
|
+
* requested by the SearchForm component and /search route visits
|
|
207
|
+
*/
|
|
208
|
+
export async function loader({request, context}: LoaderFunctionArgs) {
|
|
209
|
+
const url = new URL(request.url);
|
|
210
|
+
const isRegular = !url.searchParams.has('predictive');
|
|
211
|
+
|
|
212
|
+
if (!isRegular) {
|
|
213
|
+
return json({})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const searchPromise = regularSearch({request, context});
|
|
217
|
+
|
|
218
|
+
searchPromise.catch((error: Error) => {
|
|
219
|
+
console.error(error);
|
|
220
|
+
return {term: '', result: null, error: error.message};
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return json(await searchPromise);
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 4. Render the search form and results
|
|
228
|
+
|
|
229
|
+
Finally, create a default export to render both the search form and the search results
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import {SearchForm} from '~/components/SearchForm';
|
|
233
|
+
import {SearchResults} from '~/components/SearchResults';
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Renders the /search route
|
|
237
|
+
*/
|
|
238
|
+
export default function SearchPage() {
|
|
239
|
+
const {term, result} = useLoaderData<typeof loader>();
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className="search">
|
|
243
|
+
<h1>Search</h1>
|
|
244
|
+
<SearchForm>
|
|
245
|
+
{({inputRef}) => (
|
|
246
|
+
<>
|
|
247
|
+
<input
|
|
248
|
+
defaultValue={term}
|
|
249
|
+
name="q"
|
|
250
|
+
placeholder="Search…"
|
|
251
|
+
ref={inputRef}
|
|
252
|
+
type="search"
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
<button type="submit">Search</button>
|
|
256
|
+
</>
|
|
257
|
+
)}
|
|
258
|
+
</SearchForm>
|
|
259
|
+
{!term || !result?.total ? (
|
|
260
|
+
<SearchResults.Empty />
|
|
261
|
+
) : (
|
|
262
|
+
<SearchResults result={result} term={term}>
|
|
263
|
+
{({articles, pages, products, term}) => (
|
|
264
|
+
<div>
|
|
265
|
+
<SearchResults.Products products={products} term={term} />
|
|
266
|
+
<SearchResults.Pages pages={pages} term={term} />
|
|
267
|
+
<SearchResults.Articles articles={articles} term={term} />
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</SearchResults>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Additional Notes
|
|
278
|
+
|
|
279
|
+
### How to use a different URL search parameter?
|
|
280
|
+
|
|
281
|
+
- Modify the `name` attribute in the forms input element. e.g
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
<input name="query" />`.
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- Modify the search fetcher term variable to parse the new name. e.g
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const term = String(searchParams.get('query') || '');
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### How to customize the way the results look?
|
|
294
|
+
|
|
295
|
+
Simply go to `/app/components/SearchResults.txx` and look for the compound component you
|
|
296
|
+
want to modify.
|
|
297
|
+
|
|
298
|
+
For example, let's render articles in a horizontal flex container
|
|
299
|
+
|
|
300
|
+
```diff
|
|
301
|
+
SearchResults.Pages = function({
|
|
302
|
+
pages,
|
|
303
|
+
term,
|
|
304
|
+
}: {
|
|
305
|
+
pages: SearchItems['pages'];
|
|
306
|
+
term: string;
|
|
307
|
+
}) {
|
|
308
|
+
if (!pages?.nodes.length) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
return (
|
|
312
|
+
<div className="search-result">
|
|
313
|
+
<h2>Pages</h2>
|
|
314
|
+
+ <div className="flex">
|
|
315
|
+
{pages?.nodes?.map((page) => {
|
|
316
|
+
const pageUrl = urlWithTrackingParams({
|
|
317
|
+
baseUrl: `/pages/${page.handle}`,
|
|
318
|
+
trackingParams: page.trackingParameters,
|
|
319
|
+
term,
|
|
320
|
+
});
|
|
321
|
+
return (
|
|
322
|
+
<div className="search-results-item" key={page.id}>
|
|
323
|
+
<Link prefetch="intent" to={pageUrl}>
|
|
324
|
+
{page.title}
|
|
325
|
+
</Link>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
})}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
```
|