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,161 @@
|
|
|
1
|
+
import {redirect, useLoaderData} from 'react-router';
|
|
2
|
+
import type {Route} from './+types/collections.$handle';
|
|
3
|
+
import {getPaginationVariables, Analytics} from '@shopify/hydrogen';
|
|
4
|
+
import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
|
|
5
|
+
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
|
|
6
|
+
import {ProductItem} from '~/components/ProductItem';
|
|
7
|
+
import type {ProductItemFragment} from 'storefrontapi.generated';
|
|
8
|
+
|
|
9
|
+
export const meta: Route.MetaFunction = ({data}) => {
|
|
10
|
+
return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function loader(args: Route.LoaderArgs) {
|
|
14
|
+
// Start fetching non-critical data without blocking time to first byte
|
|
15
|
+
const deferredData = loadDeferredData(args);
|
|
16
|
+
|
|
17
|
+
// Await the critical data required to render initial state of the page
|
|
18
|
+
const criticalData = await loadCriticalData(args);
|
|
19
|
+
|
|
20
|
+
return {...deferredData, ...criticalData};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load data necessary for rendering content above the fold. This is the critical data
|
|
25
|
+
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
|
|
26
|
+
*/
|
|
27
|
+
async function loadCriticalData({context, params, request}: Route.LoaderArgs) {
|
|
28
|
+
const {handle} = params;
|
|
29
|
+
const {storefront} = context;
|
|
30
|
+
const paginationVariables = getPaginationVariables(request, {
|
|
31
|
+
pageBy: 8,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!handle) {
|
|
35
|
+
throw redirect('/collections');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [{collection}] = await Promise.all([
|
|
39
|
+
storefront.query(COLLECTION_QUERY, {
|
|
40
|
+
variables: {handle, ...paginationVariables},
|
|
41
|
+
// Add other queries here, so that they are loaded in parallel
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
if (!collection) {
|
|
46
|
+
throw new Response(`Collection ${handle} not found`, {
|
|
47
|
+
status: 404,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The API handle might be localized, so redirect to the localized handle
|
|
52
|
+
redirectIfHandleIsLocalized(request, {handle, data: collection});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
collection,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load data for rendering content below the fold. This data is deferred and will be
|
|
61
|
+
* fetched after the initial page load. If it's unavailable, the page should still 200.
|
|
62
|
+
* Make sure to not throw any errors here, as it will cause the page to 500.
|
|
63
|
+
*/
|
|
64
|
+
function loadDeferredData({context}: Route.LoaderArgs) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function Collection() {
|
|
69
|
+
const {collection} = useLoaderData<typeof loader>();
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="collection">
|
|
73
|
+
<h1>{collection.title}</h1>
|
|
74
|
+
<p className="collection-description">{collection.description}</p>
|
|
75
|
+
<PaginatedResourceSection<ProductItemFragment>
|
|
76
|
+
connection={collection.products}
|
|
77
|
+
resourcesClassName="products-grid"
|
|
78
|
+
>
|
|
79
|
+
{({node: product, index}) => (
|
|
80
|
+
<ProductItem
|
|
81
|
+
key={product.id}
|
|
82
|
+
product={product}
|
|
83
|
+
loading={index < 8 ? 'eager' : undefined}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</PaginatedResourceSection>
|
|
87
|
+
<Analytics.CollectionView
|
|
88
|
+
data={{
|
|
89
|
+
collection: {
|
|
90
|
+
id: collection.id,
|
|
91
|
+
handle: collection.handle,
|
|
92
|
+
},
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const PRODUCT_ITEM_FRAGMENT = `#graphql
|
|
100
|
+
fragment MoneyProductItem on MoneyV2 {
|
|
101
|
+
amount
|
|
102
|
+
currencyCode
|
|
103
|
+
}
|
|
104
|
+
fragment ProductItem on Product {
|
|
105
|
+
id
|
|
106
|
+
handle
|
|
107
|
+
title
|
|
108
|
+
featuredImage {
|
|
109
|
+
id
|
|
110
|
+
altText
|
|
111
|
+
url
|
|
112
|
+
width
|
|
113
|
+
height
|
|
114
|
+
}
|
|
115
|
+
priceRange {
|
|
116
|
+
minVariantPrice {
|
|
117
|
+
...MoneyProductItem
|
|
118
|
+
}
|
|
119
|
+
maxVariantPrice {
|
|
120
|
+
...MoneyProductItem
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
` as const;
|
|
125
|
+
|
|
126
|
+
// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection
|
|
127
|
+
const COLLECTION_QUERY = `#graphql
|
|
128
|
+
${PRODUCT_ITEM_FRAGMENT}
|
|
129
|
+
query Collection(
|
|
130
|
+
$handle: String!
|
|
131
|
+
$country: CountryCode
|
|
132
|
+
$language: LanguageCode
|
|
133
|
+
$first: Int
|
|
134
|
+
$last: Int
|
|
135
|
+
$startCursor: String
|
|
136
|
+
$endCursor: String
|
|
137
|
+
) @inContext(country: $country, language: $language) {
|
|
138
|
+
collection(handle: $handle) {
|
|
139
|
+
id
|
|
140
|
+
handle
|
|
141
|
+
title
|
|
142
|
+
description
|
|
143
|
+
products(
|
|
144
|
+
first: $first,
|
|
145
|
+
last: $last,
|
|
146
|
+
before: $startCursor,
|
|
147
|
+
after: $endCursor
|
|
148
|
+
) {
|
|
149
|
+
nodes {
|
|
150
|
+
...ProductItem
|
|
151
|
+
}
|
|
152
|
+
pageInfo {
|
|
153
|
+
hasPreviousPage
|
|
154
|
+
hasNextPage
|
|
155
|
+
endCursor
|
|
156
|
+
startCursor
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
` as const;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {useLoaderData, Link} from 'react-router';
|
|
2
|
+
import type {Route} from './+types/collections._index';
|
|
3
|
+
import {getPaginationVariables, Image} from '@shopify/hydrogen';
|
|
4
|
+
import type {CollectionFragment} from 'storefrontapi.generated';
|
|
5
|
+
import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
|
|
6
|
+
|
|
7
|
+
export async function loader(args: Route.LoaderArgs) {
|
|
8
|
+
// Start fetching non-critical data without blocking time to first byte
|
|
9
|
+
const deferredData = loadDeferredData(args);
|
|
10
|
+
|
|
11
|
+
// Await the critical data required to render initial state of the page
|
|
12
|
+
const criticalData = await loadCriticalData(args);
|
|
13
|
+
|
|
14
|
+
return {...deferredData, ...criticalData};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load data necessary for rendering content above the fold. This is the critical data
|
|
19
|
+
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
|
|
20
|
+
*/
|
|
21
|
+
async function loadCriticalData({context, request}: Route.LoaderArgs) {
|
|
22
|
+
const paginationVariables = getPaginationVariables(request, {
|
|
23
|
+
pageBy: 4,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const [{collections}] = await Promise.all([
|
|
27
|
+
context.storefront.query(COLLECTIONS_QUERY, {
|
|
28
|
+
variables: paginationVariables,
|
|
29
|
+
}),
|
|
30
|
+
// Add other queries here, so that they are loaded in parallel
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
return {collections};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load data for rendering content below the fold. This data is deferred and will be
|
|
38
|
+
* fetched after the initial page load. If it's unavailable, the page should still 200.
|
|
39
|
+
* Make sure to not throw any errors here, as it will cause the page to 500.
|
|
40
|
+
*/
|
|
41
|
+
function loadDeferredData({context}: Route.LoaderArgs) {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function Collections() {
|
|
46
|
+
const {collections} = useLoaderData<typeof loader>();
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="collections">
|
|
50
|
+
<h1>Collections</h1>
|
|
51
|
+
<PaginatedResourceSection<CollectionFragment>
|
|
52
|
+
connection={collections}
|
|
53
|
+
resourcesClassName="collections-grid"
|
|
54
|
+
>
|
|
55
|
+
{({node: collection, index}) => (
|
|
56
|
+
<CollectionItem
|
|
57
|
+
key={collection.id}
|
|
58
|
+
collection={collection}
|
|
59
|
+
index={index}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
</PaginatedResourceSection>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function CollectionItem({
|
|
68
|
+
collection,
|
|
69
|
+
index,
|
|
70
|
+
}: {
|
|
71
|
+
collection: CollectionFragment;
|
|
72
|
+
index: number;
|
|
73
|
+
}) {
|
|
74
|
+
return (
|
|
75
|
+
<Link
|
|
76
|
+
className="collection-item"
|
|
77
|
+
key={collection.id}
|
|
78
|
+
to={`/collections/${collection.handle}`}
|
|
79
|
+
prefetch="intent"
|
|
80
|
+
>
|
|
81
|
+
{collection?.image && (
|
|
82
|
+
<Image
|
|
83
|
+
alt={collection.image.altText || collection.title}
|
|
84
|
+
aspectRatio="1/1"
|
|
85
|
+
data={collection.image}
|
|
86
|
+
loading={index < 3 ? 'eager' : undefined}
|
|
87
|
+
sizes="(min-width: 45em) 400px, 100vw"
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
<h5>{collection.title}</h5>
|
|
91
|
+
</Link>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const COLLECTIONS_QUERY = `#graphql
|
|
96
|
+
fragment Collection on Collection {
|
|
97
|
+
id
|
|
98
|
+
title
|
|
99
|
+
handle
|
|
100
|
+
image {
|
|
101
|
+
id
|
|
102
|
+
url
|
|
103
|
+
altText
|
|
104
|
+
width
|
|
105
|
+
height
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
query StoreCollections(
|
|
109
|
+
$country: CountryCode
|
|
110
|
+
$endCursor: String
|
|
111
|
+
$first: Int
|
|
112
|
+
$language: LanguageCode
|
|
113
|
+
$last: Int
|
|
114
|
+
$startCursor: String
|
|
115
|
+
) @inContext(country: $country, language: $language) {
|
|
116
|
+
collections(
|
|
117
|
+
first: $first,
|
|
118
|
+
last: $last,
|
|
119
|
+
before: $startCursor,
|
|
120
|
+
after: $endCursor
|
|
121
|
+
) {
|
|
122
|
+
nodes {
|
|
123
|
+
...Collection
|
|
124
|
+
}
|
|
125
|
+
pageInfo {
|
|
126
|
+
hasNextPage
|
|
127
|
+
hasPreviousPage
|
|
128
|
+
startCursor
|
|
129
|
+
endCursor
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
` as const;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type {Route} from './+types/collections.all';
|
|
2
|
+
import {useLoaderData} from 'react-router';
|
|
3
|
+
import {getPaginationVariables, Image, Money} from '@shopify/hydrogen';
|
|
4
|
+
import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
|
|
5
|
+
import {ProductItem} from '~/components/ProductItem';
|
|
6
|
+
import type {CollectionItemFragment} from 'storefrontapi.generated';
|
|
7
|
+
|
|
8
|
+
export const meta: Route.MetaFunction = () => {
|
|
9
|
+
return [{title: `Hydrogen | Products`}];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function loader(args: Route.LoaderArgs) {
|
|
13
|
+
// Start fetching non-critical data without blocking time to first byte
|
|
14
|
+
const deferredData = loadDeferredData(args);
|
|
15
|
+
|
|
16
|
+
// Await the critical data required to render initial state of the page
|
|
17
|
+
const criticalData = await loadCriticalData(args);
|
|
18
|
+
|
|
19
|
+
return {...deferredData, ...criticalData};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load data necessary for rendering content above the fold. This is the critical data
|
|
24
|
+
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
|
|
25
|
+
*/
|
|
26
|
+
async function loadCriticalData({context, request}: Route.LoaderArgs) {
|
|
27
|
+
const {storefront} = context;
|
|
28
|
+
const paginationVariables = getPaginationVariables(request, {
|
|
29
|
+
pageBy: 8,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const [{products}] = await Promise.all([
|
|
33
|
+
storefront.query(CATALOG_QUERY, {
|
|
34
|
+
variables: {...paginationVariables},
|
|
35
|
+
}),
|
|
36
|
+
// Add other queries here, so that they are loaded in parallel
|
|
37
|
+
]);
|
|
38
|
+
return {products};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load data for rendering content below the fold. This data is deferred and will be
|
|
43
|
+
* fetched after the initial page load. If it's unavailable, the page should still 200.
|
|
44
|
+
* Make sure to not throw any errors here, as it will cause the page to 500.
|
|
45
|
+
*/
|
|
46
|
+
function loadDeferredData({context}: Route.LoaderArgs) {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function Collection() {
|
|
51
|
+
const {products} = useLoaderData<typeof loader>();
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="collection">
|
|
55
|
+
<h1>Products</h1>
|
|
56
|
+
<PaginatedResourceSection<CollectionItemFragment>
|
|
57
|
+
connection={products}
|
|
58
|
+
resourcesClassName="products-grid"
|
|
59
|
+
>
|
|
60
|
+
{({node: product, index}) => (
|
|
61
|
+
<ProductItem
|
|
62
|
+
key={product.id}
|
|
63
|
+
product={product}
|
|
64
|
+
loading={index < 8 ? 'eager' : undefined}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</PaginatedResourceSection>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const COLLECTION_ITEM_FRAGMENT = `#graphql
|
|
73
|
+
fragment MoneyCollectionItem on MoneyV2 {
|
|
74
|
+
amount
|
|
75
|
+
currencyCode
|
|
76
|
+
}
|
|
77
|
+
fragment CollectionItem on Product {
|
|
78
|
+
id
|
|
79
|
+
handle
|
|
80
|
+
title
|
|
81
|
+
featuredImage {
|
|
82
|
+
id
|
|
83
|
+
altText
|
|
84
|
+
url
|
|
85
|
+
width
|
|
86
|
+
height
|
|
87
|
+
}
|
|
88
|
+
priceRange {
|
|
89
|
+
minVariantPrice {
|
|
90
|
+
...MoneyCollectionItem
|
|
91
|
+
}
|
|
92
|
+
maxVariantPrice {
|
|
93
|
+
...MoneyCollectionItem
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
` as const;
|
|
98
|
+
|
|
99
|
+
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/product
|
|
100
|
+
const CATALOG_QUERY = `#graphql
|
|
101
|
+
query Catalog(
|
|
102
|
+
$country: CountryCode
|
|
103
|
+
$language: LanguageCode
|
|
104
|
+
$first: Int
|
|
105
|
+
$last: Int
|
|
106
|
+
$startCursor: String
|
|
107
|
+
$endCursor: String
|
|
108
|
+
) @inContext(country: $country, language: $language) {
|
|
109
|
+
products(first: $first, last: $last, before: $startCursor, after: $endCursor) {
|
|
110
|
+
nodes {
|
|
111
|
+
...CollectionItem
|
|
112
|
+
}
|
|
113
|
+
pageInfo {
|
|
114
|
+
hasPreviousPage
|
|
115
|
+
hasNextPage
|
|
116
|
+
startCursor
|
|
117
|
+
endCursor
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
${COLLECTION_ITEM_FRAGMENT}
|
|
122
|
+
` as const;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {redirect} from 'react-router';
|
|
2
|
+
import type {Route} from './+types/discount.$code';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Automatically applies a discount found on the url
|
|
6
|
+
* If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* Example path applying a discount and optional redirecting (defaults to the home page)
|
|
10
|
+
* ```js
|
|
11
|
+
* /discount/FREESHIPPING?redirect=/products
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export async function loader({request, context, params}: Route.LoaderArgs) {
|
|
16
|
+
const {cart} = context;
|
|
17
|
+
const {code} = params;
|
|
18
|
+
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const searchParams = new URLSearchParams(url.search);
|
|
21
|
+
let redirectParam =
|
|
22
|
+
searchParams.get('redirect') || searchParams.get('return_to') || '/';
|
|
23
|
+
|
|
24
|
+
if (redirectParam.includes('//')) {
|
|
25
|
+
// Avoid redirecting to external URLs to prevent phishing attacks
|
|
26
|
+
redirectParam = '/';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
searchParams.delete('redirect');
|
|
30
|
+
searchParams.delete('return_to');
|
|
31
|
+
|
|
32
|
+
const redirectUrl = `${redirectParam}?${searchParams}`;
|
|
33
|
+
|
|
34
|
+
if (!code) {
|
|
35
|
+
return redirect(redirectUrl);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = await cart.updateDiscountCodes([code]);
|
|
39
|
+
const headers = cart.setCartId(result.cart.id);
|
|
40
|
+
|
|
41
|
+
// Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
|
|
42
|
+
// If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
|
|
43
|
+
// on localhost:3000
|
|
44
|
+
return redirect(redirectUrl, {
|
|
45
|
+
status: 303,
|
|
46
|
+
headers,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {useLoaderData} from 'react-router';
|
|
2
|
+
import type {Route} from './+types/pages.$handle';
|
|
3
|
+
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
|
|
4
|
+
|
|
5
|
+
export const meta: Route.MetaFunction = ({data}) => {
|
|
6
|
+
return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function loader(args: Route.LoaderArgs) {
|
|
10
|
+
// Start fetching non-critical data without blocking time to first byte
|
|
11
|
+
const deferredData = loadDeferredData(args);
|
|
12
|
+
|
|
13
|
+
// Await the critical data required to render initial state of the page
|
|
14
|
+
const criticalData = await loadCriticalData(args);
|
|
15
|
+
|
|
16
|
+
return {...deferredData, ...criticalData};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load data necessary for rendering content above the fold. This is the critical data
|
|
21
|
+
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
|
|
22
|
+
*/
|
|
23
|
+
async function loadCriticalData({context, request, params}: Route.LoaderArgs) {
|
|
24
|
+
if (!params.handle) {
|
|
25
|
+
throw new Error('Missing page handle');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const [{page}] = await Promise.all([
|
|
29
|
+
context.storefront.query(PAGE_QUERY, {
|
|
30
|
+
variables: {
|
|
31
|
+
handle: params.handle,
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
// Add other queries here, so that they are loaded in parallel
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
if (!page) {
|
|
38
|
+
throw new Response('Not Found', {status: 404});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
redirectIfHandleIsLocalized(request, {handle: params.handle, data: page});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
page,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load data for rendering content below the fold. This data is deferred and will be
|
|
50
|
+
* fetched after the initial page load. If it's unavailable, the page should still 200.
|
|
51
|
+
* Make sure to not throw any errors here, as it will cause the page to 500.
|
|
52
|
+
*/
|
|
53
|
+
function loadDeferredData({context}: Route.LoaderArgs) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function Page() {
|
|
58
|
+
const {page} = useLoaderData<typeof loader>();
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="page">
|
|
62
|
+
<header>
|
|
63
|
+
<h1>{page.title}</h1>
|
|
64
|
+
</header>
|
|
65
|
+
<main dangerouslySetInnerHTML={{__html: page.body}} />
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PAGE_QUERY = `#graphql
|
|
71
|
+
query Page(
|
|
72
|
+
$language: LanguageCode,
|
|
73
|
+
$country: CountryCode,
|
|
74
|
+
$handle: String!
|
|
75
|
+
)
|
|
76
|
+
@inContext(language: $language, country: $country) {
|
|
77
|
+
page(handle: $handle) {
|
|
78
|
+
handle
|
|
79
|
+
id
|
|
80
|
+
title
|
|
81
|
+
body
|
|
82
|
+
seo {
|
|
83
|
+
description
|
|
84
|
+
title
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
` as const;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {Link, useLoaderData} from 'react-router';
|
|
2
|
+
import type {Route} from './+types/policies.$handle';
|
|
3
|
+
import {type Shop} from '@shopify/hydrogen/storefront-api-types';
|
|
4
|
+
|
|
5
|
+
type SelectedPolicies = keyof Pick<
|
|
6
|
+
Shop,
|
|
7
|
+
'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy'
|
|
8
|
+
>;
|
|
9
|
+
|
|
10
|
+
export const meta: Route.MetaFunction = ({data}) => {
|
|
11
|
+
return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function loader({params, context}: Route.LoaderArgs) {
|
|
15
|
+
if (!params.handle) {
|
|
16
|
+
throw new Response('No handle was passed in', {status: 404});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const policyName = params.handle.replace(
|
|
20
|
+
/-([a-z])/g,
|
|
21
|
+
(_: unknown, m1: string) => m1.toUpperCase(),
|
|
22
|
+
) as SelectedPolicies;
|
|
23
|
+
|
|
24
|
+
const data = await context.storefront.query(POLICY_CONTENT_QUERY, {
|
|
25
|
+
variables: {
|
|
26
|
+
privacyPolicy: false,
|
|
27
|
+
shippingPolicy: false,
|
|
28
|
+
termsOfService: false,
|
|
29
|
+
refundPolicy: false,
|
|
30
|
+
[policyName]: true,
|
|
31
|
+
language: context.storefront.i18n?.language,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const policy = data.shop?.[policyName];
|
|
36
|
+
|
|
37
|
+
if (!policy) {
|
|
38
|
+
throw new Response('Could not find the policy', {status: 404});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {policy};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function Policy() {
|
|
45
|
+
const {policy} = useLoaderData<typeof loader>();
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="policy">
|
|
49
|
+
<br />
|
|
50
|
+
<br />
|
|
51
|
+
<div>
|
|
52
|
+
<Link to="/policies">← Back to Policies</Link>
|
|
53
|
+
</div>
|
|
54
|
+
<br />
|
|
55
|
+
<h1>{policy.title}</h1>
|
|
56
|
+
<div dangerouslySetInnerHTML={{__html: policy.body}} />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Shop
|
|
62
|
+
const POLICY_CONTENT_QUERY = `#graphql
|
|
63
|
+
fragment Policy on ShopPolicy {
|
|
64
|
+
body
|
|
65
|
+
handle
|
|
66
|
+
id
|
|
67
|
+
title
|
|
68
|
+
url
|
|
69
|
+
}
|
|
70
|
+
query Policy(
|
|
71
|
+
$country: CountryCode
|
|
72
|
+
$language: LanguageCode
|
|
73
|
+
$privacyPolicy: Boolean!
|
|
74
|
+
$refundPolicy: Boolean!
|
|
75
|
+
$shippingPolicy: Boolean!
|
|
76
|
+
$termsOfService: Boolean!
|
|
77
|
+
) @inContext(language: $language, country: $country) {
|
|
78
|
+
shop {
|
|
79
|
+
privacyPolicy @include(if: $privacyPolicy) {
|
|
80
|
+
...Policy
|
|
81
|
+
}
|
|
82
|
+
shippingPolicy @include(if: $shippingPolicy) {
|
|
83
|
+
...Policy
|
|
84
|
+
}
|
|
85
|
+
termsOfService @include(if: $termsOfService) {
|
|
86
|
+
...Policy
|
|
87
|
+
}
|
|
88
|
+
refundPolicy @include(if: $refundPolicy) {
|
|
89
|
+
...Policy
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
` as const;
|