@code-collective/booking-widget 1.0.6 → 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/src/lib/api.ts CHANGED
@@ -15,6 +15,14 @@ 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;
20
28
  cartToken: string;
@@ -70,7 +78,7 @@ export class ApiClient implements BookingApi {
70
78
 
71
79
  private unwrap<T>(result: { data?: T; error?: unknown; response: Response }): T {
72
80
  if (result.data === undefined) {
73
- throw new Error(`API error ${result.response.status}`);
81
+ throw new ApiError(result.response.status);
74
82
  }
75
83
  return result.data;
76
84
  }
@@ -185,7 +193,7 @@ export class ApiClient implements BookingApi {
185
193
  params: { path: { itemId } },
186
194
  });
187
195
  if (result.response.status >= 400) {
188
- throw new Error(`API error ${result.response.status}`);
196
+ throw new ApiError(result.response.status);
189
197
  }
190
198
  }
191
199
 
@@ -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();
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,7 +18,7 @@ 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
22
  export { resolveWizardPages, resolveEditPages, DEFAULT_WIZARD_PAGES } from './config';
23
23
  export type { BookingApi } from './api';
24
24
  export type { WizardPages, WizardPageConfig, CartOverviewDisplay, WidgetMode, WizardWidgetType } from './config';
@@ -30,7 +30,11 @@ export type * from './client-types';
30
30
  interface BaseConfig {
31
31
  apiBaseUrl?: string;
32
32
  checkoutKey?: string;
33
- 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;
34
38
  }
35
39
 
36
40
  export interface ConfiguratorConfig extends BaseConfig {
@@ -63,9 +67,7 @@ export interface MountedWidget {
63
67
  // --- Shared bootstrap ---
64
68
 
65
69
  function bootstrap(config: BaseConfig): { api: BookingApi; sessionManager: SessionManager; cartManager: CartManager } {
66
- const api: BookingApi = config.useFakeApi
67
- ? new FakeApiClient()
68
- : new ApiClient(config.apiBaseUrl ?? '');
70
+ const api: BookingApi = new ApiClient(config.apiBaseUrl ?? '');
69
71
 
70
72
  const sessionManager = new SessionManager(api);
71
73
  const cartManager = new CartManager(api);
@@ -107,6 +109,9 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
107
109
  totalFormatted: (d.totalFormatted as string) ?? '',
108
110
  });
109
111
  }
112
+ if (d.type === 'cart:updated' && 'cart' in d) {
113
+ config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
114
+ }
110
115
  }
111
116
  window.addEventListener('message', messageHandler);
112
117
 
@@ -148,6 +153,9 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
148
153
  currency: (d.currency as string) ?? 'ZAR',
149
154
  });
150
155
  }
156
+ if (d.type === 'cart:updated' && 'cart' in d) {
157
+ config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
158
+ }
151
159
  }
152
160
  window.addEventListener('message', messageHandler);
153
161
 
@@ -181,6 +189,9 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
181
189
  if (!d?.type) return;
182
190
 
183
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
+ }
184
195
  }
185
196
  window.addEventListener('message', messageHandler);
186
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
+ }
@@ -1,282 +0,0 @@
1
- import type {
2
- CheckoutAddCartItemDto,
3
- CheckoutSessionDto,
4
- CheckoutProductDto,
5
- CheckoutAvailabilityCalendarDto,
6
- CheckoutAvailabilityDto,
7
- CheckoutCartResult,
8
- CheckoutCartDetailDto,
9
- CheckoutCartItemDetailDto,
10
- CheckoutCartItemAddedDto,
11
- CheckoutCartPaymentInitiationDto,
12
- CheckoutCartConfirmResultDto,
13
- CheckoutCartConfirmItemResultDto,
14
- OctoContact,
15
- } from './client-types';
16
- import type { BookingApi } from './api';
17
- import {
18
- sampleProductData,
19
- generateSampleAvailability,
20
- generateSampleTimeSlots,
21
- } from './mock-data';
22
-
23
- function delay<T>(ms: number, fn: () => T): Promise<T> {
24
- return new Promise((r) => setTimeout(() => r(fn()), ms));
25
- }
26
-
27
- function uuid(): string {
28
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
29
- const r = (Math.random() * 16) | 0;
30
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
31
- return v.toString(16);
32
- });
33
- }
34
-
35
- interface FakeCartItem {
36
- id: string;
37
- bookingUuid: string;
38
- request: CheckoutAddCartItemDto;
39
- }
40
-
41
- const CART_STORAGE_KEY = 'morii-checkout-cart';
42
- const ITEMS_STORAGE_KEY = 'morii-checkout-cart-items';
43
-
44
- export class FakeApiClient implements BookingApi {
45
- sessionToken = '';
46
- cartToken = '';
47
- private cartItems: FakeCartItem[] = [];
48
-
49
- constructor() {
50
- this.loadCartFromStorage() || this.seedCart();
51
- }
52
-
53
- private seedCart(): void {
54
- this.cartToken = `fake-cart-seeded`;
55
-
56
- this.cartItems = [
57
- {
58
- id: '00000000-0000-0000-0000-000000000001',
59
- bookingUuid: '00000000-0000-0000-0000-aaaaaaaaaaaa',
60
- request: {
61
- productId: sampleProductData.id,
62
- optionId: 'option_blue_tram',
63
- unitItems: [{ unitId: 'unit_adult' }, { unitId: 'unit_adult' }, { unitId: 'unit_child' }],
64
- availabilityId: 'avail_seeded',
65
- amount: 202500,
66
- currencyCode: 'ZAR',
67
- currencyPrecision: 2,
68
- },
69
- },
70
- ];
71
-
72
- this.saveCartToStorage();
73
- }
74
-
75
- private saveCartToStorage(): void {
76
- try {
77
- const now = new Date();
78
- const absolute = new Date(now.getTime() + 60 * 60_000);
79
- localStorage.setItem(CART_STORAGE_KEY, JSON.stringify({
80
- cartToken: this.cartToken,
81
- absoluteExpiresAt: absolute.toISOString(),
82
- }));
83
- localStorage.setItem(ITEMS_STORAGE_KEY, JSON.stringify(this.cartItems));
84
- } catch {
85
- // localStorage unavailable
86
- }
87
- }
88
-
89
- private loadCartFromStorage(): boolean {
90
- try {
91
- const cartRaw = localStorage.getItem(CART_STORAGE_KEY);
92
- const itemsRaw = localStorage.getItem(ITEMS_STORAGE_KEY);
93
- if (!cartRaw || !itemsRaw) return false;
94
-
95
- const cart = JSON.parse(cartRaw);
96
- if (new Date(cart.absoluteExpiresAt).getTime() <= Date.now()) return false;
97
-
98
- this.cartToken = cart.cartToken;
99
- this.cartItems = JSON.parse(itemsRaw);
100
- return true;
101
- } catch {
102
- return false;
103
- }
104
- }
105
-
106
- // --- Session ---
107
-
108
- async startSession(_checkoutKey: string): Promise<CheckoutSessionDto> {
109
- return delay(300, () => {
110
- const now = new Date();
111
- const expires = new Date(now.getTime() + 30 * 60_000);
112
- const refresh = new Date(now.getTime() + 20 * 60_000);
113
- this.sessionToken = `fake-session-${uuid().slice(0, 8)}`;
114
- const session: CheckoutSessionDto = {
115
- sessionToken: this.sessionToken,
116
- issuedAt: now.toISOString(),
117
- expiresAt: expires.toISOString(),
118
- refreshAfter: refresh.toISOString(),
119
- octo: {
120
- basePath: '/v1/checkout/octo',
121
- capabilities: 'pricing,content,pickups',
122
- },
123
- supplier: {
124
- name: 'Franschhoek Wine Tram',
125
- },
126
- };
127
- return session;
128
- });
129
- }
130
-
131
- async refreshSession(): Promise<CheckoutSessionDto> {
132
- return this.startSession('');
133
- }
134
-
135
- // --- Catalog ---
136
-
137
- async getProduct(productId: string): Promise<CheckoutProductDto> {
138
- return delay(500, () => ({ ...sampleProductData, id: productId }));
139
- }
140
-
141
- async getAvailabilityCalendar(
142
- _productId: string,
143
- _optionId: string,
144
- ): Promise<Record<string, CheckoutAvailabilityCalendarDto>> {
145
- return delay(600, () => generateSampleAvailability());
146
- }
147
-
148
- async getAvailability(
149
- _productId: string,
150
- _optionId: string,
151
- localDate: string,
152
- ): Promise<CheckoutAvailabilityDto[]> {
153
- return delay(400, () => {
154
- const parts = localDate.split('-').map(Number);
155
- const date = new Date(parts[0], parts[1] - 1, parts[2]);
156
- return generateSampleTimeSlots(date);
157
- });
158
- }
159
-
160
- // --- Cart ---
161
-
162
- async createCart(): Promise<CheckoutCartResult> {
163
- return delay(300, () => {
164
- const now = new Date();
165
- const idle = new Date(now.getTime() + 15 * 60_000);
166
- const absolute = new Date(now.getTime() + 60 * 60_000);
167
-
168
- if (this.cartToken && this.cartItems.length > 0) {
169
- return {
170
- cartToken: this.cartToken,
171
- issuedAt: now.toISOString(),
172
- idleExpiresAt: idle.toISOString(),
173
- absoluteExpiresAt: absolute.toISOString(),
174
- };
175
- }
176
-
177
- this.cartToken = `fake-cart-${uuid().slice(0, 8)}`;
178
- this.cartItems = [];
179
- this.saveCartToStorage();
180
- return {
181
- cartToken: this.cartToken,
182
- issuedAt: now.toISOString(),
183
- idleExpiresAt: idle.toISOString(),
184
- absoluteExpiresAt: absolute.toISOString(),
185
- };
186
- });
187
- }
188
-
189
- async getCart(): Promise<CheckoutCartDetailDto> {
190
- return delay(300, () => {
191
- const now = new Date();
192
- const idle = new Date(now.getTime() + 15 * 60_000);
193
- const absolute = new Date(now.getTime() + 60 * 60_000);
194
- const detail: CheckoutCartDetailDto = {
195
- cartToken: this.cartToken,
196
- issuedAt: now.toISOString(),
197
- idleExpiresAt: idle.toISOString(),
198
- absoluteExpiresAt: absolute.toISOString(),
199
- items: this.cartItems.map((item): CheckoutCartItemDetailDto => ({
200
- id: item.id,
201
- bookingUuid: item.bookingUuid,
202
- productId: item.request.productId,
203
- optionId: item.request.optionId,
204
- unitItems: item.request.unitItems,
205
- availabilityId: item.request.availabilityId,
206
- localDate: item.request.localDate,
207
- pickupPointId: item.request.pickupPointId,
208
- amount: item.request.amount,
209
- currencyCode: item.request.currencyCode,
210
- currencyPrecision: item.request.currencyPrecision,
211
- })),
212
- };
213
- return detail;
214
- });
215
- }
216
-
217
- async addCartItem(request: CheckoutAddCartItemDto): Promise<CheckoutCartItemAddedDto> {
218
- return delay(400, () => {
219
- const item: FakeCartItem = {
220
- id: uuid(),
221
- bookingUuid: uuid(),
222
- request,
223
- };
224
- this.cartItems.push(item);
225
- this.saveCartToStorage();
226
- return { id: item.id, bookingUuid: item.bookingUuid };
227
- });
228
- }
229
-
230
- async updateCartItem(itemId: string, request: CheckoutAddCartItemDto): Promise<CheckoutCartItemAddedDto> {
231
- return delay(400, () => {
232
- const existing = this.cartItems.find((i) => i.id === itemId);
233
- if (existing) {
234
- existing.request = request;
235
- this.saveCartToStorage();
236
- return { id: existing.id, bookingUuid: existing.bookingUuid };
237
- }
238
- const item: FakeCartItem = { id: itemId, bookingUuid: uuid(), request };
239
- this.cartItems.push(item);
240
- this.saveCartToStorage();
241
- return { id: item.id, bookingUuid: item.bookingUuid };
242
- });
243
- }
244
-
245
- async removeCartItem(itemId: string): Promise<void> {
246
- return delay(200, () => {
247
- this.cartItems = this.cartItems.filter((i) => i.id !== itemId);
248
- this.saveCartToStorage();
249
- });
250
- }
251
-
252
- async payCart(_contact: OctoContact): Promise<CheckoutCartPaymentInitiationDto> {
253
- return delay(800, () => ({
254
- checkoutId: `fake-checkout-${uuid().slice(0, 8)}`,
255
- redirectUrl: '',
256
- }));
257
- }
258
-
259
- async confirmCart(): Promise<CheckoutCartConfirmResultDto> {
260
- return delay(700, () => {
261
- const result: CheckoutCartConfirmResultDto = {
262
- items: this.cartItems.map((item): CheckoutCartConfirmItemResultDto => ({
263
- bookingUuid: item.bookingUuid,
264
- statusCode: 200,
265
- body: JSON.stringify({
266
- id: `booking-${item.bookingUuid.slice(0, 8)}`,
267
- uuid: item.bookingUuid,
268
- status: 'CONFIRMED',
269
- productId: item.request.productId,
270
- optionId: item.request.optionId,
271
- unitItems: (item.request.unitItems ?? []).map((u) => ({
272
- uuid: uuid(),
273
- unitId: u.unitId,
274
- status: 'CONFIRMED',
275
- })),
276
- }),
277
- })),
278
- };
279
- return result;
280
- });
281
- }
282
- }