@code-collective/booking-widget 1.0.12 → 1.0.13
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/CHANGELOG.md +48 -46
- package/README.md +500 -500
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +581 -538
- package/dist/booking-widget.min.js +24 -22
- package/dist/booking-widget.umd.cjs +4 -4
- package/package.json +58 -58
- package/src/lib/BookingProvider.svelte +21 -21
- package/src/lib/CartBar.svelte +26 -26
- package/src/lib/CartBarView.svelte +78 -78
- package/src/lib/CartExpiryGuard.svelte +416 -416
- package/src/lib/CartOverview.svelte +30 -30
- package/src/lib/CartOverviewButton.svelte +104 -104
- package/src/lib/Checkout.svelte +141 -141
- package/src/lib/CheckoutModal.svelte +179 -71
- package/src/lib/CheckoutPanel.svelte +121 -121
- package/src/lib/PaymentPage.svelte +191 -191
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +166 -166
- package/src/lib/booking-context.ts +33 -33
- package/src/lib/cart-overview.svelte.ts +97 -97
- package/src/lib/client-types.ts +10 -6
- package/src/lib/config.ts +162 -162
- package/src/lib/elements/bw-cart.svelte +49 -49
- package/src/lib/elements/bw-checkout.svelte +97 -97
- package/src/lib/elements/bw-configurator.svelte +72 -72
- package/src/lib/elements/register.ts +171 -171
- package/src/lib/elements/shared.ts +18 -18
- package/src/lib/host.svelte.ts +336 -336
- package/src/lib/index.ts +242 -242
- package/src/lib/messages.ts +157 -157
- package/src/lib/peach-sdk.ts +86 -86
- package/src/lib/portal.ts +23 -23
package/src/lib/messages.ts
CHANGED
|
@@ -1,157 +1,157 @@
|
|
|
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
|
-
import type { CheckoutCartDetailDto } from './client-types';
|
|
7
|
-
|
|
8
|
-
/** An item was added to the cart from a configurator. */
|
|
9
|
-
export interface CartChangeMessage {
|
|
10
|
-
type: 'cart:change';
|
|
11
|
-
itemCount: number;
|
|
12
|
-
cartItemId: string;
|
|
13
|
-
totalFormatted: string;
|
|
14
|
-
// Whether this add should carry the shopper on into checkout. The single source of truth for auto-open on
|
|
15
|
-
// both builds - createBookingHost reads it directly, and register.ts reaches it through the same host - so
|
|
16
|
-
// the elements build and the ES build cannot drift on when checkout opens.
|
|
17
|
-
openCheckout: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** The cart's contents changed, anywhere on the page. `cart` is null once the cart is gone. */
|
|
21
|
-
export interface CartUpdatedMessage {
|
|
22
|
-
type: 'cart:updated';
|
|
23
|
-
cart: CheckoutCartDetailDto | null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The cart's window ran out and the cart is gone - distinct from the `cart:updated` with a null cart that
|
|
28
|
-
* also accompanies it, which a confirmed order posts too. A host telling the shopper "your cart expired"
|
|
29
|
-
* needs the difference.
|
|
30
|
-
*/
|
|
31
|
-
export interface CartExpiredMessage {
|
|
32
|
-
type: 'cart:expired';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Something asked for checkout to be shown - the cart bar's button, or the expiry guard resuming a payment. */
|
|
36
|
-
export interface ModalOpenMessage {
|
|
37
|
-
type: 'modal:open';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Checkout was dismissed. */
|
|
41
|
-
export interface ModalCloseMessage {
|
|
42
|
-
type: 'modal:close';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Payment completed *and* was confirmed. Does not fire while a confirmation webhook is still pending. */
|
|
46
|
-
export interface OrderCompleteMessage {
|
|
47
|
-
type: 'order:complete';
|
|
48
|
-
cartToken: string;
|
|
49
|
-
value: number;
|
|
50
|
-
currency: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* A Peach checkout is open for the cart / has been abandoned so the cart is Open again. Posted by
|
|
55
|
-
* CheckoutModal for CartExpiryGuard, which keeps prompting but stops acting on the deadline itself while the
|
|
56
|
-
* server exempts the cart from expiry.
|
|
57
|
-
*/
|
|
58
|
-
export interface PaymentStartedMessage {
|
|
59
|
-
type: 'payment:started';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface PaymentEndedMessage {
|
|
63
|
-
type: 'payment:ended';
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The cart's one clock ran out while a Peach checkout was open. Posted by CartExpiryGuard for CheckoutModal,
|
|
68
|
-
* which tears the attempt down (Peach's real status permitting) so the shopper sees the cart expire there
|
|
69
|
-
* too, rather than an ever-open card form for a cart that is already gone.
|
|
70
|
-
*/
|
|
71
|
-
export interface PaymentTimedOutMessage {
|
|
72
|
-
type: 'payment:timed-out';
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Every message this widget sends itself. Anything else on the wire is not ours. */
|
|
76
|
-
export type WidgetMessage =
|
|
77
|
-
| CartChangeMessage
|
|
78
|
-
| CartUpdatedMessage
|
|
79
|
-
| CartExpiredMessage
|
|
80
|
-
| ModalOpenMessage
|
|
81
|
-
| ModalCloseMessage
|
|
82
|
-
| OrderCompleteMessage
|
|
83
|
-
| PaymentStartedMessage
|
|
84
|
-
| PaymentEndedMessage
|
|
85
|
-
| PaymentTimedOutMessage;
|
|
86
|
-
|
|
87
|
-
export type WidgetMessageType = WidgetMessage['type'];
|
|
88
|
-
|
|
89
|
-
const WIDGET_MESSAGE_TYPES = new Set<string>([
|
|
90
|
-
'cart:change',
|
|
91
|
-
'cart:updated',
|
|
92
|
-
'cart:expired',
|
|
93
|
-
'modal:open',
|
|
94
|
-
'modal:close',
|
|
95
|
-
'order:complete',
|
|
96
|
-
'payment:started',
|
|
97
|
-
'payment:ended',
|
|
98
|
-
'payment:timed-out',
|
|
99
|
-
] satisfies WidgetMessageType[]);
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Sends a widget message.
|
|
103
|
-
*
|
|
104
|
-
* Targets this window's own origin rather than '*'. These payloads carry `cart`, and CheckoutCartDetailDto
|
|
105
|
-
* includes `cartToken` - a bearer credential that authorises reading, modifying and paying for the cart. With
|
|
106
|
-
* '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
|
|
107
|
-
* not necessarily anyone we trust.
|
|
108
|
-
*/
|
|
109
|
-
export function postMessage(data: WidgetMessage): void {
|
|
110
|
-
// No window means a server render - an Astro island is server-rendered before it hydrates, and a host
|
|
111
|
-
// built during that pass would otherwise take the whole page down with a ReferenceError.
|
|
112
|
-
if (typeof window === 'undefined') return;
|
|
113
|
-
window.parent?.postMessage(JSON.stringify(data), window.location.origin);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Subscribes to widget messages, ignoring anything that did not come from this widget. Returns an
|
|
118
|
-
* unsubscribe function.
|
|
119
|
-
*
|
|
120
|
-
* The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
|
|
121
|
-
* the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
|
|
122
|
-
* chat widget, a tag manager - could reach them with window.parent.postMessage and drive the widget: spoof
|
|
123
|
-
* the total on the payment consent screen, fire a forged order confirmation into the merchant's analytics, or
|
|
124
|
-
* close the modal mid-confirm on a card that has already been charged.
|
|
125
|
-
*
|
|
126
|
-
* Checking `source` is what does the real work: a message from another frame carries that frame's own window,
|
|
127
|
-
* never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
|
|
128
|
-
*/
|
|
129
|
-
export function onWidgetMessage(handler: (data: WidgetMessage) => void): () => void {
|
|
130
|
-
// Same reason as postMessage above: nothing to subscribe to on the server, and createBookingHost
|
|
131
|
-
// subscribes the moment it is built.
|
|
132
|
-
if (typeof window === 'undefined') return () => {};
|
|
133
|
-
|
|
134
|
-
function listener(e: MessageEvent) {
|
|
135
|
-
if (e.source !== window || e.origin !== window.location.origin) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
let data: WidgetMessage;
|
|
140
|
-
try {
|
|
141
|
-
data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
|
142
|
-
} catch {
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Anything without one of our own type values is someone else's traffic sharing this window - a library
|
|
147
|
-
// or the host page talking to itself - not something to hand to a widget handler.
|
|
148
|
-
if (typeof data?.type !== 'string' || !WIDGET_MESSAGE_TYPES.has(data.type)) {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
handler(data);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
window.addEventListener('message', listener);
|
|
156
|
-
return () => window.removeEventListener('message', listener);
|
|
157
|
-
}
|
|
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
|
+
import type { CheckoutCartDetailDto } from './client-types';
|
|
7
|
+
|
|
8
|
+
/** An item was added to the cart from a configurator. */
|
|
9
|
+
export interface CartChangeMessage {
|
|
10
|
+
type: 'cart:change';
|
|
11
|
+
itemCount: number;
|
|
12
|
+
cartItemId: string;
|
|
13
|
+
totalFormatted: string;
|
|
14
|
+
// Whether this add should carry the shopper on into checkout. The single source of truth for auto-open on
|
|
15
|
+
// both builds - createBookingHost reads it directly, and register.ts reaches it through the same host - so
|
|
16
|
+
// the elements build and the ES build cannot drift on when checkout opens.
|
|
17
|
+
openCheckout: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The cart's contents changed, anywhere on the page. `cart` is null once the cart is gone. */
|
|
21
|
+
export interface CartUpdatedMessage {
|
|
22
|
+
type: 'cart:updated';
|
|
23
|
+
cart: CheckoutCartDetailDto | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The cart's window ran out and the cart is gone - distinct from the `cart:updated` with a null cart that
|
|
28
|
+
* also accompanies it, which a confirmed order posts too. A host telling the shopper "your cart expired"
|
|
29
|
+
* needs the difference.
|
|
30
|
+
*/
|
|
31
|
+
export interface CartExpiredMessage {
|
|
32
|
+
type: 'cart:expired';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Something asked for checkout to be shown - the cart bar's button, or the expiry guard resuming a payment. */
|
|
36
|
+
export interface ModalOpenMessage {
|
|
37
|
+
type: 'modal:open';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Checkout was dismissed. */
|
|
41
|
+
export interface ModalCloseMessage {
|
|
42
|
+
type: 'modal:close';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Payment completed *and* was confirmed. Does not fire while a confirmation webhook is still pending. */
|
|
46
|
+
export interface OrderCompleteMessage {
|
|
47
|
+
type: 'order:complete';
|
|
48
|
+
cartToken: string;
|
|
49
|
+
value: number;
|
|
50
|
+
currency: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A Peach checkout is open for the cart / has been abandoned so the cart is Open again. Posted by
|
|
55
|
+
* CheckoutModal for CartExpiryGuard, which keeps prompting but stops acting on the deadline itself while the
|
|
56
|
+
* server exempts the cart from expiry.
|
|
57
|
+
*/
|
|
58
|
+
export interface PaymentStartedMessage {
|
|
59
|
+
type: 'payment:started';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PaymentEndedMessage {
|
|
63
|
+
type: 'payment:ended';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The cart's one clock ran out while a Peach checkout was open. Posted by CartExpiryGuard for CheckoutModal,
|
|
68
|
+
* which tears the attempt down (Peach's real status permitting) so the shopper sees the cart expire there
|
|
69
|
+
* too, rather than an ever-open card form for a cart that is already gone.
|
|
70
|
+
*/
|
|
71
|
+
export interface PaymentTimedOutMessage {
|
|
72
|
+
type: 'payment:timed-out';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every message this widget sends itself. Anything else on the wire is not ours. */
|
|
76
|
+
export type WidgetMessage =
|
|
77
|
+
| CartChangeMessage
|
|
78
|
+
| CartUpdatedMessage
|
|
79
|
+
| CartExpiredMessage
|
|
80
|
+
| ModalOpenMessage
|
|
81
|
+
| ModalCloseMessage
|
|
82
|
+
| OrderCompleteMessage
|
|
83
|
+
| PaymentStartedMessage
|
|
84
|
+
| PaymentEndedMessage
|
|
85
|
+
| PaymentTimedOutMessage;
|
|
86
|
+
|
|
87
|
+
export type WidgetMessageType = WidgetMessage['type'];
|
|
88
|
+
|
|
89
|
+
const WIDGET_MESSAGE_TYPES = new Set<string>([
|
|
90
|
+
'cart:change',
|
|
91
|
+
'cart:updated',
|
|
92
|
+
'cart:expired',
|
|
93
|
+
'modal:open',
|
|
94
|
+
'modal:close',
|
|
95
|
+
'order:complete',
|
|
96
|
+
'payment:started',
|
|
97
|
+
'payment:ended',
|
|
98
|
+
'payment:timed-out',
|
|
99
|
+
] satisfies WidgetMessageType[]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Sends a widget message.
|
|
103
|
+
*
|
|
104
|
+
* Targets this window's own origin rather than '*'. These payloads carry `cart`, and CheckoutCartDetailDto
|
|
105
|
+
* includes `cartToken` - a bearer credential that authorises reading, modifying and paying for the cart. With
|
|
106
|
+
* '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
|
|
107
|
+
* not necessarily anyone we trust.
|
|
108
|
+
*/
|
|
109
|
+
export function postMessage(data: WidgetMessage): void {
|
|
110
|
+
// No window means a server render - an Astro island is server-rendered before it hydrates, and a host
|
|
111
|
+
// built during that pass would otherwise take the whole page down with a ReferenceError.
|
|
112
|
+
if (typeof window === 'undefined') return;
|
|
113
|
+
window.parent?.postMessage(JSON.stringify(data), window.location.origin);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Subscribes to widget messages, ignoring anything that did not come from this widget. Returns an
|
|
118
|
+
* unsubscribe function.
|
|
119
|
+
*
|
|
120
|
+
* The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
|
|
121
|
+
* the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
|
|
122
|
+
* chat widget, a tag manager - could reach them with window.parent.postMessage and drive the widget: spoof
|
|
123
|
+
* the total on the payment consent screen, fire a forged order confirmation into the merchant's analytics, or
|
|
124
|
+
* close the modal mid-confirm on a card that has already been charged.
|
|
125
|
+
*
|
|
126
|
+
* Checking `source` is what does the real work: a message from another frame carries that frame's own window,
|
|
127
|
+
* never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
|
|
128
|
+
*/
|
|
129
|
+
export function onWidgetMessage(handler: (data: WidgetMessage) => void): () => void {
|
|
130
|
+
// Same reason as postMessage above: nothing to subscribe to on the server, and createBookingHost
|
|
131
|
+
// subscribes the moment it is built.
|
|
132
|
+
if (typeof window === 'undefined') return () => {};
|
|
133
|
+
|
|
134
|
+
function listener(e: MessageEvent) {
|
|
135
|
+
if (e.source !== window || e.origin !== window.location.origin) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let data: WidgetMessage;
|
|
140
|
+
try {
|
|
141
|
+
data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Anything without one of our own type values is someone else's traffic sharing this window - a library
|
|
147
|
+
// or the host page talking to itself - not something to hand to a widget handler.
|
|
148
|
+
if (typeof data?.type !== 'string' || !WIDGET_MESSAGE_TYPES.has(data.type)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
handler(data);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
window.addEventListener('message', listener);
|
|
156
|
+
return () => window.removeEventListener('message', listener);
|
|
157
|
+
}
|
package/src/lib/peach-sdk.ts
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
const SDK_URLS: Record<string, string> = {
|
|
2
|
-
prod: 'https://checkout.peachpayments.com/js/checkout.js',
|
|
3
|
-
qa: 'https://sandbox-checkout.peachpayments.com/js/checkout.js',
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
let loadPromise: Promise<void> | null = null;
|
|
7
|
-
let configuredUrl: string | null = null;
|
|
8
|
-
let loadedUrl: string | null = null;
|
|
9
|
-
|
|
10
|
-
/** Which SDK was actually loaded. Only worth knowing when Peach then refuses to render - see PaymentPage. */
|
|
11
|
-
export function loadedPeachSdkUrl(): string | null {
|
|
12
|
-
return loadedUrl;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type PeachEnv = 'prod' | 'qa';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Chooses which Peach SDK to load, for consumers with no window globals to set.
|
|
19
|
-
*
|
|
20
|
-
* window.BW_CHECKOUT_ENV is the custom-elements build's convention, read off the page that loaded the
|
|
21
|
-
* script. An ES consumer has no such page - it passes apiBaseUrl to createBookingHost and nothing else -
|
|
22
|
-
* so without this it silently got the production SDK while its checkout API was creating sandbox
|
|
23
|
-
* checkoutIds, and Peach answered with "An unrecoverable error has occurred while attempting to render the
|
|
24
|
-
* checkout experience". createBookingHost calls this for you; it is exported for anyone composing without
|
|
25
|
-
* a host.
|
|
26
|
-
*/
|
|
27
|
-
export function setPeachSdk(options: { url?: string; env?: PeachEnv }): void {
|
|
28
|
-
const next = options.url ?? (options.env ? SDK_URLS[options.env] : undefined);
|
|
29
|
-
if (!next || next === configuredUrl) return;
|
|
30
|
-
configuredUrl = next;
|
|
31
|
-
// A different SDK than the one already promised has to be fetched afresh.
|
|
32
|
-
loadPromise = null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Lazily injects Peach's Copy&Pay SDK the first time PaymentPage mounts, so consumers embedding the widget
|
|
36
|
-
// (any of the three integration options) never have to know this script exists, let alone which
|
|
37
|
-
// environment's URL to point at - same window.BW_CHECKOUT_ENV convention elements/env.ts already uses for
|
|
38
|
-
// the checkout API base URL, with an explicit override for anything that doesn't fit that pattern.
|
|
39
|
-
export function loadPeachSdk(): Promise<void> {
|
|
40
|
-
const w = window as any;
|
|
41
|
-
const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
|
|
42
|
-
// Explicit window override first (it is the most specific thing a page can say), then whatever a host
|
|
43
|
-
// was configured with, then the elements build's env global, then production.
|
|
44
|
-
const src: string = w.BW_CHECKOUT_PEACH_SDK_URL ?? configuredUrl ?? SDK_URLS[env] ?? SDK_URLS.prod;
|
|
45
|
-
|
|
46
|
-
// Peach's SDK cannot be swapped once it has installed window.Checkout, so a page that already loaded one
|
|
47
|
-
// - its own <script> tag, or an earlier host configured differently - is stuck with it. Resolving in
|
|
48
|
-
// silence is what made this hard to find the first time: the only symptom is Peach's opaque
|
|
49
|
-
// "unrecoverable error" card, from a production SDK holding a sandbox checkoutId.
|
|
50
|
-
if (w.Checkout) {
|
|
51
|
-
if (loadedUrl !== null && loadedUrl !== src) {
|
|
52
|
-
console.warn(
|
|
53
|
-
`[booking-widget] Peach SDK already loaded from ${loadedUrl}, so ${src} was not used. ` +
|
|
54
|
-
`Configure peachEnv before the first payment, or set window.BW_CHECKOUT_PEACH_SDK_URL.`,
|
|
55
|
-
);
|
|
56
|
-
} else if (loadedUrl === null) {
|
|
57
|
-
console.warn(
|
|
58
|
-
`[booking-widget] Peach SDK was already on the page when the widget first needed it, so ${src} ` +
|
|
59
|
-
`was not used. If its environment does not match the checkout API, the card form will not render.`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
return Promise.resolve();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (loadPromise) {
|
|
66
|
-
return loadPromise;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
loadedUrl = src;
|
|
70
|
-
loadPromise = new Promise((resolve, reject) => {
|
|
71
|
-
const existing = document.querySelector(`script[src="${src}"]`);
|
|
72
|
-
if (existing) {
|
|
73
|
-
existing.addEventListener('load', () => resolve());
|
|
74
|
-
existing.addEventListener('error', () => reject(new Error('Failed to load the Peach Payments SDK script')));
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const script = document.createElement('script');
|
|
79
|
-
script.src = src;
|
|
80
|
-
script.onload = () => resolve();
|
|
81
|
-
script.onerror = () => reject(new Error('Failed to load the Peach Payments SDK script'));
|
|
82
|
-
document.head.appendChild(script);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
return loadPromise;
|
|
86
|
-
}
|
|
1
|
+
const SDK_URLS: Record<string, string> = {
|
|
2
|
+
prod: 'https://checkout.peachpayments.com/js/checkout.js',
|
|
3
|
+
qa: 'https://sandbox-checkout.peachpayments.com/js/checkout.js',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
let loadPromise: Promise<void> | null = null;
|
|
7
|
+
let configuredUrl: string | null = null;
|
|
8
|
+
let loadedUrl: string | null = null;
|
|
9
|
+
|
|
10
|
+
/** Which SDK was actually loaded. Only worth knowing when Peach then refuses to render - see PaymentPage. */
|
|
11
|
+
export function loadedPeachSdkUrl(): string | null {
|
|
12
|
+
return loadedUrl;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type PeachEnv = 'prod' | 'qa';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Chooses which Peach SDK to load, for consumers with no window globals to set.
|
|
19
|
+
*
|
|
20
|
+
* window.BW_CHECKOUT_ENV is the custom-elements build's convention, read off the page that loaded the
|
|
21
|
+
* script. An ES consumer has no such page - it passes apiBaseUrl to createBookingHost and nothing else -
|
|
22
|
+
* so without this it silently got the production SDK while its checkout API was creating sandbox
|
|
23
|
+
* checkoutIds, and Peach answered with "An unrecoverable error has occurred while attempting to render the
|
|
24
|
+
* checkout experience". createBookingHost calls this for you; it is exported for anyone composing without
|
|
25
|
+
* a host.
|
|
26
|
+
*/
|
|
27
|
+
export function setPeachSdk(options: { url?: string; env?: PeachEnv }): void {
|
|
28
|
+
const next = options.url ?? (options.env ? SDK_URLS[options.env] : undefined);
|
|
29
|
+
if (!next || next === configuredUrl) return;
|
|
30
|
+
configuredUrl = next;
|
|
31
|
+
// A different SDK than the one already promised has to be fetched afresh.
|
|
32
|
+
loadPromise = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Lazily injects Peach's Copy&Pay SDK the first time PaymentPage mounts, so consumers embedding the widget
|
|
36
|
+
// (any of the three integration options) never have to know this script exists, let alone which
|
|
37
|
+
// environment's URL to point at - same window.BW_CHECKOUT_ENV convention elements/env.ts already uses for
|
|
38
|
+
// the checkout API base URL, with an explicit override for anything that doesn't fit that pattern.
|
|
39
|
+
export function loadPeachSdk(): Promise<void> {
|
|
40
|
+
const w = window as any;
|
|
41
|
+
const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
|
|
42
|
+
// Explicit window override first (it is the most specific thing a page can say), then whatever a host
|
|
43
|
+
// was configured with, then the elements build's env global, then production.
|
|
44
|
+
const src: string = w.BW_CHECKOUT_PEACH_SDK_URL ?? configuredUrl ?? SDK_URLS[env] ?? SDK_URLS.prod;
|
|
45
|
+
|
|
46
|
+
// Peach's SDK cannot be swapped once it has installed window.Checkout, so a page that already loaded one
|
|
47
|
+
// - its own <script> tag, or an earlier host configured differently - is stuck with it. Resolving in
|
|
48
|
+
// silence is what made this hard to find the first time: the only symptom is Peach's opaque
|
|
49
|
+
// "unrecoverable error" card, from a production SDK holding a sandbox checkoutId.
|
|
50
|
+
if (w.Checkout) {
|
|
51
|
+
if (loadedUrl !== null && loadedUrl !== src) {
|
|
52
|
+
console.warn(
|
|
53
|
+
`[booking-widget] Peach SDK already loaded from ${loadedUrl}, so ${src} was not used. ` +
|
|
54
|
+
`Configure peachEnv before the first payment, or set window.BW_CHECKOUT_PEACH_SDK_URL.`,
|
|
55
|
+
);
|
|
56
|
+
} else if (loadedUrl === null) {
|
|
57
|
+
console.warn(
|
|
58
|
+
`[booking-widget] Peach SDK was already on the page when the widget first needed it, so ${src} ` +
|
|
59
|
+
`was not used. If its environment does not match the checkout API, the card form will not render.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (loadPromise) {
|
|
66
|
+
return loadPromise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
loadedUrl = src;
|
|
70
|
+
loadPromise = new Promise((resolve, reject) => {
|
|
71
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
72
|
+
if (existing) {
|
|
73
|
+
existing.addEventListener('load', () => resolve());
|
|
74
|
+
existing.addEventListener('error', () => reject(new Error('Failed to load the Peach Payments SDK script')));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const script = document.createElement('script');
|
|
79
|
+
script.src = src;
|
|
80
|
+
script.onload = () => resolve();
|
|
81
|
+
script.onerror = () => reject(new Error('Failed to load the Peach Payments SDK script'));
|
|
82
|
+
document.head.appendChild(script);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return loadPromise;
|
|
86
|
+
}
|
package/src/lib/portal.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Moves an element out to `document.body` (or another target) for as long as it is mounted, and puts nothing
|
|
3
|
-
* back - the node is removed with the component.
|
|
4
|
-
*
|
|
5
|
-
* `position: fixed` only escapes to the viewport while no ancestor has established a containing block for it.
|
|
6
|
-
* A `transform`, `filter`, `backdrop-filter`, `perspective`, `contain` or `will-change` anywhere above, and a
|
|
7
|
-
* `position: sticky` ancestor's stacking context, are all enough to trap a fixed overlay inside the subtree
|
|
8
|
-
* and let the host's own header paint over it. No z-index inside that subtree can win, because the contest is
|
|
9
|
-
* between the ancestor and the header, not between the overlay and anything.
|
|
10
|
-
*
|
|
11
|
-
* Consumers hit this the moment they put checkout in a sticky sidebar, which is the ordinary way to lay a
|
|
12
|
-
* product page out - so the modal portals itself rather than leaving each host to discover the rule.
|
|
13
|
-
*/
|
|
14
|
-
export function portal(node: HTMLElement, target?: HTMLElement | null) {
|
|
15
|
-
const destination = target ?? (typeof document === 'undefined' ? null : document.body);
|
|
16
|
-
destination?.appendChild(node);
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
destroy() {
|
|
20
|
-
node.remove();
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Moves an element out to `document.body` (or another target) for as long as it is mounted, and puts nothing
|
|
3
|
+
* back - the node is removed with the component.
|
|
4
|
+
*
|
|
5
|
+
* `position: fixed` only escapes to the viewport while no ancestor has established a containing block for it.
|
|
6
|
+
* A `transform`, `filter`, `backdrop-filter`, `perspective`, `contain` or `will-change` anywhere above, and a
|
|
7
|
+
* `position: sticky` ancestor's stacking context, are all enough to trap a fixed overlay inside the subtree
|
|
8
|
+
* and let the host's own header paint over it. No z-index inside that subtree can win, because the contest is
|
|
9
|
+
* between the ancestor and the header, not between the overlay and anything.
|
|
10
|
+
*
|
|
11
|
+
* Consumers hit this the moment they put checkout in a sticky sidebar, which is the ordinary way to lay a
|
|
12
|
+
* product page out - so the modal portals itself rather than leaving each host to discover the rule.
|
|
13
|
+
*/
|
|
14
|
+
export function portal(node: HTMLElement, target?: HTMLElement | null) {
|
|
15
|
+
const destination = target ?? (typeof document === 'undefined' ? null : document.body);
|
|
16
|
+
destination?.appendChild(node);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
destroy() {
|
|
20
|
+
node.remove();
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|