@code-collective/booking-widget 1.0.12 → 1.0.14
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/CHANGELOG.md +48 -46
- package/README.md +500 -500
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +829 -707
- package/dist/booking-widget.min.js +24 -25
- package/dist/booking-widget.umd.cjs +5 -5
- package/package.json +58 -58
- package/src/lib/BookingProvider.svelte +21 -21
- package/src/lib/CartBar.svelte +26 -26
- package/src/lib/CartBarView.svelte +78 -78
- package/src/lib/CartExpiryGuard.svelte +416 -416
- package/src/lib/CartOverview.svelte +30 -30
- package/src/lib/CartOverviewButton.svelte +104 -104
- package/src/lib/Checkout.svelte +141 -141
- package/src/lib/CheckoutModal.css +179 -0
- package/src/lib/CheckoutModal.svelte +78 -573
- package/src/lib/CheckoutPanel.svelte +121 -121
- package/src/lib/PaymentPage.svelte +208 -191
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +166 -166
- package/src/lib/api.ts +36 -5
- package/src/lib/booking-context.ts +33 -33
- package/src/lib/cart-overview.svelte.ts +97 -97
- package/src/lib/checkout-item-view.ts +63 -0
- package/src/lib/checkout-payment-flow.svelte.ts +523 -0
- package/src/lib/client-types.ts +22 -6
- package/src/lib/config.ts +162 -162
- package/src/lib/elements/bw-cart.svelte +49 -49
- package/src/lib/elements/bw-checkout.svelte +97 -97
- package/src/lib/elements/bw-configurator.svelte +72 -72
- package/src/lib/elements/register.ts +171 -171
- package/src/lib/elements/shared.ts +18 -18
- package/src/lib/generated-types.ts +94 -5
- package/src/lib/host.svelte.ts +336 -336
- package/src/lib/index.ts +242 -242
- package/src/lib/messages.ts +157 -157
- package/src/lib/peach-sdk.ts +86 -86
- package/src/lib/portal.ts +23 -23
|
@@ -1,191 +1,208 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { onMount, onDestroy } from 'svelte';
|
|
3
|
-
import { loadPeachSdk, loadedPeachSdkUrl } from './peach-sdk';
|
|
4
|
-
import CountdownTimer from './CountdownTimer.svelte';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
.error-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { loadPeachSdk, loadedPeachSdkUrl } from './peach-sdk';
|
|
4
|
+
import CountdownTimer from './CountdownTimer.svelte';
|
|
5
|
+
import type { PaymentSdkResult } from './client-types';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
checkoutId: string;
|
|
9
|
+
entityId: string;
|
|
10
|
+
onPaymentComplete: (result: PaymentSdkResult) => void;
|
|
11
|
+
// The cart's remaining life, shown in this dialog's own header so the shopper can see it over Peach's
|
|
12
|
+
// form - CheckoutModal's own cart timer is behind this overlay. Optional so callers that predate it need
|
|
13
|
+
// not pass one. CartExpiryGuard.svelte is what actually warns and acts as this counts down to zero - it
|
|
14
|
+
// renders above this overlay too, so there is nothing else for this dialog to do near the deadline.
|
|
15
|
+
expiresAt?: Date;
|
|
16
|
+
}
|
|
17
|
+
let { checkoutId, entityId, onPaymentComplete, expiresAt }: Props = $props();
|
|
18
|
+
|
|
19
|
+
// Peach does not document a stable shape for what its eventHandlers receive, and it differs between the
|
|
20
|
+
// callbacks. Anything we cannot find is simply omitted - the failure is still worth reporting without a
|
|
21
|
+
// code, and guessing one would be worse than sending none.
|
|
22
|
+
function resultOf(status: PaymentSdkResult['status'], payload: any): PaymentSdkResult {
|
|
23
|
+
const result = payload?.result ?? payload;
|
|
24
|
+
return {
|
|
25
|
+
status,
|
|
26
|
+
resultCode: result?.code ?? result?.resultCode ?? payload?.errorCode ?? undefined,
|
|
27
|
+
description: result?.description ?? payload?.message ?? undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let failed = $state(false);
|
|
32
|
+
let errorMessage = $state('');
|
|
33
|
+
|
|
34
|
+
let peachInstance: any = null;
|
|
35
|
+
|
|
36
|
+
onMount(() => {
|
|
37
|
+
(async () => {
|
|
38
|
+
try {
|
|
39
|
+
await loadPeachSdk();
|
|
40
|
+
|
|
41
|
+
const Checkout = (window as any).Checkout;
|
|
42
|
+
if (!Checkout) {
|
|
43
|
+
failed = true;
|
|
44
|
+
errorMessage = 'Payment SDK not loaded';
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
peachInstance = Checkout.initiate({
|
|
49
|
+
checkoutId,
|
|
50
|
+
// Peach's own "key" field, not our checkoutKey/entityId naming - without it Checkout.initiate()
|
|
51
|
+
// throws (Cannot read properties of undefined (reading 'trim')) before ever rendering anything,
|
|
52
|
+
// since the embedded widget authenticates to Peach directly with this rather than through us.
|
|
53
|
+
key: entityId,
|
|
54
|
+
customisations: {
|
|
55
|
+
card: { submitButtonText: 'Pay Now' },
|
|
56
|
+
theme: { brand: { primary: '#E30613' } },
|
|
57
|
+
// Peach's own Cancel (method-selection screen) and Back (card-entry screen) are the only
|
|
58
|
+
// back/exit controls in this dialog - we don't render one of our own alongside them, so there's
|
|
59
|
+
// exactly one way out per screen instead of ours and Peach's competing for the same job.
|
|
60
|
+
},
|
|
61
|
+
eventHandlers: {
|
|
62
|
+
onCompleted: (e: any) => { cleanup(); onPaymentComplete(resultOf('completed', e)); },
|
|
63
|
+
onCancelled: (e: any) => { cleanup(); onPaymentComplete(resultOf('cancelled', e)); },
|
|
64
|
+
onExpired: (e: any) => { cleanup(); onPaymentComplete(resultOf('expired', e)); },
|
|
65
|
+
onError: (e: any) => {
|
|
66
|
+
reportPeachFailure('Peach reported an error rendering the card form');
|
|
67
|
+
cleanup();
|
|
68
|
+
onPaymentComplete(resultOf('error', e));
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
peachInstance.render('#peach-container');
|
|
74
|
+
} catch (e) {
|
|
75
|
+
reportPeachFailure(String(e));
|
|
76
|
+
failed = true;
|
|
77
|
+
errorMessage = String(e);
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Peach renders its own "an unrecoverable error has occurred" card inside its iframe, which tells a
|
|
83
|
+
// developer nothing about why. By far the most common cause is an SDK from one environment against a
|
|
84
|
+
// checkoutId created in another - the checkout API makes the checkout, so the two have to agree - and
|
|
85
|
+
// that is invisible unless the URL actually loaded is named.
|
|
86
|
+
function reportPeachFailure(reason: string) {
|
|
87
|
+
console.error(
|
|
88
|
+
`[booking-widget] Peach checkout did not render: ${reason}. ` +
|
|
89
|
+
`SDK loaded from ${loadedPeachSdkUrl() ?? 'an already-present window.Checkout'} for checkoutId ` +
|
|
90
|
+
`${checkoutId}. If that environment does not match the checkout API this cart came from, set ` +
|
|
91
|
+
`peachEnv on createBookingHost (or window.BW_CHECKOUT_PEACH_SDK_URL).`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cleanup() {
|
|
96
|
+
if (peachInstance) {
|
|
97
|
+
peachInstance.unmount();
|
|
98
|
+
peachInstance = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onDestroy(cleanup);
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<!-- Peach's own card form renders inside a cross-origin iframe with its own Cancel/Back controls we have no
|
|
106
|
+
way to hide or reach (no SDK option, no CSS/JS access across origins) - see Checkout.initiate()'s
|
|
107
|
+
customisations doc comment above. Presenting it as its own nested dialog over the checkout modal, rather
|
|
108
|
+
than as just another view swapped into that modal's own body, means Peach's own Cancel/Back read as
|
|
109
|
+
belonging to Peach's own layer instead of visually competing with a second, separate Back of ours - so
|
|
110
|
+
this dialog has no back/exit control of its own at all; onCancelled below routes back to contact. -->
|
|
111
|
+
<div class="payment-overlay">
|
|
112
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
113
|
+
<div class="payment-dialog" onclick={(e) => e.stopPropagation()}>
|
|
114
|
+
<div class="page-header">
|
|
115
|
+
<h2>Payment</h2>
|
|
116
|
+
{#if expiresAt}
|
|
117
|
+
<span class="spacer"></span>
|
|
118
|
+
<CountdownTimer {expiresAt} />
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="body">
|
|
123
|
+
{#if failed}
|
|
124
|
+
<div class="error-state">
|
|
125
|
+
<div class="error-icon">!</div>
|
|
126
|
+
<p class="error-title">Unable to load payment form</p>
|
|
127
|
+
<p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
|
|
128
|
+
<!-- Peach's own Cancel/Back never rendered, so this dialog needs one exit of its own here. Reported
|
|
129
|
+
as an error rather than a cancel: nothing was charged, and CheckoutModal's error path abandons
|
|
130
|
+
the attempt so a retry mints a fresh Peach checkout instead of reopening this one - which is the
|
|
131
|
+
only sensible outcome for a checkout that would not even render (e.g. one resumed after a reload
|
|
132
|
+
that Peach has since expired). -->
|
|
133
|
+
<button class="btn btn-primary" onclick={() => onPaymentComplete({ status: 'error' })}>Start over</button>
|
|
134
|
+
</div>
|
|
135
|
+
{:else}
|
|
136
|
+
<div id="peach-container"></div>
|
|
137
|
+
{/if}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<style>
|
|
143
|
+
.payment-overlay {
|
|
144
|
+
position: fixed;
|
|
145
|
+
inset: 0;
|
|
146
|
+
/* Above bw-checkout's own .bw-modal-overlay (z-index 10000) so this reads as a second dialog opening
|
|
147
|
+
on top of the checkout modal, not content living inside it. */
|
|
148
|
+
z-index: 10001;
|
|
149
|
+
background: rgba(0, 0, 0, 0.5);
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
justify-content: center;
|
|
153
|
+
padding: 16px;
|
|
154
|
+
}
|
|
155
|
+
.payment-dialog {
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
width: 100%;
|
|
159
|
+
max-width: 480px;
|
|
160
|
+
height: min(700px, 90vh);
|
|
161
|
+
background: var(--bw-color-bg);
|
|
162
|
+
border-radius: var(--bw-radius-lg);
|
|
163
|
+
box-shadow: var(--bw-shadow-3);
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
}
|
|
166
|
+
.body {
|
|
167
|
+
flex: 1;
|
|
168
|
+
/* A definite height (not just flex:1 alone) so #peach-container's own height:100% below has something
|
|
169
|
+
real to resolve against, and so this scrolls as a single region if content overflows - Peach's own
|
|
170
|
+
root is `height: inherit`, so without that it collapses to its 360px fallback regardless of how tall
|
|
171
|
+
the card form actually gets (e.g. once billing address fields are showing), and its submit button
|
|
172
|
+
ends up overlapping the fields above it. */
|
|
173
|
+
min-height: 0;
|
|
174
|
+
overflow-y: auto;
|
|
175
|
+
padding: 16px;
|
|
176
|
+
}
|
|
177
|
+
#peach-container {
|
|
178
|
+
height: 100%;
|
|
179
|
+
}
|
|
180
|
+
.error-state {
|
|
181
|
+
text-align: center;
|
|
182
|
+
padding: 32px 16px;
|
|
183
|
+
}
|
|
184
|
+
.error-icon {
|
|
185
|
+
width: 48px;
|
|
186
|
+
height: 48px;
|
|
187
|
+
border-radius: 50%;
|
|
188
|
+
border: 2px solid var(--bw-color-border);
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
justify-content: center;
|
|
192
|
+
font-size: 24px;
|
|
193
|
+
color: var(--bw-color-text-secondary);
|
|
194
|
+
margin: 0 auto 16px;
|
|
195
|
+
}
|
|
196
|
+
.error-title {
|
|
197
|
+
font-size: 16px;
|
|
198
|
+
font-weight: 600;
|
|
199
|
+
margin-bottom: 8px;
|
|
200
|
+
}
|
|
201
|
+
.error-msg {
|
|
202
|
+
font-size: 14px;
|
|
203
|
+
color: var(--bw-color-text-secondary);
|
|
204
|
+
}
|
|
205
|
+
.error-state .btn {
|
|
206
|
+
margin-top: 16px;
|
|
207
|
+
}
|
|
208
|
+
</style>
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
let success = $derived(status?.outcome === 'successful');
|
|
16
16
|
let pending = $derived(status?.outcome === 'pending');
|
|
17
|
+
// Paid, but this gateway could not confirm every item - so it gets its own branch below rather than falling
|
|
18
|
+
// into the failure one. The shopper's card was charged; telling them "Payment Failed" over an explanation
|
|
19
|
+
// that opens "Your payment succeeded" is how someone ends up paying twice or charging back a good payment.
|
|
20
|
+
let partial = $derived(status?.outcome === 'partial');
|
|
17
21
|
// A 'failed' outcome covers two genuinely different situations - see CheckoutModal's own remarks on
|
|
18
22
|
// confirmOutcome. Only one of them is safe to offer "Try Again" for: the payment itself never went
|
|
19
23
|
// through (declined, expired, SDK error), so re-opening the card form charges the shopper once, not
|
|
@@ -27,12 +31,18 @@
|
|
|
27
31
|
{#if verifying}
|
|
28
32
|
<div class="spinner"></div>
|
|
29
33
|
<p class="hint">Verifying payment...</p>
|
|
34
|
+
<!-- 'pending' is reached whenever this gateway could not get a final answer: the webhook still in flight
|
|
35
|
+
behind a real charge, but also a confirm that gave up after ~15s, a server error, or a cart the server
|
|
36
|
+
no longer has. Peach fires onCompleted for a declined card too, and a decline the server could not read
|
|
37
|
+
back (its own status endpoint unreachable) lands here as readily as a charge does - so this screen must
|
|
38
|
+
not claim a payment was received. It says what is actually known, which is nothing yet, and still asks
|
|
39
|
+
the shopper not to pay again, which is right either way round (PR 8447 review). -->
|
|
30
40
|
{:else if pending}
|
|
31
41
|
<div class="icon pending">…</div>
|
|
32
|
-
<h2>Payment
|
|
42
|
+
<h2>Still Checking Your Payment</h2>
|
|
33
43
|
<p class="desc">
|
|
34
44
|
{status?.resultDescription ??
|
|
35
|
-
"We'
|
|
45
|
+
"We couldn't confirm this payment yet. Please don't try to pay again for now - we'll email you as soon as we know where it stands."}
|
|
36
46
|
</p>
|
|
37
47
|
<div class="actions">
|
|
38
48
|
{#if onCheckAgain}
|
|
@@ -40,6 +50,14 @@
|
|
|
40
50
|
{/if}
|
|
41
51
|
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
42
52
|
</div>
|
|
53
|
+
{:else if partial}
|
|
54
|
+
<div class="icon partial">!</div>
|
|
55
|
+
<h2>Booking Incomplete</h2>
|
|
56
|
+
<p class="desc">
|
|
57
|
+
{status?.resultDescription ??
|
|
58
|
+
"Your payment succeeded, but we couldn't confirm every item in your booking. Please contact us with your order details."}
|
|
59
|
+
</p>
|
|
60
|
+
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
43
61
|
{:else}
|
|
44
62
|
<div class="icon" class:success class:failure={!success}>
|
|
45
63
|
{success ? '✓' : '!'}
|
|
@@ -96,8 +114,10 @@
|
|
|
96
114
|
background: var(--bw-color-primary-light);
|
|
97
115
|
}
|
|
98
116
|
/* Deliberately its own amber, not success green or failure red - payment succeeded (so red reads as an
|
|
99
|
-
alarming false alarm) but
|
|
100
|
-
|
|
117
|
+
alarming false alarm) but the booking is not fully confirmed (so green would be premature). The same is
|
|
118
|
+
true either way round: 'pending' is not confirmed yet, 'partial' will not be for some of its items. */
|
|
119
|
+
.icon.pending,
|
|
120
|
+
.icon.partial {
|
|
101
121
|
border: 2px solid #B8860B;
|
|
102
122
|
color: #B8860B;
|
|
103
123
|
background: rgba(184, 134, 11, 0.08);
|