hydrogen-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -0
- package/dist/commands/add.d.ts +7 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +123 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +160 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/setup-mcp.d.ts +7 -0
- package/dist/commands/setup-mcp.d.ts.map +1 -0
- package/dist/commands/setup-mcp.js +179 -0
- package/dist/commands/setup-mcp.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/generators.d.ts +6 -0
- package/dist/lib/generators.d.ts.map +1 -0
- package/dist/lib/generators.js +470 -0
- package/dist/lib/generators.js.map +1 -0
- package/dist/lib/utils.d.ts +17 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +101 -0
- package/dist/lib/utils.js.map +1 -0
- package/package.json +54 -0
- package/templates/starter/.env.example +21 -0
- package/templates/starter/.graphqlrc.ts +27 -0
- package/templates/starter/README.md +117 -0
- package/templates/starter/app/assets/favicon.svg +28 -0
- package/templates/starter/app/components/AddToCartButton.tsx +102 -0
- package/templates/starter/app/components/Aside.tsx +136 -0
- package/templates/starter/app/components/CartLineItem.tsx +229 -0
- package/templates/starter/app/components/CartMain.tsx +131 -0
- package/templates/starter/app/components/CartSummary.tsx +315 -0
- package/templates/starter/app/components/CollectionFilters.tsx +330 -0
- package/templates/starter/app/components/CollectionGrid.tsx +141 -0
- package/templates/starter/app/components/Footer.tsx +218 -0
- package/templates/starter/app/components/Header.tsx +296 -0
- package/templates/starter/app/components/PageLayout.tsx +174 -0
- package/templates/starter/app/components/PaginatedResourceSection.tsx +41 -0
- package/templates/starter/app/components/ProductCard.tsx +151 -0
- package/templates/starter/app/components/ProductForm.tsx +156 -0
- package/templates/starter/app/components/ProductGallery.tsx +164 -0
- package/templates/starter/app/components/ProductGrid.tsx +64 -0
- package/templates/starter/app/components/ProductImage.tsx +23 -0
- package/templates/starter/app/components/ProductItem.tsx +44 -0
- package/templates/starter/app/components/ProductPrice.tsx +97 -0
- package/templates/starter/app/components/SearchDialog.tsx +599 -0
- package/templates/starter/app/components/SearchForm.tsx +68 -0
- package/templates/starter/app/components/SearchFormPredictive.tsx +76 -0
- package/templates/starter/app/components/SearchResults.tsx +161 -0
- package/templates/starter/app/components/SearchResultsPredictive.tsx +461 -0
- package/templates/starter/app/entry.client.tsx +21 -0
- package/templates/starter/app/entry.server.tsx +53 -0
- package/templates/starter/app/graphql/customer-account/CustomerAddressMutations.ts +64 -0
- package/templates/starter/app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
- package/templates/starter/app/graphql/customer-account/CustomerOrderQuery.ts +90 -0
- package/templates/starter/app/graphql/customer-account/CustomerOrdersQuery.ts +63 -0
- package/templates/starter/app/graphql/customer-account/CustomerUpdateMutation.ts +25 -0
- package/templates/starter/app/lib/context.ts +60 -0
- package/templates/starter/app/lib/fragments.ts +234 -0
- package/templates/starter/app/lib/orderFilters.ts +90 -0
- package/templates/starter/app/lib/redirect.ts +23 -0
- package/templates/starter/app/lib/search.ts +79 -0
- package/templates/starter/app/lib/session.ts +72 -0
- package/templates/starter/app/lib/variants.ts +46 -0
- package/templates/starter/app/root.tsx +209 -0
- package/templates/starter/app/routes/$.tsx +11 -0
- package/templates/starter/app/routes/[robots.txt].tsx +117 -0
- package/templates/starter/app/routes/[sitemap.xml].tsx +16 -0
- package/templates/starter/app/routes/_index.tsx +167 -0
- package/templates/starter/app/routes/account.$.tsx +9 -0
- package/templates/starter/app/routes/account._index.tsx +5 -0
- package/templates/starter/app/routes/account.addresses.tsx +516 -0
- package/templates/starter/app/routes/account.orders.$id.tsx +222 -0
- package/templates/starter/app/routes/account.orders._index.tsx +222 -0
- package/templates/starter/app/routes/account.profile.tsx +133 -0
- package/templates/starter/app/routes/account.tsx +97 -0
- package/templates/starter/app/routes/account_.authorize.tsx +5 -0
- package/templates/starter/app/routes/account_.login.tsx +7 -0
- package/templates/starter/app/routes/account_.logout.tsx +11 -0
- package/templates/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
- package/templates/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +129 -0
- package/templates/starter/app/routes/blogs.$blogHandle._index.tsx +175 -0
- package/templates/starter/app/routes/blogs._index.tsx +109 -0
- package/templates/starter/app/routes/cart.$lines.tsx +70 -0
- package/templates/starter/app/routes/cart.tsx +117 -0
- package/templates/starter/app/routes/collections.$handle.tsx +161 -0
- package/templates/starter/app/routes/collections._index.tsx +133 -0
- package/templates/starter/app/routes/collections.all.tsx +122 -0
- package/templates/starter/app/routes/discount.$code.tsx +48 -0
- package/templates/starter/app/routes/pages.$handle.tsx +88 -0
- package/templates/starter/app/routes/policies.$handle.tsx +93 -0
- package/templates/starter/app/routes/policies._index.tsx +69 -0
- package/templates/starter/app/routes/products.$handle.tsx +232 -0
- package/templates/starter/app/routes/search.tsx +426 -0
- package/templates/starter/app/routes/sitemap.$type.$page[.xml].tsx +23 -0
- package/templates/starter/app/routes.ts +9 -0
- package/templates/starter/app/styles/app.css +574 -0
- package/templates/starter/app/styles/reset.css +139 -0
- package/templates/starter/app/styles/tailwind.css +116 -0
- package/templates/starter/customer-accountapi.generated.d.ts +543 -0
- package/templates/starter/env.d.ts +7 -0
- package/templates/starter/eslint.config.js +247 -0
- package/templates/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
- package/templates/starter/guides/predictiveSearch/predictiveSearch.md +394 -0
- package/templates/starter/guides/search/search.jpg +0 -0
- package/templates/starter/guides/search/search.md +335 -0
- package/templates/starter/package.json +71 -0
- package/templates/starter/postcss.config.js +6 -0
- package/templates/starter/public/.gitkeep +0 -0
- package/templates/starter/react-router.config.ts +13 -0
- package/templates/starter/server.ts +59 -0
- package/templates/starter/storefrontapi.generated.d.ts +1264 -0
- package/templates/starter/tailwind.config.js +83 -0
- package/templates/starter/tsconfig.json +67 -0
- package/templates/starter/vite.config.ts +32 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {fixupConfigRules, fixupPluginRules} from '@eslint/compat';
|
|
2
|
+
import eslintComments from 'eslint-plugin-eslint-comments';
|
|
3
|
+
import react from 'eslint-plugin-react';
|
|
4
|
+
import reactHooks from 'eslint-plugin-react-hooks';
|
|
5
|
+
import jsxA11Y from 'eslint-plugin-jsx-a11y';
|
|
6
|
+
import globals from 'globals';
|
|
7
|
+
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
|
8
|
+
import _import from 'eslint-plugin-import';
|
|
9
|
+
import tsParser from '@typescript-eslint/parser';
|
|
10
|
+
import jest from 'eslint-plugin-jest';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {fileURLToPath} from 'node:url';
|
|
13
|
+
import js from '@eslint/js';
|
|
14
|
+
import {FlatCompat} from '@eslint/eslintrc';
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
const compat = new FlatCompat({
|
|
19
|
+
baseDirectory: __dirname,
|
|
20
|
+
recommendedConfig: js.configs.recommended,
|
|
21
|
+
allConfig: js.configs.all,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export default [
|
|
25
|
+
{
|
|
26
|
+
ignores: [
|
|
27
|
+
'**/node_modules/',
|
|
28
|
+
'**/build/',
|
|
29
|
+
'**/dist/',
|
|
30
|
+
'**/*.graphql.d.ts',
|
|
31
|
+
'**/*.graphql.ts',
|
|
32
|
+
'**/*.generated.d.ts',
|
|
33
|
+
'**/.react-router/',
|
|
34
|
+
'**/packages/hydrogen/dist/',
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
...fixupConfigRules(
|
|
38
|
+
compat.extends(
|
|
39
|
+
'eslint:recommended',
|
|
40
|
+
'plugin:eslint-comments/recommended',
|
|
41
|
+
'plugin:react/recommended',
|
|
42
|
+
'plugin:react-hooks/recommended',
|
|
43
|
+
'plugin:jsx-a11y/recommended',
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
{
|
|
47
|
+
plugins: {
|
|
48
|
+
'eslint-comments': fixupPluginRules(eslintComments),
|
|
49
|
+
react: fixupPluginRules(react),
|
|
50
|
+
'react-hooks': fixupPluginRules(reactHooks),
|
|
51
|
+
'jsx-a11y': fixupPluginRules(jsxA11Y),
|
|
52
|
+
},
|
|
53
|
+
languageOptions: {
|
|
54
|
+
globals: {
|
|
55
|
+
...globals.browser,
|
|
56
|
+
...globals.node,
|
|
57
|
+
},
|
|
58
|
+
ecmaVersion: 'latest',
|
|
59
|
+
sourceType: 'module',
|
|
60
|
+
parserOptions: {
|
|
61
|
+
ecmaFeatures: {
|
|
62
|
+
jsx: true,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
settings: {
|
|
67
|
+
react: {
|
|
68
|
+
version: 'detect',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
rules: {
|
|
72
|
+
'eslint-comments/no-unused-disable': 'error',
|
|
73
|
+
'no-console': [
|
|
74
|
+
'warn',
|
|
75
|
+
{
|
|
76
|
+
allow: ['warn', 'error'],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
'no-use-before-define': 'off',
|
|
80
|
+
'no-warning-comments': 'off',
|
|
81
|
+
'object-shorthand': [
|
|
82
|
+
'error',
|
|
83
|
+
'always',
|
|
84
|
+
{
|
|
85
|
+
avoidQuotes: true,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
'no-useless-escape': 'off',
|
|
89
|
+
'no-case-declarations': 'off',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
...fixupConfigRules(
|
|
93
|
+
compat.extends(
|
|
94
|
+
'plugin:react/recommended',
|
|
95
|
+
'plugin:react/jsx-runtime',
|
|
96
|
+
'plugin:react-hooks/recommended',
|
|
97
|
+
'plugin:jsx-a11y/recommended',
|
|
98
|
+
),
|
|
99
|
+
).map((config) => ({
|
|
100
|
+
...config,
|
|
101
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
102
|
+
})),
|
|
103
|
+
{
|
|
104
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
105
|
+
plugins: {
|
|
106
|
+
react: fixupPluginRules(react),
|
|
107
|
+
'jsx-a11y': fixupPluginRules(jsxA11Y),
|
|
108
|
+
},
|
|
109
|
+
settings: {
|
|
110
|
+
react: {
|
|
111
|
+
version: 'detect',
|
|
112
|
+
},
|
|
113
|
+
formComponents: ['Form'],
|
|
114
|
+
linkComponents: [
|
|
115
|
+
{
|
|
116
|
+
name: 'Link',
|
|
117
|
+
linkAttribute: 'to',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'NavLink',
|
|
121
|
+
linkAttribute: 'to',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
'import/resolver': {
|
|
125
|
+
typescript: {},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
rules: {
|
|
129
|
+
'jsx-a11y/control-has-associated-label': 'off',
|
|
130
|
+
'jsx-a11y/label-has-for': 'off',
|
|
131
|
+
'react/display-name': 'off',
|
|
132
|
+
'react/no-array-index-key': 'warn',
|
|
133
|
+
'react/prop-types': 'off',
|
|
134
|
+
'react/react-in-jsx-scope': 'off',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
...fixupConfigRules(
|
|
138
|
+
compat.extends(
|
|
139
|
+
'plugin:@typescript-eslint/recommended',
|
|
140
|
+
'plugin:import/recommended',
|
|
141
|
+
'plugin:import/typescript',
|
|
142
|
+
),
|
|
143
|
+
).map((config) => ({
|
|
144
|
+
...config,
|
|
145
|
+
files: ['**/*.{ts,tsx}'],
|
|
146
|
+
})),
|
|
147
|
+
{
|
|
148
|
+
files: ['**/*.{ts,tsx}'],
|
|
149
|
+
plugins: {
|
|
150
|
+
'@typescript-eslint': fixupPluginRules(typescriptEslint),
|
|
151
|
+
import: fixupPluginRules(_import),
|
|
152
|
+
},
|
|
153
|
+
languageOptions: {
|
|
154
|
+
parser: tsParser,
|
|
155
|
+
parserOptions: {
|
|
156
|
+
project: './tsconfig.json',
|
|
157
|
+
tsconfigRootDir: __dirname,
|
|
158
|
+
ecmaFeatures: {
|
|
159
|
+
jsx: true,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
settings: {
|
|
164
|
+
'import/internal-regex': '^~/',
|
|
165
|
+
'import/resolvers': {
|
|
166
|
+
node: {
|
|
167
|
+
extensions: ['.ts', '.tsx'],
|
|
168
|
+
},
|
|
169
|
+
typescript: {
|
|
170
|
+
alwaysTryTypes: true,
|
|
171
|
+
project: __dirname,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
rules: {
|
|
176
|
+
'@typescript-eslint/ban-ts-comment': 'off',
|
|
177
|
+
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
|
178
|
+
'@typescript-eslint/naming-convention': [
|
|
179
|
+
'error',
|
|
180
|
+
{
|
|
181
|
+
selector: 'default',
|
|
182
|
+
format: ['camelCase', 'PascalCase', 'UPPER_CASE'],
|
|
183
|
+
leadingUnderscore: 'allowSingleOrDouble',
|
|
184
|
+
trailingUnderscore: 'allowSingleOrDouble',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
selector: 'typeLike',
|
|
188
|
+
format: ['PascalCase'],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
selector: 'typeParameter',
|
|
192
|
+
format: ['PascalCase'],
|
|
193
|
+
leadingUnderscore: 'allow',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
selector: 'interface',
|
|
197
|
+
format: ['PascalCase'],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
selector: 'property',
|
|
201
|
+
format: null,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
'@typescript-eslint/no-empty-function': 'off',
|
|
205
|
+
'@typescript-eslint/no-empty-interface': 'off',
|
|
206
|
+
'@typescript-eslint/no-empty-object-type': 'off',
|
|
207
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
208
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
209
|
+
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
|
|
210
|
+
'@typescript-eslint/no-unused-vars': 'off',
|
|
211
|
+
'@typescript-eslint/no-floating-promises': 'error',
|
|
212
|
+
'@typescript-eslint/no-misused-promises': 'error',
|
|
213
|
+
'react/prop-types': 'off',
|
|
214
|
+
'import/no-unresolved': ['error', {ignore: ['^virtual:']}],
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
files: ['**/.eslintrc.cjs'],
|
|
219
|
+
languageOptions: {
|
|
220
|
+
globals: {
|
|
221
|
+
...globals.node,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
...compat.extends('plugin:jest/recommended').map((config) => ({
|
|
226
|
+
...config,
|
|
227
|
+
files: ['**/*.test.*'],
|
|
228
|
+
})),
|
|
229
|
+
{
|
|
230
|
+
files: ['**/*.test.*'],
|
|
231
|
+
plugins: {
|
|
232
|
+
jest,
|
|
233
|
+
},
|
|
234
|
+
languageOptions: {
|
|
235
|
+
globals: {
|
|
236
|
+
...globals.node,
|
|
237
|
+
...globals.jest,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
files: ['**/*.server.*'],
|
|
243
|
+
rules: {
|
|
244
|
+
'react-hooks/rules-of-hooks': 'off',
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
];
|
|
Binary file
|
|
@@ -0,0 +1,394 @@
|
|
|
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
|
+
selectedOrFirstAvailableVariant(
|
|
91
|
+
selectedOptions: []
|
|
92
|
+
ignoreUnknownOptions: true
|
|
93
|
+
caseInsensitiveMatch: true
|
|
94
|
+
) {
|
|
95
|
+
id
|
|
96
|
+
image {
|
|
97
|
+
url
|
|
98
|
+
altText
|
|
99
|
+
width
|
|
100
|
+
height
|
|
101
|
+
}
|
|
102
|
+
price {
|
|
103
|
+
amount
|
|
104
|
+
currencyCode
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
` as const;
|
|
109
|
+
|
|
110
|
+
const PREDICTIVE_SEARCH_QUERY_FRAGMENT = `#graphql
|
|
111
|
+
fragment PredictiveQuery on SearchQuerySuggestion {
|
|
112
|
+
__typename
|
|
113
|
+
text
|
|
114
|
+
styledText
|
|
115
|
+
trackingParameters
|
|
116
|
+
}
|
|
117
|
+
` as const;
|
|
118
|
+
|
|
119
|
+
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/predictiveSearch
|
|
120
|
+
const PREDICTIVE_SEARCH_QUERY = `#graphql
|
|
121
|
+
query predictiveSearch(
|
|
122
|
+
$country: CountryCode
|
|
123
|
+
$language: LanguageCode
|
|
124
|
+
$limit: Int!
|
|
125
|
+
$limitScope: PredictiveSearchLimitScope!
|
|
126
|
+
$term: String!
|
|
127
|
+
$types: [PredictiveSearchType!]
|
|
128
|
+
) @inContext(country: $country, language: $language) {
|
|
129
|
+
predictiveSearch(
|
|
130
|
+
limit: $limit,
|
|
131
|
+
limitScope: $limitScope,
|
|
132
|
+
query: $term,
|
|
133
|
+
types: $types,
|
|
134
|
+
) {
|
|
135
|
+
articles {
|
|
136
|
+
...PredictiveArticle
|
|
137
|
+
}
|
|
138
|
+
collections {
|
|
139
|
+
...PredictiveCollection
|
|
140
|
+
}
|
|
141
|
+
pages {
|
|
142
|
+
...PredictivePage
|
|
143
|
+
}
|
|
144
|
+
products {
|
|
145
|
+
...PredictiveProduct
|
|
146
|
+
}
|
|
147
|
+
queries {
|
|
148
|
+
...PredictiveQuery
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
${PREDICTIVE_SEARCH_ARTICLE_FRAGMENT}
|
|
153
|
+
${PREDICTIVE_SEARCH_COLLECTION_FRAGMENT}
|
|
154
|
+
${PREDICTIVE_SEARCH_PAGE_FRAGMENT}
|
|
155
|
+
${PREDICTIVE_SEARCH_PRODUCT_FRAGMENT}
|
|
156
|
+
${PREDICTIVE_SEARCH_QUERY_FRAGMENT}
|
|
157
|
+
` as const;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Predictive search fetcher
|
|
161
|
+
*/
|
|
162
|
+
async function predictiveSearch({
|
|
163
|
+
request,
|
|
164
|
+
context,
|
|
165
|
+
}: Pick<ActionFunctionArgs, 'request' | 'context'>) {
|
|
166
|
+
const {storefront} = context;
|
|
167
|
+
const formData = await request.formData();
|
|
168
|
+
const term = String(formData.get('q') || '');
|
|
169
|
+
|
|
170
|
+
const limit = Number(formData.get('limit') || 10);
|
|
171
|
+
|
|
172
|
+
// Predictively search articles, collections, pages, products, and queries (suggestions)
|
|
173
|
+
const {predictiveSearch: items, errors} = await storefront.query(
|
|
174
|
+
PREDICTIVE_SEARCH_QUERY,
|
|
175
|
+
{
|
|
176
|
+
variables: {
|
|
177
|
+
// customize search options as needed
|
|
178
|
+
limit,
|
|
179
|
+
limitScope: 'EACH',
|
|
180
|
+
term,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (errors) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Shopify API errors: ${errors.map(({message}) => message).join(', ')}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!items) {
|
|
192
|
+
throw new Error('No predictive search data returned');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const total = Object.values(items).reduce((acc, {length}) => acc + length, 0);
|
|
196
|
+
|
|
197
|
+
return {term, result: {items, total}, error: null};
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### 3. Add a `loader` export to the route
|
|
202
|
+
|
|
203
|
+
This action receives and processes `GET` requests from the `<SearchFormPredictive />`
|
|
204
|
+
component. These request include the search parameter `predictive` to identify them over
|
|
205
|
+
regular search requests.
|
|
206
|
+
|
|
207
|
+
A `q` URL parameter will be used as the search term and appended automatically by
|
|
208
|
+
the form if present in it's children prop.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
/**
|
|
212
|
+
* Handles predictive search GET requests
|
|
213
|
+
* requested by the SearchFormPredictive component
|
|
214
|
+
*/
|
|
215
|
+
export async function loader({request, context}: LoaderFunctionArgs) {
|
|
216
|
+
const url = new URL(request.url);
|
|
217
|
+
const isPredictive = url.searchParams.has('predictive');
|
|
218
|
+
|
|
219
|
+
if (!isPredictive) {
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const searchPromise = predictiveSearch({request, context});
|
|
224
|
+
|
|
225
|
+
searchPromise.catch((error: Error) => {
|
|
226
|
+
console.error(error);
|
|
227
|
+
return {term: '', result: null, error: error.message};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return await searchPromise;
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 4. Render the predictive search form and results
|
|
235
|
+
|
|
236
|
+
Create a SearchAside or similar component to render the form and results.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { SearchFormPredictive } from '~/components/SearchFormPredictive';
|
|
240
|
+
import { SearchResultsPredictive } from '~/components/SearchResultsPredictive';
|
|
241
|
+
|
|
242
|
+
function SearchAside() {
|
|
243
|
+
return (
|
|
244
|
+
<Aside type="search" heading="SEARCH">
|
|
245
|
+
<div className="predictive-search">
|
|
246
|
+
<br />
|
|
247
|
+
<SearchFormPredictive>
|
|
248
|
+
{({ fetchResults, goToSearch, inputRef }) => (
|
|
249
|
+
<>
|
|
250
|
+
<input
|
|
251
|
+
name="q"
|
|
252
|
+
onChange={fetchResults}
|
|
253
|
+
onFocus={fetchResults}
|
|
254
|
+
placeholder="Search"
|
|
255
|
+
ref={inputRef}
|
|
256
|
+
type="search"
|
|
257
|
+
/>
|
|
258
|
+
|
|
259
|
+
<button onClick={goToSearch}>
|
|
260
|
+
Search
|
|
261
|
+
</button>
|
|
262
|
+
</>
|
|
263
|
+
)}
|
|
264
|
+
</SearchFormPredictive>
|
|
265
|
+
|
|
266
|
+
<SearchResultsPredictive>
|
|
267
|
+
{({ items, total, term, state, inputRef, closeSearch }) => {
|
|
268
|
+
const { articles, collections, pages, products, queries } = items;
|
|
269
|
+
|
|
270
|
+
if (state === 'loading' && term.current) {
|
|
271
|
+
return <div>Loading...</div>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!total) {
|
|
275
|
+
return <SearchResultsPredictive.Empty term={term} />;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<>
|
|
280
|
+
<SearchResultsPredictive.Queries
|
|
281
|
+
queries={queries}
|
|
282
|
+
term={term}
|
|
283
|
+
inputRef={inputRef}
|
|
284
|
+
/>
|
|
285
|
+
<SearchResultsPredictive.Products
|
|
286
|
+
products={products}
|
|
287
|
+
closeSearch={closeSearch}
|
|
288
|
+
term={term}
|
|
289
|
+
/>
|
|
290
|
+
<SearchResultsPredictive.Collections
|
|
291
|
+
collections={collections}
|
|
292
|
+
closeSearch={closeSearch}
|
|
293
|
+
term={term}
|
|
294
|
+
/>
|
|
295
|
+
<SearchResultsPredictive.Pages
|
|
296
|
+
pages={pages}
|
|
297
|
+
closeSearch={closeSearch}
|
|
298
|
+
term={term}
|
|
299
|
+
/>
|
|
300
|
+
<SearchResultsPredictive.Articles
|
|
301
|
+
articles={articles}
|
|
302
|
+
closeSearch={closeSearch}
|
|
303
|
+
term={term}
|
|
304
|
+
/>
|
|
305
|
+
{term.current && total && (
|
|
306
|
+
<Link onClick={closeSearch} to={`/search?q=${term.current}`}>
|
|
307
|
+
<p>
|
|
308
|
+
View all results for <q>{term.current}</q> →
|
|
309
|
+
</p>
|
|
310
|
+
</Link>
|
|
311
|
+
)}
|
|
312
|
+
</>
|
|
313
|
+
);
|
|
314
|
+
}}
|
|
315
|
+
</SearchResultsPredictive>
|
|
316
|
+
</div>
|
|
317
|
+
</Aside>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Additional Notes
|
|
323
|
+
|
|
324
|
+
### How to use a different URL search parameter?
|
|
325
|
+
|
|
326
|
+
- Modify the `name` attribute in the forms input element. e.g
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
<input name="query" />`.
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- Modify the fetchers term variable to parse the new name. e.g
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
const term = String(searchParams.get('query') || '');
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### How to customize the way the results look?
|
|
339
|
+
|
|
340
|
+
Simply go to `/app/components/SearchResultsPredictive.tsx` and look for the compound
|
|
341
|
+
component you would like to modify.
|
|
342
|
+
|
|
343
|
+
Here we add images to each predictive product result item
|
|
344
|
+
|
|
345
|
+
```diff
|
|
346
|
+
SearchResultsPredictive.Products = function ({
|
|
347
|
+
products,
|
|
348
|
+
closeSearch,
|
|
349
|
+
term,
|
|
350
|
+
}: SearchResultsPredictiveProductsProps) {
|
|
351
|
+
if (!products.length) return null;
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<div className="predictive-search-result" key="products">
|
|
355
|
+
<h5>Products</h5>
|
|
356
|
+
<ul>
|
|
357
|
+
{products.map((product) => {
|
|
358
|
+
const productUrl = urlWithTrackingParams({
|
|
359
|
+
baseUrl: `/products/${product.handle}`,
|
|
360
|
+
trackingParams: product.trackingParameters,
|
|
361
|
+
term: term.current,
|
|
362
|
+
});
|
|
363
|
+
+ const price = product?.selectedOrFirstAvailableVariant?.price;
|
|
364
|
+
+ const image = product?.selectedOrFirstAvailableVariant?.image;
|
|
365
|
+
return (
|
|
366
|
+
<li className="predictive-search-result-item" key={product.id}>
|
|
367
|
+
<Link to={productUrl} onClick={closeSearch}>
|
|
368
|
+
+ {image && (
|
|
369
|
+
+ <Image
|
|
370
|
+
+ alt={image.altText ?? ''}
|
|
371
|
+
+ src={image.url}
|
|
372
|
+
+ width={50}
|
|
373
|
+
+ height={50}
|
|
374
|
+
+ />
|
|
375
|
+
+ )}
|
|
376
|
+
<div>
|
|
377
|
+
<p>{product.title}</p>
|
|
378
|
+
<small>
|
|
379
|
+
+ {price && (
|
|
380
|
+
+ <Money
|
|
381
|
+
+ data={price}
|
|
382
|
+
+ />
|
|
383
|
+
+ )}
|
|
384
|
+
</small>
|
|
385
|
+
</div>
|
|
386
|
+
</Link>
|
|
387
|
+
</li>
|
|
388
|
+
);
|
|
389
|
+
})}
|
|
390
|
+
</ul>
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
};
|
|
394
|
+
```
|
|
Binary file
|