@code-collective/booking-widget 1.0.12 → 1.0.14

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +48 -46
  2. package/README.md +500 -500
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +829 -707
  5. package/dist/booking-widget.min.js +24 -25
  6. package/dist/booking-widget.umd.cjs +5 -5
  7. package/package.json +58 -58
  8. package/src/lib/BookingProvider.svelte +21 -21
  9. package/src/lib/CartBar.svelte +26 -26
  10. package/src/lib/CartBarView.svelte +78 -78
  11. package/src/lib/CartExpiryGuard.svelte +416 -416
  12. package/src/lib/CartOverview.svelte +30 -30
  13. package/src/lib/CartOverviewButton.svelte +104 -104
  14. package/src/lib/Checkout.svelte +141 -141
  15. package/src/lib/CheckoutModal.css +179 -0
  16. package/src/lib/CheckoutModal.svelte +78 -573
  17. package/src/lib/CheckoutPanel.svelte +121 -121
  18. package/src/lib/PaymentPage.svelte +208 -191
  19. package/src/lib/ResultView.svelte +24 -4
  20. package/src/lib/TicketConfigurator.svelte +166 -166
  21. package/src/lib/api.ts +36 -5
  22. package/src/lib/booking-context.ts +33 -33
  23. package/src/lib/cart-overview.svelte.ts +97 -97
  24. package/src/lib/checkout-item-view.ts +63 -0
  25. package/src/lib/checkout-payment-flow.svelte.ts +523 -0
  26. package/src/lib/client-types.ts +22 -6
  27. package/src/lib/config.ts +162 -162
  28. package/src/lib/elements/bw-cart.svelte +49 -49
  29. package/src/lib/elements/bw-checkout.svelte +97 -97
  30. package/src/lib/elements/bw-configurator.svelte +72 -72
  31. package/src/lib/elements/register.ts +171 -171
  32. package/src/lib/elements/shared.ts +18 -18
  33. package/src/lib/generated-types.ts +94 -5
  34. package/src/lib/host.svelte.ts +336 -336
  35. package/src/lib/index.ts +242 -242
  36. package/src/lib/messages.ts +157 -157
  37. package/src/lib/peach-sdk.ts +86 -86
  38. package/src/lib/portal.ts +23 -23
@@ -1,13 +1,14 @@
1
1
  <script lang="ts">
2
- import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutCartPaymentInitiationDto, CheckoutCartConfirmResultDto, CheckoutProductDto } from './client-types';
2
+ import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutProductDto } from './client-types';
3
3
  import type { BookingApi } from './api';
4
- import { ApiError, isInvalidCart, isPaymentFailed, isPaymentPending, isPaymentSettled } from './api';
4
+ import { isInvalidCart } from './api';
5
5
  import type { WizardPages } from './config';
6
6
  import { formatCurrency } from './currency';
7
7
  import { postMessage, onWidgetMessage } from './messages';
8
8
  import { cartDeadline } from './cart-expiry';
9
- import { rememberPaymentAttempt, recallPaymentAttempt, forgetPaymentAttempt } from './payment-attempt';
10
9
  import { onDestroy } from 'svelte';
10
+ import { createCheckoutPaymentFlow } from './checkout-payment-flow.svelte';
11
+ import { cartTotal, cartCurrency, productTitle, optionTitle, groupUnits, itemDateLabel } from './checkout-item-view';
11
12
  import ContactForm from './ContactForm.svelte';
12
13
  import ConsentSection from './ConsentSection.svelte';
13
14
  import CountdownTimer from './CountdownTimer.svelte';
@@ -33,48 +34,16 @@
33
34
  // re-derives from it. Assigning to `cart` here instead would write to a prop this component does not own.
34
35
  $effect(() => onWidgetMessage((d) => {
35
36
  if (d.type === 'cart:updated' && 'cart' in d && d.cart === null) close();
36
- if (d.type === 'payment:timed-out') void onPaymentTimedOut();
37
+ if (d.type === 'payment:timed-out') void flow.onPaymentTimedOut();
37
38
  }));
38
39
 
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
40
  type View = 'cart' | 'contact' | 'edit' | 'payment' | 'result';
58
41
  let currentView = $state<View>('cart');
59
42
  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
43
  let isLoading = $state(true);
68
- let isConfirming = $state(false);
69
44
  let privacyAccepted = $state(false);
70
45
  let marketingOptIn = $state(false);
71
46
  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
47
 
79
48
  // Owned here, not inside ContactForm, so the shopper's typed details survive navigating back to the cart
80
49
  // and returning to checkout - see ContactForm's own doc comment on its bindable props for why.
@@ -85,96 +54,59 @@
85
54
 
86
55
  let contactForm = $state<ContactForm | null>(null);
87
56
 
57
+ // The current Peach payment attempt, from minting it through to a settled outcome - see
58
+ // checkout-payment-flow.svelte.ts for why this is its own module rather than living in this component.
59
+ const flow = createCheckoutPaymentFlow({
60
+ getApi: () => api,
61
+ getCart: () => cart,
62
+ reloadCart,
63
+ notifyCartUpdated,
64
+ refreshDeadlineAfterPay: () => void refreshDeadlineAfterPay(),
65
+ setView: (v) => { currentView = v; },
66
+ close,
67
+ // Wrapped, not passed directly: onOrderConfirmed is a destructured prop, and passing it by value here
68
+ // would capture whatever it happened to be when the flow was created rather than following it if the
69
+ // host ever supplies a different callback later.
70
+ onOrderConfirmed: () => onOrderConfirmed(),
71
+ });
72
+
88
73
  // Load product data so we can display titles and unit info
89
74
  let productsById = $state<Map<string, CheckoutProductDto>>(new Map());
90
75
 
91
76
  async function loadProducts() {
92
- const productIds = [...new Set(cart.items.map((i) => i.productId))];
93
- const results = await Promise.all(productIds.map((id) => api.getProduct(id)));
94
- const map = new Map<string, CheckoutProductDto>();
95
- for (const p of results) {
96
- map.set(p.id, p);
77
+ try {
78
+ const productIds = [...new Set(cart.items.map((i) => i.productId))];
79
+ const results = await Promise.all(productIds.map((id) => api.getProduct(id)));
80
+ const map = new Map<string, CheckoutProductDto>();
81
+ for (const p of results) {
82
+ map.set(p.id, p);
83
+ }
84
+ productsById = map;
85
+ } catch {
86
+ // These supply titles and unit labels; the cart itself renders from the cart's own rows, and productTitle
87
+ // falls back to the product id, so losing them is a plainer checkout rather than none. Letting isLoading
88
+ // stand would be neither: the spinner is the first branch in the markup, so one rejected getProduct hung
89
+ // the whole modal on it for good - over an already settled payment included (PR 8447 review).
90
+ } finally {
91
+ isLoading = false;
97
92
  }
98
- productsById = map;
99
- isLoading = false;
100
93
  }
101
94
 
102
- loadProducts();
103
-
104
- // A reload mid-payment: the server still holds this cart in AwaitingPaymentConfirmation for the Peach
105
- // checkout recorded before the reload, and will refuse every edit and a second payCart until that attempt
106
- // is settled or abandoned - so the only useful place to land is back on that same card form. Peach's own
107
- // handlers then route exactly as they do without a reload: a completed charge confirms, a cancel/expiry/
108
- // error abandons the attempt and reopens the cart. Same resume as onPayNow's own paymentResult short-cut.
109
- function resumeInterruptedPayment() {
110
- const interruptedAttempt = recallPaymentAttempt(api.cartToken);
111
- if (!interruptedAttempt) return;
112
- paymentResult = interruptedAttempt;
113
- currentView = 'payment';
114
- postMessage({ type: 'payment:started' });
115
- // The guard's watcher fires once per deadline and may already have done so before this modal existed (a
116
- // reload after the clock ran out, or checkout reopened late). Nothing else would tear the attempt down
117
- // then; the shopper would land on a card form for a cart that is already gone.
118
- if (cartDeadline(cart).getTime() <= Date.now()) void onPaymentTimedOut();
119
- }
120
-
121
- resumeInterruptedPayment();
95
+ void loadProducts();
96
+ void flow.resumeInterruptedPayment();
122
97
 
123
- let cartTotal = $derived(() => cart.items.reduce((s, i) => s + i.amount, 0));
124
- let cartCurrency = $derived(() => cart.items[0]?.currencyCode ?? 'ZAR');
125
-
126
- let formattedTotal = $derived(() =>
127
- formatCurrency(cartTotal(), cartCurrency(), 2, 2));
98
+ let formattedTotal = $derived(() => formatCurrency(cartTotal(cart), cartCurrency(cart), 2, 2));
128
99
 
129
100
  // Pure display - CartExpiryGuard.svelte (mounted once, page-wide) is what actually watches this and acts on
130
101
  // it, whether or not this modal exists at all.
131
102
  let expiryDeadline = $derived(cartDeadline(cart));
132
103
 
133
- function productTitle(item: CheckoutCartItemDetailDto): string {
134
- return productsById.get(item.productId)?.title ?? item.productId;
135
- }
136
-
137
- function optionTitle(item: CheckoutCartItemDetailDto): string {
138
- const product = productsById.get(item.productId);
139
- return product?.options.find((o) => o.id === item.optionId)?.title ?? item.optionId;
140
- }
141
-
142
- interface GroupedUnit {
143
- unitId: string;
144
- title: string;
145
- quantity: number;
146
- linePrice: number;
147
- }
148
-
149
- function groupUnits(item: CheckoutCartItemDetailDto): GroupedUnit[] {
150
- const product = productsById.get(item.productId);
151
- const option = product?.options.find((o) => o.id === item.optionId);
152
- const counts = new Map<string, number>();
153
- for (const u of item.unitItems) {
154
- counts.set(u.unitId, (counts.get(u.unitId) ?? 0) + 1);
155
- }
156
- return [...counts.entries()].map(([unitId, quantity]) => {
157
- const unit = option?.units.find((u) => u.id === unitId);
158
- const unitPrice = unit?.pricing?.[0]?.retail ?? 0;
159
- return {
160
- unitId,
161
- title: unit?.title ?? unitId,
162
- quantity,
163
- linePrice: unitPrice * quantity,
164
- };
165
- });
166
- }
167
-
168
- function itemDateLabel(item: CheckoutCartItemDetailDto): string | null {
169
- if (!item.availabilityId) return null;
170
- const match = item.availabilityId.match(/(\d{4})-(\d{2})-(\d{2})/);
171
- if (!match) return null;
172
- const d = new Date(+match[1], +match[2] - 1, +match[3]);
173
- const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
174
- return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
175
- }
176
-
177
- onDestroy(() => postMessage({ type: 'modal:close' }));
104
+ onDestroy(() => {
105
+ // An in-flight poll outlives the payment flow otherwise - it would keep hitting the gateway, and keep
106
+ // writing to $state on a component nobody is looking at any more.
107
+ flow.dispose();
108
+ postMessage({ type: 'modal:close' });
109
+ });
178
110
 
179
111
  function close() {
180
112
  postMessage({ type: 'modal:close' });
@@ -211,7 +143,8 @@
211
143
 
212
144
  // Re-reads the cart from the server - the source of truth for its expiry, which every add, edit and pay
213
145
  // moves. False when the server no longer has it: the cart is gone (most likely CartExpiryGuard closed it
214
- // while this modal happened to be open), and there is nothing left here to show but to close.
146
+ // while this modal happened to be open), and there is nothing left here to show but to close. Shared with
147
+ // the payment flow, which needs the exact same behaviour when releasing or reloading after an attempt ends.
215
148
  async function reloadCart(): Promise<boolean> {
216
149
  try {
217
150
  cart = await api.getCart();
@@ -236,22 +169,6 @@
236
169
  }
237
170
  }
238
171
 
239
- // Tells the server this attempt is over so a fresh payCart will be accepted - without it the cart stays
240
- // AwaitingPaymentConfirmation, no webhook ever comes for a checkout nobody submitted, and the only thing a
241
- // retry could do is reopen a Peach session that is already dead. 'settled' means Peach has this attempt as
242
- // paid, or still in flight (PAYMENT_PENDING) - either way the cart must not be reopened, and the confirm loop
243
- // already handles "not paid yet" by landing on the pending screen, so both take the same path here; 'failed'
244
- // means the server could not be reached or refused for another reason, in which case paymentResult is kept
245
- // so onPayNow falls back to resuming the same still-open attempt.
246
- async function abandonPaymentAttempt(checkoutId: string): Promise<'abandoned' | 'settled' | 'failed'> {
247
- try {
248
- await api.abandonPayment(checkoutId);
249
- return 'abandoned';
250
- } catch (e) {
251
- return isPaymentSettled(e) || isPaymentPending(e) ? 'settled' : 'failed';
252
- }
253
- }
254
-
255
172
  // Not the itemCount/cartItemId-shaped cart:change TicketConfigurator posts on add (which
256
173
  // bw-configurator.svelte re-dispatches as the public bw:cart-change event and auto-opens checkout for).
257
174
  // This carries the cart itself: CartBar/CartOverviewButton/Checkout use it to update their own state after
@@ -265,250 +182,8 @@
265
182
  async function onPayNow() {
266
183
  hasAttemptedSubmit = true;
267
184
  if (!contactForm?.isValid() || !privacyAccepted) return;
268
-
269
- // 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. The server
271
- // still holds that attempt (a second payCart would be refused with a 409), so the only useful move is to
272
- // resume it. Every other way out of PaymentPage - cancel, expiry, error - abandons the attempt and clears
273
- // paymentResult, and the next Pay Now creates a NEW Peach checkout. That is deliberate, not waste: Peach's
274
- // SDK forbids re-rendering a checkoutId once it has been unmounted, a cancelled checkout is finished on
275
- // Peach's side, and the total is frozen at creation so an edited cart needs a new one regardless.
276
- if (paymentResult) {
277
- currentView = 'payment';
278
- return;
279
- }
280
-
281
- const contact = contactForm.getContact();
282
-
283
- isPaying = true;
284
- payError = null;
285
- try {
286
- paymentResult = await api.payCart(contact);
287
- rememberPaymentAttempt(api.cartToken, {
288
- checkoutId: paymentResult.checkoutId ?? '',
289
- entityId: paymentResult.entityId ?? '',
290
- });
291
- currentView = 'payment';
292
- postMessage({ type: 'payment:started' });
293
- void refreshDeadlineAfterPay();
294
- } catch (e) {
295
- if (isInvalidCart(e)) {
296
- // The cart expired between CartExpiryGuard's last tick and this click - there is nothing left to pay for.
297
- close();
298
- return;
299
- }
300
-
301
- // PayCheckoutCartCommandHandler's own error-mapping switch: 502 means Peach itself (or the network
302
- // path to it) failed before a checkout could even be created - no charge was attempted and the cart is
303
- // still Open, so this is always safe to retry. Every other status (400/409) reflects a cart/contact state
304
- // the shopper can't fix by only retrying the same request, but there is no more specific action to suggest
305
- // here either - either way, without this catch the rejection was previously unhandled and the shopper
306
- // saw no indication anything had gone wrong at all.
307
- const status = e instanceof ApiError ? e.status : null;
308
- payError = status === 502
309
- ? 'Unable to start payment right now. Please try again.'
310
- : 'Something went wrong starting your payment. Please try again.';
311
- } finally {
312
- isPaying = false;
313
- }
185
+ await flow.payNow(contactForm.getContact());
314
186
  }
315
-
316
- async function onPaymentComplete(result: { status: string }) {
317
- // Neither cancelled, expired nor error ever reached a real charge - Peach's own SDK is reporting that the
318
- // checkout itself didn't go through (the shopper backed out, session timeout, a declined card, a 3DS
319
- // 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
- // meaning the card WAS charged, so "try again" there would risk a second charge. Here nothing was charged,
322
- // so the attempt is abandoned server-side (a retry then starts a genuinely new Peach checkout instead of
323
- // reopening this dead one) and re-opening the card form is exactly correct.
324
- if (result.status === 'cancelled' || result.status === 'expired' || result.status === 'error') {
325
- if (!(await releaseAbandonedAttempt())) return;
326
- }
327
-
328
- if (result.status === 'cancelled') {
329
- currentView = 'contact';
330
- return;
331
- }
332
-
333
- if (result.status === 'expired' || result.status === 'error') {
334
- confirmOutcome = 'notCharged';
335
- isConfirming = false;
336
- currentView = 'result';
337
- return;
338
- }
339
-
340
- currentView = 'result';
341
- await runConfirmLoop();
342
- }
343
-
344
- // False when this component has already taken over what happens next - the cart turned out to be paid and is
345
- // being confirmed, or it has expired - so the caller must not route the shopper anywhere else.
346
- async function releaseAbandonedAttempt(): Promise<boolean> {
347
- // Already cleared by an earlier call for this same attempt (e.g. a second Peach callback after the first
348
- // already abandoned it) - nothing left to tell the server about.
349
- if (!paymentResult) return await reloadCart();
350
-
351
- const abandoned = await abandonPaymentAttempt(paymentResult.checkoutId);
352
- if (abandoned === 'settled') {
353
- currentView = 'result';
354
- await runConfirmLoop();
355
- return false;
356
- }
357
-
358
- if (abandoned === 'abandoned') {
359
- paymentResult = null;
360
- forgetPaymentAttempt();
361
- }
362
-
363
- // The cart is Open again, or the abandon call itself failed and the shopper is being routed back to
364
- // contact/result regardless. payment:ended tells CartExpiryGuard to stop treating a payment as in flight -
365
- // without it the guard never prompts or expires again for the rest of the page's life - and goes out last so
366
- // the guard re-checks the deadline it ends up with. But it is only true when the attempt is actually over:
367
- // released here, or the cart gone regardless (a 401 on the reload - the payment window had already run out).
368
- // After a failed abandon the server still holds the cart awaiting payment for this attempt, and the guard's
369
- // own timeout fallback must stay armed to finish the job, so the guard is told nothing.
370
- const reloaded = await reloadCart();
371
- if (reloaded) notifyCartUpdated(cart);
372
- if (abandoned === 'abandoned' || !reloaded) postMessage({ type: 'payment:ended' });
373
- return reloaded;
374
- }
375
-
376
- // Back to the contact form rather than straight to 'payment'. Every outcome that offers Try Again is one
377
- // where nothing was charged, and in all but one of them the attempt has already been released (a decline
378
- // here, a Peach-reported expiry or error in onPaymentComplete) - so there is no card form left to return to:
379
- // Peach's SDK will not re-render a checkoutId it has unmounted, and a checkout that reached a decline is
380
- // finished on Peach's side regardless. Contact is where onPayNow creates a genuinely new one, with the
381
- // shopper's details still filled in (they live in this component, not in ContactForm) so paying again - on
382
- // another card, if that is what the decline was about - is one click away. The exception needs no handling
383
- // of its own: after an abandon the server refused, the attempt is still open and onPayNow's own paymentResult
384
- // short-cut resumes that same one from here, which is exactly what it is there for.
385
- function onRetry(): void {
386
- currentView = 'contact';
387
- }
388
-
389
- // The declined counterpart to releaseAbandonedAttempt, and deliberately without its abandon call: the server
390
- // has already resolved this attempt itself - the webhook landed with a decline, which is what moved the cart
391
- // to Failed and produced the PAYMENT_FAILED this follows - so there is nothing left to report. AbandonPaymentAsync
392
- // answers a Failed cart with AlreadyOpen, an explicit no-op, and skipping it also keeps this off the branch
393
- // that routes a 'settled' abandon back into runConfirmLoop, which would recurse from inside that very loop.
394
- // Clearing the attempt is what makes Try Again start a NEW Peach checkout instead of reopening the dead one:
395
- // onPayNow resumes paymentResult when it is still set, and resumeInterruptedPayment would land a reload
396
- // straight back on that same spent card form.
397
- async function releaseDeclinedAttempt(): Promise<void> {
398
- paymentResult = null;
399
- forgetPaymentAttempt();
400
- // Failed is live and still payable again - the decline leaves the cart's one clock exactly where it was -
401
- // 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
- if (await reloadCart()) notifyCartUpdated(cart);
404
- // Without this CartExpiryGuard keeps treating a payment as in flight for the rest of the page's life, so it
405
- // never prompts or expires this cart again.
406
- postMessage({ type: 'payment:ended' });
407
- }
408
-
409
- // One confirm attempt, interpreted: records the result and reports whether it settled (success or a
410
- // supplier-side partial failure - either way, nothing left to retry) versus still needs another attempt
411
- // (the two webhook races below). Shared by the automatic loop and the manual on-demand recheck so there is
412
- // exactly one place that decides what a given confirmCart() outcome means.
413
- async function attemptConfirm(): Promise<'settled' | 'awaitingWebhook'> {
414
- try {
415
- confirmResult = await api.confirmCart();
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
- }
424
- return 'settled';
425
- } catch (e) {
426
- // The one case that is a real, settled failure rather than a race still resolving: Peach's webhook has
427
- // already landed and it was a decline (or another non-success outcome), so there is no charge behind
428
- // this attempt and never will be - retrying confirmCart again cannot change that. Checked before the
429
- // generic 402/409 below, which would otherwise read this the same as "webhook not here yet" and retry
430
- // it right into the same generic "still confirming" pending screen a decline should never land on.
431
- if (isPaymentFailed(e)) {
432
- confirmOutcome = 'notCharged';
433
- await releaseDeclinedAttempt();
434
- return 'settled';
435
- }
436
-
437
- // Two different races with the same webhook, neither a real failure:
438
- // 402 - the charge succeeded (that's why PaymentPage's onCompleted fired) but the webhook hasn't
439
- // reached PeachWebhookEndpoints yet to move the cart to Paid.
440
- // 409 - the webhook got there first and is confirming the cart right now, so there is no outcome to
441
- // read yet. Only one caller is allowed to run confirmation, and the loser is told to retry
442
- // rather than being handed a second, independently-produced answer.
443
- // Both resolve on their own within a second or two, so both are worth retrying. Any other error settles
444
- // as 'pending' rather than a failure state - the charge already succeeded by the time this can run at
445
- // all, so there is no "payment failed" to report, only "not confirmed yet".
446
- if (e instanceof ApiError && (e.status === 402 || e.status === 409)) {
447
- return 'awaitingWebhook';
448
- }
449
- confirmOutcome = 'pending';
450
- return 'settled';
451
- }
452
- }
453
-
454
- async function runConfirmLoop() {
455
- isConfirming = true;
456
- confirmOutcome = null;
457
-
458
- for (let attempt = 1; attempt <= ConfirmMaxAttempts; attempt++) {
459
- if (await attemptConfirm() === 'settled') {
460
- isConfirming = false;
461
- return;
462
- }
463
-
464
- if (attempt === ConfirmMaxAttempts) {
465
- confirmOutcome = 'pending';
466
- isConfirming = false;
467
- return;
468
- }
469
-
470
- await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
471
- }
472
- }
473
-
474
- // A single on-demand recheck for the 'pending' screen's "Check Again" button - deliberately not another
475
- // full runConfirmLoop, which would re-impose its own ~15s auto-retry wait on someone who is already
476
- // actively engaged and can just click again. Confirming is idempotent (CheckoutCartConfirmer), so this
477
- // costs nothing to repeat; still 'pending' either way if the webhook still hasn't landed.
478
- async function checkConfirmationAgain() {
479
- isConfirming = true;
480
- if (await attemptConfirm() === 'awaitingWebhook') {
481
- confirmOutcome = 'pending';
482
- }
483
- isConfirming = false;
484
- }
485
-
486
- // The one ResultView prop that isn't a straight readout of confirmOutcome: 'partial' means the payment
487
- // succeeded but this gateway could not confirm every item, which is a real problem worth surfacing
488
- // distinctly from 'pending' (which resolves on its own) - but from ResultView's own outcome union, that is
489
- // still a kind of 'failed', just one retryPayment must gate off since the charge already happened.
490
- let resultStatus = $derived(
491
- isConfirming
492
- ? null
493
- : confirmOutcome === 'success'
494
- ? { outcome: 'successful' as const }
495
- : confirmOutcome === 'pending'
496
- ? { outcome: 'pending' as const }
497
- : 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
- }
504
- : confirmOutcome === 'notCharged'
505
- ? {
506
- outcome: 'failed' as const,
507
- resultDescription: 'Your payment could not be completed. Please try again.',
508
- retryPayment: true,
509
- }
510
- : null,
511
- );
512
187
  </script>
513
188
 
514
189
  <div class="modal">
@@ -517,27 +192,37 @@
517
192
  <div class="spinner"></div>
518
193
  </div>
519
194
 
520
- {:else if currentView === 'payment' && paymentResult}
521
- <PaymentPage
522
- checkoutId={paymentResult.checkoutId}
523
- entityId={paymentResult.entityId}
524
- expiresAt={expiryDeadline}
525
- {onPaymentComplete}
526
- />
195
+ {:else if currentView === 'payment'}
196
+ <!-- Still on the payment view but with no attempt left to render: releasing the previous attempt clears
197
+ paymentResult and then re-reads the cart before its caller routes anywhere, and without this the modal
198
+ fell straight through to the cart view below for the length of that round trip - so the shopper watched
199
+ their cart reappear before landing on the failure screen they were being sent to (PR 8447 review). -->
200
+ {#if flow.paymentResult}
201
+ <PaymentPage
202
+ checkoutId={flow.paymentResult.checkoutId}
203
+ entityId={flow.paymentResult.entityId}
204
+ expiresAt={expiryDeadline}
205
+ onPaymentComplete={flow.onPaymentComplete}
206
+ />
207
+ {:else}
208
+ <div class="loading-center" style="height:100%">
209
+ <div class="spinner"></div>
210
+ </div>
211
+ {/if}
527
212
 
528
213
  {:else if currentView === 'result'}
529
214
  <ResultView
530
- status={resultStatus}
531
- verifying={isConfirming}
215
+ status={flow.resultStatus}
216
+ verifying={flow.isConfirming}
532
217
  onDone={() => {
533
218
  // onOrderConfirmed only for the outcome that actually earns it - a 'pending'/'partial' Close is
534
219
  // just dismissing the dialog on an order this gateway cannot yet (or fully) vouch for, not
535
220
  // reporting it complete. The 'success' case already fired onOrderConfirmed the moment it was
536
- // known, inside runConfirmLoop/checkConfirmationAgain - this simply closes what's still open.
221
+ // known, inside the payment flow's own recordConfirmation - this simply closes what's still open.
537
222
  postMessage({ type: 'modal:close' });
538
223
  }}
539
- {onRetry}
540
- onCheckAgain={confirmOutcome === 'pending' ? checkConfirmationAgain : undefined}
224
+ onRetry={flow.retry}
225
+ onCheckAgain={flow.resultStatus?.outcome === 'pending' ? flow.checkStatusAgain : undefined}
541
226
  />
542
227
 
543
228
  {:else if currentView === 'edit' && editingItem && productsById.get(editingItem.productId)}
@@ -565,8 +250,8 @@
565
250
  </div>
566
251
 
567
252
  <div class="body">
568
- {#if payError}
569
- <p class="pay-error">{payError}</p>
253
+ {#if flow.payError}
254
+ <p class="pay-error">{flow.payError}</p>
570
255
  {/if}
571
256
  <div class="contact-header">
572
257
  <span class="contact-title">Contact Details</span>
@@ -593,8 +278,8 @@
593
278
 
594
279
  <div class="pay-bar">
595
280
  <span class="pay-bar-total">{formattedTotal()}</span>
596
- <button class="pay-bar-btn" onclick={onPayNow} disabled={isPaying}>
597
- {isPaying ? 'Paying…' : 'Pay Now'}
281
+ <button class="pay-bar-btn" onclick={onPayNow} disabled={flow.isPaying}>
282
+ {flow.isPaying ? 'Paying…' : 'Pay Now'}
598
283
  </button>
599
284
  </div>
600
285
 
@@ -611,20 +296,20 @@
611
296
  {#each cart.items as item}
612
297
  <div class="cart-card">
613
298
  <div class="cart-card-top">
614
- <h3 class="cart-product">{productTitle(item)}</h3>
299
+ <h3 class="cart-product">{productTitle(item, productsById)}</h3>
615
300
  <span class="cart-item-total">{formatCurrency(item.amount, item.currencyCode, 2, 0)}</span>
616
301
  </div>
617
302
  <div class="cart-detail-lines">
618
303
  {#if itemDateLabel(item)}
619
304
  <p class="cart-detail">{itemDateLabel(item)}</p>
620
305
  {/if}
621
- <p class="cart-detail">{optionTitle(item)}</p>
306
+ <p class="cart-detail">{optionTitle(item, productsById)}</p>
622
307
  </div>
623
308
 
624
309
  <div class="cart-divider"></div>
625
310
 
626
311
  <div class="cart-units">
627
- {#each groupUnits(item) as gu}
312
+ {#each groupUnits(item, productsById) as gu}
628
313
  <div class="cart-unit-line">
629
314
  <span class="cart-unit-label">{gu.title} &times; {gu.quantity}</span>
630
315
  <span class="cart-unit-price">{formatCurrency(gu.linePrice, item.currencyCode, 2, 0)}</span>
@@ -655,184 +340,4 @@
655
340
  {/if}
656
341
  </div>
657
342
 
658
- <style>
659
- .modal {
660
- display: flex;
661
- flex-direction: column;
662
- height: 100%;
663
- max-height: 100%;
664
- overflow: hidden;
665
- background: var(--bw-color-surface);
666
- }
667
- .body {
668
- flex: 1;
669
- overflow-y: auto;
670
- padding: 16px;
671
- }
672
-
673
- /* -- Cart view -- */
674
- .keep-shopping-btn {
675
- padding: 8px 16px;
676
- background: none;
677
- border: 2px solid #222;
678
- border-radius: var(--bw-radius-md);
679
- font-size: 13px;
680
- font-weight: 600;
681
- color: #222;
682
- cursor: pointer;
683
- }
684
- .keep-shopping-btn:hover {
685
- background: #f5f5f5;
686
- }
687
-
688
- .cart-card {
689
- border: 1px solid var(--bw-color-border);
690
- border-radius: var(--bw-radius-lg);
691
- padding: 20px;
692
- margin-bottom: 16px;
693
- }
694
- .cart-card-top {
695
- display: flex;
696
- justify-content: space-between;
697
- align-items: flex-start;
698
- gap: 16px;
699
- }
700
- .cart-product {
701
- font-size: 16px;
702
- font-weight: 700;
703
- flex: 1;
704
- }
705
- .cart-item-total {
706
- font-size: 16px;
707
- font-weight: 800;
708
- white-space: nowrap;
709
- }
710
- .cart-detail-lines {
711
- margin-top: 4px;
712
- }
713
- .cart-detail {
714
- font-size: 13px;
715
- color: var(--bw-color-text-secondary);
716
- line-height: 1.5;
717
- }
718
- .cart-divider {
719
- border-top: 1px dashed var(--bw-color-border);
720
- margin: 14px 0;
721
- }
722
- .cart-units {
723
- display: flex;
724
- flex-direction: column;
725
- gap: 6px;
726
- margin-bottom: 14px;
727
- }
728
- .cart-unit-line {
729
- display: flex;
730
- justify-content: space-between;
731
- align-items: center;
732
- font-size: 14px;
733
- }
734
- .cart-unit-label {
735
- color: #333;
736
- }
737
- .cart-unit-price {
738
- font-weight: 600;
739
- font-variant-numeric: tabular-nums;
740
- }
741
- .cart-actions {
742
- display: flex;
743
- gap: 16px;
744
- }
745
- .action-edit {
746
- background: none;
747
- border: none;
748
- font-size: 13px;
749
- font-weight: 700;
750
- color: var(--bw-color-primary);
751
- padding: 0;
752
- cursor: pointer;
753
- }
754
- .action-edit:hover { text-decoration: underline; }
755
- .action-remove {
756
- background: none;
757
- border: none;
758
- font-size: 13px;
759
- font-weight: 600;
760
- color: #555;
761
- padding: 0;
762
- cursor: pointer;
763
- }
764
- .action-remove:hover { text-decoration: underline; }
765
-
766
- .cart-total-divider {
767
- border-top: 3px solid #111;
768
- margin: 8px 0 20px;
769
- }
770
- .cart-total-row {
771
- display: flex;
772
- justify-content: space-between;
773
- align-items: center;
774
- font-size: 16px;
775
- font-weight: 700;
776
- margin-bottom: 20px;
777
- }
778
- .cart-total-amount {
779
- font-size: 20px;
780
- font-weight: 800;
781
- }
782
- /* -- Contact view -- */
783
- .pay-error {
784
- margin: 0 0 16px;
785
- padding: 12px 16px;
786
- background: #fdecea;
787
- color: #b3261e;
788
- font-size: 14px;
789
- border-radius: var(--bw-radius-md);
790
- }
791
- .contact-header {
792
- display: flex;
793
- justify-content: space-between;
794
- align-items: center;
795
- margin-bottom: 20px;
796
- }
797
- .contact-title {
798
- font-size: 16px;
799
- font-weight: 700;
800
- }
801
- .required-hint {
802
- font-size: 12px;
803
- color: var(--bw-color-primary);
804
- }
805
-
806
- .pay-bar {
807
- display: flex;
808
- align-items: center;
809
- flex-shrink: 0;
810
- background: var(--bw-color-primary);
811
- color: white;
812
- position: sticky;
813
- bottom: 0;
814
- }
815
- .pay-bar-total {
816
- padding: 0 20px;
817
- font-size: 16px;
818
- font-weight: 700;
819
- }
820
- .pay-bar-btn {
821
- flex: 1;
822
- display: flex;
823
- align-items: center;
824
- justify-content: flex-end;
825
- gap: 8px;
826
- height: 52px;
827
- padding: 0 20px;
828
- background: none;
829
- border: none;
830
- color: white;
831
- font-size: 16px;
832
- font-weight: 700;
833
- }
834
- .pay-bar-btn:disabled {
835
- opacity: 0.7;
836
- cursor: default;
837
- }
838
- </style>
343
+ <style src="./CheckoutModal.css"></style>