@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
@@ -1,101 +1,104 @@
1
- <script lang="ts">
2
- import type { CheckoutCartDetailDto } from './client-types';
3
- import type { BookingApi } from './api';
4
- import { CartManager } from './cart-manager';
5
- import { onWidgetMessage, postMessage } from './messages';
6
- import { formatRemaining, cartDeadline } from './cart-expiry';
7
- import { onDestroy } from 'svelte';
8
-
9
- interface Props {
10
- api: BookingApi;
11
- cartManager: CartManager;
12
- }
13
- let { api, cartManager }: Props = $props();
14
-
15
- let cart = $state<CheckoutCartDetailDto | null>(null);
16
- let remaining = $state('');
17
-
18
- async function load() {
19
- if (!cartManager.hasCart) return;
20
- try {
21
- cart = await api.getCart();
22
- } catch {
23
- cart = null;
24
- }
25
- }
26
-
27
- load();
28
-
29
- // load() above only runs once on mount - without this, adding/editing/removing an item elsewhere on the
30
- // page (the configurator, or the checkout modal's own edit/remove) leaves this button showing a stale total.
31
- // The event already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so
32
- // this applies it directly rather than triggering a second, redundant getCart() call.
33
- $effect(() => onWidgetMessage((d) => {
34
- if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
35
- }));
36
-
37
- function updateTimer() {
38
- remaining = cart ? formatRemaining(cartDeadline(cart).getTime() - Date.now()) : formatRemaining(0);
39
- }
40
-
41
- updateTimer();
42
- const interval = setInterval(updateTimer, 1000);
43
- onDestroy(() => clearInterval(interval));
44
-
45
- let itemCount = $derived(cart?.items.length ?? 0);
46
- </script>
47
-
48
- {#if cart && cart.items.length > 0}
49
- <button class="cart-overview-btn bw-widget" onclick={() => postMessage({ type: 'modal:open' })}>
50
- <svg class="cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
51
- <circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
52
- <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
53
- </svg>
54
- <span class="cart-badge">{itemCount}</span>
55
- <span class="cart-timer">{remaining}</span>
56
- </button>
57
- {/if}
58
-
59
- <style>
60
- .cart-overview-btn {
61
- display: flex;
62
- align-items: center;
63
- justify-content: center;
64
- gap: 14px;
65
- width: 100%;
66
- padding: 14px 24px;
67
- background: var(--bw-color-primary);
68
- color: white;
69
- border: none;
70
- border-radius: 999px;
71
- cursor: pointer;
72
- transition: background 0.15s;
73
- }
74
- .cart-overview-btn:hover {
75
- background: var(--bw-color-primary-dark);
76
- }
77
- .cart-icon {
78
- width: 22px;
79
- height: 22px;
80
- flex-shrink: 0;
81
- }
82
- .cart-badge {
83
- display: flex;
84
- align-items: center;
85
- justify-content: center;
86
- width: 26px;
87
- height: 26px;
88
- background: white;
89
- color: var(--bw-color-primary);
90
- border-radius: 50%;
91
- font-size: 14px;
92
- font-weight: 800;
93
- flex-shrink: 0;
94
- }
95
- .cart-timer {
96
- font-variant-numeric: tabular-nums;
97
- font-size: 20px;
98
- font-weight: 700;
99
- flex-shrink: 0;
100
- }
101
- </style>
1
+ <script lang="ts">
2
+ import type { BookingApi } from './api';
3
+ import type { CartOverviewState } from './config';
4
+ import { CartManager } from './cart-manager';
5
+ import { postMessage } from './messages';
6
+ import { formatRemaining, cartDeadline } from './cart-expiry';
7
+ import { createCartOverview } from './cart-overview.svelte';
8
+ import { onDestroy } from 'svelte';
9
+ import { getBookingHostContext, requireBookingService } from './booking-context';
10
+
11
+ interface Props {
12
+ api?: BookingApi;
13
+ cartManager?: CartManager;
14
+ // Lets a host render its own summary against the same state the button is in, rather than inferring it
15
+ // from an empty DOM - see cart-overview.svelte.ts.
16
+ onStateChange?: (state: CartOverviewState) => void;
17
+ }
18
+ let { api, cartManager, onStateChange }: Props = $props();
19
+
20
+ const bookingHost = getBookingHostContext();
21
+ const overview = createCartOverview(
22
+ () => requireBookingService(api ?? bookingHost?.api, 'CartOverviewButton', 'api'),
23
+ () => requireBookingService(cartManager ?? bookingHost?.cartManager, 'CartOverviewButton', 'cartManager'),
24
+ (s) => onStateChange?.(s),
25
+ );
26
+ let cart = $derived(overview.cart);
27
+
28
+ let remaining = $state('');
29
+
30
+ function updateTimer() {
31
+ remaining = cart ? formatRemaining(cartDeadline(cart).getTime() - Date.now()) : formatRemaining(0);
32
+ }
33
+
34
+ updateTimer();
35
+ const interval = setInterval(updateTimer, 1000);
36
+ onDestroy(() => clearInterval(interval));
37
+
38
+ let itemCount = $derived(cart?.items.length ?? 0);
39
+ </script>
40
+
41
+ {#if overview.state === 'error'}
42
+ <!-- An empty cart and a cart that could not be read used to render identically (as nothing), leaving the
43
+ shopper with no route to checkout and no sign that anything had gone wrong. -->
44
+ <button class="cart-overview-btn is-error bw-widget" onclick={() => overview.reload()}>
45
+ <span class="cart-error-label">Cart unavailable - retry</span>
46
+ </button>
47
+ {:else if cart && cart.items.length > 0}
48
+ <button class="cart-overview-btn bw-widget" onclick={() => postMessage({ type: 'modal:open' })}>
49
+ <svg class="cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
50
+ <circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
51
+ <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
52
+ </svg>
53
+ <span class="cart-badge">{itemCount}</span>
54
+ <span class="cart-timer">{remaining}</span>
55
+ </button>
56
+ {/if}
57
+
58
+ <style>
59
+ .cart-overview-btn {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ gap: 14px;
64
+ width: 100%;
65
+ padding: 14px 24px;
66
+ background: var(--bw-color-primary);
67
+ color: white;
68
+ border: none;
69
+ border-radius: 999px;
70
+ cursor: pointer;
71
+ transition: background 0.15s;
72
+ }
73
+ .cart-overview-btn:hover {
74
+ background: var(--bw-color-primary-dark);
75
+ }
76
+ .cart-icon {
77
+ width: 22px;
78
+ height: 22px;
79
+ flex-shrink: 0;
80
+ }
81
+ .cart-badge {
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ width: 26px;
86
+ height: 26px;
87
+ background: white;
88
+ color: var(--bw-color-primary);
89
+ border-radius: 50%;
90
+ font-size: 14px;
91
+ font-weight: 800;
92
+ flex-shrink: 0;
93
+ }
94
+ .cart-timer {
95
+ font-variant-numeric: tabular-nums;
96
+ font-size: 20px;
97
+ font-weight: 700;
98
+ flex-shrink: 0;
99
+ }
100
+ .cart-error-label {
101
+ font-size: 15px;
102
+ font-weight: 600;
103
+ }
104
+ </style>
@@ -1,128 +1,141 @@
1
- <script lang="ts">
2
- import type { BookingApi } from './api';
3
- import type { WizardPages } from './config';
4
- import { DEFAULT_WIZARD_PAGES } from './config';
5
- import type { CheckoutCartDetailDto } from './client-types';
6
- import { isInvalidCart } from './api';
7
- import { onWidgetMessage, postMessage } from './messages';
8
- import type { CartManager } from './cart-manager';
9
- import CheckoutModal from './CheckoutModal.svelte';
10
- import CartExpiredView from './CartExpiredView.svelte';
11
-
12
- interface Props {
13
- api: BookingApi;
14
- cartManager?: CartManager;
15
- wizardPages?: WizardPages;
16
- editPages?: WizardPages;
17
- autoSelectSingleTimeSlot?: boolean;
18
- }
19
- let { api, cartManager, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp,
20
- autoSelectSingleTimeSlot = false }: Props = $props();
21
- let editPages = $derived(editPagesProp ?? wizardPages);
22
-
23
- let cart = $state<CheckoutCartDetailDto | null>(null);
24
- let isLoading = $state(true);
25
- let expired = $state(false);
26
-
27
- async function load() {
28
- // A token this widget still holds for a cart the server has already let go (its window ran out while the
29
- // tab sat there) used to render as a bare "No items in cart", with the dead token left in storage for the
30
- // next add to trip over. Only a held token can mean that - with none, an empty cart is just empty.
31
- const hadCart = api.cartToken !== '';
32
- try {
33
- cart = await api.getCart();
34
- } catch (e) {
35
- cart = null;
36
- if (hadCart && isInvalidCart(e)) {
37
- onCartExpired();
38
- expired = true;
39
- }
40
- }
41
- isLoading = false;
42
- }
43
-
44
- load();
45
-
46
- // Drops the stored token so whatever the shopper does next starts a fresh cart, and clears bw-cart's
47
- // bar/button and any host-side summary built from bw:cart-updated - the cart they were describing no longer
48
- // exists. Mirrors what onOrderConfirmed below does once a cart has served its purpose the other way.
49
- function onCartExpired() {
50
- cartManager?.reset();
51
- // See CartExpiryGuard.svelte's own expire() for why this is posted alongside, and before, cart:updated.
52
- postMessage({ type: 'cart:expired' });
53
- postMessage({ type: 'cart:updated', cart: null });
54
- }
55
-
56
- // In modal mode, modal:close is what actually dismisses this screen - bw-checkout.svelte remounts this
57
- // component fresh the next time it opens, so the click is fully handled there. Rendered permanently
58
- // in-page instead, nothing is listening for modal:close, so without the reload below the click would do
59
- // nothing a shopper can see: expired flips off, but cart is still the stale null from the load() that
60
- // found it gone, leaving the same "No items in cart" text up with no sign anything happened.
61
- function startAgain() {
62
- expired = false;
63
- postMessage({ type: 'modal:close' });
64
- void load();
65
- }
66
-
67
- // In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
68
- // modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
69
- // would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
70
- // cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
71
- // triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
72
- //
73
- // cart:expired is CartExpiryGuard's own signal that the clock (not a manual clear) is why the cart just
74
- // went null - it fires page-wide, including while this modal is already open showing a live cart, and
75
- // without it this falls through to the plain "No items in cart" branch below instead of CartExpiredView,
76
- // exactly as if the shopper had simply never had anything in their cart at all.
77
- $effect(() => onWidgetMessage((d) => {
78
- if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
79
- if (d.type === 'cart:expired') expired = true;
80
- }));
81
- </script>
82
-
83
- <div class="bw-widget">
84
- {#if isLoading}
85
- <div class="loading-center" style="height:100vh">
86
- <div class="spinner"></div>
87
- </div>
88
- {:else if cart && cart.items.length > 0}
89
- <CheckoutModal
90
- {cart}
91
- {api}
92
- {wizardPages}
93
- {editPages}
94
- {autoSelectSingleTimeSlot}
95
- onClose={() => postMessage({ type: 'modal:close' })}
96
- onOrderConfirmed={() => {
97
- const total = cart!.items.reduce((s, i) => s + i.amount, 0);
98
- const currency = cart!.items[0]?.currencyCode ?? 'ZAR';
99
- postMessage({
100
- type: 'order:complete',
101
- cartToken: cart!.cartToken,
102
- value: total,
103
- currency,
104
- });
105
- // The cart is paid and confirmed, so its token has done its job. Nothing used to clear it, so it sat
106
- // in localStorage on the merchant's origin until absoluteExpiresAt - readable by every third-party
107
- // script they load, and picked up by the next person to use a shared or kiosk browser.
108
- cartManager?.reset();
109
- }}
110
- />
111
- {:else if expired}
112
- <div style="height:100vh">
113
- <CartExpiredView onStartAgain={startAgain} />
114
- </div>
115
- {:else}
116
- <div class="loading-center" style="height:100vh;color:var(--bw-color-text-secondary)">
117
- No items in cart.
118
- </div>
119
- {/if}
120
- </div>
121
-
122
- <style>
123
- /* display: contents - a plain box here would break CheckoutModal's own .modal{height:100%}, which needs
124
- to resolve against this component's real parent, not an unsized wrapper inserted in between. */
125
- .bw-widget {
126
- display: contents;
127
- }
128
- </style>
1
+ <script lang="ts">
2
+ // The presentation shell around CheckoutPanel. Modal chrome lives here rather than in
3
+ // elements/bw-checkout.svelte so both builds render the same modal from one implementation - an ES consumer
4
+ // composing the components itself used to get no modal at all, and had to rebuild the overlay, the scrim,
5
+ // the click-outside close and the portal by hand.
6
+ import type { BookingApi } from './api';
7
+ import type { WizardPages } from './config';
8
+ import { DEFAULT_WIZARD_PAGES } from './config';
9
+ import type { CartManager } from './cart-manager';
10
+ import { onWidgetMessage, postMessage } from './messages';
11
+ import { portal } from './portal';
12
+ import CheckoutPanel from './CheckoutPanel.svelte';
13
+ import { getBookingHostContext, requireBookingService } from './booking-context';
14
+ import type { CheckoutMode } from './config';
15
+
16
+ interface Props {
17
+ // Optional when a BookingProvider is above this component - it supplies both from its host.
18
+ api?: BookingApi;
19
+ cartManager?: CartManager;
20
+ wizardPages?: WizardPages;
21
+ editPages?: WizardPages;
22
+ autoSelectSingleTimeSlot?: boolean;
23
+ // Inline by default: a consumer already rendering <Checkout> in a panel of its own keeps exactly the
24
+ // layout it has. createBookingHost asks for 'modal', so anyone wiring up through the host gets the modal
25
+ // without opting in.
26
+ mode?: CheckoutMode;
27
+ // Modal only. A host that owns the open state (see createBookingHost) drives this from outside; a
28
+ // consumer that renders <Checkout mode="modal" /> and nothing else gets a modal that is open and closes
29
+ // itself. Note that reopening it after a dismissal means setting open - a modal with no open state has
30
+ // no way back, by construction, since nothing owns the answer to "should it be showing now".
31
+ open?: boolean;
32
+ onClose?: () => void;
33
+ }
34
+
35
+ let {
36
+ api: apiProp,
37
+ cartManager: cartManagerProp,
38
+ wizardPages = DEFAULT_WIZARD_PAGES,
39
+ editPages,
40
+ autoSelectSingleTimeSlot = false,
41
+ mode = 'inline',
42
+ open: openProp,
43
+ onClose: onCloseProp,
44
+ }: Props = $props();
45
+
46
+ const bookingHost = getBookingHostContext();
47
+ let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'Checkout', 'api'));
48
+ let cartManager = $derived(cartManagerProp ?? bookingHost?.cartManager);
49
+
50
+ // With a provider the host owns whether checkout is open, so the modal opens on an add-to-cart and closes
51
+ // on a dismissal with nothing wired up by hand - which is the whole point of the provider. An explicit
52
+ // open prop still wins, and with neither the modal is simply open, so <Checkout mode="modal" /> on its own
53
+ // shows something rather than nothing.
54
+ let open = $derived(openProp ?? bookingHost?.isCheckoutOpen ?? true);
55
+ let onClose = $derived(onCloseProp ?? (bookingHost ? () => bookingHost.closeCheckout() : undefined));
56
+
57
+ let isModal = $derived(mode === 'modal');
58
+ // Dismissing is answered here as well as reported outwards, so a consumer that passes no open state at all
59
+ // still gets a modal that closes. A host that does own the state reopens by setting open, which clears
60
+ // this - see the effect below.
61
+ let dismissed = $state(false);
62
+ let isOpen = $derived(!isModal || (open && !dismissed));
63
+
64
+ // Whether this dismissal has already been reported. Deliberately a plain variable rather than $state: the
65
+ // panel posts its own modal:close as it is torn down, which arrives while Svelte is still flushing the
66
+ // teardown, and a $state write made there is not yet readable by the time that second message is handled -
67
+ // so guarding on `dismissed` let onClose fire twice for one dismissal.
68
+ let reported = false;
69
+
70
+ // Cleared on a shut -> open transition only, never merely because open is true. An uncontrolled consumer
71
+ // leaves open at its default forever, so resetting on the level would undo the dismissal the moment this
72
+ // effect ran again. wasOpen is the effect's memory, not one of its dependencies.
73
+ let wasOpen = false;
74
+ $effect(() => {
75
+ const nowOpen = open;
76
+ if (nowOpen && !wasOpen) {
77
+ dismissed = false;
78
+ reported = false;
79
+ }
80
+ wasOpen = nowOpen;
81
+ });
82
+
83
+ // The scrim and Escape only announce the dismissal; the listener below is what acts on it. The panel has
84
+ // its own ways out - CheckoutModal's close button, and the expired view's "start again" - and each posts
85
+ // this same message, so routing every dismissal through one place is what keeps onClose from firing for
86
+ // some of them and not others.
87
+ function close() {
88
+ postMessage({ type: 'modal:close' });
89
+ }
90
+
91
+ $effect(() => onWidgetMessage((d) => {
92
+ if (d.type !== 'modal:close' || !isModal || reported) return;
93
+ // Only while this modal is actually showing. modal:close is page-wide and several things post it
94
+ // without any modal being up - the expiry guard's "start again", a cancelable configurator - and
95
+ // treating those as a dismissal fired onClose for something the shopper never did.
96
+ if (!isOpen) return;
97
+ reported = true;
98
+ dismissed = true;
99
+ onClose?.();
100
+ }));
101
+
102
+ function onKeydown(e: KeyboardEvent) {
103
+ if (isOpen && isModal && e.key === 'Escape') close();
104
+ }
105
+ </script>
106
+
107
+ <svelte:window onkeydown={onKeydown} />
108
+
109
+ {#if isModal}
110
+ {#if isOpen}
111
+ <!-- Portalled to <body>: a fixed overlay cannot escape a transformed, filtered or sticky ancestor, which
112
+ is exactly what a product page's sticky sidebar is - see portal.ts. -->
113
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
114
+ <div class="bw-modal-overlay" use:portal onclick={close}>
115
+ <div class="bw-modal-inner" onclick={(e) => e.stopPropagation()}>
116
+ <CheckoutPanel {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
117
+ </div>
118
+ </div>
119
+ {/if}
120
+ {:else}
121
+ <CheckoutPanel {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
122
+ {/if}
123
+
124
+ <style>
125
+ .bw-modal-overlay {
126
+ position: fixed;
127
+ inset: 0;
128
+ z-index: 10000;
129
+ background: rgba(0, 0, 0, 0.5);
130
+ }
131
+ .bw-modal-inner {
132
+ width: 100%;
133
+ max-width: 900px;
134
+ height: 95vh;
135
+ margin: 2.5vh auto;
136
+ border-radius: 12px;
137
+ background: #fff;
138
+ overflow: hidden;
139
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
140
+ }
141
+ </style>