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/react

v0.5.0

Published

React hooks and provider for [Kitenzo](https://apps.shopify.com/bundlebuilder). Provides everything you need to build custom bundle experiences in React and Shopify Hydrogen storefronts — you bring the UI, we handle the data.

Readme

@kitenzo/react

React hooks and provider for Kitenzo. Provides everything you need to build custom bundle experiences in React and Shopify Hydrogen storefronts — you bring the UI, we handle the data.

Built on @kitenzo/core and re-exports everything from it, so you only need a single install.

Install

npm install @kitenzo/react

Peer dependencies: react >= 18, react-dom >= 18. Optional: @shopify/hydrogen-react >= 2024.0.0.

Quick Start

1. Add the Provider

import { KitenzoProvider } from '@kitenzo/react';

function App() {
    return (
        <KitenzoProvider apiKey={import.meta.env.PUBLIC_KITENZO_API_KEY}>
            <Outlet />
        </KitenzoProvider>
    );
}

| Prop | Type | Required | Description | |------|------|----------|-------------| | apiKey | string | Yes | Kitenzo API key (kit_live_... or kit_test_...) | | baseUrl | string | No | Override the default API base URL | | apiVersion | string | No | API version (default: v1) | | countryCode | string | No | Shopify Markets country (ISO 3166-1 alpha-2, e.g. "GB"). Set it once and every price the widget fetches and displays honours that market — see Shopify Markets. |

The provider automatically fetches shop settings (currency, moneyFormat) on mount.

2. Build a Bundle Page

import {
    useBundle,
    useBundleBuilder,
    useBundlePrice,
    useBundleCart,
    buildCartPayload,
} from '@kitenzo/react';

function BundlePage({ bundleId }: { bundleId: number }) {
    const { bundle, isLoading, error } = useBundle(bundleId);

    if (isLoading) return <p>Loading...</p>;
    if (error) return <p>Error: {error.message}</p>;
    if (!bundle) return null;

    return <BundleConfigurator bundle={bundle} />;
}

function BundleConfigurator({ bundle }: { bundle: BundleDetail }) {
    const {
        selections, currentSection, currentSectionIndex,
        addItem, removeItem, nextSection, prevSection, goToSection,
        isSectionValid, isComplete, errors,
    } = useBundleBuilder(bundle);

    const { formattedOriginalPrice, formattedDiscountedPrice, hasDiscount } =
        useBundlePrice(bundle, selections);

    const { addBundleToCart, isLoading: isSubmitting } = useBundleCart();

    async function handleSubmit() {
        const result = await addBundleToCart(bundle, selections);
        const { lines, attributes } = buildCartPayload(result);
        // Add to your cart (Hydrogen, custom Storefront API, etc.)
    }

    return (
        <div>
            <h1>{bundle.name}</h1>

            {/* Section tabs */}
            {bundle.sections.map((section, i) => (
                <button key={section.id} onClick={() => goToSection(i)}>
                    {section.name}
                </button>
            ))}

            {/* Products in current section */}
            {currentSection?.products.map((product) => (
                <div key={product.id}>
                    <h3>{product.title}</h3>
                    {product.variants.map((variant) => {
                        const selected = (selections[currentSection.id] ?? [])
                            .some((s) => s.variantId === variant.id);
                        return (
                            <button
                                key={variant.id}
                                disabled={!variant.available}
                                onClick={() =>
                                    selected
                                        ? removeItem(currentSection.id, variant.id)
                                        : addItem(currentSection.id, variant.id)
                                }
                            >
                                {selected ? '✓ ' : ''}{variant.title} - {variant.price}
                            </button>
                        );
                    })}
                </div>
            ))}

            {/* Price */}
            {hasDiscount ? (
                <p><s>{formattedOriginalPrice}</s> {formattedDiscountedPrice}</p>
            ) : (
                <p>{formattedDiscountedPrice}</p>
            )}

            {/* Navigation */}
            <button onClick={prevSection}>Back</button>
            <button onClick={nextSection} disabled={!isSectionValid}>Next</button>
            {isComplete && (
                <button onClick={handleSubmit} disabled={isSubmitting}>
                    Add to Cart
                </button>
            )}
        </div>
    );
}

Full Bundle Embed

If you don't need a custom UI, use <BundleEmbed> to render the full admin-configured bundle experience — the same UI merchants see on their storefront.

import { BundleEmbed } from '@kitenzo/react';

function BundlePage({ bundleId }: { bundleId: number }) {
    return (
        <BundleEmbed
            bundleId={bundleId}
            apiKey="kit_live_..."
            shopDomain="my-store.myshopify.com"
            onAddToCart={({ lines }) => {
                cart.linesAdd(lines);
            }}
        />
    );
}

| Prop | Type | Required | Description | |------|------|----------|-------------| | bundleId | number | Yes | Bundle to display | | apiKey | string | Yes | Kitenzo API key | | shopDomain | string | Yes | Shop's myshopify.com domain | | onAddToCart | (payload: EmbedCartPayload) => void | Yes | Called with normalized Storefront API cart lines | | onError | (error: Error) => void | No | Called on script load or settings fetch failure | | baseUrl | string | No | Override the default API base URL | | className | string | No | CSS class for the container |

Note: <BundleEmbed> is self-contained — it does not require <KitenzoProvider>. Only one embed per page is supported.

Hooks

useBundles()

Fetches the list of published bundles.

const { bundles, isLoading, error, refetch } = useBundles();

useBundle(bundleId, options?)

Fetches a single bundle with full product and variant data.

const { bundle, isLoading, error, refetch } = useBundle(bundleId);

// With SSR hydration:
const { bundle } = useBundle(bundleId, { initialData: loaderBundle });

useBundleBuilder(bundle)

State machine for step-by-step bundle configuration. Wraps createBundleBuilder from core with useSyncExternalStore.

const {
    // State
    selections,              // Record<sectionId, BundleSelection[]>
    currentSection,          // BundleSection | null
    currentSectionIndex,     // number
    isSectionValid,          // current section meets min/max
    isValid,                 // all sections meet min/max
    isComplete,              // all valid + rules pass + required products present (see caveat)
    isSatisfied,             // the bundle's real limit rules are met — gate add-to-cart on this
    allItems,                // flat list of all selections
    errors,                  // ValidationError[] — limit rule / required product violations
    conditions,              // ConditionsSnapshot — hidden sections/products, hideCartButton, discountOverride

    // Mutations
    addItem,                 // (sectionId, variantId, quantity?) => void
    removeItem,              // (sectionId, variantId) => void
    updateQuantity,          // (sectionId, variantId, quantity) => void
    reset,                   // () => void

    // Navigation
    nextSection,             // () => void
    prevSection,             // () => void
    goToSection,             // (index) => void

    // Queries
    getSectionQuantity,      // (sectionId) => number
} = useBundleBuilder(bundle);

Gate add-to-cart on isSatisfied, not isComplete. isComplete treats a section with no stated minimum as needing at least one pick, so it stays false forever on a bundle-wide "pick any 4 across groups" bundle and on a step whose only rule is a maximum. isSatisfied reads the bundle's actual limit rules. isComplete is unchanged, because existing integrations depend on it. See Limit Rules in @kitenzo/core for the underlying windows and the one known divergence from the server-side validator.

For conditions-engine bundles, conditions tells you which sections/products to hide, whether to disable the cart button, and the conditional discount. useBundlePrice reflects that discount automatically, and useBundleCart sends it on submit. See the Conditions Engine docs in @kitenzo/core.

useBundleCart()

Submits a bundle configuration to the API and returns cart-ready data. It does not touch a cart — for that, prefer useBundleAjaxCart or useStorefrontBundleCart below, which own the cart choreography as well.

const { addBundleToCart, isLoading, error, lastResult } = useBundleCart();

const result = await addBundleToCart(bundle, selections, { countryCode: 'US' });
// result.variantId, result.configuredBundleId, result.pricing, etc.

Cart state — shared by both cart hooks

useBundleAjaxCart and useStorefrontBundleCart return the identical shape — UseBundleCartFlowResult, which is the reactive BundleCartState plus addToCart and reset. Annotate a prop with BundleCartState to pass the state down to a presentational button. Because both hooks share it, a widget supporting both cart modes writes one set of buttons and one set of guards:

const {
    phase,               // BundleCartPhase — see below
    isAdding,            // true while any step is in flight; disable the button on this
    isAdded,             // true only once `_bundles` is confirmed; gate checkout on this
    error,               // Error | null
    failureReason,       // BundleCartFailureReason | null
    lastResult,          // SubmitBundleResult | null — the last successful /configure
    expectedQuantity,   // lines the last attempt sent
    addedQuantity,      // lines the cart actually gained (null when the transport can't tell)
    hasMissingItems,     // a line was silently rejected — see below
    addToCart,           // (bundle, selections, { countryCode? }?) => Promise<BundleCartOutcome>
    reset,               // () => void — a no-op while an attempt is in flight
} = useBundleAjaxCart();

BundleCartPhase'idle' | 'configuring' | 'adding' | 'attributes' | 'added' | 'failed'. The split between adding and attributes is not cosmetic: the bundle discount lives in the _bundles cart attribute, written by a second mutation after the lines land. A shopper who reaches checkout between the two is charged full price, so only added means "safe to check out".

BundleCartOutcomeaddToCart never rejects. It resolves to { ok: true, result } or { ok: false, reason, error }, and the same failure lands in error / failureReason. BundleCartFailureReason is one of:

| Reason | Meaning | | --- | --- | | already-in-progress | Another attempt from this hook is still running | | cart-busy | The cart was mid-flight; adding then would clobber _bundles or create a second cart | | cart-not-ready | The cart has not finished initialising and silently discards mutations | | configure-failed | POST /configure rejected the selection, or the network failed | | cart-error | The cart failed while adding lines or writing attributes | | timeout | The cart never settled (hydrogen only; see timeoutMs) |

Retrying does not duplicate a bundle. A cart has no idempotency key, so a blind re-add is a real duplicate the shopper pays for. Both hooks avoid it, and how depends on what is known about the failed attempt:

| Failure | What is known | What a retry does | | --- | --- | --- | | /configure rejected | Nothing was added | Full clean retry | | Cart route returned a status (e.g. 422) | Server refused; nothing written | Full clean retry | | Lines landed, _bundles write failed | Lines are committed | Finishes only the outstanding write | | Cart route failed with no status (dropped connection) | Unknown — the response was lost, not necessarily the request | Reads the cart back, then adds only if the lines are genuinely absent | | hydrogen timeout | Unknown — may still land | Refused until the outcome is known; if it lands late, _bundles is still written and the state flips to added |

That last-but-one row is why CartOperations has an optional getLines: on the AJAX path the only way to settle "did it land?" is to look.

hasMissingItems is set when the cart gained fewer units than were sent — counted per merchandise id, not by line count, because the Storefront API merges a new line into an existing one when merchandise and attributes both match. It only happens on the Storefront API path, where hydrogen-react never queries userErrors, so a rejected line — usually a variant not published to the token's sales channel — is dropped with no error at all. The quantity delta is the only signal available.

useBundleAjaxCart(options?)

Configures the bundle and puts it in the theme's own cart, for a widget embedded in a normal Shopify storefront. Handles the whole /cart/add.js/cart.js/cart/update.js sequence, including merging _bundles rather than replacing it, and turning non-2xx responses into real errors (a bare fetch resolves on a 422, so without this a sold-out line sails on to the cart redirect).

const { addToCart, isAdding, isAdded, error } = useBundleAjaxCart({
    onAdded: () => {
        window.location.href = '/cart';
    },
});

<button disabled={isAdding} onClick={() => addToCart(bundle, selections)}>
    {isAdding ? 'Adding…' : 'Add to cart'}
</button>;
{error && <p role="alert">{error.message}</p>}

UseBundleAjaxCartOptions: onAdded(result) and onError(error, reason) callbacks, plus routePrefix for locale-scoped stores ('/en-gb' gives /en-gb/cart/add.js) and fetchImpl to substitute the fetch implementation.

useStorefrontBundleCart(options?)@kitenzo/react/hydrogen

The same contract, for a cart driven by @shopify/hydrogen-react. Lives behind a subpath because @shopify/hydrogen-react is an optional peer dependency: importing @kitenzo/react pulls in none of it.

import { useStorefrontBundleCart } from '@kitenzo/react/hydrogen';

const { addToCart, isAdding, isAdded, error, hasMissingItems } = useStorefrontBundleCart();

Needs a <CartProvider> ancestor — hydrogen-react's own, with no callbacks wired.

UseStorefrontBundleCartOptions: the same onAdded / onError callbacks, plus timeoutMs (default 30000, 0 disables). The timeout exists because hydrogen-react can drop a mutation without a trace; without a ceiling the button would spin for the rest of the session.

What it handles, each of which has bitten a widget in production:

| Behaviour | Why | | --- | --- | | Refuses to start unless status is idle/uninitialized | Mid-flight there is no cart object, so _bundles would be replaced and a second cart created over the shopper's real one | | First add is one cartCreate({ lines, attributes }) | No window where the lines exist without the discount data | | Attributes written only after the line add settles | hydrogen-react silently drops a mutation sent mid-flight: no error, no queue | | isAdded waits for the attributes mutation | Gate the checkout link on it, or a fast clicker checks out before _bundles lands and Cart Transform prices the bundle at full price | | Watches cart.error | hydrogen-react has no failure callbacks and no 'error' status — a failure looks exactly like a success | | Exposes hasMissingItems | hydrogen-react never queries userErrors, so a rejected line (usually a variant unpublished to the token's sales channel) is dropped with no error at all |

KitenzoShopifyProvider@kitenzo/react/hydrogen

ShopifyProvider throws and white-screens the whole widget on an empty-string storeDomain or storefrontToken — and on a themed store the token is legitimately blank, because the cart goes through /cart/add.js. This wrapper substitutes placeholders so blank config degrades instead of exploding.

KitenzoShopifyProviderProps: storeDomain (with or without a scheme), storefrontToken, storefrontApiVersion (default "2025-10"), countryIsoCode (default "US") and languageIsoCode (default "EN") — all optional.

import { KitenzoShopifyProvider } from '@kitenzo/react/hydrogen';

<KitenzoShopifyProvider storeDomain={config.shopDomain} storefrontToken={config.storefrontToken}>
    <CartProvider>
        <KitenzoProvider apiKey={config.apiKey}>
            <Widget />
        </KitenzoProvider>
    </CartProvider>
</KitenzoShopifyProvider>;

useBundlePrice(bundle, selections, options?)

Calculates pricing locally — no API call, no loading state. Updates instantly as selections change. Currency and money format are read automatically from shop settings. Pass { currency } to override.

const {
    formattedOriginalPrice,   // "$45.00" (formatted with shop's moneyFormat)
    formattedDiscountedPrice, // "$40.50"
    hasDiscount,              // true
    originalPrice,            // "45.00" (raw)
    discountedPrice,          // "40.50" (raw)
    discountType,             // "percentage"
    discountValue,            // "10.00"
    currency,                 // "USD"
} = useBundlePrice(bundle, selections);

When countryCode is set on <KitenzoProvider>, the bundle is loaded for that Shopify Market and this hook returns presentment-currency prices, formatted with Intl.NumberFormat (formattedOriginalPrice"€40,50", currency"EUR") instead of the shop's base moneyFormat. No code change needed — see below.

Before the first selection, every field is null — except on a bundle whose discount is a flat fixed price, where the final price is already known. There discountedPrice / formattedDiscountedPrice carry that price (converted to the market currency when the bundle was market-loaded), so a "Build your own box — $65.00" bundle shows its price straight away rather than a blank. originalPrice stays null and hasDiscount stays false: an empty bundle has no meaningful compare-at, so nothing renders a struck-through zero.

useSettings()

Returns shop settings auto-fetched by KitenzoProvider. Returns null while loading.

const settings = useSettings();
// settings?.currency — "USD", "EUR", etc.
// settings?.moneyFormat — "${{amount}}", "€{{amount}}", etc.

It also carries the merchant's out-of-stock preferences, so a custom UI can make the same display decisions the built-in builder makes instead of guessing:

// Hide sold-out products, or show them with a disabled add button?
if (settings?.hideOutOfStockProducts) products = products.filter(inStock);

// Same toggle governs DRAFT / ARCHIVED products.
if (settings?.hideDraftProducts) products = products.filter((p) => p.status === 'ACTIVE');

// "show-all" | "hide-unavailable" | "hide-unavailable-and-out-of-stock".
// `reachableOptionValues` implements the last of these, so a shop set to
// "show-all" needs the UI to offer the withheld values itself.
settings?.hideOptions;

// Enforce stock even where Shopify does not track it (inventory lives elsewhere).
settings?.thirdPartyInventoryCheck;

Each is optional: undefined means the API predates the field, which is not the same as false.

useKitenzo()

Returns the KitenzoClient instance from the nearest <KitenzoProvider>. Useful for direct API calls.

const client = useKitenzo();
const settings = await client.getSettings();

Shopify Markets (localised pricing)

Set countryCode on the provider and every price the widget fetches and shows is in the shopper's market currency — useBundle loads presentment prices + market availability, useBundlePrice formats the presentment currency, and useBundleCart forwards the country to the configure call. No other code changes.

<KitenzoProvider apiKey={apiKey} countryCode="DE">
    <App />
</KitenzoProvider>

Changing countryCode re-fetches for the new market. Each variant then carries presentmentPrice, presentmentCurrency, priceInShopCurrency and availableForSale (whether it's sellable in that market — gate selection / add-to-cart on it).

Match the cart. Use the same country for the widget and your Shopify cart's buyerIdentity.countryCode, or the price shown won't match checkout.

Hydrogen example

Derive the country from Hydrogen's i18n and pass it straight through — to the widget and to the cart's buyer identity:

// root loader — Hydrogen populates storefront.i18n from your locale routing
export async function loader({ context }: LoaderFunctionArgs) {
    return { country: context.storefront.i18n.country }; // e.g. "DE"
}

function App() {
    const { country } = useLoaderData<typeof loader>();
    return (
        <KitenzoProvider apiKey={apiKey} countryCode={country}>
            <Outlet />
        </KitenzoProvider>
    );
}

// when adding the bundle, set the SAME country on the Hydrogen cart
await cart.updateBuyerIdentity({ countryCode: country });

For SSR, prefetch in the loader with the same country so the first paint is already localised (pass it to useBundle via initialData):

const client = new KitenzoClient({ apiKey, countryCode: context.storefront.i18n.country });
const bundle = await client.getBundle(Number(params.id));

Out of scope: translated product titles (locale) are a separate concern from price; personalisation fees and subscription prices stay in the shop's base currency.

Core Re-exports

Everything from @kitenzo/core is re-exported, so React consumers only need @kitenzo/react:

import {
    KitenzoClient,
    createBundleBuilder,
    buildCartPayload,
    buildCartLines,
    calculatePrice,
    formatCurrency,
    formatMoney,
    resolvePresentmentPricing,
    resolveMarketPricingContext,
    resolveUpfrontFixedPrice,
    // Limit-rule windows (see @kitenzo/core → Limit Rules)
    getSectionLimits,
    getBundleLimits,
    UNBOUNDED,
} from '@kitenzo/react';

See the @kitenzo/core README for client, builder, and utility documentation.

SSR with Remix / Hydrogen

import { KitenzoClient, useBundle, useBundleBuilder } from '@kitenzo/react';

export async function loader({ params, context }) {
    const client = new KitenzoClient({
        apiKey: context.env.KITENZO_API_KEY,
    });
    return { bundle: await client.getBundle(Number(params.id)) };
}

export default function BundlePage() {
    const { bundle: loaderBundle } = useLoaderData();
    const { bundle } = useBundle(loaderBundle.id, { initialData: loaderBundle });
    // ... build your UI with useBundleBuilder, useBundlePrice, etc.
}

Troubleshooting

"Missing KitenzoProvider" -- Ensure <KitenzoProvider> wraps the component tree.

CORS errors -- Check Allowed origins in Settings > Headless.

No discount at checkout -- Make sure cart attributes from buildCartPayload() are applied via cartAttributesUpdate().

Price shows null -- Pricing only calculates once at least one variant is selected.