Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/composable-product-details.md
Original file line number Diff line number Diff line change
@@ -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 (`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.
70 changes: 68 additions & 2 deletions packages/commerce-storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` |
Expand All @@ -112,6 +113,71 @@ 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example below is labeled "the packaged layout, written out", but it leaves out the grid wrapper and the spacing classes that ProductDetails passes in. Margins moved out of the parts, so anyone who copies the example gets a single column with no space between the parts. Please show the real layout from product-details.tsx, or change the label to something like "a minimal layout".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0a4780b. The README now shows the real layout from product-details.tsx, with the grid and spacing classes. A note says that without them, the parts stack in one column. The test app renders a copy of the example and checks that it is pixel-identical to <ProductDetails />.


```tsx
import {
ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsPrice,
ProductDetailsPurchase, ProductDetailsRoot, ProductDetailsTitle,
} from '@godaddy/gd-commerce-storefront';

<ProductDetailsRoot>
<ProductDetailsBackLink className='mb-8 sm:mb-10' />
<div className='grid items-start gap-8 lg:grid-cols-12 lg:gap-12'>
<ProductDetailsGallery className='min-w-0 lg:col-span-7' />
<div className='min-w-0 lg:col-span-5 lg:py-4'>
<ProductDetailsTitle />
<ProductDetailsPrice className='mt-6' />
<ProductDetailsDescription className='my-8' />
<ProductDetailsPurchase className='border-t border-neutral-200 pt-8' />
</div>
</div>
</ProductDetailsRoot>
```

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 |
| --- | --- |
| `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`. In development builds, if no control is rendered for an attribute the product requires, a console warning names the missing ones. A control that mounts later, such as one in a closed accordion, can trigger it; the product becomes purchasable once the control renders. Products without attributes but with several SKUs need `ProductDetailsOptions` or `ProductDetailsPurchase`. Parts can be rendered more than once, such as in a sticky mobile purchase bar; each copy gets its own element IDs.

Every part accepts `className`, which is added to the part's outer element, a wrapper the package's CSS reset leaves alone. Host classes there control margin, padding, border, color, width and grid placement, including layered utilities such as Tailwind v4's. The packaged layout sets its spacing and grid placement this way. Elements inside the part, such as the title's `<h1>`, 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`.
- `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 = useProductDetailsOption('color');
if (!color) return null;
return (
<div role='group' aria-label={color.label}>
{color.values.map((value) => (
<button key={value.name} type='button' aria-pressed={value.selected} onClick={() => color.select(value.name)}>
{value.label}
</button>
))}
</div>
);
}
```

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.
Expand Down
4 changes: 2 additions & 2 deletions packages/commerce-storefront/src/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
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'),
);
});
Expand Down
30 changes: 29 additions & 1 deletion packages/commerce-storefront/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
type ProductDetailsOptionState,
type ProductDetailsOptionValueState,
type ProductDetailsPartProps,
ProductDetailsRoot,
type ProductDetailsRootProps,
type ProductDetailsState,
type ProductDetailsStatus,
useProductDetails,
useProductDetailsOption,
} from './product-details-context';
export {
ProductDetailsAddToCart,
ProductDetailsAvailability,
ProductDetailsOption,
type ProductDetailsOptionProps,
ProductDetailsOptions,
ProductDetailsPurchase,
ProductDetailsPurchaseStatus,
ProductDetailsQuantity,
} from './product-purchase';
export { CommerceStatus } from './storefront-surface';
Loading
Loading