@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.
- package/CHANGELOG.md +48 -0
- package/README.md +153 -41
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +2637 -1924
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +34 -16
- package/dist/booking-widget.umd.cjs +5 -3
- package/package.json +5 -2
- package/src/lib/BookingProvider.svelte +21 -0
- package/src/lib/CartBar.svelte +16 -31
- package/src/lib/CartBarView.svelte +20 -3
- package/src/lib/CartExpiryGuard.svelte +416 -410
- package/src/lib/CartOverview.svelte +16 -6
- package/src/lib/CartOverviewButton.svelte +29 -26
- package/src/lib/Checkout.svelte +111 -98
- package/src/lib/CheckoutModal.svelte +946 -805
- package/src/lib/CheckoutPanel.svelte +121 -0
- package/src/lib/PaymentPage.svelte +16 -2
- package/src/lib/PickupPointPicker.svelte +1 -1
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +19 -5
- package/src/lib/UnitCounter.svelte +16 -2
- package/src/lib/WizardPage.svelte +102 -35
- package/src/lib/app.css +0 -6
- package/src/lib/booking-context.ts +33 -0
- package/src/lib/cart-overview.svelte.ts +97 -0
- package/src/lib/client-types.ts +10 -6
- package/src/lib/config.ts +9 -0
- package/src/lib/elements/bw-cart.svelte +21 -7
- package/src/lib/elements/bw-checkout.svelte +54 -54
- package/src/lib/elements/bw-configurator.svelte +30 -14
- package/src/lib/elements/register.ts +96 -121
- package/src/lib/elements/shared.ts +4 -0
- package/src/lib/elements/theme.css +0 -6
- package/src/lib/host.svelte.ts +336 -0
- package/src/lib/index.ts +137 -91
- package/src/lib/layout.svelte.ts +52 -0
- package/src/lib/messages.ts +97 -17
- package/src/lib/peach-sdk.ts +51 -5
- package/src/lib/portal.ts +23 -0
- package/src/lib/CartExpiryGuard.test.ts +0 -331
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
- package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
- package/src/lib/test/fixtures.ts +0 -107
- 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
|
+
}
|
package/src/lib/client-types.ts
CHANGED
|
@@ -23,17 +23,21 @@ export type CheckoutCartConfirmItemResultDto = S['CheckoutCartConfirmItemResultD
|
|
|
23
23
|
export type OctoContact = S['OctoContact'];
|
|
24
24
|
|
|
25
25
|
// Client-side types
|
|
26
|
-
|
|
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'
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
{
|
|
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
|
|
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
|
|
31
|
-
|
|
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
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
50
|
-
currency: d.currency
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
|
|
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>
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
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);
|