@code-collective/booking-widget 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,506 +1,508 @@
1
- # Booking Widget
2
-
3
- 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.
4
-
5
- ## Option 1: HTML Custom Elements (CDN)
6
-
7
- Drop in a script tag and use the custom elements directly. No framework or npm install required.
8
-
9
- ```html
10
- <head>
11
- <link rel="stylesheet"
12
- href="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.css"
13
- integrity="sha384-LDthHLROtZeufKbWmAhXBsAFLqqhkZjWFd8gWU+Ez11qyg5ebj9vpFnvJuZcM3ft"
14
- crossorigin="anonymous" />
15
- </head>
16
- <body>
17
- <bw-configurator product-id="your-product-id" checkout-key="your-checkout-key"></bw-configurator>
18
- <bw-cart display="button"></bw-cart>
19
- <bw-cart display="bar"></bw-cart>
20
- <bw-checkout></bw-checkout>
21
-
22
- <script
23
- src="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.js"
24
- integrity="sha384-vbNVEDGKWm6wwoq+w8uD3mH8eye+XceceTkCL9vW6qd4YTK6WNqKK0Y0e7YiC541"
25
- crossorigin="anonymous"></script>
26
- </body>
27
- ```
28
-
29
- > **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.
30
-
31
- > **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.
32
-
33
- The script auto-wires the elements together:
34
- - Adding an item shows the cart and opens the checkout overlay
35
- - Clicking checkout in the cart opens the overlay
36
- - Closing or completing checkout closes the overlay
37
-
38
- ### Element attributes
39
-
40
- #### `<bw-configurator>`
41
-
42
- | Attribute | Required | Description |
43
- |---|---|---|
44
- | `product-id` | Yes | OCTO product ID |
45
- | `checkout-key` | Yes | Public key identifying the supplier |
46
- | `wizard-pages` | No | JSON string for wizard step ordering |
47
- | `auto-select-single-time-slot` | No | Boolean. Skips the time picker if only one slot |
48
- | `cancelable` | No | Boolean. Shows a cancel button |
49
- | `no-auto-checkout` | No | Boolean. Prevents auto-opening checkout on add |
50
- | `on-cart-change` | No | Name of a global JS function to call on add |
51
-
52
- #### `<bw-cart>`
53
-
54
- | Attribute | Required | Description |
55
- |---|---|---|
56
- | `checkout-key` | Yes | Public key identifying the supplier |
57
- | `display` | No | `bar` (default) or `button` |
58
-
59
- #### `<bw-checkout>`
60
-
61
- | Attribute | Required | Description |
62
- |---|---|---|
63
- | `checkout-key` | Yes | Public key identifying the supplier |
64
- | `wizard-pages` | No | JSON string for wizard step ordering |
65
- | `edit-pages` | No | JSON string for how fields are grouped in the cart edit view |
66
- | `auto-select-single-time-slot` | No | Boolean. Skips the time picker if only one slot |
67
-
68
- ### Global options
69
-
70
- Set `window.bwOptions` before the widget script loads:
71
-
72
- ```html
73
- <script>
74
- window.bwOptions = {
75
- shouldBottomCloseOnModal: true,
76
- autoSelectSingleTimeSlot: false,
77
- wizardPages: '',
78
- editPages: '',
79
- };
80
- </script>
81
- <script src="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.js"
82
- integrity="sha384-vbNVEDGKWm6wwoq+w8uD3mH8eye+XceceTkCL9vW6qd4YTK6WNqKK0Y0e7YiC541"
83
- crossorigin="anonymous"></script>
84
- ```
85
-
86
- | Option | Default | Description |
87
- |---|---|---|
88
- | `shouldBottomCloseOnModal` | `true` | Hide bar carts when checkout modal opens |
89
- | `autoSelectSingleTimeSlot` | `false` | Auto-select (and hide) the time picker when only one slot |
90
- | `wizardPages` | `''` | JSON wizard step ordering for all elements |
91
- | `editPages` | `''` | JSON field grouping for the checkout edit view (`bw-checkout` only) |
92
-
93
- ### Window events
94
-
95
- All `bw:*` events are re-dispatched on `window`:
96
-
97
- ```js
98
- window.addEventListener('bw:order-confirmed', (e) => {
99
- console.log(e.detail); // { cartToken, value, currency }
100
- });
101
- ```
102
-
103
- | Event | Detail | When |
104
- |---|---|---|
105
- | `bw:cart-change` | `{ itemCount, cartItemId, totalFormatted }` | Item added to cart |
106
- | `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 |
107
- | `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) |
108
- | `bw:modal-open` | -- | Checkout modal opens |
109
- | `bw:modal-close` | -- | Checkout modal closes |
110
- | `bw:checkout` | -- | Cart checkout button clicked |
111
- | `bw:close` | -- | Checkout dismissed |
112
- | `bw:cancel` | -- | Configurator cancelled |
113
-
114
- ### Payment outcomes
115
-
116
- 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.
117
-
118
- **A declined card never reaches this widget.** Peach re-prompts inside its own embedded form, as many times as it allows, and the shopper only comes back here once they have paid or given up. So there is no "your card was declined" screen and no retry counter: the retry happens on Peach's form, on the same checkout. The widget hears from Peach only when the checkout itself ends - completed, cancelled, expired, or broken.
119
-
120
- 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:
121
-
122
- | Outcome | Shown when | Shopper can retry payment? |
123
- |---|---|---|
124
- | Payment Successful | Charge succeeded and confirmed | -- |
125
- | 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 |
126
- | Payment Failed (not charged) | Peach reports the checkout expired, errored, or the shopper backed 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 |
127
- | 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 |
128
-
129
- `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.
130
-
131
- ### Cart expiry
132
-
133
- 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.
134
-
135
- 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.
136
-
137
- 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`.
138
-
139
- 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.
140
-
141
- ## Option 2: JavaScript mount functions
142
-
143
- For programmatic control in an Astro, Vite, or bundled project, install the package and import the ES module:
144
-
145
- ```bash
146
- npm install @code-collective/booking-widget
147
- ```
148
-
149
- ```js
150
- import { createBookingHost, mountConfigurator, mountCheckout } from '@code-collective/booking-widget';
151
- import '@code-collective/booking-widget/style.css';
152
-
153
- // One host for the page: one session, one cart, one expiry guard. Pass it to every mount function that
154
- // should share them - without it, each mount builds its own and you get two of everything.
155
- const host = createBookingHost({
156
- checkoutKey: 'your-checkout-key',
157
- apiBaseUrl: 'https://checkout.yourdomain.com',
158
- });
159
-
160
- const configurator = await mountConfigurator(document.getElementById('configurator'), {
161
- host,
162
- productId: 'your-product-id',
163
- onCartChange({ itemCount, totalFormatted }) {
164
- console.log(`${itemCount} items, ${totalFormatted}`);
165
- },
166
- });
167
-
168
- // Adding an item opens this automatically - see Host wiring below.
169
- const checkout = await mountCheckout(document.getElementById('checkout'), { host, mode: 'modal' });
170
-
171
- // Later: configurator.destroy(); checkout.destroy(); host.destroy();
172
- ```
173
-
174
- ### Mount functions
175
-
176
- **`mountConfigurator(target, config)`** — Product selection wizard
177
-
178
- | Config | Required | Description |
179
- |---|---|---|
180
- | `productId` | Yes | OCTO product ID |
181
- | `host` | No | A `BookingHost` to share (see [Host wiring](#host-wiring)). Without one, this mount builds its own |
182
- | `checkoutKey` | No | Supplier checkout key |
183
- | `apiBaseUrl` | No | Checkout API URL |
184
- | `wizardPages` | No | Wizard step ordering |
185
- | `autoSelectSingleTimeSlot` | No | Skip time picker if single slot |
186
- | Cart callbacks | No | `onCartChange`, `onCartUpdated`, `onCartExpired`, `onOrderConfirmed` - see [Host wiring](#host-wiring) |
187
- | `onCancel` | No | Callback when cancelled |
188
-
189
- **`mountCheckout(target, config)`** Cart review, contact form, payment
190
-
191
- | Config | Required | Description |
192
- |---|---|---|
193
- | `host` | No | A `BookingHost` to share (see [Host wiring](#host-wiring)). Without one, this mount builds its own |
194
- | `checkoutKey` | No | Supplier checkout key |
195
- | `apiBaseUrl` | No | Checkout API URL |
196
- | `mode` | No | `inline` (default) or `modal` - a portalled overlay with a scrim |
197
- | `onClose` | No | Callback when closed |
198
- | `onOrderConfirmed` | No | Callback: `({ cartToken, value, currency })` |
199
- | Cart callbacks | No | `onCartChange`, `onCartUpdated`, `onCartExpired`, `onOrderConfirmed` - see [Host wiring](#host-wiring) |
200
-
201
- **`mountCartOverview(target, config)`** Cart summary
202
-
203
- | Config | Required | Description |
204
- |---|---|---|
205
- | `host` | No | A `BookingHost` to share (see [Host wiring](#host-wiring)). Without one, this mount builds its own |
206
- | `checkoutKey` | No | Supplier checkout key |
207
- | `apiBaseUrl` | No | Checkout API URL |
208
- | `display` | No | `bar` or `button` |
209
- | `onCheckout` | No | Callback when checkout clicked |
210
- | Cart callbacks | No | `onCartChange`, `onCartUpdated`, `onCartExpired`, `onOrderConfirmed` - see [Host wiring](#host-wiring) |
211
-
212
- All mount functions return `Promise<{ destroy(): void }>`. Every config also accepts the
213
- [host options](#host-wiring) (`autoOpenCheckout`, `wizardPages`, `autoSelectSingleTimeSlot`, `onCartUpdated`,
214
- ...), which are used to build the host when you do not pass one.
215
-
216
- The page-wide "Are you still there?" prompt and extend flow come with the host, so this option has them.
217
-
218
- ## Option 3: Svelte components
219
-
220
- Install the package and import Svelte components directly for full reactivity:
221
-
222
- ```bash
223
- npm install @code-collective/booking-widget
224
- ```
225
-
226
- ```svelte
227
- <script lang="ts">
228
- import { createBookingHost, BookingProvider, TicketConfigurator, Checkout, CartOverviewButton } from '@code-collective/booking-widget';
229
- import '@code-collective/booking-widget/style.css';
230
-
231
- let host = $state<BookingHost | null>(null);
232
- let ready = $state(false);
233
-
234
- // In onMount, not the script body: anything server-rendered (an Astro island, SvelteKit SSR) runs the
235
- // body once with no DOM, and a host built there opens a session the hydrated page throws away.
236
- onMount(() => {
237
- const created = createBookingHost({
238
- apiBaseUrl: 'https://checkout.yourdomain.com',
239
- checkoutKey: 'your-checkout-key',
240
- });
241
- host = created;
242
- // ready rejects if the checkout service turns the session down - usually the key not being allowlisted
243
- // for this origin. Uncaught, the page sits on its loading state forever.
244
- created.ready.then(() => { ready = true; }).catch(() => { /* render your own fallback */ });
245
- return () => created.destroy();
246
- });
247
- </script>
248
-
249
- {#if ready && host}
250
- <BookingProvider host={host}>
251
- <TicketConfigurator productId="your-product-id" />
252
- <CartOverviewButton />
253
- <Checkout mode="modal" />
254
- </BookingProvider>
255
- {/if}
256
- ```
257
-
258
- Adding an item opens the checkout, the modal portals itself out of any sticky or transformed ancestor, and
259
- the page-wide "Are you still there?" prompt is mounted for you - all from the host, with no wiring of your
260
- own. See [Host wiring](#host-wiring).
261
-
262
- `BookingProvider` puts the host in Svelte context, which is why the components inside it need no `api` or
263
- `cartManager`, and why `Checkout` needs no `open` or `onClose`. Every one of those props still exists and
264
- still wins when you pass it - the `bw-*` custom elements pass them explicitly, since context does not cross
265
- a custom element's own root. Without a provider and without the prop, a component throws and names both
266
- ways to fix it rather than failing somewhere deeper.
267
-
268
- Building the services by hand (`new ApiClient(...)`, `new SessionManager(...)`, `new CartManager(...)`) still
269
- works and is what `createBookingHost` does internally, but none of the behaviour above comes with it. If you
270
- go that route, mount `CartExpiryGuard` yourself:
271
-
272
- ```svelte
273
- <CartExpiryGuard {api} {cartManager} />
274
- ```
275
-
276
- ## Host wiring
277
-
278
- `createBookingHost(config)` is the one place the widget's host behaviour lives. The custom-elements build is
279
- a thin DOM adapter over it, so both builds auto-wire through exactly the same code.
280
-
281
- ```ts
282
- const host = createBookingHost({
283
- apiBaseUrl, checkoutKey,
284
- autoOpenCheckout: true, // default - opt out, never opt in
285
- shouldBottomCloseOnModal: true, // hide bar-display carts while checkout is open
286
- onCartChange({ itemCount, cartItemId, totalFormatted }) {}, // an add, with its own detail
287
- onCartUpdated(cart) {}, // any change, with the whole cart
288
- onCartExpired() {}, // the cart's window ran out
289
- onCheckoutOpenChange(open) {},
290
- onOrderConfirmed({ cartToken, value, currency }) {},
291
- });
292
-
293
- host.api / host.cartManager / host.sessionManager // one set, shared by every component
294
- host.ready // resolves once the session exists; rejects if it is refused
295
- host.isCheckoutOpen // reactive
296
- host.openCheckout() / host.closeCheckout()
297
- host.destroy()
298
- ```
299
-
300
- | Option | Type | Default | Description |
301
- |---|---|---|---|
302
- | `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 |
303
- | `shouldBottomCloseOnModal` | `boolean` | `true` | Hide bar-display cart overviews while checkout is open |
304
- | `autoSelectSingleTimeSlot` | `boolean` | `false` | Skip the time picker when only one slot is available |
305
- | `wizardPages` / `editPages` | `WizardPages` | - | Wizard step ordering, handed to every component the host serves |
306
- | `mountExpiryGuard` | `boolean` | `true` | Mount `CartExpiryGuard` on `<body>` |
307
- | `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 |
308
- | `peachSdkUrl` | `string` | - | An explicit Peach SDK URL, for an environment `peachEnv` does not name |
309
- | `api` | `BookingApi` | - | An API client to use instead of building one from `apiBaseUrl` - a test double, or a fake for a demo page |
310
-
311
- `setBookingDefaults({ ... })` applies the same options site-wide to every host created afterwards - the ES
312
- equivalent of `window.bwOptions`. Anything passed to `createBookingHost` wins over a default.
313
-
314
- ### Callbacks
315
-
316
- | Callback | Fires |
317
- |---|---|
318
- | `onCartChange` | On an add, with the detail only the add knows: `{ itemCount, cartItemId, totalFormatted }`. Fires whether or not checkout opened |
319
- | `onCartUpdated` | After any add, edit or remove, carrying the whole cart the widget already fetched. What a custom cart summary reads |
320
- | `onCartExpired` | The cart's window ran out - distinct from the `null` `onCartUpdated` that a confirmed order also produces |
321
- | `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 |
322
- | `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 |
323
-
324
- Each is **one slot, fixed when the host is built**: set it twice and the second wins, and there is no
325
- detaching it short of `host.destroy()`. Where more than one part of the page needs the same event, or a
326
- subscription should come and go with a component, subscribe to the message bus instead - see below. The two
327
- ride the same bus and fire at the same moment.
328
-
329
- The mount functions take these on their own config too. When you hand them a `host`, the callbacks on that
330
- config are wired to the same events rather than dropped - but each event still reaches your handler once.
331
-
332
- ### Messages
333
-
334
- `onWidgetMessage(handler)` subscribes to the widget's own message bus and returns an unsubscribe function;
335
- `postMessage(message)` sends one. Both are exported, and every message is a member of the `WidgetMessage`
336
- union (`cart:change`, `cart:updated`, `cart:expired`, `modal:open`, `modal:close`, `order:complete`,
337
- `payment:started`, `payment:ended`, `payment:timed-out`), so a handler narrows on `message.type`.
338
-
339
- ```ts
340
- import { onWidgetMessage } from '@code-collective/booking-widget';
341
-
342
- const stop = onWidgetMessage((m) => {
343
- if (m.type === 'cart:change') track('add_to_cart', m.totalFormatted);
344
- });
345
- // later
346
- stop();
347
- ```
348
-
349
- Prefer the host callbacks where they cover what you need - they are the same events, already parsed. Reach
350
- for `onWidgetMessage` when you need several subscribers, or one that comes and goes.
351
-
352
- ## Astro integration
353
-
354
- ### Script tag approach
355
-
356
- ```astro
357
- ---
358
- const product = await getProduct(Astro.params.slug);
359
- ---
360
-
361
- <bw-configurator product-id={product.id} checkout-key={product.checkoutKey}></bw-configurator>
362
- <bw-cart display="bar" checkout-key={product.checkoutKey}></bw-cart>
363
- <bw-checkout checkout-key={product.checkoutKey}></bw-checkout>
364
-
365
- <link rel="stylesheet"
366
- href="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.css"
367
- integrity="sha384-LDthHLROtZeufKbWmAhXBsAFLqqhkZjWFd8gWU+Ez11qyg5ebj9vpFnvJuZcM3ft"
368
- crossorigin="anonymous" />
369
- <script is:inline
370
- src="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.js"
371
- integrity="sha384-vbNVEDGKWm6wwoq+w8uD3mH8eye+XceceTkCL9vW6qd4YTK6WNqKK0Y0e7YiC541"
372
- crossorigin="anonymous"></script>
373
- ```
374
-
375
- ### Svelte island approach
376
-
377
- ```bash
378
- npx astro add svelte
379
- npm install @code-collective/booking-widget
380
- ```
381
-
382
- ```astro
383
- ---
384
- import BookingWidget from '../components/BookingWidget.svelte';
385
- const product = await getProduct(Astro.params.slug);
386
- ---
387
-
388
- <BookingWidget
389
- client:load
390
- productId={product.id}
391
- checkoutKey={product.checkoutKey}
392
- apiBaseUrl="https://checkout.yourdomain.com"
393
- />
394
- ```
395
-
396
- ## Wizard pages
397
-
398
- 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.
399
-
400
- **Option-first (default):**
401
- ```json
402
- [
403
- { "title": "Option", "widgets": ["option", "age-category"] },
404
- { "title": "Schedule", "widgets": ["date", "time"] },
405
- { "title": "Pickup", "widgets": ["pickup"] }
406
- ]
407
- ```
408
-
409
- **Date-first:**
410
- ```json
411
- [
412
- { "title": "Age & capacity", "widgets": ["age-category"] },
413
- { "title": "Schedule", "widgets": ["date", "time"] },
414
- { "title": "Option", "widgets": ["option"] },
415
- { "title": "Pickup", "widgets": ["pickup"] }
416
- ]
417
- ```
418
-
419
- Available widget types: `option`, `age-category`, `date`, `time`, `pickup`.
420
-
421
- ### Setting wizard pages
422
-
423
- **Using a preset** (simplest):
424
- ```html
425
- <bw-configurator product-id="..." checkout-key="..." wizard-pages="option-first"></bw-configurator>
426
-
427
- <bw-configurator product-id="..." checkout-key="..." wizard-pages="date-first"></bw-configurator>
428
- ```
429
-
430
- **Using a custom JSON array** (full control):
431
- ```html
432
- <bw-configurator product-id="..." checkout-key="..."
433
- wizard-pages='[
434
- {"title":"Age & capacity","widgets":["age-category"]},
435
- {"title":"Schedule","widgets":["date","time"]},
436
- {"title":"Option","widgets":["option"]},
437
- {"title":"Pickup","widgets":["pickup"]}
438
- ]'>
439
- </bw-configurator>
440
- ```
441
-
442
- If `wizard-pages` is omitted, the default is `option-first`.
443
-
444
- 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.
445
-
446
- ### Edit pages (checkout accordion grouping)
447
-
448
- `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.
449
-
450
- ```html
451
- <bw-checkout checkout-key="..." wizard-pages="date-first" edit-pages="date-first"></bw-checkout>
452
-
453
- <bw-checkout checkout-key="..."
454
- edit-pages='[
455
- {"title":"Age, Date & Time","widgets":["age-category","date","time"]},
456
- {"title":"Option","widgets":["option"]},
457
- {"title":"Pickup","widgets":["pickup"]}
458
- ]'>
459
- </bw-checkout>
460
- ```
461
-
462
- 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.
463
-
464
- 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).
465
-
466
- ### Hiding the time selector
467
-
468
- 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:
469
-
470
- ```html
471
- <bw-configurator product-id="..." checkout-key="..." auto-select-single-time-slot></bw-configurator>
472
- <bw-checkout checkout-key="..." auto-select-single-time-slot></bw-checkout>
473
- ```
474
-
475
- 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.
476
-
477
- ## Theming
478
-
479
- The widget reads CSS custom properties. It automatically picks up site variables (`--header-background`, `--default-font-family`, `--radius`) or can be themed directly:
480
-
481
- ```css
482
- :root {
483
- --bw-color-primary: #0066CC;
484
- --bw-color-primary-dark: #004C99;
485
- --bw-font-family: 'Inter', sans-serif;
486
- --bw-radius-md: 4px;
487
- }
488
- ```
489
-
490
- | Variable | Fallback | Default |
491
- |---|---|---|
492
- | `--bw-color-primary` | `--header-background` | `#E30613` |
493
- | `--bw-color-primary-dark` | `--footer-background` | `#C00510` |
494
- | `--bw-color-primary-light` | -- | `rgba(227,6,19,0.08)` |
495
- | `--bw-color-text` | -- | `#212121` |
496
- | `--bw-color-text-secondary` | -- | `#757575` |
497
- | `--bw-color-border` | -- | `#E0E0E0` |
498
- | `--bw-color-bg` | -- | `#FFFFFF` |
499
- | `--bw-color-surface` | -- | `#F5F5F5` |
500
- | `--bw-color-success` | -- | `#4CAF50` |
501
- | `--bw-color-error` | `--header-background` | `#E30613` |
502
- | `--bw-font-family` | `--default-font-family` | `Roboto, system` |
503
- | `--bw-radius-sm` | -- | `4px` |
504
- | `--bw-radius-md` | `--radius` | `8px` |
505
- | `--bw-radius-lg` | -- | `12px` |
506
- | `--bw-transition` | -- | `0.15s ease` |
1
+ # Booking Widget
2
+
3
+ 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.
4
+
5
+ ## Option 1: HTML Custom Elements (CDN)
6
+
7
+ Drop in a script tag and use the custom elements directly. No framework or npm install required.
8
+
9
+ ```html
10
+ <head>
11
+ <link rel="stylesheet"
12
+ href="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.css"
13
+ integrity="sha384-LDthHLROtZeufKbWmAhXBsAFLqqhkZjWFd8gWU+Ez11qyg5ebj9vpFnvJuZcM3ft"
14
+ crossorigin="anonymous" />
15
+ </head>
16
+ <body>
17
+ <bw-configurator product-id="your-product-id" checkout-key="your-checkout-key"></bw-configurator>
18
+ <bw-cart display="button"></bw-cart>
19
+ <bw-cart display="bar"></bw-cart>
20
+ <bw-checkout></bw-checkout>
21
+
22
+ <script
23
+ src="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.js"
24
+ integrity="sha384-vbNVEDGKWm6wwoq+w8uD3mH8eye+XceceTkCL9vW6qd4YTK6WNqKK0Y0e7YiC541"
25
+ crossorigin="anonymous"></script>
26
+ </body>
27
+ ```
28
+
29
+ > **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.
30
+
31
+ > **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.
32
+
33
+ The script auto-wires the elements together:
34
+ - Adding an item shows the cart and opens the checkout overlay
35
+ - Clicking checkout in the cart opens the overlay
36
+ - Closing or completing checkout closes the overlay
37
+
38
+ ### Element attributes
39
+
40
+ #### `<bw-configurator>`
41
+
42
+ | Attribute | Required | Description |
43
+ |---|---|---|
44
+ | `product-id` | Yes | OCTO product ID |
45
+ | `checkout-key` | Yes | Public key identifying the supplier |
46
+ | `wizard-pages` | No | JSON string for wizard step ordering |
47
+ | `auto-select-single-time-slot` | No | Boolean. Skips the time picker if only one slot |
48
+ | `cancelable` | No | Boolean. Shows a cancel button |
49
+ | `no-auto-checkout` | No | Boolean. Prevents auto-opening checkout on add |
50
+ | `on-cart-change` | No | Name of a global JS function to call on add |
51
+
52
+ #### `<bw-cart>`
53
+
54
+ | Attribute | Required | Description |
55
+ |---|---|---|
56
+ | `checkout-key` | Yes | Public key identifying the supplier |
57
+ | `display` | No | `bar` (default) or `button` |
58
+
59
+ #### `<bw-checkout>`
60
+
61
+ | Attribute | Required | Description |
62
+ |---|---|---|
63
+ | `checkout-key` | Yes | Public key identifying the supplier |
64
+ | `wizard-pages` | No | JSON string for wizard step ordering |
65
+ | `edit-pages` | No | JSON string for how fields are grouped in the cart edit view |
66
+ | `auto-select-single-time-slot` | No | Boolean. Skips the time picker if only one slot |
67
+
68
+ ### Global options
69
+
70
+ Set `window.bwOptions` before the widget script loads:
71
+
72
+ ```html
73
+ <script>
74
+ window.bwOptions = {
75
+ shouldBottomCloseOnModal: true,
76
+ autoSelectSingleTimeSlot: false,
77
+ wizardPages: '',
78
+ editPages: '',
79
+ };
80
+ </script>
81
+ <script src="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.js"
82
+ integrity="sha384-vbNVEDGKWm6wwoq+w8uD3mH8eye+XceceTkCL9vW6qd4YTK6WNqKK0Y0e7YiC541"
83
+ crossorigin="anonymous"></script>
84
+ ```
85
+
86
+ | Option | Default | Description |
87
+ |---|---|---|
88
+ | `shouldBottomCloseOnModal` | `true` | Hide bar carts when checkout modal opens |
89
+ | `autoSelectSingleTimeSlot` | `false` | Auto-select (and hide) the time picker when only one slot |
90
+ | `wizardPages` | `''` | JSON wizard step ordering for all elements |
91
+ | `editPages` | `''` | JSON field grouping for the checkout edit view (`bw-checkout` only) |
92
+
93
+ ### Window events
94
+
95
+ All `bw:*` events are re-dispatched on `window`:
96
+
97
+ ```js
98
+ window.addEventListener('bw:order-confirmed', (e) => {
99
+ console.log(e.detail); // { cartToken, value, currency }
100
+ });
101
+ ```
102
+
103
+ | Event | Detail | When |
104
+ |---|---|---|
105
+ | `bw:cart-change` | `{ itemCount, cartItemId, totalFormatted }` | Item added to cart |
106
+ | `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 |
107
+ | `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) |
108
+ | `bw:modal-open` | -- | Checkout modal opens |
109
+ | `bw:modal-close` | -- | Checkout modal closes |
110
+ | `bw:checkout` | -- | Cart checkout button clicked |
111
+ | `bw:close` | -- | Checkout dismissed |
112
+ | `bw:cancel` | -- | Configurator cancelled |
113
+
114
+ ### Payment outcomes
115
+
116
+ 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.
117
+
118
+ **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.
119
+
120
+ 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.
121
+
122
+ 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:
123
+
124
+ | Outcome | Shown when | Shopper can retry payment? |
125
+ |---|---|---|
126
+ | Payment Successful | Charge succeeded and confirmed | -- |
127
+ | 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 |
128
+ | 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 |
129
+ | 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 |
130
+
131
+ `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.
132
+
133
+ ### Cart expiry
134
+
135
+ 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.
136
+
137
+ 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.
138
+
139
+ 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`.
140
+
141
+ 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.
142
+
143
+ ## Option 2: JavaScript mount functions
144
+
145
+ For programmatic control in an Astro, Vite, or bundled project, install the package and import the ES module:
146
+
147
+ ```bash
148
+ npm install @code-collective/booking-widget
149
+ ```
150
+
151
+ ```js
152
+ import { createBookingHost, mountConfigurator, mountCheckout } from '@code-collective/booking-widget';
153
+ import '@code-collective/booking-widget/style.css';
154
+
155
+ // One host for the page: one session, one cart, one expiry guard. Pass it to every mount function that
156
+ // should share them - without it, each mount builds its own and you get two of everything.
157
+ const host = createBookingHost({
158
+ checkoutKey: 'your-checkout-key',
159
+ apiBaseUrl: 'https://checkout.yourdomain.com',
160
+ });
161
+
162
+ const configurator = await mountConfigurator(document.getElementById('configurator'), {
163
+ host,
164
+ productId: 'your-product-id',
165
+ onCartChange({ itemCount, totalFormatted }) {
166
+ console.log(`${itemCount} items, ${totalFormatted}`);
167
+ },
168
+ });
169
+
170
+ // Adding an item opens this automatically - see Host wiring below.
171
+ const checkout = await mountCheckout(document.getElementById('checkout'), { host, mode: 'modal' });
172
+
173
+ // Later: configurator.destroy(); checkout.destroy(); host.destroy();
174
+ ```
175
+
176
+ ### Mount functions
177
+
178
+ **`mountConfigurator(target, config)`** Product selection wizard
179
+
180
+ | Config | Required | Description |
181
+ |---|---|---|
182
+ | `productId` | Yes | OCTO product ID |
183
+ | `host` | No | A `BookingHost` to share (see [Host wiring](#host-wiring)). Without one, this mount builds its own |
184
+ | `checkoutKey` | No | Supplier checkout key |
185
+ | `apiBaseUrl` | No | Checkout API URL |
186
+ | `wizardPages` | No | Wizard step ordering |
187
+ | `autoSelectSingleTimeSlot` | No | Skip time picker if single slot |
188
+ | Cart callbacks | No | `onCartChange`, `onCartUpdated`, `onCartExpired`, `onOrderConfirmed` - see [Host wiring](#host-wiring) |
189
+ | `onCancel` | No | Callback when cancelled |
190
+
191
+ **`mountCheckout(target, config)`** Cart review, contact form, payment
192
+
193
+ | Config | Required | Description |
194
+ |---|---|---|
195
+ | `host` | No | A `BookingHost` to share (see [Host wiring](#host-wiring)). Without one, this mount builds its own |
196
+ | `checkoutKey` | No | Supplier checkout key |
197
+ | `apiBaseUrl` | No | Checkout API URL |
198
+ | `mode` | No | `inline` (default) or `modal` - a portalled overlay with a scrim |
199
+ | `onClose` | No | Callback when closed |
200
+ | `onOrderConfirmed` | No | Callback: `({ cartToken, value, currency })` |
201
+ | Cart callbacks | No | `onCartChange`, `onCartUpdated`, `onCartExpired`, `onOrderConfirmed` - see [Host wiring](#host-wiring) |
202
+
203
+ **`mountCartOverview(target, config)`** Cart summary
204
+
205
+ | Config | Required | Description |
206
+ |---|---|---|
207
+ | `host` | No | A `BookingHost` to share (see [Host wiring](#host-wiring)). Without one, this mount builds its own |
208
+ | `checkoutKey` | No | Supplier checkout key |
209
+ | `apiBaseUrl` | No | Checkout API URL |
210
+ | `display` | No | `bar` or `button` |
211
+ | `onCheckout` | No | Callback when checkout clicked |
212
+ | Cart callbacks | No | `onCartChange`, `onCartUpdated`, `onCartExpired`, `onOrderConfirmed` - see [Host wiring](#host-wiring) |
213
+
214
+ All mount functions return `Promise<{ destroy(): void }>`. Every config also accepts the
215
+ [host options](#host-wiring) (`autoOpenCheckout`, `wizardPages`, `autoSelectSingleTimeSlot`, `onCartUpdated`,
216
+ ...), which are used to build the host when you do not pass one.
217
+
218
+ The page-wide "Are you still there?" prompt and extend flow come with the host, so this option has them.
219
+
220
+ ## Option 3: Svelte components
221
+
222
+ Install the package and import Svelte components directly for full reactivity:
223
+
224
+ ```bash
225
+ npm install @code-collective/booking-widget
226
+ ```
227
+
228
+ ```svelte
229
+ <script lang="ts">
230
+ import { createBookingHost, BookingProvider, TicketConfigurator, Checkout, CartOverviewButton } from '@code-collective/booking-widget';
231
+ import '@code-collective/booking-widget/style.css';
232
+
233
+ let host = $state<BookingHost | null>(null);
234
+ let ready = $state(false);
235
+
236
+ // In onMount, not the script body: anything server-rendered (an Astro island, SvelteKit SSR) runs the
237
+ // body once with no DOM, and a host built there opens a session the hydrated page throws away.
238
+ onMount(() => {
239
+ const created = createBookingHost({
240
+ apiBaseUrl: 'https://checkout.yourdomain.com',
241
+ checkoutKey: 'your-checkout-key',
242
+ });
243
+ host = created;
244
+ // ready rejects if the checkout service turns the session down - usually the key not being allowlisted
245
+ // for this origin. Uncaught, the page sits on its loading state forever.
246
+ created.ready.then(() => { ready = true; }).catch(() => { /* render your own fallback */ });
247
+ return () => created.destroy();
248
+ });
249
+ </script>
250
+
251
+ {#if ready && host}
252
+ <BookingProvider host={host}>
253
+ <TicketConfigurator productId="your-product-id" />
254
+ <CartOverviewButton />
255
+ <Checkout mode="modal" />
256
+ </BookingProvider>
257
+ {/if}
258
+ ```
259
+
260
+ Adding an item opens the checkout, the modal portals itself out of any sticky or transformed ancestor, and
261
+ the page-wide "Are you still there?" prompt is mounted for you - all from the host, with no wiring of your
262
+ own. See [Host wiring](#host-wiring).
263
+
264
+ `BookingProvider` puts the host in Svelte context, which is why the components inside it need no `api` or
265
+ `cartManager`, and why `Checkout` needs no `open` or `onClose`. Every one of those props still exists and
266
+ still wins when you pass it - the `bw-*` custom elements pass them explicitly, since context does not cross
267
+ a custom element's own root. Without a provider and without the prop, a component throws and names both
268
+ ways to fix it rather than failing somewhere deeper.
269
+
270
+ Building the services by hand (`new ApiClient(...)`, `new SessionManager(...)`, `new CartManager(...)`) still
271
+ works and is what `createBookingHost` does internally, but none of the behaviour above comes with it. If you
272
+ go that route, mount `CartExpiryGuard` yourself:
273
+
274
+ ```svelte
275
+ <CartExpiryGuard {api} {cartManager} />
276
+ ```
277
+
278
+ ## Host wiring
279
+
280
+ `createBookingHost(config)` is the one place the widget's host behaviour lives. The custom-elements build is
281
+ a thin DOM adapter over it, so both builds auto-wire through exactly the same code.
282
+
283
+ ```ts
284
+ const host = createBookingHost({
285
+ apiBaseUrl, checkoutKey,
286
+ autoOpenCheckout: true, // default - opt out, never opt in
287
+ shouldBottomCloseOnModal: true, // hide bar-display carts while checkout is open
288
+ onCartChange({ itemCount, cartItemId, totalFormatted }) {}, // an add, with its own detail
289
+ onCartUpdated(cart) {}, // any change, with the whole cart
290
+ onCartExpired() {}, // the cart's window ran out
291
+ onCheckoutOpenChange(open) {},
292
+ onOrderConfirmed({ cartToken, value, currency }) {},
293
+ });
294
+
295
+ host.api / host.cartManager / host.sessionManager // one set, shared by every component
296
+ host.ready // resolves once the session exists; rejects if it is refused
297
+ host.isCheckoutOpen // reactive
298
+ host.openCheckout() / host.closeCheckout()
299
+ host.destroy()
300
+ ```
301
+
302
+ | Option | Type | Default | Description |
303
+ |---|---|---|---|
304
+ | `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 |
305
+ | `shouldBottomCloseOnModal` | `boolean` | `true` | Hide bar-display cart overviews while checkout is open |
306
+ | `autoSelectSingleTimeSlot` | `boolean` | `false` | Skip the time picker when only one slot is available |
307
+ | `wizardPages` / `editPages` | `WizardPages` | - | Wizard step ordering, handed to every component the host serves |
308
+ | `mountExpiryGuard` | `boolean` | `true` | Mount `CartExpiryGuard` on `<body>` |
309
+ | `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 |
310
+ | `peachSdkUrl` | `string` | - | An explicit Peach SDK URL, for an environment `peachEnv` does not name |
311
+ | `api` | `BookingApi` | - | An API client to use instead of building one from `apiBaseUrl` - a test double, or a fake for a demo page |
312
+
313
+ `setBookingDefaults({ ... })` applies the same options site-wide to every host created afterwards - the ES
314
+ equivalent of `window.bwOptions`. Anything passed to `createBookingHost` wins over a default.
315
+
316
+ ### Callbacks
317
+
318
+ | Callback | Fires |
319
+ |---|---|
320
+ | `onCartChange` | On an add, with the detail only the add knows: `{ itemCount, cartItemId, totalFormatted }`. Fires whether or not checkout opened |
321
+ | `onCartUpdated` | After any add, edit or remove, carrying the whole cart the widget already fetched. What a custom cart summary reads |
322
+ | `onCartExpired` | The cart's window ran out - distinct from the `null` `onCartUpdated` that a confirmed order also produces |
323
+ | `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 |
324
+ | `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 |
325
+
326
+ Each is **one slot, fixed when the host is built**: set it twice and the second wins, and there is no
327
+ detaching it short of `host.destroy()`. Where more than one part of the page needs the same event, or a
328
+ subscription should come and go with a component, subscribe to the message bus instead - see below. The two
329
+ ride the same bus and fire at the same moment.
330
+
331
+ The mount functions take these on their own config too. When you hand them a `host`, the callbacks on that
332
+ config are wired to the same events rather than dropped - but each event still reaches your handler once.
333
+
334
+ ### Messages
335
+
336
+ `onWidgetMessage(handler)` subscribes to the widget's own message bus and returns an unsubscribe function;
337
+ `postMessage(message)` sends one. Both are exported, and every message is a member of the `WidgetMessage`
338
+ union (`cart:change`, `cart:updated`, `cart:expired`, `modal:open`, `modal:close`, `order:complete`,
339
+ `payment:started`, `payment:ended`, `payment:timed-out`), so a handler narrows on `message.type`.
340
+
341
+ ```ts
342
+ import { onWidgetMessage } from '@code-collective/booking-widget';
343
+
344
+ const stop = onWidgetMessage((m) => {
345
+ if (m.type === 'cart:change') track('add_to_cart', m.totalFormatted);
346
+ });
347
+ // later
348
+ stop();
349
+ ```
350
+
351
+ Prefer the host callbacks where they cover what you need - they are the same events, already parsed. Reach
352
+ for `onWidgetMessage` when you need several subscribers, or one that comes and goes.
353
+
354
+ ## Astro integration
355
+
356
+ ### Script tag approach
357
+
358
+ ```astro
359
+ ---
360
+ const product = await getProduct(Astro.params.slug);
361
+ ---
362
+
363
+ <bw-configurator product-id={product.id} checkout-key={product.checkoutKey}></bw-configurator>
364
+ <bw-cart display="bar" checkout-key={product.checkoutKey}></bw-cart>
365
+ <bw-checkout checkout-key={product.checkoutKey}></bw-checkout>
366
+
367
+ <link rel="stylesheet"
368
+ href="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.css"
369
+ integrity="sha384-LDthHLROtZeufKbWmAhXBsAFLqqhkZjWFd8gWU+Ez11qyg5ebj9vpFnvJuZcM3ft"
370
+ crossorigin="anonymous" />
371
+ <script is:inline
372
+ src="https://cdn.jsdelivr.net/npm/@code-collective/booking-widget@1.0.15/dist/booking-widget.min.js"
373
+ integrity="sha384-vbNVEDGKWm6wwoq+w8uD3mH8eye+XceceTkCL9vW6qd4YTK6WNqKK0Y0e7YiC541"
374
+ crossorigin="anonymous"></script>
375
+ ```
376
+
377
+ ### Svelte island approach
378
+
379
+ ```bash
380
+ npx astro add svelte
381
+ npm install @code-collective/booking-widget
382
+ ```
383
+
384
+ ```astro
385
+ ---
386
+ import BookingWidget from '../components/BookingWidget.svelte';
387
+ const product = await getProduct(Astro.params.slug);
388
+ ---
389
+
390
+ <BookingWidget
391
+ client:load
392
+ productId={product.id}
393
+ checkoutKey={product.checkoutKey}
394
+ apiBaseUrl="https://checkout.yourdomain.com"
395
+ />
396
+ ```
397
+
398
+ ## Wizard pages
399
+
400
+ 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.
401
+
402
+ **Option-first (default):**
403
+ ```json
404
+ [
405
+ { "title": "Option", "widgets": ["option", "age-category"] },
406
+ { "title": "Schedule", "widgets": ["date", "time"] },
407
+ { "title": "Pickup", "widgets": ["pickup"] }
408
+ ]
409
+ ```
410
+
411
+ **Date-first:**
412
+ ```json
413
+ [
414
+ { "title": "Age & capacity", "widgets": ["age-category"] },
415
+ { "title": "Schedule", "widgets": ["date", "time"] },
416
+ { "title": "Option", "widgets": ["option"] },
417
+ { "title": "Pickup", "widgets": ["pickup"] }
418
+ ]
419
+ ```
420
+
421
+ Available widget types: `option`, `age-category`, `date`, `time`, `pickup`.
422
+
423
+ ### Setting wizard pages
424
+
425
+ **Using a preset** (simplest):
426
+ ```html
427
+ <bw-configurator product-id="..." checkout-key="..." wizard-pages="option-first"></bw-configurator>
428
+
429
+ <bw-configurator product-id="..." checkout-key="..." wizard-pages="date-first"></bw-configurator>
430
+ ```
431
+
432
+ **Using a custom JSON array** (full control):
433
+ ```html
434
+ <bw-configurator product-id="..." checkout-key="..."
435
+ wizard-pages='[
436
+ {"title":"Age & capacity","widgets":["age-category"]},
437
+ {"title":"Schedule","widgets":["date","time"]},
438
+ {"title":"Option","widgets":["option"]},
439
+ {"title":"Pickup","widgets":["pickup"]}
440
+ ]'>
441
+ </bw-configurator>
442
+ ```
443
+
444
+ If `wizard-pages` is omitted, the default is `option-first`.
445
+
446
+ 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.
447
+
448
+ ### Edit pages (checkout accordion grouping)
449
+
450
+ `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.
451
+
452
+ ```html
453
+ <bw-checkout checkout-key="..." wizard-pages="date-first" edit-pages="date-first"></bw-checkout>
454
+
455
+ <bw-checkout checkout-key="..."
456
+ edit-pages='[
457
+ {"title":"Age, Date & Time","widgets":["age-category","date","time"]},
458
+ {"title":"Option","widgets":["option"]},
459
+ {"title":"Pickup","widgets":["pickup"]}
460
+ ]'>
461
+ </bw-checkout>
462
+ ```
463
+
464
+ 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.
465
+
466
+ 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).
467
+
468
+ ### Hiding the time selector
469
+
470
+ 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:
471
+
472
+ ```html
473
+ <bw-configurator product-id="..." checkout-key="..." auto-select-single-time-slot></bw-configurator>
474
+ <bw-checkout checkout-key="..." auto-select-single-time-slot></bw-checkout>
475
+ ```
476
+
477
+ 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.
478
+
479
+ ## Theming
480
+
481
+ The widget reads CSS custom properties. It automatically picks up site variables (`--header-background`, `--default-font-family`, `--radius`) or can be themed directly:
482
+
483
+ ```css
484
+ :root {
485
+ --bw-color-primary: #0066CC;
486
+ --bw-color-primary-dark: #004C99;
487
+ --bw-font-family: 'Inter', sans-serif;
488
+ --bw-radius-md: 4px;
489
+ }
490
+ ```
491
+
492
+ | Variable | Fallback | Default |
493
+ |---|---|---|
494
+ | `--bw-color-primary` | `--header-background` | `#E30613` |
495
+ | `--bw-color-primary-dark` | `--footer-background` | `#C00510` |
496
+ | `--bw-color-primary-light` | -- | `rgba(227,6,19,0.08)` |
497
+ | `--bw-color-text` | -- | `#212121` |
498
+ | `--bw-color-text-secondary` | -- | `#757575` |
499
+ | `--bw-color-border` | -- | `#E0E0E0` |
500
+ | `--bw-color-bg` | -- | `#FFFFFF` |
501
+ | `--bw-color-surface` | -- | `#F5F5F5` |
502
+ | `--bw-color-success` | -- | `#4CAF50` |
503
+ | `--bw-color-error` | `--header-background` | `#E30613` |
504
+ | `--bw-font-family` | `--default-font-family` | `Roboto, system` |
505
+ | `--bw-radius-sm` | -- | `4px` |
506
+ | `--bw-radius-md` | `--radius` | `8px` |
507
+ | `--bw-radius-lg` | -- | `12px` |
508
+ | `--bw-transition` | -- | `0.15s ease` |