@ticketlayer/elements 0.4.0 → 1.0.0
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/dist/cjs/{index-I9CoYv3k.js → index-DanPUX0s.js} +1 -1
- package/dist/cjs/index.cjs.js +2 -1
- package/dist/cjs/loader.cjs.js +2 -2
- package/dist/cjs/ticket-selector-BYVL13k_.js +3623 -0
- package/dist/cjs/ticketlayer-elements.cjs.js +2 -2
- package/dist/cjs/tl-buy-modal.cjs.entry.js +3 -3
- package/dist/cjs/tl-buy-tickets-button.cjs.entry.js +89 -74
- package/dist/cjs/tl-buy-tickets-wrapper.cjs.entry.js +61 -30
- package/dist/cjs/tl-cart-badge.cjs.entry.js +74 -61
- package/dist/cjs/tl-cart-drawer.cjs.entry.js +51 -23
- package/dist/cjs/tl-cart.cjs.entry.js +71 -103
- package/dist/cjs/tl-checkout-wrapper.cjs.entry.js +51 -29
- package/dist/cjs/tl-event-card.cjs.entry.js +43 -105
- package/dist/cjs/tl-event-list.cjs.entry.js +72 -35
- package/dist/cjs/tl-login.cjs.entry.js +74 -141
- package/dist/cjs/tl-my-orders.cjs.entry.js +52 -92
- package/dist/cjs/tl-occurrence-selector_2.cjs.entry.js +153 -921
- package/dist/cjs/tl-order-confirmation.cjs.entry.js +71 -107
- package/dist/cjs/tl-order-tickets_2.cjs.entry.js +48 -74
- package/dist/cjs/tl-promo-code.cjs.entry.js +50 -47
- package/dist/cjs/tl-seat-map.cjs.entry.js +3 -3
- package/dist/cjs/web-query-Ce73jB4k.js +16 -0
- package/dist/collection/components/tl-buy-modal/tl-buy-modal.js +2 -2
- package/dist/collection/components/tl-buy-tickets-button/tl-buy-tickets-button.css +37 -0
- package/dist/collection/components/tl-buy-tickets-button/tl-buy-tickets-button.js +105 -79
- package/dist/collection/components/tl-buy-tickets-wrapper/tl-buy-tickets-wrapper.js +73 -30
- package/dist/collection/components/tl-cart/tl-cart.js +82 -107
- package/dist/collection/components/tl-cart-badge/tl-cart-badge.js +62 -62
- package/dist/collection/components/tl-cart-drawer/tl-cart-drawer.js +56 -24
- package/dist/collection/components/tl-checkout-wrapper/tl-checkout-wrapper.js +61 -29
- package/dist/collection/components/tl-event-card/event-card-adapter.js +11 -23
- package/dist/collection/components/tl-event-card/tl-event-card.js +51 -84
- package/dist/collection/components/tl-event-list/tl-event-list.js +82 -38
- package/dist/collection/components/tl-login/tl-login.js +83 -147
- package/dist/collection/components/tl-my-orders/tl-my-orders.js +58 -96
- package/dist/collection/components/tl-occurrence-selector/tl-occurrence-selector.js +137 -516
- package/dist/collection/components/tl-order-confirmation/tl-order-confirmation.js +81 -115
- package/dist/collection/components/tl-order-tickets/tl-order-tickets.js +57 -92
- package/dist/collection/components/tl-promo-code/tl-promo-code.js +55 -50
- package/dist/collection/components/tl-seat-map/tl-seat-map.js +2 -2
- package/dist/collection/components/tl-ticket-selector/tl-ticket-selector.js +34 -20
- package/dist/collection/utils/order-summary.js +15 -135
- package/dist/collection/utils/testing/deep-text.js +29 -0
- package/dist/collection/utils/testing/fixtures.js +290 -0
- package/dist/collection/utils/testing/mock-sdk.js +13 -2
- package/dist/collection/utils/web-query.js +12 -0
- package/dist/collection/utils/web-storage.js +22 -0
- package/dist/components/ticket-selector.js +1 -0
- package/dist/components/tl-buy-modal.js +1 -1
- package/dist/components/tl-buy-tickets-button.js +1 -1
- package/dist/components/tl-buy-tickets-wrapper2.js +1 -1
- package/dist/components/tl-cart-badge.js +1 -1
- package/dist/components/tl-cart-drawer.js +1 -1
- package/dist/components/tl-cart2.js +1 -1
- package/dist/components/tl-checkout-wrapper2.js +1 -1
- package/dist/components/tl-event-card2.js +1 -1
- package/dist/components/tl-event-list.js +1 -1
- package/dist/components/tl-login2.js +1 -1
- package/dist/components/tl-my-orders.js +1 -1
- package/dist/components/tl-occurrence-selector2.js +1 -1
- package/dist/components/tl-order-confirmation.js +1 -1
- package/dist/components/tl-order-tickets2.js +1 -1
- package/dist/components/tl-promo-code.js +1 -1
- package/dist/components/tl-seat-map.js +1 -1
- package/dist/components/tl-ticket-selector2.js +1 -1
- package/dist/components/web-query.js +1 -0
- package/dist/elements.docs.json +34 -34
- package/dist/esm/{index-OoYV1faY.js → index-imfxp765.js} +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/loader.js +3 -3
- package/dist/esm/ticket-selector-BwChnHVC.js +3606 -0
- package/dist/esm/ticketlayer-elements.js +3 -3
- package/dist/esm/tl-buy-modal.entry.js +3 -3
- package/dist/esm/tl-buy-tickets-button.entry.js +90 -75
- package/dist/esm/tl-buy-tickets-wrapper.entry.js +62 -31
- package/dist/esm/tl-cart-badge.entry.js +75 -62
- package/dist/esm/tl-cart-drawer.entry.js +52 -24
- package/dist/esm/tl-cart.entry.js +72 -104
- package/dist/esm/tl-checkout-wrapper.entry.js +52 -30
- package/dist/esm/tl-event-card.entry.js +44 -106
- package/dist/esm/tl-event-list.entry.js +73 -36
- package/dist/esm/tl-login.entry.js +75 -142
- package/dist/esm/tl-my-orders.entry.js +53 -93
- package/dist/esm/tl-occurrence-selector_2.entry.js +152 -920
- package/dist/esm/tl-order-confirmation.entry.js +72 -108
- package/dist/esm/tl-order-tickets_2.entry.js +49 -75
- package/dist/esm/tl-promo-code.entry.js +51 -48
- package/dist/esm/tl-seat-map.entry.js +3 -3
- package/dist/esm/web-query-Bdbb2lk5.js +14 -0
- package/dist/ticketlayer-elements/index.esm.js +1 -1
- package/dist/ticketlayer-elements/p-45923d2c.entry.js +1 -0
- package/dist/ticketlayer-elements/p-492adc77.entry.js +1 -0
- package/dist/ticketlayer-elements/p-4cfc9e53.entry.js +1 -0
- package/dist/ticketlayer-elements/{p-ac264adc.entry.js → p-5135630e.entry.js} +1 -1
- package/dist/ticketlayer-elements/p-5bbce3b5.entry.js +1 -0
- package/dist/ticketlayer-elements/p-7d547721.entry.js +1 -0
- package/dist/ticketlayer-elements/p-7fe7b6ba.entry.js +1 -0
- package/dist/ticketlayer-elements/p-8122f0c1.entry.js +1 -0
- package/dist/ticketlayer-elements/p-98d40869.entry.js +1 -0
- package/dist/ticketlayer-elements/p-Bdbb2lk5.js +1 -0
- package/dist/ticketlayer-elements/p-BwChnHVC.js +1 -0
- package/dist/ticketlayer-elements/p-abec290a.entry.js +1 -0
- package/dist/ticketlayer-elements/p-ac79a3f6.entry.js +1 -0
- package/dist/ticketlayer-elements/p-d01f4429.entry.js +1 -0
- package/dist/ticketlayer-elements/p-d7ffaf46.entry.js +1 -0
- package/dist/ticketlayer-elements/p-e7170f3c.entry.js +1 -0
- package/dist/ticketlayer-elements/p-e787e862.entry.js +1 -0
- package/dist/ticketlayer-elements/p-f1ff931f.entry.js +1 -0
- package/dist/ticketlayer-elements/p-imfxp765.js +3 -0
- package/dist/ticketlayer-elements/ticketlayer-elements.css +1 -1
- package/dist/ticketlayer-elements/ticketlayer-elements.esm.js +1 -1
- package/dist/types/components/tl-buy-tickets-button/tl-buy-tickets-button.d.ts +40 -8
- package/dist/types/components/tl-buy-tickets-wrapper/tl-buy-tickets-wrapper.d.ts +20 -1
- package/dist/types/components/tl-cart/tl-cart.d.ts +40 -42
- package/dist/types/components/tl-cart-badge/tl-cart-badge.d.ts +22 -7
- package/dist/types/components/tl-cart-drawer/tl-cart-drawer.d.ts +28 -5
- package/dist/types/components/tl-checkout-wrapper/tl-checkout-wrapper.d.ts +18 -1
- package/dist/types/components/tl-event-card/event-card-adapter.d.ts +9 -20
- package/dist/types/components/tl-event-card/tl-event-card.d.ts +25 -28
- package/dist/types/components/tl-event-list/tl-event-list.d.ts +46 -6
- package/dist/types/components/tl-login/tl-login.d.ts +68 -22
- package/dist/types/components/tl-my-orders/tl-my-orders.d.ts +30 -12
- package/dist/types/components/tl-occurrence-selector/tl-occurrence-selector.d.ts +73 -161
- package/dist/types/components/tl-order-confirmation/tl-order-confirmation.d.ts +48 -15
- package/dist/types/components/tl-order-tickets/tl-order-tickets.d.ts +29 -31
- package/dist/types/components/tl-promo-code/tl-promo-code.d.ts +28 -10
- package/dist/types/components/tl-ticket/tl-ticket.d.ts +2 -11
- package/dist/types/components/tl-ticket-selector/tl-ticket-selector.d.ts +22 -4
- package/dist/types/components.d.ts +270 -62
- package/dist/types/utils/order-summary.d.ts +14 -97
- package/dist/types/utils/testing/deep-text.d.ts +13 -0
- package/dist/types/utils/testing/fixtures.d.ts +225 -0
- package/dist/types/utils/testing/mock-sdk.d.ts +14 -1
- package/dist/types/utils/web-query.d.ts +18 -0
- package/dist/types/utils/web-storage.d.ts +16 -0
- package/elements.catalogue.json +602 -50
- package/package.json +8 -6
- package/dist/cjs/money-BtmM2gt-.js +0 -118
- package/dist/cjs/order-summary-X3TlZxc2.js +0 -150
- package/dist/components/money.js +0 -1
- package/dist/components/order-summary.js +0 -1
- package/dist/esm/money-4ig0-WB6.js +0 -112
- package/dist/esm/order-summary-DiDtvfZM.js +0 -136
- package/dist/ticketlayer-elements/p-0814420f.entry.js +0 -1
- package/dist/ticketlayer-elements/p-0acabc26.entry.js +0 -1
- package/dist/ticketlayer-elements/p-109a3feb.entry.js +0 -1
- package/dist/ticketlayer-elements/p-1a4ba71c.entry.js +0 -1
- package/dist/ticketlayer-elements/p-318965c1.entry.js +0 -1
- package/dist/ticketlayer-elements/p-347c7fd1.entry.js +0 -1
- package/dist/ticketlayer-elements/p-3649c26d.entry.js +0 -1
- package/dist/ticketlayer-elements/p-40260cc9.entry.js +0 -1
- package/dist/ticketlayer-elements/p-4ig0-WB6.js +0 -1
- package/dist/ticketlayer-elements/p-55fd2f9f.entry.js +0 -1
- package/dist/ticketlayer-elements/p-62ee659b.entry.js +0 -1
- package/dist/ticketlayer-elements/p-7d4ecc2b.entry.js +0 -1
- package/dist/ticketlayer-elements/p-7f0abf95.entry.js +0 -1
- package/dist/ticketlayer-elements/p-BXpVGW51.js +0 -1
- package/dist/ticketlayer-elements/p-OoYV1faY.js +0 -3
- package/dist/ticketlayer-elements/p-a3c1b26c.entry.js +0 -1
- package/dist/ticketlayer-elements/p-aeb4c6fb.entry.js +0 -1
- package/dist/ticketlayer-elements/p-ba62541f.entry.js +0 -1
|
@@ -0,0 +1,3606 @@
|
|
|
1
|
+
/** The store a core gets when its harness supplies none: it forgets. */
|
|
2
|
+
const NO_STORAGE = {
|
|
3
|
+
get: () => null,
|
|
4
|
+
set: () => undefined,
|
|
5
|
+
};
|
|
6
|
+
/** The parameters a core gets when its harness supplies none: there are none. */
|
|
7
|
+
const NO_QUERY = {
|
|
8
|
+
get: () => null,
|
|
9
|
+
};
|
|
10
|
+
const CATEGORIES = [
|
|
11
|
+
'discovery',
|
|
12
|
+
'purchase',
|
|
13
|
+
'cart',
|
|
14
|
+
'checkout',
|
|
15
|
+
'account',
|
|
16
|
+
'tickets',
|
|
17
|
+
];
|
|
18
|
+
const RUNTIMES = ['web', 'native'];
|
|
19
|
+
/**
|
|
20
|
+
* Declare a core. The checks here are the ones a type cannot make: a tag that
|
|
21
|
+
* is not a `tl-` custom element name, a category or runtime outside the set the
|
|
22
|
+
* catalogue knows, a duplicated token, a status list that does not include what
|
|
23
|
+
* `status` can return. They run at import time, so a malformed core fails the
|
|
24
|
+
* build rather than a browser.
|
|
25
|
+
*/
|
|
26
|
+
function defineElement(core) {
|
|
27
|
+
const fail = (message) => {
|
|
28
|
+
throw new Error(`[elements-core] ${core.tag || '<no tag>'}: ${message}`);
|
|
29
|
+
};
|
|
30
|
+
if (!/^tl-[a-z0-9]+(-[a-z0-9]+)*$/.test(core.tag))
|
|
31
|
+
fail('tag must look like tl-something');
|
|
32
|
+
if (!CATEGORIES.includes(core.category))
|
|
33
|
+
fail(`category must be one of ${CATEGORIES.join(', ')}`);
|
|
34
|
+
if (!core.runtimes.length || core.runtimes.some((r) => !RUNTIMES.includes(r))) {
|
|
35
|
+
fail(`runtimes must be a non-empty subset of ${RUNTIMES.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
if (new Set(core.tokens).size !== core.tokens.length)
|
|
38
|
+
fail('tokens must not repeat');
|
|
39
|
+
if (!core.statuses.length)
|
|
40
|
+
fail('statuses must not be empty');
|
|
41
|
+
for (const [name, spec] of Object.entries(core.events)) {
|
|
42
|
+
if (!/^tl[A-Z]/.test(name))
|
|
43
|
+
fail(`event ${name} must be named tlSomething`);
|
|
44
|
+
if (!spec.detail)
|
|
45
|
+
fail(`event ${name} must state its detail type`);
|
|
46
|
+
}
|
|
47
|
+
for (const [name, spec] of Object.entries(core.props)) {
|
|
48
|
+
if (spec.required && spec.default !== undefined)
|
|
49
|
+
fail(`prop ${name} is required and has a default`);
|
|
50
|
+
}
|
|
51
|
+
return Object.freeze({ ...core });
|
|
52
|
+
}
|
|
53
|
+
const DEFAULT_LOCALE = 'en-GB';
|
|
54
|
+
const DEFAULT_CURRENCY = 'GBP';
|
|
55
|
+
/**
|
|
56
|
+
* Instantiate a core. The instance owns the state and the timers; the harness
|
|
57
|
+
* owns the pixels. `subscribe` fires after every change with the whole view, so
|
|
58
|
+
* a harness only has to assign it to whatever makes its runtime re-render.
|
|
59
|
+
*/
|
|
60
|
+
function createElement(core, options = {}) {
|
|
61
|
+
const defaults = {};
|
|
62
|
+
for (const [name, spec] of Object.entries(core.props)) {
|
|
63
|
+
if (spec.default !== undefined)
|
|
64
|
+
defaults[name] = spec.default;
|
|
65
|
+
}
|
|
66
|
+
let props = { ...defaults, ...(options.props ?? {}) };
|
|
67
|
+
const strings = { ...core.strings, ...(options.strings ?? {}) };
|
|
68
|
+
const locale = options.locale ?? DEFAULT_LOCALE;
|
|
69
|
+
const fallbackCurrency = options.currency ?? DEFAULT_CURRENCY;
|
|
70
|
+
let resolveClient = toResolver(options.client);
|
|
71
|
+
let state = core.state(props);
|
|
72
|
+
let destroyed = false;
|
|
73
|
+
const timers = new Set();
|
|
74
|
+
const listeners = new Set();
|
|
75
|
+
const formatMoney = (amountInMinorUnits, currency) => new Intl.NumberFormat(locale, {
|
|
76
|
+
style: 'currency',
|
|
77
|
+
currency: currency || fallbackCurrency,
|
|
78
|
+
}).format(amountInMinorUnits / 100);
|
|
79
|
+
const storage = options.storage ?? NO_STORAGE;
|
|
80
|
+
const query = options.query ?? NO_QUERY;
|
|
81
|
+
const viewContext = () => ({ props, state, strings, formatMoney });
|
|
82
|
+
const buildView = () => {
|
|
83
|
+
const ctx = viewContext();
|
|
84
|
+
return {
|
|
85
|
+
status: core.status(ctx),
|
|
86
|
+
props,
|
|
87
|
+
state,
|
|
88
|
+
strings,
|
|
89
|
+
derived: core.derive(ctx),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const notify = () => {
|
|
93
|
+
if (destroyed)
|
|
94
|
+
return;
|
|
95
|
+
const view = buildView();
|
|
96
|
+
for (const listener of [...listeners])
|
|
97
|
+
listener(view);
|
|
98
|
+
};
|
|
99
|
+
const whenClient = async () => {
|
|
100
|
+
if (options.clientReady)
|
|
101
|
+
await options.clientReady();
|
|
102
|
+
const client = resolveClient();
|
|
103
|
+
if (!client) {
|
|
104
|
+
throw new Error(`[elements-core] ${core.tag}: no client. A harness hands the core the client the host published.`);
|
|
105
|
+
}
|
|
106
|
+
return client;
|
|
107
|
+
};
|
|
108
|
+
const actionContext = {
|
|
109
|
+
get props() {
|
|
110
|
+
return props;
|
|
111
|
+
},
|
|
112
|
+
get state() {
|
|
113
|
+
return state;
|
|
114
|
+
},
|
|
115
|
+
strings,
|
|
116
|
+
formatMoney,
|
|
117
|
+
setState(patch) {
|
|
118
|
+
if (destroyed)
|
|
119
|
+
return;
|
|
120
|
+
const next = typeof patch === 'function' ? patch(state) : patch;
|
|
121
|
+
state = { ...state, ...next };
|
|
122
|
+
notify();
|
|
123
|
+
},
|
|
124
|
+
emit(event, detail) {
|
|
125
|
+
if (!core.events[event]) {
|
|
126
|
+
throw new Error(`[elements-core] ${core.tag}: ${event} is not a declared event`);
|
|
127
|
+
}
|
|
128
|
+
options.emit?.(event, detail);
|
|
129
|
+
},
|
|
130
|
+
client() {
|
|
131
|
+
const client = resolveClient();
|
|
132
|
+
if (!client) {
|
|
133
|
+
throw new Error(`[elements-core] ${core.tag}: no client. A harness hands the core the client the host published.`);
|
|
134
|
+
}
|
|
135
|
+
return client;
|
|
136
|
+
},
|
|
137
|
+
optionalClient() {
|
|
138
|
+
try {
|
|
139
|
+
return resolveClient();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
storage,
|
|
146
|
+
query,
|
|
147
|
+
whenClient,
|
|
148
|
+
after(ms, run) {
|
|
149
|
+
if (destroyed)
|
|
150
|
+
return;
|
|
151
|
+
const handle = setTimeout(() => {
|
|
152
|
+
timers.delete(handle);
|
|
153
|
+
if (!destroyed)
|
|
154
|
+
run();
|
|
155
|
+
}, ms);
|
|
156
|
+
timers.add(handle);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
const actions = core.actions(actionContext);
|
|
160
|
+
// The live mount, as an identity rather than a boolean: a teardown closes
|
|
161
|
+
// over the token it was made for, so a stale one tears nothing down.
|
|
162
|
+
let mountToken = null;
|
|
163
|
+
let cleanups = [];
|
|
164
|
+
const teardownMount = (token) => {
|
|
165
|
+
if (mountToken !== token)
|
|
166
|
+
return;
|
|
167
|
+
mountToken = null;
|
|
168
|
+
const pending = cleanups;
|
|
169
|
+
cleanups = [];
|
|
170
|
+
for (const cleanup of pending.reverse())
|
|
171
|
+
cleanup();
|
|
172
|
+
};
|
|
173
|
+
const mount = () => {
|
|
174
|
+
if (destroyed)
|
|
175
|
+
return () => { };
|
|
176
|
+
if (mountToken)
|
|
177
|
+
teardownMount(mountToken);
|
|
178
|
+
const token = {};
|
|
179
|
+
mountToken = token;
|
|
180
|
+
const live = () => mountToken === token && !destroyed;
|
|
181
|
+
const returned = core.mount?.({
|
|
182
|
+
...actionContext,
|
|
183
|
+
get props() {
|
|
184
|
+
return props;
|
|
185
|
+
},
|
|
186
|
+
get state() {
|
|
187
|
+
return state;
|
|
188
|
+
},
|
|
189
|
+
actions,
|
|
190
|
+
active: live,
|
|
191
|
+
onTeardown(cleanup) {
|
|
192
|
+
if (live())
|
|
193
|
+
cleanups.push(cleanup);
|
|
194
|
+
else
|
|
195
|
+
cleanup();
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
if (typeof returned === 'function') {
|
|
199
|
+
if (live())
|
|
200
|
+
cleanups.push(returned);
|
|
201
|
+
else
|
|
202
|
+
returned();
|
|
203
|
+
}
|
|
204
|
+
return () => teardownMount(token);
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
core,
|
|
208
|
+
get props() {
|
|
209
|
+
return props;
|
|
210
|
+
},
|
|
211
|
+
get state() {
|
|
212
|
+
return state;
|
|
213
|
+
},
|
|
214
|
+
get view() {
|
|
215
|
+
return buildView();
|
|
216
|
+
},
|
|
217
|
+
actions,
|
|
218
|
+
setProps(next) {
|
|
219
|
+
if (destroyed)
|
|
220
|
+
return;
|
|
221
|
+
props = { ...props, ...next };
|
|
222
|
+
notify();
|
|
223
|
+
},
|
|
224
|
+
setClient(client) {
|
|
225
|
+
resolveClient = toResolver(client);
|
|
226
|
+
},
|
|
227
|
+
subscribe(listener) {
|
|
228
|
+
listeners.add(listener);
|
|
229
|
+
return () => {
|
|
230
|
+
listeners.delete(listener);
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
mount,
|
|
234
|
+
get mounted() {
|
|
235
|
+
return mountToken !== null;
|
|
236
|
+
},
|
|
237
|
+
destroy() {
|
|
238
|
+
if (mountToken)
|
|
239
|
+
teardownMount(mountToken);
|
|
240
|
+
destroyed = true;
|
|
241
|
+
for (const handle of timers)
|
|
242
|
+
clearTimeout(handle);
|
|
243
|
+
timers.clear();
|
|
244
|
+
listeners.clear();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function toResolver(client) {
|
|
249
|
+
if (typeof client === 'function')
|
|
250
|
+
return client;
|
|
251
|
+
return () => client ?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The money rows a cart-shaped view renders: subtotal, the booking fee, a
|
|
256
|
+
* discount and the total.
|
|
257
|
+
*
|
|
258
|
+
* Pure: no Stencil, no DOM, no SDK. A core derives these figures and a harness
|
|
259
|
+
* renders what it returns, so the rule for what is shown can be unit tested on
|
|
260
|
+
* its own (the platform plan's headless-core split, AGENTS.md). It lived in
|
|
261
|
+
* `packages/elements/src/utils/cart-totals.ts` until the core split (TKT-68)
|
|
262
|
+
* and is re-exported from there, so every existing import still resolves.
|
|
263
|
+
*
|
|
264
|
+
* The client (`@ticketlayer/live`) already derives these figures from the
|
|
265
|
+
* sales-surface response and publishes `subtotal`, `fees`, `discount`,
|
|
266
|
+
* `total`, `hasFees` and `feeLabel` on the cart it hands us. Cores never
|
|
267
|
+
* import the client, so the same rule is stated here for the case where a
|
|
268
|
+
* host binds an older connector, or a bare sales cart: read what is there,
|
|
269
|
+
* and never invent money that is not.
|
|
270
|
+
*
|
|
271
|
+
* Money is integer minor units (2500 = 25.00) everywhere on this platform.
|
|
272
|
+
* Nothing here divides or multiplies money; the only division is the one
|
|
273
|
+
* `formatPrice` does to render it.
|
|
274
|
+
*/
|
|
275
|
+
/** Shown when the response names no fee of its own. */
|
|
276
|
+
const FEE_LABEL_FALLBACK = 'Booking fee';
|
|
277
|
+
/**
|
|
278
|
+
* Minor units, or null when the value is not minor units. Integers and
|
|
279
|
+
* all-digit strings only: a decimal is not minor units, and treating it as
|
|
280
|
+
* money here would put floating point into a total.
|
|
281
|
+
*/
|
|
282
|
+
function minorUnits(value) {
|
|
283
|
+
if (typeof value === 'number')
|
|
284
|
+
return Number.isInteger(value) ? value : null;
|
|
285
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value))
|
|
286
|
+
return Number(value);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* THE RULE: a fee of zero is not the same as no fee.
|
|
291
|
+
*
|
|
292
|
+
* Render the fee row when the response EXPRESSES a fee - an amount above
|
|
293
|
+
* zero, or a fee the surface has NAMED (a `type: 'fee'` line or a fee label)
|
|
294
|
+
* even where it computes to zero. A response with no fee figure at all, or a
|
|
295
|
+
* bare zero nothing names, expresses no fee: a channel with no fee profile
|
|
296
|
+
* renders no row, and we never show a "Booking fee 0.00" the surface did not
|
|
297
|
+
* put there.
|
|
298
|
+
*/
|
|
299
|
+
function expressesFee(fees, named) {
|
|
300
|
+
if (fees === null)
|
|
301
|
+
return named;
|
|
302
|
+
return fees > 0 || named;
|
|
303
|
+
}
|
|
304
|
+
const lineType = (line) => line.type || 'ticket';
|
|
305
|
+
function cartTotals(cart) {
|
|
306
|
+
const c = cart || {};
|
|
307
|
+
const lines = Array.isArray(c.items) ? c.items : [];
|
|
308
|
+
const feeLines = lines.filter((l) => lineType(l) === 'fee');
|
|
309
|
+
const discountLines = lines.filter((l) => lineType(l) === 'discount');
|
|
310
|
+
const subtotal = minorUnits(c.itemsSubtotal) ??
|
|
311
|
+
minorUnits(c.subtotal) ??
|
|
312
|
+
lines
|
|
313
|
+
.filter((l) => !['fee', 'tax', 'discount'].includes(lineType(l)))
|
|
314
|
+
.reduce((sum, l) => sum + (minorUnits(l.subtotal) ?? 0), 0);
|
|
315
|
+
const feeFromLines = feeLines.length
|
|
316
|
+
? feeLines.reduce((sum, l) => sum + (minorUnits(l.subtotal) ?? 0), 0)
|
|
317
|
+
: null;
|
|
318
|
+
const fees = minorUnits(c.totalFees) ?? minorUnits(c.fees) ?? feeFromLines;
|
|
319
|
+
const discountFromLines = discountLines.length
|
|
320
|
+
? discountLines.reduce((sum, l) => sum + Math.abs(minorUnits(l.subtotal) ?? 0), 0)
|
|
321
|
+
: null;
|
|
322
|
+
const discount = minorUnits(c.totalDiscounts) ?? minorUnits(c.discount) ?? discountFromLines;
|
|
323
|
+
const tax = minorUnits(c.totalTax) ?? 0;
|
|
324
|
+
const named = (typeof c.feeLabel === 'string' && !!c.feeLabel) ||
|
|
325
|
+
feeLines.some((l) => typeof l.name === 'string' && !!l.name);
|
|
326
|
+
const label = (typeof c.feeLabel === 'string' && c.feeLabel) ||
|
|
327
|
+
feeLines.map((l) => l.name).find((n) => typeof n === 'string' && !!n) ||
|
|
328
|
+
FEE_LABEL_FALLBACK;
|
|
329
|
+
return {
|
|
330
|
+
currency: c.currency || 'GBP',
|
|
331
|
+
subtotal,
|
|
332
|
+
fees: fees ?? 0,
|
|
333
|
+
discount: discount ?? 0,
|
|
334
|
+
// The server's total is what the buyer is charged, so it wins over any sum
|
|
335
|
+
// we could do here. We only add up when the response carries no total.
|
|
336
|
+
total: minorUnits(c.total) ?? subtotal + (fees ?? 0) + tax - (discount ?? 0),
|
|
337
|
+
// The client already applied the rule; honour its answer when it sent one.
|
|
338
|
+
showFee: typeof c.hasFees === 'boolean' ? c.hasFees : expressesFee(fees, named),
|
|
339
|
+
feeLabel: label,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* The totals for a selection that is not yet a cart (tl-ticket-selector).
|
|
344
|
+
*
|
|
345
|
+
* The buyer-facing surface quotes no fee before the cart exists - there is no
|
|
346
|
+
* quote or preview operation, and the channel's fee profile is not exposed to
|
|
347
|
+
* a publishable key - so `quote` is null on every call today and no fee row is
|
|
348
|
+
* rendered. It is a parameter rather than an assumption so that the row lights
|
|
349
|
+
* up from data the moment the surface quotes one, and so the rule stays
|
|
350
|
+
* testable now.
|
|
351
|
+
*/
|
|
352
|
+
function selectionTotals(lines, quote = null) {
|
|
353
|
+
const subtotal = (lines || []).reduce((sum, l) => sum + (minorUnits(l.price) ?? 0) * (Math.max(0, Math.trunc(l.quantity || 0)) || 0), 0);
|
|
354
|
+
const fees = quote ? minorUnits(quote.amount) : null;
|
|
355
|
+
const named = !!quote?.label;
|
|
356
|
+
return {
|
|
357
|
+
subtotal,
|
|
358
|
+
fees: fees ?? 0,
|
|
359
|
+
total: subtotal + (fees ?? 0),
|
|
360
|
+
showFee: quote ? expressesFee(fees, named) : false,
|
|
361
|
+
feeLabel: quote?.label || FEE_LABEL_FALLBACK,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The order shape, and what every harness has to work out from it.
|
|
367
|
+
*
|
|
368
|
+
* The sales order view carries line items, totals and tickets but no event
|
|
369
|
+
* name, no occurrence date and often no customer email, so these derive what
|
|
370
|
+
* they can from the order and resolve the rest through the client. They lived
|
|
371
|
+
* in `packages/elements/src/utils/order-summary.ts`, where only the web could
|
|
372
|
+
* reach them; they moved here with the split of `tl-my-orders` and
|
|
373
|
+
* `tl-order-confirmation` (TKT-69) so the same rules answer on every runtime.
|
|
374
|
+
* That module re-exports them, so every existing import still resolves and
|
|
375
|
+
* `tl-order-tickets` and `tl-ticket`, which are not split yet, read the same
|
|
376
|
+
* helpers they always did.
|
|
377
|
+
*
|
|
378
|
+
* `en-GB` is written out in the date and money helpers rather than taken from
|
|
379
|
+
* the instance's locale. That is what the components did and this is a
|
|
380
|
+
* behaviour-identical move; `ctx.formatMoney` is the locale-aware formatter and
|
|
381
|
+
* these are deliberately not it, because an order's money arrives as a minor
|
|
382
|
+
* unit integer OR a decimal string and `formatMoney` takes only the first.
|
|
383
|
+
*/
|
|
384
|
+
/** Money arrives as minor-unit integers or decimal strings ("50.00"). */
|
|
385
|
+
function formatPrice$1(value, currency = 'GBP') {
|
|
386
|
+
if (value === null || value === undefined || value === '')
|
|
387
|
+
return '';
|
|
388
|
+
const fmt = new Intl.NumberFormat('en-GB', { style: 'currency', currency });
|
|
389
|
+
if (typeof value === 'number')
|
|
390
|
+
return fmt.format(value / 100);
|
|
391
|
+
const n = Number(value);
|
|
392
|
+
return Number.isNaN(n) ? String(value) : fmt.format(n);
|
|
393
|
+
}
|
|
394
|
+
function formatDateTime(iso) {
|
|
395
|
+
return new Date(iso).toLocaleDateString('en-GB', {
|
|
396
|
+
weekday: 'short',
|
|
397
|
+
day: 'numeric',
|
|
398
|
+
month: 'short',
|
|
399
|
+
year: 'numeric',
|
|
400
|
+
hour: '2-digit',
|
|
401
|
+
minute: '2-digit',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function formatDate$3(iso) {
|
|
405
|
+
return new Date(iso).toLocaleDateString('en-GB', {
|
|
406
|
+
day: 'numeric',
|
|
407
|
+
month: 'short',
|
|
408
|
+
year: 'numeric',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/** Line items that are tickets (fees, taxes and discounts have their own type). */
|
|
412
|
+
function ticketItems(order) {
|
|
413
|
+
return (order.items ?? []).filter((i) => !i.type || i.type === 'ticket');
|
|
414
|
+
}
|
|
415
|
+
function ticketCount(order) {
|
|
416
|
+
const fromItems = ticketItems(order).reduce((sum, i) => sum + (Number(i.quantity) || 0), 0);
|
|
417
|
+
return fromItems || order.tickets?.length || 0;
|
|
418
|
+
}
|
|
419
|
+
/** Event name as the order view exposes it: the first ticket line's name. */
|
|
420
|
+
function orderEventName(order) {
|
|
421
|
+
return order.eventName || ticketItems(order)[0]?.name || order.items?.[0]?.name || null;
|
|
422
|
+
}
|
|
423
|
+
function orderCustomerEmail(order, customer) {
|
|
424
|
+
const fromOrder = order?.customerEmail || order?.customer?.email;
|
|
425
|
+
if (fromOrder)
|
|
426
|
+
return fromOrder;
|
|
427
|
+
const c = customer;
|
|
428
|
+
return c?.email || null;
|
|
429
|
+
}
|
|
430
|
+
function statusLabel(status) {
|
|
431
|
+
if (!status)
|
|
432
|
+
return '';
|
|
433
|
+
return status.replace(/_/g, ' ').replace(/^\w/, (m) => m.toUpperCase());
|
|
434
|
+
}
|
|
435
|
+
function statusTone(status) {
|
|
436
|
+
switch (status) {
|
|
437
|
+
case 'confirmed':
|
|
438
|
+
return 'success';
|
|
439
|
+
case 'completed':
|
|
440
|
+
case 'fulfilled':
|
|
441
|
+
return 'info';
|
|
442
|
+
case 'pending':
|
|
443
|
+
case 'processing':
|
|
444
|
+
return 'warning';
|
|
445
|
+
case 'cancelled':
|
|
446
|
+
case 'expired':
|
|
447
|
+
case 'failed':
|
|
448
|
+
return 'error';
|
|
449
|
+
default:
|
|
450
|
+
return 'muted';
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function errorMessage(err, fallback) {
|
|
454
|
+
return err instanceof Error && err.message ? err.message : fallback;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Find the event and start time for an occurrence id. The order view only
|
|
458
|
+
* carries the occurrence id, so this walks the channel's listings (cached by
|
|
459
|
+
* live-api) until one owns the occurrence. Best effort: null when not found,
|
|
460
|
+
* and null on a client whose events manager cannot list or fetch, which is an
|
|
461
|
+
* older connector rather than a failure.
|
|
462
|
+
*/
|
|
463
|
+
async function resolveOccurrence(client, occurrenceId, maxEvents = 12) {
|
|
464
|
+
const events = client.getEventsManager();
|
|
465
|
+
if (!events?.list || !events.get)
|
|
466
|
+
return null;
|
|
467
|
+
const listing = await events.list({ limit: maxEvents });
|
|
468
|
+
const items = (listing?.items ?? []);
|
|
469
|
+
const candidates = items.slice(0, maxEvents);
|
|
470
|
+
for (const candidate of candidates) {
|
|
471
|
+
const detail = (await events.get(candidate.id).catch(() => null));
|
|
472
|
+
const occ = detail?.occurrences?.find((o) => o.id === occurrenceId);
|
|
473
|
+
if (occ) {
|
|
474
|
+
return {
|
|
475
|
+
eventId: detail?.id ?? candidate.id,
|
|
476
|
+
eventName: detail?.name ?? candidate.name ?? '',
|
|
477
|
+
startsAt: occ.startsAt ?? null,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
function orderTotals(order) {
|
|
484
|
+
const o = order || {};
|
|
485
|
+
const items = Array.isArray(o.items) ? o.items : [];
|
|
486
|
+
const feeLines = items.filter((i) => i.type === 'fee');
|
|
487
|
+
const discountLines = items.filter((i) => i.type === 'discount');
|
|
488
|
+
const tickets = ticketItems(o);
|
|
489
|
+
const feeFromLines = feeLines.length
|
|
490
|
+
? feeLines.reduce((sum, i) => sum + (minorUnits(i.subtotal) ?? 0), 0)
|
|
491
|
+
: null;
|
|
492
|
+
const fees = feeFromLines ?? minorUnits(o.totalFees) ?? 0;
|
|
493
|
+
const declaredSubtotal = minorUnits(o.subtotal);
|
|
494
|
+
const subtotal = tickets.length
|
|
495
|
+
? tickets.reduce((sum, i) => sum + (minorUnits(i.subtotal) ?? 0), 0)
|
|
496
|
+
: (declaredSubtotal ?? 0) - fees;
|
|
497
|
+
const discountFromLines = discountLines.length
|
|
498
|
+
? discountLines.reduce((sum, i) => sum + Math.abs(minorUnits(i.subtotal) ?? 0), 0)
|
|
499
|
+
: null;
|
|
500
|
+
const discount = minorUnits(o.totalDiscounts) ?? discountFromLines ?? 0;
|
|
501
|
+
const tax = minorUnits(o.totalTax) ?? 0;
|
|
502
|
+
const named = feeLines.some((i) => typeof i.name === 'string' && !!i.name);
|
|
503
|
+
return {
|
|
504
|
+
currency: o.currency || 'GBP',
|
|
505
|
+
subtotal,
|
|
506
|
+
fees,
|
|
507
|
+
feeLabel: feeLines.map((i) => i.name).find((n) => !!n) || FEE_LABEL_FALLBACK,
|
|
508
|
+
showFee: expressesFee(feeFromLines ?? minorUnits(o.totalFees), named),
|
|
509
|
+
tax,
|
|
510
|
+
discount,
|
|
511
|
+
total: minorUnits(o.total) ?? subtotal + fees + tax - discount,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* `tl-buy-tickets-button`: the pre-styled button that opens the purchase
|
|
517
|
+
* modal for one event.
|
|
518
|
+
*
|
|
519
|
+
* The sibling of `tl-buy-tickets-wrapper`. The wrapper turns whatever the host
|
|
520
|
+
* already has into the thing that opens the modal; this one IS the thing, so
|
|
521
|
+
* on top of the wrapper's single action it carries the four sale states a
|
|
522
|
+
* seller puts a button in, the label each one defaults to, and the price the
|
|
523
|
+
* button may show beside it.
|
|
524
|
+
*
|
|
525
|
+
* Until now it could not be split for a different reason from the wrapper's.
|
|
526
|
+
* Its four states painted themselves from literal hex in the component, and a
|
|
527
|
+
* core may hold no value at all, so there was nothing to lift. TKT-203 moved
|
|
528
|
+
* them onto token families - `available` to primary, `soldOut` to error,
|
|
529
|
+
* `comingSoon` to warning, `waitlist` to info, each deriving its hover with
|
|
530
|
+
* `color-mix` - and that is what unblocked this.
|
|
531
|
+
*
|
|
532
|
+
* What a core still cannot say, and this one does not try to: WHICH family a
|
|
533
|
+
* state paints with. `tokens` is a flat list and there is no shape for mapping
|
|
534
|
+
* one onto a status. So this core does what `tl-event-card`'s status badge
|
|
535
|
+
* does - it derives the CLASS NAME the state is rendered under
|
|
536
|
+
* (`derived.stateClass`) and leaves the stylesheet to say which family that
|
|
537
|
+
* class reaches for. The core names the state; the harness names the paint.
|
|
538
|
+
*
|
|
539
|
+
* Behaviour is what the Stencil component did, to the letter:
|
|
540
|
+
*
|
|
541
|
+
* - `disabled`, `soldOut` and `comingSoon` are not pressable, and a press on
|
|
542
|
+
* one of them does nothing whatever: no event, no modal.
|
|
543
|
+
* - a press announces `tlClick` FIRST and opens the modal after, so a host
|
|
544
|
+
* hears the intent even on a page that never started Live.
|
|
545
|
+
* - with no client, or a connector too old to have the modal verbs, the press
|
|
546
|
+
* still announces `tlClick` and then quietly opens nothing. Unlike the
|
|
547
|
+
* wrapper this element has no error event to say so with, and inventing one
|
|
548
|
+
* would be a new published surface rather than a split.
|
|
549
|
+
* - the label is the `label` prop when the host set one, else the state's own.
|
|
550
|
+
* - the price is formatted `en-GB`, in the button's currency, with the pence
|
|
551
|
+
* shown only when there are any.
|
|
552
|
+
*
|
|
553
|
+
* What did not come across is the `console.error` the component wrote when the
|
|
554
|
+
* modal threw, for the reason `tl-cart` and `tl-promo-code` dropped theirs: a
|
|
555
|
+
* library should not write to its host's console.
|
|
556
|
+
*
|
|
557
|
+
* The other thing a core refuses to know is which key press counts as
|
|
558
|
+
* activation. `activate()` is the whole input surface; the harness decides what
|
|
559
|
+
* reaches it - a click and Enter or Space on the web, a press on a phone.
|
|
560
|
+
*/
|
|
561
|
+
const STRINGS$e = {
|
|
562
|
+
available: 'Buy Tickets',
|
|
563
|
+
soldOut: 'Sold Out',
|
|
564
|
+
waitlist: 'Join Waitlist',
|
|
565
|
+
comingSoon: 'On Sale Soon',
|
|
566
|
+
};
|
|
567
|
+
/** Sale state -> the class it renders under. */
|
|
568
|
+
const STATE_CLASSES = {
|
|
569
|
+
available: 'state-available',
|
|
570
|
+
soldOut: 'state-sold-out',
|
|
571
|
+
waitlist: 'state-waitlist',
|
|
572
|
+
comingSoon: 'state-coming-soon',
|
|
573
|
+
};
|
|
574
|
+
/** The states a press does nothing in, whatever `disabled` says. */
|
|
575
|
+
const NOT_PRESSABLE = ['soldOut', 'comingSoon'];
|
|
576
|
+
const pressable = (props) => !props.disabled && !NOT_PRESSABLE.includes(props.state);
|
|
577
|
+
/**
|
|
578
|
+
* As the component formatted it: `en-GB`, minor units, and the pence only when
|
|
579
|
+
* there are any. The locale is written down here rather than taken from
|
|
580
|
+
* `ctx.formatMoney` because that is what the element did before the split and
|
|
581
|
+
* this is a split, not a change (see the note on the card core's date).
|
|
582
|
+
*/
|
|
583
|
+
function formatPrice(priceInMinorUnits, currency) {
|
|
584
|
+
const amount = priceInMinorUnits / 100;
|
|
585
|
+
const hasDecimals = amount % 1 !== 0;
|
|
586
|
+
return new Intl.NumberFormat('en-GB', {
|
|
587
|
+
style: 'currency',
|
|
588
|
+
currency,
|
|
589
|
+
minimumFractionDigits: hasDecimals ? 2 : 0,
|
|
590
|
+
maximumFractionDigits: hasDecimals ? 2 : 0,
|
|
591
|
+
}).format(amount);
|
|
592
|
+
}
|
|
593
|
+
const buyTicketsButtonCore = defineElement({
|
|
594
|
+
tag: 'tl-buy-tickets-button',
|
|
595
|
+
docs: 'A buy-tickets button in one of four sale states, which opens the purchase modal.',
|
|
596
|
+
category: 'purchase',
|
|
597
|
+
runtimes: ['web'],
|
|
598
|
+
tokens: [
|
|
599
|
+
'error',
|
|
600
|
+
'error-foreground',
|
|
601
|
+
'font-family',
|
|
602
|
+
'info',
|
|
603
|
+
'info-foreground',
|
|
604
|
+
'primary',
|
|
605
|
+
'primary-foreground',
|
|
606
|
+
'radius-lg',
|
|
607
|
+
'radius-md',
|
|
608
|
+
'radius-sm',
|
|
609
|
+
'warning',
|
|
610
|
+
'warning-foreground',
|
|
611
|
+
],
|
|
612
|
+
sdkMethods: ['event.openModal'],
|
|
613
|
+
props: {
|
|
614
|
+
eventId: {
|
|
615
|
+
type: 'string',
|
|
616
|
+
attr: 'event-id',
|
|
617
|
+
required: true,
|
|
618
|
+
docs: 'Event ID to purchase tickets for',
|
|
619
|
+
},
|
|
620
|
+
occurrenceId: {
|
|
621
|
+
type: 'string',
|
|
622
|
+
tsType: 'string | undefined',
|
|
623
|
+
attr: 'occurrence-id',
|
|
624
|
+
docs: 'Optional pre-selected occurrence ID',
|
|
625
|
+
},
|
|
626
|
+
state: {
|
|
627
|
+
type: 'string',
|
|
628
|
+
tsType: '"available" | "comingSoon" | "soldOut" | "waitlist"',
|
|
629
|
+
attr: 'state',
|
|
630
|
+
default: 'available',
|
|
631
|
+
docs: 'Button state - determines colour, label, and icon',
|
|
632
|
+
},
|
|
633
|
+
label: {
|
|
634
|
+
type: 'string',
|
|
635
|
+
tsType: 'string | undefined',
|
|
636
|
+
attr: 'label',
|
|
637
|
+
docs: 'Button label override (default depends on state)',
|
|
638
|
+
},
|
|
639
|
+
variant: {
|
|
640
|
+
type: 'string',
|
|
641
|
+
tsType: '"outline" | "primary" | "secondary"',
|
|
642
|
+
attr: 'variant',
|
|
643
|
+
default: 'primary',
|
|
644
|
+
docs: 'Button variant',
|
|
645
|
+
},
|
|
646
|
+
size: {
|
|
647
|
+
type: 'string',
|
|
648
|
+
tsType: '"lg" | "md" | "sm"',
|
|
649
|
+
attr: 'size',
|
|
650
|
+
default: 'md',
|
|
651
|
+
docs: 'Button size',
|
|
652
|
+
},
|
|
653
|
+
fullWidth: {
|
|
654
|
+
type: 'boolean',
|
|
655
|
+
attr: 'full-width',
|
|
656
|
+
default: false,
|
|
657
|
+
docs: 'Full width button',
|
|
658
|
+
},
|
|
659
|
+
price: {
|
|
660
|
+
type: 'number',
|
|
661
|
+
tsType: 'number | undefined',
|
|
662
|
+
attr: 'price',
|
|
663
|
+
docs: 'Price to display (in minor units e.g. pence)',
|
|
664
|
+
},
|
|
665
|
+
currency: {
|
|
666
|
+
type: 'string',
|
|
667
|
+
attr: 'currency',
|
|
668
|
+
default: 'GBP',
|
|
669
|
+
docs: 'Currency code for price formatting',
|
|
670
|
+
},
|
|
671
|
+
pricePrefix: {
|
|
672
|
+
type: 'string',
|
|
673
|
+
attr: 'price-prefix',
|
|
674
|
+
default: 'From',
|
|
675
|
+
docs: 'Show price prefix text',
|
|
676
|
+
},
|
|
677
|
+
disabled: {
|
|
678
|
+
type: 'boolean',
|
|
679
|
+
attr: 'disabled',
|
|
680
|
+
default: false,
|
|
681
|
+
docs: 'Disabled state',
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
events: {
|
|
685
|
+
tlClick: {
|
|
686
|
+
detail: '{ eventId: string; occurrenceId?: string | undefined; }',
|
|
687
|
+
docs: 'Emitted when button is clicked (before modal opens)',
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
strings: STRINGS$e,
|
|
691
|
+
statuses: ['ready', 'disabled'],
|
|
692
|
+
state: () => ({}),
|
|
693
|
+
status: ({ props }) => (pressable(props) ? 'ready' : 'disabled'),
|
|
694
|
+
derive: ({ props, strings }) => ({
|
|
695
|
+
interactive: pressable(props),
|
|
696
|
+
buttonLabel: props.label || strings[props.state],
|
|
697
|
+
stateClass: STATE_CLASSES[props.state],
|
|
698
|
+
priceLabel: props.price === undefined ? null : formatPrice(props.price, props.currency),
|
|
699
|
+
}),
|
|
700
|
+
actions: (ctx) => ({
|
|
701
|
+
/** A click, a tap, Enter or Space: whatever the harness calls a press. */
|
|
702
|
+
activate() {
|
|
703
|
+
if (!pressable(ctx.props))
|
|
704
|
+
return;
|
|
705
|
+
// Announced first, and whatever happens next: a host that tracks buying
|
|
706
|
+
// intent hears it even on a page that never started Live.
|
|
707
|
+
ctx.emit('tlClick', { eventId: ctx.props.eventId, occurrenceId: ctx.props.occurrenceId });
|
|
708
|
+
const client = ctx.optionalClient();
|
|
709
|
+
// Tolerated rather than assumed, like every other accessor a core reads:
|
|
710
|
+
// the same core runs against whatever connector version the host has.
|
|
711
|
+
if (!client || !client.event)
|
|
712
|
+
return;
|
|
713
|
+
try {
|
|
714
|
+
client.event(ctx.props.eventId).openModal({ occurrenceId: ctx.props.occurrenceId });
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// The component swallowed this too (it logged and carried on). This
|
|
718
|
+
// element has no error event to announce it with, and giving it one
|
|
719
|
+
// would be a new published surface rather than a split.
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
}),
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* `tl-buy-tickets-wrapper`: whatever the page already has, turned into the
|
|
727
|
+
* thing that opens the purchase modal.
|
|
728
|
+
*
|
|
729
|
+
* The element renders nothing of its own - the host's own button, link or card
|
|
730
|
+
* is slotted into it - so this is the smallest core in the package: no state,
|
|
731
|
+
* one action, and three events that exist only because the modal reports back
|
|
732
|
+
* to whoever opened it.
|
|
733
|
+
*
|
|
734
|
+
* Until now it could not be split at all. Its events were `tl-complete`,
|
|
735
|
+
* `tl-error` and `tl-close`, and `defineElement` requires `tlSomething`; the
|
|
736
|
+
* names were renamed to `tlComplete`, `tlError` and `tlClose` (TKT-203), which
|
|
737
|
+
* is what made this possible. The rename is a breaking change to a published
|
|
738
|
+
* element and is deliberate.
|
|
739
|
+
*
|
|
740
|
+
* Behaviour is what the Stencil component did, to the letter:
|
|
741
|
+
*
|
|
742
|
+
* - a disabled wrapper does nothing at all, and announces nothing.
|
|
743
|
+
* - with no client on the page the click announces `tlError` rather than
|
|
744
|
+
* throwing, because the host asked for a modal and deserves to hear that it
|
|
745
|
+
* did not open.
|
|
746
|
+
* - the modal is the client's, not the element's. This core asks for it and
|
|
747
|
+
* then only relays what comes back.
|
|
748
|
+
*
|
|
749
|
+
* What did not come across is the `console.error` tracing, for the reason
|
|
750
|
+
* `tl-cart` and `tl-promo-code` dropped theirs: a library should not write to
|
|
751
|
+
* its host's console when it has an event for saying the same thing.
|
|
752
|
+
*
|
|
753
|
+
* What a core still cannot say, and this one does not try to: which key press
|
|
754
|
+
* counts as activation. `activate()` is the whole input surface, and each
|
|
755
|
+
* harness decides what reaches it - a click and Enter or Space on the web, a
|
|
756
|
+
* press on a phone.
|
|
757
|
+
*/
|
|
758
|
+
const STRINGS$d = {
|
|
759
|
+
noClient: 'SDK not initialized',
|
|
760
|
+
notAvailable: 'Tickets are not available',
|
|
761
|
+
};
|
|
762
|
+
const buyTicketsWrapperCore = defineElement({
|
|
763
|
+
tag: 'tl-buy-tickets-wrapper',
|
|
764
|
+
docs: 'Wrap any content and open the purchase modal when it is activated.',
|
|
765
|
+
category: 'purchase',
|
|
766
|
+
runtimes: ['web'],
|
|
767
|
+
tokens: ['radius-sm', 'ring'],
|
|
768
|
+
sdkMethods: ['event.openModal'],
|
|
769
|
+
props: {
|
|
770
|
+
eventId: {
|
|
771
|
+
type: 'string',
|
|
772
|
+
attr: 'event-id',
|
|
773
|
+
required: true,
|
|
774
|
+
docs: 'The event ID to purchase tickets for',
|
|
775
|
+
},
|
|
776
|
+
occurrenceId: {
|
|
777
|
+
type: 'string',
|
|
778
|
+
tsType: 'string | undefined',
|
|
779
|
+
attr: 'occurrence-id',
|
|
780
|
+
docs: 'Optional specific occurrence ID (skips occurrence selector)',
|
|
781
|
+
},
|
|
782
|
+
disabled: {
|
|
783
|
+
type: 'boolean',
|
|
784
|
+
attr: 'disabled',
|
|
785
|
+
default: false,
|
|
786
|
+
docs: 'Whether the wrapper is disabled',
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
events: {
|
|
790
|
+
// `OrderResult` because that is the name the web harness writes, and the
|
|
791
|
+
// catalogue compares the two: it is the connector's name for the shape
|
|
792
|
+
// `ElementOrderResult` describes here.
|
|
793
|
+
tlComplete: { detail: 'OrderResult', docs: 'Emitted when purchase completes successfully' },
|
|
794
|
+
tlError: { detail: '{ message: string; }', docs: 'Emitted when an error occurs' },
|
|
795
|
+
tlClose: { detail: 'void', docs: 'Emitted when the modal closes' },
|
|
796
|
+
},
|
|
797
|
+
strings: STRINGS$d,
|
|
798
|
+
statuses: ['ready', 'disabled'],
|
|
799
|
+
state: () => ({}),
|
|
800
|
+
status: ({ props }) => (props.disabled ? 'disabled' : 'ready'),
|
|
801
|
+
derive: ({ props }) => ({ interactive: !props.disabled }),
|
|
802
|
+
actions: (ctx) => ({
|
|
803
|
+
/** A click, a tap, Enter or Space: whatever the harness calls a press. */
|
|
804
|
+
activate() {
|
|
805
|
+
if (ctx.props.disabled)
|
|
806
|
+
return;
|
|
807
|
+
const client = ctx.optionalClient();
|
|
808
|
+
if (!client) {
|
|
809
|
+
ctx.emit('tlError', { message: ctx.strings.noClient });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
// Tolerated rather than assumed, like every other accessor a core reads:
|
|
813
|
+
// the same core runs against whatever connector version the host has.
|
|
814
|
+
if (!client.event) {
|
|
815
|
+
ctx.emit('tlError', { message: ctx.strings.notAvailable });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
client.event(ctx.props.eventId).openModal({
|
|
819
|
+
occurrenceId: ctx.props.occurrenceId,
|
|
820
|
+
onComplete: (order) => ctx.emit('tlComplete', order),
|
|
821
|
+
onError: (err) => ctx.emit('tlError', { message: err.message }),
|
|
822
|
+
onClose: () => ctx.emit('tlClose', undefined),
|
|
823
|
+
});
|
|
824
|
+
},
|
|
825
|
+
}),
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* `tl-cart`: the cart as a list of lines, its money rows and a checkout button.
|
|
830
|
+
*
|
|
831
|
+
* The first core that lives on a subscription. It reads the cart once when a
|
|
832
|
+
* harness mounts it and then keeps itself current from the client's
|
|
833
|
+
* `cart:updated` bus, binding that subscription inside its own `mount` and
|
|
834
|
+
* handing the unsubscribe to `ctx.onTeardown` (TKT-197). No harness owns a
|
|
835
|
+
* subscription any more, on any runtime, and a remount cannot leave two
|
|
836
|
+
* behind: `createElement` keeps at most one mount live.
|
|
837
|
+
*
|
|
838
|
+
* Behaviour is what the Stencil component did before the split, to the letter:
|
|
839
|
+
* the same money rows through `cartTotals`, the same compact mode, the same
|
|
840
|
+
* silence when the host published no client, the same events in the same
|
|
841
|
+
* order. What did not come across is the `console.log` tracing, which a library
|
|
842
|
+
* should not do for its host.
|
|
843
|
+
*/
|
|
844
|
+
const STRINGS$c = {
|
|
845
|
+
emptyTitle: 'Your cart is empty',
|
|
846
|
+
emptyMessage: 'Add tickets to get started',
|
|
847
|
+
subtotalRow: 'Subtotal',
|
|
848
|
+
discountRow: 'Discount',
|
|
849
|
+
totalRow: 'Total',
|
|
850
|
+
removeItem: 'Remove item',
|
|
851
|
+
quantityPrefix: 'Qty: ',
|
|
852
|
+
loadFailed: 'Failed to load cart',
|
|
853
|
+
removeFailed: 'Failed to remove item',
|
|
854
|
+
};
|
|
855
|
+
/**
|
|
856
|
+
* The occurrence date, as the component formatted it.
|
|
857
|
+
*
|
|
858
|
+
* `en-GB` is written here rather than taken from the instance's locale because
|
|
859
|
+
* that is what the component did, and this split is behaviour-identical. There
|
|
860
|
+
* is no locale on the view context today; `formatMoney` closes over one and
|
|
861
|
+
* nothing else does.
|
|
862
|
+
*/
|
|
863
|
+
function formatDate$2(dateString) {
|
|
864
|
+
return new Date(dateString).toLocaleDateString('en-GB', {
|
|
865
|
+
weekday: 'short',
|
|
866
|
+
day: 'numeric',
|
|
867
|
+
month: 'short',
|
|
868
|
+
hour: '2-digit',
|
|
869
|
+
minute: '2-digit',
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
const cartCore = defineElement({
|
|
873
|
+
tag: 'tl-cart',
|
|
874
|
+
docs: "The buyer's cart: its lines, the money rows the surface reported and a checkout button.",
|
|
875
|
+
category: 'cart',
|
|
876
|
+
runtimes: ['web', 'native'],
|
|
877
|
+
tokens: [
|
|
878
|
+
'background',
|
|
879
|
+
'border',
|
|
880
|
+
'error',
|
|
881
|
+
'error-bg',
|
|
882
|
+
'font-family',
|
|
883
|
+
'foreground',
|
|
884
|
+
'muted',
|
|
885
|
+
'muted-foreground',
|
|
886
|
+
'primary',
|
|
887
|
+
'primary-foreground',
|
|
888
|
+
'radius-md',
|
|
889
|
+
'radius-sm',
|
|
890
|
+
'spacing-1',
|
|
891
|
+
'spacing-2',
|
|
892
|
+
'spacing-3',
|
|
893
|
+
'spacing-4',
|
|
894
|
+
'spacing-8',
|
|
895
|
+
],
|
|
896
|
+
sdkMethods: ['cart.get', 'cart.on', 'cart.removeItem'],
|
|
897
|
+
props: {
|
|
898
|
+
showCheckoutButton: {
|
|
899
|
+
type: 'boolean',
|
|
900
|
+
attr: 'show-checkout-button',
|
|
901
|
+
default: true,
|
|
902
|
+
docs: 'Show checkout button',
|
|
903
|
+
},
|
|
904
|
+
checkoutLabel: {
|
|
905
|
+
type: 'string',
|
|
906
|
+
attr: 'checkout-label',
|
|
907
|
+
default: 'Checkout',
|
|
908
|
+
docs: 'Checkout button label',
|
|
909
|
+
},
|
|
910
|
+
showRemoveButtons: {
|
|
911
|
+
type: 'boolean',
|
|
912
|
+
attr: 'show-remove-buttons',
|
|
913
|
+
default: true,
|
|
914
|
+
docs: 'Show remove item buttons',
|
|
915
|
+
},
|
|
916
|
+
compact: { type: 'boolean', attr: 'compact', default: false, docs: 'Compact mode - hides some details' },
|
|
917
|
+
},
|
|
918
|
+
events: {
|
|
919
|
+
tlCheckout: { detail: '{ cart: TlCartData; }', docs: 'Emitted when checkout is clicked' },
|
|
920
|
+
tlItemRemoved: { detail: '{ itemId: string; }', docs: 'Emitted when item is removed' },
|
|
921
|
+
tlCartUpdated: { detail: '{ cart: TlCartData; }', docs: 'Emitted when cart is updated' },
|
|
922
|
+
tlError: { detail: '{ message: string; }', docs: 'Emitted on error' },
|
|
923
|
+
},
|
|
924
|
+
strings: STRINGS$c,
|
|
925
|
+
statuses: ['loading', 'empty', 'ready'],
|
|
926
|
+
state: () => ({ cart: null, loading: true, error: null, removingItemId: null }),
|
|
927
|
+
/**
|
|
928
|
+
* On screen: wait for the host's client, subscribe to the cart, then read it.
|
|
929
|
+
*
|
|
930
|
+
* Subscribing before the read is deliberate. The component started both at
|
|
931
|
+
* once (`componentWillLoad` read, `componentDidLoad` subscribed) and so could
|
|
932
|
+
* miss an update that landed mid-read; going first closes that window
|
|
933
|
+
* without changing anything a host can see.
|
|
934
|
+
*
|
|
935
|
+
* The subscription is the cart manager's and not the client's: the connector
|
|
936
|
+
* adapts the payload on the cart manager's bus and passes it through
|
|
937
|
+
* untouched on the client's, and this element renders the adapted figures.
|
|
938
|
+
*/
|
|
939
|
+
mount: (ctx) => {
|
|
940
|
+
void (async () => {
|
|
941
|
+
let client;
|
|
942
|
+
try {
|
|
943
|
+
client = await ctx.whenClient();
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
if (!ctx.active())
|
|
947
|
+
return;
|
|
948
|
+
ctx.actions.connectFailed(err instanceof Error ? err.message : ctx.strings.loadFailed);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (!ctx.active())
|
|
952
|
+
return;
|
|
953
|
+
const manager = client.getCartManager?.();
|
|
954
|
+
const off = manager?.on?.('cart:updated', (data) => {
|
|
955
|
+
ctx.actions.cartUpdated(data);
|
|
956
|
+
});
|
|
957
|
+
if (typeof off === 'function')
|
|
958
|
+
ctx.onTeardown(off);
|
|
959
|
+
await ctx.actions.load();
|
|
960
|
+
})();
|
|
961
|
+
},
|
|
962
|
+
status: ({ state }) => state.loading ? 'loading' : !state.cart || state.cart.items.length === 0 ? 'empty' : 'ready',
|
|
963
|
+
derive: (ctx) => {
|
|
964
|
+
const { state, strings, props, formatMoney } = ctx;
|
|
965
|
+
// Money comes from the response through cartTotals: the fee row is shown
|
|
966
|
+
// when the surface expressed a fee, and the total is the server's figure,
|
|
967
|
+
// never a sum taken here.
|
|
968
|
+
const totals = cartTotals(state.cart);
|
|
969
|
+
const currency = totals.currency;
|
|
970
|
+
const items = state.cart?.items ?? [];
|
|
971
|
+
return {
|
|
972
|
+
rows: items.map((item) => ({
|
|
973
|
+
id: item.id,
|
|
974
|
+
name: item.ticketTypeName,
|
|
975
|
+
eventName: item.eventName,
|
|
976
|
+
dateLabel: item.occurrenceDate ? formatDate$2(item.occurrenceDate) : null,
|
|
977
|
+
quantityLabel: `${strings.quantityPrefix}${item.quantity}`,
|
|
978
|
+
priceLabel: formatMoney(item.totalPrice, item.currency),
|
|
979
|
+
removing: state.removingItemId === item.id,
|
|
980
|
+
showEvent: !props.compact,
|
|
981
|
+
showDate: !props.compact && !!item.occurrenceDate,
|
|
982
|
+
})),
|
|
983
|
+
totals,
|
|
984
|
+
subtotalLabel: formatMoney(totals.subtotal, currency),
|
|
985
|
+
feeAmountLabel: formatMoney(totals.fees, currency),
|
|
986
|
+
discountLabel: formatMoney(totals.discount, currency),
|
|
987
|
+
totalLabel: formatMoney(totals.total, currency),
|
|
988
|
+
showSubtotalRow: !props.compact,
|
|
989
|
+
showFeeRow: totals.showFee && !props.compact,
|
|
990
|
+
showDiscountRow: totals.discount > 0 && !props.compact,
|
|
991
|
+
checkoutDisabled: items.length === 0,
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
actions: (ctx) => ({
|
|
995
|
+
/** Read the cart as it stands. */
|
|
996
|
+
async load() {
|
|
997
|
+
try {
|
|
998
|
+
ctx.setState({ loading: true, error: null });
|
|
999
|
+
const manager = ctx.optionalClient()?.getCartManager?.();
|
|
1000
|
+
if (manager) {
|
|
1001
|
+
// Bound rather than called optionally: a connector that carries a
|
|
1002
|
+
// cart manager without a `get` is broken, and the component said so
|
|
1003
|
+
// through tlError rather than rendering an empty cart.
|
|
1004
|
+
const read = manager.get?.bind(manager);
|
|
1005
|
+
if (!read)
|
|
1006
|
+
throw new TypeError('cartManager.get is not a function');
|
|
1007
|
+
ctx.setState({ cart: ((await read()) ?? null) });
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
catch (err) {
|
|
1011
|
+
const message = err instanceof Error ? err.message : ctx.strings.loadFailed;
|
|
1012
|
+
ctx.setState({ error: message });
|
|
1013
|
+
ctx.emit('tlError', { message });
|
|
1014
|
+
}
|
|
1015
|
+
finally {
|
|
1016
|
+
ctx.setState({ loading: false });
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
/** The cart changed under us. Show it, and tell the host. */
|
|
1020
|
+
cartUpdated(cart) {
|
|
1021
|
+
ctx.setState({ cart });
|
|
1022
|
+
ctx.emit('tlCartUpdated', { cart });
|
|
1023
|
+
},
|
|
1024
|
+
/**
|
|
1025
|
+
* Take one line out of the cart. With no client at all, nothing happens
|
|
1026
|
+
* and nothing is announced: the buyer's click is simply inert, which is
|
|
1027
|
+
* what it was before.
|
|
1028
|
+
*/
|
|
1029
|
+
async removeItem(itemId) {
|
|
1030
|
+
if (!ctx.optionalClient())
|
|
1031
|
+
return;
|
|
1032
|
+
ctx.setState({ removingItemId: itemId });
|
|
1033
|
+
try {
|
|
1034
|
+
const manager = ctx.client().getCartManager?.();
|
|
1035
|
+
if (manager) {
|
|
1036
|
+
const remove = manager.removeItem?.bind(manager);
|
|
1037
|
+
if (!remove)
|
|
1038
|
+
throw new TypeError('cartManager.removeItem is not a function');
|
|
1039
|
+
await remove(itemId);
|
|
1040
|
+
ctx.emit('tlItemRemoved', { itemId });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
const message = err instanceof Error ? err.message : ctx.strings.removeFailed;
|
|
1045
|
+
ctx.setState({ error: message });
|
|
1046
|
+
ctx.emit('tlError', { message });
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
ctx.setState({ removingItemId: null });
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
/** Hand the cart to whatever the host does next. An empty cart says nothing. */
|
|
1053
|
+
checkout() {
|
|
1054
|
+
const cart = ctx.state.cart;
|
|
1055
|
+
if (cart && cart.items.length > 0)
|
|
1056
|
+
ctx.emit('tlCheckout', { cart });
|
|
1057
|
+
},
|
|
1058
|
+
/**
|
|
1059
|
+
* The host never produced a client. Announced, because the component
|
|
1060
|
+
* announced it: the read and the wait were one try block, so a page that
|
|
1061
|
+
* never started Live emitted tlError.
|
|
1062
|
+
*/
|
|
1063
|
+
connectFailed(message) {
|
|
1064
|
+
const text = message || ctx.strings.loadFailed;
|
|
1065
|
+
ctx.setState({ error: text, loading: false });
|
|
1066
|
+
ctx.emit('tlError', { message: text });
|
|
1067
|
+
},
|
|
1068
|
+
}),
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* `tl-cart-badge`: the cart icon with the item count on it.
|
|
1073
|
+
*
|
|
1074
|
+
* Small, and the second element that lives on a subscription: it binds
|
|
1075
|
+
* `cart:updated` inside its own `mount` and hands the unsubscribe to
|
|
1076
|
+
* `ctx.onTeardown` (TKT-197), so a badge that is moved in the DOM, or rendered
|
|
1077
|
+
* twice by a host that mounts and unmounts it, never ends up counting twice.
|
|
1078
|
+
*
|
|
1079
|
+
* The one thing it remembers is the last count, so the badge does not flash
|
|
1080
|
+
* empty on the next page before the cart has been read. That is behaviour, so
|
|
1081
|
+
* it is here; where the count is written down is per runtime, so that is
|
|
1082
|
+
* `ctx.storage`, which the harness supplies.
|
|
1083
|
+
*/
|
|
1084
|
+
/** Where the last known count is kept. The key the component used. */
|
|
1085
|
+
const CART_COUNT_KEY = 'tl_cart_count';
|
|
1086
|
+
const STRINGS$b = {
|
|
1087
|
+
/** Read as "Cart with 2 items". The count is appended. */
|
|
1088
|
+
ariaPrefix: 'Cart with ',
|
|
1089
|
+
ariaSuffix: ' items',
|
|
1090
|
+
overflow: '99+',
|
|
1091
|
+
};
|
|
1092
|
+
/** Above this the bubble reads "99+" rather than a number nothing can fit. */
|
|
1093
|
+
const OVERFLOW_ABOVE = 99;
|
|
1094
|
+
const countOf = (cart) => {
|
|
1095
|
+
const c = cart;
|
|
1096
|
+
return typeof c?.itemCount === 'number' ? c.itemCount : 0;
|
|
1097
|
+
};
|
|
1098
|
+
const cartBadgeCore = defineElement({
|
|
1099
|
+
tag: 'tl-cart-badge',
|
|
1100
|
+
docs: 'A cart icon carrying the number of items in the cart, kept current from the cart bus.',
|
|
1101
|
+
category: 'cart',
|
|
1102
|
+
runtimes: ['web'],
|
|
1103
|
+
tokens: [
|
|
1104
|
+
'font-family',
|
|
1105
|
+
'foreground',
|
|
1106
|
+
'muted',
|
|
1107
|
+
'primary',
|
|
1108
|
+
'primary-foreground',
|
|
1109
|
+
'radius-md',
|
|
1110
|
+
'spacing-1',
|
|
1111
|
+
'spacing-2',
|
|
1112
|
+
'spacing-3',
|
|
1113
|
+
],
|
|
1114
|
+
sdkMethods: ['cart.get', 'cart.on'],
|
|
1115
|
+
props: {
|
|
1116
|
+
showEmpty: {
|
|
1117
|
+
type: 'boolean',
|
|
1118
|
+
attr: 'show-empty',
|
|
1119
|
+
default: false,
|
|
1120
|
+
docs: 'Show the badge even when count is 0',
|
|
1121
|
+
},
|
|
1122
|
+
icon: {
|
|
1123
|
+
type: 'string',
|
|
1124
|
+
tsType: 'string | undefined',
|
|
1125
|
+
attr: 'icon',
|
|
1126
|
+
docs: 'Custom icon (emoji or text). If not provided, uses cart SVG',
|
|
1127
|
+
},
|
|
1128
|
+
size: {
|
|
1129
|
+
type: 'string',
|
|
1130
|
+
tsType: '"lg" | "md" | "sm"',
|
|
1131
|
+
attr: 'size',
|
|
1132
|
+
default: 'md',
|
|
1133
|
+
docs: 'Size variant',
|
|
1134
|
+
},
|
|
1135
|
+
badgeOnly: {
|
|
1136
|
+
type: 'boolean',
|
|
1137
|
+
attr: 'badge-only',
|
|
1138
|
+
default: false,
|
|
1139
|
+
docs: 'Badge only mode - only shows the count bubble, no icon or button',
|
|
1140
|
+
},
|
|
1141
|
+
},
|
|
1142
|
+
events: {
|
|
1143
|
+
tlClick: { detail: '{ itemCount: number; }', docs: 'Emitted when the badge is clicked' },
|
|
1144
|
+
},
|
|
1145
|
+
strings: STRINGS$b,
|
|
1146
|
+
statuses: ['ready'],
|
|
1147
|
+
state: () => ({ itemCount: 0 }),
|
|
1148
|
+
/**
|
|
1149
|
+
* On screen: show the remembered count straight away, then wait for the
|
|
1150
|
+
* client, subscribe, and read the cart.
|
|
1151
|
+
*
|
|
1152
|
+
* Hydrating first is what keeps the badge from flashing empty; the component
|
|
1153
|
+
* did it in `componentWillLoad` and this runs earlier still, in the harness's
|
|
1154
|
+
* `connectedCallback`. A host with no store simply starts at zero.
|
|
1155
|
+
*/
|
|
1156
|
+
mount: (ctx) => {
|
|
1157
|
+
ctx.actions.hydrate();
|
|
1158
|
+
void (async () => {
|
|
1159
|
+
let client;
|
|
1160
|
+
try {
|
|
1161
|
+
client = await ctx.whenClient();
|
|
1162
|
+
}
|
|
1163
|
+
catch {
|
|
1164
|
+
// A page that never started Live keeps the remembered count and says
|
|
1165
|
+
// nothing, which is what the component did.
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (!ctx.active())
|
|
1169
|
+
return;
|
|
1170
|
+
const manager = client.getCartManager?.();
|
|
1171
|
+
if (!manager)
|
|
1172
|
+
return;
|
|
1173
|
+
const off = manager.on?.('cart:updated', (cart) => {
|
|
1174
|
+
ctx.actions.countChanged(countOf(cart));
|
|
1175
|
+
});
|
|
1176
|
+
if (typeof off === 'function')
|
|
1177
|
+
ctx.onTeardown(off);
|
|
1178
|
+
try {
|
|
1179
|
+
const read = manager.get?.bind(manager);
|
|
1180
|
+
if (!read)
|
|
1181
|
+
return;
|
|
1182
|
+
const cart = await read();
|
|
1183
|
+
if (!ctx.active())
|
|
1184
|
+
return;
|
|
1185
|
+
ctx.actions.countChanged(countOf(cart));
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
// The cart may not exist yet. Keep the remembered value.
|
|
1189
|
+
}
|
|
1190
|
+
})();
|
|
1191
|
+
},
|
|
1192
|
+
status: () => 'ready',
|
|
1193
|
+
derive: ({ state, props, strings }) => ({
|
|
1194
|
+
displayCount: state.itemCount > OVERFLOW_ABOVE ? strings.overflow : state.itemCount.toString(),
|
|
1195
|
+
showBadge: state.itemCount > 0,
|
|
1196
|
+
renderNothing: props.badgeOnly && state.itemCount === 0,
|
|
1197
|
+
ariaLabel: `${strings.ariaPrefix}${state.itemCount}${strings.ariaSuffix}`,
|
|
1198
|
+
sizeClass: `size-${props.size}`,
|
|
1199
|
+
}),
|
|
1200
|
+
actions: (ctx) => ({
|
|
1201
|
+
/** A new count from the cart: show it, and remember it for next time. */
|
|
1202
|
+
countChanged(count) {
|
|
1203
|
+
ctx.setState({ itemCount: count });
|
|
1204
|
+
ctx.storage.set(CART_COUNT_KEY, String(count));
|
|
1205
|
+
},
|
|
1206
|
+
/** Start from whatever the last visit left behind, or zero. */
|
|
1207
|
+
hydrate() {
|
|
1208
|
+
const stored = ctx.storage.get(CART_COUNT_KEY);
|
|
1209
|
+
ctx.setState({ itemCount: (stored ? parseInt(stored, 10) : 0) || 0 });
|
|
1210
|
+
},
|
|
1211
|
+
/** The buyer asked for the cart. What that opens is the host's business. */
|
|
1212
|
+
click() {
|
|
1213
|
+
ctx.emit('tlClick', { itemCount: ctx.state.itemCount });
|
|
1214
|
+
},
|
|
1215
|
+
}),
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* `tl-cart-drawer`: the off-canvas panel the cart slides into.
|
|
1220
|
+
*
|
|
1221
|
+
* It owns no cart of its own - the panel embeds `tl-cart` - so what is here is
|
|
1222
|
+
* the open/closed rule and the two bus intents it listens for, bound inside its
|
|
1223
|
+
* own `mount` and unbound by the teardown (TKT-197).
|
|
1224
|
+
*
|
|
1225
|
+
* Two ways in, and they are not the same, which is the subtlety this core has
|
|
1226
|
+
* to keep: an intent, the backdrop, the close button and Escape all go through
|
|
1227
|
+
* `setOpen`, which announces the change with `tlOpen` or `tlClose`. The host
|
|
1228
|
+
* setting the `open` prop goes through `openChanged`, which does not: the
|
|
1229
|
+
* component's `@Watch('open')` moved the panel without emitting, and a host
|
|
1230
|
+
* that drove the drawer and also listened would otherwise start hearing its
|
|
1231
|
+
* own writes back.
|
|
1232
|
+
*
|
|
1233
|
+
* The subscription is the client's bus and not the cart manager's: these are
|
|
1234
|
+
* UI intents, which `@ticketlayer/live` relays untouched, and nothing here
|
|
1235
|
+
* reads the cart.
|
|
1236
|
+
*/
|
|
1237
|
+
/** What opens the drawer, and what closes it. Relayed on the client's bus. */
|
|
1238
|
+
const OPEN_INTENT = 'ui:cart-requested';
|
|
1239
|
+
const CLOSE_INTENT = 'ui:modal-close-requested';
|
|
1240
|
+
const STRINGS$a = {
|
|
1241
|
+
close: 'Close cart',
|
|
1242
|
+
privacy: 'Privacy Policy',
|
|
1243
|
+
privacyHref: 'https://ticketlayer.com/privacy',
|
|
1244
|
+
reportAbuse: 'Report Abuse',
|
|
1245
|
+
reportAbuseHref: 'https://ticketlayer.com/report-abuse',
|
|
1246
|
+
poweredBy: 'Powered by',
|
|
1247
|
+
brand: 'ticketlayer',
|
|
1248
|
+
brandHref: 'https://ticketlayer.com',
|
|
1249
|
+
};
|
|
1250
|
+
const cartDrawerCore = defineElement({
|
|
1251
|
+
tag: 'tl-cart-drawer',
|
|
1252
|
+
docs: 'A slide-in panel holding the cart, opened by the cart intent on the client bus.',
|
|
1253
|
+
category: 'cart',
|
|
1254
|
+
runtimes: ['web'],
|
|
1255
|
+
tokens: ['background', 'border', 'font-family', 'foreground', 'muted', 'muted-foreground'],
|
|
1256
|
+
sdkMethods: ['client.on'],
|
|
1257
|
+
props: {
|
|
1258
|
+
side: {
|
|
1259
|
+
type: 'string',
|
|
1260
|
+
tsType: '"left" | "right"',
|
|
1261
|
+
attr: 'side',
|
|
1262
|
+
default: 'right',
|
|
1263
|
+
docs: 'Edge the panel slides in from.',
|
|
1264
|
+
},
|
|
1265
|
+
heading: {
|
|
1266
|
+
type: 'string',
|
|
1267
|
+
attr: 'heading',
|
|
1268
|
+
default: 'Your cart',
|
|
1269
|
+
docs: 'Heading shown at the top of the panel.',
|
|
1270
|
+
},
|
|
1271
|
+
open: {
|
|
1272
|
+
type: 'boolean',
|
|
1273
|
+
attr: 'open',
|
|
1274
|
+
default: false,
|
|
1275
|
+
docs: 'Controlled open state (also driven by the `ui:cart-requested` intent).',
|
|
1276
|
+
},
|
|
1277
|
+
},
|
|
1278
|
+
events: {
|
|
1279
|
+
tlOpen: { detail: 'void', docs: '' },
|
|
1280
|
+
tlClose: { detail: 'void', docs: '' },
|
|
1281
|
+
},
|
|
1282
|
+
strings: STRINGS$a,
|
|
1283
|
+
statuses: ['open', 'closed'],
|
|
1284
|
+
state: (props) => ({ visible: props.open }),
|
|
1285
|
+
/**
|
|
1286
|
+
* On screen: take the `open` prop as it stands, then listen for the intents.
|
|
1287
|
+
*
|
|
1288
|
+
* Taking the prop again is what the component's `connectedCallback` did, and
|
|
1289
|
+
* it matters here for a second reason: an instance is built before a harness
|
|
1290
|
+
* has read its attributes, so the state a constructor could see is the
|
|
1291
|
+
* default rather than the host's.
|
|
1292
|
+
*/
|
|
1293
|
+
mount: (ctx) => {
|
|
1294
|
+
ctx.actions.openChanged(ctx.props.open);
|
|
1295
|
+
void (async () => {
|
|
1296
|
+
let client;
|
|
1297
|
+
try {
|
|
1298
|
+
client = await ctx.whenClient();
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
// No client, no intents. The drawer still opens from its `open` prop.
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (!ctx.active())
|
|
1305
|
+
return;
|
|
1306
|
+
const offOpen = client.on?.(OPEN_INTENT, () => ctx.actions.setOpen(true));
|
|
1307
|
+
if (typeof offOpen === 'function')
|
|
1308
|
+
ctx.onTeardown(offOpen);
|
|
1309
|
+
const offClose = client.on?.(CLOSE_INTENT, () => ctx.actions.setOpen(false));
|
|
1310
|
+
if (typeof offClose === 'function')
|
|
1311
|
+
ctx.onTeardown(offClose);
|
|
1312
|
+
})();
|
|
1313
|
+
},
|
|
1314
|
+
status: ({ state }) => (state.visible ? 'open' : 'closed'),
|
|
1315
|
+
derive: ({ state, props }) => ({
|
|
1316
|
+
visible: state.visible,
|
|
1317
|
+
sideClass: `panel--${props.side}`,
|
|
1318
|
+
ariaHidden: state.visible ? 'false' : 'true',
|
|
1319
|
+
}),
|
|
1320
|
+
actions: (ctx) => ({
|
|
1321
|
+
/** Open or close, and say so. Asking for the state it is already in is nothing. */
|
|
1322
|
+
setOpen(open) {
|
|
1323
|
+
if (ctx.state.visible === open)
|
|
1324
|
+
return;
|
|
1325
|
+
ctx.setState({ visible: open });
|
|
1326
|
+
ctx.emit(open ? 'tlOpen' : 'tlClose', undefined);
|
|
1327
|
+
},
|
|
1328
|
+
/** The host moved the `open` prop. Follow it, and stay quiet about it. */
|
|
1329
|
+
openChanged(open) {
|
|
1330
|
+
if (ctx.state.visible === open)
|
|
1331
|
+
return;
|
|
1332
|
+
ctx.setState({ visible: open });
|
|
1333
|
+
},
|
|
1334
|
+
}),
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* `tl-checkout-wrapper`: the same idea as `tl-buy-tickets-wrapper`, pointed at
|
|
1339
|
+
* the checkout rather than at one event.
|
|
1340
|
+
*
|
|
1341
|
+
* Whatever the host slots in becomes the thing that opens the checkout modal
|
|
1342
|
+
* for the cart as it stands, so this core takes no subject at all: the cart is
|
|
1343
|
+
* the client's, and the modal is the client's.
|
|
1344
|
+
*
|
|
1345
|
+
* It was blocked from being split by the same thing its sibling was - events
|
|
1346
|
+
* named `tl-complete`, `tl-error` and `tl-close`, which `defineElement`
|
|
1347
|
+
* refuses - and is unblocked by the same rename to `tlComplete`, `tlError` and
|
|
1348
|
+
* `tlClose` (TKT-203).
|
|
1349
|
+
*
|
|
1350
|
+
* Behaviour is what the Stencil component did, to the letter, including the
|
|
1351
|
+
* `tlError` a click announces when no client has been published; the
|
|
1352
|
+
* `console.error` beside it did not come across, for the reason `tl-cart` and
|
|
1353
|
+
* `tl-promo-code` dropped theirs.
|
|
1354
|
+
*/
|
|
1355
|
+
const STRINGS$9 = {
|
|
1356
|
+
noClient: 'SDK not initialized',
|
|
1357
|
+
notAvailable: 'Checkout is not available',
|
|
1358
|
+
};
|
|
1359
|
+
const checkoutWrapperCore = defineElement({
|
|
1360
|
+
tag: 'tl-checkout-wrapper',
|
|
1361
|
+
docs: 'Wrap any content and open the checkout modal when it is activated.',
|
|
1362
|
+
category: 'checkout',
|
|
1363
|
+
runtimes: ['web'],
|
|
1364
|
+
tokens: ['radius-sm', 'ring'],
|
|
1365
|
+
sdkMethods: ['checkout.openModal'],
|
|
1366
|
+
props: {
|
|
1367
|
+
disabled: {
|
|
1368
|
+
type: 'boolean',
|
|
1369
|
+
attr: 'disabled',
|
|
1370
|
+
default: false,
|
|
1371
|
+
docs: 'Whether the wrapper is disabled',
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
events: {
|
|
1375
|
+
// `OrderResult` because that is the name the web harness writes; see
|
|
1376
|
+
// `buy-tickets-wrapper.ts`.
|
|
1377
|
+
tlComplete: { detail: 'OrderResult', docs: 'Emitted when checkout completes successfully' },
|
|
1378
|
+
tlError: { detail: '{ message: string; }', docs: 'Emitted when an error occurs' },
|
|
1379
|
+
tlClose: { detail: 'void', docs: 'Emitted when the modal closes' },
|
|
1380
|
+
},
|
|
1381
|
+
strings: STRINGS$9,
|
|
1382
|
+
statuses: ['ready', 'disabled'],
|
|
1383
|
+
state: () => ({}),
|
|
1384
|
+
status: ({ props }) => (props.disabled ? 'disabled' : 'ready'),
|
|
1385
|
+
derive: ({ props }) => ({ interactive: !props.disabled }),
|
|
1386
|
+
actions: (ctx) => ({
|
|
1387
|
+
/** A click, a tap, Enter or Space: whatever the harness calls a press. */
|
|
1388
|
+
activate() {
|
|
1389
|
+
if (ctx.props.disabled)
|
|
1390
|
+
return;
|
|
1391
|
+
const client = ctx.optionalClient();
|
|
1392
|
+
if (!client) {
|
|
1393
|
+
ctx.emit('tlError', { message: ctx.strings.noClient });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
if (!client.checkout) {
|
|
1397
|
+
ctx.emit('tlError', { message: ctx.strings.notAvailable });
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
client.checkout.openModal({
|
|
1401
|
+
onComplete: (order) => ctx.emit('tlComplete', order),
|
|
1402
|
+
onError: (err) => ctx.emit('tlError', { message: err.message }),
|
|
1403
|
+
onClose: () => ctx.emit('tlClose', undefined),
|
|
1404
|
+
});
|
|
1405
|
+
},
|
|
1406
|
+
}),
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* `tl-event-card`: one event, as a card.
|
|
1411
|
+
*
|
|
1412
|
+
* Two ways to fill it, which is the whole of its behaviour: a host hands it an
|
|
1413
|
+
* event through the `event` prop (that is what `tl-event-list` does), or it is
|
|
1414
|
+
* given an `eventId` and reads the event itself. The prop wins, and a card that
|
|
1415
|
+
* has one never touches the client.
|
|
1416
|
+
*
|
|
1417
|
+
* Behaviour is what the Stencil component did before the split, to the letter,
|
|
1418
|
+
* including the status badge's three labels and the `en-GB` date. What did not
|
|
1419
|
+
* come across is the `console.log` tracing, which a library should not do for
|
|
1420
|
+
* its host.
|
|
1421
|
+
*/
|
|
1422
|
+
/**
|
|
1423
|
+
* Adapt an event detail to the card shape. The list adapter lives in the SDK
|
|
1424
|
+
* connector; this one is the card's own, and it is behaviour, so it is here.
|
|
1425
|
+
*/
|
|
1426
|
+
function toEventCardEvent(detail) {
|
|
1427
|
+
const occurrences = detail.occurrences ?? [];
|
|
1428
|
+
const starts = occurrences
|
|
1429
|
+
.map((o) => o.startsAt)
|
|
1430
|
+
.filter((s) => !!s)
|
|
1431
|
+
.sort();
|
|
1432
|
+
const now = Date.now();
|
|
1433
|
+
const next = starts.find((s) => new Date(s).getTime() >= now) ?? starts[0];
|
|
1434
|
+
const soldOut = detail.status === 'sold_out' ||
|
|
1435
|
+
(occurrences.length > 0 && occurrences.every((o) => o.status === 'sold_out'));
|
|
1436
|
+
return {
|
|
1437
|
+
id: detail.id,
|
|
1438
|
+
name: detail.name,
|
|
1439
|
+
...(detail.shortDescription ? { shortDescription: detail.shortDescription } : {}),
|
|
1440
|
+
...(detail.imageUrl ? { imageUrl: detail.imageUrl } : {}),
|
|
1441
|
+
...(detail.venueName
|
|
1442
|
+
? { venue: { id: detail.venueId ?? '', name: detail.venueName, city: detail.venueCity ?? '' } }
|
|
1443
|
+
: {}),
|
|
1444
|
+
...(next ? { nextOccurrence: { startsAt: next } } : {}),
|
|
1445
|
+
availabilityStatus: soldOut ? 'sold_out' : 'available',
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
const STRINGS$8 = {
|
|
1449
|
+
available: 'Available',
|
|
1450
|
+
limited: 'Limited',
|
|
1451
|
+
soldOut: 'Sold Out',
|
|
1452
|
+
notFound: 'Event not found',
|
|
1453
|
+
loadFailed: 'Failed to load event',
|
|
1454
|
+
};
|
|
1455
|
+
/** As the component formatted it. See the note on the cart core's date. */
|
|
1456
|
+
function formatDate$1(dateString) {
|
|
1457
|
+
return new Date(dateString).toLocaleDateString('en-GB', {
|
|
1458
|
+
weekday: 'short',
|
|
1459
|
+
day: 'numeric',
|
|
1460
|
+
month: 'short',
|
|
1461
|
+
hour: '2-digit',
|
|
1462
|
+
minute: '2-digit',
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
const eventCardCore = defineElement({
|
|
1466
|
+
tag: 'tl-event-card',
|
|
1467
|
+
docs: 'One event as a card: its image, name, next date, venue and how it is selling.',
|
|
1468
|
+
category: 'discovery',
|
|
1469
|
+
runtimes: ['web', 'native'],
|
|
1470
|
+
tokens: [
|
|
1471
|
+
'error',
|
|
1472
|
+
'error-foreground',
|
|
1473
|
+
'font-family',
|
|
1474
|
+
'foreground',
|
|
1475
|
+
'muted',
|
|
1476
|
+
'muted-foreground',
|
|
1477
|
+
'primary',
|
|
1478
|
+
'radius-lg',
|
|
1479
|
+
'radius-sm',
|
|
1480
|
+
'secondary',
|
|
1481
|
+
'spacing-4',
|
|
1482
|
+
'success',
|
|
1483
|
+
'success-foreground',
|
|
1484
|
+
'warning',
|
|
1485
|
+
'warning-foreground',
|
|
1486
|
+
],
|
|
1487
|
+
sdkMethods: ['events.get'],
|
|
1488
|
+
props: {
|
|
1489
|
+
event: {
|
|
1490
|
+
type: 'object',
|
|
1491
|
+
tsType: 'TlEventCardEvent | undefined',
|
|
1492
|
+
attr: null,
|
|
1493
|
+
docs: 'Event data to display (tl-event-list passes this)',
|
|
1494
|
+
},
|
|
1495
|
+
eventId: {
|
|
1496
|
+
type: 'string',
|
|
1497
|
+
tsType: 'string | undefined',
|
|
1498
|
+
attr: 'event-id',
|
|
1499
|
+
docs: 'Load the event from the SDK by id when no `event` is given, so a single\ncard works standalone (e.g. on a Webflow page).',
|
|
1500
|
+
},
|
|
1501
|
+
},
|
|
1502
|
+
events: {
|
|
1503
|
+
tlClick: { detail: '{ eventId: string; }', docs: 'Emitted when the card is clicked' },
|
|
1504
|
+
tlError: { detail: '{ message: string; }', docs: 'Emitted when the event could not be loaded by id' },
|
|
1505
|
+
},
|
|
1506
|
+
strings: STRINGS$8,
|
|
1507
|
+
statuses: ['loading', 'error', 'empty', 'ready'],
|
|
1508
|
+
state: () => ({ loadedEvent: null, loading: false, error: null }),
|
|
1509
|
+
/**
|
|
1510
|
+
* On screen: read the event, but only when there is one to read and nobody
|
|
1511
|
+
* has handed us the event already.
|
|
1512
|
+
*
|
|
1513
|
+
* `loading` is set here, synchronously, rather than inside the read. The
|
|
1514
|
+
* component awaited its load in `componentWillLoad`, so its first paint was
|
|
1515
|
+
* already past it; a mount hook cannot hold the first paint, so the card
|
|
1516
|
+
* shows its own skeleton instead of a blank. That is the same change
|
|
1517
|
+
* `tl-ticket-selector` took when it moved onto the hook (TKT-197).
|
|
1518
|
+
*/
|
|
1519
|
+
mount: (ctx) => {
|
|
1520
|
+
if (ctx.props.event || !ctx.props.eventId)
|
|
1521
|
+
return;
|
|
1522
|
+
ctx.setState({ loading: true, error: null });
|
|
1523
|
+
void (async () => {
|
|
1524
|
+
try {
|
|
1525
|
+
await ctx.whenClient();
|
|
1526
|
+
}
|
|
1527
|
+
catch (err) {
|
|
1528
|
+
if (!ctx.active())
|
|
1529
|
+
return;
|
|
1530
|
+
ctx.actions.loadFailed(err instanceof Error && err.message ? err.message : ctx.strings.loadFailed);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (!ctx.active())
|
|
1534
|
+
return;
|
|
1535
|
+
await ctx.actions.load();
|
|
1536
|
+
})();
|
|
1537
|
+
},
|
|
1538
|
+
status: ({ state, props }) => state.loading ? 'loading' : state.error ? 'error' : props.event || state.loadedEvent ? 'ready' : 'empty',
|
|
1539
|
+
derive: ({ state, props, strings }) => {
|
|
1540
|
+
const event = props.event ?? state.loadedEvent;
|
|
1541
|
+
const badge = (suffix) => `status-badge ${suffix}`;
|
|
1542
|
+
const status = event?.availabilityStatus;
|
|
1543
|
+
return {
|
|
1544
|
+
event,
|
|
1545
|
+
// The sales surface exposes a single `imageUrl` (card/listing
|
|
1546
|
+
// thumbnail); `thumbnailUrl` is an accepted alias if a host ever
|
|
1547
|
+
// supplies a distinct one.
|
|
1548
|
+
image: event ? event.thumbnailUrl || event.imageUrl || null : null,
|
|
1549
|
+
statusBadgeClass: status === 'available'
|
|
1550
|
+
? badge('status-available')
|
|
1551
|
+
: status === 'limited'
|
|
1552
|
+
? badge('status-limited')
|
|
1553
|
+
: status === 'sold_out'
|
|
1554
|
+
? badge('status-sold-out')
|
|
1555
|
+
: 'status-badge',
|
|
1556
|
+
statusLabel: status === 'available'
|
|
1557
|
+
? strings.available
|
|
1558
|
+
: status === 'limited'
|
|
1559
|
+
? strings.limited
|
|
1560
|
+
: status === 'sold_out'
|
|
1561
|
+
? strings.soldOut
|
|
1562
|
+
: '',
|
|
1563
|
+
dateLabel: event?.nextOccurrence ? formatDate$1(event.nextOccurrence.startsAt) : null,
|
|
1564
|
+
venueLabel: event?.venue ? `${event.venue.name}, ${event.venue.city}` : null,
|
|
1565
|
+
};
|
|
1566
|
+
},
|
|
1567
|
+
actions: (ctx) => ({
|
|
1568
|
+
/**
|
|
1569
|
+
* Read the event by id. A card that was handed an event, or has no id, has
|
|
1570
|
+
* nothing to read, and this is the guard the component's watcher relied on.
|
|
1571
|
+
*/
|
|
1572
|
+
async load() {
|
|
1573
|
+
if (ctx.props.event || !ctx.props.eventId)
|
|
1574
|
+
return;
|
|
1575
|
+
try {
|
|
1576
|
+
ctx.setState({ loading: true, error: null });
|
|
1577
|
+
const events = ctx.client().getEventsManager();
|
|
1578
|
+
const read = events.get?.bind(events);
|
|
1579
|
+
if (!read)
|
|
1580
|
+
throw new TypeError('eventsManager.get is not a function');
|
|
1581
|
+
const detail = (await read(ctx.props.eventId));
|
|
1582
|
+
if (!detail)
|
|
1583
|
+
throw new Error(ctx.strings.notFound);
|
|
1584
|
+
ctx.setState({ loadedEvent: toEventCardEvent(detail) });
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
const message = err instanceof Error && err.message ? err.message : ctx.strings.loadFailed;
|
|
1588
|
+
ctx.setState({ error: message });
|
|
1589
|
+
ctx.emit('tlError', { message });
|
|
1590
|
+
}
|
|
1591
|
+
finally {
|
|
1592
|
+
ctx.setState({ loading: false });
|
|
1593
|
+
}
|
|
1594
|
+
},
|
|
1595
|
+
/**
|
|
1596
|
+
* The host never produced a client. Announced, because the component
|
|
1597
|
+
* announced it: the wait and the read were one try block.
|
|
1598
|
+
*/
|
|
1599
|
+
loadFailed(message) {
|
|
1600
|
+
const text = message || ctx.strings.loadFailed;
|
|
1601
|
+
ctx.setState({ error: text, loading: false });
|
|
1602
|
+
ctx.emit('tlError', { message: text });
|
|
1603
|
+
},
|
|
1604
|
+
/** The buyer picked this card. Where that goes is the host's business. */
|
|
1605
|
+
click() {
|
|
1606
|
+
const event = ctx.props.event ?? ctx.state.loadedEvent;
|
|
1607
|
+
if (!event)
|
|
1608
|
+
return;
|
|
1609
|
+
ctx.emit('tlClick', { eventId: event.id });
|
|
1610
|
+
},
|
|
1611
|
+
}),
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* `tl-event-list`: what the channel is selling, as a grid of cards.
|
|
1616
|
+
*
|
|
1617
|
+
* One read, and a re-read whenever the host changes what it is asking for. The
|
|
1618
|
+
* cards themselves are `tl-event-card`, which has its own core; this one
|
|
1619
|
+
* decides only what to ask for and what the grid says while there is nothing
|
|
1620
|
+
* in it.
|
|
1621
|
+
*
|
|
1622
|
+
* Behaviour is what the Stencil component did, to the letter, including the
|
|
1623
|
+
* two things that read like oversights and are kept:
|
|
1624
|
+
*
|
|
1625
|
+
* - there is NO error event. A failed listing is drawn and never announced,
|
|
1626
|
+
* which makes this the only element of the eighteen that can fail silently
|
|
1627
|
+
* as far as a host is concerned. Adding `tlError` would be a new event on a
|
|
1628
|
+
* published element, so it is a decision for somebody rather than something
|
|
1629
|
+
* to slip into a refactor.
|
|
1630
|
+
* - the element paints nothing until the first listing lands. It holds
|
|
1631
|
+
* skeleton markup and uses it for every read but the first, because the
|
|
1632
|
+
* component awaited its read in `componentWillLoad` and Stencil does not
|
|
1633
|
+
* paint until that resolves. The harness keeps that by waiting on the same
|
|
1634
|
+
* thing; the core simply reports `loading` and lets the harness decide when
|
|
1635
|
+
* a first paint happens, which is a rendering question.
|
|
1636
|
+
*
|
|
1637
|
+
* What did not come across is the `console.log` tracing, which a library
|
|
1638
|
+
* should not do for its host (`tl-cart` dropped its own for the same reason).
|
|
1639
|
+
*/
|
|
1640
|
+
const STRINGS$7 = {
|
|
1641
|
+
errorTitle: 'Error loading events',
|
|
1642
|
+
emptyTitle: 'No events available',
|
|
1643
|
+
emptyMessage: 'Check back later for upcoming events.',
|
|
1644
|
+
loadFailed: 'Failed to load events',
|
|
1645
|
+
noListing: 'Events are not available',
|
|
1646
|
+
};
|
|
1647
|
+
/** The grid has a class per column count, and only four of them. */
|
|
1648
|
+
const MIN_COLUMNS = 1;
|
|
1649
|
+
const MAX_COLUMNS = 4;
|
|
1650
|
+
const eventListCore = defineElement({
|
|
1651
|
+
tag: 'tl-event-list',
|
|
1652
|
+
docs: "What the channel is selling, as a grid of event cards, optionally narrowed to one category.",
|
|
1653
|
+
category: 'discovery',
|
|
1654
|
+
runtimes: ['web', 'native'],
|
|
1655
|
+
tokens: [
|
|
1656
|
+
'error',
|
|
1657
|
+
'font-family',
|
|
1658
|
+
'foreground',
|
|
1659
|
+
'muted',
|
|
1660
|
+
'muted-foreground',
|
|
1661
|
+
'radius-lg',
|
|
1662
|
+
'radius-sm',
|
|
1663
|
+
'secondary',
|
|
1664
|
+
'spacing-4',
|
|
1665
|
+
'spacing-6',
|
|
1666
|
+
'spacing-8',
|
|
1667
|
+
],
|
|
1668
|
+
sdkMethods: ['events.list'],
|
|
1669
|
+
props: {
|
|
1670
|
+
limit: { type: 'number', attr: 'limit', default: 12, docs: 'Maximum number of events to display' },
|
|
1671
|
+
category: {
|
|
1672
|
+
type: 'string',
|
|
1673
|
+
tsType: 'string | undefined',
|
|
1674
|
+
attr: 'category',
|
|
1675
|
+
docs: 'Category filter',
|
|
1676
|
+
},
|
|
1677
|
+
columns: { type: 'number', attr: 'columns', default: 3, docs: 'Columns for grid layout (1, 2, 3, or 4)' },
|
|
1678
|
+
},
|
|
1679
|
+
events: {
|
|
1680
|
+
tlEventClick: { detail: '{ eventId: string; }', docs: 'Emitted when an event card is clicked' },
|
|
1681
|
+
},
|
|
1682
|
+
strings: STRINGS$7,
|
|
1683
|
+
statuses: ['loading', 'error', 'empty', 'ready'],
|
|
1684
|
+
state: () => ({ events: [], loading: true, error: null }),
|
|
1685
|
+
mount: (ctx) => {
|
|
1686
|
+
void ctx.actions.load();
|
|
1687
|
+
},
|
|
1688
|
+
status: ({ state }) => state.loading ? 'loading' : state.error ? 'error' : state.events.length === 0 ? 'empty' : 'ready',
|
|
1689
|
+
derive: ({ state, props }) => ({
|
|
1690
|
+
events: state.events,
|
|
1691
|
+
columns: Math.min(Math.max(props.columns, MIN_COLUMNS), MAX_COLUMNS),
|
|
1692
|
+
}),
|
|
1693
|
+
actions: (ctx) => ({
|
|
1694
|
+
/**
|
|
1695
|
+
* Read the listing. Also what a harness calls when the limit or the
|
|
1696
|
+
* category changes, because a different question deserves a fresh answer
|
|
1697
|
+
* and the previous one is not narrowed down to it.
|
|
1698
|
+
*/
|
|
1699
|
+
async load() {
|
|
1700
|
+
try {
|
|
1701
|
+
ctx.setState({ loading: true, error: null });
|
|
1702
|
+
const client = await ctx.whenClient();
|
|
1703
|
+
const events = client.getEventsManager();
|
|
1704
|
+
if (!events?.list)
|
|
1705
|
+
throw new TypeError(ctx.strings.noListing);
|
|
1706
|
+
const response = (await events.list({ limit: ctx.props.limit, category: ctx.props.category }));
|
|
1707
|
+
ctx.setState({ events: (response?.items ?? []) });
|
|
1708
|
+
}
|
|
1709
|
+
catch (err) {
|
|
1710
|
+
// Shown and not announced: this element has no error event.
|
|
1711
|
+
ctx.setState({ error: err instanceof Error ? err.message : ctx.strings.loadFailed });
|
|
1712
|
+
}
|
|
1713
|
+
finally {
|
|
1714
|
+
ctx.setState({ loading: false });
|
|
1715
|
+
}
|
|
1716
|
+
},
|
|
1717
|
+
}),
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* `tl-login`: magic-link sign-in. No password is handled anywhere in here.
|
|
1722
|
+
*
|
|
1723
|
+
* The customer enters an email, the client is asked to send a link, and the
|
|
1724
|
+
* code that comes back is exchanged for a session either because the link
|
|
1725
|
+
* landed on this page with `?code=` or because the customer typed the six
|
|
1726
|
+
* digits out of the email. Both go through the same `exchange` action.
|
|
1727
|
+
*
|
|
1728
|
+
* The first core to read {@link ActionContext.query}, the seam this split
|
|
1729
|
+
* added. An element that is the far end of an emailed link has to be able to
|
|
1730
|
+
* see what the link said, and where that is written down is per runtime: the
|
|
1731
|
+
* web has `window.location.search`, a native harness has its deep-link router.
|
|
1732
|
+
* So the core says it reads `code`, once, on mount, and the harness says where
|
|
1733
|
+
* from. A harness that supplies no query reads null and the element simply
|
|
1734
|
+
* starts on the email step, which is what a page with no code in its URL does
|
|
1735
|
+
* anyway.
|
|
1736
|
+
*
|
|
1737
|
+
* Behaviour is what the Stencil component did, to the letter, including:
|
|
1738
|
+
*
|
|
1739
|
+
* - the URL code is single use. It is taken once per instance and never read
|
|
1740
|
+
* again, so a remount cannot spend a code that was already exchanged.
|
|
1741
|
+
* - a failed exchange goes back to where it came from, and the two ways in
|
|
1742
|
+
* are told apart: a typed code returns to the code step, a URL code returns
|
|
1743
|
+
* to the email step.
|
|
1744
|
+
* - a failed sign out is swallowed. The customer is shown as signed out and
|
|
1745
|
+
* `tlAuthChanged` says so, because the session is gone locally either way,
|
|
1746
|
+
* and that is what the component did.
|
|
1747
|
+
*
|
|
1748
|
+
* What did not come across is the `console.error` tracing, which a library
|
|
1749
|
+
* should not do for its host (`tl-cart` dropped its own for the same reason).
|
|
1750
|
+
*/
|
|
1751
|
+
const STRINGS$6 = {
|
|
1752
|
+
signedInPrefix: 'Signed in as ',
|
|
1753
|
+
fallbackAccount: 'your account',
|
|
1754
|
+
signOut: 'Sign out',
|
|
1755
|
+
verifyingTitle: 'Signing you in…',
|
|
1756
|
+
verifyingLead: 'Just a moment.',
|
|
1757
|
+
sentTitle: 'Check your inbox',
|
|
1758
|
+
// The lead reads "We've sent a sign-in link to <strong>you@…</strong>. Open
|
|
1759
|
+
// it on this device to continue." Split in two because the address is
|
|
1760
|
+
// emphasised in the middle of it, and the shape has no way to say "this copy
|
|
1761
|
+
// has a slot in it" - see the README's note on what a core still cannot
|
|
1762
|
+
// express.
|
|
1763
|
+
sentLeadBefore: "We've sent a sign-in link to ",
|
|
1764
|
+
sentLeadAfter: '. Open it on this device to continue.',
|
|
1765
|
+
sentAgain: 'Sent again. Check your inbox.',
|
|
1766
|
+
resendLink: 'Resend link',
|
|
1767
|
+
resending: 'Sending…',
|
|
1768
|
+
enterCodeInstead: 'Enter the code instead',
|
|
1769
|
+
differentEmail: 'Use a different email',
|
|
1770
|
+
codeTitle: 'Enter your code',
|
|
1771
|
+
codeLeadBefore: 'Type the 6-digit code from the email we sent to ',
|
|
1772
|
+
codeLeadAfter: '.',
|
|
1773
|
+
codeLabel: 'Code',
|
|
1774
|
+
codePlaceholder: '123456',
|
|
1775
|
+
codeSubmit: 'Sign in',
|
|
1776
|
+
checking: 'Checking…',
|
|
1777
|
+
back: 'Back',
|
|
1778
|
+
emailLead: "Enter your email and we'll send you a secure sign-in link. No password needed.",
|
|
1779
|
+
emailLabel: 'Email address',
|
|
1780
|
+
emailPlaceholder: 'you@example.com',
|
|
1781
|
+
emailSubmit: 'Email me a sign-in link',
|
|
1782
|
+
sendingLink: 'Sending link…',
|
|
1783
|
+
enterEmail: 'Enter your email address',
|
|
1784
|
+
enterCode: 'Enter the code from your email',
|
|
1785
|
+
codeRejected: 'That code did not work. Check it and try again, or request a new link.',
|
|
1786
|
+
requestFailed: 'Could not send your sign-in link',
|
|
1787
|
+
unavailable: 'Sign-in is not available right now',
|
|
1788
|
+
unsupported: 'Sign-in is not available with this SDK',
|
|
1789
|
+
};
|
|
1790
|
+
const loginCore = defineElement({
|
|
1791
|
+
tag: 'tl-login',
|
|
1792
|
+
docs: 'Magic-link sign-in: an email in, a link or a six-digit code back, and a session. No password is ever handled.',
|
|
1793
|
+
category: 'account',
|
|
1794
|
+
runtimes: ['web', 'native'],
|
|
1795
|
+
tokens: [
|
|
1796
|
+
'background',
|
|
1797
|
+
'border',
|
|
1798
|
+
'error',
|
|
1799
|
+
'font-family',
|
|
1800
|
+
'font-family-mono',
|
|
1801
|
+
'foreground',
|
|
1802
|
+
'muted-foreground',
|
|
1803
|
+
'primary',
|
|
1804
|
+
'primary-foreground',
|
|
1805
|
+
'radius-lg',
|
|
1806
|
+
'radius-md',
|
|
1807
|
+
'secondary',
|
|
1808
|
+
'spacing-3',
|
|
1809
|
+
'spacing-4',
|
|
1810
|
+
'spacing-6',
|
|
1811
|
+
'success',
|
|
1812
|
+
],
|
|
1813
|
+
sdkMethods: ['auth.requestMagicLink', 'auth.exchange', 'auth.logout', 'on'],
|
|
1814
|
+
props: {
|
|
1815
|
+
heading: { type: 'string', attr: 'heading', default: 'Sign in', docs: 'Heading for the email step.' },
|
|
1816
|
+
allowCodeEntry: {
|
|
1817
|
+
type: 'boolean',
|
|
1818
|
+
attr: 'allow-code-entry',
|
|
1819
|
+
default: true,
|
|
1820
|
+
docs: 'Offer a "enter the code" path alongside the emailed link (default true).',
|
|
1821
|
+
},
|
|
1822
|
+
},
|
|
1823
|
+
events: {
|
|
1824
|
+
tlAuthChanged: { detail: 'TlAuthChanged', docs: 'signed in or out ({ customer, authenticated })' },
|
|
1825
|
+
tlMagicLinkSent: { detail: '{ email: string; }', docs: 'a link was requested for an email' },
|
|
1826
|
+
tlError: { detail: '{ message: string; }', docs: 'request or code exchange failed' },
|
|
1827
|
+
},
|
|
1828
|
+
strings: STRINGS$6,
|
|
1829
|
+
statuses: ['email', 'sent', 'code', 'verifying', 'signed-in'],
|
|
1830
|
+
state: () => ({
|
|
1831
|
+
phase: 'email',
|
|
1832
|
+
email: '',
|
|
1833
|
+
code: '',
|
|
1834
|
+
busy: false,
|
|
1835
|
+
error: null,
|
|
1836
|
+
notice: null,
|
|
1837
|
+
customer: null,
|
|
1838
|
+
authAvailable: false,
|
|
1839
|
+
urlCodeUsed: false,
|
|
1840
|
+
}),
|
|
1841
|
+
/**
|
|
1842
|
+
* On screen: wait for the client, read who is signed in, follow both auth
|
|
1843
|
+
* signals, then spend the code the link arrived with, if it did.
|
|
1844
|
+
*
|
|
1845
|
+
* The code is taken from the query whether or not it gets used, which is
|
|
1846
|
+
* what makes it single use: arriving already signed in burns it, exactly as
|
|
1847
|
+
* the component did.
|
|
1848
|
+
*/
|
|
1849
|
+
mount: (ctx) => {
|
|
1850
|
+
void (async () => {
|
|
1851
|
+
let client;
|
|
1852
|
+
try {
|
|
1853
|
+
client = await ctx.whenClient();
|
|
1854
|
+
}
|
|
1855
|
+
catch {
|
|
1856
|
+
if (!ctx.active())
|
|
1857
|
+
return;
|
|
1858
|
+
ctx.actions.connectFailed();
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (!ctx.active())
|
|
1862
|
+
return;
|
|
1863
|
+
const auth = client.getAuthManager?.();
|
|
1864
|
+
if (!auth) {
|
|
1865
|
+
ctx.actions.unsupported();
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
ctx.setState({ authAvailable: true });
|
|
1869
|
+
ctx.actions.syncFromClient();
|
|
1870
|
+
// The session hydrates after the client publishes; follow both signals.
|
|
1871
|
+
for (const event of ['auth:changed', 'session:changed']) {
|
|
1872
|
+
const off = client.on?.(event, () => ctx.actions.syncFromClient());
|
|
1873
|
+
if (typeof off === 'function')
|
|
1874
|
+
ctx.onTeardown(off);
|
|
1875
|
+
}
|
|
1876
|
+
const urlCode = ctx.state.urlCodeUsed ? null : ctx.query.get('code');
|
|
1877
|
+
if (urlCode) {
|
|
1878
|
+
ctx.setState({ urlCodeUsed: true });
|
|
1879
|
+
if (!auth.authenticated)
|
|
1880
|
+
await ctx.actions.exchange(urlCode);
|
|
1881
|
+
}
|
|
1882
|
+
})();
|
|
1883
|
+
},
|
|
1884
|
+
status: ({ state }) => state.phase,
|
|
1885
|
+
derive: ({ state, strings, props }) => ({
|
|
1886
|
+
signedInEmail: state.customer?.email ?? strings.fallbackAccount,
|
|
1887
|
+
inputsDisabled: state.busy || !state.authAvailable,
|
|
1888
|
+
emailSubmitLabel: state.busy ? strings.sendingLink : strings.emailSubmit,
|
|
1889
|
+
resendLabel: state.busy ? strings.resending : strings.resendLink,
|
|
1890
|
+
codeSubmitLabel: state.busy ? strings.checking : strings.codeSubmit,
|
|
1891
|
+
showCodeEntry: props.allowCodeEntry,
|
|
1892
|
+
}),
|
|
1893
|
+
// Named so `submitCode` can call `exchange`: the action context does not
|
|
1894
|
+
// carry the actions, only a mount's does.
|
|
1895
|
+
actions: (ctx) => {
|
|
1896
|
+
/** The auth manager as it stands, or null on a client that has none. */
|
|
1897
|
+
const authManager = () => ctx.optionalClient()?.getAuthManager?.() ?? null;
|
|
1898
|
+
const self = {
|
|
1899
|
+
/** Typing clears whatever the last attempt complained about. */
|
|
1900
|
+
setEmail(value) {
|
|
1901
|
+
ctx.setState({ email: value, error: null });
|
|
1902
|
+
},
|
|
1903
|
+
setCode(value) {
|
|
1904
|
+
ctx.setState({ code: value, error: null });
|
|
1905
|
+
},
|
|
1906
|
+
/**
|
|
1907
|
+
* Ask for a link. The response always reports success, so nothing here
|
|
1908
|
+
* can reveal whether an account exists.
|
|
1909
|
+
*/
|
|
1910
|
+
async requestLink(resend = false) {
|
|
1911
|
+
const email = ctx.state.email.trim();
|
|
1912
|
+
if (!email) {
|
|
1913
|
+
ctx.setState({ error: ctx.strings.enterEmail });
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const auth = authManager();
|
|
1917
|
+
if (!auth)
|
|
1918
|
+
return;
|
|
1919
|
+
ctx.setState({ busy: true, error: null, notice: null });
|
|
1920
|
+
try {
|
|
1921
|
+
await auth.requestMagicLink({ email });
|
|
1922
|
+
ctx.setState({ phase: 'sent', notice: resend ? ctx.strings.sentAgain : null });
|
|
1923
|
+
ctx.emit('tlMagicLinkSent', { email });
|
|
1924
|
+
}
|
|
1925
|
+
catch (err) {
|
|
1926
|
+
const message = errorMessage(err, ctx.strings.requestFailed);
|
|
1927
|
+
ctx.setState({ error: message });
|
|
1928
|
+
ctx.emit('tlError', { message });
|
|
1929
|
+
}
|
|
1930
|
+
finally {
|
|
1931
|
+
ctx.setState({ busy: false });
|
|
1932
|
+
}
|
|
1933
|
+
},
|
|
1934
|
+
/** The code the customer typed. */
|
|
1935
|
+
async submitCode() {
|
|
1936
|
+
await self.exchange(ctx.state.code.trim());
|
|
1937
|
+
},
|
|
1938
|
+
/**
|
|
1939
|
+
* Trade a code for a session. On failure the step it came from decides
|
|
1940
|
+
* where it goes back to: a typed code returns to the code step, a URL
|
|
1941
|
+
* code (which arrives while the phase is still `email`) returns to the
|
|
1942
|
+
* email step.
|
|
1943
|
+
*/
|
|
1944
|
+
async exchange(code) {
|
|
1945
|
+
if (!code) {
|
|
1946
|
+
ctx.setState({ error: ctx.strings.enterCode });
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const auth = authManager();
|
|
1950
|
+
if (!auth)
|
|
1951
|
+
return;
|
|
1952
|
+
const previous = ctx.state.phase;
|
|
1953
|
+
ctx.setState({ busy: true, error: null, phase: 'verifying' });
|
|
1954
|
+
try {
|
|
1955
|
+
const res = (await auth.exchange(code));
|
|
1956
|
+
const customer = auth.customer ?? res?.customer ?? null;
|
|
1957
|
+
ctx.setState({ customer, phase: 'signed-in', code: '' });
|
|
1958
|
+
ctx.emit('tlAuthChanged', { customer, authenticated: true });
|
|
1959
|
+
}
|
|
1960
|
+
catch {
|
|
1961
|
+
ctx.setState({
|
|
1962
|
+
phase: previous === 'verifying' || previous === 'email' ? 'email' : 'code',
|
|
1963
|
+
error: ctx.strings.codeRejected,
|
|
1964
|
+
});
|
|
1965
|
+
ctx.emit('tlError', { message: ctx.strings.codeRejected });
|
|
1966
|
+
}
|
|
1967
|
+
finally {
|
|
1968
|
+
ctx.setState({ busy: false });
|
|
1969
|
+
}
|
|
1970
|
+
},
|
|
1971
|
+
/**
|
|
1972
|
+
* Sign out. A failure is swallowed on purpose: the session is gone from
|
|
1973
|
+
* this page either way, so the customer is shown as signed out and the
|
|
1974
|
+
* host is told so.
|
|
1975
|
+
*/
|
|
1976
|
+
async signOut() {
|
|
1977
|
+
const auth = authManager();
|
|
1978
|
+
if (!auth)
|
|
1979
|
+
return;
|
|
1980
|
+
ctx.setState({ busy: true });
|
|
1981
|
+
try {
|
|
1982
|
+
await auth.logout();
|
|
1983
|
+
}
|
|
1984
|
+
catch {
|
|
1985
|
+
// Shown as signed out regardless, which is what the component did.
|
|
1986
|
+
}
|
|
1987
|
+
finally {
|
|
1988
|
+
ctx.setState({ busy: false, customer: null, phase: 'email' });
|
|
1989
|
+
ctx.emit('tlAuthChanged', { customer: null, authenticated: false });
|
|
1990
|
+
}
|
|
1991
|
+
},
|
|
1992
|
+
/** The steps a buyer can walk between by hand. */
|
|
1993
|
+
goTo(phase) {
|
|
1994
|
+
ctx.setState({ phase });
|
|
1995
|
+
},
|
|
1996
|
+
/**
|
|
1997
|
+
* The client says the session changed. Signed in wins outright; signed
|
|
1998
|
+
* out only pulls the element back when it was showing the signed-in
|
|
1999
|
+
* step, so a customer half way through typing a code is left alone.
|
|
2000
|
+
*/
|
|
2001
|
+
syncFromClient() {
|
|
2002
|
+
const auth = authManager();
|
|
2003
|
+
if (!auth)
|
|
2004
|
+
return;
|
|
2005
|
+
if (auth.authenticated) {
|
|
2006
|
+
ctx.setState({ customer: auth.customer ?? null, phase: 'signed-in' });
|
|
2007
|
+
}
|
|
2008
|
+
else if (ctx.state.phase === 'signed-in') {
|
|
2009
|
+
ctx.setState({ customer: null, phase: 'email' });
|
|
2010
|
+
}
|
|
2011
|
+
},
|
|
2012
|
+
/** The host never started Live. Said on screen, and nowhere else. */
|
|
2013
|
+
connectFailed() {
|
|
2014
|
+
ctx.setState({ error: ctx.strings.unavailable });
|
|
2015
|
+
},
|
|
2016
|
+
/** A connector with no auth accessor cannot sign anybody in. */
|
|
2017
|
+
unsupported() {
|
|
2018
|
+
ctx.setState({ error: ctx.strings.unsupported });
|
|
2019
|
+
},
|
|
2020
|
+
};
|
|
2021
|
+
return self;
|
|
2022
|
+
},
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* `tl-promo-code`: an input and an Apply button that redeems a presale or
|
|
2027
|
+
* promo code for the current cart.
|
|
2028
|
+
*
|
|
2029
|
+
* Redemption unlocks gated pricing on the customer; the client re-reads the
|
|
2030
|
+
* cart and publishes `cart:updated`, so `tl-cart` and the badges re-render on
|
|
2031
|
+
* their own and this element subscribes to nothing. It has nothing to do when
|
|
2032
|
+
* a harness mounts it either, so it declares no `mount` hook - the harness
|
|
2033
|
+
* still calls `mount()` and still gets a teardown, which is the point of the
|
|
2034
|
+
* hook being unconditional.
|
|
2035
|
+
*
|
|
2036
|
+
* The API has no un-redeem, so the applied state offers only "use a different
|
|
2037
|
+
* code", which clears the input for another redemption.
|
|
2038
|
+
*
|
|
2039
|
+
* Behaviour is what the Stencil component did before the split, to the letter,
|
|
2040
|
+
* including the two error messages and the uppercased code in the receipt. What
|
|
2041
|
+
* did not come across is the `console.error` tracing.
|
|
2042
|
+
*/
|
|
2043
|
+
const STRINGS$5 = {
|
|
2044
|
+
applying: 'Applying…',
|
|
2045
|
+
codePrefix: 'Code ',
|
|
2046
|
+
appliedSuffix: ' applied',
|
|
2047
|
+
alreadyHeldSuffix: ' was already applied',
|
|
2048
|
+
useDifferent: 'Use a different code',
|
|
2049
|
+
enterCode: 'Enter a code',
|
|
2050
|
+
notAvailable: 'Promo codes are not available',
|
|
2051
|
+
signIn: 'Sign in to use this code',
|
|
2052
|
+
rejected: 'That code could not be applied',
|
|
2053
|
+
};
|
|
2054
|
+
/** True for the "not signed in" family of API errors (401/403, NO_SESSION, NOT_AUTHENTICATED). */
|
|
2055
|
+
function isAuthError(err) {
|
|
2056
|
+
const e = err;
|
|
2057
|
+
if (!e || typeof e !== 'object')
|
|
2058
|
+
return false;
|
|
2059
|
+
return e.status === 401 || e.status === 403 || e.code === 'NO_SESSION' || e.code === 'NOT_AUTHENTICATED';
|
|
2060
|
+
}
|
|
2061
|
+
const promoCodeCore = defineElement({
|
|
2062
|
+
tag: 'tl-promo-code',
|
|
2063
|
+
docs: 'Redeem a presale or promo code for the current cart.',
|
|
2064
|
+
category: 'purchase',
|
|
2065
|
+
runtimes: ['web'],
|
|
2066
|
+
tokens: [
|
|
2067
|
+
'background',
|
|
2068
|
+
'border',
|
|
2069
|
+
'error',
|
|
2070
|
+
'font-family',
|
|
2071
|
+
'font-family-mono',
|
|
2072
|
+
'foreground',
|
|
2073
|
+
'muted',
|
|
2074
|
+
'muted-foreground',
|
|
2075
|
+
'primary',
|
|
2076
|
+
'primary-foreground',
|
|
2077
|
+
'radius-md',
|
|
2078
|
+
'spacing-2',
|
|
2079
|
+
'spacing-3',
|
|
2080
|
+
'spacing-4',
|
|
2081
|
+
'success',
|
|
2082
|
+
],
|
|
2083
|
+
sdkMethods: ['cart.get', 'presale.redeem'],
|
|
2084
|
+
props: {
|
|
2085
|
+
placeholder: {
|
|
2086
|
+
type: 'string',
|
|
2087
|
+
attr: 'placeholder',
|
|
2088
|
+
default: 'Promo or presale code',
|
|
2089
|
+
docs: 'Input placeholder.',
|
|
2090
|
+
},
|
|
2091
|
+
buttonLabel: { type: 'string', attr: 'button-label', default: 'Apply', docs: 'Apply button label.' },
|
|
2092
|
+
},
|
|
2093
|
+
events: {
|
|
2094
|
+
tlPromoApplied: { detail: 'TlPromoApplied', docs: '' },
|
|
2095
|
+
tlError: { detail: '{ message: string; }', docs: '' },
|
|
2096
|
+
},
|
|
2097
|
+
strings: STRINGS$5,
|
|
2098
|
+
statuses: ['idle', 'applied'],
|
|
2099
|
+
state: () => ({ code: '', applying: false, applied: null, error: null }),
|
|
2100
|
+
status: ({ state }) => (state.applied ? 'applied' : 'idle'),
|
|
2101
|
+
derive: ({ state, props, strings }) => ({
|
|
2102
|
+
actionLabel: state.applying ? strings.applying : props.buttonLabel,
|
|
2103
|
+
disabled: state.applying,
|
|
2104
|
+
appliedCode: state.applied?.code ?? null,
|
|
2105
|
+
appliedSuffix: state.applied?.alreadyHeld ? strings.alreadyHeldSuffix : strings.appliedSuffix,
|
|
2106
|
+
}),
|
|
2107
|
+
actions: (ctx) => ({
|
|
2108
|
+
/** The buyer typed. Typing clears whatever the last attempt complained about. */
|
|
2109
|
+
setCode(code) {
|
|
2110
|
+
ctx.setState({ code, error: null });
|
|
2111
|
+
},
|
|
2112
|
+
/**
|
|
2113
|
+
* Redeem what is in the box.
|
|
2114
|
+
*
|
|
2115
|
+
* The client refreshes the cart on redeem, so the cart is read back only
|
|
2116
|
+
* to put it in the payload; a read that fails is not a failed redemption
|
|
2117
|
+
* and does not become one.
|
|
2118
|
+
*/
|
|
2119
|
+
async apply() {
|
|
2120
|
+
const code = ctx.state.code.trim();
|
|
2121
|
+
if (!code) {
|
|
2122
|
+
ctx.setState({ error: ctx.strings.enterCode });
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
ctx.setState({ applying: true, error: null });
|
|
2126
|
+
try {
|
|
2127
|
+
const client = await ctx.whenClient();
|
|
2128
|
+
const presale = client.getPresaleManager?.();
|
|
2129
|
+
if (!presale)
|
|
2130
|
+
throw new Error(ctx.strings.notAvailable);
|
|
2131
|
+
const result = await presale.redeem(code);
|
|
2132
|
+
const cart = (await client.getCartManager?.()?.get?.().catch(() => null)) ?? null;
|
|
2133
|
+
const applied = { code: code.toUpperCase(), ...result, cart };
|
|
2134
|
+
ctx.setState({ applied, code: '' });
|
|
2135
|
+
ctx.emit('tlPromoApplied', applied);
|
|
2136
|
+
}
|
|
2137
|
+
catch (err) {
|
|
2138
|
+
const message = isAuthError(err)
|
|
2139
|
+
? ctx.strings.signIn
|
|
2140
|
+
: err instanceof Error && err.message
|
|
2141
|
+
? err.message
|
|
2142
|
+
: ctx.strings.rejected;
|
|
2143
|
+
ctx.setState({ error: message });
|
|
2144
|
+
ctx.emit('tlError', { message });
|
|
2145
|
+
}
|
|
2146
|
+
finally {
|
|
2147
|
+
ctx.setState({ applying: false });
|
|
2148
|
+
}
|
|
2149
|
+
},
|
|
2150
|
+
/** Back to the input, for a second code. There is no un-redeem to offer. */
|
|
2151
|
+
reset() {
|
|
2152
|
+
ctx.setState({ applied: null, error: null });
|
|
2153
|
+
},
|
|
2154
|
+
}),
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
/**
|
|
2158
|
+
* `tl-my-orders`: the signed-in customer's orders, one expandable row each.
|
|
2159
|
+
*
|
|
2160
|
+
* The first core whose whole shape is decided by whether somebody is signed
|
|
2161
|
+
* in. It reads the client's auth manager once, follows `auth:changed` and
|
|
2162
|
+
* `session:changed` from there, and lists the orders whenever the answer turns
|
|
2163
|
+
* from signed out to signed in. Both subscriptions are bound in its own
|
|
2164
|
+
* `mount` and handed to `ctx.onTeardown` (TKT-197), so a remount cannot leave
|
|
2165
|
+
* four listeners on the bus.
|
|
2166
|
+
*
|
|
2167
|
+
* Behaviour is what the Stencil component did, to the letter, including the
|
|
2168
|
+
* three things that read like accidents and are not:
|
|
2169
|
+
*
|
|
2170
|
+
* - a connector with no auth accessor is treated as SIGNED IN, and a 401 from
|
|
2171
|
+
* the listing is what decides otherwise. That is deliberate: an older
|
|
2172
|
+
* connector has no way to answer the question, and showing the sign-in form
|
|
2173
|
+
* to someone who is signed in is the worse failure.
|
|
2174
|
+
* - a 401 or 403 from the listing is not an error. It drops back to the
|
|
2175
|
+
* signed-out view, which renders the inline sign-in, and emits nothing.
|
|
2176
|
+
* - the element does NOT announce that the host never started Live. It shows
|
|
2177
|
+
* "Orders are not available right now" and stays quiet, where `tl-cart`
|
|
2178
|
+
* emits `tlError` for the same condition, because the component's wait and
|
|
2179
|
+
* its read were in different places. Kept rather than made consistent.
|
|
2180
|
+
*
|
|
2181
|
+
* What did not come across is the `console.error` tracing, which a library
|
|
2182
|
+
* should not do for its host (`tl-cart` dropped its own for the same reason).
|
|
2183
|
+
*/
|
|
2184
|
+
const STRINGS$4 = {
|
|
2185
|
+
signInTitle: 'Sign in to see your orders',
|
|
2186
|
+
signInText: 'Your tickets and order history live in your account.',
|
|
2187
|
+
errorTitle: 'Could not load your orders',
|
|
2188
|
+
emptyTitle: 'No orders yet',
|
|
2189
|
+
orderPrefix: 'Order ',
|
|
2190
|
+
unnamedEvent: 'Order',
|
|
2191
|
+
ticketSingular: 'ticket',
|
|
2192
|
+
ticketPlural: 'tickets',
|
|
2193
|
+
unavailable: 'Orders are not available right now',
|
|
2194
|
+
noOrdersManager: 'Orders are not available',
|
|
2195
|
+
loadFailed: 'Failed to load your orders',
|
|
2196
|
+
};
|
|
2197
|
+
const myOrdersCore = defineElement({
|
|
2198
|
+
tag: 'tl-my-orders',
|
|
2199
|
+
docs: "The signed-in customer's orders: event, date, ticket count, total and status, each row expanding to its tickets.",
|
|
2200
|
+
category: 'account',
|
|
2201
|
+
runtimes: ['web', 'native'],
|
|
2202
|
+
tokens: [
|
|
2203
|
+
'background',
|
|
2204
|
+
'border',
|
|
2205
|
+
'error',
|
|
2206
|
+
'error-foreground',
|
|
2207
|
+
'font-family',
|
|
2208
|
+
'foreground',
|
|
2209
|
+
'muted-foreground',
|
|
2210
|
+
'primary',
|
|
2211
|
+
'primary-foreground',
|
|
2212
|
+
'radius-lg',
|
|
2213
|
+
'secondary',
|
|
2214
|
+
'secondary-foreground',
|
|
2215
|
+
'spacing-1',
|
|
2216
|
+
'spacing-3',
|
|
2217
|
+
'spacing-4',
|
|
2218
|
+
'spacing-8',
|
|
2219
|
+
'success',
|
|
2220
|
+
'success-foreground',
|
|
2221
|
+
'warning',
|
|
2222
|
+
'warning-foreground',
|
|
2223
|
+
],
|
|
2224
|
+
sdkMethods: ['orders.mine', 'auth.authenticated', 'on'],
|
|
2225
|
+
props: {
|
|
2226
|
+
emptyMessage: {
|
|
2227
|
+
type: 'string',
|
|
2228
|
+
attr: 'empty-message',
|
|
2229
|
+
default: "When you buy tickets they'll show up here, ready to view and download.",
|
|
2230
|
+
docs: 'Copy for the empty state.',
|
|
2231
|
+
},
|
|
2232
|
+
columns: { type: 'number', attr: 'columns', default: 2, docs: 'Columns for the expanded ticket grid.' },
|
|
2233
|
+
},
|
|
2234
|
+
events: {
|
|
2235
|
+
tlOrderSelect: { detail: '{ orderId: string; }', docs: 'a row was expanded ({ orderId })' },
|
|
2236
|
+
tlError: { detail: '{ message: string; }', docs: 'the orders could not be loaded' },
|
|
2237
|
+
},
|
|
2238
|
+
strings: STRINGS$4,
|
|
2239
|
+
statuses: ['loading', 'signed-out', 'error', 'empty', 'ready'],
|
|
2240
|
+
state: () => ({ orders: [], authenticated: false, loading: true, error: null, expandedId: null }),
|
|
2241
|
+
/**
|
|
2242
|
+
* On screen: wait for the client, read who is signed in, follow both auth
|
|
2243
|
+
* signals, then list the orders if there is somebody to list them for.
|
|
2244
|
+
*
|
|
2245
|
+
* The session hydrates after the client publishes, so `auth:changed` and
|
|
2246
|
+
* `session:changed` are both followed: the connector raises one or the other
|
|
2247
|
+
* depending on whether the customer signed in here or arrived with a session
|
|
2248
|
+
* already warm.
|
|
2249
|
+
*/
|
|
2250
|
+
mount: (ctx) => {
|
|
2251
|
+
void (async () => {
|
|
2252
|
+
let client;
|
|
2253
|
+
try {
|
|
2254
|
+
client = await ctx.whenClient();
|
|
2255
|
+
}
|
|
2256
|
+
catch {
|
|
2257
|
+
if (!ctx.active())
|
|
2258
|
+
return;
|
|
2259
|
+
ctx.actions.connectFailed();
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
if (!ctx.active())
|
|
2263
|
+
return;
|
|
2264
|
+
// An older connector has no auth accessor and cannot answer, so assume
|
|
2265
|
+
// signed in and let a 401 from the listing decide.
|
|
2266
|
+
const auth = client.getAuthManager?.();
|
|
2267
|
+
ctx.setState({ authenticated: auth ? auth.authenticated : true });
|
|
2268
|
+
for (const event of ['auth:changed', 'session:changed']) {
|
|
2269
|
+
const off = client.on?.(event, () => ctx.actions.syncAuth());
|
|
2270
|
+
if (typeof off === 'function')
|
|
2271
|
+
ctx.onTeardown(off);
|
|
2272
|
+
}
|
|
2273
|
+
if (ctx.state.authenticated)
|
|
2274
|
+
await ctx.actions.load();
|
|
2275
|
+
else
|
|
2276
|
+
ctx.setState({ loading: false });
|
|
2277
|
+
})();
|
|
2278
|
+
},
|
|
2279
|
+
status: ({ state }) => state.loading
|
|
2280
|
+
? 'loading'
|
|
2281
|
+
: !state.authenticated
|
|
2282
|
+
? 'signed-out'
|
|
2283
|
+
: state.error
|
|
2284
|
+
? 'error'
|
|
2285
|
+
: state.orders.length === 0
|
|
2286
|
+
? 'empty'
|
|
2287
|
+
: 'ready',
|
|
2288
|
+
derive: ({ state, strings }) => ({
|
|
2289
|
+
rows: state.orders.map((order) => {
|
|
2290
|
+
const count = ticketCount(order);
|
|
2291
|
+
return {
|
|
2292
|
+
id: order.id,
|
|
2293
|
+
numberLabel: `${strings.orderPrefix}${order.orderNumber}`,
|
|
2294
|
+
eventName: orderEventName(order) ?? strings.unnamedEvent,
|
|
2295
|
+
dateLabel: order.createdAt ? formatDate$3(order.createdAt) : null,
|
|
2296
|
+
countLabel: `${count} ${count === 1 ? strings.ticketSingular : strings.ticketPlural}`,
|
|
2297
|
+
totalLabel: formatPrice$1(order.total, order.currency),
|
|
2298
|
+
statusLabel: statusLabel(order.status),
|
|
2299
|
+
statusTone: statusTone(order.status),
|
|
2300
|
+
expanded: state.expandedId === order.id,
|
|
2301
|
+
};
|
|
2302
|
+
}),
|
|
2303
|
+
}),
|
|
2304
|
+
// The action map is named so `syncAuth` can call `load`: the action context
|
|
2305
|
+
// does not carry the actions (only a mount's does), and listing the orders
|
|
2306
|
+
// when the session turns warm is the same work the mount does.
|
|
2307
|
+
actions: (ctx) => {
|
|
2308
|
+
const self = {
|
|
2309
|
+
/** List the orders. A 401 means signed out, which is not an error. */
|
|
2310
|
+
async load() {
|
|
2311
|
+
const client = ctx.optionalClient();
|
|
2312
|
+
if (!client)
|
|
2313
|
+
return;
|
|
2314
|
+
try {
|
|
2315
|
+
ctx.setState({ loading: true, error: null });
|
|
2316
|
+
const orders = client.getOrdersManager?.();
|
|
2317
|
+
if (!orders)
|
|
2318
|
+
throw new Error(ctx.strings.noOrdersManager);
|
|
2319
|
+
const list = (await orders.mine());
|
|
2320
|
+
ctx.setState({ orders: list ?? [], authenticated: true });
|
|
2321
|
+
}
|
|
2322
|
+
catch (err) {
|
|
2323
|
+
if (isAuthError(err)) {
|
|
2324
|
+
// Not signed in: fall through to the inline sign-in rather than an error.
|
|
2325
|
+
ctx.setState({ authenticated: false, orders: [] });
|
|
2326
|
+
}
|
|
2327
|
+
else {
|
|
2328
|
+
const message = errorMessage(err, ctx.strings.loadFailed);
|
|
2329
|
+
ctx.setState({ error: message });
|
|
2330
|
+
ctx.emit('tlError', { message });
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
finally {
|
|
2334
|
+
ctx.setState({ loading: false });
|
|
2335
|
+
}
|
|
2336
|
+
},
|
|
2337
|
+
/**
|
|
2338
|
+
* The client says the session changed. Listing on the way in and clearing
|
|
2339
|
+
* on the way out; an unchanged answer does nothing, which is what keeps a
|
|
2340
|
+
* burst of `session:changed` from listing the orders four times.
|
|
2341
|
+
*/
|
|
2342
|
+
syncAuth() {
|
|
2343
|
+
const auth = ctx.optionalClient()?.getAuthManager?.();
|
|
2344
|
+
if (!auth)
|
|
2345
|
+
return;
|
|
2346
|
+
const was = ctx.state.authenticated;
|
|
2347
|
+
ctx.setState({ authenticated: auth.authenticated });
|
|
2348
|
+
if (auth.authenticated && !was)
|
|
2349
|
+
void self.load();
|
|
2350
|
+
else if (!auth.authenticated && was)
|
|
2351
|
+
ctx.setState({ orders: [], expandedId: null });
|
|
2352
|
+
},
|
|
2353
|
+
/** Open a row, or close the open one. Only opening is announced. */
|
|
2354
|
+
toggle(orderId) {
|
|
2355
|
+
const opening = ctx.state.expandedId !== orderId;
|
|
2356
|
+
ctx.setState({ expandedId: opening ? orderId : null });
|
|
2357
|
+
if (opening)
|
|
2358
|
+
ctx.emit('tlOrderSelect', { orderId });
|
|
2359
|
+
},
|
|
2360
|
+
/**
|
|
2361
|
+
* The host never produced a client. Said on screen and nowhere else: the
|
|
2362
|
+
* component did not emit `tlError` here, because its wait for the client
|
|
2363
|
+
* was outside the try block that announces a failure.
|
|
2364
|
+
*/
|
|
2365
|
+
connectFailed() {
|
|
2366
|
+
ctx.setState({ loading: false, error: ctx.strings.unavailable });
|
|
2367
|
+
},
|
|
2368
|
+
};
|
|
2369
|
+
return self;
|
|
2370
|
+
},
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* `tl-occurrence-selector`: every date and time one event is on, and the one
|
|
2375
|
+
* the buyer picks.
|
|
2376
|
+
*
|
|
2377
|
+
* The largest of the eighteen, and the last big one: 883 lines of Stencil with
|
|
2378
|
+
* no spec at all, which is why TKT-202 wrote the characterisation spec first
|
|
2379
|
+
* and split it on its own. Everything below is what that component did, ported
|
|
2380
|
+
* rather than redesigned, and the spec
|
|
2381
|
+
* (`packages/elements/src/components/tl-occurrence-selector/test/`) is the
|
|
2382
|
+
* proof - it passed against the component before this file existed and passes
|
|
2383
|
+
* against the harness now, unchanged.
|
|
2384
|
+
*
|
|
2385
|
+
* What it decides, in the order it decides it:
|
|
2386
|
+
*
|
|
2387
|
+
* - Which of three views to render. A host can force one; otherwise five
|
|
2388
|
+
* occurrences or fewer is a LIST, more than five all on one calendar date
|
|
2389
|
+
* is a grid of SLOTS, and more than five over several dates is a CALENDAR
|
|
2390
|
+
* with the picked date's times beside it.
|
|
2391
|
+
* - Where the calendar opens. Ported with its bug intact: the dates are sorted
|
|
2392
|
+
* as `Date.toDateString()` STRINGS, so "Fri Jul 05 2030" sorts before
|
|
2393
|
+
* "Fri Jun 14 2030" and a weekday name decides the order before a month
|
|
2394
|
+
* does. Fixing that is a decision about the product, not part of moving the
|
|
2395
|
+
* code, and a split that quietly corrected it would be exactly the kind of
|
|
2396
|
+
* change the characterisation spec exists to catch.
|
|
2397
|
+
* - Which days a buyer may press, which are sold out, and how far the month
|
|
2398
|
+
* navigation may walk in each direction.
|
|
2399
|
+
* - Every label: the `en-GB` dates and times, the capacity line, the weekday
|
|
2400
|
+
* headings and the two month-navigation labels a screen reader reads.
|
|
2401
|
+
*
|
|
2402
|
+
* What is NOT here, and why:
|
|
2403
|
+
*
|
|
2404
|
+
* - The `theme` prop. It is declared, because it is part of the element's
|
|
2405
|
+
* published surface, and it is the harness that acts on it: the prop is a
|
|
2406
|
+
* set of CSS CUSTOM PROPERTIES written onto the element, and a custom
|
|
2407
|
+
* property is not a fact every runtime has. A core holds no values at all,
|
|
2408
|
+
* so there is nothing here for it to hold.
|
|
2409
|
+
* - How wide the element is. `containerWidth` is state the harness measures
|
|
2410
|
+
* and pushes in through `resized()`; the core decides what a width MEANS
|
|
2411
|
+
* (`auto` becomes horizontal at 768px and not before), which is the same
|
|
2412
|
+
* bargain as `ctx.storage` - the core says what, the harness says where.
|
|
2413
|
+
* - Which token family a state paints with. The core derives the CLASS a
|
|
2414
|
+
* calendar day renders under and the stylesheet says what that class paints,
|
|
2415
|
+
* because a core cannot map a status onto a token family
|
|
2416
|
+
* (`tl-event-card`'s status badge and `tl-buy-tickets-button` make the same
|
|
2417
|
+
* bargain).
|
|
2418
|
+
*
|
|
2419
|
+
* What did not come across is the `console.error` on a failed read and the
|
|
2420
|
+
* `console.log` on every selection, for the reason `tl-cart` and
|
|
2421
|
+
* `tl-promo-code` dropped theirs: a library should not write to its host's
|
|
2422
|
+
* console.
|
|
2423
|
+
*/
|
|
2424
|
+
const STRINGS$3 = {
|
|
2425
|
+
noEventId: 'Event ID is required',
|
|
2426
|
+
noClient: 'SDK not initialized.',
|
|
2427
|
+
notFound: 'Event not found',
|
|
2428
|
+
loadFailed: 'Failed to load occurrences',
|
|
2429
|
+
empty: 'No dates available for this event.',
|
|
2430
|
+
retry: 'Retry',
|
|
2431
|
+
soldOut: 'Sold Out',
|
|
2432
|
+
/** Follows the figure: "120 available". */
|
|
2433
|
+
available: 'available',
|
|
2434
|
+
selectADate: 'Select a date to see available times',
|
|
2435
|
+
previousMonth: 'Previous month',
|
|
2436
|
+
nextMonth: 'Next month',
|
|
2437
|
+
weekdayMon: 'Mon',
|
|
2438
|
+
weekdayTue: 'Tue',
|
|
2439
|
+
weekdayWed: 'Wed',
|
|
2440
|
+
weekdayThu: 'Thu',
|
|
2441
|
+
weekdayFri: 'Fri',
|
|
2442
|
+
weekdaySat: 'Sat',
|
|
2443
|
+
weekdaySun: 'Sun',
|
|
2444
|
+
};
|
|
2445
|
+
/** Above this many pixels an `auto` layout puts the times beside the calendar. */
|
|
2446
|
+
const HORIZONTAL_MIN_WIDTH = 768;
|
|
2447
|
+
/** More than this many occurrences and the list view gives way. */
|
|
2448
|
+
const LIST_LIMIT = 5;
|
|
2449
|
+
// ---------------------------------------------------------------------------
|
|
2450
|
+
// Dates. Every one of these is the component's own arithmetic, moved.
|
|
2451
|
+
// ---------------------------------------------------------------------------
|
|
2452
|
+
/** Local midnight on the day `date` falls on. */
|
|
2453
|
+
function startOfDay(date) {
|
|
2454
|
+
const start = new Date(date);
|
|
2455
|
+
start.setHours(0, 0, 0, 0);
|
|
2456
|
+
return start;
|
|
2457
|
+
}
|
|
2458
|
+
/** The key a day is held under, which is also what a harness hands back. */
|
|
2459
|
+
function dayKey(date) {
|
|
2460
|
+
return date.toDateString();
|
|
2461
|
+
}
|
|
2462
|
+
/** Monday of the week `date` falls in. */
|
|
2463
|
+
function mondayOf(date) {
|
|
2464
|
+
const monday = new Date(date);
|
|
2465
|
+
const dayOfWeek = monday.getDay();
|
|
2466
|
+
monday.setDate(monday.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
|
2467
|
+
monday.setHours(0, 0, 0, 0);
|
|
2468
|
+
return monday;
|
|
2469
|
+
}
|
|
2470
|
+
/** The first of the month `date` falls in. */
|
|
2471
|
+
function firstOfMonth(date) {
|
|
2472
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
2473
|
+
}
|
|
2474
|
+
const startTime = (occurrence) => new Date(occurrence.startsAt).getTime();
|
|
2475
|
+
const byStart = (a, b) => startTime(a) - startTime(b);
|
|
2476
|
+
/** As the component decided it: an explicit zero counts as gone. */
|
|
2477
|
+
const isSoldOut = (occurrence) => !!occurrence.soldOut || occurrence.availableCapacity === 0;
|
|
2478
|
+
/** Day key -> the occurrences on it, in the order the event gave them. */
|
|
2479
|
+
function occurrencesByDate(occurrences) {
|
|
2480
|
+
const map = new Map();
|
|
2481
|
+
for (const occurrence of occurrences) {
|
|
2482
|
+
const key = dayKey(startOfDay(new Date(occurrence.startsAt)));
|
|
2483
|
+
const on = map.get(key);
|
|
2484
|
+
if (on)
|
|
2485
|
+
on.push(occurrence);
|
|
2486
|
+
else
|
|
2487
|
+
map.set(key, [occurrence]);
|
|
2488
|
+
}
|
|
2489
|
+
return map;
|
|
2490
|
+
}
|
|
2491
|
+
/** The earliest and latest occurrence, or null when there are none. */
|
|
2492
|
+
function dateRangeOf(occurrences) {
|
|
2493
|
+
if (!occurrences.length)
|
|
2494
|
+
return null;
|
|
2495
|
+
let first = new Date(occurrences[0].startsAt);
|
|
2496
|
+
let last = new Date(occurrences[0].startsAt);
|
|
2497
|
+
for (const occurrence of occurrences) {
|
|
2498
|
+
const date = new Date(occurrence.startsAt);
|
|
2499
|
+
if (date < first)
|
|
2500
|
+
first = date;
|
|
2501
|
+
if (date > last)
|
|
2502
|
+
last = date;
|
|
2503
|
+
}
|
|
2504
|
+
return { first, last };
|
|
2505
|
+
}
|
|
2506
|
+
/** Whether everything fits between one Monday and the Sunday after it. */
|
|
2507
|
+
function isSingleWeek(occurrences) {
|
|
2508
|
+
const range = dateRangeOf(occurrences);
|
|
2509
|
+
if (!range)
|
|
2510
|
+
return false;
|
|
2511
|
+
const sunday = new Date(mondayOf(range.first));
|
|
2512
|
+
sunday.setDate(sunday.getDate() + 6);
|
|
2513
|
+
sunday.setHours(23, 59, 59, 999);
|
|
2514
|
+
return range.last <= sunday;
|
|
2515
|
+
}
|
|
2516
|
+
/** Whether everything falls in one calendar month. */
|
|
2517
|
+
function isSingleMonth(occurrences) {
|
|
2518
|
+
const range = dateRangeOf(occurrences);
|
|
2519
|
+
if (!range)
|
|
2520
|
+
return false;
|
|
2521
|
+
return (range.first.getMonth() === range.last.getMonth() &&
|
|
2522
|
+
range.first.getFullYear() === range.last.getFullYear());
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Where the calendar opens.
|
|
2526
|
+
*
|
|
2527
|
+
* The sort is on `toDateString()` STRINGS and that is not an oversight here:
|
|
2528
|
+
* it is what the component did, so "Fri Jul 05 2030" comes before
|
|
2529
|
+
* "Fri Jun 14 2030" and the element opens on July. See the note at the top.
|
|
2530
|
+
*/
|
|
2531
|
+
function initialSelection(occurrences) {
|
|
2532
|
+
if (!occurrences.length)
|
|
2533
|
+
return null;
|
|
2534
|
+
const today = startOfDay(new Date());
|
|
2535
|
+
const keys = Array.from(occurrencesByDate(occurrences).keys()).sort();
|
|
2536
|
+
let selected = null;
|
|
2537
|
+
for (const key of keys) {
|
|
2538
|
+
const date = new Date(key);
|
|
2539
|
+
if (date >= today) {
|
|
2540
|
+
selected = date;
|
|
2541
|
+
break;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
if (!selected && keys.length)
|
|
2545
|
+
selected = new Date(keys[keys.length - 1]);
|
|
2546
|
+
if (!selected)
|
|
2547
|
+
return null;
|
|
2548
|
+
return { selectedDate: selected, currentMonth: firstOfMonth(selected) };
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* The grid: one week when everything fits in one, else the whole month padded
|
|
2552
|
+
* out to whole Monday-to-Sunday weeks.
|
|
2553
|
+
*/
|
|
2554
|
+
function calendarDays(occurrences, currentMonth) {
|
|
2555
|
+
const days = [];
|
|
2556
|
+
if (isSingleWeek(occurrences)) {
|
|
2557
|
+
const range = dateRangeOf(occurrences);
|
|
2558
|
+
if (!range)
|
|
2559
|
+
return days;
|
|
2560
|
+
const cursor = mondayOf(range.first);
|
|
2561
|
+
for (let i = 0; i < 7; i += 1) {
|
|
2562
|
+
days.push(new Date(cursor));
|
|
2563
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
2564
|
+
}
|
|
2565
|
+
return days;
|
|
2566
|
+
}
|
|
2567
|
+
const year = currentMonth.getFullYear();
|
|
2568
|
+
const month = currentMonth.getMonth();
|
|
2569
|
+
const monthStart = new Date(year, month, 1);
|
|
2570
|
+
const monthEnd = new Date(year, month + 1, 0);
|
|
2571
|
+
const startDay = monthStart.getDay();
|
|
2572
|
+
const calendarStart = new Date(monthStart);
|
|
2573
|
+
calendarStart.setDate(calendarStart.getDate() - (startDay === 0 ? 6 : startDay - 1));
|
|
2574
|
+
const endDay = monthEnd.getDay();
|
|
2575
|
+
const calendarEnd = new Date(monthEnd);
|
|
2576
|
+
calendarEnd.setDate(calendarEnd.getDate() + (endDay === 0 ? 0 : 7 - endDay));
|
|
2577
|
+
const cursor = new Date(calendarStart);
|
|
2578
|
+
while (cursor <= calendarEnd) {
|
|
2579
|
+
days.push(new Date(cursor));
|
|
2580
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
2581
|
+
}
|
|
2582
|
+
return days;
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* As the component formatted them: `en-GB`, written down here rather than taken
|
|
2586
|
+
* from the host's locale because that is what the element did before the split
|
|
2587
|
+
* and this is a split, not a change (see the note on the card core's date).
|
|
2588
|
+
*/
|
|
2589
|
+
function formatDate(date) {
|
|
2590
|
+
return date.toLocaleDateString('en-GB', {
|
|
2591
|
+
weekday: 'long',
|
|
2592
|
+
day: 'numeric',
|
|
2593
|
+
month: 'long',
|
|
2594
|
+
year: 'numeric',
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
function formatTime(dateString) {
|
|
2598
|
+
return new Date(dateString).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
2599
|
+
}
|
|
2600
|
+
function formatMonth(date) {
|
|
2601
|
+
return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
|
2602
|
+
}
|
|
2603
|
+
/** The event detail's occurrences, as this element holds them. */
|
|
2604
|
+
function toOccurrence(occurrence) {
|
|
2605
|
+
return {
|
|
2606
|
+
id: occurrence.id,
|
|
2607
|
+
startsAt: occurrence.startsAt,
|
|
2608
|
+
endsAt: occurrence.endsAt,
|
|
2609
|
+
status: occurrence.availabilityStatus,
|
|
2610
|
+
availableCapacity: occurrence.availableCapacity,
|
|
2611
|
+
soldOut: occurrence.availabilityStatus === 'sold_out',
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
const occurrenceSelectorCore = defineElement({
|
|
2615
|
+
tag: 'tl-occurrence-selector',
|
|
2616
|
+
docs: 'A smart occurrence selector component that fetches and displays event occurrences from the Ticketlayer API.',
|
|
2617
|
+
category: 'discovery',
|
|
2618
|
+
// Not `native`: the hand-kept manifest said `web` and there is no React
|
|
2619
|
+
// Native harness for a calendar yet. Splitting it does not make that claim
|
|
2620
|
+
// true, and the catalogue is read as a promise about where an element runs.
|
|
2621
|
+
runtimes: ['web'],
|
|
2622
|
+
tokens: [
|
|
2623
|
+
'background',
|
|
2624
|
+
'border',
|
|
2625
|
+
'font-family',
|
|
2626
|
+
'foreground',
|
|
2627
|
+
'muted',
|
|
2628
|
+
'muted-foreground',
|
|
2629
|
+
'primary',
|
|
2630
|
+
'primary-foreground',
|
|
2631
|
+
'radius-md',
|
|
2632
|
+
],
|
|
2633
|
+
sdkMethods: ['events.get'],
|
|
2634
|
+
props: {
|
|
2635
|
+
eventId: {
|
|
2636
|
+
type: 'string',
|
|
2637
|
+
attr: 'event-id',
|
|
2638
|
+
required: true,
|
|
2639
|
+
docs: 'Event ID to fetch occurrences for',
|
|
2640
|
+
},
|
|
2641
|
+
displayMode: {
|
|
2642
|
+
type: 'string',
|
|
2643
|
+
tsType: '"calendar" | "list" | "slots" | undefined',
|
|
2644
|
+
attr: 'display-mode',
|
|
2645
|
+
docs: 'Force a specific display mode (auto-detected if not set)',
|
|
2646
|
+
},
|
|
2647
|
+
orientation: {
|
|
2648
|
+
type: 'string',
|
|
2649
|
+
tsType: '"auto" | "horizontal" | "vertical"',
|
|
2650
|
+
attr: 'orientation',
|
|
2651
|
+
default: 'auto',
|
|
2652
|
+
docs: 'Layout orientation (auto = horizontal on desktop, vertical on mobile)',
|
|
2653
|
+
},
|
|
2654
|
+
theme: {
|
|
2655
|
+
type: 'object',
|
|
2656
|
+
tsType: 'TlOccurrenceSelectorTheme | undefined',
|
|
2657
|
+
attr: null,
|
|
2658
|
+
docs: 'Theme configuration',
|
|
2659
|
+
},
|
|
2660
|
+
},
|
|
2661
|
+
events: {
|
|
2662
|
+
tlSelect: {
|
|
2663
|
+
detail: '{ occurrence: TlOccurrence; }',
|
|
2664
|
+
docs: 'Emitted when an occurrence is selected',
|
|
2665
|
+
},
|
|
2666
|
+
},
|
|
2667
|
+
strings: STRINGS$3,
|
|
2668
|
+
statuses: ['loading', 'error', 'empty', 'ready'],
|
|
2669
|
+
state: () => ({
|
|
2670
|
+
loading: true,
|
|
2671
|
+
error: null,
|
|
2672
|
+
occurrences: [],
|
|
2673
|
+
currentMonth: new Date(),
|
|
2674
|
+
selectedDate: null,
|
|
2675
|
+
containerWidth: 0,
|
|
2676
|
+
}),
|
|
2677
|
+
/**
|
|
2678
|
+
* On screen: wait for the host's client, then read the event.
|
|
2679
|
+
*
|
|
2680
|
+
* The component did this in `componentWillLoad`, which held the first paint
|
|
2681
|
+
* until the read came back. A mount hook cannot hold a paint, so the element
|
|
2682
|
+
* shows its own skeleton first instead of a blank - the same change
|
|
2683
|
+
* `tl-ticket-selector` and `tl-event-card` took when they moved onto the hook
|
|
2684
|
+
* (TKT-197).
|
|
2685
|
+
*
|
|
2686
|
+
* There is nothing to unsubscribe: this element reads once and lives on what
|
|
2687
|
+
* it read, so the mount registers no teardown and the harness still gets one.
|
|
2688
|
+
*/
|
|
2689
|
+
mount: (ctx) => {
|
|
2690
|
+
if (!ctx.props.eventId) {
|
|
2691
|
+
ctx.actions.loadFailed(ctx.strings.noEventId);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
void (async () => {
|
|
2695
|
+
try {
|
|
2696
|
+
await ctx.whenClient();
|
|
2697
|
+
}
|
|
2698
|
+
catch (err) {
|
|
2699
|
+
if (!ctx.active())
|
|
2700
|
+
return;
|
|
2701
|
+
ctx.actions.loadFailed(err instanceof Error ? err.message : ctx.strings.noClient);
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
if (!ctx.active())
|
|
2705
|
+
return;
|
|
2706
|
+
await ctx.actions.load();
|
|
2707
|
+
})();
|
|
2708
|
+
},
|
|
2709
|
+
status: ({ state }) => state.loading ? 'loading' : state.error ? 'error' : state.occurrences.length === 0 ? 'empty' : 'ready',
|
|
2710
|
+
derive: ({ state, props, strings }) => {
|
|
2711
|
+
const { occurrences, selectedDate } = state;
|
|
2712
|
+
const byDate = occurrencesByDate(occurrences);
|
|
2713
|
+
const range = dateRangeOf(occurrences);
|
|
2714
|
+
// Which view. The component decided this once, when the read came back;
|
|
2715
|
+
// deriving it means a host that changes `display-mode` afterwards is
|
|
2716
|
+
// answered at once rather than at the next read (TKT-202, named in the
|
|
2717
|
+
// pull request as the one behaviour this split does not preserve exactly).
|
|
2718
|
+
const mode = props.displayMode
|
|
2719
|
+
? props.displayMode
|
|
2720
|
+
: occurrences.length <= LIST_LIMIT
|
|
2721
|
+
? 'list'
|
|
2722
|
+
: byDate.size === 1
|
|
2723
|
+
? 'slots'
|
|
2724
|
+
: 'calendar';
|
|
2725
|
+
const horizontal = props.orientation === 'horizontal'
|
|
2726
|
+
? true
|
|
2727
|
+
: props.orientation === 'vertical'
|
|
2728
|
+
? false
|
|
2729
|
+
: state.containerWidth >= HORIZONTAL_MIN_WIDTH;
|
|
2730
|
+
const row = (occurrence) => {
|
|
2731
|
+
const soldOut = isSoldOut(occurrence);
|
|
2732
|
+
const start = formatTime(occurrence.startsAt);
|
|
2733
|
+
return {
|
|
2734
|
+
id: occurrence.id,
|
|
2735
|
+
dateLabel: formatDate(new Date(occurrence.startsAt)),
|
|
2736
|
+
timeLabel: occurrence.endsAt ? `${start} - ${formatTime(occurrence.endsAt)}` : start,
|
|
2737
|
+
startLabel: start,
|
|
2738
|
+
soldOut,
|
|
2739
|
+
capacityLabel: occurrence.availableCapacity
|
|
2740
|
+
? `${occurrence.availableCapacity} ${strings.available}`
|
|
2741
|
+
: null,
|
|
2742
|
+
statusKind: soldOut ? 'soldOut' : occurrence.availableCapacity ? 'available' : 'arrow',
|
|
2743
|
+
};
|
|
2744
|
+
};
|
|
2745
|
+
const inTimeOrder = [...occurrences].sort(byStart);
|
|
2746
|
+
const selectedKey = selectedDate ? dayKey(selectedDate) : null;
|
|
2747
|
+
const selectedOn = selectedKey ? [...(byDate.get(selectedKey) ?? [])].sort(byStart) : [];
|
|
2748
|
+
const today = startOfDay(new Date());
|
|
2749
|
+
const firstMonth = range ? firstOfMonth(range.first) : null;
|
|
2750
|
+
const lastMonth = range ? firstOfMonth(range.last) : null;
|
|
2751
|
+
const days = calendarDays(occurrences, state.currentMonth).map((date) => {
|
|
2752
|
+
const key = dayKey(date);
|
|
2753
|
+
const on = byDate.get(key);
|
|
2754
|
+
const hasOccurrences = !!on;
|
|
2755
|
+
const soldOut = hasOccurrences && on.every(isSoldOut);
|
|
2756
|
+
const otherMonth = date.getMonth() !== state.currentMonth.getMonth();
|
|
2757
|
+
const selected = selectedKey === key;
|
|
2758
|
+
const isToday = key === dayKey(today);
|
|
2759
|
+
const past = date < today;
|
|
2760
|
+
return {
|
|
2761
|
+
key,
|
|
2762
|
+
dayNumber: String(date.getDate()),
|
|
2763
|
+
otherMonth,
|
|
2764
|
+
hasOccurrences,
|
|
2765
|
+
soldOut,
|
|
2766
|
+
selected,
|
|
2767
|
+
today: isToday,
|
|
2768
|
+
past,
|
|
2769
|
+
selectable: hasOccurrences,
|
|
2770
|
+
className: [
|
|
2771
|
+
'calendar-day',
|
|
2772
|
+
otherMonth ? 'other-month' : '',
|
|
2773
|
+
hasOccurrences ? 'has-occurrences' : '',
|
|
2774
|
+
soldOut ? 'sold-out' : '',
|
|
2775
|
+
selected ? 'selected' : '',
|
|
2776
|
+
isToday ? 'today' : '',
|
|
2777
|
+
past ? 'past' : '',
|
|
2778
|
+
]
|
|
2779
|
+
.filter(Boolean)
|
|
2780
|
+
.join(' '),
|
|
2781
|
+
};
|
|
2782
|
+
});
|
|
2783
|
+
return {
|
|
2784
|
+
mode,
|
|
2785
|
+
horizontal,
|
|
2786
|
+
viewClass: `calendar-view ${horizontal ? 'horizontal' : 'vertical'}`,
|
|
2787
|
+
rows: occurrences.map(row),
|
|
2788
|
+
slots: inTimeOrder.map(row),
|
|
2789
|
+
slotsDateLabel: inTimeOrder.length ? formatDate(new Date(inTimeOrder[0].startsAt)) : null,
|
|
2790
|
+
monthLabel: formatMonth(state.currentMonth),
|
|
2791
|
+
showMonthNav: !isSingleMonth(occurrences),
|
|
2792
|
+
canGoBack: !!firstMonth && state.currentMonth > firstMonth,
|
|
2793
|
+
canGoForward: !!lastMonth && state.currentMonth < lastMonth,
|
|
2794
|
+
weekdayLabels: [
|
|
2795
|
+
strings.weekdayMon,
|
|
2796
|
+
strings.weekdayTue,
|
|
2797
|
+
strings.weekdayWed,
|
|
2798
|
+
strings.weekdayThu,
|
|
2799
|
+
strings.weekdayFri,
|
|
2800
|
+
strings.weekdaySat,
|
|
2801
|
+
strings.weekdaySun,
|
|
2802
|
+
],
|
|
2803
|
+
days,
|
|
2804
|
+
selectedDateLabel: selectedDate && selectedOn.length ? formatDate(selectedDate) : null,
|
|
2805
|
+
selectedSlots: selectedOn.map(row),
|
|
2806
|
+
};
|
|
2807
|
+
},
|
|
2808
|
+
actions: (ctx) => ({
|
|
2809
|
+
/**
|
|
2810
|
+
* Read the event and take its occurrences. Also what Retry runs, which is
|
|
2811
|
+
* why it sets `loading` and clears the error itself rather than relying on
|
|
2812
|
+
* the state it started in.
|
|
2813
|
+
*/
|
|
2814
|
+
async load() {
|
|
2815
|
+
ctx.setState({ loading: true, error: null });
|
|
2816
|
+
try {
|
|
2817
|
+
const events = ctx.client().getEventsManager();
|
|
2818
|
+
const read = events.get?.bind(events);
|
|
2819
|
+
if (!read)
|
|
2820
|
+
throw new TypeError('eventsManager.get is not a function');
|
|
2821
|
+
const detail = (await read(ctx.props.eventId));
|
|
2822
|
+
if (!detail)
|
|
2823
|
+
throw new Error(ctx.strings.notFound);
|
|
2824
|
+
const occurrences = (detail.occurrences ?? []).map(toOccurrence);
|
|
2825
|
+
ctx.setState({ occurrences, ...(initialSelection(occurrences) ?? {}) });
|
|
2826
|
+
}
|
|
2827
|
+
catch (err) {
|
|
2828
|
+
ctx.setState({
|
|
2829
|
+
error: err instanceof Error ? err.message : ctx.strings.loadFailed,
|
|
2830
|
+
occurrences: [],
|
|
2831
|
+
});
|
|
2832
|
+
}
|
|
2833
|
+
finally {
|
|
2834
|
+
ctx.setState({ loading: false });
|
|
2835
|
+
}
|
|
2836
|
+
},
|
|
2837
|
+
/** Nothing to read: no event id, or the host never produced a client. */
|
|
2838
|
+
loadFailed(message) {
|
|
2839
|
+
ctx.setState({ error: message, loading: false });
|
|
2840
|
+
},
|
|
2841
|
+
/**
|
|
2842
|
+
* The buyer picked an occurrence. A sold out one is not pickable, which the
|
|
2843
|
+
* harness also renders as disabled: the core decides, the harness shows.
|
|
2844
|
+
*/
|
|
2845
|
+
select(occurrenceId) {
|
|
2846
|
+
const occurrence = ctx.state.occurrences.find((o) => o.id === occurrenceId);
|
|
2847
|
+
if (!occurrence || isSoldOut(occurrence))
|
|
2848
|
+
return;
|
|
2849
|
+
ctx.emit('tlSelect', { occurrence });
|
|
2850
|
+
},
|
|
2851
|
+
/** The buyer picked a day. Only a day with something on it counts. */
|
|
2852
|
+
selectDate(key) {
|
|
2853
|
+
if (!occurrencesByDate(ctx.state.occurrences).has(key))
|
|
2854
|
+
return;
|
|
2855
|
+
const date = new Date(key);
|
|
2856
|
+
if (Number.isNaN(date.getTime()))
|
|
2857
|
+
return;
|
|
2858
|
+
const { currentMonth } = ctx.state;
|
|
2859
|
+
const movedMonth = date.getMonth() !== currentMonth.getMonth() || date.getFullYear() !== currentMonth.getFullYear();
|
|
2860
|
+
ctx.setState({
|
|
2861
|
+
selectedDate: date,
|
|
2862
|
+
...(movedMonth ? { currentMonth: firstOfMonth(date) } : {}),
|
|
2863
|
+
});
|
|
2864
|
+
},
|
|
2865
|
+
/** Back a month, and never before the first month there is anything in. */
|
|
2866
|
+
previousMonth() {
|
|
2867
|
+
const range = dateRangeOf(ctx.state.occurrences);
|
|
2868
|
+
if (!range)
|
|
2869
|
+
return;
|
|
2870
|
+
const next = new Date(ctx.state.currentMonth);
|
|
2871
|
+
next.setMonth(next.getMonth() - 1);
|
|
2872
|
+
if (next >= firstOfMonth(range.first))
|
|
2873
|
+
ctx.setState({ currentMonth: next });
|
|
2874
|
+
},
|
|
2875
|
+
/** Forward a month, and never past the last month there is anything in. */
|
|
2876
|
+
nextMonth() {
|
|
2877
|
+
const range = dateRangeOf(ctx.state.occurrences);
|
|
2878
|
+
if (!range)
|
|
2879
|
+
return;
|
|
2880
|
+
const next = new Date(ctx.state.currentMonth);
|
|
2881
|
+
next.setMonth(next.getMonth() + 1);
|
|
2882
|
+
if (next <= firstOfMonth(range.last))
|
|
2883
|
+
ctx.setState({ currentMonth: next });
|
|
2884
|
+
},
|
|
2885
|
+
/**
|
|
2886
|
+
* How wide the harness measures the element. What a width means is the
|
|
2887
|
+
* core's (`auto` turns horizontal at 768px); measuring one is the
|
|
2888
|
+
* runtime's, and on the web that is a `ResizeObserver`.
|
|
2889
|
+
*/
|
|
2890
|
+
resized(width) {
|
|
2891
|
+
if (width === ctx.state.containerWidth)
|
|
2892
|
+
return;
|
|
2893
|
+
ctx.setState({ containerWidth: width });
|
|
2894
|
+
},
|
|
2895
|
+
}),
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
/**
|
|
2899
|
+
* `tl-order-confirmation`: the thank-you block - reference, email, event and
|
|
2900
|
+
* date, the ticket lines, the money rows, and the tickets themselves.
|
|
2901
|
+
*
|
|
2902
|
+
* It finds its order three ways, in this order, and that priority is the whole
|
|
2903
|
+
* point of the element:
|
|
2904
|
+
*
|
|
2905
|
+
* 1. the `order-id` prop, for a host that already knows;
|
|
2906
|
+
* 2. the query the page was opened with (`?orderId=`), which is the contract
|
|
2907
|
+
* the hosted box office confirmation page uses, plus `?redirect_status=`,
|
|
2908
|
+
* which is how a payment provider says on the way back whether the money
|
|
2909
|
+
* actually moved;
|
|
2910
|
+
* 3. the client's `checkout:completed`, for a page that mounts this before
|
|
2911
|
+
* the customer has paid.
|
|
2912
|
+
*
|
|
2913
|
+
* (2) is why this core reads {@link ActionContext.query} and (3) is why it
|
|
2914
|
+
* holds a subscription, bound in its own `mount` and handed to
|
|
2915
|
+
* `ctx.onTeardown` (TKT-197). It is the last of the eighteen elements to live
|
|
2916
|
+
* on a subscription.
|
|
2917
|
+
*
|
|
2918
|
+
* Behaviour is what the Stencil component did, to the letter, including:
|
|
2919
|
+
*
|
|
2920
|
+
* - a redirect that is not `succeeded` shows "Payment not completed" and does
|
|
2921
|
+
* NOT read the order, even when there is an order id to read.
|
|
2922
|
+
* - a re-read while an order is already on screen does not blank it: the
|
|
2923
|
+
* skeleton is only for the first read.
|
|
2924
|
+
* - a failed read keeps the thank-you header and puts the message underneath,
|
|
2925
|
+
* because someone who has just paid should not be told the purchase failed.
|
|
2926
|
+
* - `checkout:completed` shows what it carries at once and THEN re-reads,
|
|
2927
|
+
* because an embed's payload is only the id and the order number until the
|
|
2928
|
+
* tickets have been issued.
|
|
2929
|
+
* - the event name and date are best effort. The order view carries neither,
|
|
2930
|
+
* so they are walked out of the channel's listing, and a failure there
|
|
2931
|
+
* leaves the line at whatever the order itself said.
|
|
2932
|
+
*
|
|
2933
|
+
* What did not come across is the `console.error` tracing, which a library
|
|
2934
|
+
* should not do for its host (`tl-cart` dropped its own for the same reason).
|
|
2935
|
+
*/
|
|
2936
|
+
const STRINGS$2 = {
|
|
2937
|
+
paymentFailedTitle: 'Payment not completed',
|
|
2938
|
+
paymentFailedLead: "Your payment wasn't completed. Your cart is still saved, so you can try again.",
|
|
2939
|
+
title: "You're going!",
|
|
2940
|
+
leadPrefix: 'Order ',
|
|
2941
|
+
leadSuffix: ' is confirmed. Your tickets are on their way to your email.',
|
|
2942
|
+
leadGeneric: 'Your order is confirmed. Your tickets are on their way to your email.',
|
|
2943
|
+
errorPrefix: "We couldn't load your order details yet. ",
|
|
2944
|
+
rowReference: 'Order reference',
|
|
2945
|
+
rowEmail: 'Email',
|
|
2946
|
+
rowEvent: 'Event',
|
|
2947
|
+
rowDate: 'Date',
|
|
2948
|
+
subtotalRow: 'Subtotal',
|
|
2949
|
+
taxRow: 'Tax',
|
|
2950
|
+
discountRow: 'Discount',
|
|
2951
|
+
totalRow: 'Total',
|
|
2952
|
+
ticketsTitle: 'Your tickets',
|
|
2953
|
+
ticketsHint: 'Show these QR codes at the door for entry. Use Download PDF on a ticket to save it.',
|
|
2954
|
+
noOrdersManager: 'Orders are not available',
|
|
2955
|
+
notFound: 'Order not found',
|
|
2956
|
+
loadFailed: 'Failed to load your order',
|
|
2957
|
+
};
|
|
2958
|
+
/** The query parameter a payment provider comes back on, and the one value that means paid. */
|
|
2959
|
+
const REDIRECT_STATUS = 'redirect_status';
|
|
2960
|
+
const REDIRECT_SUCCEEDED = 'succeeded';
|
|
2961
|
+
const orderConfirmationCore = defineElement({
|
|
2962
|
+
tag: 'tl-order-confirmation',
|
|
2963
|
+
docs: 'The thank-you block: order reference, email, event and date, the ticket lines, the money rows and the tickets.',
|
|
2964
|
+
category: 'checkout',
|
|
2965
|
+
runtimes: ['web', 'native'],
|
|
2966
|
+
tokens: [
|
|
2967
|
+
'border',
|
|
2968
|
+
'error',
|
|
2969
|
+
'font-family',
|
|
2970
|
+
'font-family-mono',
|
|
2971
|
+
'foreground',
|
|
2972
|
+
'muted',
|
|
2973
|
+
'muted-foreground',
|
|
2974
|
+
'radius-lg',
|
|
2975
|
+
'radius-md',
|
|
2976
|
+
'secondary',
|
|
2977
|
+
'spacing-1',
|
|
2978
|
+
'spacing-2',
|
|
2979
|
+
'spacing-3',
|
|
2980
|
+
'spacing-4',
|
|
2981
|
+
'spacing-6',
|
|
2982
|
+
'success',
|
|
2983
|
+
'success-foreground',
|
|
2984
|
+
],
|
|
2985
|
+
sdkMethods: ['orders.get', 'events.list', 'events.get', 'auth.customer', 'on'],
|
|
2986
|
+
props: {
|
|
2987
|
+
orderId: {
|
|
2988
|
+
type: 'string',
|
|
2989
|
+
tsType: 'string | undefined',
|
|
2990
|
+
attr: 'order-id',
|
|
2991
|
+
docs: 'The order to show. Falls back to `?orderId=` in the page URL.',
|
|
2992
|
+
},
|
|
2993
|
+
customerEmail: {
|
|
2994
|
+
type: 'string',
|
|
2995
|
+
tsType: 'string | undefined',
|
|
2996
|
+
attr: 'customer-email',
|
|
2997
|
+
docs: 'Email to show when the order view carries none (a host that collected it at checkout).',
|
|
2998
|
+
},
|
|
2999
|
+
showTickets: {
|
|
3000
|
+
type: 'boolean',
|
|
3001
|
+
attr: 'show-tickets',
|
|
3002
|
+
default: true,
|
|
3003
|
+
docs: 'Mount tl-order-tickets under the summary (default true).',
|
|
3004
|
+
},
|
|
3005
|
+
columns: { type: 'number', attr: 'columns', default: 2, docs: 'Columns for the ticket grid.' },
|
|
3006
|
+
},
|
|
3007
|
+
events: {
|
|
3008
|
+
tlOrderLoaded: { detail: '{ order: TlOrderData; }', docs: 'the order was loaded' },
|
|
3009
|
+
tlError: { detail: '{ message: string; }', docs: 'the order could not be loaded' },
|
|
3010
|
+
},
|
|
3011
|
+
strings: STRINGS$2,
|
|
3012
|
+
statuses: ['payment-failed', 'idle', 'loading', 'ready'],
|
|
3013
|
+
state: () => ({
|
|
3014
|
+
order: null,
|
|
3015
|
+
resolvedOrderId: null,
|
|
3016
|
+
loading: false,
|
|
3017
|
+
error: null,
|
|
3018
|
+
paymentFailed: false,
|
|
3019
|
+
occurrence: null,
|
|
3020
|
+
signedInCustomer: null,
|
|
3021
|
+
}),
|
|
3022
|
+
/**
|
|
3023
|
+
* On screen: read the link, start the read, subscribe.
|
|
3024
|
+
*
|
|
3025
|
+
* The query goes first because it decides whether there is anything to read
|
|
3026
|
+
* at all: a payment that did not go through is not an order to fetch. The
|
|
3027
|
+
* read is started and not awaited before subscribing, so a
|
|
3028
|
+
* `checkout:completed` that lands mid-read is not missed - the component
|
|
3029
|
+
* read in `componentWillLoad` and subscribed in `componentDidLoad`, and had
|
|
3030
|
+
* exactly that window.
|
|
3031
|
+
*
|
|
3032
|
+
* The subscription needs the client and the read does not: `load` waits for
|
|
3033
|
+
* the client itself, inside the try block that announces a failure, which is
|
|
3034
|
+
* why a page that never starts Live still emits `tlError` here when it was
|
|
3035
|
+
* given an order to show.
|
|
3036
|
+
*/
|
|
3037
|
+
mount: (ctx) => {
|
|
3038
|
+
void (async () => {
|
|
3039
|
+
// The link, before anything else: a failed redirect means no read.
|
|
3040
|
+
const redirect = ctx.query.get(REDIRECT_STATUS);
|
|
3041
|
+
ctx.setState({
|
|
3042
|
+
paymentFailed: !!redirect && redirect !== REDIRECT_SUCCEEDED,
|
|
3043
|
+
resolvedOrderId: ctx.props.orderId || ctx.query.get('orderId') || null,
|
|
3044
|
+
});
|
|
3045
|
+
const reading = ctx.state.resolvedOrderId && !ctx.state.paymentFailed
|
|
3046
|
+
? ctx.actions.load()
|
|
3047
|
+
: Promise.resolve();
|
|
3048
|
+
let client = null;
|
|
3049
|
+
try {
|
|
3050
|
+
client = await ctx.whenClient();
|
|
3051
|
+
}
|
|
3052
|
+
catch {
|
|
3053
|
+
client = null;
|
|
3054
|
+
}
|
|
3055
|
+
if (client && ctx.active()) {
|
|
3056
|
+
const off = client.on?.('checkout:completed', (payload) => {
|
|
3057
|
+
ctx.actions.checkoutCompleted(payload);
|
|
3058
|
+
});
|
|
3059
|
+
if (typeof off === 'function')
|
|
3060
|
+
ctx.onTeardown(off);
|
|
3061
|
+
}
|
|
3062
|
+
await reading;
|
|
3063
|
+
})();
|
|
3064
|
+
},
|
|
3065
|
+
status: ({ state }) => state.paymentFailed
|
|
3066
|
+
? 'payment-failed'
|
|
3067
|
+
: !state.resolvedOrderId && !state.order
|
|
3068
|
+
? 'idle'
|
|
3069
|
+
: state.loading
|
|
3070
|
+
? 'loading'
|
|
3071
|
+
: 'ready',
|
|
3072
|
+
derive: ({ state, props, strings }) => {
|
|
3073
|
+
const order = state.order;
|
|
3074
|
+
const totals = orderTotals(order);
|
|
3075
|
+
const currency = totals.currency;
|
|
3076
|
+
const email = props.customerEmail || orderCustomerEmail(order, state.signedInCustomer);
|
|
3077
|
+
const eventName = state.occurrence?.eventName || (order ? orderEventName(order) : null);
|
|
3078
|
+
const startsAt = state.occurrence?.startsAt;
|
|
3079
|
+
const rows = [];
|
|
3080
|
+
if (order)
|
|
3081
|
+
rows.push({ label: strings.rowReference, value: order.orderNumber, reference: true });
|
|
3082
|
+
if (email)
|
|
3083
|
+
rows.push({ label: strings.rowEmail, value: email, reference: false });
|
|
3084
|
+
if (eventName)
|
|
3085
|
+
rows.push({ label: strings.rowEvent, value: eventName, reference: false });
|
|
3086
|
+
if (startsAt)
|
|
3087
|
+
rows.push({ label: strings.rowDate, value: formatDateTime(startsAt), reference: false });
|
|
3088
|
+
return {
|
|
3089
|
+
leadText: order?.orderNumber
|
|
3090
|
+
? `${strings.leadPrefix}${order.orderNumber}${strings.leadSuffix}`
|
|
3091
|
+
: strings.leadGeneric,
|
|
3092
|
+
rows,
|
|
3093
|
+
lines: order
|
|
3094
|
+
? ticketItems(order).map((item) => ({
|
|
3095
|
+
label: `${item.quantity} x ${item.name}`,
|
|
3096
|
+
priceLabel: formatPrice$1(item.subtotal, order.currency),
|
|
3097
|
+
}))
|
|
3098
|
+
: [],
|
|
3099
|
+
totals,
|
|
3100
|
+
subtotalLabel: formatPrice$1(totals.subtotal, currency),
|
|
3101
|
+
feeAmountLabel: formatPrice$1(totals.fees, currency),
|
|
3102
|
+
taxLabel: formatPrice$1(totals.tax, currency),
|
|
3103
|
+
discountLabel: formatPrice$1(totals.discount, currency),
|
|
3104
|
+
totalLabel: formatPrice$1(totals.total, currency),
|
|
3105
|
+
showTaxRow: totals.tax > 0,
|
|
3106
|
+
showDiscountRow: totals.discount > 0,
|
|
3107
|
+
// An embed's checkout:completed carries only id and order number until
|
|
3108
|
+
// the refetch lands, so there is nothing to summarise yet.
|
|
3109
|
+
showSummary: !!order && Array.isArray(order.items),
|
|
3110
|
+
showError: !!state.error && !order,
|
|
3111
|
+
showTickets: props.showTickets && !!order,
|
|
3112
|
+
ticketsOrderId: order?.id || state.resolvedOrderId || '',
|
|
3113
|
+
};
|
|
3114
|
+
},
|
|
3115
|
+
actions: (ctx) => {
|
|
3116
|
+
/**
|
|
3117
|
+
* Put an event name and a start time on the summary. The order view has
|
|
3118
|
+
* neither, so the occurrence is walked out of the channel's listing. Best
|
|
3119
|
+
* effort and never blocking: a failure leaves the line at what the order
|
|
3120
|
+
* said, and a result that arrives after the order changed underneath is
|
|
3121
|
+
* dropped.
|
|
3122
|
+
*/
|
|
3123
|
+
const resolveEvent = async (client, order) => {
|
|
3124
|
+
const occurrenceId = order.tickets?.find((t) => t.eventOccurrenceId)?.eventOccurrenceId;
|
|
3125
|
+
if (!occurrenceId)
|
|
3126
|
+
return;
|
|
3127
|
+
try {
|
|
3128
|
+
const resolved = await resolveOccurrence(client, occurrenceId);
|
|
3129
|
+
if (resolved && ctx.state.order?.id === order.id)
|
|
3130
|
+
ctx.setState({ occurrence: resolved });
|
|
3131
|
+
}
|
|
3132
|
+
catch {
|
|
3133
|
+
// Leave the event line at what the order gives us.
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
const self = {
|
|
3137
|
+
/** Read the order. The skeleton is only for the first read. */
|
|
3138
|
+
async load() {
|
|
3139
|
+
const id = ctx.state.resolvedOrderId;
|
|
3140
|
+
if (!id)
|
|
3141
|
+
return;
|
|
3142
|
+
try {
|
|
3143
|
+
ctx.setState({ loading: !ctx.state.order, error: null });
|
|
3144
|
+
const client = await ctx.whenClient();
|
|
3145
|
+
const orders = client.getOrdersManager?.();
|
|
3146
|
+
if (!orders)
|
|
3147
|
+
throw new Error(ctx.strings.noOrdersManager);
|
|
3148
|
+
const order = (await orders.get(id));
|
|
3149
|
+
if (!order)
|
|
3150
|
+
throw new Error(ctx.strings.notFound);
|
|
3151
|
+
ctx.setState({
|
|
3152
|
+
order,
|
|
3153
|
+
signedInCustomer: client.getAuthManager?.()?.customer ?? null,
|
|
3154
|
+
});
|
|
3155
|
+
ctx.emit('tlOrderLoaded', { order });
|
|
3156
|
+
void resolveEvent(client, order);
|
|
3157
|
+
}
|
|
3158
|
+
catch (err) {
|
|
3159
|
+
const message = errorMessage(err, ctx.strings.loadFailed);
|
|
3160
|
+
ctx.setState({ error: message });
|
|
3161
|
+
ctx.emit('tlError', { message });
|
|
3162
|
+
}
|
|
3163
|
+
finally {
|
|
3164
|
+
ctx.setState({ loading: false });
|
|
3165
|
+
}
|
|
3166
|
+
},
|
|
3167
|
+
/**
|
|
3168
|
+
* The host pointed the element at a different order. It resolves again
|
|
3169
|
+
* the way the mount did - the prop first, the link second - and reads.
|
|
3170
|
+
* Not a remount: the `checkout:completed` subscription belongs to the
|
|
3171
|
+
* element, not to the order it happens to be showing.
|
|
3172
|
+
*/
|
|
3173
|
+
orderIdChanged() {
|
|
3174
|
+
ctx.setState({ resolvedOrderId: ctx.props.orderId || ctx.query.get('orderId') || null });
|
|
3175
|
+
void self.load();
|
|
3176
|
+
},
|
|
3177
|
+
/**
|
|
3178
|
+
* The client finished a checkout. Show what it carries at once, because
|
|
3179
|
+
* an order number on screen beats a skeleton, then read the order again:
|
|
3180
|
+
* the tickets are issued after the order exists, so the payload almost
|
|
3181
|
+
* never has them.
|
|
3182
|
+
*/
|
|
3183
|
+
checkoutCompleted(order) {
|
|
3184
|
+
if (!order?.id)
|
|
3185
|
+
return;
|
|
3186
|
+
ctx.setState({ paymentFailed: false, order, resolvedOrderId: order.id });
|
|
3187
|
+
ctx.emit('tlOrderLoaded', { order });
|
|
3188
|
+
void self.load();
|
|
3189
|
+
},
|
|
3190
|
+
};
|
|
3191
|
+
return self;
|
|
3192
|
+
},
|
|
3193
|
+
});
|
|
3194
|
+
|
|
3195
|
+
/**
|
|
3196
|
+
* `tl-order-tickets`: every ticket in an order, as scannable cards.
|
|
3197
|
+
*
|
|
3198
|
+
* The one core that waits rather than concluding. Tickets are issued
|
|
3199
|
+
* asynchronously - the payment confirms, the order is marked paid, and
|
|
3200
|
+
* entitlement issuance follows a moment later - so a confirmation page renders
|
|
3201
|
+
* BEFORE the passes exist, reliably rather than occasionally. The element used
|
|
3202
|
+
* to answer that with "no tickets for this order yet", which is true and reads
|
|
3203
|
+
* as "something went wrong" to somebody who has just been charged. Refreshing
|
|
3204
|
+
* showed them, so the only thing missing was the waiting.
|
|
3205
|
+
*
|
|
3206
|
+
* So an empty order is treated as issuance in flight and polled, with the gaps
|
|
3207
|
+
* widening, for about a minute. Then it stops and says the tickets are coming
|
|
3208
|
+
* by email, because at that point something probably is wrong and an element
|
|
3209
|
+
* that polls forever is worse than one that admits it.
|
|
3210
|
+
*
|
|
3211
|
+
* The polling belongs to the mount, which is what the split adds: the
|
|
3212
|
+
* component cancelled its timer in `disconnectedCallback`, and here the mount's
|
|
3213
|
+
* teardown does it, so every runtime gets the same guarantee rather than each
|
|
3214
|
+
* harness remembering. A scheduled poll is cancelled by moving the generation
|
|
3215
|
+
* on rather than by clearing a handle - `ctx.after` has no handle to clear -
|
|
3216
|
+
* so a stale timer still fires and does nothing, which is the same thing from
|
|
3217
|
+
* outside: no request is made.
|
|
3218
|
+
*
|
|
3219
|
+
* Behaviour is what the Stencil component did, to the letter, including:
|
|
3220
|
+
*
|
|
3221
|
+
* - a failed read STOPS the polling. Retrying a 403 a dozen times helps
|
|
3222
|
+
* nobody, and a failure is a different thing from an order with no tickets.
|
|
3223
|
+
* - `loading` is only ever true before the first read finishes. Every later
|
|
3224
|
+
* read, including a poll and a new order id, leaves whatever is on screen.
|
|
3225
|
+
* - pointing it at another order clears the tickets without putting the
|
|
3226
|
+
* loading state back, so there is a beat where the element reads as "taking
|
|
3227
|
+
* longer than usual" before the new read lands. Kept, not tidied.
|
|
3228
|
+
*
|
|
3229
|
+
* There is no `tlError` here, and no event of any kind: nothing this element
|
|
3230
|
+
* does is announced to the host. Kept as found.
|
|
3231
|
+
*/
|
|
3232
|
+
/**
|
|
3233
|
+
* Gaps between polls, in milliseconds. Front-loaded because issuance usually
|
|
3234
|
+
* lands in the first few seconds, then widening so a genuinely stuck order is
|
|
3235
|
+
* not hammered. Sums to roughly one minute, and running out is what turns
|
|
3236
|
+
* `issuing` into `delayed`.
|
|
3237
|
+
*/
|
|
3238
|
+
const POLL_DELAYS_MS = [
|
|
3239
|
+
1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000, 12000,
|
|
3240
|
+
];
|
|
3241
|
+
const STRINGS$1 = {
|
|
3242
|
+
loading: 'Loading tickets…',
|
|
3243
|
+
preparing: 'Preparing your tickets…',
|
|
3244
|
+
delayed: 'Your tickets are taking longer than usual. They will be emailed to you as soon as they are ready.',
|
|
3245
|
+
notAvailable: 'Tickets are not available',
|
|
3246
|
+
loadFailed: 'Failed to load tickets',
|
|
3247
|
+
};
|
|
3248
|
+
const orderTicketsCore = defineElement({
|
|
3249
|
+
tag: 'tl-order-tickets',
|
|
3250
|
+
docs: "Every ticket in an order as a scannable card, waiting out issuance rather than reporting an empty order as empty.",
|
|
3251
|
+
category: 'tickets',
|
|
3252
|
+
runtimes: ['web', 'native'],
|
|
3253
|
+
tokens: ['border', 'error', 'font-family', 'muted-foreground', 'primary', 'spacing-2', 'spacing-4', 'spacing-6'],
|
|
3254
|
+
sdkMethods: ['orders.get'],
|
|
3255
|
+
props: {
|
|
3256
|
+
orderId: { type: 'string', attr: 'order-id', required: true, docs: 'The order whose tickets to show.' },
|
|
3257
|
+
columns: { type: 'number', attr: 'columns', default: 2, docs: 'Columns in the grid (default 2).' },
|
|
3258
|
+
},
|
|
3259
|
+
events: {},
|
|
3260
|
+
strings: STRINGS$1,
|
|
3261
|
+
statuses: ['loading', 'error', 'issuing', 'delayed', 'ready'],
|
|
3262
|
+
state: () => ({ tickets: [], loading: true, error: null, issuing: false }),
|
|
3263
|
+
/**
|
|
3264
|
+
* On screen: read, and keep reading while issuance is in flight. Off screen:
|
|
3265
|
+
* stop, which is the whole reason the polling lives in the mount.
|
|
3266
|
+
*/
|
|
3267
|
+
mount: (ctx) => {
|
|
3268
|
+
ctx.onTeardown(() => ctx.actions.stopPolling());
|
|
3269
|
+
void ctx.actions.load();
|
|
3270
|
+
},
|
|
3271
|
+
status: ({ state }) => state.loading
|
|
3272
|
+
? 'loading'
|
|
3273
|
+
: state.error
|
|
3274
|
+
? 'error'
|
|
3275
|
+
: state.tickets.length > 0
|
|
3276
|
+
? 'ready'
|
|
3277
|
+
: state.issuing
|
|
3278
|
+
? 'issuing'
|
|
3279
|
+
: 'delayed',
|
|
3280
|
+
derive: ({ state, props }) => ({
|
|
3281
|
+
tickets: state.tickets,
|
|
3282
|
+
columns: props.columns,
|
|
3283
|
+
orderId: props.orderId,
|
|
3284
|
+
}),
|
|
3285
|
+
actions: (ctx) => {
|
|
3286
|
+
// Neither of these is state: nothing renders from them, and the component
|
|
3287
|
+
// held both as instance fields for the same reason. `generation` is how a
|
|
3288
|
+
// scheduled poll is cancelled, since `ctx.after` hands back no handle.
|
|
3289
|
+
let generation = 0;
|
|
3290
|
+
let poll = 0;
|
|
3291
|
+
const self = {
|
|
3292
|
+
/**
|
|
3293
|
+
* Read the order. Tickets end the waiting; no tickets and a gap left in
|
|
3294
|
+
* the schedule extends it; no tickets and no gap left gives up.
|
|
3295
|
+
*/
|
|
3296
|
+
async load() {
|
|
3297
|
+
try {
|
|
3298
|
+
ctx.setState({ error: null });
|
|
3299
|
+
const client = await ctx.whenClient();
|
|
3300
|
+
const orders = client.getOrdersManager?.();
|
|
3301
|
+
if (!orders)
|
|
3302
|
+
throw new Error(ctx.strings.notAvailable);
|
|
3303
|
+
const order = (await orders.get(ctx.props.orderId));
|
|
3304
|
+
const tickets = order?.tickets ?? [];
|
|
3305
|
+
ctx.setState({ tickets });
|
|
3306
|
+
if (tickets.length > 0) {
|
|
3307
|
+
// Arrived. Stop asking.
|
|
3308
|
+
ctx.setState({ issuing: false });
|
|
3309
|
+
self.stopPolling();
|
|
3310
|
+
}
|
|
3311
|
+
else {
|
|
3312
|
+
const delay = POLL_DELAYS_MS[poll];
|
|
3313
|
+
// `undefined` once the schedule runs out: give up waiting, and say so.
|
|
3314
|
+
ctx.setState({ issuing: delay !== undefined });
|
|
3315
|
+
if (delay !== undefined) {
|
|
3316
|
+
poll += 1;
|
|
3317
|
+
self.stopPolling();
|
|
3318
|
+
const mine = generation;
|
|
3319
|
+
ctx.after(delay, () => {
|
|
3320
|
+
if (mine === generation)
|
|
3321
|
+
void self.load();
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
catch (err) {
|
|
3327
|
+
// A failed request is a different thing from an order with no
|
|
3328
|
+
// tickets, and stops the polling.
|
|
3329
|
+
//
|
|
3330
|
+
// Defensive rather than load bearing, as it was in the component:
|
|
3331
|
+
// a poll is scheduled only on the success path, and the timer that
|
|
3332
|
+
// triggered this read has already fired, so there is normally
|
|
3333
|
+
// nothing left to cancel. Mutation testing says so out loud -
|
|
3334
|
+
// removing this line kills no test - and it is kept because the
|
|
3335
|
+
// component had it and because it is what makes the intent legible.
|
|
3336
|
+
self.stopPolling();
|
|
3337
|
+
ctx.setState({ issuing: false, error: err instanceof Error ? err.message : ctx.strings.loadFailed });
|
|
3338
|
+
}
|
|
3339
|
+
finally {
|
|
3340
|
+
ctx.setState({ loading: false });
|
|
3341
|
+
}
|
|
3342
|
+
},
|
|
3343
|
+
/**
|
|
3344
|
+
* A different order: forget this one, put the schedule back to the top
|
|
3345
|
+
* and read. The loading state is deliberately NOT restored, so the
|
|
3346
|
+
* element reads as "taking longer than usual" for a beat, exactly as the
|
|
3347
|
+
* component did.
|
|
3348
|
+
*/
|
|
3349
|
+
orderIdChanged() {
|
|
3350
|
+
self.stopPolling();
|
|
3351
|
+
poll = 0;
|
|
3352
|
+
ctx.setState({ tickets: [], issuing: false });
|
|
3353
|
+
void self.load();
|
|
3354
|
+
},
|
|
3355
|
+
/** Cancel whatever poll is scheduled. Also what the teardown runs. */
|
|
3356
|
+
stopPolling() {
|
|
3357
|
+
generation += 1;
|
|
3358
|
+
},
|
|
3359
|
+
};
|
|
3360
|
+
return self;
|
|
3361
|
+
},
|
|
3362
|
+
});
|
|
3363
|
+
|
|
3364
|
+
/**
|
|
3365
|
+
* `tl-ticket-selector`: the reference core.
|
|
3366
|
+
*
|
|
3367
|
+
* This is the whole of what the element does. The Stencil harness in
|
|
3368
|
+
* `packages/elements/src/components/tl-ticket-selector/` renders `view` and
|
|
3369
|
+
* forwards the three `tl*` events; it decides nothing, holds no copy and does
|
|
3370
|
+
* no arithmetic. A React Native harness will render the same view.
|
|
3371
|
+
*
|
|
3372
|
+
* Behaviour is the behaviour the Stencil component had before the split, to
|
|
3373
|
+
* the letter: the same quantity clamping, the same reset after a successful
|
|
3374
|
+
* add, the same three second success message, the same button text, and the
|
|
3375
|
+
* same rule that no fee row is shown until the surface quotes a fee. The one
|
|
3376
|
+
* thing that did not come across is the `console.log` tracing, which a library
|
|
3377
|
+
* should not do for its host.
|
|
3378
|
+
*/
|
|
3379
|
+
const STRINGS = {
|
|
3380
|
+
selectTickets: 'Select Tickets',
|
|
3381
|
+
adding: 'Adding...',
|
|
3382
|
+
added: 'Added to cart!',
|
|
3383
|
+
limited: 'Limited',
|
|
3384
|
+
soldOut: 'Sold Out',
|
|
3385
|
+
ticketsRow: 'Tickets',
|
|
3386
|
+
totalRow: 'Total',
|
|
3387
|
+
decrease: 'Decrease quantity',
|
|
3388
|
+
increase: 'Increase quantity',
|
|
3389
|
+
loadFailed: 'Failed to load tickets',
|
|
3390
|
+
addFailed: 'Failed to add to cart',
|
|
3391
|
+
noClient: 'SDK not initialized.',
|
|
3392
|
+
};
|
|
3393
|
+
/** How long the "Added to cart!" message stays up. */
|
|
3394
|
+
const SUCCESS_MS = 3000;
|
|
3395
|
+
const totalsOf = (ctx) => selectionTotals(ctx.state.ticketTypes.map((tt) => ({ price: tt.price, quantity: ctx.state.quantities[tt.id] || 0 })), ctx.state.feeQuote);
|
|
3396
|
+
const totalItemsOf = (state) => Object.values(state.quantities).reduce((sum, qty) => sum + qty, 0);
|
|
3397
|
+
const ticketSelectorCore = defineElement({
|
|
3398
|
+
tag: 'tl-ticket-selector',
|
|
3399
|
+
docs: 'Pick quantities of the ticket types on sale for one occurrence, and add them to the cart.',
|
|
3400
|
+
category: 'purchase',
|
|
3401
|
+
runtimes: ['web', 'native'],
|
|
3402
|
+
tokens: [
|
|
3403
|
+
'background',
|
|
3404
|
+
'border',
|
|
3405
|
+
'border-hover',
|
|
3406
|
+
'error',
|
|
3407
|
+
'error-bg',
|
|
3408
|
+
'font-family',
|
|
3409
|
+
'foreground',
|
|
3410
|
+
'muted',
|
|
3411
|
+
'muted-foreground',
|
|
3412
|
+
'primary',
|
|
3413
|
+
'primary-foreground',
|
|
3414
|
+
'radius-md',
|
|
3415
|
+
'radius-sm',
|
|
3416
|
+
'spacing-1',
|
|
3417
|
+
'spacing-2',
|
|
3418
|
+
'spacing-3',
|
|
3419
|
+
'spacing-4',
|
|
3420
|
+
'success',
|
|
3421
|
+
'warning',
|
|
3422
|
+
'warning-bg',
|
|
3423
|
+
],
|
|
3424
|
+
sdkMethods: ['cart.addItem', 'events.getTicketTypes'],
|
|
3425
|
+
props: {
|
|
3426
|
+
eventId: { type: 'string', attr: 'event-id', required: true, docs: 'Event ID' },
|
|
3427
|
+
occurrenceId: { type: 'string', attr: 'occurrence-id', required: true, docs: 'Occurrence ID' },
|
|
3428
|
+
showButton: { type: 'boolean', attr: 'show-button', default: true, docs: 'Show add to cart button' },
|
|
3429
|
+
buttonLabel: { type: 'string', attr: 'button-label', default: 'Add to Cart', docs: 'Button label' },
|
|
3430
|
+
},
|
|
3431
|
+
events: {
|
|
3432
|
+
tlAddToCart: {
|
|
3433
|
+
detail: '{ items: AddToCartItem[]; }',
|
|
3434
|
+
docs: 'Emitted when items are added to cart',
|
|
3435
|
+
},
|
|
3436
|
+
tlQuantityChange: {
|
|
3437
|
+
detail: '{ ticketTypeId: string; quantity: number; total: number; }',
|
|
3438
|
+
docs: 'Emitted when quantity changes',
|
|
3439
|
+
},
|
|
3440
|
+
tlError: {
|
|
3441
|
+
detail: '{ message: string; }',
|
|
3442
|
+
docs: 'Emitted on error',
|
|
3443
|
+
},
|
|
3444
|
+
},
|
|
3445
|
+
strings: STRINGS,
|
|
3446
|
+
statuses: ['loading', 'error', 'ready'],
|
|
3447
|
+
state: () => ({
|
|
3448
|
+
ticketTypes: [],
|
|
3449
|
+
quantities: {},
|
|
3450
|
+
loading: true,
|
|
3451
|
+
error: null,
|
|
3452
|
+
adding: false,
|
|
3453
|
+
addSuccess: false,
|
|
3454
|
+
feeQuote: null,
|
|
3455
|
+
}),
|
|
3456
|
+
/**
|
|
3457
|
+
* On screen: wait for the host's client, then read the ticket types. The
|
|
3458
|
+
* harness used to do this (it awaited `whenSDK()` and called `load()`
|
|
3459
|
+
* itself); the core does it now, so every runtime loads at the same moment
|
|
3460
|
+
* and in the same order (TKT-197).
|
|
3461
|
+
*
|
|
3462
|
+
* There is nothing to undo, so nothing is returned. The guards are what make
|
|
3463
|
+
* a remount safe: `active()` is false once this mount has been torn down, so
|
|
3464
|
+
* an element that was removed while the client was still coming up neither
|
|
3465
|
+
* loads nor writes an error into a view nobody is rendering.
|
|
3466
|
+
*/
|
|
3467
|
+
mount: (ctx) => {
|
|
3468
|
+
// Nothing to read until the host has said which occurrence is on sale.
|
|
3469
|
+
// Until then the element stays in `loading`, which is what it did before.
|
|
3470
|
+
if (!ctx.props.eventId || !ctx.props.occurrenceId)
|
|
3471
|
+
return;
|
|
3472
|
+
void (async () => {
|
|
3473
|
+
try {
|
|
3474
|
+
await ctx.whenClient();
|
|
3475
|
+
if (!ctx.active())
|
|
3476
|
+
return;
|
|
3477
|
+
await ctx.actions.load();
|
|
3478
|
+
}
|
|
3479
|
+
catch (err) {
|
|
3480
|
+
if (!ctx.active())
|
|
3481
|
+
return;
|
|
3482
|
+
ctx.actions.connectFailed(err instanceof Error ? err.message : ctx.strings.noClient);
|
|
3483
|
+
}
|
|
3484
|
+
})();
|
|
3485
|
+
},
|
|
3486
|
+
status: ({ state }) => (state.loading ? 'loading' : state.error ? 'error' : 'ready'),
|
|
3487
|
+
derive: (ctx) => {
|
|
3488
|
+
const { state, strings, props, formatMoney } = ctx;
|
|
3489
|
+
const totals = totalsOf(ctx);
|
|
3490
|
+
const totalItems = totalItemsOf(state);
|
|
3491
|
+
const currency = state.ticketTypes[0]?.currency;
|
|
3492
|
+
const rows = state.ticketTypes.map((tt) => {
|
|
3493
|
+
const quantity = state.quantities[tt.id] || 0;
|
|
3494
|
+
const soldOut = tt.availabilityStatus === 'sold_out';
|
|
3495
|
+
return {
|
|
3496
|
+
id: tt.id,
|
|
3497
|
+
name: tt.name,
|
|
3498
|
+
description: tt.description ?? null,
|
|
3499
|
+
priceLabel: formatMoney(tt.price, tt.currency),
|
|
3500
|
+
quantity,
|
|
3501
|
+
soldOut,
|
|
3502
|
+
limited: tt.availabilityStatus === 'limited',
|
|
3503
|
+
canDecrease: !soldOut && quantity > 0,
|
|
3504
|
+
canIncrease: !soldOut && quantity < tt.maxPerOrder,
|
|
3505
|
+
};
|
|
3506
|
+
});
|
|
3507
|
+
return {
|
|
3508
|
+
rows,
|
|
3509
|
+
totals,
|
|
3510
|
+
totalItems,
|
|
3511
|
+
currency,
|
|
3512
|
+
subtotalLabel: formatMoney(totals.subtotal, currency),
|
|
3513
|
+
feeLabel: formatMoney(totals.fees, currency),
|
|
3514
|
+
totalLabel: formatMoney(totals.total, currency),
|
|
3515
|
+
showTotals: totals.showFee && totalItems > 0,
|
|
3516
|
+
actionLabel: state.adding
|
|
3517
|
+
? strings.adding
|
|
3518
|
+
: totalItems === 0
|
|
3519
|
+
? strings.selectTickets
|
|
3520
|
+
: `${props.buttonLabel} - ${formatMoney(totals.total)}`,
|
|
3521
|
+
actionDisabled: totalItems === 0 || state.adding,
|
|
3522
|
+
};
|
|
3523
|
+
},
|
|
3524
|
+
actions: (ctx) => ({
|
|
3525
|
+
/** Read the ticket types for the current event and occurrence. */
|
|
3526
|
+
async load() {
|
|
3527
|
+
try {
|
|
3528
|
+
ctx.setState({ loading: true, error: null });
|
|
3529
|
+
const types = (await ctx.client().getEventsManager().getTicketTypes(ctx.props.eventId, ctx.props.occurrenceId));
|
|
3530
|
+
const quantities = {};
|
|
3531
|
+
for (const type of types)
|
|
3532
|
+
quantities[type.id] = 0;
|
|
3533
|
+
ctx.setState({ ticketTypes: types, quantities });
|
|
3534
|
+
}
|
|
3535
|
+
catch (err) {
|
|
3536
|
+
const message = err instanceof Error ? err.message : ctx.strings.loadFailed;
|
|
3537
|
+
ctx.setState({ error: message });
|
|
3538
|
+
ctx.emit('tlError', { message });
|
|
3539
|
+
}
|
|
3540
|
+
finally {
|
|
3541
|
+
ctx.setState({ loading: false });
|
|
3542
|
+
}
|
|
3543
|
+
},
|
|
3544
|
+
/** Nudge one row's quantity, clamped to 0 and the type's maxPerOrder. */
|
|
3545
|
+
changeQuantity(ticketTypeId, delta) {
|
|
3546
|
+
const ticketType = ctx.state.ticketTypes.find((t) => t.id === ticketTypeId);
|
|
3547
|
+
if (!ticketType)
|
|
3548
|
+
return;
|
|
3549
|
+
const current = ctx.state.quantities[ticketTypeId] || 0;
|
|
3550
|
+
const quantity = Math.max(0, Math.min(ticketType.maxPerOrder, current + delta));
|
|
3551
|
+
ctx.setState((state) => ({ quantities: { ...state.quantities, [ticketTypeId]: quantity } }));
|
|
3552
|
+
// After setState, so the total is the one the buyer can now see.
|
|
3553
|
+
ctx.emit('tlQuantityChange', { ticketTypeId, quantity, total: totalsOf(ctx).total });
|
|
3554
|
+
},
|
|
3555
|
+
/**
|
|
3556
|
+
* Add every selected line to the cart, then clear the selection.
|
|
3557
|
+
*
|
|
3558
|
+
* With no client, or a client too old to carry a cart manager, the
|
|
3559
|
+
* selection is still cleared and `tlAddToCart` still fires: a host that
|
|
3560
|
+
* listens for the event and does its own cart keeps working.
|
|
3561
|
+
*/
|
|
3562
|
+
async addToCart() {
|
|
3563
|
+
const items = Object.entries(ctx.state.quantities)
|
|
3564
|
+
.filter(([, quantity]) => quantity > 0)
|
|
3565
|
+
.map(([ticketTypeId, quantity]) => ({ ticketTypeId, quantity }));
|
|
3566
|
+
if (items.length === 0)
|
|
3567
|
+
return;
|
|
3568
|
+
ctx.setState({ adding: true, addSuccess: false });
|
|
3569
|
+
try {
|
|
3570
|
+
const cart = ctx.optionalClient()?.getCartManager?.();
|
|
3571
|
+
if (cart) {
|
|
3572
|
+
for (const item of items) {
|
|
3573
|
+
await cart.addItem(item.ticketTypeId, item.quantity, ctx.props.occurrenceId);
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
const quantities = {};
|
|
3577
|
+
for (const type of ctx.state.ticketTypes)
|
|
3578
|
+
quantities[type.id] = 0;
|
|
3579
|
+
ctx.setState({ quantities, addSuccess: true });
|
|
3580
|
+
ctx.after(SUCCESS_MS, () => ctx.setState({ addSuccess: false }));
|
|
3581
|
+
ctx.emit('tlAddToCart', { items });
|
|
3582
|
+
}
|
|
3583
|
+
catch (err) {
|
|
3584
|
+
const message = err instanceof Error ? err.message : ctx.strings.addFailed;
|
|
3585
|
+
ctx.setState({ error: message });
|
|
3586
|
+
ctx.emit('tlError', { message });
|
|
3587
|
+
}
|
|
3588
|
+
finally {
|
|
3589
|
+
ctx.setState({ adding: false });
|
|
3590
|
+
}
|
|
3591
|
+
},
|
|
3592
|
+
/** What the surface quoted for this selection, when it ever quotes one. */
|
|
3593
|
+
setFeeQuote(quote) {
|
|
3594
|
+
ctx.setState({ feeQuote: quote });
|
|
3595
|
+
},
|
|
3596
|
+
/**
|
|
3597
|
+
* The host never produced a client. Shown, not emitted: the element has
|
|
3598
|
+
* nothing to say to a page that has not started Live yet.
|
|
3599
|
+
*/
|
|
3600
|
+
connectFailed(message) {
|
|
3601
|
+
ctx.setState({ error: message || ctx.strings.noClient, loading: false });
|
|
3602
|
+
},
|
|
3603
|
+
}),
|
|
3604
|
+
});
|
|
3605
|
+
|
|
3606
|
+
export { cartDrawerCore as a, buyTicketsButtonCore as b, createElement as c, cartCore as d, eventListCore as e, eventCardCore as f, cartBadgeCore as g, occurrenceSelectorCore as h, orderTicketsCore as i, buyTicketsWrapperCore as j, checkoutWrapperCore as k, loginCore as l, myOrdersCore as m, orderConfirmationCore as o, promoCodeCore as p, ticketSelectorCore as t };
|