@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.
- package/README.md +33 -2
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +1038 -795
- package/dist/booking-widget.min.js +23 -9
- package/dist/booking-widget.umd.cjs +3 -2
- package/package.json +6 -2
- package/src/lib/CartBar.svelte +4 -11
- 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 +6 -17
- package/src/lib/Checkout.svelte +55 -14
- 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 -560
- package/src/lib/CountdownTimer.svelte +2 -4
- package/src/lib/EditBookingView.svelte +15 -4
- package/src/lib/PaymentPage.svelte +78 -61
- package/src/lib/ResultView.svelte +43 -3
- package/src/lib/StillTherePrompt.svelte +72 -0
- package/src/lib/TicketConfigurator.svelte +25 -3
- package/src/lib/UnitCounter.svelte +84 -8
- package/src/lib/WizardPage.svelte +11 -0
- package/src/lib/api.ts +89 -2
- package/src/lib/cart-expiry.ts +46 -0
- package/src/lib/cart-manager.ts +23 -6
- package/src/lib/client-types.ts +6 -0
- package/src/lib/elements/bw-cart.svelte +3 -12
- package/src/lib/elements/bw-checkout.svelte +9 -16
- package/src/lib/elements/bw-configurator.svelte +3 -13
- package/src/lib/elements/register.ts +33 -10
- package/src/lib/generated-types.ts +133 -3
- package/src/lib/index.ts +19 -28
- package/src/lib/messages.ts +75 -1
- 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
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(
|
|
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
|
+
}
|
package/src/lib/cart-manager.ts
CHANGED
|
@@ -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
|
-
|
|
58
|
+
storage?.removeItem(STORAGE_KEY);
|
|
45
59
|
} catch {
|
|
46
|
-
//
|
|
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
|
-
|
|
78
|
+
storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
|
|
63
79
|
} catch {
|
|
64
|
-
//
|
|
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 =
|
|
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
|
-
|
|
91
|
+
storage?.removeItem(STORAGE_KEY);
|
|
92
|
+
forgetPaymentAttempt();
|
|
76
93
|
return;
|
|
77
94
|
}
|
|
78
95
|
|
package/src/lib/client-types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
}
|