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