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 {`${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('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 ? ( - {name} setFailed(true)} - /> - ) : ( -
- No image available -
- ); } -export function ProductCard({ product }: { product: SKUGroup }): ReactElement { - return ( - - - - ); -} +const CatalogContext = createContext(null); +const sampleCatalog: CatalogInternal = { source: 'sample' }; -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 ? ( - - ) : ( + 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 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') ?? ''; @@ -145,33 +94,107 @@ function CatalogContent({ headers: { 'X-Commerce-Scope': config.cartScope }, }), }); - function goToPage(cursor?: string): void { - const next = new URLSearchParams(params); - if (cursor) next.set('after', cursor); - else next.delete('after'); - setParams(next); - } - const nodes: SKUGroup[] = - products.data?.skuGroups?.edges?.flatMap((edge) => (edge?.node?.id ? [edge.node] : [])) ?? []; - const pageInfo = products.data?.skuGroups?.pageInfo; + 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 ( -
- {showHeader && ( -
-

{title}

-

{description}

-
- )} -
-

- {products.isPending - ? 'Loading products…' - : products.isError - ? 'Products unavailable' - : `Showing ${nodes.length} product${nodes.length === 1 ? '' : 's'}`} -

-
- {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 ( +
+

+ {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; +} + +/** + * 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'); + // `{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 products = catalog.source === 'live' ? catalog.products : null; + return ( + <> + {products?.isPending && (
+

+ ); } -function SampleCatalog({ - title = 'Shop all products', - description = 'Explore the collection and find your favorites.', +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..1d585e679 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,20 @@ export { useCommerce, } from './commerce-provider'; export { CommerceStorefront } from './commerce-storefront'; +export { + ProductCard, + ProductCardAction, + ProductCardDescription, + ProductCardImage, + type ProductCardPartProps, + ProductCardPrice, + type ProductCardProps, + 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..a609e14af --- /dev/null +++ b/packages/commerce-storefront/src/part.ts @@ -0,0 +1,35 @@ +import { useCallback, useMemo, useRef } from 'react'; + +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); + +export interface PartRegistry { + /** Call from a part's effect; returns the cleanup. */ + register: (key: string) => () => void; + has: (key: string) => boolean; +} + +/** + * Counts the parts mounted under a root, by key. Parts register in an effect, and a parent's effects run after its + * children's, so the root can read `has` in its own effect. `onChange` must be stable; it runs after each change. + */ +export function usePartRegistry(onChange?: (has: PartRegistry['has']) => void): PartRegistry { + const counts = useRef>(new Map()); + const has = useCallback((key: string): boolean => counts.current.has(key), []); + const register = useCallback( + (key: string): (() => void) => { + counts.current.set(key, (counts.current.get(key) ?? 0) + 1); + onChange?.(has); + return () => { + const count: number = (counts.current.get(key) ?? 1) - 1; + if (count > 0) counts.current.set(key, count); + else counts.current.delete(key); + onChange?.(has); + }; + }, + [has, onChange], + ); + return useMemo(() => ({ register, has }), [register, has]); +} diff --git a/packages/commerce-storefront/src/product-card.tsx b/packages/commerce-storefront/src/product-card.tsx new file mode 100644 index 000000000..987981269 --- /dev/null +++ b/packages/commerce-storefront/src/product-card.tsx @@ -0,0 +1,339 @@ +import { + createContext, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { Link } from 'react-router'; +import { money } from './api'; +import { AddToCartButton, buttonClass } from './cart'; +import { + getPrimaryImageUrl, + getProductAttributes, + getProductName, + getSingleMatchedSku, + type SKU, + type SKUGroup, +} from './catalog-model'; +import { useCommerce } from './commerce-provider'; +import { type PartRegistry, partClass, usePartRegistry } 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 ProductCardLinks { + /** Whether the image link is the card's product link: only when no `ProductCardTitle` link is rendered. */ + imageLinked: boolean; + register: (key: string) => () => void; +} + +type ProductCardInternal = ProductCardState & + ProductCardLinks & + ( + | { source: 'live'; product: SKUGroup; href: string } + | { source: 'sample'; sample: SampleProduct; product: null; href: null } + ); + +const titleLink = 'title-link'; + +function cardValue(item: ProductCardItem, productPath: string, links: ProductCardLinks): ProductCardInternal { + if (item.source === 'sample') + return { + ...links, + source: 'sample', + sample: item.sample, + product: null, + name: item.sample.name, + href: null, + isSample: true, + }; + return { + ...links, + source: 'live', + product: item.product, + name: getProductName(item.product), + href: `${productPath}/${encodeURIComponent(item.product.id ?? '')}`, + isSample: false, + }; +} + +/** 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 useMemo(() => ({ product, name, href, isSample }), [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(); + // The image link starts hidden, so server HTML and the first render have one product link per card. A card + // with no title link exposes it once mounted. Parent effects run after child effects, so titles have registered. + const [imageLinked, setImageLinked] = useState(false); + const { register, has } = usePartRegistry( + useCallback((registered: PartRegistry['has']) => setImageLinked(!registered(titleLink)), []), + ); + useEffect(() => { + setImageLinked(!has(titleLink)); + }, [has]); + const value = useMemo((): ProductCardInternal | null => { + const item: ProductCardItem | null = catalogItem ?? (product ? { source: 'live', product } : null); + return item && cardValue(item, config.productPath, { imageLinked, register }); + }, [catalogItem, product, config.productPath, imageLinked, register]); + if (catalogItem && product) + throw new Error( + 'Inside , ProductCardRoot shows each catalog product. Remove its product prop.', + ); + if (!value) throw new Error('ProductCardRoot needs a product prop outside .'); + 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. 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 card = useProductCardInternal('ProductCardImage'); + return ( +
+ {card.source === 'live' ? ( + + + + ) : ( + + ); +} + +/** The product name as an H2, linked to its page. */ +export function ProductCardTitle({ className }: ProductCardPartProps): ReactElement { + const { name, href, register } = useProductCardInternal('ProductCardTitle'); + useEffect(() => (href ? register(titleLink) : undefined), [href, register]); + 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 card = useProductCardInternal('ProductCardPrice'); + const { config } = useCommerce(); + let text: string; + if (card.source === 'sample') text = money(card.sample.price, 'USD'); + else { + const min: number | null | undefined = card.product.priceRange?.min; + const max: number | null | undefined = card.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 card = useProductCardInternal('ProductCardAction'); + return ( +
+ {card.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 ( + + ); +} + +// 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 overflow-hidden rounded-lg border border-neutral-200'; + +/** The packaged card's parts, inside a root that has `packagedCardClass`. */ +export function PackagedCardParts(): ReactElement { + return ( + <> + +
+ + +
+
+ + +
+
+
+ + ); +} + +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 }: ProductCardProps): ReactElement { + return ( + + + + ); +} diff --git a/packages/commerce-storefront/src/product-details-context.tsx b/packages/commerce-storefront/src/product-details-context.tsx index 0db97811a..65444af26 100644 --- a/packages/commerce-storefront/src/product-details-context.tsx +++ b/packages/commerce-storefront/src/product-details-context.tsx @@ -1,14 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { - createContext, - type ReactElement, - type ReactNode, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import { createContext, type ReactElement, type ReactNode, useContext, useEffect, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router'; import { ApiError, message, request } from './api'; import { buttonClass } from './cart'; @@ -17,6 +8,7 @@ import { getImageUrls, getLabeledSkuOptions, getProductAttributes, + getProductName, getSingleMatchedSku, type SKU, type SKUGroup, @@ -25,6 +17,7 @@ import { type StorefrontProductAttribute, } from './catalog-model'; import { useCommerce } from './commerce-provider'; +import { partClass, usePartRegistry } 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 +63,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 { @@ -156,7 +144,10 @@ export function ProductDetailsRoot({ children, className }: ProductDetailsRootPr {children} ) : ( - + // Host classes stay on while connecting, so the layout does not jump when the product replaces the status. +
+ +
)} ); @@ -236,16 +227,7 @@ function ProductDetailsLoader({ setQuantity(1); setImageIndex(0); }; - const registered = useRef>(new Map()); - const register = useCallback((control: string): (() => void) => { - const counts: Map = registered.current; - counts.set(control, (counts.get(control) ?? 0) + 1); - return () => { - const count: number = (counts.get(control) ?? 1) - 1; - if (count > 0) counts.set(control, count); - else counts.delete(control); - }; - }, []); + const { register, has } = usePartRegistry(); const requiredControls: string = [ ...attributes.map((attribute) => `attribute:${attribute.name}`), ...(skuOptions.length ? ['sku'] : []), @@ -255,9 +237,7 @@ function ProductDetailsLoader({ // like the peer query library's own checks. useEffect(() => { if (process.env.NODE_ENV === 'production') return; - const missing: string[] = requiredControls - .split('\n') - .filter((control) => control && !registered.current.has(control)); + const missing: string[] = requiredControls.split('\n').filter((control) => control && !has(control)); if (missing.length === 0) return; console.warn( `[gd-commerce-storefront] No control for ${missing @@ -266,11 +246,16 @@ function ProductDetailsLoader({ `Render , for each attribute, ` + `or , or ignore this if the control mounts later.`, ); - }, [requiredControls]); - if (product.isPending) return

Loading product…

; + }, [requiredControls, has]); + if (product.isPending) + return ( +
+

Loading product…

+
+ ); if (product.isError && !productNotFound) return ( -
+

Product unavailable

{message(product.error)}

-
, +
); + break; case 'unavailable': - return part( + content = (

{disabledReasons[status]} -

, - textClassName, +

); + break; case 'not-purchasable': - return part(

{disabledReasons[status]}

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

{disabledReasons[status]}

, textClassName); + content =

{disabledReasons[status]}

; + break; case 'incomplete': - return part( + case 'checking': + content = (

{disabledReasons[status]} -

, - textClassName, +

); + break; } + // No default case: a new status leaves `content` unassigned, which TypeScript reports. + return
{content}
; } /** Stock for the verified SKU. Renders nothing until a SKU is verified. */ 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..486abf1db --- /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$|display$|gap$|row-gap$|column-gap$|flex-(direction|wrap)$|justify-content$|align-items$|grid-template-(columns|rows)$|width$|max-width$)/; + +/** 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}`) + : []; + }); +}