@code-collective/booking-widget 1.0.10 → 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.
- package/CHANGELOG.md +48 -0
- package/README.md +153 -41
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +2637 -1924
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +34 -16
- package/dist/booking-widget.umd.cjs +5 -3
- package/package.json +5 -2
- package/src/lib/BookingProvider.svelte +21 -0
- package/src/lib/CartBar.svelte +16 -31
- package/src/lib/CartBarView.svelte +20 -3
- package/src/lib/CartExpiryGuard.svelte +416 -410
- package/src/lib/CartOverview.svelte +16 -6
- package/src/lib/CartOverviewButton.svelte +29 -26
- package/src/lib/Checkout.svelte +111 -98
- package/src/lib/CheckoutModal.svelte +946 -805
- package/src/lib/CheckoutPanel.svelte +121 -0
- package/src/lib/PaymentPage.svelte +16 -2
- package/src/lib/PickupPointPicker.svelte +1 -1
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +19 -5
- package/src/lib/UnitCounter.svelte +16 -2
- package/src/lib/WizardPage.svelte +102 -35
- package/src/lib/app.css +0 -6
- package/src/lib/booking-context.ts +33 -0
- package/src/lib/cart-overview.svelte.ts +97 -0
- package/src/lib/client-types.ts +10 -6
- package/src/lib/config.ts +9 -0
- package/src/lib/elements/bw-cart.svelte +21 -7
- package/src/lib/elements/bw-checkout.svelte +54 -54
- package/src/lib/elements/bw-configurator.svelte +30 -14
- package/src/lib/elements/register.ts +96 -121
- package/src/lib/elements/shared.ts +4 -0
- package/src/lib/elements/theme.css +0 -6
- package/src/lib/host.svelte.ts +336 -0
- package/src/lib/index.ts +137 -91
- package/src/lib/layout.svelte.ts +52 -0
- package/src/lib/messages.ts +97 -17
- package/src/lib/peach-sdk.ts +51 -5
- package/src/lib/portal.ts +23 -0
- package/src/lib/CartExpiryGuard.test.ts +0 -331
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
- package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
- package/src/lib/test/fixtures.ts +0 -107
- package/src/lib/test/messages-mock.ts +0 -34
|
@@ -1,805 +1,946 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutCartPaymentInitiationDto, CheckoutCartConfirmResultDto, CheckoutProductDto } from './client-types';
|
|
3
|
-
import type { BookingApi } from './api';
|
|
4
|
-
import { ApiError, isInvalidCart, isPaymentFailed, isPaymentPending, isPaymentSettled } from './api';
|
|
5
|
-
import type { WizardPages } from './config';
|
|
6
|
-
import { formatCurrency } from './currency';
|
|
7
|
-
import { postMessage, onWidgetMessage } from './messages';
|
|
8
|
-
import { cartDeadline } from './cart-expiry';
|
|
9
|
-
import { rememberPaymentAttempt, recallPaymentAttempt, forgetPaymentAttempt } from './payment-attempt';
|
|
10
|
-
import { onDestroy } from 'svelte';
|
|
11
|
-
import ContactForm from './ContactForm.svelte';
|
|
12
|
-
import ConsentSection from './ConsentSection.svelte';
|
|
13
|
-
import CountdownTimer from './CountdownTimer.svelte';
|
|
14
|
-
import PaymentPage from './PaymentPage.svelte';
|
|
15
|
-
import ResultView from './ResultView.svelte';
|
|
16
|
-
import EditBookingView from './EditBookingView.svelte';
|
|
17
|
-
|
|
18
|
-
interface Props {
|
|
19
|
-
cart: CheckoutCartDetailDto;
|
|
20
|
-
api: BookingApi;
|
|
21
|
-
wizardPages?: WizardPages;
|
|
22
|
-
editPages?: WizardPages;
|
|
23
|
-
autoSelectSingleTimeSlot?: boolean;
|
|
24
|
-
onClose: () => void;
|
|
25
|
-
onOrderConfirmed: () => void;
|
|
26
|
-
}
|
|
27
|
-
let { cart, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onClose, onOrderConfirmed }: Props = $props();
|
|
28
|
-
|
|
29
|
-
// CartExpiryGuard.svelte (mounted once, page-wide) owns the "still shopping?" prompt, extend and close - it
|
|
30
|
-
// works whether or not this modal even exists. If it closes the cart while this happens to be open, there is
|
|
31
|
-
// nothing left in it to show. A cart that merely *moved* needs nothing here: Checkout.svelte subscribes to
|
|
32
|
-
// this same message and feeds the fresh cart straight back down as the `cart` prop, so the header countdown
|
|
33
|
-
// re-derives from it. Assigning to `cart` here instead would write to a prop this component does not own.
|
|
34
|
-
$effect(() => onWidgetMessage((d) => {
|
|
35
|
-
if (d.type === 'cart:updated' && 'cart' in d && d.cart === null) close();
|
|
36
|
-
if (d.type === 'payment:timed-out') void onPaymentTimedOut();
|
|
37
|
-
}));
|
|
38
|
-
|
|
39
|
-
// The cart's one clock ran out while Peach's card form was open (CartExpiryGuard cannot act on that itself -
|
|
40
|
-
// the server exempts a cart with a payment in flight from expiry so a landing charge can still be confirmed).
|
|
41
|
-
// The shopper should see the cart expire here like anywhere else, so the attempt is torn down through the
|
|
42
|
-
// same Peach-verified abandon a cancel uses: a charge Peach reports as landed or still in flight is never torn
|
|
43
|
-
// down (releaseAbandonedAttempt routes those to the confirm loop instead), and an attempt Peach confirms dead
|
|
44
|
-
// reopens the cart on its already-past deadline, so the reload that follows gets a 401 and the guard's
|
|
45
|
-
// payment:ended re-check shows "Your cart has expired". Nothing to do if no attempt is open in this modal.
|
|
46
|
-
async function onPaymentTimedOut(): Promise<void> {
|
|
47
|
-
if (currentView !== 'payment' || !paymentResult) return;
|
|
48
|
-
await releaseAbandonedAttempt();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Peach's webhook is typically near-instant, but is a genuinely separate async delivery from the card
|
|
52
|
-
// charge itself - 10 attempts 1.5s apart (~15s) comfortably covers ordinary delivery latency without
|
|
53
|
-
// making a shopper whose payment already succeeded wait an unreasonable time to see it confirmed.
|
|
54
|
-
const ConfirmMaxAttempts = 10;
|
|
55
|
-
const ConfirmRetryDelayMs = 1500;
|
|
56
|
-
|
|
57
|
-
type View = 'cart' | 'contact' | 'edit' | 'payment' | 'result';
|
|
58
|
-
let currentView = $state<View>('cart');
|
|
59
|
-
let editingItem = $state<CheckoutCartItemDetailDto | null>(null);
|
|
60
|
-
let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
|
|
61
|
-
let confirmResult = $state<CheckoutCartConfirmResultDto | null>(null);
|
|
62
|
-
// What the confirm attempt actually produced, once it's settled - not a plain success/failure boolean,
|
|
63
|
-
// because "payment succeeded but this gateway couldn't confirm it in time" and "payment itself never went
|
|
64
|
-
// through" need different copy and different actions (see the ResultView status computed below and
|
|
65
|
-
// runConfirmLoop's own remarks). null while still in flight.
|
|
66
|
-
let confirmOutcome = $state<'success' | 'pending' | 'partial' | 'notCharged' | null>(null);
|
|
67
|
-
let isLoading = $state(true);
|
|
68
|
-
let isConfirming = $state(false);
|
|
69
|
-
let privacyAccepted = $state(false);
|
|
70
|
-
let marketingOptIn = $state(false);
|
|
71
|
-
let hasAttemptedSubmit = $state(false);
|
|
72
|
-
let payError = $state<string | null>(null);
|
|
73
|
-
// Deliberately separate from isLoading above - that one gates the entire view switch below (spinner vs.
|
|
74
|
-
// cart/contact/payment/result), so reusing it here would unmount ContactForm for the duration of the
|
|
75
|
-
// payCart call and, on failure, remount it with its own $state reset to blank, forcing the shopper to
|
|
76
|
-
// retype everything before retrying. This only ever disables the Pay Now button itself.
|
|
77
|
-
let isPaying = $state(false);
|
|
78
|
-
|
|
79
|
-
// Owned here, not inside ContactForm, so the shopper's typed details survive navigating back to the cart
|
|
80
|
-
// and returning to checkout - see ContactForm's own doc comment on its bindable props for why.
|
|
81
|
-
let contactFirstName = $state('');
|
|
82
|
-
let contactLastName = $state('');
|
|
83
|
-
let contactEmail = $state('');
|
|
84
|
-
let contactPhone = $state('');
|
|
85
|
-
|
|
86
|
-
let contactForm = $state<ContactForm | null>(null);
|
|
87
|
-
|
|
88
|
-
// Load product data so we can display titles and unit info
|
|
89
|
-
let productsById = $state<Map<string, CheckoutProductDto>>(new Map());
|
|
90
|
-
|
|
91
|
-
async function loadProducts() {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
resumeInterruptedPayment()
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
for
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
<div class="
|
|
615
|
-
<
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutCartPaymentInitiationDto, CheckoutCartConfirmResultDto, CheckoutProductDto } from './client-types';
|
|
3
|
+
import type { BookingApi } from './api';
|
|
4
|
+
import { ApiError, isInvalidCart, isPaymentFailed, isPaymentPending, isPaymentSettled } from './api';
|
|
5
|
+
import type { WizardPages } from './config';
|
|
6
|
+
import { formatCurrency } from './currency';
|
|
7
|
+
import { postMessage, onWidgetMessage } from './messages';
|
|
8
|
+
import { cartDeadline } from './cart-expiry';
|
|
9
|
+
import { rememberPaymentAttempt, recallPaymentAttempt, forgetPaymentAttempt } from './payment-attempt';
|
|
10
|
+
import { onDestroy } from 'svelte';
|
|
11
|
+
import ContactForm from './ContactForm.svelte';
|
|
12
|
+
import ConsentSection from './ConsentSection.svelte';
|
|
13
|
+
import CountdownTimer from './CountdownTimer.svelte';
|
|
14
|
+
import PaymentPage from './PaymentPage.svelte';
|
|
15
|
+
import ResultView from './ResultView.svelte';
|
|
16
|
+
import EditBookingView from './EditBookingView.svelte';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
cart: CheckoutCartDetailDto;
|
|
20
|
+
api: BookingApi;
|
|
21
|
+
wizardPages?: WizardPages;
|
|
22
|
+
editPages?: WizardPages;
|
|
23
|
+
autoSelectSingleTimeSlot?: boolean;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
onOrderConfirmed: () => void;
|
|
26
|
+
}
|
|
27
|
+
let { cart, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onClose, onOrderConfirmed }: Props = $props();
|
|
28
|
+
|
|
29
|
+
// CartExpiryGuard.svelte (mounted once, page-wide) owns the "still shopping?" prompt, extend and close - it
|
|
30
|
+
// works whether or not this modal even exists. If it closes the cart while this happens to be open, there is
|
|
31
|
+
// nothing left in it to show. A cart that merely *moved* needs nothing here: Checkout.svelte subscribes to
|
|
32
|
+
// this same message and feeds the fresh cart straight back down as the `cart` prop, so the header countdown
|
|
33
|
+
// re-derives from it. Assigning to `cart` here instead would write to a prop this component does not own.
|
|
34
|
+
$effect(() => onWidgetMessage((d) => {
|
|
35
|
+
if (d.type === 'cart:updated' && 'cart' in d && d.cart === null) close();
|
|
36
|
+
if (d.type === 'payment:timed-out') void onPaymentTimedOut();
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// The cart's one clock ran out while Peach's card form was open (CartExpiryGuard cannot act on that itself -
|
|
40
|
+
// the server exempts a cart with a payment in flight from expiry so a landing charge can still be confirmed).
|
|
41
|
+
// The shopper should see the cart expire here like anywhere else, so the attempt is torn down through the
|
|
42
|
+
// same Peach-verified abandon a cancel uses: a charge Peach reports as landed or still in flight is never torn
|
|
43
|
+
// down (releaseAbandonedAttempt routes those to the confirm loop instead), and an attempt Peach confirms dead
|
|
44
|
+
// reopens the cart on its already-past deadline, so the reload that follows gets a 401 and the guard's
|
|
45
|
+
// payment:ended re-check shows "Your cart has expired". Nothing to do if no attempt is open in this modal.
|
|
46
|
+
async function onPaymentTimedOut(): Promise<void> {
|
|
47
|
+
if (currentView !== 'payment' || !paymentResult) return;
|
|
48
|
+
await releaseAbandonedAttempt();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Peach's webhook is typically near-instant, but is a genuinely separate async delivery from the card
|
|
52
|
+
// charge itself - 10 attempts 1.5s apart (~15s) comfortably covers ordinary delivery latency without
|
|
53
|
+
// making a shopper whose payment already succeeded wait an unreasonable time to see it confirmed.
|
|
54
|
+
const ConfirmMaxAttempts = 10;
|
|
55
|
+
const ConfirmRetryDelayMs = 1500;
|
|
56
|
+
|
|
57
|
+
type View = 'cart' | 'contact' | 'edit' | 'payment' | 'result';
|
|
58
|
+
let currentView = $state<View>('cart');
|
|
59
|
+
let editingItem = $state<CheckoutCartItemDetailDto | null>(null);
|
|
60
|
+
let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
|
|
61
|
+
let confirmResult = $state<CheckoutCartConfirmResultDto | null>(null);
|
|
62
|
+
// What the confirm attempt actually produced, once it's settled - not a plain success/failure boolean,
|
|
63
|
+
// because "payment succeeded but this gateway couldn't confirm it in time" and "payment itself never went
|
|
64
|
+
// through" need different copy and different actions (see the ResultView status computed below and
|
|
65
|
+
// runConfirmLoop's own remarks). null while still in flight.
|
|
66
|
+
let confirmOutcome = $state<'success' | 'pending' | 'partial' | 'notCharged' | null>(null);
|
|
67
|
+
let isLoading = $state(true);
|
|
68
|
+
let isConfirming = $state(false);
|
|
69
|
+
let privacyAccepted = $state(false);
|
|
70
|
+
let marketingOptIn = $state(false);
|
|
71
|
+
let hasAttemptedSubmit = $state(false);
|
|
72
|
+
let payError = $state<string | null>(null);
|
|
73
|
+
// Deliberately separate from isLoading above - that one gates the entire view switch below (spinner vs.
|
|
74
|
+
// cart/contact/payment/result), so reusing it here would unmount ContactForm for the duration of the
|
|
75
|
+
// payCart call and, on failure, remount it with its own $state reset to blank, forcing the shopper to
|
|
76
|
+
// retype everything before retrying. This only ever disables the Pay Now button itself.
|
|
77
|
+
let isPaying = $state(false);
|
|
78
|
+
|
|
79
|
+
// Owned here, not inside ContactForm, so the shopper's typed details survive navigating back to the cart
|
|
80
|
+
// and returning to checkout - see ContactForm's own doc comment on its bindable props for why.
|
|
81
|
+
let contactFirstName = $state('');
|
|
82
|
+
let contactLastName = $state('');
|
|
83
|
+
let contactEmail = $state('');
|
|
84
|
+
let contactPhone = $state('');
|
|
85
|
+
|
|
86
|
+
let contactForm = $state<ContactForm | null>(null);
|
|
87
|
+
|
|
88
|
+
// Load product data so we can display titles and unit info
|
|
89
|
+
let productsById = $state<Map<string, CheckoutProductDto>>(new Map());
|
|
90
|
+
|
|
91
|
+
async function loadProducts() {
|
|
92
|
+
try {
|
|
93
|
+
const productIds = [...new Set(cart.items.map((i) => i.productId))];
|
|
94
|
+
const results = await Promise.all(productIds.map((id) => api.getProduct(id)));
|
|
95
|
+
const map = new Map<string, CheckoutProductDto>();
|
|
96
|
+
for (const p of results) {
|
|
97
|
+
map.set(p.id, p);
|
|
98
|
+
}
|
|
99
|
+
productsById = map;
|
|
100
|
+
} catch {
|
|
101
|
+
// These supply titles and unit labels; the cart itself renders from the cart's own rows, and productTitle
|
|
102
|
+
// falls back to the product id, so losing them is a plainer checkout rather than none. Letting isLoading
|
|
103
|
+
// stand would be neither: the spinner is the first branch in the markup, so one rejected getProduct hung
|
|
104
|
+
// the whole modal on it for good - over an already settled payment included (PR 8447 review).
|
|
105
|
+
} finally {
|
|
106
|
+
isLoading = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
void loadProducts();
|
|
111
|
+
|
|
112
|
+
// A reload mid-payment: the server still holds this cart in AwaitingPaymentConfirmation for the Peach
|
|
113
|
+
// checkout recorded before the reload, and will refuse every edit and a second payCart until that attempt
|
|
114
|
+
// is settled or abandoned. Where to land depends on how far the shopper got before the reload, and only the
|
|
115
|
+
// server knows: Peach's form may have completed a moment earlier - a charge, or a decline the server reads
|
|
116
|
+
// straight back from Peach's status endpoint, since Peach sends no webhook for one - so it is asked once
|
|
117
|
+
// first, and a recorded outcome is shown exactly as it would have been without the reload. Only "not paid"
|
|
118
|
+
// puts the shopper back on that same card form, whose Peach handlers then route as they do without a
|
|
119
|
+
// reload: a completed charge confirms, a cancel/expiry/error abandons the attempt and reopens the cart.
|
|
120
|
+
// Same resume as onPayNow's own paymentResult short-cut.
|
|
121
|
+
async function resumeInterruptedPayment() {
|
|
122
|
+
const interruptedAttempt = recallPaymentAttempt(api.cartToken);
|
|
123
|
+
if (!interruptedAttempt) return;
|
|
124
|
+
paymentResult = interruptedAttempt;
|
|
125
|
+
// Posted before the answer is known, as Pay Now does: CartExpiryGuard must treat the attempt as in flight
|
|
126
|
+
// whichever screen it ends up on, and a decline's own payment:ended (releaseDeclinedAttempt) then reads the
|
|
127
|
+
// same as it does without a reload.
|
|
128
|
+
postMessage({ type: 'payment:started' });
|
|
129
|
+
if (await resumeOntoRecordedOutcome()) return;
|
|
130
|
+
currentView = 'payment';
|
|
131
|
+
// The guard's watcher fires once per deadline and may already have done so before this modal existed (a
|
|
132
|
+
// reload after the clock ran out, or checkout reopened late). Nothing else would tear the attempt down
|
|
133
|
+
// then; the shopper would land on a card form for a cart that is already gone.
|
|
134
|
+
if (cartDeadline(cart).getTime() <= Date.now()) void onPaymentTimedOut();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Whether the server already holds an outcome for the interrupted attempt - shown on the result view if so.
|
|
138
|
+
// A settled answer (a charge, a supplier-side partial failure, or a decline) is final; a confirmation the
|
|
139
|
+
// webhook is running right now joins the ordinary confirm loop to read its result; a cart the server no
|
|
140
|
+
// longer has closes the modal, as a 401 does everywhere else here. A 402 says neither the webhook nor Peach's
|
|
141
|
+
// status endpoint has anything final for this attempt - a shopper who never submitted the card, or one who
|
|
142
|
+
// reloaded mid-3-D Secure, and the two cannot be told apart from here (PR 8447 review). The card form is
|
|
143
|
+
// where both of them were, and it is Peach's own form for this same checkoutId - the state of that checkout,
|
|
144
|
+
// not a fresh charge - so that is where they go back to. Anything else - the server unreachable, an
|
|
145
|
+
// unexpected refusal - is treated the same way rather than parking them on a screen that tells them not to
|
|
146
|
+
// pay again for a card they may never have submitted.
|
|
147
|
+
async function resumeOntoRecordedOutcome(): Promise<boolean> {
|
|
148
|
+
isConfirming = true;
|
|
149
|
+
currentView = 'result';
|
|
150
|
+
let answer: ConfirmAnswer;
|
|
151
|
+
try {
|
|
152
|
+
answer = await interpretConfirm();
|
|
153
|
+
} catch {
|
|
154
|
+
// Nothing in interpretConfirm is meant to throw past its own catch, but this resume is fire-and-forget:
|
|
155
|
+
// anything that did would strand the modal on "Verifying payment..." with no button at all. A decline
|
|
156
|
+
// already recorded before the throw is shown; anything else falls back to the card form.
|
|
157
|
+
isConfirming = false;
|
|
158
|
+
return confirmOutcome !== null;
|
|
159
|
+
}
|
|
160
|
+
isConfirming = false;
|
|
161
|
+
if (answer === 'settled') return true;
|
|
162
|
+
if (answer === 'cartGone') {
|
|
163
|
+
close();
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (answer === 'confirming') {
|
|
167
|
+
await runConfirmLoop();
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
void resumeInterruptedPayment();
|
|
174
|
+
|
|
175
|
+
let cartTotal = $derived(() => cart.items.reduce((s, i) => s + i.amount, 0));
|
|
176
|
+
let cartCurrency = $derived(() => cart.items[0]?.currencyCode ?? 'ZAR');
|
|
177
|
+
|
|
178
|
+
let formattedTotal = $derived(() =>
|
|
179
|
+
formatCurrency(cartTotal(), cartCurrency(), 2, 2));
|
|
180
|
+
|
|
181
|
+
// Pure display - CartExpiryGuard.svelte (mounted once, page-wide) is what actually watches this and acts on
|
|
182
|
+
// it, whether or not this modal exists at all.
|
|
183
|
+
let expiryDeadline = $derived(cartDeadline(cart));
|
|
184
|
+
|
|
185
|
+
function productTitle(item: CheckoutCartItemDetailDto): string {
|
|
186
|
+
return productsById.get(item.productId)?.title ?? item.productId;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function optionTitle(item: CheckoutCartItemDetailDto): string {
|
|
190
|
+
const product = productsById.get(item.productId);
|
|
191
|
+
return product?.options.find((o) => o.id === item.optionId)?.title ?? item.optionId;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface GroupedUnit {
|
|
195
|
+
unitId: string;
|
|
196
|
+
title: string;
|
|
197
|
+
quantity: number;
|
|
198
|
+
linePrice: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function groupUnits(item: CheckoutCartItemDetailDto): GroupedUnit[] {
|
|
202
|
+
const product = productsById.get(item.productId);
|
|
203
|
+
const option = product?.options.find((o) => o.id === item.optionId);
|
|
204
|
+
const counts = new Map<string, number>();
|
|
205
|
+
for (const u of item.unitItems) {
|
|
206
|
+
counts.set(u.unitId, (counts.get(u.unitId) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
return [...counts.entries()].map(([unitId, quantity]) => {
|
|
209
|
+
const unit = option?.units.find((u) => u.id === unitId);
|
|
210
|
+
const unitPrice = unit?.pricing?.[0]?.retail ?? 0;
|
|
211
|
+
return {
|
|
212
|
+
unitId,
|
|
213
|
+
title: unit?.title ?? unitId,
|
|
214
|
+
quantity,
|
|
215
|
+
linePrice: unitPrice * quantity,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function itemDateLabel(item: CheckoutCartItemDetailDto): string | null {
|
|
221
|
+
if (!item.availabilityId) return null;
|
|
222
|
+
const match = item.availabilityId.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
223
|
+
if (!match) return null;
|
|
224
|
+
const d = new Date(+match[1], +match[2] - 1, +match[3]);
|
|
225
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
226
|
+
return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
onDestroy(() => postMessage({ type: 'modal:close' }));
|
|
230
|
+
|
|
231
|
+
function close() {
|
|
232
|
+
postMessage({ type: 'modal:close' });
|
|
233
|
+
onClose();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function continueShopping() {
|
|
237
|
+
close();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function editItem(item: CheckoutCartItemDetailDto) {
|
|
241
|
+
editingItem = item;
|
|
242
|
+
currentView = 'edit';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function onItemUpdated() {
|
|
246
|
+
if (!(await reloadCart())) return;
|
|
247
|
+
currentView = 'cart';
|
|
248
|
+
notifyCartUpdated(cart);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function removeItem(item: CheckoutCartItemDetailDto) {
|
|
252
|
+
try {
|
|
253
|
+
await api.removeCartItem(item.id);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (!isInvalidCart(e)) throw e;
|
|
256
|
+
close();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!(await reloadCart())) return;
|
|
260
|
+
notifyCartUpdated(cart);
|
|
261
|
+
if (cart.items.length === 0) close();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Re-reads the cart from the server - the source of truth for its expiry, which every add, edit and pay
|
|
265
|
+
// moves. False when the server no longer has it: the cart is gone (most likely CartExpiryGuard closed it
|
|
266
|
+
// while this modal happened to be open), and there is nothing left here to show but to close.
|
|
267
|
+
async function reloadCart(): Promise<boolean> {
|
|
268
|
+
try {
|
|
269
|
+
cart = await api.getCart();
|
|
270
|
+
return true;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
if (!isInvalidCart(e)) throw e;
|
|
273
|
+
close();
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Paying does not move the cart's clock, but a still-there answer given between this modal's last read and
|
|
279
|
+
// the pay click could have, so the deadline the payment page counts down from is re-read rather than
|
|
280
|
+
// assumed. Best effort: if the re-read fails, the payment page's countdown keeps the older deadline, which is
|
|
281
|
+
// the safe direction - and CartExpiryGuard's prompt still reaches the shopper over the card form either way.
|
|
282
|
+
async function refreshDeadlineAfterPay(): Promise<void> {
|
|
283
|
+
try {
|
|
284
|
+
cart = await api.getCart();
|
|
285
|
+
notifyCartUpdated(cart);
|
|
286
|
+
} catch {
|
|
287
|
+
// see above
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Tells the server this attempt is over so a fresh payCart will be accepted - without it the cart stays
|
|
292
|
+
// AwaitingPaymentConfirmation, no webhook ever comes for a checkout nobody submitted, and the only thing a
|
|
293
|
+
// retry could do is reopen a Peach session that is already dead. 'settled' means Peach has this attempt as
|
|
294
|
+
// paid, or still in flight (PAYMENT_PENDING) - either way the cart must not be reopened, and the confirm loop
|
|
295
|
+
// already handles "not paid yet" by landing on the pending screen, so both take the same path here; 'failed'
|
|
296
|
+
// means the server could not be reached or refused for another reason, in which case paymentResult is kept
|
|
297
|
+
// so onPayNow falls back to resuming the same still-open attempt.
|
|
298
|
+
async function abandonPaymentAttempt(checkoutId: string): Promise<'abandoned' | 'settled' | 'failed'> {
|
|
299
|
+
try {
|
|
300
|
+
await api.abandonPayment(checkoutId);
|
|
301
|
+
return 'abandoned';
|
|
302
|
+
} catch (e) {
|
|
303
|
+
return isPaymentSettled(e) || isPaymentPending(e) ? 'settled' : 'failed';
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Not the itemCount/cartItemId-shaped cart:change TicketConfigurator posts on add (which
|
|
308
|
+
// bw-configurator.svelte re-dispatches as the public bw:cart-change event and auto-opens checkout for).
|
|
309
|
+
// This carries the cart itself: CartBar/CartOverviewButton/Checkout use it to update their own state after
|
|
310
|
+
// an edit or remove without a second independent fetch, and it's forwarded to consumers as the public
|
|
311
|
+
// bw:cart-updated event/onCartUpdated callback, so a consumer can build their own cart summary UI from
|
|
312
|
+
// item count/remaining time/item details without calling the API directly.
|
|
313
|
+
function notifyCartUpdated(updatedCart: CheckoutCartDetailDto): void {
|
|
314
|
+
postMessage({ type: 'cart:updated', cart: updatedCart });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function onPayNow() {
|
|
318
|
+
hasAttemptedSubmit = true;
|
|
319
|
+
if (!contactForm?.isValid() || !privacyAccepted) return;
|
|
320
|
+
|
|
321
|
+
// paymentResult is only ever still set here when the previous attempt could not be torn down: the abandon
|
|
322
|
+
// call failed (Peach unreachable), or Peach reported the charge as settled or still in flight. Every other
|
|
323
|
+
// way out of PaymentPage - cancel, expiry, error - releases the attempt and clears paymentResult, and the
|
|
324
|
+
// next Pay Now creates a NEW Peach checkout. That is deliberate, not waste: Peach's SDK will not re-render
|
|
325
|
+
// a checkoutId it has already unmounted, a cancelled checkout is finished on Peach's side, and the total is
|
|
326
|
+
// frozen at creation so an edited cart needs a new one regardless.
|
|
327
|
+
//
|
|
328
|
+
// Which is exactly why this asks the server to release the attempt once more before anything else, rather
|
|
329
|
+
// than resuming it on the spot as it used to: that put the shopper back on a card form for a checkoutId
|
|
330
|
+
// Peach was done with, and Peach's own unrecoverable-error card in front of them instead of ours (PR 8447
|
|
331
|
+
// review). A release that succeeds leaves the cart Open and falls through to a genuinely new checkout. One
|
|
332
|
+
// that reports it has taken over - the charge turned out to have settled after all, or the cart is gone -
|
|
333
|
+
// owns what happens next, and paying again is the one thing that must not happen. Only a release that fails
|
|
334
|
+
// again leaves the server still holding the attempt (a second payCart would be refused with a 409), and
|
|
335
|
+
// then resuming that same card form really is the only move left.
|
|
336
|
+
if (paymentResult) {
|
|
337
|
+
isPaying = true;
|
|
338
|
+
try {
|
|
339
|
+
if (!(await releaseAbandonedAttempt())) return;
|
|
340
|
+
} catch {
|
|
341
|
+
// reloadCart rethrows anything that is not the cart being gone, and releaseAbandonedAttempt re-reads
|
|
342
|
+
// the cart on its way out. The attempt's own outcome is already decided by then, so the two lines
|
|
343
|
+
// below still route correctly on it; all a failed re-read costs is a countdown read a moment ago.
|
|
344
|
+
} finally {
|
|
345
|
+
isPaying = false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (paymentResult) {
|
|
349
|
+
currentView = 'payment';
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const contact = contactForm.getContact();
|
|
355
|
+
|
|
356
|
+
isPaying = true;
|
|
357
|
+
payError = null;
|
|
358
|
+
try {
|
|
359
|
+
paymentResult = await api.payCart(contact);
|
|
360
|
+
rememberPaymentAttempt(api.cartToken, {
|
|
361
|
+
checkoutId: paymentResult.checkoutId ?? '',
|
|
362
|
+
entityId: paymentResult.entityId ?? '',
|
|
363
|
+
});
|
|
364
|
+
currentView = 'payment';
|
|
365
|
+
postMessage({ type: 'payment:started' });
|
|
366
|
+
void refreshDeadlineAfterPay();
|
|
367
|
+
} catch (e) {
|
|
368
|
+
if (isInvalidCart(e)) {
|
|
369
|
+
// The cart expired between CartExpiryGuard's last tick and this click - there is nothing left to pay for.
|
|
370
|
+
close();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// PayCheckoutCartCommandHandler's own error-mapping switch: 502 means Peach itself (or the network
|
|
375
|
+
// path to it) failed before a checkout could even be created - no charge was attempted and the cart is
|
|
376
|
+
// still Open, so this is always safe to retry. Every other status (400/409) reflects a cart/contact state
|
|
377
|
+
// the shopper can't fix by only retrying the same request, but there is no more specific action to suggest
|
|
378
|
+
// here either - either way, without this catch the rejection was previously unhandled and the shopper
|
|
379
|
+
// saw no indication anything had gone wrong at all.
|
|
380
|
+
const status = e instanceof ApiError ? e.status : null;
|
|
381
|
+
payError = status === 502
|
|
382
|
+
? 'Unable to start payment right now. Please try again.'
|
|
383
|
+
: 'Something went wrong starting your payment. Please try again.';
|
|
384
|
+
} finally {
|
|
385
|
+
isPaying = false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function onPaymentComplete(result: { status: string }) {
|
|
390
|
+
// Neither cancelled, expired nor error ever reached a real charge - Peach's own SDK is reporting that the
|
|
391
|
+
// checkout itself didn't go through (the shopper backed out, session timeout, a declined card, a 3DS
|
|
392
|
+
// failure, a client-side error), not that a successful charge's confirmation is in question. That
|
|
393
|
+
// distinction matters: the confirm path at the bottom is only reachable once onCompleted has fired, which
|
|
394
|
+
// Peach does for a settled outcome of either kind - a charge, or a decline (the server reads that back from
|
|
395
|
+
// Peach's status endpoint, since Peach sends no webhook for one, and answers PAYMENT_FAILED). So "try again"
|
|
396
|
+
// there is only ever offered once the server itself has said nothing was charged. Here nothing was charged,
|
|
397
|
+
// so the attempt is abandoned server-side (a retry then starts a genuinely new Peach checkout instead of
|
|
398
|
+
// reopening this dead one) and re-opening the card form is exactly correct.
|
|
399
|
+
if (result.status === 'cancelled' || result.status === 'expired' || result.status === 'error') {
|
|
400
|
+
if (!(await releaseAbandonedAttempt())) return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (result.status === 'cancelled') {
|
|
404
|
+
currentView = 'contact';
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (result.status === 'expired' || result.status === 'error') {
|
|
409
|
+
confirmOutcome = 'notCharged';
|
|
410
|
+
isConfirming = false;
|
|
411
|
+
currentView = 'result';
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
currentView = 'result';
|
|
416
|
+
await runConfirmLoop();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// False when this component has already taken over what happens next - the cart turned out to be paid and is
|
|
420
|
+
// being confirmed, or it has expired - so the caller must not route the shopper anywhere else.
|
|
421
|
+
async function releaseAbandonedAttempt(): Promise<boolean> {
|
|
422
|
+
// Already cleared by an earlier call for this same attempt (e.g. a second Peach callback after the first
|
|
423
|
+
// already abandoned it) - nothing left to tell the server about.
|
|
424
|
+
if (!paymentResult) return await reloadCart();
|
|
425
|
+
|
|
426
|
+
const abandoned = await abandonPaymentAttempt(paymentResult.checkoutId);
|
|
427
|
+
if (abandoned === 'settled') {
|
|
428
|
+
currentView = 'result';
|
|
429
|
+
await runConfirmLoop();
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (abandoned === 'abandoned') {
|
|
434
|
+
paymentResult = null;
|
|
435
|
+
forgetPaymentAttempt();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The cart is Open again, or the abandon call itself failed and the shopper is being routed back to
|
|
439
|
+
// contact/result regardless. payment:ended tells CartExpiryGuard to stop treating a payment as in flight -
|
|
440
|
+
// without it the guard never prompts or expires again for the rest of the page's life - and goes out last so
|
|
441
|
+
// the guard re-checks the deadline it ends up with. But it is only true when the attempt is actually over:
|
|
442
|
+
// released here, or the cart gone regardless (a 401 on the reload - the payment window had already run out).
|
|
443
|
+
// After a failed abandon the server still holds the cart awaiting payment for this attempt, and the guard's
|
|
444
|
+
// own timeout fallback must stay armed to finish the job, so the guard is told nothing.
|
|
445
|
+
const reloaded = await reloadCart();
|
|
446
|
+
if (reloaded) notifyCartUpdated(cart);
|
|
447
|
+
if (abandoned === 'abandoned' || !reloaded) postMessage({ type: 'payment:ended' });
|
|
448
|
+
return reloaded;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Back to the contact form rather than straight to 'payment'. Every outcome that offers Try Again is one
|
|
452
|
+
// where nothing was charged, and in all but one of them the attempt has already been released (a decline
|
|
453
|
+
// here, a Peach-reported expiry or error in onPaymentComplete) - so there is no card form left to return to:
|
|
454
|
+
// Peach's SDK will not re-render a checkoutId it has unmounted, and a checkout that reached a decline is
|
|
455
|
+
// finished on Peach's side regardless. Contact is where onPayNow creates a genuinely new one, with the
|
|
456
|
+
// shopper's details still filled in (they live in this component, not in ContactForm) so paying again - on
|
|
457
|
+
// another card, if that is what the decline was about - is one click away. The exception needs no handling
|
|
458
|
+
// of its own: after an abandon the server refused, the attempt is still open and onPayNow's own paymentResult
|
|
459
|
+
// short-cut resumes that same one from here, which is exactly what it is there for.
|
|
460
|
+
function onRetry(): void {
|
|
461
|
+
currentView = 'contact';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The declined counterpart to releaseAbandonedAttempt, and deliberately without its abandon call: the server
|
|
465
|
+
// has already resolved this attempt itself - a decline reached it (Peach's webhook, or its own read of Peach's
|
|
466
|
+
// status endpoint on confirm, since Peach sends no webhook for a declined card), which is what moved the cart
|
|
467
|
+
// to Failed and produced the PAYMENT_FAILED this follows - so there is nothing left to report. AbandonPaymentAsync
|
|
468
|
+
// answers a Failed cart with AlreadyOpen, an explicit no-op, and skipping it also keeps this off the branch
|
|
469
|
+
// that routes a 'settled' abandon back into runConfirmLoop, which would recurse from inside that very loop.
|
|
470
|
+
// Clearing the attempt is what makes Try Again start a NEW Peach checkout instead of reopening the dead one:
|
|
471
|
+
// onPayNow resumes paymentResult when it is still set, and resumeInterruptedPayment would land a reload
|
|
472
|
+
// straight back on that same spent card form.
|
|
473
|
+
async function releaseDeclinedAttempt(): Promise<void> {
|
|
474
|
+
paymentResult = null;
|
|
475
|
+
forgetPaymentAttempt();
|
|
476
|
+
// Failed is live and still payable again - the decline leaves the cart's one clock exactly where it was -
|
|
477
|
+
// so this re-reads whatever time Try Again actually has left rather than the deadline from before the card
|
|
478
|
+
// form. A cart whose clock ran out meanwhile 401s here and closes the modal, same as anywhere else. Any
|
|
479
|
+
// other failure of the re-read must not escape: this runs inside interpretConfirm's own catch, from where
|
|
480
|
+
// a throw would strand the shopper on "Verifying payment..." with no button at all, and the decline itself
|
|
481
|
+
// is already recorded - Try Again simply counts down from the older deadline (PR 8447 review).
|
|
482
|
+
try {
|
|
483
|
+
if (await reloadCart()) notifyCartUpdated(cart);
|
|
484
|
+
} catch {
|
|
485
|
+
// see above
|
|
486
|
+
}
|
|
487
|
+
// Without this CartExpiryGuard keeps treating a payment as in flight for the rest of the page's life, so it
|
|
488
|
+
// never prompts or expires this cart again.
|
|
489
|
+
postMessage({ type: 'payment:ended' });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type ConfirmAnswer = 'settled' | 'notPaid' | 'confirming' | 'cartGone' | 'unknown';
|
|
493
|
+
|
|
494
|
+
// One confirmCart() call, interpreted - the single place that decides what each answer means, shared by the
|
|
495
|
+
// automatic loop, the manual on-demand recheck and a resume after a reload. 'settled' has recorded the result
|
|
496
|
+
// (success, a supplier-side partial failure, or a decline - nothing left to retry); the other three record
|
|
497
|
+
// nothing and leave the caller to decide what "no outcome yet" means where it stands.
|
|
498
|
+
async function interpretConfirm(): Promise<ConfirmAnswer> {
|
|
499
|
+
try {
|
|
500
|
+
recordConfirmation(await api.confirmCart());
|
|
501
|
+
return 'settled';
|
|
502
|
+
} catch (e) {
|
|
503
|
+
// The one case that is a real, settled failure rather than a race still resolving: the server knows the
|
|
504
|
+
// attempt was declined (or another non-success outcome) - from Peach's webhook, or from asking Peach's
|
|
505
|
+
// status endpoint itself when no webhook had landed, which is how every ordinary card decline arrives
|
|
506
|
+
// since Peach sends no webhook for one - so there is no charge behind this attempt and never will be;
|
|
507
|
+
// retrying confirmCart again cannot change that. Checked before the
|
|
508
|
+
// generic 402/409 below, which would otherwise read this the same as "webhook not here yet" and retry
|
|
509
|
+
// it right into the same generic "still confirming" pending screen a decline should never land on.
|
|
510
|
+
if (isPaymentFailed(e)) {
|
|
511
|
+
confirmOutcome = 'notCharged';
|
|
512
|
+
await releaseDeclinedAttempt();
|
|
513
|
+
return 'settled';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Two different races with the same webhook, neither a real failure:
|
|
517
|
+
// 402 - Peach has no final answer for this attempt yet (a 3-D Secure check still in flight, or its own
|
|
518
|
+
// bookkeeping not caught up with the form that just completed) and the webhook hasn't reached
|
|
519
|
+
// PeachWebhookEndpoints yet either, so the cart is not yet Paid.
|
|
520
|
+
// 409 - the webhook got there first and is confirming the cart right now, so there is no outcome to
|
|
521
|
+
// read yet. Only one caller is allowed to run confirmation, and the loser is told to retry
|
|
522
|
+
// rather than being handed a second, independently-produced answer.
|
|
523
|
+
// Both resolve on their own within a second or two. What "no outcome yet" or an unexpected error means
|
|
524
|
+
// depends on who is asking - see attemptConfirm and resumeOntoRecordedOutcome.
|
|
525
|
+
if (e instanceof ApiError && e.status === 402) return 'notPaid';
|
|
526
|
+
if (e instanceof ApiError && e.status === 409) return 'confirming';
|
|
527
|
+
if (isInvalidCart(e)) return 'cartGone';
|
|
528
|
+
return 'unknown';
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function recordConfirmation(result: CheckoutCartConfirmResultDto): void {
|
|
533
|
+
confirmResult = result;
|
|
534
|
+
const allOk = result.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
|
|
535
|
+
confirmOutcome = allOk ? 'success' : 'partial';
|
|
536
|
+
// Notifies the host (order value/currency for analytics, clearing the stored cart token) without closing
|
|
537
|
+
// the modal - the shopper still needs to see the success screen, and only dismisses it themselves via
|
|
538
|
+
// ResultView's Done button, which is the one thing that posts modal:close.
|
|
539
|
+
if (allOk) {
|
|
540
|
+
onOrderConfirmed();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The loop and the on-demand recheck only run once the attempt is known to have completed on Peach's side -
|
|
545
|
+
// onCompleted fired, or a resume after a reload found the webhook already confirming it - so "not paid" and
|
|
546
|
+
// "being confirmed" are both webhook races worth another attempt, and any other error (a cart the server no
|
|
547
|
+
// longer has included - by now that is a paid cart it cannot show, not one to pay again) settles as 'pending'
|
|
548
|
+
// rather than a failure state: there is no "payment failed" to report, only "not confirmed yet".
|
|
549
|
+
async function attemptConfirm(): Promise<'settled' | 'awaitingWebhook'> {
|
|
550
|
+
const answer = await interpretConfirm();
|
|
551
|
+
if (answer === 'notPaid' || answer === 'confirming') return 'awaitingWebhook';
|
|
552
|
+
if (answer === 'unknown' || answer === 'cartGone') confirmOutcome = 'pending';
|
|
553
|
+
return 'settled';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function runConfirmLoop() {
|
|
557
|
+
isConfirming = true;
|
|
558
|
+
confirmOutcome = null;
|
|
559
|
+
|
|
560
|
+
for (let attempt = 1; attempt <= ConfirmMaxAttempts; attempt++) {
|
|
561
|
+
if (await attemptConfirm() === 'settled') {
|
|
562
|
+
isConfirming = false;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (attempt === ConfirmMaxAttempts) {
|
|
567
|
+
confirmOutcome = 'pending';
|
|
568
|
+
isConfirming = false;
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// A single on-demand recheck for the 'pending' screen's "Check Again" button - deliberately not another
|
|
577
|
+
// full runConfirmLoop, which would re-impose its own ~15s auto-retry wait on someone who is already
|
|
578
|
+
// actively engaged and can just click again. Confirming is idempotent (CheckoutCartConfirmer), so this
|
|
579
|
+
// costs nothing to repeat; still 'pending' either way if the webhook still hasn't landed.
|
|
580
|
+
async function checkConfirmationAgain() {
|
|
581
|
+
isConfirming = true;
|
|
582
|
+
if (await attemptConfirm() === 'awaitingWebhook') {
|
|
583
|
+
confirmOutcome = 'pending';
|
|
584
|
+
}
|
|
585
|
+
isConfirming = false;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// A straight readout of confirmOutcome onto ResultView's own outcome union, which now carries one of each.
|
|
589
|
+
// 'partial' - the payment succeeded but this gateway could not confirm every item - keeps an outcome of its
|
|
590
|
+
// own the whole way through: it is a real problem worth surfacing distinctly from 'pending' (which resolves
|
|
591
|
+
// on its own), and it must never reach the shopper as "Payment Failed", because their card was charged.
|
|
592
|
+
// ResultView owns the wording for it, as it already did for 'successful' and 'pending'.
|
|
593
|
+
let resultStatus = $derived(
|
|
594
|
+
isConfirming
|
|
595
|
+
? null
|
|
596
|
+
: confirmOutcome === 'success'
|
|
597
|
+
? { outcome: 'successful' as const }
|
|
598
|
+
: confirmOutcome === 'pending'
|
|
599
|
+
? { outcome: 'pending' as const }
|
|
600
|
+
: confirmOutcome === 'partial'
|
|
601
|
+
? { outcome: 'partial' as const }
|
|
602
|
+
: confirmOutcome === 'notCharged'
|
|
603
|
+
? {
|
|
604
|
+
outcome: 'failed' as const,
|
|
605
|
+
resultDescription: 'Your payment could not be completed. Please try again.',
|
|
606
|
+
retryPayment: true,
|
|
607
|
+
}
|
|
608
|
+
: null,
|
|
609
|
+
);
|
|
610
|
+
</script>
|
|
611
|
+
|
|
612
|
+
<div class="modal">
|
|
613
|
+
{#if isLoading}
|
|
614
|
+
<div class="loading-center" style="height:100%">
|
|
615
|
+
<div class="spinner"></div>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
{:else if currentView === 'payment'}
|
|
619
|
+
<!-- Still on the payment view but with no attempt left to render: releaseAbandonedAttempt clears
|
|
620
|
+
paymentResult and then re-reads the cart before its caller routes anywhere, and without this the modal
|
|
621
|
+
fell straight through to the cart view below for the length of that round trip - so the shopper watched
|
|
622
|
+
their cart reappear before landing on the failure screen they were being sent to (PR 8447 review). -->
|
|
623
|
+
{#if paymentResult}
|
|
624
|
+
<PaymentPage
|
|
625
|
+
checkoutId={paymentResult.checkoutId}
|
|
626
|
+
entityId={paymentResult.entityId}
|
|
627
|
+
expiresAt={expiryDeadline}
|
|
628
|
+
{onPaymentComplete}
|
|
629
|
+
/>
|
|
630
|
+
{:else}
|
|
631
|
+
<div class="loading-center" style="height:100%">
|
|
632
|
+
<div class="spinner"></div>
|
|
633
|
+
</div>
|
|
634
|
+
{/if}
|
|
635
|
+
|
|
636
|
+
{:else if currentView === 'result'}
|
|
637
|
+
<ResultView
|
|
638
|
+
status={resultStatus}
|
|
639
|
+
verifying={isConfirming}
|
|
640
|
+
onDone={() => {
|
|
641
|
+
// onOrderConfirmed only for the outcome that actually earns it - a 'pending'/'partial' Close is
|
|
642
|
+
// just dismissing the dialog on an order this gateway cannot yet (or fully) vouch for, not
|
|
643
|
+
// reporting it complete. The 'success' case already fired onOrderConfirmed the moment it was
|
|
644
|
+
// known, inside runConfirmLoop/checkConfirmationAgain - this simply closes what's still open.
|
|
645
|
+
postMessage({ type: 'modal:close' });
|
|
646
|
+
}}
|
|
647
|
+
{onRetry}
|
|
648
|
+
onCheckAgain={confirmOutcome === 'pending' ? checkConfirmationAgain : undefined}
|
|
649
|
+
/>
|
|
650
|
+
|
|
651
|
+
{:else if currentView === 'edit' && editingItem && productsById.get(editingItem.productId)}
|
|
652
|
+
<EditBookingView
|
|
653
|
+
cartItem={editingItem}
|
|
654
|
+
product={productsById.get(editingItem.productId)!}
|
|
655
|
+
{api}
|
|
656
|
+
{wizardPages}
|
|
657
|
+
{editPages}
|
|
658
|
+
{autoSelectSingleTimeSlot}
|
|
659
|
+
onBack={() => { currentView = 'cart'; }}
|
|
660
|
+
onClose={close}
|
|
661
|
+
onUpdated={onItemUpdated}
|
|
662
|
+
onPriceChanged={loadProducts}
|
|
663
|
+
/>
|
|
664
|
+
|
|
665
|
+
{:else if currentView === 'contact'}
|
|
666
|
+
<!-- Contact Details view -->
|
|
667
|
+
<div class="page-header">
|
|
668
|
+
<button class="icon-btn" onclick={() => { currentView = 'cart'; }} aria-label="Back">←</button>
|
|
669
|
+
<h2>Checkout</h2>
|
|
670
|
+
<span class="spacer"></span>
|
|
671
|
+
<CountdownTimer expiresAt={expiryDeadline} />
|
|
672
|
+
<button class="icon-btn" onclick={close} aria-label="Close">×</button>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<div class="body">
|
|
676
|
+
{#if payError}
|
|
677
|
+
<p class="pay-error">{payError}</p>
|
|
678
|
+
{/if}
|
|
679
|
+
<div class="contact-header">
|
|
680
|
+
<span class="contact-title">Contact Details</span>
|
|
681
|
+
<span class="required-hint">* Required Fields</span>
|
|
682
|
+
</div>
|
|
683
|
+
<ContactForm
|
|
684
|
+
bind:this={contactForm}
|
|
685
|
+
{hasAttemptedSubmit}
|
|
686
|
+
bind:firstName={contactFirstName}
|
|
687
|
+
bind:lastName={contactLastName}
|
|
688
|
+
bind:emailAddress={contactEmail}
|
|
689
|
+
bind:phoneNumber={contactPhone}
|
|
690
|
+
/>
|
|
691
|
+
<div style="margin-top:24px">
|
|
692
|
+
<ConsentSection
|
|
693
|
+
{privacyAccepted}
|
|
694
|
+
{marketingOptIn}
|
|
695
|
+
{hasAttemptedSubmit}
|
|
696
|
+
onPrivacyChanged={(v) => { privacyAccepted = v; }}
|
|
697
|
+
onMarketingChanged={(v) => { marketingOptIn = v; }}
|
|
698
|
+
/>
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
|
|
702
|
+
<div class="pay-bar">
|
|
703
|
+
<span class="pay-bar-total">{formattedTotal()}</span>
|
|
704
|
+
<button class="pay-bar-btn" onclick={onPayNow} disabled={isPaying}>
|
|
705
|
+
{isPaying ? 'Paying…' : 'Pay Now'}
|
|
706
|
+
</button>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
{:else}
|
|
710
|
+
<!-- Cart view -->
|
|
711
|
+
<div class="page-header">
|
|
712
|
+
<h2>Your cart</h2>
|
|
713
|
+
<span class="spacer"></span>
|
|
714
|
+
<CountdownTimer expiresAt={expiryDeadline} />
|
|
715
|
+
<button class="keep-shopping-btn" onclick={continueShopping}>+ Keep shopping</button>
|
|
716
|
+
</div>
|
|
717
|
+
|
|
718
|
+
<div class="body">
|
|
719
|
+
{#each cart.items as item}
|
|
720
|
+
<div class="cart-card">
|
|
721
|
+
<div class="cart-card-top">
|
|
722
|
+
<h3 class="cart-product">{productTitle(item)}</h3>
|
|
723
|
+
<span class="cart-item-total">{formatCurrency(item.amount, item.currencyCode, 2, 0)}</span>
|
|
724
|
+
</div>
|
|
725
|
+
<div class="cart-detail-lines">
|
|
726
|
+
{#if itemDateLabel(item)}
|
|
727
|
+
<p class="cart-detail">{itemDateLabel(item)}</p>
|
|
728
|
+
{/if}
|
|
729
|
+
<p class="cart-detail">{optionTitle(item)}</p>
|
|
730
|
+
</div>
|
|
731
|
+
|
|
732
|
+
<div class="cart-divider"></div>
|
|
733
|
+
|
|
734
|
+
<div class="cart-units">
|
|
735
|
+
{#each groupUnits(item) as gu}
|
|
736
|
+
<div class="cart-unit-line">
|
|
737
|
+
<span class="cart-unit-label">{gu.title} × {gu.quantity}</span>
|
|
738
|
+
<span class="cart-unit-price">{formatCurrency(gu.linePrice, item.currencyCode, 2, 0)}</span>
|
|
739
|
+
</div>
|
|
740
|
+
{/each}
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<div class="cart-actions">
|
|
744
|
+
<button class="action-edit" onclick={() => editItem(item)}>Edit</button>
|
|
745
|
+
<button class="action-remove" onclick={() => removeItem(item)}>Remove</button>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
{/each}
|
|
749
|
+
|
|
750
|
+
<div class="cart-total-divider"></div>
|
|
751
|
+
|
|
752
|
+
<div class="cart-total-row">
|
|
753
|
+
<span>Total payable today</span>
|
|
754
|
+
<span class="cart-total-amount">{formattedTotal()}</span>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
|
|
758
|
+
<div class="action-bar">
|
|
759
|
+
<button class="btn btn-primary" onclick={() => { currentView = 'contact'; }}>
|
|
760
|
+
Checkout
|
|
761
|
+
</button>
|
|
762
|
+
</div>
|
|
763
|
+
{/if}
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<style>
|
|
767
|
+
.modal {
|
|
768
|
+
display: flex;
|
|
769
|
+
flex-direction: column;
|
|
770
|
+
height: 100%;
|
|
771
|
+
max-height: 100%;
|
|
772
|
+
overflow: hidden;
|
|
773
|
+
background: var(--bw-color-surface);
|
|
774
|
+
}
|
|
775
|
+
.body {
|
|
776
|
+
flex: 1;
|
|
777
|
+
overflow-y: auto;
|
|
778
|
+
padding: 16px;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/* -- Cart view -- */
|
|
782
|
+
.keep-shopping-btn {
|
|
783
|
+
padding: 8px 16px;
|
|
784
|
+
background: none;
|
|
785
|
+
border: 2px solid #222;
|
|
786
|
+
border-radius: var(--bw-radius-md);
|
|
787
|
+
font-size: 13px;
|
|
788
|
+
font-weight: 600;
|
|
789
|
+
color: #222;
|
|
790
|
+
cursor: pointer;
|
|
791
|
+
}
|
|
792
|
+
.keep-shopping-btn:hover {
|
|
793
|
+
background: #f5f5f5;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
.cart-card {
|
|
797
|
+
border: 1px solid var(--bw-color-border);
|
|
798
|
+
border-radius: var(--bw-radius-lg);
|
|
799
|
+
padding: 20px;
|
|
800
|
+
margin-bottom: 16px;
|
|
801
|
+
}
|
|
802
|
+
.cart-card-top {
|
|
803
|
+
display: flex;
|
|
804
|
+
justify-content: space-between;
|
|
805
|
+
align-items: flex-start;
|
|
806
|
+
gap: 16px;
|
|
807
|
+
}
|
|
808
|
+
.cart-product {
|
|
809
|
+
font-size: 16px;
|
|
810
|
+
font-weight: 700;
|
|
811
|
+
flex: 1;
|
|
812
|
+
}
|
|
813
|
+
.cart-item-total {
|
|
814
|
+
font-size: 16px;
|
|
815
|
+
font-weight: 800;
|
|
816
|
+
white-space: nowrap;
|
|
817
|
+
}
|
|
818
|
+
.cart-detail-lines {
|
|
819
|
+
margin-top: 4px;
|
|
820
|
+
}
|
|
821
|
+
.cart-detail {
|
|
822
|
+
font-size: 13px;
|
|
823
|
+
color: var(--bw-color-text-secondary);
|
|
824
|
+
line-height: 1.5;
|
|
825
|
+
}
|
|
826
|
+
.cart-divider {
|
|
827
|
+
border-top: 1px dashed var(--bw-color-border);
|
|
828
|
+
margin: 14px 0;
|
|
829
|
+
}
|
|
830
|
+
.cart-units {
|
|
831
|
+
display: flex;
|
|
832
|
+
flex-direction: column;
|
|
833
|
+
gap: 6px;
|
|
834
|
+
margin-bottom: 14px;
|
|
835
|
+
}
|
|
836
|
+
.cart-unit-line {
|
|
837
|
+
display: flex;
|
|
838
|
+
justify-content: space-between;
|
|
839
|
+
align-items: center;
|
|
840
|
+
font-size: 14px;
|
|
841
|
+
}
|
|
842
|
+
.cart-unit-label {
|
|
843
|
+
color: #333;
|
|
844
|
+
}
|
|
845
|
+
.cart-unit-price {
|
|
846
|
+
font-weight: 600;
|
|
847
|
+
font-variant-numeric: tabular-nums;
|
|
848
|
+
}
|
|
849
|
+
.cart-actions {
|
|
850
|
+
display: flex;
|
|
851
|
+
gap: 16px;
|
|
852
|
+
}
|
|
853
|
+
.action-edit {
|
|
854
|
+
background: none;
|
|
855
|
+
border: none;
|
|
856
|
+
font-size: 13px;
|
|
857
|
+
font-weight: 700;
|
|
858
|
+
color: var(--bw-color-primary);
|
|
859
|
+
padding: 0;
|
|
860
|
+
cursor: pointer;
|
|
861
|
+
}
|
|
862
|
+
.action-edit:hover { text-decoration: underline; }
|
|
863
|
+
.action-remove {
|
|
864
|
+
background: none;
|
|
865
|
+
border: none;
|
|
866
|
+
font-size: 13px;
|
|
867
|
+
font-weight: 600;
|
|
868
|
+
color: #555;
|
|
869
|
+
padding: 0;
|
|
870
|
+
cursor: pointer;
|
|
871
|
+
}
|
|
872
|
+
.action-remove:hover { text-decoration: underline; }
|
|
873
|
+
|
|
874
|
+
.cart-total-divider {
|
|
875
|
+
border-top: 3px solid #111;
|
|
876
|
+
margin: 8px 0 20px;
|
|
877
|
+
}
|
|
878
|
+
.cart-total-row {
|
|
879
|
+
display: flex;
|
|
880
|
+
justify-content: space-between;
|
|
881
|
+
align-items: center;
|
|
882
|
+
font-size: 16px;
|
|
883
|
+
font-weight: 700;
|
|
884
|
+
margin-bottom: 20px;
|
|
885
|
+
}
|
|
886
|
+
.cart-total-amount {
|
|
887
|
+
font-size: 20px;
|
|
888
|
+
font-weight: 800;
|
|
889
|
+
}
|
|
890
|
+
/* -- Contact view -- */
|
|
891
|
+
.pay-error {
|
|
892
|
+
margin: 0 0 16px;
|
|
893
|
+
padding: 12px 16px;
|
|
894
|
+
background: #fdecea;
|
|
895
|
+
color: #b3261e;
|
|
896
|
+
font-size: 14px;
|
|
897
|
+
border-radius: var(--bw-radius-md);
|
|
898
|
+
}
|
|
899
|
+
.contact-header {
|
|
900
|
+
display: flex;
|
|
901
|
+
justify-content: space-between;
|
|
902
|
+
align-items: center;
|
|
903
|
+
margin-bottom: 20px;
|
|
904
|
+
}
|
|
905
|
+
.contact-title {
|
|
906
|
+
font-size: 16px;
|
|
907
|
+
font-weight: 700;
|
|
908
|
+
}
|
|
909
|
+
.required-hint {
|
|
910
|
+
font-size: 12px;
|
|
911
|
+
color: var(--bw-color-primary);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.pay-bar {
|
|
915
|
+
display: flex;
|
|
916
|
+
align-items: center;
|
|
917
|
+
flex-shrink: 0;
|
|
918
|
+
background: var(--bw-color-primary);
|
|
919
|
+
color: white;
|
|
920
|
+
position: sticky;
|
|
921
|
+
bottom: 0;
|
|
922
|
+
}
|
|
923
|
+
.pay-bar-total {
|
|
924
|
+
padding: 0 20px;
|
|
925
|
+
font-size: 16px;
|
|
926
|
+
font-weight: 700;
|
|
927
|
+
}
|
|
928
|
+
.pay-bar-btn {
|
|
929
|
+
flex: 1;
|
|
930
|
+
display: flex;
|
|
931
|
+
align-items: center;
|
|
932
|
+
justify-content: flex-end;
|
|
933
|
+
gap: 8px;
|
|
934
|
+
height: 52px;
|
|
935
|
+
padding: 0 20px;
|
|
936
|
+
background: none;
|
|
937
|
+
border: none;
|
|
938
|
+
color: white;
|
|
939
|
+
font-size: 16px;
|
|
940
|
+
font-weight: 700;
|
|
941
|
+
}
|
|
942
|
+
.pay-bar-btn:disabled {
|
|
943
|
+
opacity: 0.7;
|
|
944
|
+
cursor: default;
|
|
945
|
+
}
|
|
946
|
+
</style>
|