@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.
- package/CHANGELOG.md +46 -0
- package/README.md +500 -388
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +2415 -1745
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +39 -23
- package/dist/booking-widget.umd.cjs +5 -3
- package/package.json +58 -55
- package/src/lib/BookingProvider.svelte +21 -0
- package/src/lib/CartBar.svelte +26 -41
- package/src/lib/CartBarView.svelte +78 -61
- package/src/lib/CartExpiryGuard.svelte +15 -9
- package/src/lib/CartOverview.svelte +30 -20
- package/src/lib/CartOverviewButton.svelte +104 -101
- package/src/lib/Checkout.svelte +141 -128
- package/src/lib/CheckoutModal.svelte +838 -805
- package/src/lib/CheckoutPanel.svelte +121 -0
- package/src/lib/PaymentPage.svelte +191 -177
- package/src/lib/PickupPointPicker.svelte +1 -1
- package/src/lib/TicketConfigurator.svelte +166 -152
- 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/config.ts +162 -153
- package/src/lib/elements/bw-cart.svelte +49 -35
- package/src/lib/elements/bw-checkout.svelte +97 -97
- package/src/lib/elements/bw-configurator.svelte +72 -56
- package/src/lib/elements/register.ts +171 -196
- package/src/lib/elements/shared.ts +18 -14
- package/src/lib/elements/theme.css +0 -6
- package/src/lib/host.svelte.ts +336 -0
- package/src/lib/index.ts +242 -196
- package/src/lib/layout.svelte.ts +52 -0
- package/src/lib/messages.ts +157 -77
- package/src/lib/peach-sdk.ts +86 -40
- 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,152 +1,166 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { BookingApi } from './api';
|
|
3
|
-
import { ApiError, isPriceMismatch } from './api';
|
|
4
|
-
import type { WizardPages } from './config';
|
|
5
|
-
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
|
-
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
7
|
-
import { CartManager } from './cart-manager';
|
|
8
|
-
import { postMessage } from './messages';
|
|
9
|
-
import { expandUnitItems } from './utils';
|
|
10
|
-
import { formatCurrency } from './currency';
|
|
11
|
-
import WizardPage from './WizardPage.svelte';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
</div>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { BookingApi } from './api';
|
|
3
|
+
import { ApiError, isPriceMismatch } from './api';
|
|
4
|
+
import type { WizardPages } from './config';
|
|
5
|
+
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
|
+
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
7
|
+
import { CartManager } from './cart-manager';
|
|
8
|
+
import { postMessage } from './messages';
|
|
9
|
+
import { expandUnitItems } from './utils';
|
|
10
|
+
import { formatCurrency } from './currency';
|
|
11
|
+
import WizardPage from './WizardPage.svelte';
|
|
12
|
+
import { getBookingHostContext, requireBookingService } from './booking-context';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
// Optional when a BookingProvider is above this component - it supplies both from its host.
|
|
16
|
+
api?: BookingApi;
|
|
17
|
+
cartManager?: CartManager;
|
|
18
|
+
productId: string;
|
|
19
|
+
wizardPages?: WizardPages;
|
|
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;
|
|
26
|
+
onCancel?: () => void;
|
|
27
|
+
}
|
|
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
|
+
);
|
|
36
|
+
|
|
37
|
+
let product = $state<CheckoutProductDto | null>(null);
|
|
38
|
+
let isLoading = $state(true);
|
|
39
|
+
let isAddingToCart = $state(false);
|
|
40
|
+
let addToCartError = $state<string | null>(null);
|
|
41
|
+
|
|
42
|
+
async function load() {
|
|
43
|
+
product = await api.getProduct(productId);
|
|
44
|
+
isLoading = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
load();
|
|
48
|
+
|
|
49
|
+
async function addItemToCart(item: CartItem): Promise<void> {
|
|
50
|
+
await cartManager.ensureCart();
|
|
51
|
+
|
|
52
|
+
const unitItems = expandUnitItems(item.units);
|
|
53
|
+
const added = await api.addCartItem({
|
|
54
|
+
productId: item.productId,
|
|
55
|
+
optionId: item.optionId,
|
|
56
|
+
unitItems,
|
|
57
|
+
availabilityId: item.availabilityId,
|
|
58
|
+
localDate: item.localDate,
|
|
59
|
+
pickupPointId: item.pickupPointId,
|
|
60
|
+
// Currency and precision are the supplier's to state, not ours - the API resolves both server-side and
|
|
61
|
+
// ignores anything sent here. Amount stays, but only as a claim about the price the customer was shown:
|
|
62
|
+
// the server accepts it solely when it matches what the supplier is asking or a price the server itself
|
|
63
|
+
// published, and returns PRICE_MISMATCH otherwise.
|
|
64
|
+
amount: item.totalPrice,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
68
|
+
postMessage({
|
|
69
|
+
type: 'cart:change',
|
|
70
|
+
itemCount: unitItems.length,
|
|
71
|
+
cartItemId: added.id ?? '',
|
|
72
|
+
totalFormatted: total,
|
|
73
|
+
openCheckout: autoOpenCheckout,
|
|
74
|
+
});
|
|
75
|
+
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
76
|
+
// public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
|
|
77
|
+
// use it to update their own state without each independently re-fetching, and it's forwarded to
|
|
78
|
+
// consumers as the public bw:cart-updated event/onCartUpdated callback - see this event's own doc comment
|
|
79
|
+
// in CheckoutModal (the other place it's posted from, after an edit/remove).
|
|
80
|
+
const cart = await api.getCart().catch(() => null);
|
|
81
|
+
postMessage({ type: 'cart:updated', cart });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function onAddToCart(item: CartItem) {
|
|
85
|
+
isAddingToCart = true;
|
|
86
|
+
addToCartError = null;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await addItemToCart(item);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// The supplier moved the price while the customer was configuring, and the amount they agreed to is no
|
|
92
|
+
// longer one the server accepts. Retrying is useless - the wizard still holds the old prices, so it
|
|
93
|
+
// would resubmit exactly the same amount and fail identically. The only way out is to re-fetch and let
|
|
94
|
+
// the customer see the new price, which is what the message asks them to do.
|
|
95
|
+
if (isPriceMismatch(e)) {
|
|
96
|
+
await reloadAfterPriceChange();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
101
|
+
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
102
|
+
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
103
|
+
// before giving up, rather than leaving the user stuck with no feedback and no way to proceed.
|
|
104
|
+
const isStaleCart = e instanceof ApiError && (e.status === 401 || e.status === 404);
|
|
105
|
+
if (isStaleCart) {
|
|
106
|
+
cartManager.reset();
|
|
107
|
+
try {
|
|
108
|
+
await addItemToCart(item);
|
|
109
|
+
} catch {
|
|
110
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
isAddingToCart = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function reloadAfterPriceChange() {
|
|
121
|
+
addToCartError = "This ticket's price has changed since you started. Please check the updated price and try again.";
|
|
122
|
+
try {
|
|
123
|
+
await load();
|
|
124
|
+
} catch {
|
|
125
|
+
// The reload is what makes the message actionable; if even that fails the customer needs to start over
|
|
126
|
+
// rather than be left looking at prices we already know are wrong.
|
|
127
|
+
addToCartError = "This ticket's price has changed and we could not load the new one. Please reload the page.";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<div class="bw-widget">
|
|
133
|
+
{#if isLoading || isAddingToCart}
|
|
134
|
+
<div class="loading-center" style="height:100vh">
|
|
135
|
+
<div class="spinner"></div>
|
|
136
|
+
</div>
|
|
137
|
+
{:else if product}
|
|
138
|
+
{#if addToCartError}
|
|
139
|
+
<p class="add-to-cart-error">{addToCartError}</p>
|
|
140
|
+
{/if}
|
|
141
|
+
<WizardPage
|
|
142
|
+
{product}
|
|
143
|
+
{api}
|
|
144
|
+
{wizardPages}
|
|
145
|
+
{autoSelectSingleTimeSlot}
|
|
146
|
+
{onCancel}
|
|
147
|
+
onComplete={onAddToCart}
|
|
148
|
+
/>
|
|
149
|
+
{/if}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<style>
|
|
153
|
+
/* display: contents - a plain box here would break WizardPage's own .wizard{height:100%}, which needs
|
|
154
|
+
to resolve against this component's real parent, not an unsized wrapper inserted in between. */
|
|
155
|
+
.bw-widget {
|
|
156
|
+
display: contents;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.add-to-cart-error {
|
|
160
|
+
margin: 0;
|
|
161
|
+
padding: 12px 16px;
|
|
162
|
+
background: #fdecea;
|
|
163
|
+
color: #b3261e;
|
|
164
|
+
font-size: 14px;
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
@@ -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
|
+
}
|