@code-collective/booking-widget 1.0.7 → 1.0.9

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 (39) hide show
  1. package/README.md +33 -2
  2. package/dist/booking-widget.css +1 -1
  3. package/dist/booking-widget.js +1038 -795
  4. package/dist/booking-widget.min.js +23 -9
  5. package/dist/booking-widget.umd.cjs +3 -2
  6. package/package.json +6 -2
  7. package/src/lib/CartBar.svelte +4 -11
  8. package/src/lib/CartBarView.svelte +2 -1
  9. package/src/lib/CartExpiredView.svelte +63 -0
  10. package/src/lib/CartExpiryGuard.svelte +410 -0
  11. package/src/lib/CartExpiryGuard.test.ts +331 -0
  12. package/src/lib/CartExpiryWatcher.svelte +46 -0
  13. package/src/lib/CartOverviewButton.svelte +6 -17
  14. package/src/lib/Checkout.svelte +55 -14
  15. package/src/lib/CheckoutModal.confirm-outcome.test.ts +91 -0
  16. package/src/lib/CheckoutModal.payment-timeout.test.ts +140 -0
  17. package/src/lib/CheckoutModal.svelte +805 -560
  18. package/src/lib/CountdownTimer.svelte +2 -4
  19. package/src/lib/EditBookingView.svelte +15 -4
  20. package/src/lib/PaymentPage.svelte +78 -61
  21. package/src/lib/ResultView.svelte +43 -3
  22. package/src/lib/StillTherePrompt.svelte +72 -0
  23. package/src/lib/TicketConfigurator.svelte +25 -3
  24. package/src/lib/UnitCounter.svelte +84 -8
  25. package/src/lib/WizardPage.svelte +11 -0
  26. package/src/lib/api.ts +89 -2
  27. package/src/lib/cart-expiry.ts +46 -0
  28. package/src/lib/cart-manager.ts +23 -6
  29. package/src/lib/client-types.ts +6 -0
  30. package/src/lib/elements/bw-cart.svelte +3 -12
  31. package/src/lib/elements/bw-checkout.svelte +9 -16
  32. package/src/lib/elements/bw-configurator.svelte +3 -13
  33. package/src/lib/elements/register.ts +33 -10
  34. package/src/lib/generated-types.ts +133 -3
  35. package/src/lib/index.ts +19 -28
  36. package/src/lib/messages.ts +75 -1
  37. package/src/lib/payment-attempt.ts +56 -0
  38. package/src/lib/test/fixtures.ts +107 -0
  39. package/src/lib/test/messages-mock.ts +34 -0
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';
@@ -35,6 +36,10 @@ interface BaseConfig {
35
36
  // consumer build a custom cart summary (item count, remaining time, item details) without calling the
36
37
  // checkout API directly.
37
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;
38
43
  }
39
44
 
40
45
  export interface ConfiguratorConfig extends BaseConfig {
@@ -96,12 +101,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
96
101
  });
97
102
 
98
103
  // 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
-
104
+ const stopListening = onWidgetMessage((d) => {
105
105
  if (d.type === 'cart:change' && config.onCartChange) {
106
106
  config.onCartChange({
107
107
  itemCount: (d.itemCount as number) ?? 0,
@@ -112,12 +112,12 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
112
112
  if (d.type === 'cart:updated' && 'cart' in d) {
113
113
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
114
114
  }
115
- }
116
- window.addEventListener('message', messageHandler);
115
+ if (d.type === 'cart:expired') config.onCartExpired?.();
116
+ });
117
117
 
118
118
  return {
119
119
  destroy() {
120
- window.removeEventListener('message', messageHandler);
120
+ stopListening();
121
121
  sessionManager.stop();
122
122
  unmount(component);
123
123
  },
@@ -125,7 +125,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
125
125
  }
126
126
 
127
127
  export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
128
- const { api, sessionManager } = bootstrap(config);
128
+ const { api, sessionManager, cartManager } = bootstrap(config);
129
129
 
130
130
  await sessionManager.ensureSession(config.checkoutKey ?? '');
131
131
 
@@ -133,18 +133,14 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
133
133
  target,
134
134
  props: {
135
135
  api,
136
+ cartManager,
136
137
  wizardPages: config.wizardPages,
137
138
  editPages: config.editPages,
138
139
  autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
139
140
  },
140
141
  });
141
142
 
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
-
143
+ const stopListening = onWidgetMessage((d) => {
148
144
  if (d.type === 'modal:close' && config.onClose) config.onClose();
149
145
  if (d.type === 'order:complete' && config.onOrderConfirmed) {
150
146
  config.onOrderConfirmed({
@@ -156,12 +152,12 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
156
152
  if (d.type === 'cart:updated' && 'cart' in d) {
157
153
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
158
154
  }
159
- }
160
- window.addEventListener('message', messageHandler);
155
+ if (d.type === 'cart:expired') config.onCartExpired?.();
156
+ });
161
157
 
162
158
  return {
163
159
  destroy() {
164
- window.removeEventListener('message', messageHandler);
160
+ stopListening();
165
161
  sessionManager.stop();
166
162
  unmount(component);
167
163
  },
@@ -182,22 +178,17 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
182
178
  },
183
179
  });
184
180
 
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
-
181
+ const stopListening = onWidgetMessage((d) => {
191
182
  if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
192
183
  if (d.type === 'cart:updated' && 'cart' in d) {
193
184
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
194
185
  }
195
- }
196
- window.addEventListener('message', messageHandler);
186
+ if (d.type === 'cart:expired') config.onCartExpired?.();
187
+ });
197
188
 
198
189
  return {
199
190
  destroy() {
200
- window.removeEventListener('message', messageHandler);
191
+ stopListening();
201
192
  sessionManager.stop();
202
193
  unmount(component);
203
194
  },
@@ -1,3 +1,77 @@
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
+ // 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
+ 'cart:expired',
15
+ 'modal:open',
16
+ 'modal:close',
17
+ '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
+ 'payment:started',
22
+ '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
+ 'payment:timed-out',
27
+ ]);
28
+
29
+ /**
30
+ * Sends a widget message.
31
+ *
32
+ * Targets this window's own origin rather than '*'. These payloads carry `cart`, and CheckoutCartDetailDto
33
+ * includes `cartToken` - a bearer credential that authorises reading, modifying and paying for the cart. With
34
+ * '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
35
+ * not necessarily anyone we trust.
36
+ */
1
37
  export function postMessage(data: Record<string, unknown>): void {
2
- window.parent?.postMessage(JSON.stringify(data), '*');
38
+ window.parent?.postMessage(JSON.stringify(data), window.location.origin);
39
+ }
40
+
41
+ /**
42
+ * Subscribes to widget messages, ignoring anything that did not come from this widget.
43
+ *
44
+ * The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
45
+ * the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
46
+ * chat widget, a tag manager - could reach them with window.parent.postMessage and drive the widget: spoof
47
+ * the total on the payment consent screen, fire a forged order confirmation into the merchant's analytics, or
48
+ * close the modal mid-confirm on a card that has already been charged.
49
+ *
50
+ * Checking `source` is what does the real work: a message from another frame carries that frame's own window,
51
+ * never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
52
+ */
53
+ export function onWidgetMessage(handler: (data: Record<string, unknown>) => void): () => void {
54
+ function listener(e: MessageEvent) {
55
+ if (e.source !== window || e.origin !== window.location.origin) {
56
+ return;
57
+ }
58
+
59
+ let data: Record<string, unknown>;
60
+ try {
61
+ data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
62
+ } catch {
63
+ return;
64
+ }
65
+
66
+ // Anything without one of our own type values is someone else's traffic sharing this window - a library
67
+ // or the host page talking to itself - not something to hand to a widget handler.
68
+ if (typeof data?.type !== 'string' || !WIDGET_MESSAGE_TYPES.has(data.type)) {
69
+ return;
70
+ }
71
+
72
+ handler(data);
73
+ }
74
+
75
+ window.addEventListener('message', listener);
76
+ return () => window.removeEventListener('message', listener);
3
77
  }
@@ -0,0 +1,56 @@
1
+ // The Peach checkout currently open for the cart, persisted so a page reload mid-payment does not lose it.
2
+ //
3
+ // Once payCart has run, the server holds the cart in AwaitingPaymentConfirmation: exempt from expiry, refusing
4
+ // edits and a second payCart alike, until Peach's webhook settles the attempt or the widget abandons it with
5
+ // the checkoutId it was given. Before this, that id lived only in CheckoutModal's component state, so a reload
6
+ // left the shopper with a cart nothing could act on and a guard that could not tell it was locked. Same store
7
+ // and same lifetime as the cart token itself (see cart-manager.ts for why sessionStorage) - it is only ever
8
+ // meaningful for the cart it was minted against, which is why the token is recorded with it.
9
+
10
+ const STORAGE_KEY = 'morii-checkout-payment';
11
+
12
+ const storage: Storage | null = typeof sessionStorage !== 'undefined' ? sessionStorage : null;
13
+
14
+ export interface PaymentAttempt {
15
+ checkoutId: string;
16
+ entityId: string;
17
+ }
18
+
19
+ interface StoredAttempt extends PaymentAttempt {
20
+ cartToken: string;
21
+ }
22
+
23
+ export function rememberPaymentAttempt(cartToken: string, attempt: PaymentAttempt): void {
24
+ const stored: StoredAttempt = { cartToken, ...attempt };
25
+ try {
26
+ storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
27
+ } catch {
28
+ // storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
29
+ }
30
+ }
31
+
32
+ // The attempt recorded for this cart, if any. One recorded for a different cart is stale - that cart is gone,
33
+ // and the attempt with it - so it is dropped here rather than left to confuse the next reader.
34
+ export function recallPaymentAttempt(cartToken: string): PaymentAttempt | null {
35
+ try {
36
+ const raw = storage?.getItem(STORAGE_KEY);
37
+ if (!raw) return null;
38
+
39
+ const stored: StoredAttempt = JSON.parse(raw);
40
+ if (!cartToken || stored.cartToken !== cartToken || !stored.checkoutId || !stored.entityId) {
41
+ forgetPaymentAttempt();
42
+ return null;
43
+ }
44
+ return { checkoutId: stored.checkoutId, entityId: stored.entityId };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function forgetPaymentAttempt(): void {
51
+ try {
52
+ storage?.removeItem(STORAGE_KEY);
53
+ } catch {
54
+ // storage unavailable
55
+ }
56
+ }
@@ -0,0 +1,107 @@
1
+ import { vi } from 'vitest';
2
+ import type { BookingApi } from '../api';
3
+ import { ApiError } from '../api';
4
+ import type { CartManager } from '../cart-manager';
5
+ import type { CheckoutCartDetailDto, CheckoutProductDto } from '../client-types';
6
+
7
+ export const CART_TOKEN = 'cart-token-1';
8
+
9
+ /** A one-item cart whose idle deadline is `msFromNow` away and whose ceiling is 35 minutes from creation. */
10
+ export function buildCart(msFromNow: number, options: { ceilingMsFromNow?: number } = {}): CheckoutCartDetailDto {
11
+ const now = Date.now();
12
+ return {
13
+ cartToken: CART_TOKEN,
14
+ issuedAt: new Date(now - 60_000).toISOString(),
15
+ idleExpiresAt: new Date(now + msFromNow).toISOString(),
16
+ absoluteExpiresAt: new Date(now + (options.ceilingMsFromNow ?? 35 * 60_000)).toISOString(),
17
+ items: [
18
+ {
19
+ id: 'item-1',
20
+ bookingUuid: 'booking-1',
21
+ productId: 'product-1',
22
+ optionId: 'option-1',
23
+ unitItems: [{ unitId: 'adult' }],
24
+ availabilityId: '2026-10-01T09:00:00+02:00',
25
+ amount: 1000,
26
+ currencyCode: 'ZAR',
27
+ currencyPrecision: 2,
28
+ },
29
+ ],
30
+ };
31
+ }
32
+
33
+ export const product: CheckoutProductDto = {
34
+ id: 'product-1',
35
+ title: 'City Tour Pass',
36
+ options: [
37
+ {
38
+ id: 'option-1',
39
+ title: 'Day ticket',
40
+ units: [{ id: 'adult', title: 'Adult', pricing: [{ retail: 1000, currency: 'ZAR', currencyPrecision: 2 }] }],
41
+ },
42
+ ],
43
+ } as unknown as CheckoutProductDto;
44
+
45
+ export function apiError(status: number, code?: string): ApiError {
46
+ return new ApiError(status, code ? { error: code, errorMessage: code } : undefined);
47
+ }
48
+
49
+ /**
50
+ * Every BookingApi member as a vi.fn, with the cart-shaped ones answering sensibly for a live cart by
51
+ * default. Tests override the one or two members a scenario turns on with mockResolvedValue/
52
+ * mockRejectedValue/mockImplementation.
53
+ */
54
+ export function fakeApi(cart: CheckoutCartDetailDto): BookingApi & { [K in keyof BookingApi]: BookingApi[K] } {
55
+ return {
56
+ sessionToken: 'session-token-1',
57
+ cartToken: CART_TOKEN,
58
+ startSession: vi.fn(),
59
+ refreshSession: vi.fn(),
60
+ getProduct: vi.fn().mockResolvedValue(product),
61
+ getAvailabilityCalendar: vi.fn(),
62
+ getAvailability: vi.fn(),
63
+ createCart: vi.fn(),
64
+ getCart: vi.fn().mockResolvedValue(cart),
65
+ addCartItem: vi.fn(),
66
+ updateCartItem: vi.fn(),
67
+ removeCartItem: vi.fn(),
68
+ payCart: vi.fn(),
69
+ confirmCart: vi.fn(),
70
+ extendCart: vi.fn().mockResolvedValue(cart),
71
+ abandonPayment: vi.fn().mockResolvedValue(undefined),
72
+ };
73
+ }
74
+
75
+ export function fakeCartManager(hasCart = true): CartManager {
76
+ return {
77
+ hasCart,
78
+ cartToken: CART_TOKEN,
79
+ reset: vi.fn(),
80
+ ensureCart: vi.fn(),
81
+ } as unknown as CartManager;
82
+ }
83
+
84
+ /**
85
+ * A stand-in for Peach's embedded Checkout SDK global, so PaymentPage mounts for real and the test can drive
86
+ * Peach's own callback contract - the handlers PaymentPage registers are captured here.
87
+ */
88
+ export function installFakePeachSdk(): { handlers: () => Record<string, () => void>; unmount: ReturnType<typeof vi.fn> } {
89
+ let captured: Record<string, () => void> = {};
90
+ const unmount = vi.fn();
91
+ (window as unknown as { Checkout: unknown }).Checkout = {
92
+ initiate: (options: { eventHandlers: Record<string, () => void> }) => {
93
+ captured = options.eventHandlers;
94
+ return { render: vi.fn(), unmount };
95
+ },
96
+ };
97
+ return { handlers: () => captured, unmount };
98
+ }
99
+
100
+ /** Flushes Svelte's effect queue and any microtask chain a component kicked off. */
101
+ export async function settle(): Promise<void> {
102
+ const { tick } = await import('svelte');
103
+ for (let i = 0; i < 5; i++) {
104
+ await tick();
105
+ await Promise.resolve();
106
+ }
107
+ }
@@ -0,0 +1,34 @@
1
+ // A synchronous, in-memory stand-in for messages.ts, installed with `vi.mock('./messages', ...)` by tests
2
+ // that mount the widget's components. The real module rides window.postMessage, which is asynchronous and
3
+ // which jsdom delivers without the `source`/`origin` the real onWidgetMessage guards on - so under jsdom the
4
+ // components would never hear each other. This keeps the exact same contract (postMessage in, every
5
+ // subscribed handler out) but delivers inline, and records everything posted so a test can assert on it.
6
+
7
+ type Handler = (data: Record<string, unknown>) => void;
8
+
9
+ const handlers = new Set<Handler>();
10
+
11
+ /** Every message posted since the last reset, oldest first. */
12
+ export const posted: Record<string, unknown>[] = [];
13
+
14
+ export function postMessage(data: Record<string, unknown>): void {
15
+ posted.push(data);
16
+ for (const handler of [...handlers]) {
17
+ handler(data);
18
+ }
19
+ }
20
+
21
+ export function onWidgetMessage(handler: Handler): () => void {
22
+ handlers.add(handler);
23
+ return () => handlers.delete(handler);
24
+ }
25
+
26
+ export function resetMessages(): void {
27
+ handlers.clear();
28
+ posted.length = 0;
29
+ }
30
+
31
+ /** The types of every message posted so far, in order - the usual thing a test wants to assert on. */
32
+ export function postedTypes(): string[] {
33
+ return posted.map((m) => String(m.type));
34
+ }