@code-collective/booking-widget 1.0.4 → 1.0.7
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 +42 -3
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +1870 -2040
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +9 -5
- package/dist/booking-widget.umd.cjs +2 -2
- package/package.json +1 -1
- package/src/lib/CartBar.svelte +16 -0
- package/src/lib/CartBarView.svelte +1 -1
- package/src/lib/CartOverviewButton.svelte +28 -36
- package/src/lib/Checkout.svelte +55 -28
- package/src/lib/CheckoutModal.svelte +93 -16
- package/src/lib/ContactForm.svelte +15 -6
- package/src/lib/EditBookingView.svelte +49 -4
- package/src/lib/PaymentPage.svelte +30 -25
- package/src/lib/TicketConfigurator.svelte +91 -39
- package/src/lib/api.ts +12 -3
- package/src/lib/app.css +12 -4
- package/src/lib/cart-manager.ts +14 -0
- package/src/lib/config.ts +11 -8
- package/src/lib/elements/env.ts +0 -1
- package/src/lib/elements/register.ts +16 -3
- package/src/lib/index.ts +20 -8
- 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,7 +2,6 @@
|
|
|
2
2
|
import type { CheckoutCartDetailDto } from './client-types';
|
|
3
3
|
import type { BookingApi } from './api';
|
|
4
4
|
import { CartManager } from './cart-manager';
|
|
5
|
-
import { formatCurrency } from './currency';
|
|
6
5
|
import { postMessage } from './messages';
|
|
7
6
|
import { onDestroy } from 'svelte';
|
|
8
7
|
|
|
@@ -26,6 +25,22 @@
|
|
|
26
25
|
|
|
27
26
|
load();
|
|
28
27
|
|
|
28
|
+
// load() above only runs once on mount - without this, adding/editing/removing an item elsewhere on the
|
|
29
|
+
// page (the configurator, or the checkout modal's own edit/remove) leaves this button showing a stale total.
|
|
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
|
+
function handleMessage(e: MessageEvent) {
|
|
33
|
+
let d: Record<string, unknown>;
|
|
34
|
+
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
35
|
+
catch { return; }
|
|
36
|
+
if (d?.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
$effect(() => {
|
|
40
|
+
window.addEventListener('message', handleMessage);
|
|
41
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
42
|
+
});
|
|
43
|
+
|
|
29
44
|
function updateTimer() {
|
|
30
45
|
if (!cart) { remaining = '0:00'; return; }
|
|
31
46
|
const diff = Math.max(0, new Date(cart.absoluteExpiresAt).getTime() - Date.now());
|
|
@@ -39,24 +54,15 @@
|
|
|
39
54
|
onDestroy(() => clearInterval(interval));
|
|
40
55
|
|
|
41
56
|
let itemCount = $derived(cart?.items.length ?? 0);
|
|
42
|
-
|
|
43
|
-
let totalLabel = $derived(() => {
|
|
44
|
-
if (!cart || cart.items.length === 0) return '';
|
|
45
|
-
const currency = cart.items[0].currencyCode;
|
|
46
|
-
const total = cart.items.reduce((sum, i) => sum + i.amount, 0);
|
|
47
|
-
return formatCurrency(total, currency, 2, 0);
|
|
48
|
-
});
|
|
49
57
|
</script>
|
|
50
58
|
|
|
51
59
|
{#if cart && cart.items.length > 0}
|
|
52
|
-
<button class="cart-overview-btn" onclick={() => postMessage({ type: 'modal:open' })}>
|
|
60
|
+
<button class="cart-overview-btn bw-widget" onclick={() => postMessage({ type: 'modal:open' })}>
|
|
53
61
|
<svg class="cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
54
62
|
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
|
55
63
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
|
56
64
|
</svg>
|
|
57
65
|
<span class="cart-badge">{itemCount}</span>
|
|
58
|
-
<span class="cart-total">{totalLabel()}</span>
|
|
59
|
-
<span class="cart-sep"></span>
|
|
60
66
|
<span class="cart-timer">{remaining}</span>
|
|
61
67
|
</button>
|
|
62
68
|
{/if}
|
|
@@ -65,15 +71,14 @@
|
|
|
65
71
|
.cart-overview-btn {
|
|
66
72
|
display: flex;
|
|
67
73
|
align-items: center;
|
|
68
|
-
|
|
74
|
+
justify-content: center;
|
|
75
|
+
gap: 14px;
|
|
69
76
|
width: 100%;
|
|
70
|
-
padding:
|
|
77
|
+
padding: 14px 24px;
|
|
71
78
|
background: var(--bw-color-primary);
|
|
72
79
|
color: white;
|
|
73
80
|
border: none;
|
|
74
81
|
border-radius: 999px;
|
|
75
|
-
font-size: 14px;
|
|
76
|
-
font-weight: 700;
|
|
77
82
|
cursor: pointer;
|
|
78
83
|
transition: background 0.15s;
|
|
79
84
|
}
|
|
@@ -81,40 +86,27 @@
|
|
|
81
86
|
background: var(--bw-color-primary-dark);
|
|
82
87
|
}
|
|
83
88
|
.cart-icon {
|
|
84
|
-
width:
|
|
85
|
-
height:
|
|
89
|
+
width: 22px;
|
|
90
|
+
height: 22px;
|
|
86
91
|
flex-shrink: 0;
|
|
87
92
|
}
|
|
88
93
|
.cart-badge {
|
|
89
94
|
display: flex;
|
|
90
95
|
align-items: center;
|
|
91
96
|
justify-content: center;
|
|
92
|
-
|
|
93
|
-
height:
|
|
94
|
-
padding: 0 5px;
|
|
97
|
+
width: 26px;
|
|
98
|
+
height: 26px;
|
|
95
99
|
background: white;
|
|
96
100
|
color: var(--bw-color-primary);
|
|
97
|
-
border-radius:
|
|
98
|
-
font-size: 12px;
|
|
99
|
-
font-weight: 800;
|
|
100
|
-
flex-shrink: 0;
|
|
101
|
-
}
|
|
102
|
-
.cart-total {
|
|
103
|
-
flex: 1;
|
|
104
|
-
text-align: left;
|
|
101
|
+
border-radius: 50%;
|
|
105
102
|
font-size: 14px;
|
|
106
|
-
font-weight:
|
|
107
|
-
}
|
|
108
|
-
.cart-sep {
|
|
109
|
-
width: 1px;
|
|
110
|
-
height: 16px;
|
|
111
|
-
background: rgba(255,255,255,0.35);
|
|
103
|
+
font-weight: 800;
|
|
112
104
|
flex-shrink: 0;
|
|
113
105
|
}
|
|
114
106
|
.cart-timer {
|
|
115
107
|
font-variant-numeric: tabular-nums;
|
|
116
|
-
font-size:
|
|
117
|
-
|
|
108
|
+
font-size: 20px;
|
|
109
|
+
font-weight: 700;
|
|
118
110
|
flex-shrink: 0;
|
|
119
111
|
}
|
|
120
112
|
</style>
|
package/src/lib/Checkout.svelte
CHANGED
|
@@ -28,33 +28,60 @@
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
load();
|
|
31
|
+
|
|
32
|
+
// In modal mode this is redundant - bw-checkout.svelte only mounts this component fresh each time the
|
|
33
|
+
// modal opens, so it already gets a current cart. Rendered permanently in-page instead (no is-modal), it
|
|
34
|
+
// would otherwise only ever see the cart as it was on first mount. The event already carries the fresh
|
|
35
|
+
// cart (see TicketConfigurator/CheckoutModal's own posting sites), so this applies it directly rather than
|
|
36
|
+
// triggering a second, redundant getCart() call or flashing the spinner over an already-visible cart.
|
|
37
|
+
function handleMessage(e: MessageEvent) {
|
|
38
|
+
let d: Record<string, unknown>;
|
|
39
|
+
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
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
|
+
});
|
|
31
48
|
</script>
|
|
32
49
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<div class="
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
<div class="bw-widget">
|
|
51
|
+
{#if isLoading}
|
|
52
|
+
<div class="loading-center" style="height:100vh">
|
|
53
|
+
<div class="spinner"></div>
|
|
54
|
+
</div>
|
|
55
|
+
{:else if cart && cart.items.length > 0}
|
|
56
|
+
<CheckoutModal
|
|
57
|
+
{cart}
|
|
58
|
+
{api}
|
|
59
|
+
{wizardPages}
|
|
60
|
+
{editPages}
|
|
61
|
+
{autoSelectSingleTimeSlot}
|
|
62
|
+
onClose={() => postMessage({ type: 'modal:close' })}
|
|
63
|
+
onOrderConfirmed={() => {
|
|
64
|
+
const total = cart!.items.reduce((s, i) => s + i.amount, 0);
|
|
65
|
+
const currency = cart!.items[0]?.currencyCode ?? 'ZAR';
|
|
66
|
+
postMessage({
|
|
67
|
+
type: 'order:complete',
|
|
68
|
+
cartToken: cart!.cartToken,
|
|
69
|
+
value: total,
|
|
70
|
+
currency,
|
|
71
|
+
});
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
{:else}
|
|
75
|
+
<div class="loading-center" style="height:100vh;color:var(--bw-color-text-secondary)">
|
|
76
|
+
No items in cart.
|
|
77
|
+
</div>
|
|
78
|
+
{/if}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<style>
|
|
82
|
+
/* display: contents - a plain box here would break CheckoutModal's own .modal{height:100%}, which needs
|
|
83
|
+
to resolve against this component's real parent, not an unsized wrapper inserted in between. */
|
|
84
|
+
.bw-widget {
|
|
85
|
+
display: contents;
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
@@ -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,6 +24,12 @@
|
|
|
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);
|
|
@@ -34,6 +41,19 @@
|
|
|
34
41
|
let privacyAccepted = $state(false);
|
|
35
42
|
let marketingOptIn = $state(false);
|
|
36
43
|
let hasAttemptedSubmit = $state(false);
|
|
44
|
+
let payError = $state<string | null>(null);
|
|
45
|
+
// Deliberately separate from isLoading above - that one gates the entire view switch below (spinner vs.
|
|
46
|
+
// cart/contact/payment/result), so reusing it here would unmount ContactForm for the duration of the
|
|
47
|
+
// payCart call and, on failure, remount it with its own $state reset to blank, forcing the shopper to
|
|
48
|
+
// retype everything before retrying. This only ever disables the Pay Now button itself.
|
|
49
|
+
let isPaying = $state(false);
|
|
50
|
+
|
|
51
|
+
// Owned here, not inside ContactForm, so the shopper's typed details survive navigating back to the cart
|
|
52
|
+
// and returning to checkout - see ContactForm's own doc comment on its bindable props for why.
|
|
53
|
+
let contactFirstName = $state('');
|
|
54
|
+
let contactLastName = $state('');
|
|
55
|
+
let contactEmail = $state('');
|
|
56
|
+
let contactPhone = $state('');
|
|
37
57
|
|
|
38
58
|
let contactForm = $state<ContactForm | null>(null);
|
|
39
59
|
|
|
@@ -126,27 +146,50 @@
|
|
|
126
146
|
const updated = await api.getCart();
|
|
127
147
|
cart = updated;
|
|
128
148
|
currentView = 'cart';
|
|
149
|
+
notifyCartUpdated(updated);
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
async function removeItem(item: CheckoutCartItemDetailDto) {
|
|
132
153
|
await api.removeCartItem(item.id);
|
|
133
154
|
const updated = await api.getCart();
|
|
134
155
|
cart = updated;
|
|
156
|
+
notifyCartUpdated(updated);
|
|
135
157
|
if (cart.items.length === 0) close();
|
|
136
158
|
}
|
|
137
159
|
|
|
160
|
+
// Not the itemCount/cartItemId-shaped cart:change TicketConfigurator posts on add (which
|
|
161
|
+
// bw-configurator.svelte re-dispatches as the public bw:cart-change event and auto-opens checkout for).
|
|
162
|
+
// This carries the cart itself: CartBar/CartOverviewButton/Checkout use it to update their own state after
|
|
163
|
+
// an edit or remove without a second independent fetch, and it's forwarded to consumers as the public
|
|
164
|
+
// bw:cart-updated event/onCartUpdated callback, so a consumer can build their own cart summary UI from
|
|
165
|
+
// item count/remaining time/item details without calling the API directly.
|
|
166
|
+
function notifyCartUpdated(updatedCart: CheckoutCartDetailDto): void {
|
|
167
|
+
postMessage({ type: 'cart:updated', cart: updatedCart });
|
|
168
|
+
}
|
|
169
|
+
|
|
138
170
|
async function onPayNow() {
|
|
139
171
|
hasAttemptedSubmit = true;
|
|
140
172
|
if (!contactForm?.isValid() || !privacyAccepted) return;
|
|
141
173
|
|
|
142
174
|
const contact = contactForm.getContact();
|
|
143
175
|
|
|
144
|
-
|
|
176
|
+
isPaying = true;
|
|
177
|
+
payError = null;
|
|
145
178
|
try {
|
|
146
179
|
paymentResult = await api.payCart(contact);
|
|
147
180
|
currentView = 'payment';
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// PayCheckoutCartCommandHandler's own error-mapping switch: 502 means Peach itself (or the network
|
|
183
|
+
// path to it) failed before a checkout could even be created - no charge was attempted, so this is
|
|
184
|
+
// always safe to retry. Every other status (400/409) reflects a cart/contact state the shopper can't
|
|
185
|
+
// fix by only retrying the same request, but there is no more specific action to suggest here either -
|
|
186
|
+
// either way, without this catch the rejection was previously unhandled and the shopper saw no
|
|
187
|
+
// indication anything had gone wrong at all.
|
|
188
|
+
payError = e instanceof ApiError && e.status === 502
|
|
189
|
+
? 'Unable to start payment right now. Please try again.'
|
|
190
|
+
: 'Something went wrong starting your payment. Please try again.';
|
|
148
191
|
} finally {
|
|
149
|
-
|
|
192
|
+
isPaying = false;
|
|
150
193
|
}
|
|
151
194
|
}
|
|
152
195
|
|
|
@@ -165,18 +208,30 @@
|
|
|
165
208
|
|
|
166
209
|
isConfirming = true;
|
|
167
210
|
currentView = 'result';
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
|
|
212
|
+
for (let attempt = 1; attempt <= ConfirmMaxAttempts; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
confirmResult = await api.confirmCart();
|
|
215
|
+
const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
|
|
216
|
+
confirmFailed = !allOk;
|
|
217
|
+
isConfirming = false;
|
|
218
|
+
if (allOk) {
|
|
219
|
+
postMessage({ type: 'modal:close' });
|
|
220
|
+
onOrderConfirmed();
|
|
221
|
+
}
|
|
222
|
+
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));
|
|
176
234
|
}
|
|
177
|
-
} catch {
|
|
178
|
-
confirmFailed = true;
|
|
179
|
-
isConfirming = false;
|
|
180
235
|
}
|
|
181
236
|
}
|
|
182
237
|
</script>
|
|
@@ -229,11 +284,21 @@
|
|
|
229
284
|
</div>
|
|
230
285
|
|
|
231
286
|
<div class="body">
|
|
287
|
+
{#if payError}
|
|
288
|
+
<p class="pay-error">{payError}</p>
|
|
289
|
+
{/if}
|
|
232
290
|
<div class="contact-header">
|
|
233
291
|
<span class="contact-title">Contact Details</span>
|
|
234
292
|
<span class="required-hint">* Required Fields</span>
|
|
235
293
|
</div>
|
|
236
|
-
<ContactForm
|
|
294
|
+
<ContactForm
|
|
295
|
+
bind:this={contactForm}
|
|
296
|
+
{hasAttemptedSubmit}
|
|
297
|
+
bind:firstName={contactFirstName}
|
|
298
|
+
bind:lastName={contactLastName}
|
|
299
|
+
bind:emailAddress={contactEmail}
|
|
300
|
+
bind:phoneNumber={contactPhone}
|
|
301
|
+
/>
|
|
237
302
|
<div style="margin-top:24px">
|
|
238
303
|
<ConsentSection
|
|
239
304
|
{privacyAccepted}
|
|
@@ -247,8 +312,8 @@
|
|
|
247
312
|
|
|
248
313
|
<div class="pay-bar">
|
|
249
314
|
<span class="pay-bar-total">{formattedTotal()}</span>
|
|
250
|
-
<button class="pay-bar-btn" onclick={onPayNow}>
|
|
251
|
-
Pay Now <span class="arrow">→</span>
|
|
315
|
+
<button class="pay-bar-btn" onclick={onPayNow} disabled={isPaying}>
|
|
316
|
+
{isPaying ? 'Paying…' : 'Pay Now'} <span class="arrow">→</span>
|
|
252
317
|
</button>
|
|
253
318
|
</div>
|
|
254
319
|
|
|
@@ -434,6 +499,14 @@
|
|
|
434
499
|
font-weight: 800;
|
|
435
500
|
}
|
|
436
501
|
/* -- Contact view -- */
|
|
502
|
+
.pay-error {
|
|
503
|
+
margin: 0 0 16px;
|
|
504
|
+
padding: 12px 16px;
|
|
505
|
+
background: #fdecea;
|
|
506
|
+
color: #b3261e;
|
|
507
|
+
font-size: 14px;
|
|
508
|
+
border-radius: var(--bw-radius-md);
|
|
509
|
+
}
|
|
437
510
|
.contact-header {
|
|
438
511
|
display: flex;
|
|
439
512
|
justify-content: space-between;
|
|
@@ -477,6 +550,10 @@
|
|
|
477
550
|
font-size: 16px;
|
|
478
551
|
font-weight: 700;
|
|
479
552
|
}
|
|
553
|
+
.pay-bar-btn:disabled {
|
|
554
|
+
opacity: 0.7;
|
|
555
|
+
cursor: default;
|
|
556
|
+
}
|
|
480
557
|
.pay-bar-btn .arrow {
|
|
481
558
|
font-size: 20px;
|
|
482
559
|
}
|
|
@@ -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 } from './api';
|
|
4
5
|
import type { WizardPages } from './config';
|
|
5
6
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
7
|
import { expandUnitItems } from './utils';
|
|
@@ -20,9 +21,11 @@
|
|
|
20
21
|
let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated }: Props = $props();
|
|
21
22
|
|
|
22
23
|
let isSaving = $state(false);
|
|
24
|
+
let errorMessage = $state<string | null>(null);
|
|
23
25
|
|
|
24
26
|
async function onComplete(item: CartItem) {
|
|
25
27
|
isSaving = true;
|
|
28
|
+
errorMessage = null;
|
|
26
29
|
try {
|
|
27
30
|
await api.updateCartItem(cartItem.id, {
|
|
28
31
|
productId: item.productId,
|
|
@@ -36,6 +39,14 @@
|
|
|
36
39
|
currencyPrecision: item.currencyPrecision,
|
|
37
40
|
});
|
|
38
41
|
onUpdated();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// 424 means the cart item itself was rolled back server-side (see CheckoutCartEndpoints.UpdateItemAsync) -
|
|
44
|
+
// the supplier rejected the change, so nothing was saved. Surface that instead of letting it vanish
|
|
45
|
+
// silently: WizardPage stays mounted (not swapped for a spinner) specifically so the user's in-progress
|
|
46
|
+
// selections aren't lost while they read this and decide what to try instead.
|
|
47
|
+
errorMessage = e instanceof ApiError && e.status === 424
|
|
48
|
+
? 'This change could not be saved - the supplier rejected it. Please try different options.'
|
|
49
|
+
: 'Something went wrong saving your changes. Please try again.';
|
|
39
50
|
} finally {
|
|
40
51
|
isSaving = false;
|
|
41
52
|
}
|
|
@@ -50,9 +61,11 @@
|
|
|
50
61
|
<button class="icon-btn" onclick={onClose} aria-label="Close">×</button>
|
|
51
62
|
</div>
|
|
52
63
|
|
|
53
|
-
{#if
|
|
54
|
-
<
|
|
55
|
-
{
|
|
64
|
+
{#if errorMessage}
|
|
65
|
+
<p class="edit-error">{errorMessage}</p>
|
|
66
|
+
{/if}
|
|
67
|
+
|
|
68
|
+
<div class="edit-body" class:saving={isSaving}>
|
|
56
69
|
<WizardPage
|
|
57
70
|
{product}
|
|
58
71
|
{api}
|
|
@@ -63,7 +76,10 @@
|
|
|
63
76
|
{onComplete}
|
|
64
77
|
onCancel={onBack}
|
|
65
78
|
/>
|
|
66
|
-
|
|
79
|
+
{#if isSaving}
|
|
80
|
+
<div class="saving-overlay"><div class="spinner"></div></div>
|
|
81
|
+
{/if}
|
|
82
|
+
</div>
|
|
67
83
|
</div>
|
|
68
84
|
|
|
69
85
|
<style>
|
|
@@ -74,4 +90,33 @@
|
|
|
74
90
|
max-height: 100%;
|
|
75
91
|
overflow: hidden;
|
|
76
92
|
}
|
|
93
|
+
|
|
94
|
+
.edit-error {
|
|
95
|
+
margin: 0;
|
|
96
|
+
padding: 12px 16px;
|
|
97
|
+
background: #fdecea;
|
|
98
|
+
color: #b3261e;
|
|
99
|
+
font-size: 14px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.edit-body {
|
|
103
|
+
position: relative;
|
|
104
|
+
display: flex;
|
|
105
|
+
flex-direction: column;
|
|
106
|
+
flex: 1;
|
|
107
|
+
min-height: 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.edit-body.saving {
|
|
111
|
+
pointer-events: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.saving-overlay {
|
|
115
|
+
position: absolute;
|
|
116
|
+
inset: 0;
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
background: rgba(255, 255, 255, 0.7);
|
|
121
|
+
}
|
|
77
122
|
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { loadPeachSdk } from './peach-sdk';
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
checkoutId: string;
|
|
@@ -16,33 +17,37 @@
|
|
|
16
17
|
let peachInstance: any = null;
|
|
17
18
|
|
|
18
19
|
onMount(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
failed = true;
|
|
23
|
-
errorMessage = 'Payment SDK not loaded';
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
20
|
+
(async () => {
|
|
21
|
+
try {
|
|
22
|
+
await loadPeachSdk();
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
eventHandlers: {
|
|
34
|
-
onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
|
|
35
|
-
onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
|
|
36
|
-
onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
|
|
37
|
-
onError: () => { cleanup(); onPaymentComplete({ status: 'error' }); },
|
|
38
|
-
},
|
|
39
|
-
});
|
|
24
|
+
const Checkout = (window as any).Checkout;
|
|
25
|
+
if (!Checkout) {
|
|
26
|
+
failed = true;
|
|
27
|
+
errorMessage = 'Payment SDK not loaded';
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
40
30
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
peachInstance = Checkout.initiate({
|
|
32
|
+
checkoutId,
|
|
33
|
+
customisations: {
|
|
34
|
+
card: { submitButtonText: 'Pay Now' },
|
|
35
|
+
theme: { brand: { primary: '#E30613' } },
|
|
36
|
+
},
|
|
37
|
+
eventHandlers: {
|
|
38
|
+
onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
|
|
39
|
+
onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
|
|
40
|
+
onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
|
|
41
|
+
onError: () => { cleanup(); onPaymentComplete({ status: 'error' }); },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
peachInstance.render('#peach-container');
|
|
46
|
+
} catch (e) {
|
|
47
|
+
failed = true;
|
|
48
|
+
errorMessage = String(e);
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
46
51
|
});
|
|
47
52
|
|
|
48
53
|
function cleanup() {
|