From 768e36cc85325b2af0ecc729a46e6881a45cd892 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Wed, 14 Oct 2026 14:19:44 -0700 Subject: [PATCH 1/7] Add composable catalog and product card parts Catalog is now composed from CatalogRoot with CatalogHeader, CatalogSummary, CatalogProducts and CatalogPagination, and ProductCard from ProductCardRoot with image, title, description, price and action parts. CatalogProducts renders its children as the card for every product, so card parts take no product, SKU or price props and samples share the same card. Applies the lessons from the product details review: host classes go on a commerce-part wrapper the CSS reset skips, catalog cards are direct grid children so placement classes work, every part uses its root's prefix, and the README layout is checked pixel-for-pixel against the packaged component. Co-Authored-By: Claude Opus 5.5 --- .changeset/composable-catalog.md | 5 + packages/commerce-storefront/README.md | 51 ++- .../commerce-storefront/src/catalog.test.tsx | 295 ++++++++++++++ packages/commerce-storefront/src/catalog.tsx | 361 +++++++++--------- packages/commerce-storefront/src/index.ts | 27 +- packages/commerce-storefront/src/part.ts | 4 + .../commerce-storefront/src/product-card.tsx | 282 ++++++++++++++ .../src/product-details-context.tsx | 6 +- .../src/product-details.test.tsx | 30 +- .../src/product-details.tsx | 4 +- .../src/product-purchase.tsx | 2 +- .../commerce-storefront/src/sample-cart.tsx | 1 + packages/commerce-storefront/src/test-css.ts | 32 ++ 13 files changed, 872 insertions(+), 228 deletions(-) create mode 100644 .changeset/composable-catalog.md create mode 100644 packages/commerce-storefront/src/catalog.test.tsx create mode 100644 packages/commerce-storefront/src/part.ts create mode 100644 packages/commerce-storefront/src/product-card.tsx create mode 100644 packages/commerce-storefront/src/test-css.ts diff --git a/.changeset/composable-catalog.md b/.changeset/composable-catalog.md new file mode 100644 index 000000000..8af7a8040 --- /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. 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. Live catalogs and cards are visually unchanged. The sample catalog's summary row now uses the live catalog's padding, 32px instead of 24px above and below from the `sm` breakpoint. 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/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 574c718dd..9f9060ab2 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 | @@ -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 the packaged catalog, 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 | +| `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. The packaged card's `self-start` keeps each card at its own height, as in earlier releases; leave it out for cards that fill their grid row. + +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.test.tsx b/packages/commerce-storefront/src/catalog.test.tsx new file mode 100644 index 000000000..a1655d578 --- /dev/null +++ b/packages/commerce-storefront/src/catalog.test.tsx @@ -0,0 +1,295 @@ +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 { 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 { + 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 mount(layout: ReactNode) { + return render( + + + {layout} + + , + ); +} + +function Badge(): ReactElement { + const { name, href, isSample } = useProductCard(); + return {`${name}:${href ?? 'none'}:${isSample}`}; +} + +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('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..1d3b329ca 100644 --- a/packages/commerce-storefront/src/catalog.tsx +++ b/packages/commerce-storefront/src/catalog.tsx @@ -1,124 +1,65 @@ -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 { - getPrimaryImageUrl, - getProductAttributes, - getSingleMatchedSku, - type SKU, - type SKUGroup, - type SkuGroupsResult, -} from './catalog-model'; +import { type UseQueryResult, useQuery } from '@tanstack/react-query'; +import { createContext, type ReactElement, type ReactNode, useContext } 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 ? ( - {name} setFailed(true)} - /> - ) : ( -
- No image available -
- ); } -export function ProductCard({ product }: { product: SKUGroup }): ReactElement { - return ( - - - - ); -} +const CatalogContext = createContext(null); -function ProductCardContent({ product }: { product: SKUGroup }): ReactElement { - const { config } = useCommerce(); - const name: string = product.label ?? product.name ?? 'Product'; - const href: string = `${config.productPath}/${encodeURIComponent(product.id ?? '')}`; - const selectedSku: SKU | null = getSingleMatchedSku(product); - const _skuId: string | null = selectedSku?.id ?? null; - const hasVariants: boolean = getProductAttributes(product).length > 0; - const min: number | null | undefined = product.priceRange?.min; - const max: number | null | undefined = product.priceRange?.max; - return ( -
- - - -
-

- - {name} - -

- {product.description &&

{product.description}

} -
-
-

- {typeof min === 'number' - ? `${money(min, config.currencyCode)}${typeof max === 'number' && max !== min ? ` – ${money(max, config.currencyCode)}` : ''}` - : 'Price unavailable'} -

-
- {!hasVariants && selectedSku ? ( - - ) : ( - - {hasVariants ? 'Choose options' : 'View product'} - - )} -
-
-
-
-
- ); +function useCatalogInternal(part: string): CatalogInternal { + const context = useContext(CatalogContext); + if (!context) throw new Error(`${part} must be rendered inside .`); + return context; } -export interface CatalogProps { - title?: string; - description?: string; - showHeader?: boolean; +export interface CatalogRootProps { + children: ReactNode; + /** Added to the root `
`. */ + className?: string; /** Permit built-in examples only while the host confirms this site is unbound. */ sampleProducts?: boolean; } -export function Catalog(props: CatalogProps): ReactElement { +/** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */ +export function CatalogRoot({ children, className, sampleProducts = false }: CatalogRootProps): ReactElement { const { connection } = useCommerce(); return ( {connection === 'ready' ? ( - - ) : connection === 'unbound' && props.sampleProducts ? ( - + {children} + ) : connection === 'unbound' && sampleProducts ? ( + +
+ {children} +
+
) : ( )} @@ -126,11 +67,7 @@ export function Catalog(props: CatalogProps): ReactElement { ); } -function CatalogContent({ - title = 'Shop all products', - description = 'Explore the collection and find your favorites.', - showHeader = true, -}: CatalogProps): ReactElement { +function CatalogLoader({ children, className }: Omit): ReactElement { const { config } = useCommerce(); const [params, setParams] = useSearchParams(); const after: string = params.get('after') ?? ''; @@ -153,25 +90,75 @@ function CatalogContent({ } const nodes: SKUGroup[] = products.data?.skuGroups?.edges?.flatMap((edge) => (edge?.node?.id ? [edge.node] : [])) ?? []; - const pageInfo = products.data?.skuGroups?.pageInfo; return ( -
- {showHeader && ( -
-

{title}

-

{description}

-
- )} -
-

- {products.isPending + +

{children}
+ + ); +} + +export interface CatalogHeaderProps extends CatalogPartProps { + title?: string; + description?: string; +} + +/** The catalog title as the page's H1, with a description. */ +export function CatalogHeader({ + title = 'Shop all products', + description = 'Explore the collection and find your favorites.', + className, +}: CatalogHeaderProps): ReactElement { + useCatalogInternal('CatalogHeader'); + return ( +
+

{title}

+

{description}

+
+ ); +} + +/** Announces loading, failure, or how many products the page shows. */ +export function CatalogSummary({ className }: CatalogPartProps): ReactElement { + const catalog = useCatalogInternal('CatalogSummary'); + return ( +
+

+ {catalog.source === 'sample' + ? 'Sample products · Example prices in USD' + : catalog.products.isPending ? 'Loading products…' - : products.isError + : catalog.products.isError ? 'Products unavailable' - : `Showing ${nodes.length} product${nodes.length === 1 ? '' : 's'}`} -

-
- {products.isPending && ( + : `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? '' : 's'}`} +

+
+ ); +} + +export interface CatalogProductsProps extends CatalogPartProps { + /** One card, rendered for every product. Defaults to the packaged card. */ + children?: ReactNode; +} + +/** + * The product cards. `className` goes on the element that contains the cards, such as a grid. The loading + * placeholder, the error with its retry, and the empty message render just before that element. + */ +export function CatalogProducts({ className, children }: CatalogProductsProps): ReactElement { + const catalog = useCatalogInternal('CatalogProducts'); + const card: ReactNode = children ?? ( + + + + ); + const items: { key: string; item: ProductCardItem }[] = + catalog.source === 'sample' + ? sampleProducts.map((sample) => ({ key: sample.key, item: { source: 'sample', sample } })) + : catalog.nodes.map((product) => ({ key: product.id ?? '', item: { source: 'live', product } })); + const products = catalog.source === 'live' ? catalog.products : null; + return ( + <> + {products?.isPending && (
+ ); } -function SampleCatalog({ - title = 'Shop all products', - description = 'Explore the collection and find your favorites.', +/** First-page and next-page buttons. Renders nothing for samples, which have one page. */ +export function CatalogPagination({ className }: CatalogPartProps): ReactElement | null { + const catalog = useCatalogInternal('CatalogPagination'); + if (catalog.source === 'sample') return null; + const { after, goToPage } = catalog; + const pageInfo = catalog.products.data?.skuGroups?.pageInfo; + return ( + + ); +} + +export interface CatalogProps { + title?: string; + description?: string; + showHeader?: boolean; + /** Permit built-in examples only while the host confirms this site is unbound. */ + sampleProducts?: boolean; +} + +/** The packaged catalog. Compose `CatalogRoot` and the catalog parts for a custom layout. */ +export function Catalog({ + title, + description, showHeader = true, + sampleProducts, }: CatalogProps): ReactElement { - const { add } = useSampleCart(); return ( -
+ {showHeader && ( -
-

{title}

-

{description}

-
+ )} -

- Sample products · Example prices in USD -

-
- {sampleProducts.map((product) => ( -
-
- ))} -
-
+ + + + ); } diff --git a/packages/commerce-storefront/src/index.ts b/packages/commerce-storefront/src/index.ts index 0f4672892..64adf3d71 100644 --- a/packages/commerce-storefront/src/index.ts +++ b/packages/commerce-storefront/src/index.ts @@ -2,7 +2,19 @@ export { ApiError, money, type StorefrontConfig } from './api'; export { AddToCartButton, CartButton, CartDrawer } from './cart'; export type * from './cart-model'; export { getCartSummaryTotals } from './cart-model'; -export { Catalog, type CatalogProps, ProductCard } from './catalog'; +export { + Catalog, + CatalogHeader, + type CatalogHeaderProps, + CatalogPagination, + type CatalogPartProps, + CatalogProducts, + type CatalogProductsProps, + type CatalogProps, + CatalogRoot, + type CatalogRootProps, + CatalogSummary, +} from './catalog'; export type * from './catalog-model'; export { getAvailableInventoryQuantity, @@ -20,6 +32,19 @@ export { useCommerce, } from './commerce-provider'; export { CommerceStorefront } from './commerce-storefront'; +export { + ProductCard, + ProductCardAction, + ProductCardDescription, + ProductCardImage, + type ProductCardPartProps, + ProductCardPrice, + ProductCardRoot, + type ProductCardRootProps, + type ProductCardState, + ProductCardTitle, + useProductCard, +} from './product-card'; export { ProductDetails, ProductDetailsBackLink, diff --git a/packages/commerce-storefront/src/part.ts b/packages/commerce-storefront/src/part.ts new file mode 100644 index 000000000..72bcc26e4 --- /dev/null +++ b/packages/commerce-storefront/src/part.ts @@ -0,0 +1,4 @@ +export const cx = (...classes: Array): string => classes.filter(Boolean).join(' '); + +/** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */ +export const partClass = (...classes: Array): string => cx('commerce-part', ...classes); diff --git a/packages/commerce-storefront/src/product-card.tsx b/packages/commerce-storefront/src/product-card.tsx new file mode 100644 index 000000000..0739bc3a8 --- /dev/null +++ b/packages/commerce-storefront/src/product-card.tsx @@ -0,0 +1,282 @@ +import { createContext, type ReactElement, type ReactNode, useContext, useState } from 'react'; +import { Link } from 'react-router'; +import { money } from './api'; +import { AddToCartButton, buttonClass } from './cart'; +import { + getPrimaryImageUrl, + getProductAttributes, + getSingleMatchedSku, + type SKU, + type SKUGroup, +} from './catalog-model'; +import { useCommerce } from './commerce-provider'; +import { partClass } from './part'; +import { type SampleProduct, useSampleCart } from './sample-cart'; +import { StorefrontSurface } from './storefront-surface'; + +export function ProductImage({ + url, + name, + className = '', +}: { + url?: string | null; + name: string; + className?: string; +}): ReactElement { + const [failed, setFailed] = useState(false); + return url && !failed ? ( + {name} setFailed(true)} + /> + ) : ( +
+ No image available +
+ ); +} + +/** One catalog entry: a product from the server, or a built-in sample while no store is connected. */ +export type ProductCardItem = + | { source: 'live'; product: SKUGroup } + | { source: 'sample'; sample: SampleProduct }; + +/** Set by `CatalogProducts` for each entry it renders, so the card template needs no product prop. */ +export const CatalogItemContext = createContext(null); + +/** Read-only card state for custom parts rendered inside `ProductCardRoot`. */ +export interface ProductCardState { + /** `null` for a built-in sample, which is not a catalog product. */ + readonly product: SKUGroup | null; + readonly name: string; + /** The product page, or `null` for a sample, which has none. */ + readonly href: string | null; + readonly isSample: boolean; +} + +interface ProductCardInternal extends ProductCardState { + item: ProductCardItem; +} + +/** Props shared by every card part. `className` is added to the part's outer element. */ +export interface ProductCardPartProps { + className?: string; +} + +const ProductCardContext = createContext(null); + +function useProductCardInternal(part: string): ProductCardInternal { + const context = useContext(ProductCardContext); + if (!context) throw new Error(`${part} must be rendered inside .`); + return context; +} + +/** Read the card's product, name, and link from inside `ProductCardRoot`. */ +export function useProductCard(): ProductCardState { + const { product, name, href, isSample } = useProductCardInternal('useProductCard'); + return { product, name, href, isSample }; +} + +export interface ProductCardRootProps { + children: ReactNode; + /** The catalog product to show. Leave it out inside `CatalogProducts`, which supplies each product. */ + product?: SKUGroup; + /** Added to the root `
`. */ + className?: string; +} + +/** Provides one product to card parts. Inside `CatalogProducts` it renders the current catalog entry. */ +export function ProductCardRoot({ children, product, className }: ProductCardRootProps): ReactElement { + const catalogItem = useContext(CatalogItemContext); + const { config } = useCommerce(); + if (catalogItem && product) + throw new Error( + 'Inside , ProductCardRoot shows each catalog product. Remove its product prop.', + ); + const item: ProductCardItem | null = catalogItem ?? (product ? { source: 'live', product } : null); + if (!item) throw new Error('ProductCardRoot needs a product prop outside .'); + const value: ProductCardInternal = + item.source === 'live' + ? { + item, + product: item.product, + name: item.product.label ?? item.product.name ?? 'Product', + href: `${config.productPath}/${encodeURIComponent(item.product.id ?? '')}`, + isSample: false, + } + : { item, product: null, name: item.sample.name, href: null, isSample: true }; + const card: ReactElement = ( + + {/* `group` drives the image hover zoom. A card nested inside this one is not a catalog entry. */} +
+ {children} +
+
+ ); + // Inside the catalog the article is the grid item, so host classes such as `col-span-2` place it. + return catalogItem ? card : {card}; +} + +/** The product's first image, linked to its page. Samples show a placeholder. */ +export function ProductCardImage({ className }: ProductCardPartProps): ReactElement { + const { item, name, href } = useProductCardInternal('ProductCardImage'); + return ( +
+ {item.source === 'live' && href ? ( + + + + ) : ( + + ); +} + +/** The product name as an H2, linked to its page. */ +export function ProductCardTitle({ className }: ProductCardPartProps): ReactElement { + const { name, href } = useProductCardInternal('ProductCardTitle'); + return ( +
+

+ {href ? ( + + {name} + + ) : ( + name + )} +

+
+ ); +} + +/** Up to three lines of the product description. Renders nothing without one. */ +export function ProductCardDescription({ className }: ProductCardPartProps): ReactElement | null { + const { product } = useProductCardInternal('ProductCardDescription'); + if (!product?.description) return null; + return ( +
+

{product.description}

+
+ ); +} + +/** The product's price, or its price range when variants differ. */ +export function ProductCardPrice({ className }: ProductCardPartProps): ReactElement { + const { item } = useProductCardInternal('ProductCardPrice'); + const { config } = useCommerce(); + let text: string; + if (item.source === 'sample') text = money(item.sample.price, 'USD'); + else { + const min: number | null | undefined = item.product.priceRange?.min; + const max: number | null | undefined = item.product.priceRange?.max; + text = + typeof min === 'number' + ? `${money(min, config.currencyCode)}${typeof max === 'number' && max !== min ? ` – ${money(max, config.currencyCode)}` : ''}` + : 'Price unavailable'; + } + return ( +
+

{text}

+
+ ); +} + +/** + * Adds the product directly when it has exactly one SKU and no options; otherwise links to its page to choose. + * Samples add to the sample cart. + */ +export function ProductCardAction({ className }: ProductCardPartProps): ReactElement { + const { item, name, href } = useProductCardInternal('ProductCardAction'); + return ( +
+ {item.source === 'live' ? ( + + ) : ( + + )} +
+ ); +} + +function LiveAction({ + product, + name, + href, +}: { + product: SKUGroup; + name: string; + href: string; +}): ReactElement { + const selectedSku: SKU | null = getSingleMatchedSku(product); + const hasVariants: boolean = getProductAttributes(product).length > 0; + if (!hasVariants && selectedSku) return ; + return ( + + {hasVariants ? 'Choose options' : 'View product'} + + ); +} + +function SampleAction({ sample }: { sample: SampleProduct }): ReactElement { + const { add } = useSampleCart(); + return ( + + ); +} + +// `self-start` keeps each card its own height in the catalog grid, as when every card had its own surface wrapper. +export const packagedCardClass: string = + 'flex min-w-0 flex-col self-start overflow-hidden rounded-lg border border-neutral-200'; + +/** The packaged card's parts, inside a root that has `packagedCardClass`. */ +export function PackagedCardParts(): ReactElement { + return ( + <> + +
+ + +
+
+ + +
+
+
+ + ); +} + +/** The packaged card for one `SKUGroup`. Compose `ProductCardRoot` and the card parts for a custom card. */ +export function ProductCard({ product }: { product: SKUGroup }): ReactElement { + return ( + + + + ); +} diff --git a/packages/commerce-storefront/src/product-details-context.tsx b/packages/commerce-storefront/src/product-details-context.tsx index 0db97811a..141647e77 100644 --- a/packages/commerce-storefront/src/product-details-context.tsx +++ b/packages/commerce-storefront/src/product-details-context.tsx @@ -25,6 +25,7 @@ import { type StorefrontProductAttribute, } from './catalog-model'; import { useCommerce } from './commerce-provider'; +import { partClass } from './part'; import { CommerceStatus, StorefrontSurface } from './storefront-surface'; /** Where the shopper is in choosing a purchasable SKU. Only `ready` has a verified `selectedSku`. */ @@ -70,11 +71,6 @@ export interface ProductDetailsPartProps { className?: string; } -export const cx = (...classes: Array): string => classes.filter(Boolean).join(' '); - -/** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */ -export const partClass = (...classes: Array): string => cx('commerce-part', ...classes); - const ProductDetailsContext = createContext(null); export function useProductDetailsInternal(part: string): ProductDetailsInternal { diff --git a/packages/commerce-storefront/src/product-details.test.tsx b/packages/commerce-storefront/src/product-details.test.tsx index fd19ec3b9..a995d8d67 100644 --- a/packages/commerce-storefront/src/product-details.test.tsx +++ b/packages/commerce-storefront/src/product-details.test.tsx @@ -1,9 +1,6 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import postcss from 'postcss'; import type { ReactElement, ReactNode } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; @@ -26,6 +23,7 @@ import { ProductDetailsPurchaseStatus, ProductDetailsQuantity, } from './product-purchase'; +import { packageRuleConflicts } from './test-css'; const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); const sku = (id: string, quantity: number): SKU => ({ @@ -257,33 +255,9 @@ describe('composable product details', () => { expect(lists[0]?.id).not.toBe(lists[1]?.id); }); - // Package CSS ships unlayered, and unlayered rules beat a host's layered utilities (Tailwind v4) whatever - // their specificity. So no package rule may set these properties on the element that receives a host class. it('puts host classes on an element that no package rule styles', async () => { mockApi(); - const rules: Array<{ selector: string; properties: string[] }> = []; - postcss.parse(readFileSync(join(process.cwd(), 'dist/styles.css'), 'utf8')).walkRules((rule) => { - if (rule.parent?.type === 'rule') return; - // Only direct declarations: nested rules such as space-y-* style children, not this element. - const properties: string[] = []; - rule.each((node) => { - if (node.type === 'decl') properties.push(node.prop); - }); - for (const selector of rule.selectors) - if (!selector.includes('::')) rules.push({ selector, properties }); - }); - const hostProperties = - /^(margin|padding|border-(width|style|top|right|bottom|left)|border$|color$|font-family$)/; - const conflicts = (element: Element): string[] => - rules.flatMap(({ selector, properties }) => { - let matches = false; - try { - matches = element.matches(selector); - } catch {} - return matches - ? properties.filter((property) => hostProperties.test(property)).map((p) => `${selector} ${p}`) - : []; - }); + const conflicts = packageRuleConflicts(); const parts: Array<[string, ReactElement]> = [ ['BackLink', ], ['Gallery', ], diff --git a/packages/commerce-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx index 8b9ceaac4..ef188c4d7 100644 --- a/packages/commerce-storefront/src/product-details.tsx +++ b/packages/commerce-storefront/src/product-details.tsx @@ -1,12 +1,12 @@ import type { ReactElement } from 'react'; import { Link } from 'react-router'; import { money } from './api'; -import { ProductImage } from './catalog'; import { useCommerce } from './commerce-provider'; +import { partClass } from './part'; +import { ProductImage } from './product-card'; import { type ProductDetailsPartProps, ProductDetailsRoot, - partClass, useProductDetailsInternal, } from './product-details-context'; import { ProductDetailsPurchase } from './product-purchase'; diff --git a/packages/commerce-storefront/src/product-purchase.tsx b/packages/commerce-storefront/src/product-purchase.tsx index 64bade43b..86f49a852 100644 --- a/packages/commerce-storefront/src/product-purchase.tsx +++ b/packages/commerce-storefront/src/product-purchase.tsx @@ -2,10 +2,10 @@ import { type ReactElement, type ReactNode, useId } from 'react'; import { message } from './api'; import { AddToCartButton, buttonClass, inputClass } from './cart'; import type { SKU } from './catalog-model'; +import { partClass } from './part'; import { type ProductDetailsPartProps, type ProductDetailsStatus, - partClass, useProductDetailsInternal, useProductDetailsOption, useSkuOptionRegistration, diff --git a/packages/commerce-storefront/src/sample-cart.tsx b/packages/commerce-storefront/src/sample-cart.tsx index 8e907f9f2..26abb8ce4 100644 --- a/packages/commerce-storefront/src/sample-cart.tsx +++ b/packages/commerce-storefront/src/sample-cart.tsx @@ -15,6 +15,7 @@ export const sampleProducts = Array.from({ length: 6 }, (_, index) => ({ name: `Product ${index + 1}`, price: (index + 1) * 1_000, })); +export type SampleProduct = (typeof sampleProducts)[number]; interface SampleCartValue { items: { key: string; name: string; price: number; quantity: number }[]; diff --git a/packages/commerce-storefront/src/test-css.ts b/packages/commerce-storefront/src/test-css.ts new file mode 100644 index 000000000..5ffce5cd0 --- /dev/null +++ b/packages/commerce-storefront/src/test-css.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import postcss from 'postcss'; + +// Package CSS ships unlayered, and unlayered rules beat a host's layered utilities (Tailwind v4) whatever +// their specificity. So no package rule may set these properties on the element that receives a host class. +const hostProperties = + /^(margin|padding|border-(width|style|top|right|bottom|left)|border$|color$|font-family$)/; + +/** Returns `selector property` for every shipped package rule that sets a host-owned property on `element`. */ +export function packageRuleConflicts(): (element: Element) => string[] { + const rules: Array<{ selector: string; properties: string[] }> = []; + postcss.parse(readFileSync(join(process.cwd(), 'dist/styles.css'), 'utf8')).walkRules((rule) => { + if (rule.parent?.type === 'rule') return; + // Only direct declarations: nested rules such as space-y-* style children, not this element. + const properties: string[] = []; + rule.each((node) => { + if (node.type === 'decl') properties.push(node.prop); + }); + for (const selector of rule.selectors) if (!selector.includes('::')) rules.push({ selector, properties }); + }); + return (element) => + rules.flatMap(({ selector, properties }) => { + let matches = false; + try { + matches = element.matches(selector); + } catch {} + return matches + ? properties.filter((property) => hostProperties.test(property)).map((p) => `${selector} ${p}`) + : []; + }); +} From f73b79312c054b83816d55c1b45d106cd9cbcff6 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 15 Oct 2026 11:01:26 -0700 Subject: [PATCH 2/7] Make catalog cards in a row the same height Cards stretch to their grid row and line up their price rows. Drops self-start, which kept the uneven heights from 0.2.0. Co-Authored-By: Claude Opus 5.5 --- .changeset/composable-catalog.md | 2 +- packages/commerce-storefront/README.md | 4 ++-- packages/commerce-storefront/src/product-card.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/composable-catalog.md b/.changeset/composable-catalog.md index 8af7a8040..5edbb3a66 100644 --- a/.changeset/composable-catalog.md +++ b/.changeset/composable-catalog.md @@ -2,4 +2,4 @@ "@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. 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. Live catalogs and cards are visually unchanged. The sample catalog's summary row now uses the live catalog's padding, 32px instead of 24px above and below from the `sm` breakpoint. 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 `

`. +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. 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/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 9f9060ab2..5e07f5c67 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -193,7 +193,7 @@ import { - +
@@ -223,7 +223,7 @@ As with product layouts, the grid, spacing and border classes come from your own | `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. The packaged card's `self-start` keeps each card at its own height, as in earlier releases; leave it out for cards that fill their grid row. +`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). diff --git a/packages/commerce-storefront/src/product-card.tsx b/packages/commerce-storefront/src/product-card.tsx index 0739bc3a8..635859e6a 100644 --- a/packages/commerce-storefront/src/product-card.tsx +++ b/packages/commerce-storefront/src/product-card.tsx @@ -249,9 +249,9 @@ function SampleAction({ sample }: { sample: SampleProduct }): ReactElement { ); } -// `self-start` keeps each card its own height in the catalog grid, as when every card had its own surface wrapper. +// In the catalog grid each card stretches to its row, and `mt-auto` lines up the price rows. export const packagedCardClass: string = - 'flex min-w-0 flex-col self-start overflow-hidden rounded-lg border border-neutral-200'; + 'flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200'; /** The packaged card's parts, inside a root that has `packagedCardClass`. */ export function PackagedCardParts(): ReactElement { From 9dcdc6b47ce411264db2e950aa0da77c02b608af Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 15 Oct 2026 11:59:09 -0700 Subject: [PATCH 3/7] Space out the product details purchase area The purchase area cancelled its own gaps to match 0.2.0, so options, messages and quantity touched. Use a flex gap, with the status and stock messages 8px under the options. Co-Authored-By: Claude Opus 5.5 --- .changeset/purchase-area-spacing.md | 5 ++ .../src/product-purchase.tsx | 73 ++++++------------- 2 files changed, 29 insertions(+), 49 deletions(-) create mode 100644 .changeset/purchase-area-spacing.md diff --git a/.changeset/purchase-area-spacing.md b/.changeset/purchase-area-spacing.md new file mode 100644 index 000000000..c2e79fa01 --- /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. diff --git a/packages/commerce-storefront/src/product-purchase.tsx b/packages/commerce-storefront/src/product-purchase.tsx index 86f49a852..12e6e6efd 100644 --- a/packages/commerce-storefront/src/product-purchase.tsx +++ b/packages/commerce-storefront/src/product-purchase.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, type ReactNode, useId } from 'react'; +import { type ReactElement, useId } from 'react'; import { message } from './api'; import { AddToCartButton, buttonClass, inputClass } from './cart'; import type { SKU } from './catalog-model'; @@ -19,19 +19,16 @@ const disabledReasons: Record, string> = 'not-purchasable': 'This product is not currently available to purchase.', }; -// In 0.2.0 the purchase area's option fieldsets and text messages were bare
/

children of -// `space-y-6`, and the package reset's `margin: 0` outranked that zero-specificity rule, so they had no gap. -// Their wrappers now take the gap, so the packaged layout cancels it on those parts to stay visually identical. -const flush = 'mb-0'; - /** The packaged purchase area: options, status, availability, quantity, and add to cart. */ export function ProductDetailsPurchase({ className }: ProductDetailsPartProps): ReactElement { - useProductDetailsInternal('ProductDetailsPurchase'); + const { attributes, skuOptions } = useProductDetailsInternal('ProductDetailsPurchase'); + // The status and stock messages describe the selection, so they sit 8px under the options instead of 24px. + const message: string | undefined = attributes.length || skuOptions.length ? '-mt-4' : undefined; return ( -

+
- - + +
@@ -43,23 +40,19 @@ export function ProductDetailsOptions({ className }: ProductDetailsPartProps): R const { attributes, skuOptions } = useProductDetailsInternal('ProductDetailsOptions'); if (attributes.length === 0 && skuOptions.length === 0) return null; return ( -
+
); } -// Unwrapped so the packaged layout keeps the controls as direct children of its own spacing container. +// Unwrapped so the packaged purchase area spaces the controls with its other parts. function OptionControls(): ReactElement { const { attributes, skuOptions } = useProductDetailsInternal('ProductDetailsOptions'); return ( <> {attributes.map((attribute) => ( - + ))} {skuOptions.length > 0 && } @@ -128,48 +121,30 @@ export function ProductDetailsOption({ /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */ export function ProductDetailsPurchaseStatus({ className }: ProductDetailsPartProps): ReactElement | null { - return ; -} - -function PurchaseStatusMessage({ - className, - textClassName, -}: ProductDetailsPartProps & { textClassName?: string }): ReactElement | null { const { status, variantError, retryVariant } = useProductDetailsInternal('ProductDetailsPurchaseStatus'); - const part = (children: ReactNode, partClassName?: string): ReactElement => ( -
{children}
- ); - switch (status) { - case 'ready': - return null; - case 'variant-error': - return part( + if (status === 'ready') return null; + return ( +
+ {status === 'variant-error' ? (

{message(variantError)}

-
, - ); - case 'unavailable': - return part( +
+ ) : status === 'unavailable' ? (

{disabledReasons[status]} -

, - textClassName, - ); - case 'not-purchasable': - return part(

{disabledReasons[status]}

, textClassName); - case 'checking': - return part(

{disabledReasons[status]}

, textClassName); - case 'incomplete': - return part( +

+ ) : status === 'not-purchasable' ? ( +

{disabledReasons[status]}

+ ) : (

{disabledReasons[status]} -

, - textClassName, - ); - } +

+ )} +
+ ); } /** Stock for the verified SKU. Renders nothing until a SKU is verified. */ From fd7a9ae651681730b4d5568f3372e9149a6f77e2 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 15 Oct 2026 13:15:02 -0700 Subject: [PATCH 4/7] Keep a card's image link accessible when it has no title ProductCardImage always hid its link from the keyboard and screen readers, assuming ProductCardTitle supplied one. A card without a title had no accessible product link. The title now tells the root it's rendered, and the image link is hidden only then. Co-Authored-By: Claude Opus 5.5 --- packages/commerce-storefront/README.md | 2 +- .../commerce-storefront/src/catalog.test.tsx | 35 +++++++++++++++++ .../commerce-storefront/src/product-card.tsx | 39 ++++++++++++++++--- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 5e07f5c67..487ef2d4f 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -219,7 +219,7 @@ As with product layouts, the grid, spacing and border classes come from your own | `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 | +| `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 | | `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 | diff --git a/packages/commerce-storefront/src/catalog.test.tsx b/packages/commerce-storefront/src/catalog.test.tsx index a1655d578..c79f1b8dc 100644 --- a/packages/commerce-storefront/src/catalog.test.tsx +++ b/packages/commerce-storefront/src/catalog.test.tsx @@ -206,6 +206,41 @@ describe('composable catalog', () => { ]); }); + 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('names the missing root or product when parts are misplaced', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); expect(() => render()).toThrow( diff --git a/packages/commerce-storefront/src/product-card.tsx b/packages/commerce-storefront/src/product-card.tsx index 635859e6a..63e4beb45 100644 --- a/packages/commerce-storefront/src/product-card.tsx +++ b/packages/commerce-storefront/src/product-card.tsx @@ -1,4 +1,12 @@ -import { createContext, type ReactElement, type ReactNode, useContext, useState } from 'react'; +import { + createContext, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; import { Link } from 'react-router'; import { money } from './api'; import { AddToCartButton, buttonClass } from './cart'; @@ -63,6 +71,9 @@ export interface ProductCardState { interface ProductCardInternal extends ProductCardState { item: ProductCardItem; + /** Whether a `ProductCardTitle` link is rendered, so the image link can stay out of the tab order. */ + hasTitleLink: boolean; + registerTitleLink: () => () => void; } /** Props shared by every card part. `className` is added to the part's outer element. */ @@ -96,12 +107,18 @@ export interface ProductCardRootProps { export function ProductCardRoot({ children, product, className }: ProductCardRootProps): ReactElement { const catalogItem = useContext(CatalogItemContext); const { config } = useCommerce(); + const [titleLinks, setTitleLinks] = useState(0); + const registerTitleLink = useCallback((): (() => void) => { + setTitleLinks((count) => count + 1); + return () => setTitleLinks((count) => count - 1); + }, []); if (catalogItem && product) throw new Error( 'Inside , ProductCardRoot shows each catalog product. Remove its product prop.', ); const item: ProductCardItem | null = catalogItem ?? (product ? { source: 'live', product } : null); if (!item) throw new Error('ProductCardRoot needs a product prop outside .'); + const links = { hasTitleLink: titleLinks > 0, registerTitleLink }; const value: ProductCardInternal = item.source === 'live' ? { @@ -110,8 +127,9 @@ export function ProductCardRoot({ children, product, className }: ProductCardRoo name: item.product.label ?? item.product.name ?? 'Product', href: `${config.productPath}/${encodeURIComponent(item.product.id ?? '')}`, isSample: false, + ...links, } - : { item, product: null, name: item.sample.name, href: null, isSample: true }; + : { item, product: null, name: item.sample.name, href: null, isSample: true, ...links }; const card: ReactElement = ( {/* `group` drives the image hover zoom. A card nested inside this one is not a catalog entry. */} @@ -124,13 +142,21 @@ export function ProductCardRoot({ children, product, className }: ProductCardRoo return catalogItem ? card : {card}; } -/** The product's first image, linked to its page. Samples show a placeholder. */ +/** + * The product's first image, linked to its page. Samples show a placeholder. With a `ProductCardTitle` in the card, + * the title is the one link keyboard and screen reader users get; without one, the image link is. + */ export function ProductCardImage({ className }: ProductCardPartProps): ReactElement { - const { item, name, href } = useProductCardInternal('ProductCardImage'); + const { item, name, href, hasTitleLink } = useProductCardInternal('ProductCardImage'); return (
{item.source === 'live' && href ? ( - + (href ? registerTitleLink() : undefined), [href, registerTitleLink]); return (

From 993b93df9e0f72fdacd290875b79018a9b4d1604 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 15 Oct 2026 13:23:50 -0700 Subject: [PATCH 5/7] Inset the product option select chevron The native arrow sat against the border and ignored padding. Draw a chevron 12px in from the edge. Also note in the README that host classes sharing a name with a package utility lose. Co-Authored-By: Claude Opus 5.5 --- .changeset/select-chevron.md | 5 +++ packages/commerce-storefront/README.md | 2 +- .../src/product-purchase.tsx | 41 ++++++++++++++----- 3 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 .changeset/select-chevron.md 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 487ef2d4f..685018fc9 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -154,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 and grid placement, including layered utilities such as Tailwind v4's. 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: diff --git a/packages/commerce-storefront/src/product-purchase.tsx b/packages/commerce-storefront/src/product-purchase.tsx index 12e6e6efd..3e9e7344a 100644 --- a/packages/commerce-storefront/src/product-purchase.tsx +++ b/packages/commerce-storefront/src/product-purchase.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, useId } from 'react'; +import { type ComponentProps, type ReactElement, useId } from 'react'; import { message } from './api'; import { AddToCartButton, buttonClass, inputClass } from './cart'; import type { SKU } from './catalog-model'; @@ -59,6 +59,31 @@ function OptionControls(): ReactElement { ); } +// The native arrow sits against the border and ignores padding, so draw our own 12px in from the edge. +function Select({ children, ...props }: Omit, 'className'>): ReactElement { + return ( +
+ + +
+ ); +} + function SkuOption(): ReactElement { const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal('ProductDetailsOptions'); const id = useId(); @@ -68,19 +93,14 @@ function SkuOption(): ReactElement { - selectSku(event.target.value)}> {skuOptions.map((sku: SKU) => ( ))} - +

); } @@ -101,8 +121,7 @@ export function ProductDetailsOption({
{option.label} - +
); From 7b115ce8f7d987d8aac8e1a81c587338909cf498 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 15 Oct 2026 13:59:08 -0700 Subject: [PATCH 6/7] Tighten docs and changesets after checking against #1502 review Say which Catalog the README layout writes out, note the smaller checking message in the spacing changeset, and export ProductCardProps alongside the catalog prop types. Co-Authored-By: Claude Opus 5.5 --- .changeset/composable-catalog.md | 2 +- .changeset/purchase-area-spacing.md | 2 +- packages/commerce-storefront/README.md | 2 +- packages/commerce-storefront/src/index.ts | 1 + packages/commerce-storefront/src/product-card.tsx | 6 +++++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.changeset/composable-catalog.md b/.changeset/composable-catalog.md index 5edbb3a66..58f394193 100644 --- a/.changeset/composable-catalog.md +++ b/.changeset/composable-catalog.md @@ -2,4 +2,4 @@ "@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. 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 `

`. +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/purchase-area-spacing.md b/.changeset/purchase-area-spacing.md index c2e79fa01..fa76a32a3 100644 --- a/.changeset/purchase-area-spacing.md +++ b/.changeset/purchase-area-spacing.md @@ -2,4 +2,4 @@ "@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. +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/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 685018fc9..50bc66c65 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -181,7 +181,7 @@ Parts are flat named exports, not properties such as `ProductDetails.Title`, so ## 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 the packaged catalog, written out with its packaged card: +`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 { diff --git a/packages/commerce-storefront/src/index.ts b/packages/commerce-storefront/src/index.ts index 64adf3d71..1d585e679 100644 --- a/packages/commerce-storefront/src/index.ts +++ b/packages/commerce-storefront/src/index.ts @@ -39,6 +39,7 @@ export { ProductCardImage, type ProductCardPartProps, ProductCardPrice, + type ProductCardProps, ProductCardRoot, type ProductCardRootProps, type ProductCardState, diff --git a/packages/commerce-storefront/src/product-card.tsx b/packages/commerce-storefront/src/product-card.tsx index 63e4beb45..e02a0769a 100644 --- a/packages/commerce-storefront/src/product-card.tsx +++ b/packages/commerce-storefront/src/product-card.tsx @@ -299,8 +299,12 @@ export function PackagedCardParts(): ReactElement { ); } +export interface ProductCardProps { + product: SKUGroup; +} + /** The packaged card for one `SKUGroup`. Compose `ProductCardRoot` and the card parts for a custom card. */ -export function ProductCard({ product }: { product: SKUGroup }): ReactElement { +export function ProductCard({ product }: ProductCardProps): ReactElement { return ( From a3fcecc45ebb97c10a5fe6308ab54c63530059d3 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 16 Oct 2026 08:19:52 -0700 Subject: [PATCH 7/7] Address #1506 review: host layout classes, server-rendered image link - Price, Options, Purchase and Pagination keep their default layout off the element that gets the host className, so host display, gap and grid apply. Purchase is back to space-y-6; the -mt-4 messages collapse to the same 8px. - Roots keep the host className while connecting, loading or erroring. - The card's image link is hidden by default and exposed after mount only when no title link registered, so server HTML has one link per card. - A false or empty CatalogProducts template falls back to the packaged card. - Restore the purchase status switch; remove nested ternaries in new code. - Share the part registry between ProductDetailsRoot and ProductCardRoot, add getProductName, make the card context a discriminated union, and memoize catalog and card context values. - The conflict test now also checks display, gap, flex, grid columns and width. Co-Authored-By: Claude Opus 5.5 --- .changeset/host-layout-classes.md | 5 + packages/commerce-storefront/README.md | 4 +- .../commerce-storefront/src/catalog-model.ts | 5 + .../commerce-storefront/src/catalog.test.tsx | 55 ++++++- packages/commerce-storefront/src/catalog.tsx | 151 +++++++++++------- packages/commerce-storefront/src/part.ts | 31 ++++ .../commerce-storefront/src/product-card.tsx | 112 ++++++++----- .../src/product-details-context.tsx | 49 +++--- .../src/product-details.tsx | 37 +++-- .../src/product-purchase.tsx | 52 +++--- packages/commerce-storefront/src/test-css.ts | 2 +- 11 files changed, 332 insertions(+), 171 deletions(-) create mode 100644 .changeset/host-layout-classes.md 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/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 50bc66c65..f92bd142d 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -154,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 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. +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: @@ -219,7 +219,7 @@ As with product layouts, the grid, spacing and border classes come from your own | `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 | +| `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 | 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 index c79f1b8dc..0be7cf00b 100644 --- a/packages/commerce-storefront/src/catalog.test.tsx +++ b/packages/commerce-storefront/src/catalog.test.tsx @@ -2,12 +2,14 @@ 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, @@ -73,16 +75,20 @@ function mockApi({ return fn; } -function mount(layout: ReactNode) { - return render( +function app(layout: ReactNode): ReactElement { + return ( {layout} - , + ); } +function mount(layout: ReactNode) { + return render(app(layout)); +} + function Badge(): ReactElement { const { name, href, isSample } = useProductCard(); return {`${name}:${href ?? 'none'}:${isSample}`}; @@ -241,6 +247,49 @@ describe('composable catalog', () => { 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( diff --git a/packages/commerce-storefront/src/catalog.tsx b/packages/commerce-storefront/src/catalog.tsx index 1d3b329ca..b8a2e744b 100644 --- a/packages/commerce-storefront/src/catalog.tsx +++ b/packages/commerce-storefront/src/catalog.tsx @@ -1,5 +1,13 @@ import { type UseQueryResult, useQuery } from '@tanstack/react-query'; -import { createContext, type ReactElement, type ReactNode, useContext } from 'react'; +import { + 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'; @@ -32,6 +40,7 @@ export interface CatalogPartProps { } const CatalogContext = createContext(null); +const sampleCatalog: CatalogInternal = { source: 'sample' }; function useCatalogInternal(part: string): CatalogInternal { const context = useContext(CatalogContext); @@ -50,21 +59,24 @@ export interface CatalogRootProps { /** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */ export function CatalogRoot({ children, className, sampleProducts = false }: CatalogRootProps): ReactElement { const { connection } = useCommerce(); - return ( - - {connection === 'ready' ? ( - {children} - ) : connection === 'unbound' && sampleProducts ? ( - -
- {children} -
-
- ) : ( + let content: ReactElement; + if (connection === 'ready') content = {children}; + else if (connection === 'unbound' && sampleProducts) + content = ( + +
+ {children} +
+
+ ); + // Host classes stay on while connecting, so the layout does not jump when the catalog replaces the status. + else + content = ( +
- )} - - ); +
+ ); + return {content}; } function CatalogLoader({ children, className }: Omit): ReactElement { @@ -82,16 +94,27 @@ function CatalogLoader({ children, className }: Omit (edge?.node?.id ? [edge.node] : [])) ?? []; + const goToPage = useCallback( + (cursor?: string): void => { + const next = new URLSearchParams(params); + if (cursor) next.set('after', cursor); + else next.delete('after'); + setParams(next); + }, + [params, setParams], + ); + // Stable while the data is, so a refetch does not re-render every card. + const nodes = useMemo( + (): SKUGroup[] => + products.data?.skuGroups?.edges?.flatMap((edge) => (edge?.node?.id ? [edge.node] : [])) ?? [], + [products.data], + ); + const value = useMemo( + (): CatalogInternal => ({ source: 'live', products, nodes, after, goToPage }), + [products, nodes, after, goToPage], + ); return ( - +
{children}
); @@ -123,18 +146,30 @@ export function CatalogSummary({ className }: CatalogPartProps): ReactElement { return (

- {catalog.source === 'sample' - ? 'Sample products · Example prices in USD' - : catalog.products.isPending - ? 'Loading products…' - : catalog.products.isError - ? 'Products unavailable' - : `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? '' : 's'}`} + {summary(catalog)}

); } +function summary(catalog: CatalogInternal): string { + if (catalog.source === 'sample') return 'Sample products · Example prices in USD'; + if (catalog.products.isPending) return 'Loading products…'; + if (catalog.products.isError) return 'Products unavailable'; + return `Showing ${catalog.nodes.length} product${catalog.nodes.length === 1 ? '' : 's'}`; +} + +const sampleItems: { key: string; item: ProductCardItem }[] = sampleProducts.map((sample) => ({ + key: sample.key, + item: { source: 'sample', sample }, +})); + +const packagedCard: ReactElement = ( + + + +); + export interface CatalogProductsProps extends CatalogPartProps { /** One card, rendered for every product. Defaults to the packaged card. */ children?: ReactNode; @@ -146,15 +181,16 @@ export interface CatalogProductsProps extends CatalogPartProps { */ export function CatalogProducts({ className, children }: CatalogProductsProps): ReactElement { const catalog = useCatalogInternal('CatalogProducts'); - const card: ReactNode = children ?? ( - - - + // `{custom && …}` with `custom` false still gets a card, not an empty slot per product. + const card: ReactNode = Children.toArray(children).some((child) => child !== '') ? children : packagedCard; + const nodes: SKUGroup[] | null = catalog.source === 'live' ? catalog.nodes : null; + const items = useMemo( + (): { key: string; item: ProductCardItem }[] => + nodes + ? nodes.map((product) => ({ key: product.id ?? '', item: { source: 'live', product } })) + : sampleItems, + [nodes], ); - const items: { key: string; item: ProductCardItem }[] = - catalog.source === 'sample' - ? sampleProducts.map((sample) => ({ key: sample.key, item: { source: 'sample', sample } })) - : catalog.nodes.map((product) => ({ key: product.id ?? '', item: { source: 'live', product } })); const products = catalog.source === 'live' ? catalog.products : null; return ( <> @@ -206,23 +242,26 @@ export function CatalogPagination({ className }: CatalogPartProps): ReactElement const { after, goToPage } = catalog; const pageInfo = catalog.products.data?.skuGroups?.pageInfo; return ( -