@code-collective/booking-widget 1.0.10 → 1.0.12

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