diff --git a/.changeset/storefront-extensibility.md b/.changeset/storefront-extensibility.md
new file mode 100644
index 000000000..93f8f3f59
--- /dev/null
+++ b/.changeset/storefront-extensibility.md
@@ -0,0 +1,12 @@
+---
+"@godaddy/gd-commerce-storefront": minor
+---
+
+Make the storefront parts more extensible without reimplementing their rules.
+
+- `ProductDetailsRoot` takes a `productId` prop, which falls back to the route's `:productId`, so a product can render in a quick view or outside its route. `urlState={false}` keeps that root's selection out of the page URL, so two roots on one page no longer share a selection. `loading`, `notFound` and `error` replace those states. A root with no product ID now shows the not-found state without requesting `/products/`, and warns in development.
+- `CatalogRoot` and `Catalog` take `pageSize`, which defaults to 6. `CatalogRoot` takes `urlState={false}` for a second catalog on a page. `CatalogEmpty` renders a custom empty state, like `CartEmpty`.
+- `CartButton`, `AddToCartButton`, `ProductDetailsAddToCart`, `ProductCardAction`, `CartCheckout`, `CartItemRemove`, `CartTitle`, `CartDescription` and `ProductDetailsBackLink` take replacement text as children, or a function of the part's state, such as `({ busy }) => …`. The controls keep their behavior, and a replaced label stays the start of the accessible name. `CartItemRemove` now names its line with visually hidden text instead of `aria-label`; its accessible name is unchanged.
+- `CommerceProvider` and `CommerceStorefront` take `locale` for price formatting, which defaults to `en-US`; an invalid tag falls back to it. `locale` only formats; localized UI text will be a separate opt-in prop. `useMoney()` formats prices in that locale for custom parts, `money()` takes an optional locale, and `ProductImage` is exported. `ProductImage` now loads a new `url` after an earlier one failed, instead of keeping the placeholder.
+- `onEvent` reports `item-added`, `quantity-changed`, `item-removed`, `discount-applied`, `checkout-started` and `error` events. A host callback that throws or rejects, including `onItemAdded`, is now logged instead of failing the cart operation and showing its message as a cart error.
+- Every part's outer element has a stable `data-commerce-part` attribute, such as `cart-checkout`, documented as a styling hook. No elements are added or removed.
diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md
index 073b35c0b..7ccfbc6c0 100644
--- a/packages/commerce-storefront/README.md
+++ b/packages/commerce-storefront/README.md
@@ -55,6 +55,8 @@ Use the providers your application already has; do not create another router or
| `productPath` | `/products` | Same-origin prefix for product links; register `${productPath}/:productId` |
| `checkoutSuccessPath` | unset | Same-origin return route; enables checkout UI only when supplied |
| `onItemAdded` | unset | Called with `{ name, quantity, isSample }` after each add, live or sample; use it to confirm adds on a site with a cart page instead of a drawer |
+| `onEvent` | unset | Called with a [`CommerceEvent`](#events) for every cart change and failure, such as for analytics |
+| `locale` | `en-US` | BCP 47 locale for formatting prices, such as `de-DE`; an invalid tag falls back to `en-US` with a development warning. It never selects UI text |
| `theme` | inherited text and transparent wrappers; neutral control palette | CSS custom properties applied to storefront surfaces and the portalled drawer |
Provide root-relative paths without a trailing slash. Enable `checkoutSuccessPath` only after your server supports checkout and validates merchant readiness. Mount a corresponding return page. A redirect back from checkout is **not proof of payment**; that page must obtain authoritative payment status from your server. This package does not provide a payment receipt page or merchant onboarding.
@@ -85,9 +87,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. 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.
+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, and the [styling hooks](#styling-hooks) to style inside a part.
-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.
+Prices follow the provider's `locale`, which defaults to `en-US`. UI text is English. The catalog title and description are configurable through `CatalogHeader`, and many parts take [replacement text](#replacing-text). Full localization is outside this initial API. When it arrives, translated text will be a separate prop that a site opts into; `locale` will keep formatting only, so a site that sets it for prices never gets new wording in a minor release. Replacement text will still take priority over translations.
## Compatibility
@@ -100,17 +102,19 @@ 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. Its `title`, `description`, and `showHeader` props are deprecated; compose `CatalogRoot` instead |
+| `Catalog` | Six products per cursor page, or `pageSize`. 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 |
+| `ProductDetails` | Reads `:productId`; validates URL option selections against catalog results. `ProductDetailsRoot` also takes a `productId` prop |
| `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 |
| `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 |
+| `CartButton` | Opens the shared drawer and displays item count; its children replace the label, and `useCart()` builds a custom one |
+| `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity`; while disabled, its accessible description says why. Its children replace the label |
| `CommerceStatus` | Connection progress/error/retry for custom layouts |
| `useCommerce` | Cart, connection, pending/error state and serialized cart actions |
+| `useMoney` | Formats minor units in the provider's `locale`, like the package's own prices, for custom parts |
+| `ProductImage` | A product image with the package's placeholder for a missing or broken image, for custom parts |
| `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
@@ -295,7 +322,7 @@ export function CartItems({ className, children }: CartItemsProps): ReactElement
Loading your cart…
{option.attribute}: {option.values.join(', ')}
@@ -367,8 +394,9 @@ export function CartItemOptions({ className }: CartPartProps): ReactElement | nu
/** The line total. */
export function CartItemPrice({ className }: CartPartProps): ReactElement {
const { subtotal, currency } = useCartItemInternal('CartItemPrice');
+ const money = useMoney();
return (
- {subtotal === null ? 'Price unavailable' : money(subtotal, currency)}` needs a package margin and padding reset, which would beat host classes here. */}
-
{name}