@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.
- package/README.md +19 -1
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +897 -681
- package/dist/booking-widget.min.js +16 -11
- package/dist/booking-widget.umd.cjs +3 -2
- package/package.json +6 -2
- package/src/lib/CartBarView.svelte +2 -1
- package/src/lib/CartExpiredView.svelte +63 -0
- package/src/lib/CartExpiryGuard.svelte +410 -0
- package/src/lib/CartExpiryGuard.test.ts +331 -0
- package/src/lib/CartExpiryWatcher.svelte +46 -0
- package/src/lib/CartOverviewButton.svelte +2 -5
- package/src/lib/Checkout.svelte +43 -1
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +91 -0
- package/src/lib/CheckoutModal.payment-timeout.test.ts +140 -0
- package/src/lib/CheckoutModal.svelte +805 -652
- package/src/lib/CountdownTimer.svelte +2 -4
- package/src/lib/PaymentPage.svelte +20 -1
- package/src/lib/StillTherePrompt.svelte +72 -0
- package/src/lib/UnitCounter.svelte +84 -8
- package/src/lib/WizardPage.svelte +11 -0
- package/src/lib/api.ts +69 -0
- package/src/lib/cart-expiry.ts +46 -0
- package/src/lib/cart-manager.ts +4 -0
- package/src/lib/elements/register.ts +30 -5
- package/src/lib/generated-types.ts +132 -3
- package/src/lib/index.ts +7 -0
- package/src/lib/messages.ts +77 -63
- package/src/lib/payment-attempt.ts +56 -0
- package/src/lib/test/fixtures.ts +107 -0
- package/src/lib/test/messages-mock.ts +34 -0
|
@@ -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 {
|
package/src/lib/messages.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
'
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|