@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
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
postMessage({ type: 'cart:updated', cart: null });
|
|
52
|
+
postMessage({ type: 'cart:expired' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// In modal mode, modal:close is what actually dismisses this screen - bw-checkout.svelte remounts this
|
|
56
|
+
// component fresh the next time it opens, so the click is fully handled there. Rendered permanently
|
|
57
|
+
// in-page instead, nothing is listening for modal:close, so without the reload below the click would do
|
|
58
|
+
// nothing a shopper can see: expired flips off, but cart is still the stale null from the load() that
|
|
59
|
+
// found it gone, leaving the same "No items in cart" text up with no sign anything happened.
|
|
60
|
+
function startAgain() {
|
|
61
|
+
expired = false;
|
|
62
|
+
postMessage({ type: 'modal:close' });
|
|
63
|
+
void load();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
|
|
67
|
+
// modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
|
|
68
|
+
// would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
|
|
69
|
+
// cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
|
|
70
|
+
// triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
|
|
71
|
+
$effect(() => onWidgetMessage((d) => {
|
|
72
|
+
if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
73
|
+
}));
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<div class="bw-widget">
|
|
77
|
+
{#if isLoading}
|
|
78
|
+
<div class="loading-center" style="height:100vh">
|
|
79
|
+
<div class="spinner"></div>
|
|
80
|
+
</div>
|
|
81
|
+
{:else if cart && cart.items.length > 0}
|
|
82
|
+
<CheckoutModal
|
|
83
|
+
{cart}
|
|
84
|
+
{api}
|
|
85
|
+
{wizardPages}
|
|
86
|
+
{editPages}
|
|
87
|
+
{autoSelectSingleTimeSlot}
|
|
88
|
+
onClose={() => postMessage({ type: 'modal:close' })}
|
|
89
|
+
onOrderConfirmed={() => {
|
|
90
|
+
const total = cart!.items.reduce((s, i) => s + i.amount, 0);
|
|
91
|
+
const currency = cart!.items[0]?.currencyCode ?? 'ZAR';
|
|
92
|
+
postMessage({
|
|
93
|
+
type: 'order:complete',
|
|
94
|
+
cartToken: cart!.cartToken ?? '',
|
|
95
|
+
value: total,
|
|
96
|
+
currency,
|
|
97
|
+
});
|
|
98
|
+
// The cart is paid and confirmed, so its token has done its job. Nothing used to clear it, so it sat
|
|
99
|
+
// in localStorage on the merchant's origin until absoluteExpiresAt - readable by every third-party
|
|
100
|
+
// script they load, and picked up by the next person to use a shared or kiosk browser.
|
|
101
|
+
cartManager?.reset();
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
{:else if expired}
|
|
105
|
+
<div style="height:100vh">
|
|
106
|
+
<CartExpiredView onStartAgain={startAgain} />
|
|
107
|
+
</div>
|
|
108
|
+
{:else}
|
|
109
|
+
<div class="loading-center" style="height:100vh;color:var(--bw-color-text-secondary)">
|
|
110
|
+
No items in cart.
|
|
111
|
+
</div>
|
|
112
|
+
{/if}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<style>
|
|
116
|
+
/* display: contents - a plain box here would break CheckoutModal's own .modal{height:100%}, which needs
|
|
117
|
+
to resolve against this component's real parent, not an unsized wrapper inserted in between. */
|
|
118
|
+
.bw-widget {
|
|
119
|
+
display: contents;
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy } from 'svelte';
|
|
3
|
-
import { loadPeachSdk } from './peach-sdk';
|
|
3
|
+
import { loadPeachSdk, loadedPeachSdkUrl } from './peach-sdk';
|
|
4
4
|
import CountdownTimer from './CountdownTimer.svelte';
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
@@ -49,18 +49,32 @@
|
|
|
49
49
|
onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
|
|
50
50
|
onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
|
|
51
51
|
onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
|
|
52
|
-
onError: () => { cleanup(); onPaymentComplete({ status: 'error' }); },
|
|
52
|
+
onError: () => { reportPeachFailure('Peach reported an error rendering the card form'); cleanup(); onPaymentComplete({ status: 'error' }); },
|
|
53
53
|
},
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
peachInstance.render('#peach-container');
|
|
57
57
|
} catch (e) {
|
|
58
|
+
reportPeachFailure(String(e));
|
|
58
59
|
failed = true;
|
|
59
60
|
errorMessage = String(e);
|
|
60
61
|
}
|
|
61
62
|
})();
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
// Peach renders its own "an unrecoverable error has occurred" card inside its iframe, which tells a
|
|
66
|
+
// developer nothing about why. By far the most common cause is an SDK from one environment against a
|
|
67
|
+
// checkoutId created in another - the checkout API makes the checkout, so the two have to agree - and
|
|
68
|
+
// that is invisible unless the URL actually loaded is named.
|
|
69
|
+
function reportPeachFailure(reason: string) {
|
|
70
|
+
console.error(
|
|
71
|
+
`[booking-widget] Peach checkout did not render: ${reason}. ` +
|
|
72
|
+
`SDK loaded from ${loadedPeachSdkUrl() ?? 'an already-present window.Checkout'} for checkoutId ` +
|
|
73
|
+
`${checkoutId}. If that environment does not match the checkout API this cart came from, set ` +
|
|
74
|
+
`peachEnv on createBookingHost (or window.BW_CHECKOUT_PEACH_SDK_URL).`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
function cleanup() {
|
|
65
79
|
if (peachInstance) {
|
|
66
80
|
peachInstance.unmount();
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
let success = $derived(status?.outcome === 'successful');
|
|
16
16
|
let pending = $derived(status?.outcome === 'pending');
|
|
17
|
+
// Paid, but this gateway could not confirm every item - so it gets its own branch below rather than falling
|
|
18
|
+
// into the failure one. The shopper's card was charged; telling them "Payment Failed" over an explanation
|
|
19
|
+
// that opens "Your payment succeeded" is how someone ends up paying twice or charging back a good payment.
|
|
20
|
+
let partial = $derived(status?.outcome === 'partial');
|
|
17
21
|
// A 'failed' outcome covers two genuinely different situations - see CheckoutModal's own remarks on
|
|
18
22
|
// confirmOutcome. Only one of them is safe to offer "Try Again" for: the payment itself never went
|
|
19
23
|
// through (declined, expired, SDK error), so re-opening the card form charges the shopper once, not
|
|
@@ -27,12 +31,18 @@
|
|
|
27
31
|
{#if verifying}
|
|
28
32
|
<div class="spinner"></div>
|
|
29
33
|
<p class="hint">Verifying payment...</p>
|
|
34
|
+
<!-- 'pending' is reached whenever this gateway could not get a final answer: the webhook still in flight
|
|
35
|
+
behind a real charge, but also a confirm that gave up after ~15s, a server error, or a cart the server
|
|
36
|
+
no longer has. Peach fires onCompleted for a declined card too, and a decline the server could not read
|
|
37
|
+
back (its own status endpoint unreachable) lands here as readily as a charge does - so this screen must
|
|
38
|
+
not claim a payment was received. It says what is actually known, which is nothing yet, and still asks
|
|
39
|
+
the shopper not to pay again, which is right either way round (PR 8447 review). -->
|
|
30
40
|
{:else if pending}
|
|
31
41
|
<div class="icon pending">…</div>
|
|
32
|
-
<h2>Payment
|
|
42
|
+
<h2>Still Checking Your Payment</h2>
|
|
33
43
|
<p class="desc">
|
|
34
44
|
{status?.resultDescription ??
|
|
35
|
-
"We'
|
|
45
|
+
"We couldn't confirm this payment yet. Please don't try to pay again for now - we'll email you as soon as we know where it stands."}
|
|
36
46
|
</p>
|
|
37
47
|
<div class="actions">
|
|
38
48
|
{#if onCheckAgain}
|
|
@@ -40,6 +50,14 @@
|
|
|
40
50
|
{/if}
|
|
41
51
|
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
42
52
|
</div>
|
|
53
|
+
{:else if partial}
|
|
54
|
+
<div class="icon partial">!</div>
|
|
55
|
+
<h2>Booking Incomplete</h2>
|
|
56
|
+
<p class="desc">
|
|
57
|
+
{status?.resultDescription ??
|
|
58
|
+
"Your payment succeeded, but we couldn't confirm every item in your booking. Please contact us with your order details."}
|
|
59
|
+
</p>
|
|
60
|
+
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
43
61
|
{:else}
|
|
44
62
|
<div class="icon" class:success class:failure={!success}>
|
|
45
63
|
{success ? '✓' : '!'}
|
|
@@ -96,8 +114,10 @@
|
|
|
96
114
|
background: var(--bw-color-primary-light);
|
|
97
115
|
}
|
|
98
116
|
/* Deliberately its own amber, not success green or failure red - payment succeeded (so red reads as an
|
|
99
|
-
alarming false alarm) but
|
|
100
|
-
|
|
117
|
+
alarming false alarm) but the booking is not fully confirmed (so green would be premature). The same is
|
|
118
|
+
true either way round: 'pending' is not confirmed yet, 'partial' will not be for some of its items. */
|
|
119
|
+
.icon.pending,
|
|
120
|
+
.icon.partial {
|
|
101
121
|
border: 2px solid #B8860B;
|
|
102
122
|
color: #B8860B;
|
|
103
123
|
background: rgba(184, 134, 11, 0.08);
|
|
@@ -9,16 +9,30 @@
|
|
|
9
9
|
import { expandUnitItems } from './utils';
|
|
10
10
|
import { formatCurrency } from './currency';
|
|
11
11
|
import WizardPage from './WizardPage.svelte';
|
|
12
|
+
import { getBookingHostContext, requireBookingService } from './booking-context';
|
|
12
13
|
|
|
13
14
|
interface Props {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
// Optional when a BookingProvider is above this component - it supplies both from its host.
|
|
16
|
+
api?: BookingApi;
|
|
17
|
+
cartManager?: CartManager;
|
|
16
18
|
productId: string;
|
|
17
19
|
wizardPages?: WizardPages;
|
|
18
20
|
autoSelectSingleTimeSlot?: boolean;
|
|
21
|
+
// Whether an add from this configurator asks to be carried on into checkout. Stated on the cart:change
|
|
22
|
+
// message rather than acted on here, so the host decides how to honour it - and so bw-configurator's
|
|
23
|
+
// no-auto-checkout attribute and a host's own autoOpenCheckout: false meet in the same branch (see
|
|
24
|
+
// host.svelte.ts) instead of each build having its own way to suppress it.
|
|
25
|
+
autoOpenCheckout?: boolean;
|
|
19
26
|
onCancel?: () => void;
|
|
20
27
|
}
|
|
21
|
-
let { api, cartManager, productId, wizardPages = DEFAULT_WIZARD_PAGES,
|
|
28
|
+
let { api: apiProp, cartManager: cartManagerProp, productId, wizardPages = DEFAULT_WIZARD_PAGES,
|
|
29
|
+
autoSelectSingleTimeSlot = false, autoOpenCheckout = true, onCancel }: Props = $props();
|
|
30
|
+
|
|
31
|
+
const bookingHost = getBookingHostContext();
|
|
32
|
+
let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'TicketConfigurator', 'api'));
|
|
33
|
+
let cartManager = $derived(
|
|
34
|
+
requireBookingService(cartManagerProp ?? bookingHost?.cartManager, 'TicketConfigurator', 'cartManager'),
|
|
35
|
+
);
|
|
22
36
|
|
|
23
37
|
let product = $state<CheckoutProductDto | null>(null);
|
|
24
38
|
let isLoading = $state(true);
|
|
@@ -54,9 +68,9 @@
|
|
|
54
68
|
postMessage({
|
|
55
69
|
type: 'cart:change',
|
|
56
70
|
itemCount: unitItems.length,
|
|
57
|
-
cartItemId: added.id,
|
|
71
|
+
cartItemId: added.id ?? '',
|
|
58
72
|
totalFormatted: total,
|
|
59
|
-
openCheckout:
|
|
73
|
+
openCheckout: autoOpenCheckout,
|
|
60
74
|
});
|
|
61
75
|
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
62
76
|
// public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
interface Props {
|
|
6
6
|
unit: CheckoutUnitDto;
|
|
7
7
|
quantity: number;
|
|
8
|
+
/** Boxed rows separate themselves where space is tight; unboxed ones rely on spacing alone. */
|
|
9
|
+
boxed?: boolean;
|
|
10
|
+
/** Draw a rule above every row but the first, for lists too narrow to separate on spacing. */
|
|
11
|
+
divided?: boolean;
|
|
8
12
|
max?: number;
|
|
9
13
|
onChange: (qty: number) => void;
|
|
10
14
|
}
|
|
11
|
-
let { unit, quantity, max, onChange }: Props = $props();
|
|
15
|
+
let { unit, quantity, boxed = true, divided = false, max, onChange }: Props = $props();
|
|
12
16
|
|
|
13
17
|
let priceLabel = $derived(() => {
|
|
14
18
|
if (!unit.pricing || unit.pricing.length === 0) return '';
|
|
@@ -65,7 +69,7 @@
|
|
|
65
69
|
}
|
|
66
70
|
</script>
|
|
67
71
|
|
|
68
|
-
<div class="card
|
|
72
|
+
<div class="row" class:card={boxed} class:plain={!boxed} class:divided>
|
|
69
73
|
<div class="info">
|
|
70
74
|
<span class="name">{unit.title ?? unit.id}</span>
|
|
71
75
|
{#if priceLabel()}
|
|
@@ -102,6 +106,16 @@
|
|
|
102
106
|
display: flex;
|
|
103
107
|
align-items: center;
|
|
104
108
|
}
|
|
109
|
+
/* Without the card's own padding the rows would butt up against each other; the list's gap alone
|
|
110
|
+
is not enough once the border that used to imply the spacing is gone. */
|
|
111
|
+
.plain {
|
|
112
|
+
padding: 12px 0;
|
|
113
|
+
}
|
|
114
|
+
/* :not(:first-child) resolves against whatever list the caller puts these rows in, so the row keeps
|
|
115
|
+
ownership of its own chrome instead of the list reaching in through :global. */
|
|
116
|
+
.divided:not(:first-child) {
|
|
117
|
+
border-top: 1px solid var(--bw-color-border);
|
|
118
|
+
}
|
|
105
119
|
.info {
|
|
106
120
|
flex: 1;
|
|
107
121
|
display: flex;
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { WizardWidgetType as WidgetType } from './config';
|
|
3
|
+
|
|
4
|
+
// The question each step is asking, shown under the page title in place of a second bold heading.
|
|
5
|
+
// 'addon' is present only to keep the record exhaustive - the widget is never rendered, see
|
|
6
|
+
// isWidgetVisible - and renders nothing if that ever changes without copy being written.
|
|
7
|
+
const WIDGET_SUBTITLES: Record<WidgetType, string> = {
|
|
8
|
+
'option': 'Which option would you like?',
|
|
9
|
+
'age-category': 'How many tickets would you like?',
|
|
10
|
+
'date': 'When would you like to go?',
|
|
11
|
+
'time': 'What time works for you?',
|
|
12
|
+
'pickup': 'Where would you like to be picked up?',
|
|
13
|
+
'addon': '',
|
|
14
|
+
};
|
|
15
|
+
</script>
|
|
16
|
+
|
|
1
17
|
<script lang="ts">
|
|
2
18
|
import type { CartItem, CartUnitItem, CheckoutProductDto, CheckoutCartItemDetailDto, CheckoutOptionDto, CheckoutAvailabilityDto, CheckoutPickupLocationDto, CheckoutAvailabilityCalendarDto } from './client-types';
|
|
3
19
|
import type { BookingApi } from './api';
|
|
@@ -9,6 +25,7 @@
|
|
|
9
25
|
import AvailabilityCalendar from './AvailabilityCalendar.svelte';
|
|
10
26
|
import TimeSlotPicker from './TimeSlotPicker.svelte';
|
|
11
27
|
import SelectableCard from './SelectableCard.svelte';
|
|
28
|
+
import { createInlineLayout } from './layout.svelte';
|
|
12
29
|
import { onMount } from 'svelte';
|
|
13
30
|
|
|
14
31
|
interface Props {
|
|
@@ -26,6 +43,11 @@
|
|
|
26
43
|
editItem, autoSelectSingleTimeSlot = false, onComplete, onCancel,
|
|
27
44
|
}: Props = $props();
|
|
28
45
|
|
|
46
|
+
// Inline (the host's sidebar) and full-screen (the host's sheet) want different chrome - see
|
|
47
|
+
// layout.svelte.ts for why the breakpoint is what it is. Edit mode is a checkout-modal accordion
|
|
48
|
+
// rather than either presentation, so it ignores this entirely.
|
|
49
|
+
const layout = createInlineLayout();
|
|
50
|
+
|
|
29
51
|
let editMode = $derived(editItem != null);
|
|
30
52
|
// In age-first flow the option is selected after date/time, so it should
|
|
31
53
|
// stay changeable even during an edit. Lock it only in option-first flow.
|
|
@@ -448,26 +470,13 @@
|
|
|
448
470
|
}
|
|
449
471
|
</script>
|
|
450
472
|
|
|
451
|
-
<div class="wizard">
|
|
452
|
-
{#if !editMode}
|
|
473
|
+
<div class="wizard" class:inline={!editMode && layout.isInline}>
|
|
474
|
+
{#if !editMode && layout.isInline}
|
|
453
475
|
<div class="wizard-header">
|
|
454
476
|
<h2>Buy Tickets</h2>
|
|
455
477
|
</div>
|
|
456
478
|
{/if}
|
|
457
479
|
|
|
458
|
-
{#if !editMode}
|
|
459
|
-
<div class="page-title-row">
|
|
460
|
-
<h3 class="page-title">{pageTitle()}</h3>
|
|
461
|
-
{#if visiblePages().length > 1}
|
|
462
|
-
<div class="page-dots">
|
|
463
|
-
{#each visiblePages() as _, i}
|
|
464
|
-
<span class="dot" class:active={i === pageIndex}></span>
|
|
465
|
-
{/each}
|
|
466
|
-
</div>
|
|
467
|
-
{/if}
|
|
468
|
-
</div>
|
|
469
|
-
{/if}
|
|
470
|
-
|
|
471
480
|
{#if editMode}
|
|
472
481
|
<div class="wizard-body">
|
|
473
482
|
{#each editSections() as section, sectionIndex}
|
|
@@ -570,10 +579,15 @@
|
|
|
570
579
|
|
|
571
580
|
{:else}
|
|
572
581
|
<div class="wizard-body">
|
|
582
|
+
{#if !layout.isInline}
|
|
583
|
+
<h2 class="body-title">Buy Tickets</h2>
|
|
584
|
+
{/if}
|
|
585
|
+
<h3 class="page-title">{pageTitle()}</h3>
|
|
586
|
+
|
|
573
587
|
{#each currentPage()?.widgets ?? [] as widgetType}
|
|
574
588
|
{#if widgetType === 'option' && !lockedOptionId}
|
|
575
589
|
<div class="section">
|
|
576
|
-
<h4 class="section-title">
|
|
590
|
+
<h4 class="section-title">{WIDGET_SUBTITLES[widgetType]}</h4>
|
|
577
591
|
<div class="option-list">
|
|
578
592
|
{#each product.options as option}
|
|
579
593
|
<OptionCard
|
|
@@ -588,11 +602,13 @@
|
|
|
588
602
|
|
|
589
603
|
{#if widgetType === 'age-category' && availableUnits().length > 0}
|
|
590
604
|
<div class="section">
|
|
591
|
-
<h4 class="section-title">
|
|
592
|
-
<div class="unit-list">
|
|
605
|
+
<h4 class="section-title">{WIDGET_SUBTITLES[widgetType]}</h4>
|
|
606
|
+
<div class="unit-list" class:divided={layout.isInline}>
|
|
593
607
|
{#each availableUnits() as unit}
|
|
594
608
|
<UnitCounter
|
|
595
609
|
{unit}
|
|
610
|
+
boxed={false}
|
|
611
|
+
divided={layout.isInline}
|
|
596
612
|
quantity={unitQuantities[unit.id] ?? 0}
|
|
597
613
|
max={maxForUnit(unit.id)}
|
|
598
614
|
onChange={(qty) => onUnitChanged(unit.id, qty)}
|
|
@@ -604,7 +620,7 @@
|
|
|
604
620
|
|
|
605
621
|
{#if widgetType === 'date' && hasQuantities}
|
|
606
622
|
<div class="section">
|
|
607
|
-
<h4 class="section-title">
|
|
623
|
+
<h4 class="section-title">{WIDGET_SUBTITLES[widgetType]}</h4>
|
|
608
624
|
<AvailabilityCalendar
|
|
609
625
|
{availabilityByDate}
|
|
610
626
|
{selectedDate}
|
|
@@ -616,7 +632,7 @@
|
|
|
616
632
|
|
|
617
633
|
{#if widgetType === 'time' && selectedDate && !hideTimePicker}
|
|
618
634
|
<div class="section">
|
|
619
|
-
<h4 class="section-title">
|
|
635
|
+
<h4 class="section-title">{WIDGET_SUBTITLES[widgetType]}</h4>
|
|
620
636
|
<TimeSlotPicker
|
|
621
637
|
{timeSlots}
|
|
622
638
|
{selectedTime}
|
|
@@ -628,7 +644,7 @@
|
|
|
628
644
|
|
|
629
645
|
{#if widgetType === 'pickup' && selectedOption?.pickupAvailable && selectedOption.pickupLocations}
|
|
630
646
|
<div class="section">
|
|
631
|
-
<h4 class="section-title">
|
|
647
|
+
<h4 class="section-title">{WIDGET_SUBTITLES[widgetType]}</h4>
|
|
632
648
|
<div class="pickup-list">
|
|
633
649
|
{#each selectedOption.pickupLocations as point}
|
|
634
650
|
<SelectableCard
|
|
@@ -644,16 +660,28 @@
|
|
|
644
660
|
{/each}
|
|
645
661
|
</div>
|
|
646
662
|
|
|
647
|
-
<div class="
|
|
648
|
-
{#if
|
|
649
|
-
<
|
|
650
|
-
{
|
|
651
|
-
|
|
663
|
+
<div class="wizard-footer">
|
|
664
|
+
{#if visiblePages().length > 1}
|
|
665
|
+
<div class="page-dots">
|
|
666
|
+
{#each visiblePages() as _, i}
|
|
667
|
+
<span class="dot" class:active={i === pageIndex}></span>
|
|
668
|
+
{/each}
|
|
669
|
+
</div>
|
|
652
670
|
{/if}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
671
|
+
|
|
672
|
+
<div class="action-bar">
|
|
673
|
+
<!-- Back always earns its place. Cancel only does in the sheet, which the widget owns outright -
|
|
674
|
+
inline it sits in the host's page with the rest of the site still around it, so there is
|
|
675
|
+
nothing for a first-page Cancel to dismiss. -->
|
|
676
|
+
{#if pageIndex > 0 || (onCancel && !layout.isInline)}
|
|
677
|
+
<button class="btn btn-secondary" onclick={goBack}>
|
|
678
|
+
{pageIndex > 0 ? 'Back' : 'Cancel'}
|
|
679
|
+
</button>
|
|
680
|
+
{/if}
|
|
681
|
+
<button class="btn btn-primary" onclick={goNext} disabled={!pageComplete()}>
|
|
682
|
+
{isLastPage() ? 'Add to Cart' : 'Next'}
|
|
683
|
+
</button>
|
|
684
|
+
</div>
|
|
657
685
|
</div>
|
|
658
686
|
{/if}
|
|
659
687
|
</div>
|
|
@@ -679,6 +707,12 @@
|
|
|
679
707
|
max-height: 100%;
|
|
680
708
|
overflow: hidden;
|
|
681
709
|
}
|
|
710
|
+
/* The sheet is handed the whole screen and fills it. The sidebar is handed a column and hugs
|
|
711
|
+
whatever the current step actually needs, so a two-unit step isn't a mostly-empty box. */
|
|
712
|
+
.wizard.inline {
|
|
713
|
+
height: auto;
|
|
714
|
+
max-height: none;
|
|
715
|
+
}
|
|
682
716
|
.wizard-header {
|
|
683
717
|
padding: 20px 16px 16px;
|
|
684
718
|
border-bottom: 1px solid var(--bw-color-border);
|
|
@@ -687,20 +721,47 @@
|
|
|
687
721
|
font-size: 20px;
|
|
688
722
|
font-weight: 800;
|
|
689
723
|
}
|
|
690
|
-
.
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
724
|
+
/* Full-screen only. The host's sheet already puts a bar above the widget, so this title belongs
|
|
725
|
+
in the body - rendered as a second header it would sit directly under the host's own. */
|
|
726
|
+
.body-title {
|
|
727
|
+
font-size: 20px;
|
|
728
|
+
font-weight: 800;
|
|
729
|
+
margin-bottom: 16px;
|
|
695
730
|
}
|
|
696
731
|
.page-title {
|
|
697
732
|
font-size: 16px;
|
|
698
733
|
font-weight: 700;
|
|
699
734
|
color: var(--bw-color-text);
|
|
735
|
+
margin-bottom: 4px;
|
|
736
|
+
}
|
|
737
|
+
/* Dots and buttons share one sticky block, so the step indicator stays with the control that
|
|
738
|
+
advances it instead of scrolling away with the body. */
|
|
739
|
+
.wizard-footer {
|
|
740
|
+
flex-shrink: 0;
|
|
741
|
+
position: sticky;
|
|
742
|
+
bottom: 0;
|
|
743
|
+
z-index: 10;
|
|
744
|
+
background: var(--bw-color-bg);
|
|
745
|
+
border-top: 1px solid var(--bw-color-border);
|
|
746
|
+
}
|
|
747
|
+
.wizard-footer .action-bar {
|
|
748
|
+
position: static;
|
|
749
|
+
border-top: none;
|
|
700
750
|
}
|
|
701
751
|
.page-dots {
|
|
702
752
|
display: flex;
|
|
753
|
+
justify-content: center;
|
|
703
754
|
gap: 6px;
|
|
755
|
+
padding: 14px 16px 0;
|
|
756
|
+
}
|
|
757
|
+
/* Inline sits inside the host's own white card, so the whole footer is tinted to read as a footer
|
|
758
|
+
rather than as more body. The sheet has the screen edge doing that job already. The action bar
|
|
759
|
+
carries its own opaque background for the sheet, which has to give way to the tint here. */
|
|
760
|
+
.wizard.inline .wizard-footer {
|
|
761
|
+
background: var(--bw-color-surface);
|
|
762
|
+
}
|
|
763
|
+
.wizard.inline .wizard-footer .action-bar {
|
|
764
|
+
background: transparent;
|
|
704
765
|
}
|
|
705
766
|
.dot {
|
|
706
767
|
width: 8px;
|
|
@@ -783,6 +844,12 @@
|
|
|
783
844
|
flex-direction: column;
|
|
784
845
|
gap: 10px;
|
|
785
846
|
}
|
|
847
|
+
/* Inline only: at sidebar width a rule reads as the separation in less vertical space than the gap
|
|
848
|
+
the sheet can afford. The rule itself belongs to UnitCounter - only the spacing it replaces is
|
|
849
|
+
the list's business. */
|
|
850
|
+
.unit-list.divided {
|
|
851
|
+
gap: 0;
|
|
852
|
+
}
|
|
786
853
|
|
|
787
854
|
/* ── Confirm dialog ────────────────────── */
|
|
788
855
|
.dialog-backdrop {
|
package/src/lib/app.css
CHANGED
|
@@ -157,14 +157,8 @@
|
|
|
157
157
|
height: 48px;
|
|
158
158
|
font-size: 16px;
|
|
159
159
|
font-weight: 700;
|
|
160
|
-
position: relative;
|
|
161
160
|
}
|
|
162
161
|
|
|
163
|
-
.action-bar .btn-primary .arrow {
|
|
164
|
-
position: absolute;
|
|
165
|
-
right: 16px;
|
|
166
|
-
font-size: 20px;
|
|
167
|
-
}
|
|
168
162
|
|
|
169
163
|
/* ── Buttons ───────────────────────────────────────────── */
|
|
170
164
|
.btn {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// How a BookingProvider hands the host down to the components beneath it, so a consumer writes
|
|
2
|
+
// <TicketConfigurator {productId} /> rather than restating host.api and host.cartManager on every tag. The
|
|
3
|
+
// props are still there and still win - the custom-element wrappers pass them explicitly, because each
|
|
4
|
+
// bw-* element is the root of its own Svelte tree and context does not cross that boundary.
|
|
5
|
+
|
|
6
|
+
import { getContext, setContext } from 'svelte';
|
|
7
|
+
import type { BookingHost } from './host.svelte';
|
|
8
|
+
|
|
9
|
+
const BOOKING_HOST = Symbol('booking-host');
|
|
10
|
+
|
|
11
|
+
/** Call during component initialisation - BookingProvider is the only thing that should need this. */
|
|
12
|
+
export function setBookingHostContext(host: BookingHost): void {
|
|
13
|
+
setContext(BOOKING_HOST, host);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The host from the nearest BookingProvider, or undefined when there is none. */
|
|
17
|
+
export function getBookingHostContext(): BookingHost | undefined {
|
|
18
|
+
return getContext<BookingHost | undefined>(BOOKING_HOST);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a service a component cannot render without, and says plainly what to do when it is missing.
|
|
23
|
+
* Without this the failure is a `Cannot read properties of undefined` from somewhere deep in a load().
|
|
24
|
+
*/
|
|
25
|
+
export function requireBookingService<T>(value: T | undefined, component: string, prop: string): T {
|
|
26
|
+
if (value === undefined || value === null) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`<${component}> has no ${prop}. Pass ${prop}={...}, or wrap it in ` +
|
|
29
|
+
`<BookingProvider host={createBookingHost(...)}>.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|