@godaddy/gd-commerce-storefront 0.6.0 → 0.8.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
@@ -212,6 +212,11 @@ function useMoney() {
212
212
  return useCallback((value, currency = currencyCode) => money(value, currency, locale), [locale, currencyCode]);
213
213
  }
214
214
  const CONNECTING_FAILURE_GRACE_MS = 6e4;
215
+ /** Polls every five seconds for five minutes after the page loads, then every minute, and stops at thirty. */
216
+ const FAST_POLL_FOR_MS = 5 * 6e4;
217
+ const STOP_POLLING_AT_MS = 30 * 6e4;
218
+ /** A failed retry keeps showing connecting this long so the click visibly does something. */
219
+ const RETRY_FEEDBACK_MS = 1e3;
215
220
  /** `locale` as a canonical tag, or `en-US` when it is not a valid one, so one bad prop cannot break every price. */
216
221
  function resolveLocale(locale) {
217
222
  if (!locale) return "en-US";
@@ -248,8 +253,16 @@ async function loadConfig(signal) {
248
253
  currencyCode: config.currencyCode
249
254
  };
250
255
  }
251
- function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, locale, onItemAdded, onEvent }) {
256
+ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, getSwatch, locale, onItemAdded, onEvent }) {
252
257
  const resolvedLocale = useMemo(() => resolveLocale(locale), [locale]);
258
+ const [pollingSince, setPollingSince] = useState(Date.now);
259
+ const [pollingStopped, setPollingStopped] = useState(false);
260
+ const [retrying, setRetrying] = useState(false);
261
+ useEffect(() => {
262
+ setPollingStopped(false);
263
+ const timeout = setTimeout(() => setPollingStopped(true), pollingSince + STOP_POLLING_AT_MS - Date.now());
264
+ return () => clearTimeout(timeout);
265
+ }, [pollingSince]);
253
266
  const config = useQuery({
254
267
  retry: false,
255
268
  queryKey: ["commerce", "configuration"],
@@ -257,12 +270,26 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
257
270
  staleTime: 3e4,
258
271
  refetchOnMount: "always",
259
272
  refetchOnWindowFocus: "always",
260
- refetchInterval: (query) => query.state.data?.state === "unbound" || query.state.data?.state === "connecting" ? 5e3 : false,
273
+ refetchInterval: (query) => {
274
+ const waiting = query.state.data?.state === "unbound" || query.state.data?.state === "connecting";
275
+ const elapsed = Date.now() - pollingSince;
276
+ if (!waiting || elapsed >= STOP_POLLING_AT_MS) return false;
277
+ return elapsed < FAST_POLL_FOR_MS ? 5e3 : 6e4;
278
+ },
261
279
  refetchIntervalInBackground: false
262
280
  });
281
+ const [reported, setReported] = useState(config.data?.state);
282
+ if (reported !== config.data?.state) {
283
+ setReported(config.data?.state);
284
+ setPollingSince(Date.now());
285
+ setPollingStopped(false);
286
+ }
263
287
  const holdConnecting = config.isError && config.data?.state === "connecting" && config.errorUpdatedAt - config.dataUpdatedAt < CONNECTING_FAILURE_GRACE_MS;
264
288
  let connection = config.data?.state ?? "loading";
265
289
  if (config.isError) connection = holdConnecting ? "connecting" : "error";
290
+ const stalled = pollingStopped && connection === "connecting";
291
+ if (stalled) connection = "error";
292
+ if (retrying) connection = "connecting";
266
293
  return /* @__PURE__ */ jsx(BoundCommerceProvider, {
267
294
  config: {
268
295
  cartScope: "",
@@ -273,18 +300,29 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
273
300
  checkoutSuccessPath
274
301
  },
275
302
  connection,
276
- connectionError: connection === "error" ? message(config.error) : null,
303
+ connectionError: connection !== "error" ? null : stalled ? "The store is taking too long to connect." : message(config.error),
277
304
  retryConnection: () => {
278
- config.refetch();
305
+ const started = Date.now();
306
+ setPollingSince(started);
307
+ if (connection !== "error") {
308
+ config.refetch();
309
+ return;
310
+ }
311
+ setRetrying(true);
312
+ config.refetch().then(async (result) => {
313
+ if (result.isError) await new Promise((resolve) => setTimeout(resolve, started + RETRY_FEEDBACK_MS - Date.now()));
314
+ setRetrying(false);
315
+ });
279
316
  },
280
317
  theme,
318
+ getSwatch,
281
319
  locale: resolvedLocale,
282
320
  onItemAdded,
283
321
  onEvent,
284
322
  children
285
323
  });
286
324
  }
287
- function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, locale, onItemAdded, onEvent }) {
325
+ function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, getSwatch, locale, onItemAdded, onEvent }) {
288
326
  const storageKey = `godaddy:commerce-storefront:cart:${config.cartScope}`;
289
327
  const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]);
290
328
  const ready = connection === "ready";
@@ -571,6 +609,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
571
609
  connectionError,
572
610
  retryConnection,
573
611
  theme,
612
+ getSwatch,
574
613
  locale,
575
614
  cart,
576
615
  open,
@@ -732,6 +771,75 @@ function getProductAttributes(skuGroup) {
732
771
  function getProductName(skuGroup) {
733
772
  return skuGroup.label ?? skuGroup.name ?? "Product";
734
773
  }
774
+ /** Lowercases a label for use mid-sentence, leaving acronyms alone: "Size" becomes "size", "US Size" stays. */
775
+ const inSentence = (label) => label.replace(/^[A-Z](?=[a-z])/, (first) => first.toLowerCase());
776
+ /** Whether `compareAt` is above `price`, so the price is a sale price. */
777
+ function isOnSale(price, compareAt) {
778
+ return typeof price === "number" && typeof compareAt === "number" && compareAt > price;
779
+ }
780
+ /**
781
+ * Each SKU's price paired with its own compare-at price, or `null` unless the response has every SKU and each has a
782
+ * price. The catalog list returns too few SKUs and no SKU prices, so its cards fall back to the product's ranges.
783
+ */
784
+ function getPairedPrices(product) {
785
+ const connection = product?.skus;
786
+ const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
787
+ if (!skus.length || connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? skus.length) !== skus.length) return null;
788
+ const pairs = skus.flatMap((sku) => {
789
+ const price = sku.prices?.edges?.find((entry) => entry?.node?.value)?.node;
790
+ return typeof price?.value?.value === "number" ? [{
791
+ value: price.value.value,
792
+ compareAt: price.compareAtValue?.value ?? null
793
+ }] : [];
794
+ });
795
+ return pairs.length === skus.length ? pairs : null;
796
+ }
797
+ /**
798
+ * Whether some SKU's compare-at price is above that SKU's own price. Without every SKU's price, only a product whose
799
+ * lowest compare-at price is above its highest price is certainly on sale, so other products show no badge.
800
+ */
801
+ function isProductOnSale(product) {
802
+ const pairs = getPairedPrices(product);
803
+ if (pairs) return pairs.some((pair) => isOnSale(pair.value, pair.compareAt));
804
+ return isOnSale(product?.priceRange?.max, product?.compareAtPriceRange?.min);
805
+ }
806
+ /**
807
+ * The compare-at price that belongs with the product's starting price, so a struck-through price never pairs one
808
+ * SKU's price with another SKU's compare-at price. With every SKU and its price loaded, the cheapest SKUs must share
809
+ * one compare-at price above their price. Otherwise the product needs a single price and a single compare-at price.
810
+ */
811
+ function getStartingCompareAt(product) {
812
+ const price = product?.priceRange;
813
+ const compareAt = product?.compareAtPriceRange;
814
+ const pairs = getPairedPrices(product);
815
+ if (pairs) {
816
+ const lowest = Math.min(...pairs.map((pair) => pair.value));
817
+ if (lowest !== price?.min) return null;
818
+ const compareAts = new Set(pairs.flatMap((pair) => pair.value === lowest ? [pair.compareAt] : []));
819
+ const [only] = compareAts;
820
+ return compareAts.size === 1 && only != null && isOnSale(lowest, only) ? only : null;
821
+ }
822
+ return price?.min === price?.max && compareAt?.min === compareAt?.max && isOnSale(price?.min, compareAt?.min) ? compareAt?.min ?? null : null;
823
+ }
824
+ /** The SKU's attribute value names, or `null` when the response does not include them. */
825
+ function getSkuAttributeValueNames(sku) {
826
+ const edges = sku?.attributeValues?.edges;
827
+ if (!edges) return null;
828
+ return edges.flatMap((edge) => edge?.node?.name ? [edge.node.name] : []);
829
+ }
830
+
831
+ //#endregion
832
+ //#region src/class-names.ts
833
+ const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-commerce-accent px-5 py-2 text-sm font-medium text-commerce-on-accent transition-colors hover:bg-commerce-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-commerce-accent disabled:cursor-not-allowed disabled:bg-commerce-tint-strong disabled:text-commerce-muted disabled:hover:bg-commerce-tint-strong";
834
+ /** A quieter button for repeated or secondary actions: transparent, outlined in the inherited text color. */
835
+ const secondaryButtonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-commerce-edge px-5 py-2 text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:border-commerce-line disabled:text-commerce-muted disabled:hover:bg-transparent";
836
+ 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";
837
+ /** A self-contained message box: its own background and text, so it reads on any page or surface. */
838
+ const errorNoticeClass = "rounded-lg bg-red-50 px-4 py-3 text-sm text-red-800";
839
+ /** The underlined action inside an `errorNoticeClass` box. */
840
+ const errorActionClass = "mt-1 min-h-11 font-medium text-red-800 underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-red-800 disabled:cursor-not-allowed";
841
+ /** The Sale badge: its own fill and text, so it reads over an image or on any page. */
842
+ const saleBadgeClass = "inline-flex rounded bg-commerce-sale px-2 py-0.5 text-xs font-semibold leading-5 text-commerce-on-sale";
735
843
 
736
844
  //#endregion
737
845
  //#region src/part.ts
@@ -802,15 +910,18 @@ function CommerceStatus() {
802
910
  if (connection === "ready") return null;
803
911
  return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "unbound" ? /* @__PURE__ */ jsx("p", {
804
912
  role: "status",
913
+ className: "text-sm text-commerce-muted",
805
914
  children: "Connect a store to show products."
806
915
  }) : connection === "loading" || connection === "connecting" ? /* @__PURE__ */ jsx("p", {
807
916
  role: "status",
917
+ className: "text-sm text-commerce-muted",
808
918
  children: "Connecting to the store…"
809
919
  }) : /* @__PURE__ */ jsxs("div", {
810
920
  role: "alert",
921
+ className: errorNoticeClass,
811
922
  children: [/* @__PURE__ */ jsx("p", { children: connectionError }), /* @__PURE__ */ jsx("button", {
812
923
  type: "button",
813
- className: "min-h-11 underline",
924
+ className: errorActionClass,
814
925
  onClick: retryConnection,
815
926
  children: "Retry connection"
816
927
  })]
@@ -819,30 +930,51 @@ function CommerceStatus() {
819
930
 
820
931
  //#endregion
821
932
  //#region src/cart.tsx
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";
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";
824
- /** Opens the cart drawer and shows the item count. */
933
+ /**
934
+ * Opens the cart drawer: a bag icon with a count badge that pops when the count changes. Its accessible name is
935
+ * "Cart, 2 items". With `children`, a labeled button instead.
936
+ */
825
937
  function CartButton({ children } = {}) {
826
938
  const { setOpen, open, connection } = useCommerce();
827
939
  const { itemCount } = useCart();
940
+ const [bump, setBump] = useState(0);
941
+ const previous = useRef(itemCount);
942
+ useEffect(() => {
943
+ if (previous.current !== itemCount) setBump((count) => count + 1);
944
+ previous.current = itemCount;
945
+ }, [itemCount]);
946
+ const custom = renderLabel(children, { itemCount }, null);
828
947
  return /* @__PURE__ */ jsx(StorefrontSurface, {
829
948
  className: "commerce-inline",
830
949
  children: /* @__PURE__ */ jsx("button", {
831
950
  type: "button",
832
- className: buttonClass,
951
+ className: `relative inline-flex min-h-11 items-center justify-center gap-2 rounded-full text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent ${custom === null ? "min-w-11" : "px-4"}`,
833
952
  disabled: connection !== "ready" && connection !== "unbound",
834
953
  "aria-haspopup": "dialog",
835
954
  "aria-expanded": open,
955
+ "aria-label": custom === null ? `Cart, ${itemCount} ${itemCount === 1 ? "item" : "items"}` : void 0,
836
956
  "data-commerce-part": "cart-button",
837
957
  onClick: () => setOpen(true),
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
- ] }))
958
+ children: custom ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(BagIcon, {}), itemCount > 0 && /* @__PURE__ */ jsx("span", {
959
+ "aria-hidden": "true",
960
+ className: "absolute right-0 top-0.5 inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-commerce-accent px-1 text-[11px] font-semibold tabular-nums text-commerce-on-accent motion-safe:animate-commerce-pop",
961
+ children: itemCount
962
+ }, bump)] })
963
+ })
964
+ });
965
+ }
966
+ function BagIcon() {
967
+ return /* @__PURE__ */ jsx("svg", {
968
+ "aria-hidden": "true",
969
+ viewBox: "0 0 20 20",
970
+ className: "h-5 w-5 shrink-0",
971
+ children: /* @__PURE__ */ jsx("path", {
972
+ d: "M4.5 6.5h11l-.8 9.1a1.5 1.5 0 0 1-1.5 1.4H6.8a1.5 1.5 0 0 1-1.5-1.4L4.5 6.5ZM7.5 6.5V6a2.5 2.5 0 0 1 5 0v.5",
973
+ fill: "none",
974
+ stroke: "currentColor",
975
+ strokeWidth: "1.5",
976
+ strokeLinecap: "round",
977
+ strokeLinejoin: "round"
846
978
  })
847
979
  });
848
980
  }
@@ -854,7 +986,17 @@ function quantityProblem(quantity, available) {
854
986
  if (available !== null && available > 0 && quantity > available) return `Only ${available} available.`;
855
987
  return null;
856
988
  }
857
- function AddToCartButton({ sku, name, quantity = 1, children }) {
989
+ function AddToCartButton(props) {
990
+ return /* @__PURE__ */ jsx(AddToCart, {
991
+ ...props,
992
+ className: buttonClass
993
+ });
994
+ }
995
+ /**
996
+ * `AddToCartButton` with the package's button style chosen by the caller, such as the quieter card style. `block`
997
+ * fills the container's width; the public button stays inline wherever a host places it.
998
+ */
999
+ function AddToCart({ sku, name, quantity = 1, children, className, block = false, label, labelName }) {
858
1000
  const { addItem, pending, hydrating, error, open, connection } = useCommerce();
859
1001
  const [adding, setAdding] = useState(false);
860
1002
  const reasonId = useId();
@@ -867,6 +1009,10 @@ function AddToCartButton({ sku, name, quantity = 1, children }) {
867
1009
  else if (!sku.id) reason = "This item cannot be added to the cart.";
868
1010
  else if (!soldOut) reason = hydrating ? "Loading your cart…" : quantityProblem(quantity, available);
869
1011
  const disabled = adding || soldOut || reason !== null;
1012
+ const ownLabel = !!labelName && !soldOut && !adding && renderLabel(children, {
1013
+ busy: false,
1014
+ soldOut
1015
+ }, null) === null;
870
1016
  async function handleAdd() {
871
1017
  if (disabled || pending || !sku.id) return;
872
1018
  setAdding(true);
@@ -881,21 +1027,22 @@ function AddToCartButton({ sku, name, quantity = 1, children }) {
881
1027
  }
882
1028
  }
883
1029
  return /* @__PURE__ */ jsxs(StorefrontSurface, {
884
- className: "commerce-inline",
1030
+ className: block ? "" : "commerce-inline",
885
1031
  children: [
886
1032
  /* @__PURE__ */ jsx("button", {
887
1033
  type: "button",
888
- className: `${buttonClass} w-full`,
1034
+ className: `${className} w-full`,
889
1035
  disabled,
890
1036
  "aria-disabled": disabled || pending,
891
1037
  "aria-busy": adding,
892
1038
  "aria-describedby": reason ? reasonId : void 0,
1039
+ "aria-label": ownLabel ? labelName : void 0,
893
1040
  "data-commerce-part": "add-to-cart-button",
894
1041
  onClick: () => void handleAdd(),
895
1042
  children: renderLabel(children, {
896
1043
  busy: adding,
897
1044
  soldOut
898
- }, soldOut ? "Out of stock" : adding ? "Adding…" : "Add to cart")
1045
+ }, soldOut ? "Out of stock" : adding ? "Adding…" : label ?? "Add to cart")
899
1046
  }),
900
1047
  reason && /* @__PURE__ */ jsx("span", {
901
1048
  id: reasonId,
@@ -904,7 +1051,7 @@ function AddToCartButton({ sku, name, quantity = 1, children }) {
904
1051
  }),
905
1052
  error && !open && /* @__PURE__ */ jsx("p", {
906
1053
  role: "alert",
907
- className: "mt-3 bg-white text-sm text-red-700",
1054
+ className: `mt-3 ${errorNoticeClass}`,
908
1055
  children: error
909
1056
  })
910
1057
  ]
@@ -986,7 +1133,7 @@ function CartDrawerRoot({ children, className }) {
986
1133
  return /* @__PURE__ */ jsx(Dialog.Root, {
987
1134
  open,
988
1135
  onOpenChange: setOpen,
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", {
1136
+ children: /* @__PURE__ */ jsx(Dialog.Portal, { children: /* @__PURE__ */ jsxs(StorefrontSurface, { children: [/* @__PURE__ */ jsx(Dialog.Overlay, { className: "fixed inset-0 z-40 bg-neutral-950/40 motion-safe:animate-commerce-fade-in" }), /* @__PURE__ */ jsx("div", {
990
1137
  className: "pointer-events-none fixed inset-0 z-50 flex justify-end",
991
1138
  children: /* @__PURE__ */ jsx(Dialog.Content, {
992
1139
  "data-commerce-source": connection === "unbound" ? "sample" : void 0,
@@ -1004,15 +1151,25 @@ function CartDrawerRoot({ children, className }) {
1004
1151
  })] }) })
1005
1152
  });
1006
1153
  }
1007
- /** "Your cart", or "Sample cart" before a store is connected, as an H2. In the drawer it names the dialog. */
1154
+ /**
1155
+ * "Your cart", or "Sample cart" before a store is connected, as an H2, with the item count once there are items.
1156
+ * In the drawer it names the dialog.
1157
+ */
1008
1158
  function CartTitle({ className, children }) {
1009
- const { isSample } = useCartInternal("CartTitle");
1159
+ const { isSample, itemCount } = useCartInternal("CartTitle");
1010
1160
  const Heading = useContext(CartDrawerContext) ? Dialog.Title : "h2";
1011
1161
  return /* @__PURE__ */ jsx("div", {
1012
1162
  ...partProps("cart-title", className),
1013
1163
  children: /* @__PURE__ */ jsx(Heading, {
1014
- className: "text-2xl font-semibold",
1015
- children: renderLabel(children, { isSample }, isSample ? "Sample cart" : "Your cart")
1164
+ className: "text-lg font-semibold tracking-tight",
1165
+ children: renderLabel(children, { isSample }, /* @__PURE__ */ jsxs(Fragment$1, { children: [isSample ? "Sample cart" : "Your cart", itemCount > 0 && /* @__PURE__ */ jsxs("span", {
1166
+ className: "font-normal text-commerce-muted",
1167
+ children: [
1168
+ " (",
1169
+ itemCount,
1170
+ ")"
1171
+ ]
1172
+ })] }))
1016
1173
  })
1017
1174
  });
1018
1175
  }
@@ -1026,10 +1183,10 @@ function CartDescription({ className, children }) {
1026
1183
  return /* @__PURE__ */ jsx("div", {
1027
1184
  ...partProps("cart-description", className),
1028
1185
  children: drawer ? /* @__PURE__ */ jsx(Dialog.Description, {
1029
- className: "text-sm text-neutral-600",
1186
+ className: "text-sm text-commerce-muted",
1030
1187
  children: text
1031
1188
  }) : /* @__PURE__ */ jsx("p", {
1032
- className: "text-sm text-neutral-600",
1189
+ className: "text-sm text-commerce-muted",
1033
1190
  children: text
1034
1191
  })
1035
1192
  });
@@ -1041,9 +1198,20 @@ function CartClose({ className }) {
1041
1198
  return /* @__PURE__ */ jsx("div", {
1042
1199
  ...partProps("cart-close", className),
1043
1200
  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",
1201
+ className: "inline-flex min-h-11 min-w-11 items-center justify-center rounded-full text-commerce-muted transition-colors hover:bg-commerce-tint hover:text-inherit focus-visible:outline focus-visible:outline-2 focus-visible:outline-current",
1045
1202
  "aria-label": "Close cart",
1046
- children: "✕"
1203
+ children: /* @__PURE__ */ jsx("svg", {
1204
+ "aria-hidden": "true",
1205
+ viewBox: "0 0 20 20",
1206
+ className: "h-5 w-5",
1207
+ children: /* @__PURE__ */ jsx("path", {
1208
+ d: "M5 5l10 10M15 5L5 15",
1209
+ fill: "none",
1210
+ stroke: "currentColor",
1211
+ strokeWidth: "1.5",
1212
+ strokeLinecap: "round"
1213
+ })
1214
+ })
1047
1215
  })
1048
1216
  });
1049
1217
  }
@@ -1057,14 +1225,14 @@ function CartNotices({ className }) {
1057
1225
  className: "flex flex-col gap-4",
1058
1226
  children: [storageWarning && /* @__PURE__ */ jsx("p", {
1059
1227
  role: "status",
1060
- className: "text-sm text-amber-800",
1228
+ className: "rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-900",
1061
1229
  children: storageWarning
1062
1230
  }), error && /* @__PURE__ */ jsxs("div", {
1063
1231
  role: "alert",
1064
- className: "rounded-lg bg-red-50 p-4 text-red-800",
1232
+ className: errorNoticeClass,
1065
1233
  children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
1066
1234
  type: "button",
1067
- className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
1235
+ className: errorActionClass,
1068
1236
  disabled: action === "refresh",
1069
1237
  "aria-disabled": locked,
1070
1238
  "aria-busy": action === "refresh",
@@ -1104,6 +1272,7 @@ function CartItems({ className, children }) {
1104
1272
  value: registry.register,
1105
1273
  children: [hydrating && /* @__PURE__ */ jsx("p", {
1106
1274
  role: "status",
1275
+ className: "py-4 text-sm text-commerce-muted",
1107
1276
  children: "Loading your cart…"
1108
1277
  }), /* @__PURE__ */ jsx("div", {
1109
1278
  role: "list",
@@ -1143,7 +1312,7 @@ function CartItemImage({ className }) {
1143
1312
  children: /* @__PURE__ */ jsx("img", {
1144
1313
  src: imageUrl,
1145
1314
  alt: "",
1146
- className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
1315
+ className: "h-20 w-20 bg-commerce-tint object-cover"
1147
1316
  })
1148
1317
  });
1149
1318
  }
@@ -1153,25 +1322,26 @@ function CartItemName({ className }) {
1153
1322
  return /* @__PURE__ */ jsx("div", {
1154
1323
  ...partProps("cart-item-name", className),
1155
1324
  children: /* @__PURE__ */ jsx("h3", {
1156
- className: "font-semibold",
1325
+ className: "break-words text-sm font-medium leading-6",
1157
1326
  children: name
1158
1327
  })
1159
1328
  });
1160
1329
  }
1161
- /** The selected options, one per line, such as "Color: Blue". Renders nothing without options. */
1330
+ /** The selected options on one line, such as "Color: Blue · Size: M". Renders nothing without options. */
1162
1331
  function CartItemOptions({ className }) {
1163
1332
  const { options } = useCartItemInternal("CartItemOptions");
1164
1333
  if (!options.length) return null;
1165
1334
  return /* @__PURE__ */ jsx("div", {
1166
1335
  ...partProps("cart-item-options", className),
1167
- children: options.map((option) => /* @__PURE__ */ jsxs("p", {
1168
- className: "text-sm text-neutral-600",
1169
- children: [
1336
+ children: /* @__PURE__ */ jsx("p", {
1337
+ className: "text-sm text-commerce-muted",
1338
+ children: options.map((option, index) => /* @__PURE__ */ jsxs("span", { children: [
1339
+ index > 0 && " · ",
1170
1340
  option.attribute,
1171
1341
  ": ",
1172
1342
  option.values.join(", ")
1173
- ]
1174
- }, `${option.attribute}:${option.values.join(",")}`))
1343
+ ] }, `${option.attribute}:${option.values.join(",")}`))
1344
+ })
1175
1345
  });
1176
1346
  }
1177
1347
  /** The line total. */
@@ -1181,12 +1351,26 @@ function CartItemPrice({ className }) {
1181
1351
  return /* @__PURE__ */ jsx("div", {
1182
1352
  ...partProps("cart-item-price", className),
1183
1353
  children: /* @__PURE__ */ jsx("p", {
1184
- className: "text-sm",
1354
+ className: "text-sm leading-6 tabular-nums",
1185
1355
  children: subtotal === null ? "Price unavailable" : money$1(subtotal, currency)
1186
1356
  })
1187
1357
  });
1188
1358
  }
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";
1359
+ const stepperButtonClass$1 = "inline-flex min-h-11 min-w-11 items-center justify-center rounded-lg transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent";
1360
+ function StepperIcon$1({ plus }) {
1361
+ return /* @__PURE__ */ jsx("svg", {
1362
+ "aria-hidden": "true",
1363
+ viewBox: "0 0 16 16",
1364
+ className: "h-4 w-4",
1365
+ children: /* @__PURE__ */ jsx("path", {
1366
+ d: plus ? "M3.5 8h9M8 3.5v9" : "M3.5 8h9",
1367
+ fill: "none",
1368
+ stroke: "currentColor",
1369
+ strokeWidth: "1.5",
1370
+ strokeLinecap: "round"
1371
+ })
1372
+ });
1373
+ }
1190
1374
  /** Decrease and increase buttons around the line's quantity. */
1191
1375
  function CartItemQuantity({ className }) {
1192
1376
  const { id, name, quantity } = useCartItemInternal("CartItemQuantity");
@@ -1194,21 +1378,21 @@ function CartItemQuantity({ className }) {
1194
1378
  return /* @__PURE__ */ jsx("div", {
1195
1379
  ...partProps("cart-item-quantity", className),
1196
1380
  children: /* @__PURE__ */ jsxs("div", {
1197
- className: "flex items-center rounded-lg border border-neutral-300",
1381
+ className: "inline-flex items-center rounded-lg border border-commerce-edge",
1198
1382
  children: [
1199
1383
  /* @__PURE__ */ jsx("button", {
1200
1384
  type: "button",
1201
- className: stepperButtonClass,
1385
+ className: stepperButtonClass$1,
1202
1386
  "aria-label": `Decrease quantity of ${name}`,
1203
1387
  "data-commerce-control": "decrease",
1204
1388
  disabled: action === `decrease:${id}` || !id || quantity <= 1,
1205
1389
  "aria-disabled": locked || !id || quantity <= 1,
1206
1390
  "aria-busy": action === `decrease:${id}`,
1207
1391
  onClick: () => run(`decrease:${id}`, () => changeQuantity(id, quantity - 1)),
1208
- children: "−"
1392
+ children: /* @__PURE__ */ jsx(StepperIcon$1, {})
1209
1393
  }),
1210
1394
  /* @__PURE__ */ jsxs("span", {
1211
- className: "min-w-8 text-center",
1395
+ className: "min-w-8 text-center text-sm tabular-nums",
1212
1396
  children: [/* @__PURE__ */ jsx("span", {
1213
1397
  className: "sr-only",
1214
1398
  children: "Quantity: "
@@ -1216,14 +1400,14 @@ function CartItemQuantity({ className }) {
1216
1400
  }),
1217
1401
  /* @__PURE__ */ jsx("button", {
1218
1402
  type: "button",
1219
- className: stepperButtonClass,
1403
+ className: stepperButtonClass$1,
1220
1404
  "aria-label": `Increase quantity of ${name}`,
1221
1405
  "data-commerce-control": "increase",
1222
1406
  disabled: action === `increase:${id}` || !id,
1223
1407
  "aria-disabled": locked || !id,
1224
1408
  "aria-busy": action === `increase:${id}`,
1225
1409
  onClick: () => run(`increase:${id}`, () => changeQuantity(id, quantity + 1)),
1226
- children: "+"
1410
+ children: /* @__PURE__ */ jsx(StepperIcon$1, { plus: true })
1227
1411
  })
1228
1412
  ]
1229
1413
  })
@@ -1237,7 +1421,7 @@ function CartItemRemove({ className, children }) {
1237
1421
  ...partProps("cart-item-remove", className),
1238
1422
  children: /* @__PURE__ */ jsxs("button", {
1239
1423
  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",
1424
+ className: "min-h-11 rounded-sm px-1 text-sm text-commerce-muted underline-offset-4 transition-colors hover:text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:hover:no-underline",
1241
1425
  disabled: action === `remove:${id}` || !id,
1242
1426
  "aria-disabled": locked || !id,
1243
1427
  "aria-busy": action === `remove:${id}`,
@@ -1250,20 +1434,19 @@ function CartItemRemove({ className, children }) {
1250
1434
  });
1251
1435
  }
1252
1436
  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
- ]
1437
+ className: "flex gap-4 py-5",
1438
+ children: [/* @__PURE__ */ jsx(CartItemImage, { className: "shrink-0" }), /* @__PURE__ */ jsxs("div", {
1439
+ className: "flex min-w-0 flex-1 flex-col",
1440
+ children: [/* @__PURE__ */ jsxs("div", {
1441
+ className: "flex items-start justify-between gap-4",
1442
+ children: [/* @__PURE__ */ jsxs("div", {
1443
+ className: "min-w-0",
1444
+ children: [/* @__PURE__ */ jsx(CartItemName, {}), /* @__PURE__ */ jsx(CartItemOptions, {})]
1445
+ }), /* @__PURE__ */ jsx(CartItemPrice, { className: "shrink-0" })]
1446
+ }), /* @__PURE__ */ jsxs("div", {
1447
+ className: "mt-3 flex items-center justify-between gap-2",
1448
+ children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1263
1449
  })]
1264
- }), /* @__PURE__ */ jsxs("div", {
1265
- className: "mt-4 flex items-center justify-between gap-2",
1266
- children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1267
1450
  })]
1268
1451
  });
1269
1452
  /** Renders only when the cart is empty: a message and a link to the catalog, or its children. */
@@ -1273,16 +1456,32 @@ function CartEmpty({ className, children }) {
1273
1456
  return /* @__PURE__ */ jsx("div", {
1274
1457
  ...partProps("cart-empty", className),
1275
1458
  children: hasChildren(children) ? children : /* @__PURE__ */ jsxs(Fragment$1, { children: [
1459
+ /* @__PURE__ */ jsx("div", {
1460
+ className: "mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-commerce-tint text-commerce-muted",
1461
+ children: /* @__PURE__ */ jsx("svg", {
1462
+ "aria-hidden": "true",
1463
+ viewBox: "0 0 20 20",
1464
+ className: "h-6 w-6",
1465
+ children: /* @__PURE__ */ jsx("path", {
1466
+ d: "M4.5 6.5h11l-.8 9.1a1.5 1.5 0 0 1-1.5 1.4H6.8a1.5 1.5 0 0 1-1.5-1.4L4.5 6.5ZM7.5 6.5V6a2.5 2.5 0 0 1 5 0v.5",
1467
+ fill: "none",
1468
+ stroke: "currentColor",
1469
+ strokeWidth: "1.5",
1470
+ strokeLinecap: "round",
1471
+ strokeLinejoin: "round"
1472
+ })
1473
+ })
1474
+ }),
1276
1475
  /* @__PURE__ */ jsx("p", {
1277
- className: "text-xl font-medium",
1476
+ className: "text-base font-semibold",
1278
1477
  children: "Your cart is empty"
1279
1478
  }),
1280
1479
  /* @__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."
1480
+ className: "mt-1 text-sm text-commerce-muted",
1481
+ children: isSample ? "Add a sample product to try the cart." : "Add something you love and it will show up here."
1283
1482
  }),
1284
1483
  /* @__PURE__ */ jsx(Link, {
1285
- className: `${buttonClass} mt-6`,
1484
+ className: `${secondaryButtonClass} mt-6`,
1286
1485
  to: catalogPath,
1287
1486
  onClick: close,
1288
1487
  children: "Browse products"
@@ -1306,17 +1505,17 @@ function CartSubtotal({ className }) {
1306
1505
  if (!items.length || subtotal === null) return null;
1307
1506
  return /* @__PURE__ */ jsx("div", {
1308
1507
  ...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
- })
1508
+ children: /* @__PURE__ */ jsx("dl", { children: /* @__PURE__ */ jsxs("div", {
1509
+ className: "flex justify-between gap-4 font-semibold",
1510
+ children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", {
1511
+ className: "tabular-nums",
1512
+ children: money$1(subtotal, currency)
1513
+ })]
1514
+ }) })
1316
1515
  });
1317
1516
  }
1318
1517
  /**
1319
- * "Shipping, taxes, and discounts are calculated at checkout." Has the stable
1518
+ * A row under the subtotal: "Shipping, taxes & discounts", "Calculated at checkout". Has the stable
1320
1519
  * `commerce-cart-checkout-adjustments-note` class. Renders nothing for samples or an empty cart.
1321
1520
  */
1322
1521
  function CartCheckoutNote({ className }) {
@@ -1324,9 +1523,9 @@ function CartCheckoutNote({ className }) {
1324
1523
  if (isSample || !items.length) return null;
1325
1524
  return /* @__PURE__ */ jsx("div", {
1326
1525
  ...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."
1526
+ children: /* @__PURE__ */ jsxs("dl", {
1527
+ className: "flex justify-between gap-4 text-sm text-commerce-muted",
1528
+ children: [/* @__PURE__ */ jsx("dt", { children: "Shipping, taxes & discounts" }), /* @__PURE__ */ jsx("dd", { children: "Calculated at checkout" })]
1330
1529
  })
1331
1530
  });
1332
1531
  }
@@ -1339,11 +1538,11 @@ function CartCheckout({ className, children }) {
1339
1538
  if (!items.length) return null;
1340
1539
  let content;
1341
1540
  if (isSample) content = /* @__PURE__ */ jsx("p", {
1342
- className: "text-sm text-neutral-600",
1541
+ className: "text-sm text-commerce-muted",
1343
1542
  children: "Example total in USD. Checkout is not set up yet."
1344
1543
  });
1345
1544
  else if (!checkoutEnabled) content = /* @__PURE__ */ jsx("p", {
1346
- className: "text-sm text-neutral-600",
1545
+ className: "text-sm text-commerce-muted",
1347
1546
  children: "Checkout is not available yet."
1348
1547
  });
1349
1548
  else content = /* @__PURE__ */ jsx("button", {
@@ -1353,36 +1552,66 @@ function CartCheckout({ className, children }) {
1353
1552
  "aria-disabled": locked || !checkoutReady,
1354
1553
  "aria-busy": action === "checkout",
1355
1554
  onClick: () => run("checkout", checkout),
1356
- children: renderLabel(children, { busy: action === "checkout" }, action === "checkout" ? "Opening checkout…" : "Proceed to Checkout")
1555
+ children: renderLabel(children, { busy: action === "checkout" }, action === "checkout" ? "Opening checkout…" : "Proceed to checkout")
1357
1556
  });
1358
1557
  return /* @__PURE__ */ jsx("div", {
1359
1558
  ...partProps("cart-checkout", className),
1360
1559
  children: content
1361
1560
  });
1362
1561
  }
1562
+ /**
1563
+ * "Continue shopping": closes the drawer, or on a cart page links to the catalog. Renders nothing for an empty cart,
1564
+ * whose `CartEmpty` links to the catalog.
1565
+ */
1566
+ function CartContinueShopping({ className, children }) {
1567
+ const { items, isSample, catalogPath } = useCartInternal("CartContinueShopping");
1568
+ const inDrawer = !!useContext(CartDrawerContext);
1569
+ if (!items.length) return null;
1570
+ const label = renderLabel(children, { isSample }, "Continue shopping");
1571
+ const linkClass = "inline-flex min-h-11 w-full items-center justify-center rounded-lg px-5 py-2 text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current";
1572
+ return /* @__PURE__ */ jsx("div", {
1573
+ ...partProps("cart-continue-shopping", className),
1574
+ children: inDrawer ? /* @__PURE__ */ jsx(Dialog.Close, {
1575
+ className: linkClass,
1576
+ children: label
1577
+ }) : /* @__PURE__ */ jsx(Link, {
1578
+ className: linkClass,
1579
+ to: catalogPath,
1580
+ children: label
1581
+ })
1582
+ });
1583
+ }
1584
+ function SampleDescription({ className }) {
1585
+ const { isSample } = useCartInternal("CartDescription");
1586
+ return isSample ? /* @__PURE__ */ jsx(CartDescription, { className }) : null;
1587
+ }
1363
1588
  /** The packaged cart drawer. Compose `CartDrawerRoot` and the cart parts for a custom drawer. */
1364
1589
  function CartDrawer() {
1365
1590
  return /* @__PURE__ */ jsxs(CartDrawerRoot, {
1366
- className: "flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
1591
+ className: "flex w-full max-w-md flex-col bg-white text-neutral-900 shadow-2xl motion-safe:animate-commerce-drawer-in",
1367
1592
  children: [
1368
1593
  /* @__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, {})]
1594
+ className: "flex items-start justify-between gap-4 border-b border-commerce-line py-3 pl-6 pr-3",
1595
+ children: [/* @__PURE__ */ jsxs("div", {
1596
+ className: "min-w-0 pt-2.5",
1597
+ children: [/* @__PURE__ */ jsx(CartTitle, {}), /* @__PURE__ */ jsx(SampleDescription, { className: "mt-0.5" })]
1598
+ }), /* @__PURE__ */ jsx(CartClose, {})]
1371
1599
  }),
1372
1600
  /* @__PURE__ */ jsxs("div", {
1373
- className: "flex-1 overflow-y-auto p-6",
1601
+ className: "flex-1 overflow-y-auto px-6",
1374
1602
  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" })
1603
+ /* @__PURE__ */ jsx(CartNotices, { className: "mt-6" }),
1604
+ /* @__PURE__ */ jsx(CartItems, { className: "divide-y divide-commerce-line" }),
1605
+ /* @__PURE__ */ jsx(CartEmpty, { className: "py-20 text-center" })
1378
1606
  ]
1379
1607
  }),
1380
1608
  /* @__PURE__ */ jsxs(CartSummary, {
1381
- className: "border-t border-neutral-200 p-6",
1609
+ className: "border-t border-commerce-line px-6 pb-6 pt-4",
1382
1610
  children: [
1383
1611
  /* @__PURE__ */ jsx(CartSubtotal, {}),
1384
- /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-3" }),
1385
- /* @__PURE__ */ jsx(CartCheckout, {})
1612
+ /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-1" }),
1613
+ /* @__PURE__ */ jsx(CartCheckout, { className: "mt-5" }),
1614
+ /* @__PURE__ */ jsx(CartContinueShopping, { className: "mt-2" })
1386
1615
  ]
1387
1616
  })
1388
1617
  ]
@@ -1404,7 +1633,7 @@ function ProductImage({ url, name, className = "" }) {
1404
1633
  className: `h-full w-full object-cover ${className}`,
1405
1634
  onError: () => setFailedUrl(url)
1406
1635
  }) : /* @__PURE__ */ jsx("div", {
1407
- className: `flex h-full min-h-44 items-center justify-center p-6 text-center ${className}`,
1636
+ className: `flex h-full min-h-44 items-center justify-center bg-commerce-tint p-6 text-center text-sm text-commerce-muted ${className}`,
1408
1637
  role: "img",
1409
1638
  "aria-label": `${name}: image unavailable`,
1410
1639
  children: "No image available"
@@ -1504,16 +1733,16 @@ function ProductCardImage({ className }) {
1504
1733
  to: card.href,
1505
1734
  tabIndex: card.imageLinked ? void 0 : -1,
1506
1735
  "aria-hidden": card.imageLinked ? void 0 : "true",
1507
- className: "block aspect-square overflow-hidden focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4",
1736
+ className: "block aspect-square overflow-hidden bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
1508
1737
  children: /* @__PURE__ */ jsx(ProductImage, {
1509
1738
  url: getPrimaryImageUrl(card.product),
1510
1739
  name: card.name,
1511
- className: "motion-safe:transition-transform motion-safe:duration-200 motion-safe:group-hover:scale-105"
1740
+ className: "motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out motion-safe:group-hover:scale-[1.03]"
1512
1741
  })
1513
1742
  }) : /* @__PURE__ */ jsx("div", {
1514
1743
  "aria-hidden": "true",
1515
- className: "flex aspect-square items-center justify-center bg-neutral-100",
1516
- children: /* @__PURE__ */ jsx("div", { className: "h-20 w-20 rounded-lg border border-neutral-300" })
1744
+ className: "flex aspect-square items-center justify-center bg-commerce-tint",
1745
+ children: /* @__PURE__ */ jsx("div", { className: "h-16 w-16 rounded-full border border-commerce-line" })
1517
1746
  })
1518
1747
  });
1519
1748
  }
@@ -1524,9 +1753,9 @@ function ProductCardTitle({ className }) {
1524
1753
  return /* @__PURE__ */ jsx("div", {
1525
1754
  ...partProps("product-card-title", className),
1526
1755
  children: /* @__PURE__ */ jsx("h2", {
1527
- className: "break-words text-2xl font-semibold leading-tight",
1756
+ className: "break-words text-base font-medium leading-6",
1528
1757
  children: href ? /* @__PURE__ */ jsx(Link, {
1529
- className: "rounded text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
1758
+ className: "rounded-sm text-inherit underline-offset-4 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
1530
1759
  to: href,
1531
1760
  children: name
1532
1761
  }) : name
@@ -1540,31 +1769,108 @@ function ProductCardDescription({ className }) {
1540
1769
  return /* @__PURE__ */ jsx("div", {
1541
1770
  ...partProps("product-card-description", className),
1542
1771
  children: /* @__PURE__ */ jsx("p", {
1543
- className: "line-clamp-3 text-sm leading-6",
1772
+ className: "line-clamp-2 text-sm leading-6 text-commerce-muted",
1544
1773
  children: product.description
1545
1774
  })
1546
1775
  });
1547
1776
  }
1548
- /** The product's price, or its price range when variants differ. */
1777
+ const formatRange = (range, money$1) => {
1778
+ const min = range?.min;
1779
+ const max = range?.max;
1780
+ if (typeof min !== "number") return null;
1781
+ return `${money$1(min)}${typeof max === "number" && max !== min ? ` – ${money$1(max)}` : ""}`;
1782
+ };
1783
+ /**
1784
+ * "Sale" when some SKU's compare-at price is above that SKU's price. Without SKU prices, as in the catalog list, only
1785
+ * when the lowest compare-at price is above the highest price. Renders nothing otherwise, or for samples.
1786
+ */
1787
+ function ProductCardBadge({ className }) {
1788
+ const { product } = useProductCardInternal("ProductCardBadge");
1789
+ if (!isProductOnSale(product)) return null;
1790
+ return /* @__PURE__ */ jsx("div", {
1791
+ ...partProps("product-card-badge", className),
1792
+ children: /* @__PURE__ */ jsx("span", {
1793
+ className: saleBadgeClass,
1794
+ children: "Sale"
1795
+ })
1796
+ });
1797
+ }
1798
+ /** The product's price, or its price range when variants differ. A single price on sale shows its compare-at price. */
1549
1799
  function ProductCardPrice({ className }) {
1550
1800
  const card = useProductCardInternal("ProductCardPrice");
1551
1801
  const money$1 = useMoney();
1552
- let text;
1553
- if (card.source === "sample") text = money$1(card.sample.price, "USD");
1554
- else {
1555
- const min = card.product.priceRange?.min;
1556
- const max = card.product.priceRange?.max;
1557
- text = typeof min === "number" ? `${money$1(min)}${typeof max === "number" && max !== min ? ` – ${money$1(max)}` : ""}` : "Price unavailable";
1558
- }
1802
+ const text = card.source === "sample" ? money$1(card.sample.price, "USD") : formatRange(card.product.priceRange, money$1) ?? "Price unavailable";
1803
+ const range = card.source === "live" ? card.product.priceRange : null;
1804
+ const starting = card.source === "live" && range?.min === range?.max ? getStartingCompareAt(card.product) : null;
1805
+ const compareAt = starting === null ? null : money$1(starting);
1559
1806
  return /* @__PURE__ */ jsx("div", {
1560
1807
  ...partProps("product-card-price", className),
1561
- children: /* @__PURE__ */ jsx("p", {
1562
- className: "text-lg font-semibold leading-7",
1563
- children: text
1808
+ children: /* @__PURE__ */ jsxs("p", {
1809
+ className: "flex flex-wrap items-baseline gap-x-2 text-sm leading-6 tabular-nums",
1810
+ children: [/* @__PURE__ */ jsx("span", {
1811
+ className: "font-medium",
1812
+ children: text
1813
+ }), compareAt && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
1814
+ className: "sr-only",
1815
+ children: "Was"
1816
+ }), /* @__PURE__ */ jsx("del", {
1817
+ className: "text-commerce-muted",
1818
+ children: compareAt
1819
+ })] })]
1564
1820
  })
1565
1821
  });
1566
1822
  }
1567
1823
  /**
1824
+ * Dots for the first attribute whose values all have a swatch from the provider's `getSwatch`, such as colors.
1825
+ * Renders nothing without `getSwatch`, for samples, or when no attribute has swatches.
1826
+ */
1827
+ function ProductCardSwatches({ className }) {
1828
+ const { product } = useProductCardInternal("ProductCardSwatches");
1829
+ const { getSwatch } = useCommerce();
1830
+ if (!getSwatch || !product) return null;
1831
+ for (const attribute of getProductAttributes(product)) {
1832
+ const swatches = attribute.values.map((value) => ({
1833
+ ...value,
1834
+ background: getSwatch({
1835
+ attribute: attribute.name,
1836
+ name: value.name,
1837
+ label: value.label
1838
+ })
1839
+ }));
1840
+ if (!swatches.length || swatches.some((swatch) => !swatch.background)) continue;
1841
+ const shown = swatches.slice(0, 5);
1842
+ return /* @__PURE__ */ jsx("div", {
1843
+ ...partProps("product-card-swatches", className),
1844
+ children: /* @__PURE__ */ jsxs("p", {
1845
+ className: "flex items-center gap-1.5 text-xs text-commerce-muted",
1846
+ children: [
1847
+ /* @__PURE__ */ jsxs("span", {
1848
+ className: "sr-only",
1849
+ children: [
1850
+ swatches.length,
1851
+ " ",
1852
+ inSentence(attribute.label),
1853
+ " options:",
1854
+ " ",
1855
+ swatches.map((swatch) => swatch.label).join(", ")
1856
+ ]
1857
+ }),
1858
+ shown.map((swatch) => /* @__PURE__ */ jsx("span", {
1859
+ "aria-hidden": "true",
1860
+ className: "h-3.5 w-3.5 rounded-full border border-commerce-edge",
1861
+ style: { background: swatch.background ?? void 0 }
1862
+ }, swatch.name)),
1863
+ swatches.length > shown.length && /* @__PURE__ */ jsxs("span", {
1864
+ "aria-hidden": "true",
1865
+ children: ["+", swatches.length - shown.length]
1866
+ })
1867
+ ]
1868
+ })
1869
+ });
1870
+ }
1871
+ return null;
1872
+ }
1873
+ /**
1568
1874
  * Adds the product directly when it has exactly one SKU and no options; otherwise links to its page to choose.
1569
1875
  * Samples add to the sample cart.
1570
1876
  */
@@ -1586,19 +1892,22 @@ function ProductCardAction({ className, children }) {
1586
1892
  function LiveAction({ product, name, href, label }) {
1587
1893
  const selectedSku = getSingleMatchedSku(product);
1588
1894
  const hasVariants = getProductAttributes(product).length > 0;
1589
- if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCartButton, {
1895
+ if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCart, {
1590
1896
  sku: selectedSku,
1591
1897
  name,
1898
+ className: secondaryButtonClass,
1899
+ block: true,
1592
1900
  children: (state) => renderLabel(label, {
1593
1901
  action: "add",
1594
1902
  ...state
1595
1903
  }, null)
1596
1904
  });
1905
+ const action = hasVariants ? "choose-options" : "view-product";
1597
1906
  return /* @__PURE__ */ jsx(Link, {
1598
- className: buttonClass,
1907
+ className: `${secondaryButtonClass} w-full`,
1599
1908
  to: href,
1600
1909
  children: renderLabel(label, {
1601
- action: hasVariants ? "choose-options" : "view-product",
1910
+ action,
1602
1911
  busy: false,
1603
1912
  soldOut: false
1604
1913
  }, hasVariants ? "Choose options" : "View product")
@@ -1613,13 +1922,13 @@ function SampleAction({ sample, label }) {
1613
1922
  }, null);
1614
1923
  return custom === null ? /* @__PURE__ */ jsx("button", {
1615
1924
  type: "button",
1616
- className: buttonClass,
1925
+ className: `${secondaryButtonClass} w-full`,
1617
1926
  "aria-label": `Add ${sample.name} to sample cart`,
1618
1927
  onClick: () => add(sample.key),
1619
1928
  children: "Add to cart"
1620
1929
  }) : /* @__PURE__ */ jsxs("button", {
1621
1930
  type: "button",
1622
- className: buttonClass,
1931
+ className: `${secondaryButtonClass} w-full`,
1623
1932
  onClick: () => add(sample.key),
1624
1933
  children: [custom, /* @__PURE__ */ jsxs("span", {
1625
1934
  className: "sr-only",
@@ -1631,21 +1940,22 @@ function SampleAction({ sample, label }) {
1631
1940
  })]
1632
1941
  });
1633
1942
  }
1634
- const packagedCardClass = "flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200";
1635
- /** The packaged card's parts, inside a root that has `packagedCardClass`. */
1943
+ const packagedCardClass = "flex min-w-0 flex-col";
1944
+ /**
1945
+ * The packaged card's parts, inside a root that has `packagedCardClass`. No description: cards scan better without
1946
+ * it, and `ProductCardDescription` adds it back.
1947
+ */
1636
1948
  function PackagedCardParts() {
1637
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsxs("div", {
1638
- className: "flex flex-1 flex-col p-6",
1949
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
1950
+ className: "relative",
1951
+ children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsx(ProductCardBadge, { className: "pointer-events-none absolute left-3 top-3" })]
1952
+ }), /* @__PURE__ */ jsxs("div", {
1953
+ className: "flex flex-1 flex-col pt-3",
1639
1954
  children: [
1640
1955
  /* @__PURE__ */ jsx(ProductCardTitle, {}),
1641
- /* @__PURE__ */ jsx(ProductCardDescription, { className: "mt-3" }),
1642
- /* @__PURE__ */ jsx("div", {
1643
- className: "mt-auto pt-6",
1644
- children: /* @__PURE__ */ jsxs("div", {
1645
- className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
1646
- children: [/* @__PURE__ */ jsx(ProductCardPrice, { className: "min-w-0 flex-1" }), /* @__PURE__ */ jsx(ProductCardAction, { className: "max-w-full" })]
1647
- })
1648
- })
1956
+ /* @__PURE__ */ jsx(ProductCardPrice, { className: "mt-0.5" }),
1957
+ /* @__PURE__ */ jsx(ProductCardSwatches, { className: "mt-2" }),
1958
+ /* @__PURE__ */ jsx(ProductCardAction, { className: "mt-auto pt-4" })
1649
1959
  ]
1650
1960
  })] });
1651
1961
  }
@@ -1770,10 +2080,10 @@ function CatalogHeader({ title = "Shop all products", description = "Explore the
1770
2080
  return /* @__PURE__ */ jsxs("header", {
1771
2081
  ...partProps("catalog-header", className),
1772
2082
  children: [/* @__PURE__ */ jsx("h1", {
1773
- className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
2083
+ className: "max-w-3xl text-balance text-3xl font-semibold leading-tight tracking-tight",
1774
2084
  children: title
1775
2085
  }), /* @__PURE__ */ jsx("p", {
1776
- className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
2086
+ className: "mt-1 max-w-xl text-base leading-7 text-commerce-muted",
1777
2087
  children: description
1778
2088
  })]
1779
2089
  });
@@ -1784,7 +2094,7 @@ function CatalogSummary({ className }) {
1784
2094
  return /* @__PURE__ */ jsx("div", {
1785
2095
  ...partProps("catalog-summary", className),
1786
2096
  children: /* @__PURE__ */ jsx("p", {
1787
- className: "text-sm",
2097
+ className: "text-sm text-commerce-muted",
1788
2098
  role: "status",
1789
2099
  children: summary(catalog)
1790
2100
  })
@@ -1796,6 +2106,8 @@ function summary(catalog) {
1796
2106
  if (catalog.products.isError) return "Products unavailable";
1797
2107
  return `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? "" : "s"}`;
1798
2108
  }
2109
+ /** Two columns on phones, then as many 240px columns as fit. Shared with `RelatedProducts`. */
2110
+ const catalogGridClass = "grid grid-cols-2 gap-x-4 gap-y-10 sm:gap-x-6 md:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] lg:gap-y-12";
1799
2111
  const sampleItems = sampleProducts.map((sample) => ({
1800
2112
  key: sample.key,
1801
2113
  item: {
@@ -1803,7 +2115,7 @@ const sampleItems = sampleProducts.map((sample) => ({
1803
2115
  sample
1804
2116
  }
1805
2117
  }));
1806
- const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
2118
+ const packagedCard$1 = /* @__PURE__ */ jsx(ProductCardRoot, {
1807
2119
  className: packagedCardClass,
1808
2120
  children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1809
2121
  });
@@ -1814,7 +2126,7 @@ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
1814
2126
  function CatalogProducts({ className, children }) {
1815
2127
  const catalog = useCatalogInternal("CatalogProducts");
1816
2128
  const customEmpty = useContext(CatalogEmptyContext)?.custom ?? false;
1817
- const card = hasChildren(children) ? children : packagedCard;
2129
+ const card = hasChildren(children) ? children : packagedCard$1;
1818
2130
  const nodes = catalog.source === "live" ? catalog.nodes : null;
1819
2131
  const items = useMemo(() => nodes ? nodes.map((product) => ({
1820
2132
  key: product.id ?? "",
@@ -1827,16 +2139,16 @@ function CatalogProducts({ className, children }) {
1827
2139
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
1828
2140
  products?.isError && /* @__PURE__ */ jsxs("div", {
1829
2141
  role: "alert",
1830
- className: "rounded-lg bg-red-50 p-5 text-red-800",
2142
+ className: errorNoticeClass,
1831
2143
  children: [/* @__PURE__ */ jsx("p", { children: message(products.error) }), /* @__PURE__ */ jsx("button", {
1832
2144
  type: "button",
1833
- className: "min-h-11 bg-red-50 text-red-800 underline",
2145
+ className: errorActionClass,
1834
2146
  onClick: () => void products.refetch(),
1835
2147
  children: "Retry products"
1836
2148
  })]
1837
2149
  }),
1838
2150
  products?.isSuccess && !items.length && !customEmpty && /* @__PURE__ */ jsx("p", {
1839
- className: "py-16 text-center",
2151
+ className: "py-16 text-center text-commerce-muted",
1840
2152
  children: "No products available."
1841
2153
  }),
1842
2154
  /* @__PURE__ */ jsx("div", {
@@ -1847,15 +2159,15 @@ function CatalogProducts({ className, children }) {
1847
2159
  2
1848
2160
  ].map((placeholder) => /* @__PURE__ */ jsxs("div", {
1849
2161
  "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
- })]
2162
+ className: "motion-safe:animate-pulse",
2163
+ children: [
2164
+ /* @__PURE__ */ jsx("div", { className: "aspect-square bg-commerce-tint-strong" }),
2165
+ /* @__PURE__ */ jsxs("div", {
2166
+ className: "space-y-2 pt-3",
2167
+ children: [/* @__PURE__ */ jsx("div", { className: "h-4 w-2/3 rounded bg-commerce-tint-strong" }), /* @__PURE__ */ jsx("div", { className: "h-4 w-1/4 rounded bg-commerce-tint-strong" })]
2168
+ }),
2169
+ /* @__PURE__ */ jsx("div", { className: "mt-4 h-11 rounded-lg bg-commerce-tint" })
2170
+ ]
1859
2171
  }, placeholder)) : items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1860
2172
  value: item,
1861
2173
  children: card
@@ -1877,25 +2189,29 @@ function CatalogEmpty({ className, children }) {
1877
2189
  children: hasChildren(children) ? children : /* @__PURE__ */ jsx("p", { children: "No products available." })
1878
2190
  });
1879
2191
  }
1880
- /** First-page and next-page buttons. Renders nothing for samples, which have one page. */
2192
+ /**
2193
+ * First-page and next-page buttons. Renders nothing when there is only one page, including for samples.
2194
+ */
1881
2195
  function CatalogPagination({ className }) {
1882
2196
  const catalog = useCatalogInternal("CatalogPagination");
1883
2197
  if (catalog.source === "sample") return null;
1884
2198
  const { after, goToPage } = catalog;
1885
2199
  const pageInfo = catalog.products.data?.skuGroups?.pageInfo;
2200
+ const hasNext = !!(pageInfo?.hasNextPage && pageInfo.endCursor);
2201
+ if (!after && !hasNext) return null;
1886
2202
  return /* @__PURE__ */ jsx("nav", {
1887
2203
  "aria-label": "Product pages",
1888
2204
  ...partProps("catalog-pagination", className),
1889
2205
  children: /* @__PURE__ */ jsxs("div", {
1890
- className: "flex justify-between",
1891
- children: [after ? /* @__PURE__ */ jsx("button", {
2206
+ className: "flex justify-center gap-3",
2207
+ children: [after && /* @__PURE__ */ jsx("button", {
1892
2208
  type: "button",
1893
- className: buttonClass,
2209
+ className: secondaryButtonClass,
1894
2210
  onClick: () => goToPage(),
1895
2211
  children: "First page"
1896
- }) : /* @__PURE__ */ jsx("span", {}), pageInfo?.hasNextPage && pageInfo.endCursor && /* @__PURE__ */ jsx("button", {
2212
+ }), hasNext && pageInfo?.endCursor && /* @__PURE__ */ jsx("button", {
1897
2213
  type: "button",
1898
- className: buttonClass,
2214
+ className: secondaryButtonClass,
1899
2215
  onClick: () => goToPage(pageInfo.endCursor ?? void 0),
1900
2216
  children: "Next page"
1901
2217
  })]
@@ -1908,14 +2224,15 @@ function Catalog({ title, description, showHeader = true, sampleProducts: sample
1908
2224
  sampleProducts: sampleProducts$1,
1909
2225
  pageSize,
1910
2226
  children: [
1911
- showHeader && /* @__PURE__ */ jsx(CatalogHeader, {
1912
- title,
1913
- description,
1914
- className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8"
2227
+ /* @__PURE__ */ jsxs("div", {
2228
+ className: "flex flex-wrap items-end justify-between gap-x-4 gap-y-2 pb-6 pt-4 sm:pb-8 sm:pt-8",
2229
+ children: [showHeader && /* @__PURE__ */ jsx(CatalogHeader, {
2230
+ title,
2231
+ description
2232
+ }), /* @__PURE__ */ jsx(CatalogSummary, {})]
1915
2233
  }),
1916
- /* @__PURE__ */ jsx(CatalogSummary, { className: "border-b border-neutral-200 py-6 sm:py-8" }),
1917
- /* @__PURE__ */ jsx(CatalogProducts, { className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10" }),
1918
- /* @__PURE__ */ jsx(CatalogPagination, { className: "border-t border-neutral-200 pt-6" })
2234
+ /* @__PURE__ */ jsx(CatalogProducts, { className: catalogGridClass }),
2235
+ /* @__PURE__ */ jsx(CatalogPagination, { className: "mt-12 border-t border-commerce-line pt-6" })
1919
2236
  ]
1920
2237
  });
1921
2238
  }
@@ -1956,7 +2273,7 @@ function useProductDetails() {
1956
2273
  * A selection is never purchasable until the server verifies it.
1957
2274
  */
1958
2275
  function useProductDetailsOption(attribute) {
1959
- const { attributes, selections, selectOption, register } = useProductDetailsInternal("useProductDetailsOption");
2276
+ const { attributes, selections, selectOption, register, soldOutValues, showMissing, getSwatch } = useProductDetailsInternal("useProductDetailsOption");
1960
2277
  useEffect(() => register(`attribute:${attribute}`), [attribute, register]);
1961
2278
  const index = attributes.findIndex((candidate) => candidate.name === attribute);
1962
2279
  const match = attributes[index];
@@ -1966,10 +2283,17 @@ function useProductDetailsOption(attribute) {
1966
2283
  name: match.name,
1967
2284
  label: match.label,
1968
2285
  selected,
2286
+ missing: showMissing && !selected,
1969
2287
  values: match.values.map((value) => ({
1970
2288
  name: value.name,
1971
2289
  label: value.label,
1972
- selected: value.name === selected
2290
+ selected: value.name === selected,
2291
+ soldOut: soldOutValues[index]?.has(value.name) ?? false,
2292
+ swatch: getSwatch?.({
2293
+ attribute: match.name,
2294
+ name: value.name,
2295
+ label: value.label
2296
+ }) || null
1973
2297
  })),
1974
2298
  select: (value) => {
1975
2299
  if (value === null) selectOption(attribute, "");
@@ -2001,10 +2325,16 @@ function ProductDetailsRoot({ productId: productIdProp, className,...props }) {
2001
2325
  }) });
2002
2326
  }
2003
2327
  function ProductDetailsLoader({ productId, className, children, urlState = true, loading, notFound, error }) {
2004
- const { config } = useCommerce();
2328
+ const { config, getSwatch } = useCommerce();
2005
2329
  const [params, setParams] = useRootParams(urlState);
2006
2330
  const [quantity, setQuantity] = useState(1);
2007
2331
  const [imageIndex, setImageIndex] = useState(0);
2332
+ const [showMissing, setShowMissing] = useState(false);
2333
+ const [focusMissing, setFocusMissing] = useState(0);
2334
+ const root = useRef(null);
2335
+ useEffect(() => {
2336
+ if (focusMissing) focusFirstMissing(root);
2337
+ }, [focusMissing]);
2008
2338
  const product = useQuery({
2009
2339
  retry: false,
2010
2340
  enabled: !!productId,
@@ -2055,7 +2385,6 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2055
2385
  if (value$1) next.set(key, value$1);
2056
2386
  else next.delete(key);
2057
2387
  setParams(next);
2058
- setQuantity(1);
2059
2388
  setImageIndex(0);
2060
2389
  };
2061
2390
  const { register, has } = usePartRegistry();
@@ -2070,6 +2399,7 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2070
2399
  ...partProps("product-details-root", className),
2071
2400
  children: hasChildren(loading) ? loading : /* @__PURE__ */ jsx("p", {
2072
2401
  role: "status",
2402
+ className: "text-sm text-commerce-muted",
2073
2403
  children: "Loading product…"
2074
2404
  })
2075
2405
  });
@@ -2083,11 +2413,11 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2083
2413
  retry
2084
2414
  }, /* @__PURE__ */ jsxs(Fragment$1, { children: [
2085
2415
  /* @__PURE__ */ jsx("h1", {
2086
- className: "text-2xl font-semibold",
2416
+ className: "text-2xl font-semibold tracking-tight",
2087
2417
  children: "Product unavailable"
2088
2418
  }),
2089
2419
  /* @__PURE__ */ jsx("p", {
2090
- className: "my-4 text-red-700",
2420
+ className: `my-4 ${errorNoticeClass}`,
2091
2421
  children: message(product.error)
2092
2422
  }),
2093
2423
  /* @__PURE__ */ jsx("button", {
@@ -2102,10 +2432,10 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2102
2432
  if (productNotFound || !group) return /* @__PURE__ */ jsx("div", {
2103
2433
  ...partProps("product-details-root", className),
2104
2434
  children: hasChildren(notFound) ? notFound : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("h1", {
2105
- className: "text-2xl font-semibold",
2435
+ className: "text-2xl font-semibold tracking-tight",
2106
2436
  children: "Product not found"
2107
2437
  }), /* @__PURE__ */ jsx(Link, {
2108
- className: `${buttonClass} mt-4`,
2438
+ className: `${buttonClass} mt-6`,
2109
2439
  to: config.catalogPath,
2110
2440
  children: "Back to shop"
2111
2441
  })] })
@@ -2134,16 +2464,50 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2134
2464
  retryVariant: () => {
2135
2465
  if (matched.isError) matched.refetch();
2136
2466
  },
2137
- register
2467
+ register,
2468
+ soldOutValues: getSoldOutValues(group, attributes, selections),
2469
+ showMissing,
2470
+ requestMissing: () => {
2471
+ setShowMissing(true);
2472
+ setFocusMissing((count) => count + 1);
2473
+ },
2474
+ getSwatch
2138
2475
  };
2139
2476
  return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
2140
2477
  value,
2141
2478
  children: /* @__PURE__ */ jsx("article", {
2479
+ ref: root,
2142
2480
  ...partProps("product-details-root", className),
2143
2481
  children
2144
2482
  })
2145
2483
  });
2146
2484
  }
2485
+ function focusFirstMissing(root) {
2486
+ root.current?.querySelector("[data-commerce-missing] input:not(:disabled), [data-commerce-missing] select")?.focus();
2487
+ }
2488
+ /**
2489
+ * For each attribute, the values whose every SKU, among those with the other selected values, has tracked stock
2490
+ * used up. Needs every SKU with its attribute values, so it is empty for a truncated list or an older server.
2491
+ */
2492
+ function getSoldOutValues(group, attributes, selections) {
2493
+ const connection = group?.skus;
2494
+ const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
2495
+ const values = skus.map(getSkuAttributeValueNames);
2496
+ const complete = !connection?.pageInfo?.hasNextPage && (connection?.totalCount ?? skus.length) === skus.length && values.every((names) => names !== null);
2497
+ return attributes.map((attribute, index) => {
2498
+ const soldOut = /* @__PURE__ */ new Set();
2499
+ if (!complete) return soldOut;
2500
+ const others = selections.filter((selection, other) => other !== index && selection);
2501
+ for (const value of attribute.values) {
2502
+ const matching = skus.filter((_, sku) => {
2503
+ const names = values[sku] ?? [];
2504
+ return names.includes(value.name) && others.every((selection) => names.includes(selection));
2505
+ });
2506
+ if (matching.length && matching.every((sku) => isSoldOut(getAvailableInventoryQuantity(sku)))) soldOut.add(value.name);
2507
+ }
2508
+ return soldOut;
2509
+ });
2510
+ }
2147
2511
 
2148
2512
  //#endregion
2149
2513
  //#region src/product-purchase.tsx
@@ -2154,7 +2518,9 @@ const disabledReasons = {
2154
2518
  unavailable: "This combination is unavailable. Choose different options.",
2155
2519
  "not-purchasable": "This product is not currently available to purchase."
2156
2520
  };
2157
- /** The packaged purchase area: options, status, availability, quantity, and add to cart. */
2521
+ /** Above this many values, buttons wrap into a wall, so the option is a select. */
2522
+ const maxChoiceButtons = 8;
2523
+ /** The packaged purchase area: options, status, quantity, add to cart, and availability. */
2158
2524
  function ProductDetailsPurchase({ className }) {
2159
2525
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsPurchase");
2160
2526
  const belowOptions = attributes.length || skuOptions.length ? "-mt-4" : void 0;
@@ -2163,13 +2529,15 @@ function ProductDetailsPurchase({ className }) {
2163
2529
  children: [
2164
2530
  /* @__PURE__ */ jsx(OptionControls, {}),
2165
2531
  /* @__PURE__ */ jsx(ProductDetailsPurchaseStatus, { className: belowOptions }),
2166
- /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: belowOptions }),
2167
- /* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
2168
- /* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
2532
+ /* @__PURE__ */ jsxs("div", {
2533
+ className: "flex items-start gap-3",
2534
+ children: [/* @__PURE__ */ jsx(ProductDetailsQuantity, {}), /* @__PURE__ */ jsx(ProductDetailsAddToCart, { className: "min-w-0 flex-1" })]
2535
+ }),
2536
+ /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: "-mt-3" })
2169
2537
  ]
2170
2538
  });
2171
2539
  }
2172
- /** A select for every product attribute, or the SKU list for products without attributes. */
2540
+ /** A control for every product attribute, or the SKU list for products without attributes. */
2173
2541
  function ProductDetailsOptions({ className }) {
2174
2542
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
2175
2543
  if (attributes.length === 0 && skuOptions.length === 0) return null;
@@ -2204,67 +2572,155 @@ function Select({ children,...props }) {
2204
2572
  })]
2205
2573
  });
2206
2574
  }
2575
+ const choiceButtonClass = "inline-flex min-h-11 min-w-12 items-center justify-center rounded-lg border border-commerce-edge px-4 text-sm font-medium transition-colors group-hover:border-current peer-checked:border-commerce-accent peer-checked:bg-commerce-accent peer-checked:text-commerce-on-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-commerce-accent";
2576
+ const swatchClass = "relative block h-9 w-9 overflow-hidden rounded-full border border-commerce-edge peer-checked:outline peer-checked:outline-2 peer-checked:outline-offset-2 peer-checked:outline-commerce-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-current";
2577
+ /**
2578
+ * One option as buttons (radios), swatches when every value has one, or a select for long lists. Sold-out values are
2579
+ * crossed out but stay selectable, so the shopper can change any option in any order.
2580
+ */
2581
+ function ChoiceGroup({ label, selected, choices, missing, onSelect }) {
2582
+ const name = useId();
2583
+ const errorId = useId();
2584
+ const error = missing && /* @__PURE__ */ jsxs("p", {
2585
+ id: errorId,
2586
+ className: "mt-2 inline-block rounded bg-red-50 px-2 py-1 text-sm text-red-800",
2587
+ children: [
2588
+ "Select ",
2589
+ inSentence(label),
2590
+ "."
2591
+ ]
2592
+ });
2593
+ if (choices.length > maxChoiceButtons) return /* @__PURE__ */ jsxs("fieldset", { children: [
2594
+ /* @__PURE__ */ jsx("legend", {
2595
+ className: "mb-2 text-sm font-medium",
2596
+ children: label
2597
+ }),
2598
+ /* @__PURE__ */ jsxs(Select, {
2599
+ "aria-label": label,
2600
+ "aria-invalid": missing || void 0,
2601
+ "aria-describedby": missing ? errorId : void 0,
2602
+ value: selected,
2603
+ onChange: (event) => onSelect(event.target.value),
2604
+ children: [/* @__PURE__ */ jsxs("option", {
2605
+ value: "",
2606
+ children: ["Select ", inSentence(label)]
2607
+ }), choices.map((choice) => /* @__PURE__ */ jsx("option", {
2608
+ value: choice.value,
2609
+ children: choice.soldOut ? `${choice.label} (sold out)` : choice.label
2610
+ }, choice.value))]
2611
+ }),
2612
+ error
2613
+ ] });
2614
+ const swatches = choices.every((choice) => choice.swatch);
2615
+ const selectedLabel = choices.find((choice) => choice.value === selected)?.label;
2616
+ return /* @__PURE__ */ jsxs("fieldset", {
2617
+ "aria-describedby": missing ? errorId : void 0,
2618
+ children: [
2619
+ /* @__PURE__ */ jsxs("legend", {
2620
+ className: "mb-2 flex gap-2 text-sm font-medium",
2621
+ children: [label, selectedLabel && /* @__PURE__ */ jsx("span", {
2622
+ className: "font-normal text-commerce-muted",
2623
+ children: selectedLabel
2624
+ })]
2625
+ }),
2626
+ /* @__PURE__ */ jsx("div", {
2627
+ className: `flex flex-wrap ${swatches ? "gap-3" : "gap-2"}`,
2628
+ children: choices.map((choice) => /* @__PURE__ */ jsxs("label", {
2629
+ className: "group relative cursor-pointer",
2630
+ title: swatches ? `${choice.label}${choice.soldOut ? ", sold out" : ""}` : void 0,
2631
+ children: [/* @__PURE__ */ jsx("input", {
2632
+ type: "radio",
2633
+ className: "peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0",
2634
+ name,
2635
+ value: choice.value,
2636
+ checked: choice.value === selected,
2637
+ "aria-invalid": missing || void 0,
2638
+ "aria-label": choice.soldOut ? `${choice.label}, sold out` : void 0,
2639
+ "data-commerce-control": "choice",
2640
+ onChange: () => onSelect(choice.value)
2641
+ }), swatches ? /* @__PURE__ */ jsxs("span", {
2642
+ className: `${swatchClass} ${choice.soldOut ? "opacity-50" : ""}`,
2643
+ style: { background: choice.swatch ?? void 0 },
2644
+ children: [choice.soldOut && /* @__PURE__ */ jsx("span", {
2645
+ "aria-hidden": "true",
2646
+ className: "absolute left-1/2 top-0 h-full w-0.5 -translate-x-1/2 rotate-45 bg-current"
2647
+ }), /* @__PURE__ */ jsx("span", {
2648
+ className: "sr-only",
2649
+ children: choice.label
2650
+ })]
2651
+ }) : /* @__PURE__ */ jsx("span", {
2652
+ className: `${choiceButtonClass} ${choice.soldOut ? "text-commerce-muted line-through decoration-1" : ""}`,
2653
+ children: choice.label
2654
+ })]
2655
+ }, choice.value))
2656
+ }),
2657
+ error
2658
+ ]
2659
+ });
2660
+ }
2207
2661
  function SkuOption() {
2208
- const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
2209
- const id = useId();
2662
+ const { skuOptions, explicitSkuId, selectSku, showMissing } = useProductDetailsInternal("ProductDetailsOptions");
2210
2663
  useSkuOptionRegistration();
2211
- return /* @__PURE__ */ jsxs("div", {
2664
+ const missing = showMissing && !explicitSkuId;
2665
+ return /* @__PURE__ */ jsx("div", {
2212
2666
  ...partProps("product-details-sku-option"),
2213
- children: [/* @__PURE__ */ jsx("label", {
2214
- htmlFor: id,
2215
- className: "mb-2 block font-medium",
2216
- children: "Option"
2217
- }), /* @__PURE__ */ jsxs(Select, {
2218
- id,
2219
- value: explicitSkuId,
2220
- onChange: (event) => selectSku(event.target.value),
2221
- children: [/* @__PURE__ */ jsx("option", {
2222
- value: "",
2223
- children: "Select an option"
2224
- }), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
2667
+ "data-commerce-missing": missing || void 0,
2668
+ children: /* @__PURE__ */ jsx(ChoiceGroup, {
2669
+ label: "Option",
2670
+ selected: explicitSkuId,
2671
+ missing,
2672
+ onSelect: selectSku,
2673
+ choices: skuOptions.map((sku) => ({
2225
2674
  value: sku.id ?? "",
2226
- children: sku.label?.trim() || sku.name?.trim()
2227
- }, sku.id))]
2228
- })]
2675
+ label: sku.label?.trim() || sku.name?.trim() || "",
2676
+ soldOut: isSoldOut(getAvailableInventoryQuantity(sku)),
2677
+ swatch: null
2678
+ }))
2679
+ })
2229
2680
  });
2230
2681
  }
2231
- /** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
2682
+ /**
2683
+ * One product attribute: buttons, swatches when the provider's `getSwatch` gives every value one, or a select for
2684
+ * more than eight values. Use `useProductDetailsOption` for other custom controls.
2685
+ */
2232
2686
  function ProductDetailsOption({ attribute, className }) {
2233
2687
  const option = useProductDetailsOption(attribute);
2234
2688
  if (!option) return null;
2235
2689
  return /* @__PURE__ */ jsx("div", {
2236
2690
  ...partProps("product-details-option", className),
2237
2691
  "data-commerce-attribute": option.name,
2238
- children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
2239
- className: "mb-2 font-medium",
2240
- children: option.label
2241
- }), /* @__PURE__ */ jsxs(Select, {
2242
- "aria-label": option.label,
2243
- value: option.selected ?? "",
2244
- onChange: (event) => option.select(event.target.value || null),
2245
- children: [/* @__PURE__ */ jsxs("option", {
2246
- value: "",
2247
- children: ["Select ", option.label.toLowerCase()]
2248
- }), option.values.map((value) => /* @__PURE__ */ jsx("option", {
2692
+ "data-commerce-missing": option.missing || void 0,
2693
+ children: /* @__PURE__ */ jsx(ChoiceGroup, {
2694
+ label: option.label,
2695
+ selected: option.selected ?? "",
2696
+ missing: option.missing,
2697
+ onSelect: (value) => option.select(value || null),
2698
+ choices: option.values.map((value) => ({
2249
2699
  value: value.name,
2250
- children: value.label
2251
- }, value.name))]
2252
- })] })
2700
+ label: value.label,
2701
+ soldOut: value.soldOut,
2702
+ swatch: value.swatch
2703
+ }))
2704
+ })
2253
2705
  });
2254
2706
  }
2255
- /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
2707
+ /**
2708
+ * The single message explaining why the selection is not purchasable. Renders nothing when ready, or before every
2709
+ * option is chosen, when the add button names the next one.
2710
+ */
2256
2711
  function ProductDetailsPurchaseStatus({ className }) {
2257
2712
  const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
2258
2713
  let content;
2259
2714
  switch (status) {
2260
- case "ready": return null;
2715
+ case "ready":
2716
+ case "incomplete": return null;
2261
2717
  case "variant-error":
2262
2718
  content = /* @__PURE__ */ jsxs("div", {
2263
2719
  role: "alert",
2264
- className: "text-red-700",
2720
+ className: errorNoticeClass,
2265
2721
  children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
2266
2722
  type: "button",
2267
- className: "min-h-11 bg-white text-red-700 underline",
2723
+ className: errorActionClass,
2268
2724
  onClick: retryVariant,
2269
2725
  children: "Retry availability"
2270
2726
  })]
@@ -2273,21 +2729,20 @@ function ProductDetailsPurchaseStatus({ className }) {
2273
2729
  case "unavailable":
2274
2730
  content = /* @__PURE__ */ jsx("p", {
2275
2731
  role: "status",
2276
- className: "text-sm text-red-700",
2732
+ className: errorNoticeClass,
2277
2733
  children: disabledReasons[status]
2278
2734
  });
2279
2735
  break;
2280
2736
  case "not-purchasable":
2281
2737
  content = /* @__PURE__ */ jsx("p", {
2282
- className: "text-sm",
2738
+ className: "text-sm text-commerce-muted",
2283
2739
  children: disabledReasons[status]
2284
2740
  });
2285
2741
  break;
2286
- case "incomplete":
2287
2742
  case "checking":
2288
2743
  content = /* @__PURE__ */ jsx("p", {
2289
2744
  role: "status",
2290
- className: "text-sm",
2745
+ className: "text-sm text-commerce-muted",
2291
2746
  children: disabledReasons[status]
2292
2747
  });
2293
2748
  break;
@@ -2304,59 +2759,137 @@ function ProductDetailsAvailability({ className }) {
2304
2759
  return /* @__PURE__ */ jsx("div", {
2305
2760
  ...partProps("product-details-availability", className),
2306
2761
  children: /* @__PURE__ */ jsx("p", {
2307
- className: "text-sm",
2762
+ className: "text-sm text-commerce-muted",
2308
2763
  "aria-live": "polite",
2309
2764
  children: availableQuantity === null ? "Available to order" : isSoldOut(availableQuantity) ? "This variant is out of stock" : `${availableQuantity} available`
2310
2765
  })
2311
2766
  });
2312
2767
  }
2313
- /** Quantity for the verified SKU, with a message when it cannot be added. Renders nothing until a SKU is verified. */
2768
+ const stepperButtonClass = "inline-flex min-h-11 min-w-10 items-center justify-center rounded-lg transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent";
2769
+ function StepperIcon({ plus }) {
2770
+ return /* @__PURE__ */ jsx("svg", {
2771
+ "aria-hidden": "true",
2772
+ viewBox: "0 0 16 16",
2773
+ className: "h-4 w-4",
2774
+ children: /* @__PURE__ */ jsx("path", {
2775
+ d: plus ? "M3.5 8h9M8 3.5v9" : "M3.5 8h9",
2776
+ fill: "none",
2777
+ stroke: "currentColor",
2778
+ strokeWidth: "1.5",
2779
+ strokeLinecap: "round"
2780
+ })
2781
+ });
2782
+ }
2783
+ /**
2784
+ * A quantity stepper, capped at the verified SKU's stock, with a message when a typed quantity cannot be added.
2785
+ * Disabled while the verified SKU is sold out.
2786
+ */
2314
2787
  function ProductDetailsQuantity({ className }) {
2315
- const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
2788
+ const { selectedSku, availableQuantity, quantity, setQuantity, name } = useProductDetailsInternal("ProductDetailsQuantity");
2316
2789
  const id = useId();
2317
2790
  const problemId = useId();
2318
- if (!selectedSku) return null;
2319
- const problem = isSoldOut(availableQuantity) ? null : quantityProblem(quantity, availableQuantity);
2791
+ const soldOut = !!selectedSku && isSoldOut(availableQuantity);
2792
+ const max = selectedSku && availableQuantity !== null && availableQuantity > 0 ? availableQuantity : null;
2793
+ const problem = soldOut ? null : quantityProblem(quantity, max);
2794
+ const current = Number.isInteger(quantity) ? quantity : 1;
2320
2795
  return /* @__PURE__ */ jsxs("div", {
2321
2796
  ...partProps("product-details-quantity", className),
2322
2797
  children: [
2323
2798
  /* @__PURE__ */ jsx("label", {
2324
2799
  htmlFor: id,
2325
- className: "mb-2 block font-medium",
2800
+ className: "sr-only",
2326
2801
  children: "Quantity"
2327
2802
  }),
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)
2803
+ /* @__PURE__ */ jsxs("div", {
2804
+ className: "inline-flex items-center rounded-lg border border-commerce-edge",
2805
+ children: [
2806
+ /* @__PURE__ */ jsx("button", {
2807
+ type: "button",
2808
+ className: stepperButtonClass,
2809
+ "aria-label": `Decrease quantity of ${name}`,
2810
+ "data-commerce-control": "decrease",
2811
+ disabled: soldOut || current <= 1,
2812
+ onClick: () => setQuantity(current - 1),
2813
+ children: /* @__PURE__ */ jsx(StepperIcon, {})
2814
+ }),
2815
+ /* @__PURE__ */ jsx("input", {
2816
+ id,
2817
+ type: "number",
2818
+ min: 1,
2819
+ max: max ?? void 0,
2820
+ step: 1,
2821
+ disabled: soldOut,
2822
+ className: "min-h-11 w-10 appearance-none bg-transparent text-center text-sm tabular-nums [-moz-appearance:textfield] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:text-commerce-muted [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
2823
+ value: Number.isNaN(quantity) ? "" : quantity,
2824
+ "aria-invalid": problem ? true : void 0,
2825
+ "aria-describedby": problem ? problemId : void 0,
2826
+ onChange: (event) => setQuantity(event.target.valueAsNumber)
2827
+ }),
2828
+ /* @__PURE__ */ jsx("button", {
2829
+ type: "button",
2830
+ className: stepperButtonClass,
2831
+ "aria-label": `Increase quantity of ${name}`,
2832
+ "data-commerce-control": "increase",
2833
+ disabled: soldOut || max !== null && current >= max,
2834
+ onClick: () => setQuantity(current + 1),
2835
+ children: /* @__PURE__ */ jsx(StepperIcon, { plus: true })
2836
+ })
2837
+ ]
2339
2838
  }),
2340
2839
  problem && /* @__PURE__ */ jsx("p", {
2341
2840
  id: problemId,
2342
- className: "mt-2 text-sm text-red-700",
2841
+ className: `mt-2 ${errorNoticeClass}`,
2343
2842
  children: problem
2344
2843
  })
2345
2844
  ]
2346
2845
  });
2347
2846
  }
2348
- /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
2847
+ /**
2848
+ * Adds the verified SKU and quantity, labeled with the total, such as "Add to cart · $64.00". Until every option is
2849
+ * chosen it names the next one, such as "Select size", and pressing it marks the missing options and focuses the
2850
+ * first. While the selection is checked or unavailable it is disabled, and its accessible description says why.
2851
+ */
2349
2852
  function ProductDetailsAddToCart({ className, children }) {
2350
- const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
2853
+ const { selectedSku, name, quantity, status, price, attributes, selections, requestMissing } = useProductDetailsInternal("ProductDetailsAddToCart");
2854
+ const { config } = useCommerce();
2855
+ const money$1 = useMoney();
2351
2856
  const reasonId = useId();
2352
- return /* @__PURE__ */ jsx("div", {
2857
+ if (selectedSku) {
2858
+ const unit = price?.value?.value;
2859
+ const total = unit != null ? money$1(unit * (Number.isInteger(quantity) && quantity > 0 ? quantity : 1), price?.value?.currencyCode ?? config.currencyCode) : null;
2860
+ return /* @__PURE__ */ jsx("div", {
2861
+ ...partProps("product-details-add-to-cart", className),
2862
+ children: /* @__PURE__ */ jsx(AddToCart, {
2863
+ sku: selectedSku,
2864
+ name,
2865
+ quantity,
2866
+ className: buttonClass,
2867
+ block: true,
2868
+ label: total && `Add to cart · ${total}`,
2869
+ labelName: total ? `Add to cart, ${total}` : void 0,
2870
+ children
2871
+ })
2872
+ });
2873
+ }
2874
+ if (status === "incomplete") {
2875
+ const next = attributes.find((_, index) => !selections[index])?.label;
2876
+ return /* @__PURE__ */ jsx("div", {
2877
+ ...partProps("product-details-add-to-cart", className),
2878
+ children: /* @__PURE__ */ jsx("button", {
2879
+ type: "button",
2880
+ className: `${buttonClass} w-full`,
2881
+ "data-commerce-part": "add-to-cart-button",
2882
+ onClick: requestMissing,
2883
+ children: renderLabel(children, {
2884
+ busy: false,
2885
+ soldOut: false
2886
+ }, next ? `Select ${inSentence(next)}` : "Select an option")
2887
+ })
2888
+ });
2889
+ }
2890
+ return /* @__PURE__ */ jsxs("div", {
2353
2891
  ...partProps("product-details-add-to-cart", className),
2354
- children: selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
2355
- sku: selectedSku,
2356
- name,
2357
- quantity,
2358
- children
2359
- }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
2892
+ children: [/* @__PURE__ */ jsx("button", {
2360
2893
  type: "button",
2361
2894
  className: `${buttonClass} w-full`,
2362
2895
  disabled: true,
@@ -2370,7 +2903,7 @@ function ProductDetailsAddToCart({ className, children }) {
2370
2903
  id: reasonId,
2371
2904
  hidden: true,
2372
2905
  children: status === "ready" ? "" : disabledReasons[status]
2373
- })] })
2906
+ })]
2374
2907
  });
2375
2908
  }
2376
2909
 
@@ -2378,15 +2911,18 @@ function ProductDetailsAddToCart({ className, children }) {
2378
2911
  //#region src/product-details.tsx
2379
2912
  /** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
2380
2913
  function ProductDetails() {
2381
- return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-8 sm:mb-10" }), /* @__PURE__ */ jsxs("div", {
2382
- className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
2383
- children: [/* @__PURE__ */ jsx(ProductDetailsGallery, { className: "min-w-0 lg:col-span-7" }), /* @__PURE__ */ jsxs("div", {
2384
- className: "min-w-0 lg:col-span-5 lg:py-4",
2914
+ return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-4 sm:mb-6" }), /* @__PURE__ */ jsxs("div", {
2915
+ className: "grid items-start gap-8 md:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] lg:gap-12",
2916
+ children: [/* @__PURE__ */ jsxs("div", {
2917
+ className: "relative min-w-0",
2918
+ children: [/* @__PURE__ */ jsx(ProductDetailsGallery, {}), /* @__PURE__ */ jsx(ProductDetailsBadge, { className: "pointer-events-none absolute left-4 top-4" })]
2919
+ }), /* @__PURE__ */ jsxs("div", {
2920
+ className: "min-w-0 md:pt-2",
2385
2921
  children: [
2386
2922
  /* @__PURE__ */ jsx(ProductDetailsTitle, {}),
2387
- /* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-6" }),
2388
- /* @__PURE__ */ jsx(ProductDetailsDescription, { className: "my-8" }),
2389
- /* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "border-t border-neutral-200 pt-8" })
2923
+ /* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-2" }),
2924
+ /* @__PURE__ */ jsx(ProductDetailsDescription, { className: "mt-6" }),
2925
+ /* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "mt-6 border-t border-commerce-line pt-6" })
2390
2926
  ]
2391
2927
  })]
2392
2928
  })] });
@@ -2398,9 +2934,21 @@ function ProductDetailsBackLink({ className, children }) {
2398
2934
  return /* @__PURE__ */ jsx("div", {
2399
2935
  ...partProps("product-details-back-link", className),
2400
2936
  children: /* @__PURE__ */ jsx(Link, {
2401
- className: "inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2",
2937
+ className: "inline-flex min-h-11 items-center gap-1 text-sm text-commerce-muted underline-offset-4 transition-colors hover:text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-current",
2402
2938
  to: config.catalogPath,
2403
- children: renderLabel(children, void 0, "← Back to shop")
2939
+ children: renderLabel(children, void 0, /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("svg", {
2940
+ "aria-hidden": "true",
2941
+ viewBox: "0 0 16 16",
2942
+ className: "h-3.5 w-3.5",
2943
+ children: /* @__PURE__ */ jsx("path", {
2944
+ d: "M10 4L6 8l4 4",
2945
+ fill: "none",
2946
+ stroke: "currentColor",
2947
+ strokeWidth: "1.5",
2948
+ strokeLinecap: "round",
2949
+ strokeLinejoin: "round"
2950
+ })
2951
+ }), "Back to shop"] }))
2404
2952
  })
2405
2953
  });
2406
2954
  }
@@ -2410,16 +2958,16 @@ function ProductDetailsGallery({ className }) {
2410
2958
  return /* @__PURE__ */ jsxs("div", {
2411
2959
  ...partProps("product-details-gallery", className),
2412
2960
  children: [/* @__PURE__ */ jsx("div", {
2413
- className: "aspect-square overflow-hidden rounded-lg",
2961
+ className: "aspect-square overflow-hidden bg-commerce-tint",
2414
2962
  children: /* @__PURE__ */ jsx(ProductImage, {
2415
2963
  url: images[imageIndex],
2416
2964
  name
2417
2965
  }, images[imageIndex])
2418
2966
  }), images.length > 1 && /* @__PURE__ */ jsx("div", {
2419
- className: "mt-4 flex gap-3 overflow-x-auto",
2967
+ className: "mt-3 flex gap-3 overflow-x-auto p-1",
2420
2968
  children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
2421
2969
  type: "button",
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"}`,
2970
+ className: `h-20 w-20 shrink-0 overflow-hidden bg-commerce-tint outline-offset-2 transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-current ${imageIndex === index ? "outline outline-2 outline-current" : "opacity-60 hover:opacity-100"}`,
2423
2971
  "aria-label": `View image ${index + 1}`,
2424
2972
  "aria-pressed": imageIndex === index,
2425
2973
  "data-commerce-control": "thumbnail",
@@ -2432,32 +2980,59 @@ function ProductDetailsGallery({ className }) {
2432
2980
  })]
2433
2981
  });
2434
2982
  }
2983
+ /**
2984
+ * "Sale" when the verified SKU's compare-at price is above its price, or, before then, when some SKU's is. Renders
2985
+ * nothing otherwise.
2986
+ */
2987
+ function ProductDetailsBadge({ className }) {
2988
+ const { price, product } = useProductDetailsInternal("ProductDetailsBadge");
2989
+ if (!(price ? isOnSale(price.value?.value, price.compareAtValue?.value) : isProductOnSale(product))) return null;
2990
+ return /* @__PURE__ */ jsx("div", {
2991
+ ...partProps("product-details-badge", className),
2992
+ children: /* @__PURE__ */ jsx("span", {
2993
+ className: saleBadgeClass,
2994
+ children: "Sale"
2995
+ })
2996
+ });
2997
+ }
2435
2998
  /** The product name as the page's H1. */
2436
2999
  function ProductDetailsTitle({ className }) {
2437
3000
  const { name } = useProductDetailsInternal("ProductDetailsTitle");
2438
3001
  return /* @__PURE__ */ jsx("div", {
2439
3002
  ...partProps("product-details-title", className),
2440
3003
  children: /* @__PURE__ */ jsx("h1", {
2441
- className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
3004
+ className: "text-balance break-words text-3xl font-semibold leading-tight tracking-tight sm:text-4xl",
2442
3005
  children: name
2443
3006
  })
2444
3007
  });
2445
3008
  }
2446
- /** The verified SKU's price and compare-at price, or the product's starting price. */
3009
+ /**
3010
+ * The verified SKU's price and compare-at price, or the product's starting price. Before a SKU is verified, the
3011
+ * compare-at price shows only when it belongs to the cheapest SKUs, so it never pairs prices from different SKUs.
3012
+ */
2447
3013
  function ProductDetailsPrice({ className }) {
2448
3014
  const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
2449
3015
  const { config } = useCommerce();
2450
3016
  const money$1 = useMoney();
3017
+ let compareAt = null;
3018
+ if (price?.compareAtValue?.value != null && isOnSale(price.value?.value, price.compareAtValue.value)) compareAt = money$1(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode);
3019
+ else if (!price) {
3020
+ const starting = getStartingCompareAt(product);
3021
+ if (starting !== null) compareAt = money$1(starting);
3022
+ }
2451
3023
  return /* @__PURE__ */ jsx("div", {
2452
3024
  ...partProps("product-details-price", className),
2453
3025
  children: /* @__PURE__ */ jsxs("div", {
2454
3026
  "aria-live": "polite",
2455
- className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
3027
+ className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xl font-medium tabular-nums",
2456
3028
  "data-testid": "product-price",
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", {
2458
- className: "text-lg font-normal",
2459
- children: money$1(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
2460
- })]
3029
+ children: [price?.value?.value != null ? money$1(price.value.value, price.value.currencyCode ?? config.currencyCode) : product.priceRange?.min != null ? `From ${money$1(product.priceRange.min)}` : "Price unavailable", compareAt && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
3030
+ className: "sr-only",
3031
+ children: "Was"
3032
+ }), /* @__PURE__ */ jsx("del", {
3033
+ className: "text-base font-normal text-commerce-muted",
3034
+ children: compareAt
3035
+ })] })]
2461
3036
  })
2462
3037
  });
2463
3038
  }
@@ -2467,11 +3042,72 @@ function ProductDetailsDescription({ className }) {
2467
3042
  return /* @__PURE__ */ jsx("div", {
2468
3043
  ...partProps("product-details-description", className),
2469
3044
  children: /* @__PURE__ */ jsx("p", {
2470
- className: "whitespace-pre-line text-base leading-7",
3045
+ className: "whitespace-pre-line text-base leading-7 text-commerce-muted",
2471
3046
  children: selectedSku?.description ?? product.description
2472
3047
  })
2473
3048
  });
2474
3049
  }
2475
3050
 
2476
3051
  //#endregion
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 };
3052
+ //#region src/related-products.tsx
3053
+ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
3054
+ className: packagedCardClass,
3055
+ children: /* @__PURE__ */ jsx(PackagedCardParts, {})
3056
+ });
3057
+ /**
3058
+ * Other products from the catalog's first page, leaving out the current one. Optional and separate from
3059
+ * `ProductDetails`: place it wherever it fits, such as after the product page. There is no related-products API, so
3060
+ * the products are the catalog's first ones. Renders nothing until they load, when none are left, if the request
3061
+ * fails, or while no store is connected.
3062
+ */
3063
+ function RelatedProducts({ productId: productIdProp, title = "You may also like", limit = 4, className, children }) {
3064
+ const { productId: routeProductId = "" } = useParams();
3065
+ const productId = productIdProp ?? routeProductId;
3066
+ const { config, connection } = useCommerce();
3067
+ const headingId = useId();
3068
+ const first = limit + 1;
3069
+ const products = useQuery({
3070
+ retry: false,
3071
+ enabled: connection === "ready",
3072
+ queryKey: [
3073
+ "commerce",
3074
+ config.cartScope,
3075
+ "products",
3076
+ first,
3077
+ ""
3078
+ ],
3079
+ queryFn: ({ signal }) => request(`/products?first=${first}`, {
3080
+ signal,
3081
+ headers: { "X-Commerce-Scope": config.cartScope }
3082
+ })
3083
+ });
3084
+ const related = useMemo(() => (products.data?.skuGroups?.edges ?? []).flatMap((edge) => edge?.node?.id && edge.node.id !== productId ? [edge.node] : []).slice(0, limit), [
3085
+ products.data,
3086
+ productId,
3087
+ limit
3088
+ ]);
3089
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 24) throw new Error(`RelatedProducts limit must be a whole number from 1 to 24, not ${limit}.`);
3090
+ if (connection !== "ready" || !related.length) return null;
3091
+ const card = hasChildren(children) ? children : packagedCard;
3092
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: /* @__PURE__ */ jsxs("section", {
3093
+ "aria-labelledby": headingId,
3094
+ ...partProps("related-products", className),
3095
+ children: [/* @__PURE__ */ jsx("h2", {
3096
+ id: headingId,
3097
+ className: "mb-6 text-xl font-semibold tracking-tight",
3098
+ children: title
3099
+ }), /* @__PURE__ */ jsx("div", {
3100
+ className: catalogGridClass,
3101
+ children: related.map((product) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
3102
+ value: {
3103
+ source: "live",
3104
+ product
3105
+ },
3106
+ children: card
3107
+ }, product.id))
3108
+ })]
3109
+ }) });
3110
+ }
3111
+
3112
+ //#endregion
3113
+ export { AddToCartButton, ApiError, CartButton, CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDrawer, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartRoot, CartSubtotal, CartSummary, CartTitle, Catalog, CatalogEmpty, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardBadge, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardSwatches, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsBadge, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, ProductImage, RelatedProducts, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCart, useCartItem, useCommerce, useMoney, useProductCard, useProductDetails, useProductDetailsOption };