@seatlayer/js 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,31 @@ 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-inspect-card{padding:12px;border:1px solid var(--slm-line);border-radius:12px;background:var(--slm-surface)}
2372
+ .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}
2373
+ .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}
2374
+ .slm:fullscreen{border-radius:0;min-height:100vh;background:var(--slm-bg)}
2375
+ .slm:fullscreen .slm-bar{padding:14px 22px}.slm:fullscreen .slm-kpi b{font-size:21px}.slm:fullscreen .slm-rail{width:360px}
2376
+
2377
+ .slm.compact .slm-rail{width:100%;border-left:0;border-top:1px solid var(--slm-line);height:44%}
2378
+ .slm.compact .slm-body{flex-direction:column}
2379
+ .slm.compact .slm-bar{gap:8px;padding:8px}.slm.compact .slm-barbtn{padding:6px 9px}
2380
+ .slm.compact .slm-kpis{gap:9px}.slm.compact .slm-kpi:nth-child(n+5){display:none}
2352
2381
  `;
2353
2382
  function injectStyle() {
2354
2383
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
@@ -2386,6 +2415,9 @@ function fmtMoney(amount, currency) {
2386
2415
  return `${currency} ${Math.round(amount).toLocaleString()}`;
2387
2416
  }
2388
2417
  }
2418
+ function esc(value) {
2419
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
2420
+ }
2389
2421
  var SeatManager = class {
2390
2422
  constructor(options) {
2391
2423
  this.els = {};
@@ -2396,8 +2428,14 @@ var SeatManager = class {
2396
2428
  this.labelToSeat = /* @__PURE__ */ new Map();
2397
2429
  this.allIds = [];
2398
2430
  this.status = /* @__PURE__ */ new Map();
2399
- this.priceByCat = /* @__PURE__ */ new Map();
2400
2431
  this.currency = "USD";
2432
+ this.authoritativeGrossRevenue = 0;
2433
+ this.revenueStatus = "loading";
2434
+ this.revenueRequest = 0;
2435
+ this.revenueRefreshTimer = null;
2436
+ this.controlRoomSnapshot = null;
2437
+ this.trendWindowMinutes = 15;
2438
+ this.heatEnabled = false;
2401
2439
  // realtime socket
2402
2440
  this.ws = null;
2403
2441
  this.reconnectTimer = null;
@@ -2408,12 +2446,37 @@ var SeatManager = class {
2408
2446
  this.feedTimer = null;
2409
2447
  this.toastTimer = null;
2410
2448
  this.releaseAt = null;
2449
+ this.layoutObserver = null;
2450
+ this.tokenExpiresAt = null;
2451
+ this.tokenRefreshTimer = null;
2452
+ this.tokenRefreshInFlight = false;
2453
+ this.sectionByObject = /* @__PURE__ */ new Map();
2454
+ this.sectionLabelById = /* @__PURE__ */ new Map();
2455
+ this.lastSyncedAt = null;
2456
+ this.onFullscreenChange = () => {
2457
+ this.paintFullscreenButton();
2458
+ this.updateContainerLayout();
2459
+ this.renderer?.forceDraw();
2460
+ };
2461
+ this.onKeyDown = (event) => {
2462
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
2463
+ const target = event.target;
2464
+ if (target?.matches('input,select,textarea,[contenteditable="true"]')) return;
2465
+ const key = event.key.toLowerCase();
2466
+ if (key === "m") this.setMode("view");
2467
+ else if (key === "i") this.setMode("inspect");
2468
+ else if (key === "b") this.setMode("block");
2469
+ else if (key === "f") this.toggleFullscreen();
2470
+ else return;
2471
+ event.preventDefault();
2472
+ };
2411
2473
  this.sectionOptions = [];
2412
2474
  this.opts = options;
2413
2475
  this.key = options.eventKey;
2414
2476
  this.mode = options.mode ?? "view";
2415
2477
  this.keepLive = options.keepLiveWhileHidden ?? true;
2416
2478
  this.currency = options.currency ?? "USD";
2479
+ this.tokenExpiresAt = options.tokenExpiresAt ?? null;
2417
2480
  this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE3, options.token);
2418
2481
  this.host = resolveContainer4(options.container);
2419
2482
  }
@@ -2425,7 +2488,6 @@ var SeatManager = class {
2425
2488
  const res = await this.api.chart(this.key);
2426
2489
  this.doc = res.doc;
2427
2490
  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
2491
  const seats = expandChart2(res.doc);
2430
2492
  for (const s of seats) {
2431
2493
  this.labelToId.set(s.label, s.id);
@@ -2434,13 +2496,17 @@ var SeatManager = class {
2434
2496
  }
2435
2497
  this.buildRenderer();
2436
2498
  this.buildSectionOptions();
2437
- await this.resnapshot();
2499
+ await Promise.all([
2500
+ this.resnapshot(),
2501
+ this.refreshControlRoom().catch((err) => this.opts.onError?.(err))
2502
+ ]);
2438
2503
  this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
2439
2504
  });
2440
2505
  this.connect();
2441
2506
  this.startFeedClock();
2442
2507
  this.ready = true;
2443
2508
  this.setMode(this.mode);
2509
+ this.scheduleTokenRefresh();
2444
2510
  this.opts.onReady?.();
2445
2511
  } catch (err) {
2446
2512
  this.fail(err);
@@ -2449,11 +2515,82 @@ var SeatManager = class {
2449
2515
  }
2450
2516
  // ---- public API -----------------------------------------------------------
2451
2517
  setMode(mode) {
2452
- const changed = mode !== this.mode || !this.renderer;
2518
+ const changed = mode !== this.mode;
2453
2519
  this.mode = mode;
2454
- if (changed && this.doc) this.buildRenderer();
2520
+ if (!this.renderer && this.doc) this.buildRenderer();
2521
+ else this.updateRendererInteraction();
2522
+ if (changed) this.renderer?.clearSelection();
2455
2523
  this.paintModeTabs();
2456
2524
  this.paintRail();
2525
+ if (changed) this.opts.onModeChange?.(mode);
2526
+ }
2527
+ /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
2528
+ setHeatOverlay(enabled) {
2529
+ this.heatEnabled = enabled;
2530
+ this.applyHeatOverlay();
2531
+ this.paintHeatButton();
2532
+ }
2533
+ /** Change the current-vs-previous sales window and refresh the private projection. */
2534
+ setTrendWindow(windowMinutes) {
2535
+ const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
2536
+ this.trendWindowMinutes = Math.max(5, Math.min(60, normalized));
2537
+ this.paintTrendWindow();
2538
+ return this.refreshControlRoom();
2539
+ }
2540
+ async enterFullscreen() {
2541
+ if (!this.root?.requestFullscreen || this.isFullscreen()) return;
2542
+ await this.root.requestFullscreen();
2543
+ this.root.focus({ preventScroll: true });
2544
+ }
2545
+ async exitFullscreen() {
2546
+ if (typeof document === "undefined" || !this.isFullscreen()) return;
2547
+ await document.exitFullscreen();
2548
+ }
2549
+ isFullscreen() {
2550
+ return typeof document !== "undefined" && document.fullscreenElement === this.root;
2551
+ }
2552
+ toggleFullscreen() {
2553
+ const request2 = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
2554
+ void request2.catch((err) => this.opts.onError?.(err));
2555
+ }
2556
+ /** Rotate the delegated credential without rebuilding DOM, canvas or socket. */
2557
+ setToken(token, expiresAt) {
2558
+ this.api.setToken(token);
2559
+ this.tokenExpiresAt = expiresAt ?? null;
2560
+ this.scheduleTokenRefresh();
2561
+ }
2562
+ scheduleTokenRefresh() {
2563
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
2564
+ this.tokenRefreshTimer = null;
2565
+ const refresh = this.opts.onTokenRefresh;
2566
+ const expiresAt = this.tokenExpiresAt;
2567
+ if (this.closed || !refresh || !expiresAt || !Number.isFinite(expiresAt)) return;
2568
+ const remaining = expiresAt - Date.now();
2569
+ const lead = Math.min(12e4, Math.max(3e4, remaining * 0.2));
2570
+ const delay = Math.max(0, remaining - lead);
2571
+ this.tokenRefreshTimer = setTimeout(() => {
2572
+ this.tokenRefreshTimer = null;
2573
+ void this.rotateToken();
2574
+ }, delay);
2575
+ }
2576
+ async rotateToken() {
2577
+ if (this.closed || this.tokenRefreshInFlight || !this.opts.onTokenRefresh) return;
2578
+ this.tokenRefreshInFlight = true;
2579
+ try {
2580
+ const next = await this.opts.onTokenRefresh();
2581
+ if (!next?.token || !Number.isFinite(next.expiresAt)) throw new Error("invalid_token_refresh_result");
2582
+ this.setToken(next.token, next.expiresAt);
2583
+ } catch (err) {
2584
+ this.opts.onError?.(err);
2585
+ if (!this.closed) {
2586
+ this.tokenRefreshTimer = setTimeout(() => {
2587
+ this.tokenRefreshTimer = null;
2588
+ void this.rotateToken();
2589
+ }, 3e4);
2590
+ }
2591
+ } finally {
2592
+ this.tokenRefreshInFlight = false;
2593
+ }
2457
2594
  }
2458
2595
  /** Bulk block the given labels (or the current selection when omitted). */
2459
2596
  async block(labels, opts = {}) {
@@ -2538,7 +2675,13 @@ var SeatManager = class {
2538
2675
  return this.renderer?.getSelection() ?? [];
2539
2676
  }
2540
2677
  getReport() {
2541
- return this.api.report(this.key);
2678
+ return this.api.report(this.key).then((report) => {
2679
+ this.applyReportRevenue(report);
2680
+ return report;
2681
+ });
2682
+ }
2683
+ getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
2684
+ return this.setTrendWindow(windowMinutes);
2542
2685
  }
2543
2686
  getLog(opts = {}) {
2544
2687
  return this.api.log(this.key, opts);
@@ -2565,6 +2708,12 @@ var SeatManager = class {
2565
2708
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2566
2709
  if (this.feedTimer) clearInterval(this.feedTimer);
2567
2710
  if (this.toastTimer) clearTimeout(this.toastTimer);
2711
+ if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
2712
+ if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
2713
+ this.layoutObserver?.disconnect();
2714
+ this.layoutObserver = null;
2715
+ this.root?.removeEventListener("keydown", this.onKeyDown);
2716
+ if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
2568
2717
  if (this.ws) {
2569
2718
  try {
2570
2719
  this.ws.close();
@@ -2576,26 +2725,45 @@ var SeatManager = class {
2576
2725
  this.renderer = null;
2577
2726
  if (this.root && this.root.parentNode === this.host) this.host.removeChild(this.root);
2578
2727
  }
2579
- // ---- renderer lifecycle (rebuilt per mode, like ManageEventPage) ----------
2728
+ // ---- renderer lifecycle ---------------------------------------------------
2580
2729
  buildRenderer() {
2581
2730
  if (!this.doc) return;
2582
- this.renderer?.destroy();
2583
2731
  const block = this.mode === "block";
2732
+ const inspect = this.mode === "inspect";
2584
2733
  this.renderer = new SeatmapRenderer(this.mapHost, {
2585
2734
  manageMode: true,
2586
2735
  marqueeSelect: block,
2587
2736
  maxSelection: 1e6,
2588
- selectableStatuses: block ? ["free", "not_for_sale", "booked"] : [],
2737
+ selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : [],
2589
2738
  currency: this.currency,
2590
- onSelect: () => this.syncSelection(),
2739
+ onSelect: (seat) => this.handleSeatSelect(seat),
2591
2740
  onDeselect: () => this.syncSelection(),
2592
2741
  onMarquee: () => this.syncSelection(),
2593
2742
  onViewChange: () => this.updateZoomHint()
2594
2743
  });
2595
2744
  this.renderer.setChart(this.doc);
2596
2745
  this.repaintAll();
2746
+ this.applyHeatOverlay();
2747
+ this.updateZoomHint();
2748
+ }
2749
+ updateRendererInteraction() {
2750
+ const block = this.mode === "block";
2751
+ const inspect = this.mode === "inspect";
2752
+ this.renderer?.setManageInteraction({
2753
+ manageMode: true,
2754
+ marqueeSelect: block,
2755
+ maxSelection: 1e6,
2756
+ selectableStatuses: block ? ["free", "not_for_sale"] : inspect ? ["free", "held", "booked", "not_for_sale"] : []
2757
+ });
2597
2758
  this.updateZoomHint();
2598
2759
  }
2760
+ handleSeatSelect(seat) {
2761
+ if (this.mode === "inspect") {
2762
+ const others = this.getSelection().filter((selected) => selected.id !== seat.id).map((selected) => selected.id);
2763
+ if (others.length) this.renderer?.deselect(others);
2764
+ }
2765
+ this.syncSelection();
2766
+ }
2599
2767
  repaintAll() {
2600
2768
  const r = this.renderer;
2601
2769
  if (!r) return;
@@ -2623,7 +2791,7 @@ var SeatManager = class {
2623
2791
  ws.onopen = () => {
2624
2792
  this.attempt = 0;
2625
2793
  this.setLive(true);
2626
- void this.resnapshot();
2794
+ void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
2627
2795
  };
2628
2796
  ws.onmessage = (e) => this.onMessage(e);
2629
2797
  ws.onclose = () => {
@@ -2655,11 +2823,25 @@ var SeatManager = class {
2655
2823
  }
2656
2824
  if (!msg || typeof msg !== "object") return;
2657
2825
  const m = msg;
2826
+ if (m.type === "presence") {
2827
+ if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
2828
+ this.controlRoomSnapshot = {
2829
+ ...this.controlRoomSnapshot,
2830
+ presence: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
2831
+ };
2832
+ this.lastSyncedAt = Date.now();
2833
+ this.recomputeTallies();
2834
+ this.paintMonitorInsights();
2835
+ this.opts.onControlRoom?.(this.controlRoomSnapshot);
2836
+ }
2837
+ return;
2838
+ }
2658
2839
  if (m.type === "hidden") return;
2659
2840
  if (m.seats && typeof m.seats === "object") {
2660
2841
  this.applySnapshot(m.seats);
2661
2842
  } else if (Array.isArray(m.changes)) {
2662
2843
  const ids = [];
2844
+ const groups = /* @__PURE__ */ new Map();
2663
2845
  for (const ch of m.changes) {
2664
2846
  const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
2665
2847
  const prev = this.status.get(ch.label) ?? "free";
@@ -2671,10 +2853,19 @@ var SeatManager = class {
2671
2853
  this.flash(id, st);
2672
2854
  ids.push(id);
2673
2855
  }
2674
- this.pushActivity(ch.label, prev, st);
2856
+ const verb = this.verbFor(prev, st);
2857
+ const groupKey = `${verb}:${st}`;
2858
+ const group = groups.get(groupKey) ?? { labels: [], verb, status: st };
2859
+ group.labels.push(ch.label);
2860
+ groups.set(groupKey, group);
2861
+ }
2862
+ for (const group of groups.values()) this.pushActivity(group.labels, group.verb, group.status);
2863
+ if (ids.length) {
2864
+ this.lastSyncedAt = Date.now();
2865
+ this.afterPaint();
2675
2866
  }
2676
- if (ids.length) this.afterPaint();
2677
2867
  this.recomputeTallies();
2868
+ if (ids.length) this.scheduleRevenueRefresh();
2678
2869
  }
2679
2870
  }
2680
2871
  async resnapshot() {
@@ -2690,6 +2881,7 @@ var SeatManager = class {
2690
2881
  next.set(label, ["free", "held", "booked", "blocked"].includes(st) ? st : "free");
2691
2882
  }
2692
2883
  this.status = next;
2884
+ this.lastSyncedAt = Date.now();
2693
2885
  this.repaintAll();
2694
2886
  this.afterPaint();
2695
2887
  this.recomputeTallies();
@@ -2713,6 +2905,47 @@ var SeatManager = class {
2713
2905
  else if (st === "booked") this.renderer?.flashSeat(id, "#22a06b");
2714
2906
  }
2715
2907
  // ---- tallies + feed -------------------------------------------------------
2908
+ applyReportRevenue(report) {
2909
+ this.authoritativeGrossRevenue = report.report.byCategory.reduce(
2910
+ (sum, row) => sum + (Number.isFinite(row.bookedRevenue) ? row.bookedRevenue : 0),
2911
+ 0
2912
+ );
2913
+ this.revenueStatus = "current";
2914
+ this.recomputeTallies();
2915
+ }
2916
+ async refreshControlRoom() {
2917
+ const request2 = ++this.revenueRequest;
2918
+ try {
2919
+ const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
2920
+ if (request2 === this.revenueRequest) {
2921
+ this.controlRoomSnapshot = snapshot;
2922
+ this.lastSyncedAt = Date.now();
2923
+ this.authoritativeGrossRevenue = snapshot.revenue.gross;
2924
+ this.currency = snapshot.currency;
2925
+ this.revenueStatus = "current";
2926
+ this.recomputeTallies();
2927
+ this.applyHeatOverlay();
2928
+ this.paintMonitorInsights();
2929
+ this.opts.onControlRoom?.(snapshot);
2930
+ }
2931
+ return snapshot;
2932
+ } catch (err) {
2933
+ if (request2 === this.revenueRequest) {
2934
+ this.revenueStatus = "stale";
2935
+ this.recomputeTallies();
2936
+ }
2937
+ throw err;
2938
+ }
2939
+ }
2940
+ scheduleRevenueRefresh(delay = 140) {
2941
+ this.revenueStatus = "stale";
2942
+ this.recomputeTallies();
2943
+ if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
2944
+ this.revenueRefreshTimer = setTimeout(() => {
2945
+ this.revenueRefreshTimer = null;
2946
+ void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
2947
+ }, delay);
2948
+ }
2716
2949
  recomputeTallies() {
2717
2950
  const t3 = {
2718
2951
  free: 0,
@@ -2722,24 +2955,24 @@ var SeatManager = class {
2722
2955
  total: this.allIds.length,
2723
2956
  capacityPct: 0,
2724
2957
  sellThroughPct: 0,
2725
- grossRevenue: 0,
2958
+ grossRevenue: this.authoritativeGrossRevenue,
2959
+ revenueStatus: this.revenueStatus,
2726
2960
  currency: this.currency
2727
2961
  };
2728
2962
  let nonFree = 0;
2729
- for (const [label, st] of this.status.entries()) {
2963
+ for (const st of this.status.values()) {
2730
2964
  t3[st] += 1;
2731
2965
  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
2966
  }
2737
2967
  t3.free = Math.max(0, t3.total - nonFree);
2738
2968
  t3.capacityPct = t3.total ? Math.round(t3.booked / t3.total * 100) : 0;
2739
2969
  const sellable = t3.total - t3.blocked;
2740
2970
  t3.sellThroughPct = sellable > 0 ? Math.round(t3.booked / sellable * 100) : 0;
2741
2971
  this.paintKpis(t3);
2742
- if (this.mode === "view") this.paintLegend(t3);
2972
+ if (this.mode === "view") {
2973
+ this.paintLegend(t3);
2974
+ this.paintMonitorInsights();
2975
+ } else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
2743
2976
  this.opts.onTallies?.(t3);
2744
2977
  }
2745
2978
  verbFor(prev, next) {
@@ -2749,17 +2982,22 @@ var SeatManager = class {
2749
2982
  if (next === "free") return prev === "blocked" ? "unblocked" : prev === "booked" ? "cancelled" : "released";
2750
2983
  return next;
2751
2984
  }
2752
- pushActivity(label, prev, next) {
2985
+ pushActivity(labels, verb, status, at = Date.now()) {
2986
+ const label = labels[0];
2987
+ if (!label) return;
2753
2988
  const item = {
2754
- id: `${label}:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`,
2755
- at: Date.now(),
2989
+ id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
2990
+ at,
2756
2991
  label,
2757
- verb: this.verbFor(prev, next),
2758
- status: next
2992
+ labels: [...labels],
2993
+ count: labels.length,
2994
+ verb,
2995
+ status
2759
2996
  };
2760
2997
  this.feed.unshift(item);
2761
2998
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
2762
2999
  if (this.mode === "view") this.paintFeed();
3000
+ this.opts.onActivity?.(item);
2763
3001
  }
2764
3002
  seedFeed(entries) {
2765
3003
  const verbByAction = {
@@ -2781,14 +3019,17 @@ var SeatManager = class {
2781
3019
  for (const e of entries) {
2782
3020
  const label = e.labels[0];
2783
3021
  if (!label) continue;
2784
- const extra = e.labels.length > 1 ? ` +${e.labels.length - 1}` : "";
2785
- this.feed.push({
3022
+ const item = {
2786
3023
  id: `log:${e.id}`,
2787
3024
  at: e.at,
2788
- label: label + extra,
3025
+ label,
3026
+ labels: [...e.labels],
3027
+ count: e.labels.length,
2789
3028
  verb: verbByAction[e.action] ?? e.action,
2790
3029
  status: stByAction[e.action] ?? "free"
2791
- });
3030
+ };
3031
+ this.feed.push(item);
3032
+ this.opts.onActivity?.(item);
2792
3033
  }
2793
3034
  this.feed.sort((a, b) => b.at - a.at);
2794
3035
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
@@ -2796,7 +3037,10 @@ var SeatManager = class {
2796
3037
  }
2797
3038
  startFeedClock() {
2798
3039
  this.feedTimer = setInterval(() => {
2799
- if (this.mode === "view") this.paintFeed();
3040
+ if (this.mode === "view") {
3041
+ this.paintFeed();
3042
+ this.paintMonitorInsights();
3043
+ }
2800
3044
  }, 1e4);
2801
3045
  }
2802
3046
  // ---- selection ------------------------------------------------------------
@@ -2806,21 +3050,30 @@ var SeatManager = class {
2806
3050
  syncSelection() {
2807
3051
  const seats = this.getSelection();
2808
3052
  if (this.mode === "block") this.paintSelBar(seats);
3053
+ else if (this.mode === "inspect") this.renderInspectRail(seats);
2809
3054
  this.opts.onSelectionChange?.(seats);
2810
3055
  }
2811
3056
  // ---- DOM: chrome ----------------------------------------------------------
2812
3057
  buildChrome() {
2813
3058
  const root = document.createElement("div");
2814
3059
  root.className = "slm";
3060
+ root.tabIndex = 0;
3061
+ root.setAttribute("role", "region");
3062
+ root.setAttribute("aria-label", "SeatLayer live control room");
2815
3063
  const vars = themeVars(this.opts.theme);
2816
3064
  for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
2817
3065
  root.innerHTML = `
2818
3066
  <div class="slm-bar">
2819
3067
  <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>
3068
+ <button class="slm-mode" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
3069
+ <button class="slm-mode" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
3070
+ <button class="slm-mode" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
2822
3071
  </div>
2823
3072
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
3073
+ <div class="slm-bar-actions">
3074
+ <button class="slm-barbtn" data-ref="heat" aria-pressed="false">Heat</button>
3075
+ <button class="slm-barbtn" data-ref="fullscreen" title="Full screen (F)" aria-keyshortcuts="F">Full screen</button>
3076
+ </div>
2824
3077
  <div class="slm-kpis" data-ref="kpis"></div>
2825
3078
  </div>
2826
3079
  <div class="slm-body">
@@ -2835,12 +3088,19 @@ var SeatManager = class {
2835
3088
  `;
2836
3089
  this.host.appendChild(root);
2837
3090
  this.root = root;
3091
+ this.updateContainerLayout();
3092
+ if (typeof ResizeObserver !== "undefined") {
3093
+ this.layoutObserver = new ResizeObserver(() => this.updateContainerLayout());
3094
+ this.layoutObserver.observe(root);
3095
+ }
2838
3096
  const ref = (n) => root.querySelector(`[data-ref="${n}"]`);
2839
3097
  this.mapHost = ref("maphost");
2840
3098
  this.els = {
2841
3099
  modes: ref("modes"),
2842
3100
  livetext: ref("livetext"),
2843
3101
  kpis: ref("kpis"),
3102
+ heat: ref("heat"),
3103
+ fullscreen: ref("fullscreen"),
2844
3104
  zoomhint: ref("zoomhint"),
2845
3105
  rail: ref("rail"),
2846
3106
  toast: ref("toast"),
@@ -2848,14 +3108,33 @@ var SeatManager = class {
2848
3108
  };
2849
3109
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
2850
3110
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
3111
+ this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
3112
+ this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
3113
+ root.addEventListener("keydown", this.onKeyDown);
3114
+ document.addEventListener("fullscreenchange", this.onFullscreenChange);
2851
3115
  this.paintModeTabs();
3116
+ this.paintHeatButton();
3117
+ this.paintFullscreenButton();
3118
+ }
3119
+ updateContainerLayout() {
3120
+ const width = this.root?.getBoundingClientRect().width || this.host.clientWidth;
3121
+ this.root?.classList.toggle("compact", width > 0 && width < 800);
2852
3122
  }
2853
3123
  buildSectionOptions() {
2854
3124
  if (!this.doc) return;
2855
3125
  try {
2856
3126
  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 });
3127
+ this.sectionOptions = [];
3128
+ this.sectionByObject = new Map(secs.objectToSection);
3129
+ this.sectionLabelById.clear();
3130
+ for (const s of secs.sections) {
3131
+ this.sectionOptions.push({ id: s.id, label: s.label });
3132
+ this.sectionLabelById.set(s.id, s.label);
3133
+ }
3134
+ if (secs.ungrouped) {
3135
+ this.sectionOptions.push({ id: UNGROUPED_ID, label: secs.ungrouped.label });
3136
+ this.sectionLabelById.set(UNGROUPED_ID, secs.ungrouped.label);
3137
+ }
2859
3138
  } catch {
2860
3139
  }
2861
3140
  }
@@ -2866,9 +3145,27 @@ var SeatManager = class {
2866
3145
  });
2867
3146
  this.root?.classList.toggle("block-mode", this.mode === "block");
2868
3147
  }
3148
+ paintHeatButton() {
3149
+ const button = this.els.heat;
3150
+ if (!button) return;
3151
+ button.classList.toggle("on", this.heatEnabled);
3152
+ button.setAttribute("aria-pressed", String(this.heatEnabled));
3153
+ button.textContent = this.heatEnabled ? "Heat on" : "Heat";
3154
+ }
3155
+ paintFullscreenButton() {
3156
+ if (!this.els.fullscreen) return;
3157
+ this.els.fullscreen.textContent = this.isFullscreen() ? "Exit full screen" : "Full screen";
3158
+ }
3159
+ paintTrendWindow() {
3160
+ this.els.rail?.querySelectorAll("[data-window]").forEach((button) => {
3161
+ const value = Number(button.dataset.window);
3162
+ button.classList.toggle("on", value === this.trendWindowMinutes);
3163
+ });
3164
+ }
2869
3165
  setLive(on) {
2870
3166
  this.root?.classList.toggle("live", on);
2871
3167
  if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
3168
+ this.paintMonitorInsights();
2872
3169
  }
2873
3170
  updateZoomHint() {
2874
3171
  const hint = this.els.zoomhint;
@@ -2878,10 +3175,13 @@ var SeatManager = class {
2878
3175
  }
2879
3176
  paintKpis(t3) {
2880
3177
  if (!this.els.kpis) return;
2881
- const rev = fmtMoney(t3.grossRevenue, t3.currency);
3178
+ const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
3179
+ const presence = this.controlRoomSnapshot?.presence;
2882
3180
  this.els.kpis.innerHTML = [
2883
3181
  { n: t3.booked.toLocaleString(), l: "Sold", dot: "#22a06b" },
2884
3182
  { n: t3.held.toLocaleString(), l: "Held", dot: "#f4b740" },
3183
+ { n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Shopping" },
3184
+ { n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Live holds" },
2885
3185
  { n: t3.free.toLocaleString(), l: "Free", dot: "#6e7bff" },
2886
3186
  { n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
2887
3187
  { n: `${t3.capacityPct}%`, l: "Capacity" },
@@ -2891,22 +3191,120 @@ var SeatManager = class {
2891
3191
  // ---- DOM: rails -----------------------------------------------------------
2892
3192
  paintRail() {
2893
3193
  if (this.mode === "view") this.renderViewRail();
3194
+ else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
2894
3195
  else this.renderBlockRail();
2895
3196
  this.updateZoomHint();
2896
3197
  }
2897
3198
  renderViewRail() {
2898
3199
  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>
3200
+ <p class="slm-eyebrow">Monitor</p>
3201
+ <p class="slm-hint">Read-only. Inventory, buyer presence and sales movement update on the same live board.</p>
3202
+ <div class="slm-health" data-ref="presence"></div>
2901
3203
  <div class="slm-legend" data-ref="legend"></div>
3204
+ <div class="slm-sectionhead">
3205
+ <div><p class="slm-eyebrow">Section performance</p><p class="slm-note">Exact booked revenue \xB7 net sales velocity</p></div>
3206
+ <div class="slm-windows" aria-label="Sales velocity window">
3207
+ ${[5, 15, 30, 60].map((window2) => `<button class="slm-window" data-window="${window2}">${window2}m</button>`).join("")}
3208
+ </div>
3209
+ </div>
3210
+ <div class="slm-sectionlist" data-ref="sections"></div>
2902
3211
  <p class="slm-eyebrow">Activity</p>
2903
3212
  <div class="slm-feed" data-ref="feed"></div>
2904
3213
  `;
3214
+ this.els.presence = this.els.rail.querySelector('[data-ref="presence"]');
2905
3215
  this.els.legend = this.els.rail.querySelector('[data-ref="legend"]');
3216
+ this.els.sections = this.els.rail.querySelector('[data-ref="sections"]');
2906
3217
  this.els.feed = this.els.rail.querySelector('[data-ref="feed"]');
3218
+ this.els.rail.querySelectorAll("[data-window]").forEach((button) => button.addEventListener("click", () => {
3219
+ const windowMinutes = Number(button.dataset.window);
3220
+ void this.setTrendWindow(windowMinutes).catch((err) => this.opts.onError?.(err));
3221
+ }));
2907
3222
  this.recomputeTallies();
3223
+ this.paintMonitorInsights();
3224
+ this.paintTrendWindow();
2908
3225
  this.paintFeed();
2909
3226
  }
3227
+ paintMonitorInsights() {
3228
+ if (this.mode !== "view") return;
3229
+ const snapshot = this.controlRoomSnapshot;
3230
+ if (this.els.presence) {
3231
+ const connected = this.root?.classList.contains("live");
3232
+ const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
3233
+ this.els.presence.innerHTML = `
3234
+ <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyer sessions</span></div>
3235
+ <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Active holds</span></div>
3236
+ <div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
3237
+ <div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
3238
+ }
3239
+ if (!this.els.sections) return;
3240
+ if (!snapshot) {
3241
+ this.els.sections.innerHTML = '<div class="slm-empty">Loading authoritative section metrics\u2026</div>';
3242
+ return;
3243
+ }
3244
+ const velocity = new Map(snapshot.velocity.bySection.map((row) => [row.sectionId, row]));
3245
+ const rows = [...snapshot.revenue.bySection].sort((a, b) => {
3246
+ const av = velocity.get(a.sectionId)?.netBooked ?? 0;
3247
+ const bv = velocity.get(b.sectionId)?.netBooked ?? 0;
3248
+ return bv - av || b.bookedRevenue - a.bookedRevenue;
3249
+ });
3250
+ this.els.sections.innerHTML = rows.length ? rows.map((row) => {
3251
+ const speed = velocity.get(row.sectionId);
3252
+ const net = speed?.netBooked ?? 0;
3253
+ const netLabel = `${net > 0 ? "+" : ""}${net}`;
3254
+ const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
3255
+ return `<div class="slm-sectionrow">
3256
+ <div class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></div>
3257
+ <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>
3258
+ </div>`;
3259
+ }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
3260
+ this.paintTrendWindow();
3261
+ }
3262
+ applyHeatOverlay() {
3263
+ const snapshot = this.controlRoomSnapshot;
3264
+ if (!this.heatEnabled || !snapshot) {
3265
+ this.renderer?.setSectionHeat(null);
3266
+ return;
3267
+ }
3268
+ const capacity = new Map(snapshot.revenue.bySection.map((row) => [row.sectionId, Math.max(1, row.total)]));
3269
+ const rates = snapshot.velocity.bySection.map((row) => ({
3270
+ sectionId: row.sectionId,
3271
+ rate: Math.max(0, row.netBooked) / (capacity.get(row.sectionId) ?? 1) / snapshot.velocity.windowMinutes
3272
+ }));
3273
+ const max = Math.max(0, ...rates.map((row) => row.rate));
3274
+ const scores = {};
3275
+ for (const row of rates) scores[row.sectionId] = max > 0 ? Math.sqrt(row.rate / max) : 0;
3276
+ this.renderer?.setSectionHeat(scores);
3277
+ }
3278
+ renderInspectRail(seats) {
3279
+ const seat = seats[seats.length - 1];
3280
+ if (!seat) {
3281
+ this.els.rail.innerHTML = `
3282
+ <p class="slm-eyebrow">Inspect</p>
3283
+ <p class="slm-hint">Select any seat to see its live inventory context. Inspect is read-only and never changes availability.</p>
3284
+ <div class="slm-empty">Choose a seat on the map.</div>`;
3285
+ return;
3286
+ }
3287
+ const status = this.status.get(seat.label) ?? "free";
3288
+ const statusLabel = { free: "Free", held: "Held", booked: "Booked", blocked: "Blocked" };
3289
+ const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
3290
+ const sectionLabel = this.sectionLabelById.get(sectionId) ?? "Other seats";
3291
+ const category = this.doc?.categories.find((item) => item.key === seat.categoryKey);
3292
+ const sectionMetric = this.controlRoomSnapshot?.revenue.bySection.find((row) => row.sectionId === sectionId);
3293
+ this.els.rail.innerHTML = `
3294
+ <p class="slm-eyebrow">Inspect</p>
3295
+ <p class="slm-hint">Live inventory context. Booked seats remain read-only; order and payment actions belong to the host commerce system.</p>
3296
+ <div class="slm-inspect-card">
3297
+ <div class="slm-inspect-label">${esc(seat.label)}</div>
3298
+ <div class="slm-inspect-grid">
3299
+ <div><span>Status</span><b>${statusLabel[status]}</b></div>
3300
+ <div><span>Section</span><b>${esc(sectionLabel)}</b></div>
3301
+ <div><span>Row</span><b>${esc(seat.rowId)}</b></div>
3302
+ <div><span>Category</span><b>${esc(category?.label ?? seat.categoryKey)}</b></div>
3303
+ <div><span>Section sold</span><b>${sectionMetric ? `${sectionMetric.booked}/${sectionMetric.total}` : "\u2014"}</b></div>
3304
+ <div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>
3305
+ </div>
3306
+ </div>`;
3307
+ }
2910
3308
  paintLegend(t3) {
2911
3309
  if (!this.els.legend) return;
2912
3310
  this.els.legend.innerHTML = LEGEND.map((l) => `<div class="slm-legrow"><span class="slm-legdot" style="background:${l.color}"></span>
@@ -2920,19 +3318,22 @@ var SeatManager = class {
2920
3318
  }
2921
3319
  const now = Date.now();
2922
3320
  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("");
3321
+ this.els.feed.innerHTML = this.feed.map((a) => {
3322
+ const extra = a.count > 1 ? ` +${a.count - 1}` : "";
3323
+ return `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
3324
+ <span class="slm-feedtext">${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
3325
+ <span class="slm-feedtime">${relTime(a.at, now)}</span></div>`;
3326
+ }).join("");
2926
3327
  }
2927
3328
  renderBlockRail() {
2928
3329
  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("");
3330
+ 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
3331
  const sectionField = this.sectionOptions.length ? `<div class="slm-field"><label>Select a whole section</label>
2931
3332
  <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>` : "";
3333
+ ${this.sectionOptions.map((s) => `<option value="${esc(s.id)}">${esc(s.label)}</option>`).join("")}</select></div>` : "";
2933
3334
  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>
3335
+ <p class="slm-eyebrow">Block &amp; unblock</p>
3336
+ <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
3337
  <div class="slm-selbar"><span class="slm-selnum" data-ref="selnum">0</span><span class="slm-sellabel">selected</span></div>
2937
3338
  <div class="slm-row">
2938
3339
  <button class="slm-btn" data-ref="doblock" disabled>Block</button>
@@ -2941,7 +3342,6 @@ var SeatManager = class {
2941
3342
  <div class="slm-row">
2942
3343
  <button class="slm-btn ghost" data-ref="selall">Select all</button>
2943
3344
  <button class="slm-btn ghost" data-ref="clearsel">Clear</button>
2944
- <button class="slm-btn danger" data-ref="docancel" disabled>Cancel booking</button>
2945
3345
  </div>
2946
3346
  <p class="slm-eyebrow" style="margin-top:8px">By category</p>
2947
3347
  <div class="slm-chiprow">${catChips || '<span class="slm-empty">No categories.</span>'}</div>
@@ -2957,10 +3357,8 @@ var SeatManager = class {
2957
3357
  this.els.selnum = r("selnum");
2958
3358
  this.els.doblock = r("doblock");
2959
3359
  this.els.dounblock = r("dounblock");
2960
- this.els.docancel = r("docancel");
2961
3360
  r("doblock").addEventListener("click", () => void this.block());
2962
3361
  r("dounblock").addEventListener("click", () => void this.unblock());
2963
- r("docancel").addEventListener("click", () => this.promptCancel());
2964
3362
  r("selall").addEventListener("click", () => this.selectAll());
2965
3363
  r("clearsel").addEventListener("click", () => this.clearSelection());
2966
3364
  r("markall").addEventListener("click", () => void this.unblockAll());
@@ -2986,28 +3384,18 @@ var SeatManager = class {
2986
3384
  for (const [label, seat] of this.labelToSeat.entries()) if (seat.categoryKey === catKey) labels.push(label);
2987
3385
  this.selectByLabels(labels);
2988
3386
  }
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
3387
  paintSelBar(seats) {
2999
3388
  if (!this.els.selnum) return;
3000
3389
  this.els.selnum.textContent = seats.length.toLocaleString();
3001
3390
  const hasFree = seats.some((s) => this.status.get(s.label) === "free");
3002
3391
  const hasBlocked = seats.some((s) => this.status.get(s.label) === "blocked");
3003
- const hasBooked = seats.some((s) => this.status.get(s.label) === "booked");
3004
3392
  this.els.doblock.disabled = !hasFree;
3005
3393
  this.els.dounblock.disabled = !hasBlocked;
3006
- this.els.docancel.disabled = !hasBooked;
3007
3394
  }
3008
3395
  // ---- toast / done / fail --------------------------------------------------
3009
3396
  done(action, labels, msg) {
3010
3397
  this.toastOk(msg);
3398
+ if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
3011
3399
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
3012
3400
  }
3013
3401
  toastOk(msg) {