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,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
|
+
|
|
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
|
+
}
|