@moonbase.sh/storefront
v3.8.2
Published
Package to let you embed Moonbase.sh into your storefront as a payment and delivery provider
Downloads
10,097
Readme
@moonbase.sh/storefront
Embeddable Moonbase storefront widget for browser apps.
This package wraps auth, cart, checkout, voucher redemption, product downloads, activation, and subscription management behind one client-side API.
Learn more about our embedded storefront in our official docs: https://moonbase.sh/docs/storefronts/embedded/
Install
pnpm add @moonbase.sh/storefrontQuick start
import Moonbase, { MoonbaseEvent } from '@moonbase.sh/storefront'
Moonbase.setup('https://demo.moonbase.sh', {
toolbar: {
enabled: true,
location: 'top-right',
},
})
Moonbase.on(MoonbaseEvent.CheckoutCompleted, ({ order }) => {
console.log('Order completed', order.id)
})setup should be run once, client-side.
When you load the widget via the CDN script tag:
<script src="https://assets.moonbase.sh/storefront/moonbase.js"></script>
<script>
Moonbase.setup('https://demo.moonbase.sh')
Moonbase.on('checkout-completed', ({ order }) => console.log(order.id))
</script>…you can call Moonbase.setup(...), Moonbase.on(...), and any intent method from anywhere on the page — including inline <script> in <head> — without waiting for DOMContentLoaded. The CDN script is a tiny loader that queues calls and replays them once the real bundle and the DOM are both ready.
When you import from npm (import Moonbase from '@moonbase.sh/storefront'), setup still needs the DOM to be available before it runs, since there is no loader in that path.
Testing pre-release builds with ?mb_storefront_version=
The CDN loader recognises an ?mb_storefront_version= query parameter for opting into a non-default storefront build on a per-page-load basis. This is intended for testing pre-release changes on a live merchant site without changing the embed code.
?mb_storefront_version=next— loads the latest@nextsnapshot from/storefront/next/moonbase.js?mb_storefront_version=pr-<N>— loads the snapshot from a labeled PR<N>from/storefront/pr-<N>/moonbase.js(only present while the PR carries therelease-snapshotlabel)?mb_storefront_version=2.1.0— pins to a specific published version from/storefront/<version>/moonbase.js?mb_storefront_version=latestor omitted — loads the current stable release (default)
Don't use this to pin a production embed to a specific version — pin the <script src> URL instead.
Host page requirements
The widget renders directly into the host page's DOM and inherits its viewport. For the widget to render correctly on mobile, the host page should declare a standard responsive viewport:
<meta name="viewport" content="width=device-width, initial-scale=1" />Hosts that ship a fixed-width legacy mobile viewport (for example Wix's classic mobile sites use <meta name="viewport" content="width=320, user-scalable=yes">) cause the browser to upscale every CSS pixel on the page. The widget auto-detects this and counter-scales itself with CSS zoom to remain readable; opt out with disableViewportCompensation: true if you want the widget to match the host's upscaled density. On Wix specifically, mounting the widget into a contained element via target is also an option — it switches the drawer from a viewport overlay to an in-flow component and sidesteps the upscale entirely.
Trigger intents
Use intent methods to open specific views or execute actions:
Moonbase.sign_in({ email: '[email protected]' })
Moonbase.add_to_cart({
product_id: 'my-product',
quantity: 1,
})
Moonbase.view_cart()
Moonbase.checkout()
Moonbase.view_products()All intents are snake_case methods on the Moonbase instance (for example view_product, manage_subscription, redeem_voucher, join_group).
Configure behavior and theme
You can pass options in setup(...) and update them later with configure(...):
Moonbase.configure({
auth: {
signUp: {
enabled: false,
},
},
cart: {
quantity: 'single',
},
checkout: {
redirect: 'auto',
},
pricing: {
trailingZeros: 'always',
currencyDisplay: 'code',
},
theme: {
colors: {
primary: '#E5A000',
background: 'white',
},
fonts: {
heading: 'Aleo',
body: 'Inter',
},
},
})checkout.redirect decides where checkout happens:
| Value | Behavior |
| --- | --- |
| 'auto' (default) | Overlay on desktop, full-page redirect to the hosted checkout on mobile. |
| 'always' / true | Always redirect to the hosted checkout. |
| 'never' / false | Always use the overlay. |
The overlay keeps buyers on your page, but it cannot show Apple Pay or Google Pay.
Those wallets require the top-level page's domain to be a registered payment-method
domain, and for an embedded storefront that is your own site, not Moonbase's — no
amount of configuration on our side changes that. Redirecting makes the hosted
checkout the top-level document, so the wallets appear. 'auto' takes that trade only
on mobile, where wallet usage concentrates, and keeps the overlay everywhere else.
Phones redirect; tablets and desktops get the overlay.
A redirected buyer is always brought back to your page once they've paid, so
checkout-completed fires and the cart clears exactly as it does with the overlay.
The hosted checkout stays anonymous for the trip — it prefills from the order rather
than signing the buyer into a Moonbase account, matching the overlay's behavior.
Customer groups
If you keep customer lists in Moonbase and have opened one to public sign-ups, name it by ID and the widget enrolls people who use the matching form:
Moonbase.configure({
groups: {
signUp: ['newsletter-2026'],
subscribe: ['newsletter-2026'],
checkout: ['buyers-2026'],
},
})Enrollment is silent. There is no checkbox and no label, because the API deliberately tells a storefront nothing about a group: an ID naming a list the merchant has not opened to public sign-ups is skipped without a word, so a stale ID here can never fail somebody's registration or somebody's purchase. At most five groups per request are applied, and IDs are matched lowercase.
signUp and subscribe can be added to per link with mb_groups:
<a href="?mb_intent=sign_up&mb_groups=beta,vip">Join the beta</a>The two are unioned rather than overridden: configure names the lists everyone
signing up through your site joins, the link adds whichever the campaign is for.
A sign-up form on your own page adds to subscribe the
same way, with a field named groups.
checkout is configuration-only, because the cart outlives any one link.
For somebody who already has an account, join_group does it as a request of its
own, and unlike the three above it reports whether it worked:
<a href="?mb_intent=join_group&mb_group_id=beta">Join the beta list</a>It signs the visitor in first if they aren't, and emits joined-group on
success. Joining a list twice is not an error.
Fonts
theme.fonts.heading and theme.fonts.body each take one of:
| Value | What gets loaded |
| --- | --- |
| A built-in name, e.g. 'Montserrat' | The widget loads that family from assets.moonbase.sh. |
| 'inherit' | Nothing. The widget renders in whatever font your page already uses. |
| 'system' | Nothing. The widget uses its own system stack. |
| { family } | Nothing. Names any family your page already loads. |
| { family, stylesheet } | The stylesheet you name, from wherever you host it. |
The built-in families are Inter, Roboto, EB Garamond, Merriweather, Poppins,
PT Serif, Montserrat and Aleo. Either slot accepts any of them.
They are served from Moonbase's own asset host — the same origin that serves the widget
itself, and the same payload the hosted checkout uses, so the two share a cache entry.
Nothing is requested from Google Fonts, which matters if you sell into mainland China
(where fonts.googleapis.com is unreachable) or want to keep visitor IPs away from
Google.
The distinction that catches people out: 'Inter' means "load Inter for me";
{ family: 'Inter' } means "the family is Inter, I already load it".
// Inherit your page's fonts. Loads nothing, requests nothing.
Moonbase.configure({ theme: { fonts: { heading: 'inherit', body: 'inherit' } } })
// Self-hosted faces — the widget injects the stylesheet.
Moonbase.configure({
theme: {
fonts: {
heading: { family: '"Noto Serif SC", serif', stylesheet: '/assets/fonts/noto-serif-sc.css' },
body: { family: '"Noto Sans SC", sans-serif', stylesheet: '/assets/fonts/noto-sans-sc.css' },
},
},
})
// A family your page already loads — the widget just names it.
Moonbase.configure({ theme: { fonts: { body: { family: '"Inter var", sans-serif' } } } })pricing.trailingZeros controls how whole prices render. The default 'auto'
drops the decimals — a $10.00 product shows as $10. Set it to 'always' to keep
the currency's own fraction digits instead ($10.00, and still ¥1000 for
zero-decimal currencies like JPY). It applies to the widget's own UI and to
data-moonbase-render price elements on your page alike.
pricing.currencyDisplay picks which marker identifies the currency. Taking $10 in
USD read from a British locale, the default 'narrowSymbol' renders the shortest
symbol ($10), 'symbol' the locale's usual one (US$10), 'code' the ISO 4217
code (USD 10), and 'name' spells it out in the visitor's language
(10 US dollars). The symbol and its placement still follow the visitor's own
locale — this only chooses between the four forms.
Both pricing options cover the widget's UI, your data-moonbase-render price
elements, and the checkout itself: the widget passes them to Moonbase's own
checkout page, so the prices a buyer pays are written the same way as the ones
that got them there.
cart.offers.cartWide covers offers keyed off the cart as a whole, such as "10% off orders
over $50", rather than off a single product. These apply themselves the moment the
cart qualifies, so there is nothing for the buyer to press. While the cart is short
of the threshold the widget shows a meter and how much further to go; once it
qualifies, the saving appears as its own row above the total.
Moonbase.configure({
cart: {
offers: {
cartWide: {
showProgress: true,
savingLabel: 'You save',
},
},
},
})Set showProgress to false to let such an offer apply silently: the saving still
shows in the totals, the nudge does not. With no cart-wide offer configured on your
storefront, the meter never renders either way. savingLabel names the saving row
in the cart totals.
Offers keyed off a single product work the other way round: the widget surfaces one
as a card in the cart, and the buyer presses it to take the offer. That card is
hidden once the offer's product is already in the cart, so a buyer who adds that
product before the one that qualifies them for the offer never gets a chance to
claim it. cart.offers.autoApply closes that gap.
Moonbase.configure({
cart: {
offers: {
autoApply: true,
},
},
})With it on, the cart applies an eligible offer to the product it discounts by itself, once every product that offer names is in the cart. It never adds anything to the cart on the buyer's behalf, and it never takes an offer the cart is still selling: an offer naming a product they have not added keeps showing as a card and still takes a press, since an offer is good for one line only and applying it early would take that card away. It never replaces an offer already on a line either. Off by default.
That governs when an offer is taken rather than where it stays: remove one of the products that qualified an offer and it stays on the line it landed on, just as an offer claimed from a card does. A buyer can already put a multi-target offer on one product and never add the other, so nothing here is a cart they couldn't have built by hand.
cart.minimumTotal sets the smallest order the cart will check out. Below it, the
checkout button gives way to a meter of the same size that fills as the cart grows
and tells the buyer how much more to spend; it turns back into the button once the
cart reaches the minimum.
Moonbase.configure({
cart: {
minimumTotal: { USD: 25, EUR: 25, GBP: 20 },
},
})Amounts are keyed by currency code, in major units, because Moonbase picks each
visitor's currency and the widget never converts one into another. A currency you
leave out has no minimum. The minimum is measured on the cart's Total, after product
discounts and offers but before coupons and tax, and a cart holding nothing but free
items is exempt. The checkout intent respects it too: below the minimum it opens
the cart instead of checking out. The widget enforces this, not the API, so
purchase() and checkouts you build outside the widget are not held to it. A value
you pass replaces the previous one; pass {} to remove it. No minimum by default.
You can also control where the widget mounts by providing target.
Promotions
When you run a sale across your catalogue, the widget surfaces it on its own — as a banner, a popup, or both, depending on what you asked for when you set the promotion up in Moonbase. Nothing to wire in: the sale appears while it is live and to the shoppers it applies to, and disappears when it ends.
Once a shopper dismisses a promotion it stays dismissed in that browser, for as long as the promotion runs. Editing the campaign doesn't bring it back — a shopper who has turned a sale away isn't asked again. Start a new promotion to reach them again.
The two surfaces are tracked separately, so closing the popup leaves the banner up as a reminder that the sale is still on. A popup also gets a single interaction: clicking through to the campaign retires it for good, the same as closing it would, since it interrupted the shopper to ask. A banner interrupted nobody, so clicking through leaves it standing — only an explicit dismissal takes it down.
By default the banner pins itself to the top of the viewport. Give the widget a container and it renders inline in your own layout instead — anywhere on the page:
<div data-moonbase-promotion></div>Moonbase.configure({
promotions: {
// Set to false to render sales yourself.
enabled: true,
// 'top' | 'bottom' — only used when the page offers no container.
location: 'top',
dismissLabel: 'Dismiss',
},
})Where a sale leads, and the wording on the link, both come from the promotion itself — only the merchant running the campaign knows what it is offering. A sale with no destination set shows the dismiss action as its only button.
To render sales entirely yourself, turn the built-in surfaces off and listen for
promotion-shown, or read them from the usePromotions() composable in
@moonbase.sh/vue.
Newsletter sign-up forms
A sign-up form on your own page, in your own design, can go straight to Moonbase. Mark it
with data-moonbase-form="subscribe" and the widget sends it in the background instead of
letting the browser load a new page:
<form data-moonbase-form="subscribe">
<input name="name" placeholder="Name" maxlength="200">
<input name="email" type="email" placeholder="[email protected]" required>
<button>Subscribe</button>
</form>The widget reads the fields by name:
| Field | |
| --- | --- |
| email | Required. |
| name | Optional, up to 200 characters. |
| groups | Optional group IDs, comma-separated in one field or repeated (checkboxes, say). Added to groups.subscribe, as mb_groups is on a link. |
The emails signed up for are the ones communicationPreferences.show names, as for the
drawer's own subscribe form, and whether the visitor has to confirm by email first is your
account's double opt-in setting. The form is yours, so the consent wording is too: the drawer's
form says "By subscribing you agree to receive newsletter and product update emails", and
yours should say as much.
Showing the result
By default the drawer opens on the outcome: "You're subscribed!", or "Check your email to finish" under double opt-in. If the server turns the email down, the drawer opens its own form, filled in from yours, with the reason under the button, so the visitor can correct it there.
To keep everything on your page, add data-moonbase-feedback="inline". The drawer then stays
closed, and the widget reports progress on the form itself for you to style:
<form class="newsletter" data-moonbase-form="subscribe" data-moonbase-feedback="inline">
<div class="fields">
<input name="email" type="email" required>
<button>Subscribe</button>
</div>
<p data-moonbase-message></p>
</form>| data-moonbase-state | When |
| --- | --- |
| submitting | The request is out. The form's submit buttons are disabled until it returns. |
| subscribed | The visitor is on the list. |
| confirmation_sent | Double opt-in: a confirmation email went out, and the visitor is on the list once they click it. |
| error | The server turned the email down, or the request failed. |
A [data-moonbase-message] element inside the form gets the outcome as text, worded as the
drawer words it, and is made a live region so screen readers announce it. Leave it out to show
your own wording instead:
.newsletter:is([data-moonbase-state="subscribed"], [data-moonbase-state="confirmation_sent"]) .fields {
display: none;
}
.newsletter[data-moonbase-state="error"] [data-moonbase-message] {
color: #c00;
}Style both success states. For a visitor who is not signed in, the status follows your double
opt-in setting rather than whether the address was new, so the form cannot be used to find out
who is subscribed already. With double opt-in on, nearly every sign-up ends in
confirmation_sent.
data-moonbase-state is set in the default mode as well, so a spinner can cover the moment
before the drawer opens.
Reacting in code
Every successful sign-up emits subscribed, from these forms and from the drawer's own:
Moonbase.on(MoonbaseEvent.Subscribed, ({ email, status, source }) => {
// source is 'form' for a data-moonbase-form form, 'drawer' for the drawer's own
})Good to know
- The attribute hands the form to Moonbase: the widget claims the submit before any other
script on the page sees it, so a site builder's own form handling does not post it somewhere
else as well. Your own submit listeners on that form do not run either; use the
subscribedevent instead. - Loaded from the CDN, the widget holds a form submitted before it is ready and sends it once
setup()has run. Installed from npm, callsetup()early: until then the browser submits the form as a plain form. - A signed-in customer subscribes the address they type in, which is usually their own.
- Forms inside a shadow root are not picked up.
- An unknown
data-moonbase-formvalue is not submitted, and logs a warning to the console. - To open the drawer's own form instead, filled in, call
Moonbase.subscribe({ email, name })or link to?mb_intent=subscribe&mb_email=...&mb_name=....
Events
Subscribe to widget lifecycle events with Moonbase.on(...).
Available events include:
signed-insigned-upsigned-outsubscribedstorefront-updatedpromotion-shownpromotion-clickedpromotion-dismissedredeemed-voucherjoined-groupdownloaded-productactivated-productadded-to-cartcheckout-initiatedcheckout-closedcheckout-completed
Integrations
The widget can forward its commerce and auth events to common marketing/analytics SDKs without any extra glue code. Enable a provider by passing its config in integrations:
Moonbase.setup('https://demo.moonbase.sh', {
integrations: {
metaPixel: { pixelId: '1234567890' },
googleAnalytics: { measurementId: 'G-XXXXXXX' },
googleTagManager: { containerId: 'GTM-XXXXXXX' },
klaviyo: { companyId: 'ABC123' },
tiktokPixel: { pixelId: 'C12345...' },
},
})The widget never loads SDK scripts itself — it only forwards to globals the host page has already initialised (window.fbq, window.gtag, window.dataLayer, window.klaviyo, window.ttq). If a global is missing when an event fires, the forwarding is a silent no-op (with a one-time console.warn so a misconfiguration is discoverable).
You install the SDK; Moonbase forwards to it
You load each SDK with its own official snippet, exactly as you would without Moonbase — Moonbase only needs the same id so it can forward. Load order doesn't matter: if the global appears after setup(), forwarding begins as soon as it's there. For example, Meta Pixel:
<!-- Your existing Meta Pixel snippet -->
<script>
!function(f,b,e,v,n,t,s){/* …standard fbq loader… */}(window,document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '1234567890');
fbq('track', 'PageView');
</script>// Hand Moonbase the same id so it can forward commerce/auth events to fbq:
Moonbase.setup('https://demo.moonbase.sh', {
integrations: { metaPixel: { pixelId: '1234567890' } },
})The same pattern applies to GA4 (gtag), GTM (dataLayer), Klaviyo (klaviyo), and TikTok (ttq): install the vendor snippet, then pass the matching id. For Klaviyo, both the modern onsite object (klaviyo.identify() / klaviyo.track()) and the legacy klaviyo.push([...]) queue are supported automatically. The googleTagManager.containerId is informational/reserved — GTM reads from window.dataLayer regardless of which container loaded it, so it isn't used at runtime.
Consent
Forwarding is unconditional: Moonbase forwards to whatever SDK global is present, and relies on that SDK's own consent state (e.g. Google Consent Mode v2, or your CMP gating fbq/ttq). To suppress forwarding entirely until consent is granted, omit the provider from setup() and add it once consent lands:
onConsentGranted(() => Moonbase.configure({ integrations: { metaPixel: { pixelId: '1234567890' } } }))configure({ integrations }) can add, change, or remove providers at any time. Merge semantics apply per provider: pass a provider to add or update it, pass it as undefined (or null) to remove it, and omit it to leave it unchanged — so granting one provider after consent never disturbs the others.
// Stop forwarding to Meta Pixel, leave every other provider running:
Moonbase.configure({ integrations: { metaPixel: undefined } })Identity
signed-in / signed-up forward an identify call when an email/id is available (Meta advanced match, GA4 user_id, Klaviyo identify, TikTok identify). On signed-out, Moonbase clears what each SDK supports (GA4 user_id is reset, GTM gets a logout push); Meta, TikTok, and Klaviyo expose no per-user logout, so their profiles persist for the session.
Data conventions
Forwarded order value is the amount the customer pays — total.due, post-discount and tax-inclusive (no separate tax field is sent). Per-item prices are the net (post-discount) unit price. checkout-initiated is the one exception: an order has no settled total until billing details are entered, so its value is the cart's own post-discount total and excludes tax. checkout-completed events carry the order id as a dedup key (Meta eventID, TikTok event_id, GA4/GTM transaction_id) so a completion isn't double-counted.
Event mapping
| Moonbase event | Meta Pixel | GA4 | GTM (dataLayer) | Klaviyo | TikTok Pixel |
| -------------------- | ----------------------- | -------------------- | -------------------- | ------------------------ | --------------------- |
| added-to-cart | AddToCart | add_to_cart | add_to_cart | Added to Cart | AddToCart |
| checkout-initiated | InitiateCheckout | begin_checkout | begin_checkout | Started Checkout | InitiateCheckout |
| checkout-completed | Purchase | purchase | purchase | Placed Order | CompletePayment |
| signed-in | advanced match | login + user_id | login | identify | identify |
| signed-up | CompleteRegistration | sign_up + user_id| sign_up | identify | CompleteRegistration|
| signed-out | — | clears user_id | logout | — | — |
For any event not listed (or a provider that isn't built in), use Moonbase.on(...) and forward by hand.
