@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
@@ -0,0 +1,336 @@
1
+ // The widget's host layer: everything that makes an embedded widget behave like a finished checkout rather
2
+ // than a set of rendering primitives. It used to exist only inside elements/register.ts, so it shipped with
3
+ // the custom-elements build alone and every consumer of the ES build re-implemented it - differently, and
4
+ // without the parts that are easy to miss (the expiry guard, the payment timeout, the portalled modal).
5
+ //
6
+ // register.ts is now a thin DOM adapter over this: it reads attributes and window.bwOptions, builds a host,
7
+ // and binds bw:* events to it. Both builds therefore auto-wire through the same branches.
8
+
9
+ import { mount, unmount } from 'svelte';
10
+ import type { BookingApi } from './api';
11
+ import { ApiClient } from './api';
12
+ import { SessionManager } from './session-manager';
13
+ import { CartManager } from './cart-manager';
14
+ import type { WizardPages } from './config';
15
+ import type { CheckoutCartDetailDto } from './client-types';
16
+ import { onWidgetMessage, postMessage } from './messages';
17
+ import CartExpiryGuard from './CartExpiryGuard.svelte';
18
+ import { setPeachSdk, type PeachEnv } from './peach-sdk';
19
+
20
+ /**
21
+ * Site-wide defaults, applied to every host created afterwards. The ES equivalent of window.bwOptions, which
22
+ * register.ts now feeds through this same function - so a site does not have to thread
23
+ * autoSelectSingleTimeSlot and wizardPages down through every component individually.
24
+ */
25
+ export interface BookingDefaults {
26
+ apiBaseUrl?: string;
27
+ checkoutKey?: string;
28
+ /**
29
+ * Whether adding an item carries the shopper on into checkout. Opt out, never opt in - the same default,
30
+ * and the same branch, as the elements build's no-auto-checkout attribute.
31
+ *
32
+ * A function is re-asked on every add, so a layout that already shows the configurator inline (a desktop
33
+ * sidebar, say) can suppress the hand-off it does not need without the widget knowing anything about
34
+ * breakpoints: autoOpenCheckout: () => !desktopQuery.matches.
35
+ */
36
+ autoOpenCheckout?: boolean | (() => boolean);
37
+ /** Hide bar-display cart overviews while checkout is open, and restore them on close. */
38
+ shouldBottomCloseOnModal?: boolean;
39
+ autoSelectSingleTimeSlot?: boolean;
40
+ wizardPages?: WizardPages;
41
+ editPages?: WizardPages;
42
+ /**
43
+ * Mounts CartExpiryGuard on <body>. On by default, and worth leaving on: it owns the "still shopping?"
44
+ * warning, the expired view, and the payment-timeout hand-over, none of which live anywhere else.
45
+ */
46
+ mountExpiryGuard?: boolean;
47
+ /** Whether the expiry guard offers to extend, or only warns. Defaults to the guard's own setting. */
48
+ extensionEnabled?: boolean;
49
+ /**
50
+ * Which Peach Payments SDK the card form loads. **Set this whenever apiBaseUrl points at anything but
51
+ * production**: the checkout API creates the Peach checkout, so a sandbox API with the production SDK
52
+ * gives a card form that refuses to render. The elements build reads window.BW_CHECKOUT_ENV instead.
53
+ */
54
+ peachEnv?: PeachEnv;
55
+ /** An explicit Peach SDK URL, for an environment peachEnv does not name. */
56
+ peachSdkUrl?: string;
57
+ }
58
+
59
+ export interface BookingHostConfig extends BookingDefaults {
60
+ /**
61
+ * An API client to use instead of building one from apiBaseUrl - a test double, or a fake that serves a
62
+ * demo page without a backend.
63
+ */
64
+ api?: BookingApi;
65
+ /**
66
+ * Fires on an add, with the detail only the add knows: which item, how many units, the formatted total.
67
+ * onCartUpdated follows it with the whole cart - use that one for a summary, this one for "added to cart"
68
+ * analytics or a toast naming what was just added.
69
+ */
70
+ onCartChange?: (detail: { itemCount: number; cartItemId: string; totalFormatted: string }) => void;
71
+ /** Fires after any add/edit/remove anywhere on the page, carrying the cart the widget already fetched. */
72
+ onCartUpdated?: (cart: CheckoutCartDetailDto | null) => void;
73
+ /** The cart's window ran out. Distinct from the null onCartUpdated a confirmed order also produces. */
74
+ onCartExpired?: () => void;
75
+ onCheckoutOpenChange?: (open: boolean) => void;
76
+ onOrderConfirmed?: (detail: { cartToken: string; value: number; currency: string }) => void;
77
+ }
78
+
79
+ /** The options a host resolved, for components that need to be handed them as props. */
80
+ export interface ResolvedBookingOptions {
81
+ autoSelectSingleTimeSlot: boolean;
82
+ wizardPages: WizardPages | undefined;
83
+ editPages: WizardPages | undefined;
84
+ shouldBottomCloseOnModal: boolean;
85
+ }
86
+
87
+ export interface BookingHost {
88
+ readonly api: BookingApi;
89
+ readonly sessionManager: SessionManager;
90
+ readonly cartManager: CartManager;
91
+ /** Resolves once the checkout session exists. Components can render before it; requests cannot. */
92
+ readonly ready: Promise<void>;
93
+ readonly isCheckoutOpen: boolean;
94
+ readonly options: ResolvedBookingOptions;
95
+ openCheckout: () => void;
96
+ closeCheckout: () => void;
97
+ destroy: () => void;
98
+ }
99
+
100
+ let siteDefaults: BookingDefaults = {};
101
+
102
+ /** Merges into the site-wide defaults. Call before creating hosts; later calls do not affect existing ones. */
103
+ export function setBookingDefaults(defaults: BookingDefaults): void {
104
+ siteDefaults = { ...siteDefaults, ...defaults };
105
+ }
106
+
107
+ export function getBookingDefaults(): BookingDefaults {
108
+ return { ...siteDefaults };
109
+ }
110
+
111
+ /** Test seam - drops anything a previous setBookingDefaults put in place. */
112
+ export function resetBookingDefaults(): void {
113
+ siteDefaults = {};
114
+ }
115
+
116
+ export function createBookingHost(config: BookingHostConfig = {}): BookingHost {
117
+ const settings = { ...siteDefaults, ...config };
118
+
119
+ if (settings.peachEnv || settings.peachSdkUrl) {
120
+ setPeachSdk({ env: settings.peachEnv, url: settings.peachSdkUrl });
121
+ }
122
+
123
+ const api: BookingApi = settings.api ?? new ApiClient(settings.apiBaseUrl ?? '');
124
+ const sessionManager = new SessionManager(api);
125
+ const cartManager = new CartManager(api);
126
+
127
+ // No session and no refresh timer on a server render. ApiClient's middleware reads
128
+ // window.location.origin for the Origin header the service enforces, so a session started there rejects
129
+ // with a ReferenceError - and a background interval would outlive the render with nothing to cancel it.
130
+ // The hydrated page builds its own host; this one exists only so a component body can run.
131
+ const hasDom = typeof window !== 'undefined';
132
+ let ready: Promise<void>;
133
+ if (hasDom) {
134
+ sessionManager.startBackgroundRefresh();
135
+ ready = sessionManager.ensureSession(settings.checkoutKey ?? '').then(() => {});
136
+ // A refusal is the consumer's to handle - the docs tell them to catch it - but an unhandled rejection
137
+ // can take a Node SSR render down with it, and nothing else here observes this promise. Attaching a
138
+ // no-op handler marks it handled without preventing the consumer's own catch from running.
139
+ void ready.catch(() => {});
140
+ } else {
141
+ ready = Promise.resolve();
142
+ }
143
+
144
+ const options: ResolvedBookingOptions = {
145
+ autoSelectSingleTimeSlot: settings.autoSelectSingleTimeSlot ?? false,
146
+ wizardPages: settings.wizardPages,
147
+ editPages: settings.editPages,
148
+ shouldBottomCloseOnModal: settings.shouldBottomCloseOnModal ?? true,
149
+ };
150
+
151
+ let isCheckoutOpen = $state(false);
152
+ let destroyed = false;
153
+
154
+ function setCheckoutOpen(open: boolean) {
155
+ if (isCheckoutOpen === open) return;
156
+ isCheckoutOpen = open;
157
+ settings.onCheckoutOpenChange?.(open);
158
+ }
159
+
160
+ function openCheckout() {
161
+ setCheckoutOpen(true);
162
+ }
163
+
164
+ function closeCheckout() {
165
+ setCheckoutOpen(false);
166
+ }
167
+
168
+ // Asked per add rather than read once, so a consumer can answer it from whatever its layout currently is.
169
+ function shouldAutoOpen(): boolean {
170
+ const setting = settings.autoOpenCheckout ?? true;
171
+ return typeof setting === 'function' ? setting() : setting;
172
+ }
173
+
174
+ const stopListening = onWidgetMessage((d) => {
175
+ switch (d.type) {
176
+ case 'cart:change':
177
+ // openCheckout on the message is the single source of truth for both builds - the configurator that
178
+ // added the item states whether this add is one the shopper should be carried on from, and the host
179
+ // decides whether to honour it. Nothing else reads the intent to check out from an add.
180
+ if (d.openCheckout && shouldAutoOpen()) openCheckout();
181
+ settings.onCartChange?.({
182
+ itemCount: d.itemCount,
183
+ cartItemId: d.cartItemId,
184
+ totalFormatted: d.totalFormatted,
185
+ });
186
+ break;
187
+ case 'modal:open':
188
+ openCheckout();
189
+ break;
190
+ case 'modal:close':
191
+ closeCheckout();
192
+ break;
193
+ case 'cart:updated':
194
+ settings.onCartUpdated?.(d.cart);
195
+ break;
196
+ case 'cart:expired':
197
+ settings.onCartExpired?.();
198
+ break;
199
+ case 'order:complete':
200
+ settings.onOrderConfirmed?.({ cartToken: d.cartToken, value: d.value, currency: d.currency });
201
+ break;
202
+ }
203
+ });
204
+
205
+ const releaseGuard = (settings.mountExpiryGuard ?? true)
206
+ ? acquireExpiryGuard(api, cartManager, settings.extensionEnabled)
207
+ : null;
208
+
209
+ return {
210
+ api,
211
+ sessionManager,
212
+ cartManager,
213
+ ready,
214
+ get isCheckoutOpen() {
215
+ return isCheckoutOpen;
216
+ },
217
+ options,
218
+ openCheckout,
219
+ closeCheckout,
220
+ destroy() {
221
+ if (destroyed) return;
222
+ destroyed = true;
223
+ // Closed before the teardown so anything driven off onCheckoutOpenChange is left in a sane state - the
224
+ // elements adapter hides the bottom cart bars while checkout is open, and a host destroyed mid-checkout
225
+ // would otherwise leave them hidden with nothing left alive to bring them back.
226
+ closeCheckout();
227
+ stopListening();
228
+ releaseGuard?.();
229
+ sessionManager.stop();
230
+ },
231
+ };
232
+ }
233
+
234
+ // The guard is a page singleton, not a per-host one. Every mount function builds its own host when it is not
235
+ // handed one, so the documented three-call sequence (configurator + cart overview + checkout) would otherwise
236
+ // put three guards on <body> and show the shopper three "still shopping?" dialogs at once. The first host to
237
+ // ask mounts it; it goes away when the last one releases it.
238
+ interface GuardHolder {
239
+ api: BookingApi;
240
+ cartManager: CartManager;
241
+ extensionEnabled?: boolean;
242
+ }
243
+
244
+ let guardHolders: GuardHolder[] = [];
245
+ let unmountGuard: (() => void) | null = null;
246
+
247
+ function acquireExpiryGuard(
248
+ api: BookingApi,
249
+ cartManager: CartManager,
250
+ extensionEnabled?: boolean,
251
+ ): () => void {
252
+ const holder: GuardHolder = { api, cartManager, extensionEnabled };
253
+ guardHolders.push(holder);
254
+ if (guardHolders.length === 1) mountGuardFor(holder);
255
+
256
+ let released = false;
257
+ return () => {
258
+ if (released) return;
259
+ released = true;
260
+
261
+ const wasMounted = guardHolders[0] === holder;
262
+ guardHolders = guardHolders.filter((h) => h !== holder);
263
+
264
+ if (guardHolders.length === 0) {
265
+ unmountGuard?.();
266
+ unmountGuard = null;
267
+ return;
268
+ }
269
+
270
+ // The guard was watching this holder's cart through this holder's api, and that host is going away -
271
+ // its session is stopped and nobody else uses its CartManager. Left alone it would keep polling a dead
272
+ // client and watching a cart nobody has, so the surviving holders would get no still-shopping prompt
273
+ // and no expiry clear at all.
274
+ if (wasMounted) {
275
+ unmountGuard?.();
276
+ mountGuardFor(guardHolders[0]);
277
+ }
278
+ };
279
+ }
280
+
281
+ function mountGuardFor(holder: GuardHolder) {
282
+ unmountGuard = mountExpiryGuard(holder.api, holder.cartManager, holder.extensionEnabled);
283
+ }
284
+
285
+ /**
286
+ * Mounted on <body> rather than inside any one widget's tree: a page may carry only a configurator and a cart
287
+ * bar and no checkout at all, and the warning - and the expiry it can lead to - still has to reach the
288
+ * shopper. Session readiness is irrelevant to it; cart operations only need the cart token.
289
+ */
290
+ function mountExpiryGuard(
291
+ api: BookingApi,
292
+ cartManager: CartManager,
293
+ extensionEnabled?: boolean,
294
+ ): (() => void) | null {
295
+ if (typeof document === 'undefined') return null;
296
+
297
+ const guardHost = document.createElement('div');
298
+ guardHost.dataset.bwExpiryGuard = '';
299
+
300
+ // A host created from a script in <head> runs before <body> exists, so the mount waits for the document
301
+ // rather than throwing on a null body and taking the message wiring above down with it.
302
+ let component: Record<string, unknown> | null = null;
303
+ let cancelled = false;
304
+
305
+ function doMount() {
306
+ if (cancelled) return;
307
+ document.body.appendChild(guardHost);
308
+ component = mount(CartExpiryGuard, {
309
+ target: guardHost,
310
+ props: extensionEnabled === undefined ? { api, cartManager } : { api, cartManager, extensionEnabled },
311
+ });
312
+ }
313
+
314
+ if (document.readyState === 'loading') {
315
+ document.addEventListener('DOMContentLoaded', doMount, { once: true });
316
+ } else {
317
+ doMount();
318
+ }
319
+
320
+ return () => {
321
+ cancelled = true;
322
+ document.removeEventListener('DOMContentLoaded', doMount);
323
+ if (component) unmount(component);
324
+ guardHost.remove();
325
+ };
326
+ }
327
+
328
+ /** Closes checkout from outside a host - the message every build already listens to. */
329
+ export function requestCheckoutClose(): void {
330
+ postMessage({ type: 'modal:close' });
331
+ }
332
+
333
+ /** Opens checkout from outside a host - what the cart bar's own button posts. */
334
+ export function requestCheckoutOpen(): void {
335
+ postMessage({ type: 'modal:open' });
336
+ }