@code-collective/booking-widget 1.0.6 → 1.0.8

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.
@@ -2,8 +2,7 @@
2
2
  import type { CheckoutCartDetailDto } from './client-types';
3
3
  import type { BookingApi } from './api';
4
4
  import { CartManager } from './cart-manager';
5
- import { formatCurrency } from './currency';
6
- import { postMessage } from './messages';
5
+ import { onWidgetMessage, postMessage } from './messages';
7
6
  import { onDestroy } from 'svelte';
8
7
 
9
8
  interface Props {
@@ -28,17 +27,11 @@
28
27
 
29
28
  // load() above only runs once on mount - without this, adding/editing/removing an item elsewhere on the
30
29
  // page (the configurator, or the checkout modal's own edit/remove) leaves this button showing a stale total.
31
- function handleMessage(e: MessageEvent) {
32
- let d: Record<string, unknown>;
33
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
34
- catch { return; }
35
- if (d?.type === 'cart:updated') load();
36
- }
37
-
38
- $effect(() => {
39
- window.addEventListener('message', handleMessage);
40
- return () => window.removeEventListener('message', handleMessage);
41
- });
30
+ // The event already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so
31
+ // this applies it directly rather than triggering a second, redundant getCart() call.
32
+ $effect(() => onWidgetMessage((d) => {
33
+ if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
34
+ }));
42
35
 
43
36
  function updateTimer() {
44
37
  if (!cart) { remaining = '0:00'; return; }
@@ -53,13 +46,6 @@
53
46
  onDestroy(() => clearInterval(interval));
54
47
 
55
48
  let itemCount = $derived(cart?.items.length ?? 0);
56
-
57
- let totalLabel = $derived(() => {
58
- if (!cart || cart.items.length === 0) return '';
59
- const currency = cart.items[0].currencyCode;
60
- const total = cart.items.reduce((sum, i) => sum + i.amount, 0);
61
- return formatCurrency(total, currency, 2, 0);
62
- });
63
49
  </script>
64
50
 
65
51
  {#if cart && cart.items.length > 0}
@@ -69,8 +55,6 @@
69
55
  <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
70
56
  </svg>
71
57
  <span class="cart-badge">{itemCount}</span>
72
- <span class="cart-total">{totalLabel()}</span>
73
- <span class="cart-sep"></span>
74
58
  <span class="cart-timer">{remaining}</span>
75
59
  </button>
76
60
  {/if}
@@ -79,15 +63,14 @@
79
63
  .cart-overview-btn {
80
64
  display: flex;
81
65
  align-items: center;
82
- gap: 10px;
66
+ justify-content: center;
67
+ gap: 14px;
83
68
  width: 100%;
84
- padding: 12px 20px;
69
+ padding: 14px 24px;
85
70
  background: var(--bw-color-primary);
86
71
  color: white;
87
72
  border: none;
88
73
  border-radius: 999px;
89
- font-size: 14px;
90
- font-weight: 700;
91
74
  cursor: pointer;
92
75
  transition: background 0.15s;
93
76
  }
@@ -95,40 +78,27 @@
95
78
  background: var(--bw-color-primary-dark);
96
79
  }
97
80
  .cart-icon {
98
- width: 18px;
99
- height: 18px;
81
+ width: 22px;
82
+ height: 22px;
100
83
  flex-shrink: 0;
101
84
  }
102
85
  .cart-badge {
103
86
  display: flex;
104
87
  align-items: center;
105
88
  justify-content: center;
106
- min-width: 22px;
107
- height: 22px;
108
- padding: 0 5px;
89
+ width: 26px;
90
+ height: 26px;
109
91
  background: white;
110
92
  color: var(--bw-color-primary);
111
- border-radius: 5px;
112
- font-size: 12px;
113
- font-weight: 800;
114
- flex-shrink: 0;
115
- }
116
- .cart-total {
117
- flex: 1;
118
- text-align: left;
93
+ border-radius: 50%;
119
94
  font-size: 14px;
120
- font-weight: 700;
121
- }
122
- .cart-sep {
123
- width: 1px;
124
- height: 16px;
125
- background: rgba(255,255,255,0.35);
95
+ font-weight: 800;
126
96
  flex-shrink: 0;
127
97
  }
128
98
  .cart-timer {
129
99
  font-variant-numeric: tabular-nums;
130
- font-size: 13px;
131
- opacity: 0.85;
100
+ font-size: 20px;
101
+ font-weight: 700;
132
102
  flex-shrink: 0;
133
103
  }
134
104
  </style>
@@ -3,16 +3,19 @@
3
3
  import type { WizardPages } from './config';
4
4
  import { DEFAULT_WIZARD_PAGES } from './config';
5
5
  import type { CheckoutCartDetailDto } from './client-types';
6
- import { postMessage } from './messages';
6
+ import { onWidgetMessage, postMessage } from './messages';
7
+ import type { CartManager } from './cart-manager';
7
8
  import CheckoutModal from './CheckoutModal.svelte';
8
9
 
9
10
  interface Props {
10
11
  api: BookingApi;
12
+ cartManager?: CartManager;
11
13
  wizardPages?: WizardPages;
12
14
  editPages?: WizardPages;
13
15
  autoSelectSingleTimeSlot?: boolean;
14
16
  }
15
- let { api, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp, autoSelectSingleTimeSlot = false }: Props = $props();
17
+ let { api, cartManager, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp,
18
+ autoSelectSingleTimeSlot = false }: Props = $props();
16
19
  let editPages = $derived(editPagesProp ?? wizardPages);
17
20
 
18
21
  let cart = $state<CheckoutCartDetailDto | null>(null);
@@ -28,6 +31,15 @@
28
31
  }
29
32
 
30
33
  load();
34
+
35
+ // In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
36
+ // modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
37
+ // would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
38
+ // cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
39
+ // triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
40
+ $effect(() => onWidgetMessage((d) => {
41
+ if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
42
+ }));
31
43
  </script>
32
44
 
33
45
  <div class="bw-widget">
@@ -52,6 +64,10 @@
52
64
  value: total,
53
65
  currency,
54
66
  });
67
+ // The cart is paid and confirmed, so its token has done its job. Nothing used to clear it, so it sat
68
+ // in localStorage on the merchant's origin until absoluteExpiresAt - readable by every third-party
69
+ // script they load, and picked up by the next person to use a shared or kiosk browser.
70
+ cartManager?.reset();
55
71
  }}
56
72
  />
57
73
  {:else}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutCartPaymentInitiationDto, CheckoutCartConfirmResultDto, CheckoutProductDto } from './client-types';
3
3
  import type { BookingApi } from './api';
4
+ import { ApiError } from './api';
4
5
  import type { WizardPages } from './config';
5
6
  import { formatCurrency } from './currency';
6
7
  import { postMessage } from './messages';
@@ -23,17 +24,40 @@
23
24
  }
24
25
  let { cart, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onClose, onOrderConfirmed }: Props = $props();
25
26
 
27
+ // Peach's webhook is typically near-instant, but is a genuinely separate async delivery from the card
28
+ // charge itself - 10 attempts 1.5s apart (~15s) comfortably covers ordinary delivery latency without
29
+ // making a shopper whose payment already succeeded wait an unreasonable time to see it confirmed.
30
+ const ConfirmMaxAttempts = 10;
31
+ const ConfirmRetryDelayMs = 1500;
32
+
26
33
  type View = 'cart' | 'contact' | 'edit' | 'payment' | 'result';
27
34
  let currentView = $state<View>('cart');
28
35
  let editingItem = $state<CheckoutCartItemDetailDto | null>(null);
29
36
  let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
30
37
  let confirmResult = $state<CheckoutCartConfirmResultDto | null>(null);
31
- let confirmFailed = $state(false);
38
+ // What the confirm attempt actually produced, once it's settled - not a plain success/failure boolean,
39
+ // because "payment succeeded but this gateway couldn't confirm it in time" and "payment itself never went
40
+ // through" need different copy and different actions (see the ResultView status computed below and
41
+ // runConfirmLoop's own remarks). null while still in flight.
42
+ let confirmOutcome = $state<'success' | 'pending' | 'partial' | 'notCharged' | null>(null);
32
43
  let isLoading = $state(true);
33
44
  let isConfirming = $state(false);
34
45
  let privacyAccepted = $state(false);
35
46
  let marketingOptIn = $state(false);
36
47
  let hasAttemptedSubmit = $state(false);
48
+ let payError = $state<string | null>(null);
49
+ // Deliberately separate from isLoading above - that one gates the entire view switch below (spinner vs.
50
+ // cart/contact/payment/result), so reusing it here would unmount ContactForm for the duration of the
51
+ // payCart call and, on failure, remount it with its own $state reset to blank, forcing the shopper to
52
+ // retype everything before retrying. This only ever disables the Pay Now button itself.
53
+ let isPaying = $state(false);
54
+
55
+ // Owned here, not inside ContactForm, so the shopper's typed details survive navigating back to the cart
56
+ // and returning to checkout - see ContactForm's own doc comment on its bindable props for why.
57
+ let contactFirstName = $state('');
58
+ let contactLastName = $state('');
59
+ let contactEmail = $state('');
60
+ let contactPhone = $state('');
37
61
 
38
62
  let contactForm = $state<ContactForm | null>(null);
39
63
 
@@ -126,37 +150,60 @@
126
150
  const updated = await api.getCart();
127
151
  cart = updated;
128
152
  currentView = 'cart';
129
- notifyCartUpdated();
153
+ notifyCartUpdated(updated);
130
154
  }
131
155
 
132
156
  async function removeItem(item: CheckoutCartItemDetailDto) {
133
157
  await api.removeCartItem(item.id);
134
158
  const updated = await api.getCart();
135
159
  cart = updated;
136
- notifyCartUpdated();
160
+ notifyCartUpdated(updated);
137
161
  if (cart.items.length === 0) close();
138
162
  }
139
163
 
140
- // A standalone postMessage - not the itemCount/cartItemId-shaped cart:change TicketConfigurator posts on
141
- // add (which bw-configurator.svelte re-dispatches as the public bw:cart-change event and auto-opens
142
- // checkout for). This is purely internal: CartBar/CartOverviewButton listen for it to refetch their own
143
- // total after an edit or remove, which otherwise only ever fetch once on mount.
144
- function notifyCartUpdated(): void {
145
- postMessage({ type: 'cart:updated' });
164
+ // Not the itemCount/cartItemId-shaped cart:change TicketConfigurator posts on add (which
165
+ // bw-configurator.svelte re-dispatches as the public bw:cart-change event and auto-opens checkout for).
166
+ // This carries the cart itself: CartBar/CartOverviewButton/Checkout use it to update their own state after
167
+ // an edit or remove without a second independent fetch, and it's forwarded to consumers as the public
168
+ // bw:cart-updated event/onCartUpdated callback, so a consumer can build their own cart summary UI from
169
+ // item count/remaining time/item details without calling the API directly.
170
+ function notifyCartUpdated(updatedCart: CheckoutCartDetailDto): void {
171
+ postMessage({ type: 'cart:updated', cart: updatedCart });
146
172
  }
147
173
 
148
174
  async function onPayNow() {
149
175
  hasAttemptedSubmit = true;
150
176
  if (!contactForm?.isValid() || !privacyAccepted) return;
151
177
 
178
+ // A cart only ever gets one Peach checkout - RecordPaymentInitiatedAsync's own concurrency lock rejects
179
+ // a second payCart call on the same cart with a 409 ("already has a payment initiated"), even though
180
+ // nothing has actually been charged yet. Cancelling out of PaymentPage (onPaymentComplete's 'cancelled'
181
+ // case below) never clears paymentResult, so re-entering here just resumes that same still-open attempt
182
+ // instead of re-initiating.
183
+ if (paymentResult) {
184
+ currentView = 'payment';
185
+ return;
186
+ }
187
+
152
188
  const contact = contactForm.getContact();
153
189
 
154
- isLoading = true;
190
+ isPaying = true;
191
+ payError = null;
155
192
  try {
156
193
  paymentResult = await api.payCart(contact);
157
194
  currentView = 'payment';
195
+ } catch (e) {
196
+ // PayCheckoutCartCommandHandler's own error-mapping switch: 502 means Peach itself (or the network
197
+ // path to it) failed before a checkout could even be created - no charge was attempted, so this is
198
+ // always safe to retry. Every other status (400/409) reflects a cart/contact state the shopper can't
199
+ // fix by only retrying the same request, but there is no more specific action to suggest here either -
200
+ // either way, without this catch the rejection was previously unhandled and the shopper saw no
201
+ // indication anything had gone wrong at all.
202
+ payError = e instanceof ApiError && e.status === 502
203
+ ? 'Unable to start payment right now. Please try again.'
204
+ : 'Something went wrong starting your payment. Please try again.';
158
205
  } finally {
159
- isLoading = false;
206
+ isPaying = false;
160
207
  }
161
208
  }
162
209
 
@@ -166,29 +213,115 @@
166
213
  return;
167
214
  }
168
215
 
169
- if (result.status === 'expired') {
170
- confirmFailed = true;
216
+ // Neither of these ever reached a real charge - Peach's own SDK is reporting that the checkout itself
217
+ // didn't go through (session timeout, a declined card, a 3DS failure, a client-side error), not that a
218
+ // successful charge's confirmation is in question. That distinction matters: every path below this point
219
+ // is only reachable once onCompleted has already fired, meaning the card WAS charged, so "try again"
220
+ // there would risk a second charge rather than retrying a payment that never happened. Here, nothing was
221
+ // charged, so re-opening the card form is exactly correct.
222
+ if (result.status === 'expired' || result.status === 'error') {
223
+ confirmOutcome = 'notCharged';
171
224
  isConfirming = false;
172
225
  currentView = 'result';
173
226
  return;
174
227
  }
175
228
 
176
- isConfirming = true;
177
229
  currentView = 'result';
230
+ await runConfirmLoop();
231
+ }
232
+
233
+ // One confirm attempt, interpreted: records the result and reports whether it settled (success or a
234
+ // supplier-side partial failure - either way, nothing left to retry) versus still needs another attempt
235
+ // (the two webhook races below). Shared by the automatic loop and the manual on-demand recheck so there is
236
+ // exactly one place that decides what a given confirmCart() outcome means.
237
+ async function attemptConfirm(): Promise<'settled' | 'awaitingWebhook'> {
178
238
  try {
179
239
  confirmResult = await api.confirmCart();
180
240
  const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
181
- confirmFailed = !allOk;
182
- isConfirming = false;
241
+ confirmOutcome = allOk ? 'success' : 'partial';
242
+ // Notifies the host (order value/currency for analytics, clearing the stored cart token) without
243
+ // closing the modal - the shopper still needs to see the success screen below, and only dismisses it
244
+ // themselves via ResultView's Done button, which is the one thing that posts modal:close.
183
245
  if (allOk) {
184
- postMessage({ type: 'modal:close' });
185
246
  onOrderConfirmed();
186
247
  }
187
- } catch {
188
- confirmFailed = true;
189
- isConfirming = false;
248
+ return 'settled';
249
+ } catch (e) {
250
+ // Two different races with the same webhook, neither a real failure:
251
+ // 402 - the charge succeeded (that's why PaymentPage's onCompleted fired) but the webhook hasn't
252
+ // reached PeachWebhookEndpoints yet to move the cart to Paid.
253
+ // 409 - the webhook got there first and is confirming the cart right now, so there is no outcome to
254
+ // read yet. Only one caller is allowed to run confirmation, and the loser is told to retry
255
+ // rather than being handed a second, independently-produced answer.
256
+ // Both resolve on their own within a second or two, so both are worth retrying. Any other error settles
257
+ // as 'pending' rather than a failure state - the charge already succeeded by the time this can run at
258
+ // all, so there is no "payment failed" to report, only "not confirmed yet".
259
+ if (e instanceof ApiError && (e.status === 402 || e.status === 409)) {
260
+ return 'awaitingWebhook';
261
+ }
262
+ confirmOutcome = 'pending';
263
+ return 'settled';
264
+ }
265
+ }
266
+
267
+ async function runConfirmLoop() {
268
+ isConfirming = true;
269
+ confirmOutcome = null;
270
+
271
+ for (let attempt = 1; attempt <= ConfirmMaxAttempts; attempt++) {
272
+ if (await attemptConfirm() === 'settled') {
273
+ isConfirming = false;
274
+ return;
275
+ }
276
+
277
+ if (attempt === ConfirmMaxAttempts) {
278
+ confirmOutcome = 'pending';
279
+ isConfirming = false;
280
+ return;
281
+ }
282
+
283
+ await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
190
284
  }
191
285
  }
286
+
287
+ // A single on-demand recheck for the 'pending' screen's "Check Again" button - deliberately not another
288
+ // full runConfirmLoop, which would re-impose its own ~15s auto-retry wait on someone who is already
289
+ // actively engaged and can just click again. Confirming is idempotent (CheckoutCartConfirmer), so this
290
+ // costs nothing to repeat; still 'pending' either way if the webhook still hasn't landed.
291
+ async function checkConfirmationAgain() {
292
+ isConfirming = true;
293
+ if (await attemptConfirm() === 'awaitingWebhook') {
294
+ confirmOutcome = 'pending';
295
+ }
296
+ isConfirming = false;
297
+ }
298
+
299
+ // The one ResultView prop that isn't a straight readout of confirmOutcome: 'partial' means the payment
300
+ // succeeded but this gateway could not confirm every item, which is a real problem worth surfacing
301
+ // distinctly from 'pending' (which resolves on its own) - but from ResultView's own outcome union, that is
302
+ // still a kind of 'failed', just one retryPayment must gate off since the charge already happened.
303
+ let resultStatus = $derived(
304
+ isConfirming
305
+ ? null
306
+ : confirmOutcome === 'success'
307
+ ? { outcome: 'successful' as const }
308
+ : confirmOutcome === 'pending'
309
+ ? { outcome: 'pending' as const }
310
+ : confirmOutcome === 'partial'
311
+ ? {
312
+ outcome: 'failed' as const,
313
+ resultDescription:
314
+ "Your payment succeeded, but we couldn't confirm every item in your booking. Please contact us with your order details.",
315
+ retryPayment: false,
316
+ }
317
+ : confirmOutcome === 'notCharged'
318
+ ? {
319
+ outcome: 'failed' as const,
320
+ resultDescription: 'Your payment could not be completed. Please try again.',
321
+ retryPayment: true,
322
+ }
323
+ : null,
324
+ );
192
325
  </script>
193
326
 
194
327
  <div class="modal">
@@ -200,20 +333,23 @@
200
333
  {:else if currentView === 'payment' && paymentResult}
201
334
  <PaymentPage
202
335
  checkoutId={paymentResult.checkoutId}
203
- totalFormatted={formattedTotal()}
204
- onBack={() => { currentView = 'contact'; }}
205
- onClose={close}
336
+ entityId={paymentResult.entityId}
206
337
  {onPaymentComplete}
207
338
  />
208
339
 
209
340
  {:else if currentView === 'result'}
210
341
  <ResultView
211
- status={isConfirming ? null : confirmFailed
212
- ? { outcome: 'failed', resultDescription: 'Something went wrong confirming your order. Please try again.' }
213
- : { outcome: 'successful' }}
342
+ status={resultStatus}
214
343
  verifying={isConfirming}
215
- onDone={() => { postMessage({ type: 'modal:close' }); onOrderConfirmed(); }}
344
+ onDone={() => {
345
+ // onOrderConfirmed only for the outcome that actually earns it - a 'pending'/'partial' Close is
346
+ // just dismissing the dialog on an order this gateway cannot yet (or fully) vouch for, not
347
+ // reporting it complete. The 'success' case already fired onOrderConfirmed the moment it was
348
+ // known, inside runConfirmLoop/checkConfirmationAgain - this simply closes what's still open.
349
+ postMessage({ type: 'modal:close' });
350
+ }}
216
351
  onRetry={() => { currentView = 'payment'; }}
352
+ onCheckAgain={confirmOutcome === 'pending' ? checkConfirmationAgain : undefined}
217
353
  />
218
354
 
219
355
  {:else if currentView === 'edit' && editingItem && productsById.get(editingItem.productId)}
@@ -227,6 +363,7 @@
227
363
  onBack={() => { currentView = 'cart'; }}
228
364
  onClose={close}
229
365
  onUpdated={onItemUpdated}
366
+ onPriceChanged={loadProducts}
230
367
  />
231
368
 
232
369
  {:else if currentView === 'contact'}
@@ -239,11 +376,21 @@
239
376
  </div>
240
377
 
241
378
  <div class="body">
379
+ {#if payError}
380
+ <p class="pay-error">{payError}</p>
381
+ {/if}
242
382
  <div class="contact-header">
243
383
  <span class="contact-title">Contact Details</span>
244
384
  <span class="required-hint">* Required Fields</span>
245
385
  </div>
246
- <ContactForm bind:this={contactForm} {hasAttemptedSubmit} />
386
+ <ContactForm
387
+ bind:this={contactForm}
388
+ {hasAttemptedSubmit}
389
+ bind:firstName={contactFirstName}
390
+ bind:lastName={contactLastName}
391
+ bind:emailAddress={contactEmail}
392
+ bind:phoneNumber={contactPhone}
393
+ />
247
394
  <div style="margin-top:24px">
248
395
  <ConsentSection
249
396
  {privacyAccepted}
@@ -257,8 +404,8 @@
257
404
 
258
405
  <div class="pay-bar">
259
406
  <span class="pay-bar-total">{formattedTotal()}</span>
260
- <button class="pay-bar-btn" onclick={onPayNow}>
261
- Pay Now <span class="arrow">&rarr;</span>
407
+ <button class="pay-bar-btn" onclick={onPayNow} disabled={isPaying}>
408
+ {isPaying ? 'Paying…' : 'Pay Now'} <span class="arrow">&rarr;</span>
262
409
  </button>
263
410
  </div>
264
411
 
@@ -444,6 +591,14 @@
444
591
  font-weight: 800;
445
592
  }
446
593
  /* -- Contact view -- */
594
+ .pay-error {
595
+ margin: 0 0 16px;
596
+ padding: 12px 16px;
597
+ background: #fdecea;
598
+ color: #b3261e;
599
+ font-size: 14px;
600
+ border-radius: var(--bw-radius-md);
601
+ }
447
602
  .contact-header {
448
603
  display: flex;
449
604
  justify-content: space-between;
@@ -487,6 +642,10 @@
487
642
  font-size: 16px;
488
643
  font-weight: 700;
489
644
  }
645
+ .pay-bar-btn:disabled {
646
+ opacity: 0.7;
647
+ cursor: default;
648
+ }
490
649
  .pay-bar-btn .arrow {
491
650
  font-size: 20px;
492
651
  }
@@ -3,13 +3,22 @@
3
3
 
4
4
  interface Props {
5
5
  hasAttemptedSubmit: boolean;
6
+ firstName: string;
7
+ lastName: string;
8
+ emailAddress: string;
9
+ phoneNumber: string;
6
10
  }
7
- let { hasAttemptedSubmit }: Props = $props();
8
-
9
- let firstName = $state('');
10
- let lastName = $state('');
11
- let emailAddress = $state('');
12
- let phoneNumber = $state('');
11
+ // Bindable so the caller owns the actual values - CheckoutModal's own currentView switch unmounts this
12
+ // component entirely when navigating away from the contact view (a different arm of the same {#if/:else
13
+ // if} chain as the cart/edit/payment views), so state kept only here would reset to blank every time the
14
+ // shopper went back to the cart and returned. Binding lets it survive in the parent instead.
15
+ let {
16
+ hasAttemptedSubmit,
17
+ firstName = $bindable(''),
18
+ lastName = $bindable(''),
19
+ emailAddress = $bindable(''),
20
+ phoneNumber = $bindable(''),
21
+ }: Props = $props();
13
22
 
14
23
  export function isValid(): boolean {
15
24
  return firstName.trim() !== '' && emailAddress.trim() !== '' && phoneNumber.trim() !== '';
@@ -1,6 +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, isPriceMismatch } from './api';
4
5
  import type { WizardPages } from './config';
5
6
  import { DEFAULT_WIZARD_PAGES } from './config';
6
7
  import { expandUnitItems } from './utils';
@@ -16,13 +17,18 @@
16
17
  onBack: () => void;
17
18
  onClose: () => void;
18
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>;
19
23
  }
20
- 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();
21
25
 
22
26
  let isSaving = $state(false);
27
+ let errorMessage = $state<string | null>(null);
23
28
 
24
29
  async function onComplete(item: CartItem) {
25
30
  isSaving = true;
31
+ errorMessage = null;
26
32
  try {
27
33
  await api.updateCartItem(cartItem.id, {
28
34
  productId: item.productId,
@@ -31,11 +37,27 @@
31
37
  availabilityId: item.availabilityId,
32
38
  localDate: item.localDate,
33
39
  pickupPointId: item.pickupPointId,
40
+ // Currency and precision are resolved server-side - see the same call in TicketConfigurator.
34
41
  amount: item.totalPrice,
35
- currencyCode: item.currency,
36
- currencyPrecision: item.currencyPrecision,
37
42
  });
38
43
  onUpdated();
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
+
54
+ // 424 means the cart item itself was rolled back server-side (see CheckoutCartEndpoints.UpdateItemAsync) -
55
+ // the supplier rejected the change, so nothing was saved. Surface that instead of letting it vanish
56
+ // silently: WizardPage stays mounted (not swapped for a spinner) specifically so the user's in-progress
57
+ // selections aren't lost while they read this and decide what to try instead.
58
+ errorMessage = e instanceof ApiError && e.status === 424
59
+ ? 'This change could not be saved - the supplier rejected it. Please try different options.'
60
+ : 'Something went wrong saving your changes. Please try again.';
39
61
  } finally {
40
62
  isSaving = false;
41
63
  }
@@ -50,9 +72,11 @@
50
72
  <button class="icon-btn" onclick={onClose} aria-label="Close">&times;</button>
51
73
  </div>
52
74
 
53
- {#if isSaving}
54
- <div class="loading-center"><div class="spinner"></div></div>
55
- {:else}
75
+ {#if errorMessage}
76
+ <p class="edit-error">{errorMessage}</p>
77
+ {/if}
78
+
79
+ <div class="edit-body" class:saving={isSaving}>
56
80
  <WizardPage
57
81
  {product}
58
82
  {api}
@@ -63,7 +87,10 @@
63
87
  {onComplete}
64
88
  onCancel={onBack}
65
89
  />
66
- {/if}
90
+ {#if isSaving}
91
+ <div class="saving-overlay"><div class="spinner"></div></div>
92
+ {/if}
93
+ </div>
67
94
  </div>
68
95
 
69
96
  <style>
@@ -74,4 +101,33 @@
74
101
  max-height: 100%;
75
102
  overflow: hidden;
76
103
  }
104
+
105
+ .edit-error {
106
+ margin: 0;
107
+ padding: 12px 16px;
108
+ background: #fdecea;
109
+ color: #b3261e;
110
+ font-size: 14px;
111
+ }
112
+
113
+ .edit-body {
114
+ position: relative;
115
+ display: flex;
116
+ flex-direction: column;
117
+ flex: 1;
118
+ min-height: 0;
119
+ }
120
+
121
+ .edit-body.saving {
122
+ pointer-events: none;
123
+ }
124
+
125
+ .saving-overlay {
126
+ position: absolute;
127
+ inset: 0;
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+ background: rgba(255, 255, 255, 0.7);
132
+ }
77
133
  </style>