@godaddy/gd-commerce-storefront 0.7.0 → 0.9.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/README.md CHANGED
@@ -165,7 +165,7 @@ Add your own elements between parts, reorder them, or leave out display parts. `
165
165
  | `ProductDetailsAvailability` | Stock for the verified SKU; nothing before verification |
166
166
  | `ProductDetailsAddToCart` | Adds the verified SKU and quantity, labeled with the total, such as "Add to cart · $64.00". Until every option is chosen it names the next one, such as "Select size", and pressing it shows an error on each missing option and focuses the first. While a selection is checked or unavailable, it is disabled and its accessible description gives the reason |
167
167
 
168
- Values whose SKUs are all sold out, given the shopper's other selections, are crossed out and announced as "sold out", but stay selectable, so the shopper can change options in any order. This needs every SKU with its attribute values in the product response, which `@godaddy/gd-commerce-server` returns for up to 50 SKUs; for a product with more, no value is marked, and the add button still says "Out of stock" once a sold-out SKU is chosen.
168
+ Values that no active SKU has are left out, so every value shown can be bought, and a link that names one is treated as if that option is not chosen. An attribute that no SKU has a value for keeps all its values. Values whose SKUs are all sold out, given the shopper's other selections, are crossed out and announced as "sold out", but stay selectable, so the shopper can change options in any order. Both need every SKU with its attribute values in the product response, which `@godaddy/gd-commerce-server` returns for up to 50 SKUs; for a product with more, every value is shown, none is marked, and the add button still says "Out of stock" once a sold-out SKU is chosen.
169
169
 
170
170
  Every part is named `ProductDetails*` because it works only inside `ProductDetailsRoot`. `AddToCartButton` is the standalone button for a SKU you already have.
171
171
 
@@ -525,12 +525,16 @@ mutation methods apply only to the connected store; sample cart data is internal
525
525
  without `sampleProducts`, and direct product-detail visits, show the unconnected state.
526
526
  Sample grids and drawers have the attribute `data-commerce-source="sample"`.
527
527
 
528
- The provider reads configuration on mount and focus, and every five seconds while the page is
529
- visible and the state is `unbound` or `connecting`. The first failed refresh hides samples and
530
- clears the sample cart; a later `unbound` response starts an empty sample cart. While
531
- connecting, refresh failures keep the connecting state for up to 60 seconds before showing
532
- the error. Polling stops
533
- when the state is `ready`; focus and explicit retry still refresh configuration.
528
+ The provider reads configuration on mount and focus. While the page is visible and the state is
529
+ `unbound` or `connecting`, it also polls every five seconds for five minutes after the page loads,
530
+ then every minute, and stops thirty minutes after load. Reloading, retrying or a change in the
531
+ reported state starts it again. A page still `connecting` when polling stops shows an error with
532
+ "Retry connection". The first
533
+ failed refresh hides samples and clears the sample cart; a later `unbound` response starts an
534
+ empty sample cart. While connecting, refresh failures keep the connecting
535
+ state for up to 60 seconds before showing the error. Once polling slows to every minute, the next
536
+ failure is already 60 seconds after the last success, so it shows the error at once. Polling
537
+ stops when the state is `ready`; focus and explicit retry still refresh configuration.
534
538
 
535
539
  Use a server that reports connection state before you enable `sampleProducts`. Successful
536
540
  config responses without `state` still mean ready. A server that does not report `unbound`
package/dist/index.js CHANGED
@@ -212,6 +212,11 @@ function useMoney() {
212
212
  return useCallback((value, currency = currencyCode) => money(value, currency, locale), [locale, currencyCode]);
213
213
  }
214
214
  const CONNECTING_FAILURE_GRACE_MS = 6e4;
215
+ /** Polls every five seconds for five minutes after the page loads, then every minute, and stops at thirty. */
216
+ const FAST_POLL_FOR_MS = 5 * 6e4;
217
+ const STOP_POLLING_AT_MS = 30 * 6e4;
218
+ /** A failed retry keeps showing connecting this long so the click visibly does something. */
219
+ const RETRY_FEEDBACK_MS = 1e3;
215
220
  /** `locale` as a canonical tag, or `en-US` when it is not a valid one, so one bad prop cannot break every price. */
216
221
  function resolveLocale(locale) {
217
222
  if (!locale) return "en-US";
@@ -250,6 +255,14 @@ async function loadConfig(signal) {
250
255
  }
251
256
  function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {}, getSwatch, locale, onItemAdded, onEvent }) {
252
257
  const resolvedLocale = useMemo(() => resolveLocale(locale), [locale]);
258
+ const [pollingSince, setPollingSince] = useState(Date.now);
259
+ const [pollingStopped, setPollingStopped] = useState(false);
260
+ const [retrying, setRetrying] = useState(false);
261
+ useEffect(() => {
262
+ setPollingStopped(false);
263
+ const timeout = setTimeout(() => setPollingStopped(true), pollingSince + STOP_POLLING_AT_MS - Date.now());
264
+ return () => clearTimeout(timeout);
265
+ }, [pollingSince]);
253
266
  const config = useQuery({
254
267
  retry: false,
255
268
  queryKey: ["commerce", "configuration"],
@@ -257,12 +270,26 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
257
270
  staleTime: 3e4,
258
271
  refetchOnMount: "always",
259
272
  refetchOnWindowFocus: "always",
260
- refetchInterval: (query) => query.state.data?.state === "unbound" || query.state.data?.state === "connecting" ? 5e3 : false,
273
+ refetchInterval: (query) => {
274
+ const waiting = query.state.data?.state === "unbound" || query.state.data?.state === "connecting";
275
+ const elapsed = Date.now() - pollingSince;
276
+ if (!waiting || elapsed >= STOP_POLLING_AT_MS) return false;
277
+ return elapsed < FAST_POLL_FOR_MS ? 5e3 : 6e4;
278
+ },
261
279
  refetchIntervalInBackground: false
262
280
  });
281
+ const [reported, setReported] = useState(config.data?.state);
282
+ if (reported !== config.data?.state) {
283
+ setReported(config.data?.state);
284
+ setPollingSince(Date.now());
285
+ setPollingStopped(false);
286
+ }
263
287
  const holdConnecting = config.isError && config.data?.state === "connecting" && config.errorUpdatedAt - config.dataUpdatedAt < CONNECTING_FAILURE_GRACE_MS;
264
288
  let connection = config.data?.state ?? "loading";
265
289
  if (config.isError) connection = holdConnecting ? "connecting" : "error";
290
+ const stalled = pollingStopped && connection === "connecting";
291
+ if (stalled) connection = "error";
292
+ if (retrying) connection = "connecting";
266
293
  return /* @__PURE__ */ jsx(BoundCommerceProvider, {
267
294
  config: {
268
295
  cartScope: "",
@@ -273,9 +300,19 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
273
300
  checkoutSuccessPath
274
301
  },
275
302
  connection,
276
- connectionError: connection === "error" ? message(config.error) : null,
303
+ connectionError: connection !== "error" ? null : stalled ? "The store is taking too long to connect." : message(config.error),
277
304
  retryConnection: () => {
278
- config.refetch();
305
+ const started = Date.now();
306
+ setPollingSince(started);
307
+ if (connection !== "error") {
308
+ config.refetch();
309
+ return;
310
+ }
311
+ setRetrying(true);
312
+ config.refetch().then(async (result) => {
313
+ if (result.isError) await new Promise((resolve) => setTimeout(resolve, started + RETRY_FEEDBACK_MS - Date.now()));
314
+ setRetrying(false);
315
+ });
279
316
  },
280
317
  theme,
281
318
  getSwatch,
@@ -2314,11 +2351,14 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2314
2351
  });
2315
2352
  const productNotFound = product.isError && product.error instanceof ApiError && product.error.status === 404;
2316
2353
  const group = product.data?.skuGroup;
2317
- const attributes = getProductAttributes(group);
2354
+ const attributes = getOfferedAttributes(group, getProductAttributes(group));
2318
2355
  const skuOptions = getLabeledSkuOptions(group);
2319
2356
  const explicitSku = skuOptions.find((sku) => sku.id === params.get("sku"));
2320
- const selections = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? "");
2321
- const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value$1) => value$1.name === selections[index]));
2357
+ const selections = attributes.map((attribute) => {
2358
+ const value$1 = params.get(`option.${attribute.name}`) ?? "";
2359
+ return attribute.values.some((candidate) => candidate.name === value$1) ? value$1 : "";
2360
+ });
2361
+ const complete = skuOptions.length > 0 ? !!explicitSku : selections.every(Boolean);
2322
2362
  const selectionParams = new URLSearchParams();
2323
2363
  selections.filter(Boolean).forEach((value$1) => {
2324
2364
  selectionParams.append("attributeValues", value$1);
@@ -2448,15 +2488,39 @@ function ProductDetailsLoader({ productId, className, children, urlState = true,
2448
2488
  function focusFirstMissing(root) {
2449
2489
  root.current?.querySelector("[data-commerce-missing] input:not(:disabled), [data-commerce-missing] select")?.focus();
2450
2490
  }
2491
+ function getSkuAttributeValues(group) {
2492
+ const connection = group?.skus;
2493
+ const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
2494
+ const values = skus.map(getSkuAttributeValueNames);
2495
+ return {
2496
+ skus,
2497
+ values,
2498
+ complete: !connection?.pageInfo?.hasNextPage && (connection?.totalCount ?? skus.length) === skus.length && values.every((names) => names !== null)
2499
+ };
2500
+ }
2501
+ /**
2502
+ * The attributes with only the values some active SKU has, so a shopper is never offered a value that cannot be
2503
+ * bought. Needs every SKU with its attribute values, so it keeps every value for a truncated list or an older
2504
+ * server. An attribute that no SKU has a value for keeps every value, so its control still renders.
2505
+ */
2506
+ function getOfferedAttributes(group, attributes) {
2507
+ const { values, complete } = getSkuAttributeValues(group);
2508
+ if (!complete) return attributes;
2509
+ const offered = new Set(values.flatMap((names) => names ?? []));
2510
+ return attributes.map((attribute) => {
2511
+ const kept = attribute.values.filter((value) => offered.has(value.name));
2512
+ return kept.length ? {
2513
+ ...attribute,
2514
+ values: kept
2515
+ } : attribute;
2516
+ });
2517
+ }
2451
2518
  /**
2452
2519
  * For each attribute, the values whose every SKU, among those with the other selected values, has tracked stock
2453
2520
  * used up. Needs every SKU with its attribute values, so it is empty for a truncated list or an older server.
2454
2521
  */
2455
2522
  function getSoldOutValues(group, attributes, selections) {
2456
- const connection = group?.skus;
2457
- const skus = connection?.edges?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
2458
- const values = skus.map(getSkuAttributeValueNames);
2459
- const complete = !connection?.pageInfo?.hasNextPage && (connection?.totalCount ?? skus.length) === skus.length && values.every((names) => names !== null);
2523
+ const { skus, values, complete } = getSkuAttributeValues(group);
2460
2524
  return attributes.map((attribute, index) => {
2461
2525
  const soldOut = /* @__PURE__ */ new Set();
2462
2526
  if (!complete) return soldOut;
@@ -68,7 +68,7 @@ Exported `SKUGroup`, `SKU`, `Connection`, `SkuGroupsResult` and `SkuGroupResult`
68
68
 
69
69
  For options, return `attributes.edges[].node` with `name`, `label`, and `values.edges[].node` containing `name` and `label`. For example, the attribute `color` has values named `blue` and `clay`. The browser sends those value names as repeated `attributeValues` parameters. Return the group with a SKU connection filtered to that exact combination. Zero or multiple results cannot be purchased. Include accurate `totalCount` and `hasNextPage`; a truncated one-item result must never look like a complete match.
70
70
 
71
- Optionally, give each SKU in the unfiltered response its `attributeValues.edges[].node.name`. With every SKU and its value names, the product page crosses out values whose SKUs are all sold out, given the shopper's other selections. Without them, or when `hasNextPage` is true, no value is marked sold out; the add button still reports a sold-out SKU once it is chosen.
71
+ Optionally, give each SKU in the unfiltered response its `attributeValues.edges[].node.name`. With every SKU and its value names, the product page leaves out values that no SKU has and crosses out values whose SKUs are all sold out, given the shopper's other selections. Without them, or when `hasNextPage` is true, every value is shown and none is marked sold out; the add button still reports a sold-out SKU once it is chosen.
72
72
 
73
73
  Products without attribute definitions can offer explicit SKU selection only when all SKUs are returned with unique IDs and unique nonempty labels/names. Ambiguous or incomplete collections cannot be purchased. No inventory records means untracked inventory; records without an `AVAILABLE` quantity mean unavailable. Supply appropriate records for physical inventory.
74
74
 
@@ -134,9 +134,14 @@ live data only when the deployed server reports a new state. If a host's deploym
134
134
  state from deployment configuration, a deployed site keeps showing samples until that
135
135
  configuration is updated, for example by publishing again.
136
136
 
137
- The storefront polls every five seconds while the page is visible and the state is `unbound` or
138
- `connecting`, and refreshes on mount and focus. A failed refresh immediately hides samples, without
139
- automatic request retries. While the last response was `connecting`, refresh failures keep the
140
- connecting state for up to 60 seconds, so a host restart during connection does not flash an
141
- error. Polling continues, and a longer failure reports the error. Aborted configuration requests cannot overwrite newer responses.
137
+ While the page is visible and the state is `unbound` or `connecting`, the storefront polls every
138
+ five seconds for five minutes after the page loads, then every minute, and stops thirty minutes
139
+ after load. It also refreshes on mount and focus, and reloading or retrying starts polling again. A
140
+ page still `connecting` when polling stops shows an error with a retry button, so finish connecting
141
+ well within thirty minutes. A failed refresh immediately hides
142
+ samples, without automatic request retries. While the last response was `connecting`, refresh
143
+ failures keep the connecting state for up to 60 seconds, so a host restart during connection does
144
+ not flash an error. This holds only in the first five minutes; after that, polls are a minute
145
+ apart, and the first failure reports the error. Polling continues, and a longer failure reports
146
+ the error. Aborted configuration requests cannot overwrite newer responses.
142
147
  Release server and client support before using the `sampleProducts` prop. No new endpoint is required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godaddy/gd-commerce-storefront",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Opinionated React storefront templates for GoDaddy Commerce",
5
5
  "type": "module",
6
6
  "license": "MIT",