@code-collective/booking-widget 1.0.6 → 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 +4 -0
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +1762 -2036
- package/dist/booking-widget.min.js +7 -7
- package/dist/booking-widget.umd.cjs +2 -2
- package/package.json +1 -1
- package/src/lib/CartBar.svelte +3 -1
- package/src/lib/CartOverviewButton.svelte +14 -36
- package/src/lib/Checkout.svelte +17 -0
- package/src/lib/CheckoutModal.svelte +91 -24
- 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 +64 -27
- package/src/lib/api.ts +10 -2
- package/src/lib/cart-manager.ts +13 -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 +18 -7
- 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
|
|
|
@@ -28,11 +27,13 @@
|
|
|
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.
|
|
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.
|
|
31
32
|
function handleMessage(e: MessageEvent) {
|
|
32
33
|
let d: Record<string, unknown>;
|
|
33
34
|
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
34
35
|
catch { return; }
|
|
35
|
-
if (d?.type === 'cart:updated')
|
|
36
|
+
if (d?.type === 'cart:updated' && 'cart' in d) cart = d.cart as CheckoutCartDetailDto | null;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
$effect(() => {
|
|
@@ -53,13 +54,6 @@
|
|
|
53
54
|
onDestroy(() => clearInterval(interval));
|
|
54
55
|
|
|
55
56
|
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
57
|
</script>
|
|
64
58
|
|
|
65
59
|
{#if cart && cart.items.length > 0}
|
|
@@ -69,8 +63,6 @@
|
|
|
69
63
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
|
70
64
|
</svg>
|
|
71
65
|
<span class="cart-badge">{itemCount}</span>
|
|
72
|
-
<span class="cart-total">{totalLabel()}</span>
|
|
73
|
-
<span class="cart-sep"></span>
|
|
74
66
|
<span class="cart-timer">{remaining}</span>
|
|
75
67
|
</button>
|
|
76
68
|
{/if}
|
|
@@ -79,15 +71,14 @@
|
|
|
79
71
|
.cart-overview-btn {
|
|
80
72
|
display: flex;
|
|
81
73
|
align-items: center;
|
|
82
|
-
|
|
74
|
+
justify-content: center;
|
|
75
|
+
gap: 14px;
|
|
83
76
|
width: 100%;
|
|
84
|
-
padding:
|
|
77
|
+
padding: 14px 24px;
|
|
85
78
|
background: var(--bw-color-primary);
|
|
86
79
|
color: white;
|
|
87
80
|
border: none;
|
|
88
81
|
border-radius: 999px;
|
|
89
|
-
font-size: 14px;
|
|
90
|
-
font-weight: 700;
|
|
91
82
|
cursor: pointer;
|
|
92
83
|
transition: background 0.15s;
|
|
93
84
|
}
|
|
@@ -95,40 +86,27 @@
|
|
|
95
86
|
background: var(--bw-color-primary-dark);
|
|
96
87
|
}
|
|
97
88
|
.cart-icon {
|
|
98
|
-
width:
|
|
99
|
-
height:
|
|
89
|
+
width: 22px;
|
|
90
|
+
height: 22px;
|
|
100
91
|
flex-shrink: 0;
|
|
101
92
|
}
|
|
102
93
|
.cart-badge {
|
|
103
94
|
display: flex;
|
|
104
95
|
align-items: center;
|
|
105
96
|
justify-content: center;
|
|
106
|
-
|
|
107
|
-
height:
|
|
108
|
-
padding: 0 5px;
|
|
97
|
+
width: 26px;
|
|
98
|
+
height: 26px;
|
|
109
99
|
background: white;
|
|
110
100
|
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;
|
|
101
|
+
border-radius: 50%;
|
|
119
102
|
font-size: 14px;
|
|
120
|
-
font-weight:
|
|
121
|
-
}
|
|
122
|
-
.cart-sep {
|
|
123
|
-
width: 1px;
|
|
124
|
-
height: 16px;
|
|
125
|
-
background: rgba(255,255,255,0.35);
|
|
103
|
+
font-weight: 800;
|
|
126
104
|
flex-shrink: 0;
|
|
127
105
|
}
|
|
128
106
|
.cart-timer {
|
|
129
107
|
font-variant-numeric: tabular-nums;
|
|
130
|
-
font-size:
|
|
131
|
-
|
|
108
|
+
font-size: 20px;
|
|
109
|
+
font-weight: 700;
|
|
132
110
|
flex-shrink: 0;
|
|
133
111
|
}
|
|
134
112
|
</style>
|
package/src/lib/Checkout.svelte
CHANGED
|
@@ -28,6 +28,23 @@
|
|
|
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
50
|
<div class="bw-widget">
|
|
@@ -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,23 +146,25 @@
|
|
|
126
146
|
const updated = await api.getCart();
|
|
127
147
|
cart = updated;
|
|
128
148
|
currentView = 'cart';
|
|
129
|
-
notifyCartUpdated();
|
|
149
|
+
notifyCartUpdated(updated);
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
async function removeItem(item: CheckoutCartItemDetailDto) {
|
|
133
153
|
await api.removeCartItem(item.id);
|
|
134
154
|
const updated = await api.getCart();
|
|
135
155
|
cart = updated;
|
|
136
|
-
notifyCartUpdated();
|
|
156
|
+
notifyCartUpdated(updated);
|
|
137
157
|
if (cart.items.length === 0) close();
|
|
138
158
|
}
|
|
139
159
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
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 });
|
|
146
168
|
}
|
|
147
169
|
|
|
148
170
|
async function onPayNow() {
|
|
@@ -151,12 +173,23 @@
|
|
|
151
173
|
|
|
152
174
|
const contact = contactForm.getContact();
|
|
153
175
|
|
|
154
|
-
|
|
176
|
+
isPaying = true;
|
|
177
|
+
payError = null;
|
|
155
178
|
try {
|
|
156
179
|
paymentResult = await api.payCart(contact);
|
|
157
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.';
|
|
158
191
|
} finally {
|
|
159
|
-
|
|
192
|
+
isPaying = false;
|
|
160
193
|
}
|
|
161
194
|
}
|
|
162
195
|
|
|
@@ -175,18 +208,30 @@
|
|
|
175
208
|
|
|
176
209
|
isConfirming = true;
|
|
177
210
|
currentView = 'result';
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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));
|
|
186
234
|
}
|
|
187
|
-
} catch {
|
|
188
|
-
confirmFailed = true;
|
|
189
|
-
isConfirming = false;
|
|
190
235
|
}
|
|
191
236
|
}
|
|
192
237
|
</script>
|
|
@@ -239,11 +284,21 @@
|
|
|
239
284
|
</div>
|
|
240
285
|
|
|
241
286
|
<div class="body">
|
|
287
|
+
{#if payError}
|
|
288
|
+
<p class="pay-error">{payError}</p>
|
|
289
|
+
{/if}
|
|
242
290
|
<div class="contact-header">
|
|
243
291
|
<span class="contact-title">Contact Details</span>
|
|
244
292
|
<span class="required-hint">* Required Fields</span>
|
|
245
293
|
</div>
|
|
246
|
-
<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
|
+
/>
|
|
247
302
|
<div style="margin-top:24px">
|
|
248
303
|
<ConsentSection
|
|
249
304
|
{privacyAccepted}
|
|
@@ -257,8 +312,8 @@
|
|
|
257
312
|
|
|
258
313
|
<div class="pay-bar">
|
|
259
314
|
<span class="pay-bar-total">{formattedTotal()}</span>
|
|
260
|
-
<button class="pay-bar-btn" onclick={onPayNow}>
|
|
261
|
-
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>
|
|
262
317
|
</button>
|
|
263
318
|
</div>
|
|
264
319
|
|
|
@@ -444,6 +499,14 @@
|
|
|
444
499
|
font-weight: 800;
|
|
445
500
|
}
|
|
446
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
|
+
}
|
|
447
510
|
.contact-header {
|
|
448
511
|
display: flex;
|
|
449
512
|
justify-content: space-between;
|
|
@@ -487,6 +550,10 @@
|
|
|
487
550
|
font-size: 16px;
|
|
488
551
|
font-weight: 700;
|
|
489
552
|
}
|
|
553
|
+
.pay-bar-btn:disabled {
|
|
554
|
+
opacity: 0.7;
|
|
555
|
+
cursor: default;
|
|
556
|
+
}
|
|
490
557
|
.pay-bar-btn .arrow {
|
|
491
558
|
font-size: 20px;
|
|
492
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() {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { BookingApi } from './api';
|
|
3
|
+
import { ApiError } from './api';
|
|
3
4
|
import type { WizardPages } from './config';
|
|
4
5
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
5
6
|
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
let product = $state<CheckoutProductDto | null>(null);
|
|
23
24
|
let isLoading = $state(true);
|
|
24
25
|
let isAddingToCart = $state(false);
|
|
26
|
+
let addToCartError = $state<string | null>(null);
|
|
25
27
|
|
|
26
28
|
async function load() {
|
|
27
29
|
product = await api.getProduct(productId);
|
|
@@ -30,37 +32,61 @@
|
|
|
30
32
|
|
|
31
33
|
load();
|
|
32
34
|
|
|
35
|
+
async function addItemToCart(item: CartItem): Promise<void> {
|
|
36
|
+
await cartManager.ensureCart();
|
|
37
|
+
|
|
38
|
+
const unitItems = expandUnitItems(item.units);
|
|
39
|
+
const added = await api.addCartItem({
|
|
40
|
+
productId: item.productId,
|
|
41
|
+
optionId: item.optionId,
|
|
42
|
+
unitItems,
|
|
43
|
+
availabilityId: item.availabilityId,
|
|
44
|
+
localDate: item.localDate,
|
|
45
|
+
pickupPointId: item.pickupPointId,
|
|
46
|
+
amount: item.totalPrice,
|
|
47
|
+
currencyCode: item.currency,
|
|
48
|
+
currencyPrecision: item.currencyPrecision,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
52
|
+
postMessage({
|
|
53
|
+
type: 'cart:change',
|
|
54
|
+
itemCount: unitItems.length,
|
|
55
|
+
cartItemId: added.id,
|
|
56
|
+
totalFormatted: total,
|
|
57
|
+
openCheckout: true,
|
|
58
|
+
});
|
|
59
|
+
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
60
|
+
// public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
|
|
61
|
+
// use it to update their own state without each independently re-fetching, and it's forwarded to
|
|
62
|
+
// consumers as the public bw:cart-updated event/onCartUpdated callback - see this event's own doc comment
|
|
63
|
+
// in CheckoutModal (the other place it's posted from, after an edit/remove).
|
|
64
|
+
const cart = await api.getCart().catch(() => null);
|
|
65
|
+
postMessage({ type: 'cart:updated', cart });
|
|
66
|
+
}
|
|
67
|
+
|
|
33
68
|
async function onAddToCart(item: CartItem) {
|
|
34
69
|
isAddingToCart = true;
|
|
70
|
+
addToCartError = null;
|
|
35
71
|
|
|
36
72
|
try {
|
|
37
|
-
await
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
type: 'cart:change',
|
|
55
|
-
itemCount: unitItems.length,
|
|
56
|
-
cartItemId: added.id,
|
|
57
|
-
totalFormatted: total,
|
|
58
|
-
openCheckout: true,
|
|
59
|
-
});
|
|
60
|
-
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
61
|
-
// public bw:cart-change event. This one just tells CartBar/CartOverviewButton to refetch their own
|
|
62
|
-
// total, same signal update/remove in CheckoutModal use - see this event's own doc comment there.
|
|
63
|
-
postMessage({ type: 'cart:updated' });
|
|
73
|
+
await addItemToCart(item);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
76
|
+
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
77
|
+
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
78
|
+
// before giving up, rather than leaving the user stuck with no feedback and no way to proceed.
|
|
79
|
+
const isStaleCart = e instanceof ApiError && (e.status === 401 || e.status === 404);
|
|
80
|
+
if (isStaleCart) {
|
|
81
|
+
cartManager.reset();
|
|
82
|
+
try {
|
|
83
|
+
await addItemToCart(item);
|
|
84
|
+
} catch {
|
|
85
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
89
|
+
}
|
|
64
90
|
} finally {
|
|
65
91
|
isAddingToCart = false;
|
|
66
92
|
}
|
|
@@ -73,6 +99,9 @@
|
|
|
73
99
|
<div class="spinner"></div>
|
|
74
100
|
</div>
|
|
75
101
|
{:else if product}
|
|
102
|
+
{#if addToCartError}
|
|
103
|
+
<p class="add-to-cart-error">{addToCartError}</p>
|
|
104
|
+
{/if}
|
|
76
105
|
<WizardPage
|
|
77
106
|
{product}
|
|
78
107
|
{api}
|
|
@@ -90,4 +119,12 @@
|
|
|
90
119
|
.bw-widget {
|
|
91
120
|
display: contents;
|
|
92
121
|
}
|
|
122
|
+
|
|
123
|
+
.add-to-cart-error {
|
|
124
|
+
margin: 0;
|
|
125
|
+
padding: 12px 16px;
|
|
126
|
+
background: #fdecea;
|
|
127
|
+
color: #b3261e;
|
|
128
|
+
font-size: 14px;
|
|
129
|
+
}
|
|
93
130
|
</style>
|