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