@godaddy/gd-commerce-storefront 0.3.0 → 0.5.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/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import * as Dialog from "@radix-ui/react-dialog";
2
- import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
3
- import { Link, useParams, useSearchParams } from "react-router";
1
+ import { Children, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
4
2
  import { useQuery } from "@tanstack/react-query";
5
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import * as Dialog from "@radix-ui/react-dialog";
5
+ import { Link, useParams, useSearchParams } from "react-router";
6
6
 
7
7
  //#region src/api.ts
8
8
  var CartIdStorage = class {
@@ -98,57 +98,6 @@ function getCartSummaryTotals(order) {
98
98
  };
99
99
  }
100
100
 
101
- //#endregion
102
- //#region src/catalog-model.ts
103
- function getSingleMatchedSku(skuGroup) {
104
- const connection = skuGroup?.skus;
105
- if (connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? 0) > 1) return null;
106
- const edges = connection?.edges ?? [];
107
- const sku = edges.length === 1 ? edges[0]?.node : null;
108
- return sku?.id ? sku : null;
109
- }
110
- function getLabeledSkuOptions(skuGroup) {
111
- if (getProductAttributes(skuGroup).length > 0) return [];
112
- const connection = skuGroup?.skus;
113
- const edges = connection?.edges ?? [];
114
- if (edges.length < 2 || connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? edges.length) !== edges.length) return [];
115
- const skus = edges.flatMap((edge) => edge?.node?.id && (edge.node.label?.trim() || edge.node.name?.trim()) ? [edge.node] : []);
116
- const labels = new Set(skus.map((sku) => (sku.label?.trim() || sku.name?.trim() || "").toLowerCase()));
117
- const ids = new Set(skus.map((sku) => sku.id));
118
- return skus.length === edges.length && labels.size === skus.length && ids.size === skus.length ? skus : [];
119
- }
120
- function getAvailableInventoryQuantity(item) {
121
- const edges = item?.inventoryCounts?.edges;
122
- if (!edges || edges.length === 0) return null;
123
- return edges.find((edge) => edge?.node?.type === "AVAILABLE")?.node?.quantity ?? 0;
124
- }
125
- const imageMediaTypes = new Set(["IMAGE", "PHOTO"]);
126
- function getImageUrls(item) {
127
- return item?.mediaObjects?.edges?.filter((edge) => imageMediaTypes.has(edge?.node?.type ?? "") && edge?.node?.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
128
- }
129
- function getPrimaryImageUrl(item) {
130
- return getImageUrls(item)[0] ?? null;
131
- }
132
- function getProductAttributes(skuGroup) {
133
- return skuGroup?.attributes?.edges?.map((edge) => {
134
- const attributeNode = edge?.node;
135
- const values = attributeNode?.values?.edges?.map((valueEdge) => {
136
- const valueNode = valueEdge?.node;
137
- return {
138
- id: valueNode?.id || "",
139
- name: valueNode?.name || "",
140
- label: valueNode?.label || valueNode?.name || ""
141
- };
142
- }) ?? [];
143
- return {
144
- id: attributeNode?.id || "",
145
- name: attributeNode?.name || "",
146
- label: attributeNode?.label || attributeNode?.name || "",
147
- values
148
- };
149
- }) ?? [];
150
- }
151
-
152
101
  //#endregion
153
102
  //#region src/sample-cart.tsx
154
103
  const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
@@ -156,6 +105,7 @@ const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
156
105
  name: `Product ${index + 1}`,
157
106
  price: (index + 1) * 1e3
158
107
  }));
108
+ const noQuantities = {};
159
109
  const SampleCartContext = createContext(null);
160
110
  function useSampleCart() {
161
111
  const value = useContext(SampleCartContext);
@@ -180,7 +130,11 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
180
130
  active.current = null;
181
131
  };
182
132
  }, [session]);
183
- const quantities = state.session === session && enabled ? state.quantities : {};
133
+ const quantities = state.session === session && enabled ? state.quantities : noQuantities;
134
+ const items = useMemo(() => sampleProducts.flatMap((product) => quantities[product.key] ? [{
135
+ ...product,
136
+ quantity: quantities[product.key]
137
+ }] : []), [quantities]);
184
138
  function update(key, quantity) {
185
139
  if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) return false;
186
140
  if (quantity !== void 0 && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) return false;
@@ -200,10 +154,7 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
200
154
  }
201
155
  return /* @__PURE__ */ jsx(SampleCartContext.Provider, {
202
156
  value: {
203
- items: sampleProducts.flatMap((product) => quantities[product.key] ? [{
204
- ...product,
205
- quantity: quantities[product.key]
206
- }] : []),
157
+ items,
207
158
  add: (key) => {
208
159
  const product = sampleProducts.find((product$1) => product$1.key === key);
209
160
  if (!product || !update(key)) return false;
@@ -228,6 +179,8 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
228
179
  //#endregion
229
180
  //#region src/commerce-provider.tsx
230
181
  const CommerceContext = createContext(null);
182
+ /** Lets a mounted cart drawer register itself, so adds open it only when one exists. Not public. */
183
+ const CartDrawerRegistry = createContext(null);
231
184
  function useCommerce() {
232
185
  const context = useContext(CommerceContext);
233
186
  if (!context) throw new Error("Mount CommerceProvider once around the header and page outlet, inside the router.");
@@ -247,7 +200,7 @@ async function loadConfig(signal) {
247
200
  currencyCode: config.currencyCode
248
201
  };
249
202
  }
250
- function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {} }) {
203
+ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, onItemAdded }) {
251
204
  const config = useQuery({
252
205
  retry: false,
253
206
  queryKey: ["commerce", "configuration"],
@@ -276,10 +229,11 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
276
229
  config.refetch();
277
230
  },
278
231
  theme,
232
+ onItemAdded,
279
233
  children
280
234
  });
281
235
  }
282
- function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme }) {
236
+ function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, onItemAdded }) {
283
237
  const storageKey = `godaddy:commerce-storefront:cart:${config.cartScope}`;
284
238
  const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]);
285
239
  const ready = connection === "ready";
@@ -303,6 +257,23 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
303
257
  const queue = useRef(Promise.resolve(true));
304
258
  const alive = useRef(true);
305
259
  const opener = useRef(null);
260
+ const added = useRef(onItemAdded);
261
+ added.current = onItemAdded;
262
+ const drawers = useRef(0);
263
+ const registerDrawer = useCallback(() => {
264
+ drawers.current += 1;
265
+ if (process.env.NODE_ENV !== "production" && drawers.current === 2) console.warn("[gd-commerce-storefront] More than one cart drawer is mounted, so every add opens all of them. CommerceStorefront already mounts CartDrawer; use CommerceProvider to mount your own CartDrawerRoot.");
266
+ return () => {
267
+ drawers.current -= 1;
268
+ if (drawers.current === 0) setOpen(false);
269
+ };
270
+ }, []);
271
+ /** Opens the drawer for an add, if one is mounted. */
272
+ function openDrawer() {
273
+ if (drawers.current === 0) return;
274
+ rememberOpener();
275
+ setOpen(true);
276
+ }
306
277
  function rememberOpener() {
307
278
  opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
308
279
  }
@@ -432,7 +403,12 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
432
403
  if (!isActive()) return;
433
404
  commit(result.cart);
434
405
  setAnnouncement(`${item.name} added to your cart.`);
435
- setOpen(true);
406
+ openDrawer();
407
+ added.current?.({
408
+ name: item.name,
409
+ quantity: item.quantity,
410
+ isSample: false
411
+ });
436
412
  });
437
413
  }
438
414
  function mutateItem(itemId, method, quantity) {
@@ -501,6 +477,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
501
477
  cart,
502
478
  open,
503
479
  setOpen: (next) => {
480
+ if (next && drawers.current === 0) return;
504
481
  if (next) rememberOpener();
505
482
  setOpen(next);
506
483
  },
@@ -519,15 +496,22 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
519
496
  refresh,
520
497
  checkout
521
498
  },
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
499
+ children: [/* @__PURE__ */ jsx(CartDrawerRegistry.Provider, {
500
+ value: registerDrawer,
501
+ children: /* @__PURE__ */ jsx(SampleCartProvider, {
502
+ enabled: connection === "unbound",
503
+ announce: setAnnouncement,
504
+ onAdd: (name) => {
505
+ setAnnouncement(`${name} added to your sample cart.`);
506
+ openDrawer();
507
+ added.current?.({
508
+ name,
509
+ quantity: 1,
510
+ isSample: true
511
+ });
512
+ },
513
+ children
514
+ })
531
515
  }), /* @__PURE__ */ jsx("span", {
532
516
  className: "commerce-sr-only",
533
517
  role: "status",
@@ -537,6 +521,117 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
537
521
  });
538
522
  }
539
523
 
524
+ //#endregion
525
+ //#region src/cart-state.ts
526
+ /** Read the cart from anywhere inside `CommerceProvider`, for example to build a custom cart button. */
527
+ function useCart() {
528
+ const { cart, config, connection, hydrating } = useCommerce();
529
+ const sample = useSampleCart();
530
+ const isSample = connection === "unbound";
531
+ const sampleItems$1 = sample.items;
532
+ return useMemo(() => {
533
+ if (isSample) return {
534
+ items: sampleItems$1.map((item) => ({
535
+ id: item.key,
536
+ name: item.name,
537
+ quantity: item.quantity,
538
+ imageUrl: null,
539
+ options: [],
540
+ subtotal: item.price * item.quantity,
541
+ currency: "USD"
542
+ })),
543
+ itemCount: sampleItems$1.reduce((total, item) => total + item.quantity, 0),
544
+ subtotal: sampleItems$1.reduce((sum, item) => sum + item.price * item.quantity, 0),
545
+ currency: "USD",
546
+ isSample,
547
+ loading: false
548
+ };
549
+ const currency = cart?.totals?.total?.currencyCode ?? config.currencyCode;
550
+ const lines = cart?.lineItems ?? [];
551
+ const subtotal = cart?.totals?.subTotal?.value;
552
+ return {
553
+ items: lines.map((item) => ({
554
+ id: item.id ?? "",
555
+ name: item.name ?? "",
556
+ quantity: item.quantity ?? 0,
557
+ imageUrl: item.details?.productAssetUrl || null,
558
+ options: (item.details?.selectedOptions ?? []).map((option) => ({
559
+ attribute: option.attribute ?? "",
560
+ values: option.values ?? []
561
+ })),
562
+ subtotal: typeof item.totals?.subTotal?.value === "number" ? item.totals.subTotal.value : null,
563
+ currency: item.totals?.subTotal?.currencyCode ?? currency
564
+ })),
565
+ itemCount: lines.reduce((total, item) => total + (item.quantity ?? 0), 0),
566
+ subtotal: typeof subtotal === "number" ? subtotal : null,
567
+ currency: cart?.totals?.subTotal?.currencyCode ?? currency,
568
+ isSample,
569
+ loading: hydrating
570
+ };
571
+ }, [
572
+ cart,
573
+ config.currencyCode,
574
+ hydrating,
575
+ isSample,
576
+ sampleItems$1
577
+ ]);
578
+ }
579
+
580
+ //#endregion
581
+ //#region src/catalog-model.ts
582
+ function getSingleMatchedSku(skuGroup) {
583
+ const connection = skuGroup?.skus;
584
+ if (connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? 0) > 1) return null;
585
+ const edges = connection?.edges ?? [];
586
+ const sku = edges.length === 1 ? edges[0]?.node : null;
587
+ return sku?.id ? sku : null;
588
+ }
589
+ function getLabeledSkuOptions(skuGroup) {
590
+ if (getProductAttributes(skuGroup).length > 0) return [];
591
+ const connection = skuGroup?.skus;
592
+ const edges = connection?.edges ?? [];
593
+ if (edges.length < 2 || connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? edges.length) !== edges.length) return [];
594
+ const skus = edges.flatMap((edge) => edge?.node?.id && (edge.node.label?.trim() || edge.node.name?.trim()) ? [edge.node] : []);
595
+ const labels = new Set(skus.map((sku) => (sku.label?.trim() || sku.name?.trim() || "").toLowerCase()));
596
+ const ids = new Set(skus.map((sku) => sku.id));
597
+ return skus.length === edges.length && labels.size === skus.length && ids.size === skus.length ? skus : [];
598
+ }
599
+ function getAvailableInventoryQuantity(item) {
600
+ const edges = item?.inventoryCounts?.edges;
601
+ if (!edges || edges.length === 0) return null;
602
+ return edges.find((edge) => edge?.node?.type === "AVAILABLE")?.node?.quantity ?? 0;
603
+ }
604
+ const imageMediaTypes = new Set(["IMAGE", "PHOTO"]);
605
+ function getImageUrls(item) {
606
+ return item?.mediaObjects?.edges?.filter((edge) => imageMediaTypes.has(edge?.node?.type ?? "") && edge?.node?.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
607
+ }
608
+ function getPrimaryImageUrl(item) {
609
+ return getImageUrls(item)[0] ?? null;
610
+ }
611
+ function getProductAttributes(skuGroup) {
612
+ return skuGroup?.attributes?.edges?.map((edge) => {
613
+ const attributeNode = edge?.node;
614
+ const values = attributeNode?.values?.edges?.map((valueEdge) => {
615
+ const valueNode = valueEdge?.node;
616
+ return {
617
+ id: valueNode?.id || "",
618
+ name: valueNode?.name || "",
619
+ label: valueNode?.label || valueNode?.name || ""
620
+ };
621
+ }) ?? [];
622
+ return {
623
+ id: attributeNode?.id || "",
624
+ name: attributeNode?.name || "",
625
+ label: attributeNode?.label || attributeNode?.name || "",
626
+ values
627
+ };
628
+ }) ?? [];
629
+ }
630
+ /** The product's display name: its label, then its name. */
631
+ function getProductName(skuGroup) {
632
+ return skuGroup.label ?? skuGroup.name ?? "Product";
633
+ }
634
+
540
635
  //#endregion
541
636
  //#region src/storefront-surface.tsx
542
637
  function StorefrontSurface({ children, className = "" }) {
@@ -572,9 +667,8 @@ function CommerceStatus() {
572
667
  const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-commerce-accent px-5 py-2 text-sm font-semibold text-commerce-on-accent hover:bg-commerce-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-700 disabled:hover:bg-neutral-200";
573
668
  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";
574
669
  function CartButton() {
575
- const { cart, setOpen, open, connection } = useCommerce();
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;
670
+ const { setOpen, open, connection } = useCommerce();
671
+ const { itemCount } = useCart();
578
672
  return /* @__PURE__ */ jsx(StorefrontSurface, {
579
673
  className: "commerce-inline",
580
674
  children: /* @__PURE__ */ jsxs("button", {
@@ -586,16 +680,32 @@ function CartButton() {
586
680
  onClick: () => setOpen(true),
587
681
  children: ["Cart ", /* @__PURE__ */ jsx("span", {
588
682
  className: "rounded-full bg-white px-2 py-0.5 text-xs text-neutral-900",
589
- children: count
683
+ children: itemCount
590
684
  })]
591
685
  })
592
686
  });
593
687
  }
688
+ /** Whether tracked stock is used up. Oversold SKUs report a negative count. */
689
+ const isSoldOut = (available) => available !== null && available <= 0;
690
+ /** Why `quantity` cannot be added, or `null` when it can. `available` is `null` when stock is not tracked. */
691
+ function quantityProblem(quantity, available) {
692
+ if (!Number.isInteger(quantity) || quantity < 1) return "Enter a whole number of 1 or more.";
693
+ if (available !== null && available > 0 && quantity > available) return `Only ${available} available.`;
694
+ return null;
695
+ }
594
696
  function AddToCartButton({ sku, name, quantity = 1 }) {
595
697
  const { addItem, pending, hydrating, error, open, connection } = useCommerce();
596
698
  const [adding, setAdding] = useState(false);
699
+ const reasonId = useId();
597
700
  const available = getAvailableInventoryQuantity(sku);
598
- const disabled = connection !== "ready" || !sku.id || adding || hydrating || available === 0 || !Number.isInteger(quantity) || quantity < 1 || available !== null && quantity > available;
701
+ const soldOut = isSoldOut(available);
702
+ let reason = null;
703
+ if (connection === "unbound") reason = "Connect a store to add items to the cart.";
704
+ else if (connection === "error") reason = "The store could not be reached.";
705
+ else if (connection !== "ready") reason = "Connecting to the store…";
706
+ else if (!sku.id) reason = "This item cannot be added to the cart.";
707
+ else if (!soldOut) reason = hydrating ? "Loading your cart…" : quantityProblem(quantity, available);
708
+ const disabled = adding || soldOut || reason !== null;
599
709
  async function handleAdd() {
600
710
  if (disabled || pending || !sku.id) return;
601
711
  setAdding(true);
@@ -611,394 +721,907 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
611
721
  }
612
722
  return /* @__PURE__ */ jsxs(StorefrontSurface, {
613
723
  className: "commerce-inline",
614
- children: [/* @__PURE__ */ jsx("button", {
615
- type: "button",
616
- className: `${buttonClass} w-full`,
617
- disabled,
618
- "aria-disabled": disabled || pending,
619
- "aria-busy": adding,
620
- onClick: () => void handleAdd(),
621
- children: available === 0 ? "Out of stock" : adding ? "Adding…" : "Add to cart"
622
- }), error && !open && /* @__PURE__ */ jsx("p", {
623
- role: "alert",
624
- className: "mt-3 bg-white text-sm text-red-700",
625
- children: error
626
- })]
724
+ children: [
725
+ /* @__PURE__ */ jsx("button", {
726
+ type: "button",
727
+ className: `${buttonClass} w-full`,
728
+ disabled,
729
+ "aria-disabled": disabled || pending,
730
+ "aria-busy": adding,
731
+ "aria-describedby": reason ? reasonId : void 0,
732
+ onClick: () => void handleAdd(),
733
+ children: soldOut ? "Out of stock" : adding ? "Adding…" : "Add to cart"
734
+ }),
735
+ reason && /* @__PURE__ */ jsx("span", {
736
+ id: reasonId,
737
+ hidden: true,
738
+ children: reason
739
+ }),
740
+ error && !open && /* @__PURE__ */ jsx("p", {
741
+ role: "alert",
742
+ className: "mt-3 bg-white text-sm text-red-700",
743
+ children: error
744
+ })
745
+ ]
627
746
  });
628
747
  }
629
- function CartDrawer() {
630
- const { config, connection, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity: changeLiveQuantity, removeItem: removeLiveItem, refresh, checkout } = useCommerce();
748
+
749
+ //#endregion
750
+ //#region src/part.ts
751
+ /** Whether `children` renders anything, so `{custom && <Part />}` with `custom` false falls back to the default. */
752
+ const hasChildren = (children) => Children.toArray(children).some((child) => child !== "");
753
+ const cx = (...classes) => classes.filter(Boolean).join(" ");
754
+ /** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
755
+ const partClass = (...classes) => cx("commerce-part", ...classes);
756
+ /**
757
+ * Counts the parts mounted under a root, by key. Parts register in an effect, and a parent's effects run after its
758
+ * children's, so the root can read `has` in its own effect. `onChange` must be stable; it runs after each change.
759
+ */
760
+ function usePartRegistry(onChange) {
761
+ const counts = useRef(/* @__PURE__ */ new Map());
762
+ const has = useCallback((key) => counts.current.has(key), []);
763
+ const register = useCallback((key) => {
764
+ counts.current.set(key, (counts.current.get(key) ?? 0) + 1);
765
+ onChange?.(has);
766
+ return () => {
767
+ const count = (counts.current.get(key) ?? 1) - 1;
768
+ if (count > 0) counts.current.set(key, count);
769
+ else counts.current.delete(key);
770
+ onChange?.(has);
771
+ };
772
+ }, [has, onChange]);
773
+ return useMemo(() => ({
774
+ register,
775
+ has
776
+ }), [register, has]);
777
+ }
778
+
779
+ //#endregion
780
+ //#region src/cart-parts.tsx
781
+ const CartContext = createContext(null);
782
+ const CartDrawerContext = createContext(null);
783
+ function useCartInternal(part) {
784
+ const context = useContext(CartContext);
785
+ if (!context) throw new Error(`${part} must be rendered inside <CartRoot> or <CartDrawerRoot>.`);
786
+ return context;
787
+ }
788
+ function CartProvider({ children }) {
789
+ const commerce = useCommerce();
631
790
  const sample = useSampleCart();
791
+ const state = useCart();
792
+ const [action, setAction] = useState(null);
793
+ const locked = commerce.pending || commerce.hydrating || action !== null;
794
+ const value = {
795
+ ...state,
796
+ hydrating: commerce.hydrating,
797
+ error: commerce.error,
798
+ storageWarning: commerce.storageWarning,
799
+ catalogPath: commerce.config.catalogPath,
800
+ checkoutEnabled: !!commerce.config.checkoutSuccessPath,
801
+ checkoutReady: commerce.cart?.totals?.total?.value != null,
802
+ action,
803
+ locked,
804
+ run: (next, operation) => {
805
+ if (locked) return;
806
+ setAction(next);
807
+ operation().finally(() => setAction(null));
808
+ },
809
+ changeQuantity: state.isSample ? sample.changeQuantity : commerce.changeQuantity,
810
+ removeItem: state.isSample ? sample.remove : commerce.removeItem,
811
+ refresh: commerce.refresh,
812
+ checkout: commerce.checkout,
813
+ close: () => commerce.setOpen(false)
814
+ };
815
+ return /* @__PURE__ */ jsx(CartContext.Provider, {
816
+ value,
817
+ children
818
+ });
819
+ }
820
+ /**
821
+ * Provides the cart to cart parts on a page, such as a `/cart` route. While no store is connected it shows the sample
822
+ * cart. Use `CartDrawerRoot` for the drawer.
823
+ */
824
+ function CartRoot({ children, className }) {
825
+ const { connection } = useCommerce();
632
826
  const isSample = connection === "unbound";
633
- const changeQuantity = isSample ? sample.changeQuantity : changeLiveQuantity;
634
- const removeItem = isSample ? sample.remove : removeLiveItem;
635
- const [drawerAction, setDrawerAction] = useState(null);
636
- const checkingOut = drawerAction === "checkout";
637
- const locked = pending || hydrating || drawerAction !== null;
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
- }));
655
- const summary = getCartSummaryTotals(cart);
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;
658
- async function handleAction(action, operation) {
659
- if (locked) return;
660
- setDrawerAction(action);
661
- try {
662
- await operation();
663
- } finally {
664
- setDrawerAction(null);
665
- }
666
- }
827
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" || isSample ? /* @__PURE__ */ jsx(CartProvider, { children: /* @__PURE__ */ jsx("section", {
828
+ className: partClass(className),
829
+ "data-commerce-source": isSample ? "sample" : void 0,
830
+ children
831
+ }) }) : /* @__PURE__ */ jsx("div", {
832
+ className: partClass(className),
833
+ children: /* @__PURE__ */ jsx(CommerceStatus, {})
834
+ }) });
835
+ }
836
+ /**
837
+ * The cart dialog. Opens when an item is added or a `CartButton` is pressed, and returns focus when it closes.
838
+ * The panel sits at the right edge at full height; `mr-auto` on it moves it to the left. Mount one per provider,
839
+ * in place of `CartDrawer`.
840
+ */
841
+ function CartDrawerRoot({ children, className }) {
842
+ const { open, setOpen, restoreFocus, connection } = useCommerce();
843
+ const registerDrawer = useContext(CartDrawerRegistry);
844
+ useEffect(() => registerDrawer?.(), [registerDrawer]);
845
+ const [described, setDescribed] = useState(false);
846
+ const { register, has } = usePartRegistry(useCallback((registered) => setDescribed(registered("description")), []));
847
+ useEffect(() => {
848
+ setDescribed(has("description"));
849
+ }, [has]);
850
+ const drawer = useMemo(() => ({ register }), [register]);
667
851
  return /* @__PURE__ */ jsx(Dialog.Root, {
668
852
  open,
669
853
  onOpenChange: setOpen,
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,
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",
673
- onCloseAutoFocus: (event) => {
674
- event.preventDefault();
675
- restoreFocus();
676
- },
677
- children: [
678
- /* @__PURE__ */ jsxs("div", {
679
- className: "flex items-start justify-between border-b border-neutral-200 p-6",
680
- children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Dialog.Title, {
681
- className: "text-2xl font-semibold",
682
- children: isSample ? "Sample cart" : "Your cart"
683
- }), /* @__PURE__ */ jsx(Dialog.Description, {
684
- className: "mt-1 text-sm text-neutral-600",
685
- children: isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout."
686
- })] }), /* @__PURE__ */ jsx(Dialog.Close, {
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",
688
- "aria-label": "Close cart",
689
- children: "✕"
690
- })]
691
- }),
692
- /* @__PURE__ */ jsxs("div", {
693
- className: "flex-1 overflow-y-auto p-6",
694
- children: [
695
- storageWarning && /* @__PURE__ */ jsx("p", {
696
- role: "status",
697
- className: "mb-4 text-sm text-amber-800",
698
- children: storageWarning
699
- }),
700
- error && /* @__PURE__ */ jsxs("div", {
701
- role: "alert",
702
- className: "mb-4 rounded-lg bg-red-50 p-4 text-red-800",
703
- children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
704
- type: "button",
705
- className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
706
- disabled: drawerAction === "refresh",
707
- "aria-disabled": locked,
708
- "aria-busy": drawerAction === "refresh",
709
- onClick: () => void handleAction("refresh", refresh),
710
- children: "Refresh cart"
711
- })]
712
- }),
713
- hydrating && /* @__PURE__ */ jsx("p", {
714
- role: "status",
715
- children: "Loading your cart…"
716
- }),
717
- !hydrating && !items.length && /* @__PURE__ */ jsxs("div", {
718
- className: "py-16 text-center",
719
- children: [
720
- /* @__PURE__ */ jsx("p", {
721
- className: "text-xl font-medium",
722
- children: "Your cart is empty"
723
- }),
724
- /* @__PURE__ */ jsx("p", {
725
- className: "mt-2 text-neutral-600",
726
- children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
727
- }),
728
- /* @__PURE__ */ jsx(Link, {
729
- className: `${buttonClass} mt-6`,
730
- to: config.catalogPath,
731
- onClick: () => setOpen(false),
732
- children: "Browse products"
733
- })
734
- ]
735
- }),
736
- /* @__PURE__ */ jsx("ul", {
737
- className: "divide-y divide-neutral-200",
738
- children: items.map((item) => /* @__PURE__ */ jsxs("li", {
739
- className: "py-5 first:pt-0",
740
- "data-testid": "cart-item",
741
- children: [/* @__PURE__ */ jsxs("div", {
742
- className: "flex gap-4",
743
- children: [item.image && /* @__PURE__ */ jsx("img", {
744
- src: item.image,
745
- alt: "",
746
- className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
747
- }), /* @__PURE__ */ jsxs("div", {
748
- className: "min-w-0 flex-1",
749
- children: [
750
- /* @__PURE__ */ jsx("h3", {
751
- className: "font-semibold",
752
- children: item.name
753
- }),
754
- item.options?.map((option) => /* @__PURE__ */ jsxs("p", {
755
- className: "text-sm text-neutral-600",
756
- children: [
757
- option.attribute,
758
- ": ",
759
- option.values?.join(", ")
760
- ]
761
- }, `${option.attribute}:${option.values?.join(",")}`)),
762
- /* @__PURE__ */ jsx("p", {
763
- className: "mt-1 text-sm",
764
- children: typeof item.subtotal === "number" ? money(item.subtotal, item.currency ?? currency) : "Price unavailable"
765
- })
766
- ]
767
- })]
768
- }), /* @__PURE__ */ jsxs("div", {
769
- className: "mt-4 flex items-center justify-between gap-2",
770
- children: [/* @__PURE__ */ jsxs("div", {
771
- className: "flex items-center rounded-lg border border-neutral-300",
772
- children: [
773
- /* @__PURE__ */ jsx("button", {
774
- type: "button",
775
- className: "min-h-11 min-w-11 rounded-lg bg-white text-neutral-900 focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-600",
776
- "aria-label": `Decrease quantity of ${item.name}`,
777
- disabled: drawerAction === `decrease:${item.id}` || !item.id || (item.quantity ?? 0) <= 1,
778
- "aria-disabled": locked || !item.id || (item.quantity ?? 0) <= 1,
779
- "aria-busy": drawerAction === `decrease:${item.id}`,
780
- onClick: () => void handleAction(`decrease:${item.id}`, () => changeQuantity(item.id ?? "", (item.quantity ?? 1) - 1)),
781
- children: "−"
782
- }),
783
- /* @__PURE__ */ jsxs("span", {
784
- className: "min-w-8 text-center",
785
- children: [/* @__PURE__ */ jsx("span", {
786
- className: "sr-only",
787
- children: "Quantity: "
788
- }), item.quantity]
789
- }),
790
- /* @__PURE__ */ jsx("button", {
791
- type: "button",
792
- className: "min-h-11 min-w-11 rounded-lg bg-white text-neutral-900 focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-600",
793
- "aria-label": `Increase quantity of ${item.name}`,
794
- disabled: drawerAction === `increase:${item.id}` || !item.id,
795
- "aria-disabled": locked || !item.id,
796
- "aria-busy": drawerAction === `increase:${item.id}`,
797
- onClick: () => void handleAction(`increase:${item.id}`, () => changeQuantity(item.id ?? "", (item.quantity ?? 1) + 1)),
798
- children: "+"
799
- })
800
- ]
801
- }), /* @__PURE__ */ jsx("button", {
802
- type: "button",
803
- className: "min-h-11 bg-white px-2 text-sm text-neutral-900 underline focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:text-neutral-600",
804
- "aria-label": `Remove ${item.name}`,
805
- disabled: drawerAction === `remove:${item.id}` || !item.id,
806
- "aria-disabled": locked || !item.id,
807
- "aria-busy": drawerAction === `remove:${item.id}`,
808
- onClick: () => void handleAction(`remove:${item.id}`, () => removeItem(item.id ?? "")),
809
- children: "Remove"
810
- })]
811
- })]
812
- }, item.id))
813
- })
814
- ]
815
- }),
816
- items.length > 0 && /* @__PURE__ */ jsxs("div", {
817
- className: "border-t border-neutral-200 p-6",
818
- children: [
819
- /* @__PURE__ */ jsx("dl", {
820
- className: "space-y-2 text-sm",
821
- children: (isSample || typeof cart?.totals?.subTotal?.value === "number") && /* @__PURE__ */ jsxs("div", {
822
- className: "flex justify-between",
823
- children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(subtotal, isSample ? "USD" : cart?.totals?.subTotal?.currencyCode ?? currency) })]
824
- })
825
- }),
826
- !isSample && /* @__PURE__ */ jsx("p", {
827
- className: "commerce-cart-checkout-adjustments-note mt-3 text-sm text-neutral-600",
828
- children: "Shipping, taxes, and discounts are calculated at checkout."
829
- }),
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", {
834
- type: "button",
835
- className: `${buttonClass} w-full`,
836
- disabled: checkingOut || hydrating || cart?.totals?.total?.value == null,
837
- "aria-disabled": locked || cart?.totals?.total?.value == null,
838
- "aria-busy": checkingOut,
839
- onClick: () => void handleAction("checkout", checkout),
840
- children: checkingOut ? "Opening checkout…" : "Proceed to Checkout"
841
- }) : /* @__PURE__ */ jsx("p", {
842
- className: "text-sm text-neutral-600",
843
- children: "Checkout is not available yet."
844
- })
845
- ]
854
+ 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__ */ jsx("div", {
855
+ className: "pointer-events-none fixed inset-0 z-50 flex justify-end",
856
+ children: /* @__PURE__ */ jsx(Dialog.Content, {
857
+ "data-commerce-source": connection === "unbound" ? "sample" : void 0,
858
+ ...described ? {} : { "aria-describedby": void 0 },
859
+ className: partClass("pointer-events-auto", className),
860
+ onCloseAutoFocus: (event) => {
861
+ event.preventDefault();
862
+ restoreFocus();
863
+ },
864
+ children: /* @__PURE__ */ jsx(CartDrawerContext.Provider, {
865
+ value: drawer,
866
+ children: /* @__PURE__ */ jsx(CartProvider, { children })
846
867
  })
847
- ]
868
+ })
848
869
  })] }) })
849
870
  });
850
871
  }
851
-
852
- //#endregion
853
- //#region src/catalog.tsx
854
- function ProductImage({ url, name, className = "" }) {
855
- const [failed, setFailed] = useState(false);
856
- return url && !failed ? /* @__PURE__ */ jsx("img", {
857
- src: url,
858
- alt: name,
859
- loading: "lazy",
860
- className: `h-full w-full object-cover ${className}`,
861
- onError: () => setFailed(true)
862
- }) : /* @__PURE__ */ jsx("div", {
863
- className: `flex h-full min-h-44 items-center justify-center p-6 text-center ${className}`,
864
- role: "img",
865
- "aria-label": `${name}: image unavailable`,
866
- children: "No image available"
872
+ /** "Your cart", or "Sample cart" before a store is connected, as an H2. In the drawer it names the dialog. */
873
+ function CartTitle({ className }) {
874
+ const { isSample } = useCartInternal("CartTitle");
875
+ const Heading = useContext(CartDrawerContext) ? Dialog.Title : "h2";
876
+ return /* @__PURE__ */ jsx("div", {
877
+ className: partClass(className),
878
+ children: /* @__PURE__ */ jsx(Heading, {
879
+ className: "text-2xl font-semibold",
880
+ children: isSample ? "Sample cart" : "Your cart"
881
+ })
867
882
  });
868
883
  }
869
- function ProductCard({ product }) {
870
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: /* @__PURE__ */ jsx(ProductCardContent, { product }) });
871
- }
872
- function ProductCardContent({ product }) {
873
- const { config } = useCommerce();
874
- const name = product.label ?? product.name ?? "Product";
875
- const href = `${config.productPath}/${encodeURIComponent(product.id ?? "")}`;
876
- const selectedSku = getSingleMatchedSku(product);
877
- selectedSku?.id;
878
- const hasVariants = getProductAttributes(product).length > 0;
879
- const min = product.priceRange?.min;
880
- const max = product.priceRange?.max;
881
- return /* @__PURE__ */ jsxs("article", {
882
- className: "group flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200",
883
- children: [/* @__PURE__ */ jsx(Link, {
884
- to: href,
885
- tabIndex: -1,
886
- "aria-hidden": "true",
887
- className: "block aspect-square overflow-hidden",
888
- children: /* @__PURE__ */ jsx(ProductImage, {
889
- url: getPrimaryImageUrl(product),
890
- name,
891
- className: "motion-safe:transition-transform motion-safe:duration-200 motion-safe:group-hover:scale-105"
892
- })
893
- }), /* @__PURE__ */ jsxs("div", {
894
- className: "flex flex-1 flex-col p-6",
895
- children: [
896
- /* @__PURE__ */ jsx("h2", {
897
- className: "break-words text-2xl font-semibold leading-tight",
898
- children: /* @__PURE__ */ jsx(Link, {
899
- className: "rounded text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
900
- to: href,
901
- children: name
902
- })
903
- }),
904
- product.description && /* @__PURE__ */ jsx("p", {
905
- className: "mt-3 line-clamp-3 text-sm leading-6",
906
- children: product.description
907
- }),
908
- /* @__PURE__ */ jsx("div", {
909
- className: "mt-auto pt-6",
910
- children: /* @__PURE__ */ jsxs("div", {
911
- className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
912
- children: [/* @__PURE__ */ jsx("p", {
913
- className: "min-w-0 flex-1 text-lg font-semibold leading-7",
914
- children: typeof min === "number" ? `${money(min, config.currencyCode)}${typeof max === "number" && max !== min ? ` – ${money(max, config.currencyCode)}` : ""}` : "Price unavailable"
915
- }), /* @__PURE__ */ jsx("div", {
916
- className: "max-w-full",
917
- children: !hasVariants && selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
918
- sku: selectedSku,
919
- name
920
- }) : /* @__PURE__ */ jsx(Link, {
921
- className: buttonClass,
922
- to: href,
923
- children: hasVariants ? "Choose options" : "View product"
924
- })
925
- })]
926
- })
927
- })
928
- ]
929
- })]
884
+ /** One line about the cart. In the drawer it describes the dialog. */
885
+ function CartDescription({ className }) {
886
+ const { isSample } = useCartInternal("CartDescription");
887
+ const drawer = useContext(CartDrawerContext);
888
+ const register = drawer?.register;
889
+ useEffect(() => register?.("description"), [register]);
890
+ const text = isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout.";
891
+ return /* @__PURE__ */ jsx("div", {
892
+ className: partClass(className),
893
+ children: drawer ? /* @__PURE__ */ jsx(Dialog.Description, {
894
+ className: "text-sm text-neutral-600",
895
+ children: text
896
+ }) : /* @__PURE__ */ jsx("p", {
897
+ className: "text-sm text-neutral-600",
898
+ children: text
899
+ })
930
900
  });
931
901
  }
932
- function Catalog(props) {
933
- const { connection } = useCommerce();
934
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : connection === "unbound" && props.sampleProducts ? /* @__PURE__ */ jsx(SampleCatalog, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
935
- }
936
- function CatalogContent({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
937
- const { config } = useCommerce();
938
- const [params, setParams] = useSearchParams();
939
- const after = params.get("after") ?? "";
940
- const urlParams = new URLSearchParams({ first: "6" });
941
- if (after) urlParams.set("after", after);
942
- const products = useQuery({
943
- retry: false,
944
- queryKey: [
945
- "commerce",
946
- config.cartScope,
947
- "products",
948
- after
949
- ],
950
- queryFn: ({ signal }) => request(`/products?${urlParams}`, {
951
- signal,
952
- headers: { "X-Commerce-Scope": config.cartScope }
902
+ /** The drawer's close button. Renders nothing outside the drawer. */
903
+ function CartClose({ className }) {
904
+ useCartInternal("CartClose");
905
+ if (!useContext(CartDrawerContext)) return null;
906
+ return /* @__PURE__ */ jsx("div", {
907
+ className: partClass(className),
908
+ children: /* @__PURE__ */ jsx(Dialog.Close, {
909
+ 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",
910
+ "aria-label": "Close cart",
911
+ children: "✕"
953
912
  })
954
913
  });
955
- function goToPage(cursor) {
956
- const next = new URLSearchParams(params);
957
- if (cursor) next.set("after", cursor);
958
- else next.delete("after");
959
- setParams(next);
960
- }
961
- const nodes = products.data?.skuGroups?.edges?.flatMap((edge) => edge?.node?.id ? [edge.node] : []) ?? [];
962
- const pageInfo = products.data?.skuGroups?.pageInfo;
963
- return /* @__PURE__ */ jsxs("section", { children: [
964
- showHeader && /* @__PURE__ */ jsxs("header", {
965
- className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8",
966
- children: [/* @__PURE__ */ jsx("h1", {
967
- className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
968
- children: title
969
- }), /* @__PURE__ */ jsx("p", {
970
- className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
971
- children: description
914
+ }
915
+ /** A storage warning, and a cart error with a refresh button. Renders nothing when there are neither. */
916
+ function CartNotices({ className }) {
917
+ const { storageWarning, error, action, locked, run, refresh } = useCartInternal("CartNotices");
918
+ if (!storageWarning && !error) return null;
919
+ return /* @__PURE__ */ jsx("div", {
920
+ className: partClass(className),
921
+ children: /* @__PURE__ */ jsxs("div", {
922
+ className: "flex flex-col gap-4",
923
+ children: [storageWarning && /* @__PURE__ */ jsx("p", {
924
+ role: "status",
925
+ className: "text-sm text-amber-800",
926
+ children: storageWarning
927
+ }), error && /* @__PURE__ */ jsxs("div", {
928
+ role: "alert",
929
+ className: "rounded-lg bg-red-50 p-4 text-red-800",
930
+ children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
931
+ type: "button",
932
+ className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
933
+ disabled: action === "refresh",
934
+ "aria-disabled": locked,
935
+ "aria-busy": action === "refresh",
936
+ onClick: () => run("refresh", refresh),
937
+ children: "Refresh cart"
938
+ })]
972
939
  })]
973
- }),
974
- /* @__PURE__ */ jsx("div", {
975
- className: "border-b border-neutral-200 py-6 sm:py-8",
976
- children: /* @__PURE__ */ jsx("p", {
977
- className: "text-sm",
978
- role: "status",
979
- children: products.isPending ? "Loading products…" : products.isError ? "Products unavailable" : `Showing ${nodes.length} product${nodes.length === 1 ? "" : "s"}`
940
+ })
941
+ });
942
+ }
943
+ const CartItemsContext = createContext(null);
944
+ const CartItemsRegistry = createContext(null);
945
+ const CartItemContext = createContext(null);
946
+ function useCartItemInternal(part) {
947
+ const item = useContext(CartItemContext);
948
+ if (!item) throw new Error(`${part} must be rendered inside <CartItemRoot>.`);
949
+ return item;
950
+ }
951
+ /** Read the line from inside `CartItemRoot`, for custom item parts. */
952
+ function useCartItem() {
953
+ return useCartItemInternal("useCartItem");
954
+ }
955
+ /**
956
+ * The cart lines as a list. `className` goes on the list element whose children are the items. While the saved cart
957
+ * loads, a status message renders just before it.
958
+ */
959
+ function CartItems({ className, children }) {
960
+ const { items, hydrating } = useCartInternal("CartItems");
961
+ const template = hasChildren(children) ? children : packagedItem;
962
+ const registry = usePartRegistry();
963
+ const { has } = registry;
964
+ useEffect(() => {
965
+ if (process.env.NODE_ENV === "production" || items.length === 0 || has("item")) return;
966
+ console.warn("[gd-commerce-storefront] The <CartItems> template renders no <CartItemRoot>, so the cart list has no items. Wrap each line in <CartItemRoot>.");
967
+ }, [items.length, has]);
968
+ return /* @__PURE__ */ jsxs(CartItemsRegistry.Provider, {
969
+ value: registry.register,
970
+ children: [hydrating && /* @__PURE__ */ jsx("p", {
971
+ role: "status",
972
+ children: "Loading your cart…"
973
+ }), /* @__PURE__ */ jsx("div", {
974
+ role: "list",
975
+ className: partClass(className),
976
+ children: items.map((item, index) => /* @__PURE__ */ jsx(CartItemsContext.Provider, {
977
+ value: item,
978
+ children: template
979
+ }, item.id || `line-${index}`))
980
+ })]
981
+ });
982
+ }
983
+ /** One cart line. Render it as the child of `CartItems`, which renders it once for every line. */
984
+ function CartItemRoot({ children, className }) {
985
+ const item = useContext(CartItemsContext);
986
+ const register = useContext(CartItemsRegistry);
987
+ useEffect(() => register?.("item"), [register]);
988
+ if (!item) throw new Error("CartItemRoot must be rendered inside <CartItems>.");
989
+ return /* @__PURE__ */ jsx(CartItemContext.Provider, {
990
+ value: item,
991
+ children: /* @__PURE__ */ jsx("div", {
992
+ role: "listitem",
993
+ className: partClass(className),
994
+ "data-testid": "cart-item",
995
+ children: /* @__PURE__ */ jsx(CartItemsContext.Provider, {
996
+ value: null,
997
+ children
980
998
  })
981
- }),
982
- products.isPending && /* @__PURE__ */ jsx("div", {
983
- "aria-hidden": "true",
984
- className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3",
999
+ })
1000
+ });
1001
+ }
1002
+ /** The line's product image. Renders nothing without one. */
1003
+ function CartItemImage({ className }) {
1004
+ const { imageUrl } = useCartItemInternal("CartItemImage");
1005
+ if (!imageUrl) return null;
1006
+ return /* @__PURE__ */ jsx("div", {
1007
+ className: partClass(className),
1008
+ children: /* @__PURE__ */ jsx("img", {
1009
+ src: imageUrl,
1010
+ alt: "",
1011
+ className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
1012
+ })
1013
+ });
1014
+ }
1015
+ /** The product name as an H3. */
1016
+ function CartItemName({ className }) {
1017
+ const { name } = useCartItemInternal("CartItemName");
1018
+ return /* @__PURE__ */ jsx("div", {
1019
+ className: partClass(className),
1020
+ children: /* @__PURE__ */ jsx("h3", {
1021
+ className: "font-semibold",
1022
+ children: name
1023
+ })
1024
+ });
1025
+ }
1026
+ /** The selected options, one per line, such as "Color: Blue". Renders nothing without options. */
1027
+ function CartItemOptions({ className }) {
1028
+ const { options } = useCartItemInternal("CartItemOptions");
1029
+ if (!options.length) return null;
1030
+ return /* @__PURE__ */ jsx("div", {
1031
+ className: partClass(className),
1032
+ children: options.map((option) => /* @__PURE__ */ jsxs("p", {
1033
+ className: "text-sm text-neutral-600",
985
1034
  children: [
986
- 0,
987
- 1,
988
- 2
989
- ].map((item) => /* @__PURE__ */ jsxs("div", {
990
- className: "overflow-hidden rounded-lg border border-neutral-200 motion-safe:animate-pulse",
991
- children: [/* @__PURE__ */ jsx("div", { className: "aspect-square bg-neutral-100" }), /* @__PURE__ */ jsxs("div", {
992
- className: "space-y-4 p-6",
993
- children: [
994
- /* @__PURE__ */ jsx("div", { className: "h-6 w-2/3 rounded bg-neutral-100" }),
995
- /* @__PURE__ */ jsx("div", { className: "h-4 rounded bg-neutral-100" }),
996
- /* @__PURE__ */ jsx("div", { className: "h-11 rounded bg-neutral-100" })
997
- ]
998
- })]
999
- }, item))
1000
- }),
1001
- products.isError && /* @__PURE__ */ jsxs("div", {
1035
+ option.attribute,
1036
+ ": ",
1037
+ option.values.join(", ")
1038
+ ]
1039
+ }, `${option.attribute}:${option.values.join(",")}`))
1040
+ });
1041
+ }
1042
+ /** The line total. */
1043
+ function CartItemPrice({ className }) {
1044
+ const { subtotal, currency } = useCartItemInternal("CartItemPrice");
1045
+ return /* @__PURE__ */ jsx("div", {
1046
+ className: partClass(className),
1047
+ children: /* @__PURE__ */ jsx("p", {
1048
+ className: "text-sm",
1049
+ children: subtotal === null ? "Price unavailable" : money(subtotal, currency)
1050
+ })
1051
+ });
1052
+ }
1053
+ const stepperButtonClass = "min-h-11 min-w-11 rounded-lg bg-white text-neutral-900 focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-600";
1054
+ /** Decrease and increase buttons around the line's quantity. */
1055
+ function CartItemQuantity({ className }) {
1056
+ const { id, name, quantity } = useCartItemInternal("CartItemQuantity");
1057
+ const { action, locked, run, changeQuantity } = useCartInternal("CartItemQuantity");
1058
+ return /* @__PURE__ */ jsx("div", {
1059
+ className: partClass(className),
1060
+ children: /* @__PURE__ */ jsxs("div", {
1061
+ className: "flex items-center rounded-lg border border-neutral-300",
1062
+ children: [
1063
+ /* @__PURE__ */ jsx("button", {
1064
+ type: "button",
1065
+ className: stepperButtonClass,
1066
+ "aria-label": `Decrease quantity of ${name}`,
1067
+ disabled: action === `decrease:${id}` || !id || quantity <= 1,
1068
+ "aria-disabled": locked || !id || quantity <= 1,
1069
+ "aria-busy": action === `decrease:${id}`,
1070
+ onClick: () => run(`decrease:${id}`, () => changeQuantity(id, quantity - 1)),
1071
+ children: "−"
1072
+ }),
1073
+ /* @__PURE__ */ jsxs("span", {
1074
+ className: "min-w-8 text-center",
1075
+ children: [/* @__PURE__ */ jsx("span", {
1076
+ className: "sr-only",
1077
+ children: "Quantity: "
1078
+ }), quantity]
1079
+ }),
1080
+ /* @__PURE__ */ jsx("button", {
1081
+ type: "button",
1082
+ className: stepperButtonClass,
1083
+ "aria-label": `Increase quantity of ${name}`,
1084
+ disabled: action === `increase:${id}` || !id,
1085
+ "aria-disabled": locked || !id,
1086
+ "aria-busy": action === `increase:${id}`,
1087
+ onClick: () => run(`increase:${id}`, () => changeQuantity(id, quantity + 1)),
1088
+ children: "+"
1089
+ })
1090
+ ]
1091
+ })
1092
+ });
1093
+ }
1094
+ /** Removes the line. */
1095
+ function CartItemRemove({ className }) {
1096
+ const { id, name } = useCartItemInternal("CartItemRemove");
1097
+ const { action, locked, run, removeItem } = useCartInternal("CartItemRemove");
1098
+ return /* @__PURE__ */ jsx("div", {
1099
+ className: partClass(className),
1100
+ children: /* @__PURE__ */ jsx("button", {
1101
+ type: "button",
1102
+ className: "min-h-11 bg-white px-2 text-sm text-neutral-900 underline focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:text-neutral-600",
1103
+ "aria-label": `Remove ${name}`,
1104
+ disabled: action === `remove:${id}` || !id,
1105
+ "aria-disabled": locked || !id,
1106
+ "aria-busy": action === `remove:${id}`,
1107
+ onClick: () => run(`remove:${id}`, () => removeItem(id)),
1108
+ children: "Remove"
1109
+ })
1110
+ });
1111
+ }
1112
+ const packagedItem = /* @__PURE__ */ jsxs(CartItemRoot, {
1113
+ className: "py-5 first:pt-0",
1114
+ children: [/* @__PURE__ */ jsxs("div", {
1115
+ className: "flex gap-4",
1116
+ children: [/* @__PURE__ */ jsx(CartItemImage, {}), /* @__PURE__ */ jsxs("div", {
1117
+ className: "min-w-0 flex-1",
1118
+ children: [
1119
+ /* @__PURE__ */ jsx(CartItemName, {}),
1120
+ /* @__PURE__ */ jsx(CartItemOptions, {}),
1121
+ /* @__PURE__ */ jsx(CartItemPrice, { className: "mt-1" })
1122
+ ]
1123
+ })]
1124
+ }), /* @__PURE__ */ jsxs("div", {
1125
+ className: "mt-4 flex items-center justify-between gap-2",
1126
+ children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1127
+ })]
1128
+ });
1129
+ /** Renders only when the cart is empty: a message and a link to the catalog, or its children. */
1130
+ function CartEmpty({ className, children }) {
1131
+ const { items, hydrating, isSample, catalogPath, close } = useCartInternal("CartEmpty");
1132
+ if (hydrating || items.length) return null;
1133
+ return /* @__PURE__ */ jsx("div", {
1134
+ className: partClass(className),
1135
+ children: hasChildren(children) ? children : /* @__PURE__ */ jsxs(Fragment, { children: [
1136
+ /* @__PURE__ */ jsx("p", {
1137
+ className: "text-xl font-medium",
1138
+ children: "Your cart is empty"
1139
+ }),
1140
+ /* @__PURE__ */ jsx("p", {
1141
+ className: "mt-2 text-neutral-600",
1142
+ children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
1143
+ }),
1144
+ /* @__PURE__ */ jsx(Link, {
1145
+ className: `${buttonClass} mt-6`,
1146
+ to: catalogPath,
1147
+ onClick: close,
1148
+ children: "Browse products"
1149
+ })
1150
+ ] })
1151
+ });
1152
+ }
1153
+ /** A container for the subtotal and checkout. Renders only when the cart has items. */
1154
+ function CartSummary({ className, children }) {
1155
+ const { items } = useCartInternal("CartSummary");
1156
+ if (!items.length) return null;
1157
+ return /* @__PURE__ */ jsx("div", {
1158
+ className: partClass(className),
1159
+ children
1160
+ });
1161
+ }
1162
+ /** The subtotal, labeled "Sample subtotal" for samples. Renders nothing until the server prices the cart. */
1163
+ function CartSubtotal({ className }) {
1164
+ const { items, subtotal, currency, isSample } = useCartInternal("CartSubtotal");
1165
+ if (!items.length || subtotal === null) return null;
1166
+ return /* @__PURE__ */ jsx("div", {
1167
+ className: partClass(className),
1168
+ children: /* @__PURE__ */ jsx("dl", {
1169
+ className: "space-y-2 text-sm",
1170
+ children: /* @__PURE__ */ jsxs("div", {
1171
+ className: "flex justify-between",
1172
+ children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(subtotal, currency) })]
1173
+ })
1174
+ })
1175
+ });
1176
+ }
1177
+ /**
1178
+ * "Shipping, taxes, and discounts are calculated at checkout." Has the stable
1179
+ * `commerce-cart-checkout-adjustments-note` class. Renders nothing for samples or an empty cart.
1180
+ */
1181
+ function CartCheckoutNote({ className }) {
1182
+ const { items, isSample } = useCartInternal("CartCheckoutNote");
1183
+ if (isSample || !items.length) return null;
1184
+ return /* @__PURE__ */ jsx("div", {
1185
+ className: partClass("commerce-cart-checkout-adjustments-note", className),
1186
+ children: /* @__PURE__ */ jsx("p", {
1187
+ className: "text-sm text-neutral-600",
1188
+ children: "Shipping, taxes, and discounts are calculated at checkout."
1189
+ })
1190
+ });
1191
+ }
1192
+ /**
1193
+ * The checkout button when `checkoutSuccessPath` is set; otherwise a message that checkout is unavailable.
1194
+ * Samples never check out. Renders nothing for an empty cart.
1195
+ */
1196
+ function CartCheckout({ className }) {
1197
+ const { items, isSample, checkoutEnabled, checkoutReady, hydrating, action, locked, run, checkout } = useCartInternal("CartCheckout");
1198
+ if (!items.length) return null;
1199
+ let content;
1200
+ if (isSample) content = /* @__PURE__ */ jsx("p", {
1201
+ className: "text-sm text-neutral-600",
1202
+ children: "Example total in USD. Checkout is not set up yet."
1203
+ });
1204
+ else if (!checkoutEnabled) content = /* @__PURE__ */ jsx("p", {
1205
+ className: "text-sm text-neutral-600",
1206
+ children: "Checkout is not available yet."
1207
+ });
1208
+ else content = /* @__PURE__ */ jsx("button", {
1209
+ type: "button",
1210
+ className: `${buttonClass} w-full`,
1211
+ disabled: action === "checkout" || hydrating || !checkoutReady,
1212
+ "aria-disabled": locked || !checkoutReady,
1213
+ "aria-busy": action === "checkout",
1214
+ onClick: () => run("checkout", checkout),
1215
+ children: action === "checkout" ? "Opening checkout…" : "Proceed to Checkout"
1216
+ });
1217
+ return /* @__PURE__ */ jsx("div", {
1218
+ className: partClass(className),
1219
+ children: content
1220
+ });
1221
+ }
1222
+ /** The packaged cart drawer. Compose `CartDrawerRoot` and the cart parts for a custom drawer. */
1223
+ function CartDrawer() {
1224
+ return /* @__PURE__ */ jsxs(CartDrawerRoot, {
1225
+ className: "flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
1226
+ children: [
1227
+ /* @__PURE__ */ jsxs("div", {
1228
+ className: "flex items-start justify-between border-b border-neutral-200 p-6",
1229
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(CartTitle, {}), /* @__PURE__ */ jsx(CartDescription, { className: "mt-1" })] }), /* @__PURE__ */ jsx(CartClose, {})]
1230
+ }),
1231
+ /* @__PURE__ */ jsxs("div", {
1232
+ className: "flex-1 overflow-y-auto p-6",
1233
+ children: [
1234
+ /* @__PURE__ */ jsx(CartNotices, { className: "mb-4" }),
1235
+ /* @__PURE__ */ jsx(CartItems, { className: "divide-y divide-neutral-200" }),
1236
+ /* @__PURE__ */ jsx(CartEmpty, { className: "py-16 text-center" })
1237
+ ]
1238
+ }),
1239
+ /* @__PURE__ */ jsxs(CartSummary, {
1240
+ className: "border-t border-neutral-200 p-6",
1241
+ children: [
1242
+ /* @__PURE__ */ jsx(CartSubtotal, {}),
1243
+ /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-3" }),
1244
+ /* @__PURE__ */ jsx(CartCheckout, {})
1245
+ ]
1246
+ })
1247
+ ]
1248
+ });
1249
+ }
1250
+
1251
+ //#endregion
1252
+ //#region src/product-card.tsx
1253
+ function ProductImage({ url, name, className = "" }) {
1254
+ const [failed, setFailed] = useState(false);
1255
+ return url && !failed ? /* @__PURE__ */ jsx("img", {
1256
+ src: url,
1257
+ alt: name,
1258
+ loading: "lazy",
1259
+ className: `h-full w-full object-cover ${className}`,
1260
+ onError: () => setFailed(true)
1261
+ }) : /* @__PURE__ */ jsx("div", {
1262
+ className: `flex h-full min-h-44 items-center justify-center p-6 text-center ${className}`,
1263
+ role: "img",
1264
+ "aria-label": `${name}: image unavailable`,
1265
+ children: "No image available"
1266
+ });
1267
+ }
1268
+ /** Set by `CatalogProducts` for each entry it renders, so the card template needs no product prop. */
1269
+ const CatalogItemContext = createContext(null);
1270
+ const titleLink = "title-link";
1271
+ function cardValue(item, productPath, links) {
1272
+ if (item.source === "sample") return {
1273
+ ...links,
1274
+ source: "sample",
1275
+ sample: item.sample,
1276
+ product: null,
1277
+ name: item.sample.name,
1278
+ href: null,
1279
+ isSample: true
1280
+ };
1281
+ return {
1282
+ ...links,
1283
+ source: "live",
1284
+ product: item.product,
1285
+ name: getProductName(item.product),
1286
+ href: `${productPath}/${encodeURIComponent(item.product.id ?? "")}`,
1287
+ isSample: false
1288
+ };
1289
+ }
1290
+ const ProductCardContext = createContext(null);
1291
+ function useProductCardInternal(part) {
1292
+ const context = useContext(ProductCardContext);
1293
+ if (!context) throw new Error(`${part} must be rendered inside <ProductCardRoot>.`);
1294
+ return context;
1295
+ }
1296
+ /** Read the card's product, name, and link from inside `ProductCardRoot`. */
1297
+ function useProductCard() {
1298
+ const { product, name, href, isSample } = useProductCardInternal("useProductCard");
1299
+ return useMemo(() => ({
1300
+ product,
1301
+ name,
1302
+ href,
1303
+ isSample
1304
+ }), [
1305
+ product,
1306
+ name,
1307
+ href,
1308
+ isSample
1309
+ ]);
1310
+ }
1311
+ /** Provides one product to card parts. Inside `CatalogProducts` it renders the current catalog entry. */
1312
+ function ProductCardRoot({ children, product, className }) {
1313
+ const catalogItem = useContext(CatalogItemContext);
1314
+ const { config } = useCommerce();
1315
+ const [imageLinked, setImageLinked] = useState(false);
1316
+ const { register, has } = usePartRegistry(useCallback((registered) => setImageLinked(!registered(titleLink)), []));
1317
+ useEffect(() => {
1318
+ setImageLinked(!has(titleLink));
1319
+ }, [has]);
1320
+ const value = useMemo(() => {
1321
+ const item = catalogItem ?? (product ? {
1322
+ source: "live",
1323
+ product
1324
+ } : null);
1325
+ return item && cardValue(item, config.productPath, {
1326
+ imageLinked,
1327
+ register
1328
+ });
1329
+ }, [
1330
+ catalogItem,
1331
+ product,
1332
+ config.productPath,
1333
+ imageLinked,
1334
+ register
1335
+ ]);
1336
+ if (catalogItem && product) throw new Error("Inside <CatalogProducts>, ProductCardRoot shows each catalog product. Remove its product prop.");
1337
+ if (!value) throw new Error("ProductCardRoot needs a product prop outside <CatalogProducts>.");
1338
+ const card = /* @__PURE__ */ jsx(ProductCardContext.Provider, {
1339
+ value,
1340
+ children: /* @__PURE__ */ jsx("article", {
1341
+ className: partClass("group", className),
1342
+ children: /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1343
+ value: null,
1344
+ children
1345
+ })
1346
+ })
1347
+ });
1348
+ return catalogItem ? card : /* @__PURE__ */ jsx(StorefrontSurface, { children: card });
1349
+ }
1350
+ /**
1351
+ * The product's first image, linked to its page. Samples show a placeholder. With a `ProductCardTitle` in the card,
1352
+ * the title is the one link keyboard and screen reader users get; without one, the image link is.
1353
+ */
1354
+ function ProductCardImage({ className }) {
1355
+ const card = useProductCardInternal("ProductCardImage");
1356
+ return /* @__PURE__ */ jsx("div", {
1357
+ className: partClass(className),
1358
+ children: card.source === "live" ? /* @__PURE__ */ jsx(Link, {
1359
+ to: card.href,
1360
+ tabIndex: card.imageLinked ? void 0 : -1,
1361
+ "aria-hidden": card.imageLinked ? void 0 : "true",
1362
+ className: "block aspect-square overflow-hidden focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4",
1363
+ children: /* @__PURE__ */ jsx(ProductImage, {
1364
+ url: getPrimaryImageUrl(card.product),
1365
+ name: card.name,
1366
+ className: "motion-safe:transition-transform motion-safe:duration-200 motion-safe:group-hover:scale-105"
1367
+ })
1368
+ }) : /* @__PURE__ */ jsx("div", {
1369
+ "aria-hidden": "true",
1370
+ className: "flex aspect-square items-center justify-center bg-neutral-100",
1371
+ children: /* @__PURE__ */ jsx("div", { className: "h-20 w-20 rounded-lg border border-neutral-300" })
1372
+ })
1373
+ });
1374
+ }
1375
+ /** The product name as an H2, linked to its page. */
1376
+ function ProductCardTitle({ className }) {
1377
+ const { name, href, register } = useProductCardInternal("ProductCardTitle");
1378
+ useEffect(() => href ? register(titleLink) : void 0, [href, register]);
1379
+ return /* @__PURE__ */ jsx("div", {
1380
+ className: partClass(className),
1381
+ children: /* @__PURE__ */ jsx("h2", {
1382
+ className: "break-words text-2xl font-semibold leading-tight",
1383
+ children: href ? /* @__PURE__ */ jsx(Link, {
1384
+ className: "rounded text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
1385
+ to: href,
1386
+ children: name
1387
+ }) : name
1388
+ })
1389
+ });
1390
+ }
1391
+ /** Up to three lines of the product description. Renders nothing without one. */
1392
+ function ProductCardDescription({ className }) {
1393
+ const { product } = useProductCardInternal("ProductCardDescription");
1394
+ if (!product?.description) return null;
1395
+ return /* @__PURE__ */ jsx("div", {
1396
+ className: partClass(className),
1397
+ children: /* @__PURE__ */ jsx("p", {
1398
+ className: "line-clamp-3 text-sm leading-6",
1399
+ children: product.description
1400
+ })
1401
+ });
1402
+ }
1403
+ /** The product's price, or its price range when variants differ. */
1404
+ function ProductCardPrice({ className }) {
1405
+ const card = useProductCardInternal("ProductCardPrice");
1406
+ const { config } = useCommerce();
1407
+ let text;
1408
+ if (card.source === "sample") text = money(card.sample.price, "USD");
1409
+ else {
1410
+ const min = card.product.priceRange?.min;
1411
+ const max = card.product.priceRange?.max;
1412
+ text = typeof min === "number" ? `${money(min, config.currencyCode)}${typeof max === "number" && max !== min ? ` – ${money(max, config.currencyCode)}` : ""}` : "Price unavailable";
1413
+ }
1414
+ return /* @__PURE__ */ jsx("div", {
1415
+ className: partClass(className),
1416
+ children: /* @__PURE__ */ jsx("p", {
1417
+ className: "text-lg font-semibold leading-7",
1418
+ children: text
1419
+ })
1420
+ });
1421
+ }
1422
+ /**
1423
+ * Adds the product directly when it has exactly one SKU and no options; otherwise links to its page to choose.
1424
+ * Samples add to the sample cart.
1425
+ */
1426
+ function ProductCardAction({ className }) {
1427
+ const card = useProductCardInternal("ProductCardAction");
1428
+ return /* @__PURE__ */ jsx("div", {
1429
+ className: partClass(className),
1430
+ children: card.source === "live" ? /* @__PURE__ */ jsx(LiveAction, {
1431
+ product: card.product,
1432
+ name: card.name,
1433
+ href: card.href
1434
+ }) : /* @__PURE__ */ jsx(SampleAction, { sample: card.sample })
1435
+ });
1436
+ }
1437
+ function LiveAction({ product, name, href }) {
1438
+ const selectedSku = getSingleMatchedSku(product);
1439
+ const hasVariants = getProductAttributes(product).length > 0;
1440
+ if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCartButton, {
1441
+ sku: selectedSku,
1442
+ name
1443
+ });
1444
+ return /* @__PURE__ */ jsx(Link, {
1445
+ className: buttonClass,
1446
+ to: href,
1447
+ children: hasVariants ? "Choose options" : "View product"
1448
+ });
1449
+ }
1450
+ function SampleAction({ sample }) {
1451
+ const { add } = useSampleCart();
1452
+ return /* @__PURE__ */ jsx("button", {
1453
+ type: "button",
1454
+ className: buttonClass,
1455
+ "aria-label": `Add ${sample.name} to sample cart`,
1456
+ onClick: () => add(sample.key),
1457
+ children: "Add to cart"
1458
+ });
1459
+ }
1460
+ const packagedCardClass = "flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200";
1461
+ /** The packaged card's parts, inside a root that has `packagedCardClass`. */
1462
+ function PackagedCardParts() {
1463
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsxs("div", {
1464
+ className: "flex flex-1 flex-col p-6",
1465
+ children: [
1466
+ /* @__PURE__ */ jsx(ProductCardTitle, {}),
1467
+ /* @__PURE__ */ jsx(ProductCardDescription, { className: "mt-3" }),
1468
+ /* @__PURE__ */ jsx("div", {
1469
+ className: "mt-auto pt-6",
1470
+ children: /* @__PURE__ */ jsxs("div", {
1471
+ className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
1472
+ children: [/* @__PURE__ */ jsx(ProductCardPrice, { className: "min-w-0 flex-1" }), /* @__PURE__ */ jsx(ProductCardAction, { className: "max-w-full" })]
1473
+ })
1474
+ })
1475
+ ]
1476
+ })] });
1477
+ }
1478
+ /** The packaged card for one `SKUGroup`. Compose `ProductCardRoot` and the card parts for a custom card. */
1479
+ function ProductCard({ product }) {
1480
+ return /* @__PURE__ */ jsx(ProductCardRoot, {
1481
+ product,
1482
+ className: packagedCardClass,
1483
+ children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1484
+ });
1485
+ }
1486
+
1487
+ //#endregion
1488
+ //#region src/catalog.tsx
1489
+ const CatalogContext = createContext(null);
1490
+ const sampleCatalog = { source: "sample" };
1491
+ function useCatalogInternal(part) {
1492
+ const context = useContext(CatalogContext);
1493
+ if (!context) throw new Error(`${part} must be rendered inside <CatalogRoot>.`);
1494
+ return context;
1495
+ }
1496
+ /** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */
1497
+ function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = false }) {
1498
+ const { connection } = useCommerce();
1499
+ let content;
1500
+ if (connection === "ready") content = /* @__PURE__ */ jsx(CatalogLoader, {
1501
+ className,
1502
+ children
1503
+ });
1504
+ else if (connection === "unbound" && sampleProducts$1) content = /* @__PURE__ */ jsx(CatalogContext.Provider, {
1505
+ value: sampleCatalog,
1506
+ children: /* @__PURE__ */ jsx("section", {
1507
+ className: partClass(className),
1508
+ "data-commerce-source": "sample",
1509
+ children
1510
+ })
1511
+ });
1512
+ else content = /* @__PURE__ */ jsx("div", {
1513
+ className: partClass(className),
1514
+ children: /* @__PURE__ */ jsx(CommerceStatus, {})
1515
+ });
1516
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: content });
1517
+ }
1518
+ function CatalogLoader({ children, className }) {
1519
+ const { config } = useCommerce();
1520
+ const [params, setParams] = useSearchParams();
1521
+ const after = params.get("after") ?? "";
1522
+ const urlParams = new URLSearchParams({ first: "6" });
1523
+ if (after) urlParams.set("after", after);
1524
+ const products = useQuery({
1525
+ retry: false,
1526
+ queryKey: [
1527
+ "commerce",
1528
+ config.cartScope,
1529
+ "products",
1530
+ after
1531
+ ],
1532
+ queryFn: ({ signal }) => request(`/products?${urlParams}`, {
1533
+ signal,
1534
+ headers: { "X-Commerce-Scope": config.cartScope }
1535
+ })
1536
+ });
1537
+ const goToPage = useCallback((cursor) => {
1538
+ const next = new URLSearchParams(params);
1539
+ if (cursor) next.set("after", cursor);
1540
+ else next.delete("after");
1541
+ setParams(next);
1542
+ }, [params, setParams]);
1543
+ const nodes = useMemo(() => products.data?.skuGroups?.edges?.flatMap((edge) => edge?.node?.id ? [edge.node] : []) ?? [], [products.data]);
1544
+ const value = useMemo(() => ({
1545
+ source: "live",
1546
+ products,
1547
+ nodes,
1548
+ after,
1549
+ goToPage
1550
+ }), [
1551
+ products,
1552
+ nodes,
1553
+ after,
1554
+ goToPage
1555
+ ]);
1556
+ return /* @__PURE__ */ jsx(CatalogContext.Provider, {
1557
+ value,
1558
+ children: /* @__PURE__ */ jsx("section", {
1559
+ className: partClass(className),
1560
+ children
1561
+ })
1562
+ });
1563
+ }
1564
+ /** The catalog title as the page's H1, with a description. */
1565
+ function CatalogHeader({ title = "Shop all products", description = "Explore the collection and find your favorites.", className }) {
1566
+ useCatalogInternal("CatalogHeader");
1567
+ return /* @__PURE__ */ jsxs("header", {
1568
+ className: partClass(className),
1569
+ children: [/* @__PURE__ */ jsx("h1", {
1570
+ className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
1571
+ children: title
1572
+ }), /* @__PURE__ */ jsx("p", {
1573
+ className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
1574
+ children: description
1575
+ })]
1576
+ });
1577
+ }
1578
+ /** Announces loading, failure, or how many products the page shows. */
1579
+ function CatalogSummary({ className }) {
1580
+ const catalog = useCatalogInternal("CatalogSummary");
1581
+ return /* @__PURE__ */ jsx("div", {
1582
+ className: partClass(className),
1583
+ children: /* @__PURE__ */ jsx("p", {
1584
+ className: "text-sm",
1585
+ role: "status",
1586
+ children: summary(catalog)
1587
+ })
1588
+ });
1589
+ }
1590
+ function summary(catalog) {
1591
+ if (catalog.source === "sample") return "Sample products · Example prices in USD";
1592
+ if (catalog.products.isPending) return "Loading products…";
1593
+ if (catalog.products.isError) return "Products unavailable";
1594
+ return `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? "" : "s"}`;
1595
+ }
1596
+ const sampleItems = sampleProducts.map((sample) => ({
1597
+ key: sample.key,
1598
+ item: {
1599
+ source: "sample",
1600
+ sample
1601
+ }
1602
+ }));
1603
+ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
1604
+ className: packagedCardClass,
1605
+ children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1606
+ });
1607
+ /**
1608
+ * The product cards. `className` goes on the element that contains the cards, such as a grid. While loading, that
1609
+ * element holds placeholder cards. The error with its retry and the empty message render just before it.
1610
+ */
1611
+ function CatalogProducts({ className, children }) {
1612
+ const catalog = useCatalogInternal("CatalogProducts");
1613
+ const card = hasChildren(children) ? children : packagedCard;
1614
+ const nodes = catalog.source === "live" ? catalog.nodes : null;
1615
+ const items = useMemo(() => nodes ? nodes.map((product) => ({
1616
+ key: product.id ?? "",
1617
+ item: {
1618
+ source: "live",
1619
+ product
1620
+ }
1621
+ })) : sampleItems, [nodes]);
1622
+ const products = catalog.source === "live" ? catalog.products : null;
1623
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1624
+ products?.isError && /* @__PURE__ */ jsxs("div", {
1002
1625
  role: "alert",
1003
1626
  className: "rounded-lg bg-red-50 p-5 text-red-800",
1004
1627
  children: [/* @__PURE__ */ jsx("p", { children: message(products.error) }), /* @__PURE__ */ jsx("button", {
@@ -1008,17 +1631,45 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
1008
1631
  children: "Retry products"
1009
1632
  })]
1010
1633
  }),
1011
- products.isSuccess && !nodes.length && /* @__PURE__ */ jsx("p", {
1634
+ products?.isSuccess && !items.length && /* @__PURE__ */ jsx("p", {
1012
1635
  className: "py-16 text-center",
1013
1636
  children: "No products available."
1014
1637
  }),
1015
1638
  /* @__PURE__ */ jsx("div", {
1016
- className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10",
1017
- children: nodes.map((product) => /* @__PURE__ */ jsx(ProductCard, { product }, product.id))
1018
- }),
1019
- /* @__PURE__ */ jsxs("nav", {
1020
- "aria-label": "Product pages",
1021
- className: "flex justify-between border-t border-neutral-200 pt-6",
1639
+ className: partClass(className),
1640
+ children: products?.isPending ? [
1641
+ 0,
1642
+ 1,
1643
+ 2
1644
+ ].map((placeholder) => /* @__PURE__ */ jsxs("div", {
1645
+ "aria-hidden": "true",
1646
+ className: "overflow-hidden rounded-lg border border-neutral-200 motion-safe:animate-pulse",
1647
+ children: [/* @__PURE__ */ jsx("div", { className: "aspect-square bg-neutral-100" }), /* @__PURE__ */ jsxs("div", {
1648
+ className: "space-y-4 p-6",
1649
+ children: [
1650
+ /* @__PURE__ */ jsx("div", { className: "h-6 w-2/3 rounded bg-neutral-100" }),
1651
+ /* @__PURE__ */ jsx("div", { className: "h-4 rounded bg-neutral-100" }),
1652
+ /* @__PURE__ */ jsx("div", { className: "h-11 rounded bg-neutral-100" })
1653
+ ]
1654
+ })]
1655
+ }, placeholder)) : items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1656
+ value: item,
1657
+ children: card
1658
+ }, key))
1659
+ })
1660
+ ] });
1661
+ }
1662
+ /** First-page and next-page buttons. Renders nothing for samples, which have one page. */
1663
+ function CatalogPagination({ className }) {
1664
+ const catalog = useCatalogInternal("CatalogPagination");
1665
+ if (catalog.source === "sample") return null;
1666
+ const { after, goToPage } = catalog;
1667
+ const pageInfo = catalog.products.data?.skuGroups?.pageInfo;
1668
+ return /* @__PURE__ */ jsx("nav", {
1669
+ "aria-label": "Product pages",
1670
+ className: partClass(className),
1671
+ children: /* @__PURE__ */ jsxs("div", {
1672
+ className: "flex justify-between",
1022
1673
  children: [after ? /* @__PURE__ */ jsx("button", {
1023
1674
  type: "button",
1024
1675
  className: buttonClass,
@@ -1031,60 +1682,21 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
1031
1682
  children: "Next page"
1032
1683
  })]
1033
1684
  })
1034
- ] });
1685
+ });
1035
1686
  }
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",
1687
+ /** The packaged catalog. Compose `CatalogRoot` and the catalog parts for a custom layout. */
1688
+ function Catalog({ title, description, showHeader = true, sampleProducts: sampleProducts$1 }) {
1689
+ return /* @__PURE__ */ jsxs(CatalogRoot, {
1690
+ sampleProducts: sampleProducts$1,
1040
1691
  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"
1692
+ showHeader && /* @__PURE__ */ jsx(CatalogHeader, {
1693
+ title,
1694
+ description,
1695
+ className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8"
1055
1696
  }),
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
- })
1697
+ /* @__PURE__ */ jsx(CatalogSummary, { className: "border-b border-neutral-200 py-6 sm:py-8" }),
1698
+ /* @__PURE__ */ jsx(CatalogProducts, { className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10" }),
1699
+ /* @__PURE__ */ jsx(CatalogPagination, { className: "border-t border-neutral-200 pt-6" })
1088
1700
  ]
1089
1701
  });
1090
1702
  }
@@ -1101,9 +1713,6 @@ function CommerceStorefront({ children,...props }) {
1101
1713
 
1102
1714
  //#endregion
1103
1715
  //#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
1716
  const ProductDetailsContext = createContext(null);
1108
1717
  function useProductDetailsInternal(part) {
1109
1718
  const context = useContext(ProductDetailsContext);
@@ -1162,7 +1771,10 @@ function ProductDetailsRoot({ children, className }) {
1162
1771
  productId,
1163
1772
  className,
1164
1773
  children
1165
- }, productId) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
1774
+ }, productId) : /* @__PURE__ */ jsx("div", {
1775
+ className: partClass(className),
1776
+ children: /* @__PURE__ */ jsx(CommerceStatus, {})
1777
+ }) });
1166
1778
  }
1167
1779
  function ProductDetailsLoader({ productId, className, children }) {
1168
1780
  const { config } = useCommerce();
@@ -1221,29 +1833,24 @@ function ProductDetailsLoader({ productId, className, children }) {
1221
1833
  setQuantity(1);
1222
1834
  setImageIndex(0);
1223
1835
  };
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
- }, []);
1836
+ const { register, has } = usePartRegistry();
1234
1837
  const requiredControls = [...attributes.map((attribute) => `attribute:${attribute.name}`), ...skuOptions.length ? ["sku"] : []].join("\n");
1235
1838
  useEffect(() => {
1236
1839
  if (process.env.NODE_ENV === "production") return;
1237
- const missing = requiredControls.split("\n").filter((control) => control && !registered.current.has(control));
1840
+ const missing = requiredControls.split("\n").filter((control) => control && !has(control));
1238
1841
  if (missing.length === 0) return;
1239
1842
  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]);
1241
- if (product.isPending) return /* @__PURE__ */ jsx("p", {
1242
- role: "status",
1243
- children: "Loading product…"
1843
+ }, [requiredControls, has]);
1844
+ if (product.isPending) return /* @__PURE__ */ jsx("div", {
1845
+ className: partClass(className),
1846
+ children: /* @__PURE__ */ jsx("p", {
1847
+ role: "status",
1848
+ children: "Loading product…"
1849
+ })
1244
1850
  });
1245
1851
  if (product.isError && !productNotFound) return /* @__PURE__ */ jsxs("div", {
1246
1852
  role: "alert",
1853
+ className: partClass(className),
1247
1854
  children: [
1248
1855
  /* @__PURE__ */ jsx("h1", {
1249
1856
  className: "text-2xl font-semibold",
@@ -1261,17 +1868,20 @@ function ProductDetailsLoader({ productId, className, children }) {
1261
1868
  })
1262
1869
  ]
1263
1870
  });
1264
- if (productNotFound || !group) return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
1265
- className: "text-2xl font-semibold",
1266
- children: "Product not found"
1267
- }), /* @__PURE__ */ jsx(Link, {
1268
- className: `${buttonClass} mt-4`,
1269
- to: config.catalogPath,
1270
- children: "Back to shop"
1271
- })] });
1871
+ if (productNotFound || !group) return /* @__PURE__ */ jsxs("div", {
1872
+ className: partClass(className),
1873
+ children: [/* @__PURE__ */ jsx("h1", {
1874
+ className: "text-2xl font-semibold",
1875
+ children: "Product not found"
1876
+ }), /* @__PURE__ */ jsx(Link, {
1877
+ className: `${buttonClass} mt-4`,
1878
+ to: config.catalogPath,
1879
+ children: "Back to shop"
1880
+ })]
1881
+ });
1272
1882
  const value = {
1273
1883
  product: group,
1274
- name: group.label ?? group.name ?? "Product",
1884
+ name: getProductName(group),
1275
1885
  selectedSku,
1276
1886
  quantity,
1277
1887
  availableQuantity: getAvailableInventoryQuantity(selectedSku),
@@ -1313,16 +1923,16 @@ const disabledReasons = {
1313
1923
  unavailable: "This combination is unavailable. Choose different options.",
1314
1924
  "not-purchasable": "This product is not currently available to purchase."
1315
1925
  };
1316
- const flush = "mb-0";
1317
1926
  /** The packaged purchase area: options, status, availability, quantity, and add to cart. */
1318
1927
  function ProductDetailsPurchase({ className }) {
1319
- useProductDetailsInternal("ProductDetailsPurchase");
1928
+ const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsPurchase");
1929
+ const belowOptions = attributes.length || skuOptions.length ? "-mt-4" : void 0;
1320
1930
  return /* @__PURE__ */ jsxs("div", {
1321
1931
  className: partClass("space-y-6", className),
1322
1932
  children: [
1323
1933
  /* @__PURE__ */ jsx(OptionControls, {}),
1324
- /* @__PURE__ */ jsx(PurchaseStatusMessage, { textClassName: flush }),
1325
- /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: flush }),
1934
+ /* @__PURE__ */ jsx(ProductDetailsPurchaseStatus, { className: belowOptions }),
1935
+ /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: belowOptions }),
1326
1936
  /* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
1327
1937
  /* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
1328
1938
  ]
@@ -1339,32 +1949,53 @@ function ProductDetailsOptions({ className }) {
1339
1949
  }
1340
1950
  function OptionControls() {
1341
1951
  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, {})] });
1952
+ return /* @__PURE__ */ jsxs(Fragment, { children: [attributes.map((attribute) => /* @__PURE__ */ jsx(ProductDetailsOption, { attribute: attribute.name }, attribute.id || attribute.name)), skuOptions.length > 0 && /* @__PURE__ */ jsx(SkuOption, {})] });
1953
+ }
1954
+ function Select({ children,...props }) {
1955
+ return /* @__PURE__ */ jsxs("div", {
1956
+ className: "relative",
1957
+ children: [/* @__PURE__ */ jsx("select", {
1958
+ className: `${inputClass} w-full appearance-none pr-10`,
1959
+ ...props,
1960
+ children
1961
+ }), /* @__PURE__ */ jsx("svg", {
1962
+ "aria-hidden": "true",
1963
+ viewBox: "0 0 16 16",
1964
+ className: "pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-600",
1965
+ children: /* @__PURE__ */ jsx("path", {
1966
+ d: "M4 6l4 4 4-4",
1967
+ fill: "none",
1968
+ stroke: "currentColor",
1969
+ strokeWidth: "1.5",
1970
+ strokeLinecap: "round",
1971
+ strokeLinejoin: "round"
1972
+ })
1973
+ })]
1974
+ });
1346
1975
  }
1347
1976
  function SkuOption() {
1348
1977
  const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
1349
1978
  const id = useId();
1350
1979
  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
- })] });
1980
+ return /* @__PURE__ */ jsxs("div", {
1981
+ className: partClass(),
1982
+ children: [/* @__PURE__ */ jsx("label", {
1983
+ htmlFor: id,
1984
+ className: "mb-2 block font-medium",
1985
+ children: "Option"
1986
+ }), /* @__PURE__ */ jsxs(Select, {
1987
+ id,
1988
+ value: explicitSkuId,
1989
+ onChange: (event) => selectSku(event.target.value),
1990
+ children: [/* @__PURE__ */ jsx("option", {
1991
+ value: "",
1992
+ children: "Select an option"
1993
+ }), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
1994
+ value: sku.id ?? "",
1995
+ children: sku.label?.trim() || sku.name?.trim()
1996
+ }, sku.id))]
1997
+ })]
1998
+ });
1368
1999
  }
1369
2000
  /** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
1370
2001
  function ProductDetailsOption({ attribute, className }) {
@@ -1375,8 +2006,7 @@ function ProductDetailsOption({ attribute, className }) {
1375
2006
  children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
1376
2007
  className: "mb-2 font-medium",
1377
2008
  children: option.label
1378
- }), /* @__PURE__ */ jsxs("select", {
1379
- className: `${inputClass} w-full`,
2009
+ }), /* @__PURE__ */ jsxs(Select, {
1380
2010
  "aria-label": option.label,
1381
2011
  value: option.selected ?? "",
1382
2012
  onChange: (event) => option.select(event.target.value || null),
@@ -1392,45 +2022,48 @@ function ProductDetailsOption({ attribute, className }) {
1392
2022
  }
1393
2023
  /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
1394
2024
  function ProductDetailsPurchaseStatus({ className }) {
1395
- return /* @__PURE__ */ jsx(PurchaseStatusMessage, { className });
1396
- }
1397
- function PurchaseStatusMessage({ className, textClassName }) {
1398
2025
  const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
1399
- const part = (children, partClassName) => /* @__PURE__ */ jsx("div", {
1400
- className: partClass(partClassName, className),
1401
- children
1402
- });
2026
+ let content;
1403
2027
  switch (status) {
1404
2028
  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"
1413
- })]
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);
2029
+ case "variant-error":
2030
+ content = /* @__PURE__ */ jsxs("div", {
2031
+ role: "alert",
2032
+ className: "text-red-700",
2033
+ children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
2034
+ type: "button",
2035
+ className: "min-h-11 bg-white text-red-700 underline",
2036
+ onClick: retryVariant,
2037
+ children: "Retry availability"
2038
+ })]
2039
+ });
2040
+ break;
2041
+ case "unavailable":
2042
+ content = /* @__PURE__ */ jsx("p", {
2043
+ role: "status",
2044
+ className: "text-sm text-red-700",
2045
+ children: disabledReasons[status]
2046
+ });
2047
+ break;
2048
+ case "not-purchasable":
2049
+ content = /* @__PURE__ */ jsx("p", {
2050
+ className: "text-sm",
2051
+ children: disabledReasons[status]
2052
+ });
2053
+ break;
2054
+ case "incomplete":
2055
+ case "checking":
2056
+ content = /* @__PURE__ */ jsx("p", {
2057
+ role: "status",
2058
+ className: "text-sm",
2059
+ children: disabledReasons[status]
2060
+ });
2061
+ break;
1433
2062
  }
2063
+ return /* @__PURE__ */ jsx("div", {
2064
+ className: partClass(className),
2065
+ children: content
2066
+ });
1434
2067
  }
1435
2068
  /** Stock for the verified SKU. Renders nothing until a SKU is verified. */
1436
2069
  function ProductDetailsAvailability({ className }) {
@@ -1441,31 +2074,43 @@ function ProductDetailsAvailability({ className }) {
1441
2074
  children: /* @__PURE__ */ jsx("p", {
1442
2075
  className: "text-sm",
1443
2076
  "aria-live": "polite",
1444
- children: availableQuantity === null ? "Available to order" : availableQuantity === 0 ? "This variant is out of stock" : `${availableQuantity} available`
2077
+ children: availableQuantity === null ? "Available to order" : isSoldOut(availableQuantity) ? "This variant is out of stock" : `${availableQuantity} available`
1445
2078
  })
1446
2079
  });
1447
2080
  }
1448
- /** Quantity for the verified SKU. Renders nothing until a SKU is verified. */
2081
+ /** Quantity for the verified SKU, with a message when it cannot be added. Renders nothing until a SKU is verified. */
1449
2082
  function ProductDetailsQuantity({ className }) {
1450
2083
  const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
1451
2084
  const id = useId();
2085
+ const problemId = useId();
1452
2086
  if (!selectedSku) return null;
2087
+ const problem = isSoldOut(availableQuantity) ? null : quantityProblem(quantity, availableQuantity);
1453
2088
  return /* @__PURE__ */ jsxs("div", {
1454
2089
  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
- })]
2090
+ children: [
2091
+ /* @__PURE__ */ jsx("label", {
2092
+ htmlFor: id,
2093
+ className: "mb-2 block font-medium",
2094
+ children: "Quantity"
2095
+ }),
2096
+ /* @__PURE__ */ jsx("input", {
2097
+ id,
2098
+ type: "number",
2099
+ min: 1,
2100
+ max: availableQuantity ?? void 0,
2101
+ step: 1,
2102
+ className: `${inputClass} w-28`,
2103
+ value: Number.isNaN(quantity) ? "" : quantity,
2104
+ "aria-invalid": problem ? true : void 0,
2105
+ "aria-describedby": problem ? problemId : void 0,
2106
+ onChange: (event) => setQuantity(event.target.valueAsNumber)
2107
+ }),
2108
+ problem && /* @__PURE__ */ jsx("p", {
2109
+ id: problemId,
2110
+ className: "mt-2 text-sm text-red-700",
2111
+ children: problem
2112
+ })
2113
+ ]
1469
2114
  });
1470
2115
  }
1471
2116
  /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
@@ -1563,14 +2208,17 @@ function ProductDetailsTitle({ className }) {
1563
2208
  function ProductDetailsPrice({ className }) {
1564
2209
  const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
1565
2210
  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
- })]
2211
+ return /* @__PURE__ */ jsx("div", {
2212
+ className: partClass(className),
2213
+ children: /* @__PURE__ */ jsxs("div", {
2214
+ "aria-live": "polite",
2215
+ className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
2216
+ "data-testid": "product-price",
2217
+ 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", {
2218
+ className: "text-lg font-normal",
2219
+ children: money(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
2220
+ })]
2221
+ })
1574
2222
  });
1575
2223
  }
1576
2224
  /** The verified SKU's description, or the product's. */
@@ -1586,4 +2234,4 @@ function ProductDetailsDescription({ className }) {
1586
2234
  }
1587
2235
 
1588
2236
  //#endregion
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 };
2237
+ export { AddToCartButton, ApiError, CartButton, CartCheckout, CartCheckoutNote, CartClose, CartDescription, CartDrawer, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartRoot, CartSubtotal, CartSummary, CartTitle, Catalog, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCart, useCartItem, useCommerce, useProductCard, useProductDetails, useProductDetailsOption };