npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kitenzo/core

v0.5.0

Published

Framework-agnostic SDK for [Kitenzo](https://apps.shopify.com/bundlebuilder). Provides an API client, bundle configuration state machine, types, and utilities for building custom bundle experiences on any JavaScript platform.

Readme

@kitenzo/core

Framework-agnostic SDK for Kitenzo. Provides an API client, bundle configuration state machine, types, and utilities for building custom bundle experiences on any JavaScript platform.

Use this package directly for vanilla JS, Vue, Svelte, or server-side integrations. For React, use @kitenzo/react which wraps this package with hooks and components.

Install

npm install @kitenzo/core

Quick Start

import { KitenzoClient, createBundleBuilder, addBundleToCart } from '@kitenzo/core';

// 1. Create a client
const client = new KitenzoClient({
    apiKey: 'kit_live_...',
});

// 2. Find your bundle
const bundles = await client.listBundles();
const bundleId = bundles[0].id;

// 3. Fetch bundle with full product data
const bundle = await client.getBundle(bundleId);

// 4. Build a bundle configuration
const builder = createBundleBuilder(bundle);

builder.subscribe(() => {
    const state = builder.getState();
    console.log('Selections:', state.selections);
    console.log('Section:', state.currentSectionIndex);
    console.log('Valid:', state.isComplete);
    console.log('Errors:', state.errors);
});

const section = bundle.sections[0];
builder.addItem(section.id, section.products[0].variants[0].id);

// 5. Submit and add to cart
const selections = builder.getState().selections;
const result = await client.submitBundle(bundle, selections);
await addBundleToCart(result, {
    addLines: (lines) => cart.linesAdd(lines),
    getAttributes: () => cart.attributes ?? [],
    setAttributes: (attrs) => cart.cartAttributesUpdate(attrs),
}, { bundle, selections });

Full Bundle Embed

Use createBundleEmbed to render the full admin-configured bundle experience — the same UI merchants see on their storefront — inside any container element. No custom UI code needed.

import { createBundleEmbed } from '@kitenzo/core';

const embed = createBundleEmbed(document.getElementById('bundle-root')!, {
    bundleId: 42,
    apiKey: 'kit_live_...',
    shopDomain: 'my-store.myshopify.com',
    onAddToCart: ({ lines, bundleContent }) => {
        // lines are Storefront API CartLineInput objects
        // Add them to your cart via cartLinesAdd
        cart.linesAdd(lines);

        // For native bundles, set the _bundles cart attribute so the
        // cart transform extension can apply the discount at checkout.
        if (bundleContent) {
            const existingBundles = JSON.parse(
                cart.attributes?.find(a => a.key === '_bundles')?.value ?? '{}'
            );
            existingBundles[bundleContent.configuredBundleId] = bundleContent;
            cart.cartAttributesUpdate([
                ...(cart.attributes ?? []).filter(a => a.key !== '_bundles'),
                { key: '_bundles', value: JSON.stringify(existingBundles) },
            ]);
        }
    },
    onError: (error) => console.error('Embed error:', error),
});

// Clean up when done:
embed.destroy();

The embed automatically loads the storefront script, fetches shop settings (currency, moneyFormat), and intercepts add-to-cart events — normalizing the payload to Storefront API format.

onAddToCart Payload

The onAddToCart callback receives an EmbedCartPayload with:

| Field | Type | Description | |-------|------|-------------| | lines | CartLine[] | Cart lines ready for cartLinesAdd (Storefront API format). | | bundleContent | BundleContentPayload \| undefined | Bundle metadata for native bundles. When present, write it to the _bundles cart attribute so the cart transform can apply discounts. |

BundleContentPayload contains:

| Field | Type | Description | |-------|------|-------------| | id | number | Bundle ID. | | configuredBundleId | number | Unique ID for this bundle configuration. Use as key in the _bundles attribute. | | title | string | Bundle title. | | discount | string | Encrypted discount string (consumed by the cart transform). | | image | string \| null | Bundle image URL. | | items | Array<{ variantId, count }> | Products in the bundle. |

Each cart line in lines includes a _bundle_data attribute containing configuredBundleId#parentVariantId#uniqueId. This is how the cart transform matches individual line items to their bundle. When using the embed, these attributes are set automatically. If you construct cart lines manually (e.g. re-adding items from a saved cart), every line item that belongs to a native bundle must have the _bundle_data attribute — without it, the cart transform cannot identify the line as part of a bundle.

Important: Both _bundle_data (on each line item) and _bundles (on the cart) are required for the cart transform to apply discounts. The embed does not write the _bundles cart attribute itself — your onAddToCart handler must do it.

Bundle Type Differences

All necessary line item attributes (_bundle_data, _bundle_id, _bundle_price, subscription fields, etc.) are included automatically in lines — pass them through to cartLinesAdd as-is.

Native bundles: bundleContent is present. You must write it to the _bundles cart attribute (see example above) — the cart transform reads it to apply the discount at checkout.

Single-product and multiple-products bundles: bundleContent is undefined. No cart attributes needed — the discount is already baked into the variant price. Just call cartLinesAdd(lines).

Note: Only one embed per page is supported.

Setup

1. Enable the Headless API

In the Kitenzo admin panel, go to Settings > Headless to enable the headless API.

2. Create an API Key

On the same page, click Create API key. Keys are prefixed kit_live_ (production) or kit_test_ (development).

API key security: kit_live_ and kit_test_ keys are publishable keys, safe to use in client-side code (similar to Stripe publishable keys). They can only read bundle data and submit configurations — they cannot modify bundles or access admin functionality. Do not confuse these with server-side secrets.

3. Configure Allowed Origins (CORS)

If you're calling the API from a browser, you must add your domain to the API key's allowed origins list. Go to Settings > Headless, click your API key, and add each origin (e.g. https://your-store.com). Requests from unlisted origins will be blocked by CORS.

API Client

const client = new KitenzoClient({
    apiKey: 'kit_live_...',       // Required — your publishable API key
    apiVersion: 'v1',             // Optional (default: 'v1')
    baseUrl: 'https://...',       // Optional — override the API base URL
    countryCode: 'GB',            // Optional — Shopify Markets country (see below)
});

When no baseUrl is provided, the SDK uses https://live.bb.eight-cdn.com/api/headless/{apiVersion}. Use baseUrl for local development (e.g. /api/headless/v1 behind a Vite proxy) or custom proxy setups — when provided, apiVersion is ignored.

Methods

| Method | Returns | Description | |--------|---------|-------------| | listBundles() | Bundle[] | Published bundles (lightweight, no product data). | | getBundle(id, options?) | BundleDetail | Bundle with sections, products, and variants — everything needed to render a builder. Accepts an optional { countryCode } for market pricing. | | getPrice(bundle, selections, options?) | PriceResponse | Server-authoritative pricing for a selection without creating a configuration (read-only counterpart to submitBundle). Returns base-currency amounts; accepts { countryCode }. | | submitBundle(bundle, selections, options?) | SubmitBundleResult | Submit builder selections — handles product mapping automatically. Accepts an optional { countryCode } for market-aware pricing. | | getSettings() | ShopSettings | Shop settings (currency, moneyFormat, features). Auto-fetched by KitenzoProvider in React. |

Variant IDs accept GID format (gid://shopify/ProductVariant/123) or plain numeric strings ("123"). When a countryCode is set on the constructor it is the default for getBundle, getPrice and submitBundle; pass one in the per-call options to override it.

API Endpoints

All endpoints require a Bearer token (Authorization: Bearer kit_live_...).

| Method | Endpoint | Description | |--------|----------|-------------| | GET | /api/headless/v1/bundles | List published bundles | | GET | /api/headless/v1/bundles/:id | Bundle detail | | GET | /api/headless/v1/bundles/:id/products | Product and variant data | | POST | /api/headless/v1/bundles/:id/configure | Validate and create configuration | | POST | /api/headless/v1/bundles/:id/price | Calculate price | | GET | /api/headless/v1/settings | Shop settings |

Rate limit: 100 requests/minute per API key (configurable).

Kitenzo State Machine

createBundleBuilder(bundle) returns a state machine for step-by-step bundle configuration. It follows the subscribe/getState pattern, making it compatible with React's useSyncExternalStore, Svelte stores, or plain callbacks.

import { createBundleBuilder } from '@kitenzo/core';

const builder = createBundleBuilder(bundleDetail);

// Subscribe to state changes
const unsubscribe = builder.subscribe(() => {
    const state = builder.getState();
    renderUI(state);
});

// Mutations
builder.addItem(sectionId, variantId, quantity?);
builder.removeItem(sectionId, variantId);
builder.updateQuantity(sectionId, variantId, newQuantity);
builder.reset();

// Navigation
builder.nextSection();
builder.prevSection();
builder.goToSection(index);

// Queries
builder.getSectionQuantity(sectionId); // number

Snapshot Shape

builder.getState() returns a BundleBuilderSnapshot:

{
    selections: SectionSelections;       // Record<sectionId, BundleSelection[]>
    currentSectionIndex: number;
    currentSection: BundleSection | null;
    isSectionValid: boolean;
    isValid: boolean;
    isComplete: boolean;                 // all sections valid + all rules pass (see caveat below)
    isSatisfied: boolean;                // the bundle's real limit rules are met — gate add-to-cart on this
    // Sections also carry `autoNextSection`: the merchant's "advance once this step is
    // satisfied" setting. Mirror it rather than hardcoding the behaviour.
    allItems: BundleSelection[];         // flat list of all selections
    errors: ValidationError[];           // limit rule / required product violations (empty when valid)
    conditions: ConditionsSnapshot;      // conditional discount + hidden sections/products (see Conditions Engine)
}

The snapshot is immutable — a new object reference is created on each state change (for efficient React reconciliation or shallow comparison).

isComplete vs isSatisfied. isComplete treats a section with no stated minimum as needing at least one pick. That is wrong for the two commonest bundle shapes — a bundle-wide "pick any 4 across groups", and a step whose only rule is a maximum — so it reports false forever on bundles the API will happily accept. isSatisfied reads the bundle's actual limit rules instead. isComplete is unchanged, because existing integrations depend on it; gate new add-to-cart buttons on isSatisfied.

Limit Rules (pick counts)

The bundle structure carries the layout and the bundle carries the pick counts. There is no min/max field on BundleSection — a step's counts arrive as total-number-of-products limit rules tagged with that step's sectionId, and "pick any N across groups" arrives as the same rule with sectionId: null.

getSectionLimits(bundle, sectionId)PickLimits

| Rules on the step | Result | | --- | --- | | eq 8 | { min: 8, max: 8 } — exactly 8 | | gte 3 + lte 5 | { min: 3, max: 5 } — a range | | gte 4 | { min: 4, max: UNBOUNDED } — at least 4, no ceiling | | lte 5 | { min: 0, max: 5 } — up to 5, nothing required | | none | { min: 0, max: UNBOUNDED } — optional, free quantity |

gt and lt are exclusive and convert to the reachable whole number: gt N is a minimum of N + 1, lt N a maximum of N - 1. Translate all five, or the widget under-gates a step the engine then rejects at /configure.

The bundle-wide rule is deliberately not consulted here: "pick any 4 across groups" says nothing about how many come from each group.

Do not send a PickLimits through JSON. An open ceiling is UNBOUNDED (Infinity), and JSON.stringify turns that into null — after which count <= max is false for every count, so every step reads as full. Recompute limits on the side that uses them (they are derived from the bundle, which you already have), or map max to your own sentinel before serialising.

getBundleLimits(bundle)

The bundle-wide window, from the count rule with sectionId: null — the "pick any N across groups" shape. min is what a "pick 4" tracker needs to size its slots; max is what tells it when the bundle is full.

const { min, max } = getBundleLimits(bundle);
const slots = Array.from({ length: min }, (_, i) => picks[i] ?? null);
const canAdd = picks.length < max;

snapshot.isSatisfied

Whether the selection actually satisfies the bundle, computed from these windows. Read it off builder.getState() (or useBundleBuilder) — that is the only place it is exposed, because the builder is how every consumer holds selections, the vanilla path included.

It judges the effective configure payload, not just what the shopper touched: a required product usually belongs to the bundle rather than to a step, so it never appears in selections and the SDK appends it on submit. Counting only selections would report every such bundle unsatisfiable forever. Appended required products count toward the bundle-wide total (as the engine counts them) but not toward any per-section window (as the engine does not).

An empty selection is never satisfied. Rules targeting a conditions-engine-hidden section are still checked, because the engine's own validator has no conditions awareness and would reject the configuration; hiding only relieves a section of its own minQuantity.

Client-side rule validation mirrors the engine's validate_bundle_limits, which is what accepts or rejects the configuration at /configure. Where the two disagreed the shopper got a generic server rejection instead of an explanation, so two semantics are matched deliberately and are worth knowing when reading errors:

  • amount-of-one-product / amount-of-one-variant hold when every product satisfies the rule, not just the most-picked one, and hold vacuously on an empty selection. (For the common lte "at most N of any one product" shape these are the same thing; they diverge for gte/gt/eq.)
  • multiples-of ignores its comparison operator: the value is the unit and the count must be a positive multiple of it. A zero or blank unit is a no-op.

Product Options (one dropdown per option)

A custom UI that renders Hand / Loft / Shaft rather than a flat list of variant titles has to answer three questions on every click: which variant this selection means, which values are still reachable, and what happens to the other options when one changes. These four functions answer them, so no widget has to re-derive the rules.

import { defaultOptionValues, reachableOptionValues, resolveVariant, selectOptionValue } from '@kitenzo/core';

// OptionSelection is Record<optionName, chosenValue>
const [values, setValues] = useState<OptionSelection>(() => defaultOptionValues(product));
const variant = resolveVariant(product, values);          // BundleVariant | null

// Render one <select> per option, disabling what is out of reach
const offered = reachableOptionValues(product, values, 'Hand');

// Apply a change; later options repair themselves
setValues(selectOptionValue(product, values, 'Hand', 'Left Hand'));

Option order is significance order. A value is offered or withheld based only on the options before it, and changing one repairs only the options after it. That rule is not cosmetic: on a driver that is left-handed in one loft only, constraining on every other option means a shopper who picked the 9° can never switch to left-handed — the value sits disabled with nothing telling them which other option to change first. Repairing in both directions is worse still: a shaft change silently flips the hand they deliberately chose.

Disable unreachable values rather than hiding them, so a shopper can see that left-handed exists before working out how to reach it. Values come back in the merchant's declared order, and only available variants count.

These need an API serving product options and per-variant optionValues. Without them there is no grid to resolve and every function returns empty.

Personalisation

When the shop's personalisation_sets feature is on, BundleDetail.personalisation carries the per-product field definitions — engraving text, a gift message, an uploaded image — keyed by Shopify product id as a string. The key is omitted entirely when the feature is off, and it covers section products and required products alike.

const fields = bundle.personalisation?.['7654321'] ?? [];

In custom-UI mode you own the rendering. Submit the shopper's answer as a line-item property under the field's key, not its labelkey is frozen at creation and does not change when the merchant renames the label. For an image field, host the image yourself and submit its URL as the value.

A field may carry a fee (PersonalisationFieldFee): an upcharge added to the cart as its own line for the hidden fee product at fee.variantId, priced fee.amount in the shop's base currency.

Pricing

calculatePrice(bundle, selections, currency?)

Calculate bundle pricing from variant prices and discount rules. Handles flat discounts (percentage, fixed, price) and tiered discounts with all operation types.

The currency parameter is a label included in the response — pass the shop's currency from getSettings() so the returned PriceResponse.currency is accurate. If omitted, the field is empty.

import { calculatePrice } from '@kitenzo/core';

const settings = await client.getSettings();
const pricing = calculatePrice(bundle, builder.getState().selections, settings.currency);
console.log(pricing.originalPrice);    // "45.00"
console.log(pricing.discountedPrice);  // "40.50"
console.log(pricing.discountType);     // "percentage"
console.log(pricing.discountValue);    // "10.00"
console.log(pricing.currency);         // "CZK"

React users: Use the useBundlePrice hook instead — it returns pre-formatted prices (e.g. "$45.00") using the shop's moneyFormat automatically.

calculatePriceWithConditions(bundle, selections, options?)

Like calculatePrice, but also applies the conditions engine: if a conditional discount (apply-discount) fires for the current selection, it replaces the flat/tiered discount. Use this for the displayed price on conditions-driven bundles. options accepts { currency, currentStepName }. React's useBundlePrice uses it automatically. See Conditions Engine.

resolveUpfrontFixedPrice(bundle)

The price the bundle will sell at before anything is selected, when that is knowable. A flat price-type discount fixes the final price regardless of what the shopper picks, so it can be shown immediately; every other configuration (percentage, fixed, and tiered discounts) depends on the selection and returns null.

import { resolveUpfrontFixedPrice } from '@kitenzo/core';

resolveUpfrontFixedPrice(bundle); // "65.00" for a flat price discount, else null

Returned in the shop's base currency — convert it with resolveMarketPricingContext for a market-loaded bundle.

React users: useBundlePrice calls this for you — with no selections it returns the fixed price instead of a blank one.

formatMoney(amount, moneyFormat)

Format an amount using a Shopify money format string. Supports all standard Shopify placeholders.

import { formatMoney } from '@kitenzo/core';

formatMoney(45, '${{amount}}');                           // "$45.00"
formatMoney(1234.5, '€{{amount_with_comma_separator}}'); // "€1.234,50"
formatMoney('99.99', '{{amount}} kr');                    // "99.99 kr"

Shopify Markets (localised pricing)

By default prices are returned in the shop's base currency. To show a shopper the price they'll pay in their Shopify Market, pass a countryCode (ISO 3166-1 alpha-2). Shopify derives the market from the country, so a country is all you need.

const client = new KitenzoClient({ apiKey: 'kit_live_...', countryCode: 'DE' });
const bundle = await client.getBundle(42); // variants now carry presentment prices

When a countryCode is supplied, each variant gains these fields (all optional — absent when no market was requested, or when the shop has no Shopify Markets):

| Field | Description | |-------|-------------| | presentmentPrice | The price in the shopper's market currency (full precision). | | presentmentCurrency | ISO 4217 code for that price (e.g. "EUR"). | | priceInShopCurrency | The presentment price expressed back in shop currency. | | availableForSale | Whether the variant is sellable in that market. false = not sold there (or out of stock there) — distinct from available, which is base-store stock only. Gate selection on this in a market. |

The base price (shop currency) is always present, so you can render a compare-at or fall back. Omit countryCode and the response is byte-identical to before.

Formatting a localised price

The shop's moneyFormat only fits the base currency. Format presentment amounts with formatCurrency, and turn a base-currency PriceResponse into the market currency with resolvePresentmentPricing:

import { calculatePrice, resolvePresentmentPricing, formatCurrency } from '@kitenzo/core';

const base = calculatePrice(bundle, selections, settings.currency);
const pricing = resolvePresentmentPricing(bundle, selections, base) ?? base; // null when no market
formatCurrency(pricing.discountedPrice, pricing.currency); // "€21.60"

resolvePresentmentPricing shows the exact market subtotal (Σ presentment price) and applies the bundle discount as the same ratio the base price used — the same way the Kitenzo theme widget renders, and the way the Cart Transform reconciles at checkout. This is exact for percentage discounts in any market, and for every discount type under Shopify's standard exchange-rate conversion.

React users: the useBundlePrice hook does all of this automatically once you set countryCode on <KitenzoProvider> — see @kitenzo/react.

resolveMarketPricingContext(bundle)

resolvePresentmentPricing needs a selection to convert. When you need the market currency and rate without one — to show a fixed bundle price before the first pick, say — derive them from the bundle itself:

import { resolveMarketPricingContext, resolveUpfrontFixedPrice, formatCurrency } from '@kitenzo/core';

const market = resolveMarketPricingContext(bundle); // null when not market-loaded
const fixed = resolveUpfrontFixedPrice(bundle);     // null unless a flat price discount

if (fixed && market) {
    formatCurrency((Number(fixed) * market.rate).toFixed(2), market.currency); // "€60.00"
}

The rate comes from the first section variant carrying usable presentment data — its presentmentPrice / priceInShopCurrency, the same rate the Cart Transform applies to fixed discounts at checkout. Variants missing either figure, or priced at zero, are skipped. Under fixed per-market pricing the rate varies per variant, so this shares the proportional approximation described above.

Match the cart. The displayed price only matches checkout if the same countryCode is set as the buyerIdentity.countryCode on your Shopify cart (cartBuyerIdentityUpdate). submitBundle forwards your countryCode to the configure call, so the native discount stays consistent.

Not covered: translated product titles (locale) — a separate concern from price. Personalisation fees and subscription selling-plan prices are shown in the shop's base currency. Responses become market-specific, so include the country in any cache key you add in front of the API.

Conditions Engine

Bundles configured with the conditions engine — conditional discounts and/or conditional step & product visibility — are supported client-side. When the shop has the feature enabled, getBundle() includes the serialised rule graph on BundleDetail.conditionsEngineNodes, and createBundleBuilder evaluates it on every state change. Read the result from the snapshot's conditions field:

const { conditions } = builder.getState();

conditions.hiddenSectionIds; // number[] — section ids to hide
conditions.hiddenProducts;   // { productId: string; sectionId: number | null }[]
conditions.hideCartButton;   // boolean  — disable add-to-cart
conditions.gotoSectionId;    // number?  — a step-goto target
conditions.discountOverride; // { type, value }? — the conditional discount

alwaysShow rules are already applied, so hiddenSectionIds / hiddenProducts are the final set to hide.

  • Pricing — use calculatePriceWithConditions(bundle, selections) (or the useBundlePrice hook) to reflect a conditional discount in the displayed price.
  • SubmittingsubmitBundle() computes the conditional discount and sends it automatically; the server validates and signs it into the cart payload.
  • Completion — sections hidden by the rules don't block isComplete.

Supported conditions: bundle-price, bundle-product-total, bundle-contains-product(-amount), product-quantity, product-stock, product-title, step-title, step-contains-product(-amount), step-product-total, step-price. Supported actions: apply-discount, product-visibility (hide/show), step-visibility (hide/show), add-to-cart-btn (disable), step-goto.

Limitations

  • Cascade / variant-selected rules are not evaluated yet. They are stripped from the payload server-side; if a bundle uses them, BundleDetail.conditionsPartial is true — the rest of the graph still evaluates, but the bundle is not fully driveable headlessly. Check this flag if full parity matters.
  • Price-based conditions evaluate on base (shop-currency) prices, not buyer/country (Markets) prices, even when countryCode is passed to submitBundle.
  • The conditional discount is computed client-side and signed by the server (the same trust model as the Kitenzo theme storefront). The configure endpoint remains the authoritative validator.

Cart Utilities

addBundleToCart(result, cart, context?)

Add a configured bundle to a Shopify cart in one call. Builds cart lines, merges the _bundles cart attribute, and calls both mutations — so the cart transform can always apply the discount. Pass { bundle, selections } as the third argument for native bundles, which is required to produce the correct _bundle_data line attributes and _bundles cart attribute.

import { addBundleToCart } from '@kitenzo/core';

const result = await client.submitBundle(bundle, selections);
await addBundleToCart(result, {
    addLines: (lines) => cart.linesAdd(lines),
    getAttributes: () => cart.attributes ?? [],
    setAttributes: (attrs) => cart.cartAttributesUpdate(attrs),
}, { bundle, selections });

buildCartPayload(result, existingAttributes?, context?)

Lower-level alternative when you need direct control. Returns { lines, attributes } — you must call both cartLinesAdd and cartAttributesUpdate yourself. Pass { bundle, selections } as the third argument for native bundles.

import { buildCartPayload } from '@kitenzo/core';

const result = await client.submitBundle(bundle, selections);
const { lines, attributes } = buildCartPayload(result, cart.attributes, { bundle, selections });
cart.linesAdd(lines);
cart.cartAttributesUpdate(attributes);

buildCartLines(result)

Build cart lines only (without attribute merging). Useful when you manage cart attributes separately.

import { buildCartLines } from '@kitenzo/core';

const result = await client.submitBundle(bundle, selections);
const lines = buildCartLines(result);
cart.linesAdd(lines);

Section Utilities

computeSectionQuantity(selections, sectionId)

Returns the total quantity selected in a section.

isSectionMet(selections, section)

Returns whether a section's minimum/maximum quantity constraints are satisfied.

TypeScript Unions

The package exports typed union types for fields that accept a fixed set of values, providing autocomplete and compile-time safety:

| Type | Values | |------|--------| | BundleType | 'single-product' | 'multiple-products' | 'native' | | BundlingOption | 'bundles' | | DiscountType | 'percentage' | 'fixed' | 'price' | | DiscountMode | 'flat' | 'tiered' | | ComparisonOperator | 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | | TierOperator | 'max' | 'cumulative' | | TierConditionType | 'total_products' | 'bulk_buy' | 'total_price' | | LimitRuleType | 'bundle-price' | 'total-number-of-products' | ... | | PersonalisationFieldType | 'text' | 'dropdown' | 'checkbox' | 'image' |

BundlingOption admits three deprecated members. 'upsells' is a legacy mode that the API does still emit — a published upsell bundle comes back from GET /bundles like any other, since neither endpoint filters on this field — but it is not a supported mode for headless builds; handle it defensively rather than building on it. 'native' and 'custom' were never emitted by any version of the API and are retained only so existing code keeps compiling. 'subscriptions' is declared in the field's choices but is never written or read by any code path, so it is not in the union.

Prefer === 'bundles' over an exhaustive switch.

Note that a discount tier's comparison field is operation, while operator on the parent BundleDiscount is a different thing — how to combine several active tiers. A tier may also carry customText, the merchant's override for that tier's discount-progress message (supporting the {{ amount }}, {{ discount }} and {{ currentDiscount }} placeholders); it is null when they have not set one, in which case your own default copy applies.

BundleDiscount.type is typed DiscountType | '': the bundle serializer emits Bundle.discount_type verbatim, and that field is blank when the merchant configured no discount. PriceResponse.discountType is DiscountType | null — the pricing serializer normalises the same empty value to null.

Variant Availability

The BundleVariant.available field is a boolean indicating whether the variant can be selected. When available is false, the variant should be shown as disabled. The API does not currently provide a specific reason for unavailability — it may be out of stock, restricted by inventory policy, or excluded by bundle rules. Display unavailable variants as disabled to let customers see the full selection, and use the inventoryQuantity field (when present) to distinguish out-of-stock variants from other restrictions.

When you load a bundle for a Shopify Market (countryCode), each variant also carries availableForSale — whether it is sellable in that market. A variant can be available (in stock in the base store) but availableForSale: false because it isn't part of the market's catalogue. Gate selection / add-to-cart on availableForSale when a market is set, or the shopper can build a bundle that fails at checkout.

Troubleshooting

CORS errors -- Your origin is not in the API key's allowed origins list. Go to Settings > Headless, click your API key, and add your domain.

401/403 -- Verify the API key is correct, active, and headless is enabled.

Cart lines added but no discount -- Make sure cart attributes from buildCartPayload() are applied via cartAttributesUpdate(). If using the embed (createBundleEmbed), check that your onAddToCart handler writes bundleContent to the _bundles cart attribute — see the embed section above.

"Unable to add kit to cart" in console (embed mode) -- This happened in older versions when the embed tried to call Shopify Liquid endpoints (/cart.js, /cart/update.js) that don't exist on headless storefronts. Update to the latest version — the embed now skips these calls when onAddToCart handles the event.