From 7a9a76d8bf94b06cfa245b5d0a0e89f6ce237a71 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Tue, 13 Oct 2026 15:11:50 -0700 Subject: [PATCH 1/3] Add composable product details parts ProductDetails is now composed from ProductDetailsRoot and flat-exported display and purchase parts, plus useProductDetails and useProductOption hooks. Parts read state from the root, so any layout can only add a server-verified SKU. ProductDetails output is unchanged. Co-Authored-By: Claude Opus 5.5 --- .changeset/composable-product-details.md | 5 + packages/commerce-storefront/README.md | 62 ++- packages/commerce-storefront/src/index.ts | 30 +- .../src/product-details-context.tsx | 318 ++++++++++++++ .../src/product-details.test.tsx | 202 +++++++++ .../src/product-details.tsx | 387 +++++------------- .../src/product-purchase.tsx | 205 ++++++++++ 7 files changed, 924 insertions(+), 285 deletions(-) create mode 100644 .changeset/composable-product-details.md create mode 100644 packages/commerce-storefront/src/product-details-context.tsx create mode 100644 packages/commerce-storefront/src/product-details.test.tsx create mode 100644 packages/commerce-storefront/src/product-purchase.tsx diff --git a/.changeset/composable-product-details.md b/.changeset/composable-product-details.md new file mode 100644 index 00000000..5e8ba652 --- /dev/null +++ b/.changeset/composable-product-details.md @@ -0,0 +1,5 @@ +--- +'@godaddy/gd-commerce-storefront': minor +--- + +Add composable product parts. `ProductDetailsRoot` with display parts (`ProductDetailsGallery`, `ProductDetailsTitle`, `ProductDetailsPrice`, `ProductDetailsDescription`, `ProductDetailsBackLink`) and purchase parts (`ProductPurchase`, `ProductOptions`, `ProductOption`, `ProductPurchaseStatus`, `ProductAvailability`, `ProductQuantity`, `ProductAddToCart`) build custom product layouts. `useProductDetails` reads product state and `useProductOption` builds custom option controls. Only server-verified SKUs can be added in any layout. `ProductDetails` is unchanged and is now composed from these parts. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 0f8ec793..58789ada 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -84,9 +84,9 @@ A connection failure leaves the surrounding application and its state mounted. C 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. Utility class names and internal markup are not a customization API. +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. -The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization and arbitrary component slots are outside this initial API. +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. ## Compatibility @@ -101,6 +101,7 @@ 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 | | `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 | | `CartButton` | Opens the shared drawer and displays item count | | `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity` | @@ -112,6 +113,63 @@ The package exports TypeScript catalog/cart response types and selection/summary `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. +## Custom product layouts + +`ProductDetails` is the recommended product page. When its layout does not fit, compose the same parts inside `ProductDetailsRoot`. The root loads the route's `:productId` and shows the connection, loading, error and not-found states itself, so parts render only once the product exists. This is the packaged layout, written out: + +```tsx +import { + ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsPrice, + ProductDetailsRoot, ProductDetailsTitle, ProductPurchase, +} from '@godaddy/gd-commerce-storefront'; + + + + + + + + + +``` + +Add your own elements between parts, reorder them, or leave out display parts. `ProductPurchase` renders the purchase parts below in order. Replace it with individual parts to place options apart from the add button: + +| Part | Renders | +| --- | --- | +| `ProductOptions` | A select for every attribute, or the SKU list for products without attributes | +| `ProductOption` | A select for one attribute, by `attribute` name; nothing when the product lacks it | +| `ProductPurchaseStatus` | The one message explaining why the selection is not purchasable yet; nothing when ready | +| `ProductAvailability`, `ProductQuantity` | Stock and quantity for the verified SKU; nothing before verification | +| `ProductAddToCart` | Adds the verified SKU and quantity; while disabled, its accessible description gives the reason | + +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`. If a layout cannot select every attribute a product requires, a console warning names the missing ones. Products without attributes but with several SKUs need `ProductOptions` or `ProductPurchase`. + +Every part accepts `className`, which is added to the part's outer element. The packaged layout sets spacing and grid placement this way, so your classes can control margins, width and position. Package styles are scoped under `.commerce-storefront` and win over unscoped host classes for the properties a part already sets, such as its font size. + +Two hooks support custom parts inside the root: + +- `useProductDetails()` returns read-only `product`, `name`, `selectedSku`, `quantity`, `availableQuantity`, `images` and `status` (`incomplete`, `checking`, `variant-error`, `unavailable`, `not-purchasable` or `ready`). `selectedSku` is set only when `status` is `ready`. +- `useProductOption(attribute)` returns `{ name, label, selected, values, select }` for building swatches or buttons, or `null` when the product has no such attribute. `select(value)` accepts only that attribute's value names, or `null` to clear. Other values are ignored. A hook-based control counts toward the layout check. + +```tsx +function ColorSwatches() { + const color = useProductOption('color'); + if (!color) return null; + return ( +
+ {color.values.map((value) => ( + + ))} +
+ ); +} +``` + +Parts are flat named exports, not properties such as `ProductDetails.Title`, so each one is an ordinary module export. + ## 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/index.ts b/packages/commerce-storefront/src/index.ts index 43a74992..b7b26680 100644 --- a/packages/commerce-storefront/src/index.ts +++ b/packages/commerce-storefront/src/index.ts @@ -20,5 +20,33 @@ export { useCommerce, } from './commerce-provider'; export { CommerceStorefront } from './commerce-storefront'; -export { ProductDetails } from './product-details'; +export { + ProductDetails, + ProductDetailsBackLink, + ProductDetailsDescription, + ProductDetailsGallery, + ProductDetailsPrice, + ProductDetailsTitle, +} from './product-details'; +export { + ProductDetailsRoot, + type ProductDetailsRootProps, + type ProductDetailsState, + type ProductOptionState, + type ProductOptionValueState, + type ProductPartProps, + type PurchaseStatus, + useProductDetails, + useProductOption, +} from './product-details-context'; +export { + ProductAddToCart, + ProductAvailability, + ProductOption, + type ProductOptionProps, + ProductOptions, + ProductPurchase, + ProductPurchaseStatus, + ProductQuantity, +} from './product-purchase'; export { CommerceStatus } from './storefront-surface'; diff --git a/packages/commerce-storefront/src/product-details-context.tsx b/packages/commerce-storefront/src/product-details-context.tsx new file mode 100644 index 00000000..163e71f4 --- /dev/null +++ b/packages/commerce-storefront/src/product-details-context.tsx @@ -0,0 +1,318 @@ +import { useQuery } from '@tanstack/react-query'; +import { + createContext, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useRef, + useState, +} from 'react'; +import { Link, useParams, useSearchParams } from 'react-router'; +import { ApiError, message, request } from './api'; +import { buttonClass } from './cart'; +import { + getAvailableInventoryQuantity, + getImageUrls, + getLabeledSkuOptions, + getProductAttributes, + getSingleMatchedSku, + type SKU, + type SKUGroup, + type SKUPrice, + type SkuGroupResult, + type StorefrontProductAttribute, +} from './catalog-model'; +import { useCommerce } from './commerce-provider'; +import { CommerceStatus, StorefrontSurface } from './storefront-surface'; + +/** Where the shopper is in choosing a purchasable SKU. Only `ready` has a verified `selectedSku`. */ +export type PurchaseStatus = + | 'incomplete' + | 'checking' + | 'variant-error' + | 'unavailable' + | 'not-purchasable' + | 'ready'; + +/** Read-only product state for custom parts rendered inside `ProductDetailsRoot`. */ +export interface ProductDetailsState { + readonly product: SKUGroup; + readonly name: string; + /** Set only after the server verifies the full selection. */ + readonly selectedSku: SKU | null; + readonly quantity: number; + /** `null` means inventory is not tracked; `0` means sold out. */ + readonly availableQuantity: number | null; + readonly status: PurchaseStatus; + readonly images: readonly string[]; +} + +interface ProductDetailsInternal extends ProductDetailsState { + fieldId: string; + attributes: StorefrontProductAttribute[]; + selections: string[]; + skuOptions: SKU[]; + explicitSkuId: string; + price: SKUPrice | null | undefined; + imageIndex: number; + variantError: unknown; + setImageIndex: (index: number) => void; + setQuantity: (quantity: number) => void; + selectOption: (attribute: string, value: string) => void; + selectSku: (skuId: string) => void; + retryVariant: () => void; + register: (control: string) => () => void; +} + +/** Props shared by every product part. `className` is added to the part's outer element. */ +export interface ProductPartProps { + className?: string; +} + +export const cx = (...classes: Array): string => classes.filter(Boolean).join(' '); + +const ProductDetailsContext = createContext(null); + +export function useProductDetailsInternal(part: string): ProductDetailsInternal { + const context = useContext(ProductDetailsContext); + if (!context) throw new Error(`${part} must be rendered inside .`); + return context; +} + +/** Read the current product, verified SKU, and purchase status from inside `ProductDetailsRoot`. */ +export function useProductDetails(): ProductDetailsState { + const { product, name, selectedSku, quantity, availableQuantity, status, images } = + useProductDetailsInternal('useProductDetails'); + return { product, name, selectedSku, quantity, availableQuantity, status, images }; +} + +export interface ProductOptionValueState { + readonly name: string; + readonly label: string; + readonly selected: boolean; +} + +export interface ProductOptionState { + readonly name: string; + readonly label: string; + readonly selected: string | null; + readonly values: readonly ProductOptionValueState[]; + /** Accepts only this attribute's own value names, or `null` to clear. Other values are ignored. */ + readonly select: (value: string | null) => void; +} + +/** + * Build a custom control for one product attribute. Returns `null` when the product has no such attribute. + * A selection is never purchasable until the server verifies it. + */ +export function useProductOption(attribute: string): ProductOptionState | null { + const { attributes, selections, selectOption, register } = useProductDetailsInternal('useProductOption'); + useEffect(() => register(`attribute:${attribute}`), [attribute, register]); + const index: number = attributes.findIndex((candidate) => candidate.name === attribute); + const match: StorefrontProductAttribute | undefined = attributes[index]; + if (!match) return null; + const selected: string | null = selections[index] || null; + return { + name: match.name, + label: match.label, + selected, + values: match.values.map((value) => ({ + name: value.name, + label: value.label, + selected: value.name === selected, + })), + select: (value: string | null): void => { + if (value === null) selectOption(attribute, ''); + else if (match.values.some((candidate) => candidate.name === value)) selectOption(attribute, value); + }, + }; +} + +/** Register the explicit SKU control so the layout check knows SKU-list products can be completed. */ +export function useSkuOptionRegistration(): void { + const { register } = useProductDetailsInternal('ProductOptions'); + useEffect(() => register('sku'), [register]); +} + +export interface ProductDetailsRootProps { + children: ReactNode; + /** Added to the root `
`. */ + className?: string; +} + +/** Loads the product for the route's `:productId` and provides state to product parts. */ +export function ProductDetailsRoot({ children, className }: ProductDetailsRootProps): ReactElement { + const { productId = '' } = useParams(); + const { connection } = useCommerce(); + return ( + + {connection === 'ready' ? ( + + {children} + + ) : ( + + )} + + ); +} + +function ProductDetailsLoader({ + productId, + className, + children, +}: ProductDetailsRootProps & { productId: string }): ReactElement { + const { config } = useCommerce(); + const fieldId = useId(); + const [params, setParams] = useSearchParams(); + const [quantity, setQuantity] = useState(1); + const [imageIndex, setImageIndex] = useState(0); + const product = useQuery({ + retry: false, + queryKey: ['commerce', config.cartScope, 'product', productId], + queryFn: ({ signal }) => + request(`/products/${encodeURIComponent(productId)}`, { + signal, + headers: { 'X-Commerce-Scope': config.cartScope }, + }), + }); + const productNotFound: boolean = + product.isError && product.error instanceof ApiError && product.error.status === 404; + const group = product.data?.skuGroup; + const attributes = getProductAttributes(group); + const skuOptions: SKU[] = getLabeledSkuOptions(group); + const explicitSku: SKU | undefined = skuOptions.find((sku: SKU): boolean => sku.id === params.get('sku')); + const selections: string[] = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? ''); + const complete: boolean = + skuOptions.length > 0 + ? !!explicitSku + : attributes.every((attribute, index) => + attribute.values.some((value) => value.name === selections[index]), + ); + const selectionParams: URLSearchParams = new URLSearchParams(); + selections.filter(Boolean).forEach((value) => { + selectionParams.append('attributeValues', value); + }); + const matched = useQuery({ + retry: false, + queryKey: ['commerce', config.cartScope, 'product-variants', productId, selections], + queryFn: ({ signal }) => + request(`/products/${encodeURIComponent(productId)}?${selectionParams}`, { + signal, + headers: { 'X-Commerce-Scope': config.cartScope }, + }), + enabled: !!group && attributes.length > 0 && complete, + }); + const selectionVerified: boolean = + complete && + product.isSuccess && + !product.isFetching && + (attributes.length === 0 || (matched.isSuccess && !matched.isFetching)); + const selectedSku: SKU | null = selectionVerified + ? (explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group)) + : null; + const status: PurchaseStatus = !complete + ? 'incomplete' + : matched.isError && !matched.isFetching + ? 'variant-error' + : !selectionVerified + ? 'checking' + : selectedSku + ? 'ready' + : attributes.length + ? 'unavailable' + : 'not-purchasable'; + const selectedImages: string[] = getImageUrls(selectedSku); + const images: string[] = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))]; + const changeSelection = (key: string, value: string): void => { + const next: URLSearchParams = new URLSearchParams(params); + if (value) next.set(key, value); + else next.delete(key); + setParams(next); + 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 requiredControls: string = [ + ...attributes.map((attribute) => `attribute:${attribute.name}`), + ...(skuOptions.length ? ['sku'] : []), + ].join('\n'); + // Parent effects run after child effects, so every mounted control has registered by now. + useEffect(() => { + const missing: string[] = requiredControls + .split('\n') + .filter((control) => control && !registered.current.has(control)); + if (missing.length === 0) return; + console.warn( + `[gd-commerce-storefront] This product layout cannot select ${missing + .map((control) => (control === 'sku' ? 'a SKU option' : `"${control.slice('attribute:'.length)}"`)) + .join(', ')}, so it can never be added to the cart. Render , ` + + ` for each attribute, or .`, + ); + }, [requiredControls]); + if (product.isPending) return

Loading product…

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

Product unavailable

+

{message(product.error)}

+ +
+ ); + if (productNotFound || !group) + return ( +
+

Product not found

+ + Back to shop + +
+ ); + const value: ProductDetailsInternal = { + product: group, + name: group.label ?? group.name ?? 'Product', + selectedSku, + quantity, + availableQuantity: getAvailableInventoryQuantity(selectedSku), + status, + images, + fieldId, + attributes, + selections, + skuOptions, + explicitSkuId: explicitSku?.id ?? '', + price: selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node, + imageIndex: Math.min(imageIndex, Math.max(images.length - 1, 0)), + variantError: matched.error, + setImageIndex: (index: number): void => { + if (Number.isInteger(index) && index >= 0 && index < images.length) setImageIndex(index); + }, + setQuantity, + selectOption: (attribute: string, next: string): void => changeSelection(`option.${attribute}`, next), + selectSku: (skuId: string): void => + changeSelection('sku', skuOptions.some((sku) => sku.id === skuId) ? skuId : ''), + retryVariant: (): void => { + if (matched.isError) void matched.refetch(); + }, + register, + }; + return ( + +
{children}
+
+ ); +} diff --git a/packages/commerce-storefront/src/product-details.test.tsx b/packages/commerce-storefront/src/product-details.test.tsx new file mode 100644 index 00000000..e8f940a1 --- /dev/null +++ b/packages/commerce-storefront/src/product-details.test.tsx @@ -0,0 +1,202 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement, ReactNode } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { describe, expect, it, vi } from 'vitest'; +import type { SKU, SKUGroup } from './catalog-model'; +import { CommerceStorefront } from './commerce-storefront'; +import { ProductDetailsTitle } from './product-details'; +import { ProductDetailsRoot, useProductDetails, useProductOption } from './product-details-context'; +import { + ProductAddToCart, + ProductOption, + ProductPurchase, + ProductPurchaseStatus, + ProductQuantity, +} from './product-purchase'; + +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const sku = (id: string, quantity: number): SKU => ({ + id, + prices: { edges: [{ node: { value: { value: 1200, currencyCode: 'USD' } } }] }, + inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity } }] }, +}); +const shirt: SKUGroup = { + id: 'shirt', + label: 'Shirt', + attributes: { + edges: ['color', 'size'].map((name) => ({ + node: { + name, + label: name === 'color' ? 'Color' : 'Size', + values: { + edges: (name === 'color' ? ['blue', 'red'] : ['s', 'm']).map((value) => ({ + node: { id: value, name: value, label: value.toUpperCase() }, + })), + }, + }, + })), + }, + skus: { totalCount: 4, edges: [] }, +}; + +function mockApi() { + 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({ cartScope: 's', currencyCode: 'USD' })); + if (init?.method === 'POST') + return Promise.resolve(response({ cart: { id: 'cart-1', lineItems: [], totals: {} } }, 201)); + const selected = url.searchParams.getAll('attributeValues'); + return Promise.resolve( + response({ + skuGroup: selected.length + ? { ...shirt, skus: { totalCount: 1, edges: [{ node: sku(`sku-${selected.join('-')}`, 5) }] } } + : shirt, + }), + ); + }); + vi.stubGlobal('fetch', fn); + return fn; +} + +function mount(layout: ReactNode) { + return render( + + + + + + + + + , + ); +} + +function Swatches({ attribute }: { attribute: string }): ReactElement | null { + const option = useProductOption(attribute); + if (!option) return null; + return ( +
+ {option.values.map((value) => ( + + ))} + +
+ ); +} + +function SelectedSku(): ReactElement { + const { selectedSku, status } = useProductDetails(); + return {`${status}:${selectedSku?.id ?? 'none'}`}; +} + +describe('composable product details', () => { + it('purchases through a custom layout with swatches, reordered parts, and host classes', async () => { + const api = mockApi(); + mount( + + + +

Size guide

+ + + + +
, + ); + const title = await screen.findByRole('heading', { level: 1, name: 'Shirt' }); + expect(title).toHaveClass('host-title'); + expect(title.closest('article')).toHaveClass('host-article'); + const add = screen.getByRole('button', { name: 'Add to cart' }); + expect(add).toBeDisabled(); + expect(add).toHaveAccessibleDescription('Select all options to add this item to your cart.'); + + await userEvent.click(screen.getByRole('button', { name: 'Invalid' })); + expect(screen.getByTestId('selected')).toHaveTextContent('incomplete:none'); + await userEvent.click(screen.getByRole('button', { name: 'BLUE' })); + expect(screen.getByRole('button', { name: 'BLUE' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('selected')).toHaveTextContent('incomplete:none'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Size' }), 'm'); + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('ready:sku-blue-m')); + await userEvent.clear(screen.getByRole('spinbutton', { name: 'Quantity' })); + await userEvent.type(screen.getByRole('spinbutton', { name: 'Quantity' }), '2'); + 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-blue-m', name: 'Shirt', quantity: 2 }], + }); + }); + + it('resets quantity when any control changes the selection', async () => { + mockApi(); + mount( + + + + + + , + ); + await userEvent.click(await screen.findByRole('button', { name: 'BLUE' })); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Size' }), 's'); + const quantity = await screen.findByRole('spinbutton', { name: 'Quantity' }); + await userEvent.clear(quantity); + await userEvent.type(quantity, '4'); + await userEvent.click(screen.getByRole('button', { name: 'RED' })); + expect(await screen.findByRole('spinbutton', { name: 'Quantity' })).toHaveValue(1); + }); + + it('warns when a layout leaves out an option the product needs', async () => { + mockApi(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount( + + + + , + ); + await screen.findByRole('combobox', { name: 'Color' }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cannot select "size"')); + expect(warn.mock.calls.join('')).not.toContain('"color"'); + }); + + it('does not warn for the packaged purchase area', async () => { + mockApi(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount( + + + , + ); + await screen.findByRole('combobox', { name: 'Size' }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('names the missing root when a part is rendered outside ProductDetailsRoot', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'ProductAddToCart must be rendered inside .', + ); + function Orphan(): null { + useProductOption('color'); + return null; + } + expect(() => render()).toThrow( + 'useProductOption must be rendered inside .', + ); + }); +}); diff --git a/packages/commerce-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx index bd52809f..16b85578 100644 --- a/packages/commerce-storefront/src/product-details.tsx +++ b/packages/commerce-storefront/src/product-details.tsx @@ -1,295 +1,118 @@ -import { useQuery } from '@tanstack/react-query'; -import { type ReactElement, useId, useState } from 'react'; -import { Link, useParams, useSearchParams } from 'react-router'; -import { ApiError, message, money, request } from './api'; -import { AddToCartButton, buttonClass, inputClass } from './cart'; +import type { ReactElement } from 'react'; +import { Link } from 'react-router'; +import { money } from './api'; import { ProductImage } from './catalog'; -import { - getAvailableInventoryQuantity, - getImageUrls, - getLabeledSkuOptions, - getProductAttributes, - getSingleMatchedSku, - type SKU, - type SkuGroupResult, -} from './catalog-model'; import { useCommerce } from './commerce-provider'; -import { CommerceStatus, StorefrontSurface } from './storefront-surface'; +import { + cx, + ProductDetailsRoot, + type ProductPartProps, + useProductDetailsInternal, +} from './product-details-context'; +import { ProductPurchase } from './product-purchase'; +/** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */ export function ProductDetails(): ReactElement { - const { productId = '' } = useParams(); - const { connection } = useCommerce(); return ( - - {connection === 'ready' ? ( - - ) : ( - - )} - + + +
+ +
+ + + + +
+
+
); } -function ProductDetailsContent({ productId }: { productId: string }): ReactElement { +export function ProductDetailsBackLink({ className }: ProductPartProps): ReactElement { + useProductDetailsInternal('ProductDetailsBackLink'); const { config } = useCommerce(); - const fieldId = useId(); - const [params, setParams] = useSearchParams(); - const [quantity, setQuantity] = useState(1); - const [imageIndex, setImageIndex] = useState(0); - const product = useQuery({ - retry: false, - queryKey: ['commerce', config.cartScope, 'product', productId], - queryFn: ({ signal }) => - request(`/products/${encodeURIComponent(productId)}`, { - signal, - headers: { 'X-Commerce-Scope': config.cartScope }, - }), - }); - const productNotFound: boolean = - product.isError && product.error instanceof ApiError && product.error.status === 404; - const group = product.data?.skuGroup; - const attributes = getProductAttributes(group); - const skuOptions: SKU[] = getLabeledSkuOptions(group); - const explicitSku: SKU | undefined = skuOptions.find((sku: SKU): boolean => sku.id === params.get('sku')); - const selections: string[] = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? ''); - const complete: boolean = - skuOptions.length > 0 - ? !!explicitSku - : attributes.every((attribute, index) => - attribute.values.some((value) => value.name === selections[index]), - ); - const selectionParams: URLSearchParams = new URLSearchParams(); - selections.filter(Boolean).forEach((value) => { - selectionParams.append('attributeValues', value); - }); - const matched = useQuery({ - retry: false, - queryKey: ['commerce', config.cartScope, 'product-variants', productId, selections], - queryFn: ({ signal }) => - request(`/products/${encodeURIComponent(productId)}?${selectionParams}`, { - signal, - headers: { 'X-Commerce-Scope': config.cartScope }, - }), - enabled: !!group && attributes.length > 0 && complete, - }); - const selectionVerified: boolean = - complete && - product.isSuccess && - !product.isFetching && - (attributes.length === 0 || (matched.isSuccess && !matched.isFetching)); - const selectedSku: SKU | null = selectionVerified - ? (explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group)) - : null; - const skuId: string | null = selectedSku?.id ?? null; - const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node; - const selectedImages: string[] = getImageUrls(selectedSku); - const images: string[] = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))]; - const available: number | null = getAvailableInventoryQuantity(selectedSku); - const name: string = group?.label ?? group?.name ?? 'Product'; - const selectOption = (attribute: string, value: string): void => { - const next: URLSearchParams = new URLSearchParams(params); - if (value) next.set(`option.${attribute}`, value); - else next.delete(`option.${attribute}`); - setParams(next); - setQuantity(1); - setImageIndex(0); - }; - if (product.isPending) return

Loading product…

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

Product unavailable

-

{message(product.error)}

- -
- ); - if (productNotFound || !group) - return ( -
-

Product not found

- - Back to shop - -
- ); return ( -
- - ← Back to shop - -
-
-
- -
- {images.length > 1 && ( -
- {images.map((url, index) => ( - - ))} -
- )} -
-
-

{name}

-
- {skuPrice?.value?.value != null - ? money(skuPrice.value.value, skuPrice.value.currencyCode ?? config.currencyCode) - : group.priceRange?.min != null - ? `From ${money(group.priceRange.min, config.currencyCode)}` - : 'Price unavailable'} - {skuPrice?.compareAtValue?.value != null && - skuPrice.value?.value != null && - skuPrice.compareAtValue.value > skuPrice.value.value && ( - - {money( - skuPrice.compareAtValue.value, - skuPrice.compareAtValue.currencyCode ?? config.currencyCode, - )} - - )} -
-

- {selectedSku?.description ?? group.description} -

-
- {attributes.map((attribute, index) => ( -
- {attribute.label} - -
- ))} - {skuOptions.length > 0 && ( -
- - -
- )} - {matched.isError ? ( -
-

{message(matched.error)}

- -
- ) : null} - {!complete && ( -

- Select all options to add this item to your cart. -

- )} - {selectionVerified && attributes.length > 0 && !skuId && ( -

- This combination is unavailable. Choose different options. -

- )} - {selectionVerified && attributes.length === 0 && !skuId && ( -

This product is not currently available to purchase.

- )} - {complete && (product.isFetching || matched.isFetching) && ( -

Checking this variant…

- )} - {selectedSku && ( - <> -

- {available === null - ? 'Available to order' - : available === 0 - ? 'This variant is out of stock' - : `${available} available`} -

-
- - setQuantity(event.target.valueAsNumber)} - /> -
- - - )} - {!selectedSku && ( - - )} -
-
+ + ← Back to shop + + ); +} + +/** Main image with thumbnails; the selected SKU's images replace the product's when present. */ +export function ProductDetailsGallery({ className }: ProductPartProps): ReactElement { + const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal('ProductDetailsGallery'); + return ( +
+
+
-
+ {images.length > 1 && ( +
+ {images.map((url, index) => ( + + ))} +
+ )} + + ); +} + +/** The product name as the page's H1. */ +export function ProductDetailsTitle({ className }: ProductPartProps): ReactElement { + const { name } = useProductDetailsInternal('ProductDetailsTitle'); + return ( +

{name}

+ ); +} + +/** The verified SKU's price and compare-at price, or the product's starting price. */ +export function ProductDetailsPrice({ className }: ProductPartProps): ReactElement { + const { price, product } = useProductDetailsInternal('ProductDetailsPrice'); + const { config } = useCommerce(); + return ( +
+ {price?.value?.value != null + ? money(price.value.value, price.value.currencyCode ?? config.currencyCode) + : product.priceRange?.min != null + ? `From ${money(product.priceRange.min, config.currencyCode)}` + : 'Price unavailable'} + {price?.compareAtValue?.value != null && + price.value?.value != null && + price.compareAtValue.value > price.value.value && ( + + {money(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)} + + )} +
+ ); +} + +/** The verified SKU's description, or the product's. */ +export function ProductDetailsDescription({ className }: ProductPartProps): ReactElement { + const { selectedSku, product } = useProductDetailsInternal('ProductDetailsDescription'); + return ( +

+ {selectedSku?.description ?? product.description} +

); } diff --git a/packages/commerce-storefront/src/product-purchase.tsx b/packages/commerce-storefront/src/product-purchase.tsx new file mode 100644 index 00000000..75d10b35 --- /dev/null +++ b/packages/commerce-storefront/src/product-purchase.tsx @@ -0,0 +1,205 @@ +import type { ReactElement } from 'react'; +import { message } from './api'; +import { AddToCartButton, buttonClass, inputClass } from './cart'; +import type { SKU } from './catalog-model'; +import { + cx, + type ProductPartProps, + type PurchaseStatus, + useProductDetailsInternal, + useProductOption, + useSkuOptionRegistration, +} from './product-details-context'; + +const disabledReasons: Record, string> = { + incomplete: 'Select all options to add this item to your cart.', + checking: 'Checking this variant…', + 'variant-error': 'Availability for this selection could not be checked.', + unavailable: 'This combination is unavailable. Choose different options.', + 'not-purchasable': 'This product is not currently available to purchase.', +}; + +/** The packaged purchase area: options, status, availability, quantity, and add to cart. */ +export function ProductPurchase({ className }: ProductPartProps): ReactElement { + useProductDetailsInternal('ProductPurchase'); + return ( +
+ + + + + +
+ ); +} + +/** A select for every product attribute, or the SKU list for products without attributes. */ +export function ProductOptions({ className }: ProductPartProps): ReactElement | null { + const { attributes, skuOptions } = useProductDetailsInternal('ProductOptions'); + if (attributes.length === 0 && skuOptions.length === 0) return null; + return ( +
+ {attributes.map((attribute) => ( + + ))} + {skuOptions.length > 0 && } +
+ ); +} + +function SkuOption(): ReactElement { + const { fieldId, skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal('ProductOptions'); + useSkuOptionRegistration(); + return ( +
+ + +
+ ); +} + +export interface ProductOptionProps extends ProductPartProps { + /** The attribute `name`, such as `color`. Renders nothing when the product has no such attribute. */ + attribute: string; +} + +/** A select for one product attribute. Use `useProductOption` for swatches or other custom controls. */ +export function ProductOption({ attribute, className }: ProductOptionProps): ReactElement | null { + const option = useProductOption(attribute); + if (!option) return null; + return ( +
+ {option.label} + +
+ ); +} + +/** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */ +export function ProductPurchaseStatus({ className }: ProductPartProps): ReactElement | null { + const { status, variantError, retryVariant } = useProductDetailsInternal('ProductPurchaseStatus'); + switch (status) { + case 'ready': + return null; + case 'variant-error': + return ( +
+

{message(variantError)}

+ +
+ ); + case 'unavailable': + return ( +

+ {disabledReasons[status]} +

+ ); + case 'not-purchasable': + return

{disabledReasons[status]}

; + case 'checking': + return ( +

+ {disabledReasons[status]} +

+ ); + case 'incomplete': + return ( +

+ {disabledReasons[status]} +

+ ); + } +} + +/** Stock for the verified SKU. Renders nothing until a SKU is verified. */ +export function ProductAvailability({ className }: ProductPartProps): ReactElement | null { + const { selectedSku, availableQuantity } = useProductDetailsInternal('ProductAvailability'); + if (!selectedSku) return null; + return ( +

+ {availableQuantity === null + ? 'Available to order' + : availableQuantity === 0 + ? 'This variant is out of stock' + : `${availableQuantity} available`} +

+ ); +} + +/** Quantity for the verified SKU. Renders nothing until a SKU is verified. */ +export function ProductQuantity({ className }: ProductPartProps): ReactElement | null { + const { fieldId, selectedSku, availableQuantity, quantity, setQuantity } = + useProductDetailsInternal('ProductQuantity'); + if (!selectedSku) return null; + return ( +
+ + setQuantity(event.target.valueAsNumber)} + /> +
+ ); +} + +/** Adds the verified SKU and quantity. While disabled, its accessible description says why. */ +export function ProductAddToCart({ className }: ProductPartProps): ReactElement { + const { fieldId, selectedSku, name, quantity, status } = useProductDetailsInternal('ProductAddToCart'); + return ( +
+ {selectedSku ? ( + + ) : ( + <> + + + + )} +
+ ); +} From 1cea7c68c936be80475230df0b7d9057fa2d3103 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Tue, 13 Oct 2026 15:25:09 -0700 Subject: [PATCH 2/3] Keep packaged option spacing identical to 0.2.0 ProductPurchase renders option controls without the ProductOptions wrapper, so the default layout's spacing matches the previous release. Co-Authored-By: Claude Opus 5.5 --- .../commerce-storefront/src/product-purchase.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/commerce-storefront/src/product-purchase.tsx b/packages/commerce-storefront/src/product-purchase.tsx index 75d10b35..5c442146 100644 --- a/packages/commerce-storefront/src/product-purchase.tsx +++ b/packages/commerce-storefront/src/product-purchase.tsx @@ -24,7 +24,7 @@ export function ProductPurchase({ className }: ProductPartProps): ReactElement { useProductDetailsInternal('ProductPurchase'); return (
- + @@ -39,11 +39,21 @@ export function ProductOptions({ className }: ProductPartProps): ReactElement | if (attributes.length === 0 && skuOptions.length === 0) return null; return (
+ +
+ ); +} + +// Unwrapped so the packaged layout keeps the controls as direct children of its own spacing container. +function OptionControls(): ReactElement { + const { attributes, skuOptions } = useProductDetailsInternal('ProductOptions'); + return ( + <> {attributes.map((attribute) => ( ))} {skuOptions.length > 0 && } -
+ ); } From 0a4780bd549bbc3bef32d894de50ea6c53c36646 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Wed, 14 Oct 2026 09:08:42 -0700 Subject: [PATCH 3/3] Address review feedback on composable product details - Give each part its own useId() so parts rendered twice, such as a sticky purchase bar, keep unique IDs and labels. - Put className on a commerce-part wrapper that the package CSS reset skips, so layered host utilities (Tailwind v4) control margin, padding, border and color on every part. Cancel the space-y gap on the parts that had none in 0.2.0 so ProductDetails stays pixel-identical. - Rename purchase parts, the option hook and the shared types to the ProductDetails* prefix before release. - Reword the missing-control warning for controls that mount later, and limit it to development builds. - Show the real packaged layout in the README, document the className limits, and correct the changeset wording. Co-Authored-By: Claude Opus 5.5 --- .changeset/composable-product-details.md | 2 +- packages/commerce-storefront/README.md | 42 ++-- .../commerce-storefront/src/artifacts.test.ts | 4 +- packages/commerce-storefront/src/index.ts | 26 +-- .../src/product-details-context.tsx | 44 ++-- .../src/product-details.test.tsx | 198 +++++++++++++++--- .../src/product-details.tsx | 51 ++--- .../src/product-purchase.tsx | 188 +++++++++-------- packages/commerce-storefront/src/styles.css | 9 +- 9 files changed, 373 insertions(+), 191 deletions(-) diff --git a/.changeset/composable-product-details.md b/.changeset/composable-product-details.md index 5e8ba652..a3ad0cc9 100644 --- a/.changeset/composable-product-details.md +++ b/.changeset/composable-product-details.md @@ -2,4 +2,4 @@ '@godaddy/gd-commerce-storefront': minor --- -Add composable product parts. `ProductDetailsRoot` with display parts (`ProductDetailsGallery`, `ProductDetailsTitle`, `ProductDetailsPrice`, `ProductDetailsDescription`, `ProductDetailsBackLink`) and purchase parts (`ProductPurchase`, `ProductOptions`, `ProductOption`, `ProductPurchaseStatus`, `ProductAvailability`, `ProductQuantity`, `ProductAddToCart`) build custom product layouts. `useProductDetails` reads product state and `useProductOption` builds custom option controls. Only server-verified SKUs can be added in any layout. `ProductDetails` is unchanged and is now composed from these parts. +Add composable product parts. `ProductDetailsRoot` with display parts (`ProductDetailsGallery`, `ProductDetailsTitle`, `ProductDetailsPrice`, `ProductDetailsDescription`, `ProductDetailsBackLink`) and purchase parts (`ProductDetailsPurchase`, `ProductDetailsOptions`, `ProductDetailsOption`, `ProductDetailsPurchaseStatus`, `ProductDetailsAvailability`, `ProductDetailsQuantity`, `ProductDetailsAddToCart`) build custom product layouts. `useProductDetails` reads product state and `useProductDetailsOption` builds custom option controls. Only server-verified SKUs can be added in any layout. `ProductDetails` is now composed from these parts and is visually unchanged. Its markup gains wrapper elements, and the disabled add button now gets its accessible description from a hidden reason element. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 58789ada..574c718d 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -120,41 +120,49 @@ The package exports TypeScript catalog/cart response types and selection/summary ```tsx import { ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsPrice, - ProductDetailsRoot, ProductDetailsTitle, ProductPurchase, + ProductDetailsPurchase, ProductDetailsRoot, ProductDetailsTitle, } from '@godaddy/gd-commerce-storefront'; - - - - - - + +
+ +
+ + + + +
+
``` -Add your own elements between parts, reorder them, or leave out display parts. `ProductPurchase` renders the purchase parts below in order. Replace it with individual parts to place options apart from the add button: +The grid, spacing and border classes come from your own stylesheet in a custom layout. Without them, the parts stack in one column with no space between them. + +Add your own elements between parts, reorder them, or leave out display parts. `ProductDetailsPurchase` renders the purchase parts below in order. Replace it with individual parts to place options apart from the add button: | Part | Renders | | --- | --- | -| `ProductOptions` | A select for every attribute, or the SKU list for products without attributes | -| `ProductOption` | A select for one attribute, by `attribute` name; nothing when the product lacks it | -| `ProductPurchaseStatus` | The one message explaining why the selection is not purchasable yet; nothing when ready | -| `ProductAvailability`, `ProductQuantity` | Stock and quantity for the verified SKU; nothing before verification | -| `ProductAddToCart` | Adds the verified SKU and quantity; while disabled, its accessible description gives the reason | +| `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 | +| `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. -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`. If a layout cannot select every attribute a product requires, a console warning names the missing ones. Products without attributes but with several SKUs need `ProductOptions` or `ProductPurchase`. +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. The packaged layout sets spacing and grid placement this way, so your classes can control margins, width and position. Package styles are scoped under `.commerce-storefront` and win over unscoped host classes for the properties a part already sets, such as its font size. +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. Two hooks support custom parts inside the root: - `useProductDetails()` returns read-only `product`, `name`, `selectedSku`, `quantity`, `availableQuantity`, `images` and `status` (`incomplete`, `checking`, `variant-error`, `unavailable`, `not-purchasable` or `ready`). `selectedSku` is set only when `status` is `ready`. -- `useProductOption(attribute)` returns `{ name, label, selected, values, select }` for building swatches or buttons, or `null` when the product has no such attribute. `select(value)` accepts only that attribute's value names, or `null` to clear. Other values are ignored. A hook-based control counts toward the layout check. +- `useProductDetailsOption(attribute)` returns `{ name, label, selected, values, select }` for building swatches or buttons, or `null` when the product has no such attribute. `select(value)` accepts only that attribute's value names, or `null` to clear. Other values are ignored. A hook-based control counts toward the layout check. ```tsx function ColorSwatches() { - const color = useProductOption('color'); + const color = useProductDetailsOption('color'); if (!color) return null; return (
diff --git a/packages/commerce-storefront/src/artifacts.test.ts b/packages/commerce-storefront/src/artifacts.test.ts index bea80de5..bea3409f 100644 --- a/packages/commerce-storefront/src/artifacts.test.ts +++ b/packages/commerce-storefront/src/artifacts.test.ts @@ -57,13 +57,13 @@ it('inherits host text without painting wrappers while retaining opaque themed c }); expect(controlDeclarations['background-color']).toBe('var(--commerce-surface, #fff)'); const resets: Record = {}; - css.walkRules('.commerce-storefront :where(*)', (rule) => { + css.walkRules('.commerce-storefront :where(*:not(.commerce-part))', (rule) => { rule.walkDecls((declaration) => { resets[declaration.prop] = declaration.value; }); }); expect(resets).toMatchObject({ color: 'inherit', 'font-family': 'inherit' }); - expect(css.toString().indexOf('.commerce-storefront :where(*)')).toBeLessThan( + expect(css.toString().indexOf('.commerce-storefront :where(*:not(.commerce-part))')).toBeLessThan( css.toString().indexOf('.commerce-storefront .text-commerce-on-accent'), ); }); diff --git a/packages/commerce-storefront/src/index.ts b/packages/commerce-storefront/src/index.ts index b7b26680..0f467289 100644 --- a/packages/commerce-storefront/src/index.ts +++ b/packages/commerce-storefront/src/index.ts @@ -29,24 +29,24 @@ export { ProductDetailsTitle, } from './product-details'; export { + type ProductDetailsOptionState, + type ProductDetailsOptionValueState, + type ProductDetailsPartProps, ProductDetailsRoot, type ProductDetailsRootProps, type ProductDetailsState, - type ProductOptionState, - type ProductOptionValueState, - type ProductPartProps, - type PurchaseStatus, + type ProductDetailsStatus, useProductDetails, - useProductOption, + useProductDetailsOption, } from './product-details-context'; export { - ProductAddToCart, - ProductAvailability, - ProductOption, - type ProductOptionProps, - ProductOptions, - ProductPurchase, - ProductPurchaseStatus, - ProductQuantity, + ProductDetailsAddToCart, + ProductDetailsAvailability, + ProductDetailsOption, + type ProductDetailsOptionProps, + ProductDetailsOptions, + ProductDetailsPurchase, + ProductDetailsPurchaseStatus, + ProductDetailsQuantity, } from './product-purchase'; export { CommerceStatus } from './storefront-surface'; diff --git a/packages/commerce-storefront/src/product-details-context.tsx b/packages/commerce-storefront/src/product-details-context.tsx index 163e71f4..0db97811 100644 --- a/packages/commerce-storefront/src/product-details-context.tsx +++ b/packages/commerce-storefront/src/product-details-context.tsx @@ -6,7 +6,6 @@ import { useCallback, useContext, useEffect, - useId, useRef, useState, } from 'react'; @@ -29,7 +28,7 @@ import { useCommerce } from './commerce-provider'; import { CommerceStatus, StorefrontSurface } from './storefront-surface'; /** Where the shopper is in choosing a purchasable SKU. Only `ready` has a verified `selectedSku`. */ -export type PurchaseStatus = +export type ProductDetailsStatus = | 'incomplete' | 'checking' | 'variant-error' @@ -46,12 +45,11 @@ export interface ProductDetailsState { readonly quantity: number; /** `null` means inventory is not tracked; `0` means sold out. */ readonly availableQuantity: number | null; - readonly status: PurchaseStatus; + readonly status: ProductDetailsStatus; readonly images: readonly string[]; } interface ProductDetailsInternal extends ProductDetailsState { - fieldId: string; attributes: StorefrontProductAttribute[]; selections: string[]; skuOptions: SKU[]; @@ -68,12 +66,15 @@ interface ProductDetailsInternal extends ProductDetailsState { } /** Props shared by every product part. `className` is added to the part's outer element. */ -export interface ProductPartProps { +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 { @@ -89,17 +90,17 @@ export function useProductDetails(): ProductDetailsState { return { product, name, selectedSku, quantity, availableQuantity, status, images }; } -export interface ProductOptionValueState { +export interface ProductDetailsOptionValueState { readonly name: string; readonly label: string; readonly selected: boolean; } -export interface ProductOptionState { +export interface ProductDetailsOptionState { readonly name: string; readonly label: string; readonly selected: string | null; - readonly values: readonly ProductOptionValueState[]; + readonly values: readonly ProductDetailsOptionValueState[]; /** Accepts only this attribute's own value names, or `null` to clear. Other values are ignored. */ readonly select: (value: string | null) => void; } @@ -108,8 +109,9 @@ export interface ProductOptionState { * Build a custom control for one product attribute. Returns `null` when the product has no such attribute. * A selection is never purchasable until the server verifies it. */ -export function useProductOption(attribute: string): ProductOptionState | null { - const { attributes, selections, selectOption, register } = useProductDetailsInternal('useProductOption'); +export function useProductDetailsOption(attribute: string): ProductDetailsOptionState | null { + const { attributes, selections, selectOption, register } = + useProductDetailsInternal('useProductDetailsOption'); useEffect(() => register(`attribute:${attribute}`), [attribute, register]); const index: number = attributes.findIndex((candidate) => candidate.name === attribute); const match: StorefrontProductAttribute | undefined = attributes[index]; @@ -133,7 +135,7 @@ export function useProductOption(attribute: string): ProductOptionState | null { /** Register the explicit SKU control so the layout check knows SKU-list products can be completed. */ export function useSkuOptionRegistration(): void { - const { register } = useProductDetailsInternal('ProductOptions'); + const { register } = useProductDetailsInternal('ProductDetailsOptions'); useEffect(() => register('sku'), [register]); } @@ -166,7 +168,6 @@ function ProductDetailsLoader({ children, }: ProductDetailsRootProps & { productId: string }): ReactElement { const { config } = useCommerce(); - const fieldId = useId(); const [params, setParams] = useSearchParams(); const [quantity, setQuantity] = useState(1); const [imageIndex, setImageIndex] = useState(0); @@ -214,7 +215,7 @@ function ProductDetailsLoader({ const selectedSku: SKU | null = selectionVerified ? (explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group)) : null; - const status: PurchaseStatus = !complete + const status: ProductDetailsStatus = !complete ? 'incomplete' : matched.isError && !matched.isFetching ? 'variant-error' @@ -249,17 +250,21 @@ function ProductDetailsLoader({ ...attributes.map((attribute) => `attribute:${attribute.name}`), ...(skuOptions.length ? ['sku'] : []), ].join('\n'); - // Parent effects run after child effects, so every mounted control has registered by now. + // Parent effects run after child effects, so every mounted control has registered by now. Controls can mount + // later (accordions, tabs, sheets), so the warning describes the current render only. Development builds only, + // 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)); if (missing.length === 0) return; console.warn( - `[gd-commerce-storefront] This product layout cannot select ${missing - .map((control) => (control === 'sku' ? 'a SKU option' : `"${control.slice('attribute:'.length)}"`)) - .join(', ')}, so it can never be added to the cart. Render , ` + - ` for each attribute, or .`, + `[gd-commerce-storefront] No control for ${missing + .map((control) => (control === 'sku' ? 'the SKU option' : `"${control.slice('attribute:'.length)}"`)) + .join(', ')} is rendered right now, so this product cannot be added to the cart until one is. ` + + `Render , for each attribute, ` + + `or , or ignore this if the control mounts later.`, ); }, [requiredControls]); if (product.isPending) return

Loading product…

; @@ -290,7 +295,6 @@ function ProductDetailsLoader({ availableQuantity: getAvailableInventoryQuantity(selectedSku), status, images, - fieldId, attributes, selections, skuOptions, @@ -312,7 +316,7 @@ function ProductDetailsLoader({ }; return ( -
{children}
+
{children}
); } diff --git a/packages/commerce-storefront/src/product-details.test.tsx b/packages/commerce-storefront/src/product-details.test.tsx index e8f940a1..fd19ec3b 100644 --- a/packages/commerce-storefront/src/product-details.test.tsx +++ b/packages/commerce-storefront/src/product-details.test.tsx @@ -1,19 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import postcss from 'postcss'; import type { ReactElement, ReactNode } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; import type { SKU, SKUGroup } from './catalog-model'; import { CommerceStorefront } from './commerce-storefront'; -import { ProductDetailsTitle } from './product-details'; -import { ProductDetailsRoot, useProductDetails, useProductOption } from './product-details-context'; import { - ProductAddToCart, - ProductOption, - ProductPurchase, - ProductPurchaseStatus, - ProductQuantity, + ProductDetailsBackLink, + ProductDetailsDescription, + ProductDetailsGallery, + ProductDetailsPrice, + ProductDetailsTitle, +} from './product-details'; +import { ProductDetailsRoot, useProductDetails, useProductDetailsOption } from './product-details-context'; +import { + ProductDetailsAddToCart, + ProductDetailsAvailability, + ProductDetailsOption, + ProductDetailsOptions, + ProductDetailsPurchase, + ProductDetailsPurchaseStatus, + ProductDetailsQuantity, } from './product-purchase'; const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); @@ -40,8 +51,16 @@ const shirt: SKUGroup = { }, skus: { totalCount: 4, edges: [] }, }; +const poster: SKUGroup = { + id: 'poster', + label: 'Poster', + skus: { + totalCount: 2, + edges: ['small', 'large'].map((size) => ({ node: { ...sku(`poster-${size}`, 3), label: size } })), + }, +}; -function mockApi() { +function mockApi(group: SKUGroup = shirt) { const fn = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input), 'https://host.test'); if (url.pathname.endsWith('/config')) @@ -52,8 +71,8 @@ function mockApi() { return Promise.resolve( response({ skuGroup: selected.length - ? { ...shirt, skus: { totalCount: 1, edges: [{ node: sku(`sku-${selected.join('-')}`, 5) }] } } - : shirt, + ? { ...group, skus: { totalCount: 1, edges: [{ node: sku(`sku-${selected.join('-')}`, 5) }] } } + : group, }), ); }); @@ -61,10 +80,10 @@ function mockApi() { return fn; } -function mount(layout: ReactNode) { +function mount(layout: ReactNode, path = '/products/shirt') { return render( - + @@ -76,7 +95,7 @@ function mount(layout: ReactNode) { } function Swatches({ attribute }: { attribute: string }): ReactElement | null { - const option = useProductOption(attribute); + const option = useProductDetailsOption(attribute); if (!option) return null; return (
@@ -110,14 +129,14 @@ describe('composable product details', () => {

Size guide

- + - - + + , ); const title = await screen.findByRole('heading', { level: 1, name: 'Shirt' }); - expect(title).toHaveClass('host-title'); + expect(title.parentElement).toHaveClass('host-title'); expect(title.closest('article')).toHaveClass('host-article'); const add = screen.getByRole('button', { name: 'Add to cart' }); expect(add).toBeDisabled(); @@ -146,9 +165,9 @@ describe('composable product details', () => { mount( - - - + + + , ); await userEvent.click(await screen.findByRole('button', { name: 'BLUE' })); @@ -165,12 +184,13 @@ describe('composable product details', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); mount( - - + + , ); await screen.findByRole('combobox', { name: 'Color' }); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cannot select "size"')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('No control for "size" is rendered right now')); + expect(warn.mock.calls.join('')).not.toMatch(/never/); expect(warn.mock.calls.join('')).not.toContain('"color"'); }); @@ -179,7 +199,7 @@ describe('composable product details', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); mount( - + , ); await screen.findByRole('combobox', { name: 'Size' }); @@ -188,15 +208,137 @@ describe('composable product details', () => { it('names the missing root when a part is rendered outside ProductDetailsRoot', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( - 'ProductAddToCart must be rendered inside .', + expect(() => render()).toThrow( + 'ProductDetailsAddToCart must be rendered inside .', ); function Orphan(): null { - useProductOption('color'); + useProductDetailsOption('color'); return null; } expect(() => render()).toThrow( - 'useProductOption must be rendered inside .', + 'useProductDetailsOption must be rendered inside .', + ); + }); + it('keeps IDs unique and labels attached when purchase parts render twice', async () => { + mockApi(); + const ids = (): string[] => [...document.querySelectorAll('[id]')].map((element) => element.id); + const unique = (): void => expect(new Set(ids()).size).toBe(ids().length); + mount( + + +
+ + +
+
, + ); + const [first, second] = await screen.findAllByRole('button', { name: 'Add to cart' }); + unique(); + expect(first).toHaveAccessibleDescription('Select all options to add this item to your cart.'); + expect(second).toHaveAccessibleDescription('Select all options to add this item to your cart.'); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Color' }), 'blue'); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Size' }), 'm'); + const quantities = await screen.findAllByRole('spinbutton', { name: 'Quantity' }); + expect(quantities).toHaveLength(2); + unique(); + }); + + it('keeps the SKU list ID unique when the options render twice', async () => { + mockApi(poster); + mount( + + + + , + '/products/poster', + ); + const lists = await screen.findAllByRole('combobox', { name: 'Option' }); + expect(lists).toHaveLength(2); + expect(lists[0]?.id).not.toBe(lists[1]?.id); + }); + + // Package CSS ships unlayered, and unlayered rules beat a host's layered utilities (Tailwind v4) whatever + // their specificity. So no package rule may set these properties on the element that receives a host class. + it('puts host classes on an element that no package rule styles', async () => { + mockApi(); + const rules: Array<{ selector: string; properties: string[] }> = []; + postcss.parse(readFileSync(join(process.cwd(), 'dist/styles.css'), 'utf8')).walkRules((rule) => { + if (rule.parent?.type === 'rule') return; + // Only direct declarations: nested rules such as space-y-* style children, not this element. + const properties: string[] = []; + rule.each((node) => { + if (node.type === 'decl') properties.push(node.prop); + }); + for (const selector of rule.selectors) + if (!selector.includes('::')) rules.push({ selector, properties }); + }); + const hostProperties = + /^(margin|padding|border-(width|style|top|right|bottom|left)|border$|color$|font-family$)/; + const conflicts = (element: Element): string[] => + rules.flatMap(({ selector, properties }) => { + let matches = false; + try { + matches = element.matches(selector); + } catch {} + return matches + ? properties.filter((property) => hostProperties.test(property)).map((p) => `${selector} ${p}`) + : []; + }); + const parts: Array<[string, ReactElement]> = [ + ['BackLink', ], + ['Gallery', ], + ['Title', ], + ['Price', ], + ['Description', ], + ['Purchase', ], + ['Options', ], + ['Option', ], + ['PurchaseStatus', ], + ['AddToCart', ], + ]; + mount( + + {parts.map(([name, part]) => ( +
+ {part} +
+ ))} +
+ + + +
+
, ); + const expectNoConflicts = (count: number): void => { + const hosts = [...document.querySelectorAll('.host')]; + expect(hosts).toHaveLength(count); + for (const host of hosts) { + const part = host.closest('[data-part]')?.getAttribute('data-part') ?? 'Root'; + expect({ part, conflicts: conflicts(host) }).toEqual({ part, conflicts: [] }); + } + }; + await screen.findByRole('heading', { level: 1, name: 'Shirt' }); + // Every part plus the root; the status message shows until the selection is ready. + expectNoConflicts(parts.length + 1); + await userEvent.click(screen.getByRole('button', { name: 'BLUE' })); + await userEvent.selectOptions(screen.getAllByRole('combobox', { name: 'Size' })[0] as HTMLElement, 'm'); + await screen.findAllByRole('spinbutton', { name: 'Quantity' }); + // The status message is gone; availability and quantity appear. + expectNoConflicts(parts.length + 1 - 1 + 2); + }); + + it('does not run the layout check in production builds', async () => { + vi.stubEnv('NODE_ENV', 'production'); + mockApi(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount( + + + , + ); + await screen.findByRole('combobox', { name: 'Color' }); + expect(warn).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); }); }); diff --git a/packages/commerce-storefront/src/product-details.tsx b/packages/commerce-storefront/src/product-details.tsx index 16b85578..8b9ceaac 100644 --- a/packages/commerce-storefront/src/product-details.tsx +++ b/packages/commerce-storefront/src/product-details.tsx @@ -4,12 +4,12 @@ import { money } from './api'; import { ProductImage } from './catalog'; import { useCommerce } from './commerce-provider'; import { - cx, + type ProductDetailsPartProps, ProductDetailsRoot, - type ProductPartProps, + partClass, useProductDetailsInternal, } from './product-details-context'; -import { ProductPurchase } from './product-purchase'; +import { ProductDetailsPurchase } from './product-purchase'; /** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */ export function ProductDetails(): ReactElement { @@ -22,34 +22,33 @@ export function ProductDetails(): ReactElement { - +
); } -export function ProductDetailsBackLink({ className }: ProductPartProps): ReactElement { +export function ProductDetailsBackLink({ className }: ProductDetailsPartProps): ReactElement { useProductDetailsInternal('ProductDetailsBackLink'); const { config } = useCommerce(); return ( - - ← Back to shop - +
+ + ← Back to shop + +
); } /** Main image with thumbnails; the selected SKU's images replace the product's when present. */ -export function ProductDetailsGallery({ className }: ProductPartProps): ReactElement { +export function ProductDetailsGallery({ className }: ProductDetailsPartProps): ReactElement { const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal('ProductDetailsGallery'); return ( -
+
@@ -74,21 +73,23 @@ export function ProductDetailsGallery({ className }: ProductPartProps): ReactEle } /** The product name as the page's H1. */ -export function ProductDetailsTitle({ className }: ProductPartProps): ReactElement { +export function ProductDetailsTitle({ className }: ProductDetailsPartProps): ReactElement { const { name } = useProductDetailsInternal('ProductDetailsTitle'); return ( -

{name}

+
+

{name}

+
); } /** The verified SKU's price and compare-at price, or the product's starting price. */ -export function ProductDetailsPrice({ className }: ProductPartProps): ReactElement { +export function ProductDetailsPrice({ className }: ProductDetailsPartProps): ReactElement { const { price, product } = useProductDetailsInternal('ProductDetailsPrice'); const { config } = useCommerce(); return (
{price?.value?.value != null @@ -108,11 +109,13 @@ export function ProductDetailsPrice({ className }: ProductPartProps): ReactEleme } /** The verified SKU's description, or the product's. */ -export function ProductDetailsDescription({ className }: ProductPartProps): ReactElement { +export function ProductDetailsDescription({ className }: ProductDetailsPartProps): ReactElement { const { selectedSku, product } = useProductDetailsInternal('ProductDetailsDescription'); return ( -

- {selectedSku?.description ?? product.description} -

+
+

+ {selectedSku?.description ?? product.description} +

+
); } diff --git a/packages/commerce-storefront/src/product-purchase.tsx b/packages/commerce-storefront/src/product-purchase.tsx index 5c442146..64bade43 100644 --- a/packages/commerce-storefront/src/product-purchase.tsx +++ b/packages/commerce-storefront/src/product-purchase.tsx @@ -1,17 +1,17 @@ -import type { ReactElement } from 'react'; +import { type ReactElement, type ReactNode, useId } from 'react'; import { message } from './api'; import { AddToCartButton, buttonClass, inputClass } from './cart'; import type { SKU } from './catalog-model'; import { - cx, - type ProductPartProps, - type PurchaseStatus, + type ProductDetailsPartProps, + type ProductDetailsStatus, + partClass, useProductDetailsInternal, - useProductOption, + useProductDetailsOption, useSkuOptionRegistration, } from './product-details-context'; -const disabledReasons: Record, string> = { +const disabledReasons: Record, string> = { incomplete: 'Select all options to add this item to your cart.', checking: 'Checking this variant…', 'variant-error': 'Availability for this selection could not be checked.', @@ -19,26 +19,31 @@ const disabledReasons: Record, string> = { 'not-purchasable': 'This product is not currently available to purchase.', }; +// In 0.2.0 the purchase area's option fieldsets and text messages were bare
/

children of +// `space-y-6`, and the package reset's `margin: 0` outranked that zero-specificity rule, so they had no gap. +// Their wrappers now take the gap, so the packaged layout cancels it on those parts to stay visually identical. +const flush = 'mb-0'; + /** The packaged purchase area: options, status, availability, quantity, and add to cart. */ -export function ProductPurchase({ className }: ProductPartProps): ReactElement { - useProductDetailsInternal('ProductPurchase'); +export function ProductDetailsPurchase({ className }: ProductDetailsPartProps): ReactElement { + useProductDetailsInternal('ProductDetailsPurchase'); return ( -

+
- - - - + + + +
); } /** A select for every product attribute, or the SKU list for products without attributes. */ -export function ProductOptions({ className }: ProductPartProps): ReactElement | null { - const { attributes, skuOptions } = useProductDetailsInternal('ProductOptions'); +export function ProductDetailsOptions({ className }: ProductDetailsPartProps): ReactElement | null { + const { attributes, skuOptions } = useProductDetailsInternal('ProductDetailsOptions'); if (attributes.length === 0 && skuOptions.length === 0) return null; return ( -
+
); @@ -46,11 +51,15 @@ export function ProductOptions({ className }: ProductPartProps): ReactElement | // Unwrapped so the packaged layout keeps the controls as direct children of its own spacing container. function OptionControls(): ReactElement { - const { attributes, skuOptions } = useProductDetailsInternal('ProductOptions'); + const { attributes, skuOptions } = useProductDetailsInternal('ProductDetailsOptions'); return ( <> {attributes.map((attribute) => ( - + ))} {skuOptions.length > 0 && } @@ -58,15 +67,16 @@ function OptionControls(): ReactElement { } function SkuOption(): ReactElement { - const { fieldId, skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal('ProductOptions'); + const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal('ProductDetailsOptions'); + const id = useId(); useSkuOptionRegistration(); return (
-
+
+
+ {option.label} + +
+
); } /** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */ -export function ProductPurchaseStatus({ className }: ProductPartProps): ReactElement | null { - const { status, variantError, retryVariant } = useProductDetailsInternal('ProductPurchaseStatus'); +export function ProductDetailsPurchaseStatus({ className }: ProductDetailsPartProps): ReactElement | null { + return ; +} + +function PurchaseStatusMessage({ + className, + textClassName, +}: ProductDetailsPartProps & { textClassName?: string }): ReactElement | null { + const { status, variantError, retryVariant } = useProductDetailsInternal('ProductDetailsPurchaseStatus'); + const part = (children: ReactNode, partClassName?: string): ReactElement => ( +
{children}
+ ); switch (status) { case 'ready': return null; case 'variant-error': - return ( -
+ return part( +

{message(variantError)}

-
+
, ); case 'unavailable': - return ( -

+ return part( +

{disabledReasons[status]} -

+

, + textClassName, ); case 'not-purchasable': - return

{disabledReasons[status]}

; + return part(

{disabledReasons[status]}

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

- {disabledReasons[status]} -

- ); + return part(

{disabledReasons[status]}

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

+ return part( +

{disabledReasons[status]} -

+

, + textClassName, ); } } /** Stock for the verified SKU. Renders nothing until a SKU is verified. */ -export function ProductAvailability({ className }: ProductPartProps): ReactElement | null { - const { selectedSku, availableQuantity } = useProductDetailsInternal('ProductAvailability'); +export function ProductDetailsAvailability({ className }: ProductDetailsPartProps): ReactElement | null { + const { selectedSku, availableQuantity } = useProductDetailsInternal('ProductDetailsAvailability'); if (!selectedSku) return null; return ( -

- {availableQuantity === null - ? 'Available to order' - : availableQuantity === 0 - ? 'This variant is out of stock' - : `${availableQuantity} available`} -

+
+

+ {availableQuantity === null + ? 'Available to order' + : availableQuantity === 0 + ? 'This variant is out of stock' + : `${availableQuantity} available`} +

+
); } /** Quantity for the verified SKU. Renders nothing until a SKU is verified. */ -export function ProductQuantity({ className }: ProductPartProps): ReactElement | null { - const { fieldId, selectedSku, availableQuantity, quantity, setQuantity } = - useProductDetailsInternal('ProductQuantity'); +export function ProductDetailsQuantity({ className }: ProductDetailsPartProps): ReactElement | null { + const { selectedSku, availableQuantity, quantity, setQuantity } = + useProductDetailsInternal('ProductDetailsQuantity'); + const id = useId(); if (!selectedSku) return null; return ( -
-