@code-collective/booking-widget 1.0.10 → 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/package.json CHANGED
@@ -1,55 +1,58 @@
1
- {
2
- "name": "@code-collective/booking-widget",
3
- "version": "1.0.10",
4
- "type": "module",
5
- "main": "dist/booking-widget.umd.cjs",
6
- "module": "dist/booking-widget.js",
7
- "svelte": "src/lib/index.ts",
8
- "exports": {
9
- ".": {
10
- "svelte": "./src/lib/index.ts",
11
- "import": "./dist/booking-widget.js",
12
- "require": "./dist/booking-widget.umd.cjs"
13
- },
14
- "./style.css": "./dist/booking-widget.css",
15
- "./elements": "./dist/booking-widget.min.js"
16
- },
17
- "files": [
18
- "dist",
19
- "src/lib"
20
- ],
21
- "scripts": {
22
- "dev": "vite",
23
- "build": "vite build",
24
- "prepublishOnly": "npm run build && npm run build:elements",
25
- "build:elements": "vite build --mode elements",
26
- "postbuild:elements": "node -e \"const{readFileSync:r}=require('fs'),{createHash:h}=require('crypto');['dist/booking-widget.min.js','dist/booking-widget.min.css'].forEach(f=>{const d=h('sha384').update(r(f)).digest('base64');console.log(f+' -> sha384-'+d)})\"",
27
- "build:demo": "vite build --mode demo",
28
- "preview": "vite preview",
29
- "check": "svelte-check --tsconfig ./tsconfig.json",
30
- "test": "vitest run",
31
- "release": "node scripts/publish.cjs"
32
- },
33
- "devDependencies": {
34
- "@sveltejs/vite-plugin-svelte": "^7.3.0",
35
- "@testing-library/svelte": "^5.4.2",
36
- "@tsconfig/svelte": "^5.0.0",
37
- "jsdom": "^30.0.1",
38
- "svelte": "^5.0.0",
39
- "svelte-check": "^4.0.0",
40
- "typescript": "^5.5.0",
41
- "vite": "^8.2.2",
42
- "vitest": "^5.0.0"
43
- },
44
- "peerDependencies": {
45
- "svelte": "^5.0.0"
46
- },
47
- "peerDependenciesMeta": {
48
- "svelte": {
49
- "optional": true
50
- }
51
- },
52
- "dependencies": {
53
- "openapi-fetch": "^0.17.0"
54
- }
55
- }
1
+ {
2
+ "name": "@code-collective/booking-widget",
3
+ "version": "1.0.12",
4
+ "type": "module",
5
+ "main": "dist/booking-widget.umd.cjs",
6
+ "module": "dist/booking-widget.js",
7
+ "svelte": "src/lib/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "svelte": "./src/lib/index.ts",
11
+ "import": "./dist/booking-widget.js",
12
+ "require": "./dist/booking-widget.umd.cjs"
13
+ },
14
+ "./style.css": "./dist/booking-widget.css",
15
+ "./elements": "./dist/booking-widget.min.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src/lib",
20
+ "!src/lib/**/*.test.ts",
21
+ "!src/lib/test",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "scripts": {
25
+ "dev": "vite",
26
+ "build": "vite build",
27
+ "prepublishOnly": "npm run build && npm run build:elements",
28
+ "build:elements": "vite build --mode elements",
29
+ "postbuild:elements": "node -e \"const{readFileSync:r}=require('fs'),{createHash:h}=require('crypto');['dist/booking-widget.min.js','dist/booking-widget.min.css'].forEach(f=>{const d=h('sha384').update(r(f)).digest('base64');console.log(f+' -> sha384-'+d)})\"",
30
+ "build:demo": "vite build --mode demo",
31
+ "preview": "vite preview",
32
+ "check": "svelte-check --tsconfig ./tsconfig.json",
33
+ "test": "vitest run",
34
+ "release": "node scripts/publish.cjs"
35
+ },
36
+ "devDependencies": {
37
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
38
+ "@testing-library/svelte": "^5.4.2",
39
+ "@tsconfig/svelte": "^5.0.0",
40
+ "jsdom": "^30.0.1",
41
+ "svelte": "^5.0.0",
42
+ "svelte-check": "^4.0.0",
43
+ "typescript": "^5.5.0",
44
+ "vite": "^8.2.2",
45
+ "vitest": "^5.0.0"
46
+ },
47
+ "peerDependencies": {
48
+ "svelte": "^5.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "svelte": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "dependencies": {
56
+ "openapi-fetch": "^0.17.0"
57
+ }
58
+ }
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ // Puts a host in context for everything beneath it. The host itself is still built by the consumer -
3
+ // createBookingHost() works anywhere, including a module shared across several Astro islands, and this
4
+ // only decides which components can see it.
5
+ import type { Snippet } from 'svelte';
6
+ import type { BookingHost } from './host.svelte';
7
+ import { setBookingHostContext } from './booking-context';
8
+
9
+ interface Props {
10
+ host: BookingHost;
11
+ children: Snippet;
12
+ }
13
+ let { host, children }: Props = $props();
14
+
15
+ // Read once, deliberately: context is set at initialisation and a provider serves one host for its
16
+ // lifetime. Swapping hosts mid-flight would strand every component already reading the old one.
17
+ // svelte-ignore state_referenced_locally
18
+ setBookingHostContext(host);
19
+ </script>
20
+
21
+ {@render children()}
@@ -1,41 +1,26 @@
1
- <script lang="ts">
2
- import type { BookingApi } from './api';
3
- import type { CheckoutCartDetailDto } from './client-types';
4
- import { CartManager } from './cart-manager';
5
- import { onWidgetMessage } from './messages';
6
- import CartBarView from './CartBarView.svelte';
7
-
8
- interface Props {
9
- api: BookingApi;
10
- cartManager: CartManager;
11
- }
12
- let { api, cartManager }: Props = $props();
13
-
14
- let cart = $state<CheckoutCartDetailDto | null>(null);
15
- let isLoading = $state(true);
16
-
17
- async function load() {
18
- if (cartManager.hasCart) {
19
- try {
20
- cart = await api.getCart();
21
- } catch {
22
- cart = null;
23
- }
24
- }
25
- isLoading = false;
26
- }
27
-
28
- load();
29
-
30
- // load() above only runs once on mount - without this, adding/editing/removing an item elsewhere on the
31
- // page (the configurator, or the checkout modal's own edit/remove) leaves this bar showing a stale total.
32
- // The event already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so
33
- // this applies it directly rather than triggering a second, redundant getCart() call.
34
- $effect(() => onWidgetMessage((d) => {
35
- if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
36
- }));
37
- </script>
38
-
39
- {#if !isLoading}
40
- <CartBarView {cart} />
41
- {/if}
1
+ <script lang="ts">
2
+ import type { BookingApi } from './api';
3
+ import type { CartOverviewState } from './config';
4
+ import { CartManager } from './cart-manager';
5
+ import { createCartOverview } from './cart-overview.svelte';
6
+ import CartBarView from './CartBarView.svelte';
7
+ import { getBookingHostContext, requireBookingService } from './booking-context';
8
+
9
+ interface Props {
10
+ api?: BookingApi;
11
+ cartManager?: CartManager;
12
+ // Lets a host render its own summary against the same state the bar is in, rather than inferring it from
13
+ // an empty DOM - see cart-overview.svelte.ts.
14
+ onStateChange?: (state: CartOverviewState) => void;
15
+ }
16
+ let { api, cartManager, onStateChange }: Props = $props();
17
+
18
+ const bookingHost = getBookingHostContext();
19
+ const overview = createCartOverview(
20
+ () => requireBookingService(api ?? bookingHost?.api, 'CartBar', 'api'),
21
+ () => requireBookingService(cartManager ?? bookingHost?.cartManager, 'CartBar', 'cartManager'),
22
+ (s) => onStateChange?.(s),
23
+ );
24
+ </script>
25
+
26
+ <CartBarView cart={overview.cart} state={overview.state} onRetry={overview.reload} />
@@ -1,61 +1,78 @@
1
- <script lang="ts">
2
- import type { CheckoutCartDetailDto } from './client-types';
3
- import { formatCurrency } from './currency';
4
- import { postMessage } from './messages';
5
- import CountdownTimer from './CountdownTimer.svelte';
6
- import { cartDeadline } from './cart-expiry';
7
-
8
- interface Props {
9
- cart: CheckoutCartDetailDto | null;
10
- }
11
- let { cart }: Props = $props();
12
-
13
- let itemCount = $derived(cart?.items.length ?? 0);
14
-
15
- let totalLabel = $derived(() => {
16
- if (!cart || cart.items.length === 0) return '';
17
- const currency = cart.items[0].currencyCode;
18
- const total = cart.items.reduce((sum, i) => sum + i.amount, 0);
19
- return formatCurrency(total, currency, 2, 2);
20
- });
21
-
22
- let expiresAt = $derived(() => cart ? cartDeadline(cart) : new Date());
23
- </script>
24
-
25
- {#if cart && cart.items.length > 0}
26
- <div class="cart-bar bw-widget">
27
- <div class="cart-info">
28
- <span class="item-count">{itemCount === 1 ? '1 item' : `${itemCount} items`} <CountdownTimer expiresAt={expiresAt()} /></span>
29
- <span class="total">{totalLabel()}</span>
30
- </div>
31
- <button class="btn btn-primary" onclick={() => postMessage({ type: 'modal:open' })}>
32
- Checkout &rarr;
33
- </button>
34
- </div>
35
- {/if}
36
-
37
- <style>
38
- .cart-bar {
39
- display: flex;
40
- align-items: center;
41
- justify-content: space-between;
42
- padding: 12px 16px;
43
- background: white;
44
- border-top: 1px solid var(--bw-color-border);
45
- box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
46
- width: 100%;
47
- }
48
- .cart-info {
49
- display: flex;
50
- flex-direction: column;
51
- gap: 2px;
52
- }
53
- .item-count {
54
- font-size: 12px;
55
- color: var(--bw-color-text-secondary);
56
- }
57
- .total {
58
- font-size: 16px;
59
- font-weight: 700;
60
- }
61
- </style>
1
+ <script lang="ts">
2
+ import type { CheckoutCartDetailDto } from './client-types';
3
+ import type { CartOverviewState } from './config';
4
+ import { formatCurrency } from './currency';
5
+ import { postMessage } from './messages';
6
+ import CountdownTimer from './CountdownTimer.svelte';
7
+ import { cartDeadline } from './cart-expiry';
8
+
9
+ interface Props {
10
+ cart: CheckoutCartDetailDto | null;
11
+ state?: CartOverviewState;
12
+ onRetry?: () => void;
13
+ }
14
+ let { cart, state = 'ready', onRetry }: Props = $props();
15
+
16
+ let itemCount = $derived(cart?.items.length ?? 0);
17
+
18
+ let totalLabel = $derived(() => {
19
+ if (!cart || cart.items.length === 0) return '';
20
+ const currency = cart.items[0].currencyCode;
21
+ const total = cart.items.reduce((sum, i) => sum + i.amount, 0);
22
+ return formatCurrency(total, currency, 2, 2);
23
+ });
24
+
25
+ let expiresAt = $derived(() => cart ? cartDeadline(cart) : new Date());
26
+ </script>
27
+
28
+ {#if state === 'error'}
29
+ <!-- An empty cart and a cart that could not be read used to render identically (as nothing), which left a
30
+ shopper on a narrow layout with no route to checkout and no sign that anything had gone wrong. -->
31
+ <div class="cart-bar bw-widget" role="alert">
32
+ <div class="cart-info">
33
+ <span class="item-count">Cart unavailable</span>
34
+ <span class="error-detail">We could not load your cart just now.</span>
35
+ </div>
36
+ <button class="btn btn-primary" onclick={() => onRetry?.()}>Retry</button>
37
+ </div>
38
+ {:else if cart && cart.items.length > 0}
39
+ <div class="cart-bar bw-widget">
40
+ <div class="cart-info">
41
+ <span class="item-count">{itemCount === 1 ? '1 item' : `${itemCount} items`} <CountdownTimer expiresAt={expiresAt()} /></span>
42
+ <span class="total">{totalLabel()}</span>
43
+ </div>
44
+ <button class="btn btn-primary" onclick={() => postMessage({ type: 'modal:open' })}>
45
+ Checkout
46
+ </button>
47
+ </div>
48
+ {/if}
49
+
50
+ <style>
51
+ .cart-bar {
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ padding: 12px 16px;
56
+ background: white;
57
+ border-top: 1px solid var(--bw-color-border);
58
+ box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
59
+ width: 100%;
60
+ }
61
+ .cart-info {
62
+ display: flex;
63
+ flex-direction: column;
64
+ gap: 2px;
65
+ }
66
+ .item-count {
67
+ font-size: 12px;
68
+ color: var(--bw-color-text-secondary);
69
+ }
70
+ .error-detail {
71
+ font-size: 14px;
72
+ font-weight: 600;
73
+ }
74
+ .total {
75
+ font-size: 16px;
76
+ font-weight: 700;
77
+ }
78
+ </style>
@@ -16,15 +16,22 @@
16
16
  import CartExpiryWatcher from './CartExpiryWatcher.svelte';
17
17
  import StillTherePrompt from './StillTherePrompt.svelte';
18
18
  import CartExpiredView from './CartExpiredView.svelte';
19
+ import { getBookingHostContext, requireBookingService } from './booking-context';
19
20
 
20
21
  interface Props {
21
- api: BookingApi;
22
- cartManager: CartManager;
22
+ api?: BookingApi;
23
+ cartManager?: CartManager;
23
24
  // Whether the "are you still there?" prompt is offered - see EXTENSION_ENABLED. Off, the guard only ever
24
25
  // shows the expired view at 0:00; the clock itself is unchanged either way.
25
26
  extensionEnabled?: boolean;
26
27
  }
27
- let { api, cartManager, extensionEnabled = EXTENSION_ENABLED }: Props = $props();
28
+ let { api: apiProp, cartManager: cartManagerProp, extensionEnabled = EXTENSION_ENABLED }: Props = $props();
29
+
30
+ const bookingHost = getBookingHostContext();
31
+ let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'CartExpiryGuard', 'api'));
32
+ let cartManager = $derived(
33
+ requireBookingService(cartManagerProp ?? bookingHost?.cartManager, 'CartExpiryGuard', 'cartManager'),
34
+ );
28
35
 
29
36
  let cart = $state<CheckoutCartDetailDto | null>(null);
30
37
  let remaining = $state('');
@@ -322,14 +329,13 @@
322
329
  clearTimers();
323
330
  closed = true;
324
331
  cartManager.reset();
325
- // Posted first so a listener that cares about expiry specifically (register.ts's bw:cart-expired) hears
326
- // it before the more general cart:updated below - order does not matter to either side today, but this
327
- // keeps the more specific signal from ever reading as a follow-up to the general one.
328
- postMessage({ type: 'cart:expired' });
329
332
  // Tells bw-cart/CartOverviewButton/CheckoutModal (if it happens to be mounted) the cart is gone, the same
330
- // signal a manual remove-to-empty already sends - see Checkout.svelte's own onCartExpired for the other
331
- // caller of this exact message.
333
+ // signal a manual remove-to-empty already sends - see CheckoutPanel's own onCartExpired for the other
334
+ // caller of this exact message. cart:expired follows it because a null cart alone does not say why: a
335
+ // confirmed order clears the cart the same way, and a host announcing "your cart expired" needs the two
336
+ // apart.
332
337
  postMessage({ type: 'cart:updated', cart: null });
338
+ postMessage({ type: 'cart:expired' });
333
339
  }
334
340
 
335
341
  function startAgain() {
@@ -1,20 +1,30 @@
1
- <script lang="ts">
2
- import type { BookingApi } from './api';
3
- import type { CartOverviewDisplay } from './config';
4
- import { CartManager } from './cart-manager';
5
- import CartBar from './CartBar.svelte';
6
- import CartOverviewButton from './CartOverviewButton.svelte';
7
-
8
- interface Props {
9
- api: BookingApi;
10
- cartManager: CartManager;
11
- display: CartOverviewDisplay;
12
- }
13
- let { api, cartManager, display }: Props = $props();
14
- </script>
15
-
16
- {#if display === 'button'}
17
- <CartOverviewButton {api} {cartManager} />
18
- {:else}
19
- <CartBar {api} {cartManager} />
20
- {/if}
1
+ <script lang="ts">
2
+ import type { BookingApi } from './api';
3
+ import type { CartOverviewDisplay, CartOverviewState } from './config';
4
+ import { CartManager } from './cart-manager';
5
+ import CartBar from './CartBar.svelte';
6
+ import CartOverviewButton from './CartOverviewButton.svelte';
7
+ import { getBookingHostContext, requireBookingService } from './booking-context';
8
+
9
+ interface Props {
10
+ api?: BookingApi;
11
+ cartManager?: CartManager;
12
+ display: CartOverviewDisplay;
13
+ onStateChange?: (state: CartOverviewState) => void;
14
+ }
15
+ let { api, cartManager, display, onStateChange }: Props = $props();
16
+
17
+ // Resolved here as well as in the children so a missing host names CartOverview, which is what the
18
+ // consumer actually wrote, rather than CartBar.
19
+ const bookingHost = getBookingHostContext();
20
+ let resolvedApi = $derived(requireBookingService(api ?? bookingHost?.api, 'CartOverview', 'api'));
21
+ let resolvedCartManager = $derived(
22
+ requireBookingService(cartManager ?? bookingHost?.cartManager, 'CartOverview', 'cartManager'),
23
+ );
24
+ </script>
25
+
26
+ {#if display === 'button'}
27
+ <CartOverviewButton api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
28
+ {:else}
29
+ <CartBar api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
30
+ {/if}