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,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'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
|
|
76
|
+
</NavLink>
|
|
77
|
+
|
|
|
78
|
+
<NavLink to="/account/profile" style={isActiveStyle}>
|
|
79
|
+
Profile
|
|
80
|
+
</NavLink>
|
|
81
|
+
|
|
|
82
|
+
<NavLink to="/account/addresses" style={isActiveStyle}>
|
|
83
|
+
Addresses
|
|
84
|
+
</NavLink>
|
|
85
|
+
|
|
|
86
|
+
<Logout />
|
|
87
|
+
</nav>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Logout() {
|
|
92
|
+
return (
|
|
93
|
+
<Form className="account-logout" method="POST" action="/account/logout">
|
|
94
|
+
<button type="submit">Sign out</button>
|
|
95
|
+
</Form>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -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
|
+
}
|