@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.
@@ -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
- totalFormatted: string;
7
- onBack: () => void;
8
- onClose: () => void;
7
+ entityId: string;
9
8
  onPaymentComplete: (result: { status: string }) => void;
10
9
  }
11
- let { checkoutId, totalFormatted, onBack, onClose, onPaymentComplete }: Props = $props();
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
- try {
20
- const Checkout = (window as any).Checkout;
21
- if (!Checkout) {
22
- failed = true;
23
- errorMessage = 'Payment SDK not loaded';
24
- return;
25
- }
18
+ (async () => {
19
+ try {
20
+ await loadPeachSdk();
26
21
 
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
- });
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
- peachInstance.render('#peach-container');
42
- } catch (e) {
43
- failed = true;
44
- errorMessage = String(e);
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
- <div class="payment-page">
59
- <div class="page-header">
60
- <button class="icon-btn" onclick={onBack} aria-label="Back">&larr;</button>
61
- <h2>Payment</h2>
62
- <span class="spacer"></span>
63
- <button class="icon-btn" onclick={onClose} aria-label="Close">&times;</button>
64
- </div>
65
-
66
- <div class="body">
67
- <div class="amount-bar">
68
- <span>Amount to pay</span>
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
- {#if failed}
73
- <div class="error-state">
74
- <div class="error-icon">!</div>
75
- <p class="error-title">Unable to load payment form</p>
76
- <p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
77
- </div>
78
- {:else}
79
- <div class="brands">
80
- <span class="brand">VISA</span>
81
- <span class="brand">MC</span>
82
- <span class="brand">AMEX</span>
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-page {
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
- height: 100%;
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
- .amount-bar {
101
- display: flex;
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">&hellip;</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 ? '\u2713' : '!'}
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 cartManager.ensureCart();
38
-
39
- const unitItems = expandUnitItems(item.units);
40
- const added = await api.addCartItem({
41
- productId: item.productId,
42
- optionId: item.optionId,
43
- unitItems,
44
- availabilityId: item.availabilityId,
45
- localDate: item.localDate,
46
- pickupPointId: item.pickupPointId,
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
- const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
53
- postMessage({
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' });
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 Error(`API error ${result.response.status}`);
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 Error(`API error ${result.response.status}`);
214
+ throw new ApiError(result.response.status);
189
215
  }
190
216
  }
191
217
 
@@ -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
- localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
75
+ storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
50
76
  } catch {
51
- // localStorage unavailable (e.g. iframe sandbox)
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 = localStorage.getItem(STORAGE_KEY);
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
- localStorage.removeItem(STORAGE_KEY);
88
+ storage?.removeItem(STORAGE_KEY);
63
89
  return;
64
90
  }
65
91
 
@@ -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: 'morii',
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
- function handleMessage(e: MessageEvent) {
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}