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,167 @@
|
|
|
1
|
+
import {Await, useLoaderData, Link} from 'react-router';
|
|
2
|
+
import type {Route} from './+types/_index';
|
|
3
|
+
import {Suspense} from 'react';
|
|
4
|
+
import {Image} from '@shopify/hydrogen';
|
|
5
|
+
import type {
|
|
6
|
+
FeaturedCollectionFragment,
|
|
7
|
+
RecommendedProductsQuery,
|
|
8
|
+
} from 'storefrontapi.generated';
|
|
9
|
+
import {ProductItem} from '~/components/ProductItem';
|
|
10
|
+
|
|
11
|
+
export const meta: Route.MetaFunction = () => {
|
|
12
|
+
return [{title: 'Hydrogen | Home'}];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function loader(args: Route.LoaderArgs) {
|
|
16
|
+
// Start fetching non-critical data without blocking time to first byte
|
|
17
|
+
const deferredData = loadDeferredData(args);
|
|
18
|
+
|
|
19
|
+
// Await the critical data required to render initial state of the page
|
|
20
|
+
const criticalData = await loadCriticalData(args);
|
|
21
|
+
|
|
22
|
+
return {...deferredData, ...criticalData};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load data necessary for rendering content above the fold. This is the critical data
|
|
27
|
+
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
|
|
28
|
+
*/
|
|
29
|
+
async function loadCriticalData({context}: Route.LoaderArgs) {
|
|
30
|
+
const [{collections}] = await Promise.all([
|
|
31
|
+
context.storefront.query(FEATURED_COLLECTION_QUERY),
|
|
32
|
+
// Add other queries here, so that they are loaded in parallel
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
featuredCollection: collections.nodes[0],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load data for rendering content below the fold. This data is deferred and will be
|
|
42
|
+
* fetched after the initial page load. If it's unavailable, the page should still 200.
|
|
43
|
+
* Make sure to not throw any errors here, as it will cause the page to 500.
|
|
44
|
+
*/
|
|
45
|
+
function loadDeferredData({context}: Route.LoaderArgs) {
|
|
46
|
+
const recommendedProducts = context.storefront
|
|
47
|
+
.query(RECOMMENDED_PRODUCTS_QUERY)
|
|
48
|
+
.catch((error: Error) => {
|
|
49
|
+
// Log query errors, but don't throw them so the page can still render
|
|
50
|
+
console.error(error);
|
|
51
|
+
return null;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
recommendedProducts,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function Homepage() {
|
|
60
|
+
const data = useLoaderData<typeof loader>();
|
|
61
|
+
return (
|
|
62
|
+
<div className="home">
|
|
63
|
+
<FeaturedCollection collection={data.featuredCollection} />
|
|
64
|
+
<RecommendedProducts products={data.recommendedProducts} />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function FeaturedCollection({
|
|
70
|
+
collection,
|
|
71
|
+
}: {
|
|
72
|
+
collection: FeaturedCollectionFragment;
|
|
73
|
+
}) {
|
|
74
|
+
if (!collection) return null;
|
|
75
|
+
const image = collection?.image;
|
|
76
|
+
return (
|
|
77
|
+
<Link
|
|
78
|
+
className="featured-collection"
|
|
79
|
+
to={`/collections/${collection.handle}`}
|
|
80
|
+
>
|
|
81
|
+
{image && (
|
|
82
|
+
<div className="featured-collection-image">
|
|
83
|
+
<Image data={image} sizes="100vw" />
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
<h1>{collection.title}</h1>
|
|
87
|
+
</Link>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function RecommendedProducts({
|
|
92
|
+
products,
|
|
93
|
+
}: {
|
|
94
|
+
products: Promise<RecommendedProductsQuery | null>;
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<div className="recommended-products">
|
|
98
|
+
<h2>Recommended Products</h2>
|
|
99
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
100
|
+
<Await resolve={products}>
|
|
101
|
+
{(response) => (
|
|
102
|
+
<div className="recommended-products-grid">
|
|
103
|
+
{response
|
|
104
|
+
? response.products.nodes.map((product) => (
|
|
105
|
+
<ProductItem key={product.id} product={product} />
|
|
106
|
+
))
|
|
107
|
+
: null}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</Await>
|
|
111
|
+
</Suspense>
|
|
112
|
+
<br />
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const FEATURED_COLLECTION_QUERY = `#graphql
|
|
118
|
+
fragment FeaturedCollection on Collection {
|
|
119
|
+
id
|
|
120
|
+
title
|
|
121
|
+
image {
|
|
122
|
+
id
|
|
123
|
+
url
|
|
124
|
+
altText
|
|
125
|
+
width
|
|
126
|
+
height
|
|
127
|
+
}
|
|
128
|
+
handle
|
|
129
|
+
}
|
|
130
|
+
query FeaturedCollection($country: CountryCode, $language: LanguageCode)
|
|
131
|
+
@inContext(country: $country, language: $language) {
|
|
132
|
+
collections(first: 1, sortKey: UPDATED_AT, reverse: true) {
|
|
133
|
+
nodes {
|
|
134
|
+
...FeaturedCollection
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
` as const;
|
|
139
|
+
|
|
140
|
+
const RECOMMENDED_PRODUCTS_QUERY = `#graphql
|
|
141
|
+
fragment RecommendedProduct on Product {
|
|
142
|
+
id
|
|
143
|
+
title
|
|
144
|
+
handle
|
|
145
|
+
priceRange {
|
|
146
|
+
minVariantPrice {
|
|
147
|
+
amount
|
|
148
|
+
currencyCode
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
featuredImage {
|
|
152
|
+
id
|
|
153
|
+
url
|
|
154
|
+
altText
|
|
155
|
+
width
|
|
156
|
+
height
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
|
|
160
|
+
@inContext(country: $country, language: $language) {
|
|
161
|
+
products(first: 4, sortKey: UPDATED_AT, reverse: true) {
|
|
162
|
+
nodes {
|
|
163
|
+
...RecommendedProduct
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
` as const;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {redirect} from 'react-router';
|
|
2
|
+
import type {Route} from './+types/account.$';
|
|
3
|
+
|
|
4
|
+
// fallback wild card for all unauthenticated routes in account section
|
|
5
|
+
export async function loader({context}: Route.LoaderArgs) {
|
|
6
|
+
context.customerAccount.handleAuthStatus();
|
|
7
|
+
|
|
8
|
+
return redirect('/account');
|
|
9
|
+
}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types';
|
|
2
|
+
import type {
|
|
3
|
+
AddressFragment,
|
|
4
|
+
CustomerFragment,
|
|
5
|
+
} from 'customer-accountapi.generated';
|
|
6
|
+
import {
|
|
7
|
+
data,
|
|
8
|
+
Form,
|
|
9
|
+
useActionData,
|
|
10
|
+
useNavigation,
|
|
11
|
+
useOutletContext,
|
|
12
|
+
type Fetcher,
|
|
13
|
+
} from 'react-router';
|
|
14
|
+
import type {Route} from './+types/account.addresses';
|
|
15
|
+
import {
|
|
16
|
+
UPDATE_ADDRESS_MUTATION,
|
|
17
|
+
DELETE_ADDRESS_MUTATION,
|
|
18
|
+
CREATE_ADDRESS_MUTATION,
|
|
19
|
+
} from '~/graphql/customer-account/CustomerAddressMutations';
|
|
20
|
+
|
|
21
|
+
export type ActionResponse = {
|
|
22
|
+
addressId?: string | null;
|
|
23
|
+
createdAddress?: AddressFragment;
|
|
24
|
+
defaultAddress?: string | null;
|
|
25
|
+
deletedAddress?: string | null;
|
|
26
|
+
error: Record<AddressFragment['id'], string> | null;
|
|
27
|
+
updatedAddress?: AddressFragment;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const meta: Route.MetaFunction = () => {
|
|
31
|
+
return [{title: 'Addresses'}];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function loader({context}: Route.LoaderArgs) {
|
|
35
|
+
context.customerAccount.handleAuthStatus();
|
|
36
|
+
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function action({request, context}: Route.ActionArgs) {
|
|
41
|
+
const {customerAccount} = context;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const form = await request.formData();
|
|
45
|
+
|
|
46
|
+
const addressId = form.has('addressId')
|
|
47
|
+
? String(form.get('addressId'))
|
|
48
|
+
: null;
|
|
49
|
+
if (!addressId) {
|
|
50
|
+
throw new Error('You must provide an address id.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// this will ensure redirecting to login never happen for mutatation
|
|
54
|
+
const isLoggedIn = await customerAccount.isLoggedIn();
|
|
55
|
+
if (!isLoggedIn) {
|
|
56
|
+
return data(
|
|
57
|
+
{error: {[addressId]: 'Unauthorized'}},
|
|
58
|
+
{
|
|
59
|
+
status: 401,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultAddress = form.has('defaultAddress')
|
|
65
|
+
? String(form.get('defaultAddress')) === 'on'
|
|
66
|
+
: false;
|
|
67
|
+
const address: CustomerAddressInput = {};
|
|
68
|
+
const keys: (keyof CustomerAddressInput)[] = [
|
|
69
|
+
'address1',
|
|
70
|
+
'address2',
|
|
71
|
+
'city',
|
|
72
|
+
'company',
|
|
73
|
+
'territoryCode',
|
|
74
|
+
'firstName',
|
|
75
|
+
'lastName',
|
|
76
|
+
'phoneNumber',
|
|
77
|
+
'zoneCode',
|
|
78
|
+
'zip',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const key of keys) {
|
|
82
|
+
const value = form.get(key);
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
address[key] = value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (request.method) {
|
|
89
|
+
case 'POST': {
|
|
90
|
+
// handle new address creation
|
|
91
|
+
try {
|
|
92
|
+
const {data, errors} = await customerAccount.mutate(
|
|
93
|
+
CREATE_ADDRESS_MUTATION,
|
|
94
|
+
{
|
|
95
|
+
variables: {
|
|
96
|
+
address,
|
|
97
|
+
defaultAddress,
|
|
98
|
+
language: customerAccount.i18n.language,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (errors?.length) {
|
|
104
|
+
throw new Error(errors[0].message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (data?.customerAddressCreate?.userErrors?.length) {
|
|
108
|
+
throw new Error(data?.customerAddressCreate?.userErrors[0].message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!data?.customerAddressCreate?.customerAddress) {
|
|
112
|
+
throw new Error('Customer address create failed.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
error: null,
|
|
117
|
+
createdAddress: data?.customerAddressCreate?.customerAddress,
|
|
118
|
+
defaultAddress,
|
|
119
|
+
};
|
|
120
|
+
} catch (error: unknown) {
|
|
121
|
+
if (error instanceof Error) {
|
|
122
|
+
return data(
|
|
123
|
+
{error: {[addressId]: error.message}},
|
|
124
|
+
{
|
|
125
|
+
status: 400,
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return data(
|
|
130
|
+
{error: {[addressId]: error}},
|
|
131
|
+
{
|
|
132
|
+
status: 400,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'PUT': {
|
|
139
|
+
// handle address updates
|
|
140
|
+
try {
|
|
141
|
+
const {data, errors} = await customerAccount.mutate(
|
|
142
|
+
UPDATE_ADDRESS_MUTATION,
|
|
143
|
+
{
|
|
144
|
+
variables: {
|
|
145
|
+
address,
|
|
146
|
+
addressId: decodeURIComponent(addressId),
|
|
147
|
+
defaultAddress,
|
|
148
|
+
language: customerAccount.i18n.language,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (errors?.length) {
|
|
154
|
+
throw new Error(errors[0].message);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (data?.customerAddressUpdate?.userErrors?.length) {
|
|
158
|
+
throw new Error(data?.customerAddressUpdate?.userErrors[0].message);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!data?.customerAddressUpdate?.customerAddress) {
|
|
162
|
+
throw new Error('Customer address update failed.');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
error: null,
|
|
167
|
+
updatedAddress: address,
|
|
168
|
+
defaultAddress,
|
|
169
|
+
};
|
|
170
|
+
} catch (error: unknown) {
|
|
171
|
+
if (error instanceof Error) {
|
|
172
|
+
return data(
|
|
173
|
+
{error: {[addressId]: error.message}},
|
|
174
|
+
{
|
|
175
|
+
status: 400,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return data(
|
|
180
|
+
{error: {[addressId]: error}},
|
|
181
|
+
{
|
|
182
|
+
status: 400,
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'DELETE': {
|
|
189
|
+
// handles address deletion
|
|
190
|
+
try {
|
|
191
|
+
const {data, errors} = await customerAccount.mutate(
|
|
192
|
+
DELETE_ADDRESS_MUTATION,
|
|
193
|
+
{
|
|
194
|
+
variables: {
|
|
195
|
+
addressId: decodeURIComponent(addressId),
|
|
196
|
+
language: customerAccount.i18n.language,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (errors?.length) {
|
|
202
|
+
throw new Error(errors[0].message);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (data?.customerAddressDelete?.userErrors?.length) {
|
|
206
|
+
throw new Error(data?.customerAddressDelete?.userErrors[0].message);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!data?.customerAddressDelete?.deletedAddressId) {
|
|
210
|
+
throw new Error('Customer address delete failed.');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {error: null, deletedAddress: addressId};
|
|
214
|
+
} catch (error: unknown) {
|
|
215
|
+
if (error instanceof Error) {
|
|
216
|
+
return data(
|
|
217
|
+
{error: {[addressId]: error.message}},
|
|
218
|
+
{
|
|
219
|
+
status: 400,
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return data(
|
|
224
|
+
{error: {[addressId]: error}},
|
|
225
|
+
{
|
|
226
|
+
status: 400,
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
default: {
|
|
233
|
+
return data(
|
|
234
|
+
{error: {[addressId]: 'Method not allowed'}},
|
|
235
|
+
{
|
|
236
|
+
status: 405,
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (error: unknown) {
|
|
242
|
+
if (error instanceof Error) {
|
|
243
|
+
return data(
|
|
244
|
+
{error: error.message},
|
|
245
|
+
{
|
|
246
|
+
status: 400,
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return data(
|
|
251
|
+
{error},
|
|
252
|
+
{
|
|
253
|
+
status: 400,
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default function Addresses() {
|
|
260
|
+
const {customer} = useOutletContext<{customer: CustomerFragment}>();
|
|
261
|
+
const {defaultAddress, addresses} = customer;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="account-addresses">
|
|
265
|
+
<h2>Addresses</h2>
|
|
266
|
+
<br />
|
|
267
|
+
{!addresses.nodes.length ? (
|
|
268
|
+
<p>You have no addresses saved.</p>
|
|
269
|
+
) : (
|
|
270
|
+
<div>
|
|
271
|
+
<div>
|
|
272
|
+
<legend>Create address</legend>
|
|
273
|
+
<NewAddressForm />
|
|
274
|
+
</div>
|
|
275
|
+
<br />
|
|
276
|
+
<hr />
|
|
277
|
+
<br />
|
|
278
|
+
<ExistingAddresses
|
|
279
|
+
addresses={addresses}
|
|
280
|
+
defaultAddress={defaultAddress}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function NewAddressForm() {
|
|
289
|
+
const newAddress = {
|
|
290
|
+
address1: '',
|
|
291
|
+
address2: '',
|
|
292
|
+
city: '',
|
|
293
|
+
company: '',
|
|
294
|
+
territoryCode: '',
|
|
295
|
+
firstName: '',
|
|
296
|
+
id: 'new',
|
|
297
|
+
lastName: '',
|
|
298
|
+
phoneNumber: '',
|
|
299
|
+
zoneCode: '',
|
|
300
|
+
zip: '',
|
|
301
|
+
} as CustomerAddressInput;
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<AddressForm
|
|
305
|
+
addressId={'NEW_ADDRESS_ID'}
|
|
306
|
+
address={newAddress}
|
|
307
|
+
defaultAddress={null}
|
|
308
|
+
>
|
|
309
|
+
{({stateForMethod}) => (
|
|
310
|
+
<div>
|
|
311
|
+
<button
|
|
312
|
+
disabled={stateForMethod('POST') !== 'idle'}
|
|
313
|
+
formMethod="POST"
|
|
314
|
+
type="submit"
|
|
315
|
+
>
|
|
316
|
+
{stateForMethod('POST') !== 'idle' ? 'Creating' : 'Create'}
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
</AddressForm>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ExistingAddresses({
|
|
325
|
+
addresses,
|
|
326
|
+
defaultAddress,
|
|
327
|
+
}: Pick<CustomerFragment, 'addresses' | 'defaultAddress'>) {
|
|
328
|
+
return (
|
|
329
|
+
<div>
|
|
330
|
+
<legend>Existing addresses</legend>
|
|
331
|
+
{addresses.nodes.map((address) => (
|
|
332
|
+
<AddressForm
|
|
333
|
+
key={address.id}
|
|
334
|
+
addressId={address.id}
|
|
335
|
+
address={address}
|
|
336
|
+
defaultAddress={defaultAddress}
|
|
337
|
+
>
|
|
338
|
+
{({stateForMethod}) => (
|
|
339
|
+
<div>
|
|
340
|
+
<button
|
|
341
|
+
disabled={stateForMethod('PUT') !== 'idle'}
|
|
342
|
+
formMethod="PUT"
|
|
343
|
+
type="submit"
|
|
344
|
+
>
|
|
345
|
+
{stateForMethod('PUT') !== 'idle' ? 'Saving' : 'Save'}
|
|
346
|
+
</button>
|
|
347
|
+
<button
|
|
348
|
+
disabled={stateForMethod('DELETE') !== 'idle'}
|
|
349
|
+
formMethod="DELETE"
|
|
350
|
+
type="submit"
|
|
351
|
+
>
|
|
352
|
+
{stateForMethod('DELETE') !== 'idle' ? 'Deleting' : 'Delete'}
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
</AddressForm>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function AddressForm({
|
|
363
|
+
addressId,
|
|
364
|
+
address,
|
|
365
|
+
defaultAddress,
|
|
366
|
+
children,
|
|
367
|
+
}: {
|
|
368
|
+
addressId: AddressFragment['id'];
|
|
369
|
+
address: CustomerAddressInput;
|
|
370
|
+
defaultAddress: CustomerFragment['defaultAddress'];
|
|
371
|
+
children: (props: {
|
|
372
|
+
stateForMethod: (method: 'PUT' | 'POST' | 'DELETE') => Fetcher['state'];
|
|
373
|
+
}) => React.ReactNode;
|
|
374
|
+
}) {
|
|
375
|
+
const {state, formMethod} = useNavigation();
|
|
376
|
+
const action = useActionData<ActionResponse>();
|
|
377
|
+
const error = action?.error?.[addressId];
|
|
378
|
+
const isDefaultAddress = defaultAddress?.id === addressId;
|
|
379
|
+
return (
|
|
380
|
+
<Form id={addressId}>
|
|
381
|
+
<fieldset>
|
|
382
|
+
<input type="hidden" name="addressId" defaultValue={addressId} />
|
|
383
|
+
<label htmlFor="firstName">First name*</label>
|
|
384
|
+
<input
|
|
385
|
+
aria-label="First name"
|
|
386
|
+
autoComplete="given-name"
|
|
387
|
+
defaultValue={address?.firstName ?? ''}
|
|
388
|
+
id="firstName"
|
|
389
|
+
name="firstName"
|
|
390
|
+
placeholder="First name"
|
|
391
|
+
required
|
|
392
|
+
type="text"
|
|
393
|
+
/>
|
|
394
|
+
<label htmlFor="lastName">Last name*</label>
|
|
395
|
+
<input
|
|
396
|
+
aria-label="Last name"
|
|
397
|
+
autoComplete="family-name"
|
|
398
|
+
defaultValue={address?.lastName ?? ''}
|
|
399
|
+
id="lastName"
|
|
400
|
+
name="lastName"
|
|
401
|
+
placeholder="Last name"
|
|
402
|
+
required
|
|
403
|
+
type="text"
|
|
404
|
+
/>
|
|
405
|
+
<label htmlFor="company">Company</label>
|
|
406
|
+
<input
|
|
407
|
+
aria-label="Company"
|
|
408
|
+
autoComplete="organization"
|
|
409
|
+
defaultValue={address?.company ?? ''}
|
|
410
|
+
id="company"
|
|
411
|
+
name="company"
|
|
412
|
+
placeholder="Company"
|
|
413
|
+
type="text"
|
|
414
|
+
/>
|
|
415
|
+
<label htmlFor="address1">Address line*</label>
|
|
416
|
+
<input
|
|
417
|
+
aria-label="Address line 1"
|
|
418
|
+
autoComplete="address-line1"
|
|
419
|
+
defaultValue={address?.address1 ?? ''}
|
|
420
|
+
id="address1"
|
|
421
|
+
name="address1"
|
|
422
|
+
placeholder="Address line 1*"
|
|
423
|
+
required
|
|
424
|
+
type="text"
|
|
425
|
+
/>
|
|
426
|
+
<label htmlFor="address2">Address line 2</label>
|
|
427
|
+
<input
|
|
428
|
+
aria-label="Address line 2"
|
|
429
|
+
autoComplete="address-line2"
|
|
430
|
+
defaultValue={address?.address2 ?? ''}
|
|
431
|
+
id="address2"
|
|
432
|
+
name="address2"
|
|
433
|
+
placeholder="Address line 2"
|
|
434
|
+
type="text"
|
|
435
|
+
/>
|
|
436
|
+
<label htmlFor="city">City*</label>
|
|
437
|
+
<input
|
|
438
|
+
aria-label="City"
|
|
439
|
+
autoComplete="address-level2"
|
|
440
|
+
defaultValue={address?.city ?? ''}
|
|
441
|
+
id="city"
|
|
442
|
+
name="city"
|
|
443
|
+
placeholder="City"
|
|
444
|
+
required
|
|
445
|
+
type="text"
|
|
446
|
+
/>
|
|
447
|
+
<label htmlFor="zoneCode">State / Province*</label>
|
|
448
|
+
<input
|
|
449
|
+
aria-label="State/Province"
|
|
450
|
+
autoComplete="address-level1"
|
|
451
|
+
defaultValue={address?.zoneCode ?? ''}
|
|
452
|
+
id="zoneCode"
|
|
453
|
+
name="zoneCode"
|
|
454
|
+
placeholder="State / Province"
|
|
455
|
+
required
|
|
456
|
+
type="text"
|
|
457
|
+
/>
|
|
458
|
+
<label htmlFor="zip">Zip / Postal Code*</label>
|
|
459
|
+
<input
|
|
460
|
+
aria-label="Zip"
|
|
461
|
+
autoComplete="postal-code"
|
|
462
|
+
defaultValue={address?.zip ?? ''}
|
|
463
|
+
id="zip"
|
|
464
|
+
name="zip"
|
|
465
|
+
placeholder="Zip / Postal Code"
|
|
466
|
+
required
|
|
467
|
+
type="text"
|
|
468
|
+
/>
|
|
469
|
+
<label htmlFor="territoryCode">Country Code*</label>
|
|
470
|
+
<input
|
|
471
|
+
aria-label="territoryCode"
|
|
472
|
+
autoComplete="country"
|
|
473
|
+
defaultValue={address?.territoryCode ?? ''}
|
|
474
|
+
id="territoryCode"
|
|
475
|
+
name="territoryCode"
|
|
476
|
+
placeholder="Country"
|
|
477
|
+
required
|
|
478
|
+
type="text"
|
|
479
|
+
maxLength={2}
|
|
480
|
+
/>
|
|
481
|
+
<label htmlFor="phoneNumber">Phone</label>
|
|
482
|
+
<input
|
|
483
|
+
aria-label="Phone Number"
|
|
484
|
+
autoComplete="tel"
|
|
485
|
+
defaultValue={address?.phoneNumber ?? ''}
|
|
486
|
+
id="phoneNumber"
|
|
487
|
+
name="phoneNumber"
|
|
488
|
+
placeholder="+16135551111"
|
|
489
|
+
pattern="^\+?[1-9]\d{3,14}$"
|
|
490
|
+
type="tel"
|
|
491
|
+
/>
|
|
492
|
+
<div>
|
|
493
|
+
<input
|
|
494
|
+
defaultChecked={isDefaultAddress}
|
|
495
|
+
id="defaultAddress"
|
|
496
|
+
name="defaultAddress"
|
|
497
|
+
type="checkbox"
|
|
498
|
+
/>
|
|
499
|
+
<label htmlFor="defaultAddress">Set as default address</label>
|
|
500
|
+
</div>
|
|
501
|
+
{error ? (
|
|
502
|
+
<p>
|
|
503
|
+
<mark>
|
|
504
|
+
<small>{error}</small>
|
|
505
|
+
</mark>
|
|
506
|
+
</p>
|
|
507
|
+
) : (
|
|
508
|
+
<br />
|
|
509
|
+
)}
|
|
510
|
+
{children({
|
|
511
|
+
stateForMethod: (method) => (formMethod === method ? state : 'idle'),
|
|
512
|
+
})}
|
|
513
|
+
</fieldset>
|
|
514
|
+
</Form>
|
|
515
|
+
);
|
|
516
|
+
}
|