@code-collective/booking-widget 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +411 -384
- package/dist/booking-widget.min.js +16 -7
- package/dist/booking-widget.umd.cjs +2 -2
- package/package.json +1 -1
- package/src/lib/CartBar.svelte +4 -11
- package/src/lib/CartOverviewButton.svelte +4 -12
- package/src/lib/Checkout.svelte +12 -13
- package/src/lib/CheckoutModal.svelte +122 -30
- package/src/lib/EditBookingView.svelte +15 -4
- package/src/lib/PaymentPage.svelte +59 -61
- package/src/lib/ResultView.svelte +43 -3
- package/src/lib/TicketConfigurator.svelte +25 -3
- package/src/lib/api.ts +20 -2
- package/src/lib/cart-manager.ts +19 -6
- package/src/lib/client-types.ts +6 -0
- package/src/lib/elements/bw-cart.svelte +3 -12
- package/src/lib/elements/bw-checkout.svelte +9 -16
- package/src/lib/elements/bw-configurator.svelte +3 -13
- package/src/lib/elements/register.ts +3 -5
- package/src/lib/generated-types.ts +1 -0
- package/src/lib/index.ts +12 -28
- package/src/lib/messages.ts +63 -3
package/src/lib/Checkout.svelte
CHANGED
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
import type { WizardPages } from './config';
|
|
4
4
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
5
5
|
import type { CheckoutCartDetailDto } from './client-types';
|
|
6
|
-
import { postMessage } from './messages';
|
|
6
|
+
import { onWidgetMessage, postMessage } from './messages';
|
|
7
|
+
import type { CartManager } from './cart-manager';
|
|
7
8
|
import CheckoutModal from './CheckoutModal.svelte';
|
|
8
9
|
|
|
9
10
|
interface Props {
|
|
10
11
|
api: BookingApi;
|
|
12
|
+
cartManager?: CartManager;
|
|
11
13
|
wizardPages?: WizardPages;
|
|
12
14
|
editPages?: WizardPages;
|
|
13
15
|
autoSelectSingleTimeSlot?: boolean;
|
|
14
16
|
}
|
|
15
|
-
let { api, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp,
|
|
17
|
+
let { api, cartManager, wizardPages = DEFAULT_WIZARD_PAGES, editPages: editPagesProp,
|
|
18
|
+
autoSelectSingleTimeSlot = false }: Props = $props();
|
|
16
19
|
let editPages = $derived(editPagesProp ?? wizardPages);
|
|
17
20
|
|
|
18
21
|
let cart = $state<CheckoutCartDetailDto | null>(null);
|
|
@@ -34,17 +37,9 @@
|
|
|
34
37
|
// would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
|
|
35
38
|
// cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
|
|
36
39
|
// triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
catch { return; }
|
|
41
|
-
if (d?.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
$effect(() => {
|
|
45
|
-
window.addEventListener('message', handleMessage);
|
|
46
|
-
return () => window.removeEventListener('message', handleMessage);
|
|
47
|
-
});
|
|
40
|
+
$effect(() => onWidgetMessage((d) => {
|
|
41
|
+
if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
42
|
+
}));
|
|
48
43
|
</script>
|
|
49
44
|
|
|
50
45
|
<div class="bw-widget">
|
|
@@ -69,6 +64,10 @@
|
|
|
69
64
|
value: total,
|
|
70
65
|
currency,
|
|
71
66
|
});
|
|
67
|
+
// The cart is paid and confirmed, so its token has done its job. Nothing used to clear it, so it sat
|
|
68
|
+
// in localStorage on the merchant's origin until absoluteExpiresAt - readable by every third-party
|
|
69
|
+
// script they load, and picked up by the next person to use a shared or kiosk browser.
|
|
70
|
+
cartManager?.reset();
|
|
72
71
|
}}
|
|
73
72
|
/>
|
|
74
73
|
{:else}
|
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
let editingItem = $state<CheckoutCartItemDetailDto | null>(null);
|
|
36
36
|
let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
|
|
37
37
|
let confirmResult = $state<CheckoutCartConfirmResultDto | null>(null);
|
|
38
|
-
|
|
38
|
+
// What the confirm attempt actually produced, once it's settled - not a plain success/failure boolean,
|
|
39
|
+
// because "payment succeeded but this gateway couldn't confirm it in time" and "payment itself never went
|
|
40
|
+
// through" need different copy and different actions (see the ResultView status computed below and
|
|
41
|
+
// runConfirmLoop's own remarks). null while still in flight.
|
|
42
|
+
let confirmOutcome = $state<'success' | 'pending' | 'partial' | 'notCharged' | null>(null);
|
|
39
43
|
let isLoading = $state(true);
|
|
40
44
|
let isConfirming = $state(false);
|
|
41
45
|
let privacyAccepted = $state(false);
|
|
@@ -171,6 +175,16 @@
|
|
|
171
175
|
hasAttemptedSubmit = true;
|
|
172
176
|
if (!contactForm?.isValid() || !privacyAccepted) return;
|
|
173
177
|
|
|
178
|
+
// A cart only ever gets one Peach checkout - RecordPaymentInitiatedAsync's own concurrency lock rejects
|
|
179
|
+
// a second payCart call on the same cart with a 409 ("already has a payment initiated"), even though
|
|
180
|
+
// nothing has actually been charged yet. Cancelling out of PaymentPage (onPaymentComplete's 'cancelled'
|
|
181
|
+
// case below) never clears paymentResult, so re-entering here just resumes that same still-open attempt
|
|
182
|
+
// instead of re-initiating.
|
|
183
|
+
if (paymentResult) {
|
|
184
|
+
currentView = 'payment';
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
174
188
|
const contact = contactForm.getContact();
|
|
175
189
|
|
|
176
190
|
isPaying = true;
|
|
@@ -199,41 +213,115 @@
|
|
|
199
213
|
return;
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
// Neither of these ever reached a real charge - Peach's own SDK is reporting that the checkout itself
|
|
217
|
+
// didn't go through (session timeout, a declined card, a 3DS failure, a client-side error), not that a
|
|
218
|
+
// successful charge's confirmation is in question. That distinction matters: every path below this point
|
|
219
|
+
// is only reachable once onCompleted has already fired, meaning the card WAS charged, so "try again"
|
|
220
|
+
// there would risk a second charge rather than retrying a payment that never happened. Here, nothing was
|
|
221
|
+
// charged, so re-opening the card form is exactly correct.
|
|
222
|
+
if (result.status === 'expired' || result.status === 'error') {
|
|
223
|
+
confirmOutcome = 'notCharged';
|
|
204
224
|
isConfirming = false;
|
|
205
225
|
currentView = 'result';
|
|
206
226
|
return;
|
|
207
227
|
}
|
|
208
228
|
|
|
209
|
-
isConfirming = true;
|
|
210
229
|
currentView = 'result';
|
|
230
|
+
await runConfirmLoop();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// One confirm attempt, interpreted: records the result and reports whether it settled (success or a
|
|
234
|
+
// supplier-side partial failure - either way, nothing left to retry) versus still needs another attempt
|
|
235
|
+
// (the two webhook races below). Shared by the automatic loop and the manual on-demand recheck so there is
|
|
236
|
+
// exactly one place that decides what a given confirmCart() outcome means.
|
|
237
|
+
async function attemptConfirm(): Promise<'settled' | 'awaitingWebhook'> {
|
|
238
|
+
try {
|
|
239
|
+
confirmResult = await api.confirmCart();
|
|
240
|
+
const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
|
|
241
|
+
confirmOutcome = allOk ? 'success' : 'partial';
|
|
242
|
+
// Notifies the host (order value/currency for analytics, clearing the stored cart token) without
|
|
243
|
+
// closing the modal - the shopper still needs to see the success screen below, and only dismisses it
|
|
244
|
+
// themselves via ResultView's Done button, which is the one thing that posts modal:close.
|
|
245
|
+
if (allOk) {
|
|
246
|
+
onOrderConfirmed();
|
|
247
|
+
}
|
|
248
|
+
return 'settled';
|
|
249
|
+
} catch (e) {
|
|
250
|
+
// Two different races with the same webhook, neither a real failure:
|
|
251
|
+
// 402 - the charge succeeded (that's why PaymentPage's onCompleted fired) but the webhook hasn't
|
|
252
|
+
// reached PeachWebhookEndpoints yet to move the cart to Paid.
|
|
253
|
+
// 409 - the webhook got there first and is confirming the cart right now, so there is no outcome to
|
|
254
|
+
// read yet. Only one caller is allowed to run confirmation, and the loser is told to retry
|
|
255
|
+
// rather than being handed a second, independently-produced answer.
|
|
256
|
+
// Both resolve on their own within a second or two, so both are worth retrying. Any other error settles
|
|
257
|
+
// as 'pending' rather than a failure state - the charge already succeeded by the time this can run at
|
|
258
|
+
// all, so there is no "payment failed" to report, only "not confirmed yet".
|
|
259
|
+
if (e instanceof ApiError && (e.status === 402 || e.status === 409)) {
|
|
260
|
+
return 'awaitingWebhook';
|
|
261
|
+
}
|
|
262
|
+
confirmOutcome = 'pending';
|
|
263
|
+
return 'settled';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function runConfirmLoop() {
|
|
268
|
+
isConfirming = true;
|
|
269
|
+
confirmOutcome = null;
|
|
211
270
|
|
|
212
271
|
for (let attempt = 1; attempt <= ConfirmMaxAttempts; attempt++) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
272
|
+
if (await attemptConfirm() === 'settled') {
|
|
273
|
+
isConfirming = false;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (attempt === ConfirmMaxAttempts) {
|
|
278
|
+
confirmOutcome = 'pending';
|
|
217
279
|
isConfirming = false;
|
|
218
|
-
if (allOk) {
|
|
219
|
-
postMessage({ type: 'modal:close' });
|
|
220
|
-
onOrderConfirmed();
|
|
221
|
-
}
|
|
222
280
|
return;
|
|
223
|
-
} catch (e) {
|
|
224
|
-
// 402 means the card charge already succeeded (that's why PaymentPage's onCompleted fired at all) but
|
|
225
|
-
// Peach's own webhook hasn't reached PeachWebhookEndpoints yet to move the cart to Paid server-side -
|
|
226
|
-
// a race with the webhook, not a real failure, so it's worth a few retries rather than failing outright.
|
|
227
|
-
const isAwaitingWebhook = e instanceof ApiError && e.status === 402;
|
|
228
|
-
if (!isAwaitingWebhook || attempt === ConfirmMaxAttempts) {
|
|
229
|
-
confirmFailed = true;
|
|
230
|
-
isConfirming = false;
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
|
|
234
281
|
}
|
|
282
|
+
|
|
283
|
+
await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
|
|
235
284
|
}
|
|
236
285
|
}
|
|
286
|
+
|
|
287
|
+
// A single on-demand recheck for the 'pending' screen's "Check Again" button - deliberately not another
|
|
288
|
+
// full runConfirmLoop, which would re-impose its own ~15s auto-retry wait on someone who is already
|
|
289
|
+
// actively engaged and can just click again. Confirming is idempotent (CheckoutCartConfirmer), so this
|
|
290
|
+
// costs nothing to repeat; still 'pending' either way if the webhook still hasn't landed.
|
|
291
|
+
async function checkConfirmationAgain() {
|
|
292
|
+
isConfirming = true;
|
|
293
|
+
if (await attemptConfirm() === 'awaitingWebhook') {
|
|
294
|
+
confirmOutcome = 'pending';
|
|
295
|
+
}
|
|
296
|
+
isConfirming = false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The one ResultView prop that isn't a straight readout of confirmOutcome: 'partial' means the payment
|
|
300
|
+
// succeeded but this gateway could not confirm every item, which is a real problem worth surfacing
|
|
301
|
+
// distinctly from 'pending' (which resolves on its own) - but from ResultView's own outcome union, that is
|
|
302
|
+
// still a kind of 'failed', just one retryPayment must gate off since the charge already happened.
|
|
303
|
+
let resultStatus = $derived(
|
|
304
|
+
isConfirming
|
|
305
|
+
? null
|
|
306
|
+
: confirmOutcome === 'success'
|
|
307
|
+
? { outcome: 'successful' as const }
|
|
308
|
+
: confirmOutcome === 'pending'
|
|
309
|
+
? { outcome: 'pending' as const }
|
|
310
|
+
: confirmOutcome === 'partial'
|
|
311
|
+
? {
|
|
312
|
+
outcome: 'failed' as const,
|
|
313
|
+
resultDescription:
|
|
314
|
+
"Your payment succeeded, but we couldn't confirm every item in your booking. Please contact us with your order details.",
|
|
315
|
+
retryPayment: false,
|
|
316
|
+
}
|
|
317
|
+
: confirmOutcome === 'notCharged'
|
|
318
|
+
? {
|
|
319
|
+
outcome: 'failed' as const,
|
|
320
|
+
resultDescription: 'Your payment could not be completed. Please try again.',
|
|
321
|
+
retryPayment: true,
|
|
322
|
+
}
|
|
323
|
+
: null,
|
|
324
|
+
);
|
|
237
325
|
</script>
|
|
238
326
|
|
|
239
327
|
<div class="modal">
|
|
@@ -245,20 +333,23 @@
|
|
|
245
333
|
{:else if currentView === 'payment' && paymentResult}
|
|
246
334
|
<PaymentPage
|
|
247
335
|
checkoutId={paymentResult.checkoutId}
|
|
248
|
-
|
|
249
|
-
onBack={() => { currentView = 'contact'; }}
|
|
250
|
-
onClose={close}
|
|
336
|
+
entityId={paymentResult.entityId}
|
|
251
337
|
{onPaymentComplete}
|
|
252
338
|
/>
|
|
253
339
|
|
|
254
340
|
{:else if currentView === 'result'}
|
|
255
341
|
<ResultView
|
|
256
|
-
status={
|
|
257
|
-
? { outcome: 'failed', resultDescription: 'Something went wrong confirming your order. Please try again.' }
|
|
258
|
-
: { outcome: 'successful' }}
|
|
342
|
+
status={resultStatus}
|
|
259
343
|
verifying={isConfirming}
|
|
260
|
-
onDone={() => {
|
|
344
|
+
onDone={() => {
|
|
345
|
+
// onOrderConfirmed only for the outcome that actually earns it - a 'pending'/'partial' Close is
|
|
346
|
+
// just dismissing the dialog on an order this gateway cannot yet (or fully) vouch for, not
|
|
347
|
+
// reporting it complete. The 'success' case already fired onOrderConfirmed the moment it was
|
|
348
|
+
// known, inside runConfirmLoop/checkConfirmationAgain - this simply closes what's still open.
|
|
349
|
+
postMessage({ type: 'modal:close' });
|
|
350
|
+
}}
|
|
261
351
|
onRetry={() => { currentView = 'payment'; }}
|
|
352
|
+
onCheckAgain={confirmOutcome === 'pending' ? checkConfirmationAgain : undefined}
|
|
262
353
|
/>
|
|
263
354
|
|
|
264
355
|
{:else if currentView === 'edit' && editingItem && productsById.get(editingItem.productId)}
|
|
@@ -272,6 +363,7 @@
|
|
|
272
363
|
onBack={() => { currentView = 'cart'; }}
|
|
273
364
|
onClose={close}
|
|
274
365
|
onUpdated={onItemUpdated}
|
|
366
|
+
onPriceChanged={loadProducts}
|
|
275
367
|
/>
|
|
276
368
|
|
|
277
369
|
{:else if currentView === 'contact'}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { CartItem, CheckoutCartItemDetailDto, CheckoutProductDto } from './client-types';
|
|
3
3
|
import type { BookingApi } from './api';
|
|
4
|
-
import { ApiError } from './api';
|
|
4
|
+
import { ApiError, isPriceMismatch } from './api';
|
|
5
5
|
import type { WizardPages } from './config';
|
|
6
6
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
7
7
|
import { expandUnitItems } from './utils';
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
onBack: () => void;
|
|
18
18
|
onClose: () => void;
|
|
19
19
|
onUpdated: () => void;
|
|
20
|
+
/// Re-fetches the products this view is handed, so a price that moved under the customer can be shown to
|
|
21
|
+
/// them. Owned by the parent because `product` is a prop here - this view cannot refresh it itself.
|
|
22
|
+
onPriceChanged?: () => Promise<void>;
|
|
20
23
|
}
|
|
21
|
-
let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated }: Props = $props();
|
|
24
|
+
let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated, onPriceChanged }: Props = $props();
|
|
22
25
|
|
|
23
26
|
let isSaving = $state(false);
|
|
24
27
|
let errorMessage = $state<string | null>(null);
|
|
@@ -34,12 +37,20 @@
|
|
|
34
37
|
availabilityId: item.availabilityId,
|
|
35
38
|
localDate: item.localDate,
|
|
36
39
|
pickupPointId: item.pickupPointId,
|
|
40
|
+
// Currency and precision are resolved server-side - see the same call in TicketConfigurator.
|
|
37
41
|
amount: item.totalPrice,
|
|
38
|
-
currencyCode: item.currency,
|
|
39
|
-
currencyPrecision: item.currencyPrecision,
|
|
40
42
|
});
|
|
41
43
|
onUpdated();
|
|
42
44
|
} catch (e) {
|
|
45
|
+
// The supplier moved the price mid-edit, so the amount being submitted is no longer one the server will
|
|
46
|
+
// accept. Retrying resubmits the same stale figure and fails identically, so the prices this view is
|
|
47
|
+
// showing have to be refreshed before the customer can do anything useful.
|
|
48
|
+
if (isPriceMismatch(e)) {
|
|
49
|
+
errorMessage = "This item's price has changed. Please check the updated price and save again.";
|
|
50
|
+
await onPriceChanged?.();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
// 424 means the cart item itself was rolled back server-side (see CheckoutCartEndpoints.UpdateItemAsync) -
|
|
44
55
|
// the supplier rejected the change, so nothing was saved. Surface that instead of letting it vanish
|
|
45
56
|
// silently: WizardPage stays mounted (not swapped for a spinner) specifically so the user's in-progress
|
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
checkoutId: string;
|
|
7
|
-
|
|
8
|
-
onBack: () => void;
|
|
9
|
-
onClose: () => void;
|
|
7
|
+
entityId: string;
|
|
10
8
|
onPaymentComplete: (result: { status: string }) => void;
|
|
11
9
|
}
|
|
12
|
-
let { checkoutId,
|
|
10
|
+
let { checkoutId, entityId, onPaymentComplete }: Props = $props();
|
|
13
11
|
|
|
14
12
|
let failed = $state(false);
|
|
15
13
|
let errorMessage = $state('');
|
|
@@ -30,9 +28,16 @@
|
|
|
30
28
|
|
|
31
29
|
peachInstance = Checkout.initiate({
|
|
32
30
|
checkoutId,
|
|
31
|
+
// Peach's own "key" field, not our checkoutKey/entityId naming - without it Checkout.initiate()
|
|
32
|
+
// throws (Cannot read properties of undefined (reading 'trim')) before ever rendering anything,
|
|
33
|
+
// since the embedded widget authenticates to Peach directly with this rather than through us.
|
|
34
|
+
key: entityId,
|
|
33
35
|
customisations: {
|
|
34
36
|
card: { submitButtonText: 'Pay Now' },
|
|
35
37
|
theme: { brand: { primary: '#E30613' } },
|
|
38
|
+
// Peach's own Cancel (method-selection screen) and Back (card-entry screen) are the only
|
|
39
|
+
// back/exit controls in this dialog - we don't render one of our own alongside them, so there's
|
|
40
|
+
// exactly one way out per screen instead of ours and Peach's competing for the same job.
|
|
36
41
|
},
|
|
37
42
|
eventHandlers: {
|
|
38
43
|
onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
|
|
@@ -60,77 +65,70 @@
|
|
|
60
65
|
onDestroy(cleanup);
|
|
61
66
|
</script>
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<div class="
|
|
72
|
-
<div class="
|
|
73
|
-
<
|
|
74
|
-
<span class="amount">{totalFormatted}</span>
|
|
68
|
+
<!-- Peach's own card form renders inside a cross-origin iframe with its own Cancel/Back controls we have no
|
|
69
|
+
way to hide or reach (no SDK option, no CSS/JS access across origins) - see Checkout.initiate()'s
|
|
70
|
+
customisations doc comment above. Presenting it as its own nested dialog over the checkout modal, rather
|
|
71
|
+
than as just another view swapped into that modal's own body, means Peach's own Cancel/Back read as
|
|
72
|
+
belonging to Peach's own layer instead of visually competing with a second, separate Back of ours - so
|
|
73
|
+
this dialog has no back/exit control of its own at all; onCancelled below routes back to contact. -->
|
|
74
|
+
<div class="payment-overlay">
|
|
75
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
76
|
+
<div class="payment-dialog" onclick={(e) => e.stopPropagation()}>
|
|
77
|
+
<div class="page-header">
|
|
78
|
+
<h2>Payment</h2>
|
|
75
79
|
</div>
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<div class="error-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
</div>
|
|
89
|
-
<div id="peach-container"></div>
|
|
90
|
-
{/if}
|
|
81
|
+
<div class="body">
|
|
82
|
+
{#if failed}
|
|
83
|
+
<div class="error-state">
|
|
84
|
+
<div class="error-icon">!</div>
|
|
85
|
+
<p class="error-title">Unable to load payment form</p>
|
|
86
|
+
<p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
|
|
87
|
+
</div>
|
|
88
|
+
{:else}
|
|
89
|
+
<div id="peach-container"></div>
|
|
90
|
+
{/if}
|
|
91
|
+
</div>
|
|
91
92
|
</div>
|
|
92
93
|
</div>
|
|
93
94
|
|
|
94
95
|
<style>
|
|
95
|
-
.payment-
|
|
96
|
+
.payment-overlay {
|
|
97
|
+
position: fixed;
|
|
98
|
+
inset: 0;
|
|
99
|
+
/* Above bw-checkout's own .bw-modal-overlay (z-index 10000) so this reads as a second dialog opening
|
|
100
|
+
on top of the checkout modal, not content living inside it. */
|
|
101
|
+
z-index: 10001;
|
|
102
|
+
background: rgba(0, 0, 0, 0.5);
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
padding: 16px;
|
|
107
|
+
}
|
|
108
|
+
.payment-dialog {
|
|
96
109
|
display: flex;
|
|
97
110
|
flex-direction: column;
|
|
98
|
-
|
|
111
|
+
width: 100%;
|
|
112
|
+
max-width: 480px;
|
|
113
|
+
height: min(700px, 90vh);
|
|
114
|
+
background: var(--bw-color-bg);
|
|
115
|
+
border-radius: var(--bw-radius-lg);
|
|
116
|
+
box-shadow: var(--bw-shadow-3);
|
|
117
|
+
overflow: hidden;
|
|
99
118
|
}
|
|
100
119
|
.body {
|
|
101
120
|
flex: 1;
|
|
121
|
+
/* A definite height (not just flex:1 alone) so #peach-container's own height:100% below has something
|
|
122
|
+
real to resolve against, and so this scrolls as a single region if content overflows - Peach's own
|
|
123
|
+
root is `height: inherit`, so without that it collapses to its 360px fallback regardless of how tall
|
|
124
|
+
the card form actually gets (e.g. once billing address fields are showing), and its submit button
|
|
125
|
+
ends up overlapping the fields above it. */
|
|
126
|
+
min-height: 0;
|
|
102
127
|
overflow-y: auto;
|
|
103
128
|
padding: 16px;
|
|
104
129
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
justify-content: space-between;
|
|
108
|
-
align-items: center;
|
|
109
|
-
padding: 16px;
|
|
110
|
-
background: var(--bw-color-surface);
|
|
111
|
-
border-radius: var(--bw-radius-lg);
|
|
112
|
-
margin-bottom: 20px;
|
|
113
|
-
font-weight: 600;
|
|
114
|
-
font-size: 15px;
|
|
115
|
-
}
|
|
116
|
-
.amount {
|
|
117
|
-
color: var(--bw-color-primary);
|
|
118
|
-
font-size: 18px;
|
|
119
|
-
font-weight: 700;
|
|
120
|
-
}
|
|
121
|
-
.brands {
|
|
122
|
-
display: flex;
|
|
123
|
-
gap: 8px;
|
|
124
|
-
margin-bottom: 16px;
|
|
125
|
-
}
|
|
126
|
-
.brand {
|
|
127
|
-
padding: 4px 10px;
|
|
128
|
-
border: 1px solid var(--bw-color-border);
|
|
129
|
-
border-radius: var(--bw-radius-sm);
|
|
130
|
-
font-size: 11px;
|
|
131
|
-
font-weight: 700;
|
|
132
|
-
color: var(--bw-color-text-secondary);
|
|
133
|
-
letter-spacing: 0.02em;
|
|
130
|
+
#peach-container {
|
|
131
|
+
height: 100%;
|
|
134
132
|
}
|
|
135
133
|
.error-state {
|
|
136
134
|
text-align: center;
|
|
@@ -6,19 +6,43 @@
|
|
|
6
6
|
verifying: boolean;
|
|
7
7
|
onDone: () => void;
|
|
8
8
|
onRetry: () => void;
|
|
9
|
+
// Re-checks confirmation on demand without touching payment - only meaningful (and only rendered) for
|
|
10
|
+
// a 'pending' outcome. Optional so callers that predate this state need not pass it.
|
|
11
|
+
onCheckAgain?: () => void;
|
|
9
12
|
}
|
|
10
|
-
let { status, verifying, onDone, onRetry }: Props = $props();
|
|
13
|
+
let { status, verifying, onDone, onRetry, onCheckAgain }: Props = $props();
|
|
11
14
|
|
|
12
15
|
let success = $derived(status?.outcome === 'successful');
|
|
16
|
+
let pending = $derived(status?.outcome === 'pending');
|
|
17
|
+
// A 'failed' outcome covers two genuinely different situations - see CheckoutModal's own remarks on
|
|
18
|
+
// confirmOutcome. Only one of them is safe to offer "Try Again" for: the payment itself never went
|
|
19
|
+
// through (declined, expired, SDK error), so re-opening the card form charges the shopper once, not
|
|
20
|
+
// twice. The other - payment succeeded but this gateway could not confirm every item - already has the
|
|
21
|
+
// shopper's money against a real charge; offering to pay again there would risk a second charge for
|
|
22
|
+
// nothing this screen can fix, so status.retryPayment gates the button rather than the outcome alone.
|
|
23
|
+
let retryable = $derived(status?.outcome === 'failed' && status.retryPayment !== false);
|
|
13
24
|
</script>
|
|
14
25
|
|
|
15
26
|
<div class="result">
|
|
16
27
|
{#if verifying}
|
|
17
28
|
<div class="spinner"></div>
|
|
18
29
|
<p class="hint">Verifying payment...</p>
|
|
30
|
+
{:else if pending}
|
|
31
|
+
<div class="icon pending">…</div>
|
|
32
|
+
<h2>Payment Received</h2>
|
|
33
|
+
<p class="desc">
|
|
34
|
+
{status?.resultDescription ??
|
|
35
|
+
"We're still confirming your booking - this can take a minute. Please don't try to pay again; we'll email your confirmation as soon as it's ready."}
|
|
36
|
+
</p>
|
|
37
|
+
<div class="actions">
|
|
38
|
+
{#if onCheckAgain}
|
|
39
|
+
<button class="btn btn-primary" onclick={onCheckAgain}>Check Again</button>
|
|
40
|
+
{/if}
|
|
41
|
+
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
42
|
+
</div>
|
|
19
43
|
{:else}
|
|
20
44
|
<div class="icon" class:success class:failure={!success}>
|
|
21
|
-
{success ? '
|
|
45
|
+
{success ? '✓' : '!'}
|
|
22
46
|
</div>
|
|
23
47
|
<h2>{success ? 'Payment Successful' : 'Payment Failed'}</h2>
|
|
24
48
|
<p class="desc">
|
|
@@ -28,8 +52,10 @@
|
|
|
28
52
|
</p>
|
|
29
53
|
{#if success}
|
|
30
54
|
<button class="btn btn-primary" onclick={onDone}>Done</button>
|
|
31
|
-
{:else}
|
|
55
|
+
{:else if retryable}
|
|
32
56
|
<button class="btn btn-outline" onclick={onRetry}>Try Again</button>
|
|
57
|
+
{:else}
|
|
58
|
+
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
33
59
|
{/if}
|
|
34
60
|
{/if}
|
|
35
61
|
</div>
|
|
@@ -69,6 +95,13 @@
|
|
|
69
95
|
color: var(--bw-color-error);
|
|
70
96
|
background: var(--bw-color-primary-light);
|
|
71
97
|
}
|
|
98
|
+
/* Deliberately its own amber, not success green or failure red - payment succeeded (so red reads as an
|
|
99
|
+
alarming false alarm) but nothing is confirmed yet (so green would be premature). */
|
|
100
|
+
.icon.pending {
|
|
101
|
+
border: 2px solid #B8860B;
|
|
102
|
+
color: #B8860B;
|
|
103
|
+
background: rgba(184, 134, 11, 0.08);
|
|
104
|
+
}
|
|
72
105
|
h2 {
|
|
73
106
|
font-size: 20px;
|
|
74
107
|
font-weight: 700;
|
|
@@ -81,4 +114,11 @@
|
|
|
81
114
|
max-width: 280px;
|
|
82
115
|
line-height: 1.5;
|
|
83
116
|
}
|
|
117
|
+
.actions {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
gap: 8px;
|
|
121
|
+
width: 100%;
|
|
122
|
+
max-width: 220px;
|
|
123
|
+
}
|
|
84
124
|
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { BookingApi } from './api';
|
|
3
|
-
import { ApiError } from './api';
|
|
3
|
+
import { ApiError, isPriceMismatch } from './api';
|
|
4
4
|
import type { WizardPages } from './config';
|
|
5
5
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
6
|
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
@@ -43,9 +43,11 @@
|
|
|
43
43
|
availabilityId: item.availabilityId,
|
|
44
44
|
localDate: item.localDate,
|
|
45
45
|
pickupPointId: item.pickupPointId,
|
|
46
|
+
// Currency and precision are the supplier's to state, not ours - the API resolves both server-side and
|
|
47
|
+
// ignores anything sent here. Amount stays, but only as a claim about the price the customer was shown:
|
|
48
|
+
// the server accepts it solely when it matches what the supplier is asking or a price the server itself
|
|
49
|
+
// published, and returns PRICE_MISMATCH otherwise.
|
|
46
50
|
amount: item.totalPrice,
|
|
47
|
-
currencyCode: item.currency,
|
|
48
|
-
currencyPrecision: item.currencyPrecision,
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
@@ -72,6 +74,15 @@
|
|
|
72
74
|
try {
|
|
73
75
|
await addItemToCart(item);
|
|
74
76
|
} catch (e) {
|
|
77
|
+
// The supplier moved the price while the customer was configuring, and the amount they agreed to is no
|
|
78
|
+
// longer one the server accepts. Retrying is useless - the wizard still holds the old prices, so it
|
|
79
|
+
// would resubmit exactly the same amount and fail identically. The only way out is to re-fetch and let
|
|
80
|
+
// the customer see the new price, which is what the message asks them to do.
|
|
81
|
+
if (isPriceMismatch(e)) {
|
|
82
|
+
await reloadAfterPriceChange();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
76
87
|
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
77
88
|
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
@@ -91,6 +102,17 @@
|
|
|
91
102
|
isAddingToCart = false;
|
|
92
103
|
}
|
|
93
104
|
}
|
|
105
|
+
|
|
106
|
+
async function reloadAfterPriceChange() {
|
|
107
|
+
addToCartError = "This ticket's price has changed since you started. Please check the updated price and try again.";
|
|
108
|
+
try {
|
|
109
|
+
await load();
|
|
110
|
+
} catch {
|
|
111
|
+
// The reload is what makes the message actionable; if even that fails the customer needs to start over
|
|
112
|
+
// rather than be left looking at prices we already know are wrong.
|
|
113
|
+
addToCartError = "This ticket's price has changed and we could not load the new one. Please reload the page.";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
94
116
|
</script>
|
|
95
117
|
|
|
96
118
|
<div class="bw-widget">
|
package/src/lib/api.ts
CHANGED
|
@@ -18,9 +18,27 @@ import { dateKey } from './utils';
|
|
|
18
18
|
// Lets a caller distinguish "the server said no, and specifically why" (e.g. 402 - not paid yet, retryable
|
|
19
19
|
// while Peach's webhook is still in flight) from any other failure, without parsing the message string.
|
|
20
20
|
export class ApiError extends Error {
|
|
21
|
-
constructor(
|
|
21
|
+
constructor(
|
|
22
|
+
public readonly status: number,
|
|
23
|
+
public readonly body?: unknown,
|
|
24
|
+
) {
|
|
22
25
|
super(`API error ${status}`);
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
// The server's own machine-readable reason, where it sends one. Several distinct failures share a status -
|
|
29
|
+
// a price that moved and a malformed request are both 400 - and only this tells them apart.
|
|
30
|
+
get code(): string | undefined {
|
|
31
|
+
return typeof this.body === 'object' && this.body !== null && 'error' in this.body
|
|
32
|
+
? String((this.body as { error: unknown }).error)
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The price of an item changed between the widget displaying it and the item being added, and the amount the
|
|
38
|
+
// customer agreed to is no longer one the server will accept. Recoverable, but only by showing the customer
|
|
39
|
+
// the new price - see PRICE_MISMATCH handling in TicketConfigurator/EditBookingView.
|
|
40
|
+
export function isPriceMismatch(e: unknown): e is ApiError {
|
|
41
|
+
return e instanceof ApiError && e.status === 400 && e.code === 'PRICE_MISMATCH';
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
export interface BookingApi {
|
|
@@ -78,7 +96,7 @@ export class ApiClient implements BookingApi {
|
|
|
78
96
|
|
|
79
97
|
private unwrap<T>(result: { data?: T; error?: unknown; response: Response }): T {
|
|
80
98
|
if (result.data === undefined) {
|
|
81
|
-
throw new ApiError(result.response.status);
|
|
99
|
+
throw new ApiError(result.response.status, result.error);
|
|
82
100
|
}
|
|
83
101
|
return result.data;
|
|
84
102
|
}
|