@code-collective/booking-widget 1.0.6 → 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 +18 -1
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +1874 -2121
- 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 +6 -11
- package/src/lib/CartOverviewButton.svelte +17 -47
- package/src/lib/Checkout.svelte +18 -2
- package/src/lib/CheckoutModal.svelte +189 -30
- package/src/lib/ContactForm.svelte +15 -6
- package/src/lib/EditBookingView.svelte +63 -7
- package/src/lib/PaymentPage.svelte +89 -86
- package/src/lib/ResultView.svelte +43 -3
- package/src/lib/TicketConfigurator.svelte +85 -26
- package/src/lib/api.ts +28 -2
- package/src/lib/cart-manager.ts +30 -4
- package/src/lib/client-types.ts +6 -0
- package/src/lib/config.ts +11 -8
- 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/env.ts +0 -1
- package/src/lib/elements/register.ts +14 -3
- package/src/lib/generated-types.ts +1 -0
- package/src/lib/index.ts +30 -35
- package/src/lib/messages.ts +63 -3
- package/src/lib/peach-sdk.ts +40 -0
- package/src/lib/fake-api.ts +0 -282
- package/src/lib/mock-data.ts +0 -241
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import type { CheckoutCartDetailDto } from './client-types';
|
|
3
3
|
import type { BookingApi } from './api';
|
|
4
4
|
import { CartManager } from './cart-manager';
|
|
5
|
-
import {
|
|
6
|
-
import { postMessage } from './messages';
|
|
5
|
+
import { onWidgetMessage, postMessage } from './messages';
|
|
7
6
|
import { onDestroy } from 'svelte';
|
|
8
7
|
|
|
9
8
|
interface Props {
|
|
@@ -28,17 +27,11 @@
|
|
|
28
27
|
|
|
29
28
|
// load() above only runs once on mount - without this, adding/editing/removing an item elsewhere on the
|
|
30
29
|
// page (the configurator, or the checkout modal's own edit/remove) leaves this button showing a stale total.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
$effect(() => {
|
|
39
|
-
window.addEventListener('message', handleMessage);
|
|
40
|
-
return () => window.removeEventListener('message', handleMessage);
|
|
41
|
-
});
|
|
30
|
+
// The event already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so
|
|
31
|
+
// this applies it directly rather than triggering a second, redundant getCart() call.
|
|
32
|
+
$effect(() => onWidgetMessage((d) => {
|
|
33
|
+
if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
34
|
+
}));
|
|
42
35
|
|
|
43
36
|
function updateTimer() {
|
|
44
37
|
if (!cart) { remaining = '0:00'; return; }
|
|
@@ -53,13 +46,6 @@
|
|
|
53
46
|
onDestroy(() => clearInterval(interval));
|
|
54
47
|
|
|
55
48
|
let itemCount = $derived(cart?.items.length ?? 0);
|
|
56
|
-
|
|
57
|
-
let totalLabel = $derived(() => {
|
|
58
|
-
if (!cart || cart.items.length === 0) return '';
|
|
59
|
-
const currency = cart.items[0].currencyCode;
|
|
60
|
-
const total = cart.items.reduce((sum, i) => sum + i.amount, 0);
|
|
61
|
-
return formatCurrency(total, currency, 2, 0);
|
|
62
|
-
});
|
|
63
49
|
</script>
|
|
64
50
|
|
|
65
51
|
{#if cart && cart.items.length > 0}
|
|
@@ -69,8 +55,6 @@
|
|
|
69
55
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
|
70
56
|
</svg>
|
|
71
57
|
<span class="cart-badge">{itemCount}</span>
|
|
72
|
-
<span class="cart-total">{totalLabel()}</span>
|
|
73
|
-
<span class="cart-sep"></span>
|
|
74
58
|
<span class="cart-timer">{remaining}</span>
|
|
75
59
|
</button>
|
|
76
60
|
{/if}
|
|
@@ -79,15 +63,14 @@
|
|
|
79
63
|
.cart-overview-btn {
|
|
80
64
|
display: flex;
|
|
81
65
|
align-items: center;
|
|
82
|
-
|
|
66
|
+
justify-content: center;
|
|
67
|
+
gap: 14px;
|
|
83
68
|
width: 100%;
|
|
84
|
-
padding:
|
|
69
|
+
padding: 14px 24px;
|
|
85
70
|
background: var(--bw-color-primary);
|
|
86
71
|
color: white;
|
|
87
72
|
border: none;
|
|
88
73
|
border-radius: 999px;
|
|
89
|
-
font-size: 14px;
|
|
90
|
-
font-weight: 700;
|
|
91
74
|
cursor: pointer;
|
|
92
75
|
transition: background 0.15s;
|
|
93
76
|
}
|
|
@@ -95,40 +78,27 @@
|
|
|
95
78
|
background: var(--bw-color-primary-dark);
|
|
96
79
|
}
|
|
97
80
|
.cart-icon {
|
|
98
|
-
width:
|
|
99
|
-
height:
|
|
81
|
+
width: 22px;
|
|
82
|
+
height: 22px;
|
|
100
83
|
flex-shrink: 0;
|
|
101
84
|
}
|
|
102
85
|
.cart-badge {
|
|
103
86
|
display: flex;
|
|
104
87
|
align-items: center;
|
|
105
88
|
justify-content: center;
|
|
106
|
-
|
|
107
|
-
height:
|
|
108
|
-
padding: 0 5px;
|
|
89
|
+
width: 26px;
|
|
90
|
+
height: 26px;
|
|
109
91
|
background: white;
|
|
110
92
|
color: var(--bw-color-primary);
|
|
111
|
-
border-radius:
|
|
112
|
-
font-size: 12px;
|
|
113
|
-
font-weight: 800;
|
|
114
|
-
flex-shrink: 0;
|
|
115
|
-
}
|
|
116
|
-
.cart-total {
|
|
117
|
-
flex: 1;
|
|
118
|
-
text-align: left;
|
|
93
|
+
border-radius: 50%;
|
|
119
94
|
font-size: 14px;
|
|
120
|
-
font-weight:
|
|
121
|
-
}
|
|
122
|
-
.cart-sep {
|
|
123
|
-
width: 1px;
|
|
124
|
-
height: 16px;
|
|
125
|
-
background: rgba(255,255,255,0.35);
|
|
95
|
+
font-weight: 800;
|
|
126
96
|
flex-shrink: 0;
|
|
127
97
|
}
|
|
128
98
|
.cart-timer {
|
|
129
99
|
font-variant-numeric: tabular-nums;
|
|
130
|
-
font-size:
|
|
131
|
-
|
|
100
|
+
font-size: 20px;
|
|
101
|
+
font-weight: 700;
|
|
132
102
|
flex-shrink: 0;
|
|
133
103
|
}
|
|
134
104
|
</style>
|
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);
|
|
@@ -28,6 +31,15 @@
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
load();
|
|
34
|
+
|
|
35
|
+
// In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
|
|
36
|
+
// modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
|
|
37
|
+
// would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
|
|
38
|
+
// cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
|
|
39
|
+
// triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
|
|
40
|
+
$effect(() => onWidgetMessage((d) => {
|
|
41
|
+
if (d.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
42
|
+
}));
|
|
31
43
|
</script>
|
|
32
44
|
|
|
33
45
|
<div class="bw-widget">
|
|
@@ -52,6 +64,10 @@
|
|
|
52
64
|
value: total,
|
|
53
65
|
currency,
|
|
54
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();
|
|
55
71
|
}}
|
|
56
72
|
/>
|
|
57
73
|
{:else}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutCartPaymentInitiationDto, CheckoutCartConfirmResultDto, CheckoutProductDto } from './client-types';
|
|
3
3
|
import type { BookingApi } from './api';
|
|
4
|
+
import { ApiError } from './api';
|
|
4
5
|
import type { WizardPages } from './config';
|
|
5
6
|
import { formatCurrency } from './currency';
|
|
6
7
|
import { postMessage } from './messages';
|
|
@@ -23,17 +24,40 @@
|
|
|
23
24
|
}
|
|
24
25
|
let { cart, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onClose, onOrderConfirmed }: Props = $props();
|
|
25
26
|
|
|
27
|
+
// Peach's webhook is typically near-instant, but is a genuinely separate async delivery from the card
|
|
28
|
+
// charge itself - 10 attempts 1.5s apart (~15s) comfortably covers ordinary delivery latency without
|
|
29
|
+
// making a shopper whose payment already succeeded wait an unreasonable time to see it confirmed.
|
|
30
|
+
const ConfirmMaxAttempts = 10;
|
|
31
|
+
const ConfirmRetryDelayMs = 1500;
|
|
32
|
+
|
|
26
33
|
type View = 'cart' | 'contact' | 'edit' | 'payment' | 'result';
|
|
27
34
|
let currentView = $state<View>('cart');
|
|
28
35
|
let editingItem = $state<CheckoutCartItemDetailDto | null>(null);
|
|
29
36
|
let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
|
|
30
37
|
let confirmResult = $state<CheckoutCartConfirmResultDto | null>(null);
|
|
31
|
-
|
|
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);
|
|
32
43
|
let isLoading = $state(true);
|
|
33
44
|
let isConfirming = $state(false);
|
|
34
45
|
let privacyAccepted = $state(false);
|
|
35
46
|
let marketingOptIn = $state(false);
|
|
36
47
|
let hasAttemptedSubmit = $state(false);
|
|
48
|
+
let payError = $state<string | null>(null);
|
|
49
|
+
// Deliberately separate from isLoading above - that one gates the entire view switch below (spinner vs.
|
|
50
|
+
// cart/contact/payment/result), so reusing it here would unmount ContactForm for the duration of the
|
|
51
|
+
// payCart call and, on failure, remount it with its own $state reset to blank, forcing the shopper to
|
|
52
|
+
// retype everything before retrying. This only ever disables the Pay Now button itself.
|
|
53
|
+
let isPaying = $state(false);
|
|
54
|
+
|
|
55
|
+
// Owned here, not inside ContactForm, so the shopper's typed details survive navigating back to the cart
|
|
56
|
+
// and returning to checkout - see ContactForm's own doc comment on its bindable props for why.
|
|
57
|
+
let contactFirstName = $state('');
|
|
58
|
+
let contactLastName = $state('');
|
|
59
|
+
let contactEmail = $state('');
|
|
60
|
+
let contactPhone = $state('');
|
|
37
61
|
|
|
38
62
|
let contactForm = $state<ContactForm | null>(null);
|
|
39
63
|
|
|
@@ -126,37 +150,60 @@
|
|
|
126
150
|
const updated = await api.getCart();
|
|
127
151
|
cart = updated;
|
|
128
152
|
currentView = 'cart';
|
|
129
|
-
notifyCartUpdated();
|
|
153
|
+
notifyCartUpdated(updated);
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
async function removeItem(item: CheckoutCartItemDetailDto) {
|
|
133
157
|
await api.removeCartItem(item.id);
|
|
134
158
|
const updated = await api.getCart();
|
|
135
159
|
cart = updated;
|
|
136
|
-
notifyCartUpdated();
|
|
160
|
+
notifyCartUpdated(updated);
|
|
137
161
|
if (cart.items.length === 0) close();
|
|
138
162
|
}
|
|
139
163
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
164
|
+
// Not the itemCount/cartItemId-shaped cart:change TicketConfigurator posts on add (which
|
|
165
|
+
// bw-configurator.svelte re-dispatches as the public bw:cart-change event and auto-opens checkout for).
|
|
166
|
+
// This carries the cart itself: CartBar/CartOverviewButton/Checkout use it to update their own state after
|
|
167
|
+
// an edit or remove without a second independent fetch, and it's forwarded to consumers as the public
|
|
168
|
+
// bw:cart-updated event/onCartUpdated callback, so a consumer can build their own cart summary UI from
|
|
169
|
+
// item count/remaining time/item details without calling the API directly.
|
|
170
|
+
function notifyCartUpdated(updatedCart: CheckoutCartDetailDto): void {
|
|
171
|
+
postMessage({ type: 'cart:updated', cart: updatedCart });
|
|
146
172
|
}
|
|
147
173
|
|
|
148
174
|
async function onPayNow() {
|
|
149
175
|
hasAttemptedSubmit = true;
|
|
150
176
|
if (!contactForm?.isValid() || !privacyAccepted) return;
|
|
151
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
|
+
|
|
152
188
|
const contact = contactForm.getContact();
|
|
153
189
|
|
|
154
|
-
|
|
190
|
+
isPaying = true;
|
|
191
|
+
payError = null;
|
|
155
192
|
try {
|
|
156
193
|
paymentResult = await api.payCart(contact);
|
|
157
194
|
currentView = 'payment';
|
|
195
|
+
} catch (e) {
|
|
196
|
+
// PayCheckoutCartCommandHandler's own error-mapping switch: 502 means Peach itself (or the network
|
|
197
|
+
// path to it) failed before a checkout could even be created - no charge was attempted, so this is
|
|
198
|
+
// always safe to retry. Every other status (400/409) reflects a cart/contact state the shopper can't
|
|
199
|
+
// fix by only retrying the same request, but there is no more specific action to suggest here either -
|
|
200
|
+
// either way, without this catch the rejection was previously unhandled and the shopper saw no
|
|
201
|
+
// indication anything had gone wrong at all.
|
|
202
|
+
payError = e instanceof ApiError && e.status === 502
|
|
203
|
+
? 'Unable to start payment right now. Please try again.'
|
|
204
|
+
: 'Something went wrong starting your payment. Please try again.';
|
|
158
205
|
} finally {
|
|
159
|
-
|
|
206
|
+
isPaying = false;
|
|
160
207
|
}
|
|
161
208
|
}
|
|
162
209
|
|
|
@@ -166,29 +213,115 @@
|
|
|
166
213
|
return;
|
|
167
214
|
}
|
|
168
215
|
|
|
169
|
-
|
|
170
|
-
|
|
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';
|
|
171
224
|
isConfirming = false;
|
|
172
225
|
currentView = 'result';
|
|
173
226
|
return;
|
|
174
227
|
}
|
|
175
228
|
|
|
176
|
-
isConfirming = true;
|
|
177
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'> {
|
|
178
238
|
try {
|
|
179
239
|
confirmResult = await api.confirmCart();
|
|
180
240
|
const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
183
245
|
if (allOk) {
|
|
184
|
-
postMessage({ type: 'modal:close' });
|
|
185
246
|
onOrderConfirmed();
|
|
186
247
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
|
270
|
+
|
|
271
|
+
for (let attempt = 1; attempt <= ConfirmMaxAttempts; attempt++) {
|
|
272
|
+
if (await attemptConfirm() === 'settled') {
|
|
273
|
+
isConfirming = false;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (attempt === ConfirmMaxAttempts) {
|
|
278
|
+
confirmOutcome = 'pending';
|
|
279
|
+
isConfirming = false;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await new Promise((resolve) => setTimeout(resolve, ConfirmRetryDelayMs));
|
|
190
284
|
}
|
|
191
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
|
+
);
|
|
192
325
|
</script>
|
|
193
326
|
|
|
194
327
|
<div class="modal">
|
|
@@ -200,20 +333,23 @@
|
|
|
200
333
|
{:else if currentView === 'payment' && paymentResult}
|
|
201
334
|
<PaymentPage
|
|
202
335
|
checkoutId={paymentResult.checkoutId}
|
|
203
|
-
|
|
204
|
-
onBack={() => { currentView = 'contact'; }}
|
|
205
|
-
onClose={close}
|
|
336
|
+
entityId={paymentResult.entityId}
|
|
206
337
|
{onPaymentComplete}
|
|
207
338
|
/>
|
|
208
339
|
|
|
209
340
|
{:else if currentView === 'result'}
|
|
210
341
|
<ResultView
|
|
211
|
-
status={
|
|
212
|
-
? { outcome: 'failed', resultDescription: 'Something went wrong confirming your order. Please try again.' }
|
|
213
|
-
: { outcome: 'successful' }}
|
|
342
|
+
status={resultStatus}
|
|
214
343
|
verifying={isConfirming}
|
|
215
|
-
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
|
+
}}
|
|
216
351
|
onRetry={() => { currentView = 'payment'; }}
|
|
352
|
+
onCheckAgain={confirmOutcome === 'pending' ? checkConfirmationAgain : undefined}
|
|
217
353
|
/>
|
|
218
354
|
|
|
219
355
|
{:else if currentView === 'edit' && editingItem && productsById.get(editingItem.productId)}
|
|
@@ -227,6 +363,7 @@
|
|
|
227
363
|
onBack={() => { currentView = 'cart'; }}
|
|
228
364
|
onClose={close}
|
|
229
365
|
onUpdated={onItemUpdated}
|
|
366
|
+
onPriceChanged={loadProducts}
|
|
230
367
|
/>
|
|
231
368
|
|
|
232
369
|
{:else if currentView === 'contact'}
|
|
@@ -239,11 +376,21 @@
|
|
|
239
376
|
</div>
|
|
240
377
|
|
|
241
378
|
<div class="body">
|
|
379
|
+
{#if payError}
|
|
380
|
+
<p class="pay-error">{payError}</p>
|
|
381
|
+
{/if}
|
|
242
382
|
<div class="contact-header">
|
|
243
383
|
<span class="contact-title">Contact Details</span>
|
|
244
384
|
<span class="required-hint">* Required Fields</span>
|
|
245
385
|
</div>
|
|
246
|
-
<ContactForm
|
|
386
|
+
<ContactForm
|
|
387
|
+
bind:this={contactForm}
|
|
388
|
+
{hasAttemptedSubmit}
|
|
389
|
+
bind:firstName={contactFirstName}
|
|
390
|
+
bind:lastName={contactLastName}
|
|
391
|
+
bind:emailAddress={contactEmail}
|
|
392
|
+
bind:phoneNumber={contactPhone}
|
|
393
|
+
/>
|
|
247
394
|
<div style="margin-top:24px">
|
|
248
395
|
<ConsentSection
|
|
249
396
|
{privacyAccepted}
|
|
@@ -257,8 +404,8 @@
|
|
|
257
404
|
|
|
258
405
|
<div class="pay-bar">
|
|
259
406
|
<span class="pay-bar-total">{formattedTotal()}</span>
|
|
260
|
-
<button class="pay-bar-btn" onclick={onPayNow}>
|
|
261
|
-
Pay Now <span class="arrow">→</span>
|
|
407
|
+
<button class="pay-bar-btn" onclick={onPayNow} disabled={isPaying}>
|
|
408
|
+
{isPaying ? 'Paying…' : 'Pay Now'} <span class="arrow">→</span>
|
|
262
409
|
</button>
|
|
263
410
|
</div>
|
|
264
411
|
|
|
@@ -444,6 +591,14 @@
|
|
|
444
591
|
font-weight: 800;
|
|
445
592
|
}
|
|
446
593
|
/* -- Contact view -- */
|
|
594
|
+
.pay-error {
|
|
595
|
+
margin: 0 0 16px;
|
|
596
|
+
padding: 12px 16px;
|
|
597
|
+
background: #fdecea;
|
|
598
|
+
color: #b3261e;
|
|
599
|
+
font-size: 14px;
|
|
600
|
+
border-radius: var(--bw-radius-md);
|
|
601
|
+
}
|
|
447
602
|
.contact-header {
|
|
448
603
|
display: flex;
|
|
449
604
|
justify-content: space-between;
|
|
@@ -487,6 +642,10 @@
|
|
|
487
642
|
font-size: 16px;
|
|
488
643
|
font-weight: 700;
|
|
489
644
|
}
|
|
645
|
+
.pay-bar-btn:disabled {
|
|
646
|
+
opacity: 0.7;
|
|
647
|
+
cursor: default;
|
|
648
|
+
}
|
|
490
649
|
.pay-bar-btn .arrow {
|
|
491
650
|
font-size: 20px;
|
|
492
651
|
}
|
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
hasAttemptedSubmit: boolean;
|
|
6
|
+
firstName: string;
|
|
7
|
+
lastName: string;
|
|
8
|
+
emailAddress: string;
|
|
9
|
+
phoneNumber: string;
|
|
6
10
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
let
|
|
12
|
-
|
|
11
|
+
// Bindable so the caller owns the actual values - CheckoutModal's own currentView switch unmounts this
|
|
12
|
+
// component entirely when navigating away from the contact view (a different arm of the same {#if/:else
|
|
13
|
+
// if} chain as the cart/edit/payment views), so state kept only here would reset to blank every time the
|
|
14
|
+
// shopper went back to the cart and returned. Binding lets it survive in the parent instead.
|
|
15
|
+
let {
|
|
16
|
+
hasAttemptedSubmit,
|
|
17
|
+
firstName = $bindable(''),
|
|
18
|
+
lastName = $bindable(''),
|
|
19
|
+
emailAddress = $bindable(''),
|
|
20
|
+
phoneNumber = $bindable(''),
|
|
21
|
+
}: Props = $props();
|
|
13
22
|
|
|
14
23
|
export function isValid(): boolean {
|
|
15
24
|
return firstName.trim() !== '' && emailAddress.trim() !== '' && phoneNumber.trim() !== '';
|
|
@@ -1,6 +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, isPriceMismatch } from './api';
|
|
4
5
|
import type { WizardPages } from './config';
|
|
5
6
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
7
|
import { expandUnitItems } from './utils';
|
|
@@ -16,13 +17,18 @@
|
|
|
16
17
|
onBack: () => void;
|
|
17
18
|
onClose: () => void;
|
|
18
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>;
|
|
19
23
|
}
|
|
20
|
-
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();
|
|
21
25
|
|
|
22
26
|
let isSaving = $state(false);
|
|
27
|
+
let errorMessage = $state<string | null>(null);
|
|
23
28
|
|
|
24
29
|
async function onComplete(item: CartItem) {
|
|
25
30
|
isSaving = true;
|
|
31
|
+
errorMessage = null;
|
|
26
32
|
try {
|
|
27
33
|
await api.updateCartItem(cartItem.id, {
|
|
28
34
|
productId: item.productId,
|
|
@@ -31,11 +37,27 @@
|
|
|
31
37
|
availabilityId: item.availabilityId,
|
|
32
38
|
localDate: item.localDate,
|
|
33
39
|
pickupPointId: item.pickupPointId,
|
|
40
|
+
// Currency and precision are resolved server-side - see the same call in TicketConfigurator.
|
|
34
41
|
amount: item.totalPrice,
|
|
35
|
-
currencyCode: item.currency,
|
|
36
|
-
currencyPrecision: item.currencyPrecision,
|
|
37
42
|
});
|
|
38
43
|
onUpdated();
|
|
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
|
+
|
|
54
|
+
// 424 means the cart item itself was rolled back server-side (see CheckoutCartEndpoints.UpdateItemAsync) -
|
|
55
|
+
// the supplier rejected the change, so nothing was saved. Surface that instead of letting it vanish
|
|
56
|
+
// silently: WizardPage stays mounted (not swapped for a spinner) specifically so the user's in-progress
|
|
57
|
+
// selections aren't lost while they read this and decide what to try instead.
|
|
58
|
+
errorMessage = e instanceof ApiError && e.status === 424
|
|
59
|
+
? 'This change could not be saved - the supplier rejected it. Please try different options.'
|
|
60
|
+
: 'Something went wrong saving your changes. Please try again.';
|
|
39
61
|
} finally {
|
|
40
62
|
isSaving = false;
|
|
41
63
|
}
|
|
@@ -50,9 +72,11 @@
|
|
|
50
72
|
<button class="icon-btn" onclick={onClose} aria-label="Close">×</button>
|
|
51
73
|
</div>
|
|
52
74
|
|
|
53
|
-
{#if
|
|
54
|
-
<
|
|
55
|
-
{
|
|
75
|
+
{#if errorMessage}
|
|
76
|
+
<p class="edit-error">{errorMessage}</p>
|
|
77
|
+
{/if}
|
|
78
|
+
|
|
79
|
+
<div class="edit-body" class:saving={isSaving}>
|
|
56
80
|
<WizardPage
|
|
57
81
|
{product}
|
|
58
82
|
{api}
|
|
@@ -63,7 +87,10 @@
|
|
|
63
87
|
{onComplete}
|
|
64
88
|
onCancel={onBack}
|
|
65
89
|
/>
|
|
66
|
-
|
|
90
|
+
{#if isSaving}
|
|
91
|
+
<div class="saving-overlay"><div class="spinner"></div></div>
|
|
92
|
+
{/if}
|
|
93
|
+
</div>
|
|
67
94
|
</div>
|
|
68
95
|
|
|
69
96
|
<style>
|
|
@@ -74,4 +101,33 @@
|
|
|
74
101
|
max-height: 100%;
|
|
75
102
|
overflow: hidden;
|
|
76
103
|
}
|
|
104
|
+
|
|
105
|
+
.edit-error {
|
|
106
|
+
margin: 0;
|
|
107
|
+
padding: 12px 16px;
|
|
108
|
+
background: #fdecea;
|
|
109
|
+
color: #b3261e;
|
|
110
|
+
font-size: 14px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.edit-body {
|
|
114
|
+
position: relative;
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
flex: 1;
|
|
118
|
+
min-height: 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.edit-body.saving {
|
|
122
|
+
pointer-events: none;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.saving-overlay {
|
|
126
|
+
position: absolute;
|
|
127
|
+
inset: 0;
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
background: rgba(255, 255, 255, 0.7);
|
|
132
|
+
}
|
|
77
133
|
</style>
|