@code-collective/booking-widget 1.0.9 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +500 -388
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +2415 -1745
  5. package/dist/booking-widget.min.css +1 -1
  6. package/dist/booking-widget.min.js +39 -23
  7. package/dist/booking-widget.umd.cjs +5 -3
  8. package/package.json +58 -55
  9. package/src/lib/BookingProvider.svelte +21 -0
  10. package/src/lib/CartBar.svelte +26 -41
  11. package/src/lib/CartBarView.svelte +78 -61
  12. package/src/lib/CartExpiryGuard.svelte +15 -9
  13. package/src/lib/CartOverview.svelte +30 -20
  14. package/src/lib/CartOverviewButton.svelte +104 -101
  15. package/src/lib/Checkout.svelte +141 -128
  16. package/src/lib/CheckoutModal.svelte +838 -805
  17. package/src/lib/CheckoutPanel.svelte +121 -0
  18. package/src/lib/PaymentPage.svelte +191 -177
  19. package/src/lib/PickupPointPicker.svelte +1 -1
  20. package/src/lib/TicketConfigurator.svelte +166 -152
  21. package/src/lib/UnitCounter.svelte +16 -2
  22. package/src/lib/WizardPage.svelte +102 -35
  23. package/src/lib/app.css +0 -6
  24. package/src/lib/booking-context.ts +33 -0
  25. package/src/lib/cart-overview.svelte.ts +97 -0
  26. package/src/lib/config.ts +162 -153
  27. package/src/lib/elements/bw-cart.svelte +49 -35
  28. package/src/lib/elements/bw-checkout.svelte +97 -97
  29. package/src/lib/elements/bw-configurator.svelte +72 -56
  30. package/src/lib/elements/register.ts +171 -196
  31. package/src/lib/elements/shared.ts +18 -14
  32. package/src/lib/elements/theme.css +0 -6
  33. package/src/lib/host.svelte.ts +336 -0
  34. package/src/lib/index.ts +242 -196
  35. package/src/lib/layout.svelte.ts +52 -0
  36. package/src/lib/messages.ts +157 -77
  37. package/src/lib/peach-sdk.ts +86 -40
  38. package/src/lib/portal.ts +23 -0
  39. package/src/lib/CartExpiryGuard.test.ts +0 -331
  40. package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
  41. package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
  42. package/src/lib/test/fixtures.ts +0 -107
  43. package/src/lib/test/messages-mock.ts +0 -34
@@ -1,77 +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
- /** Messages this widget sends itself. Anything else on the wire is not ours. */
7
- const WIDGET_MESSAGE_TYPES = new Set([
8
- 'cart:change',
9
- 'cart:updated',
10
- // The cart the widget was holding onto is gone because its clock ran out - a strict subset of
11
- // cart:updated's own cart:null case (see that message's own callers), fired alongside it so a host that
12
- // wants to react to expiry specifically (analytics, a redirect, its own message) does not have to infer it
13
- // from an absent cart, which cart:updated also carries for an ordinary manual clear.
14
- 'cart:expired',
15
- 'modal:open',
16
- 'modal:close',
17
- 'order:complete',
18
- // A Peach checkout is open for the cart / has been abandoned so the cart is Open again. Posted by
19
- // CheckoutModal for CartExpiryGuard, which keeps prompting but stops acting on the deadline itself while
20
- // the server exempts the cart from expiry.
21
- 'payment:started',
22
- 'payment:ended',
23
- // The cart's one clock ran out while a Peach checkout was open. Posted by CartExpiryGuard for
24
- // CheckoutModal, which tears the attempt down (Peach's real status permitting) so the shopper sees the
25
- // cart expire there too, rather than an ever-open card form for a cart that is already gone.
26
- 'payment:timed-out',
27
- ]);
28
-
29
- /**
30
- * Sends a widget message.
31
- *
32
- * Targets this window's own origin rather than '*'. These payloads carry `cart`, and CheckoutCartDetailDto
33
- * includes `cartToken` - a bearer credential that authorises reading, modifying and paying for the cart. With
34
- * '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
35
- * not necessarily anyone we trust.
36
- */
37
- export function postMessage(data: Record<string, unknown>): void {
38
- window.parent?.postMessage(JSON.stringify(data), window.location.origin);
39
- }
40
-
41
- /**
42
- * Subscribes to widget messages, ignoring anything that did not come from this widget.
43
- *
44
- * The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
45
- * the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
46
- * chat widget, a tag manager - could reach them with window.parent.postMessage and drive the widget: spoof
47
- * the total on the payment consent screen, fire a forged order confirmation into the merchant's analytics, or
48
- * close the modal mid-confirm on a card that has already been charged.
49
- *
50
- * Checking `source` is what does the real work: a message from another frame carries that frame's own window,
51
- * never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
52
- */
53
- export function onWidgetMessage(handler: (data: Record<string, unknown>) => void): () => void {
54
- function listener(e: MessageEvent) {
55
- if (e.source !== window || e.origin !== window.location.origin) {
56
- return;
57
- }
58
-
59
- let data: Record<string, unknown>;
60
- try {
61
- data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
62
- } catch {
63
- return;
64
- }
65
-
66
- // Anything without one of our own type values is someone else's traffic sharing this window - a library
67
- // or the host page talking to itself - not something to hand to a widget handler.
68
- if (typeof data?.type !== 'string' || !WIDGET_MESSAGE_TYPES.has(data.type)) {
69
- return;
70
- }
71
-
72
- handler(data);
73
- }
74
-
75
- window.addEventListener('message', listener);
76
- return () => window.removeEventListener('message', listener);
77
- }
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,40 +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
-
8
- // Lazily injects Peach's Copy&Pay SDK the first time PaymentPage mounts, so consumers embedding the widget
9
- // (any of the three integration options) never have to know this script exists, let alone which
10
- // environment's URL to point at - same window.BW_CHECKOUT_ENV convention elements/env.ts already uses for
11
- // the checkout API base URL, with an explicit override for anything that doesn't fit that pattern.
12
- export function loadPeachSdk(): Promise<void> {
13
- if ((window as any).Checkout) {
14
- return Promise.resolve();
15
- }
16
- if (loadPromise) {
17
- return loadPromise;
18
- }
19
-
20
- const w = window as any;
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
-
24
- loadPromise = new Promise((resolve, reject) => {
25
- const existing = document.querySelector(`script[src="${src}"]`);
26
- if (existing) {
27
- existing.addEventListener('load', () => resolve());
28
- existing.addEventListener('error', () => reject(new Error('Failed to load the Peach Payments SDK script')));
29
- return;
30
- }
31
-
32
- const script = document.createElement('script');
33
- script.src = src;
34
- script.onload = () => resolve();
35
- script.onerror = () => reject(new Error('Failed to load the Peach Payments SDK script'));
36
- document.head.appendChild(script);
37
- });
38
-
39
- return loadPromise;
40
- }
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
+ }
@@ -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
+ }