@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,331 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, render, screen } from '@testing-library/svelte';
|
|
3
|
+
import CartExpiryGuard from './CartExpiryGuard.svelte';
|
|
4
|
+
import { postMessage, postedTypes, posted, resetMessages } from './test/messages-mock';
|
|
5
|
+
import { apiError, buildCart, CART_TOKEN, fakeApi, fakeCartManager, settle } from './test/fixtures';
|
|
6
|
+
import { EXPIRY_WARNING_MS, EXTENSION_ENABLED } from './cart-expiry';
|
|
7
|
+
import { recallPaymentAttempt, rememberPaymentAttempt } from './payment-attempt';
|
|
8
|
+
|
|
9
|
+
vi.mock('./messages', async () => await import('./test/messages-mock'));
|
|
10
|
+
|
|
11
|
+
// The guard owns the whole "one clock" experience on the client: the still-there prompt, the extend, and the
|
|
12
|
+
// server-confirmed expiry. Time is faked so a 20-minute cart can be walked to its deadline in a test, and the
|
|
13
|
+
// server is a fake BookingApi whose answers each scenario sets.
|
|
14
|
+
|
|
15
|
+
const MINUTE = 60_000;
|
|
16
|
+
|
|
17
|
+
// The prompt is switched off in production for now (EXTENSION_ENABLED in cart-expiry.ts); these tests turn it
|
|
18
|
+
// on explicitly so the extension path stays covered for the day it is turned back on. The "deadline passing"
|
|
19
|
+
// tests below run with it on too - the expiry behaviour must be identical either way - and one test checks
|
|
20
|
+
// the default.
|
|
21
|
+
function mount(cart = buildCart(EXPIRY_WARNING_MS + MINUTE), extensionEnabled = true) {
|
|
22
|
+
const api = fakeApi(cart);
|
|
23
|
+
const cartManager = fakeCartManager();
|
|
24
|
+
render(CartExpiryGuard, { props: { api, cartManager, extensionEnabled } });
|
|
25
|
+
return { api, cartManager, cart };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function advance(ms: number) {
|
|
29
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
30
|
+
await settle();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
|
|
35
|
+
vi.setSystemTime(new Date('2026-09-12T10:00:00Z'));
|
|
36
|
+
resetMessages();
|
|
37
|
+
sessionStorage.clear();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
cleanup();
|
|
42
|
+
vi.useRealTimers();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('with the extension switched off (the current default)', () => {
|
|
46
|
+
it('never shows the prompt - the shopper just sees the cart expire at 0:00', async () => {
|
|
47
|
+
// Mounted with the production default (EXTENSION_ENABLED, currently false), not this file's opt-in.
|
|
48
|
+
expect(EXTENSION_ENABLED).toBe(false);
|
|
49
|
+
const { api } = mount(buildCart(EXPIRY_WARNING_MS - 1000), EXTENSION_ENABLED);
|
|
50
|
+
await settle();
|
|
51
|
+
await advance(1000);
|
|
52
|
+
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
53
|
+
|
|
54
|
+
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
55
|
+
await advance(EXPIRY_WARNING_MS);
|
|
56
|
+
|
|
57
|
+
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
58
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
59
|
+
expect(api.extendCart).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('the still-there prompt', () => {
|
|
64
|
+
it('appears once the deadline is within the warning window', async () => {
|
|
65
|
+
mount();
|
|
66
|
+
await settle();
|
|
67
|
+
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
68
|
+
|
|
69
|
+
await advance(MINUTE + 1000);
|
|
70
|
+
|
|
71
|
+
expect(screen.getByText('Are you still there?')).toBeTruthy();
|
|
72
|
+
expect(screen.getByRole('button', { name: "Yes, I'm still here" })).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is still shown while a Peach checkout is open - the clock keeps running on the card form', async () => {
|
|
76
|
+
mount();
|
|
77
|
+
await settle();
|
|
78
|
+
postMessage({ type: 'payment:started' });
|
|
79
|
+
|
|
80
|
+
await advance(MINUTE + 1000);
|
|
81
|
+
|
|
82
|
+
expect(screen.getByText('Are you still there?')).toBeTruthy();
|
|
83
|
+
expect(screen.getByRole('button', { name: "Yes, I'm still here" })).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('Yes extends the cart and tells every other reader about the new deadline', async () => {
|
|
87
|
+
const { api } = mount();
|
|
88
|
+
await settle();
|
|
89
|
+
await advance(MINUTE + 1000);
|
|
90
|
+
const extended = buildCart(15 * MINUTE);
|
|
91
|
+
api.extendCart = vi.fn().mockResolvedValue(extended);
|
|
92
|
+
|
|
93
|
+
screen.getByRole('button', { name: "Yes, I'm still here" }).click();
|
|
94
|
+
await settle();
|
|
95
|
+
|
|
96
|
+
expect(api.extendCart).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
98
|
+
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: extended });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('a refused hold extension (deadline comes back unmoved) just closes the prompt and leaves the clock', async () => {
|
|
102
|
+
const { api, cart } = mount();
|
|
103
|
+
await settle();
|
|
104
|
+
await advance(MINUTE + 1000);
|
|
105
|
+
api.extendCart = vi.fn().mockResolvedValue({ ...cart });
|
|
106
|
+
|
|
107
|
+
screen.getByRole('button', { name: "Yes, I'm still here" }).click();
|
|
108
|
+
await settle();
|
|
109
|
+
|
|
110
|
+
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
111
|
+
expect(screen.queryByText(/can't be held any longer/)).toBeNull();
|
|
112
|
+
// Not re-shown for the same deadline on the next tick.
|
|
113
|
+
await advance(5000);
|
|
114
|
+
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
115
|
+
// The clock is untouched: at the original deadline the guard asks the server, and expires on its 401.
|
|
116
|
+
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
117
|
+
await advance(EXPIRY_WARNING_MS);
|
|
118
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('only warns, without a Yes, once the cart is at its absolute ceiling', async () => {
|
|
122
|
+
mount(buildCart(2 * MINUTE, { ceilingMsFromNow: 2 * MINUTE }));
|
|
123
|
+
await settle();
|
|
124
|
+
await advance(1000);
|
|
125
|
+
|
|
126
|
+
expect(screen.getByText(/can't be held any longer/)).toBeTruthy();
|
|
127
|
+
expect(screen.queryByRole('button', { name: "Yes, I'm still here" })).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('the deadline passing', () => {
|
|
132
|
+
it('asks the server and shows the cart as expired on its 401, clearing the cart everywhere', async () => {
|
|
133
|
+
const { api, cartManager } = mount(buildCart(2000));
|
|
134
|
+
await settle();
|
|
135
|
+
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
136
|
+
|
|
137
|
+
await advance(3000);
|
|
138
|
+
|
|
139
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
140
|
+
expect(cartManager.reset).toHaveBeenCalledTimes(1);
|
|
141
|
+
// cart:expired is the more specific signal, posted right before the general cart:updated/null every
|
|
142
|
+
// other reader already listens for.
|
|
143
|
+
expect(postedTypes().slice(-2)).toEqual(['cart:expired', 'cart:updated']);
|
|
144
|
+
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: null });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('keeps a cart the server still reports live - the browser clock only decides when to ask', async () => {
|
|
148
|
+
const { api, cartManager } = mount(buildCart(2000));
|
|
149
|
+
await settle();
|
|
150
|
+
const stillLive = buildCart(2000);
|
|
151
|
+
api.getCart = vi.fn().mockResolvedValue(stillLive);
|
|
152
|
+
|
|
153
|
+
await advance(3000);
|
|
154
|
+
|
|
155
|
+
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
156
|
+
expect(cartManager.reset).not.toHaveBeenCalled();
|
|
157
|
+
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: stillLive });
|
|
158
|
+
// Exactly one read, then a 30s recheck - not a tight loop. Re-reading the cart hands the watcher a fresh
|
|
159
|
+
// Date for the same past deadline, and a watcher that fired once per effect run rather than once per
|
|
160
|
+
// deadline re-fired straight away, asking again, and again (CartExpiryWatcher's firedFor).
|
|
161
|
+
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
162
|
+
// The recheck was scheduled at T-0, one second into the advance above.
|
|
163
|
+
await advance(28_000);
|
|
164
|
+
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
165
|
+
await advance(2000);
|
|
166
|
+
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('with a payment in flight, asks the server first and hands a deadline it still reports as past to CheckoutModal', async () => {
|
|
170
|
+
const { api } = mount(buildCart(2000));
|
|
171
|
+
await settle();
|
|
172
|
+
postMessage({ type: 'payment:started' });
|
|
173
|
+
api.getCart = vi.fn().mockResolvedValue(buildCart(-1000));
|
|
174
|
+
|
|
175
|
+
await advance(3000);
|
|
176
|
+
|
|
177
|
+
// One ask, one hand-over - even though the cart read back carries a (still past) deadline the watcher had
|
|
178
|
+
// not fired for yet.
|
|
179
|
+
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(postedTypes().filter((type) => type === 'payment:timed-out')).toHaveLength(1);
|
|
181
|
+
expect(postedTypes()).toContain('payment:timed-out');
|
|
182
|
+
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('with a payment in flight, keeps a cart the server reports on a later deadline rather than tearing the attempt down', async () => {
|
|
186
|
+
// Another tab answered the prompt, or the server pushed the clock out to the payment floor when the
|
|
187
|
+
// attempt began and this guard never heard - either way the browser's deadline was stale, and acting on
|
|
188
|
+
// it would have destroyed a card form the shopper may be mid-way through.
|
|
189
|
+
const { api } = mount(buildCart(2000));
|
|
190
|
+
await settle();
|
|
191
|
+
postMessage({ type: 'payment:started' });
|
|
192
|
+
const movedOut = buildCart(5 * MINUTE);
|
|
193
|
+
api.getCart = vi.fn().mockResolvedValue(movedOut);
|
|
194
|
+
|
|
195
|
+
await advance(3000);
|
|
196
|
+
|
|
197
|
+
expect(postedTypes()).not.toContain('payment:timed-out');
|
|
198
|
+
expect(api.abandonPayment).not.toHaveBeenCalled();
|
|
199
|
+
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: movedOut });
|
|
200
|
+
// The watcher re-armed on the later deadline and asks again there; this time the server agrees.
|
|
201
|
+
await advance(5 * MINUTE);
|
|
202
|
+
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
203
|
+
expect(postedTypes()).toContain('payment:timed-out');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('with a payment in flight, a read that fails is no grounds to tear the attempt down - it asks again', async () => {
|
|
207
|
+
const { api } = mount(buildCart(2000));
|
|
208
|
+
await settle();
|
|
209
|
+
postMessage({ type: 'payment:started' });
|
|
210
|
+
api.getCart = vi.fn().mockRejectedValueOnce(apiError(500)).mockResolvedValue(buildCart(-1000));
|
|
211
|
+
|
|
212
|
+
await advance(3000);
|
|
213
|
+
expect(postedTypes()).not.toContain('payment:timed-out');
|
|
214
|
+
|
|
215
|
+
await advance(30_000);
|
|
216
|
+
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
217
|
+
expect(postedTypes()).toContain('payment:timed-out');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('after the attempt is torn down, payment:ended brings it back to confirm the expiry', async () => {
|
|
221
|
+
const { api } = mount(buildCart(2000));
|
|
222
|
+
await settle();
|
|
223
|
+
postMessage({ type: 'payment:started' });
|
|
224
|
+
await advance(3000);
|
|
225
|
+
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
226
|
+
|
|
227
|
+
postMessage({ type: 'payment:ended' });
|
|
228
|
+
await settle();
|
|
229
|
+
|
|
230
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('with no checkout modal open to answer payment:timed-out, the guard abandons the attempt itself', async () => {
|
|
234
|
+
// After a reload the guard knows of the attempt from sessionStorage alone; nothing else would ever tear
|
|
235
|
+
// it down, so the cart would sit in limbo with no expiry shown.
|
|
236
|
+
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
237
|
+
const { api } = mount(buildCart(2000));
|
|
238
|
+
await settle();
|
|
239
|
+
// The server still holds the cart for the attempt (a cart awaiting payment is exempt from the 401), so the
|
|
240
|
+
// ask at T-0 gets it back unmoved; only once the attempt is abandoned does the reopened, past-deadline cart
|
|
241
|
+
// stop validating.
|
|
242
|
+
api.getCart = vi.fn().mockResolvedValueOnce(buildCart(-1000)).mockRejectedValue(apiError(401));
|
|
243
|
+
|
|
244
|
+
await advance(3000);
|
|
245
|
+
expect(postedTypes()).toContain('payment:timed-out');
|
|
246
|
+
expect(api.abandonPayment).not.toHaveBeenCalled();
|
|
247
|
+
|
|
248
|
+
await advance(5000);
|
|
249
|
+
|
|
250
|
+
expect(api.abandonPayment).toHaveBeenCalledWith('peach-1');
|
|
251
|
+
expect(recallPaymentAttempt(CART_TOKEN)).toBeNull();
|
|
252
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('a payment that ends while the guard is still asking the server falls through to the ordinary expiry check', async () => {
|
|
256
|
+
// CheckoutModal tears a resumed attempt down itself when it finds the deadline already past, and its
|
|
257
|
+
// payment:ended can land while this guard's own read is in flight. Nothing may be handed over after that,
|
|
258
|
+
// and the expiry still has to be confirmed and shown.
|
|
259
|
+
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
260
|
+
const { api } = mount(buildCart(2000));
|
|
261
|
+
await settle();
|
|
262
|
+
let answerTheRead: (cart: ReturnType<typeof buildCart>) => void = () => {};
|
|
263
|
+
const pendingRead = new Promise<ReturnType<typeof buildCart>>((resolve) => { answerTheRead = resolve; });
|
|
264
|
+
api.getCart = vi.fn().mockReturnValueOnce(pendingRead).mockRejectedValue(apiError(401));
|
|
265
|
+
|
|
266
|
+
await advance(3000);
|
|
267
|
+
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
268
|
+
postMessage({ type: 'payment:ended' });
|
|
269
|
+
await settle();
|
|
270
|
+
answerTheRead(buildCart(-1000));
|
|
271
|
+
await settle();
|
|
272
|
+
|
|
273
|
+
expect(postedTypes()).not.toContain('payment:timed-out');
|
|
274
|
+
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
275
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('drops the unanswered-timeout fallback when the guard is unmounted before it fires', async () => {
|
|
279
|
+
// A pending fallback outliving the guard would abandon a payment attempt on behalf of a page that no
|
|
280
|
+
// longer exists.
|
|
281
|
+
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
282
|
+
const { api } = mount(buildCart(2000));
|
|
283
|
+
await settle();
|
|
284
|
+
|
|
285
|
+
await advance(3000);
|
|
286
|
+
expect(postedTypes()).toContain('payment:timed-out');
|
|
287
|
+
cleanup();
|
|
288
|
+
|
|
289
|
+
await advance(5000);
|
|
290
|
+
|
|
291
|
+
expect(api.abandonPayment).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it.each(['PAYMENT_PENDING', 'PAYMENT_SETTLED'])(
|
|
295
|
+
'opens checkout instead of tearing down an attempt Peach reports as live (%s), even with no modal to answer',
|
|
296
|
+
async (code) => {
|
|
297
|
+
// Money in motion is never torn down, but nothing else on the page would confirm it either: reopening
|
|
298
|
+
// checkout resumes the attempt into the modal's confirm loop.
|
|
299
|
+
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
300
|
+
const { api } = mount(buildCart(2000));
|
|
301
|
+
await settle();
|
|
302
|
+
api.abandonPayment = vi.fn().mockRejectedValue(apiError(409, code));
|
|
303
|
+
api.getCart = vi.fn().mockResolvedValue(buildCart(-1000));
|
|
304
|
+
|
|
305
|
+
await advance(3000 + 5000);
|
|
306
|
+
|
|
307
|
+
expect(api.abandonPayment).toHaveBeenCalledWith('peach-1');
|
|
308
|
+
expect(postedTypes()).toContain('modal:open');
|
|
309
|
+
// One read: the ask at T-0. Nothing asked about expiry after the refusal - the attempt is left as it is.
|
|
310
|
+
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
311
|
+
expect(recallPaymentAttempt(CART_TOKEN)).not.toBeNull();
|
|
312
|
+
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
it("a late Yes the server refuses with CART_EXPIRED takes the same tear-down route", async () => {
|
|
317
|
+
const { api } = mount();
|
|
318
|
+
await settle();
|
|
319
|
+
postMessage({ type: 'payment:started' });
|
|
320
|
+
await advance(MINUTE + 1000);
|
|
321
|
+
api.extendCart = vi.fn().mockRejectedValue(apiError(409, 'CART_EXPIRED'));
|
|
322
|
+
|
|
323
|
+
screen.getByRole('button', { name: "Yes, I'm still here" }).click();
|
|
324
|
+
await settle();
|
|
325
|
+
|
|
326
|
+
expect(postedTypes()).toContain('payment:timed-out');
|
|
327
|
+
// CART_EXPIRED is the server's own word - nothing to ask before handing over.
|
|
328
|
+
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
329
|
+
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { EXPIRY_WARNING_MS, formatRemaining } from './cart-expiry';
|
|
4
|
+
|
|
5
|
+
// Headless: keeps the clock and reports it, renders nothing. The owner decides what a warning looks like -
|
|
6
|
+
// a blocking "are you still there?" while shopping, a non-blocking banner over a card form the shopper may
|
|
7
|
+
// be mid-way through typing into.
|
|
8
|
+
interface Props {
|
|
9
|
+
expiresAt: Date;
|
|
10
|
+
// Fires once per deadline, the moment it passes. A new expiresAt (an extension, an add) re-arms it.
|
|
11
|
+
onExpired: () => void;
|
|
12
|
+
remaining?: string;
|
|
13
|
+
warning?: boolean;
|
|
14
|
+
}
|
|
15
|
+
let { expiresAt, onExpired, remaining = $bindable(''), warning = $bindable(false) }: Props = $props();
|
|
16
|
+
|
|
17
|
+
// The deadline (ms) onExpired last fired for. Kept outside the effect and keyed to the *value*, not to the
|
|
18
|
+
// effect run: the owner re-derives expiresAt as a fresh Date whenever its cart object changes, which
|
|
19
|
+
// includes the owner re-reading the same cart from the server at T-0 - and that read is exactly what
|
|
20
|
+
// onExpired triggers. A per-run flag re-armed on every such change fired onExpired again for the same past
|
|
21
|
+
// deadline, which asked the server again, which re-armed it again: a tight loop of reads against a cart the
|
|
22
|
+
// server considers live, ending only when the deadline actually moved. Plain let, not $state - nothing
|
|
23
|
+
// rendered depends on it.
|
|
24
|
+
let firedFor: number | null = null;
|
|
25
|
+
|
|
26
|
+
$effect(() => {
|
|
27
|
+
const deadline = expiresAt.getTime();
|
|
28
|
+
|
|
29
|
+
function tick() {
|
|
30
|
+
const diff = deadline - Date.now();
|
|
31
|
+
remaining = formatRemaining(diff);
|
|
32
|
+
warning = diff > 0 && diff <= EXPIRY_WARNING_MS;
|
|
33
|
+
if (diff <= 0 && firedFor !== deadline) {
|
|
34
|
+
firedFor = deadline;
|
|
35
|
+
// untrack the prop *read* itself, not just the call - the owner passes an inline callback, so a
|
|
36
|
+
// tracked read of onExpired here (not just of expiresAt above) would re-run this effect on every
|
|
37
|
+
// parent render, resetting `fired` and firing the same expiry again.
|
|
38
|
+
untrack(() => onExpired)();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
tick();
|
|
43
|
+
const interval = setInterval(tick, 1000);
|
|
44
|
+
return () => clearInterval(interval);
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { BookingApi } from './api';
|
|
4
4
|
import { CartManager } from './cart-manager';
|
|
5
5
|
import { onWidgetMessage, postMessage } from './messages';
|
|
6
|
+
import { formatRemaining, cartDeadline } from './cart-expiry';
|
|
6
7
|
import { onDestroy } from 'svelte';
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
@@ -34,11 +35,7 @@
|
|
|
34
35
|
}));
|
|
35
36
|
|
|
36
37
|
function updateTimer() {
|
|
37
|
-
|
|
38
|
-
const diff = Math.max(0, new Date(cart.absoluteExpiresAt).getTime() - Date.now());
|
|
39
|
-
const mins = Math.floor(diff / 60000);
|
|
40
|
-
const secs = Math.floor((diff % 60000) / 1000);
|
|
41
|
-
remaining = `${mins}:${String(secs).padStart(2, '0')}`;
|
|
38
|
+
remaining = cart ? formatRemaining(cartDeadline(cart).getTime() - Date.now()) : formatRemaining(0);
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
updateTimer();
|
package/src/lib/Checkout.svelte
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import type { WizardPages } from './config';
|
|
4
4
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
5
5
|
import type { CheckoutCartDetailDto } from './client-types';
|
|
6
|
+
import { isInvalidCart } from './api';
|
|
6
7
|
import { onWidgetMessage, postMessage } from './messages';
|
|
7
8
|
import type { CartManager } from './cart-manager';
|
|
8
9
|
import CheckoutModal from './CheckoutModal.svelte';
|
|
10
|
+
import CartExpiredView from './CartExpiredView.svelte';
|
|
9
11
|
|
|
10
12
|
interface Props {
|
|
11
13
|
api: BookingApi;
|
|
@@ -20,25 +22,61 @@
|
|
|
20
22
|
|
|
21
23
|
let cart = $state<CheckoutCartDetailDto | null>(null);
|
|
22
24
|
let isLoading = $state(true);
|
|
25
|
+
let expired = $state(false);
|
|
23
26
|
|
|
24
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 !== '';
|
|
25
32
|
try {
|
|
26
33
|
cart = await api.getCart();
|
|
27
|
-
} catch {
|
|
34
|
+
} catch (e) {
|
|
28
35
|
cart = null;
|
|
36
|
+
if (hadCart && isInvalidCart(e)) {
|
|
37
|
+
onCartExpired();
|
|
38
|
+
expired = true;
|
|
39
|
+
}
|
|
29
40
|
}
|
|
30
41
|
isLoading = false;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
load();
|
|
34
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
|
+
// See CartExpiryGuard.svelte's own expire() for why this is posted alongside, and before, cart:updated.
|
|
52
|
+
postMessage({ type: 'cart:expired' });
|
|
53
|
+
postMessage({ type: 'cart:updated', cart: null });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// In modal mode, modal:close is what actually dismisses this screen - bw-checkout.svelte remounts this
|
|
57
|
+
// component fresh the next time it opens, so the click is fully handled there. Rendered permanently
|
|
58
|
+
// in-page instead, nothing is listening for modal:close, so without the reload below the click would do
|
|
59
|
+
// nothing a shopper can see: expired flips off, but cart is still the stale null from the load() that
|
|
60
|
+
// found it gone, leaving the same "No items in cart" text up with no sign anything happened.
|
|
61
|
+
function startAgain() {
|
|
62
|
+
expired = false;
|
|
63
|
+
postMessage({ type: 'modal:close' });
|
|
64
|
+
void load();
|
|
65
|
+
}
|
|
66
|
+
|
|
35
67
|
// In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
|
|
36
68
|
// modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
|
|
37
69
|
// would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
|
|
38
70
|
// cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
|
|
39
71
|
// triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
|
|
72
|
+
//
|
|
73
|
+
// cart:expired is CartExpiryGuard's own signal that the clock (not a manual clear) is why the cart just
|
|
74
|
+
// went null - it fires page-wide, including while this modal is already open showing a live cart, and
|
|
75
|
+
// without it this falls through to the plain "No items in cart" branch below instead of CartExpiredView,
|
|
76
|
+
// exactly as if the shopper had simply never had anything in their cart at all.
|
|
40
77
|
$effect(() => onWidgetMessage((d) => {
|
|
41
78
|
if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
79
|
+
if (d.type === 'cart:expired') expired = true;
|
|
42
80
|
}));
|
|
43
81
|
</script>
|
|
44
82
|
|
|
@@ -70,6 +108,10 @@
|
|
|
70
108
|
cartManager?.reset();
|
|
71
109
|
}}
|
|
72
110
|
/>
|
|
111
|
+
{:else if expired}
|
|
112
|
+
<div style="height:100vh">
|
|
113
|
+
<CartExpiredView onStartAgain={startAgain} />
|
|
114
|
+
</div>
|
|
73
115
|
{:else}
|
|
74
116
|
<div class="loading-center" style="height:100vh;color:var(--bw-color-text-secondary)">
|
|
75
117
|
No items in cart.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, render, screen } from '@testing-library/svelte';
|
|
3
|
+
import CheckoutModal from './CheckoutModal.svelte';
|
|
4
|
+
import { resetMessages } from './test/messages-mock';
|
|
5
|
+
import { apiError, buildCart, CART_TOKEN, fakeApi, installFakePeachSdk, settle } from './test/fixtures';
|
|
6
|
+
import { rememberPaymentAttempt } from './payment-attempt';
|
|
7
|
+
|
|
8
|
+
vi.mock('./messages', async () => await import('./test/messages-mock'));
|
|
9
|
+
|
|
10
|
+
// confirmCart answers with a bare 402/409 both while Peach's webhook genuinely hasn't landed yet (worth
|
|
11
|
+
// retrying) and once it has landed with a decline (nothing left to wait for) - PAYMENT_FAILED is the one
|
|
12
|
+
// coded exception to that. Regression coverage for the bug this used to have: every one of these outcomes
|
|
13
|
+
// used to retry into the same generic "still confirming" pending screen, so a declined card never told the
|
|
14
|
+
// shopper their card was declined.
|
|
15
|
+
|
|
16
|
+
const ATTEMPT = { checkoutId: 'peach-checkout-1', entityId: 'entity-1' };
|
|
17
|
+
|
|
18
|
+
// Mirrors CheckoutModal.payment-timeout.test.ts's own approach: a remembered attempt makes CheckoutModal
|
|
19
|
+
// resume straight onto the card form on mount (resumeInterruptedPayment), the same place a real payCart()
|
|
20
|
+
// success lands, without having to drive the contact form through the UI first.
|
|
21
|
+
function mount(cart = buildCart(10 * 60_000)) {
|
|
22
|
+
rememberPaymentAttempt(CART_TOKEN, ATTEMPT);
|
|
23
|
+
const api = fakeApi(cart);
|
|
24
|
+
const onClose = vi.fn();
|
|
25
|
+
const onOrderConfirmed = vi.fn();
|
|
26
|
+
const peach = installFakePeachSdk();
|
|
27
|
+
render(CheckoutModal, { props: { cart, api, onClose, onOrderConfirmed } });
|
|
28
|
+
return { api, onClose, onOrderConfirmed, peach };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
|
|
33
|
+
vi.setSystemTime(new Date('2026-09-12T10:00:00Z'));
|
|
34
|
+
resetMessages();
|
|
35
|
+
sessionStorage.clear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
cleanup();
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
delete (window as unknown as { Checkout?: unknown }).Checkout;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('a card Peach declined', () => {
|
|
45
|
+
it('shows Payment Failed with a Try Again button, on the first confirm attempt, not another "still confirming"', async () => {
|
|
46
|
+
const { api, peach } = mount();
|
|
47
|
+
api.confirmCart = vi.fn().mockRejectedValue(apiError(409, 'PAYMENT_FAILED'));
|
|
48
|
+
await settle();
|
|
49
|
+
|
|
50
|
+
peach.handlers().onCompleted();
|
|
51
|
+
await settle();
|
|
52
|
+
|
|
53
|
+
expect(api.confirmCart).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(screen.getByText('Payment Failed')).toBeTruthy();
|
|
55
|
+
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
|
|
56
|
+
expect(screen.queryByText('Payment Received')).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not retry the way a webhook-not-landed-yet 402 does', async () => {
|
|
60
|
+
const { api, peach } = mount();
|
|
61
|
+
api.confirmCart = vi.fn().mockRejectedValue(apiError(409, 'PAYMENT_FAILED'));
|
|
62
|
+
await settle();
|
|
63
|
+
|
|
64
|
+
peach.handlers().onCompleted();
|
|
65
|
+
await settle();
|
|
66
|
+
|
|
67
|
+
// Even if the modal were still retrying (the bug this guards against), the retry delay would need real
|
|
68
|
+
// time to elapse - advancing well past it and confirming the call count never grows proves it settled
|
|
69
|
+
// immediately instead.
|
|
70
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
71
|
+
await settle();
|
|
72
|
+
|
|
73
|
+
expect(api.confirmCart).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('a genuinely still-pending webhook, for contrast', () => {
|
|
78
|
+
it('keeps retrying a plain 402 rather than treating it as a decline', async () => {
|
|
79
|
+
const { api, peach } = mount();
|
|
80
|
+
api.confirmCart = vi.fn().mockRejectedValue(apiError(402));
|
|
81
|
+
await settle();
|
|
82
|
+
|
|
83
|
+
peach.handlers().onCompleted();
|
|
84
|
+
await settle();
|
|
85
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
86
|
+
await settle();
|
|
87
|
+
|
|
88
|
+
expect(api.confirmCart.mock.calls.length).toBeGreaterThan(1);
|
|
89
|
+
expect(screen.queryByText('Payment Failed')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|