@godaddy/gd-commerce-storefront 0.1.3 → 0.3.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 +110 -2
- package/dist/index.d.ts +109 -2
- package/dist/index.js +611 -200
- package/dist/styles.css +9 -1
- package/docs/server-api.md +37 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
2
|
-
import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Link, useParams, useSearchParams } from "react-router";
|
|
4
4
|
import { useQuery } from "@tanstack/react-query";
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -122,8 +122,9 @@ function getAvailableInventoryQuantity(item) {
|
|
|
122
122
|
if (!edges || edges.length === 0) return null;
|
|
123
123
|
return edges.find((edge) => edge?.node?.type === "AVAILABLE")?.node?.quantity ?? 0;
|
|
124
124
|
}
|
|
125
|
+
const imageMediaTypes = new Set(["IMAGE", "PHOTO"]);
|
|
125
126
|
function getImageUrls(item) {
|
|
126
|
-
return item?.mediaObjects?.edges?.filter((edge) => edge?.node?.type
|
|
127
|
+
return item?.mediaObjects?.edges?.filter((edge) => imageMediaTypes.has(edge?.node?.type ?? "") && edge?.node?.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
|
|
127
128
|
}
|
|
128
129
|
function getPrimaryImageUrl(item) {
|
|
129
130
|
return getImageUrls(item)[0] ?? null;
|
|
@@ -148,6 +149,82 @@ function getProductAttributes(skuGroup) {
|
|
|
148
149
|
}) ?? [];
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/sample-cart.tsx
|
|
154
|
+
const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
|
|
155
|
+
key: `sample-${index + 1}`,
|
|
156
|
+
name: `Product ${index + 1}`,
|
|
157
|
+
price: (index + 1) * 1e3
|
|
158
|
+
}));
|
|
159
|
+
const SampleCartContext = createContext(null);
|
|
160
|
+
function useSampleCart() {
|
|
161
|
+
const value = useContext(SampleCartContext);
|
|
162
|
+
if (!value) throw new Error("Mount CommerceProvider around sample products.");
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
function SampleCartProvider({ enabled, onAdd, announce, children }) {
|
|
166
|
+
const session = useMemo(() => ({ enabled }), [enabled]);
|
|
167
|
+
const active = useRef(session);
|
|
168
|
+
active.current = session;
|
|
169
|
+
const [state, setState] = useState({
|
|
170
|
+
session,
|
|
171
|
+
quantities: {}
|
|
172
|
+
});
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
active.current = session;
|
|
175
|
+
setState({
|
|
176
|
+
session,
|
|
177
|
+
quantities: {}
|
|
178
|
+
});
|
|
179
|
+
return () => {
|
|
180
|
+
active.current = null;
|
|
181
|
+
};
|
|
182
|
+
}, [session]);
|
|
183
|
+
const quantities = state.session === session && enabled ? state.quantities : {};
|
|
184
|
+
function update(key, quantity) {
|
|
185
|
+
if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) return false;
|
|
186
|
+
if (quantity !== void 0 && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) return false;
|
|
187
|
+
setState((previous) => {
|
|
188
|
+
const current = previous.session === session ? previous.quantities : {};
|
|
189
|
+
const next = quantity ?? (current[key] ?? 0) + 1;
|
|
190
|
+
if (next > 999) return previous;
|
|
191
|
+
return {
|
|
192
|
+
session,
|
|
193
|
+
quantities: {
|
|
194
|
+
...current,
|
|
195
|
+
[key]: next
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return /* @__PURE__ */ jsx(SampleCartContext.Provider, {
|
|
202
|
+
value: {
|
|
203
|
+
items: sampleProducts.flatMap((product) => quantities[product.key] ? [{
|
|
204
|
+
...product,
|
|
205
|
+
quantity: quantities[product.key]
|
|
206
|
+
}] : []),
|
|
207
|
+
add: (key) => {
|
|
208
|
+
const product = sampleProducts.find((product$1) => product$1.key === key);
|
|
209
|
+
if (!product || !update(key)) return false;
|
|
210
|
+
onAdd(product.name);
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
changeQuantity: async (key, quantity) => {
|
|
214
|
+
const updated = quantity >= 1 && update(key, quantity);
|
|
215
|
+
if (updated) announce("Sample cart quantity updated.");
|
|
216
|
+
return updated;
|
|
217
|
+
},
|
|
218
|
+
remove: async (key) => {
|
|
219
|
+
const updated = update(key, 0);
|
|
220
|
+
if (updated) announce("Sample item removed from cart.");
|
|
221
|
+
return updated;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
children
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
151
228
|
//#endregion
|
|
152
229
|
//#region src/commerce-provider.tsx
|
|
153
230
|
const CommerceContext = createContext(null);
|
|
@@ -156,10 +233,19 @@ function useCommerce() {
|
|
|
156
233
|
if (!context) throw new Error("Mount CommerceProvider once around the header and page outlet, inside the router.");
|
|
157
234
|
return context;
|
|
158
235
|
}
|
|
236
|
+
const CONNECTING_FAILURE_GRACE_MS = 6e4;
|
|
159
237
|
async function loadConfig(signal) {
|
|
160
238
|
const config = await request("/config", { signal });
|
|
161
|
-
if (
|
|
162
|
-
|
|
239
|
+
if (config?.state === "unbound" || config?.state === "connecting") {
|
|
240
|
+
if ("cartScope" in config || "currencyCode" in config) throw new Error("The store returned invalid configuration.");
|
|
241
|
+
return { state: config.state };
|
|
242
|
+
}
|
|
243
|
+
if (!config || config.state !== void 0 && config.state !== "ready" || typeof config.cartScope !== "string" || !config.cartScope.trim() || typeof config.currencyCode !== "string" || !/^[A-Z]{3}$/.test(config.currencyCode)) throw new Error("The store returned invalid configuration.");
|
|
244
|
+
return {
|
|
245
|
+
state: "ready",
|
|
246
|
+
cartScope: config.cartScope,
|
|
247
|
+
currencyCode: config.currencyCode
|
|
248
|
+
};
|
|
163
249
|
}
|
|
164
250
|
function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {} }) {
|
|
165
251
|
const config = useQuery({
|
|
@@ -167,19 +253,25 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
|
|
|
167
253
|
queryKey: ["commerce", "configuration"],
|
|
168
254
|
queryFn: ({ signal }) => loadConfig(signal),
|
|
169
255
|
staleTime: 3e4,
|
|
170
|
-
|
|
256
|
+
refetchOnMount: "always",
|
|
257
|
+
refetchOnWindowFocus: "always",
|
|
258
|
+
refetchInterval: (query) => query.state.data?.state === "unbound" || query.state.data?.state === "connecting" ? 5e3 : false,
|
|
259
|
+
refetchIntervalInBackground: false
|
|
171
260
|
});
|
|
261
|
+
const holdConnecting = config.isError && config.data?.state === "connecting" && config.errorUpdatedAt - config.dataUpdatedAt < CONNECTING_FAILURE_GRACE_MS;
|
|
262
|
+
let connection = config.data?.state ?? "loading";
|
|
263
|
+
if (config.isError) connection = holdConnecting ? "connecting" : "error";
|
|
172
264
|
return /* @__PURE__ */ jsx(BoundCommerceProvider, {
|
|
173
265
|
config: {
|
|
174
266
|
cartScope: "",
|
|
175
267
|
currencyCode: "USD",
|
|
176
|
-
...config.data,
|
|
268
|
+
...config.data?.state === "ready" ? config.data : {},
|
|
177
269
|
catalogPath,
|
|
178
270
|
productPath,
|
|
179
271
|
checkoutSuccessPath
|
|
180
272
|
},
|
|
181
|
-
connection
|
|
182
|
-
connectionError:
|
|
273
|
+
connection,
|
|
274
|
+
connectionError: connection === "error" ? message(config.error) : null,
|
|
183
275
|
retryConnection: () => {
|
|
184
276
|
config.refetch();
|
|
185
277
|
},
|
|
@@ -193,8 +285,8 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
|
|
|
193
285
|
const ready = connection === "ready";
|
|
194
286
|
const session = useMemo(() => ({
|
|
195
287
|
cartIdStorage,
|
|
196
|
-
|
|
197
|
-
}), [cartIdStorage,
|
|
288
|
+
connection
|
|
289
|
+
}), [cartIdStorage, connection]);
|
|
198
290
|
const currentSession = useRef(session);
|
|
199
291
|
currentSession.current = session;
|
|
200
292
|
const readyRef = useRef(ready);
|
|
@@ -299,6 +391,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
|
|
|
299
391
|
setOpen(false);
|
|
300
392
|
setError(null);
|
|
301
393
|
setStorageWarning(null);
|
|
394
|
+
setAnnouncement("");
|
|
302
395
|
setPending(false);
|
|
303
396
|
setHydrating(ready);
|
|
304
397
|
if (ready) refresh();
|
|
@@ -315,7 +408,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
|
|
|
315
408
|
window.removeEventListener("storage", onStorage);
|
|
316
409
|
window.removeEventListener("focus", onFocus);
|
|
317
410
|
};
|
|
318
|
-
}, [cartIdStorage,
|
|
411
|
+
}, [cartIdStorage, connection]);
|
|
319
412
|
function addItem(item) {
|
|
320
413
|
rememberOpener();
|
|
321
414
|
return run(async () => {
|
|
@@ -426,7 +519,16 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
|
|
|
426
519
|
refresh,
|
|
427
520
|
checkout
|
|
428
521
|
},
|
|
429
|
-
children: [
|
|
522
|
+
children: [/* @__PURE__ */ jsx(SampleCartProvider, {
|
|
523
|
+
enabled: connection === "unbound",
|
|
524
|
+
announce: setAnnouncement,
|
|
525
|
+
onAdd: (name) => {
|
|
526
|
+
rememberOpener();
|
|
527
|
+
setAnnouncement(`${name} added to your sample cart.`);
|
|
528
|
+
setOpen(true);
|
|
529
|
+
},
|
|
530
|
+
children
|
|
531
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
430
532
|
className: "commerce-sr-only",
|
|
431
533
|
role: "status",
|
|
432
534
|
"aria-live": "polite",
|
|
@@ -448,7 +550,10 @@ function StorefrontSurface({ children, className = "" }) {
|
|
|
448
550
|
function CommerceStatus() {
|
|
449
551
|
const { connection, connectionError, retryConnection } = useCommerce();
|
|
450
552
|
if (connection === "ready") return null;
|
|
451
|
-
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "
|
|
553
|
+
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "unbound" ? /* @__PURE__ */ jsx("p", {
|
|
554
|
+
role: "status",
|
|
555
|
+
children: "Connect a store to show products."
|
|
556
|
+
}) : connection === "loading" || connection === "connecting" ? /* @__PURE__ */ jsx("p", {
|
|
452
557
|
role: "status",
|
|
453
558
|
children: "Connecting to the store…"
|
|
454
559
|
}) : /* @__PURE__ */ jsxs("div", {
|
|
@@ -468,13 +573,14 @@ const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 roun
|
|
|
468
573
|
const inputClass = "min-h-11 rounded-lg border border-neutral-400 bg-white px-3 py-2 text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900";
|
|
469
574
|
function CartButton() {
|
|
470
575
|
const { cart, setOpen, open, connection } = useCommerce();
|
|
471
|
-
const
|
|
576
|
+
const sample = useSampleCart();
|
|
577
|
+
const count = connection === "unbound" ? sample.items.reduce((total, item) => total + item.quantity, 0) : cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0;
|
|
472
578
|
return /* @__PURE__ */ jsx(StorefrontSurface, {
|
|
473
579
|
className: "commerce-inline",
|
|
474
580
|
children: /* @__PURE__ */ jsxs("button", {
|
|
475
581
|
type: "button",
|
|
476
582
|
className: buttonClass,
|
|
477
|
-
disabled: connection !== "ready",
|
|
583
|
+
disabled: connection !== "ready" && connection !== "unbound",
|
|
478
584
|
"aria-haspopup": "dialog",
|
|
479
585
|
"aria-expanded": open,
|
|
480
586
|
onClick: () => setOpen(true),
|
|
@@ -521,13 +627,34 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
|
|
|
521
627
|
});
|
|
522
628
|
}
|
|
523
629
|
function CartDrawer() {
|
|
524
|
-
const { config, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity, removeItem, refresh, checkout } = useCommerce();
|
|
630
|
+
const { config, connection, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity: changeLiveQuantity, removeItem: removeLiveItem, refresh, checkout } = useCommerce();
|
|
631
|
+
const sample = useSampleCart();
|
|
632
|
+
const isSample = connection === "unbound";
|
|
633
|
+
const changeQuantity = isSample ? sample.changeQuantity : changeLiveQuantity;
|
|
634
|
+
const removeItem = isSample ? sample.remove : removeLiveItem;
|
|
525
635
|
const [drawerAction, setDrawerAction] = useState(null);
|
|
526
636
|
const checkingOut = drawerAction === "checkout";
|
|
527
637
|
const locked = pending || hydrating || drawerAction !== null;
|
|
528
|
-
const items =
|
|
638
|
+
const items = isSample ? sample.items.map((item) => ({
|
|
639
|
+
id: item.key,
|
|
640
|
+
name: item.name,
|
|
641
|
+
quantity: item.quantity,
|
|
642
|
+
image: void 0,
|
|
643
|
+
options: void 0,
|
|
644
|
+
subtotal: item.price * item.quantity,
|
|
645
|
+
currency: "USD"
|
|
646
|
+
})) : (cart?.lineItems ?? []).map((item) => ({
|
|
647
|
+
id: item.id,
|
|
648
|
+
name: item.name,
|
|
649
|
+
quantity: item.quantity,
|
|
650
|
+
image: item.details?.productAssetUrl,
|
|
651
|
+
options: item.details?.selectedOptions,
|
|
652
|
+
subtotal: item.totals?.subTotal?.value,
|
|
653
|
+
currency: item.totals?.subTotal?.currencyCode
|
|
654
|
+
}));
|
|
529
655
|
const summary = getCartSummaryTotals(cart);
|
|
530
|
-
const currency = cart?.totals?.total?.currencyCode ?? config.currencyCode;
|
|
656
|
+
const currency = isSample ? "USD" : cart?.totals?.total?.currencyCode ?? config.currencyCode;
|
|
657
|
+
const subtotal = isSample ? sample.items.reduce((sum, item) => sum + item.price * item.quantity, 0) : summary.subtotal;
|
|
531
658
|
async function handleAction(action, operation) {
|
|
532
659
|
if (locked) return;
|
|
533
660
|
setDrawerAction(action);
|
|
@@ -541,6 +668,7 @@ function CartDrawer() {
|
|
|
541
668
|
open,
|
|
542
669
|
onOpenChange: setOpen,
|
|
543
670
|
children: /* @__PURE__ */ jsx(Dialog.Portal, { children: /* @__PURE__ */ jsxs(StorefrontSurface, { children: [/* @__PURE__ */ jsx(Dialog.Overlay, { className: "fixed inset-0 z-40 bg-neutral-950/40" }), /* @__PURE__ */ jsxs(Dialog.Content, {
|
|
671
|
+
"data-commerce-source": isSample ? "sample" : void 0,
|
|
544
672
|
className: "fixed inset-y-0 right-0 z-50 flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
|
|
545
673
|
onCloseAutoFocus: (event) => {
|
|
546
674
|
event.preventDefault();
|
|
@@ -551,10 +679,10 @@ function CartDrawer() {
|
|
|
551
679
|
className: "flex items-start justify-between border-b border-neutral-200 p-6",
|
|
552
680
|
children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Dialog.Title, {
|
|
553
681
|
className: "text-2xl font-semibold",
|
|
554
|
-
children: "Your cart"
|
|
682
|
+
children: isSample ? "Sample cart" : "Your cart"
|
|
555
683
|
}), /* @__PURE__ */ jsx(Dialog.Description, {
|
|
556
684
|
className: "mt-1 text-sm text-neutral-600",
|
|
557
|
-
children: "Review your items before checkout."
|
|
685
|
+
children: isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout."
|
|
558
686
|
})] }), /* @__PURE__ */ jsx(Dialog.Close, {
|
|
559
687
|
className: "min-h-11 min-w-11 rounded-lg border border-neutral-400 bg-white px-3 text-neutral-900 focus-visible:outline focus-visible:outline-2",
|
|
560
688
|
"aria-label": "Close cart",
|
|
@@ -595,7 +723,7 @@ function CartDrawer() {
|
|
|
595
723
|
}),
|
|
596
724
|
/* @__PURE__ */ jsx("p", {
|
|
597
725
|
className: "mt-2 text-neutral-600",
|
|
598
|
-
children: "Find something you love in the shop."
|
|
726
|
+
children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
|
|
599
727
|
}),
|
|
600
728
|
/* @__PURE__ */ jsx(Link, {
|
|
601
729
|
className: `${buttonClass} mt-6`,
|
|
@@ -612,8 +740,8 @@ function CartDrawer() {
|
|
|
612
740
|
"data-testid": "cart-item",
|
|
613
741
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
614
742
|
className: "flex gap-4",
|
|
615
|
-
children: [item.
|
|
616
|
-
src: item.
|
|
743
|
+
children: [item.image && /* @__PURE__ */ jsx("img", {
|
|
744
|
+
src: item.image,
|
|
617
745
|
alt: "",
|
|
618
746
|
className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
|
|
619
747
|
}), /* @__PURE__ */ jsxs("div", {
|
|
@@ -623,7 +751,7 @@ function CartDrawer() {
|
|
|
623
751
|
className: "font-semibold",
|
|
624
752
|
children: item.name
|
|
625
753
|
}),
|
|
626
|
-
item.
|
|
754
|
+
item.options?.map((option) => /* @__PURE__ */ jsxs("p", {
|
|
627
755
|
className: "text-sm text-neutral-600",
|
|
628
756
|
children: [
|
|
629
757
|
option.attribute,
|
|
@@ -633,7 +761,7 @@ function CartDrawer() {
|
|
|
633
761
|
}, `${option.attribute}:${option.values?.join(",")}`)),
|
|
634
762
|
/* @__PURE__ */ jsx("p", {
|
|
635
763
|
className: "mt-1 text-sm",
|
|
636
|
-
children: typeof item.
|
|
764
|
+
children: typeof item.subtotal === "number" ? money(item.subtotal, item.currency ?? currency) : "Price unavailable"
|
|
637
765
|
})
|
|
638
766
|
]
|
|
639
767
|
})]
|
|
@@ -690,16 +818,19 @@ function CartDrawer() {
|
|
|
690
818
|
children: [
|
|
691
819
|
/* @__PURE__ */ jsx("dl", {
|
|
692
820
|
className: "space-y-2 text-sm",
|
|
693
|
-
children: typeof cart?.totals?.subTotal?.value === "number" && /* @__PURE__ */ jsxs("div", {
|
|
821
|
+
children: (isSample || typeof cart?.totals?.subTotal?.value === "number") && /* @__PURE__ */ jsxs("div", {
|
|
694
822
|
className: "flex justify-between",
|
|
695
|
-
children: [/* @__PURE__ */ jsx("dt", { children: "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(
|
|
823
|
+
children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(subtotal, isSample ? "USD" : cart?.totals?.subTotal?.currencyCode ?? currency) })]
|
|
696
824
|
})
|
|
697
825
|
}),
|
|
698
|
-
/* @__PURE__ */ jsx("p", {
|
|
826
|
+
!isSample && /* @__PURE__ */ jsx("p", {
|
|
699
827
|
className: "commerce-cart-checkout-adjustments-note mt-3 text-sm text-neutral-600",
|
|
700
828
|
children: "Shipping, taxes, and discounts are calculated at checkout."
|
|
701
829
|
}),
|
|
702
|
-
|
|
830
|
+
isSample ? /* @__PURE__ */ jsx("p", {
|
|
831
|
+
className: "text-sm text-neutral-600",
|
|
832
|
+
children: "Example total in USD. Checkout is not set up yet."
|
|
833
|
+
}) : config.checkoutSuccessPath ? /* @__PURE__ */ jsx("button", {
|
|
703
834
|
type: "button",
|
|
704
835
|
className: `${buttonClass} w-full`,
|
|
705
836
|
disabled: checkingOut || hydrating || cart?.totals?.total?.value == null,
|
|
@@ -800,7 +931,7 @@ function ProductCardContent({ product }) {
|
|
|
800
931
|
}
|
|
801
932
|
function Catalog(props) {
|
|
802
933
|
const { connection } = useCommerce();
|
|
803
|
-
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
|
|
934
|
+
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : connection === "unbound" && props.sampleProducts ? /* @__PURE__ */ jsx(SampleCatalog, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
|
|
804
935
|
}
|
|
805
936
|
function CatalogContent({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
|
|
806
937
|
const { config } = useCommerce();
|
|
@@ -902,6 +1033,61 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
|
|
|
902
1033
|
})
|
|
903
1034
|
] });
|
|
904
1035
|
}
|
|
1036
|
+
function SampleCatalog({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
|
|
1037
|
+
const { add } = useSampleCart();
|
|
1038
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
1039
|
+
"data-commerce-source": "sample",
|
|
1040
|
+
children: [
|
|
1041
|
+
showHeader && /* @__PURE__ */ jsxs("header", {
|
|
1042
|
+
className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8",
|
|
1043
|
+
children: [/* @__PURE__ */ jsx("h1", {
|
|
1044
|
+
className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
|
|
1045
|
+
children: title
|
|
1046
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
1047
|
+
className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
|
|
1048
|
+
children: description
|
|
1049
|
+
})]
|
|
1050
|
+
}),
|
|
1051
|
+
/* @__PURE__ */ jsx("p", {
|
|
1052
|
+
className: "border-b border-neutral-200 py-6 text-sm",
|
|
1053
|
+
role: "status",
|
|
1054
|
+
children: "Sample products · Example prices in USD"
|
|
1055
|
+
}),
|
|
1056
|
+
/* @__PURE__ */ jsx("div", {
|
|
1057
|
+
className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10",
|
|
1058
|
+
children: sampleProducts.map((product) => /* @__PURE__ */ jsxs("article", {
|
|
1059
|
+
className: "flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200",
|
|
1060
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1061
|
+
"aria-hidden": "true",
|
|
1062
|
+
className: "flex aspect-square items-center justify-center bg-neutral-100",
|
|
1063
|
+
children: /* @__PURE__ */ jsx("div", { className: "h-20 w-20 rounded-lg border border-neutral-300" })
|
|
1064
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1065
|
+
className: "flex flex-1 flex-col p-6",
|
|
1066
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
1067
|
+
className: "break-words text-2xl font-semibold leading-tight",
|
|
1068
|
+
children: product.name
|
|
1069
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1070
|
+
className: "mt-auto pt-6",
|
|
1071
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1072
|
+
className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
|
|
1073
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
1074
|
+
className: "text-lg font-semibold leading-7",
|
|
1075
|
+
children: money(product.price, "USD")
|
|
1076
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
1077
|
+
type: "button",
|
|
1078
|
+
className: buttonClass,
|
|
1079
|
+
"aria-label": `Add ${product.name} to sample cart`,
|
|
1080
|
+
onClick: () => add(product.key),
|
|
1081
|
+
children: "Add to cart"
|
|
1082
|
+
})]
|
|
1083
|
+
})
|
|
1084
|
+
})]
|
|
1085
|
+
})]
|
|
1086
|
+
}, product.key))
|
|
1087
|
+
})
|
|
1088
|
+
]
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
905
1091
|
|
|
906
1092
|
//#endregion
|
|
907
1093
|
//#region src/commerce-storefront.tsx
|
|
@@ -914,15 +1100,72 @@ function CommerceStorefront({ children,...props }) {
|
|
|
914
1100
|
}
|
|
915
1101
|
|
|
916
1102
|
//#endregion
|
|
917
|
-
//#region src/product-details.tsx
|
|
918
|
-
|
|
1103
|
+
//#region src/product-details-context.tsx
|
|
1104
|
+
const cx = (...classes) => classes.filter(Boolean).join(" ");
|
|
1105
|
+
/** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
|
|
1106
|
+
const partClass = (...classes) => cx("commerce-part", ...classes);
|
|
1107
|
+
const ProductDetailsContext = createContext(null);
|
|
1108
|
+
function useProductDetailsInternal(part) {
|
|
1109
|
+
const context = useContext(ProductDetailsContext);
|
|
1110
|
+
if (!context) throw new Error(`${part} must be rendered inside <ProductDetailsRoot>.`);
|
|
1111
|
+
return context;
|
|
1112
|
+
}
|
|
1113
|
+
/** Read the current product, verified SKU, and purchase status from inside `ProductDetailsRoot`. */
|
|
1114
|
+
function useProductDetails() {
|
|
1115
|
+
const { product, name, selectedSku, quantity, availableQuantity, status, images } = useProductDetailsInternal("useProductDetails");
|
|
1116
|
+
return {
|
|
1117
|
+
product,
|
|
1118
|
+
name,
|
|
1119
|
+
selectedSku,
|
|
1120
|
+
quantity,
|
|
1121
|
+
availableQuantity,
|
|
1122
|
+
status,
|
|
1123
|
+
images
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Build a custom control for one product attribute. Returns `null` when the product has no such attribute.
|
|
1128
|
+
* A selection is never purchasable until the server verifies it.
|
|
1129
|
+
*/
|
|
1130
|
+
function useProductDetailsOption(attribute) {
|
|
1131
|
+
const { attributes, selections, selectOption, register } = useProductDetailsInternal("useProductDetailsOption");
|
|
1132
|
+
useEffect(() => register(`attribute:${attribute}`), [attribute, register]);
|
|
1133
|
+
const index = attributes.findIndex((candidate) => candidate.name === attribute);
|
|
1134
|
+
const match = attributes[index];
|
|
1135
|
+
if (!match) return null;
|
|
1136
|
+
const selected = selections[index] || null;
|
|
1137
|
+
return {
|
|
1138
|
+
name: match.name,
|
|
1139
|
+
label: match.label,
|
|
1140
|
+
selected,
|
|
1141
|
+
values: match.values.map((value) => ({
|
|
1142
|
+
name: value.name,
|
|
1143
|
+
label: value.label,
|
|
1144
|
+
selected: value.name === selected
|
|
1145
|
+
})),
|
|
1146
|
+
select: (value) => {
|
|
1147
|
+
if (value === null) selectOption(attribute, "");
|
|
1148
|
+
else if (match.values.some((candidate) => candidate.name === value)) selectOption(attribute, value);
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
/** Register the explicit SKU control so the layout check knows SKU-list products can be completed. */
|
|
1153
|
+
function useSkuOptionRegistration() {
|
|
1154
|
+
const { register } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1155
|
+
useEffect(() => register("sku"), [register]);
|
|
1156
|
+
}
|
|
1157
|
+
/** Loads the product for the route's `:productId` and provides state to product parts. */
|
|
1158
|
+
function ProductDetailsRoot({ children, className }) {
|
|
919
1159
|
const { productId = "" } = useParams();
|
|
920
1160
|
const { connection } = useCommerce();
|
|
921
|
-
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(
|
|
1161
|
+
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsLoader, {
|
|
1162
|
+
productId,
|
|
1163
|
+
className,
|
|
1164
|
+
children
|
|
1165
|
+
}, productId) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
|
|
922
1166
|
}
|
|
923
|
-
function
|
|
1167
|
+
function ProductDetailsLoader({ productId, className, children }) {
|
|
924
1168
|
const { config } = useCommerce();
|
|
925
|
-
const fieldId = useId();
|
|
926
1169
|
const [params, setParams] = useSearchParams();
|
|
927
1170
|
const [quantity, setQuantity] = useState(1);
|
|
928
1171
|
const [imageIndex, setImageIndex] = useState(0);
|
|
@@ -945,10 +1188,10 @@ function ProductDetailsContent({ productId }) {
|
|
|
945
1188
|
const skuOptions = getLabeledSkuOptions(group);
|
|
946
1189
|
const explicitSku = skuOptions.find((sku) => sku.id === params.get("sku"));
|
|
947
1190
|
const selections = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? "");
|
|
948
|
-
const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value) => value.name === selections[index]));
|
|
1191
|
+
const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value$1) => value$1.name === selections[index]));
|
|
949
1192
|
const selectionParams = new URLSearchParams();
|
|
950
|
-
selections.filter(Boolean).forEach((value) => {
|
|
951
|
-
selectionParams.append("attributeValues", value);
|
|
1193
|
+
selections.filter(Boolean).forEach((value$1) => {
|
|
1194
|
+
selectionParams.append("attributeValues", value$1);
|
|
952
1195
|
});
|
|
953
1196
|
const matched = useQuery({
|
|
954
1197
|
retry: false,
|
|
@@ -967,20 +1210,34 @@ function ProductDetailsContent({ productId }) {
|
|
|
967
1210
|
});
|
|
968
1211
|
const selectionVerified = complete && product.isSuccess && !product.isFetching && (attributes.length === 0 || matched.isSuccess && !matched.isFetching);
|
|
969
1212
|
const selectedSku = selectionVerified ? explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group) : null;
|
|
970
|
-
const
|
|
971
|
-
const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node;
|
|
1213
|
+
const status = !complete ? "incomplete" : matched.isError && !matched.isFetching ? "variant-error" : !selectionVerified ? "checking" : selectedSku ? "ready" : attributes.length ? "unavailable" : "not-purchasable";
|
|
972
1214
|
const selectedImages = getImageUrls(selectedSku);
|
|
973
1215
|
const images = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))];
|
|
974
|
-
const
|
|
975
|
-
const name = group?.label ?? group?.name ?? "Product";
|
|
976
|
-
const selectOption = (attribute, value) => {
|
|
1216
|
+
const changeSelection = (key, value$1) => {
|
|
977
1217
|
const next = new URLSearchParams(params);
|
|
978
|
-
if (value) next.set(
|
|
979
|
-
else next.delete(
|
|
1218
|
+
if (value$1) next.set(key, value$1);
|
|
1219
|
+
else next.delete(key);
|
|
980
1220
|
setParams(next);
|
|
981
1221
|
setQuantity(1);
|
|
982
1222
|
setImageIndex(0);
|
|
983
1223
|
};
|
|
1224
|
+
const registered = useRef(/* @__PURE__ */ new Map());
|
|
1225
|
+
const register = useCallback((control) => {
|
|
1226
|
+
const counts = registered.current;
|
|
1227
|
+
counts.set(control, (counts.get(control) ?? 0) + 1);
|
|
1228
|
+
return () => {
|
|
1229
|
+
const count = (counts.get(control) ?? 1) - 1;
|
|
1230
|
+
if (count > 0) counts.set(control, count);
|
|
1231
|
+
else counts.delete(control);
|
|
1232
|
+
};
|
|
1233
|
+
}, []);
|
|
1234
|
+
const requiredControls = [...attributes.map((attribute) => `attribute:${attribute.name}`), ...skuOptions.length ? ["sku"] : []].join("\n");
|
|
1235
|
+
useEffect(() => {
|
|
1236
|
+
if (process.env.NODE_ENV === "production") return;
|
|
1237
|
+
const missing = requiredControls.split("\n").filter((control) => control && !registered.current.has(control));
|
|
1238
|
+
if (missing.length === 0) return;
|
|
1239
|
+
console.warn(`[gd-commerce-storefront] No control for ${missing.map((control) => control === "sku" ? "the SKU option" : `"${control.slice(10)}"`).join(", ")} is rendered right now, so this product cannot be added to the cart until one is. Render <ProductDetailsOptions />, <ProductDetailsOption attribute='…' /> for each attribute, or <ProductDetailsPurchase />, or ignore this if the control mounts later.`);
|
|
1240
|
+
}, [requiredControls]);
|
|
984
1241
|
if (product.isPending) return /* @__PURE__ */ jsx("p", {
|
|
985
1242
|
role: "status",
|
|
986
1243
|
children: "Loading product…"
|
|
@@ -1012,167 +1269,321 @@ function ProductDetailsContent({ productId }) {
|
|
|
1012
1269
|
to: config.catalogPath,
|
|
1013
1270
|
children: "Back to shop"
|
|
1014
1271
|
})] });
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1272
|
+
const value = {
|
|
1273
|
+
product: group,
|
|
1274
|
+
name: group.label ?? group.name ?? "Product",
|
|
1275
|
+
selectedSku,
|
|
1276
|
+
quantity,
|
|
1277
|
+
availableQuantity: getAvailableInventoryQuantity(selectedSku),
|
|
1278
|
+
status,
|
|
1279
|
+
images,
|
|
1280
|
+
attributes,
|
|
1281
|
+
selections,
|
|
1282
|
+
skuOptions,
|
|
1283
|
+
explicitSkuId: explicitSku?.id ?? "",
|
|
1284
|
+
price: selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node,
|
|
1285
|
+
imageIndex: Math.min(imageIndex, Math.max(images.length - 1, 0)),
|
|
1286
|
+
variantError: matched.error,
|
|
1287
|
+
setImageIndex: (index) => {
|
|
1288
|
+
if (Number.isInteger(index) && index >= 0 && index < images.length) setImageIndex(index);
|
|
1289
|
+
},
|
|
1290
|
+
setQuantity,
|
|
1291
|
+
selectOption: (attribute, next) => changeSelection(`option.${attribute}`, next),
|
|
1292
|
+
selectSku: (skuId) => changeSelection("sku", skuOptions.some((sku) => sku.id === skuId) ? skuId : ""),
|
|
1293
|
+
retryVariant: () => {
|
|
1294
|
+
if (matched.isError) matched.refetch();
|
|
1295
|
+
},
|
|
1296
|
+
register
|
|
1297
|
+
};
|
|
1298
|
+
return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
|
|
1299
|
+
value,
|
|
1300
|
+
children: /* @__PURE__ */ jsx("article", {
|
|
1301
|
+
className: partClass(className),
|
|
1302
|
+
children
|
|
1303
|
+
})
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
//#endregion
|
|
1308
|
+
//#region src/product-purchase.tsx
|
|
1309
|
+
const disabledReasons = {
|
|
1310
|
+
incomplete: "Select all options to add this item to your cart.",
|
|
1311
|
+
checking: "Checking this variant…",
|
|
1312
|
+
"variant-error": "Availability for this selection could not be checked.",
|
|
1313
|
+
unavailable: "This combination is unavailable. Choose different options.",
|
|
1314
|
+
"not-purchasable": "This product is not currently available to purchase."
|
|
1315
|
+
};
|
|
1316
|
+
const flush = "mb-0";
|
|
1317
|
+
/** The packaged purchase area: options, status, availability, quantity, and add to cart. */
|
|
1318
|
+
function ProductDetailsPurchase({ className }) {
|
|
1319
|
+
useProductDetailsInternal("ProductDetailsPurchase");
|
|
1320
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1321
|
+
className: partClass("space-y-6", className),
|
|
1322
|
+
children: [
|
|
1323
|
+
/* @__PURE__ */ jsx(OptionControls, {}),
|
|
1324
|
+
/* @__PURE__ */ jsx(PurchaseStatusMessage, { textClassName: flush }),
|
|
1325
|
+
/* @__PURE__ */ jsx(ProductDetailsAvailability, { className: flush }),
|
|
1326
|
+
/* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
|
|
1327
|
+
/* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
|
|
1328
|
+
]
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
/** A select for every product attribute, or the SKU list for products without attributes. */
|
|
1332
|
+
function ProductDetailsOptions({ className }) {
|
|
1333
|
+
const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1334
|
+
if (attributes.length === 0 && skuOptions.length === 0) return null;
|
|
1335
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1336
|
+
className: partClass("space-y-6", className),
|
|
1337
|
+
children: /* @__PURE__ */ jsx(OptionControls, {})
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
function OptionControls() {
|
|
1341
|
+
const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1342
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [attributes.map((attribute) => /* @__PURE__ */ jsx(ProductDetailsOption, {
|
|
1343
|
+
attribute: attribute.name,
|
|
1344
|
+
className: flush
|
|
1345
|
+
}, attribute.id || attribute.name)), skuOptions.length > 0 && /* @__PURE__ */ jsx(SkuOption, {})] });
|
|
1346
|
+
}
|
|
1347
|
+
function SkuOption() {
|
|
1348
|
+
const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1349
|
+
const id = useId();
|
|
1350
|
+
useSkuOptionRegistration();
|
|
1351
|
+
return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
|
|
1352
|
+
htmlFor: id,
|
|
1353
|
+
className: "mb-2 block font-medium",
|
|
1354
|
+
children: "Option"
|
|
1355
|
+
}), /* @__PURE__ */ jsxs("select", {
|
|
1356
|
+
id,
|
|
1357
|
+
className: `${inputClass} w-full`,
|
|
1358
|
+
value: explicitSkuId,
|
|
1359
|
+
onChange: (event) => selectSku(event.target.value),
|
|
1360
|
+
children: [/* @__PURE__ */ jsx("option", {
|
|
1361
|
+
value: "",
|
|
1362
|
+
children: "Select an option"
|
|
1363
|
+
}), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
|
|
1364
|
+
value: sku.id ?? "",
|
|
1365
|
+
children: sku.label?.trim() || sku.name?.trim()
|
|
1366
|
+
}, sku.id))]
|
|
1367
|
+
})] });
|
|
1368
|
+
}
|
|
1369
|
+
/** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
|
|
1370
|
+
function ProductDetailsOption({ attribute, className }) {
|
|
1371
|
+
const option = useProductDetailsOption(attribute);
|
|
1372
|
+
if (!option) return null;
|
|
1373
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1374
|
+
className: partClass(className),
|
|
1375
|
+
children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
|
|
1376
|
+
className: "mb-2 font-medium",
|
|
1377
|
+
children: option.label
|
|
1378
|
+
}), /* @__PURE__ */ jsxs("select", {
|
|
1379
|
+
className: `${inputClass} w-full`,
|
|
1380
|
+
"aria-label": option.label,
|
|
1381
|
+
value: option.selected ?? "",
|
|
1382
|
+
onChange: (event) => option.select(event.target.value || null),
|
|
1383
|
+
children: [/* @__PURE__ */ jsxs("option", {
|
|
1384
|
+
value: "",
|
|
1385
|
+
children: ["Select ", option.label.toLowerCase()]
|
|
1386
|
+
}), option.values.map((value) => /* @__PURE__ */ jsx("option", {
|
|
1387
|
+
value: value.name,
|
|
1388
|
+
children: value.label
|
|
1389
|
+
}, value.name))]
|
|
1390
|
+
})] })
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
/** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
|
|
1394
|
+
function ProductDetailsPurchaseStatus({ className }) {
|
|
1395
|
+
return /* @__PURE__ */ jsx(PurchaseStatusMessage, { className });
|
|
1396
|
+
}
|
|
1397
|
+
function PurchaseStatusMessage({ className, textClassName }) {
|
|
1398
|
+
const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
|
|
1399
|
+
const part = (children, partClassName) => /* @__PURE__ */ jsx("div", {
|
|
1400
|
+
className: partClass(partClassName, className),
|
|
1401
|
+
children
|
|
1402
|
+
});
|
|
1403
|
+
switch (status) {
|
|
1404
|
+
case "ready": return null;
|
|
1405
|
+
case "variant-error": return part(/* @__PURE__ */ jsxs("div", {
|
|
1406
|
+
role: "alert",
|
|
1407
|
+
className: "text-red-700",
|
|
1408
|
+
children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
|
|
1409
|
+
type: "button",
|
|
1410
|
+
className: "min-h-11 bg-white text-red-700 underline",
|
|
1411
|
+
onClick: retryVariant,
|
|
1412
|
+
children: "Retry availability"
|
|
1042
1413
|
})]
|
|
1043
|
-
})
|
|
1414
|
+
}));
|
|
1415
|
+
case "unavailable": return part(/* @__PURE__ */ jsx("p", {
|
|
1416
|
+
role: "status",
|
|
1417
|
+
className: "text-sm text-red-700",
|
|
1418
|
+
children: disabledReasons[status]
|
|
1419
|
+
}), textClassName);
|
|
1420
|
+
case "not-purchasable": return part(/* @__PURE__ */ jsx("p", {
|
|
1421
|
+
className: "text-sm",
|
|
1422
|
+
children: disabledReasons[status]
|
|
1423
|
+
}), textClassName);
|
|
1424
|
+
case "checking": return part(/* @__PURE__ */ jsx("p", {
|
|
1425
|
+
role: "status",
|
|
1426
|
+
children: disabledReasons[status]
|
|
1427
|
+
}), textClassName);
|
|
1428
|
+
case "incomplete": return part(/* @__PURE__ */ jsx("p", {
|
|
1429
|
+
role: "status",
|
|
1430
|
+
className: "text-sm",
|
|
1431
|
+
children: disabledReasons[status]
|
|
1432
|
+
}), textClassName);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/** Stock for the verified SKU. Renders nothing until a SKU is verified. */
|
|
1436
|
+
function ProductDetailsAvailability({ className }) {
|
|
1437
|
+
const { selectedSku, availableQuantity } = useProductDetailsInternal("ProductDetailsAvailability");
|
|
1438
|
+
if (!selectedSku) return null;
|
|
1439
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1440
|
+
className: partClass(className),
|
|
1441
|
+
children: /* @__PURE__ */ jsx("p", {
|
|
1442
|
+
className: "text-sm",
|
|
1443
|
+
"aria-live": "polite",
|
|
1444
|
+
children: availableQuantity === null ? "Available to order" : availableQuantity === 0 ? "This variant is out of stock" : `${availableQuantity} available`
|
|
1445
|
+
})
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
/** Quantity for the verified SKU. Renders nothing until a SKU is verified. */
|
|
1449
|
+
function ProductDetailsQuantity({ className }) {
|
|
1450
|
+
const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
|
|
1451
|
+
const id = useId();
|
|
1452
|
+
if (!selectedSku) return null;
|
|
1453
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1454
|
+
className: partClass(className),
|
|
1455
|
+
children: [/* @__PURE__ */ jsx("label", {
|
|
1456
|
+
htmlFor: id,
|
|
1457
|
+
className: "mb-2 block font-medium",
|
|
1458
|
+
children: "Quantity"
|
|
1459
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1460
|
+
id,
|
|
1461
|
+
type: "number",
|
|
1462
|
+
min: 1,
|
|
1463
|
+
max: availableQuantity ?? void 0,
|
|
1464
|
+
step: 1,
|
|
1465
|
+
className: `${inputClass} w-28`,
|
|
1466
|
+
value: Number.isNaN(quantity) ? "" : quantity,
|
|
1467
|
+
onChange: (event) => setQuantity(event.target.valueAsNumber)
|
|
1468
|
+
})]
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
/** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
|
|
1472
|
+
function ProductDetailsAddToCart({ className }) {
|
|
1473
|
+
const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
|
|
1474
|
+
const reasonId = useId();
|
|
1475
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1476
|
+
className: partClass(className),
|
|
1477
|
+
children: selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
|
|
1478
|
+
sku: selectedSku,
|
|
1479
|
+
name,
|
|
1480
|
+
quantity
|
|
1481
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
|
|
1482
|
+
type: "button",
|
|
1483
|
+
className: `${buttonClass} w-full`,
|
|
1484
|
+
disabled: true,
|
|
1485
|
+
"aria-describedby": reasonId,
|
|
1486
|
+
children: "Add to cart"
|
|
1487
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1488
|
+
id: reasonId,
|
|
1489
|
+
hidden: true,
|
|
1490
|
+
children: status === "ready" ? "" : disabledReasons[status]
|
|
1491
|
+
})] })
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/product-details.tsx
|
|
1497
|
+
/** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
|
|
1498
|
+
function ProductDetails() {
|
|
1499
|
+
return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-8 sm:mb-10" }), /* @__PURE__ */ jsxs("div", {
|
|
1500
|
+
className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
|
|
1501
|
+
children: [/* @__PURE__ */ jsx(ProductDetailsGallery, { className: "min-w-0 lg:col-span-7" }), /* @__PURE__ */ jsxs("div", {
|
|
1044
1502
|
className: "min-w-0 lg:col-span-5 lg:py-4",
|
|
1045
1503
|
children: [
|
|
1046
|
-
/* @__PURE__ */ jsx(
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
})
|
|
1050
|
-
/* @__PURE__ */ jsxs("div", {
|
|
1051
|
-
"aria-live": "polite",
|
|
1052
|
-
className: "mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
|
|
1053
|
-
"data-testid": "product-price",
|
|
1054
|
-
children: [skuPrice?.value?.value != null ? money(skuPrice.value.value, skuPrice.value.currencyCode ?? config.currencyCode) : group.priceRange?.min != null ? `From ${money(group.priceRange.min, config.currencyCode)}` : "Price unavailable", skuPrice?.compareAtValue?.value != null && skuPrice.value?.value != null && skuPrice.compareAtValue.value > skuPrice.value.value && /* @__PURE__ */ jsx("del", {
|
|
1055
|
-
className: "text-lg font-normal",
|
|
1056
|
-
children: money(skuPrice.compareAtValue.value, skuPrice.compareAtValue.currencyCode ?? config.currencyCode)
|
|
1057
|
-
})]
|
|
1058
|
-
}),
|
|
1059
|
-
/* @__PURE__ */ jsx("p", {
|
|
1060
|
-
className: "my-8 whitespace-pre-line text-base leading-7",
|
|
1061
|
-
children: selectedSku?.description ?? group.description
|
|
1062
|
-
}),
|
|
1063
|
-
/* @__PURE__ */ jsxs("div", {
|
|
1064
|
-
className: "space-y-6 border-t border-neutral-200 pt-8",
|
|
1065
|
-
children: [
|
|
1066
|
-
attributes.map((attribute, index) => /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
|
|
1067
|
-
className: "mb-2 font-medium",
|
|
1068
|
-
children: attribute.label
|
|
1069
|
-
}), /* @__PURE__ */ jsxs("select", {
|
|
1070
|
-
className: `${inputClass} w-full`,
|
|
1071
|
-
"aria-label": attribute.label,
|
|
1072
|
-
value: selections[index],
|
|
1073
|
-
onChange: (event) => selectOption(attribute.name, event.target.value),
|
|
1074
|
-
children: [/* @__PURE__ */ jsxs("option", {
|
|
1075
|
-
value: "",
|
|
1076
|
-
children: ["Select ", attribute.label.toLowerCase()]
|
|
1077
|
-
}), attribute.values.map((value) => /* @__PURE__ */ jsx("option", {
|
|
1078
|
-
value: value.name,
|
|
1079
|
-
children: value.label
|
|
1080
|
-
}, value.id || value.name))]
|
|
1081
|
-
})] }, attribute.id || attribute.name)),
|
|
1082
|
-
skuOptions.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
|
|
1083
|
-
htmlFor: `${fieldId}-sku-option`,
|
|
1084
|
-
className: "mb-2 block font-medium",
|
|
1085
|
-
children: "Option"
|
|
1086
|
-
}), /* @__PURE__ */ jsxs("select", {
|
|
1087
|
-
id: `${fieldId}-sku-option`,
|
|
1088
|
-
className: `${inputClass} w-full`,
|
|
1089
|
-
value: explicitSku?.id ?? "",
|
|
1090
|
-
onChange: (event) => {
|
|
1091
|
-
const next = new URLSearchParams(params);
|
|
1092
|
-
if (event.target.value) next.set("sku", event.target.value);
|
|
1093
|
-
else next.delete("sku");
|
|
1094
|
-
setParams(next);
|
|
1095
|
-
setQuantity(1);
|
|
1096
|
-
setImageIndex(0);
|
|
1097
|
-
},
|
|
1098
|
-
children: [/* @__PURE__ */ jsx("option", {
|
|
1099
|
-
value: "",
|
|
1100
|
-
children: "Select an option"
|
|
1101
|
-
}), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
|
|
1102
|
-
value: sku.id ?? "",
|
|
1103
|
-
children: sku.label?.trim() || sku.name?.trim()
|
|
1104
|
-
}, sku.id))]
|
|
1105
|
-
})] }),
|
|
1106
|
-
matched.isError ? /* @__PURE__ */ jsxs("div", {
|
|
1107
|
-
role: "alert",
|
|
1108
|
-
className: "text-red-700",
|
|
1109
|
-
children: [/* @__PURE__ */ jsx("p", { children: message(matched.error) }), /* @__PURE__ */ jsx("button", {
|
|
1110
|
-
type: "button",
|
|
1111
|
-
className: "min-h-11 bg-white text-red-700 underline",
|
|
1112
|
-
onClick: () => {
|
|
1113
|
-
if (matched.isError) matched.refetch();
|
|
1114
|
-
},
|
|
1115
|
-
children: "Retry availability"
|
|
1116
|
-
})]
|
|
1117
|
-
}) : null,
|
|
1118
|
-
!complete && /* @__PURE__ */ jsx("p", {
|
|
1119
|
-
id: `${fieldId}-variant-guidance`,
|
|
1120
|
-
role: "status",
|
|
1121
|
-
className: "text-sm",
|
|
1122
|
-
children: "Select all options to add this item to your cart."
|
|
1123
|
-
}),
|
|
1124
|
-
selectionVerified && attributes.length > 0 && !skuId && /* @__PURE__ */ jsx("p", {
|
|
1125
|
-
role: "status",
|
|
1126
|
-
className: "text-sm text-red-700",
|
|
1127
|
-
children: "This combination is unavailable. Choose different options."
|
|
1128
|
-
}),
|
|
1129
|
-
selectionVerified && attributes.length === 0 && !skuId && /* @__PURE__ */ jsx("p", {
|
|
1130
|
-
className: "text-sm",
|
|
1131
|
-
children: "This product is not currently available to purchase."
|
|
1132
|
-
}),
|
|
1133
|
-
complete && (product.isFetching || matched.isFetching) && /* @__PURE__ */ jsx("p", {
|
|
1134
|
-
role: "status",
|
|
1135
|
-
children: "Checking this variant…"
|
|
1136
|
-
}),
|
|
1137
|
-
selectedSku && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1138
|
-
/* @__PURE__ */ jsx("p", {
|
|
1139
|
-
className: "text-sm",
|
|
1140
|
-
"aria-live": "polite",
|
|
1141
|
-
children: available === null ? "Available to order" : available === 0 ? "This variant is out of stock" : `${available} available`
|
|
1142
|
-
}),
|
|
1143
|
-
/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
|
|
1144
|
-
htmlFor: `${fieldId}-quantity`,
|
|
1145
|
-
className: "mb-2 block font-medium",
|
|
1146
|
-
children: "Quantity"
|
|
1147
|
-
}), /* @__PURE__ */ jsx("input", {
|
|
1148
|
-
id: `${fieldId}-quantity`,
|
|
1149
|
-
type: "number",
|
|
1150
|
-
min: 1,
|
|
1151
|
-
max: available ?? void 0,
|
|
1152
|
-
step: 1,
|
|
1153
|
-
className: `${inputClass} w-28`,
|
|
1154
|
-
value: Number.isNaN(quantity) ? "" : quantity,
|
|
1155
|
-
onChange: (event) => setQuantity(event.target.valueAsNumber)
|
|
1156
|
-
})] }),
|
|
1157
|
-
/* @__PURE__ */ jsx(AddToCartButton, {
|
|
1158
|
-
sku: selectedSku,
|
|
1159
|
-
name,
|
|
1160
|
-
quantity
|
|
1161
|
-
})
|
|
1162
|
-
] }),
|
|
1163
|
-
!selectedSku && /* @__PURE__ */ jsx("button", {
|
|
1164
|
-
type: "button",
|
|
1165
|
-
className: `${buttonClass} w-full`,
|
|
1166
|
-
disabled: true,
|
|
1167
|
-
"aria-describedby": !complete ? `${fieldId}-variant-guidance` : void 0,
|
|
1168
|
-
children: "Add to cart"
|
|
1169
|
-
})
|
|
1170
|
-
]
|
|
1171
|
-
})
|
|
1504
|
+
/* @__PURE__ */ jsx(ProductDetailsTitle, {}),
|
|
1505
|
+
/* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-6" }),
|
|
1506
|
+
/* @__PURE__ */ jsx(ProductDetailsDescription, { className: "my-8" }),
|
|
1507
|
+
/* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "border-t border-neutral-200 pt-8" })
|
|
1172
1508
|
]
|
|
1173
1509
|
})]
|
|
1174
1510
|
})] });
|
|
1175
1511
|
}
|
|
1512
|
+
function ProductDetailsBackLink({ className }) {
|
|
1513
|
+
useProductDetailsInternal("ProductDetailsBackLink");
|
|
1514
|
+
const { config } = useCommerce();
|
|
1515
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1516
|
+
className: partClass(className),
|
|
1517
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
1518
|
+
className: "inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2",
|
|
1519
|
+
to: config.catalogPath,
|
|
1520
|
+
children: "← Back to shop"
|
|
1521
|
+
})
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
/** Main image with thumbnails; the selected SKU's images replace the product's when present. */
|
|
1525
|
+
function ProductDetailsGallery({ className }) {
|
|
1526
|
+
const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal("ProductDetailsGallery");
|
|
1527
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1528
|
+
className: partClass(className),
|
|
1529
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1530
|
+
className: "aspect-square overflow-hidden rounded-lg",
|
|
1531
|
+
children: /* @__PURE__ */ jsx(ProductImage, {
|
|
1532
|
+
url: images[imageIndex],
|
|
1533
|
+
name
|
|
1534
|
+
}, images[imageIndex])
|
|
1535
|
+
}), images.length > 1 && /* @__PURE__ */ jsx("div", {
|
|
1536
|
+
className: "mt-4 flex gap-3 overflow-x-auto",
|
|
1537
|
+
children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
|
|
1538
|
+
type: "button",
|
|
1539
|
+
className: `h-20 w-20 shrink-0 overflow-hidden rounded-lg border-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${imageIndex === index ? "border-neutral-900" : "border-transparent"}`,
|
|
1540
|
+
"aria-label": `View image ${index + 1}`,
|
|
1541
|
+
"aria-pressed": imageIndex === index,
|
|
1542
|
+
onClick: () => setImageIndex(index),
|
|
1543
|
+
children: /* @__PURE__ */ jsx(ProductImage, {
|
|
1544
|
+
url,
|
|
1545
|
+
name: ""
|
|
1546
|
+
})
|
|
1547
|
+
}, url))
|
|
1548
|
+
})]
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
/** The product name as the page's H1. */
|
|
1552
|
+
function ProductDetailsTitle({ className }) {
|
|
1553
|
+
const { name } = useProductDetailsInternal("ProductDetailsTitle");
|
|
1554
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1555
|
+
className: partClass(className),
|
|
1556
|
+
children: /* @__PURE__ */ jsx("h1", {
|
|
1557
|
+
className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
|
|
1558
|
+
children: name
|
|
1559
|
+
})
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
/** The verified SKU's price and compare-at price, or the product's starting price. */
|
|
1563
|
+
function ProductDetailsPrice({ className }) {
|
|
1564
|
+
const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
|
|
1565
|
+
const { config } = useCommerce();
|
|
1566
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1567
|
+
"aria-live": "polite",
|
|
1568
|
+
className: partClass("flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium", className),
|
|
1569
|
+
"data-testid": "product-price",
|
|
1570
|
+
children: [price?.value?.value != null ? money(price.value.value, price.value.currencyCode ?? config.currencyCode) : product.priceRange?.min != null ? `From ${money(product.priceRange.min, config.currencyCode)}` : "Price unavailable", price?.compareAtValue?.value != null && price.value?.value != null && price.compareAtValue.value > price.value.value && /* @__PURE__ */ jsx("del", {
|
|
1571
|
+
className: "text-lg font-normal",
|
|
1572
|
+
children: money(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
|
|
1573
|
+
})]
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
/** The verified SKU's description, or the product's. */
|
|
1577
|
+
function ProductDetailsDescription({ className }) {
|
|
1578
|
+
const { selectedSku, product } = useProductDetailsInternal("ProductDetailsDescription");
|
|
1579
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1580
|
+
className: partClass(className),
|
|
1581
|
+
children: /* @__PURE__ */ jsx("p", {
|
|
1582
|
+
className: "whitespace-pre-line text-base leading-7",
|
|
1583
|
+
children: selectedSku?.description ?? product.description
|
|
1584
|
+
})
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1176
1587
|
|
|
1177
1588
|
//#endregion
|
|
1178
|
-
export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductDetails, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce };
|
|
1589
|
+
export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce, useProductDetails, useProductDetailsOption };
|