diff --git a/.changeset/composable-catalog.md b/.changeset/composable-catalog.md
new file mode 100644
index 000000000..58f394193
--- /dev/null
+++ b/.changeset/composable-catalog.md
@@ -0,0 +1,5 @@
+---
+"@godaddy/gd-commerce-storefront": minor
+---
+
+Add composable catalog and product card parts. `CatalogRoot` with `CatalogHeader`, `CatalogSummary`, `CatalogProducts` and `CatalogPagination` builds custom catalog layouts. `ProductCardRoot` with `ProductCardImage`, `ProductCardTitle`, `ProductCardDescription`, `ProductCardPrice` and `ProductCardAction` builds custom cards, either as the card template inside `CatalogProducts` or for a product the host already has. `useProductCard` reads card state. `ProductCardProps` types the packaged card's props. Card parts take no product, SKU or price props, so a direct add is still offered only for a product with one SKU and no options, and samples add only to the sample cart. `Catalog` and `ProductCard` are now composed from these parts. Visual changes: catalog cards in the same row are now the same height, with their price rows aligned at the bottom, and the sample catalog's summary row uses the live catalog's padding, 32px instead of 24px above and below from the `sm` breakpoint. A standalone `ProductCard` is unchanged. Markup changes: each part has a wrapper `
`, catalog cards are now direct children of the grid instead of each having its own surface wrapper, and the sample summary text is a `
` inside a `
`.
diff --git a/.changeset/host-layout-classes.md b/.changeset/host-layout-classes.md
new file mode 100644
index 000000000..f9636d0da
--- /dev/null
+++ b/.changeset/host-layout-classes.md
@@ -0,0 +1,5 @@
+---
+"@godaddy/gd-commerce-storefront": patch
+---
+
+Host layout classes now take effect on `ProductDetailsPrice`, `ProductDetailsOptions`, `ProductDetailsPurchase` and `CatalogPagination`. For example, `` puts the selects in two columns. `ProductDetailsRoot` and `CatalogRoot` keep the host `className` while connecting, loading or showing an error. A card's image link is hidden from keyboard and screen reader users in server HTML when the card has a title. A `CatalogProducts` card template of `false` or an empty string renders the packaged card. Default rendering is unchanged.
diff --git a/.changeset/purchase-area-spacing.md b/.changeset/purchase-area-spacing.md
new file mode 100644
index 000000000..fa76a32a3
--- /dev/null
+++ b/.changeset/purchase-area-spacing.md
@@ -0,0 +1,5 @@
+---
+"@godaddy/gd-commerce-storefront": patch
+---
+
+Space out the product details purchase area. Options are now 24px apart, the status or stock message sits 8px below the options, and the quantity and add to cart button are 24px apart. Previously the options, messages and quantity had no gap between them. `ProductDetailsOptions` spaces its selects the same way. The "Checking this variant…" message now uses the same small text as the other purchase messages.
diff --git a/.changeset/select-chevron.md b/.changeset/select-chevron.md
new file mode 100644
index 000000000..4bd64e538
--- /dev/null
+++ b/.changeset/select-chevron.md
@@ -0,0 +1,5 @@
+---
+"@godaddy/gd-commerce-storefront": patch
+---
+
+Product option selects draw their own chevron 12px in from the right edge instead of the browser's arrow, which sat against the border. Their text now starts at the same 12px padding as other inputs. The README notes that a host class with the same name as one of the package's utilities loses to the package's rule.
diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md
index 574c718dd..f92bd142d 100644
--- a/packages/commerce-storefront/README.md
+++ b/packages/commerce-storefront/README.md
@@ -100,9 +100,10 @@ The repository example uses React Router 7. Follow each router version's own Rea
| --- | --- |
| `CommerceStorefront` | Recommended integration: provider and one drawer |
| `Catalog` | Six products per cursor page, with `title`, `description`, and `showHeader` props |
+| `CatalogRoot` and catalog parts | Build a [custom catalog or card layout](#custom-catalog-layouts) with the same rules as `Catalog` |
| `ProductDetails` | Reads `:productId`; validates URL option selections against catalog results |
| `ProductDetailsRoot` and product parts | Build a [custom product layout](#custom-product-layouts) with the same rules as `ProductDetails` |
-| `ProductCard` | Renders one `SKUGroup` with direct add or a product-details link |
+| `ProductCard` | Renders one `SKUGroup` with direct add or a product-details link; `ProductCardRoot` and card parts customize it |
| `CartButton` | Opens the shared drawer and displays item count |
| `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity` |
| `CommerceStatus` | Connection progress/error/retry for custom layouts |
@@ -153,7 +154,7 @@ Every part is named `ProductDetails*` because it works only inside `ProductDetai
Parts read state from the root and take no product, SKU, price or quantity props. Whatever the layout, only a server-verified SKU can be added, and changing a selection resets the quantity and image. Every part throws if it is rendered outside `ProductDetailsRoot`. In development builds, if no control is rendered for an attribute the product requires, a console warning names the missing ones. A control that mounts later, such as one in a closed accordion, can trigger it; the product becomes purchasable once the control renders. Products without attributes but with several SKUs need `ProductDetailsOptions` or `ProductDetailsPurchase`. Parts can be rendered more than once, such as in a sticky mobile purchase bar; each copy gets its own element IDs.
-Every part accepts `className`, which is added to the part's outer element, a wrapper the package's CSS reset leaves alone. Host classes there control margin, padding, border, color, width and grid placement, including layered utilities such as Tailwind v4's. The packaged layout sets its spacing and grid placement this way. Elements inside the part, such as the title's `
`, keep the package's own styles: a host font size on `ProductDetailsTitle` does not change the heading's size, but a host color does reach it, because the heading inherits its color.
+Every part accepts `className`, which is added to the part's outer element, a wrapper the package's CSS reset leaves alone. Host classes there control margin, padding, border, color, width, display, gap and grid placement, including layered utilities such as Tailwind v4's. In `ProductDetailsOptions` and `ProductDetailsPurchase`, each control keeps a 24px bottom margin, even in a host grid. The exception is a class name the package also uses, such as `grid-cols-1` or `p-6`: the package's rule wins, even over the host's responsive variants on the same element, such as `md:grid-cols-4`. The packaged layout sets its spacing and grid placement this way. Elements inside the part, such as the title's `
`, keep the package's own styles: a host font size on `ProductDetailsTitle` does not change the heading's size, but a host color does reach it, because the heading inherits its color.
Two hooks support custom parts inside the root:
@@ -178,6 +179,54 @@ function ColorSwatches() {
Parts are flat named exports, not properties such as `ProductDetails.Title`, so each one is an ordinary module export.
+## Custom catalog layouts
+
+`Catalog` is the recommended catalog. When its layout or its cards do not fit, compose the same parts inside `CatalogRoot`. The root loads one cursor page of six products, or the built-in samples when `sampleProducts` is set and no store is connected, and shows the connection state itself. This is `` written out with its packaged card:
+
+```tsx
+import {
+ CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, ProductCardAction,
+ ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardTitle,
+} from '@godaddy/gd-commerce-storefront';
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+As with product layouts, the grid, spacing and border classes come from your own stylesheet in a custom layout. Without them, the cards stack in one column.
+
+| Part | Renders |
+| --- | --- |
+| `CatalogHeader` | The `title` as the page's H1 and a `description`; leave it out when the host page owns the H1 |
+| `CatalogSummary` | The loading, failure or product-count status line |
+| `CatalogProducts` | One card per product. Its children are the card, rendered once for each product; without children, the packaged card |
+| `CatalogPagination` | First-page and next-page buttons; nothing for samples |
+| `ProductCardImage`, `ProductCardTitle` | The first image and the name as an H2, each linked to the product page. With a title in the card, keyboard and screen reader users get only the title link; without one, the image link takes its place once the page is interactive |
+| `ProductCardDescription`, `ProductCardPrice` | Up to three lines of description (nothing without one), and the price or price range |
+| `ProductCardAction` | Adds the product directly only when it has one SKU and no options; otherwise links to the product page to choose |
+
+`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. The loading placeholder (three cards, one to three columns), the error with its retry button, and the empty message render just before that element, whatever the layout. 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 description and no 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.
+
+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).
+
## How it works
Products are SKU groups. The package purchases only an unambiguous SKU, verifies all selected attributes through the server, uses SKU prices/images, and distinguishes untracked inventory from sold-out inventory. Server-side inventory and pricing checks remain mandatory.
diff --git a/packages/commerce-storefront/src/catalog-model.ts b/packages/commerce-storefront/src/catalog-model.ts
index 669497202..4a701a11e 100644
--- a/packages/commerce-storefront/src/catalog-model.ts
+++ b/packages/commerce-storefront/src/catalog-model.ts
@@ -254,3 +254,8 @@ export function getProductAttributes(skuGroup: SKUGroup | null | undefined): Sto
}) ?? []
);
}
+
+/** The product's display name: its label, then its name. */
+export function getProductName(skuGroup: SKUGroup): string {
+ return skuGroup.label ?? skuGroup.name ?? 'Product';
+}
diff --git a/packages/commerce-storefront/src/catalog.test.tsx b/packages/commerce-storefront/src/catalog.test.tsx
new file mode 100644
index 000000000..0be7cf00b
--- /dev/null
+++ b/packages/commerce-storefront/src/catalog.test.tsx
@@ -0,0 +1,379 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Component, type ErrorInfo, type ReactElement, type ReactNode } from 'react';
+import { renderToString } from 'react-dom/server';
+import { MemoryRouter } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+import { CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary } from './catalog';
+import type { SKUGroup } from './catalog-model';
+import { CommerceStorefront } from './commerce-storefront';
+import {
+ ProductCard,
+ ProductCardAction,
+ ProductCardDescription,
+ ProductCardImage,
+ ProductCardPrice,
+ ProductCardRoot,
+ ProductCardTitle,
+ useProductCard,
+} from './product-card';
+import { packageRuleConflicts } from './test-css';
+
+const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
+const mug: SKUGroup = {
+ id: 'mug',
+ label: 'Mug',
+ description: 'A ceramic mug.',
+ priceRange: { min: 1200, max: 1200 },
+ skus: {
+ totalCount: 1,
+ edges: [
+ { node: { id: 'sku-mug', inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity: 5 } }] } } },
+ ],
+ },
+};
+const shirt: SKUGroup = {
+ id: 'shirt',
+ label: 'Shirt',
+ priceRange: { min: 2000, max: 2500 },
+ attributes: {
+ edges: [
+ {
+ node: {
+ name: 'size',
+ label: 'Size',
+ values: { edges: [{ node: { id: 's', name: 's', label: 'S' } }] },
+ },
+ },
+ ],
+ },
+ skus: { totalCount: 2, edges: [] },
+};
+
+function mockApi({
+ config = { cartScope: 's', currencyCode: 'USD' },
+ products = () =>
+ response({
+ skuGroups: {
+ edges: [mug, shirt].map((node) => ({ node })),
+ pageInfo: { hasNextPage: true, endCursor: 'c2' },
+ },
+ }),
+}: {
+ config?: unknown;
+ products?: (url: URL) => Response;
+} = {}) {
+ const fn = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(String(input), 'https://host.test');
+ if (url.pathname.endsWith('/config')) return Promise.resolve(response(config));
+ if (init?.method === 'POST')
+ return Promise.resolve(response({ cart: { id: 'cart-1', lineItems: [], totals: {} } }, 201));
+ return Promise.resolve(products(url));
+ });
+ vi.stubGlobal('fetch', fn);
+ return fn;
+}
+
+function app(layout: ReactNode): ReactElement {
+ return (
+
+
+ {layout}
+
+
+ );
+}
+
+function mount(layout: ReactNode) {
+ return render(app(layout));
+}
+
+function Badge(): ReactElement {
+ const { name, href, isSample } = useProductCard();
+ return ;
+}
+
+describe('composable catalog', () => {
+ it('renders a custom catalog and card layout, places cards with host classes, and purchases', async () => {
+ const api = mockApi();
+ mount(
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ const title = await screen.findByRole('heading', { level: 2, name: 'Mug' });
+ const card = title.closest('article');
+ expect(card).toHaveClass('host-card');
+ // The card is the grid's own child, so placement classes such as col-span-2 reach the grid.
+ expect(card?.parentElement).toHaveClass('host-grid');
+ expect(card?.closest('section')).toHaveClass('host-root');
+ expect(screen.getByText('Showing 2 products')).toHaveAttribute('role', 'status');
+ expect(screen.getAllByTestId('badge').map((badge) => badge.textContent)).toEqual([
+ 'Mug:/products/mug:false',
+ 'Shirt:/products/shirt:false',
+ ]);
+ expect(screen.getByRole('link', { name: 'Choose options' })).toHaveAttribute('href', '/products/shirt');
+ expect(screen.getByText('$20.00 – $25.00')).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'Add to cart' }));
+ await waitFor(() => expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true));
+ const post = api.mock.calls.find((call) => call[1]?.method === 'POST');
+ expect(JSON.parse(String(post?.[1]?.body))).toEqual({
+ lineItems: [{ skuId: 'sku-mug', name: 'Mug', quantity: 1 }],
+ });
+ await userEvent.keyboard('{Escape}');
+ await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ await waitFor(() =>
+ expect(api.mock.calls.some(([input]) => String(input).includes('after=c2'))).toBe(true),
+ );
+ });
+
+ it('shows samples in a custom layout and adds them only to the sample cart', async () => {
+ const api = mockApi({ config: { state: 'unbound' } });
+ mount(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ await screen.findByRole('heading', { level: 2, name: 'Product 6' });
+ expect(screen.getByRole('heading', { level: 1, name: 'Sample shop' }).closest('section')).toHaveAttribute(
+ 'data-commerce-source',
+ 'sample',
+ );
+ expect(screen.getAllByRole('article')).toHaveLength(6);
+ expect(screen.getAllByTestId('badge')[0]).toHaveTextContent('Product 1:none:true');
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'Add Product 3 to sample cart' }));
+ const drawer = await screen.findByRole('dialog', { name: 'Sample cart' });
+ expect(within(drawer).getByRole('heading', { name: 'Product 3' })).toBeInTheDocument();
+ expect(api.mock.calls.every(([input]) => String(input).endsWith('/config'))).toBe(true);
+ });
+
+ it('shows the error and retry before a custom grid, then the products', async () => {
+ let fail = true;
+ mockApi({
+ products: () =>
+ fail
+ ? response({ error: 'Catalog is down.' }, 503)
+ : response({ skuGroups: { edges: [{ node: mug }], pageInfo: { hasNextPage: false } } }),
+ });
+ mount(
+
+
+ ,
+ );
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent('Catalog is down.');
+ expect(alert.nextElementSibling).toHaveClass('host-grid');
+ fail = false;
+ await userEvent.click(within(alert).getByRole('button', { name: 'Retry products' }));
+ expect(await screen.findByRole('heading', { level: 2, name: 'Mug' })).toBeInTheDocument();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('gives a card nested in a catalog card its own product', async () => {
+ mockApi();
+ mount(
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ await screen.findByRole('heading', { level: 2, name: 'Mug' });
+ expect(screen.getAllByTestId('badge').map((badge) => badge.textContent)).toEqual([
+ 'Related:/products/related:false',
+ 'Related:/products/related:false',
+ ]);
+ });
+
+ it('keeps one accessible product link whether or not the card has a title', async () => {
+ mockApi();
+ const { unmount } = mount(
+
+
+
+
+
+
+
+
+ ,
+ );
+ // Without a title, the image is the product link.
+ const imageLink = await screen.findByRole('link', { name: 'Mug: image unavailable' });
+ expect(imageLink).toHaveAttribute('href', '/products/mug');
+ expect(imageLink).not.toHaveAttribute('tabindex');
+ unmount();
+ mount(
+
+
+
+
+
+
+
+ ,
+ );
+ // With a title, the title is the only link assistive technology and the keyboard reach.
+ await screen.findByRole('link', { name: 'Mug' });
+ expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['Mug', 'Shirt']);
+ const hidden = document.querySelector('a[aria-hidden="true"][href="/products/mug"]');
+ expect(hidden).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('hides the image link in server HTML when the card has a title', () => {
+ mockApi();
+ const html = renderToString(app());
+ const card = document.createElement('div');
+ card.innerHTML = html;
+ const links = [...card.querySelectorAll('a[href="/products/mug"]')];
+ expect(links.map((link) => [link.getAttribute('tabindex'), link.getAttribute('aria-hidden')])).toEqual([
+ ['-1', 'true'],
+ [null, null],
+ ]);
+ });
+
+ it('falls back to the packaged card when the card template is false or empty', async () => {
+ mockApi();
+ const custom = false;
+ mount(
+
+
+ {custom && (
+
+
+
+ )}
+
+ ,
+ );
+ expect(await screen.findByRole('heading', { level: 2, name: 'Mug' })).toBeInTheDocument();
+ expect(screen.getAllByRole('article')).toHaveLength(2);
+ });
+
+ it('keeps host classes on the connection status', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => new Promise(() => {})),
+ );
+ mount(
+
+
+ ,
+ );
+ expect(screen.getByText('Connecting to the store…').closest('.host-root')).not.toBeNull();
+ });
+
+ it('names the missing root or product when parts are misplaced', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(() => render()).toThrow(
+ 'ProductCardTitle must be rendered inside .',
+ );
+ function Orphan(): null {
+ useProductCard();
+ return null;
+ }
+ expect(() => render()).toThrow('useProductCard must be rendered inside .');
+ expect(() => render()).toThrow('CatalogSummary must be rendered inside .');
+ mockApi();
+ expect(() =>
+ mount(
+
+
+ ,
+ ),
+ ).toThrow('ProductCardRoot needs a product prop outside .');
+ const errors: unknown[] = [];
+ mount(
+
+
+
+
+
+
+
+
+ ,
+ );
+ await waitFor(() => expect(errors).toHaveLength(2));
+ expect(String(errors[0])).toContain(
+ 'Inside , ProductCardRoot shows each catalog product. Remove its product prop.',
+ );
+ });
+
+ it('puts host classes on an element that no package rule styles', async () => {
+ const conflicts = packageRuleConflicts();
+ const layout = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ const expectNoConflicts = (): void => {
+ for (const host of document.querySelectorAll('.host')) {
+ const part = host.localName + [...host.classList].filter((name) => name !== 'host').join('.');
+ expect({ part, conflicts: conflicts(host) }).toEqual({ part, conflicts: [] });
+ }
+ };
+ mockApi();
+ const live = mount(layout);
+ await screen.findByRole('heading', { level: 2, name: 'Mug' });
+ // Root, header, summary, grid and pagination; card root and parts: six for the mug, five for the shirt (no description).
+ expect(document.querySelectorAll('.host')).toHaveLength(5 + 6 + 5);
+ expectNoConflicts();
+ live.unmount();
+ mockApi({ config: { state: 'unbound' } });
+ mount(layout);
+ await screen.findByRole('heading', { level: 2, name: 'Product 6' });
+ expectNoConflicts();
+ });
+});
+
+class Boundary extends Component<{ errors: unknown[]; children: ReactNode }, { failed: boolean }> {
+ state = { failed: false };
+ static getDerivedStateFromError(): { failed: boolean } {
+ return { failed: true };
+ }
+ componentDidCatch(error: unknown, _info: ErrorInfo): void {
+ this.props.errors.push(error);
+ }
+ render(): ReactNode {
+ return this.state.failed ? null : this.props.children;
+ }
+}
diff --git a/packages/commerce-storefront/src/catalog.tsx b/packages/commerce-storefront/src/catalog.tsx
index 0e1d0396a..b8a2e744b 100644
--- a/packages/commerce-storefront/src/catalog.tsx
+++ b/packages/commerce-storefront/src/catalog.tsx
@@ -1,136 +1,85 @@
-import { useQuery } from '@tanstack/react-query';
-import { type ReactElement, useState } from 'react';
-import { Link, useSearchParams } from 'react-router';
-import { message, money, request } from './api';
-import { AddToCartButton, buttonClass } from './cart';
+import { type UseQueryResult, useQuery } from '@tanstack/react-query';
import {
- getPrimaryImageUrl,
- getProductAttributes,
- getSingleMatchedSku,
- type SKU,
- type SKUGroup,
- type SkuGroupsResult,
-} from './catalog-model';
+ Children,
+ createContext,
+ type ReactElement,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+} from 'react';
+import { useSearchParams } from 'react-router';
+import { message, request } from './api';
+import { buttonClass } from './cart';
+import type { SKUGroup, SkuGroupsResult } from './catalog-model';
import { useCommerce } from './commerce-provider';
-import { sampleProducts, useSampleCart } from './sample-cart';
+import { partClass } from './part';
+import {
+ CatalogItemContext,
+ PackagedCardParts,
+ type ProductCardItem,
+ ProductCardRoot,
+ packagedCardClass,
+} from './product-card';
+import { sampleProducts } from './sample-cart';
import { CommerceStatus, StorefrontSurface } from './storefront-surface';
-export function ProductImage({
- url,
- name,
- className = '',
-}: {
- url?: string | null;
- name: string;
+type CatalogInternal =
+ | { source: 'sample' }
+ | {
+ source: 'live';
+ products: UseQueryResult;
+ nodes: SKUGroup[];
+ after: string;
+ goToPage: (cursor?: string) => void;
+ };
+
+/** Props shared by every catalog part. `className` is added to the part's outer element. */
+export interface CatalogPartProps {
className?: string;
-}): ReactElement {
- const [failed, setFailed] = useState(false);
- return url && !failed ? (
- setFailed(true)}
- />
- ) : (
-