@code-collective/booking-widget 1.0.4 → 1.0.7

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