Tab Commerce

Bundle JavaScript SDK

Use the independent public SDK to resolve offers, re-quote exact variants, and verify Add bundle outcomes.

@tabcommerceio/buy-together-sdk handles Bundle offers. @tabcommerceio/cross-sell-sdk continues to handle individual recommendations. An Online Store can use both, with separate clients, roots, observers, and event identities.

Install and prerequisites

npm install @tabcommerceio/buy-together-sdk

The package is browser-targeted ESM with TypeScript declarations and no runtime dependencies. Its component-based public contract identifies selections with component_id, product_id, and variant_id.

Use the installed shop’s actual same-origin App Proxy path. The examples use /apps/tab-cross-sell; a development install or customized proxy can have a different path. Resolving by slot_key, the official PDP rendered route, and the quotes they issue work on every plan. Resolving by explicit offer_ids, and its quotes, requires API access (Optimize or Scale). There is no browser API key, bearer-token route, or cross-origin headless delivery path.

Resolve and re-quote the selection

Use exactly one selector: slot_key for a spot key added in the offer’s Spot keys card, or offer_ids for explicit offers. Supply current country and cart currency. The server owns eligible variants, market prices, totals, tracking tokens, and the bundle instance.

The following controller uses a host-provided context so it does not invent country or currency values:

import {
  init,
  createQuoteRequestFence,
  type BundleOffer,
  type BundleQuoteSelection,
} from '@tabcommerceio/buy-together-sdk';

export function createBundleController({ proxyBase, country, currency, routeRoot }: {
  proxyBase: string; country: string; currency: string; routeRoot: string;
}) {
  const client = init({ proxyBase });
  const context = { country };
  const cart = { currency };
  const fence = createQuoteRequestFence();
  let tokenSource: BundleOffer | null = null;
  let ready: BundleOffer | null = null;
  let adding = false;
  let needsRecovery = false;

  function canAdd() {
    return !adding && !needsRecovery && ready !== null &&
      Date.parse(ready.quote_expires_at) > Date.now();
  }

  return {
    canAdd,
    async resolve(slotKey: string) {
      if (adding || needsRecovery) return null;
      ready = null;
      tokenSource = null;
      const attempt = fence.begin();
      const result = await client.resolve({ slot_key: slotKey, context, cart });
      if (!fence.isCurrent(attempt)) return null;
      tokenSource = result?.offers[0] ?? null;
      ready = tokenSource;
      return ready;
    },
    async chooseVariants(selections: BundleQuoteSelection[]) {
      if (!tokenSource || adding || needsRecovery) return null;
      const tracking_token = tokenSource.tracking_token;
      ready = null;
      const attempt = fence.begin();
      const result = await client.quote({
        tracking_token, selections, context, cart,
      }, attempt.signal);
      if (!fence.isCurrent(attempt)) return null;
      if (!result?.eligible || !result.offer) return null;
      tokenSource = result.offer;
      ready = result.offer;
      return ready;
    },
    async add() {
      if (!canAdd() || !ready) return null;
      adding = true;
      try {
        const result = await client.addAll(ready, { routeRoot });
        if (result.status === 'added') {
          ready = null;
          tokenSource = null;
        } else if (result.status === 'recoverable_error') {
          needsRecovery = true;
          ready = null;
          window.location.assign(`${routeRoot}cart`);
        }
        return result;
      } finally {
        adding = false;
      }
    },
    destroy() {
      fence.cancel();
      client.destroy();
    },
  };
}

Pass the locale-aware Shopify route root, such as / or /fr/. Disable option controls while adding. Re-render server-returned totals after resolve or chooseVariants; clear the prior purchasable state while a new quote is pending. Send every component tuple, including unchanged members.

Recreate the controller and resolve again when market or currency changes. An expired or ineligible quote must disable Add. Never construct a new price, tracking token, or replacement instance in the browser.

Handle the cart outcome

StatusRequired handling
addedRefresh the cart UI. The exact component/property tuples were verified; resolve a new instance before another add.
failedA fresh cart read proved a clean no-op. Retry only deliberately with a valid quote.
compensatedPartial instance lines were removed and the clean result verified. A deliberate retry with a valid quote is safe.
recoverable_errorThe outcome is uncertain. Stop retries and send the shopper to review a fresh cart.

The helper queues add-to-cart tracking only after cart verification. Do not send a duplicate add-to-cart event. Custom ESM consumers refresh their own theme cart; the official theme bundle has its own interaction and cart-sync adapter.

Rendering and impression tracking

resolvePDP() returns a server-rendered fragment. mountPDP() inserts it and observes impressions, but a custom host still owns variant and add-button handlers. Its request includes the current source Product and Variant GIDs and a fresh instance UUID before the server signs the fragment.

For custom cards, use createBundleImpressionObserver and forward its impression payload through the bundle client’s track method. Preserve the offer’s actual offer_id, tracking_token, and request_id. Valid impressions require 50% visibility for 500 ms and exclude hidden-tab time.

Disconnect observers and destroy clients during teardown. Upsell tracking tokens and observers do not belong to this workflow.