@code-collective/booking-widget 1.0.4 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -3
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +1870 -2040
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +9 -5
- package/dist/booking-widget.umd.cjs +2 -2
- package/package.json +1 -1
- package/src/lib/CartBar.svelte +16 -0
- package/src/lib/CartBarView.svelte +1 -1
- package/src/lib/CartOverviewButton.svelte +28 -36
- package/src/lib/Checkout.svelte +55 -28
- package/src/lib/CheckoutModal.svelte +93 -16
- package/src/lib/ContactForm.svelte +15 -6
- package/src/lib/EditBookingView.svelte +49 -4
- package/src/lib/PaymentPage.svelte +30 -25
- package/src/lib/TicketConfigurator.svelte +91 -39
- package/src/lib/api.ts +12 -3
- package/src/lib/app.css +12 -4
- package/src/lib/cart-manager.ts +14 -0
- package/src/lib/config.ts +11 -8
- package/src/lib/elements/env.ts +0 -1
- package/src/lib/elements/register.ts +16 -3
- package/src/lib/index.ts +20 -8
- package/src/lib/peach-sdk.ts +40 -0
- package/src/lib/fake-api.ts +0 -282
- package/src/lib/mock-data.ts +0 -241
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { BookingApi } from './api';
|
|
3
|
+
import { ApiError } from './api';
|
|
3
4
|
import type { WizardPages } from './config';
|
|
5
|
+
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
4
6
|
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
5
7
|
import { CartManager } from './cart-manager';
|
|
6
8
|
import { postMessage } from './messages';
|
|
@@ -12,15 +14,16 @@
|
|
|
12
14
|
api: BookingApi;
|
|
13
15
|
cartManager: CartManager;
|
|
14
16
|
productId: string;
|
|
15
|
-
wizardPages
|
|
17
|
+
wizardPages?: WizardPages;
|
|
16
18
|
autoSelectSingleTimeSlot?: boolean;
|
|
17
19
|
onCancel?: () => void;
|
|
18
20
|
}
|
|
19
|
-
let { api, cartManager, productId, wizardPages, autoSelectSingleTimeSlot = false, onCancel }: Props = $props();
|
|
21
|
+
let { api, cartManager, productId, wizardPages = DEFAULT_WIZARD_PAGES, autoSelectSingleTimeSlot = false, onCancel }: Props = $props();
|
|
20
22
|
|
|
21
23
|
let product = $state<CheckoutProductDto | null>(null);
|
|
22
24
|
let isLoading = $state(true);
|
|
23
25
|
let isAddingToCart = $state(false);
|
|
26
|
+
let addToCartError = $state<string | null>(null);
|
|
24
27
|
|
|
25
28
|
async function load() {
|
|
26
29
|
product = await api.getProduct(productId);
|
|
@@ -29,50 +32,99 @@
|
|
|
29
32
|
|
|
30
33
|
load();
|
|
31
34
|
|
|
35
|
+
async function addItemToCart(item: CartItem): Promise<void> {
|
|
36
|
+
await cartManager.ensureCart();
|
|
37
|
+
|
|
38
|
+
const unitItems = expandUnitItems(item.units);
|
|
39
|
+
const added = await api.addCartItem({
|
|
40
|
+
productId: item.productId,
|
|
41
|
+
optionId: item.optionId,
|
|
42
|
+
unitItems,
|
|
43
|
+
availabilityId: item.availabilityId,
|
|
44
|
+
localDate: item.localDate,
|
|
45
|
+
pickupPointId: item.pickupPointId,
|
|
46
|
+
amount: item.totalPrice,
|
|
47
|
+
currencyCode: item.currency,
|
|
48
|
+
currencyPrecision: item.currencyPrecision,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
52
|
+
postMessage({
|
|
53
|
+
type: 'cart:change',
|
|
54
|
+
itemCount: unitItems.length,
|
|
55
|
+
cartItemId: added.id,
|
|
56
|
+
totalFormatted: total,
|
|
57
|
+
openCheckout: true,
|
|
58
|
+
});
|
|
59
|
+
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
60
|
+
// public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
|
|
61
|
+
// use it to update their own state without each independently re-fetching, and it's forwarded to
|
|
62
|
+
// consumers as the public bw:cart-updated event/onCartUpdated callback - see this event's own doc comment
|
|
63
|
+
// in CheckoutModal (the other place it's posted from, after an edit/remove).
|
|
64
|
+
const cart = await api.getCart().catch(() => null);
|
|
65
|
+
postMessage({ type: 'cart:updated', cart });
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
async function onAddToCart(item: CartItem) {
|
|
33
69
|
isAddingToCart = true;
|
|
70
|
+
addToCartError = null;
|
|
34
71
|
|
|
35
72
|
try {
|
|
36
|
-
await
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
type: 'cart:change',
|
|
54
|
-
itemCount: unitItems.length,
|
|
55
|
-
cartItemId: added.id,
|
|
56
|
-
totalFormatted: total,
|
|
57
|
-
openCheckout: true,
|
|
58
|
-
});
|
|
73
|
+
await addItemToCart(item);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
76
|
+
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
77
|
+
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
78
|
+
// before giving up, rather than leaving the user stuck with no feedback and no way to proceed.
|
|
79
|
+
const isStaleCart = e instanceof ApiError && (e.status === 401 || e.status === 404);
|
|
80
|
+
if (isStaleCart) {
|
|
81
|
+
cartManager.reset();
|
|
82
|
+
try {
|
|
83
|
+
await addItemToCart(item);
|
|
84
|
+
} catch {
|
|
85
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
89
|
+
}
|
|
59
90
|
} finally {
|
|
60
91
|
isAddingToCart = false;
|
|
61
92
|
}
|
|
62
93
|
}
|
|
63
94
|
</script>
|
|
64
95
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<div class="
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{
|
|
96
|
+
<div class="bw-widget">
|
|
97
|
+
{#if isLoading || isAddingToCart}
|
|
98
|
+
<div class="loading-center" style="height:100vh">
|
|
99
|
+
<div class="spinner"></div>
|
|
100
|
+
</div>
|
|
101
|
+
{:else if product}
|
|
102
|
+
{#if addToCartError}
|
|
103
|
+
<p class="add-to-cart-error">{addToCartError}</p>
|
|
104
|
+
{/if}
|
|
105
|
+
<WizardPage
|
|
106
|
+
{product}
|
|
107
|
+
{api}
|
|
108
|
+
{wizardPages}
|
|
109
|
+
{autoSelectSingleTimeSlot}
|
|
110
|
+
{onCancel}
|
|
111
|
+
onComplete={onAddToCart}
|
|
112
|
+
/>
|
|
113
|
+
{/if}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<style>
|
|
117
|
+
/* display: contents - a plain box here would break WizardPage's own .wizard{height:100%}, which needs
|
|
118
|
+
to resolve against this component's real parent, not an unsized wrapper inserted in between. */
|
|
119
|
+
.bw-widget {
|
|
120
|
+
display: contents;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.add-to-cart-error {
|
|
124
|
+
margin: 0;
|
|
125
|
+
padding: 12px 16px;
|
|
126
|
+
background: #fdecea;
|
|
127
|
+
color: #b3261e;
|
|
128
|
+
font-size: 14px;
|
|
129
|
+
}
|
|
130
|
+
</style>
|
package/src/lib/api.ts
CHANGED
|
@@ -15,8 +15,17 @@ import type {
|
|
|
15
15
|
} from './client-types';
|
|
16
16
|
import { dateKey } from './utils';
|
|
17
17
|
|
|
18
|
+
// Lets a caller distinguish "the server said no, and specifically why" (e.g. 402 - not paid yet, retryable
|
|
19
|
+
// while Peach's webhook is still in flight) from any other failure, without parsing the message string.
|
|
20
|
+
export class ApiError extends Error {
|
|
21
|
+
constructor(public readonly status: number) {
|
|
22
|
+
super(`API error ${status}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
export interface BookingApi {
|
|
19
27
|
sessionToken: string;
|
|
28
|
+
cartToken: string;
|
|
20
29
|
|
|
21
30
|
// Session
|
|
22
31
|
startSession(checkoutKey: string): Promise<CheckoutSessionDto>;
|
|
@@ -46,7 +55,7 @@ export interface BookingApi {
|
|
|
46
55
|
|
|
47
56
|
export class ApiClient implements BookingApi {
|
|
48
57
|
sessionToken = '';
|
|
49
|
-
|
|
58
|
+
cartToken = '';
|
|
50
59
|
private client;
|
|
51
60
|
|
|
52
61
|
constructor(baseUrl: string) {
|
|
@@ -69,7 +78,7 @@ export class ApiClient implements BookingApi {
|
|
|
69
78
|
|
|
70
79
|
private unwrap<T>(result: { data?: T; error?: unknown; response: Response }): T {
|
|
71
80
|
if (result.data === undefined) {
|
|
72
|
-
throw new
|
|
81
|
+
throw new ApiError(result.response.status);
|
|
73
82
|
}
|
|
74
83
|
return result.data;
|
|
75
84
|
}
|
|
@@ -184,7 +193,7 @@ export class ApiClient implements BookingApi {
|
|
|
184
193
|
params: { path: { itemId } },
|
|
185
194
|
});
|
|
186
195
|
if (result.response.status >= 400) {
|
|
187
|
-
throw new
|
|
196
|
+
throw new ApiError(result.response.status);
|
|
188
197
|
}
|
|
189
198
|
}
|
|
190
199
|
|
package/src/lib/app.css
CHANGED
|
@@ -26,13 +26,17 @@
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/* ── Reset ─────────────────────────────────────────────── */
|
|
29
|
-
|
|
29
|
+
/* Scoped to .bw-widget (present on every mountable component's own root - TicketConfigurator, Checkout,
|
|
30
|
+
CartBar, CartOverviewButton) rather than the bare selectors this used to be, since Option 2/3 consumers
|
|
31
|
+
mount these directly into their own app alongside their own global styles/resets (Tailwind preflight,
|
|
32
|
+
etc.) - an unscoped * or body rule here would leak onto their entire page, not just the widget. */
|
|
33
|
+
.bw-widget, .bw-widget * {
|
|
30
34
|
margin: 0;
|
|
31
35
|
padding: 0;
|
|
32
36
|
box-sizing: border-box;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
.bw-widget {
|
|
36
40
|
font-family: var(--bw-font-family);
|
|
37
41
|
font-size: 14px;
|
|
38
42
|
line-height: 1.5;
|
|
@@ -41,8 +45,12 @@ body {
|
|
|
41
45
|
-webkit-font-smoothing: antialiased;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
button
|
|
45
|
-
|
|
48
|
+
/* button/input/etc form controls don't inherit font-family by default even from an ancestor that sets it
|
|
49
|
+
(a standard browser quirk, not specific to this reset) - covers both a bw-widget div wrapping a button
|
|
50
|
+
(TicketConfigurator, Checkout) and bw-widget being set directly on the control itself (CartOverviewButton's
|
|
51
|
+
own <button>), since a plain descendant selector alone only matches the first case. */
|
|
52
|
+
.bw-widget button, button.bw-widget { cursor: pointer; font-family: inherit; }
|
|
53
|
+
.bw-widget :is(input, select, textarea), :is(input, select, textarea).bw-widget { font-family: inherit; }
|
|
46
54
|
|
|
47
55
|
/* ── Spinner ───────────────────────────────────────────── */
|
|
48
56
|
@keyframes spin { to { transform: rotate(360deg); } }
|
package/src/lib/cart-manager.ts
CHANGED
|
@@ -34,6 +34,19 @@ export class CartManager {
|
|
|
34
34
|
return cart;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// The client only knows a cart's absolute/idle expiry from whenever it was last (re)created here - it
|
|
38
|
+
// never sees the server's sliding idle window update in between, so a cart the server has since rejected
|
|
39
|
+
// (e.g. idle timeout) can still look valid locally. Callers use this to drop that stale state and force
|
|
40
|
+
// ensureCart to mint a fresh one after the server refuses an operation on the cart it was given.
|
|
41
|
+
reset(): void {
|
|
42
|
+
this.cart = null;
|
|
43
|
+
try {
|
|
44
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
45
|
+
} catch {
|
|
46
|
+
// localStorage unavailable (e.g. iframe sandbox)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
private isExpired(): boolean {
|
|
38
51
|
if (!this.cart) return true;
|
|
39
52
|
return new Date(this.cart.absoluteExpiresAt ?? 0).getTime() <= Date.now();
|
|
@@ -69,6 +82,7 @@ export class CartManager {
|
|
|
69
82
|
idleExpiresAt: '',
|
|
70
83
|
absoluteExpiresAt: stored.absoluteExpiresAt,
|
|
71
84
|
};
|
|
85
|
+
this.api.cartToken = stored.cartToken;
|
|
72
86
|
} catch {
|
|
73
87
|
// corrupt or unavailable
|
|
74
88
|
}
|
package/src/lib/config.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// Defaults for the standalone dev app (App.svelte) when no query params are given at all, or no apiBaseUrl
|
|
2
|
+
// is given - same real QA product booking/demo/product.html's own fallback attributes point at, so `npm run
|
|
3
|
+
// dev` with no params still shows something real instead of talking to an empty relative URL.
|
|
4
|
+
const DEFAULT_QA_API_BASE_URL = 'https://qa.tranzact.co.za/morii-checkout/api';
|
|
5
|
+
const DEFAULT_QA_PRODUCT_ID = 'fec72dc6-0357-411b-88f5-d37083ab88cb';
|
|
6
|
+
const DEFAULT_QA_CHECKOUT_KEY = 'morii_checkout_key_2qGS3onnv2eigOiV6RT1yQ';
|
|
7
|
+
|
|
1
8
|
export type WidgetMode = 'configurator' | 'checkout' | 'cart-overview';
|
|
2
9
|
|
|
3
10
|
export type CartOverviewDisplay = 'bar' | 'button';
|
|
@@ -81,7 +88,6 @@ export interface AppConfig {
|
|
|
81
88
|
productId: string;
|
|
82
89
|
checkoutKey: string;
|
|
83
90
|
currency: string;
|
|
84
|
-
useFakeApi: boolean;
|
|
85
91
|
mode: WidgetMode;
|
|
86
92
|
cartDisplay: CartOverviewDisplay;
|
|
87
93
|
orderId: string;
|
|
@@ -96,14 +102,12 @@ export function readConfig(): AppConfig {
|
|
|
96
102
|
const params = new URLSearchParams(window.location.search);
|
|
97
103
|
|
|
98
104
|
if (params.has('product') || params.has('mode')) {
|
|
99
|
-
const apiBaseUrl = params.get('apiBaseUrl') ?? '';
|
|
100
105
|
const productId = params.get('product') ?? '';
|
|
101
106
|
return {
|
|
102
|
-
apiBaseUrl,
|
|
107
|
+
apiBaseUrl: params.get('apiBaseUrl') ?? DEFAULT_QA_API_BASE_URL,
|
|
103
108
|
productId,
|
|
104
109
|
checkoutKey: params.get('checkoutKey') ?? deriveCheckoutKey(productId),
|
|
105
110
|
currency: params.get('currency') ?? 'ZAR',
|
|
106
|
-
useFakeApi: params.get('useFakeApi') === 'true' || !apiBaseUrl,
|
|
107
111
|
mode: parseMode(params.get('mode')),
|
|
108
112
|
cartDisplay: parseCartDisplay(params.get('cartDisplay')),
|
|
109
113
|
orderId: params.get('orderId') ?? '',
|
|
@@ -116,11 +120,10 @@ export function readConfig(): AppConfig {
|
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
return {
|
|
119
|
-
apiBaseUrl:
|
|
120
|
-
productId:
|
|
121
|
-
checkoutKey:
|
|
123
|
+
apiBaseUrl: DEFAULT_QA_API_BASE_URL,
|
|
124
|
+
productId: DEFAULT_QA_PRODUCT_ID,
|
|
125
|
+
checkoutKey: DEFAULT_QA_CHECKOUT_KEY,
|
|
122
126
|
currency: 'ZAR',
|
|
123
|
-
useFakeApi: true,
|
|
124
127
|
mode: 'configurator',
|
|
125
128
|
cartDisplay: 'bar',
|
|
126
129
|
orderId: '',
|
package/src/lib/elements/env.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import '../app.css';
|
|
2
2
|
import type { BookingApi } from '../api';
|
|
3
3
|
import { ApiClient } from '../api';
|
|
4
|
-
import { FakeApiClient } from '../fake-api';
|
|
5
4
|
import { SessionManager } from '../session-manager';
|
|
6
5
|
import { CartManager } from '../cart-manager';
|
|
7
|
-
import { defaultApiBaseUrl
|
|
6
|
+
import { defaultApiBaseUrl } from './env';
|
|
8
7
|
|
|
9
8
|
// Bootstrap shared services BEFORE element imports trigger connectedCallback.
|
|
10
9
|
// Elements read from window.__bwServices instead of importing shared.ts,
|
|
11
10
|
// because the IIFE bundler inlines imports per-element — a module-level
|
|
12
11
|
// singleton would be duplicated. window is the only truly shared scope.
|
|
13
12
|
const checkoutKey = document.querySelector('[checkout-key]')?.getAttribute('checkout-key') ?? '';
|
|
14
|
-
const api: BookingApi =
|
|
13
|
+
const api: BookingApi = new ApiClient(defaultApiBaseUrl);
|
|
15
14
|
const sessionManager = new SessionManager(api);
|
|
16
15
|
const cartManager = new CartManager(api);
|
|
17
16
|
sessionManager.startBackgroundRefresh();
|
|
@@ -22,6 +21,20 @@ import './bw-configurator.svelte';
|
|
|
22
21
|
import './bw-cart.svelte';
|
|
23
22
|
import './bw-checkout.svelte';
|
|
24
23
|
|
|
24
|
+
// Cart-global, not tied to any one element (unlike the bw:* events forwarded per-element in autoWire below),
|
|
25
|
+
// so this is wired once here rather than per bw-configurator/bw-cart/bw-checkout instance. Carries the same
|
|
26
|
+
// cart TicketConfigurator/CheckoutModal already fetch for their own posting - see those files' own comments -
|
|
27
|
+
// so a consumer can build a custom cart summary (item count, remaining time, item details) without calling
|
|
28
|
+
// the checkout API directly.
|
|
29
|
+
window.addEventListener('message', (e) => {
|
|
30
|
+
let d: Record<string, unknown>;
|
|
31
|
+
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
32
|
+
catch { return; }
|
|
33
|
+
if (d?.type === 'cart:updated' && 'cart' in d) {
|
|
34
|
+
window.dispatchEvent(new CustomEvent('bw:cart-updated', { detail: { cart: d.cart } }));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
25
38
|
interface BwOptions {
|
|
26
39
|
shouldBottomCloseOnModal: boolean;
|
|
27
40
|
autoSelectSingleTimeSlot: boolean;
|
package/src/lib/index.ts
CHANGED
|
@@ -2,8 +2,8 @@ import './app.css';
|
|
|
2
2
|
import { mount, unmount } from 'svelte';
|
|
3
3
|
import type { WizardPages, CartOverviewDisplay } from './config';
|
|
4
4
|
import type { BookingApi } from './api';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import type { CheckoutCartDetailDto } from './client-types';
|
|
6
|
+
import { ApiClient, ApiError } from './api';
|
|
7
7
|
import { SessionManager } from './session-manager';
|
|
8
8
|
import { CartManager } from './cart-manager';
|
|
9
9
|
import TicketConfiguratorComponent from './TicketConfigurator.svelte';
|
|
@@ -18,9 +18,10 @@ export { default as CartBar } from './CartBar.svelte';
|
|
|
18
18
|
export { default as CartOverviewButton } from './CartOverviewButton.svelte';
|
|
19
19
|
|
|
20
20
|
// Re-export utilities
|
|
21
|
-
export { ApiClient,
|
|
21
|
+
export { ApiClient, ApiError, SessionManager, CartManager };
|
|
22
|
+
export { resolveWizardPages, resolveEditPages, DEFAULT_WIZARD_PAGES } from './config';
|
|
22
23
|
export type { BookingApi } from './api';
|
|
23
|
-
export type { WizardPages, CartOverviewDisplay, WidgetMode, WizardWidgetType } from './config';
|
|
24
|
+
export type { WizardPages, WizardPageConfig, CartOverviewDisplay, WidgetMode, WizardWidgetType } from './config';
|
|
24
25
|
export type { components, operations, paths } from './generated-types';
|
|
25
26
|
export type * from './client-types';
|
|
26
27
|
|
|
@@ -29,7 +30,11 @@ export type * from './client-types';
|
|
|
29
30
|
interface BaseConfig {
|
|
30
31
|
apiBaseUrl?: string;
|
|
31
32
|
checkoutKey?: string;
|
|
32
|
-
|
|
33
|
+
// Cart-global, not tied to any one mount function - fires after any add/edit/remove, anywhere on the page,
|
|
34
|
+
// carrying the same cart TicketConfigurator/CheckoutModal already fetch for their own internal use. Lets a
|
|
35
|
+
// consumer build a custom cart summary (item count, remaining time, item details) without calling the
|
|
36
|
+
// checkout API directly.
|
|
37
|
+
onCartUpdated?: (cart: CheckoutCartDetailDto | null) => void;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface ConfiguratorConfig extends BaseConfig {
|
|
@@ -62,9 +67,7 @@ export interface MountedWidget {
|
|
|
62
67
|
// --- Shared bootstrap ---
|
|
63
68
|
|
|
64
69
|
function bootstrap(config: BaseConfig): { api: BookingApi; sessionManager: SessionManager; cartManager: CartManager } {
|
|
65
|
-
const api: BookingApi = config.
|
|
66
|
-
? new FakeApiClient()
|
|
67
|
-
: new ApiClient(config.apiBaseUrl ?? '');
|
|
70
|
+
const api: BookingApi = new ApiClient(config.apiBaseUrl ?? '');
|
|
68
71
|
|
|
69
72
|
const sessionManager = new SessionManager(api);
|
|
70
73
|
const cartManager = new CartManager(api);
|
|
@@ -106,6 +109,9 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
|
|
|
106
109
|
totalFormatted: (d.totalFormatted as string) ?? '',
|
|
107
110
|
});
|
|
108
111
|
}
|
|
112
|
+
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
113
|
+
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
114
|
+
}
|
|
109
115
|
}
|
|
110
116
|
window.addEventListener('message', messageHandler);
|
|
111
117
|
|
|
@@ -147,6 +153,9 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
|
|
|
147
153
|
currency: (d.currency as string) ?? 'ZAR',
|
|
148
154
|
});
|
|
149
155
|
}
|
|
156
|
+
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
157
|
+
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
158
|
+
}
|
|
150
159
|
}
|
|
151
160
|
window.addEventListener('message', messageHandler);
|
|
152
161
|
|
|
@@ -180,6 +189,9 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
|
|
|
180
189
|
if (!d?.type) return;
|
|
181
190
|
|
|
182
191
|
if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
|
|
192
|
+
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
193
|
+
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
194
|
+
}
|
|
183
195
|
}
|
|
184
196
|
window.addEventListener('message', messageHandler);
|
|
185
197
|
|
|
@@ -0,0 +1,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
|
+
|
|
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
|
+
}
|