@code-collective/booking-widget 1.0.6 → 1.0.7

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,7 +2,6 @@
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
5
  import { postMessage } from './messages';
7
6
  import { onDestroy } from 'svelte';
8
7
 
@@ -28,11 +27,13 @@
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.
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.
31
32
  function handleMessage(e: MessageEvent) {
32
33
  let d: Record<string, unknown>;
33
34
  try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
34
35
  catch { return; }
35
- if (d?.type === 'cart:updated') load();
36
+ if (d?.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
36
37
  }
37
38
 
38
39
  $effect(() => {
@@ -53,13 +54,6 @@
53
54
  onDestroy(() => clearInterval(interval));
54
55
 
55
56
  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
57
  </script>
64
58
 
65
59
  {#if cart && cart.items.length > 0}
@@ -69,8 +63,6 @@
69
63
  <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
70
64
  </svg>
71
65
  <span class="cart-badge">{itemCount}</span>
72
- <span class="cart-total">{totalLabel()}</span>
73
- <span class="cart-sep"></span>
74
66
  <span class="cart-timer">{remaining}</span>
75
67
  </button>
76
68
  {/if}
@@ -79,15 +71,14 @@
79
71
  .cart-overview-btn {
80
72
  display: flex;
81
73
  align-items: center;
82
- gap: 10px;
74
+ justify-content: center;
75
+ gap: 14px;
83
76
  width: 100%;
84
- padding: 12px 20px;
77
+ padding: 14px 24px;
85
78
  background: var(--bw-color-primary);
86
79
  color: white;
87
80
  border: none;
88
81
  border-radius: 999px;
89
- font-size: 14px;
90
- font-weight: 700;
91
82
  cursor: pointer;
92
83
  transition: background 0.15s;
93
84
  }
@@ -95,40 +86,27 @@
95
86
  background: var(--bw-color-primary-dark);
96
87
  }
97
88
  .cart-icon {
98
- width: 18px;
99
- height: 18px;
89
+ width: 22px;
90
+ height: 22px;
100
91
  flex-shrink: 0;
101
92
  }
102
93
  .cart-badge {
103
94
  display: flex;
104
95
  align-items: center;
105
96
  justify-content: center;
106
- min-width: 22px;
107
- height: 22px;
108
- padding: 0 5px;
97
+ width: 26px;
98
+ height: 26px;
109
99
  background: white;
110
100
  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;
101
+ border-radius: 50%;
119
102
  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);
103
+ font-weight: 800;
126
104
  flex-shrink: 0;
127
105
  }
128
106
  .cart-timer {
129
107
  font-variant-numeric: tabular-nums;
130
- font-size: 13px;
131
- opacity: 0.85;
108
+ font-size: 20px;
109
+ font-weight: 700;
132
110
  flex-shrink: 0;
133
111
  }
134
112
  </style>
@@ -28,6 +28,23 @@
28
28
  }
29
29
 
30
30
  load();
31
+
32
+ // In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
33
+ // modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
34
+ // would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
35
+ // cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
36
+ // 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
+ });
31
48
  </script>
32
49
 
33
50
  <div class="bw-widget">
@@ -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,6 +24,12 @@
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);
@@ -34,6 +41,19 @@
34
41
  let privacyAccepted = $state(false);
35
42
  let marketingOptIn = $state(false);
36
43
  let hasAttemptedSubmit = $state(false);
44
+ let payError = $state<string | null>(null);
45
+ // Deliberately separate from isLoading above - that one gates the entire view switch below (spinner vs.
46
+ // cart/contact/payment/result), so reusing it here would unmount ContactForm for the duration of the
47
+ // payCart call and, on failure, remount it with its own $state reset to blank, forcing the shopper to
48
+ // retype everything before retrying. This only ever disables the Pay Now button itself.
49
+ let isPaying = $state(false);
50
+
51
+ // Owned here, not inside ContactForm, so the shopper's typed details survive navigating back to the cart
52
+ // and returning to checkout - see ContactForm's own doc comment on its bindable props for why.
53
+ let contactFirstName = $state('');
54
+ let contactLastName = $state('');
55
+ let contactEmail = $state('');
56
+ let contactPhone = $state('');
37
57
 
38
58
  let contactForm = $state<ContactForm | null>(null);
39
59
 
@@ -126,23 +146,25 @@
126
146
  const updated = await api.getCart();
127
147
  cart = updated;
128
148
  currentView = 'cart';
129
- notifyCartUpdated();
149
+ notifyCartUpdated(updated);
130
150
  }
131
151
 
132
152
  async function removeItem(item: CheckoutCartItemDetailDto) {
133
153
  await api.removeCartItem(item.id);
134
154
  const updated = await api.getCart();
135
155
  cart = updated;
136
- notifyCartUpdated();
156
+ notifyCartUpdated(updated);
137
157
  if (cart.items.length === 0) close();
138
158
  }
139
159
 
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' });
160
+ // Not the itemCount/cartItemId-shaped cart:change TicketConfigurator posts on add (which
161
+ // bw-configurator.svelte re-dispatches as the public bw:cart-change event and auto-opens checkout for).
162
+ // This carries the cart itself: CartBar/CartOverviewButton/Checkout use it to update their own state after
163
+ // an edit or remove without a second independent fetch, and it's forwarded to consumers as the public
164
+ // bw:cart-updated event/onCartUpdated callback, so a consumer can build their own cart summary UI from
165
+ // item count/remaining time/item details without calling the API directly.
166
+ function notifyCartUpdated(updatedCart: CheckoutCartDetailDto): void {
167
+ postMessage({ type: 'cart:updated', cart: updatedCart });
146
168
  }
147
169
 
148
170
  async function onPayNow() {
@@ -151,12 +173,23 @@
151
173
 
152
174
  const contact = contactForm.getContact();
153
175
 
154
- isLoading = true;
176
+ isPaying = true;
177
+ payError = null;
155
178
  try {
156
179
  paymentResult = await api.payCart(contact);
157
180
  currentView = 'payment';
181
+ } catch (e) {
182
+ // PayCheckoutCartCommandHandler's own error-mapping switch: 502 means Peach itself (or the network
183
+ // path to it) failed before a checkout could even be created - no charge was attempted, so this is
184
+ // always safe to retry. Every other status (400/409) reflects a cart/contact state the shopper can't
185
+ // fix by only retrying the same request, but there is no more specific action to suggest here either -
186
+ // either way, without this catch the rejection was previously unhandled and the shopper saw no
187
+ // indication anything had gone wrong at all.
188
+ payError = e instanceof ApiError && e.status === 502
189
+ ? 'Unable to start payment right now. Please try again.'
190
+ : 'Something went wrong starting your payment. Please try again.';
158
191
  } finally {
159
- isLoading = false;
192
+ isPaying = false;
160
193
  }
161
194
  }
162
195
 
@@ -175,18 +208,30 @@
175
208
 
176
209
  isConfirming = true;
177
210
  currentView = 'result';
178
- try {
179
- confirmResult = await api.confirmCart();
180
- const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
181
- confirmFailed = !allOk;
182
- isConfirming = false;
183
- if (allOk) {
184
- postMessage({ type: 'modal:close' });
185
- onOrderConfirmed();
211
+
212
+ 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;
217
+ isConfirming = false;
218
+ if (allOk) {
219
+ postMessage({ type: 'modal:close' });
220
+ onOrderConfirmed();
221
+ }
222
+ 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));
186
234
  }
187
- } catch {
188
- confirmFailed = true;
189
- isConfirming = false;
190
235
  }
191
236
  }
192
237
  </script>
@@ -239,11 +284,21 @@
239
284
  </div>
240
285
 
241
286
  <div class="body">
287
+ {#if payError}
288
+ <p class="pay-error">{payError}</p>
289
+ {/if}
242
290
  <div class="contact-header">
243
291
  <span class="contact-title">Contact Details</span>
244
292
  <span class="required-hint">* Required Fields</span>
245
293
  </div>
246
- <ContactForm bind:this={contactForm} {hasAttemptedSubmit} />
294
+ <ContactForm
295
+ bind:this={contactForm}
296
+ {hasAttemptedSubmit}
297
+ bind:firstName={contactFirstName}
298
+ bind:lastName={contactLastName}
299
+ bind:emailAddress={contactEmail}
300
+ bind:phoneNumber={contactPhone}
301
+ />
247
302
  <div style="margin-top:24px">
248
303
  <ConsentSection
249
304
  {privacyAccepted}
@@ -257,8 +312,8 @@
257
312
 
258
313
  <div class="pay-bar">
259
314
  <span class="pay-bar-total">{formattedTotal()}</span>
260
- <button class="pay-bar-btn" onclick={onPayNow}>
261
- Pay Now <span class="arrow">&rarr;</span>
315
+ <button class="pay-bar-btn" onclick={onPayNow} disabled={isPaying}>
316
+ {isPaying ? 'Paying…' : 'Pay Now'} <span class="arrow">&rarr;</span>
262
317
  </button>
263
318
  </div>
264
319
 
@@ -444,6 +499,14 @@
444
499
  font-weight: 800;
445
500
  }
446
501
  /* -- Contact view -- */
502
+ .pay-error {
503
+ margin: 0 0 16px;
504
+ padding: 12px 16px;
505
+ background: #fdecea;
506
+ color: #b3261e;
507
+ font-size: 14px;
508
+ border-radius: var(--bw-radius-md);
509
+ }
447
510
  .contact-header {
448
511
  display: flex;
449
512
  justify-content: space-between;
@@ -487,6 +550,10 @@
487
550
  font-size: 16px;
488
551
  font-weight: 700;
489
552
  }
553
+ .pay-bar-btn:disabled {
554
+ opacity: 0.7;
555
+ cursor: default;
556
+ }
490
557
  .pay-bar-btn .arrow {
491
558
  font-size: 20px;
492
559
  }
@@ -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 } from './api';
4
5
  import type { WizardPages } from './config';
5
6
  import { DEFAULT_WIZARD_PAGES } from './config';
6
7
  import { expandUnitItems } from './utils';
@@ -20,9 +21,11 @@
20
21
  let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated }: Props = $props();
21
22
 
22
23
  let isSaving = $state(false);
24
+ let errorMessage = $state<string | null>(null);
23
25
 
24
26
  async function onComplete(item: CartItem) {
25
27
  isSaving = true;
28
+ errorMessage = null;
26
29
  try {
27
30
  await api.updateCartItem(cartItem.id, {
28
31
  productId: item.productId,
@@ -36,6 +39,14 @@
36
39
  currencyPrecision: item.currencyPrecision,
37
40
  });
38
41
  onUpdated();
42
+ } catch (e) {
43
+ // 424 means the cart item itself was rolled back server-side (see CheckoutCartEndpoints.UpdateItemAsync) -
44
+ // the supplier rejected the change, so nothing was saved. Surface that instead of letting it vanish
45
+ // silently: WizardPage stays mounted (not swapped for a spinner) specifically so the user's in-progress
46
+ // selections aren't lost while they read this and decide what to try instead.
47
+ errorMessage = e instanceof ApiError && e.status === 424
48
+ ? 'This change could not be saved - the supplier rejected it. Please try different options.'
49
+ : 'Something went wrong saving your changes. Please try again.';
39
50
  } finally {
40
51
  isSaving = false;
41
52
  }
@@ -50,9 +61,11 @@
50
61
  <button class="icon-btn" onclick={onClose} aria-label="Close">&times;</button>
51
62
  </div>
52
63
 
53
- {#if isSaving}
54
- <div class="loading-center"><div class="spinner"></div></div>
55
- {:else}
64
+ {#if errorMessage}
65
+ <p class="edit-error">{errorMessage}</p>
66
+ {/if}
67
+
68
+ <div class="edit-body" class:saving={isSaving}>
56
69
  <WizardPage
57
70
  {product}
58
71
  {api}
@@ -63,7 +76,10 @@
63
76
  {onComplete}
64
77
  onCancel={onBack}
65
78
  />
66
- {/if}
79
+ {#if isSaving}
80
+ <div class="saving-overlay"><div class="spinner"></div></div>
81
+ {/if}
82
+ </div>
67
83
  </div>
68
84
 
69
85
  <style>
@@ -74,4 +90,33 @@
74
90
  max-height: 100%;
75
91
  overflow: hidden;
76
92
  }
93
+
94
+ .edit-error {
95
+ margin: 0;
96
+ padding: 12px 16px;
97
+ background: #fdecea;
98
+ color: #b3261e;
99
+ font-size: 14px;
100
+ }
101
+
102
+ .edit-body {
103
+ position: relative;
104
+ display: flex;
105
+ flex-direction: column;
106
+ flex: 1;
107
+ min-height: 0;
108
+ }
109
+
110
+ .edit-body.saving {
111
+ pointer-events: none;
112
+ }
113
+
114
+ .saving-overlay {
115
+ position: absolute;
116
+ inset: 0;
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ background: rgba(255, 255, 255, 0.7);
121
+ }
77
122
  </style>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy } from 'svelte';
3
+ import { loadPeachSdk } from './peach-sdk';
3
4
 
4
5
  interface Props {
5
6
  checkoutId: string;
@@ -16,33 +17,37 @@
16
17
  let peachInstance: any = null;
17
18
 
18
19
  onMount(() => {
19
- try {
20
- const Checkout = (window as any).Checkout;
21
- if (!Checkout) {
22
- failed = true;
23
- errorMessage = 'Payment SDK not loaded';
24
- return;
25
- }
20
+ (async () => {
21
+ try {
22
+ await loadPeachSdk();
26
23
 
27
- peachInstance = Checkout.initiate({
28
- checkoutId,
29
- customisations: {
30
- card: { submitButtonText: 'Pay Now' },
31
- theme: { brand: { primary: '#E30613' } },
32
- },
33
- eventHandlers: {
34
- onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
35
- onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
36
- onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
37
- onError: () => { cleanup(); onPaymentComplete({ status: 'error' }); },
38
- },
39
- });
24
+ const Checkout = (window as any).Checkout;
25
+ if (!Checkout) {
26
+ failed = true;
27
+ errorMessage = 'Payment SDK not loaded';
28
+ return;
29
+ }
40
30
 
41
- peachInstance.render('#peach-container');
42
- } catch (e) {
43
- failed = true;
44
- errorMessage = String(e);
45
- }
31
+ peachInstance = Checkout.initiate({
32
+ checkoutId,
33
+ customisations: {
34
+ card: { submitButtonText: 'Pay Now' },
35
+ theme: { brand: { primary: '#E30613' } },
36
+ },
37
+ eventHandlers: {
38
+ onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
39
+ onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
40
+ onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
41
+ onError: () => { cleanup(); onPaymentComplete({ status: 'error' }); },
42
+ },
43
+ });
44
+
45
+ peachInstance.render('#peach-container');
46
+ } catch (e) {
47
+ failed = true;
48
+ errorMessage = String(e);
49
+ }
50
+ })();
46
51
  });
47
52
 
48
53
  function cleanup() {
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { BookingApi } from './api';
3
+ import { ApiError } from './api';
3
4
  import type { WizardPages } from './config';
4
5
  import { DEFAULT_WIZARD_PAGES } from './config';
5
6
  import type { CartItem, CheckoutProductDto } from './client-types';
@@ -22,6 +23,7 @@
22
23
  let product = $state<CheckoutProductDto | null>(null);
23
24
  let isLoading = $state(true);
24
25
  let isAddingToCart = $state(false);
26
+ let addToCartError = $state<string | null>(null);
25
27
 
26
28
  async function load() {
27
29
  product = await api.getProduct(productId);
@@ -30,37 +32,61 @@
30
32
 
31
33
  load();
32
34
 
35
+ async function addItemToCart(item: CartItem): Promise<void> {
36
+ await cartManager.ensureCart();
37
+
38
+ const unitItems = expandUnitItems(item.units);
39
+ const added = await api.addCartItem({
40
+ productId: item.productId,
41
+ optionId: item.optionId,
42
+ unitItems,
43
+ availabilityId: item.availabilityId,
44
+ localDate: item.localDate,
45
+ pickupPointId: item.pickupPointId,
46
+ amount: item.totalPrice,
47
+ currencyCode: item.currency,
48
+ currencyPrecision: item.currencyPrecision,
49
+ });
50
+
51
+ const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
52
+ postMessage({
53
+ type: 'cart:change',
54
+ itemCount: unitItems.length,
55
+ cartItemId: added.id,
56
+ totalFormatted: total,
57
+ openCheckout: true,
58
+ });
59
+ // Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
60
+ // public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
61
+ // use it to update their own state without each independently re-fetching, and it's forwarded to
62
+ // consumers as the public bw:cart-updated event/onCartUpdated callback - see this event's own doc comment
63
+ // in CheckoutModal (the other place it's posted from, after an edit/remove).
64
+ const cart = await api.getCart().catch(() => null);
65
+ postMessage({ type: 'cart:updated', cart });
66
+ }
67
+
33
68
  async function onAddToCart(item: CartItem) {
34
69
  isAddingToCart = true;
70
+ addToCartError = null;
35
71
 
36
72
  try {
37
- await cartManager.ensureCart();
38
-
39
- const unitItems = expandUnitItems(item.units);
40
- const added = await api.addCartItem({
41
- productId: item.productId,
42
- optionId: item.optionId,
43
- unitItems,
44
- availabilityId: item.availabilityId,
45
- localDate: item.localDate,
46
- pickupPointId: item.pickupPointId,
47
- amount: item.totalPrice,
48
- currencyCode: item.currency,
49
- currencyPrecision: item.currencyPrecision,
50
- });
51
-
52
- const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
53
- postMessage({
54
- type: 'cart:change',
55
- itemCount: unitItems.length,
56
- cartItemId: added.id,
57
- totalFormatted: total,
58
- openCheckout: true,
59
- });
60
- // Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
61
- // public bw:cart-change event. This one just tells CartBar/CartOverviewButton to refetch their own
62
- // total, same signal update/remove in CheckoutModal use - see this event's own doc comment there.
63
- postMessage({ type: 'cart:updated' });
73
+ await addItemToCart(item);
74
+ } catch (e) {
75
+ // CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
76
+ // server's idle window sliding forward, so a cart that looks valid locally can still be rejected
77
+ // server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
78
+ // before giving up, rather than leaving the user stuck with no feedback and no way to proceed.
79
+ const isStaleCart = e instanceof ApiError && (e.status === 401 || e.status === 404);
80
+ if (isStaleCart) {
81
+ cartManager.reset();
82
+ try {
83
+ await addItemToCart(item);
84
+ } catch {
85
+ addToCartError = 'Something went wrong adding this to your cart. Please try again.';
86
+ }
87
+ } else {
88
+ addToCartError = 'Something went wrong adding this to your cart. Please try again.';
89
+ }
64
90
  } finally {
65
91
  isAddingToCart = false;
66
92
  }
@@ -73,6 +99,9 @@
73
99
  <div class="spinner"></div>
74
100
  </div>
75
101
  {:else if product}
102
+ {#if addToCartError}
103
+ <p class="add-to-cart-error">{addToCartError}</p>
104
+ {/if}
76
105
  <WizardPage
77
106
  {product}
78
107
  {api}
@@ -90,4 +119,12 @@
90
119
  .bw-widget {
91
120
  display: contents;
92
121
  }
122
+
123
+ .add-to-cart-error {
124
+ margin: 0;
125
+ padding: 12px 16px;
126
+ background: #fdecea;
127
+ color: #b3261e;
128
+ font-size: 14px;
129
+ }
93
130
  </style>