Bundles & Cross-sell
Product bundles, related products, and upsells
Bundles
A bundle is a fixed set of products with an explicit offer in each enabled currency. Read it on the server in the same visitor context as the product catalog:
const {data, error} = await client.catalog.getBundles({
locale: 'cs', currency: 'CZK', country: 'CZ',
});
// Render an error with retry when error is present; only data.items.length === 0
// means there are no active bundles.
const result = await client.catalog.getBundle('starter-pack', {
locale: 'cs', currency: 'CZK', country: 'CZ',
});
// Only a 404 means the bundle does not exist. Do not turn an outage into a 404.useBundles(options) and useBundle(slug, options) accept the same locale,
currency and country, alongside enabled and server-provided initialData.
The hooks wait for the provider to restore the visitor session. Resolve the
customer and X-Cart-Session on the server before fetching a bundle. Its purchase
range depends on the current basket, so list and detail responses are private,
no-store, and vary by cart session, authorization and API key. Preserve this
behavior through a storefront proxy or CDN.
| Field | Rendering rule |
|---|---|
bundlePrice, currency | Render only a non-null amount in this currency. Null never means free. |
priceHidden | Show a sign-in message. Suppress the price and savings. |
isPurchasable, unavailableReason | Disable purchase and explain PRICE_HIDDEN, CURRENCY_UNAVAILABLE, QUANTITY_UNAVAILABLE or ITEM_UNAVAILABLE. |
activeCurrencies | Explicitly enabled offers. Missing bundle prices are not inferred with FX. |
items, itemsSum | Localized component names and current comparable prices in the requested currency. A missing comparable price makes the sum null. |
savings, savingsPercent | Compare with those component prices, including their customer price lists and discounts. Render only a known positive saving. |
quantityRules | Seed the native input with minimum, step and nullable maximum. These are additional complete sets allowed for this visitor's current basket. Null rules mean no valid quantity. |
minQuantity, maxQuantity | Merchant limits for the resulting bundle line; use only as a fallback for older servers without quantityRules. |
stockLimit, soldCount | Lifetime quota metadata. The authoritative purchase range already accounts for this and sets in the current basket. |
List and detail respect the sale window, shop/domain visibility, active currencies and component availability. The fixed bundle price is independent of customer discounts on the separate components; those discounts affect the comparison, not the fixed offer. Sanitize merchant-authored descriptions before rendering their HTML, and turn them into plain text for metadata.
Add a bundle to the cart
const {data: bundle, error} = await client.catalog.getBundle('starter-pack');
if (!error && bundle?.isPurchasable && bundle.bundlePrice != null) {
const quantity = bundle.quantityRules?.minimum ?? bundle.minQuantity;
const result = await client.cart.addBundle({slug: bundle.slug}, quantity);
// Surface result.error. Availability or quantity rules may have changed.
}The requested amount is the number of complete bundles. Adding again increments
the existing line, so validate the resulting quantity and handle an API rejection.
Use cart.updateBundleQuantity(lineId, quantity) and cart.removeBundle(lineId)
with the cart bundle line ID. Use line.quantityControls.decreaseTo and
increaseTo as exact resulting quantities; a null target disables that direction.
Adding one set locally can violate a component step. The server accounts for
ordinary rows, other bundles, shared stock and merchant limits. A nullable catalog
quantityRules.maximum does not disclose hidden stock counts; writes still check
availability. Render the cart's authoritative quantity and
price after a mutation. SSR templates should supply real form actions and put
cart contents in the first HTML, then enhance them on the client.
After a cart change, fetch the bundle offer again in the same visitor context.
useCart refreshes mounted useBundle / useBundles queries after add, quantity,
remove, clear and merge; useAuth refreshes them after authentication changes.
In React use the bundle actions on useCart (SDK 2.10.0+):
const {cart, isEmpty, addBundle, updateBundleQuantity, removeBundle} = useCart();
await addBundle({slug: bundle.slug}, quantity);
const line = cart?.bundleLines?.[0];
if (line?.quantityControls.increaseTo != null) await updateBundleQuantity(line.id, line.quantityControls.increaseTo);Each action stores the complete server cart (bundle price, units inside the
bundle, totalsAvailable), also on a useCart({enabled: false}) instance,
persists the first anonymous session and refreshes the bundle ranges. A cart
written through the client directly (client.cart.addBundle, server code that
shares the client) is adopted from the cart:updated event the same way. Server
actions should still revalidate the rendered route.
Checkout and quota changes
Use the checkout preview and submit its previewToken with the confirmed order.
The server resolves the current bundle price in the cart currency again, checks
the serving domain and component availability, and applies product quantity
limits to the combined standalone and bundled units. A changed offer invalidates
a previously confirmed preview. Show the updated total and ask the shopper to
review it using the existing checkout flow.
Order component snapshots use localized names and current comparison prices. Amounts are allocated in cents, including rounding remainders, and each component uses its own applicable tax rate. A fixed bundle offer remains separate from customer discounts on its comparison products.
Unpaid orders reserve the lifetime quota during order creation. Concurrent orders cannot claim the same remaining units. Cancellation or a terminal refunded order releases the quota once; reopening claims it again and can fail when another order has consumed the remainder. A partial return does not itself release a complete bundle. Templates must handle these API errors and refresh availability.
Cross-sell
Get related, upsell, and cross-sell products for a specific product. Pass an
optional locale and currency so the cards come back localized and priced in
the currency you display:
const result = await client.catalog.getCrossSell('wireless-headphones', {
locale: 'cs',
currency: 'CZK',
});Response
{
related: CrossSellItem[], // Similar products (podobné produkty)
upsell: CrossSellItem[], // Higher-value alternatives (dražší varianta)
crossSell: CrossSellItem[], // Complementary products (doporučené k nákupu)
}Each CrossSellItem carries productId, slug, name (localized), sku,
price and compareAtPrice (in currency), imageUrl, stockCached and
inStock. The price already includes any price-list override for the logged-in
customer, so you can render the same card component you use for getFeatured
and getProductGroup.
Aktuální nabídka v košíku
Košík při načtení znovu vyhodnotí cenu balíčku v měně nákupu. Změnu ukazuje
příznak priceChanged; minimum, maximum a zbývající kvóta omezují ovládání množství.
Historický název pole bundlePriceSnapshot v košíkové API odpovědi označuje tuto
aktuální nabídku. Uložené snapshoty původního košíku a objednávek se čtením nemění.
Cenu rozdělujeme mezi komponenty poměrně k jejich aktuálním cenám. Každá komponenta zachová svoji sazbu DPH a její přidělená část vstupuje do cílení slevového kódu. Košík znovu kontroluje platnost a podmínky kódu po změně nabídky nebo obsahu. Šablona má zobrazit chybu změny množství přímo u příslušného řádku.
Nedostupný balíček v košíku (SDK 2.0+)
Změna dostupnosti nezahodí řádek košíku. isPurchasable: false a
unavailableReason vyžadují vysvětlení a funkční odebrání. Důvody jsou
CURRENCY_UNAVAILABLE, OFFER_UNAVAILABLE, ITEM_UNAVAILABLE a
QUANTITY_UNAVAILABLE. Poslední stav lze podle aktuálních pravidel opravit
změnou množství. Chyba mutace zůstane přímo u řádku.
Při cart.totalsAvailable === false skryjte částky souhrnu i mini košíku a
zablokujte vstup do checkoutu. Číselné mezisoučty nejsou v tomto stavu nabídkou
k zaplacení. Neexistující měnová nabídka vrací bundlePriceSnapshot: null;
neformátujte ji jako nulu. Jde o změnu typového kontraktu proti SDK 1.x.
První anonymní cart.addBundle vytvoří návštěvnický košík a SDK si uloží vrácený
sessionToken. Serverová integrace jej musí uchovat v cookie pro další požadavky.