@godaddy/gd-commerce-storefront 0.1.2 → 0.2.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
@@ -84,7 +84,7 @@ A connection failure leaves the surrounding application and its state mounted. C
84
84
 
85
85
  The stylesheet includes all required utilities and scopes them to the package's surfaces. The build removes CSS layer wrappers in their declared order, so the exported CSS can pass through a host Tailwind v3 PostCSS pipeline without `@tailwind` directives. Import it directly; consumers do not need to copy or rewrite the CSS. It does not add a global reset or require dependency scanning by a host Tailwind build. The `theme` prop reaches the drawer even though it is portalled into `document.body`.
86
86
 
87
- By default, `.commerce-storefront` wrappers have transparent backgrounds and inherit the host's text color. Set `--commerce-surface` and `--commerce-text` to explicitly color those wrappers. Controls, muted text, borders, and the drawer retain their neutral palette defaults; set both variables to adapt them to a dark theme. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors. Utility class names and internal markup are not a customization API.
87
+ By default, `.commerce-storefront` wrappers do not paint a background and inherit the host's text color. Set page and section backgrounds on the host application's layout elements. `--commerce-surface` colors controls and the opaque cart drawer, and supplies the base for muted colors; it does not color component wrappers. Set `--commerce-surface` and `--commerce-text` together to adapt controls and the drawer to a dark theme. Avoid setting wrapper backgrounds through CSS or the `theme` prop's `background`, `backgroundColor`, or `backgroundImage` properties, which can still override the transparent default. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors. Utility class names and internal markup are not a customization API.
88
88
 
89
89
  The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization and arbitrary component slots are outside this initial API.
90
90
 
@@ -144,3 +144,45 @@ Build before testing: artifact tests check the compiled JavaScript and shipped C
144
144
  ## License
145
145
 
146
146
  [MIT](LICENSE.md).
147
+
148
+ ### Before a store is connected
149
+
150
+ A site can mount the same components before it has a connected store:
151
+
152
+ ```tsx
153
+ <CommerceStorefront theme={theme}>
154
+ <Header><CartButton /></Header>
155
+ <Catalog sampleProducts />
156
+ </CommerceStorefront>
157
+ ```
158
+
159
+ `Catalog.sampleProducts` defaults to `false`. It allows six built-in sample products
160
+ (Product 1–6, $10–$60 USD) **only** when `/api/commerce/config` reports `state: 'unbound'`.
161
+ When a store is connected, the server reports a different state and the components show real
162
+ products. You do not need to change props or the component tree. The prop has no effect while
163
+ a store is connected. Real empty catalogs and errors never fall back to samples.
164
+
165
+ The sample cart supports adding items, changing quantities (1–999), removing items, and an
166
+ example subtotal. It uses the existing drawer and theme, keeps its items during navigation under
167
+ the shared provider, and resets on reload, unmount, or any change away from `unbound`. It uses no
168
+ live cart storage and makes no product, cart, checkout, or upstream Commerce requests.
169
+ Configuration reads continue. Sample entries are not SKUs or draft orders and never move into a
170
+ real cart. Checkout is unavailable, including direct `useCommerce().checkout()` calls.
171
+
172
+ `useCommerce().connection` now includes `unbound` and `connecting` in addition to `loading`,
173
+ `ready`, and `error`. Only `ready` means a usable store connection. The public `cart` and live
174
+ mutation methods apply only to the connected store; sample cart data is internal. A catalog
175
+ without `sampleProducts`, and direct product-detail visits, show the unconnected state.
176
+ Sample grids and drawers have the attribute `data-commerce-source="sample"`.
177
+
178
+ The provider reads configuration on mount and focus, and every five seconds while the page is
179
+ visible and the state is `unbound` or `connecting`. The first failed refresh hides samples and
180
+ clears the sample cart; a later `unbound` response starts an empty sample cart. While
181
+ connecting, refresh failures keep the connecting state for up to 60 seconds before showing
182
+ the error. Polling stops
183
+ when the state is `ready`; focus and explicit retry still refresh configuration.
184
+
185
+ Use a server that reports connection state before you enable `sampleProducts`. Successful
186
+ config responses without `state` still mean ready. A server that does not report `unbound`
187
+ never shows samples. See the [server contract](docs/server-api.md) for the host's
188
+ responsibilities.
package/dist/index.d.ts CHANGED
@@ -325,12 +325,14 @@ interface CatalogProps {
325
325
  title?: string;
326
326
  description?: string;
327
327
  showHeader?: boolean;
328
+ /** Permit built-in examples only while the host confirms this site is unbound. */
329
+ sampleProducts?: boolean;
328
330
  }
329
331
  declare function Catalog(props: CatalogProps): ReactElement;
330
332
  //#endregion
331
333
  //#region src/commerce-provider.d.ts
332
334
  interface CommerceContextValue {
333
- connection: 'loading' | 'ready' | 'error';
335
+ connection: 'loading' | 'unbound' | 'connecting' | 'ready' | 'error';
334
336
  connectionError: string | null;
335
337
  retryConnection: () => void;
336
338
  theme: StorefrontTheme;
package/dist/index.js CHANGED
@@ -148,6 +148,82 @@ function getProductAttributes(skuGroup) {
148
148
  }) ?? [];
149
149
  }
150
150
 
151
+ //#endregion
152
+ //#region src/sample-cart.tsx
153
+ const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
154
+ key: `sample-${index + 1}`,
155
+ name: `Product ${index + 1}`,
156
+ price: (index + 1) * 1e3
157
+ }));
158
+ const SampleCartContext = createContext(null);
159
+ function useSampleCart() {
160
+ const value = useContext(SampleCartContext);
161
+ if (!value) throw new Error("Mount CommerceProvider around sample products.");
162
+ return value;
163
+ }
164
+ function SampleCartProvider({ enabled, onAdd, announce, children }) {
165
+ const session = useMemo(() => ({ enabled }), [enabled]);
166
+ const active = useRef(session);
167
+ active.current = session;
168
+ const [state, setState] = useState({
169
+ session,
170
+ quantities: {}
171
+ });
172
+ useEffect(() => {
173
+ active.current = session;
174
+ setState({
175
+ session,
176
+ quantities: {}
177
+ });
178
+ return () => {
179
+ active.current = null;
180
+ };
181
+ }, [session]);
182
+ const quantities = state.session === session && enabled ? state.quantities : {};
183
+ function update(key, quantity) {
184
+ if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) return false;
185
+ if (quantity !== void 0 && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) return false;
186
+ setState((previous) => {
187
+ const current = previous.session === session ? previous.quantities : {};
188
+ const next = quantity ?? (current[key] ?? 0) + 1;
189
+ if (next > 999) return previous;
190
+ return {
191
+ session,
192
+ quantities: {
193
+ ...current,
194
+ [key]: next
195
+ }
196
+ };
197
+ });
198
+ return true;
199
+ }
200
+ return /* @__PURE__ */ jsx(SampleCartContext.Provider, {
201
+ value: {
202
+ items: sampleProducts.flatMap((product) => quantities[product.key] ? [{
203
+ ...product,
204
+ quantity: quantities[product.key]
205
+ }] : []),
206
+ add: (key) => {
207
+ const product = sampleProducts.find((product$1) => product$1.key === key);
208
+ if (!product || !update(key)) return false;
209
+ onAdd(product.name);
210
+ return true;
211
+ },
212
+ changeQuantity: async (key, quantity) => {
213
+ const updated = quantity >= 1 && update(key, quantity);
214
+ if (updated) announce("Sample cart quantity updated.");
215
+ return updated;
216
+ },
217
+ remove: async (key) => {
218
+ const updated = update(key, 0);
219
+ if (updated) announce("Sample item removed from cart.");
220
+ return updated;
221
+ }
222
+ },
223
+ children
224
+ });
225
+ }
226
+
151
227
  //#endregion
152
228
  //#region src/commerce-provider.tsx
153
229
  const CommerceContext = createContext(null);
@@ -156,10 +232,19 @@ function useCommerce() {
156
232
  if (!context) throw new Error("Mount CommerceProvider once around the header and page outlet, inside the router.");
157
233
  return context;
158
234
  }
235
+ const CONNECTING_FAILURE_GRACE_MS = 6e4;
159
236
  async function loadConfig(signal) {
160
237
  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;
238
+ if (config?.state === "unbound" || config?.state === "connecting") {
239
+ if ("cartScope" in config || "currencyCode" in config) throw new Error("The store returned invalid configuration.");
240
+ return { state: config.state };
241
+ }
242
+ 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.");
243
+ return {
244
+ state: "ready",
245
+ cartScope: config.cartScope,
246
+ currencyCode: config.currencyCode
247
+ };
163
248
  }
164
249
  function CommerceProvider({ children, catalogPath = "/shop", productPath = "/products", checkoutSuccessPath, theme = {} }) {
165
250
  const config = useQuery({
@@ -167,19 +252,25 @@ function CommerceProvider({ children, catalogPath = "/shop", productPath = "/pro
167
252
  queryKey: ["commerce", "configuration"],
168
253
  queryFn: ({ signal }) => loadConfig(signal),
169
254
  staleTime: 3e4,
170
- refetchOnWindowFocus: true
255
+ refetchOnMount: "always",
256
+ refetchOnWindowFocus: "always",
257
+ refetchInterval: (query) => query.state.data?.state === "unbound" || query.state.data?.state === "connecting" ? 5e3 : false,
258
+ refetchIntervalInBackground: false
171
259
  });
260
+ const holdConnecting = config.isError && config.data?.state === "connecting" && config.errorUpdatedAt - config.dataUpdatedAt < CONNECTING_FAILURE_GRACE_MS;
261
+ let connection = config.data?.state ?? "loading";
262
+ if (config.isError) connection = holdConnecting ? "connecting" : "error";
172
263
  return /* @__PURE__ */ jsx(BoundCommerceProvider, {
173
264
  config: {
174
265
  cartScope: "",
175
266
  currencyCode: "USD",
176
- ...config.data,
267
+ ...config.data?.state === "ready" ? config.data : {},
177
268
  catalogPath,
178
269
  productPath,
179
270
  checkoutSuccessPath
180
271
  },
181
- connection: config.isError ? "error" : config.data ? "ready" : "loading",
182
- connectionError: config.isError ? message(config.error) : null,
272
+ connection,
273
+ connectionError: connection === "error" ? message(config.error) : null,
183
274
  retryConnection: () => {
184
275
  config.refetch();
185
276
  },
@@ -193,8 +284,8 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
193
284
  const ready = connection === "ready";
194
285
  const session = useMemo(() => ({
195
286
  cartIdStorage,
196
- ready
197
- }), [cartIdStorage, ready]);
287
+ connection
288
+ }), [cartIdStorage, connection]);
198
289
  const currentSession = useRef(session);
199
290
  currentSession.current = session;
200
291
  const readyRef = useRef(ready);
@@ -299,6 +390,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
299
390
  setOpen(false);
300
391
  setError(null);
301
392
  setStorageWarning(null);
393
+ setAnnouncement("");
302
394
  setPending(false);
303
395
  setHydrating(ready);
304
396
  if (ready) refresh();
@@ -315,7 +407,7 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
315
407
  window.removeEventListener("storage", onStorage);
316
408
  window.removeEventListener("focus", onFocus);
317
409
  };
318
- }, [cartIdStorage, ready]);
410
+ }, [cartIdStorage, connection]);
319
411
  function addItem(item) {
320
412
  rememberOpener();
321
413
  return run(async () => {
@@ -426,7 +518,16 @@ function BoundCommerceProvider({ config, children, connection, connectionError,
426
518
  refresh,
427
519
  checkout
428
520
  },
429
- children: [children, /* @__PURE__ */ jsx("span", {
521
+ children: [/* @__PURE__ */ jsx(SampleCartProvider, {
522
+ enabled: connection === "unbound",
523
+ announce: setAnnouncement,
524
+ onAdd: (name) => {
525
+ rememberOpener();
526
+ setAnnouncement(`${name} added to your sample cart.`);
527
+ setOpen(true);
528
+ },
529
+ children
530
+ }), /* @__PURE__ */ jsx("span", {
430
531
  className: "commerce-sr-only",
431
532
  role: "status",
432
533
  "aria-live": "polite",
@@ -448,7 +549,10 @@ function StorefrontSurface({ children, className = "" }) {
448
549
  function CommerceStatus() {
449
550
  const { connection, connectionError, retryConnection } = useCommerce();
450
551
  if (connection === "ready") return null;
451
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "loading" ? /* @__PURE__ */ jsx("p", {
552
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "unbound" ? /* @__PURE__ */ jsx("p", {
553
+ role: "status",
554
+ children: "Connect a store to show products."
555
+ }) : connection === "loading" || connection === "connecting" ? /* @__PURE__ */ jsx("p", {
452
556
  role: "status",
453
557
  children: "Connecting to the store…"
454
558
  }) : /* @__PURE__ */ jsxs("div", {
@@ -468,13 +572,14 @@ const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 roun
468
572
  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
573
  function CartButton() {
470
574
  const { cart, setOpen, open, connection } = useCommerce();
471
- const count = cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0;
575
+ const sample = useSampleCart();
576
+ 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
577
  return /* @__PURE__ */ jsx(StorefrontSurface, {
473
578
  className: "commerce-inline",
474
579
  children: /* @__PURE__ */ jsxs("button", {
475
580
  type: "button",
476
581
  className: buttonClass,
477
- disabled: connection !== "ready",
582
+ disabled: connection !== "ready" && connection !== "unbound",
478
583
  "aria-haspopup": "dialog",
479
584
  "aria-expanded": open,
480
585
  onClick: () => setOpen(true),
@@ -521,13 +626,34 @@ function AddToCartButton({ sku, name, quantity = 1 }) {
521
626
  });
522
627
  }
523
628
  function CartDrawer() {
524
- const { config, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity, removeItem, refresh, checkout } = useCommerce();
629
+ const { config, connection, cart, open, setOpen, restoreFocus, pending, hydrating, error, storageWarning, changeQuantity: changeLiveQuantity, removeItem: removeLiveItem, refresh, checkout } = useCommerce();
630
+ const sample = useSampleCart();
631
+ const isSample = connection === "unbound";
632
+ const changeQuantity = isSample ? sample.changeQuantity : changeLiveQuantity;
633
+ const removeItem = isSample ? sample.remove : removeLiveItem;
525
634
  const [drawerAction, setDrawerAction] = useState(null);
526
635
  const checkingOut = drawerAction === "checkout";
527
636
  const locked = pending || hydrating || drawerAction !== null;
528
- const items = cart?.lineItems ?? [];
637
+ const items = isSample ? sample.items.map((item) => ({
638
+ id: item.key,
639
+ name: item.name,
640
+ quantity: item.quantity,
641
+ image: void 0,
642
+ options: void 0,
643
+ subtotal: item.price * item.quantity,
644
+ currency: "USD"
645
+ })) : (cart?.lineItems ?? []).map((item) => ({
646
+ id: item.id,
647
+ name: item.name,
648
+ quantity: item.quantity,
649
+ image: item.details?.productAssetUrl,
650
+ options: item.details?.selectedOptions,
651
+ subtotal: item.totals?.subTotal?.value,
652
+ currency: item.totals?.subTotal?.currencyCode
653
+ }));
529
654
  const summary = getCartSummaryTotals(cart);
530
- const currency = cart?.totals?.total?.currencyCode ?? config.currencyCode;
655
+ 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;
531
657
  async function handleAction(action, operation) {
532
658
  if (locked) return;
533
659
  setDrawerAction(action);
@@ -541,6 +667,7 @@ function CartDrawer() {
541
667
  open,
542
668
  onOpenChange: setOpen,
543
669
  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, {
670
+ "data-commerce-source": isSample ? "sample" : void 0,
544
671
  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
672
  onCloseAutoFocus: (event) => {
546
673
  event.preventDefault();
@@ -551,10 +678,10 @@ function CartDrawer() {
551
678
  className: "flex items-start justify-between border-b border-neutral-200 p-6",
552
679
  children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Dialog.Title, {
553
680
  className: "text-2xl font-semibold",
554
- children: "Your cart"
681
+ children: isSample ? "Sample cart" : "Your cart"
555
682
  }), /* @__PURE__ */ jsx(Dialog.Description, {
556
683
  className: "mt-1 text-sm text-neutral-600",
557
- children: "Review your items before checkout."
684
+ children: isSample ? "Example products and prices in USD. Nothing can be purchased." : "Review your items before checkout."
558
685
  })] }), /* @__PURE__ */ jsx(Dialog.Close, {
559
686
  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
687
  "aria-label": "Close cart",
@@ -595,7 +722,7 @@ function CartDrawer() {
595
722
  }),
596
723
  /* @__PURE__ */ jsx("p", {
597
724
  className: "mt-2 text-neutral-600",
598
- children: "Find something you love in the shop."
725
+ children: isSample ? "Add a sample product to try the cart." : "Find something you love in the shop."
599
726
  }),
600
727
  /* @__PURE__ */ jsx(Link, {
601
728
  className: `${buttonClass} mt-6`,
@@ -612,8 +739,8 @@ function CartDrawer() {
612
739
  "data-testid": "cart-item",
613
740
  children: [/* @__PURE__ */ jsxs("div", {
614
741
  className: "flex gap-4",
615
- children: [item.details?.productAssetUrl && /* @__PURE__ */ jsx("img", {
616
- src: item.details.productAssetUrl,
742
+ children: [item.image && /* @__PURE__ */ jsx("img", {
743
+ src: item.image,
617
744
  alt: "",
618
745
  className: "h-20 w-20 rounded-lg bg-neutral-100 object-cover"
619
746
  }), /* @__PURE__ */ jsxs("div", {
@@ -623,7 +750,7 @@ function CartDrawer() {
623
750
  className: "font-semibold",
624
751
  children: item.name
625
752
  }),
626
- item.details?.selectedOptions?.map((option) => /* @__PURE__ */ jsxs("p", {
753
+ item.options?.map((option) => /* @__PURE__ */ jsxs("p", {
627
754
  className: "text-sm text-neutral-600",
628
755
  children: [
629
756
  option.attribute,
@@ -633,7 +760,7 @@ function CartDrawer() {
633
760
  }, `${option.attribute}:${option.values?.join(",")}`)),
634
761
  /* @__PURE__ */ jsx("p", {
635
762
  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"
763
+ children: typeof item.subtotal === "number" ? money(item.subtotal, item.currency ?? currency) : "Price unavailable"
637
764
  })
638
765
  ]
639
766
  })]
@@ -690,16 +817,19 @@ function CartDrawer() {
690
817
  children: [
691
818
  /* @__PURE__ */ jsx("dl", {
692
819
  className: "space-y-2 text-sm",
693
- children: typeof cart?.totals?.subTotal?.value === "number" && /* @__PURE__ */ jsxs("div", {
820
+ children: (isSample || typeof cart?.totals?.subTotal?.value === "number") && /* @__PURE__ */ jsxs("div", {
694
821
  className: "flex justify-between",
695
- children: [/* @__PURE__ */ jsx("dt", { children: "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(summary.subtotal, cart.totals.subTotal.currencyCode ?? currency) })]
822
+ children: [/* @__PURE__ */ jsx("dt", { children: isSample ? "Sample subtotal" : "Subtotal" }), /* @__PURE__ */ jsx("dd", { children: money(subtotal, isSample ? "USD" : cart?.totals?.subTotal?.currencyCode ?? currency) })]
696
823
  })
697
824
  }),
698
- /* @__PURE__ */ jsx("p", {
825
+ !isSample && /* @__PURE__ */ jsx("p", {
699
826
  className: "commerce-cart-checkout-adjustments-note mt-3 text-sm text-neutral-600",
700
827
  children: "Shipping, taxes, and discounts are calculated at checkout."
701
828
  }),
702
- config.checkoutSuccessPath ? /* @__PURE__ */ jsx("button", {
829
+ isSample ? /* @__PURE__ */ jsx("p", {
830
+ className: "text-sm text-neutral-600",
831
+ children: "Example total in USD. Checkout is not set up yet."
832
+ }) : config.checkoutSuccessPath ? /* @__PURE__ */ jsx("button", {
703
833
  type: "button",
704
834
  className: `${buttonClass} w-full`,
705
835
  disabled: checkingOut || hydrating || cart?.totals?.total?.value == null,
@@ -800,7 +930,7 @@ function ProductCardContent({ product }) {
800
930
  }
801
931
  function Catalog(props) {
802
932
  const { connection } = useCommerce();
803
- return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
933
+ return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(CatalogContent, { ...props }) : connection === "unbound" && props.sampleProducts ? /* @__PURE__ */ jsx(SampleCatalog, { ...props }) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
804
934
  }
805
935
  function CatalogContent({ title = "Shop all products", description = "Explore the collection and find your favorites.", showHeader = true }) {
806
936
  const { config } = useCommerce();
@@ -902,6 +1032,61 @@ function CatalogContent({ title = "Shop all products", description = "Explore th
902
1032
  })
903
1033
  ] });
904
1034
  }
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",
1039
+ 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"
1054
+ }),
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
+ })
1087
+ ]
1088
+ });
1089
+ }
905
1090
 
906
1091
  //#endregion
907
1092
  //#region src/commerce-storefront.tsx
package/dist/styles.css CHANGED
@@ -65,7 +65,6 @@
65
65
  }
66
66
  .commerce-storefront {
67
67
  color: var(--commerce-text, inherit);
68
- background-color: var(--commerce-surface, transparent);
69
68
  font-family: inherit;
70
69
  line-height: 1.5;
71
70
  }
@@ -108,7 +107,6 @@
108
107
  }
109
108
  .commerce-inline {
110
109
  display: inline-block;
111
- background-color: transparent;
112
110
  }
113
111
  .commerce-sr-only {
114
112
  position: absolute;
@@ -101,3 +101,40 @@ The client refreshes the cart before checkout and sends its ID, an absolute cata
101
101
  When using `@godaddy/gd-commerce-server`, configure the router's `checkoutReturnUrls.returnUrls` with the absolute catalog return URL and `checkoutReturnUrls.successUrls` with the absolute success-page URL. The package matches these exact destinations and permits an additional `orderId` parameter on success URLs. Missing policy disables HTTP checkout. Public HTTP requests accept only cart or SKU checkout; non-catalog amounts belong in a trusted server handler.
102
102
 
103
103
  Do not treat `checkoutSuccessPath`, a client-provided URL, the redirect itself, or a draft-order response as proof that payment succeeded. Verify payment using the checkout/payment service or trusted webhook state. Expire or reject paid/closed drafts on subsequent cart reads so returning customers cannot reuse a completed cart.
104
+
105
+ ## Connection state
106
+
107
+ A host may return one of these uncached config responses:
108
+
109
+ ```json
110
+ { "state": "unbound" }
111
+ { "state": "connecting" }
112
+ { "state": "ready", "cartScope": "opaque-cart-scope", "currencyCode": "USD" }
113
+ ```
114
+
115
+ Only an explicit `unbound` response allows `Catalog sampleProducts`. `connecting` suspends all
116
+ catalog and cart actions. Loading, malformed state, HTTP errors, and missing routes never allow
117
+ samples. A successful `{ cartScope, currencyCode }` response without `state` still means ready.
118
+
119
+ `@godaddy/gd-commerce-server` accepts an optional synchronous
120
+ `CommerceConfiguration.readConnectionState(): 'unbound' | 'connecting' | 'ready' | undefined`.
121
+ Return `undefined` to keep the existing config behavior. Return `unbound` only when the host has
122
+ confirmed that no store is connected. Throw when the state is unknown or cannot be read.
123
+ `unbound` and `connecting` responses need no credentials. `ready` still requires `read()` to
124
+ succeed. Data and checkout handlers reject any state other than `ready`, even if credentials
125
+ remain. Do not infer `unbound` from missing credentials or configuration.
126
+
127
+ The host owns the connection lifecycle. Report `ready` only when the store connection is
128
+ complete, and keep failures in a state other than `unbound`. After a store connects, missing
129
+ configuration must never bring samples back. Report `unbound` again only after the store is
130
+ deliberately disconnected. State changes must be visible to the next request. Pages switch to
131
+ live data only when the deployed server reports a new state. If a host's deployments read
132
+ state from deployment configuration, a deployed site keeps showing samples until that
133
+ configuration is updated, for example by publishing again.
134
+
135
+ The storefront polls every five seconds while the page is visible and the state is `unbound` or
136
+ `connecting`, and refreshes on mount and focus. A failed refresh immediately hides samples, without
137
+ automatic request retries. While the last response was `connecting`, refresh failures keep the
138
+ connecting state for up to 60 seconds, so a host restart during connection does not flash an
139
+ error. Polling continues, and a longer failure reports the error. Aborted configuration requests cannot overwrite newer responses.
140
+ 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.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Opinionated React storefront templates for GoDaddy Commerce",
5
5
  "type": "module",
6
6
  "license": "MIT",