@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/api.ts CHANGED
@@ -18,9 +18,70 @@ import { dateKey } from './utils';
18
18
  // Lets a caller distinguish "the server said no, and specifically why" (e.g. 402 - not paid yet, retryable
19
19
  // while Peach's webhook is still in flight) from any other failure, without parsing the message string.
20
20
  export class ApiError extends Error {
21
- constructor(public readonly status: number) {
21
+ constructor(
22
+ public readonly status: number,
23
+ public readonly body?: unknown,
24
+ ) {
22
25
  super(`API error ${status}`);
23
26
  }
27
+
28
+ // The server's own machine-readable reason, where it sends one. Several distinct failures share a status -
29
+ // a price that moved and a malformed request are both 400 - and only this tells them apart.
30
+ get code(): string | undefined {
31
+ return typeof this.body === 'object' && this.body !== null && 'error' in this.body
32
+ ? String((this.body as { error: unknown }).error)
33
+ : undefined;
34
+ }
35
+ }
36
+
37
+ // The price of an item changed between the widget displaying it and the item being added, and the amount the
38
+ // customer agreed to is no longer one the server will accept. Recoverable, but only by showing the customer
39
+ // the new price - see PRICE_MISMATCH handling in TicketConfigurator/EditBookingView.
40
+ export function isPriceMismatch(e: unknown): e is ApiError {
41
+ return e instanceof ApiError && e.status === 400 && e.code === 'PRICE_MISMATCH';
42
+ }
43
+
44
+ // The cart token the widget holds no longer names a live cart - it expired (idle or absolute) or was never
45
+ // valid. Every cart route answers this the same way, and the only recovery is a fresh cart.
46
+ export function isInvalidCart(e: unknown): e is ApiError {
47
+ return e instanceof ApiError && e.status === 401;
48
+ }
49
+
50
+ // The cart is past the point of being modified or extended - a payment is in flight for it or already done
51
+ // (CheckoutCartEndpoints.CartLockedResult). Unlike the other 409s the API sends, this one carries no code.
52
+ export function isCartLocked(e: unknown): e is ApiError {
53
+ return e instanceof ApiError && e.status === 409 && e.code === undefined;
54
+ }
55
+
56
+ // extendCart was refused because the cart's one clock has already run out. Only ever answered while a payment
57
+ // is in flight - anywhere else an expired cart is a plain 401 - because such a cart still validates so its
58
+ // charge can be confirmed, yet its time is up. Not a 401 on purpose: the widget must not drop the cart while
59
+ // a charge may be landing, but tear the attempt down through abandonPayment (Peach's real status permitting).
60
+ export function isCartExpired(e: unknown): e is ApiError {
61
+ return e instanceof ApiError && e.status === 409 && e.code === 'CART_EXPIRED';
62
+ }
63
+
64
+ // abandonPayment was refused because Peach's webhook already settled this attempt - most likely as paid. The
65
+ // shopper's money is against the cart, so the right move is to confirm it, not to report it expired.
66
+ export function isPaymentSettled(e: unknown): e is ApiError {
67
+ return e instanceof ApiError && e.status === 409 && e.code === 'PAYMENT_SETTLED';
68
+ }
69
+
70
+ // abandonPayment was refused because Peach still has this attempt in flight (a 3-D Secure check or similar has
71
+ // not reached a final outcome). Not paid yet, but a charge may still land, so the cart must not be reopened -
72
+ // wait for the outcome instead. Peach times a pending attempt out within about 30 minutes, after which
73
+ // abandoning may be accepted again.
74
+ export function isPaymentPending(e: unknown): e is ApiError {
75
+ return e instanceof ApiError && e.status === 409 && e.code === 'PAYMENT_PENDING';
76
+ }
77
+
78
+ // confirmCart was refused because Peach's webhook already landed with a decline (or other non-success
79
+ // outcome) for this attempt - there is no charge to confirm and waiting longer will not change that. Distinct
80
+ // from the plain 402 confirmCart otherwise answers with (not paid yet, keep retrying while the webhook is
81
+ // still in flight): without this, a declined card retries the same way a slow-but-live one does, and only
82
+ // ever surfaces as "still confirming", never as the decline it actually was.
83
+ export function isPaymentFailed(e: unknown): e is ApiError {
84
+ return e instanceof ApiError && e.status === 409 && e.code === 'PAYMENT_FAILED';
24
85
  }
25
86
 
26
87
  export interface BookingApi {
@@ -51,6 +112,15 @@ export interface BookingApi {
51
112
  removeCartItem(itemId: string): Promise<void>;
52
113
  payCart(contact: OctoContact): Promise<CheckoutCartPaymentInitiationDto>;
53
114
  confirmCart(): Promise<CheckoutCartConfirmResultDto>;
115
+ // Slides the cart's idle window out to a full window from now, clamped at its absolute ceiling, and
116
+ // re-extends the OCTO holds behind it. There is no refusal to handle: a cart already at its ceiling comes
117
+ // back with the deadline it already had (see isAtExpiryCeiling). Returns the cart with its new expiry so the
118
+ // countdown resumes from the server's numbers rather than a local guess.
119
+ extendCart(): Promise<CheckoutCartDetailDto>;
120
+ // Reports a specific Peach attempt dead without an outcome so the server reopens the cart for a new one -
121
+ // used by CheckoutModal itself, which knows the checkoutId Peach's own callback fired for. See
122
+ // isPaymentSettled for the one refusal that changes what the caller does next.
123
+ abandonPayment(checkoutId: string): Promise<void>;
54
124
  }
55
125
 
56
126
  export class ApiClient implements BookingApi {
@@ -78,7 +148,7 @@ export class ApiClient implements BookingApi {
78
148
 
79
149
  private unwrap<T>(result: { data?: T; error?: unknown; response: Response }): T {
80
150
  if (result.data === undefined) {
81
- throw new ApiError(result.response.status);
151
+ throw new ApiError(result.response.status, result.error);
82
152
  }
83
153
  return result.data;
84
154
  }
@@ -210,4 +280,21 @@ export class ApiClient implements BookingApi {
210
280
  await this.client.POST('/v1/checkout/cart/confirm'),
211
281
  );
212
282
  }
283
+
284
+ async extendCart(): Promise<CheckoutCartDetailDto> {
285
+ return this.unwrap(
286
+ await this.client.POST('/v1/checkout/cart/extend'),
287
+ );
288
+ }
289
+
290
+ async abandonPayment(checkoutId: string): Promise<void> {
291
+ const result = await this.client.POST('/v1/checkout/cart/abandon-payment', {
292
+ body: { checkoutId },
293
+ });
294
+ // 204 on success, so there is no data to unwrap - same shape as removeCartItem. The error body is kept:
295
+ // the 409s here carry a code the caller branches on (see isPaymentSettled).
296
+ if (result.response.status >= 400) {
297
+ throw new ApiError(result.response.status, result.error);
298
+ }
299
+ }
213
300
  }
@@ -0,0 +1,46 @@
1
+ import type { CheckoutCartDetailDto } from './client-types';
2
+
3
+ // Whether the "are you still there?" prompt - and with it the shopper's ability to extend the cart - is
4
+ // offered at all. Switched off for now: the cart gets one flat window (CheckoutCartService.IdleDuration,
5
+ // 20 minutes), the countdown shows on every step, and at 0:00 the shopper sees "Your cart has expired" and
6
+ // starts again. The prompt, the extend call and everything the server does for them stay in place behind
7
+ // this flag so a later change can turn them back on without rebuilding any of it. CartExpiryGuard takes it
8
+ // as a prop defaulting to this, so tests can exercise the prompt with it on.
9
+ export const EXTENSION_ENABLED = false;
10
+
11
+ // How long before the deadline the page-wide "are you still shopping?" prompt appears, when it is enabled at
12
+ // all (EXTENSION_ENABLED above). The one source of truth for the lead time - the guard's prompt reads it from
13
+ // here. Three minutes against the server's 20-minute idle window (CheckoutCartService.IdleDuration) would put
14
+ // the prompt at 17 minutes on a fresh cart, with the cart expiring at 20 if it went unanswered.
15
+ export const EXPIRY_WARNING_MS = 3 * 60 * 1000;
16
+
17
+ export function formatRemaining(ms: number): string {
18
+ const clamped = Math.max(0, ms);
19
+ const mins = Math.floor(clamped / 60000);
20
+ const secs = Math.floor((clamped % 60000) / 1000);
21
+ return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
22
+ }
23
+
24
+ // The cart's own clock: one standard idle window that only answering the "still there?" prompt slides forward
25
+ // (adding or editing an item deliberately do not, and paying only holds it to a floor so the card form always
26
+ // has time for card entry - CheckoutCartService.PaymentWindowFloor), clamped at the absolute ceiling from
27
+ // creation (see CheckoutCartService's own remarks). idleExpiresAt is therefore always the sooner of the two.
28
+ export function cartDeadline(cart: CheckoutCartDetailDto): Date {
29
+ return new Date(instant(cart.idleExpiresAt));
30
+ }
31
+
32
+ // Whether the idle window has been slid as far as the absolute ceiling allows, so answering the prompt again
33
+ // cannot buy any more time. Derived from the two instants the cart already carries rather than from a failed
34
+ // request: the server has no refusal to report, it simply hands back the same deadline.
35
+ export function isAtExpiryCeiling(cart: CheckoutCartDetailDto): boolean {
36
+ return instant(cart.idleExpiresAt) >= instant(cart.absoluteExpiresAt);
37
+ }
38
+
39
+ // The latest instant a Date can hold - "no deadline". The API always sends idleExpiresAt; the generated type
40
+ // only marks it optional because of its nullability defaults, so this is a type-level fallback, not a case the
41
+ // server produces.
42
+ const NoDeadline = 8.64e15;
43
+
44
+ function instant(iso: string | null | undefined): number {
45
+ return iso ? new Date(iso).getTime() : NoDeadline;
46
+ }
@@ -1,8 +1,19 @@
1
1
  import type { CheckoutCartResult } from './client-types';
2
2
  import type { BookingApi } from './api';
3
+ import { forgetPaymentAttempt } from './payment-attempt';
3
4
 
4
5
  const STORAGE_KEY = 'morii-checkout-cart';
5
6
 
7
+ // sessionStorage, not localStorage: the cart token is a full pay/confirm capability, and the widget ships as
8
+ // a shadow:none custom element, so it runs in the *merchant's* top-level origin - its web storage is shared
9
+ // with every other script on that page. sessionStorage narrows the exposure to the one tab and clears it when
10
+ // that tab closes (it still survives reloads and same-tab navigation, so the cart-survives-reload behaviour is
11
+ // unchanged), rather than persisting the token across all tabs indefinitely. The token is additionally bound
12
+ // to this origin server-side (see CheckoutCart.OriginAtIssue), so it cannot be lifted and replayed from
13
+ // another site; what remains - a hostile script on the merchant's own page - is inherent to shadow:none
14
+ // embedding and can only be fully closed by isolating the widget in its own iframe origin.
15
+ const storage: Storage | null = typeof sessionStorage !== 'undefined' ? sessionStorage : null;
16
+
6
17
  interface StoredCart {
7
18
  cartToken: string;
8
19
  absoluteExpiresAt: string;
@@ -40,11 +51,16 @@ export class CartManager {
40
51
  // ensureCart to mint a fresh one after the server refuses an operation on the cart it was given.
41
52
  reset(): void {
42
53
  this.cart = null;
54
+ // Also cleared on the api - loadFromStorage sets it there, so leaving it behind would keep sending the
55
+ // dropped cart's token on every subsequent request.
56
+ this.api.cartToken = '';
43
57
  try {
44
- localStorage.removeItem(STORAGE_KEY);
58
+ storage?.removeItem(STORAGE_KEY);
45
59
  } catch {
46
- // localStorage unavailable (e.g. iframe sandbox)
60
+ // storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
47
61
  }
62
+ // A Peach attempt only ever belongs to the cart it was started for.
63
+ forgetPaymentAttempt();
48
64
  }
49
65
 
50
66
  private isExpired(): boolean {
@@ -59,20 +75,21 @@ export class CartManager {
59
75
  absoluteExpiresAt: this.cart.absoluteExpiresAt ?? '',
60
76
  };
61
77
  try {
62
- localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
78
+ storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
63
79
  } catch {
64
- // localStorage unavailable (e.g. iframe sandbox)
80
+ // storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
65
81
  }
66
82
  }
67
83
 
68
84
  private loadFromStorage(): void {
69
85
  try {
70
- const raw = localStorage.getItem(STORAGE_KEY);
86
+ const raw = storage?.getItem(STORAGE_KEY);
71
87
  if (!raw) return;
72
88
 
73
89
  const stored: StoredCart = JSON.parse(raw);
74
90
  if (new Date(stored.absoluteExpiresAt).getTime() <= Date.now()) {
75
- localStorage.removeItem(STORAGE_KEY);
91
+ storage?.removeItem(STORAGE_KEY);
92
+ forgetPaymentAttempt();
76
93
  return;
77
94
  }
78
95
 
@@ -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,9 @@ 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';
8
+ import { mount } from 'svelte';
9
+ import CartExpiryGuard from '../CartExpiryGuard.svelte';
7
10
 
8
11
  // Bootstrap shared services BEFORE element imports trigger connectedCallback.
9
12
  // Elements read from window.__bwServices instead of importing shared.ts,
@@ -17,6 +20,30 @@ sessionManager.startBackgroundRefresh();
17
20
  const ready = sessionManager.ensureSession(checkoutKey).then(() => {});
18
21
  (window as any).__bwServices = { api, cartManager, ready };
19
22
 
23
+ function whenDomReady(fn: () => void) {
24
+ if (document.readyState === 'loading') {
25
+ document.addEventListener('DOMContentLoaded', fn, { once: true });
26
+ } else {
27
+ fn();
28
+ }
29
+ }
30
+
31
+ // Mounted once, directly on <body> - not inside any <bw-*> element's own tree, so the "still shopping?"
32
+ // warning and the expiry it can lead to work regardless of which combination of elements (if any include
33
+ // <bw-checkout> at all) the merchant actually placed on this page. Session readiness is irrelevant to it -
34
+ // cart operations only need the cart token, never the session token - so it does not wait on `ready`.
35
+ //
36
+ // Deferred to DOMContentLoaded rather than run inline: a <script> placed in <head> without defer/type=module
37
+ // runs before <body> exists at all, and document.body would be null here. An exception at module level would
38
+ // also skip the cart:updated forwarding and autoWire below, not just the expiry guard.
39
+ function mountExpiryGuard() {
40
+ const guardHost = document.createElement('div');
41
+ guardHost.dataset.bwExpiryGuard = '';
42
+ document.body.appendChild(guardHost);
43
+ mount(CartExpiryGuard, { target: guardHost, props: { api, cartManager } });
44
+ }
45
+ whenDomReady(mountExpiryGuard);
46
+
20
47
  import './bw-configurator.svelte';
21
48
  import './bw-cart.svelte';
22
49
  import './bw-checkout.svelte';
@@ -26,13 +53,13 @@ import './bw-checkout.svelte';
26
53
  // cart TicketConfigurator/CheckoutModal already fetch for their own posting - see those files' own comments -
27
54
  // so a consumer can build a custom cart summary (item count, remaining time, item details) without calling
28
55
  // 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) {
56
+ onWidgetMessage((d) => {
57
+ if (d.type === 'cart:updated' && 'cart' in d) {
34
58
  window.dispatchEvent(new CustomEvent('bw:cart-updated', { detail: { cart: d.cart } }));
35
59
  }
60
+ if (d.type === 'cart:expired') {
61
+ window.dispatchEvent(new CustomEvent('bw:cart-expired'));
62
+ }
36
63
  });
37
64
 
38
65
  interface BwOptions {
@@ -166,8 +193,4 @@ function autoWire() {
166
193
  });
167
194
  }
168
195
 
169
- if (document.readyState === 'loading') {
170
- document.addEventListener('DOMContentLoaded', autoWire);
171
- } else {
172
- autoWire();
173
- }
196
+ whenDomReady(autoWire);
@@ -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;
@@ -373,6 +423,7 @@ export interface components {
373
423
  CheckoutCartPaymentInitiationDto: {
374
424
  checkoutId?: string | null;
375
425
  redirectUrl?: string | null;
426
+ entityId?: string | null;
376
427
  };
377
428
  CheckoutCartResult: {
378
429
  cartToken?: string | null;
@@ -392,6 +443,18 @@ export interface components {
392
443
  */
393
444
  absoluteExpiresAt?: string;
394
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
+ };
395
458
  CheckoutOpeningHoursDto: {
396
459
  from: string | null;
397
460
  to: string | null;
@@ -428,7 +491,14 @@ export interface components {
428
491
  id: string | null;
429
492
  internalName: string | null;
430
493
  title?: string | null;
494
+ shortDescription?: string | null;
431
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;
432
502
  options: components["schemas"]["CheckoutOptionDto"][] | null;
433
503
  };
434
504
  CheckoutSessionDto: {
@@ -715,6 +785,48 @@ export interface operations {
715
785
  };
716
786
  };
717
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
+ };
718
830
  CheckoutGetSupplier_v1: {
719
831
  parameters: {
720
832
  query?: never;
@@ -825,4 +937,22 @@ export interface operations {
825
937
  };
826
938
  };
827
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
+ };
828
958
  }