@code-collective/booking-widget 1.0.10 → 1.0.13
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/CHANGELOG.md +48 -0
- package/README.md +153 -41
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +2637 -1924
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +34 -16
- package/dist/booking-widget.umd.cjs +5 -3
- package/package.json +5 -2
- package/src/lib/BookingProvider.svelte +21 -0
- package/src/lib/CartBar.svelte +16 -31
- package/src/lib/CartBarView.svelte +20 -3
- package/src/lib/CartExpiryGuard.svelte +416 -410
- package/src/lib/CartOverview.svelte +16 -6
- package/src/lib/CartOverviewButton.svelte +29 -26
- package/src/lib/Checkout.svelte +111 -98
- package/src/lib/CheckoutModal.svelte +946 -805
- package/src/lib/CheckoutPanel.svelte +121 -0
- package/src/lib/PaymentPage.svelte +16 -2
- package/src/lib/PickupPointPicker.svelte +1 -1
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +19 -5
- package/src/lib/UnitCounter.svelte +16 -2
- package/src/lib/WizardPage.svelte +102 -35
- package/src/lib/app.css +0 -6
- package/src/lib/booking-context.ts +33 -0
- package/src/lib/cart-overview.svelte.ts +97 -0
- package/src/lib/client-types.ts +10 -6
- package/src/lib/config.ts +9 -0
- package/src/lib/elements/bw-cart.svelte +21 -7
- package/src/lib/elements/bw-checkout.svelte +54 -54
- package/src/lib/elements/bw-configurator.svelte +30 -14
- package/src/lib/elements/register.ts +96 -121
- package/src/lib/elements/shared.ts +4 -0
- package/src/lib/elements/theme.css +0 -6
- package/src/lib/host.svelte.ts +336 -0
- package/src/lib/index.ts +137 -91
- package/src/lib/layout.svelte.ts +52 -0
- package/src/lib/messages.ts +97 -17
- package/src/lib/peach-sdk.ts +51 -5
- package/src/lib/portal.ts +23 -0
- package/src/lib/CartExpiryGuard.test.ts +0 -331
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
- package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
- package/src/lib/test/fixtures.ts +0 -107
- package/src/lib/test/messages-mock.ts +0 -34
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { BookingApi } from './api';
|
|
3
|
-
import type { CartOverviewDisplay } from './config';
|
|
3
|
+
import type { CartOverviewDisplay, CartOverviewState } from './config';
|
|
4
4
|
import { CartManager } from './cart-manager';
|
|
5
5
|
import CartBar from './CartBar.svelte';
|
|
6
6
|
import CartOverviewButton from './CartOverviewButton.svelte';
|
|
7
|
+
import { getBookingHostContext, requireBookingService } from './booking-context';
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
|
-
api
|
|
10
|
-
cartManager
|
|
10
|
+
api?: BookingApi;
|
|
11
|
+
cartManager?: CartManager;
|
|
11
12
|
display: CartOverviewDisplay;
|
|
13
|
+
onStateChange?: (state: CartOverviewState) => void;
|
|
12
14
|
}
|
|
13
|
-
let { api, cartManager, display }: Props = $props();
|
|
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
|
+
);
|
|
14
24
|
</script>
|
|
15
25
|
|
|
16
26
|
{#if display === 'button'}
|
|
17
|
-
<CartOverviewButton {
|
|
27
|
+
<CartOverviewButton api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
|
|
18
28
|
{:else}
|
|
19
|
-
<CartBar {
|
|
29
|
+
<CartBar api={resolvedApi} cartManager={resolvedCartManager} {onStateChange} />
|
|
20
30
|
{/if}
|
|
@@ -1,38 +1,31 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type { CheckoutCartDetailDto } from './client-types';
|
|
3
2
|
import type { BookingApi } from './api';
|
|
3
|
+
import type { CartOverviewState } from './config';
|
|
4
4
|
import { CartManager } from './cart-manager';
|
|
5
|
-
import {
|
|
5
|
+
import { postMessage } from './messages';
|
|
6
6
|
import { formatRemaining, cartDeadline } from './cart-expiry';
|
|
7
|
+
import { createCartOverview } from './cart-overview.svelte';
|
|
7
8
|
import { onDestroy } from 'svelte';
|
|
9
|
+
import { getBookingHostContext, requireBookingService } from './booking-context';
|
|
8
10
|
|
|
9
11
|
interface Props {
|
|
10
|
-
api
|
|
11
|
-
cartManager
|
|
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;
|
|
12
17
|
}
|
|
13
|
-
let { api, cartManager }: Props = $props();
|
|
18
|
+
let { api, cartManager, onStateChange }: Props = $props();
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} catch {
|
|
23
|
-
cart = null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
load();
|
|
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);
|
|
28
27
|
|
|
29
|
-
|
|
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
|
-
}));
|
|
28
|
+
let remaining = $state('');
|
|
36
29
|
|
|
37
30
|
function updateTimer() {
|
|
38
31
|
remaining = cart ? formatRemaining(cartDeadline(cart).getTime() - Date.now()) : formatRemaining(0);
|
|
@@ -45,7 +38,13 @@
|
|
|
45
38
|
let itemCount = $derived(cart?.items.length ?? 0);
|
|
46
39
|
</script>
|
|
47
40
|
|
|
48
|
-
{#if
|
|
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}
|
|
49
48
|
<button class="cart-overview-btn bw-widget" onclick={() => postMessage({ type: 'modal:open' })}>
|
|
50
49
|
<svg class="cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
51
50
|
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
|
@@ -98,4 +97,8 @@
|
|
|
98
97
|
font-weight: 700;
|
|
99
98
|
flex-shrink: 0;
|
|
100
99
|
}
|
|
100
|
+
.cart-error-label {
|
|
101
|
+
font-size: 15px;
|
|
102
|
+
font-weight: 600;
|
|
103
|
+
}
|
|
101
104
|
</style>
|
package/src/lib/Checkout.svelte
CHANGED
|
@@ -1,128 +1,141 @@
|
|
|
1
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.
|
|
2
6
|
import type { BookingApi } from './api';
|
|
3
7
|
import type { WizardPages } from './config';
|
|
4
8
|
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
9
|
import type { CartManager } from './cart-manager';
|
|
9
|
-
import
|
|
10
|
-
import
|
|
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';
|
|
11
15
|
|
|
12
16
|
interface Props {
|
|
13
|
-
|
|
17
|
+
// Optional when a BookingProvider is above this component - it supplies both from its host.
|
|
18
|
+
api?: BookingApi;
|
|
14
19
|
cartManager?: CartManager;
|
|
15
20
|
wizardPages?: WizardPages;
|
|
16
21
|
editPages?: WizardPages;
|
|
17
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;
|
|
18
33
|
}
|
|
19
|
-
let { api, cartManager, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp,
|
|
20
|
-
autoSelectSingleTimeSlot = false }: Props = $props();
|
|
21
|
-
let editPages = $derived(editPagesProp ?? wizardPages);
|
|
22
34
|
|
|
23
|
-
let
|
|
24
|
-
|
|
25
|
-
|
|
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();
|
|
26
45
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
}
|
|
46
|
+
const bookingHost = getBookingHostContext();
|
|
47
|
+
let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'Checkout', 'api'));
|
|
48
|
+
let cartManager = $derived(cartManagerProp ?? bookingHost?.cartManager);
|
|
43
49
|
|
|
44
|
-
|
|
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));
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
});
|
|
55
82
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
function startAgain() {
|
|
62
|
-
expired = false;
|
|
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() {
|
|
63
88
|
postMessage({ type: 'modal:close' });
|
|
64
|
-
void load();
|
|
65
89
|
}
|
|
66
90
|
|
|
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
91
|
$effect(() => onWidgetMessage((d) => {
|
|
78
|
-
if (d.type
|
|
79
|
-
|
|
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?.();
|
|
80
100
|
}));
|
|
101
|
+
|
|
102
|
+
function onKeydown(e: KeyboardEvent) {
|
|
103
|
+
if (isOpen && isModal && e.key === 'Escape') close();
|
|
104
|
+
}
|
|
81
105
|
</script>
|
|
82
106
|
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
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
118
|
</div>
|
|
119
119
|
{/if}
|
|
120
|
-
|
|
120
|
+
{:else}
|
|
121
|
+
<CheckoutPanel {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
|
|
122
|
+
{/if}
|
|
121
123
|
|
|
122
124
|
<style>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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);
|
|
127
140
|
}
|
|
128
141
|
</style>
|