@code-collective/booking-widget 1.0.7 → 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,16 @@ import type { BookingApi } from './api';
3
3
 
4
4
  const STORAGE_KEY = 'morii-checkout-cart';
5
5
 
6
+ // sessionStorage, not localStorage: the cart token is a full pay/confirm capability, and the widget ships as
7
+ // a shadow:none custom element, so it runs in the *merchant's* top-level origin - its web storage is shared
8
+ // with every other script on that page. sessionStorage narrows the exposure to the one tab and clears it when
9
+ // that tab closes (it still survives reloads and same-tab navigation, so the cart-survives-reload behaviour is
10
+ // unchanged), rather than persisting the token across all tabs indefinitely. The token is additionally bound
11
+ // to this origin server-side (see CheckoutCart.OriginAtIssue), so it cannot be lifted and replayed from
12
+ // another site; what remains - a hostile script on the merchant's own page - is inherent to shadow:none
13
+ // embedding and can only be fully closed by isolating the widget in its own iframe origin.
14
+ const storage: Storage | null = typeof sessionStorage !== 'undefined' ? sessionStorage : null;
15
+
6
16
  interface StoredCart {
7
17
  cartToken: string;
8
18
  absoluteExpiresAt: string;
@@ -40,10 +50,13 @@ export class CartManager {
40
50
  // ensureCart to mint a fresh one after the server refuses an operation on the cart it was given.
41
51
  reset(): void {
42
52
  this.cart = null;
53
+ // Also cleared on the api - loadFromStorage sets it there, so leaving it behind would keep sending the
54
+ // dropped cart's token on every subsequent request.
55
+ this.api.cartToken = '';
43
56
  try {
44
- localStorage.removeItem(STORAGE_KEY);
57
+ storage?.removeItem(STORAGE_KEY);
45
58
  } catch {
46
- // localStorage unavailable (e.g. iframe sandbox)
59
+ // storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
47
60
  }
48
61
  }
49
62
 
@@ -59,20 +72,20 @@ export class CartManager {
59
72
  absoluteExpiresAt: this.cart.absoluteExpiresAt ?? '',
60
73
  };
61
74
  try {
62
- localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
75
+ storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
63
76
  } catch {
64
- // localStorage unavailable (e.g. iframe sandbox)
77
+ // storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
65
78
  }
66
79
  }
67
80
 
68
81
  private loadFromStorage(): void {
69
82
  try {
70
- const raw = localStorage.getItem(STORAGE_KEY);
83
+ const raw = storage?.getItem(STORAGE_KEY);
71
84
  if (!raw) return;
72
85
 
73
86
  const stored: StoredCart = JSON.parse(raw);
74
87
  if (new Date(stored.absoluteExpiresAt).getTime() <= Date.now()) {
75
- localStorage.removeItem(STORAGE_KEY);
88
+ storage?.removeItem(STORAGE_KEY);
76
89
  return;
77
90
  }
78
91
 
@@ -29,6 +29,12 @@ export interface PaymentStatus {
29
29
  outcome: PaymentOutcome;
30
30
  resultCode?: string;
31
31
  resultDescription?: string;
32
+ // Only meaningful for outcome 'failed'. A card that was actually charged - which is every 'failed' this
33
+ // gateway produces once confirmation itself is what failed, not the payment - must never be offered a
34
+ // "try again" that re-opens the card form, since the shopper would be charged a second time for a booking
35
+ // that already has their money against it. Defaults true (the ordinary declined/expired/errored case,
36
+ // where nothing was charged and retrying is exactly correct) so existing callers need not set it.
37
+ retryPayment?: boolean;
32
38
  }
33
39
 
34
40
  export interface CartItem {
@@ -3,6 +3,7 @@
3
3
  <script lang="ts">
4
4
  import CartOverview from '../CartOverview.svelte';
5
5
  import type { CartOverviewDisplay } from '../config';
6
+ import { onWidgetMessage } from '../messages';
6
7
  import { getSharedServices } from './shared';
7
8
 
8
9
  let {
@@ -19,21 +20,11 @@
19
20
  host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
20
21
  }
21
22
 
22
- function handleMessage(e: MessageEvent) {
23
- let d: Record<string, unknown>;
24
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
25
- catch { return; }
26
- if (!d?.type) return;
27
-
23
+ $effect(() => onWidgetMessage((d) => {
28
24
  if (d.type === 'modal:open') {
29
25
  dispatch('bw:checkout', {});
30
26
  }
31
- }
32
-
33
- $effect(() => {
34
- window.addEventListener('message', handleMessage);
35
- return () => window.removeEventListener('message', handleMessage);
36
- });
27
+ }));
37
28
  </script>
38
29
 
39
30
  {#if ready}
@@ -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', {}); }
@@ -4,6 +4,7 @@ import { ApiClient } from '../api';
4
4
  import { SessionManager } from '../session-manager';
5
5
  import { CartManager } from '../cart-manager';
6
6
  import { defaultApiBaseUrl } from './env';
7
+ import { onWidgetMessage } from '../messages';
7
8
 
8
9
  // Bootstrap shared services BEFORE element imports trigger connectedCallback.
9
10
  // Elements read from window.__bwServices instead of importing shared.ts,
@@ -26,11 +27,8 @@ import './bw-checkout.svelte';
26
27
  // cart TicketConfigurator/CheckoutModal already fetch for their own posting - see those files' own comments -
27
28
  // so a consumer can build a custom cart summary (item count, remaining time, item details) without calling
28
29
  // the checkout API directly.
29
- window.addEventListener('message', (e) => {
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 === 'cart:updated' && 'cart' in d) {
30
+ onWidgetMessage((d) => {
31
+ if (d.type === 'cart:updated' && 'cart' in d) {
34
32
  window.dispatchEvent(new CustomEvent('bw:cart-updated', { detail: { cart: d.cart } }));
35
33
  }
36
34
  });
@@ -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
@@ -6,6 +6,7 @@ import type { CheckoutCartDetailDto } from './client-types';
6
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';
@@ -96,12 +97,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
96
97
  });
97
98
 
98
99
  // Listen for postMessage events and forward to callbacks
99
- function messageHandler(e: MessageEvent) {
100
- let d: Record<string, unknown>;
101
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
102
- catch { return; }
103
- if (!d?.type) return;
104
-
100
+ const stopListening = onWidgetMessage((d) => {
105
101
  if (d.type === 'cart:change' && config.onCartChange) {
106
102
  config.onCartChange({
107
103
  itemCount: (d.itemCount as number) ?? 0,
@@ -112,12 +108,11 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
112
108
  if (d.type === 'cart:updated' && 'cart' in d) {
113
109
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
114
110
  }
115
- }
116
- window.addEventListener('message', messageHandler);
111
+ });
117
112
 
118
113
  return {
119
114
  destroy() {
120
- window.removeEventListener('message', messageHandler);
115
+ stopListening();
121
116
  sessionManager.stop();
122
117
  unmount(component);
123
118
  },
@@ -125,7 +120,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
125
120
  }
126
121
 
127
122
  export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
128
- const { api, sessionManager } = bootstrap(config);
123
+ const { api, sessionManager, cartManager } = bootstrap(config);
129
124
 
130
125
  await sessionManager.ensureSession(config.checkoutKey ?? '');
131
126
 
@@ -133,18 +128,14 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
133
128
  target,
134
129
  props: {
135
130
  api,
131
+ cartManager,
136
132
  wizardPages: config.wizardPages,
137
133
  editPages: config.editPages,
138
134
  autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
139
135
  },
140
136
  });
141
137
 
142
- function messageHandler(e: MessageEvent) {
143
- let d: Record<string, unknown>;
144
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
145
- catch { return; }
146
- if (!d?.type) return;
147
-
138
+ const stopListening = onWidgetMessage((d) => {
148
139
  if (d.type === 'modal:close' && config.onClose) config.onClose();
149
140
  if (d.type === 'order:complete' && config.onOrderConfirmed) {
150
141
  config.onOrderConfirmed({
@@ -156,12 +147,11 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
156
147
  if (d.type === 'cart:updated' && 'cart' in d) {
157
148
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
158
149
  }
159
- }
160
- window.addEventListener('message', messageHandler);
150
+ });
161
151
 
162
152
  return {
163
153
  destroy() {
164
- window.removeEventListener('message', messageHandler);
154
+ stopListening();
165
155
  sessionManager.stop();
166
156
  unmount(component);
167
157
  },
@@ -182,22 +172,16 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
182
172
  },
183
173
  });
184
174
 
185
- function messageHandler(e: MessageEvent) {
186
- let d: Record<string, unknown>;
187
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
188
- catch { return; }
189
- if (!d?.type) return;
190
-
175
+ const stopListening = onWidgetMessage((d) => {
191
176
  if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
192
177
  if (d.type === 'cart:updated' && 'cart' in d) {
193
178
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
194
179
  }
195
- }
196
- window.addEventListener('message', messageHandler);
180
+ });
197
181
 
198
182
  return {
199
183
  destroy() {
200
- window.removeEventListener('message', messageHandler);
184
+ stopListening();
201
185
  sessionManager.stop();
202
186
  unmount(component);
203
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
+ }