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.
| Component | Renders |
|---|---|
ProductFilters | Search, 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 / ProductCard | Cards with image, type pill, name, seller and the card price ("From" when variants differ), an empty state and Load more. |
ProductView | The 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. |
Basket | Lines with quantity and remove, totals from a quote function you pass. |
PurchasesList | The 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 latersplitBasket(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
| Component | Props |
|---|---|
ProductFilters | value / 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) |
ProductCard | product, href (string or (product) => string, default /marketplace/<sku or id>), renderLink, imageOverlay, locale, labels |
ProductGrid | products, every ProductCard prop, imageOverlay(product), empty, onLoadMore, hasMore, loading |
ProductView | product, defaultVariantId, onVariantChange, onAddToBasket, onBuyNow, showQuantity, busy, sellerExtra, buyFooter, children, renderDescription, locale, labels |
Basket | lines, onQuantityChange, onRemove, quote, onQuoteError, onCheckout, busy, beforeCheckout, locale, labels |
CheckoutSummary | quote, lineNames, fallbackSubtotal, currency, hideLines, locale, labels |
VatInput | value, onChange, countries (default VAT_COUNTRIES), countryName, locale, labels |
PurchasesList | purchases, 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_COUNTRIES | A 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
| Class | Element | State attributes |
|---|---|---|
cb-product-grid / __list / __item / __more / __empty | Grid, Load more, empty state | data-loading |
cb-product-card / __inner | The card link | data-sold-out, data-type |
__media / __img / __type / __sold-out | Image, type pill, sold-out badge | data-has-image |
__meta / __name / __seller / __price | Text | seller: data-seller="community | member"; price: data-free |
Product view
| Class | Element | State attributes |
|---|---|---|
cb-product-view | Root | data-type, data-sold-out |
__head, __type, __name, __category | Head | |
__gallery, __hero, __hero-img, __thumbs, __thumb, __thumb-img | Gallery | thumb: data-state |
__buy | Buy card | |
__seller, __seller-img, __seller-label, __seller-name | Sold by | data-seller |
__variants, __variant, __variant-name, __variant-attrs, __variant-price, __variant-sold-out | Variant picker (radios) | data-state, data-available |
__price, __price-amount, __price-member, __price-list, __price-note, __price-pwyw, __offers, __offer | Price | data-member; offer: data-mode |
__stock | Stock line | data-state="ok | low | sold-out | closed" |
__shipping, __files, __variant-description | Notes | |
__pwyw, __pwyw-label, __pwyw-input, __pwyw-min | Pay what you want | aria-invalid |
__quantity, __qty-btn, __qty-value | Quantity | |
__actions, __buy-now, __add | Buttons (with cb-button) | disabled |
__description, __description-title, __description-text | Description |
Basket
| Class | Element | State attributes |
|---|---|---|
cb-basket / __empty | Root | data-quoting |
__lines, __line, __img, __info, __name, __variant, __list-price, __line-total, __remove | Lines | data-kind="product | ticket", data-step |
__date, __attendees, __held, __step | Ticket line details | |
__quantity, __qty-btn, __qty-value | Quantity | |
__totals, __row | Totals | data-row="subtotal | savings | shipping | total" |
__groups, __group, __group-total, __group-account | Payment steps | data-reason |
__tax-note, __checkout | Footer |
Checkout summary and VAT
| Class | Element | State attributes |
|---|---|---|
cb-summary | Root | aria-busy until the quote |
__lines, __line, __name, __qty, __amount, __held | Lines | data-kind="product | ticket" |
__totals, __row | Totals | data-row="subtotal | postage | savings | total" |
__groups, __group, __group-label, __group-total, __group-account | Payment steps | data-reason, data-mode |
cb-vat, __field, __label, __country, __value | VAT input |
Purchases
| Class | Element | State attributes |
|---|---|---|
cb-purchases / __empty | Root list | |
cb-purchase | One order | data-status="ok | refunded | refund-pending | awaiting-approval | shipped | delivered" |
__img, __main, __name, __variant, __meta, __date, __qty, __amount, __status | Order text | |
__downloads, __downloads-title, __download-list, __download, __download-name, __download-action, __download-left | Downloads | data-kind="file | link" |
__actions, __invoice, __receipt, __refund | Links and refund |