@code-collective/booking-widget 1.0.12 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -46
- package/README.md +500 -500
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +829 -707
- package/dist/booking-widget.min.js +24 -25
- package/dist/booking-widget.umd.cjs +5 -5
- package/package.json +58 -58
- package/src/lib/BookingProvider.svelte +21 -21
- package/src/lib/CartBar.svelte +26 -26
- package/src/lib/CartBarView.svelte +78 -78
- package/src/lib/CartExpiryGuard.svelte +416 -416
- package/src/lib/CartOverview.svelte +30 -30
- package/src/lib/CartOverviewButton.svelte +104 -104
- package/src/lib/Checkout.svelte +141 -141
- package/src/lib/CheckoutModal.css +179 -0
- package/src/lib/CheckoutModal.svelte +78 -573
- package/src/lib/CheckoutPanel.svelte +121 -121
- package/src/lib/PaymentPage.svelte +208 -191
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +166 -166
- package/src/lib/api.ts +36 -5
- package/src/lib/booking-context.ts +33 -33
- package/src/lib/cart-overview.svelte.ts +97 -97
- package/src/lib/checkout-item-view.ts +63 -0
- package/src/lib/checkout-payment-flow.svelte.ts +523 -0
- package/src/lib/client-types.ts +22 -6
- package/src/lib/config.ts +162 -162
- package/src/lib/elements/bw-cart.svelte +49 -49
- package/src/lib/elements/bw-checkout.svelte +97 -97
- package/src/lib/elements/bw-configurator.svelte +72 -72
- package/src/lib/elements/register.ts +171 -171
- package/src/lib/elements/shared.ts +18 -18
- package/src/lib/generated-types.ts +94 -5
- package/src/lib/host.svelte.ts +336 -336
- package/src/lib/index.ts +242 -242
- package/src/lib/messages.ts +157 -157
- package/src/lib/peach-sdk.ts +86 -86
- package/src/lib/portal.ts +23 -23
package/src/lib/config.ts
CHANGED
|
@@ -1,162 +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
|
-
// 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
|
+
// 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,49 +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
|
-
// 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
|
-
|
|
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
|
+
|
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
<svelte:options customElement={{ tag: 'bw-checkout', shadow: 'none' }} />
|
|
2
|
-
|
|
3
|
-
<script lang="ts">
|
|
4
|
-
// Presentation-free: the overlay, the scrim, the click-outside close and the portal all live in
|
|
5
|
-
// Checkout.svelte now, so the ES build renders the same modal from the same code. What is left here is the
|
|
6
|
-
// custom element's own surface - attributes in, bw:* events out.
|
|
7
|
-
import Checkout from '../Checkout.svelte';
|
|
8
|
-
import { resolveWizardPages, resolveEditPages } from '../config';
|
|
9
|
-
import { onWidgetMessage } from '../messages';
|
|
10
|
-
import { getSharedServices } from './shared';
|
|
11
|
-
|
|
12
|
-
let {
|
|
13
|
-
'wizard-pages': wizardPagesJson = '',
|
|
14
|
-
'edit-pages': editPagesJson = '',
|
|
15
|
-
'is-modal': isModalAttr = undefined,
|
|
16
|
-
'auto-select-single-time-slot': autoSelectSingleTimeSlotAttr = undefined,
|
|
17
|
-
} = $props();
|
|
18
|
-
|
|
19
|
-
let autoSelectSingleTimeSlot = $derived(autoSelectSingleTimeSlotAttr !== undefined);
|
|
20
|
-
|
|
21
|
-
let mode = $derived(isModalAttr !== undefined ? 'modal' as const : 'inline' as const);
|
|
22
|
-
|
|
23
|
-
const { api, cartManager, ready: readyPromise, host } = getSharedServices();
|
|
24
|
-
|
|
25
|
-
let ready = $state(false);
|
|
26
|
-
readyPromise.then(() => { ready = true; });
|
|
27
|
-
|
|
28
|
-
let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
|
|
29
|
-
let editPages = $derived(resolveEditPages(wizardPagesJson, editPagesJson));
|
|
30
|
-
|
|
31
|
-
// Each instance dispatches on its own element, found by walking up from a node it rendered. It used to
|
|
32
|
-
// use document.querySelector('bw-checkout'), which is the *first* one on the page - so with two of these
|
|
33
|
-
// elements every event was dispatched twice on that first element (and the second element never received
|
|
34
|
-
// its own at all). The shipped demo has two <bw-cart>, so that was not hypothetical.
|
|
35
|
-
//
|
|
36
|
-
// Walking up rather than $host(): these wrappers are compiled both with and without customElement (the
|
|
37
|
-
// elements build and the test/ES build), and $host() only exists in the former. With no custom element
|
|
38
|
-
// above it - which is how the tests mount these - closest() finds nothing and body carries the event,
|
|
39
|
-
// which still reaches window.
|
|
40
|
-
let anchor = $state<HTMLElement | null>(null);
|
|
41
|
-
|
|
42
|
-
function dispatch(name: string, detail: unknown) {
|
|
43
|
-
const target = anchor?.closest('bw-checkout') ?? document.body;
|
|
44
|
-
target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// The element's public methods - Svelte exposes these on the custom element itself, so a merchant can
|
|
48
|
-
// drive checkout from their own UI: document.querySelector('bw-checkout').open(). Both go through the
|
|
49
|
-
// host, which is what bw-cart's button and an add-to-cart already reach, so opening this way hides the
|
|
50
|
-
// bar carts and fires bw:modal-open like any other route in. They used to set this element's own
|
|
51
|
-
// showModal instead, which skipped all of that.
|
|
52
|
-
export function open() { host.openCheckout(); }
|
|
53
|
-
export function close() { host.closeCheckout(); }
|
|
54
|
-
|
|
55
|
-
// One dismissal produces two modal:close messages - Checkout's own scrim/Escape, and the panel posting
|
|
56
|
-
// again as it is torn down - so dispatching per message fired bw:close twice for one close. A plain
|
|
57
|
-
// variable rather than $state, for the same reason Checkout.svelte's own guard is: the second message
|
|
58
|
-
// arrives while Svelte is still flushing the teardown, where a $state write is not yet readable.
|
|
59
|
-
let closeReported = false;
|
|
60
|
-
let wasOpen = false;
|
|
61
|
-
$effect(() => {
|
|
62
|
-
const isOpen = host.isCheckoutOpen;
|
|
63
|
-
if (isOpen && !wasOpen) closeReported = false;
|
|
64
|
-
wasOpen = isOpen;
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
$effect(() => onWidgetMessage((d) => {
|
|
68
|
-
// Only a close this element was open for. modal:close is page-wide - the expiry guard's "start again"
|
|
69
|
-
// and a cancelable configurator both post it with no checkout showing - and reporting those burned the
|
|
70
|
-
// guard, so the shopper's real dismissal afterwards said nothing at all.
|
|
71
|
-
if (d.type === 'modal:close' && !closeReported && wasOpen) {
|
|
72
|
-
closeReported = true;
|
|
73
|
-
dispatch('bw:close', {});
|
|
74
|
-
}
|
|
75
|
-
if (d.type === 'order:complete') {
|
|
76
|
-
// Deliberately does not close the modal - order:complete only reports that the order is confirmed
|
|
77
|
-
// (for host analytics/cart-clearing), not that the shopper has finished looking at the result screen.
|
|
78
|
-
// The modal stays open until they dismiss it themselves, which sends modal:close separately.
|
|
79
|
-
dispatch('bw:order-confirmed', {
|
|
80
|
-
cartToken: d.cartToken,
|
|
81
|
-
value: d.value,
|
|
82
|
-
currency: d.currency,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}));
|
|
86
|
-
</script>
|
|
87
|
-
|
|
88
|
-
<!-- Always rendered, even in modal mode with nothing on screen: dispatch() needs an anchor before the
|
|
89
|
-
session is ready, and Checkout portals its overlay out of here anyway. -->
|
|
90
|
-
<div style="display: contents" bind:this={anchor}>
|
|
91
|
-
{#if ready}
|
|
92
|
-
<Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot}
|
|
93
|
-
{mode} open={host.isCheckoutOpen} onClose={() => host.closeCheckout()} />
|
|
94
|
-
{:else if mode === 'inline'}
|
|
95
|
-
<div class="loading-center"><div class="spinner"></div></div>
|
|
96
|
-
{/if}
|
|
97
|
-
</div>
|
|
1
|
+
<svelte:options customElement={{ tag: 'bw-checkout', shadow: 'none' }} />
|
|
2
|
+
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
// Presentation-free: the overlay, the scrim, the click-outside close and the portal all live in
|
|
5
|
+
// Checkout.svelte now, so the ES build renders the same modal from the same code. What is left here is the
|
|
6
|
+
// custom element's own surface - attributes in, bw:* events out.
|
|
7
|
+
import Checkout from '../Checkout.svelte';
|
|
8
|
+
import { resolveWizardPages, resolveEditPages } from '../config';
|
|
9
|
+
import { onWidgetMessage } from '../messages';
|
|
10
|
+
import { getSharedServices } from './shared';
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
'wizard-pages': wizardPagesJson = '',
|
|
14
|
+
'edit-pages': editPagesJson = '',
|
|
15
|
+
'is-modal': isModalAttr = undefined,
|
|
16
|
+
'auto-select-single-time-slot': autoSelectSingleTimeSlotAttr = undefined,
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
let autoSelectSingleTimeSlot = $derived(autoSelectSingleTimeSlotAttr !== undefined);
|
|
20
|
+
|
|
21
|
+
let mode = $derived(isModalAttr !== undefined ? 'modal' as const : 'inline' as const);
|
|
22
|
+
|
|
23
|
+
const { api, cartManager, ready: readyPromise, host } = getSharedServices();
|
|
24
|
+
|
|
25
|
+
let ready = $state(false);
|
|
26
|
+
readyPromise.then(() => { ready = true; });
|
|
27
|
+
|
|
28
|
+
let wizardPages = $derived(resolveWizardPages(wizardPagesJson));
|
|
29
|
+
let editPages = $derived(resolveEditPages(wizardPagesJson, editPagesJson));
|
|
30
|
+
|
|
31
|
+
// Each instance dispatches on its own element, found by walking up from a node it rendered. It used to
|
|
32
|
+
// use document.querySelector('bw-checkout'), which is the *first* one on the page - so with two of these
|
|
33
|
+
// elements every event was dispatched twice on that first element (and the second element never received
|
|
34
|
+
// its own at all). The shipped demo has two <bw-cart>, so that was not hypothetical.
|
|
35
|
+
//
|
|
36
|
+
// Walking up rather than $host(): these wrappers are compiled both with and without customElement (the
|
|
37
|
+
// elements build and the test/ES build), and $host() only exists in the former. With no custom element
|
|
38
|
+
// above it - which is how the tests mount these - closest() finds nothing and body carries the event,
|
|
39
|
+
// which still reaches window.
|
|
40
|
+
let anchor = $state<HTMLElement | null>(null);
|
|
41
|
+
|
|
42
|
+
function dispatch(name: string, detail: unknown) {
|
|
43
|
+
const target = anchor?.closest('bw-checkout') ?? document.body;
|
|
44
|
+
target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The element's public methods - Svelte exposes these on the custom element itself, so a merchant can
|
|
48
|
+
// drive checkout from their own UI: document.querySelector('bw-checkout').open(). Both go through the
|
|
49
|
+
// host, which is what bw-cart's button and an add-to-cart already reach, so opening this way hides the
|
|
50
|
+
// bar carts and fires bw:modal-open like any other route in. They used to set this element's own
|
|
51
|
+
// showModal instead, which skipped all of that.
|
|
52
|
+
export function open() { host.openCheckout(); }
|
|
53
|
+
export function close() { host.closeCheckout(); }
|
|
54
|
+
|
|
55
|
+
// One dismissal produces two modal:close messages - Checkout's own scrim/Escape, and the panel posting
|
|
56
|
+
// again as it is torn down - so dispatching per message fired bw:close twice for one close. A plain
|
|
57
|
+
// variable rather than $state, for the same reason Checkout.svelte's own guard is: the second message
|
|
58
|
+
// arrives while Svelte is still flushing the teardown, where a $state write is not yet readable.
|
|
59
|
+
let closeReported = false;
|
|
60
|
+
let wasOpen = false;
|
|
61
|
+
$effect(() => {
|
|
62
|
+
const isOpen = host.isCheckoutOpen;
|
|
63
|
+
if (isOpen && !wasOpen) closeReported = false;
|
|
64
|
+
wasOpen = isOpen;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
$effect(() => onWidgetMessage((d) => {
|
|
68
|
+
// Only a close this element was open for. modal:close is page-wide - the expiry guard's "start again"
|
|
69
|
+
// and a cancelable configurator both post it with no checkout showing - and reporting those burned the
|
|
70
|
+
// guard, so the shopper's real dismissal afterwards said nothing at all.
|
|
71
|
+
if (d.type === 'modal:close' && !closeReported && wasOpen) {
|
|
72
|
+
closeReported = true;
|
|
73
|
+
dispatch('bw:close', {});
|
|
74
|
+
}
|
|
75
|
+
if (d.type === 'order:complete') {
|
|
76
|
+
// Deliberately does not close the modal - order:complete only reports that the order is confirmed
|
|
77
|
+
// (for host analytics/cart-clearing), not that the shopper has finished looking at the result screen.
|
|
78
|
+
// The modal stays open until they dismiss it themselves, which sends modal:close separately.
|
|
79
|
+
dispatch('bw:order-confirmed', {
|
|
80
|
+
cartToken: d.cartToken,
|
|
81
|
+
value: d.value,
|
|
82
|
+
currency: d.currency,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<!-- Always rendered, even in modal mode with nothing on screen: dispatch() needs an anchor before the
|
|
89
|
+
session is ready, and Checkout portals its overlay out of here anyway. -->
|
|
90
|
+
<div style="display: contents" bind:this={anchor}>
|
|
91
|
+
{#if ready}
|
|
92
|
+
<Checkout {api} {cartManager} {wizardPages} {editPages} {autoSelectSingleTimeSlot}
|
|
93
|
+
{mode} open={host.isCheckoutOpen} onClose={() => host.closeCheckout()} />
|
|
94
|
+
{:else if mode === 'inline'}
|
|
95
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|