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

@code-collective/booking-widget

v1.1.1

Published

A Svelte 5 widget library for embedding ticket sales on tourism websites. Three custom HTML elements handle product selection, cart management, and payment via Peach Payments.

Readme

Booking Widget

A Svelte 5 widget library for embedding ticket sales on tourism websites. Three custom HTML elements handle product selection, cart management, and payment via Peach Payments.

Option 1: HTML Custom Elements (CDN)

Drop in a script tag and use the custom elements directly. No framework or npm install required.

<head>
  <link rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/@code-collective/[email protected]/dist/booking-widget.min.css"
    integrity="sha384-mbTpbdaRTc+mMImDK16lDtNSQLXwygTaIuGQ79qxBtaaAN5PK5yQb3YOoYtHWi7W"
    crossorigin="anonymous" />
</head>
<body>
  <bw-configurator product-id="your-product-id" checkout-key="your-checkout-key"></bw-configurator>
  <bw-cart display="button"></bw-cart>
  <bw-cart display="bar"></bw-cart>
  <bw-checkout></bw-checkout>

  <script
    src="https://cdn.jsdelivr.net/npm/@code-collective/[email protected]/dist/booking-widget.min.js"
    integrity="sha384-mTt5lra/mAhFaN7q2fWAcL3/RnkYSHs3CQJCxueBy+ikpRh8DjXlQNHhG/QZ/VRP"
    crossorigin="anonymous"></script>
</body>

Note: Pin to a specific version (e.g. @1.0.0) when using SRI. The hashes change with every release. Run npm run build:elements to see the current hashes.

1.0.15 fixes a CSS regression from 1.0.13/1.0.14 where the checkout modal's own styles (cart card, pay bar, buttons) were silently missing from the built widget - anyone pinned to an affected version should upgrade.

The script auto-wires the elements together:

  • Adding an item shows the cart and opens the checkout overlay
  • Clicking checkout in the cart opens the overlay
  • Closing or completing checkout closes the overlay

Element attributes

<bw-configurator>

| Attribute | Required | Description | |---|---|---| | product-id | Yes | OCTO product ID | | checkout-key | Yes | Public key identifying the supplier | | wizard-pages | No | JSON string for wizard step ordering | | auto-select-single-time-slot | No | Boolean. Skips the time picker if only one slot | | cancelable | No | Boolean. Shows a cancel button | | no-auto-checkout | No | Boolean. Prevents auto-opening checkout on add | | on-cart-change | No | Name of a global JS function to call on add |

<bw-cart>

| Attribute | Required | Description | |---|---|---| | checkout-key | Yes | Public key identifying the supplier | | display | No | bar (default) or button |

<bw-checkout>

| Attribute | Required | Description | |---|---|---| | checkout-key | Yes | Public key identifying the supplier | | wizard-pages | No | JSON string for wizard step ordering | | edit-pages | No | JSON string for how fields are grouped in the cart edit view | | auto-select-single-time-slot | No | Boolean. Skips the time picker if only one slot | | privacy-policy-url | No | Full URL to your privacy policy, linked from the contact form's consent checkbox. No default - unset renders plain, unlinked text | | support-phone | No | Support number shown on the checkout result screen once a payment has been taken, e.g. 021 511 6000. No default | | support-phone-hours | No | Shown beside the number, e.g. 08h00-17h00. Ignored without support-phone | | support-email | No | Support address shown on the result screen, e.g. [email protected]. No default |

Global options

Set window.bwOptions before the widget script loads:

<script>
  window.bwOptions = {
    shouldBottomCloseOnModal: true,
    autoSelectSingleTimeSlot: false,
    wizardPages: '',
    editPages: '',
    privacyPolicyUrl: '',
    supportPhone: '',
    supportPhoneHours: '',
    supportEmail: '',
  };
</script>
<script src="https://cdn.jsdelivr.net/npm/@code-collective/[email protected]/dist/booking-widget.min.js"
  integrity="sha384-mTt5lra/mAhFaN7q2fWAcL3/RnkYSHs3CQJCxueBy+ikpRh8DjXlQNHhG/QZ/VRP"
  crossorigin="anonymous"></script>

| Option | Default | Description | |---|---|---| | shouldBottomCloseOnModal | true | Hide bar carts when checkout modal opens | | autoSelectSingleTimeSlot | false | Auto-select (and hide) the time picker when only one slot | | wizardPages | '' | JSON wizard step ordering for all elements | | editPages | '' | JSON field grouping for the checkout edit view (bw-checkout only) | | privacyPolicyUrl | '' | Full URL to your privacy policy, linked from the checkout contact form's consent checkbox (bw-checkout only). No default - unset renders plain, unlinked text | | supportPhone | '' | Support number shown on the checkout result screen once a payment has been taken (bw-checkout only). No default | | supportPhoneHours | '' | Shown beside the number, e.g. 08h00-17h00. Ignored without supportPhone | | supportEmail | '' | Support address shown on the result screen. No default |

Window events

All bw:* events are re-dispatched on window:

window.addEventListener('bw:order-confirmed', (e) => {
  console.log(e.detail); // { cartToken, value, currency }
});

| Event | Detail | When | |---|---|---| | bw:cart-change | { itemCount, cartItemId, totalFormatted } | Item added to cart | | bw:cart-updated | { cart } (full CheckoutCartDetailDto, or null if empty) | Cart changed - add, edit, remove, extend, or expiry. Use this to build a custom cart summary (item count, remaining time - see Cart expiry below, item details via cart.items) instead of bw-cart. Fires with cart: null when the cart expires, same as a manual clear | | bw:order-confirmed | { cartToken, value, currency } | Payment completed and confirmed. Fires as soon as confirmation succeeds, while the success screen is still showing - it does not close the modal (see Payment outcomes below) | | bw:modal-open | -- | Checkout modal opens | | bw:modal-close | -- | Checkout modal closes | | bw:checkout | -- | Cart checkout button clicked | | bw:close | -- | Checkout dismissed | | bw:cancel | -- | Configurator cancelled |

Payment outcomes

Peach's own charge and this gateway's confirmation of it are two separate steps - the charge succeeds immediately, but confirmation depends on a webhook that can lag by a few seconds. Confirmation is entirely server-driven: the widget never confirms a cart itself, it only polls the checkout API's status endpoint and renders whatever it reports. A shopper who closes the tab mid-payment is still resolved correctly - nothing depends on their browser staying open.

Peach owns the retries. While it still has attempts left it re-prompts inside its own embedded form and shows its own error screen - its UI even counts them down ("4 of 5 card attempts remaining") - and the widget shows nothing over it. There is no retry counter of ours and no decline screen while that is happening.

When Peach finally hands back without a charge, the widget resolves immediately to Payment Failed with a Try Again button. It does not ask the checkout API what happened: Peach's checkout-level status endpoint answers "transaction pending" for a checkout whose transaction was rejected, so it is only good for confirming a success. Try Again returns to the contact form, and the next Pay Now mints a brand-new Peach checkout over the dead one - the cart itself is never moved. A new checkout is unavoidable (Peach's SDK will not re-render one it has unmounted), but the merchant order ID is reused, so every attempt at one basket stays searchable as a single order in Peach's dashboard. Changing the basket is what earns a new order ID, since Peach freezes a checkout's amount at creation.

A checkout Peach cannot render gets a way out. When Peach's SDK cannot load a checkout at all - most often an SDK from a different environment to the checkout API (see peachEnv), which Peach answers with "Checkout not found" - it shows its own "Oh no! Something went wrong / An unrecoverable error has occurred" card, which has no controls, and calls none of the widget's event handlers. The widget watches for that card and replaces it with its own "Unable to load payment form" screen and a Start over button. Start over releases the checkout and returns the shopper to the contact form, and the next Pay Now mints a brand-new checkout. If the server will not release the old one because Peach reports it still pending, the widget moves on anyway - no card can have been entered on a form that never appeared - and the new checkout replaces the old one on the cart. The watch stops as soon as Peach's form shows a field or button a shopper could use, so an error card after that point is left to Peach rather than risk a second charge over one that may still land. The card is recognised by its wording, the only thing that identifies it; if Peach rewords it, the shopper sees Peach's card again with no way out but closing the checkout.

The checkout modal reflects the outcome as distinct result screens, and stays open until the shopper dismisses it themselves (Done/Close) rather than closing automatically:

| Outcome | Shown when | Shopper can retry payment? | |---|---|---| | Payment Successful | Charge succeeded and confirmed | -- | | Still Checking Your Payment (pending) | No final answer within the poll's window - typically a charge whose webhook is slow | No - a "Check Again" button re-checks on demand instead | | Payment Failed (not charged) | Peach handed back without a charge - a rejected transaction once its own retries were exhausted, an expired or errored checkout, or the shopper backing out; or a server-side sweep released a cart nobody came back to | Yes - nothing was charged, and Try Again mints a brand-new Peach checkout | | Booking Incomplete (partial) | Charge succeeded but some cart items didn't confirm | No - the charge already happened; the shopper is told to contact support instead |

Every outcome except an outright decline also shows the shopper what they are holding: the booking reference for each confirmed item, Peach's payment reference and transaction ID, the name and email the booking was made with, and a summary of the order with its total. A declined payment shows none of it - there is nothing to quote and no order to summarise.

The payment reference is Peach's merchant order ID - the value their dashboard is searched by, e.g. 7E30CFBE19E6C772. The transaction ID is Peach's own "Transaction ID", which Peach only reports for a completed or expired checkout. Both come from the payload Peach's embedded form hands back when it finishes, so a result screen reached by resuming straight onto it after a page reload has neither: the payment reference then falls back to Peach's checkout ID, and the transaction ID row is left out. A new payment attempt clears both, so an earlier attempt's references are never quoted for a later one.

The name and email stay in view; the references fold away behind a "See more" disclosure. They are what someone goes looking for when something has gone wrong, not what reassures them on arrival. It is a native <details>, so it opens without script, is reachable from the keyboard, and the references are in the DOM either way - a page that copies or prints the screen still gets them.

Each row appears only if it has a value, so a "pending" screen shows its payment reference without a booking reference over nothing. On a partial confirmation only the items that did book carry a reference, which is what someone chasing the rest needs to hand over. The order and the checkout ID are captured when the charge is created, not read back afterwards - confirming an order resets the cart - so they survive a page reload and a 3-D Secure return.

Set supportPhone / supportPhoneHours / supportEmail (or support-phone / support-phone-hours / support-email) and they are shown here too, so a shopper holding a charge can reach someone. All three are optional and independent; nothing renders for the ones you leave unset, and this widget deliberately ships with no default number or address of its own.

bw:order-confirmed only fires for the first outcome. A shopper who dismisses the "pending" screen before it resolves gets no order-confirmed event even though the payment may still confirm moments later via the webhook - build any "wait for confirmation" UI around the event, not around the modal closing.

Cart expiry

A cart is created with a 15-minute idle window and a hard ceiling of 35 minutes from creation. Every action on the cart - adding an item, editing one, starting payment, reopening an abandoned payment attempt, and answering the "Are you still there?" prompt - resets the idle window to a full 15 minutes from that moment, but never past the ceiling. cart.idleExpiresAt is therefore always the cart's real deadline, and cart.absoluteExpiresAt is the ceiling it can never move beyond. If a supplier hold behind one of the items cannot be extended, the cart's deadline is left where it was, so the cart never outlives the holds it fronts.

At two minutes remaining, an "Are you still there?" prompt appears. Unlike the rest of the widget's UI it is page-wide: it renders above whatever the shopper is looking at, whether or not a <bw-checkout> is even open, since a merchant might embed only <bw-configurator>/<bw-cart> with no checkout element on the page at all. Answering "Yes, I'm still here" resets the idle window as above and fires bw:cart-updated with the extended cart. Once the idle deadline has already reached the ceiling - or an answer came back with the deadline unmoved because a supplier hold could not be extended - the prompt only warns and offers OK: there is no more time to be had. While a payment is in progress the cart is exempt from expiry and cannot be extended (Peach's own session cannot be either), so the prompt does not appear over the card form; if the payment is cancelled or fails and the cart reopens, the countdown resumes from the server's deadline. A cart left sitting on Peach's form past its hard ceiling is released by a server-side sweep, which asks Peach first and never releases one Peach reports as paid; the widget shows that as Payment Failed (not charged). A page reload during payment is remembered: reopening checkout returns the shopper to the same card form, or offers to start over if Peach no longer accepts it.

If the deadline passes unanswered, the widget checks with the server and, once the server confirms it has let the cart go, clears it: a "Your cart has expired" message replaces the prompt, bw:cart-updated fires with cart: null, and there is no way back into the expired cart - only a fresh one. The browser's clock only decides when to ask; a clock running ahead of the server's never throws away a live cart. If you compute your own "time remaining", read it from cart.idleExpiresAt.

The prompt, the extension it offers, and the automatic clear-on-expiry come with createBookingHost(), which mounts CartExpiryGuard on <body> once per page - so every option below has them, not just the custom-elements script. Pass mountExpiryGuard: false to opt out, or mount <CartExpiryGuard /> yourself. Only a consumer building ApiClient / SessionManager / CartManager by hand goes without: then the cart still expires server-side on the same schedule and onCartUpdated still reports cart: null once the widget next hears the server refuse it, but nothing warns the shopper beforehand.

Option 2: JavaScript mount functions

For programmatic control in an Astro, Vite, or bundled project, install the package and import the ES module:

npm install @code-collective/booking-widget
import { createBookingHost, mountConfigurator, mountCheckout } from '@code-collective/booking-widget';
import '@code-collective/booking-widget/style.css';

// One host for the page: one session, one cart, one expiry guard. Pass it to every mount function that
// should share them - without it, each mount builds its own host and starts its own session. The cart is
// shared either way (see Host wiring), but separate sessions are wasted requests.
const host = createBookingHost({
  checkoutKey: 'your-checkout-key',
  apiBaseUrl: 'https://checkout.yourdomain.com',
});

const configurator = await mountConfigurator(document.getElementById('configurator'), {
  host,
  productId: 'your-product-id',
  onCartChange({ itemCount, totalFormatted }) {
    console.log(`${itemCount} items, ${totalFormatted}`);
  },
});

// Adding an item opens this automatically - see Host wiring below.
const checkout = await mountCheckout(document.getElementById('checkout'), { host, mode: 'modal' });

// Later: configurator.destroy(); checkout.destroy(); host.destroy();

Mount functions

mountConfigurator(target, config) — Product selection wizard

| Config | Required | Description | |---|---|---| | productId | Yes | OCTO product ID | | host | No | A BookingHost to share (see Host wiring). Without one, this mount builds its own | | checkoutKey | No | Supplier checkout key | | apiBaseUrl | No | Checkout API URL | | wizardPages | No | Wizard step ordering | | autoSelectSingleTimeSlot | No | Skip time picker if single slot | | Cart callbacks | No | onCartChange, onCartUpdated, onCartExpired, onOrderConfirmed - see Host wiring | | onCancel | No | Callback when cancelled |

mountCheckout(target, config) — Cart review, contact form, payment

| Config | Required | Description | |---|---|---| | host | No | A BookingHost to share (see Host wiring). Without one, this mount builds its own | | checkoutKey | No | Supplier checkout key | | apiBaseUrl | No | Checkout API URL | | mode | No | inline (default) or modal - a portalled overlay with a scrim | | privacyPolicyUrl | No | Full URL to your privacy policy, linked from the contact form's consent checkbox. No default - unset renders plain, unlinked text | | supportPhone | No | Support number shown on the checkout result screen. No default | | supportPhoneHours | No | Shown beside the number, e.g. 08h00-17h00 | | supportEmail | No | Support address shown on the checkout result screen. No default | | onClose | No | Callback when closed | | onOrderConfirmed | No | Callback: ({ cartToken, value, currency }) | | Cart callbacks | No | onCartChange, onCartUpdated, onCartExpired, onOrderConfirmed - see Host wiring |

mountCartOverview(target, config) — Cart summary

| Config | Required | Description | |---|---|---| | host | No | A BookingHost to share (see Host wiring). Without one, this mount builds its own | | checkoutKey | No | Supplier checkout key | | apiBaseUrl | No | Checkout API URL | | display | No | bar or button | | onCheckout | No | Callback when checkout clicked | | Cart callbacks | No | onCartChange, onCartUpdated, onCartExpired, onOrderConfirmed - see Host wiring |

All mount functions return Promise<{ destroy(): void }>. Every config also accepts the host options (autoOpenCheckout, wizardPages, autoSelectSingleTimeSlot, onCartUpdated, ...), which are used to build the host when you do not pass one.

The page-wide "Are you still there?" prompt and extend flow come with the host, so this option has them.

Option 3: Svelte components

Install the package and import Svelte components directly for full reactivity:

npm install @code-collective/booking-widget
<script lang="ts">
  import { createBookingHost, BookingProvider, TicketConfigurator, Checkout, CartOverviewButton } from '@code-collective/booking-widget';
  import '@code-collective/booking-widget/style.css';

  let host = $state<BookingHost | null>(null);
  let ready = $state(false);

  // In onMount, not the script body: anything server-rendered (an Astro island, SvelteKit SSR) runs the
  // body once with no DOM, and a host built there opens a session the hydrated page throws away.
  onMount(() => {
    const created = createBookingHost({
      apiBaseUrl: 'https://checkout.yourdomain.com',
      checkoutKey: 'your-checkout-key',
    });
    host = created;
    // ready rejects if the checkout service turns the session down - usually the key not being allowlisted
    // for this origin. Uncaught, the page sits on its loading state forever.
    created.ready.then(() => { ready = true; }).catch(() => { /* render your own fallback */ });
    return () => created.destroy();
  });
</script>

{#if ready && host}
  <BookingProvider host={host}>
    <TicketConfigurator productId="your-product-id" />
    <CartOverviewButton />
    <Checkout mode="modal" />
  </BookingProvider>
{/if}

Adding an item opens the checkout, the modal portals itself out of any sticky or transformed ancestor, and the page-wide "Are you still there?" prompt is mounted for you - all from the host, with no wiring of your own. See Host wiring.

BookingProvider puts the host in Svelte context, which is why the components inside it need no api or cartManager, and why Checkout needs no open or onClose. Every one of those props still exists and still wins when you pass it - the bw-* custom elements pass them explicitly, since context does not cross a custom element's own root. Without a provider and without the prop, a component throws and names both ways to fix it rather than failing somewhere deeper.

wizardPages, editPages, autoSelectSingleTimeSlot and privacyPolicyUrl are the same story but the other way round: they are plain props on Checkout/TicketConfigurator, not read from the host's context, so pass them directly - <Checkout mode="modal" privacyPolicyUrl="https://example.com/privacy" /> - rather than setting them on createBookingHost. Only the mount-function build (Option 2) and the custom elements pick these up from the host/window.bwOptions automatically.

Building the services by hand (new ApiClient(...), new SessionManager(...), new CartManager(...)) still works and is what createBookingHost does internally, but none of the behaviour above comes with it - except the shared cart, which lives in CartManager itself (see One cart per page). If you go that route, mount CartExpiryGuard yourself:

<CartExpiryGuard {api} {cartManager} />

Host wiring

createBookingHost(config) is the one place the widget's host behaviour lives. The custom-elements build is a thin DOM adapter over it, so both builds auto-wire through exactly the same code.

const host = createBookingHost({
  apiBaseUrl, checkoutKey,
  autoOpenCheckout: true,          // default - opt out, never opt in
  shouldBottomCloseOnModal: true,  // hide bar-display carts while checkout is open
  onCartChange({ itemCount, cartItemId, totalFormatted }) {},   // an add, with its own detail
  onCartUpdated(cart) {},                                       // any change, with the whole cart
  onCartExpired() {},                                           // the cart's window ran out
  onCheckoutOpenChange(open) {},
  onOrderConfirmed({ cartToken, value, currency }) {},
});

host.api / host.cartManager / host.sessionManager   // one set, shared by every component
host.ready                                          // resolves once the session exists; rejects if it is refused
host.isCheckoutOpen                                 // reactive
host.openCheckout() / host.closeCheckout()
host.destroy()

| Option | Type | Default | Description | |---|---|---|---| | autoOpenCheckout | boolean \| (() => boolean) | true | Whether adding an item opens checkout. A function is re-asked on every add, so a layout that already shows the configurator inline can answer false for that case only | | shouldBottomCloseOnModal | boolean | true | Hide bar-display cart overviews while checkout is open | | autoSelectSingleTimeSlot | boolean | false | Skip the time picker when only one slot is available | | wizardPages / editPages | WizardPages | - | Wizard step ordering, handed to every component the host serves | | privacyPolicyUrl | string | - | Full URL to your privacy policy, e.g. https://example.com/privacy - linked from the checkout contact form's "I accept the privacy policy" checkbox. No default: this widget has no policy of its own, so leaving it unset renders plain, unlinked text rather than a link to somewhere wrong | | supportPhone | string | - | Shown on the checkout result screen once a payment has been taken, so a shopper holding a charge can reach someone, e.g. 021 511 6000. Each support field renders only if set; there is deliberately no default, since this widget ships to more than one supplier | | supportPhoneHours | string | - | Shown beside the number, e.g. 08h00-17h00. Ignored without supportPhone | | supportEmail | string | - | Support address shown on the result screen, e.g. [email protected] | | mountExpiryGuard | boolean | true | Mount CartExpiryGuard on <body> | | peachEnv | 'prod' \| 'qa' | 'prod' | Which Peach Payments SDK the card form loads. Set it whenever apiBaseUrl is not production - the checkout API creates the Peach checkout, so a sandbox API with the production SDK gives a card form that refuses to render (the shopper gets Start over instead - see Payment outcomes). Every host and island on a page must agree: if two setPeachSdk calls disagree, or one contradicts window.BW_CHECKOUT_ENV, the console logs [booking-widget] The Peach SDK was ... naming both (once per disagreement; silent when window.BW_CHECKOUT_PEACH_SDK_URL overrides them). The last one set still wins | | peachSdkUrl | string | - | An explicit Peach SDK URL, for an environment peachEnv does not name | | resellerCode | string | - | Sent as the Reseller-Code header when the checkout session starts, so Connect can attribute the booking to a specific reseller relationship | | agentToken | string | - | Sent as the Agent-Token header alongside resellerCode, so Connect can scope the booking to a specific signed-in agent. Reaches the browser in plain text - only set this in a context where that is an accepted risk (e.g. behind your own authenticated proxy), never on a public page | | api | BookingApi | - | An API client to use instead of building one from apiBaseUrl - a test double, or a fake for a demo page |

setBookingDefaults({ ... }) applies the same options site-wide to every host created afterwards - the ES equivalent of window.bwOptions. Anything passed to createBookingHost wins over a default.

One cart per page

A tab has one cart, and every host on the page shares it - whether they were handed the same host or each built their own (a mount function without host, a header cart with its own createBookingHost). Each CartManager is a view over one page-wide store that owns the cart and its sessionStorage entry:

  • A cart created through one host reaches every other host's api straight away, so a checkout on the page sends the token of the cart the configurator just filled.
  • reset() from any host clears the cart - and its token - for all of them.
  • Adds racing to an empty cart share one create, rather than each minting a cart of its own.
  • The cart is created through the adding host's own api, so it carries that host's session and resellerCode/agentToken headers.
  • host.destroy() detaches that host's api from the cart; the rest keep it.

The sharing is per bundle: two separate copies of the widget script on one page each have their own store, though both still read the same sessionStorage entry on load. Server renders get a store per CartManager, so nothing is shared across requests.

Callbacks

| Callback | Fires | |---|---| | onCartChange | On an add, with the detail only the add knows: { itemCount, cartItemId, totalFormatted }. Fires whether or not checkout opened | | onCartUpdated | After any add, edit or remove, carrying the whole cart the widget already fetched. What a custom cart summary reads | | onCartExpired | The cart's window ran out - distinct from the null onCartUpdated that a confirmed order also produces | | onCheckoutOpenChange | Checkout opened or closed, once per change. false after an order means the shopper dismissed the result - this is the one to navigate on | | onOrderConfirmed | Payment succeeded and was confirmed - not while a webhook is still pending. { cartToken, value, currency }. Fires while the shopper is still looking at the result screen, so do not close or navigate on it |

Each is one slot, fixed when the host is built: set it twice and the second wins, and there is no detaching it short of host.destroy(). Where more than one part of the page needs the same event, or a subscription should come and go with a component, subscribe to the message bus instead - see below. The two ride the same bus and fire at the same moment.

The mount functions take these on their own config too. When you hand them a host, the callbacks on that config are wired to the same events rather than dropped - but each event still reaches your handler once.

Messages

onWidgetMessage(handler) subscribes to the widget's own message bus and returns an unsubscribe function; postMessage(message) sends one. Both are exported, and every message is a member of the WidgetMessage union (cart:change, cart:updated, cart:expired, modal:open, modal:close, order:complete, payment:started, payment:ended, payment:timed-out), so a handler narrows on message.type.

import { onWidgetMessage } from '@code-collective/booking-widget';

const stop = onWidgetMessage((m) => {
  if (m.type === 'cart:change') track('add_to_cart', m.totalFormatted);
});
// later
stop();

Prefer the host callbacks where they cover what you need - they are the same events, already parsed. Reach for onWidgetMessage when you need several subscribers, or one that comes and goes.

Astro integration

Script tag approach

---
const product = await getProduct(Astro.params.slug);
---

<bw-configurator product-id={product.id} checkout-key={product.checkoutKey}></bw-configurator>
<bw-cart display="bar" checkout-key={product.checkoutKey}></bw-cart>
<bw-checkout checkout-key={product.checkoutKey}></bw-checkout>

<link rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/@code-collective/[email protected]/dist/booking-widget.min.css"
  integrity="sha384-mbTpbdaRTc+mMImDK16lDtNSQLXwygTaIuGQ79qxBtaaAN5PK5yQb3YOoYtHWi7W"
  crossorigin="anonymous" />
<script is:inline
  src="https://cdn.jsdelivr.net/npm/@code-collective/[email protected]/dist/booking-widget.min.js"
  integrity="sha384-mTt5lra/mAhFaN7q2fWAcL3/RnkYSHs3CQJCxueBy+ikpRh8DjXlQNHhG/QZ/VRP"
  crossorigin="anonymous"></script>

Svelte island approach

npx astro add svelte
npm install @code-collective/booking-widget
---
import BookingWidget from '../components/BookingWidget.svelte';
const product = await getProduct(Astro.params.slug);
---

<BookingWidget
  client:load
  productId={product.id}
  checkoutKey={product.checkoutKey}
  apiBaseUrl="https://checkout.yourdomain.com"
/>

A cart button outside the widget

A cart outlives the page it was built on - CartManager persists its token to sessionStorage - so a shopper who adds a ticket and then reads an FAQ still has one, on a page that mounts no widget at all. A site-wide cart button therefore needs its own host (and so its own session), started with the site's checkout key from its own config - setBookingDefaults({ checkoutKey }) or the value it would pass to checkout-key. On the pages that do mount a widget, the button and the widget already share one cart (see One cart per page), but the two must not both open a checkout. The button must also load the same Peach SDK as the widget - set peachEnv (or setPeachSdk) from the same config, or checkout from a page without the widget fails with Peach's "unrecoverable error" card.

The pattern that works: the widget island listens for a custom event and opens its own sheet; the header button dispatches that event and opens its own modal only when no widget answered.

// In the widget island, alongside its other listeners:
window.addEventListener('cart:open-request', () => (sheet = 'checkout'))
document.documentElement.setAttribute('data-booking-ready', '')   // on mount

// In the header button:
const onClick = () => {
  if (document.documentElement.hasAttribute('data-booking-ready')) {
    window.dispatchEvent(new CustomEvent('cart:open-request'))    // the widget owns it
    return
  }
  open = true                                                     // no widget here - open our own
}

The header button must not also listen for the event it dispatches: on a page with a widget, one click would then open the widget's sheet and a second modal on top of it. Keep exactly one listener and one dispatcher.

Reserving height without trapping it

A host that reserves height to stop hydration shifting the page should release the reservation once the widget is up, rather than leaving it as a permanent floor - otherwise a short step sits in a tall box with white space under its own footer.

data-booking-ready is set on <html> when the session exists, which is the moment the real height is known:

@media (min-width: 1024px) {
  .booking-panel { min-height: 28rem; }
  html[data-booking-ready] .booking-panel { min-height: 0; }
}

Space is reserved before hydration and the panel hugs the current step after it. If the widget never comes up the floor stays, which is what a fallback panel wants anyway.

The custom-elements build needs none of this - host.openCheckout() already opens the one checkout the page shares.

Wizard pages

Each wizard page is an object with a title (shown as the page heading and accordion label) and a widgets array that controls which sections appear on that page.

Option-first (default):

[
  { "title": "Option",         "widgets": ["option", "age-category"] },
  { "title": "Schedule",       "widgets": ["date", "time"] },
  { "title": "Pickup",         "widgets": ["pickup"] }
]

Date-first:

[
  { "title": "Age & capacity", "widgets": ["age-category"] },
  { "title": "Schedule",       "widgets": ["date", "time"] },
  { "title": "Option",         "widgets": ["option"] },
  { "title": "Pickup",         "widgets": ["pickup"] }
]

Available widget types: option, age-category, date, time, pickup.

Setting wizard pages

Using a preset (simplest):

<bw-configurator product-id="..." checkout-key="..." wizard-pages="option-first"></bw-configurator>

<bw-configurator product-id="..." checkout-key="..." wizard-pages="date-first"></bw-configurator>

Using a custom JSON array (full control):

<bw-configurator product-id="..." checkout-key="..."
  wizard-pages='[
    {"title":"Age & capacity","widgets":["age-category"]},
    {"title":"Schedule","widgets":["date","time"]},
    {"title":"Option","widgets":["option"]},
    {"title":"Pickup","widgets":["pickup"]}
  ]'>
</bw-configurator>

If wizard-pages is omitted, the default is option-first.

In the configurator, pages are shown as a step-by-step wizard with dot indicators. In the checkout edit view, pages are shown as collapsible accordion sections with a summary of the selected values when collapsed.

Edit pages (checkout accordion grouping)

edit-pages controls the same thing as wizard-pages — which widgets appear together, and in what order — but for the collapsible accordion shown when editing an item already in the cart (<bw-checkout> only; it has no effect on <bw-configurator>'s step wizard). It accepts the same two shapes as wizard-pages: a preset name ("option-first" / "date-first") or a custom JSON array of { "title", "widgets" } objects.

<bw-checkout checkout-key="..." wizard-pages="date-first" edit-pages="date-first"></bw-checkout>

<bw-checkout checkout-key="..."
  edit-pages='[
    {"title":"Age, Date & Time","widgets":["age-category","date","time"]},
    {"title":"Option","widgets":["option"]},
    {"title":"Pickup","widgets":["pickup"]}
  ]'>
</bw-checkout>

If edit-pages is omitted, it's derived from wizard-pages: age/date/time are grouped into one accordion section, positioned first for date-first and second (after Option) for option-first. If wizard-pages is itself a custom JSON array rather than a named preset, there's no sensible grouping to infer, so edit-pages just mirrors it directly — set edit-pages explicitly in that case if you want different grouping in the edit view.

While an item is being edited, changing a field can invalidate an earlier selection (e.g. increasing ticket quantity beyond the previously-selected time slot's vacancies resets the date/time). An invalidated accordion section is shown with a red border and a "Changes made require new configuration" summary — even while collapsed — until it's reselected. Fields that don't need to change are preserved automatically (e.g. an already-chosen pickup point stays selected across a date change, since pickup is a property of the option, not the date/time).

What the shopper is offered

The wizard only ever offers choices that can actually be booked, and it narrows forwards only: for steps A -> B -> C, nothing in B or C limits what is selectable in A. Raising the ticket count never clamps the counter - it narrows the dates, times and options that follow it.

  • Times that cannot be booked are not shown. A departure that is sold out, or too small for the party already counted, is left out rather than greyed. If a day has no bookable departure at all, the step says so in place of the list ("No departure on this day can seat 8 people"), and the date stays selected so nothing is withdrawn from under the shopper after they picked it.
  • Options are filtered by the chosen date and time. In date-first, where the option is chosen last, only options that actually run at the chosen slot are listed. Changing the time re-filters them and clears a selection that no longer applies.
  • A single option is chosen for the shopper. In option-first, a product with exactly one option selects it and drops the step entirely - the same bargain auto-select-single-time-slot makes for a lone departure.
  • Availability is asked per option. An availability id belongs to the option it was fetched for, so in date-first the widget asks every option and keeps each answer, then submits the chosen option's own slot. Pairing one option's id with another's is rejected by the supplier.

The progress dots map the whole journey from the first screen, not the part unlocked so far - a two-step product shows two dots before anything is chosen. Only steps this product can never ask for are left out: pickup where no option offers it, and the option step where there is one option or it is locked for an edit.

Option cards show the option's description where the supplier provides one (OctoOption.Description), not a list of per-unit prices - prices appear on the ticket counters and under each date in the calendar instead. A product whose supplier has not authored descriptions shows option titles alone.

Hiding the time selector

Some products only ever offer a single time slot per day (or an all-day slot with no specific time). For these, showing a time picker step with only one option to click is pointless friction. Set auto-select-single-time-slot to skip it:

<bw-configurator product-id="..." checkout-key="..." auto-select-single-time-slot></bw-configurator>
<bw-checkout checkout-key="..." auto-select-single-time-slot></bw-checkout>

When enabled, and the selected date has exactly one available time slot, that slot is selected automatically and the time-picker widget is hidden entirely — the flow moves straight from date to the next widget (option or pickup, depending on wizard-pages). A date with a genuine all-day slot (allDay: true from the availability API) always behaves this way, regardless of this flag. Set it on both <bw-configurator> and <bw-checkout> (or via window.bwOptions.autoSelectSingleTimeSlot, which wires it onto every element automatically) so the behavior is consistent between adding an item and editing one already in the cart.

Money formatting

Every amount the widget renders uses one format: the currency symbol, thousands grouped with a non-breaking space, and minor units after a full stop only when there are any.

R 745        R 1 490        R 1 234 567.89        R 12.50        -R 12.50

Cents are judged per amount rather than per screen, so a cart holding R 745 and R 12.50 shows each as it is. Decimals come from the currency's own minor-unit exponent, so a zero-decimal currency renders without a trailing .00. The number of decimals is not configurable per call site - one format everywhere is what stops a line and a total disagreeing about whether cents are shown.

This is built deliberately rather than handed to Intl.NumberFormat: en-ZA formats 1490.00 as R 1 490,00, with a comma where the decimal separator has to be a full stop, and a locale-driven format would also shift with the host page's locale, which the widget does not control.

Theming

The widget reads CSS custom properties. It automatically picks up site variables (--header-background, --default-font-family, --radius) or can be themed directly:

:root {
  --bw-color-primary: #0066CC;
  --bw-color-primary-dark: #004C99;
  --bw-font-family: 'Inter', sans-serif;
  --bw-radius-md: 4px;
}

| Variable | Fallback | Default | |---|---|---| | --bw-color-primary | --header-background | #E30613 | | --bw-color-primary-dark | --footer-background | #C00510 | | --bw-color-primary-light | -- | rgba(227,6,19,0.08) | | --bw-color-text | -- | #212121 | | --bw-color-text-secondary | -- | #757575 | | --bw-color-border | -- | #E0E0E0 | | --bw-color-bg | -- | #FFFFFF | | --bw-color-surface | -- | #F5F5F5 | | --bw-color-success | -- | #4CAF50 | | --bw-color-error | --header-background | #E30613 | | --bw-font-family | --default-font-family | Roboto, system | | --bw-radius-sm | -- | 4px | | --bw-radius-md | --radius | 8px | | --bw-radius-lg | -- | 12px | | --bw-transition | -- | 0.15s ease |