Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/storefront-custom-samples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@godaddy/gd-commerce-storefront": minor
---

A site can show its own sample products before a store is connected. `sampleProducts` on `Catalog` and `CatalogRoot` takes an array as well as `true`. Each sample has a `key`, `name` and `price`, and optionally a `compareAtPrice`, `description` and `imageUrl`, such as a file in the site's public folder. The card parts show a sample's image, description, price and Sale badge, and the sample cart keeps each line's image. While no store is connected, `CatalogRoot` throws for an invalid list, such as duplicate keys, a packaged sample's key or a decimal price; once one is connected, development builds log the problem instead and the store's catalog still renders. A cart line shows the current fields of a mounted catalog's sample with the same key. `CartItemImage` renders nothing when its image fails to load. `sampleProducts` still shows samples only while the server reports `unbound`, and `true` still shows the six packaged samples without images.

Custom parts can read the site's own sample fields. `useProductCard()` and `useCartItem()` return `sample`, typed with the site's sample type, such as `useProductCard<MySample>()`. A new `useCatalog()` returns the catalog's `count`, `isSample` and `status` for a custom product count. Card images and their loading placeholders follow a new `--commerce-card-image-aspect` theme property, which defaults to square. The `SampleProduct` and `CatalogState` types are exported. The default design is unchanged.
76 changes: 71 additions & 5 deletions packages/commerce-storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ A connection failure leaves the surrounding application and its state mounted. C
'--commerce-radius': '0.75rem',
'--commerce-sale': '#b42318',
'--commerce-on-sale': '#ffffff',
'--commerce-card-image-aspect': '1 / 1',
}}
>
{/* Register /collection and /item/:productId in your router. */}
Expand All @@ -108,6 +109,8 @@ 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, 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` |
| `useProductCard`, `useCatalog` | Card and catalog state for your own card parts, such as a category line or a product count |
| `SampleProduct` | The type of [your own sample products](#your-own-sample-products), for a template shown before a store is connected |
| `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 |
Expand Down Expand Up @@ -305,11 +308,25 @@ As with product layouts, the grid, spacing and border classes come from your own
| `ProductCardDescription` | Up to two lines of description; nothing without one. The packaged card leaves it out |
| `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` |

`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.
`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 and add only to the sample cart. They have no product page, so their title and image are not links. The packaged samples show a placeholder image and no badge or description; [your own samples](#your-own-sample-products) show their image, description and sale badge. Card images and their loading placeholders are square; set `--commerce-card-image-aspect`, such as `3 / 4`, in the `theme` or on any element around the cards to change that. 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.

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.

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).
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), `sample` (the sample as you passed it, or `null` for a product), `name`, `href` (`null` for samples) and `isSample` for building custom card parts. `useCatalog()` returns the catalog's `count`, `isSample` and `status` (`loading`, `error` or `ready`) inside `CatalogRoot`, for a custom product count.

Elements that are only display, such as a "Bestseller" label, a category line or a "View" link, are your own components, written with these hooks. Write such a component once and read its data where you place it, so the same label works on a card and in the cart:

```tsx
function CategoryLine() {
const { sample, product } = useProductCard<RoutineSample>();
// Samples carry your own fields; live products come from your catalog data.
// The type is not checked, so a sample from another catalog, such as the packaged ones, may lack your fields.
const category = sample ? sample.category : categoryFor(product);
return category ? <p className='eyebrow'>{category}</p> : null;
}
```

Every part accepts `className` on an outer wrapper that the package's CSS reset leaves alone, with the same limits as [product parts](#custom-product-layouts).

## Custom cart layouts

Expand Down Expand Up @@ -408,7 +425,7 @@ For a cart page instead of, or as well as, the drawer, compose the same parts in
Two hooks support custom cart markup:

- `useCart()` works anywhere inside `CommerceProvider`. It returns read-only `items`, `itemCount`, `subtotal` (minor units, or `null` when the server has not priced the cart), `currency`, `isSample` and `loading`. Combine `itemCount` with `useCommerce().setOpen(true)` for a custom cart button.
- `useCartItem()` returns the line inside `CartItemRoot`: `id`, `name`, `quantity`, `imageUrl`, `options`, `subtotal` and `currency`.
- `useCartItem()` returns the line inside `CartItemRoot`: `id`, `name`, `quantity`, `imageUrl`, `options`, `subtotal`, `currency` and `sample` (the sample as it was added, or `null` for a store line).

## Replacing text

Expand Down Expand Up @@ -531,8 +548,8 @@ A site can mount the same components before it has a connected store:
</CommerceStorefront>
```

`Catalog.sampleProducts` defaults to `false`. It allows six built-in sample products
(Product 1–6, $10–$60 USD) **only** when `/api/commerce/config` reports `state: 'unbound'`.
`Catalog.sampleProducts` defaults to `false`. `true` allows six built-in sample products
(Product 1–6, $10–$60 USD, without images) **only** when `/api/commerce/config` reports `state: 'unbound'`.
When a store is connected, the server reports a different state and the components show real
products. You do not need to change props or the component tree. The prop has no effect while
a store is connected. Real empty catalogs and errors never fall back to samples.
Expand Down Expand Up @@ -561,6 +578,55 @@ state for up to 60 seconds before showing the error. Once polling slows to every
failure is already 60 seconds after the last success, so it shows the error at once. Polling
stops when the state is `ready`; focus and explicit retry still refresh configuration.

#### Your own sample products

A template can show its own samples instead, with the same rule. Pass them to `sampleProducts` on
`Catalog` or `CatalogRoot`:

```tsx
interface RoutineSample extends SampleProduct {
category: string;
}

const samples: RoutineSample[] = [
{
key: 'daily-cleanser',
name: 'Daily Cleanser',
price: 3200,
description: 'Gentle, pH-balanced foam that removes without stripping.',
imageUrl: '/images/cleanser.jpg',
category: 'Cleanse',
},
];

<Catalog sampleProducts={samples} />
```

Each sample needs a unique `key`, a `name` and a `price` in minor units of USD. `compareAtPrice`,
`description` and `imageUrl` are optional. `imageUrl` is any URL the browser can load, such as a
file in the site's own public folder; the package does not upload or check it. An image that
fails to load shows the image placeholder on the card and no image in the cart. `CatalogRoot`
throws, while no store is connected, for an empty list, an entry that is not an object, a
duplicate or empty key, a packaged sample's key (`sample-1` to `sample-6`), an empty name, a
`description` or `imageUrl` that is not a string, or a price or compare-at price that is not a
whole number of 0 or more. Once a store is connected the samples are not shown, so development
builds log the problem instead and the store's catalog still renders. A list written inline is
compared by its values, so it does not rebuild the cards when it is unchanged. In a card with
`ProductCardTitle`, a sample's image is hidden from screen readers, which read the name from the
title.

The card parts show a sample's image, description, price, and a Sale badge and struck-through
price when `compareAtPrice` is above `price`. Samples have no product page. Fields of your own,
such as `category`, come back unchanged from `useProductCard().sample` and, once added, from
`useCartItem().sample`. The type you pass, as in `useProductCard<RoutineSample>()`, is not
checked, so where other samples can appear, read your own fields as possibly missing. The sample
cart keeps each sample as it was added, with its image, so the cart still shows it on a page
without the catalog. While a catalog with a sample of the same key is mounted, the cart shows
that sample's current fields, so a price or name loaded later matches the card. The cart lists
packaged samples in catalog order and your own in the order they were added, after them. Once a
store is connected, the same card tree shows the store's products, so build the card for both:
your own parts read `sample` before a store is connected and `product` after.

Use a server that reports connection state before you enable `sampleProducts`. Successful
config responses without `state` still mean ready. A server that does not report `unbound`
never shows samples. See the [server contract](docs/server-api.md) for the host's
Expand Down
23 changes: 17 additions & 6 deletions packages/commerce-storefront/src/cart-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
renderLabel,
usePartRegistry,
} from './part';
import { sampleCurrency, useSampleCart } from './sample-cart';
import { type SampleProduct, sampleCurrency, useSampleCart } from './sample-cart';
import { CommerceStatus, StorefrontSurface } from './storefront-surface';

/** Props shared by every cart part. `className` is added to the part's outer element. */
Expand Down Expand Up @@ -310,9 +310,13 @@ function useCartItemInternal(part: string): CartItemState {
return item;
}

/** Read the line from inside `CartItemRoot`, for custom item parts. */
export function useCartItem(): CartItemState {
return useCartItemInternal('useCartItem');
/**
* Read the line from inside `CartItemRoot`, for custom item parts. Pass the type of the site's samples, such as
* `useCartItem<MySample>()`, to read their own fields from `sample`. The type is not checked: when the cart can hold
* samples from more than one catalog, read the site's own fields as possibly missing.
*/
export function useCartItem<Sample extends SampleProduct = SampleProduct>(): CartItemState<Sample> {
return useCartItemInternal('useCartItem') as CartItemState<Sample>;
}

export interface CartItemsProps extends CartPartProps {
Expand Down Expand Up @@ -383,10 +387,17 @@ export function CartItemRoot({ children, className }: CartItemRootProps): ReactE
/** The line's product image. Renders nothing without one. */
export function CartItemImage({ className }: CartPartProps): ReactElement | null {
const { imageUrl } = useCartItemInternal('CartItemImage');
if (!imageUrl) return null;
// Keyed by URL, like `ProductImage`, so a new URL after a failure is requested again.
const [failedUrl, setFailedUrl] = useState<string | null>(null);
if (!imageUrl || imageUrl === failedUrl) return null;
return (
<div {...partProps('cart-item-image', className)}>
<img src={imageUrl} alt='' className='h-20 w-20 bg-commerce-tint object-cover' />
<img
src={imageUrl}
alt=''
className='h-20 w-20 bg-commerce-tint object-cover'
onError={() => setFailedUrl(imageUrl)}
/>
</div>
);
}
Expand Down
10 changes: 7 additions & 3 deletions packages/commerce-storefront/src/cart-state.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { useCommerce } from './commerce-provider';
import { sampleCurrency, useSampleCart } from './sample-cart';
import { type SampleProduct, sampleCurrency, useSampleCart } from './sample-cart';

/** One cart line, for custom cart parts. */
export interface CartItemState {
export interface CartItemState<Sample extends SampleProduct = SampleProduct> {
/** Empty when the server returned a line without an ID. Such a line cannot be changed or removed. */
readonly id: string;
readonly name: string;
Expand All @@ -13,6 +13,8 @@ export interface CartItemState {
/** The line total in minor units, or `null` when the server did not price it. */
readonly subtotal: number | null;
readonly currency: string;
/** The sample as it was added, with the site's own fields. `null` for a line in the connected store's cart. */
readonly sample: Sample | null;
}

/** Read-only cart contents: the connected store's cart, or the sample cart while no store is connected. */
Expand Down Expand Up @@ -41,10 +43,11 @@ export function useCart(): CartState {
id: item.key,
name: item.name,
quantity: item.quantity,
imageUrl: null,
imageUrl: item.sample.imageUrl || null,
Comment thread
pbennett1-godaddy marked this conversation as resolved.
Resolved
options: [],
subtotal: item.price * item.quantity,
currency: sampleCurrency,
sample: item.sample,
})),
itemCount: sampleItems.reduce((total, item) => total + item.quantity, 0),
subtotal: sampleItems.reduce((sum, item) => sum + item.price * item.quantity, 0),
Expand All @@ -67,6 +70,7 @@ export function useCart(): CartState {
})),
subtotal: typeof item.totals?.subTotal?.value === 'number' ? item.totals.subTotal.value : null,
currency: item.totals?.subTotal?.currencyCode ?? currency,
sample: null,
})),
itemCount: lines.reduce((total, item) => total + (item.quantity ?? 0), 0),
subtotal: typeof subtotal === 'number' ? subtotal : null,
Expand Down
Loading
Loading