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