@code-collective/booking-widget 1.0.7 → 1.0.9

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 (39) hide show
  1. package/README.md +33 -2
  2. package/dist/booking-widget.css +1 -1
  3. package/dist/booking-widget.js +1038 -795
  4. package/dist/booking-widget.min.js +23 -9
  5. package/dist/booking-widget.umd.cjs +3 -2
  6. package/package.json +6 -2
  7. package/src/lib/CartBar.svelte +4 -11
  8. package/src/lib/CartBarView.svelte +2 -1
  9. package/src/lib/CartExpiredView.svelte +63 -0
  10. package/src/lib/CartExpiryGuard.svelte +410 -0
  11. package/src/lib/CartExpiryGuard.test.ts +331 -0
  12. package/src/lib/CartExpiryWatcher.svelte +46 -0
  13. package/src/lib/CartOverviewButton.svelte +6 -17
  14. package/src/lib/Checkout.svelte +55 -14
  15. package/src/lib/CheckoutModal.confirm-outcome.test.ts +91 -0
  16. package/src/lib/CheckoutModal.payment-timeout.test.ts +140 -0
  17. package/src/lib/CheckoutModal.svelte +805 -560
  18. package/src/lib/CountdownTimer.svelte +2 -4
  19. package/src/lib/EditBookingView.svelte +15 -4
  20. package/src/lib/PaymentPage.svelte +78 -61
  21. package/src/lib/ResultView.svelte +43 -3
  22. package/src/lib/StillTherePrompt.svelte +72 -0
  23. package/src/lib/TicketConfigurator.svelte +25 -3
  24. package/src/lib/UnitCounter.svelte +84 -8
  25. package/src/lib/WizardPage.svelte +11 -0
  26. package/src/lib/api.ts +89 -2
  27. package/src/lib/cart-expiry.ts +46 -0
  28. package/src/lib/cart-manager.ts +23 -6
  29. package/src/lib/client-types.ts +6 -0
  30. package/src/lib/elements/bw-cart.svelte +3 -12
  31. package/src/lib/elements/bw-checkout.svelte +9 -16
  32. package/src/lib/elements/bw-configurator.svelte +3 -13
  33. package/src/lib/elements/register.ts +33 -10
  34. package/src/lib/generated-types.ts +133 -3
  35. package/src/lib/index.ts +19 -28
  36. package/src/lib/messages.ts +75 -1
  37. package/src/lib/payment-attempt.ts +56 -0
  38. package/src/lib/test/fixtures.ts +107 -0
  39. package/src/lib/test/messages-mock.ts +34 -0
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy } from 'svelte';
3
+ import { formatRemaining } from './cart-expiry';
3
4
 
4
5
  interface Props { expiresAt: Date }
5
6
  let { expiresAt }: Props = $props();
@@ -7,10 +8,7 @@
7
8
  let remaining = $state('');
8
9
 
9
10
  function update() {
10
- const diff = Math.max(0, expiresAt.getTime() - Date.now());
11
- const mins = Math.floor(diff / 60000);
12
- const secs = Math.floor((diff % 60000) / 1000);
13
- remaining = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
11
+ remaining = formatRemaining(expiresAt.getTime() - Date.now());
14
12
  }
15
13
 
16
14
  update();
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { CartItem, CheckoutCartItemDetailDto, CheckoutProductDto } from './client-types';
3
3
  import type { BookingApi } from './api';
4
- import { ApiError } from './api';
4
+ import { ApiError, isPriceMismatch } from './api';
5
5
  import type { WizardPages } from './config';
6
6
  import { DEFAULT_WIZARD_PAGES } from './config';
7
7
  import { expandUnitItems } from './utils';
@@ -17,8 +17,11 @@
17
17
  onBack: () => void;
18
18
  onClose: () => void;
19
19
  onUpdated: () => void;
20
+ /// Re-fetches the products this view is handed, so a price that moved under the customer can be shown to
21
+ /// them. Owned by the parent because `product` is a prop here - this view cannot refresh it itself.
22
+ onPriceChanged?: () => Promise<void>;
20
23
  }
21
- let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated }: Props = $props();
24
+ let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated, onPriceChanged }: Props = $props();
22
25
 
23
26
  let isSaving = $state(false);
24
27
  let errorMessage = $state<string | null>(null);
@@ -34,12 +37,20 @@
34
37
  availabilityId: item.availabilityId,
35
38
  localDate: item.localDate,
36
39
  pickupPointId: item.pickupPointId,
40
+ // Currency and precision are resolved server-side - see the same call in TicketConfigurator.
37
41
  amount: item.totalPrice,
38
- currencyCode: item.currency,
39
- currencyPrecision: item.currencyPrecision,
40
42
  });
41
43
  onUpdated();
42
44
  } catch (e) {
45
+ // The supplier moved the price mid-edit, so the amount being submitted is no longer one the server will
46
+ // accept. Retrying resubmits the same stale figure and fails identically, so the prices this view is
47
+ // showing have to be refreshed before the customer can do anything useful.
48
+ if (isPriceMismatch(e)) {
49
+ errorMessage = "This item's price has changed. Please check the updated price and save again.";
50
+ await onPriceChanged?.();
51
+ return;
52
+ }
53
+
43
54
  // 424 means the cart item itself was rolled back server-side (see CheckoutCartEndpoints.UpdateItemAsync) -
44
55
  // the supplier rejected the change, so nothing was saved. Surface that instead of letting it vanish
45
56
  // silently: WizardPage stays mounted (not swapped for a spinner) specifically so the user's in-progress
@@ -1,15 +1,19 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy } from 'svelte';
3
3
  import { loadPeachSdk } from './peach-sdk';
4
+ import CountdownTimer from './CountdownTimer.svelte';
4
5
 
5
6
  interface Props {
6
7
  checkoutId: string;
7
- totalFormatted: string;
8
- onBack: () => void;
9
- onClose: () => void;
8
+ entityId: string;
10
9
  onPaymentComplete: (result: { status: string }) => void;
10
+ // The cart's remaining life, shown in this dialog's own header so the shopper can see it over Peach's
11
+ // form - CheckoutModal's own cart timer is behind this overlay. Optional so callers that predate it need
12
+ // not pass one. CartExpiryGuard.svelte is what actually warns and acts as this counts down to zero - it
13
+ // renders above this overlay too, so there is nothing else for this dialog to do near the deadline.
14
+ expiresAt?: Date;
11
15
  }
12
- let { checkoutId, totalFormatted, onBack, onClose, onPaymentComplete }: Props = $props();
16
+ let { checkoutId, entityId, onPaymentComplete, expiresAt }: Props = $props();
13
17
 
14
18
  let failed = $state(false);
15
19
  let errorMessage = $state('');
@@ -30,9 +34,16 @@
30
34
 
31
35
  peachInstance = Checkout.initiate({
32
36
  checkoutId,
37
+ // Peach's own "key" field, not our checkoutKey/entityId naming - without it Checkout.initiate()
38
+ // throws (Cannot read properties of undefined (reading 'trim')) before ever rendering anything,
39
+ // since the embedded widget authenticates to Peach directly with this rather than through us.
40
+ key: entityId,
33
41
  customisations: {
34
42
  card: { submitButtonText: 'Pay Now' },
35
43
  theme: { brand: { primary: '#E30613' } },
44
+ // Peach's own Cancel (method-selection screen) and Back (card-entry screen) are the only
45
+ // back/exit controls in this dialog - we don't render one of our own alongside them, so there's
46
+ // exactly one way out per screen instead of ours and Peach's competing for the same job.
36
47
  },
37
48
  eventHandlers: {
38
49
  onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
@@ -60,77 +71,80 @@
60
71
  onDestroy(cleanup);
61
72
  </script>
62
73
 
63
- <div class="payment-page">
64
- <div class="page-header">
65
- <button class="icon-btn" onclick={onBack} aria-label="Back">&larr;</button>
66
- <h2>Payment</h2>
67
- <span class="spacer"></span>
68
- <button class="icon-btn" onclick={onClose} aria-label="Close">&times;</button>
69
- </div>
70
-
71
- <div class="body">
72
- <div class="amount-bar">
73
- <span>Amount to pay</span>
74
- <span class="amount">{totalFormatted}</span>
74
+ <!-- Peach's own card form renders inside a cross-origin iframe with its own Cancel/Back controls we have no
75
+ way to hide or reach (no SDK option, no CSS/JS access across origins) - see Checkout.initiate()'s
76
+ customisations doc comment above. Presenting it as its own nested dialog over the checkout modal, rather
77
+ than as just another view swapped into that modal's own body, means Peach's own Cancel/Back read as
78
+ belonging to Peach's own layer instead of visually competing with a second, separate Back of ours - so
79
+ this dialog has no back/exit control of its own at all; onCancelled below routes back to contact. -->
80
+ <div class="payment-overlay">
81
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
82
+ <div class="payment-dialog" onclick={(e) => e.stopPropagation()}>
83
+ <div class="page-header">
84
+ <h2>Payment</h2>
85
+ {#if expiresAt}
86
+ <span class="spacer"></span>
87
+ <CountdownTimer {expiresAt} />
88
+ {/if}
75
89
  </div>
76
90
 
77
- {#if failed}
78
- <div class="error-state">
79
- <div class="error-icon">!</div>
80
- <p class="error-title">Unable to load payment form</p>
81
- <p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
82
- </div>
83
- {:else}
84
- <div class="brands">
85
- <span class="brand">VISA</span>
86
- <span class="brand">MC</span>
87
- <span class="brand">AMEX</span>
88
- </div>
89
- <div id="peach-container"></div>
90
- {/if}
91
+ <div class="body">
92
+ {#if failed}
93
+ <div class="error-state">
94
+ <div class="error-icon">!</div>
95
+ <p class="error-title">Unable to load payment form</p>
96
+ <p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
97
+ <!-- Peach's own Cancel/Back never rendered, so this dialog needs one exit of its own here. Reported
98
+ as an error rather than a cancel: nothing was charged, and CheckoutModal's error path abandons
99
+ the attempt so a retry mints a fresh Peach checkout instead of reopening this one - which is the
100
+ only sensible outcome for a checkout that would not even render (e.g. one resumed after a reload
101
+ that Peach has since expired). -->
102
+ <button class="btn btn-primary" onclick={() => onPaymentComplete({ status: 'error' })}>Start over</button>
103
+ </div>
104
+ {:else}
105
+ <div id="peach-container"></div>
106
+ {/if}
107
+ </div>
91
108
  </div>
92
109
  </div>
93
110
 
94
111
  <style>
95
- .payment-page {
112
+ .payment-overlay {
113
+ position: fixed;
114
+ inset: 0;
115
+ /* Above bw-checkout's own .bw-modal-overlay (z-index 10000) so this reads as a second dialog opening
116
+ on top of the checkout modal, not content living inside it. */
117
+ z-index: 10001;
118
+ background: rgba(0, 0, 0, 0.5);
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ padding: 16px;
123
+ }
124
+ .payment-dialog {
96
125
  display: flex;
97
126
  flex-direction: column;
98
- height: 100%;
127
+ width: 100%;
128
+ max-width: 480px;
129
+ height: min(700px, 90vh);
130
+ background: var(--bw-color-bg);
131
+ border-radius: var(--bw-radius-lg);
132
+ box-shadow: var(--bw-shadow-3);
133
+ overflow: hidden;
99
134
  }
100
135
  .body {
101
136
  flex: 1;
137
+ /* A definite height (not just flex:1 alone) so #peach-container's own height:100% below has something
138
+ real to resolve against, and so this scrolls as a single region if content overflows - Peach's own
139
+ root is `height: inherit`, so without that it collapses to its 360px fallback regardless of how tall
140
+ the card form actually gets (e.g. once billing address fields are showing), and its submit button
141
+ ends up overlapping the fields above it. */
142
+ min-height: 0;
102
143
  overflow-y: auto;
103
144
  padding: 16px;
104
145
  }
105
- .amount-bar {
106
- display: flex;
107
- justify-content: space-between;
108
- align-items: center;
109
- padding: 16px;
110
- background: var(--bw-color-surface);
111
- border-radius: var(--bw-radius-lg);
112
- margin-bottom: 20px;
113
- font-weight: 600;
114
- font-size: 15px;
115
- }
116
- .amount {
117
- color: var(--bw-color-primary);
118
- font-size: 18px;
119
- font-weight: 700;
120
- }
121
- .brands {
122
- display: flex;
123
- gap: 8px;
124
- margin-bottom: 16px;
125
- }
126
- .brand {
127
- padding: 4px 10px;
128
- border: 1px solid var(--bw-color-border);
129
- border-radius: var(--bw-radius-sm);
130
- font-size: 11px;
131
- font-weight: 700;
132
- color: var(--bw-color-text-secondary);
133
- letter-spacing: 0.02em;
146
+ #peach-container {
147
+ height: 100%;
134
148
  }
135
149
  .error-state {
136
150
  text-align: center;
@@ -157,4 +171,7 @@
157
171
  font-size: 14px;
158
172
  color: var(--bw-color-text-secondary);
159
173
  }
174
+ .error-state .btn {
175
+ margin-top: 16px;
176
+ }
160
177
  </style>
@@ -6,19 +6,43 @@
6
6
  verifying: boolean;
7
7
  onDone: () => void;
8
8
  onRetry: () => void;
9
+ // Re-checks confirmation on demand without touching payment - only meaningful (and only rendered) for
10
+ // a 'pending' outcome. Optional so callers that predate this state need not pass it.
11
+ onCheckAgain?: () => void;
9
12
  }
10
- let { status, verifying, onDone, onRetry }: Props = $props();
13
+ let { status, verifying, onDone, onRetry, onCheckAgain }: Props = $props();
11
14
 
12
15
  let success = $derived(status?.outcome === 'successful');
16
+ let pending = $derived(status?.outcome === 'pending');
17
+ // A 'failed' outcome covers two genuinely different situations - see CheckoutModal's own remarks on
18
+ // confirmOutcome. Only one of them is safe to offer "Try Again" for: the payment itself never went
19
+ // through (declined, expired, SDK error), so re-opening the card form charges the shopper once, not
20
+ // twice. The other - payment succeeded but this gateway could not confirm every item - already has the
21
+ // shopper's money against a real charge; offering to pay again there would risk a second charge for
22
+ // nothing this screen can fix, so status.retryPayment gates the button rather than the outcome alone.
23
+ let retryable = $derived(status?.outcome === 'failed' && status.retryPayment !== false);
13
24
  </script>
14
25
 
15
26
  <div class="result">
16
27
  {#if verifying}
17
28
  <div class="spinner"></div>
18
29
  <p class="hint">Verifying payment...</p>
30
+ {:else if pending}
31
+ <div class="icon pending">&hellip;</div>
32
+ <h2>Payment Received</h2>
33
+ <p class="desc">
34
+ {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."}
36
+ </p>
37
+ <div class="actions">
38
+ {#if onCheckAgain}
39
+ <button class="btn btn-primary" onclick={onCheckAgain}>Check Again</button>
40
+ {/if}
41
+ <button class="btn btn-outline" onclick={onDone}>Close</button>
42
+ </div>
19
43
  {:else}
20
44
  <div class="icon" class:success class:failure={!success}>
21
- {success ? '\u2713' : '!'}
45
+ {success ? '' : '!'}
22
46
  </div>
23
47
  <h2>{success ? 'Payment Successful' : 'Payment Failed'}</h2>
24
48
  <p class="desc">
@@ -28,8 +52,10 @@
28
52
  </p>
29
53
  {#if success}
30
54
  <button class="btn btn-primary" onclick={onDone}>Done</button>
31
- {:else}
55
+ {:else if retryable}
32
56
  <button class="btn btn-outline" onclick={onRetry}>Try Again</button>
57
+ {:else}
58
+ <button class="btn btn-outline" onclick={onDone}>Close</button>
33
59
  {/if}
34
60
  {/if}
35
61
  </div>
@@ -69,6 +95,13 @@
69
95
  color: var(--bw-color-error);
70
96
  background: var(--bw-color-primary-light);
71
97
  }
98
+ /* 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 {
101
+ border: 2px solid #B8860B;
102
+ color: #B8860B;
103
+ background: rgba(184, 134, 11, 0.08);
104
+ }
72
105
  h2 {
73
106
  font-size: 20px;
74
107
  font-weight: 700;
@@ -81,4 +114,11 @@
81
114
  max-width: 280px;
82
115
  line-height: 1.5;
83
116
  }
117
+ .actions {
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 8px;
121
+ width: 100%;
122
+ max-width: 220px;
123
+ }
84
124
  </style>
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ remaining: string;
4
+ // False once the cart can be given no more time - its deadline is at its absolute ceiling (read off the
5
+ // cart's own instants, see CartExpiryGuard.svelte) - the prompt then only warns, it does not offer what it
6
+ // cannot deliver. A hold extension the supplier refuses is not this case: the guard closes the prompt
7
+ // for that deadline instead of showing a second message.
8
+ canExtend: boolean;
9
+ extending: boolean;
10
+ onExtend: () => void;
11
+ onDismiss: () => void;
12
+ }
13
+ let { remaining, canExtend, extending, onExtend, onDismiss }: Props = $props();
14
+
15
+ // Focused on mount so a keyboard or screen-reader user lands inside the dialog they have just been shown
16
+ // rather than wherever they happened to be on the merchant's page behind it.
17
+ let primaryButton = $state<HTMLButtonElement | null>(null);
18
+ $effect(() => {
19
+ primaryButton?.focus();
20
+ });
21
+ </script>
22
+
23
+ <!-- No overlay/backdrop of its own - CartExpiryGuard.svelte owns the one shared page-wide overlay this and
24
+ CartExpiredView both render inside, so there is exactly one dimmed backdrop, never two stacking. -->
25
+ <div class="prompt-card" role="dialog" aria-modal="true" aria-labelledby="bw-still-there-title">
26
+ <h3 id="bw-still-there-title">Are you still there?</h3>
27
+ {#if canExtend}
28
+ <p>
29
+ Your cart will expire in <strong class="remaining">{remaining}</strong>. Let us know you're still here
30
+ and we'll hold your tickets a little longer.
31
+ </p>
32
+ <button class="btn btn-primary" bind:this={primaryButton} onclick={onExtend} disabled={extending}>
33
+ {extending ? 'One moment...' : "Yes, I'm still here"}
34
+ </button>
35
+ {:else}
36
+ <p>
37
+ Your cart will expire in <strong class="remaining">{remaining}</strong> and can't be held any longer.
38
+ Please complete your booking before then.
39
+ </p>
40
+ <button class="btn btn-primary" bind:this={primaryButton} onclick={onDismiss}>OK</button>
41
+ {/if}
42
+ </div>
43
+
44
+ <style>
45
+ .prompt-card {
46
+ width: 100%;
47
+ max-width: 360px;
48
+ padding: 28px 24px;
49
+ background: var(--bw-color-bg);
50
+ border-radius: var(--bw-radius-lg);
51
+ box-shadow: var(--bw-shadow-3);
52
+ text-align: center;
53
+ }
54
+ h3 {
55
+ font-size: 18px;
56
+ font-weight: 700;
57
+ margin-bottom: 10px;
58
+ }
59
+ p {
60
+ font-size: 14px;
61
+ line-height: 1.5;
62
+ color: var(--bw-color-text-secondary);
63
+ margin-bottom: 20px;
64
+ }
65
+ .remaining {
66
+ color: var(--bw-color-text);
67
+ font-variant-numeric: tabular-nums;
68
+ }
69
+ .btn {
70
+ width: 100%;
71
+ }
72
+ </style>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { BookingApi } from './api';
3
- import { ApiError } from './api';
3
+ import { ApiError, isPriceMismatch } from './api';
4
4
  import type { WizardPages } from './config';
5
5
  import { DEFAULT_WIZARD_PAGES } from './config';
6
6
  import type { CartItem, CheckoutProductDto } from './client-types';
@@ -43,9 +43,11 @@
43
43
  availabilityId: item.availabilityId,
44
44
  localDate: item.localDate,
45
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.
46
50
  amount: item.totalPrice,
47
- currencyCode: item.currency,
48
- currencyPrecision: item.currencyPrecision,
49
51
  });
50
52
 
51
53
  const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
@@ -72,6 +74,15 @@
72
74
  try {
73
75
  await addItemToCart(item);
74
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
+
75
86
  // CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
76
87
  // server's idle window sliding forward, so a cart that looks valid locally can still be rejected
77
88
  // server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
@@ -91,6 +102,17 @@
91
102
  isAddingToCart = false;
92
103
  }
93
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
+ }
94
116
  </script>
95
117
 
96
118
  <div class="bw-widget">
@@ -5,9 +5,10 @@
5
5
  interface Props {
6
6
  unit: CheckoutUnitDto;
7
7
  quantity: number;
8
+ max?: number;
8
9
  onChange: (qty: number) => void;
9
10
  }
10
- let { unit, quantity, onChange }: Props = $props();
11
+ let { unit, quantity, max, onChange }: Props = $props();
11
12
 
12
13
  let priceLabel = $derived(() => {
13
14
  if (!unit.pricing || unit.pricing.length === 0) return '';
@@ -15,11 +16,53 @@
15
16
  return `from ${formatCurrency(p.retail, p.currency, p.currencyPrecision)}`;
16
17
  });
17
18
 
18
- function decrement() { if (quantity > 0) onChange(quantity - 1); }
19
+ let inputValue = $state(String(quantity));
20
+ let limitMessage = $state('');
21
+
22
+ $effect(() => {
23
+ inputValue = String(quantity);
24
+ });
25
+
26
+ function decrement() {
27
+ if (quantity > 0) onChange(quantity - 1);
28
+ }
19
29
 
20
30
  function increment() {
31
+ if (max != null && quantity >= max) {
32
+ showLimitMessage();
33
+ return;
34
+ }
35
+ limitMessage = '';
21
36
  onChange(quantity + 1);
22
37
  }
38
+
39
+ function showLimitMessage() {
40
+ const label = unit.title ?? unit.id;
41
+ limitMessage = max === 0
42
+ ? `No ${label} tickets left for this time slot.`
43
+ : `Only ${max} ${label} ticket${max === 1 ? '' : 's'} left for this time slot.`;
44
+ }
45
+
46
+ function onInput() {
47
+ const digitsOnly = inputValue.replace(/[^0-9]/g, '');
48
+ if (digitsOnly !== inputValue) inputValue = digitsOnly;
49
+ limitMessage = '';
50
+ }
51
+
52
+ function onBlur() {
53
+ let parsed = inputValue === '' ? 0 : parseInt(inputValue, 10);
54
+ if (Number.isNaN(parsed)) parsed = 0;
55
+ if (max != null && parsed > max) {
56
+ parsed = max;
57
+ showLimitMessage();
58
+ }
59
+ inputValue = String(parsed);
60
+ if (parsed !== quantity) onChange(parsed);
61
+ }
62
+
63
+ function onFocus(event: Event) {
64
+ (event.currentTarget as HTMLInputElement).select();
65
+ }
23
66
  </script>
24
67
 
25
68
  <div class="card row">
@@ -31,10 +74,28 @@
31
74
  </div>
32
75
  <div class="stepper">
33
76
  <button class="step-btn" onclick={decrement} disabled={quantity === 0} aria-label="Decrease">&minus;</button>
34
- <span class="qty">{quantity}</span>
35
- <button class="step-btn" onclick={increment} aria-label="Increase">+</button>
77
+ <input
78
+ class="qty"
79
+ type="text"
80
+ inputmode="numeric"
81
+ pattern="[0-9]*"
82
+ bind:value={inputValue}
83
+ oninput={onInput}
84
+ onblur={onBlur}
85
+ onfocus={onFocus}
86
+ aria-label="{unit.title ?? unit.id} quantity"
87
+ />
88
+ <button
89
+ class="step-btn"
90
+ onclick={increment}
91
+ disabled={max != null && quantity >= max}
92
+ aria-label="Increase"
93
+ >+</button>
36
94
  </div>
37
95
  </div>
96
+ {#if limitMessage}
97
+ <p class="limit-message">{limitMessage}</p>
98
+ {/if}
38
99
 
39
100
  <style>
40
101
  .row {
@@ -61,8 +122,8 @@
61
122
  gap: 4px;
62
123
  }
63
124
  .step-btn {
64
- width: 40px;
65
- height: 40px;
125
+ width: 44px;
126
+ height: 44px;
66
127
  border-radius: 50%;
67
128
  background: transparent;
68
129
  border: 1.5px solid var(--bw-color-border);
@@ -82,10 +143,25 @@
82
143
  cursor: default;
83
144
  }
84
145
  .qty {
146
+ width: 64px;
147
+ height: 44px;
148
+ border-radius: 12px;
149
+ border: 1.5px solid var(--bw-color-border);
150
+ background: transparent;
151
+ color: var(--bw-color-text);
85
152
  font-size: 17px;
86
- font-weight: 700;
87
- min-width: 32px;
153
+ font-weight: 800;
88
154
  text-align: center;
89
155
  font-variant-numeric: tabular-nums;
156
+ font-family: inherit;
157
+ }
158
+ .qty:focus {
159
+ outline: none;
160
+ border-color: var(--bw-color-primary);
161
+ }
162
+ .limit-message {
163
+ margin: 4px 0 0;
164
+ font-size: 13px;
165
+ color: var(--bw-color-error);
90
166
  }
91
167
  </style>
@@ -318,6 +318,15 @@
318
318
  markInvalidated('date', 'time');
319
319
  }
320
320
 
321
+ // Once a slot is selected, its vacancy count is the live ceiling on how many of a given
322
+ // unit can be added - shared across all unit types, so it shrinks as other units are added.
323
+ function maxForUnit(unitId: string): number | undefined {
324
+ if (!selectedTimeSlot || selectedTimeSlot.vacancies == null) return undefined;
325
+ const totalQty = Object.values(unitQuantities).reduce((sum, q) => sum + q, 0);
326
+ const thisQty = unitQuantities[unitId] ?? 0;
327
+ return selectedTimeSlot.vacancies - totalQty + thisQty;
328
+ }
329
+
321
330
  async function fetchAvailability() {
322
331
  const optionId = effectiveOptionId();
323
332
  if (!optionId) return;
@@ -499,6 +508,7 @@
499
508
  <UnitCounter
500
509
  {unit}
501
510
  quantity={unitQuantities[unit.id] ?? 0}
511
+ max={maxForUnit(unit.id)}
502
512
  onChange={(qty) => onUnitChanged(unit.id, qty)}
503
513
  />
504
514
  {/each}
@@ -584,6 +594,7 @@
584
594
  <UnitCounter
585
595
  {unit}
586
596
  quantity={unitQuantities[unit.id] ?? 0}
597
+ max={maxForUnit(unit.id)}
587
598
  onChange={(qty) => onUnitChanged(unit.id, qty)}
588
599
  />
589
600
  {/each}