@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/README.md +26 -0
- package/dist/index.cjs +473 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +115 -7
- package/dist/index.d.ts +115 -7
- package/dist/index.js +473 -61
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
|
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
|
|
2541
|
+
const changed = mode !== this.mode;
|
|
2472
2542
|
this.mode = mode;
|
|
2473
|
-
if (
|
|
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
|
|
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.
|
|
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.
|
|
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:
|
|
2981
|
+
grossRevenue: this.authoritativeGrossRevenue,
|
|
2982
|
+
revenueStatus: this.revenueStatus,
|
|
2745
2983
|
currency: this.currency
|
|
2746
2984
|
};
|
|
2747
2985
|
let nonFree = 0;
|
|
2748
|
-
for (const
|
|
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")
|
|
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(
|
|
3008
|
+
pushActivity(labels, verb, status, at = Date.now()) {
|
|
3009
|
+
const label = labels[0];
|
|
3010
|
+
if (!label) return;
|
|
2772
3011
|
const item = {
|
|
2773
|
-
id: `${label}:${
|
|
2774
|
-
at
|
|
3012
|
+
id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
|
|
3013
|
+
at,
|
|
2775
3014
|
label,
|
|
2776
|
-
|
|
2777
|
-
|
|
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
|
|
2804
|
-
this.feed.push({
|
|
3045
|
+
const item = {
|
|
2805
3046
|
id: `log:${e.id}`,
|
|
2806
3047
|
at: e.at,
|
|
2807
|
-
label
|
|
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")
|
|
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">
|
|
2840
|
-
<button class="slm-mode" data-mode="
|
|
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
|
-
|
|
2877
|
-
|
|
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">
|
|
2919
|
-
<p class="slm-hint">Read-only.
|
|
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) =>
|
|
2943
|
-
|
|
2944
|
-
|
|
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 &
|
|
2954
|
-
<p class="slm-hint">Drag a box on the map to marquee-select, \u2318A for all, or pick a category/section
|
|
3378
|
+
<p class="slm-eyebrow">Block & 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) {
|