@godaddy/gd-commerce-storefront 0.5.0 → 0.7.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,6 +1,6 @@
1
- import { Children, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
1
+ import { Children, Fragment, createContext, isValidElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import { useQuery } from "@tanstack/react-query";
3
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
4
4
  import * as Dialog from "@radix-ui/react-dialog";
5
5
  import { Link, useParams, useSearchParams } from "react-router";
6
6
 
@@ -61,8 +61,9 @@ async function request(path, init) {
61
61
  throw cause;
62
62
  }
63
63
  }
64
- function money(value, currency) {
65
- const formatter = new Intl.NumberFormat("en-US", {
64
+ /** Formats minor units, such as cents, as currency. Inside the provider, prefer `useMoney`, which follows its `locale`. */
65
+ function money(value, currency, locale = "en-US") {
66
+ const formatter = new Intl.NumberFormat(locale, {
66
67
  style: "currency",
67
68
  currency
68
69
  });
@@ -112,7 +113,7 @@ function useSampleCart() {
112
113
  if (!value) throw new Error("Mount CommerceProvider around sample products.");
113
114
  return value;
114
115
  }
115
- function SampleCartProvider({ enabled, onAdd, announce, children }) {
116
+ function SampleCartProvider({ enabled, onAdd, announce, emit, children }) {
116
117
  const session = useMemo(() => ({ enabled }), [enabled]);
117
118
  const active = useRef(session);
118
119
  active.current = session;
@@ -163,12 +164,27 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
163
164
  },
164
165
  changeQuantity: async (key, quantity) => {
165
166
  const updated = quantity >= 1 && update(key, quantity);
166
- if (updated) announce("Sample cart quantity updated.");
167
+ if (updated) {
168
+ announce("Sample cart quantity updated.");
169
+ emit({
170
+ type: "quantity-changed",
171
+ itemId: key,
172
+ quantity,
173
+ isSample: true
174
+ });
175
+ }
167
176
  return updated;
168
177
  },
169
178
  remove: async (key) => {
170
179
  const updated = update(key, 0);
171
- if (updated) announce("Sample item removed from cart.");
180
+ if (updated) {
181
+ announce("Sample item removed from cart.");
182
+ emit({
183
+ type: "item-removed",
184
+ itemId: key,
185
+ isSample: true
186
+ });
187
+ }
172
188
  return updated;
173
189
  }
174
190
  },
@@ -186,7 +202,39 @@ function useCommerce() {
186
202
  if (!context) throw new Error("Mount CommerceProvider once around the header and page outlet, inside the router.");
187
203
  return context;
188
204
  }
205
+ /**
206
+ * Formats minor units, such as cents, in the provider's `locale`, the way the package's own parts do. Uses the
207
+ * store's currency unless `currency` is given.
208
+ */
209
+ function useMoney() {
210
+ const { locale, config } = useCommerce();
211
+ const { currencyCode } = config;
212
+ return useCallback((value, currency = currencyCode) => money(value, currency, locale), [locale, currencyCode]);
213
+ }
189
214
  const CONNECTING_FAILURE_GRACE_MS = 6e4;
215
+ /** `locale` as a canonical tag, or `en-US` when it is not a valid one, so one bad prop cannot break every price. */
216
+ function resolveLocale(locale) {
217
+ if (!locale) return "en-US";
218
+ try {
219
+ return Intl.getCanonicalLocales(locale)[0] ?? "en-US";
220
+ } catch {
221
+ if (process.env.NODE_ENV !== "production") console.warn(`[gd-commerce-storefront] "${locale}" is not a valid locale, so prices use en-US.`);
222
+ return "en-US";
223
+ }
224
+ }
225
+ const logCallbackFailure = (cause) => console.error("[gd-commerce-storefront] A storefront callback threw.", cause);
226
+ /**
227
+ * Runs a host callback so that its failure cannot fail the cart operation that triggered it. An async callback is
228
+ * not awaited; its rejection is logged instead of becoming an unhandled rejection.
229
+ */
230
+ function callHost(callback) {
231
+ try {
232
+ const result = callback();
233
+ if (typeof result?.then === "function") Promise.resolve(result).catch(logCallbackFailure);
234
+ } catch (cause) {
235
+ logCallbackFailure(cause);
236
+ }
237
+ }
190
238
  async function loadConfig(signal) {
191
239
  const config = await request("/config", { signal });
192
240
  if (config?.state === "unbound" || config?.state === "connecting") {
@@ -200,7 +248,8 @@ async function loadConfig(signal) {
200
248
  currencyCode: config.currencyCode
201
249
  };
202
250
  }
203
- function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, onItemAdded }) {
251
+ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, getSwatch, locale, onItemAdded, onEvent }) {
252
+ const resolvedLocale = useMemo(() => resolveLocale(locale), [locale]);
204
253
  const config = useQuery({
205
254
  retry: false,
206
255
  queryKey: ["commerce", "configuration"],
@@ -229,11 +278,14 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
229
278
  config.refetch();
230
279
  },
231
280
  theme,
281
+ getSwatch,
282
+ locale: resolvedLocale,
232
283
  onItemAdded,
284
+ onEvent,
233
285
  children
234
286
  });
235
287
  }
236
- function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, onItemAdded }) {
288
+ function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, getSwatch, locale, onItemAdded, onEvent }) {
237
289
  const storageKey = `godaddy:commerce-storefront:cart:${config.cartScope}`;
238
290
  const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]);
239
291
  const ready = connection === "ready";
@@ -257,8 +309,26 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
257
309
  const queue = useRef(Promise.resolve(true));
258
310
  const alive = useRef(true);
259
311
  const opener = useRef(null);
260
- const added = useRef(onItemAdded);
261
- added.current = onItemAdded;
312
+ const callbacks = useRef({
313
+ onItemAdded,
314
+ onEvent
315
+ });
316
+ callbacks.current = {
317
+ onItemAdded,
318
+ onEvent
319
+ };
320
+ /** Reports an event to the host. A callback that throws is logged, and the operation still succeeds. */
321
+ function emit(event) {
322
+ if (event.type === "item-added") {
323
+ const { name, quantity, isSample } = event;
324
+ callHost(() => callbacks.current.onItemAdded?.({
325
+ name,
326
+ quantity,
327
+ isSample
328
+ }));
329
+ }
330
+ callHost(() => callbacks.current.onEvent?.(event));
331
+ }
262
332
  const drawers = useRef(0);
263
333
  const registerDrawer = useCallback(() => {
264
334
  drawers.current += 1;
@@ -325,7 +395,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
325
395
  throw cause;
326
396
  }
327
397
  }
328
- function run(operation, loading = false) {
398
+ function run(kind, operation, loading = false) {
329
399
  const task = queue.current.then(async () => {
330
400
  if (!isActive()) return false;
331
401
  setPending(true);
@@ -338,7 +408,15 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
338
408
  else await operation();
339
409
  return isActive();
340
410
  } catch (cause) {
341
- if (isActive()) setError(message(cause));
411
+ if (isActive()) {
412
+ const text = message(cause);
413
+ setError(text);
414
+ emit({
415
+ type: "error",
416
+ operation: kind,
417
+ message: text
418
+ });
419
+ }
342
420
  return false;
343
421
  } finally {
344
422
  if (isActive()) {
@@ -351,7 +429,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
351
429
  return task;
352
430
  }
353
431
  function refresh() {
354
- return run(hydrate, true);
432
+ return run("refresh", hydrate, true);
355
433
  }
356
434
  useEffect(() => {
357
435
  alive.current = true;
@@ -382,7 +460,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
382
460
  }, [cartIdStorage, connection]);
383
461
  function addItem(item) {
384
462
  rememberOpener();
385
- return run(async () => {
463
+ return run("add", async () => {
386
464
  if (!item.skuId || !item.name || !Number.isInteger(item.quantity) || item.quantity < 1) throw new Error("Select a product variant and a positive quantity.");
387
465
  await hydrate();
388
466
  if (!isActive()) return;
@@ -404,7 +482,9 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
404
482
  commit(result.cart);
405
483
  setAnnouncement(`${item.name} added to your cart.`);
406
484
  openDrawer();
407
- added.current?.({
485
+ emit({
486
+ type: "item-added",
487
+ skuId: item.skuId,
408
488
  name: item.name,
409
489
  quantity: item.quantity,
410
490
  isSample: false
@@ -412,7 +492,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
412
492
  });
413
493
  }
414
494
  function mutateItem(itemId, method, quantity) {
415
- return run(async () => {
495
+ return run(method === "DELETE" ? "remove" : "change-quantity", async () => {
416
496
  await hydrate();
417
497
  if (!isActive()) return;
418
498
  if (!id.current || !currentCart.current?.lineItems?.some((item) => item.id === itemId)) throw new Error("This item is no longer in the cart.");
@@ -425,10 +505,20 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
425
505
  if (!isActive()) return;
426
506
  commit(result.cart);
427
507
  setAnnouncement(method === "DELETE" ? "Item removed from cart." : "Cart quantity updated.");
508
+ emit(method === "DELETE" ? {
509
+ type: "item-removed",
510
+ itemId,
511
+ isSample: false
512
+ } : {
513
+ type: "quantity-changed",
514
+ itemId,
515
+ quantity: quantity ?? 0,
516
+ isSample: false
517
+ });
428
518
  });
429
519
  }
430
520
  function applyDiscount(code) {
431
- return run(async () => {
521
+ return run("discount", async () => {
432
522
  await hydrate();
433
523
  if (!isActive()) return;
434
524
  if (!id.current || !code.trim()) throw new Error("Add an item and enter a promotion code.");
@@ -441,10 +531,14 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
441
531
  if (!isActive()) return;
442
532
  commit(result.cart);
443
533
  setAnnouncement("Promotion codes updated.");
534
+ emit({
535
+ type: "discount-applied",
536
+ code: code.trim()
537
+ });
444
538
  });
445
539
  }
446
540
  function checkout() {
447
- return run(async () => {
541
+ return run("checkout", async () => {
448
542
  if (!config.checkoutSuccessPath) throw new Error("Checkout has not been enabled for this store.");
449
543
  await hydrate();
450
544
  if (!isActive()) return;
@@ -464,6 +558,10 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
464
558
  if (typeof session$1.url !== "string" || !session$1.url.trim()) throw new Error("Commerce did not return a checkout URL.");
465
559
  const url = new URL(session$1.url, origin);
466
560
  if (url.protocol !== "https:") throw new Error("Commerce returned an invalid checkout URL.");
561
+ emit({
562
+ type: "checkout-started",
563
+ cartId: id.current
564
+ });
467
565
  window.location.assign(url.href);
468
566
  });
469
567
  }
@@ -474,6 +572,8 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
474
572
  connectionError,
475
573
  retryConnection,
476
574
  theme,
575
+ getSwatch,
576
+ locale,
477
577
  cart,
478
578
  open,
479
579
  setOpen: (next) => {
@@ -501,10 +601,13 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
501
601
  children: /* @__PURE__ */ jsx(SampleCartProvider, {
502
602
  enabled: connection === "unbound",
503
603
  announce: setAnnouncement,
604
+ emit,
504
605
  onAdd: (name) => {
505
606
  setAnnouncement(`${name} added to your sample cart.`);
506
607
  openDrawer();
507
- added.current?.({
608
+ emit({
609
+ type: "item-added",
610
+ skuId: null,
508
611
  name,
509
612
  quantity: 1,
510
613
  isSample: true
@@ -631,6 +734,129 @@ function getProductAttributes(skuGroup) {
631
734
  function getProductName(skuGroup) {
632
735
  return skuGroup.label ?? skuGroup.name ?? "Product";
633
736
  }
737
+ /** Lowercases a label for use mid-sentence, leaving acronyms alone: "Size" becomes "size", "US Size" stays. */
738
+ const inSentence = (label) => label.replace(/^[A-Z](?=[a-z])/, (first) => first.toLowerCase());
739
+ /** Whether `compareAt` is above `price`, so the price is a sale price. */
740
+ function isOnSale(price, compareAt) {
741
+ return typeof price === "number" && typeof compareAt === "number" && compareAt > price;
742
+ }
743
+ /**
744
+ * Each SKU's price paired with its own compare-at price, or `null` unless the response has every SKU and each has a
745
+ * price. The catalog list returns too few SKUs and no SKU prices, so its cards fall back to the product's ranges.
746
+ */
747
+ function getPairedPrices(product) {
748
+ const connection = product?.skus;
749
+ const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
750
+ if (!skus.length || connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? skus.length) !== skus.length) return null;
751
+ const pairs = skus.flatMap((sku) => {
752
+ const price = sku.prices?.edges?.find((entry) => entry?.node?.value)?.node;
753
+ return typeof price?.value?.value === "number" ? [{
754
+ value: price.value.value,
755
+ compareAt: price.compareAtValue?.value ?? null
756
+ }] : [];
757
+ });
758
+ return pairs.length === skus.length ? pairs : null;
759
+ }
760
+ /**
761
+ * Whether some SKU's compare-at price is above that SKU's own price. Without every SKU's price, only a product whose
762
+ * lowest compare-at price is above its highest price is certainly on sale, so other products show no badge.
763
+ */
764
+ function isProductOnSale(product) {
765
+ const pairs = getPairedPrices(product);
766
+ if (pairs) return pairs.some((pair) => isOnSale(pair.value, pair.compareAt));
767
+ return isOnSale(product?.priceRange?.max, product?.compareAtPriceRange?.min);
768
+ }
769
+ /**
770
+ * The compare-at price that belongs with the product's starting price, so a struck-through price never pairs one
771
+ * SKU's price with another SKU's compare-at price. With every SKU and its price loaded, the cheapest SKUs must share
772
+ * one compare-at price above their price. Otherwise the product needs a single price and a single compare-at price.
773
+ */
774
+ function getStartingCompareAt(product) {
775
+ const price = product?.priceRange;
776
+ const compareAt = product?.compareAtPriceRange;
777
+ const pairs = getPairedPrices(product);
778
+ if (pairs) {
779
+ const lowest = Math.min(...pairs.map((pair) => pair.value));
780
+ if (lowest !== price?.min) return null;
781
+ const compareAts = new Set(pairs.flatMap((pair) => pair.value === lowest ? [pair.compareAt] : []));
782
+ const [only] = compareAts;
783
+ return compareAts.size === 1 && only != null && isOnSale(lowest, only) ? only : null;
784
+ }
785
+ return price?.min === price?.max && compareAt?.min === compareAt?.max && isOnSale(price?.min, compareAt?.min) ? compareAt?.min ?? null : null;
786
+ }
787
+ /** The SKU's attribute value names, or `null` when the response does not include them. */
788
+ function getSkuAttributeValueNames(sku) {
789
+ const edges = sku?.attributeValues?.edges;
790
+ if (!edges) return null;
791
+ return edges.flatMap((edge) => edge?.node?.name ? [edge.node.name] : []);
792
+ }
793
+
794
+ //#endregion
795
+ //#region src/class-names.ts
796
+ 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-medium text-commerce-on-accent transition-colors hover:bg-commerce-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-commerce-accent disabled:cursor-not-allowed disabled:bg-commerce-tint-strong disabled:text-commerce-muted disabled:hover:bg-commerce-tint-strong";
797
+ /** A quieter button for repeated or secondary actions: transparent, outlined in the inherited text color. */
798
+ const secondaryButtonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-commerce-edge px-5 py-2 text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:border-commerce-line disabled:text-commerce-muted disabled:hover:bg-transparent";
799
+ 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";
800
+ /** A self-contained message box: its own background and text, so it reads on any page or surface. */
801
+ const errorNoticeClass = "rounded-lg bg-red-50 px-4 py-3 text-sm text-red-800";
802
+ /** The underlined action inside an `errorNoticeClass` box. */
803
+ const errorActionClass = "mt-1 min-h-11 font-medium text-red-800 underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-red-800 disabled:cursor-not-allowed";
804
+ /** The Sale badge: its own fill and text, so it reads over an image or on any page. */
805
+ const saleBadgeClass = "inline-flex rounded bg-commerce-sale px-2 py-0.5 text-xs font-semibold leading-5 text-commerce-on-sale";
806
+
807
+ //#endregion
808
+ //#region src/part.ts
809
+ /**
810
+ * Whether `children` renders anything, so `{custom && <Part />}` with `custom` false falls back to the default. Blank
811
+ * text and fragments holding nothing count as nothing. A component element counts as content: what it renders is
812
+ * not known until it renders.
813
+ */
814
+ const hasChildren = (children) => Children.toArray(children).some((child) => {
815
+ if (typeof child === "string") return child.trim() !== "";
816
+ if (isValidElement(child) && child.type === Fragment) return hasChildren(child.props.children);
817
+ return true;
818
+ });
819
+ const cx = (...classes) => classes.filter(Boolean).join(" ");
820
+ /** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
821
+ const partClass = (...classes) => cx("commerce-part", ...classes);
822
+ /**
823
+ * Props for a part's outer element: `partClass` plus `data-commerce-part`, the part's name in kebab case, such as
824
+ * `cart-checkout`. The attribute is a stable styling hook; internal utility classes are not.
825
+ */
826
+ const partProps = (name, ...classes) => ({
827
+ className: partClass(...classes),
828
+ "data-commerce-part": name
829
+ });
830
+ /** The host's label for `state`, or `fallback` when the host gave none or it renders nothing. */
831
+ function renderLabel(label, state, fallback) {
832
+ if (typeof label === "function") {
833
+ const content = label(state);
834
+ return hasChildren(content) ? content : fallback;
835
+ }
836
+ return hasChildren(label) ? label : fallback;
837
+ }
838
+ /**
839
+ * Counts the parts mounted under a root, by key. Parts register in an effect, and a parent's effects run after its
840
+ * children's, so the root can read `has` in its own effect. `onChange` must be stable; it runs after each change.
841
+ */
842
+ function usePartRegistry(onChange) {
843
+ const counts = useRef(/* @__PURE__ */ new Map());
844
+ const has = useCallback((key) => counts.current.has(key), []);
845
+ const register = useCallback((key) => {
846
+ counts.current.set(key, (counts.current.get(key) ?? 0) + 1);
847
+ onChange?.(has);
848
+ return () => {
849
+ const count = (counts.current.get(key) ?? 1) - 1;
850
+ if (count > 0) counts.current.set(key, count);
851
+ else counts.current.delete(key);
852
+ onChange?.(has);
853
+ };
854
+ }, [has, onChange]);
855
+ return useMemo(() => ({
856
+ register,
857
+ has
858
+ }), [register, has]);
859
+ }
634
860
 
635
861
  //#endregion
636
862
  //#region src/storefront-surface.tsx
@@ -647,15 +873,18 @@ function CommerceStatus() {
647
873
  if (connection === "ready") return null;
648
874
  return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "unbound" ? /* @__PURE__ */ jsx("p", {
649
875
  role: "status",
876
+ className: "text-sm text-commerce-muted",
650
877
  children: "Connect a store to show products."
651
878
  }) : connection === "loading" || connection === "connecting" ? /* @__PURE__ */ jsx("p", {
652
879
  role: "status",
880
+ className: "text-sm text-commerce-muted",
653
881
  children: "Connecting to the store…"
654
882
  }) : /* @__PURE__ */ jsxs("div", {
655
883
  role: "alert",
884
+ className: errorNoticeClass,
656
885
  children: [/* @__PURE__ */ jsx("p", { children: connectionError }), /* @__PURE__ */ jsx("button", {
657
886
  type: "button",
658
- className: "min-h-11 underline",
887
+ className: errorActionClass,
659
888
  onClick: retryConnection,
660
889
  children: "Retry connection"
661
890
  })]
@@ -664,24 +893,51 @@ function CommerceStatus() {
664
893
 
665
894
  //#endregion
666
895
  //#region src/cart.tsx
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";
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";
669
- function CartButton() {
896
+ /**
897
+ * Opens the cart drawer: a bag icon with a count badge that pops when the count changes. Its accessible name is
898
+ * "Cart, 2 items". With `children`, a labeled button instead.
899
+ */
900
+ function CartButton({ children } = {}) {
670
901
  const { setOpen, open, connection } = useCommerce();
671
902
  const { itemCount } = useCart();
903
+ const [bump, setBump] = useState(0);
904
+ const previous = useRef(itemCount);
905
+ useEffect(() => {
906
+ if (previous.current !== itemCount) setBump((count) => count + 1);
907
+ previous.current = itemCount;
908
+ }, [itemCount]);
909
+ const custom = renderLabel(children, { itemCount }, null);
672
910
  return /* @__PURE__ */ jsx(StorefrontSurface, {
673
911
  className: "commerce-inline",
674
- children: /* @__PURE__ */ jsxs("button", {
912
+ children: /* @__PURE__ */ jsx("button", {
675
913
  type: "button",
676
- className: buttonClass,
914
+ className: `relative inline-flex min-h-11 items-center justify-center gap-2 rounded-full text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent ${custom === null ? "min-w-11" : "px-4"}`,
677
915
  disabled: connection !== "ready" && connection !== "unbound",
678
916
  "aria-haspopup": "dialog",
679
917
  "aria-expanded": open,
918
+ "aria-label": custom === null ? `Cart, ${itemCount} ${itemCount === 1 ? "item" : "items"}` : void 0,
919
+ "data-commerce-part": "cart-button",
680
920
  onClick: () => setOpen(true),
681
- children: ["Cart ", /* @__PURE__ */ jsx("span", {
682
- className: "rounded-full bg-white px-2 py-0.5 text-xs text-neutral-900",
921
+ children: custom ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(BagIcon, {}), itemCount > 0 && /* @__PURE__ */ jsx("span", {
922
+ "aria-hidden": "true",
923
+ className: "absolute right-0 top-0.5 inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-commerce-accent px-1 text-[11px] font-semibold tabular-nums text-commerce-on-accent motion-safe:animate-commerce-pop",
683
924
  children: itemCount
684
- })]
925
+ }, bump)] })
926
+ })
927
+ });
928
+ }
929
+ function BagIcon() {
930
+ return /* @__PURE__ */ jsx("svg", {
931
+ "aria-hidden": "true",
932
+ viewBox: "0 0 20 20",
933
+ className: "h-5 w-5 shrink-0",
934
+ children: /* @__PURE__ */ jsx("path", {
935
+ d: "M4.5 6.5h11l-.8 9.1a1.5 1.5 0 0 1-1.5 1.4H6.8a1.5 1.5 0 0 1-1.5-1.4L4.5 6.5ZM7.5 6.5V6a2.5 2.5 0 0 1 5 0v.5",
936
+ fill: "none",
937
+ stroke: "currentColor",
938
+ strokeWidth: "1.5",
939
+ strokeLinecap: "round",
940
+ strokeLinejoin: "round"
685
941
  })
686
942
  });
687
943
  }
@@ -693,7 +949,17 @@ function quantityProblem(quantity, available) {
693
949
  if (available !== null && available > 0 && quantity > available) return `Only ${available} available.`;
694
950
  return null;
695
951
  }
696
- function AddToCartButton({ sku, name, quantity = 1 }) {
952
+ function AddToCartButton(props) {
953
+ return /* @__PURE__ */ jsx(AddToCart, {
954
+ ...props,
955
+ className: buttonClass
956
+ });
957
+ }
958
+ /**
959
+ * `AddToCartButton` with the package's button style chosen by the caller, such as the quieter card style. `block`
960
+ * fills the container's width; the public button stays inline wherever a host places it.
961
+ */
962
+ function AddToCart({ sku, name, quantity = 1, children, className, block = false, label, labelName }) {
697
963
  const { addItem, pending, hydrating, error, open, connection } = useCommerce();
698
964
  const [adding, setAdding] = useState(false);
699
965
  const reasonId = useId();
@@ -706,6 +972,10 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
706
972
  else if (!sku.id) reason = "This item cannot be added to the cart.";
707
973
  else if (!soldOut) reason = hydrating ? "Loading your cart…" : quantityProblem(quantity, available);
708
974
  const disabled = adding || soldOut || reason !== null;
975
+ const ownLabel = !!labelName && !soldOut && !adding && renderLabel(children, {
976
+ busy: false,
977
+ soldOut
978
+ }, null) === null;
709
979
  async function handleAdd() {
710
980
  if (disabled || pending || !sku.id) return;
711
981
  setAdding(true);
@@ -720,17 +990,22 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
720
990
  }
721
991
  }
722
992
  return /* @__PURE__ */ jsxs(StorefrontSurface, {
723
- className: "commerce-inline",
993
+ className: block ? "" : "commerce-inline",
724
994
  children: [
725
995
  /* @__PURE__ */ jsx("button", {
726
996
  type: "button",
727
- className: `${buttonClass} w-full`,
997
+ className: `${className} w-full`,
728
998
  disabled,
729
999
  "aria-disabled": disabled || pending,
730
1000
  "aria-busy": adding,
731
1001
  "aria-describedby": reason ? reasonId : void 0,
1002
+ "aria-label": ownLabel ? labelName : void 0,
1003
+ "data-commerce-part": "add-to-cart-button",
732
1004
  onClick: () => void handleAdd(),
733
- children: soldOut ? "Out of stock" : adding ? "Adding…" : "Add to cart"
1005
+ children: renderLabel(children, {
1006
+ busy: adding,
1007
+ soldOut
1008
+ }, soldOut ? "Out of stock" : adding ? "Adding…" : label ?? "Add to cart")
734
1009
  }),
735
1010
  reason && /* @__PURE__ */ jsx("span", {
736
1011
  id: reasonId,
@@ -739,43 +1014,13 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
739
1014
  }),
740
1015
  error && !open && /* @__PURE__ */ jsx("p", {
741
1016
  role: "alert",
742
- className: "mt-3 bg-white text-sm text-red-700",
1017
+ className: `mt-3 ${errorNoticeClass}`,
743
1018
  children: error
744
1019
  })
745
1020
  ]
746
1021
  });
747
1022
  }
748
1023
 
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
1024
  //#endregion
780
1025
  //#region src/cart-parts.tsx
781
1026
  const CartContext = createContext(null);
@@ -825,11 +1070,11 @@ function CartRoot({ children, className }) {
825
1070
  const { connection } = useCommerce();
826
1071
  const isSample = connection === "unbound";
827
1072
  return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" || isSample ? /* @__PURE__ */ jsx(CartProvider, { children: /* @__PURE__ */ jsx("section", {
828
- className: partClass(className),
1073
+ ...partProps("cart-root", className),
829
1074
  "data-commerce-source": isSample ? "sample" : void 0,
830
1075
  children
831
1076
  }) }) : /* @__PURE__ */ jsx("div", {
832
- className: partClass(className),
1077
+ ...partProps("cart-root", className),
833
1078
  children: /* @__PURE__ */ jsx(CommerceStatus, {})
834
1079
  }) });
835
1080
  }
@@ -851,12 +1096,12 @@ function CartDrawerRoot({ children, className }) {
851
1096
  return /* @__PURE__ */ jsx(Dialog.Root, {
852
1097
  open,
853
1098
  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", {
1099
+ children: /* @__PURE__ */ jsx(Dialog.Portal, { children: /* @__PURE__ */ jsxs(StorefrontSurface, { children: [/* @__PURE__ */ jsx(Dialog.Overlay, { className: "fixed inset-0 z-40 bg-neutral-950/40 motion-safe:animate-commerce-fade-in" }), /* @__PURE__ */ jsx("div", {
855
1100
  className: "pointer-events-none fixed inset-0 z-50 flex justify-end",
856
1101
  children: /* @__PURE__ */ jsx(Dialog.Content, {
857
1102
  "data-commerce-source": connection === "unbound" ? "sample" : void 0,
858
1103
  ...described ? {} : { "aria-describedby": void 0 },
859
- className: partClass("pointer-events-auto", className),
1104
+ ...partProps("cart-drawer-root", "pointer-events-auto", className),
860
1105
  onCloseAutoFocus: (event) => {
861
1106
  event.preventDefault();
862
1107
  restoreFocus();
@@ -869,32 +1114,42 @@ function CartDrawerRoot({ children, className }) {
869
1114
  })] }) })
870
1115
  });
871
1116
  }
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");
1117
+ /**
1118
+ * "Your cart", or "Sample cart" before a store is connected, as an H2, with the item count once there are items.
1119
+ * In the drawer it names the dialog.
1120
+ */
1121
+ function CartTitle({ className, children }) {
1122
+ const { isSample, itemCount } = useCartInternal("CartTitle");
875
1123
  const Heading = useContext(CartDrawerContext) ? Dialog.Title : "h2";
876
1124
  return /* @__PURE__ */ jsx("div", {
877
- className: partClass(className),
1125
+ ...partProps("cart-title", className),
878
1126
  children: /* @__PURE__ */ jsx(Heading, {
879
- className: "text-2xl font-semibold",
880
- children: isSample ? "Sample cart" : "Your cart"
1127
+ className: "text-lg font-semibold tracking-tight",
1128
+ children: renderLabel(children, { isSample }, /* @__PURE__ */ jsxs(Fragment$1, { children: [isSample ? "Sample cart" : "Your cart", itemCount > 0 && /* @__PURE__ */ jsxs("span", {
1129
+ className: "font-normal text-commerce-muted",
1130
+ children: [
1131
+ " (",
1132
+ itemCount,
1133
+ ")"
1134
+ ]
1135
+ })] }))
881
1136
  })
882
1137
  });
883
1138
  }
884
1139
  /** One line about the cart. In the drawer it describes the dialog. */
885
- function CartDescription({ className }) {
1140
+ function CartDescription({ className, children }) {
886
1141
  const { isSample } = useCartInternal("CartDescription");
887
1142
  const drawer = useContext(CartDrawerContext);
888
1143
  const register = drawer?.register;
889
1144
  useEffect(() => register?.("description"), [register]);
890
- const text = isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout.";
1145
+ const text = renderLabel(children, { isSample }, isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout.");
891
1146
  return /* @__PURE__ */ jsx("div", {
892
- className: partClass(className),
1147
+ ...partProps("cart-description", className),
893
1148
  children: drawer ? /* @__PURE__ */ jsx(Dialog.Description, {
894
- className: "text-sm text-neutral-600",
1149
+ className: "text-sm text-commerce-muted",
895
1150
  children: text
896
1151
  }) : /* @__PURE__ */ jsx("p", {
897
- className: "text-sm text-neutral-600",
1152
+ className: "text-sm text-commerce-muted",
898
1153
  children: text
899
1154
  })
900
1155
  });
@@ -904,11 +1159,22 @@ function CartClose({ className }) {
904
1159
  useCartInternal("CartClose");
905
1160
  if (!useContext(CartDrawerContext)) return null;
906
1161
  return /* @__PURE__ */ jsx("div", {
907
- className: partClass(className),
1162
+ ...partProps("cart-close", className),
908
1163
  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",
1164
+ className: "inline-flex min-h-11 min-w-11 items-center justify-center rounded-full text-commerce-muted transition-colors hover:bg-commerce-tint hover:text-inherit focus-visible:outline focus-visible:outline-2 focus-visible:outline-current",
910
1165
  "aria-label": "Close cart",
911
- children: "✕"
1166
+ children: /* @__PURE__ */ jsx("svg", {
1167
+ "aria-hidden": "true",
1168
+ viewBox: "0 0 20 20",
1169
+ className: "h-5 w-5",
1170
+ children: /* @__PURE__ */ jsx("path", {
1171
+ d: "M5 5l10 10M15 5L5 15",
1172
+ fill: "none",
1173
+ stroke: "currentColor",
1174
+ strokeWidth: "1.5",
1175
+ strokeLinecap: "round"
1176
+ })
1177
+ })
912
1178
  })
913
1179
  });
914
1180
  }
@@ -917,19 +1183,19 @@ function CartNotices({ className }) {
917
1183
  const { storageWarning, error, action, locked, run, refresh } = useCartInternal("CartNotices");
918
1184
  if (!storageWarning && !error) return null;
919
1185
  return /* @__PURE__ */ jsx("div", {
920
- className: partClass(className),
1186
+ ...partProps("cart-notices", className),
921
1187
  children: /* @__PURE__ */ jsxs("div", {
922
1188
  className: "flex flex-col gap-4",
923
1189
  children: [storageWarning && /* @__PURE__ */ jsx("p", {
924
1190
  role: "status",
925
- className: "text-sm text-amber-800",
1191
+ className: "rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-900",
926
1192
  children: storageWarning
927
1193
  }), error && /* @__PURE__ */ jsxs("div", {
928
1194
  role: "alert",
929
- className: "rounded-lg bg-red-50 p-4 text-red-800",
1195
+ className: errorNoticeClass,
930
1196
  children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
931
1197
  type: "button",
932
- className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
1198
+ className: errorActionClass,
933
1199
  disabled: action === "refresh",
934
1200
  "aria-disabled": locked,
935
1201
  "aria-busy": action === "refresh",
@@ -969,10 +1235,11 @@ function CartItems({ className, children }) {
969
1235
  value: registry.register,
970
1236
  children: [hydrating && /* @__PURE__ */ jsx("p", {
971
1237
  role: "status",
1238
+ className: "py-4 text-sm text-commerce-muted",
972
1239
  children: "Loading your cart…"
973
1240
  }), /* @__PURE__ */ jsx("div", {
974
1241
  role: "list",
975
- className: partClass(className),
1242
+ ...partProps("cart-items", className),
976
1243
  children: items.map((item, index) => /* @__PURE__ */ jsx(CartItemsContext.Provider, {
977
1244
  value: item,
978
1245
  children: template
@@ -990,7 +1257,7 @@ function CartItemRoot({ children, className }) {
990
1257
  value: item,
991
1258
  children: /* @__PURE__ */ jsx("div", {
992
1259
  role: "listitem",
993
- className: partClass(className),
1260
+ ...partProps("cart-item-root", className),
994
1261
  "data-testid": "cart-item",
995
1262
  children: /* @__PURE__ */ jsx(CartItemsContext.Provider, {
996
1263
  value: null,
@@ -1004,11 +1271,11 @@ function CartItemImage({ className }) {
1004
1271
  const { imageUrl } = useCartItemInternal("CartItemImage");
1005
1272
  if (!imageUrl) return null;
1006
1273
  return /* @__PURE__ */ jsx("div", {
1007
- className: partClass(className),
1274
+ ...partProps("cart-item-image", className),
1008
1275
  children: /* @__PURE__ */ jsx("img", {
1009
1276
  src: imageUrl,
1010
1277
  alt: "",
1011
- className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
1278
+ className: "h-20 w-20 bg-commerce-tint object-cover"
1012
1279
  })
1013
1280
  });
1014
1281
  }
@@ -1016,62 +1283,79 @@ function CartItemImage({ className }) {
1016
1283
  function CartItemName({ className }) {
1017
1284
  const { name } = useCartItemInternal("CartItemName");
1018
1285
  return /* @__PURE__ */ jsx("div", {
1019
- className: partClass(className),
1286
+ ...partProps("cart-item-name", className),
1020
1287
  children: /* @__PURE__ */ jsx("h3", {
1021
- className: "font-semibold",
1288
+ className: "break-words text-sm font-medium leading-6",
1022
1289
  children: name
1023
1290
  })
1024
1291
  });
1025
1292
  }
1026
- /** The selected options, one per line, such as "Color: Blue". Renders nothing without options. */
1293
+ /** The selected options on one line, such as "Color: Blue · Size: M". Renders nothing without options. */
1027
1294
  function CartItemOptions({ className }) {
1028
1295
  const { options } = useCartItemInternal("CartItemOptions");
1029
1296
  if (!options.length) return null;
1030
1297
  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: [
1298
+ ...partProps("cart-item-options", className),
1299
+ children: /* @__PURE__ */ jsx("p", {
1300
+ className: "text-sm text-commerce-muted",
1301
+ children: options.map((option, index) => /* @__PURE__ */ jsxs("span", { children: [
1302
+ index > 0 && " · ",
1035
1303
  option.attribute,
1036
1304
  ": ",
1037
1305
  option.values.join(", ")
1038
- ]
1039
- }, `${option.attribute}:${option.values.join(",")}`))
1306
+ ] }, `${option.attribute}:${option.values.join(",")}`))
1307
+ })
1040
1308
  });
1041
1309
  }
1042
1310
  /** The line total. */
1043
1311
  function CartItemPrice({ className }) {
1044
1312
  const { subtotal, currency } = useCartItemInternal("CartItemPrice");
1313
+ const money$1 = useMoney();
1045
1314
  return /* @__PURE__ */ jsx("div", {
1046
- className: partClass(className),
1315
+ ...partProps("cart-item-price", className),
1047
1316
  children: /* @__PURE__ */ jsx("p", {
1048
- className: "text-sm",
1049
- children: subtotal === null ? "Price unavailable" : money(subtotal, currency)
1317
+ className: "text-sm leading-6 tabular-nums",
1318
+ children: subtotal === null ? "Price unavailable" : money$1(subtotal, currency)
1319
+ })
1320
+ });
1321
+ }
1322
+ const stepperButtonClass$1 = "inline-flex min-h-11 min-w-11 items-center justify-center rounded-lg transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent";
1323
+ function StepperIcon$1({ plus }) {
1324
+ return /* @__PURE__ */ jsx("svg", {
1325
+ "aria-hidden": "true",
1326
+ viewBox: "0 0 16 16",
1327
+ className: "h-4 w-4",
1328
+ children: /* @__PURE__ */ jsx("path", {
1329
+ d: plus ? "M3.5 8h9M8 3.5v9" : "M3.5 8h9",
1330
+ fill: "none",
1331
+ stroke: "currentColor",
1332
+ strokeWidth: "1.5",
1333
+ strokeLinecap: "round"
1050
1334
  })
1051
1335
  });
1052
1336
  }
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
1337
  /** Decrease and increase buttons around the line's quantity. */
1055
1338
  function CartItemQuantity({ className }) {
1056
1339
  const { id, name, quantity } = useCartItemInternal("CartItemQuantity");
1057
1340
  const { action, locked, run, changeQuantity } = useCartInternal("CartItemQuantity");
1058
1341
  return /* @__PURE__ */ jsx("div", {
1059
- className: partClass(className),
1342
+ ...partProps("cart-item-quantity", className),
1060
1343
  children: /* @__PURE__ */ jsxs("div", {
1061
- className: "flex items-center rounded-lg border border-neutral-300",
1344
+ className: "inline-flex items-center rounded-lg border border-commerce-edge",
1062
1345
  children: [
1063
1346
  /* @__PURE__ */ jsx("button", {
1064
1347
  type: "button",
1065
- className: stepperButtonClass,
1348
+ className: stepperButtonClass$1,
1066
1349
  "aria-label": `Decrease quantity of ${name}`,
1350
+ "data-commerce-control": "decrease",
1067
1351
  disabled: action === `decrease:${id}` || !id || quantity <= 1,
1068
1352
  "aria-disabled": locked || !id || quantity <= 1,
1069
1353
  "aria-busy": action === `decrease:${id}`,
1070
1354
  onClick: () => run(`decrease:${id}`, () => changeQuantity(id, quantity - 1)),
1071
- children: "−"
1355
+ children: /* @__PURE__ */ jsx(StepperIcon$1, {})
1072
1356
  }),
1073
1357
  /* @__PURE__ */ jsxs("span", {
1074
- className: "min-w-8 text-center",
1358
+ className: "min-w-8 text-center text-sm tabular-nums",
1075
1359
  children: [/* @__PURE__ */ jsx("span", {
1076
1360
  className: "sr-only",
1077
1361
  children: "Quantity: "
@@ -1079,51 +1363,53 @@ function CartItemQuantity({ className }) {
1079
1363
  }),
1080
1364
  /* @__PURE__ */ jsx("button", {
1081
1365
  type: "button",
1082
- className: stepperButtonClass,
1366
+ className: stepperButtonClass$1,
1083
1367
  "aria-label": `Increase quantity of ${name}`,
1368
+ "data-commerce-control": "increase",
1084
1369
  disabled: action === `increase:${id}` || !id,
1085
1370
  "aria-disabled": locked || !id,
1086
1371
  "aria-busy": action === `increase:${id}`,
1087
1372
  onClick: () => run(`increase:${id}`, () => changeQuantity(id, quantity + 1)),
1088
- children: "+"
1373
+ children: /* @__PURE__ */ jsx(StepperIcon$1, { plus: true })
1089
1374
  })
1090
1375
  ]
1091
1376
  })
1092
1377
  });
1093
1378
  }
1094
1379
  /** Removes the line. */
1095
- function CartItemRemove({ className }) {
1380
+ function CartItemRemove({ className, children }) {
1096
1381
  const { id, name } = useCartItemInternal("CartItemRemove");
1097
1382
  const { action, locked, run, removeItem } = useCartInternal("CartItemRemove");
1098
1383
  return /* @__PURE__ */ jsx("div", {
1099
- className: partClass(className),
1100
- children: /* @__PURE__ */ jsx("button", {
1384
+ ...partProps("cart-item-remove", className),
1385
+ children: /* @__PURE__ */ jsxs("button", {
1101
1386
  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}`,
1387
+ className: "min-h-11 rounded-sm px-1 text-sm text-commerce-muted underline-offset-4 transition-colors hover:text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:hover:no-underline",
1104
1388
  disabled: action === `remove:${id}` || !id,
1105
1389
  "aria-disabled": locked || !id,
1106
1390
  "aria-busy": action === `remove:${id}`,
1107
1391
  onClick: () => run(`remove:${id}`, () => removeItem(id)),
1108
- children: "Remove"
1392
+ children: [renderLabel(children, { busy: action === `remove:${id}` }, "Remove"), /* @__PURE__ */ jsxs("span", {
1393
+ className: "sr-only",
1394
+ children: [" ", name]
1395
+ })]
1109
1396
  })
1110
1397
  });
1111
1398
  }
1112
1399
  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
- ]
1400
+ className: "flex gap-4 py-5",
1401
+ children: [/* @__PURE__ */ jsx(CartItemImage, { className: "shrink-0" }), /* @__PURE__ */ jsxs("div", {
1402
+ className: "flex min-w-0 flex-1 flex-col",
1403
+ children: [/* @__PURE__ */ jsxs("div", {
1404
+ className: "flex items-start justify-between gap-4",
1405
+ children: [/* @__PURE__ */ jsxs("div", {
1406
+ className: "min-w-0",
1407
+ children: [/* @__PURE__ */ jsx(CartItemName, {}), /* @__PURE__ */ jsx(CartItemOptions, {})]
1408
+ }), /* @__PURE__ */ jsx(CartItemPrice, { className: "shrink-0" })]
1409
+ }), /* @__PURE__ */ jsxs("div", {
1410
+ className: "mt-3 flex items-center justify-between gap-2",
1411
+ children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1123
1412
  })]
1124
- }), /* @__PURE__ */ jsxs("div", {
1125
- className: "mt-4 flex items-center justify-between gap-2",
1126
- children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1127
1413
  })]
1128
1414
  });
1129
1415
  /** Renders only when the cart is empty: a message and a link to the catalog, or its children. */
@@ -1131,18 +1417,34 @@ function CartEmpty({ className, children }) {
1131
1417
  const { items, hydrating, isSample, catalogPath, close } = useCartInternal("CartEmpty");
1132
1418
  if (hydrating || items.length) return null;
1133
1419
  return /* @__PURE__ */ jsx("div", {
1134
- className: partClass(className),
1135
- children: hasChildren(children) ? children : /* @__PURE__ */ jsxs(Fragment, { children: [
1420
+ ...partProps("cart-empty", className),
1421
+ children: hasChildren(children) ? children : /* @__PURE__ */ jsxs(Fragment$1, { children: [
1422
+ /* @__PURE__ */ jsx("div", {
1423
+ className: "mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-commerce-tint text-commerce-muted",
1424
+ children: /* @__PURE__ */ jsx("svg", {
1425
+ "aria-hidden": "true",
1426
+ viewBox: "0 0 20 20",
1427
+ className: "h-6 w-6",
1428
+ children: /* @__PURE__ */ jsx("path", {
1429
+ d: "M4.5 6.5h11l-.8 9.1a1.5 1.5 0 0 1-1.5 1.4H6.8a1.5 1.5 0 0 1-1.5-1.4L4.5 6.5ZM7.5 6.5V6a2.5 2.5 0 0 1 5 0v.5",
1430
+ fill: "none",
1431
+ stroke: "currentColor",
1432
+ strokeWidth: "1.5",
1433
+ strokeLinecap: "round",
1434
+ strokeLinejoin: "round"
1435
+ })
1436
+ })
1437
+ }),
1136
1438
  /* @__PURE__ */ jsx("p", {
1137
- className: "text-xl font-medium",
1439
+ className: "text-base font-semibold",
1138
1440
  children: "Your cart is empty"
1139
1441
  }),
1140
1442
  /* @__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."
1443
+ className: "mt-1 text-sm text-commerce-muted",
1444
+ children: isSample ? "Add a sample product to try the cart." : "Add something you love and it will show up here."
1143
1445
  }),
1144
1446
  /* @__PURE__ */ jsx(Link, {
1145
- className: `${buttonClass} mt-6`,
1447
+ className: `${secondaryButtonClass} mt-6`,
1146
1448
  to: catalogPath,
1147
1449
  onClick: close,
1148
1450
  children: "Browse products"
@@ -1155,37 +1457,38 @@ function CartSummary({ className, children }) {
1155
1457
  const { items } = useCartInternal("CartSummary");
1156
1458
  if (!items.length) return null;
1157
1459
  return /* @__PURE__ */ jsx("div", {
1158
- className: partClass(className),
1460
+ ...partProps("cart-summary", className),
1159
1461
  children
1160
1462
  });
1161
1463
  }
1162
1464
  /** The subtotal, labeled "Sample subtotal" for samples. Renders nothing until the server prices the cart. */
1163
1465
  function CartSubtotal({ className }) {
1164
1466
  const { items, subtotal, currency, isSample } = useCartInternal("CartSubtotal");
1467
+ const money$1 = useMoney();
1165
1468
  if (!items.length || subtotal === null) return null;
1166
1469
  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
- })
1470
+ ...partProps("cart-subtotal", className),
1471
+ children: /* @__PURE__ */ jsx("dl", { children: /* @__PURE__ */ jsxs("div", {
1472
+ className: "flex justify-between gap-4 font-semibold",
1473
+ children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", {
1474
+ className: "tabular-nums",
1475
+ children: money$1(subtotal, currency)
1476
+ })]
1477
+ }) })
1175
1478
  });
1176
1479
  }
1177
1480
  /**
1178
- * "Shipping, taxes, and discounts are calculated at checkout." Has the stable
1481
+ * A row under the subtotal: "Shipping, taxes & discounts", "Calculated at checkout". Has the stable
1179
1482
  * `commerce-cart-checkout-adjustments-note` class. Renders nothing for samples or an empty cart.
1180
1483
  */
1181
1484
  function CartCheckoutNote({ className }) {
1182
1485
  const { items, isSample } = useCartInternal("CartCheckoutNote");
1183
1486
  if (isSample || !items.length) return null;
1184
1487
  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."
1488
+ ...partProps("cart-checkout-note", "commerce-cart-checkout-adjustments-note", className),
1489
+ children: /* @__PURE__ */ jsxs("dl", {
1490
+ className: "flex justify-between gap-4 text-sm text-commerce-muted",
1491
+ children: [/* @__PURE__ */ jsx("dt", { children: "Shipping, taxes & discounts" }), /* @__PURE__ */ jsx("dd", { children: "Calculated at checkout" })]
1189
1492
  })
1190
1493
  });
1191
1494
  }
@@ -1193,16 +1496,16 @@ function CartCheckoutNote({ className }) {
1193
1496
  * The checkout button when `checkoutSuccessPath` is set; otherwise a message that checkout is unavailable.
1194
1497
  * Samples never check out. Renders nothing for an empty cart.
1195
1498
  */
1196
- function CartCheckout({ className }) {
1499
+ function CartCheckout({ className, children }) {
1197
1500
  const { items, isSample, checkoutEnabled, checkoutReady, hydrating, action, locked, run, checkout } = useCartInternal("CartCheckout");
1198
1501
  if (!items.length) return null;
1199
1502
  let content;
1200
1503
  if (isSample) content = /* @__PURE__ */ jsx("p", {
1201
- className: "text-sm text-neutral-600",
1504
+ className: "text-sm text-commerce-muted",
1202
1505
  children: "Example total in USD. Checkout is not set up yet."
1203
1506
  });
1204
1507
  else if (!checkoutEnabled) content = /* @__PURE__ */ jsx("p", {
1205
- className: "text-sm text-neutral-600",
1508
+ className: "text-sm text-commerce-muted",
1206
1509
  children: "Checkout is not available yet."
1207
1510
  });
1208
1511
  else content = /* @__PURE__ */ jsx("button", {
@@ -1212,36 +1515,66 @@ function CartCheckout({ className }) {
1212
1515
  "aria-disabled": locked || !checkoutReady,
1213
1516
  "aria-busy": action === "checkout",
1214
1517
  onClick: () => run("checkout", checkout),
1215
- children: action === "checkout" ? "Opening checkout…" : "Proceed to Checkout"
1518
+ children: renderLabel(children, { busy: action === "checkout" }, action === "checkout" ? "Opening checkout…" : "Proceed to checkout")
1216
1519
  });
1217
1520
  return /* @__PURE__ */ jsx("div", {
1218
- className: partClass(className),
1521
+ ...partProps("cart-checkout", className),
1219
1522
  children: content
1220
1523
  });
1221
1524
  }
1525
+ /**
1526
+ * "Continue shopping": closes the drawer, or on a cart page links to the catalog. Renders nothing for an empty cart,
1527
+ * whose `CartEmpty` links to the catalog.
1528
+ */
1529
+ function CartContinueShopping({ className, children }) {
1530
+ const { items, isSample, catalogPath } = useCartInternal("CartContinueShopping");
1531
+ const inDrawer = !!useContext(CartDrawerContext);
1532
+ if (!items.length) return null;
1533
+ const label = renderLabel(children, { isSample }, "Continue shopping");
1534
+ const linkClass = "inline-flex min-h-11 w-full items-center justify-center rounded-lg px-5 py-2 text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current";
1535
+ return /* @__PURE__ */ jsx("div", {
1536
+ ...partProps("cart-continue-shopping", className),
1537
+ children: inDrawer ? /* @__PURE__ */ jsx(Dialog.Close, {
1538
+ className: linkClass,
1539
+ children: label
1540
+ }) : /* @__PURE__ */ jsx(Link, {
1541
+ className: linkClass,
1542
+ to: catalogPath,
1543
+ children: label
1544
+ })
1545
+ });
1546
+ }
1547
+ function SampleDescription({ className }) {
1548
+ const { isSample } = useCartInternal("CartDescription");
1549
+ return isSample ? /* @__PURE__ */ jsx(CartDescription, { className }) : null;
1550
+ }
1222
1551
  /** The packaged cart drawer. Compose `CartDrawerRoot` and the cart parts for a custom drawer. */
1223
1552
  function CartDrawer() {
1224
1553
  return /* @__PURE__ */ jsxs(CartDrawerRoot, {
1225
- className: "flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
1554
+ className: "flex w-full max-w-md flex-col bg-white text-neutral-900 shadow-2xl motion-safe:animate-commerce-drawer-in",
1226
1555
  children: [
1227
1556
  /* @__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, {})]
1557
+ className: "flex items-start justify-between gap-4 border-b border-commerce-line py-3 pl-6 pr-3",
1558
+ children: [/* @__PURE__ */ jsxs("div", {
1559
+ className: "min-w-0 pt-2.5",
1560
+ children: [/* @__PURE__ */ jsx(CartTitle, {}), /* @__PURE__ */ jsx(SampleDescription, { className: "mt-0.5" })]
1561
+ }), /* @__PURE__ */ jsx(CartClose, {})]
1230
1562
  }),
1231
1563
  /* @__PURE__ */ jsxs("div", {
1232
- className: "flex-1 overflow-y-auto p-6",
1564
+ className: "flex-1 overflow-y-auto px-6",
1233
1565
  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" })
1566
+ /* @__PURE__ */ jsx(CartNotices, { className: "mt-6" }),
1567
+ /* @__PURE__ */ jsx(CartItems, { className: "divide-y divide-commerce-line" }),
1568
+ /* @__PURE__ */ jsx(CartEmpty, { className: "py-20 text-center" })
1237
1569
  ]
1238
1570
  }),
1239
1571
  /* @__PURE__ */ jsxs(CartSummary, {
1240
- className: "border-t border-neutral-200 p-6",
1572
+ className: "border-t border-commerce-line px-6 pb-6 pt-4",
1241
1573
  children: [
1242
1574
  /* @__PURE__ */ jsx(CartSubtotal, {}),
1243
- /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-3" }),
1244
- /* @__PURE__ */ jsx(CartCheckout, {})
1575
+ /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-1" }),
1576
+ /* @__PURE__ */ jsx(CartCheckout, { className: "mt-5" }),
1577
+ /* @__PURE__ */ jsx(CartContinueShopping, { className: "mt-2" })
1245
1578
  ]
1246
1579
  })
1247
1580
  ]
@@ -1250,16 +1583,20 @@ function CartDrawer() {
1250
1583
 
1251
1584
  //#endregion
1252
1585
  //#region src/product-card.tsx
1586
+ /**
1587
+ * A product image that fills its container, with a labeled placeholder when there is no URL or it fails to load.
1588
+ * Use it in custom parts so they handle missing images like the package's own.
1589
+ */
1253
1590
  function ProductImage({ url, name, className = "" }) {
1254
- const [failed, setFailed] = useState(false);
1255
- return url && !failed ? /* @__PURE__ */ jsx("img", {
1591
+ const [failedUrl, setFailedUrl] = useState(null);
1592
+ return url && url !== failedUrl ? /* @__PURE__ */ jsx("img", {
1256
1593
  src: url,
1257
1594
  alt: name,
1258
1595
  loading: "lazy",
1259
1596
  className: `h-full w-full object-cover ${className}`,
1260
- onError: () => setFailed(true)
1597
+ onError: () => setFailedUrl(url)
1261
1598
  }) : /* @__PURE__ */ jsx("div", {
1262
- className: `flex h-full min-h-44 items-center justify-center p-6 text-center ${className}`,
1599
+ className: `flex h-full min-h-44 items-center justify-center bg-commerce-tint p-6 text-center text-sm text-commerce-muted ${className}`,
1263
1600
  role: "img",
1264
1601
  "aria-label": `${name}: image unavailable`,
1265
1602
  children: "No image available"
@@ -1338,7 +1675,7 @@ function ProductCardRoot({ children, product, className }) {
1338
1675
  const card = /* @__PURE__ */ jsx(ProductCardContext.Provider, {
1339
1676
  value,
1340
1677
  children: /* @__PURE__ */ jsx("article", {
1341
- className: partClass("group", className),
1678
+ ...partProps("product-card-root", "group", className),
1342
1679
  children: /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1343
1680
  value: null,
1344
1681
  children
@@ -1354,21 +1691,21 @@ function ProductCardRoot({ children, product, className }) {
1354
1691
  function ProductCardImage({ className }) {
1355
1692
  const card = useProductCardInternal("ProductCardImage");
1356
1693
  return /* @__PURE__ */ jsx("div", {
1357
- className: partClass(className),
1694
+ ...partProps("product-card-image", className),
1358
1695
  children: card.source === "live" ? /* @__PURE__ */ jsx(Link, {
1359
1696
  to: card.href,
1360
1697
  tabIndex: card.imageLinked ? void 0 : -1,
1361
1698
  "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",
1699
+ className: "block aspect-square overflow-hidden bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
1363
1700
  children: /* @__PURE__ */ jsx(ProductImage, {
1364
1701
  url: getPrimaryImageUrl(card.product),
1365
1702
  name: card.name,
1366
- className: "motion-safe:transition-transform motion-safe:duration-200 motion-safe:group-hover:scale-105"
1703
+ className: "motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out motion-safe:group-hover:scale-[1.03]"
1367
1704
  })
1368
1705
  }) : /* @__PURE__ */ jsx("div", {
1369
1706
  "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" })
1707
+ className: "flex aspect-square items-center justify-center bg-commerce-tint",
1708
+ children: /* @__PURE__ */ jsx("div", { className: "h-16 w-16 rounded-full border border-commerce-line" })
1372
1709
  })
1373
1710
  });
1374
1711
  }
@@ -1377,11 +1714,11 @@ function ProductCardTitle({ className }) {
1377
1714
  const { name, href, register } = useProductCardInternal("ProductCardTitle");
1378
1715
  useEffect(() => href ? register(titleLink) : void 0, [href, register]);
1379
1716
  return /* @__PURE__ */ jsx("div", {
1380
- className: partClass(className),
1717
+ ...partProps("product-card-title", className),
1381
1718
  children: /* @__PURE__ */ jsx("h2", {
1382
- className: "break-words text-2xl font-semibold leading-tight",
1719
+ className: "break-words text-base font-medium leading-6",
1383
1720
  children: href ? /* @__PURE__ */ jsx(Link, {
1384
- className: "rounded text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
1721
+ className: "rounded-sm text-inherit underline-offset-4 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
1385
1722
  to: href,
1386
1723
  children: name
1387
1724
  }) : name
@@ -1393,85 +1730,195 @@ function ProductCardDescription({ className }) {
1393
1730
  const { product } = useProductCardInternal("ProductCardDescription");
1394
1731
  if (!product?.description) return null;
1395
1732
  return /* @__PURE__ */ jsx("div", {
1396
- className: partClass(className),
1733
+ ...partProps("product-card-description", className),
1397
1734
  children: /* @__PURE__ */ jsx("p", {
1398
- className: "line-clamp-3 text-sm leading-6",
1735
+ className: "line-clamp-2 text-sm leading-6 text-commerce-muted",
1399
1736
  children: product.description
1400
1737
  })
1401
1738
  });
1402
1739
  }
1403
- /** The product's price, or its price range when variants differ. */
1740
+ const formatRange = (range, money$1) => {
1741
+ const min = range?.min;
1742
+ const max = range?.max;
1743
+ if (typeof min !== "number") return null;
1744
+ return `${money$1(min)}${typeof max === "number" && max !== min ? ` – ${money$1(max)}` : ""}`;
1745
+ };
1746
+ /**
1747
+ * "Sale" when some SKU's compare-at price is above that SKU's price. Without SKU prices, as in the catalog list, only
1748
+ * when the lowest compare-at price is above the highest price. Renders nothing otherwise, or for samples.
1749
+ */
1750
+ function ProductCardBadge({ className }) {
1751
+ const { product } = useProductCardInternal("ProductCardBadge");
1752
+ if (!isProductOnSale(product)) return null;
1753
+ return /* @__PURE__ */ jsx("div", {
1754
+ ...partProps("product-card-badge", className),
1755
+ children: /* @__PURE__ */ jsx("span", {
1756
+ className: saleBadgeClass,
1757
+ children: "Sale"
1758
+ })
1759
+ });
1760
+ }
1761
+ /** The product's price, or its price range when variants differ. A single price on sale shows its compare-at price. */
1404
1762
  function ProductCardPrice({ className }) {
1405
1763
  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
- }
1764
+ const money$1 = useMoney();
1765
+ const text = card.source === "sample" ? money$1(card.sample.price, "USD") : formatRange(card.product.priceRange, money$1) ?? "Price unavailable";
1766
+ const range = card.source === "live" ? card.product.priceRange : null;
1767
+ const starting = card.source === "live" && range?.min === range?.max ? getStartingCompareAt(card.product) : null;
1768
+ const compareAt = starting === null ? null : money$1(starting);
1414
1769
  return /* @__PURE__ */ jsx("div", {
1415
- className: partClass(className),
1416
- children: /* @__PURE__ */ jsx("p", {
1417
- className: "text-lg font-semibold leading-7",
1418
- children: text
1770
+ ...partProps("product-card-price", className),
1771
+ children: /* @__PURE__ */ jsxs("p", {
1772
+ className: "flex flex-wrap items-baseline gap-x-2 text-sm leading-6 tabular-nums",
1773
+ children: [/* @__PURE__ */ jsx("span", {
1774
+ className: "font-medium",
1775
+ children: text
1776
+ }), compareAt && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
1777
+ className: "sr-only",
1778
+ children: "Was"
1779
+ }), /* @__PURE__ */ jsx("del", {
1780
+ className: "text-commerce-muted",
1781
+ children: compareAt
1782
+ })] })]
1419
1783
  })
1420
1784
  });
1421
1785
  }
1422
1786
  /**
1787
+ * Dots for the first attribute whose values all have a swatch from the provider's `getSwatch`, such as colors.
1788
+ * Renders nothing without `getSwatch`, for samples, or when no attribute has swatches.
1789
+ */
1790
+ function ProductCardSwatches({ className }) {
1791
+ const { product } = useProductCardInternal("ProductCardSwatches");
1792
+ const { getSwatch } = useCommerce();
1793
+ if (!getSwatch || !product) return null;
1794
+ for (const attribute of getProductAttributes(product)) {
1795
+ const swatches = attribute.values.map((value) => ({
1796
+ ...value,
1797
+ background: getSwatch({
1798
+ attribute: attribute.name,
1799
+ name: value.name,
1800
+ label: value.label
1801
+ })
1802
+ }));
1803
+ if (!swatches.length || swatches.some((swatch) => !swatch.background)) continue;
1804
+ const shown = swatches.slice(0, 5);
1805
+ return /* @__PURE__ */ jsx("div", {
1806
+ ...partProps("product-card-swatches", className),
1807
+ children: /* @__PURE__ */ jsxs("p", {
1808
+ className: "flex items-center gap-1.5 text-xs text-commerce-muted",
1809
+ children: [
1810
+ /* @__PURE__ */ jsxs("span", {
1811
+ className: "sr-only",
1812
+ children: [
1813
+ swatches.length,
1814
+ " ",
1815
+ inSentence(attribute.label),
1816
+ " options:",
1817
+ " ",
1818
+ swatches.map((swatch) => swatch.label).join(", ")
1819
+ ]
1820
+ }),
1821
+ shown.map((swatch) => /* @__PURE__ */ jsx("span", {
1822
+ "aria-hidden": "true",
1823
+ className: "h-3.5 w-3.5 rounded-full border border-commerce-edge",
1824
+ style: { background: swatch.background ?? void 0 }
1825
+ }, swatch.name)),
1826
+ swatches.length > shown.length && /* @__PURE__ */ jsxs("span", {
1827
+ "aria-hidden": "true",
1828
+ children: ["+", swatches.length - shown.length]
1829
+ })
1830
+ ]
1831
+ })
1832
+ });
1833
+ }
1834
+ return null;
1835
+ }
1836
+ /**
1423
1837
  * Adds the product directly when it has exactly one SKU and no options; otherwise links to its page to choose.
1424
1838
  * Samples add to the sample cart.
1425
1839
  */
1426
- function ProductCardAction({ className }) {
1840
+ function ProductCardAction({ className, children }) {
1427
1841
  const card = useProductCardInternal("ProductCardAction");
1428
1842
  return /* @__PURE__ */ jsx("div", {
1429
- className: partClass(className),
1843
+ ...partProps("product-card-action", className),
1430
1844
  children: card.source === "live" ? /* @__PURE__ */ jsx(LiveAction, {
1431
1845
  product: card.product,
1432
1846
  name: card.name,
1433
- href: card.href
1434
- }) : /* @__PURE__ */ jsx(SampleAction, { sample: card.sample })
1847
+ href: card.href,
1848
+ label: children
1849
+ }) : /* @__PURE__ */ jsx(SampleAction, {
1850
+ sample: card.sample,
1851
+ label: children
1852
+ })
1435
1853
  });
1436
1854
  }
1437
- function LiveAction({ product, name, href }) {
1855
+ function LiveAction({ product, name, href, label }) {
1438
1856
  const selectedSku = getSingleMatchedSku(product);
1439
1857
  const hasVariants = getProductAttributes(product).length > 0;
1440
- if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCartButton, {
1858
+ if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCart, {
1441
1859
  sku: selectedSku,
1442
- name
1443
- });
1860
+ name,
1861
+ className: secondaryButtonClass,
1862
+ block: true,
1863
+ children: (state) => renderLabel(label, {
1864
+ action: "add",
1865
+ ...state
1866
+ }, null)
1867
+ });
1868
+ const action = hasVariants ? "choose-options" : "view-product";
1444
1869
  return /* @__PURE__ */ jsx(Link, {
1445
- className: buttonClass,
1870
+ className: `${secondaryButtonClass} w-full`,
1446
1871
  to: href,
1447
- children: hasVariants ? "Choose options" : "View product"
1872
+ children: renderLabel(label, {
1873
+ action,
1874
+ busy: false,
1875
+ soldOut: false
1876
+ }, hasVariants ? "Choose options" : "View product")
1448
1877
  });
1449
1878
  }
1450
- function SampleAction({ sample }) {
1879
+ function SampleAction({ sample, label }) {
1451
1880
  const { add } = useSampleCart();
1452
- return /* @__PURE__ */ jsx("button", {
1881
+ const custom = renderLabel(label, {
1882
+ action: "add",
1883
+ busy: false,
1884
+ soldOut: false
1885
+ }, null);
1886
+ return custom === null ? /* @__PURE__ */ jsx("button", {
1453
1887
  type: "button",
1454
- className: buttonClass,
1888
+ className: `${secondaryButtonClass} w-full`,
1455
1889
  "aria-label": `Add ${sample.name} to sample cart`,
1456
1890
  onClick: () => add(sample.key),
1457
1891
  children: "Add to cart"
1892
+ }) : /* @__PURE__ */ jsxs("button", {
1893
+ type: "button",
1894
+ className: `${secondaryButtonClass} w-full`,
1895
+ onClick: () => add(sample.key),
1896
+ children: [custom, /* @__PURE__ */ jsxs("span", {
1897
+ className: "sr-only",
1898
+ children: [
1899
+ " (",
1900
+ sample.name,
1901
+ ", sample)"
1902
+ ]
1903
+ })]
1458
1904
  });
1459
1905
  }
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`. */
1906
+ const packagedCardClass = "flex min-w-0 flex-col";
1907
+ /**
1908
+ * The packaged card's parts, inside a root that has `packagedCardClass`. No description: cards scan better without
1909
+ * it, and `ProductCardDescription` adds it back.
1910
+ */
1462
1911
  function PackagedCardParts() {
1463
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsxs("div", {
1464
- className: "flex flex-1 flex-col p-6",
1912
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
1913
+ className: "relative",
1914
+ children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsx(ProductCardBadge, { className: "pointer-events-none absolute left-3 top-3" })]
1915
+ }), /* @__PURE__ */ jsxs("div", {
1916
+ className: "flex flex-1 flex-col pt-3",
1465
1917
  children: [
1466
1918
  /* @__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
- })
1919
+ /* @__PURE__ */ jsx(ProductCardPrice, { className: "mt-0.5" }),
1920
+ /* @__PURE__ */ jsx(ProductCardSwatches, { className: "mt-2" }),
1921
+ /* @__PURE__ */ jsx(ProductCardAction, { className: "mt-auto pt-4" })
1475
1922
  ]
1476
1923
  })] });
1477
1924
  }
@@ -1484,9 +1931,22 @@ function ProductCard({ product }) {
1484
1931
  });
1485
1932
  }
1486
1933
 
1934
+ //#endregion
1935
+ //#region src/root-params.ts
1936
+ /**
1937
+ * A root's search params: the page URL's, or with `urlState` off, the root's own. A second root on the page, such
1938
+ * as a quick view, then neither reads nor changes the page's params, and its state ends when it unmounts.
1939
+ */
1940
+ function useRootParams(urlState) {
1941
+ const [urlParams, setUrlParams] = useSearchParams();
1942
+ const [localParams, setLocalParams] = useState(() => new URLSearchParams());
1943
+ return urlState ? [urlParams, setUrlParams] : [localParams, setLocalParams];
1944
+ }
1945
+
1487
1946
  //#endregion
1488
1947
  //#region src/catalog.tsx
1489
1948
  const CatalogContext = createContext(null);
1949
+ const CatalogEmptyContext = createContext(null);
1490
1950
  const sampleCatalog = { source: "sample" };
1491
1951
  function useCatalogInternal(part) {
1492
1952
  const context = useContext(CatalogContext);
@@ -1494,32 +1954,47 @@ function useCatalogInternal(part) {
1494
1954
  return context;
1495
1955
  }
1496
1956
  /** 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 }) {
1957
+ function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = false, pageSize = 6, urlState = true }) {
1498
1958
  const { connection } = useCommerce();
1959
+ const [custom, setCustom] = useState(false);
1960
+ const { register, has } = usePartRegistry(useCallback((registered) => setCustom(registered("empty")), []));
1961
+ useEffect(() => {
1962
+ setCustom(has("empty"));
1963
+ }, [has]);
1964
+ const empty = useMemo(() => ({
1965
+ register,
1966
+ custom
1967
+ }), [register, custom]);
1968
+ if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw new Error(`CatalogRoot pageSize must be a whole number of 1 or more, not ${pageSize}.`);
1499
1969
  let content;
1500
1970
  if (connection === "ready") content = /* @__PURE__ */ jsx(CatalogLoader, {
1501
1971
  className,
1972
+ pageSize,
1973
+ urlState,
1502
1974
  children
1503
1975
  });
1504
1976
  else if (connection === "unbound" && sampleProducts$1) content = /* @__PURE__ */ jsx(CatalogContext.Provider, {
1505
1977
  value: sampleCatalog,
1506
1978
  children: /* @__PURE__ */ jsx("section", {
1507
- className: partClass(className),
1979
+ ...partProps("catalog-root", className),
1508
1980
  "data-commerce-source": "sample",
1509
1981
  children
1510
1982
  })
1511
1983
  });
1512
1984
  else content = /* @__PURE__ */ jsx("div", {
1513
- className: partClass(className),
1985
+ ...partProps("catalog-root", className),
1514
1986
  children: /* @__PURE__ */ jsx(CommerceStatus, {})
1515
1987
  });
1516
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: content });
1988
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: /* @__PURE__ */ jsx(CatalogEmptyContext.Provider, {
1989
+ value: empty,
1990
+ children: content
1991
+ }) });
1517
1992
  }
1518
- function CatalogLoader({ children, className }) {
1993
+ function CatalogLoader({ children, className, pageSize, urlState }) {
1519
1994
  const { config } = useCommerce();
1520
- const [params, setParams] = useSearchParams();
1995
+ const [params, setParams] = useRootParams(urlState);
1521
1996
  const after = params.get("after") ?? "";
1522
- const urlParams = new URLSearchParams({ first: "6" });
1997
+ const urlParams = new URLSearchParams({ first: String(pageSize) });
1523
1998
  if (after) urlParams.set("after", after);
1524
1999
  const products = useQuery({
1525
2000
  retry: false,
@@ -1527,6 +2002,7 @@ function CatalogLoader({ children, className }) {
1527
2002
  "commerce",
1528
2003
  config.cartScope,
1529
2004
  "products",
2005
+ pageSize,
1530
2006
  after
1531
2007
  ],
1532
2008
  queryFn: ({ signal }) => request(`/products?${urlParams}`, {
@@ -1556,7 +2032,7 @@ function CatalogLoader({ children, className }) {
1556
2032
  return /* @__PURE__ */ jsx(CatalogContext.Provider, {
1557
2033
  value,
1558
2034
  children: /* @__PURE__ */ jsx("section", {
1559
- className: partClass(className),
2035
+ ...partProps("catalog-root", className),
1560
2036
  children
1561
2037
  })
1562
2038
  });
@@ -1565,12 +2041,12 @@ function CatalogLoader({ children, className }) {
1565
2041
  function CatalogHeader({ title = "Shop all products", description = "Explore the collection and find your favorites.", className }) {
1566
2042
  useCatalogInternal("CatalogHeader");
1567
2043
  return /* @__PURE__ */ jsxs("header", {
1568
- className: partClass(className),
2044
+ ...partProps("catalog-header", className),
1569
2045
  children: [/* @__PURE__ */ jsx("h1", {
1570
- className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
2046
+ className: "max-w-3xl text-balance text-3xl font-semibold leading-tight tracking-tight",
1571
2047
  children: title
1572
2048
  }), /* @__PURE__ */ jsx("p", {
1573
- className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
2049
+ className: "mt-1 max-w-xl text-base leading-7 text-commerce-muted",
1574
2050
  children: description
1575
2051
  })]
1576
2052
  });
@@ -1579,9 +2055,9 @@ function CatalogHeader({ title = "Shop all products", description = "Explore the
1579
2055
  function CatalogSummary({ className }) {
1580
2056
  const catalog = useCatalogInternal("CatalogSummary");
1581
2057
  return /* @__PURE__ */ jsx("div", {
1582
- className: partClass(className),
2058
+ ...partProps("catalog-summary", className),
1583
2059
  children: /* @__PURE__ */ jsx("p", {
1584
- className: "text-sm",
2060
+ className: "text-sm text-commerce-muted",
1585
2061
  role: "status",
1586
2062
  children: summary(catalog)
1587
2063
  })
@@ -1593,6 +2069,8 @@ function summary(catalog) {
1593
2069
  if (catalog.products.isError) return "Products unavailable";
1594
2070
  return `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? "" : "s"}`;
1595
2071
  }
2072
+ /** Two columns on phones, then as many 240px columns as fit. Shared with `RelatedProducts`. */
2073
+ const catalogGridClass = "grid grid-cols-2 gap-x-4 gap-y-10 sm:gap-x-6 md:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] lg:gap-y-12";
1596
2074
  const sampleItems = sampleProducts.map((sample) => ({
1597
2075
  key: sample.key,
1598
2076
  item: {
@@ -1600,17 +2078,18 @@ const sampleItems = sampleProducts.map((sample) => ({
1600
2078
  sample
1601
2079
  }
1602
2080
  }));
1603
- const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
2081
+ const packagedCard$1 = /* @__PURE__ */ jsx(ProductCardRoot, {
1604
2082
  className: packagedCardClass,
1605
2083
  children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1606
2084
  });
1607
2085
  /**
1608
2086
  * 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.
2087
+ * element holds placeholder cards. The error with its retry and, without a `CatalogEmpty`, the empty message render just before it.
1610
2088
  */
1611
2089
  function CatalogProducts({ className, children }) {
1612
2090
  const catalog = useCatalogInternal("CatalogProducts");
1613
- const card = hasChildren(children) ? children : packagedCard;
2091
+ const customEmpty = useContext(CatalogEmptyContext)?.custom ?? false;
2092
+ const card = hasChildren(children) ? children : packagedCard$1;
1614
2093
  const nodes = catalog.source === "live" ? catalog.nodes : null;
1615
2094
  const items = useMemo(() => nodes ? nodes.map((product) => ({
1616
2095
  key: product.id ?? "",
@@ -1620,38 +2099,38 @@ function CatalogProducts({ className, children }) {
1620
2099
  }
1621
2100
  })) : sampleItems, [nodes]);
1622
2101
  const products = catalog.source === "live" ? catalog.products : null;
1623
- return /* @__PURE__ */ jsxs(Fragment, { children: [
2102
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
1624
2103
  products?.isError && /* @__PURE__ */ jsxs("div", {
1625
2104
  role: "alert",
1626
- className: "rounded-lg bg-red-50 p-5 text-red-800",
2105
+ className: errorNoticeClass,
1627
2106
  children: [/* @__PURE__ */ jsx("p", { children: message(products.error) }), /* @__PURE__ */ jsx("button", {
1628
2107
  type: "button",
1629
- className: "min-h-11 bg-red-50 text-red-800 underline",
2108
+ className: errorActionClass,
1630
2109
  onClick: () => void products.refetch(),
1631
2110
  children: "Retry products"
1632
2111
  })]
1633
2112
  }),
1634
- products?.isSuccess && !items.length && /* @__PURE__ */ jsx("p", {
1635
- className: "py-16 text-center",
2113
+ products?.isSuccess && !items.length && !customEmpty && /* @__PURE__ */ jsx("p", {
2114
+ className: "py-16 text-center text-commerce-muted",
1636
2115
  children: "No products available."
1637
2116
  }),
1638
2117
  /* @__PURE__ */ jsx("div", {
1639
- className: partClass(className),
2118
+ ...partProps("catalog-products", className),
1640
2119
  children: products?.isPending ? [
1641
2120
  0,
1642
2121
  1,
1643
2122
  2
1644
2123
  ].map((placeholder) => /* @__PURE__ */ jsxs("div", {
1645
2124
  "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
- })]
2125
+ className: "motion-safe:animate-pulse",
2126
+ children: [
2127
+ /* @__PURE__ */ jsx("div", { className: "aspect-square bg-commerce-tint-strong" }),
2128
+ /* @__PURE__ */ jsxs("div", {
2129
+ className: "space-y-2 pt-3",
2130
+ children: [/* @__PURE__ */ jsx("div", { className: "h-4 w-2/3 rounded bg-commerce-tint-strong" }), /* @__PURE__ */ jsx("div", { className: "h-4 w-1/4 rounded bg-commerce-tint-strong" })]
2131
+ }),
2132
+ /* @__PURE__ */ jsx("div", { className: "mt-4 h-11 rounded-lg bg-commerce-tint" })
2133
+ ]
1655
2134
  }, placeholder)) : items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1656
2135
  value: item,
1657
2136
  children: card
@@ -1659,25 +2138,43 @@ function CatalogProducts({ className, children }) {
1659
2138
  })
1660
2139
  ] });
1661
2140
  }
1662
- /** First-page and next-page buttons. Renders nothing for samples, which have one page. */
2141
+ /**
2142
+ * Renders only when a connected store's catalog has no products: "No products available.", or its children. While
2143
+ * one is mounted, `CatalogProducts` leaves out its own empty message. Samples are never empty.
2144
+ */
2145
+ function CatalogEmpty({ className, children }) {
2146
+ const catalog = useCatalogInternal("CatalogEmpty");
2147
+ const register = useContext(CatalogEmptyContext)?.register;
2148
+ useEffect(() => register?.("empty"), [register]);
2149
+ if (catalog.source === "sample" || !catalog.products.isSuccess || catalog.nodes.length) return null;
2150
+ return /* @__PURE__ */ jsx("div", {
2151
+ ...partProps("catalog-empty", className),
2152
+ children: hasChildren(children) ? children : /* @__PURE__ */ jsx("p", { children: "No products available." })
2153
+ });
2154
+ }
2155
+ /**
2156
+ * First-page and next-page buttons. Renders nothing when there is only one page, including for samples.
2157
+ */
1663
2158
  function CatalogPagination({ className }) {
1664
2159
  const catalog = useCatalogInternal("CatalogPagination");
1665
2160
  if (catalog.source === "sample") return null;
1666
2161
  const { after, goToPage } = catalog;
1667
2162
  const pageInfo = catalog.products.data?.skuGroups?.pageInfo;
2163
+ const hasNext = !!(pageInfo?.hasNextPage && pageInfo.endCursor);
2164
+ if (!after && !hasNext) return null;
1668
2165
  return /* @__PURE__ */ jsx("nav", {
1669
2166
  "aria-label": "Product pages",
1670
- className: partClass(className),
2167
+ ...partProps("catalog-pagination", className),
1671
2168
  children: /* @__PURE__ */ jsxs("div", {
1672
- className: "flex justify-between",
1673
- children: [after ? /* @__PURE__ */ jsx("button", {
2169
+ className: "flex justify-center gap-3",
2170
+ children: [after && /* @__PURE__ */ jsx("button", {
1674
2171
  type: "button",
1675
- className: buttonClass,
2172
+ className: secondaryButtonClass,
1676
2173
  onClick: () => goToPage(),
1677
2174
  children: "First page"
1678
- }) : /* @__PURE__ */ jsx("span", {}), pageInfo?.hasNextPage && pageInfo.endCursor && /* @__PURE__ */ jsx("button", {
2175
+ }), hasNext && pageInfo?.endCursor && /* @__PURE__ */ jsx("button", {
1679
2176
  type: "button",
1680
- className: buttonClass,
2177
+ className: secondaryButtonClass,
1681
2178
  onClick: () => goToPage(pageInfo.endCursor ?? void 0),
1682
2179
  children: "Next page"
1683
2180
  })]
@@ -1685,18 +2182,20 @@ function CatalogPagination({ className }) {
1685
2182
  });
1686
2183
  }
1687
2184
  /** The packaged catalog. Compose `CatalogRoot` and the catalog parts for a custom layout. */
1688
- function Catalog({ title, description, showHeader = true, sampleProducts: sampleProducts$1 }) {
2185
+ function Catalog({ title, description, showHeader = true, sampleProducts: sampleProducts$1, pageSize }) {
1689
2186
  return /* @__PURE__ */ jsxs(CatalogRoot, {
1690
2187
  sampleProducts: sampleProducts$1,
2188
+ pageSize,
1691
2189
  children: [
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"
2190
+ /* @__PURE__ */ jsxs("div", {
2191
+ className: "flex flex-wrap items-end justify-between gap-x-4 gap-y-2 pb-6 pt-4 sm:pb-8 sm:pt-8",
2192
+ children: [showHeader && /* @__PURE__ */ jsx(CatalogHeader, {
2193
+ title,
2194
+ description
2195
+ }), /* @__PURE__ */ jsx(CatalogSummary, {})]
1696
2196
  }),
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" })
2197
+ /* @__PURE__ */ jsx(CatalogProducts, { className: catalogGridClass }),
2198
+ /* @__PURE__ */ jsx(CatalogPagination, { className: "mt-12 border-t border-commerce-line pt-6" })
1700
2199
  ]
1701
2200
  });
1702
2201
  }
@@ -1737,7 +2236,7 @@ function useProductDetails() {
1737
2236
  * A selection is never purchasable until the server verifies it.
1738
2237
  */
1739
2238
  function useProductDetailsOption(attribute) {
1740
- const { attributes, selections, selectOption, register } = useProductDetailsInternal("useProductDetailsOption");
2239
+ const { attributes, selections, selectOption, register, soldOutValues, showMissing, getSwatch } = useProductDetailsInternal("useProductDetailsOption");
1741
2240
  useEffect(() => register(`attribute:${attribute}`), [attribute, register]);
1742
2241
  const index = attributes.findIndex((candidate) => candidate.name === attribute);
1743
2242
  const match = attributes[index];
@@ -1747,10 +2246,17 @@ function useProductDetailsOption(attribute) {
1747
2246
  name: match.name,
1748
2247
  label: match.label,
1749
2248
  selected,
2249
+ missing: showMissing && !selected,
1750
2250
  values: match.values.map((value) => ({
1751
2251
  name: value.name,
1752
2252
  label: value.label,
1753
- selected: value.name === selected
2253
+ selected: value.name === selected,
2254
+ soldOut: soldOutValues[index]?.has(value.name) ?? false,
2255
+ swatch: getSwatch?.({
2256
+ attribute: match.name,
2257
+ name: value.name,
2258
+ label: value.label
2259
+ }) || null
1754
2260
  })),
1755
2261
  select: (value) => {
1756
2262
  if (value === null) selectOption(attribute, "");
@@ -1763,26 +2269,38 @@ function useSkuOptionRegistration() {
1763
2269
  const { register } = useProductDetailsInternal("ProductDetailsOptions");
1764
2270
  useEffect(() => register("sku"), [register]);
1765
2271
  }
1766
- /** Loads the product for the route's `:productId` and provides state to product parts. */
1767
- function ProductDetailsRoot({ children, className }) {
1768
- const { productId = "" } = useParams();
2272
+ /** Loads one product, by default the route's `:productId`, and provides state to product parts. */
2273
+ function ProductDetailsRoot({ productId: productIdProp, className,...props }) {
2274
+ const { productId: routeProductId = "" } = useParams();
2275
+ const productId = productIdProp ?? routeProductId;
1769
2276
  const { connection } = useCommerce();
2277
+ useEffect(() => {
2278
+ if (process.env.NODE_ENV === "production" || productId || connection !== "ready") return;
2279
+ console.warn("[gd-commerce-storefront] ProductDetailsRoot has no product to show. Pass `productId`, or render it on a route with a `:productId` parameter.");
2280
+ }, [productId, connection]);
1770
2281
  return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsLoader, {
1771
2282
  productId,
1772
2283
  className,
1773
- children
2284
+ ...props
1774
2285
  }, productId) : /* @__PURE__ */ jsx("div", {
1775
- className: partClass(className),
2286
+ ...partProps("product-details-root", className),
1776
2287
  children: /* @__PURE__ */ jsx(CommerceStatus, {})
1777
2288
  }) });
1778
2289
  }
1779
- function ProductDetailsLoader({ productId, className, children }) {
1780
- const { config } = useCommerce();
1781
- const [params, setParams] = useSearchParams();
2290
+ function ProductDetailsLoader({ productId, className, children, urlState = true, loading, notFound, error }) {
2291
+ const { config, getSwatch } = useCommerce();
2292
+ const [params, setParams] = useRootParams(urlState);
1782
2293
  const [quantity, setQuantity] = useState(1);
1783
2294
  const [imageIndex, setImageIndex] = useState(0);
2295
+ const [showMissing, setShowMissing] = useState(false);
2296
+ const [focusMissing, setFocusMissing] = useState(0);
2297
+ const root = useRef(null);
2298
+ useEffect(() => {
2299
+ if (focusMissing) focusFirstMissing(root);
2300
+ }, [focusMissing]);
1784
2301
  const product = useQuery({
1785
2302
  retry: false,
2303
+ enabled: !!productId,
1786
2304
  queryKey: [
1787
2305
  "commerce",
1788
2306
  config.cartScope,
@@ -1830,7 +2348,6 @@ function ProductDetailsLoader({ productId, className, children }) {
1830
2348
  if (value$1) next.set(key, value$1);
1831
2349
  else next.delete(key);
1832
2350
  setParams(next);
1833
- setQuantity(1);
1834
2351
  setImageIndex(0);
1835
2352
  };
1836
2353
  const { register, has } = usePartRegistry();
@@ -1841,43 +2358,50 @@ function ProductDetailsLoader({ productId, className, children }) {
1841
2358
  if (missing.length === 0) return;
1842
2359
  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.`);
1843
2360
  }, [requiredControls, has]);
1844
- if (product.isPending) return /* @__PURE__ */ jsx("div", {
1845
- className: partClass(className),
1846
- children: /* @__PURE__ */ jsx("p", {
2361
+ if (product.isPending && productId) return /* @__PURE__ */ jsx("div", {
2362
+ ...partProps("product-details-root", className),
2363
+ children: hasChildren(loading) ? loading : /* @__PURE__ */ jsx("p", {
1847
2364
  role: "status",
2365
+ className: "text-sm text-commerce-muted",
1848
2366
  children: "Loading product…"
1849
2367
  })
1850
2368
  });
1851
- if (product.isError && !productNotFound) return /* @__PURE__ */ jsxs("div", {
1852
- role: "alert",
1853
- className: partClass(className),
1854
- children: [
1855
- /* @__PURE__ */ jsx("h1", {
1856
- className: "text-2xl font-semibold",
1857
- children: "Product unavailable"
1858
- }),
1859
- /* @__PURE__ */ jsx("p", {
1860
- className: "my-4 text-red-700",
1861
- children: message(product.error)
1862
- }),
1863
- /* @__PURE__ */ jsx("button", {
1864
- type: "button",
1865
- className: buttonClass,
1866
- onClick: () => void product.refetch(),
1867
- children: "Retry product"
1868
- })
1869
- ]
1870
- });
1871
- if (productNotFound || !group) return /* @__PURE__ */ jsxs("div", {
1872
- className: partClass(className),
1873
- children: [/* @__PURE__ */ jsx("h1", {
1874
- className: "text-2xl font-semibold",
2369
+ if (product.isError && !productNotFound) {
2370
+ const retry = () => void product.refetch();
2371
+ return /* @__PURE__ */ jsx("div", {
2372
+ role: "alert",
2373
+ ...partProps("product-details-root", className),
2374
+ children: renderLabel(error, {
2375
+ message: message(product.error),
2376
+ retry
2377
+ }, /* @__PURE__ */ jsxs(Fragment$1, { children: [
2378
+ /* @__PURE__ */ jsx("h1", {
2379
+ className: "text-2xl font-semibold tracking-tight",
2380
+ children: "Product unavailable"
2381
+ }),
2382
+ /* @__PURE__ */ jsx("p", {
2383
+ className: `my-4 ${errorNoticeClass}`,
2384
+ children: message(product.error)
2385
+ }),
2386
+ /* @__PURE__ */ jsx("button", {
2387
+ type: "button",
2388
+ className: buttonClass,
2389
+ onClick: retry,
2390
+ children: "Retry product"
2391
+ })
2392
+ ] }))
2393
+ });
2394
+ }
2395
+ if (productNotFound || !group) return /* @__PURE__ */ jsx("div", {
2396
+ ...partProps("product-details-root", className),
2397
+ children: hasChildren(notFound) ? notFound : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("h1", {
2398
+ className: "text-2xl font-semibold tracking-tight",
1875
2399
  children: "Product not found"
1876
2400
  }), /* @__PURE__ */ jsx(Link, {
1877
- className: `${buttonClass} mt-4`,
2401
+ className: `${buttonClass} mt-6`,
1878
2402
  to: config.catalogPath,
1879
2403
  children: "Back to shop"
1880
- })]
2404
+ })] })
1881
2405
  });
1882
2406
  const value = {
1883
2407
  product: group,
@@ -1903,16 +2427,50 @@ function ProductDetailsLoader({ productId, className, children }) {
1903
2427
  retryVariant: () => {
1904
2428
  if (matched.isError) matched.refetch();
1905
2429
  },
1906
- register
2430
+ register,
2431
+ soldOutValues: getSoldOutValues(group, attributes, selections),
2432
+ showMissing,
2433
+ requestMissing: () => {
2434
+ setShowMissing(true);
2435
+ setFocusMissing((count) => count + 1);
2436
+ },
2437
+ getSwatch
1907
2438
  };
1908
2439
  return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
1909
2440
  value,
1910
2441
  children: /* @__PURE__ */ jsx("article", {
1911
- className: partClass(className),
2442
+ ref: root,
2443
+ ...partProps("product-details-root", className),
1912
2444
  children
1913
2445
  })
1914
2446
  });
1915
2447
  }
2448
+ function focusFirstMissing(root) {
2449
+ root.current?.querySelector("[data-commerce-missing] input:not(:disabled), [data-commerce-missing] select")?.focus();
2450
+ }
2451
+ /**
2452
+ * For each attribute, the values whose every SKU, among those with the other selected values, has tracked stock
2453
+ * used up. Needs every SKU with its attribute values, so it is empty for a truncated list or an older server.
2454
+ */
2455
+ function getSoldOutValues(group, attributes, selections) {
2456
+ const connection = group?.skus;
2457
+ const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
2458
+ const values = skus.map(getSkuAttributeValueNames);
2459
+ const complete = !connection?.pageInfo?.hasNextPage && (connection?.totalCount ?? skus.length) === skus.length && values.every((names) => names !== null);
2460
+ return attributes.map((attribute, index) => {
2461
+ const soldOut = /* @__PURE__ */ new Set();
2462
+ if (!complete) return soldOut;
2463
+ const others = selections.filter((selection, other) => other !== index && selection);
2464
+ for (const value of attribute.values) {
2465
+ const matching = skus.filter((_, sku) => {
2466
+ const names = values[sku] ?? [];
2467
+ return names.includes(value.name) && others.every((selection) => names.includes(selection));
2468
+ });
2469
+ if (matching.length && matching.every((sku) => isSoldOut(getAvailableInventoryQuantity(sku)))) soldOut.add(value.name);
2470
+ }
2471
+ return soldOut;
2472
+ });
2473
+ }
1916
2474
 
1917
2475
  //#endregion
1918
2476
  //#region src/product-purchase.tsx
@@ -1923,33 +2481,37 @@ const disabledReasons = {
1923
2481
  unavailable: "This combination is unavailable. Choose different options.",
1924
2482
  "not-purchasable": "This product is not currently available to purchase."
1925
2483
  };
1926
- /** The packaged purchase area: options, status, availability, quantity, and add to cart. */
2484
+ /** Above this many values, buttons wrap into a wall, so the option is a select. */
2485
+ const maxChoiceButtons = 8;
2486
+ /** The packaged purchase area: options, status, quantity, add to cart, and availability. */
1927
2487
  function ProductDetailsPurchase({ className }) {
1928
2488
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsPurchase");
1929
2489
  const belowOptions = attributes.length || skuOptions.length ? "-mt-4" : void 0;
1930
2490
  return /* @__PURE__ */ jsxs("div", {
1931
- className: partClass("space-y-6", className),
2491
+ ...partProps("product-details-purchase", "space-y-6", className),
1932
2492
  children: [
1933
2493
  /* @__PURE__ */ jsx(OptionControls, {}),
1934
2494
  /* @__PURE__ */ jsx(ProductDetailsPurchaseStatus, { className: belowOptions }),
1935
- /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: belowOptions }),
1936
- /* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
1937
- /* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
2495
+ /* @__PURE__ */ jsxs("div", {
2496
+ className: "flex items-start gap-3",
2497
+ children: [/* @__PURE__ */ jsx(ProductDetailsQuantity, {}), /* @__PURE__ */ jsx(ProductDetailsAddToCart, { className: "min-w-0 flex-1" })]
2498
+ }),
2499
+ /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: "-mt-3" })
1938
2500
  ]
1939
2501
  });
1940
2502
  }
1941
- /** A select for every product attribute, or the SKU list for products without attributes. */
2503
+ /** A control for every product attribute, or the SKU list for products without attributes. */
1942
2504
  function ProductDetailsOptions({ className }) {
1943
2505
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
1944
2506
  if (attributes.length === 0 && skuOptions.length === 0) return null;
1945
2507
  return /* @__PURE__ */ jsx("div", {
1946
- className: partClass("space-y-6", className),
2508
+ ...partProps("product-details-options", "space-y-6", className),
1947
2509
  children: /* @__PURE__ */ jsx(OptionControls, {})
1948
2510
  });
1949
2511
  }
1950
2512
  function OptionControls() {
1951
2513
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
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, {})] });
2514
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [attributes.map((attribute) => /* @__PURE__ */ jsx(ProductDetailsOption, { attribute: attribute.name }, attribute.id || attribute.name)), skuOptions.length > 0 && /* @__PURE__ */ jsx(SkuOption, {})] });
1953
2515
  }
1954
2516
  function Select({ children,...props }) {
1955
2517
  return /* @__PURE__ */ jsxs("div", {
@@ -1973,66 +2535,155 @@ function Select({ children,...props }) {
1973
2535
  })]
1974
2536
  });
1975
2537
  }
2538
+ const choiceButtonClass = "inline-flex min-h-11 min-w-12 items-center justify-center rounded-lg border border-commerce-edge px-4 text-sm font-medium transition-colors group-hover:border-current peer-checked:border-commerce-accent peer-checked:bg-commerce-accent peer-checked:text-commerce-on-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-commerce-accent";
2539
+ const swatchClass = "relative block h-9 w-9 overflow-hidden rounded-full border border-commerce-edge peer-checked:outline peer-checked:outline-2 peer-checked:outline-offset-2 peer-checked:outline-commerce-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-current";
2540
+ /**
2541
+ * One option as buttons (radios), swatches when every value has one, or a select for long lists. Sold-out values are
2542
+ * crossed out but stay selectable, so the shopper can change any option in any order.
2543
+ */
2544
+ function ChoiceGroup({ label, selected, choices, missing, onSelect }) {
2545
+ const name = useId();
2546
+ const errorId = useId();
2547
+ const error = missing && /* @__PURE__ */ jsxs("p", {
2548
+ id: errorId,
2549
+ className: "mt-2 inline-block rounded bg-red-50 px-2 py-1 text-sm text-red-800",
2550
+ children: [
2551
+ "Select ",
2552
+ inSentence(label),
2553
+ "."
2554
+ ]
2555
+ });
2556
+ if (choices.length > maxChoiceButtons) return /* @__PURE__ */ jsxs("fieldset", { children: [
2557
+ /* @__PURE__ */ jsx("legend", {
2558
+ className: "mb-2 text-sm font-medium",
2559
+ children: label
2560
+ }),
2561
+ /* @__PURE__ */ jsxs(Select, {
2562
+ "aria-label": label,
2563
+ "aria-invalid": missing || void 0,
2564
+ "aria-describedby": missing ? errorId : void 0,
2565
+ value: selected,
2566
+ onChange: (event) => onSelect(event.target.value),
2567
+ children: [/* @__PURE__ */ jsxs("option", {
2568
+ value: "",
2569
+ children: ["Select ", inSentence(label)]
2570
+ }), choices.map((choice) => /* @__PURE__ */ jsx("option", {
2571
+ value: choice.value,
2572
+ children: choice.soldOut ? `${choice.label} (sold out)` : choice.label
2573
+ }, choice.value))]
2574
+ }),
2575
+ error
2576
+ ] });
2577
+ const swatches = choices.every((choice) => choice.swatch);
2578
+ const selectedLabel = choices.find((choice) => choice.value === selected)?.label;
2579
+ return /* @__PURE__ */ jsxs("fieldset", {
2580
+ "aria-describedby": missing ? errorId : void 0,
2581
+ children: [
2582
+ /* @__PURE__ */ jsxs("legend", {
2583
+ className: "mb-2 flex gap-2 text-sm font-medium",
2584
+ children: [label, selectedLabel && /* @__PURE__ */ jsx("span", {
2585
+ className: "font-normal text-commerce-muted",
2586
+ children: selectedLabel
2587
+ })]
2588
+ }),
2589
+ /* @__PURE__ */ jsx("div", {
2590
+ className: `flex flex-wrap ${swatches ? "gap-3" : "gap-2"}`,
2591
+ children: choices.map((choice) => /* @__PURE__ */ jsxs("label", {
2592
+ className: "group relative cursor-pointer",
2593
+ title: swatches ? `${choice.label}${choice.soldOut ? ", sold out" : ""}` : void 0,
2594
+ children: [/* @__PURE__ */ jsx("input", {
2595
+ type: "radio",
2596
+ className: "peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0",
2597
+ name,
2598
+ value: choice.value,
2599
+ checked: choice.value === selected,
2600
+ "aria-invalid": missing || void 0,
2601
+ "aria-label": choice.soldOut ? `${choice.label}, sold out` : void 0,
2602
+ "data-commerce-control": "choice",
2603
+ onChange: () => onSelect(choice.value)
2604
+ }), swatches ? /* @__PURE__ */ jsxs("span", {
2605
+ className: `${swatchClass} ${choice.soldOut ? "opacity-50" : ""}`,
2606
+ style: { background: choice.swatch ?? void 0 },
2607
+ children: [choice.soldOut && /* @__PURE__ */ jsx("span", {
2608
+ "aria-hidden": "true",
2609
+ className: "absolute left-1/2 top-0 h-full w-0.5 -translate-x-1/2 rotate-45 bg-current"
2610
+ }), /* @__PURE__ */ jsx("span", {
2611
+ className: "sr-only",
2612
+ children: choice.label
2613
+ })]
2614
+ }) : /* @__PURE__ */ jsx("span", {
2615
+ className: `${choiceButtonClass} ${choice.soldOut ? "text-commerce-muted line-through decoration-1" : ""}`,
2616
+ children: choice.label
2617
+ })]
2618
+ }, choice.value))
2619
+ }),
2620
+ error
2621
+ ]
2622
+ });
2623
+ }
1976
2624
  function SkuOption() {
1977
- const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
1978
- const id = useId();
2625
+ const { skuOptions, explicitSkuId, selectSku, showMissing } = useProductDetailsInternal("ProductDetailsOptions");
1979
2626
  useSkuOptionRegistration();
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", {
2627
+ const missing = showMissing && !explicitSkuId;
2628
+ return /* @__PURE__ */ jsx("div", {
2629
+ ...partProps("product-details-sku-option"),
2630
+ "data-commerce-missing": missing || void 0,
2631
+ children: /* @__PURE__ */ jsx(ChoiceGroup, {
2632
+ label: "Option",
2633
+ selected: explicitSkuId,
2634
+ missing,
2635
+ onSelect: selectSku,
2636
+ choices: skuOptions.map((sku) => ({
1994
2637
  value: sku.id ?? "",
1995
- children: sku.label?.trim() || sku.name?.trim()
1996
- }, sku.id))]
1997
- })]
2638
+ label: sku.label?.trim() || sku.name?.trim() || "",
2639
+ soldOut: isSoldOut(getAvailableInventoryQuantity(sku)),
2640
+ swatch: null
2641
+ }))
2642
+ })
1998
2643
  });
1999
2644
  }
2000
- /** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
2645
+ /**
2646
+ * One product attribute: buttons, swatches when the provider's `getSwatch` gives every value one, or a select for
2647
+ * more than eight values. Use `useProductDetailsOption` for other custom controls.
2648
+ */
2001
2649
  function ProductDetailsOption({ attribute, className }) {
2002
2650
  const option = useProductDetailsOption(attribute);
2003
2651
  if (!option) return null;
2004
2652
  return /* @__PURE__ */ jsx("div", {
2005
- className: partClass(className),
2006
- children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
2007
- className: "mb-2 font-medium",
2008
- children: option.label
2009
- }), /* @__PURE__ */ jsxs(Select, {
2010
- "aria-label": option.label,
2011
- value: option.selected ?? "",
2012
- onChange: (event) => option.select(event.target.value || null),
2013
- children: [/* @__PURE__ */ jsxs("option", {
2014
- value: "",
2015
- children: ["Select ", option.label.toLowerCase()]
2016
- }), option.values.map((value) => /* @__PURE__ */ jsx("option", {
2653
+ ...partProps("product-details-option", className),
2654
+ "data-commerce-attribute": option.name,
2655
+ "data-commerce-missing": option.missing || void 0,
2656
+ children: /* @__PURE__ */ jsx(ChoiceGroup, {
2657
+ label: option.label,
2658
+ selected: option.selected ?? "",
2659
+ missing: option.missing,
2660
+ onSelect: (value) => option.select(value || null),
2661
+ choices: option.values.map((value) => ({
2017
2662
  value: value.name,
2018
- children: value.label
2019
- }, value.name))]
2020
- })] })
2663
+ label: value.label,
2664
+ soldOut: value.soldOut,
2665
+ swatch: value.swatch
2666
+ }))
2667
+ })
2021
2668
  });
2022
2669
  }
2023
- /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
2670
+ /**
2671
+ * The single message explaining why the selection is not purchasable. Renders nothing when ready, or before every
2672
+ * option is chosen, when the add button names the next one.
2673
+ */
2024
2674
  function ProductDetailsPurchaseStatus({ className }) {
2025
2675
  const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
2026
2676
  let content;
2027
2677
  switch (status) {
2028
- case "ready": return null;
2678
+ case "ready":
2679
+ case "incomplete": return null;
2029
2680
  case "variant-error":
2030
2681
  content = /* @__PURE__ */ jsxs("div", {
2031
2682
  role: "alert",
2032
- className: "text-red-700",
2683
+ className: errorNoticeClass,
2033
2684
  children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
2034
2685
  type: "button",
2035
- className: "min-h-11 bg-white text-red-700 underline",
2686
+ className: errorActionClass,
2036
2687
  onClick: retryVariant,
2037
2688
  children: "Retry availability"
2038
2689
  })]
@@ -2041,27 +2692,26 @@ function ProductDetailsPurchaseStatus({ className }) {
2041
2692
  case "unavailable":
2042
2693
  content = /* @__PURE__ */ jsx("p", {
2043
2694
  role: "status",
2044
- className: "text-sm text-red-700",
2695
+ className: errorNoticeClass,
2045
2696
  children: disabledReasons[status]
2046
2697
  });
2047
2698
  break;
2048
2699
  case "not-purchasable":
2049
2700
  content = /* @__PURE__ */ jsx("p", {
2050
- className: "text-sm",
2701
+ className: "text-sm text-commerce-muted",
2051
2702
  children: disabledReasons[status]
2052
2703
  });
2053
2704
  break;
2054
- case "incomplete":
2055
2705
  case "checking":
2056
2706
  content = /* @__PURE__ */ jsx("p", {
2057
2707
  role: "status",
2058
- className: "text-sm",
2708
+ className: "text-sm text-commerce-muted",
2059
2709
  children: disabledReasons[status]
2060
2710
  });
2061
2711
  break;
2062
2712
  }
2063
2713
  return /* @__PURE__ */ jsx("div", {
2064
- className: partClass(className),
2714
+ ...partProps("product-details-purchase-status", className),
2065
2715
  children: content
2066
2716
  });
2067
2717
  }
@@ -2070,70 +2720,153 @@ function ProductDetailsAvailability({ className }) {
2070
2720
  const { selectedSku, availableQuantity } = useProductDetailsInternal("ProductDetailsAvailability");
2071
2721
  if (!selectedSku) return null;
2072
2722
  return /* @__PURE__ */ jsx("div", {
2073
- className: partClass(className),
2723
+ ...partProps("product-details-availability", className),
2074
2724
  children: /* @__PURE__ */ jsx("p", {
2075
- className: "text-sm",
2725
+ className: "text-sm text-commerce-muted",
2076
2726
  "aria-live": "polite",
2077
2727
  children: availableQuantity === null ? "Available to order" : isSoldOut(availableQuantity) ? "This variant is out of stock" : `${availableQuantity} available`
2078
2728
  })
2079
2729
  });
2080
2730
  }
2081
- /** Quantity for the verified SKU, with a message when it cannot be added. Renders nothing until a SKU is verified. */
2731
+ const stepperButtonClass = "inline-flex min-h-11 min-w-10 items-center justify-center rounded-lg transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent";
2732
+ function StepperIcon({ plus }) {
2733
+ return /* @__PURE__ */ jsx("svg", {
2734
+ "aria-hidden": "true",
2735
+ viewBox: "0 0 16 16",
2736
+ className: "h-4 w-4",
2737
+ children: /* @__PURE__ */ jsx("path", {
2738
+ d: plus ? "M3.5 8h9M8 3.5v9" : "M3.5 8h9",
2739
+ fill: "none",
2740
+ stroke: "currentColor",
2741
+ strokeWidth: "1.5",
2742
+ strokeLinecap: "round"
2743
+ })
2744
+ });
2745
+ }
2746
+ /**
2747
+ * A quantity stepper, capped at the verified SKU's stock, with a message when a typed quantity cannot be added.
2748
+ * Disabled while the verified SKU is sold out.
2749
+ */
2082
2750
  function ProductDetailsQuantity({ className }) {
2083
- const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
2751
+ const { selectedSku, availableQuantity, quantity, setQuantity, name } = useProductDetailsInternal("ProductDetailsQuantity");
2084
2752
  const id = useId();
2085
2753
  const problemId = useId();
2086
- if (!selectedSku) return null;
2087
- const problem = isSoldOut(availableQuantity) ? null : quantityProblem(quantity, availableQuantity);
2754
+ const soldOut = !!selectedSku && isSoldOut(availableQuantity);
2755
+ const max = selectedSku && availableQuantity !== null && availableQuantity > 0 ? availableQuantity : null;
2756
+ const problem = soldOut ? null : quantityProblem(quantity, max);
2757
+ const current = Number.isInteger(quantity) ? quantity : 1;
2088
2758
  return /* @__PURE__ */ jsxs("div", {
2089
- className: partClass(className),
2759
+ ...partProps("product-details-quantity", className),
2090
2760
  children: [
2091
2761
  /* @__PURE__ */ jsx("label", {
2092
2762
  htmlFor: id,
2093
- className: "mb-2 block font-medium",
2763
+ className: "sr-only",
2094
2764
  children: "Quantity"
2095
2765
  }),
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)
2766
+ /* @__PURE__ */ jsxs("div", {
2767
+ className: "inline-flex items-center rounded-lg border border-commerce-edge",
2768
+ children: [
2769
+ /* @__PURE__ */ jsx("button", {
2770
+ type: "button",
2771
+ className: stepperButtonClass,
2772
+ "aria-label": `Decrease quantity of ${name}`,
2773
+ "data-commerce-control": "decrease",
2774
+ disabled: soldOut || current <= 1,
2775
+ onClick: () => setQuantity(current - 1),
2776
+ children: /* @__PURE__ */ jsx(StepperIcon, {})
2777
+ }),
2778
+ /* @__PURE__ */ jsx("input", {
2779
+ id,
2780
+ type: "number",
2781
+ min: 1,
2782
+ max: max ?? void 0,
2783
+ step: 1,
2784
+ disabled: soldOut,
2785
+ className: "min-h-11 w-10 appearance-none bg-transparent text-center text-sm tabular-nums [-moz-appearance:textfield] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:text-commerce-muted [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
2786
+ value: Number.isNaN(quantity) ? "" : quantity,
2787
+ "aria-invalid": problem ? true : void 0,
2788
+ "aria-describedby": problem ? problemId : void 0,
2789
+ onChange: (event) => setQuantity(event.target.valueAsNumber)
2790
+ }),
2791
+ /* @__PURE__ */ jsx("button", {
2792
+ type: "button",
2793
+ className: stepperButtonClass,
2794
+ "aria-label": `Increase quantity of ${name}`,
2795
+ "data-commerce-control": "increase",
2796
+ disabled: soldOut || max !== null && current >= max,
2797
+ onClick: () => setQuantity(current + 1),
2798
+ children: /* @__PURE__ */ jsx(StepperIcon, { plus: true })
2799
+ })
2800
+ ]
2107
2801
  }),
2108
2802
  problem && /* @__PURE__ */ jsx("p", {
2109
2803
  id: problemId,
2110
- className: "mt-2 text-sm text-red-700",
2804
+ className: `mt-2 ${errorNoticeClass}`,
2111
2805
  children: problem
2112
2806
  })
2113
2807
  ]
2114
2808
  });
2115
2809
  }
2116
- /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
2117
- function ProductDetailsAddToCart({ className }) {
2118
- const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
2810
+ /**
2811
+ * Adds the verified SKU and quantity, labeled with the total, such as "Add to cart · $64.00". Until every option is
2812
+ * chosen it names the next one, such as "Select size", and pressing it marks the missing options and focuses the
2813
+ * first. While the selection is checked or unavailable it is disabled, and its accessible description says why.
2814
+ */
2815
+ function ProductDetailsAddToCart({ className, children }) {
2816
+ const { selectedSku, name, quantity, status, price, attributes, selections, requestMissing } = useProductDetailsInternal("ProductDetailsAddToCart");
2817
+ const { config } = useCommerce();
2818
+ const money$1 = useMoney();
2119
2819
  const reasonId = useId();
2120
- return /* @__PURE__ */ jsx("div", {
2121
- className: partClass(className),
2122
- children: selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
2123
- sku: selectedSku,
2124
- name,
2125
- quantity
2126
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
2820
+ if (selectedSku) {
2821
+ const unit = price?.value?.value;
2822
+ const total = unit != null ? money$1(unit * (Number.isInteger(quantity) && quantity > 0 ? quantity : 1), price?.value?.currencyCode ?? config.currencyCode) : null;
2823
+ return /* @__PURE__ */ jsx("div", {
2824
+ ...partProps("product-details-add-to-cart", className),
2825
+ children: /* @__PURE__ */ jsx(AddToCart, {
2826
+ sku: selectedSku,
2827
+ name,
2828
+ quantity,
2829
+ className: buttonClass,
2830
+ block: true,
2831
+ label: total && `Add to cart · ${total}`,
2832
+ labelName: total ? `Add to cart, ${total}` : void 0,
2833
+ children
2834
+ })
2835
+ });
2836
+ }
2837
+ if (status === "incomplete") {
2838
+ const next = attributes.find((_, index) => !selections[index])?.label;
2839
+ return /* @__PURE__ */ jsx("div", {
2840
+ ...partProps("product-details-add-to-cart", className),
2841
+ children: /* @__PURE__ */ jsx("button", {
2842
+ type: "button",
2843
+ className: `${buttonClass} w-full`,
2844
+ "data-commerce-part": "add-to-cart-button",
2845
+ onClick: requestMissing,
2846
+ children: renderLabel(children, {
2847
+ busy: false,
2848
+ soldOut: false
2849
+ }, next ? `Select ${inSentence(next)}` : "Select an option")
2850
+ })
2851
+ });
2852
+ }
2853
+ return /* @__PURE__ */ jsxs("div", {
2854
+ ...partProps("product-details-add-to-cart", className),
2855
+ children: [/* @__PURE__ */ jsx("button", {
2127
2856
  type: "button",
2128
2857
  className: `${buttonClass} w-full`,
2129
2858
  disabled: true,
2130
2859
  "aria-describedby": reasonId,
2131
- children: "Add to cart"
2860
+ "data-commerce-part": "add-to-cart-button",
2861
+ children: renderLabel(children, {
2862
+ busy: false,
2863
+ soldOut: false
2864
+ }, "Add to cart")
2132
2865
  }), /* @__PURE__ */ jsx("span", {
2133
2866
  id: reasonId,
2134
2867
  hidden: true,
2135
2868
  children: status === "ready" ? "" : disabledReasons[status]
2136
- })] })
2869
+ })]
2137
2870
  });
2138
2871
  }
2139
2872
 
@@ -2141,28 +2874,44 @@ function ProductDetailsAddToCart({ className }) {
2141
2874
  //#region src/product-details.tsx
2142
2875
  /** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
2143
2876
  function ProductDetails() {
2144
- return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-8 sm:mb-10" }), /* @__PURE__ */ jsxs("div", {
2145
- className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
2146
- children: [/* @__PURE__ */ jsx(ProductDetailsGallery, { className: "min-w-0 lg:col-span-7" }), /* @__PURE__ */ jsxs("div", {
2147
- className: "min-w-0 lg:col-span-5 lg:py-4",
2877
+ return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-4 sm:mb-6" }), /* @__PURE__ */ jsxs("div", {
2878
+ className: "grid items-start gap-8 md:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] lg:gap-12",
2879
+ children: [/* @__PURE__ */ jsxs("div", {
2880
+ className: "relative min-w-0",
2881
+ children: [/* @__PURE__ */ jsx(ProductDetailsGallery, {}), /* @__PURE__ */ jsx(ProductDetailsBadge, { className: "pointer-events-none absolute left-4 top-4" })]
2882
+ }), /* @__PURE__ */ jsxs("div", {
2883
+ className: "min-w-0 md:pt-2",
2148
2884
  children: [
2149
2885
  /* @__PURE__ */ jsx(ProductDetailsTitle, {}),
2150
- /* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-6" }),
2151
- /* @__PURE__ */ jsx(ProductDetailsDescription, { className: "my-8" }),
2152
- /* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "border-t border-neutral-200 pt-8" })
2886
+ /* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-2" }),
2887
+ /* @__PURE__ */ jsx(ProductDetailsDescription, { className: "mt-6" }),
2888
+ /* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "mt-6 border-t border-commerce-line pt-6" })
2153
2889
  ]
2154
2890
  })]
2155
2891
  })] });
2156
2892
  }
2157
- function ProductDetailsBackLink({ className }) {
2893
+ /** A link to the catalog. */
2894
+ function ProductDetailsBackLink({ className, children }) {
2158
2895
  useProductDetailsInternal("ProductDetailsBackLink");
2159
2896
  const { config } = useCommerce();
2160
2897
  return /* @__PURE__ */ jsx("div", {
2161
- className: partClass(className),
2898
+ ...partProps("product-details-back-link", className),
2162
2899
  children: /* @__PURE__ */ jsx(Link, {
2163
- className: "inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2",
2900
+ className: "inline-flex min-h-11 items-center gap-1 text-sm text-commerce-muted underline-offset-4 transition-colors hover:text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-current",
2164
2901
  to: config.catalogPath,
2165
- children: "← Back to shop"
2902
+ children: renderLabel(children, void 0, /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("svg", {
2903
+ "aria-hidden": "true",
2904
+ viewBox: "0 0 16 16",
2905
+ className: "h-3.5 w-3.5",
2906
+ children: /* @__PURE__ */ jsx("path", {
2907
+ d: "M10 4L6 8l4 4",
2908
+ fill: "none",
2909
+ stroke: "currentColor",
2910
+ strokeWidth: "1.5",
2911
+ strokeLinecap: "round",
2912
+ strokeLinejoin: "round"
2913
+ })
2914
+ }), "Back to shop"] }))
2166
2915
  })
2167
2916
  });
2168
2917
  }
@@ -2170,20 +2919,21 @@ function ProductDetailsBackLink({ className }) {
2170
2919
  function ProductDetailsGallery({ className }) {
2171
2920
  const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal("ProductDetailsGallery");
2172
2921
  return /* @__PURE__ */ jsxs("div", {
2173
- className: partClass(className),
2922
+ ...partProps("product-details-gallery", className),
2174
2923
  children: [/* @__PURE__ */ jsx("div", {
2175
- className: "aspect-square overflow-hidden rounded-lg",
2924
+ className: "aspect-square overflow-hidden bg-commerce-tint",
2176
2925
  children: /* @__PURE__ */ jsx(ProductImage, {
2177
2926
  url: images[imageIndex],
2178
2927
  name
2179
2928
  }, images[imageIndex])
2180
2929
  }), images.length > 1 && /* @__PURE__ */ jsx("div", {
2181
- className: "mt-4 flex gap-3 overflow-x-auto",
2930
+ className: "mt-3 flex gap-3 overflow-x-auto p-1",
2182
2931
  children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
2183
2932
  type: "button",
2184
- className: `h-20 w-20 shrink-0 overflow-hidden rounded-lg border-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${imageIndex === index ? "border-neutral-900" : "border-transparent"}`,
2933
+ className: `h-20 w-20 shrink-0 overflow-hidden bg-commerce-tint outline-offset-2 transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-current ${imageIndex === index ? "outline outline-2 outline-current" : "opacity-60 hover:opacity-100"}`,
2185
2934
  "aria-label": `View image ${index + 1}`,
2186
2935
  "aria-pressed": imageIndex === index,
2936
+ "data-commerce-control": "thumbnail",
2187
2937
  onClick: () => setImageIndex(index),
2188
2938
  children: /* @__PURE__ */ jsx(ProductImage, {
2189
2939
  url,
@@ -2193,31 +2943,59 @@ function ProductDetailsGallery({ className }) {
2193
2943
  })]
2194
2944
  });
2195
2945
  }
2946
+ /**
2947
+ * "Sale" when the verified SKU's compare-at price is above its price, or, before then, when some SKU's is. Renders
2948
+ * nothing otherwise.
2949
+ */
2950
+ function ProductDetailsBadge({ className }) {
2951
+ const { price, product } = useProductDetailsInternal("ProductDetailsBadge");
2952
+ if (!(price ? isOnSale(price.value?.value, price.compareAtValue?.value) : isProductOnSale(product))) return null;
2953
+ return /* @__PURE__ */ jsx("div", {
2954
+ ...partProps("product-details-badge", className),
2955
+ children: /* @__PURE__ */ jsx("span", {
2956
+ className: saleBadgeClass,
2957
+ children: "Sale"
2958
+ })
2959
+ });
2960
+ }
2196
2961
  /** The product name as the page's H1. */
2197
2962
  function ProductDetailsTitle({ className }) {
2198
2963
  const { name } = useProductDetailsInternal("ProductDetailsTitle");
2199
2964
  return /* @__PURE__ */ jsx("div", {
2200
- className: partClass(className),
2965
+ ...partProps("product-details-title", className),
2201
2966
  children: /* @__PURE__ */ jsx("h1", {
2202
- className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
2967
+ className: "text-balance break-words text-3xl font-semibold leading-tight tracking-tight sm:text-4xl",
2203
2968
  children: name
2204
2969
  })
2205
2970
  });
2206
2971
  }
2207
- /** The verified SKU's price and compare-at price, or the product's starting price. */
2972
+ /**
2973
+ * The verified SKU's price and compare-at price, or the product's starting price. Before a SKU is verified, the
2974
+ * compare-at price shows only when it belongs to the cheapest SKUs, so it never pairs prices from different SKUs.
2975
+ */
2208
2976
  function ProductDetailsPrice({ className }) {
2209
2977
  const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
2210
2978
  const { config } = useCommerce();
2979
+ const money$1 = useMoney();
2980
+ let compareAt = null;
2981
+ if (price?.compareAtValue?.value != null && isOnSale(price.value?.value, price.compareAtValue.value)) compareAt = money$1(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode);
2982
+ else if (!price) {
2983
+ const starting = getStartingCompareAt(product);
2984
+ if (starting !== null) compareAt = money$1(starting);
2985
+ }
2211
2986
  return /* @__PURE__ */ jsx("div", {
2212
- className: partClass(className),
2987
+ ...partProps("product-details-price", className),
2213
2988
  children: /* @__PURE__ */ jsxs("div", {
2214
2989
  "aria-live": "polite",
2215
- className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
2990
+ className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xl font-medium tabular-nums",
2216
2991
  "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
- })]
2992
+ children: [price?.value?.value != null ? money$1(price.value.value, price.value.currencyCode ?? config.currencyCode) : product.priceRange?.min != null ? `From ${money$1(product.priceRange.min)}` : "Price unavailable", compareAt && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
2993
+ className: "sr-only",
2994
+ children: "Was"
2995
+ }), /* @__PURE__ */ jsx("del", {
2996
+ className: "text-base font-normal text-commerce-muted",
2997
+ children: compareAt
2998
+ })] })]
2221
2999
  })
2222
3000
  });
2223
3001
  }
@@ -2225,13 +3003,74 @@ function ProductDetailsPrice({ className }) {
2225
3003
  function ProductDetailsDescription({ className }) {
2226
3004
  const { selectedSku, product } = useProductDetailsInternal("ProductDetailsDescription");
2227
3005
  return /* @__PURE__ */ jsx("div", {
2228
- className: partClass(className),
3006
+ ...partProps("product-details-description", className),
2229
3007
  children: /* @__PURE__ */ jsx("p", {
2230
- className: "whitespace-pre-line text-base leading-7",
3008
+ className: "whitespace-pre-line text-base leading-7 text-commerce-muted",
2231
3009
  children: selectedSku?.description ?? product.description
2232
3010
  })
2233
3011
  });
2234
3012
  }
2235
3013
 
2236
3014
  //#endregion
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 };
3015
+ //#region src/related-products.tsx
3016
+ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
3017
+ className: packagedCardClass,
3018
+ children: /* @__PURE__ */ jsx(PackagedCardParts, {})
3019
+ });
3020
+ /**
3021
+ * Other products from the catalog's first page, leaving out the current one. Optional and separate from
3022
+ * `ProductDetails`: place it wherever it fits, such as after the product page. There is no related-products API, so
3023
+ * the products are the catalog's first ones. Renders nothing until they load, when none are left, if the request
3024
+ * fails, or while no store is connected.
3025
+ */
3026
+ function RelatedProducts({ productId: productIdProp, title = "You may also like", limit = 4, className, children }) {
3027
+ const { productId: routeProductId = "" } = useParams();
3028
+ const productId = productIdProp ?? routeProductId;
3029
+ const { config, connection } = useCommerce();
3030
+ const headingId = useId();
3031
+ const first = limit + 1;
3032
+ const products = useQuery({
3033
+ retry: false,
3034
+ enabled: connection === "ready",
3035
+ queryKey: [
3036
+ "commerce",
3037
+ config.cartScope,
3038
+ "products",
3039
+ first,
3040
+ ""
3041
+ ],
3042
+ queryFn: ({ signal }) => request(`/products?first=${first}`, {
3043
+ signal,
3044
+ headers: { "X-Commerce-Scope": config.cartScope }
3045
+ })
3046
+ });
3047
+ const related = useMemo(() => (products.data?.skuGroups?.edges ?? []).flatMap((edge) => edge?.node?.id && edge.node.id !== productId ? [edge.node] : []).slice(0, limit), [
3048
+ products.data,
3049
+ productId,
3050
+ limit
3051
+ ]);
3052
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 24) throw new Error(`RelatedProducts limit must be a whole number from 1 to 24, not ${limit}.`);
3053
+ if (connection !== "ready" || !related.length) return null;
3054
+ const card = hasChildren(children) ? children : packagedCard;
3055
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: /* @__PURE__ */ jsxs("section", {
3056
+ "aria-labelledby": headingId,
3057
+ ...partProps("related-products", className),
3058
+ children: [/* @__PURE__ */ jsx("h2", {
3059
+ id: headingId,
3060
+ className: "mb-6 text-xl font-semibold tracking-tight",
3061
+ children: title
3062
+ }), /* @__PURE__ */ jsx("div", {
3063
+ className: catalogGridClass,
3064
+ children: related.map((product) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
3065
+ value: {
3066
+ source: "live",
3067
+ product
3068
+ },
3069
+ children: card
3070
+ }, product.id))
3071
+ })]
3072
+ }) });
3073
+ }
3074
+
3075
+ //#endregion
3076
+ export { AddToCartButton, ApiError, CartButton, CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDrawer, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartRoot, CartSubtotal, CartSummary, CartTitle, Catalog, CatalogEmpty, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardBadge, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardSwatches, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsBadge, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, ProductImage, RelatedProducts, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCart, useCartItem, useCommerce, useMoney, useProductCard, useProductDetails, useProductDetailsOption };