@code-collective/booking-widget 1.0.7 → 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.
@@ -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);
@@ -34,17 +37,9 @@
34
37
  // would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
35
38
  // cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
36
39
  // triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
37
- function handleMessage(e: MessageEvent) {
38
- let d: Record<string, unknown>;
39
- try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
40
- catch { return; }
41
- if (d?.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
42
- }
43
-
44
- $effect(() => {
45
- window.addEventListener('message', handleMessage);
46
- return () => window.removeEventListener('message', handleMessage);
47
- });
40
+ $effect(() => onWidgetMessage((d) => {
41
+ if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
42
+ }));
48
43
  </script>
49
44
 
50
45
  <div class="bw-widget">
@@ -69,6 +64,10 @@
69
64
  value: total,
70
65
  currency,
71
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();
72
71
  }}
73
72
  />
74
73
  {:else}
@@ -35,7 +35,11 @@
35
35
  let editingItem = $state<CheckoutCartItemDetailDto | null>(null);
36
36
  let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
37
37
  let confirmResult = $state<CheckoutCartConfirmResultDto | null>(null);
38
- 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);
39
43
  let isLoading = $state(true);
40
44
  let isConfirming = $state(false);
41
45
  let privacyAccepted = $state(false);
@@ -171,6 +175,16 @@
171
175
  hasAttemptedSubmit = true;
172
176
  if (!contactForm?.isValid() || !privacyAccepted) return;
173
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
+
174
188
  const contact = contactForm.getContact();
175
189
 
176
190
  isPaying = true;
@@ -199,41 +213,115 @@
199
213
  return;
200
214
  }
201
215
 
202
- if (result.status === 'expired') {
203
- 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';
204
224
  isConfirming = false;
205
225
  currentView = 'result';
206
226
  return;
207
227
  }
208
228
 
209
- isConfirming = true;
210
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'> {
238
+ try {
239
+ confirmResult = await api.confirmCart();
240
+ const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
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.
245
+ if (allOk) {
246
+ onOrderConfirmed();
247
+ }
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;
211
270
 
212
271
  for (let attempt = 1; attempt <= ConfirmMaxAttempts; attempt++) {
213
- try {
214
- confirmResult = await api.confirmCart();
215
- const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
216
- confirmFailed = !allOk;
272
+ if (await attemptConfirm() === 'settled') {
273
+ isConfirming = false;
274
+ return;
275
+ }
276
+
277
+ if (attempt === ConfirmMaxAttempts) {
278
+ confirmOutcome = 'pending';
217
279
  isConfirming = false;
218
- if (allOk) {
219
- postMessage({ type: 'modal:close' });
220
- onOrderConfirmed();
221
- }
222
280
  return;
223
- } catch (e) {
224
- // 402 means the card charge already succeeded (that's why PaymentPage's onCompleted fired at all) but
225
- // Peach's own webhook hasn't reached PeachWebhookEndpoints yet to move the cart to Paid server-side -
226
- // a race with the webhook, not a real failure, so it's worth a few retries rather than failing outright.
227
- const isAwaitingWebhook = e instanceof ApiError && e.status === 402;
228
- if (!isAwaitingWebhook || attempt === ConfirmMaxAttempts) {
229
- confirmFailed = true;
230
- isConfirming = false;
231
- return;
232
- }
233
- await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
234
281
  }
282
+
283
+ await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
235
284
  }
236
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
+ );
237
325
  </script>
238
326
 
239
327
  <div class="modal">
@@ -245,20 +333,23 @@
245
333
  {:else if currentView === 'payment' && paymentResult}
246
334
  <PaymentPage
247
335
  checkoutId={paymentResult.checkoutId}
248
- totalFormatted={formattedTotal()}
249
- onBack={() => { currentView = 'contact'; }}
250
- onClose={close}
336
+ entityId={paymentResult.entityId}
251
337
  {onPaymentComplete}
252
338
  />
253
339
 
254
340
  {:else if currentView === 'result'}
255
341
  <ResultView
256
- status={isConfirming ? null : confirmFailed
257
- ? { outcome: 'failed', resultDescription: 'Something went wrong confirming your order. Please try again.' }
258
- : { outcome: 'successful' }}
342
+ status={resultStatus}
259
343
  verifying={isConfirming}
260
- 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
+ }}
261
351
  onRetry={() => { currentView = 'payment'; }}
352
+ onCheckAgain={confirmOutcome === 'pending' ? checkConfirmationAgain : undefined}
262
353
  />
263
354
 
264
355
  {:else if currentView === 'edit' && editingItem && productsById.get(editingItem.productId)}
@@ -272,6 +363,7 @@
272
363
  onBack={() => { currentView = 'cart'; }}
273
364
  onClose={close}
274
365
  onUpdated={onItemUpdated}
366
+ onPriceChanged={loadProducts}
275
367
  />
276
368
 
277
369
  {:else if currentView === 'contact'}
@@ -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
@@ -4,12 +4,10 @@
4
4
 
5
5
  interface Props {
6
6
  checkoutId: string;
7
- totalFormatted: string;
8
- onBack: () => void;
9
- onClose: () => void;
7
+ entityId: string;
10
8
  onPaymentComplete: (result: { status: string }) => void;
11
9
  }
12
- let { checkoutId, totalFormatted, onBack, onClose, onPaymentComplete }: Props = $props();
10
+ let { checkoutId, entityId, onPaymentComplete }: Props = $props();
13
11
 
14
12
  let failed = $state(false);
15
13
  let errorMessage = $state('');
@@ -30,9 +28,16 @@
30
28
 
31
29
  peachInstance = Checkout.initiate({
32
30
  checkoutId,
31
+ // Peach's own "key" field, not our checkoutKey/entityId naming - without it Checkout.initiate()
32
+ // throws (Cannot read properties of undefined (reading 'trim')) before ever rendering anything,
33
+ // since the embedded widget authenticates to Peach directly with this rather than through us.
34
+ key: entityId,
33
35
  customisations: {
34
36
  card: { submitButtonText: 'Pay Now' },
35
37
  theme: { brand: { primary: '#E30613' } },
38
+ // Peach's own Cancel (method-selection screen) and Back (card-entry screen) are the only
39
+ // back/exit controls in this dialog - we don't render one of our own alongside them, so there's
40
+ // exactly one way out per screen instead of ours and Peach's competing for the same job.
36
41
  },
37
42
  eventHandlers: {
38
43
  onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
@@ -60,77 +65,70 @@
60
65
  onDestroy(cleanup);
61
66
  </script>
62
67
 
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>
68
+ <!-- Peach's own card form renders inside a cross-origin iframe with its own Cancel/Back controls we have no
69
+ way to hide or reach (no SDK option, no CSS/JS access across origins) - see Checkout.initiate()'s
70
+ customisations doc comment above. Presenting it as its own nested dialog over the checkout modal, rather
71
+ than as just another view swapped into that modal's own body, means Peach's own Cancel/Back read as
72
+ belonging to Peach's own layer instead of visually competing with a second, separate Back of ours - so
73
+ this dialog has no back/exit control of its own at all; onCancelled below routes back to contact. -->
74
+ <div class="payment-overlay">
75
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
76
+ <div class="payment-dialog" onclick={(e) => e.stopPropagation()}>
77
+ <div class="page-header">
78
+ <h2>Payment</h2>
75
79
  </div>
76
80
 
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}
81
+ <div class="body">
82
+ {#if failed}
83
+ <div class="error-state">
84
+ <div class="error-icon">!</div>
85
+ <p class="error-title">Unable to load payment form</p>
86
+ <p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
87
+ </div>
88
+ {:else}
89
+ <div id="peach-container"></div>
90
+ {/if}
91
+ </div>
91
92
  </div>
92
93
  </div>
93
94
 
94
95
  <style>
95
- .payment-page {
96
+ .payment-overlay {
97
+ position: fixed;
98
+ inset: 0;
99
+ /* Above bw-checkout's own .bw-modal-overlay (z-index 10000) so this reads as a second dialog opening
100
+ on top of the checkout modal, not content living inside it. */
101
+ z-index: 10001;
102
+ background: rgba(0, 0, 0, 0.5);
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ padding: 16px;
107
+ }
108
+ .payment-dialog {
96
109
  display: flex;
97
110
  flex-direction: column;
98
- height: 100%;
111
+ width: 100%;
112
+ max-width: 480px;
113
+ height: min(700px, 90vh);
114
+ background: var(--bw-color-bg);
115
+ border-radius: var(--bw-radius-lg);
116
+ box-shadow: var(--bw-shadow-3);
117
+ overflow: hidden;
99
118
  }
100
119
  .body {
101
120
  flex: 1;
121
+ /* A definite height (not just flex:1 alone) so #peach-container's own height:100% below has something
122
+ real to resolve against, and so this scrolls as a single region if content overflows - Peach's own
123
+ root is `height: inherit`, so without that it collapses to its 360px fallback regardless of how tall
124
+ the card form actually gets (e.g. once billing address fields are showing), and its submit button
125
+ ends up overlapping the fields above it. */
126
+ min-height: 0;
102
127
  overflow-y: auto;
103
128
  padding: 16px;
104
129
  }
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;
130
+ #peach-container {
131
+ height: 100%;
134
132
  }
135
133
  .error-state {
136
134
  text-align: center;
@@ -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>
@@ -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">
package/src/lib/api.ts CHANGED
@@ -18,9 +18,27 @@ import { dateKey } from './utils';
18
18
  // Lets a caller distinguish "the server said no, and specifically why" (e.g. 402 - not paid yet, retryable
19
19
  // while Peach's webhook is still in flight) from any other failure, without parsing the message string.
20
20
  export class ApiError extends Error {
21
- constructor(public readonly status: number) {
21
+ constructor(
22
+ public readonly status: number,
23
+ public readonly body?: unknown,
24
+ ) {
22
25
  super(`API error ${status}`);
23
26
  }
27
+
28
+ // The server's own machine-readable reason, where it sends one. Several distinct failures share a status -
29
+ // a price that moved and a malformed request are both 400 - and only this tells them apart.
30
+ get code(): string | undefined {
31
+ return typeof this.body === 'object' && this.body !== null && 'error' in this.body
32
+ ? String((this.body as { error: unknown }).error)
33
+ : undefined;
34
+ }
35
+ }
36
+
37
+ // The price of an item changed between the widget displaying it and the item being added, and the amount the
38
+ // customer agreed to is no longer one the server will accept. Recoverable, but only by showing the customer
39
+ // the new price - see PRICE_MISMATCH handling in TicketConfigurator/EditBookingView.
40
+ export function isPriceMismatch(e: unknown): e is ApiError {
41
+ return e instanceof ApiError && e.status === 400 && e.code === 'PRICE_MISMATCH';
24
42
  }
25
43
 
26
44
  export interface BookingApi {
@@ -78,7 +96,7 @@ export class ApiClient implements BookingApi {
78
96
 
79
97
  private unwrap<T>(result: { data?: T; error?: unknown; response: Response }): T {
80
98
  if (result.data === undefined) {
81
- throw new ApiError(result.response.status);
99
+ throw new ApiError(result.response.status, result.error);
82
100
  }
83
101
  return result.data;
84
102
  }