biznexus-store-sdk
v0.9.0
Published
Browser SDK for embedding a BizNexus tenant's online store catalog + checkout on any website. Public per-store API key + Firebase Anonymous Auth (via direct REST calls, no firebase package) - no secrets shipped to the client, and zero runtime dependencies
Readme
biznexus-store-sdk
Embed a BizNexus tenant's online store (catalog, variants, reviews, wishlist, and M-Pesa checkout)
on any website - not just genetech-website - with no server-side code of your own and no
secrets in the page. Published to the public npm registry (nothing in this package is a secret -
see "How auth works" below).
Zero runtime dependencies - Firebase Auth (anonymous sessions and real email/password accounts) is
done via direct REST calls (see src/authSession.js), not the firebase package, so nothing else
ships to the page either.
Get a key
In the BizNexus app: Online Store → Stores → New Store. Copy the store's public key
(pk_store_...) - that's the only credential you need. It's publishable, not secret: safe to put
directly in your page's JS. It only ever lets a caller read that one store's visible catalog and
place orders against it - nothing else, and it can be rotated (instantly revoking the old one) any
time from the same screen.
By default a brand-new store accepts requests from any origin. Once you know your site's real domain, add it under that store's Allowed Origins to lock it down.
Install
npm install biznexus-store-sdkimport { OnlineStoreClient, StoreCart, formatCurrency } from "biznexus-store-sdk";
const store = new OnlineStoreClient({ publicKey: "pk_store_xxxxxxxx" });Quick start (plain <script> tag, no build step, no npm at all)
For a site with no bundler (WordPress, a static HTML page, etc.), copy
dist/biznexus-store-sdk.iife.js wherever that site serves static assets from:
<script src="/assets/biznexus-store-sdk.iife.js"></script>
<script>
const store = new BiznexusStoreSDK.OnlineStoreClient({ publicKey: "pk_store_xxxxxxxx" });
store.getCatalog().then((listings) => {
console.log(listings); // [{ id, type, name, price, quantity, availability, image_url, ... }]
});
</script>API
Catalog
await store.getCatalog({ type: "product", minPrice: 500, maxPrice: 5000, limit: 50, offset: 0 }); // all optional
await store.getListing(12); // single product/service, for a detail page
await store.getCategories(); // ["Electronics", "Home", ...] - for a filter sidebar
await store.search({ q: "laptop", type: "product", minPrice: 500, maxPrice: 5000 }); // FULLTEXT, prefix-matching search
await store.getBranding(); // { name, tagline, logo_url, primary_color, accent_color }
await store.getVatRate(); // number, e.g. 0.16Every catalog/search row also carries avg_rating/review_count (see Reviews below), and
size/color if the listing is part of a variant group. getListing(id) additionally returns:
const listing = await store.getListing(12);
listing.gallery_images; // string[] - extra photos beyond the cover image_url
listing.variants; // this listing's size/color siblings, itself included (see Variants below)
listing.rating_breakdown; // { 5: 12, 4: 3, 3: 0, 2: 0, 1: 1 } - for a star bar chartEvery read above is cached in-memory for a short TTL (30s for catalog/search/listing/categories,
5min for branding/VAT) with in-flight de-duping - two components asking for the same thing at once
share one request. Call store.clearCache() if your own app knows something changed and you want
the next read to hit the network regardless. This is a small built-in cache, not RTK Query/Redux -
deliberately, so the SDK works the same whether or not your app uses Redux. If you do use RTK
Query, wrap this client's methods in your own baseQuery - it's a plain class, nothing here fights
that.
Variants (size/color)
A "variant" (e.g. "Red T-Shirt, size M") is just another listing row with size/color set,
grouped with its siblings under a shared parent. There's no separate variant object to fetch -
getListing(id) on any row in the group returns the whole family via variants:
const listing = await store.getListing(12);
listing.variants;
// [
// { id: 12, name: "T-Shirt", size: "S", color: "Red", price: 1200, quantity: 4, availability: "in_stock", image_url: "..." },
// { id: 13, name: "T-Shirt", size: "M", color: "Red", price: 1200, quantity: 0, availability: "out_of_stock", image_url: "..." },
// { id: 14, name: "T-Shirt", size: "M", color: "Blue", price: 1200, quantity: 9, availability: "in_stock", image_url: "..." },
// ]A listing with no variant group just gets itself back (variants.length === 1) - check that before
deciding whether to render a size/color picker at all. Picking a different variant is just
getListing(otherVariant.id) (or use the row already in hand from variants) - each has its own
independent price/stock/checkout, same as any other listing.
size is a plain string with no fixed vocabulary - "S"/"M"/"L", a shoe size ("42", "9.5"), or
anything else a tenant's staff typed when creating the listing. variants comes back pre-sorted
server-side so you don't have to: numeric-looking sizes ("6", "7", "8", "9", "10") sort in actual
numeric order rather than insertion order (where "10" would otherwise land before "9"), and
non-numeric sizes fall back to alphabetical - no client-side sorting needed. A variant's own
availability/quantity still tell you whether that specific size/color is in stock; a picker
built from variants should render every size (so a shopper can see what exists at all) but
visually distinguish the sold-out ones rather than only showing what's currently purchasable - see
templates/sokoni-store-react's Product screen for a reference implementation (grays out sold-out
size/color chips instead of hiding them).
Reviews & ratings
Reading reviews needs no account; writing one does (see Accounts below).
await store.getListingReviews(12, { limit: 20, offset: 0 }); // newest first
// [{ id, rating, title, body, is_verified_purchase, reviewer_name, created_at }, ...]
await store.submitReview(12, { rating: 5, title: "Great fit", body: "Runs true to size." });
// re-submitting for the same listing edits your existing review, not a duplicate
await store.deleteReview(12); // deletes your own review onlyis_verified_purchase is set automatically (from whether you have a paid order containing that
listing) - there's no input for it.
Wishlist (requires an account)
await store.getWishlist(); // full listing objects, newest-saved first
await store.addToWishlist(12); // idempotent - adding twice is a no-op
await store.removeFromWishlist(12);Delivery addresses (requires an account)
A saved address is a map pin, not a typed street address - riders/drivers navigate to a
pin far more reliably than they parse free text. Bring your own map picker (any library
that gives you a { lat, lng } on click/drag works); this SDK just stores/retrieves the
result.
await store.getMyAddresses();
// [{ id, label, latitude, longitude, notes, is_default, created_at, updated_at }, ...]
// default pin (if any) first
const home = await store.addAddress({
label: "Home",
latitude: -1.2921,
longitude: 36.8219,
notes: "Blue gate, call on arrival", // optional
isDefault: true, // optional - exclusive, unsets any other default
});
// a shopper's very first address becomes default automatically even without isDefault
await store.updateAddress(home.id, { label: "Home (new)", isDefault: true });
await store.removeAddress(home.id);Pass one of these straight to createOrder as deliveryAddressId at checkout - see below.
Cart (local, no server round-trip)
There's no server-side "cart" - an order is created directly from a line-item list at checkout.
StoreCart is a small localStorage-backed helper so you don't have to build that part yourself:
import { OnlineStoreClient, StoreCart } from "biznexus-store-sdk";
const cart = new StoreCart();
const listing = await store.getListing(12);
cart.add(listing, 2); // survives a page reload
cart.setQuantity(listing.id, 3);
cart.remove(listing.id);
cart.itemCount; // 3
cart.subtotal; // sum of price * quantity
cart.toOrderItems(); // [{ listing_id, quantity }] - ready for createOrder belowCheckout
await store.checkAvailability(cart.toOrderItems()); // pre-flight - surfaces "only 2 left" before payment
// { items: [{ listing_id, available, reason?, quantity_available? }], all_available }
const order = await store.createOrder({
items: cart.toOrderItems(),
customerName: "Jane Doe",
customerPhone: "254712345678",
customerEmail: "[email protected]", // optional
notes: "Leave at the gate", // optional
// Delivery is entirely optional - omit deliveryMethod for the same
// behavior as before (no delivery info recorded, business handles
// fulfillment out of band).
deliveryMethod: "delivery", // 'pickup' | 'delivery'
deliveryAddressId: address.id, // one of getMyAddresses()'s saved pins (requires an account) ...
// ...OR, for a guest or a one-off location, skip deliveryAddressId and pass a pin directly:
// deliveryLatitude: -1.2921,
// deliveryLongitude: 36.8219,
// deliveryLabel: "Office - 4th floor",
// deliveryNotes: "Blue gate, call on arrival",
});
// order.status === 'processing' - an M-Pesa prompt has been sent to customerPhone
const finalStatus = await store.waitForOrder(order.public_token, {
onUpdate: (status) => console.log("still", status.status),
});
// resolves once status leaves 'pending' (paid/failed/cancelled), or throws after ~90s of no answerdeliveryMethod: "delivery" requires either deliveryAddressId or both deliveryLatitude/deliveryLongitude - the server rejects the order otherwise. deliveryMethod: "pickup" (or omitting it) needs none of the above. Whichever pin is used is snapshotted onto the order at checkout time, so editing or deleting a saved address later never changes a past order's delivery location.
formatCurrency(order.total_amount) → "KES 4,500".
Event tracking
getListing(), search() (when q is given) and createOrder() already fire product_viewed / product_searched / checkout_started + order_created on their own - nothing to wire up. For anything else (most notably adding to cart, since StoreCart is a deliberately network-free local class with no reference to the client):
const lines = cart.add(listing, quantity);
store.track("product_added_to_cart", { listingId: listing.id, properties: { quantity } });track() is fire-and-forget and never throws - a tracking failure never breaks or slows down the storefront. This is the raw event log a future BizNexus dashboard reads conversion analytics/abandoned-cart detection/recommendations from - nothing computes those yet, this just makes sure the data exists.
Accounts (optional - guest checkout always works)
Every method above already works with no account at all - createOrder works from a plain
anonymous session. Accounts are additive: a shopper who signs up gets order history and doesn't
have to retype their details next time, but nothing requires it. Each account is scoped to this
one store - the same email/password used at a different BizNexus-powered store is a completely
separate account there, with its own separate order history.
// { name, phone } are optional - saved to the shopper's profile right after signup.
const customer = await store.signUp({
email: "[email protected]",
password: "correct horse battery staple",
name: "Jane Doe",
phone: "254712345678",
});
const customer2 = await store.logIn({ email: "[email protected]", password: "..." });
store.getLocalUser();
// { uid, email, isAnonymous } for whichever session is currently active - synchronous, no
// network call, safe to check on every render for "logged in as ..." UI.
await store.getCurrentCustomer(); // { id, email, name, phone, created_at }
await store.updateProfile({ name: "Jane K. Doe" });
await store.getMyOrders({ limit: 20 }); // this store's orders placed while logged in
await store.sendPasswordReset("[email protected]"); // emails a reset link if that account exists
await store.sendEmailVerification(); // emails a 6-digit code to the current account
await store.confirmEmailVerification("123456"); // marks it verified once jane enters that code
store.logOut(); // falls back to a fresh anonymous session - browsing/guest checkout keeps workingcreateOrder automatically links the order to the logged-in shopper when there's an active
account - no extra step needed at checkout for that to show up in getMyOrders() afterward. A
guest order placed before signing up is never retroactively linked.
How auth works (and why there's no "secret key")
Nothing shipped to the browser in this SDK is a secret - a secret embedded in client-side JS isn't actually secret (anyone can read it from dev tools). Instead:
- Public key identifies which store, and is checked against that store's registered origin allowlist server-side.
- A Firebase session token - anonymous by default (handled automatically by this SDK on first
use, via direct REST calls to Identity Toolkit - see
src/authSession.js- not thefirebasepackage), or a real one oncesignUp/logInhas been called - proves the request came from a real browser session, not a fabricated request. The backend only ever uses this to tell "real account" from "anonymous" (for the account-only endpoints above) - the public key + origin above are what actually scope what it can access. - Firebase App Check (optional, recommended once you've set it up for your own site) is the
real bot/scraping deterrent - pass a
getAppCheckTokenfunction to the client if you have it.
See api/middleware/storefrontAuth.js in the main repo for exactly what the backend checks on
every request.
Prerequisite: both the Anonymous and Email/Password sign-in providers need to be
enabled for the genetech-hub Firebase project (Console → Authentication → Sign-in method) -
signUp/logIn fail with auth/operation-not-allowed otherwise.
Building from source
npm install
npm run buildOutputs dist/biznexus-store-sdk.esm.js, dist/biznexus-store-sdk.iife.js (global
BiznexusStoreSDK), and dist/index.d.ts.
Not yet included
- Faceted filtering beyond type/category/price (brand, rating threshold, etc.) -
search/getCatalogcover type + price range today; anything richer needs client-side post-filtering over a page of results for now. - Coupons/discount codes, flash sales, and "customers also bought" recommendations.
- True multi-vendor marketplace semantics - every store still belongs to exactly one BizNexus
business (see
db-init/genetech_db.sql'sonline_storestable).
