@code-collective/booking-widget 1.0.9 → 1.0.12

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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +500 -388
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +2415 -1745
  5. package/dist/booking-widget.min.css +1 -1
  6. package/dist/booking-widget.min.js +39 -23
  7. package/dist/booking-widget.umd.cjs +5 -3
  8. package/package.json +58 -55
  9. package/src/lib/BookingProvider.svelte +21 -0
  10. package/src/lib/CartBar.svelte +26 -41
  11. package/src/lib/CartBarView.svelte +78 -61
  12. package/src/lib/CartExpiryGuard.svelte +15 -9
  13. package/src/lib/CartOverview.svelte +30 -20
  14. package/src/lib/CartOverviewButton.svelte +104 -101
  15. package/src/lib/Checkout.svelte +141 -128
  16. package/src/lib/CheckoutModal.svelte +838 -805
  17. package/src/lib/CheckoutPanel.svelte +121 -0
  18. package/src/lib/PaymentPage.svelte +191 -177
  19. package/src/lib/PickupPointPicker.svelte +1 -1
  20. package/src/lib/TicketConfigurator.svelte +166 -152
  21. package/src/lib/UnitCounter.svelte +16 -2
  22. package/src/lib/WizardPage.svelte +102 -35
  23. package/src/lib/app.css +0 -6
  24. package/src/lib/booking-context.ts +33 -0
  25. package/src/lib/cart-overview.svelte.ts +97 -0
  26. package/src/lib/config.ts +162 -153
  27. package/src/lib/elements/bw-cart.svelte +49 -35
  28. package/src/lib/elements/bw-checkout.svelte +97 -97
  29. package/src/lib/elements/bw-configurator.svelte +72 -56
  30. package/src/lib/elements/register.ts +171 -196
  31. package/src/lib/elements/shared.ts +18 -14
  32. package/src/lib/elements/theme.css +0 -6
  33. package/src/lib/host.svelte.ts +336 -0
  34. package/src/lib/index.ts +242 -196
  35. package/src/lib/layout.svelte.ts +52 -0
  36. package/src/lib/messages.ts +157 -77
  37. package/src/lib/peach-sdk.ts +86 -40
  38. package/src/lib/portal.ts +23 -0
  39. package/src/lib/CartExpiryGuard.test.ts +0 -331
  40. package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
  41. package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
  42. package/src/lib/test/fixtures.ts +0 -107
  43. package/src/lib/test/messages-mock.ts +0 -34
@@ -0,0 +1,97 @@
1
+ // The cart state behind both CartOverview presentations. Extracted because the bar and the button had the
2
+ // same load-then-follow-cart:updated logic written twice, and both answered a failed fetch the same wrong
3
+ // way: `cart = null`, rendering exactly what an empty cart renders - nothing at all. On a narrow layout the
4
+ // bar is the only route to checkout, so "no DOM" has to mean something a consumer can act on.
5
+
6
+ import type { BookingApi } from './api';
7
+ import type { CartManager } from './cart-manager';
8
+ import type { CartOverviewState } from './config';
9
+ import type { CheckoutCartDetailDto } from './client-types';
10
+ import { onWidgetMessage } from './messages';
11
+
12
+ export interface CartOverview {
13
+ readonly cart: CheckoutCartDetailDto | null;
14
+ readonly state: CartOverviewState;
15
+ /** Re-reads the cart. What the error presentation's retry is wired to. */
16
+ readonly reload: () => void;
17
+ }
18
+
19
+ /**
20
+ * Call during component initialisation - it registers an $effect that follows cart:updated for the
21
+ * component's lifetime.
22
+ */
23
+ export function createCartOverview(
24
+ // Accessors rather than values: these arrive as props, and reading them through a closure keeps this
25
+ // following the current ones rather than pinning whichever pair happened to be passed on first render.
26
+ getApi: () => BookingApi,
27
+ getCartManager: () => CartManager,
28
+ onStateChange?: (state: CartOverviewState) => void,
29
+ ): CartOverview {
30
+ let cart = $state<CheckoutCartDetailDto | null>(null);
31
+ let failed = $state(false);
32
+ let isLoading = $state(true);
33
+ // Only the newest load may write. A read still in flight when a fresher cart arrives - from a retry, or
34
+ // from cart:updated after an add elsewhere on the page - would otherwise land afterwards and overwrite it,
35
+ // and a transient failure landing late would replace a perfectly good cart summary with the error view.
36
+ let currentLoad = 0;
37
+
38
+ async function load() {
39
+ const thisLoad = ++currentLoad;
40
+ isLoading = true;
41
+ failed = false;
42
+ // No token held means there is genuinely nothing to fetch - an empty cart, not a failure.
43
+ if (!getCartManager().hasCart) {
44
+ cart = null;
45
+ isLoading = false;
46
+ return;
47
+ }
48
+ try {
49
+ const loaded = await getApi().getCart();
50
+ if (thisLoad !== currentLoad) return;
51
+ cart = loaded;
52
+ } catch {
53
+ if (thisLoad !== currentLoad) return;
54
+ cart = null;
55
+ failed = true;
56
+ }
57
+ isLoading = false;
58
+ }
59
+
60
+ void load();
61
+
62
+ // load() only runs once - without this, adding/editing/removing an item elsewhere on the page (the
63
+ // configurator, or the checkout modal's own edit/remove) leaves this showing a stale total. The event
64
+ // already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so it is
65
+ // applied directly rather than triggering a second, redundant getCart() call.
66
+ $effect(() =>
67
+ onWidgetMessage((d) => {
68
+ if (d.type === 'cart:updated') {
69
+ // Supersedes any read still in flight - this cart is newer than whatever it will return.
70
+ currentLoad += 1;
71
+ cart = d.cart;
72
+ // A cart arriving by message is a cart successfully read somewhere else on the page, so whatever
73
+ // this one's own fetch ran into is no longer the current answer.
74
+ failed = false;
75
+ isLoading = false;
76
+ }
77
+ }),
78
+ );
79
+
80
+ const state = $derived<CartOverviewState>(
81
+ isLoading ? 'loading' : failed ? 'error' : (cart?.items?.length ?? 0) > 0 ? 'ready' : 'empty',
82
+ );
83
+
84
+ $effect(() => {
85
+ onStateChange?.(state);
86
+ });
87
+
88
+ return {
89
+ get cart() {
90
+ return cart;
91
+ },
92
+ get state() {
93
+ return state;
94
+ },
95
+ reload: () => void load(),
96
+ };
97
+ }
package/src/lib/config.ts CHANGED
@@ -1,153 +1,162 @@
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
-
8
- export type WidgetMode = 'configurator' | 'checkout' | 'cart-overview';
9
-
10
- export type CartOverviewDisplay = 'bar' | 'button';
11
-
12
- export type WizardWidgetType = 'option' | 'age-category' | 'date' | 'time' | 'pickup' | 'addon';
13
-
14
- export interface WizardPageConfig {
15
- title: string;
16
- widgets: WizardWidgetType[];
17
- }
18
-
19
- export type WizardPages = WizardPageConfig[];
20
-
21
- export const DEFAULT_WIZARD_PAGES: WizardPages = [
22
- { title: 'Option', widgets: ['option', 'age-category'] },
23
- { title: 'Schedule', widgets: ['date', 'time'] },
24
- { title: 'Pickup', widgets: ['pickup'] },
25
- ];
26
-
27
- const WIZARD_PRESETS: Record<string, WizardPages> = {
28
- 'option-first': DEFAULT_WIZARD_PAGES,
29
- 'date-first': [
30
- { title: 'Age & capacity', widgets: ['age-category'] },
31
- { title: 'Schedule', widgets: ['date', 'time'] },
32
- { title: 'Option', widgets: ['option'] },
33
- { title: 'Pickup', widgets: ['pickup'] },
34
- ],
35
- };
36
-
37
- // Accepts a preset name ("option-first", "date-first") or a JSON array of { title, widgets } objects.
38
- export function resolveWizardPages(value: string | null | undefined): WizardPages {
39
- if (!value) return DEFAULT_WIZARD_PAGES;
40
- if (value in WIZARD_PRESETS) return WIZARD_PRESETS[value];
41
- try {
42
- const parsed = JSON.parse(value);
43
- if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].widgets) return parsed;
44
- } catch { /* use default */ }
45
- return DEFAULT_WIZARD_PAGES;
46
- }
47
-
48
- // Grouping for the checkout edit-item accordion - deliberately different from the step wizard's own page
49
- // breakdown, since a step-by-step flow and a "review everything at once" accordion don't need the same
50
- // shape. Age & capacity, date and time always live together in one section here, regardless of which
51
- // preset is in play - only where that section falls (first vs. second) changes with option-first vs.
52
- // date-first.
53
- const EDIT_PRESETS: Record<string, WizardPages> = {
54
- 'option-first': [
55
- { title: 'Option', widgets: ['option'] },
56
- { title: 'Age, Date & Time', widgets: ['age-category', 'date', 'time'] },
57
- { title: 'Pickup', widgets: ['pickup'] },
58
- ],
59
- 'date-first': [
60
- { title: 'Age, Date & Time', widgets: ['age-category', 'date', 'time'] },
61
- { title: 'Option', widgets: ['option'] },
62
- { title: 'Pickup', widgets: ['pickup'] },
63
- ],
64
- };
65
-
66
- // Resolves the grouping for the checkout edit-item accordion. If editValue is set, it's resolved the same
67
- // way as wizardPages (a preset name or a custom JSON array). Otherwise, defaults to the grouped layout for
68
- // whichever named wizard preset wizardValue resolves to (including the "no value given" case, which is
69
- // option-first) - but if wizardValue is itself a custom, unnamed page layout, there's no sensible grouping
70
- // to infer, so editPages just mirrors it directly.
71
- export function resolveEditPages(wizardValue: string | null | undefined, editValue: string | null | undefined): WizardPages {
72
- if (editValue) {
73
- if (editValue in EDIT_PRESETS) return EDIT_PRESETS[editValue];
74
- try {
75
- const parsed = JSON.parse(editValue);
76
- if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].widgets) return parsed;
77
- } catch { /* fall through to the wizardPages-derived default */ }
78
- }
79
-
80
- const wizardKey = wizardValue ? (wizardValue in WIZARD_PRESETS ? wizardValue : null) : 'option-first';
81
- if (wizardKey) return EDIT_PRESETS[wizardKey];
82
-
83
- return resolveWizardPages(wizardValue);
84
- }
85
-
86
- export interface AppConfig {
87
- apiBaseUrl: string;
88
- productId: string;
89
- checkoutKey: string;
90
- currency: string;
91
- mode: WidgetMode;
92
- cartDisplay: CartOverviewDisplay;
93
- orderId: string;
94
- wizardPages: WizardPages;
95
- editPages: WizardPages;
96
- autoSelectSingleTimeSlot: boolean;
97
- name: string;
98
- cancelable: boolean;
99
- }
100
-
101
- export function readConfig(): AppConfig {
102
- const params = new URLSearchParams(window.location.search);
103
-
104
- if (params.has('product') || params.has('mode')) {
105
- const productId = params.get('product') ?? '';
106
- return {
107
- apiBaseUrl: params.get('apiBaseUrl') ?? DEFAULT_QA_API_BASE_URL,
108
- productId,
109
- checkoutKey: params.get('checkoutKey') ?? deriveCheckoutKey(productId),
110
- currency: params.get('currency') ?? 'ZAR',
111
- mode: parseMode(params.get('mode')),
112
- cartDisplay: parseCartDisplay(params.get('cartDisplay')),
113
- orderId: params.get('orderId') ?? '',
114
- wizardPages: resolveWizardPages(params.get('wizard')),
115
- editPages: resolveEditPages(params.get('wizard'), params.get('edit')),
116
- autoSelectSingleTimeSlot: params.get('autoSelectSingleTimeSlot') === 'true',
117
- name: params.get('name') ?? '',
118
- cancelable: params.get('cancelable') === 'true',
119
- };
120
- }
121
-
122
- return {
123
- apiBaseUrl: DEFAULT_QA_API_BASE_URL,
124
- productId: DEFAULT_QA_PRODUCT_ID,
125
- checkoutKey: DEFAULT_QA_CHECKOUT_KEY,
126
- currency: 'ZAR',
127
- mode: 'configurator',
128
- cartDisplay: 'bar',
129
- orderId: '',
130
- wizardPages: DEFAULT_WIZARD_PAGES,
131
- editPages: EDIT_PRESETS['option-first'],
132
- autoSelectSingleTimeSlot: false,
133
- name: '',
134
- cancelable: false,
135
- };
136
- }
137
-
138
- function parseMode(value: string | null): WidgetMode {
139
- if (value === 'checkout' || value === 'cart-overview') return value;
140
- return 'configurator';
141
- }
142
-
143
- function parseCartDisplay(value: string | null): CartOverviewDisplay {
144
- if (value === 'button') return 'button';
145
- return 'bar';
146
- }
147
-
148
- // Demo only products whose id starts with a-j use "morii", the rest use "csbs".
149
- // This will be replaced with a real checkout key lookup.
150
- function deriveCheckoutKey(productId: string): string {
151
- const first = productId[0]?.toLowerCase() ?? '';
152
- return first >= 'a' && first <= 'j' ? 'morii' : 'csbs';
153
- }
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
+
8
+ export type WidgetMode = 'configurator' | 'checkout' | 'cart-overview';
9
+
10
+ export type CartOverviewDisplay = 'bar' | 'button';
11
+
12
+ // How <Checkout> presents itself: in the page where it is placed, or as an overlay portalled to <body>.
13
+ export type CheckoutMode = 'inline' | 'modal';
14
+
15
+ // What <CartOverview> and its two presentations currently have to show. Distinguishing 'empty' from 'error'
16
+ // is the whole point: a bar that renders nothing for both leaves a consumer unable to tell a cart with no
17
+ // items from a fetch that failed, which - as the only route to checkout on a narrow layout - is the one
18
+ // place that cannot be undebuggable.
19
+ export type CartOverviewState = 'loading' | 'empty' | 'ready' | 'error';
20
+
21
+ export type WizardWidgetType = 'option' | 'age-category' | 'date' | 'time' | 'pickup' | 'addon';
22
+
23
+ export interface WizardPageConfig {
24
+ title: string;
25
+ widgets: WizardWidgetType[];
26
+ }
27
+
28
+ export type WizardPages = WizardPageConfig[];
29
+
30
+ export const DEFAULT_WIZARD_PAGES: WizardPages = [
31
+ { title: 'Option', widgets: ['option', 'age-category'] },
32
+ { title: 'Schedule', widgets: ['date', 'time'] },
33
+ { title: 'Pickup', widgets: ['pickup'] },
34
+ ];
35
+
36
+ const WIZARD_PRESETS: Record<string, WizardPages> = {
37
+ 'option-first': DEFAULT_WIZARD_PAGES,
38
+ 'date-first': [
39
+ { title: 'Age & capacity', widgets: ['age-category'] },
40
+ { title: 'Schedule', widgets: ['date', 'time'] },
41
+ { title: 'Option', widgets: ['option'] },
42
+ { title: 'Pickup', widgets: ['pickup'] },
43
+ ],
44
+ };
45
+
46
+ // Accepts a preset name ("option-first", "date-first") or a JSON array of { title, widgets } objects.
47
+ export function resolveWizardPages(value: string | null | undefined): WizardPages {
48
+ if (!value) return DEFAULT_WIZARD_PAGES;
49
+ if (value in WIZARD_PRESETS) return WIZARD_PRESETS[value];
50
+ try {
51
+ const parsed = JSON.parse(value);
52
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].widgets) return parsed;
53
+ } catch { /* use default */ }
54
+ return DEFAULT_WIZARD_PAGES;
55
+ }
56
+
57
+ // Grouping for the checkout edit-item accordion - deliberately different from the step wizard's own page
58
+ // breakdown, since a step-by-step flow and a "review everything at once" accordion don't need the same
59
+ // shape. Age & capacity, date and time always live together in one section here, regardless of which
60
+ // preset is in play - only where that section falls (first vs. second) changes with option-first vs.
61
+ // date-first.
62
+ const EDIT_PRESETS: Record<string, WizardPages> = {
63
+ 'option-first': [
64
+ { title: 'Option', widgets: ['option'] },
65
+ { title: 'Age, Date & Time', widgets: ['age-category', 'date', 'time'] },
66
+ { title: 'Pickup', widgets: ['pickup'] },
67
+ ],
68
+ 'date-first': [
69
+ { title: 'Age, Date & Time', widgets: ['age-category', 'date', 'time'] },
70
+ { title: 'Option', widgets: ['option'] },
71
+ { title: 'Pickup', widgets: ['pickup'] },
72
+ ],
73
+ };
74
+
75
+ // Resolves the grouping for the checkout edit-item accordion. If editValue is set, it's resolved the same
76
+ // way as wizardPages (a preset name or a custom JSON array). Otherwise, defaults to the grouped layout for
77
+ // whichever named wizard preset wizardValue resolves to (including the "no value given" case, which is
78
+ // option-first) - but if wizardValue is itself a custom, unnamed page layout, there's no sensible grouping
79
+ // to infer, so editPages just mirrors it directly.
80
+ export function resolveEditPages(wizardValue: string | null | undefined, editValue: string | null | undefined): WizardPages {
81
+ if (editValue) {
82
+ if (editValue in EDIT_PRESETS) return EDIT_PRESETS[editValue];
83
+ try {
84
+ const parsed = JSON.parse(editValue);
85
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].widgets) return parsed;
86
+ } catch { /* fall through to the wizardPages-derived default */ }
87
+ }
88
+
89
+ const wizardKey = wizardValue ? (wizardValue in WIZARD_PRESETS ? wizardValue : null) : 'option-first';
90
+ if (wizardKey) return EDIT_PRESETS[wizardKey];
91
+
92
+ return resolveWizardPages(wizardValue);
93
+ }
94
+
95
+ export interface AppConfig {
96
+ apiBaseUrl: string;
97
+ productId: string;
98
+ checkoutKey: string;
99
+ currency: string;
100
+ mode: WidgetMode;
101
+ cartDisplay: CartOverviewDisplay;
102
+ orderId: string;
103
+ wizardPages: WizardPages;
104
+ editPages: WizardPages;
105
+ autoSelectSingleTimeSlot: boolean;
106
+ name: string;
107
+ cancelable: boolean;
108
+ }
109
+
110
+ export function readConfig(): AppConfig {
111
+ const params = new URLSearchParams(window.location.search);
112
+
113
+ if (params.has('product') || params.has('mode')) {
114
+ const productId = params.get('product') ?? '';
115
+ return {
116
+ apiBaseUrl: params.get('apiBaseUrl') ?? DEFAULT_QA_API_BASE_URL,
117
+ productId,
118
+ checkoutKey: params.get('checkoutKey') ?? deriveCheckoutKey(productId),
119
+ currency: params.get('currency') ?? 'ZAR',
120
+ mode: parseMode(params.get('mode')),
121
+ cartDisplay: parseCartDisplay(params.get('cartDisplay')),
122
+ orderId: params.get('orderId') ?? '',
123
+ wizardPages: resolveWizardPages(params.get('wizard')),
124
+ editPages: resolveEditPages(params.get('wizard'), params.get('edit')),
125
+ autoSelectSingleTimeSlot: params.get('autoSelectSingleTimeSlot') === 'true',
126
+ name: params.get('name') ?? '',
127
+ cancelable: params.get('cancelable') === 'true',
128
+ };
129
+ }
130
+
131
+ return {
132
+ apiBaseUrl: DEFAULT_QA_API_BASE_URL,
133
+ productId: DEFAULT_QA_PRODUCT_ID,
134
+ checkoutKey: DEFAULT_QA_CHECKOUT_KEY,
135
+ currency: 'ZAR',
136
+ mode: 'configurator',
137
+ cartDisplay: 'bar',
138
+ orderId: '',
139
+ wizardPages: DEFAULT_WIZARD_PAGES,
140
+ editPages: EDIT_PRESETS['option-first'],
141
+ autoSelectSingleTimeSlot: false,
142
+ name: '',
143
+ cancelable: false,
144
+ };
145
+ }
146
+
147
+ function parseMode(value: string | null): WidgetMode {
148
+ if (value === 'checkout' || value === 'cart-overview') return value;
149
+ return 'configurator';
150
+ }
151
+
152
+ function parseCartDisplay(value: string | null): CartOverviewDisplay {
153
+ if (value === 'button') return 'button';
154
+ return 'bar';
155
+ }
156
+
157
+ // Demo only — products whose id starts with a-j use "morii", the rest use "csbs".
158
+ // This will be replaced with a real checkout key lookup.
159
+ function deriveCheckoutKey(productId: string): string {
160
+ const first = productId[0]?.toLowerCase() ?? '';
161
+ return first >= 'a' && first <= 'j' ? 'morii' : 'csbs';
162
+ }
@@ -1,35 +1,49 @@
1
- <svelte:options customElement={{ tag: 'bw-cart', shadow: 'none' }} />
2
-
3
- <script lang="ts">
4
- import CartOverview from '../CartOverview.svelte';
5
- import type { CartOverviewDisplay } from '../config';
6
- import { onWidgetMessage } from '../messages';
7
- import { getSharedServices } from './shared';
8
-
9
- let {
10
- display = 'bar' as CartOverviewDisplay,
11
- } = $props();
12
-
13
- const { api, cartManager, ready: readyPromise } = getSharedServices();
14
-
15
- let ready = $state(false);
16
- readyPromise.then(() => { ready = true; });
17
-
18
- function dispatch(name: string, detail: unknown) {
19
- const host = (document.querySelector('bw-cart') as HTMLElement) ?? document.body;
20
- host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
21
- }
22
-
23
- $effect(() => onWidgetMessage((d) => {
24
- if (d.type === 'modal:open') {
25
- dispatch('bw:checkout', {});
26
- }
27
- }));
28
- </script>
29
-
30
- {#if ready}
31
- <CartOverview {api} {cartManager} {display} />
32
- {:else}
33
- <div class="loading-center"><div class="spinner"></div></div>
34
- {/if}
35
-
1
+ <svelte:options customElement={{ tag: 'bw-cart', shadow: 'none' }} />
2
+
3
+ <script lang="ts">
4
+ import CartOverview from '../CartOverview.svelte';
5
+ import type { CartOverviewDisplay } from '../config';
6
+ import { onWidgetMessage } from '../messages';
7
+ import { getSharedServices } from './shared';
8
+
9
+ let {
10
+ display = 'bar' as CartOverviewDisplay,
11
+ } = $props();
12
+
13
+ const { api, cartManager, ready: readyPromise } = getSharedServices();
14
+
15
+ let ready = $state(false);
16
+ readyPromise.then(() => { ready = true; });
17
+
18
+ // Each instance dispatches on its own element, found by walking up from a node it rendered. It used to
19
+ // use document.querySelector('bw-cart'), which is the *first* one on the page - so with two of these
20
+ // elements every event was dispatched twice on that first element (and the second element never received
21
+ // its own at all). The shipped demo has two <bw-cart>, so that was not hypothetical.
22
+ //
23
+ // Walking up rather than $host(): these wrappers are compiled both with and without customElement (the
24
+ // elements build and the test/ES build), and $host() only exists in the former. With no custom element
25
+ // above it - which is how the tests mount these - closest() finds nothing and body carries the event,
26
+ // which still reaches window.
27
+ let anchor = $state<HTMLElement | null>(null);
28
+
29
+ function dispatch(name: string, detail: unknown) {
30
+ const target = anchor?.closest('bw-cart') ?? document.body;
31
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
32
+ }
33
+
34
+ $effect(() => onWidgetMessage((d) => {
35
+ if (d.type === 'modal:open') {
36
+ dispatch('bw:checkout', {});
37
+ }
38
+ }));
39
+ </script>
40
+
41
+ <!-- display: contents so this is only an anchor for dispatch(), never a box in the layout. -->
42
+ <div style="display: contents" bind:this={anchor}>
43
+ {#if ready}
44
+ <CartOverview {api} {cartManager} {display} />
45
+ {:else}
46
+ <div class="loading-center"><div class="spinner"></div></div>
47
+ {/if}
48
+ </div>
49
+