@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/reactPeer 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".
BundleCartOutcome — addToCart 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.
