Reference
Marketplace

Marketplace

Part of @cobuntu/embed-react: headless, data via props, links through your router.

Five headless pieces for a storefront on your own site. They take data and callbacks; none of them fetch. Load with @cobuntu/embed on your server and pass the answers in: Product, Quote and Purchase fit the props as they are.

ComponentRenders
ProductFiltersSearch, the category rail (and the sub-category rail once a category is chosen), a Filters sheet with Type, Price, Sold by, Sort and a price range, Clear, and a result line. Counts come from products.facets.
ProductGrid / ProductCardCards with image, type pill, name, seller and the card price ("From" when variants differ), an empty state and Load more.
ProductViewThe product page: gallery with thumbnails, variant picker, price with member price and member offers, stock and sold out, "Sold by", shipping, quantity, pay-what-you-want amount, Buy now and Add to basket.
BasketLines with quantity and remove, totals from a quote function you pass.
PurchasesListThe member's orders with downloads, invoice and receipt links, and refund.

Browse page

ProductFilters follows EventFilters exactly: one plain state object, controlled or uncontrolled, URL helpers, and pure helpers on a server-safe entry (@cobuntu/embed-react/products).

interface ProductFilterState {
  q: string;
  category: string;      // "" = all
  subCategory: string;   // "" = all
  type: "" | "DIGITAL" | "PHYSICAL";
  sort: "newest" | "price_asc" | "price_desc" | "name";
  price: "" | "free" | "paid";
  seller: "" | "community" | "member";
  minPrice: number | null; // smallest currency unit
  maxPrice: number | null;
}
// app/shop/page.tsx (server component)
import { fromProductSearchParams, toProductQuery, toProductFacetsQuery } from "@cobuntu/embed-react/products";
import { cobuntu } from "@/lib/cobuntu";
import { ShopBrowser } from "./ShopBrowser";
 
export default async function ShopPage({ searchParams }: { searchParams: Promise<Record<string, string>> }) {
  const state = fromProductSearchParams(await searchParams);
  const [list, facets] = await Promise.all([
    cobuntu.products.list({ ...toProductQuery(state), limit: 24 }),
    cobuntu.products.facets(toProductFacetsQuery(state)),
  ]);
  return <ShopBrowser initial={state} list={list} facets={facets} />;
}
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ProductFilters, ProductGrid, toProductSearchParams, type ProductFilterState } from "@cobuntu/embed-react";
 
export function ShopBrowser({ initial, list, facets }) {
  const router = useRouter();
  const [state, setState] = useState<ProductFilterState>(initial);
  const update = (next: ProductFilterState) => {
    setState(next);
    const qs = toProductSearchParams(next, window.location.search).toString();
    router.replace(qs ? `?${qs}` : "?", { scroll: false });
  };
  return (
    <>
      <ProductFilters value={state} onChange={update} facets={facets} resultCount={list.pagination.total} />
      <ProductGrid products={list.products} href={(p) => `/shop/${p.sku ?? p.id}`} />
    </>
  );
}

URL keys: q, categoryId, subCategoryId, type, sort, price, seller, minPrice, maxPrice. The facet counts are narrowed by search and price range only, so choosing a category never zeroes its siblings.

Product page

"use client";
import { ProductView, type ProductSelection } from "@cobuntu/embed-react";
 
export function ProductPage({ product }) {
  const addToBasket = (s: ProductSelection) => basket.add({
    key: `${s.product.id}:${s.tierId ?? ""}`, productId: s.product.id, tierId: s.tierId,
    quantity: s.quantity, customAmount: s.customAmount, name: s.product.name,
    variantName: s.variant?.name, unitPrice: s.variant?.product?.price ?? s.product.price, currency: s.product.currency,
  });
  const buyNow = async (s: ProductSelection) => {
    const res = await fetch("/api/checkout", { method: "POST", body: JSON.stringify({ items: [{ productId: s.product.id, tierId: s.tierId ?? undefined, quantity: s.quantity, customAmount: s.customAmount }] }) });
    const { checkoutUrl } = await res.json();
    if (checkoutUrl) window.location.href = checkoutUrl;
  };
  return <ProductView product={product} onAddToBasket={addToBasket} onBuyNow={buyNow} />;
}

The first available variant is preselected. A sold-out or closed variant disables the buttons; a pay-what-you-want variant needs an amount at or above its minimum. Quantity is shown for physical products (set showQuantity to change that) and is capped at 10 and at remaining. Member prices show only when you fetched the product with the member's session; offers for members show to everyone.

Basket

<Basket
  lines={lines}
  onQuantityChange={(key, qty) => basket.setQuantity(key, qty)}
  onRemove={(key) => basket.remove(key)}
  quote={(items) => fetch("/api/quote", { method: "POST", body: JSON.stringify({ items }) }).then((r) => r.json())}
  onCheckout={(items) => startCheckout(items)}
/>

Your /api/quote route calls checkout.quote({ items }) with the secret key (and the member's session when signed in). The basket re-quotes when the lines change, ignores stale answers, and keeps the last totals if a quote fails (onQuoteError). toBasketItems(lines) gives the same items array for your own calls.

Event tickets are basket lines too. Give the line kind: "ticket", the tier as tierId, and optionally eventId, eventName, eventDate, attendees and requiresApproval. The basket shows the event name, date, tier and the number of attendees, and prices each line from the quote's lines[].index. When the quote returns more than one group, each line says which payment it belongs to ("Payment 1 of 2") and the groups are listed under the totals.

basket.add({
  key: `ticket:${event.slug}:${tier.id}`,
  kind: "ticket",
  tierId: tier.id,
  eventId: event.id,
  eventName: event.name,
  eventDate: event.startDate,
  name: event.name,
  variantName: tier.name,
  quantity: 2,
  unitPrice: tier.price,
  currency: tier.currency,
  attendees: [{}, { name: "Sam Lee", email: "sam@example.com" }],
});

Checkout summary

<CheckoutSummary quote={quote} fallbackSubtotal={localSubtotal} currency="EUR" />

A headless order summary for your checkout page: lines, subtotal, postage, member savings, total, and the payment steps when the basket is paid in more than one checkout. Until the quote lands it shows fallbackSubtotal. Pass lineNames (keyed by line index) to use your own names, or hideLines for totals only.

VAT number

const [taxId, setTaxId] = useState<CbTaxId | null>(null);
<VatInput value={taxId} onChange={setTaxId} locale="en" />
// at checkout: taxId: toTaxId(taxId)

An optional country and VAT number pair. toTaxId returns the pair only when both fields are filled, which is what checkout.create expects. Stripe validates the format at payment; a rejected number is INVALID_TAX_ID.

Paying in steps

const steps = splitBasket(items, quote.groups); // MAIN first
await startCheckout(steps[0].items);           // keep the rest for later

splitBasket(items, groups) takes your items (or lines) and the groups from a quote or a BASKET_NEEDS_SPLIT error and returns { group, items, indexes } per checkout, MAIN first. With no groups it returns the whole basket as one step.

Purchases

<PurchasesList
  purchases={purchases}
  onDownload={async (p, d) => {
    const { url } = await fetch(`/api/download/${p.saleId}/${d.attachmentId}`).then((r) => r.json());
    window.location.href = url;
  }}
  invoiceHref={(p) => `/api/invoice/${p.saleId}`}
  receiptHref={(p) => `/api/receipt/${p.saleId}`}
  onRefund={(p) => openRefundDialog(p)}
/>

Downloads and receipts need the secret key, so each link points at a small route on your server that calls me.purchaseDownload, me.invoice or me.receipt.

Marketplace props

ComponentProps
ProductFiltersvalue / defaultValue, onChange, facets, resultCount, show ({ search?, categories?, type?, sort?, price?, priceRange?, seller? }), currencyDigits (default 2), trailing, categoriesIn ("inline" default, or "sheet" to put the category and sub-category rails at the top of the Filters sheet; the badge and result line still count them), labels (ProductFilterLabels)
ProductCardproduct, href (string or (product) => string, default /marketplace/<sku or id>), renderLink, imageOverlay, locale, labels
ProductGridproducts, every ProductCard prop, imageOverlay(product), empty, onLoadMore, hasMore, loading
ProductViewproduct, defaultVariantId, onVariantChange, onAddToBasket, onBuyNow, showQuantity, busy, sellerExtra, buyFooter, children, renderDescription, locale, labels
Basketlines, onQuantityChange, onRemove, quote, onQuoteError, onCheckout, busy, beforeCheckout, locale, labels
CheckoutSummaryquote, lineNames, fallbackSubtotal, currency, hideLines, locale, labels
VatInputvalue, onChange, countries (default VAT_COUNTRIES), countryName, locale, labels
PurchasesListpurchases, onDownload, invoiceHref, receiptHref, onRefund, renderExtra, empty, locale, labels

labels on these take any subset of MarketplaceLabels.

Marketplace helpers

Helper
toProductQuery(state), toProductFacetsQuery(state)The products.list and products.facets params. A max below the min is dropped.
fromProductSearchParams(params), toProductSearchParams(state, base?)URL sync. Unknown values fall back to defaults.
productFacetsToOptions(facets, category, labels)Option lists with counts, for your own controls.
applyProductFilterChange, activeProductFilterCount, EMPTY_PRODUCT_FILTERS
cardPrice(product){ amount, currency, from }: the cheapest variant, and whether variants differ.
variantPrice, isVariantAvailable, isProductSoldOut, productImage, productGallery, sellerOf, productSlug, formatProductMoney
toBasketItems(lines)Basket lines to checkout items. Ticket lines become { tierId, quantity, customAmount?, attendees? }.
splitBasket(items, groups), orderGroups(groups)The checkouts a basket is paid in, MAIN first.
toTaxId(value), VAT_COUNTRIESA complete { value, country } or undefined; the countries with a VAT number type.

Class map

See the class map conventions.

Product filters

Same structure as the event filters above, on the cb-product-filters block: __row, __search / __search-input / __search-clear, __toggle / __badge, __clear, __rail (data-facet="category | subCategory", data-depth), __pill / __pill-label / __pill-count, __summary / __count, the sheet (__backdrop, __sheet, __sheet-header, __sheet-title, __close, __body, __footer, __reset, __done), __facet (data-facet="type | price | seller | sort | priceRange"), __option / __option-label / __option-count, and the price range __range / __range-field / __range-label / __range-input.

Product card and grid

ClassElementState attributes
cb-product-grid / __list / __item / __more / __emptyGrid, Load more, empty statedata-loading
cb-product-card / __innerThe card linkdata-sold-out, data-type
__media / __img / __type / __sold-outImage, type pill, sold-out badgedata-has-image
__meta / __name / __seller / __priceTextseller: data-seller="community | member"; price: data-free

Product view

ClassElementState attributes
cb-product-viewRootdata-type, data-sold-out
__head, __type, __name, __categoryHead
__gallery, __hero, __hero-img, __thumbs, __thumb, __thumb-imgGallerythumb: data-state
__buyBuy card
__seller, __seller-img, __seller-label, __seller-nameSold bydata-seller
__variants, __variant, __variant-name, __variant-attrs, __variant-price, __variant-sold-outVariant picker (radios)data-state, data-available
__price, __price-amount, __price-member, __price-list, __price-note, __price-pwyw, __offers, __offerPricedata-member; offer: data-mode
__stockStock linedata-state="ok | low | sold-out | closed"
__shipping, __files, __variant-descriptionNotes
__pwyw, __pwyw-label, __pwyw-input, __pwyw-minPay what you wantaria-invalid
__quantity, __qty-btn, __qty-valueQuantity
__actions, __buy-now, __addButtons (with cb-button)disabled
__description, __description-title, __description-textDescription

Basket

ClassElementState attributes
cb-basket / __emptyRootdata-quoting
__lines, __line, __img, __info, __name, __variant, __list-price, __line-total, __removeLinesdata-kind="product | ticket", data-step
__date, __attendees, __held, __stepTicket line details
__quantity, __qty-btn, __qty-valueQuantity
__totals, __rowTotalsdata-row="subtotal | savings | shipping | total"
__groups, __group, __group-total, __group-accountPayment stepsdata-reason
__tax-note, __checkoutFooter

Checkout summary and VAT

ClassElementState attributes
cb-summaryRootaria-busy until the quote
__lines, __line, __name, __qty, __amount, __heldLinesdata-kind="product | ticket"
__totals, __rowTotalsdata-row="subtotal | postage | savings | total"
__groups, __group, __group-label, __group-total, __group-accountPayment stepsdata-reason, data-mode
cb-vat, __field, __label, __country, __valueVAT input

Purchases

ClassElementState attributes
cb-purchases / __emptyRoot list
cb-purchaseOne orderdata-status="ok | refunded | refund-pending | awaiting-approval | shipped | delivered"
__img, __main, __name, __variant, __meta, __date, __qty, __amount, __statusOrder text
__downloads, __downloads-title, __download-list, __download, __download-name, __download-action, __download-leftDownloadsdata-kind="file | link"
__actions, __invoice, __receipt, __refundLinks and refund