@code-collective/booking-widget 1.0.10 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +153 -41
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +2637 -1924
  5. package/dist/booking-widget.min.css +1 -1
  6. package/dist/booking-widget.min.js +34 -16
  7. package/dist/booking-widget.umd.cjs +5 -3
  8. package/package.json +5 -2
  9. package/src/lib/BookingProvider.svelte +21 -0
  10. package/src/lib/CartBar.svelte +16 -31
  11. package/src/lib/CartBarView.svelte +20 -3
  12. package/src/lib/CartExpiryGuard.svelte +416 -410
  13. package/src/lib/CartOverview.svelte +16 -6
  14. package/src/lib/CartOverviewButton.svelte +29 -26
  15. package/src/lib/Checkout.svelte +111 -98
  16. package/src/lib/CheckoutModal.svelte +946 -805
  17. package/src/lib/CheckoutPanel.svelte +121 -0
  18. package/src/lib/PaymentPage.svelte +16 -2
  19. package/src/lib/PickupPointPicker.svelte +1 -1
  20. package/src/lib/ResultView.svelte +24 -4
  21. package/src/lib/TicketConfigurator.svelte +19 -5
  22. package/src/lib/UnitCounter.svelte +16 -2
  23. package/src/lib/WizardPage.svelte +102 -35
  24. package/src/lib/app.css +0 -6
  25. package/src/lib/booking-context.ts +33 -0
  26. package/src/lib/cart-overview.svelte.ts +97 -0
  27. package/src/lib/client-types.ts +10 -6
  28. package/src/lib/config.ts +9 -0
  29. package/src/lib/elements/bw-cart.svelte +21 -7
  30. package/src/lib/elements/bw-checkout.svelte +54 -54
  31. package/src/lib/elements/bw-configurator.svelte +30 -14
  32. package/src/lib/elements/register.ts +96 -121
  33. package/src/lib/elements/shared.ts +4 -0
  34. package/src/lib/elements/theme.css +0 -6
  35. package/src/lib/host.svelte.ts +336 -0
  36. package/src/lib/index.ts +137 -91
  37. package/src/lib/layout.svelte.ts +52 -0
  38. package/src/lib/messages.ts +97 -17
  39. package/src/lib/peach-sdk.ts +51 -5
  40. package/src/lib/portal.ts +23 -0
  41. package/src/lib/CartExpiryGuard.test.ts +0 -331
  42. package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
  43. package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
  44. package/src/lib/test/fixtures.ts +0 -107
  45. package/src/lib/test/messages-mock.ts +0 -34
@@ -0,0 +1,97 @@
1
+ // The cart state behind both CartOverview presentations. Extracted because the bar and the button had the
2
+ // same load-then-follow-cart:updated logic written twice, and both answered a failed fetch the same wrong
3
+ // way: `cart = null`, rendering exactly what an empty cart renders - nothing at all. On a narrow layout the
4
+ // bar is the only route to checkout, so "no DOM" has to mean something a consumer can act on.
5
+
6
+ import type { BookingApi } from './api';
7
+ import type { CartManager } from './cart-manager';
8
+ import type { CartOverviewState } from './config';
9
+ import type { CheckoutCartDetailDto } from './client-types';
10
+ import { onWidgetMessage } from './messages';
11
+
12
+ export interface CartOverview {
13
+ readonly cart: CheckoutCartDetailDto | null;
14
+ readonly state: CartOverviewState;
15
+ /** Re-reads the cart. What the error presentation's retry is wired to. */
16
+ readonly reload: () => void;
17
+ }
18
+
19
+ /**
20
+ * Call during component initialisation - it registers an $effect that follows cart:updated for the
21
+ * component's lifetime.
22
+ */
23
+ export function createCartOverview(
24
+ // Accessors rather than values: these arrive as props, and reading them through a closure keeps this
25
+ // following the current ones rather than pinning whichever pair happened to be passed on first render.
26
+ getApi: () => BookingApi,
27
+ getCartManager: () => CartManager,
28
+ onStateChange?: (state: CartOverviewState) => void,
29
+ ): CartOverview {
30
+ let cart = $state<CheckoutCartDetailDto | null>(null);
31
+ let failed = $state(false);
32
+ let isLoading = $state(true);
33
+ // Only the newest load may write. A read still in flight when a fresher cart arrives - from a retry, or
34
+ // from cart:updated after an add elsewhere on the page - would otherwise land afterwards and overwrite it,
35
+ // and a transient failure landing late would replace a perfectly good cart summary with the error view.
36
+ let currentLoad = 0;
37
+
38
+ async function load() {
39
+ const thisLoad = ++currentLoad;
40
+ isLoading = true;
41
+ failed = false;
42
+ // No token held means there is genuinely nothing to fetch - an empty cart, not a failure.
43
+ if (!getCartManager().hasCart) {
44
+ cart = null;
45
+ isLoading = false;
46
+ return;
47
+ }
48
+ try {
49
+ const loaded = await getApi().getCart();
50
+ if (thisLoad !== currentLoad) return;
51
+ cart = loaded;
52
+ } catch {
53
+ if (thisLoad !== currentLoad) return;
54
+ cart = null;
55
+ failed = true;
56
+ }
57
+ isLoading = false;
58
+ }
59
+
60
+ void load();
61
+
62
+ // load() only runs once - without this, adding/editing/removing an item elsewhere on the page (the
63
+ // configurator, or the checkout modal's own edit/remove) leaves this showing a stale total. The event
64
+ // already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so it is
65
+ // applied directly rather than triggering a second, redundant getCart() call.
66
+ $effect(() =>
67
+ onWidgetMessage((d) => {
68
+ if (d.type === 'cart:updated') {
69
+ // Supersedes any read still in flight - this cart is newer than whatever it will return.
70
+ currentLoad += 1;
71
+ cart = d.cart;
72
+ // A cart arriving by message is a cart successfully read somewhere else on the page, so whatever
73
+ // this one's own fetch ran into is no longer the current answer.
74
+ failed = false;
75
+ isLoading = false;
76
+ }
77
+ }),
78
+ );
79
+
80
+ const state = $derived<CartOverviewState>(
81
+ isLoading ? 'loading' : failed ? 'error' : (cart?.items?.length ?? 0) > 0 ? 'ready' : 'empty',
82
+ );
83
+
84
+ $effect(() => {
85
+ onStateChange?.(state);
86
+ });
87
+
88
+ return {
89
+ get cart() {
90
+ return cart;
91
+ },
92
+ get state() {
93
+ return state;
94
+ },
95
+ reload: () => void load(),
96
+ };
97
+ }
@@ -23,17 +23,21 @@ export type CheckoutCartConfirmItemResultDto = S['CheckoutCartConfirmItemResultD
23
23
  export type OctoContact = S['OctoContact'];
24
24
 
25
25
  // Client-side types
26
- export type PaymentOutcome = 'successful' | 'pending' | 'failed' | 'cancelled';
26
+ // 'partial' is its own outcome rather than a kind of 'failed' because the shopper's card WAS charged: the
27
+ // payment succeeded and only some of the booking's items could be confirmed afterwards. It gets a heading of
28
+ // its own in ResultView, since "Payment Failed" printed over the words "Your payment succeeded" invites
29
+ // exactly the wrong reaction - paying again elsewhere, or charging back a payment that did go through.
30
+ export type PaymentOutcome = 'successful' | 'pending' | 'partial' | 'failed' | 'cancelled';
27
31
 
28
32
  export interface PaymentStatus {
29
33
  outcome: PaymentOutcome;
30
34
  resultCode?: string;
31
35
  resultDescription?: string;
32
- // Only meaningful for outcome 'failed'. A card that was actually charged - which is every 'failed' this
33
- // gateway produces once confirmation itself is what failed, not the payment - must never be offered a
34
- // "try again" that re-opens the card form, since the shopper would be charged a second time for a booking
35
- // that already has their money against it. Defaults true (the ordinary declined/expired/errored case,
36
- // where nothing was charged and retrying is exactly correct) so existing callers need not set it.
36
+ // Only meaningful for outcome 'failed', and only for a 'failed' that nonetheless took the shopper's money:
37
+ // it must never be offered a "try again" that re-opens the card form, since they would be charged a second
38
+ // time for a booking that already has their money against it. Defaults true (the ordinary declined/expired/
39
+ // errored case, where nothing was charged and retrying is exactly correct) so existing callers need not set
40
+ // it. The confirm-partial case this was first introduced for now carries its own 'partial' outcome.
37
41
  retryPayment?: boolean;
38
42
  }
39
43
 
package/src/lib/config.ts CHANGED
@@ -9,6 +9,15 @@ export type WidgetMode = 'configurator' | 'checkout' | 'cart-overview';
9
9
 
10
10
  export type CartOverviewDisplay = 'bar' | 'button';
11
11
 
12
+ // How <Checkout> presents itself: in the page where it is placed, or as an overlay portalled to <body>.
13
+ export type CheckoutMode = 'inline' | 'modal';
14
+
15
+ // What <CartOverview> and its two presentations currently have to show. Distinguishing 'empty' from 'error'
16
+ // is the whole point: a bar that renders nothing for both leaves a consumer unable to tell a cart with no
17
+ // items from a fetch that failed, which - as the only route to checkout on a narrow layout - is the one
18
+ // place that cannot be undebuggable.
19
+ export type CartOverviewState = 'loading' | 'empty' | 'ready' | 'error';
20
+
12
21
  export type WizardWidgetType = 'option' | 'age-category' | 'date' | 'time' | 'pickup' | 'addon';
13
22
 
14
23
  export interface WizardPageConfig {
@@ -15,9 +15,20 @@
15
15
  let ready = $state(false);
16
16
  readyPromise.then(() => { ready = true; });
17
17
 
18
+ // Each instance dispatches on its own element, found by walking up from a node it rendered. It used to
19
+ // use document.querySelector('bw-cart'), which is the *first* one on the page - so with two of these
20
+ // elements every event was dispatched twice on that first element (and the second element never received
21
+ // its own at all). The shipped demo has two <bw-cart>, so that was not hypothetical.
22
+ //
23
+ // Walking up rather than $host(): these wrappers are compiled both with and without customElement (the
24
+ // elements build and the test/ES build), and $host() only exists in the former. With no custom element
25
+ // above it - which is how the tests mount these - closest() finds nothing and body carries the event,
26
+ // which still reaches window.
27
+ let anchor = $state<HTMLElement | null>(null);
28
+
18
29
  function dispatch(name: string, detail: unknown) {
19
- const host = (document.querySelector('bw-cart') as HTMLElement) ?? document.body;
20
- host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
30
+ const target = anchor?.closest('bw-cart') ?? document.body;
31
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
21
32
  }
22
33
 
23
34
  $effect(() => onWidgetMessage((d) => {
@@ -27,9 +38,12 @@
27
38
  }));
28
39
  </script>
29
40
 
30
- {#if ready}
31
- <CartOverview {api} {cartManager} {display} />
32
- {:else}
33
- <div class="loading-center"><div class="spinner"></div></div>
34
- {/if}
41
+ <!-- display: contents so this is only an anchor for dispatch(), never a box in the layout. -->
42
+ <div style="display: contents" bind:this={anchor}>
43
+ {#if ready}
44
+ <CartOverview {api} {cartManager} {display} />
45
+ {:else}
46
+ <div class="loading-center"><div class="spinner"></div></div>
47
+ {/if}
48
+ </div>
35
49
 
@@ -1,6 +1,9 @@
1
1
  <svelte:options customElement={{ tag: 'bw-checkout', shadow: 'none' }} />
2
2
 
3
3
  <script lang="ts">
4
+ // Presentation-free: the overlay, the scrim, the click-outside close and the portal all live in
5
+ // Checkout.svelte now, so the ES build renders the same modal from the same code. What is left here is the
6
+ // custom element's own surface - attributes in, bw:* events out.
4
7
  import Checkout from '../Checkout.svelte';
5
8
  import { resolveWizardPages, resolveEditPages } from '../config';
6
9
  import { onWidgetMessage } from '../messages';
@@ -15,10 +18,9 @@
15
18
 
16
19
  let autoSelectSingleTimeSlot = $derived(autoSelectSingleTimeSlotAttr !== undefined);
17
20
 
18
- let isModal = $derived(isModalAttr !== undefined);
19
- let showModal = $state(false);
21
+ let mode = $derived(isModalAttr !== undefined ? 'modal' as const : 'inline' as const);
20
22
 
21
- const { api, cartManager, ready: readyPromise } = getSharedServices();
23
+ const { api, cartManager, ready: readyPromise, host } = getSharedServices();
22
24
 
23
25
  let ready = $state(false);
24
26
  readyPromise.then(() => { ready = true; });
@@ -26,18 +28,48 @@
26
28
  let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
27
29
  let editPages = $derived(resolveEditPages(wizardPagesJson, editPagesJson));
28
30
 
31
+ // Each instance dispatches on its own element, found by walking up from a node it rendered. It used to
32
+ // use document.querySelector('bw-checkout'), which is the *first* one on the page - so with two of these
33
+ // elements every event was dispatched twice on that first element (and the second element never received
34
+ // its own at all). The shipped demo has two <bw-cart>, so that was not hypothetical.
35
+ //
36
+ // Walking up rather than $host(): these wrappers are compiled both with and without customElement (the
37
+ // elements build and the test/ES build), and $host() only exists in the former. With no custom element
38
+ // above it - which is how the tests mount these - closest() finds nothing and body carries the event,
39
+ // which still reaches window.
40
+ let anchor = $state<HTMLElement | null>(null);
41
+
29
42
  function dispatch(name: string, detail: unknown) {
30
- const host = (document.querySelector('bw-checkout') as HTMLElement) ?? document.body;
31
- host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
43
+ const target = anchor?.closest('bw-checkout') ?? document.body;
44
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
32
45
  }
33
46
 
34
- // Public API: open/close the modal from outside
35
- export function open() { showModal = true; }
36
- export function close() { showModal = false; dispatch('bw:close', {}); }
47
+ // The element's public methods - Svelte exposes these on the custom element itself, so a merchant can
48
+ // drive checkout from their own UI: document.querySelector('bw-checkout').open(). Both go through the
49
+ // host, which is what bw-cart's button and an add-to-cart already reach, so opening this way hides the
50
+ // bar carts and fires bw:modal-open like any other route in. They used to set this element's own
51
+ // showModal instead, which skipped all of that.
52
+ export function open() { host.openCheckout(); }
53
+ export function close() { host.closeCheckout(); }
54
+
55
+ // One dismissal produces two modal:close messages - Checkout's own scrim/Escape, and the panel posting
56
+ // again as it is torn down - so dispatching per message fired bw:close twice for one close. A plain
57
+ // variable rather than $state, for the same reason Checkout.svelte's own guard is: the second message
58
+ // arrives while Svelte is still flushing the teardown, where a $state write is not yet readable.
59
+ let closeReported = false;
60
+ let wasOpen = false;
61
+ $effect(() => {
62
+ const isOpen = host.isCheckoutOpen;
63
+ if (isOpen && !wasOpen) closeReported = false;
64
+ wasOpen = isOpen;
65
+ });
37
66
 
38
67
  $effect(() => onWidgetMessage((d) => {
39
- if (d.type === 'modal:close') {
40
- if (isModal) showModal = false;
68
+ // Only a close this element was open for. modal:close is page-wide - the expiry guard's "start again"
69
+ // and a cancelable configurator both post it with no checkout showing - and reporting those burned the
70
+ // guard, so the shopper's real dismissal afterwards said nothing at all.
71
+ if (d.type === 'modal:close' && !closeReported && wasOpen) {
72
+ closeReported = true;
41
73
  dispatch('bw:close', {});
42
74
  }
43
75
  if (d.type === 'order:complete') {
@@ -45,53 +77,21 @@
45
77
  // (for host analytics/cart-clearing), not that the shopper has finished looking at the result screen.
46
78
  // The modal stays open until they dismiss it themselves, which sends modal:close separately.
47
79
  dispatch('bw:order-confirmed', {
48
- cartToken: d.cartToken ?? '',
49
- value: d.value ?? 0,
50
- currency: d.currency ?? 'ZAR',
80
+ cartToken: d.cartToken,
81
+ value: d.value,
82
+ currency: d.currency,
51
83
  });
52
84
  }
53
85
  }));
54
-
55
- // Listen for a custom 'bw:open' event so register.ts can trigger it
56
- $effect(() => {
57
- const host = document.querySelector('bw-checkout');
58
- if (!host) return;
59
- function onOpen() { showModal = true; }
60
- host.addEventListener('bw:open', onOpen);
61
- return () => host.removeEventListener('bw:open', onOpen);
62
- });
63
86
  </script>
64
87
 
65
- {#if isModal}
66
- {#if showModal && ready}
67
- <div class="bw-modal-overlay" onclick={() => close()}>
68
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
69
- <div class="bw-modal-inner" onclick={(e) => e.stopPropagation()}>
70
- <Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
71
- </div>
72
- </div>
88
+ <!-- Always rendered, even in modal mode with nothing on screen: dispatch() needs an anchor before the
89
+ session is ready, and Checkout portals its overlay out of here anyway. -->
90
+ <div style="display: contents" bind:this={anchor}>
91
+ {#if ready}
92
+ <Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot}
93
+ {mode} open={host.isCheckoutOpen} onClose={() => host.closeCheckout()} />
94
+ {:else if mode === 'inline'}
95
+ <div class="loading-center"><div class="spinner"></div></div>
73
96
  {/if}
74
- {:else if ready}
75
- <Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
76
- {:else}
77
- <div class="loading-center"><div class="spinner"></div></div>
78
- {/if}
79
-
80
- <style>
81
- .bw-modal-overlay {
82
- position: fixed;
83
- inset: 0;
84
- z-index: 10000;
85
- background: rgba(0, 0, 0, 0.5);
86
- }
87
- .bw-modal-inner {
88
- width: 100%;
89
- max-width: 900px;
90
- height: 95vh;
91
- margin: 2.5vh auto;
92
- border-radius: 12px;
93
- background: #fff;
94
- overflow: hidden;
95
- box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
96
- }
97
- </style>
97
+ </div>
@@ -10,6 +10,7 @@
10
10
  'product-id': productId = '',
11
11
  'wizard-pages': wizardPagesJson = '',
12
12
  'auto-select-single-time-slot': autoSelectSingleTimeSlot = undefined,
13
+ 'no-auto-checkout': noAutoCheckout = undefined,
13
14
  cancelable = undefined,
14
15
  } = $props();
15
16
 
@@ -20,9 +21,20 @@
20
21
 
21
22
  let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
22
23
 
24
+ // Each instance dispatches on its own element, found by walking up from a node it rendered. It used to
25
+ // use document.querySelector('bw-configurator'), which is the *first* one on the page - so with two of these
26
+ // elements every event was dispatched twice on that first element (and the second element never received
27
+ // its own at all). The shipped demo has two <bw-cart>, so that was not hypothetical.
28
+ //
29
+ // Walking up rather than $host(): these wrappers are compiled both with and without customElement (the
30
+ // elements build and the test/ES build), and $host() only exists in the former. With no custom element
31
+ // above it - which is how the tests mount these - closest() finds nothing and body carries the event,
32
+ // which still reaches window.
33
+ let anchor = $state<HTMLElement | null>(null);
34
+
23
35
  function dispatch(name: string, detail: unknown) {
24
- const host = (document.querySelector('bw-configurator') as HTMLElement) ?? document.body;
25
- host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
36
+ const target = anchor?.closest('bw-configurator') ?? document.body;
37
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
26
38
  }
27
39
 
28
40
  // Forward postMessage events as CustomEvents
@@ -41,16 +53,20 @@
41
53
  : undefined;
42
54
  </script>
43
55
 
44
- {#if ready}
45
- <TicketConfigurator
46
- {api}
47
- {cartManager}
48
- {productId}
49
- {wizardPages}
50
- autoSelectSingleTimeSlot={autoSelectSingleTimeSlot !== undefined}
51
- {onCancel}
52
- />
53
- {:else}
54
- <div class="loading-center"><div class="spinner"></div></div>
55
- {/if}
56
+ <!-- display: contents so this is only an anchor for dispatch(), never a box in the layout. -->
57
+ <div style="display: contents" bind:this={anchor}>
58
+ {#if ready}
59
+ <TicketConfigurator
60
+ {api}
61
+ {cartManager}
62
+ {productId}
63
+ {wizardPages}
64
+ autoSelectSingleTimeSlot={autoSelectSingleTimeSlot !== undefined}
65
+ autoOpenCheckout={noAutoCheckout === undefined}
66
+ {onCancel}
67
+ />
68
+ {:else}
69
+ <div class="loading-center"><div class="spinner"></div></div>
70
+ {/if}
71
+ </div>
56
72
 
@@ -1,70 +1,16 @@
1
+ // The custom-elements build's DOM adapter. Everything it used to decide for itself - when checkout opens,
2
+ // where the expiry guard lives, what the site-wide defaults are - now lives in host.svelte.ts, which the ES
3
+ // build reaches too. What is left here is genuinely DOM: read attributes and window.bwOptions, and bind
4
+ // bw:* events to the host.
5
+
1
6
  import '../app.css';
2
- import type { BookingApi } from '../api';
3
- import { ApiClient } from '../api';
4
- import { SessionManager } from '../session-manager';
5
- import { CartManager } from '../cart-manager';
7
+ import { createBookingHost, setBookingDefaults } from '../host.svelte';
6
8
  import { defaultApiBaseUrl } from './env';
7
- import { onWidgetMessage } from '../messages';
8
- import { mount } from 'svelte';
9
- import CartExpiryGuard from '../CartExpiryGuard.svelte';
10
-
11
- // Bootstrap shared services BEFORE element imports trigger connectedCallback.
12
- // Elements read from window.__bwServices instead of importing shared.ts,
13
- // because the IIFE bundler inlines imports per-element — a module-level
14
- // singleton would be duplicated. window is the only truly shared scope.
15
- const checkoutKey = document.querySelector('[checkout-key]')?.getAttribute('checkout-key') ?? '';
16
- const api: BookingApi = new ApiClient(defaultApiBaseUrl);
17
- const sessionManager = new SessionManager(api);
18
- const cartManager = new CartManager(api);
19
- sessionManager.startBackgroundRefresh();
20
- const ready = sessionManager.ensureSession(checkoutKey).then(() => {});
21
- (window as any).__bwServices = { api, cartManager, ready };
22
-
23
- function whenDomReady(fn: () => void) {
24
- if (document.readyState === 'loading') {
25
- document.addEventListener('DOMContentLoaded', fn, { once: true });
26
- } else {
27
- fn();
28
- }
29
- }
30
-
31
- // Mounted once, directly on <body> - not inside any <bw-*> element's own tree, so the "still shopping?"
32
- // warning and the expiry it can lead to work regardless of which combination of elements (if any include
33
- // <bw-checkout> at all) the merchant actually placed on this page. Session readiness is irrelevant to it -
34
- // cart operations only need the cart token, never the session token - so it does not wait on `ready`.
35
- //
36
- // Deferred to DOMContentLoaded rather than run inline: a <script> placed in <head> without defer/type=module
37
- // runs before <body> exists at all, and document.body would be null here. An exception at module level would
38
- // also skip the cart:updated forwarding and autoWire below, not just the expiry guard.
39
- function mountExpiryGuard() {
40
- const guardHost = document.createElement('div');
41
- guardHost.dataset.bwExpiryGuard = '';
42
- document.body.appendChild(guardHost);
43
- mount(CartExpiryGuard, { target: guardHost, props: { api, cartManager } });
44
- }
45
- whenDomReady(mountExpiryGuard);
46
-
47
- import './bw-configurator.svelte';
48
- import './bw-cart.svelte';
49
- import './bw-checkout.svelte';
50
-
51
- // Cart-global, not tied to any one element (unlike the bw:* events forwarded per-element in autoWire below),
52
- // so this is wired once here rather than per bw-configurator/bw-cart/bw-checkout instance. Carries the same
53
- // cart TicketConfigurator/CheckoutModal already fetch for their own posting - see those files' own comments -
54
- // so a consumer can build a custom cart summary (item count, remaining time, item details) without calling
55
- // the checkout API directly.
56
- onWidgetMessage((d) => {
57
- if (d.type === 'cart:updated' && 'cart' in d) {
58
- window.dispatchEvent(new CustomEvent('bw:cart-updated', { detail: { cart: d.cart } }));
59
- }
60
- if (d.type === 'cart:expired') {
61
- window.dispatchEvent(new CustomEvent('bw:cart-expired'));
62
- }
63
- });
64
9
 
65
10
  interface BwOptions {
66
11
  shouldBottomCloseOnModal: boolean;
67
12
  autoSelectSingleTimeSlot: boolean;
13
+ autoOpenCheckout: boolean;
68
14
  wizardPages: string;
69
15
  editPages: string;
70
16
  }
@@ -72,6 +18,7 @@ interface BwOptions {
72
18
  const defaultOptions: BwOptions = {
73
19
  shouldBottomCloseOnModal: true,
74
20
  autoSelectSingleTimeSlot: false,
21
+ autoOpenCheckout: true,
75
22
  wizardPages: '',
76
23
  editPages: '',
77
24
  };
@@ -81,12 +28,88 @@ const options: BwOptions = {
81
28
  ...((window as any)['bwOptions'] ?? {}),
82
29
  };
83
30
 
84
- function forwardToWindow(e: Event) {
85
- if (e instanceof CustomEvent) {
86
- window.dispatchEvent(new CustomEvent(e.type, { detail: e.detail }));
31
+ // Bootstrap the host BEFORE element imports trigger connectedCallback. Elements read from
32
+ // window.__bwServices instead of importing shared.ts, because the IIFE bundler inlines imports per-element -
33
+ // a module-level singleton would be duplicated. window is the only truly shared scope.
34
+ const checkoutKey = document.querySelector('[checkout-key]')?.getAttribute('checkout-key') ?? '';
35
+
36
+ // Same values the host is built with, so an ES consumer sharing this page (an island alongside the elements)
37
+ // creates hosts that behave identically without restating them.
38
+ setBookingDefaults({
39
+ apiBaseUrl: defaultApiBaseUrl,
40
+ checkoutKey,
41
+ autoOpenCheckout: options.autoOpenCheckout,
42
+ shouldBottomCloseOnModal: options.shouldBottomCloseOnModal,
43
+ autoSelectSingleTimeSlot: options.autoSelectSingleTimeSlot,
44
+ });
45
+
46
+ const host = createBookingHost({
47
+ apiBaseUrl: defaultApiBaseUrl,
48
+ checkoutKey,
49
+ autoOpenCheckout: options.autoOpenCheckout,
50
+ shouldBottomCloseOnModal: options.shouldBottomCloseOnModal,
51
+ // Cart-global, not tied to any one element (unlike the bw:* events forwarded per-element in autoWire
52
+ // below). Carries the same cart TicketConfigurator/CheckoutModal already fetch for their own use, so a
53
+ // consumer can build a custom cart summary without calling the checkout API directly.
54
+ onCartUpdated: (cart) => dispatchOnWindow('bw:cart-updated', { cart }),
55
+ onCartExpired: () => dispatchOnWindow('bw:cart-expired', {}),
56
+ onCheckoutOpenChange: onCheckoutOpenChange,
57
+ });
58
+
59
+ (window as any).__bwServices = {
60
+ api: host.api,
61
+ cartManager: host.cartManager,
62
+ ready: host.ready,
63
+ host,
64
+ };
65
+
66
+ function dispatchOnWindow(name: string, detail: unknown) {
67
+ window.dispatchEvent(new CustomEvent(name, { detail }));
68
+ }
69
+
70
+ function whenDomReady(fn: () => void) {
71
+ if (document.readyState === 'loading') {
72
+ document.addEventListener('DOMContentLoaded', fn, { once: true });
73
+ } else {
74
+ fn();
87
75
  }
88
76
  }
89
77
 
78
+ import './bw-configurator.svelte';
79
+ import './bw-cart.svelte';
80
+ import './bw-checkout.svelte';
81
+
82
+ function cartElements(): HTMLElement[] {
83
+ return [...document.querySelectorAll<HTMLElement>('bw-cart')];
84
+ }
85
+
86
+ function setCartBarsHidden(hidden: boolean) {
87
+ cartElements().forEach((el) => {
88
+ if (el.getAttribute('display') !== 'bar') return;
89
+ el.parentElement?.classList.toggle('hidden', hidden);
90
+ if (!hidden) el.style.display = '';
91
+ });
92
+ }
93
+
94
+ // Called on the first add, to reveal carts a merchant left hidden until there was something in them. The
95
+ // host subscribes to the message bus before any element does, so by the time this runs it has already opened
96
+ // checkout for this same add and hidden the bottom bars - revealing everything unconditionally would put a
97
+ // bar straight back over the modal, which is the one thing shouldBottomCloseOnModal exists to prevent.
98
+ function showCarts() {
99
+ cartElements().forEach((el) => {
100
+ el.parentElement?.classList.remove('hidden');
101
+ el.style.display = '';
102
+ });
103
+ if (host.options.shouldBottomCloseOnModal) setCartBarsHidden(host.isCheckoutOpen);
104
+ }
105
+
106
+ // The host owns whether checkout is open; bw-checkout renders from the same state. All this has to do is
107
+ // tell the page about it.
108
+ function onCheckoutOpenChange(open: boolean) {
109
+ dispatchOnWindow(open ? 'bw:modal-open' : 'bw:modal-close', {});
110
+ if (host.options.shouldBottomCloseOnModal) setCartBarsHidden(open);
111
+ }
112
+
90
113
  function autoWire() {
91
114
  const configurators = document.querySelectorAll('bw-configurator');
92
115
  const carts = document.querySelectorAll('bw-cart');
@@ -125,51 +148,17 @@ function autoWire() {
125
148
  }
126
149
  });
127
150
 
128
- function openCheckout() {
129
- checkouts.forEach((el) => {
130
- el.dispatchEvent(new CustomEvent('bw:open'));
131
- });
132
- window.dispatchEvent(new CustomEvent('bw:modal-open'));
133
-
134
- if (options.shouldBottomCloseOnModal) {
135
- hideCartBars();
136
- }
137
- }
138
-
139
- function showCarts() {
140
- carts.forEach((el) => {
141
- const parent = el.parentElement;
142
- if (parent) parent.classList.remove('hidden');
143
- (el as HTMLElement).style.display = '';
144
- });
145
- }
146
-
147
- function hideCartBars() {
148
- carts.forEach((el) => {
149
- if (el.getAttribute('display') === 'bar') {
150
- const parent = el.parentElement;
151
- if (parent) parent.classList.add('hidden');
152
- }
153
- });
154
- }
155
-
156
- function showCartBars() {
157
- carts.forEach((el) => {
158
- if (el.getAttribute('display') === 'bar') {
159
- const parent = el.parentElement;
160
- if (parent) parent.classList.remove('hidden');
161
- (el as HTMLElement).style.display = '';
162
- }
163
- });
164
- }
165
-
166
- // Forward bw:* events to window and wire up default behaviour
151
+ // Nothing here re-dispatches bw:* on window any more. Every one of them is dispatched on its element with
152
+ // bubbles+composed, so it reaches window by itself; forwarding a copy as well delivered each event twice,
153
+ // and a host counting bw:order-confirmed was double-counting orders. Bubbling is also the delivery that
154
+ // keeps working for an element added to the page after this ran, which a listener bound here would miss.
155
+ //
156
+ // Opening checkout is not wired here either: the add posts cart:change with its own openCheckout, the cart
157
+ // bar's button posts modal:open, and the host acts on both - so no-auto-checkout suppresses the open
158
+ // through the same branch an ES consumer's autoOpenCheckout: false does.
167
159
  configurators.forEach((el) => {
168
- el.addEventListener('bw:cancel', forwardToWindow);
169
- el.addEventListener('bw:cart-change', (e) => {
170
- forwardToWindow(e);
160
+ el.addEventListener('bw:cart-change', () => {
171
161
  showCarts();
172
- if (!el.hasAttribute('no-auto-checkout')) openCheckout();
173
162
 
174
163
  const handler = el.getAttribute('on-cart-change');
175
164
  if (handler && typeof (window as any)[handler] === 'function') {
@@ -177,20 +166,6 @@ function autoWire() {
177
166
  }
178
167
  });
179
168
  });
180
- carts.forEach((el) => {
181
- el.addEventListener('bw:checkout', (e) => {
182
- forwardToWindow(e);
183
- openCheckout();
184
- });
185
- });
186
- checkouts.forEach((el) => {
187
- el.addEventListener('bw:close', (e) => {
188
- forwardToWindow(e);
189
- window.dispatchEvent(new CustomEvent('bw:modal-close'));
190
- if (options.shouldBottomCloseOnModal) showCartBars();
191
- });
192
- el.addEventListener('bw:order-confirmed', forwardToWindow);
193
- });
194
169
  }
195
170
 
196
171
  whenDomReady(autoWire);