@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.
@@ -1,416 +1,416 @@
1
- <script lang="ts">
2
- // The one place the cart's expiry is watched and acted on - mounted exactly once per page load (see
3
- // register.ts), independently of which <bw-*> elements the merchant actually embeds. A merchant might place
4
- // only <bw-configurator> and <bw-cart> with no <bw-checkout> anywhere on the page at all, so the warning and
5
- // the close-on-timeout it can lead to cannot live inside the checkout modal - by the time that would matter,
6
- // the modal may never have existed. CheckoutModal.svelte only ever *displays* the same idleExpiresAt this
7
- // reads; it owns no prompt/extend/close logic of its own any more.
8
- import type { BookingApi } from './api';
9
- import { isInvalidCart, isCartLocked, isCartExpired, isPaymentSettled, isPaymentPending } from './api';
10
- import type { CartManager } from './cart-manager';
11
- import type { CheckoutCartDetailDto } from './client-types';
12
- import { onWidgetMessage, postMessage } from './messages';
13
- import { onDestroy } from 'svelte';
14
- import { cartDeadline, isAtExpiryCeiling, EXTENSION_ENABLED } from './cart-expiry';
15
- import { recallPaymentAttempt, forgetPaymentAttempt } from './payment-attempt';
16
- import CartExpiryWatcher from './CartExpiryWatcher.svelte';
17
- import StillTherePrompt from './StillTherePrompt.svelte';
18
- import CartExpiredView from './CartExpiredView.svelte';
19
- import { getBookingHostContext, requireBookingService } from './booking-context';
20
-
21
- interface Props {
22
- api?: BookingApi;
23
- cartManager?: CartManager;
24
- // Whether the "are you still there?" prompt is offered - see EXTENSION_ENABLED. Off, the guard only ever
25
- // shows the expired view at 0:00; the clock itself is unchanged either way.
26
- extensionEnabled?: boolean;
27
- }
28
- let { api: apiProp, cartManager: cartManagerProp, extensionEnabled = EXTENSION_ENABLED }: Props = $props();
29
-
30
- const bookingHost = getBookingHostContext();
31
- let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'CartExpiryGuard', 'api'));
32
- let cartManager = $derived(
33
- requireBookingService(cartManagerProp ?? bookingHost?.cartManager, 'CartExpiryGuard', 'cartManager'),
34
- );
35
-
36
- let cart = $state<CheckoutCartDetailDto | null>(null);
37
- let remaining = $state('');
38
- let warning = $state(false);
39
- let extending = $state(false);
40
- let closed = $state(false);
41
- // Set while a Peach checkout is open for this cart. The server exempts such a cart from expiry
42
- // (CheckoutCartService.IsPastThePointOfNoReturn) - the money may be moving - so the deadline *passing* under
43
- // the card form is not acted on here until CheckoutModal reports the attempt abandoned or the order
44
- // completes. The clock itself keeps running, though, and the still-there prompt is still shown and still
45
- // extends (ExtendAsync accepts a cart awaiting payment): a shopper who lets it run out on the card form and
46
- // then cancels finds the cart expired, exactly as they would anywhere else. Learned from CheckoutModal's
47
- // payment:started/ended messages while the page lives, and from the attempt CheckoutModal persisted
48
- // (payment-attempt.ts) when this guard first sees a cart - so a reload mid-payment resumes the same way.
49
- let paymentInFlight = $state(false);
50
- // The deadline (ms) the prompt was last put away for - by OK on the at-the-ceiling warning, or by an extend
51
- // that came back without moving the clock (see extend()) - so it doesn't just reappear next tick, but a
52
- // *later* deadline (after an extend moves it) shows its own prompt.
53
- let dismissedFor = $state<number | null>(null);
54
-
55
- async function loadInitialCart() {
56
- if (!cartManager.hasCart) return;
57
- try {
58
- cart = await api.getCart();
59
- } catch (e) {
60
- if (isInvalidCart(e)) {
61
- cartManager.reset();
62
- }
63
- }
64
- }
65
- loadInitialCart();
66
-
67
- $effect(() => onWidgetMessage((d) => {
68
- if (d.type === 'cart:updated') {
69
- cart = ('cart' in d ? d.cart : null) as CheckoutCartDetailDto | null;
70
- }
71
- // The cart is paid and confirmed - Checkout.svelte's onOrderConfirmed already cleared the token and
72
- // posted order:complete, but nothing told this guard the cart is done. Without this, the watcher keeps
73
- // ticking on the stale deadline and eventually fires the "are you still there?" prompt (and then the
74
- // "expired" overlay) over the confirmation screen.
75
- if (d.type === 'order:complete') {
76
- cart = null;
77
- paymentInFlight = false;
78
- }
79
- if (d.type === 'payment:started') {
80
- paymentInFlight = true;
81
- }
82
- if (d.type === 'payment:ended') {
83
- paymentInFlight = false;
84
- // Whoever ended the payment has done what the fallback below exists to do if nobody answers.
85
- clearTimedOutFallback();
86
- // CartExpiryWatcher fires once per deadline and already did so during the pause (ignored below). If the
87
- // cart came back from the abandon on a fresh deadline, cart:updated has already replaced this one.
88
- if (cart && expiryDeadline && expiryDeadline.getTime() <= Date.now()) void confirmExpiry();
89
- }
90
- }));
91
-
92
- let lastToken = $state('');
93
- $effect(() => {
94
- const token = cart?.cartToken ?? '';
95
- if (token !== lastToken) {
96
- lastToken = token;
97
- dismissedFor = null;
98
- clearTimers();
99
- paymentInFlight = recallPaymentAttempt(token) !== null;
100
- if (token) closed = false;
101
- }
102
- });
103
-
104
- let expiryDeadline = $derived(cart ? cartDeadline(cart) : null);
105
- // Not gated on paymentInFlight: this overlay sits above PaymentPage (see z-index below) precisely so the
106
- // prompt can reach a shopper mid card entry - when the prompt is enabled at all.
107
- let showPrompt = $derived(extensionEnabled && !!cart && !closed && warning && dismissedFor !== expiryDeadline?.getTime());
108
- // Read off the cart's own instants, not set by a failed request: at the ceiling there is no more time to be
109
- // had, so the prompt only warns.
110
- let canExtend = $derived(!!cart && !isAtExpiryCeiling(cart));
111
-
112
- async function extend() {
113
- extending = true;
114
- const deadlineBefore = expiryDeadline?.getTime() ?? null;
115
- try {
116
- cart = await api.extendCart();
117
- // A slide always moves idleExpiresAt to a full IdleDuration from now (short of the ceiling, which
118
- // isAtExpiryCeiling already covers), so the same deadline coming back means no slide happened: a hold
119
- // behind the cart could not be re-extended and the server left the clock where it was
120
- // (ExtendCheckoutCartCommandHandler - it reports no refusal, the unmoved deadline is the answer). The
121
- // prompt simply goes away for this deadline. The countdown carries on from where it was, and if it runs
122
- // out the cart expires and every hold is released as usual - no second message, no button whose every
123
- // click would fire another round of supplier calls that fail the same way.
124
- if (cartDeadline(cart).getTime() === deadlineBefore) dismissedFor = deadlineBefore;
125
- // Every other reader of the deadline (CartBarView, CartOverviewButton, an open CheckoutModal, the host's
126
- // own bw:cart-updated) only learns about it through this message - without it they keep counting down to
127
- // the pre-extension deadline even though the guard itself now knows better.
128
- postMessage({ type: 'cart:updated', cart });
129
- } catch (e) {
130
- if (isInvalidCart(e)) {
131
- expire();
132
- } else if (isCartExpired(e)) {
133
- // The server's own word that the clock has run out - only ever said with a payment in flight, since an
134
- // expired cart anywhere else is a 401 above. Nothing left to ask, so straight to the hand-over the
135
- // watcher reaching 0:00 during payment arrives at after asking: CheckoutModal tears the attempt down
136
- // and payment:ended brings the guard back to confirm the expiry.
137
- handOverTimedOutPayment();
138
- } else if (isCartLocked(e)) {
139
- // The cart's money has moved (Paid or beyond - a payment awaiting confirmation is still extendable)
140
- // without this guard hearing order:complete yet, most likely from another tab. Nothing left to extend
141
- // or expire; stop prompting for this deadline rather than leave a button up that can never succeed.
142
- dismissedFor = deadlineBefore;
143
- }
144
- // Anything else (a blip): the prompt stays up and the shopper can simply click again.
145
- } finally {
146
- extending = false;
147
- }
148
- }
149
-
150
- function dismissWarning() {
151
- if (expiryDeadline) dismissedFor = expiryDeadline.getTime();
152
- }
153
-
154
- // How long to wait before asking the server again once it has reported the cart still live at our local T-0.
155
- const ExpiryRecheckMs = 30_000;
156
- // How long the guard gives CheckoutModal to act on payment:timed-out before doing it itself.
157
- const TimedOutFallbackMs = 5_000;
158
- // Plain lets, not $state - nothing rendered depends on any of these, they only sequence the confirms below.
159
- let recheckTimer: ReturnType<typeof setTimeout> | null = null;
160
- let timedOutFallbackTimer: ReturnType<typeof setTimeout> | null = null;
161
- let confirming = false;
162
-
163
- function clearRecheck() {
164
- if (recheckTimer !== null) {
165
- clearTimeout(recheckTimer);
166
- recheckTimer = null;
167
- }
168
- }
169
-
170
- function clearTimedOutFallback() {
171
- if (timedOutFallbackTimer !== null) {
172
- clearTimeout(timedOutFallbackTimer);
173
- timedOutFallbackTimer = null;
174
- }
175
- }
176
-
177
- // Both pending timers are about the cart this guard currently has: neither may fire for a page that has gone
178
- // or a cart that has been replaced (the fallback in particular would abandon a payment attempt).
179
- function clearTimers() {
180
- clearRecheck();
181
- clearTimedOutFallback();
182
- }
183
- onDestroy(clearTimers);
184
-
185
- // CartExpiryWatcher's callback: the deadline passed with no extension - by the browser's clock. That is a
186
- // claim, not a verdict. Expiry is the server's own doing (an idle cart simply stops validating, and
187
- // CheckoutCartExpirySweepBackgroundService releases the holds behind it on its next pass), and a clock
188
- // running fast would otherwise throw away a cart the server still considers live, holds and all. So the
189
- // clock only decides when to *ask* - with or without a payment in flight, though what is asked differs.
190
- function onDeadlinePassed() {
191
- if (closed) return;
192
- if (paymentInFlight) {
193
- void confirmPaymentTimedOut();
194
- return;
195
- }
196
- void confirmExpiry();
197
- }
198
-
199
- // A cart whose payment is in flight is exempt from expiry server-side, so there is no 401 to ask for. What
200
- // one read does settle is whether this guard's deadline is still the cart's: another tab may have answered
201
- // the still-there prompt, or the server may have pushed the clock out to the payment floor when the attempt
202
- // began (CheckoutCartService.PaymentWindowFloor) and this guard never heard. Only a deadline the server hands
203
- // back already past is acted on - a later one re-arms the watcher by itself and is announced to every other
204
- // reader. What the read cannot settle is a browser clock running ahead of the server's with an unmoved
205
- // deadline: the cart detail carries no server time to compare against, so that case still tears the attempt
206
- // down early. The abandon behind it is Peach-verified either way, so money in motion is never touched, and
207
- // a cart the server still considers live simply reopens on its remaining time. A read that fails says nothing
208
- // about the cart and is no grounds to tear a live Peach session down - it is asked again.
209
- async function confirmPaymentTimedOut() {
210
- if (!cart || closed || !paymentInFlight || confirming) return;
211
- // Already handed over and not yet answered: the fresh cart a hand-over reads back may carry a deadline
212
- // that differs from the one the watcher fired for while still being past, and the watcher fires again for
213
- // any deadline it has not fired for. One hand-over per unanswered timeout, not one per re-fire.
214
- if (timedOutFallbackTimer !== null) return;
215
- confirming = true;
216
- clearRecheck();
217
- let answer: 'past' | 'live' | 'gone' | 'unknown';
218
- try {
219
- cart = await api.getCart();
220
- postMessage({ type: 'cart:updated', cart });
221
- answer = cartDeadline(cart).getTime() <= Date.now() ? 'past' : 'live';
222
- } catch (e) {
223
- answer = isInvalidCart(e) ? 'gone' : 'unknown';
224
- } finally {
225
- confirming = false;
226
- }
227
- if (closed) return;
228
-
229
- if (answer === 'gone') {
230
- // The server has let this cart go despite our record of a payment in flight - the attempt must have been
231
- // ended elsewhere (another tab) and the cart expired behind it. Same answer as anywhere else.
232
- forgetPaymentAttempt();
233
- paymentInFlight = false;
234
- expire();
235
- return;
236
- }
237
- if (answer === 'live') return;
238
- // The payment may have ended while the read was in flight - CheckoutModal tears a resumed attempt down
239
- // itself when it finds the deadline already past - and its payment:ended found this guard busy. Then there
240
- // is nothing to hand over any more; this is the ordinary expiry question, asked afresh.
241
- if (!paymentInFlight) {
242
- void confirmExpiry();
243
- return;
244
- }
245
- if (answer === 'past') handOverTimedOutPayment();
246
- else scheduleRecheck();
247
- }
248
-
249
- // The clock ran out on the card form, on the server's own word or as near as this guard can get to it (see
250
- // confirmPaymentTimedOut). CheckoutModal is told, and it tears the attempt down through the same
251
- // Peach-verified abandon a cancel uses (so a charge that is actually landing is never torn down). Its
252
- // payment:ended then brings the guard back here to ask, and the answer is the expired view.
253
- function handOverTimedOutPayment() {
254
- postMessage({ type: 'payment:timed-out' });
255
- scheduleUnansweredTimeoutFallback();
256
- }
257
-
258
- // payment:timed-out only does anything if a CheckoutModal is mounted with the attempt open. After a reload
259
- // the guard learns of the attempt from sessionStorage alone - the modal may never have been reopened - and
260
- // then nobody would tear it down: paymentInFlight stays true, the watcher has already fired for this
261
- // deadline, and the cart sits in limbo with no expiry ever shown. So if nothing has ended the payment shortly
262
- // after the message, the guard abandons the attempt itself, through the same Peach-verified call the modal
263
- // uses. A refusal because Peach has the charge as settled or still in flight is never torn down - that is
264
- // money in motion - but it cannot be left to sit either: nothing else on the page would ever confirm it. So
265
- // checkout is opened, the same way the cart bar's own button opens it; CheckoutModal resumes the attempt,
266
- // runs into the same refusal, and drops into its confirm loop, which is where a settled charge belongs.
267
- function scheduleUnansweredTimeoutFallback() {
268
- clearTimedOutFallback();
269
- timedOutFallbackTimer = setTimeout(() => {
270
- timedOutFallbackTimer = null;
271
- void abandonUnansweredTimedOutAttempt();
272
- }, TimedOutFallbackMs);
273
- }
274
-
275
- async function abandonUnansweredTimedOutAttempt() {
276
- if (!cart || closed || !paymentInFlight) return;
277
- const attempt = recallPaymentAttempt(cart.cartToken ?? '');
278
- if (!attempt) return;
279
- try {
280
- await api.abandonPayment(attempt.checkoutId);
281
- } catch (e) {
282
- if (isPaymentSettled(e) || isPaymentPending(e)) {
283
- postMessage({ type: 'modal:open' });
284
- return;
285
- }
286
- // Anything else (a blip): nothing changed server-side; reopening checkout lands back on the attempt.
287
- return;
288
- }
289
- forgetPaymentAttempt();
290
- paymentInFlight = false;
291
- void confirmExpiry();
292
- }
293
-
294
- // One read settles it. A 401 means the server has let the cart go, so clearing it locally is safe. A live
295
- // cart back means our clock is ahead of the server's: it is kept, on whatever deadline the server now
296
- // reports (a moved one re-arms the watcher by itself, an unmoved one is asked about again shortly, since
297
- // the watcher fires only once per deadline), and every other reader is told so their countdowns catch up.
298
- // A read that fails for any other reason says nothing about the cart and is likewise no grounds to destroy
299
- // it - it is simply asked again.
300
- async function confirmExpiry() {
301
- if (!cart || closed || paymentInFlight || confirming) return;
302
- confirming = true;
303
- clearRecheck();
304
- try {
305
- cart = await api.getCart();
306
- postMessage({ type: 'cart:updated', cart });
307
- if (cartDeadline(cart).getTime() <= Date.now()) scheduleRecheck();
308
- } catch (e) {
309
- if (isInvalidCart(e)) {
310
- expire();
311
- } else {
312
- scheduleRecheck();
313
- }
314
- } finally {
315
- confirming = false;
316
- }
317
- }
318
-
319
- // Whichever question fits the cart's state by the time it fires - a payment may have started or ended since.
320
- function scheduleRecheck() {
321
- recheckTimer = setTimeout(() => {
322
- recheckTimer = null;
323
- if (paymentInFlight) void confirmPaymentTimedOut();
324
- else void confirmExpiry();
325
- }, ExpiryRecheckMs);
326
- }
327
-
328
- function expire() {
329
- clearTimers();
330
- closed = true;
331
- cartManager.reset();
332
- // Tells bw-cart/CartOverviewButton/CheckoutModal (if it happens to be mounted) the cart is gone, the same
333
- // signal a manual remove-to-empty already sends - see CheckoutPanel's own onCartExpired for the other
334
- // caller of this exact message. cart:expired follows it because a null cart alone does not say why: a
335
- // confirmed order clears the cart the same way, and a host announcing "your cart expired" needs the two
336
- // apart.
337
- postMessage({ type: 'cart:updated', cart: null });
338
- postMessage({ type: 'cart:expired' });
339
- }
340
-
341
- function startAgain() {
342
- closed = false;
343
- cart = null;
344
- // If checkout happened to be open when this fired, there is nothing left in it to show.
345
- postMessage({ type: 'modal:close' });
346
- }
347
-
348
- // This overlay covers the merchant's whole page and claims aria-modal, so it has to behave like a modal:
349
- // Tab must not walk out of it into the page behind, and Escape must do something. Escape dismisses the
350
- // warning for this deadline (exactly what the OK button does) - never closes the cart, which is not the
351
- // shopper's to trigger and would be a destructive thing to hang off a stray keypress.
352
- function onOverlayKeydown(event: KeyboardEvent) {
353
- if (event.key === 'Escape' && showPrompt) {
354
- event.preventDefault();
355
- dismissWarning();
356
- return;
357
- }
358
-
359
- if (event.key !== 'Tab') return;
360
-
361
- const focusable = overlay?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])');
362
- if (!focusable?.length) return;
363
-
364
- const first = focusable[0];
365
- const last = focusable[focusable.length - 1];
366
- const wrappingBackwards = event.shiftKey && document.activeElement === first;
367
- const wrappingForwards = !event.shiftKey && document.activeElement === last;
368
- if (wrappingBackwards || wrappingForwards) {
369
- event.preventDefault();
370
- (event.shiftKey ? last : first).focus();
371
- }
372
- }
373
-
374
- let overlay = $state<HTMLDivElement | null>(null);
375
- </script>
376
-
377
- <svelte:window onkeydown={showPrompt || closed ? onOverlayKeydown : undefined} />
378
-
379
- {#if cart && expiryDeadline}
380
- <CartExpiryWatcher expiresAt={expiryDeadline} onExpired={onDeadlinePassed} bind:remaining bind:warning />
381
- {/if}
382
-
383
- {#if showPrompt}
384
- <div class="guard-overlay bw-widget" bind:this={overlay}>
385
- <StillTherePrompt {remaining} {canExtend} {extending} onExtend={extend} onDismiss={dismissWarning} />
386
- </div>
387
- {:else if closed}
388
- <div class="guard-overlay bw-widget" bind:this={overlay}>
389
- <div class="guard-card">
390
- <CartExpiredView onStartAgain={startAgain} />
391
- </div>
392
- </div>
393
- {/if}
394
-
395
- <style>
396
- .guard-overlay {
397
- position: fixed;
398
- inset: 0;
399
- /* Above every other layer this widget draws, including bw-checkout's own .bw-modal-overlay (10000) and
400
- PaymentPage's .payment-overlay (10001) - this has to be able to interrupt someone mid-payment too. */
401
- z-index: 20000;
402
- display: flex;
403
- align-items: center;
404
- justify-content: center;
405
- padding: 16px;
406
- background: rgba(0, 0, 0, 0.45);
407
- }
408
- .guard-card {
409
- width: 100%;
410
- max-width: 360px;
411
- background: var(--bw-color-bg);
412
- border-radius: var(--bw-radius-lg);
413
- box-shadow: var(--bw-shadow-3);
414
- overflow: hidden;
415
- }
416
- </style>
1
+ <script lang="ts">
2
+ // The one place the cart's expiry is watched and acted on - mounted exactly once per page load (see
3
+ // register.ts), independently of which <bw-*> elements the merchant actually embeds. A merchant might place
4
+ // only <bw-configurator> and <bw-cart> with no <bw-checkout> anywhere on the page at all, so the warning and
5
+ // the close-on-timeout it can lead to cannot live inside the checkout modal - by the time that would matter,
6
+ // the modal may never have existed. CheckoutModal.svelte only ever *displays* the same idleExpiresAt this
7
+ // reads; it owns no prompt/extend/close logic of its own any more.
8
+ import type { BookingApi } from './api';
9
+ import { isInvalidCart, isCartLocked, isCartExpired, isPaymentSettled, isPaymentPending } from './api';
10
+ import type { CartManager } from './cart-manager';
11
+ import type { CheckoutCartDetailDto } from './client-types';
12
+ import { onWidgetMessage, postMessage } from './messages';
13
+ import { onDestroy } from 'svelte';
14
+ import { cartDeadline, isAtExpiryCeiling, EXTENSION_ENABLED } from './cart-expiry';
15
+ import { recallPaymentAttempt, forgetPaymentAttempt } from './payment-attempt';
16
+ import CartExpiryWatcher from './CartExpiryWatcher.svelte';
17
+ import StillTherePrompt from './StillTherePrompt.svelte';
18
+ import CartExpiredView from './CartExpiredView.svelte';
19
+ import { getBookingHostContext, requireBookingService } from './booking-context';
20
+
21
+ interface Props {
22
+ api?: BookingApi;
23
+ cartManager?: CartManager;
24
+ // Whether the "are you still there?" prompt is offered - see EXTENSION_ENABLED. Off, the guard only ever
25
+ // shows the expired view at 0:00; the clock itself is unchanged either way.
26
+ extensionEnabled?: boolean;
27
+ }
28
+ let { api: apiProp, cartManager: cartManagerProp, extensionEnabled = EXTENSION_ENABLED }: Props = $props();
29
+
30
+ const bookingHost = getBookingHostContext();
31
+ let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'CartExpiryGuard', 'api'));
32
+ let cartManager = $derived(
33
+ requireBookingService(cartManagerProp ?? bookingHost?.cartManager, 'CartExpiryGuard', 'cartManager'),
34
+ );
35
+
36
+ let cart = $state<CheckoutCartDetailDto | null>(null);
37
+ let remaining = $state('');
38
+ let warning = $state(false);
39
+ let extending = $state(false);
40
+ let closed = $state(false);
41
+ // Set while a Peach checkout is open for this cart. The server exempts such a cart from expiry
42
+ // (CheckoutCartService.IsPastThePointOfNoReturn) - the money may be moving - so the deadline *passing* under
43
+ // the card form is not acted on here until CheckoutModal reports the attempt abandoned or the order
44
+ // completes. The clock itself keeps running, though, and the still-there prompt is still shown and still
45
+ // extends (ExtendAsync accepts a cart awaiting payment): a shopper who lets it run out on the card form and
46
+ // then cancels finds the cart expired, exactly as they would anywhere else. Learned from CheckoutModal's
47
+ // payment:started/ended messages while the page lives, and from the attempt CheckoutModal persisted
48
+ // (payment-attempt.ts) when this guard first sees a cart - so a reload mid-payment resumes the same way.
49
+ let paymentInFlight = $state(false);
50
+ // The deadline (ms) the prompt was last put away for - by OK on the at-the-ceiling warning, or by an extend
51
+ // that came back without moving the clock (see extend()) - so it doesn't just reappear next tick, but a
52
+ // *later* deadline (after an extend moves it) shows its own prompt.
53
+ let dismissedFor = $state<number | null>(null);
54
+
55
+ async function loadInitialCart() {
56
+ if (!cartManager.hasCart) return;
57
+ try {
58
+ cart = await api.getCart();
59
+ } catch (e) {
60
+ if (isInvalidCart(e)) {
61
+ cartManager.reset();
62
+ }
63
+ }
64
+ }
65
+ loadInitialCart();
66
+
67
+ $effect(() => onWidgetMessage((d) => {
68
+ if (d.type === 'cart:updated') {
69
+ cart = ('cart' in d ? d.cart : null) as CheckoutCartDetailDto | null;
70
+ }
71
+ // The cart is paid and confirmed - Checkout.svelte's onOrderConfirmed already cleared the token and
72
+ // posted order:complete, but nothing told this guard the cart is done. Without this, the watcher keeps
73
+ // ticking on the stale deadline and eventually fires the "are you still there?" prompt (and then the
74
+ // "expired" overlay) over the confirmation screen.
75
+ if (d.type === 'order:complete') {
76
+ cart = null;
77
+ paymentInFlight = false;
78
+ }
79
+ if (d.type === 'payment:started') {
80
+ paymentInFlight = true;
81
+ }
82
+ if (d.type === 'payment:ended') {
83
+ paymentInFlight = false;
84
+ // Whoever ended the payment has done what the fallback below exists to do if nobody answers.
85
+ clearTimedOutFallback();
86
+ // CartExpiryWatcher fires once per deadline and already did so during the pause (ignored below). If the
87
+ // cart came back from the abandon on a fresh deadline, cart:updated has already replaced this one.
88
+ if (cart && expiryDeadline && expiryDeadline.getTime() <= Date.now()) void confirmExpiry();
89
+ }
90
+ }));
91
+
92
+ let lastToken = $state('');
93
+ $effect(() => {
94
+ const token = cart?.cartToken ?? '';
95
+ if (token !== lastToken) {
96
+ lastToken = token;
97
+ dismissedFor = null;
98
+ clearTimers();
99
+ paymentInFlight = recallPaymentAttempt(token) !== null;
100
+ if (token) closed = false;
101
+ }
102
+ });
103
+
104
+ let expiryDeadline = $derived(cart ? cartDeadline(cart) : null);
105
+ // Not gated on paymentInFlight: this overlay sits above PaymentPage (see z-index below) precisely so the
106
+ // prompt can reach a shopper mid card entry - when the prompt is enabled at all.
107
+ let showPrompt = $derived(extensionEnabled && !!cart && !closed && warning && dismissedFor !== expiryDeadline?.getTime());
108
+ // Read off the cart's own instants, not set by a failed request: at the ceiling there is no more time to be
109
+ // had, so the prompt only warns.
110
+ let canExtend = $derived(!!cart && !isAtExpiryCeiling(cart));
111
+
112
+ async function extend() {
113
+ extending = true;
114
+ const deadlineBefore = expiryDeadline?.getTime() ?? null;
115
+ try {
116
+ cart = await api.extendCart();
117
+ // A slide always moves idleExpiresAt to a full IdleDuration from now (short of the ceiling, which
118
+ // isAtExpiryCeiling already covers), so the same deadline coming back means no slide happened: a hold
119
+ // behind the cart could not be re-extended and the server left the clock where it was
120
+ // (ExtendCheckoutCartCommandHandler - it reports no refusal, the unmoved deadline is the answer). The
121
+ // prompt simply goes away for this deadline. The countdown carries on from where it was, and if it runs
122
+ // out the cart expires and every hold is released as usual - no second message, no button whose every
123
+ // click would fire another round of supplier calls that fail the same way.
124
+ if (cartDeadline(cart).getTime() === deadlineBefore) dismissedFor = deadlineBefore;
125
+ // Every other reader of the deadline (CartBarView, CartOverviewButton, an open CheckoutModal, the host's
126
+ // own bw:cart-updated) only learns about it through this message - without it they keep counting down to
127
+ // the pre-extension deadline even though the guard itself now knows better.
128
+ postMessage({ type: 'cart:updated', cart });
129
+ } catch (e) {
130
+ if (isInvalidCart(e)) {
131
+ expire();
132
+ } else if (isCartExpired(e)) {
133
+ // The server's own word that the clock has run out - only ever said with a payment in flight, since an
134
+ // expired cart anywhere else is a 401 above. Nothing left to ask, so straight to the hand-over the
135
+ // watcher reaching 0:00 during payment arrives at after asking: CheckoutModal tears the attempt down
136
+ // and payment:ended brings the guard back to confirm the expiry.
137
+ handOverTimedOutPayment();
138
+ } else if (isCartLocked(e)) {
139
+ // The cart's money has moved (Paid or beyond - a payment awaiting confirmation is still extendable)
140
+ // without this guard hearing order:complete yet, most likely from another tab. Nothing left to extend
141
+ // or expire; stop prompting for this deadline rather than leave a button up that can never succeed.
142
+ dismissedFor = deadlineBefore;
143
+ }
144
+ // Anything else (a blip): the prompt stays up and the shopper can simply click again.
145
+ } finally {
146
+ extending = false;
147
+ }
148
+ }
149
+
150
+ function dismissWarning() {
151
+ if (expiryDeadline) dismissedFor = expiryDeadline.getTime();
152
+ }
153
+
154
+ // How long to wait before asking the server again once it has reported the cart still live at our local T-0.
155
+ const ExpiryRecheckMs = 30_000;
156
+ // How long the guard gives CheckoutModal to act on payment:timed-out before doing it itself.
157
+ const TimedOutFallbackMs = 5_000;
158
+ // Plain lets, not $state - nothing rendered depends on any of these, they only sequence the confirms below.
159
+ let recheckTimer: ReturnType<typeof setTimeout> | null = null;
160
+ let timedOutFallbackTimer: ReturnType<typeof setTimeout> | null = null;
161
+ let confirming = false;
162
+
163
+ function clearRecheck() {
164
+ if (recheckTimer !== null) {
165
+ clearTimeout(recheckTimer);
166
+ recheckTimer = null;
167
+ }
168
+ }
169
+
170
+ function clearTimedOutFallback() {
171
+ if (timedOutFallbackTimer !== null) {
172
+ clearTimeout(timedOutFallbackTimer);
173
+ timedOutFallbackTimer = null;
174
+ }
175
+ }
176
+
177
+ // Both pending timers are about the cart this guard currently has: neither may fire for a page that has gone
178
+ // or a cart that has been replaced (the fallback in particular would abandon a payment attempt).
179
+ function clearTimers() {
180
+ clearRecheck();
181
+ clearTimedOutFallback();
182
+ }
183
+ onDestroy(clearTimers);
184
+
185
+ // CartExpiryWatcher's callback: the deadline passed with no extension - by the browser's clock. That is a
186
+ // claim, not a verdict. Expiry is the server's own doing (an idle cart simply stops validating, and
187
+ // CheckoutCartExpirySweepBackgroundService releases the holds behind it on its next pass), and a clock
188
+ // running fast would otherwise throw away a cart the server still considers live, holds and all. So the
189
+ // clock only decides when to *ask* - with or without a payment in flight, though what is asked differs.
190
+ function onDeadlinePassed() {
191
+ if (closed) return;
192
+ if (paymentInFlight) {
193
+ void confirmPaymentTimedOut();
194
+ return;
195
+ }
196
+ void confirmExpiry();
197
+ }
198
+
199
+ // A cart whose payment is in flight is exempt from expiry server-side, so there is no 401 to ask for. What
200
+ // one read does settle is whether this guard's deadline is still the cart's: another tab may have answered
201
+ // the still-there prompt, or the server may have pushed the clock out to the payment floor when the attempt
202
+ // began (CheckoutCartService.PaymentWindowFloor) and this guard never heard. Only a deadline the server hands
203
+ // back already past is acted on - a later one re-arms the watcher by itself and is announced to every other
204
+ // reader. What the read cannot settle is a browser clock running ahead of the server's with an unmoved
205
+ // deadline: the cart detail carries no server time to compare against, so that case still tears the attempt
206
+ // down early. The abandon behind it is Peach-verified either way, so money in motion is never touched, and
207
+ // a cart the server still considers live simply reopens on its remaining time. A read that fails says nothing
208
+ // about the cart and is no grounds to tear a live Peach session down - it is asked again.
209
+ async function confirmPaymentTimedOut() {
210
+ if (!cart || closed || !paymentInFlight || confirming) return;
211
+ // Already handed over and not yet answered: the fresh cart a hand-over reads back may carry a deadline
212
+ // that differs from the one the watcher fired for while still being past, and the watcher fires again for
213
+ // any deadline it has not fired for. One hand-over per unanswered timeout, not one per re-fire.
214
+ if (timedOutFallbackTimer !== null) return;
215
+ confirming = true;
216
+ clearRecheck();
217
+ let answer: 'past' | 'live' | 'gone' | 'unknown';
218
+ try {
219
+ cart = await api.getCart();
220
+ postMessage({ type: 'cart:updated', cart });
221
+ answer = cartDeadline(cart).getTime() <= Date.now() ? 'past' : 'live';
222
+ } catch (e) {
223
+ answer = isInvalidCart(e) ? 'gone' : 'unknown';
224
+ } finally {
225
+ confirming = false;
226
+ }
227
+ if (closed) return;
228
+
229
+ if (answer === 'gone') {
230
+ // The server has let this cart go despite our record of a payment in flight - the attempt must have been
231
+ // ended elsewhere (another tab) and the cart expired behind it. Same answer as anywhere else.
232
+ forgetPaymentAttempt();
233
+ paymentInFlight = false;
234
+ expire();
235
+ return;
236
+ }
237
+ if (answer === 'live') return;
238
+ // The payment may have ended while the read was in flight - CheckoutModal tears a resumed attempt down
239
+ // itself when it finds the deadline already past - and its payment:ended found this guard busy. Then there
240
+ // is nothing to hand over any more; this is the ordinary expiry question, asked afresh.
241
+ if (!paymentInFlight) {
242
+ void confirmExpiry();
243
+ return;
244
+ }
245
+ if (answer === 'past') handOverTimedOutPayment();
246
+ else scheduleRecheck();
247
+ }
248
+
249
+ // The clock ran out on the card form, on the server's own word or as near as this guard can get to it (see
250
+ // confirmPaymentTimedOut). CheckoutModal is told, and it tears the attempt down through the same
251
+ // Peach-verified abandon a cancel uses (so a charge that is actually landing is never torn down). Its
252
+ // payment:ended then brings the guard back here to ask, and the answer is the expired view.
253
+ function handOverTimedOutPayment() {
254
+ postMessage({ type: 'payment:timed-out' });
255
+ scheduleUnansweredTimeoutFallback();
256
+ }
257
+
258
+ // payment:timed-out only does anything if a CheckoutModal is mounted with the attempt open. After a reload
259
+ // the guard learns of the attempt from sessionStorage alone - the modal may never have been reopened - and
260
+ // then nobody would tear it down: paymentInFlight stays true, the watcher has already fired for this
261
+ // deadline, and the cart sits in limbo with no expiry ever shown. So if nothing has ended the payment shortly
262
+ // after the message, the guard abandons the attempt itself, through the same Peach-verified call the modal
263
+ // uses. A refusal because Peach has the charge as settled or still in flight is never torn down - that is
264
+ // money in motion - but it cannot be left to sit either: nothing else on the page would ever confirm it. So
265
+ // checkout is opened, the same way the cart bar's own button opens it; CheckoutModal resumes the attempt,
266
+ // runs into the same refusal, and drops into its confirm loop, which is where a settled charge belongs.
267
+ function scheduleUnansweredTimeoutFallback() {
268
+ clearTimedOutFallback();
269
+ timedOutFallbackTimer = setTimeout(() => {
270
+ timedOutFallbackTimer = null;
271
+ void abandonUnansweredTimedOutAttempt();
272
+ }, TimedOutFallbackMs);
273
+ }
274
+
275
+ async function abandonUnansweredTimedOutAttempt() {
276
+ if (!cart || closed || !paymentInFlight) return;
277
+ const attempt = recallPaymentAttempt(cart.cartToken ?? '');
278
+ if (!attempt) return;
279
+ try {
280
+ await api.abandonPayment(attempt.checkoutId);
281
+ } catch (e) {
282
+ if (isPaymentSettled(e) || isPaymentPending(e)) {
283
+ postMessage({ type: 'modal:open' });
284
+ return;
285
+ }
286
+ // Anything else (a blip): nothing changed server-side; reopening checkout lands back on the attempt.
287
+ return;
288
+ }
289
+ forgetPaymentAttempt();
290
+ paymentInFlight = false;
291
+ void confirmExpiry();
292
+ }
293
+
294
+ // One read settles it. A 401 means the server has let the cart go, so clearing it locally is safe. A live
295
+ // cart back means our clock is ahead of the server's: it is kept, on whatever deadline the server now
296
+ // reports (a moved one re-arms the watcher by itself, an unmoved one is asked about again shortly, since
297
+ // the watcher fires only once per deadline), and every other reader is told so their countdowns catch up.
298
+ // A read that fails for any other reason says nothing about the cart and is likewise no grounds to destroy
299
+ // it - it is simply asked again.
300
+ async function confirmExpiry() {
301
+ if (!cart || closed || paymentInFlight || confirming) return;
302
+ confirming = true;
303
+ clearRecheck();
304
+ try {
305
+ cart = await api.getCart();
306
+ postMessage({ type: 'cart:updated', cart });
307
+ if (cartDeadline(cart).getTime() <= Date.now()) scheduleRecheck();
308
+ } catch (e) {
309
+ if (isInvalidCart(e)) {
310
+ expire();
311
+ } else {
312
+ scheduleRecheck();
313
+ }
314
+ } finally {
315
+ confirming = false;
316
+ }
317
+ }
318
+
319
+ // Whichever question fits the cart's state by the time it fires - a payment may have started or ended since.
320
+ function scheduleRecheck() {
321
+ recheckTimer = setTimeout(() => {
322
+ recheckTimer = null;
323
+ if (paymentInFlight) void confirmPaymentTimedOut();
324
+ else void confirmExpiry();
325
+ }, ExpiryRecheckMs);
326
+ }
327
+
328
+ function expire() {
329
+ clearTimers();
330
+ closed = true;
331
+ cartManager.reset();
332
+ // Tells bw-cart/CartOverviewButton/CheckoutModal (if it happens to be mounted) the cart is gone, the same
333
+ // signal a manual remove-to-empty already sends - see CheckoutPanel's own onCartExpired for the other
334
+ // caller of this exact message. cart:expired follows it because a null cart alone does not say why: a
335
+ // confirmed order clears the cart the same way, and a host announcing "your cart expired" needs the two
336
+ // apart.
337
+ postMessage({ type: 'cart:updated', cart: null });
338
+ postMessage({ type: 'cart:expired' });
339
+ }
340
+
341
+ function startAgain() {
342
+ closed = false;
343
+ cart = null;
344
+ // If checkout happened to be open when this fired, there is nothing left in it to show.
345
+ postMessage({ type: 'modal:close' });
346
+ }
347
+
348
+ // This overlay covers the merchant's whole page and claims aria-modal, so it has to behave like a modal:
349
+ // Tab must not walk out of it into the page behind, and Escape must do something. Escape dismisses the
350
+ // warning for this deadline (exactly what the OK button does) - never closes the cart, which is not the
351
+ // shopper's to trigger and would be a destructive thing to hang off a stray keypress.
352
+ function onOverlayKeydown(event: KeyboardEvent) {
353
+ if (event.key === 'Escape' && showPrompt) {
354
+ event.preventDefault();
355
+ dismissWarning();
356
+ return;
357
+ }
358
+
359
+ if (event.key !== 'Tab') return;
360
+
361
+ const focusable = overlay?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])');
362
+ if (!focusable?.length) return;
363
+
364
+ const first = focusable[0];
365
+ const last = focusable[focusable.length - 1];
366
+ const wrappingBackwards = event.shiftKey && document.activeElement === first;
367
+ const wrappingForwards = !event.shiftKey && document.activeElement === last;
368
+ if (wrappingBackwards || wrappingForwards) {
369
+ event.preventDefault();
370
+ (event.shiftKey ? last : first).focus();
371
+ }
372
+ }
373
+
374
+ let overlay = $state<HTMLDivElement | null>(null);
375
+ </script>
376
+
377
+ <svelte:window onkeydown={showPrompt || closed ? onOverlayKeydown : undefined} />
378
+
379
+ {#if cart && expiryDeadline}
380
+ <CartExpiryWatcher expiresAt={expiryDeadline} onExpired={onDeadlinePassed} bind:remaining bind:warning />
381
+ {/if}
382
+
383
+ {#if showPrompt}
384
+ <div class="guard-overlay bw-widget" bind:this={overlay}>
385
+ <StillTherePrompt {remaining} {canExtend} {extending} onExtend={extend} onDismiss={dismissWarning} />
386
+ </div>
387
+ {:else if closed}
388
+ <div class="guard-overlay bw-widget" bind:this={overlay}>
389
+ <div class="guard-card">
390
+ <CartExpiredView onStartAgain={startAgain} />
391
+ </div>
392
+ </div>
393
+ {/if}
394
+
395
+ <style>
396
+ .guard-overlay {
397
+ position: fixed;
398
+ inset: 0;
399
+ /* Above every other layer this widget draws, including bw-checkout's own .bw-modal-overlay (10000) and
400
+ PaymentPage's .payment-overlay (10001) - this has to be able to interrupt someone mid-payment too. */
401
+ z-index: 20000;
402
+ display: flex;
403
+ align-items: center;
404
+ justify-content: center;
405
+ padding: 16px;
406
+ background: rgba(0, 0, 0, 0.45);
407
+ }
408
+ .guard-card {
409
+ width: 100%;
410
+ max-width: 360px;
411
+ background: var(--bw-color-bg);
412
+ border-radius: var(--bw-radius-lg);
413
+ box-shadow: var(--bw-shadow-3);
414
+ overflow: hidden;
415
+ }
416
+ </style>