@godaddy/gd-commerce-storefront 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,11 +55,14 @@ Use the providers your application already has; do not create another router or
55
55
  | `productPath` | `/products` | Same-origin prefix for product links; register `${productPath}/:productId` |
56
56
  | `checkoutSuccessPath` | unset | Same-origin return route; enables checkout UI only when supplied |
57
57
  | `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 |
58
+ | `onEvent` | unset | Called with a [`CommerceEvent`](#events) for every cart change and failure, such as for analytics |
59
+ | `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 |
58
60
  | `theme` | inherited text and transparent wrappers; neutral control palette | CSS custom properties applied to storefront surfaces and the portalled drawer |
61
+ | `getSwatch` | unset | Swatches for attribute values, such as colors. Returns a CSS `background` for a value, or nothing. See [swatches](#swatches) |
59
62
 
60
63
  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.
61
64
 
62
- `GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store, channel, or currency binding changes. The draft-order cart displays its subtotal and explains that shipping, taxes, and discounts are calculated at checkout. The explanation's outer element has the stable `commerce-cart-checkout-adjustments-note` class so applications can hide it when needed. The cart does not create a checkout session or calculate adjustments. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client.
65
+ `GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store, channel, or currency binding changes. The draft-order cart displays its subtotal and a row saying that shipping, taxes and discounts are calculated at checkout. The explanation's outer element has the stable `commerce-cart-checkout-adjustments-note` class so applications can hide it when needed. The cart does not create a checkout session or calculate adjustments. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client.
63
66
 
64
67
  A connection failure leaves the surrounding application and its state mounted. Catalog and product surfaces show the connection error and retry action. Custom integrations can render `CommerceStatus` or inspect `useCommerce().connection`.
65
68
 
@@ -76,6 +79,8 @@ A connection failure leaves the surrounding application and its state mounted. C
76
79
  '--commerce-surface': '#ffffff',
77
80
  '--commerce-text': '#171717',
78
81
  '--commerce-radius': '0.75rem',
82
+ '--commerce-sale': '#b42318',
83
+ '--commerce-on-sale': '#ffffff',
79
84
  }}
80
85
  >
81
86
  {/* Register /collection and /item/:productId in your router. */}
@@ -85,9 +90,9 @@ A connection failure leaves the surrounding application and its state mounted. C
85
90
 
86
91
  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`.
87
92
 
88
- 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.
93
+ 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. Muted text, hairlines, image placeholders and the quieter outlined buttons (cart button, product card actions, pagination) derive from the inherited text color, so they read on a light or dark page without configuration. `--commerce-surface` colors form inputs and the opaque cart drawer, paired with `--commerce-text`; it does not color component wrappers. Error and warning messages bring their own light background. The Sale badge fills with `--commerce-sale` behind `--commerce-on-sale` text, so it reads over an image and on any page; sale prices keep the surrounding text color, with the compare-at price struck through beside them. Images have square corners; `--commerce-radius` rounds buttons, inputs and option buttons. Primary buttons use `--commerce-accent` and are ringed in it on focus, so choose an accent that contrasts with the page. 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.
89
94
 
90
- 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.
95
+ 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.
91
96
 
92
97
  ## Compatibility
93
98
 
@@ -100,17 +105,20 @@ The repository example uses React Router 7. Follow each router version's own Rea
100
105
  | Export | Purpose |
101
106
  | --- | --- |
102
107
  | `CommerceStorefront` | Recommended integration: provider and one drawer |
103
- | `Catalog` | Six products per cursor page. Its `title`, `description`, and `showHeader` props are deprecated; compose `CatalogRoot` instead |
108
+ | `Catalog` | Six products per cursor page, or `pageSize`. Its `title`, `description`, and `showHeader` props are deprecated; compose `CatalogRoot` instead |
104
109
  | `CatalogRoot` and catalog parts | Build a [custom catalog or card layout](#custom-catalog-layouts) with the same rules as `Catalog` |
105
- | `ProductDetails` | Reads `:productId`; validates URL option selections against catalog results |
110
+ | `ProductDetails` | Reads `:productId`; validates URL option selections against catalog results. `ProductDetailsRoot` also takes a `productId` prop |
106
111
  | `ProductDetailsRoot` and product parts | Build a [custom product layout](#custom-product-layouts) with the same rules as `ProductDetails` |
107
112
  | `ProductCard` | Renders one `SKUGroup` with direct add or a product-details link; `ProductCardRoot` and card parts customize it |
113
+ | `RelatedProducts` | Optional: other products from the catalog's first page, for after the product page. See [related products](#related-products) |
108
114
  | `CartDrawer` | The cart dialog; `CommerceStorefront` mounts one |
109
115
  | `CartDrawerRoot`, `CartRoot` and cart parts | Build a [custom drawer or a cart page](#custom-cart-layouts) with the same rules as `CartDrawer` |
110
- | `CartButton` | Opens the shared drawer and displays item count; `useCart()` builds a custom one |
111
- | `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity`; while disabled, its accessible description says why |
116
+ | `CartButton` | Opens the shared drawer: a bag icon with a count badge, named "Cart, 2 items". Its children replace the icon with a label, and `useCart()` builds a custom one |
117
+ | `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity`; while disabled, its accessible description says why. Its children replace the label |
112
118
  | `CommerceStatus` | Connection progress/error/retry for custom layouts |
113
119
  | `useCommerce` | Cart, connection, pending/error state and serialized cart actions |
120
+ | `useMoney` | Formats minor units in the provider's `locale`, like the package's own prices, for custom parts |
121
+ | `ProductImage` | A product image with the package's placeholder for a missing or broken image, for custom parts |
114
122
  | `CommerceProvider` | Lower-level composition when the recommended wrapper does not fit, such as a custom drawer; mount it once |
115
123
 
116
124
  The package exports TypeScript catalog/cart response types and selection/summary helpers for custom product layouts. `useCommerce` actions return `Promise<boolean>`: `false` means an operation failed or its connection became stale. Inspect `error` for active-session failures. Do not automatically retry a failed mutation: the server may have committed it before the response failed. `applyDiscount(code)` is available to custom layouts; the default drawer does not render a promotion form.
@@ -119,43 +127,66 @@ The package exports TypeScript catalog/cart response types and selection/summary
119
127
 
120
128
  ## Custom product layouts
121
129
 
122
- `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:
130
+ `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`, or its `productId` prop, 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:
123
131
 
124
132
  ```tsx
125
133
  import {
126
- ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsPrice,
134
+ ProductDetailsBackLink, ProductDetailsBadge, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsPrice,
127
135
  ProductDetailsPurchase, ProductDetailsRoot, ProductDetailsTitle,
128
136
  } from '@godaddy/gd-commerce-storefront';
129
137
 
130
138
  <ProductDetailsRoot>
131
- <ProductDetailsBackLink className='mb-8 sm:mb-10' />
132
- <div className='grid items-start gap-8 lg:grid-cols-12 lg:gap-12'>
133
- <ProductDetailsGallery className='min-w-0 lg:col-span-7' />
134
- <div className='min-w-0 lg:col-span-5 lg:py-4'>
139
+ <ProductDetailsBackLink className='mb-4 sm:mb-6' />
140
+ <div className='grid items-start gap-8 md:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] lg:gap-12'>
141
+ <div className='relative min-w-0'>
142
+ <ProductDetailsGallery />
143
+ <ProductDetailsBadge className='pointer-events-none absolute left-4 top-4' />
144
+ </div>
145
+ <div className='min-w-0 md:pt-2'>
135
146
  <ProductDetailsTitle />
136
- <ProductDetailsPrice className='mt-6' />
137
- <ProductDetailsDescription className='my-8' />
138
- <ProductDetailsPurchase className='border-t border-neutral-200 pt-8' />
147
+ <ProductDetailsPrice className='mt-2' />
148
+ <ProductDetailsDescription className='mt-6' />
149
+ <ProductDetailsPurchase className='mt-6 border-t border-commerce-line pt-6' />
139
150
  </div>
140
151
  </div>
141
152
  </ProductDetailsRoot>
142
153
  ```
143
154
 
144
- 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.
155
+ 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. `border-commerce-line` and `divide-commerce-line` stand for the package's hairline, the surrounding text color at 14% opacity, which reads on light and dark pages alike; use your own border color in its place.
145
156
 
146
- 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:
157
+ Add your own elements between parts, reorder them, or leave out display parts. `ProductDetailsBadge` shows "Sale" when the verified SKU's compare-at price is above its price, or before then when some SKU's compare-at price is above that SKU's own price, and nothing otherwise. `ProductDetailsPrice` shows "From" and the starting price until a SKU is verified. It strikes through a compare-at price beside it only when the cheapest SKUs all have that compare-at price, so a strike-through never pairs one SKU's price with another's compare-at price. `ProductDetailsPurchase` renders the options and status, then the quantity and add button in one row, then the availability. Replace it with individual parts to place options apart from the add button:
147
158
 
148
159
  | Part | Renders |
149
160
  | --- | --- |
150
- | `ProductDetailsOptions` | A select for every attribute, or the SKU list for products without attributes |
151
- | `ProductDetailsOption` | A select for one attribute, by `attribute` name; nothing when the product lacks it |
152
- | `ProductDetailsPurchaseStatus` | The one message explaining why the selection is not purchasable yet; nothing when ready |
153
- | `ProductDetailsAvailability`, `ProductDetailsQuantity` | Stock and quantity for the verified SKU; nothing before verification. The quantity shows a message when it cannot be added, such as more than the stock |
154
- | `ProductDetailsAddToCart` | Adds the verified SKU and quantity; before a SKU is verified, its accessible description gives the reason |
161
+ | `ProductDetailsOptions` | A control for every attribute, or the SKU list for products without attributes |
162
+ | `ProductDetailsOption` | One attribute, by `attribute` name, as a group of buttons (radios); as swatches when [`getSwatch`](#swatches) gives every value one; or as a select when it has more than eight values. Nothing when the product lacks it |
163
+ | `ProductDetailsPurchaseStatus` | The one message explaining why a full selection is not purchasable, such as an unavailable combination; nothing when ready or before every option is chosen |
164
+ | `ProductDetailsQuantity` | A quantity stepper, capped at the verified SKU's stock and disabled when it is sold out. It shows a message when a typed quantity cannot be added |
165
+ | `ProductDetailsAvailability` | Stock for the verified SKU; nothing before verification |
166
+ | `ProductDetailsAddToCart` | Adds the verified SKU and quantity, labeled with the total, such as "Add to cart · $64.00". Until every option is chosen it names the next one, such as "Select size", and pressing it shows an error on each missing option and focuses the first. While a selection is checked or unavailable, it is disabled and its accessible description gives the reason |
167
+
168
+ Values whose SKUs are all sold out, given the shopper's other selections, are crossed out and announced as "sold out", but stay selectable, so the shopper can change options in any order. This needs every SKU with its attribute values in the product response, which `@godaddy/gd-commerce-server` returns for up to 50 SKUs; for a product with more, no value is marked, and the add button still says "Out of stock" once a sold-out SKU is chosen.
155
169
 
156
170
  Every part is named `ProductDetails*` because it works only inside `ProductDetailsRoot`. `AddToCartButton` is the standalone button for a SKU you already have.
157
171
 
158
- 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.
172
+ The root keeps the selection in the page URL, as `option.<attribute>` and `sku`, so a reload or a shared link keeps it. To show a product outside its route, such as in a quick view or next to another product, pass `productId` and set `urlState={false}`. That root keeps its selection to itself, and it ends when the root unmounts. Give every root but one on a page `urlState={false}`; otherwise the roots share one selection. A root with no `productId` on a route without `:productId` shows the not-found state and, in development builds, warns.
173
+
174
+ ```tsx
175
+ {/* Your own dialog, opened from a card with the product's ID. */}
176
+ <QuickViewDialog open={quickViewId !== null} onClose={() => setQuickViewId(null)}>
177
+ {quickViewId && (
178
+ <ProductDetailsRoot productId={quickViewId} urlState={false} loading={<p role='status'>Loading…</p>}>
179
+ <ProductDetailsTitle />
180
+ <ProductDetailsPrice />
181
+ <ProductDetailsPurchase />
182
+ </ProductDetailsRoot>
183
+ )}
184
+ </QuickViewDialog>
185
+ ```
186
+
187
+ `loading` and `notFound` replace those states. `error` replaces the load failure; as a function, it receives `{ message, retry }`. Replacement content renders inside the root's element, which keeps its `className`, and the error keeps `role='alert'`; a replacement loading message should have its own `role='status'`.
188
+
189
+ 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 image but keeps the quantity. A quantity above the new SKU's stock shows an error under the field and disables the add button. 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.
159
190
 
160
191
  Every part accepts `className`, which is added to the part's outer element, a wrapper the package's CSS reset leaves alone. Host classes there control margin, padding, border, color, width, display, gap and grid placement, including layered utilities such as Tailwind v4's. In `ProductDetailsOptions` and `ProductDetailsPurchase`, each control keeps a 24px bottom margin, even in a host grid. The exception is a class name the package also uses, such as `grid-cols-1` or `p-6`: the package's rule wins, even over the host's responsive variants on the same element, such as `md:grid-cols-4`. The packaged layout sets its spacing and grid placement this way. Elements inside the part, such as the title's `<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.
161
192
 
@@ -174,7 +205,7 @@ A part that renders nothing, such as `ProductDetailsAvailability` before a SKU i
174
205
  Two hooks support custom parts inside the root:
175
206
 
176
207
  - `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`.
177
- - `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.
208
+ - `useProductDetailsOption(attribute)` returns `{ name, label, selected, missing, values, select }` for building swatches or buttons, or `null` when the product has no such attribute. Each value has `name`, `label`, `selected`, `soldOut` and `swatch` (from `getSwatch`, or `null`). `missing` is `true` once the shopper has pressed add without choosing this option, so the control can show an error; mark the control's element with `data-commerce-missing` to have add focus it. `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.
178
209
 
179
210
  ```tsx
180
211
  function ColorSwatches() {
@@ -185,6 +216,7 @@ function ColorSwatches() {
185
216
  {color.values.map((value) => (
186
217
  <button key={value.name} type='button' aria-pressed={value.selected} onClick={() => color.select(value.name)}>
187
218
  {value.label}
219
+ {value.soldOut && ' (sold out)'}
188
220
  </button>
189
221
  ))}
190
222
  </div>
@@ -194,35 +226,64 @@ function ColorSwatches() {
194
226
 
195
227
  Parts are flat named exports, not properties such as `ProductDetails.Title`, so each one is an ordinary module export.
196
228
 
229
+ ### Swatches
230
+
231
+ Swatches are off by default: attribute values have no color or image in the catalog. Pass the provider a `getSwatch` function that returns a CSS `background` for a value, such as `'#3f6352'` or `'url(/swatches/linen.png) center / cover'`. An attribute whose values all get one renders as swatches on the product page, with the value's label as its accessible name, and catalog cards show it as dots. Any other attribute keeps its buttons.
232
+
233
+ ```tsx
234
+ const colors: Record<string, string> = { forest: '#3f6352', black: '#1a1a1a', white: '#f7f7f5' };
235
+
236
+ <CommerceStorefront getSwatch={({ attribute, name }) => (attribute === 'color' ? colors[name] : undefined)}>
237
+ ```
238
+
239
+ ### Related products
240
+
241
+ `RelatedProducts` is optional and separate from `ProductDetails`. Place it where it fits, such as after the product page. It shows packaged cards for the catalog's first products, leaving out the route's `:productId` or its `productId` prop. There is no related-products API, so the products are not chosen for the current one.
242
+
243
+ ```tsx
244
+ <Route
245
+ path='/products/:productId'
246
+ element={
247
+ <>
248
+ <ProductDetails />
249
+ <RelatedProducts className='mt-16 border-t border-commerce-line pt-12' />
250
+ </>
251
+ }
252
+ />
253
+ ```
254
+
255
+ `title` replaces "You may also like", `limit` sets the most products, from 1 to 24 (default 4), and children replace the card, as in `CatalogProducts`. It renders nothing until the products load, when no others are left, if the request fails, or while no store is connected.
256
+
197
257
  ## Custom catalog layouts
198
258
 
199
- `Catalog` is the recommended catalog. When its layout or its cards do not fit, compose the same parts inside `CatalogRoot`. The root loads one cursor page of six products, or the built-in samples when `sampleProducts` is set and no store is connected, and shows the connection state itself. This is `<Catalog sampleProducts />` written out with its packaged card:
259
+ `Catalog` is the recommended catalog. When its layout or its cards do not fit, compose the same parts inside `CatalogRoot`. The root loads one cursor page of six products, or `pageSize` products, or the built-in samples when `sampleProducts` is set and no store is connected, and shows the connection state itself. This is `<Catalog sampleProducts />` written out with its packaged card:
200
260
 
201
261
  ```tsx
202
262
  import {
203
- CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, ProductCardAction,
204
- ProductCardDescription, ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardTitle,
263
+ CatalogHeader, CatalogPagination, CatalogProducts, CatalogRoot, CatalogSummary, ProductCardAction, ProductCardBadge,
264
+ ProductCardImage, ProductCardPrice, ProductCardRoot, ProductCardSwatches, ProductCardTitle,
205
265
  } from '@godaddy/gd-commerce-storefront';
206
266
 
207
267
  <CatalogRoot sampleProducts>
208
- <CatalogHeader className='border-b border-neutral-200 pb-10 pt-4 sm:pb-14 sm:pt-8' />
209
- <CatalogSummary className='border-b border-neutral-200 py-6 sm:py-8' />
210
- <CatalogProducts className='grid grid-cols-1 gap-6 py-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8 lg:py-10'>
211
- <ProductCardRoot className='flex min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200'>
212
- <ProductCardImage />
213
- <div className='flex flex-1 flex-col p-6'>
268
+ <div className='flex flex-wrap items-end justify-between gap-x-4 gap-y-2 pb-6 pt-4 sm:pb-8 sm:pt-8'>
269
+ <CatalogHeader />
270
+ <CatalogSummary />
271
+ </div>
272
+ <CatalogProducts className='grid grid-cols-2 gap-x-4 gap-y-10 sm:gap-x-6 md:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] lg:gap-y-12'>
273
+ <ProductCardRoot className='flex min-w-0 flex-col'>
274
+ <div className='relative'>
275
+ <ProductCardImage />
276
+ <ProductCardBadge className='pointer-events-none absolute left-3 top-3' />
277
+ </div>
278
+ <div className='flex flex-1 flex-col pt-3'>
214
279
  <ProductCardTitle />
215
- <ProductCardDescription className='mt-3' />
216
- <div className='mt-auto pt-6'>
217
- <div className='flex flex-wrap items-end justify-between gap-4 border-t border-neutral-200 pt-5'>
218
- <ProductCardPrice className='min-w-0 flex-1' />
219
- <ProductCardAction className='max-w-full' />
220
- </div>
221
- </div>
280
+ <ProductCardPrice className='mt-0.5' />
281
+ <ProductCardSwatches className='mt-2' />
282
+ <ProductCardAction className='mt-auto pt-4' />
222
283
  </div>
223
284
  </ProductCardRoot>
224
285
  </CatalogProducts>
225
- <CatalogPagination className='border-t border-neutral-200 pt-6' />
286
+ <CatalogPagination className='mt-12 border-t border-commerce-line pt-6' />
226
287
  </CatalogRoot>
227
288
  ```
228
289
 
@@ -233,12 +294,18 @@ As with product layouts, the grid, spacing and border classes come from your own
233
294
  | `CatalogHeader` | The `title` as the page's H1 and a `description`; leave it out when the host page owns the H1 |
234
295
  | `CatalogSummary` | The loading, failure or product-count status line |
235
296
  | `CatalogProducts` | One card per product. Its children are the card, rendered once for each product; without children, the packaged card |
236
- | `CatalogPagination` | First-page and next-page buttons; nothing for samples |
297
+ | `CatalogEmpty` | Only when the store's catalog has no products: "No products available.", or its own children instead. While one is mounted, `CatalogProducts` leaves out its own empty message. Samples are never empty |
298
+ | `CatalogPagination` | First-page and next-page buttons; nothing when there is one page, or for samples |
237
299
  | `ProductCardImage`, `ProductCardTitle` | The first image and the name as an H2, each linked to the product page. With a title in the card, keyboard and screen reader users get only the title link; without one, the image link takes its place once the page is interactive |
238
- | `ProductCardDescription`, `ProductCardPrice` | Up to three lines of description (nothing without one), and the price or price range |
239
- | `ProductCardAction` | Adds the product directly only when it has one SKU and no options; otherwise links to the product page to choose |
300
+ | `ProductCardBadge` | "Sale" when some SKU's compare-at price is above that SKU's price. The catalog list has no SKU prices, so there it shows only when the lowest compare-at price is above the highest price |
301
+ | `ProductCardPrice` | The price or price range. A single price on sale shows its compare-at price struck through; a range does not |
302
+ | `ProductCardSwatches` | Dots for the first attribute whose values all have a [swatch](#swatches); nothing without `getSwatch` |
303
+ | `ProductCardDescription` | Up to two lines of description; nothing without one. The packaged card leaves it out |
304
+ | `ProductCardAction` | Adds the product directly only when it has one SKU and no options; otherwise links to the product page to choose. Its children replace the label; as a function, they receive the `action`: `add`, `choose-options` or `view-product` |
305
+
306
+ `className` on `CatalogProducts` goes on the element whose direct children are the cards, so a host grid places them, and `className` on `ProductCardRoot` goes on each card, so classes such as `col-span-2` work. While products load, that element holds three placeholder cards, so they follow the host's grid. The error with its retry button and, without a `CatalogEmpty`, the empty message render just before it. Card parts take no product, SKU or price props. Inside `CatalogProducts`, `ProductCardRoot` takes no `product` prop either and throws if it gets one, so a card always shows the product it is rendered for. Samples use the same card: they show a placeholder image, no badge, description or links, and add only to the sample cart. Card and catalog parts throw if they are rendered outside their root. Packaged cards fill their grid row, with the price and action at the bottom of each card.
240
307
 
241
- `className` on `CatalogProducts` goes on the element whose direct children are the cards, so a host grid places them, and `className` on `ProductCardRoot` goes on each card, so classes such as `col-span-2` work. While products load, that element holds three placeholder cards, so they follow the host's grid. The error with its retry button and the empty message render just before it. Card parts take no product, SKU or price props. Inside `CatalogProducts`, `ProductCardRoot` takes no `product` prop either and throws if it gets one, so a card always shows the product it is rendered for. Samples use the same card: they show a placeholder image, no description and no links, and add only to the sample cart. Card and catalog parts throw if they are rendered outside their root. Packaged cards fill their grid row, with the price and action at the bottom of each card.
308
+ The root keeps its page cursor in the page URL, as `after`. For a second catalog on the same page, such as a featured row above the main grid, set `urlState={false}` on it so the two do not share one cursor.
242
309
 
243
310
  For a product the host already has, such as a featured product, use `ProductCard` or `ProductCardRoot` with a `product` prop outside the catalog. `useProductCard()` returns read-only `product` (`null` for samples), `name`, `href` (`null` for samples) and `isSample` for building custom card parts. Every part accepts `className` on an outer wrapper that the package's CSS reset leaves alone, with the same limits as [product parts](#custom-product-layouts).
244
311
 
@@ -248,51 +315,59 @@ For a product the host already has, such as a featured product, use `ProductCard
248
315
 
249
316
  ```tsx
250
317
  import {
251
- CartCheckout, CartCheckoutNote, CartClose, CartDescription, CartDrawerRoot, CartEmpty, CartItemImage, CartItemName,
252
- CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems, CartNotices, CartSubtotal,
253
- CartSummary, CartTitle, CommerceProvider,
318
+ CartCheckout, CartCheckoutNote, CartClose, CartContinueShopping, CartDescription, CartDrawerRoot, CartEmpty,
319
+ CartItemImage, CartItemName, CartItemOptions, CartItemPrice, CartItemQuantity, CartItemRemove, CartItemRoot, CartItems,
320
+ CartNotices, CartSubtotal, CartSummary, CartTitle, CommerceProvider, useCart,
254
321
  } from '@godaddy/gd-commerce-storefront';
255
322
 
323
+ // Only the sample cart needs a description: it says nothing can be bought.
324
+ function SampleDescription() {
325
+ return useCart().isSample ? <CartDescription className='mt-0.5' /> : null;
326
+ }
327
+
256
328
  <CommerceProvider>
257
329
  {/* Header and routes */}
258
- <CartDrawerRoot className='flex w-full max-w-lg flex-col bg-white text-neutral-900 shadow-xl'>
259
- <div className='flex items-start justify-between border-b border-neutral-200 p-6'>
260
- <div>
330
+ <CartDrawerRoot className='flex w-full max-w-md flex-col bg-white text-neutral-900 shadow-2xl'>
331
+ <div className='flex items-start justify-between gap-4 border-b border-commerce-line py-3 pl-6 pr-3'>
332
+ <div className='min-w-0 pt-2.5'>
261
333
  <CartTitle />
262
- <CartDescription className='mt-1' />
334
+ <SampleDescription />
263
335
  </div>
264
336
  <CartClose />
265
337
  </div>
266
- <div className='flex-1 overflow-y-auto p-6'>
267
- <CartNotices className='mb-4' />
268
- <CartItems className='divide-y divide-neutral-200'>
269
- <CartItemRoot className='py-5 first:pt-0'>
270
- <div className='flex gap-4'>
271
- <CartItemImage />
272
- <div className='min-w-0 flex-1'>
273
- <CartItemName />
274
- <CartItemOptions />
275
- <CartItemPrice className='mt-1' />
338
+ <div className='flex-1 overflow-y-auto px-6'>
339
+ <CartNotices className='mt-6' />
340
+ <CartItems className='divide-y divide-commerce-line'>
341
+ <CartItemRoot className='flex gap-4 py-5'>
342
+ <CartItemImage className='shrink-0' />
343
+ <div className='flex min-w-0 flex-1 flex-col'>
344
+ <div className='flex items-start justify-between gap-4'>
345
+ <div className='min-w-0'>
346
+ <CartItemName />
347
+ <CartItemOptions />
348
+ </div>
349
+ <CartItemPrice className='shrink-0' />
350
+ </div>
351
+ <div className='mt-3 flex items-center justify-between gap-2'>
352
+ <CartItemQuantity />
353
+ <CartItemRemove />
276
354
  </div>
277
- </div>
278
- <div className='mt-4 flex items-center justify-between gap-2'>
279
- <CartItemQuantity />
280
- <CartItemRemove />
281
355
  </div>
282
356
  </CartItemRoot>
283
357
  </CartItems>
284
- <CartEmpty className='py-16 text-center' />
358
+ <CartEmpty className='py-20 text-center' />
285
359
  </div>
286
- <CartSummary className='border-t border-neutral-200 p-6'>
360
+ <CartSummary className='border-t border-commerce-line px-6 pb-6 pt-4'>
287
361
  <CartSubtotal />
288
- <CartCheckoutNote className='mt-3' />
289
- <CartCheckout />
362
+ <CartCheckoutNote className='mt-1' />
363
+ <CartCheckout className='mt-5' />
364
+ <CartContinueShopping className='mt-2' />
290
365
  </CartSummary>
291
366
  </CartDrawerRoot>
292
367
  </CommerceProvider>
293
368
  ```
294
369
 
295
- `className` on `CartDrawerRoot` goes on the dialog panel, which sits at the right edge at full height. The packaged width, background and column layout are the classes above; without them the panel is only as wide as its content and has no background. Add `mr-auto` to open it from the left. Keep `CartTitle` in a drawer: it names the dialog, and the dialog library logs an error without it. `CartDescription` is optional and describes the dialog when present. Render each of them once per drawer, because they use the dialog's own element IDs.
370
+ `className` on `CartDrawerRoot` goes on the dialog panel, which sits at the right edge at full height. The packaged width, background and column layout are the classes above; without them the panel is only as wide as its content and has no background. Give the panel a background and a text color together, as `bg-white text-neutral-900` do here with `--commerce-surface` and `--commerce-text`, so the parts inside can derive their muted text and lines from it. The packaged drawer also slides in, unless the visitor prefers reduced motion. Add `mr-auto` to open it from the left. Keep `CartTitle` in a drawer: it names the dialog, and the dialog library logs an error without it. `CartDescription` is optional and describes the dialog when present. Render each of them once per drawer, because they use the dialog's own element IDs.
296
371
 
297
372
  For a cart page instead of, or as well as, the drawer, compose the same parts inside `CartRoot` on a route such as `/cart`. It shows the connection state itself, and `CartClose` renders nothing there. If the site has no drawer, use `CommerceProvider` without `CartDrawerRoot` and link to the page instead of rendering `CartButton`. Adds then open nothing, and `useCommerce().open` stays `false`. Confirm adds with the provider's `onItemAdded`, for example with a toast that links to the cart.
298
373
 
@@ -301,10 +376,10 @@ For a cart page instead of, or as well as, the drawer, compose the same parts in
301
376
  <CartRoot className='grid gap-8 lg:grid-cols-3'>
302
377
  <div className='lg:col-span-2'>
303
378
  <CartNotices className='mb-4' />
304
- <CartItems className='divide-y divide-neutral-200' />
379
+ <CartItems className='divide-y divide-commerce-line' />
305
380
  <CartEmpty className='py-16 text-center' />
306
381
  </div>
307
- <CartSummary className='rounded-lg border border-neutral-200 p-6'>
382
+ <CartSummary className='rounded-lg border border-commerce-line p-6'>
308
383
  <CartSubtotal />
309
384
  <CartCheckoutNote className='mt-3' />
310
385
  <CartCheckout className='mt-6' />
@@ -314,16 +389,17 @@ For a cart page instead of, or as well as, the drawer, compose the same parts in
314
389
 
315
390
  | Part | Renders |
316
391
  | --- | --- |
317
- | `CartTitle`, `CartDescription` | "Your cart" as an H2 and one line about it; sample wording before a store is connected |
392
+ | `CartTitle`, `CartDescription` | "Your cart" as an H2, with the item count once there are items, and one line about it; sample wording before a store is connected |
318
393
  | `CartClose` | The drawer's close button; nothing outside the drawer |
319
394
  | `CartNotices` | A storage warning, and a cart error with a refresh button; nothing when there are neither |
320
395
  | `CartItems` | The cart lines as a list. Its child is the item, rendered once for each line; without one, the packaged item. "Loading your cart…" renders just before the list while the saved cart loads |
321
- | `CartItemImage`, `CartItemName`, `CartItemOptions`, `CartItemPrice` | The line's image (nothing without one), name as an H3, selected options (nothing without any) and line total |
396
+ | `CartItemImage`, `CartItemName`, `CartItemOptions`, `CartItemPrice` | The line's image (nothing without one), name as an H3, selected options on one line, such as "Color: Blue · Size: M" (nothing without any), and line total |
322
397
  | `CartItemQuantity`, `CartItemRemove` | Decrease and increase buttons around the quantity, and the remove button |
323
- | `CartEmpty` | Only when the cart is empty: a message and a link to the catalog, or its own children instead |
398
+ | `CartEmpty` | Only when the cart is empty: a bag icon, a message and a link to the catalog, or its own children instead |
324
399
  | `CartSummary` | A container that renders only when the cart has items |
325
- | `CartSubtotal`, `CartCheckoutNote` | The subtotal, and the note that shipping, taxes and discounts come at checkout. The note's outer element keeps the `commerce-cart-checkout-adjustments-note` class |
400
+ | `CartSubtotal`, `CartCheckoutNote` | The subtotal, and a row saying shipping, taxes and discounts are calculated at checkout. The note's outer element keeps the `commerce-cart-checkout-adjustments-note` class |
326
401
  | `CartCheckout` | The checkout button when `checkoutSuccessPath` is set; otherwise a message that checkout is unavailable. Samples never check out |
402
+ | `CartContinueShopping` | "Continue shopping", which closes the drawer, or links to the catalog on a cart page; nothing for an empty cart |
327
403
 
328
404
  `className` on `CartItems` goes on the list element whose direct children are the items, and `className` on `CartItemRoot` goes on each item. The list is a `role='list'` element rather than a `<ul>`, so it has no browser margin or indent for a package rule to reset. Cart and item parts take no item, quantity or price props, and they throw if they are rendered outside their root. In development builds, an item template without a `CartItemRoot` logs a warning, because the list would have no items. `CartItemImage` and `CartItemOptions` render nothing for a line without an image or options, which moves the line's other cells in a host grid. Every cart action goes through the shared provider's queue, whatever the layout, and while no store is connected the parts show the sample cart, which never reaches the store. Every part accepts `className` on an outer wrapper that the package's CSS reset leaves alone, with the same limits as [product parts](#custom-product-layouts).
329
405
 
@@ -332,6 +408,60 @@ Two hooks support custom cart markup:
332
408
  - `useCart()` works anywhere inside `CommerceProvider`. It returns read-only `items`, `itemCount`, `subtotal` (minor units, or `null` when the server has not priced the cart), `currency`, `isSample` and `loading`. Combine `itemCount` with `useCommerce().setOpen(true)` for a custom cart button.
333
409
  - `useCartItem()` returns the line inside `CartItemRoot`: `id`, `name`, `quantity`, `imageUrl`, `options`, `subtotal` and `currency`.
334
410
 
411
+ ## Replacing text
412
+
413
+ The package has one rule for replacing content, so a layout never has to guess:
414
+
415
+ - **A part's own text** is the part's children, such as `<CartTitle>Your bag</CartTitle>`.
416
+ - **A state inside a loaded root**, such as an empty cart or an empty catalog, is its own part that renders only in that state: `CartEmpty` and `CatalogEmpty`. Place it anywhere in the layout.
417
+ - **A state before the root has anything to show**, such as loading, not found or a load failure, is a root prop: `ProductDetailsRoot`'s `loading`, `notFound` and `error`. Like `<Suspense fallback>`, it replaces the root's content, because the root's children render only once its data exists. That is why `useProductDetails()` can always return a product.
418
+
419
+ Parts whose wording a site often changes take replacement content as children. The part keeps its element, accessibility and rules: a replaced checkout label still waits for the cart total and still blocks a second click. As a function, the children receive the part's state, so the label can change with it. Content that is empty, such as `null`, `false`, blank text or an empty fragment, falls back to the package text. The package cannot see what a component of yours renders, so a label component that returns `null` leaves the control unlabeled. Make that choice in the children function instead, which can return `null` to keep the package text.
420
+
421
+ ```tsx
422
+ <CartCheckout>{({ busy }) => (busy ? 'Redirecting…' : 'Pay securely')}</CartCheckout>
423
+ <CartButton>{({ itemCount }) => `Bag (${itemCount})`}</CartButton>
424
+ <ProductDetailsAddToCart>{({ soldOut }) => (soldOut ? 'Sold out' : 'Add to bag')}</ProductDetailsAddToCart>
425
+ ```
426
+
427
+ | Part | Function receives |
428
+ | --- | --- |
429
+ | `CartTitle`, `CartDescription`, `CartContinueShopping` | `{ isSample }` |
430
+ | `CartCheckout`, `CartItemRemove` | `{ busy }` |
431
+ | `CartButton` | `{ itemCount }`. Replaces the icon and badge with a visible label; keep the count in it |
432
+ | `AddToCartButton`, `ProductDetailsAddToCart` | `{ busy, soldOut }`. A `ProductDetailsAddToCart` label also replaces "Select size" and the total |
433
+ | `ProductCardAction` | `{ action, busy, soldOut }` |
434
+ | `ProductDetailsBackLink` | Content only |
435
+
436
+ A replaced label stays the start of the control's accessible name, so voice control users can say what they see. Where the package adds the product name for screen readers, it follows as visually hidden text: `<CartItemRemove>Delete</CartItemRemove>` is named "Delete Mug". Controls without replacement text, such as "Increase quantity of Mug", keep English names.
437
+
438
+ ## Styling hooks
439
+
440
+ Every part's outer element has a `data-commerce-part` attribute with the part's name in kebab case, such as `cart-checkout` or `product-details-option`. Roots have one too, such as `product-details-root`, including while they show the connection, loading or error state. `CartButton` and add-to-cart buttons have `cart-button` and `add-to-cart-button` on the button itself. These names, and the controls' standard state attributes (`disabled`, `aria-busy`, `aria-pressed`, `aria-invalid`, `aria-expanded`), are a stable styling API; utility classes and other markup are not.
441
+
442
+ ```css
443
+ .commerce-storefront [data-commerce-part='cart-checkout'] button { border-radius: 9999px; }
444
+ .commerce-storefront [data-commerce-part='cart-checkout'] button[aria-busy='true'] { opacity: 0.7; }
445
+ .commerce-storefront [data-commerce-part='product-details-option'][data-commerce-attribute='color'] { display: none; }
446
+ ```
447
+
448
+ `ProductDetailsOption` also has `data-commerce-attribute` with its attribute name. Controls inside a part that has more than one have `data-commerce-control`: `decrease` and `increase` in `CartItemQuantity` and `ProductDetailsQuantity`, `choice` on each option radio, and `thumbnail` in `ProductDetailsGallery`. An option the shopper skipped before pressing add has `data-commerce-missing` on its part, and its controls have `aria-invalid`. The package's utilities are unlayered and have the specificity of two classes, such as `.commerce-storefront .w-full`, or three with a state such as `:hover`. To override one, write unlayered CSS that starts with `.commerce-storefront` and a part attribute, and add the same state when the utility has one, as above. Layered host CSS, such as Tailwind v4 utilities, loses to the package's unlayered rules whatever its specificity.
449
+
450
+ ## Events
451
+
452
+ `onEvent` on `CommerceProvider` or `CommerceStorefront` receives every cart change once the server has returned the updated cart, and every cart failure. Sample-cart changes have `isSample: true` and never reach the store.
453
+
454
+ | `type` | Fields |
455
+ | --- | --- |
456
+ | `item-added` | `skuId` (`null` for samples), `name`, `quantity`, `isSample` |
457
+ | `quantity-changed` | `itemId`, `quantity`, `isSample` |
458
+ | `item-removed` | `itemId`, `isSample` |
459
+ | `discount-applied` | `code` |
460
+ | `checkout-started` | `cartId`; sent just before the browser leaves for checkout, so use `navigator.sendBeacon` or a keepalive request |
461
+ | `error` | `operation` (`add`, `change-quantity`, `remove`, `discount`, `refresh` or `checkout`) and the `message` the cart shows |
462
+
463
+ New event types may be added in minor releases, so ignore types you do not handle. A callback that throws or returns a rejected promise, including `onItemAdded`, is logged to the console and does not fail the cart operation. Async callbacks are not awaited, so a slow analytics call never delays the cart.
464
+
335
465
  ## How it works
336
466
 
337
467
  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.