diff --git a/.changeset/storefront-custom-samples.md b/.changeset/storefront-custom-samples.md new file mode 100644 index 00000000..128307cd --- /dev/null +++ b/.changeset/storefront-custom-samples.md @@ -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()`. 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. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 5bed89e2..2a091fff 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -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. */} @@ -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 | @@ -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(); + // 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 ?

{category}

: 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 @@ -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 @@ -531,8 +548,8 @@ A site can mount the same components before it has a connected store: ``` -`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. @@ -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', + }, +]; + + +``` + +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()`, 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 diff --git a/packages/commerce-storefront/src/cart-parts.tsx b/packages/commerce-storefront/src/cart-parts.tsx index e592bfb9..f31f26f1 100644 --- a/packages/commerce-storefront/src/cart-parts.tsx +++ b/packages/commerce-storefront/src/cart-parts.tsx @@ -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. */ @@ -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()`, 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(): CartItemState { + return useCartItemInternal('useCartItem') as CartItemState; } export interface CartItemsProps extends CartPartProps { @@ -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(null); + if (!imageUrl || imageUrl === failedUrl) return null; return (
- + setFailedUrl(imageUrl)} + />
); } diff --git a/packages/commerce-storefront/src/cart-state.ts b/packages/commerce-storefront/src/cart-state.ts index 4a6a6b55..90b90b8c 100644 --- a/packages/commerce-storefront/src/cart-state.ts +++ b/packages/commerce-storefront/src/cart-state.ts @@ -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 { /** Empty when the server returned a line without an ID. Such a line cannot be changed or removed. */ readonly id: string; readonly name: string; @@ -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. */ @@ -41,10 +43,11 @@ export function useCart(): CartState { id: item.key, name: item.name, quantity: item.quantity, - imageUrl: null, + imageUrl: item.sample.imageUrl || null, 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), @@ -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, diff --git a/packages/commerce-storefront/src/catalog.tsx b/packages/commerce-storefront/src/catalog.tsx index dd1d13b4..d0e3c40d 100644 --- a/packages/commerce-storefront/src/catalog.tsx +++ b/packages/commerce-storefront/src/catalog.tsx @@ -23,11 +23,18 @@ import { packagedCardClass, } from './product-card'; import { useRootParams } from './root-params'; -import { sampleCurrency, sampleProducts } from './sample-cart'; +import { + builtInSamples, + type SampleProduct, + sameSamples, + sampleCurrency, + sampleProblem, + useSampleCart, +} from './sample-cart'; import { CommerceStatus, StorefrontSurface } from './storefront-surface'; type CatalogInternal = - | { source: 'sample' } + | { source: 'sample'; samples: readonly SampleProduct[] } | { source: 'live'; products: UseQueryResult; @@ -50,7 +57,7 @@ interface CatalogEmptyInternal { } const CatalogEmptyContext = createContext(null); -const sampleCatalog: CatalogInternal = { source: 'sample' }; +const builtInCatalog: CatalogInternal = { source: 'sample', samples: builtInSamples }; function useCatalogInternal(part: string): CatalogInternal { const context = useContext(CatalogContext); @@ -58,13 +65,43 @@ function useCatalogInternal(part: string): CatalogInternal { return context; } +/** Read-only catalog state for custom catalog parts rendered inside `CatalogRoot`. */ +export interface CatalogState { + /** + * Products on the current page: `0` while loading or after a first load fails. A failed refetch keeps the last page, + * so `status` can be `error` while `count` is above `0`. + */ + readonly count: number; + readonly isSample: boolean; + /** Samples are always `ready`. */ + readonly status: 'loading' | 'error' | 'ready'; +} + +/** Read how many products the catalog shows, and whether they are samples, for a custom summary. */ +export function useCatalog(): CatalogState { + const catalog = useCatalogInternal('useCatalog'); + let count: number; + let status: CatalogState['status'] = 'ready'; + if (catalog.source === 'sample') count = catalog.samples.length; + else { + count = catalog.nodes.length; + if (catalog.products.isPending) status = 'loading'; + else if (catalog.products.isError) status = 'error'; + } + const isSample: boolean = catalog.source === 'sample'; + return useMemo(() => ({ count, isSample, status }), [count, isSample, status]); +} + export interface CatalogRootProps { children: ReactNode; /** Added to the root `
`. */ className?: string; - /** Permit built-in examples only while the host confirms this site is unbound. */ - sampleProducts?: boolean; - /** Products per page, a whole number of 1 or more. Defaults to 6. Samples are always one page of six. */ + /** + * Products to show only while the server reports no connected store (`unbound`): `true` for the six packaged + * samples, or the site's own. Never shown once a store is connected. Defaults to `false`. + */ + sampleProducts?: boolean | readonly SampleProduct[]; + /** Products per page, a whole number of 1 or more. Defaults to 6. Samples are always one page. */ pageSize?: number; /** * Keep the page cursor in the page URL, as `after`, so a reload or a shared link keeps the page. Defaults to @@ -73,7 +110,7 @@ export interface CatalogRootProps { urlState?: boolean; } -/** Loads one cursor page of products, or the built-in samples, and provides them to catalog parts. */ +/** Loads one cursor page of products, or the samples, and provides them to catalog parts. */ export function CatalogRoot({ children, className, @@ -93,6 +130,25 @@ export function CatalogRoot({ const empty = useMemo((): CatalogEmptyInternal => ({ register, custom }), [register, custom]); if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw new Error(`CatalogRoot pageSize must be a whole number of 1 or more, not ${pageSize}.`); + const hostSamples: readonly SampleProduct[] | null = useStableSamples( + Array.isArray(sampleProducts) ? sampleProducts : null, + ); + const problem: string | null = useMemo(() => hostSamples && sampleProblem(hostSamples), [hostSamples]); + // Thrown only while the samples would show. Once a store is connected, a mistake in a list that is no longer shown + // must not take down the live catalog, so development builds log it instead. + if (problem && connection === 'unbound') throw new Error(problem); + useEffect(() => { + if (problem && process.env.NODE_ENV !== 'production') + console.error(`[gd-commerce-storefront] ${problem}`); + }, [problem]); + const { refresh } = useSampleCart(); + useEffect(() => { + if (hostSamples && !problem && connection === 'unbound') refresh(hostSamples); + }, [hostSamples, problem, connection, refresh]); + const sampleCatalog = useMemo( + (): CatalogInternal => (hostSamples ? { source: 'sample', samples: hostSamples } : builtInCatalog), + [hostSamples], + ); let content: ReactElement; if (connection === 'ready') content = ( @@ -122,6 +178,14 @@ export function CatalogRoot({ ); } +/** The previous list while the samples and their fields are unchanged, so an inline list does not rebuild the cards. */ +function useStableSamples(samples: readonly SampleProduct[] | null): readonly SampleProduct[] | null { + const [stable, setStable] = useState(samples); + const same: boolean = stable === samples || (!!stable && !!samples && sameSamples(stable, samples)); + if (!same) setStable(samples); + return same ? stable : samples; +} + function CatalogLoader({ children, className, @@ -221,11 +285,6 @@ function summary(catalog: CatalogInternal): { key: StorefrontMessageKey; values? export const catalogGridClass: string = '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'; -const sampleItems: { key: string; item: ProductCardItem }[] = sampleProducts.map((sample) => ({ - key: sample.key, - item: { source: 'sample', sample }, -})); - const packagedCard: ReactElement = ( @@ -247,12 +306,13 @@ export function CatalogProducts({ className, children }: CatalogProductsProps): // `{custom && …}` with `custom` false still gets a card, not an empty slot per product. const card: ReactNode = hasChildren(children) ? children : packagedCard; const nodes: SKUGroup[] | null = catalog.source === 'live' ? catalog.nodes : null; + const samples: readonly SampleProduct[] | null = catalog.source === 'sample' ? catalog.samples : null; const items = useMemo( (): { key: string; item: ProductCardItem }[] => nodes ? nodes.map((product) => ({ key: product.id ?? '', item: { source: 'live', product } })) - : sampleItems, - [nodes], + : (samples ?? []).map((sample) => ({ key: sample.key, item: { source: 'sample', sample } })), + [nodes, samples], ); const products = catalog.source === 'live' ? catalog.products : null; const { messages } = useCommerce(); @@ -275,7 +335,7 @@ export function CatalogProducts({ className, children }: CatalogProductsProps): {products?.isPending ? [0, 1, 2].map((placeholder: number) => (