@doany-ai/catalog
What a business sells, on its site: hooks that read the catalog through the
SDK client given to @doany-ai/app-core's <DoanyProvider>, and default
components a site can restyle through the theme, adjust through props, or
replace while keeping the hooks.
The site only reads the catalog. What is published, purchasable and at what price is the platform's to decide; the components show what the API answers. A visitor only ever sees published items; a site lists its own items and those every site of the business sells. Items are added and put on sale by the agent's business tools, never from the site's code.
Needs @doany-ai/app-core (DoanyProvider) and a client from
@doany-ai/sdk 0.3.0-alpha.0 or later. It ships no CSS: the site's Tailwind
build scans it (see @doany-ai/app-core).
import { CatalogGrid, CatalogItemDetail } from "@doany-ai/catalog";
import { BuyButton } from "@doany-ai/commerce";
<CatalogGrid
params={{ purchasable: true }}
hrefFor={(item) => `/services/${item.id}`}
renderLink={({ href, className, children }) => <Link to={href} className={className}>{children}</Link>}
/>
<CatalogItemDetail
itemId={id}
actions={(variant, item) => variant && item.purchasable && <BuyButton items={[{ catalog_item_variant_id: variant.id }]}>Order now</BuyButton>}
/>
Records
CatalogItem:id,kind(service/physical/digital/plan/bundle),status(draft/active/paused/archived),name,description,category,image_url,service_mode(booking/order/inquiry, ornullwhen unclassified), optionalpayment_requirement(none/full_online/null),direct_purchase_enabled,purchasable(the API decides website eligibility),sort_order,variants(in display order). An item that was a product of a site built before the catalog also carrieslegacy_id(the id that site knew it by) andattributes(the fields the site kept on it beside its name and price, such asslugorcolor, as it kept them). Both are read only, and{}/nullon an item made in the catalog.CatalogVariant:id,name,price_amount(integer, smallest currency unit;null= no price,0= free), optionalcompare_at_amount(display-only reference price in the same currency),currency,duration_minutes,is_default(preselected),status,sku,sort_order.
Booking uses Calendar; Inquiry requires a confirmed quote. Their direct purchase is
fixed off. Order services have direct purchase fixed on, but charged website Orders
currently require full_online; none is available for merchant-entered orders.
Zero is free and null means no price. Follow the API's purchasable result.
Filter explicit Order services with params={{ service_mode: "order", purchasable: true }}
or useCatalogItems({ service_mode: "order" }). A service inquiry form itself creates
no order or appointment.
Components
Every component takes locale (money; default: the browser's), labels (a
part of CatalogLabels, see below) and className.
CatalogGrid
Cards in a grid, with loading, empty and error states and "Load more".
| Prop | Type | |
|---|---|---|
params | { purchasable?, category?, kind?, type?, service_mode?, q?, sort?, limit? } | which items. sort: sort_order (default), name, created_at, updated_at, - in front for descending. limit 1–200, default 50 |
hrefFor | (item) => string | each card's link |
renderLink | ({ href, className, children }) => ReactNode | how a link is rendered; a plain <a> by default. Pass the router's Link to navigate in the site |
onSelect | (item) => void | makes each card a button instead |
renderItem | (item) => ReactNode | replaces the default card |
layout | "cards" | "list" | default cards: capped, centered tiles with automatic columns. list: one full-width column, including loading placeholders; no 384px tile cap or small-count cap |
footer | ReactNode | (item) => ReactNode | under each card's price |
showDescription | boolean | |
appearance | "default" | "editorial" | "discovery" | "course" | default: compact cards. Editorial: landscape media and headings. Discovery: large square covers, borderless layout, full titles/outcomes and a prominent navigation CTA; Course: 16:9 covers, centered titles and descriptions, compact centered CTA, no price or category by default; the package owns all layouts |
showPrice | boolean | default true; hide displayed amounts without changing API eligibility or selected variants |
media | ReactNode | (item) => ReactNode | replace each card's image with configured site media |
mediaOverlay | ReactNode | (item) => ReactNode | independent preview control over the cover, outside the navigation link/button |
formatLabel | ReactNode | (item) => ReactNode | offering format such as Online course or 1:1 coaching, separate from the topic; omit in a list already dedicated to one format |
accessLabel | ReactNode | (item) => ReactNode | supplied access/membership note; remains visible with showPrice={false}. Presentation only, never an entitlement calculation |
eyebrow | ReactNode | (item) => ReactNode | replace the category line |
actionLabel | string | navigation cue inside each linked/selectable card; discovery uses it as the CTA text (defaults to labels.viewItem). It is not a purchase action |
empty | ReactNode | shown when nothing is for sale. Default: the empty label |
For a service price row or a full-width feature, select layout="list".
renderItem alone deliberately keeps the existing card layout. Make the
custom row's own columns respond to its available width, not the screen:
<CatalogGrid layout="list" renderItem={(item) => (
<article className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))] gap-6 [&>*]:min-w-0">
<div><h3>{item.name}</h3><p>{item.description}</p></div>
<div><Price item={item} /><Button wrap asChild><Link to={`/services/${item.id}`}>Explore this service</Link></Button></div>
</article>
)} />
Price is from @doany-ai/catalog, Button from
@doany-ai/app-core/ui/button, and Link from the site's router. Use the same
responsive column definition for a price table's header and rows; on narrow
containers stack the cells and show their labels. Do not hide overflow to
conceal clipped text or actions.
CatalogItemCard
Image, name, category, price (From … when variants differ) and duration.
| Prop | Type | |
|---|---|---|
item | CatalogItem | required |
href | string | makes the whole card a link; in discovery, only the explicit CTA is the link |
onSelect | (item) => void | makes the whole card a button; in discovery, only the explicit CTA is the button |
renderLink, footer, showDescription | as CatalogGrid | |
appearance, showPrice, media, mediaOverlay, formatLabel, accessLabel, eyebrow, actionLabel | as CatalogGrid |
media, eyebrow, formatLabel and accessLabel sit inside the card-wide link or
button in default/editorial appearance. Keep them noninteractive (an image or muted
preview video without controls). A separate preview control belongs in mediaOverlay;
additional play/purchase controls can use footer. Both slots remain outside navigation.
Default and editorial overlays align to the package's 4:3 and 16:10 media areas; custom
media should keep those proportions. Discovery uses a square cover and a separate CTA,
so a preview button can never activate the course link. Its CTA includes the item name
for assistive technology. An interactive slot needs its own accessible label.
Media config and page copy come from the site; the reusable card layout stays
here. These slots do not change product classification, access or purchase rules.
<CatalogGrid
appearance="editorial"
showPrice={false}
hrefFor={(item) => `/collections/${item.id}`}
actionLabel="Explore collection"
media={(item) => <img src={posters[item.id]} alt={item.name} />}
footer={(item) => <button onClick={() => openPreview(item.id)}>Watch preview</button>}
/>
For course discovery, use appearance="course": a 16:9 cover, centered course title and description, and a compact CTA. Show a
clear link to the detail page. Hide prices in this collection explicitly; detail
offers retain their prices. Use eyebrow="" to omit a redundant topic line, and
formatLabel only where the context needs it. Neither this appearance nor hidden
prices changes the generic Catalog default or decides whether an item is purchasable.
<CatalogGrid
appearance="course"
showPrice={false}
eyebrow=""
actionLabel="View course"
hrefFor={(item) => `/courses/${item.id}`}
media={(item) => <img src={posters[item.id]} alt="" />}
mediaOverlay={(item) => <button onClick={() => openPreview(item.id)}>Preview<span className="sr-only"> {item.name}</span></button>}
accessLabel={(item) => accessNotes[item.id]}
/>
Access notes are supplied by the site from its authorized projection; the component does not infer membership from a product type, price, or browser-local state.
CatalogItemDetail
An item's page: image, name, description, the variant picker and the price of the chosen variant.
| Prop | Type | |
|---|---|---|
itemId | string | read by id… |
item | CatalogItem | …or shown as given |
actions | (variant | null, item) => ReactNode | under the options: what to do with the chosen variant, e.g. a BuyButton |
variantId, onVariantChange | string | null, (variantId) => void | optional controlled choice for shared hero/sidebar/mobile purchase placements; omit to keep local selection. Missing or inactive IDs fall back through the package's default-variant rule |
notFound | ReactNode | the item does not exist or is not published. Default: the notAvailable label |
appearance | "default" | "editorial" | editorial uses landscape media and a prominent title, with the same query/selection/actions |
media | ReactNode | replace the item's image; configured by the site |
showMedia | boolean | default true; false removes the entire media column (including custom media and the loading placeholder), for a compact summary |
headingLevel | 1 | 2 | 3 | default 1; match the page's heading hierarchy when the item summary sits below an existing page heading |
eyebrow | ReactNode | replace the category line |
showPrice | boolean | default true; when false, hide the chosen price and picker amounts; options and action arguments remain intact |
For a purchase aside below a site's existing hero <h1>, use
<CatalogItemDetail itemId={id} headingLevel={2} showMedia={false} actions={...} />.
The item query, priced-variant selection and action inputs remain the same;
there is no empty image column. The detail props example below runs this
composition with a second-level heading.
VariantPicker
The priced active variants as a radio group; nothing for an item sold one way.
| Prop | Type | |
|---|---|---|
variants | CatalogVariant[] | required |
value | string | null | the chosen variant's id |
onChange | (variantId) => void | required |
includeUnpriced | boolean | also offer variants without a price (they cannot be ordered). Default false |
label | string | for screen readers; wins over options |
showPrice | boolean | default true; presentation only, does not enable unpriced choices |
Price
variant (one price) or item (one price, or From … when its variants
differ); fallback when there is no price (default: nothing).
Hooks
| Hook | Returns |
|---|---|
useCatalogItems(params) | the site's items, a page at a time, sort_order first: items (all pages read so far), isLoading, isError, hasNextPage, fetchNextPage(). params as CatalogGrid |
useCatalogItem(itemId) | a query; data is the item with its active variants |
useVariantSelection(item, { value?, onChange? }) | { variant, select(variantId) }: starts on the item's default variant, follows a new item; an id that is not the item's falls back to the default. Optional controlled input shares one choice across multiple placements |
Display helpers the components use: itemPriceLabel(item, locale?, words?),
variantPriceLabel(variant, locale?, words?) (text, or null when there is no
price), formatDuration(minutes) (1 h 30 min), pricedVariants(item),
defaultVariant(item). catalogKeys are the React Query keys, for
invalidating.
Labels
Every word the components show is in CatalogLabels. A component takes a part
of it and keeps the defaults for the rest, and passes it on to the ones inside
it (a grid to its cards, an item page to its picker). defaultCatalogLabels
holds the defaults; catalogLabels(partial) merges.
| Key | Default |
|---|---|
free | Free |
fromPrice | (price) => "From " + price |
duration | (minutes) => "45 min", "1 h 30 min" |
options | Options (the picker, for screen readers) |
empty | Nothing here yet. |
loadError | The catalog could not be loaded. (unless set, the platform's own message is shown) |
tryAgain | Try again |
loadMore | Load more |
loadingMore | Loading… |
notAvailable | This item is not available. |
viewItem | View details (discovery CTA; explicit actionLabel wins) |
Customizing
Stop at the first level that does what is needed:
-
CSS: the theme variables on
:root(see@doany-ai/app-core). -
Props: the slots and props above, and
labelsfor every word:<CatalogGrid labels={{ loadMore: "Show more", free: "Complimentary", duration: (m) => `${m} minutes` }} /> -
Hooks: build the list or the item page on
useCatalogItems,useCatalogItemanduseVariantSelection, withitemPriceLabel/variantPriceLabelfor the prices.
Never edit this package under node_modules or copy its source into the site.
Four runnable usages
The official examples ship in @doany-ai/catalog/examples. Each changed
component has the four usages: grid, card, detail, and picker.
import { CatalogExamples } from "@doany-ai/catalog/examples";
<CatalogExamples component="grid" mode="default" />
<CatalogExamples component="grid" mode="css" />
<CatalogExamples component="grid" mode="props" />
<CatalogExamples component="grid" mode="hook" />
default: the actual package component and its query/selection behavior.css: identical component props with a scoped set of theme variables. These examples contain no portals; a real site's global theme belongs on:root.props: package-owned editorial appearance, labels/slots and price visibility.hook: explicit custom markup usinguseCatalogItems,useCatalogItemanduseVariantSelection; formatting still uses the package's price helpers.
The same four card/grid usages can demonstrate the discovery presentation:
<CatalogExamples component="card" appearance="course" mode="default" />
<CatalogExamples component="card" appearance="course" mode="css" />
<CatalogExamples component="card" appearance="course" mode="props" />
<CatalogExamples component="card" appearance="course" mode="hook" />
Default and CSS-only have identical component props and records. Props adds an explicit format/access note and custom CTA; hook-only builds the interface from the real catalog query. Every discovery example has separate Preview and View course actions: Preview reports the selected local sample without navigating or claiming to play a trailer. The caller supplies actual media and player behavior.
onSelect(item) can observe selections. Every example has its own local
DoanyProvider and query cache. The exported catalogExampleItems and
createCatalogExampleClient(items?, pageSize = 2) also let a preview compose
package components in one provider. The client answers local catalog list/get,
supports the shown filters, name sort and pagination, and rejects every
account/upload/mutation capability with EXAMPLE_ONLY. It never calls the
network. These illustrative digital records do not establish a Course API,
authorization, subscription, download-delivery or checkout contract. Production
pages use the site's real SDK client instead of this example adapter.
Develop
npm run typecheck includes tests/sdk-contract.types.ts: the real
@doany-ai/sdk client (from ../doany-sdk) must satisfy the client type this
package calls, so a changed SDK contract fails here, not on a site.
CatalogItemDetail appearance="summary" uses a compact heading and hides the long description by default for purchase sidebars and closing actions. showDescription can override that presentation; option selection, actual prices, and actions(variant, item) keep the same behavior. The official props example demonstrates this appearance.
Optional original price
Price and CatalogItemDetail display a struck-through compare_at_amount supplied
by Catalog beside the current price_amount. Missing/null reference prices render
only the current price. An item price range has no reference price unless every
priced variant shares both amounts and currency. Selecting another variant updates
both amounts. showCompareAt={false} hides the reference; labels.currentPrice and
labels.originalPrice customize screen-reader wording. Orders and checkout always
use price_amount; the reference amount is never a charge or client discount.
All four usages are runnable with <CatalogExamples component="price" mode={mode} />:
default, theme-only css, wording props, and hook using useCatalogItem,
useVariantSelection, variantPriceLabel and compareAtPriceLabel with custom markup.