@godaddy/gd-commerce-storefront 0.4.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
1
  import { Children, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
3
- import { Link, useParams, useSearchParams } from "react-router";
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,61 +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
- /** The product's display name: its label, then its name. */
152
- function getProductName(skuGroup) {
153
- return skuGroup.label ?? skuGroup.name ?? "Product";
154
- }
155
-
156
101
  //#endregion
157
102
  //#region src/sample-cart.tsx
158
103
  const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
@@ -160,6 +105,7 @@ const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
160
105
  name: `Product ${index + 1}`,
161
106
  price: (index + 1) * 1e3
162
107
  }));
108
+ const noQuantities = {};
163
109
  const SampleCartContext = createContext(null);
164
110
  function useSampleCart() {
165
111
  const value = useContext(SampleCartContext);
@@ -184,7 +130,11 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
184
130
  active.current = null;
185
131
  };
186
132
  }, [session]);
187
- 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]);
188
138
  function update(key, quantity) {
189
139
  if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) return false;
190
140
  if (quantity !== void 0 && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) return false;
@@ -204,10 +154,7 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
204
154
  }
205
155
  return /* @__PURE__ */ jsx(SampleCartContext.Provider, {
206
156
  value: {
207
- items: sampleProducts.flatMap((product) => quantities[product.key] ? [{
208
- ...product,
209
- quantity: quantities[product.key]
210
- }] : []),
157
+ items,
211
158
  add: (key) => {
212
159
  const product = sampleProducts.find((product$1) => product$1.key === key);
213
160
  if (!product || !update(key)) return false;
@@ -232,6 +179,8 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
232
179
  //#endregion
233
180
  //#region src/commerce-provider.tsx
234
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);
235
184
  function useCommerce() {
236
185
  const context = useContext(CommerceContext);
237
186
  if (!context) throw new Error("Mount CommerceProvider once around the header and page outlet, inside the router.");
@@ -251,7 +200,7 @@ async function loadConfig(signal) {
251
200
  currencyCode: config.currencyCode
252
201
  };
253
202
  }
254
- function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {} }) {
203
+ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, onItemAdded }) {
255
204
  const config = useQuery({
256
205
  retry: false,
257
206
  queryKey: ["commerce", "configuration"],
@@ -280,10 +229,11 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
280
229
  config.refetch();
281
230
  },
282
231
  theme,
232
+ onItemAdded,
283
233
  children
284
234
  });
285
235
  }
286
- function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme }) {
236
+ function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, onItemAdded }) {
287
237
  const storageKey = `godaddy:commerce-storefront:cart:${config.cartScope}`;
288
238
  const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]);
289
239
  const ready = connection === "ready";
@@ -307,6 +257,23 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
307
257
  const queue = useRef(Promise.resolve(true));
308
258
  const alive = useRef(true);
309
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
+ }
310
277
  function rememberOpener() {
311
278
  opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
312
279
  }
@@ -436,7 +403,12 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
436
403
  if (!isActive()) return;
437
404
  commit(result.cart);
438
405
  setAnnouncement(`${item.name} added to your cart.`);
439
- setOpen(true);
406
+ openDrawer();
407
+ added.current?.({
408
+ name: item.name,
409
+ quantity: item.quantity,
410
+ isSample: false
411
+ });
440
412
  });
441
413
  }
442
414
  function mutateItem(itemId, method, quantity) {
@@ -505,6 +477,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
505
477
  cart,
506
478
  open,
507
479
  setOpen: (next) => {
480
+ if (next && drawers.current === 0) return;
508
481
  if (next) rememberOpener();
509
482
  setOpen(next);
510
483
  },
@@ -523,15 +496,22 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
523
496
  refresh,
524
497
  checkout
525
498
  },
526
- children: [/* @__PURE__ */ jsx(SampleCartProvider, {
527
- enabled: connection === "unbound",
528
- announce: setAnnouncement,
529
- onAdd: (name) => {
530
- rememberOpener();
531
- setAnnouncement(`${name} added to your sample cart.`);
532
- setOpen(true);
533
- },
534
- 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
+ })
535
515
  }), /* @__PURE__ */ jsx("span", {
536
516
  className: "commerce-sr-only",
537
517
  role: "status",
@@ -541,6 +521,117 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
541
521
  });
542
522
  }
543
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
+
544
635
  //#endregion
545
636
  //#region src/storefront-surface.tsx
546
637
  function StorefrontSurface({ children, className = "" }) {
@@ -576,9 +667,8 @@ function CommerceStatus() {
576
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";
577
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";
578
669
  function CartButton() {
579
- const { cart, setOpen, open, connection } = useCommerce();
580
- const sample = useSampleCart();
581
- 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();
582
672
  return /* @__PURE__ */ jsx(StorefrontSurface, {
583
673
  className: "commerce-inline",
584
674
  children: /* @__PURE__ */ jsxs("button", {
@@ -590,16 +680,32 @@ function CartButton() {
590
680
  onClick: () => setOpen(true),
591
681
  children: ["Cart ", /* @__PURE__ */ jsx("span", {
592
682
  className: "rounded-full bg-white px-2 py-0.5 text-xs text-neutral-900",
593
- children: count
683
+ children: itemCount
594
684
  })]
595
685
  })
596
686
  });
597
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
+ }
598
696
  function AddToCartButton({ sku, name, quantity = 1 }) {
599
697
  const { addItem, pending, hydrating, error, open, connection } = useCommerce();
600
698
  const [adding, setAdding] = useState(false);
699
+ const reasonId = useId();
601
700
  const available = getAvailableInventoryQuantity(sku);
602
- 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;
603
709
  async function handleAdd() {
604
710
  if (disabled || pending || !sku.id) return;
605
711
  setAdding(true);
@@ -615,246 +721,35 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
615
721
  }
616
722
  return /* @__PURE__ */ jsxs(StorefrontSurface, {
617
723
  className: "commerce-inline",
618
- children: [/* @__PURE__ */ jsx("button", {
619
- type: "button",
620
- className: `${buttonClass} w-full`,
621
- disabled,
622
- "aria-disabled": disabled || pending,
623
- "aria-busy": adding,
624
- onClick: () => void handleAdd(),
625
- children: available === 0 ? "Out of stock" : adding ? "Adding…" : "Add to cart"
626
- }), error && !open && /* @__PURE__ */ jsx("p", {
627
- role: "alert",
628
- className: "mt-3 bg-white text-sm text-red-700",
629
- children: error
630
- })]
631
- });
632
- }
633
- function CartDrawer() {
634
- const { config, connection, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity: changeLiveQuantity, removeItem: removeLiveItem, refresh, checkout } = useCommerce();
635
- const sample = useSampleCart();
636
- const isSample = connection === "unbound";
637
- const changeQuantity = isSample ? sample.changeQuantity : changeLiveQuantity;
638
- const removeItem = isSample ? sample.remove : removeLiveItem;
639
- const [drawerAction, setDrawerAction] = useState(null);
640
- const checkingOut = drawerAction === "checkout";
641
- const locked = pending || hydrating || drawerAction !== null;
642
- const items = isSample ? sample.items.map((item) => ({
643
- id: item.key,
644
- name: item.name,
645
- quantity: item.quantity,
646
- image: void 0,
647
- options: void 0,
648
- subtotal: item.price * item.quantity,
649
- currency: "USD"
650
- })) : (cart?.lineItems ?? []).map((item) => ({
651
- id: item.id,
652
- name: item.name,
653
- quantity: item.quantity,
654
- image: item.details?.productAssetUrl,
655
- options: item.details?.selectedOptions,
656
- subtotal: item.totals?.subTotal?.value,
657
- currency: item.totals?.subTotal?.currencyCode
658
- }));
659
- const summary$1 = getCartSummaryTotals(cart);
660
- const currency = isSample ? "USD" : cart?.totals?.total?.currencyCode ?? config.currencyCode;
661
- const subtotal = isSample ? sample.items.reduce((sum, item) => sum + item.price * item.quantity, 0) : summary$1.subtotal;
662
- async function handleAction(action, operation) {
663
- if (locked) return;
664
- setDrawerAction(action);
665
- try {
666
- await operation();
667
- } finally {
668
- setDrawerAction(null);
669
- }
670
- }
671
- return /* @__PURE__ */ jsx(Dialog.Root, {
672
- open,
673
- onOpenChange: setOpen,
674
- 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, {
675
- "data-commerce-source": isSample ? "sample" : void 0,
676
- className: "fixed inset-y-0 right-0 z-50 flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
677
- onCloseAutoFocus: (event) => {
678
- event.preventDefault();
679
- restoreFocus();
680
- },
681
- children: [
682
- /* @__PURE__ */ jsxs("div", {
683
- className: "flex items-start justify-between border-b border-neutral-200 p-6",
684
- children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Dialog.Title, {
685
- className: "text-2xl font-semibold",
686
- children: isSample ? "Sample cart" : "Your cart"
687
- }), /* @__PURE__ */ jsx(Dialog.Description, {
688
- className: "mt-1 text-sm text-neutral-600",
689
- children: isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout."
690
- })] }), /* @__PURE__ */ jsx(Dialog.Close, {
691
- 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",
692
- "aria-label": "Close cart",
693
- children: "✕"
694
- })]
695
- }),
696
- /* @__PURE__ */ jsxs("div", {
697
- className: "flex-1 overflow-y-auto p-6",
698
- children: [
699
- storageWarning && /* @__PURE__ */ jsx("p", {
700
- role: "status",
701
- className: "mb-4 text-sm text-amber-800",
702
- children: storageWarning
703
- }),
704
- error && /* @__PURE__ */ jsxs("div", {
705
- role: "alert",
706
- className: "mb-4 rounded-lg bg-red-50 p-4 text-red-800",
707
- children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
708
- type: "button",
709
- className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
710
- disabled: drawerAction === "refresh",
711
- "aria-disabled": locked,
712
- "aria-busy": drawerAction === "refresh",
713
- onClick: () => void handleAction("refresh", refresh),
714
- children: "Refresh cart"
715
- })]
716
- }),
717
- hydrating && /* @__PURE__ */ jsx("p", {
718
- role: "status",
719
- children: "Loading your cart…"
720
- }),
721
- !hydrating && !items.length && /* @__PURE__ */ jsxs("div", {
722
- className: "py-16 text-center",
723
- children: [
724
- /* @__PURE__ */ jsx("p", {
725
- className: "text-xl font-medium",
726
- children: "Your cart is empty"
727
- }),
728
- /* @__PURE__ */ jsx("p", {
729
- className: "mt-2 text-neutral-600",
730
- children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
731
- }),
732
- /* @__PURE__ */ jsx(Link, {
733
- className: `${buttonClass} mt-6`,
734
- to: config.catalogPath,
735
- onClick: () => setOpen(false),
736
- children: "Browse products"
737
- })
738
- ]
739
- }),
740
- /* @__PURE__ */ jsx("ul", {
741
- className: "divide-y divide-neutral-200",
742
- children: items.map((item) => /* @__PURE__ */ jsxs("li", {
743
- className: "py-5 first:pt-0",
744
- "data-testid": "cart-item",
745
- children: [/* @__PURE__ */ jsxs("div", {
746
- className: "flex gap-4",
747
- children: [item.image && /* @__PURE__ */ jsx("img", {
748
- src: item.image,
749
- alt: "",
750
- className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
751
- }), /* @__PURE__ */ jsxs("div", {
752
- className: "min-w-0 flex-1",
753
- children: [
754
- /* @__PURE__ */ jsx("h3", {
755
- className: "font-semibold",
756
- children: item.name
757
- }),
758
- item.options?.map((option) => /* @__PURE__ */ jsxs("p", {
759
- className: "text-sm text-neutral-600",
760
- children: [
761
- option.attribute,
762
- ": ",
763
- option.values?.join(", ")
764
- ]
765
- }, `${option.attribute}:${option.values?.join(",")}`)),
766
- /* @__PURE__ */ jsx("p", {
767
- className: "mt-1 text-sm",
768
- children: typeof item.subtotal === "number" ? money(item.subtotal, item.currency ?? currency) : "Price unavailable"
769
- })
770
- ]
771
- })]
772
- }), /* @__PURE__ */ jsxs("div", {
773
- className: "mt-4 flex items-center justify-between gap-2",
774
- children: [/* @__PURE__ */ jsxs("div", {
775
- className: "flex items-center rounded-lg border border-neutral-300",
776
- children: [
777
- /* @__PURE__ */ jsx("button", {
778
- type: "button",
779
- 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",
780
- "aria-label": `Decrease quantity of ${item.name}`,
781
- disabled: drawerAction === `decrease:${item.id}` || !item.id || (item.quantity ?? 0) <= 1,
782
- "aria-disabled": locked || !item.id || (item.quantity ?? 0) <= 1,
783
- "aria-busy": drawerAction === `decrease:${item.id}`,
784
- onClick: () => void handleAction(`decrease:${item.id}`, () => changeQuantity(item.id ?? "", (item.quantity ?? 1) - 1)),
785
- children: "−"
786
- }),
787
- /* @__PURE__ */ jsxs("span", {
788
- className: "min-w-8 text-center",
789
- children: [/* @__PURE__ */ jsx("span", {
790
- className: "sr-only",
791
- children: "Quantity: "
792
- }), item.quantity]
793
- }),
794
- /* @__PURE__ */ jsx("button", {
795
- type: "button",
796
- 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",
797
- "aria-label": `Increase quantity of ${item.name}`,
798
- disabled: drawerAction === `increase:${item.id}` || !item.id,
799
- "aria-disabled": locked || !item.id,
800
- "aria-busy": drawerAction === `increase:${item.id}`,
801
- onClick: () => void handleAction(`increase:${item.id}`, () => changeQuantity(item.id ?? "", (item.quantity ?? 1) + 1)),
802
- children: "+"
803
- })
804
- ]
805
- }), /* @__PURE__ */ jsx("button", {
806
- type: "button",
807
- 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",
808
- "aria-label": `Remove ${item.name}`,
809
- disabled: drawerAction === `remove:${item.id}` || !item.id,
810
- "aria-disabled": locked || !item.id,
811
- "aria-busy": drawerAction === `remove:${item.id}`,
812
- onClick: () => void handleAction(`remove:${item.id}`, () => removeItem(item.id ?? "")),
813
- children: "Remove"
814
- })]
815
- })]
816
- }, item.id))
817
- })
818
- ]
819
- }),
820
- items.length > 0 && /* @__PURE__ */ jsxs("div", {
821
- className: "border-t border-neutral-200 p-6",
822
- children: [
823
- /* @__PURE__ */ jsx("dl", {
824
- className: "space-y-2 text-sm",
825
- children: (isSample || typeof cart?.totals?.subTotal?.value === "number") && /* @__PURE__ */ jsxs("div", {
826
- className: "flex justify-between",
827
- children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(subtotal, isSample ? "USD" : cart?.totals?.subTotal?.currencyCode ?? currency) })]
828
- })
829
- }),
830
- !isSample && /* @__PURE__ */ jsx("p", {
831
- className: "commerce-cart-checkout-adjustments-note mt-3 text-sm text-neutral-600",
832
- children: "Shipping, taxes, and discounts are calculated at checkout."
833
- }),
834
- isSample ? /* @__PURE__ */ jsx("p", {
835
- className: "text-sm text-neutral-600",
836
- children: "Example total in USD. Checkout is not set up yet."
837
- }) : config.checkoutSuccessPath ? /* @__PURE__ */ jsx("button", {
838
- type: "button",
839
- className: `${buttonClass} w-full`,
840
- disabled: checkingOut || hydrating || cart?.totals?.total?.value == null,
841
- "aria-disabled": locked || cart?.totals?.total?.value == null,
842
- "aria-busy": checkingOut,
843
- onClick: () => void handleAction("checkout", checkout),
844
- children: checkingOut ? "Opening checkout…" : "Proceed to Checkout"
845
- }) : /* @__PURE__ */ jsx("p", {
846
- className: "text-sm text-neutral-600",
847
- children: "Checkout is not available yet."
848
- })
849
- ]
850
- })
851
- ]
852
- })] }) })
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
+ ]
853
746
  });
854
747
  }
855
748
 
856
749
  //#endregion
857
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 !== "");
858
753
  const cx = (...classes) => classes.filter(Boolean).join(" ");
859
754
  /** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
860
755
  const partClass = (...classes) => cx("commerce-part", ...classes);
@@ -881,6 +776,478 @@ function usePartRegistry(onChange) {
881
776
  }), [register, has]);
882
777
  }
883
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();
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();
826
+ const isSample = connection === "unbound";
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]);
851
+ return /* @__PURE__ */ jsx(Dialog.Root, {
852
+ open,
853
+ onOpenChange: setOpen,
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 })
867
+ })
868
+ })
869
+ })] }) })
870
+ });
871
+ }
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
+ })
882
+ });
883
+ }
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
+ })
900
+ });
901
+ }
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: "✕"
912
+ })
913
+ });
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
+ })]
939
+ })]
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
998
+ })
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",
1034
+ children: [
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
+
884
1251
  //#endregion
885
1252
  //#region src/product-card.tsx
886
1253
  function ProductImage({ url, name, className = "" }) {
@@ -1238,12 +1605,12 @@ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
1238
1605
  children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1239
1606
  });
1240
1607
  /**
1241
- * The product cards. `className` goes on the element that contains the cards, such as a grid. The loading
1242
- * placeholder, the error with its retry, and the empty message render just before that element.
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.
1243
1610
  */
1244
1611
  function CatalogProducts({ className, children }) {
1245
1612
  const catalog = useCatalogInternal("CatalogProducts");
1246
- const card = Children.toArray(children).some((child) => child !== "") ? children : packagedCard;
1613
+ const card = hasChildren(children) ? children : packagedCard;
1247
1614
  const nodes = catalog.source === "live" ? catalog.nodes : null;
1248
1615
  const items = useMemo(() => nodes ? nodes.map((product) => ({
1249
1616
  key: product.id ?? "",
@@ -1254,25 +1621,6 @@ function CatalogProducts({ className, children }) {
1254
1621
  })) : sampleItems, [nodes]);
1255
1622
  const products = catalog.source === "live" ? catalog.products : null;
1256
1623
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1257
- products?.isPending && /* @__PURE__ */ jsx("div", {
1258
- "aria-hidden": "true",
1259
- className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3",
1260
- children: [
1261
- 0,
1262
- 1,
1263
- 2
1264
- ].map((item) => /* @__PURE__ */ jsxs("div", {
1265
- className: "overflow-hidden rounded-lg border border-neutral-200 motion-safe:animate-pulse",
1266
- children: [/* @__PURE__ */ jsx("div", { className: "aspect-square bg-neutral-100" }), /* @__PURE__ */ jsxs("div", {
1267
- className: "space-y-4 p-6",
1268
- children: [
1269
- /* @__PURE__ */ jsx("div", { className: "h-6 w-2/3 rounded bg-neutral-100" }),
1270
- /* @__PURE__ */ jsx("div", { className: "h-4 rounded bg-neutral-100" }),
1271
- /* @__PURE__ */ jsx("div", { className: "h-11 rounded bg-neutral-100" })
1272
- ]
1273
- })]
1274
- }, item))
1275
- }),
1276
1624
  products?.isError && /* @__PURE__ */ jsxs("div", {
1277
1625
  role: "alert",
1278
1626
  className: "rounded-lg bg-red-50 p-5 text-red-800",
@@ -1289,7 +1637,22 @@ function CatalogProducts({ className, children }) {
1289
1637
  }),
1290
1638
  /* @__PURE__ */ jsx("div", {
1291
1639
  className: partClass(className),
1292
- children: items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
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, {
1293
1656
  value: item,
1294
1657
  children: card
1295
1658
  }, key))
@@ -1711,31 +2074,43 @@ function ProductDetailsAvailability({ className }) {
1711
2074
  children: /* @__PURE__ */ jsx("p", {
1712
2075
  className: "text-sm",
1713
2076
  "aria-live": "polite",
1714
- 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`
1715
2078
  })
1716
2079
  });
1717
2080
  }
1718
- /** 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. */
1719
2082
  function ProductDetailsQuantity({ className }) {
1720
2083
  const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
1721
2084
  const id = useId();
2085
+ const problemId = useId();
1722
2086
  if (!selectedSku) return null;
2087
+ const problem = isSoldOut(availableQuantity) ? null : quantityProblem(quantity, availableQuantity);
1723
2088
  return /* @__PURE__ */ jsxs("div", {
1724
2089
  className: partClass(className),
1725
- children: [/* @__PURE__ */ jsx("label", {
1726
- htmlFor: id,
1727
- className: "mb-2 block font-medium",
1728
- children: "Quantity"
1729
- }), /* @__PURE__ */ jsx("input", {
1730
- id,
1731
- type: "number",
1732
- min: 1,
1733
- max: availableQuantity ?? void 0,
1734
- step: 1,
1735
- className: `${inputClass} w-28`,
1736
- value: Number.isNaN(quantity) ? "" : quantity,
1737
- onChange: (event) => setQuantity(event.target.valueAsNumber)
1738
- })]
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
+ ]
1739
2114
  });
1740
2115
  }
1741
2116
  /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
@@ -1859,4 +2234,4 @@ function ProductDetailsDescription({ className }) {
1859
2234
  }
1860
2235
 
1861
2236
  //#endregion
1862
- export { AddToCartButton, ApiError, CartButton, CartDrawer, 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, useCommerce, useProductCard, 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 };