@code-collective/booking-widget 1.0.10 → 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 -0
- package/README.md +153 -41
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +2637 -1924
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +34 -16
- package/dist/booking-widget.umd.cjs +5 -3
- package/package.json +5 -2
- package/src/lib/BookingProvider.svelte +21 -0
- package/src/lib/CartBar.svelte +16 -31
- package/src/lib/CartBarView.svelte +20 -3
- package/src/lib/CartExpiryGuard.svelte +416 -410
- package/src/lib/CartOverview.svelte +16 -6
- package/src/lib/CartOverviewButton.svelte +29 -26
- package/src/lib/Checkout.svelte +111 -98
- package/src/lib/CheckoutModal.svelte +946 -805
- package/src/lib/CheckoutPanel.svelte +121 -0
- package/src/lib/PaymentPage.svelte +16 -2
- package/src/lib/PickupPointPicker.svelte +1 -1
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +19 -5
- package/src/lib/UnitCounter.svelte +16 -2
- package/src/lib/WizardPage.svelte +102 -35
- package/src/lib/app.css +0 -6
- package/src/lib/booking-context.ts +33 -0
- package/src/lib/cart-overview.svelte.ts +97 -0
- package/src/lib/client-types.ts +10 -6
- package/src/lib/config.ts +9 -0
- package/src/lib/elements/bw-cart.svelte +21 -7
- package/src/lib/elements/bw-checkout.svelte +54 -54
- package/src/lib/elements/bw-configurator.svelte +30 -14
- package/src/lib/elements/register.ts +96 -121
- package/src/lib/elements/shared.ts +4 -0
- package/src/lib/elements/theme.css +0 -6
- package/src/lib/host.svelte.ts +336 -0
- package/src/lib/index.ts +137 -91
- package/src/lib/layout.svelte.ts +52 -0
- package/src/lib/messages.ts +97 -17
- package/src/lib/peach-sdk.ts +51 -5
- package/src/lib/portal.ts +23 -0
- package/src/lib/CartExpiryGuard.test.ts +0 -331
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
- package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
- package/src/lib/test/fixtures.ts +0 -107
- package/src/lib/test/messages-mock.ts +0 -34
package/src/lib/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import './app.css';
|
|
2
2
|
import { mount, unmount } from 'svelte';
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
5
|
-
import
|
|
3
|
+
import type { CartOverviewDisplay, CartOverviewState } from './config';
|
|
4
|
+
import type { BookingHost, BookingHostConfig } from './host.svelte';
|
|
5
|
+
import { createBookingHost } from './host.svelte';
|
|
6
6
|
import { ApiClient, ApiError } from './api';
|
|
7
7
|
import { SessionManager } from './session-manager';
|
|
8
8
|
import { CartManager } from './cart-manager';
|
|
@@ -17,180 +17,226 @@ export { default as Checkout } from './Checkout.svelte';
|
|
|
17
17
|
export { default as CartOverview } from './CartOverview.svelte';
|
|
18
18
|
export { default as CartBar } from './CartBar.svelte';
|
|
19
19
|
export { default as CartOverviewButton } from './CartOverviewButton.svelte';
|
|
20
|
+
// Required if you are not using the elements build: it owns the "still shopping?" warning, the expired view
|
|
21
|
+
// and the payment-timeout hand-over, and nothing else on the page does. createBookingHost mounts it for you;
|
|
22
|
+
// export it for consumers composing everything by hand.
|
|
23
|
+
export { default as CartExpiryGuard } from './CartExpiryGuard.svelte';
|
|
24
|
+
// Supplies the host to everything beneath it, so components need no api/cartManager props of their own.
|
|
25
|
+
export { default as BookingProvider } from './BookingProvider.svelte';
|
|
20
26
|
|
|
21
27
|
// Re-export utilities
|
|
22
28
|
export { ApiClient, ApiError, SessionManager, CartManager };
|
|
23
29
|
export { resolveWizardPages, resolveEditPages, DEFAULT_WIZARD_PAGES } from './config';
|
|
30
|
+
export { createBookingHost, setBookingDefaults, getBookingDefaults, requestCheckoutOpen, requestCheckoutClose } from './host.svelte';
|
|
31
|
+
export { portal } from './portal';
|
|
32
|
+
export { setPeachSdk } from './peach-sdk';
|
|
33
|
+
export type { PeachEnv } from './peach-sdk';
|
|
34
|
+
export { getBookingHostContext } from './booking-context';
|
|
35
|
+
export { onWidgetMessage, postMessage } from './messages';
|
|
24
36
|
export type { BookingApi } from './api';
|
|
25
|
-
export type {
|
|
37
|
+
export type { BookingHost, BookingHostConfig, BookingDefaults, ResolvedBookingOptions } from './host.svelte';
|
|
38
|
+
export type {
|
|
39
|
+
WizardPages, WizardPageConfig, CartOverviewDisplay, CartOverviewState, CheckoutMode, WidgetMode, WizardWidgetType,
|
|
40
|
+
} from './config';
|
|
41
|
+
export type {
|
|
42
|
+
WidgetMessage, WidgetMessageType, CartChangeMessage, CartUpdatedMessage, CartExpiredMessage,
|
|
43
|
+
ModalOpenMessage, ModalCloseMessage, OrderCompleteMessage, PaymentStartedMessage, PaymentEndedMessage,
|
|
44
|
+
PaymentTimedOutMessage,
|
|
45
|
+
} from './messages';
|
|
26
46
|
export type { components, operations, paths } from './generated-types';
|
|
27
47
|
export type * from './client-types';
|
|
28
48
|
|
|
29
49
|
// --- Mount function configs ---
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
|
43
|
-
}
|
|
51
|
+
// Every mount function takes the same host options, so a consumer mounting more than one widget can hand all
|
|
52
|
+
// of them the same host and get one session, one cart and one expiry guard - see `host` below.
|
|
53
|
+
type BaseConfig = Omit<BookingHostConfig, 'onCheckoutOpenChange'> & {
|
|
54
|
+
// An existing host to mount into. Without one each mount function builds its own, which means its own
|
|
55
|
+
// session and its own cart - fine for a single widget on a page, wrong for two.
|
|
56
|
+
host?: BookingHost;
|
|
57
|
+
};
|
|
44
58
|
|
|
45
59
|
export interface ConfiguratorConfig extends BaseConfig {
|
|
46
60
|
productId: string;
|
|
47
61
|
currency?: string;
|
|
48
|
-
wizardPages?: WizardPages;
|
|
49
|
-
autoSelectSingleTimeSlot?: boolean;
|
|
50
62
|
cancelable?: boolean;
|
|
51
|
-
onCartChange
|
|
63
|
+
// onCartChange and the other cart callbacks come from BookingHostConfig - see createBookingHost.
|
|
52
64
|
onCancel?: () => void;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
export interface CheckoutConfig extends BaseConfig {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
// Inline by default - the mount target is a container the consumer positioned itself. Pass 'modal' for the
|
|
69
|
+
// portalled overlay the custom-elements build shows.
|
|
70
|
+
mode?: 'inline' | 'modal';
|
|
59
71
|
onClose?: () => void;
|
|
60
|
-
onOrderConfirmed?: (detail: { cartToken: string; value: number; currency: string }) => void;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
export interface CartOverviewConfig extends BaseConfig {
|
|
64
75
|
display?: CartOverviewDisplay;
|
|
65
76
|
onCheckout?: () => void;
|
|
77
|
+
/** Distinguishes an empty cart from one that could not be read - see CartOverview. */
|
|
78
|
+
onStateChange?: (state: CartOverviewState) => void;
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
export interface MountedWidget {
|
|
69
82
|
destroy: () => void;
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
// ---
|
|
85
|
+
// --- Mount functions ---
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
interface MountContext {
|
|
88
|
+
host: BookingHost;
|
|
89
|
+
/** Tearing the widget down tears the host down too - but only the host this mount built itself. */
|
|
90
|
+
release: () => void;
|
|
91
|
+
}
|
|
76
92
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Resolves the host a mount function will use. A host passed in is the consumer's to destroy - other widgets
|
|
95
|
+
* are probably still using it - so this mount only unsubscribes the callbacks it added to it.
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* Waits for the session, releasing whatever the mount just acquired if it is refused. Without this a
|
|
99
|
+
* rejected ready - an unallowlisted checkout key, the failure consumers are told to expect - left the
|
|
100
|
+
* host's message subscription, its session-refresh interval and its expiry-guard refcount behind, and that
|
|
101
|
+
* leaked refcount pins CartExpiryGuard on <body> for the life of the page.
|
|
102
|
+
*/
|
|
103
|
+
async function readyOrRelease(context: MountContext): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
await context.host.ready;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
context.release();
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
80
111
|
|
|
81
|
-
|
|
112
|
+
function hostFor(config: BaseConfig): MountContext {
|
|
113
|
+
if (!config.host) {
|
|
114
|
+
const host = createBookingHost(config);
|
|
115
|
+
return { host, release: () => host.destroy() };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The host was built elsewhere and already carries its own callbacks, so the ones on this config would
|
|
119
|
+
// otherwise be dropped on the floor - a consumer following the documented `{ host, onOrderConfirmed }`
|
|
120
|
+
// shape would get a checkout that never reports a confirmed order. They are wired to the same messages
|
|
121
|
+
// the host itself listens to.
|
|
122
|
+
return { host: config.host, release: subscribeCallbacks(config) };
|
|
82
123
|
}
|
|
83
124
|
|
|
84
|
-
|
|
125
|
+
function subscribeCallbacks(config: BaseConfig): () => void {
|
|
126
|
+
const { onCartChange, onCartUpdated, onCartExpired, onOrderConfirmed } = config;
|
|
127
|
+
if (!onCartChange && !onCartUpdated && !onCartExpired && !onOrderConfirmed) return () => {};
|
|
128
|
+
|
|
129
|
+
return onWidgetMessage((d) => {
|
|
130
|
+
if (d.type === 'cart:change') {
|
|
131
|
+
onCartChange?.({
|
|
132
|
+
itemCount: d.itemCount,
|
|
133
|
+
cartItemId: d.cartItemId,
|
|
134
|
+
totalFormatted: d.totalFormatted,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (d.type === 'cart:updated') onCartUpdated?.(d.cart);
|
|
138
|
+
if (d.type === 'cart:expired') onCartExpired?.();
|
|
139
|
+
if (d.type === 'order:complete') {
|
|
140
|
+
onOrderConfirmed?.({ cartToken: d.cartToken, value: d.value, currency: d.currency });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
85
144
|
|
|
86
145
|
export async function mountConfigurator(target: HTMLElement, config: ConfiguratorConfig): Promise<MountedWidget> {
|
|
87
|
-
const
|
|
146
|
+
const context = hostFor(config);
|
|
147
|
+
const { host, release } = context;
|
|
88
148
|
|
|
89
|
-
await
|
|
149
|
+
await readyOrRelease(context);
|
|
90
150
|
|
|
91
151
|
const component = mount(TicketConfiguratorComponent, {
|
|
92
152
|
target,
|
|
93
153
|
props: {
|
|
94
|
-
api,
|
|
95
|
-
cartManager,
|
|
154
|
+
api: host.api,
|
|
155
|
+
cartManager: host.cartManager,
|
|
96
156
|
productId: config.productId,
|
|
97
|
-
wizardPages: config.wizardPages,
|
|
98
|
-
autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ??
|
|
157
|
+
wizardPages: config.wizardPages ?? host.options.wizardPages,
|
|
158
|
+
autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? host.options.autoSelectSingleTimeSlot,
|
|
159
|
+
// Stated per configurator so this and the host's own autoOpenCheckout meet in the same branch - see
|
|
160
|
+
// host.svelte.ts. Both have to say yes for an add to open checkout, which is what lets one configurator
|
|
161
|
+
// on a page opt out (the ES equivalent of the element's no-auto-checkout) without silencing the rest.
|
|
162
|
+
autoOpenCheckout: config.autoOpenCheckout !== false,
|
|
99
163
|
onCancel: config.onCancel,
|
|
100
164
|
},
|
|
101
165
|
});
|
|
102
166
|
|
|
103
|
-
// Listen for postMessage events and forward to callbacks
|
|
104
|
-
const stopListening = onWidgetMessage((d) => {
|
|
105
|
-
if (d.type === 'cart:change' && config.onCartChange) {
|
|
106
|
-
config.onCartChange({
|
|
107
|
-
itemCount: (d.itemCount as number) ?? 0,
|
|
108
|
-
cartItemId: (d.cartItemId as string) ?? '',
|
|
109
|
-
totalFormatted: (d.totalFormatted as string) ?? '',
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
113
|
-
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
114
|
-
}
|
|
115
|
-
if (d.type === 'cart:expired') config.onCartExpired?.();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
167
|
return {
|
|
119
168
|
destroy() {
|
|
120
|
-
stopListening();
|
|
121
|
-
sessionManager.stop();
|
|
122
169
|
unmount(component);
|
|
170
|
+
release();
|
|
123
171
|
},
|
|
124
172
|
};
|
|
125
173
|
}
|
|
126
174
|
|
|
127
175
|
export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
|
|
128
|
-
const
|
|
176
|
+
const context = hostFor(config);
|
|
177
|
+
const { host, release } = context;
|
|
129
178
|
|
|
130
|
-
await
|
|
179
|
+
await readyOrRelease(context);
|
|
131
180
|
|
|
132
181
|
const component = mount(CheckoutComponent, {
|
|
133
182
|
target,
|
|
134
183
|
props: {
|
|
135
|
-
api,
|
|
136
|
-
cartManager,
|
|
137
|
-
wizardPages: config.wizardPages,
|
|
138
|
-
editPages: config.editPages,
|
|
139
|
-
autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ??
|
|
184
|
+
api: host.api,
|
|
185
|
+
cartManager: host.cartManager,
|
|
186
|
+
wizardPages: config.wizardPages ?? host.options.wizardPages,
|
|
187
|
+
editPages: config.editPages ?? host.options.editPages,
|
|
188
|
+
autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? host.options.autoSelectSingleTimeSlot,
|
|
189
|
+
mode: config.mode ?? 'inline',
|
|
190
|
+
// The modal opens and closes with the host, which is what an add-to-cart and the cart bar's button
|
|
191
|
+
// both reach. A consumer wanting to drive it directly can call host.openCheckout().
|
|
192
|
+
get open() {
|
|
193
|
+
return host.isCheckoutOpen;
|
|
194
|
+
},
|
|
195
|
+
// Fires for every dismissal, not just the scrim - Checkout answers the modal:close message rather
|
|
196
|
+
// than only its own overlay click.
|
|
197
|
+
onClose: config.onClose,
|
|
140
198
|
},
|
|
141
199
|
});
|
|
142
200
|
|
|
143
|
-
const stopListening = onWidgetMessage((d) => {
|
|
144
|
-
if (d.type === 'modal:close' && config.onClose) config.onClose();
|
|
145
|
-
if (d.type === 'order:complete' && config.onOrderConfirmed) {
|
|
146
|
-
config.onOrderConfirmed({
|
|
147
|
-
cartToken: (d.cartToken as string) ?? '',
|
|
148
|
-
value: (d.value as number) ?? 0,
|
|
149
|
-
currency: (d.currency as string) ?? 'ZAR',
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
153
|
-
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
154
|
-
}
|
|
155
|
-
if (d.type === 'cart:expired') config.onCartExpired?.();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
201
|
return {
|
|
159
202
|
destroy() {
|
|
160
|
-
stopListening();
|
|
161
|
-
sessionManager.stop();
|
|
162
203
|
unmount(component);
|
|
204
|
+
release();
|
|
163
205
|
},
|
|
164
206
|
};
|
|
165
207
|
}
|
|
166
208
|
|
|
167
209
|
export async function mountCartOverview(target: HTMLElement, config: CartOverviewConfig): Promise<MountedWidget> {
|
|
168
|
-
const
|
|
210
|
+
const context = hostFor(config);
|
|
211
|
+
const { host, release } = context;
|
|
169
212
|
|
|
170
|
-
await
|
|
213
|
+
await readyOrRelease(context);
|
|
171
214
|
|
|
172
215
|
const component = mount(CartOverviewComponent, {
|
|
173
216
|
target,
|
|
174
217
|
props: {
|
|
175
|
-
api,
|
|
176
|
-
cartManager,
|
|
218
|
+
api: host.api,
|
|
219
|
+
cartManager: host.cartManager,
|
|
177
220
|
display: config.display ?? 'bar',
|
|
221
|
+
onStateChange: config.onStateChange,
|
|
178
222
|
},
|
|
179
223
|
});
|
|
180
224
|
|
|
181
|
-
const stopListening =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
185
|
-
}
|
|
186
|
-
if (d.type === 'cart:expired') config.onCartExpired?.();
|
|
187
|
-
});
|
|
225
|
+
const stopListening = config.onCheckout
|
|
226
|
+
? onCheckoutRequested(config.onCheckout)
|
|
227
|
+
: () => {};
|
|
188
228
|
|
|
189
229
|
return {
|
|
190
230
|
destroy() {
|
|
191
231
|
stopListening();
|
|
192
|
-
sessionManager.stop();
|
|
193
232
|
unmount(component);
|
|
233
|
+
release();
|
|
194
234
|
},
|
|
195
235
|
};
|
|
196
236
|
}
|
|
237
|
+
|
|
238
|
+
function onCheckoutRequested(onCheckout: () => void): () => void {
|
|
239
|
+
return onWidgetMessage((d) => {
|
|
240
|
+
if (d.type === 'modal:open') onCheckout();
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The configurator is presented two different ways by its host, and the two need different chrome.
|
|
3
|
+
* The marketing site (city-sightseeing-site/marketing-site/apps/web/src/components/BookingWidget.svelte)
|
|
4
|
+
* splits on Tailwind's `lg`: at and above it the widget sits inline in the product page's sticky
|
|
5
|
+
* sidebar, below it that sidebar is display:none and the widget is opened as a full-screen sheet
|
|
6
|
+
* instead. Matching that exact breakpoint here is what keeps the widget's own layout in step with
|
|
7
|
+
* the host's - a widget that thought it was inline while sitting in a full-screen sheet would show
|
|
8
|
+
* a duplicate header and a card that refuses to fill the screen.
|
|
9
|
+
*/
|
|
10
|
+
export const INLINE_LAYOUT_QUERY = '(min-width: 1024px)';
|
|
11
|
+
|
|
12
|
+
// The one place that knows whether this environment can answer the question at all. SSR has no window,
|
|
13
|
+
// and jsdom (this package's test environment) has no matchMedia, so both the seed and the subscription
|
|
14
|
+
// below go through here rather than each guarding differently - or, as happened once, one not guarding.
|
|
15
|
+
function inlineLayoutQuery(): MediaQueryList | null {
|
|
16
|
+
if (typeof window === 'undefined' || !window.matchMedia) return null;
|
|
17
|
+
return window.matchMedia(INLINE_LAYOUT_QUERY);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function matchesInlineLayout(): boolean {
|
|
21
|
+
// Where the question cannot be asked, inline is the safer answer: it is the variant whose chrome the
|
|
22
|
+
// host never duplicates.
|
|
23
|
+
return inlineLayoutQuery()?.matches ?? true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InlineLayout {
|
|
27
|
+
readonly isInline: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Tracks which of the two presentations the widget is currently in. Call during component
|
|
32
|
+
* initialisation - it registers an $effect that follows viewport changes for the component's
|
|
33
|
+
* lifetime, so a desktop window dragged narrow re-lays out rather than keeping stale chrome.
|
|
34
|
+
*/
|
|
35
|
+
export function createInlineLayout(): InlineLayout {
|
|
36
|
+
// Seeded synchronously rather than waiting for the effect below, so the first paint is already
|
|
37
|
+
// the right variant instead of rendering full-screen and visibly correcting itself on desktop.
|
|
38
|
+
let isInline = $state(matchesInlineLayout());
|
|
39
|
+
|
|
40
|
+
$effect(() => {
|
|
41
|
+
const query = inlineLayoutQuery();
|
|
42
|
+
if (!query) return;
|
|
43
|
+
isInline = query.matches;
|
|
44
|
+
const onChange = (e: MediaQueryListEvent) => { isInline = e.matches; };
|
|
45
|
+
query.addEventListener('change', onChange);
|
|
46
|
+
return () => query.removeEventListener('change', onChange);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
get isInline() { return isInline; },
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/lib/messages.ts
CHANGED
|
@@ -3,28 +3,100 @@
|
|
|
3
3
|
// Svelte tree. That makes every one of these messages same-window and same-origin, and the guards below exist
|
|
4
4
|
// to keep it that way.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
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>([
|
|
8
90
|
'cart:change',
|
|
9
91
|
'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
92
|
'cart:expired',
|
|
15
93
|
'modal:open',
|
|
16
94
|
'modal:close',
|
|
17
95
|
'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
96
|
'payment:started',
|
|
22
97
|
'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
98
|
'payment:timed-out',
|
|
27
|
-
]);
|
|
99
|
+
] satisfies WidgetMessageType[]);
|
|
28
100
|
|
|
29
101
|
/**
|
|
30
102
|
* Sends a widget message.
|
|
@@ -34,12 +106,16 @@ const WIDGET_MESSAGE_TYPES = new Set([
|
|
|
34
106
|
* '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
|
|
35
107
|
* not necessarily anyone we trust.
|
|
36
108
|
*/
|
|
37
|
-
export function postMessage(data:
|
|
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;
|
|
38
113
|
window.parent?.postMessage(JSON.stringify(data), window.location.origin);
|
|
39
114
|
}
|
|
40
115
|
|
|
41
116
|
/**
|
|
42
|
-
* Subscribes to widget messages, ignoring anything that did not come from this widget.
|
|
117
|
+
* Subscribes to widget messages, ignoring anything that did not come from this widget. Returns an
|
|
118
|
+
* unsubscribe function.
|
|
43
119
|
*
|
|
44
120
|
* The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
|
|
45
121
|
* the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
|
|
@@ -50,13 +126,17 @@ export function postMessage(data: Record<string, unknown>): void {
|
|
|
50
126
|
* Checking `source` is what does the real work: a message from another frame carries that frame's own window,
|
|
51
127
|
* never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
|
|
52
128
|
*/
|
|
53
|
-
export function onWidgetMessage(handler: (data:
|
|
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
|
+
|
|
54
134
|
function listener(e: MessageEvent) {
|
|
55
135
|
if (e.source !== window || e.origin !== window.location.origin) {
|
|
56
136
|
return;
|
|
57
137
|
}
|
|
58
138
|
|
|
59
|
-
let data:
|
|
139
|
+
let data: WidgetMessage;
|
|
60
140
|
try {
|
|
61
141
|
data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
|
62
142
|
} catch {
|
package/src/lib/peach-sdk.ts
CHANGED
|
@@ -4,23 +4,69 @@ const SDK_URLS: Record<string, string> = {
|
|
|
4
4
|
};
|
|
5
5
|
|
|
6
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
|
+
}
|
|
7
34
|
|
|
8
35
|
// Lazily injects Peach's Copy&Pay SDK the first time PaymentPage mounts, so consumers embedding the widget
|
|
9
36
|
// (any of the three integration options) never have to know this script exists, let alone which
|
|
10
37
|
// environment's URL to point at - same window.BW_CHECKOUT_ENV convention elements/env.ts already uses for
|
|
11
38
|
// the checkout API base URL, with an explicit override for anything that doesn't fit that pattern.
|
|
12
39
|
export function loadPeachSdk(): Promise<void> {
|
|
13
|
-
|
|
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
|
+
}
|
|
14
62
|
return Promise.resolve();
|
|
15
63
|
}
|
|
64
|
+
|
|
16
65
|
if (loadPromise) {
|
|
17
66
|
return loadPromise;
|
|
18
67
|
}
|
|
19
68
|
|
|
20
|
-
|
|
21
|
-
const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
|
|
22
|
-
const src: string = w.BW_CHECKOUT_PEACH_SDK_URL ?? SDK_URLS[env] ?? SDK_URLS.prod;
|
|
23
|
-
|
|
69
|
+
loadedUrl = src;
|
|
24
70
|
loadPromise = new Promise((resolve, reject) => {
|
|
25
71
|
const existing = document.querySelector(`script[src="${src}"]`);
|
|
26
72
|
if (existing) {
|
|
@@ -0,0 +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
|
+
}
|