@godaddy/gd-commerce-storefront 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
+ import { Children, Fragment, createContext, isValidElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
1
4
  import * as Dialog from "@radix-ui/react-dialog";
2
- import { Children, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
3
5
  import { Link, useParams, useSearchParams } from "react-router";
4
- import { useQuery } from "@tanstack/react-query";
5
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
 
7
7
  //#region src/api.ts
8
8
  var CartIdStorage = class {
@@ -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
  });
@@ -98,61 +99,6 @@ function getCartSummaryTotals(order) {
98
99
  };
99
100
  }
100
101
 
101
- //#endregion
102
- //#region src/catalog-model.ts
103
- function getSingleMatchedSku(skuGroup) {
104
- const connection = skuGroup?.skus;
105
- if (connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? 0) > 1) return null;
106
- const edges = connection?.edges ?? [];
107
- const sku = edges.length === 1 ? edges[0]?.node : null;
108
- return sku?.id ? sku : null;
109
- }
110
- function getLabeledSkuOptions(skuGroup) {
111
- if (getProductAttributes(skuGroup).length > 0) return [];
112
- const connection = skuGroup?.skus;
113
- const edges = connection?.edges ?? [];
114
- if (edges.length < 2 || connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? edges.length) !== edges.length) return [];
115
- const skus = edges.flatMap((edge) => edge?.node?.id && (edge.node.label?.trim() || edge.node.name?.trim()) ? [edge.node] : []);
116
- const labels = new Set(skus.map((sku) => (sku.label?.trim() || sku.name?.trim() || "").toLowerCase()));
117
- const ids = new Set(skus.map((sku) => sku.id));
118
- return skus.length === edges.length && labels.size === skus.length && ids.size === skus.length ? skus : [];
119
- }
120
- function getAvailableInventoryQuantity(item) {
121
- const edges = item?.inventoryCounts?.edges;
122
- if (!edges || edges.length === 0) return null;
123
- return edges.find((edge) => edge?.node?.type === "AVAILABLE")?.node?.quantity ?? 0;
124
- }
125
- const imageMediaTypes = new Set(["IMAGE", "PHOTO"]);
126
- function getImageUrls(item) {
127
- return item?.mediaObjects?.edges?.filter((edge) => imageMediaTypes.has(edge?.node?.type ?? "") && edge?.node?.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
128
- }
129
- function getPrimaryImageUrl(item) {
130
- return getImageUrls(item)[0] ?? null;
131
- }
132
- function getProductAttributes(skuGroup) {
133
- return skuGroup?.attributes?.edges?.map((edge) => {
134
- const attributeNode = edge?.node;
135
- const values = attributeNode?.values?.edges?.map((valueEdge) => {
136
- const valueNode = valueEdge?.node;
137
- return {
138
- id: valueNode?.id || "",
139
- name: valueNode?.name || "",
140
- label: valueNode?.label || valueNode?.name || ""
141
- };
142
- }) ?? [];
143
- return {
144
- id: attributeNode?.id || "",
145
- name: attributeNode?.name || "",
146
- label: attributeNode?.label || attributeNode?.name || "",
147
- values
148
- };
149
- }) ?? [];
150
- }
151
- /** The product's display name: its label, then its name. */
152
- function getProductName(skuGroup) {
153
- return skuGroup.label ?? skuGroup.name ?? "Product";
154
- }
155
-
156
102
  //#endregion
157
103
  //#region src/sample-cart.tsx
158
104
  const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
@@ -160,13 +106,14 @@ const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
160
106
  name: `Product ${index + 1}`,
161
107
  price: (index + 1) * 1e3
162
108
  }));
109
+ const noQuantities = {};
163
110
  const SampleCartContext = createContext(null);
164
111
  function useSampleCart() {
165
112
  const value = useContext(SampleCartContext);
166
113
  if (!value) throw new Error("Mount CommerceProvider around sample products.");
167
114
  return value;
168
115
  }
169
- function SampleCartProvider({ enabled, onAdd, announce, children }) {
116
+ function SampleCartProvider({ enabled, onAdd, announce, emit, children }) {
170
117
  const session = useMemo(() => ({ enabled }), [enabled]);
171
118
  const active = useRef(session);
172
119
  active.current = session;
@@ -184,7 +131,11 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
184
131
  active.current = null;
185
132
  };
186
133
  }, [session]);
187
- const quantities = state.session === session && enabled ? state.quantities : {};
134
+ const quantities = state.session === session && enabled ? state.quantities : noQuantities;
135
+ const items = useMemo(() => sampleProducts.flatMap((product) => quantities[product.key] ? [{
136
+ ...product,
137
+ quantity: quantities[product.key]
138
+ }] : []), [quantities]);
188
139
  function update(key, quantity) {
189
140
  if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) return false;
190
141
  if (quantity !== void 0 && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) return false;
@@ -204,10 +155,7 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
204
155
  }
205
156
  return /* @__PURE__ */ jsx(SampleCartContext.Provider, {
206
157
  value: {
207
- items: sampleProducts.flatMap((product) => quantities[product.key] ? [{
208
- ...product,
209
- quantity: quantities[product.key]
210
- }] : []),
158
+ items,
211
159
  add: (key) => {
212
160
  const product = sampleProducts.find((product$1) => product$1.key === key);
213
161
  if (!product || !update(key)) return false;
@@ -216,12 +164,27 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
216
164
  },
217
165
  changeQuantity: async (key, quantity) => {
218
166
  const updated = quantity >= 1 && update(key, quantity);
219
- 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
+ }
220
176
  return updated;
221
177
  },
222
178
  remove: async (key) => {
223
179
  const updated = update(key, 0);
224
- 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
+ }
225
188
  return updated;
226
189
  }
227
190
  },
@@ -232,12 +195,46 @@ function SampleCartProvider({ enabled, onAdd, announce, children }) {
232
195
  //#endregion
233
196
  //#region src/commerce-provider.tsx
234
197
  const CommerceContext = createContext(null);
198
+ /** Lets a mounted cart drawer register itself, so adds open it only when one exists. Not public. */
199
+ const CartDrawerRegistry = createContext(null);
235
200
  function useCommerce() {
236
201
  const context = useContext(CommerceContext);
237
202
  if (!context) throw new Error("Mount CommerceProvider once around the header and page outlet, inside the router.");
238
203
  return context;
239
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
+ }
240
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
+ }
241
238
  async function loadConfig(signal) {
242
239
  const config = await request("/config", { signal });
243
240
  if (config?.state === "unbound" || config?.state === "connecting") {
@@ -251,7 +248,8 @@ async function loadConfig(signal) {
251
248
  currencyCode: config.currencyCode
252
249
  };
253
250
  }
254
- function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {} }) {
251
+ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, locale, onItemAdded, onEvent }) {
252
+ const resolvedLocale = useMemo(() => resolveLocale(locale), [locale]);
255
253
  const config = useQuery({
256
254
  retry: false,
257
255
  queryKey: ["commerce", "configuration"],
@@ -280,10 +278,13 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
280
278
  config.refetch();
281
279
  },
282
280
  theme,
281
+ locale: resolvedLocale,
282
+ onItemAdded,
283
+ onEvent,
283
284
  children
284
285
  });
285
286
  }
286
- function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme }) {
287
+ function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, locale, onItemAdded, onEvent }) {
287
288
  const storageKey = `godaddy:commerce-storefront:cart:${config.cartScope}`;
288
289
  const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]);
289
290
  const ready = connection === "ready";
@@ -307,6 +308,41 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
307
308
  const queue = useRef(Promise.resolve(true));
308
309
  const alive = useRef(true);
309
310
  const opener = useRef(null);
311
+ const callbacks = useRef({
312
+ onItemAdded,
313
+ onEvent
314
+ });
315
+ callbacks.current = {
316
+ onItemAdded,
317
+ onEvent
318
+ };
319
+ /** Reports an event to the host. A callback that throws is logged, and the operation still succeeds. */
320
+ function emit(event) {
321
+ if (event.type === "item-added") {
322
+ const { name, quantity, isSample } = event;
323
+ callHost(() => callbacks.current.onItemAdded?.({
324
+ name,
325
+ quantity,
326
+ isSample
327
+ }));
328
+ }
329
+ callHost(() => callbacks.current.onEvent?.(event));
330
+ }
331
+ const drawers = useRef(0);
332
+ const registerDrawer = useCallback(() => {
333
+ drawers.current += 1;
334
+ if (process.env.NODE_ENV !== "production" && drawers.current === 2) console.warn("[gd-commerce-storefront] More than one cart drawer is mounted, so every add opens all of them. CommerceStorefront already mounts CartDrawer; use CommerceProvider to mount your own CartDrawerRoot.");
335
+ return () => {
336
+ drawers.current -= 1;
337
+ if (drawers.current === 0) setOpen(false);
338
+ };
339
+ }, []);
340
+ /** Opens the drawer for an add, if one is mounted. */
341
+ function openDrawer() {
342
+ if (drawers.current === 0) return;
343
+ rememberOpener();
344
+ setOpen(true);
345
+ }
310
346
  function rememberOpener() {
311
347
  opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
312
348
  }
@@ -358,7 +394,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
358
394
  throw cause;
359
395
  }
360
396
  }
361
- function run(operation, loading = false) {
397
+ function run(kind, operation, loading = false) {
362
398
  const task = queue.current.then(async () => {
363
399
  if (!isActive()) return false;
364
400
  setPending(true);
@@ -371,7 +407,15 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
371
407
  else await operation();
372
408
  return isActive();
373
409
  } catch (cause) {
374
- if (isActive()) setError(message(cause));
410
+ if (isActive()) {
411
+ const text = message(cause);
412
+ setError(text);
413
+ emit({
414
+ type: "error",
415
+ operation: kind,
416
+ message: text
417
+ });
418
+ }
375
419
  return false;
376
420
  } finally {
377
421
  if (isActive()) {
@@ -384,7 +428,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
384
428
  return task;
385
429
  }
386
430
  function refresh() {
387
- return run(hydrate, true);
431
+ return run("refresh", hydrate, true);
388
432
  }
389
433
  useEffect(() => {
390
434
  alive.current = true;
@@ -415,7 +459,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
415
459
  }, [cartIdStorage, connection]);
416
460
  function addItem(item) {
417
461
  rememberOpener();
418
- return run(async () => {
462
+ return run("add", async () => {
419
463
  if (!item.skuId || !item.name || !Number.isInteger(item.quantity) || item.quantity < 1) throw new Error("Select a product variant and a positive quantity.");
420
464
  await hydrate();
421
465
  if (!isActive()) return;
@@ -436,11 +480,18 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
436
480
  if (!isActive()) return;
437
481
  commit(result.cart);
438
482
  setAnnouncement(`${item.name} added to your cart.`);
439
- setOpen(true);
483
+ openDrawer();
484
+ emit({
485
+ type: "item-added",
486
+ skuId: item.skuId,
487
+ name: item.name,
488
+ quantity: item.quantity,
489
+ isSample: false
490
+ });
440
491
  });
441
492
  }
442
493
  function mutateItem(itemId, method, quantity) {
443
- return run(async () => {
494
+ return run(method === "DELETE" ? "remove" : "change-quantity", async () => {
444
495
  await hydrate();
445
496
  if (!isActive()) return;
446
497
  if (!id.current || !currentCart.current?.lineItems?.some((item) => item.id === itemId)) throw new Error("This item is no longer in the cart.");
@@ -453,10 +504,20 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
453
504
  if (!isActive()) return;
454
505
  commit(result.cart);
455
506
  setAnnouncement(method === "DELETE" ? "Item removed from cart." : "Cart quantity updated.");
507
+ emit(method === "DELETE" ? {
508
+ type: "item-removed",
509
+ itemId,
510
+ isSample: false
511
+ } : {
512
+ type: "quantity-changed",
513
+ itemId,
514
+ quantity: quantity ?? 0,
515
+ isSample: false
516
+ });
456
517
  });
457
518
  }
458
519
  function applyDiscount(code) {
459
- return run(async () => {
520
+ return run("discount", async () => {
460
521
  await hydrate();
461
522
  if (!isActive()) return;
462
523
  if (!id.current || !code.trim()) throw new Error("Add an item and enter a promotion code.");
@@ -469,10 +530,14 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
469
530
  if (!isActive()) return;
470
531
  commit(result.cart);
471
532
  setAnnouncement("Promotion codes updated.");
533
+ emit({
534
+ type: "discount-applied",
535
+ code: code.trim()
536
+ });
472
537
  });
473
538
  }
474
539
  function checkout() {
475
- return run(async () => {
540
+ return run("checkout", async () => {
476
541
  if (!config.checkoutSuccessPath) throw new Error("Checkout has not been enabled for this store.");
477
542
  await hydrate();
478
543
  if (!isActive()) return;
@@ -492,6 +557,10 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
492
557
  if (typeof session$1.url !== "string" || !session$1.url.trim()) throw new Error("Commerce did not return a checkout URL.");
493
558
  const url = new URL(session$1.url, origin);
494
559
  if (url.protocol !== "https:") throw new Error("Commerce returned an invalid checkout URL.");
560
+ emit({
561
+ type: "checkout-started",
562
+ cartId: id.current
563
+ });
495
564
  window.location.assign(url.href);
496
565
  });
497
566
  }
@@ -502,9 +571,11 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
502
571
  connectionError,
503
572
  retryConnection,
504
573
  theme,
574
+ locale,
505
575
  cart,
506
576
  open,
507
577
  setOpen: (next) => {
578
+ if (next && drawers.current === 0) return;
508
579
  if (next) rememberOpener();
509
580
  setOpen(next);
510
581
  },
@@ -523,15 +594,25 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
523
594
  refresh,
524
595
  checkout
525
596
  },
526
- children: [/* @__PURE__ */ jsx(SampleCartProvider, {
527
- enabled: connection === "unbound",
528
- announce: setAnnouncement,
529
- onAdd: (name) => {
530
- rememberOpener();
531
- setAnnouncement(`${name} added to your sample cart.`);
532
- setOpen(true);
533
- },
534
- children
597
+ children: [/* @__PURE__ */ jsx(CartDrawerRegistry.Provider, {
598
+ value: registerDrawer,
599
+ children: /* @__PURE__ */ jsx(SampleCartProvider, {
600
+ enabled: connection === "unbound",
601
+ announce: setAnnouncement,
602
+ emit,
603
+ onAdd: (name) => {
604
+ setAnnouncement(`${name} added to your sample cart.`);
605
+ openDrawer();
606
+ emit({
607
+ type: "item-added",
608
+ skuId: null,
609
+ name,
610
+ quantity: 1,
611
+ isSample: true
612
+ });
613
+ },
614
+ children
615
+ })
535
616
  }), /* @__PURE__ */ jsx("span", {
536
617
  className: "commerce-sr-only",
537
618
  role: "status",
@@ -541,6 +622,171 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
541
622
  });
542
623
  }
543
624
 
625
+ //#endregion
626
+ //#region src/cart-state.ts
627
+ /** Read the cart from anywhere inside `CommerceProvider`, for example to build a custom cart button. */
628
+ function useCart() {
629
+ const { cart, config, connection, hydrating } = useCommerce();
630
+ const sample = useSampleCart();
631
+ const isSample = connection === "unbound";
632
+ const sampleItems$1 = sample.items;
633
+ return useMemo(() => {
634
+ if (isSample) return {
635
+ items: sampleItems$1.map((item) => ({
636
+ id: item.key,
637
+ name: item.name,
638
+ quantity: item.quantity,
639
+ imageUrl: null,
640
+ options: [],
641
+ subtotal: item.price * item.quantity,
642
+ currency: "USD"
643
+ })),
644
+ itemCount: sampleItems$1.reduce((total, item) => total + item.quantity, 0),
645
+ subtotal: sampleItems$1.reduce((sum, item) => sum + item.price * item.quantity, 0),
646
+ currency: "USD",
647
+ isSample,
648
+ loading: false
649
+ };
650
+ const currency = cart?.totals?.total?.currencyCode ?? config.currencyCode;
651
+ const lines = cart?.lineItems ?? [];
652
+ const subtotal = cart?.totals?.subTotal?.value;
653
+ return {
654
+ items: lines.map((item) => ({
655
+ id: item.id ?? "",
656
+ name: item.name ?? "",
657
+ quantity: item.quantity ?? 0,
658
+ imageUrl: item.details?.productAssetUrl || null,
659
+ options: (item.details?.selectedOptions ?? []).map((option) => ({
660
+ attribute: option.attribute ?? "",
661
+ values: option.values ?? []
662
+ })),
663
+ subtotal: typeof item.totals?.subTotal?.value === "number" ? item.totals.subTotal.value : null,
664
+ currency: item.totals?.subTotal?.currencyCode ?? currency
665
+ })),
666
+ itemCount: lines.reduce((total, item) => total + (item.quantity ?? 0), 0),
667
+ subtotal: typeof subtotal === "number" ? subtotal : null,
668
+ currency: cart?.totals?.subTotal?.currencyCode ?? currency,
669
+ isSample,
670
+ loading: hydrating
671
+ };
672
+ }, [
673
+ cart,
674
+ config.currencyCode,
675
+ hydrating,
676
+ isSample,
677
+ sampleItems$1
678
+ ]);
679
+ }
680
+
681
+ //#endregion
682
+ //#region src/catalog-model.ts
683
+ function getSingleMatchedSku(skuGroup) {
684
+ const connection = skuGroup?.skus;
685
+ if (connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? 0) > 1) return null;
686
+ const edges = connection?.edges ?? [];
687
+ const sku = edges.length === 1 ? edges[0]?.node : null;
688
+ return sku?.id ? sku : null;
689
+ }
690
+ function getLabeledSkuOptions(skuGroup) {
691
+ if (getProductAttributes(skuGroup).length > 0) return [];
692
+ const connection = skuGroup?.skus;
693
+ const edges = connection?.edges ?? [];
694
+ if (edges.length < 2 || connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? edges.length) !== edges.length) return [];
695
+ const skus = edges.flatMap((edge) => edge?.node?.id && (edge.node.label?.trim() || edge.node.name?.trim()) ? [edge.node] : []);
696
+ const labels = new Set(skus.map((sku) => (sku.label?.trim() || sku.name?.trim() || "").toLowerCase()));
697
+ const ids = new Set(skus.map((sku) => sku.id));
698
+ return skus.length === edges.length && labels.size === skus.length && ids.size === skus.length ? skus : [];
699
+ }
700
+ function getAvailableInventoryQuantity(item) {
701
+ const edges = item?.inventoryCounts?.edges;
702
+ if (!edges || edges.length === 0) return null;
703
+ return edges.find((edge) => edge?.node?.type === "AVAILABLE")?.node?.quantity ?? 0;
704
+ }
705
+ const imageMediaTypes = new Set(["IMAGE", "PHOTO"]);
706
+ function getImageUrls(item) {
707
+ return item?.mediaObjects?.edges?.filter((edge) => imageMediaTypes.has(edge?.node?.type ?? "") && edge?.node?.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
708
+ }
709
+ function getPrimaryImageUrl(item) {
710
+ return getImageUrls(item)[0] ?? null;
711
+ }
712
+ function getProductAttributes(skuGroup) {
713
+ return skuGroup?.attributes?.edges?.map((edge) => {
714
+ const attributeNode = edge?.node;
715
+ const values = attributeNode?.values?.edges?.map((valueEdge) => {
716
+ const valueNode = valueEdge?.node;
717
+ return {
718
+ id: valueNode?.id || "",
719
+ name: valueNode?.name || "",
720
+ label: valueNode?.label || valueNode?.name || ""
721
+ };
722
+ }) ?? [];
723
+ return {
724
+ id: attributeNode?.id || "",
725
+ name: attributeNode?.name || "",
726
+ label: attributeNode?.label || attributeNode?.name || "",
727
+ values
728
+ };
729
+ }) ?? [];
730
+ }
731
+ /** The product's display name: its label, then its name. */
732
+ function getProductName(skuGroup) {
733
+ return skuGroup.label ?? skuGroup.name ?? "Product";
734
+ }
735
+
736
+ //#endregion
737
+ //#region src/part.ts
738
+ /**
739
+ * Whether `children` renders anything, so `{custom && <Part />}` with `custom` false falls back to the default. Blank
740
+ * text and fragments holding nothing count as nothing. A component element counts as content: what it renders is
741
+ * not known until it renders.
742
+ */
743
+ const hasChildren = (children) => Children.toArray(children).some((child) => {
744
+ if (typeof child === "string") return child.trim() !== "";
745
+ if (isValidElement(child) && child.type === Fragment) return hasChildren(child.props.children);
746
+ return true;
747
+ });
748
+ const cx = (...classes) => classes.filter(Boolean).join(" ");
749
+ /** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
750
+ const partClass = (...classes) => cx("commerce-part", ...classes);
751
+ /**
752
+ * Props for a part's outer element: `partClass` plus `data-commerce-part`, the part's name in kebab case, such as
753
+ * `cart-checkout`. The attribute is a stable styling hook; internal utility classes are not.
754
+ */
755
+ const partProps = (name, ...classes) => ({
756
+ className: partClass(...classes),
757
+ "data-commerce-part": name
758
+ });
759
+ /** The host's label for `state`, or `fallback` when the host gave none or it renders nothing. */
760
+ function renderLabel(label, state, fallback) {
761
+ if (typeof label === "function") {
762
+ const content = label(state);
763
+ return hasChildren(content) ? content : fallback;
764
+ }
765
+ return hasChildren(label) ? label : fallback;
766
+ }
767
+ /**
768
+ * Counts the parts mounted under a root, by key. Parts register in an effect, and a parent's effects run after its
769
+ * children's, so the root can read `has` in its own effect. `onChange` must be stable; it runs after each change.
770
+ */
771
+ function usePartRegistry(onChange) {
772
+ const counts = useRef(/* @__PURE__ */ new Map());
773
+ const has = useCallback((key) => counts.current.has(key), []);
774
+ const register = useCallback((key) => {
775
+ counts.current.set(key, (counts.current.get(key) ?? 0) + 1);
776
+ onChange?.(has);
777
+ return () => {
778
+ const count = (counts.current.get(key) ?? 1) - 1;
779
+ if (count > 0) counts.current.set(key, count);
780
+ else counts.current.delete(key);
781
+ onChange?.(has);
782
+ };
783
+ }, [has, onChange]);
784
+ return useMemo(() => ({
785
+ register,
786
+ has
787
+ }), [register, has]);
788
+ }
789
+
544
790
  //#endregion
545
791
  //#region src/storefront-surface.tsx
546
792
  function StorefrontSurface({ children, className = "" }) {
@@ -575,31 +821,52 @@ function CommerceStatus() {
575
821
  //#region src/cart.tsx
576
822
  const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-commerce-accent px-5 py-2 text-sm font-semibold text-commerce-on-accent hover:bg-commerce-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-700 disabled:hover:bg-neutral-200";
577
823
  const inputClass = "min-h-11 rounded-lg border border-neutral-400 bg-white px-3 py-2 text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900";
578
- function CartButton() {
579
- const { cart, setOpen, open, connection } = useCommerce();
580
- const sample = useSampleCart();
581
- const count = connection === "unbound" ? sample.items.reduce((total, item) => total + item.quantity, 0) : cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0;
824
+ /** Opens the cart drawer and shows the item count. */
825
+ function CartButton({ children } = {}) {
826
+ const { setOpen, open, connection } = useCommerce();
827
+ const { itemCount } = useCart();
582
828
  return /* @__PURE__ */ jsx(StorefrontSurface, {
583
829
  className: "commerce-inline",
584
- children: /* @__PURE__ */ jsxs("button", {
830
+ children: /* @__PURE__ */ jsx("button", {
585
831
  type: "button",
586
832
  className: buttonClass,
587
833
  disabled: connection !== "ready" && connection !== "unbound",
588
834
  "aria-haspopup": "dialog",
589
835
  "aria-expanded": open,
836
+ "data-commerce-part": "cart-button",
590
837
  onClick: () => setOpen(true),
591
- children: ["Cart ", /* @__PURE__ */ jsx("span", {
592
- className: "rounded-full bg-white px-2 py-0.5 text-xs text-neutral-900",
593
- children: count
594
- })]
838
+ children: renderLabel(children, { itemCount }, /* @__PURE__ */ jsxs(Fragment$1, { children: [
839
+ "Cart",
840
+ " ",
841
+ /* @__PURE__ */ jsx("span", {
842
+ className: "rounded-full bg-white px-2 py-0.5 text-xs text-neutral-900",
843
+ children: itemCount
844
+ })
845
+ ] }))
595
846
  })
596
847
  });
597
848
  }
598
- function AddToCartButton({ sku, name, quantity = 1 }) {
849
+ /** Whether tracked stock is used up. Oversold SKUs report a negative count. */
850
+ const isSoldOut = (available) => available !== null && available <= 0;
851
+ /** Why `quantity` cannot be added, or `null` when it can. `available` is `null` when stock is not tracked. */
852
+ function quantityProblem(quantity, available) {
853
+ if (!Number.isInteger(quantity) || quantity < 1) return "Enter a whole number of 1 or more.";
854
+ if (available !== null && available > 0 && quantity > available) return `Only ${available} available.`;
855
+ return null;
856
+ }
857
+ function AddToCartButton({ sku, name, quantity = 1, children }) {
599
858
  const { addItem, pending, hydrating, error, open, connection } = useCommerce();
600
859
  const [adding, setAdding] = useState(false);
860
+ const reasonId = useId();
601
861
  const available = getAvailableInventoryQuantity(sku);
602
- const disabled = connection !== "ready" || !sku.id || adding || hydrating || available === 0 || !Number.isInteger(quantity) || quantity < 1 || available !== null && quantity > available;
862
+ const soldOut = isSoldOut(available);
863
+ let reason = null;
864
+ if (connection === "unbound") reason = "Connect a store to add items to the cart.";
865
+ else if (connection === "error") reason = "The store could not be reached.";
866
+ else if (connection !== "ready") reason = "Connecting to the store…";
867
+ else if (!sku.id) reason = "This item cannot be added to the cart.";
868
+ else if (!soldOut) reason = hydrating ? "Loading your cart…" : quantityProblem(quantity, available);
869
+ const disabled = adding || soldOut || reason !== null;
603
870
  async function handleAdd() {
604
871
  if (disabled || pending || !sku.id) return;
605
872
  setAdding(true);
@@ -615,282 +882,527 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
615
882
  }
616
883
  return /* @__PURE__ */ jsxs(StorefrontSurface, {
617
884
  className: "commerce-inline",
618
- children: [/* @__PURE__ */ jsx("button", {
619
- type: "button",
620
- className: `${buttonClass} w-full`,
621
- disabled,
622
- "aria-disabled": disabled || pending,
623
- "aria-busy": adding,
624
- onClick: () => void handleAdd(),
625
- children: available === 0 ? "Out of stock" : adding ? "Adding…" : "Add to cart"
626
- }), error && !open && /* @__PURE__ */ jsx("p", {
627
- role: "alert",
628
- className: "mt-3 bg-white text-sm text-red-700",
629
- children: error
630
- })]
885
+ children: [
886
+ /* @__PURE__ */ jsx("button", {
887
+ type: "button",
888
+ className: `${buttonClass} w-full`,
889
+ disabled,
890
+ "aria-disabled": disabled || pending,
891
+ "aria-busy": adding,
892
+ "aria-describedby": reason ? reasonId : void 0,
893
+ "data-commerce-part": "add-to-cart-button",
894
+ onClick: () => void handleAdd(),
895
+ children: renderLabel(children, {
896
+ busy: adding,
897
+ soldOut
898
+ }, soldOut ? "Out of stock" : adding ? "Adding…" : "Add to cart")
899
+ }),
900
+ reason && /* @__PURE__ */ jsx("span", {
901
+ id: reasonId,
902
+ hidden: true,
903
+ children: reason
904
+ }),
905
+ error && !open && /* @__PURE__ */ jsx("p", {
906
+ role: "alert",
907
+ className: "mt-3 bg-white text-sm text-red-700",
908
+ children: error
909
+ })
910
+ ]
631
911
  });
632
912
  }
633
- function CartDrawer() {
634
- const { config, connection, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity: changeLiveQuantity, removeItem: removeLiveItem, refresh, checkout } = useCommerce();
913
+
914
+ //#endregion
915
+ //#region src/cart-parts.tsx
916
+ const CartContext = createContext(null);
917
+ const CartDrawerContext = createContext(null);
918
+ function useCartInternal(part) {
919
+ const context = useContext(CartContext);
920
+ if (!context) throw new Error(`${part} must be rendered inside <CartRoot> or <CartDrawerRoot>.`);
921
+ return context;
922
+ }
923
+ function CartProvider({ children }) {
924
+ const commerce = useCommerce();
635
925
  const sample = useSampleCart();
926
+ const state = useCart();
927
+ const [action, setAction] = useState(null);
928
+ const locked = commerce.pending || commerce.hydrating || action !== null;
929
+ const value = {
930
+ ...state,
931
+ hydrating: commerce.hydrating,
932
+ error: commerce.error,
933
+ storageWarning: commerce.storageWarning,
934
+ catalogPath: commerce.config.catalogPath,
935
+ checkoutEnabled: !!commerce.config.checkoutSuccessPath,
936
+ checkoutReady: commerce.cart?.totals?.total?.value != null,
937
+ action,
938
+ locked,
939
+ run: (next, operation) => {
940
+ if (locked) return;
941
+ setAction(next);
942
+ operation().finally(() => setAction(null));
943
+ },
944
+ changeQuantity: state.isSample ? sample.changeQuantity : commerce.changeQuantity,
945
+ removeItem: state.isSample ? sample.remove : commerce.removeItem,
946
+ refresh: commerce.refresh,
947
+ checkout: commerce.checkout,
948
+ close: () => commerce.setOpen(false)
949
+ };
950
+ return /* @__PURE__ */ jsx(CartContext.Provider, {
951
+ value,
952
+ children
953
+ });
954
+ }
955
+ /**
956
+ * Provides the cart to cart parts on a page, such as a `/cart` route. While no store is connected it shows the sample
957
+ * cart. Use `CartDrawerRoot` for the drawer.
958
+ */
959
+ function CartRoot({ children, className }) {
960
+ const { connection } = useCommerce();
636
961
  const isSample = connection === "unbound";
637
- const changeQuantity = isSample ? sample.changeQuantity : changeLiveQuantity;
638
- const removeItem = isSample ? sample.remove : removeLiveItem;
639
- const [drawerAction, setDrawerAction] = useState(null);
640
- const checkingOut = drawerAction === "checkout";
641
- const locked = pending || hydrating || drawerAction !== null;
642
- const items = isSample ? sample.items.map((item) => ({
643
- id: item.key,
644
- name: item.name,
645
- quantity: item.quantity,
646
- image: void 0,
647
- options: void 0,
648
- subtotal: item.price * item.quantity,
649
- currency: "USD"
650
- })) : (cart?.lineItems ?? []).map((item) => ({
651
- id: item.id,
652
- name: item.name,
653
- quantity: item.quantity,
654
- image: item.details?.productAssetUrl,
655
- options: item.details?.selectedOptions,
656
- subtotal: item.totals?.subTotal?.value,
657
- currency: item.totals?.subTotal?.currencyCode
658
- }));
659
- const summary$1 = getCartSummaryTotals(cart);
660
- const currency = isSample ? "USD" : cart?.totals?.total?.currencyCode ?? config.currencyCode;
661
- const subtotal = isSample ? sample.items.reduce((sum, item) => sum + item.price * item.quantity, 0) : summary$1.subtotal;
662
- async function handleAction(action, operation) {
663
- if (locked) return;
664
- setDrawerAction(action);
665
- try {
666
- await operation();
667
- } finally {
668
- setDrawerAction(null);
669
- }
670
- }
962
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" || isSample ? /* @__PURE__ */ jsx(CartProvider, { children: /* @__PURE__ */ jsx("section", {
963
+ ...partProps("cart-root", className),
964
+ "data-commerce-source": isSample ? "sample" : void 0,
965
+ children
966
+ }) }) : /* @__PURE__ */ jsx("div", {
967
+ ...partProps("cart-root", className),
968
+ children: /* @__PURE__ */ jsx(CommerceStatus, {})
969
+ }) });
970
+ }
971
+ /**
972
+ * The cart dialog. Opens when an item is added or a `CartButton` is pressed, and returns focus when it closes.
973
+ * The panel sits at the right edge at full height; `mr-auto` on it moves it to the left. Mount one per provider,
974
+ * in place of `CartDrawer`.
975
+ */
976
+ function CartDrawerRoot({ children, className }) {
977
+ const { open, setOpen, restoreFocus, connection } = useCommerce();
978
+ const registerDrawer = useContext(CartDrawerRegistry);
979
+ useEffect(() => registerDrawer?.(), [registerDrawer]);
980
+ const [described, setDescribed] = useState(false);
981
+ const { register, has } = usePartRegistry(useCallback((registered) => setDescribed(registered("description")), []));
982
+ useEffect(() => {
983
+ setDescribed(has("description"));
984
+ }, [has]);
985
+ const drawer = useMemo(() => ({ register }), [register]);
671
986
  return /* @__PURE__ */ jsx(Dialog.Root, {
672
987
  open,
673
988
  onOpenChange: setOpen,
674
- children: /* @__PURE__ */ jsx(Dialog.Portal, { children: /* @__PURE__ */ jsxs(StorefrontSurface, { children: [/* @__PURE__ */ jsx(Dialog.Overlay, { className: "fixed inset-0 z-40 bg-neutral-950/40" }), /* @__PURE__ */ jsxs(Dialog.Content, {
675
- "data-commerce-source": isSample ? "sample" : void 0,
676
- className: "fixed inset-y-0 right-0 z-50 flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
677
- onCloseAutoFocus: (event) => {
678
- event.preventDefault();
679
- restoreFocus();
680
- },
989
+ 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", {
990
+ className: "pointer-events-none fixed inset-0 z-50 flex justify-end",
991
+ children: /* @__PURE__ */ jsx(Dialog.Content, {
992
+ "data-commerce-source": connection === "unbound" ? "sample" : void 0,
993
+ ...described ? {} : { "aria-describedby": void 0 },
994
+ ...partProps("cart-drawer-root", "pointer-events-auto", className),
995
+ onCloseAutoFocus: (event) => {
996
+ event.preventDefault();
997
+ restoreFocus();
998
+ },
999
+ children: /* @__PURE__ */ jsx(CartDrawerContext.Provider, {
1000
+ value: drawer,
1001
+ children: /* @__PURE__ */ jsx(CartProvider, { children })
1002
+ })
1003
+ })
1004
+ })] }) })
1005
+ });
1006
+ }
1007
+ /** "Your cart", or "Sample cart" before a store is connected, as an H2. In the drawer it names the dialog. */
1008
+ function CartTitle({ className, children }) {
1009
+ const { isSample } = useCartInternal("CartTitle");
1010
+ const Heading = useContext(CartDrawerContext) ? Dialog.Title : "h2";
1011
+ return /* @__PURE__ */ jsx("div", {
1012
+ ...partProps("cart-title", className),
1013
+ children: /* @__PURE__ */ jsx(Heading, {
1014
+ className: "text-2xl font-semibold",
1015
+ children: renderLabel(children, { isSample }, isSample ? "Sample cart" : "Your cart")
1016
+ })
1017
+ });
1018
+ }
1019
+ /** One line about the cart. In the drawer it describes the dialog. */
1020
+ function CartDescription({ className, children }) {
1021
+ const { isSample } = useCartInternal("CartDescription");
1022
+ const drawer = useContext(CartDrawerContext);
1023
+ const register = drawer?.register;
1024
+ useEffect(() => register?.("description"), [register]);
1025
+ const text = renderLabel(children, { isSample }, isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout.");
1026
+ return /* @__PURE__ */ jsx("div", {
1027
+ ...partProps("cart-description", className),
1028
+ children: drawer ? /* @__PURE__ */ jsx(Dialog.Description, {
1029
+ className: "text-sm text-neutral-600",
1030
+ children: text
1031
+ }) : /* @__PURE__ */ jsx("p", {
1032
+ className: "text-sm text-neutral-600",
1033
+ children: text
1034
+ })
1035
+ });
1036
+ }
1037
+ /** The drawer's close button. Renders nothing outside the drawer. */
1038
+ function CartClose({ className }) {
1039
+ useCartInternal("CartClose");
1040
+ if (!useContext(CartDrawerContext)) return null;
1041
+ return /* @__PURE__ */ jsx("div", {
1042
+ ...partProps("cart-close", className),
1043
+ children: /* @__PURE__ */ jsx(Dialog.Close, {
1044
+ 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",
1045
+ "aria-label": "Close cart",
1046
+ children: "✕"
1047
+ })
1048
+ });
1049
+ }
1050
+ /** A storage warning, and a cart error with a refresh button. Renders nothing when there are neither. */
1051
+ function CartNotices({ className }) {
1052
+ const { storageWarning, error, action, locked, run, refresh } = useCartInternal("CartNotices");
1053
+ if (!storageWarning && !error) return null;
1054
+ return /* @__PURE__ */ jsx("div", {
1055
+ ...partProps("cart-notices", className),
1056
+ children: /* @__PURE__ */ jsxs("div", {
1057
+ className: "flex flex-col gap-4",
1058
+ children: [storageWarning && /* @__PURE__ */ jsx("p", {
1059
+ role: "status",
1060
+ className: "text-sm text-amber-800",
1061
+ children: storageWarning
1062
+ }), error && /* @__PURE__ */ jsxs("div", {
1063
+ role: "alert",
1064
+ className: "rounded-lg bg-red-50 p-4 text-red-800",
1065
+ children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
1066
+ type: "button",
1067
+ className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
1068
+ disabled: action === "refresh",
1069
+ "aria-disabled": locked,
1070
+ "aria-busy": action === "refresh",
1071
+ onClick: () => run("refresh", refresh),
1072
+ children: "Refresh cart"
1073
+ })]
1074
+ })]
1075
+ })
1076
+ });
1077
+ }
1078
+ const CartItemsContext = createContext(null);
1079
+ const CartItemsRegistry = createContext(null);
1080
+ const CartItemContext = createContext(null);
1081
+ function useCartItemInternal(part) {
1082
+ const item = useContext(CartItemContext);
1083
+ if (!item) throw new Error(`${part} must be rendered inside <CartItemRoot>.`);
1084
+ return item;
1085
+ }
1086
+ /** Read the line from inside `CartItemRoot`, for custom item parts. */
1087
+ function useCartItem() {
1088
+ return useCartItemInternal("useCartItem");
1089
+ }
1090
+ /**
1091
+ * The cart lines as a list. `className` goes on the list element whose children are the items. While the saved cart
1092
+ * loads, a status message renders just before it.
1093
+ */
1094
+ function CartItems({ className, children }) {
1095
+ const { items, hydrating } = useCartInternal("CartItems");
1096
+ const template = hasChildren(children) ? children : packagedItem;
1097
+ const registry = usePartRegistry();
1098
+ const { has } = registry;
1099
+ useEffect(() => {
1100
+ if (process.env.NODE_ENV === "production" || items.length === 0 || has("item")) return;
1101
+ console.warn("[gd-commerce-storefront] The <CartItems> template renders no <CartItemRoot>, so the cart list has no items. Wrap each line in <CartItemRoot>.");
1102
+ }, [items.length, has]);
1103
+ return /* @__PURE__ */ jsxs(CartItemsRegistry.Provider, {
1104
+ value: registry.register,
1105
+ children: [hydrating && /* @__PURE__ */ jsx("p", {
1106
+ role: "status",
1107
+ children: "Loading your cart…"
1108
+ }), /* @__PURE__ */ jsx("div", {
1109
+ role: "list",
1110
+ ...partProps("cart-items", className),
1111
+ children: items.map((item, index) => /* @__PURE__ */ jsx(CartItemsContext.Provider, {
1112
+ value: item,
1113
+ children: template
1114
+ }, item.id || `line-${index}`))
1115
+ })]
1116
+ });
1117
+ }
1118
+ /** One cart line. Render it as the child of `CartItems`, which renders it once for every line. */
1119
+ function CartItemRoot({ children, className }) {
1120
+ const item = useContext(CartItemsContext);
1121
+ const register = useContext(CartItemsRegistry);
1122
+ useEffect(() => register?.("item"), [register]);
1123
+ if (!item) throw new Error("CartItemRoot must be rendered inside <CartItems>.");
1124
+ return /* @__PURE__ */ jsx(CartItemContext.Provider, {
1125
+ value: item,
1126
+ children: /* @__PURE__ */ jsx("div", {
1127
+ role: "listitem",
1128
+ ...partProps("cart-item-root", className),
1129
+ "data-testid": "cart-item",
1130
+ children: /* @__PURE__ */ jsx(CartItemsContext.Provider, {
1131
+ value: null,
1132
+ children
1133
+ })
1134
+ })
1135
+ });
1136
+ }
1137
+ /** The line's product image. Renders nothing without one. */
1138
+ function CartItemImage({ className }) {
1139
+ const { imageUrl } = useCartItemInternal("CartItemImage");
1140
+ if (!imageUrl) return null;
1141
+ return /* @__PURE__ */ jsx("div", {
1142
+ ...partProps("cart-item-image", className),
1143
+ children: /* @__PURE__ */ jsx("img", {
1144
+ src: imageUrl,
1145
+ alt: "",
1146
+ className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
1147
+ })
1148
+ });
1149
+ }
1150
+ /** The product name as an H3. */
1151
+ function CartItemName({ className }) {
1152
+ const { name } = useCartItemInternal("CartItemName");
1153
+ return /* @__PURE__ */ jsx("div", {
1154
+ ...partProps("cart-item-name", className),
1155
+ children: /* @__PURE__ */ jsx("h3", {
1156
+ className: "font-semibold",
1157
+ children: name
1158
+ })
1159
+ });
1160
+ }
1161
+ /** The selected options, one per line, such as "Color: Blue". Renders nothing without options. */
1162
+ function CartItemOptions({ className }) {
1163
+ const { options } = useCartItemInternal("CartItemOptions");
1164
+ if (!options.length) return null;
1165
+ return /* @__PURE__ */ jsx("div", {
1166
+ ...partProps("cart-item-options", className),
1167
+ children: options.map((option) => /* @__PURE__ */ jsxs("p", {
1168
+ className: "text-sm text-neutral-600",
1169
+ children: [
1170
+ option.attribute,
1171
+ ": ",
1172
+ option.values.join(", ")
1173
+ ]
1174
+ }, `${option.attribute}:${option.values.join(",")}`))
1175
+ });
1176
+ }
1177
+ /** The line total. */
1178
+ function CartItemPrice({ className }) {
1179
+ const { subtotal, currency } = useCartItemInternal("CartItemPrice");
1180
+ const money$1 = useMoney();
1181
+ return /* @__PURE__ */ jsx("div", {
1182
+ ...partProps("cart-item-price", className),
1183
+ children: /* @__PURE__ */ jsx("p", {
1184
+ className: "text-sm",
1185
+ children: subtotal === null ? "Price unavailable" : money$1(subtotal, currency)
1186
+ })
1187
+ });
1188
+ }
1189
+ 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";
1190
+ /** Decrease and increase buttons around the line's quantity. */
1191
+ function CartItemQuantity({ className }) {
1192
+ const { id, name, quantity } = useCartItemInternal("CartItemQuantity");
1193
+ const { action, locked, run, changeQuantity } = useCartInternal("CartItemQuantity");
1194
+ return /* @__PURE__ */ jsx("div", {
1195
+ ...partProps("cart-item-quantity", className),
1196
+ children: /* @__PURE__ */ jsxs("div", {
1197
+ className: "flex items-center rounded-lg border border-neutral-300",
681
1198
  children: [
682
- /* @__PURE__ */ jsxs("div", {
683
- className: "flex items-start justify-between border-b border-neutral-200 p-6",
684
- children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Dialog.Title, {
685
- className: "text-2xl font-semibold",
686
- children: isSample ? "Sample cart" : "Your cart"
687
- }), /* @__PURE__ */ jsx(Dialog.Description, {
688
- className: "mt-1 text-sm text-neutral-600",
689
- children: isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout."
690
- })] }), /* @__PURE__ */ jsx(Dialog.Close, {
691
- className: "min-h-11 min-w-11 rounded-lg border border-neutral-400 bg-white px-3 text-neutral-900 focus-visible:outline focus-visible:outline-2",
692
- "aria-label": "Close cart",
693
- children: "✕"
694
- })]
1199
+ /* @__PURE__ */ jsx("button", {
1200
+ type: "button",
1201
+ className: stepperButtonClass,
1202
+ "aria-label": `Decrease quantity of ${name}`,
1203
+ "data-commerce-control": "decrease",
1204
+ disabled: action === `decrease:${id}` || !id || quantity <= 1,
1205
+ "aria-disabled": locked || !id || quantity <= 1,
1206
+ "aria-busy": action === `decrease:${id}`,
1207
+ onClick: () => run(`decrease:${id}`, () => changeQuantity(id, quantity - 1)),
1208
+ children: "−"
695
1209
  }),
696
- /* @__PURE__ */ jsxs("div", {
697
- className: "flex-1 overflow-y-auto p-6",
698
- children: [
699
- storageWarning && /* @__PURE__ */ jsx("p", {
700
- role: "status",
701
- className: "mb-4 text-sm text-amber-800",
702
- children: storageWarning
703
- }),
704
- error && /* @__PURE__ */ jsxs("div", {
705
- role: "alert",
706
- className: "mb-4 rounded-lg bg-red-50 p-4 text-red-800",
707
- children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
708
- type: "button",
709
- className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
710
- disabled: drawerAction === "refresh",
711
- "aria-disabled": locked,
712
- "aria-busy": drawerAction === "refresh",
713
- onClick: () => void handleAction("refresh", refresh),
714
- children: "Refresh cart"
715
- })]
716
- }),
717
- hydrating && /* @__PURE__ */ jsx("p", {
718
- role: "status",
719
- children: "Loading your cart…"
720
- }),
721
- !hydrating && !items.length && /* @__PURE__ */ jsxs("div", {
722
- className: "py-16 text-center",
723
- children: [
724
- /* @__PURE__ */ jsx("p", {
725
- className: "text-xl font-medium",
726
- children: "Your cart is empty"
727
- }),
728
- /* @__PURE__ */ jsx("p", {
729
- className: "mt-2 text-neutral-600",
730
- children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
731
- }),
732
- /* @__PURE__ */ jsx(Link, {
733
- className: `${buttonClass} mt-6`,
734
- to: config.catalogPath,
735
- onClick: () => setOpen(false),
736
- children: "Browse products"
737
- })
738
- ]
739
- }),
740
- /* @__PURE__ */ jsx("ul", {
741
- className: "divide-y divide-neutral-200",
742
- children: items.map((item) => /* @__PURE__ */ jsxs("li", {
743
- className: "py-5 first:pt-0",
744
- "data-testid": "cart-item",
745
- children: [/* @__PURE__ */ jsxs("div", {
746
- className: "flex gap-4",
747
- children: [item.image && /* @__PURE__ */ jsx("img", {
748
- src: item.image,
749
- alt: "",
750
- className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
751
- }), /* @__PURE__ */ jsxs("div", {
752
- className: "min-w-0 flex-1",
753
- children: [
754
- /* @__PURE__ */ jsx("h3", {
755
- className: "font-semibold",
756
- children: item.name
757
- }),
758
- item.options?.map((option) => /* @__PURE__ */ jsxs("p", {
759
- className: "text-sm text-neutral-600",
760
- children: [
761
- option.attribute,
762
- ": ",
763
- option.values?.join(", ")
764
- ]
765
- }, `${option.attribute}:${option.values?.join(",")}`)),
766
- /* @__PURE__ */ jsx("p", {
767
- className: "mt-1 text-sm",
768
- children: typeof item.subtotal === "number" ? money(item.subtotal, item.currency ?? currency) : "Price unavailable"
769
- })
770
- ]
771
- })]
772
- }), /* @__PURE__ */ jsxs("div", {
773
- className: "mt-4 flex items-center justify-between gap-2",
774
- children: [/* @__PURE__ */ jsxs("div", {
775
- className: "flex items-center rounded-lg border border-neutral-300",
776
- children: [
777
- /* @__PURE__ */ jsx("button", {
778
- type: "button",
779
- className: "min-h-11 min-w-11 rounded-lg bg-white text-neutral-900 focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-600",
780
- "aria-label": `Decrease quantity of ${item.name}`,
781
- disabled: drawerAction === `decrease:${item.id}` || !item.id || (item.quantity ?? 0) <= 1,
782
- "aria-disabled": locked || !item.id || (item.quantity ?? 0) <= 1,
783
- "aria-busy": drawerAction === `decrease:${item.id}`,
784
- onClick: () => void handleAction(`decrease:${item.id}`, () => changeQuantity(item.id ?? "", (item.quantity ?? 1) - 1)),
785
- children: "−"
786
- }),
787
- /* @__PURE__ */ jsxs("span", {
788
- className: "min-w-8 text-center",
789
- children: [/* @__PURE__ */ jsx("span", {
790
- className: "sr-only",
791
- children: "Quantity: "
792
- }), item.quantity]
793
- }),
794
- /* @__PURE__ */ jsx("button", {
795
- type: "button",
796
- className: "min-h-11 min-w-11 rounded-lg bg-white text-neutral-900 focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-600",
797
- "aria-label": `Increase quantity of ${item.name}`,
798
- disabled: drawerAction === `increase:${item.id}` || !item.id,
799
- "aria-disabled": locked || !item.id,
800
- "aria-busy": drawerAction === `increase:${item.id}`,
801
- onClick: () => void handleAction(`increase:${item.id}`, () => changeQuantity(item.id ?? "", (item.quantity ?? 1) + 1)),
802
- children: "+"
803
- })
804
- ]
805
- }), /* @__PURE__ */ jsx("button", {
806
- type: "button",
807
- className: "min-h-11 bg-white px-2 text-sm text-neutral-900 underline focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:text-neutral-600",
808
- "aria-label": `Remove ${item.name}`,
809
- disabled: drawerAction === `remove:${item.id}` || !item.id,
810
- "aria-disabled": locked || !item.id,
811
- "aria-busy": drawerAction === `remove:${item.id}`,
812
- onClick: () => void handleAction(`remove:${item.id}`, () => removeItem(item.id ?? "")),
813
- children: "Remove"
814
- })]
815
- })]
816
- }, item.id))
817
- })
818
- ]
1210
+ /* @__PURE__ */ jsxs("span", {
1211
+ className: "min-w-8 text-center",
1212
+ children: [/* @__PURE__ */ jsx("span", {
1213
+ className: "sr-only",
1214
+ children: "Quantity: "
1215
+ }), quantity]
819
1216
  }),
820
- items.length > 0 && /* @__PURE__ */ jsxs("div", {
821
- className: "border-t border-neutral-200 p-6",
822
- children: [
823
- /* @__PURE__ */ jsx("dl", {
824
- className: "space-y-2 text-sm",
825
- children: (isSample || typeof cart?.totals?.subTotal?.value === "number") && /* @__PURE__ */ jsxs("div", {
826
- className: "flex justify-between",
827
- children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(subtotal, isSample ? "USD" : cart?.totals?.subTotal?.currencyCode ?? currency) })]
828
- })
829
- }),
830
- !isSample && /* @__PURE__ */ jsx("p", {
831
- className: "commerce-cart-checkout-adjustments-note mt-3 text-sm text-neutral-600",
832
- children: "Shipping, taxes, and discounts are calculated at checkout."
833
- }),
834
- isSample ? /* @__PURE__ */ jsx("p", {
835
- className: "text-sm text-neutral-600",
836
- children: "Example total in USD. Checkout is not set up yet."
837
- }) : config.checkoutSuccessPath ? /* @__PURE__ */ jsx("button", {
838
- type: "button",
839
- className: `${buttonClass} w-full`,
840
- disabled: checkingOut || hydrating || cart?.totals?.total?.value == null,
841
- "aria-disabled": locked || cart?.totals?.total?.value == null,
842
- "aria-busy": checkingOut,
843
- onClick: () => void handleAction("checkout", checkout),
844
- children: checkingOut ? "Opening checkout…" : "Proceed to Checkout"
845
- }) : /* @__PURE__ */ jsx("p", {
846
- className: "text-sm text-neutral-600",
847
- children: "Checkout is not available yet."
848
- })
849
- ]
1217
+ /* @__PURE__ */ jsx("button", {
1218
+ type: "button",
1219
+ className: stepperButtonClass,
1220
+ "aria-label": `Increase quantity of ${name}`,
1221
+ "data-commerce-control": "increase",
1222
+ disabled: action === `increase:${id}` || !id,
1223
+ "aria-disabled": locked || !id,
1224
+ "aria-busy": action === `increase:${id}`,
1225
+ onClick: () => run(`increase:${id}`, () => changeQuantity(id, quantity + 1)),
1226
+ children: "+"
850
1227
  })
851
1228
  ]
852
- })] }) })
1229
+ })
1230
+ });
1231
+ }
1232
+ /** Removes the line. */
1233
+ function CartItemRemove({ className, children }) {
1234
+ const { id, name } = useCartItemInternal("CartItemRemove");
1235
+ const { action, locked, run, removeItem } = useCartInternal("CartItemRemove");
1236
+ return /* @__PURE__ */ jsx("div", {
1237
+ ...partProps("cart-item-remove", className),
1238
+ children: /* @__PURE__ */ jsxs("button", {
1239
+ type: "button",
1240
+ 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",
1241
+ disabled: action === `remove:${id}` || !id,
1242
+ "aria-disabled": locked || !id,
1243
+ "aria-busy": action === `remove:${id}`,
1244
+ onClick: () => run(`remove:${id}`, () => removeItem(id)),
1245
+ children: [renderLabel(children, { busy: action === `remove:${id}` }, "Remove"), /* @__PURE__ */ jsxs("span", {
1246
+ className: "sr-only",
1247
+ children: [" ", name]
1248
+ })]
1249
+ })
1250
+ });
1251
+ }
1252
+ const packagedItem = /* @__PURE__ */ jsxs(CartItemRoot, {
1253
+ className: "py-5 first:pt-0",
1254
+ children: [/* @__PURE__ */ jsxs("div", {
1255
+ className: "flex gap-4",
1256
+ children: [/* @__PURE__ */ jsx(CartItemImage, {}), /* @__PURE__ */ jsxs("div", {
1257
+ className: "min-w-0 flex-1",
1258
+ children: [
1259
+ /* @__PURE__ */ jsx(CartItemName, {}),
1260
+ /* @__PURE__ */ jsx(CartItemOptions, {}),
1261
+ /* @__PURE__ */ jsx(CartItemPrice, { className: "mt-1" })
1262
+ ]
1263
+ })]
1264
+ }), /* @__PURE__ */ jsxs("div", {
1265
+ className: "mt-4 flex items-center justify-between gap-2",
1266
+ children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1267
+ })]
1268
+ });
1269
+ /** Renders only when the cart is empty: a message and a link to the catalog, or its children. */
1270
+ function CartEmpty({ className, children }) {
1271
+ const { items, hydrating, isSample, catalogPath, close } = useCartInternal("CartEmpty");
1272
+ if (hydrating || items.length) return null;
1273
+ return /* @__PURE__ */ jsx("div", {
1274
+ ...partProps("cart-empty", className),
1275
+ children: hasChildren(children) ? children : /* @__PURE__ */ jsxs(Fragment$1, { children: [
1276
+ /* @__PURE__ */ jsx("p", {
1277
+ className: "text-xl font-medium",
1278
+ children: "Your cart is empty"
1279
+ }),
1280
+ /* @__PURE__ */ jsx("p", {
1281
+ className: "mt-2 text-neutral-600",
1282
+ children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
1283
+ }),
1284
+ /* @__PURE__ */ jsx(Link, {
1285
+ className: `${buttonClass} mt-6`,
1286
+ to: catalogPath,
1287
+ onClick: close,
1288
+ children: "Browse products"
1289
+ })
1290
+ ] })
1291
+ });
1292
+ }
1293
+ /** A container for the subtotal and checkout. Renders only when the cart has items. */
1294
+ function CartSummary({ className, children }) {
1295
+ const { items } = useCartInternal("CartSummary");
1296
+ if (!items.length) return null;
1297
+ return /* @__PURE__ */ jsx("div", {
1298
+ ...partProps("cart-summary", className),
1299
+ children
1300
+ });
1301
+ }
1302
+ /** The subtotal, labeled "Sample subtotal" for samples. Renders nothing until the server prices the cart. */
1303
+ function CartSubtotal({ className }) {
1304
+ const { items, subtotal, currency, isSample } = useCartInternal("CartSubtotal");
1305
+ const money$1 = useMoney();
1306
+ if (!items.length || subtotal === null) return null;
1307
+ return /* @__PURE__ */ jsx("div", {
1308
+ ...partProps("cart-subtotal", className),
1309
+ children: /* @__PURE__ */ jsx("dl", {
1310
+ className: "space-y-2 text-sm",
1311
+ children: /* @__PURE__ */ jsxs("div", {
1312
+ className: "flex justify-between",
1313
+ children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money$1(subtotal, currency) })]
1314
+ })
1315
+ })
853
1316
  });
854
1317
  }
855
-
856
- //#endregion
857
- //#region src/part.ts
858
- const cx = (...classes) => classes.filter(Boolean).join(" ");
859
- /** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
860
- const partClass = (...classes) => cx("commerce-part", ...classes);
861
1318
  /**
862
- * Counts the parts mounted under a root, by key. Parts register in an effect, and a parent's effects run after its
863
- * children's, so the root can read `has` in its own effect. `onChange` must be stable; it runs after each change.
1319
+ * "Shipping, taxes, and discounts are calculated at checkout." Has the stable
1320
+ * `commerce-cart-checkout-adjustments-note` class. Renders nothing for samples or an empty cart.
864
1321
  */
865
- function usePartRegistry(onChange) {
866
- const counts = useRef(/* @__PURE__ */ new Map());
867
- const has = useCallback((key) => counts.current.has(key), []);
868
- const register = useCallback((key) => {
869
- counts.current.set(key, (counts.current.get(key) ?? 0) + 1);
870
- onChange?.(has);
871
- return () => {
872
- const count = (counts.current.get(key) ?? 1) - 1;
873
- if (count > 0) counts.current.set(key, count);
874
- else counts.current.delete(key);
875
- onChange?.(has);
876
- };
877
- }, [has, onChange]);
878
- return useMemo(() => ({
879
- register,
880
- has
881
- }), [register, has]);
1322
+ function CartCheckoutNote({ className }) {
1323
+ const { items, isSample } = useCartInternal("CartCheckoutNote");
1324
+ if (isSample || !items.length) return null;
1325
+ return /* @__PURE__ */ jsx("div", {
1326
+ ...partProps("cart-checkout-note", "commerce-cart-checkout-adjustments-note", className),
1327
+ children: /* @__PURE__ */ jsx("p", {
1328
+ className: "text-sm text-neutral-600",
1329
+ children: "Shipping, taxes, and discounts are calculated at checkout."
1330
+ })
1331
+ });
1332
+ }
1333
+ /**
1334
+ * The checkout button when `checkoutSuccessPath` is set; otherwise a message that checkout is unavailable.
1335
+ * Samples never check out. Renders nothing for an empty cart.
1336
+ */
1337
+ function CartCheckout({ className, children }) {
1338
+ const { items, isSample, checkoutEnabled, checkoutReady, hydrating, action, locked, run, checkout } = useCartInternal("CartCheckout");
1339
+ if (!items.length) return null;
1340
+ let content;
1341
+ if (isSample) content = /* @__PURE__ */ jsx("p", {
1342
+ className: "text-sm text-neutral-600",
1343
+ children: "Example total in USD. Checkout is not set up yet."
1344
+ });
1345
+ else if (!checkoutEnabled) content = /* @__PURE__ */ jsx("p", {
1346
+ className: "text-sm text-neutral-600",
1347
+ children: "Checkout is not available yet."
1348
+ });
1349
+ else content = /* @__PURE__ */ jsx("button", {
1350
+ type: "button",
1351
+ className: `${buttonClass} w-full`,
1352
+ disabled: action === "checkout" || hydrating || !checkoutReady,
1353
+ "aria-disabled": locked || !checkoutReady,
1354
+ "aria-busy": action === "checkout",
1355
+ onClick: () => run("checkout", checkout),
1356
+ children: renderLabel(children, { busy: action === "checkout" }, action === "checkout" ? "Opening checkout…" : "Proceed to Checkout")
1357
+ });
1358
+ return /* @__PURE__ */ jsx("div", {
1359
+ ...partProps("cart-checkout", className),
1360
+ children: content
1361
+ });
1362
+ }
1363
+ /** The packaged cart drawer. Compose `CartDrawerRoot` and the cart parts for a custom drawer. */
1364
+ function CartDrawer() {
1365
+ return /* @__PURE__ */ jsxs(CartDrawerRoot, {
1366
+ className: "flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
1367
+ children: [
1368
+ /* @__PURE__ */ jsxs("div", {
1369
+ className: "flex items-start justify-between border-b border-neutral-200 p-6",
1370
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(CartTitle, {}), /* @__PURE__ */ jsx(CartDescription, { className: "mt-1" })] }), /* @__PURE__ */ jsx(CartClose, {})]
1371
+ }),
1372
+ /* @__PURE__ */ jsxs("div", {
1373
+ className: "flex-1 overflow-y-auto p-6",
1374
+ children: [
1375
+ /* @__PURE__ */ jsx(CartNotices, { className: "mb-4" }),
1376
+ /* @__PURE__ */ jsx(CartItems, { className: "divide-y divide-neutral-200" }),
1377
+ /* @__PURE__ */ jsx(CartEmpty, { className: "py-16 text-center" })
1378
+ ]
1379
+ }),
1380
+ /* @__PURE__ */ jsxs(CartSummary, {
1381
+ className: "border-t border-neutral-200 p-6",
1382
+ children: [
1383
+ /* @__PURE__ */ jsx(CartSubtotal, {}),
1384
+ /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-3" }),
1385
+ /* @__PURE__ */ jsx(CartCheckout, {})
1386
+ ]
1387
+ })
1388
+ ]
1389
+ });
882
1390
  }
883
1391
 
884
1392
  //#endregion
885
1393
  //#region src/product-card.tsx
1394
+ /**
1395
+ * A product image that fills its container, with a labeled placeholder when there is no URL or it fails to load.
1396
+ * Use it in custom parts so they handle missing images like the package's own.
1397
+ */
886
1398
  function ProductImage({ url, name, className = "" }) {
887
- const [failed, setFailed] = useState(false);
888
- return url && !failed ? /* @__PURE__ */ jsx("img", {
1399
+ const [failedUrl, setFailedUrl] = useState(null);
1400
+ return url && url !== failedUrl ? /* @__PURE__ */ jsx("img", {
889
1401
  src: url,
890
1402
  alt: name,
891
1403
  loading: "lazy",
892
1404
  className: `h-full w-full object-cover ${className}`,
893
- onError: () => setFailed(true)
1405
+ onError: () => setFailedUrl(url)
894
1406
  }) : /* @__PURE__ */ jsx("div", {
895
1407
  className: `flex h-full min-h-44 items-center justify-center p-6 text-center ${className}`,
896
1408
  role: "img",
@@ -971,7 +1483,7 @@ function ProductCardRoot({ children, product, className }) {
971
1483
  const card = /* @__PURE__ */ jsx(ProductCardContext.Provider, {
972
1484
  value,
973
1485
  children: /* @__PURE__ */ jsx("article", {
974
- className: partClass("group", className),
1486
+ ...partProps("product-card-root", "group", className),
975
1487
  children: /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
976
1488
  value: null,
977
1489
  children
@@ -987,7 +1499,7 @@ function ProductCardRoot({ children, product, className }) {
987
1499
  function ProductCardImage({ className }) {
988
1500
  const card = useProductCardInternal("ProductCardImage");
989
1501
  return /* @__PURE__ */ jsx("div", {
990
- className: partClass(className),
1502
+ ...partProps("product-card-image", className),
991
1503
  children: card.source === "live" ? /* @__PURE__ */ jsx(Link, {
992
1504
  to: card.href,
993
1505
  tabIndex: card.imageLinked ? void 0 : -1,
@@ -1010,7 +1522,7 @@ function ProductCardTitle({ className }) {
1010
1522
  const { name, href, register } = useProductCardInternal("ProductCardTitle");
1011
1523
  useEffect(() => href ? register(titleLink) : void 0, [href, register]);
1012
1524
  return /* @__PURE__ */ jsx("div", {
1013
- className: partClass(className),
1525
+ ...partProps("product-card-title", className),
1014
1526
  children: /* @__PURE__ */ jsx("h2", {
1015
1527
  className: "break-words text-2xl font-semibold leading-tight",
1016
1528
  children: href ? /* @__PURE__ */ jsx(Link, {
@@ -1026,7 +1538,7 @@ function ProductCardDescription({ className }) {
1026
1538
  const { product } = useProductCardInternal("ProductCardDescription");
1027
1539
  if (!product?.description) return null;
1028
1540
  return /* @__PURE__ */ jsx("div", {
1029
- className: partClass(className),
1541
+ ...partProps("product-card-description", className),
1030
1542
  children: /* @__PURE__ */ jsx("p", {
1031
1543
  className: "line-clamp-3 text-sm leading-6",
1032
1544
  children: product.description
@@ -1036,16 +1548,16 @@ function ProductCardDescription({ className }) {
1036
1548
  /** The product's price, or its price range when variants differ. */
1037
1549
  function ProductCardPrice({ className }) {
1038
1550
  const card = useProductCardInternal("ProductCardPrice");
1039
- const { config } = useCommerce();
1551
+ const money$1 = useMoney();
1040
1552
  let text;
1041
- if (card.source === "sample") text = money(card.sample.price, "USD");
1553
+ if (card.source === "sample") text = money$1(card.sample.price, "USD");
1042
1554
  else {
1043
1555
  const min = card.product.priceRange?.min;
1044
1556
  const max = card.product.priceRange?.max;
1045
- text = typeof min === "number" ? `${money(min, config.currencyCode)}${typeof max === "number" && max !== min ? ` – ${money(max, config.currencyCode)}` : ""}` : "Price unavailable";
1557
+ text = typeof min === "number" ? `${money$1(min)}${typeof max === "number" && max !== min ? ` – ${money$1(max)}` : ""}` : "Price unavailable";
1046
1558
  }
1047
1559
  return /* @__PURE__ */ jsx("div", {
1048
- className: partClass(className),
1560
+ ...partProps("product-card-price", className),
1049
1561
  children: /* @__PURE__ */ jsx("p", {
1050
1562
  className: "text-lg font-semibold leading-7",
1051
1563
  children: text
@@ -1056,44 +1568,73 @@ function ProductCardPrice({ className }) {
1056
1568
  * Adds the product directly when it has exactly one SKU and no options; otherwise links to its page to choose.
1057
1569
  * Samples add to the sample cart.
1058
1570
  */
1059
- function ProductCardAction({ className }) {
1571
+ function ProductCardAction({ className, children }) {
1060
1572
  const card = useProductCardInternal("ProductCardAction");
1061
1573
  return /* @__PURE__ */ jsx("div", {
1062
- className: partClass(className),
1574
+ ...partProps("product-card-action", className),
1063
1575
  children: card.source === "live" ? /* @__PURE__ */ jsx(LiveAction, {
1064
1576
  product: card.product,
1065
1577
  name: card.name,
1066
- href: card.href
1067
- }) : /* @__PURE__ */ jsx(SampleAction, { sample: card.sample })
1578
+ href: card.href,
1579
+ label: children
1580
+ }) : /* @__PURE__ */ jsx(SampleAction, {
1581
+ sample: card.sample,
1582
+ label: children
1583
+ })
1068
1584
  });
1069
1585
  }
1070
- function LiveAction({ product, name, href }) {
1586
+ function LiveAction({ product, name, href, label }) {
1071
1587
  const selectedSku = getSingleMatchedSku(product);
1072
1588
  const hasVariants = getProductAttributes(product).length > 0;
1073
1589
  if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCartButton, {
1074
1590
  sku: selectedSku,
1075
- name
1591
+ name,
1592
+ children: (state) => renderLabel(label, {
1593
+ action: "add",
1594
+ ...state
1595
+ }, null)
1076
1596
  });
1077
1597
  return /* @__PURE__ */ jsx(Link, {
1078
1598
  className: buttonClass,
1079
1599
  to: href,
1080
- children: hasVariants ? "Choose options" : "View product"
1600
+ children: renderLabel(label, {
1601
+ action: hasVariants ? "choose-options" : "view-product",
1602
+ busy: false,
1603
+ soldOut: false
1604
+ }, hasVariants ? "Choose options" : "View product")
1081
1605
  });
1082
1606
  }
1083
- function SampleAction({ sample }) {
1607
+ function SampleAction({ sample, label }) {
1084
1608
  const { add } = useSampleCart();
1085
- return /* @__PURE__ */ jsx("button", {
1609
+ const custom = renderLabel(label, {
1610
+ action: "add",
1611
+ busy: false,
1612
+ soldOut: false
1613
+ }, null);
1614
+ return custom === null ? /* @__PURE__ */ jsx("button", {
1086
1615
  type: "button",
1087
1616
  className: buttonClass,
1088
1617
  "aria-label": `Add ${sample.name} to sample cart`,
1089
1618
  onClick: () => add(sample.key),
1090
1619
  children: "Add to cart"
1620
+ }) : /* @__PURE__ */ jsxs("button", {
1621
+ type: "button",
1622
+ className: buttonClass,
1623
+ onClick: () => add(sample.key),
1624
+ children: [custom, /* @__PURE__ */ jsxs("span", {
1625
+ className: "sr-only",
1626
+ children: [
1627
+ " (",
1628
+ sample.name,
1629
+ ", sample)"
1630
+ ]
1631
+ })]
1091
1632
  });
1092
1633
  }
1093
1634
  const packagedCardClass = "flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200";
1094
1635
  /** The packaged card's parts, inside a root that has `packagedCardClass`. */
1095
1636
  function PackagedCardParts() {
1096
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsxs("div", {
1637
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsxs("div", {
1097
1638
  className: "flex flex-1 flex-col p-6",
1098
1639
  children: [
1099
1640
  /* @__PURE__ */ jsx(ProductCardTitle, {}),
@@ -1117,9 +1658,22 @@ function ProductCard({ product }) {
1117
1658
  });
1118
1659
  }
1119
1660
 
1661
+ //#endregion
1662
+ //#region src/root-params.ts
1663
+ /**
1664
+ * A root's search params: the page URL's, or with `urlState` off, the root's own. A second root on the page, such
1665
+ * as a quick view, then neither reads nor changes the page's params, and its state ends when it unmounts.
1666
+ */
1667
+ function useRootParams(urlState) {
1668
+ const [urlParams, setUrlParams] = useSearchParams();
1669
+ const [localParams, setLocalParams] = useState(() => new URLSearchParams());
1670
+ return urlState ? [urlParams, setUrlParams] : [localParams, setLocalParams];
1671
+ }
1672
+
1120
1673
  //#endregion
1121
1674
  //#region src/catalog.tsx
1122
1675
  const CatalogContext = createContext(null);
1676
+ const CatalogEmptyContext = createContext(null);
1123
1677
  const sampleCatalog = { source: "sample" };
1124
1678
  function useCatalogInternal(part) {
1125
1679
  const context = useContext(CatalogContext);
@@ -1127,32 +1681,47 @@ function useCatalogInternal(part) {
1127
1681
  return context;
1128
1682
  }
1129
1683
  /** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */
1130
- function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = false }) {
1684
+ function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = false, pageSize = 6, urlState = true }) {
1131
1685
  const { connection } = useCommerce();
1686
+ const [custom, setCustom] = useState(false);
1687
+ const { register, has } = usePartRegistry(useCallback((registered) => setCustom(registered("empty")), []));
1688
+ useEffect(() => {
1689
+ setCustom(has("empty"));
1690
+ }, [has]);
1691
+ const empty = useMemo(() => ({
1692
+ register,
1693
+ custom
1694
+ }), [register, custom]);
1695
+ if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw new Error(`CatalogRoot pageSize must be a whole number of 1 or more, not ${pageSize}.`);
1132
1696
  let content;
1133
1697
  if (connection === "ready") content = /* @__PURE__ */ jsx(CatalogLoader, {
1134
1698
  className,
1699
+ pageSize,
1700
+ urlState,
1135
1701
  children
1136
1702
  });
1137
1703
  else if (connection === "unbound" && sampleProducts$1) content = /* @__PURE__ */ jsx(CatalogContext.Provider, {
1138
1704
  value: sampleCatalog,
1139
1705
  children: /* @__PURE__ */ jsx("section", {
1140
- className: partClass(className),
1706
+ ...partProps("catalog-root", className),
1141
1707
  "data-commerce-source": "sample",
1142
1708
  children
1143
1709
  })
1144
1710
  });
1145
1711
  else content = /* @__PURE__ */ jsx("div", {
1146
- className: partClass(className),
1712
+ ...partProps("catalog-root", className),
1147
1713
  children: /* @__PURE__ */ jsx(CommerceStatus, {})
1148
1714
  });
1149
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: content });
1715
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: /* @__PURE__ */ jsx(CatalogEmptyContext.Provider, {
1716
+ value: empty,
1717
+ children: content
1718
+ }) });
1150
1719
  }
1151
- function CatalogLoader({ children, className }) {
1720
+ function CatalogLoader({ children, className, pageSize, urlState }) {
1152
1721
  const { config } = useCommerce();
1153
- const [params, setParams] = useSearchParams();
1722
+ const [params, setParams] = useRootParams(urlState);
1154
1723
  const after = params.get("after") ?? "";
1155
- const urlParams = new URLSearchParams({ first: "6" });
1724
+ const urlParams = new URLSearchParams({ first: String(pageSize) });
1156
1725
  if (after) urlParams.set("after", after);
1157
1726
  const products = useQuery({
1158
1727
  retry: false,
@@ -1160,6 +1729,7 @@ function CatalogLoader({ children, className }) {
1160
1729
  "commerce",
1161
1730
  config.cartScope,
1162
1731
  "products",
1732
+ pageSize,
1163
1733
  after
1164
1734
  ],
1165
1735
  queryFn: ({ signal }) => request(`/products?${urlParams}`, {
@@ -1189,7 +1759,7 @@ function CatalogLoader({ children, className }) {
1189
1759
  return /* @__PURE__ */ jsx(CatalogContext.Provider, {
1190
1760
  value,
1191
1761
  children: /* @__PURE__ */ jsx("section", {
1192
- className: partClass(className),
1762
+ ...partProps("catalog-root", className),
1193
1763
  children
1194
1764
  })
1195
1765
  });
@@ -1198,7 +1768,7 @@ function CatalogLoader({ children, className }) {
1198
1768
  function CatalogHeader({ title = "Shop all products", description = "Explore the collection and find your favorites.", className }) {
1199
1769
  useCatalogInternal("CatalogHeader");
1200
1770
  return /* @__PURE__ */ jsxs("header", {
1201
- className: partClass(className),
1771
+ ...partProps("catalog-header", className),
1202
1772
  children: [/* @__PURE__ */ jsx("h1", {
1203
1773
  className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
1204
1774
  children: title
@@ -1212,7 +1782,7 @@ function CatalogHeader({ title = "Shop all products", description = "Explore the
1212
1782
  function CatalogSummary({ className }) {
1213
1783
  const catalog = useCatalogInternal("CatalogSummary");
1214
1784
  return /* @__PURE__ */ jsx("div", {
1215
- className: partClass(className),
1785
+ ...partProps("catalog-summary", className),
1216
1786
  children: /* @__PURE__ */ jsx("p", {
1217
1787
  className: "text-sm",
1218
1788
  role: "status",
@@ -1238,12 +1808,13 @@ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
1238
1808
  children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1239
1809
  });
1240
1810
  /**
1241
- * The product cards. `className` goes on the element that contains the cards, such as a grid. The loading
1242
- * placeholder, the error with its retry, and the empty message render just before that element.
1811
+ * The product cards. `className` goes on the element that contains the cards, such as a grid. While loading, that
1812
+ * element holds placeholder cards. The error with its retry and, without a `CatalogEmpty`, the empty message render just before it.
1243
1813
  */
1244
1814
  function CatalogProducts({ className, children }) {
1245
1815
  const catalog = useCatalogInternal("CatalogProducts");
1246
- const card = Children.toArray(children).some((child) => child !== "") ? children : packagedCard;
1816
+ const customEmpty = useContext(CatalogEmptyContext)?.custom ?? false;
1817
+ const card = hasChildren(children) ? children : packagedCard;
1247
1818
  const nodes = catalog.source === "live" ? catalog.nodes : null;
1248
1819
  const items = useMemo(() => nodes ? nodes.map((product) => ({
1249
1820
  key: product.id ?? "",
@@ -1253,26 +1824,7 @@ function CatalogProducts({ className, children }) {
1253
1824
  }
1254
1825
  })) : sampleItems, [nodes]);
1255
1826
  const products = catalog.source === "live" ? catalog.products : null;
1256
- return /* @__PURE__ */ jsxs(Fragment, { children: [
1257
- products?.isPending && /* @__PURE__ */ jsx("div", {
1258
- "aria-hidden": "true",
1259
- className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3",
1260
- children: [
1261
- 0,
1262
- 1,
1263
- 2
1264
- ].map((item) => /* @__PURE__ */ jsxs("div", {
1265
- className: "overflow-hidden rounded-lg border border-neutral-200 motion-safe:animate-pulse",
1266
- children: [/* @__PURE__ */ jsx("div", { className: "aspect-square bg-neutral-100" }), /* @__PURE__ */ jsxs("div", {
1267
- className: "space-y-4 p-6",
1268
- children: [
1269
- /* @__PURE__ */ jsx("div", { className: "h-6 w-2/3 rounded bg-neutral-100" }),
1270
- /* @__PURE__ */ jsx("div", { className: "h-4 rounded bg-neutral-100" }),
1271
- /* @__PURE__ */ jsx("div", { className: "h-11 rounded bg-neutral-100" })
1272
- ]
1273
- })]
1274
- }, item))
1275
- }),
1827
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
1276
1828
  products?.isError && /* @__PURE__ */ jsxs("div", {
1277
1829
  role: "alert",
1278
1830
  className: "rounded-lg bg-red-50 p-5 text-red-800",
@@ -1283,19 +1835,48 @@ function CatalogProducts({ className, children }) {
1283
1835
  children: "Retry products"
1284
1836
  })]
1285
1837
  }),
1286
- products?.isSuccess && !items.length && /* @__PURE__ */ jsx("p", {
1838
+ products?.isSuccess && !items.length && !customEmpty && /* @__PURE__ */ jsx("p", {
1287
1839
  className: "py-16 text-center",
1288
1840
  children: "No products available."
1289
1841
  }),
1290
1842
  /* @__PURE__ */ jsx("div", {
1291
- className: partClass(className),
1292
- children: items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1843
+ ...partProps("catalog-products", className),
1844
+ children: products?.isPending ? [
1845
+ 0,
1846
+ 1,
1847
+ 2
1848
+ ].map((placeholder) => /* @__PURE__ */ jsxs("div", {
1849
+ "aria-hidden": "true",
1850
+ className: "overflow-hidden rounded-lg border border-neutral-200 motion-safe:animate-pulse",
1851
+ children: [/* @__PURE__ */ jsx("div", { className: "aspect-square bg-neutral-100" }), /* @__PURE__ */ jsxs("div", {
1852
+ className: "space-y-4 p-6",
1853
+ children: [
1854
+ /* @__PURE__ */ jsx("div", { className: "h-6 w-2/3 rounded bg-neutral-100" }),
1855
+ /* @__PURE__ */ jsx("div", { className: "h-4 rounded bg-neutral-100" }),
1856
+ /* @__PURE__ */ jsx("div", { className: "h-11 rounded bg-neutral-100" })
1857
+ ]
1858
+ })]
1859
+ }, placeholder)) : items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1293
1860
  value: item,
1294
1861
  children: card
1295
1862
  }, key))
1296
1863
  })
1297
1864
  ] });
1298
1865
  }
1866
+ /**
1867
+ * Renders only when a connected store's catalog has no products: "No products available.", or its children. While
1868
+ * one is mounted, `CatalogProducts` leaves out its own empty message. Samples are never empty.
1869
+ */
1870
+ function CatalogEmpty({ className, children }) {
1871
+ const catalog = useCatalogInternal("CatalogEmpty");
1872
+ const register = useContext(CatalogEmptyContext)?.register;
1873
+ useEffect(() => register?.("empty"), [register]);
1874
+ if (catalog.source === "sample" || !catalog.products.isSuccess || catalog.nodes.length) return null;
1875
+ return /* @__PURE__ */ jsx("div", {
1876
+ ...partProps("catalog-empty", className),
1877
+ children: hasChildren(children) ? children : /* @__PURE__ */ jsx("p", { children: "No products available." })
1878
+ });
1879
+ }
1299
1880
  /** First-page and next-page buttons. Renders nothing for samples, which have one page. */
1300
1881
  function CatalogPagination({ className }) {
1301
1882
  const catalog = useCatalogInternal("CatalogPagination");
@@ -1304,7 +1885,7 @@ function CatalogPagination({ className }) {
1304
1885
  const pageInfo = catalog.products.data?.skuGroups?.pageInfo;
1305
1886
  return /* @__PURE__ */ jsx("nav", {
1306
1887
  "aria-label": "Product pages",
1307
- className: partClass(className),
1888
+ ...partProps("catalog-pagination", className),
1308
1889
  children: /* @__PURE__ */ jsxs("div", {
1309
1890
  className: "flex justify-between",
1310
1891
  children: [after ? /* @__PURE__ */ jsx("button", {
@@ -1322,9 +1903,10 @@ function CatalogPagination({ className }) {
1322
1903
  });
1323
1904
  }
1324
1905
  /** The packaged catalog. Compose `CatalogRoot` and the catalog parts for a custom layout. */
1325
- function Catalog({ title, description, showHeader = true, sampleProducts: sampleProducts$1 }) {
1906
+ function Catalog({ title, description, showHeader = true, sampleProducts: sampleProducts$1, pageSize }) {
1326
1907
  return /* @__PURE__ */ jsxs(CatalogRoot, {
1327
1908
  sampleProducts: sampleProducts$1,
1909
+ pageSize,
1328
1910
  children: [
1329
1911
  showHeader && /* @__PURE__ */ jsx(CatalogHeader, {
1330
1912
  title,
@@ -1400,26 +1982,32 @@ function useSkuOptionRegistration() {
1400
1982
  const { register } = useProductDetailsInternal("ProductDetailsOptions");
1401
1983
  useEffect(() => register("sku"), [register]);
1402
1984
  }
1403
- /** Loads the product for the route's `:productId` and provides state to product parts. */
1404
- function ProductDetailsRoot({ children, className }) {
1405
- const { productId = "" } = useParams();
1985
+ /** Loads one product, by default the route's `:productId`, and provides state to product parts. */
1986
+ function ProductDetailsRoot({ productId: productIdProp, className,...props }) {
1987
+ const { productId: routeProductId = "" } = useParams();
1988
+ const productId = productIdProp ?? routeProductId;
1406
1989
  const { connection } = useCommerce();
1990
+ useEffect(() => {
1991
+ if (process.env.NODE_ENV === "production" || productId || connection !== "ready") return;
1992
+ console.warn("[gd-commerce-storefront] ProductDetailsRoot has no product to show. Pass `productId`, or render it on a route with a `:productId` parameter.");
1993
+ }, [productId, connection]);
1407
1994
  return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsLoader, {
1408
1995
  productId,
1409
1996
  className,
1410
- children
1997
+ ...props
1411
1998
  }, productId) : /* @__PURE__ */ jsx("div", {
1412
- className: partClass(className),
1999
+ ...partProps("product-details-root", className),
1413
2000
  children: /* @__PURE__ */ jsx(CommerceStatus, {})
1414
2001
  }) });
1415
2002
  }
1416
- function ProductDetailsLoader({ productId, className, children }) {
2003
+ function ProductDetailsLoader({ productId, className, children, urlState = true, loading, notFound, error }) {
1417
2004
  const { config } = useCommerce();
1418
- const [params, setParams] = useSearchParams();
2005
+ const [params, setParams] = useRootParams(urlState);
1419
2006
  const [quantity, setQuantity] = useState(1);
1420
2007
  const [imageIndex, setImageIndex] = useState(0);
1421
2008
  const product = useQuery({
1422
2009
  retry: false,
2010
+ enabled: !!productId,
1423
2011
  queryKey: [
1424
2012
  "commerce",
1425
2013
  config.cartScope,
@@ -1478,43 +2066,49 @@ function ProductDetailsLoader({ productId, className, children }) {
1478
2066
  if (missing.length === 0) return;
1479
2067
  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.`);
1480
2068
  }, [requiredControls, has]);
1481
- if (product.isPending) return /* @__PURE__ */ jsx("div", {
1482
- className: partClass(className),
1483
- children: /* @__PURE__ */ jsx("p", {
2069
+ if (product.isPending && productId) return /* @__PURE__ */ jsx("div", {
2070
+ ...partProps("product-details-root", className),
2071
+ children: hasChildren(loading) ? loading : /* @__PURE__ */ jsx("p", {
1484
2072
  role: "status",
1485
2073
  children: "Loading product…"
1486
2074
  })
1487
2075
  });
1488
- if (product.isError && !productNotFound) return /* @__PURE__ */ jsxs("div", {
1489
- role: "alert",
1490
- className: partClass(className),
1491
- children: [
1492
- /* @__PURE__ */ jsx("h1", {
1493
- className: "text-2xl font-semibold",
1494
- children: "Product unavailable"
1495
- }),
1496
- /* @__PURE__ */ jsx("p", {
1497
- className: "my-4 text-red-700",
1498
- children: message(product.error)
1499
- }),
1500
- /* @__PURE__ */ jsx("button", {
1501
- type: "button",
1502
- className: buttonClass,
1503
- onClick: () => void product.refetch(),
1504
- children: "Retry product"
1505
- })
1506
- ]
1507
- });
1508
- if (productNotFound || !group) return /* @__PURE__ */ jsxs("div", {
1509
- className: partClass(className),
1510
- children: [/* @__PURE__ */ jsx("h1", {
2076
+ if (product.isError && !productNotFound) {
2077
+ const retry = () => void product.refetch();
2078
+ return /* @__PURE__ */ jsx("div", {
2079
+ role: "alert",
2080
+ ...partProps("product-details-root", className),
2081
+ children: renderLabel(error, {
2082
+ message: message(product.error),
2083
+ retry
2084
+ }, /* @__PURE__ */ jsxs(Fragment$1, { children: [
2085
+ /* @__PURE__ */ jsx("h1", {
2086
+ className: "text-2xl font-semibold",
2087
+ children: "Product unavailable"
2088
+ }),
2089
+ /* @__PURE__ */ jsx("p", {
2090
+ className: "my-4 text-red-700",
2091
+ children: message(product.error)
2092
+ }),
2093
+ /* @__PURE__ */ jsx("button", {
2094
+ type: "button",
2095
+ className: buttonClass,
2096
+ onClick: retry,
2097
+ children: "Retry product"
2098
+ })
2099
+ ] }))
2100
+ });
2101
+ }
2102
+ if (productNotFound || !group) return /* @__PURE__ */ jsx("div", {
2103
+ ...partProps("product-details-root", className),
2104
+ children: hasChildren(notFound) ? notFound : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("h1", {
1511
2105
  className: "text-2xl font-semibold",
1512
2106
  children: "Product not found"
1513
2107
  }), /* @__PURE__ */ jsx(Link, {
1514
2108
  className: `${buttonClass} mt-4`,
1515
2109
  to: config.catalogPath,
1516
2110
  children: "Back to shop"
1517
- })]
2111
+ })] })
1518
2112
  });
1519
2113
  const value = {
1520
2114
  product: group,
@@ -1545,7 +2139,7 @@ function ProductDetailsLoader({ productId, className, children }) {
1545
2139
  return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
1546
2140
  value,
1547
2141
  children: /* @__PURE__ */ jsx("article", {
1548
- className: partClass(className),
2142
+ ...partProps("product-details-root", className),
1549
2143
  children
1550
2144
  })
1551
2145
  });
@@ -1565,7 +2159,7 @@ function ProductDetailsPurchase({ className }) {
1565
2159
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsPurchase");
1566
2160
  const belowOptions = attributes.length || skuOptions.length ? "-mt-4" : void 0;
1567
2161
  return /* @__PURE__ */ jsxs("div", {
1568
- className: partClass("space-y-6", className),
2162
+ ...partProps("product-details-purchase", "space-y-6", className),
1569
2163
  children: [
1570
2164
  /* @__PURE__ */ jsx(OptionControls, {}),
1571
2165
  /* @__PURE__ */ jsx(ProductDetailsPurchaseStatus, { className: belowOptions }),
@@ -1580,13 +2174,13 @@ function ProductDetailsOptions({ className }) {
1580
2174
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
1581
2175
  if (attributes.length === 0 && skuOptions.length === 0) return null;
1582
2176
  return /* @__PURE__ */ jsx("div", {
1583
- className: partClass("space-y-6", className),
2177
+ ...partProps("product-details-options", "space-y-6", className),
1584
2178
  children: /* @__PURE__ */ jsx(OptionControls, {})
1585
2179
  });
1586
2180
  }
1587
2181
  function OptionControls() {
1588
2182
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
1589
- return /* @__PURE__ */ jsxs(Fragment, { children: [attributes.map((attribute) => /* @__PURE__ */ jsx(ProductDetailsOption, { attribute: attribute.name }, attribute.id || attribute.name)), skuOptions.length > 0 && /* @__PURE__ */ jsx(SkuOption, {})] });
2183
+ 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, {})] });
1590
2184
  }
1591
2185
  function Select({ children,...props }) {
1592
2186
  return /* @__PURE__ */ jsxs("div", {
@@ -1615,7 +2209,7 @@ function SkuOption() {
1615
2209
  const id = useId();
1616
2210
  useSkuOptionRegistration();
1617
2211
  return /* @__PURE__ */ jsxs("div", {
1618
- className: partClass(),
2212
+ ...partProps("product-details-sku-option"),
1619
2213
  children: [/* @__PURE__ */ jsx("label", {
1620
2214
  htmlFor: id,
1621
2215
  className: "mb-2 block font-medium",
@@ -1639,7 +2233,8 @@ function ProductDetailsOption({ attribute, className }) {
1639
2233
  const option = useProductDetailsOption(attribute);
1640
2234
  if (!option) return null;
1641
2235
  return /* @__PURE__ */ jsx("div", {
1642
- className: partClass(className),
2236
+ ...partProps("product-details-option", className),
2237
+ "data-commerce-attribute": option.name,
1643
2238
  children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
1644
2239
  className: "mb-2 font-medium",
1645
2240
  children: option.label
@@ -1698,7 +2293,7 @@ function ProductDetailsPurchaseStatus({ className }) {
1698
2293
  break;
1699
2294
  }
1700
2295
  return /* @__PURE__ */ jsx("div", {
1701
- className: partClass(className),
2296
+ ...partProps("product-details-purchase-status", className),
1702
2297
  children: content
1703
2298
  });
1704
2299
  }
@@ -1707,53 +2302,70 @@ function ProductDetailsAvailability({ className }) {
1707
2302
  const { selectedSku, availableQuantity } = useProductDetailsInternal("ProductDetailsAvailability");
1708
2303
  if (!selectedSku) return null;
1709
2304
  return /* @__PURE__ */ jsx("div", {
1710
- className: partClass(className),
2305
+ ...partProps("product-details-availability", className),
1711
2306
  children: /* @__PURE__ */ jsx("p", {
1712
2307
  className: "text-sm",
1713
2308
  "aria-live": "polite",
1714
- children: availableQuantity === null ? "Available to order" : availableQuantity === 0 ? "This variant is out of stock" : `${availableQuantity} available`
2309
+ children: availableQuantity === null ? "Available to order" : isSoldOut(availableQuantity) ? "This variant is out of stock" : `${availableQuantity} available`
1715
2310
  })
1716
2311
  });
1717
2312
  }
1718
- /** Quantity for the verified SKU. Renders nothing until a SKU is verified. */
2313
+ /** Quantity for the verified SKU, with a message when it cannot be added. Renders nothing until a SKU is verified. */
1719
2314
  function ProductDetailsQuantity({ className }) {
1720
2315
  const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
1721
2316
  const id = useId();
2317
+ const problemId = useId();
1722
2318
  if (!selectedSku) return null;
2319
+ const problem = isSoldOut(availableQuantity) ? null : quantityProblem(quantity, availableQuantity);
1723
2320
  return /* @__PURE__ */ jsxs("div", {
1724
- className: partClass(className),
1725
- children: [/* @__PURE__ */ jsx("label", {
1726
- htmlFor: id,
1727
- className: "mb-2 block font-medium",
1728
- children: "Quantity"
1729
- }), /* @__PURE__ */ jsx("input", {
1730
- id,
1731
- type: "number",
1732
- min: 1,
1733
- max: availableQuantity ?? void 0,
1734
- step: 1,
1735
- className: `${inputClass} w-28`,
1736
- value: Number.isNaN(quantity) ? "" : quantity,
1737
- onChange: (event) => setQuantity(event.target.valueAsNumber)
1738
- })]
2321
+ ...partProps("product-details-quantity", className),
2322
+ children: [
2323
+ /* @__PURE__ */ jsx("label", {
2324
+ htmlFor: id,
2325
+ className: "mb-2 block font-medium",
2326
+ children: "Quantity"
2327
+ }),
2328
+ /* @__PURE__ */ jsx("input", {
2329
+ id,
2330
+ type: "number",
2331
+ min: 1,
2332
+ max: availableQuantity ?? void 0,
2333
+ step: 1,
2334
+ className: `${inputClass} w-28`,
2335
+ value: Number.isNaN(quantity) ? "" : quantity,
2336
+ "aria-invalid": problem ? true : void 0,
2337
+ "aria-describedby": problem ? problemId : void 0,
2338
+ onChange: (event) => setQuantity(event.target.valueAsNumber)
2339
+ }),
2340
+ problem && /* @__PURE__ */ jsx("p", {
2341
+ id: problemId,
2342
+ className: "mt-2 text-sm text-red-700",
2343
+ children: problem
2344
+ })
2345
+ ]
1739
2346
  });
1740
2347
  }
1741
2348
  /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
1742
- function ProductDetailsAddToCart({ className }) {
2349
+ function ProductDetailsAddToCart({ className, children }) {
1743
2350
  const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
1744
2351
  const reasonId = useId();
1745
2352
  return /* @__PURE__ */ jsx("div", {
1746
- className: partClass(className),
2353
+ ...partProps("product-details-add-to-cart", className),
1747
2354
  children: selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
1748
2355
  sku: selectedSku,
1749
2356
  name,
1750
- quantity
1751
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
2357
+ quantity,
2358
+ children
2359
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
1752
2360
  type: "button",
1753
2361
  className: `${buttonClass} w-full`,
1754
2362
  disabled: true,
1755
2363
  "aria-describedby": reasonId,
1756
- children: "Add to cart"
2364
+ "data-commerce-part": "add-to-cart-button",
2365
+ children: renderLabel(children, {
2366
+ busy: false,
2367
+ soldOut: false
2368
+ }, "Add to cart")
1757
2369
  }), /* @__PURE__ */ jsx("span", {
1758
2370
  id: reasonId,
1759
2371
  hidden: true,
@@ -1779,15 +2391,16 @@ function ProductDetails() {
1779
2391
  })]
1780
2392
  })] });
1781
2393
  }
1782
- function ProductDetailsBackLink({ className }) {
2394
+ /** A link to the catalog. */
2395
+ function ProductDetailsBackLink({ className, children }) {
1783
2396
  useProductDetailsInternal("ProductDetailsBackLink");
1784
2397
  const { config } = useCommerce();
1785
2398
  return /* @__PURE__ */ jsx("div", {
1786
- className: partClass(className),
2399
+ ...partProps("product-details-back-link", className),
1787
2400
  children: /* @__PURE__ */ jsx(Link, {
1788
2401
  className: "inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2",
1789
2402
  to: config.catalogPath,
1790
- children: "← Back to shop"
2403
+ children: renderLabel(children, void 0, "← Back to shop")
1791
2404
  })
1792
2405
  });
1793
2406
  }
@@ -1795,7 +2408,7 @@ function ProductDetailsBackLink({ className }) {
1795
2408
  function ProductDetailsGallery({ className }) {
1796
2409
  const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal("ProductDetailsGallery");
1797
2410
  return /* @__PURE__ */ jsxs("div", {
1798
- className: partClass(className),
2411
+ ...partProps("product-details-gallery", className),
1799
2412
  children: [/* @__PURE__ */ jsx("div", {
1800
2413
  className: "aspect-square overflow-hidden rounded-lg",
1801
2414
  children: /* @__PURE__ */ jsx(ProductImage, {
@@ -1809,6 +2422,7 @@ function ProductDetailsGallery({ className }) {
1809
2422
  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"}`,
1810
2423
  "aria-label": `View image ${index + 1}`,
1811
2424
  "aria-pressed": imageIndex === index,
2425
+ "data-commerce-control": "thumbnail",
1812
2426
  onClick: () => setImageIndex(index),
1813
2427
  children: /* @__PURE__ */ jsx(ProductImage, {
1814
2428
  url,
@@ -1822,7 +2436,7 @@ function ProductDetailsGallery({ className }) {
1822
2436
  function ProductDetailsTitle({ className }) {
1823
2437
  const { name } = useProductDetailsInternal("ProductDetailsTitle");
1824
2438
  return /* @__PURE__ */ jsx("div", {
1825
- className: partClass(className),
2439
+ ...partProps("product-details-title", className),
1826
2440
  children: /* @__PURE__ */ jsx("h1", {
1827
2441
  className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
1828
2442
  children: name
@@ -1833,15 +2447,16 @@ function ProductDetailsTitle({ className }) {
1833
2447
  function ProductDetailsPrice({ className }) {
1834
2448
  const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
1835
2449
  const { config } = useCommerce();
2450
+ const money$1 = useMoney();
1836
2451
  return /* @__PURE__ */ jsx("div", {
1837
- className: partClass(className),
2452
+ ...partProps("product-details-price", className),
1838
2453
  children: /* @__PURE__ */ jsxs("div", {
1839
2454
  "aria-live": "polite",
1840
2455
  className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
1841
2456
  "data-testid": "product-price",
1842
- 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", {
2457
+ 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", price?.compareAtValue?.value != null && price.value?.value != null && price.compareAtValue.value > price.value.value && /* @__PURE__ */ jsx("del", {
1843
2458
  className: "text-lg font-normal",
1844
- children: money(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
2459
+ children: money$1(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
1845
2460
  })]
1846
2461
  })
1847
2462
  });
@@ -1850,7 +2465,7 @@ function ProductDetailsPrice({ className }) {
1850
2465
  function ProductDetailsDescription({ className }) {
1851
2466
  const { selectedSku, product } = useProductDetailsInternal("ProductDetailsDescription");
1852
2467
  return /* @__PURE__ */ jsx("div", {
1853
- className: partClass(className),
2468
+ ...partProps("product-details-description", className),
1854
2469
  children: /* @__PURE__ */ jsx("p", {
1855
2470
  className: "whitespace-pre-line text-base leading-7",
1856
2471
  children: selectedSku?.description ?? product.description
@@ -1859,4 +2474,4 @@ function ProductDetailsDescription({ className }) {
1859
2474
  }
1860
2475
 
1861
2476
  //#endregion
1862
- export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce, useProductCard, useProductDetails, useProductDetailsOption };
2477
+ 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, CatalogEmpty, 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, ProductImage, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCart, useCartItem, useCommerce, useMoney, useProductCard, useProductDetails, useProductDetailsOption };