@godaddy/gd-commerce-storefront 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -248,7 +248,7 @@ async function loadConfig(signal) {
248
248
  currencyCode: config.currencyCode
249
249
  };
250
250
  }
251
- function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, locale, onItemAdded, onEvent }) {
251
+ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, getSwatch, locale, onItemAdded, onEvent }) {
252
252
  const resolvedLocale = useMemo(() => resolveLocale(locale), [locale]);
253
253
  const config = useQuery({
254
254
  retry: false,
@@ -278,13 +278,14 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
278
278
  config.refetch();
279
279
  },
280
280
  theme,
281
+ getSwatch,
281
282
  locale: resolvedLocale,
282
283
  onItemAdded,
283
284
  onEvent,
284
285
  children
285
286
  });
286
287
  }
287
- function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, locale, onItemAdded, onEvent }) {
288
+ function BoundCommerceProvider({ config, children, connection, connectionError, retryConnection, theme, getSwatch, locale, onItemAdded, onEvent }) {
288
289
  const storageKey = `godaddy:commerce-storefront:cart:${config.cartScope}`;
289
290
  const cartIdStorage = useMemo(() => new CartIdStorage(storageKey), [storageKey]);
290
291
  const ready = connection === "ready";
@@ -571,6 +572,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
571
572
  connectionError,
572
573
  retryConnection,
573
574
  theme,
575
+ getSwatch,
574
576
  locale,
575
577
  cart,
576
578
  open,
@@ -732,6 +734,75 @@ function getProductAttributes(skuGroup) {
732
734
  function getProductName(skuGroup) {
733
735
  return skuGroup.label ?? skuGroup.name ?? "Product";
734
736
  }
737
+ /** Lowercases a label for use mid-sentence, leaving acronyms alone: "Size" becomes "size", "US Size" stays. */
738
+ const inSentence = (label) => label.replace(/^[A-Z](?=[a-z])/, (first) => first.toLowerCase());
739
+ /** Whether `compareAt` is above `price`, so the price is a sale price. */
740
+ function isOnSale(price, compareAt) {
741
+ return typeof price === "number" && typeof compareAt === "number" && compareAt > price;
742
+ }
743
+ /**
744
+ * Each SKU's price paired with its own compare-at price, or `null` unless the response has every SKU and each has a
745
+ * price. The catalog list returns too few SKUs and no SKU prices, so its cards fall back to the product's ranges.
746
+ */
747
+ function getPairedPrices(product) {
748
+ const connection = product?.skus;
749
+ const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
750
+ if (!skus.length || connection?.pageInfo?.hasNextPage || (connection?.totalCount ?? skus.length) !== skus.length) return null;
751
+ const pairs = skus.flatMap((sku) => {
752
+ const price = sku.prices?.edges?.find((entry) => entry?.node?.value)?.node;
753
+ return typeof price?.value?.value === "number" ? [{
754
+ value: price.value.value,
755
+ compareAt: price.compareAtValue?.value ?? null
756
+ }] : [];
757
+ });
758
+ return pairs.length === skus.length ? pairs : null;
759
+ }
760
+ /**
761
+ * Whether some SKU's compare-at price is above that SKU's own price. Without every SKU's price, only a product whose
762
+ * lowest compare-at price is above its highest price is certainly on sale, so other products show no badge.
763
+ */
764
+ function isProductOnSale(product) {
765
+ const pairs = getPairedPrices(product);
766
+ if (pairs) return pairs.some((pair) => isOnSale(pair.value, pair.compareAt));
767
+ return isOnSale(product?.priceRange?.max, product?.compareAtPriceRange?.min);
768
+ }
769
+ /**
770
+ * The compare-at price that belongs with the product's starting price, so a struck-through price never pairs one
771
+ * SKU's price with another SKU's compare-at price. With every SKU and its price loaded, the cheapest SKUs must share
772
+ * one compare-at price above their price. Otherwise the product needs a single price and a single compare-at price.
773
+ */
774
+ function getStartingCompareAt(product) {
775
+ const price = product?.priceRange;
776
+ const compareAt = product?.compareAtPriceRange;
777
+ const pairs = getPairedPrices(product);
778
+ if (pairs) {
779
+ const lowest = Math.min(...pairs.map((pair) => pair.value));
780
+ if (lowest !== price?.min) return null;
781
+ const compareAts = new Set(pairs.flatMap((pair) => pair.value === lowest ? [pair.compareAt] : []));
782
+ const [only] = compareAts;
783
+ return compareAts.size === 1 && only != null && isOnSale(lowest, only) ? only : null;
784
+ }
785
+ return price?.min === price?.max && compareAt?.min === compareAt?.max && isOnSale(price?.min, compareAt?.min) ? compareAt?.min ?? null : null;
786
+ }
787
+ /** The SKU's attribute value names, or `null` when the response does not include them. */
788
+ function getSkuAttributeValueNames(sku) {
789
+ const edges = sku?.attributeValues?.edges;
790
+ if (!edges) return null;
791
+ return edges.flatMap((edge) => edge?.node?.name ? [edge.node.name] : []);
792
+ }
793
+
794
+ //#endregion
795
+ //#region src/class-names.ts
796
+ const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-commerce-accent px-5 py-2 text-sm font-medium text-commerce-on-accent transition-colors hover:bg-commerce-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-commerce-accent disabled:cursor-not-allowed disabled:bg-commerce-tint-strong disabled:text-commerce-muted disabled:hover:bg-commerce-tint-strong";
797
+ /** A quieter button for repeated or secondary actions: transparent, outlined in the inherited text color. */
798
+ const secondaryButtonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-commerce-edge px-5 py-2 text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:border-commerce-line disabled:text-commerce-muted disabled:hover:bg-transparent";
799
+ const inputClass = "min-h-11 rounded-lg border border-neutral-400 bg-white px-3 py-2 text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-900";
800
+ /** A self-contained message box: its own background and text, so it reads on any page or surface. */
801
+ const errorNoticeClass = "rounded-lg bg-red-50 px-4 py-3 text-sm text-red-800";
802
+ /** The underlined action inside an `errorNoticeClass` box. */
803
+ const errorActionClass = "mt-1 min-h-11 font-medium text-red-800 underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-red-800 disabled:cursor-not-allowed";
804
+ /** The Sale badge: its own fill and text, so it reads over an image or on any page. */
805
+ const saleBadgeClass = "inline-flex rounded bg-commerce-sale px-2 py-0.5 text-xs font-semibold leading-5 text-commerce-on-sale";
735
806
 
736
807
  //#endregion
737
808
  //#region src/part.ts
@@ -802,15 +873,18 @@ function CommerceStatus() {
802
873
  if (connection === "ready") return null;
803
874
  return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "unbound" ? /* @__PURE__ */ jsx("p", {
804
875
  role: "status",
876
+ className: "text-sm text-commerce-muted",
805
877
  children: "Connect a store to show products."
806
878
  }) : connection === "loading" || connection === "connecting" ? /* @__PURE__ */ jsx("p", {
807
879
  role: "status",
880
+ className: "text-sm text-commerce-muted",
808
881
  children: "Connecting to the store…"
809
882
  }) : /* @__PURE__ */ jsxs("div", {
810
883
  role: "alert",
884
+ className: errorNoticeClass,
811
885
  children: [/* @__PURE__ */ jsx("p", { children: connectionError }), /* @__PURE__ */ jsx("button", {
812
886
  type: "button",
813
- className: "min-h-11 underline",
887
+ className: errorActionClass,
814
888
  onClick: retryConnection,
815
889
  children: "Retry connection"
816
890
  })]
@@ -819,30 +893,51 @@ function CommerceStatus() {
819
893
 
820
894
  //#endregion
821
895
  //#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. */
896
+ /**
897
+ * Opens the cart drawer: a bag icon with a count badge that pops when the count changes. Its accessible name is
898
+ * "Cart, 2 items". With `children`, a labeled button instead.
899
+ */
825
900
  function CartButton({ children } = {}) {
826
901
  const { setOpen, open, connection } = useCommerce();
827
902
  const { itemCount } = useCart();
903
+ const [bump, setBump] = useState(0);
904
+ const previous = useRef(itemCount);
905
+ useEffect(() => {
906
+ if (previous.current !== itemCount) setBump((count) => count + 1);
907
+ previous.current = itemCount;
908
+ }, [itemCount]);
909
+ const custom = renderLabel(children, { itemCount }, null);
828
910
  return /* @__PURE__ */ jsx(StorefrontSurface, {
829
911
  className: "commerce-inline",
830
912
  children: /* @__PURE__ */ jsx("button", {
831
913
  type: "button",
832
- className: buttonClass,
914
+ className: `relative inline-flex min-h-11 items-center justify-center gap-2 rounded-full text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent ${custom === null ? "min-w-11" : "px-4"}`,
833
915
  disabled: connection !== "ready" && connection !== "unbound",
834
916
  "aria-haspopup": "dialog",
835
917
  "aria-expanded": open,
918
+ "aria-label": custom === null ? `Cart, ${itemCount} ${itemCount === 1 ? "item" : "items"}` : void 0,
836
919
  "data-commerce-part": "cart-button",
837
920
  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
- ] }))
921
+ children: custom ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(BagIcon, {}), itemCount > 0 && /* @__PURE__ */ jsx("span", {
922
+ "aria-hidden": "true",
923
+ className: "absolute right-0 top-0.5 inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-commerce-accent px-1 text-[11px] font-semibold tabular-nums text-commerce-on-accent motion-safe:animate-commerce-pop",
924
+ children: itemCount
925
+ }, bump)] })
926
+ })
927
+ });
928
+ }
929
+ function BagIcon() {
930
+ return /* @__PURE__ */ jsx("svg", {
931
+ "aria-hidden": "true",
932
+ viewBox: "0 0 20 20",
933
+ className: "h-5 w-5 shrink-0",
934
+ children: /* @__PURE__ */ jsx("path", {
935
+ d: "M4.5 6.5h11l-.8 9.1a1.5 1.5 0 0 1-1.5 1.4H6.8a1.5 1.5 0 0 1-1.5-1.4L4.5 6.5ZM7.5 6.5V6a2.5 2.5 0 0 1 5 0v.5",
936
+ fill: "none",
937
+ stroke: "currentColor",
938
+ strokeWidth: "1.5",
939
+ strokeLinecap: "round",
940
+ strokeLinejoin: "round"
846
941
  })
847
942
  });
848
943
  }
@@ -854,7 +949,17 @@ function quantityProblem(quantity, available) {
854
949
  if (available !== null && available > 0 && quantity > available) return `Only ${available} available.`;
855
950
  return null;
856
951
  }
857
- function AddToCartButton({ sku, name, quantity = 1, children }) {
952
+ function AddToCartButton(props) {
953
+ return /* @__PURE__ */ jsx(AddToCart, {
954
+ ...props,
955
+ className: buttonClass
956
+ });
957
+ }
958
+ /**
959
+ * `AddToCartButton` with the package's button style chosen by the caller, such as the quieter card style. `block`
960
+ * fills the container's width; the public button stays inline wherever a host places it.
961
+ */
962
+ function AddToCart({ sku, name, quantity = 1, children, className, block = false, label, labelName }) {
858
963
  const { addItem, pending, hydrating, error, open, connection } = useCommerce();
859
964
  const [adding, setAdding] = useState(false);
860
965
  const reasonId = useId();
@@ -867,6 +972,10 @@ function AddToCartButton({ sku, name, quantity = 1, children }) {
867
972
  else if (!sku.id) reason = "This item cannot be added to the cart.";
868
973
  else if (!soldOut) reason = hydrating ? "Loading your cart…" : quantityProblem(quantity, available);
869
974
  const disabled = adding || soldOut || reason !== null;
975
+ const ownLabel = !!labelName && !soldOut && !adding && renderLabel(children, {
976
+ busy: false,
977
+ soldOut
978
+ }, null) === null;
870
979
  async function handleAdd() {
871
980
  if (disabled || pending || !sku.id) return;
872
981
  setAdding(true);
@@ -881,21 +990,22 @@ function AddToCartButton({ sku, name, quantity = 1, children }) {
881
990
  }
882
991
  }
883
992
  return /* @__PURE__ */ jsxs(StorefrontSurface, {
884
- className: "commerce-inline",
993
+ className: block ? "" : "commerce-inline",
885
994
  children: [
886
995
  /* @__PURE__ */ jsx("button", {
887
996
  type: "button",
888
- className: `${buttonClass} w-full`,
997
+ className: `${className} w-full`,
889
998
  disabled,
890
999
  "aria-disabled": disabled || pending,
891
1000
  "aria-busy": adding,
892
1001
  "aria-describedby": reason ? reasonId : void 0,
1002
+ "aria-label": ownLabel ? labelName : void 0,
893
1003
  "data-commerce-part": "add-to-cart-button",
894
1004
  onClick: () => void handleAdd(),
895
1005
  children: renderLabel(children, {
896
1006
  busy: adding,
897
1007
  soldOut
898
- }, soldOut ? "Out of stock" : adding ? "Adding…" : "Add to cart")
1008
+ }, soldOut ? "Out of stock" : adding ? "Adding…" : label ?? "Add to cart")
899
1009
  }),
900
1010
  reason && /* @__PURE__ */ jsx("span", {
901
1011
  id: reasonId,
@@ -904,7 +1014,7 @@ function AddToCartButton({ sku, name, quantity = 1, children }) {
904
1014
  }),
905
1015
  error && !open && /* @__PURE__ */ jsx("p", {
906
1016
  role: "alert",
907
- className: "mt-3 bg-white text-sm text-red-700",
1017
+ className: `mt-3 ${errorNoticeClass}`,
908
1018
  children: error
909
1019
  })
910
1020
  ]
@@ -986,7 +1096,7 @@ function CartDrawerRoot({ children, className }) {
986
1096
  return /* @__PURE__ */ jsx(Dialog.Root, {
987
1097
  open,
988
1098
  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", {
1099
+ children: /* @__PURE__ */ jsx(Dialog.Portal, { children: /* @__PURE__ */ jsxs(StorefrontSurface, { children: [/* @__PURE__ */ jsx(Dialog.Overlay, { className: "fixed inset-0 z-40 bg-neutral-950/40 motion-safe:animate-commerce-fade-in" }), /* @__PURE__ */ jsx("div", {
990
1100
  className: "pointer-events-none fixed inset-0 z-50 flex justify-end",
991
1101
  children: /* @__PURE__ */ jsx(Dialog.Content, {
992
1102
  "data-commerce-source": connection === "unbound" ? "sample" : void 0,
@@ -1004,15 +1114,25 @@ function CartDrawerRoot({ children, className }) {
1004
1114
  })] }) })
1005
1115
  });
1006
1116
  }
1007
- /** "Your cart", or "Sample cart" before a store is connected, as an H2. In the drawer it names the dialog. */
1117
+ /**
1118
+ * "Your cart", or "Sample cart" before a store is connected, as an H2, with the item count once there are items.
1119
+ * In the drawer it names the dialog.
1120
+ */
1008
1121
  function CartTitle({ className, children }) {
1009
- const { isSample } = useCartInternal("CartTitle");
1122
+ const { isSample, itemCount } = useCartInternal("CartTitle");
1010
1123
  const Heading = useContext(CartDrawerContext) ? Dialog.Title : "h2";
1011
1124
  return /* @__PURE__ */ jsx("div", {
1012
1125
  ...partProps("cart-title", className),
1013
1126
  children: /* @__PURE__ */ jsx(Heading, {
1014
- className: "text-2xl font-semibold",
1015
- children: renderLabel(children, { isSample }, isSample ? "Sample cart" : "Your cart")
1127
+ className: "text-lg font-semibold tracking-tight",
1128
+ children: renderLabel(children, { isSample }, /* @__PURE__ */ jsxs(Fragment$1, { children: [isSample ? "Sample cart" : "Your cart", itemCount > 0 && /* @__PURE__ */ jsxs("span", {
1129
+ className: "font-normal text-commerce-muted",
1130
+ children: [
1131
+ " (",
1132
+ itemCount,
1133
+ ")"
1134
+ ]
1135
+ })] }))
1016
1136
  })
1017
1137
  });
1018
1138
  }
@@ -1026,10 +1146,10 @@ function CartDescription({ className, children }) {
1026
1146
  return /* @__PURE__ */ jsx("div", {
1027
1147
  ...partProps("cart-description", className),
1028
1148
  children: drawer ? /* @__PURE__ */ jsx(Dialog.Description, {
1029
- className: "text-sm text-neutral-600",
1149
+ className: "text-sm text-commerce-muted",
1030
1150
  children: text
1031
1151
  }) : /* @__PURE__ */ jsx("p", {
1032
- className: "text-sm text-neutral-600",
1152
+ className: "text-sm text-commerce-muted",
1033
1153
  children: text
1034
1154
  })
1035
1155
  });
@@ -1041,9 +1161,20 @@ function CartClose({ className }) {
1041
1161
  return /* @__PURE__ */ jsx("div", {
1042
1162
  ...partProps("cart-close", className),
1043
1163
  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",
1164
+ className: "inline-flex min-h-11 min-w-11 items-center justify-center rounded-full text-commerce-muted transition-colors hover:bg-commerce-tint hover:text-inherit focus-visible:outline focus-visible:outline-2 focus-visible:outline-current",
1045
1165
  "aria-label": "Close cart",
1046
- children: "✕"
1166
+ children: /* @__PURE__ */ jsx("svg", {
1167
+ "aria-hidden": "true",
1168
+ viewBox: "0 0 20 20",
1169
+ className: "h-5 w-5",
1170
+ children: /* @__PURE__ */ jsx("path", {
1171
+ d: "M5 5l10 10M15 5L5 15",
1172
+ fill: "none",
1173
+ stroke: "currentColor",
1174
+ strokeWidth: "1.5",
1175
+ strokeLinecap: "round"
1176
+ })
1177
+ })
1047
1178
  })
1048
1179
  });
1049
1180
  }
@@ -1057,14 +1188,14 @@ function CartNotices({ className }) {
1057
1188
  className: "flex flex-col gap-4",
1058
1189
  children: [storageWarning && /* @__PURE__ */ jsx("p", {
1059
1190
  role: "status",
1060
- className: "text-sm text-amber-800",
1191
+ className: "rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-900",
1061
1192
  children: storageWarning
1062
1193
  }), error && /* @__PURE__ */ jsxs("div", {
1063
1194
  role: "alert",
1064
- className: "rounded-lg bg-red-50 p-4 text-red-800",
1195
+ className: errorNoticeClass,
1065
1196
  children: [/* @__PURE__ */ jsx("p", { children: error }), /* @__PURE__ */ jsx("button", {
1066
1197
  type: "button",
1067
- className: "mt-2 min-h-11 bg-red-50 text-red-800 underline disabled:cursor-not-allowed",
1198
+ className: errorActionClass,
1068
1199
  disabled: action === "refresh",
1069
1200
  "aria-disabled": locked,
1070
1201
  "aria-busy": action === "refresh",
@@ -1104,6 +1235,7 @@ function CartItems({ className, children }) {
1104
1235
  value: registry.register,
1105
1236
  children: [hydrating && /* @__PURE__ */ jsx("p", {
1106
1237
  role: "status",
1238
+ className: "py-4 text-sm text-commerce-muted",
1107
1239
  children: "Loading your cart…"
1108
1240
  }), /* @__PURE__ */ jsx("div", {
1109
1241
  role: "list",
@@ -1143,7 +1275,7 @@ function CartItemImage({ className }) {
1143
1275
  children: /* @__PURE__ */ jsx("img", {
1144
1276
  src: imageUrl,
1145
1277
  alt: "",
1146
- className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
1278
+ className: "h-20 w-20 bg-commerce-tint object-cover"
1147
1279
  })
1148
1280
  });
1149
1281
  }
@@ -1153,25 +1285,26 @@ function CartItemName({ className }) {
1153
1285
  return /* @__PURE__ */ jsx("div", {
1154
1286
  ...partProps("cart-item-name", className),
1155
1287
  children: /* @__PURE__ */ jsx("h3", {
1156
- className: "font-semibold",
1288
+ className: "break-words text-sm font-medium leading-6",
1157
1289
  children: name
1158
1290
  })
1159
1291
  });
1160
1292
  }
1161
- /** The selected options, one per line, such as "Color: Blue". Renders nothing without options. */
1293
+ /** The selected options on one line, such as "Color: Blue · Size: M". Renders nothing without options. */
1162
1294
  function CartItemOptions({ className }) {
1163
1295
  const { options } = useCartItemInternal("CartItemOptions");
1164
1296
  if (!options.length) return null;
1165
1297
  return /* @__PURE__ */ jsx("div", {
1166
1298
  ...partProps("cart-item-options", className),
1167
- children: options.map((option) => /* @__PURE__ */ jsxs("p", {
1168
- className: "text-sm text-neutral-600",
1169
- children: [
1299
+ children: /* @__PURE__ */ jsx("p", {
1300
+ className: "text-sm text-commerce-muted",
1301
+ children: options.map((option, index) => /* @__PURE__ */ jsxs("span", { children: [
1302
+ index > 0 && " · ",
1170
1303
  option.attribute,
1171
1304
  ": ",
1172
1305
  option.values.join(", ")
1173
- ]
1174
- }, `${option.attribute}:${option.values.join(",")}`))
1306
+ ] }, `${option.attribute}:${option.values.join(",")}`))
1307
+ })
1175
1308
  });
1176
1309
  }
1177
1310
  /** The line total. */
@@ -1181,12 +1314,26 @@ function CartItemPrice({ className }) {
1181
1314
  return /* @__PURE__ */ jsx("div", {
1182
1315
  ...partProps("cart-item-price", className),
1183
1316
  children: /* @__PURE__ */ jsx("p", {
1184
- className: "text-sm",
1317
+ className: "text-sm leading-6 tabular-nums",
1185
1318
  children: subtotal === null ? "Price unavailable" : money$1(subtotal, currency)
1186
1319
  })
1187
1320
  });
1188
1321
  }
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";
1322
+ const stepperButtonClass$1 = "inline-flex min-h-11 min-w-11 items-center justify-center rounded-lg transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent";
1323
+ function StepperIcon$1({ plus }) {
1324
+ return /* @__PURE__ */ jsx("svg", {
1325
+ "aria-hidden": "true",
1326
+ viewBox: "0 0 16 16",
1327
+ className: "h-4 w-4",
1328
+ children: /* @__PURE__ */ jsx("path", {
1329
+ d: plus ? "M3.5 8h9M8 3.5v9" : "M3.5 8h9",
1330
+ fill: "none",
1331
+ stroke: "currentColor",
1332
+ strokeWidth: "1.5",
1333
+ strokeLinecap: "round"
1334
+ })
1335
+ });
1336
+ }
1190
1337
  /** Decrease and increase buttons around the line's quantity. */
1191
1338
  function CartItemQuantity({ className }) {
1192
1339
  const { id, name, quantity } = useCartItemInternal("CartItemQuantity");
@@ -1194,21 +1341,21 @@ function CartItemQuantity({ className }) {
1194
1341
  return /* @__PURE__ */ jsx("div", {
1195
1342
  ...partProps("cart-item-quantity", className),
1196
1343
  children: /* @__PURE__ */ jsxs("div", {
1197
- className: "flex items-center rounded-lg border border-neutral-300",
1344
+ className: "inline-flex items-center rounded-lg border border-commerce-edge",
1198
1345
  children: [
1199
1346
  /* @__PURE__ */ jsx("button", {
1200
1347
  type: "button",
1201
- className: stepperButtonClass,
1348
+ className: stepperButtonClass$1,
1202
1349
  "aria-label": `Decrease quantity of ${name}`,
1203
1350
  "data-commerce-control": "decrease",
1204
1351
  disabled: action === `decrease:${id}` || !id || quantity <= 1,
1205
1352
  "aria-disabled": locked || !id || quantity <= 1,
1206
1353
  "aria-busy": action === `decrease:${id}`,
1207
1354
  onClick: () => run(`decrease:${id}`, () => changeQuantity(id, quantity - 1)),
1208
- children: "−"
1355
+ children: /* @__PURE__ */ jsx(StepperIcon$1, {})
1209
1356
  }),
1210
1357
  /* @__PURE__ */ jsxs("span", {
1211
- className: "min-w-8 text-center",
1358
+ className: "min-w-8 text-center text-sm tabular-nums",
1212
1359
  children: [/* @__PURE__ */ jsx("span", {
1213
1360
  className: "sr-only",
1214
1361
  children: "Quantity: "
@@ -1216,14 +1363,14 @@ function CartItemQuantity({ className }) {
1216
1363
  }),
1217
1364
  /* @__PURE__ */ jsx("button", {
1218
1365
  type: "button",
1219
- className: stepperButtonClass,
1366
+ className: stepperButtonClass$1,
1220
1367
  "aria-label": `Increase quantity of ${name}`,
1221
1368
  "data-commerce-control": "increase",
1222
1369
  disabled: action === `increase:${id}` || !id,
1223
1370
  "aria-disabled": locked || !id,
1224
1371
  "aria-busy": action === `increase:${id}`,
1225
1372
  onClick: () => run(`increase:${id}`, () => changeQuantity(id, quantity + 1)),
1226
- children: "+"
1373
+ children: /* @__PURE__ */ jsx(StepperIcon$1, { plus: true })
1227
1374
  })
1228
1375
  ]
1229
1376
  })
@@ -1237,7 +1384,7 @@ function CartItemRemove({ className, children }) {
1237
1384
  ...partProps("cart-item-remove", className),
1238
1385
  children: /* @__PURE__ */ jsxs("button", {
1239
1386
  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",
1387
+ className: "min-h-11 rounded-sm px-1 text-sm text-commerce-muted underline-offset-4 transition-colors hover:text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:hover:no-underline",
1241
1388
  disabled: action === `remove:${id}` || !id,
1242
1389
  "aria-disabled": locked || !id,
1243
1390
  "aria-busy": action === `remove:${id}`,
@@ -1250,20 +1397,19 @@ function CartItemRemove({ className, children }) {
1250
1397
  });
1251
1398
  }
1252
1399
  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
- ]
1400
+ className: "flex gap-4 py-5",
1401
+ children: [/* @__PURE__ */ jsx(CartItemImage, { className: "shrink-0" }), /* @__PURE__ */ jsxs("div", {
1402
+ className: "flex min-w-0 flex-1 flex-col",
1403
+ children: [/* @__PURE__ */ jsxs("div", {
1404
+ className: "flex items-start justify-between gap-4",
1405
+ children: [/* @__PURE__ */ jsxs("div", {
1406
+ className: "min-w-0",
1407
+ children: [/* @__PURE__ */ jsx(CartItemName, {}), /* @__PURE__ */ jsx(CartItemOptions, {})]
1408
+ }), /* @__PURE__ */ jsx(CartItemPrice, { className: "shrink-0" })]
1409
+ }), /* @__PURE__ */ jsxs("div", {
1410
+ className: "mt-3 flex items-center justify-between gap-2",
1411
+ children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1263
1412
  })]
1264
- }), /* @__PURE__ */ jsxs("div", {
1265
- className: "mt-4 flex items-center justify-between gap-2",
1266
- children: [/* @__PURE__ */ jsx(CartItemQuantity, {}), /* @__PURE__ */ jsx(CartItemRemove, {})]
1267
1413
  })]
1268
1414
  });
1269
1415
  /** Renders only when the cart is empty: a message and a link to the catalog, or its children. */
@@ -1273,16 +1419,32 @@ function CartEmpty({ className, children }) {
1273
1419
  return /* @__PURE__ */ jsx("div", {
1274
1420
  ...partProps("cart-empty", className),
1275
1421
  children: hasChildren(children) ? children : /* @__PURE__ */ jsxs(Fragment$1, { children: [
1422
+ /* @__PURE__ */ jsx("div", {
1423
+ className: "mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-commerce-tint text-commerce-muted",
1424
+ children: /* @__PURE__ */ jsx("svg", {
1425
+ "aria-hidden": "true",
1426
+ viewBox: "0 0 20 20",
1427
+ className: "h-6 w-6",
1428
+ children: /* @__PURE__ */ jsx("path", {
1429
+ d: "M4.5 6.5h11l-.8 9.1a1.5 1.5 0 0 1-1.5 1.4H6.8a1.5 1.5 0 0 1-1.5-1.4L4.5 6.5ZM7.5 6.5V6a2.5 2.5 0 0 1 5 0v.5",
1430
+ fill: "none",
1431
+ stroke: "currentColor",
1432
+ strokeWidth: "1.5",
1433
+ strokeLinecap: "round",
1434
+ strokeLinejoin: "round"
1435
+ })
1436
+ })
1437
+ }),
1276
1438
  /* @__PURE__ */ jsx("p", {
1277
- className: "text-xl font-medium",
1439
+ className: "text-base font-semibold",
1278
1440
  children: "Your cart is empty"
1279
1441
  }),
1280
1442
  /* @__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."
1443
+ className: "mt-1 text-sm text-commerce-muted",
1444
+ children: isSample ? "Add a sample product to try the cart." : "Add something you love and it will show up here."
1283
1445
  }),
1284
1446
  /* @__PURE__ */ jsx(Link, {
1285
- className: `${buttonClass} mt-6`,
1447
+ className: `${secondaryButtonClass} mt-6`,
1286
1448
  to: catalogPath,
1287
1449
  onClick: close,
1288
1450
  children: "Browse products"
@@ -1306,17 +1468,17 @@ function CartSubtotal({ className }) {
1306
1468
  if (!items.length || subtotal === null) return null;
1307
1469
  return /* @__PURE__ */ jsx("div", {
1308
1470
  ...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
- })
1471
+ children: /* @__PURE__ */ jsx("dl", { children: /* @__PURE__ */ jsxs("div", {
1472
+ className: "flex justify-between gap-4 font-semibold",
1473
+ children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", {
1474
+ className: "tabular-nums",
1475
+ children: money$1(subtotal, currency)
1476
+ })]
1477
+ }) })
1316
1478
  });
1317
1479
  }
1318
1480
  /**
1319
- * "Shipping, taxes, and discounts are calculated at checkout." Has the stable
1481
+ * A row under the subtotal: "Shipping, taxes & discounts", "Calculated at checkout". Has the stable
1320
1482
  * `commerce-cart-checkout-adjustments-note` class. Renders nothing for samples or an empty cart.
1321
1483
  */
1322
1484
  function CartCheckoutNote({ className }) {
@@ -1324,9 +1486,9 @@ function CartCheckoutNote({ className }) {
1324
1486
  if (isSample || !items.length) return null;
1325
1487
  return /* @__PURE__ */ jsx("div", {
1326
1488
  ...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."
1489
+ children: /* @__PURE__ */ jsxs("dl", {
1490
+ className: "flex justify-between gap-4 text-sm text-commerce-muted",
1491
+ children: [/* @__PURE__ */ jsx("dt", { children: "Shipping, taxes & discounts" }), /* @__PURE__ */ jsx("dd", { children: "Calculated at checkout" })]
1330
1492
  })
1331
1493
  });
1332
1494
  }
@@ -1339,11 +1501,11 @@ function CartCheckout({ className, children }) {
1339
1501
  if (!items.length) return null;
1340
1502
  let content;
1341
1503
  if (isSample) content = /* @__PURE__ */ jsx("p", {
1342
- className: "text-sm text-neutral-600",
1504
+ className: "text-sm text-commerce-muted",
1343
1505
  children: "Example total in USD. Checkout is not set up yet."
1344
1506
  });
1345
1507
  else if (!checkoutEnabled) content = /* @__PURE__ */ jsx("p", {
1346
- className: "text-sm text-neutral-600",
1508
+ className: "text-sm text-commerce-muted",
1347
1509
  children: "Checkout is not available yet."
1348
1510
  });
1349
1511
  else content = /* @__PURE__ */ jsx("button", {
@@ -1353,36 +1515,66 @@ function CartCheckout({ className, children }) {
1353
1515
  "aria-disabled": locked || !checkoutReady,
1354
1516
  "aria-busy": action === "checkout",
1355
1517
  onClick: () => run("checkout", checkout),
1356
- children: renderLabel(children, { busy: action === "checkout" }, action === "checkout" ? "Opening checkout…" : "Proceed to Checkout")
1518
+ children: renderLabel(children, { busy: action === "checkout" }, action === "checkout" ? "Opening checkout…" : "Proceed to checkout")
1357
1519
  });
1358
1520
  return /* @__PURE__ */ jsx("div", {
1359
1521
  ...partProps("cart-checkout", className),
1360
1522
  children: content
1361
1523
  });
1362
1524
  }
1525
+ /**
1526
+ * "Continue shopping": closes the drawer, or on a cart page links to the catalog. Renders nothing for an empty cart,
1527
+ * whose `CartEmpty` links to the catalog.
1528
+ */
1529
+ function CartContinueShopping({ className, children }) {
1530
+ const { items, isSample, catalogPath } = useCartInternal("CartContinueShopping");
1531
+ const inDrawer = !!useContext(CartDrawerContext);
1532
+ if (!items.length) return null;
1533
+ const label = renderLabel(children, { isSample }, "Continue shopping");
1534
+ const linkClass = "inline-flex min-h-11 w-full items-center justify-center rounded-lg px-5 py-2 text-sm font-medium transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current";
1535
+ return /* @__PURE__ */ jsx("div", {
1536
+ ...partProps("cart-continue-shopping", className),
1537
+ children: inDrawer ? /* @__PURE__ */ jsx(Dialog.Close, {
1538
+ className: linkClass,
1539
+ children: label
1540
+ }) : /* @__PURE__ */ jsx(Link, {
1541
+ className: linkClass,
1542
+ to: catalogPath,
1543
+ children: label
1544
+ })
1545
+ });
1546
+ }
1547
+ function SampleDescription({ className }) {
1548
+ const { isSample } = useCartInternal("CartDescription");
1549
+ return isSample ? /* @__PURE__ */ jsx(CartDescription, { className }) : null;
1550
+ }
1363
1551
  /** The packaged cart drawer. Compose `CartDrawerRoot` and the cart parts for a custom drawer. */
1364
1552
  function CartDrawer() {
1365
1553
  return /* @__PURE__ */ jsxs(CartDrawerRoot, {
1366
- className: "flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
1554
+ className: "flex w-full max-w-md flex-col bg-white text-neutral-900 shadow-2xl motion-safe:animate-commerce-drawer-in",
1367
1555
  children: [
1368
1556
  /* @__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, {})]
1557
+ className: "flex items-start justify-between gap-4 border-b border-commerce-line py-3 pl-6 pr-3",
1558
+ children: [/* @__PURE__ */ jsxs("div", {
1559
+ className: "min-w-0 pt-2.5",
1560
+ children: [/* @__PURE__ */ jsx(CartTitle, {}), /* @__PURE__ */ jsx(SampleDescription, { className: "mt-0.5" })]
1561
+ }), /* @__PURE__ */ jsx(CartClose, {})]
1371
1562
  }),
1372
1563
  /* @__PURE__ */ jsxs("div", {
1373
- className: "flex-1 overflow-y-auto p-6",
1564
+ className: "flex-1 overflow-y-auto px-6",
1374
1565
  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" })
1566
+ /* @__PURE__ */ jsx(CartNotices, { className: "mt-6" }),
1567
+ /* @__PURE__ */ jsx(CartItems, { className: "divide-y divide-commerce-line" }),
1568
+ /* @__PURE__ */ jsx(CartEmpty, { className: "py-20 text-center" })
1378
1569
  ]
1379
1570
  }),
1380
1571
  /* @__PURE__ */ jsxs(CartSummary, {
1381
- className: "border-t border-neutral-200 p-6",
1572
+ className: "border-t border-commerce-line px-6 pb-6 pt-4",
1382
1573
  children: [
1383
1574
  /* @__PURE__ */ jsx(CartSubtotal, {}),
1384
- /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-3" }),
1385
- /* @__PURE__ */ jsx(CartCheckout, {})
1575
+ /* @__PURE__ */ jsx(CartCheckoutNote, { className: "mt-1" }),
1576
+ /* @__PURE__ */ jsx(CartCheckout, { className: "mt-5" }),
1577
+ /* @__PURE__ */ jsx(CartContinueShopping, { className: "mt-2" })
1386
1578
  ]
1387
1579
  })
1388
1580
  ]
@@ -1404,7 +1596,7 @@ function ProductImage({ url, name, className = "" }) {
1404
1596
  className: `h-full w-full object-cover ${className}`,
1405
1597
  onError: () => setFailedUrl(url)
1406
1598
  }) : /* @__PURE__ */ jsx("div", {
1407
- className: `flex h-full min-h-44 items-center justify-center p-6 text-center ${className}`,
1599
+ className: `flex h-full min-h-44 items-center justify-center bg-commerce-tint p-6 text-center text-sm text-commerce-muted ${className}`,
1408
1600
  role: "img",
1409
1601
  "aria-label": `${name}: image unavailable`,
1410
1602
  children: "No image available"
@@ -1504,16 +1696,16 @@ function ProductCardImage({ className }) {
1504
1696
  to: card.href,
1505
1697
  tabIndex: card.imageLinked ? void 0 : -1,
1506
1698
  "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",
1699
+ className: "block aspect-square overflow-hidden bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
1508
1700
  children: /* @__PURE__ */ jsx(ProductImage, {
1509
1701
  url: getPrimaryImageUrl(card.product),
1510
1702
  name: card.name,
1511
- className: "motion-safe:transition-transform motion-safe:duration-200 motion-safe:group-hover:scale-105"
1703
+ className: "motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out motion-safe:group-hover:scale-[1.03]"
1512
1704
  })
1513
1705
  }) : /* @__PURE__ */ jsx("div", {
1514
1706
  "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" })
1707
+ className: "flex aspect-square items-center justify-center bg-commerce-tint",
1708
+ children: /* @__PURE__ */ jsx("div", { className: "h-16 w-16 rounded-full border border-commerce-line" })
1517
1709
  })
1518
1710
  });
1519
1711
  }
@@ -1524,9 +1716,9 @@ function ProductCardTitle({ className }) {
1524
1716
  return /* @__PURE__ */ jsx("div", {
1525
1717
  ...partProps("product-card-title", className),
1526
1718
  children: /* @__PURE__ */ jsx("h2", {
1527
- className: "break-words text-2xl font-semibold leading-tight",
1719
+ className: "break-words text-base font-medium leading-6",
1528
1720
  children: href ? /* @__PURE__ */ jsx(Link, {
1529
- className: "rounded text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
1721
+ className: "rounded-sm text-inherit underline-offset-4 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
1530
1722
  to: href,
1531
1723
  children: name
1532
1724
  }) : name
@@ -1540,31 +1732,108 @@ function ProductCardDescription({ className }) {
1540
1732
  return /* @__PURE__ */ jsx("div", {
1541
1733
  ...partProps("product-card-description", className),
1542
1734
  children: /* @__PURE__ */ jsx("p", {
1543
- className: "line-clamp-3 text-sm leading-6",
1735
+ className: "line-clamp-2 text-sm leading-6 text-commerce-muted",
1544
1736
  children: product.description
1545
1737
  })
1546
1738
  });
1547
1739
  }
1548
- /** The product's price, or its price range when variants differ. */
1740
+ const formatRange = (range, money$1) => {
1741
+ const min = range?.min;
1742
+ const max = range?.max;
1743
+ if (typeof min !== "number") return null;
1744
+ return `${money$1(min)}${typeof max === "number" && max !== min ? ` – ${money$1(max)}` : ""}`;
1745
+ };
1746
+ /**
1747
+ * "Sale" when some SKU's compare-at price is above that SKU's price. Without SKU prices, as in the catalog list, only
1748
+ * when the lowest compare-at price is above the highest price. Renders nothing otherwise, or for samples.
1749
+ */
1750
+ function ProductCardBadge({ className }) {
1751
+ const { product } = useProductCardInternal("ProductCardBadge");
1752
+ if (!isProductOnSale(product)) return null;
1753
+ return /* @__PURE__ */ jsx("div", {
1754
+ ...partProps("product-card-badge", className),
1755
+ children: /* @__PURE__ */ jsx("span", {
1756
+ className: saleBadgeClass,
1757
+ children: "Sale"
1758
+ })
1759
+ });
1760
+ }
1761
+ /** The product's price, or its price range when variants differ. A single price on sale shows its compare-at price. */
1549
1762
  function ProductCardPrice({ className }) {
1550
1763
  const card = useProductCardInternal("ProductCardPrice");
1551
1764
  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
- }
1765
+ const text = card.source === "sample" ? money$1(card.sample.price, "USD") : formatRange(card.product.priceRange, money$1) ?? "Price unavailable";
1766
+ const range = card.source === "live" ? card.product.priceRange : null;
1767
+ const starting = card.source === "live" && range?.min === range?.max ? getStartingCompareAt(card.product) : null;
1768
+ const compareAt = starting === null ? null : money$1(starting);
1559
1769
  return /* @__PURE__ */ jsx("div", {
1560
1770
  ...partProps("product-card-price", className),
1561
- children: /* @__PURE__ */ jsx("p", {
1562
- className: "text-lg font-semibold leading-7",
1563
- children: text
1771
+ children: /* @__PURE__ */ jsxs("p", {
1772
+ className: "flex flex-wrap items-baseline gap-x-2 text-sm leading-6 tabular-nums",
1773
+ children: [/* @__PURE__ */ jsx("span", {
1774
+ className: "font-medium",
1775
+ children: text
1776
+ }), compareAt && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
1777
+ className: "sr-only",
1778
+ children: "Was"
1779
+ }), /* @__PURE__ */ jsx("del", {
1780
+ className: "text-commerce-muted",
1781
+ children: compareAt
1782
+ })] })]
1564
1783
  })
1565
1784
  });
1566
1785
  }
1567
1786
  /**
1787
+ * Dots for the first attribute whose values all have a swatch from the provider's `getSwatch`, such as colors.
1788
+ * Renders nothing without `getSwatch`, for samples, or when no attribute has swatches.
1789
+ */
1790
+ function ProductCardSwatches({ className }) {
1791
+ const { product } = useProductCardInternal("ProductCardSwatches");
1792
+ const { getSwatch } = useCommerce();
1793
+ if (!getSwatch || !product) return null;
1794
+ for (const attribute of getProductAttributes(product)) {
1795
+ const swatches = attribute.values.map((value) => ({
1796
+ ...value,
1797
+ background: getSwatch({
1798
+ attribute: attribute.name,
1799
+ name: value.name,
1800
+ label: value.label
1801
+ })
1802
+ }));
1803
+ if (!swatches.length || swatches.some((swatch) => !swatch.background)) continue;
1804
+ const shown = swatches.slice(0, 5);
1805
+ return /* @__PURE__ */ jsx("div", {
1806
+ ...partProps("product-card-swatches", className),
1807
+ children: /* @__PURE__ */ jsxs("p", {
1808
+ className: "flex items-center gap-1.5 text-xs text-commerce-muted",
1809
+ children: [
1810
+ /* @__PURE__ */ jsxs("span", {
1811
+ className: "sr-only",
1812
+ children: [
1813
+ swatches.length,
1814
+ " ",
1815
+ inSentence(attribute.label),
1816
+ " options:",
1817
+ " ",
1818
+ swatches.map((swatch) => swatch.label).join(", ")
1819
+ ]
1820
+ }),
1821
+ shown.map((swatch) => /* @__PURE__ */ jsx("span", {
1822
+ "aria-hidden": "true",
1823
+ className: "h-3.5 w-3.5 rounded-full border border-commerce-edge",
1824
+ style: { background: swatch.background ?? void 0 }
1825
+ }, swatch.name)),
1826
+ swatches.length > shown.length && /* @__PURE__ */ jsxs("span", {
1827
+ "aria-hidden": "true",
1828
+ children: ["+", swatches.length - shown.length]
1829
+ })
1830
+ ]
1831
+ })
1832
+ });
1833
+ }
1834
+ return null;
1835
+ }
1836
+ /**
1568
1837
  * Adds the product directly when it has exactly one SKU and no options; otherwise links to its page to choose.
1569
1838
  * Samples add to the sample cart.
1570
1839
  */
@@ -1586,19 +1855,22 @@ function ProductCardAction({ className, children }) {
1586
1855
  function LiveAction({ product, name, href, label }) {
1587
1856
  const selectedSku = getSingleMatchedSku(product);
1588
1857
  const hasVariants = getProductAttributes(product).length > 0;
1589
- if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCartButton, {
1858
+ if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCart, {
1590
1859
  sku: selectedSku,
1591
1860
  name,
1861
+ className: secondaryButtonClass,
1862
+ block: true,
1592
1863
  children: (state) => renderLabel(label, {
1593
1864
  action: "add",
1594
1865
  ...state
1595
1866
  }, null)
1596
1867
  });
1868
+ const action = hasVariants ? "choose-options" : "view-product";
1597
1869
  return /* @__PURE__ */ jsx(Link, {
1598
- className: buttonClass,
1870
+ className: `${secondaryButtonClass} w-full`,
1599
1871
  to: href,
1600
1872
  children: renderLabel(label, {
1601
- action: hasVariants ? "choose-options" : "view-product",
1873
+ action,
1602
1874
  busy: false,
1603
1875
  soldOut: false
1604
1876
  }, hasVariants ? "Choose options" : "View product")
@@ -1613,13 +1885,13 @@ function SampleAction({ sample, label }) {
1613
1885
  }, null);
1614
1886
  return custom === null ? /* @__PURE__ */ jsx("button", {
1615
1887
  type: "button",
1616
- className: buttonClass,
1888
+ className: `${secondaryButtonClass} w-full`,
1617
1889
  "aria-label": `Add ${sample.name} to sample cart`,
1618
1890
  onClick: () => add(sample.key),
1619
1891
  children: "Add to cart"
1620
1892
  }) : /* @__PURE__ */ jsxs("button", {
1621
1893
  type: "button",
1622
- className: buttonClass,
1894
+ className: `${secondaryButtonClass} w-full`,
1623
1895
  onClick: () => add(sample.key),
1624
1896
  children: [custom, /* @__PURE__ */ jsxs("span", {
1625
1897
  className: "sr-only",
@@ -1631,21 +1903,22 @@ function SampleAction({ sample, label }) {
1631
1903
  })]
1632
1904
  });
1633
1905
  }
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`. */
1906
+ const packagedCardClass = "flex min-w-0 flex-col";
1907
+ /**
1908
+ * The packaged card's parts, inside a root that has `packagedCardClass`. No description: cards scan better without
1909
+ * it, and `ProductCardDescription` adds it back.
1910
+ */
1636
1911
  function PackagedCardParts() {
1637
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsxs("div", {
1638
- className: "flex flex-1 flex-col p-6",
1912
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
1913
+ className: "relative",
1914
+ children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsx(ProductCardBadge, { className: "pointer-events-none absolute left-3 top-3" })]
1915
+ }), /* @__PURE__ */ jsxs("div", {
1916
+ className: "flex flex-1 flex-col pt-3",
1639
1917
  children: [
1640
1918
  /* @__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
- })
1919
+ /* @__PURE__ */ jsx(ProductCardPrice, { className: "mt-0.5" }),
1920
+ /* @__PURE__ */ jsx(ProductCardSwatches, { className: "mt-2" }),
1921
+ /* @__PURE__ */ jsx(ProductCardAction, { className: "mt-auto pt-4" })
1649
1922
  ]
1650
1923
  })] });
1651
1924
  }
@@ -1770,10 +2043,10 @@ function CatalogHeader({ title = "Shop all products", description = "Explore the
1770
2043
  return /* @__PURE__ */ jsxs("header", {
1771
2044
  ...partProps("catalog-header", className),
1772
2045
  children: [/* @__PURE__ */ jsx("h1", {
1773
- className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
2046
+ className: "max-w-3xl text-balance text-3xl font-semibold leading-tight tracking-tight",
1774
2047
  children: title
1775
2048
  }), /* @__PURE__ */ jsx("p", {
1776
- className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
2049
+ className: "mt-1 max-w-xl text-base leading-7 text-commerce-muted",
1777
2050
  children: description
1778
2051
  })]
1779
2052
  });
@@ -1784,7 +2057,7 @@ function CatalogSummary({ className }) {
1784
2057
  return /* @__PURE__ */ jsx("div", {
1785
2058
  ...partProps("catalog-summary", className),
1786
2059
  children: /* @__PURE__ */ jsx("p", {
1787
- className: "text-sm",
2060
+ className: "text-sm text-commerce-muted",
1788
2061
  role: "status",
1789
2062
  children: summary(catalog)
1790
2063
  })
@@ -1796,6 +2069,8 @@ function summary(catalog) {
1796
2069
  if (catalog.products.isError) return "Products unavailable";
1797
2070
  return `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? "" : "s"}`;
1798
2071
  }
2072
+ /** Two columns on phones, then as many 240px columns as fit. Shared with `RelatedProducts`. */
2073
+ const catalogGridClass = "grid grid-cols-2 gap-x-4 gap-y-10 sm:gap-x-6 md:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] lg:gap-y-12";
1799
2074
  const sampleItems = sampleProducts.map((sample) => ({
1800
2075
  key: sample.key,
1801
2076
  item: {
@@ -1803,7 +2078,7 @@ const sampleItems = sampleProducts.map((sample) => ({
1803
2078
  sample
1804
2079
  }
1805
2080
  }));
1806
- const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
2081
+ const packagedCard$1 = /* @__PURE__ */ jsx(ProductCardRoot, {
1807
2082
  className: packagedCardClass,
1808
2083
  children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1809
2084
  });
@@ -1814,7 +2089,7 @@ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
1814
2089
  function CatalogProducts({ className, children }) {
1815
2090
  const catalog = useCatalogInternal("CatalogProducts");
1816
2091
  const customEmpty = useContext(CatalogEmptyContext)?.custom ?? false;
1817
- const card = hasChildren(children) ? children : packagedCard;
2092
+ const card = hasChildren(children) ? children : packagedCard$1;
1818
2093
  const nodes = catalog.source === "live" ? catalog.nodes : null;
1819
2094
  const items = useMemo(() => nodes ? nodes.map((product) => ({
1820
2095
  key: product.id ?? "",
@@ -1827,16 +2102,16 @@ function CatalogProducts({ className, children }) {
1827
2102
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
1828
2103
  products?.isError && /* @__PURE__ */ jsxs("div", {
1829
2104
  role: "alert",
1830
- className: "rounded-lg bg-red-50 p-5 text-red-800",
2105
+ className: errorNoticeClass,
1831
2106
  children: [/* @__PURE__ */ jsx("p", { children: message(products.error) }), /* @__PURE__ */ jsx("button", {
1832
2107
  type: "button",
1833
- className: "min-h-11 bg-red-50 text-red-800 underline",
2108
+ className: errorActionClass,
1834
2109
  onClick: () => void products.refetch(),
1835
2110
  children: "Retry products"
1836
2111
  })]
1837
2112
  }),
1838
2113
  products?.isSuccess && !items.length && !customEmpty && /* @__PURE__ */ jsx("p", {
1839
- className: "py-16 text-center",
2114
+ className: "py-16 text-center text-commerce-muted",
1840
2115
  children: "No products available."
1841
2116
  }),
1842
2117
  /* @__PURE__ */ jsx("div", {
@@ -1847,15 +2122,15 @@ function CatalogProducts({ className, children }) {
1847
2122
  2
1848
2123
  ].map((placeholder) => /* @__PURE__ */ jsxs("div", {
1849
2124
  "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
- })]
2125
+ className: "motion-safe:animate-pulse",
2126
+ children: [
2127
+ /* @__PURE__ */ jsx("div", { className: "aspect-square bg-commerce-tint-strong" }),
2128
+ /* @__PURE__ */ jsxs("div", {
2129
+ className: "space-y-2 pt-3",
2130
+ children: [/* @__PURE__ */ jsx("div", { className: "h-4 w-2/3 rounded bg-commerce-tint-strong" }), /* @__PURE__ */ jsx("div", { className: "h-4 w-1/4 rounded bg-commerce-tint-strong" })]
2131
+ }),
2132
+ /* @__PURE__ */ jsx("div", { className: "mt-4 h-11 rounded-lg bg-commerce-tint" })
2133
+ ]
1859
2134
  }, placeholder)) : items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1860
2135
  value: item,
1861
2136
  children: card
@@ -1877,25 +2152,29 @@ function CatalogEmpty({ className, children }) {
1877
2152
  children: hasChildren(children) ? children : /* @__PURE__ */ jsx("p", { children: "No products available." })
1878
2153
  });
1879
2154
  }
1880
- /** First-page and next-page buttons. Renders nothing for samples, which have one page. */
2155
+ /**
2156
+ * First-page and next-page buttons. Renders nothing when there is only one page, including for samples.
2157
+ */
1881
2158
  function CatalogPagination({ className }) {
1882
2159
  const catalog = useCatalogInternal("CatalogPagination");
1883
2160
  if (catalog.source === "sample") return null;
1884
2161
  const { after, goToPage } = catalog;
1885
2162
  const pageInfo = catalog.products.data?.skuGroups?.pageInfo;
2163
+ const hasNext = !!(pageInfo?.hasNextPage && pageInfo.endCursor);
2164
+ if (!after && !hasNext) return null;
1886
2165
  return /* @__PURE__ */ jsx("nav", {
1887
2166
  "aria-label": "Product pages",
1888
2167
  ...partProps("catalog-pagination", className),
1889
2168
  children: /* @__PURE__ */ jsxs("div", {
1890
- className: "flex justify-between",
1891
- children: [after ? /* @__PURE__ */ jsx("button", {
2169
+ className: "flex justify-center gap-3",
2170
+ children: [after && /* @__PURE__ */ jsx("button", {
1892
2171
  type: "button",
1893
- className: buttonClass,
2172
+ className: secondaryButtonClass,
1894
2173
  onClick: () => goToPage(),
1895
2174
  children: "First page"
1896
- }) : /* @__PURE__ */ jsx("span", {}), pageInfo?.hasNextPage && pageInfo.endCursor && /* @__PURE__ */ jsx("button", {
2175
+ }), hasNext && pageInfo?.endCursor && /* @__PURE__ */ jsx("button", {
1897
2176
  type: "button",
1898
- className: buttonClass,
2177
+ className: secondaryButtonClass,
1899
2178
  onClick: () => goToPage(pageInfo.endCursor ?? void 0),
1900
2179
  children: "Next page"
1901
2180
  })]
@@ -1908,14 +2187,15 @@ function Catalog({ title, description, showHeader = true, sampleProducts: sample
1908
2187
  sampleProducts: sampleProducts$1,
1909
2188
  pageSize,
1910
2189
  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"
2190
+ /* @__PURE__ */ jsxs("div", {
2191
+ className: "flex flex-wrap items-end justify-between gap-x-4 gap-y-2 pb-6 pt-4 sm:pb-8 sm:pt-8",
2192
+ children: [showHeader && /* @__PURE__ */ jsx(CatalogHeader, {
2193
+ title,
2194
+ description
2195
+ }), /* @__PURE__ */ jsx(CatalogSummary, {})]
1915
2196
  }),
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" })
2197
+ /* @__PURE__ */ jsx(CatalogProducts, { className: catalogGridClass }),
2198
+ /* @__PURE__ */ jsx(CatalogPagination, { className: "mt-12 border-t border-commerce-line pt-6" })
1919
2199
  ]
1920
2200
  });
1921
2201
  }
@@ -1956,7 +2236,7 @@ function useProductDetails() {
1956
2236
  * A selection is never purchasable until the server verifies it.
1957
2237
  */
1958
2238
  function useProductDetailsOption(attribute) {
1959
- const { attributes, selections, selectOption, register } = useProductDetailsInternal("useProductDetailsOption");
2239
+ const { attributes, selections, selectOption, register, soldOutValues, showMissing, getSwatch } = useProductDetailsInternal("useProductDetailsOption");
1960
2240
  useEffect(() => register(`attribute:${attribute}`), [attribute, register]);
1961
2241
  const index = attributes.findIndex((candidate) => candidate.name === attribute);
1962
2242
  const match = attributes[index];
@@ -1966,10 +2246,17 @@ function useProductDetailsOption(attribute) {
1966
2246
  name: match.name,
1967
2247
  label: match.label,
1968
2248
  selected,
2249
+ missing: showMissing && !selected,
1969
2250
  values: match.values.map((value) => ({
1970
2251
  name: value.name,
1971
2252
  label: value.label,
1972
- selected: value.name === selected
2253
+ selected: value.name === selected,
2254
+ soldOut: soldOutValues[index]?.has(value.name) ?? false,
2255
+ swatch: getSwatch?.({
2256
+ attribute: match.name,
2257
+ name: value.name,
2258
+ label: value.label
2259
+ }) || null
1973
2260
  })),
1974
2261
  select: (value) => {
1975
2262
  if (value === null) selectOption(attribute, "");
@@ -2001,10 +2288,16 @@ function ProductDetailsRoot({ productId: productIdProp, className,...props }) {
2001
2288
  }) });
2002
2289
  }
2003
2290
  function ProductDetailsLoader({ productId, className, children, urlState = true, loading, notFound, error }) {
2004
- const { config } = useCommerce();
2291
+ const { config, getSwatch } = useCommerce();
2005
2292
  const [params, setParams] = useRootParams(urlState);
2006
2293
  const [quantity, setQuantity] = useState(1);
2007
2294
  const [imageIndex, setImageIndex] = useState(0);
2295
+ const [showMissing, setShowMissing] = useState(false);
2296
+ const [focusMissing, setFocusMissing] = useState(0);
2297
+ const root = useRef(null);
2298
+ useEffect(() => {
2299
+ if (focusMissing) focusFirstMissing(root);
2300
+ }, [focusMissing]);
2008
2301
  const product = useQuery({
2009
2302
  retry: false,
2010
2303
  enabled: !!productId,
@@ -2055,7 +2348,6 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2055
2348
  if (value$1) next.set(key, value$1);
2056
2349
  else next.delete(key);
2057
2350
  setParams(next);
2058
- setQuantity(1);
2059
2351
  setImageIndex(0);
2060
2352
  };
2061
2353
  const { register, has } = usePartRegistry();
@@ -2070,6 +2362,7 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2070
2362
  ...partProps("product-details-root", className),
2071
2363
  children: hasChildren(loading) ? loading : /* @__PURE__ */ jsx("p", {
2072
2364
  role: "status",
2365
+ className: "text-sm text-commerce-muted",
2073
2366
  children: "Loading product…"
2074
2367
  })
2075
2368
  });
@@ -2083,11 +2376,11 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2083
2376
  retry
2084
2377
  }, /* @__PURE__ */ jsxs(Fragment$1, { children: [
2085
2378
  /* @__PURE__ */ jsx("h1", {
2086
- className: "text-2xl font-semibold",
2379
+ className: "text-2xl font-semibold tracking-tight",
2087
2380
  children: "Product unavailable"
2088
2381
  }),
2089
2382
  /* @__PURE__ */ jsx("p", {
2090
- className: "my-4 text-red-700",
2383
+ className: `my-4 ${errorNoticeClass}`,
2091
2384
  children: message(product.error)
2092
2385
  }),
2093
2386
  /* @__PURE__ */ jsx("button", {
@@ -2102,10 +2395,10 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2102
2395
  if (productNotFound || !group) return /* @__PURE__ */ jsx("div", {
2103
2396
  ...partProps("product-details-root", className),
2104
2397
  children: hasChildren(notFound) ? notFound : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("h1", {
2105
- className: "text-2xl font-semibold",
2398
+ className: "text-2xl font-semibold tracking-tight",
2106
2399
  children: "Product not found"
2107
2400
  }), /* @__PURE__ */ jsx(Link, {
2108
- className: `${buttonClass} mt-4`,
2401
+ className: `${buttonClass} mt-6`,
2109
2402
  to: config.catalogPath,
2110
2403
  children: "Back to shop"
2111
2404
  })] })
@@ -2134,16 +2427,50 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2134
2427
  retryVariant: () => {
2135
2428
  if (matched.isError) matched.refetch();
2136
2429
  },
2137
- register
2430
+ register,
2431
+ soldOutValues: getSoldOutValues(group, attributes, selections),
2432
+ showMissing,
2433
+ requestMissing: () => {
2434
+ setShowMissing(true);
2435
+ setFocusMissing((count) => count + 1);
2436
+ },
2437
+ getSwatch
2138
2438
  };
2139
2439
  return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
2140
2440
  value,
2141
2441
  children: /* @__PURE__ */ jsx("article", {
2442
+ ref: root,
2142
2443
  ...partProps("product-details-root", className),
2143
2444
  children
2144
2445
  })
2145
2446
  });
2146
2447
  }
2448
+ function focusFirstMissing(root) {
2449
+ root.current?.querySelector("[data-commerce-missing] input:not(:disabled), [data-commerce-missing] select")?.focus();
2450
+ }
2451
+ /**
2452
+ * For each attribute, the values whose every SKU, among those with the other selected values, has tracked stock
2453
+ * used up. Needs every SKU with its attribute values, so it is empty for a truncated list or an older server.
2454
+ */
2455
+ function getSoldOutValues(group, attributes, selections) {
2456
+ const connection = group?.skus;
2457
+ const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
2458
+ const values = skus.map(getSkuAttributeValueNames);
2459
+ const complete = !connection?.pageInfo?.hasNextPage && (connection?.totalCount ?? skus.length) === skus.length && values.every((names) => names !== null);
2460
+ return attributes.map((attribute, index) => {
2461
+ const soldOut = /* @__PURE__ */ new Set();
2462
+ if (!complete) return soldOut;
2463
+ const others = selections.filter((selection, other) => other !== index && selection);
2464
+ for (const value of attribute.values) {
2465
+ const matching = skus.filter((_, sku) => {
2466
+ const names = values[sku] ?? [];
2467
+ return names.includes(value.name) && others.every((selection) => names.includes(selection));
2468
+ });
2469
+ if (matching.length && matching.every((sku) => isSoldOut(getAvailableInventoryQuantity(sku)))) soldOut.add(value.name);
2470
+ }
2471
+ return soldOut;
2472
+ });
2473
+ }
2147
2474
 
2148
2475
  //#endregion
2149
2476
  //#region src/product-purchase.tsx
@@ -2154,7 +2481,9 @@ const disabledReasons = {
2154
2481
  unavailable: "This combination is unavailable. Choose different options.",
2155
2482
  "not-purchasable": "This product is not currently available to purchase."
2156
2483
  };
2157
- /** The packaged purchase area: options, status, availability, quantity, and add to cart. */
2484
+ /** Above this many values, buttons wrap into a wall, so the option is a select. */
2485
+ const maxChoiceButtons = 8;
2486
+ /** The packaged purchase area: options, status, quantity, add to cart, and availability. */
2158
2487
  function ProductDetailsPurchase({ className }) {
2159
2488
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsPurchase");
2160
2489
  const belowOptions = attributes.length || skuOptions.length ? "-mt-4" : void 0;
@@ -2163,13 +2492,15 @@ function ProductDetailsPurchase({ className }) {
2163
2492
  children: [
2164
2493
  /* @__PURE__ */ jsx(OptionControls, {}),
2165
2494
  /* @__PURE__ */ jsx(ProductDetailsPurchaseStatus, { className: belowOptions }),
2166
- /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: belowOptions }),
2167
- /* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
2168
- /* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
2495
+ /* @__PURE__ */ jsxs("div", {
2496
+ className: "flex items-start gap-3",
2497
+ children: [/* @__PURE__ */ jsx(ProductDetailsQuantity, {}), /* @__PURE__ */ jsx(ProductDetailsAddToCart, { className: "min-w-0 flex-1" })]
2498
+ }),
2499
+ /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: "-mt-3" })
2169
2500
  ]
2170
2501
  });
2171
2502
  }
2172
- /** A select for every product attribute, or the SKU list for products without attributes. */
2503
+ /** A control for every product attribute, or the SKU list for products without attributes. */
2173
2504
  function ProductDetailsOptions({ className }) {
2174
2505
  const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
2175
2506
  if (attributes.length === 0 && skuOptions.length === 0) return null;
@@ -2204,67 +2535,155 @@ function Select({ children,...props }) {
2204
2535
  })]
2205
2536
  });
2206
2537
  }
2538
+ const choiceButtonClass = "inline-flex min-h-11 min-w-12 items-center justify-center rounded-lg border border-commerce-edge px-4 text-sm font-medium transition-colors group-hover:border-current peer-checked:border-commerce-accent peer-checked:bg-commerce-accent peer-checked:text-commerce-on-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-commerce-accent";
2539
+ const swatchClass = "relative block h-9 w-9 overflow-hidden rounded-full border border-commerce-edge peer-checked:outline peer-checked:outline-2 peer-checked:outline-offset-2 peer-checked:outline-commerce-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-current";
2540
+ /**
2541
+ * One option as buttons (radios), swatches when every value has one, or a select for long lists. Sold-out values are
2542
+ * crossed out but stay selectable, so the shopper can change any option in any order.
2543
+ */
2544
+ function ChoiceGroup({ label, selected, choices, missing, onSelect }) {
2545
+ const name = useId();
2546
+ const errorId = useId();
2547
+ const error = missing && /* @__PURE__ */ jsxs("p", {
2548
+ id: errorId,
2549
+ className: "mt-2 inline-block rounded bg-red-50 px-2 py-1 text-sm text-red-800",
2550
+ children: [
2551
+ "Select ",
2552
+ inSentence(label),
2553
+ "."
2554
+ ]
2555
+ });
2556
+ if (choices.length > maxChoiceButtons) return /* @__PURE__ */ jsxs("fieldset", { children: [
2557
+ /* @__PURE__ */ jsx("legend", {
2558
+ className: "mb-2 text-sm font-medium",
2559
+ children: label
2560
+ }),
2561
+ /* @__PURE__ */ jsxs(Select, {
2562
+ "aria-label": label,
2563
+ "aria-invalid": missing || void 0,
2564
+ "aria-describedby": missing ? errorId : void 0,
2565
+ value: selected,
2566
+ onChange: (event) => onSelect(event.target.value),
2567
+ children: [/* @__PURE__ */ jsxs("option", {
2568
+ value: "",
2569
+ children: ["Select ", inSentence(label)]
2570
+ }), choices.map((choice) => /* @__PURE__ */ jsx("option", {
2571
+ value: choice.value,
2572
+ children: choice.soldOut ? `${choice.label} (sold out)` : choice.label
2573
+ }, choice.value))]
2574
+ }),
2575
+ error
2576
+ ] });
2577
+ const swatches = choices.every((choice) => choice.swatch);
2578
+ const selectedLabel = choices.find((choice) => choice.value === selected)?.label;
2579
+ return /* @__PURE__ */ jsxs("fieldset", {
2580
+ "aria-describedby": missing ? errorId : void 0,
2581
+ children: [
2582
+ /* @__PURE__ */ jsxs("legend", {
2583
+ className: "mb-2 flex gap-2 text-sm font-medium",
2584
+ children: [label, selectedLabel && /* @__PURE__ */ jsx("span", {
2585
+ className: "font-normal text-commerce-muted",
2586
+ children: selectedLabel
2587
+ })]
2588
+ }),
2589
+ /* @__PURE__ */ jsx("div", {
2590
+ className: `flex flex-wrap ${swatches ? "gap-3" : "gap-2"}`,
2591
+ children: choices.map((choice) => /* @__PURE__ */ jsxs("label", {
2592
+ className: "group relative cursor-pointer",
2593
+ title: swatches ? `${choice.label}${choice.soldOut ? ", sold out" : ""}` : void 0,
2594
+ children: [/* @__PURE__ */ jsx("input", {
2595
+ type: "radio",
2596
+ className: "peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0",
2597
+ name,
2598
+ value: choice.value,
2599
+ checked: choice.value === selected,
2600
+ "aria-invalid": missing || void 0,
2601
+ "aria-label": choice.soldOut ? `${choice.label}, sold out` : void 0,
2602
+ "data-commerce-control": "choice",
2603
+ onChange: () => onSelect(choice.value)
2604
+ }), swatches ? /* @__PURE__ */ jsxs("span", {
2605
+ className: `${swatchClass} ${choice.soldOut ? "opacity-50" : ""}`,
2606
+ style: { background: choice.swatch ?? void 0 },
2607
+ children: [choice.soldOut && /* @__PURE__ */ jsx("span", {
2608
+ "aria-hidden": "true",
2609
+ className: "absolute left-1/2 top-0 h-full w-0.5 -translate-x-1/2 rotate-45 bg-current"
2610
+ }), /* @__PURE__ */ jsx("span", {
2611
+ className: "sr-only",
2612
+ children: choice.label
2613
+ })]
2614
+ }) : /* @__PURE__ */ jsx("span", {
2615
+ className: `${choiceButtonClass} ${choice.soldOut ? "text-commerce-muted line-through decoration-1" : ""}`,
2616
+ children: choice.label
2617
+ })]
2618
+ }, choice.value))
2619
+ }),
2620
+ error
2621
+ ]
2622
+ });
2623
+ }
2207
2624
  function SkuOption() {
2208
- const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
2209
- const id = useId();
2625
+ const { skuOptions, explicitSkuId, selectSku, showMissing } = useProductDetailsInternal("ProductDetailsOptions");
2210
2626
  useSkuOptionRegistration();
2211
- return /* @__PURE__ */ jsxs("div", {
2627
+ const missing = showMissing && !explicitSkuId;
2628
+ return /* @__PURE__ */ jsx("div", {
2212
2629
  ...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", {
2630
+ "data-commerce-missing": missing || void 0,
2631
+ children: /* @__PURE__ */ jsx(ChoiceGroup, {
2632
+ label: "Option",
2633
+ selected: explicitSkuId,
2634
+ missing,
2635
+ onSelect: selectSku,
2636
+ choices: skuOptions.map((sku) => ({
2225
2637
  value: sku.id ?? "",
2226
- children: sku.label?.trim() || sku.name?.trim()
2227
- }, sku.id))]
2228
- })]
2638
+ label: sku.label?.trim() || sku.name?.trim() || "",
2639
+ soldOut: isSoldOut(getAvailableInventoryQuantity(sku)),
2640
+ swatch: null
2641
+ }))
2642
+ })
2229
2643
  });
2230
2644
  }
2231
- /** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
2645
+ /**
2646
+ * One product attribute: buttons, swatches when the provider's `getSwatch` gives every value one, or a select for
2647
+ * more than eight values. Use `useProductDetailsOption` for other custom controls.
2648
+ */
2232
2649
  function ProductDetailsOption({ attribute, className }) {
2233
2650
  const option = useProductDetailsOption(attribute);
2234
2651
  if (!option) return null;
2235
2652
  return /* @__PURE__ */ jsx("div", {
2236
2653
  ...partProps("product-details-option", className),
2237
2654
  "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", {
2655
+ "data-commerce-missing": option.missing || void 0,
2656
+ children: /* @__PURE__ */ jsx(ChoiceGroup, {
2657
+ label: option.label,
2658
+ selected: option.selected ?? "",
2659
+ missing: option.missing,
2660
+ onSelect: (value) => option.select(value || null),
2661
+ choices: option.values.map((value) => ({
2249
2662
  value: value.name,
2250
- children: value.label
2251
- }, value.name))]
2252
- })] })
2663
+ label: value.label,
2664
+ soldOut: value.soldOut,
2665
+ swatch: value.swatch
2666
+ }))
2667
+ })
2253
2668
  });
2254
2669
  }
2255
- /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
2670
+ /**
2671
+ * The single message explaining why the selection is not purchasable. Renders nothing when ready, or before every
2672
+ * option is chosen, when the add button names the next one.
2673
+ */
2256
2674
  function ProductDetailsPurchaseStatus({ className }) {
2257
2675
  const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
2258
2676
  let content;
2259
2677
  switch (status) {
2260
- case "ready": return null;
2678
+ case "ready":
2679
+ case "incomplete": return null;
2261
2680
  case "variant-error":
2262
2681
  content = /* @__PURE__ */ jsxs("div", {
2263
2682
  role: "alert",
2264
- className: "text-red-700",
2683
+ className: errorNoticeClass,
2265
2684
  children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
2266
2685
  type: "button",
2267
- className: "min-h-11 bg-white text-red-700 underline",
2686
+ className: errorActionClass,
2268
2687
  onClick: retryVariant,
2269
2688
  children: "Retry availability"
2270
2689
  })]
@@ -2273,21 +2692,20 @@ function ProductDetailsPurchaseStatus({ className }) {
2273
2692
  case "unavailable":
2274
2693
  content = /* @__PURE__ */ jsx("p", {
2275
2694
  role: "status",
2276
- className: "text-sm text-red-700",
2695
+ className: errorNoticeClass,
2277
2696
  children: disabledReasons[status]
2278
2697
  });
2279
2698
  break;
2280
2699
  case "not-purchasable":
2281
2700
  content = /* @__PURE__ */ jsx("p", {
2282
- className: "text-sm",
2701
+ className: "text-sm text-commerce-muted",
2283
2702
  children: disabledReasons[status]
2284
2703
  });
2285
2704
  break;
2286
- case "incomplete":
2287
2705
  case "checking":
2288
2706
  content = /* @__PURE__ */ jsx("p", {
2289
2707
  role: "status",
2290
- className: "text-sm",
2708
+ className: "text-sm text-commerce-muted",
2291
2709
  children: disabledReasons[status]
2292
2710
  });
2293
2711
  break;
@@ -2304,59 +2722,137 @@ function ProductDetailsAvailability({ className }) {
2304
2722
  return /* @__PURE__ */ jsx("div", {
2305
2723
  ...partProps("product-details-availability", className),
2306
2724
  children: /* @__PURE__ */ jsx("p", {
2307
- className: "text-sm",
2725
+ className: "text-sm text-commerce-muted",
2308
2726
  "aria-live": "polite",
2309
2727
  children: availableQuantity === null ? "Available to order" : isSoldOut(availableQuantity) ? "This variant is out of stock" : `${availableQuantity} available`
2310
2728
  })
2311
2729
  });
2312
2730
  }
2313
- /** Quantity for the verified SKU, with a message when it cannot be added. Renders nothing until a SKU is verified. */
2731
+ const stepperButtonClass = "inline-flex min-h-11 min-w-10 items-center justify-center rounded-lg transition-colors hover:bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:cursor-not-allowed disabled:text-commerce-muted disabled:hover:bg-transparent";
2732
+ function StepperIcon({ plus }) {
2733
+ return /* @__PURE__ */ jsx("svg", {
2734
+ "aria-hidden": "true",
2735
+ viewBox: "0 0 16 16",
2736
+ className: "h-4 w-4",
2737
+ children: /* @__PURE__ */ jsx("path", {
2738
+ d: plus ? "M3.5 8h9M8 3.5v9" : "M3.5 8h9",
2739
+ fill: "none",
2740
+ stroke: "currentColor",
2741
+ strokeWidth: "1.5",
2742
+ strokeLinecap: "round"
2743
+ })
2744
+ });
2745
+ }
2746
+ /**
2747
+ * A quantity stepper, capped at the verified SKU's stock, with a message when a typed quantity cannot be added.
2748
+ * Disabled while the verified SKU is sold out.
2749
+ */
2314
2750
  function ProductDetailsQuantity({ className }) {
2315
- const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
2751
+ const { selectedSku, availableQuantity, quantity, setQuantity, name } = useProductDetailsInternal("ProductDetailsQuantity");
2316
2752
  const id = useId();
2317
2753
  const problemId = useId();
2318
- if (!selectedSku) return null;
2319
- const problem = isSoldOut(availableQuantity) ? null : quantityProblem(quantity, availableQuantity);
2754
+ const soldOut = !!selectedSku && isSoldOut(availableQuantity);
2755
+ const max = selectedSku && availableQuantity !== null && availableQuantity > 0 ? availableQuantity : null;
2756
+ const problem = soldOut ? null : quantityProblem(quantity, max);
2757
+ const current = Number.isInteger(quantity) ? quantity : 1;
2320
2758
  return /* @__PURE__ */ jsxs("div", {
2321
2759
  ...partProps("product-details-quantity", className),
2322
2760
  children: [
2323
2761
  /* @__PURE__ */ jsx("label", {
2324
2762
  htmlFor: id,
2325
- className: "mb-2 block font-medium",
2763
+ className: "sr-only",
2326
2764
  children: "Quantity"
2327
2765
  }),
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)
2766
+ /* @__PURE__ */ jsxs("div", {
2767
+ className: "inline-flex items-center rounded-lg border border-commerce-edge",
2768
+ children: [
2769
+ /* @__PURE__ */ jsx("button", {
2770
+ type: "button",
2771
+ className: stepperButtonClass,
2772
+ "aria-label": `Decrease quantity of ${name}`,
2773
+ "data-commerce-control": "decrease",
2774
+ disabled: soldOut || current <= 1,
2775
+ onClick: () => setQuantity(current - 1),
2776
+ children: /* @__PURE__ */ jsx(StepperIcon, {})
2777
+ }),
2778
+ /* @__PURE__ */ jsx("input", {
2779
+ id,
2780
+ type: "number",
2781
+ min: 1,
2782
+ max: max ?? void 0,
2783
+ step: 1,
2784
+ disabled: soldOut,
2785
+ className: "min-h-11 w-10 appearance-none bg-transparent text-center text-sm tabular-nums [-moz-appearance:textfield] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current disabled:text-commerce-muted [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
2786
+ value: Number.isNaN(quantity) ? "" : quantity,
2787
+ "aria-invalid": problem ? true : void 0,
2788
+ "aria-describedby": problem ? problemId : void 0,
2789
+ onChange: (event) => setQuantity(event.target.valueAsNumber)
2790
+ }),
2791
+ /* @__PURE__ */ jsx("button", {
2792
+ type: "button",
2793
+ className: stepperButtonClass,
2794
+ "aria-label": `Increase quantity of ${name}`,
2795
+ "data-commerce-control": "increase",
2796
+ disabled: soldOut || max !== null && current >= max,
2797
+ onClick: () => setQuantity(current + 1),
2798
+ children: /* @__PURE__ */ jsx(StepperIcon, { plus: true })
2799
+ })
2800
+ ]
2339
2801
  }),
2340
2802
  problem && /* @__PURE__ */ jsx("p", {
2341
2803
  id: problemId,
2342
- className: "mt-2 text-sm text-red-700",
2804
+ className: `mt-2 ${errorNoticeClass}`,
2343
2805
  children: problem
2344
2806
  })
2345
2807
  ]
2346
2808
  });
2347
2809
  }
2348
- /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
2810
+ /**
2811
+ * Adds the verified SKU and quantity, labeled with the total, such as "Add to cart · $64.00". Until every option is
2812
+ * chosen it names the next one, such as "Select size", and pressing it marks the missing options and focuses the
2813
+ * first. While the selection is checked or unavailable it is disabled, and its accessible description says why.
2814
+ */
2349
2815
  function ProductDetailsAddToCart({ className, children }) {
2350
- const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
2816
+ const { selectedSku, name, quantity, status, price, attributes, selections, requestMissing } = useProductDetailsInternal("ProductDetailsAddToCart");
2817
+ const { config } = useCommerce();
2818
+ const money$1 = useMoney();
2351
2819
  const reasonId = useId();
2352
- return /* @__PURE__ */ jsx("div", {
2820
+ if (selectedSku) {
2821
+ const unit = price?.value?.value;
2822
+ const total = unit != null ? money$1(unit * (Number.isInteger(quantity) && quantity > 0 ? quantity : 1), price?.value?.currencyCode ?? config.currencyCode) : null;
2823
+ return /* @__PURE__ */ jsx("div", {
2824
+ ...partProps("product-details-add-to-cart", className),
2825
+ children: /* @__PURE__ */ jsx(AddToCart, {
2826
+ sku: selectedSku,
2827
+ name,
2828
+ quantity,
2829
+ className: buttonClass,
2830
+ block: true,
2831
+ label: total && `Add to cart · ${total}`,
2832
+ labelName: total ? `Add to cart, ${total}` : void 0,
2833
+ children
2834
+ })
2835
+ });
2836
+ }
2837
+ if (status === "incomplete") {
2838
+ const next = attributes.find((_, index) => !selections[index])?.label;
2839
+ return /* @__PURE__ */ jsx("div", {
2840
+ ...partProps("product-details-add-to-cart", className),
2841
+ children: /* @__PURE__ */ jsx("button", {
2842
+ type: "button",
2843
+ className: `${buttonClass} w-full`,
2844
+ "data-commerce-part": "add-to-cart-button",
2845
+ onClick: requestMissing,
2846
+ children: renderLabel(children, {
2847
+ busy: false,
2848
+ soldOut: false
2849
+ }, next ? `Select ${inSentence(next)}` : "Select an option")
2850
+ })
2851
+ });
2852
+ }
2853
+ return /* @__PURE__ */ jsxs("div", {
2353
2854
  ...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", {
2855
+ children: [/* @__PURE__ */ jsx("button", {
2360
2856
  type: "button",
2361
2857
  className: `${buttonClass} w-full`,
2362
2858
  disabled: true,
@@ -2370,7 +2866,7 @@ function ProductDetailsAddToCart({ className, children }) {
2370
2866
  id: reasonId,
2371
2867
  hidden: true,
2372
2868
  children: status === "ready" ? "" : disabledReasons[status]
2373
- })] })
2869
+ })]
2374
2870
  });
2375
2871
  }
2376
2872
 
@@ -2378,15 +2874,18 @@ function ProductDetailsAddToCart({ className, children }) {
2378
2874
  //#region src/product-details.tsx
2379
2875
  /** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
2380
2876
  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",
2877
+ return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-4 sm:mb-6" }), /* @__PURE__ */ jsxs("div", {
2878
+ className: "grid items-start gap-8 md:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] lg:gap-12",
2879
+ children: [/* @__PURE__ */ jsxs("div", {
2880
+ className: "relative min-w-0",
2881
+ children: [/* @__PURE__ */ jsx(ProductDetailsGallery, {}), /* @__PURE__ */ jsx(ProductDetailsBadge, { className: "pointer-events-none absolute left-4 top-4" })]
2882
+ }), /* @__PURE__ */ jsxs("div", {
2883
+ className: "min-w-0 md:pt-2",
2385
2884
  children: [
2386
2885
  /* @__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" })
2886
+ /* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-2" }),
2887
+ /* @__PURE__ */ jsx(ProductDetailsDescription, { className: "mt-6" }),
2888
+ /* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "mt-6 border-t border-commerce-line pt-6" })
2390
2889
  ]
2391
2890
  })]
2392
2891
  })] });
@@ -2398,9 +2897,21 @@ function ProductDetailsBackLink({ className, children }) {
2398
2897
  return /* @__PURE__ */ jsx("div", {
2399
2898
  ...partProps("product-details-back-link", className),
2400
2899
  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",
2900
+ className: "inline-flex min-h-11 items-center gap-1 text-sm text-commerce-muted underline-offset-4 transition-colors hover:text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-current",
2402
2901
  to: config.catalogPath,
2403
- children: renderLabel(children, void 0, "← Back to shop")
2902
+ children: renderLabel(children, void 0, /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("svg", {
2903
+ "aria-hidden": "true",
2904
+ viewBox: "0 0 16 16",
2905
+ className: "h-3.5 w-3.5",
2906
+ children: /* @__PURE__ */ jsx("path", {
2907
+ d: "M10 4L6 8l4 4",
2908
+ fill: "none",
2909
+ stroke: "currentColor",
2910
+ strokeWidth: "1.5",
2911
+ strokeLinecap: "round",
2912
+ strokeLinejoin: "round"
2913
+ })
2914
+ }), "Back to shop"] }))
2404
2915
  })
2405
2916
  });
2406
2917
  }
@@ -2410,16 +2921,16 @@ function ProductDetailsGallery({ className }) {
2410
2921
  return /* @__PURE__ */ jsxs("div", {
2411
2922
  ...partProps("product-details-gallery", className),
2412
2923
  children: [/* @__PURE__ */ jsx("div", {
2413
- className: "aspect-square overflow-hidden rounded-lg",
2924
+ className: "aspect-square overflow-hidden bg-commerce-tint",
2414
2925
  children: /* @__PURE__ */ jsx(ProductImage, {
2415
2926
  url: images[imageIndex],
2416
2927
  name
2417
2928
  }, images[imageIndex])
2418
2929
  }), images.length > 1 && /* @__PURE__ */ jsx("div", {
2419
- className: "mt-4 flex gap-3 overflow-x-auto",
2930
+ className: "mt-3 flex gap-3 overflow-x-auto p-1",
2420
2931
  children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
2421
2932
  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"}`,
2933
+ className: `h-20 w-20 shrink-0 overflow-hidden bg-commerce-tint outline-offset-2 transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-current ${imageIndex === index ? "outline outline-2 outline-current" : "opacity-60 hover:opacity-100"}`,
2423
2934
  "aria-label": `View image ${index + 1}`,
2424
2935
  "aria-pressed": imageIndex === index,
2425
2936
  "data-commerce-control": "thumbnail",
@@ -2432,32 +2943,59 @@ function ProductDetailsGallery({ className }) {
2432
2943
  })]
2433
2944
  });
2434
2945
  }
2946
+ /**
2947
+ * "Sale" when the verified SKU's compare-at price is above its price, or, before then, when some SKU's is. Renders
2948
+ * nothing otherwise.
2949
+ */
2950
+ function ProductDetailsBadge({ className }) {
2951
+ const { price, product } = useProductDetailsInternal("ProductDetailsBadge");
2952
+ if (!(price ? isOnSale(price.value?.value, price.compareAtValue?.value) : isProductOnSale(product))) return null;
2953
+ return /* @__PURE__ */ jsx("div", {
2954
+ ...partProps("product-details-badge", className),
2955
+ children: /* @__PURE__ */ jsx("span", {
2956
+ className: saleBadgeClass,
2957
+ children: "Sale"
2958
+ })
2959
+ });
2960
+ }
2435
2961
  /** The product name as the page's H1. */
2436
2962
  function ProductDetailsTitle({ className }) {
2437
2963
  const { name } = useProductDetailsInternal("ProductDetailsTitle");
2438
2964
  return /* @__PURE__ */ jsx("div", {
2439
2965
  ...partProps("product-details-title", className),
2440
2966
  children: /* @__PURE__ */ jsx("h1", {
2441
- className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
2967
+ className: "text-balance break-words text-3xl font-semibold leading-tight tracking-tight sm:text-4xl",
2442
2968
  children: name
2443
2969
  })
2444
2970
  });
2445
2971
  }
2446
- /** The verified SKU's price and compare-at price, or the product's starting price. */
2972
+ /**
2973
+ * The verified SKU's price and compare-at price, or the product's starting price. Before a SKU is verified, the
2974
+ * compare-at price shows only when it belongs to the cheapest SKUs, so it never pairs prices from different SKUs.
2975
+ */
2447
2976
  function ProductDetailsPrice({ className }) {
2448
2977
  const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
2449
2978
  const { config } = useCommerce();
2450
2979
  const money$1 = useMoney();
2980
+ let compareAt = null;
2981
+ if (price?.compareAtValue?.value != null && isOnSale(price.value?.value, price.compareAtValue.value)) compareAt = money$1(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode);
2982
+ else if (!price) {
2983
+ const starting = getStartingCompareAt(product);
2984
+ if (starting !== null) compareAt = money$1(starting);
2985
+ }
2451
2986
  return /* @__PURE__ */ jsx("div", {
2452
2987
  ...partProps("product-details-price", className),
2453
2988
  children: /* @__PURE__ */ jsxs("div", {
2454
2989
  "aria-live": "polite",
2455
- className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
2990
+ className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xl font-medium tabular-nums",
2456
2991
  "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
- })]
2992
+ children: [price?.value?.value != null ? money$1(price.value.value, price.value.currencyCode ?? config.currencyCode) : product.priceRange?.min != null ? `From ${money$1(product.priceRange.min)}` : "Price unavailable", compareAt && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
2993
+ className: "sr-only",
2994
+ children: "Was"
2995
+ }), /* @__PURE__ */ jsx("del", {
2996
+ className: "text-base font-normal text-commerce-muted",
2997
+ children: compareAt
2998
+ })] })]
2461
2999
  })
2462
3000
  });
2463
3001
  }
@@ -2467,11 +3005,72 @@ function ProductDetailsDescription({ className }) {
2467
3005
  return /* @__PURE__ */ jsx("div", {
2468
3006
  ...partProps("product-details-description", className),
2469
3007
  children: /* @__PURE__ */ jsx("p", {
2470
- className: "whitespace-pre-line text-base leading-7",
3008
+ className: "whitespace-pre-line text-base leading-7 text-commerce-muted",
2471
3009
  children: selectedSku?.description ?? product.description
2472
3010
  })
2473
3011
  });
2474
3012
  }
2475
3013
 
2476
3014
  //#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 };
3015
+ //#region src/related-products.tsx
3016
+ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
3017
+ className: packagedCardClass,
3018
+ children: /* @__PURE__ */ jsx(PackagedCardParts, {})
3019
+ });
3020
+ /**
3021
+ * Other products from the catalog's first page, leaving out the current one. Optional and separate from
3022
+ * `ProductDetails`: place it wherever it fits, such as after the product page. There is no related-products API, so
3023
+ * the products are the catalog's first ones. Renders nothing until they load, when none are left, if the request
3024
+ * fails, or while no store is connected.
3025
+ */
3026
+ function RelatedProducts({ productId: productIdProp, title = "You may also like", limit = 4, className, children }) {
3027
+ const { productId: routeProductId = "" } = useParams();
3028
+ const productId = productIdProp ?? routeProductId;
3029
+ const { config, connection } = useCommerce();
3030
+ const headingId = useId();
3031
+ const first = limit + 1;
3032
+ const products = useQuery({
3033
+ retry: false,
3034
+ enabled: connection === "ready",
3035
+ queryKey: [
3036
+ "commerce",
3037
+ config.cartScope,
3038
+ "products",
3039
+ first,
3040
+ ""
3041
+ ],
3042
+ queryFn: ({ signal }) => request(`/products?first=${first}`, {
3043
+ signal,
3044
+ headers: { "X-Commerce-Scope": config.cartScope }
3045
+ })
3046
+ });
3047
+ const related = useMemo(() => (products.data?.skuGroups?.edges ?? []).flatMap((edge) => edge?.node?.id && edge.node.id !== productId ? [edge.node] : []).slice(0, limit), [
3048
+ products.data,
3049
+ productId,
3050
+ limit
3051
+ ]);
3052
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 24) throw new Error(`RelatedProducts limit must be a whole number from 1 to 24, not ${limit}.`);
3053
+ if (connection !== "ready" || !related.length) return null;
3054
+ const card = hasChildren(children) ? children : packagedCard;
3055
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: /* @__PURE__ */ jsxs("section", {
3056
+ "aria-labelledby": headingId,
3057
+ ...partProps("related-products", className),
3058
+ children: [/* @__PURE__ */ jsx("h2", {
3059
+ id: headingId,
3060
+ className: "mb-6 text-xl font-semibold tracking-tight",
3061
+ children: title
3062
+ }), /* @__PURE__ */ jsx("div", {
3063
+ className: catalogGridClass,
3064
+ children: related.map((product) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
3065
+ value: {
3066
+ source: "live",
3067
+ product
3068
+ },
3069
+ children: card
3070
+ }, product.id))
3071
+ })]
3072
+ }) });
3073
+ }
3074
+
3075
+ //#endregion
3076
+ export { AddToCartButton, ApiError, CartButton, CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDrawer, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartRoot, CartSubtotal, CartSummary, CartTitle, Catalog, CatalogEmpty, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardBadge, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardSwatches, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsBadge, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, ProductImage, RelatedProducts, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCart, useCartItem, useCommerce, useMoney, useProductCard, useProductDetails, useProductDetailsOption };