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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field name constants for order filtering
|
|
3
|
+
*/
|
|
4
|
+
export const ORDER_FILTER_FIELDS = {
|
|
5
|
+
NAME: 'name',
|
|
6
|
+
CONFIRMATION_NUMBER: 'confirmation_number',
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parameters for filtering customer orders, see: https://shopify.dev/docs/api/customer/latest/queries/customer#returns-Customer.fields.orders.arguments.query
|
|
11
|
+
*/
|
|
12
|
+
export interface OrderFilterParams {
|
|
13
|
+
/** Order name or number (e.g., "#1001" or "1001") */
|
|
14
|
+
name?: string;
|
|
15
|
+
/** Order confirmation number */
|
|
16
|
+
confirmationNumber?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sanitizes a filter value to prevent injection attacks or malformed queries.
|
|
21
|
+
* Allows only alphanumeric characters, underscore, and dash.
|
|
22
|
+
* @param value - The input string to sanitize
|
|
23
|
+
* @returns The sanitized string
|
|
24
|
+
*/
|
|
25
|
+
function sanitizeFilterValue(value: string): string {
|
|
26
|
+
// Only allow alphanumeric, underscore, and dash
|
|
27
|
+
// Remove anything else to prevent injection
|
|
28
|
+
return value.replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds a query string for filtering customer orders using the Customer Account API
|
|
33
|
+
* @param filters - The filter parameters
|
|
34
|
+
* @returns A formatted query string for the GraphQL query parameter, or undefined if no filters
|
|
35
|
+
* @example
|
|
36
|
+
* buildOrderSearchQuery(\{ name: '1001' \}) // returns "name:1001"
|
|
37
|
+
* buildOrderSearchQuery(\{ name: '1001', confirmationNumber: 'ABC123' \}) // returns "name:1001 AND confirmation_number:ABC123"
|
|
38
|
+
*/
|
|
39
|
+
export function buildOrderSearchQuery(
|
|
40
|
+
filters: OrderFilterParams,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
const queryParts: string[] = [];
|
|
43
|
+
|
|
44
|
+
if (filters.name) {
|
|
45
|
+
// Remove # if present and trim
|
|
46
|
+
const cleanName = filters.name.replace(/^#/, '').trim();
|
|
47
|
+
const sanitizedName = sanitizeFilterValue(cleanName);
|
|
48
|
+
if (sanitizedName) {
|
|
49
|
+
queryParts.push(`name:${sanitizedName}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (filters.confirmationNumber) {
|
|
54
|
+
const cleanConfirmation = filters.confirmationNumber.trim();
|
|
55
|
+
const sanitizedConfirmation = sanitizeFilterValue(cleanConfirmation);
|
|
56
|
+
if (sanitizedConfirmation) {
|
|
57
|
+
queryParts.push(`confirmation_number:${sanitizedConfirmation}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return queryParts.length > 0 ? queryParts.join(' AND ') : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parses order filter parameters from URLSearchParams
|
|
66
|
+
* @param searchParams - The URL search parameters
|
|
67
|
+
* @returns Parsed filter parameters
|
|
68
|
+
* @example
|
|
69
|
+
* const url = new URL('https://example.com/orders?name=1001&confirmation_number=ABC123');
|
|
70
|
+
* parseOrderFilters(url.searchParams) // returns \{ name: '1001', confirmationNumber: 'ABC123' \}
|
|
71
|
+
*/
|
|
72
|
+
export function parseOrderFilters(
|
|
73
|
+
searchParams: URLSearchParams,
|
|
74
|
+
): OrderFilterParams {
|
|
75
|
+
const filters: OrderFilterParams = {};
|
|
76
|
+
|
|
77
|
+
const name = searchParams.get(ORDER_FILTER_FIELDS.NAME);
|
|
78
|
+
if (name) {
|
|
79
|
+
filters.name = name;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const confirmationNumber = searchParams.get(
|
|
83
|
+
ORDER_FILTER_FIELDS.CONFIRMATION_NUMBER,
|
|
84
|
+
);
|
|
85
|
+
if (confirmationNumber) {
|
|
86
|
+
filters.confirmationNumber = confirmationNumber;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return filters;
|
|
90
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {redirect} from 'react-router';
|
|
2
|
+
|
|
3
|
+
export function redirectIfHandleIsLocalized(
|
|
4
|
+
request: Request,
|
|
5
|
+
...localizedResources: Array<{
|
|
6
|
+
handle: string;
|
|
7
|
+
data: {handle: string} & unknown;
|
|
8
|
+
}>
|
|
9
|
+
) {
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
let shouldRedirect = false;
|
|
12
|
+
|
|
13
|
+
localizedResources.forEach(({handle, data}) => {
|
|
14
|
+
if (handle !== data.handle) {
|
|
15
|
+
url.pathname = url.pathname.replace(handle, data.handle);
|
|
16
|
+
shouldRedirect = true;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (shouldRedirect) {
|
|
21
|
+
throw redirect(url.toString());
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PredictiveSearchQuery,
|
|
3
|
+
RegularSearchQuery,
|
|
4
|
+
} from 'storefrontapi.generated';
|
|
5
|
+
|
|
6
|
+
type ResultWithItems<Type extends 'predictive' | 'regular', Items> = {
|
|
7
|
+
type: Type;
|
|
8
|
+
term: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
result: {total: number; items: Items};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RegularSearchReturn = ResultWithItems<
|
|
14
|
+
'regular',
|
|
15
|
+
RegularSearchQuery
|
|
16
|
+
>;
|
|
17
|
+
export type PredictiveSearchReturn = ResultWithItems<
|
|
18
|
+
'predictive',
|
|
19
|
+
NonNullable<PredictiveSearchQuery['predictiveSearch']>
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the empty state of a predictive search result to reset the search state.
|
|
24
|
+
*/
|
|
25
|
+
export function getEmptyPredictiveSearchResult(): PredictiveSearchReturn['result'] {
|
|
26
|
+
return {
|
|
27
|
+
total: 0,
|
|
28
|
+
items: {
|
|
29
|
+
articles: [],
|
|
30
|
+
collections: [],
|
|
31
|
+
products: [],
|
|
32
|
+
pages: [],
|
|
33
|
+
queries: [],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface UrlWithTrackingParams {
|
|
39
|
+
/** The base URL to which the tracking parameters will be appended. */
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
/** The trackingParams returned by the Storefront API. */
|
|
42
|
+
trackingParams?: string | null;
|
|
43
|
+
/** Any additional query parameters to be appended to the URL. */
|
|
44
|
+
params?: Record<string, string>;
|
|
45
|
+
/** The search term to be appended to the URL. */
|
|
46
|
+
term: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A utility function that appends tracking parameters to a URL. Tracking parameters are
|
|
51
|
+
* used internally by Shopify to enhance search results and admin dashboards.
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const baseUrl = 'www.example.com';
|
|
55
|
+
* const trackingParams = 'utm_source=shopify&utm_medium=shopify_app&utm_campaign=storefront';
|
|
56
|
+
* const params = { foo: 'bar' };
|
|
57
|
+
* const term = 'search term';
|
|
58
|
+
* const url = urlWithTrackingParams({ baseUrl, trackingParams, params, term });
|
|
59
|
+
* console.log(url);
|
|
60
|
+
* // Output: 'https://www.example.com?foo=bar&q=search%20term&utm_source=shopify&utm_medium=shopify_app&utm_campaign=storefront'
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function urlWithTrackingParams({
|
|
64
|
+
baseUrl,
|
|
65
|
+
trackingParams,
|
|
66
|
+
params: extraParams,
|
|
67
|
+
term,
|
|
68
|
+
}: UrlWithTrackingParams) {
|
|
69
|
+
let search = new URLSearchParams({
|
|
70
|
+
...extraParams,
|
|
71
|
+
q: encodeURIComponent(term),
|
|
72
|
+
}).toString();
|
|
73
|
+
|
|
74
|
+
if (trackingParams) {
|
|
75
|
+
search = `${search}&${trackingParams}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return `${baseUrl}?${search}`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {HydrogenSession} from '@shopify/hydrogen';
|
|
2
|
+
import {
|
|
3
|
+
createCookieSessionStorage,
|
|
4
|
+
type SessionStorage,
|
|
5
|
+
type Session,
|
|
6
|
+
} from 'react-router';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* This is a custom session implementation for your Hydrogen shop.
|
|
10
|
+
* Feel free to customize it to your needs, add helper methods, or
|
|
11
|
+
* swap out the cookie-based implementation with something else!
|
|
12
|
+
*/
|
|
13
|
+
export class AppSession implements HydrogenSession {
|
|
14
|
+
public isPending = false;
|
|
15
|
+
|
|
16
|
+
#sessionStorage;
|
|
17
|
+
#session;
|
|
18
|
+
|
|
19
|
+
constructor(sessionStorage: SessionStorage, session: Session) {
|
|
20
|
+
this.#sessionStorage = sessionStorage;
|
|
21
|
+
this.#session = session;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static async init(request: Request, secrets: string[]) {
|
|
25
|
+
const storage = createCookieSessionStorage({
|
|
26
|
+
cookie: {
|
|
27
|
+
name: 'session',
|
|
28
|
+
httpOnly: true,
|
|
29
|
+
path: '/',
|
|
30
|
+
sameSite: 'lax',
|
|
31
|
+
secrets,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const session = await storage
|
|
36
|
+
.getSession(request.headers.get('Cookie'))
|
|
37
|
+
.catch(() => storage.getSession());
|
|
38
|
+
|
|
39
|
+
return new this(storage, session);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get has() {
|
|
43
|
+
return this.#session.has;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get get() {
|
|
47
|
+
return this.#session.get;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get flash() {
|
|
51
|
+
return this.#session.flash;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get unset() {
|
|
55
|
+
this.isPending = true;
|
|
56
|
+
return this.#session.unset;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get set() {
|
|
60
|
+
this.isPending = true;
|
|
61
|
+
return this.#session.set;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
destroy() {
|
|
65
|
+
return this.#sessionStorage.destroySession(this.#session);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
commit() {
|
|
69
|
+
this.isPending = false;
|
|
70
|
+
return this.#sessionStorage.commitSession(this.#session);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {useLocation} from 'react-router';
|
|
2
|
+
import type {SelectedOption} from '@shopify/hydrogen/storefront-api-types';
|
|
3
|
+
import {useMemo} from 'react';
|
|
4
|
+
|
|
5
|
+
export function useVariantUrl(
|
|
6
|
+
handle: string,
|
|
7
|
+
selectedOptions?: SelectedOption[],
|
|
8
|
+
) {
|
|
9
|
+
const {pathname} = useLocation();
|
|
10
|
+
|
|
11
|
+
return useMemo(() => {
|
|
12
|
+
return getVariantUrl({
|
|
13
|
+
handle,
|
|
14
|
+
pathname,
|
|
15
|
+
searchParams: new URLSearchParams(),
|
|
16
|
+
selectedOptions,
|
|
17
|
+
});
|
|
18
|
+
}, [handle, selectedOptions, pathname]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getVariantUrl({
|
|
22
|
+
handle,
|
|
23
|
+
pathname,
|
|
24
|
+
searchParams,
|
|
25
|
+
selectedOptions,
|
|
26
|
+
}: {
|
|
27
|
+
handle: string;
|
|
28
|
+
pathname: string;
|
|
29
|
+
searchParams: URLSearchParams;
|
|
30
|
+
selectedOptions?: SelectedOption[];
|
|
31
|
+
}) {
|
|
32
|
+
const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
|
|
33
|
+
const isLocalePathname = match && match.length > 0;
|
|
34
|
+
|
|
35
|
+
const path = isLocalePathname
|
|
36
|
+
? `${match![0]}products/${handle}`
|
|
37
|
+
: `/products/${handle}`;
|
|
38
|
+
|
|
39
|
+
selectedOptions?.forEach((option) => {
|
|
40
|
+
searchParams.set(option.name, option.value);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const searchString = searchParams.toString();
|
|
44
|
+
|
|
45
|
+
return path + (searchString ? '?' + searchParams.toString() : '');
|
|
46
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {Analytics, getShopAnalytics, useNonce} from '@shopify/hydrogen';
|
|
2
|
+
import {
|
|
3
|
+
Outlet,
|
|
4
|
+
useRouteError,
|
|
5
|
+
isRouteErrorResponse,
|
|
6
|
+
type ShouldRevalidateFunction,
|
|
7
|
+
Links,
|
|
8
|
+
Meta,
|
|
9
|
+
Scripts,
|
|
10
|
+
ScrollRestoration,
|
|
11
|
+
useRouteLoaderData,
|
|
12
|
+
} from 'react-router';
|
|
13
|
+
import type {Route} from './+types/root';
|
|
14
|
+
import favicon from '~/assets/favicon.svg';
|
|
15
|
+
import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments';
|
|
16
|
+
import resetStyles from '~/styles/reset.css?url';
|
|
17
|
+
import tailwindStyles from '~/styles/tailwind.css?url';
|
|
18
|
+
import {PageLayout} from './components/PageLayout';
|
|
19
|
+
|
|
20
|
+
export type RootLoader = typeof loader;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* This is important to avoid re-fetching root queries on sub-navigations
|
|
24
|
+
*/
|
|
25
|
+
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
|
26
|
+
formMethod,
|
|
27
|
+
currentUrl,
|
|
28
|
+
nextUrl,
|
|
29
|
+
}) => {
|
|
30
|
+
// revalidate when a mutation is performed e.g add to cart, login...
|
|
31
|
+
if (formMethod && formMethod !== 'GET') return true;
|
|
32
|
+
|
|
33
|
+
// revalidate when manually revalidating via useRevalidator
|
|
34
|
+
if (currentUrl.toString() === nextUrl.toString()) return true;
|
|
35
|
+
|
|
36
|
+
// Defaulting to no revalidation for root loader data to improve performance.
|
|
37
|
+
// When using this feature, you risk your UI getting out of sync with your server.
|
|
38
|
+
// Use with caution. If you are uncomfortable with this optimization, update the
|
|
39
|
+
// line below to `return defaultShouldRevalidate` instead.
|
|
40
|
+
// For more details see: https://remix.run/docs/en/main/route/should-revalidate
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The main and reset stylesheets are added in the Layout component
|
|
46
|
+
* to prevent a bug in development HMR updates.
|
|
47
|
+
*
|
|
48
|
+
* This avoids the "failed to execute 'insertBefore' on 'Node'" error
|
|
49
|
+
* that occurs after editing and navigating to another page.
|
|
50
|
+
*
|
|
51
|
+
* It's a temporary fix until the issue is resolved.
|
|
52
|
+
* https://github.com/remix-run/remix/issues/9242
|
|
53
|
+
*/
|
|
54
|
+
export function links() {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
rel: 'preconnect',
|
|
58
|
+
href: 'https://cdn.shopify.com',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
rel: 'preconnect',
|
|
62
|
+
href: 'https://shop.app',
|
|
63
|
+
},
|
|
64
|
+
{rel: 'icon', type: 'image/svg+xml', href: favicon},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function loader(args: Route.LoaderArgs) {
|
|
69
|
+
// Start fetching non-critical data without blocking time to first byte
|
|
70
|
+
const deferredData = loadDeferredData(args);
|
|
71
|
+
|
|
72
|
+
// Await the critical data required to render initial state of the page
|
|
73
|
+
const criticalData = await loadCriticalData(args);
|
|
74
|
+
|
|
75
|
+
const {storefront, env} = args.context;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
...deferredData,
|
|
79
|
+
...criticalData,
|
|
80
|
+
publicStoreDomain: env.PUBLIC_STORE_DOMAIN,
|
|
81
|
+
shop: getShopAnalytics({
|
|
82
|
+
storefront,
|
|
83
|
+
publicStorefrontId: env.PUBLIC_STOREFRONT_ID,
|
|
84
|
+
}),
|
|
85
|
+
consent: {
|
|
86
|
+
checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
|
|
87
|
+
storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN,
|
|
88
|
+
withPrivacyBanner: false,
|
|
89
|
+
// localize the privacy banner
|
|
90
|
+
country: args.context.storefront.i18n.country,
|
|
91
|
+
language: args.context.storefront.i18n.language,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load data necessary for rendering content above the fold. This is the critical data
|
|
98
|
+
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
|
|
99
|
+
*/
|
|
100
|
+
async function loadCriticalData({context}: Route.LoaderArgs) {
|
|
101
|
+
const {storefront} = context;
|
|
102
|
+
|
|
103
|
+
const [header] = await Promise.all([
|
|
104
|
+
storefront.query(HEADER_QUERY, {
|
|
105
|
+
cache: storefront.CacheLong(),
|
|
106
|
+
variables: {
|
|
107
|
+
headerMenuHandle: 'main-menu', // Adjust to your header menu handle
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
// Add other queries here, so that they are loaded in parallel
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
return {header};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load data for rendering content below the fold. This data is deferred and will be
|
|
118
|
+
* fetched after the initial page load. If it's unavailable, the page should still 200.
|
|
119
|
+
* Make sure to not throw any errors here, as it will cause the page to 500.
|
|
120
|
+
*/
|
|
121
|
+
function loadDeferredData({context}: Route.LoaderArgs) {
|
|
122
|
+
const {storefront, customerAccount, cart} = context;
|
|
123
|
+
|
|
124
|
+
// defer the footer query (below the fold)
|
|
125
|
+
const footer = storefront
|
|
126
|
+
.query(FOOTER_QUERY, {
|
|
127
|
+
cache: storefront.CacheLong(),
|
|
128
|
+
variables: {
|
|
129
|
+
footerMenuHandle: 'footer', // Adjust to your footer menu handle
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
.catch((error: Error) => {
|
|
133
|
+
// Log query errors, but don't throw them so the page can still render
|
|
134
|
+
console.error(error);
|
|
135
|
+
return null;
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
cart: cart.get(),
|
|
139
|
+
isLoggedIn: customerAccount.isLoggedIn(),
|
|
140
|
+
footer,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function Layout({children}: {children?: React.ReactNode}) {
|
|
145
|
+
const nonce = useNonce();
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<html lang="en">
|
|
149
|
+
<head>
|
|
150
|
+
<meta charSet="utf-8" />
|
|
151
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
152
|
+
<link rel="stylesheet" href={resetStyles}></link>
|
|
153
|
+
<link rel="stylesheet" href={tailwindStyles}></link>
|
|
154
|
+
<Meta />
|
|
155
|
+
<Links />
|
|
156
|
+
</head>
|
|
157
|
+
<body>
|
|
158
|
+
{children}
|
|
159
|
+
<ScrollRestoration nonce={nonce} />
|
|
160
|
+
<Scripts nonce={nonce} />
|
|
161
|
+
</body>
|
|
162
|
+
</html>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default function App() {
|
|
167
|
+
const data = useRouteLoaderData<RootLoader>('root');
|
|
168
|
+
|
|
169
|
+
if (!data) {
|
|
170
|
+
return <Outlet />;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Analytics.Provider
|
|
175
|
+
cart={data.cart}
|
|
176
|
+
shop={data.shop}
|
|
177
|
+
consent={data.consent}
|
|
178
|
+
>
|
|
179
|
+
<PageLayout {...data}>
|
|
180
|
+
<Outlet />
|
|
181
|
+
</PageLayout>
|
|
182
|
+
</Analytics.Provider>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function ErrorBoundary() {
|
|
187
|
+
const error = useRouteError();
|
|
188
|
+
let errorMessage = 'Unknown error';
|
|
189
|
+
let errorStatus = 500;
|
|
190
|
+
|
|
191
|
+
if (isRouteErrorResponse(error)) {
|
|
192
|
+
errorMessage = error?.data?.message ?? error.data;
|
|
193
|
+
errorStatus = error.status;
|
|
194
|
+
} else if (error instanceof Error) {
|
|
195
|
+
errorMessage = error.message;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div className="route-error">
|
|
200
|
+
<h1>Oops</h1>
|
|
201
|
+
<h2>{errorStatus}</h2>
|
|
202
|
+
{errorMessage && (
|
|
203
|
+
<fieldset>
|
|
204
|
+
<pre>{errorMessage}</pre>
|
|
205
|
+
</fieldset>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type {Route} from './+types/$';
|
|
2
|
+
|
|
3
|
+
export async function loader({request}: Route.LoaderArgs) {
|
|
4
|
+
throw new Response(`${new URL(request.url).pathname} not found`, {
|
|
5
|
+
status: 404,
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function CatchAllPage() {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {Route} from './+types/[robots.txt]';
|
|
2
|
+
import {parseGid} from '@shopify/hydrogen';
|
|
3
|
+
|
|
4
|
+
export async function loader({request, context}: Route.LoaderArgs) {
|
|
5
|
+
const url = new URL(request.url);
|
|
6
|
+
|
|
7
|
+
const {shop} = await context.storefront.query(ROBOTS_QUERY);
|
|
8
|
+
|
|
9
|
+
const shopId = parseGid(shop.id).id;
|
|
10
|
+
const body = robotsTxtData({url: url.origin, shopId});
|
|
11
|
+
|
|
12
|
+
return new Response(body, {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'text/plain',
|
|
16
|
+
|
|
17
|
+
'Cache-Control': `max-age=${60 * 60 * 24}`,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function robotsTxtData({url, shopId}: {shopId?: string; url?: string}) {
|
|
23
|
+
const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
|
|
24
|
+
|
|
25
|
+
return `
|
|
26
|
+
User-agent: *
|
|
27
|
+
${generalDisallowRules({sitemapUrl, shopId})}
|
|
28
|
+
|
|
29
|
+
# Google adsbot ignores robots.txt unless specifically named!
|
|
30
|
+
User-agent: adsbot-google
|
|
31
|
+
Disallow: /checkouts/
|
|
32
|
+
Disallow: /checkout
|
|
33
|
+
Disallow: /carts
|
|
34
|
+
Disallow: /orders
|
|
35
|
+
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
|
36
|
+
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
|
37
|
+
Disallow: /*?*oseid=*
|
|
38
|
+
Disallow: /*preview_theme_id*
|
|
39
|
+
Disallow: /*preview_script_id*
|
|
40
|
+
|
|
41
|
+
User-agent: Nutch
|
|
42
|
+
Disallow: /
|
|
43
|
+
|
|
44
|
+
User-agent: AhrefsBot
|
|
45
|
+
Crawl-delay: 10
|
|
46
|
+
${generalDisallowRules({sitemapUrl, shopId})}
|
|
47
|
+
|
|
48
|
+
User-agent: AhrefsSiteAudit
|
|
49
|
+
Crawl-delay: 10
|
|
50
|
+
${generalDisallowRules({sitemapUrl, shopId})}
|
|
51
|
+
|
|
52
|
+
User-agent: MJ12bot
|
|
53
|
+
Crawl-Delay: 10
|
|
54
|
+
|
|
55
|
+
User-agent: Pinterest
|
|
56
|
+
Crawl-delay: 1
|
|
57
|
+
`.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* This function generates disallow rules that generally follow what Shopify's
|
|
62
|
+
* Online Store has as defaults for their robots.txt
|
|
63
|
+
*/
|
|
64
|
+
function generalDisallowRules({
|
|
65
|
+
shopId,
|
|
66
|
+
sitemapUrl,
|
|
67
|
+
}: {
|
|
68
|
+
shopId?: string;
|
|
69
|
+
sitemapUrl?: string;
|
|
70
|
+
}) {
|
|
71
|
+
return `Disallow: /admin
|
|
72
|
+
Disallow: /cart
|
|
73
|
+
Disallow: /orders
|
|
74
|
+
Disallow: /checkouts/
|
|
75
|
+
Disallow: /checkout
|
|
76
|
+
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
|
77
|
+
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
|
78
|
+
Disallow: /carts
|
|
79
|
+
Disallow: /account
|
|
80
|
+
Disallow: /collections/*sort_by*
|
|
81
|
+
Disallow: /*/collections/*sort_by*
|
|
82
|
+
Disallow: /collections/*+*
|
|
83
|
+
Disallow: /collections/*%2B*
|
|
84
|
+
Disallow: /collections/*%2b*
|
|
85
|
+
Disallow: /*/collections/*+*
|
|
86
|
+
Disallow: /*/collections/*%2B*
|
|
87
|
+
Disallow: /*/collections/*%2b*
|
|
88
|
+
Disallow: */collections/*filter*&*filter*
|
|
89
|
+
Disallow: /blogs/*+*
|
|
90
|
+
Disallow: /blogs/*%2B*
|
|
91
|
+
Disallow: /blogs/*%2b*
|
|
92
|
+
Disallow: /*/blogs/*+*
|
|
93
|
+
Disallow: /*/blogs/*%2B*
|
|
94
|
+
Disallow: /*/blogs/*%2b*
|
|
95
|
+
Disallow: /*?*oseid=*
|
|
96
|
+
Disallow: /*preview_theme_id*
|
|
97
|
+
Disallow: /*preview_script_id*
|
|
98
|
+
Disallow: /policies/
|
|
99
|
+
Disallow: /*/*?*ls=*&ls=*
|
|
100
|
+
Disallow: /*/*?*ls%3D*%3Fls%3D*
|
|
101
|
+
Disallow: /*/*?*ls%3d*%3fls%3d*
|
|
102
|
+
Disallow: /search
|
|
103
|
+
Allow: /search/
|
|
104
|
+
Disallow: /search/?*
|
|
105
|
+
Disallow: /apple-app-site-association
|
|
106
|
+
Disallow: /.well-known/shopify/monorail
|
|
107
|
+
${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ROBOTS_QUERY = `#graphql
|
|
111
|
+
query StoreRobots($country: CountryCode, $language: LanguageCode)
|
|
112
|
+
@inContext(country: $country, language: $language) {
|
|
113
|
+
shop {
|
|
114
|
+
id
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
` as const;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {Route} from './+types/[sitemap.xml]';
|
|
2
|
+
import {getSitemapIndex} from '@shopify/hydrogen';
|
|
3
|
+
|
|
4
|
+
export async function loader({
|
|
5
|
+
request,
|
|
6
|
+
context: {storefront},
|
|
7
|
+
}: Route.LoaderArgs) {
|
|
8
|
+
const response = await getSitemapIndex({
|
|
9
|
+
storefront,
|
|
10
|
+
request,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
response.headers.set('Cache-Control', `max-age=${60 * 60 * 24}`);
|
|
14
|
+
|
|
15
|
+
return response;
|
|
16
|
+
}
|