@godaddy/gd-commerce-storefront 0.10.0 → 0.11.1

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
@@ -82,6 +82,7 @@ A connection failure leaves the surrounding application and its state mounted. C
82
82
  '--commerce-radius': '0.75rem',
83
83
  '--commerce-sale': '#b42318',
84
84
  '--commerce-on-sale': '#ffffff',
85
+ '--commerce-card-image-aspect': '1 / 1',
85
86
  }}
86
87
  >
87
88
  {/* Register /collection and /item/:productId in your router. */}
@@ -108,6 +109,8 @@ The repository example uses React Router 7. Follow each router version's own Rea
108
109
  | `CommerceStorefront` | Recommended integration: provider and one drawer |
109
110
  | `Catalog` | Six products per cursor page, or `pageSize`. Its `title`, `description`, and `showHeader` props are deprecated; compose `CatalogRoot` instead |
110
111
  | `CatalogRoot` and catalog parts | Build a [custom catalog or card layout](#custom-catalog-layouts) with the same rules as `Catalog` |
112
+ | `useProductCard`, `useCatalog` | Card and catalog state for your own card parts, such as a category line or a product count |
113
+ | `SampleProduct` | The type of [your own sample products](#your-own-sample-products), for a template shown before a store is connected |
111
114
  | `ProductDetails` | Reads `:productId`; validates URL option selections against catalog results. `ProductDetailsRoot` also takes a `productId` prop |
112
115
  | `ProductDetailsRoot` and product parts | Build a [custom product layout](#custom-product-layouts) with the same rules as `ProductDetails` |
113
116
  | `ProductCard` | Renders one `SKUGroup` with direct add or a product-details link; `ProductCardRoot` and card parts customize it |
@@ -305,11 +308,25 @@ As with product layouts, the grid, spacing and border classes come from your own
305
308
  | `ProductCardDescription` | Up to two lines of description; nothing without one. The packaged card leaves it out |
306
309
  | `ProductCardAction` | Adds the product directly only when it has one SKU and no options; otherwise links to the product page to choose. Its children replace the label; as a function, they receive the `action`: `add`, `choose-options` or `view-product` |
307
310
 
308
- `className` on `CatalogProducts` goes on the element whose direct children are the cards, so a host grid places them, and `className` on `ProductCardRoot` goes on each card, so classes such as `col-span-2` work. While products load, that element holds three placeholder cards, so they follow the host's grid. The error with its retry button and, without a `CatalogEmpty`, the empty message render just before it. Card parts take no product, SKU or price props. Inside `CatalogProducts`, `ProductCardRoot` takes no `product` prop either and throws if it gets one, so a card always shows the product it is rendered for. Samples use the same card: they show a placeholder image, no badge, description or links, and add only to the sample cart. Card and catalog parts throw if they are rendered outside their root. Packaged cards fill their grid row, with the price and action at the bottom of each card.
311
+ `className` on `CatalogProducts` goes on the element whose direct children are the cards, so a host grid places them, and `className` on `ProductCardRoot` goes on each card, so classes such as `col-span-2` work. While products load, that element holds three placeholder cards, so they follow the host's grid. The error with its retry button and, without a `CatalogEmpty`, the empty message render just before it. Card parts take no product, SKU or price props. Inside `CatalogProducts`, `ProductCardRoot` takes no `product` prop either and throws if it gets one, so a card always shows the product it is rendered for. Samples use the same card and add only to the sample cart. They have no product page, so their title and image are not links. The packaged samples show a placeholder image and no badge or description; [your own samples](#your-own-sample-products) show their image, description and sale badge. Card images and their loading placeholders are square; set `--commerce-card-image-aspect`, such as `3 / 4`, in the `theme` or on any element around the cards to change that. Card and catalog parts throw if they are rendered outside their root. Packaged cards fill their grid row, with the price and action at the bottom of each card.
309
312
 
310
313
  The root keeps its page cursor in the page URL, as `after`. For a second catalog on the same page, such as a featured row above the main grid, set `urlState={false}` on it so the two do not share one cursor.
311
314
 
312
- For a product the host already has, such as a featured product, use `ProductCard` or `ProductCardRoot` with a `product` prop outside the catalog. `useProductCard()` returns read-only `product` (`null` for samples), `name`, `href` (`null` for samples) and `isSample` for building custom card parts. Every part accepts `className` on an outer wrapper that the package's CSS reset leaves alone, with the same limits as [product parts](#custom-product-layouts).
315
+ For a product the host already has, such as a featured product, use `ProductCard` or `ProductCardRoot` with a `product` prop outside the catalog. `useProductCard()` returns read-only `product` (`null` for samples), `sample` (the sample as you passed it, or `null` for a product), `name`, `href` (`null` for samples) and `isSample` for building custom card parts. `useCatalog()` returns the catalog's `count`, `isSample` and `status` (`loading`, `error` or `ready`) inside `CatalogRoot`, for a custom product count.
316
+
317
+ Elements that are only display, such as a "Bestseller" label, a category line or a "View" link, are your own components, written with these hooks. Write such a component once and read its data where you place it, so the same label works on a card and in the cart:
318
+
319
+ ```tsx
320
+ function CategoryLine() {
321
+ const { sample, product } = useProductCard<RoutineSample>();
322
+ // Samples carry your own fields; live products come from your catalog data.
323
+ // The type is not checked, so a sample from another catalog, such as the packaged ones, may lack your fields.
324
+ const category = sample ? sample.category : categoryFor(product);
325
+ return category ? <p className='eyebrow'>{category}</p> : null;
326
+ }
327
+ ```
328
+
329
+ Every part accepts `className` on an outer wrapper that the package's CSS reset leaves alone, with the same limits as [product parts](#custom-product-layouts).
313
330
 
314
331
  ## Custom cart layouts
315
332
 
@@ -408,7 +425,7 @@ For a cart page instead of, or as well as, the drawer, compose the same parts in
408
425
  Two hooks support custom cart markup:
409
426
 
410
427
  - `useCart()` works anywhere inside `CommerceProvider`. It returns read-only `items`, `itemCount`, `subtotal` (minor units, or `null` when the server has not priced the cart), `currency`, `isSample` and `loading`. Combine `itemCount` with `useCommerce().setOpen(true)` for a custom cart button.
411
- - `useCartItem()` returns the line inside `CartItemRoot`: `id`, `name`, `quantity`, `imageUrl`, `options`, `subtotal` and `currency`.
428
+ - `useCartItem()` returns the line inside `CartItemRoot`: `id`, `name`, `quantity`, `imageUrl`, `options`, `subtotal`, `currency` and `sample` (the sample as it was added, or `null` for a store line).
412
429
 
413
430
  ## Replacing text
414
431
 
@@ -495,6 +512,16 @@ Cart mutations share one queue, including initial cart creation. The cart ID is
495
512
 
496
513
  Changing the server-provided cart scope resets cart state and prevents old responses from affecting the new connection. A failed configuration refresh temporarily disables commerce and also invalidates pending responses. Normal configuration loading and failures do not unmount host content.
497
514
 
515
+ ## Troubleshooting
516
+
517
+ **"The store could not be reached" with `returned an HTML page instead of JSON` in the console.** A request under
518
+ `/api/commerce` got the application's HTML page. The Commerce routes are not mounted on this origin, or a static
519
+ file, single-page app or SSR fallback route answers before them. Mount them at `/api/commerce` ahead of those
520
+ routes; with a separate API server in development, proxy `/api/commerce` to it. Opening `/api/commerce/config` in
521
+ the browser should return JSON. A missing route never shows sample products, even with `sampleProducts`: the server
522
+ must report `state: 'unbound'`. See [Before a store is connected](#before-a-store-is-connected) and the server
523
+ package's [example for a site before its store connects](../commerce-server/README.md#a-site-before-its-store-connects).
524
+
498
525
  ## Guides
499
526
 
500
527
  - [Server API contract](docs/server-api.md)
@@ -531,8 +558,8 @@ A site can mount the same components before it has a connected store:
531
558
  </CommerceStorefront>
532
559
  ```
533
560
 
534
- `Catalog.sampleProducts` defaults to `false`. It allows six built-in sample products
535
- (Product 1–6, $10–$60 USD) **only** when `/api/commerce/config` reports `state: 'unbound'`.
561
+ `Catalog.sampleProducts` defaults to `false`. `true` allows six built-in sample products
562
+ (Product 1–6, $10–$60 USD, without images) **only** when `/api/commerce/config` reports `state: 'unbound'`.
536
563
  When a store is connected, the server reports a different state and the components show real
537
564
  products. You do not need to change props or the component tree. The prop has no effect while
538
565
  a store is connected. Real empty catalogs and errors never fall back to samples.
@@ -561,6 +588,55 @@ state for up to 60 seconds before showing the error. Once polling slows to every
561
588
  failure is already 60 seconds after the last success, so it shows the error at once. Polling
562
589
  stops when the state is `ready`; focus and explicit retry still refresh configuration.
563
590
 
591
+ #### Your own sample products
592
+
593
+ A template can show its own samples instead, with the same rule. Pass them to `sampleProducts` on
594
+ `Catalog` or `CatalogRoot`:
595
+
596
+ ```tsx
597
+ interface RoutineSample extends SampleProduct {
598
+ category: string;
599
+ }
600
+
601
+ const samples: RoutineSample[] = [
602
+ {
603
+ key: 'daily-cleanser',
604
+ name: 'Daily Cleanser',
605
+ price: 3200,
606
+ description: 'Gentle, pH-balanced foam that removes without stripping.',
607
+ imageUrl: '/images/cleanser.jpg',
608
+ category: 'Cleanse',
609
+ },
610
+ ];
611
+
612
+ <Catalog sampleProducts={samples} />
613
+ ```
614
+
615
+ Each sample needs a unique `key`, a `name` and a `price` in minor units of USD. `compareAtPrice`,
616
+ `description` and `imageUrl` are optional. `imageUrl` is any URL the browser can load, such as a
617
+ file in the site's own public folder; the package does not upload or check it. An image that
618
+ fails to load shows the image placeholder on the card and no image in the cart. `CatalogRoot`
619
+ throws, while no store is connected, for an empty list, an entry that is not an object, a
620
+ duplicate or empty key, a packaged sample's key (`sample-1` to `sample-6`), an empty name, a
621
+ `description` or `imageUrl` that is not a string, or a price or compare-at price that is not a
622
+ whole number of 0 or more. Once a store is connected the samples are not shown, so development
623
+ builds log the problem instead and the store's catalog still renders. A list written inline is
624
+ compared by its values, so it does not rebuild the cards when it is unchanged. In a card with
625
+ `ProductCardTitle`, a sample's image is hidden from screen readers, which read the name from the
626
+ title.
627
+
628
+ The card parts show a sample's image, description, price, and a Sale badge and struck-through
629
+ price when `compareAtPrice` is above `price`. Samples have no product page. Fields of your own,
630
+ such as `category`, come back unchanged from `useProductCard().sample` and, once added, from
631
+ `useCartItem().sample`. The type you pass, as in `useProductCard<RoutineSample>()`, is not
632
+ checked, so where other samples can appear, read your own fields as possibly missing. The sample
633
+ cart keeps each sample as it was added, with its image, so the cart still shows it on a page
634
+ without the catalog. While a catalog with a sample of the same key is mounted, the cart shows
635
+ that sample's current fields, so a price or name loaded later matches the card. The cart lists
636
+ packaged samples in catalog order and your own in the order they were added, after them. Once a
637
+ store is connected, the same card tree shows the store's products, so build the card for both:
638
+ your own parts read `sample` before a store is connected and `product` after.
639
+
564
640
  Use a server that reports connection state before you enable `sampleProducts`. Successful
565
641
  config responses without `state` still mean ready. A server that does not report `unbound`
566
642
  never shows samples. See the [server contract](docs/server-api.md) for the host's
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as StorefrontMessages, i as StorefrontMessageKey, n as MessageValues, o as enUS, r as StorefrontMessage, s as formatMessage, t as CommerceErrorCode } from "./messages-CHl-bWx9.js";
1
+ import { a as StorefrontMessages, i as StorefrontMessageKey, n as MessageValues, o as enUS, r as StorefrontMessage, s as formatMessage, t as CommerceErrorCode } from "./messages-peJIfMO4.js";
2
2
  import { CSSProperties, ReactElement, ReactNode } from "react";
3
3
  import * as react_jsx_runtime0 from "react/jsx-runtime";
4
4
 
@@ -376,9 +376,192 @@ interface AddToCartButtonProps {
376
376
  }
377
377
  declare function AddToCartButton(props: AddToCartButtonProps): ReactElement;
378
378
  //#endregion
379
+ //#region src/commerce-provider.d.ts
380
+ interface CommerceContextValue {
381
+ connection: 'loading' | 'unbound' | 'connecting' | 'ready' | 'error';
382
+ connectionError: string | null;
383
+ retryConnection: () => void;
384
+ theme: StorefrontTheme;
385
+ /** The host's swatch lookup, or `undefined` when options render as buttons. */
386
+ getSwatch: GetSwatch | undefined;
387
+ config: StorefrontConfig;
388
+ /** The BCP 47 locale that prices are formatted in, such as `en-US`. */
389
+ locale: string;
390
+ /** The package's text: English, with the provider's `messages` over it. */
391
+ messages: StorefrontMessages;
392
+ cart: CartOrder | null;
393
+ /** Whether the cart drawer is open. Always `false` when no `CartDrawer` or `CartDrawerRoot` is mounted. */
394
+ open: boolean;
395
+ /** Opens or closes the cart drawer. Opening does nothing without a mounted drawer. */
396
+ setOpen: (open: boolean) => void;
397
+ /** Restores the initiating control after the cart drawer closes. */
398
+ restoreFocus: () => void;
399
+ pending: boolean;
400
+ hydrating: boolean;
401
+ error: string | null;
402
+ storageWarning: string | null;
403
+ announcement: string;
404
+ addItem: (item: AddToCartItemInput) => Promise<boolean>;
405
+ changeQuantity: (itemId: string, quantity: number) => Promise<boolean>;
406
+ removeItem: (itemId: string) => Promise<boolean>;
407
+ applyDiscount: (code: string) => Promise<boolean>;
408
+ refresh: () => Promise<boolean>;
409
+ checkout: () => Promise<boolean>;
410
+ }
411
+ declare function useCommerce(): CommerceContextValue;
412
+ /**
413
+ * Formats minor units, such as cents, in the provider's `locale`, the way the package's own parts do. Uses the
414
+ * store's currency unless `currency` is given.
415
+ */
416
+ declare function useMoney(): (value: number, currency?: string) => string;
417
+ /** `useMoney` for a range, such as `$20.00 – $25.00`, written the way the provider's `locale` writes ranges. */
418
+ declare function useMoneyRange(): (min: number, max: number, currency?: string) => string;
419
+ /**
420
+ * The provider's text for `key`, with `{placeholders}` filled from `values`, the way the package's own parts read
421
+ * it. For custom parts that should match the package's language. English outside a provider.
422
+ */
423
+ declare function useMessage(): (key: StorefrontMessageKey, values?: MessageValues) => string;
424
+ type StorefrontTheme = CSSProperties & {
425
+ '--commerce-accent'?: string;
426
+ '--commerce-accent-hover'?: string;
427
+ '--commerce-on-accent'?: string;
428
+ '--commerce-surface'?: string;
429
+ '--commerce-text'?: string;
430
+ '--commerce-radius'?: string;
431
+ /** Fills the Sale badge. Defaults to a red that white text reads on. */
432
+ '--commerce-sale'?: string;
433
+ /** Text on the Sale badge. Defaults to white. */
434
+ '--commerce-on-sale'?: string;
435
+ /**
436
+ * The aspect ratio of catalog card images and their loading placeholders, such as `3 / 4`. Defaults to `1 / 1`.
437
+ * Can also be set on a card or any element around it.
438
+ */
439
+ '--commerce-card-image-aspect'?: string;
440
+ };
441
+ /** The attribute value a host can give a swatch, such as `{ attribute: 'color', name: 'forest', label: 'Forest' }`. */
442
+ interface SwatchValue {
443
+ readonly attribute: string;
444
+ readonly name: string;
445
+ readonly label: string;
446
+ }
447
+ /**
448
+ * Returns a CSS `background` for a value, such as `'#3f6352'` or `'url(/swatches/linen.png) center / cover'`, or
449
+ * nothing for no swatch. An attribute shows swatches only when every value has one.
450
+ */
451
+ type GetSwatch = (value: SwatchValue) => string | null | undefined;
452
+ interface CommerceProviderProps {
453
+ children: ReactNode;
454
+ catalogPath?: string;
455
+ productPath?: string;
456
+ /** Applied to storefront surfaces and the portalled cart drawer. */
457
+ theme?: StorefrontTheme;
458
+ /**
459
+ * Swatches for attribute values, off by default. Attributes whose values all have a swatch render as swatches on
460
+ * the product page, and cards show them as dots. See `GetSwatch`.
461
+ */
462
+ getSwatch?: GetSwatch;
463
+ /** Set only when your server has enabled checkout and this return route exists. */
464
+ checkoutSuccessPath?: string;
465
+ /**
466
+ * The BCP 47 locale for formatting prices, such as `de-DE`. Defaults to `en-US`; an invalid tag falls back to it.
467
+ * It never selects UI text; set `messages` for that.
468
+ */
469
+ locale?: string;
470
+ /**
471
+ * The package's text, such as `frFR` from `@godaddy/gd-commerce-storefront/messages/fr-FR`. Keys left out use
472
+ * English. Independent of `locale`, so set both for a translated store. Part `children` and props such as
473
+ * `CatalogHeader`'s `title` still replace the text where given.
474
+ */
475
+ messages?: Partial<StorefrontMessages>;
476
+ /**
477
+ * Called after an item is added, live or sample. With a drawer mounted, the drawer also opens. Without one, such as
478
+ * on a site with a cart page, use it to confirm the add.
479
+ */
480
+ onItemAdded?: (item: AddedCartItem) => void;
481
+ /** Called for every cart change and failure, such as for analytics. See `CommerceEvent`. */
482
+ onEvent?: (event: CommerceEvent) => void;
483
+ }
484
+ /** A cart operation that can fail. */
485
+ type CommerceOperation = 'add' | 'change-quantity' | 'remove' | 'discount' | 'refresh' | 'checkout';
486
+ /**
487
+ * What `onEvent` reports. Live changes are reported once the server returns the updated cart; sample changes have
488
+ * `isSample: true` and never reach the store. New event types may be added, so ignore types you do not handle.
489
+ */
490
+ type CommerceEvent = {
491
+ readonly type: 'item-added';
492
+ /** `null` for a sample product, which is not a SKU. */
493
+ readonly skuId: string | null;
494
+ readonly name: string;
495
+ readonly quantity: number;
496
+ readonly isSample: boolean;
497
+ } | {
498
+ readonly type: 'quantity-changed';
499
+ readonly itemId: string;
500
+ readonly quantity: number;
501
+ readonly isSample: boolean;
502
+ } | {
503
+ readonly type: 'item-removed';
504
+ readonly itemId: string;
505
+ readonly isSample: boolean;
506
+ } | {
507
+ readonly type: 'discount-applied';
508
+ readonly code: string;
509
+ }
510
+ /** Sent just before the browser leaves for checkout. */ | {
511
+ readonly type: 'checkout-started';
512
+ readonly cartId: string;
513
+ }
514
+ /**
515
+ * `message` is the one the cart shows, in the provider's language. `code` is the server's failure code, such as
516
+ * `scope_mismatch`, when it sent one.
517
+ */ | {
518
+ readonly type: 'error';
519
+ readonly operation: CommerceOperation;
520
+ readonly message: string;
521
+ readonly code?: string;
522
+ };
523
+ /** The item passed to `onItemAdded`. */
524
+ interface AddedCartItem {
525
+ readonly name: string;
526
+ readonly quantity: number;
527
+ /** `true` for the sample cart, before a store is connected. */
528
+ readonly isSample: boolean;
529
+ }
530
+ declare function CommerceProvider({
531
+ children,
532
+ catalogPath,
533
+ productPath,
534
+ checkoutSuccessPath,
535
+ theme,
536
+ getSwatch,
537
+ locale,
538
+ messages,
539
+ onItemAdded,
540
+ onEvent
541
+ }: CommerceProviderProps): ReactElement;
542
+ //#endregion
543
+ //#region src/sample-cart.d.ts
544
+ /**
545
+ * A product to show while no store is connected. Deliberately distinct from SKU groups, SKUs and draft orders: it
546
+ * is never sent to Commerce. Add any fields of your own, such as a category, and read them back from `sample` in
547
+ * `useProductCard()` and `useCartItem()`.
548
+ */
549
+ interface SampleProduct {
550
+ /** Unique among the site's samples. `sample-1` to `sample-6` are the packaged samples' keys. */
551
+ readonly key: string;
552
+ readonly name: string;
553
+ /** In minor units of `sampleCurrency`, such as 3200 for $32.00. */
554
+ readonly price: number;
555
+ /** A higher earlier price, in minor units. The card then shows the sale badge and the struck-through price. */
556
+ readonly compareAtPrice?: number;
557
+ readonly description?: string;
558
+ /** Any URL the browser can load, such as a file in the site's own public folder. */
559
+ readonly imageUrl?: string;
560
+ }
561
+ //#endregion
379
562
  //#region src/cart-state.d.ts
380
563
  /** One cart line, for custom cart parts. */
381
- interface CartItemState {
564
+ interface CartItemState<Sample extends SampleProduct = SampleProduct> {
382
565
  /** Empty when the server returned a line without an ID. Such a line cannot be changed or removed. */
383
566
  readonly id: string;
384
567
  readonly name: string;
@@ -391,6 +574,8 @@ interface CartItemState {
391
574
  /** The line total in minor units, or `null` when the server did not price it. */
392
575
  readonly subtotal: number | null;
393
576
  readonly currency: string;
577
+ /** The sample as it was added, with the site's own fields. `null` for a line in the connected store's cart. */
578
+ readonly sample: Sample | null;
394
579
  }
395
580
  /** Read-only cart contents: the connected store's cart, or the sample cart while no store is connected. */
396
581
  interface CartState {
@@ -473,8 +658,12 @@ declare function CartClose({
473
658
  declare function CartNotices({
474
659
  className
475
660
  }: CartPartProps): ReactElement | null;
476
- /** Read the line from inside `CartItemRoot`, for custom item parts. */
477
- declare function useCartItem(): CartItemState;
661
+ /**
662
+ * Read the line from inside `CartItemRoot`, for custom item parts. Pass the type of the site's samples, such as
663
+ * `useCartItem<MySample>()`, to read their own fields from `sample`. The type is not checked: when the cart can hold
664
+ * samples from more than one catalog, read the site's own fields as possibly missing.
665
+ */
666
+ declare function useCartItem<Sample extends SampleProduct = SampleProduct>(): CartItemState<Sample>;
478
667
  interface CartItemsProps extends CartPartProps {
479
668
  /** One item, rendered for every cart line. Defaults to the packaged item. */
480
669
  children?: ReactNode;
@@ -574,13 +763,29 @@ declare function CartDrawer(): ReactElement;
574
763
  interface CatalogPartProps {
575
764
  className?: string;
576
765
  }
766
+ /** Read-only catalog state for custom catalog parts rendered inside `CatalogRoot`. */
767
+ interface CatalogState {
768
+ /**
769
+ * Products on the current page: `0` while loading or after a first load fails. A failed refetch keeps the last page,
770
+ * so `status` can be `error` while `count` is above `0`.
771
+ */
772
+ readonly count: number;
773
+ readonly isSample: boolean;
774
+ /** Samples are always `ready`. */
775
+ readonly status: 'loading' | 'error' | 'ready';
776
+ }
777
+ /** Read how many products the catalog shows, and whether they are samples, for a custom summary. */
778
+ declare function useCatalog(): CatalogState;
577
779
  interface CatalogRootProps {
578
780
  children: ReactNode;
579
781
  /** Added to the root `<section>`. */
580
782
  className?: string;
581
- /** Permit built-in examples only while the host confirms this site is unbound. */
582
- sampleProducts?: boolean;
583
- /** Products per page, a whole number of 1 or more. Defaults to 6. Samples are always one page of six. */
783
+ /**
784
+ * Products to show only while the server reports no connected store (`unbound`): `true` for the six packaged
785
+ * samples, or the site's own. Never shown once a store is connected. Defaults to `false`.
786
+ */
787
+ sampleProducts?: boolean | readonly SampleProduct[];
788
+ /** Products per page, a whole number of 1 or more. Defaults to 6. Samples are always one page. */
584
789
  pageSize?: number;
585
790
  /**
586
791
  * Keep the page cursor in the page URL, as `after`, so a reload or a shared link keeps the page. Defaults to
@@ -588,7 +793,7 @@ interface CatalogRootProps {
588
793
  */
589
794
  urlState?: boolean;
590
795
  }
591
- /** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */
796
+ /** Loads one cursor page of products, or the samples, and provides them to catalog parts. */
592
797
  declare function CatalogRoot({
593
798
  children,
594
799
  className,
@@ -649,8 +854,8 @@ interface CatalogProps {
649
854
  description?: string;
650
855
  /** @deprecated To render the page's own H1, compose `CatalogRoot` without `CatalogHeader` instead. */
651
856
  showHeader?: boolean;
652
- /** Permit built-in examples only while the host confirms this site is unbound. */
653
- sampleProducts?: boolean;
857
+ /** Products to show only while no store is connected, as in `CatalogRoot`. */
858
+ sampleProducts?: boolean | readonly SampleProduct[];
654
859
  /** Products per page. Defaults to 6. */
655
860
  pageSize?: number;
656
861
  }
@@ -663,165 +868,6 @@ declare function Catalog({
663
868
  pageSize
664
869
  }: CatalogProps): ReactElement;
665
870
  //#endregion
666
- //#region src/commerce-provider.d.ts
667
- interface CommerceContextValue {
668
- connection: 'loading' | 'unbound' | 'connecting' | 'ready' | 'error';
669
- connectionError: string | null;
670
- retryConnection: () => void;
671
- theme: StorefrontTheme;
672
- /** The host's swatch lookup, or `undefined` when options render as buttons. */
673
- getSwatch: GetSwatch | undefined;
674
- config: StorefrontConfig;
675
- /** The BCP 47 locale that prices are formatted in, such as `en-US`. */
676
- locale: string;
677
- /** The package's text: English, with the provider's `messages` over it. */
678
- messages: StorefrontMessages;
679
- cart: CartOrder | null;
680
- /** Whether the cart drawer is open. Always `false` when no `CartDrawer` or `CartDrawerRoot` is mounted. */
681
- open: boolean;
682
- /** Opens or closes the cart drawer. Opening does nothing without a mounted drawer. */
683
- setOpen: (open: boolean) => void;
684
- /** Restores the initiating control after the cart drawer closes. */
685
- restoreFocus: () => void;
686
- pending: boolean;
687
- hydrating: boolean;
688
- error: string | null;
689
- storageWarning: string | null;
690
- announcement: string;
691
- addItem: (item: AddToCartItemInput) => Promise<boolean>;
692
- changeQuantity: (itemId: string, quantity: number) => Promise<boolean>;
693
- removeItem: (itemId: string) => Promise<boolean>;
694
- applyDiscount: (code: string) => Promise<boolean>;
695
- refresh: () => Promise<boolean>;
696
- checkout: () => Promise<boolean>;
697
- }
698
- declare function useCommerce(): CommerceContextValue;
699
- /**
700
- * Formats minor units, such as cents, in the provider's `locale`, the way the package's own parts do. Uses the
701
- * store's currency unless `currency` is given.
702
- */
703
- declare function useMoney(): (value: number, currency?: string) => string;
704
- /** `useMoney` for a range, such as `$20.00 – $25.00`, written the way the provider's `locale` writes ranges. */
705
- declare function useMoneyRange(): (min: number, max: number, currency?: string) => string;
706
- /**
707
- * The provider's text for `key`, with `{placeholders}` filled from `values`, the way the package's own parts read
708
- * it. For custom parts that should match the package's language. English outside a provider.
709
- */
710
- declare function useMessage(): (key: StorefrontMessageKey, values?: MessageValues) => string;
711
- type StorefrontTheme = CSSProperties & {
712
- '--commerce-accent'?: string;
713
- '--commerce-accent-hover'?: string;
714
- '--commerce-on-accent'?: string;
715
- '--commerce-surface'?: string;
716
- '--commerce-text'?: string;
717
- '--commerce-radius'?: string;
718
- /** Fills the Sale badge. Defaults to a red that white text reads on. */
719
- '--commerce-sale'?: string;
720
- /** Text on the Sale badge. Defaults to white. */
721
- '--commerce-on-sale'?: string;
722
- };
723
- /** The attribute value a host can give a swatch, such as `{ attribute: 'color', name: 'forest', label: 'Forest' }`. */
724
- interface SwatchValue {
725
- readonly attribute: string;
726
- readonly name: string;
727
- readonly label: string;
728
- }
729
- /**
730
- * Returns a CSS `background` for a value, such as `'#3f6352'` or `'url(/swatches/linen.png) center / cover'`, or
731
- * nothing for no swatch. An attribute shows swatches only when every value has one.
732
- */
733
- type GetSwatch = (value: SwatchValue) => string | null | undefined;
734
- interface CommerceProviderProps {
735
- children: ReactNode;
736
- catalogPath?: string;
737
- productPath?: string;
738
- /** Applied to storefront surfaces and the portalled cart drawer. */
739
- theme?: StorefrontTheme;
740
- /**
741
- * Swatches for attribute values, off by default. Attributes whose values all have a swatch render as swatches on
742
- * the product page, and cards show them as dots. See `GetSwatch`.
743
- */
744
- getSwatch?: GetSwatch;
745
- /** Set only when your server has enabled checkout and this return route exists. */
746
- checkoutSuccessPath?: string;
747
- /**
748
- * The BCP 47 locale for formatting prices, such as `de-DE`. Defaults to `en-US`; an invalid tag falls back to it.
749
- * It never selects UI text; set `messages` for that.
750
- */
751
- locale?: string;
752
- /**
753
- * The package's text, such as `frFR` from `@godaddy/gd-commerce-storefront/messages/fr-FR`. Keys left out use
754
- * English. Independent of `locale`, so set both for a translated store. Part `children` and props such as
755
- * `CatalogHeader`'s `title` still replace the text where given.
756
- */
757
- messages?: Partial<StorefrontMessages>;
758
- /**
759
- * Called after an item is added, live or sample. With a drawer mounted, the drawer also opens. Without one, such as
760
- * on a site with a cart page, use it to confirm the add.
761
- */
762
- onItemAdded?: (item: AddedCartItem) => void;
763
- /** Called for every cart change and failure, such as for analytics. See `CommerceEvent`. */
764
- onEvent?: (event: CommerceEvent) => void;
765
- }
766
- /** A cart operation that can fail. */
767
- type CommerceOperation = 'add' | 'change-quantity' | 'remove' | 'discount' | 'refresh' | 'checkout';
768
- /**
769
- * What `onEvent` reports. Live changes are reported once the server returns the updated cart; sample changes have
770
- * `isSample: true` and never reach the store. New event types may be added, so ignore types you do not handle.
771
- */
772
- type CommerceEvent = {
773
- readonly type: 'item-added';
774
- /** `null` for a sample product, which is not a SKU. */
775
- readonly skuId: string | null;
776
- readonly name: string;
777
- readonly quantity: number;
778
- readonly isSample: boolean;
779
- } | {
780
- readonly type: 'quantity-changed';
781
- readonly itemId: string;
782
- readonly quantity: number;
783
- readonly isSample: boolean;
784
- } | {
785
- readonly type: 'item-removed';
786
- readonly itemId: string;
787
- readonly isSample: boolean;
788
- } | {
789
- readonly type: 'discount-applied';
790
- readonly code: string;
791
- }
792
- /** Sent just before the browser leaves for checkout. */ | {
793
- readonly type: 'checkout-started';
794
- readonly cartId: string;
795
- }
796
- /**
797
- * `message` is the one the cart shows, in the provider's language. `code` is the server's failure code, such as
798
- * `scope_mismatch`, when it sent one.
799
- */ | {
800
- readonly type: 'error';
801
- readonly operation: CommerceOperation;
802
- readonly message: string;
803
- readonly code?: string;
804
- };
805
- /** The item passed to `onItemAdded`. */
806
- interface AddedCartItem {
807
- readonly name: string;
808
- readonly quantity: number;
809
- /** `true` for the sample cart, before a store is connected. */
810
- readonly isSample: boolean;
811
- }
812
- declare function CommerceProvider({
813
- children,
814
- catalogPath,
815
- productPath,
816
- checkoutSuccessPath,
817
- theme,
818
- getSwatch,
819
- locale,
820
- messages,
821
- onItemAdded,
822
- onEvent
823
- }: CommerceProviderProps): ReactElement;
824
- //#endregion
825
871
  //#region src/commerce-storefront.d.ts
826
872
  /** Mount once inside the application's Router and QueryClientProvider. */
827
873
  declare function CommerceStorefront({
@@ -847,9 +893,11 @@ declare function ProductImage({
847
893
  className
848
894
  }: ProductImageProps): ReactElement;
849
895
  /** Read-only card state for custom parts rendered inside `ProductCardRoot`. */
850
- interface ProductCardState {
851
- /** `null` for a built-in sample, which is not a catalog product. */
896
+ interface ProductCardState<Sample extends SampleProduct = SampleProduct> {
897
+ /** `null` for a sample, which is not a catalog product. */
852
898
  readonly product: SKUGroup | null;
899
+ /** The sample as the site passed it to `sampleProducts`, with its own fields. `null` for a catalog product. */
900
+ readonly sample: Sample | null;
853
901
  readonly name: string;
854
902
  /** The product page, or `null` for a sample, which has none. */
855
903
  readonly href: string | null;
@@ -859,8 +907,12 @@ interface ProductCardState {
859
907
  interface ProductCardPartProps {
860
908
  className?: string;
861
909
  }
862
- /** Read the card's product, name, and link from inside `ProductCardRoot`. */
863
- declare function useProductCard(): ProductCardState;
910
+ /**
911
+ * Read the card's product or sample, name, and link from inside `ProductCardRoot`. Pass the type of the site's
912
+ * samples, such as `useProductCard<MySample>()`, to read their own fields from `sample`. The type is not checked:
913
+ * on a page that also shows the packaged samples, read the site's own fields as possibly missing.
914
+ */
915
+ declare function useProductCard<Sample extends SampleProduct = SampleProduct>(): ProductCardState<Sample>;
864
916
  interface ProductCardRootProps {
865
917
  children: ReactNode;
866
918
  /** The catalog product to show. Leave it out inside `CatalogProducts`, which supplies each product. */
@@ -875,8 +927,9 @@ declare function ProductCardRoot({
875
927
  className
876
928
  }: ProductCardRootProps): ReactElement;
877
929
  /**
878
- * The product's first image, linked to its page. Samples show a placeholder. With a `ProductCardTitle` in the card,
879
- * the title is the one link keyboard and screen reader users get; without one, the image link is.
930
+ * The product's first image, linked to its page. A sample shows its `imageUrl`, unlinked, or the packaged samples'
931
+ * placeholder. With a `ProductCardTitle` in the card, the title is the one link keyboard and screen reader users get,
932
+ * and a sample's image is hidden from screen readers so its name is not read twice; without one, the image is exposed.
880
933
  */
881
934
  declare function ProductCardImage({
882
935
  className
@@ -885,13 +938,14 @@ declare function ProductCardImage({
885
938
  declare function ProductCardTitle({
886
939
  className
887
940
  }: ProductCardPartProps): ReactElement;
888
- /** Up to three lines of the product description. Renders nothing without one. */
941
+ /** Up to two lines of the product or sample description. Renders nothing without one. */
889
942
  declare function ProductCardDescription({
890
943
  className
891
944
  }: ProductCardPartProps): ReactElement | null;
892
945
  /**
893
946
  * "Sale" when some SKU's compare-at price is above that SKU's price. Without SKU prices, as in the catalog list, only
894
- * when the lowest compare-at price is above the highest price. Renders nothing otherwise, or for samples.
947
+ * when the lowest compare-at price is above the highest price. For a sample, when its `compareAtPrice` is above its
948
+ * price. Renders nothing otherwise.
895
949
  */
896
950
  declare function ProductCardBadge({
897
951
  className
@@ -1133,4 +1187,4 @@ declare function RelatedProducts({
1133
1187
  //#region src/storefront-surface.d.ts
1134
1188
  declare function CommerceStatus(): ReactElement | null;
1135
1189
  //#endregion
1136
- export { AddToCartButton, type AddToCartButtonProps, AddToCartItemInput, type AddToCartLabelState, type AddedCartItem, ApiError, type CartActionPartProps, CartAddress, CartButton, type CartButtonProps, CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDiscount, CartDrawer, CartDrawerRoot, type CartDrawerRootProps, CartEmpty, type CartEmptyProps, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, type CartItemRootProps, type CartItemState, CartItems, type CartItemsProps, CartLineItem, CartLineItemDetails, CartNote, CartNotices, CartOrder, type CartPartProps, CartRoot, type CartRootProps, CartSelectedAddon, CartSelectedAddonValue, CartSelectedOption, CartShippingInfo, type CartState, CartSubtotal, CartSummary, type CartSummaryProps, CartSummaryTotals, CartTax, type CartTextPartProps, CartTitle, Catalog, CatalogEmpty, type CatalogEmptyProps, CatalogHeader, type CatalogHeaderProps, CatalogPagination, type CatalogPartProps, CatalogProducts, type CatalogProductsProps, type CatalogProps, CatalogRoot, type CatalogRootProps, CatalogSummary, type CommerceContextValue, type CommerceErrorCode, type CommerceEvent, type CommerceOperation, CommerceProvider, type CommerceProviderProps, CommerceStatus, CommerceStorefront, Connection, ConnectionEdge, DraftOrderContext, type GetSwatch, InventoryCount, LabelFilter, LineItemTotals, MediaObject, type MessageValues, Money, OrderTotals, PageInfo, type PartLabel, PriceRange, ProductCard, ProductCardAction, type ProductCardActionLabelState, type ProductCardActionProps, ProductCardBadge, ProductCardDescription, ProductCardImage, type ProductCardPartProps, ProductCardPrice, type ProductCardProps, ProductCardRoot, type ProductCardRootProps, type ProductCardState, ProductCardSwatches, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, type ProductDetailsAddToCartProps, ProductDetailsAvailability, ProductDetailsBackLink, type ProductDetailsBackLinkProps, ProductDetailsBadge, ProductDetailsDescription, type ProductDetailsErrorState, ProductDetailsGallery, ProductDetailsOption, type ProductDetailsOptionProps, type ProductDetailsOptionState, type ProductDetailsOptionValueState, ProductDetailsOptions, type ProductDetailsPartProps, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, type ProductDetailsRootProps, type ProductDetailsState, type ProductDetailsStatus, ProductDetailsTitle, ProductDetailsVariablesInput, ProductGridVariablesInput, ProductImage, type ProductImageProps, RelatedProducts, type RelatedProductsProps, SKU, SKUAttributeValue, SKUGroup, SKUGroupAttribute, SKUGroupAttributeValue, SKUGroupSKU, SKUPrice, SkuGroupResult, SkuGroupVariables, SkuGroupsResult, SkuGroupsVariables, SkuResult, SkuVariables, type StorefrontConfig, type StorefrontMessage, type StorefrontMessageKey, type StorefrontMessages, StorefrontProductAttribute, StorefrontProductAttributeValue, type StorefrontTheme, StringInFilter, type SwatchValue, addToCart, enUS, formatMessage, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getProductName, getSingleMatchedSku, getSingleMatchedSkuId, getSkuAttributeValueNames, getStartingCompareAt, inSentence, isOnSale, isProductOnSale, money, moneyRange, useCart, useCartItem, useCommerce, useMessage, useMoney, useMoneyRange, useProductCard, useProductDetails, useProductDetailsOption };
1190
+ export { AddToCartButton, type AddToCartButtonProps, AddToCartItemInput, type AddToCartLabelState, type AddedCartItem, ApiError, type CartActionPartProps, CartAddress, CartButton, type CartButtonProps, CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDiscount, CartDrawer, CartDrawerRoot, type CartDrawerRootProps, CartEmpty, type CartEmptyProps, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, type CartItemRootProps, type CartItemState, CartItems, type CartItemsProps, CartLineItem, CartLineItemDetails, CartNote, CartNotices, CartOrder, type CartPartProps, CartRoot, type CartRootProps, CartSelectedAddon, CartSelectedAddonValue, CartSelectedOption, CartShippingInfo, type CartState, CartSubtotal, CartSummary, type CartSummaryProps, CartSummaryTotals, CartTax, type CartTextPartProps, CartTitle, Catalog, CatalogEmpty, type CatalogEmptyProps, CatalogHeader, type CatalogHeaderProps, CatalogPagination, type CatalogPartProps, CatalogProducts, type CatalogProductsProps, type CatalogProps, CatalogRoot, type CatalogRootProps, type CatalogState, CatalogSummary, type CommerceContextValue, type CommerceErrorCode, type CommerceEvent, type CommerceOperation, CommerceProvider, type CommerceProviderProps, CommerceStatus, CommerceStorefront, Connection, ConnectionEdge, DraftOrderContext, type GetSwatch, InventoryCount, LabelFilter, LineItemTotals, MediaObject, type MessageValues, Money, OrderTotals, PageInfo, type PartLabel, PriceRange, ProductCard, ProductCardAction, type ProductCardActionLabelState, type ProductCardActionProps, ProductCardBadge, ProductCardDescription, ProductCardImage, type ProductCardPartProps, ProductCardPrice, type ProductCardProps, ProductCardRoot, type ProductCardRootProps, type ProductCardState, ProductCardSwatches, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, type ProductDetailsAddToCartProps, ProductDetailsAvailability, ProductDetailsBackLink, type ProductDetailsBackLinkProps, ProductDetailsBadge, ProductDetailsDescription, type ProductDetailsErrorState, ProductDetailsGallery, ProductDetailsOption, type ProductDetailsOptionProps, type ProductDetailsOptionState, type ProductDetailsOptionValueState, ProductDetailsOptions, type ProductDetailsPartProps, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, type ProductDetailsRootProps, type ProductDetailsState, type ProductDetailsStatus, ProductDetailsTitle, ProductDetailsVariablesInput, ProductGridVariablesInput, ProductImage, type ProductImageProps, RelatedProducts, type RelatedProductsProps, SKU, SKUAttributeValue, SKUGroup, SKUGroupAttribute, SKUGroupAttributeValue, SKUGroupSKU, SKUPrice, type SampleProduct, SkuGroupResult, SkuGroupVariables, SkuGroupsResult, SkuGroupsVariables, SkuResult, SkuVariables, type StorefrontConfig, type StorefrontMessage, type StorefrontMessageKey, type StorefrontMessages, StorefrontProductAttribute, StorefrontProductAttributeValue, type StorefrontTheme, StringInFilter, type SwatchValue, addToCart, enUS, formatMessage, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getProductName, getSingleMatchedSku, getSingleMatchedSkuId, getSkuAttributeValueNames, getStartingCompareAt, inSentence, isOnSale, isProductOnSale, money, moneyRange, useCart, useCartItem, useCatalog, useCommerce, useMessage, useMoney, useMoneyRange, useProductCard, useProductDetails, useProductDetailsOption };
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ const englishText = {
14
14
  requestFailed: "Commerce request failed. Please retry.",
15
15
  requestFailedStatus: "Commerce request failed ({status}). Please retry.",
16
16
  requestTimeout: "Commerce information took too long to load. Please retry.",
17
+ apiUnavailable: "The store could not be reached. Please retry.",
17
18
  unknownError: "Something went wrong. Please retry.",
18
19
  storageUnavailable: "Your browser cannot save the cart. Keep this page open while shopping.",
19
20
  invalidAddItem: "Select a product variant and a positive quantity.",
@@ -219,8 +220,22 @@ var ApiError = class extends Error {
219
220
  this.text = options?.text;
220
221
  }
221
222
  };
223
+ const warnedHtmlPaths = /* @__PURE__ */ new Set();
224
+ function warnHtmlResponse(input) {
225
+ if (process.env.NODE_ENV === "production") return;
226
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
227
+ const path = url.split(/[?#]/)[0] ?? url;
228
+ if (warnedHtmlPaths.has(path)) return;
229
+ warnedHtmlPaths.add(path);
230
+ console.error(`[gd-commerce-storefront] ${path} returned an HTML page instead of JSON. Mount the commerce routes at /api/commerce on this origin, before any static file or single-page app fallback route.`);
231
+ }
222
232
  const checkedFetch = async (input, init) => {
223
233
  const response = await fetch(input, init);
234
+ if (response.status < 500 && /^\s*text\/html\b/i.test(response.headers.get("Content-Type") ?? "")) {
235
+ warnHtmlResponse(input);
236
+ const text = { key: "apiUnavailable" };
237
+ throw new ApiError(formatMessage(enUS, text.key), 502, { text });
238
+ }
224
239
  if (!response.ok) {
225
240
  const data = await response.text();
226
241
  const parsed = (() => {
@@ -348,16 +363,50 @@ function getCartSummaryTotals(order, fallbackCurrency = "USD") {
348
363
 
349
364
  //#endregion
350
365
  //#region src/sample-cart.tsx
351
- const sampleProducts = Array.from({ length: 6 }, (_, index) => ({
366
+ /** The six packaged samples, "Product 1" to "Product 6", without images. */
367
+ const builtInSamples = Array.from({ length: 6 }, (_, index) => ({
352
368
  key: `sample-${index + 1}`,
353
- number: index + 1,
369
+ name: `Product ${index + 1}`,
354
370
  price: (index + 1) * 1e3
355
371
  }));
372
+ const builtInNumbers = new Map(builtInSamples.map((sample, index) => [sample.key, index + 1]));
373
+ const builtInNumber = (sample) => builtInNumbers.get(sample.key);
374
+ const isBuiltInSample = (sample) => builtInNumber(sample) !== void 0;
356
375
  /** Sample prices are examples, in this currency whatever the store's. */
357
376
  const sampleCurrency = "USD";
358
- /** A sample's name, such as "Product 1". The card and the cart both name samples with this. */
359
- const sampleName = (text, sample) => text("sampleProductName", { number: sample.number });
360
- const noQuantities = {};
377
+ /** A sample's name. Packaged samples are named in the provider's language, such as "Product 1". */
378
+ const sampleName = (text, sample) => {
379
+ const number = builtInNumber(sample);
380
+ return number === void 0 ? sample.name : text("sampleProductName", { number });
381
+ };
382
+ const validPrice = (price) => Number.isSafeInteger(price) && price >= 0;
383
+ /** The first reason host samples could not be shown or added consistently, or `null` when they can. */
384
+ function sampleProblem(samples) {
385
+ if (!samples.length) return "CatalogRoot sampleProducts needs at least one product, or `true`.";
386
+ const keys = /* @__PURE__ */ new Set();
387
+ for (const [index, sample] of samples.entries()) {
388
+ if (typeof sample !== "object" || sample === null) return `CatalogRoot sampleProducts: entry ${index} is ${String(sample)}, not a sample.`;
389
+ if (typeof sample.key !== "string" || !sample.key || keys.has(sample.key)) return `CatalogRoot sampleProducts need unique, non-empty string keys, not "${String(sample.key)}".`;
390
+ if (builtInNumbers.has(sample.key)) return `CatalogRoot sampleProducts: "${sample.key}" is a packaged sample's key. Use another key.`;
391
+ keys.add(sample.key);
392
+ if (typeof sample.name !== "string" || !sample.name.trim()) return `CatalogRoot sampleProducts: "${sample.key}" needs a non-empty name.`;
393
+ for (const field of ["description", "imageUrl"]) if (sample[field] !== void 0 && typeof sample[field] !== "string") return `CatalogRoot sampleProducts: "${sample.key}" has a ${field} that is not a string.`;
394
+ for (const field of ["price", "compareAtPrice"]) {
395
+ const value = sample[field];
396
+ if ((field === "price" || value !== void 0) && !validPrice(value)) return `CatalogRoot sampleProducts: "${sample.key}" has an invalid ${field}, ${String(value)}. A price must be a whole number of minor units, 0 or more.`;
397
+ }
398
+ }
399
+ return null;
400
+ }
401
+ const sameSample = (a, b) => {
402
+ if (a === b) return true;
403
+ if (typeof a !== "object" || !a || typeof b !== "object" || !b) return false;
404
+ const fields = Object.keys(a);
405
+ return fields.length === Object.keys(b).length && fields.every((field) => Object.is(a[field], b[field]));
406
+ };
407
+ /** Whether two sample lists have the same samples with the same field values, so an inline list can be reused. */
408
+ const sameSamples = (a, b) => a === b || a.length === b.length && a.every((sample, index) => sameSample(sample, b[index]));
409
+ const noLines = [];
361
410
  const SampleCartContext = createContext(null);
362
411
  function useSampleCart() {
363
412
  const value = useContext(SampleCartContext);
@@ -371,37 +420,73 @@ function SampleCartProvider({ enabled, messages, onAdd, announce, emit, children
371
420
  active.current = session;
372
421
  const [state, setState] = useState({
373
422
  session,
374
- quantities: {}
423
+ lines: noLines
375
424
  });
376
425
  useEffect(() => {
377
426
  active.current = session;
378
427
  setState({
379
428
  session,
380
- quantities: {}
429
+ lines: noLines
381
430
  });
382
431
  return () => {
383
432
  active.current = null;
384
433
  };
385
434
  }, [session]);
386
- const quantities = state.session === session && enabled ? state.quantities : noQuantities;
387
- const items = useMemo(() => sampleProducts.flatMap((product) => quantities[product.key] ? [{
388
- ...product,
389
- name: sampleName(text, product),
390
- quantity: quantities[product.key]
391
- }] : []), [quantities, text]);
392
- function update(key, quantity) {
393
- if (!enabled || active.current !== session || !sampleProducts.some((product) => product.key === key)) return false;
435
+ const lines = state.session === session && enabled ? state.lines : noLines;
436
+ const items = useMemo(() => [...lines].sort((a, b) => (builtInNumber(a.sample) ?? Number.POSITIVE_INFINITY) - (builtInNumber(b.sample) ?? Number.POSITIVE_INFINITY)).map(({ sample, quantity }) => ({
437
+ key: sample.key,
438
+ name: sampleName(text, sample),
439
+ price: sample.price,
440
+ quantity,
441
+ sample
442
+ })), [lines, text]);
443
+ const refresh = useCallback((samples) => {
444
+ const byKey = new Map(samples.map((sample) => [sample.key, sample]));
445
+ setState((previous) => {
446
+ if (previous.session !== session) return previous;
447
+ let changed = false;
448
+ const next = previous.lines.map((line) => {
449
+ const current = byKey.get(line.sample.key);
450
+ if (!current || current === line.sample) return line;
451
+ changed = true;
452
+ return {
453
+ ...line,
454
+ sample: current
455
+ };
456
+ });
457
+ return changed ? {
458
+ session,
459
+ lines: next
460
+ } : previous;
461
+ });
462
+ }, [session]);
463
+ function update(key, quantity, added) {
464
+ if (!enabled || active.current !== session) return false;
465
+ if (!added && !lines.some((line) => line.sample.key === key)) return false;
394
466
  if (quantity !== void 0 && (!Number.isSafeInteger(quantity) || quantity < 0 || quantity > 999)) return false;
395
467
  setState((previous) => {
396
- const current = previous.session === session ? previous.quantities : {};
397
- const next = quantity ?? (current[key] ?? 0) + 1;
468
+ const current = previous.session === session ? previous.lines : noLines;
469
+ const line = current.find((candidate) => candidate.sample.key === key);
470
+ const next = quantity ?? (line?.quantity ?? 0) + 1;
398
471
  if (next > 999) return previous;
472
+ const kept = current.filter((candidate) => candidate !== line);
473
+ if (next === 0) return {
474
+ session,
475
+ lines: kept
476
+ };
477
+ if (!line && added) return {
478
+ session,
479
+ lines: [...current, {
480
+ sample: added,
481
+ quantity: next
482
+ }]
483
+ };
399
484
  return {
400
485
  session,
401
- quantities: {
402
- ...current,
403
- [key]: next
404
- }
486
+ lines: current.map((candidate) => candidate === line ? {
487
+ ...candidate,
488
+ quantity: next
489
+ } : candidate)
405
490
  };
406
491
  });
407
492
  return true;
@@ -409,10 +494,10 @@ function SampleCartProvider({ enabled, messages, onAdd, announce, emit, children
409
494
  return /* @__PURE__ */ jsx(SampleCartContext.Provider, {
410
495
  value: {
411
496
  items,
412
- add: (key) => {
413
- const product = sampleProducts.find((product$1) => product$1.key === key);
414
- if (!product || !update(key)) return false;
415
- onAdd(sampleName(text, product));
497
+ refresh,
498
+ add: (sample) => {
499
+ if (!update(sample.key, void 0, sample)) return false;
500
+ onAdd(sampleName(text, sample));
416
501
  return true;
417
502
  },
418
503
  changeQuantity: async (key, quantity) => {
@@ -977,20 +1062,21 @@ function useCart() {
977
1062
  const { cart, config, connection, hydrating } = useCommerce();
978
1063
  const sample = useSampleCart();
979
1064
  const isSample = connection === "unbound";
980
- const sampleItems$1 = sample.items;
1065
+ const sampleItems = sample.items;
981
1066
  return useMemo(() => {
982
1067
  if (isSample) return {
983
- items: sampleItems$1.map((item) => ({
1068
+ items: sampleItems.map((item) => ({
984
1069
  id: item.key,
985
1070
  name: item.name,
986
1071
  quantity: item.quantity,
987
- imageUrl: null,
1072
+ imageUrl: item.sample.imageUrl || null,
988
1073
  options: [],
989
1074
  subtotal: item.price * item.quantity,
990
- currency: sampleCurrency
1075
+ currency: sampleCurrency,
1076
+ sample: item.sample
991
1077
  })),
992
- itemCount: sampleItems$1.reduce((total, item) => total + item.quantity, 0),
993
- subtotal: sampleItems$1.reduce((sum, item) => sum + item.price * item.quantity, 0),
1078
+ itemCount: sampleItems.reduce((total, item) => total + item.quantity, 0),
1079
+ subtotal: sampleItems.reduce((sum, item) => sum + item.price * item.quantity, 0),
994
1080
  currency: sampleCurrency,
995
1081
  isSample,
996
1082
  loading: false
@@ -1009,7 +1095,8 @@ function useCart() {
1009
1095
  values: option.values ?? []
1010
1096
  })),
1011
1097
  subtotal: typeof item.totals?.subTotal?.value === "number" ? item.totals.subTotal.value : null,
1012
- currency: item.totals?.subTotal?.currencyCode ?? currency
1098
+ currency: item.totals?.subTotal?.currencyCode ?? currency,
1099
+ sample: null
1013
1100
  })),
1014
1101
  itemCount: lines.reduce((total, item) => total + (item.quantity ?? 0), 0),
1015
1102
  subtotal: typeof subtotal === "number" ? subtotal : null,
@@ -1022,7 +1109,7 @@ function useCart() {
1022
1109
  config.currencyCode,
1023
1110
  hydrating,
1024
1111
  isSample,
1025
- sampleItems$1
1112
+ sampleItems
1026
1113
  ]);
1027
1114
  }
1028
1115
 
@@ -1574,7 +1661,11 @@ function useCartItemInternal(part) {
1574
1661
  if (!item) throw new Error(`${part} must be rendered inside <CartItemRoot>.`);
1575
1662
  return item;
1576
1663
  }
1577
- /** Read the line from inside `CartItemRoot`, for custom item parts. */
1664
+ /**
1665
+ * Read the line from inside `CartItemRoot`, for custom item parts. Pass the type of the site's samples, such as
1666
+ * `useCartItem<MySample>()`, to read their own fields from `sample`. The type is not checked: when the cart can hold
1667
+ * samples from more than one catalog, read the site's own fields as possibly missing.
1668
+ */
1578
1669
  function useCartItem() {
1579
1670
  return useCartItemInternal("useCartItem");
1580
1671
  }
@@ -1630,13 +1721,15 @@ function CartItemRoot({ children, className }) {
1630
1721
  /** The line's product image. Renders nothing without one. */
1631
1722
  function CartItemImage({ className }) {
1632
1723
  const { imageUrl } = useCartItemInternal("CartItemImage");
1633
- if (!imageUrl) return null;
1724
+ const [failedUrl, setFailedUrl] = useState(null);
1725
+ if (!imageUrl || imageUrl === failedUrl) return null;
1634
1726
  return /* @__PURE__ */ jsx("div", {
1635
1727
  ...partProps("cart-item-image", className),
1636
1728
  children: /* @__PURE__ */ jsx("img", {
1637
1729
  src: imageUrl,
1638
1730
  alt: "",
1639
- className: "h-20 w-20 bg-commerce-tint object-cover"
1731
+ className: "h-20 w-20 bg-commerce-tint object-cover",
1732
+ onError: () => setFailedUrl(imageUrl)
1640
1733
  })
1641
1734
  });
1642
1735
  }
@@ -1973,7 +2066,7 @@ function ProductImage({ url, name, className = "" }) {
1973
2066
  }
1974
2067
  /** Set by `CatalogProducts` for each entry it renders, so the card template needs no product prop. */
1975
2068
  const CatalogItemContext = createContext(null);
1976
- const titleLink = "title-link";
2069
+ const titleLink = "title";
1977
2070
  function cardValue(item, productPath, links, sampleNameText) {
1978
2071
  if (item.source === "sample") return {
1979
2072
  ...links,
@@ -1999,16 +2092,24 @@ function useProductCardInternal(part) {
1999
2092
  if (!context) throw new Error(`${part} must be rendered inside <ProductCardRoot>.`);
2000
2093
  return context;
2001
2094
  }
2002
- /** Read the card's product, name, and link from inside `ProductCardRoot`. */
2095
+ /**
2096
+ * Read the card's product or sample, name, and link from inside `ProductCardRoot`. Pass the type of the site's
2097
+ * samples, such as `useProductCard<MySample>()`, to read their own fields from `sample`. The type is not checked:
2098
+ * on a page that also shows the packaged samples, read the site's own fields as possibly missing.
2099
+ */
2003
2100
  function useProductCard() {
2004
- const { product, name, href, isSample } = useProductCardInternal("useProductCard");
2101
+ const card = useProductCardInternal("useProductCard");
2102
+ const { product, name, href, isSample } = card;
2103
+ const sample = card.source === "sample" ? card.sample : null;
2005
2104
  return useMemo(() => ({
2006
2105
  product,
2106
+ sample,
2007
2107
  name,
2008
2108
  href,
2009
2109
  isSample
2010
2110
  }), [
2011
2111
  product,
2112
+ sample,
2012
2113
  name,
2013
2114
  href,
2014
2115
  isSample
@@ -2057,8 +2158,9 @@ function ProductCardRoot({ children, product, className }) {
2057
2158
  return catalogItem ? card : /* @__PURE__ */ jsx(StorefrontSurface, { children: card });
2058
2159
  }
2059
2160
  /**
2060
- * The product's first image, linked to its page. Samples show a placeholder. With a `ProductCardTitle` in the card,
2061
- * the title is the one link keyboard and screen reader users get; without one, the image link is.
2161
+ * The product's first image, linked to its page. A sample shows its `imageUrl`, unlinked, or the packaged samples'
2162
+ * placeholder. With a `ProductCardTitle` in the card, the title is the one link keyboard and screen reader users get,
2163
+ * and a sample's image is hidden from screen readers so its name is not read twice; without one, the image is exposed.
2062
2164
  */
2063
2165
  function ProductCardImage({ className }) {
2064
2166
  const card = useProductCardInternal("ProductCardImage");
@@ -2068,23 +2170,30 @@ function ProductCardImage({ className }) {
2068
2170
  to: card.href,
2069
2171
  tabIndex: card.imageLinked ? void 0 : -1,
2070
2172
  "aria-hidden": card.imageLinked ? void 0 : "true",
2071
- className: "block aspect-square overflow-hidden bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
2173
+ className: "block aspect-(--commerce-card-image-aspect,1/1) overflow-hidden bg-commerce-tint focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
2072
2174
  children: /* @__PURE__ */ jsx(ProductImage, {
2073
2175
  url: getPrimaryImageUrl(card.product),
2074
2176
  name: card.name,
2075
2177
  className: "motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-out motion-safe:group-hover:scale-[1.03]"
2076
2178
  })
2077
- }) : /* @__PURE__ */ jsx("div", {
2179
+ }) : isBuiltInSample(card.sample) ? /* @__PURE__ */ jsx("div", {
2078
2180
  "aria-hidden": "true",
2079
- className: "flex aspect-square items-center justify-center bg-commerce-tint",
2181
+ className: "flex aspect-(--commerce-card-image-aspect,1/1) items-center justify-center bg-commerce-tint",
2080
2182
  children: /* @__PURE__ */ jsx("div", { className: "h-16 w-16 rounded-full border border-commerce-line" })
2183
+ }) : /* @__PURE__ */ jsx("div", {
2184
+ "aria-hidden": card.imageLinked ? void 0 : "true",
2185
+ className: "aspect-(--commerce-card-image-aspect,1/1) overflow-hidden bg-commerce-tint",
2186
+ children: /* @__PURE__ */ jsx(ProductImage, {
2187
+ url: card.sample.imageUrl,
2188
+ name: card.name
2189
+ })
2081
2190
  })
2082
2191
  });
2083
2192
  }
2084
2193
  /** The product name as an H2, linked to its page. */
2085
2194
  function ProductCardTitle({ className }) {
2086
2195
  const { name, href, register } = useProductCardInternal("ProductCardTitle");
2087
- useEffect(() => href ? register(titleLink) : void 0, [href, register]);
2196
+ useEffect(() => register(titleLink), [register]);
2088
2197
  return /* @__PURE__ */ jsx("div", {
2089
2198
  ...partProps("product-card-title", className),
2090
2199
  children: /* @__PURE__ */ jsx("h2", {
@@ -2097,15 +2206,16 @@ function ProductCardTitle({ className }) {
2097
2206
  })
2098
2207
  });
2099
2208
  }
2100
- /** Up to three lines of the product description. Renders nothing without one. */
2209
+ /** Up to two lines of the product or sample description. Renders nothing without one. */
2101
2210
  function ProductCardDescription({ className }) {
2102
- const { product } = useProductCardInternal("ProductCardDescription");
2103
- if (!product?.description) return null;
2211
+ const card = useProductCardInternal("ProductCardDescription");
2212
+ const description = card.source === "sample" ? card.sample.description : card.product.description;
2213
+ if (!description) return null;
2104
2214
  return /* @__PURE__ */ jsx("div", {
2105
2215
  ...partProps("product-card-description", className),
2106
2216
  children: /* @__PURE__ */ jsx("p", {
2107
2217
  className: "line-clamp-2 text-sm leading-6 text-commerce-muted",
2108
- children: product.description
2218
+ children: description
2109
2219
  })
2110
2220
  });
2111
2221
  }
@@ -2117,12 +2227,13 @@ const formatRange = (range, money$1, moneyRange$1) => {
2117
2227
  };
2118
2228
  /**
2119
2229
  * "Sale" when some SKU's compare-at price is above that SKU's price. Without SKU prices, as in the catalog list, only
2120
- * when the lowest compare-at price is above the highest price. Renders nothing otherwise, or for samples.
2230
+ * when the lowest compare-at price is above the highest price. For a sample, when its `compareAtPrice` is above its
2231
+ * price. Renders nothing otherwise.
2121
2232
  */
2122
2233
  function ProductCardBadge({ className }) {
2123
- const { product } = useProductCardInternal("ProductCardBadge");
2234
+ const card = useProductCardInternal("ProductCardBadge");
2124
2235
  const text = useMessage();
2125
- if (!isProductOnSale(product)) return null;
2236
+ if (card.source === "sample" ? sampleCompareAt(card.sample) === null : !isProductOnSale(card.product)) return null;
2126
2237
  return /* @__PURE__ */ jsx("div", {
2127
2238
  ...partProps("product-card-badge", className),
2128
2239
  children: /* @__PURE__ */ jsx("span", {
@@ -2131,6 +2242,7 @@ function ProductCardBadge({ className }) {
2131
2242
  })
2132
2243
  });
2133
2244
  }
2245
+ const sampleCompareAt = (sample) => sample.compareAtPrice !== void 0 && sample.compareAtPrice > sample.price ? sample.compareAtPrice : null;
2134
2246
  /** The product's price, or its price range when variants differ. A single price on sale shows its compare-at price. */
2135
2247
  function ProductCardPrice({ className }) {
2136
2248
  const card = useProductCardInternal("ProductCardPrice");
@@ -2139,8 +2251,10 @@ function ProductCardPrice({ className }) {
2139
2251
  const message = useMessage();
2140
2252
  const text = card.source === "sample" ? money$1(card.sample.price, sampleCurrency) : formatRange(card.product.priceRange, money$1, moneyRange$1) ?? message("priceUnavailable");
2141
2253
  const range = card.source === "live" ? card.product.priceRange : null;
2142
- const starting = card.source === "live" && range?.min === range?.max ? getStartingCompareAt(card.product) : null;
2143
- const compareAt = starting === null ? null : money$1(starting);
2254
+ let starting = null;
2255
+ if (card.source === "sample") starting = sampleCompareAt(card.sample);
2256
+ else if (range?.min === range?.max) starting = getStartingCompareAt(card.product);
2257
+ const compareAt = starting === null ? null : money$1(starting, card.source === "sample" ? sampleCurrency : void 0);
2144
2258
  return /* @__PURE__ */ jsx("div", {
2145
2259
  ...partProps("product-card-price", className),
2146
2260
  children: /* @__PURE__ */ jsxs("p", {
@@ -2263,12 +2377,12 @@ function SampleAction({ sample, label }) {
2263
2377
  type: "button",
2264
2378
  className: `${secondaryButtonClass} w-full`,
2265
2379
  "aria-label": text("addSampleToCart", { name }),
2266
- onClick: () => add(sample.key),
2380
+ onClick: () => add(sample),
2267
2381
  children: text("addToCart")
2268
2382
  }) : /* @__PURE__ */ jsxs("button", {
2269
2383
  type: "button",
2270
2384
  className: `${secondaryButtonClass} w-full`,
2271
- onClick: () => add(sample.key),
2385
+ onClick: () => add(sample),
2272
2386
  children: [custom, /* @__PURE__ */ jsxs("span", {
2273
2387
  className: "sr-only",
2274
2388
  children: [" ", text("sampleItemName", { name })]
@@ -2319,14 +2433,39 @@ function useRootParams(urlState) {
2319
2433
  //#region src/catalog.tsx
2320
2434
  const CatalogContext = createContext(null);
2321
2435
  const CatalogEmptyContext = createContext(null);
2322
- const sampleCatalog = { source: "sample" };
2436
+ const builtInCatalog = {
2437
+ source: "sample",
2438
+ samples: builtInSamples
2439
+ };
2323
2440
  function useCatalogInternal(part) {
2324
2441
  const context = useContext(CatalogContext);
2325
2442
  if (!context) throw new Error(`${part} must be rendered inside <CatalogRoot>.`);
2326
2443
  return context;
2327
2444
  }
2328
- /** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */
2329
- function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = false, pageSize = 6, urlState = true }) {
2445
+ /** Read how many products the catalog shows, and whether they are samples, for a custom summary. */
2446
+ function useCatalog() {
2447
+ const catalog = useCatalogInternal("useCatalog");
2448
+ let count;
2449
+ let status = "ready";
2450
+ if (catalog.source === "sample") count = catalog.samples.length;
2451
+ else {
2452
+ count = catalog.nodes.length;
2453
+ if (catalog.products.isPending) status = "loading";
2454
+ else if (catalog.products.isError) status = "error";
2455
+ }
2456
+ const isSample = catalog.source === "sample";
2457
+ return useMemo(() => ({
2458
+ count,
2459
+ isSample,
2460
+ status
2461
+ }), [
2462
+ count,
2463
+ isSample,
2464
+ status
2465
+ ]);
2466
+ }
2467
+ /** Loads one cursor page of products, or the samples, and provides them to catalog parts. */
2468
+ function CatalogRoot({ children, className, sampleProducts = false, pageSize = 6, urlState = true }) {
2330
2469
  const { connection } = useCommerce();
2331
2470
  const [custom, setCustom] = useState(false);
2332
2471
  const { register, has } = usePartRegistry(useCallback((registered) => setCustom(registered("empty")), []));
@@ -2338,6 +2477,25 @@ function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = f
2338
2477
  custom
2339
2478
  }), [register, custom]);
2340
2479
  if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw new Error(`CatalogRoot pageSize must be a whole number of 1 or more, not ${pageSize}.`);
2480
+ const hostSamples = useStableSamples(Array.isArray(sampleProducts) ? sampleProducts : null);
2481
+ const problem = useMemo(() => hostSamples && sampleProblem(hostSamples), [hostSamples]);
2482
+ if (problem && connection === "unbound") throw new Error(problem);
2483
+ useEffect(() => {
2484
+ if (problem && process.env.NODE_ENV !== "production") console.error(`[gd-commerce-storefront] ${problem}`);
2485
+ }, [problem]);
2486
+ const { refresh } = useSampleCart();
2487
+ useEffect(() => {
2488
+ if (hostSamples && !problem && connection === "unbound") refresh(hostSamples);
2489
+ }, [
2490
+ hostSamples,
2491
+ problem,
2492
+ connection,
2493
+ refresh
2494
+ ]);
2495
+ const sampleCatalog = useMemo(() => hostSamples ? {
2496
+ source: "sample",
2497
+ samples: hostSamples
2498
+ } : builtInCatalog, [hostSamples]);
2341
2499
  let content;
2342
2500
  if (connection === "ready") content = /* @__PURE__ */ jsx(CatalogLoader, {
2343
2501
  className,
@@ -2345,7 +2503,7 @@ function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = f
2345
2503
  urlState,
2346
2504
  children
2347
2505
  });
2348
- else if (connection === "unbound" && sampleProducts$1) content = /* @__PURE__ */ jsx(CatalogContext.Provider, {
2506
+ else if (connection === "unbound" && sampleProducts) content = /* @__PURE__ */ jsx(CatalogContext.Provider, {
2349
2507
  value: sampleCatalog,
2350
2508
  children: /* @__PURE__ */ jsx("section", {
2351
2509
  ...partProps("catalog-root", className),
@@ -2362,6 +2520,13 @@ function CatalogRoot({ children, className, sampleProducts: sampleProducts$1 = f
2362
2520
  children: content
2363
2521
  }) });
2364
2522
  }
2523
+ /** The previous list while the samples and their fields are unchanged, so an inline list does not rebuild the cards. */
2524
+ function useStableSamples(samples) {
2525
+ const [stable, setStable] = useState(samples);
2526
+ const same = stable === samples || !!stable && !!samples && sameSamples(stable, samples);
2527
+ if (!same) setStable(samples);
2528
+ return same ? stable : samples;
2529
+ }
2365
2530
  function CatalogLoader({ children, className, pageSize, urlState }) {
2366
2531
  const { config } = useCommerce();
2367
2532
  const [params, setParams] = useRootParams(urlState);
@@ -2451,13 +2616,6 @@ function summary(catalog) {
2451
2616
  }
2452
2617
  /** Two columns on phones, then as many 240px columns as fit. Shared with `RelatedProducts`. */
2453
2618
  const catalogGridClass = "grid grid-cols-2 gap-x-4 gap-y-10 sm:gap-x-6 md:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] lg:gap-y-12";
2454
- const sampleItems = sampleProducts.map((sample) => ({
2455
- key: sample.key,
2456
- item: {
2457
- source: "sample",
2458
- sample
2459
- }
2460
- }));
2461
2619
  const packagedCard$1 = /* @__PURE__ */ jsx(ProductCardRoot, {
2462
2620
  className: packagedCardClass,
2463
2621
  children: /* @__PURE__ */ jsx(PackagedCardParts, {})
@@ -2471,13 +2629,20 @@ function CatalogProducts({ className, children }) {
2471
2629
  const customEmpty = useContext(CatalogEmptyContext)?.custom ?? false;
2472
2630
  const card = hasChildren(children) ? children : packagedCard$1;
2473
2631
  const nodes = catalog.source === "live" ? catalog.nodes : null;
2632
+ const samples = catalog.source === "sample" ? catalog.samples : null;
2474
2633
  const items = useMemo(() => nodes ? nodes.map((product) => ({
2475
2634
  key: product.id ?? "",
2476
2635
  item: {
2477
2636
  source: "live",
2478
2637
  product
2479
2638
  }
2480
- })) : sampleItems, [nodes]);
2639
+ })) : (samples ?? []).map((sample) => ({
2640
+ key: sample.key,
2641
+ item: {
2642
+ source: "sample",
2643
+ sample
2644
+ }
2645
+ })), [nodes, samples]);
2481
2646
  const products = catalog.source === "live" ? catalog.products : null;
2482
2647
  const { messages } = useCommerce();
2483
2648
  const text = useMessage();
@@ -2506,7 +2671,7 @@ function CatalogProducts({ className, children }) {
2506
2671
  "aria-hidden": "true",
2507
2672
  className: "motion-safe:animate-pulse",
2508
2673
  children: [
2509
- /* @__PURE__ */ jsx("div", { className: "aspect-square bg-commerce-tint-strong" }),
2674
+ /* @__PURE__ */ jsx("div", { className: "aspect-(--commerce-card-image-aspect,1/1) bg-commerce-tint-strong" }),
2510
2675
  /* @__PURE__ */ jsxs("div", {
2511
2676
  className: "space-y-2 pt-3",
2512
2677
  children: [/* @__PURE__ */ jsx("div", { className: "h-4 w-2/3 rounded bg-commerce-tint-strong" }), /* @__PURE__ */ jsx("div", { className: "h-4 w-1/4 rounded bg-commerce-tint-strong" })]
@@ -2566,9 +2731,9 @@ function CatalogPagination({ className }) {
2566
2731
  });
2567
2732
  }
2568
2733
  /** The packaged catalog. Compose `CatalogRoot` and the catalog parts for a custom layout. */
2569
- function Catalog({ title, description, showHeader = true, sampleProducts: sampleProducts$1, pageSize }) {
2734
+ function Catalog({ title, description, showHeader = true, sampleProducts, pageSize }) {
2570
2735
  return /* @__PURE__ */ jsxs(CatalogRoot, {
2571
- sampleProducts: sampleProducts$1,
2736
+ sampleProducts,
2572
2737
  pageSize,
2573
2738
  children: [
2574
2739
  /* @__PURE__ */ jsxs("div", {
@@ -3494,4 +3659,4 @@ function RelatedProducts({ productId: productIdProp, title, limit = 4, className
3494
3659
  }
3495
3660
 
3496
3661
  //#endregion
3497
- export { AddToCartButton, ApiError, CartButton, CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDrawer, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartRoot, CartSubtotal, CartSummary, CartTitle, Catalog, CatalogEmpty, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardBadge, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardSwatches, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsBadge, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, ProductImage, RelatedProducts, enUS, formatMessage, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, moneyRange, useCart, useCartItem, useCommerce, useMessage, useMoney, useMoneyRange, useProductCard, useProductDetails, useProductDetailsOption };
3662
+ export { AddToCartButton, ApiError, CartButton, CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDrawer, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartRoot, CartSubtotal, CartSummary, CartTitle, Catalog, CatalogEmpty, CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductCardAction, ProductCardBadge, ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardSwatches, ProductCardTitle, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsBadge, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, ProductImage, RelatedProducts, enUS, formatMessage, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, moneyRange, useCart, useCartItem, useCatalog, useCommerce, useMessage, useMoney, useMoneyRange, useProductCard, useProductDetails, useProductDetailsOption };
@@ -1,4 +1,4 @@
1
- import { a as StorefrontMessages } from "../messages-CHl-bWx9.js";
1
+ import { a as StorefrontMessages } from "../messages-peJIfMO4.js";
2
2
 
3
3
  //#region src/locales/fr-fr.d.ts
4
4
  /** The package's text in French (France). Pass it to `CommerceProvider` as `messages`, with `locale='fr-FR'`. */
@@ -20,6 +20,7 @@ const frFR = {
20
20
  requestFailed: "La requête a échoué. Veuillez réessayer.",
21
21
  requestFailedStatus: "La requête a échoué ({status}). Veuillez réessayer.",
22
22
  requestTimeout: "Le chargement des informations a pris trop de temps. Veuillez réessayer.",
23
+ apiUnavailable: "La boutique est injoignable. Veuillez réessayer.",
23
24
  unknownError: "Un problème est survenu. Veuillez réessayer.",
24
25
  storageUnavailable: "Votre navigateur ne peut pas enregistrer le panier. Gardez cette page ouverte pendant vos achats.",
25
26
  invalidAddItem: "Sélectionnez une variante du produit et une quantité positive.",
@@ -15,6 +15,7 @@ declare const englishText: {
15
15
  requestFailed: string;
16
16
  requestFailedStatus: string;
17
17
  requestTimeout: string;
18
+ apiUnavailable: string;
18
19
  unknownError: string;
19
20
  storageUnavailable: string;
20
21
  invalidAddItem: string;
package/dist/styles.css CHANGED
@@ -311,6 +311,9 @@
311
311
  .commerce-storefront .inline-flex {
312
312
  display: inline-flex;
313
313
  }
314
+ .commerce-storefront .aspect-\(--commerce-card-image-aspect\,1\/1\) {
315
+ aspect-ratio: var(--commerce-card-image-aspect,1/1);
316
+ }
314
317
  .commerce-storefront .aspect-square {
315
318
  aspect-ratio: 1 / 1;
316
319
  }
@@ -9,7 +9,7 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati
9
9
  - Requests use same-origin browser credentials. JSON writes set `Content-Type: application/json`.
10
10
  - Cart, catalog and checkout requests include `X-Commerce-Scope` from `/config`. Validate that scope against the server's current store/channel binding, especially before writes. Treat it as a stale-binding guard, not authorization. Authenticate and authorize requests independently.
11
11
  - Read requests use `cache: no-store`, cancellation, and a 15-second timeout. The initial cart creation/add request is also a write and is never retried automatically.
12
- - Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures.
12
+ - Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures. A `text/html` response below 500, such as a single-page app fallback or an HTML 404 page answering an unmounted route, is reported as "The store could not be reached" with status 502, so it never clears a saved cart.
13
13
  - Only cart reads with HTTP 404 or 410 clear an expired saved cart. Network errors, 401/403/409/5xx, and other failures preserve the ID and block writes until hydration succeeds.
14
14
  - Resolve prices and availability on the server. The cart does not calculate shipping, taxes, or discounts; it explains that they are calculated at checkout. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication.
15
15
 
@@ -31,7 +31,9 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati
31
31
 
32
32
  `/checkout` is required only when checkout is enabled. `/discounts` is needed if the host uses `applyDiscount`. The standard catalog/detail/cart flow uses the other routes. All product/cart IDs in request paths are encoded.
33
33
 
34
- The companion `@godaddy/gd-commerce-server` returns 404 from product details when the group is missing or excluded by the catalog's ACTIVE filter, including direct links to inactive products.
34
+ The companion `@godaddy/gd-commerce-server` filters `GET /products` to ACTIVE groups available on the server-configured channel. It returns 404 from `GET /products/:id` when the group is missing, inactive, or unavailable on that channel, including direct product links. Browser callers cannot override the configured channel. This browsing filter does not check product-channel eligibility for `GET /skus/:id`, cart creation, or item addition by SKU ID; hosts must enforce any required restrictions on those operations. See the [server package's channel visibility notes][server-channel-visibility] for stale-channel troubleshooting and upstream lookup costs.
35
+
36
+ [server-channel-visibility]: ../../commerce-server/README.md#product-channel-visibility
35
37
 
36
38
  ## Configuration
37
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godaddy/gd-commerce-storefront",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Opinionated React storefront templates for GoDaddy Commerce",
5
5
  "type": "module",
6
6
  "license": "MIT",