@godaddy/gd-commerce-storefront 0.1.3 → 0.3.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 { 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;
@@ -148,6 +149,82 @@ function getProductAttributes(skuGroup) {
148
149
  }) ?? [];
149
150
  }
150
151
 
152
+ //#endregion
153
+ //#region src/sample-cart.tsx
154
+ const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
155
+ key: `sample-${index + 1}`,
156
+ name: `Product ${index + 1}`,
157
+ price: (index + 1) * 1e3
158
+ }));
159
+ const SampleCartContext = createContext(null);
160
+ function useSampleCart() {
161
+ const value = useContext(SampleCartContext);
162
+ if (!value) throw new Error("Mount CommerceProvider around sample products.");
163
+ return value;
164
+ }
165
+ function SampleCartProvider({ enabled, onAdd, announce, children }) {
166
+ const session = useMemo(() => ({ enabled }), [enabled]);
167
+ const active = useRef(session);
168
+ active.current = session;
169
+ const [state, setState] = useState({
170
+ session,
171
+ quantities: {}
172
+ });
173
+ useEffect(() => {
174
+ active.current = session;
175
+ setState({
176
+ session,
177
+ quantities: {}
178
+ });
179
+ return () => {
180
+ active.current = null;
181
+ };
182
+ }, [session]);
183
+ const quantities = state.session === session && enabled ? state.quantities : {};
184
+ function update(key, quantity) {
185
+ if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) return false;
186
+ if (quantity !== void 0 && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) return false;
187
+ setState((previous) => {
188
+ const current = previous.session === session ? previous.quantities : {};
189
+ const next = quantity ?? (current[key] ?? 0) + 1;
190
+ if (next > 999) return previous;
191
+ return {
192
+ session,
193
+ quantities: {
194
+ ...current,
195
+ [key]: next
196
+ }
197
+ };
198
+ });
199
+ return true;
200
+ }
201
+ return /* @__PURE__ */ jsx(SampleCartContext.Provider, {
202
+ value: {
203
+ items: sampleProducts.flatMap((product) => quantities[product.key] ? [{
204
+ ...product,
205
+ quantity: quantities[product.key]
206
+ }] : []),
207
+ add: (key) => {
208
+ const product = sampleProducts.find((product$1) => product$1.key === key);
209
+ if (!product || !update(key)) return false;
210
+ onAdd(product.name);
211
+ return true;
212
+ },
213
+ changeQuantity: async (key, quantity) => {
214
+ const updated = quantity >= 1 && update(key, quantity);
215
+ if (updated) announce("Sample cart quantity updated.");
216
+ return updated;
217
+ },
218
+ remove: async (key) => {
219
+ const updated = update(key, 0);
220
+ if (updated) announce("Sample item removed from cart.");
221
+ return updated;
222
+ }
223
+ },
224
+ children
225
+ });
226
+ }
227
+
151
228
  //#endregion
152
229
  //#region src/commerce-provider.tsx
153
230
  const CommerceContext = createContext(null);
@@ -156,10 +233,19 @@ function useCommerce() {
156
233
  if (!context) throw new Error("Mount CommerceProvider once around the header and page outlet, inside the router.");
157
234
  return context;
158
235
  }
236
+ const CONNECTING_FAILURE_GRACE_MS = 6e4;
159
237
  async function loadConfig(signal) {
160
238
  const config = await request("/config", { signal });
161
- if (!config || typeof config.cartScope !== "string" || !config.cartScope.trim() || typeof config.currencyCode !== "string" || !/^[A-Z]{3}$/.test(config.currencyCode)) throw new Error("The store returned invalid configuration.");
162
- return config;
239
+ if (config?.state === "unbound" || config?.state === "connecting") {
240
+ if ("cartScope" in config || "currencyCode" in config) throw new Error("The store returned invalid configuration.");
241
+ return { state: config.state };
242
+ }
243
+ if (!config || config.state !== void 0 && config.state !== "ready" || typeof config.cartScope !== "string" || !config.cartScope.trim() || typeof config.currencyCode !== "string" || !/^[A-Z]{3}$/.test(config.currencyCode)) throw new Error("The store returned invalid configuration.");
244
+ return {
245
+ state: "ready",
246
+ cartScope: config.cartScope,
247
+ currencyCode: config.currencyCode
248
+ };
163
249
  }
164
250
  function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {} }) {
165
251
  const config = useQuery({
@@ -167,19 +253,25 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
167
253
  queryKey: ["commerce", "configuration"],
168
254
  queryFn: ({ signal }) => loadConfig(signal),
169
255
  staleTime: 3e4,
170
- refetchOnWindowFocus: true
256
+ refetchOnMount: "always",
257
+ refetchOnWindowFocus: "always",
258
+ refetchInterval: (query) => query.state.data?.state === "unbound" || query.state.data?.state === "connecting" ? 5e3 : false,
259
+ refetchIntervalInBackground: false
171
260
  });
261
+ const holdConnecting = config.isError && config.data?.state === "connecting" && config.errorUpdatedAt - config.dataUpdatedAt < CONNECTING_FAILURE_GRACE_MS;
262
+ let connection = config.data?.state ?? "loading";
263
+ if (config.isError) connection = holdConnecting ? "connecting" : "error";
172
264
  return /* @__PURE__ */ jsx(BoundCommerceProvider, {
173
265
  config: {
174
266
  cartScope: "",
175
267
  currencyCode: "USD",
176
- ...config.data,
268
+ ...config.data?.state === "ready" ? config.data : {},
177
269
  catalogPath,
178
270
  productPath,
179
271
  checkoutSuccessPath
180
272
  },
181
- connection: config.isError ? "error" : config.data ? "ready" : "loading",
182
- connectionError: config.isError ? message(config.error) : null,
273
+ connection,
274
+ connectionError: connection === "error" ? message(config.error) : null,
183
275
  retryConnection: () => {
184
276
  config.refetch();
185
277
  },
@@ -193,8 +285,8 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
193
285
  const ready = connection === "ready";
194
286
  const session = useMemo(() => ({
195
287
  cartIdStorage,
196
- ready
197
- }), [cartIdStorage, ready]);
288
+ connection
289
+ }), [cartIdStorage, connection]);
198
290
  const currentSession = useRef(session);
199
291
  currentSession.current = session;
200
292
  const readyRef = useRef(ready);
@@ -299,6 +391,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
299
391
  setOpen(false);
300
392
  setError(null);
301
393
  setStorageWarning(null);
394
+ setAnnouncement("");
302
395
  setPending(false);
303
396
  setHydrating(ready);
304
397
  if (ready) refresh();
@@ -315,7 +408,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
315
408
  window.removeEventListener("storage", onStorage);
316
409
  window.removeEventListener("focus", onFocus);
317
410
  };
318
- }, [cartIdStorage, ready]);
411
+ }, [cartIdStorage, connection]);
319
412
  function addItem(item) {
320
413
  rememberOpener();
321
414
  return run(async () => {
@@ -426,7 +519,16 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
426
519
  refresh,
427
520
  checkout
428
521
  },
429
- children: [children, /* @__PURE__ */ jsx("span", {
522
+ children: [/* @__PURE__ */ jsx(SampleCartProvider, {
523
+ enabled: connection === "unbound",
524
+ announce: setAnnouncement,
525
+ onAdd: (name) => {
526
+ rememberOpener();
527
+ setAnnouncement(`${name} added to your sample cart.`);
528
+ setOpen(true);
529
+ },
530
+ children
531
+ }), /* @__PURE__ */ jsx("span", {
430
532
  className: "commerce-sr-only",
431
533
  role: "status",
432
534
  "aria-live": "polite",
@@ -448,7 +550,10 @@ function StorefrontSurface({ children, className = "" }) {
448
550
  function CommerceStatus() {
449
551
  const { connection, connectionError, retryConnection } = useCommerce();
450
552
  if (connection === "ready") return null;
451
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "loading" ? /* @__PURE__ */ jsx("p", {
553
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "unbound" ? /* @__PURE__ */ jsx("p", {
554
+ role: "status",
555
+ children: "Connect a store to show products."
556
+ }) : connection === "loading" || connection === "connecting" ? /* @__PURE__ */ jsx("p", {
452
557
  role: "status",
453
558
  children: "Connecting to the store…"
454
559
  }) : /* @__PURE__ */ jsxs("div", {
@@ -468,13 +573,14 @@ const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 roun
468
573
  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";
469
574
  function CartButton() {
470
575
  const { cart, setOpen, open, connection } = useCommerce();
471
- const count = cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0;
576
+ const sample = useSampleCart();
577
+ const count = connection === "unbound" ? sample.items.reduce((total, item) => total + item.quantity, 0) : cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0;
472
578
  return /* @__PURE__ */ jsx(StorefrontSurface, {
473
579
  className: "commerce-inline",
474
580
  children: /* @__PURE__ */ jsxs("button", {
475
581
  type: "button",
476
582
  className: buttonClass,
477
- disabled: connection !== "ready",
583
+ disabled: connection !== "ready" && connection !== "unbound",
478
584
  "aria-haspopup": "dialog",
479
585
  "aria-expanded": open,
480
586
  onClick: () => setOpen(true),
@@ -521,13 +627,34 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
521
627
  });
522
628
  }
523
629
  function CartDrawer() {
524
- const { config, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity, removeItem, refresh, checkout } = useCommerce();
630
+ const { config, connection, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity: changeLiveQuantity, removeItem: removeLiveItem, refresh, checkout } = useCommerce();
631
+ const sample = useSampleCart();
632
+ const isSample = connection === "unbound";
633
+ const changeQuantity = isSample ? sample.changeQuantity : changeLiveQuantity;
634
+ const removeItem = isSample ? sample.remove : removeLiveItem;
525
635
  const [drawerAction, setDrawerAction] = useState(null);
526
636
  const checkingOut = drawerAction === "checkout";
527
637
  const locked = pending || hydrating || drawerAction !== null;
528
- const items = cart?.lineItems ?? [];
638
+ const items = isSample ? sample.items.map((item) => ({
639
+ id: item.key,
640
+ name: item.name,
641
+ quantity: item.quantity,
642
+ image: void 0,
643
+ options: void 0,
644
+ subtotal: item.price * item.quantity,
645
+ currency: "USD"
646
+ })) : (cart?.lineItems ?? []).map((item) => ({
647
+ id: item.id,
648
+ name: item.name,
649
+ quantity: item.quantity,
650
+ image: item.details?.productAssetUrl,
651
+ options: item.details?.selectedOptions,
652
+ subtotal: item.totals?.subTotal?.value,
653
+ currency: item.totals?.subTotal?.currencyCode
654
+ }));
529
655
  const summary = getCartSummaryTotals(cart);
530
- const currency = cart?.totals?.total?.currencyCode ?? config.currencyCode;
656
+ const currency = isSample ? "USD" : cart?.totals?.total?.currencyCode ?? config.currencyCode;
657
+ const subtotal = isSample ? sample.items.reduce((sum, item) => sum + item.price * item.quantity, 0) : summary.subtotal;
531
658
  async function handleAction(action, operation) {
532
659
  if (locked) return;
533
660
  setDrawerAction(action);
@@ -541,6 +668,7 @@ function CartDrawer() {
541
668
  open,
542
669
  onOpenChange: setOpen,
543
670
  children: /* @__PURE__ */ jsx(Dialog.Portal, { children: /* @__PURE__ */ jsxs(StorefrontSurface, { children: [/* @__PURE__ */ jsx(Dialog.Overlay, { className: "fixed inset-0 z-40 bg-neutral-950/40" }), /* @__PURE__ */ jsxs(Dialog.Content, {
671
+ "data-commerce-source": isSample ? "sample" : void 0,
544
672
  className: "fixed inset-y-0 right-0 z-50 flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl",
545
673
  onCloseAutoFocus: (event) => {
546
674
  event.preventDefault();
@@ -551,10 +679,10 @@ function CartDrawer() {
551
679
  className: "flex items-start justify-between border-b border-neutral-200 p-6",
552
680
  children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Dialog.Title, {
553
681
  className: "text-2xl font-semibold",
554
- children: "Your cart"
682
+ children: isSample ? "Sample cart" : "Your cart"
555
683
  }), /* @__PURE__ */ jsx(Dialog.Description, {
556
684
  className: "mt-1 text-sm text-neutral-600",
557
- children: "Review your items before checkout."
685
+ children: isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout."
558
686
  })] }), /* @__PURE__ */ jsx(Dialog.Close, {
559
687
  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",
560
688
  "aria-label": "Close cart",
@@ -595,7 +723,7 @@ function CartDrawer() {
595
723
  }),
596
724
  /* @__PURE__ */ jsx("p", {
597
725
  className: "mt-2 text-neutral-600",
598
- children: "Find something you love in the shop."
726
+ children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
599
727
  }),
600
728
  /* @__PURE__ */ jsx(Link, {
601
729
  className: `${buttonClass} mt-6`,
@@ -612,8 +740,8 @@ function CartDrawer() {
612
740
  "data-testid": "cart-item",
613
741
  children: [/* @__PURE__ */ jsxs("div", {
614
742
  className: "flex gap-4",
615
- children: [item.details?.productAssetUrl && /* @__PURE__ */ jsx("img", {
616
- src: item.details.productAssetUrl,
743
+ children: [item.image && /* @__PURE__ */ jsx("img", {
744
+ src: item.image,
617
745
  alt: "",
618
746
  className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
619
747
  }), /* @__PURE__ */ jsxs("div", {
@@ -623,7 +751,7 @@ function CartDrawer() {
623
751
  className: "font-semibold",
624
752
  children: item.name
625
753
  }),
626
- item.details?.selectedOptions?.map((option) => /* @__PURE__ */ jsxs("p", {
754
+ item.options?.map((option) => /* @__PURE__ */ jsxs("p", {
627
755
  className: "text-sm text-neutral-600",
628
756
  children: [
629
757
  option.attribute,
@@ -633,7 +761,7 @@ function CartDrawer() {
633
761
  }, `${option.attribute}:${option.values?.join(",")}`)),
634
762
  /* @__PURE__ */ jsx("p", {
635
763
  className: "mt-1 text-sm",
636
- children: typeof item.totals?.subTotal?.value === "number" ? money(item.totals.subTotal.value, item.totals.subTotal.currencyCode ?? currency) : "Price unavailable"
764
+ children: typeof item.subtotal === "number" ? money(item.subtotal, item.currency ?? currency) : "Price unavailable"
637
765
  })
638
766
  ]
639
767
  })]
@@ -690,16 +818,19 @@ function CartDrawer() {
690
818
  children: [
691
819
  /* @__PURE__ */ jsx("dl", {
692
820
  className: "space-y-2 text-sm",
693
- children: typeof cart?.totals?.subTotal?.value === "number" && /* @__PURE__ */ jsxs("div", {
821
+ children: (isSample || typeof cart?.totals?.subTotal?.value === "number") && /* @__PURE__ */ jsxs("div", {
694
822
  className: "flex justify-between",
695
- children: [/* @__PURE__ */ jsx("dt", { children: "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(summary.subtotal, cart.totals.subTotal.currencyCode ?? currency) })]
823
+ children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(subtotal, isSample ? "USD" : cart?.totals?.subTotal?.currencyCode ?? currency) })]
696
824
  })
697
825
  }),
698
- /* @__PURE__ */ jsx("p", {
826
+ !isSample && /* @__PURE__ */ jsx("p", {
699
827
  className: "commerce-cart-checkout-adjustments-note mt-3 text-sm text-neutral-600",
700
828
  children: "Shipping, taxes, and discounts are calculated at checkout."
701
829
  }),
702
- config.checkoutSuccessPath ? /* @__PURE__ */ jsx("button", {
830
+ isSample ? /* @__PURE__ */ jsx("p", {
831
+ className: "text-sm text-neutral-600",
832
+ children: "Example total in USD. Checkout is not set up yet."
833
+ }) : config.checkoutSuccessPath ? /* @__PURE__ */ jsx("button", {
703
834
  type: "button",
704
835
  className: `${buttonClass} w-full`,
705
836
  disabled: checkingOut || hydrating || cart?.totals?.total?.value == null,
@@ -800,7 +931,7 @@ function ProductCardContent({ product }) {
800
931
  }
801
932
  function Catalog(props) {
802
933
  const { connection } = useCommerce();
803
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
934
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : connection === "unbound" && props.sampleProducts ? /* @__PURE__ */ jsx(SampleCatalog, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
804
935
  }
805
936
  function CatalogContent({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
806
937
  const { config } = useCommerce();
@@ -902,6 +1033,61 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
902
1033
  })
903
1034
  ] });
904
1035
  }
1036
+ function SampleCatalog({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
1037
+ const { add } = useSampleCart();
1038
+ return /* @__PURE__ */ jsxs("section", {
1039
+ "data-commerce-source": "sample",
1040
+ children: [
1041
+ showHeader && /* @__PURE__ */ jsxs("header", {
1042
+ className: "border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8",
1043
+ children: [/* @__PURE__ */ jsx("h1", {
1044
+ className: "max-w-3xl text-4xl font-semibold leading-tight sm:text-5xl lg:text-6xl",
1045
+ children: title
1046
+ }), /* @__PURE__ */ jsx("p", {
1047
+ className: "mt-5 max-w-xl text-base leading-7 sm:text-lg",
1048
+ children: description
1049
+ })]
1050
+ }),
1051
+ /* @__PURE__ */ jsx("p", {
1052
+ className: "border-b border-neutral-200 py-6 text-sm",
1053
+ role: "status",
1054
+ children: "Sample products · Example prices in USD"
1055
+ }),
1056
+ /* @__PURE__ */ jsx("div", {
1057
+ className: "grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10",
1058
+ children: sampleProducts.map((product) => /* @__PURE__ */ jsxs("article", {
1059
+ className: "flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200",
1060
+ children: [/* @__PURE__ */ jsx("div", {
1061
+ "aria-hidden": "true",
1062
+ className: "flex aspect-square items-center justify-center bg-neutral-100",
1063
+ children: /* @__PURE__ */ jsx("div", { className: "h-20 w-20 rounded-lg border border-neutral-300" })
1064
+ }), /* @__PURE__ */ jsxs("div", {
1065
+ className: "flex flex-1 flex-col p-6",
1066
+ children: [/* @__PURE__ */ jsx("h2", {
1067
+ className: "break-words text-2xl font-semibold leading-tight",
1068
+ children: product.name
1069
+ }), /* @__PURE__ */ jsx("div", {
1070
+ className: "mt-auto pt-6",
1071
+ children: /* @__PURE__ */ jsxs("div", {
1072
+ className: "flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5",
1073
+ children: [/* @__PURE__ */ jsx("p", {
1074
+ className: "text-lg font-semibold leading-7",
1075
+ children: money(product.price, "USD")
1076
+ }), /* @__PURE__ */ jsx("button", {
1077
+ type: "button",
1078
+ className: buttonClass,
1079
+ "aria-label": `Add ${product.name} to sample cart`,
1080
+ onClick: () => add(product.key),
1081
+ children: "Add to cart"
1082
+ })]
1083
+ })
1084
+ })]
1085
+ })]
1086
+ }, product.key))
1087
+ })
1088
+ ]
1089
+ });
1090
+ }
905
1091
 
906
1092
  //#endregion
907
1093
  //#region src/commerce-storefront.tsx
@@ -914,15 +1100,72 @@ function CommerceStorefront({ children,...props }) {
914
1100
  }
915
1101
 
916
1102
  //#endregion
917
- //#region src/product-details.tsx
918
- function ProductDetails() {
1103
+ //#region src/product-details-context.tsx
1104
+ const cx = (...classes) => classes.filter(Boolean).join(" ");
1105
+ /** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
1106
+ const partClass = (...classes) => cx("commerce-part", ...classes);
1107
+ const ProductDetailsContext = createContext(null);
1108
+ function useProductDetailsInternal(part) {
1109
+ const context = useContext(ProductDetailsContext);
1110
+ if (!context) throw new Error(`${part} must be rendered inside <ProductDetailsRoot>.`);
1111
+ return context;
1112
+ }
1113
+ /** Read the current product, verified SKU, and purchase status from inside `ProductDetailsRoot`. */
1114
+ function useProductDetails() {
1115
+ const { product, name, selectedSku, quantity, availableQuantity, status, images } = useProductDetailsInternal("useProductDetails");
1116
+ return {
1117
+ product,
1118
+ name,
1119
+ selectedSku,
1120
+ quantity,
1121
+ availableQuantity,
1122
+ status,
1123
+ images
1124
+ };
1125
+ }
1126
+ /**
1127
+ * Build a custom control for one product attribute. Returns `null` when the product has no such attribute.
1128
+ * A selection is never purchasable until the server verifies it.
1129
+ */
1130
+ function useProductDetailsOption(attribute) {
1131
+ const { attributes, selections, selectOption, register } = useProductDetailsInternal("useProductDetailsOption");
1132
+ useEffect(() => register(`attribute:${attribute}`), [attribute, register]);
1133
+ const index = attributes.findIndex((candidate) => candidate.name === attribute);
1134
+ const match = attributes[index];
1135
+ if (!match) return null;
1136
+ const selected = selections[index] || null;
1137
+ return {
1138
+ name: match.name,
1139
+ label: match.label,
1140
+ selected,
1141
+ values: match.values.map((value) => ({
1142
+ name: value.name,
1143
+ label: value.label,
1144
+ selected: value.name === selected
1145
+ })),
1146
+ select: (value) => {
1147
+ if (value === null) selectOption(attribute, "");
1148
+ else if (match.values.some((candidate) => candidate.name === value)) selectOption(attribute, value);
1149
+ }
1150
+ };
1151
+ }
1152
+ /** Register the explicit SKU control so the layout check knows SKU-list products can be completed. */
1153
+ function useSkuOptionRegistration() {
1154
+ const { register } = useProductDetailsInternal("ProductDetailsOptions");
1155
+ useEffect(() => register("sku"), [register]);
1156
+ }
1157
+ /** Loads the product for the route's `:productId` and provides state to product parts. */
1158
+ function ProductDetailsRoot({ children, className }) {
919
1159
  const { productId = "" } = useParams();
920
1160
  const { connection } = useCommerce();
921
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsContent, { productId }, productId) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
1161
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsLoader, {
1162
+ productId,
1163
+ className,
1164
+ children
1165
+ }, productId) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
922
1166
  }
923
- function ProductDetailsContent({ productId }) {
1167
+ function ProductDetailsLoader({ productId, className, children }) {
924
1168
  const { config } = useCommerce();
925
- const fieldId = useId();
926
1169
  const [params, setParams] = useSearchParams();
927
1170
  const [quantity, setQuantity] = useState(1);
928
1171
  const [imageIndex, setImageIndex] = useState(0);
@@ -945,10 +1188,10 @@ function ProductDetailsContent({ productId }) {
945
1188
  const skuOptions = getLabeledSkuOptions(group);
946
1189
  const explicitSku = skuOptions.find((sku) => sku.id === params.get("sku"));
947
1190
  const selections = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? "");
948
- const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value) => value.name === selections[index]));
1191
+ const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value$1) => value$1.name === selections[index]));
949
1192
  const selectionParams = new URLSearchParams();
950
- selections.filter(Boolean).forEach((value) => {
951
- selectionParams.append("attributeValues", value);
1193
+ selections.filter(Boolean).forEach((value$1) => {
1194
+ selectionParams.append("attributeValues", value$1);
952
1195
  });
953
1196
  const matched = useQuery({
954
1197
  retry: false,
@@ -967,20 +1210,34 @@ function ProductDetailsContent({ productId }) {
967
1210
  });
968
1211
  const selectionVerified = complete && product.isSuccess && !product.isFetching && (attributes.length === 0 || matched.isSuccess && !matched.isFetching);
969
1212
  const selectedSku = selectionVerified ? explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group) : null;
970
- const skuId = selectedSku?.id ?? null;
971
- const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node;
1213
+ const status = !complete ? "incomplete" : matched.isError && !matched.isFetching ? "variant-error" : !selectionVerified ? "checking" : selectedSku ? "ready" : attributes.length ? "unavailable" : "not-purchasable";
972
1214
  const selectedImages = getImageUrls(selectedSku);
973
1215
  const images = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))];
974
- const available = getAvailableInventoryQuantity(selectedSku);
975
- const name = group?.label ?? group?.name ?? "Product";
976
- const selectOption = (attribute, value) => {
1216
+ const changeSelection = (key, value$1) => {
977
1217
  const next = new URLSearchParams(params);
978
- if (value) next.set(`option.${attribute}`, value);
979
- else next.delete(`option.${attribute}`);
1218
+ if (value$1) next.set(key, value$1);
1219
+ else next.delete(key);
980
1220
  setParams(next);
981
1221
  setQuantity(1);
982
1222
  setImageIndex(0);
983
1223
  };
1224
+ const registered = useRef(/* @__PURE__ */ new Map());
1225
+ const register = useCallback((control) => {
1226
+ const counts = registered.current;
1227
+ counts.set(control, (counts.get(control) ?? 0) + 1);
1228
+ return () => {
1229
+ const count = (counts.get(control) ?? 1) - 1;
1230
+ if (count > 0) counts.set(control, count);
1231
+ else counts.delete(control);
1232
+ };
1233
+ }, []);
1234
+ const requiredControls = [...attributes.map((attribute) => `attribute:${attribute.name}`), ...skuOptions.length ? ["sku"] : []].join("\n");
1235
+ useEffect(() => {
1236
+ if (process.env.NODE_ENV === "production") return;
1237
+ const missing = requiredControls.split("\n").filter((control) => control && !registered.current.has(control));
1238
+ if (missing.length === 0) return;
1239
+ 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.`);
1240
+ }, [requiredControls]);
984
1241
  if (product.isPending) return /* @__PURE__ */ jsx("p", {
985
1242
  role: "status",
986
1243
  children: "Loading product…"
@@ -1012,167 +1269,321 @@ function ProductDetailsContent({ productId }) {
1012
1269
  to: config.catalogPath,
1013
1270
  children: "Back to shop"
1014
1271
  })] });
1015
- return /* @__PURE__ */ jsxs("article", { children: [/* @__PURE__ */ jsx(Link, {
1016
- 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",
1017
- to: config.catalogPath,
1018
- children: "← Back to shop"
1019
- }), /* @__PURE__ */ jsxs("div", {
1020
- className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
1021
- children: [/* @__PURE__ */ jsxs("div", {
1022
- className: "min-w-0 lg:col-span-7",
1023
- children: [/* @__PURE__ */ jsx("div", {
1024
- className: "aspect-square overflow-hidden rounded-lg",
1025
- children: /* @__PURE__ */ jsx(ProductImage, {
1026
- url: images[imageIndex] ?? images[0],
1027
- name
1028
- }, images[imageIndex] ?? images[0])
1029
- }), images.length > 1 && /* @__PURE__ */ jsx("div", {
1030
- className: "mt-4 flex gap-3 overflow-x-auto",
1031
- children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
1032
- type: "button",
1033
- 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"}`,
1034
- "aria-label": `View image ${index + 1}`,
1035
- "aria-pressed": imageIndex === index,
1036
- onClick: () => setImageIndex(index),
1037
- children: /* @__PURE__ */ jsx(ProductImage, {
1038
- url,
1039
- name: ""
1040
- })
1041
- }, url))
1272
+ const value = {
1273
+ product: group,
1274
+ name: group.label ?? group.name ?? "Product",
1275
+ selectedSku,
1276
+ quantity,
1277
+ availableQuantity: getAvailableInventoryQuantity(selectedSku),
1278
+ status,
1279
+ images,
1280
+ attributes,
1281
+ selections,
1282
+ skuOptions,
1283
+ explicitSkuId: explicitSku?.id ?? "",
1284
+ price: selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node,
1285
+ imageIndex: Math.min(imageIndex, Math.max(images.length - 1, 0)),
1286
+ variantError: matched.error,
1287
+ setImageIndex: (index) => {
1288
+ if (Number.isInteger(index) && index >= 0 && index < images.length) setImageIndex(index);
1289
+ },
1290
+ setQuantity,
1291
+ selectOption: (attribute, next) => changeSelection(`option.${attribute}`, next),
1292
+ selectSku: (skuId) => changeSelection("sku", skuOptions.some((sku) => sku.id === skuId) ? skuId : ""),
1293
+ retryVariant: () => {
1294
+ if (matched.isError) matched.refetch();
1295
+ },
1296
+ register
1297
+ };
1298
+ return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
1299
+ value,
1300
+ children: /* @__PURE__ */ jsx("article", {
1301
+ className: partClass(className),
1302
+ children
1303
+ })
1304
+ });
1305
+ }
1306
+
1307
+ //#endregion
1308
+ //#region src/product-purchase.tsx
1309
+ const disabledReasons = {
1310
+ incomplete: "Select all options to add this item to your cart.",
1311
+ checking: "Checking this variant…",
1312
+ "variant-error": "Availability for this selection could not be checked.",
1313
+ unavailable: "This combination is unavailable. Choose different options.",
1314
+ "not-purchasable": "This product is not currently available to purchase."
1315
+ };
1316
+ const flush = "mb-0";
1317
+ /** The packaged purchase area: options, status, availability, quantity, and add to cart. */
1318
+ function ProductDetailsPurchase({ className }) {
1319
+ useProductDetailsInternal("ProductDetailsPurchase");
1320
+ return /* @__PURE__ */ jsxs("div", {
1321
+ className: partClass("space-y-6", className),
1322
+ children: [
1323
+ /* @__PURE__ */ jsx(OptionControls, {}),
1324
+ /* @__PURE__ */ jsx(PurchaseStatusMessage, { textClassName: flush }),
1325
+ /* @__PURE__ */ jsx(ProductDetailsAvailability, { className: flush }),
1326
+ /* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
1327
+ /* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
1328
+ ]
1329
+ });
1330
+ }
1331
+ /** A select for every product attribute, or the SKU list for products without attributes. */
1332
+ function ProductDetailsOptions({ className }) {
1333
+ const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
1334
+ if (attributes.length === 0 && skuOptions.length === 0) return null;
1335
+ return /* @__PURE__ */ jsx("div", {
1336
+ className: partClass("space-y-6", className),
1337
+ children: /* @__PURE__ */ jsx(OptionControls, {})
1338
+ });
1339
+ }
1340
+ function OptionControls() {
1341
+ const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
1342
+ return /* @__PURE__ */ jsxs(Fragment, { children: [attributes.map((attribute) => /* @__PURE__ */ jsx(ProductDetailsOption, {
1343
+ attribute: attribute.name,
1344
+ className: flush
1345
+ }, attribute.id || attribute.name)), skuOptions.length > 0 && /* @__PURE__ */ jsx(SkuOption, {})] });
1346
+ }
1347
+ function SkuOption() {
1348
+ const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
1349
+ const id = useId();
1350
+ useSkuOptionRegistration();
1351
+ return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
1352
+ htmlFor: id,
1353
+ className: "mb-2 block font-medium",
1354
+ children: "Option"
1355
+ }), /* @__PURE__ */ jsxs("select", {
1356
+ id,
1357
+ className: `${inputClass} w-full`,
1358
+ value: explicitSkuId,
1359
+ onChange: (event) => selectSku(event.target.value),
1360
+ children: [/* @__PURE__ */ jsx("option", {
1361
+ value: "",
1362
+ children: "Select an option"
1363
+ }), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
1364
+ value: sku.id ?? "",
1365
+ children: sku.label?.trim() || sku.name?.trim()
1366
+ }, sku.id))]
1367
+ })] });
1368
+ }
1369
+ /** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
1370
+ function ProductDetailsOption({ attribute, className }) {
1371
+ const option = useProductDetailsOption(attribute);
1372
+ if (!option) return null;
1373
+ return /* @__PURE__ */ jsx("div", {
1374
+ className: partClass(className),
1375
+ children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
1376
+ className: "mb-2 font-medium",
1377
+ children: option.label
1378
+ }), /* @__PURE__ */ jsxs("select", {
1379
+ className: `${inputClass} w-full`,
1380
+ "aria-label": option.label,
1381
+ value: option.selected ?? "",
1382
+ onChange: (event) => option.select(event.target.value || null),
1383
+ children: [/* @__PURE__ */ jsxs("option", {
1384
+ value: "",
1385
+ children: ["Select ", option.label.toLowerCase()]
1386
+ }), option.values.map((value) => /* @__PURE__ */ jsx("option", {
1387
+ value: value.name,
1388
+ children: value.label
1389
+ }, value.name))]
1390
+ })] })
1391
+ });
1392
+ }
1393
+ /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
1394
+ function ProductDetailsPurchaseStatus({ className }) {
1395
+ return /* @__PURE__ */ jsx(PurchaseStatusMessage, { className });
1396
+ }
1397
+ function PurchaseStatusMessage({ className, textClassName }) {
1398
+ const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
1399
+ const part = (children, partClassName) => /* @__PURE__ */ jsx("div", {
1400
+ className: partClass(partClassName, className),
1401
+ children
1402
+ });
1403
+ switch (status) {
1404
+ case "ready": return null;
1405
+ case "variant-error": return part(/* @__PURE__ */ jsxs("div", {
1406
+ role: "alert",
1407
+ className: "text-red-700",
1408
+ children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
1409
+ type: "button",
1410
+ className: "min-h-11 bg-white text-red-700 underline",
1411
+ onClick: retryVariant,
1412
+ children: "Retry availability"
1042
1413
  })]
1043
- }), /* @__PURE__ */ jsxs("div", {
1414
+ }));
1415
+ case "unavailable": return part(/* @__PURE__ */ jsx("p", {
1416
+ role: "status",
1417
+ className: "text-sm text-red-700",
1418
+ children: disabledReasons[status]
1419
+ }), textClassName);
1420
+ case "not-purchasable": return part(/* @__PURE__ */ jsx("p", {
1421
+ className: "text-sm",
1422
+ children: disabledReasons[status]
1423
+ }), textClassName);
1424
+ case "checking": return part(/* @__PURE__ */ jsx("p", {
1425
+ role: "status",
1426
+ children: disabledReasons[status]
1427
+ }), textClassName);
1428
+ case "incomplete": return part(/* @__PURE__ */ jsx("p", {
1429
+ role: "status",
1430
+ className: "text-sm",
1431
+ children: disabledReasons[status]
1432
+ }), textClassName);
1433
+ }
1434
+ }
1435
+ /** Stock for the verified SKU. Renders nothing until a SKU is verified. */
1436
+ function ProductDetailsAvailability({ className }) {
1437
+ const { selectedSku, availableQuantity } = useProductDetailsInternal("ProductDetailsAvailability");
1438
+ if (!selectedSku) return null;
1439
+ return /* @__PURE__ */ jsx("div", {
1440
+ className: partClass(className),
1441
+ children: /* @__PURE__ */ jsx("p", {
1442
+ className: "text-sm",
1443
+ "aria-live": "polite",
1444
+ children: availableQuantity === null ? "Available to order" : availableQuantity === 0 ? "This variant is out of stock" : `${availableQuantity} available`
1445
+ })
1446
+ });
1447
+ }
1448
+ /** Quantity for the verified SKU. Renders nothing until a SKU is verified. */
1449
+ function ProductDetailsQuantity({ className }) {
1450
+ const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
1451
+ const id = useId();
1452
+ if (!selectedSku) return null;
1453
+ return /* @__PURE__ */ jsxs("div", {
1454
+ className: partClass(className),
1455
+ children: [/* @__PURE__ */ jsx("label", {
1456
+ htmlFor: id,
1457
+ className: "mb-2 block font-medium",
1458
+ children: "Quantity"
1459
+ }), /* @__PURE__ */ jsx("input", {
1460
+ id,
1461
+ type: "number",
1462
+ min: 1,
1463
+ max: availableQuantity ?? void 0,
1464
+ step: 1,
1465
+ className: `${inputClass} w-28`,
1466
+ value: Number.isNaN(quantity) ? "" : quantity,
1467
+ onChange: (event) => setQuantity(event.target.valueAsNumber)
1468
+ })]
1469
+ });
1470
+ }
1471
+ /** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
1472
+ function ProductDetailsAddToCart({ className }) {
1473
+ const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
1474
+ const reasonId = useId();
1475
+ return /* @__PURE__ */ jsx("div", {
1476
+ className: partClass(className),
1477
+ children: selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
1478
+ sku: selectedSku,
1479
+ name,
1480
+ quantity
1481
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
1482
+ type: "button",
1483
+ className: `${buttonClass} w-full`,
1484
+ disabled: true,
1485
+ "aria-describedby": reasonId,
1486
+ children: "Add to cart"
1487
+ }), /* @__PURE__ */ jsx("span", {
1488
+ id: reasonId,
1489
+ hidden: true,
1490
+ children: status === "ready" ? "" : disabledReasons[status]
1491
+ })] })
1492
+ });
1493
+ }
1494
+
1495
+ //#endregion
1496
+ //#region src/product-details.tsx
1497
+ /** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
1498
+ function ProductDetails() {
1499
+ return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-8 sm:mb-10" }), /* @__PURE__ */ jsxs("div", {
1500
+ className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
1501
+ children: [/* @__PURE__ */ jsx(ProductDetailsGallery, { className: "min-w-0 lg:col-span-7" }), /* @__PURE__ */ jsxs("div", {
1044
1502
  className: "min-w-0 lg:col-span-5 lg:py-4",
1045
1503
  children: [
1046
- /* @__PURE__ */ jsx("h1", {
1047
- className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
1048
- children: name
1049
- }),
1050
- /* @__PURE__ */ jsxs("div", {
1051
- "aria-live": "polite",
1052
- className: "mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
1053
- "data-testid": "product-price",
1054
- 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", {
1055
- className: "text-lg font-normal",
1056
- children: money(skuPrice.compareAtValue.value, skuPrice.compareAtValue.currencyCode ?? config.currencyCode)
1057
- })]
1058
- }),
1059
- /* @__PURE__ */ jsx("p", {
1060
- className: "my-8 whitespace-pre-line text-base leading-7",
1061
- children: selectedSku?.description ?? group.description
1062
- }),
1063
- /* @__PURE__ */ jsxs("div", {
1064
- className: "space-y-6 border-t border-neutral-200 pt-8",
1065
- children: [
1066
- attributes.map((attribute, index) => /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
1067
- className: "mb-2 font-medium",
1068
- children: attribute.label
1069
- }), /* @__PURE__ */ jsxs("select", {
1070
- className: `${inputClass} w-full`,
1071
- "aria-label": attribute.label,
1072
- value: selections[index],
1073
- onChange: (event) => selectOption(attribute.name, event.target.value),
1074
- children: [/* @__PURE__ */ jsxs("option", {
1075
- value: "",
1076
- children: ["Select ", attribute.label.toLowerCase()]
1077
- }), attribute.values.map((value) => /* @__PURE__ */ jsx("option", {
1078
- value: value.name,
1079
- children: value.label
1080
- }, value.id || value.name))]
1081
- })] }, attribute.id || attribute.name)),
1082
- skuOptions.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
1083
- htmlFor: `${fieldId}-sku-option`,
1084
- className: "mb-2 block font-medium",
1085
- children: "Option"
1086
- }), /* @__PURE__ */ jsxs("select", {
1087
- id: `${fieldId}-sku-option`,
1088
- className: `${inputClass} w-full`,
1089
- value: explicitSku?.id ?? "",
1090
- onChange: (event) => {
1091
- const next = new URLSearchParams(params);
1092
- if (event.target.value) next.set("sku", event.target.value);
1093
- else next.delete("sku");
1094
- setParams(next);
1095
- setQuantity(1);
1096
- setImageIndex(0);
1097
- },
1098
- children: [/* @__PURE__ */ jsx("option", {
1099
- value: "",
1100
- children: "Select an option"
1101
- }), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
1102
- value: sku.id ?? "",
1103
- children: sku.label?.trim() || sku.name?.trim()
1104
- }, sku.id))]
1105
- })] }),
1106
- matched.isError ? /* @__PURE__ */ jsxs("div", {
1107
- role: "alert",
1108
- className: "text-red-700",
1109
- children: [/* @__PURE__ */ jsx("p", { children: message(matched.error) }), /* @__PURE__ */ jsx("button", {
1110
- type: "button",
1111
- className: "min-h-11 bg-white text-red-700 underline",
1112
- onClick: () => {
1113
- if (matched.isError) matched.refetch();
1114
- },
1115
- children: "Retry availability"
1116
- })]
1117
- }) : null,
1118
- !complete && /* @__PURE__ */ jsx("p", {
1119
- id: `${fieldId}-variant-guidance`,
1120
- role: "status",
1121
- className: "text-sm",
1122
- children: "Select all options to add this item to your cart."
1123
- }),
1124
- selectionVerified && attributes.length > 0 && !skuId && /* @__PURE__ */ jsx("p", {
1125
- role: "status",
1126
- className: "text-sm text-red-700",
1127
- children: "This combination is unavailable. Choose different options."
1128
- }),
1129
- selectionVerified && attributes.length === 0 && !skuId && /* @__PURE__ */ jsx("p", {
1130
- className: "text-sm",
1131
- children: "This product is not currently available to purchase."
1132
- }),
1133
- complete && (product.isFetching || matched.isFetching) && /* @__PURE__ */ jsx("p", {
1134
- role: "status",
1135
- children: "Checking this variant…"
1136
- }),
1137
- selectedSku && /* @__PURE__ */ jsxs(Fragment, { children: [
1138
- /* @__PURE__ */ jsx("p", {
1139
- className: "text-sm",
1140
- "aria-live": "polite",
1141
- children: available === null ? "Available to order" : available === 0 ? "This variant is out of stock" : `${available} available`
1142
- }),
1143
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
1144
- htmlFor: `${fieldId}-quantity`,
1145
- className: "mb-2 block font-medium",
1146
- children: "Quantity"
1147
- }), /* @__PURE__ */ jsx("input", {
1148
- id: `${fieldId}-quantity`,
1149
- type: "number",
1150
- min: 1,
1151
- max: available ?? void 0,
1152
- step: 1,
1153
- className: `${inputClass} w-28`,
1154
- value: Number.isNaN(quantity) ? "" : quantity,
1155
- onChange: (event) => setQuantity(event.target.valueAsNumber)
1156
- })] }),
1157
- /* @__PURE__ */ jsx(AddToCartButton, {
1158
- sku: selectedSku,
1159
- name,
1160
- quantity
1161
- })
1162
- ] }),
1163
- !selectedSku && /* @__PURE__ */ jsx("button", {
1164
- type: "button",
1165
- className: `${buttonClass} w-full`,
1166
- disabled: true,
1167
- "aria-describedby": !complete ? `${fieldId}-variant-guidance` : void 0,
1168
- children: "Add to cart"
1169
- })
1170
- ]
1171
- })
1504
+ /* @__PURE__ */ jsx(ProductDetailsTitle, {}),
1505
+ /* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-6" }),
1506
+ /* @__PURE__ */ jsx(ProductDetailsDescription, { className: "my-8" }),
1507
+ /* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "border-t border-neutral-200 pt-8" })
1172
1508
  ]
1173
1509
  })]
1174
1510
  })] });
1175
1511
  }
1512
+ function ProductDetailsBackLink({ className }) {
1513
+ useProductDetailsInternal("ProductDetailsBackLink");
1514
+ const { config } = useCommerce();
1515
+ return /* @__PURE__ */ jsx("div", {
1516
+ className: partClass(className),
1517
+ children: /* @__PURE__ */ jsx(Link, {
1518
+ className: "inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2",
1519
+ to: config.catalogPath,
1520
+ children: "← Back to shop"
1521
+ })
1522
+ });
1523
+ }
1524
+ /** Main image with thumbnails; the selected SKU's images replace the product's when present. */
1525
+ function ProductDetailsGallery({ className }) {
1526
+ const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal("ProductDetailsGallery");
1527
+ return /* @__PURE__ */ jsxs("div", {
1528
+ className: partClass(className),
1529
+ children: [/* @__PURE__ */ jsx("div", {
1530
+ className: "aspect-square overflow-hidden rounded-lg",
1531
+ children: /* @__PURE__ */ jsx(ProductImage, {
1532
+ url: images[imageIndex],
1533
+ name
1534
+ }, images[imageIndex])
1535
+ }), images.length > 1 && /* @__PURE__ */ jsx("div", {
1536
+ className: "mt-4 flex gap-3 overflow-x-auto",
1537
+ children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
1538
+ type: "button",
1539
+ 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"}`,
1540
+ "aria-label": `View image ${index + 1}`,
1541
+ "aria-pressed": imageIndex === index,
1542
+ onClick: () => setImageIndex(index),
1543
+ children: /* @__PURE__ */ jsx(ProductImage, {
1544
+ url,
1545
+ name: ""
1546
+ })
1547
+ }, url))
1548
+ })]
1549
+ });
1550
+ }
1551
+ /** The product name as the page's H1. */
1552
+ function ProductDetailsTitle({ className }) {
1553
+ const { name } = useProductDetailsInternal("ProductDetailsTitle");
1554
+ return /* @__PURE__ */ jsx("div", {
1555
+ className: partClass(className),
1556
+ children: /* @__PURE__ */ jsx("h1", {
1557
+ className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
1558
+ children: name
1559
+ })
1560
+ });
1561
+ }
1562
+ /** The verified SKU's price and compare-at price, or the product's starting price. */
1563
+ function ProductDetailsPrice({ className }) {
1564
+ const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
1565
+ const { config } = useCommerce();
1566
+ return /* @__PURE__ */ jsxs("div", {
1567
+ "aria-live": "polite",
1568
+ className: partClass("flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium", className),
1569
+ "data-testid": "product-price",
1570
+ 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", {
1571
+ className: "text-lg font-normal",
1572
+ children: money(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
1573
+ })]
1574
+ });
1575
+ }
1576
+ /** The verified SKU's description, or the product's. */
1577
+ function ProductDetailsDescription({ className }) {
1578
+ const { selectedSku, product } = useProductDetailsInternal("ProductDetailsDescription");
1579
+ return /* @__PURE__ */ jsx("div", {
1580
+ className: partClass(className),
1581
+ children: /* @__PURE__ */ jsx("p", {
1582
+ className: "whitespace-pre-line text-base leading-7",
1583
+ children: selectedSku?.description ?? product.description
1584
+ })
1585
+ });
1586
+ }
1176
1587
 
1177
1588
  //#endregion
1178
- export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductDetails, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce };
1589
+ export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce, useProductDetails, useProductDetailsOption };