@code-collective/booking-widget 1.0.6 → 1.0.8

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.
@@ -3,6 +3,7 @@
3
3
  <script lang="ts">
4
4
  import Checkout from '../Checkout.svelte';
5
5
  import { resolveWizardPages, resolveEditPages } from '../config';
6
+ import { onWidgetMessage } from '../messages';
6
7
  import { getSharedServices } from './shared';
7
8
 
8
9
  let {
@@ -17,7 +18,7 @@
17
18
  let isModal = $derived(isModalAttr !== undefined);
18
19
  let showModal = $state(false);
19
20
 
20
- const { api, ready: readyPromise } = getSharedServices();
21
+ const { api, cartManager, ready: readyPromise } = getSharedServices();
21
22
 
22
23
  let ready = $state(false);
23
24
  readyPromise.then(() => { ready = true; });
@@ -34,30 +35,22 @@
34
35
  export function open() { showModal = true; }
35
36
  export function close() { showModal = false; dispatch('bw:close', {}); }
36
37
 
37
- function handleMessage(e: MessageEvent) {
38
- let d: Record<string, unknown>;
39
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
40
- catch { return; }
41
- if (!d?.type) return;
42
-
38
+ $effect(() => onWidgetMessage((d) => {
43
39
  if (d.type === 'modal:close') {
44
40
  if (isModal) showModal = false;
45
41
  dispatch('bw:close', {});
46
42
  }
47
43
  if (d.type === 'order:complete') {
48
- if (isModal) showModal = false;
44
+ // Deliberately does not close the modal - order:complete only reports that the order is confirmed
45
+ // (for host analytics/cart-clearing), not that the shopper has finished looking at the result screen.
46
+ // The modal stays open until they dismiss it themselves, which sends modal:close separately.
49
47
  dispatch('bw:order-confirmed', {
50
48
  cartToken: d.cartToken ?? '',
51
49
  value: d.value ?? 0,
52
50
  currency: d.currency ?? 'ZAR',
53
51
  });
54
52
  }
55
- }
56
-
57
- $effect(() => {
58
- window.addEventListener('message', handleMessage);
59
- return () => window.removeEventListener('message', handleMessage);
60
- });
53
+ }));
61
54
 
62
55
  // Listen for a custom 'bw:open' event so register.ts can trigger it
63
56
  $effect(() => {
@@ -74,12 +67,12 @@
74
67
  <div class="bw-modal-overlay" onclick={() => close()}>
75
68
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
76
69
  <div class="bw-modal-inner" onclick={(e) => e.stopPropagation()}>
77
- <Checkout {api} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
70
+ <Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
78
71
  </div>
79
72
  </div>
80
73
  {/if}
81
74
  {:else if ready}
82
- <Checkout {api} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
75
+ <Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
83
76
  {:else}
84
77
  <div class="loading-center"><div class="spinner"></div></div>
85
78
  {/if}
@@ -3,7 +3,7 @@
3
3
  <script lang="ts">
4
4
  import TicketConfigurator from '../TicketConfigurator.svelte';
5
5
  import { resolveWizardPages } from '../config';
6
- import { postMessage } from '../messages';
6
+ import { onWidgetMessage, postMessage } from '../messages';
7
7
  import { getSharedServices } from './shared';
8
8
 
9
9
  let {
@@ -26,12 +26,7 @@
26
26
  }
27
27
 
28
28
  // Forward postMessage events as CustomEvents
29
- function handleMessage(e: MessageEvent) {
30
- let d: Record<string, unknown>;
31
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
32
- catch { return; }
33
- if (!d?.type) return;
34
-
29
+ $effect(() => onWidgetMessage((d) => {
35
30
  if (d.type === 'cart:change') {
36
31
  dispatch('bw:cart-change', {
37
32
  itemCount: d.itemCount ?? 0,
@@ -39,12 +34,7 @@
39
34
  totalFormatted: d.totalFormatted ?? '',
40
35
  });
41
36
  }
42
- }
43
-
44
- $effect(() => {
45
- window.addEventListener('message', handleMessage);
46
- return () => window.removeEventListener('message', handleMessage);
47
- });
37
+ }));
48
38
 
49
39
  const onCancel = cancelable !== undefined
50
40
  ? () => { postMessage({ type: 'modal:close' }); dispatch('bw:cancel', {}); }
@@ -7,5 +7,4 @@ const w = window as any;
7
7
  const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
8
8
  const explicitUrl: string | undefined = w.BW_CHECKOUT_API_URL;
9
9
 
10
- export const useFakeApi = env === 'fake';
11
10
  export const defaultApiBaseUrl = explicitUrl ?? API_URLS[env] ?? API_URLS.prod;
@@ -1,17 +1,17 @@
1
1
  import '../app.css';
2
2
  import type { BookingApi } from '../api';
3
3
  import { ApiClient } from '../api';
4
- import { FakeApiClient } from '../fake-api';
5
4
  import { SessionManager } from '../session-manager';
6
5
  import { CartManager } from '../cart-manager';
7
- import { defaultApiBaseUrl, useFakeApi } from './env';
6
+ import { defaultApiBaseUrl } from './env';
7
+ import { onWidgetMessage } from '../messages';
8
8
 
9
9
  // Bootstrap shared services BEFORE element imports trigger connectedCallback.
10
10
  // Elements read from window.__bwServices instead of importing shared.ts,
11
11
  // because the IIFE bundler inlines imports per-element — a module-level
12
12
  // singleton would be duplicated. window is the only truly shared scope.
13
13
  const checkoutKey = document.querySelector('[checkout-key]')?.getAttribute('checkout-key') ?? '';
14
- const api: BookingApi = useFakeApi ? new FakeApiClient() : new ApiClient(defaultApiBaseUrl);
14
+ const api: BookingApi = new ApiClient(defaultApiBaseUrl);
15
15
  const sessionManager = new SessionManager(api);
16
16
  const cartManager = new CartManager(api);
17
17
  sessionManager.startBackgroundRefresh();
@@ -22,6 +22,17 @@ import './bw-configurator.svelte';
22
22
  import './bw-cart.svelte';
23
23
  import './bw-checkout.svelte';
24
24
 
25
+ // Cart-global, not tied to any one element (unlike the bw:* events forwarded per-element in autoWire below),
26
+ // so this is wired once here rather than per bw-configurator/bw-cart/bw-checkout instance. Carries the same
27
+ // cart TicketConfigurator/CheckoutModal already fetch for their own posting - see those files' own comments -
28
+ // so a consumer can build a custom cart summary (item count, remaining time, item details) without calling
29
+ // the checkout API directly.
30
+ onWidgetMessage((d) => {
31
+ if (d.type === 'cart:updated' && 'cart' in d) {
32
+ window.dispatchEvent(new CustomEvent('bw:cart-updated', { detail: { cart: d.cart } }));
33
+ }
34
+ });
35
+
25
36
  interface BwOptions {
26
37
  shouldBottomCloseOnModal: boolean;
27
38
  autoSelectSingleTimeSlot: boolean;
@@ -373,6 +373,7 @@ export interface components {
373
373
  CheckoutCartPaymentInitiationDto: {
374
374
  checkoutId?: string | null;
375
375
  redirectUrl?: string | null;
376
+ entityId?: string | null;
376
377
  };
377
378
  CheckoutCartResult: {
378
379
  cartToken?: string | null;
package/src/lib/index.ts CHANGED
@@ -2,10 +2,11 @@ import './app.css';
2
2
  import { mount, unmount } from 'svelte';
3
3
  import type { WizardPages, CartOverviewDisplay } from './config';
4
4
  import type { BookingApi } from './api';
5
- import { ApiClient } from './api';
6
- import { FakeApiClient } from './fake-api';
5
+ import type { CheckoutCartDetailDto } from './client-types';
6
+ import { ApiClient, ApiError } from './api';
7
7
  import { SessionManager } from './session-manager';
8
8
  import { CartManager } from './cart-manager';
9
+ import { onWidgetMessage } from './messages';
9
10
  import TicketConfiguratorComponent from './TicketConfigurator.svelte';
10
11
  import CheckoutComponent from './Checkout.svelte';
11
12
  import CartOverviewComponent from './CartOverview.svelte';
@@ -18,7 +19,7 @@ export { default as CartBar } from './CartBar.svelte';
18
19
  export { default as CartOverviewButton } from './CartOverviewButton.svelte';
19
20
 
20
21
  // Re-export utilities
21
- export { ApiClient, FakeApiClient, SessionManager, CartManager };
22
+ export { ApiClient, ApiError, SessionManager, CartManager };
22
23
  export { resolveWizardPages, resolveEditPages, DEFAULT_WIZARD_PAGES } from './config';
23
24
  export type { BookingApi } from './api';
24
25
  export type { WizardPages, WizardPageConfig, CartOverviewDisplay, WidgetMode, WizardWidgetType } from './config';
@@ -30,7 +31,11 @@ export type * from './client-types';
30
31
  interface BaseConfig {
31
32
  apiBaseUrl?: string;
32
33
  checkoutKey?: string;
33
- useFakeApi?: boolean;
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;
34
39
  }
35
40
 
36
41
  export interface ConfiguratorConfig extends BaseConfig {
@@ -63,9 +68,7 @@ export interface MountedWidget {
63
68
  // --- Shared bootstrap ---
64
69
 
65
70
  function bootstrap(config: BaseConfig): { api: BookingApi; sessionManager: SessionManager; cartManager: CartManager } {
66
- const api: BookingApi = config.useFakeApi
67
- ? new FakeApiClient()
68
- : new ApiClient(config.apiBaseUrl ?? '');
71
+ const api: BookingApi = new ApiClient(config.apiBaseUrl ?? '');
69
72
 
70
73
  const sessionManager = new SessionManager(api);
71
74
  const cartManager = new CartManager(api);
@@ -94,12 +97,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
94
97
  });
95
98
 
96
99
  // Listen for postMessage events and forward to callbacks
97
- function messageHandler(e: MessageEvent) {
98
- let d: Record<string, unknown>;
99
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
100
- catch { return; }
101
- if (!d?.type) return;
102
-
100
+ const stopListening = onWidgetMessage((d) => {
103
101
  if (d.type === 'cart:change' && config.onCartChange) {
104
102
  config.onCartChange({
105
103
  itemCount: (d.itemCount as number) ?? 0,
@@ -107,12 +105,14 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
107
105
  totalFormatted: (d.totalFormatted as string) ?? '',
108
106
  });
109
107
  }
110
- }
111
- window.addEventListener('message', messageHandler);
108
+ if (d.type === 'cart:updated' && 'cart' in d) {
109
+ config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
110
+ }
111
+ });
112
112
 
113
113
  return {
114
114
  destroy() {
115
- window.removeEventListener('message', messageHandler);
115
+ stopListening();
116
116
  sessionManager.stop();
117
117
  unmount(component);
118
118
  },
@@ -120,7 +120,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
120
120
  }
121
121
 
122
122
  export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
123
- const { api, sessionManager } = bootstrap(config);
123
+ const { api, sessionManager, cartManager } = bootstrap(config);
124
124
 
125
125
  await sessionManager.ensureSession(config.checkoutKey ?? '');
126
126
 
@@ -128,18 +128,14 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
128
128
  target,
129
129
  props: {
130
130
  api,
131
+ cartManager,
131
132
  wizardPages: config.wizardPages,
132
133
  editPages: config.editPages,
133
134
  autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
134
135
  },
135
136
  });
136
137
 
137
- function messageHandler(e: MessageEvent) {
138
- let d: Record<string, unknown>;
139
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
140
- catch { return; }
141
- if (!d?.type) return;
142
-
138
+ const stopListening = onWidgetMessage((d) => {
143
139
  if (d.type === 'modal:close' && config.onClose) config.onClose();
144
140
  if (d.type === 'order:complete' && config.onOrderConfirmed) {
145
141
  config.onOrderConfirmed({
@@ -148,12 +144,14 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
148
144
  currency: (d.currency as string) ?? 'ZAR',
149
145
  });
150
146
  }
151
- }
152
- window.addEventListener('message', messageHandler);
147
+ if (d.type === 'cart:updated' && 'cart' in d) {
148
+ config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
149
+ }
150
+ });
153
151
 
154
152
  return {
155
153
  destroy() {
156
- window.removeEventListener('message', messageHandler);
154
+ stopListening();
157
155
  sessionManager.stop();
158
156
  unmount(component);
159
157
  },
@@ -174,19 +172,16 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
174
172
  },
175
173
  });
176
174
 
177
- function messageHandler(e: MessageEvent) {
178
- let d: Record<string, unknown>;
179
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
180
- catch { return; }
181
- if (!d?.type) return;
182
-
175
+ const stopListening = onWidgetMessage((d) => {
183
176
  if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
184
- }
185
- window.addEventListener('message', messageHandler);
177
+ if (d.type === 'cart:updated' && 'cart' in d) {
178
+ config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
179
+ }
180
+ });
186
181
 
187
182
  return {
188
183
  destroy() {
189
- window.removeEventListener('message', messageHandler);
184
+ stopListening();
190
185
  sessionManager.stop();
191
186
  unmount(component);
192
187
  },
@@ -1,3 +1,63 @@
1
- export function postMessage(data: Record<string, unknown>): void {
2
- window.parent?.postMessage(JSON.stringify(data), '*');
3
- }
1
+ // The widget's own components talk to each other over postMessage - a cart added in bw-configurator has to
2
+ // reach the bar in bw-cart and the modal in bw-checkout, which are separate custom elements with no shared
3
+ // Svelte tree. That makes every one of these messages same-window and same-origin, and the guards below exist
4
+ // to keep it that way.
5
+
6
+ /** Messages this widget sends itself. Anything else on the wire is not ours. */
7
+ const WIDGET_MESSAGE_TYPES = new Set([
8
+ 'cart:change',
9
+ 'cart:updated',
10
+ 'modal:open',
11
+ 'modal:close',
12
+ 'order:complete',
13
+ ]);
14
+
15
+ /**
16
+ * Sends a widget message.
17
+ *
18
+ * Targets this window's own origin rather than '*'. These payloads carry `cart`, and CheckoutCartDetailDto
19
+ * includes `cartToken` - a bearer credential that authorises reading, modifying and paying for the cart. With
20
+ * '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
21
+ * not necessarily anyone we trust.
22
+ */
23
+ export function postMessage(data: Record<string, unknown>): void {
24
+ window.parent?.postMessage(JSON.stringify(data), window.location.origin);
25
+ }
26
+
27
+ /**
28
+ * Subscribes to widget messages, ignoring anything that did not come from this widget.
29
+ *
30
+ * The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
31
+ * the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
32
+ * chat widget, a tag manager - could reach them with window.parent.postMessage and drive the widget: spoof
33
+ * the total on the payment consent screen, fire a forged order confirmation into the merchant's analytics, or
34
+ * close the modal mid-confirm on a card that has already been charged.
35
+ *
36
+ * Checking `source` is what does the real work: a message from another frame carries that frame's own window,
37
+ * never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
38
+ */
39
+ export function onWidgetMessage(handler: (data: Record<string, unknown>) => void): () => void {
40
+ function listener(e: MessageEvent) {
41
+ if (e.source !== window || e.origin !== window.location.origin) {
42
+ return;
43
+ }
44
+
45
+ let data: Record<string, unknown>;
46
+ try {
47
+ data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
48
+ } catch {
49
+ return;
50
+ }
51
+
52
+ // Anything without one of our own type values is someone else's traffic sharing this window - a library
53
+ // or the host page talking to itself - not something to hand to a widget handler.
54
+ if (typeof data?.type !== 'string' || !WIDGET_MESSAGE_TYPES.has(data.type)) {
55
+ return;
56
+ }
57
+
58
+ handler(data);
59
+ }
60
+
61
+ window.addEventListener('message', listener);
62
+ return () => window.removeEventListener('message', listener);
63
+ }
@@ -0,0 +1,40 @@
1
+ const SDK_URLS: Record<string, string> = {
2
+ prod: 'https://checkout.peachpayments.com/js/checkout.js',
3
+ qa: 'https://sandbox-checkout.peachpayments.com/js/checkout.js',
4
+ };
5
+
6
+ let loadPromise: Promise<void> | null = null;
7
+
8
+ // Lazily injects Peach's Copy&Pay SDK the first time PaymentPage mounts, so consumers embedding the widget
9
+ // (any of the three integration options) never have to know this script exists, let alone which
10
+ // environment's URL to point at - same window.BW_CHECKOUT_ENV convention elements/env.ts already uses for
11
+ // the checkout API base URL, with an explicit override for anything that doesn't fit that pattern.
12
+ export function loadPeachSdk(): Promise<void> {
13
+ if ((window as any).Checkout) {
14
+ return Promise.resolve();
15
+ }
16
+ if (loadPromise) {
17
+ return loadPromise;
18
+ }
19
+
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
+
24
+ loadPromise = new Promise((resolve, reject) => {
25
+ const existing = document.querySelector(`script[src="${src}"]`);
26
+ if (existing) {
27
+ existing.addEventListener('load', () => resolve());
28
+ existing.addEventListener('error', () => reject(new Error('Failed to load the Peach Payments SDK script')));
29
+ return;
30
+ }
31
+
32
+ const script = document.createElement('script');
33
+ script.src = src;
34
+ script.onload = () => resolve();
35
+ script.onerror = () => reject(new Error('Failed to load the Peach Payments SDK script'));
36
+ document.head.appendChild(script);
37
+ });
38
+
39
+ return loadPromise;
40
+ }