Behio Storefront SDK
Catalog & Products

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.

FieldRendering rule
bundlePrice, currencyRender only a non-null amount in this currency. Null never means free.
priceHiddenShow a sign-in message. Suppress the price and savings.
isPurchasable, unavailableReasonDisable purchase and explain PRICE_HIDDEN, CURRENCY_UNAVAILABLE, QUANTITY_UNAVAILABLE or ITEM_UNAVAILABLE.
activeCurrenciesExplicitly enabled offers. Missing bundle prices are not inferred with FX.
items, itemsSumLocalized component names and current comparable prices in the requested currency. A missing comparable price makes the sum null.
savings, savingsPercentCompare with those component prices, including their customer price lists and discounts. Render only a known positive saving.
quantityRulesSeed 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, maxQuantityMerchant limits for the resulting bundle line; use only as a fallback for older servers without quantityRules.
stockLimit, soldCountLifetime 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.

On this page