@godaddy/gd-commerce-storefront 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as Dialog from "@radix-ui/react-dialog";
2
- import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
2
+ import { Children, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
3
3
  import { Link, useParams, useSearchParams } from "react-router";
4
4
  import { useQuery } from "@tanstack/react-query";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -122,8 +122,9 @@ function getAvailableInventoryQuantity(item) {
122
122
  if (!edges || edges.length === 0) return null;
123
123
  return edges.find((edge) => edge?.node?.type === "AVAILABLE")?.node?.quantity ?? 0;
124
124
  }
125
+ const imageMediaTypes = new Set(["IMAGE", "PHOTO"]);
125
126
  function getImageUrls(item) {
126
- return item?.mediaObjects?.edges?.filter((edge) => edge?.node?.type === "IMAGE" && edge.node.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
127
+ return item?.mediaObjects?.edges?.filter((edge) => imageMediaTypes.has(edge?.node?.type ?? "") && edge?.node?.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
127
128
  }
128
129
  function getPrimaryImageUrl(item) {
129
130
  return getImageUrls(item)[0] ?? null;
@@ -147,6 +148,10 @@ function getProductAttributes(skuGroup) {
147
148
  };
148
149
  }) ?? [];
149
150
  }
151
+ /** The product's display name: its label, then its name. */
152
+ function getProductName(skuGroup) {
153
+ return skuGroup.label ?? skuGroup.name ?? "Product";
154
+ }
150
155
 
151
156
  //#endregion
152
157
  //#region src/sample-cart.tsx
@@ -651,9 +656,9 @@ function CartDrawer() {
651
656
  subtotal: item.totals?.subTotal?.value,
652
657
  currency: item.totals?.subTotal?.currencyCode
653
658
  }));
654
- const summary = getCartSummaryTotals(cart);
659
+ const summary$1 = getCartSummaryTotals(cart);
655
660
  const currency = isSample ? "USD" : cart?.totals?.total?.currencyCode ?? config.currencyCode;
656
- const subtotal = isSample ? sample.items.reduce((sum, item) => sum + item.price * item.quantity, 0) : summary.subtotal;
661
+ const subtotal = isSample ? sample.items.reduce((sum, item) => sum + item.price * item.quantity, 0) : summary$1.subtotal;
657
662
  async function handleAction(action, operation) {
658
663
  if (locked) return;
659
664
  setDrawerAction(action);
@@ -849,7 +854,35 @@ function CartDrawer() {
849
854
  }
850
855
 
851
856
  //#endregion
852
- //#region src/catalog.tsx
857
+ //#region src/part.ts
858
+ const cx = (...classes) => classes.filter(Boolean).join(" ");
859
+ /** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
860
+ const partClass = (...classes) => cx("commerce-part", ...classes);
861
+ /**
862
+ * Counts the parts mounted under a root, by key. Parts register in an effect, and a parent's effects run after its
863
+ * children's, so the root can read `has` in its own effect. `onChange` must be stable; it runs after each change.
864
+ */
865
+ function usePartRegistry(onChange) {
866
+ const counts = useRef(/* @__PURE__ */ new Map());
867
+ const has = useCallback((key) => counts.current.has(key), []);
868
+ const register = useCallback((key) => {
869
+ counts.current.set(key, (counts.current.get(key) ?? 0) + 1);
870
+ onChange?.(has);
871
+ return () => {
872
+ const count = (counts.current.get(key) ?? 1) - 1;
873
+ if (count > 0) counts.current.set(key, count);
874
+ else counts.current.delete(key);
875
+ onChange?.(has);
876
+ };
877
+ }, [has, onChange]);
878
+ return useMemo(() => ({
879
+ register,
880
+ has
881
+ }), [register, has]);
882
+ }
883
+
884
+ //#endregion
885
+ //#region src/product-card.tsx
853
886
  function ProductImage({ url, name, className = "" }) {
854
887
  const [failed, setFailed] = useState(false);
855
888
  return url && !failed ? /* @__PURE__ */ jsx("img", {
@@ -865,74 +898,257 @@ function ProductImage({ url, name, className = "" }) {
865
898
  children: "No image available"
866
899
  });
867
900
  }
868
- function ProductCard({ product }) {
869
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: /* @__PURE__ */ jsx(ProductCardContent, { product }) });
901
+ /** Set by `CatalogProducts` for each entry it renders, so the card template needs no product prop. */
902
+ const CatalogItemContext = createContext(null);
903
+ const titleLink = "title-link";
904
+ function cardValue(item, productPath, links) {
905
+ if (item.source === "sample") return {
906
+ ...links,
907
+ source: "sample",
908
+ sample: item.sample,
909
+ product: null,
910
+ name: item.sample.name,
911
+ href: null,
912
+ isSample: true
913
+ };
914
+ return {
915
+ ...links,
916
+ source: "live",
917
+ product: item.product,
918
+ name: getProductName(item.product),
919
+ href: `${productPath}/${encodeURIComponent(item.product.id ?? "")}`,
920
+ isSample: false
921
+ };
922
+ }
923
+ const ProductCardContext = createContext(null);
924
+ function useProductCardInternal(part) {
925
+ const context = useContext(ProductCardContext);
926
+ if (!context) throw new Error(`${part} must be rendered inside <ProductCardRoot>.`);
927
+ return context;
928
+ }
929
+ /** Read the card's product, name, and link from inside `ProductCardRoot`. */
930
+ function useProductCard() {
931
+ const { product, name, href, isSample } = useProductCardInternal("useProductCard");
932
+ return useMemo(() => ({
933
+ product,
934
+ name,
935
+ href,
936
+ isSample
937
+ }), [
938
+ product,
939
+ name,
940
+ href,
941
+ isSample
942
+ ]);
870
943
  }
871
- function ProductCardContent({ product }) {
944
+ /** Provides one product to card parts. Inside `CatalogProducts` it renders the current catalog entry. */
945
+ function ProductCardRoot({ children, product, className }) {
946
+ const catalogItem = useContext(CatalogItemContext);
872
947
  const { config } = useCommerce();
873
- const name = product.label ?? product.name ?? "Product";
874
- const href = `${config.productPath}/${encodeURIComponent(product.id ?? "")}`;
875
- const selectedSku = getSingleMatchedSku(product);
876
- selectedSku?.id;
877
- const hasVariants = getProductAttributes(product).length > 0;
878
- const min = product.priceRange?.min;
879
- const max = product.priceRange?.max;
880
- return /* @__PURE__ */ jsxs("article", {
881
- className: "group flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200",
882
- children: [/* @__PURE__ */ jsx(Link, {
883
- to: href,
884
- tabIndex: -1,
885
- "aria-hidden": "true",
886
- className: "block aspect-square overflow-hidden",
948
+ const [imageLinked, setImageLinked] = useState(false);
949
+ const { register, has } = usePartRegistry(useCallback((registered) => setImageLinked(!registered(titleLink)), []));
950
+ useEffect(() => {
951
+ setImageLinked(!has(titleLink));
952
+ }, [has]);
953
+ const value = useMemo(() => {
954
+ const item = catalogItem ?? (product ? {
955
+ source: "live",
956
+ product
957
+ } : null);
958
+ return item && cardValue(item, config.productPath, {
959
+ imageLinked,
960
+ register
961
+ });
962
+ }, [
963
+ catalogItem,
964
+ product,
965
+ config.productPath,
966
+ imageLinked,
967
+ register
968
+ ]);
969
+ if (catalogItem && product) throw new Error("Inside <CatalogProducts>, ProductCardRoot shows each catalog product. Remove its product prop.");
970
+ if (!value) throw new Error("ProductCardRoot needs a product prop outside <CatalogProducts>.");
971
+ const card = /* @__PURE__ */ jsx(ProductCardContext.Provider, {
972
+ value,
973
+ children: /* @__PURE__ */ jsx("article", {
974
+ className: partClass("group", className),
975
+ children: /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
976
+ value: null,
977
+ children
978
+ })
979
+ })
980
+ });
981
+ return catalogItem ? card : /* @__PURE__ */ jsx(StorefrontSurface, { children: card });
982
+ }
983
+ /**
984
+ * The product's first image, linked to its page. Samples show a placeholder. With a `ProductCardTitle` in the card,
985
+ * the title is the one link keyboard and screen reader users get; without one, the image link is.
986
+ */
987
+ function ProductCardImage({ className }) {
988
+ const card = useProductCardInternal("ProductCardImage");
989
+ return /* @__PURE__ */ jsx("div", {
990
+ className: partClass(className),
991
+ children: card.source === "live" ? /* @__PURE__ */ jsx(Link, {
992
+ to: card.href,
993
+ tabIndex: card.imageLinked ? void 0 : -1,
994
+ "aria-hidden": card.imageLinked ? void 0 : "true",
995
+ className: "block aspect-square overflow-hidden focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4",
887
996
  children: /* @__PURE__ */ jsx(ProductImage, {
888
- url: getPrimaryImageUrl(product),
889
- name,
997
+ url: getPrimaryImageUrl(card.product),
998
+ name: card.name,
890
999
  className: "motion-safe:transition-transform motion-safe:duration-200 motion-safe:group-hover:scale-105"
891
1000
  })
892
- }), /* @__PURE__ */ jsxs("div", {
893
- className: "flex flex-1 flex-col p-6",
894
- children: [
895
- /* @__PURE__ */ jsx("h2", {
896
- className: "break-words text-2xl font-semibold leading-tight",
897
- children: /* @__PURE__ */ jsx(Link, {
898
- className: "rounded text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
899
- to: href,
900
- children: name
901
- })
902
- }),
903
- product.description && /* @__PURE__ */ jsx("p", {
904
- className: "mt-3 line-clamp-3 text-sm leading-6",
905
- children: product.description
906
- }),
907
- /* @__PURE__ */ jsx("div", {
908
- className: "mt-auto pt-6",
909
- children: /* @__PURE__ */ jsxs("div", {
910
- className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
911
- children: [/* @__PURE__ */ jsx("p", {
912
- className: "min-w-0 flex-1 text-lg font-semibold leading-7",
913
- children: typeof min === "number" ? `${money(min, config.currencyCode)}${typeof max === "number" && max !== min ? ` – ${money(max, config.currencyCode)}` : ""}` : "Price unavailable"
914
- }), /* @__PURE__ */ jsx("div", {
915
- className: "max-w-full",
916
- children: !hasVariants && selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
917
- sku: selectedSku,
918
- name
919
- }) : /* @__PURE__ */ jsx(Link, {
920
- className: buttonClass,
921
- to: href,
922
- children: hasVariants ? "Choose options" : "View product"
923
- })
924
- })]
925
- })
1001
+ }) : /* @__PURE__ */ jsx("div", {
1002
+ "aria-hidden": "true",
1003
+ className: "flex aspect-square items-center justify-center bg-neutral-100",
1004
+ children: /* @__PURE__ */ jsx("div", { className: "h-20 w-20 rounded-lg border border-neutral-300" })
1005
+ })
1006
+ });
1007
+ }
1008
+ /** The product name as an H2, linked to its page. */
1009
+ function ProductCardTitle({ className }) {
1010
+ const { name, href, register } = useProductCardInternal("ProductCardTitle");
1011
+ useEffect(() => href ? register(titleLink) : void 0, [href, register]);
1012
+ return /* @__PURE__ */ jsx("div", {
1013
+ className: partClass(className),
1014
+ children: /* @__PURE__ */ jsx("h2", {
1015
+ className: "break-words text-2xl font-semibold leading-tight",
1016
+ children: href ? /* @__PURE__ */ jsx(Link, {
1017
+ className: "rounded text-inherit hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
1018
+ to: href,
1019
+ children: name
1020
+ }) : name
1021
+ })
1022
+ });
1023
+ }
1024
+ /** Up to three lines of the product description. Renders nothing without one. */
1025
+ function ProductCardDescription({ className }) {
1026
+ const { product } = useProductCardInternal("ProductCardDescription");
1027
+ if (!product?.description) return null;
1028
+ return /* @__PURE__ */ jsx("div", {
1029
+ className: partClass(className),
1030
+ children: /* @__PURE__ */ jsx("p", {
1031
+ className: "line-clamp-3 text-sm leading-6",
1032
+ children: product.description
1033
+ })
1034
+ });
1035
+ }
1036
+ /** The product's price, or its price range when variants differ. */
1037
+ function ProductCardPrice({ className }) {
1038
+ const card = useProductCardInternal("ProductCardPrice");
1039
+ const { config } = useCommerce();
1040
+ let text;
1041
+ if (card.source === "sample") text = money(card.sample.price, "USD");
1042
+ else {
1043
+ const min = card.product.priceRange?.min;
1044
+ const max = card.product.priceRange?.max;
1045
+ text = typeof min === "number" ? `${money(min, config.currencyCode)}${typeof max === "number" && max !== min ? ` – ${money(max, config.currencyCode)}` : ""}` : "Price unavailable";
1046
+ }
1047
+ return /* @__PURE__ */ jsx("div", {
1048
+ className: partClass(className),
1049
+ children: /* @__PURE__ */ jsx("p", {
1050
+ className: "text-lg font-semibold leading-7",
1051
+ children: text
1052
+ })
1053
+ });
1054
+ }
1055
+ /**
1056
+ * Adds the product directly when it has exactly one SKU and no options; otherwise links to its page to choose.
1057
+ * Samples add to the sample cart.
1058
+ */
1059
+ function ProductCardAction({ className }) {
1060
+ const card = useProductCardInternal("ProductCardAction");
1061
+ return /* @__PURE__ */ jsx("div", {
1062
+ className: partClass(className),
1063
+ children: card.source === "live" ? /* @__PURE__ */ jsx(LiveAction, {
1064
+ product: card.product,
1065
+ name: card.name,
1066
+ href: card.href
1067
+ }) : /* @__PURE__ */ jsx(SampleAction, { sample: card.sample })
1068
+ });
1069
+ }
1070
+ function LiveAction({ product, name, href }) {
1071
+ const selectedSku = getSingleMatchedSku(product);
1072
+ const hasVariants = getProductAttributes(product).length > 0;
1073
+ if (!hasVariants && selectedSku) return /* @__PURE__ */ jsx(AddToCartButton, {
1074
+ sku: selectedSku,
1075
+ name
1076
+ });
1077
+ return /* @__PURE__ */ jsx(Link, {
1078
+ className: buttonClass,
1079
+ to: href,
1080
+ children: hasVariants ? "Choose options" : "View product"
1081
+ });
1082
+ }
1083
+ function SampleAction({ sample }) {
1084
+ const { add } = useSampleCart();
1085
+ return /* @__PURE__ */ jsx("button", {
1086
+ type: "button",
1087
+ className: buttonClass,
1088
+ "aria-label": `Add ${sample.name} to sample cart`,
1089
+ onClick: () => add(sample.key),
1090
+ children: "Add to cart"
1091
+ });
1092
+ }
1093
+ const packagedCardClass = "flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200";
1094
+ /** The packaged card's parts, inside a root that has `packagedCardClass`. */
1095
+ function PackagedCardParts() {
1096
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ProductCardImage, {}), /* @__PURE__ */ jsxs("div", {
1097
+ className: "flex flex-1 flex-col p-6",
1098
+ children: [
1099
+ /* @__PURE__ */ jsx(ProductCardTitle, {}),
1100
+ /* @__PURE__ */ jsx(ProductCardDescription, { className: "mt-3" }),
1101
+ /* @__PURE__ */ jsx("div", {
1102
+ className: "mt-auto pt-6",
1103
+ children: /* @__PURE__ */ jsxs("div", {
1104
+ className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
1105
+ children: [/* @__PURE__ */ jsx(ProductCardPrice, { className: "min-w-0 flex-1" }), /* @__PURE__ */ jsx(ProductCardAction, { className: "max-w-full" })]
926
1106
  })
927
- ]
928
- })]
1107
+ })
1108
+ ]
1109
+ })] });
1110
+ }
1111
+ /** The packaged card for one `SKUGroup`. Compose `ProductCardRoot` and the card parts for a custom card. */
1112
+ function ProductCard({ product }) {
1113
+ return /* @__PURE__ */ jsx(ProductCardRoot, {
1114
+ product,
1115
+ className: packagedCardClass,
1116
+ children: /* @__PURE__ */ jsx(PackagedCardParts, {})
929
1117
  });
930
1118
  }
931
- function Catalog(props) {
1119
+
1120
+ //#endregion
1121
+ //#region src/catalog.tsx
1122
+ const CatalogContext = createContext(null);
1123
+ const sampleCatalog = { source: "sample" };
1124
+ function useCatalogInternal(part) {
1125
+ const context = useContext(CatalogContext);
1126
+ if (!context) throw new Error(`${part} must be rendered inside <CatalogRoot>.`);
1127
+ return context;
1128
+ }
1129
+ /** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */
1130
+ function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = false }) {
932
1131
  const { connection } = useCommerce();
933
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : connection === "unbound" && props.sampleProducts ? /* @__PURE__ */ jsx(SampleCatalog, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
1132
+ let content;
1133
+ if (connection === "ready") content = /* @__PURE__ */ jsx(CatalogLoader, {
1134
+ className,
1135
+ children
1136
+ });
1137
+ else if (connection === "unbound" && sampleProducts$1) content = /* @__PURE__ */ jsx(CatalogContext.Provider, {
1138
+ value: sampleCatalog,
1139
+ children: /* @__PURE__ */ jsx("section", {
1140
+ className: partClass(className),
1141
+ "data-commerce-source": "sample",
1142
+ children
1143
+ })
1144
+ });
1145
+ else content = /* @__PURE__ */ jsx("div", {
1146
+ className: partClass(className),
1147
+ children: /* @__PURE__ */ jsx(CommerceStatus, {})
1148
+ });
1149
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: content });
934
1150
  }
935
- function CatalogContent({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
1151
+ function CatalogLoader({ children, className }) {
936
1152
  const { config } = useCommerce();
937
1153
  const [params, setParams] = useSearchParams();
938
1154
  const after = params.get("after") ?? "";
@@ -951,34 +1167,94 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
951
1167
  headers: { "X-Commerce-Scope": config.cartScope }
952
1168
  })
953
1169
  });
954
- function goToPage(cursor) {
1170
+ const goToPage = useCallback((cursor) => {
955
1171
  const next = new URLSearchParams(params);
956
1172
  if (cursor) next.set("after", cursor);
957
1173
  else next.delete("after");
958
1174
  setParams(next);
1175
+ }, [params, setParams]);
1176
+ const nodes = useMemo(() => products.data?.skuGroups?.edges?.flatMap((edge) => edge?.node?.id ? [edge.node] : []) ?? [], [products.data]);
1177
+ const value = useMemo(() => ({
1178
+ source: "live",
1179
+ products,
1180
+ nodes,
1181
+ after,
1182
+ goToPage
1183
+ }), [
1184
+ products,
1185
+ nodes,
1186
+ after,
1187
+ goToPage
1188
+ ]);
1189
+ return /* @__PURE__ */ jsx(CatalogContext.Provider, {
1190
+ value,
1191
+ children: /* @__PURE__ */ jsx("section", {
1192
+ className: partClass(className),
1193
+ children
1194
+ })
1195
+ });
1196
+ }
1197
+ /** The catalog title as the page's H1, with a description. */
1198
+ function CatalogHeader({ title = "Shop all products", description = "Explore the collection and find your favorites.", className }) {
1199
+ useCatalogInternal("CatalogHeader");
1200
+ return /* @__PURE__ */ jsxs("header", {
1201
+ className: partClass(className),
1202
+ children: [/* @__PURE__ */ jsx("h1", {
1203
+ className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
1204
+ children: title
1205
+ }), /* @__PURE__ */ jsx("p", {
1206
+ className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
1207
+ children: description
1208
+ })]
1209
+ });
1210
+ }
1211
+ /** Announces loading, failure, or how many products the page shows. */
1212
+ function CatalogSummary({ className }) {
1213
+ const catalog = useCatalogInternal("CatalogSummary");
1214
+ return /* @__PURE__ */ jsx("div", {
1215
+ className: partClass(className),
1216
+ children: /* @__PURE__ */ jsx("p", {
1217
+ className: "text-sm",
1218
+ role: "status",
1219
+ children: summary(catalog)
1220
+ })
1221
+ });
1222
+ }
1223
+ function summary(catalog) {
1224
+ if (catalog.source === "sample") return "Sample products · Example prices in USD";
1225
+ if (catalog.products.isPending) return "Loading products…";
1226
+ if (catalog.products.isError) return "Products unavailable";
1227
+ return `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? "" : "s"}`;
1228
+ }
1229
+ const sampleItems = sampleProducts.map((sample) => ({
1230
+ key: sample.key,
1231
+ item: {
1232
+ source: "sample",
1233
+ sample
959
1234
  }
960
- const nodes = products.data?.skuGroups?.edges?.flatMap((edge) => edge?.node?.id ? [edge.node] : []) ?? [];
961
- const pageInfo = products.data?.skuGroups?.pageInfo;
962
- return /* @__PURE__ */ jsxs("section", { children: [
963
- showHeader && /* @__PURE__ */ jsxs("header", {
964
- className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8",
965
- children: [/* @__PURE__ */ jsx("h1", {
966
- className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
967
- children: title
968
- }), /* @__PURE__ */ jsx("p", {
969
- className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
970
- children: description
971
- })]
972
- }),
973
- /* @__PURE__ */ jsx("div", {
974
- className: "border-b border-neutral-200 py-6 sm:py-8",
975
- children: /* @__PURE__ */ jsx("p", {
976
- className: "text-sm",
977
- role: "status",
978
- children: products.isPending ? "Loading products…" : products.isError ? "Products unavailable" : `Showing ${nodes.length} product${nodes.length === 1 ? "" : "s"}`
979
- })
980
- }),
981
- products.isPending && /* @__PURE__ */ jsx("div", {
1235
+ }));
1236
+ const packagedCard = /* @__PURE__ */ jsx(ProductCardRoot, {
1237
+ className: packagedCardClass,
1238
+ children: /* @__PURE__ */ jsx(PackagedCardParts, {})
1239
+ });
1240
+ /**
1241
+ * The product cards. `className` goes on the element that contains the cards, such as a grid. The loading
1242
+ * placeholder, the error with its retry, and the empty message render just before that element.
1243
+ */
1244
+ function CatalogProducts({ className, children }) {
1245
+ const catalog = useCatalogInternal("CatalogProducts");
1246
+ const card = Children.toArray(children).some((child) => child !== "") ? children : packagedCard;
1247
+ const nodes = catalog.source === "live" ? catalog.nodes : null;
1248
+ const items = useMemo(() => nodes ? nodes.map((product) => ({
1249
+ key: product.id ?? "",
1250
+ item: {
1251
+ source: "live",
1252
+ product
1253
+ }
1254
+ })) : sampleItems, [nodes]);
1255
+ const products = catalog.source === "live" ? catalog.products : null;
1256
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1257
+ products?.isPending && /* @__PURE__ */ jsx("div", {
982
1258
  "aria-hidden": "true",
983
1259
  className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3",
984
1260
  children: [
@@ -997,7 +1273,7 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
997
1273
  })]
998
1274
  }, item))
999
1275
  }),
1000
- products.isError && /* @__PURE__ */ jsxs("div", {
1276
+ products?.isError && /* @__PURE__ */ jsxs("div", {
1001
1277
  role: "alert",
1002
1278
  className: "rounded-lg bg-red-50 p-5 text-red-800",
1003
1279
  children: [/* @__PURE__ */ jsx("p", { children: message(products.error) }), /* @__PURE__ */ jsx("button", {
@@ -1007,17 +1283,30 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
1007
1283
  children: "Retry products"
1008
1284
  })]
1009
1285
  }),
1010
- products.isSuccess && !nodes.length && /* @__PURE__ */ jsx("p", {
1286
+ products?.isSuccess && !items.length && /* @__PURE__ */ jsx("p", {
1011
1287
  className: "py-16 text-center",
1012
1288
  children: "No products available."
1013
1289
  }),
1014
1290
  /* @__PURE__ */ jsx("div", {
1015
- className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10",
1016
- children: nodes.map((product) => /* @__PURE__ */ jsx(ProductCard, { product }, product.id))
1017
- }),
1018
- /* @__PURE__ */ jsxs("nav", {
1019
- "aria-label": "Product pages",
1020
- className: "flex justify-between border-t border-neutral-200 pt-6",
1291
+ className: partClass(className),
1292
+ children: items.map(({ key, item }) => /* @__PURE__ */ jsx(CatalogItemContext.Provider, {
1293
+ value: item,
1294
+ children: card
1295
+ }, key))
1296
+ })
1297
+ ] });
1298
+ }
1299
+ /** First-page and next-page buttons. Renders nothing for samples, which have one page. */
1300
+ function CatalogPagination({ className }) {
1301
+ const catalog = useCatalogInternal("CatalogPagination");
1302
+ if (catalog.source === "sample") return null;
1303
+ const { after, goToPage } = catalog;
1304
+ const pageInfo = catalog.products.data?.skuGroups?.pageInfo;
1305
+ return /* @__PURE__ */ jsx("nav", {
1306
+ "aria-label": "Product pages",
1307
+ className: partClass(className),
1308
+ children: /* @__PURE__ */ jsxs("div", {
1309
+ className: "flex justify-between",
1021
1310
  children: [after ? /* @__PURE__ */ jsx("button", {
1022
1311
  type: "button",
1023
1312
  className: buttonClass,
@@ -1030,60 +1319,21 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
1030
1319
  children: "Next page"
1031
1320
  })]
1032
1321
  })
1033
- ] });
1322
+ });
1034
1323
  }
1035
- function SampleCatalog({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
1036
- const { add } = useSampleCart();
1037
- return /* @__PURE__ */ jsxs("section", {
1038
- "data-commerce-source": "sample",
1324
+ /** The packaged catalog. Compose `CatalogRoot` and the catalog parts for a custom layout. */
1325
+ function Catalog({ title, description, showHeader = true, sampleProducts: sampleProducts$1 }) {
1326
+ return /* @__PURE__ */ jsxs(CatalogRoot, {
1327
+ sampleProducts: sampleProducts$1,
1039
1328
  children: [
1040
- showHeader && /* @__PURE__ */ jsxs("header", {
1041
- className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8",
1042
- children: [/* @__PURE__ */ jsx("h1", {
1043
- className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
1044
- children: title
1045
- }), /* @__PURE__ */ jsx("p", {
1046
- className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
1047
- children: description
1048
- })]
1049
- }),
1050
- /* @__PURE__ */ jsx("p", {
1051
- className: "border-b border-neutral-200 py-6 text-sm",
1052
- role: "status",
1053
- children: "Sample products · Example prices in USD"
1329
+ showHeader && /* @__PURE__ */ jsx(CatalogHeader, {
1330
+ title,
1331
+ description,
1332
+ className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8"
1054
1333
  }),
1055
- /* @__PURE__ */ jsx("div", {
1056
- className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10",
1057
- children: sampleProducts.map((product) => /* @__PURE__ */ jsxs("article", {
1058
- className: "flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200",
1059
- children: [/* @__PURE__ */ jsx("div", {
1060
- "aria-hidden": "true",
1061
- className: "flex aspect-square items-center justify-center bg-neutral-100",
1062
- children: /* @__PURE__ */ jsx("div", { className: "h-20 w-20 rounded-lg border border-neutral-300" })
1063
- }), /* @__PURE__ */ jsxs("div", {
1064
- className: "flex flex-1 flex-col p-6",
1065
- children: [/* @__PURE__ */ jsx("h2", {
1066
- className: "break-words text-2xl font-semibold leading-tight",
1067
- children: product.name
1068
- }), /* @__PURE__ */ jsx("div", {
1069
- className: "mt-auto pt-6",
1070
- children: /* @__PURE__ */ jsxs("div", {
1071
- className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
1072
- children: [/* @__PURE__ */ jsx("p", {
1073
- className: "text-lg font-semibold leading-7",
1074
- children: money(product.price, "USD")
1075
- }), /* @__PURE__ */ jsx("button", {
1076
- type: "button",
1077
- className: buttonClass,
1078
- "aria-label": `Add ${product.name} to sample cart`,
1079
- onClick: () => add(product.key),
1080
- children: "Add to cart"
1081
- })]
1082
- })
1083
- })]
1084
- })]
1085
- }, product.key))
1086
- })
1334
+ /* @__PURE__ */ jsx(CatalogSummary, { className: "border-b border-neutral-200 py-6 sm:py-8" }),
1335
+ /* @__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" }),
1336
+ /* @__PURE__ */ jsx(CatalogPagination, { className: "border-t border-neutral-200 pt-6" })
1087
1337
  ]
1088
1338
  });
1089
1339
  }
@@ -1099,15 +1349,72 @@ function CommerceStorefront({ children,...props }) {
1099
1349
  }
1100
1350
 
1101
1351
  //#endregion
1102
- //#region src/product-details.tsx
1103
- function ProductDetails() {
1352
+ //#region src/product-details-context.tsx
1353
+ const ProductDetailsContext = createContext(null);
1354
+ function useProductDetailsInternal(part) {
1355
+ const context = useContext(ProductDetailsContext);
1356
+ if (!context) throw new Error(`${part} must be rendered inside <ProductDetailsRoot>.`);
1357
+ return context;
1358
+ }
1359
+ /** Read the current product, verified SKU, and purchase status from inside `ProductDetailsRoot`. */
1360
+ function useProductDetails() {
1361
+ const { product, name, selectedSku, quantity, availableQuantity, status, images } = useProductDetailsInternal("useProductDetails");
1362
+ return {
1363
+ product,
1364
+ name,
1365
+ selectedSku,
1366
+ quantity,
1367
+ availableQuantity,
1368
+ status,
1369
+ images
1370
+ };
1371
+ }
1372
+ /**
1373
+ * Build a custom control for one product attribute. Returns `null` when the product has no such attribute.
1374
+ * A selection is never purchasable until the server verifies it.
1375
+ */
1376
+ function useProductDetailsOption(attribute) {
1377
+ const { attributes, selections, selectOption, register } = useProductDetailsInternal("useProductDetailsOption");
1378
+ useEffect(() => register(`attribute:${attribute}`), [attribute, register]);
1379
+ const index = attributes.findIndex((candidate) => candidate.name === attribute);
1380
+ const match = attributes[index];
1381
+ if (!match) return null;
1382
+ const selected = selections[index] || null;
1383
+ return {
1384
+ name: match.name,
1385
+ label: match.label,
1386
+ selected,
1387
+ values: match.values.map((value) => ({
1388
+ name: value.name,
1389
+ label: value.label,
1390
+ selected: value.name === selected
1391
+ })),
1392
+ select: (value) => {
1393
+ if (value === null) selectOption(attribute, "");
1394
+ else if (match.values.some((candidate) => candidate.name === value)) selectOption(attribute, value);
1395
+ }
1396
+ };
1397
+ }
1398
+ /** Register the explicit SKU control so the layout check knows SKU-list products can be completed. */
1399
+ function useSkuOptionRegistration() {
1400
+ const { register } = useProductDetailsInternal("ProductDetailsOptions");
1401
+ useEffect(() => register("sku"), [register]);
1402
+ }
1403
+ /** Loads the product for the route's `:productId` and provides state to product parts. */
1404
+ function ProductDetailsRoot({ children, className }) {
1104
1405
  const { productId = "" } = useParams();
1105
1406
  const { connection } = useCommerce();
1106
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsContent, { productId }, productId) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
1407
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsLoader, {
1408
+ productId,
1409
+ className,
1410
+ children
1411
+ }, productId) : /* @__PURE__ */ jsx("div", {
1412
+ className: partClass(className),
1413
+ children: /* @__PURE__ */ jsx(CommerceStatus, {})
1414
+ }) });
1107
1415
  }
1108
- function ProductDetailsContent({ productId }) {
1416
+ function ProductDetailsLoader({ productId, className, children }) {
1109
1417
  const { config } = useCommerce();
1110
- const fieldId = useId();
1111
1418
  const [params, setParams] = useSearchParams();
1112
1419
  const [quantity, setQuantity] = useState(1);
1113
1420
  const [imageIndex, setImageIndex] = useState(0);
@@ -1130,10 +1437,10 @@ function ProductDetailsContent({ productId }) {
1130
1437
  const skuOptions = getLabeledSkuOptions(group);
1131
1438
  const explicitSku = skuOptions.find((sku) => sku.id === params.get("sku"));
1132
1439
  const selections = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? "");
1133
- const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value) => value.name === selections[index]));
1440
+ const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value$1) => value$1.name === selections[index]));
1134
1441
  const selectionParams = new URLSearchParams();
1135
- selections.filter(Boolean).forEach((value) => {
1136
- selectionParams.append("attributeValues", value);
1442
+ selections.filter(Boolean).forEach((value$1) => {
1443
+ selectionParams.append("attributeValues", value$1);
1137
1444
  });
1138
1445
  const matched = useQuery({
1139
1446
  retry: false,
@@ -1152,26 +1459,35 @@ function ProductDetailsContent({ productId }) {
1152
1459
  });
1153
1460
  const selectionVerified = complete && product.isSuccess && !product.isFetching && (attributes.length === 0 || matched.isSuccess && !matched.isFetching);
1154
1461
  const selectedSku = selectionVerified ? explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group) : null;
1155
- const skuId = selectedSku?.id ?? null;
1156
- const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node;
1462
+ const status = !complete ? "incomplete" : matched.isError && !matched.isFetching ? "variant-error" : !selectionVerified ? "checking" : selectedSku ? "ready" : attributes.length ? "unavailable" : "not-purchasable";
1157
1463
  const selectedImages = getImageUrls(selectedSku);
1158
1464
  const images = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))];
1159
- const available = getAvailableInventoryQuantity(selectedSku);
1160
- const name = group?.label ?? group?.name ?? "Product";
1161
- const selectOption = (attribute, value) => {
1465
+ const changeSelection = (key, value$1) => {
1162
1466
  const next = new URLSearchParams(params);
1163
- if (value) next.set(`option.${attribute}`, value);
1164
- else next.delete(`option.${attribute}`);
1467
+ if (value$1) next.set(key, value$1);
1468
+ else next.delete(key);
1165
1469
  setParams(next);
1166
1470
  setQuantity(1);
1167
1471
  setImageIndex(0);
1168
1472
  };
1169
- if (product.isPending) return /* @__PURE__ */ jsx("p", {
1170
- role: "status",
1171
- children: "Loading product…"
1473
+ const { register, has } = usePartRegistry();
1474
+ const requiredControls = [...attributes.map((attribute) => `attribute:${attribute.name}`), ...skuOptions.length ? ["sku"] : []].join("\n");
1475
+ useEffect(() => {
1476
+ if (process.env.NODE_ENV === "production") return;
1477
+ const missing = requiredControls.split("\n").filter((control) => control && !has(control));
1478
+ if (missing.length === 0) return;
1479
+ console.warn(`[gd-commerce-storefront] No control for ${missing.map((control) => control === "sku" ? "the SKU option" : `"${control.slice(10)}"`).join(", ")} is rendered right now, so this product cannot be added to the cart until one is. Render <ProductDetailsOptions />, <ProductDetailsOption attribute='…' /> for each attribute, or <ProductDetailsPurchase />, or ignore this if the control mounts later.`);
1480
+ }, [requiredControls, has]);
1481
+ if (product.isPending) return /* @__PURE__ */ jsx("div", {
1482
+ className: partClass(className),
1483
+ children: /* @__PURE__ */ jsx("p", {
1484
+ role: "status",
1485
+ children: "Loading product…"
1486
+ })
1172
1487
  });
1173
1488
  if (product.isError && !productNotFound) return /* @__PURE__ */ jsxs("div", {
1174
1489
  role: "alert",
1490
+ className: partClass(className),
1175
1491
  children: [
1176
1492
  /* @__PURE__ */ jsx("h1", {
1177
1493
  className: "text-2xl font-semibold",
@@ -1189,175 +1505,358 @@ function ProductDetailsContent({ productId }) {
1189
1505
  })
1190
1506
  ]
1191
1507
  });
1192
- if (productNotFound || !group) return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
1193
- className: "text-2xl font-semibold",
1194
- children: "Product not found"
1195
- }), /* @__PURE__ */ jsx(Link, {
1196
- className: `${buttonClass} mt-4`,
1197
- to: config.catalogPath,
1198
- children: "Back to shop"
1199
- })] });
1200
- return /* @__PURE__ */ jsxs("article", { children: [/* @__PURE__ */ jsx(Link, {
1201
- className: "mb-8 inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2 sm:mb-10",
1202
- to: config.catalogPath,
1203
- children: "← Back to shop"
1204
- }), /* @__PURE__ */ jsxs("div", {
1205
- className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
1206
- children: [/* @__PURE__ */ jsxs("div", {
1207
- className: "min-w-0 lg:col-span-7",
1208
- children: [/* @__PURE__ */ jsx("div", {
1209
- className: "aspect-square overflow-hidden rounded-lg",
1210
- children: /* @__PURE__ */ jsx(ProductImage, {
1211
- url: images[imageIndex] ?? images[0],
1212
- name
1213
- }, images[imageIndex] ?? images[0])
1214
- }), images.length > 1 && /* @__PURE__ */ jsx("div", {
1215
- className: "mt-4 flex gap-3 overflow-x-auto",
1216
- children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
1508
+ if (productNotFound || !group) return /* @__PURE__ */ jsxs("div", {
1509
+ className: partClass(className),
1510
+ children: [/* @__PURE__ */ jsx("h1", {
1511
+ className: "text-2xl font-semibold",
1512
+ children: "Product not found"
1513
+ }), /* @__PURE__ */ jsx(Link, {
1514
+ className: `${buttonClass} mt-4`,
1515
+ to: config.catalogPath,
1516
+ children: "Back to shop"
1517
+ })]
1518
+ });
1519
+ const value = {
1520
+ product: group,
1521
+ name: getProductName(group),
1522
+ selectedSku,
1523
+ quantity,
1524
+ availableQuantity: getAvailableInventoryQuantity(selectedSku),
1525
+ status,
1526
+ images,
1527
+ attributes,
1528
+ selections,
1529
+ skuOptions,
1530
+ explicitSkuId: explicitSku?.id ?? "",
1531
+ price: selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node,
1532
+ imageIndex: Math.min(imageIndex, Math.max(images.length - 1, 0)),
1533
+ variantError: matched.error,
1534
+ setImageIndex: (index) => {
1535
+ if (Number.isInteger(index) && index >= 0 && index < images.length) setImageIndex(index);
1536
+ },
1537
+ setQuantity,
1538
+ selectOption: (attribute, next) => changeSelection(`option.${attribute}`, next),
1539
+ selectSku: (skuId) => changeSelection("sku", skuOptions.some((sku) => sku.id === skuId) ? skuId : ""),
1540
+ retryVariant: () => {
1541
+ if (matched.isError) matched.refetch();
1542
+ },
1543
+ register
1544
+ };
1545
+ return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
1546
+ value,
1547
+ children: /* @__PURE__ */ jsx("article", {
1548
+ className: partClass(className),
1549
+ children
1550
+ })
1551
+ });
1552
+ }
1553
+
1554
+ //#endregion
1555
+ //#region src/product-purchase.tsx
1556
+ const disabledReasons = {
1557
+ incomplete: "Select all options to add this item to your cart.",
1558
+ checking: "Checking this variant…",
1559
+ "variant-error": "Availability for this selection could not be checked.",
1560
+ unavailable: "This combination is unavailable. Choose different options.",
1561
+ "not-purchasable": "This product is not currently available to purchase."
1562
+ };
1563
+ /** The packaged purchase area: options, status, availability, quantity, and add to cart. */
1564
+ function ProductDetailsPurchase({ className }) {
1565
+ const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsPurchase");
1566
+ const belowOptions = attributes.length || skuOptions.length ? "-mt-4" : void 0;
1567
+ return /* @__PURE__ */ jsxs("div", {
1568
+ className: partClass("space-y-6", className),
1569
+ children: [
1570
+ /* @__PURE__ */ jsx(OptionControls, {}),
1571
+ /* @__PURE__ */ jsx(ProductDetailsPurchaseStatus, { className: belowOptions }),
1572
+ /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: belowOptions }),
1573
+ /* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
1574
+ /* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
1575
+ ]
1576
+ });
1577
+ }
1578
+ /** A select for every product attribute, or the SKU list for products without attributes. */
1579
+ function ProductDetailsOptions({ className }) {
1580
+ const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
1581
+ if (attributes.length === 0 && skuOptions.length === 0) return null;
1582
+ return /* @__PURE__ */ jsx("div", {
1583
+ className: partClass("space-y-6", className),
1584
+ children: /* @__PURE__ */ jsx(OptionControls, {})
1585
+ });
1586
+ }
1587
+ function OptionControls() {
1588
+ const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
1589
+ return /* @__PURE__ */ jsxs(Fragment, { children: [attributes.map((attribute) => /* @__PURE__ */ jsx(ProductDetailsOption, { attribute: attribute.name }, attribute.id || attribute.name)), skuOptions.length > 0 && /* @__PURE__ */ jsx(SkuOption, {})] });
1590
+ }
1591
+ function Select({ children,...props }) {
1592
+ return /* @__PURE__ */ jsxs("div", {
1593
+ className: "relative",
1594
+ children: [/* @__PURE__ */ jsx("select", {
1595
+ className: `${inputClass} w-full appearance-none pr-10`,
1596
+ ...props,
1597
+ children
1598
+ }), /* @__PURE__ */ jsx("svg", {
1599
+ "aria-hidden": "true",
1600
+ viewBox: "0 0 16 16",
1601
+ className: "pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-600",
1602
+ children: /* @__PURE__ */ jsx("path", {
1603
+ d: "M4 6l4 4 4-4",
1604
+ fill: "none",
1605
+ stroke: "currentColor",
1606
+ strokeWidth: "1.5",
1607
+ strokeLinecap: "round",
1608
+ strokeLinejoin: "round"
1609
+ })
1610
+ })]
1611
+ });
1612
+ }
1613
+ function SkuOption() {
1614
+ const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
1615
+ const id = useId();
1616
+ useSkuOptionRegistration();
1617
+ return /* @__PURE__ */ jsxs("div", {
1618
+ className: partClass(),
1619
+ children: [/* @__PURE__ */ jsx("label", {
1620
+ htmlFor: id,
1621
+ className: "mb-2 block font-medium",
1622
+ children: "Option"
1623
+ }), /* @__PURE__ */ jsxs(Select, {
1624
+ id,
1625
+ value: explicitSkuId,
1626
+ onChange: (event) => selectSku(event.target.value),
1627
+ children: [/* @__PURE__ */ jsx("option", {
1628
+ value: "",
1629
+ children: "Select an option"
1630
+ }), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
1631
+ value: sku.id ?? "",
1632
+ children: sku.label?.trim() || sku.name?.trim()
1633
+ }, sku.id))]
1634
+ })]
1635
+ });
1636
+ }
1637
+ /** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
1638
+ function ProductDetailsOption({ attribute, className }) {
1639
+ const option = useProductDetailsOption(attribute);
1640
+ if (!option) return null;
1641
+ return /* @__PURE__ */ jsx("div", {
1642
+ className: partClass(className),
1643
+ children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
1644
+ className: "mb-2 font-medium",
1645
+ children: option.label
1646
+ }), /* @__PURE__ */ jsxs(Select, {
1647
+ "aria-label": option.label,
1648
+ value: option.selected ?? "",
1649
+ onChange: (event) => option.select(event.target.value || null),
1650
+ children: [/* @__PURE__ */ jsxs("option", {
1651
+ value: "",
1652
+ children: ["Select ", option.label.toLowerCase()]
1653
+ }), option.values.map((value) => /* @__PURE__ */ jsx("option", {
1654
+ value: value.name,
1655
+ children: value.label
1656
+ }, value.name))]
1657
+ })] })
1658
+ });
1659
+ }
1660
+ /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
1661
+ function ProductDetailsPurchaseStatus({ className }) {
1662
+ const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
1663
+ let content;
1664
+ switch (status) {
1665
+ case "ready": return null;
1666
+ case "variant-error":
1667
+ content = /* @__PURE__ */ jsxs("div", {
1668
+ role: "alert",
1669
+ className: "text-red-700",
1670
+ children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
1217
1671
  type: "button",
1218
- 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"}`,
1219
- "aria-label": `View image ${index + 1}`,
1220
- "aria-pressed": imageIndex === index,
1221
- onClick: () => setImageIndex(index),
1222
- children: /* @__PURE__ */ jsx(ProductImage, {
1223
- url,
1224
- name: ""
1225
- })
1226
- }, url))
1227
- })]
1228
- }), /* @__PURE__ */ jsxs("div", {
1672
+ className: "min-h-11 bg-white text-red-700 underline",
1673
+ onClick: retryVariant,
1674
+ children: "Retry availability"
1675
+ })]
1676
+ });
1677
+ break;
1678
+ case "unavailable":
1679
+ content = /* @__PURE__ */ jsx("p", {
1680
+ role: "status",
1681
+ className: "text-sm text-red-700",
1682
+ children: disabledReasons[status]
1683
+ });
1684
+ break;
1685
+ case "not-purchasable":
1686
+ content = /* @__PURE__ */ jsx("p", {
1687
+ className: "text-sm",
1688
+ children: disabledReasons[status]
1689
+ });
1690
+ break;
1691
+ case "incomplete":
1692
+ case "checking":
1693
+ content = /* @__PURE__ */ jsx("p", {
1694
+ role: "status",
1695
+ className: "text-sm",
1696
+ children: disabledReasons[status]
1697
+ });
1698
+ break;
1699
+ }
1700
+ return /* @__PURE__ */ jsx("div", {
1701
+ className: partClass(className),
1702
+ children: content
1703
+ });
1704
+ }
1705
+ /** Stock for the verified SKU. Renders nothing until a SKU is verified. */
1706
+ function ProductDetailsAvailability({ className }) {
1707
+ const { selectedSku, availableQuantity } = useProductDetailsInternal("ProductDetailsAvailability");
1708
+ if (!selectedSku) return null;
1709
+ return /* @__PURE__ */ jsx("div", {
1710
+ className: partClass(className),
1711
+ children: /* @__PURE__ */ jsx("p", {
1712
+ className: "text-sm",
1713
+ "aria-live": "polite",
1714
+ children: availableQuantity === null ? "Available to order" : availableQuantity === 0 ? "This variant is out of stock" : `${availableQuantity} available`
1715
+ })
1716
+ });
1717
+ }
1718
+ /** Quantity for the verified SKU. Renders nothing until a SKU is verified. */
1719
+ function ProductDetailsQuantity({ className }) {
1720
+ const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
1721
+ const id = useId();
1722
+ if (!selectedSku) return null;
1723
+ return /* @__PURE__ */ jsxs("div", {
1724
+ className: partClass(className),
1725
+ children: [/* @__PURE__ */ jsx("label", {
1726
+ htmlFor: id,
1727
+ className: "mb-2 block font-medium",
1728
+ children: "Quantity"
1729
+ }), /* @__PURE__ */ jsx("input", {
1730
+ id,
1731
+ type: "number",
1732
+ min: 1,
1733
+ max: availableQuantity ?? void 0,
1734
+ step: 1,
1735
+ className: `${inputClass} w-28`,
1736
+ value: Number.isNaN(quantity) ? "" : quantity,
1737
+ onChange: (event) => setQuantity(event.target.valueAsNumber)
1738
+ })]
1739
+ });
1740
+ }
1741
+ /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
1742
+ function ProductDetailsAddToCart({ className }) {
1743
+ const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
1744
+ const reasonId = useId();
1745
+ return /* @__PURE__ */ jsx("div", {
1746
+ className: partClass(className),
1747
+ children: selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
1748
+ sku: selectedSku,
1749
+ name,
1750
+ quantity
1751
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
1752
+ type: "button",
1753
+ className: `${buttonClass} w-full`,
1754
+ disabled: true,
1755
+ "aria-describedby": reasonId,
1756
+ children: "Add to cart"
1757
+ }), /* @__PURE__ */ jsx("span", {
1758
+ id: reasonId,
1759
+ hidden: true,
1760
+ children: status === "ready" ? "" : disabledReasons[status]
1761
+ })] })
1762
+ });
1763
+ }
1764
+
1765
+ //#endregion
1766
+ //#region src/product-details.tsx
1767
+ /** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
1768
+ function ProductDetails() {
1769
+ return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-8 sm:mb-10" }), /* @__PURE__ */ jsxs("div", {
1770
+ className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
1771
+ children: [/* @__PURE__ */ jsx(ProductDetailsGallery, { className: "min-w-0 lg:col-span-7" }), /* @__PURE__ */ jsxs("div", {
1229
1772
  className: "min-w-0 lg:col-span-5 lg:py-4",
1230
1773
  children: [
1231
- /* @__PURE__ */ jsx("h1", {
1232
- className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
1233
- children: name
1234
- }),
1235
- /* @__PURE__ */ jsxs("div", {
1236
- "aria-live": "polite",
1237
- className: "mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
1238
- "data-testid": "product-price",
1239
- children: [skuPrice?.value?.value != null ? money(skuPrice.value.value, skuPrice.value.currencyCode ?? config.currencyCode) : group.priceRange?.min != null ? `From ${money(group.priceRange.min, config.currencyCode)}` : "Price unavailable", skuPrice?.compareAtValue?.value != null && skuPrice.value?.value != null && skuPrice.compareAtValue.value > skuPrice.value.value && /* @__PURE__ */ jsx("del", {
1240
- className: "text-lg font-normal",
1241
- children: money(skuPrice.compareAtValue.value, skuPrice.compareAtValue.currencyCode ?? config.currencyCode)
1242
- })]
1243
- }),
1244
- /* @__PURE__ */ jsx("p", {
1245
- className: "my-8 whitespace-pre-line text-base leading-7",
1246
- children: selectedSku?.description ?? group.description
1247
- }),
1248
- /* @__PURE__ */ jsxs("div", {
1249
- className: "space-y-6 border-t border-neutral-200 pt-8",
1250
- children: [
1251
- attributes.map((attribute, index) => /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
1252
- className: "mb-2 font-medium",
1253
- children: attribute.label
1254
- }), /* @__PURE__ */ jsxs("select", {
1255
- className: `${inputClass} w-full`,
1256
- "aria-label": attribute.label,
1257
- value: selections[index],
1258
- onChange: (event) => selectOption(attribute.name, event.target.value),
1259
- children: [/* @__PURE__ */ jsxs("option", {
1260
- value: "",
1261
- children: ["Select ", attribute.label.toLowerCase()]
1262
- }), attribute.values.map((value) => /* @__PURE__ */ jsx("option", {
1263
- value: value.name,
1264
- children: value.label
1265
- }, value.id || value.name))]
1266
- })] }, attribute.id || attribute.name)),
1267
- skuOptions.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
1268
- htmlFor: `${fieldId}-sku-option`,
1269
- className: "mb-2 block font-medium",
1270
- children: "Option"
1271
- }), /* @__PURE__ */ jsxs("select", {
1272
- id: `${fieldId}-sku-option`,
1273
- className: `${inputClass} w-full`,
1274
- value: explicitSku?.id ?? "",
1275
- onChange: (event) => {
1276
- const next = new URLSearchParams(params);
1277
- if (event.target.value) next.set("sku", event.target.value);
1278
- else next.delete("sku");
1279
- setParams(next);
1280
- setQuantity(1);
1281
- setImageIndex(0);
1282
- },
1283
- children: [/* @__PURE__ */ jsx("option", {
1284
- value: "",
1285
- children: "Select an option"
1286
- }), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
1287
- value: sku.id ?? "",
1288
- children: sku.label?.trim() || sku.name?.trim()
1289
- }, sku.id))]
1290
- })] }),
1291
- matched.isError ? /* @__PURE__ */ jsxs("div", {
1292
- role: "alert",
1293
- className: "text-red-700",
1294
- children: [/* @__PURE__ */ jsx("p", { children: message(matched.error) }), /* @__PURE__ */ jsx("button", {
1295
- type: "button",
1296
- className: "min-h-11 bg-white text-red-700 underline",
1297
- onClick: () => {
1298
- if (matched.isError) matched.refetch();
1299
- },
1300
- children: "Retry availability"
1301
- })]
1302
- }) : null,
1303
- !complete && /* @__PURE__ */ jsx("p", {
1304
- id: `${fieldId}-variant-guidance`,
1305
- role: "status",
1306
- className: "text-sm",
1307
- children: "Select all options to add this item to your cart."
1308
- }),
1309
- selectionVerified && attributes.length > 0 && !skuId && /* @__PURE__ */ jsx("p", {
1310
- role: "status",
1311
- className: "text-sm text-red-700",
1312
- children: "This combination is unavailable. Choose different options."
1313
- }),
1314
- selectionVerified && attributes.length === 0 && !skuId && /* @__PURE__ */ jsx("p", {
1315
- className: "text-sm",
1316
- children: "This product is not currently available to purchase."
1317
- }),
1318
- complete && (product.isFetching || matched.isFetching) && /* @__PURE__ */ jsx("p", {
1319
- role: "status",
1320
- children: "Checking this variant…"
1321
- }),
1322
- selectedSku && /* @__PURE__ */ jsxs(Fragment, { children: [
1323
- /* @__PURE__ */ jsx("p", {
1324
- className: "text-sm",
1325
- "aria-live": "polite",
1326
- children: available === null ? "Available to order" : available === 0 ? "This variant is out of stock" : `${available} available`
1327
- }),
1328
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
1329
- htmlFor: `${fieldId}-quantity`,
1330
- className: "mb-2 block font-medium",
1331
- children: "Quantity"
1332
- }), /* @__PURE__ */ jsx("input", {
1333
- id: `${fieldId}-quantity`,
1334
- type: "number",
1335
- min: 1,
1336
- max: available ?? void 0,
1337
- step: 1,
1338
- className: `${inputClass} w-28`,
1339
- value: Number.isNaN(quantity) ? "" : quantity,
1340
- onChange: (event) => setQuantity(event.target.valueAsNumber)
1341
- })] }),
1342
- /* @__PURE__ */ jsx(AddToCartButton, {
1343
- sku: selectedSku,
1344
- name,
1345
- quantity
1346
- })
1347
- ] }),
1348
- !selectedSku && /* @__PURE__ */ jsx("button", {
1349
- type: "button",
1350
- className: `${buttonClass} w-full`,
1351
- disabled: true,
1352
- "aria-describedby": !complete ? `${fieldId}-variant-guidance` : void 0,
1353
- children: "Add to cart"
1354
- })
1355
- ]
1356
- })
1774
+ /* @__PURE__ */ jsx(ProductDetailsTitle, {}),
1775
+ /* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-6" }),
1776
+ /* @__PURE__ */ jsx(ProductDetailsDescription, { className: "my-8" }),
1777
+ /* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "border-t border-neutral-200 pt-8" })
1357
1778
  ]
1358
1779
  })]
1359
1780
  })] });
1360
1781
  }
1782
+ function ProductDetailsBackLink({ className }) {
1783
+ useProductDetailsInternal("ProductDetailsBackLink");
1784
+ const { config } = useCommerce();
1785
+ return /* @__PURE__ */ jsx("div", {
1786
+ className: partClass(className),
1787
+ children: /* @__PURE__ */ jsx(Link, {
1788
+ className: "inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2",
1789
+ to: config.catalogPath,
1790
+ children: "← Back to shop"
1791
+ })
1792
+ });
1793
+ }
1794
+ /** Main image with thumbnails; the selected SKU's images replace the product's when present. */
1795
+ function ProductDetailsGallery({ className }) {
1796
+ const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal("ProductDetailsGallery");
1797
+ return /* @__PURE__ */ jsxs("div", {
1798
+ className: partClass(className),
1799
+ children: [/* @__PURE__ */ jsx("div", {
1800
+ className: "aspect-square overflow-hidden rounded-lg",
1801
+ children: /* @__PURE__ */ jsx(ProductImage, {
1802
+ url: images[imageIndex],
1803
+ name
1804
+ }, images[imageIndex])
1805
+ }), images.length > 1 && /* @__PURE__ */ jsx("div", {
1806
+ className: "mt-4 flex gap-3 overflow-x-auto",
1807
+ children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
1808
+ type: "button",
1809
+ className: `h-20 w-20 shrink-0 overflow-hidden rounded-lg border-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${imageIndex === index ? "border-neutral-900" : "border-transparent"}`,
1810
+ "aria-label": `View image ${index + 1}`,
1811
+ "aria-pressed": imageIndex === index,
1812
+ onClick: () => setImageIndex(index),
1813
+ children: /* @__PURE__ */ jsx(ProductImage, {
1814
+ url,
1815
+ name: ""
1816
+ })
1817
+ }, url))
1818
+ })]
1819
+ });
1820
+ }
1821
+ /** The product name as the page's H1. */
1822
+ function ProductDetailsTitle({ className }) {
1823
+ const { name } = useProductDetailsInternal("ProductDetailsTitle");
1824
+ return /* @__PURE__ */ jsx("div", {
1825
+ className: partClass(className),
1826
+ children: /* @__PURE__ */ jsx("h1", {
1827
+ className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
1828
+ children: name
1829
+ })
1830
+ });
1831
+ }
1832
+ /** The verified SKU's price and compare-at price, or the product's starting price. */
1833
+ function ProductDetailsPrice({ className }) {
1834
+ const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
1835
+ const { config } = useCommerce();
1836
+ return /* @__PURE__ */ jsx("div", {
1837
+ className: partClass(className),
1838
+ children: /* @__PURE__ */ jsxs("div", {
1839
+ "aria-live": "polite",
1840
+ className: "flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
1841
+ "data-testid": "product-price",
1842
+ children: [price?.value?.value != null ? money(price.value.value, price.value.currencyCode ?? config.currencyCode) : product.priceRange?.min != null ? `From ${money(product.priceRange.min, config.currencyCode)}` : "Price unavailable", price?.compareAtValue?.value != null && price.value?.value != null && price.compareAtValue.value > price.value.value && /* @__PURE__ */ jsx("del", {
1843
+ className: "text-lg font-normal",
1844
+ children: money(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
1845
+ })]
1846
+ })
1847
+ });
1848
+ }
1849
+ /** The verified SKU's description, or the product's. */
1850
+ function ProductDetailsDescription({ className }) {
1851
+ const { selectedSku, product } = useProductDetailsInternal("ProductDetailsDescription");
1852
+ return /* @__PURE__ */ jsx("div", {
1853
+ className: partClass(className),
1854
+ children: /* @__PURE__ */ jsx("p", {
1855
+ className: "whitespace-pre-line text-base leading-7",
1856
+ children: selectedSku?.description ?? product.description
1857
+ })
1858
+ });
1859
+ }
1361
1860
 
1362
1861
  //#endregion
1363
- export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductDetails, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce };
1862
+ export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce, useProductCard, useProductDetails, useProductDetailsOption };