@code-collective/booking-widget 1.0.9 → 1.0.12

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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +500 -388
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +2415 -1745
  5. package/dist/booking-widget.min.css +1 -1
  6. package/dist/booking-widget.min.js +39 -23
  7. package/dist/booking-widget.umd.cjs +5 -3
  8. package/package.json +58 -55
  9. package/src/lib/BookingProvider.svelte +21 -0
  10. package/src/lib/CartBar.svelte +26 -41
  11. package/src/lib/CartBarView.svelte +78 -61
  12. package/src/lib/CartExpiryGuard.svelte +15 -9
  13. package/src/lib/CartOverview.svelte +30 -20
  14. package/src/lib/CartOverviewButton.svelte +104 -101
  15. package/src/lib/Checkout.svelte +141 -128
  16. package/src/lib/CheckoutModal.svelte +838 -805
  17. package/src/lib/CheckoutPanel.svelte +121 -0
  18. package/src/lib/PaymentPage.svelte +191 -177
  19. package/src/lib/PickupPointPicker.svelte +1 -1
  20. package/src/lib/TicketConfigurator.svelte +166 -152
  21. package/src/lib/UnitCounter.svelte +16 -2
  22. package/src/lib/WizardPage.svelte +102 -35
  23. package/src/lib/app.css +0 -6
  24. package/src/lib/booking-context.ts +33 -0
  25. package/src/lib/cart-overview.svelte.ts +97 -0
  26. package/src/lib/config.ts +162 -153
  27. package/src/lib/elements/bw-cart.svelte +49 -35
  28. package/src/lib/elements/bw-checkout.svelte +97 -97
  29. package/src/lib/elements/bw-configurator.svelte +72 -56
  30. package/src/lib/elements/register.ts +171 -196
  31. package/src/lib/elements/shared.ts +18 -14
  32. package/src/lib/elements/theme.css +0 -6
  33. package/src/lib/host.svelte.ts +336 -0
  34. package/src/lib/index.ts +242 -196
  35. package/src/lib/layout.svelte.ts +52 -0
  36. package/src/lib/messages.ts +157 -77
  37. package/src/lib/peach-sdk.ts +86 -40
  38. package/src/lib/portal.ts +23 -0
  39. package/src/lib/CartExpiryGuard.test.ts +0 -331
  40. package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
  41. package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
  42. package/src/lib/test/fixtures.ts +0 -107
  43. package/src/lib/test/messages-mock.ts +0 -34
@@ -1,97 +1,97 @@
1
- <svelte:options customElement={{ tag: 'bw-checkout', shadow: 'none' }} />
2
-
3
- <script lang="ts">
4
- import Checkout from '../Checkout.svelte';
5
- import { resolveWizardPages, resolveEditPages } from '../config';
6
- import { onWidgetMessage } from '../messages';
7
- import { getSharedServices } from './shared';
8
-
9
- let {
10
- 'wizard-pages': wizardPagesJson = '',
11
- 'edit-pages': editPagesJson = '',
12
- 'is-modal': isModalAttr = undefined,
13
- 'auto-select-single-time-slot': autoSelectSingleTimeSlotAttr = undefined,
14
- } = $props();
15
-
16
- let autoSelectSingleTimeSlot = $derived(autoSelectSingleTimeSlotAttr !== undefined);
17
-
18
- let isModal = $derived(isModalAttr !== undefined);
19
- let showModal = $state(false);
20
-
21
- const { api, cartManager, ready: readyPromise } = getSharedServices();
22
-
23
- let ready = $state(false);
24
- readyPromise.then(() => { ready = true; });
25
-
26
- let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
27
- let editPages = $derived(resolveEditPages(wizardPagesJson, editPagesJson));
28
-
29
- 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 }));
32
- }
33
-
34
- // Public API: open/close the modal from outside
35
- export function open() { showModal = true; }
36
- export function close() { showModal = false; dispatch('bw:close', {}); }
37
-
38
- $effect(() => onWidgetMessage((d) => {
39
- if (d.type === 'modal:close') {
40
- if (isModal) showModal = false;
41
- dispatch('bw:close', {});
42
- }
43
- if (d.type === 'order:complete') {
44
- // Deliberately does not close the modal - order:complete only reports that the order is confirmed
45
- // (for host analytics/cart-clearing), not that the shopper has finished looking at the result screen.
46
- // The modal stays open until they dismiss it themselves, which sends modal:close separately.
47
- dispatch('bw:order-confirmed', {
48
- cartToken: d.cartToken ?? '',
49
- value: d.value ?? 0,
50
- currency: d.currency ?? 'ZAR',
51
- });
52
- }
53
- }));
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
- </script>
64
-
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>
73
- {/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>
1
+ <svelte:options customElement={{ tag: 'bw-checkout', shadow: 'none' }} />
2
+
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.
7
+ import Checkout from '../Checkout.svelte';
8
+ import { resolveWizardPages, resolveEditPages } from '../config';
9
+ import { onWidgetMessage } from '../messages';
10
+ import { getSharedServices } from './shared';
11
+
12
+ let {
13
+ 'wizard-pages': wizardPagesJson = '',
14
+ 'edit-pages': editPagesJson = '',
15
+ 'is-modal': isModalAttr = undefined,
16
+ 'auto-select-single-time-slot': autoSelectSingleTimeSlotAttr = undefined,
17
+ } = $props();
18
+
19
+ let autoSelectSingleTimeSlot = $derived(autoSelectSingleTimeSlotAttr !== undefined);
20
+
21
+ let mode = $derived(isModalAttr !== undefined ? 'modal' as const : 'inline' as const);
22
+
23
+ const { api, cartManager, ready: readyPromise, host } = getSharedServices();
24
+
25
+ let ready = $state(false);
26
+ readyPromise.then(() => { ready = true; });
27
+
28
+ let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
29
+ let editPages = $derived(resolveEditPages(wizardPagesJson, editPagesJson));
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
+
42
+ function dispatch(name: string, detail: unknown) {
43
+ const target = anchor?.closest('bw-checkout') ?? document.body;
44
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
45
+ }
46
+
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
+ });
66
+
67
+ $effect(() => onWidgetMessage((d) => {
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;
73
+ dispatch('bw:close', {});
74
+ }
75
+ if (d.type === 'order:complete') {
76
+ // Deliberately does not close the modal - order:complete only reports that the order is confirmed
77
+ // (for host analytics/cart-clearing), not that the shopper has finished looking at the result screen.
78
+ // The modal stays open until they dismiss it themselves, which sends modal:close separately.
79
+ dispatch('bw:order-confirmed', {
80
+ cartToken: d.cartToken,
81
+ value: d.value,
82
+ currency: d.currency,
83
+ });
84
+ }
85
+ }));
86
+ </script>
87
+
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>
96
+ {/if}
97
+ </div>
@@ -1,56 +1,72 @@
1
- <svelte:options customElement={{ tag: 'bw-configurator', shadow: 'none' }} />
2
-
3
- <script lang="ts">
4
- import TicketConfigurator from '../TicketConfigurator.svelte';
5
- import { resolveWizardPages } from '../config';
6
- import { onWidgetMessage, postMessage } from '../messages';
7
- import { getSharedServices } from './shared';
8
-
9
- let {
10
- 'product-id': productId = '',
11
- 'wizard-pages': wizardPagesJson = '',
12
- 'auto-select-single-time-slot': autoSelectSingleTimeSlot = undefined,
13
- cancelable = undefined,
14
- } = $props();
15
-
16
- const { api, cartManager, ready: readyPromise } = getSharedServices();
17
-
18
- let ready = $state(false);
19
- readyPromise.then(() => { ready = true; });
20
-
21
- let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
22
-
23
- 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 }));
26
- }
27
-
28
- // Forward postMessage events as CustomEvents
29
- $effect(() => onWidgetMessage((d) => {
30
- if (d.type === 'cart:change') {
31
- dispatch('bw:cart-change', {
32
- itemCount: d.itemCount ?? 0,
33
- cartItemId: d.cartItemId ?? '',
34
- totalFormatted: d.totalFormatted ?? '',
35
- });
36
- }
37
- }));
38
-
39
- const onCancel = cancelable !== undefined
40
- ? () => { postMessage({ type: 'modal:close' }); dispatch('bw:cancel', {}); }
41
- : undefined;
42
- </script>
43
-
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
-
1
+ <svelte:options customElement={{ tag: 'bw-configurator', shadow: 'none' }} />
2
+
3
+ <script lang="ts">
4
+ import TicketConfigurator from '../TicketConfigurator.svelte';
5
+ import { resolveWizardPages } from '../config';
6
+ import { onWidgetMessage, postMessage } from '../messages';
7
+ import { getSharedServices } from './shared';
8
+
9
+ let {
10
+ 'product-id': productId = '',
11
+ 'wizard-pages': wizardPagesJson = '',
12
+ 'auto-select-single-time-slot': autoSelectSingleTimeSlot = undefined,
13
+ 'no-auto-checkout': noAutoCheckout = undefined,
14
+ cancelable = undefined,
15
+ } = $props();
16
+
17
+ const { api, cartManager, ready: readyPromise } = getSharedServices();
18
+
19
+ let ready = $state(false);
20
+ readyPromise.then(() => { ready = true; });
21
+
22
+ let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
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
+
35
+ function dispatch(name: string, detail: unknown) {
36
+ const target = anchor?.closest('bw-configurator') ?? document.body;
37
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
38
+ }
39
+
40
+ // Forward postMessage events as CustomEvents
41
+ $effect(() => onWidgetMessage((d) => {
42
+ if (d.type === 'cart:change') {
43
+ dispatch('bw:cart-change', {
44
+ itemCount: d.itemCount ?? 0,
45
+ cartItemId: d.cartItemId ?? '',
46
+ totalFormatted: d.totalFormatted ?? '',
47
+ });
48
+ }
49
+ }));
50
+
51
+ const onCancel = cancelable !== undefined
52
+ ? () => { postMessage({ type: 'modal:close' }); dispatch('bw:cancel', {}); }
53
+ : undefined;
54
+ </script>
55
+
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>
72
+