@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
package/src/lib/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import './app.css';
2
2
  import { mount, unmount } from 'svelte';
3
- import type { WizardPages, CartOverviewDisplay } from './config';
4
- import type { BookingApi } from './api';
5
- import type { CheckoutCartDetailDto } from './client-types';
3
+ import type { CartOverviewDisplay, CartOverviewState } from './config';
4
+ import type { BookingHost, BookingHostConfig } from './host.svelte';
5
+ import { createBookingHost } from './host.svelte';
6
6
  import { ApiClient, ApiError } from './api';
7
7
  import { SessionManager } from './session-manager';
8
8
  import { CartManager } from './cart-manager';
@@ -17,180 +17,226 @@ export { default as Checkout } from './Checkout.svelte';
17
17
  export { default as CartOverview } from './CartOverview.svelte';
18
18
  export { default as CartBar } from './CartBar.svelte';
19
19
  export { default as CartOverviewButton } from './CartOverviewButton.svelte';
20
+ // Required if you are not using the elements build: it owns the "still shopping?" warning, the expired view
21
+ // and the payment-timeout hand-over, and nothing else on the page does. createBookingHost mounts it for you;
22
+ // export it for consumers composing everything by hand.
23
+ export { default as CartExpiryGuard } from './CartExpiryGuard.svelte';
24
+ // Supplies the host to everything beneath it, so components need no api/cartManager props of their own.
25
+ export { default as BookingProvider } from './BookingProvider.svelte';
20
26
 
21
27
  // Re-export utilities
22
28
  export { ApiClient, ApiError, SessionManager, CartManager };
23
29
  export { resolveWizardPages, resolveEditPages, DEFAULT_WIZARD_PAGES } from './config';
30
+ export { createBookingHost, setBookingDefaults, getBookingDefaults, requestCheckoutOpen, requestCheckoutClose } from './host.svelte';
31
+ export { portal } from './portal';
32
+ export { setPeachSdk } from './peach-sdk';
33
+ export type { PeachEnv } from './peach-sdk';
34
+ export { getBookingHostContext } from './booking-context';
35
+ export { onWidgetMessage, postMessage } from './messages';
24
36
  export type { BookingApi } from './api';
25
- export type { WizardPages, WizardPageConfig, CartOverviewDisplay, WidgetMode, WizardWidgetType } from './config';
37
+ export type { BookingHost, BookingHostConfig, BookingDefaults, ResolvedBookingOptions } from './host.svelte';
38
+ export type {
39
+ WizardPages, WizardPageConfig, CartOverviewDisplay, CartOverviewState, CheckoutMode, WidgetMode, WizardWidgetType,
40
+ } from './config';
41
+ export type {
42
+ WidgetMessage, WidgetMessageType, CartChangeMessage, CartUpdatedMessage, CartExpiredMessage,
43
+ ModalOpenMessage, ModalCloseMessage, OrderCompleteMessage, PaymentStartedMessage, PaymentEndedMessage,
44
+ PaymentTimedOutMessage,
45
+ } from './messages';
26
46
  export type { components, operations, paths } from './generated-types';
27
47
  export type * from './client-types';
28
48
 
29
49
  // --- Mount function configs ---
30
50
 
31
- interface BaseConfig {
32
- apiBaseUrl?: string;
33
- checkoutKey?: string;
34
- // Cart-global, not tied to any one mount function - fires after any add/edit/remove, anywhere on the page,
35
- // carrying the same cart TicketConfigurator/CheckoutModal already fetch for their own internal use. Lets a
36
- // consumer build a custom cart summary (item count, remaining time, item details) without calling the
37
- // checkout API directly.
38
- onCartUpdated?: (cart: CheckoutCartDetailDto | null) => void;
39
- // Fires when the cart's own clock ran out (or, for mountCheckout, when it loads onto a cart the server has
40
- // already let go) - see messages.ts's own cart:expired doc comment for why this is narrower than an
41
- // onCartUpdated(null) call, which a manual clear can also produce.
42
- onCartExpired?: () => void;
43
- }
51
+ // Every mount function takes the same host options, so a consumer mounting more than one widget can hand all
52
+ // of them the same host and get one session, one cart and one expiry guard - see `host` below.
53
+ type BaseConfig = Omit<BookingHostConfig, 'onCheckoutOpenChange'> & {
54
+ // An existing host to mount into. Without one each mount function builds its own, which means its own
55
+ // session and its own cart - fine for a single widget on a page, wrong for two.
56
+ host?: BookingHost;
57
+ };
44
58
 
45
59
  export interface ConfiguratorConfig extends BaseConfig {
46
60
  productId: string;
47
61
  currency?: string;
48
- wizardPages?: WizardPages;
49
- autoSelectSingleTimeSlot?: boolean;
50
62
  cancelable?: boolean;
51
- onCartChange?: (detail: { itemCount: number; cartItemId: string; totalFormatted: string }) => void;
63
+ // onCartChange and the other cart callbacks come from BookingHostConfig - see createBookingHost.
52
64
  onCancel?: () => void;
53
65
  }
54
66
 
55
67
  export interface CheckoutConfig extends BaseConfig {
56
- wizardPages?: WizardPages;
57
- editPages?: WizardPages;
58
- autoSelectSingleTimeSlot?: boolean;
68
+ // Inline by default - the mount target is a container the consumer positioned itself. Pass 'modal' for the
69
+ // portalled overlay the custom-elements build shows.
70
+ mode?: 'inline' | 'modal';
59
71
  onClose?: () => void;
60
- onOrderConfirmed?: (detail: { cartToken: string; value: number; currency: string }) => void;
61
72
  }
62
73
 
63
74
  export interface CartOverviewConfig extends BaseConfig {
64
75
  display?: CartOverviewDisplay;
65
76
  onCheckout?: () => void;
77
+ /** Distinguishes an empty cart from one that could not be read - see CartOverview. */
78
+ onStateChange?: (state: CartOverviewState) => void;
66
79
  }
67
80
 
68
81
  export interface MountedWidget {
69
82
  destroy: () => void;
70
83
  }
71
84
 
72
- // --- Shared bootstrap ---
85
+ // --- Mount functions ---
73
86
 
74
- function bootstrap(config: BaseConfig): { api: BookingApi; sessionManager: SessionManager; cartManager: CartManager } {
75
- const api: BookingApi = new ApiClient(config.apiBaseUrl ?? '');
87
+ interface MountContext {
88
+ host: BookingHost;
89
+ /** Tearing the widget down tears the host down too - but only the host this mount built itself. */
90
+ release: () => void;
91
+ }
76
92
 
77
- const sessionManager = new SessionManager(api);
78
- const cartManager = new CartManager(api);
79
- sessionManager.startBackgroundRefresh();
93
+ /**
94
+ * Resolves the host a mount function will use. A host passed in is the consumer's to destroy - other widgets
95
+ * are probably still using it - so this mount only unsubscribes the callbacks it added to it.
96
+ */
97
+ /**
98
+ * Waits for the session, releasing whatever the mount just acquired if it is refused. Without this a
99
+ * rejected ready - an unallowlisted checkout key, the failure consumers are told to expect - left the
100
+ * host's message subscription, its session-refresh interval and its expiry-guard refcount behind, and that
101
+ * leaked refcount pins CartExpiryGuard on <body> for the life of the page.
102
+ */
103
+ async function readyOrRelease(context: MountContext): Promise<void> {
104
+ try {
105
+ await context.host.ready;
106
+ } catch (e) {
107
+ context.release();
108
+ throw e;
109
+ }
110
+ }
80
111
 
81
- return { api, sessionManager, cartManager };
112
+ function hostFor(config: BaseConfig): MountContext {
113
+ if (!config.host) {
114
+ const host = createBookingHost(config);
115
+ return { host, release: () => host.destroy() };
116
+ }
117
+
118
+ // The host was built elsewhere and already carries its own callbacks, so the ones on this config would
119
+ // otherwise be dropped on the floor - a consumer following the documented `{ host, onOrderConfirmed }`
120
+ // shape would get a checkout that never reports a confirmed order. They are wired to the same messages
121
+ // the host itself listens to.
122
+ return { host: config.host, release: subscribeCallbacks(config) };
82
123
  }
83
124
 
84
- // --- Mount functions ---
125
+ function subscribeCallbacks(config: BaseConfig): () => void {
126
+ const { onCartChange, onCartUpdated, onCartExpired, onOrderConfirmed } = config;
127
+ if (!onCartChange && !onCartUpdated && !onCartExpired && !onOrderConfirmed) return () => {};
128
+
129
+ return onWidgetMessage((d) => {
130
+ if (d.type === 'cart:change') {
131
+ onCartChange?.({
132
+ itemCount: d.itemCount,
133
+ cartItemId: d.cartItemId,
134
+ totalFormatted: d.totalFormatted,
135
+ });
136
+ }
137
+ if (d.type === 'cart:updated') onCartUpdated?.(d.cart);
138
+ if (d.type === 'cart:expired') onCartExpired?.();
139
+ if (d.type === 'order:complete') {
140
+ onOrderConfirmed?.({ cartToken: d.cartToken, value: d.value, currency: d.currency });
141
+ }
142
+ });
143
+ }
85
144
 
86
145
  export async function mountConfigurator(target: HTMLElement, config: ConfiguratorConfig): Promise<MountedWidget> {
87
- const { api, sessionManager, cartManager } = bootstrap(config);
146
+ const context = hostFor(config);
147
+ const { host, release } = context;
88
148
 
89
- await sessionManager.ensureSession(config.checkoutKey ?? '');
149
+ await readyOrRelease(context);
90
150
 
91
151
  const component = mount(TicketConfiguratorComponent, {
92
152
  target,
93
153
  props: {
94
- api,
95
- cartManager,
154
+ api: host.api,
155
+ cartManager: host.cartManager,
96
156
  productId: config.productId,
97
- wizardPages: config.wizardPages,
98
- autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
157
+ wizardPages: config.wizardPages ?? host.options.wizardPages,
158
+ autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? host.options.autoSelectSingleTimeSlot,
159
+ // Stated per configurator so this and the host's own autoOpenCheckout meet in the same branch - see
160
+ // host.svelte.ts. Both have to say yes for an add to open checkout, which is what lets one configurator
161
+ // on a page opt out (the ES equivalent of the element's no-auto-checkout) without silencing the rest.
162
+ autoOpenCheckout: config.autoOpenCheckout !== false,
99
163
  onCancel: config.onCancel,
100
164
  },
101
165
  });
102
166
 
103
- // Listen for postMessage events and forward to callbacks
104
- const stopListening = onWidgetMessage((d) => {
105
- if (d.type === 'cart:change' && config.onCartChange) {
106
- config.onCartChange({
107
- itemCount: (d.itemCount as number) ?? 0,
108
- cartItemId: (d.cartItemId as string) ?? '',
109
- totalFormatted: (d.totalFormatted as string) ?? '',
110
- });
111
- }
112
- if (d.type === 'cart:updated' && 'cart' in d) {
113
- config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
114
- }
115
- if (d.type === 'cart:expired') config.onCartExpired?.();
116
- });
117
-
118
167
  return {
119
168
  destroy() {
120
- stopListening();
121
- sessionManager.stop();
122
169
  unmount(component);
170
+ release();
123
171
  },
124
172
  };
125
173
  }
126
174
 
127
175
  export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
128
- const { api, sessionManager, cartManager } = bootstrap(config);
176
+ const context = hostFor(config);
177
+ const { host, release } = context;
129
178
 
130
- await sessionManager.ensureSession(config.checkoutKey ?? '');
179
+ await readyOrRelease(context);
131
180
 
132
181
  const component = mount(CheckoutComponent, {
133
182
  target,
134
183
  props: {
135
- api,
136
- cartManager,
137
- wizardPages: config.wizardPages,
138
- editPages: config.editPages,
139
- autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
184
+ api: host.api,
185
+ cartManager: host.cartManager,
186
+ wizardPages: config.wizardPages ?? host.options.wizardPages,
187
+ editPages: config.editPages ?? host.options.editPages,
188
+ autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? host.options.autoSelectSingleTimeSlot,
189
+ mode: config.mode ?? 'inline',
190
+ // The modal opens and closes with the host, which is what an add-to-cart and the cart bar's button
191
+ // both reach. A consumer wanting to drive it directly can call host.openCheckout().
192
+ get open() {
193
+ return host.isCheckoutOpen;
194
+ },
195
+ // Fires for every dismissal, not just the scrim - Checkout answers the modal:close message rather
196
+ // than only its own overlay click.
197
+ onClose: config.onClose,
140
198
  },
141
199
  });
142
200
 
143
- const stopListening = onWidgetMessage((d) => {
144
- if (d.type === 'modal:close' && config.onClose) config.onClose();
145
- if (d.type === 'order:complete' && config.onOrderConfirmed) {
146
- config.onOrderConfirmed({
147
- cartToken: (d.cartToken as string) ?? '',
148
- value: (d.value as number) ?? 0,
149
- currency: (d.currency as string) ?? 'ZAR',
150
- });
151
- }
152
- if (d.type === 'cart:updated' && 'cart' in d) {
153
- config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
154
- }
155
- if (d.type === 'cart:expired') config.onCartExpired?.();
156
- });
157
-
158
201
  return {
159
202
  destroy() {
160
- stopListening();
161
- sessionManager.stop();
162
203
  unmount(component);
204
+ release();
163
205
  },
164
206
  };
165
207
  }
166
208
 
167
209
  export async function mountCartOverview(target: HTMLElement, config: CartOverviewConfig): Promise<MountedWidget> {
168
- const { api, sessionManager, cartManager } = bootstrap(config);
210
+ const context = hostFor(config);
211
+ const { host, release } = context;
169
212
 
170
- await sessionManager.ensureSession(config.checkoutKey ?? '');
213
+ await readyOrRelease(context);
171
214
 
172
215
  const component = mount(CartOverviewComponent, {
173
216
  target,
174
217
  props: {
175
- api,
176
- cartManager,
218
+ api: host.api,
219
+ cartManager: host.cartManager,
177
220
  display: config.display ?? 'bar',
221
+ onStateChange: config.onStateChange,
178
222
  },
179
223
  });
180
224
 
181
- const stopListening = onWidgetMessage((d) => {
182
- if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
183
- if (d.type === 'cart:updated' && 'cart' in d) {
184
- config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
185
- }
186
- if (d.type === 'cart:expired') config.onCartExpired?.();
187
- });
225
+ const stopListening = config.onCheckout
226
+ ? onCheckoutRequested(config.onCheckout)
227
+ : () => {};
188
228
 
189
229
  return {
190
230
  destroy() {
191
231
  stopListening();
192
- sessionManager.stop();
193
232
  unmount(component);
233
+ release();
194
234
  },
195
235
  };
196
236
  }
237
+
238
+ function onCheckoutRequested(onCheckout: () => void): () => void {
239
+ return onWidgetMessage((d) => {
240
+ if (d.type === 'modal:open') onCheckout();
241
+ });
242
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The configurator is presented two different ways by its host, and the two need different chrome.
3
+ * The marketing site (city-sightseeing-site/marketing-site/apps/web/src/components/BookingWidget.svelte)
4
+ * splits on Tailwind's `lg`: at and above it the widget sits inline in the product page's sticky
5
+ * sidebar, below it that sidebar is display:none and the widget is opened as a full-screen sheet
6
+ * instead. Matching that exact breakpoint here is what keeps the widget's own layout in step with
7
+ * the host's - a widget that thought it was inline while sitting in a full-screen sheet would show
8
+ * a duplicate header and a card that refuses to fill the screen.
9
+ */
10
+ export const INLINE_LAYOUT_QUERY = '(min-width: 1024px)';
11
+
12
+ // The one place that knows whether this environment can answer the question at all. SSR has no window,
13
+ // and jsdom (this package's test environment) has no matchMedia, so both the seed and the subscription
14
+ // below go through here rather than each guarding differently - or, as happened once, one not guarding.
15
+ function inlineLayoutQuery(): MediaQueryList | null {
16
+ if (typeof window === 'undefined' || !window.matchMedia) return null;
17
+ return window.matchMedia(INLINE_LAYOUT_QUERY);
18
+ }
19
+
20
+ function matchesInlineLayout(): boolean {
21
+ // Where the question cannot be asked, inline is the safer answer: it is the variant whose chrome the
22
+ // host never duplicates.
23
+ return inlineLayoutQuery()?.matches ?? true;
24
+ }
25
+
26
+ export interface InlineLayout {
27
+ readonly isInline: boolean;
28
+ }
29
+
30
+ /**
31
+ * Tracks which of the two presentations the widget is currently in. Call during component
32
+ * initialisation - it registers an $effect that follows viewport changes for the component's
33
+ * lifetime, so a desktop window dragged narrow re-lays out rather than keeping stale chrome.
34
+ */
35
+ export function createInlineLayout(): InlineLayout {
36
+ // Seeded synchronously rather than waiting for the effect below, so the first paint is already
37
+ // the right variant instead of rendering full-screen and visibly correcting itself on desktop.
38
+ let isInline = $state(matchesInlineLayout());
39
+
40
+ $effect(() => {
41
+ const query = inlineLayoutQuery();
42
+ if (!query) return;
43
+ isInline = query.matches;
44
+ const onChange = (e: MediaQueryListEvent) => { isInline = e.matches; };
45
+ query.addEventListener('change', onChange);
46
+ return () => query.removeEventListener('change', onChange);
47
+ });
48
+
49
+ return {
50
+ get isInline() { return isInline; },
51
+ };
52
+ }
@@ -3,28 +3,100 @@
3
3
  // Svelte tree. That makes every one of these messages same-window and same-origin, and the guards below exist
4
4
  // to keep it that way.
5
5
 
6
- /** Messages this widget sends itself. Anything else on the wire is not ours. */
7
- const WIDGET_MESSAGE_TYPES = new Set([
6
+ import type { CheckoutCartDetailDto } from './client-types';
7
+
8
+ /** An item was added to the cart from a configurator. */
9
+ export interface CartChangeMessage {
10
+ type: 'cart:change';
11
+ itemCount: number;
12
+ cartItemId: string;
13
+ totalFormatted: string;
14
+ // Whether this add should carry the shopper on into checkout. The single source of truth for auto-open on
15
+ // both builds - createBookingHost reads it directly, and register.ts reaches it through the same host - so
16
+ // the elements build and the ES build cannot drift on when checkout opens.
17
+ openCheckout: boolean;
18
+ }
19
+
20
+ /** The cart's contents changed, anywhere on the page. `cart` is null once the cart is gone. */
21
+ export interface CartUpdatedMessage {
22
+ type: 'cart:updated';
23
+ cart: CheckoutCartDetailDto | null;
24
+ }
25
+
26
+ /**
27
+ * The cart's window ran out and the cart is gone - distinct from the `cart:updated` with a null cart that
28
+ * also accompanies it, which a confirmed order posts too. A host telling the shopper "your cart expired"
29
+ * needs the difference.
30
+ */
31
+ export interface CartExpiredMessage {
32
+ type: 'cart:expired';
33
+ }
34
+
35
+ /** Something asked for checkout to be shown - the cart bar's button, or the expiry guard resuming a payment. */
36
+ export interface ModalOpenMessage {
37
+ type: 'modal:open';
38
+ }
39
+
40
+ /** Checkout was dismissed. */
41
+ export interface ModalCloseMessage {
42
+ type: 'modal:close';
43
+ }
44
+
45
+ /** Payment completed *and* was confirmed. Does not fire while a confirmation webhook is still pending. */
46
+ export interface OrderCompleteMessage {
47
+ type: 'order:complete';
48
+ cartToken: string;
49
+ value: number;
50
+ currency: string;
51
+ }
52
+
53
+ /**
54
+ * A Peach checkout is open for the cart / has been abandoned so the cart is Open again. Posted by
55
+ * CheckoutModal for CartExpiryGuard, which keeps prompting but stops acting on the deadline itself while the
56
+ * server exempts the cart from expiry.
57
+ */
58
+ export interface PaymentStartedMessage {
59
+ type: 'payment:started';
60
+ }
61
+
62
+ export interface PaymentEndedMessage {
63
+ type: 'payment:ended';
64
+ }
65
+
66
+ /**
67
+ * The cart's one clock ran out while a Peach checkout was open. Posted by CartExpiryGuard for CheckoutModal,
68
+ * which tears the attempt down (Peach's real status permitting) so the shopper sees the cart expire there
69
+ * too, rather than an ever-open card form for a cart that is already gone.
70
+ */
71
+ export interface PaymentTimedOutMessage {
72
+ type: 'payment:timed-out';
73
+ }
74
+
75
+ /** Every message this widget sends itself. Anything else on the wire is not ours. */
76
+ export type WidgetMessage =
77
+ | CartChangeMessage
78
+ | CartUpdatedMessage
79
+ | CartExpiredMessage
80
+ | ModalOpenMessage
81
+ | ModalCloseMessage
82
+ | OrderCompleteMessage
83
+ | PaymentStartedMessage
84
+ | PaymentEndedMessage
85
+ | PaymentTimedOutMessage;
86
+
87
+ export type WidgetMessageType = WidgetMessage['type'];
88
+
89
+ const WIDGET_MESSAGE_TYPES = new Set<string>([
8
90
  'cart:change',
9
91
  'cart:updated',
10
- // The cart the widget was holding onto is gone because its clock ran out - a strict subset of
11
- // cart:updated's own cart:null case (see that message's own callers), fired alongside it so a host that
12
- // wants to react to expiry specifically (analytics, a redirect, its own message) does not have to infer it
13
- // from an absent cart, which cart:updated also carries for an ordinary manual clear.
14
92
  'cart:expired',
15
93
  'modal:open',
16
94
  'modal:close',
17
95
  'order:complete',
18
- // A Peach checkout is open for the cart / has been abandoned so the cart is Open again. Posted by
19
- // CheckoutModal for CartExpiryGuard, which keeps prompting but stops acting on the deadline itself while
20
- // the server exempts the cart from expiry.
21
96
  'payment:started',
22
97
  'payment:ended',
23
- // The cart's one clock ran out while a Peach checkout was open. Posted by CartExpiryGuard for
24
- // CheckoutModal, which tears the attempt down (Peach's real status permitting) so the shopper sees the
25
- // cart expire there too, rather than an ever-open card form for a cart that is already gone.
26
98
  'payment:timed-out',
27
- ]);
99
+ ] satisfies WidgetMessageType[]);
28
100
 
29
101
  /**
30
102
  * Sends a widget message.
@@ -34,12 +106,16 @@ const WIDGET_MESSAGE_TYPES = new Set([
34
106
  * '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
35
107
  * not necessarily anyone we trust.
36
108
  */
37
- export function postMessage(data: Record<string, unknown>): void {
109
+ export function postMessage(data: WidgetMessage): void {
110
+ // No window means a server render - an Astro island is server-rendered before it hydrates, and a host
111
+ // built during that pass would otherwise take the whole page down with a ReferenceError.
112
+ if (typeof window === 'undefined') return;
38
113
  window.parent?.postMessage(JSON.stringify(data), window.location.origin);
39
114
  }
40
115
 
41
116
  /**
42
- * Subscribes to widget messages, ignoring anything that did not come from this widget.
117
+ * Subscribes to widget messages, ignoring anything that did not come from this widget. Returns an
118
+ * unsubscribe function.
43
119
  *
44
120
  * The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
45
121
  * the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
@@ -50,13 +126,17 @@ export function postMessage(data: Record<string, unknown>): void {
50
126
  * Checking `source` is what does the real work: a message from another frame carries that frame's own window,
51
127
  * never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
52
128
  */
53
- export function onWidgetMessage(handler: (data: Record<string, unknown>) => void): () => void {
129
+ export function onWidgetMessage(handler: (data: WidgetMessage) => void): () => void {
130
+ // Same reason as postMessage above: nothing to subscribe to on the server, and createBookingHost
131
+ // subscribes the moment it is built.
132
+ if (typeof window === 'undefined') return () => {};
133
+
54
134
  function listener(e: MessageEvent) {
55
135
  if (e.source !== window || e.origin !== window.location.origin) {
56
136
  return;
57
137
  }
58
138
 
59
- let data: Record<string, unknown>;
139
+ let data: WidgetMessage;
60
140
  try {
61
141
  data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
62
142
  } catch {
@@ -4,23 +4,69 @@ const SDK_URLS: Record<string, string> = {
4
4
  };
5
5
 
6
6
  let loadPromise: Promise<void> | null = null;
7
+ let configuredUrl: string | null = null;
8
+ let loadedUrl: string | null = null;
9
+
10
+ /** Which SDK was actually loaded. Only worth knowing when Peach then refuses to render - see PaymentPage. */
11
+ export function loadedPeachSdkUrl(): string | null {
12
+ return loadedUrl;
13
+ }
14
+
15
+ export type PeachEnv = 'prod' | 'qa';
16
+
17
+ /**
18
+ * Chooses which Peach SDK to load, for consumers with no window globals to set.
19
+ *
20
+ * window.BW_CHECKOUT_ENV is the custom-elements build's convention, read off the page that loaded the
21
+ * script. An ES consumer has no such page - it passes apiBaseUrl to createBookingHost and nothing else -
22
+ * so without this it silently got the production SDK while its checkout API was creating sandbox
23
+ * checkoutIds, and Peach answered with "An unrecoverable error has occurred while attempting to render the
24
+ * checkout experience". createBookingHost calls this for you; it is exported for anyone composing without
25
+ * a host.
26
+ */
27
+ export function setPeachSdk(options: { url?: string; env?: PeachEnv }): void {
28
+ const next = options.url ?? (options.env ? SDK_URLS[options.env] : undefined);
29
+ if (!next || next === configuredUrl) return;
30
+ configuredUrl = next;
31
+ // A different SDK than the one already promised has to be fetched afresh.
32
+ loadPromise = null;
33
+ }
7
34
 
8
35
  // Lazily injects Peach's Copy&Pay SDK the first time PaymentPage mounts, so consumers embedding the widget
9
36
  // (any of the three integration options) never have to know this script exists, let alone which
10
37
  // environment's URL to point at - same window.BW_CHECKOUT_ENV convention elements/env.ts already uses for
11
38
  // the checkout API base URL, with an explicit override for anything that doesn't fit that pattern.
12
39
  export function loadPeachSdk(): Promise<void> {
13
- if ((window as any).Checkout) {
40
+ const w = window as any;
41
+ const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
42
+ // Explicit window override first (it is the most specific thing a page can say), then whatever a host
43
+ // was configured with, then the elements build's env global, then production.
44
+ const src: string = w.BW_CHECKOUT_PEACH_SDK_URL ?? configuredUrl ?? SDK_URLS[env] ?? SDK_URLS.prod;
45
+
46
+ // Peach's SDK cannot be swapped once it has installed window.Checkout, so a page that already loaded one
47
+ // - its own <script> tag, or an earlier host configured differently - is stuck with it. Resolving in
48
+ // silence is what made this hard to find the first time: the only symptom is Peach's opaque
49
+ // "unrecoverable error" card, from a production SDK holding a sandbox checkoutId.
50
+ if (w.Checkout) {
51
+ if (loadedUrl !== null && loadedUrl !== src) {
52
+ console.warn(
53
+ `[booking-widget] Peach SDK already loaded from ${loadedUrl}, so ${src} was not used. ` +
54
+ `Configure peachEnv before the first payment, or set window.BW_CHECKOUT_PEACH_SDK_URL.`,
55
+ );
56
+ } else if (loadedUrl === null) {
57
+ console.warn(
58
+ `[booking-widget] Peach SDK was already on the page when the widget first needed it, so ${src} ` +
59
+ `was not used. If its environment does not match the checkout API, the card form will not render.`,
60
+ );
61
+ }
14
62
  return Promise.resolve();
15
63
  }
64
+
16
65
  if (loadPromise) {
17
66
  return loadPromise;
18
67
  }
19
68
 
20
- const w = window as any;
21
- const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
22
- const src: string = w.BW_CHECKOUT_PEACH_SDK_URL ?? SDK_URLS[env] ?? SDK_URLS.prod;
23
-
69
+ loadedUrl = src;
24
70
  loadPromise = new Promise((resolve, reject) => {
25
71
  const existing = document.querySelector(`script[src="${src}"]`);
26
72
  if (existing) {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Moves an element out to `document.body` (or another target) for as long as it is mounted, and puts nothing
3
+ * back - the node is removed with the component.
4
+ *
5
+ * `position: fixed` only escapes to the viewport while no ancestor has established a containing block for it.
6
+ * A `transform`, `filter`, `backdrop-filter`, `perspective`, `contain` or `will-change` anywhere above, and a
7
+ * `position: sticky` ancestor's stacking context, are all enough to trap a fixed overlay inside the subtree
8
+ * and let the host's own header paint over it. No z-index inside that subtree can win, because the contest is
9
+ * between the ancestor and the header, not between the overlay and anything.
10
+ *
11
+ * Consumers hit this the moment they put checkout in a sticky sidebar, which is the ordinary way to lay a
12
+ * product page out - so the modal portals itself rather than leaving each host to discover the rule.
13
+ */
14
+ export function portal(node: HTMLElement, target?: HTMLElement | null) {
15
+ const destination = target ?? (typeof document === 'undefined' ? null : document.body);
16
+ destination?.appendChild(node);
17
+
18
+ return {
19
+ destroy() {
20
+ node.remove();
21
+ },
22
+ };
23
+ }