@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
package/src/lib/index.ts CHANGED
@@ -1,196 +1,242 @@
1
- import './app.css';
2
- import { mount, unmount } from 'svelte';
3
- import type { WizardPages, CartOverviewDisplay } from './config';
4
- import type { BookingApi } from './api';
5
- import type { CheckoutCartDetailDto } from './client-types';
6
- import { ApiClient, ApiError } from './api';
7
- import { SessionManager } from './session-manager';
8
- import { CartManager } from './cart-manager';
9
- import { onWidgetMessage } from './messages';
10
- import TicketConfiguratorComponent from './TicketConfigurator.svelte';
11
- import CheckoutComponent from './Checkout.svelte';
12
- import CartOverviewComponent from './CartOverview.svelte';
13
-
14
- // Re-export components for direct Svelte usage
15
- export { default as TicketConfigurator } from './TicketConfigurator.svelte';
16
- export { default as Checkout } from './Checkout.svelte';
17
- export { default as CartOverview } from './CartOverview.svelte';
18
- export { default as CartBar } from './CartBar.svelte';
19
- export { default as CartOverviewButton } from './CartOverviewButton.svelte';
20
-
21
- // Re-export utilities
22
- export { ApiClient, ApiError, SessionManager, CartManager };
23
- export { resolveWizardPages, resolveEditPages, DEFAULT_WIZARD_PAGES } from './config';
24
- export type { BookingApi } from './api';
25
- export type { WizardPages, WizardPageConfig, CartOverviewDisplay, WidgetMode, WizardWidgetType } from './config';
26
- export type { components, operations, paths } from './generated-types';
27
- export type * from './client-types';
28
-
29
- // --- Mount function configs ---
30
-
31
- interface BaseConfig {
32
- apiBaseUrl?: string;
33
- checkoutKey?: string;
34
- // Cart-global, not tied to any one mount function - fires after any add/edit/remove, anywhere on the page,
35
- // carrying the same cart TicketConfigurator/CheckoutModal already fetch for their own internal use. Lets a
36
- // consumer build a custom cart summary (item count, remaining time, item details) without calling the
37
- // checkout API directly.
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
- }
44
-
45
- export interface ConfiguratorConfig extends BaseConfig {
46
- productId: string;
47
- currency?: string;
48
- wizardPages?: WizardPages;
49
- autoSelectSingleTimeSlot?: boolean;
50
- cancelable?: boolean;
51
- onCartChange?: (detail: { itemCount: number; cartItemId: string; totalFormatted: string }) => void;
52
- onCancel?: () => void;
53
- }
54
-
55
- export interface CheckoutConfig extends BaseConfig {
56
- wizardPages?: WizardPages;
57
- editPages?: WizardPages;
58
- autoSelectSingleTimeSlot?: boolean;
59
- onClose?: () => void;
60
- onOrderConfirmed?: (detail: { cartToken: string; value: number; currency: string }) => void;
61
- }
62
-
63
- export interface CartOverviewConfig extends BaseConfig {
64
- display?: CartOverviewDisplay;
65
- onCheckout?: () => void;
66
- }
67
-
68
- export interface MountedWidget {
69
- destroy: () => void;
70
- }
71
-
72
- // --- Shared bootstrap ---
73
-
74
- function bootstrap(config: BaseConfig): { api: BookingApi; sessionManager: SessionManager; cartManager: CartManager } {
75
- const api: BookingApi = new ApiClient(config.apiBaseUrl ?? '');
76
-
77
- const sessionManager = new SessionManager(api);
78
- const cartManager = new CartManager(api);
79
- sessionManager.startBackgroundRefresh();
80
-
81
- return { api, sessionManager, cartManager };
82
- }
83
-
84
- // --- Mount functions ---
85
-
86
- export async function mountConfigurator(target: HTMLElement, config: ConfiguratorConfig): Promise<MountedWidget> {
87
- const { api, sessionManager, cartManager } = bootstrap(config);
88
-
89
- await sessionManager.ensureSession(config.checkoutKey ?? '');
90
-
91
- const component = mount(TicketConfiguratorComponent, {
92
- target,
93
- props: {
94
- api,
95
- cartManager,
96
- productId: config.productId,
97
- wizardPages: config.wizardPages,
98
- autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
99
- onCancel: config.onCancel,
100
- },
101
- });
102
-
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
- return {
119
- destroy() {
120
- stopListening();
121
- sessionManager.stop();
122
- unmount(component);
123
- },
124
- };
125
- }
126
-
127
- export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
128
- const { api, sessionManager, cartManager } = bootstrap(config);
129
-
130
- await sessionManager.ensureSession(config.checkoutKey ?? '');
131
-
132
- const component = mount(CheckoutComponent, {
133
- target,
134
- props: {
135
- api,
136
- cartManager,
137
- wizardPages: config.wizardPages,
138
- editPages: config.editPages,
139
- autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
140
- },
141
- });
142
-
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
- return {
159
- destroy() {
160
- stopListening();
161
- sessionManager.stop();
162
- unmount(component);
163
- },
164
- };
165
- }
166
-
167
- export async function mountCartOverview(target: HTMLElement, config: CartOverviewConfig): Promise<MountedWidget> {
168
- const { api, sessionManager, cartManager } = bootstrap(config);
169
-
170
- await sessionManager.ensureSession(config.checkoutKey ?? '');
171
-
172
- const component = mount(CartOverviewComponent, {
173
- target,
174
- props: {
175
- api,
176
- cartManager,
177
- display: config.display ?? 'bar',
178
- },
179
- });
180
-
181
- const stopListening = onWidgetMessage((d) => {
182
- if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
183
- if (d.type === 'cart:updated' && 'cart' in d) {
184
- config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
185
- }
186
- if (d.type === 'cart:expired') config.onCartExpired?.();
187
- });
188
-
189
- return {
190
- destroy() {
191
- stopListening();
192
- sessionManager.stop();
193
- unmount(component);
194
- },
195
- };
196
- }
1
+ import './app.css';
2
+ import { mount, unmount } from 'svelte';
3
+ import type { CartOverviewDisplay, CartOverviewState } from './config';
4
+ import type { BookingHost, BookingHostConfig } from './host.svelte';
5
+ import { createBookingHost } from './host.svelte';
6
+ import { ApiClient, ApiError } from './api';
7
+ import { SessionManager } from './session-manager';
8
+ import { CartManager } from './cart-manager';
9
+ import { onWidgetMessage } from './messages';
10
+ import TicketConfiguratorComponent from './TicketConfigurator.svelte';
11
+ import CheckoutComponent from './Checkout.svelte';
12
+ import CartOverviewComponent from './CartOverview.svelte';
13
+
14
+ // Re-export components for direct Svelte usage
15
+ export { default as TicketConfigurator } from './TicketConfigurator.svelte';
16
+ export { default as Checkout } from './Checkout.svelte';
17
+ export { default as CartOverview } from './CartOverview.svelte';
18
+ export { default as CartBar } from './CartBar.svelte';
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';
26
+
27
+ // Re-export utilities
28
+ export { ApiClient, ApiError, SessionManager, CartManager };
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';
36
+ export type { BookingApi } from './api';
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';
46
+ export type { components, operations, paths } from './generated-types';
47
+ export type * from './client-types';
48
+
49
+ // --- Mount function configs ---
50
+
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
+ };
58
+
59
+ export interface ConfiguratorConfig extends BaseConfig {
60
+ productId: string;
61
+ currency?: string;
62
+ cancelable?: boolean;
63
+ // onCartChange and the other cart callbacks come from BookingHostConfig - see createBookingHost.
64
+ onCancel?: () => void;
65
+ }
66
+
67
+ export interface CheckoutConfig extends BaseConfig {
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';
71
+ onClose?: () => void;
72
+ }
73
+
74
+ export interface CartOverviewConfig extends BaseConfig {
75
+ display?: CartOverviewDisplay;
76
+ onCheckout?: () => void;
77
+ /** Distinguishes an empty cart from one that could not be read - see CartOverview. */
78
+ onStateChange?: (state: CartOverviewState) => void;
79
+ }
80
+
81
+ export interface MountedWidget {
82
+ destroy: () => void;
83
+ }
84
+
85
+ // --- Mount functions ---
86
+
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
+ }
92
+
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
+ }
111
+
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) };
123
+ }
124
+
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
+ }
144
+
145
+ export async function mountConfigurator(target: HTMLElement, config: ConfiguratorConfig): Promise<MountedWidget> {
146
+ const context = hostFor(config);
147
+ const { host, release } = context;
148
+
149
+ await readyOrRelease(context);
150
+
151
+ const component = mount(TicketConfiguratorComponent, {
152
+ target,
153
+ props: {
154
+ api: host.api,
155
+ cartManager: host.cartManager,
156
+ productId: config.productId,
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,
163
+ onCancel: config.onCancel,
164
+ },
165
+ });
166
+
167
+ return {
168
+ destroy() {
169
+ unmount(component);
170
+ release();
171
+ },
172
+ };
173
+ }
174
+
175
+ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
176
+ const context = hostFor(config);
177
+ const { host, release } = context;
178
+
179
+ await readyOrRelease(context);
180
+
181
+ const component = mount(CheckoutComponent, {
182
+ target,
183
+ props: {
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,
198
+ },
199
+ });
200
+
201
+ return {
202
+ destroy() {
203
+ unmount(component);
204
+ release();
205
+ },
206
+ };
207
+ }
208
+
209
+ export async function mountCartOverview(target: HTMLElement, config: CartOverviewConfig): Promise<MountedWidget> {
210
+ const context = hostFor(config);
211
+ const { host, release } = context;
212
+
213
+ await readyOrRelease(context);
214
+
215
+ const component = mount(CartOverviewComponent, {
216
+ target,
217
+ props: {
218
+ api: host.api,
219
+ cartManager: host.cartManager,
220
+ display: config.display ?? 'bar',
221
+ onStateChange: config.onStateChange,
222
+ },
223
+ });
224
+
225
+ const stopListening = config.onCheckout
226
+ ? onCheckoutRequested(config.onCheckout)
227
+ : () => {};
228
+
229
+ return {
230
+ destroy() {
231
+ stopListening();
232
+ unmount(component);
233
+ release();
234
+ },
235
+ };
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
+ }