@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.
@@ -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: 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 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
- });
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
- {#if isLoading || isAddingToCart}
66
- <div class="loading-center" style="height:100vh">
67
- <div class="spinner"></div>
68
- </div>
69
- {:else if product}
70
- <WizardPage
71
- {product}
72
- {api}
73
- {wizardPages}
74
- {autoSelectSingleTimeSlot}
75
- {onCancel}
76
- onComplete={onAddToCart}
77
- />
78
- {/if}
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
- private cartToken = '';
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 Error(`API error ${result.response.status}`);
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 Error(`API error ${result.response.status}`);
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
- body {
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 { cursor: pointer; font-family: inherit; }
45
- input, select, textarea { font-family: inherit; }
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); } }
@@ -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: 'morii',
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: '',
@@ -7,5 +7,4 @@ const w = window as any;
7
7
  const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
8
8
  const explicitUrl: string | undefined = w.BW_CHECKOUT_API_URL;
9
9
 
10
- export const useFakeApi = env === 'fake';
11
10
  export const defaultApiBaseUrl = explicitUrl ?? API_URLS[env] ?? API_URLS.prod;
@@ -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, useFakeApi } from './env';
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 = useFakeApi ? new FakeApiClient() : new ApiClient(defaultApiBaseUrl);
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 { ApiClient } from './api';
6
- import { FakeApiClient } from './fake-api';
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, FakeApiClient, SessionManager, CartManager };
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
- useFakeApi?: boolean;
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.useFakeApi
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
+ }