@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +48 -46
  2. package/README.md +500 -500
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +829 -707
  5. package/dist/booking-widget.min.js +24 -25
  6. package/dist/booking-widget.umd.cjs +5 -5
  7. package/package.json +58 -58
  8. package/src/lib/BookingProvider.svelte +21 -21
  9. package/src/lib/CartBar.svelte +26 -26
  10. package/src/lib/CartBarView.svelte +78 -78
  11. package/src/lib/CartExpiryGuard.svelte +416 -416
  12. package/src/lib/CartOverview.svelte +30 -30
  13. package/src/lib/CartOverviewButton.svelte +104 -104
  14. package/src/lib/Checkout.svelte +141 -141
  15. package/src/lib/CheckoutModal.css +179 -0
  16. package/src/lib/CheckoutModal.svelte +78 -573
  17. package/src/lib/CheckoutPanel.svelte +121 -121
  18. package/src/lib/PaymentPage.svelte +208 -191
  19. package/src/lib/ResultView.svelte +24 -4
  20. package/src/lib/TicketConfigurator.svelte +166 -166
  21. package/src/lib/api.ts +36 -5
  22. package/src/lib/booking-context.ts +33 -33
  23. package/src/lib/cart-overview.svelte.ts +97 -97
  24. package/src/lib/checkout-item-view.ts +63 -0
  25. package/src/lib/checkout-payment-flow.svelte.ts +523 -0
  26. package/src/lib/client-types.ts +22 -6
  27. package/src/lib/config.ts +162 -162
  28. package/src/lib/elements/bw-cart.svelte +49 -49
  29. package/src/lib/elements/bw-checkout.svelte +97 -97
  30. package/src/lib/elements/bw-configurator.svelte +72 -72
  31. package/src/lib/elements/register.ts +171 -171
  32. package/src/lib/elements/shared.ts +18 -18
  33. package/src/lib/generated-types.ts +94 -5
  34. package/src/lib/host.svelte.ts +336 -336
  35. package/src/lib/index.ts +242 -242
  36. package/src/lib/messages.ts +157 -157
  37. package/src/lib/peach-sdk.ts +86 -86
  38. 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
- interface Props {
7
- checkoutId: string;
8
- entityId: string;
9
- onPaymentComplete: (result: { status: string }) => void;
10
- // The cart's remaining life, shown in this dialog's own header so the shopper can see it over Peach's
11
- // form - CheckoutModal's own cart timer is behind this overlay. Optional so callers that predate it need
12
- // not pass one. CartExpiryGuard.svelte is what actually warns and acts as this counts down to zero - it
13
- // renders above this overlay too, so there is nothing else for this dialog to do near the deadline.
14
- expiresAt?: Date;
15
- }
16
- let { checkoutId, entityId, onPaymentComplete, expiresAt }: Props = $props();
17
-
18
- let failed = $state(false);
19
- let errorMessage = $state('');
20
-
21
- let peachInstance: any = null;
22
-
23
- onMount(() => {
24
- (async () => {
25
- try {
26
- await loadPeachSdk();
27
-
28
- const Checkout = (window as any).Checkout;
29
- if (!Checkout) {
30
- failed = true;
31
- errorMessage = 'Payment SDK not loaded';
32
- return;
33
- }
34
-
35
- peachInstance = Checkout.initiate({
36
- checkoutId,
37
- // Peach's own "key" field, not our checkoutKey/entityId naming - without it Checkout.initiate()
38
- // throws (Cannot read properties of undefined (reading 'trim')) before ever rendering anything,
39
- // since the embedded widget authenticates to Peach directly with this rather than through us.
40
- key: entityId,
41
- customisations: {
42
- card: { submitButtonText: 'Pay Now' },
43
- theme: { brand: { primary: '#E30613' } },
44
- // Peach's own Cancel (method-selection screen) and Back (card-entry screen) are the only
45
- // back/exit controls in this dialog - we don't render one of our own alongside them, so there's
46
- // exactly one way out per screen instead of ours and Peach's competing for the same job.
47
- },
48
- eventHandlers: {
49
- onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
50
- onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
51
- onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
52
- onError: () => { reportPeachFailure('Peach reported an error rendering the card form'); cleanup(); onPaymentComplete({ status: 'error' }); },
53
- },
54
- });
55
-
56
- peachInstance.render('#peach-container');
57
- } catch (e) {
58
- reportPeachFailure(String(e));
59
- failed = true;
60
- errorMessage = String(e);
61
- }
62
- })();
63
- });
64
-
65
- // Peach renders its own "an unrecoverable error has occurred" card inside its iframe, which tells a
66
- // developer nothing about why. By far the most common cause is an SDK from one environment against a
67
- // checkoutId created in another - the checkout API makes the checkout, so the two have to agree - and
68
- // that is invisible unless the URL actually loaded is named.
69
- function reportPeachFailure(reason: string) {
70
- console.error(
71
- `[booking-widget] Peach checkout did not render: ${reason}. ` +
72
- `SDK loaded from ${loadedPeachSdkUrl() ?? 'an already-present window.Checkout'} for checkoutId ` +
73
- `${checkoutId}. If that environment does not match the checkout API this cart came from, set ` +
74
- `peachEnv on createBookingHost (or window.BW_CHECKOUT_PEACH_SDK_URL).`,
75
- );
76
- }
77
-
78
- function cleanup() {
79
- if (peachInstance) {
80
- peachInstance.unmount();
81
- peachInstance = null;
82
- }
83
- }
84
-
85
- onDestroy(cleanup);
86
- </script>
87
-
88
- <!-- Peach's own card form renders inside a cross-origin iframe with its own Cancel/Back controls we have no
89
- way to hide or reach (no SDK option, no CSS/JS access across origins) - see Checkout.initiate()'s
90
- customisations doc comment above. Presenting it as its own nested dialog over the checkout modal, rather
91
- than as just another view swapped into that modal's own body, means Peach's own Cancel/Back read as
92
- belonging to Peach's own layer instead of visually competing with a second, separate Back of ours - so
93
- this dialog has no back/exit control of its own at all; onCancelled below routes back to contact. -->
94
- <div class="payment-overlay">
95
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
96
- <div class="payment-dialog" onclick={(e) => e.stopPropagation()}>
97
- <div class="page-header">
98
- <h2>Payment</h2>
99
- {#if expiresAt}
100
- <span class="spacer"></span>
101
- <CountdownTimer {expiresAt} />
102
- {/if}
103
- </div>
104
-
105
- <div class="body">
106
- {#if failed}
107
- <div class="error-state">
108
- <div class="error-icon">!</div>
109
- <p class="error-title">Unable to load payment form</p>
110
- <p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
111
- <!-- Peach's own Cancel/Back never rendered, so this dialog needs one exit of its own here. Reported
112
- as an error rather than a cancel: nothing was charged, and CheckoutModal's error path abandons
113
- the attempt so a retry mints a fresh Peach checkout instead of reopening this one - which is the
114
- only sensible outcome for a checkout that would not even render (e.g. one resumed after a reload
115
- that Peach has since expired). -->
116
- <button class="btn btn-primary" onclick={() => onPaymentComplete({ status: 'error' })}>Start over</button>
117
- </div>
118
- {:else}
119
- <div id="peach-container"></div>
120
- {/if}
121
- </div>
122
- </div>
123
- </div>
124
-
125
- <style>
126
- .payment-overlay {
127
- position: fixed;
128
- inset: 0;
129
- /* Above bw-checkout's own .bw-modal-overlay (z-index 10000) so this reads as a second dialog opening
130
- on top of the checkout modal, not content living inside it. */
131
- z-index: 10001;
132
- background: rgba(0, 0, 0, 0.5);
133
- display: flex;
134
- align-items: center;
135
- justify-content: center;
136
- padding: 16px;
137
- }
138
- .payment-dialog {
139
- display: flex;
140
- flex-direction: column;
141
- width: 100%;
142
- max-width: 480px;
143
- height: min(700px, 90vh);
144
- background: var(--bw-color-bg);
145
- border-radius: var(--bw-radius-lg);
146
- box-shadow: var(--bw-shadow-3);
147
- overflow: hidden;
148
- }
149
- .body {
150
- flex: 1;
151
- /* A definite height (not just flex:1 alone) so #peach-container's own height:100% below has something
152
- real to resolve against, and so this scrolls as a single region if content overflows - Peach's own
153
- root is `height: inherit`, so without that it collapses to its 360px fallback regardless of how tall
154
- the card form actually gets (e.g. once billing address fields are showing), and its submit button
155
- ends up overlapping the fields above it. */
156
- min-height: 0;
157
- overflow-y: auto;
158
- padding: 16px;
159
- }
160
- #peach-container {
161
- height: 100%;
162
- }
163
- .error-state {
164
- text-align: center;
165
- padding: 32px 16px;
166
- }
167
- .error-icon {
168
- width: 48px;
169
- height: 48px;
170
- border-radius: 50%;
171
- border: 2px solid var(--bw-color-border);
172
- display: flex;
173
- align-items: center;
174
- justify-content: center;
175
- font-size: 24px;
176
- color: var(--bw-color-text-secondary);
177
- margin: 0 auto 16px;
178
- }
179
- .error-title {
180
- font-size: 16px;
181
- font-weight: 600;
182
- margin-bottom: 8px;
183
- }
184
- .error-msg {
185
- font-size: 14px;
186
- color: var(--bw-color-text-secondary);
187
- }
188
- .error-state .btn {
189
- margin-top: 16px;
190
- }
191
- </style>
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">&hellip;</div>
32
- <h2>Payment Received</h2>
42
+ <h2>Still Checking Your Payment</h2>
33
43
  <p class="desc">
34
44
  {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."}
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 nothing is confirmed yet (so green would be premature). */
100
- .icon.pending {
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);