@code-collective/booking-widget 1.0.8 → 1.0.10

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.
@@ -125,6 +125,40 @@ export interface paths {
125
125
  patch?: never;
126
126
  trace?: never;
127
127
  };
128
+ "/v1/checkout/cart/extend": {
129
+ parameters: {
130
+ query?: never;
131
+ header?: never;
132
+ path?: never;
133
+ cookie?: never;
134
+ };
135
+ get?: never;
136
+ put?: never;
137
+ /** Extend checkout cart */
138
+ post: operations["ExtendCheckoutCart_v1"];
139
+ delete?: never;
140
+ options?: never;
141
+ head?: never;
142
+ patch?: never;
143
+ trace?: never;
144
+ };
145
+ "/v1/checkout/cart/abandon-payment": {
146
+ parameters: {
147
+ query?: never;
148
+ header?: never;
149
+ path?: never;
150
+ cookie?: never;
151
+ };
152
+ get?: never;
153
+ put?: never;
154
+ /** Abandon checkout cart payment */
155
+ post: operations["AbandonCheckoutCartPayment_v1"];
156
+ delete?: never;
157
+ options?: never;
158
+ head?: never;
159
+ patch?: never;
160
+ trace?: never;
161
+ };
128
162
  "/v1/checkout/supplier": {
129
163
  parameters: {
130
164
  query?: never;
@@ -210,6 +244,22 @@ export interface paths {
210
244
  patch?: never;
211
245
  trace?: never;
212
246
  };
247
+ "/webhooks/peach": {
248
+ parameters: {
249
+ query?: never;
250
+ header?: never;
251
+ path?: never;
252
+ cookie?: never;
253
+ };
254
+ get?: never;
255
+ put?: never;
256
+ post: operations["ReceivePeachWebhook"];
257
+ delete?: never;
258
+ options?: never;
259
+ head?: never;
260
+ patch?: never;
261
+ trace?: never;
262
+ };
213
263
  }
214
264
  export type webhooks = Record<string, never>;
215
265
  export interface components {
@@ -228,9 +278,6 @@ export interface components {
228
278
  notes?: string | null;
229
279
  /** Format: int32 */
230
280
  amount?: number;
231
- currencyCode?: string | null;
232
- /** Format: int32 */
233
- currencyPrecision?: number;
234
281
  };
235
282
  CheckoutAvailabilityCalendarDto: {
236
283
  /**
@@ -311,6 +358,9 @@ export interface components {
311
358
  */
312
359
  localDate?: string;
313
360
  };
361
+ CheckoutCartAbandonPaymentDto: {
362
+ checkoutId?: string | null;
363
+ };
314
364
  CheckoutCartConfirmItemResultDto: {
315
365
  /** Format: uuid */
316
366
  bookingUuid?: string;
@@ -393,6 +443,18 @@ export interface components {
393
443
  */
394
444
  absoluteExpiresAt?: string;
395
445
  };
446
+ CheckoutFaqDto: {
447
+ question: string | null;
448
+ answer: string | null;
449
+ };
450
+ CheckoutMediaDto: {
451
+ src: string | null;
452
+ type: string | null;
453
+ rel: string | null;
454
+ title?: string | null;
455
+ caption?: string | null;
456
+ copyright?: string | null;
457
+ };
396
458
  CheckoutOpeningHoursDto: {
397
459
  from: string | null;
398
460
  to: string | null;
@@ -429,7 +491,14 @@ export interface components {
429
491
  id: string | null;
430
492
  internalName: string | null;
431
493
  title?: string | null;
494
+ shortDescription?: string | null;
432
495
  description?: string | null;
496
+ media?: components["schemas"]["CheckoutMediaDto"][] | null;
497
+ /** Format: int32 */
498
+ durationMinutesFrom?: number | null;
499
+ /** Format: int32 */
500
+ durationMinutesTo?: number | null;
501
+ faqs?: components["schemas"]["CheckoutFaqDto"][] | null;
433
502
  options: components["schemas"]["CheckoutOptionDto"][] | null;
434
503
  };
435
504
  CheckoutSessionDto: {
@@ -716,6 +785,48 @@ export interface operations {
716
785
  };
717
786
  };
718
787
  };
788
+ ExtendCheckoutCart_v1: {
789
+ parameters: {
790
+ query?: never;
791
+ header?: never;
792
+ path?: never;
793
+ cookie?: never;
794
+ };
795
+ requestBody?: never;
796
+ responses: {
797
+ /** @description OK */
798
+ 200: {
799
+ headers: {
800
+ [name: string]: unknown;
801
+ };
802
+ content: {
803
+ "application/json": components["schemas"]["CheckoutCartDetailDto"];
804
+ };
805
+ };
806
+ };
807
+ };
808
+ AbandonCheckoutCartPayment_v1: {
809
+ parameters: {
810
+ query?: never;
811
+ header?: never;
812
+ path?: never;
813
+ cookie?: never;
814
+ };
815
+ requestBody: {
816
+ content: {
817
+ "application/json": components["schemas"]["CheckoutCartAbandonPaymentDto"];
818
+ };
819
+ };
820
+ responses: {
821
+ /** @description No Content */
822
+ 204: {
823
+ headers: {
824
+ [name: string]: unknown;
825
+ };
826
+ content?: never;
827
+ };
828
+ };
829
+ };
719
830
  CheckoutGetSupplier_v1: {
720
831
  parameters: {
721
832
  query?: never;
@@ -826,4 +937,22 @@ export interface operations {
826
937
  };
827
938
  };
828
939
  };
940
+ ReceivePeachWebhook: {
941
+ parameters: {
942
+ query?: never;
943
+ header?: never;
944
+ path?: never;
945
+ cookie?: never;
946
+ };
947
+ requestBody?: never;
948
+ responses: {
949
+ /** @description OK */
950
+ 200: {
951
+ headers: {
952
+ [name: string]: unknown;
953
+ };
954
+ content?: never;
955
+ };
956
+ };
957
+ };
829
958
  }
package/src/lib/index.ts CHANGED
@@ -36,6 +36,10 @@ interface BaseConfig {
36
36
  // consumer build a custom cart summary (item count, remaining time, item details) without calling the
37
37
  // checkout API directly.
38
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;
39
43
  }
40
44
 
41
45
  export interface ConfiguratorConfig extends BaseConfig {
@@ -108,6 +112,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
108
112
  if (d.type === 'cart:updated' && 'cart' in d) {
109
113
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
110
114
  }
115
+ if (d.type === 'cart:expired') config.onCartExpired?.();
111
116
  });
112
117
 
113
118
  return {
@@ -147,6 +152,7 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
147
152
  if (d.type === 'cart:updated' && 'cart' in d) {
148
153
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
149
154
  }
155
+ if (d.type === 'cart:expired') config.onCartExpired?.();
150
156
  });
151
157
 
152
158
  return {
@@ -177,6 +183,7 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
177
183
  if (d.type === 'cart:updated' && 'cart' in d) {
178
184
  config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
179
185
  }
186
+ if (d.type === 'cart:expired') config.onCartExpired?.();
180
187
  });
181
188
 
182
189
  return {
@@ -1,63 +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
- '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
- }
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
+ */
37
+ export function postMessage(data: Record<string, unknown>): void {
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);
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
+ }