@seatlayer/js 0.11.0 → 0.12.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
@@ -32,6 +32,7 @@ async function request(base, path, init = {}) {
32
32
  var PubApi = class {
33
33
  constructor(base) {
34
34
  this.base = base;
35
+ this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
35
36
  }
36
37
  chart(key) {
37
38
  return request(this.base, `/pub/events/${encodeURIComponent(key)}/chart`);
@@ -67,7 +68,8 @@ var PubApi = class {
67
68
  }
68
69
  socketUrl(key) {
69
70
  const wsBase = this.base.replace(/^http/, "ws");
70
- return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe`;
71
+ const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
72
+ return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
71
73
  }
72
74
  };
73
75
 
@@ -2186,7 +2188,7 @@ var ManageApi = class {
2186
2188
  return this.pub(`/pub/events/${encodeURIComponent(key)}/objects`);
2187
2189
  }
2188
2190
  socketUrl(key) {
2189
- return `${this.base.replace(/^http/, "ws")}/pub/events/${encodeURIComponent(key)}/subscribe`;
2191
+ return `${this.base.replace(/^http/, "ws")}/pub/events/${encodeURIComponent(key)}/subscribe?surface=manager`;
2190
2192
  }
2191
2193
  // ---- inventory writes (token) ----
2192
2194
  /** Take FREE seats off sale in one batched call. Optional `releaseAt` (epoch
@@ -2219,6 +2221,9 @@ var ManageApi = class {
2219
2221
  report(key) {
2220
2222
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
2221
2223
  }
2224
+ controlRoom(key, windowMinutes = 15) {
2225
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/control-room?window=${windowMinutes}`);
2226
+ }
2222
2227
  log(key, opts = {}) {
2223
2228
  const params = new URLSearchParams();
2224
2229
  if (opts.limit != null) params.set("limit", String(opts.limit));
@@ -2348,7 +2353,35 @@ var CSS2 = `
2348
2353
  .slm-toast.err{background:#c0392b;color:#fff;border-color:#c0392b}
2349
2354
  .slm-toast.ok{background:#1f7a4d;color:#fff;border-color:#1f7a4d}
2350
2355
 
2351
- @media (max-width:720px){.slm-rail{width:100%;border-left:0;border-top:1px solid var(--slm-line);height:44%}.slm-body{flex-direction:column}}
2356
+ /* control-room actions + insights */
2357
+ .slm-bar-actions{display:flex;align-items:center;gap:7px}
2358
+ .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
2359
+ .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
2360
+ .slm-sectionlist + .slm-eyebrow{margin-top:18px}
2361
+ .slm-sectionrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
2362
+ .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
2363
+ .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
2364
+ .slm-trend{font-size:10px;text-transform:uppercase;letter-spacing:.08em}.slm-trend.rising{color:#22a06b}.slm-trend.cooling{color:#f4b740}
2365
+ .slm-health{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
2366
+ .slm-healthitem{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
2367
+ .slm-healthitem b{display:block;font-size:17px;font-variant-numeric:tabular-nums}.slm-healthitem span{display:block;margin-top:2px;color:var(--slm-muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}
2368
+ .slm-sectionhead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-top:18px}
2369
+ .slm-windows{display:flex;gap:3px;padding:2px;border:1px solid var(--slm-line);border-radius:8px;background:var(--slm-surface)}
2370
+ .slm-window{padding:4px 6px;border-radius:6px;font-size:10px;font-weight:800;color:var(--slm-muted)}.slm-window.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
2371
+ .slm-momentumhelp{margin:10px 0 14px;padding:10px;border:1px solid rgba(244,183,64,.28);border-radius:10px;background:rgba(244,183,64,.07)}
2372
+ .slm-momentumhelp[hidden]{display:none}.slm-momentumscale{display:flex;align-items:center;gap:7px;color:var(--slm-muted);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.07em}
2373
+ .slm-momentumgradient{height:6px;min-width:64px;flex:1;border-radius:999px;background:linear-gradient(90deg,#f4b740,#ef4444)}
2374
+ .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
2375
+ .slm-inspect-card{padding:12px;border:1px solid var(--slm-line);border-radius:12px;background:var(--slm-surface)}
2376
+ .slm-inspect-label{font-size:24px;font-weight:850;letter-spacing:-.02em}.slm-inspect-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px}
2377
+ .slm-inspect-grid span{display:block;color:var(--slm-muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.slm-inspect-grid b{display:block;margin-top:3px;font-size:12.5px}
2378
+ .slm:fullscreen{border-radius:0;min-height:100vh;background:var(--slm-bg)}
2379
+ .slm:fullscreen .slm-bar{padding:14px 22px}.slm:fullscreen .slm-kpi b{font-size:21px}.slm:fullscreen .slm-rail{width:360px}
2380
+
2381
+ .slm.compact .slm-rail{width:100%;border-left:0;border-top:1px solid var(--slm-line);height:44%}
2382
+ .slm.compact .slm-body{flex-direction:column}
2383
+ .slm.compact .slm-bar{gap:8px;padding:8px}.slm.compact .slm-barbtn{padding:6px 9px}
2384
+ .slm.compact .slm-kpis{gap:9px}.slm.compact .slm-kpi:nth-child(n+5){display:none}
2352
2385
  `;
2353
2386
  function injectStyle() {
2354
2387
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
@@ -2386,6 +2419,9 @@ function fmtMoney(amount, currency) {
2386
2419
  return `${currency} ${Math.round(amount).toLocaleString()}`;
2387
2420
  }
2388
2421
  }
2422
+ function esc(value) {
2423
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2424
+ }
2389
2425
  var SeatManager = class {
2390
2426
  constructor(options) {
2391
2427
  this.els = {};
@@ -2396,8 +2432,14 @@ var SeatManager = class {
2396
2432
  this.labelToSeat = /* @__PURE__ */ new Map();
2397
2433
  this.allIds = [];
2398
2434
  this.status = /* @__PURE__ */ new Map();
2399
- this.priceByCat = /* @__PURE__ */ new Map();
2400
2435
  this.currency = "USD";
2436
+ this.authoritativeGrossRevenue = 0;
2437
+ this.revenueStatus = "loading";
2438
+ this.revenueRequest = 0;
2439
+ this.revenueRefreshTimer = null;
2440
+ this.controlRoomSnapshot = null;
2441
+ this.trendWindowMinutes = 15;
2442
+ this.heatEnabled = false;
2401
2443
  // realtime socket
2402
2444
  this.ws = null;
2403
2445
  this.reconnectTimer = null;
@@ -2408,12 +2450,37 @@ var SeatManager = class {
2408
2450
  this.feedTimer = null;
2409
2451
  this.toastTimer = null;
2410
2452
  this.releaseAt = null;
2453
+ this.layoutObserver = null;
2454
+ this.tokenExpiresAt = null;
2455
+ this.tokenRefreshTimer = null;
2456
+ this.tokenRefreshInFlight = false;
2457
+ this.sectionByObject = /* @__PURE__ */ new Map();
2458
+ this.sectionLabelById = /* @__PURE__ */ new Map();
2459
+ this.lastSyncedAt = null;
2460
+ this.onFullscreenChange = () => {
2461
+ this.paintFullscreenButton();
2462
+ this.updateContainerLayout();
2463
+ this.renderer?.forceDraw();
2464
+ };
2465
+ this.onKeyDown = (event) => {
2466
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
2467
+ const target = event.target;
2468
+ if (target?.matches('input,select,textarea,[contenteditable="true"]')) return;
2469
+ const key = event.key.toLowerCase();
2470
+ if (key === "m") this.setMode("view");
2471
+ else if (key === "i") this.setMode("inspect");
2472
+ else if (key === "b") this.setMode("block");
2473
+ else if (key === "f") this.toggleFullscreen();
2474
+ else return;
2475
+ event.preventDefault();
2476
+ };
2411
2477
  this.sectionOptions = [];
2412
2478
  this.opts = options;
2413
2479
  this.key = options.eventKey;
2414
2480
  this.mode = options.mode ?? "view";
2415
2481
  this.keepLive = options.keepLiveWhileHidden ?? true;
2416
2482
  this.currency = options.currency ?? "USD";
2483
+ this.tokenExpiresAt = options.tokenExpiresAt ?? null;
2417
2484
  this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE3, options.token);
2418
2485
  this.host = resolveContainer4(options.container);
2419
2486
  }
@@ -2425,7 +2492,6 @@ var SeatManager = class {
2425
2492
  const res = await this.api.chart(this.key);
2426
2493
  this.doc = res.doc;
2427
2494
  this.currency = res.event.currency ?? this.opts.currency ?? this.currency;
2428
- for (const cat of res.doc.categories) this.priceByCat.set(cat.key, cat.price ?? 0);
2429
2495
  const seats = expandChart2(res.doc);
2430
2496
  for (const s of seats) {
2431
2497
  this.labelToId.set(s.label, s.id);
@@ -2434,13 +2500,17 @@ var SeatManager = class {
2434
2500
  }
2435
2501
  this.buildRenderer();
2436
2502
  this.buildSectionOptions();
2437
- await this.resnapshot();
2503
+ await Promise.all([
2504
+ this.resnapshot(),
2505
+ this.refreshControlRoom().catch((err) => this.opts.onError?.(err))
2506
+ ]);
2438
2507
  this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
2439
2508
  });
2440
2509
  this.connect();
2441
2510
  this.startFeedClock();
2442
2511
  this.ready = true;
2443
2512
  this.setMode(this.mode);
2513
+ this.scheduleTokenRefresh();
2444
2514
  this.opts.onReady?.();
2445
2515
  } catch (err) {
2446
2516
  this.fail(err);
@@ -2449,11 +2519,82 @@ var SeatManager = class {
2449
2519
  }
2450
2520
  // ---- public API -----------------------------------------------------------
2451
2521
  setMode(mode) {
2452
- const changed = mode !== this.mode || !this.renderer;
2522
+ const changed = mode !== this.mode;
2453
2523
  this.mode = mode;
2454
- if (changed && this.doc) this.buildRenderer();
2524
+ if (!this.renderer && this.doc) this.buildRenderer();
2525
+ else this.updateRendererInteraction();
2526
+ if (changed) this.renderer?.clearSelection();
2455
2527
  this.paintModeTabs();
2456
2528
  this.paintRail();
2529
+ if (changed) this.opts.onModeChange?.(mode);
2530
+ }
2531
+ /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
2532
+ setHeatOverlay(enabled) {
2533
+ this.heatEnabled = enabled;
2534
+ this.applyHeatOverlay();
2535
+ this.paintHeatButton();
2536
+ }
2537
+ /** Change the current-vs-previous sales window and refresh the private projection. */
2538
+ setTrendWindow(windowMinutes) {
2539
+ const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
2540
+ this.trendWindowMinutes = Math.max(5, Math.min(60, normalized));
2541
+ this.paintTrendWindow();
2542
+ return this.refreshControlRoom();
2543
+ }
2544
+ async enterFullscreen() {
2545
+ if (!this.root?.requestFullscreen || this.isFullscreen()) return;
2546
+ await this.root.requestFullscreen();
2547
+ this.root.focus({ preventScroll: true });
2548
+ }
2549
+ async exitFullscreen() {
2550
+ if (typeof document === "undefined" || !this.isFullscreen()) return;
2551
+ await document.exitFullscreen();
2552
+ }
2553
+ isFullscreen() {
2554
+ return typeof document !== "undefined" && document.fullscreenElement === this.root;
2555
+ }
2556
+ toggleFullscreen() {
2557
+ const request2 = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
2558
+ void request2.catch((err) => this.opts.onError?.(err));
2559
+ }
2560
+ /** Rotate the delegated credential without rebuilding DOM, canvas or socket. */
2561
+ setToken(token, expiresAt) {
2562
+ this.api.setToken(token);
2563
+ this.tokenExpiresAt = expiresAt ?? null;
2564
+ this.scheduleTokenRefresh();
2565
+ }
2566
+ scheduleTokenRefresh() {
2567
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
2568
+ this.tokenRefreshTimer = null;
2569
+ const refresh = this.opts.onTokenRefresh;
2570
+ const expiresAt = this.tokenExpiresAt;
2571
+ if (this.closed || !refresh || !expiresAt || !Number.isFinite(expiresAt)) return;
2572
+ const remaining = expiresAt - Date.now();
2573
+ const lead = Math.min(12e4, Math.max(3e4, remaining * 0.2));
2574
+ const delay = Math.max(0, remaining - lead);
2575
+ this.tokenRefreshTimer = setTimeout(() => {
2576
+ this.tokenRefreshTimer = null;
2577
+ void this.rotateToken();
2578
+ }, delay);
2579
+ }
2580
+ async rotateToken() {
2581
+ if (this.closed || this.tokenRefreshInFlight || !this.opts.onTokenRefresh) return;
2582
+ this.tokenRefreshInFlight = true;
2583
+ try {
2584
+ const next = await this.opts.onTokenRefresh();
2585
+ if (!next?.token || !Number.isFinite(next.expiresAt)) throw new Error("invalid_token_refresh_result");
2586
+ this.setToken(next.token, next.expiresAt);
2587
+ } catch (err) {
2588
+ this.opts.onError?.(err);
2589
+ if (!this.closed) {
2590
+ this.tokenRefreshTimer = setTimeout(() => {
2591
+ this.tokenRefreshTimer = null;
2592
+ void this.rotateToken();
2593
+ }, 3e4);
2594
+ }
2595
+ } finally {
2596
+ this.tokenRefreshInFlight = false;
2597
+ }
2457
2598
  }
2458
2599
  /** Bulk block the given labels (or the current selection when omitted). */
2459
2600
  async block(labels, opts = {}) {
@@ -2538,7 +2679,13 @@ var SeatManager = class {
2538
2679
  return this.renderer?.getSelection() ?? [];
2539
2680
  }
2540
2681
  getReport() {
2541
- return this.api.report(this.key);
2682
+ return this.api.report(this.key).then((report) => {
2683
+ this.applyReportRevenue(report);
2684
+ return report;
2685
+ });
2686
+ }
2687
+ getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
2688
+ return this.setTrendWindow(windowMinutes);
2542
2689
  }
2543
2690
  getLog(opts = {}) {
2544
2691
  return this.api.log(this.key, opts);
@@ -2565,6 +2712,12 @@ var SeatManager = class {
2565
2712
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2566
2713
  if (this.feedTimer) clearInterval(this.feedTimer);
2567
2714
  if (this.toastTimer) clearTimeout(this.toastTimer);
2715
+ if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
2716
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
2717
+ this.layoutObserver?.disconnect();
2718
+ this.layoutObserver = null;
2719
+ this.root?.removeEventListener("keydown", this.onKeyDown);
2720
+ if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
2568
2721
  if (this.ws) {
2569
2722
  try {
2570
2723
  this.ws.close();
@@ -2576,26 +2729,45 @@ var SeatManager = class {
2576
2729
  this.renderer = null;
2577
2730
  if (this.root && this.root.parentNode === this.host) this.host.removeChild(this.root);
2578
2731
  }
2579
- // ---- renderer lifecycle (rebuilt per mode, like ManageEventPage) ----------
2732
+ // ---- renderer lifecycle ---------------------------------------------------
2580
2733
  buildRenderer() {
2581
2734
  if (!this.doc) return;
2582
- this.renderer?.destroy();
2583
2735
  const block = this.mode === "block";
2736
+ const inspect = this.mode === "inspect";
2584
2737
  this.renderer = new SeatmapRenderer(this.mapHost, {
2585
2738
  manageMode: true,
2586
2739
  marqueeSelect: block,
2587
2740
  maxSelection: 1e6,
2588
- selectableStatuses: block ? ["free", "not_for_sale", "booked"] : [],
2741
+ selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : [],
2589
2742
  currency: this.currency,
2590
- onSelect: () => this.syncSelection(),
2743
+ onSelect: (seat) => this.handleSeatSelect(seat),
2591
2744
  onDeselect: () => this.syncSelection(),
2592
2745
  onMarquee: () => this.syncSelection(),
2593
2746
  onViewChange: () => this.updateZoomHint()
2594
2747
  });
2595
2748
  this.renderer.setChart(this.doc);
2596
2749
  this.repaintAll();
2750
+ this.applyHeatOverlay();
2751
+ this.updateZoomHint();
2752
+ }
2753
+ updateRendererInteraction() {
2754
+ const block = this.mode === "block";
2755
+ const inspect = this.mode === "inspect";
2756
+ this.renderer?.setManageInteraction({
2757
+ manageMode: true,
2758
+ marqueeSelect: block,
2759
+ maxSelection: 1e6,
2760
+ selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : []
2761
+ });
2597
2762
  this.updateZoomHint();
2598
2763
  }
2764
+ handleSeatSelect(seat) {
2765
+ if (this.mode === "inspect") {
2766
+ const others = this.getSelection().filter((selected) => selected.id !== seat.id).map((selected) => selected.id);
2767
+ if (others.length) this.renderer?.deselect(others);
2768
+ }
2769
+ this.syncSelection();
2770
+ }
2599
2771
  repaintAll() {
2600
2772
  const r = this.renderer;
2601
2773
  if (!r) return;
@@ -2623,7 +2795,7 @@ var SeatManager = class {
2623
2795
  ws.onopen = () => {
2624
2796
  this.attempt = 0;
2625
2797
  this.setLive(true);
2626
- void this.resnapshot();
2798
+ void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
2627
2799
  };
2628
2800
  ws.onmessage = (e) => this.onMessage(e);
2629
2801
  ws.onclose = () => {
@@ -2655,11 +2827,25 @@ var SeatManager = class {
2655
2827
  }
2656
2828
  if (!msg || typeof msg !== "object") return;
2657
2829
  const m = msg;
2830
+ if (m.type === "presence") {
2831
+ if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
2832
+ this.controlRoomSnapshot = {
2833
+ ...this.controlRoomSnapshot,
2834
+ presence: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
2835
+ };
2836
+ this.lastSyncedAt = Date.now();
2837
+ this.recomputeTallies();
2838
+ this.paintMonitorInsights();
2839
+ this.opts.onControlRoom?.(this.controlRoomSnapshot);
2840
+ }
2841
+ return;
2842
+ }
2658
2843
  if (m.type === "hidden") return;
2659
2844
  if (m.seats && typeof m.seats === "object") {
2660
2845
  this.applySnapshot(m.seats);
2661
2846
  } else if (Array.isArray(m.changes)) {
2662
2847
  const ids = [];
2848
+ const groups = /* @__PURE__ */ new Map();
2663
2849
  for (const ch of m.changes) {
2664
2850
  const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
2665
2851
  const prev = this.status.get(ch.label) ?? "free";
@@ -2671,10 +2857,19 @@ var SeatManager = class {
2671
2857
  this.flash(id, st);
2672
2858
  ids.push(id);
2673
2859
  }
2674
- this.pushActivity(ch.label, prev, st);
2860
+ const verb = this.verbFor(prev, st);
2861
+ const groupKey = `${verb}:${st}`;
2862
+ const group = groups.get(groupKey) ?? { labels: [], verb, status: st };
2863
+ group.labels.push(ch.label);
2864
+ groups.set(groupKey, group);
2865
+ }
2866
+ for (const group of groups.values()) this.pushActivity(group.labels, group.verb, group.status);
2867
+ if (ids.length) {
2868
+ this.lastSyncedAt = Date.now();
2869
+ this.afterPaint();
2675
2870
  }
2676
- if (ids.length) this.afterPaint();
2677
2871
  this.recomputeTallies();
2872
+ if (ids.length) this.scheduleRevenueRefresh();
2678
2873
  }
2679
2874
  }
2680
2875
  async resnapshot() {
@@ -2690,6 +2885,7 @@ var SeatManager = class {
2690
2885
  next.set(label, ["free", "held", "booked", "blocked"].includes(st) ? st : "free");
2691
2886
  }
2692
2887
  this.status = next;
2888
+ this.lastSyncedAt = Date.now();
2693
2889
  this.repaintAll();
2694
2890
  this.afterPaint();
2695
2891
  this.recomputeTallies();
@@ -2713,6 +2909,47 @@ var SeatManager = class {
2713
2909
  else if (st === "booked") this.renderer?.flashSeat(id, "#22a06b");
2714
2910
  }
2715
2911
  // ---- tallies + feed -------------------------------------------------------
2912
+ applyReportRevenue(report) {
2913
+ this.authoritativeGrossRevenue = report.report.byCategory.reduce(
2914
+ (sum, row) => sum + (Number.isFinite(row.bookedRevenue) ? row.bookedRevenue : 0),
2915
+ 0
2916
+ );
2917
+ this.revenueStatus = "current";
2918
+ this.recomputeTallies();
2919
+ }
2920
+ async refreshControlRoom() {
2921
+ const request2 = ++this.revenueRequest;
2922
+ try {
2923
+ const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
2924
+ if (request2 === this.revenueRequest) {
2925
+ this.controlRoomSnapshot = snapshot;
2926
+ this.lastSyncedAt = Date.now();
2927
+ this.authoritativeGrossRevenue = snapshot.revenue.gross;
2928
+ this.currency = snapshot.currency;
2929
+ this.revenueStatus = "current";
2930
+ this.recomputeTallies();
2931
+ this.applyHeatOverlay();
2932
+ this.paintMonitorInsights();
2933
+ this.opts.onControlRoom?.(snapshot);
2934
+ }
2935
+ return snapshot;
2936
+ } catch (err) {
2937
+ if (request2 === this.revenueRequest) {
2938
+ this.revenueStatus = "stale";
2939
+ this.recomputeTallies();
2940
+ }
2941
+ throw err;
2942
+ }
2943
+ }
2944
+ scheduleRevenueRefresh(delay = 140) {
2945
+ this.revenueStatus = "stale";
2946
+ this.recomputeTallies();
2947
+ if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
2948
+ this.revenueRefreshTimer = setTimeout(() => {
2949
+ this.revenueRefreshTimer = null;
2950
+ void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
2951
+ }, delay);
2952
+ }
2716
2953
  recomputeTallies() {
2717
2954
  const t3 = {
2718
2955
  free: 0,
@@ -2722,24 +2959,24 @@ var SeatManager = class {
2722
2959
  total: this.allIds.length,
2723
2960
  capacityPct: 0,
2724
2961
  sellThroughPct: 0,
2725
- grossRevenue: 0,
2962
+ grossRevenue: this.authoritativeGrossRevenue,
2963
+ revenueStatus: this.revenueStatus,
2726
2964
  currency: this.currency
2727
2965
  };
2728
2966
  let nonFree = 0;
2729
- for (const [label, st] of this.status.entries()) {
2967
+ for (const st of this.status.values()) {
2730
2968
  t3[st] += 1;
2731
2969
  if (st !== "free") nonFree += 1;
2732
- if (st === "booked") {
2733
- const seat = this.labelToSeat.get(label);
2734
- if (seat) t3.grossRevenue += this.priceByCat.get(seat.categoryKey) ?? 0;
2735
- }
2736
2970
  }
2737
2971
  t3.free = Math.max(0, t3.total - nonFree);
2738
2972
  t3.capacityPct = t3.total ? Math.round(t3.booked / t3.total * 100) : 0;
2739
2973
  const sellable = t3.total - t3.blocked;
2740
2974
  t3.sellThroughPct = sellable > 0 ? Math.round(t3.booked / sellable * 100) : 0;
2741
2975
  this.paintKpis(t3);
2742
- if (this.mode === "view") this.paintLegend(t3);
2976
+ if (this.mode === "view") {
2977
+ this.paintLegend(t3);
2978
+ this.paintMonitorInsights();
2979
+ } else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
2743
2980
  this.opts.onTallies?.(t3);
2744
2981
  }
2745
2982
  verbFor(prev, next) {
@@ -2749,17 +2986,22 @@ var SeatManager = class {
2749
2986
  if (next === "free") return prev === "blocked" ? "unblocked" : prev === "booked" ? "cancelled" : "released";
2750
2987
  return next;
2751
2988
  }
2752
- pushActivity(label, prev, next) {
2989
+ pushActivity(labels, verb, status, at = Date.now()) {
2990
+ const label = labels[0];
2991
+ if (!label) return;
2753
2992
  const item = {
2754
- id: `${label}:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`,
2755
- at: Date.now(),
2993
+ id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
2994
+ at,
2756
2995
  label,
2757
- verb: this.verbFor(prev, next),
2758
- status: next
2996
+ labels: [...labels],
2997
+ count: labels.length,
2998
+ verb,
2999
+ status
2759
3000
  };
2760
3001
  this.feed.unshift(item);
2761
3002
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
2762
3003
  if (this.mode === "view") this.paintFeed();
3004
+ this.opts.onActivity?.(item);
2763
3005
  }
2764
3006
  seedFeed(entries) {
2765
3007
  const verbByAction = {
@@ -2781,14 +3023,17 @@ var SeatManager = class {
2781
3023
  for (const e of entries) {
2782
3024
  const label = e.labels[0];
2783
3025
  if (!label) continue;
2784
- const extra = e.labels.length > 1 ? ` +${e.labels.length - 1}` : "";
2785
- this.feed.push({
3026
+ const item = {
2786
3027
  id: `log:${e.id}`,
2787
3028
  at: e.at,
2788
- label: label + extra,
3029
+ label,
3030
+ labels: [...e.labels],
3031
+ count: e.labels.length,
2789
3032
  verb: verbByAction[e.action] ?? e.action,
2790
3033
  status: stByAction[e.action] ?? "free"
2791
- });
3034
+ };
3035
+ this.feed.push(item);
3036
+ this.opts.onActivity?.(item);
2792
3037
  }
2793
3038
  this.feed.sort((a, b) => b.at - a.at);
2794
3039
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
@@ -2796,7 +3041,10 @@ var SeatManager = class {
2796
3041
  }
2797
3042
  startFeedClock() {
2798
3043
  this.feedTimer = setInterval(() => {
2799
- if (this.mode === "view") this.paintFeed();
3044
+ if (this.mode === "view") {
3045
+ this.paintFeed();
3046
+ this.paintMonitorInsights();
3047
+ }
2800
3048
  }, 1e4);
2801
3049
  }
2802
3050
  // ---- selection ------------------------------------------------------------
@@ -2806,21 +3054,32 @@ var SeatManager = class {
2806
3054
  syncSelection() {
2807
3055
  const seats = this.getSelection();
2808
3056
  if (this.mode === "block") this.paintSelBar(seats);
3057
+ else if (this.mode === "inspect") this.renderInspectRail(seats);
2809
3058
  this.opts.onSelectionChange?.(seats);
2810
3059
  }
2811
3060
  // ---- DOM: chrome ----------------------------------------------------------
2812
3061
  buildChrome() {
2813
3062
  const root = document.createElement("div");
2814
3063
  root.className = "slm";
3064
+ root.tabIndex = 0;
3065
+ root.setAttribute("role", "region");
3066
+ root.setAttribute("aria-label", "SeatLayer live control room");
2815
3067
  const vars = themeVars(this.opts.theme);
2816
3068
  for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
2817
3069
  root.innerHTML = `
2818
3070
  <div class="slm-bar">
2819
3071
  <div class="slm-modes" data-ref="modes">
2820
- <button class="slm-mode" data-mode="view">View</button>
2821
- <button class="slm-mode" data-mode="block">Block</button>
3072
+ <button class="slm-mode" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
3073
+ <button class="slm-mode" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
3074
+ <button class="slm-mode" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
2822
3075
  </div>
2823
3076
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
3077
+ <div class="slm-bar-actions">
3078
+ <button class="slm-barbtn" data-ref="heat" aria-pressed="false"
3079
+ aria-label="Sales momentum overlay off"
3080
+ title="Highlight sections selling fastest in the selected time window">Sales momentum</button>
3081
+ <button class="slm-barbtn" data-ref="fullscreen" title="Full screen (F)" aria-keyshortcuts="F">Full screen</button>
3082
+ </div>
2824
3083
  <div class="slm-kpis" data-ref="kpis"></div>
2825
3084
  </div>
2826
3085
  <div class="slm-body">
@@ -2835,12 +3094,19 @@ var SeatManager = class {
2835
3094
  `;
2836
3095
  this.host.appendChild(root);
2837
3096
  this.root = root;
3097
+ this.updateContainerLayout();
3098
+ if (typeof ResizeObserver !== "undefined") {
3099
+ this.layoutObserver = new ResizeObserver(() => this.updateContainerLayout());
3100
+ this.layoutObserver.observe(root);
3101
+ }
2838
3102
  const ref = (n) => root.querySelector(`[data-ref="${n}"]`);
2839
3103
  this.mapHost = ref("maphost");
2840
3104
  this.els = {
2841
3105
  modes: ref("modes"),
2842
3106
  livetext: ref("livetext"),
2843
3107
  kpis: ref("kpis"),
3108
+ heat: ref("heat"),
3109
+ fullscreen: ref("fullscreen"),
2844
3110
  zoomhint: ref("zoomhint"),
2845
3111
  rail: ref("rail"),
2846
3112
  toast: ref("toast"),
@@ -2848,14 +3114,33 @@ var SeatManager = class {
2848
3114
  };
2849
3115
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
2850
3116
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
3117
+ this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
3118
+ this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
3119
+ root.addEventListener("keydown", this.onKeyDown);
3120
+ document.addEventListener("fullscreenchange", this.onFullscreenChange);
2851
3121
  this.paintModeTabs();
3122
+ this.paintHeatButton();
3123
+ this.paintFullscreenButton();
3124
+ }
3125
+ updateContainerLayout() {
3126
+ const width = this.root?.getBoundingClientRect().width || this.host.clientWidth;
3127
+ this.root?.classList.toggle("compact", width > 0 && width < 800);
2852
3128
  }
2853
3129
  buildSectionOptions() {
2854
3130
  if (!this.doc) return;
2855
3131
  try {
2856
3132
  const secs = computeSections(this.doc);
2857
- for (const s of secs.sections) this.sectionOptions.push({ id: s.id, label: s.label });
2858
- if (secs.ungrouped) this.sectionOptions.push({ id: UNGROUPED_ID, label: secs.ungrouped.label });
3133
+ this.sectionOptions = [];
3134
+ this.sectionByObject = new Map(secs.objectToSection);
3135
+ this.sectionLabelById.clear();
3136
+ for (const s of secs.sections) {
3137
+ this.sectionOptions.push({ id: s.id, label: s.label });
3138
+ this.sectionLabelById.set(s.id, s.label);
3139
+ }
3140
+ if (secs.ungrouped) {
3141
+ this.sectionOptions.push({ id: UNGROUPED_ID, label: secs.ungrouped.label });
3142
+ this.sectionLabelById.set(UNGROUPED_ID, secs.ungrouped.label);
3143
+ }
2859
3144
  } catch {
2860
3145
  }
2861
3146
  }
@@ -2866,9 +3151,39 @@ var SeatManager = class {
2866
3151
  });
2867
3152
  this.root?.classList.toggle("block-mode", this.mode === "block");
2868
3153
  }
3154
+ paintHeatButton() {
3155
+ const button = this.els.heat;
3156
+ if (!button) return;
3157
+ button.classList.toggle("on", this.heatEnabled);
3158
+ button.setAttribute("aria-pressed", String(this.heatEnabled));
3159
+ button.setAttribute("aria-label", `Sales momentum overlay ${this.heatEnabled ? "on" : "off"}`);
3160
+ button.setAttribute("title", `${this.heatEnabled ? "Hide" : "Highlight"} sections selling fastest in the selected time window`);
3161
+ button.textContent = "Sales momentum";
3162
+ this.paintMomentumHelp();
3163
+ }
3164
+ paintMomentumHelp() {
3165
+ const help = this.els.rail?.querySelector('[data-ref="momentumhelp"]');
3166
+ if (!help) return;
3167
+ help.hidden = !this.heatEnabled;
3168
+ const copy = help.querySelector('[data-ref="momentumcopy"]');
3169
+ if (!copy) return;
3170
+ const hasRecentSales = this.controlRoomSnapshot?.velocity.bySection.some((row) => row.netBooked > 0);
3171
+ copy.textContent = hasRecentSales ? "Warmer sections have more completed bookings, adjusted for section size. Holds and viewers are not counted." : `No completed bookings in the last ${this.trendWindowMinutes} minutes.`;
3172
+ }
3173
+ paintFullscreenButton() {
3174
+ if (!this.els.fullscreen) return;
3175
+ this.els.fullscreen.textContent = this.isFullscreen() ? "Exit full screen" : "Full screen";
3176
+ }
3177
+ paintTrendWindow() {
3178
+ this.els.rail?.querySelectorAll("[data-window]").forEach((button) => {
3179
+ const value = Number(button.dataset.window);
3180
+ button.classList.toggle("on", value === this.trendWindowMinutes);
3181
+ });
3182
+ }
2869
3183
  setLive(on) {
2870
3184
  this.root?.classList.toggle("live", on);
2871
3185
  if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
3186
+ this.paintMonitorInsights();
2872
3187
  }
2873
3188
  updateZoomHint() {
2874
3189
  const hint = this.els.zoomhint;
@@ -2878,10 +3193,13 @@ var SeatManager = class {
2878
3193
  }
2879
3194
  paintKpis(t3) {
2880
3195
  if (!this.els.kpis) return;
2881
- const rev = fmtMoney(t3.grossRevenue, t3.currency);
3196
+ const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
3197
+ const presence = this.controlRoomSnapshot?.presence;
2882
3198
  this.els.kpis.innerHTML = [
2883
3199
  { n: t3.booked.toLocaleString(), l: "Sold", dot: "#22a06b" },
2884
3200
  { n: t3.held.toLocaleString(), l: "Held", dot: "#f4b740" },
3201
+ { n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Shopping" },
3202
+ { n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Live holds" },
2885
3203
  { n: t3.free.toLocaleString(), l: "Free", dot: "#6e7bff" },
2886
3204
  { n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
2887
3205
  { n: `${t3.capacityPct}%`, l: "Capacity" },
@@ -2891,22 +3209,126 @@ var SeatManager = class {
2891
3209
  // ---- DOM: rails -----------------------------------------------------------
2892
3210
  paintRail() {
2893
3211
  if (this.mode === "view") this.renderViewRail();
3212
+ else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
2894
3213
  else this.renderBlockRail();
2895
3214
  this.updateZoomHint();
2896
3215
  }
2897
3216
  renderViewRail() {
2898
3217
  this.els.rail.innerHTML = `
2899
- <p class="slm-eyebrow">Live board</p>
2900
- <p class="slm-hint">Read-only. Seats repaint and flash as buyers hold and book \u2014 watch your event breathe.</p>
3218
+ <p class="slm-eyebrow">Monitor</p>
3219
+ <p class="slm-hint">Read-only. Inventory, buyer presence and sales movement update on the same live board.</p>
3220
+ <div class="slm-health" data-ref="presence"></div>
2901
3221
  <div class="slm-legend" data-ref="legend"></div>
3222
+ <div class="slm-sectionhead">
3223
+ <div><p class="slm-eyebrow">Section performance</p><p class="slm-note">Exact booked revenue \xB7 net sales velocity</p></div>
3224
+ <div class="slm-windows" aria-label="Sales velocity window">
3225
+ ${[5, 15, 30, 60].map((window2) => `<button class="slm-window" data-window="${window2}">${window2}m</button>`).join("")}
3226
+ </div>
3227
+ </div>
3228
+ <div class="slm-momentumhelp" data-ref="momentumhelp" ${this.heatEnabled ? "" : "hidden"}>
3229
+ <div class="slm-momentumscale"><span>Warm</span><span class="slm-momentumgradient"></span><span>Hot</span></div>
3230
+ <p class="slm-momentumcopy" data-ref="momentumcopy"></p>
3231
+ </div>
3232
+ <div class="slm-sectionlist" data-ref="sections"></div>
2902
3233
  <p class="slm-eyebrow">Activity</p>
2903
3234
  <div class="slm-feed" data-ref="feed"></div>
2904
3235
  `;
3236
+ this.els.presence = this.els.rail.querySelector('[data-ref="presence"]');
2905
3237
  this.els.legend = this.els.rail.querySelector('[data-ref="legend"]');
3238
+ this.els.sections = this.els.rail.querySelector('[data-ref="sections"]');
2906
3239
  this.els.feed = this.els.rail.querySelector('[data-ref="feed"]');
3240
+ this.els.rail.querySelectorAll("[data-window]").forEach((button) => button.addEventListener("click", () => {
3241
+ const windowMinutes = Number(button.dataset.window);
3242
+ void this.setTrendWindow(windowMinutes).catch((err) => this.opts.onError?.(err));
3243
+ }));
2907
3244
  this.recomputeTallies();
3245
+ this.paintMonitorInsights();
3246
+ this.paintTrendWindow();
3247
+ this.paintMomentumHelp();
2908
3248
  this.paintFeed();
2909
3249
  }
3250
+ paintMonitorInsights() {
3251
+ if (this.mode !== "view") return;
3252
+ const snapshot = this.controlRoomSnapshot;
3253
+ if (this.els.presence) {
3254
+ const connected = this.root?.classList.contains("live");
3255
+ const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
3256
+ this.els.presence.innerHTML = `
3257
+ <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyer sessions</span></div>
3258
+ <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Active holds</span></div>
3259
+ <div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
3260
+ <div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
3261
+ }
3262
+ if (!this.els.sections) return;
3263
+ if (!snapshot) {
3264
+ this.els.sections.innerHTML = '<div class="slm-empty">Loading authoritative section metrics\u2026</div>';
3265
+ return;
3266
+ }
3267
+ const velocity = new Map(snapshot.velocity.bySection.map((row) => [row.sectionId, row]));
3268
+ const rows = [...snapshot.revenue.bySection].sort((a, b) => {
3269
+ const av = velocity.get(a.sectionId)?.netBooked ?? 0;
3270
+ const bv = velocity.get(b.sectionId)?.netBooked ?? 0;
3271
+ return bv - av || b.bookedRevenue - a.bookedRevenue;
3272
+ });
3273
+ this.els.sections.innerHTML = rows.length ? rows.map((row) => {
3274
+ const speed = velocity.get(row.sectionId);
3275
+ const net = speed?.netBooked ?? 0;
3276
+ const netLabel = `${net > 0 ? "+" : ""}${net}`;
3277
+ const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
3278
+ return `<div class="slm-sectionrow">
3279
+ <div class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></div>
3280
+ <div 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></div>
3281
+ </div>`;
3282
+ }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
3283
+ this.paintTrendWindow();
3284
+ this.paintMomentumHelp();
3285
+ }
3286
+ applyHeatOverlay() {
3287
+ const snapshot = this.controlRoomSnapshot;
3288
+ if (!this.heatEnabled || !snapshot) {
3289
+ this.renderer?.setSectionHeat(null);
3290
+ return;
3291
+ }
3292
+ const capacity = new Map(snapshot.revenue.bySection.map((row) => [row.sectionId, Math.max(1, row.total)]));
3293
+ const rates = snapshot.velocity.bySection.map((row) => ({
3294
+ sectionId: row.sectionId,
3295
+ rate: Math.max(0, row.netBooked) / (capacity.get(row.sectionId) ?? 1) / snapshot.velocity.windowMinutes
3296
+ }));
3297
+ const max = Math.max(0, ...rates.map((row) => row.rate));
3298
+ const scores = {};
3299
+ for (const row of rates) scores[row.sectionId] = max > 0 ? Math.sqrt(row.rate / max) : 0;
3300
+ this.renderer?.setSectionHeat(scores);
3301
+ }
3302
+ renderInspectRail(seats) {
3303
+ const seat = seats[seats.length - 1];
3304
+ if (!seat) {
3305
+ this.els.rail.innerHTML = `
3306
+ <p class="slm-eyebrow">Inspect</p>
3307
+ <p class="slm-hint">Select any seat to see its live inventory context. Inspect is read-only and never changes availability.</p>
3308
+ <div class="slm-empty">Choose a seat on the map.</div>`;
3309
+ return;
3310
+ }
3311
+ const status = this.status.get(seat.label) ?? "free";
3312
+ const statusLabel = { free: "Free", held: "Held", booked: "Booked", blocked: "Blocked" };
3313
+ const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
3314
+ const sectionLabel = this.sectionLabelById.get(sectionId) ?? "Other seats";
3315
+ const category = this.doc?.categories.find((item) => item.key === seat.categoryKey);
3316
+ const sectionMetric = this.controlRoomSnapshot?.revenue.bySection.find((row) => row.sectionId === sectionId);
3317
+ this.els.rail.innerHTML = `
3318
+ <p class="slm-eyebrow">Inspect</p>
3319
+ <p class="slm-hint">Live inventory context. Booked seats remain read-only; order and payment actions belong to the host commerce system.</p>
3320
+ <div class="slm-inspect-card">
3321
+ <div class="slm-inspect-label">${esc(seat.label)}</div>
3322
+ <div class="slm-inspect-grid">
3323
+ <div><span>Status</span><b>${statusLabel[status]}</b></div>
3324
+ <div><span>Section</span><b>${esc(sectionLabel)}</b></div>
3325
+ <div><span>Row</span><b>${esc(seat.rowId)}</b></div>
3326
+ <div><span>Category</span><b>${esc(category?.label ?? seat.categoryKey)}</b></div>
3327
+ <div><span>Section sold</span><b>${sectionMetric ? `${sectionMetric.booked}/${sectionMetric.total}` : "\u2014"}</b></div>
3328
+ <div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>
3329
+ </div>
3330
+ </div>`;
3331
+ }
2910
3332
  paintLegend(t3) {
2911
3333
  if (!this.els.legend) return;
2912
3334
  this.els.legend.innerHTML = LEGEND.map((l) => `<div class="slm-legrow"><span class="slm-legdot" style="background:${l.color}"></span>
@@ -2920,19 +3342,22 @@ var SeatManager = class {
2920
3342
  }
2921
3343
  const now = Date.now();
2922
3344
  const color = { free: "#6e7bff", held: "#f4b740", booked: "#22a06b", blocked: "#8b94ac" };
2923
- this.els.feed.innerHTML = this.feed.map((a) => `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
2924
- <span class="slm-feedtext">Seat <b>${a.label}</b> ${a.verb}</span>
2925
- <span class="slm-feedtime">${relTime(a.at, now)}</span></div>`).join("");
3345
+ this.els.feed.innerHTML = this.feed.map((a) => {
3346
+ const extra = a.count > 1 ? ` +${a.count - 1}` : "";
3347
+ return `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
3348
+ <span class="slm-feedtext">${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
3349
+ <span class="slm-feedtime">${relTime(a.at, now)}</span></div>`;
3350
+ }).join("");
2926
3351
  }
2927
3352
  renderBlockRail() {
2928
3353
  const cats = this.doc?.categories ?? [];
2929
- const catChips = cats.map((c) => `<button class="slm-chip" data-cat="${c.key}"><span class="dot" style="background:${c.color ?? "#6e7bff"}"></span>${c.label ?? c.key}</button>`).join("");
3354
+ const catChips = cats.map((c) => `<button class="slm-chip" data-cat="${esc(c.key)}"><span class="dot" style="background:${esc(c.color ?? "#6e7bff")}"></span>${esc(c.label ?? c.key)}</button>`).join("");
2930
3355
  const sectionField = this.sectionOptions.length ? `<div class="slm-field"><label>Select a whole section</label>
2931
3356
  <select class="slm-select" data-ref="section"><option value="">Choose a section\u2026</option>
2932
- ${this.sectionOptions.map((s) => `<option value="${s.id}">${s.label}</option>`).join("")}</select></div>` : "";
3357
+ ${this.sectionOptions.map((s) => `<option value="${esc(s.id)}">${esc(s.label)}</option>`).join("")}</select></div>` : "";
2933
3358
  this.els.rail.innerHTML = `
2934
- <p class="slm-eyebrow">Block &amp; cancel</p>
2935
- <p class="slm-hint">Drag a box on the map to marquee-select, \u2318A for all, or pick a category/section \u2014 then block, unblock, or cancel bookings in one action.</p>
3359
+ <p class="slm-eyebrow">Block &amp; unblock</p>
3360
+ <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>
2936
3361
  <div class="slm-selbar"><span class="slm-selnum" data-ref="selnum">0</span><span class="slm-sellabel">selected</span></div>
2937
3362
  <div class="slm-row">
2938
3363
  <button class="slm-btn" data-ref="doblock" disabled>Block</button>
@@ -2941,7 +3366,6 @@ var SeatManager = class {
2941
3366
  <div class="slm-row">
2942
3367
  <button class="slm-btn ghost" data-ref="selall">Select all</button>
2943
3368
  <button class="slm-btn ghost" data-ref="clearsel">Clear</button>
2944
- <button class="slm-btn danger" data-ref="docancel" disabled>Cancel booking</button>
2945
3369
  </div>
2946
3370
  <p class="slm-eyebrow" style="margin-top:8px">By category</p>
2947
3371
  <div class="slm-chiprow">${catChips || '<span class="slm-empty">No categories.</span>'}</div>
@@ -2957,10 +3381,8 @@ var SeatManager = class {
2957
3381
  this.els.selnum = r("selnum");
2958
3382
  this.els.doblock = r("doblock");
2959
3383
  this.els.dounblock = r("dounblock");
2960
- this.els.docancel = r("docancel");
2961
3384
  r("doblock").addEventListener("click", () => void this.block());
2962
3385
  r("dounblock").addEventListener("click", () => void this.unblock());
2963
- r("docancel").addEventListener("click", () => this.promptCancel());
2964
3386
  r("selall").addEventListener("click", () => this.selectAll());
2965
3387
  r("clearsel").addEventListener("click", () => this.clearSelection());
2966
3388
  r("markall").addEventListener("click", () => void this.unblockAll());
@@ -2986,28 +3408,18 @@ var SeatManager = class {
2986
3408
  for (const [label, seat] of this.labelToSeat.entries()) if (seat.categoryKey === catKey) labels.push(label);
2987
3409
  this.selectByLabels(labels);
2988
3410
  }
2989
- promptCancel() {
2990
- const booked = this.getSelection().filter((s) => this.status.get(s.label) === "booked");
2991
- if (!booked.length) return;
2992
- if (typeof window === "undefined") return;
2993
- if (!window.confirm(`Cancel ${booked.length} booking${booked.length === 1 ? "" : "s"}? Seats return to sale (credit not refunded).`)) return;
2994
- const ref = window.prompt("Enter the original booking reference to cancel safely:")?.trim();
2995
- if (!ref) return;
2996
- void this.cancelBooking(booked.map((s) => s.label), ref);
2997
- }
2998
3411
  paintSelBar(seats) {
2999
3412
  if (!this.els.selnum) return;
3000
3413
  this.els.selnum.textContent = seats.length.toLocaleString();
3001
3414
  const hasFree = seats.some((s) => this.status.get(s.label) === "free");
3002
3415
  const hasBlocked = seats.some((s) => this.status.get(s.label) === "blocked");
3003
- const hasBooked = seats.some((s) => this.status.get(s.label) === "booked");
3004
3416
  this.els.doblock.disabled = !hasFree;
3005
3417
  this.els.dounblock.disabled = !hasBlocked;
3006
- this.els.docancel.disabled = !hasBooked;
3007
3418
  }
3008
3419
  // ---- toast / done / fail --------------------------------------------------
3009
3420
  done(action, labels, msg) {
3010
3421
  this.toastOk(msg);
3422
+ if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
3011
3423
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
3012
3424
  }
3013
3425
  toastOk(msg) {