@seatlayer/js 0.35.0 → 0.36.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/index.cjs +2890 -113
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1171 -5
- package/dist/index.d.ts +1171 -5
- package/dist/index.js +2868 -111
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
var __typeError = (msg) => {
|
|
2
|
+
throw TypeError(msg);
|
|
3
|
+
};
|
|
4
|
+
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
|
|
5
|
+
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
|
|
6
|
+
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
|
|
7
|
+
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
|
|
8
|
+
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
|
|
9
|
+
|
|
1
10
|
// src/SeatingChart.ts
|
|
2
11
|
import {
|
|
3
12
|
PickerController,
|
|
@@ -17,63 +26,88 @@ var ApiError = class extends Error {
|
|
|
17
26
|
this.reason = reason;
|
|
18
27
|
}
|
|
19
28
|
};
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
body = JSON.stringify(init.body);
|
|
27
|
-
}
|
|
28
|
-
const res = await fetch(`${base}${path}`, { method, headers, body, credentials: "omit" });
|
|
29
|
-
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
30
|
-
const data = isJson ? await res.json().catch(() => null) : null;
|
|
31
|
-
if (!res.ok) {
|
|
32
|
-
const err = data;
|
|
33
|
-
throw new ApiError(
|
|
34
|
-
res.status,
|
|
35
|
-
err?.error ?? `request_failed_${res.status}`,
|
|
36
|
-
err?.code ?? err?.error,
|
|
37
|
-
err?.conflicts,
|
|
38
|
-
err?.reason
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
return data;
|
|
42
|
-
}
|
|
29
|
+
var OBJECT_UNAVAILABLE_CODES = {
|
|
30
|
+
seat_conflict: "taken",
|
|
31
|
+
conflict: "taken",
|
|
32
|
+
channel_assignment_conflict: "ineligible",
|
|
33
|
+
allocation_exhausted: "exhausted"
|
|
34
|
+
};
|
|
43
35
|
var PubApi = class {
|
|
44
|
-
constructor(base) {
|
|
36
|
+
constructor(base, options = {}) {
|
|
45
37
|
this.base = base;
|
|
46
38
|
this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
39
|
+
this.access = options.access;
|
|
40
|
+
this.onObjectUnavailable = options.onObjectUnavailable;
|
|
41
|
+
}
|
|
42
|
+
/** True when this client is bound to a buyer access session. */
|
|
43
|
+
get accessScoped() {
|
|
44
|
+
return !!this.access?.configured;
|
|
45
|
+
}
|
|
46
|
+
async request(path, init = {}, retried = false) {
|
|
47
|
+
const method = init.method ?? "GET";
|
|
48
|
+
const headers = {};
|
|
49
|
+
let body;
|
|
50
|
+
if (init.body !== void 0) {
|
|
51
|
+
headers["Content-Type"] = "application/json";
|
|
52
|
+
body = JSON.stringify(init.body);
|
|
53
|
+
}
|
|
54
|
+
const authorization = await this.access?.authorization(retried ? "unauthorized" : "initial");
|
|
55
|
+
if (authorization) headers.Authorization = authorization;
|
|
56
|
+
const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
|
|
57
|
+
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
58
|
+
const data = isJson ? await res.json().catch(() => null) : null;
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const err = data;
|
|
61
|
+
const code = err?.code ?? err?.error;
|
|
62
|
+
if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
|
|
63
|
+
const refreshed = await this.access.handleFailure(res.status, code);
|
|
64
|
+
if (refreshed && !retried) return this.request(path, init, true);
|
|
65
|
+
}
|
|
66
|
+
if (res.status === 409) {
|
|
67
|
+
const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
|
|
68
|
+
const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
|
|
69
|
+
if (reason) this.onObjectUnavailable?.({ labels, reason, code });
|
|
70
|
+
}
|
|
71
|
+
throw new ApiError(
|
|
72
|
+
res.status,
|
|
73
|
+
err?.error ?? `request_failed_${res.status}`,
|
|
74
|
+
code,
|
|
75
|
+
err?.conflicts,
|
|
76
|
+
err?.reason
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return data;
|
|
47
80
|
}
|
|
48
81
|
chart(key) {
|
|
49
|
-
return request(
|
|
82
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
|
|
50
83
|
}
|
|
51
84
|
objects(key) {
|
|
52
|
-
return request(
|
|
85
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
|
|
53
86
|
}
|
|
54
87
|
hold(key, selections, ttlMs, replaceHoldId) {
|
|
55
|
-
return request(
|
|
88
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
|
|
56
89
|
method: "POST",
|
|
57
|
-
body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} }
|
|
90
|
+
body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
|
|
91
|
+
labels: selections.map((s) => s.label)
|
|
58
92
|
});
|
|
59
93
|
}
|
|
60
94
|
// `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
|
|
61
95
|
// window — both are part of the route contract, and dropping either here made
|
|
62
96
|
// the SDK quietly pick venue-wide and hold for the server default instead.
|
|
63
97
|
bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
|
|
64
|
-
return request(
|
|
98
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
|
|
65
99
|
method: "POST",
|
|
66
100
|
body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
|
|
67
101
|
});
|
|
68
102
|
}
|
|
69
103
|
resume(key, holdId) {
|
|
70
|
-
return request(
|
|
104
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
|
|
71
105
|
method: "POST",
|
|
72
106
|
body: { holdId }
|
|
73
107
|
});
|
|
74
108
|
}
|
|
75
109
|
release(key, labels, holdId) {
|
|
76
|
-
return request(
|
|
110
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
|
|
77
111
|
method: "POST",
|
|
78
112
|
body: { labels, holdId }
|
|
79
113
|
});
|
|
@@ -81,17 +115,662 @@ var PubApi = class {
|
|
|
81
115
|
/** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
|
|
82
116
|
* (reason: expired | extend_limit | not_found | not_active) if it can't. */
|
|
83
117
|
extend(key, holdId, ttlMs) {
|
|
84
|
-
return request(
|
|
118
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
|
|
85
119
|
method: "POST",
|
|
86
120
|
body: { holdId, ...ttlMs ? { ttlMs } : {} }
|
|
87
121
|
});
|
|
88
122
|
}
|
|
89
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Mint a one-use subscribe ticket for the next socket attempt (protocol doc
|
|
125
|
+
* §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
|
|
126
|
+
* already apply; the socket then carries only the short-lived ticket, in its
|
|
127
|
+
* subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
|
|
128
|
+
*/
|
|
129
|
+
subscribeTicket(key) {
|
|
130
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: {}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The subscribe URL. Never carries a credential — not the bearer, not the
|
|
137
|
+
* ticket. Query parameters are diagnostics only.
|
|
138
|
+
*/
|
|
139
|
+
subscribeUrl(key) {
|
|
90
140
|
const wsBase = this.base.replace(/^http/, "ws");
|
|
91
141
|
const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
|
|
92
142
|
return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
|
|
93
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* What PickerController opens its own socket with.
|
|
146
|
+
*
|
|
147
|
+
* Empty for an access-scoped client: a private scope authenticates with a
|
|
148
|
+
* subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
|
|
149
|
+
* BuyerRealtimeClient owns that socket instead and the controller skips its
|
|
150
|
+
* own (an empty URL is its documented "no live feed" contract). A tokenless
|
|
151
|
+
* public client returns exactly the URL it always has, so nothing about the
|
|
152
|
+
* public picker's realtime path changes.
|
|
153
|
+
*/
|
|
154
|
+
socketUrl(key) {
|
|
155
|
+
return this.accessScoped ? "" : this.subscribeUrl(key);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// src/buyerAccess.ts
|
|
160
|
+
var BuyerAccessUnavailableError = class extends Error {
|
|
161
|
+
constructor(event) {
|
|
162
|
+
super(`buyer_access_unavailable:${event.reason}`);
|
|
163
|
+
this.name = "BuyerAccessUnavailableError";
|
|
164
|
+
this.reason = event.reason;
|
|
165
|
+
this.code = event.code;
|
|
166
|
+
this.status = event.status;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
|
|
170
|
+
var RECOVERABLE = /* @__PURE__ */ new Set([
|
|
171
|
+
"paused",
|
|
172
|
+
"provider_failed",
|
|
173
|
+
"channel_denied"
|
|
174
|
+
]);
|
|
175
|
+
function classifyAccessFailure(status, code) {
|
|
176
|
+
switch (code) {
|
|
177
|
+
case "buyer_access_invalid":
|
|
178
|
+
return "invalid";
|
|
179
|
+
case "buyer_access_revoked":
|
|
180
|
+
return "revoked";
|
|
181
|
+
case "buyer_access_origin_mismatch":
|
|
182
|
+
return "origin_mismatch";
|
|
183
|
+
case "buyer_access_event_mismatch":
|
|
184
|
+
return "event_mismatch";
|
|
185
|
+
case "buyer_access_mode_mismatch":
|
|
186
|
+
return "mode_mismatch";
|
|
187
|
+
case "channel_access_denied":
|
|
188
|
+
return "channel_denied";
|
|
189
|
+
case "channel_paused":
|
|
190
|
+
return "paused";
|
|
191
|
+
case "invalid_channel_scope":
|
|
192
|
+
return "invalid_scope";
|
|
193
|
+
default:
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (status === 401) return "invalid";
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
function isAccessExpiry(status, code) {
|
|
200
|
+
return status === 401 && !!code && EXPIRED_CODES.has(code);
|
|
201
|
+
}
|
|
202
|
+
var DEFAULT_SKEW_MS = 3e4;
|
|
203
|
+
var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
|
|
204
|
+
var BuyerAccessContext = class {
|
|
205
|
+
constructor(options) {
|
|
206
|
+
__privateAdd(this, _BuyerAccessContext_instances);
|
|
207
|
+
/** Private field: not enumerable, not spreadable, not serializable. */
|
|
208
|
+
__privateAdd(this, _token, null);
|
|
209
|
+
__privateAdd(this, _expiresAt, 0);
|
|
210
|
+
__privateAdd(this, _provider);
|
|
211
|
+
__privateAdd(this, _skewMs);
|
|
212
|
+
__privateAdd(this, _inflight, null);
|
|
213
|
+
__privateAdd(this, _terminal, null);
|
|
214
|
+
/** The most recent failure, terminal or not — so one cause reports once. */
|
|
215
|
+
__privateAdd(this, _lastFailure, null);
|
|
216
|
+
__privateAdd(this, _onExpired);
|
|
217
|
+
__privateAdd(this, _onUnavailable);
|
|
218
|
+
/** Decided once, at construction. See the `configured` getter. */
|
|
219
|
+
__privateAdd(this, _configured, false);
|
|
220
|
+
__privateSet(this, _provider, options.provider);
|
|
221
|
+
__privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
|
|
222
|
+
__privateSet(this, _onExpired, options.onExpired);
|
|
223
|
+
__privateSet(this, _onUnavailable, options.onUnavailable);
|
|
224
|
+
if (options.token) {
|
|
225
|
+
const seed = typeof options.token === "string" ? { token: options.token } : options.token;
|
|
226
|
+
__privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
|
|
227
|
+
}
|
|
228
|
+
__privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* True when this picker is access-scoped at all. A false here is the
|
|
232
|
+
* tokenless public picker, which must behave exactly as it always has.
|
|
233
|
+
*
|
|
234
|
+
* Answered from what the HOST asked for, never from live token state. It used
|
|
235
|
+
* to be `!!#provider || !!#token`, which quietly inverted this file's central
|
|
236
|
+
* rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
|
|
237
|
+
* the first refusal turned a configured context into an "unconfigured" one,
|
|
238
|
+
* `authorization()` then returned null instead of throwing, and the very next
|
|
239
|
+
* call went out with no bearer — the anonymous Public sale fallback this
|
|
240
|
+
* module exists to prevent. A provider host never saw it, because `#provider`
|
|
241
|
+
* held `configured` true. Found against a live worker in the M9 pass.
|
|
242
|
+
*/
|
|
243
|
+
get configured() {
|
|
244
|
+
return __privateGet(this, _configured);
|
|
245
|
+
}
|
|
246
|
+
/** Set once a state arrives that refreshing cannot clear. */
|
|
247
|
+
get unavailable() {
|
|
248
|
+
return __privateGet(this, _terminal);
|
|
249
|
+
}
|
|
250
|
+
/** True while a usable bearer is held (ignores skew). */
|
|
251
|
+
get hasToken() {
|
|
252
|
+
return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
|
|
253
|
+
}
|
|
254
|
+
/** Epoch ms the current token expires, or 0 when the host didn't say. */
|
|
255
|
+
get expiresAt() {
|
|
256
|
+
return __privateGet(this, _expiresAt);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The `Authorization` header value for a scoped operation.
|
|
260
|
+
*
|
|
261
|
+
* Returns null only when this context is not configured at all (the ordinary
|
|
262
|
+
* anonymous public picker). A configured context either returns a bearer or
|
|
263
|
+
* throws `BuyerAccessUnavailableError` — it never returns null, because a
|
|
264
|
+
* null here would send the request as anonymous Public sale.
|
|
265
|
+
*/
|
|
266
|
+
async authorization(reason = "initial") {
|
|
267
|
+
if (!this.configured) return null;
|
|
268
|
+
if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
|
|
271
|
+
if (stale) {
|
|
272
|
+
const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
|
|
273
|
+
const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
|
|
274
|
+
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
|
|
275
|
+
if (!token) {
|
|
276
|
+
throw new BuyerAccessUnavailableError(
|
|
277
|
+
__privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return `Bearer ${token}`;
|
|
281
|
+
}
|
|
282
|
+
return `Bearer ${__privateGet(this, _token)}`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Handle a 401/403 from a scoped call. Returns true when the caller should
|
|
286
|
+
* retry the same request once with the refreshed bearer.
|
|
287
|
+
*/
|
|
288
|
+
async handleFailure(status, code) {
|
|
289
|
+
var _a;
|
|
290
|
+
if (!this.configured) return false;
|
|
291
|
+
if (isAccessExpiry(status, code)) {
|
|
292
|
+
__privateSet(this, _token, null);
|
|
293
|
+
__privateSet(this, _expiresAt, 0);
|
|
294
|
+
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
|
|
295
|
+
(_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
|
|
296
|
+
return !!token;
|
|
297
|
+
}
|
|
298
|
+
const reason = classifyAccessFailure(status, code);
|
|
299
|
+
if (reason) {
|
|
300
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
/** Host-driven re-acquisition (after the buyer signs in again, say). */
|
|
306
|
+
async refresh(reason = "manual") {
|
|
307
|
+
__privateSet(this, _terminal, null);
|
|
308
|
+
__privateSet(this, _lastFailure, null);
|
|
309
|
+
__privateSet(this, _token, null);
|
|
310
|
+
__privateSet(this, _expiresAt, 0);
|
|
311
|
+
return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
|
|
312
|
+
}
|
|
313
|
+
/** Drop the bearer. Called on destroy so nothing outlives the widget. */
|
|
314
|
+
clear() {
|
|
315
|
+
__privateSet(this, _token, null);
|
|
316
|
+
__privateSet(this, _expiresAt, 0);
|
|
317
|
+
__privateSet(this, _inflight, null);
|
|
318
|
+
}
|
|
319
|
+
/** Redaction: the bearer must not survive a stringify or an interpolation. */
|
|
320
|
+
toJSON() {
|
|
321
|
+
return { configured: this.configured, hasToken: this.hasToken };
|
|
322
|
+
}
|
|
323
|
+
toString() {
|
|
324
|
+
return "[BuyerAccessContext redacted]";
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
_token = new WeakMap();
|
|
328
|
+
_expiresAt = new WeakMap();
|
|
329
|
+
_provider = new WeakMap();
|
|
330
|
+
_skewMs = new WeakMap();
|
|
331
|
+
_inflight = new WeakMap();
|
|
332
|
+
_terminal = new WeakMap();
|
|
333
|
+
_lastFailure = new WeakMap();
|
|
334
|
+
_onExpired = new WeakMap();
|
|
335
|
+
_onUnavailable = new WeakMap();
|
|
336
|
+
_configured = new WeakMap();
|
|
337
|
+
_BuyerAccessContext_instances = new WeakSet();
|
|
338
|
+
// ---- internals ------------------------------------------------------------
|
|
339
|
+
accept_fn = function(next) {
|
|
340
|
+
if (!next || typeof next.token !== "string" || !next.token) return null;
|
|
341
|
+
__privateSet(this, _token, next.token);
|
|
342
|
+
__privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
|
|
343
|
+
return __privateGet(this, _token);
|
|
344
|
+
};
|
|
345
|
+
/**
|
|
346
|
+
* One provider call at a time. Several operations racing an expiry (chart +
|
|
347
|
+
* objects + a socket ticket) must not mint several sessions — the guide's
|
|
348
|
+
* rotate-on-retry rule would revoke the ones they didn't observe.
|
|
349
|
+
*/
|
|
350
|
+
renew_fn = function(reason, code) {
|
|
351
|
+
if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
|
|
352
|
+
const provider = __privateGet(this, _provider);
|
|
353
|
+
if (!provider) {
|
|
354
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
|
|
355
|
+
return Promise.resolve(null);
|
|
356
|
+
}
|
|
357
|
+
const run = (async () => {
|
|
358
|
+
try {
|
|
359
|
+
const next = await provider({ reason });
|
|
360
|
+
const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
|
|
361
|
+
if (!token) {
|
|
362
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return token;
|
|
366
|
+
} catch {
|
|
367
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
368
|
+
return null;
|
|
369
|
+
} finally {
|
|
370
|
+
__privateSet(this, _inflight, null);
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
__privateSet(this, _inflight, run);
|
|
374
|
+
return run;
|
|
375
|
+
};
|
|
376
|
+
fail_fn = function(reason, code, status) {
|
|
377
|
+
var _a;
|
|
378
|
+
const event = {
|
|
379
|
+
reason,
|
|
380
|
+
code,
|
|
381
|
+
status,
|
|
382
|
+
// Unchanged: `retryable` means "the SAME request may succeed later".
|
|
383
|
+
// `provider_failed` is recoverable but not retryable — the host must fix
|
|
384
|
+
// its mint endpoint first — so the two sets are deliberately different.
|
|
385
|
+
retryable: reason === "paused" || reason === "channel_denied"
|
|
386
|
+
};
|
|
387
|
+
__privateSet(this, _lastFailure, event);
|
|
388
|
+
if (!RECOVERABLE.has(reason)) {
|
|
389
|
+
__privateSet(this, _terminal, event);
|
|
390
|
+
__privateSet(this, _token, null);
|
|
391
|
+
__privateSet(this, _expiresAt, 0);
|
|
392
|
+
}
|
|
393
|
+
(_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
|
|
394
|
+
return event;
|
|
395
|
+
};
|
|
396
|
+
function createBuyerAccessContext(options, hooks = {}) {
|
|
397
|
+
if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
|
|
398
|
+
return new BuyerAccessContext({
|
|
399
|
+
provider: options.buyerAccessTokenProvider,
|
|
400
|
+
token: options.buyerAccessToken,
|
|
401
|
+
...hooks
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/buyerRealtime.ts
|
|
406
|
+
var SEATLAYER_V1 = "seatlayer.v1";
|
|
407
|
+
var SEP = "\0";
|
|
408
|
+
var CLOSE_ACCESS_REVOKED = 4401;
|
|
409
|
+
var MAX_BACKOFF_MS = 15e3;
|
|
410
|
+
var PING_INTERVAL_MS = 25e3;
|
|
411
|
+
var PONG_GRACE_MS = 1e4;
|
|
412
|
+
var RESUME_ANSWER_GRACE_MS = 5e3;
|
|
413
|
+
function projectionFromSnapshot(frame) {
|
|
414
|
+
const fallback = typeof frame.default === "string" ? frame.default : "free";
|
|
415
|
+
const exceptions = {};
|
|
416
|
+
if (frame.seats && typeof frame.seats === "object") {
|
|
417
|
+
for (const [label, status] of Object.entries(frame.seats)) {
|
|
418
|
+
if (typeof status === "string" && status !== fallback) exceptions[label] = status;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { default: fallback, exceptions };
|
|
422
|
+
}
|
|
423
|
+
function diffProjections(prev, next) {
|
|
424
|
+
if (!prev || prev.default !== next.default) return null;
|
|
425
|
+
const changes = [];
|
|
426
|
+
for (const [label, status] of Object.entries(next.exceptions)) {
|
|
427
|
+
if (prev.exceptions[label] !== status) changes.push({ label, status });
|
|
428
|
+
}
|
|
429
|
+
for (const label of Object.keys(prev.exceptions)) {
|
|
430
|
+
if (!(label in next.exceptions)) changes.push({ label, status: next.default });
|
|
431
|
+
}
|
|
432
|
+
return changes;
|
|
433
|
+
}
|
|
434
|
+
function applyChanges(projection, changes) {
|
|
435
|
+
for (const change of changes) {
|
|
436
|
+
if (change.status === projection.default) delete projection.exceptions[change.label];
|
|
437
|
+
else projection.exceptions[change.label] = change.status;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function assertCredentialFreeUrl(url) {
|
|
441
|
+
if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
|
|
442
|
+
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
443
|
+
}
|
|
444
|
+
if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
|
|
445
|
+
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
var BuyerRealtimeClient = class {
|
|
449
|
+
constructor(options) {
|
|
450
|
+
this.ws = null;
|
|
451
|
+
this.stopped = true;
|
|
452
|
+
this.attempt = 0;
|
|
453
|
+
this.reconnectTimer = null;
|
|
454
|
+
this.pingTimer = null;
|
|
455
|
+
this.pongTimer = null;
|
|
456
|
+
this.resumeTimer = null;
|
|
457
|
+
/** Our model of this scope's projection. Null until the first snapshot. */
|
|
458
|
+
this.projection = null;
|
|
459
|
+
/** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
|
|
460
|
+
this.version = null;
|
|
461
|
+
/** True once the 101 echoed `seatlayer.v1`. */
|
|
462
|
+
this.v1 = false;
|
|
463
|
+
/** Set when we offered v1 and the handshake came back without it — a proxy
|
|
464
|
+
* most likely stripped the header, so the next attempt selects the v1 frame
|
|
465
|
+
* format with the `?pv=1` marker instead (protocol doc §1). The marker
|
|
466
|
+
* selects a format and can never carry a credential or widen a scope. */
|
|
467
|
+
this.useQueryMarker = false;
|
|
468
|
+
this.hidden = null;
|
|
469
|
+
this.closedSections = null;
|
|
470
|
+
this.opts = options;
|
|
471
|
+
assertCredentialFreeUrl(options.url);
|
|
472
|
+
}
|
|
473
|
+
/** Negotiated protocol, for tests and diagnostics. */
|
|
474
|
+
get protocol() {
|
|
475
|
+
return this.ws ? this.v1 ? "v1" : "legacy" : null;
|
|
476
|
+
}
|
|
477
|
+
get snapshotVersion() {
|
|
478
|
+
return this.version;
|
|
479
|
+
}
|
|
480
|
+
start() {
|
|
481
|
+
if (!this.stopped) return;
|
|
482
|
+
this.stopped = false;
|
|
483
|
+
void this.connect();
|
|
484
|
+
}
|
|
485
|
+
/** Stop for good (destroy, or a revocation). Safe to call twice. */
|
|
486
|
+
stop() {
|
|
487
|
+
this.stopped = true;
|
|
488
|
+
this.clearTimers();
|
|
489
|
+
const ws = this.ws;
|
|
490
|
+
this.ws = null;
|
|
491
|
+
if (ws) {
|
|
492
|
+
ws.onopen = null;
|
|
493
|
+
ws.onmessage = null;
|
|
494
|
+
ws.onclose = null;
|
|
495
|
+
ws.onerror = null;
|
|
496
|
+
try {
|
|
497
|
+
ws.close();
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
|
|
503
|
+
restart() {
|
|
504
|
+
this.stop();
|
|
505
|
+
this.projection = null;
|
|
506
|
+
this.version = null;
|
|
507
|
+
this.attempt = 0;
|
|
508
|
+
this.start();
|
|
509
|
+
}
|
|
510
|
+
// ---- connection -----------------------------------------------------------
|
|
511
|
+
async connect() {
|
|
512
|
+
if (this.stopped) return;
|
|
513
|
+
let protocols = [SEATLAYER_V1];
|
|
514
|
+
if (this.opts.mintTicket) {
|
|
515
|
+
let minted;
|
|
516
|
+
try {
|
|
517
|
+
minted = await this.opts.mintTicket();
|
|
518
|
+
} catch (err) {
|
|
519
|
+
this.reportIfAccessError(err);
|
|
520
|
+
this.scheduleReconnect();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (this.stopped) return;
|
|
524
|
+
if (minted?.protocols?.length) {
|
|
525
|
+
protocols = [...minted.protocols];
|
|
526
|
+
if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
|
|
527
|
+
} else if (minted?.ticket) {
|
|
528
|
+
protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const offeredResume = this.version !== null;
|
|
532
|
+
if (offeredResume) protocols.push(`sv.${this.version}`);
|
|
533
|
+
const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
|
|
534
|
+
assertCredentialFreeUrl(url);
|
|
535
|
+
let ws;
|
|
536
|
+
try {
|
|
537
|
+
const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
|
|
538
|
+
ws = make(url, protocols);
|
|
539
|
+
} catch {
|
|
540
|
+
this.scheduleReconnect();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
this.ws = ws;
|
|
544
|
+
ws.onopen = () => {
|
|
545
|
+
if (this.ws !== ws) return;
|
|
546
|
+
this.attempt = 0;
|
|
547
|
+
this.v1 = ws.protocol === SEATLAYER_V1;
|
|
548
|
+
if (!this.v1) this.useQueryMarker = true;
|
|
549
|
+
this.startKeepalive(ws);
|
|
550
|
+
if (offeredResume) {
|
|
551
|
+
this.resumeTimer = setTimeout(() => {
|
|
552
|
+
this.resumeTimer = null;
|
|
553
|
+
void this.opts.sink.resync();
|
|
554
|
+
}, RESUME_ANSWER_GRACE_MS);
|
|
555
|
+
} else {
|
|
556
|
+
void this.opts.sink.resync();
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
ws.onmessage = (event) => {
|
|
560
|
+
if (this.ws !== ws) return;
|
|
561
|
+
let parsed;
|
|
562
|
+
try {
|
|
563
|
+
parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
564
|
+
} catch {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
568
|
+
this.handleFrame(parsed);
|
|
569
|
+
};
|
|
570
|
+
ws.onclose = (event) => {
|
|
571
|
+
if (this.ws !== ws) return;
|
|
572
|
+
this.ws = null;
|
|
573
|
+
this.clearTimers();
|
|
574
|
+
if (event?.code === CLOSE_ACCESS_REVOKED) {
|
|
575
|
+
this.stopped = true;
|
|
576
|
+
this.opts.onAccessUnavailable?.({
|
|
577
|
+
reason: "revoked",
|
|
578
|
+
code: "access_revoked",
|
|
579
|
+
retryable: false
|
|
580
|
+
});
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
this.scheduleReconnect();
|
|
584
|
+
};
|
|
585
|
+
ws.onerror = () => {
|
|
586
|
+
try {
|
|
587
|
+
ws.close();
|
|
588
|
+
} catch {
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
handleFrame(frame) {
|
|
593
|
+
const type = typeof frame.type === "string" ? frame.type : "";
|
|
594
|
+
if (frame.protocol === 1) this.v1 = true;
|
|
595
|
+
if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
|
|
596
|
+
if (type === "pong") {
|
|
597
|
+
this.clearPongTimer();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
|
|
601
|
+
const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
|
|
602
|
+
const closed = Array.isArray(frame.closed) ? frame.closed : [];
|
|
603
|
+
const hKey = hidden.join(SEP);
|
|
604
|
+
const cKey = closed.join(SEP);
|
|
605
|
+
if (hKey !== this.hidden || cKey !== this.closedSections) {
|
|
606
|
+
this.hidden = hKey;
|
|
607
|
+
this.closedSections = cKey;
|
|
608
|
+
this.opts.sink.onSections?.(hidden, closed);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (type === "hidden") return;
|
|
612
|
+
if (type === "presence") {
|
|
613
|
+
this.opts.sink.onPresence?.({
|
|
614
|
+
shoppingSessions: Number(frame.shoppingSessions) || 0,
|
|
615
|
+
activeHolds: Number(frame.activeHolds) || 0
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (type === "allocation") {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (type === "snapshot" || !type && frame.seats) {
|
|
623
|
+
this.answered();
|
|
624
|
+
const next = projectionFromSnapshot(frame);
|
|
625
|
+
const changes = diffProjections(this.projection, next);
|
|
626
|
+
this.projection = next;
|
|
627
|
+
if (changes === null) void this.opts.sink.resync();
|
|
628
|
+
else if (changes.length) this.opts.sink.applyStatuses(changes);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (type === "delta" && Array.isArray(frame.changes)) {
|
|
632
|
+
this.answered();
|
|
633
|
+
const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
|
|
634
|
+
if (!changes.length) return;
|
|
635
|
+
if (this.projection) applyChanges(this.projection, changes);
|
|
636
|
+
this.opts.sink.applyStatuses(changes);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** The server answered our resume; cancel the fallback resync. */
|
|
640
|
+
answered() {
|
|
641
|
+
if (!this.resumeTimer) return;
|
|
642
|
+
clearTimeout(this.resumeTimer);
|
|
643
|
+
this.resumeTimer = null;
|
|
644
|
+
}
|
|
645
|
+
reportIfAccessError(err) {
|
|
646
|
+
const reason = err?.reason;
|
|
647
|
+
if (err?.name !== "BuyerAccessUnavailableError") return;
|
|
648
|
+
this.stopped = true;
|
|
649
|
+
this.opts.onAccessUnavailable?.({
|
|
650
|
+
reason: reason ?? "invalid",
|
|
651
|
+
code: err.code,
|
|
652
|
+
status: err.status,
|
|
653
|
+
retryable: reason === "paused"
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
// ---- keepalive & backoff --------------------------------------------------
|
|
657
|
+
/**
|
|
658
|
+
* Liveness is ping/pong, and only ping/pong. A socket that receives nothing
|
|
659
|
+
* for minutes is the normal, correct state for a narrowly-scoped buyer on a
|
|
660
|
+
* busy event (protocol doc §5), so quiet time never triggers a reconnect.
|
|
661
|
+
*/
|
|
662
|
+
startKeepalive(ws) {
|
|
663
|
+
this.pingTimer = setInterval(() => {
|
|
664
|
+
if (this.ws !== ws) return;
|
|
665
|
+
try {
|
|
666
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
667
|
+
} catch {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.clearPongTimer();
|
|
671
|
+
this.pongTimer = setTimeout(() => {
|
|
672
|
+
this.pongTimer = null;
|
|
673
|
+
try {
|
|
674
|
+
ws.close();
|
|
675
|
+
} catch {
|
|
676
|
+
}
|
|
677
|
+
}, PONG_GRACE_MS);
|
|
678
|
+
}, PING_INTERVAL_MS);
|
|
679
|
+
}
|
|
680
|
+
scheduleReconnect() {
|
|
681
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
682
|
+
const attempt = Math.min(this.attempt++, 5);
|
|
683
|
+
const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
|
|
684
|
+
this.reconnectTimer = setTimeout(() => {
|
|
685
|
+
this.reconnectTimer = null;
|
|
686
|
+
void this.connect();
|
|
687
|
+
}, delay);
|
|
688
|
+
}
|
|
689
|
+
clearPongTimer() {
|
|
690
|
+
if (!this.pongTimer) return;
|
|
691
|
+
clearTimeout(this.pongTimer);
|
|
692
|
+
this.pongTimer = null;
|
|
693
|
+
}
|
|
694
|
+
clearTimers() {
|
|
695
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
696
|
+
this.pingTimer = null;
|
|
697
|
+
this.clearPongTimer();
|
|
698
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
699
|
+
this.reconnectTimer = null;
|
|
700
|
+
if (this.resumeTimer) clearTimeout(this.resumeTimer);
|
|
701
|
+
this.resumeTimer = null;
|
|
702
|
+
}
|
|
94
703
|
};
|
|
704
|
+
function rendererStatus(wire) {
|
|
705
|
+
if (wire === "blocked") return "not_for_sale";
|
|
706
|
+
if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
|
|
707
|
+
return "free";
|
|
708
|
+
}
|
|
709
|
+
function createControllerSink(controller, options = {}) {
|
|
710
|
+
const idsForLabel = (label) => {
|
|
711
|
+
const table = controller.tableSelection(label);
|
|
712
|
+
if (table) return table.physicalSeatIds;
|
|
713
|
+
const id = controller.idForLabel(label);
|
|
714
|
+
return id ? [id] : [];
|
|
715
|
+
};
|
|
716
|
+
return {
|
|
717
|
+
applyStatuses(changes) {
|
|
718
|
+
const held = controller.currentHold()?.labels ?? [];
|
|
719
|
+
const buckets = {
|
|
720
|
+
free: [],
|
|
721
|
+
held: [],
|
|
722
|
+
booked: [],
|
|
723
|
+
not_for_sale: []
|
|
724
|
+
};
|
|
725
|
+
const flashes = [];
|
|
726
|
+
const lost = [];
|
|
727
|
+
const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
|
|
728
|
+
for (const change of changes) {
|
|
729
|
+
const ids = idsForLabel(change.label);
|
|
730
|
+
if (!ids.length) continue;
|
|
731
|
+
const next = rendererStatus(change.status);
|
|
732
|
+
buckets[next].push(...ids);
|
|
733
|
+
if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
|
|
734
|
+
const color = next === "held" ? "#f4b740" : "#f43f5e";
|
|
735
|
+
for (const id of ids) flashes.push({ id, color });
|
|
736
|
+
}
|
|
737
|
+
if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
|
|
738
|
+
lost.push(change.label);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
for (const status of ["free", "held", "booked", "not_for_sale"]) {
|
|
742
|
+
if (buckets[status].length) controller.setStatus(buckets[status], status);
|
|
743
|
+
}
|
|
744
|
+
for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
|
|
745
|
+
if (lost.length) {
|
|
746
|
+
const ids = lost.flatMap((label) => idsForLabel(label));
|
|
747
|
+
if (ids.length) controller.deselect(ids);
|
|
748
|
+
const ineligible = changes.some(
|
|
749
|
+
(c) => c.status === "blocked" && lost.includes(c.label)
|
|
750
|
+
);
|
|
751
|
+
options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
|
|
752
|
+
}
|
|
753
|
+
options.onStatusChange?.();
|
|
754
|
+
},
|
|
755
|
+
async resync() {
|
|
756
|
+
await controller.refresh();
|
|
757
|
+
},
|
|
758
|
+
/**
|
|
759
|
+
* Section availability moved. Statuses are re-pulled so the map repaints.
|
|
760
|
+
*
|
|
761
|
+
* Known limit: rebuilding the chart when a section is newly HIDDEN (its
|
|
762
|
+
* seats are stripped, not greyed) lives inside PickerController's own
|
|
763
|
+
* socket handler and has no public entry point, so an access-scoped picker
|
|
764
|
+
* repaints statuses but does not restructure the chart until its next
|
|
765
|
+
* mount. Closing/opening a section — the common mid-sale move — is a
|
|
766
|
+
* status-level change and is handled here in full.
|
|
767
|
+
*/
|
|
768
|
+
onSections(hidden, closed) {
|
|
769
|
+
void controller.refresh();
|
|
770
|
+
options.onSections?.(hidden, closed);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
}
|
|
95
774
|
|
|
96
775
|
// src/seatLayerBrand.ts
|
|
97
776
|
var SEATLAYER_ATTRIBUTION_MARK_SVG = '<svg viewBox="0 0 64 56" width="12" height="11" fill="none" aria-hidden="true" focusable="false" style="display:block"><path d="M4 13 Q16 6 29 7 L28.5 17 Q17 16.5 7 22 Z" fill="#f4b740"/><path d="M4 13 Q16 6 29 7 L28.5 17 Q17 16.5 7 22 Z" fill="#f4b740" transform="translate(64 0) scale(-1 1)"/><path d="M8 28 Q18 22 28.6 23 L28.2 32 Q18 28.5 10 36 Z" fill="#fcf7ee"/><path d="M8 28 Q18 22 28.6 23 L28.2 32 Q18 28.5 10 36 Z" fill="#fcf7ee" transform="translate(64 0) scale(-1 1)"/><path d="M11 41 Q19 35 28.2 36 L27.8 45 Q19.5 42 13.5 49 Z" fill="#fcf7ee"/><path d="M11 41 Q19 35 28.2 36 L27.8 45 Q19.5 42 13.5 49 Z" fill="#fcf7ee" transform="translate(64 0) scale(-1 1)"/></svg>';
|
|
@@ -119,12 +798,21 @@ var SeatingChart = class {
|
|
|
119
798
|
this.tipEl = null;
|
|
120
799
|
this.tipPos = { x: 0, y: 0 };
|
|
121
800
|
this.onTipMove = null;
|
|
801
|
+
this.realtime = null;
|
|
122
802
|
if (!options || typeof options !== "object") throw new Error("seatmap: options object is required");
|
|
123
803
|
if (!options.container) throw new Error("seatmap: `container` is required");
|
|
124
804
|
if (!options.event || typeof options.event !== "string") throw new Error("seatmap: `event` key is required");
|
|
125
805
|
this.opts = options;
|
|
126
806
|
this.publicKey = options.publicKey;
|
|
127
|
-
|
|
807
|
+
this.access = createBuyerAccessContext(options, {
|
|
808
|
+
onExpired: (event) => this.opts.onAccessExpired?.(event),
|
|
809
|
+
onUnavailable: (event) => this.opts.onAccessUnavailable?.(event)
|
|
810
|
+
});
|
|
811
|
+
const api = new PubApi((options.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, ""), {
|
|
812
|
+
access: this.access ?? void 0,
|
|
813
|
+
onObjectUnavailable: (event) => this.opts.onSelectedObjectUnavailable?.(event)
|
|
814
|
+
});
|
|
815
|
+
this.api = api;
|
|
128
816
|
this.controller = new PickerController({
|
|
129
817
|
transport: api,
|
|
130
818
|
eventKey: options.event,
|
|
@@ -170,6 +858,7 @@ var SeatingChart = class {
|
|
|
170
858
|
return this;
|
|
171
859
|
}
|
|
172
860
|
this.controller.setViewMode(this.opts.initialView ?? "flat");
|
|
861
|
+
this.startRealtime();
|
|
173
862
|
this.mode_ = info.mode === "test" ? "test" : "live";
|
|
174
863
|
if (this.opts.seatTooltip !== false) {
|
|
175
864
|
const tip = document.createElement("div");
|
|
@@ -421,7 +1110,45 @@ var SeatingChart = class {
|
|
|
421
1110
|
return this.controller.releaseLabels(labels);
|
|
422
1111
|
}
|
|
423
1112
|
/** Tear everything down: close the socket, stop timers, drop the canvas. */
|
|
1113
|
+
/**
|
|
1114
|
+
* Realtime for an access-scoped chart.
|
|
1115
|
+
*
|
|
1116
|
+
* A tokenless chart never gets here: `access` is null, `PubApi.socketUrl()`
|
|
1117
|
+
* returns the URL it always has, and PickerController keeps its own socket
|
|
1118
|
+
* and its own legacy frames. Nothing about the public path changes.
|
|
1119
|
+
*/
|
|
1120
|
+
startRealtime() {
|
|
1121
|
+
if (!this.access?.configured || this.realtime) return;
|
|
1122
|
+
this.realtime = new BuyerRealtimeClient({
|
|
1123
|
+
url: this.api.subscribeUrl(this.opts.event),
|
|
1124
|
+
mintTicket: () => this.api.subscribeTicket(this.opts.event),
|
|
1125
|
+
onAccessUnavailable: (event) => this.opts.onAccessUnavailable?.(event),
|
|
1126
|
+
sink: createControllerSink(this.controller, {
|
|
1127
|
+
flashOnLiveChange: true,
|
|
1128
|
+
onSelectedObjectUnavailable: (labels, reason) => this.opts.onSelectedObjectUnavailable?.({ labels, reason })
|
|
1129
|
+
})
|
|
1130
|
+
});
|
|
1131
|
+
this.realtime.start();
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Re-acquire the buyer access session — call after your app has re-authorized
|
|
1135
|
+
* the buyer (a revoked session cannot be recovered any other way). Resolves
|
|
1136
|
+
* true when a fresh bearer is held; the realtime feed restarts with it.
|
|
1137
|
+
*/
|
|
1138
|
+
async refreshAccess() {
|
|
1139
|
+
if (!this.access?.configured) return false;
|
|
1140
|
+
const ok = await this.access.refresh("manual");
|
|
1141
|
+
if (ok) {
|
|
1142
|
+
await this.controller.refresh();
|
|
1143
|
+
this.realtime?.restart();
|
|
1144
|
+
if (!this.realtime) this.startRealtime();
|
|
1145
|
+
}
|
|
1146
|
+
return ok;
|
|
1147
|
+
}
|
|
424
1148
|
destroy() {
|
|
1149
|
+
this.realtime?.stop();
|
|
1150
|
+
this.realtime = null;
|
|
1151
|
+
this.access?.clear();
|
|
425
1152
|
if (this.hostEl && this.onTipMove) this.hostEl.removeEventListener("mousemove", this.onTipMove);
|
|
426
1153
|
this.tipEl = null;
|
|
427
1154
|
this.onTipMove = null;
|
|
@@ -1151,7 +1878,12 @@ var STYLE_ID = "seatlayer-picker-style";
|
|
|
1151
1878
|
var CSS = `
|
|
1152
1879
|
.sl-picker{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:420px;overflow:hidden;
|
|
1153
1880
|
background:var(--sl-bg);color:var(--sl-text);font-family:var(--sl-font);border-radius:var(--sl-radius);
|
|
1154
|
-
--sl-r-sm:calc(var(--sl-radius) * .55)
|
|
1881
|
+
--sl-r-sm:calc(var(--sl-radius) * .55);
|
|
1882
|
+
/* Motion tokens, defined ON the widget root so an embed is self-contained and
|
|
1883
|
+
never inherits (or fights) the host page's own timing. Values mirror
|
|
1884
|
+
docs/motion-system-2026-08-01.md \xA72. */
|
|
1885
|
+
--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;
|
|
1886
|
+
--slm-mo-out:cubic-bezier(0.2,0.8,0.2,1);--slm-mo-exit:cubic-bezier(0.4,0,1,1)}
|
|
1155
1887
|
.sl-picker *{box-sizing:border-box;margin:0;padding:0}
|
|
1156
1888
|
.sl-picker button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
1157
1889
|
|
|
@@ -1855,9 +2587,24 @@ var CSS = `
|
|
|
1855
2587
|
@keyframes slConfirmBelowIn{from{opacity:0;transform:translate(-50%,8px) scale(.96)}to{opacity:1;transform:translate(-50%,16px) scale(1)}}
|
|
1856
2588
|
@keyframes slConfirmMobileIn{from{opacity:0;transform:translate(-50%,10px) scale(.97)}to{opacity:1;transform:translate(-50%,0) scale(1)}}
|
|
1857
2589
|
|
|
2590
|
+
/* Access state (channels): the panel fades AND rises at --slm-mo-base. It never
|
|
2591
|
+
covers the map \u2014 inventory is cross-faded to neutral by the canvas in one
|
|
2592
|
+
batched pass, so nothing blinks away underneath it. */
|
|
2593
|
+
.sl-access{position:absolute;left:50%;bottom:18px;z-index:9;transform:translateX(-50%);
|
|
2594
|
+
max-width:min(420px,calc(100% - 24px));display:flex;gap:12px;align-items:flex-start;
|
|
2595
|
+
padding:12px 14px;border-radius:var(--sl-r-sm);background:var(--sl-panel,#151b2c);color:var(--sl-text);
|
|
2596
|
+
border:1px solid var(--sl-line);box-shadow:0 18px 44px -18px rgba(0,0,0,.6);
|
|
2597
|
+
animation:slAccessIn var(--slm-mo-base) var(--slm-mo-out) both}
|
|
2598
|
+
.sl-access-title{font-weight:700;font-size:13px}
|
|
2599
|
+
.sl-access-body{font-size:12px;line-height:1.5;opacity:.82;margin-top:2px}
|
|
2600
|
+
.sl-access-act{margin-top:8px;padding:6px 12px;border-radius:999px;font-size:12px;font-weight:700;
|
|
2601
|
+
background:var(--sl-accent);color:var(--sl-accent-ink)}
|
|
2602
|
+
@keyframes slAccessIn{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
|
2603
|
+
|
|
1858
2604
|
@media(prefers-reduced-motion:reduce){
|
|
1859
2605
|
.sl-picker *,.sl-modal-scrim *{animation-duration:.001ms!important;animation-iteration-count:1!important;
|
|
1860
2606
|
transition-duration:.001ms!important;scroll-behavior:auto!important}
|
|
2607
|
+
.sl-access{animation:none;opacity:1;transform:translate(-50%,0)}
|
|
1861
2608
|
}
|
|
1862
2609
|
.sl-ba [data-ba-zone]{grid-column:1/-1;width:100%}
|
|
1863
2610
|
|
|
@@ -1906,6 +2653,8 @@ function writeStoredColorblind(on) {
|
|
|
1906
2653
|
}
|
|
1907
2654
|
var SeatPicker = class _SeatPicker {
|
|
1908
2655
|
constructor(options) {
|
|
2656
|
+
this.realtime = null;
|
|
2657
|
+
this.accessEl = null;
|
|
1909
2658
|
this.root = null;
|
|
1910
2659
|
this.mapHost = null;
|
|
1911
2660
|
this.rendered = false;
|
|
@@ -2027,7 +2776,21 @@ var SeatPicker = class _SeatPicker {
|
|
|
2027
2776
|
if (!options.container) throw new Error("seatmap: `container` is required (or use SeatPicker.open())");
|
|
2028
2777
|
this.opts = { ...options, confirmSelection: options.confirmSelection ?? true };
|
|
2029
2778
|
this.apiBase = (options.apiBase ?? DEFAULT_API_BASE2).replace(/\/+$/, "");
|
|
2030
|
-
this.
|
|
2779
|
+
this.access = options.transport ? null : createBuyerAccessContext(options, {
|
|
2780
|
+
onExpired: (event) => {
|
|
2781
|
+
this.opts.onAccessExpired?.(event);
|
|
2782
|
+
if (!event.refreshed) this.showAccessPanel({ reason: "no_token", retryable: false });
|
|
2783
|
+
},
|
|
2784
|
+
onUnavailable: (event) => {
|
|
2785
|
+
this.opts.onAccessUnavailable?.(event);
|
|
2786
|
+
this.showAccessPanel(event);
|
|
2787
|
+
}
|
|
2788
|
+
});
|
|
2789
|
+
this.pubApi = options.transport ? null : new PubApi(this.apiBase, {
|
|
2790
|
+
access: this.access ?? void 0,
|
|
2791
|
+
onObjectUnavailable: (event) => this.opts.onSelectedObjectUnavailable?.(event)
|
|
2792
|
+
});
|
|
2793
|
+
this.api = options.transport ?? this.pubApi;
|
|
2031
2794
|
this.maxTickets = Math.max(1, Math.floor(options.maxSelection ?? DEFAULT_MAX_SELECTION2));
|
|
2032
2795
|
this.cbSafe = readStoredColorblind() ?? !!options.colorblindSafe;
|
|
2033
2796
|
this.controller = new PickerController2({
|
|
@@ -2571,6 +3334,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
2571
3334
|
return this;
|
|
2572
3335
|
}
|
|
2573
3336
|
this.els.boot.remove();
|
|
3337
|
+
this.startRealtime();
|
|
2574
3338
|
this.salesClosed = !!info.salesClosed;
|
|
2575
3339
|
this.controller.setViewMode(this.normalizeInitialView(this.opts.initialView));
|
|
2576
3340
|
this.buildRegions();
|
|
@@ -3501,7 +4265,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
3501
4265
|
this.tableDialog = { ...table };
|
|
3502
4266
|
this.tableDialogHeld = held;
|
|
3503
4267
|
this.tableDialogReturnFocus = returnFocus ?? document.activeElement;
|
|
3504
|
-
const
|
|
4268
|
+
const esc3 = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
|
3505
4269
|
"&": "&",
|
|
3506
4270
|
"<": "<",
|
|
3507
4271
|
">": ">",
|
|
@@ -3513,7 +4277,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
3513
4277
|
const typeWord = this.rowTypeWord(table);
|
|
3514
4278
|
const el = document.createElement("div");
|
|
3515
4279
|
el.className = "sl-table-scrim";
|
|
3516
|
-
el.innerHTML = `<section class="sl-table-dialog" role="dialog" aria-modal="true" aria-labelledby="sl-table-title" aria-describedby="sl-table-copy"><div class="sl-table-head"><div class="sl-table-eyebrow">${
|
|
4280
|
+
el.innerHTML = `<section class="sl-table-dialog" role="dialog" aria-modal="true" aria-labelledby="sl-table-title" aria-describedby="sl-table-copy"><div class="sl-table-head"><div class="sl-table-eyebrow">${esc3(variable ? `Flexible party \xB7 ${typeWord}` : `Whole ${typeWord}`)}</div><h2 class="sl-table-title" id="sl-table-title">${esc3(table.displayLabel ?? table.label)}</h2><p class="sl-table-copy" id="sl-table-copy">${variable ? `Choose how many guests will sit together. This table is held exclusively for your party.` : `All ${table.capacity} places are booked together as one exclusive table.`}</p></div><div class="sl-table-body"><div class="sl-table-summary"><span>${esc3(cat?.label ?? table.categoryKey)}</span><b data-table-unit>${this.money(this.paidPrice(table.categoryKey, table.tierId ?? null, table.price))} per guest</b><span class="muted">Table capacity</span><span>${table.capacity} guests</span><span class="muted">Total</span><b data-table-total></b></div>` + (variable ? `<label class="sl-table-qtylabel" id="sl-table-qty-label">Number of guests</label><div class="sl-table-stepper" role="group" aria-labelledby="sl-table-qty-label"><button type="button" data-table-step="-1" aria-label="Fewer guests">\u2212</button><output aria-live="polite" data-table-qty>${table.quantity}</output><button type="button" data-table-step="1" aria-label="More guests">+</button></div><div class="sl-table-range">Choose ${table.minOccupancy}\u2013${table.maxOccupancy} guests</div>` : `<input type="hidden" data-table-qty value="${table.capacity}">`) + `<div class="sl-table-actions"><button type="button" class="sl-table-cancel">Cancel</button><button type="button" class="sl-table-confirm">${held ? "Update table" : variable ? "Select table" : "Select whole table"}</button></div></div></section>`;
|
|
3517
4281
|
this.root.appendChild(el);
|
|
3518
4282
|
this.tableDialogEl = el;
|
|
3519
4283
|
this.renderTableDialogState();
|
|
@@ -3982,7 +4746,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
3982
4746
|
(this.controller.hasPremiumSeats() ? `<button type="button" class="sl-ba-premium${this.baPremium ? " on" : ""}" data-ba-premium aria-pressed="${this.baPremium ? "true" : "false"}"><span class="star" aria-hidden="true">\u2605</span>${this.tf("picker.bestSeatsPremium", "Best seats")}</button>` : "") + (cats.length > 1 ? `<select aria-label="Preferred ticket type" data-ba-cat><option value="">Any ticket type</option>` + cats.map((c) => `<option value="${c.key}"${this.baCat === c.key ? " selected" : ""}>${c.label}</option>`).join("") + `</select>` : `<span aria-hidden="true"></span>`) + (zones.length ? `<select aria-label="Preferred venue zone" data-ba-zone><option value="">Any venue zone</option>` + zones.map((zone) => `<option value="${escapeOption(zone.id)}"${this.baZone === zone.id ? " selected" : ""}>${escapeOption(zone.label)}</option>`).join("") + `</select>` : "") + `<div class="sl-ba-qty"><button type="button" data-ba="-1" aria-label="Fewer seats">\u2212</button><span>${this.baQty}</span><button type="button" data-ba="1" aria-label="More seats">+</button></div><button type="button" class="sl-ba-go"${this.bestAvailableBusy ? " disabled" : ""}>` + (this.bestAvailableBusy ? `<span class="sl-ba-spin" aria-hidden="true"></span>Finding the best seats\u2026` : `Find ${this.baQty} best ${this.baQty === 1 ? "seat" : "seats"}`) + `</button></div>`);
|
|
3983
4747
|
}
|
|
3984
4748
|
const idGrid = (seatId, label, objectType, quantity = 1, objectId, identity) => {
|
|
3985
|
-
const
|
|
4749
|
+
const esc3 = (value) => String(value ?? "\u2014").replace(/[&<>"]/g, (char) => ({
|
|
3986
4750
|
"&": "&",
|
|
3987
4751
|
"<": "<",
|
|
3988
4752
|
">": ">",
|
|
@@ -3994,18 +4758,18 @@ var SeatPicker = class _SeatPicker {
|
|
|
3994
4758
|
const typeWord = identity?.displayType?.trim() || d?.displayType?.trim() || area?.displayType?.trim() || (effectiveType === "table" ? "Table" : effectiveType === "booth" ? "Booth" : effectiveType === "ga" ? "General admission" : "Row");
|
|
3995
4759
|
const buyerName = identity?.rowLabel ?? identity?.displayLabel ?? d?.rowLabel ?? d?.displayLabel ?? area?.displayLabel ?? area?.label ?? label;
|
|
3996
4760
|
if (effectiveType === "table" && identity?.bookingMode) {
|
|
3997
|
-
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${
|
|
4761
|
+
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(buyerName)}</span></span><span class="fld mid"><span class="sl-chip-eb">Guests</span><span class="val">${quantity}</span></span></div>`;
|
|
3998
4762
|
}
|
|
3999
4763
|
if (effectiveType === "ga") {
|
|
4000
|
-
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${
|
|
4764
|
+
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(buyerName)}</span></span>` + (quantity > 1 ? `<span class="fld mid"><span class="sl-chip-eb">Tickets</span><span class="val">${quantity}</span></span>` : "") + `</div>`;
|
|
4001
4765
|
}
|
|
4002
4766
|
if (effectiveType === "booth") {
|
|
4003
|
-
return `<div class="sl-chip-id">` + (d?.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${
|
|
4767
|
+
return `<div class="sl-chip-id">` + (d?.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${esc3(d.sectionLabel)}</span></span>` : "") + `<span class="fld mid"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(buyerName)}</span></span></div>`;
|
|
4004
4768
|
}
|
|
4005
4769
|
if (!d?.sectionLabel && !d?.rowLabel && !d?.seatNumber) {
|
|
4006
|
-
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">Seat</span><span class="val">${
|
|
4770
|
+
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">Seat</span><span class="val">${esc3(buyerName)}</span></span></div>`;
|
|
4007
4771
|
}
|
|
4008
|
-
return `<div class="sl-chip-id">` + (d.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${
|
|
4772
|
+
return `<div class="sl-chip-id">` + (d.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${esc3(d.sectionLabel)}</span></span>` : "") + (d.rowLabel ? `<span class="fld mid"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(this.rowShort(d))}</span></span>` : "") + (d.seatNumber ? `<span class="fld mid"><span class="sl-chip-eb">Seat</span><span class="val">${esc3(d.seatNumber)}</span></span>` : "") + `</div>`;
|
|
4009
4773
|
};
|
|
4010
4774
|
const iconRail = (rmAria, viewLabel) => `<div class="sl-chip-rail"><button type="button" class="rm" aria-label="${rmAria}"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>` + (viewLabel ? `<button type="button" class="view" data-view-label="${viewLabel}" aria-label="${t2("picker.viewFromSeat", { label: viewLabel })}"><svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>` : "") + `</div>`;
|
|
4011
4775
|
for (const item of heldItems) {
|
|
@@ -4500,19 +5264,19 @@ var SeatPicker = class _SeatPicker {
|
|
|
4500
5264
|
this.tipEl.style.display = "none";
|
|
4501
5265
|
return;
|
|
4502
5266
|
}
|
|
4503
|
-
const
|
|
5267
|
+
const esc3 = (v) => String(v ?? "\u2014").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]);
|
|
4504
5268
|
const price = this.money(this.paidPrice(details.categoryKey, details.tierId ?? null, details.price));
|
|
4505
5269
|
const isGroupedTable = details.objectType === "table" && !!details.bookingMode;
|
|
4506
5270
|
const isBooth = details.objectType === "booth";
|
|
4507
5271
|
const hasLoc = details.sectionLabel || details.rowLabel || details.seatNumber;
|
|
4508
|
-
const grid = isGroupedTable ? `<div class="sl-tip-grid"><div class="sl-tip-field"><span class="sl-tip-key">${
|
|
5272
|
+
const grid = isGroupedTable ? `<div class="sl-tip-grid"><div class="sl-tip-field"><span class="sl-tip-key">${esc3(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc3(details.rowLabel ?? details.displayLabel ?? details.label)}</span></div><div class="sl-tip-field"><span class="sl-tip-key">Guests</span><span class="sl-tip-val">${details.bookingMode === "variable" ? `${details.minOccupancy}\u2013${details.maxOccupancy}` : details.capacity}</span></div></div>` : isBooth ? `<div class="sl-tip-grid">` + (details.sectionLabel ? `<div class="sl-tip-field"><span class="sl-tip-key">Section</span><span class="sl-tip-val">${esc3(details.sectionLabel)}</span></div>` : "") + `<div class="sl-tip-field"><span class="sl-tip-key">${esc3(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc3(details.rowLabel ?? details.displayLabel ?? details.label)}</span></div></div>` : hasLoc ? `<div class="sl-tip-grid">` + (details.sectionLabel ? `<div class="sl-tip-field"><span class="sl-tip-key">Section</span><span class="sl-tip-val">${esc3(details.sectionLabel)}</span></div>` : "") + (details.rowLabel ? `<div class="sl-tip-field"><span class="sl-tip-key">${esc3(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc3(this.rowShort(details))}</span></div>` : "") + (details.seatNumber ? `<div class="sl-tip-field"><span class="sl-tip-key">Seat</span><span class="sl-tip-val">${esc3(details.seatNumber)}</span></div>` : "") + `</div>` : `<div class="sl-tip-grid one"><div class="sl-tip-field"><span class="sl-tip-key">Seat</span><span class="sl-tip-val">${esc3(details.displayLabel ?? details.label)}</span></div></div>`;
|
|
4509
5273
|
const statusLine = details.status === "free" ? "" : `<div class="sl-tip-status">${details.status === "held" ? t2("map.statusHeld") : t2("map.statusTaken")}</div>`;
|
|
4510
5274
|
const limited = this.limitedViewLabel(details.commercial);
|
|
4511
|
-
const cxLine = limited ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u25D0</span>${
|
|
5275
|
+
const cxLine = limited ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u25D0</span>${esc3(limited)}</div>` : "";
|
|
4512
5276
|
const wheelchair = this.wheelchairProvisionLabel(details.wheelchairSpaceType);
|
|
4513
|
-
const wheelchairLine = wheelchair ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u267F</span>${
|
|
5277
|
+
const wheelchairLine = wheelchair ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u267F</span>${esc3(wheelchair)}</div>` : "";
|
|
4514
5278
|
this.tipEl.style.setProperty("--sl-cat", details.categoryColor);
|
|
4515
|
-
this.tipEl.innerHTML = grid + `<div class="sl-tip-cat"><span class="sl-tip-dot" style="background:${details.categoryColor}"></span><span class="sl-tip-name">${
|
|
5279
|
+
this.tipEl.innerHTML = grid + `<div class="sl-tip-cat"><span class="sl-tip-dot" style="background:${details.categoryColor}"></span><span class="sl-tip-name">${esc3(details.categoryLabel)}</span><span class="sl-tip-amt">${price}</span></div>` + wheelchairLine + cxLine + statusLine;
|
|
4516
5280
|
this.tipEl.style.display = "block";
|
|
4517
5281
|
this.placeTooltip();
|
|
4518
5282
|
}
|
|
@@ -4910,8 +5674,154 @@ var SeatPicker = class _SeatPicker {
|
|
|
4910
5674
|
this.syncTray();
|
|
4911
5675
|
this.emitHoldChange();
|
|
4912
5676
|
}
|
|
5677
|
+
// ---- buyer access (Sales Channels) ---------------------------------------
|
|
5678
|
+
/**
|
|
5679
|
+
* Realtime for an access-scoped picker.
|
|
5680
|
+
*
|
|
5681
|
+
* A tokenless picker never gets here: `access` is null, `PubApi.socketUrl()`
|
|
5682
|
+
* returns the URL it always has, and PickerController keeps its own socket
|
|
5683
|
+
* and its own legacy frames. Nothing about the public path changes.
|
|
5684
|
+
*/
|
|
5685
|
+
startRealtime() {
|
|
5686
|
+
if (!this.access?.configured || !this.pubApi || this.realtime) return;
|
|
5687
|
+
const event = this.opts.event;
|
|
5688
|
+
this.realtime = new BuyerRealtimeClient({
|
|
5689
|
+
url: this.pubApi.subscribeUrl(event),
|
|
5690
|
+
mintTicket: () => this.pubApi.subscribeTicket(event),
|
|
5691
|
+
onAccessUnavailable: (state) => {
|
|
5692
|
+
this.opts.onAccessUnavailable?.(state);
|
|
5693
|
+
this.showAccessPanel(state);
|
|
5694
|
+
},
|
|
5695
|
+
sink: createControllerSink(this.controller, {
|
|
5696
|
+
flashOnLiveChange: true,
|
|
5697
|
+
onStatusChange: () => {
|
|
5698
|
+
this.syncPrices();
|
|
5699
|
+
this.detectBooked();
|
|
5700
|
+
this.refreshMinimap();
|
|
5701
|
+
this.pushAvailabilityTo3d();
|
|
5702
|
+
},
|
|
5703
|
+
onSelectedObjectUnavailable: (labels, reason) => {
|
|
5704
|
+
this.opts.onSelectedObjectUnavailable?.({ labels, reason });
|
|
5705
|
+
this.syncTray();
|
|
5706
|
+
this.toast(
|
|
5707
|
+
reason === "ineligible" ? this.tf(
|
|
5708
|
+
"picker.seatNoLongerYours",
|
|
5709
|
+
"Some seats are no longer available to you. They have been removed from your order."
|
|
5710
|
+
) : this.tf(
|
|
5711
|
+
"picker.seatTaken",
|
|
5712
|
+
"Someone else took a seat you had picked. It has been removed from your order."
|
|
5713
|
+
),
|
|
5714
|
+
"warning"
|
|
5715
|
+
);
|
|
5716
|
+
}
|
|
5717
|
+
})
|
|
5718
|
+
});
|
|
5719
|
+
this.realtime.start();
|
|
5720
|
+
}
|
|
5721
|
+
/**
|
|
5722
|
+
* Re-acquire the buyer access session — call after your app has re-authorized
|
|
5723
|
+
* the buyer. A revoked session cannot recover any other way. Resolves true
|
|
5724
|
+
* when a fresh bearer is held; the map and the realtime feed resume with it.
|
|
5725
|
+
*/
|
|
5726
|
+
async refreshAccess() {
|
|
5727
|
+
if (!this.access?.configured) return false;
|
|
5728
|
+
const ok = await this.access.refresh("manual");
|
|
5729
|
+
if (!ok) return false;
|
|
5730
|
+
this.dismissAccessPanel();
|
|
5731
|
+
await this.controller.refresh();
|
|
5732
|
+
if (this.realtime) this.realtime.restart();
|
|
5733
|
+
else this.startRealtime();
|
|
5734
|
+
return true;
|
|
5735
|
+
}
|
|
5736
|
+
/**
|
|
5737
|
+
* The buyer-facing access state. Plain language, no internal vocabulary, and
|
|
5738
|
+
* never a channel name, id or count — the buyer is told what happened and
|
|
5739
|
+
* what to do, not which allocation they missed (guide §7, §10).
|
|
5740
|
+
*
|
|
5741
|
+
* Held seats are deliberately left alone: a hold is relinquished by its own
|
|
5742
|
+
* opaque capability, not by channel access, so losing access never strands
|
|
5743
|
+
* inventory and never silently drops a buyer's cart (guide §9).
|
|
5744
|
+
*/
|
|
5745
|
+
showAccessPanel(state) {
|
|
5746
|
+
if (this.destroyed || !this.root) return;
|
|
5747
|
+
const copy = this.accessCopy(state.reason);
|
|
5748
|
+
this.dismissAccessPanel();
|
|
5749
|
+
const panel = document.createElement("div");
|
|
5750
|
+
panel.className = "sl-access";
|
|
5751
|
+
panel.setAttribute("role", "status");
|
|
5752
|
+
panel.setAttribute("aria-live", "polite");
|
|
5753
|
+
const text = document.createElement("div");
|
|
5754
|
+
const title = document.createElement("div");
|
|
5755
|
+
title.className = "sl-access-title";
|
|
5756
|
+
title.textContent = copy.title;
|
|
5757
|
+
const body = document.createElement("div");
|
|
5758
|
+
body.className = "sl-access-body";
|
|
5759
|
+
body.textContent = copy.body;
|
|
5760
|
+
text.appendChild(title);
|
|
5761
|
+
text.appendChild(body);
|
|
5762
|
+
if (copy.action) {
|
|
5763
|
+
const button = document.createElement("button");
|
|
5764
|
+
button.type = "button";
|
|
5765
|
+
button.className = "sl-access-act";
|
|
5766
|
+
button.textContent = copy.action;
|
|
5767
|
+
button.addEventListener("click", () => {
|
|
5768
|
+
void this.refreshAccess();
|
|
5769
|
+
});
|
|
5770
|
+
text.appendChild(button);
|
|
5771
|
+
}
|
|
5772
|
+
panel.appendChild(text);
|
|
5773
|
+
(this.regions?.["bottom-center"] ?? this.root).appendChild(panel);
|
|
5774
|
+
this.accessEl = panel;
|
|
5775
|
+
}
|
|
5776
|
+
dismissAccessPanel() {
|
|
5777
|
+
this.accessEl?.remove();
|
|
5778
|
+
this.accessEl = null;
|
|
5779
|
+
}
|
|
5780
|
+
accessCopy(reason) {
|
|
5781
|
+
switch (reason) {
|
|
5782
|
+
case "paused":
|
|
5783
|
+
return {
|
|
5784
|
+
title: this.tf("picker.accessPausedTitle", "These seats are on hold right now"),
|
|
5785
|
+
body: this.tf(
|
|
5786
|
+
"picker.accessPausedBody",
|
|
5787
|
+
"The organizer has paused this selection. Try again in a few minutes."
|
|
5788
|
+
),
|
|
5789
|
+
action: this.tf("picker.accessRetry", "Try again")
|
|
5790
|
+
};
|
|
5791
|
+
case "revoked":
|
|
5792
|
+
return {
|
|
5793
|
+
title: this.tf("picker.accessRevokedTitle", "This access link is no longer active"),
|
|
5794
|
+
body: this.tf(
|
|
5795
|
+
"picker.accessRevokedBody",
|
|
5796
|
+
"Ask whoever sent you here for a new link to keep booking these seats."
|
|
5797
|
+
)
|
|
5798
|
+
};
|
|
5799
|
+
case "no_token":
|
|
5800
|
+
case "provider_failed":
|
|
5801
|
+
return {
|
|
5802
|
+
title: this.tf("picker.accessExpiredTitle", "Your access session has ended"),
|
|
5803
|
+
body: this.tf(
|
|
5804
|
+
"picker.accessExpiredBody",
|
|
5805
|
+
"Sign in again, or reload the page, to keep browsing these seats. Anything you are already holding stays yours."
|
|
5806
|
+
),
|
|
5807
|
+
action: this.tf("picker.accessRetry", "Try again")
|
|
5808
|
+
};
|
|
5809
|
+
default:
|
|
5810
|
+
return {
|
|
5811
|
+
title: this.tf("picker.accessInvalidTitle", "We couldn\u2019t verify your access"),
|
|
5812
|
+
body: this.tf(
|
|
5813
|
+
"picker.accessInvalidBody",
|
|
5814
|
+
"You can still book anything shown as available. Contact whoever sent you here for access to the rest."
|
|
5815
|
+
)
|
|
5816
|
+
};
|
|
5817
|
+
}
|
|
5818
|
+
}
|
|
4913
5819
|
destroy() {
|
|
4914
5820
|
this.destroyed = true;
|
|
5821
|
+
this.realtime?.stop();
|
|
5822
|
+
this.realtime = null;
|
|
5823
|
+
this.dismissAccessPanel();
|
|
5824
|
+
this.access?.clear();
|
|
4915
5825
|
if (this.hold && !this.handedOff) void this.controller.release();
|
|
4916
5826
|
this.closeConfirm();
|
|
4917
5827
|
this.dismissTableDialog(false);
|
|
@@ -5034,27 +5944,245 @@ import {
|
|
|
5034
5944
|
UNGROUPED_ID
|
|
5035
5945
|
} from "@seatlayer/core";
|
|
5036
5946
|
|
|
5037
|
-
// src/
|
|
5038
|
-
var
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5947
|
+
// src/channelPlan.ts
|
|
5948
|
+
var PUBLIC_CHANNEL_ID = "";
|
|
5949
|
+
var PUBLIC_CHANNEL_NAME = "Public sale";
|
|
5950
|
+
var CHANNEL_COLORS = [
|
|
5951
|
+
"#a78bfa",
|
|
5952
|
+
"#2dd4bf",
|
|
5953
|
+
"#fb923c",
|
|
5954
|
+
"#60a5fa",
|
|
5955
|
+
"#f472b6",
|
|
5956
|
+
"#a3e635",
|
|
5957
|
+
"#f87171",
|
|
5958
|
+
"#38bdf8",
|
|
5959
|
+
"#c084fc",
|
|
5960
|
+
"#facc15"
|
|
5961
|
+
];
|
|
5962
|
+
var PUBLIC_CHANNEL_COLOR = "#f4b740";
|
|
5963
|
+
var LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
5964
|
+
function suggestMarker(name, taken) {
|
|
5965
|
+
const used = new Set([...taken].map((m) => m.trim().toUpperCase()).filter(Boolean));
|
|
5966
|
+
const first = (name.trim()[0] ?? "").toUpperCase();
|
|
5967
|
+
const letter = LETTERS.includes(first) && !used.has(first) ? first : [...LETTERS].find((candidate) => !used.has(candidate)) ?? (first || "X");
|
|
5968
|
+
return { letter, color: CHANNEL_COLORS[used.size % CHANNEL_COLORS.length] };
|
|
5969
|
+
}
|
|
5970
|
+
function markerOf(channel, index = 0) {
|
|
5971
|
+
if (channel.id === PUBLIC_CHANNEL_ID) {
|
|
5972
|
+
return { letter: (channel.marker || "P").slice(0, 2).toUpperCase(), color: channel.color || PUBLIC_CHANNEL_COLOR };
|
|
5053
5973
|
}
|
|
5054
|
-
|
|
5974
|
+
const letter = (channel.marker || channel.name.trim()[0] || "?").slice(0, 2).toUpperCase();
|
|
5975
|
+
return { letter, color: channel.color || CHANNEL_COLORS[index % CHANNEL_COLORS.length] };
|
|
5055
5976
|
}
|
|
5056
|
-
|
|
5057
|
-
|
|
5977
|
+
function selectionSources(labels, allocation, list) {
|
|
5978
|
+
const counts = /* @__PURE__ */ new Map();
|
|
5979
|
+
for (const label of labels) {
|
|
5980
|
+
const channelId = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
|
|
5981
|
+
counts.set(channelId, (counts.get(channelId) ?? 0) + 1);
|
|
5982
|
+
}
|
|
5983
|
+
const order = [
|
|
5984
|
+
{ id: PUBLIC_CHANNEL_ID, name: list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
5985
|
+
...(list?.channels ?? []).map((channel) => ({ id: channel.id, name: channel.name }))
|
|
5986
|
+
];
|
|
5987
|
+
const rows = [];
|
|
5988
|
+
for (const entry of order) {
|
|
5989
|
+
const count = counts.get(entry.id);
|
|
5990
|
+
if (count) rows.push({ channelId: entry.id, name: entry.name, count });
|
|
5991
|
+
counts.delete(entry.id);
|
|
5992
|
+
}
|
|
5993
|
+
for (const [channelId, count] of counts) {
|
|
5994
|
+
rows.push({ channelId, name: channelId ? "Another channel" : PUBLIC_CHANNEL_NAME, count });
|
|
5995
|
+
}
|
|
5996
|
+
return rows;
|
|
5997
|
+
}
|
|
5998
|
+
var SKIP_SAMPLE = 12;
|
|
5999
|
+
function skipBucket(labels) {
|
|
6000
|
+
return {
|
|
6001
|
+
count: labels.length,
|
|
6002
|
+
labels: labels.slice(0, SKIP_SAMPLE),
|
|
6003
|
+
truncated: labels.length > SKIP_SAMPLE
|
|
6004
|
+
};
|
|
6005
|
+
}
|
|
6006
|
+
function planAssignment(input) {
|
|
6007
|
+
const { labels, targetChannelId, allocation, statusOf, nameOf } = input;
|
|
6008
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6009
|
+
let fromPublic = 0;
|
|
6010
|
+
let alreadyIn = 0;
|
|
6011
|
+
const movedBySource = /* @__PURE__ */ new Map();
|
|
6012
|
+
const held = [];
|
|
6013
|
+
const booked = [];
|
|
6014
|
+
const missing = [];
|
|
6015
|
+
for (const label of labels) {
|
|
6016
|
+
if (seen.has(label)) continue;
|
|
6017
|
+
seen.add(label);
|
|
6018
|
+
const status = statusOf(label);
|
|
6019
|
+
if (!status) {
|
|
6020
|
+
missing.push(label);
|
|
6021
|
+
continue;
|
|
6022
|
+
}
|
|
6023
|
+
const current = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
|
|
6024
|
+
if (current === targetChannelId) {
|
|
6025
|
+
alreadyIn += 1;
|
|
6026
|
+
continue;
|
|
6027
|
+
}
|
|
6028
|
+
if (status === "held") {
|
|
6029
|
+
held.push(label);
|
|
6030
|
+
continue;
|
|
6031
|
+
}
|
|
6032
|
+
if (status === "booked") {
|
|
6033
|
+
booked.push(label);
|
|
6034
|
+
continue;
|
|
6035
|
+
}
|
|
6036
|
+
if (current === PUBLIC_CHANNEL_ID) fromPublic += 1;
|
|
6037
|
+
else movedBySource.set(current, (movedBySource.get(current) ?? 0) + 1);
|
|
6038
|
+
}
|
|
6039
|
+
const channels = [...movedBySource.entries()].map(([channelId, count]) => ({
|
|
6040
|
+
channelId,
|
|
6041
|
+
name: nameOf(channelId),
|
|
6042
|
+
count
|
|
6043
|
+
}));
|
|
6044
|
+
return {
|
|
6045
|
+
changedFromPublic: { count: fromPublic },
|
|
6046
|
+
movedFromOtherChannel: {
|
|
6047
|
+
count: channels.reduce((sum, row) => sum + row.count, 0),
|
|
6048
|
+
channels
|
|
6049
|
+
},
|
|
6050
|
+
alreadyInTarget: { count: alreadyIn },
|
|
6051
|
+
skippedHeld: skipBucket(held),
|
|
6052
|
+
skippedBooked: skipBucket(booked),
|
|
6053
|
+
notFound: skipBucket(missing)
|
|
6054
|
+
};
|
|
6055
|
+
}
|
|
6056
|
+
function mutationCount(buckets) {
|
|
6057
|
+
return buckets.changedFromPublic.count + buckets.movedFromOtherChannel.count;
|
|
6058
|
+
}
|
|
6059
|
+
function needsMoveConfirmation(buckets) {
|
|
6060
|
+
return buckets.movedFromOtherChannel.count > 0;
|
|
6061
|
+
}
|
|
6062
|
+
function bucketRows(buckets, targetName) {
|
|
6063
|
+
const rows = [];
|
|
6064
|
+
if (buckets.changedFromPublic.count) {
|
|
6065
|
+
rows.push({
|
|
6066
|
+
kind: "add",
|
|
6067
|
+
icon: "+",
|
|
6068
|
+
count: buckets.changedFromPublic.count,
|
|
6069
|
+
text: `${buckets.changedFromPublic.count.toLocaleString()} from ${PUBLIC_CHANNEL_NAME}`
|
|
6070
|
+
});
|
|
6071
|
+
}
|
|
6072
|
+
for (const source of buckets.movedFromOtherChannel.channels) {
|
|
6073
|
+
rows.push({
|
|
6074
|
+
kind: "move",
|
|
6075
|
+
icon: "\u21C4",
|
|
6076
|
+
count: source.count,
|
|
6077
|
+
text: `${source.count.toLocaleString()} moved out of ${source.name ?? "another channel"}`,
|
|
6078
|
+
why: "needs this confirmation"
|
|
6079
|
+
});
|
|
6080
|
+
}
|
|
6081
|
+
if (buckets.alreadyInTarget.count) {
|
|
6082
|
+
rows.push({
|
|
6083
|
+
kind: "same",
|
|
6084
|
+
icon: "=",
|
|
6085
|
+
count: buckets.alreadyInTarget.count,
|
|
6086
|
+
text: `${buckets.alreadyInTarget.count.toLocaleString()} already in ${targetName}`,
|
|
6087
|
+
why: "unchanged"
|
|
6088
|
+
});
|
|
6089
|
+
}
|
|
6090
|
+
if (buckets.skippedHeld.count) {
|
|
6091
|
+
rows.push({
|
|
6092
|
+
kind: "skip",
|
|
6093
|
+
icon: "\u23F8",
|
|
6094
|
+
count: buckets.skippedHeld.count,
|
|
6095
|
+
text: `${buckets.skippedHeld.count.toLocaleString()} in a buyer's checkout`,
|
|
6096
|
+
why: "can't move while held",
|
|
6097
|
+
peek: peekOf(buckets.skippedHeld)
|
|
6098
|
+
});
|
|
6099
|
+
}
|
|
6100
|
+
if (buckets.skippedBooked.count) {
|
|
6101
|
+
rows.push({
|
|
6102
|
+
kind: "skip",
|
|
6103
|
+
icon: "\u{1F512}",
|
|
6104
|
+
count: buckets.skippedBooked.count,
|
|
6105
|
+
text: `${buckets.skippedBooked.count.toLocaleString()} already sold`,
|
|
6106
|
+
why: "sales are never rewritten",
|
|
6107
|
+
peek: peekOf(buckets.skippedBooked)
|
|
6108
|
+
});
|
|
6109
|
+
}
|
|
6110
|
+
if (buckets.notFound.count) {
|
|
6111
|
+
rows.push({
|
|
6112
|
+
kind: "skip",
|
|
6113
|
+
icon: "?",
|
|
6114
|
+
count: buckets.notFound.count,
|
|
6115
|
+
text: `${buckets.notFound.count.toLocaleString()} not on this map`,
|
|
6116
|
+
why: "these seats are no longer part of the event",
|
|
6117
|
+
peek: peekOf(buckets.notFound)
|
|
6118
|
+
});
|
|
6119
|
+
}
|
|
6120
|
+
return rows;
|
|
6121
|
+
}
|
|
6122
|
+
function peekOf(bucket) {
|
|
6123
|
+
if (!bucket.labels.length) return void 0;
|
|
6124
|
+
const shown = bucket.labels.slice(0, 4).join(", ");
|
|
6125
|
+
return bucket.truncated || bucket.labels.length > 4 ? `${shown}\u2026` : shown;
|
|
6126
|
+
}
|
|
6127
|
+
function retryAfterCopy(details) {
|
|
6128
|
+
const ms = details?.retryAfterMs ?? (details?.latestHoldExpiresAt ? Math.max(0, details.latestHoldExpiresAt - Date.now()) : 0);
|
|
6129
|
+
if (!ms) return "in a moment";
|
|
6130
|
+
const minutes = Math.ceil(ms / 6e4);
|
|
6131
|
+
if (minutes <= 1) return "in about a minute";
|
|
6132
|
+
return `in about ${minutes} minutes`;
|
|
6133
|
+
}
|
|
6134
|
+
function accessLine(access) {
|
|
6135
|
+
if (!access || !access.intent) return "\u2014";
|
|
6136
|
+
const base = access.intent === "internal" ? "Internal selling \xB7 no buyer access needed" : access.intent === "server" ? "Server integration" : access.intent === "hosted_link" ? "Hosted access link" : "No buyer access configured";
|
|
6137
|
+
const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
|
|
6138
|
+
const detail = access.detail ?? grants;
|
|
6139
|
+
return detail ? `${base} \xB7 ${detail}` : base;
|
|
6140
|
+
}
|
|
6141
|
+
function accessIntentLabel(intent) {
|
|
6142
|
+
return intent === "internal" ? "Internal selling \u2014 our own staff sell these" : intent === "server" ? "Server integration \u2014 our backend lets buyers in" : intent === "hosted_link" ? "Hosted access link \u2014 SeatLayer issues the link" : "No buyer access yet \u2014 the allocation is just protected";
|
|
6143
|
+
}
|
|
6144
|
+
function dropReviewRows(details) {
|
|
6145
|
+
return (details?.channels ?? []).map((channel) => ({
|
|
6146
|
+
kind: "skip",
|
|
6147
|
+
icon: "\u26A0",
|
|
6148
|
+
count: channel.count,
|
|
6149
|
+
text: `${channel.count.toLocaleString()} would leave ${channel.name ?? "a channel"}`,
|
|
6150
|
+
why: "the new chart no longer has these seats",
|
|
6151
|
+
peek: channel.labels?.length ? peekOf({ count: channel.count, labels: channel.labels, truncated: channel.truncated ?? false }) : void 0
|
|
6152
|
+
}));
|
|
6153
|
+
}
|
|
6154
|
+
function stateBadge(state) {
|
|
6155
|
+
return state === "builtin" ? "Built-in" : state === "active" ? "Active" : state === "paused" ? "Paused" : "Archived";
|
|
6156
|
+
}
|
|
6157
|
+
|
|
6158
|
+
// src/manageApi.ts
|
|
6159
|
+
var ManageApiError = class extends Error {
|
|
6160
|
+
constructor(status, message, code, conflicts, details) {
|
|
6161
|
+
super(message);
|
|
6162
|
+
this.name = "ManageApiError";
|
|
6163
|
+
this.status = status;
|
|
6164
|
+
this.code = code;
|
|
6165
|
+
this.conflicts = conflicts;
|
|
6166
|
+
this.details = details;
|
|
6167
|
+
}
|
|
6168
|
+
};
|
|
6169
|
+
async function parse(res) {
|
|
6170
|
+
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
6171
|
+
const data = isJson ? await res.json().catch(() => null) : null;
|
|
6172
|
+
if (!res.ok) {
|
|
6173
|
+
const err = data;
|
|
6174
|
+
throw new ManageApiError(
|
|
6175
|
+
res.status,
|
|
6176
|
+
err?.error ?? `request_failed_${res.status}`,
|
|
6177
|
+
err?.code,
|
|
6178
|
+
err?.conflicts,
|
|
6179
|
+
err?.details
|
|
6180
|
+
);
|
|
6181
|
+
}
|
|
6182
|
+
return data;
|
|
6183
|
+
}
|
|
6184
|
+
var ManageApi = class {
|
|
6185
|
+
constructor(apiBase, token) {
|
|
5058
6186
|
this.base = apiBase.replace(/\/+$/, "");
|
|
5059
6187
|
this.token = token;
|
|
5060
6188
|
}
|
|
@@ -5127,6 +6255,97 @@ var ManageApi = class {
|
|
|
5127
6255
|
setAvailability(key, rules) {
|
|
5128
6256
|
return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`, { method: "POST", body: { rules } });
|
|
5129
6257
|
}
|
|
6258
|
+
// ---- sales channels (token, capability-gated) ----
|
|
6259
|
+
// Reads need `event:channels:view`, mutations `event:channels:manage`.
|
|
6260
|
+
// `event:block` grants NEITHER (spec §10), so a Block-only cockpit token gets
|
|
6261
|
+
// a 403 here and Channels mode never renders.
|
|
6262
|
+
/** Allocation list with exact per-channel counts. `includeArchived` adds the
|
|
6263
|
+
* read-only archived rows behind the rail's "Show archived" control. */
|
|
6264
|
+
channels(key, opts = {}) {
|
|
6265
|
+
const qs = opts.includeArchived ? "?includeArchived=1" : "";
|
|
6266
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels${qs}`);
|
|
6267
|
+
}
|
|
6268
|
+
/** One page of the label → channel map that paints the allocation overlay.
|
|
6269
|
+
* Paged by label; follow `nextAfterLabel` until it is null. */
|
|
6270
|
+
channelAllocation(key, opts = {}) {
|
|
6271
|
+
const params = new URLSearchParams();
|
|
6272
|
+
if (opts.afterLabel) params.set("afterLabel", opts.afterLabel);
|
|
6273
|
+
if (opts.limit != null) params.set("limit", String(opts.limit));
|
|
6274
|
+
const qs = params.toString();
|
|
6275
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/allocation${qs ? `?${qs}` : ""}`);
|
|
6276
|
+
}
|
|
6277
|
+
channelAudit(key, opts = {}) {
|
|
6278
|
+
const params = new URLSearchParams();
|
|
6279
|
+
if (opts.limit != null) params.set("limit", String(opts.limit));
|
|
6280
|
+
if (opts.before != null) params.set("before", String(opts.before));
|
|
6281
|
+
const qs = params.toString();
|
|
6282
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/audit${qs ? `?${qs}` : ""}`);
|
|
6283
|
+
}
|
|
6284
|
+
createChannel(key, input) {
|
|
6285
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels`, { method: "POST", body: input });
|
|
6286
|
+
}
|
|
6287
|
+
renameChannel(key, channelId, name) {
|
|
6288
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
|
|
6289
|
+
method: "PATCH",
|
|
6290
|
+
body: { name }
|
|
6291
|
+
});
|
|
6292
|
+
}
|
|
6293
|
+
setChannelPaused(key, channelId, paused) {
|
|
6294
|
+
const path = paused ? "pause" : "unpause";
|
|
6295
|
+
return this.auth(
|
|
6296
|
+
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/${path}`,
|
|
6297
|
+
{ method: "POST", body: {} }
|
|
6298
|
+
);
|
|
6299
|
+
}
|
|
6300
|
+
/** Archive with a mandatory destination for the remaining allocation.
|
|
6301
|
+
* Throws ManageApiError 409 `channel_archive_blocked_by_holds` while any hold
|
|
6302
|
+
* is live; `err.details` carries the exact counts + retry window. */
|
|
6303
|
+
archiveChannel(key, channelId, destination) {
|
|
6304
|
+
return this.auth(
|
|
6305
|
+
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/archive`,
|
|
6306
|
+
{ method: "POST", body: { destination } }
|
|
6307
|
+
);
|
|
6308
|
+
}
|
|
6309
|
+
/**
|
|
6310
|
+
* Versioned Apply. A stale `assignmentVersion` mutates NOTHING and throws
|
|
6311
|
+
* ManageApiError 409 `channel_assignment_conflict` — the caller keeps its
|
|
6312
|
+
* selection and offers "Refresh and review". There is no dry-run: the review
|
|
6313
|
+
* sheet previews locally, this call returns the authoritative buckets.
|
|
6314
|
+
*/
|
|
6315
|
+
applyChannelAssignment(key, input) {
|
|
6316
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/assignments`, {
|
|
6317
|
+
method: "POST",
|
|
6318
|
+
body: {
|
|
6319
|
+
targetChannelId: input.targetChannelId || null,
|
|
6320
|
+
labels: input.labels,
|
|
6321
|
+
assignmentVersion: input.assignmentVersion
|
|
6322
|
+
}
|
|
6323
|
+
});
|
|
6324
|
+
}
|
|
6325
|
+
/**
|
|
6326
|
+
* Read-only buyer projection for an audience (§8.6) — the SAME scoped server
|
|
6327
|
+
* view the buyer SDK receives, never a local approximation.
|
|
6328
|
+
*
|
|
6329
|
+
* Ships on the access-hardening branch. Older workers 404/405 here; callers
|
|
6330
|
+
* MUST feature-detect and quietly say the preview needs a newer server rather
|
|
6331
|
+
* than faking a projection client-side.
|
|
6332
|
+
*/
|
|
6333
|
+
channelPreview(key, channelIds, opts = {}) {
|
|
6334
|
+
const params = new URLSearchParams();
|
|
6335
|
+
if (channelIds.length) params.set("channelIds", channelIds.join(","));
|
|
6336
|
+
if (opts.includePublic != null) params.set("includePublic", opts.includePublic ? "1" : "0");
|
|
6337
|
+
const qs = params.toString();
|
|
6338
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/preview${qs ? `?${qs}` : ""}`);
|
|
6339
|
+
}
|
|
6340
|
+
/** Declare how buyers are meant to reach this channel. Drives the rail's
|
|
6341
|
+
* access line and turns "No buyer access configured" from information into a
|
|
6342
|
+
* warning when the organizer says the channel is for buyer self-service. */
|
|
6343
|
+
setChannelAccessIntent(key, channelId, accessIntent) {
|
|
6344
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
|
|
6345
|
+
method: "PATCH",
|
|
6346
|
+
body: { accessIntent }
|
|
6347
|
+
});
|
|
6348
|
+
}
|
|
5130
6349
|
// ---- reports (token) ----
|
|
5131
6350
|
report(key) {
|
|
5132
6351
|
return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
|
|
@@ -5153,6 +6372,1369 @@ var ManageApi = class {
|
|
|
5153
6372
|
}
|
|
5154
6373
|
};
|
|
5155
6374
|
|
|
6375
|
+
// src/channelsMode.ts
|
|
6376
|
+
var POLL_MS = 1e4;
|
|
6377
|
+
var MAX_FLAGS = 8;
|
|
6378
|
+
var SEAT_LIST_PAGE = 300;
|
|
6379
|
+
var CHANNELS_CSS = `
|
|
6380
|
+
.slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
|
|
6381
|
+
--slm-mo-out:cubic-bezier(.2,.8,.2,1);--slm-mo-in-out:cubic-bezier(.4,0,.2,1);--slm-mo-exit:cubic-bezier(.4,0,1,1);
|
|
6382
|
+
--slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
|
|
6383
|
+
|
|
6384
|
+
/* map overlay: ONE layer, faded in as a whole (never per seat) */
|
|
6385
|
+
.slm-ch-layer{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
6386
|
+
.slm-ch-layer.on{opacity:1}
|
|
6387
|
+
.slm-ch-canvas{position:absolute;inset:0;width:100%;height:100%}
|
|
6388
|
+
.slm-ch-flag{position:absolute;display:flex;align-items:center;gap:5px;padding:3px 8px;border-radius:999px;
|
|
6389
|
+
background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
|
|
6390
|
+
transform:translate(-50%,-50%);white-space:nowrap}
|
|
6391
|
+
.slm-ch-flag .mk{width:14px;height:14px;border-radius:4px;display:grid;place-items:center;font-size:8.5px;font-weight:800;color:#0e1017}
|
|
6392
|
+
|
|
6393
|
+
/* preview banner \u2014 raised with the organizer chrome dim, as one transition */
|
|
6394
|
+
.slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
|
|
6395
|
+
border-radius:999px;background:rgba(14,16,23,.92);border:1px solid var(--slm-line);font-size:12px;font-weight:700;
|
|
6396
|
+
transform:translate(-50%,-8px);opacity:0;pointer-events:none;
|
|
6397
|
+
transition:opacity var(--slm-mo-base) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out)}
|
|
6398
|
+
.slm-ch-banner.on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
|
|
6399
|
+
.slm-ch-banner .dot{width:8px;height:8px;border-radius:50%}
|
|
6400
|
+
.slm-ch-banner button{color:var(--slm-accent);font-weight:800;font-size:11.5px;min-height:32px}
|
|
6401
|
+
.slm.ch-preview .slm-ch-flag{opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
6402
|
+
|
|
6403
|
+
/* sticky staged bar */
|
|
6404
|
+
.slm-ch-staged{position:absolute;left:12px;right:12px;bottom:12px;z-index:6;display:flex;align-items:center;gap:12px;
|
|
6405
|
+
padding:10px 14px;min-height:44px;border-radius:12px;background:rgba(24,27,36,.96);border:1px solid var(--slm-line);
|
|
6406
|
+
box-shadow:0 12px 34px rgba(0,0,0,.45);font-size:12.5px;pointer-events:auto;
|
|
6407
|
+
transform:translateY(calc(100% + 18px));opacity:0;
|
|
6408
|
+
transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
6409
|
+
.slm-ch-staged.on{transform:none;opacity:1}
|
|
6410
|
+
.slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
|
|
6411
|
+
.slm-ch-staged.shake{animation:slm-ch-shake 320ms var(--slm-mo-in-out) 2}
|
|
6412
|
+
.slm-ch-staged b{font-variant-numeric:tabular-nums}
|
|
6413
|
+
.slm-ch-staged .grow{flex:1}
|
|
6414
|
+
.slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
|
|
6415
|
+
background:#f4b740;color:#1a1200;font-weight:800;font-size:12.5px}
|
|
6416
|
+
.slm-ch-staged .drop{color:var(--slm-muted);font-weight:700;font-size:11.5px;min-height:44px;padding-inline:8px}
|
|
6417
|
+
.slm-ch-tick{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;
|
|
6418
|
+
background:#fff;color:#1f7a4d;font-weight:900;font-size:11px;animation:slm-ch-tick var(--slm-mo-base) var(--slm-mo-spring)}
|
|
6419
|
+
@keyframes slm-ch-shake{0%,100%{transform:none}25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}
|
|
6420
|
+
@keyframes slm-ch-tick{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
|
6421
|
+
|
|
6422
|
+
/* rail */
|
|
6423
|
+
.slm-ch-viewseg{display:flex;gap:3px;padding:3px;border:1px solid var(--slm-line);border-radius:9px;
|
|
6424
|
+
background:var(--slm-surface);margin-bottom:12px}
|
|
6425
|
+
.slm-ch-viewseg button{flex:1;padding:6px 8px;min-height:34px;border-radius:7px;font-size:11px;font-weight:800;color:var(--slm-muted)}
|
|
6426
|
+
.slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
6427
|
+
.slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
|
|
6428
|
+
.slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
|
|
6429
|
+
.slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
|
|
6430
|
+
text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
|
|
6431
|
+
.slm-ch-row:hover{border-color:var(--slm-muted)}
|
|
6432
|
+
.slm-ch-row.on{border-color:var(--slm-accent);box-shadow:0 0 0 1px color-mix(in srgb,var(--slm-accent) 40%,transparent)}
|
|
6433
|
+
.slm-ch-row.public{background:linear-gradient(100deg,rgba(244,183,64,.09),var(--slm-surface) 60%)}
|
|
6434
|
+
.slm-ch-row.archived{opacity:.68}
|
|
6435
|
+
.slm-ch-head{display:flex;align-items:center;gap:8px}
|
|
6436
|
+
.slm-ch-mk{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800;
|
|
6437
|
+
color:#0e1017;flex:none}
|
|
6438
|
+
.slm-ch-mk.dim{opacity:.55}
|
|
6439
|
+
.slm-ch-name{flex:1;min-width:0;font-size:13px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
6440
|
+
.slm-ch-badge{flex:none;font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:2px 7px;border-radius:999px}
|
|
6441
|
+
.slm-ch-badge.active{background:rgba(34,160,107,.16);color:#5bd39b}
|
|
6442
|
+
.slm-ch-badge.paused,.slm-ch-badge.builtin{background:rgba(244,183,64,.15);color:#f7ca6b}
|
|
6443
|
+
.slm-ch-badge.archived{background:rgba(139,148,172,.18);color:#c2c9d8}
|
|
6444
|
+
.slm-ch-counts{display:flex;gap:10px;flex-wrap:wrap;margin-top:7px;font-size:11px;color:var(--slm-muted);
|
|
6445
|
+
font-variant-numeric:tabular-nums}
|
|
6446
|
+
.slm-ch-counts b{color:var(--slm-text);font-weight:800}
|
|
6447
|
+
.slm-ch-counts .free b{color:#5bd39b}
|
|
6448
|
+
.slm-ch-counts b.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
|
|
6449
|
+
@keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
|
|
6450
|
+
.slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
|
|
6451
|
+
.slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
|
|
6452
|
+
.slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
|
|
6453
|
+
.slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
|
|
6454
|
+
.slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
|
|
6455
|
+
font-weight:800;color:#0e1017}
|
|
6456
|
+
.slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
|
|
6457
|
+
.slm-ch-selsrc-row span{color:var(--slm-muted)}
|
|
6458
|
+
.slm-ch-selnum.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
|
|
6459
|
+
.slm-ch-row2{display:flex;gap:8px;margin-top:8px}
|
|
6460
|
+
.slm-ch-row2 .slm-btn{flex:1;min-width:0}
|
|
6461
|
+
.slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
|
|
6462
|
+
line-height:1.5;margin-bottom:12px}
|
|
6463
|
+
.slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
|
|
6464
|
+
.slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
|
|
6465
|
+
.slm-ch-alert b{color:#fff}
|
|
6466
|
+
.slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
|
|
6467
|
+
.slm-ch-legend{display:flex;flex-direction:column;gap:6px;margin-top:10px}
|
|
6468
|
+
.slm-ch-legend .r{display:flex;align-items:center;gap:9px;font-size:12px;color:var(--slm-muted)}
|
|
6469
|
+
.slm-ch-legend .sw{width:13px;height:13px;border-radius:3.5px;flex:none}
|
|
6470
|
+
.slm-ch-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
6471
|
+
|
|
6472
|
+
/* dialogs */
|
|
6473
|
+
.slm-ch-scrim{position:absolute;inset:0;z-index:12;background:rgba(4,6,12,.62);display:grid;place-items:center;
|
|
6474
|
+
padding:18px;animation:slm-ch-fade var(--slm-mo-quick) var(--slm-mo-out)}
|
|
6475
|
+
.slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;background:#12151f;border:1px solid var(--slm-line);
|
|
6476
|
+
border-radius:14px;padding:20px;box-shadow:0 24px 70px rgba(0,0,0,.6);
|
|
6477
|
+
animation:slm-ch-rise var(--slm-mo-base) var(--slm-mo-out)}
|
|
6478
|
+
.slm-ch-dialog h3{margin:0 0 4px;font-size:16px;font-weight:800;letter-spacing:-.01em}
|
|
6479
|
+
.slm-ch-dialog .sub{font-size:12.5px;color:var(--slm-muted);line-height:1.5;margin-bottom:14px}
|
|
6480
|
+
.slm-ch-dialog .foot{display:flex;gap:8px;margin-top:16px}
|
|
6481
|
+
.slm-ch-dialog .foot .slm-btn{flex:1;min-width:0}
|
|
6482
|
+
.slm-ch-dialog .foot .quiet{flex:none;padding:10px 14px;min-height:44px;color:var(--slm-muted);font-weight:700;font-size:13px}
|
|
6483
|
+
@keyframes slm-ch-fade{from{opacity:0}to{opacity:1}}
|
|
6484
|
+
@keyframes slm-ch-rise{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
|
|
6485
|
+
.slm-ch-bucket{display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:10px;padding:9px 4px;
|
|
6486
|
+
border-top:1px solid var(--slm-line);font-size:12.5px;animation:slm-ch-bucket var(--slm-mo-base) var(--slm-mo-out) both}
|
|
6487
|
+
.slm-ch-bucket:first-of-type{border-top:0}
|
|
6488
|
+
.slm-ch-bucket .ico{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800}
|
|
6489
|
+
.slm-ch-bucket .ico.add{background:rgba(34,160,107,.18);color:#5bd39b}
|
|
6490
|
+
.slm-ch-bucket .ico.move{background:rgba(167,139,250,.18);color:#c4b5fd}
|
|
6491
|
+
.slm-ch-bucket .ico.same{background:rgba(139,148,172,.14);color:#aab2c4}
|
|
6492
|
+
.slm-ch-bucket .ico.skip{background:rgba(244,183,64,.16);color:#f7ca6b}
|
|
6493
|
+
.slm-ch-bucket b{font-variant-numeric:tabular-nums;font-weight:800}
|
|
6494
|
+
.slm-ch-bucket .why{color:var(--slm-muted);font-size:11px}
|
|
6495
|
+
.slm-ch-bucket .peek{color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums}
|
|
6496
|
+
@keyframes slm-ch-bucket{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
6497
|
+
.slm-ch-secret{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed rgba(244,183,64,.55);
|
|
6498
|
+
border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
|
|
6499
|
+
overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
6500
|
+
.slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
|
|
6501
|
+
.slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
|
|
6502
|
+
background:var(--slm-surface);margin-top:10px}
|
|
6503
|
+
.slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
|
|
6504
|
+
justify-content:space-between;gap:8px;font-size:11px;font-weight:800;color:var(--slm-muted);position:sticky;top:0;
|
|
6505
|
+
background:var(--slm-surface)}
|
|
6506
|
+
.slm-ch-seatgroup button{color:var(--slm-accent);font-weight:800;font-size:11px;min-height:32px}
|
|
6507
|
+
.slm-ch-seatitem{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;border-bottom:1px solid var(--slm-line);
|
|
6508
|
+
text-align:left;font-size:12px}
|
|
6509
|
+
.slm-ch-seatitem .box{width:16px;height:16px;border-radius:4px;border:1px solid var(--slm-muted);display:grid;
|
|
6510
|
+
place-items:center;font-size:10px;font-weight:900;color:transparent;flex:none}
|
|
6511
|
+
.slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
6512
|
+
.slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
|
|
6513
|
+
|
|
6514
|
+
/* compact: bottom sheet with three detents (\xA713) */
|
|
6515
|
+
.slm.compact.ch-sheet .slm-rail{position:absolute;left:0;right:0;bottom:0;z-index:8;border-top:1px solid var(--slm-line);
|
|
6516
|
+
border-radius:18px 18px 0 0;background:#12151f;
|
|
6517
|
+
transition:height var(--slm-mo-slow) var(--slm-mo-in-out)}
|
|
6518
|
+
.slm.compact.ch-sheet.detent-collapsed .slm-rail{height:132px}
|
|
6519
|
+
.slm.compact.ch-sheet.detent-medium .slm-rail{height:46%}
|
|
6520
|
+
.slm.compact.ch-sheet.detent-full .slm-rail{height:92%}
|
|
6521
|
+
.slm.compact.ch-sheet .slm-railscroll{padding:8px 14px calc(12px + env(safe-area-inset-bottom,0px))}
|
|
6522
|
+
.slm-ch-grab{display:none}
|
|
6523
|
+
.slm.compact.ch-sheet .slm-ch-grab{display:flex;align-items:center;gap:10px;width:100%;padding:6px 0 10px}
|
|
6524
|
+
.slm-ch-grabbar{width:42px;height:4px;border-radius:2px;background:rgba(255,255,255,.22);margin:0 auto}
|
|
6525
|
+
.slm.compact .slm-ch-staged{bottom:auto;top:8px}
|
|
6526
|
+
.slm.compact .slm-btn,.slm.compact .slm-ch-row,.slm.compact .slm-ch-viewseg button{min-height:44px}
|
|
6527
|
+
.slm-tools{display:none}
|
|
6528
|
+
.slm.compact .slm-tools{display:block;width:100%;padding:9px 13px;min-height:44px;border:1px solid var(--slm-line);
|
|
6529
|
+
border-radius:10px;background:var(--slm-surface);color:var(--slm-text);font-size:13px;font-weight:800}
|
|
6530
|
+
.slm.compact .slm-modes{display:none}
|
|
6531
|
+
|
|
6532
|
+
@media (prefers-reduced-motion:reduce){
|
|
6533
|
+
.slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail{transition:none!important}
|
|
6534
|
+
.slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
|
|
6535
|
+
.slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
|
|
6536
|
+
.slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
|
|
6537
|
+
.slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
|
|
6538
|
+
}
|
|
6539
|
+
`;
|
|
6540
|
+
function bucketRowsHtml(rows) {
|
|
6541
|
+
return rows.map((row, index) => `
|
|
6542
|
+
<div class="slm-ch-bucket" style="animation-delay:${Math.min(index, 4) * 30}ms">
|
|
6543
|
+
<span class="ico ${row.kind}" aria-hidden="true">${esc(row.icon)}</span>
|
|
6544
|
+
<span><b>${row.count.toLocaleString()}</b> ${esc(row.text.replace(/^[\d,.\s]+/, ""))}
|
|
6545
|
+
${row.why ? `<span class="why">\u2014 ${esc(row.why)}</span>` : ""}</span>
|
|
6546
|
+
${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
|
|
6547
|
+
</div>`).join("");
|
|
6548
|
+
}
|
|
6549
|
+
function esc(value) {
|
|
6550
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6551
|
+
}
|
|
6552
|
+
var ChannelsMode = class {
|
|
6553
|
+
constructor(host, capabilities) {
|
|
6554
|
+
this.active = false;
|
|
6555
|
+
this.list = null;
|
|
6556
|
+
this.allocation = /* @__PURE__ */ new Map();
|
|
6557
|
+
this.assignmentVersion = 0;
|
|
6558
|
+
this.loadError = null;
|
|
6559
|
+
this.loading = true;
|
|
6560
|
+
this.view = "inspect";
|
|
6561
|
+
this.showArchived = false;
|
|
6562
|
+
this.detailChannelId = null;
|
|
6563
|
+
this.targetChannelId = "";
|
|
6564
|
+
this.conflict = false;
|
|
6565
|
+
this.dialog = null;
|
|
6566
|
+
this.detent = "medium";
|
|
6567
|
+
this.seatListLimit = SEAT_LIST_PAGE;
|
|
6568
|
+
this.previewAudience = [];
|
|
6569
|
+
this.previewIncludePublic = false;
|
|
6570
|
+
this.previewProjection = null;
|
|
6571
|
+
this.previewSupported = null;
|
|
6572
|
+
// null = not yet probed
|
|
6573
|
+
this.pollTimer = null;
|
|
6574
|
+
this.layer = null;
|
|
6575
|
+
this.canvas = null;
|
|
6576
|
+
/** undefined = not resolved yet, null = this environment has no 2d canvas. */
|
|
6577
|
+
this.ctx = void 0;
|
|
6578
|
+
this.bannerEl = null;
|
|
6579
|
+
this.stagedEl = null;
|
|
6580
|
+
this.liveEl = null;
|
|
6581
|
+
this.scrimEl = null;
|
|
6582
|
+
this.lastFocus = null;
|
|
6583
|
+
this.stagedDoneTimer = null;
|
|
6584
|
+
this.lastSelectionCount = 0;
|
|
6585
|
+
this.lastCounts = /* @__PURE__ */ new Map();
|
|
6586
|
+
this.host = host;
|
|
6587
|
+
this.caps = capabilities;
|
|
6588
|
+
}
|
|
6589
|
+
// ---- lifecycle ------------------------------------------------------------
|
|
6590
|
+
/** Called when the cockpit switches into Channels mode. */
|
|
6591
|
+
enter() {
|
|
6592
|
+
if (this.active) return;
|
|
6593
|
+
this.active = true;
|
|
6594
|
+
this.ensureLayer();
|
|
6595
|
+
this.host.root.classList.add("ch-mode");
|
|
6596
|
+
this.applySheetClasses();
|
|
6597
|
+
this.paintRail();
|
|
6598
|
+
void this.refresh();
|
|
6599
|
+
this.pollTimer = setInterval(() => {
|
|
6600
|
+
void this.refresh({ quiet: true });
|
|
6601
|
+
}, POLL_MS);
|
|
6602
|
+
}
|
|
6603
|
+
/** Called when the cockpit leaves Channels mode. Everything this mode painted
|
|
6604
|
+
* over the map goes with it — no other tool ever inherits a channel overlay. */
|
|
6605
|
+
leave() {
|
|
6606
|
+
if (!this.active) return;
|
|
6607
|
+
this.active = false;
|
|
6608
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
6609
|
+
this.pollTimer = null;
|
|
6610
|
+
this.closeDialog({ restoreFocus: false });
|
|
6611
|
+
this.layer?.classList.remove("on");
|
|
6612
|
+
this.host.root.classList.remove(
|
|
6613
|
+
"ch-mode",
|
|
6614
|
+
"ch-preview",
|
|
6615
|
+
"ch-sheet",
|
|
6616
|
+
"detent-collapsed",
|
|
6617
|
+
"detent-medium",
|
|
6618
|
+
"detent-full"
|
|
6619
|
+
);
|
|
6620
|
+
this.setBanner(false);
|
|
6621
|
+
this.setStaged(null);
|
|
6622
|
+
}
|
|
6623
|
+
destroy() {
|
|
6624
|
+
this.leave();
|
|
6625
|
+
if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
|
|
6626
|
+
this.layer?.remove();
|
|
6627
|
+
this.layer = null;
|
|
6628
|
+
}
|
|
6629
|
+
/** Capabilities can change when a token rotates. Re-render, fail-closed. */
|
|
6630
|
+
setCapabilities(capabilities) {
|
|
6631
|
+
this.caps = capabilities;
|
|
6632
|
+
if (this.active) this.paintRail();
|
|
6633
|
+
}
|
|
6634
|
+
isActive() {
|
|
6635
|
+
return this.active;
|
|
6636
|
+
}
|
|
6637
|
+
/**
|
|
6638
|
+
* Whether the map should accept bulk selection right now. Preview is a
|
|
6639
|
+
* read-only simulation of somebody else's view, and a view-only token has no
|
|
6640
|
+
* assignment to stage — in both cases the canvas must not offer selection at
|
|
6641
|
+
* all rather than collect a selection nothing can act on.
|
|
6642
|
+
*/
|
|
6643
|
+
canSelect() {
|
|
6644
|
+
return this.caps.manage && this.view === "inspect";
|
|
6645
|
+
}
|
|
6646
|
+
/**
|
|
6647
|
+
* Organizer realtime integration point. M5 ships a per-scope socket for
|
|
6648
|
+
* buyers; the organizer channel-count stream is a later milestone. When it
|
|
6649
|
+
* arrives, call this from the cockpit's WS handler instead of waiting for the
|
|
6650
|
+
* poll — everything downstream already reacts to a fresh list.
|
|
6651
|
+
*/
|
|
6652
|
+
applyRealtimeHint() {
|
|
6653
|
+
if (this.active) void this.refresh({ quiet: true });
|
|
6654
|
+
}
|
|
6655
|
+
/** The cockpit's selection changed (marquee / click / section / category). */
|
|
6656
|
+
handleSelectionChange() {
|
|
6657
|
+
if (!this.active) return;
|
|
6658
|
+
this.paintSelection();
|
|
6659
|
+
this.paintStagedBar();
|
|
6660
|
+
}
|
|
6661
|
+
/** Camera moved or the container resized — the overlay is screen-space. */
|
|
6662
|
+
handleViewChange() {
|
|
6663
|
+
if (this.active) this.paintOverlay();
|
|
6664
|
+
}
|
|
6665
|
+
handleLayoutChange() {
|
|
6666
|
+
if (!this.active) return;
|
|
6667
|
+
this.applySheetClasses();
|
|
6668
|
+
this.paintOverlay();
|
|
6669
|
+
}
|
|
6670
|
+
// ---- data -----------------------------------------------------------------
|
|
6671
|
+
async refresh(opts = {}) {
|
|
6672
|
+
if (!this.caps.view) return;
|
|
6673
|
+
try {
|
|
6674
|
+
const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
|
|
6675
|
+
this.list = list;
|
|
6676
|
+
this.assignmentVersion = list.assignmentVersion;
|
|
6677
|
+
this.loadError = null;
|
|
6678
|
+
if (!this.targetChannelId) {
|
|
6679
|
+
this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
|
|
6680
|
+
}
|
|
6681
|
+
await this.loadAllocation();
|
|
6682
|
+
this.loading = false;
|
|
6683
|
+
if (this.active) {
|
|
6684
|
+
this.paintRail();
|
|
6685
|
+
this.paintOverlay();
|
|
6686
|
+
}
|
|
6687
|
+
} catch (err) {
|
|
6688
|
+
this.loading = false;
|
|
6689
|
+
if (err instanceof ManageApiError && err.status === 403) {
|
|
6690
|
+
this.caps = { view: false, manage: false };
|
|
6691
|
+
}
|
|
6692
|
+
this.loadError = err;
|
|
6693
|
+
if (!opts.quiet) this.host.onError(err);
|
|
6694
|
+
if (this.active) this.paintRail();
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
6697
|
+
/** Walk every allocation page. Bounded by the event's seat count, and the
|
|
6698
|
+
* server caps each page, so an arena is a handful of round trips. */
|
|
6699
|
+
async loadAllocation() {
|
|
6700
|
+
const next = /* @__PURE__ */ new Map();
|
|
6701
|
+
let afterLabel;
|
|
6702
|
+
for (let page = 0; page < 200; page += 1) {
|
|
6703
|
+
const res = await this.host.api.channelAllocation(this.host.eventKey, {
|
|
6704
|
+
afterLabel,
|
|
6705
|
+
limit: 1e3
|
|
6706
|
+
});
|
|
6707
|
+
for (const row of res.allocations) {
|
|
6708
|
+
if (row.channelId && row.channelId !== PUBLIC_CHANNEL_ID) next.set(row.label, row.channelId);
|
|
6709
|
+
}
|
|
6710
|
+
this.assignmentVersion = res.assignmentVersion;
|
|
6711
|
+
if (!res.nextAfterLabel) break;
|
|
6712
|
+
afterLabel = res.nextAfterLabel;
|
|
6713
|
+
}
|
|
6714
|
+
this.allocation = next;
|
|
6715
|
+
}
|
|
6716
|
+
// ---- lookups --------------------------------------------------------------
|
|
6717
|
+
channelById(id) {
|
|
6718
|
+
if (id === PUBLIC_CHANNEL_ID) {
|
|
6719
|
+
return { id, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME, marker: "P", color: null };
|
|
6720
|
+
}
|
|
6721
|
+
const found = this.list?.channels.find((channel) => channel.id === id);
|
|
6722
|
+
return found ? { id, name: found.name, marker: found.marker, color: found.color } : null;
|
|
6723
|
+
}
|
|
6724
|
+
nameOf(id) {
|
|
6725
|
+
return this.channelById(id)?.name ?? null;
|
|
6726
|
+
}
|
|
6727
|
+
markerFor(id) {
|
|
6728
|
+
const index = Math.max(0, this.list?.channels.findIndex((channel2) => channel2.id === id) ?? 0);
|
|
6729
|
+
const channel = this.channelById(id);
|
|
6730
|
+
return markerOf(channel ?? { id, name: "?", marker: null, color: null }, index);
|
|
6731
|
+
}
|
|
6732
|
+
/** Channels an organizer may assign INTO: public sale plus every live channel. */
|
|
6733
|
+
assignableChannels() {
|
|
6734
|
+
return [
|
|
6735
|
+
{ id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
6736
|
+
...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
|
|
6737
|
+
];
|
|
6738
|
+
}
|
|
6739
|
+
currentPlan() {
|
|
6740
|
+
const labels = this.host.selectionLabels();
|
|
6741
|
+
const buckets = planAssignment({
|
|
6742
|
+
labels,
|
|
6743
|
+
targetChannelId: this.targetChannelId,
|
|
6744
|
+
allocation: this.allocation,
|
|
6745
|
+
statusOf: (label) => this.host.statusOf(label),
|
|
6746
|
+
nameOf: (id) => this.nameOf(id)
|
|
6747
|
+
});
|
|
6748
|
+
return { labels, buckets, target: this.targetChannelId };
|
|
6749
|
+
}
|
|
6750
|
+
// ---- map overlay ----------------------------------------------------------
|
|
6751
|
+
ensureLayer() {
|
|
6752
|
+
if (this.layer) return;
|
|
6753
|
+
const layer = document.createElement("div");
|
|
6754
|
+
layer.className = "slm-ch-layer";
|
|
6755
|
+
layer.innerHTML = `
|
|
6756
|
+
<canvas class="slm-ch-canvas" data-ch="canvas" aria-hidden="true"></canvas>
|
|
6757
|
+
<div class="slm-ch-banner" data-ch="banner" role="status"></div>
|
|
6758
|
+
<div class="slm-ch-staged" data-ch="staged" role="group" aria-label="Staged channel changes"></div>
|
|
6759
|
+
<div class="slm-ch-live" data-ch="live" role="status" aria-live="polite"></div>`;
|
|
6760
|
+
this.host.mapLayer.appendChild(layer);
|
|
6761
|
+
this.layer = layer;
|
|
6762
|
+
this.canvas = layer.querySelector('[data-ch="canvas"]');
|
|
6763
|
+
this.bannerEl = layer.querySelector('[data-ch="banner"]');
|
|
6764
|
+
this.stagedEl = layer.querySelector('[data-ch="staged"]');
|
|
6765
|
+
this.liveEl = layer.querySelector('[data-ch="live"]');
|
|
6766
|
+
requestAnimationFrame(() => layer.classList.add("on"));
|
|
6767
|
+
}
|
|
6768
|
+
announce(message) {
|
|
6769
|
+
if (this.liveEl) this.liveEl.textContent = message;
|
|
6770
|
+
}
|
|
6771
|
+
/**
|
|
6772
|
+
* Repaint the allocation (or preview) overlay in ONE canvas pass.
|
|
6773
|
+
*
|
|
6774
|
+
* Channel identity on the map is a fill in the administrative color PLUS the
|
|
6775
|
+
* letter flags below — never color alone. Physical status keeps its own cue:
|
|
6776
|
+
* only FREE units take a channel fill, so sold/held/blocked seats still read
|
|
6777
|
+
* exactly as they do in every other tool.
|
|
6778
|
+
*/
|
|
6779
|
+
paintOverlay() {
|
|
6780
|
+
const canvas = this.canvas;
|
|
6781
|
+
const layer = this.layer;
|
|
6782
|
+
if (!canvas || !layer) return;
|
|
6783
|
+
const rect = this.host.mapLayer.getBoundingClientRect();
|
|
6784
|
+
const width = Math.max(1, Math.round(rect.width));
|
|
6785
|
+
const height = Math.max(1, Math.round(rect.height));
|
|
6786
|
+
const dpr = typeof devicePixelRatio === "number" ? Math.min(3, Math.max(1, devicePixelRatio)) : 1;
|
|
6787
|
+
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
|
|
6788
|
+
canvas.width = width * dpr;
|
|
6789
|
+
canvas.height = height * dpr;
|
|
6790
|
+
}
|
|
6791
|
+
if (this.ctx === void 0) {
|
|
6792
|
+
try {
|
|
6793
|
+
this.ctx = canvas.getContext("2d");
|
|
6794
|
+
} catch {
|
|
6795
|
+
this.ctx = null;
|
|
6796
|
+
}
|
|
6797
|
+
}
|
|
6798
|
+
const ctx = this.ctx;
|
|
6799
|
+
if (!ctx) return;
|
|
6800
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
6801
|
+
ctx.clearRect(0, 0, width, height);
|
|
6802
|
+
const size = Math.max(3, this.host.seatPixelSize());
|
|
6803
|
+
const half = size / 2;
|
|
6804
|
+
const projection = this.view === "preview" ? this.previewProjection : null;
|
|
6805
|
+
const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
|
|
6806
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
6807
|
+
for (const seat of this.host.seats()) {
|
|
6808
|
+
const status = this.host.statusOf(seat.label) ?? "free";
|
|
6809
|
+
const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
|
|
6810
|
+
if (channelId !== PUBLIC_CHANNEL_ID) {
|
|
6811
|
+
const cluster = clusters.get(channelId) ?? { x: 0, y: 0, n: 0 };
|
|
6812
|
+
cluster.x += seat.x;
|
|
6813
|
+
cluster.y += seat.y;
|
|
6814
|
+
cluster.n += 1;
|
|
6815
|
+
clusters.set(channelId, cluster);
|
|
6816
|
+
}
|
|
6817
|
+
if (status !== "free") continue;
|
|
6818
|
+
let fill = null;
|
|
6819
|
+
if (this.view === "preview") {
|
|
6820
|
+
fill = eligible ? eligible.has(seat.label) ? null : "#3a4051" : null;
|
|
6821
|
+
} else if (channelId !== PUBLIC_CHANNEL_ID) {
|
|
6822
|
+
fill = this.markerFor(channelId).color;
|
|
6823
|
+
}
|
|
6824
|
+
if (!fill) continue;
|
|
6825
|
+
const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
|
|
6826
|
+
if (!point) continue;
|
|
6827
|
+
if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
|
|
6828
|
+
ctx.fillStyle = fill;
|
|
6829
|
+
ctx.globalAlpha = this.view === "preview" ? 0.9 : 0.85;
|
|
6830
|
+
ctx.fillRect(point.x - half, point.y - half, size, size);
|
|
6831
|
+
}
|
|
6832
|
+
ctx.globalAlpha = 1;
|
|
6833
|
+
this.paintFlags(clusters);
|
|
6834
|
+
}
|
|
6835
|
+
/** Letter flags at each channel's centroid — the non-color identity cue. */
|
|
6836
|
+
paintFlags(clusters) {
|
|
6837
|
+
const layer = this.layer;
|
|
6838
|
+
if (!layer) return;
|
|
6839
|
+
layer.querySelectorAll(".slm-ch-flag").forEach((el) => el.remove());
|
|
6840
|
+
if (this.view === "preview") return;
|
|
6841
|
+
const ranked = [...clusters.entries()].sort((a, b) => b[1].n - a[1].n).slice(0, MAX_FLAGS);
|
|
6842
|
+
for (const [channelId, cluster] of ranked) {
|
|
6843
|
+
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
6844
|
+
if (!channel) continue;
|
|
6845
|
+
const point = this.host.worldToScreen({ x: cluster.x / cluster.n, y: cluster.y / cluster.n });
|
|
6846
|
+
if (!point) continue;
|
|
6847
|
+
const marker = this.markerFor(channelId);
|
|
6848
|
+
const flag = document.createElement("span");
|
|
6849
|
+
flag.className = "slm-ch-flag";
|
|
6850
|
+
flag.style.left = `${point.x}px`;
|
|
6851
|
+
flag.style.top = `${point.y}px`;
|
|
6852
|
+
flag.innerHTML = `<span class="mk" style="background:${esc(marker.color)}">${esc(marker.letter)}</span>${esc(channel.name)}${channel.state === "paused" ? " \xB7 Paused" : ""}`;
|
|
6853
|
+
layer.appendChild(flag);
|
|
6854
|
+
}
|
|
6855
|
+
}
|
|
6856
|
+
// ---- staged bar -----------------------------------------------------------
|
|
6857
|
+
setStaged(html, cls = "") {
|
|
6858
|
+
const bar = this.stagedEl;
|
|
6859
|
+
if (!bar) return;
|
|
6860
|
+
if (!html) {
|
|
6861
|
+
bar.classList.remove("on", "done", "shake");
|
|
6862
|
+
bar.innerHTML = "";
|
|
6863
|
+
return;
|
|
6864
|
+
}
|
|
6865
|
+
bar.innerHTML = html;
|
|
6866
|
+
bar.className = `slm-ch-staged on${cls ? ` ${cls}` : ""}`;
|
|
6867
|
+
}
|
|
6868
|
+
paintStagedBar() {
|
|
6869
|
+
if (!this.active || this.view === "preview" || !this.caps.manage) {
|
|
6870
|
+
this.setStaged(null);
|
|
6871
|
+
return;
|
|
6872
|
+
}
|
|
6873
|
+
const { labels, buckets } = this.currentPlan();
|
|
6874
|
+
this.host.onStagedChange?.(mutationCount(buckets));
|
|
6875
|
+
if (!labels.length) {
|
|
6876
|
+
this.setStaged(null);
|
|
6877
|
+
return;
|
|
6878
|
+
}
|
|
6879
|
+
const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
|
|
6880
|
+
const mutations = mutationCount(buckets);
|
|
6881
|
+
const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
|
|
6882
|
+
const parts = [
|
|
6883
|
+
`<b>${labels.length.toLocaleString()}</b> selected`,
|
|
6884
|
+
`<b>+${mutations.toLocaleString()}</b> to ${esc(target)}`
|
|
6885
|
+
];
|
|
6886
|
+
if (buckets.alreadyInTarget.count) parts.push(`<b>${buckets.alreadyInTarget.count.toLocaleString()}</b> already in`);
|
|
6887
|
+
if (skipped) parts.push(`<b>${skipped.toLocaleString()}</b> can't move now`);
|
|
6888
|
+
this.setStaged(`
|
|
6889
|
+
<span>${parts.join(" \xB7 ")}</span>
|
|
6890
|
+
<span class="grow"></span>
|
|
6891
|
+
<button type="button" class="drop" data-ch-act="discard">Discard</button>
|
|
6892
|
+
<button type="button" class="go" data-ch-act="review">Review changes</button>`);
|
|
6893
|
+
this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
|
|
6894
|
+
button.addEventListener("click", () => {
|
|
6895
|
+
if (button.dataset.chAct === "discard") this.host.clearSelection();
|
|
6896
|
+
else this.openDialog({ kind: "review" });
|
|
6897
|
+
});
|
|
6898
|
+
});
|
|
6899
|
+
}
|
|
6900
|
+
setBanner(on, name = "") {
|
|
6901
|
+
const banner = this.bannerEl;
|
|
6902
|
+
if (!banner) return;
|
|
6903
|
+
this.host.root.classList.toggle("ch-preview", on);
|
|
6904
|
+
if (!on) {
|
|
6905
|
+
banner.classList.remove("on");
|
|
6906
|
+
banner.innerHTML = "";
|
|
6907
|
+
return;
|
|
6908
|
+
}
|
|
6909
|
+
const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
|
|
6910
|
+
banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
|
|
6911
|
+
Previewing buyer access \xB7 ${esc(name)} \xB7 read-only
|
|
6912
|
+
<button type="button" data-ch-act="exit-preview">Exit preview</button>`;
|
|
6913
|
+
banner.classList.add("on");
|
|
6914
|
+
banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
|
|
6915
|
+
}
|
|
6916
|
+
// ---- rail -----------------------------------------------------------------
|
|
6917
|
+
setView(view) {
|
|
6918
|
+
this.view = view;
|
|
6919
|
+
if (view === "inspect") {
|
|
6920
|
+
this.previewProjection = null;
|
|
6921
|
+
this.setBanner(false);
|
|
6922
|
+
} else {
|
|
6923
|
+
if (!this.previewAudience.length) {
|
|
6924
|
+
const first = this.list?.channels.find((channel) => channel.state === "active");
|
|
6925
|
+
this.previewAudience = [first ? first.id : PUBLIC_CHANNEL_ID];
|
|
6926
|
+
}
|
|
6927
|
+
void this.loadPreview();
|
|
6928
|
+
}
|
|
6929
|
+
this.paintRail();
|
|
6930
|
+
this.paintOverlay();
|
|
6931
|
+
this.paintStagedBar();
|
|
6932
|
+
this.onInteractionChange?.();
|
|
6933
|
+
}
|
|
6934
|
+
async loadPreview() {
|
|
6935
|
+
const audience = [...this.previewAudience];
|
|
6936
|
+
const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
|
|
6937
|
+
this.setBanner(true, names);
|
|
6938
|
+
try {
|
|
6939
|
+
this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
|
|
6940
|
+
includePublic: this.previewIncludePublic
|
|
6941
|
+
});
|
|
6942
|
+
this.previewSupported = true;
|
|
6943
|
+
} catch (err) {
|
|
6944
|
+
const status = err instanceof ManageApiError ? err.status : 0;
|
|
6945
|
+
this.previewSupported = !(status === 404 || status === 405 || status === 501);
|
|
6946
|
+
this.previewProjection = null;
|
|
6947
|
+
if (this.previewSupported) this.host.onError(err);
|
|
6948
|
+
}
|
|
6949
|
+
if (this.active) {
|
|
6950
|
+
this.paintRail();
|
|
6951
|
+
this.paintOverlay();
|
|
6952
|
+
}
|
|
6953
|
+
}
|
|
6954
|
+
paintRail() {
|
|
6955
|
+
if (!this.active) return;
|
|
6956
|
+
const rail = this.host.rail;
|
|
6957
|
+
if (!this.caps.view) {
|
|
6958
|
+
rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
|
|
6959
|
+
<p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`;
|
|
6960
|
+
return;
|
|
6961
|
+
}
|
|
6962
|
+
if (this.loading && !this.list) {
|
|
6963
|
+
rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
|
|
6964
|
+
<div class="slm-empty">Loading allocations\u2026</div>`;
|
|
6965
|
+
return;
|
|
6966
|
+
}
|
|
6967
|
+
if (!this.list && this.loadError) {
|
|
6968
|
+
rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
|
|
6969
|
+
<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
|
|
6970
|
+
<span><b>Couldn't load sales channels.</b> Everything else on this event still works.
|
|
6971
|
+
<button type="button" data-ch-act="retry">Try again</button></span></div>`;
|
|
6972
|
+
rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
|
|
6973
|
+
void this.refresh();
|
|
6974
|
+
});
|
|
6975
|
+
return;
|
|
6976
|
+
}
|
|
6977
|
+
const selection = this.host.selectionLabels();
|
|
6978
|
+
const grab = this.host.isCompact() ? `<div class="slm-ch-grab"><span class="slm-ch-grabbar"></span></div>` : "";
|
|
6979
|
+
const segment = this.viewSegmentHtml();
|
|
6980
|
+
const body = this.view === "preview" ? this.previewRailHtml() : this.detailChannelId ? this.detailRailHtml(this.detailChannelId) : selection.length && this.caps.manage ? this.selectionRailHtml(selection) : this.listRailHtml();
|
|
6981
|
+
rail.innerHTML = `${grab}${segment}${body}`;
|
|
6982
|
+
this.wireRail();
|
|
6983
|
+
this.paintStagedBar();
|
|
6984
|
+
}
|
|
6985
|
+
viewSegmentHtml() {
|
|
6986
|
+
const inspectOn = this.view === "inspect" ? " on" : "";
|
|
6987
|
+
const previewOn = this.view === "preview" ? " on" : "";
|
|
6988
|
+
return `<div class="slm-ch-viewseg" role="group" aria-label="Channels view">
|
|
6989
|
+
<button type="button" class="${inspectOn.trim()}" data-ch-view="inspect"
|
|
6990
|
+
aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
|
|
6991
|
+
<button type="button" class="${previewOn.trim()}" data-ch-view="preview"
|
|
6992
|
+
aria-pressed="${this.view === "preview"}">Preview buyer access</button>
|
|
6993
|
+
</div>`;
|
|
6994
|
+
}
|
|
6995
|
+
countsHtml(counts, key) {
|
|
6996
|
+
const cell = (id, value, label, cls = "") => {
|
|
6997
|
+
const previous = this.lastCounts.get(`${key}:${id}`);
|
|
6998
|
+
const bump = previous != null && previous !== value ? " bump" : "";
|
|
6999
|
+
this.lastCounts.set(`${key}:${id}`, value);
|
|
7000
|
+
return `<span class="${cls}"><b class="${bump.trim()}">${value.toLocaleString()}</b> ${label}</span>`;
|
|
7001
|
+
};
|
|
7002
|
+
return `<span class="slm-ch-counts">
|
|
7003
|
+
${cell("allocated", counts.allocated, "allocated")}
|
|
7004
|
+
${cell("free", counts.free, "free", "free")}
|
|
7005
|
+
${cell("booked", counts.booked, "sold")}
|
|
7006
|
+
${counts.held ? cell("held", counts.held, "held") : ""}
|
|
7007
|
+
</span>`;
|
|
7008
|
+
}
|
|
7009
|
+
channelRowHtml(channel, opts = {}) {
|
|
7010
|
+
const marker = this.markerFor(channel.id);
|
|
7011
|
+
const badgeKind = opts.builtin ? "builtin" : channel.state;
|
|
7012
|
+
const dim = channel.state === "paused" || channel.state === "archived" ? " dim" : "";
|
|
7013
|
+
const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}`;
|
|
7014
|
+
const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-detail="${esc(channel.id)}"
|
|
7015
|
+
aria-label="Manage ${esc(channel.name)}">\u22EF</button>` : "";
|
|
7016
|
+
return `<div class="${cls}">
|
|
7017
|
+
<span class="slm-ch-head">
|
|
7018
|
+
<span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
|
|
7019
|
+
<span class="slm-ch-name">${esc(channel.name)}</span>
|
|
7020
|
+
<span class="slm-ch-badge ${badgeKind}">${esc(stateBadge(opts.builtin ? "builtin" : channel.state))}</span>
|
|
7021
|
+
${more}
|
|
7022
|
+
</span>
|
|
7023
|
+
${this.countsHtml(channel.counts, channel.id || "public")}
|
|
7024
|
+
${opts.builtin ? "" : `<span class="slm-ch-access">${esc(accessLine(channel.access))}</span>`}
|
|
7025
|
+
</div>`;
|
|
7026
|
+
}
|
|
7027
|
+
listRailHtml() {
|
|
7028
|
+
const list = this.list;
|
|
7029
|
+
const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
|
|
7030
|
+
const rows = [
|
|
7031
|
+
this.channelRowHtml(list.publicSale, { builtin: true }),
|
|
7032
|
+
...list.channels.filter((channel) => this.showArchived || channel.state !== "archived").map((channel, index) => this.channelRowHtml(channel, { index }))
|
|
7033
|
+
].join("");
|
|
7034
|
+
const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
|
|
7035
|
+
const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
|
|
7036
|
+
return `
|
|
7037
|
+
<p class="slm-eyebrow">Sales channels</p>
|
|
7038
|
+
<p class="slm-hint">Select seats on the map, then assign them. Channel colours and names are never shown to buyers.</p>
|
|
7039
|
+
<div class="slm-ch-list">${rows}</div>
|
|
7040
|
+
${create}
|
|
7041
|
+
${readOnly}
|
|
7042
|
+
<p class="slm-note" style="margin-top:10px">
|
|
7043
|
+
<button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
|
|
7044
|
+
style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
|
|
7045
|
+
</p>`;
|
|
7046
|
+
}
|
|
7047
|
+
selectionRailHtml(selection) {
|
|
7048
|
+
const sources = selectionSources(selection, this.allocation, this.list);
|
|
7049
|
+
const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
|
|
7050
|
+
<span><b>Assignments changed while you were editing.</b> Nothing was applied.
|
|
7051
|
+
Your ${selection.length.toLocaleString()}-seat selection is kept.
|
|
7052
|
+
<button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
|
|
7053
|
+
const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
|
|
7054
|
+
this.lastSelectionCount = selection.length;
|
|
7055
|
+
const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
|
|
7056
|
+
const sourceRows = sources.map((row) => {
|
|
7057
|
+
const marker = this.markerFor(row.channelId);
|
|
7058
|
+
return `<div class="slm-ch-selsrc-row">
|
|
7059
|
+
<span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
|
|
7060
|
+
<b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
|
|
7061
|
+
}).join("");
|
|
7062
|
+
const sectionSelect = this.host.sections().length ? `<button type="button" class="slm-btn ghost" data-ch-act="pick-section">Section</button>` : "";
|
|
7063
|
+
return `
|
|
7064
|
+
${conflict}
|
|
7065
|
+
<div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
|
|
7066
|
+
<span class="slm-sellabel">selected</span></div>
|
|
7067
|
+
<div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
|
|
7068
|
+
<div class="slm-field">
|
|
7069
|
+
<label for="slm-ch-target">Assign to</label>
|
|
7070
|
+
<select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
|
|
7071
|
+
</div>
|
|
7072
|
+
<p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
|
|
7073
|
+
Seats in checkout or already sold are never moved.</p>
|
|
7074
|
+
<div class="slm-ch-row2">
|
|
7075
|
+
<button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
|
|
7076
|
+
<button type="button" class="slm-btn" data-ch-act="review">Review changes</button>
|
|
7077
|
+
</div>
|
|
7078
|
+
<p class="slm-eyebrow" style="margin-top:18px">Select by</p>
|
|
7079
|
+
<div class="slm-ch-row2" style="margin-top:2px">
|
|
7080
|
+
${sectionSelect}
|
|
7081
|
+
<button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
|
|
7082
|
+
<button type="button" class="slm-btn ghost" data-ch-act="seatlist">List \u2328</button>
|
|
7083
|
+
</div>
|
|
7084
|
+
<p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
|
|
7085
|
+
}
|
|
7086
|
+
detailRailHtml(channelId) {
|
|
7087
|
+
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
7088
|
+
if (!channel) return this.listRailHtml();
|
|
7089
|
+
const lifecycle = this.caps.manage ? `
|
|
7090
|
+
<p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
|
|
7091
|
+
<div class="slm-ch-row2" style="margin-top:2px">
|
|
7092
|
+
<button type="button" class="slm-btn ghost" data-ch-act="rename">Rename</button>
|
|
7093
|
+
<button type="button" class="slm-btn ghost" data-ch-act="pause">${channel.state === "paused" ? "Resume" : "Pause"}</button>
|
|
7094
|
+
<button type="button" class="slm-btn ghost" data-ch-act="archive">Archive\u2026</button>
|
|
7095
|
+
</div>
|
|
7096
|
+
<p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
|
|
7097
|
+
const intent = channel.access?.intent ?? "none";
|
|
7098
|
+
const intents = ["none", "internal", "server", "hosted_link"];
|
|
7099
|
+
const selfServiceGap = (intent === "server" || intent === "hosted_link") && !channel.access?.hasActiveGrants;
|
|
7100
|
+
const access = this.caps.manage ? `
|
|
7101
|
+
<p class="slm-eyebrow" style="margin-top:14px">Buyer access</p>
|
|
7102
|
+
<div class="slm-field">
|
|
7103
|
+
<label for="slm-ch-intent">How should buyers reach this channel?</label>
|
|
7104
|
+
<select class="slm-select" id="slm-ch-intent" data-ch-intent>
|
|
7105
|
+
${intents.map((value) => `<option value="${value}"${value === intent ? " selected" : ""}>${esc(accessIntentLabel(value))}</option>`).join("")}
|
|
7106
|
+
</select>
|
|
7107
|
+
</div>
|
|
7108
|
+
<p class="slm-hint">${channel.access?.hasActiveGrants ? "Buyer access is live. Only this audience can buy from the allocation." : "No buyer access is configured yet. The allocation is protected \u2014 it is not available to Public sale."}</p>
|
|
7109
|
+
${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
|
|
7110
|
+
<span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
|
|
7111
|
+
<button type="button" class="slm-btn" data-ch-act="hosted-link" disabled
|
|
7112
|
+
title="Hosted access links ship in the next milestone">Create hosted access link \xB7 Coming soon</button>
|
|
7113
|
+
<div class="slm-ch-row2"><button type="button" class="slm-btn ghost" data-ch-act="server-access" disabled
|
|
7114
|
+
title="Guided server setup ships in the next milestone">Configure server integration \xB7 Coming soon</button></div>
|
|
7115
|
+
<p class="slm-note">Your own server can already mint buyer access sessions for this channel with the server SDK.</p>` : "";
|
|
7116
|
+
return `
|
|
7117
|
+
<p class="slm-eyebrow">
|
|
7118
|
+
<button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
|
|
7119
|
+
</p>
|
|
7120
|
+
<p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
|
|
7121
|
+
<div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
|
|
7122
|
+
${access}
|
|
7123
|
+
${lifecycle}`;
|
|
7124
|
+
}
|
|
7125
|
+
previewRailHtml() {
|
|
7126
|
+
const audienceOptions = [
|
|
7127
|
+
{ id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
7128
|
+
...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
|
|
7129
|
+
];
|
|
7130
|
+
const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
|
|
7131
|
+
const options = audienceOptions.map((entry) => `<option value="${esc(entry.id)}"${entry.id === current ? " selected" : ""}>${esc(entry.name)}</option>`).join("");
|
|
7132
|
+
const unsupported = this.previewSupported === false ? `<div class="slm-ch-alert warn"><span>\u2139</span>
|
|
7133
|
+
<span><b>Preview needs a newer server.</b> Allocation management works normally;
|
|
7134
|
+
the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
|
|
7135
|
+
const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
|
|
7136
|
+
<span><b>This private sale is not available.</b> ${esc((this.previewProjection.unavailable ?? []).map((entry) => `${this.nameOf(entry.channelId) ?? "This channel"} is ${entry.state}`).join("; ") || "The audience cannot buy right now")}.
|
|
7137
|
+
A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
|
|
7138
|
+
const counts = this.previewProjection?.counts;
|
|
7139
|
+
const summary = counts?.eligible != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>${counts.eligible.toLocaleString()} seats are buyable
|
|
7140
|
+
through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
|
|
7141
|
+
const includePublic = current === PUBLIC_CHANNEL_ID ? "" : `
|
|
7142
|
+
<label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
|
|
7143
|
+
<input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
|
|
7144
|
+
Also include Public sale seats in this grant
|
|
7145
|
+
</label>`;
|
|
7146
|
+
return `
|
|
7147
|
+
<p class="slm-eyebrow">Preview buyer access</p>
|
|
7148
|
+
<div class="slm-field">
|
|
7149
|
+
<label for="slm-ch-audience">Audience</label>
|
|
7150
|
+
<select class="slm-select" id="slm-ch-audience" data-ch-audience>${options}</select>
|
|
7151
|
+
</div>
|
|
7152
|
+
${includePublic}
|
|
7153
|
+
<p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
|
|
7154
|
+
approximation. It is read-only: clicks open seat details, and no holds are created.</p>
|
|
7155
|
+
${unsupported}${unavailable}
|
|
7156
|
+
<div class="slm-ch-legend">
|
|
7157
|
+
<div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible & free \u2014 buyable by this audience</div>
|
|
7158
|
+
<div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
|
|
7159
|
+
<div class="r"><span class="sw" style="background:#22a06b"></span> Sold \u2014 same as any buyer sees</div>
|
|
7160
|
+
</div>
|
|
7161
|
+
${summary}`;
|
|
7162
|
+
}
|
|
7163
|
+
paintSelection() {
|
|
7164
|
+
if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
|
|
7165
|
+
}
|
|
7166
|
+
wireRail() {
|
|
7167
|
+
const rail = this.host.rail;
|
|
7168
|
+
rail.querySelectorAll("[data-ch-view]").forEach((button) => {
|
|
7169
|
+
button.addEventListener("click", () => this.setView(button.dataset.chView));
|
|
7170
|
+
});
|
|
7171
|
+
rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
|
|
7172
|
+
button.addEventListener("click", () => {
|
|
7173
|
+
this.detailChannelId = button.dataset.chDetail;
|
|
7174
|
+
this.paintRail();
|
|
7175
|
+
});
|
|
7176
|
+
});
|
|
7177
|
+
const target = rail.querySelector("[data-ch-target]");
|
|
7178
|
+
target?.addEventListener("change", () => {
|
|
7179
|
+
this.targetChannelId = target.value;
|
|
7180
|
+
this.conflict = false;
|
|
7181
|
+
this.paintRail();
|
|
7182
|
+
});
|
|
7183
|
+
const audience = rail.querySelector("[data-ch-audience]");
|
|
7184
|
+
audience?.addEventListener("change", () => {
|
|
7185
|
+
this.previewAudience = [audience.value];
|
|
7186
|
+
void this.loadPreview();
|
|
7187
|
+
});
|
|
7188
|
+
const includePublic = rail.querySelector("[data-ch-includepublic]");
|
|
7189
|
+
includePublic?.addEventListener("change", () => {
|
|
7190
|
+
this.previewIncludePublic = includePublic.checked;
|
|
7191
|
+
void this.loadPreview();
|
|
7192
|
+
});
|
|
7193
|
+
const intent = rail.querySelector("[data-ch-intent]");
|
|
7194
|
+
intent?.addEventListener("change", () => {
|
|
7195
|
+
void this.setAccessIntent(intent.value);
|
|
7196
|
+
});
|
|
7197
|
+
const grab = rail.querySelector(".slm-ch-grab");
|
|
7198
|
+
grab?.addEventListener("click", () => this.cycleDetent());
|
|
7199
|
+
rail.querySelectorAll("[data-ch-act]").forEach((button) => {
|
|
7200
|
+
button.addEventListener("click", () => this.railAction(button.dataset.chAct));
|
|
7201
|
+
});
|
|
7202
|
+
}
|
|
7203
|
+
railAction(action) {
|
|
7204
|
+
switch (action) {
|
|
7205
|
+
case "create":
|
|
7206
|
+
this.openDialog({ kind: "create" });
|
|
7207
|
+
break;
|
|
7208
|
+
case "review":
|
|
7209
|
+
this.openDialog({ kind: "review" });
|
|
7210
|
+
break;
|
|
7211
|
+
case "rename":
|
|
7212
|
+
this.openDialog({ kind: "rename", channelId: this.detailChannelId });
|
|
7213
|
+
break;
|
|
7214
|
+
case "archive":
|
|
7215
|
+
this.openDialog({ kind: "archive", channelId: this.detailChannelId });
|
|
7216
|
+
break;
|
|
7217
|
+
case "seatlist":
|
|
7218
|
+
this.openDialog({ kind: "seatlist" });
|
|
7219
|
+
break;
|
|
7220
|
+
case "pause":
|
|
7221
|
+
void this.togglePause();
|
|
7222
|
+
break;
|
|
7223
|
+
case "discard":
|
|
7224
|
+
this.host.clearSelection();
|
|
7225
|
+
break;
|
|
7226
|
+
case "back":
|
|
7227
|
+
this.detailChannelId = null;
|
|
7228
|
+
this.paintRail();
|
|
7229
|
+
break;
|
|
7230
|
+
case "retry":
|
|
7231
|
+
void this.refresh();
|
|
7232
|
+
break;
|
|
7233
|
+
case "toggle-archived":
|
|
7234
|
+
this.showArchived = !this.showArchived;
|
|
7235
|
+
void this.refresh();
|
|
7236
|
+
break;
|
|
7237
|
+
case "refresh-review":
|
|
7238
|
+
this.conflict = false;
|
|
7239
|
+
void this.refresh().then(() => this.openDialog({ kind: "review" }));
|
|
7240
|
+
break;
|
|
7241
|
+
case "pick-section":
|
|
7242
|
+
this.pickSection();
|
|
7243
|
+
break;
|
|
7244
|
+
case "pick-category":
|
|
7245
|
+
this.pickCategory();
|
|
7246
|
+
break;
|
|
7247
|
+
default:
|
|
7248
|
+
break;
|
|
7249
|
+
}
|
|
7250
|
+
}
|
|
7251
|
+
// ---- selection helpers ----------------------------------------------------
|
|
7252
|
+
pickSection() {
|
|
7253
|
+
const sections = this.host.sections();
|
|
7254
|
+
if (!sections.length) return;
|
|
7255
|
+
this.promptChoice("Select a whole section", sections.map((s) => ({ value: s.id, label: s.label })), (value) => {
|
|
7256
|
+
this.host.selectSection(value);
|
|
7257
|
+
});
|
|
7258
|
+
}
|
|
7259
|
+
pickCategory() {
|
|
7260
|
+
const categories = this.host.categories();
|
|
7261
|
+
if (!categories.length) return;
|
|
7262
|
+
this.promptChoice("Select a whole category", categories.map((c) => ({ value: c.key, label: c.label })), (value) => {
|
|
7263
|
+
this.host.selectByLabels(this.host.labelsInCategory(value));
|
|
7264
|
+
});
|
|
7265
|
+
}
|
|
7266
|
+
/** A tiny modal chooser reusing the dialog primitive (focus trap + Escape). */
|
|
7267
|
+
promptChoice(title, options, onPick) {
|
|
7268
|
+
this.renderScrim(`
|
|
7269
|
+
<h3 id="slm-ch-dlg-title">${esc(title)}</h3>
|
|
7270
|
+
<div class="slm-field">
|
|
7271
|
+
<label for="slm-ch-choice">Choose one</label>
|
|
7272
|
+
<select class="slm-select" id="slm-ch-choice">
|
|
7273
|
+
${options.map((option) => `<option value="${esc(option.value)}">${esc(option.label)}</option>`).join("")}
|
|
7274
|
+
</select>
|
|
7275
|
+
</div>
|
|
7276
|
+
<div class="foot">
|
|
7277
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7278
|
+
<button type="button" class="slm-btn" data-ch-confirm>Select</button>
|
|
7279
|
+
</div>`, (root) => {
|
|
7280
|
+
root.querySelector("[data-ch-confirm]")?.addEventListener("click", () => {
|
|
7281
|
+
const select = root.querySelector("#slm-ch-choice");
|
|
7282
|
+
const value = select?.value;
|
|
7283
|
+
this.closeDialog();
|
|
7284
|
+
if (value) onPick(value);
|
|
7285
|
+
});
|
|
7286
|
+
});
|
|
7287
|
+
}
|
|
7288
|
+
// ---- dialogs --------------------------------------------------------------
|
|
7289
|
+
openDialog(state) {
|
|
7290
|
+
this.dialog = state;
|
|
7291
|
+
this.renderDialog();
|
|
7292
|
+
}
|
|
7293
|
+
renderDialog() {
|
|
7294
|
+
const state = this.dialog;
|
|
7295
|
+
if (!state) return;
|
|
7296
|
+
if (state.kind === "create") this.renderCreateDialog(state);
|
|
7297
|
+
else if (state.kind === "review") this.renderReviewDialog(state);
|
|
7298
|
+
else if (state.kind === "archive") this.renderArchiveDialog(state);
|
|
7299
|
+
else if (state.kind === "rename") this.renderRenameDialog(state);
|
|
7300
|
+
else if (state.kind === "seatlist") this.renderSeatListDialog();
|
|
7301
|
+
}
|
|
7302
|
+
/**
|
|
7303
|
+
* Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
|
|
7304
|
+
* Tab trapped, Escape closes WITHOUT mutating, focus restored on close (§13).
|
|
7305
|
+
*/
|
|
7306
|
+
renderScrim(inner, wire) {
|
|
7307
|
+
const existing = this.scrimEl;
|
|
7308
|
+
if (!existing) this.lastFocus = document.activeElement ?? null;
|
|
7309
|
+
existing?.remove();
|
|
7310
|
+
const scrim = document.createElement("div");
|
|
7311
|
+
scrim.className = "slm-ch-scrim";
|
|
7312
|
+
scrim.innerHTML = `<div class="slm-ch-dialog" role="dialog" aria-modal="true"
|
|
7313
|
+
aria-labelledby="slm-ch-dlg-title" tabindex="-1">${inner}</div>`;
|
|
7314
|
+
this.host.root.appendChild(scrim);
|
|
7315
|
+
this.scrimEl = scrim;
|
|
7316
|
+
const dialog = scrim.firstElementChild;
|
|
7317
|
+
dialog.querySelectorAll("[data-ch-close]").forEach((button) => {
|
|
7318
|
+
button.addEventListener("click", () => this.closeDialog());
|
|
7319
|
+
});
|
|
7320
|
+
scrim.addEventListener("keydown", (event) => {
|
|
7321
|
+
if (event.key === "Escape") {
|
|
7322
|
+
event.stopPropagation();
|
|
7323
|
+
this.closeDialog();
|
|
7324
|
+
return;
|
|
7325
|
+
}
|
|
7326
|
+
if (event.key !== "Tab") return;
|
|
7327
|
+
const focusable = [...dialog.querySelectorAll(
|
|
7328
|
+
'button:not([disabled]),select,input,textarea,a[href],[tabindex]:not([tabindex="-1"])'
|
|
7329
|
+
)];
|
|
7330
|
+
if (!focusable.length) return;
|
|
7331
|
+
const first = focusable[0];
|
|
7332
|
+
const last = focusable[focusable.length - 1];
|
|
7333
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
7334
|
+
event.preventDefault();
|
|
7335
|
+
last.focus();
|
|
7336
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
7337
|
+
event.preventDefault();
|
|
7338
|
+
first.focus();
|
|
7339
|
+
}
|
|
7340
|
+
});
|
|
7341
|
+
wire(dialog);
|
|
7342
|
+
const autofocus = dialog.querySelector("input,select,button");
|
|
7343
|
+
(autofocus ?? dialog).focus();
|
|
7344
|
+
}
|
|
7345
|
+
closeDialog(opts = {}) {
|
|
7346
|
+
this.dialog = null;
|
|
7347
|
+
this.scrimEl?.remove();
|
|
7348
|
+
this.scrimEl = null;
|
|
7349
|
+
if (opts.restoreFocus !== false) this.lastFocus?.focus?.();
|
|
7350
|
+
this.lastFocus = null;
|
|
7351
|
+
}
|
|
7352
|
+
renderCreateDialog(state) {
|
|
7353
|
+
const taken = (this.list?.channels ?? []).map((channel) => channel.marker ?? channel.name[0] ?? "");
|
|
7354
|
+
const suggestion = suggestMarker("", taken);
|
|
7355
|
+
this.renderScrim(`
|
|
7356
|
+
<h3 id="slm-ch-dlg-title">Create channel</h3>
|
|
7357
|
+
<p class="sub">A named allocation only the right audience can buy from. You'll pick the seats next.</p>
|
|
7358
|
+
<div class="slm-field">
|
|
7359
|
+
<label for="slm-ch-name">Name</label>
|
|
7360
|
+
<input class="slm-input" id="slm-ch-name" maxlength="80" />
|
|
7361
|
+
<p class="slm-note">Shown to your team and in reports \u2014 never to buyers.</p>
|
|
7362
|
+
</div>
|
|
7363
|
+
<div class="slm-field">
|
|
7364
|
+
<label>Marker</label>
|
|
7365
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
7366
|
+
<span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
|
|
7367
|
+
<span class="slm-note" style="margin:0">Letter + colour suggested from the name. Buyers never see either.</span>
|
|
7368
|
+
</div>
|
|
7369
|
+
</div>
|
|
7370
|
+
<div class="slm-field">
|
|
7371
|
+
<label for="slm-ch-ref">Reference <span style="text-transform:none;font-weight:500">(optional)</span></label>
|
|
7372
|
+
<input class="slm-input" id="slm-ch-ref" maxlength="120" placeholder="e.g. travel-agency-a" />
|
|
7373
|
+
<p class="slm-note">A stable ID for your own system and webhooks.</p>
|
|
7374
|
+
</div>
|
|
7375
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7376
|
+
<div class="foot">
|
|
7377
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7378
|
+
<button type="button" class="slm-btn ghost" data-ch-create="plain">Create without allocating</button>
|
|
7379
|
+
<button type="button" class="slm-btn" data-ch-create="allocate">Create and allocate seats</button>
|
|
7380
|
+
</div>`, (dialog) => {
|
|
7381
|
+
const name = dialog.querySelector("#slm-ch-name");
|
|
7382
|
+
const marker = dialog.querySelector("[data-ch-marker]");
|
|
7383
|
+
name.addEventListener("input", () => {
|
|
7384
|
+
const next = suggestMarker(name.value, taken);
|
|
7385
|
+
marker.textContent = next.letter;
|
|
7386
|
+
marker.style.background = next.color;
|
|
7387
|
+
});
|
|
7388
|
+
dialog.querySelectorAll("[data-ch-create]").forEach((button) => {
|
|
7389
|
+
button.addEventListener("click", () => {
|
|
7390
|
+
const allocate = button.dataset.chCreate === "allocate";
|
|
7391
|
+
void this.createChannel(
|
|
7392
|
+
name.value,
|
|
7393
|
+
marker.textContent ?? "",
|
|
7394
|
+
marker.style.background,
|
|
7395
|
+
dialog.querySelector("#slm-ch-ref")?.value ?? "",
|
|
7396
|
+
allocate
|
|
7397
|
+
);
|
|
7398
|
+
});
|
|
7399
|
+
});
|
|
7400
|
+
});
|
|
7401
|
+
}
|
|
7402
|
+
async createChannel(name, letter, color, externalRef, allocate) {
|
|
7403
|
+
const trimmed = name.trim();
|
|
7404
|
+
if (!trimmed) {
|
|
7405
|
+
this.showDialogError("Give the channel a name your team will recognise.");
|
|
7406
|
+
return;
|
|
7407
|
+
}
|
|
7408
|
+
try {
|
|
7409
|
+
const res = await this.host.api.createChannel(this.host.eventKey, {
|
|
7410
|
+
name: trimmed,
|
|
7411
|
+
marker: letter || null,
|
|
7412
|
+
color: color || null,
|
|
7413
|
+
externalRef: externalRef.trim() || null
|
|
7414
|
+
});
|
|
7415
|
+
this.closeDialog();
|
|
7416
|
+
await this.refresh();
|
|
7417
|
+
this.targetChannelId = res.channel.id;
|
|
7418
|
+
this.detailChannelId = allocate ? null : res.channel.id;
|
|
7419
|
+
this.announce(`Channel ${trimmed} created with 0 seats allocated.`);
|
|
7420
|
+
this.host.toast(allocate ? `${trimmed} created. Select seats on the map to allocate them.` : `${trimmed} created.`, "ok");
|
|
7421
|
+
this.paintRail();
|
|
7422
|
+
} catch (err) {
|
|
7423
|
+
const code = err instanceof ManageApiError ? err.code : void 0;
|
|
7424
|
+
this.showDialogError(code === "channel_name_taken" ? "That name is already used on this event. Pick another." : "Couldn't create the channel. Try again.");
|
|
7425
|
+
this.host.onError(err);
|
|
7426
|
+
}
|
|
7427
|
+
}
|
|
7428
|
+
showDialogError(message) {
|
|
7429
|
+
const field = this.scrimEl?.querySelector("[data-ch-error]");
|
|
7430
|
+
if (!field) return;
|
|
7431
|
+
field.textContent = message;
|
|
7432
|
+
field.hidden = false;
|
|
7433
|
+
}
|
|
7434
|
+
renderReviewDialog(state) {
|
|
7435
|
+
const { labels, buckets } = this.currentPlan();
|
|
7436
|
+
const authoritative = state.applied;
|
|
7437
|
+
const shown = authoritative ? authoritative.buckets : buckets;
|
|
7438
|
+
const targetName = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
|
|
7439
|
+
const rows = bucketRows(shown, targetName);
|
|
7440
|
+
const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
|
|
7441
|
+
const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
|
|
7442
|
+
const rowsHtml = bucketRowsHtml(rows);
|
|
7443
|
+
const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
|
|
7444
|
+
<button type="button" class="quiet" data-ch-close>Back</button>
|
|
7445
|
+
<button type="button" class="slm-btn" data-ch-apply ${allSkipped || state.busy ? "disabled" : ""}>
|
|
7446
|
+
${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
|
|
7447
|
+
</button>
|
|
7448
|
+
</div>`;
|
|
7449
|
+
const confirmNote = !authoritative && needsMoveConfirmation(buckets) ? `<p class="slm-note">Applying moves inventory out of another private channel. That is the line marked above.</p>` : "";
|
|
7450
|
+
const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
|
|
7451
|
+
every seat is in a buyer's checkout, already sold, or already in ${esc(targetName)}.</span></div>` : "";
|
|
7452
|
+
this.renderScrim(`
|
|
7453
|
+
<h3 id="slm-ch-dlg-title">${authoritative ? `Moved ${authoritative.applied.toLocaleString()} seat${authoritative.applied === 1 ? "" : "s"} to ${esc(targetName)}` : `Move ${labels.length.toLocaleString()} selected seat${labels.length === 1 ? "" : "s"} to ${esc(targetName)}`}</h3>
|
|
7454
|
+
<p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
|
|
7455
|
+
${skippedNote}
|
|
7456
|
+
${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
|
|
7457
|
+
${confirmNote}
|
|
7458
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7459
|
+
${foot}`, (dialog) => {
|
|
7460
|
+
dialog.querySelector("[data-ch-apply]")?.addEventListener("click", () => void this.apply());
|
|
7461
|
+
});
|
|
7462
|
+
if (authoritative) {
|
|
7463
|
+
this.announce(`Applied ${authoritative.applied} change${authoritative.applied === 1 ? "" : "s"} to ${targetName}.`);
|
|
7464
|
+
}
|
|
7465
|
+
}
|
|
7466
|
+
/**
|
|
7467
|
+
* Apply. On success the review sheet re-renders with the AUTHORITATIVE server
|
|
7468
|
+
* buckets and the staged bar morphs to a ✓ for 1.2s. On a stale version the
|
|
7469
|
+
* server mutated nothing: keep the selection, shake the bar once, and offer
|
|
7470
|
+
* exactly one action — Refresh and review.
|
|
7471
|
+
*/
|
|
7472
|
+
async apply() {
|
|
7473
|
+
if (!this.dialog || !this.caps.manage) return;
|
|
7474
|
+
const { labels } = this.currentPlan();
|
|
7475
|
+
if (!labels.length) return;
|
|
7476
|
+
this.dialog = { ...this.dialog, busy: true, error: null };
|
|
7477
|
+
this.renderDialog();
|
|
7478
|
+
try {
|
|
7479
|
+
const result = await this.host.api.applyChannelAssignment(this.host.eventKey, {
|
|
7480
|
+
targetChannelId: this.targetChannelId || null,
|
|
7481
|
+
labels,
|
|
7482
|
+
assignmentVersion: this.assignmentVersion
|
|
7483
|
+
});
|
|
7484
|
+
this.assignmentVersion = result.assignmentVersion;
|
|
7485
|
+
this.conflict = false;
|
|
7486
|
+
this.dialog = { kind: "review", applied: result };
|
|
7487
|
+
await this.refresh({ quiet: true });
|
|
7488
|
+
this.renderDialog();
|
|
7489
|
+
this.showApplied(result);
|
|
7490
|
+
this.host.clearSelection();
|
|
7491
|
+
} catch (err) {
|
|
7492
|
+
const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
|
|
7493
|
+
if (conflict) {
|
|
7494
|
+
this.conflict = true;
|
|
7495
|
+
this.closeDialog();
|
|
7496
|
+
this.shakeStaged();
|
|
7497
|
+
this.paintRail();
|
|
7498
|
+
this.announce("Assignments changed while you were editing. Nothing was applied and your selection is kept.");
|
|
7499
|
+
return;
|
|
7500
|
+
}
|
|
7501
|
+
if (err instanceof ManageApiError && err.status === 403) {
|
|
7502
|
+
this.caps = { view: this.caps.view, manage: false };
|
|
7503
|
+
this.closeDialog();
|
|
7504
|
+
this.paintRail();
|
|
7505
|
+
this.host.toast("Changing channels needs channel-management permission.", "err");
|
|
7506
|
+
return;
|
|
7507
|
+
}
|
|
7508
|
+
this.dialog = { kind: "review", busy: false, error: "Couldn't apply those changes. Try again." };
|
|
7509
|
+
this.renderDialog();
|
|
7510
|
+
this.host.onError(err);
|
|
7511
|
+
}
|
|
7512
|
+
}
|
|
7513
|
+
showApplied(result) {
|
|
7514
|
+
this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
|
|
7515
|
+
<span>Applied <b>${result.applied.toLocaleString()}</b> change${result.applied === 1 ? "" : "s"}</span>
|
|
7516
|
+
<span class="grow"></span>`, "done");
|
|
7517
|
+
if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
|
|
7518
|
+
this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 1200);
|
|
7519
|
+
}
|
|
7520
|
+
shakeStaged() {
|
|
7521
|
+
const bar = this.stagedEl;
|
|
7522
|
+
if (!bar || !bar.classList.contains("on")) return;
|
|
7523
|
+
bar.classList.remove("shake");
|
|
7524
|
+
void bar.offsetWidth;
|
|
7525
|
+
bar.classList.add("shake");
|
|
7526
|
+
}
|
|
7527
|
+
renderRenameDialog(state) {
|
|
7528
|
+
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
7529
|
+
if (!channel) {
|
|
7530
|
+
this.closeDialog();
|
|
7531
|
+
return;
|
|
7532
|
+
}
|
|
7533
|
+
this.renderScrim(`
|
|
7534
|
+
<h3 id="slm-ch-dlg-title">Rename ${esc(channel.name)}</h3>
|
|
7535
|
+
<p class="sub">Only your team and your reports see this name.</p>
|
|
7536
|
+
<div class="slm-field">
|
|
7537
|
+
<label for="slm-ch-newname">Name</label>
|
|
7538
|
+
<input class="slm-input" id="slm-ch-newname" maxlength="80" value="${esc(channel.name)}" />
|
|
7539
|
+
</div>
|
|
7540
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7541
|
+
<div class="foot">
|
|
7542
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7543
|
+
<button type="button" class="slm-btn" data-ch-rename>Save name</button>
|
|
7544
|
+
</div>`, (dialog) => {
|
|
7545
|
+
dialog.querySelector("[data-ch-rename]")?.addEventListener("click", () => {
|
|
7546
|
+
const value = dialog.querySelector("#slm-ch-newname")?.value ?? "";
|
|
7547
|
+
if (!value.trim()) {
|
|
7548
|
+
this.showDialogError("A channel needs a name.");
|
|
7549
|
+
return;
|
|
7550
|
+
}
|
|
7551
|
+
void this.host.api.renameChannel(this.host.eventKey, channel.id, value.trim()).then(() => {
|
|
7552
|
+
this.closeDialog();
|
|
7553
|
+
return this.refresh();
|
|
7554
|
+
}).catch((err) => {
|
|
7555
|
+
this.showDialogError(err instanceof ManageApiError && err.code === "channel_name_taken" ? "That name is already used on this event." : "Couldn't rename the channel.");
|
|
7556
|
+
this.host.onError(err);
|
|
7557
|
+
});
|
|
7558
|
+
});
|
|
7559
|
+
});
|
|
7560
|
+
}
|
|
7561
|
+
async setAccessIntent(accessIntent) {
|
|
7562
|
+
const channelId = this.detailChannelId;
|
|
7563
|
+
if (!channelId || !this.caps.manage) return;
|
|
7564
|
+
try {
|
|
7565
|
+
await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, accessIntent);
|
|
7566
|
+
await this.refresh();
|
|
7567
|
+
} catch (err) {
|
|
7568
|
+
this.host.toast("Couldn't save how buyers reach this channel.", "err");
|
|
7569
|
+
this.host.onError(err);
|
|
7570
|
+
}
|
|
7571
|
+
}
|
|
7572
|
+
async togglePause() {
|
|
7573
|
+
const channel = this.list?.channels.find((item) => item.id === this.detailChannelId);
|
|
7574
|
+
if (!channel) return;
|
|
7575
|
+
const paused = channel.state !== "paused";
|
|
7576
|
+
try {
|
|
7577
|
+
await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);
|
|
7578
|
+
await this.refresh();
|
|
7579
|
+
this.host.toast(paused ? `${channel.name} paused. Existing checkouts can finish; no new buyer access is issued.` : `${channel.name} resumed.`, "ok");
|
|
7580
|
+
} catch (err) {
|
|
7581
|
+
this.host.toast("Couldn't change that channel.", "err");
|
|
7582
|
+
this.host.onError(err);
|
|
7583
|
+
}
|
|
7584
|
+
}
|
|
7585
|
+
renderArchiveDialog(state) {
|
|
7586
|
+
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
7587
|
+
if (!channel) {
|
|
7588
|
+
this.closeDialog();
|
|
7589
|
+
return;
|
|
7590
|
+
}
|
|
7591
|
+
const blocked = state.archiveBlocked ?? null;
|
|
7592
|
+
const heads_up = !blocked && channel.counts.held > 0;
|
|
7593
|
+
const destinations = this.assignableChannels().filter((entry) => entry.id !== channel.id);
|
|
7594
|
+
const blockedAlert = blocked ? `<div class="slm-ch-alert warn" role="alert"><span>\u23F3</span>
|
|
7595
|
+
<span><b>${(blocked.heldUnits ?? blocked.activeHolds ?? 0).toLocaleString()} seats are in a buyer's checkout right now.</b>
|
|
7596
|
+
Archive is unavailable while seats are held \u2014 try again ${esc(retryAfterCopy(blocked))}.</span></div>` : heads_up ? `<div class="slm-ch-alert warn"><span>\u23F3</span>
|
|
7597
|
+
<span>${channel.counts.held.toLocaleString()} seats are in a buyer's checkout right now.
|
|
7598
|
+
Archive is refused while any seat is held \u2014 you can try, and we'll tell you when to come back.</span></div>` : "";
|
|
7599
|
+
this.renderScrim(`
|
|
7600
|
+
<h3 id="slm-ch-dlg-title">Archive ${esc(channel.name)}</h3>
|
|
7601
|
+
<p class="sub">The channel closes for good. Its seats move to a destination you choose;
|
|
7602
|
+
sales history keeps its attribution.</p>
|
|
7603
|
+
${blockedAlert}
|
|
7604
|
+
<div class="slm-field">
|
|
7605
|
+
<label for="slm-ch-dest">Move the ${channel.counts.free.toLocaleString()} remaining free seats to</label>
|
|
7606
|
+
<select class="slm-select" id="slm-ch-dest">
|
|
7607
|
+
${destinations.map((entry) => `<option value="${esc(entry.id)}">${esc(entry.name)}</option>`).join("")}
|
|
7608
|
+
</select>
|
|
7609
|
+
</div>
|
|
7610
|
+
<p class="slm-note">${channel.counts.booked.toLocaleString()} sold seats keep "${esc(channel.name)}" on their sale
|
|
7611
|
+
record. If one is cancelled later it returns to the destination above. Any buyer access for this channel
|
|
7612
|
+
stops working.</p>
|
|
7613
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7614
|
+
<div class="foot">
|
|
7615
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7616
|
+
<button type="button" class="slm-btn danger" data-ch-archive ${blocked ? "disabled" : ""}>Archive channel</button>
|
|
7617
|
+
</div>`, (dialog) => {
|
|
7618
|
+
dialog.querySelector("[data-ch-archive]")?.addEventListener("click", () => {
|
|
7619
|
+
const destination = dialog.querySelector("#slm-ch-dest")?.value ?? "";
|
|
7620
|
+
void this.archive(channel.id, destination || null);
|
|
7621
|
+
});
|
|
7622
|
+
});
|
|
7623
|
+
}
|
|
7624
|
+
async archive(channelId, destination) {
|
|
7625
|
+
try {
|
|
7626
|
+
await this.host.api.archiveChannel(this.host.eventKey, channelId, destination);
|
|
7627
|
+
this.closeDialog();
|
|
7628
|
+
this.detailChannelId = null;
|
|
7629
|
+
await this.refresh();
|
|
7630
|
+
this.host.toast("Channel archived. Its remaining seats moved to the destination you chose.", "ok");
|
|
7631
|
+
} catch (err) {
|
|
7632
|
+
if (err instanceof ManageApiError && err.status === 409 && err.code === "channel_archive_blocked_by_holds") {
|
|
7633
|
+
this.dialog = {
|
|
7634
|
+
kind: "archive",
|
|
7635
|
+
channelId,
|
|
7636
|
+
archiveBlocked: err.details ?? {}
|
|
7637
|
+
};
|
|
7638
|
+
this.renderDialog();
|
|
7639
|
+
return;
|
|
7640
|
+
}
|
|
7641
|
+
this.showDialogError("Couldn't archive that channel. Try again.");
|
|
7642
|
+
this.host.onError(err);
|
|
7643
|
+
}
|
|
7644
|
+
}
|
|
7645
|
+
/**
|
|
7646
|
+
* The synchronized inventory list (§13): the keyboard and screen-reader
|
|
7647
|
+
* equivalent of canvas click / marquee / brush, grouped by section with
|
|
7648
|
+
* per-section select actions.
|
|
7649
|
+
*/
|
|
7650
|
+
renderSeatListDialog() {
|
|
7651
|
+
const selected = new Set(this.host.selectionLabels());
|
|
7652
|
+
const groups = /* @__PURE__ */ new Map();
|
|
7653
|
+
let total = 0;
|
|
7654
|
+
for (const seat of this.host.seats()) {
|
|
7655
|
+
total += 1;
|
|
7656
|
+
if (total > this.seatListLimit) break;
|
|
7657
|
+
const section = this.host.sectionOfLabel(seat.label);
|
|
7658
|
+
const key = section?.id ?? "";
|
|
7659
|
+
const group = groups.get(key) ?? { label: section?.label ?? "Other seats", seats: [] };
|
|
7660
|
+
group.seats.push({ label: seat.label, status: this.host.statusOf(seat.label) ?? "free" });
|
|
7661
|
+
groups.set(key, group);
|
|
7662
|
+
}
|
|
7663
|
+
const body = [...groups.entries()].map(([id, group]) => `
|
|
7664
|
+
<div class="slm-ch-seatgroup">
|
|
7665
|
+
<span>${esc(group.label)}</span>
|
|
7666
|
+
${id ? `<button type="button" data-ch-section="${esc(id)}">Select section</button>` : ""}
|
|
7667
|
+
</div>
|
|
7668
|
+
${group.seats.map((seat) => {
|
|
7669
|
+
const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
|
|
7670
|
+
const channelName = this.nameOf(channelId) ?? PUBLIC_CHANNEL_NAME;
|
|
7671
|
+
return `<button type="button" class="slm-ch-seatitem" role="checkbox"
|
|
7672
|
+
aria-checked="${selected.has(seat.label)}" data-ch-seat="${esc(seat.label)}">
|
|
7673
|
+
<span class="box" aria-hidden="true">\u2713</span>
|
|
7674
|
+
<span>${esc(seat.label)}</span>
|
|
7675
|
+
<span class="meta">${esc(channelName)} \xB7 ${esc(seat.status)}</span>
|
|
7676
|
+
</button>`;
|
|
7677
|
+
}).join("")}`).join("");
|
|
7678
|
+
this.renderScrim(`
|
|
7679
|
+
<h3 id="slm-ch-dlg-title">Seat list</h3>
|
|
7680
|
+
<p class="sub">The same selection as the map, with checkboxes. Space or Enter toggles a seat.</p>
|
|
7681
|
+
<div class="slm-ch-seatlist">${body || '<div class="slm-empty">No seats on this chart.</div>'}</div>
|
|
7682
|
+
${total > this.seatListLimit ? `<div class="foot"><button type="button" class="slm-btn ghost" data-ch-more>Show more seats</button></div>` : ""}
|
|
7683
|
+
<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>`, (dialog) => {
|
|
7684
|
+
dialog.querySelectorAll("[data-ch-seat]").forEach((button) => {
|
|
7685
|
+
button.addEventListener("click", () => {
|
|
7686
|
+
const label = button.dataset.chSeat;
|
|
7687
|
+
const next = new Set(this.host.selectionLabels());
|
|
7688
|
+
if (next.has(label)) next.delete(label);
|
|
7689
|
+
else next.add(label);
|
|
7690
|
+
this.host.clearSelection();
|
|
7691
|
+
if (next.size) this.host.selectByLabels([...next]);
|
|
7692
|
+
this.renderSeatListDialog();
|
|
7693
|
+
});
|
|
7694
|
+
});
|
|
7695
|
+
dialog.querySelectorAll("[data-ch-section]").forEach((button) => {
|
|
7696
|
+
button.addEventListener("click", () => {
|
|
7697
|
+
this.host.selectSection(button.dataset.chSection);
|
|
7698
|
+
this.renderSeatListDialog();
|
|
7699
|
+
});
|
|
7700
|
+
});
|
|
7701
|
+
dialog.querySelector("[data-ch-more]")?.addEventListener("click", () => {
|
|
7702
|
+
this.seatListLimit += SEAT_LIST_PAGE;
|
|
7703
|
+
this.renderSeatListDialog();
|
|
7704
|
+
});
|
|
7705
|
+
});
|
|
7706
|
+
}
|
|
7707
|
+
// ---- compact detents ------------------------------------------------------
|
|
7708
|
+
applySheetClasses() {
|
|
7709
|
+
const root = this.host.root;
|
|
7710
|
+
const compact = this.host.isCompact();
|
|
7711
|
+
root.classList.toggle("ch-sheet", compact && this.active);
|
|
7712
|
+
for (const detent of ["collapsed", "medium", "full"]) {
|
|
7713
|
+
root.classList.toggle(`detent-${detent}`, compact && this.active && this.detent === detent);
|
|
7714
|
+
}
|
|
7715
|
+
this.host.setMapInert(compact && this.active && this.detent === "full");
|
|
7716
|
+
}
|
|
7717
|
+
cycleDetent() {
|
|
7718
|
+
const order = ["collapsed", "medium", "full"];
|
|
7719
|
+
this.detent = order[(order.indexOf(this.detent) + 1) % order.length];
|
|
7720
|
+
this.applySheetClasses();
|
|
7721
|
+
}
|
|
7722
|
+
/** Back/Close from the full detent returns to the previous one and keeps the
|
|
7723
|
+
* selection — losing a hard-won selection to a Back press is unforgivable. */
|
|
7724
|
+
handleBack() {
|
|
7725
|
+
if (this.scrimEl) {
|
|
7726
|
+
this.closeDialog();
|
|
7727
|
+
return true;
|
|
7728
|
+
}
|
|
7729
|
+
if (this.host.isCompact() && this.detent === "full") {
|
|
7730
|
+
this.detent = "medium";
|
|
7731
|
+
this.applySheetClasses();
|
|
7732
|
+
return true;
|
|
7733
|
+
}
|
|
7734
|
+
return false;
|
|
7735
|
+
}
|
|
7736
|
+
};
|
|
7737
|
+
|
|
5156
7738
|
// src/SeatManager.ts
|
|
5157
7739
|
function availabilityModeOf(rule) {
|
|
5158
7740
|
return rule ? rule.mode : "open";
|
|
@@ -5409,7 +7991,7 @@ var CSS2 = `
|
|
|
5409
7991
|
.slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
|
|
5410
7992
|
.slm-liveevent,.slm-sectionrow{transition:none!important}
|
|
5411
7993
|
}
|
|
5412
|
-
`;
|
|
7994
|
+
${CHANNELS_CSS}`;
|
|
5413
7995
|
function injectStyle() {
|
|
5414
7996
|
if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
|
|
5415
7997
|
const el = document.createElement("style");
|
|
@@ -5446,7 +8028,7 @@ function fmtMoney(amount, currency) {
|
|
|
5446
8028
|
return `${currency} ${Math.round(amount).toLocaleString()}`;
|
|
5447
8029
|
}
|
|
5448
8030
|
}
|
|
5449
|
-
function
|
|
8031
|
+
function esc2(value) {
|
|
5450
8032
|
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
5451
8033
|
}
|
|
5452
8034
|
var SeatManager = class {
|
|
@@ -5501,6 +8083,10 @@ var SeatManager = class {
|
|
|
5501
8083
|
this.blockedSection = "";
|
|
5502
8084
|
this.blockedResultLimit = 100;
|
|
5503
8085
|
this.unblockAllConfirmTimer = null;
|
|
8086
|
+
// Sales channels (M6b). The mode object is built only once the token is known
|
|
8087
|
+
// to carry `event:channels:view`; until then there is no pill and no rail.
|
|
8088
|
+
this.channels = null;
|
|
8089
|
+
this.channelCaps = { view: false, manage: false };
|
|
5504
8090
|
this.onFullscreenChange = () => {
|
|
5505
8091
|
this.paintFullscreenButton();
|
|
5506
8092
|
this.updateContainerLayout();
|
|
@@ -5515,8 +8101,13 @@ var SeatManager = class {
|
|
|
5515
8101
|
else if (key === "i") this.setMode("inspect");
|
|
5516
8102
|
else if (key === "b") this.setMode("block");
|
|
5517
8103
|
else if (key === "s") this.setMode("sections");
|
|
5518
|
-
else if (key === "
|
|
5519
|
-
|
|
8104
|
+
else if (key === "c") {
|
|
8105
|
+
if (!this.channels) return;
|
|
8106
|
+
this.setMode("channels");
|
|
8107
|
+
} else if (key === "f") this.toggleFullscreen();
|
|
8108
|
+
else if (key === "escape") {
|
|
8109
|
+
if (!this.channels?.handleBack()) return;
|
|
8110
|
+
} else return;
|
|
5520
8111
|
event.preventDefault();
|
|
5521
8112
|
};
|
|
5522
8113
|
this.onRailClick = (event) => {
|
|
@@ -5567,6 +8158,7 @@ var SeatManager = class {
|
|
|
5567
8158
|
this.connect();
|
|
5568
8159
|
this.startFeedClock();
|
|
5569
8160
|
this.ready = true;
|
|
8161
|
+
await this.resolveChannelCapabilities();
|
|
5570
8162
|
this.setMode(this.mode);
|
|
5571
8163
|
this.scheduleTokenRefresh();
|
|
5572
8164
|
this.opts.onReady?.();
|
|
@@ -5577,16 +8169,119 @@ var SeatManager = class {
|
|
|
5577
8169
|
}
|
|
5578
8170
|
// ---- public API -----------------------------------------------------------
|
|
5579
8171
|
setMode(mode) {
|
|
8172
|
+
if (mode === "channels" && !this.channels) mode = "view";
|
|
5580
8173
|
const changed = mode !== this.mode;
|
|
8174
|
+
const wasChannels = this.mode === "channels";
|
|
5581
8175
|
this.mode = mode;
|
|
5582
8176
|
if (!this.renderer && this.doc) this.buildRenderer();
|
|
5583
8177
|
else this.updateRendererInteraction();
|
|
5584
8178
|
if (changed) this.renderer?.clearSelection();
|
|
8179
|
+
if (wasChannels && mode !== "channels") this.channels?.leave();
|
|
5585
8180
|
this.paintModeTabs();
|
|
5586
8181
|
this.paintRail();
|
|
5587
8182
|
this.applySectionCanvasTreatment();
|
|
8183
|
+
if (mode === "channels") this.channels?.enter();
|
|
5588
8184
|
if (changed) this.opts.onModeChange?.(mode);
|
|
5589
8185
|
}
|
|
8186
|
+
/**
|
|
8187
|
+
* Decide what this token may do with sales channels.
|
|
8188
|
+
*
|
|
8189
|
+
* Declared capabilities win — a host that mints an `mse_…` grant knows exactly
|
|
8190
|
+
* what it asked for. Otherwise a tenant secret (`sk_…`) is org authority the
|
|
8191
|
+
* worker never narrows, so it is fully capable; and a delegated token with no
|
|
8192
|
+
* declaration is probed for read access and then treated as READ-ONLY, because
|
|
8193
|
+
* "we could not tell" must never render mutation controls.
|
|
8194
|
+
*/
|
|
8195
|
+
async resolveChannelCapabilities() {
|
|
8196
|
+
const declared = this.opts.capabilities;
|
|
8197
|
+
if (declared) {
|
|
8198
|
+
const set = new Set(declared);
|
|
8199
|
+
this.channelCaps = {
|
|
8200
|
+
view: set.has("event:channels:view"),
|
|
8201
|
+
manage: set.has("event:channels:view") && set.has("event:channels:manage")
|
|
8202
|
+
};
|
|
8203
|
+
} else if (/^sk_/.test(this.opts.token)) {
|
|
8204
|
+
this.channelCaps = { view: true, manage: true };
|
|
8205
|
+
} else {
|
|
8206
|
+
this.channelCaps = { view: false, manage: false };
|
|
8207
|
+
}
|
|
8208
|
+
if (!this.channelCaps.view && !declared && !/^sk_/.test(this.opts.token)) {
|
|
8209
|
+
try {
|
|
8210
|
+
await this.api.channels(this.key);
|
|
8211
|
+
this.channelCaps = { view: true, manage: false };
|
|
8212
|
+
} catch {
|
|
8213
|
+
this.channelCaps = { view: false, manage: false };
|
|
8214
|
+
}
|
|
8215
|
+
}
|
|
8216
|
+
if (!this.channelCaps.view) {
|
|
8217
|
+
this.channels?.destroy();
|
|
8218
|
+
this.channels = null;
|
|
8219
|
+
this.paintModeTabs();
|
|
8220
|
+
return;
|
|
8221
|
+
}
|
|
8222
|
+
if (this.channels) {
|
|
8223
|
+
this.channels.setCapabilities(this.channelCaps);
|
|
8224
|
+
} else {
|
|
8225
|
+
this.channels = new ChannelsMode(this.buildChannelsHost(), this.channelCaps);
|
|
8226
|
+
this.channels.onInteractionChange = () => this.updateRendererInteraction();
|
|
8227
|
+
}
|
|
8228
|
+
this.paintModeTabs();
|
|
8229
|
+
}
|
|
8230
|
+
/** The adapter between the cockpit's internals and Channels mode. */
|
|
8231
|
+
buildChannelsHost() {
|
|
8232
|
+
return {
|
|
8233
|
+
eventKey: this.key,
|
|
8234
|
+
api: this.api,
|
|
8235
|
+
rail: this.els.rail,
|
|
8236
|
+
mapLayer: this.root.querySelector(".slm-map"),
|
|
8237
|
+
root: this.root,
|
|
8238
|
+
seats: () => [...this.labelToSeat.values()].map((seat) => ({
|
|
8239
|
+
id: seat.id,
|
|
8240
|
+
label: seat.label,
|
|
8241
|
+
x: seat.x,
|
|
8242
|
+
y: seat.y
|
|
8243
|
+
})),
|
|
8244
|
+
statusOf: (label) => this.status.get(label) ?? (this.labelToSeat.has(label) ? "free" : void 0),
|
|
8245
|
+
selectionLabels: () => this.selectionLabels(),
|
|
8246
|
+
selectByLabels: (labels) => {
|
|
8247
|
+
this.selectByLabels(labels);
|
|
8248
|
+
},
|
|
8249
|
+
clearSelection: () => this.clearSelection(),
|
|
8250
|
+
selectSection: (sectionId) => {
|
|
8251
|
+
this.selectSection(sectionId);
|
|
8252
|
+
},
|
|
8253
|
+
sections: () => this.sectionOptions,
|
|
8254
|
+
categories: () => (this.doc?.categories ?? []).map((category) => ({
|
|
8255
|
+
key: category.key,
|
|
8256
|
+
label: category.label ?? category.key,
|
|
8257
|
+
color: category.color
|
|
8258
|
+
})),
|
|
8259
|
+
labelsInCategory: (key) => [...this.labelToSeat.entries()].filter(([, seat]) => seat.categoryKey === key).map(([label]) => label),
|
|
8260
|
+
sectionOfLabel: (label) => {
|
|
8261
|
+
const seat = this.labelToSeat.get(label);
|
|
8262
|
+
if (!seat) return null;
|
|
8263
|
+
const id = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
|
|
8264
|
+
return { id, label: this.sectionLabelById.get(id) ?? "Other seats" };
|
|
8265
|
+
},
|
|
8266
|
+
worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
|
|
8267
|
+
seatPixelSize: () => this.seatPixelSize(),
|
|
8268
|
+
isCompact: () => !!this.root?.classList.contains("compact"),
|
|
8269
|
+
setMapInert: (inert) => {
|
|
8270
|
+
this.mapHost.toggleAttribute("inert", inert);
|
|
8271
|
+
this.mapHost.setAttribute("aria-hidden", String(inert));
|
|
8272
|
+
},
|
|
8273
|
+
toast: (message, kind) => this.toast(message, kind),
|
|
8274
|
+
onError: (err) => this.opts.onError?.(err)
|
|
8275
|
+
};
|
|
8276
|
+
}
|
|
8277
|
+
/** Approximate on-screen seat size, for the channel overlay's marks. Derived
|
|
8278
|
+
* from the live camera so the overlay tracks zoom without a renderer hook. */
|
|
8279
|
+
seatPixelSize() {
|
|
8280
|
+
const rect = this.renderer?.getVisibleWorldRect?.();
|
|
8281
|
+
const width = this.mapHost?.clientWidth ?? 0;
|
|
8282
|
+
if (!rect?.width || !width) return 6;
|
|
8283
|
+
return Math.max(3, Math.min(24, width / rect.width * 14));
|
|
8284
|
+
}
|
|
5590
8285
|
/** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
|
|
5591
8286
|
setHeatOverlay(enabled) {
|
|
5592
8287
|
this.heatEnabled = enabled;
|
|
@@ -5626,14 +8321,15 @@ var SeatManager = class {
|
|
|
5626
8321
|
return typeof document !== "undefined" && document.fullscreenElement === this.root;
|
|
5627
8322
|
}
|
|
5628
8323
|
toggleFullscreen() {
|
|
5629
|
-
const
|
|
5630
|
-
void
|
|
8324
|
+
const request = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
|
|
8325
|
+
void request.catch((err) => this.opts.onError?.(err));
|
|
5631
8326
|
}
|
|
5632
8327
|
/** Rotate the delegated credential without rebuilding DOM, canvas or socket. */
|
|
5633
8328
|
setToken(token, expiresAt) {
|
|
5634
8329
|
this.api.setToken(token);
|
|
5635
8330
|
this.tokenExpiresAt = expiresAt ?? null;
|
|
5636
8331
|
this.scheduleTokenRefresh();
|
|
8332
|
+
if (this.ready) void this.resolveChannelCapabilities();
|
|
5637
8333
|
}
|
|
5638
8334
|
scheduleTokenRefresh() {
|
|
5639
8335
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
@@ -5792,6 +8488,8 @@ var SeatManager = class {
|
|
|
5792
8488
|
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
5793
8489
|
if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
|
|
5794
8490
|
if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
|
|
8491
|
+
this.channels?.destroy();
|
|
8492
|
+
this.channels = null;
|
|
5795
8493
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
5796
8494
|
this.layoutObserver?.disconnect();
|
|
5797
8495
|
this.layoutObserver = null;
|
|
@@ -5812,32 +8510,51 @@ var SeatManager = class {
|
|
|
5812
8510
|
// ---- renderer lifecycle ---------------------------------------------------
|
|
5813
8511
|
buildRenderer() {
|
|
5814
8512
|
if (!this.doc) return;
|
|
5815
|
-
const
|
|
5816
|
-
const inspect = this.mode === "inspect";
|
|
8513
|
+
const bulk = this.isBulkSelectMode();
|
|
5817
8514
|
this.renderer = new SeatmapRenderer(this.mapHost, {
|
|
5818
8515
|
manageMode: true,
|
|
5819
|
-
marqueeSelect:
|
|
8516
|
+
marqueeSelect: bulk,
|
|
5820
8517
|
maxSelection: 1e6,
|
|
5821
|
-
selectableStatuses:
|
|
8518
|
+
selectableStatuses: this.selectableStatuses(),
|
|
5822
8519
|
currency: this.currency,
|
|
5823
8520
|
onSelect: (seat) => this.handleSeatSelect(seat),
|
|
5824
8521
|
onDeselect: () => this.syncSelection(),
|
|
5825
8522
|
onMarquee: () => this.syncSelection(),
|
|
5826
|
-
onViewChange: () =>
|
|
8523
|
+
onViewChange: () => {
|
|
8524
|
+
this.updateZoomHint();
|
|
8525
|
+
this.channels?.handleViewChange();
|
|
8526
|
+
}
|
|
5827
8527
|
});
|
|
5828
8528
|
this.renderer.setChart(this.doc);
|
|
5829
8529
|
this.repaintAll();
|
|
5830
8530
|
this.applyHeatOverlay();
|
|
5831
8531
|
this.updateZoomHint();
|
|
5832
8532
|
}
|
|
8533
|
+
/** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
|
|
8534
|
+
* section. The two differ only in WHICH statuses they may act on. */
|
|
8535
|
+
isBulkSelectMode() {
|
|
8536
|
+
return this.mode === "block" || this.mode === "channels" && this.channels?.canSelect() === true;
|
|
8537
|
+
}
|
|
8538
|
+
/**
|
|
8539
|
+
* Block never touches held or booked inventory, so it cannot select it.
|
|
8540
|
+
* Channels must be able to select it — the Review sheet's honesty depends on
|
|
8541
|
+
* counting the held and sold units inside a marquee and saying they will not
|
|
8542
|
+
* move, rather than silently omitting them from the selection.
|
|
8543
|
+
*/
|
|
8544
|
+
selectableStatuses() {
|
|
8545
|
+
if (this.mode === "block") return ["free", "not_for_sale"];
|
|
8546
|
+
if (this.mode === "inspect" || this.isBulkSelectMode()) {
|
|
8547
|
+
return ["free", "held", "booked", "not_for_sale"];
|
|
8548
|
+
}
|
|
8549
|
+
return [];
|
|
8550
|
+
}
|
|
5833
8551
|
updateRendererInteraction() {
|
|
5834
|
-
const
|
|
5835
|
-
const inspect = this.mode === "inspect";
|
|
8552
|
+
const bulk = this.isBulkSelectMode();
|
|
5836
8553
|
this.renderer?.setManageInteraction({
|
|
5837
8554
|
manageMode: true,
|
|
5838
|
-
marqueeSelect:
|
|
8555
|
+
marqueeSelect: bulk,
|
|
5839
8556
|
maxSelection: 1e6,
|
|
5840
|
-
selectableStatuses:
|
|
8557
|
+
selectableStatuses: this.selectableStatuses()
|
|
5841
8558
|
});
|
|
5842
8559
|
this.updateZoomHint();
|
|
5843
8560
|
}
|
|
@@ -6089,7 +8806,7 @@ var SeatManager = class {
|
|
|
6089
8806
|
const place = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : activity.label;
|
|
6090
8807
|
const noun = activity.count === 1 ? "seat" : "seats";
|
|
6091
8808
|
element.innerHTML = `<span class="slm-liveeventdot" style="background:${this.activityColor(activity.status)}"></span>
|
|
6092
|
-
<span class="slm-liveeventcopy">${
|
|
8809
|
+
<span class="slm-liveeventcopy">${esc2(place)} \xB7 ${activity.count.toLocaleString()} ${noun} ${esc2(activity.verb)}</span>
|
|
6093
8810
|
<span class="slm-liveeventhint">Live</span>`;
|
|
6094
8811
|
element.classList.add("on");
|
|
6095
8812
|
if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
|
|
@@ -6109,10 +8826,10 @@ var SeatManager = class {
|
|
|
6109
8826
|
this.recomputeTallies();
|
|
6110
8827
|
}
|
|
6111
8828
|
async refreshControlRoom() {
|
|
6112
|
-
const
|
|
8829
|
+
const request = ++this.revenueRequest;
|
|
6113
8830
|
try {
|
|
6114
8831
|
const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
|
|
6115
|
-
if (
|
|
8832
|
+
if (request === this.revenueRequest) {
|
|
6116
8833
|
this.controlRoomSnapshot = snapshot;
|
|
6117
8834
|
this.lastSyncedAt = Date.now();
|
|
6118
8835
|
this.authoritativeGrossRevenue = snapshot.revenue.gross;
|
|
@@ -6125,7 +8842,7 @@ var SeatManager = class {
|
|
|
6125
8842
|
}
|
|
6126
8843
|
return snapshot;
|
|
6127
8844
|
} catch (err) {
|
|
6128
|
-
if (
|
|
8845
|
+
if (request === this.revenueRequest) {
|
|
6129
8846
|
this.revenueStatus = "stale";
|
|
6130
8847
|
this.recomputeTallies();
|
|
6131
8848
|
}
|
|
@@ -6169,6 +8886,7 @@ var SeatManager = class {
|
|
|
6169
8886
|
this.paintMonitorInsights();
|
|
6170
8887
|
} else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
|
|
6171
8888
|
else if (this.mode === "block") this.paintSelBar(this.getSelection());
|
|
8889
|
+
else if (this.mode === "channels") this.channels?.handleSelectionChange();
|
|
6172
8890
|
this.opts.onTallies?.(t3);
|
|
6173
8891
|
}
|
|
6174
8892
|
verbFor(prev, next) {
|
|
@@ -6257,6 +8975,7 @@ var SeatManager = class {
|
|
|
6257
8975
|
const seats = this.getSelection();
|
|
6258
8976
|
if (this.mode === "block") this.paintSelBar(seats);
|
|
6259
8977
|
else if (this.mode === "inspect") this.renderInspectRail(seats);
|
|
8978
|
+
else if (this.mode === "channels") this.channels?.handleSelectionChange();
|
|
6260
8979
|
this.opts.onSelectionChange?.(seats);
|
|
6261
8980
|
}
|
|
6262
8981
|
// ---- DOM: chrome ----------------------------------------------------------
|
|
@@ -6275,7 +8994,9 @@ var SeatManager = class {
|
|
|
6275
8994
|
<button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
|
|
6276
8995
|
<button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
|
|
6277
8996
|
<button class="slm-mode" role="tab" data-mode="sections" title="Sections (S)" aria-keyshortcuts="S">Sections</button>
|
|
8997
|
+
<button class="slm-mode" role="tab" data-mode="channels" title="Channels (C)" aria-keyshortcuts="C" hidden>Channels</button>
|
|
6278
8998
|
</div>
|
|
8999
|
+
<select class="slm-tools" data-ref="tools" aria-label="Manager tools"></select>
|
|
6279
9000
|
<span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
|
|
6280
9001
|
<div class="slm-bar-actions">
|
|
6281
9002
|
<button class="slm-barbtn follow" data-ref="follow" aria-pressed="false"
|
|
@@ -6309,6 +9030,7 @@ var SeatManager = class {
|
|
|
6309
9030
|
this.mapHost = ref("maphost");
|
|
6310
9031
|
this.els = {
|
|
6311
9032
|
modes: ref("modes"),
|
|
9033
|
+
tools: ref("tools"),
|
|
6312
9034
|
livetext: ref("livetext"),
|
|
6313
9035
|
kpis: ref("kpis"),
|
|
6314
9036
|
follow: ref("follow"),
|
|
@@ -6321,6 +9043,7 @@ var SeatManager = class {
|
|
|
6321
9043
|
zfit: ref("zfit")
|
|
6322
9044
|
};
|
|
6323
9045
|
this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
|
|
9046
|
+
this.els.tools.addEventListener("change", () => this.setMode(this.els.tools.value));
|
|
6324
9047
|
this.els.zfit.addEventListener("click", () => this.zoomToFit());
|
|
6325
9048
|
this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
|
|
6326
9049
|
this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
|
|
@@ -6336,6 +9059,7 @@ var SeatManager = class {
|
|
|
6336
9059
|
updateContainerLayout() {
|
|
6337
9060
|
const width = this.root?.getBoundingClientRect().width || this.host.clientWidth;
|
|
6338
9061
|
this.root?.classList.toggle("compact", width > 0 && width < 800);
|
|
9062
|
+
this.channels?.handleLayoutChange();
|
|
6339
9063
|
}
|
|
6340
9064
|
buildSectionOptions() {
|
|
6341
9065
|
if (!this.doc) return;
|
|
@@ -6357,13 +9081,24 @@ var SeatManager = class {
|
|
|
6357
9081
|
}
|
|
6358
9082
|
}
|
|
6359
9083
|
paintModeTabs() {
|
|
9084
|
+
const available = [];
|
|
6360
9085
|
this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
|
|
6361
9086
|
const el = b;
|
|
6362
|
-
const
|
|
9087
|
+
const mode = el.dataset.mode;
|
|
9088
|
+
const permitted = mode !== "channels" || !!this.channels;
|
|
9089
|
+
el.hidden = !permitted;
|
|
9090
|
+
if (!permitted) return;
|
|
9091
|
+
available.push({ mode, label: el.textContent ?? mode });
|
|
9092
|
+
const active = mode === this.mode;
|
|
6363
9093
|
el.classList.toggle("on", active);
|
|
6364
9094
|
el.setAttribute("aria-selected", String(active));
|
|
6365
9095
|
el.tabIndex = active ? 0 : -1;
|
|
6366
9096
|
});
|
|
9097
|
+
const tools = this.els.tools;
|
|
9098
|
+
if (tools) {
|
|
9099
|
+
tools.innerHTML = available.map((entry) => `<option value="${entry.mode}"${entry.mode === this.mode ? " selected" : ""}>${esc2(entry.label)}</option>`).join("");
|
|
9100
|
+
tools.value = this.mode;
|
|
9101
|
+
}
|
|
6367
9102
|
this.root?.classList.toggle("block-mode", this.mode === "block");
|
|
6368
9103
|
}
|
|
6369
9104
|
paintFollowLiveButton() {
|
|
@@ -6468,6 +9203,7 @@ var SeatManager = class {
|
|
|
6468
9203
|
if (this.mode === "view") this.renderViewRail();
|
|
6469
9204
|
else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
|
|
6470
9205
|
else if (this.mode === "sections") this.renderSectionsRail();
|
|
9206
|
+
else if (this.mode === "channels") this.channels?.paintRail();
|
|
6471
9207
|
else this.renderBlockRail();
|
|
6472
9208
|
this.updateZoomHint();
|
|
6473
9209
|
}
|
|
@@ -6533,8 +9269,8 @@ var SeatManager = class {
|
|
|
6533
9269
|
const net = speed?.netBooked ?? 0;
|
|
6534
9270
|
const netLabel = `${net > 0 ? "+" : ""}${net}`;
|
|
6535
9271
|
const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
|
|
6536
|
-
return `<button type="button" class="slm-sectionrow" data-section-focus="${
|
|
6537
|
-
<span class="slm-sectiontop"><span>${
|
|
9272
|
+
return `<button type="button" class="slm-sectionrow" data-section-focus="${esc2(row.sectionId)}" title="Focus ${esc2(row.sectionLabel)} on the map">
|
|
9273
|
+
<span class="slm-sectiontop"><span>${esc2(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></span>
|
|
6538
9274
|
<span class="slm-sectionmeta"><span>${row.booked.toLocaleString()}/${row.total.toLocaleString()} sold \xB7 ${netLabel} in ${snapshot.velocity.windowMinutes}m</span><span class="slm-trend ${trend}">${trend}</span><span class="slm-sectionlocate">Locate</span></span>
|
|
6539
9275
|
</button>`;
|
|
6540
9276
|
}).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
|
|
@@ -6579,12 +9315,12 @@ var SeatManager = class {
|
|
|
6579
9315
|
<p class="slm-eyebrow">${itemKind} details</p>
|
|
6580
9316
|
<p class="slm-hint">Live availability and section performance.</p>
|
|
6581
9317
|
<div class="slm-inspect-card">
|
|
6582
|
-
<div class="slm-inspect-label">${
|
|
9318
|
+
<div class="slm-inspect-label">${esc2(seat.label)}</div>
|
|
6583
9319
|
<div class="slm-inspect-grid">
|
|
6584
9320
|
<div><span>Status</span><b>${statusLabel[status]}</b></div>
|
|
6585
|
-
<div><span>Section</span><b>${
|
|
6586
|
-
${location2 ? `<div><span>${location2.label}</span><b>${
|
|
6587
|
-
<div><span>Category</span><b>${
|
|
9321
|
+
<div><span>Section</span><b>${esc2(sectionLabel)}</b></div>
|
|
9322
|
+
${location2 ? `<div><span>${location2.label}</span><b>${esc2(location2.value)}</b></div>` : ""}
|
|
9323
|
+
<div><span>Category</span><b>${esc2(category?.label ?? seat.categoryKey)}</b></div>
|
|
6588
9324
|
<div><span>Sold in section</span><b>${sectionMetric ? `${sectionMetric.booked} of ${sectionMetric.total}` : "\u2014"}</b></div>
|
|
6589
9325
|
<div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>
|
|
6590
9326
|
</div>
|
|
@@ -6730,7 +9466,7 @@ var SeatManager = class {
|
|
|
6730
9466
|
<div class="slm-availlist" data-ref="availlist">${rows.map((row) => this.sectionRowHtml(row)).join("")}</div>
|
|
6731
9467
|
<div class="slm-availsummary">
|
|
6732
9468
|
<span class="slm-availdot${warn ? " warn" : ""}"></span>
|
|
6733
|
-
<span>${
|
|
9469
|
+
<span>${esc2(summary)}</span>
|
|
6734
9470
|
</div>
|
|
6735
9471
|
<div class="slm-availcallout">
|
|
6736
9472
|
<span class="slm-availstar" aria-hidden="true">\u2726</span>
|
|
@@ -6745,7 +9481,7 @@ var SeatManager = class {
|
|
|
6745
9481
|
const disabled = this.availabilitySaving ? " disabled" : "";
|
|
6746
9482
|
const option = (value, text) => `<option value="${value}"${mode === value ? " selected" : ""}>${text}</option>`;
|
|
6747
9483
|
const control = row.followsZone ? '<span class="slm-availfollows">Follows zone</span>' : `<span class="slm-availselwrap">
|
|
6748
|
-
<select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${
|
|
9484
|
+
<select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc2(row.id)}"${disabled} aria-label="Availability for ${esc2(row.label)}">
|
|
6749
9485
|
${option("open", "Open \u2014 on sale")}
|
|
6750
9486
|
${option("closed", "Closed \u2014 visible, not on sale")}
|
|
6751
9487
|
${option("hidden", "Hidden \u2014 off the buyer map")}
|
|
@@ -6755,15 +9491,15 @@ var SeatManager = class {
|
|
|
6755
9491
|
</span>`;
|
|
6756
9492
|
let detail = "";
|
|
6757
9493
|
if (!row.followsZone && mode === "timed") {
|
|
6758
|
-
const value = row.rule?.revealAt ?
|
|
9494
|
+
const value = row.rule?.revealAt ? esc2(toLocalInput(row.rule.revealAt)) : "";
|
|
6759
9495
|
detail = `<div class="slm-availdetail">
|
|
6760
|
-
<input type="datetime-local" class="slm-input" data-avail-reveal="${
|
|
9496
|
+
<input type="datetime-local" class="slm-input" data-avail-reveal="${esc2(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc2(row.label)}" />
|
|
6761
9497
|
</div>`;
|
|
6762
9498
|
} else if (!row.followsZone && mode === "threshold") {
|
|
6763
9499
|
const pct = row.rule?.thresholdPct ?? 80;
|
|
6764
9500
|
detail = `<div class="slm-availdetail">
|
|
6765
9501
|
<span class="slm-availpctlabel">Reveal at</span>
|
|
6766
|
-
<input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${
|
|
9502
|
+
<input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${esc2(row.id)}" value="${esc2(pct)}"${disabled} aria-label="Percent sold to reveal ${esc2(row.label)}" />
|
|
6767
9503
|
<span class="slm-availpctlabel">% sold</span>
|
|
6768
9504
|
</div>`;
|
|
6769
9505
|
}
|
|
@@ -6771,7 +9507,7 @@ var SeatManager = class {
|
|
|
6771
9507
|
const caret = row.kind === "zone" ? `<span class="slm-availcaret" aria-hidden="true">${row.hidden ? "\u25B8" : "\u25BE"}</span>` : "";
|
|
6772
9508
|
return `<div class="${cls}">
|
|
6773
9509
|
<div class="slm-availhead">
|
|
6774
|
-
<span class="slm-availlabel">${caret}${
|
|
9510
|
+
<span class="slm-availlabel">${caret}${esc2(row.label)}</span>
|
|
6775
9511
|
${badge}
|
|
6776
9512
|
<span class="slm-availcount">${row.seatCount.toLocaleString()}</span>
|
|
6777
9513
|
${control}
|
|
@@ -6860,25 +9596,25 @@ var SeatManager = class {
|
|
|
6860
9596
|
const extra = a.count > 1 ? ` +${a.count - 1}` : "";
|
|
6861
9597
|
const sections = a.sectionLabels ?? [];
|
|
6862
9598
|
const sectionCopy = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : "";
|
|
6863
|
-
return `<button type="button" class="slm-feedrow" data-feed-id="${
|
|
9599
|
+
return `<button type="button" class="slm-feedrow" data-feed-id="${esc2(a.id)}" title="Locate this activity on the map">
|
|
6864
9600
|
<span class="slm-feeddot" style="background:${color[a.status]}"></span>
|
|
6865
|
-
<span class="slm-feedtext">${sectionCopy ? `<span class="slm-feedsection">${
|
|
9601
|
+
<span class="slm-feedtext">${sectionCopy ? `<span class="slm-feedsection">${esc2(sectionCopy)}</span>` : ""}${a.count === 1 ? "Seat" : "Seats"} <b>${esc2(a.label)}${extra}</b> ${esc2(a.verb)}</span>
|
|
6866
9602
|
<span class="slm-feedmeta"><span class="slm-feedtime">${relTime(a.at, now)}</span><span class="slm-feedlocate">Locate</span></span>
|
|
6867
9603
|
</button>`;
|
|
6868
9604
|
}).join("");
|
|
6869
9605
|
}
|
|
6870
9606
|
renderBlockRail() {
|
|
6871
9607
|
const cats = this.doc?.categories ?? [];
|
|
6872
|
-
const catChips = cats.map((c) => `<button class="slm-chip" type="button" data-cat="${
|
|
6873
|
-
<span class="dot" style="background:${
|
|
6874
|
-
<span>${
|
|
9608
|
+
const catChips = cats.map((c) => `<button class="slm-chip" type="button" data-cat="${esc2(c.key)}" aria-pressed="false">
|
|
9609
|
+
<span class="dot" style="background:${esc2(c.color ?? "#6e7bff")}"></span>
|
|
9610
|
+
<span>${esc2(c.label ?? c.key)}</span>
|
|
6875
9611
|
<span class="slm-chipcount" data-cat-count>0</span>
|
|
6876
9612
|
<span class="slm-chipcheck" aria-hidden="true">\u2713</span>
|
|
6877
9613
|
</button>`).join("");
|
|
6878
9614
|
const sectionField = this.sectionOptions.length ? `<div class="slm-field"><label>Select a whole section</label>
|
|
6879
9615
|
<select class="slm-select" data-ref="section"><option value="">Choose a section\u2026</option>
|
|
6880
|
-
${this.sectionOptions.map((s) => `<option value="${
|
|
6881
|
-
const blockedSectionOptions = this.sectionOptions.map((s) => `<option value="${
|
|
9616
|
+
${this.sectionOptions.map((s) => `<option value="${esc2(s.id)}">${esc2(s.label)}</option>`).join("")}</select></div>` : "";
|
|
9617
|
+
const blockedSectionOptions = this.sectionOptions.map((s) => `<option value="${esc2(s.id)}">${esc2(s.label)}</option>`).join("");
|
|
6882
9618
|
this.els.rail.innerHTML = `
|
|
6883
9619
|
<p class="slm-eyebrow">Block & unblock</p>
|
|
6884
9620
|
<p class="slm-hint">Drag a box on the map to marquee-select, \u2318A for all, or pick a category/section. Booked and held inventory is never actionable here.</p>
|
|
@@ -7081,10 +9817,10 @@ var SeatManager = class {
|
|
|
7081
9817
|
const section = this.sectionLabelById.get(sectionId) ?? "Other seats";
|
|
7082
9818
|
const category = this.doc?.categories.find((item) => item.key === seat.categoryKey)?.label ?? seat.categoryKey;
|
|
7083
9819
|
const isSelected = selected.has(seat.label);
|
|
7084
|
-
return `<button type="button" class="slm-blockeditem${isSelected ? " on" : ""}" data-blocked-label="${
|
|
9820
|
+
return `<button type="button" class="slm-blockeditem${isSelected ? " on" : ""}" data-blocked-label="${esc2(seat.label)}" aria-pressed="${isSelected}">
|
|
7085
9821
|
<span class="slm-blockedcheck" aria-hidden="true">\u2713</span>
|
|
7086
|
-
<span class="slm-blockedcopy"><span class="slm-blockedlabel">${
|
|
7087
|
-
<span class="slm-blockedmeta">${
|
|
9822
|
+
<span class="slm-blockedcopy"><span class="slm-blockedlabel">${esc2(seat.label)}</span>
|
|
9823
|
+
<span class="slm-blockedmeta">${esc2(section)} \xB7 ${esc2(category)}</span></span>
|
|
7088
9824
|
</button>`;
|
|
7089
9825
|
}).join("") + (filtered.length > visible.length ? `<button type="button" class="slm-blockedmore" data-blocked-more>Show 100 more</button>` : "") : `<div class="slm-blockedempty">${allBlocked ? "No blocked seats match this search or section." : "No seats are blocked. Newly blocked seats will appear here."}</div>`;
|
|
7090
9826
|
const markAll = this.els.markall;
|
|
@@ -7150,12 +9886,33 @@ var SeatManager = class {
|
|
|
7150
9886
|
};
|
|
7151
9887
|
export {
|
|
7152
9888
|
ApiError,
|
|
9889
|
+
BuyerAccessContext,
|
|
9890
|
+
BuyerAccessUnavailableError,
|
|
9891
|
+
BuyerRealtimeClient,
|
|
9892
|
+
ChannelsMode,
|
|
7153
9893
|
EmbeddedDesigner,
|
|
7154
9894
|
ManageApi,
|
|
7155
9895
|
ManageApiError,
|
|
9896
|
+
PUBLIC_CHANNEL_ID,
|
|
9897
|
+
PUBLIC_CHANNEL_NAME,
|
|
7156
9898
|
SeatManager,
|
|
7157
9899
|
SeatPicker,
|
|
7158
9900
|
SeatingChart,
|
|
7159
|
-
|
|
9901
|
+
accessIntentLabel,
|
|
9902
|
+
accessLine,
|
|
9903
|
+
attachPickerFrame,
|
|
9904
|
+
bucketRows,
|
|
9905
|
+
bucketRowsHtml,
|
|
9906
|
+
createBuyerAccessContext,
|
|
9907
|
+
createControllerSink,
|
|
9908
|
+
dropReviewRows,
|
|
9909
|
+
markerOf,
|
|
9910
|
+
mutationCount,
|
|
9911
|
+
needsMoveConfirmation,
|
|
9912
|
+
planAssignment,
|
|
9913
|
+
retryAfterCopy,
|
|
9914
|
+
selectionSources,
|
|
9915
|
+
stateBadge,
|
|
9916
|
+
suggestMarker
|
|
7160
9917
|
};
|
|
7161
9918
|
//# sourceMappingURL=index.js.map
|