@code-collective/booking-widget 1.0.7 → 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 +14 -1
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +411 -384
- 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 +4 -11
- package/src/lib/CartOverviewButton.svelte +4 -12
- package/src/lib/Checkout.svelte +12 -13
- package/src/lib/CheckoutModal.svelte +122 -30
- package/src/lib/EditBookingView.svelte +15 -4
- package/src/lib/PaymentPage.svelte +59 -61
- package/src/lib/ResultView.svelte +43 -3
- package/src/lib/TicketConfigurator.svelte +25 -3
- package/src/lib/api.ts +20 -2
- package/src/lib/cart-manager.ts +19 -6
- package/src/lib/client-types.ts +6 -0
- 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/register.ts +3 -5
- package/src/lib/generated-types.ts +1 -0
- package/src/lib/index.ts +12 -28
- package/src/lib/messages.ts +63 -3
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;
|
|
@@ -40,10 +50,13 @@ export class CartManager {
|
|
|
40
50
|
// ensureCart to mint a fresh one after the server refuses an operation on the cart it was given.
|
|
41
51
|
reset(): void {
|
|
42
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 = '';
|
|
43
56
|
try {
|
|
44
|
-
|
|
57
|
+
storage?.removeItem(STORAGE_KEY);
|
|
45
58
|
} catch {
|
|
46
|
-
//
|
|
59
|
+
// storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
|
|
47
60
|
}
|
|
48
61
|
}
|
|
49
62
|
|
|
@@ -59,20 +72,20 @@ export class CartManager {
|
|
|
59
72
|
absoluteExpiresAt: this.cart.absoluteExpiresAt ?? '',
|
|
60
73
|
};
|
|
61
74
|
try {
|
|
62
|
-
|
|
75
|
+
storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
|
|
63
76
|
} catch {
|
|
64
|
-
//
|
|
77
|
+
// storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
|
|
65
78
|
}
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
private loadFromStorage(): void {
|
|
69
82
|
try {
|
|
70
|
-
const raw =
|
|
83
|
+
const raw = storage?.getItem(STORAGE_KEY);
|
|
71
84
|
if (!raw) return;
|
|
72
85
|
|
|
73
86
|
const stored: StoredCart = JSON.parse(raw);
|
|
74
87
|
if (new Date(stored.absoluteExpiresAt).getTime() <= Date.now()) {
|
|
75
|
-
|
|
88
|
+
storage?.removeItem(STORAGE_KEY);
|
|
76
89
|
return;
|
|
77
90
|
}
|
|
78
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 {
|
|
@@ -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}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<script lang="ts">
|
|
4
4
|
import Checkout from '../Checkout.svelte';
|
|
5
5
|
import { resolveWizardPages, resolveEditPages } from '../config';
|
|
6
|
+
import { onWidgetMessage } from '../messages';
|
|
6
7
|
import { getSharedServices } from './shared';
|
|
7
8
|
|
|
8
9
|
let {
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
let isModal = $derived(isModalAttr !== undefined);
|
|
18
19
|
let showModal = $state(false);
|
|
19
20
|
|
|
20
|
-
const { api, ready: readyPromise } = getSharedServices();
|
|
21
|
+
const { api, cartManager, ready: readyPromise } = getSharedServices();
|
|
21
22
|
|
|
22
23
|
let ready = $state(false);
|
|
23
24
|
readyPromise.then(() => { ready = true; });
|
|
@@ -34,30 +35,22 @@
|
|
|
34
35
|
export function open() { showModal = true; }
|
|
35
36
|
export function close() { showModal = false; dispatch('bw:close', {}); }
|
|
36
37
|
|
|
37
|
-
|
|
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) return;
|
|
42
|
-
|
|
38
|
+
$effect(() => onWidgetMessage((d) => {
|
|
43
39
|
if (d.type === 'modal:close') {
|
|
44
40
|
if (isModal) showModal = false;
|
|
45
41
|
dispatch('bw:close', {});
|
|
46
42
|
}
|
|
47
43
|
if (d.type === 'order:complete') {
|
|
48
|
-
|
|
44
|
+
// Deliberately does not close the modal - order:complete only reports that the order is confirmed
|
|
45
|
+
// (for host analytics/cart-clearing), not that the shopper has finished looking at the result screen.
|
|
46
|
+
// The modal stays open until they dismiss it themselves, which sends modal:close separately.
|
|
49
47
|
dispatch('bw:order-confirmed', {
|
|
50
48
|
cartToken: d.cartToken ?? '',
|
|
51
49
|
value: d.value ?? 0,
|
|
52
50
|
currency: d.currency ?? 'ZAR',
|
|
53
51
|
});
|
|
54
52
|
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
$effect(() => {
|
|
58
|
-
window.addEventListener('message', handleMessage);
|
|
59
|
-
return () => window.removeEventListener('message', handleMessage);
|
|
60
|
-
});
|
|
53
|
+
}));
|
|
61
54
|
|
|
62
55
|
// Listen for a custom 'bw:open' event so register.ts can trigger it
|
|
63
56
|
$effect(() => {
|
|
@@ -74,12 +67,12 @@
|
|
|
74
67
|
<div class="bw-modal-overlay" onclick={() => close()}>
|
|
75
68
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
76
69
|
<div class="bw-modal-inner" onclick={(e) => e.stopPropagation()}>
|
|
77
|
-
<Checkout {api} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
|
|
70
|
+
<Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
|
|
78
71
|
</div>
|
|
79
72
|
</div>
|
|
80
73
|
{/if}
|
|
81
74
|
{:else if ready}
|
|
82
|
-
<Checkout {api} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
|
|
75
|
+
<Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot} />
|
|
83
76
|
{:else}
|
|
84
77
|
<div class="loading-center"><div class="spinner"></div></div>
|
|
85
78
|
{/if}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<script lang="ts">
|
|
4
4
|
import TicketConfigurator from '../TicketConfigurator.svelte';
|
|
5
5
|
import { resolveWizardPages } from '../config';
|
|
6
|
-
import { postMessage } from '../messages';
|
|
6
|
+
import { onWidgetMessage, postMessage } from '../messages';
|
|
7
7
|
import { getSharedServices } from './shared';
|
|
8
8
|
|
|
9
9
|
let {
|
|
@@ -26,12 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// Forward postMessage events as CustomEvents
|
|
29
|
-
|
|
30
|
-
let d: Record<string, unknown>;
|
|
31
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
32
|
-
catch { return; }
|
|
33
|
-
if (!d?.type) return;
|
|
34
|
-
|
|
29
|
+
$effect(() => onWidgetMessage((d) => {
|
|
35
30
|
if (d.type === 'cart:change') {
|
|
36
31
|
dispatch('bw:cart-change', {
|
|
37
32
|
itemCount: d.itemCount ?? 0,
|
|
@@ -39,12 +34,7 @@
|
|
|
39
34
|
totalFormatted: d.totalFormatted ?? '',
|
|
40
35
|
});
|
|
41
36
|
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
$effect(() => {
|
|
45
|
-
window.addEventListener('message', handleMessage);
|
|
46
|
-
return () => window.removeEventListener('message', handleMessage);
|
|
47
|
-
});
|
|
37
|
+
}));
|
|
48
38
|
|
|
49
39
|
const onCancel = cancelable !== undefined
|
|
50
40
|
? () => { postMessage({ type: 'modal:close' }); dispatch('bw:cancel', {}); }
|
|
@@ -4,6 +4,7 @@ import { ApiClient } from '../api';
|
|
|
4
4
|
import { SessionManager } from '../session-manager';
|
|
5
5
|
import { CartManager } from '../cart-manager';
|
|
6
6
|
import { defaultApiBaseUrl } from './env';
|
|
7
|
+
import { onWidgetMessage } from '../messages';
|
|
7
8
|
|
|
8
9
|
// Bootstrap shared services BEFORE element imports trigger connectedCallback.
|
|
9
10
|
// Elements read from window.__bwServices instead of importing shared.ts,
|
|
@@ -26,11 +27,8 @@ import './bw-checkout.svelte';
|
|
|
26
27
|
// cart TicketConfigurator/CheckoutModal already fetch for their own posting - see those files' own comments -
|
|
27
28
|
// so a consumer can build a custom cart summary (item count, remaining time, item details) without calling
|
|
28
29
|
// the checkout API directly.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
32
|
-
catch { return; }
|
|
33
|
-
if (d?.type === 'cart:updated' && 'cart' in d) {
|
|
30
|
+
onWidgetMessage((d) => {
|
|
31
|
+
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
34
32
|
window.dispatchEvent(new CustomEvent('bw:cart-updated', { detail: { cart: d.cart } }));
|
|
35
33
|
}
|
|
36
34
|
});
|
package/src/lib/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { CheckoutCartDetailDto } from './client-types';
|
|
|
6
6
|
import { ApiClient, ApiError } from './api';
|
|
7
7
|
import { SessionManager } from './session-manager';
|
|
8
8
|
import { CartManager } from './cart-manager';
|
|
9
|
+
import { onWidgetMessage } from './messages';
|
|
9
10
|
import TicketConfiguratorComponent from './TicketConfigurator.svelte';
|
|
10
11
|
import CheckoutComponent from './Checkout.svelte';
|
|
11
12
|
import CartOverviewComponent from './CartOverview.svelte';
|
|
@@ -96,12 +97,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
|
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
// Listen for postMessage events and forward to callbacks
|
|
99
|
-
|
|
100
|
-
let d: Record<string, unknown>;
|
|
101
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
102
|
-
catch { return; }
|
|
103
|
-
if (!d?.type) return;
|
|
104
|
-
|
|
100
|
+
const stopListening = onWidgetMessage((d) => {
|
|
105
101
|
if (d.type === 'cart:change' && config.onCartChange) {
|
|
106
102
|
config.onCartChange({
|
|
107
103
|
itemCount: (d.itemCount as number) ?? 0,
|
|
@@ -112,12 +108,11 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
|
|
|
112
108
|
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
113
109
|
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
114
110
|
}
|
|
115
|
-
}
|
|
116
|
-
window.addEventListener('message', messageHandler);
|
|
111
|
+
});
|
|
117
112
|
|
|
118
113
|
return {
|
|
119
114
|
destroy() {
|
|
120
|
-
|
|
115
|
+
stopListening();
|
|
121
116
|
sessionManager.stop();
|
|
122
117
|
unmount(component);
|
|
123
118
|
},
|
|
@@ -125,7 +120,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
|
|
|
125
120
|
}
|
|
126
121
|
|
|
127
122
|
export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
|
|
128
|
-
const { api, sessionManager } = bootstrap(config);
|
|
123
|
+
const { api, sessionManager, cartManager } = bootstrap(config);
|
|
129
124
|
|
|
130
125
|
await sessionManager.ensureSession(config.checkoutKey ?? '');
|
|
131
126
|
|
|
@@ -133,18 +128,14 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
|
|
|
133
128
|
target,
|
|
134
129
|
props: {
|
|
135
130
|
api,
|
|
131
|
+
cartManager,
|
|
136
132
|
wizardPages: config.wizardPages,
|
|
137
133
|
editPages: config.editPages,
|
|
138
134
|
autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
|
|
139
135
|
},
|
|
140
136
|
});
|
|
141
137
|
|
|
142
|
-
|
|
143
|
-
let d: Record<string, unknown>;
|
|
144
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
145
|
-
catch { return; }
|
|
146
|
-
if (!d?.type) return;
|
|
147
|
-
|
|
138
|
+
const stopListening = onWidgetMessage((d) => {
|
|
148
139
|
if (d.type === 'modal:close' && config.onClose) config.onClose();
|
|
149
140
|
if (d.type === 'order:complete' && config.onOrderConfirmed) {
|
|
150
141
|
config.onOrderConfirmed({
|
|
@@ -156,12 +147,11 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
|
|
|
156
147
|
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
157
148
|
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
158
149
|
}
|
|
159
|
-
}
|
|
160
|
-
window.addEventListener('message', messageHandler);
|
|
150
|
+
});
|
|
161
151
|
|
|
162
152
|
return {
|
|
163
153
|
destroy() {
|
|
164
|
-
|
|
154
|
+
stopListening();
|
|
165
155
|
sessionManager.stop();
|
|
166
156
|
unmount(component);
|
|
167
157
|
},
|
|
@@ -182,22 +172,16 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
|
|
|
182
172
|
},
|
|
183
173
|
});
|
|
184
174
|
|
|
185
|
-
|
|
186
|
-
let d: Record<string, unknown>;
|
|
187
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
188
|
-
catch { return; }
|
|
189
|
-
if (!d?.type) return;
|
|
190
|
-
|
|
175
|
+
const stopListening = onWidgetMessage((d) => {
|
|
191
176
|
if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
|
|
192
177
|
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
193
178
|
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
194
179
|
}
|
|
195
|
-
}
|
|
196
|
-
window.addEventListener('message', messageHandler);
|
|
180
|
+
});
|
|
197
181
|
|
|
198
182
|
return {
|
|
199
183
|
destroy() {
|
|
200
|
-
|
|
184
|
+
stopListening();
|
|
201
185
|
sessionManager.stop();
|
|
202
186
|
unmount(component);
|
|
203
187
|
},
|
package/src/lib/messages.ts
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// The widget's own components talk to each other over postMessage - a cart added in bw-configurator has to
|
|
2
|
+
// reach the bar in bw-cart and the modal in bw-checkout, which are separate custom elements with no shared
|
|
3
|
+
// Svelte tree. That makes every one of these messages same-window and same-origin, and the guards below exist
|
|
4
|
+
// to keep it that way.
|
|
5
|
+
|
|
6
|
+
/** Messages this widget sends itself. Anything else on the wire is not ours. */
|
|
7
|
+
const WIDGET_MESSAGE_TYPES = new Set([
|
|
8
|
+
'cart:change',
|
|
9
|
+
'cart:updated',
|
|
10
|
+
'modal:open',
|
|
11
|
+
'modal:close',
|
|
12
|
+
'order:complete',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sends a widget message.
|
|
17
|
+
*
|
|
18
|
+
* Targets this window's own origin rather than '*'. These payloads carry `cart`, and CheckoutCartDetailDto
|
|
19
|
+
* includes `cartToken` - a bearer credential that authorises reading, modifying and paying for the cart. With
|
|
20
|
+
* '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
|
|
21
|
+
* not necessarily anyone we trust.
|
|
22
|
+
*/
|
|
23
|
+
export function postMessage(data: Record<string, unknown>): void {
|
|
24
|
+
window.parent?.postMessage(JSON.stringify(data), window.location.origin);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Subscribes to widget messages, ignoring anything that did not come from this widget.
|
|
29
|
+
*
|
|
30
|
+
* The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
|
|
31
|
+
* the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
|
|
32
|
+
* chat widget, a tag manager - could reach them with window.parent.postMessage and drive the widget: spoof
|
|
33
|
+
* the total on the payment consent screen, fire a forged order confirmation into the merchant's analytics, or
|
|
34
|
+
* close the modal mid-confirm on a card that has already been charged.
|
|
35
|
+
*
|
|
36
|
+
* Checking `source` is what does the real work: a message from another frame carries that frame's own window,
|
|
37
|
+
* never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
|
|
38
|
+
*/
|
|
39
|
+
export function onWidgetMessage(handler: (data: Record<string, unknown>) => void): () => void {
|
|
40
|
+
function listener(e: MessageEvent) {
|
|
41
|
+
if (e.source !== window || e.origin !== window.location.origin) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let data: Record<string, unknown>;
|
|
46
|
+
try {
|
|
47
|
+
data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Anything without one of our own type values is someone else's traffic sharing this window - a library
|
|
53
|
+
// or the host page talking to itself - not something to hand to a widget handler.
|
|
54
|
+
if (typeof data?.type !== 'string' || !WIDGET_MESSAGE_TYPES.has(data.type)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
handler(data);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
window.addEventListener('message', listener);
|
|
62
|
+
return () => window.removeEventListener('message', listener);
|
|
63
|
+
}
|