From 067a15598dcd362b097149edb51fb59437a972a9 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 16 Oct 2026 10:49:03 -0700 Subject: [PATCH 1/3] Add composable cart parts CartDrawerRoot builds a custom cart drawer and CartRoot a cart page from the same parts: title, description, close, notices, items, empty state, summary, subtotal, checkout note and checkout. CartItems renders its child as the item for every line, built from CartItemRoot and the image, name, options, price, quantity and remove parts. useCart reads the cart anywhere inside the provider and useCartItem reads one line. CartDrawer is now composed from these parts; the dividers it always declared between lines now show. AddToCartButton says why it is disabled, and ProductDetailsQuantity shows the quantity message under the field. Catalog's title, description and showHeader props are deprecated, and catalog loading placeholders render inside the host's grid. Co-Authored-By: Claude Opus 5.5 --- .changeset/add-to-cart-reasons.md | 5 + .changeset/catalog-header-deprecation.md | 5 + .changeset/composable-cart.md | 5 + packages/commerce-storefront/README.md | 112 +++- .../src/cart-parts.test.tsx | 551 +++++++++++++++++ .../commerce-storefront/src/cart-parts.tsx | 564 ++++++++++++++++++ .../commerce-storefront/src/cart-state.ts | 78 +++ packages/commerce-storefront/src/cart.tsx | 308 +--------- packages/commerce-storefront/src/catalog.tsx | 50 +- .../src/commerce-storefront.tsx | 2 +- packages/commerce-storefront/src/index.ts | 33 +- .../src/product-details.test.tsx | 27 + .../src/product-purchase.tsx | 15 +- 13 files changed, 1435 insertions(+), 320 deletions(-) create mode 100644 .changeset/add-to-cart-reasons.md create mode 100644 .changeset/catalog-header-deprecation.md create mode 100644 .changeset/composable-cart.md create mode 100644 packages/commerce-storefront/src/cart-parts.test.tsx create mode 100644 packages/commerce-storefront/src/cart-parts.tsx create mode 100644 packages/commerce-storefront/src/cart-state.ts diff --git a/.changeset/add-to-cart-reasons.md b/.changeset/add-to-cart-reasons.md new file mode 100644 index 00000000..906af49c --- /dev/null +++ b/.changeset/add-to-cart-reasons.md @@ -0,0 +1,5 @@ +--- +"@godaddy/gd-commerce-storefront": minor +--- + +`AddToCartButton` now says why it is disabled through its accessible description: more than the available stock, a quantity that is not a whole number of 1 or more, the cart still loading, the store connecting, or no store connected. `ProductDetailsQuantity` shows the quantity message under the field and marks the field invalid. Default rendering is unchanged for quantities that can be added. diff --git a/.changeset/catalog-header-deprecation.md b/.changeset/catalog-header-deprecation.md new file mode 100644 index 00000000..f7be1d5a --- /dev/null +++ b/.changeset/catalog-header-deprecation.md @@ -0,0 +1,5 @@ +--- +"@godaddy/gd-commerce-storefront": patch +--- + +Deprecate `Catalog`'s `title`, `description` and `showHeader` props. They still work. Compose `CatalogRoot` with `` instead, or leave `CatalogHeader` out when the page renders its own H1. Catalog loading placeholders now render inside the `CatalogProducts` element, so they follow the host's grid. The packaged catalog's placeholders now use its loaded spacing from the `lg` breakpoint, 32px between cards and 40px above and below, instead of 24px and 32px. diff --git a/.changeset/composable-cart.md b/.changeset/composable-cart.md new file mode 100644 index 00000000..a9d7efee --- /dev/null +++ b/.changeset/composable-cart.md @@ -0,0 +1,5 @@ +--- +"@godaddy/gd-commerce-storefront": minor +--- + +Add composable cart parts. `CartDrawerRoot` builds a custom cart drawer and `CartRoot` builds a cart page, both from the same parts: `CartTitle`, `CartDescription`, `CartClose`, `CartNotices`, `CartItems`, `CartEmpty`, `CartSummary`, `CartSubtotal`, `CartCheckoutNote` and `CartCheckout`. `CartItems` renders its child as the item for every line, built from `CartItemRoot` with `CartItemImage`, `CartItemName`, `CartItemOptions`, `CartItemPrice`, `CartItemQuantity` and `CartItemRemove`. `useCart` reads the cart anywhere inside the provider, for example for a custom cart button, and `useCartItem` reads one line. Every action still goes through the shared cart queue, and the sample cart never reaches the store. `CartDrawer` is now composed from these parts. Visual change: the packaged drawer now shows the 1px divider between cart lines that it always declared, which the CSS reset hid. Markup changes: each part has a wrapper `
`, the line list is a `role="list"` element instead of a `
    `, its lines are `role="listitem"` elements, and the drawer panel sits inside a positioning element. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index f92bd142..52a2dcbc 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -78,15 +78,15 @@ A connection failure leaves the surrounding application and its state mounted. C }} > {/* Register /collection and /item/:productId in your router. */} - + ``` The stylesheet includes all required utilities and scopes them to the package's surfaces. The build removes CSS layer wrappers in their declared order, so the exported CSS can pass through a host Tailwind v3 PostCSS pipeline without `@tailwind` directives. Import it directly; consumers do not need to copy or rewrite the CSS. It does not add a global reset or require dependency scanning by a host Tailwind build. The `theme` prop reaches the drawer even though it is portalled into `document.body`. -By default, `.commerce-storefront` wrappers do not paint a background and inherit the host's text color. Set page and section backgrounds on the host application's layout elements. `--commerce-surface` colors controls and the opaque cart drawer, and supplies the base for muted colors; it does not color component wrappers. Set `--commerce-surface` and `--commerce-text` together to adapt controls and the drawer to a dark theme. Avoid setting wrapper backgrounds through CSS or the `theme` prop's `background`, `backgroundColor`, or `backgroundImage` properties, which can still override the transparent default. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors. Internal utility class names and markup are not a customization API; use the [product parts](#custom-product-layouts) to change a product page's layout. +By default, `.commerce-storefront` wrappers do not paint a background and inherit the host's text color. Set page and section backgrounds on the host application's layout elements. `--commerce-surface` colors controls and the opaque cart drawer, and supplies the base for muted colors; it does not color component wrappers. Set `--commerce-surface` and `--commerce-text` together to adapt controls and the drawer to a dark theme. Avoid setting wrapper backgrounds through CSS or the `theme` prop's `background`, `backgroundColor`, or `backgroundImage` properties, which can still override the transparent default. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors. Internal utility class names and markup are not a customization API; use the [product](#custom-product-layouts), [catalog](#custom-catalog-layouts) and [cart](#custom-cart-layouts) parts to change a layout. -The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization is outside this initial API. +The first release uses English UI text and `en-US` currency formatting. The catalog title and description are configurable through `CatalogHeader`. Full localization is outside this initial API. ## Compatibility @@ -99,20 +99,22 @@ The repository example uses React Router 7. Follow each router version's own Rea | Export | Purpose | | --- | --- | | `CommerceStorefront` | Recommended integration: provider and one drawer | -| `Catalog` | Six products per cursor page, with `title`, `description`, and `showHeader` props | +| `Catalog` | Six products per cursor page. Its `title`, `description`, and `showHeader` props are deprecated; compose `CatalogRoot` instead | | `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; `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` | +| `CartDrawer` | The cart dialog; `CommerceStorefront` mounts one | +| `CartDrawerRoot`, `CartRoot` and cart parts | Build a [custom drawer or a cart page](#custom-cart-layouts) with the same rules as `CartDrawer` | +| `CartButton` | Opens the shared drawer and displays item count; `useCart()` builds a custom one | +| `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity`; while disabled, its accessible description says why | | `CommerceStatus` | Connection progress/error/retry for custom layouts | | `useCommerce` | Cart, connection, pending/error state and serialized cart actions | -| `CommerceProvider`, `CartDrawer` | Lower-level composition when the recommended wrapper does not fit; mount each once | +| `CommerceProvider` | Lower-level composition when the recommended wrapper does not fit, such as a custom drawer; mount it once | The package exports TypeScript catalog/cart response types and selection/summary helpers for custom product layouts. `useCommerce` actions return `Promise`: `false` means an operation failed or its connection became stale. Inspect `error` for active-session failures. Do not automatically retry a failed mutation: the server may have committed it before the response failed. `applyDiscount(code)` is available to custom layouts; the default drawer does not render a promotion form. -`Catalog` renders its `title` as an H1 by default. When the host page owns its semantic heading, render that page H1 and pass `showHeader={false}` so the document still has exactly one H1. +`Catalog` renders its title as an H1. When the host page owns its semantic heading, compose `CatalogRoot` without `CatalogHeader` (see [custom catalog layouts](#custom-catalog-layouts)) so the document still has exactly one H1. `Catalog`'s `title`, `description` and `showHeader` props still work but are deprecated: pass the copy to `CatalogHeader`, or leave `CatalogHeader` out. ## Custom product layouts @@ -147,7 +149,7 @@ Add your own elements between parts, reorder them, or leave out display parts. ` | `ProductDetailsOptions` | A select for every attribute, or the SKU list for products without attributes | | `ProductDetailsOption` | A select for one attribute, by `attribute` name; nothing when the product lacks it | | `ProductDetailsPurchaseStatus` | The one message explaining why the selection is not purchasable yet; nothing when ready | -| `ProductDetailsAvailability`, `ProductDetailsQuantity` | Stock and quantity for the verified SKU; nothing before verification | +| `ProductDetailsAvailability`, `ProductDetailsQuantity` | Stock and quantity for the verified SKU; nothing before verification. The quantity shows a message when it cannot be added, such as more than the stock | | `ProductDetailsAddToCart` | Adds the verified SKU and quantity; before a SKU is verified, its accessible description gives the reason | Every part is named `ProductDetails*` because it works only inside `ProductDetailsRoot`. `AddToCartButton` is the standalone button for a SKU you already have. @@ -223,10 +225,100 @@ 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. Packaged cards fill their grid row, with the price and action at the bottom of each card. +`className` on `CatalogProducts` goes on the element whose direct children are the cards, so a host grid places them, and `className` on `ProductCardRoot` goes on each card, so classes such as `col-span-2` work. While products load, that element holds three placeholder cards, so they follow the host's grid. The error with its retry button and the empty message render just before it. Card parts take no product, SKU or price props. Inside `CatalogProducts`, `ProductCardRoot` takes no `product` prop either and throws if it gets one, so a card always shows the product it is rendered for. Samples use the same card: they show a placeholder image, no 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). +## Custom cart layouts + +`CartDrawer` is the recommended cart. `CommerceStorefront` mounts it. When its layout does not fit, use `CommerceProvider` and mount your own `CartDrawerRoot` once in its place. It opens when an item is added or a `CartButton` is pressed and returns focus when it closes, like the packaged drawer. This is `CartDrawer` written out: + +```tsx +import { + CartCheckout, CartCheckoutNote, CartClose, CartDescription, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName, + CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartSubtotal, + CartSummary, CartTitle, CommerceProvider, +} from '@godaddy/gd-commerce-storefront'; + + + {/* Header and routes */} + +
    +
    + + +
    + +
    +
    + + + +
    + +
    + + + +
    +
    +
    + + +
    +
    +
    + +
    + + + + + +
    +
    +``` + +`className` on `CartDrawerRoot` goes on the dialog panel, which sits at the right edge at full height. The packaged width, background and column layout are the classes above; without them the panel is only as wide as its content and has no background. Add `mr-auto` to open it from the left. Keep `CartTitle` in a drawer: it names the dialog, and the dialog library logs an error without it. `CartDescription` is optional and describes the dialog when present. + +For a cart page instead of, or as well as, the drawer, compose the same parts inside `CartRoot` on a route such as `/cart`. It shows the connection state itself, and `CartClose` renders nothing there. If the site has no drawer, use `CommerceProvider` without `CartDrawerRoot` and link to the page instead of rendering `CartButton`. + +```tsx + +
    +

    Your cart

    + + + +
    + + + + + +
    +``` + +| Part | Renders | +| --- | --- | +| `CartTitle`, `CartDescription` | "Your cart" as an H2 and one line about it; sample wording before a store is connected | +| `CartClose` | The drawer's close button; nothing outside the drawer | +| `CartNotices` | A storage warning, and a cart error with a refresh button; nothing when there are neither | +| `CartItems` | The cart lines as a list. Its child is the item, rendered once for each line; without one, the packaged item. "Loading your cart…" renders just before the list while the saved cart loads | +| `CartItemImage`, `CartItemName`, `CartItemOptions`, `CartItemPrice` | The line's image (nothing without one), name as an H3, selected options (nothing without any) and line total | +| `CartItemQuantity`, `CartItemRemove` | Decrease and increase buttons around the quantity, and the remove button | +| `CartEmpty` | Only when the cart is empty: a message and a link to the catalog, or its own children instead | +| `CartSummary` | A container that renders only when the cart has items | +| `CartSubtotal`, `CartCheckoutNote` | The subtotal, and the note that shipping, taxes and discounts come at checkout. The note keeps the `commerce-cart-checkout-adjustments-note` class | +| `CartCheckout` | The checkout button when `checkoutSuccessPath` is set; otherwise a message that checkout is unavailable. Samples never check out | + +`className` on `CartItems` goes on the list element whose direct children are the items, and `className` on `CartItemRoot` goes on each item. The list is a `role='list'` element rather than a `
      `, so it has no browser margin or indent for a package rule to reset. Cart and item parts take no item, quantity or price props, and they throw if they are rendered outside their root. Every cart action goes through the shared provider's queue, whatever the layout, and while no store is connected the parts show the sample cart, which never reaches the store. 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). + +Two hooks support custom cart markup: + +- `useCart()` works anywhere inside `CommerceProvider`. It returns read-only `items`, `itemCount`, `subtotal` (minor units, or `null` when the server has not priced the cart), `currency`, `isSample` and `loading`. Combine `itemCount` with `useCommerce().setOpen(true)` for a custom cart button. +- `useCartItem()` returns the line inside `CartItemRoot`: `id`, `name`, `quantity`, `imageUrl`, `options`, `subtotal` and `currency`. + ## 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/cart-parts.test.tsx b/packages/commerce-storefront/src/cart-parts.test.tsx new file mode 100644 index 00000000..e4f8d5b9 --- /dev/null +++ b/packages/commerce-storefront/src/cart-parts.test.tsx @@ -0,0 +1,551 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Component, type ErrorInfo, type ReactElement, type ReactNode, useState } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { describe, expect, it, vi } from 'vitest'; +import { AddToCartButton, CartButton } from './cart'; +import type { CartOrder } from './cart-model'; +import { + CartCheckout, + CartCheckoutNote, + CartClose, + CartDescription, + CartDrawerRoot, + CartEmpty, + CartItemImage, + CartItemName, + CartItemOptions, + CartItemPrice, + CartItemQuantity, + CartItemRemove, + CartItemRoot, + CartItems, + CartNotices, + CartRoot, + CartSubtotal, + CartSummary, + CartTitle, + useCartItem, +} from './cart-parts'; +import { useCart } from './cart-state'; +import { Catalog, CatalogProducts, CatalogRoot } from './catalog'; +import { CommerceProvider, type CommerceProviderProps, useCommerce } from './commerce-provider'; +import { CommerceStorefront } from './commerce-storefront'; +import { packageRuleConflicts } from './test-css'; + +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const storageKey = 'godaddy:commerce-storefront:cart:s'; + +function order(lines: { id: string; name: string; quantity: number; price: number }[]): CartOrder { + const subtotal = lines.reduce((sum, line) => sum + line.price * line.quantity, 0); + return { + id: 'cart-1', + lineItems: lines.map((line) => ({ + id: line.id, + name: line.name, + quantity: line.quantity, + details: { + productAssetUrl: line.id === 'line-1' ? 'https://cdn.test/mug.png' : null, + selectedOptions: line.id === 'line-2' ? [{ attribute: 'Size', values: ['M'] }] : null, + }, + totals: { subTotal: { value: line.price * line.quantity, currencyCode: 'USD' } }, + })), + totals: { + subTotal: { value: subtotal, currencyCode: 'USD' }, + total: { value: subtotal, currencyCode: 'USD' }, + }, + }; +} + +/** A connected store with a saved cart of two lines that accepts quantity changes and removals. */ +function mockStore({ state = 'ready' }: { state?: string } = {}) { + localStorage.setItem(storageKey, 'cart-1'); + let lines = [ + { id: 'line-1', name: 'Mug', quantity: 2, price: 1200 }, + { id: 'line-2', name: 'Shirt', quantity: 1, price: 2500 }, + ]; + const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), 'https://host.test'); + if (url.pathname.endsWith('/config')) + return response(state === 'ready' ? { cartScope: 's', currencyCode: 'USD' } : { state }); + const line = url.pathname.match(/\/items\/([^/]+)$/)?.[1]; + if (line && init?.method === 'PATCH') { + const { quantity } = JSON.parse(String(init.body)); + lines = lines.map((candidate) => (candidate.id === line ? { ...candidate, quantity } : candidate)); + } + if (line && init?.method === 'DELETE') lines = lines.filter((candidate) => candidate.id !== line); + if (url.pathname.endsWith('/checkout')) return response({ url: 'https://pay.test/session' }); + if (url.pathname.endsWith('/products')) + return response({ skuGroups: { edges: [], pageInfo: { hasNextPage: false } } }); + return response({ cart: order(lines) }); + }); + vi.stubGlobal('fetch', fn); + return fn; +} + +function app(children: ReactNode, props: Partial = {}, path = '/cart'): ReactElement { + return ( + + + {children} + + + ); +} + +function OpenCart(): ReactElement { + const { setOpen } = useCommerce(); + return ( + + ); +} + +function LineBadge(): ReactElement { + const { name, quantity } = useCartItem(); + return {`${name}×${quantity}`}; +} + +describe('composable cart', () => { + it('builds a custom drawer from parts, edits lines, and checks out', async () => { + const api = mockStore(); + const assign = vi.fn(); + vi.stubGlobal('location', { ...window.location, origin: 'https://shop.test', assign }); + render( + app( + <> + + + + + + + + + + + + + + + + + + + , + { checkoutSuccessPath: '/thanks' }, + ), + ); + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + const dialog = await screen.findByRole('dialog', { name: 'Your cart' }); + expect(dialog).toHaveClass('host-panel'); + // Without a CartDescription the dialog names no description, so Radix has nothing to warn about. + expect(dialog).not.toHaveAttribute('aria-describedby'); + await waitFor(() => expect(within(dialog).getAllByTestId('line')).toHaveLength(2)); + const lines = within(dialog).getAllByRole('listitem'); + expect(lines[0]).toHaveClass('host-line'); + expect(lines[0].parentElement).toHaveClass('host-list'); + expect( + within(dialog) + .getAllByTestId('line') + .map((line) => line.textContent), + ).toEqual(['Mug×2', 'Shirt×1']); + expect(within(dialog).getByText('$49.00')).toBeVisible(); + + await userEvent.click(within(dialog).getByRole('button', { name: 'Increase quantity of Mug' })); + await waitFor(() => expect(within(dialog).getByText('Mug×3')).toBeInTheDocument()); + const patch = api.mock.calls.find((call) => call[1]?.method === 'PATCH'); + expect(String(patch?.[0])).toBe('/api/commerce/cart/cart-1/items/line-1'); + expect(JSON.parse(String(patch?.[1]?.body))).toEqual({ quantity: 3 }); + + await userEvent.click(within(dialog).getByRole('button', { name: 'Remove Shirt' })); + await waitFor(() => expect(within(dialog).getAllByTestId('line')).toHaveLength(1)); + expect(within(dialog).getByRole('button', { name: 'Decrease quantity of Mug' })).toBeEnabled(); + + await userEvent.click(within(dialog).getByRole('button', { name: 'Proceed to Checkout' })); + await waitFor(() => expect(assign).toHaveBeenCalledWith('https://pay.test/session')); + await userEvent.click(within(dialog).getByRole('button', { name: 'Close cart' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('links the description only while one is rendered', async () => { + mockStore(); + const warn = vi.spyOn(console, 'warn'); + render( + app( + <> + + + + + + + , + ), + ); + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + const dialog = await screen.findByRole('dialog', { name: 'Your cart' }); + await waitFor(() => expect(dialog).toHaveAccessibleDescription('Review your items before checkout.')); + expect(warn).not.toHaveBeenCalled(); + }); + + it('shows the cart on a page, with a custom empty state and no close button', async () => { + mockStore(); + render( + app( + + +

      Cart

      + + + + +

      Nothing here yet.

      +
      + + + + + + + } + /> +
      , + ), + ); + const title = await screen.findByRole('heading', { level: 2, name: 'Your cart' }); + expect(title.closest('section')).toHaveClass('host-page'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close cart' })).not.toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 3, name: 'Mug' })).toBeVisible(); + // The packaged item shows the image and options. + expect(document.querySelector('img[src="https://cdn.test/mug.png"]')).not.toBeNull(); + expect(screen.getByText('Size: M')).toBeVisible(); + expect(screen.getByText('Checkout is not available yet.')).toBeVisible(); + for (const name of ['Remove Mug', 'Remove Shirt']) { + await userEvent.click(screen.getByRole('button', { name })); + await waitFor(() => expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()); + } + expect(screen.getByText('Nothing here yet.')).toBeVisible(); + expect(screen.queryByText('Subtotal')).not.toBeInTheDocument(); + expect( + screen.queryByText('Shipping, taxes, and discounts are calculated at checkout.'), + ).not.toBeInTheDocument(); + }); + + it('shows the sample cart on a page and never sends it to the store', async () => { + const api = mockStore({ state: 'unbound' }); + render( + app( + <> + + + + + + + + + + + + + + , + ), + ); + const title = await screen.findByRole('heading', { level: 2, name: 'Sample cart' }); + const page = title.closest('section') as HTMLElement; + expect(page).toHaveAttribute('data-commerce-source', 'sample'); + expect(within(page).getByRole('link', { name: 'Browse products' })).toHaveAttribute('href', '/shop'); + await userEvent.click(screen.getByRole('button', { name: 'Add Product 2 to sample cart' })); + expect(await within(page).findByRole('heading', { level: 3, name: 'Product 2' })).toBeVisible(); + expect(within(page).getByText('Sample subtotal')).toBeVisible(); + expect(within(page).getByText('Example total in USD. Checkout is not set up yet.')).toBeVisible(); + expect(within(page).queryByText(/calculated at checkout/)).not.toBeInTheDocument(); + await userEvent.click(within(page).getByRole('button', { name: 'Increase quantity of Product 2' })); + // The line and the subtotal. + expect(within(page).getAllByText('$40.00')).toHaveLength(2); + expect(api.mock.calls.every(([input]) => String(input).endsWith('/config'))).toBe(true); + }); + + it('keeps host classes on the connection status', () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})), + ); + render( + app( + + + , + ), + ); + expect(screen.getByText('Connecting to the store…').closest('.host-page')).not.toBeNull(); + }); + + it('shows a storage warning and a refreshable error together', async () => { + mockStore(); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + let fail = true; + const api = vi.mocked(fetch); + const store = api.getMockImplementation(); + api.mockImplementation(async (input, init) => { + if (String(input).endsWith('/cart/cart-1') && fail) return response({ error: 'Cart offline' }, 503); + return store?.(input, init) as Promise; + }); + render( + app( + + + + , + ), + ); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Cart offline'); + expect(alert.closest('.host-notices')).not.toBeNull(); + fail = false; + await userEvent.click(within(alert).getByRole('button', { name: 'Refresh cart' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect( + await screen.findByText('Your browser cannot save the cart. Keep this page open while shopping.'), + ).toBeVisible(); + }); + + it('counts the cart for a custom cart button', async () => { + mockStore(); + function CustomButton(): ReactElement { + const { itemCount, loading } = useCart(); + return {loading ? 'loading' : itemCount}; + } + render( + app( + <> + + + , + ), + ); + await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('3')); + expect(screen.getByRole('button', { name: 'Cart 3' })).toBeEnabled(); + }); + + it('names the missing root when parts are misplaced', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockStore(); + expect(() => render(app())).toThrow( + 'CartItems must be rendered inside or .', + ); + expect(() => render(app())).toThrow( + 'CartItemName must be rendered inside .', + ); + function Orphan(): null { + useCartItem(); + return null; + } + expect(() => render(app())).toThrow('useCartItem must be rendered inside .'); + }); + + it('names CartItems when an item is rendered outside it', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockStore(); + const errors: unknown[] = []; + render( + app( + + + + + + + , + ), + ); + await waitFor(() => expect(errors).toHaveLength(1)); + expect(String(errors[0])).toContain('CartItemRoot must be rendered inside .'); + }); + + it('puts host classes on an element that no package rule styles', async () => { + mockStore(); + const conflicts = packageRuleConflicts(); + render( + app( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + , + ), + ); + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await screen.findByRole('heading', { level: 3, name: 'Shirt' }); + // Drawer, title, description, close, list, summary, subtotal, note, checkout, page root; per line: the root, + // name, price, quantity and remove, plus the mug's image and the shirt's options. + expect(document.querySelectorAll('.host')).toHaveLength(10 + 2 * 5 + 2); + 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: [] }); + } + }); + + it('renders the packaged drawer from CommerceStorefront with dividers between lines', async () => { + mockStore(); + render( + + + + + + + , + ); + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + const dialog = await screen.findByRole('dialog', { name: 'Your cart' }); + expect(dialog).toHaveAccessibleDescription('Review your items before checkout.'); + await within(dialog).findByRole('heading', { level: 3, name: 'Shirt' }); + // The reset skips part elements, so the list's divider reaches each line. + expect(getComputedStyle(within(dialog).getAllByRole('listitem')[0]).borderBottomWidth).not.toBe('0px'); + }); +}); + +describe('add to cart reasons', () => { + const mug = (quantity: number) => ({ + id: 'sku-mug', + inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity } }] }, + }); + + it('says why the button is disabled', async () => { + mockStore(); + function Harness(): ReactElement { + const [quantity, setQuantity] = useState(5); + const [stock, setStock] = useState(3); + return ( + <> + + + + + + ); + } + render(app()); + const button = screen.getByRole('button', { name: 'Add to cart' }); + expect(button).toHaveAccessibleDescription('Connecting to the store…'); + await waitFor(() => expect(button).toHaveAccessibleDescription('Only 3 available.')); + expect(button).toBeDisabled(); + await userEvent.click(screen.getByRole('button', { name: 'Fraction' })); + expect(button).toHaveAccessibleDescription('Enter a whole number of 1 or more.'); + await userEvent.click(screen.getByRole('button', { name: 'Three' })); + expect(button).toBeEnabled(); + expect(button).not.toHaveAttribute('aria-describedby'); + await userEvent.click(screen.getByRole('button', { name: 'Sell out' })); + expect(screen.getByRole('button', { name: 'Out of stock' })).not.toHaveAttribute('aria-describedby'); + }); + + it('says a store must be connected before adding', async () => { + mockStore({ state: 'unbound' }); + render(app()); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Add to cart' })).toHaveAccessibleDescription( + 'Connect a store to add items to the cart.', + ), + ); + }); +}); + +describe('catalog loading placeholders', () => { + it('render inside the host grid', async () => { + let finish!: () => void; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/config')) return response({ cartScope: 's', currencyCode: 'USD' }); + if (String(input).includes('/products')) { + await new Promise((done) => { + finish = done; + }); + return response({ skuGroups: { edges: [], pageInfo: { hasNextPage: false } } }); + } + return response({ cart: null }); + }), + ); + render( + app( + + + , + ), + ); + await waitFor(() => + expect(document.querySelectorAll('.host-grid > [aria-hidden="true"]')).toHaveLength(3), + ); + await act(async () => finish()); + expect(await screen.findByText('No products available.')).toBeVisible(); + expect(document.querySelector('.host-grid')?.children).toHaveLength(0); + }); + + it('still render for the packaged catalog', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => + String(input).endsWith('/config') + ? response({ cartScope: 's', currencyCode: 'USD' }) + : new Promise(() => {}), + ), + ); + render(app()); + await waitFor(() => + expect(document.querySelectorAll('[aria-hidden="true"].motion-safe\\:animate-pulse')).toHaveLength(3), + ); + }); +}); + +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/cart-parts.tsx b/packages/commerce-storefront/src/cart-parts.tsx new file mode 100644 index 00000000..0852a025 --- /dev/null +++ b/packages/commerce-storefront/src/cart-parts.tsx @@ -0,0 +1,564 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { + Children, + createContext, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { Link } from 'react-router'; +import { money } from './api'; +import { buttonClass } from './cart'; +import { type CartItemState, type CartState, useCart } from './cart-state'; +import { useCommerce } from './commerce-provider'; +import { type PartRegistry, partClass, usePartRegistry } from './part'; +import { useSampleCart } from './sample-cart'; +import { CommerceStatus, StorefrontSurface } from './storefront-surface'; + +/** Props shared by every cart part. `className` is added to the part's outer element. */ +export interface CartPartProps { + className?: string; +} + +interface CartDrawerInternal { + register: PartRegistry['register']; +} + +interface CartInternal extends CartState { + hydrating: boolean; + error: string | null; + storageWarning: string | null; + catalogPath: string; + checkoutEnabled: boolean; + /** Whether the server has totalled the cart, which checkout needs. */ + checkoutReady: boolean; + /** The action in progress from this cart, such as `remove:`. */ + action: string | null; + /** Any cart action, from here or anywhere else, is running. */ + locked: boolean; + run: (action: string, operation: () => Promise) => void; + changeQuantity: (itemId: string, quantity: number) => Promise; + removeItem: (itemId: string) => Promise; + refresh: () => Promise; + checkout: () => Promise; + close: () => void; +} + +const CartContext = createContext(null); +const CartDrawerContext = createContext(null); + +function useCartInternal(part: string): CartInternal { + const context = useContext(CartContext); + if (!context) throw new Error(`${part} must be rendered inside or .`); + return context; +} + +function CartProvider({ children }: { children: ReactNode }): ReactElement { + const commerce = useCommerce(); + const sample = useSampleCart(); + const state = useCart(); + const [action, setAction] = useState(null); + const locked: boolean = commerce.pending || commerce.hydrating || action !== null; + const value: CartInternal = { + ...state, + hydrating: commerce.hydrating, + error: commerce.error, + storageWarning: commerce.storageWarning, + catalogPath: commerce.config.catalogPath, + checkoutEnabled: !!commerce.config.checkoutSuccessPath, + checkoutReady: commerce.cart?.totals?.total?.value != null, + action, + locked, + run: (next, operation) => { + if (locked) return; + setAction(next); + void operation().finally(() => setAction(null)); + }, + changeQuantity: state.isSample ? sample.changeQuantity : commerce.changeQuantity, + removeItem: state.isSample ? sample.remove : commerce.removeItem, + refresh: commerce.refresh, + checkout: commerce.checkout, + close: () => commerce.setOpen(false), + }; + return {children}; +} + +export interface CartRootProps { + children: ReactNode; + /** Added to the root `
      `. */ + className?: string; +} + +/** + * Provides the cart to cart parts on a page, such as a `/cart` route. While no store is connected it shows the sample + * cart. Use `CartDrawerRoot` for the drawer. + */ +export function CartRoot({ children, className }: CartRootProps): ReactElement { + const { connection } = useCommerce(); + const isSample: boolean = connection === 'unbound'; + return ( + + {connection === 'ready' || isSample ? ( + +
      + {children} +
      +
      + ) : ( + // Host classes stay on while connecting, so the layout does not jump when the cart replaces the status. +
      + +
      + )} +
      + ); +} + +export interface CartDrawerRootProps { + children: ReactNode; + /** Added to the drawer panel. The packaged drawer passes its width, background and column layout here. */ + className?: string; +} + +/** + * The cart dialog. Opens when an item is added or a `CartButton` is pressed, and returns focus when it closes. + * The panel sits at the right edge at full height; `mr-auto` on it moves it to the left. Mount one per provider, + * in place of `CartDrawer`. + */ +export function CartDrawerRoot({ children, className }: CartDrawerRootProps): ReactElement { + const { open, setOpen, restoreFocus, connection } = useCommerce(); + // Radix links its description by default. Radix 1.1 also does so with none mounted, then warns about the missing + // element, so `aria-describedby={undefined}` turns the link off until a `CartDescription` mounts. Without the + // override, each Radix version links its own description ID. + const [described, setDescribed] = useState(false); + const { register, has } = usePartRegistry( + useCallback((registered: PartRegistry['has']) => setDescribed(registered('description')), []), + ); + useEffect(() => { + setDescribed(has('description')); + }, [has]); + const drawer = useMemo((): CartDrawerInternal => ({ register }), [register]); + return ( + + + + + {/* Positions the panel, so the panel itself carries no package layout that host classes would lose to. */} +
      + { + event.preventDefault(); + restoreFocus(); + }} + > + + {children} + + +
      +
      +
      +
      + ); +} + +/** "Your cart", or "Sample cart" before a store is connected, as an H2. In the drawer it names the dialog. */ +export function CartTitle({ className }: CartPartProps): ReactElement { + const { isSample } = useCartInternal('CartTitle'); + const Heading = useContext(CartDrawerContext) ? Dialog.Title : 'h2'; + return ( +
      + {isSample ? 'Sample cart' : 'Your cart'} +
      + ); +} + +/** One line about the cart. In the drawer it describes the dialog. */ +export function CartDescription({ className }: CartPartProps): ReactElement { + const { isSample } = useCartInternal('CartDescription'); + const drawer = useContext(CartDrawerContext); + const register = drawer?.register; + useEffect(() => register?.('description'), [register]); + const text: string = isSample + ? 'Example products and prices in USD. Nothing can be purchased.' + : 'Review your items before checkout.'; + return ( +
      + {drawer ? ( + {text} + ) : ( +

      {text}

      + )} +
      + ); +} + +/** The drawer's close button. Renders nothing outside the drawer. */ +export function CartClose({ className }: CartPartProps): ReactElement | null { + useCartInternal('CartClose'); + if (!useContext(CartDrawerContext)) return null; + return ( +
      + + ✕ + +
      + ); +} + +/** A storage warning, and a cart error with a refresh button. Renders nothing when there are neither. */ +export function CartNotices({ className }: CartPartProps): ReactElement | null { + const { storageWarning, error, action, locked, run, refresh } = useCartInternal('CartNotices'); + if (!storageWarning && !error) return null; + // The gap is on an inner element, so host layout classes on the part still apply. (`space-y-*` would lose to the + // reset's paragraph margin.) + return ( +
      +
      + {storageWarning && ( +

      + {storageWarning} +

      + )} + {error && ( +
      +

      {error}

      + +
      + )} +
      +
      + ); +} + +const CartItemsContext = createContext(null); +const CartItemContext = createContext(null); + +function useCartItemInternal(part: string): CartItemState { + const item = useContext(CartItemContext); + if (!item) throw new Error(`${part} must be rendered inside .`); + return item; +} + +/** Read the line from inside `CartItemRoot`, for custom item parts. */ +export function useCartItem(): CartItemState { + return useCartItemInternal('useCartItem'); +} + +export interface CartItemsProps extends CartPartProps { + /** One item, rendered for every cart line. Defaults to the packaged item. */ + children?: ReactNode; +} + +/** + * The cart lines as a list. `className` goes on the list element whose children are the items. While the saved cart + * loads, a status message renders just before it. + */ +export function CartItems({ className, children }: CartItemsProps): ReactElement { + const { items, hydrating } = useCartInternal('CartItems'); + // `{custom && …}` with `custom` false still gets an item, not an empty slot per line. + const template: ReactNode = Children.toArray(children).some((child) => child !== '') + ? children + : packagedItem; + return ( + <> + {hydrating &&

      Loading your cart…

      } + {/* A `
        ` needs a package margin and padding reset, which would beat host classes here. */} +
        + {items.map((item, index) => ( + + {template} + + ))} +
        + + ); +} + +export interface CartItemRootProps { + children: ReactNode; + /** Added to the item's list item element. */ + className?: string; +} + +/** One cart line. Render it as the child of `CartItems`, which renders it once for every line. */ +export function CartItemRoot({ children, className }: CartItemRootProps): ReactElement { + const item = useContext(CartItemsContext); + if (!item) throw new Error('CartItemRoot must be rendered inside .'); + return ( + +
        + {/* An item nested inside this one is not a cart line. */} + {children} +
        +
        + ); +} + +/** The line's product image. Renders nothing without one. */ +export function CartItemImage({ className }: CartPartProps): ReactElement | null { + const { imageUrl } = useCartItemInternal('CartItemImage'); + if (!imageUrl) return null; + return ( +
        + +
        + ); +} + +/** The product name as an H3. */ +export function CartItemName({ className }: CartPartProps): ReactElement { + const { name } = useCartItemInternal('CartItemName'); + return ( +
        +

        {name}

        +
        + ); +} + +/** The selected options, one per line, such as "Color: Blue". Renders nothing without options. */ +export function CartItemOptions({ className }: CartPartProps): ReactElement | null { + const { options } = useCartItemInternal('CartItemOptions'); + if (!options.length) return null; + return ( +
        + {options.map((option) => ( +

        + {option.attribute}: {option.values.join(', ')} +

        + ))} +
        + ); +} + +/** The line total. */ +export function CartItemPrice({ className }: CartPartProps): ReactElement { + const { subtotal, currency } = useCartItemInternal('CartItemPrice'); + return ( +
        +

        {subtotal === null ? 'Price unavailable' : money(subtotal, currency)}

        +
        + ); +} + +const stepperButtonClass: string = + 'min-h-11 min-w-11 rounded-lg bg-white text-neutral-900 focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-600'; + +/** Decrease and increase buttons around the line's quantity. */ +export function CartItemQuantity({ className }: CartPartProps): ReactElement { + const { id, name, quantity } = useCartItemInternal('CartItemQuantity'); + const { action, locked, run, changeQuantity } = useCartInternal('CartItemQuantity'); + return ( +
        +
        + + + Quantity: + {quantity} + + +
        +
        + ); +} + +/** Removes the line. */ +export function CartItemRemove({ className }: CartPartProps): ReactElement { + const { id, name } = useCartItemInternal('CartItemRemove'); + const { action, locked, run, removeItem } = useCartInternal('CartItemRemove'); + return ( +
        + +
        + ); +} + +const packagedItem: ReactElement = ( + +
        + +
        + + + +
        +
        +
        + + +
        +
        +); + +export interface CartEmptyProps extends CartPartProps { + /** Replaces the default message and link. */ + children?: ReactNode; +} + +/** Renders only when the cart is empty: a message and a link to the catalog, or its children. */ +export function CartEmpty({ className, children }: CartEmptyProps): ReactElement | null { + const { items, hydrating, isSample, catalogPath, close } = useCartInternal('CartEmpty'); + if (hydrating || items.length) return null; + return ( +
        + {children ?? ( + <> +

        Your cart is empty

        +

        + {isSample ? 'Add a sample product to try the cart.' : 'Find something you love in the shop.'} +

        + + Browse products + + + )} +
        + ); +} + +export interface CartSummaryProps extends CartPartProps { + children: ReactNode; +} + +/** A container for the subtotal and checkout. Renders only when the cart has items. */ +export function CartSummary({ className, children }: CartSummaryProps): ReactElement | null { + const { items } = useCartInternal('CartSummary'); + if (!items.length) return null; + return
        {children}
        ; +} + +/** The subtotal, labeled "Sample subtotal" for samples. Renders nothing until the server prices the cart. */ +export function CartSubtotal({ className }: CartPartProps): ReactElement | null { + const { items, subtotal, currency, isSample } = useCartInternal('CartSubtotal'); + if (!items.length || subtotal === null) return null; + return ( +
        +
        +
        +
        {isSample ? 'Sample subtotal' : 'Subtotal'}
        +
        {money(subtotal, currency)}
        +
        +
        +
        + ); +} + +/** + * "Shipping, taxes, and discounts are calculated at checkout." Has the stable + * `commerce-cart-checkout-adjustments-note` class. Renders nothing for samples or an empty cart. + */ +export function CartCheckoutNote({ className }: CartPartProps): ReactElement | null { + const { items, isSample } = useCartInternal('CartCheckoutNote'); + if (isSample || !items.length) return null; + // The stable class is on the outer element too, so hiding it also removes the host's spacing. + return ( +
        +

        + Shipping, taxes, and discounts are calculated at checkout. +

        +
        + ); +} + +/** + * The checkout button when `checkoutSuccessPath` is set; otherwise a message that checkout is unavailable. + * Samples never check out. Renders nothing for an empty cart. + */ +export function CartCheckout({ className }: CartPartProps): ReactElement | null { + const { items, isSample, checkoutEnabled, checkoutReady, hydrating, action, locked, run, checkout } = + useCartInternal('CartCheckout'); + if (!items.length) return null; + let content: ReactElement; + if (isSample) + content =

        Example total in USD. Checkout is not set up yet.

        ; + else if (!checkoutEnabled) + content =

        Checkout is not available yet.

        ; + else + content = ( + + ); + return
        {content}
        ; +} + +/** The packaged cart drawer. Compose `CartDrawerRoot` and the cart parts for a custom drawer. */ +export function CartDrawer(): ReactElement { + return ( + +
        +
        + + +
        + +
        +
        + + + +
        + + + + + +
        + ); +} diff --git a/packages/commerce-storefront/src/cart-state.ts b/packages/commerce-storefront/src/cart-state.ts new file mode 100644 index 00000000..b60bd782 --- /dev/null +++ b/packages/commerce-storefront/src/cart-state.ts @@ -0,0 +1,78 @@ +import { useMemo } from 'react'; +import { useCommerce } from './commerce-provider'; +import { useSampleCart } from './sample-cart'; + +/** One cart line, for custom cart parts. */ +export interface CartItemState { + /** Empty when the server returned a line without an ID. Such a line cannot be changed or removed. */ + readonly id: string; + readonly name: string; + readonly quantity: number; + readonly imageUrl: string | null; + readonly options: readonly { readonly attribute: string; readonly values: readonly string[] }[]; + /** The line total in minor units, or `null` when the server did not price it. */ + readonly subtotal: number | null; + readonly currency: string; +} + +/** Read-only cart contents: the connected store's cart, or the sample cart while no store is connected. */ +export interface CartState { + readonly items: readonly CartItemState[]; + readonly itemCount: number; + /** The cart subtotal in minor units, or `null` when the server did not price it. */ + readonly subtotal: number | null; + readonly currency: string; + readonly isSample: boolean; + /** `true` while the saved cart loads. */ + readonly loading: boolean; +} + +/** Read the cart from anywhere inside `CommerceProvider`, for example to build a custom cart button. */ +export function useCart(): CartState { + const { cart, config, connection, hydrating } = useCommerce(); + const sample = useSampleCart(); + const isSample: boolean = connection === 'unbound'; + const sampleItems = sample.items; + return useMemo((): CartState => { + // Shared presentation only: sample entries never become draft orders or live mutation inputs. + if (isSample) + return { + items: sampleItems.map((item) => ({ + id: item.key, + name: item.name, + quantity: item.quantity, + imageUrl: null, + options: [], + subtotal: item.price * item.quantity, + currency: 'USD', + })), + itemCount: sampleItems.reduce((total, item) => total + item.quantity, 0), + subtotal: sampleItems.reduce((sum, item) => sum + item.price * item.quantity, 0), + currency: 'USD', + isSample, + loading: false, + }; + const currency: string = cart?.totals?.total?.currencyCode ?? config.currencyCode; + const lines = cart?.lineItems ?? []; + const subtotal = cart?.totals?.subTotal?.value; + return { + items: lines.map((item) => ({ + id: item.id ?? '', + name: item.name ?? '', + quantity: item.quantity ?? 0, + imageUrl: item.details?.productAssetUrl || null, + options: (item.details?.selectedOptions ?? []).map((option) => ({ + attribute: option.attribute ?? '', + values: option.values ?? [], + })), + subtotal: typeof item.totals?.subTotal?.value === 'number' ? item.totals.subTotal.value : null, + currency: item.totals?.subTotal?.currencyCode ?? currency, + })), + itemCount: lines.reduce((total, item) => total + (item.quantity ?? 0), 0), + subtotal: typeof subtotal === 'number' ? subtotal : null, + currency: cart?.totals?.subTotal?.currencyCode ?? currency, + isSample, + loading: hydrating, + }; + }, [cart, config.currencyCode, hydrating, isSample, sampleItems]); +} diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index 871ce681..683fd25e 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -1,12 +1,8 @@ -import * as Dialog from '@radix-ui/react-dialog'; -import { type ReactElement, useState } from 'react'; -import { Link } from 'react-router'; -import { money } from './api'; -import { type CartSummaryTotals, getCartSummaryTotals } from './cart-model'; +import { type ReactElement, useId, useState } from 'react'; +import { useCart } from './cart-state'; import type { SKU } from './catalog-model'; import { getAvailableInventoryQuantity } from './catalog-model'; import { useCommerce } from './commerce-provider'; -import { useSampleCart } from './sample-cart'; import { StorefrontSurface } from './storefront-surface'; export const buttonClass: string = @@ -15,12 +11,8 @@ export const inputClass: string = 'min-h-11 rounded-lg border border-neutral-400 bg-white px-3 py-2 text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900'; export function CartButton(): ReactElement { - const { cart, setOpen, open, connection } = useCommerce(); - const sample = useSampleCart(); - const count: number = - connection === 'unbound' - ? sample.items.reduce((total, item) => total + item.quantity, 0) - : (cart?.lineItems?.reduce((total, item) => total + (item.quantity ?? 0), 0) ?? 0); + const { setOpen, open, connection } = useCommerce(); + const { itemCount } = useCart(); return ( ); } +/** Why `quantity` cannot be added, or `null` when it can. `available` is `null` when stock is not tracked. */ +export function quantityProblem(quantity: number, available: number | null): string | null { + if (!Number.isInteger(quantity) || quantity < 1) return 'Enter a whole number of 1 or more.'; + if (available !== null && available > 0 && quantity > available) return `Only ${available} available.`; + return null; +} + export function AddToCartButton({ sku, name, @@ -48,16 +47,15 @@ export function AddToCartButton({ }): ReactElement { const { addItem, pending, hydrating, error, open, connection } = useCommerce(); const [adding, setAdding] = useState(false); + const reasonId = useId(); const available: number | null = getAvailableInventoryQuantity(sku); - const disabled: boolean = - connection !== 'ready' || - !sku.id || - adding || - hydrating || - available === 0 || - !Number.isInteger(quantity) || - quantity < 1 || - (available !== null && quantity > available); + // Sold out needs no description: the button says so. + let reason: string | null = null; + if (connection === 'unbound') reason = 'Connect a store to add items to the cart.'; + else if (connection !== 'ready') reason = 'Connecting to the store…'; + else if (!sku.id) reason = 'This item cannot be added to the cart.'; + else if (available !== 0) reason = hydrating ? 'Loading your cart…' : quantityProblem(quantity, available); + const disabled: boolean = adding || available === 0 || reason !== null; async function handleAdd(): Promise { if (disabled || pending || !sku.id) return; setAdding(true); @@ -75,10 +73,16 @@ export function AddToCartButton({ disabled={disabled} aria-disabled={disabled || pending} aria-busy={adding} + aria-describedby={reason ? reasonId : undefined} onClick={() => void handleAdd()} > {available === 0 ? 'Out of stock' : adding ? 'Adding…' : 'Add to cart'} + {reason && ( + + )} {error && !open && (

        {error} @@ -87,263 +91,3 @@ export function AddToCartButton({ ); } - -export function CartDrawer(): ReactElement { - const { - config, - connection, - cart, - open, - setOpen, - restoreFocus, - pending, - hydrating, - error, - storageWarning, - changeQuantity: changeLiveQuantity, - removeItem: removeLiveItem, - refresh, - checkout, - } = useCommerce(); - const sample = useSampleCart(); - const isSample = connection === 'unbound'; - const changeQuantity = isSample ? sample.changeQuantity : changeLiveQuantity; - const removeItem = isSample ? sample.remove : removeLiveItem; - const [drawerAction, setDrawerAction] = useState(null); - const checkingOut: boolean = drawerAction === 'checkout'; - const locked: boolean = pending || hydrating || drawerAction !== null; - // Shared presentation only: sample entries never become draft orders or live mutation inputs. - const items = isSample - ? sample.items.map((item) => ({ - id: item.key, - name: item.name, - quantity: item.quantity, - image: undefined, - options: undefined, - subtotal: item.price * item.quantity, - currency: 'USD', - })) - : (cart?.lineItems ?? []).map((item) => ({ - id: item.id, - name: item.name, - quantity: item.quantity, - image: item.details?.productAssetUrl, - options: item.details?.selectedOptions, - subtotal: item.totals?.subTotal?.value, - currency: item.totals?.subTotal?.currencyCode, - })); - const summary: CartSummaryTotals = getCartSummaryTotals(cart); - const currency: string = isSample ? 'USD' : (cart?.totals?.total?.currencyCode ?? config.currencyCode); - const subtotal = isSample - ? sample.items.reduce((sum, item) => sum + item.price * item.quantity, 0) - : summary.subtotal; - async function handleAction(action: string, operation: () => Promise): Promise { - if (locked) return; - setDrawerAction(action); - try { - await operation(); - } finally { - setDrawerAction(null); - } - } - return ( - - - - - { - event.preventDefault(); - restoreFocus(); - }} - > -

        -
        - - {isSample ? 'Sample cart' : 'Your cart'} - - - {isSample - ? 'Example products and prices in USD. Nothing can be purchased.' - : 'Review your items before checkout.'} - -
        - - ✕ - -
        -
        - {storageWarning && ( -

        - {storageWarning} -

        - )} - {error && ( -
        -

        {error}

        - -
        - )} - {hydrating &&

        Loading your cart…

        } - {!hydrating && !items.length && ( -
        -

        Your cart is empty

        -

        - {isSample - ? 'Add a sample product to try the cart.' - : 'Find something you love in the shop.'} -

        - setOpen(false)} - > - Browse products - -
        - )} -
          - {items.map((item) => ( -
        • -
          - {item.image && ( - - )} -
          -

          {item.name}

          - {item.options?.map((option) => ( -

          - {option.attribute}: {option.values?.join(', ')} -

          - ))} -

          - {typeof item.subtotal === 'number' - ? money(item.subtotal, item.currency ?? currency) - : 'Price unavailable'} -

          -
          -
          -
          -
          - - - Quantity: - {item.quantity} - - -
          - -
          -
        • - ))} -
        -
        - {items.length > 0 && ( -
        -
        - {(isSample || typeof cart?.totals?.subTotal?.value === 'number') && ( -
        -
        {isSample ? 'Sample subtotal' : 'Subtotal'}
        -
        - {money( - subtotal, - isSample ? 'USD' : (cart?.totals?.subTotal?.currencyCode ?? currency), - )} -
        -
        - )} -
        - {!isSample && ( -

        - Shipping, taxes, and discounts are calculated at checkout. -

        - )} - {isSample ? ( -

        - Example total in USD. Checkout is not set up yet. -

        - ) : config.checkoutSuccessPath ? ( - - ) : ( -

        Checkout is not available yet.

        - )} -
        - )} - - - - - ); -} diff --git a/packages/commerce-storefront/src/catalog.tsx b/packages/commerce-storefront/src/catalog.tsx index b8a2e744..f9bcd9d5 100644 --- a/packages/commerce-storefront/src/catalog.tsx +++ b/packages/commerce-storefront/src/catalog.tsx @@ -176,8 +176,8 @@ export interface CatalogProductsProps extends CatalogPartProps { } /** - * 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. + * The product cards. `className` goes on the element that contains the cards, such as a grid. While loading, that + * element holds placeholder cards. The error with its retry and the empty message render just before it. */ export function CatalogProducts({ className, children }: CatalogProductsProps): ReactElement { const catalog = useCatalogInternal('CatalogProducts'); @@ -194,23 +194,6 @@ export function CatalogProducts({ className, children }: CatalogProductsProps): const products = catalog.source === 'live' ? catalog.products : null; return ( <> - {products?.isPending && ( -