@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
|
@@ -1,14 +1,13 @@
|
|
|
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;
|
|
6
|
-
|
|
7
|
-
onBack: () => void;
|
|
8
|
-
onClose: () => void;
|
|
7
|
+
entityId: string;
|
|
9
8
|
onPaymentComplete: (result: { status: string }) => void;
|
|
10
9
|
}
|
|
11
|
-
let { checkoutId,
|
|
10
|
+
let { checkoutId, entityId, onPaymentComplete }: Props = $props();
|
|
12
11
|
|
|
13
12
|
let failed = $state(false);
|
|
14
13
|
let errorMessage = $state('');
|
|
@@ -16,33 +15,44 @@
|
|
|
16
15
|
let peachInstance: any = null;
|
|
17
16
|
|
|
18
17
|
onMount(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
failed = true;
|
|
23
|
-
errorMessage = 'Payment SDK not loaded';
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
18
|
+
(async () => {
|
|
19
|
+
try {
|
|
20
|
+
await loadPeachSdk();
|
|
26
21
|
|
|
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
|
-
});
|
|
22
|
+
const Checkout = (window as any).Checkout;
|
|
23
|
+
if (!Checkout) {
|
|
24
|
+
failed = true;
|
|
25
|
+
errorMessage = 'Payment SDK not loaded';
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
40
28
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
29
|
+
peachInstance = Checkout.initiate({
|
|
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,
|
|
35
|
+
customisations: {
|
|
36
|
+
card: { submitButtonText: 'Pay Now' },
|
|
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.
|
|
41
|
+
},
|
|
42
|
+
eventHandlers: {
|
|
43
|
+
onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
|
|
44
|
+
onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
|
|
45
|
+
onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
|
|
46
|
+
onError: () => { cleanup(); onPaymentComplete({ status: 'error' }); },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
peachInstance.render('#peach-container');
|
|
51
|
+
} catch (e) {
|
|
52
|
+
failed = true;
|
|
53
|
+
errorMessage = String(e);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
46
56
|
});
|
|
47
57
|
|
|
48
58
|
function cleanup() {
|
|
@@ -55,77 +65,70 @@
|
|
|
55
65
|
onDestroy(cleanup);
|
|
56
66
|
</script>
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
<div class="
|
|
67
|
-
<div class="
|
|
68
|
-
<
|
|
69
|
-
<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>
|
|
70
79
|
</div>
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<div class="error-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
</div>
|
|
84
|
-
<div id="peach-container"></div>
|
|
85
|
-
{/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>
|
|
86
92
|
</div>
|
|
87
93
|
</div>
|
|
88
94
|
|
|
89
95
|
<style>
|
|
90
|
-
.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 {
|
|
91
109
|
display: flex;
|
|
92
110
|
flex-direction: column;
|
|
93
|
-
|
|
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;
|
|
94
118
|
}
|
|
95
119
|
.body {
|
|
96
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;
|
|
97
127
|
overflow-y: auto;
|
|
98
128
|
padding: 16px;
|
|
99
129
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
justify-content: space-between;
|
|
103
|
-
align-items: center;
|
|
104
|
-
padding: 16px;
|
|
105
|
-
background: var(--bw-color-surface);
|
|
106
|
-
border-radius: var(--bw-radius-lg);
|
|
107
|
-
margin-bottom: 20px;
|
|
108
|
-
font-weight: 600;
|
|
109
|
-
font-size: 15px;
|
|
110
|
-
}
|
|
111
|
-
.amount {
|
|
112
|
-
color: var(--bw-color-primary);
|
|
113
|
-
font-size: 18px;
|
|
114
|
-
font-weight: 700;
|
|
115
|
-
}
|
|
116
|
-
.brands {
|
|
117
|
-
display: flex;
|
|
118
|
-
gap: 8px;
|
|
119
|
-
margin-bottom: 16px;
|
|
120
|
-
}
|
|
121
|
-
.brand {
|
|
122
|
-
padding: 4px 10px;
|
|
123
|
-
border: 1px solid var(--bw-color-border);
|
|
124
|
-
border-radius: var(--bw-radius-sm);
|
|
125
|
-
font-size: 11px;
|
|
126
|
-
font-weight: 700;
|
|
127
|
-
color: var(--bw-color-text-secondary);
|
|
128
|
-
letter-spacing: 0.02em;
|
|
130
|
+
#peach-container {
|
|
131
|
+
height: 100%;
|
|
129
132
|
}
|
|
130
133
|
.error-state {
|
|
131
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,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { BookingApi } from './api';
|
|
3
|
+
import { ApiError, isPriceMismatch } 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,41 +32,87 @@
|
|
|
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
|
+
// 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.
|
|
50
|
+
amount: item.totalPrice,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
54
|
+
postMessage({
|
|
55
|
+
type: 'cart:change',
|
|
56
|
+
itemCount: unitItems.length,
|
|
57
|
+
cartItemId: added.id,
|
|
58
|
+
totalFormatted: total,
|
|
59
|
+
openCheckout: true,
|
|
60
|
+
});
|
|
61
|
+
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
62
|
+
// public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
|
|
63
|
+
// use it to update their own state without each independently re-fetching, and it's forwarded to
|
|
64
|
+
// consumers as the public bw:cart-updated event/onCartUpdated callback - see this event's own doc comment
|
|
65
|
+
// in CheckoutModal (the other place it's posted from, after an edit/remove).
|
|
66
|
+
const cart = await api.getCart().catch(() => null);
|
|
67
|
+
postMessage({ type: 'cart:updated', cart });
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
async function onAddToCart(item: CartItem) {
|
|
34
71
|
isAddingToCart = true;
|
|
72
|
+
addToCartError = null;
|
|
35
73
|
|
|
36
74
|
try {
|
|
37
|
-
await
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
amount: item.totalPrice,
|
|
48
|
-
currencyCode: item.currency,
|
|
49
|
-
currencyPrecision: item.currencyPrecision,
|
|
50
|
-
});
|
|
75
|
+
await addItemToCart(item);
|
|
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
|
+
}
|
|
51
85
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
86
|
+
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
87
|
+
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
88
|
+
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
89
|
+
// before giving up, rather than leaving the user stuck with no feedback and no way to proceed.
|
|
90
|
+
const isStaleCart = e instanceof ApiError && (e.status === 401 || e.status === 404);
|
|
91
|
+
if (isStaleCart) {
|
|
92
|
+
cartManager.reset();
|
|
93
|
+
try {
|
|
94
|
+
await addItemToCart(item);
|
|
95
|
+
} catch {
|
|
96
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
100
|
+
}
|
|
64
101
|
} finally {
|
|
65
102
|
isAddingToCart = false;
|
|
66
103
|
}
|
|
67
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
|
+
}
|
|
68
116
|
</script>
|
|
69
117
|
|
|
70
118
|
<div class="bw-widget">
|
|
@@ -73,6 +121,9 @@
|
|
|
73
121
|
<div class="spinner"></div>
|
|
74
122
|
</div>
|
|
75
123
|
{:else if product}
|
|
124
|
+
{#if addToCartError}
|
|
125
|
+
<p class="add-to-cart-error">{addToCartError}</p>
|
|
126
|
+
{/if}
|
|
76
127
|
<WizardPage
|
|
77
128
|
{product}
|
|
78
129
|
{api}
|
|
@@ -90,4 +141,12 @@
|
|
|
90
141
|
.bw-widget {
|
|
91
142
|
display: contents;
|
|
92
143
|
}
|
|
144
|
+
|
|
145
|
+
.add-to-cart-error {
|
|
146
|
+
margin: 0;
|
|
147
|
+
padding: 12px 16px;
|
|
148
|
+
background: #fdecea;
|
|
149
|
+
color: #b3261e;
|
|
150
|
+
font-size: 14px;
|
|
151
|
+
}
|
|
93
152
|
</style>
|
package/src/lib/api.ts
CHANGED
|
@@ -15,6 +15,32 @@ import type {
|
|
|
15
15
|
} from './client-types';
|
|
16
16
|
import { dateKey } from './utils';
|
|
17
17
|
|
|
18
|
+
// Lets a caller distinguish "the server said no, and specifically why" (e.g. 402 - not paid yet, retryable
|
|
19
|
+
// while Peach's webhook is still in flight) from any other failure, without parsing the message string.
|
|
20
|
+
export class ApiError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
public readonly status: number,
|
|
23
|
+
public readonly body?: unknown,
|
|
24
|
+
) {
|
|
25
|
+
super(`API error ${status}`);
|
|
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';
|
|
42
|
+
}
|
|
43
|
+
|
|
18
44
|
export interface BookingApi {
|
|
19
45
|
sessionToken: string;
|
|
20
46
|
cartToken: string;
|
|
@@ -70,7 +96,7 @@ export class ApiClient implements BookingApi {
|
|
|
70
96
|
|
|
71
97
|
private unwrap<T>(result: { data?: T; error?: unknown; response: Response }): T {
|
|
72
98
|
if (result.data === undefined) {
|
|
73
|
-
throw new
|
|
99
|
+
throw new ApiError(result.response.status, result.error);
|
|
74
100
|
}
|
|
75
101
|
return result.data;
|
|
76
102
|
}
|
|
@@ -185,7 +211,7 @@ export class ApiClient implements BookingApi {
|
|
|
185
211
|
params: { path: { itemId } },
|
|
186
212
|
});
|
|
187
213
|
if (result.response.status >= 400) {
|
|
188
|
-
throw new
|
|
214
|
+
throw new ApiError(result.response.status);
|
|
189
215
|
}
|
|
190
216
|
}
|
|
191
217
|
|
package/src/lib/cart-manager.ts
CHANGED
|
@@ -3,6 +3,16 @@ import type { BookingApi } from './api';
|
|
|
3
3
|
|
|
4
4
|
const STORAGE_KEY = 'morii-checkout-cart';
|
|
5
5
|
|
|
6
|
+
// sessionStorage, not localStorage: the cart token is a full pay/confirm capability, and the widget ships as
|
|
7
|
+
// a shadow:none custom element, so it runs in the *merchant's* top-level origin - its web storage is shared
|
|
8
|
+
// with every other script on that page. sessionStorage narrows the exposure to the one tab and clears it when
|
|
9
|
+
// that tab closes (it still survives reloads and same-tab navigation, so the cart-survives-reload behaviour is
|
|
10
|
+
// unchanged), rather than persisting the token across all tabs indefinitely. The token is additionally bound
|
|
11
|
+
// to this origin server-side (see CheckoutCart.OriginAtIssue), so it cannot be lifted and replayed from
|
|
12
|
+
// another site; what remains - a hostile script on the merchant's own page - is inherent to shadow:none
|
|
13
|
+
// embedding and can only be fully closed by isolating the widget in its own iframe origin.
|
|
14
|
+
const storage: Storage | null = typeof sessionStorage !== 'undefined' ? sessionStorage : null;
|
|
15
|
+
|
|
6
16
|
interface StoredCart {
|
|
7
17
|
cartToken: string;
|
|
8
18
|
absoluteExpiresAt: string;
|
|
@@ -34,6 +44,22 @@ export class CartManager {
|
|
|
34
44
|
return cart;
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
// The client only knows a cart's absolute/idle expiry from whenever it was last (re)created here - it
|
|
48
|
+
// never sees the server's sliding idle window update in between, so a cart the server has since rejected
|
|
49
|
+
// (e.g. idle timeout) can still look valid locally. Callers use this to drop that stale state and force
|
|
50
|
+
// ensureCart to mint a fresh one after the server refuses an operation on the cart it was given.
|
|
51
|
+
reset(): void {
|
|
52
|
+
this.cart = null;
|
|
53
|
+
// Also cleared on the api - loadFromStorage sets it there, so leaving it behind would keep sending the
|
|
54
|
+
// dropped cart's token on every subsequent request.
|
|
55
|
+
this.api.cartToken = '';
|
|
56
|
+
try {
|
|
57
|
+
storage?.removeItem(STORAGE_KEY);
|
|
58
|
+
} catch {
|
|
59
|
+
// storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
private isExpired(): boolean {
|
|
38
64
|
if (!this.cart) return true;
|
|
39
65
|
return new Date(this.cart.absoluteExpiresAt ?? 0).getTime() <= Date.now();
|
|
@@ -46,20 +72,20 @@ export class CartManager {
|
|
|
46
72
|
absoluteExpiresAt: this.cart.absoluteExpiresAt ?? '',
|
|
47
73
|
};
|
|
48
74
|
try {
|
|
49
|
-
|
|
75
|
+
storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
|
|
50
76
|
} catch {
|
|
51
|
-
//
|
|
77
|
+
// storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
|
|
52
78
|
}
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
private loadFromStorage(): void {
|
|
56
82
|
try {
|
|
57
|
-
const raw =
|
|
83
|
+
const raw = storage?.getItem(STORAGE_KEY);
|
|
58
84
|
if (!raw) return;
|
|
59
85
|
|
|
60
86
|
const stored: StoredCart = JSON.parse(raw);
|
|
61
87
|
if (new Date(stored.absoluteExpiresAt).getTime() <= Date.now()) {
|
|
62
|
-
|
|
88
|
+
storage?.removeItem(STORAGE_KEY);
|
|
63
89
|
return;
|
|
64
90
|
}
|
|
65
91
|
|
package/src/lib/client-types.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface PaymentStatus {
|
|
|
29
29
|
outcome: PaymentOutcome;
|
|
30
30
|
resultCode?: string;
|
|
31
31
|
resultDescription?: string;
|
|
32
|
+
// Only meaningful for outcome 'failed'. A card that was actually charged - which is every 'failed' this
|
|
33
|
+
// gateway produces once confirmation itself is what failed, not the payment - must never be offered a
|
|
34
|
+
// "try again" that re-opens the card form, since the shopper would be charged a second time for a booking
|
|
35
|
+
// that already has their money against it. Defaults true (the ordinary declined/expired/errored case,
|
|
36
|
+
// where nothing was charged and retrying is exactly correct) so existing callers need not set it.
|
|
37
|
+
retryPayment?: boolean;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface CartItem {
|
package/src/lib/config.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// Defaults for the standalone dev app (App.svelte) when no query params are given at all, or no apiBaseUrl
|
|
2
|
+
// is given - same real QA product booking/demo/product.html's own fallback attributes point at, so `npm run
|
|
3
|
+
// dev` with no params still shows something real instead of talking to an empty relative URL.
|
|
4
|
+
const DEFAULT_QA_API_BASE_URL = 'https://qa.tranzact.co.za/morii-checkout/api';
|
|
5
|
+
const DEFAULT_QA_PRODUCT_ID = 'fec72dc6-0357-411b-88f5-d37083ab88cb';
|
|
6
|
+
const DEFAULT_QA_CHECKOUT_KEY = 'morii_checkout_key_2qGS3onnv2eigOiV6RT1yQ';
|
|
7
|
+
|
|
1
8
|
export type WidgetMode = 'configurator' | 'checkout' | 'cart-overview';
|
|
2
9
|
|
|
3
10
|
export type CartOverviewDisplay = 'bar' | 'button';
|
|
@@ -81,7 +88,6 @@ export interface AppConfig {
|
|
|
81
88
|
productId: string;
|
|
82
89
|
checkoutKey: string;
|
|
83
90
|
currency: string;
|
|
84
|
-
useFakeApi: boolean;
|
|
85
91
|
mode: WidgetMode;
|
|
86
92
|
cartDisplay: CartOverviewDisplay;
|
|
87
93
|
orderId: string;
|
|
@@ -96,14 +102,12 @@ export function readConfig(): AppConfig {
|
|
|
96
102
|
const params = new URLSearchParams(window.location.search);
|
|
97
103
|
|
|
98
104
|
if (params.has('product') || params.has('mode')) {
|
|
99
|
-
const apiBaseUrl = params.get('apiBaseUrl') ?? '';
|
|
100
105
|
const productId = params.get('product') ?? '';
|
|
101
106
|
return {
|
|
102
|
-
apiBaseUrl,
|
|
107
|
+
apiBaseUrl: params.get('apiBaseUrl') ?? DEFAULT_QA_API_BASE_URL,
|
|
103
108
|
productId,
|
|
104
109
|
checkoutKey: params.get('checkoutKey') ?? deriveCheckoutKey(productId),
|
|
105
110
|
currency: params.get('currency') ?? 'ZAR',
|
|
106
|
-
useFakeApi: params.get('useFakeApi') === 'true' || !apiBaseUrl,
|
|
107
111
|
mode: parseMode(params.get('mode')),
|
|
108
112
|
cartDisplay: parseCartDisplay(params.get('cartDisplay')),
|
|
109
113
|
orderId: params.get('orderId') ?? '',
|
|
@@ -116,11 +120,10 @@ export function readConfig(): AppConfig {
|
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
return {
|
|
119
|
-
apiBaseUrl:
|
|
120
|
-
productId:
|
|
121
|
-
checkoutKey:
|
|
123
|
+
apiBaseUrl: DEFAULT_QA_API_BASE_URL,
|
|
124
|
+
productId: DEFAULT_QA_PRODUCT_ID,
|
|
125
|
+
checkoutKey: DEFAULT_QA_CHECKOUT_KEY,
|
|
122
126
|
currency: 'ZAR',
|
|
123
|
-
useFakeApi: true,
|
|
124
127
|
mode: 'configurator',
|
|
125
128
|
cartDisplay: 'bar',
|
|
126
129
|
orderId: '',
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<script lang="ts">
|
|
4
4
|
import CartOverview from '../CartOverview.svelte';
|
|
5
5
|
import type { CartOverviewDisplay } from '../config';
|
|
6
|
+
import { onWidgetMessage } from '../messages';
|
|
6
7
|
import { getSharedServices } from './shared';
|
|
7
8
|
|
|
8
9
|
let {
|
|
@@ -19,21 +20,11 @@
|
|
|
19
20
|
host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
let d: Record<string, unknown>;
|
|
24
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
25
|
-
catch { return; }
|
|
26
|
-
if (!d?.type) return;
|
|
27
|
-
|
|
23
|
+
$effect(() => onWidgetMessage((d) => {
|
|
28
24
|
if (d.type === 'modal:open') {
|
|
29
25
|
dispatch('bw:checkout', {});
|
|
30
26
|
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
$effect(() => {
|
|
34
|
-
window.addEventListener('message', handleMessage);
|
|
35
|
-
return () => window.removeEventListener('message', handleMessage);
|
|
36
|
-
});
|
|
27
|
+
}));
|
|
37
28
|
</script>
|
|
38
29
|
|
|
39
30
|
{#if ready}
|