@seatlayer/js 0.35.0 → 0.36.1

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