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.
Files changed (118) hide show
  1. package/README.md +212 -0
  2. package/dist/commands/add.d.ts +7 -0
  3. package/dist/commands/add.d.ts.map +1 -0
  4. package/dist/commands/add.js +123 -0
  5. package/dist/commands/add.js.map +1 -0
  6. package/dist/commands/create.d.ts +8 -0
  7. package/dist/commands/create.d.ts.map +1 -0
  8. package/dist/commands/create.js +160 -0
  9. package/dist/commands/create.js.map +1 -0
  10. package/dist/commands/setup-mcp.d.ts +7 -0
  11. package/dist/commands/setup-mcp.d.ts.map +1 -0
  12. package/dist/commands/setup-mcp.js +179 -0
  13. package/dist/commands/setup-mcp.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +50 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lib/generators.d.ts +6 -0
  19. package/dist/lib/generators.d.ts.map +1 -0
  20. package/dist/lib/generators.js +470 -0
  21. package/dist/lib/generators.js.map +1 -0
  22. package/dist/lib/utils.d.ts +17 -0
  23. package/dist/lib/utils.d.ts.map +1 -0
  24. package/dist/lib/utils.js +101 -0
  25. package/dist/lib/utils.js.map +1 -0
  26. package/package.json +54 -0
  27. package/templates/starter/.env.example +21 -0
  28. package/templates/starter/.graphqlrc.ts +27 -0
  29. package/templates/starter/README.md +117 -0
  30. package/templates/starter/app/assets/favicon.svg +28 -0
  31. package/templates/starter/app/components/AddToCartButton.tsx +102 -0
  32. package/templates/starter/app/components/Aside.tsx +136 -0
  33. package/templates/starter/app/components/CartLineItem.tsx +229 -0
  34. package/templates/starter/app/components/CartMain.tsx +131 -0
  35. package/templates/starter/app/components/CartSummary.tsx +315 -0
  36. package/templates/starter/app/components/CollectionFilters.tsx +330 -0
  37. package/templates/starter/app/components/CollectionGrid.tsx +141 -0
  38. package/templates/starter/app/components/Footer.tsx +218 -0
  39. package/templates/starter/app/components/Header.tsx +296 -0
  40. package/templates/starter/app/components/PageLayout.tsx +174 -0
  41. package/templates/starter/app/components/PaginatedResourceSection.tsx +41 -0
  42. package/templates/starter/app/components/ProductCard.tsx +151 -0
  43. package/templates/starter/app/components/ProductForm.tsx +156 -0
  44. package/templates/starter/app/components/ProductGallery.tsx +164 -0
  45. package/templates/starter/app/components/ProductGrid.tsx +64 -0
  46. package/templates/starter/app/components/ProductImage.tsx +23 -0
  47. package/templates/starter/app/components/ProductItem.tsx +44 -0
  48. package/templates/starter/app/components/ProductPrice.tsx +97 -0
  49. package/templates/starter/app/components/SearchDialog.tsx +599 -0
  50. package/templates/starter/app/components/SearchForm.tsx +68 -0
  51. package/templates/starter/app/components/SearchFormPredictive.tsx +76 -0
  52. package/templates/starter/app/components/SearchResults.tsx +161 -0
  53. package/templates/starter/app/components/SearchResultsPredictive.tsx +461 -0
  54. package/templates/starter/app/entry.client.tsx +21 -0
  55. package/templates/starter/app/entry.server.tsx +53 -0
  56. package/templates/starter/app/graphql/customer-account/CustomerAddressMutations.ts +64 -0
  57. package/templates/starter/app/graphql/customer-account/CustomerDetailsQuery.ts +40 -0
  58. package/templates/starter/app/graphql/customer-account/CustomerOrderQuery.ts +90 -0
  59. package/templates/starter/app/graphql/customer-account/CustomerOrdersQuery.ts +63 -0
  60. package/templates/starter/app/graphql/customer-account/CustomerUpdateMutation.ts +25 -0
  61. package/templates/starter/app/lib/context.ts +60 -0
  62. package/templates/starter/app/lib/fragments.ts +234 -0
  63. package/templates/starter/app/lib/orderFilters.ts +90 -0
  64. package/templates/starter/app/lib/redirect.ts +23 -0
  65. package/templates/starter/app/lib/search.ts +79 -0
  66. package/templates/starter/app/lib/session.ts +72 -0
  67. package/templates/starter/app/lib/variants.ts +46 -0
  68. package/templates/starter/app/root.tsx +209 -0
  69. package/templates/starter/app/routes/$.tsx +11 -0
  70. package/templates/starter/app/routes/[robots.txt].tsx +117 -0
  71. package/templates/starter/app/routes/[sitemap.xml].tsx +16 -0
  72. package/templates/starter/app/routes/_index.tsx +167 -0
  73. package/templates/starter/app/routes/account.$.tsx +9 -0
  74. package/templates/starter/app/routes/account._index.tsx +5 -0
  75. package/templates/starter/app/routes/account.addresses.tsx +516 -0
  76. package/templates/starter/app/routes/account.orders.$id.tsx +222 -0
  77. package/templates/starter/app/routes/account.orders._index.tsx +222 -0
  78. package/templates/starter/app/routes/account.profile.tsx +133 -0
  79. package/templates/starter/app/routes/account.tsx +97 -0
  80. package/templates/starter/app/routes/account_.authorize.tsx +5 -0
  81. package/templates/starter/app/routes/account_.login.tsx +7 -0
  82. package/templates/starter/app/routes/account_.logout.tsx +11 -0
  83. package/templates/starter/app/routes/api.$version.[graphql.json].tsx +14 -0
  84. package/templates/starter/app/routes/blogs.$blogHandle.$articleHandle.tsx +129 -0
  85. package/templates/starter/app/routes/blogs.$blogHandle._index.tsx +175 -0
  86. package/templates/starter/app/routes/blogs._index.tsx +109 -0
  87. package/templates/starter/app/routes/cart.$lines.tsx +70 -0
  88. package/templates/starter/app/routes/cart.tsx +117 -0
  89. package/templates/starter/app/routes/collections.$handle.tsx +161 -0
  90. package/templates/starter/app/routes/collections._index.tsx +133 -0
  91. package/templates/starter/app/routes/collections.all.tsx +122 -0
  92. package/templates/starter/app/routes/discount.$code.tsx +48 -0
  93. package/templates/starter/app/routes/pages.$handle.tsx +88 -0
  94. package/templates/starter/app/routes/policies.$handle.tsx +93 -0
  95. package/templates/starter/app/routes/policies._index.tsx +69 -0
  96. package/templates/starter/app/routes/products.$handle.tsx +232 -0
  97. package/templates/starter/app/routes/search.tsx +426 -0
  98. package/templates/starter/app/routes/sitemap.$type.$page[.xml].tsx +23 -0
  99. package/templates/starter/app/routes.ts +9 -0
  100. package/templates/starter/app/styles/app.css +574 -0
  101. package/templates/starter/app/styles/reset.css +139 -0
  102. package/templates/starter/app/styles/tailwind.css +116 -0
  103. package/templates/starter/customer-accountapi.generated.d.ts +543 -0
  104. package/templates/starter/env.d.ts +7 -0
  105. package/templates/starter/eslint.config.js +247 -0
  106. package/templates/starter/guides/predictiveSearch/predictiveSearch.jpg +0 -0
  107. package/templates/starter/guides/predictiveSearch/predictiveSearch.md +394 -0
  108. package/templates/starter/guides/search/search.jpg +0 -0
  109. package/templates/starter/guides/search/search.md +335 -0
  110. package/templates/starter/package.json +71 -0
  111. package/templates/starter/postcss.config.js +6 -0
  112. package/templates/starter/public/.gitkeep +0 -0
  113. package/templates/starter/react-router.config.ts +13 -0
  114. package/templates/starter/server.ts +59 -0
  115. package/templates/starter/storefrontapi.generated.d.ts +1264 -0
  116. package/templates/starter/tailwind.config.js +83 -0
  117. package/templates/starter/tsconfig.json +67 -0
  118. package/templates/starter/vite.config.ts +32 -0
@@ -0,0 +1,222 @@
1
+ import {redirect, useLoaderData} from 'react-router';
2
+ import type {Route} from './+types/account.orders.$id';
3
+ import {Money, Image} from '@shopify/hydrogen';
4
+ import type {
5
+ OrderLineItemFullFragment,
6
+ OrderQuery,
7
+ } from 'customer-accountapi.generated';
8
+ import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery';
9
+
10
+ export const meta: Route.MetaFunction = ({data}) => {
11
+ return [{title: `Order ${data?.order?.name}`}];
12
+ };
13
+
14
+ export async function loader({params, context}: Route.LoaderArgs) {
15
+ const {customerAccount} = context;
16
+ if (!params.id) {
17
+ return redirect('/account/orders');
18
+ }
19
+
20
+ const orderId = atob(params.id);
21
+ const {data, errors}: {data: OrderQuery; errors?: Array<{message: string}>} =
22
+ await customerAccount.query(CUSTOMER_ORDER_QUERY, {
23
+ variables: {
24
+ orderId,
25
+ language: customerAccount.i18n.language,
26
+ },
27
+ });
28
+
29
+ if (errors?.length || !data?.order) {
30
+ throw new Error('Order not found');
31
+ }
32
+
33
+ const {order} = data;
34
+
35
+ // Extract line items directly from nodes array
36
+ const lineItems = order.lineItems.nodes;
37
+
38
+ // Extract discount applications directly from nodes array
39
+ const discountApplications = order.discountApplications.nodes;
40
+
41
+ // Get fulfillment status from first fulfillment node
42
+ const fulfillmentStatus = order.fulfillments.nodes[0]?.status ?? 'N/A';
43
+
44
+ // Get first discount value with proper type checking
45
+ const firstDiscount = discountApplications[0]?.value;
46
+
47
+ // Type guard for MoneyV2 discount
48
+ const discountValue =
49
+ firstDiscount?.__typename === 'MoneyV2'
50
+ ? (firstDiscount as Extract<
51
+ typeof firstDiscount,
52
+ {__typename: 'MoneyV2'}
53
+ >)
54
+ : null;
55
+
56
+ // Type guard for percentage discount
57
+ const discountPercentage =
58
+ firstDiscount?.__typename === 'PricingPercentageValue'
59
+ ? (
60
+ firstDiscount as Extract<
61
+ typeof firstDiscount,
62
+ {__typename: 'PricingPercentageValue'}
63
+ >
64
+ ).percentage
65
+ : null;
66
+
67
+ return {
68
+ order,
69
+ lineItems,
70
+ discountValue,
71
+ discountPercentage,
72
+ fulfillmentStatus,
73
+ };
74
+ }
75
+
76
+ export default function OrderRoute() {
77
+ const {
78
+ order,
79
+ lineItems,
80
+ discountValue,
81
+ discountPercentage,
82
+ fulfillmentStatus,
83
+ } = useLoaderData<typeof loader>();
84
+ return (
85
+ <div className="account-order">
86
+ <h2>Order {order.name}</h2>
87
+ <p>Placed on {new Date(order.processedAt!).toDateString()}</p>
88
+ {order.confirmationNumber && (
89
+ <p>Confirmation: {order.confirmationNumber}</p>
90
+ )}
91
+ <br />
92
+ <div>
93
+ <table>
94
+ <thead>
95
+ <tr>
96
+ <th scope="col">Product</th>
97
+ <th scope="col">Price</th>
98
+ <th scope="col">Quantity</th>
99
+ <th scope="col">Total</th>
100
+ </tr>
101
+ </thead>
102
+ <tbody>
103
+ {lineItems.map((lineItem, lineItemIndex) => (
104
+ // eslint-disable-next-line react/no-array-index-key
105
+ <OrderLineRow key={lineItemIndex} lineItem={lineItem} />
106
+ ))}
107
+ </tbody>
108
+ <tfoot>
109
+ {((discountValue && discountValue.amount) ||
110
+ discountPercentage) && (
111
+ <tr>
112
+ <th scope="row" colSpan={3}>
113
+ <p>Discounts</p>
114
+ </th>
115
+ <th scope="row">
116
+ <p>Discounts</p>
117
+ </th>
118
+ <td>
119
+ {discountPercentage ? (
120
+ <span>-{discountPercentage}% OFF</span>
121
+ ) : (
122
+ discountValue && <Money data={discountValue!} />
123
+ )}
124
+ </td>
125
+ </tr>
126
+ )}
127
+ <tr>
128
+ <th scope="row" colSpan={3}>
129
+ <p>Subtotal</p>
130
+ </th>
131
+ <th scope="row">
132
+ <p>Subtotal</p>
133
+ </th>
134
+ <td>
135
+ <Money data={order.subtotal!} />
136
+ </td>
137
+ </tr>
138
+ <tr>
139
+ <th scope="row" colSpan={3}>
140
+ Tax
141
+ </th>
142
+ <th scope="row">
143
+ <p>Tax</p>
144
+ </th>
145
+ <td>
146
+ <Money data={order.totalTax!} />
147
+ </td>
148
+ </tr>
149
+ <tr>
150
+ <th scope="row" colSpan={3}>
151
+ Total
152
+ </th>
153
+ <th scope="row">
154
+ <p>Total</p>
155
+ </th>
156
+ <td>
157
+ <Money data={order.totalPrice!} />
158
+ </td>
159
+ </tr>
160
+ </tfoot>
161
+ </table>
162
+ <div>
163
+ <h3>Shipping Address</h3>
164
+ {order?.shippingAddress ? (
165
+ <address>
166
+ <p>{order.shippingAddress.name}</p>
167
+ {order.shippingAddress.formatted ? (
168
+ <p>{order.shippingAddress.formatted}</p>
169
+ ) : (
170
+ ''
171
+ )}
172
+ {order.shippingAddress.formattedArea ? (
173
+ <p>{order.shippingAddress.formattedArea}</p>
174
+ ) : (
175
+ ''
176
+ )}
177
+ </address>
178
+ ) : (
179
+ <p>No shipping address defined</p>
180
+ )}
181
+ <h3>Status</h3>
182
+ <div>
183
+ <p>{fulfillmentStatus}</p>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ <br />
188
+ <p>
189
+ <a target="_blank" href={order.statusPageUrl} rel="noreferrer">
190
+ View Order Status →
191
+ </a>
192
+ </p>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) {
198
+ return (
199
+ <tr key={lineItem.id}>
200
+ <td>
201
+ <div>
202
+ {lineItem?.image && (
203
+ <div>
204
+ <Image data={lineItem.image} width={96} height={96} />
205
+ </div>
206
+ )}
207
+ <div>
208
+ <p>{lineItem.title}</p>
209
+ <small>{lineItem.variantTitle}</small>
210
+ </div>
211
+ </div>
212
+ </td>
213
+ <td>
214
+ <Money data={lineItem.price!} />
215
+ </td>
216
+ <td>{lineItem.quantity}</td>
217
+ <td>
218
+ <Money data={lineItem.totalDiscount!} />
219
+ </td>
220
+ </tr>
221
+ );
222
+ }
@@ -0,0 +1,222 @@
1
+ import {
2
+ Link,
3
+ useLoaderData,
4
+ useNavigation,
5
+ useSearchParams,
6
+ } from 'react-router';
7
+ import type {Route} from './+types/account.orders._index';
8
+ import {useRef} from 'react';
9
+ import {
10
+ Money,
11
+ getPaginationVariables,
12
+ flattenConnection,
13
+ } from '@shopify/hydrogen';
14
+ import {
15
+ buildOrderSearchQuery,
16
+ parseOrderFilters,
17
+ ORDER_FILTER_FIELDS,
18
+ type OrderFilterParams,
19
+ } from '~/lib/orderFilters';
20
+ import {CUSTOMER_ORDERS_QUERY} from '~/graphql/customer-account/CustomerOrdersQuery';
21
+ import type {
22
+ CustomerOrdersFragment,
23
+ OrderItemFragment,
24
+ } from 'customer-accountapi.generated';
25
+ import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
26
+
27
+ type OrdersLoaderData = {
28
+ customer: CustomerOrdersFragment;
29
+ filters: OrderFilterParams;
30
+ };
31
+
32
+ export const meta: Route.MetaFunction = () => {
33
+ return [{title: 'Orders'}];
34
+ };
35
+
36
+ export async function loader({request, context}: Route.LoaderArgs) {
37
+ const {customerAccount} = context;
38
+ const paginationVariables = getPaginationVariables(request, {
39
+ pageBy: 20,
40
+ });
41
+
42
+ const url = new URL(request.url);
43
+ const filters = parseOrderFilters(url.searchParams);
44
+ const query = buildOrderSearchQuery(filters);
45
+
46
+ const {data, errors} = await customerAccount.query(CUSTOMER_ORDERS_QUERY, {
47
+ variables: {
48
+ ...paginationVariables,
49
+ query,
50
+ language: customerAccount.i18n.language,
51
+ },
52
+ });
53
+
54
+ if (errors?.length || !data?.customer) {
55
+ throw Error('Customer orders not found');
56
+ }
57
+
58
+ return {customer: data.customer, filters};
59
+ }
60
+
61
+ export default function Orders() {
62
+ const {customer, filters} = useLoaderData<OrdersLoaderData>();
63
+ const {orders} = customer;
64
+
65
+ return (
66
+ <div className="orders">
67
+ <OrderSearchForm currentFilters={filters} />
68
+ <OrdersTable orders={orders} filters={filters} />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function OrdersTable({
74
+ orders,
75
+ filters,
76
+ }: {
77
+ orders: CustomerOrdersFragment['orders'];
78
+ filters: OrderFilterParams;
79
+ }) {
80
+ const hasFilters = !!(filters.name || filters.confirmationNumber);
81
+
82
+ return (
83
+ <div className="acccount-orders" aria-live="polite">
84
+ {orders?.nodes.length ? (
85
+ <PaginatedResourceSection connection={orders}>
86
+ {({node: order}) => <OrderItem key={order.id} order={order} />}
87
+ </PaginatedResourceSection>
88
+ ) : (
89
+ <EmptyOrders hasFilters={hasFilters} />
90
+ )}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ function EmptyOrders({hasFilters = false}: {hasFilters?: boolean}) {
96
+ return (
97
+ <div>
98
+ {hasFilters ? (
99
+ <>
100
+ <p>No orders found matching your search.</p>
101
+ <br />
102
+ <p>
103
+ <Link to="/account/orders">Clear filters →</Link>
104
+ </p>
105
+ </>
106
+ ) : (
107
+ <>
108
+ <p>You haven&apos;t placed any orders yet.</p>
109
+ <br />
110
+ <p>
111
+ <Link to="/collections">Start Shopping →</Link>
112
+ </p>
113
+ </>
114
+ )}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ function OrderSearchForm({
120
+ currentFilters,
121
+ }: {
122
+ currentFilters: OrderFilterParams;
123
+ }) {
124
+ const [searchParams, setSearchParams] = useSearchParams();
125
+ const navigation = useNavigation();
126
+ const isSearching =
127
+ navigation.state !== 'idle' &&
128
+ navigation.location?.pathname?.includes('orders');
129
+ const formRef = useRef<HTMLFormElement>(null);
130
+
131
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
132
+ event.preventDefault();
133
+ const formData = new FormData(event.currentTarget);
134
+ const params = new URLSearchParams();
135
+
136
+ const name = formData.get(ORDER_FILTER_FIELDS.NAME)?.toString().trim();
137
+ const confirmationNumber = formData
138
+ .get(ORDER_FILTER_FIELDS.CONFIRMATION_NUMBER)
139
+ ?.toString()
140
+ .trim();
141
+
142
+ if (name) params.set(ORDER_FILTER_FIELDS.NAME, name);
143
+ if (confirmationNumber)
144
+ params.set(ORDER_FILTER_FIELDS.CONFIRMATION_NUMBER, confirmationNumber);
145
+
146
+ setSearchParams(params);
147
+ };
148
+
149
+ const hasFilters = currentFilters.name || currentFilters.confirmationNumber;
150
+
151
+ return (
152
+ <form
153
+ ref={formRef}
154
+ onSubmit={handleSubmit}
155
+ className="order-search-form"
156
+ aria-label="Search orders"
157
+ >
158
+ <fieldset className="order-search-fieldset">
159
+ <legend className="order-search-legend">Filter Orders</legend>
160
+
161
+ <div className="order-search-inputs">
162
+ <input
163
+ type="search"
164
+ name={ORDER_FILTER_FIELDS.NAME}
165
+ placeholder="Order #"
166
+ aria-label="Order number"
167
+ defaultValue={currentFilters.name || ''}
168
+ className="order-search-input"
169
+ />
170
+ <input
171
+ type="search"
172
+ name={ORDER_FILTER_FIELDS.CONFIRMATION_NUMBER}
173
+ placeholder="Confirmation #"
174
+ aria-label="Confirmation number"
175
+ defaultValue={currentFilters.confirmationNumber || ''}
176
+ className="order-search-input"
177
+ />
178
+ </div>
179
+
180
+ <div className="order-search-buttons">
181
+ <button type="submit" disabled={isSearching}>
182
+ {isSearching ? 'Searching' : 'Search'}
183
+ </button>
184
+ {hasFilters && (
185
+ <button
186
+ type="button"
187
+ disabled={isSearching}
188
+ onClick={() => {
189
+ setSearchParams(new URLSearchParams());
190
+ formRef.current?.reset();
191
+ }}
192
+ >
193
+ Clear
194
+ </button>
195
+ )}
196
+ </div>
197
+ </fieldset>
198
+ </form>
199
+ );
200
+ }
201
+
202
+ function OrderItem({order}: {order: OrderItemFragment}) {
203
+ const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status;
204
+ return (
205
+ <>
206
+ <fieldset>
207
+ <Link to={`/account/orders/${btoa(order.id)}`}>
208
+ <strong>#{order.number}</strong>
209
+ </Link>
210
+ <p>{new Date(order.processedAt).toDateString()}</p>
211
+ {order.confirmationNumber && (
212
+ <p>Confirmation: {order.confirmationNumber}</p>
213
+ )}
214
+ <p>{order.financialStatus}</p>
215
+ {fulfillmentStatus && <p>{fulfillmentStatus}</p>}
216
+ <Money data={order.totalPrice} />
217
+ <Link to={`/account/orders/${btoa(order.id)}`}>View Order →</Link>
218
+ </fieldset>
219
+ <br />
220
+ </>
221
+ );
222
+ }
@@ -0,0 +1,133 @@
1
+ import type {CustomerFragment} from 'customer-accountapi.generated';
2
+ import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types';
3
+ import {CUSTOMER_UPDATE_MUTATION} from '~/graphql/customer-account/CustomerUpdateMutation';
4
+ import {
5
+ data,
6
+ Form,
7
+ useActionData,
8
+ useNavigation,
9
+ useOutletContext,
10
+ } from 'react-router';
11
+ import type {Route} from './+types/account.profile';
12
+
13
+ export type ActionResponse = {
14
+ error: string | null;
15
+ customer: CustomerFragment | null;
16
+ };
17
+
18
+ export const meta: Route.MetaFunction = () => {
19
+ return [{title: 'Profile'}];
20
+ };
21
+
22
+ export async function loader({context}: Route.LoaderArgs) {
23
+ context.customerAccount.handleAuthStatus();
24
+
25
+ return {};
26
+ }
27
+
28
+ export async function action({request, context}: Route.ActionArgs) {
29
+ const {customerAccount} = context;
30
+
31
+ if (request.method !== 'PUT') {
32
+ return data({error: 'Method not allowed'}, {status: 405});
33
+ }
34
+
35
+ const form = await request.formData();
36
+
37
+ try {
38
+ const customer: CustomerUpdateInput = {};
39
+ const validInputKeys = ['firstName', 'lastName'] as const;
40
+ for (const [key, value] of form.entries()) {
41
+ if (!validInputKeys.includes(key as any)) {
42
+ continue;
43
+ }
44
+ if (typeof value === 'string' && value.length) {
45
+ customer[key as (typeof validInputKeys)[number]] = value;
46
+ }
47
+ }
48
+
49
+ // update customer and possibly password
50
+ const {data, errors} = await customerAccount.mutate(
51
+ CUSTOMER_UPDATE_MUTATION,
52
+ {
53
+ variables: {
54
+ customer,
55
+ language: customerAccount.i18n.language,
56
+ },
57
+ },
58
+ );
59
+
60
+ if (errors?.length) {
61
+ throw new Error(errors[0].message);
62
+ }
63
+
64
+ if (!data?.customerUpdate?.customer) {
65
+ throw new Error('Customer profile update failed.');
66
+ }
67
+
68
+ return {
69
+ error: null,
70
+ customer: data?.customerUpdate?.customer,
71
+ };
72
+ } catch (error: any) {
73
+ return data(
74
+ {error: error.message, customer: null},
75
+ {
76
+ status: 400,
77
+ },
78
+ );
79
+ }
80
+ }
81
+
82
+ export default function AccountProfile() {
83
+ const account = useOutletContext<{customer: CustomerFragment}>();
84
+ const {state} = useNavigation();
85
+ const action = useActionData<ActionResponse>();
86
+ const customer = action?.customer ?? account?.customer;
87
+
88
+ return (
89
+ <div className="account-profile">
90
+ <h2>My profile</h2>
91
+ <br />
92
+ <Form method="PUT">
93
+ <legend>Personal information</legend>
94
+ <fieldset>
95
+ <label htmlFor="firstName">First name</label>
96
+ <input
97
+ id="firstName"
98
+ name="firstName"
99
+ type="text"
100
+ autoComplete="given-name"
101
+ placeholder="First name"
102
+ aria-label="First name"
103
+ defaultValue={customer.firstName ?? ''}
104
+ minLength={2}
105
+ />
106
+ <label htmlFor="lastName">Last name</label>
107
+ <input
108
+ id="lastName"
109
+ name="lastName"
110
+ type="text"
111
+ autoComplete="family-name"
112
+ placeholder="Last name"
113
+ aria-label="Last name"
114
+ defaultValue={customer.lastName ?? ''}
115
+ minLength={2}
116
+ />
117
+ </fieldset>
118
+ {action?.error ? (
119
+ <p>
120
+ <mark>
121
+ <small>{action.error}</small>
122
+ </mark>
123
+ </p>
124
+ ) : (
125
+ <br />
126
+ )}
127
+ <button type="submit" disabled={state !== 'idle'}>
128
+ {state !== 'idle' ? 'Updating' : 'Update'}
129
+ </button>
130
+ </Form>
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ data as remixData,
3
+ Form,
4
+ NavLink,
5
+ Outlet,
6
+ useLoaderData,
7
+ } from 'react-router';
8
+ import type {Route} from './+types/account';
9
+ import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery';
10
+
11
+ export function shouldRevalidate() {
12
+ return true;
13
+ }
14
+
15
+ export async function loader({context}: Route.LoaderArgs) {
16
+ const {customerAccount} = context;
17
+ const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, {
18
+ variables: {
19
+ language: customerAccount.i18n.language,
20
+ },
21
+ });
22
+
23
+ if (errors?.length || !data?.customer) {
24
+ throw new Error('Customer not found');
25
+ }
26
+
27
+ return remixData(
28
+ {customer: data.customer},
29
+ {
30
+ headers: {
31
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
32
+ },
33
+ },
34
+ );
35
+ }
36
+
37
+ export default function AccountLayout() {
38
+ const {customer} = useLoaderData<typeof loader>();
39
+
40
+ const heading = customer
41
+ ? customer.firstName
42
+ ? `Welcome, ${customer.firstName}`
43
+ : `Welcome to your account.`
44
+ : 'Account Details';
45
+
46
+ return (
47
+ <div className="account">
48
+ <h1>{heading}</h1>
49
+ <br />
50
+ <AccountMenu />
51
+ <br />
52
+ <br />
53
+ <Outlet context={{customer}} />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ function AccountMenu() {
59
+ function isActiveStyle({
60
+ isActive,
61
+ isPending,
62
+ }: {
63
+ isActive: boolean;
64
+ isPending: boolean;
65
+ }) {
66
+ return {
67
+ fontWeight: isActive ? 'bold' : undefined,
68
+ color: isPending ? 'grey' : 'black',
69
+ };
70
+ }
71
+
72
+ return (
73
+ <nav role="navigation">
74
+ <NavLink to="/account/orders" style={isActiveStyle}>
75
+ Orders &nbsp;
76
+ </NavLink>
77
+ &nbsp;|&nbsp;
78
+ <NavLink to="/account/profile" style={isActiveStyle}>
79
+ &nbsp; Profile &nbsp;
80
+ </NavLink>
81
+ &nbsp;|&nbsp;
82
+ <NavLink to="/account/addresses" style={isActiveStyle}>
83
+ &nbsp; Addresses &nbsp;
84
+ </NavLink>
85
+ &nbsp;|&nbsp;
86
+ <Logout />
87
+ </nav>
88
+ );
89
+ }
90
+
91
+ function Logout() {
92
+ return (
93
+ <Form className="account-logout" method="POST" action="/account/logout">
94
+ &nbsp;<button type="submit">Sign out</button>
95
+ </Form>
96
+ );
97
+ }
@@ -0,0 +1,5 @@
1
+ import type {Route} from './+types/account_.authorize';
2
+
3
+ export async function loader({context}: Route.LoaderArgs) {
4
+ return context.customerAccount.authorize();
5
+ }
@@ -0,0 +1,7 @@
1
+ import type {Route} from './+types/account_.login';
2
+
3
+ export async function loader({request, context}: Route.LoaderArgs) {
4
+ return context.customerAccount.login({
5
+ countryCode: context.storefront.i18n.country,
6
+ });
7
+ }
@@ -0,0 +1,11 @@
1
+ import {redirect} from 'react-router';
2
+ import type {Route} from './+types/account_.logout';
3
+
4
+ // if we don't implement this, /account/logout will get caught by account.$.tsx to do login
5
+ export async function loader() {
6
+ return redirect('/');
7
+ }
8
+
9
+ export async function action({context}: Route.ActionArgs) {
10
+ return context.customerAccount.logout();
11
+ }