@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +153 -41
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +2637 -1924
  5. package/dist/booking-widget.min.css +1 -1
  6. package/dist/booking-widget.min.js +34 -16
  7. package/dist/booking-widget.umd.cjs +5 -3
  8. package/package.json +5 -2
  9. package/src/lib/BookingProvider.svelte +21 -0
  10. package/src/lib/CartBar.svelte +16 -31
  11. package/src/lib/CartBarView.svelte +20 -3
  12. package/src/lib/CartExpiryGuard.svelte +416 -410
  13. package/src/lib/CartOverview.svelte +16 -6
  14. package/src/lib/CartOverviewButton.svelte +29 -26
  15. package/src/lib/Checkout.svelte +111 -98
  16. package/src/lib/CheckoutModal.svelte +946 -805
  17. package/src/lib/CheckoutPanel.svelte +121 -0
  18. package/src/lib/PaymentPage.svelte +16 -2
  19. package/src/lib/PickupPointPicker.svelte +1 -1
  20. package/src/lib/ResultView.svelte +24 -4
  21. package/src/lib/TicketConfigurator.svelte +19 -5
  22. package/src/lib/UnitCounter.svelte +16 -2
  23. package/src/lib/WizardPage.svelte +102 -35
  24. package/src/lib/app.css +0 -6
  25. package/src/lib/booking-context.ts +33 -0
  26. package/src/lib/cart-overview.svelte.ts +97 -0
  27. package/src/lib/client-types.ts +10 -6
  28. package/src/lib/config.ts +9 -0
  29. package/src/lib/elements/bw-cart.svelte +21 -7
  30. package/src/lib/elements/bw-checkout.svelte +54 -54
  31. package/src/lib/elements/bw-configurator.svelte +30 -14
  32. package/src/lib/elements/register.ts +96 -121
  33. package/src/lib/elements/shared.ts +4 -0
  34. package/src/lib/elements/theme.css +0 -6
  35. package/src/lib/host.svelte.ts +336 -0
  36. package/src/lib/index.ts +137 -91
  37. package/src/lib/layout.svelte.ts +52 -0
  38. package/src/lib/messages.ts +97 -17
  39. package/src/lib/peach-sdk.ts +51 -5
  40. package/src/lib/portal.ts +23 -0
  41. package/src/lib/CartExpiryGuard.test.ts +0 -331
  42. package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
  43. package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
  44. package/src/lib/test/fixtures.ts +0 -107
  45. 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();
@@ -45,7 +45,7 @@
45
45
  onclick={() => onContinue(selectedPoint)}
46
46
  >
47
47
  <span>{formattedTotal}</span>
48
- <span>Continue &rarr;</span>
48
+ <span>Continue</span>
49
49
  </button>
50
50
  </div>
51
51
  </div>
@@ -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">&hellip;</div>
32
- <h2>Payment Received</h2>
42
+ <h2>Still Checking Your Payment</h2>
33
43
  <p class="desc">
34
44
  {status?.resultDescription ??
35
- "We're still confirming your booking - this can take a minute. Please don't try to pay again; we'll email your confirmation as soon as it's ready."}
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 nothing is confirmed yet (so green would be premature). */
100
- .icon.pending {
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
- api: BookingApi;
15
- cartManager: CartManager;
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, autoSelectSingleTimeSlot = false, onCancel }: Props = $props();
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: true,
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 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
+ }