@code-collective/booking-widget 1.0.12 → 1.0.13

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