@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.cjs CHANGED
@@ -64,6 +64,7 @@ async function request(base, path, init = {}) {
64
64
  var PubApi = class {
65
65
  constructor(base) {
66
66
  this.base = base;
67
+ this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
67
68
  }
68
69
  chart(key) {
69
70
  return request(this.base, `/pub/events/${encodeURIComponent(key)}/chart`);
@@ -99,7 +100,8 @@ var PubApi = class {
99
100
  }
100
101
  socketUrl(key) {
101
102
  const wsBase = this.base.replace(/^http/, "ws");
102
- return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe`;
103
+ const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
104
+ return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
103
105
  }
104
106
  };
105
107
 
@@ -2205,7 +2207,7 @@ var ManageApi = class {
2205
2207
  return this.pub(`/pub/events/${encodeURIComponent(key)}/objects`);
2206
2208
  }
2207
2209
  socketUrl(key) {
2208
- return `${this.base.replace(/^http/, "ws")}/pub/events/${encodeURIComponent(key)}/subscribe`;
2210
+ return `${this.base.replace(/^http/, "ws")}/pub/events/${encodeURIComponent(key)}/subscribe?surface=manager`;
2209
2211
  }
2210
2212
  // ---- inventory writes (token) ----
2211
2213
  /** Take FREE seats off sale in one batched call. Optional `releaseAt` (epoch
@@ -2238,6 +2240,9 @@ var ManageApi = class {
2238
2240
  report(key) {
2239
2241
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
2240
2242
  }
2243
+ controlRoom(key, windowMinutes = 15) {
2244
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/control-room?window=${windowMinutes}`);
2245
+ }
2241
2246
  log(key, opts = {}) {
2242
2247
  const params = new URLSearchParams();
2243
2248
  if (opts.limit != null) params.set("limit", String(opts.limit));
@@ -2367,7 +2372,35 @@ var CSS2 = `
2367
2372
  .slm-toast.err{background:#c0392b;color:#fff;border-color:#c0392b}
2368
2373
  .slm-toast.ok{background:#1f7a4d;color:#fff;border-color:#1f7a4d}
2369
2374
 
2370
- @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}}
2375
+ /* control-room actions + insights */
2376
+ .slm-bar-actions{display:flex;align-items:center;gap:7px}
2377
+ .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
2378
+ .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
2379
+ .slm-sectionlist + .slm-eyebrow{margin-top:18px}
2380
+ .slm-sectionrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
2381
+ .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
2382
+ .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
2383
+ .slm-trend{font-size:10px;text-transform:uppercase;letter-spacing:.08em}.slm-trend.rising{color:#22a06b}.slm-trend.cooling{color:#f4b740}
2384
+ .slm-health{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
2385
+ .slm-healthitem{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
2386
+ .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}
2387
+ .slm-sectionhead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-top:18px}
2388
+ .slm-windows{display:flex;gap:3px;padding:2px;border:1px solid var(--slm-line);border-radius:8px;background:var(--slm-surface)}
2389
+ .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)}
2390
+ .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)}
2391
+ .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}
2392
+ .slm-momentumgradient{height:6px;min-width:64px;flex:1;border-radius:999px;background:linear-gradient(90deg,#f4b740,#ef4444)}
2393
+ .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
2394
+ .slm-inspect-card{padding:12px;border:1px solid var(--slm-line);border-radius:12px;background:var(--slm-surface)}
2395
+ .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}
2396
+ .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}
2397
+ .slm:fullscreen{border-radius:0;min-height:100vh;background:var(--slm-bg)}
2398
+ .slm:fullscreen .slm-bar{padding:14px 22px}.slm:fullscreen .slm-kpi b{font-size:21px}.slm:fullscreen .slm-rail{width:360px}
2399
+
2400
+ .slm.compact .slm-rail{width:100%;border-left:0;border-top:1px solid var(--slm-line);height:44%}
2401
+ .slm.compact .slm-body{flex-direction:column}
2402
+ .slm.compact .slm-bar{gap:8px;padding:8px}.slm.compact .slm-barbtn{padding:6px 9px}
2403
+ .slm.compact .slm-kpis{gap:9px}.slm.compact .slm-kpi:nth-child(n+5){display:none}
2371
2404
  `;
2372
2405
  function injectStyle() {
2373
2406
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
@@ -2405,6 +2438,9 @@ function fmtMoney(amount, currency) {
2405
2438
  return `${currency} ${Math.round(amount).toLocaleString()}`;
2406
2439
  }
2407
2440
  }
2441
+ function esc(value) {
2442
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2443
+ }
2408
2444
  var SeatManager = class {
2409
2445
  constructor(options) {
2410
2446
  this.els = {};
@@ -2415,8 +2451,14 @@ var SeatManager = class {
2415
2451
  this.labelToSeat = /* @__PURE__ */ new Map();
2416
2452
  this.allIds = [];
2417
2453
  this.status = /* @__PURE__ */ new Map();
2418
- this.priceByCat = /* @__PURE__ */ new Map();
2419
2454
  this.currency = "USD";
2455
+ this.authoritativeGrossRevenue = 0;
2456
+ this.revenueStatus = "loading";
2457
+ this.revenueRequest = 0;
2458
+ this.revenueRefreshTimer = null;
2459
+ this.controlRoomSnapshot = null;
2460
+ this.trendWindowMinutes = 15;
2461
+ this.heatEnabled = false;
2420
2462
  // realtime socket
2421
2463
  this.ws = null;
2422
2464
  this.reconnectTimer = null;
@@ -2427,12 +2469,37 @@ var SeatManager = class {
2427
2469
  this.feedTimer = null;
2428
2470
  this.toastTimer = null;
2429
2471
  this.releaseAt = null;
2472
+ this.layoutObserver = null;
2473
+ this.tokenExpiresAt = null;
2474
+ this.tokenRefreshTimer = null;
2475
+ this.tokenRefreshInFlight = false;
2476
+ this.sectionByObject = /* @__PURE__ */ new Map();
2477
+ this.sectionLabelById = /* @__PURE__ */ new Map();
2478
+ this.lastSyncedAt = null;
2479
+ this.onFullscreenChange = () => {
2480
+ this.paintFullscreenButton();
2481
+ this.updateContainerLayout();
2482
+ this.renderer?.forceDraw();
2483
+ };
2484
+ this.onKeyDown = (event) => {
2485
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
2486
+ const target = event.target;
2487
+ if (target?.matches('input,select,textarea,[contenteditable="true"]')) return;
2488
+ const key = event.key.toLowerCase();
2489
+ if (key === "m") this.setMode("view");
2490
+ else if (key === "i") this.setMode("inspect");
2491
+ else if (key === "b") this.setMode("block");
2492
+ else if (key === "f") this.toggleFullscreen();
2493
+ else return;
2494
+ event.preventDefault();
2495
+ };
2430
2496
  this.sectionOptions = [];
2431
2497
  this.opts = options;
2432
2498
  this.key = options.eventKey;
2433
2499
  this.mode = options.mode ?? "view";
2434
2500
  this.keepLive = options.keepLiveWhileHidden ?? true;
2435
2501
  this.currency = options.currency ?? "USD";
2502
+ this.tokenExpiresAt = options.tokenExpiresAt ?? null;
2436
2503
  this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE3, options.token);
2437
2504
  this.host = resolveContainer4(options.container);
2438
2505
  }
@@ -2444,7 +2511,6 @@ var SeatManager = class {
2444
2511
  const res = await this.api.chart(this.key);
2445
2512
  this.doc = res.doc;
2446
2513
  this.currency = res.event.currency ?? this.opts.currency ?? this.currency;
2447
- for (const cat of res.doc.categories) this.priceByCat.set(cat.key, cat.price ?? 0);
2448
2514
  const seats = (0, import_core3.expandChart)(res.doc);
2449
2515
  for (const s of seats) {
2450
2516
  this.labelToId.set(s.label, s.id);
@@ -2453,13 +2519,17 @@ var SeatManager = class {
2453
2519
  }
2454
2520
  this.buildRenderer();
2455
2521
  this.buildSectionOptions();
2456
- await this.resnapshot();
2522
+ await Promise.all([
2523
+ this.resnapshot(),
2524
+ this.refreshControlRoom().catch((err) => this.opts.onError?.(err))
2525
+ ]);
2457
2526
  this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
2458
2527
  });
2459
2528
  this.connect();
2460
2529
  this.startFeedClock();
2461
2530
  this.ready = true;
2462
2531
  this.setMode(this.mode);
2532
+ this.scheduleTokenRefresh();
2463
2533
  this.opts.onReady?.();
2464
2534
  } catch (err) {
2465
2535
  this.fail(err);
@@ -2468,11 +2538,82 @@ var SeatManager = class {
2468
2538
  }
2469
2539
  // ---- public API -----------------------------------------------------------
2470
2540
  setMode(mode) {
2471
- const changed = mode !== this.mode || !this.renderer;
2541
+ const changed = mode !== this.mode;
2472
2542
  this.mode = mode;
2473
- if (changed && this.doc) this.buildRenderer();
2543
+ if (!this.renderer && this.doc) this.buildRenderer();
2544
+ else this.updateRendererInteraction();
2545
+ if (changed) this.renderer?.clearSelection();
2474
2546
  this.paintModeTabs();
2475
2547
  this.paintRail();
2548
+ if (changed) this.opts.onModeChange?.(mode);
2549
+ }
2550
+ /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
2551
+ setHeatOverlay(enabled) {
2552
+ this.heatEnabled = enabled;
2553
+ this.applyHeatOverlay();
2554
+ this.paintHeatButton();
2555
+ }
2556
+ /** Change the current-vs-previous sales window and refresh the private projection. */
2557
+ setTrendWindow(windowMinutes) {
2558
+ const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
2559
+ this.trendWindowMinutes = Math.max(5, Math.min(60, normalized));
2560
+ this.paintTrendWindow();
2561
+ return this.refreshControlRoom();
2562
+ }
2563
+ async enterFullscreen() {
2564
+ if (!this.root?.requestFullscreen || this.isFullscreen()) return;
2565
+ await this.root.requestFullscreen();
2566
+ this.root.focus({ preventScroll: true });
2567
+ }
2568
+ async exitFullscreen() {
2569
+ if (typeof document === "undefined" || !this.isFullscreen()) return;
2570
+ await document.exitFullscreen();
2571
+ }
2572
+ isFullscreen() {
2573
+ return typeof document !== "undefined" && document.fullscreenElement === this.root;
2574
+ }
2575
+ toggleFullscreen() {
2576
+ const request2 = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
2577
+ void request2.catch((err) => this.opts.onError?.(err));
2578
+ }
2579
+ /** Rotate the delegated credential without rebuilding DOM, canvas or socket. */
2580
+ setToken(token, expiresAt) {
2581
+ this.api.setToken(token);
2582
+ this.tokenExpiresAt = expiresAt ?? null;
2583
+ this.scheduleTokenRefresh();
2584
+ }
2585
+ scheduleTokenRefresh() {
2586
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
2587
+ this.tokenRefreshTimer = null;
2588
+ const refresh = this.opts.onTokenRefresh;
2589
+ const expiresAt = this.tokenExpiresAt;
2590
+ if (this.closed || !refresh || !expiresAt || !Number.isFinite(expiresAt)) return;
2591
+ const remaining = expiresAt - Date.now();
2592
+ const lead = Math.min(12e4, Math.max(3e4, remaining * 0.2));
2593
+ const delay = Math.max(0, remaining - lead);
2594
+ this.tokenRefreshTimer = setTimeout(() => {
2595
+ this.tokenRefreshTimer = null;
2596
+ void this.rotateToken();
2597
+ }, delay);
2598
+ }
2599
+ async rotateToken() {
2600
+ if (this.closed || this.tokenRefreshInFlight || !this.opts.onTokenRefresh) return;
2601
+ this.tokenRefreshInFlight = true;
2602
+ try {
2603
+ const next = await this.opts.onTokenRefresh();
2604
+ if (!next?.token || !Number.isFinite(next.expiresAt)) throw new Error("invalid_token_refresh_result");
2605
+ this.setToken(next.token, next.expiresAt);
2606
+ } catch (err) {
2607
+ this.opts.onError?.(err);
2608
+ if (!this.closed) {
2609
+ this.tokenRefreshTimer = setTimeout(() => {
2610
+ this.tokenRefreshTimer = null;
2611
+ void this.rotateToken();
2612
+ }, 3e4);
2613
+ }
2614
+ } finally {
2615
+ this.tokenRefreshInFlight = false;
2616
+ }
2476
2617
  }
2477
2618
  /** Bulk block the given labels (or the current selection when omitted). */
2478
2619
  async block(labels, opts = {}) {
@@ -2557,7 +2698,13 @@ var SeatManager = class {
2557
2698
  return this.renderer?.getSelection() ?? [];
2558
2699
  }
2559
2700
  getReport() {
2560
- return this.api.report(this.key);
2701
+ return this.api.report(this.key).then((report) => {
2702
+ this.applyReportRevenue(report);
2703
+ return report;
2704
+ });
2705
+ }
2706
+ getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
2707
+ return this.setTrendWindow(windowMinutes);
2561
2708
  }
2562
2709
  getLog(opts = {}) {
2563
2710
  return this.api.log(this.key, opts);
@@ -2584,6 +2731,12 @@ var SeatManager = class {
2584
2731
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2585
2732
  if (this.feedTimer) clearInterval(this.feedTimer);
2586
2733
  if (this.toastTimer) clearTimeout(this.toastTimer);
2734
+ if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
2735
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
2736
+ this.layoutObserver?.disconnect();
2737
+ this.layoutObserver = null;
2738
+ this.root?.removeEventListener("keydown", this.onKeyDown);
2739
+ if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
2587
2740
  if (this.ws) {
2588
2741
  try {
2589
2742
  this.ws.close();
@@ -2595,26 +2748,45 @@ var SeatManager = class {
2595
2748
  this.renderer = null;
2596
2749
  if (this.root && this.root.parentNode === this.host) this.host.removeChild(this.root);
2597
2750
  }
2598
- // ---- renderer lifecycle (rebuilt per mode, like ManageEventPage) ----------
2751
+ // ---- renderer lifecycle ---------------------------------------------------
2599
2752
  buildRenderer() {
2600
2753
  if (!this.doc) return;
2601
- this.renderer?.destroy();
2602
2754
  const block = this.mode === "block";
2755
+ const inspect = this.mode === "inspect";
2603
2756
  this.renderer = new import_core3.SeatmapRenderer(this.mapHost, {
2604
2757
  manageMode: true,
2605
2758
  marqueeSelect: block,
2606
2759
  maxSelection: 1e6,
2607
- selectableStatuses: block ? ["free", "not_for_sale", "booked"] : [],
2760
+ selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : [],
2608
2761
  currency: this.currency,
2609
- onSelect: () => this.syncSelection(),
2762
+ onSelect: (seat) => this.handleSeatSelect(seat),
2610
2763
  onDeselect: () => this.syncSelection(),
2611
2764
  onMarquee: () => this.syncSelection(),
2612
2765
  onViewChange: () => this.updateZoomHint()
2613
2766
  });
2614
2767
  this.renderer.setChart(this.doc);
2615
2768
  this.repaintAll();
2769
+ this.applyHeatOverlay();
2770
+ this.updateZoomHint();
2771
+ }
2772
+ updateRendererInteraction() {
2773
+ const block = this.mode === "block";
2774
+ const inspect = this.mode === "inspect";
2775
+ this.renderer?.setManageInteraction({
2776
+ manageMode: true,
2777
+ marqueeSelect: block,
2778
+ maxSelection: 1e6,
2779
+ selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : []
2780
+ });
2616
2781
  this.updateZoomHint();
2617
2782
  }
2783
+ handleSeatSelect(seat) {
2784
+ if (this.mode === "inspect") {
2785
+ const others = this.getSelection().filter((selected) => selected.id !== seat.id).map((selected) => selected.id);
2786
+ if (others.length) this.renderer?.deselect(others);
2787
+ }
2788
+ this.syncSelection();
2789
+ }
2618
2790
  repaintAll() {
2619
2791
  const r = this.renderer;
2620
2792
  if (!r) return;
@@ -2642,7 +2814,7 @@ var SeatManager = class {
2642
2814
  ws.onopen = () => {
2643
2815
  this.attempt = 0;
2644
2816
  this.setLive(true);
2645
- void this.resnapshot();
2817
+ void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
2646
2818
  };
2647
2819
  ws.onmessage = (e) => this.onMessage(e);
2648
2820
  ws.onclose = () => {
@@ -2674,11 +2846,25 @@ var SeatManager = class {
2674
2846
  }
2675
2847
  if (!msg || typeof msg !== "object") return;
2676
2848
  const m = msg;
2849
+ if (m.type === "presence") {
2850
+ if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
2851
+ this.controlRoomSnapshot = {
2852
+ ...this.controlRoomSnapshot,
2853
+ presence: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
2854
+ };
2855
+ this.lastSyncedAt = Date.now();
2856
+ this.recomputeTallies();
2857
+ this.paintMonitorInsights();
2858
+ this.opts.onControlRoom?.(this.controlRoomSnapshot);
2859
+ }
2860
+ return;
2861
+ }
2677
2862
  if (m.type === "hidden") return;
2678
2863
  if (m.seats && typeof m.seats === "object") {
2679
2864
  this.applySnapshot(m.seats);
2680
2865
  } else if (Array.isArray(m.changes)) {
2681
2866
  const ids = [];
2867
+ const groups = /* @__PURE__ */ new Map();
2682
2868
  for (const ch of m.changes) {
2683
2869
  const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
2684
2870
  const prev = this.status.get(ch.label) ?? "free";
@@ -2690,10 +2876,19 @@ var SeatManager = class {
2690
2876
  this.flash(id, st);
2691
2877
  ids.push(id);
2692
2878
  }
2693
- this.pushActivity(ch.label, prev, st);
2879
+ const verb = this.verbFor(prev, st);
2880
+ const groupKey = `${verb}:${st}`;
2881
+ const group = groups.get(groupKey) ?? { labels: [], verb, status: st };
2882
+ group.labels.push(ch.label);
2883
+ groups.set(groupKey, group);
2884
+ }
2885
+ for (const group of groups.values()) this.pushActivity(group.labels, group.verb, group.status);
2886
+ if (ids.length) {
2887
+ this.lastSyncedAt = Date.now();
2888
+ this.afterPaint();
2694
2889
  }
2695
- if (ids.length) this.afterPaint();
2696
2890
  this.recomputeTallies();
2891
+ if (ids.length) this.scheduleRevenueRefresh();
2697
2892
  }
2698
2893
  }
2699
2894
  async resnapshot() {
@@ -2709,6 +2904,7 @@ var SeatManager = class {
2709
2904
  next.set(label, ["free", "held", "booked", "blocked"].includes(st) ? st : "free");
2710
2905
  }
2711
2906
  this.status = next;
2907
+ this.lastSyncedAt = Date.now();
2712
2908
  this.repaintAll();
2713
2909
  this.afterPaint();
2714
2910
  this.recomputeTallies();
@@ -2732,6 +2928,47 @@ var SeatManager = class {
2732
2928
  else if (st === "booked") this.renderer?.flashSeat(id, "#22a06b");
2733
2929
  }
2734
2930
  // ---- tallies + feed -------------------------------------------------------
2931
+ applyReportRevenue(report) {
2932
+ this.authoritativeGrossRevenue = report.report.byCategory.reduce(
2933
+ (sum, row) => sum + (Number.isFinite(row.bookedRevenue) ? row.bookedRevenue : 0),
2934
+ 0
2935
+ );
2936
+ this.revenueStatus = "current";
2937
+ this.recomputeTallies();
2938
+ }
2939
+ async refreshControlRoom() {
2940
+ const request2 = ++this.revenueRequest;
2941
+ try {
2942
+ const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
2943
+ if (request2 === this.revenueRequest) {
2944
+ this.controlRoomSnapshot = snapshot;
2945
+ this.lastSyncedAt = Date.now();
2946
+ this.authoritativeGrossRevenue = snapshot.revenue.gross;
2947
+ this.currency = snapshot.currency;
2948
+ this.revenueStatus = "current";
2949
+ this.recomputeTallies();
2950
+ this.applyHeatOverlay();
2951
+ this.paintMonitorInsights();
2952
+ this.opts.onControlRoom?.(snapshot);
2953
+ }
2954
+ return snapshot;
2955
+ } catch (err) {
2956
+ if (request2 === this.revenueRequest) {
2957
+ this.revenueStatus = "stale";
2958
+ this.recomputeTallies();
2959
+ }
2960
+ throw err;
2961
+ }
2962
+ }
2963
+ scheduleRevenueRefresh(delay = 140) {
2964
+ this.revenueStatus = "stale";
2965
+ this.recomputeTallies();
2966
+ if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
2967
+ this.revenueRefreshTimer = setTimeout(() => {
2968
+ this.revenueRefreshTimer = null;
2969
+ void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
2970
+ }, delay);
2971
+ }
2735
2972
  recomputeTallies() {
2736
2973
  const t3 = {
2737
2974
  free: 0,
@@ -2741,24 +2978,24 @@ var SeatManager = class {
2741
2978
  total: this.allIds.length,
2742
2979
  capacityPct: 0,
2743
2980
  sellThroughPct: 0,
2744
- grossRevenue: 0,
2981
+ grossRevenue: this.authoritativeGrossRevenue,
2982
+ revenueStatus: this.revenueStatus,
2745
2983
  currency: this.currency
2746
2984
  };
2747
2985
  let nonFree = 0;
2748
- for (const [label, st] of this.status.entries()) {
2986
+ for (const st of this.status.values()) {
2749
2987
  t3[st] += 1;
2750
2988
  if (st !== "free") nonFree += 1;
2751
- if (st === "booked") {
2752
- const seat = this.labelToSeat.get(label);
2753
- if (seat) t3.grossRevenue += this.priceByCat.get(seat.categoryKey) ?? 0;
2754
- }
2755
2989
  }
2756
2990
  t3.free = Math.max(0, t3.total - nonFree);
2757
2991
  t3.capacityPct = t3.total ? Math.round(t3.booked / t3.total * 100) : 0;
2758
2992
  const sellable = t3.total - t3.blocked;
2759
2993
  t3.sellThroughPct = sellable > 0 ? Math.round(t3.booked / sellable * 100) : 0;
2760
2994
  this.paintKpis(t3);
2761
- if (this.mode === "view") this.paintLegend(t3);
2995
+ if (this.mode === "view") {
2996
+ this.paintLegend(t3);
2997
+ this.paintMonitorInsights();
2998
+ } else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
2762
2999
  this.opts.onTallies?.(t3);
2763
3000
  }
2764
3001
  verbFor(prev, next) {
@@ -2768,17 +3005,22 @@ var SeatManager = class {
2768
3005
  if (next === "free") return prev === "blocked" ? "unblocked" : prev === "booked" ? "cancelled" : "released";
2769
3006
  return next;
2770
3007
  }
2771
- pushActivity(label, prev, next) {
3008
+ pushActivity(labels, verb, status, at = Date.now()) {
3009
+ const label = labels[0];
3010
+ if (!label) return;
2772
3011
  const item = {
2773
- id: `${label}:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`,
2774
- at: Date.now(),
3012
+ id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
3013
+ at,
2775
3014
  label,
2776
- verb: this.verbFor(prev, next),
2777
- status: next
3015
+ labels: [...labels],
3016
+ count: labels.length,
3017
+ verb,
3018
+ status
2778
3019
  };
2779
3020
  this.feed.unshift(item);
2780
3021
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
2781
3022
  if (this.mode === "view") this.paintFeed();
3023
+ this.opts.onActivity?.(item);
2782
3024
  }
2783
3025
  seedFeed(entries) {
2784
3026
  const verbByAction = {
@@ -2800,14 +3042,17 @@ var SeatManager = class {
2800
3042
  for (const e of entries) {
2801
3043
  const label = e.labels[0];
2802
3044
  if (!label) continue;
2803
- const extra = e.labels.length > 1 ? ` +${e.labels.length - 1}` : "";
2804
- this.feed.push({
3045
+ const item = {
2805
3046
  id: `log:${e.id}`,
2806
3047
  at: e.at,
2807
- label: label + extra,
3048
+ label,
3049
+ labels: [...e.labels],
3050
+ count: e.labels.length,
2808
3051
  verb: verbByAction[e.action] ?? e.action,
2809
3052
  status: stByAction[e.action] ?? "free"
2810
- });
3053
+ };
3054
+ this.feed.push(item);
3055
+ this.opts.onActivity?.(item);
2811
3056
  }
2812
3057
  this.feed.sort((a, b) => b.at - a.at);
2813
3058
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
@@ -2815,7 +3060,10 @@ var SeatManager = class {
2815
3060
  }
2816
3061
  startFeedClock() {
2817
3062
  this.feedTimer = setInterval(() => {
2818
- if (this.mode === "view") this.paintFeed();
3063
+ if (this.mode === "view") {
3064
+ this.paintFeed();
3065
+ this.paintMonitorInsights();
3066
+ }
2819
3067
  }, 1e4);
2820
3068
  }
2821
3069
  // ---- selection ------------------------------------------------------------
@@ -2825,21 +3073,32 @@ var SeatManager = class {
2825
3073
  syncSelection() {
2826
3074
  const seats = this.getSelection();
2827
3075
  if (this.mode === "block") this.paintSelBar(seats);
3076
+ else if (this.mode === "inspect") this.renderInspectRail(seats);
2828
3077
  this.opts.onSelectionChange?.(seats);
2829
3078
  }
2830
3079
  // ---- DOM: chrome ----------------------------------------------------------
2831
3080
  buildChrome() {
2832
3081
  const root = document.createElement("div");
2833
3082
  root.className = "slm";
3083
+ root.tabIndex = 0;
3084
+ root.setAttribute("role", "region");
3085
+ root.setAttribute("aria-label", "SeatLayer live control room");
2834
3086
  const vars = themeVars(this.opts.theme);
2835
3087
  for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
2836
3088
  root.innerHTML = `
2837
3089
  <div class="slm-bar">
2838
3090
  <div class="slm-modes" data-ref="modes">
2839
- <button class="slm-mode" data-mode="view">View</button>
2840
- <button class="slm-mode" data-mode="block">Block</button>
3091
+ <button class="slm-mode" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
3092
+ <button class="slm-mode" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
3093
+ <button class="slm-mode" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
2841
3094
  </div>
2842
3095
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
3096
+ <div class="slm-bar-actions">
3097
+ <button class="slm-barbtn" data-ref="heat" aria-pressed="false"
3098
+ aria-label="Sales momentum overlay off"
3099
+ title="Highlight sections selling fastest in the selected time window">Sales momentum</button>
3100
+ <button class="slm-barbtn" data-ref="fullscreen" title="Full screen (F)" aria-keyshortcuts="F">Full screen</button>
3101
+ </div>
2843
3102
  <div class="slm-kpis" data-ref="kpis"></div>
2844
3103
  </div>
2845
3104
  <div class="slm-body">
@@ -2854,12 +3113,19 @@ var SeatManager = class {
2854
3113
  `;
2855
3114
  this.host.appendChild(root);
2856
3115
  this.root = root;
3116
+ this.updateContainerLayout();
3117
+ if (typeof ResizeObserver !== "undefined") {
3118
+ this.layoutObserver = new ResizeObserver(() => this.updateContainerLayout());
3119
+ this.layoutObserver.observe(root);
3120
+ }
2857
3121
  const ref = (n) => root.querySelector(`[data-ref="${n}"]`);
2858
3122
  this.mapHost = ref("maphost");
2859
3123
  this.els = {
2860
3124
  modes: ref("modes"),
2861
3125
  livetext: ref("livetext"),
2862
3126
  kpis: ref("kpis"),
3127
+ heat: ref("heat"),
3128
+ fullscreen: ref("fullscreen"),
2863
3129
  zoomhint: ref("zoomhint"),
2864
3130
  rail: ref("rail"),
2865
3131
  toast: ref("toast"),
@@ -2867,14 +3133,33 @@ var SeatManager = class {
2867
3133
  };
2868
3134
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
2869
3135
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
3136
+ this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
3137
+ this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
3138
+ root.addEventListener("keydown", this.onKeyDown);
3139
+ document.addEventListener("fullscreenchange", this.onFullscreenChange);
2870
3140
  this.paintModeTabs();
3141
+ this.paintHeatButton();
3142
+ this.paintFullscreenButton();
3143
+ }
3144
+ updateContainerLayout() {
3145
+ const width = this.root?.getBoundingClientRect().width || this.host.clientWidth;
3146
+ this.root?.classList.toggle("compact", width > 0 && width < 800);
2871
3147
  }
2872
3148
  buildSectionOptions() {
2873
3149
  if (!this.doc) return;
2874
3150
  try {
2875
3151
  const secs = (0, import_core3.computeSections)(this.doc);
2876
- for (const s of secs.sections) this.sectionOptions.push({ id: s.id, label: s.label });
2877
- if (secs.ungrouped) this.sectionOptions.push({ id: import_core3.UNGROUPED_ID, label: secs.ungrouped.label });
3152
+ this.sectionOptions = [];
3153
+ this.sectionByObject = new Map(secs.objectToSection);
3154
+ this.sectionLabelById.clear();
3155
+ for (const s of secs.sections) {
3156
+ this.sectionOptions.push({ id: s.id, label: s.label });
3157
+ this.sectionLabelById.set(s.id, s.label);
3158
+ }
3159
+ if (secs.ungrouped) {
3160
+ this.sectionOptions.push({ id: import_core3.UNGROUPED_ID, label: secs.ungrouped.label });
3161
+ this.sectionLabelById.set(import_core3.UNGROUPED_ID, secs.ungrouped.label);
3162
+ }
2878
3163
  } catch {
2879
3164
  }
2880
3165
  }
@@ -2885,9 +3170,39 @@ var SeatManager = class {
2885
3170
  });
2886
3171
  this.root?.classList.toggle("block-mode", this.mode === "block");
2887
3172
  }
3173
+ paintHeatButton() {
3174
+ const button = this.els.heat;
3175
+ if (!button) return;
3176
+ button.classList.toggle("on", this.heatEnabled);
3177
+ button.setAttribute("aria-pressed", String(this.heatEnabled));
3178
+ button.setAttribute("aria-label", `Sales momentum overlay ${this.heatEnabled ? "on" : "off"}`);
3179
+ button.setAttribute("title", `${this.heatEnabled ? "Hide" : "Highlight"} sections selling fastest in the selected time window`);
3180
+ button.textContent = "Sales momentum";
3181
+ this.paintMomentumHelp();
3182
+ }
3183
+ paintMomentumHelp() {
3184
+ const help = this.els.rail?.querySelector('[data-ref="momentumhelp"]');
3185
+ if (!help) return;
3186
+ help.hidden = !this.heatEnabled;
3187
+ const copy = help.querySelector('[data-ref="momentumcopy"]');
3188
+ if (!copy) return;
3189
+ const hasRecentSales = this.controlRoomSnapshot?.velocity.bySection.some((row) => row.netBooked > 0);
3190
+ 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.`;
3191
+ }
3192
+ paintFullscreenButton() {
3193
+ if (!this.els.fullscreen) return;
3194
+ this.els.fullscreen.textContent = this.isFullscreen() ? "Exit full screen" : "Full screen";
3195
+ }
3196
+ paintTrendWindow() {
3197
+ this.els.rail?.querySelectorAll("[data-window]").forEach((button) => {
3198
+ const value = Number(button.dataset.window);
3199
+ button.classList.toggle("on", value === this.trendWindowMinutes);
3200
+ });
3201
+ }
2888
3202
  setLive(on) {
2889
3203
  this.root?.classList.toggle("live", on);
2890
3204
  if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
3205
+ this.paintMonitorInsights();
2891
3206
  }
2892
3207
  updateZoomHint() {
2893
3208
  const hint = this.els.zoomhint;
@@ -2897,10 +3212,13 @@ var SeatManager = class {
2897
3212
  }
2898
3213
  paintKpis(t3) {
2899
3214
  if (!this.els.kpis) return;
2900
- const rev = fmtMoney(t3.grossRevenue, t3.currency);
3215
+ const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
3216
+ const presence = this.controlRoomSnapshot?.presence;
2901
3217
  this.els.kpis.innerHTML = [
2902
3218
  { n: t3.booked.toLocaleString(), l: "Sold", dot: "#22a06b" },
2903
3219
  { n: t3.held.toLocaleString(), l: "Held", dot: "#f4b740" },
3220
+ { n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Shopping" },
3221
+ { n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Live holds" },
2904
3222
  { n: t3.free.toLocaleString(), l: "Free", dot: "#6e7bff" },
2905
3223
  { n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
2906
3224
  { n: `${t3.capacityPct}%`, l: "Capacity" },
@@ -2910,22 +3228,126 @@ var SeatManager = class {
2910
3228
  // ---- DOM: rails -----------------------------------------------------------
2911
3229
  paintRail() {
2912
3230
  if (this.mode === "view") this.renderViewRail();
3231
+ else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
2913
3232
  else this.renderBlockRail();
2914
3233
  this.updateZoomHint();
2915
3234
  }
2916
3235
  renderViewRail() {
2917
3236
  this.els.rail.innerHTML = `
2918
- <p class="slm-eyebrow">Live board</p>
2919
- <p class="slm-hint">Read-only. Seats repaint and flash as buyers hold and book \u2014 watch your event breathe.</p>
3237
+ <p class="slm-eyebrow">Monitor</p>
3238
+ <p class="slm-hint">Read-only. Inventory, buyer presence and sales movement update on the same live board.</p>
3239
+ <div class="slm-health" data-ref="presence"></div>
2920
3240
  <div class="slm-legend" data-ref="legend"></div>
3241
+ <div class="slm-sectionhead">
3242
+ <div><p class="slm-eyebrow">Section performance</p><p class="slm-note">Exact booked revenue \xB7 net sales velocity</p></div>
3243
+ <div class="slm-windows" aria-label="Sales velocity window">
3244
+ ${[5, 15, 30, 60].map((window2) => `<button class="slm-window" data-window="${window2}">${window2}m</button>`).join("")}
3245
+ </div>
3246
+ </div>
3247
+ <div class="slm-momentumhelp" data-ref="momentumhelp" ${this.heatEnabled ? "" : "hidden"}>
3248
+ <div class="slm-momentumscale"><span>Warm</span><span class="slm-momentumgradient"></span><span>Hot</span></div>
3249
+ <p class="slm-momentumcopy" data-ref="momentumcopy"></p>
3250
+ </div>
3251
+ <div class="slm-sectionlist" data-ref="sections"></div>
2921
3252
  <p class="slm-eyebrow">Activity</p>
2922
3253
  <div class="slm-feed" data-ref="feed"></div>
2923
3254
  `;
3255
+ this.els.presence = this.els.rail.querySelector('[data-ref="presence"]');
2924
3256
  this.els.legend = this.els.rail.querySelector('[data-ref="legend"]');
3257
+ this.els.sections = this.els.rail.querySelector('[data-ref="sections"]');
2925
3258
  this.els.feed = this.els.rail.querySelector('[data-ref="feed"]');
3259
+ this.els.rail.querySelectorAll("[data-window]").forEach((button) => button.addEventListener("click", () => {
3260
+ const windowMinutes = Number(button.dataset.window);
3261
+ void this.setTrendWindow(windowMinutes).catch((err) => this.opts.onError?.(err));
3262
+ }));
2926
3263
  this.recomputeTallies();
3264
+ this.paintMonitorInsights();
3265
+ this.paintTrendWindow();
3266
+ this.paintMomentumHelp();
2927
3267
  this.paintFeed();
2928
3268
  }
3269
+ paintMonitorInsights() {
3270
+ if (this.mode !== "view") return;
3271
+ const snapshot = this.controlRoomSnapshot;
3272
+ if (this.els.presence) {
3273
+ const connected = this.root?.classList.contains("live");
3274
+ const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
3275
+ this.els.presence.innerHTML = `
3276
+ <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyer sessions</span></div>
3277
+ <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Active holds</span></div>
3278
+ <div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
3279
+ <div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
3280
+ }
3281
+ if (!this.els.sections) return;
3282
+ if (!snapshot) {
3283
+ this.els.sections.innerHTML = '<div class="slm-empty">Loading authoritative section metrics\u2026</div>';
3284
+ return;
3285
+ }
3286
+ const velocity = new Map(snapshot.velocity.bySection.map((row) => [row.sectionId, row]));
3287
+ const rows = [...snapshot.revenue.bySection].sort((a, b) => {
3288
+ const av = velocity.get(a.sectionId)?.netBooked ?? 0;
3289
+ const bv = velocity.get(b.sectionId)?.netBooked ?? 0;
3290
+ return bv - av || b.bookedRevenue - a.bookedRevenue;
3291
+ });
3292
+ this.els.sections.innerHTML = rows.length ? rows.map((row) => {
3293
+ const speed = velocity.get(row.sectionId);
3294
+ const net = speed?.netBooked ?? 0;
3295
+ const netLabel = `${net > 0 ? "+" : ""}${net}`;
3296
+ const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
3297
+ return `<div class="slm-sectionrow">
3298
+ <div class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></div>
3299
+ <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>
3300
+ </div>`;
3301
+ }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
3302
+ this.paintTrendWindow();
3303
+ this.paintMomentumHelp();
3304
+ }
3305
+ applyHeatOverlay() {
3306
+ const snapshot = this.controlRoomSnapshot;
3307
+ if (!this.heatEnabled || !snapshot) {
3308
+ this.renderer?.setSectionHeat(null);
3309
+ return;
3310
+ }
3311
+ const capacity = new Map(snapshot.revenue.bySection.map((row) => [row.sectionId, Math.max(1, row.total)]));
3312
+ const rates = snapshot.velocity.bySection.map((row) => ({
3313
+ sectionId: row.sectionId,
3314
+ rate: Math.max(0, row.netBooked) / (capacity.get(row.sectionId) ?? 1) / snapshot.velocity.windowMinutes
3315
+ }));
3316
+ const max = Math.max(0, ...rates.map((row) => row.rate));
3317
+ const scores = {};
3318
+ for (const row of rates) scores[row.sectionId] = max > 0 ? Math.sqrt(row.rate / max) : 0;
3319
+ this.renderer?.setSectionHeat(scores);
3320
+ }
3321
+ renderInspectRail(seats) {
3322
+ const seat = seats[seats.length - 1];
3323
+ if (!seat) {
3324
+ this.els.rail.innerHTML = `
3325
+ <p class="slm-eyebrow">Inspect</p>
3326
+ <p class="slm-hint">Select any seat to see its live inventory context. Inspect is read-only and never changes availability.</p>
3327
+ <div class="slm-empty">Choose a seat on the map.</div>`;
3328
+ return;
3329
+ }
3330
+ const status = this.status.get(seat.label) ?? "free";
3331
+ const statusLabel = { free: "Free", held: "Held", booked: "Booked", blocked: "Blocked" };
3332
+ const sectionId = this.sectionByObject.get(seat.rowId) ?? import_core3.UNGROUPED_ID;
3333
+ const sectionLabel = this.sectionLabelById.get(sectionId) ?? "Other seats";
3334
+ const category = this.doc?.categories.find((item) => item.key === seat.categoryKey);
3335
+ const sectionMetric = this.controlRoomSnapshot?.revenue.bySection.find((row) => row.sectionId === sectionId);
3336
+ this.els.rail.innerHTML = `
3337
+ <p class="slm-eyebrow">Inspect</p>
3338
+ <p class="slm-hint">Live inventory context. Booked seats remain read-only; order and payment actions belong to the host commerce system.</p>
3339
+ <div class="slm-inspect-card">
3340
+ <div class="slm-inspect-label">${esc(seat.label)}</div>
3341
+ <div class="slm-inspect-grid">
3342
+ <div><span>Status</span><b>${statusLabel[status]}</b></div>
3343
+ <div><span>Section</span><b>${esc(sectionLabel)}</b></div>
3344
+ <div><span>Row</span><b>${esc(seat.rowId)}</b></div>
3345
+ <div><span>Category</span><b>${esc(category?.label ?? seat.categoryKey)}</b></div>
3346
+ <div><span>Section sold</span><b>${sectionMetric ? `${sectionMetric.booked}/${sectionMetric.total}` : "\u2014"}</b></div>
3347
+ <div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>
3348
+ </div>
3349
+ </div>`;
3350
+ }
2929
3351
  paintLegend(t3) {
2930
3352
  if (!this.els.legend) return;
2931
3353
  this.els.legend.innerHTML = LEGEND.map((l) => `<div class="slm-legrow"><span class="slm-legdot" style="background:${l.color}"></span>
@@ -2939,19 +3361,22 @@ var SeatManager = class {
2939
3361
  }
2940
3362
  const now = Date.now();
2941
3363
  const color = { free: "#6e7bff", held: "#f4b740", booked: "#22a06b", blocked: "#8b94ac" };
2942
- this.els.feed.innerHTML = this.feed.map((a) => `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
2943
- <span class="slm-feedtext">Seat <b>${a.label}</b> ${a.verb}</span>
2944
- <span class="slm-feedtime">${relTime(a.at, now)}</span></div>`).join("");
3364
+ this.els.feed.innerHTML = this.feed.map((a) => {
3365
+ const extra = a.count > 1 ? ` +${a.count - 1}` : "";
3366
+ return `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
3367
+ <span class="slm-feedtext">${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
3368
+ <span class="slm-feedtime">${relTime(a.at, now)}</span></div>`;
3369
+ }).join("");
2945
3370
  }
2946
3371
  renderBlockRail() {
2947
3372
  const cats = this.doc?.categories ?? [];
2948
- 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("");
3373
+ 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("");
2949
3374
  const sectionField = this.sectionOptions.length ? `<div class="slm-field"><label>Select a whole section</label>
2950
3375
  <select class="slm-select" data-ref="section"><option value="">Choose a section\u2026</option>
2951
- ${this.sectionOptions.map((s) => `<option value="${s.id}">${s.label}</option>`).join("")}</select></div>` : "";
3376
+ ${this.sectionOptions.map((s) => `<option value="${esc(s.id)}">${esc(s.label)}</option>`).join("")}</select></div>` : "";
2952
3377
  this.els.rail.innerHTML = `
2953
- <p class="slm-eyebrow">Block &amp; cancel</p>
2954
- <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>
3378
+ <p class="slm-eyebrow">Block &amp; unblock</p>
3379
+ <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>
2955
3380
  <div class="slm-selbar"><span class="slm-selnum" data-ref="selnum">0</span><span class="slm-sellabel">selected</span></div>
2956
3381
  <div class="slm-row">
2957
3382
  <button class="slm-btn" data-ref="doblock" disabled>Block</button>
@@ -2960,7 +3385,6 @@ var SeatManager = class {
2960
3385
  <div class="slm-row">
2961
3386
  <button class="slm-btn ghost" data-ref="selall">Select all</button>
2962
3387
  <button class="slm-btn ghost" data-ref="clearsel">Clear</button>
2963
- <button class="slm-btn danger" data-ref="docancel" disabled>Cancel booking</button>
2964
3388
  </div>
2965
3389
  <p class="slm-eyebrow" style="margin-top:8px">By category</p>
2966
3390
  <div class="slm-chiprow">${catChips || '<span class="slm-empty">No categories.</span>'}</div>
@@ -2976,10 +3400,8 @@ var SeatManager = class {
2976
3400
  this.els.selnum = r("selnum");
2977
3401
  this.els.doblock = r("doblock");
2978
3402
  this.els.dounblock = r("dounblock");
2979
- this.els.docancel = r("docancel");
2980
3403
  r("doblock").addEventListener("click", () => void this.block());
2981
3404
  r("dounblock").addEventListener("click", () => void this.unblock());
2982
- r("docancel").addEventListener("click", () => this.promptCancel());
2983
3405
  r("selall").addEventListener("click", () => this.selectAll());
2984
3406
  r("clearsel").addEventListener("click", () => this.clearSelection());
2985
3407
  r("markall").addEventListener("click", () => void this.unblockAll());
@@ -3005,28 +3427,18 @@ var SeatManager = class {
3005
3427
  for (const [label, seat] of this.labelToSeat.entries()) if (seat.categoryKey === catKey) labels.push(label);
3006
3428
  this.selectByLabels(labels);
3007
3429
  }
3008
- promptCancel() {
3009
- const booked = this.getSelection().filter((s) => this.status.get(s.label) === "booked");
3010
- if (!booked.length) return;
3011
- if (typeof window === "undefined") return;
3012
- if (!window.confirm(`Cancel ${booked.length} booking${booked.length === 1 ? "" : "s"}? Seats return to sale (credit not refunded).`)) return;
3013
- const ref = window.prompt("Enter the original booking reference to cancel safely:")?.trim();
3014
- if (!ref) return;
3015
- void this.cancelBooking(booked.map((s) => s.label), ref);
3016
- }
3017
3430
  paintSelBar(seats) {
3018
3431
  if (!this.els.selnum) return;
3019
3432
  this.els.selnum.textContent = seats.length.toLocaleString();
3020
3433
  const hasFree = seats.some((s) => this.status.get(s.label) === "free");
3021
3434
  const hasBlocked = seats.some((s) => this.status.get(s.label) === "blocked");
3022
- const hasBooked = seats.some((s) => this.status.get(s.label) === "booked");
3023
3435
  this.els.doblock.disabled = !hasFree;
3024
3436
  this.els.dounblock.disabled = !hasBlocked;
3025
- this.els.docancel.disabled = !hasBooked;
3026
3437
  }
3027
3438
  // ---- toast / done / fail --------------------------------------------------
3028
3439
  done(action, labels, msg) {
3029
3440
  this.toastOk(msg);
3441
+ if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
3030
3442
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
3031
3443
  }
3032
3444
  toastOk(msg) {