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