@code-collective/booking-widget 1.0.12 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,36 +89,88 @@
89
89
  let productsById = $state<Map<string, CheckoutProductDto>>(new Map());
90
90
 
91
91
  async function loadProducts() {
92
- const productIds = [...new Set(cart.items.map((i) => i.productId))];
93
- const results = await Promise.all(productIds.map((id) => api.getProduct(id)));
94
- const map = new Map<string, CheckoutProductDto>();
95
- for (const p of results) {
96
- map.set(p.id, p);
92
+ try {
93
+ const productIds = [...new Set(cart.items.map((i) => i.productId))];
94
+ const results = await Promise.all(productIds.map((id) => api.getProduct(id)));
95
+ const map = new Map<string, CheckoutProductDto>();
96
+ for (const p of results) {
97
+ map.set(p.id, p);
98
+ }
99
+ productsById = map;
100
+ } catch {
101
+ // These supply titles and unit labels; the cart itself renders from the cart's own rows, and productTitle
102
+ // falls back to the product id, so losing them is a plainer checkout rather than none. Letting isLoading
103
+ // stand would be neither: the spinner is the first branch in the markup, so one rejected getProduct hung
104
+ // the whole modal on it for good - over an already settled payment included (PR 8447 review).
105
+ } finally {
106
+ isLoading = false;
97
107
  }
98
- productsById = map;
99
- isLoading = false;
100
108
  }
101
109
 
102
- loadProducts();
110
+ void loadProducts();
103
111
 
104
112
  // A reload mid-payment: the server still holds this cart in AwaitingPaymentConfirmation for the Peach
105
113
  // checkout recorded before the reload, and will refuse every edit and a second payCart until that attempt
106
- // is settled or abandoned - so the only useful place to land is back on that same card form. Peach's own
107
- // handlers then route exactly as they do without a reload: a completed charge confirms, a cancel/expiry/
108
- // error abandons the attempt and reopens the cart. Same resume as onPayNow's own paymentResult short-cut.
109
- function resumeInterruptedPayment() {
114
+ // is settled or abandoned. Where to land depends on how far the shopper got before the reload, and only the
115
+ // server knows: Peach's form may have completed a moment earlier - a charge, or a decline the server reads
116
+ // straight back from Peach's status endpoint, since Peach sends no webhook for one - so it is asked once
117
+ // first, and a recorded outcome is shown exactly as it would have been without the reload. Only "not paid"
118
+ // puts the shopper back on that same card form, whose Peach handlers then route as they do without a
119
+ // reload: a completed charge confirms, a cancel/expiry/error abandons the attempt and reopens the cart.
120
+ // Same resume as onPayNow's own paymentResult short-cut.
121
+ async function resumeInterruptedPayment() {
110
122
  const interruptedAttempt = recallPaymentAttempt(api.cartToken);
111
123
  if (!interruptedAttempt) return;
112
124
  paymentResult = interruptedAttempt;
113
- currentView = 'payment';
125
+ // Posted before the answer is known, as Pay Now does: CartExpiryGuard must treat the attempt as in flight
126
+ // whichever screen it ends up on, and a decline's own payment:ended (releaseDeclinedAttempt) then reads the
127
+ // same as it does without a reload.
114
128
  postMessage({ type: 'payment:started' });
129
+ if (await resumeOntoRecordedOutcome()) return;
130
+ currentView = 'payment';
115
131
  // The guard's watcher fires once per deadline and may already have done so before this modal existed (a
116
132
  // reload after the clock ran out, or checkout reopened late). Nothing else would tear the attempt down
117
133
  // then; the shopper would land on a card form for a cart that is already gone.
118
134
  if (cartDeadline(cart).getTime() <= Date.now()) void onPaymentTimedOut();
119
135
  }
120
136
 
121
- resumeInterruptedPayment();
137
+ // Whether the server already holds an outcome for the interrupted attempt - shown on the result view if so.
138
+ // A settled answer (a charge, a supplier-side partial failure, or a decline) is final; a confirmation the
139
+ // webhook is running right now joins the ordinary confirm loop to read its result; a cart the server no
140
+ // longer has closes the modal, as a 401 does everywhere else here. A 402 says neither the webhook nor Peach's
141
+ // status endpoint has anything final for this attempt - a shopper who never submitted the card, or one who
142
+ // reloaded mid-3-D Secure, and the two cannot be told apart from here (PR 8447 review). The card form is
143
+ // where both of them were, and it is Peach's own form for this same checkoutId - the state of that checkout,
144
+ // not a fresh charge - so that is where they go back to. Anything else - the server unreachable, an
145
+ // unexpected refusal - is treated the same way rather than parking them on a screen that tells them not to
146
+ // pay again for a card they may never have submitted.
147
+ async function resumeOntoRecordedOutcome(): Promise<boolean> {
148
+ isConfirming = true;
149
+ currentView = 'result';
150
+ let answer: ConfirmAnswer;
151
+ try {
152
+ answer = await interpretConfirm();
153
+ } catch {
154
+ // Nothing in interpretConfirm is meant to throw past its own catch, but this resume is fire-and-forget:
155
+ // anything that did would strand the modal on "Verifying payment..." with no button at all. A decline
156
+ // already recorded before the throw is shown; anything else falls back to the card form.
157
+ isConfirming = false;
158
+ return confirmOutcome !== null;
159
+ }
160
+ isConfirming = false;
161
+ if (answer === 'settled') return true;
162
+ if (answer === 'cartGone') {
163
+ close();
164
+ return true;
165
+ }
166
+ if (answer === 'confirming') {
167
+ await runConfirmLoop();
168
+ return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ void resumeInterruptedPayment();
122
174
 
123
175
  let cartTotal = $derived(() => cart.items.reduce((s, i) => s + i.amount, 0));
124
176
  let cartCurrency = $derived(() => cart.items[0]?.currencyCode ?? 'ZAR');
@@ -267,15 +319,36 @@
267
319
  if (!contactForm?.isValid() || !privacyAccepted) return;
268
320
 
269
321
  // paymentResult is only ever still set here when the previous attempt could not be torn down: the abandon
270
- // call failed (Peach unreachable), or Peach reported the charge as settled or still in flight. The server
271
- // still holds that attempt (a second payCart would be refused with a 409), so the only useful move is to
272
- // resume it. Every other way out of PaymentPage - cancel, expiry, error - abandons the attempt and clears
273
- // paymentResult, and the next Pay Now creates a NEW Peach checkout. That is deliberate, not waste: Peach's
274
- // SDK forbids re-rendering a checkoutId once it has been unmounted, a cancelled checkout is finished on
275
- // Peach's side, and the total is frozen at creation so an edited cart needs a new one regardless.
322
+ // call failed (Peach unreachable), or Peach reported the charge as settled or still in flight. Every other
323
+ // way out of PaymentPage - cancel, expiry, error - releases the attempt and clears paymentResult, and the
324
+ // next Pay Now creates a NEW Peach checkout. That is deliberate, not waste: Peach's SDK will not re-render
325
+ // a checkoutId it has already unmounted, a cancelled checkout is finished on Peach's side, and the total is
326
+ // frozen at creation so an edited cart needs a new one regardless.
327
+ //
328
+ // Which is exactly why this asks the server to release the attempt once more before anything else, rather
329
+ // than resuming it on the spot as it used to: that put the shopper back on a card form for a checkoutId
330
+ // Peach was done with, and Peach's own unrecoverable-error card in front of them instead of ours (PR 8447
331
+ // review). A release that succeeds leaves the cart Open and falls through to a genuinely new checkout. One
332
+ // that reports it has taken over - the charge turned out to have settled after all, or the cart is gone -
333
+ // owns what happens next, and paying again is the one thing that must not happen. Only a release that fails
334
+ // again leaves the server still holding the attempt (a second payCart would be refused with a 409), and
335
+ // then resuming that same card form really is the only move left.
276
336
  if (paymentResult) {
277
- currentView = 'payment';
278
- return;
337
+ isPaying = true;
338
+ try {
339
+ if (!(await releaseAbandonedAttempt())) return;
340
+ } catch {
341
+ // reloadCart rethrows anything that is not the cart being gone, and releaseAbandonedAttempt re-reads
342
+ // the cart on its way out. The attempt's own outcome is already decided by then, so the two lines
343
+ // below still route correctly on it; all a failed re-read costs is a countdown read a moment ago.
344
+ } finally {
345
+ isPaying = false;
346
+ }
347
+
348
+ if (paymentResult) {
349
+ currentView = 'payment';
350
+ return;
351
+ }
279
352
  }
280
353
 
281
354
  const contact = contactForm.getContact();
@@ -317,8 +390,10 @@
317
390
  // Neither cancelled, expired nor error ever reached a real charge - Peach's own SDK is reporting that the
318
391
  // checkout itself didn't go through (the shopper backed out, session timeout, a declined card, a 3DS
319
392
  // failure, a client-side error), not that a successful charge's confirmation is in question. That
320
- // distinction matters: the confirm path at the bottom is only reachable once onCompleted has fired,
321
- // meaning the card WAS charged, so "try again" there would risk a second charge. Here nothing was charged,
393
+ // distinction matters: the confirm path at the bottom is only reachable once onCompleted has fired, which
394
+ // Peach does for a settled outcome of either kind - a charge, or a decline (the server reads that back from
395
+ // Peach's status endpoint, since Peach sends no webhook for one, and answers PAYMENT_FAILED). So "try again"
396
+ // there is only ever offered once the server itself has said nothing was charged. Here nothing was charged,
322
397
  // so the attempt is abandoned server-side (a retry then starts a genuinely new Peach checkout instead of
323
398
  // reopening this dead one) and re-opening the card form is exactly correct.
324
399
  if (result.status === 'cancelled' || result.status === 'expired' || result.status === 'error') {
@@ -387,7 +462,8 @@
387
462
  }
388
463
 
389
464
  // The declined counterpart to releaseAbandonedAttempt, and deliberately without its abandon call: the server
390
- // has already resolved this attempt itself - the webhook landed with a decline, which is what moved the cart
465
+ // has already resolved this attempt itself - a decline reached it (Peach's webhook, or its own read of Peach's
466
+ // status endpoint on confirm, since Peach sends no webhook for a declined card), which is what moved the cart
391
467
  // to Failed and produced the PAYMENT_FAILED this follows - so there is nothing left to report. AbandonPaymentAsync
392
468
  // answers a Failed cart with AlreadyOpen, an explicit no-op, and skipping it also keeps this off the branch
393
469
  // that routes a 'settled' abandon back into runConfirmLoop, which would recurse from inside that very loop.
@@ -399,33 +475,36 @@
399
475
  forgetPaymentAttempt();
400
476
  // Failed is live and still payable again - the decline leaves the cart's one clock exactly where it was -
401
477
  // so this re-reads whatever time Try Again actually has left rather than the deadline from before the card
402
- // form. A cart whose clock ran out meanwhile 401s here and closes the modal, same as anywhere else.
403
- if (await reloadCart()) notifyCartUpdated(cart);
478
+ // form. A cart whose clock ran out meanwhile 401s here and closes the modal, same as anywhere else. Any
479
+ // other failure of the re-read must not escape: this runs inside interpretConfirm's own catch, from where
480
+ // a throw would strand the shopper on "Verifying payment..." with no button at all, and the decline itself
481
+ // is already recorded - Try Again simply counts down from the older deadline (PR 8447 review).
482
+ try {
483
+ if (await reloadCart()) notifyCartUpdated(cart);
484
+ } catch {
485
+ // see above
486
+ }
404
487
  // Without this CartExpiryGuard keeps treating a payment as in flight for the rest of the page's life, so it
405
488
  // never prompts or expires this cart again.
406
489
  postMessage({ type: 'payment:ended' });
407
490
  }
408
491
 
409
- // One confirm attempt, interpreted: records the result and reports whether it settled (success or a
410
- // supplier-side partial failure - either way, nothing left to retry) versus still needs another attempt
411
- // (the two webhook races below). Shared by the automatic loop and the manual on-demand recheck so there is
412
- // exactly one place that decides what a given confirmCart() outcome means.
413
- async function attemptConfirm(): Promise<'settled' | 'awaitingWebhook'> {
492
+ type ConfirmAnswer = 'settled' | 'notPaid' | 'confirming' | 'cartGone' | 'unknown';
493
+
494
+ // One confirmCart() call, interpreted - the single place that decides what each answer means, shared by the
495
+ // automatic loop, the manual on-demand recheck and a resume after a reload. 'settled' has recorded the result
496
+ // (success, a supplier-side partial failure, or a decline - nothing left to retry); the other three record
497
+ // nothing and leave the caller to decide what "no outcome yet" means where it stands.
498
+ async function interpretConfirm(): Promise<ConfirmAnswer> {
414
499
  try {
415
- confirmResult = await api.confirmCart();
416
- const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
417
- confirmOutcome = allOk ? 'success' : 'partial';
418
- // Notifies the host (order value/currency for analytics, clearing the stored cart token) without
419
- // closing the modal - the shopper still needs to see the success screen below, and only dismisses it
420
- // themselves via ResultView's Done button, which is the one thing that posts modal:close.
421
- if (allOk) {
422
- onOrderConfirmed();
423
- }
500
+ recordConfirmation(await api.confirmCart());
424
501
  return 'settled';
425
502
  } catch (e) {
426
- // The one case that is a real, settled failure rather than a race still resolving: Peach's webhook has
427
- // already landed and it was a decline (or another non-success outcome), so there is no charge behind
428
- // this attempt and never will be - retrying confirmCart again cannot change that. Checked before the
503
+ // The one case that is a real, settled failure rather than a race still resolving: the server knows the
504
+ // attempt was declined (or another non-success outcome) - from Peach's webhook, or from asking Peach's
505
+ // status endpoint itself when no webhook had landed, which is how every ordinary card decline arrives
506
+ // since Peach sends no webhook for one - so there is no charge behind this attempt and never will be;
507
+ // retrying confirmCart again cannot change that. Checked before the
429
508
  // generic 402/409 below, which would otherwise read this the same as "webhook not here yet" and retry
430
509
  // it right into the same generic "still confirming" pending screen a decline should never land on.
431
510
  if (isPaymentFailed(e)) {
@@ -435,22 +514,45 @@
435
514
  }
436
515
 
437
516
  // Two different races with the same webhook, neither a real failure:
438
- // 402 - the charge succeeded (that's why PaymentPage's onCompleted fired) but the webhook hasn't
439
- // reached PeachWebhookEndpoints yet to move the cart to Paid.
517
+ // 402 - Peach has no final answer for this attempt yet (a 3-D Secure check still in flight, or its own
518
+ // bookkeeping not caught up with the form that just completed) and the webhook hasn't reached
519
+ // PeachWebhookEndpoints yet either, so the cart is not yet Paid.
440
520
  // 409 - the webhook got there first and is confirming the cart right now, so there is no outcome to
441
521
  // read yet. Only one caller is allowed to run confirmation, and the loser is told to retry
442
522
  // rather than being handed a second, independently-produced answer.
443
- // Both resolve on their own within a second or two, so both are worth retrying. Any other error settles
444
- // as 'pending' rather than a failure state - the charge already succeeded by the time this can run at
445
- // all, so there is no "payment failed" to report, only "not confirmed yet".
446
- if (e instanceof ApiError && (e.status === 402 || e.status === 409)) {
447
- return 'awaitingWebhook';
448
- }
449
- confirmOutcome = 'pending';
450
- return 'settled';
523
+ // Both resolve on their own within a second or two. What "no outcome yet" or an unexpected error means
524
+ // depends on who is asking - see attemptConfirm and resumeOntoRecordedOutcome.
525
+ if (e instanceof ApiError && e.status === 402) return 'notPaid';
526
+ if (e instanceof ApiError && e.status === 409) return 'confirming';
527
+ if (isInvalidCart(e)) return 'cartGone';
528
+ return 'unknown';
529
+ }
530
+ }
531
+
532
+ function recordConfirmation(result: CheckoutCartConfirmResultDto): void {
533
+ confirmResult = result;
534
+ const allOk = result.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
535
+ confirmOutcome = allOk ? 'success' : 'partial';
536
+ // Notifies the host (order value/currency for analytics, clearing the stored cart token) without closing
537
+ // the modal - the shopper still needs to see the success screen, and only dismisses it themselves via
538
+ // ResultView's Done button, which is the one thing that posts modal:close.
539
+ if (allOk) {
540
+ onOrderConfirmed();
451
541
  }
452
542
  }
453
543
 
544
+ // The loop and the on-demand recheck only run once the attempt is known to have completed on Peach's side -
545
+ // onCompleted fired, or a resume after a reload found the webhook already confirming it - so "not paid" and
546
+ // "being confirmed" are both webhook races worth another attempt, and any other error (a cart the server no
547
+ // longer has included - by now that is a paid cart it cannot show, not one to pay again) settles as 'pending'
548
+ // rather than a failure state: there is no "payment failed" to report, only "not confirmed yet".
549
+ async function attemptConfirm(): Promise<'settled' | 'awaitingWebhook'> {
550
+ const answer = await interpretConfirm();
551
+ if (answer === 'notPaid' || answer === 'confirming') return 'awaitingWebhook';
552
+ if (answer === 'unknown' || answer === 'cartGone') confirmOutcome = 'pending';
553
+ return 'settled';
554
+ }
555
+
454
556
  async function runConfirmLoop() {
455
557
  isConfirming = true;
456
558
  confirmOutcome = null;
@@ -483,10 +585,11 @@
483
585
  isConfirming = false;
484
586
  }
485
587
 
486
- // The one ResultView prop that isn't a straight readout of confirmOutcome: 'partial' means the payment
487
- // succeeded but this gateway could not confirm every item, which is a real problem worth surfacing
488
- // distinctly from 'pending' (which resolves on its own) - but from ResultView's own outcome union, that is
489
- // still a kind of 'failed', just one retryPayment must gate off since the charge already happened.
588
+ // A straight readout of confirmOutcome onto ResultView's own outcome union, which now carries one of each.
589
+ // 'partial' - the payment succeeded but this gateway could not confirm every item - keeps an outcome of its
590
+ // own the whole way through: it is a real problem worth surfacing distinctly from 'pending' (which resolves
591
+ // on its own), and it must never reach the shopper as "Payment Failed", because their card was charged.
592
+ // ResultView owns the wording for it, as it already did for 'successful' and 'pending'.
490
593
  let resultStatus = $derived(
491
594
  isConfirming
492
595
  ? null
@@ -495,12 +598,7 @@
495
598
  : confirmOutcome === 'pending'
496
599
  ? { outcome: 'pending' as const }
497
600
  : confirmOutcome === 'partial'
498
- ? {
499
- outcome: 'failed' as const,
500
- resultDescription:
501
- "Your payment succeeded, but we couldn't confirm every item in your booking. Please contact us with your order details.",
502
- retryPayment: false,
503
- }
601
+ ? { outcome: 'partial' as const }
504
602
  : confirmOutcome === 'notCharged'
505
603
  ? {
506
604
  outcome: 'failed' as const,
@@ -517,13 +615,23 @@
517
615
  <div class="spinner"></div>
518
616
  </div>
519
617
 
520
- {:else if currentView === 'payment' && paymentResult}
521
- <PaymentPage
522
- checkoutId={paymentResult.checkoutId}
523
- entityId={paymentResult.entityId}
524
- expiresAt={expiryDeadline}
525
- {onPaymentComplete}
526
- />
618
+ {:else if currentView === 'payment'}
619
+ <!-- Still on the payment view but with no attempt left to render: releaseAbandonedAttempt clears
620
+ paymentResult and then re-reads the cart before its caller routes anywhere, and without this the modal
621
+ fell straight through to the cart view below for the length of that round trip - so the shopper watched
622
+ their cart reappear before landing on the failure screen they were being sent to (PR 8447 review). -->
623
+ {#if paymentResult}
624
+ <PaymentPage
625
+ checkoutId={paymentResult.checkoutId}
626
+ entityId={paymentResult.entityId}
627
+ expiresAt={expiryDeadline}
628
+ {onPaymentComplete}
629
+ />
630
+ {:else}
631
+ <div class="loading-center" style="height:100%">
632
+ <div class="spinner"></div>
633
+ </div>
634
+ {/if}
527
635
 
528
636
  {:else if currentView === 'result'}
529
637
  <ResultView
@@ -1,121 +1,121 @@
1
- <script lang="ts">
2
- import type { BookingApi } from './api';
3
- import type { WizardPages } from './config';
4
- import { DEFAULT_WIZARD_PAGES } from './config';
5
- import type { CheckoutCartDetailDto } from './client-types';
6
- import { isInvalidCart } from './api';
7
- import { onWidgetMessage, postMessage } from './messages';
8
- import type { CartManager } from './cart-manager';
9
- import CheckoutModal from './CheckoutModal.svelte';
10
- import CartExpiredView from './CartExpiredView.svelte';
11
-
12
- interface Props {
13
- api: BookingApi;
14
- cartManager?: CartManager;
15
- wizardPages?: WizardPages;
16
- editPages?: WizardPages;
17
- autoSelectSingleTimeSlot?: boolean;
18
- }
19
- let { api, cartManager, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp,
20
- autoSelectSingleTimeSlot = false }: Props = $props();
21
- let editPages = $derived(editPagesProp ?? wizardPages);
22
-
23
- let cart = $state<CheckoutCartDetailDto | null>(null);
24
- let isLoading = $state(true);
25
- let expired = $state(false);
26
-
27
- async function load() {
28
- // A token this widget still holds for a cart the server has already let go (its window ran out while the
29
- // tab sat there) used to render as a bare "No items in cart", with the dead token left in storage for the
30
- // next add to trip over. Only a held token can mean that - with none, an empty cart is just empty.
31
- const hadCart = api.cartToken !== '';
32
- try {
33
- cart = await api.getCart();
34
- } catch (e) {
35
- cart = null;
36
- if (hadCart && isInvalidCart(e)) {
37
- onCartExpired();
38
- expired = true;
39
- }
40
- }
41
- isLoading = false;
42
- }
43
-
44
- load();
45
-
46
- // Drops the stored token so whatever the shopper does next starts a fresh cart, and clears bw-cart's
47
- // bar/button and any host-side summary built from bw:cart-updated - the cart they were describing no longer
48
- // exists. Mirrors what onOrderConfirmed below does once a cart has served its purpose the other way.
49
- function onCartExpired() {
50
- cartManager?.reset();
51
- postMessage({ type: 'cart:updated', cart: null });
52
- postMessage({ type: 'cart:expired' });
53
- }
54
-
55
- // In modal mode, modal:close is what actually dismisses this screen - bw-checkout.svelte remounts this
56
- // component fresh the next time it opens, so the click is fully handled there. Rendered permanently
57
- // in-page instead, nothing is listening for modal:close, so without the reload below the click would do
58
- // nothing a shopper can see: expired flips off, but cart is still the stale null from the load() that
59
- // found it gone, leaving the same "No items in cart" text up with no sign anything happened.
60
- function startAgain() {
61
- expired = false;
62
- postMessage({ type: 'modal:close' });
63
- void load();
64
- }
65
-
66
- // In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
67
- // modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
68
- // would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
69
- // cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
70
- // triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
71
- $effect(() => onWidgetMessage((d) => {
72
- if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
73
- }));
74
- </script>
75
-
76
- <div class="bw-widget">
77
- {#if isLoading}
78
- <div class="loading-center" style="height:100vh">
79
- <div class="spinner"></div>
80
- </div>
81
- {:else if cart && cart.items.length > 0}
82
- <CheckoutModal
83
- {cart}
84
- {api}
85
- {wizardPages}
86
- {editPages}
87
- {autoSelectSingleTimeSlot}
88
- onClose={() => postMessage({ type: 'modal:close' })}
89
- onOrderConfirmed={() => {
90
- const total = cart!.items.reduce((s, i) => s + i.amount, 0);
91
- const currency = cart!.items[0]?.currencyCode ?? 'ZAR';
92
- postMessage({
93
- type: 'order:complete',
94
- cartToken: cart!.cartToken ?? '',
95
- value: total,
96
- currency,
97
- });
98
- // The cart is paid and confirmed, so its token has done its job. Nothing used to clear it, so it sat
99
- // in localStorage on the merchant's origin until absoluteExpiresAt - readable by every third-party
100
- // script they load, and picked up by the next person to use a shared or kiosk browser.
101
- cartManager?.reset();
102
- }}
103
- />
104
- {:else if expired}
105
- <div style="height:100vh">
106
- <CartExpiredView onStartAgain={startAgain} />
107
- </div>
108
- {:else}
109
- <div class="loading-center" style="height:100vh;color:var(--bw-color-text-secondary)">
110
- No items in cart.
111
- </div>
112
- {/if}
113
- </div>
114
-
115
- <style>
116
- /* display: contents - a plain box here would break CheckoutModal's own .modal{height:100%}, which needs
117
- to resolve against this component's real parent, not an unsized wrapper inserted in between. */
118
- .bw-widget {
119
- display: contents;
120
- }
121
- </style>
1
+ <script lang="ts">
2
+ import type { BookingApi } from './api';
3
+ import type { WizardPages } from './config';
4
+ import { DEFAULT_WIZARD_PAGES } from './config';
5
+ import type { CheckoutCartDetailDto } from './client-types';
6
+ import { isInvalidCart } from './api';
7
+ import { onWidgetMessage, postMessage } from './messages';
8
+ import type { CartManager } from './cart-manager';
9
+ import CheckoutModal from './CheckoutModal.svelte';
10
+ import CartExpiredView from './CartExpiredView.svelte';
11
+
12
+ interface Props {
13
+ api: BookingApi;
14
+ cartManager?: CartManager;
15
+ wizardPages?: WizardPages;
16
+ editPages?: WizardPages;
17
+ autoSelectSingleTimeSlot?: boolean;
18
+ }
19
+ let { api, cartManager, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp,
20
+ autoSelectSingleTimeSlot = false }: Props = $props();
21
+ let editPages = $derived(editPagesProp ?? wizardPages);
22
+
23
+ let cart = $state<CheckoutCartDetailDto | null>(null);
24
+ let isLoading = $state(true);
25
+ let expired = $state(false);
26
+
27
+ async function load() {
28
+ // A token this widget still holds for a cart the server has already let go (its window ran out while the
29
+ // tab sat there) used to render as a bare "No items in cart", with the dead token left in storage for the
30
+ // next add to trip over. Only a held token can mean that - with none, an empty cart is just empty.
31
+ const hadCart = api.cartToken !== '';
32
+ try {
33
+ cart = await api.getCart();
34
+ } catch (e) {
35
+ cart = null;
36
+ if (hadCart && isInvalidCart(e)) {
37
+ onCartExpired();
38
+ expired = true;
39
+ }
40
+ }
41
+ isLoading = false;
42
+ }
43
+
44
+ load();
45
+
46
+ // Drops the stored token so whatever the shopper does next starts a fresh cart, and clears bw-cart's
47
+ // bar/button and any host-side summary built from bw:cart-updated - the cart they were describing no longer
48
+ // exists. Mirrors what onOrderConfirmed below does once a cart has served its purpose the other way.
49
+ function onCartExpired() {
50
+ cartManager?.reset();
51
+ postMessage({ type: 'cart:updated', cart: null });
52
+ postMessage({ type: 'cart:expired' });
53
+ }
54
+
55
+ // In modal mode, modal:close is what actually dismisses this screen - bw-checkout.svelte remounts this
56
+ // component fresh the next time it opens, so the click is fully handled there. Rendered permanently
57
+ // in-page instead, nothing is listening for modal:close, so without the reload below the click would do
58
+ // nothing a shopper can see: expired flips off, but cart is still the stale null from the load() that
59
+ // found it gone, leaving the same "No items in cart" text up with no sign anything happened.
60
+ function startAgain() {
61
+ expired = false;
62
+ postMessage({ type: 'modal:close' });
63
+ void load();
64
+ }
65
+
66
+ // In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
67
+ // modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
68
+ // would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
69
+ // cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
70
+ // triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
71
+ $effect(() => onWidgetMessage((d) => {
72
+ if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
73
+ }));
74
+ </script>
75
+
76
+ <div class="bw-widget">
77
+ {#if isLoading}
78
+ <div class="loading-center" style="height:100vh">
79
+ <div class="spinner"></div>
80
+ </div>
81
+ {:else if cart && cart.items.length > 0}
82
+ <CheckoutModal
83
+ {cart}
84
+ {api}
85
+ {wizardPages}
86
+ {editPages}
87
+ {autoSelectSingleTimeSlot}
88
+ onClose={() => postMessage({ type: 'modal:close' })}
89
+ onOrderConfirmed={() => {
90
+ const total = cart!.items.reduce((s, i) => s + i.amount, 0);
91
+ const currency = cart!.items[0]?.currencyCode ?? 'ZAR';
92
+ postMessage({
93
+ type: 'order:complete',
94
+ cartToken: cart!.cartToken ?? '',
95
+ value: total,
96
+ currency,
97
+ });
98
+ // The cart is paid and confirmed, so its token has done its job. Nothing used to clear it, so it sat
99
+ // in localStorage on the merchant's origin until absoluteExpiresAt - readable by every third-party
100
+ // script they load, and picked up by the next person to use a shared or kiosk browser.
101
+ cartManager?.reset();
102
+ }}
103
+ />
104
+ {:else if expired}
105
+ <div style="height:100vh">
106
+ <CartExpiredView onStartAgain={startAgain} />
107
+ </div>
108
+ {:else}
109
+ <div class="loading-center" style="height:100vh;color:var(--bw-color-text-secondary)">
110
+ No items in cart.
111
+ </div>
112
+ {/if}
113
+ </div>
114
+
115
+ <style>
116
+ /* display: contents - a plain box here would break CheckoutModal's own .modal{height:100%}, which needs
117
+ to resolve against this component's real parent, not an unsized wrapper inserted in between. */
118
+ .bw-widget {
119
+ display: contents;
120
+ }
121
+ </style>