@code-collective/booking-widget 1.0.12 → 1.0.14

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 (38) hide show
  1. package/CHANGELOG.md +48 -46
  2. package/README.md +500 -500
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +829 -707
  5. package/dist/booking-widget.min.js +24 -25
  6. package/dist/booking-widget.umd.cjs +5 -5
  7. package/package.json +58 -58
  8. package/src/lib/BookingProvider.svelte +21 -21
  9. package/src/lib/CartBar.svelte +26 -26
  10. package/src/lib/CartBarView.svelte +78 -78
  11. package/src/lib/CartExpiryGuard.svelte +416 -416
  12. package/src/lib/CartOverview.svelte +30 -30
  13. package/src/lib/CartOverviewButton.svelte +104 -104
  14. package/src/lib/Checkout.svelte +141 -141
  15. package/src/lib/CheckoutModal.css +179 -0
  16. package/src/lib/CheckoutModal.svelte +78 -573
  17. package/src/lib/CheckoutPanel.svelte +121 -121
  18. package/src/lib/PaymentPage.svelte +208 -191
  19. package/src/lib/ResultView.svelte +24 -4
  20. package/src/lib/TicketConfigurator.svelte +166 -166
  21. package/src/lib/api.ts +36 -5
  22. package/src/lib/booking-context.ts +33 -33
  23. package/src/lib/cart-overview.svelte.ts +97 -97
  24. package/src/lib/checkout-item-view.ts +63 -0
  25. package/src/lib/checkout-payment-flow.svelte.ts +523 -0
  26. package/src/lib/client-types.ts +22 -6
  27. package/src/lib/config.ts +162 -162
  28. package/src/lib/elements/bw-cart.svelte +49 -49
  29. package/src/lib/elements/bw-checkout.svelte +97 -97
  30. package/src/lib/elements/bw-configurator.svelte +72 -72
  31. package/src/lib/elements/register.ts +171 -171
  32. package/src/lib/elements/shared.ts +18 -18
  33. package/src/lib/generated-types.ts +94 -5
  34. package/src/lib/host.svelte.ts +336 -336
  35. package/src/lib/index.ts +242 -242
  36. package/src/lib/messages.ts +157 -157
  37. package/src/lib/peach-sdk.ts +86 -86
  38. package/src/lib/portal.ts +23 -23
@@ -1,30 +1,30 @@
1
- <script lang="ts">
2
- import type { BookingApi } from './api';
3
- import type { CartOverviewDisplay, CartOverviewState } from './config';
4
- import { CartManager } from './cart-manager';
5
- import CartBar from './CartBar.svelte';
6
- import CartOverviewButton from './CartOverviewButton.svelte';
7
- import { getBookingHostContext, requireBookingService } from './booking-context';
8
-
9
- interface Props {
10
- api?: BookingApi;
11
- cartManager?: CartManager;
12
- display: CartOverviewDisplay;
13
- onStateChange?: (state: CartOverviewState) => void;
14
- }
15
- let { api, cartManager, display, onStateChange }: Props = $props();
16
-
17
- // Resolved here as well as in the children so a missing host names CartOverview, which is what the
18
- // consumer actually wrote, rather than CartBar.
19
- const bookingHost = getBookingHostContext();
20
- let resolvedApi = $derived(requireBookingService(api ?? bookingHost?.api, 'CartOverview', 'api'));
21
- let resolvedCartManager = $derived(
22
- requireBookingService(cartManager ?? bookingHost?.cartManager, 'CartOverview', 'cartManager'),
23
- );
24
- </script>
25
-
26
- {#if display === 'button'}
27
- <CartOverviewButton api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
28
- {:else}
29
- <CartBar api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
30
- {/if}
1
+ <script lang="ts">
2
+ import type { BookingApi } from './api';
3
+ import type { CartOverviewDisplay, CartOverviewState } from './config';
4
+ import { CartManager } from './cart-manager';
5
+ import CartBar from './CartBar.svelte';
6
+ import CartOverviewButton from './CartOverviewButton.svelte';
7
+ import { getBookingHostContext, requireBookingService } from './booking-context';
8
+
9
+ interface Props {
10
+ api?: BookingApi;
11
+ cartManager?: CartManager;
12
+ display: CartOverviewDisplay;
13
+ onStateChange?: (state: CartOverviewState) => void;
14
+ }
15
+ let { api, cartManager, display, onStateChange }: Props = $props();
16
+
17
+ // Resolved here as well as in the children so a missing host names CartOverview, which is what the
18
+ // consumer actually wrote, rather than CartBar.
19
+ const bookingHost = getBookingHostContext();
20
+ let resolvedApi = $derived(requireBookingService(api ?? bookingHost?.api, 'CartOverview', 'api'));
21
+ let resolvedCartManager = $derived(
22
+ requireBookingService(cartManager ?? bookingHost?.cartManager, 'CartOverview', 'cartManager'),
23
+ );
24
+ </script>
25
+
26
+ {#if display === 'button'}
27
+ <CartOverviewButton api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
28
+ {:else}
29
+ <CartBar api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
30
+ {/if}
@@ -1,104 +1,104 @@
1
- <script lang="ts">
2
- import type { BookingApi } from './api';
3
- import type { CartOverviewState } from './config';
4
- import { CartManager } from './cart-manager';
5
- import { postMessage } from './messages';
6
- import { formatRemaining, cartDeadline } from './cart-expiry';
7
- import { createCartOverview } from './cart-overview.svelte';
8
- import { onDestroy } from 'svelte';
9
- import { getBookingHostContext, requireBookingService } from './booking-context';
10
-
11
- interface Props {
12
- api?: BookingApi;
13
- cartManager?: CartManager;
14
- // Lets a host render its own summary against the same state the button is in, rather than inferring it
15
- // from an empty DOM - see cart-overview.svelte.ts.
16
- onStateChange?: (state: CartOverviewState) => void;
17
- }
18
- let { api, cartManager, onStateChange }: Props = $props();
19
-
20
- const bookingHost = getBookingHostContext();
21
- const overview = createCartOverview(
22
- () => requireBookingService(api ?? bookingHost?.api, 'CartOverviewButton', 'api'),
23
- () => requireBookingService(cartManager ?? bookingHost?.cartManager, 'CartOverviewButton', 'cartManager'),
24
- (s) => onStateChange?.(s),
25
- );
26
- let cart = $derived(overview.cart);
27
-
28
- let remaining = $state('');
29
-
30
- function updateTimer() {
31
- remaining = cart ? formatRemaining(cartDeadline(cart).getTime() - Date.now()) : formatRemaining(0);
32
- }
33
-
34
- updateTimer();
35
- const interval = setInterval(updateTimer, 1000);
36
- onDestroy(() => clearInterval(interval));
37
-
38
- let itemCount = $derived(cart?.items.length ?? 0);
39
- </script>
40
-
41
- {#if overview.state === 'error'}
42
- <!-- An empty cart and a cart that could not be read used to render identically (as nothing), leaving the
43
- shopper with no route to checkout and no sign that anything had gone wrong. -->
44
- <button class="cart-overview-btn is-error bw-widget" onclick={() => overview.reload()}>
45
- <span class="cart-error-label">Cart unavailable - retry</span>
46
- </button>
47
- {:else if cart && cart.items.length > 0}
48
- <button class="cart-overview-btn bw-widget" onclick={() => postMessage({ type: 'modal:open' })}>
49
- <svg class="cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
50
- <circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
51
- <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
52
- </svg>
53
- <span class="cart-badge">{itemCount}</span>
54
- <span class="cart-timer">{remaining}</span>
55
- </button>
56
- {/if}
57
-
58
- <style>
59
- .cart-overview-btn {
60
- display: flex;
61
- align-items: center;
62
- justify-content: center;
63
- gap: 14px;
64
- width: 100%;
65
- padding: 14px 24px;
66
- background: var(--bw-color-primary);
67
- color: white;
68
- border: none;
69
- border-radius: 999px;
70
- cursor: pointer;
71
- transition: background 0.15s;
72
- }
73
- .cart-overview-btn:hover {
74
- background: var(--bw-color-primary-dark);
75
- }
76
- .cart-icon {
77
- width: 22px;
78
- height: 22px;
79
- flex-shrink: 0;
80
- }
81
- .cart-badge {
82
- display: flex;
83
- align-items: center;
84
- justify-content: center;
85
- width: 26px;
86
- height: 26px;
87
- background: white;
88
- color: var(--bw-color-primary);
89
- border-radius: 50%;
90
- font-size: 14px;
91
- font-weight: 800;
92
- flex-shrink: 0;
93
- }
94
- .cart-timer {
95
- font-variant-numeric: tabular-nums;
96
- font-size: 20px;
97
- font-weight: 700;
98
- flex-shrink: 0;
99
- }
100
- .cart-error-label {
101
- font-size: 15px;
102
- font-weight: 600;
103
- }
104
- </style>
1
+ <script lang="ts">
2
+ import type { BookingApi } from './api';
3
+ import type { CartOverviewState } from './config';
4
+ import { CartManager } from './cart-manager';
5
+ import { postMessage } from './messages';
6
+ import { formatRemaining, cartDeadline } from './cart-expiry';
7
+ import { createCartOverview } from './cart-overview.svelte';
8
+ import { onDestroy } from 'svelte';
9
+ import { getBookingHostContext, requireBookingService } from './booking-context';
10
+
11
+ interface Props {
12
+ api?: BookingApi;
13
+ cartManager?: CartManager;
14
+ // Lets a host render its own summary against the same state the button is in, rather than inferring it
15
+ // from an empty DOM - see cart-overview.svelte.ts.
16
+ onStateChange?: (state: CartOverviewState) => void;
17
+ }
18
+ let { api, cartManager, onStateChange }: Props = $props();
19
+
20
+ const bookingHost = getBookingHostContext();
21
+ const overview = createCartOverview(
22
+ () => requireBookingService(api ?? bookingHost?.api, 'CartOverviewButton', 'api'),
23
+ () => requireBookingService(cartManager ?? bookingHost?.cartManager, 'CartOverviewButton', 'cartManager'),
24
+ (s) => onStateChange?.(s),
25
+ );
26
+ let cart = $derived(overview.cart);
27
+
28
+ let remaining = $state('');
29
+
30
+ function updateTimer() {
31
+ remaining = cart ? formatRemaining(cartDeadline(cart).getTime() - Date.now()) : formatRemaining(0);
32
+ }
33
+
34
+ updateTimer();
35
+ const interval = setInterval(updateTimer, 1000);
36
+ onDestroy(() => clearInterval(interval));
37
+
38
+ let itemCount = $derived(cart?.items.length ?? 0);
39
+ </script>
40
+
41
+ {#if overview.state === 'error'}
42
+ <!-- An empty cart and a cart that could not be read used to render identically (as nothing), leaving the
43
+ shopper with no route to checkout and no sign that anything had gone wrong. -->
44
+ <button class="cart-overview-btn is-error bw-widget" onclick={() => overview.reload()}>
45
+ <span class="cart-error-label">Cart unavailable - retry</span>
46
+ </button>
47
+ {:else if cart && cart.items.length > 0}
48
+ <button class="cart-overview-btn bw-widget" onclick={() => postMessage({ type: 'modal:open' })}>
49
+ <svg class="cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
50
+ <circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
51
+ <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
52
+ </svg>
53
+ <span class="cart-badge">{itemCount}</span>
54
+ <span class="cart-timer">{remaining}</span>
55
+ </button>
56
+ {/if}
57
+
58
+ <style>
59
+ .cart-overview-btn {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ gap: 14px;
64
+ width: 100%;
65
+ padding: 14px 24px;
66
+ background: var(--bw-color-primary);
67
+ color: white;
68
+ border: none;
69
+ border-radius: 999px;
70
+ cursor: pointer;
71
+ transition: background 0.15s;
72
+ }
73
+ .cart-overview-btn:hover {
74
+ background: var(--bw-color-primary-dark);
75
+ }
76
+ .cart-icon {
77
+ width: 22px;
78
+ height: 22px;
79
+ flex-shrink: 0;
80
+ }
81
+ .cart-badge {
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ width: 26px;
86
+ height: 26px;
87
+ background: white;
88
+ color: var(--bw-color-primary);
89
+ border-radius: 50%;
90
+ font-size: 14px;
91
+ font-weight: 800;
92
+ flex-shrink: 0;
93
+ }
94
+ .cart-timer {
95
+ font-variant-numeric: tabular-nums;
96
+ font-size: 20px;
97
+ font-weight: 700;
98
+ flex-shrink: 0;
99
+ }
100
+ .cart-error-label {
101
+ font-size: 15px;
102
+ font-weight: 600;
103
+ }
104
+ </style>
@@ -1,141 +1,141 @@
1
- <script lang="ts">
2
- // The presentation shell around CheckoutPanel. Modal chrome lives here rather than in
3
- // elements/bw-checkout.svelte so both builds render the same modal from one implementation - an ES consumer
4
- // composing the components itself used to get no modal at all, and had to rebuild the overlay, the scrim,
5
- // the click-outside close and the portal by hand.
6
- import type { BookingApi } from './api';
7
- import type { WizardPages } from './config';
8
- import { DEFAULT_WIZARD_PAGES } from './config';
9
- import type { CartManager } from './cart-manager';
10
- import { onWidgetMessage, postMessage } from './messages';
11
- import { portal } from './portal';
12
- import CheckoutPanel from './CheckoutPanel.svelte';
13
- import { getBookingHostContext, requireBookingService } from './booking-context';
14
- import type { CheckoutMode } from './config';
15
-
16
- interface Props {
17
- // Optional when a BookingProvider is above this component - it supplies both from its host.
18
- api?: BookingApi;
19
- cartManager?: CartManager;
20
- wizardPages?: WizardPages;
21
- editPages?: WizardPages;
22
- autoSelectSingleTimeSlot?: boolean;
23
- // Inline by default: a consumer already rendering <Checkout> in a panel of its own keeps exactly the
24
- // layout it has. createBookingHost asks for 'modal', so anyone wiring up through the host gets the modal
25
- // without opting in.
26
- mode?: CheckoutMode;
27
- // Modal only. A host that owns the open state (see createBookingHost) drives this from outside; a
28
- // consumer that renders <Checkout mode="modal" /> and nothing else gets a modal that is open and closes
29
- // itself. Note that reopening it after a dismissal means setting open - a modal with no open state has
30
- // no way back, by construction, since nothing owns the answer to "should it be showing now".
31
- open?: boolean;
32
- onClose?: () => void;
33
- }
34
-
35
- let {
36
- api: apiProp,
37
- cartManager: cartManagerProp,
38
- wizardPages = DEFAULT_WIZARD_PAGES,
39
- editPages,
40
- autoSelectSingleTimeSlot = false,
41
- mode = 'inline',
42
- open: openProp,
43
- onClose: onCloseProp,
44
- }: Props = $props();
45
-
46
- const bookingHost = getBookingHostContext();
47
- let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'Checkout', 'api'));
48
- let cartManager = $derived(cartManagerProp ?? bookingHost?.cartManager);
49
-
50
- // With a provider the host owns whether checkout is open, so the modal opens on an add-to-cart and closes
51
- // on a dismissal with nothing wired up by hand - which is the whole point of the provider. An explicit
52
- // open prop still wins, and with neither the modal is simply open, so <Checkout mode="modal" /> on its own
53
- // shows something rather than nothing.
54
- let open = $derived(openProp ?? bookingHost?.isCheckoutOpen ?? true);
55
- let onClose = $derived(onCloseProp ?? (bookingHost ? () => bookingHost.closeCheckout() : undefined));
56
-
57
- let isModal = $derived(mode === 'modal');
58
- // Dismissing is answered here as well as reported outwards, so a consumer that passes no open state at all
59
- // still gets a modal that closes. A host that does own the state reopens by setting open, which clears
60
- // this - see the effect below.
61
- let dismissed = $state(false);
62
- let isOpen = $derived(!isModal || (open && !dismissed));
63
-
64
- // Whether this dismissal has already been reported. Deliberately a plain variable rather than $state: the
65
- // panel posts its own modal:close as it is torn down, which arrives while Svelte is still flushing the
66
- // teardown, and a $state write made there is not yet readable by the time that second message is handled -
67
- // so guarding on `dismissed` let onClose fire twice for one dismissal.
68
- let reported = false;
69
-
70
- // Cleared on a shut -> open transition only, never merely because open is true. An uncontrolled consumer
71
- // leaves open at its default forever, so resetting on the level would undo the dismissal the moment this
72
- // effect ran again. wasOpen is the effect's memory, not one of its dependencies.
73
- let wasOpen = false;
74
- $effect(() => {
75
- const nowOpen = open;
76
- if (nowOpen && !wasOpen) {
77
- dismissed = false;
78
- reported = false;
79
- }
80
- wasOpen = nowOpen;
81
- });
82
-
83
- // The scrim and Escape only announce the dismissal; the listener below is what acts on it. The panel has
84
- // its own ways out - CheckoutModal's close button, and the expired view's "start again" - and each posts
85
- // this same message, so routing every dismissal through one place is what keeps onClose from firing for
86
- // some of them and not others.
87
- function close() {
88
- postMessage({ type: 'modal:close' });
89
- }
90
-
91
- $effect(() => onWidgetMessage((d) => {
92
- if (d.type !== 'modal:close' || !isModal || reported) return;
93
- // Only while this modal is actually showing. modal:close is page-wide and several things post it
94
- // without any modal being up - the expiry guard's "start again", a cancelable configurator - and
95
- // treating those as a dismissal fired onClose for something the shopper never did.
96
- if (!isOpen) return;
97
- reported = true;
98
- dismissed = true;
99
- onClose?.();
100
- }));
101
-
102
- function onKeydown(e: KeyboardEvent) {
103
- if (isOpen && isModal && e.key === 'Escape') close();
104
- }
105
- </script>
106
-
107
- <svelte:window onkeydown={onKeydown} />
108
-
109
- {#if isModal}
110
- {#if isOpen}
111
- <!-- Portalled to <body>: a fixed overlay cannot escape a transformed, filtered or sticky ancestor, which
112
- is exactly what a product page's sticky sidebar is - see portal.ts. -->
113
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
114
- <div class="bw-modal-overlay" use:portal onclick={close}>
115
- <div class="bw-modal-inner" onclick={(e) => e.stopPropagation()}>
116
- <CheckoutPanel {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
117
- </div>
118
- </div>
119
- {/if}
120
- {:else}
121
- <CheckoutPanel {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
122
- {/if}
123
-
124
- <style>
125
- .bw-modal-overlay {
126
- position: fixed;
127
- inset: 0;
128
- z-index: 10000;
129
- background: rgba(0, 0, 0, 0.5);
130
- }
131
- .bw-modal-inner {
132
- width: 100%;
133
- max-width: 900px;
134
- height: 95vh;
135
- margin: 2.5vh auto;
136
- border-radius: 12px;
137
- background: #fff;
138
- overflow: hidden;
139
- box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
140
- }
141
- </style>
1
+ <script lang="ts">
2
+ // The presentation shell around CheckoutPanel. Modal chrome lives here rather than in
3
+ // elements/bw-checkout.svelte so both builds render the same modal from one implementation - an ES consumer
4
+ // composing the components itself used to get no modal at all, and had to rebuild the overlay, the scrim,
5
+ // the click-outside close and the portal by hand.
6
+ import type { BookingApi } from './api';
7
+ import type { WizardPages } from './config';
8
+ import { DEFAULT_WIZARD_PAGES } from './config';
9
+ import type { CartManager } from './cart-manager';
10
+ import { onWidgetMessage, postMessage } from './messages';
11
+ import { portal } from './portal';
12
+ import CheckoutPanel from './CheckoutPanel.svelte';
13
+ import { getBookingHostContext, requireBookingService } from './booking-context';
14
+ import type { CheckoutMode } from './config';
15
+
16
+ interface Props {
17
+ // Optional when a BookingProvider is above this component - it supplies both from its host.
18
+ api?: BookingApi;
19
+ cartManager?: CartManager;
20
+ wizardPages?: WizardPages;
21
+ editPages?: WizardPages;
22
+ autoSelectSingleTimeSlot?: boolean;
23
+ // Inline by default: a consumer already rendering <Checkout> in a panel of its own keeps exactly the
24
+ // layout it has. createBookingHost asks for 'modal', so anyone wiring up through the host gets the modal
25
+ // without opting in.
26
+ mode?: CheckoutMode;
27
+ // Modal only. A host that owns the open state (see createBookingHost) drives this from outside; a
28
+ // consumer that renders <Checkout mode="modal" /> and nothing else gets a modal that is open and closes
29
+ // itself. Note that reopening it after a dismissal means setting open - a modal with no open state has
30
+ // no way back, by construction, since nothing owns the answer to "should it be showing now".
31
+ open?: boolean;
32
+ onClose?: () => void;
33
+ }
34
+
35
+ let {
36
+ api: apiProp,
37
+ cartManager: cartManagerProp,
38
+ wizardPages = DEFAULT_WIZARD_PAGES,
39
+ editPages,
40
+ autoSelectSingleTimeSlot = false,
41
+ mode = 'inline',
42
+ open: openProp,
43
+ onClose: onCloseProp,
44
+ }: Props = $props();
45
+
46
+ const bookingHost = getBookingHostContext();
47
+ let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'Checkout', 'api'));
48
+ let cartManager = $derived(cartManagerProp ?? bookingHost?.cartManager);
49
+
50
+ // With a provider the host owns whether checkout is open, so the modal opens on an add-to-cart and closes
51
+ // on a dismissal with nothing wired up by hand - which is the whole point of the provider. An explicit
52
+ // open prop still wins, and with neither the modal is simply open, so <Checkout mode="modal" /> on its own
53
+ // shows something rather than nothing.
54
+ let open = $derived(openProp ?? bookingHost?.isCheckoutOpen ?? true);
55
+ let onClose = $derived(onCloseProp ?? (bookingHost ? () => bookingHost.closeCheckout() : undefined));
56
+
57
+ let isModal = $derived(mode === 'modal');
58
+ // Dismissing is answered here as well as reported outwards, so a consumer that passes no open state at all
59
+ // still gets a modal that closes. A host that does own the state reopens by setting open, which clears
60
+ // this - see the effect below.
61
+ let dismissed = $state(false);
62
+ let isOpen = $derived(!isModal || (open && !dismissed));
63
+
64
+ // Whether this dismissal has already been reported. Deliberately a plain variable rather than $state: the
65
+ // panel posts its own modal:close as it is torn down, which arrives while Svelte is still flushing the
66
+ // teardown, and a $state write made there is not yet readable by the time that second message is handled -
67
+ // so guarding on `dismissed` let onClose fire twice for one dismissal.
68
+ let reported = false;
69
+
70
+ // Cleared on a shut -> open transition only, never merely because open is true. An uncontrolled consumer
71
+ // leaves open at its default forever, so resetting on the level would undo the dismissal the moment this
72
+ // effect ran again. wasOpen is the effect's memory, not one of its dependencies.
73
+ let wasOpen = false;
74
+ $effect(() => {
75
+ const nowOpen = open;
76
+ if (nowOpen && !wasOpen) {
77
+ dismissed = false;
78
+ reported = false;
79
+ }
80
+ wasOpen = nowOpen;
81
+ });
82
+
83
+ // The scrim and Escape only announce the dismissal; the listener below is what acts on it. The panel has
84
+ // its own ways out - CheckoutModal's close button, and the expired view's "start again" - and each posts
85
+ // this same message, so routing every dismissal through one place is what keeps onClose from firing for
86
+ // some of them and not others.
87
+ function close() {
88
+ postMessage({ type: 'modal:close' });
89
+ }
90
+
91
+ $effect(() => onWidgetMessage((d) => {
92
+ if (d.type !== 'modal:close' || !isModal || reported) return;
93
+ // Only while this modal is actually showing. modal:close is page-wide and several things post it
94
+ // without any modal being up - the expiry guard's "start again", a cancelable configurator - and
95
+ // treating those as a dismissal fired onClose for something the shopper never did.
96
+ if (!isOpen) return;
97
+ reported = true;
98
+ dismissed = true;
99
+ onClose?.();
100
+ }));
101
+
102
+ function onKeydown(e: KeyboardEvent) {
103
+ if (isOpen && isModal && e.key === 'Escape') close();
104
+ }
105
+ </script>
106
+
107
+ <svelte:window onkeydown={onKeydown} />
108
+
109
+ {#if isModal}
110
+ {#if isOpen}
111
+ <!-- Portalled to <body>: a fixed overlay cannot escape a transformed, filtered or sticky ancestor, which
112
+ is exactly what a product page's sticky sidebar is - see portal.ts. -->
113
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
114
+ <div class="bw-modal-overlay" use:portal onclick={close}>
115
+ <div class="bw-modal-inner" onclick={(e) => e.stopPropagation()}>
116
+ <CheckoutPanel {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
117
+ </div>
118
+ </div>
119
+ {/if}
120
+ {:else}
121
+ <CheckoutPanel {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
122
+ {/if}
123
+
124
+ <style>
125
+ .bw-modal-overlay {
126
+ position: fixed;
127
+ inset: 0;
128
+ z-index: 10000;
129
+ background: rgba(0, 0, 0, 0.5);
130
+ }
131
+ .bw-modal-inner {
132
+ width: 100%;
133
+ max-width: 900px;
134
+ height: 95vh;
135
+ margin: 2.5vh auto;
136
+ border-radius: 12px;
137
+ background: #fff;
138
+ overflow: hidden;
139
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
140
+ }
141
+ </style>