@tabcommerceio/buy-together-sdk
v1.0.0
Published
Storefront JavaScript SDK for Product Bundle offers, exact Variant quotes, Add bundle, and isolated Bundle event delivery.
Maintainers
Readme
Product Bundle SDK
@tabcommerceio/buy-together-sdk provides the Product Bundle storefront workflow:
Offer lookup, exact Variant re-quotes, verified Add bundle, and independent
Bundle event delivery. It is browser-targeted ESM with TypeScript declarations
and no runtime dependencies. It does not import or mount the Cross Sell SDK.
Install and choose the SDK
npm install @tabcommerceio/buy-together-sdkUse @tabcommerceio/cross-sell-sdk for individual recommendations and Cross Sell
tracking. Use this package for Product Bundle Offers. An Online Store can use
both packages, with separate clients, roots, tokens, and event observers.
1.0.0 is the first public release of the current component-based contract.
It replaces the repository's earlier experimental role-based API: components
are identified by component_id, and every purchasable selection carries a
short quote_expires_at. Code using older workspace builds must migrate all
component tuples and implement the re-quote flow below. The package name stays
buy-together-sdk; a product display-name change does not require an npm rename.
Before connecting
Use the installed shop's actual App Proxy path; the examples use the development
path /apps/tab-cross-sell-dev. Requests run in the shopper's Online Store
browser through Shopify's signed proxy. Do not put Admin API credentials into
browser code. Resolve and quote require the shop's api_access entitlement
(currently Optimize, or an otherwise eligible enabled plan).
An Offer is a peer Product set. Every member Product can be its PDP source. The server owns eligible variants, contextual prices, totals, tokens, and the Bundle instance identity. Never calculate a replacement quote in the browser. The current protocol accepts two to four distinct component Products.
Custom UI: resolve, re-quote, add
The following controller uses public exports only. Bind your option controls to
chooseVariants, refresh displayed totals from its returned Offer, and enable
Add only when canAdd() is true. Disable option controls while Add is running.
Each selection contains every component tuple, including unchanged members.
Replace the example country, currency, proxy path, slot, and locale root with
current shop facts. Use offer_ids instead of slot_key when selecting explicit
Offers; never send both selectors.
import {
init,
createQuoteRequestFence,
type BundleOffer,
type BundleQuoteSelection,
} from '@tabcommerceio/buy-together-sdk'
const client = init({ proxyBase: '/apps/tab-cross-sell-dev' })
const context = { country: 'US' }
const cart = { currency: 'USD' }
const routeRoot = '/' // Use the shop's Shopify.routes.root, e.g. '/fr/'.
const fence = createQuoteRequestFence()
const resolved = await client.resolve({ slot_key: 'summer-bundles', context, cart })
let tokenSource: BundleOffer | null = resolved?.offers[0] ?? null
let ready: BundleOffer | null = tokenSource
let adding = false
let needsCartRecovery = false
export function canAdd(): boolean {
return !adding && !needsCartRecovery && ready !== null &&
Date.parse(ready.quote_expires_at) > Date.now()
}
export async function chooseVariants(selections: BundleQuoteSelection[]) {
if (!tokenSource || adding || needsCartRecovery) return null
const tracking_token = tokenSource.tracking_token
ready = null // The previous selection cannot remain purchasable.
const attempt = fence.begin() // Aborts and invalidates the previous request.
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 // Render these server-returned totals and selected variants.
}
export async function addSelectedBundle() {
if (!canAdd() || !ready) return null
adding = true
try {
const result = await client.addAll(ready, { routeRoot })
if (result.status === 'added') {
ready = null
tokenSource = null // Resolve again before starting another Bundle instance.
} else if (result.status === 'recoverable_error') {
needsCartRecovery = true
ready = null
window.location.assign(`${routeRoot}cart`)
}
return result
} finally {
adding = false
}
}
export function destroyBundleUI() {
fence.cancel()
client.destroy()
}Read a component's eligible variants to construct its selected
{ component_id, product_id, variant_id } tuple. Pass only the latest eligible
server Offer to addAll(). Preserve its bundle_instance_id and tracking token;
never mint a replacement instance after the server has signed it. Re-resolve
when the live Offer is stale. Update country/cart facts and re-resolve when the
shopper's market or currency changes.
Add outcomes
| Status | Meaning and next action |
|---|---|
| added | Both the add response and a fresh cart read prove the exact component/property tuples. Refresh your cart UI; resolve a new instance for another add. |
| failed | A fresh read proves a clean no-op. Let the shopper retry only with a still-valid quote. |
| compensated | Partial instance lines were removed and the clean result was verified. A deliberate retry with a valid quote is safe. |
| recoverable_error | The cart outcome could not be proven. Stop retries and navigate to a fresh cart; do not mint another request/instance to retry blindly. |
The SDK sends one Shopify Ajax /cart/add.js multi-item request with the five
_tab_bundle_* private properties. Its compensation targets only lines from
that exact instance/token. It queues add_to_cart only after full cart proof;
custom consumers must not send a duplicate event. Resolve/quote return null
for transport failures; an ineligible quote must leave Add disabled.
Rendering and tracking
resolvePDP() returns server-rendered HTML. mountPDP() inserts that HTML,
fences overlapping mounts, and observes impressions. Its request requires the
currently selected source Product/Variant GIDs and a new crypto.randomUUID()
instance before requesting a signed fragment. It does not wire custom UI
option/add handlers: those remain the host's responsibility. The official
Theme App Block bundles its own interaction controller and needs no npm install.
For custom cards, use createBundleImpressionObserver with an observe target
containing the actual Offer's offer_id, tracking_token, and request_id;
forward its onImpression payload to client.track. The public observer enforces
50% visibility for 500 ms and excludes hidden-tab time. Track clicks with the
same Offer identity. client.flush() drains queued events, and
client.destroy() releases client resources; disconnect any observer you create
separately. No Bundle root or event belongs to the Cross Sell client.
In browsers init uses the page's document/window for visibility and pagehide
flushes. Explicit refs are only needed for custom environments.
Using both packages
import { init as initCrossSell } from '@tabcommerceio/cross-sell-sdk'
import { init as initBundles } from '@tabcommerceio/buy-together-sdk'
const proxyBase = '/apps/tab-cross-sell-dev'
const crossSell = initCrossSell({ proxyBase })
const bundles = initBundles({ proxyBase })
// Bind each client to its own cards and event identities.
// On teardown: crossSell.destroy(); bundles.destroy().The public ESM API supplies data/commerce primitives for your own markup. The
internal official Theme IIFE is built separately as
tab-buy-together-storefront.js and is excluded from this npm package. After a
proven official add it synchronizes the theme cart and emits
tab:buy-together:cart-changed; custom ESM consumers refresh their own cart UI.
Maintainer release
Source: tabcommerce/tab-cross-sell, directory packages/web/buy-together-sdk.
The shared workflow publish-cross-sell-sdk.yml publishes exact manifest
versions for both SDKs from main using package-scoped npm Trusted Publishing.
The initial publication requires an authenticated package owner; configure the
Bundle package's trust binding after that first publish. Local validation uses
pnpm publish:buy-together:dry; the authorized manual release command is
pnpm publish:buy-together. See scripts/release/README.md in the repository
for digest, authentication, tagging, and post-publication checks.
