@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 CHANGED
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __typeError = (msg) => {
9
+ throw TypeError(msg);
10
+ };
8
11
  var __export = (target, all) => {
9
12
  for (var name in all)
10
13
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -26,18 +29,44 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
29
  mod
27
30
  ));
28
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
33
+ var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
34
+ 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);
35
+ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
36
+ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
29
37
 
30
38
  // src/index.ts
31
39
  var index_exports = {};
32
40
  __export(index_exports, {
33
41
  ApiError: () => ApiError,
42
+ BuyerAccessContext: () => BuyerAccessContext,
43
+ BuyerAccessUnavailableError: () => BuyerAccessUnavailableError,
44
+ BuyerRealtimeClient: () => BuyerRealtimeClient,
45
+ ChannelsMode: () => ChannelsMode,
34
46
  EmbeddedDesigner: () => EmbeddedDesigner,
35
47
  ManageApi: () => ManageApi,
36
48
  ManageApiError: () => ManageApiError,
49
+ PUBLIC_CHANNEL_ID: () => PUBLIC_CHANNEL_ID,
50
+ PUBLIC_CHANNEL_NAME: () => PUBLIC_CHANNEL_NAME,
37
51
  SeatManager: () => SeatManager,
38
52
  SeatPicker: () => SeatPicker,
39
53
  SeatingChart: () => SeatingChart,
40
- attachPickerFrame: () => attachPickerFrame
54
+ accessIntentLabel: () => accessIntentLabel,
55
+ accessLine: () => accessLine,
56
+ attachPickerFrame: () => attachPickerFrame,
57
+ bucketRows: () => bucketRows,
58
+ bucketRowsHtml: () => bucketRowsHtml,
59
+ createBuyerAccessContext: () => createBuyerAccessContext,
60
+ createControllerSink: () => createControllerSink,
61
+ dropReviewRows: () => dropReviewRows,
62
+ markerOf: () => markerOf,
63
+ mutationCount: () => mutationCount,
64
+ needsMoveConfirmation: () => needsMoveConfirmation,
65
+ planAssignment: () => planAssignment,
66
+ retryAfterCopy: () => retryAfterCopy,
67
+ selectionSources: () => selectionSources,
68
+ stateBadge: () => stateBadge,
69
+ suggestMarker: () => suggestMarker
41
70
  });
42
71
  module.exports = __toCommonJS(index_exports);
43
72
 
@@ -55,63 +84,88 @@ var ApiError = class extends Error {
55
84
  this.reason = reason;
56
85
  }
57
86
  };
58
- async function request(base, path, init = {}) {
59
- const method = init.method ?? "GET";
60
- const headers = {};
61
- let body;
62
- if (init.body !== void 0) {
63
- headers["Content-Type"] = "application/json";
64
- body = JSON.stringify(init.body);
65
- }
66
- const res = await fetch(`${base}${path}`, { method, headers, body, credentials: "omit" });
67
- const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
68
- const data = isJson ? await res.json().catch(() => null) : null;
69
- if (!res.ok) {
70
- const err = data;
71
- throw new ApiError(
72
- res.status,
73
- err?.error ?? `request_failed_${res.status}`,
74
- err?.code ?? err?.error,
75
- err?.conflicts,
76
- err?.reason
77
- );
78
- }
79
- return data;
80
- }
87
+ var OBJECT_UNAVAILABLE_CODES = {
88
+ seat_conflict: "taken",
89
+ conflict: "taken",
90
+ channel_assignment_conflict: "ineligible",
91
+ allocation_exhausted: "exhausted"
92
+ };
81
93
  var PubApi = class {
82
- constructor(base) {
94
+ constructor(base, options = {}) {
83
95
  this.base = base;
84
96
  this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
97
+ this.access = options.access;
98
+ this.onObjectUnavailable = options.onObjectUnavailable;
99
+ }
100
+ /** True when this client is bound to a buyer access session. */
101
+ get accessScoped() {
102
+ return !!this.access?.configured;
103
+ }
104
+ async request(path, init = {}, retried = false) {
105
+ const method = init.method ?? "GET";
106
+ const headers = {};
107
+ let body;
108
+ if (init.body !== void 0) {
109
+ headers["Content-Type"] = "application/json";
110
+ body = JSON.stringify(init.body);
111
+ }
112
+ const authorization = await this.access?.authorization(retried ? "unauthorized" : "initial");
113
+ if (authorization) headers.Authorization = authorization;
114
+ const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
115
+ const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
116
+ const data = isJson ? await res.json().catch(() => null) : null;
117
+ if (!res.ok) {
118
+ const err = data;
119
+ const code = err?.code ?? err?.error;
120
+ if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
121
+ const refreshed = await this.access.handleFailure(res.status, code);
122
+ if (refreshed && !retried) return this.request(path, init, true);
123
+ }
124
+ if (res.status === 409) {
125
+ const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
126
+ const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
127
+ if (reason) this.onObjectUnavailable?.({ labels, reason, code });
128
+ }
129
+ throw new ApiError(
130
+ res.status,
131
+ err?.error ?? `request_failed_${res.status}`,
132
+ code,
133
+ err?.conflicts,
134
+ err?.reason
135
+ );
136
+ }
137
+ return data;
85
138
  }
86
139
  chart(key) {
87
- return request(this.base, `/pub/events/${encodeURIComponent(key)}/chart`);
140
+ return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
88
141
  }
89
142
  objects(key) {
90
- return request(this.base, `/pub/events/${encodeURIComponent(key)}/objects`);
143
+ return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
91
144
  }
92
145
  hold(key, selections, ttlMs, replaceHoldId) {
93
- return request(this.base, `/pub/events/${encodeURIComponent(key)}/hold`, {
146
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
94
147
  method: "POST",
95
- body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} }
148
+ body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
149
+ labels: selections.map((s) => s.label)
96
150
  });
97
151
  }
98
152
  // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
99
153
  // window — both are part of the route contract, and dropping either here made
100
154
  // the SDK quietly pick venue-wide and hold for the server default instead.
101
155
  bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
102
- return request(this.base, `/pub/events/${encodeURIComponent(key)}/best-available`, {
156
+ return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
103
157
  method: "POST",
104
158
  body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
105
159
  });
106
160
  }
107
161
  resume(key, holdId) {
108
- return request(this.base, `/pub/events/${encodeURIComponent(key)}/hold/resume`, {
162
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
109
163
  method: "POST",
110
164
  body: { holdId }
111
165
  });
112
166
  }
113
167
  release(key, labels, holdId) {
114
- return request(this.base, `/pub/events/${encodeURIComponent(key)}/release`, {
168
+ return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
115
169
  method: "POST",
116
170
  body: { labels, holdId }
117
171
  });
@@ -119,17 +173,662 @@ var PubApi = class {
119
173
  /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
120
174
  * (reason: expired | extend_limit | not_found | not_active) if it can't. */
121
175
  extend(key, holdId, ttlMs) {
122
- return request(this.base, `/pub/events/${encodeURIComponent(key)}/extend`, {
176
+ return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
123
177
  method: "POST",
124
178
  body: { holdId, ...ttlMs ? { ttlMs } : {} }
125
179
  });
126
180
  }
127
- socketUrl(key) {
181
+ /**
182
+ * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
183
+ * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
184
+ * already apply; the socket then carries only the short-lived ticket, in its
185
+ * subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
186
+ */
187
+ subscribeTicket(key) {
188
+ return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
189
+ method: "POST",
190
+ body: {}
191
+ });
192
+ }
193
+ /**
194
+ * The subscribe URL. Never carries a credential — not the bearer, not the
195
+ * ticket. Query parameters are diagnostics only.
196
+ */
197
+ subscribeUrl(key) {
128
198
  const wsBase = this.base.replace(/^http/, "ws");
129
199
  const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
130
200
  return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
131
201
  }
202
+ /**
203
+ * What PickerController opens its own socket with.
204
+ *
205
+ * Empty for an access-scoped client: a private scope authenticates with a
206
+ * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
207
+ * BuyerRealtimeClient owns that socket instead and the controller skips its
208
+ * own (an empty URL is its documented "no live feed" contract). A tokenless
209
+ * public client returns exactly the URL it always has, so nothing about the
210
+ * public picker's realtime path changes.
211
+ */
212
+ socketUrl(key) {
213
+ return this.accessScoped ? "" : this.subscribeUrl(key);
214
+ }
215
+ };
216
+
217
+ // src/buyerAccess.ts
218
+ var BuyerAccessUnavailableError = class extends Error {
219
+ constructor(event) {
220
+ super(`buyer_access_unavailable:${event.reason}`);
221
+ this.name = "BuyerAccessUnavailableError";
222
+ this.reason = event.reason;
223
+ this.code = event.code;
224
+ this.status = event.status;
225
+ }
226
+ };
227
+ var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
228
+ var RECOVERABLE = /* @__PURE__ */ new Set([
229
+ "paused",
230
+ "provider_failed",
231
+ "channel_denied"
232
+ ]);
233
+ function classifyAccessFailure(status, code) {
234
+ switch (code) {
235
+ case "buyer_access_invalid":
236
+ return "invalid";
237
+ case "buyer_access_revoked":
238
+ return "revoked";
239
+ case "buyer_access_origin_mismatch":
240
+ return "origin_mismatch";
241
+ case "buyer_access_event_mismatch":
242
+ return "event_mismatch";
243
+ case "buyer_access_mode_mismatch":
244
+ return "mode_mismatch";
245
+ case "channel_access_denied":
246
+ return "channel_denied";
247
+ case "channel_paused":
248
+ return "paused";
249
+ case "invalid_channel_scope":
250
+ return "invalid_scope";
251
+ default:
252
+ break;
253
+ }
254
+ if (status === 401) return "invalid";
255
+ return null;
256
+ }
257
+ function isAccessExpiry(status, code) {
258
+ return status === 401 && !!code && EXPIRED_CODES.has(code);
259
+ }
260
+ var DEFAULT_SKEW_MS = 3e4;
261
+ var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
262
+ var BuyerAccessContext = class {
263
+ constructor(options) {
264
+ __privateAdd(this, _BuyerAccessContext_instances);
265
+ /** Private field: not enumerable, not spreadable, not serializable. */
266
+ __privateAdd(this, _token, null);
267
+ __privateAdd(this, _expiresAt, 0);
268
+ __privateAdd(this, _provider);
269
+ __privateAdd(this, _skewMs);
270
+ __privateAdd(this, _inflight, null);
271
+ __privateAdd(this, _terminal, null);
272
+ /** The most recent failure, terminal or not — so one cause reports once. */
273
+ __privateAdd(this, _lastFailure, null);
274
+ __privateAdd(this, _onExpired);
275
+ __privateAdd(this, _onUnavailable);
276
+ /** Decided once, at construction. See the `configured` getter. */
277
+ __privateAdd(this, _configured, false);
278
+ __privateSet(this, _provider, options.provider);
279
+ __privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
280
+ __privateSet(this, _onExpired, options.onExpired);
281
+ __privateSet(this, _onUnavailable, options.onUnavailable);
282
+ if (options.token) {
283
+ const seed = typeof options.token === "string" ? { token: options.token } : options.token;
284
+ __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
285
+ }
286
+ __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
287
+ }
288
+ /**
289
+ * True when this picker is access-scoped at all. A false here is the
290
+ * tokenless public picker, which must behave exactly as it always has.
291
+ *
292
+ * Answered from what the HOST asked for, never from live token state. It used
293
+ * to be `!!#provider || !!#token`, which quietly inverted this file's central
294
+ * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
295
+ * the first refusal turned a configured context into an "unconfigured" one,
296
+ * `authorization()` then returned null instead of throwing, and the very next
297
+ * call went out with no bearer — the anonymous Public sale fallback this
298
+ * module exists to prevent. A provider host never saw it, because `#provider`
299
+ * held `configured` true. Found against a live worker in the M9 pass.
300
+ */
301
+ get configured() {
302
+ return __privateGet(this, _configured);
303
+ }
304
+ /** Set once a state arrives that refreshing cannot clear. */
305
+ get unavailable() {
306
+ return __privateGet(this, _terminal);
307
+ }
308
+ /** True while a usable bearer is held (ignores skew). */
309
+ get hasToken() {
310
+ return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
311
+ }
312
+ /** Epoch ms the current token expires, or 0 when the host didn't say. */
313
+ get expiresAt() {
314
+ return __privateGet(this, _expiresAt);
315
+ }
316
+ /**
317
+ * The `Authorization` header value for a scoped operation.
318
+ *
319
+ * Returns null only when this context is not configured at all (the ordinary
320
+ * anonymous public picker). A configured context either returns a bearer or
321
+ * throws `BuyerAccessUnavailableError` — it never returns null, because a
322
+ * null here would send the request as anonymous Public sale.
323
+ */
324
+ async authorization(reason = "initial") {
325
+ if (!this.configured) return null;
326
+ if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
327
+ const now = Date.now();
328
+ const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
329
+ if (stale) {
330
+ const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
331
+ const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
332
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
333
+ if (!token) {
334
+ throw new BuyerAccessUnavailableError(
335
+ __privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
336
+ );
337
+ }
338
+ return `Bearer ${token}`;
339
+ }
340
+ return `Bearer ${__privateGet(this, _token)}`;
341
+ }
342
+ /**
343
+ * Handle a 401/403 from a scoped call. Returns true when the caller should
344
+ * retry the same request once with the refreshed bearer.
345
+ */
346
+ async handleFailure(status, code) {
347
+ var _a;
348
+ if (!this.configured) return false;
349
+ if (isAccessExpiry(status, code)) {
350
+ __privateSet(this, _token, null);
351
+ __privateSet(this, _expiresAt, 0);
352
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
353
+ (_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
354
+ return !!token;
355
+ }
356
+ const reason = classifyAccessFailure(status, code);
357
+ if (reason) {
358
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
359
+ return false;
360
+ }
361
+ return false;
362
+ }
363
+ /** Host-driven re-acquisition (after the buyer signs in again, say). */
364
+ async refresh(reason = "manual") {
365
+ __privateSet(this, _terminal, null);
366
+ __privateSet(this, _lastFailure, null);
367
+ __privateSet(this, _token, null);
368
+ __privateSet(this, _expiresAt, 0);
369
+ return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
370
+ }
371
+ /** Drop the bearer. Called on destroy so nothing outlives the widget. */
372
+ clear() {
373
+ __privateSet(this, _token, null);
374
+ __privateSet(this, _expiresAt, 0);
375
+ __privateSet(this, _inflight, null);
376
+ }
377
+ /** Redaction: the bearer must not survive a stringify or an interpolation. */
378
+ toJSON() {
379
+ return { configured: this.configured, hasToken: this.hasToken };
380
+ }
381
+ toString() {
382
+ return "[BuyerAccessContext redacted]";
383
+ }
384
+ };
385
+ _token = new WeakMap();
386
+ _expiresAt = new WeakMap();
387
+ _provider = new WeakMap();
388
+ _skewMs = new WeakMap();
389
+ _inflight = new WeakMap();
390
+ _terminal = new WeakMap();
391
+ _lastFailure = new WeakMap();
392
+ _onExpired = new WeakMap();
393
+ _onUnavailable = new WeakMap();
394
+ _configured = new WeakMap();
395
+ _BuyerAccessContext_instances = new WeakSet();
396
+ // ---- internals ------------------------------------------------------------
397
+ accept_fn = function(next) {
398
+ if (!next || typeof next.token !== "string" || !next.token) return null;
399
+ __privateSet(this, _token, next.token);
400
+ __privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
401
+ return __privateGet(this, _token);
402
+ };
403
+ /**
404
+ * One provider call at a time. Several operations racing an expiry (chart +
405
+ * objects + a socket ticket) must not mint several sessions — the guide's
406
+ * rotate-on-retry rule would revoke the ones they didn't observe.
407
+ */
408
+ renew_fn = function(reason, code) {
409
+ if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
410
+ const provider = __privateGet(this, _provider);
411
+ if (!provider) {
412
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
413
+ return Promise.resolve(null);
414
+ }
415
+ const run = (async () => {
416
+ try {
417
+ const next = await provider({ reason });
418
+ const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
419
+ if (!token) {
420
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
421
+ return null;
422
+ }
423
+ return token;
424
+ } catch {
425
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
426
+ return null;
427
+ } finally {
428
+ __privateSet(this, _inflight, null);
429
+ }
430
+ })();
431
+ __privateSet(this, _inflight, run);
432
+ return run;
433
+ };
434
+ fail_fn = function(reason, code, status) {
435
+ var _a;
436
+ const event = {
437
+ reason,
438
+ code,
439
+ status,
440
+ // Unchanged: `retryable` means "the SAME request may succeed later".
441
+ // `provider_failed` is recoverable but not retryable — the host must fix
442
+ // its mint endpoint first — so the two sets are deliberately different.
443
+ retryable: reason === "paused" || reason === "channel_denied"
444
+ };
445
+ __privateSet(this, _lastFailure, event);
446
+ if (!RECOVERABLE.has(reason)) {
447
+ __privateSet(this, _terminal, event);
448
+ __privateSet(this, _token, null);
449
+ __privateSet(this, _expiresAt, 0);
450
+ }
451
+ (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
452
+ return event;
453
+ };
454
+ function createBuyerAccessContext(options, hooks = {}) {
455
+ if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
456
+ return new BuyerAccessContext({
457
+ provider: options.buyerAccessTokenProvider,
458
+ token: options.buyerAccessToken,
459
+ ...hooks
460
+ });
461
+ }
462
+
463
+ // src/buyerRealtime.ts
464
+ var SEATLAYER_V1 = "seatlayer.v1";
465
+ var SEP = "\0";
466
+ var CLOSE_ACCESS_REVOKED = 4401;
467
+ var MAX_BACKOFF_MS = 15e3;
468
+ var PING_INTERVAL_MS = 25e3;
469
+ var PONG_GRACE_MS = 1e4;
470
+ var RESUME_ANSWER_GRACE_MS = 5e3;
471
+ function projectionFromSnapshot(frame) {
472
+ const fallback = typeof frame.default === "string" ? frame.default : "free";
473
+ const exceptions = {};
474
+ if (frame.seats && typeof frame.seats === "object") {
475
+ for (const [label, status] of Object.entries(frame.seats)) {
476
+ if (typeof status === "string" && status !== fallback) exceptions[label] = status;
477
+ }
478
+ }
479
+ return { default: fallback, exceptions };
480
+ }
481
+ function diffProjections(prev, next) {
482
+ if (!prev || prev.default !== next.default) return null;
483
+ const changes = [];
484
+ for (const [label, status] of Object.entries(next.exceptions)) {
485
+ if (prev.exceptions[label] !== status) changes.push({ label, status });
486
+ }
487
+ for (const label of Object.keys(prev.exceptions)) {
488
+ if (!(label in next.exceptions)) changes.push({ label, status: next.default });
489
+ }
490
+ return changes;
491
+ }
492
+ function applyChanges(projection, changes) {
493
+ for (const change of changes) {
494
+ if (change.status === projection.default) delete projection.exceptions[change.label];
495
+ else projection.exceptions[change.label] = change.status;
496
+ }
497
+ }
498
+ function assertCredentialFreeUrl(url) {
499
+ if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
500
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
501
+ }
502
+ if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
503
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
504
+ }
505
+ }
506
+ var BuyerRealtimeClient = class {
507
+ constructor(options) {
508
+ this.ws = null;
509
+ this.stopped = true;
510
+ this.attempt = 0;
511
+ this.reconnectTimer = null;
512
+ this.pingTimer = null;
513
+ this.pongTimer = null;
514
+ this.resumeTimer = null;
515
+ /** Our model of this scope's projection. Null until the first snapshot. */
516
+ this.projection = null;
517
+ /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
518
+ this.version = null;
519
+ /** True once the 101 echoed `seatlayer.v1`. */
520
+ this.v1 = false;
521
+ /** Set when we offered v1 and the handshake came back without it — a proxy
522
+ * most likely stripped the header, so the next attempt selects the v1 frame
523
+ * format with the `?pv=1` marker instead (protocol doc §1). The marker
524
+ * selects a format and can never carry a credential or widen a scope. */
525
+ this.useQueryMarker = false;
526
+ this.hidden = null;
527
+ this.closedSections = null;
528
+ this.opts = options;
529
+ assertCredentialFreeUrl(options.url);
530
+ }
531
+ /** Negotiated protocol, for tests and diagnostics. */
532
+ get protocol() {
533
+ return this.ws ? this.v1 ? "v1" : "legacy" : null;
534
+ }
535
+ get snapshotVersion() {
536
+ return this.version;
537
+ }
538
+ start() {
539
+ if (!this.stopped) return;
540
+ this.stopped = false;
541
+ void this.connect();
542
+ }
543
+ /** Stop for good (destroy, or a revocation). Safe to call twice. */
544
+ stop() {
545
+ this.stopped = true;
546
+ this.clearTimers();
547
+ const ws = this.ws;
548
+ this.ws = null;
549
+ if (ws) {
550
+ ws.onopen = null;
551
+ ws.onmessage = null;
552
+ ws.onclose = null;
553
+ ws.onerror = null;
554
+ try {
555
+ ws.close();
556
+ } catch {
557
+ }
558
+ }
559
+ }
560
+ /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
561
+ restart() {
562
+ this.stop();
563
+ this.projection = null;
564
+ this.version = null;
565
+ this.attempt = 0;
566
+ this.start();
567
+ }
568
+ // ---- connection -----------------------------------------------------------
569
+ async connect() {
570
+ if (this.stopped) return;
571
+ let protocols = [SEATLAYER_V1];
572
+ if (this.opts.mintTicket) {
573
+ let minted;
574
+ try {
575
+ minted = await this.opts.mintTicket();
576
+ } catch (err) {
577
+ this.reportIfAccessError(err);
578
+ this.scheduleReconnect();
579
+ return;
580
+ }
581
+ if (this.stopped) return;
582
+ if (minted?.protocols?.length) {
583
+ protocols = [...minted.protocols];
584
+ if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
585
+ } else if (minted?.ticket) {
586
+ protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
587
+ }
588
+ }
589
+ const offeredResume = this.version !== null;
590
+ if (offeredResume) protocols.push(`sv.${this.version}`);
591
+ const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
592
+ assertCredentialFreeUrl(url);
593
+ let ws;
594
+ try {
595
+ const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
596
+ ws = make(url, protocols);
597
+ } catch {
598
+ this.scheduleReconnect();
599
+ return;
600
+ }
601
+ this.ws = ws;
602
+ ws.onopen = () => {
603
+ if (this.ws !== ws) return;
604
+ this.attempt = 0;
605
+ this.v1 = ws.protocol === SEATLAYER_V1;
606
+ if (!this.v1) this.useQueryMarker = true;
607
+ this.startKeepalive(ws);
608
+ if (offeredResume) {
609
+ this.resumeTimer = setTimeout(() => {
610
+ this.resumeTimer = null;
611
+ void this.opts.sink.resync();
612
+ }, RESUME_ANSWER_GRACE_MS);
613
+ } else {
614
+ void this.opts.sink.resync();
615
+ }
616
+ };
617
+ ws.onmessage = (event) => {
618
+ if (this.ws !== ws) return;
619
+ let parsed;
620
+ try {
621
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
622
+ } catch {
623
+ return;
624
+ }
625
+ if (!parsed || typeof parsed !== "object") return;
626
+ this.handleFrame(parsed);
627
+ };
628
+ ws.onclose = (event) => {
629
+ if (this.ws !== ws) return;
630
+ this.ws = null;
631
+ this.clearTimers();
632
+ if (event?.code === CLOSE_ACCESS_REVOKED) {
633
+ this.stopped = true;
634
+ this.opts.onAccessUnavailable?.({
635
+ reason: "revoked",
636
+ code: "access_revoked",
637
+ retryable: false
638
+ });
639
+ return;
640
+ }
641
+ this.scheduleReconnect();
642
+ };
643
+ ws.onerror = () => {
644
+ try {
645
+ ws.close();
646
+ } catch {
647
+ }
648
+ };
649
+ }
650
+ handleFrame(frame) {
651
+ const type = typeof frame.type === "string" ? frame.type : "";
652
+ if (frame.protocol === 1) this.v1 = true;
653
+ if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
654
+ if (type === "pong") {
655
+ this.clearPongTimer();
656
+ return;
657
+ }
658
+ if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
659
+ const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
660
+ const closed = Array.isArray(frame.closed) ? frame.closed : [];
661
+ const hKey = hidden.join(SEP);
662
+ const cKey = closed.join(SEP);
663
+ if (hKey !== this.hidden || cKey !== this.closedSections) {
664
+ this.hidden = hKey;
665
+ this.closedSections = cKey;
666
+ this.opts.sink.onSections?.(hidden, closed);
667
+ }
668
+ }
669
+ if (type === "hidden") return;
670
+ if (type === "presence") {
671
+ this.opts.sink.onPresence?.({
672
+ shoppingSessions: Number(frame.shoppingSessions) || 0,
673
+ activeHolds: Number(frame.activeHolds) || 0
674
+ });
675
+ return;
676
+ }
677
+ if (type === "allocation") {
678
+ return;
679
+ }
680
+ if (type === "snapshot" || !type && frame.seats) {
681
+ this.answered();
682
+ const next = projectionFromSnapshot(frame);
683
+ const changes = diffProjections(this.projection, next);
684
+ this.projection = next;
685
+ if (changes === null) void this.opts.sink.resync();
686
+ else if (changes.length) this.opts.sink.applyStatuses(changes);
687
+ return;
688
+ }
689
+ if (type === "delta" && Array.isArray(frame.changes)) {
690
+ this.answered();
691
+ const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
692
+ if (!changes.length) return;
693
+ if (this.projection) applyChanges(this.projection, changes);
694
+ this.opts.sink.applyStatuses(changes);
695
+ }
696
+ }
697
+ /** The server answered our resume; cancel the fallback resync. */
698
+ answered() {
699
+ if (!this.resumeTimer) return;
700
+ clearTimeout(this.resumeTimer);
701
+ this.resumeTimer = null;
702
+ }
703
+ reportIfAccessError(err) {
704
+ const reason = err?.reason;
705
+ if (err?.name !== "BuyerAccessUnavailableError") return;
706
+ this.stopped = true;
707
+ this.opts.onAccessUnavailable?.({
708
+ reason: reason ?? "invalid",
709
+ code: err.code,
710
+ status: err.status,
711
+ retryable: reason === "paused"
712
+ });
713
+ }
714
+ // ---- keepalive & backoff --------------------------------------------------
715
+ /**
716
+ * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
717
+ * for minutes is the normal, correct state for a narrowly-scoped buyer on a
718
+ * busy event (protocol doc §5), so quiet time never triggers a reconnect.
719
+ */
720
+ startKeepalive(ws) {
721
+ this.pingTimer = setInterval(() => {
722
+ if (this.ws !== ws) return;
723
+ try {
724
+ ws.send(JSON.stringify({ type: "ping" }));
725
+ } catch {
726
+ return;
727
+ }
728
+ this.clearPongTimer();
729
+ this.pongTimer = setTimeout(() => {
730
+ this.pongTimer = null;
731
+ try {
732
+ ws.close();
733
+ } catch {
734
+ }
735
+ }, PONG_GRACE_MS);
736
+ }, PING_INTERVAL_MS);
737
+ }
738
+ scheduleReconnect() {
739
+ if (this.stopped || this.reconnectTimer) return;
740
+ const attempt = Math.min(this.attempt++, 5);
741
+ const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
742
+ this.reconnectTimer = setTimeout(() => {
743
+ this.reconnectTimer = null;
744
+ void this.connect();
745
+ }, delay);
746
+ }
747
+ clearPongTimer() {
748
+ if (!this.pongTimer) return;
749
+ clearTimeout(this.pongTimer);
750
+ this.pongTimer = null;
751
+ }
752
+ clearTimers() {
753
+ if (this.pingTimer) clearInterval(this.pingTimer);
754
+ this.pingTimer = null;
755
+ this.clearPongTimer();
756
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
757
+ this.reconnectTimer = null;
758
+ if (this.resumeTimer) clearTimeout(this.resumeTimer);
759
+ this.resumeTimer = null;
760
+ }
132
761
  };
762
+ function rendererStatus(wire) {
763
+ if (wire === "blocked") return "not_for_sale";
764
+ if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
765
+ return "free";
766
+ }
767
+ function createControllerSink(controller, options = {}) {
768
+ const idsForLabel = (label) => {
769
+ const table = controller.tableSelection(label);
770
+ if (table) return table.physicalSeatIds;
771
+ const id = controller.idForLabel(label);
772
+ return id ? [id] : [];
773
+ };
774
+ return {
775
+ applyStatuses(changes) {
776
+ const held = controller.currentHold()?.labels ?? [];
777
+ const buckets = {
778
+ free: [],
779
+ held: [],
780
+ booked: [],
781
+ not_for_sale: []
782
+ };
783
+ const flashes = [];
784
+ const lost = [];
785
+ const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
786
+ for (const change of changes) {
787
+ const ids = idsForLabel(change.label);
788
+ if (!ids.length) continue;
789
+ const next = rendererStatus(change.status);
790
+ buckets[next].push(...ids);
791
+ if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
792
+ const color = next === "held" ? "#f4b740" : "#f43f5e";
793
+ for (const id of ids) flashes.push({ id, color });
794
+ }
795
+ if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
796
+ lost.push(change.label);
797
+ }
798
+ }
799
+ for (const status of ["free", "held", "booked", "not_for_sale"]) {
800
+ if (buckets[status].length) controller.setStatus(buckets[status], status);
801
+ }
802
+ for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
803
+ if (lost.length) {
804
+ const ids = lost.flatMap((label) => idsForLabel(label));
805
+ if (ids.length) controller.deselect(ids);
806
+ const ineligible = changes.some(
807
+ (c) => c.status === "blocked" && lost.includes(c.label)
808
+ );
809
+ options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
810
+ }
811
+ options.onStatusChange?.();
812
+ },
813
+ async resync() {
814
+ await controller.refresh();
815
+ },
816
+ /**
817
+ * Section availability moved. Statuses are re-pulled so the map repaints.
818
+ *
819
+ * Known limit: rebuilding the chart when a section is newly HIDDEN (its
820
+ * seats are stripped, not greyed) lives inside PickerController's own
821
+ * socket handler and has no public entry point, so an access-scoped picker
822
+ * repaints statuses but does not restructure the chart until its next
823
+ * mount. Closing/opening a section — the common mid-sale move — is a
824
+ * status-level change and is handled here in full.
825
+ */
826
+ onSections(hidden, closed) {
827
+ void controller.refresh();
828
+ options.onSections?.(hidden, closed);
829
+ }
830
+ };
831
+ }
133
832
 
134
833
  // src/seatLayerBrand.ts
135
834
  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>';
@@ -157,12 +856,21 @@ var SeatingChart = class {
157
856
  this.tipEl = null;
158
857
  this.tipPos = { x: 0, y: 0 };
159
858
  this.onTipMove = null;
859
+ this.realtime = null;
160
860
  if (!options || typeof options !== "object") throw new Error("seatmap: options object is required");
161
861
  if (!options.container) throw new Error("seatmap: `container` is required");
162
862
  if (!options.event || typeof options.event !== "string") throw new Error("seatmap: `event` key is required");
163
863
  this.opts = options;
164
864
  this.publicKey = options.publicKey;
165
- const api = new PubApi((options.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, ""));
865
+ this.access = createBuyerAccessContext(options, {
866
+ onExpired: (event) => this.opts.onAccessExpired?.(event),
867
+ onUnavailable: (event) => this.opts.onAccessUnavailable?.(event)
868
+ });
869
+ const api = new PubApi((options.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, ""), {
870
+ access: this.access ?? void 0,
871
+ onObjectUnavailable: (event) => this.opts.onSelectedObjectUnavailable?.(event)
872
+ });
873
+ this.api = api;
166
874
  this.controller = new import_core.PickerController({
167
875
  transport: api,
168
876
  eventKey: options.event,
@@ -208,6 +916,7 @@ var SeatingChart = class {
208
916
  return this;
209
917
  }
210
918
  this.controller.setViewMode(this.opts.initialView ?? "flat");
919
+ this.startRealtime();
211
920
  this.mode_ = info.mode === "test" ? "test" : "live";
212
921
  if (this.opts.seatTooltip !== false) {
213
922
  const tip = document.createElement("div");
@@ -459,7 +1168,45 @@ var SeatingChart = class {
459
1168
  return this.controller.releaseLabels(labels);
460
1169
  }
461
1170
  /** Tear everything down: close the socket, stop timers, drop the canvas. */
1171
+ /**
1172
+ * Realtime for an access-scoped chart.
1173
+ *
1174
+ * A tokenless chart never gets here: `access` is null, `PubApi.socketUrl()`
1175
+ * returns the URL it always has, and PickerController keeps its own socket
1176
+ * and its own legacy frames. Nothing about the public path changes.
1177
+ */
1178
+ startRealtime() {
1179
+ if (!this.access?.configured || this.realtime) return;
1180
+ this.realtime = new BuyerRealtimeClient({
1181
+ url: this.api.subscribeUrl(this.opts.event),
1182
+ mintTicket: () => this.api.subscribeTicket(this.opts.event),
1183
+ onAccessUnavailable: (event) => this.opts.onAccessUnavailable?.(event),
1184
+ sink: createControllerSink(this.controller, {
1185
+ flashOnLiveChange: true,
1186
+ onSelectedObjectUnavailable: (labels, reason) => this.opts.onSelectedObjectUnavailable?.({ labels, reason })
1187
+ })
1188
+ });
1189
+ this.realtime.start();
1190
+ }
1191
+ /**
1192
+ * Re-acquire the buyer access session — call after your app has re-authorized
1193
+ * the buyer (a revoked session cannot be recovered any other way). Resolves
1194
+ * true when a fresh bearer is held; the realtime feed restarts with it.
1195
+ */
1196
+ async refreshAccess() {
1197
+ if (!this.access?.configured) return false;
1198
+ const ok = await this.access.refresh("manual");
1199
+ if (ok) {
1200
+ await this.controller.refresh();
1201
+ this.realtime?.restart();
1202
+ if (!this.realtime) this.startRealtime();
1203
+ }
1204
+ return ok;
1205
+ }
462
1206
  destroy() {
1207
+ this.realtime?.stop();
1208
+ this.realtime = null;
1209
+ this.access?.clear();
463
1210
  if (this.hostEl && this.onTipMove) this.hostEl.removeEventListener("mousemove", this.onTipMove);
464
1211
  this.tipEl = null;
465
1212
  this.onTipMove = null;
@@ -1180,7 +1927,12 @@ var STYLE_ID = "seatlayer-picker-style";
1180
1927
  var CSS = `
1181
1928
  .sl-picker{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:420px;overflow:hidden;
1182
1929
  background:var(--sl-bg);color:var(--sl-text);font-family:var(--sl-font);border-radius:var(--sl-radius);
1183
- --sl-r-sm:calc(var(--sl-radius) * .55)}
1930
+ --sl-r-sm:calc(var(--sl-radius) * .55);
1931
+ /* Motion tokens, defined ON the widget root so an embed is self-contained and
1932
+ never inherits (or fights) the host page's own timing. Values mirror
1933
+ docs/motion-system-2026-08-01.md \xA72. */
1934
+ --slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;
1935
+ --slm-mo-out:cubic-bezier(0.2,0.8,0.2,1);--slm-mo-exit:cubic-bezier(0.4,0,1,1)}
1184
1936
  .sl-picker *{box-sizing:border-box;margin:0;padding:0}
1185
1937
  .sl-picker button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
1186
1938
 
@@ -1884,9 +2636,24 @@ var CSS = `
1884
2636
  @keyframes slConfirmBelowIn{from{opacity:0;transform:translate(-50%,8px) scale(.96)}to{opacity:1;transform:translate(-50%,16px) scale(1)}}
1885
2637
  @keyframes slConfirmMobileIn{from{opacity:0;transform:translate(-50%,10px) scale(.97)}to{opacity:1;transform:translate(-50%,0) scale(1)}}
1886
2638
 
2639
+ /* Access state (channels): the panel fades AND rises at --slm-mo-base. It never
2640
+ covers the map \u2014 inventory is cross-faded to neutral by the canvas in one
2641
+ batched pass, so nothing blinks away underneath it. */
2642
+ .sl-access{position:absolute;left:50%;bottom:18px;z-index:9;transform:translateX(-50%);
2643
+ max-width:min(420px,calc(100% - 24px));display:flex;gap:12px;align-items:flex-start;
2644
+ padding:12px 14px;border-radius:var(--sl-r-sm);background:var(--sl-panel,#151b2c);color:var(--sl-text);
2645
+ border:1px solid var(--sl-line);box-shadow:0 18px 44px -18px rgba(0,0,0,.6);
2646
+ animation:slAccessIn var(--slm-mo-base) var(--slm-mo-out) both}
2647
+ .sl-access-title{font-weight:700;font-size:13px}
2648
+ .sl-access-body{font-size:12px;line-height:1.5;opacity:.82;margin-top:2px}
2649
+ .sl-access-act{margin-top:8px;padding:6px 12px;border-radius:999px;font-size:12px;font-weight:700;
2650
+ background:var(--sl-accent);color:var(--sl-accent-ink)}
2651
+ @keyframes slAccessIn{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
2652
+
1887
2653
  @media(prefers-reduced-motion:reduce){
1888
2654
  .sl-picker *,.sl-modal-scrim *{animation-duration:.001ms!important;animation-iteration-count:1!important;
1889
2655
  transition-duration:.001ms!important;scroll-behavior:auto!important}
2656
+ .sl-access{animation:none;opacity:1;transform:translate(-50%,0)}
1890
2657
  }
1891
2658
  .sl-ba [data-ba-zone]{grid-column:1/-1;width:100%}
1892
2659
 
@@ -1935,6 +2702,8 @@ function writeStoredColorblind(on) {
1935
2702
  }
1936
2703
  var SeatPicker = class _SeatPicker {
1937
2704
  constructor(options) {
2705
+ this.realtime = null;
2706
+ this.accessEl = null;
1938
2707
  this.root = null;
1939
2708
  this.mapHost = null;
1940
2709
  this.rendered = false;
@@ -2056,7 +2825,21 @@ var SeatPicker = class _SeatPicker {
2056
2825
  if (!options.container) throw new Error("seatmap: `container` is required (or use SeatPicker.open())");
2057
2826
  this.opts = { ...options, confirmSelection: options.confirmSelection ?? true };
2058
2827
  this.apiBase = (options.apiBase ?? DEFAULT_API_BASE2).replace(/\/+$/, "");
2059
- this.api = options.transport ?? new PubApi(this.apiBase);
2828
+ this.access = options.transport ? null : createBuyerAccessContext(options, {
2829
+ onExpired: (event) => {
2830
+ this.opts.onAccessExpired?.(event);
2831
+ if (!event.refreshed) this.showAccessPanel({ reason: "no_token", retryable: false });
2832
+ },
2833
+ onUnavailable: (event) => {
2834
+ this.opts.onAccessUnavailable?.(event);
2835
+ this.showAccessPanel(event);
2836
+ }
2837
+ });
2838
+ this.pubApi = options.transport ? null : new PubApi(this.apiBase, {
2839
+ access: this.access ?? void 0,
2840
+ onObjectUnavailable: (event) => this.opts.onSelectedObjectUnavailable?.(event)
2841
+ });
2842
+ this.api = options.transport ?? this.pubApi;
2060
2843
  this.maxTickets = Math.max(1, Math.floor(options.maxSelection ?? DEFAULT_MAX_SELECTION2));
2061
2844
  this.cbSafe = readStoredColorblind() ?? !!options.colorblindSafe;
2062
2845
  this.controller = new import_core2.PickerController({
@@ -2600,6 +3383,7 @@ var SeatPicker = class _SeatPicker {
2600
3383
  return this;
2601
3384
  }
2602
3385
  this.els.boot.remove();
3386
+ this.startRealtime();
2603
3387
  this.salesClosed = !!info.salesClosed;
2604
3388
  this.controller.setViewMode(this.normalizeInitialView(this.opts.initialView));
2605
3389
  this.buildRegions();
@@ -3530,7 +4314,7 @@ var SeatPicker = class _SeatPicker {
3530
4314
  this.tableDialog = { ...table };
3531
4315
  this.tableDialogHeld = held;
3532
4316
  this.tableDialogReturnFocus = returnFocus ?? document.activeElement;
3533
- const esc2 = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
4317
+ const esc3 = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
3534
4318
  "&": "&amp;",
3535
4319
  "<": "&lt;",
3536
4320
  ">": "&gt;",
@@ -3542,7 +4326,7 @@ var SeatPicker = class _SeatPicker {
3542
4326
  const typeWord = this.rowTypeWord(table);
3543
4327
  const el = document.createElement("div");
3544
4328
  el.className = "sl-table-scrim";
3545
- 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">${esc2(variable ? `Flexible party \xB7 ${typeWord}` : `Whole ${typeWord}`)}</div><h2 class="sl-table-title" id="sl-table-title">${esc2(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>${esc2(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>`;
4329
+ 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>`;
3546
4330
  this.root.appendChild(el);
3547
4331
  this.tableDialogEl = el;
3548
4332
  this.renderTableDialogState();
@@ -4011,7 +4795,7 @@ var SeatPicker = class _SeatPicker {
4011
4795
  (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>`);
4012
4796
  }
4013
4797
  const idGrid = (seatId, label, objectType, quantity = 1, objectId, identity) => {
4014
- const esc2 = (value) => String(value ?? "\u2014").replace(/[&<>"]/g, (char) => ({
4798
+ const esc3 = (value) => String(value ?? "\u2014").replace(/[&<>"]/g, (char) => ({
4015
4799
  "&": "&amp;",
4016
4800
  "<": "&lt;",
4017
4801
  ">": "&gt;",
@@ -4023,18 +4807,18 @@ var SeatPicker = class _SeatPicker {
4023
4807
  const typeWord = identity?.displayType?.trim() || d?.displayType?.trim() || area?.displayType?.trim() || (effectiveType === "table" ? "Table" : effectiveType === "booth" ? "Booth" : effectiveType === "ga" ? "General admission" : "Row");
4024
4808
  const buyerName = identity?.rowLabel ?? identity?.displayLabel ?? d?.rowLabel ?? d?.displayLabel ?? area?.displayLabel ?? area?.label ?? label;
4025
4809
  if (effectiveType === "table" && identity?.bookingMode) {
4026
- return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${esc2(typeWord)}</span><span class="val">${esc2(buyerName)}</span></span><span class="fld mid"><span class="sl-chip-eb">Guests</span><span class="val">${quantity}</span></span></div>`;
4810
+ 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>`;
4027
4811
  }
4028
4812
  if (effectiveType === "ga") {
4029
- return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${esc2(typeWord)}</span><span class="val">${esc2(buyerName)}</span></span>` + (quantity > 1 ? `<span class="fld mid"><span class="sl-chip-eb">Tickets</span><span class="val">${quantity}</span></span>` : "") + `</div>`;
4813
+ 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>`;
4030
4814
  }
4031
4815
  if (effectiveType === "booth") {
4032
- return `<div class="sl-chip-id">` + (d?.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${esc2(d.sectionLabel)}</span></span>` : "") + `<span class="fld mid"><span class="sl-chip-eb">${esc2(typeWord)}</span><span class="val">${esc2(buyerName)}</span></span></div>`;
4816
+ 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>`;
4033
4817
  }
4034
4818
  if (!d?.sectionLabel && !d?.rowLabel && !d?.seatNumber) {
4035
- return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">Seat</span><span class="val">${esc2(buyerName)}</span></span></div>`;
4819
+ 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>`;
4036
4820
  }
4037
- return `<div class="sl-chip-id">` + (d.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${esc2(d.sectionLabel)}</span></span>` : "") + (d.rowLabel ? `<span class="fld mid"><span class="sl-chip-eb">${esc2(typeWord)}</span><span class="val">${esc2(this.rowShort(d))}</span></span>` : "") + (d.seatNumber ? `<span class="fld mid"><span class="sl-chip-eb">Seat</span><span class="val">${esc2(d.seatNumber)}</span></span>` : "") + `</div>`;
4821
+ 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>`;
4038
4822
  };
4039
4823
  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="${(0, import_core2.t)("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>`;
4040
4824
  for (const item of heldItems) {
@@ -4529,19 +5313,19 @@ var SeatPicker = class _SeatPicker {
4529
5313
  this.tipEl.style.display = "none";
4530
5314
  return;
4531
5315
  }
4532
- const esc2 = (v) => String(v ?? "\u2014").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]);
5316
+ const esc3 = (v) => String(v ?? "\u2014").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]);
4533
5317
  const price = this.money(this.paidPrice(details.categoryKey, details.tierId ?? null, details.price));
4534
5318
  const isGroupedTable = details.objectType === "table" && !!details.bookingMode;
4535
5319
  const isBooth = details.objectType === "booth";
4536
5320
  const hasLoc = details.sectionLabel || details.rowLabel || details.seatNumber;
4537
- const grid = isGroupedTable ? `<div class="sl-tip-grid"><div class="sl-tip-field"><span class="sl-tip-key">${esc2(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc2(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">${esc2(details.sectionLabel)}</span></div>` : "") + `<div class="sl-tip-field"><span class="sl-tip-key">${esc2(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc2(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">${esc2(details.sectionLabel)}</span></div>` : "") + (details.rowLabel ? `<div class="sl-tip-field"><span class="sl-tip-key">${esc2(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc2(this.rowShort(details))}</span></div>` : "") + (details.seatNumber ? `<div class="sl-tip-field"><span class="sl-tip-key">Seat</span><span class="sl-tip-val">${esc2(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">${esc2(details.displayLabel ?? details.label)}</span></div></div>`;
5321
+ 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>`;
4538
5322
  const statusLine = details.status === "free" ? "" : `<div class="sl-tip-status">${details.status === "held" ? (0, import_core2.t)("map.statusHeld") : (0, import_core2.t)("map.statusTaken")}</div>`;
4539
5323
  const limited = this.limitedViewLabel(details.commercial);
4540
- const cxLine = limited ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u25D0</span>${esc2(limited)}</div>` : "";
5324
+ const cxLine = limited ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u25D0</span>${esc3(limited)}</div>` : "";
4541
5325
  const wheelchair = this.wheelchairProvisionLabel(details.wheelchairSpaceType);
4542
- const wheelchairLine = wheelchair ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u267F</span>${esc2(wheelchair)}</div>` : "";
5326
+ const wheelchairLine = wheelchair ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u267F</span>${esc3(wheelchair)}</div>` : "";
4543
5327
  this.tipEl.style.setProperty("--sl-cat", details.categoryColor);
4544
- this.tipEl.innerHTML = grid + `<div class="sl-tip-cat"><span class="sl-tip-dot" style="background:${details.categoryColor}"></span><span class="sl-tip-name">${esc2(details.categoryLabel)}</span><span class="sl-tip-amt">${price}</span></div>` + wheelchairLine + cxLine + statusLine;
5328
+ 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;
4545
5329
  this.tipEl.style.display = "block";
4546
5330
  this.placeTooltip();
4547
5331
  }
@@ -4939,8 +5723,154 @@ var SeatPicker = class _SeatPicker {
4939
5723
  this.syncTray();
4940
5724
  this.emitHoldChange();
4941
5725
  }
5726
+ // ---- buyer access (Sales Channels) ---------------------------------------
5727
+ /**
5728
+ * Realtime for an access-scoped picker.
5729
+ *
5730
+ * A tokenless picker never gets here: `access` is null, `PubApi.socketUrl()`
5731
+ * returns the URL it always has, and PickerController keeps its own socket
5732
+ * and its own legacy frames. Nothing about the public path changes.
5733
+ */
5734
+ startRealtime() {
5735
+ if (!this.access?.configured || !this.pubApi || this.realtime) return;
5736
+ const event = this.opts.event;
5737
+ this.realtime = new BuyerRealtimeClient({
5738
+ url: this.pubApi.subscribeUrl(event),
5739
+ mintTicket: () => this.pubApi.subscribeTicket(event),
5740
+ onAccessUnavailable: (state) => {
5741
+ this.opts.onAccessUnavailable?.(state);
5742
+ this.showAccessPanel(state);
5743
+ },
5744
+ sink: createControllerSink(this.controller, {
5745
+ flashOnLiveChange: true,
5746
+ onStatusChange: () => {
5747
+ this.syncPrices();
5748
+ this.detectBooked();
5749
+ this.refreshMinimap();
5750
+ this.pushAvailabilityTo3d();
5751
+ },
5752
+ onSelectedObjectUnavailable: (labels, reason) => {
5753
+ this.opts.onSelectedObjectUnavailable?.({ labels, reason });
5754
+ this.syncTray();
5755
+ this.toast(
5756
+ reason === "ineligible" ? this.tf(
5757
+ "picker.seatNoLongerYours",
5758
+ "Some seats are no longer available to you. They have been removed from your order."
5759
+ ) : this.tf(
5760
+ "picker.seatTaken",
5761
+ "Someone else took a seat you had picked. It has been removed from your order."
5762
+ ),
5763
+ "warning"
5764
+ );
5765
+ }
5766
+ })
5767
+ });
5768
+ this.realtime.start();
5769
+ }
5770
+ /**
5771
+ * Re-acquire the buyer access session — call after your app has re-authorized
5772
+ * the buyer. A revoked session cannot recover any other way. Resolves true
5773
+ * when a fresh bearer is held; the map and the realtime feed resume with it.
5774
+ */
5775
+ async refreshAccess() {
5776
+ if (!this.access?.configured) return false;
5777
+ const ok = await this.access.refresh("manual");
5778
+ if (!ok) return false;
5779
+ this.dismissAccessPanel();
5780
+ await this.controller.refresh();
5781
+ if (this.realtime) this.realtime.restart();
5782
+ else this.startRealtime();
5783
+ return true;
5784
+ }
5785
+ /**
5786
+ * The buyer-facing access state. Plain language, no internal vocabulary, and
5787
+ * never a channel name, id or count — the buyer is told what happened and
5788
+ * what to do, not which allocation they missed (guide §7, §10).
5789
+ *
5790
+ * Held seats are deliberately left alone: a hold is relinquished by its own
5791
+ * opaque capability, not by channel access, so losing access never strands
5792
+ * inventory and never silently drops a buyer's cart (guide §9).
5793
+ */
5794
+ showAccessPanel(state) {
5795
+ if (this.destroyed || !this.root) return;
5796
+ const copy = this.accessCopy(state.reason);
5797
+ this.dismissAccessPanel();
5798
+ const panel = document.createElement("div");
5799
+ panel.className = "sl-access";
5800
+ panel.setAttribute("role", "status");
5801
+ panel.setAttribute("aria-live", "polite");
5802
+ const text = document.createElement("div");
5803
+ const title = document.createElement("div");
5804
+ title.className = "sl-access-title";
5805
+ title.textContent = copy.title;
5806
+ const body = document.createElement("div");
5807
+ body.className = "sl-access-body";
5808
+ body.textContent = copy.body;
5809
+ text.appendChild(title);
5810
+ text.appendChild(body);
5811
+ if (copy.action) {
5812
+ const button = document.createElement("button");
5813
+ button.type = "button";
5814
+ button.className = "sl-access-act";
5815
+ button.textContent = copy.action;
5816
+ button.addEventListener("click", () => {
5817
+ void this.refreshAccess();
5818
+ });
5819
+ text.appendChild(button);
5820
+ }
5821
+ panel.appendChild(text);
5822
+ (this.regions?.["bottom-center"] ?? this.root).appendChild(panel);
5823
+ this.accessEl = panel;
5824
+ }
5825
+ dismissAccessPanel() {
5826
+ this.accessEl?.remove();
5827
+ this.accessEl = null;
5828
+ }
5829
+ accessCopy(reason) {
5830
+ switch (reason) {
5831
+ case "paused":
5832
+ return {
5833
+ title: this.tf("picker.accessPausedTitle", "These seats are on hold right now"),
5834
+ body: this.tf(
5835
+ "picker.accessPausedBody",
5836
+ "The organizer has paused this selection. Try again in a few minutes."
5837
+ ),
5838
+ action: this.tf("picker.accessRetry", "Try again")
5839
+ };
5840
+ case "revoked":
5841
+ return {
5842
+ title: this.tf("picker.accessRevokedTitle", "This access link is no longer active"),
5843
+ body: this.tf(
5844
+ "picker.accessRevokedBody",
5845
+ "Ask whoever sent you here for a new link to keep booking these seats."
5846
+ )
5847
+ };
5848
+ case "no_token":
5849
+ case "provider_failed":
5850
+ return {
5851
+ title: this.tf("picker.accessExpiredTitle", "Your access session has ended"),
5852
+ body: this.tf(
5853
+ "picker.accessExpiredBody",
5854
+ "Sign in again, or reload the page, to keep browsing these seats. Anything you are already holding stays yours."
5855
+ ),
5856
+ action: this.tf("picker.accessRetry", "Try again")
5857
+ };
5858
+ default:
5859
+ return {
5860
+ title: this.tf("picker.accessInvalidTitle", "We couldn\u2019t verify your access"),
5861
+ body: this.tf(
5862
+ "picker.accessInvalidBody",
5863
+ "You can still book anything shown as available. Contact whoever sent you here for access to the rest."
5864
+ )
5865
+ };
5866
+ }
5867
+ }
4942
5868
  destroy() {
4943
5869
  this.destroyed = true;
5870
+ this.realtime?.stop();
5871
+ this.realtime = null;
5872
+ this.dismissAccessPanel();
5873
+ this.access?.clear();
4944
5874
  if (this.hold && !this.handedOff) void this.controller.release();
4945
5875
  this.closeConfirm();
4946
5876
  this.dismissTableDialog(false);
@@ -5058,28 +5988,246 @@ function attachPickerFrame(iframe, opts = {}) {
5058
5988
  // src/SeatManager.ts
5059
5989
  var import_core3 = require("@seatlayer/core");
5060
5990
 
5061
- // src/manageApi.ts
5062
- var ManageApiError = class extends Error {
5063
- constructor(status, message, code, conflicts) {
5064
- super(message);
5065
- this.name = "ManageApiError";
5066
- this.status = status;
5067
- this.code = code;
5068
- this.conflicts = conflicts;
5069
- }
5070
- };
5071
- async function parse(res) {
5072
- const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
5073
- const data = isJson ? await res.json().catch(() => null) : null;
5074
- if (!res.ok) {
5075
- const err = data;
5076
- throw new ManageApiError(res.status, err?.error ?? `request_failed_${res.status}`, err?.code, err?.conflicts);
5991
+ // src/channelPlan.ts
5992
+ var PUBLIC_CHANNEL_ID = "";
5993
+ var PUBLIC_CHANNEL_NAME = "Public sale";
5994
+ var CHANNEL_COLORS = [
5995
+ "#a78bfa",
5996
+ "#2dd4bf",
5997
+ "#fb923c",
5998
+ "#60a5fa",
5999
+ "#f472b6",
6000
+ "#a3e635",
6001
+ "#f87171",
6002
+ "#38bdf8",
6003
+ "#c084fc",
6004
+ "#facc15"
6005
+ ];
6006
+ var PUBLIC_CHANNEL_COLOR = "#f4b740";
6007
+ var LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
6008
+ function suggestMarker(name, taken) {
6009
+ const used = new Set([...taken].map((m) => m.trim().toUpperCase()).filter(Boolean));
6010
+ const first = (name.trim()[0] ?? "").toUpperCase();
6011
+ const letter = LETTERS.includes(first) && !used.has(first) ? first : [...LETTERS].find((candidate) => !used.has(candidate)) ?? (first || "X");
6012
+ return { letter, color: CHANNEL_COLORS[used.size % CHANNEL_COLORS.length] };
6013
+ }
6014
+ function markerOf(channel, index = 0) {
6015
+ if (channel.id === PUBLIC_CHANNEL_ID) {
6016
+ return { letter: (channel.marker || "P").slice(0, 2).toUpperCase(), color: channel.color || PUBLIC_CHANNEL_COLOR };
5077
6017
  }
5078
- return data;
6018
+ const letter = (channel.marker || channel.name.trim()[0] || "?").slice(0, 2).toUpperCase();
6019
+ return { letter, color: channel.color || CHANNEL_COLORS[index % CHANNEL_COLORS.length] };
5079
6020
  }
5080
- var ManageApi = class {
5081
- constructor(apiBase, token) {
5082
- this.base = apiBase.replace(/\/+$/, "");
6021
+ function selectionSources(labels, allocation, list) {
6022
+ const counts = /* @__PURE__ */ new Map();
6023
+ for (const label of labels) {
6024
+ const channelId = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
6025
+ counts.set(channelId, (counts.get(channelId) ?? 0) + 1);
6026
+ }
6027
+ const order = [
6028
+ { id: PUBLIC_CHANNEL_ID, name: list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
6029
+ ...(list?.channels ?? []).map((channel) => ({ id: channel.id, name: channel.name }))
6030
+ ];
6031
+ const rows = [];
6032
+ for (const entry of order) {
6033
+ const count = counts.get(entry.id);
6034
+ if (count) rows.push({ channelId: entry.id, name: entry.name, count });
6035
+ counts.delete(entry.id);
6036
+ }
6037
+ for (const [channelId, count] of counts) {
6038
+ rows.push({ channelId, name: channelId ? "Another channel" : PUBLIC_CHANNEL_NAME, count });
6039
+ }
6040
+ return rows;
6041
+ }
6042
+ var SKIP_SAMPLE = 12;
6043
+ function skipBucket(labels) {
6044
+ return {
6045
+ count: labels.length,
6046
+ labels: labels.slice(0, SKIP_SAMPLE),
6047
+ truncated: labels.length > SKIP_SAMPLE
6048
+ };
6049
+ }
6050
+ function planAssignment(input) {
6051
+ const { labels, targetChannelId, allocation, statusOf, nameOf } = input;
6052
+ const seen = /* @__PURE__ */ new Set();
6053
+ let fromPublic = 0;
6054
+ let alreadyIn = 0;
6055
+ const movedBySource = /* @__PURE__ */ new Map();
6056
+ const held = [];
6057
+ const booked = [];
6058
+ const missing = [];
6059
+ for (const label of labels) {
6060
+ if (seen.has(label)) continue;
6061
+ seen.add(label);
6062
+ const status = statusOf(label);
6063
+ if (!status) {
6064
+ missing.push(label);
6065
+ continue;
6066
+ }
6067
+ const current = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
6068
+ if (current === targetChannelId) {
6069
+ alreadyIn += 1;
6070
+ continue;
6071
+ }
6072
+ if (status === "held") {
6073
+ held.push(label);
6074
+ continue;
6075
+ }
6076
+ if (status === "booked") {
6077
+ booked.push(label);
6078
+ continue;
6079
+ }
6080
+ if (current === PUBLIC_CHANNEL_ID) fromPublic += 1;
6081
+ else movedBySource.set(current, (movedBySource.get(current) ?? 0) + 1);
6082
+ }
6083
+ const channels = [...movedBySource.entries()].map(([channelId, count]) => ({
6084
+ channelId,
6085
+ name: nameOf(channelId),
6086
+ count
6087
+ }));
6088
+ return {
6089
+ changedFromPublic: { count: fromPublic },
6090
+ movedFromOtherChannel: {
6091
+ count: channels.reduce((sum, row) => sum + row.count, 0),
6092
+ channels
6093
+ },
6094
+ alreadyInTarget: { count: alreadyIn },
6095
+ skippedHeld: skipBucket(held),
6096
+ skippedBooked: skipBucket(booked),
6097
+ notFound: skipBucket(missing)
6098
+ };
6099
+ }
6100
+ function mutationCount(buckets) {
6101
+ return buckets.changedFromPublic.count + buckets.movedFromOtherChannel.count;
6102
+ }
6103
+ function needsMoveConfirmation(buckets) {
6104
+ return buckets.movedFromOtherChannel.count > 0;
6105
+ }
6106
+ function bucketRows(buckets, targetName) {
6107
+ const rows = [];
6108
+ if (buckets.changedFromPublic.count) {
6109
+ rows.push({
6110
+ kind: "add",
6111
+ icon: "+",
6112
+ count: buckets.changedFromPublic.count,
6113
+ text: `${buckets.changedFromPublic.count.toLocaleString()} from ${PUBLIC_CHANNEL_NAME}`
6114
+ });
6115
+ }
6116
+ for (const source of buckets.movedFromOtherChannel.channels) {
6117
+ rows.push({
6118
+ kind: "move",
6119
+ icon: "\u21C4",
6120
+ count: source.count,
6121
+ text: `${source.count.toLocaleString()} moved out of ${source.name ?? "another channel"}`,
6122
+ why: "needs this confirmation"
6123
+ });
6124
+ }
6125
+ if (buckets.alreadyInTarget.count) {
6126
+ rows.push({
6127
+ kind: "same",
6128
+ icon: "=",
6129
+ count: buckets.alreadyInTarget.count,
6130
+ text: `${buckets.alreadyInTarget.count.toLocaleString()} already in ${targetName}`,
6131
+ why: "unchanged"
6132
+ });
6133
+ }
6134
+ if (buckets.skippedHeld.count) {
6135
+ rows.push({
6136
+ kind: "skip",
6137
+ icon: "\u23F8",
6138
+ count: buckets.skippedHeld.count,
6139
+ text: `${buckets.skippedHeld.count.toLocaleString()} in a buyer's checkout`,
6140
+ why: "can't move while held",
6141
+ peek: peekOf(buckets.skippedHeld)
6142
+ });
6143
+ }
6144
+ if (buckets.skippedBooked.count) {
6145
+ rows.push({
6146
+ kind: "skip",
6147
+ icon: "\u{1F512}",
6148
+ count: buckets.skippedBooked.count,
6149
+ text: `${buckets.skippedBooked.count.toLocaleString()} already sold`,
6150
+ why: "sales are never rewritten",
6151
+ peek: peekOf(buckets.skippedBooked)
6152
+ });
6153
+ }
6154
+ if (buckets.notFound.count) {
6155
+ rows.push({
6156
+ kind: "skip",
6157
+ icon: "?",
6158
+ count: buckets.notFound.count,
6159
+ text: `${buckets.notFound.count.toLocaleString()} not on this map`,
6160
+ why: "these seats are no longer part of the event",
6161
+ peek: peekOf(buckets.notFound)
6162
+ });
6163
+ }
6164
+ return rows;
6165
+ }
6166
+ function peekOf(bucket) {
6167
+ if (!bucket.labels.length) return void 0;
6168
+ const shown = bucket.labels.slice(0, 4).join(", ");
6169
+ return bucket.truncated || bucket.labels.length > 4 ? `${shown}\u2026` : shown;
6170
+ }
6171
+ function retryAfterCopy(details) {
6172
+ const ms = details?.retryAfterMs ?? (details?.latestHoldExpiresAt ? Math.max(0, details.latestHoldExpiresAt - Date.now()) : 0);
6173
+ if (!ms) return "in a moment";
6174
+ const minutes = Math.ceil(ms / 6e4);
6175
+ if (minutes <= 1) return "in about a minute";
6176
+ return `in about ${minutes} minutes`;
6177
+ }
6178
+ function accessLine(access) {
6179
+ if (!access || !access.intent) return "\u2014";
6180
+ 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";
6181
+ const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
6182
+ const detail = access.detail ?? grants;
6183
+ return detail ? `${base} \xB7 ${detail}` : base;
6184
+ }
6185
+ function accessIntentLabel(intent) {
6186
+ 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";
6187
+ }
6188
+ function dropReviewRows(details) {
6189
+ return (details?.channels ?? []).map((channel) => ({
6190
+ kind: "skip",
6191
+ icon: "\u26A0",
6192
+ count: channel.count,
6193
+ text: `${channel.count.toLocaleString()} would leave ${channel.name ?? "a channel"}`,
6194
+ why: "the new chart no longer has these seats",
6195
+ peek: channel.labels?.length ? peekOf({ count: channel.count, labels: channel.labels, truncated: channel.truncated ?? false }) : void 0
6196
+ }));
6197
+ }
6198
+ function stateBadge(state) {
6199
+ return state === "builtin" ? "Built-in" : state === "active" ? "Active" : state === "paused" ? "Paused" : "Archived";
6200
+ }
6201
+
6202
+ // src/manageApi.ts
6203
+ var ManageApiError = class extends Error {
6204
+ constructor(status, message, code, conflicts, details) {
6205
+ super(message);
6206
+ this.name = "ManageApiError";
6207
+ this.status = status;
6208
+ this.code = code;
6209
+ this.conflicts = conflicts;
6210
+ this.details = details;
6211
+ }
6212
+ };
6213
+ async function parse(res) {
6214
+ const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
6215
+ const data = isJson ? await res.json().catch(() => null) : null;
6216
+ if (!res.ok) {
6217
+ const err = data;
6218
+ throw new ManageApiError(
6219
+ res.status,
6220
+ err?.error ?? `request_failed_${res.status}`,
6221
+ err?.code,
6222
+ err?.conflicts,
6223
+ err?.details
6224
+ );
6225
+ }
6226
+ return data;
6227
+ }
6228
+ var ManageApi = class {
6229
+ constructor(apiBase, token) {
6230
+ this.base = apiBase.replace(/\/+$/, "");
5083
6231
  this.token = token;
5084
6232
  }
5085
6233
  /** Swap the Bearer token in place (SeatManager re-mints on 401). */
@@ -5151,6 +6299,97 @@ var ManageApi = class {
5151
6299
  setAvailability(key, rules) {
5152
6300
  return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`, { method: "POST", body: { rules } });
5153
6301
  }
6302
+ // ---- sales channels (token, capability-gated) ----
6303
+ // Reads need `event:channels:view`, mutations `event:channels:manage`.
6304
+ // `event:block` grants NEITHER (spec §10), so a Block-only cockpit token gets
6305
+ // a 403 here and Channels mode never renders.
6306
+ /** Allocation list with exact per-channel counts. `includeArchived` adds the
6307
+ * read-only archived rows behind the rail's "Show archived" control. */
6308
+ channels(key, opts = {}) {
6309
+ const qs = opts.includeArchived ? "?includeArchived=1" : "";
6310
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels${qs}`);
6311
+ }
6312
+ /** One page of the label → channel map that paints the allocation overlay.
6313
+ * Paged by label; follow `nextAfterLabel` until it is null. */
6314
+ channelAllocation(key, opts = {}) {
6315
+ const params = new URLSearchParams();
6316
+ if (opts.afterLabel) params.set("afterLabel", opts.afterLabel);
6317
+ if (opts.limit != null) params.set("limit", String(opts.limit));
6318
+ const qs = params.toString();
6319
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/allocation${qs ? `?${qs}` : ""}`);
6320
+ }
6321
+ channelAudit(key, opts = {}) {
6322
+ const params = new URLSearchParams();
6323
+ if (opts.limit != null) params.set("limit", String(opts.limit));
6324
+ if (opts.before != null) params.set("before", String(opts.before));
6325
+ const qs = params.toString();
6326
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/audit${qs ? `?${qs}` : ""}`);
6327
+ }
6328
+ createChannel(key, input) {
6329
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels`, { method: "POST", body: input });
6330
+ }
6331
+ renameChannel(key, channelId, name) {
6332
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
6333
+ method: "PATCH",
6334
+ body: { name }
6335
+ });
6336
+ }
6337
+ setChannelPaused(key, channelId, paused) {
6338
+ const path = paused ? "pause" : "unpause";
6339
+ return this.auth(
6340
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/${path}`,
6341
+ { method: "POST", body: {} }
6342
+ );
6343
+ }
6344
+ /** Archive with a mandatory destination for the remaining allocation.
6345
+ * Throws ManageApiError 409 `channel_archive_blocked_by_holds` while any hold
6346
+ * is live; `err.details` carries the exact counts + retry window. */
6347
+ archiveChannel(key, channelId, destination) {
6348
+ return this.auth(
6349
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/archive`,
6350
+ { method: "POST", body: { destination } }
6351
+ );
6352
+ }
6353
+ /**
6354
+ * Versioned Apply. A stale `assignmentVersion` mutates NOTHING and throws
6355
+ * ManageApiError 409 `channel_assignment_conflict` — the caller keeps its
6356
+ * selection and offers "Refresh and review". There is no dry-run: the review
6357
+ * sheet previews locally, this call returns the authoritative buckets.
6358
+ */
6359
+ applyChannelAssignment(key, input) {
6360
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/assignments`, {
6361
+ method: "POST",
6362
+ body: {
6363
+ targetChannelId: input.targetChannelId || null,
6364
+ labels: input.labels,
6365
+ assignmentVersion: input.assignmentVersion
6366
+ }
6367
+ });
6368
+ }
6369
+ /**
6370
+ * Read-only buyer projection for an audience (§8.6) — the SAME scoped server
6371
+ * view the buyer SDK receives, never a local approximation.
6372
+ *
6373
+ * Ships on the access-hardening branch. Older workers 404/405 here; callers
6374
+ * MUST feature-detect and quietly say the preview needs a newer server rather
6375
+ * than faking a projection client-side.
6376
+ */
6377
+ channelPreview(key, channelIds, opts = {}) {
6378
+ const params = new URLSearchParams();
6379
+ if (channelIds.length) params.set("channelIds", channelIds.join(","));
6380
+ if (opts.includePublic != null) params.set("includePublic", opts.includePublic ? "1" : "0");
6381
+ const qs = params.toString();
6382
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/preview${qs ? `?${qs}` : ""}`);
6383
+ }
6384
+ /** Declare how buyers are meant to reach this channel. Drives the rail's
6385
+ * access line and turns "No buyer access configured" from information into a
6386
+ * warning when the organizer says the channel is for buyer self-service. */
6387
+ setChannelAccessIntent(key, channelId, accessIntent) {
6388
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
6389
+ method: "PATCH",
6390
+ body: { accessIntent }
6391
+ });
6392
+ }
5154
6393
  // ---- reports (token) ----
5155
6394
  report(key) {
5156
6395
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
@@ -5177,6 +6416,1369 @@ var ManageApi = class {
5177
6416
  }
5178
6417
  };
5179
6418
 
6419
+ // src/channelsMode.ts
6420
+ var POLL_MS = 1e4;
6421
+ var MAX_FLAGS = 8;
6422
+ var SEAT_LIST_PAGE = 300;
6423
+ var CHANNELS_CSS = `
6424
+ .slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
6425
+ --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);
6426
+ --slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
6427
+
6428
+ /* map overlay: ONE layer, faded in as a whole (never per seat) */
6429
+ .slm-ch-layer{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
6430
+ .slm-ch-layer.on{opacity:1}
6431
+ .slm-ch-canvas{position:absolute;inset:0;width:100%;height:100%}
6432
+ .slm-ch-flag{position:absolute;display:flex;align-items:center;gap:5px;padding:3px 8px;border-radius:999px;
6433
+ background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
6434
+ transform:translate(-50%,-50%);white-space:nowrap}
6435
+ .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}
6436
+
6437
+ /* preview banner \u2014 raised with the organizer chrome dim, as one transition */
6438
+ .slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
6439
+ border-radius:999px;background:rgba(14,16,23,.92);border:1px solid var(--slm-line);font-size:12px;font-weight:700;
6440
+ transform:translate(-50%,-8px);opacity:0;pointer-events:none;
6441
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out)}
6442
+ .slm-ch-banner.on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
6443
+ .slm-ch-banner .dot{width:8px;height:8px;border-radius:50%}
6444
+ .slm-ch-banner button{color:var(--slm-accent);font-weight:800;font-size:11.5px;min-height:32px}
6445
+ .slm.ch-preview .slm-ch-flag{opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
6446
+
6447
+ /* sticky staged bar */
6448
+ .slm-ch-staged{position:absolute;left:12px;right:12px;bottom:12px;z-index:6;display:flex;align-items:center;gap:12px;
6449
+ padding:10px 14px;min-height:44px;border-radius:12px;background:rgba(24,27,36,.96);border:1px solid var(--slm-line);
6450
+ box-shadow:0 12px 34px rgba(0,0,0,.45);font-size:12.5px;pointer-events:auto;
6451
+ transform:translateY(calc(100% + 18px));opacity:0;
6452
+ transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
6453
+ .slm-ch-staged.on{transform:none;opacity:1}
6454
+ .slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
6455
+ .slm-ch-staged.shake{animation:slm-ch-shake 320ms var(--slm-mo-in-out) 2}
6456
+ .slm-ch-staged b{font-variant-numeric:tabular-nums}
6457
+ .slm-ch-staged .grow{flex:1}
6458
+ .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
6459
+ background:#f4b740;color:#1a1200;font-weight:800;font-size:12.5px}
6460
+ .slm-ch-staged .drop{color:var(--slm-muted);font-weight:700;font-size:11.5px;min-height:44px;padding-inline:8px}
6461
+ .slm-ch-tick{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;
6462
+ background:#fff;color:#1f7a4d;font-weight:900;font-size:11px;animation:slm-ch-tick var(--slm-mo-base) var(--slm-mo-spring)}
6463
+ @keyframes slm-ch-shake{0%,100%{transform:none}25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}
6464
+ @keyframes slm-ch-tick{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
6465
+
6466
+ /* rail */
6467
+ .slm-ch-viewseg{display:flex;gap:3px;padding:3px;border:1px solid var(--slm-line);border-radius:9px;
6468
+ background:var(--slm-surface);margin-bottom:12px}
6469
+ .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)}
6470
+ .slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
6471
+ .slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
6472
+ .slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
6473
+ .slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6474
+ text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
6475
+ .slm-ch-row:hover{border-color:var(--slm-muted)}
6476
+ .slm-ch-row.on{border-color:var(--slm-accent);box-shadow:0 0 0 1px color-mix(in srgb,var(--slm-accent) 40%,transparent)}
6477
+ .slm-ch-row.public{background:linear-gradient(100deg,rgba(244,183,64,.09),var(--slm-surface) 60%)}
6478
+ .slm-ch-row.archived{opacity:.68}
6479
+ .slm-ch-head{display:flex;align-items:center;gap:8px}
6480
+ .slm-ch-mk{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800;
6481
+ color:#0e1017;flex:none}
6482
+ .slm-ch-mk.dim{opacity:.55}
6483
+ .slm-ch-name{flex:1;min-width:0;font-size:13px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
6484
+ .slm-ch-badge{flex:none;font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:2px 7px;border-radius:999px}
6485
+ .slm-ch-badge.active{background:rgba(34,160,107,.16);color:#5bd39b}
6486
+ .slm-ch-badge.paused,.slm-ch-badge.builtin{background:rgba(244,183,64,.15);color:#f7ca6b}
6487
+ .slm-ch-badge.archived{background:rgba(139,148,172,.18);color:#c2c9d8}
6488
+ .slm-ch-counts{display:flex;gap:10px;flex-wrap:wrap;margin-top:7px;font-size:11px;color:var(--slm-muted);
6489
+ font-variant-numeric:tabular-nums}
6490
+ .slm-ch-counts b{color:var(--slm-text);font-weight:800}
6491
+ .slm-ch-counts .free b{color:#5bd39b}
6492
+ .slm-ch-counts b.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6493
+ @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
6494
+ .slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
6495
+ .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
6496
+ .slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
6497
+ .slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
6498
+ .slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
6499
+ font-weight:800;color:#0e1017}
6500
+ .slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
6501
+ .slm-ch-selsrc-row span{color:var(--slm-muted)}
6502
+ .slm-ch-selnum.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6503
+ .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
6504
+ .slm-ch-row2 .slm-btn{flex:1;min-width:0}
6505
+ .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
6506
+ line-height:1.5;margin-bottom:12px}
6507
+ .slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
6508
+ .slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
6509
+ .slm-ch-alert b{color:#fff}
6510
+ .slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
6511
+ .slm-ch-legend{display:flex;flex-direction:column;gap:6px;margin-top:10px}
6512
+ .slm-ch-legend .r{display:flex;align-items:center;gap:9px;font-size:12px;color:var(--slm-muted)}
6513
+ .slm-ch-legend .sw{width:13px;height:13px;border-radius:3.5px;flex:none}
6514
+ .slm-ch-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
6515
+
6516
+ /* dialogs */
6517
+ .slm-ch-scrim{position:absolute;inset:0;z-index:12;background:rgba(4,6,12,.62);display:grid;place-items:center;
6518
+ padding:18px;animation:slm-ch-fade var(--slm-mo-quick) var(--slm-mo-out)}
6519
+ .slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;background:#12151f;border:1px solid var(--slm-line);
6520
+ border-radius:14px;padding:20px;box-shadow:0 24px 70px rgba(0,0,0,.6);
6521
+ animation:slm-ch-rise var(--slm-mo-base) var(--slm-mo-out)}
6522
+ .slm-ch-dialog h3{margin:0 0 4px;font-size:16px;font-weight:800;letter-spacing:-.01em}
6523
+ .slm-ch-dialog .sub{font-size:12.5px;color:var(--slm-muted);line-height:1.5;margin-bottom:14px}
6524
+ .slm-ch-dialog .foot{display:flex;gap:8px;margin-top:16px}
6525
+ .slm-ch-dialog .foot .slm-btn{flex:1;min-width:0}
6526
+ .slm-ch-dialog .foot .quiet{flex:none;padding:10px 14px;min-height:44px;color:var(--slm-muted);font-weight:700;font-size:13px}
6527
+ @keyframes slm-ch-fade{from{opacity:0}to{opacity:1}}
6528
+ @keyframes slm-ch-rise{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
6529
+ .slm-ch-bucket{display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:10px;padding:9px 4px;
6530
+ border-top:1px solid var(--slm-line);font-size:12.5px;animation:slm-ch-bucket var(--slm-mo-base) var(--slm-mo-out) both}
6531
+ .slm-ch-bucket:first-of-type{border-top:0}
6532
+ .slm-ch-bucket .ico{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800}
6533
+ .slm-ch-bucket .ico.add{background:rgba(34,160,107,.18);color:#5bd39b}
6534
+ .slm-ch-bucket .ico.move{background:rgba(167,139,250,.18);color:#c4b5fd}
6535
+ .slm-ch-bucket .ico.same{background:rgba(139,148,172,.14);color:#aab2c4}
6536
+ .slm-ch-bucket .ico.skip{background:rgba(244,183,64,.16);color:#f7ca6b}
6537
+ .slm-ch-bucket b{font-variant-numeric:tabular-nums;font-weight:800}
6538
+ .slm-ch-bucket .why{color:var(--slm-muted);font-size:11px}
6539
+ .slm-ch-bucket .peek{color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums}
6540
+ @keyframes slm-ch-bucket{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
6541
+ .slm-ch-secret{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed rgba(244,183,64,.55);
6542
+ border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
6543
+ overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
6544
+ .slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
6545
+ .slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
6546
+ background:var(--slm-surface);margin-top:10px}
6547
+ .slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
6548
+ justify-content:space-between;gap:8px;font-size:11px;font-weight:800;color:var(--slm-muted);position:sticky;top:0;
6549
+ background:var(--slm-surface)}
6550
+ .slm-ch-seatgroup button{color:var(--slm-accent);font-weight:800;font-size:11px;min-height:32px}
6551
+ .slm-ch-seatitem{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;border-bottom:1px solid var(--slm-line);
6552
+ text-align:left;font-size:12px}
6553
+ .slm-ch-seatitem .box{width:16px;height:16px;border-radius:4px;border:1px solid var(--slm-muted);display:grid;
6554
+ place-items:center;font-size:10px;font-weight:900;color:transparent;flex:none}
6555
+ .slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
6556
+ .slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
6557
+
6558
+ /* compact: bottom sheet with three detents (\xA713) */
6559
+ .slm.compact.ch-sheet .slm-rail{position:absolute;left:0;right:0;bottom:0;z-index:8;border-top:1px solid var(--slm-line);
6560
+ border-radius:18px 18px 0 0;background:#12151f;
6561
+ transition:height var(--slm-mo-slow) var(--slm-mo-in-out)}
6562
+ .slm.compact.ch-sheet.detent-collapsed .slm-rail{height:132px}
6563
+ .slm.compact.ch-sheet.detent-medium .slm-rail{height:46%}
6564
+ .slm.compact.ch-sheet.detent-full .slm-rail{height:92%}
6565
+ .slm.compact.ch-sheet .slm-railscroll{padding:8px 14px calc(12px + env(safe-area-inset-bottom,0px))}
6566
+ .slm-ch-grab{display:none}
6567
+ .slm.compact.ch-sheet .slm-ch-grab{display:flex;align-items:center;gap:10px;width:100%;padding:6px 0 10px}
6568
+ .slm-ch-grabbar{width:42px;height:4px;border-radius:2px;background:rgba(255,255,255,.22);margin:0 auto}
6569
+ .slm.compact .slm-ch-staged{bottom:auto;top:8px}
6570
+ .slm.compact .slm-btn,.slm.compact .slm-ch-row,.slm.compact .slm-ch-viewseg button{min-height:44px}
6571
+ .slm-tools{display:none}
6572
+ .slm.compact .slm-tools{display:block;width:100%;padding:9px 13px;min-height:44px;border:1px solid var(--slm-line);
6573
+ border-radius:10px;background:var(--slm-surface);color:var(--slm-text);font-size:13px;font-weight:800}
6574
+ .slm.compact .slm-modes{display:none}
6575
+
6576
+ @media (prefers-reduced-motion:reduce){
6577
+ .slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail{transition:none!important}
6578
+ .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
6579
+ .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
6580
+ .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
6581
+ .slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
6582
+ }
6583
+ `;
6584
+ function bucketRowsHtml(rows) {
6585
+ return rows.map((row, index) => `
6586
+ <div class="slm-ch-bucket" style="animation-delay:${Math.min(index, 4) * 30}ms">
6587
+ <span class="ico ${row.kind}" aria-hidden="true">${esc(row.icon)}</span>
6588
+ <span><b>${row.count.toLocaleString()}</b> ${esc(row.text.replace(/^[\d,.\s]+/, ""))}
6589
+ ${row.why ? `<span class="why">\u2014 ${esc(row.why)}</span>` : ""}</span>
6590
+ ${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
6591
+ </div>`).join("");
6592
+ }
6593
+ function esc(value) {
6594
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
6595
+ }
6596
+ var ChannelsMode = class {
6597
+ constructor(host, capabilities) {
6598
+ this.active = false;
6599
+ this.list = null;
6600
+ this.allocation = /* @__PURE__ */ new Map();
6601
+ this.assignmentVersion = 0;
6602
+ this.loadError = null;
6603
+ this.loading = true;
6604
+ this.view = "inspect";
6605
+ this.showArchived = false;
6606
+ this.detailChannelId = null;
6607
+ this.targetChannelId = "";
6608
+ this.conflict = false;
6609
+ this.dialog = null;
6610
+ this.detent = "medium";
6611
+ this.seatListLimit = SEAT_LIST_PAGE;
6612
+ this.previewAudience = [];
6613
+ this.previewIncludePublic = false;
6614
+ this.previewProjection = null;
6615
+ this.previewSupported = null;
6616
+ // null = not yet probed
6617
+ this.pollTimer = null;
6618
+ this.layer = null;
6619
+ this.canvas = null;
6620
+ /** undefined = not resolved yet, null = this environment has no 2d canvas. */
6621
+ this.ctx = void 0;
6622
+ this.bannerEl = null;
6623
+ this.stagedEl = null;
6624
+ this.liveEl = null;
6625
+ this.scrimEl = null;
6626
+ this.lastFocus = null;
6627
+ this.stagedDoneTimer = null;
6628
+ this.lastSelectionCount = 0;
6629
+ this.lastCounts = /* @__PURE__ */ new Map();
6630
+ this.host = host;
6631
+ this.caps = capabilities;
6632
+ }
6633
+ // ---- lifecycle ------------------------------------------------------------
6634
+ /** Called when the cockpit switches into Channels mode. */
6635
+ enter() {
6636
+ if (this.active) return;
6637
+ this.active = true;
6638
+ this.ensureLayer();
6639
+ this.host.root.classList.add("ch-mode");
6640
+ this.applySheetClasses();
6641
+ this.paintRail();
6642
+ void this.refresh();
6643
+ this.pollTimer = setInterval(() => {
6644
+ void this.refresh({ quiet: true });
6645
+ }, POLL_MS);
6646
+ }
6647
+ /** Called when the cockpit leaves Channels mode. Everything this mode painted
6648
+ * over the map goes with it — no other tool ever inherits a channel overlay. */
6649
+ leave() {
6650
+ if (!this.active) return;
6651
+ this.active = false;
6652
+ if (this.pollTimer) clearInterval(this.pollTimer);
6653
+ this.pollTimer = null;
6654
+ this.closeDialog({ restoreFocus: false });
6655
+ this.layer?.classList.remove("on");
6656
+ this.host.root.classList.remove(
6657
+ "ch-mode",
6658
+ "ch-preview",
6659
+ "ch-sheet",
6660
+ "detent-collapsed",
6661
+ "detent-medium",
6662
+ "detent-full"
6663
+ );
6664
+ this.setBanner(false);
6665
+ this.setStaged(null);
6666
+ }
6667
+ destroy() {
6668
+ this.leave();
6669
+ if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
6670
+ this.layer?.remove();
6671
+ this.layer = null;
6672
+ }
6673
+ /** Capabilities can change when a token rotates. Re-render, fail-closed. */
6674
+ setCapabilities(capabilities) {
6675
+ this.caps = capabilities;
6676
+ if (this.active) this.paintRail();
6677
+ }
6678
+ isActive() {
6679
+ return this.active;
6680
+ }
6681
+ /**
6682
+ * Whether the map should accept bulk selection right now. Preview is a
6683
+ * read-only simulation of somebody else's view, and a view-only token has no
6684
+ * assignment to stage — in both cases the canvas must not offer selection at
6685
+ * all rather than collect a selection nothing can act on.
6686
+ */
6687
+ canSelect() {
6688
+ return this.caps.manage && this.view === "inspect";
6689
+ }
6690
+ /**
6691
+ * Organizer realtime integration point. M5 ships a per-scope socket for
6692
+ * buyers; the organizer channel-count stream is a later milestone. When it
6693
+ * arrives, call this from the cockpit's WS handler instead of waiting for the
6694
+ * poll — everything downstream already reacts to a fresh list.
6695
+ */
6696
+ applyRealtimeHint() {
6697
+ if (this.active) void this.refresh({ quiet: true });
6698
+ }
6699
+ /** The cockpit's selection changed (marquee / click / section / category). */
6700
+ handleSelectionChange() {
6701
+ if (!this.active) return;
6702
+ this.paintSelection();
6703
+ this.paintStagedBar();
6704
+ }
6705
+ /** Camera moved or the container resized — the overlay is screen-space. */
6706
+ handleViewChange() {
6707
+ if (this.active) this.paintOverlay();
6708
+ }
6709
+ handleLayoutChange() {
6710
+ if (!this.active) return;
6711
+ this.applySheetClasses();
6712
+ this.paintOverlay();
6713
+ }
6714
+ // ---- data -----------------------------------------------------------------
6715
+ async refresh(opts = {}) {
6716
+ if (!this.caps.view) return;
6717
+ try {
6718
+ const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
6719
+ this.list = list;
6720
+ this.assignmentVersion = list.assignmentVersion;
6721
+ this.loadError = null;
6722
+ if (!this.targetChannelId) {
6723
+ this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
6724
+ }
6725
+ await this.loadAllocation();
6726
+ this.loading = false;
6727
+ if (this.active) {
6728
+ this.paintRail();
6729
+ this.paintOverlay();
6730
+ }
6731
+ } catch (err) {
6732
+ this.loading = false;
6733
+ if (err instanceof ManageApiError && err.status === 403) {
6734
+ this.caps = { view: false, manage: false };
6735
+ }
6736
+ this.loadError = err;
6737
+ if (!opts.quiet) this.host.onError(err);
6738
+ if (this.active) this.paintRail();
6739
+ }
6740
+ }
6741
+ /** Walk every allocation page. Bounded by the event's seat count, and the
6742
+ * server caps each page, so an arena is a handful of round trips. */
6743
+ async loadAllocation() {
6744
+ const next = /* @__PURE__ */ new Map();
6745
+ let afterLabel;
6746
+ for (let page = 0; page < 200; page += 1) {
6747
+ const res = await this.host.api.channelAllocation(this.host.eventKey, {
6748
+ afterLabel,
6749
+ limit: 1e3
6750
+ });
6751
+ for (const row of res.allocations) {
6752
+ if (row.channelId && row.channelId !== PUBLIC_CHANNEL_ID) next.set(row.label, row.channelId);
6753
+ }
6754
+ this.assignmentVersion = res.assignmentVersion;
6755
+ if (!res.nextAfterLabel) break;
6756
+ afterLabel = res.nextAfterLabel;
6757
+ }
6758
+ this.allocation = next;
6759
+ }
6760
+ // ---- lookups --------------------------------------------------------------
6761
+ channelById(id) {
6762
+ if (id === PUBLIC_CHANNEL_ID) {
6763
+ return { id, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME, marker: "P", color: null };
6764
+ }
6765
+ const found = this.list?.channels.find((channel) => channel.id === id);
6766
+ return found ? { id, name: found.name, marker: found.marker, color: found.color } : null;
6767
+ }
6768
+ nameOf(id) {
6769
+ return this.channelById(id)?.name ?? null;
6770
+ }
6771
+ markerFor(id) {
6772
+ const index = Math.max(0, this.list?.channels.findIndex((channel2) => channel2.id === id) ?? 0);
6773
+ const channel = this.channelById(id);
6774
+ return markerOf(channel ?? { id, name: "?", marker: null, color: null }, index);
6775
+ }
6776
+ /** Channels an organizer may assign INTO: public sale plus every live channel. */
6777
+ assignableChannels() {
6778
+ return [
6779
+ { id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
6780
+ ...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
6781
+ ];
6782
+ }
6783
+ currentPlan() {
6784
+ const labels = this.host.selectionLabels();
6785
+ const buckets = planAssignment({
6786
+ labels,
6787
+ targetChannelId: this.targetChannelId,
6788
+ allocation: this.allocation,
6789
+ statusOf: (label) => this.host.statusOf(label),
6790
+ nameOf: (id) => this.nameOf(id)
6791
+ });
6792
+ return { labels, buckets, target: this.targetChannelId };
6793
+ }
6794
+ // ---- map overlay ----------------------------------------------------------
6795
+ ensureLayer() {
6796
+ if (this.layer) return;
6797
+ const layer = document.createElement("div");
6798
+ layer.className = "slm-ch-layer";
6799
+ layer.innerHTML = `
6800
+ <canvas class="slm-ch-canvas" data-ch="canvas" aria-hidden="true"></canvas>
6801
+ <div class="slm-ch-banner" data-ch="banner" role="status"></div>
6802
+ <div class="slm-ch-staged" data-ch="staged" role="group" aria-label="Staged channel changes"></div>
6803
+ <div class="slm-ch-live" data-ch="live" role="status" aria-live="polite"></div>`;
6804
+ this.host.mapLayer.appendChild(layer);
6805
+ this.layer = layer;
6806
+ this.canvas = layer.querySelector('[data-ch="canvas"]');
6807
+ this.bannerEl = layer.querySelector('[data-ch="banner"]');
6808
+ this.stagedEl = layer.querySelector('[data-ch="staged"]');
6809
+ this.liveEl = layer.querySelector('[data-ch="live"]');
6810
+ requestAnimationFrame(() => layer.classList.add("on"));
6811
+ }
6812
+ announce(message) {
6813
+ if (this.liveEl) this.liveEl.textContent = message;
6814
+ }
6815
+ /**
6816
+ * Repaint the allocation (or preview) overlay in ONE canvas pass.
6817
+ *
6818
+ * Channel identity on the map is a fill in the administrative color PLUS the
6819
+ * letter flags below — never color alone. Physical status keeps its own cue:
6820
+ * only FREE units take a channel fill, so sold/held/blocked seats still read
6821
+ * exactly as they do in every other tool.
6822
+ */
6823
+ paintOverlay() {
6824
+ const canvas = this.canvas;
6825
+ const layer = this.layer;
6826
+ if (!canvas || !layer) return;
6827
+ const rect = this.host.mapLayer.getBoundingClientRect();
6828
+ const width = Math.max(1, Math.round(rect.width));
6829
+ const height = Math.max(1, Math.round(rect.height));
6830
+ const dpr = typeof devicePixelRatio === "number" ? Math.min(3, Math.max(1, devicePixelRatio)) : 1;
6831
+ if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
6832
+ canvas.width = width * dpr;
6833
+ canvas.height = height * dpr;
6834
+ }
6835
+ if (this.ctx === void 0) {
6836
+ try {
6837
+ this.ctx = canvas.getContext("2d");
6838
+ } catch {
6839
+ this.ctx = null;
6840
+ }
6841
+ }
6842
+ const ctx = this.ctx;
6843
+ if (!ctx) return;
6844
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
6845
+ ctx.clearRect(0, 0, width, height);
6846
+ const size = Math.max(3, this.host.seatPixelSize());
6847
+ const half = size / 2;
6848
+ const projection = this.view === "preview" ? this.previewProjection : null;
6849
+ const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
6850
+ const clusters = /* @__PURE__ */ new Map();
6851
+ for (const seat of this.host.seats()) {
6852
+ const status = this.host.statusOf(seat.label) ?? "free";
6853
+ const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
6854
+ if (channelId !== PUBLIC_CHANNEL_ID) {
6855
+ const cluster = clusters.get(channelId) ?? { x: 0, y: 0, n: 0 };
6856
+ cluster.x += seat.x;
6857
+ cluster.y += seat.y;
6858
+ cluster.n += 1;
6859
+ clusters.set(channelId, cluster);
6860
+ }
6861
+ if (status !== "free") continue;
6862
+ let fill = null;
6863
+ if (this.view === "preview") {
6864
+ fill = eligible ? eligible.has(seat.label) ? null : "#3a4051" : null;
6865
+ } else if (channelId !== PUBLIC_CHANNEL_ID) {
6866
+ fill = this.markerFor(channelId).color;
6867
+ }
6868
+ if (!fill) continue;
6869
+ const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
6870
+ if (!point) continue;
6871
+ if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
6872
+ ctx.fillStyle = fill;
6873
+ ctx.globalAlpha = this.view === "preview" ? 0.9 : 0.85;
6874
+ ctx.fillRect(point.x - half, point.y - half, size, size);
6875
+ }
6876
+ ctx.globalAlpha = 1;
6877
+ this.paintFlags(clusters);
6878
+ }
6879
+ /** Letter flags at each channel's centroid — the non-color identity cue. */
6880
+ paintFlags(clusters) {
6881
+ const layer = this.layer;
6882
+ if (!layer) return;
6883
+ layer.querySelectorAll(".slm-ch-flag").forEach((el) => el.remove());
6884
+ if (this.view === "preview") return;
6885
+ const ranked = [...clusters.entries()].sort((a, b) => b[1].n - a[1].n).slice(0, MAX_FLAGS);
6886
+ for (const [channelId, cluster] of ranked) {
6887
+ const channel = this.list?.channels.find((item) => item.id === channelId);
6888
+ if (!channel) continue;
6889
+ const point = this.host.worldToScreen({ x: cluster.x / cluster.n, y: cluster.y / cluster.n });
6890
+ if (!point) continue;
6891
+ const marker = this.markerFor(channelId);
6892
+ const flag = document.createElement("span");
6893
+ flag.className = "slm-ch-flag";
6894
+ flag.style.left = `${point.x}px`;
6895
+ flag.style.top = `${point.y}px`;
6896
+ flag.innerHTML = `<span class="mk" style="background:${esc(marker.color)}">${esc(marker.letter)}</span>${esc(channel.name)}${channel.state === "paused" ? " \xB7 Paused" : ""}`;
6897
+ layer.appendChild(flag);
6898
+ }
6899
+ }
6900
+ // ---- staged bar -----------------------------------------------------------
6901
+ setStaged(html, cls = "") {
6902
+ const bar = this.stagedEl;
6903
+ if (!bar) return;
6904
+ if (!html) {
6905
+ bar.classList.remove("on", "done", "shake");
6906
+ bar.innerHTML = "";
6907
+ return;
6908
+ }
6909
+ bar.innerHTML = html;
6910
+ bar.className = `slm-ch-staged on${cls ? ` ${cls}` : ""}`;
6911
+ }
6912
+ paintStagedBar() {
6913
+ if (!this.active || this.view === "preview" || !this.caps.manage) {
6914
+ this.setStaged(null);
6915
+ return;
6916
+ }
6917
+ const { labels, buckets } = this.currentPlan();
6918
+ this.host.onStagedChange?.(mutationCount(buckets));
6919
+ if (!labels.length) {
6920
+ this.setStaged(null);
6921
+ return;
6922
+ }
6923
+ const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
6924
+ const mutations = mutationCount(buckets);
6925
+ const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
6926
+ const parts = [
6927
+ `<b>${labels.length.toLocaleString()}</b> selected`,
6928
+ `<b>+${mutations.toLocaleString()}</b> to ${esc(target)}`
6929
+ ];
6930
+ if (buckets.alreadyInTarget.count) parts.push(`<b>${buckets.alreadyInTarget.count.toLocaleString()}</b> already in`);
6931
+ if (skipped) parts.push(`<b>${skipped.toLocaleString()}</b> can't move now`);
6932
+ this.setStaged(`
6933
+ <span>${parts.join(" \xB7 ")}</span>
6934
+ <span class="grow"></span>
6935
+ <button type="button" class="drop" data-ch-act="discard">Discard</button>
6936
+ <button type="button" class="go" data-ch-act="review">Review changes</button>`);
6937
+ this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
6938
+ button.addEventListener("click", () => {
6939
+ if (button.dataset.chAct === "discard") this.host.clearSelection();
6940
+ else this.openDialog({ kind: "review" });
6941
+ });
6942
+ });
6943
+ }
6944
+ setBanner(on, name = "") {
6945
+ const banner = this.bannerEl;
6946
+ if (!banner) return;
6947
+ this.host.root.classList.toggle("ch-preview", on);
6948
+ if (!on) {
6949
+ banner.classList.remove("on");
6950
+ banner.innerHTML = "";
6951
+ return;
6952
+ }
6953
+ const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
6954
+ banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
6955
+ Previewing buyer access \xB7 ${esc(name)} \xB7 read-only
6956
+ <button type="button" data-ch-act="exit-preview">Exit preview</button>`;
6957
+ banner.classList.add("on");
6958
+ banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
6959
+ }
6960
+ // ---- rail -----------------------------------------------------------------
6961
+ setView(view) {
6962
+ this.view = view;
6963
+ if (view === "inspect") {
6964
+ this.previewProjection = null;
6965
+ this.setBanner(false);
6966
+ } else {
6967
+ if (!this.previewAudience.length) {
6968
+ const first = this.list?.channels.find((channel) => channel.state === "active");
6969
+ this.previewAudience = [first ? first.id : PUBLIC_CHANNEL_ID];
6970
+ }
6971
+ void this.loadPreview();
6972
+ }
6973
+ this.paintRail();
6974
+ this.paintOverlay();
6975
+ this.paintStagedBar();
6976
+ this.onInteractionChange?.();
6977
+ }
6978
+ async loadPreview() {
6979
+ const audience = [...this.previewAudience];
6980
+ const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
6981
+ this.setBanner(true, names);
6982
+ try {
6983
+ this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
6984
+ includePublic: this.previewIncludePublic
6985
+ });
6986
+ this.previewSupported = true;
6987
+ } catch (err) {
6988
+ const status = err instanceof ManageApiError ? err.status : 0;
6989
+ this.previewSupported = !(status === 404 || status === 405 || status === 501);
6990
+ this.previewProjection = null;
6991
+ if (this.previewSupported) this.host.onError(err);
6992
+ }
6993
+ if (this.active) {
6994
+ this.paintRail();
6995
+ this.paintOverlay();
6996
+ }
6997
+ }
6998
+ paintRail() {
6999
+ if (!this.active) return;
7000
+ const rail = this.host.rail;
7001
+ if (!this.caps.view) {
7002
+ rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
7003
+ <p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`;
7004
+ return;
7005
+ }
7006
+ if (this.loading && !this.list) {
7007
+ rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
7008
+ <div class="slm-empty">Loading allocations\u2026</div>`;
7009
+ return;
7010
+ }
7011
+ if (!this.list && this.loadError) {
7012
+ rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
7013
+ <div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7014
+ <span><b>Couldn't load sales channels.</b> Everything else on this event still works.
7015
+ <button type="button" data-ch-act="retry">Try again</button></span></div>`;
7016
+ rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
7017
+ void this.refresh();
7018
+ });
7019
+ return;
7020
+ }
7021
+ const selection = this.host.selectionLabels();
7022
+ const grab = this.host.isCompact() ? `<div class="slm-ch-grab"><span class="slm-ch-grabbar"></span></div>` : "";
7023
+ const segment = this.viewSegmentHtml();
7024
+ const body = this.view === "preview" ? this.previewRailHtml() : this.detailChannelId ? this.detailRailHtml(this.detailChannelId) : selection.length && this.caps.manage ? this.selectionRailHtml(selection) : this.listRailHtml();
7025
+ rail.innerHTML = `${grab}${segment}${body}`;
7026
+ this.wireRail();
7027
+ this.paintStagedBar();
7028
+ }
7029
+ viewSegmentHtml() {
7030
+ const inspectOn = this.view === "inspect" ? " on" : "";
7031
+ const previewOn = this.view === "preview" ? " on" : "";
7032
+ return `<div class="slm-ch-viewseg" role="group" aria-label="Channels view">
7033
+ <button type="button" class="${inspectOn.trim()}" data-ch-view="inspect"
7034
+ aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
7035
+ <button type="button" class="${previewOn.trim()}" data-ch-view="preview"
7036
+ aria-pressed="${this.view === "preview"}">Preview buyer access</button>
7037
+ </div>`;
7038
+ }
7039
+ countsHtml(counts, key) {
7040
+ const cell = (id, value, label, cls = "") => {
7041
+ const previous = this.lastCounts.get(`${key}:${id}`);
7042
+ const bump = previous != null && previous !== value ? " bump" : "";
7043
+ this.lastCounts.set(`${key}:${id}`, value);
7044
+ return `<span class="${cls}"><b class="${bump.trim()}">${value.toLocaleString()}</b> ${label}</span>`;
7045
+ };
7046
+ return `<span class="slm-ch-counts">
7047
+ ${cell("allocated", counts.allocated, "allocated")}
7048
+ ${cell("free", counts.free, "free", "free")}
7049
+ ${cell("booked", counts.booked, "sold")}
7050
+ ${counts.held ? cell("held", counts.held, "held") : ""}
7051
+ </span>`;
7052
+ }
7053
+ channelRowHtml(channel, opts = {}) {
7054
+ const marker = this.markerFor(channel.id);
7055
+ const badgeKind = opts.builtin ? "builtin" : channel.state;
7056
+ const dim = channel.state === "paused" || channel.state === "archived" ? " dim" : "";
7057
+ const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}`;
7058
+ const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-detail="${esc(channel.id)}"
7059
+ aria-label="Manage ${esc(channel.name)}">\u22EF</button>` : "";
7060
+ return `<div class="${cls}">
7061
+ <span class="slm-ch-head">
7062
+ <span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
7063
+ <span class="slm-ch-name">${esc(channel.name)}</span>
7064
+ <span class="slm-ch-badge ${badgeKind}">${esc(stateBadge(opts.builtin ? "builtin" : channel.state))}</span>
7065
+ ${more}
7066
+ </span>
7067
+ ${this.countsHtml(channel.counts, channel.id || "public")}
7068
+ ${opts.builtin ? "" : `<span class="slm-ch-access">${esc(accessLine(channel.access))}</span>`}
7069
+ </div>`;
7070
+ }
7071
+ listRailHtml() {
7072
+ const list = this.list;
7073
+ const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
7074
+ const rows = [
7075
+ this.channelRowHtml(list.publicSale, { builtin: true }),
7076
+ ...list.channels.filter((channel) => this.showArchived || channel.state !== "archived").map((channel, index) => this.channelRowHtml(channel, { index }))
7077
+ ].join("");
7078
+ const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
7079
+ const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
7080
+ return `
7081
+ <p class="slm-eyebrow">Sales channels</p>
7082
+ <p class="slm-hint">Select seats on the map, then assign them. Channel colours and names are never shown to buyers.</p>
7083
+ <div class="slm-ch-list">${rows}</div>
7084
+ ${create}
7085
+ ${readOnly}
7086
+ <p class="slm-note" style="margin-top:10px">
7087
+ <button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
7088
+ style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
7089
+ </p>`;
7090
+ }
7091
+ selectionRailHtml(selection) {
7092
+ const sources = selectionSources(selection, this.allocation, this.list);
7093
+ const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7094
+ <span><b>Assignments changed while you were editing.</b> Nothing was applied.
7095
+ Your ${selection.length.toLocaleString()}-seat selection is kept.
7096
+ <button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
7097
+ const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
7098
+ this.lastSelectionCount = selection.length;
7099
+ const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
7100
+ const sourceRows = sources.map((row) => {
7101
+ const marker = this.markerFor(row.channelId);
7102
+ return `<div class="slm-ch-selsrc-row">
7103
+ <span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
7104
+ <b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
7105
+ }).join("");
7106
+ const sectionSelect = this.host.sections().length ? `<button type="button" class="slm-btn ghost" data-ch-act="pick-section">Section</button>` : "";
7107
+ return `
7108
+ ${conflict}
7109
+ <div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
7110
+ <span class="slm-sellabel">selected</span></div>
7111
+ <div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
7112
+ <div class="slm-field">
7113
+ <label for="slm-ch-target">Assign to</label>
7114
+ <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
7115
+ </div>
7116
+ <p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
7117
+ Seats in checkout or already sold are never moved.</p>
7118
+ <div class="slm-ch-row2">
7119
+ <button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
7120
+ <button type="button" class="slm-btn" data-ch-act="review">Review changes</button>
7121
+ </div>
7122
+ <p class="slm-eyebrow" style="margin-top:18px">Select by</p>
7123
+ <div class="slm-ch-row2" style="margin-top:2px">
7124
+ ${sectionSelect}
7125
+ <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
7126
+ <button type="button" class="slm-btn ghost" data-ch-act="seatlist">List \u2328</button>
7127
+ </div>
7128
+ <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
7129
+ }
7130
+ detailRailHtml(channelId) {
7131
+ const channel = this.list?.channels.find((item) => item.id === channelId);
7132
+ if (!channel) return this.listRailHtml();
7133
+ const lifecycle = this.caps.manage ? `
7134
+ <p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
7135
+ <div class="slm-ch-row2" style="margin-top:2px">
7136
+ <button type="button" class="slm-btn ghost" data-ch-act="rename">Rename</button>
7137
+ <button type="button" class="slm-btn ghost" data-ch-act="pause">${channel.state === "paused" ? "Resume" : "Pause"}</button>
7138
+ <button type="button" class="slm-btn ghost" data-ch-act="archive">Archive\u2026</button>
7139
+ </div>
7140
+ <p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
7141
+ const intent = channel.access?.intent ?? "none";
7142
+ const intents = ["none", "internal", "server", "hosted_link"];
7143
+ const selfServiceGap = (intent === "server" || intent === "hosted_link") && !channel.access?.hasActiveGrants;
7144
+ const access = this.caps.manage ? `
7145
+ <p class="slm-eyebrow" style="margin-top:14px">Buyer access</p>
7146
+ <div class="slm-field">
7147
+ <label for="slm-ch-intent">How should buyers reach this channel?</label>
7148
+ <select class="slm-select" id="slm-ch-intent" data-ch-intent>
7149
+ ${intents.map((value) => `<option value="${value}"${value === intent ? " selected" : ""}>${esc(accessIntentLabel(value))}</option>`).join("")}
7150
+ </select>
7151
+ </div>
7152
+ <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>
7153
+ ${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
7154
+ <span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
7155
+ <button type="button" class="slm-btn" data-ch-act="hosted-link" disabled
7156
+ title="Hosted access links ship in the next milestone">Create hosted access link \xB7 Coming soon</button>
7157
+ <div class="slm-ch-row2"><button type="button" class="slm-btn ghost" data-ch-act="server-access" disabled
7158
+ title="Guided server setup ships in the next milestone">Configure server integration \xB7 Coming soon</button></div>
7159
+ <p class="slm-note">Your own server can already mint buyer access sessions for this channel with the server SDK.</p>` : "";
7160
+ return `
7161
+ <p class="slm-eyebrow">
7162
+ <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
7163
+ </p>
7164
+ <p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
7165
+ <div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
7166
+ ${access}
7167
+ ${lifecycle}`;
7168
+ }
7169
+ previewRailHtml() {
7170
+ const audienceOptions = [
7171
+ { id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
7172
+ ...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
7173
+ ];
7174
+ const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
7175
+ const options = audienceOptions.map((entry) => `<option value="${esc(entry.id)}"${entry.id === current ? " selected" : ""}>${esc(entry.name)}</option>`).join("");
7176
+ const unsupported = this.previewSupported === false ? `<div class="slm-ch-alert warn"><span>\u2139</span>
7177
+ <span><b>Preview needs a newer server.</b> Allocation management works normally;
7178
+ the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
7179
+ const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
7180
+ <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")}.
7181
+ A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
7182
+ const counts = this.previewProjection?.counts;
7183
+ 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
7184
+ through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
7185
+ const includePublic = current === PUBLIC_CHANNEL_ID ? "" : `
7186
+ <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
7187
+ <input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
7188
+ Also include Public sale seats in this grant
7189
+ </label>`;
7190
+ return `
7191
+ <p class="slm-eyebrow">Preview buyer access</p>
7192
+ <div class="slm-field">
7193
+ <label for="slm-ch-audience">Audience</label>
7194
+ <select class="slm-select" id="slm-ch-audience" data-ch-audience>${options}</select>
7195
+ </div>
7196
+ ${includePublic}
7197
+ <p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
7198
+ approximation. It is read-only: clicks open seat details, and no holds are created.</p>
7199
+ ${unsupported}${unavailable}
7200
+ <div class="slm-ch-legend">
7201
+ <div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible &amp; free \u2014 buyable by this audience</div>
7202
+ <div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
7203
+ <div class="r"><span class="sw" style="background:#22a06b"></span> Sold \u2014 same as any buyer sees</div>
7204
+ </div>
7205
+ ${summary}`;
7206
+ }
7207
+ paintSelection() {
7208
+ if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
7209
+ }
7210
+ wireRail() {
7211
+ const rail = this.host.rail;
7212
+ rail.querySelectorAll("[data-ch-view]").forEach((button) => {
7213
+ button.addEventListener("click", () => this.setView(button.dataset.chView));
7214
+ });
7215
+ rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
7216
+ button.addEventListener("click", () => {
7217
+ this.detailChannelId = button.dataset.chDetail;
7218
+ this.paintRail();
7219
+ });
7220
+ });
7221
+ const target = rail.querySelector("[data-ch-target]");
7222
+ target?.addEventListener("change", () => {
7223
+ this.targetChannelId = target.value;
7224
+ this.conflict = false;
7225
+ this.paintRail();
7226
+ });
7227
+ const audience = rail.querySelector("[data-ch-audience]");
7228
+ audience?.addEventListener("change", () => {
7229
+ this.previewAudience = [audience.value];
7230
+ void this.loadPreview();
7231
+ });
7232
+ const includePublic = rail.querySelector("[data-ch-includepublic]");
7233
+ includePublic?.addEventListener("change", () => {
7234
+ this.previewIncludePublic = includePublic.checked;
7235
+ void this.loadPreview();
7236
+ });
7237
+ const intent = rail.querySelector("[data-ch-intent]");
7238
+ intent?.addEventListener("change", () => {
7239
+ void this.setAccessIntent(intent.value);
7240
+ });
7241
+ const grab = rail.querySelector(".slm-ch-grab");
7242
+ grab?.addEventListener("click", () => this.cycleDetent());
7243
+ rail.querySelectorAll("[data-ch-act]").forEach((button) => {
7244
+ button.addEventListener("click", () => this.railAction(button.dataset.chAct));
7245
+ });
7246
+ }
7247
+ railAction(action) {
7248
+ switch (action) {
7249
+ case "create":
7250
+ this.openDialog({ kind: "create" });
7251
+ break;
7252
+ case "review":
7253
+ this.openDialog({ kind: "review" });
7254
+ break;
7255
+ case "rename":
7256
+ this.openDialog({ kind: "rename", channelId: this.detailChannelId });
7257
+ break;
7258
+ case "archive":
7259
+ this.openDialog({ kind: "archive", channelId: this.detailChannelId });
7260
+ break;
7261
+ case "seatlist":
7262
+ this.openDialog({ kind: "seatlist" });
7263
+ break;
7264
+ case "pause":
7265
+ void this.togglePause();
7266
+ break;
7267
+ case "discard":
7268
+ this.host.clearSelection();
7269
+ break;
7270
+ case "back":
7271
+ this.detailChannelId = null;
7272
+ this.paintRail();
7273
+ break;
7274
+ case "retry":
7275
+ void this.refresh();
7276
+ break;
7277
+ case "toggle-archived":
7278
+ this.showArchived = !this.showArchived;
7279
+ void this.refresh();
7280
+ break;
7281
+ case "refresh-review":
7282
+ this.conflict = false;
7283
+ void this.refresh().then(() => this.openDialog({ kind: "review" }));
7284
+ break;
7285
+ case "pick-section":
7286
+ this.pickSection();
7287
+ break;
7288
+ case "pick-category":
7289
+ this.pickCategory();
7290
+ break;
7291
+ default:
7292
+ break;
7293
+ }
7294
+ }
7295
+ // ---- selection helpers ----------------------------------------------------
7296
+ pickSection() {
7297
+ const sections = this.host.sections();
7298
+ if (!sections.length) return;
7299
+ this.promptChoice("Select a whole section", sections.map((s) => ({ value: s.id, label: s.label })), (value) => {
7300
+ this.host.selectSection(value);
7301
+ });
7302
+ }
7303
+ pickCategory() {
7304
+ const categories = this.host.categories();
7305
+ if (!categories.length) return;
7306
+ this.promptChoice("Select a whole category", categories.map((c) => ({ value: c.key, label: c.label })), (value) => {
7307
+ this.host.selectByLabels(this.host.labelsInCategory(value));
7308
+ });
7309
+ }
7310
+ /** A tiny modal chooser reusing the dialog primitive (focus trap + Escape). */
7311
+ promptChoice(title, options, onPick) {
7312
+ this.renderScrim(`
7313
+ <h3 id="slm-ch-dlg-title">${esc(title)}</h3>
7314
+ <div class="slm-field">
7315
+ <label for="slm-ch-choice">Choose one</label>
7316
+ <select class="slm-select" id="slm-ch-choice">
7317
+ ${options.map((option) => `<option value="${esc(option.value)}">${esc(option.label)}</option>`).join("")}
7318
+ </select>
7319
+ </div>
7320
+ <div class="foot">
7321
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
7322
+ <button type="button" class="slm-btn" data-ch-confirm>Select</button>
7323
+ </div>`, (root) => {
7324
+ root.querySelector("[data-ch-confirm]")?.addEventListener("click", () => {
7325
+ const select = root.querySelector("#slm-ch-choice");
7326
+ const value = select?.value;
7327
+ this.closeDialog();
7328
+ if (value) onPick(value);
7329
+ });
7330
+ });
7331
+ }
7332
+ // ---- dialogs --------------------------------------------------------------
7333
+ openDialog(state) {
7334
+ this.dialog = state;
7335
+ this.renderDialog();
7336
+ }
7337
+ renderDialog() {
7338
+ const state = this.dialog;
7339
+ if (!state) return;
7340
+ if (state.kind === "create") this.renderCreateDialog(state);
7341
+ else if (state.kind === "review") this.renderReviewDialog(state);
7342
+ else if (state.kind === "archive") this.renderArchiveDialog(state);
7343
+ else if (state.kind === "rename") this.renderRenameDialog(state);
7344
+ else if (state.kind === "seatlist") this.renderSeatListDialog();
7345
+ }
7346
+ /**
7347
+ * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
7348
+ * Tab trapped, Escape closes WITHOUT mutating, focus restored on close (§13).
7349
+ */
7350
+ renderScrim(inner, wire) {
7351
+ const existing = this.scrimEl;
7352
+ if (!existing) this.lastFocus = document.activeElement ?? null;
7353
+ existing?.remove();
7354
+ const scrim = document.createElement("div");
7355
+ scrim.className = "slm-ch-scrim";
7356
+ scrim.innerHTML = `<div class="slm-ch-dialog" role="dialog" aria-modal="true"
7357
+ aria-labelledby="slm-ch-dlg-title" tabindex="-1">${inner}</div>`;
7358
+ this.host.root.appendChild(scrim);
7359
+ this.scrimEl = scrim;
7360
+ const dialog = scrim.firstElementChild;
7361
+ dialog.querySelectorAll("[data-ch-close]").forEach((button) => {
7362
+ button.addEventListener("click", () => this.closeDialog());
7363
+ });
7364
+ scrim.addEventListener("keydown", (event) => {
7365
+ if (event.key === "Escape") {
7366
+ event.stopPropagation();
7367
+ this.closeDialog();
7368
+ return;
7369
+ }
7370
+ if (event.key !== "Tab") return;
7371
+ const focusable = [...dialog.querySelectorAll(
7372
+ 'button:not([disabled]),select,input,textarea,a[href],[tabindex]:not([tabindex="-1"])'
7373
+ )];
7374
+ if (!focusable.length) return;
7375
+ const first = focusable[0];
7376
+ const last = focusable[focusable.length - 1];
7377
+ if (event.shiftKey && document.activeElement === first) {
7378
+ event.preventDefault();
7379
+ last.focus();
7380
+ } else if (!event.shiftKey && document.activeElement === last) {
7381
+ event.preventDefault();
7382
+ first.focus();
7383
+ }
7384
+ });
7385
+ wire(dialog);
7386
+ const autofocus = dialog.querySelector("input,select,button");
7387
+ (autofocus ?? dialog).focus();
7388
+ }
7389
+ closeDialog(opts = {}) {
7390
+ this.dialog = null;
7391
+ this.scrimEl?.remove();
7392
+ this.scrimEl = null;
7393
+ if (opts.restoreFocus !== false) this.lastFocus?.focus?.();
7394
+ this.lastFocus = null;
7395
+ }
7396
+ renderCreateDialog(state) {
7397
+ const taken = (this.list?.channels ?? []).map((channel) => channel.marker ?? channel.name[0] ?? "");
7398
+ const suggestion = suggestMarker("", taken);
7399
+ this.renderScrim(`
7400
+ <h3 id="slm-ch-dlg-title">Create channel</h3>
7401
+ <p class="sub">A named allocation only the right audience can buy from. You'll pick the seats next.</p>
7402
+ <div class="slm-field">
7403
+ <label for="slm-ch-name">Name</label>
7404
+ <input class="slm-input" id="slm-ch-name" maxlength="80" />
7405
+ <p class="slm-note">Shown to your team and in reports \u2014 never to buyers.</p>
7406
+ </div>
7407
+ <div class="slm-field">
7408
+ <label>Marker</label>
7409
+ <div style="display:flex;gap:8px;align-items:center">
7410
+ <span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
7411
+ <span class="slm-note" style="margin:0">Letter + colour suggested from the name. Buyers never see either.</span>
7412
+ </div>
7413
+ </div>
7414
+ <div class="slm-field">
7415
+ <label for="slm-ch-ref">Reference <span style="text-transform:none;font-weight:500">(optional)</span></label>
7416
+ <input class="slm-input" id="slm-ch-ref" maxlength="120" placeholder="e.g. travel-agency-a" />
7417
+ <p class="slm-note">A stable ID for your own system and webhooks.</p>
7418
+ </div>
7419
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
7420
+ <div class="foot">
7421
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
7422
+ <button type="button" class="slm-btn ghost" data-ch-create="plain">Create without allocating</button>
7423
+ <button type="button" class="slm-btn" data-ch-create="allocate">Create and allocate seats</button>
7424
+ </div>`, (dialog) => {
7425
+ const name = dialog.querySelector("#slm-ch-name");
7426
+ const marker = dialog.querySelector("[data-ch-marker]");
7427
+ name.addEventListener("input", () => {
7428
+ const next = suggestMarker(name.value, taken);
7429
+ marker.textContent = next.letter;
7430
+ marker.style.background = next.color;
7431
+ });
7432
+ dialog.querySelectorAll("[data-ch-create]").forEach((button) => {
7433
+ button.addEventListener("click", () => {
7434
+ const allocate = button.dataset.chCreate === "allocate";
7435
+ void this.createChannel(
7436
+ name.value,
7437
+ marker.textContent ?? "",
7438
+ marker.style.background,
7439
+ dialog.querySelector("#slm-ch-ref")?.value ?? "",
7440
+ allocate
7441
+ );
7442
+ });
7443
+ });
7444
+ });
7445
+ }
7446
+ async createChannel(name, letter, color, externalRef, allocate) {
7447
+ const trimmed = name.trim();
7448
+ if (!trimmed) {
7449
+ this.showDialogError("Give the channel a name your team will recognise.");
7450
+ return;
7451
+ }
7452
+ try {
7453
+ const res = await this.host.api.createChannel(this.host.eventKey, {
7454
+ name: trimmed,
7455
+ marker: letter || null,
7456
+ color: color || null,
7457
+ externalRef: externalRef.trim() || null
7458
+ });
7459
+ this.closeDialog();
7460
+ await this.refresh();
7461
+ this.targetChannelId = res.channel.id;
7462
+ this.detailChannelId = allocate ? null : res.channel.id;
7463
+ this.announce(`Channel ${trimmed} created with 0 seats allocated.`);
7464
+ this.host.toast(allocate ? `${trimmed} created. Select seats on the map to allocate them.` : `${trimmed} created.`, "ok");
7465
+ this.paintRail();
7466
+ } catch (err) {
7467
+ const code = err instanceof ManageApiError ? err.code : void 0;
7468
+ this.showDialogError(code === "channel_name_taken" ? "That name is already used on this event. Pick another." : "Couldn't create the channel. Try again.");
7469
+ this.host.onError(err);
7470
+ }
7471
+ }
7472
+ showDialogError(message) {
7473
+ const field = this.scrimEl?.querySelector("[data-ch-error]");
7474
+ if (!field) return;
7475
+ field.textContent = message;
7476
+ field.hidden = false;
7477
+ }
7478
+ renderReviewDialog(state) {
7479
+ const { labels, buckets } = this.currentPlan();
7480
+ const authoritative = state.applied;
7481
+ const shown = authoritative ? authoritative.buckets : buckets;
7482
+ const targetName = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
7483
+ const rows = bucketRows(shown, targetName);
7484
+ const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
7485
+ const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
7486
+ const rowsHtml = bucketRowsHtml(rows);
7487
+ const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
7488
+ <button type="button" class="quiet" data-ch-close>Back</button>
7489
+ <button type="button" class="slm-btn" data-ch-apply ${allSkipped || state.busy ? "disabled" : ""}>
7490
+ ${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
7491
+ </button>
7492
+ </div>`;
7493
+ const confirmNote = !authoritative && needsMoveConfirmation(buckets) ? `<p class="slm-note">Applying moves inventory out of another private channel. That is the line marked above.</p>` : "";
7494
+ const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
7495
+ every seat is in a buyer's checkout, already sold, or already in ${esc(targetName)}.</span></div>` : "";
7496
+ this.renderScrim(`
7497
+ <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>
7498
+ <p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
7499
+ ${skippedNote}
7500
+ ${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
7501
+ ${confirmNote}
7502
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
7503
+ ${foot}`, (dialog) => {
7504
+ dialog.querySelector("[data-ch-apply]")?.addEventListener("click", () => void this.apply());
7505
+ });
7506
+ if (authoritative) {
7507
+ this.announce(`Applied ${authoritative.applied} change${authoritative.applied === 1 ? "" : "s"} to ${targetName}.`);
7508
+ }
7509
+ }
7510
+ /**
7511
+ * Apply. On success the review sheet re-renders with the AUTHORITATIVE server
7512
+ * buckets and the staged bar morphs to a ✓ for 1.2s. On a stale version the
7513
+ * server mutated nothing: keep the selection, shake the bar once, and offer
7514
+ * exactly one action — Refresh and review.
7515
+ */
7516
+ async apply() {
7517
+ if (!this.dialog || !this.caps.manage) return;
7518
+ const { labels } = this.currentPlan();
7519
+ if (!labels.length) return;
7520
+ this.dialog = { ...this.dialog, busy: true, error: null };
7521
+ this.renderDialog();
7522
+ try {
7523
+ const result = await this.host.api.applyChannelAssignment(this.host.eventKey, {
7524
+ targetChannelId: this.targetChannelId || null,
7525
+ labels,
7526
+ assignmentVersion: this.assignmentVersion
7527
+ });
7528
+ this.assignmentVersion = result.assignmentVersion;
7529
+ this.conflict = false;
7530
+ this.dialog = { kind: "review", applied: result };
7531
+ await this.refresh({ quiet: true });
7532
+ this.renderDialog();
7533
+ this.showApplied(result);
7534
+ this.host.clearSelection();
7535
+ } catch (err) {
7536
+ const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
7537
+ if (conflict) {
7538
+ this.conflict = true;
7539
+ this.closeDialog();
7540
+ this.shakeStaged();
7541
+ this.paintRail();
7542
+ this.announce("Assignments changed while you were editing. Nothing was applied and your selection is kept.");
7543
+ return;
7544
+ }
7545
+ if (err instanceof ManageApiError && err.status === 403) {
7546
+ this.caps = { view: this.caps.view, manage: false };
7547
+ this.closeDialog();
7548
+ this.paintRail();
7549
+ this.host.toast("Changing channels needs channel-management permission.", "err");
7550
+ return;
7551
+ }
7552
+ this.dialog = { kind: "review", busy: false, error: "Couldn't apply those changes. Try again." };
7553
+ this.renderDialog();
7554
+ this.host.onError(err);
7555
+ }
7556
+ }
7557
+ showApplied(result) {
7558
+ this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
7559
+ <span>Applied <b>${result.applied.toLocaleString()}</b> change${result.applied === 1 ? "" : "s"}</span>
7560
+ <span class="grow"></span>`, "done");
7561
+ if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
7562
+ this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 1200);
7563
+ }
7564
+ shakeStaged() {
7565
+ const bar = this.stagedEl;
7566
+ if (!bar || !bar.classList.contains("on")) return;
7567
+ bar.classList.remove("shake");
7568
+ void bar.offsetWidth;
7569
+ bar.classList.add("shake");
7570
+ }
7571
+ renderRenameDialog(state) {
7572
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
7573
+ if (!channel) {
7574
+ this.closeDialog();
7575
+ return;
7576
+ }
7577
+ this.renderScrim(`
7578
+ <h3 id="slm-ch-dlg-title">Rename ${esc(channel.name)}</h3>
7579
+ <p class="sub">Only your team and your reports see this name.</p>
7580
+ <div class="slm-field">
7581
+ <label for="slm-ch-newname">Name</label>
7582
+ <input class="slm-input" id="slm-ch-newname" maxlength="80" value="${esc(channel.name)}" />
7583
+ </div>
7584
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
7585
+ <div class="foot">
7586
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
7587
+ <button type="button" class="slm-btn" data-ch-rename>Save name</button>
7588
+ </div>`, (dialog) => {
7589
+ dialog.querySelector("[data-ch-rename]")?.addEventListener("click", () => {
7590
+ const value = dialog.querySelector("#slm-ch-newname")?.value ?? "";
7591
+ if (!value.trim()) {
7592
+ this.showDialogError("A channel needs a name.");
7593
+ return;
7594
+ }
7595
+ void this.host.api.renameChannel(this.host.eventKey, channel.id, value.trim()).then(() => {
7596
+ this.closeDialog();
7597
+ return this.refresh();
7598
+ }).catch((err) => {
7599
+ this.showDialogError(err instanceof ManageApiError && err.code === "channel_name_taken" ? "That name is already used on this event." : "Couldn't rename the channel.");
7600
+ this.host.onError(err);
7601
+ });
7602
+ });
7603
+ });
7604
+ }
7605
+ async setAccessIntent(accessIntent) {
7606
+ const channelId = this.detailChannelId;
7607
+ if (!channelId || !this.caps.manage) return;
7608
+ try {
7609
+ await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, accessIntent);
7610
+ await this.refresh();
7611
+ } catch (err) {
7612
+ this.host.toast("Couldn't save how buyers reach this channel.", "err");
7613
+ this.host.onError(err);
7614
+ }
7615
+ }
7616
+ async togglePause() {
7617
+ const channel = this.list?.channels.find((item) => item.id === this.detailChannelId);
7618
+ if (!channel) return;
7619
+ const paused = channel.state !== "paused";
7620
+ try {
7621
+ await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);
7622
+ await this.refresh();
7623
+ this.host.toast(paused ? `${channel.name} paused. Existing checkouts can finish; no new buyer access is issued.` : `${channel.name} resumed.`, "ok");
7624
+ } catch (err) {
7625
+ this.host.toast("Couldn't change that channel.", "err");
7626
+ this.host.onError(err);
7627
+ }
7628
+ }
7629
+ renderArchiveDialog(state) {
7630
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
7631
+ if (!channel) {
7632
+ this.closeDialog();
7633
+ return;
7634
+ }
7635
+ const blocked = state.archiveBlocked ?? null;
7636
+ const heads_up = !blocked && channel.counts.held > 0;
7637
+ const destinations = this.assignableChannels().filter((entry) => entry.id !== channel.id);
7638
+ const blockedAlert = blocked ? `<div class="slm-ch-alert warn" role="alert"><span>\u23F3</span>
7639
+ <span><b>${(blocked.heldUnits ?? blocked.activeHolds ?? 0).toLocaleString()} seats are in a buyer's checkout right now.</b>
7640
+ 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>
7641
+ <span>${channel.counts.held.toLocaleString()} seats are in a buyer's checkout right now.
7642
+ Archive is refused while any seat is held \u2014 you can try, and we'll tell you when to come back.</span></div>` : "";
7643
+ this.renderScrim(`
7644
+ <h3 id="slm-ch-dlg-title">Archive ${esc(channel.name)}</h3>
7645
+ <p class="sub">The channel closes for good. Its seats move to a destination you choose;
7646
+ sales history keeps its attribution.</p>
7647
+ ${blockedAlert}
7648
+ <div class="slm-field">
7649
+ <label for="slm-ch-dest">Move the ${channel.counts.free.toLocaleString()} remaining free seats to</label>
7650
+ <select class="slm-select" id="slm-ch-dest">
7651
+ ${destinations.map((entry) => `<option value="${esc(entry.id)}">${esc(entry.name)}</option>`).join("")}
7652
+ </select>
7653
+ </div>
7654
+ <p class="slm-note">${channel.counts.booked.toLocaleString()} sold seats keep "${esc(channel.name)}" on their sale
7655
+ record. If one is cancelled later it returns to the destination above. Any buyer access for this channel
7656
+ stops working.</p>
7657
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
7658
+ <div class="foot">
7659
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
7660
+ <button type="button" class="slm-btn danger" data-ch-archive ${blocked ? "disabled" : ""}>Archive channel</button>
7661
+ </div>`, (dialog) => {
7662
+ dialog.querySelector("[data-ch-archive]")?.addEventListener("click", () => {
7663
+ const destination = dialog.querySelector("#slm-ch-dest")?.value ?? "";
7664
+ void this.archive(channel.id, destination || null);
7665
+ });
7666
+ });
7667
+ }
7668
+ async archive(channelId, destination) {
7669
+ try {
7670
+ await this.host.api.archiveChannel(this.host.eventKey, channelId, destination);
7671
+ this.closeDialog();
7672
+ this.detailChannelId = null;
7673
+ await this.refresh();
7674
+ this.host.toast("Channel archived. Its remaining seats moved to the destination you chose.", "ok");
7675
+ } catch (err) {
7676
+ if (err instanceof ManageApiError && err.status === 409 && err.code === "channel_archive_blocked_by_holds") {
7677
+ this.dialog = {
7678
+ kind: "archive",
7679
+ channelId,
7680
+ archiveBlocked: err.details ?? {}
7681
+ };
7682
+ this.renderDialog();
7683
+ return;
7684
+ }
7685
+ this.showDialogError("Couldn't archive that channel. Try again.");
7686
+ this.host.onError(err);
7687
+ }
7688
+ }
7689
+ /**
7690
+ * The synchronized inventory list (§13): the keyboard and screen-reader
7691
+ * equivalent of canvas click / marquee / brush, grouped by section with
7692
+ * per-section select actions.
7693
+ */
7694
+ renderSeatListDialog() {
7695
+ const selected = new Set(this.host.selectionLabels());
7696
+ const groups = /* @__PURE__ */ new Map();
7697
+ let total = 0;
7698
+ for (const seat of this.host.seats()) {
7699
+ total += 1;
7700
+ if (total > this.seatListLimit) break;
7701
+ const section = this.host.sectionOfLabel(seat.label);
7702
+ const key = section?.id ?? "";
7703
+ const group = groups.get(key) ?? { label: section?.label ?? "Other seats", seats: [] };
7704
+ group.seats.push({ label: seat.label, status: this.host.statusOf(seat.label) ?? "free" });
7705
+ groups.set(key, group);
7706
+ }
7707
+ const body = [...groups.entries()].map(([id, group]) => `
7708
+ <div class="slm-ch-seatgroup">
7709
+ <span>${esc(group.label)}</span>
7710
+ ${id ? `<button type="button" data-ch-section="${esc(id)}">Select section</button>` : ""}
7711
+ </div>
7712
+ ${group.seats.map((seat) => {
7713
+ const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
7714
+ const channelName = this.nameOf(channelId) ?? PUBLIC_CHANNEL_NAME;
7715
+ return `<button type="button" class="slm-ch-seatitem" role="checkbox"
7716
+ aria-checked="${selected.has(seat.label)}" data-ch-seat="${esc(seat.label)}">
7717
+ <span class="box" aria-hidden="true">\u2713</span>
7718
+ <span>${esc(seat.label)}</span>
7719
+ <span class="meta">${esc(channelName)} \xB7 ${esc(seat.status)}</span>
7720
+ </button>`;
7721
+ }).join("")}`).join("");
7722
+ this.renderScrim(`
7723
+ <h3 id="slm-ch-dlg-title">Seat list</h3>
7724
+ <p class="sub">The same selection as the map, with checkboxes. Space or Enter toggles a seat.</p>
7725
+ <div class="slm-ch-seatlist">${body || '<div class="slm-empty">No seats on this chart.</div>'}</div>
7726
+ ${total > this.seatListLimit ? `<div class="foot"><button type="button" class="slm-btn ghost" data-ch-more>Show more seats</button></div>` : ""}
7727
+ <div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>`, (dialog) => {
7728
+ dialog.querySelectorAll("[data-ch-seat]").forEach((button) => {
7729
+ button.addEventListener("click", () => {
7730
+ const label = button.dataset.chSeat;
7731
+ const next = new Set(this.host.selectionLabels());
7732
+ if (next.has(label)) next.delete(label);
7733
+ else next.add(label);
7734
+ this.host.clearSelection();
7735
+ if (next.size) this.host.selectByLabels([...next]);
7736
+ this.renderSeatListDialog();
7737
+ });
7738
+ });
7739
+ dialog.querySelectorAll("[data-ch-section]").forEach((button) => {
7740
+ button.addEventListener("click", () => {
7741
+ this.host.selectSection(button.dataset.chSection);
7742
+ this.renderSeatListDialog();
7743
+ });
7744
+ });
7745
+ dialog.querySelector("[data-ch-more]")?.addEventListener("click", () => {
7746
+ this.seatListLimit += SEAT_LIST_PAGE;
7747
+ this.renderSeatListDialog();
7748
+ });
7749
+ });
7750
+ }
7751
+ // ---- compact detents ------------------------------------------------------
7752
+ applySheetClasses() {
7753
+ const root = this.host.root;
7754
+ const compact = this.host.isCompact();
7755
+ root.classList.toggle("ch-sheet", compact && this.active);
7756
+ for (const detent of ["collapsed", "medium", "full"]) {
7757
+ root.classList.toggle(`detent-${detent}`, compact && this.active && this.detent === detent);
7758
+ }
7759
+ this.host.setMapInert(compact && this.active && this.detent === "full");
7760
+ }
7761
+ cycleDetent() {
7762
+ const order = ["collapsed", "medium", "full"];
7763
+ this.detent = order[(order.indexOf(this.detent) + 1) % order.length];
7764
+ this.applySheetClasses();
7765
+ }
7766
+ /** Back/Close from the full detent returns to the previous one and keeps the
7767
+ * selection — losing a hard-won selection to a Back press is unforgivable. */
7768
+ handleBack() {
7769
+ if (this.scrimEl) {
7770
+ this.closeDialog();
7771
+ return true;
7772
+ }
7773
+ if (this.host.isCompact() && this.detent === "full") {
7774
+ this.detent = "medium";
7775
+ this.applySheetClasses();
7776
+ return true;
7777
+ }
7778
+ return false;
7779
+ }
7780
+ };
7781
+
5180
7782
  // src/SeatManager.ts
5181
7783
  function availabilityModeOf(rule) {
5182
7784
  return rule ? rule.mode : "open";
@@ -5433,7 +8035,7 @@ var CSS2 = `
5433
8035
  .slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
5434
8036
  .slm-liveevent,.slm-sectionrow{transition:none!important}
5435
8037
  }
5436
- `;
8038
+ ${CHANNELS_CSS}`;
5437
8039
  function injectStyle() {
5438
8040
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
5439
8041
  const el = document.createElement("style");
@@ -5470,7 +8072,7 @@ function fmtMoney(amount, currency) {
5470
8072
  return `${currency} ${Math.round(amount).toLocaleString()}`;
5471
8073
  }
5472
8074
  }
5473
- function esc(value) {
8075
+ function esc2(value) {
5474
8076
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
5475
8077
  }
5476
8078
  var SeatManager = class {
@@ -5525,6 +8127,10 @@ var SeatManager = class {
5525
8127
  this.blockedSection = "";
5526
8128
  this.blockedResultLimit = 100;
5527
8129
  this.unblockAllConfirmTimer = null;
8130
+ // Sales channels (M6b). The mode object is built only once the token is known
8131
+ // to carry `event:channels:view`; until then there is no pill and no rail.
8132
+ this.channels = null;
8133
+ this.channelCaps = { view: false, manage: false };
5528
8134
  this.onFullscreenChange = () => {
5529
8135
  this.paintFullscreenButton();
5530
8136
  this.updateContainerLayout();
@@ -5539,8 +8145,13 @@ var SeatManager = class {
5539
8145
  else if (key === "i") this.setMode("inspect");
5540
8146
  else if (key === "b") this.setMode("block");
5541
8147
  else if (key === "s") this.setMode("sections");
5542
- else if (key === "f") this.toggleFullscreen();
5543
- else return;
8148
+ else if (key === "c") {
8149
+ if (!this.channels) return;
8150
+ this.setMode("channels");
8151
+ } else if (key === "f") this.toggleFullscreen();
8152
+ else if (key === "escape") {
8153
+ if (!this.channels?.handleBack()) return;
8154
+ } else return;
5544
8155
  event.preventDefault();
5545
8156
  };
5546
8157
  this.onRailClick = (event) => {
@@ -5591,6 +8202,7 @@ var SeatManager = class {
5591
8202
  this.connect();
5592
8203
  this.startFeedClock();
5593
8204
  this.ready = true;
8205
+ await this.resolveChannelCapabilities();
5594
8206
  this.setMode(this.mode);
5595
8207
  this.scheduleTokenRefresh();
5596
8208
  this.opts.onReady?.();
@@ -5601,16 +8213,119 @@ var SeatManager = class {
5601
8213
  }
5602
8214
  // ---- public API -----------------------------------------------------------
5603
8215
  setMode(mode) {
8216
+ if (mode === "channels" && !this.channels) mode = "view";
5604
8217
  const changed = mode !== this.mode;
8218
+ const wasChannels = this.mode === "channels";
5605
8219
  this.mode = mode;
5606
8220
  if (!this.renderer && this.doc) this.buildRenderer();
5607
8221
  else this.updateRendererInteraction();
5608
8222
  if (changed) this.renderer?.clearSelection();
8223
+ if (wasChannels && mode !== "channels") this.channels?.leave();
5609
8224
  this.paintModeTabs();
5610
8225
  this.paintRail();
5611
8226
  this.applySectionCanvasTreatment();
8227
+ if (mode === "channels") this.channels?.enter();
5612
8228
  if (changed) this.opts.onModeChange?.(mode);
5613
8229
  }
8230
+ /**
8231
+ * Decide what this token may do with sales channels.
8232
+ *
8233
+ * Declared capabilities win — a host that mints an `mse_…` grant knows exactly
8234
+ * what it asked for. Otherwise a tenant secret (`sk_…`) is org authority the
8235
+ * worker never narrows, so it is fully capable; and a delegated token with no
8236
+ * declaration is probed for read access and then treated as READ-ONLY, because
8237
+ * "we could not tell" must never render mutation controls.
8238
+ */
8239
+ async resolveChannelCapabilities() {
8240
+ const declared = this.opts.capabilities;
8241
+ if (declared) {
8242
+ const set = new Set(declared);
8243
+ this.channelCaps = {
8244
+ view: set.has("event:channels:view"),
8245
+ manage: set.has("event:channels:view") && set.has("event:channels:manage")
8246
+ };
8247
+ } else if (/^sk_/.test(this.opts.token)) {
8248
+ this.channelCaps = { view: true, manage: true };
8249
+ } else {
8250
+ this.channelCaps = { view: false, manage: false };
8251
+ }
8252
+ if (!this.channelCaps.view && !declared && !/^sk_/.test(this.opts.token)) {
8253
+ try {
8254
+ await this.api.channels(this.key);
8255
+ this.channelCaps = { view: true, manage: false };
8256
+ } catch {
8257
+ this.channelCaps = { view: false, manage: false };
8258
+ }
8259
+ }
8260
+ if (!this.channelCaps.view) {
8261
+ this.channels?.destroy();
8262
+ this.channels = null;
8263
+ this.paintModeTabs();
8264
+ return;
8265
+ }
8266
+ if (this.channels) {
8267
+ this.channels.setCapabilities(this.channelCaps);
8268
+ } else {
8269
+ this.channels = new ChannelsMode(this.buildChannelsHost(), this.channelCaps);
8270
+ this.channels.onInteractionChange = () => this.updateRendererInteraction();
8271
+ }
8272
+ this.paintModeTabs();
8273
+ }
8274
+ /** The adapter between the cockpit's internals and Channels mode. */
8275
+ buildChannelsHost() {
8276
+ return {
8277
+ eventKey: this.key,
8278
+ api: this.api,
8279
+ rail: this.els.rail,
8280
+ mapLayer: this.root.querySelector(".slm-map"),
8281
+ root: this.root,
8282
+ seats: () => [...this.labelToSeat.values()].map((seat) => ({
8283
+ id: seat.id,
8284
+ label: seat.label,
8285
+ x: seat.x,
8286
+ y: seat.y
8287
+ })),
8288
+ statusOf: (label) => this.status.get(label) ?? (this.labelToSeat.has(label) ? "free" : void 0),
8289
+ selectionLabels: () => this.selectionLabels(),
8290
+ selectByLabels: (labels) => {
8291
+ this.selectByLabels(labels);
8292
+ },
8293
+ clearSelection: () => this.clearSelection(),
8294
+ selectSection: (sectionId) => {
8295
+ this.selectSection(sectionId);
8296
+ },
8297
+ sections: () => this.sectionOptions,
8298
+ categories: () => (this.doc?.categories ?? []).map((category) => ({
8299
+ key: category.key,
8300
+ label: category.label ?? category.key,
8301
+ color: category.color
8302
+ })),
8303
+ labelsInCategory: (key) => [...this.labelToSeat.entries()].filter(([, seat]) => seat.categoryKey === key).map(([label]) => label),
8304
+ sectionOfLabel: (label) => {
8305
+ const seat = this.labelToSeat.get(label);
8306
+ if (!seat) return null;
8307
+ const id = this.sectionByObject.get(seat.rowId) ?? import_core3.UNGROUPED_ID;
8308
+ return { id, label: this.sectionLabelById.get(id) ?? "Other seats" };
8309
+ },
8310
+ worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
8311
+ seatPixelSize: () => this.seatPixelSize(),
8312
+ isCompact: () => !!this.root?.classList.contains("compact"),
8313
+ setMapInert: (inert) => {
8314
+ this.mapHost.toggleAttribute("inert", inert);
8315
+ this.mapHost.setAttribute("aria-hidden", String(inert));
8316
+ },
8317
+ toast: (message, kind) => this.toast(message, kind),
8318
+ onError: (err) => this.opts.onError?.(err)
8319
+ };
8320
+ }
8321
+ /** Approximate on-screen seat size, for the channel overlay's marks. Derived
8322
+ * from the live camera so the overlay tracks zoom without a renderer hook. */
8323
+ seatPixelSize() {
8324
+ const rect = this.renderer?.getVisibleWorldRect?.();
8325
+ const width = this.mapHost?.clientWidth ?? 0;
8326
+ if (!rect?.width || !width) return 6;
8327
+ return Math.max(3, Math.min(24, width / rect.width * 14));
8328
+ }
5614
8329
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
5615
8330
  setHeatOverlay(enabled) {
5616
8331
  this.heatEnabled = enabled;
@@ -5650,14 +8365,15 @@ var SeatManager = class {
5650
8365
  return typeof document !== "undefined" && document.fullscreenElement === this.root;
5651
8366
  }
5652
8367
  toggleFullscreen() {
5653
- const request2 = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
5654
- void request2.catch((err) => this.opts.onError?.(err));
8368
+ const request = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
8369
+ void request.catch((err) => this.opts.onError?.(err));
5655
8370
  }
5656
8371
  /** Rotate the delegated credential without rebuilding DOM, canvas or socket. */
5657
8372
  setToken(token, expiresAt) {
5658
8373
  this.api.setToken(token);
5659
8374
  this.tokenExpiresAt = expiresAt ?? null;
5660
8375
  this.scheduleTokenRefresh();
8376
+ if (this.ready) void this.resolveChannelCapabilities();
5661
8377
  }
5662
8378
  scheduleTokenRefresh() {
5663
8379
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
@@ -5816,6 +8532,8 @@ var SeatManager = class {
5816
8532
  if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
5817
8533
  if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
5818
8534
  if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
8535
+ this.channels?.destroy();
8536
+ this.channels = null;
5819
8537
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
5820
8538
  this.layoutObserver?.disconnect();
5821
8539
  this.layoutObserver = null;
@@ -5836,32 +8554,51 @@ var SeatManager = class {
5836
8554
  // ---- renderer lifecycle ---------------------------------------------------
5837
8555
  buildRenderer() {
5838
8556
  if (!this.doc) return;
5839
- const block = this.mode === "block";
5840
- const inspect = this.mode === "inspect";
8557
+ const bulk = this.isBulkSelectMode();
5841
8558
  this.renderer = new import_core3.SeatmapRenderer(this.mapHost, {
5842
8559
  manageMode: true,
5843
- marqueeSelect: block,
8560
+ marqueeSelect: bulk,
5844
8561
  maxSelection: 1e6,
5845
- selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : [],
8562
+ selectableStatuses: this.selectableStatuses(),
5846
8563
  currency: this.currency,
5847
8564
  onSelect: (seat) => this.handleSeatSelect(seat),
5848
8565
  onDeselect: () => this.syncSelection(),
5849
8566
  onMarquee: () => this.syncSelection(),
5850
- onViewChange: () => this.updateZoomHint()
8567
+ onViewChange: () => {
8568
+ this.updateZoomHint();
8569
+ this.channels?.handleViewChange();
8570
+ }
5851
8571
  });
5852
8572
  this.renderer.setChart(this.doc);
5853
8573
  this.repaintAll();
5854
8574
  this.applyHeatOverlay();
5855
8575
  this.updateZoomHint();
5856
8576
  }
8577
+ /** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
8578
+ * section. The two differ only in WHICH statuses they may act on. */
8579
+ isBulkSelectMode() {
8580
+ return this.mode === "block" || this.mode === "channels" && this.channels?.canSelect() === true;
8581
+ }
8582
+ /**
8583
+ * Block never touches held or booked inventory, so it cannot select it.
8584
+ * Channels must be able to select it — the Review sheet's honesty depends on
8585
+ * counting the held and sold units inside a marquee and saying they will not
8586
+ * move, rather than silently omitting them from the selection.
8587
+ */
8588
+ selectableStatuses() {
8589
+ if (this.mode === "block") return ["free", "not_for_sale"];
8590
+ if (this.mode === "inspect" || this.isBulkSelectMode()) {
8591
+ return ["free", "held", "booked", "not_for_sale"];
8592
+ }
8593
+ return [];
8594
+ }
5857
8595
  updateRendererInteraction() {
5858
- const block = this.mode === "block";
5859
- const inspect = this.mode === "inspect";
8596
+ const bulk = this.isBulkSelectMode();
5860
8597
  this.renderer?.setManageInteraction({
5861
8598
  manageMode: true,
5862
- marqueeSelect: block,
8599
+ marqueeSelect: bulk,
5863
8600
  maxSelection: 1e6,
5864
- selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : []
8601
+ selectableStatuses: this.selectableStatuses()
5865
8602
  });
5866
8603
  this.updateZoomHint();
5867
8604
  }
@@ -6113,7 +8850,7 @@ var SeatManager = class {
6113
8850
  const place = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : activity.label;
6114
8851
  const noun = activity.count === 1 ? "seat" : "seats";
6115
8852
  element.innerHTML = `<span class="slm-liveeventdot" style="background:${this.activityColor(activity.status)}"></span>
6116
- <span class="slm-liveeventcopy">${esc(place)} \xB7 ${activity.count.toLocaleString()} ${noun} ${esc(activity.verb)}</span>
8853
+ <span class="slm-liveeventcopy">${esc2(place)} \xB7 ${activity.count.toLocaleString()} ${noun} ${esc2(activity.verb)}</span>
6117
8854
  <span class="slm-liveeventhint">Live</span>`;
6118
8855
  element.classList.add("on");
6119
8856
  if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
@@ -6133,10 +8870,10 @@ var SeatManager = class {
6133
8870
  this.recomputeTallies();
6134
8871
  }
6135
8872
  async refreshControlRoom() {
6136
- const request2 = ++this.revenueRequest;
8873
+ const request = ++this.revenueRequest;
6137
8874
  try {
6138
8875
  const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
6139
- if (request2 === this.revenueRequest) {
8876
+ if (request === this.revenueRequest) {
6140
8877
  this.controlRoomSnapshot = snapshot;
6141
8878
  this.lastSyncedAt = Date.now();
6142
8879
  this.authoritativeGrossRevenue = snapshot.revenue.gross;
@@ -6149,7 +8886,7 @@ var SeatManager = class {
6149
8886
  }
6150
8887
  return snapshot;
6151
8888
  } catch (err) {
6152
- if (request2 === this.revenueRequest) {
8889
+ if (request === this.revenueRequest) {
6153
8890
  this.revenueStatus = "stale";
6154
8891
  this.recomputeTallies();
6155
8892
  }
@@ -6193,6 +8930,7 @@ var SeatManager = class {
6193
8930
  this.paintMonitorInsights();
6194
8931
  } else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
6195
8932
  else if (this.mode === "block") this.paintSelBar(this.getSelection());
8933
+ else if (this.mode === "channels") this.channels?.handleSelectionChange();
6196
8934
  this.opts.onTallies?.(t3);
6197
8935
  }
6198
8936
  verbFor(prev, next) {
@@ -6281,6 +9019,7 @@ var SeatManager = class {
6281
9019
  const seats = this.getSelection();
6282
9020
  if (this.mode === "block") this.paintSelBar(seats);
6283
9021
  else if (this.mode === "inspect") this.renderInspectRail(seats);
9022
+ else if (this.mode === "channels") this.channels?.handleSelectionChange();
6284
9023
  this.opts.onSelectionChange?.(seats);
6285
9024
  }
6286
9025
  // ---- DOM: chrome ----------------------------------------------------------
@@ -6299,7 +9038,9 @@ var SeatManager = class {
6299
9038
  <button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
6300
9039
  <button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
6301
9040
  <button class="slm-mode" role="tab" data-mode="sections" title="Sections (S)" aria-keyshortcuts="S">Sections</button>
9041
+ <button class="slm-mode" role="tab" data-mode="channels" title="Channels (C)" aria-keyshortcuts="C" hidden>Channels</button>
6302
9042
  </div>
9043
+ <select class="slm-tools" data-ref="tools" aria-label="Manager tools"></select>
6303
9044
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
6304
9045
  <div class="slm-bar-actions">
6305
9046
  <button class="slm-barbtn follow" data-ref="follow" aria-pressed="false"
@@ -6333,6 +9074,7 @@ var SeatManager = class {
6333
9074
  this.mapHost = ref("maphost");
6334
9075
  this.els = {
6335
9076
  modes: ref("modes"),
9077
+ tools: ref("tools"),
6336
9078
  livetext: ref("livetext"),
6337
9079
  kpis: ref("kpis"),
6338
9080
  follow: ref("follow"),
@@ -6345,6 +9087,7 @@ var SeatManager = class {
6345
9087
  zfit: ref("zfit")
6346
9088
  };
6347
9089
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
9090
+ this.els.tools.addEventListener("change", () => this.setMode(this.els.tools.value));
6348
9091
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
6349
9092
  this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
6350
9093
  this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
@@ -6360,6 +9103,7 @@ var SeatManager = class {
6360
9103
  updateContainerLayout() {
6361
9104
  const width = this.root?.getBoundingClientRect().width || this.host.clientWidth;
6362
9105
  this.root?.classList.toggle("compact", width > 0 && width < 800);
9106
+ this.channels?.handleLayoutChange();
6363
9107
  }
6364
9108
  buildSectionOptions() {
6365
9109
  if (!this.doc) return;
@@ -6381,13 +9125,24 @@ var SeatManager = class {
6381
9125
  }
6382
9126
  }
6383
9127
  paintModeTabs() {
9128
+ const available = [];
6384
9129
  this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
6385
9130
  const el = b;
6386
- const active = el.dataset.mode === this.mode;
9131
+ const mode = el.dataset.mode;
9132
+ const permitted = mode !== "channels" || !!this.channels;
9133
+ el.hidden = !permitted;
9134
+ if (!permitted) return;
9135
+ available.push({ mode, label: el.textContent ?? mode });
9136
+ const active = mode === this.mode;
6387
9137
  el.classList.toggle("on", active);
6388
9138
  el.setAttribute("aria-selected", String(active));
6389
9139
  el.tabIndex = active ? 0 : -1;
6390
9140
  });
9141
+ const tools = this.els.tools;
9142
+ if (tools) {
9143
+ tools.innerHTML = available.map((entry) => `<option value="${entry.mode}"${entry.mode === this.mode ? " selected" : ""}>${esc2(entry.label)}</option>`).join("");
9144
+ tools.value = this.mode;
9145
+ }
6391
9146
  this.root?.classList.toggle("block-mode", this.mode === "block");
6392
9147
  }
6393
9148
  paintFollowLiveButton() {
@@ -6492,6 +9247,7 @@ var SeatManager = class {
6492
9247
  if (this.mode === "view") this.renderViewRail();
6493
9248
  else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
6494
9249
  else if (this.mode === "sections") this.renderSectionsRail();
9250
+ else if (this.mode === "channels") this.channels?.paintRail();
6495
9251
  else this.renderBlockRail();
6496
9252
  this.updateZoomHint();
6497
9253
  }
@@ -6557,8 +9313,8 @@ var SeatManager = class {
6557
9313
  const net = speed?.netBooked ?? 0;
6558
9314
  const netLabel = `${net > 0 ? "+" : ""}${net}`;
6559
9315
  const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
6560
- return `<button type="button" class="slm-sectionrow" data-section-focus="${esc(row.sectionId)}" title="Focus ${esc(row.sectionLabel)} on the map">
6561
- <span class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></span>
9316
+ return `<button type="button" class="slm-sectionrow" data-section-focus="${esc2(row.sectionId)}" title="Focus ${esc2(row.sectionLabel)} on the map">
9317
+ <span class="slm-sectiontop"><span>${esc2(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></span>
6562
9318
  <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>
6563
9319
  </button>`;
6564
9320
  }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
@@ -6603,12 +9359,12 @@ var SeatManager = class {
6603
9359
  <p class="slm-eyebrow">${itemKind} details</p>
6604
9360
  <p class="slm-hint">Live availability and section performance.</p>
6605
9361
  <div class="slm-inspect-card">
6606
- <div class="slm-inspect-label">${esc(seat.label)}</div>
9362
+ <div class="slm-inspect-label">${esc2(seat.label)}</div>
6607
9363
  <div class="slm-inspect-grid">
6608
9364
  <div><span>Status</span><b>${statusLabel[status]}</b></div>
6609
- <div><span>Section</span><b>${esc(sectionLabel)}</b></div>
6610
- ${location2 ? `<div><span>${location2.label}</span><b>${esc(location2.value)}</b></div>` : ""}
6611
- <div><span>Category</span><b>${esc(category?.label ?? seat.categoryKey)}</b></div>
9365
+ <div><span>Section</span><b>${esc2(sectionLabel)}</b></div>
9366
+ ${location2 ? `<div><span>${location2.label}</span><b>${esc2(location2.value)}</b></div>` : ""}
9367
+ <div><span>Category</span><b>${esc2(category?.label ?? seat.categoryKey)}</b></div>
6612
9368
  <div><span>Sold in section</span><b>${sectionMetric ? `${sectionMetric.booked} of ${sectionMetric.total}` : "\u2014"}</b></div>
6613
9369
  <div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>
6614
9370
  </div>
@@ -6754,7 +9510,7 @@ var SeatManager = class {
6754
9510
  <div class="slm-availlist" data-ref="availlist">${rows.map((row) => this.sectionRowHtml(row)).join("")}</div>
6755
9511
  <div class="slm-availsummary">
6756
9512
  <span class="slm-availdot${warn ? " warn" : ""}"></span>
6757
- <span>${esc(summary)}</span>
9513
+ <span>${esc2(summary)}</span>
6758
9514
  </div>
6759
9515
  <div class="slm-availcallout">
6760
9516
  <span class="slm-availstar" aria-hidden="true">\u2726</span>
@@ -6769,7 +9525,7 @@ var SeatManager = class {
6769
9525
  const disabled = this.availabilitySaving ? " disabled" : "";
6770
9526
  const option = (value, text) => `<option value="${value}"${mode === value ? " selected" : ""}>${text}</option>`;
6771
9527
  const control = row.followsZone ? '<span class="slm-availfollows">Follows zone</span>' : `<span class="slm-availselwrap">
6772
- <select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc(row.id)}"${disabled} aria-label="Availability for ${esc(row.label)}">
9528
+ <select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc2(row.id)}"${disabled} aria-label="Availability for ${esc2(row.label)}">
6773
9529
  ${option("open", "Open \u2014 on sale")}
6774
9530
  ${option("closed", "Closed \u2014 visible, not on sale")}
6775
9531
  ${option("hidden", "Hidden \u2014 off the buyer map")}
@@ -6779,15 +9535,15 @@ var SeatManager = class {
6779
9535
  </span>`;
6780
9536
  let detail = "";
6781
9537
  if (!row.followsZone && mode === "timed") {
6782
- const value = row.rule?.revealAt ? esc(toLocalInput(row.rule.revealAt)) : "";
9538
+ const value = row.rule?.revealAt ? esc2(toLocalInput(row.rule.revealAt)) : "";
6783
9539
  detail = `<div class="slm-availdetail">
6784
- <input type="datetime-local" class="slm-input" data-avail-reveal="${esc(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc(row.label)}" />
9540
+ <input type="datetime-local" class="slm-input" data-avail-reveal="${esc2(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc2(row.label)}" />
6785
9541
  </div>`;
6786
9542
  } else if (!row.followsZone && mode === "threshold") {
6787
9543
  const pct = row.rule?.thresholdPct ?? 80;
6788
9544
  detail = `<div class="slm-availdetail">
6789
9545
  <span class="slm-availpctlabel">Reveal at</span>
6790
- <input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${esc(row.id)}" value="${esc(pct)}"${disabled} aria-label="Percent sold to reveal ${esc(row.label)}" />
9546
+ <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)}" />
6791
9547
  <span class="slm-availpctlabel">% sold</span>
6792
9548
  </div>`;
6793
9549
  }
@@ -6795,7 +9551,7 @@ var SeatManager = class {
6795
9551
  const caret = row.kind === "zone" ? `<span class="slm-availcaret" aria-hidden="true">${row.hidden ? "\u25B8" : "\u25BE"}</span>` : "";
6796
9552
  return `<div class="${cls}">
6797
9553
  <div class="slm-availhead">
6798
- <span class="slm-availlabel">${caret}${esc(row.label)}</span>
9554
+ <span class="slm-availlabel">${caret}${esc2(row.label)}</span>
6799
9555
  ${badge}
6800
9556
  <span class="slm-availcount">${row.seatCount.toLocaleString()}</span>
6801
9557
  ${control}
@@ -6884,25 +9640,25 @@ var SeatManager = class {
6884
9640
  const extra = a.count > 1 ? ` +${a.count - 1}` : "";
6885
9641
  const sections = a.sectionLabels ?? [];
6886
9642
  const sectionCopy = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : "";
6887
- return `<button type="button" class="slm-feedrow" data-feed-id="${esc(a.id)}" title="Locate this activity on the map">
9643
+ return `<button type="button" class="slm-feedrow" data-feed-id="${esc2(a.id)}" title="Locate this activity on the map">
6888
9644
  <span class="slm-feeddot" style="background:${color[a.status]}"></span>
6889
- <span class="slm-feedtext">${sectionCopy ? `<span class="slm-feedsection">${esc(sectionCopy)}</span>` : ""}${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
9645
+ <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>
6890
9646
  <span class="slm-feedmeta"><span class="slm-feedtime">${relTime(a.at, now)}</span><span class="slm-feedlocate">Locate</span></span>
6891
9647
  </button>`;
6892
9648
  }).join("");
6893
9649
  }
6894
9650
  renderBlockRail() {
6895
9651
  const cats = this.doc?.categories ?? [];
6896
- const catChips = cats.map((c) => `<button class="slm-chip" type="button" data-cat="${esc(c.key)}" aria-pressed="false">
6897
- <span class="dot" style="background:${esc(c.color ?? "#6e7bff")}"></span>
6898
- <span>${esc(c.label ?? c.key)}</span>
9652
+ const catChips = cats.map((c) => `<button class="slm-chip" type="button" data-cat="${esc2(c.key)}" aria-pressed="false">
9653
+ <span class="dot" style="background:${esc2(c.color ?? "#6e7bff")}"></span>
9654
+ <span>${esc2(c.label ?? c.key)}</span>
6899
9655
  <span class="slm-chipcount" data-cat-count>0</span>
6900
9656
  <span class="slm-chipcheck" aria-hidden="true">\u2713</span>
6901
9657
  </button>`).join("");
6902
9658
  const sectionField = this.sectionOptions.length ? `<div class="slm-field"><label>Select a whole section</label>
6903
9659
  <select class="slm-select" data-ref="section"><option value="">Choose a section\u2026</option>
6904
- ${this.sectionOptions.map((s) => `<option value="${esc(s.id)}">${esc(s.label)}</option>`).join("")}</select></div>` : "";
6905
- const blockedSectionOptions = this.sectionOptions.map((s) => `<option value="${esc(s.id)}">${esc(s.label)}</option>`).join("");
9660
+ ${this.sectionOptions.map((s) => `<option value="${esc2(s.id)}">${esc2(s.label)}</option>`).join("")}</select></div>` : "";
9661
+ const blockedSectionOptions = this.sectionOptions.map((s) => `<option value="${esc2(s.id)}">${esc2(s.label)}</option>`).join("");
6906
9662
  this.els.rail.innerHTML = `
6907
9663
  <p class="slm-eyebrow">Block &amp; unblock</p>
6908
9664
  <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>
@@ -7105,10 +9861,10 @@ var SeatManager = class {
7105
9861
  const section = this.sectionLabelById.get(sectionId) ?? "Other seats";
7106
9862
  const category = this.doc?.categories.find((item) => item.key === seat.categoryKey)?.label ?? seat.categoryKey;
7107
9863
  const isSelected = selected.has(seat.label);
7108
- return `<button type="button" class="slm-blockeditem${isSelected ? " on" : ""}" data-blocked-label="${esc(seat.label)}" aria-pressed="${isSelected}">
9864
+ return `<button type="button" class="slm-blockeditem${isSelected ? " on" : ""}" data-blocked-label="${esc2(seat.label)}" aria-pressed="${isSelected}">
7109
9865
  <span class="slm-blockedcheck" aria-hidden="true">\u2713</span>
7110
- <span class="slm-blockedcopy"><span class="slm-blockedlabel">${esc(seat.label)}</span>
7111
- <span class="slm-blockedmeta">${esc(section)} \xB7 ${esc(category)}</span></span>
9866
+ <span class="slm-blockedcopy"><span class="slm-blockedlabel">${esc2(seat.label)}</span>
9867
+ <span class="slm-blockedmeta">${esc2(section)} \xB7 ${esc2(category)}</span></span>
7112
9868
  </button>`;
7113
9869
  }).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>`;
7114
9870
  const markAll = this.els.markall;
@@ -7175,12 +9931,33 @@ var SeatManager = class {
7175
9931
  // Annotate the CommonJS export names for ESM import in node:
7176
9932
  0 && (module.exports = {
7177
9933
  ApiError,
9934
+ BuyerAccessContext,
9935
+ BuyerAccessUnavailableError,
9936
+ BuyerRealtimeClient,
9937
+ ChannelsMode,
7178
9938
  EmbeddedDesigner,
7179
9939
  ManageApi,
7180
9940
  ManageApiError,
9941
+ PUBLIC_CHANNEL_ID,
9942
+ PUBLIC_CHANNEL_NAME,
7181
9943
  SeatManager,
7182
9944
  SeatPicker,
7183
9945
  SeatingChart,
7184
- attachPickerFrame
9946
+ accessIntentLabel,
9947
+ accessLine,
9948
+ attachPickerFrame,
9949
+ bucketRows,
9950
+ bucketRowsHtml,
9951
+ createBuyerAccessContext,
9952
+ createControllerSink,
9953
+ dropReviewRows,
9954
+ markerOf,
9955
+ mutationCount,
9956
+ needsMoveConfirmation,
9957
+ planAssignment,
9958
+ retryAfterCopy,
9959
+ selectionSources,
9960
+ stateBadge,
9961
+ suggestMarker
7185
9962
  });
7186
9963
  //# sourceMappingURL=index.cjs.map