@seatlayer/js 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channelsMode-33ZLORVF.js +13 -0
- package/dist/channelsMode-33ZLORVF.js.map +1 -0
- package/dist/chunk-CF5MS7UR.js +2543 -0
- package/dist/chunk-CF5MS7UR.js.map +1 -0
- package/dist/chunk-DMEFXZIL.js +3198 -0
- package/dist/chunk-DMEFXZIL.js.map +1 -0
- package/dist/chunk-X2R4AQSZ.js +323 -0
- package/dist/chunk-X2R4AQSZ.js.map +1 -0
- package/dist/index.cjs +9517 -9416
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -1382
- package/dist/index.d.ts +6 -1382
- package/dist/index.js +48 -5958
- package/dist/index.js.map +1 -1
- package/dist/manager-CAIPkUYl.d.cts +1419 -0
- package/dist/manager-CAIPkUYl.d.ts +1419 -0
- package/dist/manager.cjs +6069 -0
- package/dist/manager.cjs.map +1 -0
- package/dist/manager.d.cts +2 -0
- package/dist/manager.d.ts +2 -0
- package/dist/manager.js +14 -0
- package/dist/manager.js.map +1 -0
- package/package.json +20 -2
|
@@ -0,0 +1,2543 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ManageApi,
|
|
3
|
+
ManageApiError
|
|
4
|
+
} from "./chunk-X2R4AQSZ.js";
|
|
5
|
+
|
|
6
|
+
// src/SeatManager.ts
|
|
7
|
+
import {
|
|
8
|
+
SeatmapRenderer,
|
|
9
|
+
expandChart,
|
|
10
|
+
computeSections,
|
|
11
|
+
gaAreasOf,
|
|
12
|
+
gaUnitLabels,
|
|
13
|
+
UNGROUPED_ID
|
|
14
|
+
} from "@seatlayer/core";
|
|
15
|
+
function availabilityModeOf(rule) {
|
|
16
|
+
return rule ? rule.mode : "open";
|
|
17
|
+
}
|
|
18
|
+
function availabilityRuleForMode(mode, seatLabels, prev) {
|
|
19
|
+
switch (mode) {
|
|
20
|
+
case "open":
|
|
21
|
+
return null;
|
|
22
|
+
case "hidden":
|
|
23
|
+
return { mode: "hidden", labels: seatLabels };
|
|
24
|
+
case "closed":
|
|
25
|
+
return { mode: "closed", labels: seatLabels };
|
|
26
|
+
case "timed":
|
|
27
|
+
return { mode: "timed", revealAt: prev?.revealAt ?? Date.now() + 36e5, labels: seatLabels };
|
|
28
|
+
case "threshold":
|
|
29
|
+
return { mode: "threshold", thresholdPct: prev?.thresholdPct ?? 80, labels: seatLabels };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function toLocalInput(ms) {
|
|
33
|
+
const d = new Date(ms - (/* @__PURE__ */ new Date()).getTimezoneOffset() * 6e4);
|
|
34
|
+
return d.toISOString().slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
function resolveContainer(container) {
|
|
37
|
+
if (typeof container === "string") {
|
|
38
|
+
const el = document.querySelector(container);
|
|
39
|
+
if (!el) throw new Error(`seatmanager: container "${container}" not found`);
|
|
40
|
+
return el;
|
|
41
|
+
}
|
|
42
|
+
if (!(container instanceof HTMLElement)) {
|
|
43
|
+
throw new Error("seatmanager: container must be a CSS selector or an HTMLElement");
|
|
44
|
+
}
|
|
45
|
+
return container;
|
|
46
|
+
}
|
|
47
|
+
function toRenderStatus(s) {
|
|
48
|
+
return s === "blocked" ? "not_for_sale" : s;
|
|
49
|
+
}
|
|
50
|
+
var DEFAULT_API_BASE = "https://api.seatlayer.io";
|
|
51
|
+
var STYLE_ID = "seatlayer-manager-style";
|
|
52
|
+
var CHANNELS_STYLE_ID = "seatlayer-manager-channels-style";
|
|
53
|
+
var FEED_CAP = 80;
|
|
54
|
+
var MAX_LIVE_SEAT_PULSES = 16;
|
|
55
|
+
var MAX_LIVE_SECTION_PULSES = 4;
|
|
56
|
+
var LEGEND = [
|
|
57
|
+
{ key: "free", label: "Free", color: "#6e7bff" },
|
|
58
|
+
{ key: "held", label: "Held", color: "#f4b740" },
|
|
59
|
+
{ key: "booked", label: "Booked", color: "#22a06b" },
|
|
60
|
+
{ key: "blocked", label: "Blocked", color: "#8b94ac" }
|
|
61
|
+
];
|
|
62
|
+
var MANAGER_CSS = (
|
|
63
|
+
/* @sl-css */
|
|
64
|
+
`
|
|
65
|
+
/* The floor was 480px, which trapped the cockpit in any host shorter than that:
|
|
66
|
+
every ancestor here is overflow:hidden, so the bottom of the rail (Create
|
|
67
|
+
channel, Show archived) was rendered but clipped away by the host and could
|
|
68
|
+
not be reached by scrolling anything. 320px still keeps an unsized host from
|
|
69
|
+
collapsing to the top bar, and the rail scrolls to its full content below. */
|
|
70
|
+
.slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:320px;overflow:hidden;
|
|
71
|
+
background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius);
|
|
72
|
+
/* Motion tokens (motion-system \xA72), declared by the cockpit ROOT rather than
|
|
73
|
+
borrowed from CHANNELS_CSS. The base cockpit animates whether or not
|
|
74
|
+
Channels mode is in use, so owning its own tokens is what stops a token
|
|
75
|
+
edit from silently changing only half the surface. Channels mode declares
|
|
76
|
+
the identical values so an embed of it stays self-contained. */
|
|
77
|
+
--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
|
|
78
|
+
--slm-mo-out:cubic-bezier(.2,.8,.2,1);--slm-mo-in-out:cubic-bezier(.4,0,.2,1);--slm-mo-exit:cubic-bezier(.4,0,1,1);
|
|
79
|
+
--slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
|
|
80
|
+
.slm *{box-sizing:border-box;margin:0;padding:0}
|
|
81
|
+
.slm button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
82
|
+
.slm input{font:inherit}
|
|
83
|
+
|
|
84
|
+
/* top bar */
|
|
85
|
+
.slm-bar{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;column-gap:14px;row-gap:10px;
|
|
86
|
+
padding:10px 16px;border-bottom:1px solid var(--slm-line);flex:none}
|
|
87
|
+
.slm-modes{display:inline-flex;background:var(--slm-surface);border:1px solid var(--slm-line);border-radius:999px;padding:3px}
|
|
88
|
+
.slm-mode{padding:6px 16px;border-radius:999px;font-weight:700;font-size:13px;color:var(--slm-muted)}
|
|
89
|
+
.slm-mode.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
90
|
+
.slm-live{display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.12em;font-weight:800;color:var(--slm-muted)}
|
|
91
|
+
.slm-live-dot{width:8px;height:8px;border-radius:50%;background:#8b94ac}
|
|
92
|
+
.slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);
|
|
93
|
+
animation:slm-pulse var(--slm-mo-ambient) infinite}
|
|
94
|
+
@keyframes slm-pulse{0%{box-shadow:0 0 0 0 rgba(34,160,107,.5)}70%{box-shadow:0 0 0 7px rgba(34,160,107,0)}100%{box-shadow:0 0 0 0 rgba(34,160,107,0)}}
|
|
95
|
+
.slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
|
|
96
|
+
border-top:1px solid var(--slm-line)}
|
|
97
|
+
.slm-kpi{position:relative;display:flex;min-width:0;flex-direction:column;align-items:center;padding:0 5px;line-height:1.15;text-align:center}
|
|
98
|
+
.slm-kpi b{display:flex;min-width:0;align-items:baseline;justify-content:center;font-size:17px;font-weight:800;
|
|
99
|
+
font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
100
|
+
.slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
|
|
101
|
+
.slm-kpi .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:baseline}
|
|
102
|
+
/* The one playful moment this surface is allowed (\xA72 --mo-spring). It ran at
|
|
103
|
+
.58s against a 200ms catalog, which read as a different design language from
|
|
104
|
+
the dashboard tile it mirrors; Channels mode's own count bump is the same
|
|
105
|
+
pattern and must stay in step with it. */
|
|
106
|
+
.slm-kpi.changed b{animation:slm-kpi-bump var(--slm-mo-base) var(--slm-mo-spring)}
|
|
107
|
+
.slm-kpidelta{position:absolute;right:4px;top:-12px;padding:2px 5px;border-radius:999px;background:rgba(34,160,107,.17);
|
|
108
|
+
color:#5bd39b!important;font-size:9px!important;letter-spacing:0!important;text-transform:none!important;white-space:nowrap;
|
|
109
|
+
animation:slm-kpi-delta 1.45s ease-out both;pointer-events:none}
|
|
110
|
+
.slm-kpidelta.down{background:rgba(244,183,64,.14);color:#f7ca6b!important}
|
|
111
|
+
@keyframes slm-kpi-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08);text-shadow:0 0 18px rgba(255,255,255,.24)}}
|
|
112
|
+
@keyframes slm-kpi-delta{0%{opacity:0;transform:translateY(5px)}18%,72%{opacity:1;transform:none}100%{opacity:0;transform:translateY(-5px)}}
|
|
113
|
+
.slm-barbtn{padding:7px 13px;border-radius:9px;border:1px solid var(--slm-line);color:var(--slm-text);font-weight:700;font-size:12.5px}
|
|
114
|
+
.slm-barbtn:hover{border-color:var(--slm-muted)}
|
|
115
|
+
.slm-barbtn.follow.on{background:rgba(34,160,107,.13);border-color:#22a06b;color:#5bd39b}
|
|
116
|
+
|
|
117
|
+
/* body */
|
|
118
|
+
.slm-body{display:flex;flex:1;min-height:0}
|
|
119
|
+
.slm-map{position:relative;flex:1;min-width:0}
|
|
120
|
+
.slm-map-host{position:absolute;inset:0}
|
|
121
|
+
.slm-hud{position:absolute;left:12px;bottom:12px;display:flex;gap:8px}
|
|
122
|
+
.slm-hud-chip{padding:6px 11px;border-radius:999px;font-size:12px;font-weight:700;background:var(--slm-surface);
|
|
123
|
+
border:1px solid var(--slm-line);color:var(--slm-text)}
|
|
124
|
+
.slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
|
|
125
|
+
background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;
|
|
126
|
+
transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
127
|
+
.slm-zoomhint.on{opacity:1}
|
|
128
|
+
.slm-liveevent{position:absolute;left:50%;top:14px;z-index:4;display:flex;align-items:center;gap:8px;max-width:min(560px,calc(100% - 32px));
|
|
129
|
+
padding:8px 12px;border:1px solid var(--slm-line);border-radius:999px;background:color-mix(in srgb,var(--slm-surface) 92%,transparent);
|
|
130
|
+
box-shadow:0 10px 34px rgba(0,0,0,.32);opacity:0;transform:translate(-50%,-8px);pointer-events:none;
|
|
131
|
+
transition:opacity var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out);
|
|
132
|
+
backdrop-filter:blur(10px)}
|
|
133
|
+
.slm-liveevent.on{opacity:1;transform:translate(-50%,0)}
|
|
134
|
+
.slm.block-mode .slm-liveevent{top:52px}
|
|
135
|
+
.slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
|
|
136
|
+
white-space:nowrap;font-size:12px;font-weight:800}.slm-liveeventhint{color:var(--slm-muted);font-size:10px;white-space:nowrap}
|
|
137
|
+
.slm-rail{width:320px;flex:none;border-left:1px solid var(--slm-line);display:flex;flex-direction:column;min-height:0}
|
|
138
|
+
.slm-railscroll{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:16px}
|
|
139
|
+
.slm-eyebrow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--slm-muted);font-weight:800;margin-bottom:6px}
|
|
140
|
+
.slm-hint{font-size:12.5px;color:var(--slm-muted);line-height:1.5;margin-bottom:14px}
|
|
141
|
+
|
|
142
|
+
/* legend rows */
|
|
143
|
+
.slm-legend{display:flex;flex-direction:column;gap:2px;margin-bottom:16px}
|
|
144
|
+
.slm-legrow{display:flex;align-items:center;gap:9px;padding:7px 2px;border-bottom:1px solid var(--slm-line)}
|
|
145
|
+
.slm-legdot{width:10px;height:10px;border-radius:50%;flex:none}
|
|
146
|
+
.slm-leglabel{flex:1;font-size:13px;font-weight:600}
|
|
147
|
+
.slm-legcount{font-size:13px;font-weight:800;font-variant-numeric:tabular-nums}
|
|
148
|
+
|
|
149
|
+
/* activity feed */
|
|
150
|
+
.slm-feed{display:flex;flex-direction:column;gap:0}
|
|
151
|
+
.slm-feedrow{display:flex!important;width:100%;align-items:center;gap:9px;padding:8px 2px!important;border-bottom:1px solid var(--slm-line)!important;
|
|
152
|
+
border-radius:6px;font-size:12.5px;text-align:left!important;
|
|
153
|
+
animation:slm-in var(--slm-mo-quick) var(--slm-mo-out)}
|
|
154
|
+
.slm-feedrow:hover{background:rgba(255,255,255,.035)!important}
|
|
155
|
+
@keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
|
|
156
|
+
.slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
|
|
157
|
+
.slm-feedtext{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
158
|
+
.slm-feedtext b{font-weight:800}
|
|
159
|
+
.slm-feedsection{display:block;overflow:hidden;text-overflow:ellipsis;color:var(--slm-muted);font-size:10px;font-weight:750}
|
|
160
|
+
.slm-feedmeta{display:flex;flex:none;flex-direction:column;align-items:flex-end;gap:1px}.slm-feedtime{font-size:10px;color:var(--slm-muted);font-variant-numeric:tabular-nums}
|
|
161
|
+
.slm-feedlocate{font-size:9.5px;color:var(--slm-accent);font-weight:800}
|
|
162
|
+
.slm-empty{font-size:12.5px;color:var(--slm-muted);padding:12px 0}
|
|
163
|
+
|
|
164
|
+
/* block toolbar */
|
|
165
|
+
.slm-selbar{display:flex;align-items:baseline;gap:8px;margin-bottom:10px}
|
|
166
|
+
.slm-selnum{font-size:26px;font-weight:800;font-variant-numeric:tabular-nums}
|
|
167
|
+
.slm-sellabel{font-size:12px;color:var(--slm-muted);font-weight:600}
|
|
168
|
+
.slm-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
|
169
|
+
.slm-btn{flex:1;min-width:120px;padding:10px 14px;border-radius:10px;background:var(--slm-accent);color:var(--slm-accent-ink);
|
|
170
|
+
font-weight:800;font-size:13px;text-align:center}
|
|
171
|
+
.slm-btn:disabled{opacity:.45;cursor:not-allowed}
|
|
172
|
+
.slm-btn.ghost{background:var(--slm-surface);border:1px solid var(--slm-line);color:var(--slm-text)}
|
|
173
|
+
.slm-btn.danger{background:#c0392b;color:#fff}
|
|
174
|
+
.slm-chiprow{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
|
|
175
|
+
.slm-chip{padding:6px 11px;border-radius:999px;border:1px solid var(--slm-line);background:var(--slm-surface);
|
|
176
|
+
font-size:12px;font-weight:700;color:var(--slm-text);display:inline-flex;align-items:center;gap:6px}
|
|
177
|
+
.slm-chip:hover{border-color:var(--slm-muted)}
|
|
178
|
+
.slm-chip .dot{width:8px;height:8px;border-radius:50%}
|
|
179
|
+
.slm-chip .slm-chipcount{min-width:18px;padding:1px 5px;border-radius:999px;background:rgba(255,255,255,.07);
|
|
180
|
+
color:var(--slm-muted);font-size:10px;font-variant-numeric:tabular-nums;text-align:center}
|
|
181
|
+
.slm-chip .slm-chipcheck{display:none;font-size:11px;line-height:1}
|
|
182
|
+
.slm-chip.on{border-color:var(--slm-accent);background:color-mix(in srgb,var(--slm-accent) 20%,var(--slm-surface));
|
|
183
|
+
box-shadow:0 0 0 1px color-mix(in srgb,var(--slm-accent) 45%,transparent)}
|
|
184
|
+
.slm-chip.on .slm-chipcount{background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
185
|
+
.slm-chip.on .slm-chipcheck{display:inline}
|
|
186
|
+
.slm-chip.partial{border-style:dashed;border-color:var(--slm-accent)}
|
|
187
|
+
.slm-chip:disabled{opacity:.42;cursor:not-allowed}
|
|
188
|
+
.slm-selecthelp{margin:-1px 0 9px;color:var(--slm-muted);font-size:11px;line-height:1.4}
|
|
189
|
+
.slm-field{margin:14px 0}
|
|
190
|
+
.slm-field label{display:block;font-size:11px;font-weight:700;color:var(--slm-muted);margin-bottom:5px}
|
|
191
|
+
.slm-input,.slm-select{width:100%;padding:8px 10px;border-radius:9px;border:1px solid var(--slm-line);
|
|
192
|
+
background:var(--slm-surface);color:var(--slm-text)}
|
|
193
|
+
.slm-note{font-size:11.5px;color:var(--slm-muted);margin-top:5px}
|
|
194
|
+
.slm-blocked{margin-top:17px;padding-top:15px;border-top:1px solid var(--slm-line)}
|
|
195
|
+
.slm-blockedhead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:8px}
|
|
196
|
+
.slm-blockedhead .slm-eyebrow{margin-bottom:0}.slm-blockedtotal{font-size:11px;color:var(--slm-muted)}
|
|
197
|
+
.slm-blockedtotal b{color:var(--slm-text);font-variant-numeric:tabular-nums}
|
|
198
|
+
.slm-blockedtools{display:grid;grid-template-columns:minmax(0,1fr);gap:7px}
|
|
199
|
+
.slm-blockedsummary{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:9px 0 6px;
|
|
200
|
+
color:var(--slm-muted);font-size:10.5px}
|
|
201
|
+
.slm-linkbtn{font-size:11px!important;font-weight:800!important;color:var(--slm-accent)!important;text-align:right}
|
|
202
|
+
.slm-linkbtn:disabled{opacity:.45;cursor:not-allowed}
|
|
203
|
+
.slm-blockedlist{max-height:246px;overflow:auto;overscroll-behavior:contain;border:1px solid var(--slm-line);
|
|
204
|
+
border-radius:10px;background:var(--slm-surface)}
|
|
205
|
+
.slm-blockeditem{display:grid!important;grid-template-columns:18px minmax(0,1fr);width:100%;gap:8px;padding:8px 9px!important;
|
|
206
|
+
border-bottom:1px solid var(--slm-line)!important;text-align:left!important}
|
|
207
|
+
.slm-blockeditem:last-child{border-bottom:0!important}.slm-blockeditem:hover{background:rgba(255,255,255,.035)!important}
|
|
208
|
+
.slm-blockeditem.on{background:color-mix(in srgb,var(--slm-accent) 13%,var(--slm-surface))!important}
|
|
209
|
+
.slm-blockedcheck{display:flex;align-items:center;justify-content:center;width:16px;height:16px;margin-top:1px;border-radius:4px;
|
|
210
|
+
border:1px solid var(--slm-muted);color:transparent;font-size:10px;font-weight:900}
|
|
211
|
+
.slm-blockeditem.on .slm-blockedcheck{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
212
|
+
.slm-blockedcopy{min-width:0}.slm-blockedlabel{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
213
|
+
font-size:12px;font-weight:800}.slm-blockedmeta{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
214
|
+
margin-top:2px;color:var(--slm-muted);font-size:10px}
|
|
215
|
+
.slm-blockedmore{width:100%;padding:9px!important;color:var(--slm-accent)!important;font-size:11px!important;font-weight:800!important}
|
|
216
|
+
.slm-blockedempty{padding:12px;color:var(--slm-muted);font-size:11.5px;line-height:1.45}
|
|
217
|
+
.slm-allnote{margin-top:-4px;margin-bottom:10px}
|
|
218
|
+
|
|
219
|
+
/* toast */
|
|
220
|
+
.slm-toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);padding:10px 16px;border-radius:10px;
|
|
221
|
+
font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;
|
|
222
|
+
transition:opacity var(--slm-mo-base) var(--slm-mo-out);
|
|
223
|
+
background:var(--slm-surface);color:var(--slm-text);border:1px solid var(--slm-line);z-index:5}
|
|
224
|
+
.slm-toast.on{opacity:1}
|
|
225
|
+
.slm-toast.err{background:#c0392b;color:#fff;border-color:#c0392b}
|
|
226
|
+
.slm-toast.ok{background:#1f7a4d;color:#fff;border-color:#1f7a4d}
|
|
227
|
+
|
|
228
|
+
/* control-room actions + insights */
|
|
229
|
+
.slm-bar-actions{display:flex;align-items:center;justify-self:end;gap:7px}
|
|
230
|
+
.slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
|
|
231
|
+
.slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
|
|
232
|
+
.slm-sectionlist + .slm-eyebrow{margin-top:18px}
|
|
233
|
+
.slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;
|
|
234
|
+
background:var(--slm-surface)!important;text-align:left!important;
|
|
235
|
+
transition:border-color var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-quick) var(--slm-mo-out)}
|
|
236
|
+
.slm-sectionrow:hover{border-color:var(--slm-muted)!important;transform:translateY(-1px)}
|
|
237
|
+
.slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
238
|
+
.slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
|
|
239
|
+
.slm-sectionmeta>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
240
|
+
.slm-trend{font-size:10px;text-transform:uppercase;letter-spacing:.08em}.slm-trend.rising{color:#22a06b}.slm-trend.cooling{color:#f4b740}
|
|
241
|
+
.slm-sectionlocate{color:var(--slm-accent);font-size:9.5px;font-weight:800}
|
|
242
|
+
.slm-health{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
|
|
243
|
+
.slm-healthitem{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
|
|
244
|
+
.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}
|
|
245
|
+
.slm-sectionhead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-top:18px}
|
|
246
|
+
.slm-windows{display:flex;gap:3px;padding:2px;border:1px solid var(--slm-line);border-radius:8px;background:var(--slm-surface)}
|
|
247
|
+
.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)}
|
|
248
|
+
.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)}
|
|
249
|
+
.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}
|
|
250
|
+
.slm-momentumgradient{height:6px;min-width:64px;flex:1;border-radius:999px;background:linear-gradient(90deg,#f4b740,#ef4444)}
|
|
251
|
+
.slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
|
|
252
|
+
/* sections: availability windows */
|
|
253
|
+
.slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
|
|
254
|
+
.slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
|
|
255
|
+
transition:border-color var(--slm-mo-quick) var(--slm-mo-out),opacity var(--slm-mo-quick) var(--slm-mo-out)}
|
|
256
|
+
.slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
|
|
257
|
+
.slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
|
|
258
|
+
.slm-availhead{display:flex;align-items:center;gap:8px}
|
|
259
|
+
.slm-availlabel{display:flex;align-items:center;gap:5px;flex:1;min-width:0;font-size:12.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
260
|
+
.slm-availcaret{flex:none;color:var(--slm-muted);font-size:10px}
|
|
261
|
+
.slm-availcount{flex:none;font-size:11px;font-weight:700;color:var(--slm-muted);font-variant-numeric:tabular-nums}
|
|
262
|
+
.slm-availbadge{flex:none;font-size:9px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:999px}
|
|
263
|
+
.slm-availbadge.hidden{background:rgba(139,148,172,.18);color:#c2c9d8}
|
|
264
|
+
.slm-availbadge.closed{background:rgba(244,183,64,.16);color:#f7ca6b}
|
|
265
|
+
.slm-availselwrap{position:relative;flex:none;display:inline-flex}
|
|
266
|
+
.slm-availmode{width:auto;max-width:190px;padding:6px 8px;font-size:11.5px;font-weight:700;cursor:pointer}
|
|
267
|
+
.slm-availmode.on{border-color:var(--slm-accent);color:var(--slm-text)}
|
|
268
|
+
.slm-availmode:disabled{opacity:.55;cursor:progress}
|
|
269
|
+
.slm-availfollows{flex:none;padding:5px 10px;border:1px solid var(--slm-line);border-radius:7px;background:var(--slm-surface);color:var(--slm-muted);font-size:11px;font-weight:600;white-space:nowrap}
|
|
270
|
+
.slm-availdetail{display:flex;align-items:center;gap:8px;margin-top:9px}
|
|
271
|
+
.slm-availdetail .slm-input{flex:1}
|
|
272
|
+
.slm-availpct{max-width:74px;flex:none!important}
|
|
273
|
+
.slm-availpctlabel{font-size:11px;color:var(--slm-muted);font-weight:600;white-space:nowrap}
|
|
274
|
+
.slm-availsummary{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--slm-line);border-radius:9px;color:var(--slm-muted);font-size:12.5px}
|
|
275
|
+
.slm-availdot{width:9px;height:9px;border-radius:50%;flex:none;background:#22a06b}.slm-availdot.warn{background:#f4b740}
|
|
276
|
+
.slm-availcallout{display:flex;align-items:flex-start;gap:8px;margin-top:10px;padding:10px 12px;border:1px solid rgba(244,183,64,.45);border-radius:9px;background:rgba(244,183,64,.1)}
|
|
277
|
+
.slm-availstar{flex:none;margin-top:1px;color:#f4b740;font-size:13px;line-height:1}
|
|
278
|
+
.slm-availcallout p{font-size:11.5px;line-height:1.55;color:#f4d58a}.slm-availcallout b{color:#ffe4a3;font-weight:800}
|
|
279
|
+
.slm-inspect-card{padding:16px;border:1px solid var(--slm-line);border-radius:12px;background:var(--slm-surface)}
|
|
280
|
+
.slm-inspect-label{font-size:24px;font-weight:850;letter-spacing:-.02em;line-height:1.1}
|
|
281
|
+
.slm-inspect-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px 20px;margin-top:18px}
|
|
282
|
+
.slm-inspect-grid>div{min-width:0}.slm-inspect-grid span{display:block;color:var(--slm-muted);font-size:10px;
|
|
283
|
+
text-transform:uppercase;letter-spacing:.08em}.slm-inspect-grid b{display:block;margin-top:4px;font-size:13px;line-height:1.35;overflow-wrap:anywhere}
|
|
284
|
+
.slm:fullscreen{border-radius:0;min-height:100vh;background:var(--slm-bg)}
|
|
285
|
+
.slm:fullscreen .slm-bar{padding:14px 22px}.slm:fullscreen .slm-kpi b{font-size:21px}.slm:fullscreen .slm-rail{width:360px}
|
|
286
|
+
|
|
287
|
+
.slm.compact .slm-rail{width:100%;border-left:0;border-top:1px solid var(--slm-line);height:44%}
|
|
288
|
+
.slm.compact .slm-body{flex-direction:column}
|
|
289
|
+
.slm.compact .slm-bar{grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:8px}
|
|
290
|
+
.slm.compact .slm-modes{min-width:0}.slm.compact .slm-mode{padding-inline:11px}
|
|
291
|
+
.slm.compact .slm-live{justify-self:end}.slm.compact .slm-bar-actions{grid-column:1/-1;justify-self:stretch}
|
|
292
|
+
.slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
|
293
|
+
.slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
|
|
294
|
+
.slm.compact .slm-kpi[data-kpi="sold-pct"],.slm.compact .slm-kpi[data-kpi="gross-sales"]{display:none}
|
|
295
|
+
/* Reduced motion, as a BLANKET over the cockpit subtree rather than a list of
|
|
296
|
+
selectors. The list this replaces named four animations and two transitions,
|
|
297
|
+
and had silently fallen behind the stylesheet: the zoom hint, the toast and
|
|
298
|
+
the availability rows all still animated for a user who had asked the OS for
|
|
299
|
+
none. An enumerated list has to be edited every time a rule is added, and
|
|
300
|
+
nothing fails when it isn't \u2014 so it drifts. This cannot.
|
|
301
|
+
|
|
302
|
+
Motion is removed, never the information it carried: Channels mode's own
|
|
303
|
+
block substitutes static outlines for its shake and success states, and it
|
|
304
|
+
stays authoritative for those. No JS here waits on animationend or
|
|
305
|
+
transitionend, so cutting them outright strands no state. */
|
|
306
|
+
@media (prefers-reduced-motion:reduce){
|
|
307
|
+
.slm,.slm *,.slm *::before,.slm *::after{
|
|
308
|
+
animation:none!important;
|
|
309
|
+
transition:none!important;
|
|
310
|
+
scroll-behavior:auto!important}
|
|
311
|
+
}
|
|
312
|
+
`
|
|
313
|
+
);
|
|
314
|
+
function injectStyle() {
|
|
315
|
+
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
|
|
316
|
+
const el = document.createElement("style");
|
|
317
|
+
el.id = STYLE_ID;
|
|
318
|
+
el.textContent = MANAGER_CSS;
|
|
319
|
+
document.head.appendChild(el);
|
|
320
|
+
}
|
|
321
|
+
function injectChannelsStyle(css) {
|
|
322
|
+
if (typeof document === "undefined" || document.getElementById(CHANNELS_STYLE_ID)) return;
|
|
323
|
+
const el = document.createElement("style");
|
|
324
|
+
el.id = CHANNELS_STYLE_ID;
|
|
325
|
+
el.textContent = css;
|
|
326
|
+
document.head.appendChild(el);
|
|
327
|
+
}
|
|
328
|
+
function themeVars(theme) {
|
|
329
|
+
const t = theme ?? {};
|
|
330
|
+
return {
|
|
331
|
+
"--slm-bg": t.background ?? "#0e1017",
|
|
332
|
+
"--slm-surface": "#181b24",
|
|
333
|
+
"--slm-text": "#eef1f7",
|
|
334
|
+
"--slm-muted": "#8b93a7",
|
|
335
|
+
"--slm-line": "rgba(255,255,255,.09)",
|
|
336
|
+
"--slm-accent": t.accent ?? "#6e7bff",
|
|
337
|
+
"--slm-accent-ink": t.accentInk ?? "#ffffff",
|
|
338
|
+
"--slm-font": "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif",
|
|
339
|
+
"--slm-radius": "14px"
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function relTime(at, now) {
|
|
343
|
+
const s = Math.max(0, Math.round((now - at) / 1e3));
|
|
344
|
+
if (s < 5) return "just now";
|
|
345
|
+
if (s < 60) return `${s}s ago`;
|
|
346
|
+
const m = Math.round(s / 60);
|
|
347
|
+
if (m < 60) return `${m}m ago`;
|
|
348
|
+
return `${Math.round(m / 60)}h ago`;
|
|
349
|
+
}
|
|
350
|
+
function fmtMoney(amount, currency) {
|
|
351
|
+
try {
|
|
352
|
+
return new Intl.NumberFormat(void 0, { style: "currency", currency, maximumFractionDigits: 0 }).format(amount);
|
|
353
|
+
} catch {
|
|
354
|
+
return `${currency} ${Math.round(amount).toLocaleString()}`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function esc(value) {
|
|
358
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
359
|
+
}
|
|
360
|
+
var SeatManager = class {
|
|
361
|
+
constructor(options) {
|
|
362
|
+
this.els = {};
|
|
363
|
+
this.renderer = null;
|
|
364
|
+
this.doc = null;
|
|
365
|
+
// label ⇄ id + status truth (backend speaks labels, engine speaks ids).
|
|
366
|
+
this.labelToId = /* @__PURE__ */ new Map();
|
|
367
|
+
this.labelToSeat = /* @__PURE__ */ new Map();
|
|
368
|
+
this.allIds = [];
|
|
369
|
+
/**
|
|
370
|
+
* GA inventory units — real sellable labels the server counts, with NO seat
|
|
371
|
+
* geometry and therefore no renderer binding. They live here rather than in
|
|
372
|
+
* `labelToId`/`allIds` so every paint path keeps addressing paintable nodes
|
|
373
|
+
* only, while the tally denominator finally covers the same universe the
|
|
374
|
+
* numerator does. Without them a GA sale hit `booked` but not `total`:
|
|
375
|
+
* Free under-reported by GA capacity and SOLD% could exceed 100%.
|
|
376
|
+
*/
|
|
377
|
+
this.gaUnitLabelSet = /* @__PURE__ */ new Set();
|
|
378
|
+
this.status = /* @__PURE__ */ new Map();
|
|
379
|
+
/** Live non-free counters, moved by each delta rather than re-walked. */
|
|
380
|
+
this.counts = { held: 0, booked: 0, blocked: 0 };
|
|
381
|
+
/** Bumped whenever the seat model is replaced wholesale (a full snapshot). */
|
|
382
|
+
this.modelVersion = 0;
|
|
383
|
+
this.currency = "USD";
|
|
384
|
+
this.authoritativeGrossRevenue = 0;
|
|
385
|
+
this.revenueStatus = "loading";
|
|
386
|
+
this.revenueRequest = 0;
|
|
387
|
+
this.controlRoomSnapshot = null;
|
|
388
|
+
/**
|
|
389
|
+
* The server's own totals, pinned to the client model they were read against.
|
|
390
|
+
* Display = server baseline + (client now − client then), so the authoritative
|
|
391
|
+
* numbers land exactly on arrival and deltas still move them between reads.
|
|
392
|
+
* A wholesale model replacement invalidates the pairing (`model`), and the
|
|
393
|
+
* client tallies — themselves a fresh authenticated read — take over.
|
|
394
|
+
*/
|
|
395
|
+
this.serverBaseline = null;
|
|
396
|
+
/** Latest presence frame, held whether or not a snapshot has landed yet. */
|
|
397
|
+
this.livePresence = null;
|
|
398
|
+
/** Latest cumulative booked gross pushed on a delta frame. */
|
|
399
|
+
this.liveGross = null;
|
|
400
|
+
/** Coalesces a burst of deltas into one KPI/rail repaint. */
|
|
401
|
+
this.paintHandle = null;
|
|
402
|
+
this.trendWindowMinutes = 15;
|
|
403
|
+
this.heatEnabled = false;
|
|
404
|
+
this.lastKpiValues = /* @__PURE__ */ new Map();
|
|
405
|
+
this.activeKpiDeltas = /* @__PURE__ */ new Map();
|
|
406
|
+
// realtime socket
|
|
407
|
+
this.ws = null;
|
|
408
|
+
this.reconnectTimer = null;
|
|
409
|
+
this.attempt = 0;
|
|
410
|
+
this.closed = false;
|
|
411
|
+
/** Mirrors the `live` root class, so the getter never has to read the DOM. */
|
|
412
|
+
this.connectionStatus = "reconnecting";
|
|
413
|
+
/** When the server last told us something. Stamped on accepted traffic only —
|
|
414
|
+
* a socket that opens and says nothing has not refreshed anything. */
|
|
415
|
+
this.lastMessageAt = null;
|
|
416
|
+
this.ready = false;
|
|
417
|
+
this.feed = [];
|
|
418
|
+
this.feedTimer = null;
|
|
419
|
+
this.toastTimer = null;
|
|
420
|
+
this.liveEventTimer = null;
|
|
421
|
+
this.kpiCleanupTimer = null;
|
|
422
|
+
this.followLiveTimer = null;
|
|
423
|
+
this.followSeatTimer = null;
|
|
424
|
+
this.releaseAt = null;
|
|
425
|
+
this.layoutObserver = null;
|
|
426
|
+
this.tokenExpiresAt = null;
|
|
427
|
+
this.tokenRefreshTimer = null;
|
|
428
|
+
this.tokenRefreshInFlight = false;
|
|
429
|
+
this.sectionByObject = /* @__PURE__ */ new Map();
|
|
430
|
+
this.sectionLabelById = /* @__PURE__ */ new Map();
|
|
431
|
+
this.sectionsBase = null;
|
|
432
|
+
// Sections mode (availability windows): organizer rules + the live effective
|
|
433
|
+
// hidden/closed sets from the snapshot + WS (a timed/threshold rule fires DO-side).
|
|
434
|
+
this.availabilityRules = {};
|
|
435
|
+
this.effectiveHidden = /* @__PURE__ */ new Set();
|
|
436
|
+
this.effectiveClosed = /* @__PURE__ */ new Set();
|
|
437
|
+
this.availabilitySaving = false;
|
|
438
|
+
this.lastSyncedAt = null;
|
|
439
|
+
this.blockedQuery = "";
|
|
440
|
+
this.blockedSection = "";
|
|
441
|
+
this.blockedResultLimit = 100;
|
|
442
|
+
this.unblockAllConfirmTimer = null;
|
|
443
|
+
/**
|
|
444
|
+
* Sales channels (M6b).
|
|
445
|
+
*
|
|
446
|
+
* Two fields, and the split between them is the whole point. `channelCaps` is
|
|
447
|
+
* AUTHORITY and is known early — it is read from the token's declared
|
|
448
|
+
* capabilities before the first rail paint, so the Channels pill either
|
|
449
|
+
* exists from the start or never appears. `channels` is the loaded sub-app,
|
|
450
|
+
* and it now arrives late: its module is fetched on first entry into Channels
|
|
451
|
+
* mode (`ensureChannels`), not at mount.
|
|
452
|
+
*
|
|
453
|
+
* So every gate that used to ask `!!this.channels` — the pill, the mode
|
|
454
|
+
* whitelist, the `c` shortcut — asks `channelCaps.view` instead. Asking the
|
|
455
|
+
* instance would make a permission the member genuinely has look like one
|
|
456
|
+
* they do not for as long as a network fetch takes.
|
|
457
|
+
*/
|
|
458
|
+
this.channels = null;
|
|
459
|
+
this.channelCaps = { view: false, manage: false };
|
|
460
|
+
/** In-flight `import('./channelsMode')`, so concurrent entries load once. */
|
|
461
|
+
this.channelsLoading = null;
|
|
462
|
+
this.onFullscreenChange = () => {
|
|
463
|
+
this.paintFullscreenButton();
|
|
464
|
+
this.updateContainerLayout();
|
|
465
|
+
this.renderer?.forceDraw();
|
|
466
|
+
};
|
|
467
|
+
this.onKeyDown = (event) => {
|
|
468
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
469
|
+
const target = event.target;
|
|
470
|
+
if (target?.matches('input,select,textarea,[contenteditable="true"]')) return;
|
|
471
|
+
const key = event.key.toLowerCase();
|
|
472
|
+
if (key === "m") this.setMode("view");
|
|
473
|
+
else if (key === "i") this.setMode("inspect");
|
|
474
|
+
else if (key === "b") this.setMode("block");
|
|
475
|
+
else if (key === "s") this.setMode("sections");
|
|
476
|
+
else if (key === "c") {
|
|
477
|
+
if (!this.channelCaps.view) return;
|
|
478
|
+
this.setMode("channels");
|
|
479
|
+
} else if (key === "f") this.toggleFullscreen();
|
|
480
|
+
else if (key === "escape") {
|
|
481
|
+
if (!this.channels?.handleBack()) return;
|
|
482
|
+
} else return;
|
|
483
|
+
event.preventDefault();
|
|
484
|
+
};
|
|
485
|
+
this.onRailClick = (event) => {
|
|
486
|
+
const target = event.target;
|
|
487
|
+
const sectionButton = target?.closest("[data-section-focus]");
|
|
488
|
+
if (sectionButton?.dataset.sectionFocus) {
|
|
489
|
+
this.locateSection(sectionButton.dataset.sectionFocus);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const feedButton = target?.closest("[data-feed-id]");
|
|
493
|
+
if (feedButton?.dataset.feedId) this.locateActivity(feedButton.dataset.feedId);
|
|
494
|
+
};
|
|
495
|
+
this.sectionOptions = [];
|
|
496
|
+
this.opts = options;
|
|
497
|
+
this.key = options.eventKey;
|
|
498
|
+
this.mode = options.mode ?? "view";
|
|
499
|
+
this.keepLive = options.keepLiveWhileHidden ?? true;
|
|
500
|
+
this.followLive = options.followLive ?? false;
|
|
501
|
+
this.currency = options.currency ?? "USD";
|
|
502
|
+
this.tokenExpiresAt = options.tokenExpiresAt ?? null;
|
|
503
|
+
this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE, options.token);
|
|
504
|
+
this.host = resolveContainer(options.container);
|
|
505
|
+
}
|
|
506
|
+
/** Build the DOM, load the chart, subscribe to realtime, mount the board. */
|
|
507
|
+
async render() {
|
|
508
|
+
injectStyle();
|
|
509
|
+
this.buildChrome();
|
|
510
|
+
try {
|
|
511
|
+
const res = await this.api.chart(this.key);
|
|
512
|
+
this.doc = res.doc;
|
|
513
|
+
this.currency = res.event.currency ?? this.opts.currency ?? this.currency;
|
|
514
|
+
this.buildUnitUniverse(res.doc);
|
|
515
|
+
this.buildRenderer();
|
|
516
|
+
this.buildSectionOptions();
|
|
517
|
+
const [, controlRoom] = await Promise.all([
|
|
518
|
+
this.resnapshot(),
|
|
519
|
+
this.refreshControlRoom().catch((err) => this.opts.onError?.(err)),
|
|
520
|
+
this.refreshAvailability()
|
|
521
|
+
]);
|
|
522
|
+
if (controlRoom?.activity) this.seedFeed(controlRoom.activity);
|
|
523
|
+
else this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
|
|
524
|
+
});
|
|
525
|
+
void this.connect();
|
|
526
|
+
this.startFeedClock();
|
|
527
|
+
this.ready = true;
|
|
528
|
+
await this.resolveChannelCapabilities();
|
|
529
|
+
this.setMode(this.mode);
|
|
530
|
+
this.scheduleTokenRefresh();
|
|
531
|
+
this.opts.onReady?.();
|
|
532
|
+
} catch (err) {
|
|
533
|
+
this.fail(err);
|
|
534
|
+
}
|
|
535
|
+
return this;
|
|
536
|
+
}
|
|
537
|
+
// ---- public API -----------------------------------------------------------
|
|
538
|
+
setMode(mode) {
|
|
539
|
+
if (mode === "channels" && !this.channelCaps.view) mode = "view";
|
|
540
|
+
if (mode === "channels" && !this.channels) void this.ensureChannels();
|
|
541
|
+
const changed = mode !== this.mode;
|
|
542
|
+
const wasChannels = this.mode === "channels";
|
|
543
|
+
this.mode = mode;
|
|
544
|
+
if (!this.renderer && this.doc) this.buildRenderer();
|
|
545
|
+
else this.updateRendererInteraction();
|
|
546
|
+
if (changed) this.renderer?.clearSelection();
|
|
547
|
+
if (wasChannels && mode !== "channels") this.channels?.leave();
|
|
548
|
+
this.paintModeTabs();
|
|
549
|
+
this.paintRail();
|
|
550
|
+
this.applySectionCanvasTreatment();
|
|
551
|
+
if (mode === "channels") this.channels?.enter();
|
|
552
|
+
if (changed) this.opts.onModeChange?.(mode);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Decide what this token may do with sales channels.
|
|
556
|
+
*
|
|
557
|
+
* Declared capabilities win — a host that mints an `mse_…` grant knows exactly
|
|
558
|
+
* what it asked for. Otherwise a tenant secret (`sk_…`) is org authority the
|
|
559
|
+
* worker never narrows, so it is fully capable; and a delegated token with no
|
|
560
|
+
* declaration is probed for read access and then treated as READ-ONLY, because
|
|
561
|
+
* "we could not tell" must never render mutation controls.
|
|
562
|
+
*/
|
|
563
|
+
async resolveChannelCapabilities() {
|
|
564
|
+
const declared = this.opts.capabilities;
|
|
565
|
+
if (declared) {
|
|
566
|
+
const set = new Set(declared);
|
|
567
|
+
this.channelCaps = {
|
|
568
|
+
view: set.has("event:channels:view"),
|
|
569
|
+
manage: set.has("event:channels:view") && set.has("event:channels:manage")
|
|
570
|
+
};
|
|
571
|
+
} else if (/^sk_/.test(this.opts.token)) {
|
|
572
|
+
this.channelCaps = { view: true, manage: true };
|
|
573
|
+
} else {
|
|
574
|
+
this.channelCaps = { view: false, manage: false };
|
|
575
|
+
}
|
|
576
|
+
if (!this.channelCaps.view && !declared && !/^sk_/.test(this.opts.token)) {
|
|
577
|
+
try {
|
|
578
|
+
await this.api.channels(this.key);
|
|
579
|
+
this.channelCaps = { view: true, manage: false };
|
|
580
|
+
} catch {
|
|
581
|
+
this.channelCaps = { view: false, manage: false };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (!this.channelCaps.view) {
|
|
585
|
+
this.channels?.destroy();
|
|
586
|
+
this.channels = null;
|
|
587
|
+
this.paintModeTabs();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
this.channels?.setCapabilities(this.channelCaps);
|
|
591
|
+
this.paintModeTabs();
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Load Channels mode, once, on first entry.
|
|
595
|
+
*
|
|
596
|
+
* Everything about this method is shaped by one rule: the cockpit must stay
|
|
597
|
+
* usable and honest while the module is in the air.
|
|
598
|
+
*
|
|
599
|
+
* - The promise is memoized, so a member who taps the pill twice, or a host
|
|
600
|
+
* whose deep link and initial `mode` prop both ask for Channels, loads one
|
|
601
|
+
* module and builds one instance.
|
|
602
|
+
* - Authority is re-checked on arrival. A token rotation can revoke
|
|
603
|
+
* `event:channels:view` between the tap and the load, and building the
|
|
604
|
+
* sub-app for a token that no longer carries the capability would put
|
|
605
|
+
* mutation controls on screen that every server call then refuses.
|
|
606
|
+
* - The mode is re-checked too. Someone who taps Channels and then Monitor
|
|
607
|
+
* before the chunk lands must not be yanked into Channels when it does; the
|
|
608
|
+
* instance is kept (it is paid for) but only entered if we are still there.
|
|
609
|
+
* - A failed load is stated in the rail rather than swallowed. Channels is a
|
|
610
|
+
* whole surface — silently showing an empty one would read as "this event
|
|
611
|
+
* has no channels", which is a lie about inventory.
|
|
612
|
+
*/
|
|
613
|
+
ensureChannels() {
|
|
614
|
+
if (this.channelsLoading) return this.channelsLoading;
|
|
615
|
+
if (this.channels) return Promise.resolve();
|
|
616
|
+
const load = (async () => {
|
|
617
|
+
try {
|
|
618
|
+
const mod = await import("./channelsMode-33ZLORVF.js");
|
|
619
|
+
if (this.closed) return;
|
|
620
|
+
if (!this.channelCaps.view) return;
|
|
621
|
+
if (!this.channels) {
|
|
622
|
+
injectChannelsStyle(mod.CHANNELS_CSS);
|
|
623
|
+
this.channels = new mod.ChannelsMode(this.buildChannelsHost(), this.channelCaps);
|
|
624
|
+
this.channels.onInteractionChange = () => this.updateRendererInteraction();
|
|
625
|
+
}
|
|
626
|
+
if (this.mode !== "channels") return;
|
|
627
|
+
this.updateRendererInteraction();
|
|
628
|
+
this.paintRail();
|
|
629
|
+
this.channels.enter();
|
|
630
|
+
} catch (err) {
|
|
631
|
+
if (this.closed) return;
|
|
632
|
+
this.channelsLoading = null;
|
|
633
|
+
if (this.mode === "channels") this.paintRail();
|
|
634
|
+
this.opts.onError?.(err);
|
|
635
|
+
}
|
|
636
|
+
})();
|
|
637
|
+
this.channelsLoading = load;
|
|
638
|
+
return load;
|
|
639
|
+
}
|
|
640
|
+
/** The adapter between the cockpit's internals and Channels mode. */
|
|
641
|
+
buildChannelsHost() {
|
|
642
|
+
return {
|
|
643
|
+
eventKey: this.key,
|
|
644
|
+
api: this.api,
|
|
645
|
+
rail: this.els.rail,
|
|
646
|
+
mapLayer: this.root.querySelector(".slm-map"),
|
|
647
|
+
root: this.root,
|
|
648
|
+
seats: () => [...this.labelToSeat.values()].map((seat) => ({
|
|
649
|
+
id: seat.id,
|
|
650
|
+
label: seat.label,
|
|
651
|
+
x: seat.x,
|
|
652
|
+
y: seat.y
|
|
653
|
+
})),
|
|
654
|
+
statusOf: (label) => this.status.get(label) ?? (this.labelToSeat.has(label) ? "free" : void 0),
|
|
655
|
+
selectionLabels: () => this.selectionLabels(),
|
|
656
|
+
selectByLabels: (labels) => {
|
|
657
|
+
this.selectByLabels(labels);
|
|
658
|
+
},
|
|
659
|
+
clearSelection: () => this.clearSelection(),
|
|
660
|
+
selectSection: (sectionId) => {
|
|
661
|
+
this.selectSection(sectionId);
|
|
662
|
+
},
|
|
663
|
+
sections: () => this.sectionOptions,
|
|
664
|
+
labelsInSection: (sectionId) => this.renderer?.getSelectableInSection(sectionId).map((seat) => seat.label) ?? [],
|
|
665
|
+
// Logical rows, not physical row objects: a segmented row is authored as
|
|
666
|
+
// several objects sharing one `logicalRowId`, and the organizer thinks of
|
|
667
|
+
// it as one row. Tables carry seats too, so they are rows here as well.
|
|
668
|
+
rows: () => {
|
|
669
|
+
const objects = new Map((this.doc?.objects ?? []).map((object) => [object.id, object]));
|
|
670
|
+
const rows = /* @__PURE__ */ new Map();
|
|
671
|
+
for (const seat of this.labelToSeat.values()) {
|
|
672
|
+
const object = objects.get(seat.rowId);
|
|
673
|
+
if (!object || object.type !== "row" && object.type !== "table") continue;
|
|
674
|
+
const id = seat.logicalRowId ?? seat.rowId;
|
|
675
|
+
const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
|
|
676
|
+
const row = rows.get(id) ?? {
|
|
677
|
+
id,
|
|
678
|
+
label: object.type === "row" ? object.segmentedRow?.displayLabel ?? object.displayLabel ?? object.label : object.displayLabel ?? object.label,
|
|
679
|
+
sectionId,
|
|
680
|
+
sectionLabel: this.sectionLabelById.get(sectionId) ?? "Other seats",
|
|
681
|
+
labels: []
|
|
682
|
+
};
|
|
683
|
+
row.labels.push(seat.label);
|
|
684
|
+
rows.set(id, row);
|
|
685
|
+
}
|
|
686
|
+
return [...rows.values()].sort((left, right) => left.sectionLabel.localeCompare(right.sectionLabel, void 0, { numeric: true, sensitivity: "base" }) || left.label.localeCompare(right.label, void 0, { numeric: true, sensitivity: "base" }));
|
|
687
|
+
},
|
|
688
|
+
categories: () => (this.doc?.categories ?? []).map((category) => ({
|
|
689
|
+
key: category.key,
|
|
690
|
+
label: category.label ?? category.key,
|
|
691
|
+
color: category.color
|
|
692
|
+
})),
|
|
693
|
+
labelsInCategory: (key) => [...this.labelToSeat.entries()].filter(([, seat]) => seat.categoryKey === key).map(([label]) => label),
|
|
694
|
+
sectionOfLabel: (label) => {
|
|
695
|
+
const seat = this.labelToSeat.get(label);
|
|
696
|
+
if (!seat) return null;
|
|
697
|
+
const id = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
|
|
698
|
+
return { id, label: this.sectionLabelById.get(id) ?? "Other seats" };
|
|
699
|
+
},
|
|
700
|
+
worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
|
|
701
|
+
seatPixelSize: () => this.seatPixelSize(),
|
|
702
|
+
isSeatDetail: () => this.renderer?.getRung?.() === "seats",
|
|
703
|
+
showSectionOverview: () => {
|
|
704
|
+
this.renderer?.clearSectionFocus();
|
|
705
|
+
this.renderer?.setRung?.("sections");
|
|
706
|
+
},
|
|
707
|
+
focusSection: (sectionId) => this.renderer?.focusSection(sectionId),
|
|
708
|
+
isCompact: () => !!this.root?.classList.contains("compact"),
|
|
709
|
+
setMapInert: (inert) => {
|
|
710
|
+
this.mapHost.toggleAttribute("inert", inert);
|
|
711
|
+
this.mapHost.setAttribute("aria-hidden", String(inert));
|
|
712
|
+
},
|
|
713
|
+
toast: (message, kind) => this.toast(message, kind),
|
|
714
|
+
onError: (err) => this.opts.onError?.(err)
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
/** Actual on-screen seat diameter, for the channel overlay's marks. The
|
|
718
|
+
* renderer's base seat radius is 9 chart units; retaining the camera scale
|
|
719
|
+
* (rather than capping it) keeps every preview paint aligned with the real
|
|
720
|
+
* chart geometry at deep zoom. */
|
|
721
|
+
seatPixelSize() {
|
|
722
|
+
const rect = this.renderer?.getVisibleWorldRect?.();
|
|
723
|
+
const width = this.mapHost?.clientWidth ?? 0;
|
|
724
|
+
if (!rect?.width || !width) return 6;
|
|
725
|
+
return Math.max(3, width / rect.width * 18);
|
|
726
|
+
}
|
|
727
|
+
/** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
|
|
728
|
+
setHeatOverlay(enabled) {
|
|
729
|
+
this.heatEnabled = enabled;
|
|
730
|
+
this.applyHeatOverlay();
|
|
731
|
+
this.paintHeatButton();
|
|
732
|
+
}
|
|
733
|
+
/** Toggle opt-in camera following for new buyer hold/book events. */
|
|
734
|
+
setFollowLive(enabled) {
|
|
735
|
+
const changed = this.followLive !== enabled;
|
|
736
|
+
this.followLive = enabled;
|
|
737
|
+
if (!enabled) {
|
|
738
|
+
if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
|
|
739
|
+
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
740
|
+
this.followLiveTimer = null;
|
|
741
|
+
this.followSeatTimer = null;
|
|
742
|
+
}
|
|
743
|
+
this.paintFollowLiveButton();
|
|
744
|
+
if (changed) this.opts.onFollowLiveChange?.(enabled);
|
|
745
|
+
}
|
|
746
|
+
/** Change the current-vs-previous sales window and refresh the private projection. */
|
|
747
|
+
setTrendWindow(windowMinutes) {
|
|
748
|
+
const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
|
|
749
|
+
this.trendWindowMinutes = Math.max(5, Math.min(60, normalized));
|
|
750
|
+
this.paintTrendWindow();
|
|
751
|
+
return this.refreshControlRoom();
|
|
752
|
+
}
|
|
753
|
+
async enterFullscreen() {
|
|
754
|
+
if (!this.root?.requestFullscreen || this.isFullscreen()) return;
|
|
755
|
+
await this.root.requestFullscreen();
|
|
756
|
+
this.root.focus({ preventScroll: true });
|
|
757
|
+
}
|
|
758
|
+
async exitFullscreen() {
|
|
759
|
+
if (typeof document === "undefined" || !this.isFullscreen()) return;
|
|
760
|
+
await document.exitFullscreen();
|
|
761
|
+
}
|
|
762
|
+
isFullscreen() {
|
|
763
|
+
return typeof document !== "undefined" && document.fullscreenElement === this.root;
|
|
764
|
+
}
|
|
765
|
+
toggleFullscreen() {
|
|
766
|
+
const request = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
|
|
767
|
+
void request.catch((err) => this.opts.onError?.(err));
|
|
768
|
+
}
|
|
769
|
+
/** Rotate the delegated credential without rebuilding DOM, canvas or socket. */
|
|
770
|
+
setToken(token, expiresAt) {
|
|
771
|
+
this.api.setToken(token);
|
|
772
|
+
this.tokenExpiresAt = expiresAt ?? null;
|
|
773
|
+
this.scheduleTokenRefresh();
|
|
774
|
+
if (this.ready) void this.resolveChannelCapabilities();
|
|
775
|
+
}
|
|
776
|
+
scheduleTokenRefresh() {
|
|
777
|
+
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
778
|
+
this.tokenRefreshTimer = null;
|
|
779
|
+
const refresh = this.opts.onTokenRefresh;
|
|
780
|
+
const expiresAt = this.tokenExpiresAt;
|
|
781
|
+
if (this.closed || !refresh || !expiresAt || !Number.isFinite(expiresAt)) return;
|
|
782
|
+
const remaining = expiresAt - Date.now();
|
|
783
|
+
const lead = Math.min(12e4, Math.max(3e4, remaining * 0.2));
|
|
784
|
+
const delay = Math.max(0, remaining - lead);
|
|
785
|
+
this.tokenRefreshTimer = setTimeout(() => {
|
|
786
|
+
this.tokenRefreshTimer = null;
|
|
787
|
+
void this.rotateToken();
|
|
788
|
+
}, delay);
|
|
789
|
+
}
|
|
790
|
+
async rotateToken() {
|
|
791
|
+
if (this.closed || this.tokenRefreshInFlight || !this.opts.onTokenRefresh) return;
|
|
792
|
+
this.tokenRefreshInFlight = true;
|
|
793
|
+
try {
|
|
794
|
+
const next = await this.opts.onTokenRefresh();
|
|
795
|
+
if (!next?.token || !Number.isFinite(next.expiresAt)) throw new Error("invalid_token_refresh_result");
|
|
796
|
+
this.setToken(next.token, next.expiresAt);
|
|
797
|
+
} catch (err) {
|
|
798
|
+
this.opts.onError?.(err);
|
|
799
|
+
if (!this.closed) {
|
|
800
|
+
this.tokenRefreshTimer = setTimeout(() => {
|
|
801
|
+
this.tokenRefreshTimer = null;
|
|
802
|
+
void this.rotateToken();
|
|
803
|
+
}, 3e4);
|
|
804
|
+
}
|
|
805
|
+
} finally {
|
|
806
|
+
this.tokenRefreshInFlight = false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/** Bulk block the given labels (or the current selection when omitted). */
|
|
810
|
+
async block(labels, opts = {}) {
|
|
811
|
+
const targets = (labels ?? this.selectionLabels()).filter((l) => this.status.get(l) === "free");
|
|
812
|
+
if (!targets.length) return;
|
|
813
|
+
const releaseAt = opts.releaseAt ?? this.releaseAt ?? void 0;
|
|
814
|
+
this.setSeatsLocal(targets, "blocked");
|
|
815
|
+
try {
|
|
816
|
+
await this.api.block(this.key, targets, { ...opts, releaseAt });
|
|
817
|
+
this.clearSelection();
|
|
818
|
+
this.done("block", targets, releaseAt ? `Blocked ${targets.length} \u2014 auto-release ${new Date(releaseAt).toLocaleString()}.` : `Blocked ${targets.length} seat${targets.length === 1 ? "" : "s"}.`);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
this.setSeatsLocal(targets, "free");
|
|
821
|
+
this.toastErr(err instanceof ManageApiError && err.status === 409 ? "Some seats were just taken. Try again." : "Couldn't block those seats.");
|
|
822
|
+
this.opts.onError?.(err);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
async unblock(labels) {
|
|
826
|
+
const targets = (labels ?? this.selectionLabels()).filter((l) => this.status.get(l) === "blocked");
|
|
827
|
+
if (!targets.length) return;
|
|
828
|
+
this.setSeatsLocal(targets, "free");
|
|
829
|
+
try {
|
|
830
|
+
await this.api.unblock(this.key, targets);
|
|
831
|
+
this.clearSelection();
|
|
832
|
+
this.done("unblock", targets, `Unblocked ${targets.length} seat${targets.length === 1 ? "" : "s"}.`);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
this.setSeatsLocal(targets, "blocked");
|
|
835
|
+
this.toastErr("Couldn't unblock those seats.");
|
|
836
|
+
this.opts.onError?.(err);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async unblockAll() {
|
|
840
|
+
const blocked = [...this.status.entries()].filter(([, s]) => s === "blocked").map(([l]) => l);
|
|
841
|
+
if (!blocked.length) return;
|
|
842
|
+
this.setSeatsLocal(blocked, "free");
|
|
843
|
+
try {
|
|
844
|
+
const res = await this.api.unblockAll(this.key);
|
|
845
|
+
this.clearSelection();
|
|
846
|
+
this.done("unblockAll", blocked, `Unblocked ${res.freed} seat${res.freed === 1 ? "" : "s"}.`);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
await this.resnapshot();
|
|
849
|
+
this.toastErr("Couldn't mark everything for sale.");
|
|
850
|
+
this.opts.onError?.(err);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/** Cancel bookings (BOOKED → free), guarded by the original booking ref. */
|
|
854
|
+
async cancelBooking(labels, bookingRef) {
|
|
855
|
+
const targets = labels.filter((l) => this.status.get(l) === "booked");
|
|
856
|
+
if (!targets.length || !bookingRef) return;
|
|
857
|
+
this.setSeatsLocal(targets, "free");
|
|
858
|
+
try {
|
|
859
|
+
await this.api.unbook(this.key, targets, bookingRef);
|
|
860
|
+
this.clearSelection();
|
|
861
|
+
this.done("cancelBooking", targets, `Cancelled ${targets.length} booking${targets.length === 1 ? "" : "s"}.`);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
this.setSeatsLocal(targets, "booked");
|
|
864
|
+
this.toastErr("Couldn't cancel that booking. Check the reference.");
|
|
865
|
+
this.opts.onError?.(err);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
selectAll() {
|
|
869
|
+
const seats = this.renderer?.selectAllSelectable() ?? [];
|
|
870
|
+
this.syncSelection();
|
|
871
|
+
return seats;
|
|
872
|
+
}
|
|
873
|
+
selectSection(sectionId) {
|
|
874
|
+
if (!this.renderer) return [];
|
|
875
|
+
const seats = this.renderer.getSelectableInSection(sectionId);
|
|
876
|
+
this.renderer.selectByLabels(seats.map((s) => s.label));
|
|
877
|
+
this.syncSelection();
|
|
878
|
+
return this.renderer.getSelection();
|
|
879
|
+
}
|
|
880
|
+
selectByLabels(labels) {
|
|
881
|
+
const seats = this.renderer?.selectByLabels(labels) ?? [];
|
|
882
|
+
this.syncSelection();
|
|
883
|
+
return seats;
|
|
884
|
+
}
|
|
885
|
+
clearSelection() {
|
|
886
|
+
this.renderer?.clearSelection();
|
|
887
|
+
this.syncSelection();
|
|
888
|
+
}
|
|
889
|
+
getSelection() {
|
|
890
|
+
return this.renderer?.getSelection() ?? [];
|
|
891
|
+
}
|
|
892
|
+
getReport() {
|
|
893
|
+
return this.api.report(this.key).then((report) => {
|
|
894
|
+
this.applyReportRevenue(report);
|
|
895
|
+
return report;
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
|
|
899
|
+
return this.setTrendWindow(windowMinutes);
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* The realtime link's current state and the "as of" behind it.
|
|
903
|
+
*
|
|
904
|
+
* Pair with `onConnectionChange` for the edges: a host that mounts after a
|
|
905
|
+
* drop, or re-reads on tab focus, needs to be able to ASK rather than wait
|
|
906
|
+
* for the next transition that may never come.
|
|
907
|
+
*/
|
|
908
|
+
getConnection() {
|
|
909
|
+
return { status: this.connectionStatus, lastMessageAt: this.lastMessageAt };
|
|
910
|
+
}
|
|
911
|
+
getLog(opts = {}) {
|
|
912
|
+
return this.api.log(this.key, opts);
|
|
913
|
+
}
|
|
914
|
+
async setHoldTtl(ms) {
|
|
915
|
+
try {
|
|
916
|
+
await this.api.setHoldTtl(this.key, ms);
|
|
917
|
+
this.done("setHoldTtl", [], ms ? `Checkout window set to ${Math.round(ms / 6e4)} min.` : "Checkout window reset.");
|
|
918
|
+
} catch (err) {
|
|
919
|
+
this.toastErr("Couldn't update the checkout window.");
|
|
920
|
+
this.opts.onError?.(err);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/** M2 — box-office booking from free seats. Stubbed (route is session-only today). */
|
|
924
|
+
boxBook(_labels, _bookingRef) {
|
|
925
|
+
this.toastErr("Box office ships in a later milestone.");
|
|
926
|
+
return Promise.resolve();
|
|
927
|
+
}
|
|
928
|
+
zoomToFit() {
|
|
929
|
+
this.renderer?.clearSectionFocus();
|
|
930
|
+
this.renderer?.zoomToFit();
|
|
931
|
+
}
|
|
932
|
+
destroy() {
|
|
933
|
+
this.closed = true;
|
|
934
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
935
|
+
if (this.feedTimer) clearInterval(this.feedTimer);
|
|
936
|
+
if (this.toastTimer) clearTimeout(this.toastTimer);
|
|
937
|
+
if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
|
|
938
|
+
if (this.kpiCleanupTimer) clearTimeout(this.kpiCleanupTimer);
|
|
939
|
+
if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
|
|
940
|
+
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
941
|
+
if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
|
|
942
|
+
if (this.paintHandle !== null && typeof cancelAnimationFrame === "function") {
|
|
943
|
+
cancelAnimationFrame(this.paintHandle);
|
|
944
|
+
}
|
|
945
|
+
this.paintHandle = null;
|
|
946
|
+
this.channels?.destroy();
|
|
947
|
+
this.channels = null;
|
|
948
|
+
this.channelsLoading = null;
|
|
949
|
+
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
950
|
+
this.layoutObserver?.disconnect();
|
|
951
|
+
this.layoutObserver = null;
|
|
952
|
+
this.root?.removeEventListener("keydown", this.onKeyDown);
|
|
953
|
+
this.els.rail?.removeEventListener("click", this.onRailClick);
|
|
954
|
+
if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
|
|
955
|
+
if (this.ws) {
|
|
956
|
+
try {
|
|
957
|
+
this.ws.close();
|
|
958
|
+
} catch {
|
|
959
|
+
}
|
|
960
|
+
this.ws = null;
|
|
961
|
+
}
|
|
962
|
+
this.renderer?.destroy();
|
|
963
|
+
this.renderer = null;
|
|
964
|
+
if (this.root && this.root.parentNode === this.host) this.host.removeChild(this.root);
|
|
965
|
+
}
|
|
966
|
+
// ---- renderer lifecycle ---------------------------------------------------
|
|
967
|
+
buildRenderer() {
|
|
968
|
+
if (!this.doc) return;
|
|
969
|
+
const bulk = this.isBulkSelectMode();
|
|
970
|
+
this.renderer = new SeatmapRenderer(this.mapHost, {
|
|
971
|
+
manageMode: true,
|
|
972
|
+
marqueeSelect: bulk,
|
|
973
|
+
maxSelection: 1e6,
|
|
974
|
+
selectableStatuses: this.selectableStatuses(),
|
|
975
|
+
currency: this.currency,
|
|
976
|
+
onSelect: (seat) => this.handleSeatSelect(seat),
|
|
977
|
+
onDeselect: () => this.syncSelection(),
|
|
978
|
+
onMarquee: () => this.syncSelection(),
|
|
979
|
+
onSectionTap: (sectionId) => {
|
|
980
|
+
this.renderer?.focusSection(sectionId);
|
|
981
|
+
this.channels?.handleSectionFocus(sectionId);
|
|
982
|
+
},
|
|
983
|
+
onViewChange: () => {
|
|
984
|
+
this.updateZoomHint();
|
|
985
|
+
this.channels?.handleViewChange();
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
this.renderer.setChart(this.doc);
|
|
989
|
+
this.repaintAll();
|
|
990
|
+
this.applyHeatOverlay();
|
|
991
|
+
this.updateZoomHint();
|
|
992
|
+
}
|
|
993
|
+
/** Block always uses a marquee. Channels only enables its marquee after the
|
|
994
|
+
* organizer deliberately chooses Assign seats; Pan map keeps desktop drag
|
|
995
|
+
* available for large charts. */
|
|
996
|
+
isBulkSelectMode() {
|
|
997
|
+
return this.mode === "block" || this.mode === "channels" && this.channels?.usesMarqueeSelection() === true;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Block never touches held or booked inventory, so it cannot select it.
|
|
1001
|
+
* Channels must be able to select it — the Review sheet's honesty depends on
|
|
1002
|
+
* counting the held and sold units inside a marquee and saying they will not
|
|
1003
|
+
* move, rather than silently omitting them from the selection.
|
|
1004
|
+
*/
|
|
1005
|
+
selectableStatuses() {
|
|
1006
|
+
if (this.mode === "block") return ["free", "not_for_sale"];
|
|
1007
|
+
if (this.mode === "inspect" || this.isBulkSelectMode() || this.mode === "channels" && this.channels?.canSelect() === true) {
|
|
1008
|
+
return ["free", "held", "booked", "not_for_sale"];
|
|
1009
|
+
}
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
updateRendererInteraction() {
|
|
1013
|
+
const bulk = this.isBulkSelectMode();
|
|
1014
|
+
this.renderer?.setManageInteraction({
|
|
1015
|
+
manageMode: true,
|
|
1016
|
+
marqueeSelect: bulk,
|
|
1017
|
+
maxSelection: 1e6,
|
|
1018
|
+
selectableStatuses: this.selectableStatuses()
|
|
1019
|
+
});
|
|
1020
|
+
this.updateZoomHint();
|
|
1021
|
+
}
|
|
1022
|
+
handleSeatSelect(seat) {
|
|
1023
|
+
if (this.mode === "inspect") {
|
|
1024
|
+
const others = this.getSelection().filter((selected) => selected.id !== seat.id).map((selected) => selected.id);
|
|
1025
|
+
if (others.length) this.renderer?.deselect(others);
|
|
1026
|
+
}
|
|
1027
|
+
this.syncSelection();
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Build the client's inventory universe from the chart.
|
|
1031
|
+
*
|
|
1032
|
+
* `expandChart` yields SEATS — it has no output for a GA area, whose capacity
|
|
1033
|
+
* is sold as N synthetic unit labels. The server's seat map keys, its deltas
|
|
1034
|
+
* and its `totals` all speak those labels, so a client that only knows seats
|
|
1035
|
+
* counts GA sales in the numerator (every key of the snapshot is written into
|
|
1036
|
+
* `status`) while leaving them out of the denominator. Registering the GA
|
|
1037
|
+
* units here — labels only, never a render binding — is what makes the two
|
|
1038
|
+
* agree.
|
|
1039
|
+
*/
|
|
1040
|
+
buildUnitUniverse(doc) {
|
|
1041
|
+
for (const seat of expandChart(doc)) {
|
|
1042
|
+
this.labelToId.set(seat.label, seat.id);
|
|
1043
|
+
this.labelToSeat.set(seat.label, seat);
|
|
1044
|
+
this.allIds.push(seat.id);
|
|
1045
|
+
}
|
|
1046
|
+
for (const area of gaAreasOf(doc)) {
|
|
1047
|
+
for (const label of gaUnitLabels(area)) {
|
|
1048
|
+
if (!this.labelToId.has(label)) this.gaUnitLabelSet.add(label);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
/** Every sellable unit the client knows: seats + GA capacity. */
|
|
1053
|
+
unitTotal() {
|
|
1054
|
+
return this.allIds.length + this.gaUnitLabelSet.size;
|
|
1055
|
+
}
|
|
1056
|
+
/** Every label the client models, whether or not it can be painted. */
|
|
1057
|
+
knownLabels() {
|
|
1058
|
+
return [...this.labelToId.keys(), ...this.gaUnitLabelSet];
|
|
1059
|
+
}
|
|
1060
|
+
repaintAll() {
|
|
1061
|
+
const r = this.renderer;
|
|
1062
|
+
if (!r) return;
|
|
1063
|
+
if (this.allIds.length) r.setStatus(this.allIds, "free");
|
|
1064
|
+
const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
|
|
1065
|
+
for (const [label, st] of this.status.entries()) {
|
|
1066
|
+
const id = this.labelToId.get(label);
|
|
1067
|
+
if (id) byStatus[toRenderStatus(st)].push(id);
|
|
1068
|
+
}
|
|
1069
|
+
["held", "booked", "not_for_sale"].forEach((st) => {
|
|
1070
|
+
if (byStatus[st].length) r.setStatus(byStatus[st], st);
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
// ---- realtime -------------------------------------------------------------
|
|
1074
|
+
/**
|
|
1075
|
+
* Open the cockpit's realtime socket AS THE ORGANIZER.
|
|
1076
|
+
*
|
|
1077
|
+
* The scope has to be established before the upgrade, because a browser
|
|
1078
|
+
* `WebSocket` cannot send an Authorization header: the manage token is traded
|
|
1079
|
+
* over HTTPS for a one-use ticket which rides in `Sec-WebSocket-Protocol`.
|
|
1080
|
+
* Without it the server treats this socket as an anonymous public buyer and
|
|
1081
|
+
* projects its deltas, so any change inside a private channel allocation is
|
|
1082
|
+
* structurally suppressed and the map silently drifts.
|
|
1083
|
+
*
|
|
1084
|
+
* If the mint fails (an expired token, a worker that predates the route) we
|
|
1085
|
+
* still connect unticketed rather than going dark — the public-sale stream is
|
|
1086
|
+
* worth having, and every `resnapshot()` re-establishes physical truth from
|
|
1087
|
+
* the authenticated HTTP read.
|
|
1088
|
+
*/
|
|
1089
|
+
async connect() {
|
|
1090
|
+
if (this.closed) return;
|
|
1091
|
+
let protocols;
|
|
1092
|
+
try {
|
|
1093
|
+
protocols = (await this.api.subscribeTicket(this.key)).protocols;
|
|
1094
|
+
} catch {
|
|
1095
|
+
protocols = void 0;
|
|
1096
|
+
}
|
|
1097
|
+
if (this.closed) return;
|
|
1098
|
+
let ws;
|
|
1099
|
+
try {
|
|
1100
|
+
ws = protocols ? new WebSocket(this.api.socketUrl(this.key), protocols) : new WebSocket(this.api.socketUrl(this.key));
|
|
1101
|
+
} catch {
|
|
1102
|
+
this.scheduleReconnect();
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
this.ws = ws;
|
|
1106
|
+
ws.onopen = () => {
|
|
1107
|
+
this.attempt = 0;
|
|
1108
|
+
this.setLive(true);
|
|
1109
|
+
void this.resnapshot().then(() => this.refreshControlRoom()).catch((err) => this.opts.onError?.(err));
|
|
1110
|
+
void this.refreshAvailability();
|
|
1111
|
+
};
|
|
1112
|
+
ws.onmessage = (e) => this.onMessage(e);
|
|
1113
|
+
ws.onclose = () => {
|
|
1114
|
+
if (this.ws === ws) this.ws = null;
|
|
1115
|
+
this.setLive(false);
|
|
1116
|
+
this.scheduleReconnect();
|
|
1117
|
+
};
|
|
1118
|
+
ws.onerror = () => {
|
|
1119
|
+
try {
|
|
1120
|
+
ws.close();
|
|
1121
|
+
} catch {
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
scheduleReconnect() {
|
|
1126
|
+
if (this.closed || this.reconnectTimer) return;
|
|
1127
|
+
const delay = Math.min(1e3 * 2 ** Math.min(this.attempt++, 5), 15e3);
|
|
1128
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1129
|
+
this.reconnectTimer = null;
|
|
1130
|
+
void this.connect();
|
|
1131
|
+
}, delay);
|
|
1132
|
+
}
|
|
1133
|
+
onMessage(e) {
|
|
1134
|
+
let msg;
|
|
1135
|
+
try {
|
|
1136
|
+
msg = JSON.parse(typeof e.data === "string" ? e.data : "");
|
|
1137
|
+
} catch {
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if (!msg || typeof msg !== "object") return;
|
|
1141
|
+
this.lastMessageAt = Date.now();
|
|
1142
|
+
const m = msg;
|
|
1143
|
+
if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
|
|
1144
|
+
this.updateEffectiveAvailability(m.hidden, m.closed);
|
|
1145
|
+
}
|
|
1146
|
+
if (m.type === "presence") {
|
|
1147
|
+
if (typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
|
|
1148
|
+
this.livePresence = {
|
|
1149
|
+
at: Date.now(),
|
|
1150
|
+
value: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
|
|
1151
|
+
};
|
|
1152
|
+
if (this.controlRoomSnapshot) {
|
|
1153
|
+
this.controlRoomSnapshot = { ...this.controlRoomSnapshot, presence: this.livePresence.value };
|
|
1154
|
+
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
1155
|
+
}
|
|
1156
|
+
this.lastSyncedAt = Date.now();
|
|
1157
|
+
this.recomputeTallies();
|
|
1158
|
+
this.paintMonitorInsights();
|
|
1159
|
+
}
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (m.type === "hidden") return;
|
|
1163
|
+
if (m.seats && typeof m.seats === "object") {
|
|
1164
|
+
this.applySnapshot(m.seats, typeof m.default === "string" ? m.default : void 0);
|
|
1165
|
+
} else if (Array.isArray(m.changes)) {
|
|
1166
|
+
const ids = [];
|
|
1167
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1168
|
+
for (const ch of m.changes) {
|
|
1169
|
+
const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
|
|
1170
|
+
const prev = this.status.get(ch.label) ?? "free";
|
|
1171
|
+
if (prev === st) continue;
|
|
1172
|
+
this.setStatusLabel(ch.label, st, prev);
|
|
1173
|
+
const id = this.labelToId.get(ch.label);
|
|
1174
|
+
if (id) {
|
|
1175
|
+
this.renderer?.setStatus([id], toRenderStatus(st));
|
|
1176
|
+
ids.push(id);
|
|
1177
|
+
}
|
|
1178
|
+
const verb = this.verbFor(prev, st);
|
|
1179
|
+
const groupKey = `${verb}:${st}`;
|
|
1180
|
+
const group = groups.get(groupKey) ?? { labels: [], verb, status: st };
|
|
1181
|
+
group.labels.push(ch.label);
|
|
1182
|
+
groups.set(groupKey, group);
|
|
1183
|
+
}
|
|
1184
|
+
for (const group of groups.values()) {
|
|
1185
|
+
const activity = this.pushActivity(group.labels, group.verb, group.status);
|
|
1186
|
+
if (activity) this.paintSpatialActivity(activity);
|
|
1187
|
+
}
|
|
1188
|
+
if (ids.length) {
|
|
1189
|
+
this.lastSyncedAt = Date.now();
|
|
1190
|
+
this.afterPaint();
|
|
1191
|
+
}
|
|
1192
|
+
if (typeof m.revenue?.gross === "number" && Number.isFinite(m.revenue.gross)) {
|
|
1193
|
+
this.applyLiveGross(m.revenue.gross);
|
|
1194
|
+
}
|
|
1195
|
+
this.recomputeTallies();
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Adopt the cumulative booked gross a delta frame carried.
|
|
1200
|
+
*
|
|
1201
|
+
* Stashed with its arrival time so an in-flight control-room read can decide
|
|
1202
|
+
* whether it is holding the newer number: a frame that landed after the
|
|
1203
|
+
* request started is newer than the response, one that landed before is not.
|
|
1204
|
+
*/
|
|
1205
|
+
applyLiveGross(gross) {
|
|
1206
|
+
this.liveGross = { at: Date.now(), value: gross };
|
|
1207
|
+
this.authoritativeGrossRevenue = gross;
|
|
1208
|
+
this.revenueStatus = "current";
|
|
1209
|
+
if (this.controlRoomSnapshot) {
|
|
1210
|
+
this.controlRoomSnapshot = {
|
|
1211
|
+
...this.controlRoomSnapshot,
|
|
1212
|
+
revenue: { ...this.controlRoomSnapshot.revenue, gross }
|
|
1213
|
+
};
|
|
1214
|
+
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
/** The single writer for a label's status, so the counters never drift. */
|
|
1218
|
+
setStatusLabel(label, next, prev = this.status.get(label) ?? "free") {
|
|
1219
|
+
this.status.set(label, next);
|
|
1220
|
+
if (prev === next) return;
|
|
1221
|
+
if (prev !== "free") this.counts[prev] -= 1;
|
|
1222
|
+
if (next !== "free") this.counts[next] += 1;
|
|
1223
|
+
}
|
|
1224
|
+
async resnapshot() {
|
|
1225
|
+
try {
|
|
1226
|
+
const objs = await this.api.objects(this.key);
|
|
1227
|
+
this.applySnapshot(objs.seats);
|
|
1228
|
+
this.updateEffectiveAvailability(objs.hidden, objs.closed);
|
|
1229
|
+
this.lastMessageAt = Date.now();
|
|
1230
|
+
} catch {
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Replace the whole seat model.
|
|
1235
|
+
*
|
|
1236
|
+
* `fallback` is the compact frame's modal status: those snapshots list only
|
|
1237
|
+
* the seats that DIFFER from it, so every other known label takes it. Without
|
|
1238
|
+
* this the omitted majority would silently fall back to `free` — fine when
|
|
1239
|
+
* the mode really is free, wrong the moment it is not.
|
|
1240
|
+
*/
|
|
1241
|
+
applySnapshot(seats, fallback) {
|
|
1242
|
+
const known = (st) => ["free", "held", "booked", "blocked"].includes(st) ? st : "free";
|
|
1243
|
+
const next = /* @__PURE__ */ new Map();
|
|
1244
|
+
if (fallback !== void 0) {
|
|
1245
|
+
const base = known(fallback);
|
|
1246
|
+
for (const label of this.knownLabels()) next.set(label, base);
|
|
1247
|
+
}
|
|
1248
|
+
for (const [label, st] of Object.entries(seats)) {
|
|
1249
|
+
next.set(label, known(st));
|
|
1250
|
+
}
|
|
1251
|
+
this.status = next;
|
|
1252
|
+
this.modelVersion += 1;
|
|
1253
|
+
this.recountAll();
|
|
1254
|
+
this.lastSyncedAt = Date.now();
|
|
1255
|
+
this.repaintAll();
|
|
1256
|
+
this.afterPaint();
|
|
1257
|
+
this.recomputeTallies();
|
|
1258
|
+
}
|
|
1259
|
+
/** The one O(n) walk left: a wholesale model replacement re-bases the counters. */
|
|
1260
|
+
recountAll() {
|
|
1261
|
+
const counts = { held: 0, booked: 0, blocked: 0 };
|
|
1262
|
+
for (const st of this.status.values()) if (st !== "free") counts[st] += 1;
|
|
1263
|
+
this.counts = counts;
|
|
1264
|
+
}
|
|
1265
|
+
/** Optimistic local write shared by organizer actions. Paint and tally once,
|
|
1266
|
+
* even when an arena-sized operation changes hundreds of seats. */
|
|
1267
|
+
setSeatsLocal(labels, st) {
|
|
1268
|
+
const ids = [];
|
|
1269
|
+
for (const label of labels) {
|
|
1270
|
+
this.setStatusLabel(label, st);
|
|
1271
|
+
const id = this.labelToId.get(label);
|
|
1272
|
+
if (id) ids.push(id);
|
|
1273
|
+
}
|
|
1274
|
+
if (ids.length) this.renderer?.setStatus(ids, toRenderStatus(st));
|
|
1275
|
+
this.afterPaint();
|
|
1276
|
+
this.recomputeTallies();
|
|
1277
|
+
}
|
|
1278
|
+
/** Keep the canvas painting on hidden/occluded tabs (war-room second monitor). */
|
|
1279
|
+
afterPaint() {
|
|
1280
|
+
if (this.keepLive && typeof document !== "undefined" && document.hidden) {
|
|
1281
|
+
this.renderer?.forceDraw();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
activityColor(status) {
|
|
1285
|
+
return status === "held" ? "#f4b740" : status === "booked" ? "#22a06b" : status === "blocked" ? "#8b94ac" : "#6e7bff";
|
|
1286
|
+
}
|
|
1287
|
+
sectionsForLabels(labels) {
|
|
1288
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1289
|
+
for (const label of labels) {
|
|
1290
|
+
const seat = this.labelToSeat.get(label);
|
|
1291
|
+
if (!seat) continue;
|
|
1292
|
+
const sectionId = this.sectionByObject.get(seat.rowId);
|
|
1293
|
+
if (sectionId && sectionId !== UNGROUPED_ID) ids.add(sectionId);
|
|
1294
|
+
}
|
|
1295
|
+
const sectionIds = [...ids];
|
|
1296
|
+
return {
|
|
1297
|
+
ids: sectionIds,
|
|
1298
|
+
labels: sectionIds.map((id) => this.sectionLabelById.get(id) ?? id)
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
pulseSeatLabels(labels, status) {
|
|
1302
|
+
const color = this.activityColor(status);
|
|
1303
|
+
for (const label of labels.slice(0, MAX_LIVE_SEAT_PULSES)) {
|
|
1304
|
+
const id = this.labelToId.get(label);
|
|
1305
|
+
if (id) this.renderer?.flashSeat(id, color);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/** Render one grouped realtime operation at the right semantic zoom level. */
|
|
1309
|
+
paintSpatialActivity(activity) {
|
|
1310
|
+
const sectionIds = activity.sectionIds ?? this.sectionsForLabels(activity.labels).ids;
|
|
1311
|
+
const focused = this.renderer?.getFocusedSection() ?? null;
|
|
1312
|
+
const followable = this.followLive && sectionIds.length === 1 && (activity.status === "held" || activity.status === "booked");
|
|
1313
|
+
if (followable && focused === sectionIds[0]) {
|
|
1314
|
+
this.pulseSeatLabels(activity.labels, activity.status);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (followable) {
|
|
1318
|
+
if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
|
|
1319
|
+
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
1320
|
+
this.followLiveTimer = setTimeout(() => {
|
|
1321
|
+
this.followLiveTimer = null;
|
|
1322
|
+
this.renderer?.focusSection(sectionIds[0]);
|
|
1323
|
+
this.followSeatTimer = setTimeout(() => {
|
|
1324
|
+
this.followSeatTimer = null;
|
|
1325
|
+
this.pulseSeatLabels(activity.labels, activity.status);
|
|
1326
|
+
}, 520);
|
|
1327
|
+
}, 220);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (!focused && sectionIds.length) {
|
|
1331
|
+
const color = this.activityColor(activity.status);
|
|
1332
|
+
for (const sectionId of sectionIds.slice(0, MAX_LIVE_SECTION_PULSES)) {
|
|
1333
|
+
this.renderer?.flashSection(sectionId, color);
|
|
1334
|
+
}
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
if (!sectionIds.length || focused && sectionIds.includes(focused)) {
|
|
1338
|
+
this.pulseSeatLabels(activity.labels, activity.status);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
locateSection(sectionId) {
|
|
1342
|
+
this.renderer?.focusSection(sectionId);
|
|
1343
|
+
}
|
|
1344
|
+
locateActivity(activityId) {
|
|
1345
|
+
const activity = this.feed.find((item) => item.id === activityId);
|
|
1346
|
+
if (!activity) return;
|
|
1347
|
+
const sectionIds = activity.sectionIds ?? this.sectionsForLabels(activity.labels).ids;
|
|
1348
|
+
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
1349
|
+
if (sectionIds.length === 1) {
|
|
1350
|
+
this.locateSection(sectionIds[0]);
|
|
1351
|
+
this.followSeatTimer = setTimeout(() => {
|
|
1352
|
+
this.followSeatTimer = null;
|
|
1353
|
+
this.pulseSeatLabels(activity.labels, activity.status);
|
|
1354
|
+
}, 520);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
this.zoomToFit();
|
|
1358
|
+
this.followSeatTimer = setTimeout(() => {
|
|
1359
|
+
this.followSeatTimer = null;
|
|
1360
|
+
if (sectionIds.length) {
|
|
1361
|
+
const color = this.activityColor(activity.status);
|
|
1362
|
+
for (const sectionId of sectionIds.slice(0, MAX_LIVE_SECTION_PULSES)) {
|
|
1363
|
+
this.renderer?.flashSection(sectionId, color);
|
|
1364
|
+
}
|
|
1365
|
+
} else {
|
|
1366
|
+
this.pulseSeatLabels(activity.labels, activity.status);
|
|
1367
|
+
}
|
|
1368
|
+
}, 280);
|
|
1369
|
+
}
|
|
1370
|
+
showLiveEvent(activity) {
|
|
1371
|
+
const element = this.els.liveevent;
|
|
1372
|
+
if (!element) return;
|
|
1373
|
+
const sections = activity.sectionLabels ?? [];
|
|
1374
|
+
const place = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : activity.label;
|
|
1375
|
+
const noun = activity.count === 1 ? "seat" : "seats";
|
|
1376
|
+
element.innerHTML = `<span class="slm-liveeventdot" style="background:${this.activityColor(activity.status)}"></span>
|
|
1377
|
+
<span class="slm-liveeventcopy">${esc(place)} \xB7 ${activity.count.toLocaleString()} ${noun} ${esc(activity.verb)}</span>
|
|
1378
|
+
<span class="slm-liveeventhint">Live</span>`;
|
|
1379
|
+
element.classList.add("on");
|
|
1380
|
+
if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
|
|
1381
|
+
this.liveEventTimer = setTimeout(() => {
|
|
1382
|
+
this.liveEventTimer = null;
|
|
1383
|
+
element.classList.remove("on");
|
|
1384
|
+
element.innerHTML = "";
|
|
1385
|
+
}, 2800);
|
|
1386
|
+
}
|
|
1387
|
+
// ---- tallies + feed -------------------------------------------------------
|
|
1388
|
+
applyReportRevenue(report) {
|
|
1389
|
+
this.authoritativeGrossRevenue = report.report.byCategory.reduce(
|
|
1390
|
+
(sum, row) => sum + (Number.isFinite(row.bookedRevenue) ? row.bookedRevenue : 0),
|
|
1391
|
+
0
|
|
1392
|
+
);
|
|
1393
|
+
this.revenueStatus = "current";
|
|
1394
|
+
this.recomputeTallies();
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Read the server's own control-room projection.
|
|
1398
|
+
*
|
|
1399
|
+
* Called on mount, on every socket (re)connect and after an organizer action —
|
|
1400
|
+
* never on a timer and never per delta frame. Presence and gross that arrived
|
|
1401
|
+
* on the socket AFTER this request started are newer than the response, so
|
|
1402
|
+
* they survive it; anything older defers to the read.
|
|
1403
|
+
*/
|
|
1404
|
+
async refreshControlRoom() {
|
|
1405
|
+
const request = ++this.revenueRequest;
|
|
1406
|
+
const requestedAt = Date.now();
|
|
1407
|
+
try {
|
|
1408
|
+
const fetched = await this.api.controlRoom(this.key, this.trendWindowMinutes);
|
|
1409
|
+
let snapshot = fetched;
|
|
1410
|
+
if (request === this.revenueRequest) {
|
|
1411
|
+
if (this.livePresence && this.livePresence.at >= requestedAt) {
|
|
1412
|
+
snapshot = { ...snapshot, presence: this.livePresence.value };
|
|
1413
|
+
} else {
|
|
1414
|
+
this.livePresence = null;
|
|
1415
|
+
}
|
|
1416
|
+
if (this.liveGross && this.liveGross.at >= requestedAt) {
|
|
1417
|
+
snapshot = { ...snapshot, revenue: { ...snapshot.revenue, gross: this.liveGross.value } };
|
|
1418
|
+
} else {
|
|
1419
|
+
this.liveGross = null;
|
|
1420
|
+
}
|
|
1421
|
+
this.controlRoomSnapshot = snapshot;
|
|
1422
|
+
this.rebaseServerTotals(snapshot);
|
|
1423
|
+
this.lastSyncedAt = Date.now();
|
|
1424
|
+
this.authoritativeGrossRevenue = snapshot.revenue.gross;
|
|
1425
|
+
this.currency = snapshot.currency;
|
|
1426
|
+
this.revenueStatus = "current";
|
|
1427
|
+
this.recomputeTallies();
|
|
1428
|
+
this.applyHeatOverlay();
|
|
1429
|
+
this.paintMonitorInsights();
|
|
1430
|
+
this.opts.onControlRoom?.(snapshot);
|
|
1431
|
+
}
|
|
1432
|
+
return snapshot;
|
|
1433
|
+
} catch (err) {
|
|
1434
|
+
if (request === this.revenueRequest) {
|
|
1435
|
+
this.revenueStatus = "stale";
|
|
1436
|
+
this.recomputeTallies();
|
|
1437
|
+
}
|
|
1438
|
+
throw err;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/** Pin the server's totals to the client model they were read against. */
|
|
1442
|
+
rebaseServerTotals(snapshot) {
|
|
1443
|
+
const totals = snapshot.totals;
|
|
1444
|
+
if (!totals || ["free", "held", "booked", "blocked"].some(
|
|
1445
|
+
(key) => !Number.isFinite(totals[key])
|
|
1446
|
+
)) {
|
|
1447
|
+
this.serverBaseline = null;
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
this.serverBaseline = {
|
|
1451
|
+
model: this.modelVersion,
|
|
1452
|
+
server: { free: totals.free, held: totals.held, booked: totals.booked, blocked: totals.blocked },
|
|
1453
|
+
client: this.clientTallies()
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
/** What the client's own model says — GA units included since `render()`. */
|
|
1457
|
+
clientTallies() {
|
|
1458
|
+
const { held, booked, blocked } = this.counts;
|
|
1459
|
+
return { held, booked, blocked, free: Math.max(0, this.unitTotal() - held - booked - blocked) };
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* The numbers the KPI bar and rail render.
|
|
1463
|
+
*
|
|
1464
|
+
* The server is the authority: its totals land exactly as read, and the
|
|
1465
|
+
* delta-driven client model carries them forward until the next read. Before
|
|
1466
|
+
* the first snapshot — and after a wholesale model replacement invalidates the
|
|
1467
|
+
* pairing — the client model stands alone.
|
|
1468
|
+
*/
|
|
1469
|
+
buildTallies() {
|
|
1470
|
+
const client = this.clientTallies();
|
|
1471
|
+
const baseline = this.serverBaseline?.model === this.modelVersion ? this.serverBaseline : null;
|
|
1472
|
+
const of = (key) => baseline ? Math.max(0, baseline.server[key] + (client[key] - baseline.client[key])) : client[key];
|
|
1473
|
+
const seatTotal = this.controlRoomSnapshot?.event?.seatTotal;
|
|
1474
|
+
const t = {
|
|
1475
|
+
free: of("free"),
|
|
1476
|
+
held: of("held"),
|
|
1477
|
+
booked: of("booked"),
|
|
1478
|
+
blocked: of("blocked"),
|
|
1479
|
+
total: Number.isFinite(seatTotal) ? seatTotal : this.unitTotal(),
|
|
1480
|
+
capacityPct: 0,
|
|
1481
|
+
sellThroughPct: 0,
|
|
1482
|
+
grossRevenue: this.authoritativeGrossRevenue,
|
|
1483
|
+
revenueStatus: this.revenueStatus,
|
|
1484
|
+
currency: this.currency
|
|
1485
|
+
};
|
|
1486
|
+
t.capacityPct = t.total ? Math.round(t.booked / t.total * 100) : 0;
|
|
1487
|
+
const sellable = t.total - t.blocked;
|
|
1488
|
+
t.sellThroughPct = sellable > 0 ? Math.round(t.booked / sellable * 100) : 0;
|
|
1489
|
+
return t;
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Queue one KPI/rail repaint for this burst of changes.
|
|
1493
|
+
*
|
|
1494
|
+
* A delta frame can carry hundreds of seats and `paintKpis` rebuilds eight
|
|
1495
|
+
* nodes from scratch, so painting per change is what made an arena-sized
|
|
1496
|
+
* frame expensive. Coalescing on a frame keeps the burst to a single rebuild;
|
|
1497
|
+
* without `requestAnimationFrame` (SSR, an older test env) it paints inline
|
|
1498
|
+
* rather than dropping the update.
|
|
1499
|
+
*/
|
|
1500
|
+
recomputeTallies() {
|
|
1501
|
+
if (this.closed) return;
|
|
1502
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
1503
|
+
this.flushTallies();
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (this.paintHandle !== null) return;
|
|
1507
|
+
this.paintHandle = requestAnimationFrame(() => {
|
|
1508
|
+
this.paintHandle = null;
|
|
1509
|
+
this.flushTallies();
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
flushTallies() {
|
|
1513
|
+
if (this.closed) return;
|
|
1514
|
+
const t = this.buildTallies();
|
|
1515
|
+
this.paintKpis(t);
|
|
1516
|
+
if (this.mode === "view") {
|
|
1517
|
+
this.paintLegend(t);
|
|
1518
|
+
this.paintMonitorInsights();
|
|
1519
|
+
} else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
|
|
1520
|
+
else if (this.mode === "block") this.paintSelBar(this.getSelection());
|
|
1521
|
+
else if (this.mode === "channels") this.channels?.handleSelectionChange();
|
|
1522
|
+
this.opts.onTallies?.(t);
|
|
1523
|
+
}
|
|
1524
|
+
verbFor(prev, next) {
|
|
1525
|
+
if (next === "held") return "held";
|
|
1526
|
+
if (next === "booked") return "booked";
|
|
1527
|
+
if (next === "blocked") return "blocked";
|
|
1528
|
+
if (next === "free") return prev === "blocked" ? "unblocked" : prev === "booked" ? "cancelled" : "released";
|
|
1529
|
+
return next;
|
|
1530
|
+
}
|
|
1531
|
+
pushActivity(labels, verb, status, at = Date.now()) {
|
|
1532
|
+
const label = labels[0];
|
|
1533
|
+
if (!label) return null;
|
|
1534
|
+
const sections = this.sectionsForLabels(labels);
|
|
1535
|
+
const item = {
|
|
1536
|
+
id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
|
|
1537
|
+
at,
|
|
1538
|
+
label,
|
|
1539
|
+
labels: [...labels],
|
|
1540
|
+
count: labels.length,
|
|
1541
|
+
verb,
|
|
1542
|
+
status,
|
|
1543
|
+
sectionIds: sections.ids,
|
|
1544
|
+
sectionLabels: sections.labels
|
|
1545
|
+
};
|
|
1546
|
+
this.feed.unshift(item);
|
|
1547
|
+
if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
|
|
1548
|
+
if (this.mode === "view") this.paintFeed();
|
|
1549
|
+
this.showLiveEvent(item);
|
|
1550
|
+
this.opts.onActivity?.(item);
|
|
1551
|
+
return item;
|
|
1552
|
+
}
|
|
1553
|
+
seedFeed(entries) {
|
|
1554
|
+
const verbByAction = {
|
|
1555
|
+
hold: "held",
|
|
1556
|
+
book: "booked",
|
|
1557
|
+
release: "released",
|
|
1558
|
+
expire: "expired",
|
|
1559
|
+
block: "blocked",
|
|
1560
|
+
unblock: "unblocked",
|
|
1561
|
+
unbook: "cancelled"
|
|
1562
|
+
};
|
|
1563
|
+
const stByAction = {
|
|
1564
|
+
hold: "held",
|
|
1565
|
+
book: "booked",
|
|
1566
|
+
release: "free",
|
|
1567
|
+
expire: "free",
|
|
1568
|
+
block: "blocked",
|
|
1569
|
+
unblock: "free",
|
|
1570
|
+
unbook: "free"
|
|
1571
|
+
};
|
|
1572
|
+
for (const e of entries) {
|
|
1573
|
+
const label = e.labels[0];
|
|
1574
|
+
if (!label) continue;
|
|
1575
|
+
const sections = this.sectionsForLabels(e.labels);
|
|
1576
|
+
const item = {
|
|
1577
|
+
id: `log:${e.id}`,
|
|
1578
|
+
at: e.at,
|
|
1579
|
+
label,
|
|
1580
|
+
labels: [...e.labels],
|
|
1581
|
+
count: e.labels.length,
|
|
1582
|
+
verb: verbByAction[e.action] ?? e.action,
|
|
1583
|
+
status: stByAction[e.action] ?? "free",
|
|
1584
|
+
sectionIds: sections.ids,
|
|
1585
|
+
sectionLabels: sections.labels
|
|
1586
|
+
};
|
|
1587
|
+
this.feed.push(item);
|
|
1588
|
+
this.opts.onActivity?.(item);
|
|
1589
|
+
}
|
|
1590
|
+
this.feed.sort((a, b) => b.at - a.at);
|
|
1591
|
+
if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
|
|
1592
|
+
if (this.mode === "view") this.paintFeed();
|
|
1593
|
+
}
|
|
1594
|
+
startFeedClock() {
|
|
1595
|
+
this.feedTimer = setInterval(() => {
|
|
1596
|
+
if (this.mode === "view") {
|
|
1597
|
+
this.paintFeed();
|
|
1598
|
+
this.paintMonitorInsights();
|
|
1599
|
+
}
|
|
1600
|
+
}, 1e4);
|
|
1601
|
+
}
|
|
1602
|
+
// ---- selection ------------------------------------------------------------
|
|
1603
|
+
selectionLabels() {
|
|
1604
|
+
return this.getSelection().map((s) => s.label);
|
|
1605
|
+
}
|
|
1606
|
+
syncSelection() {
|
|
1607
|
+
const seats = this.getSelection();
|
|
1608
|
+
if (this.mode === "block") this.paintSelBar(seats);
|
|
1609
|
+
else if (this.mode === "inspect") this.renderInspectRail(seats);
|
|
1610
|
+
else if (this.mode === "channels") this.channels?.handleSelectionChange();
|
|
1611
|
+
this.opts.onSelectionChange?.(seats);
|
|
1612
|
+
}
|
|
1613
|
+
// ---- DOM: chrome ----------------------------------------------------------
|
|
1614
|
+
buildChrome() {
|
|
1615
|
+
const root = document.createElement("div");
|
|
1616
|
+
root.className = "slm";
|
|
1617
|
+
root.tabIndex = 0;
|
|
1618
|
+
root.setAttribute("role", "region");
|
|
1619
|
+
root.setAttribute("aria-label", "SeatLayer live control room");
|
|
1620
|
+
const vars = themeVars(this.opts.theme);
|
|
1621
|
+
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
|
|
1622
|
+
root.innerHTML = `
|
|
1623
|
+
<div class="slm-bar">
|
|
1624
|
+
<div class="slm-modes" data-ref="modes" role="tablist" aria-label="Manager tools">
|
|
1625
|
+
<button class="slm-mode" role="tab" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
|
|
1626
|
+
<button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
|
|
1627
|
+
<button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
|
|
1628
|
+
<button class="slm-mode" role="tab" data-mode="sections" title="Sections (S)" aria-keyshortcuts="S">Sections</button>
|
|
1629
|
+
<button class="slm-mode" role="tab" data-mode="channels" title="Channels (C)" aria-keyshortcuts="C" hidden>Channels</button>
|
|
1630
|
+
</div>
|
|
1631
|
+
<select class="slm-tools" data-ref="tools" aria-label="Manager tools"></select>
|
|
1632
|
+
<span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
|
|
1633
|
+
<div class="slm-bar-actions">
|
|
1634
|
+
<button class="slm-barbtn follow" data-ref="follow" aria-pressed="false"
|
|
1635
|
+
title="Stay on the current map view unless enabled">Follow live</button>
|
|
1636
|
+
<button class="slm-barbtn" data-ref="heat" aria-pressed="false"
|
|
1637
|
+
aria-label="Sales momentum overlay off"
|
|
1638
|
+
title="Highlight sections selling fastest in the selected time window">Sales momentum</button>
|
|
1639
|
+
<button class="slm-barbtn" data-ref="fullscreen" title="Full screen (F)" aria-keyshortcuts="F">Full screen</button>
|
|
1640
|
+
</div>
|
|
1641
|
+
<div class="slm-kpis" data-ref="kpis"></div>
|
|
1642
|
+
</div>
|
|
1643
|
+
<div class="slm-body">
|
|
1644
|
+
<div class="slm-map">
|
|
1645
|
+
<div class="slm-map-host" data-ref="maphost"></div>
|
|
1646
|
+
<div class="slm-zoomhint" data-ref="zoomhint">Zoom in to marquee-select</div>
|
|
1647
|
+
<div class="slm-liveevent" data-ref="liveevent" role="status" aria-live="polite"></div>
|
|
1648
|
+
<div class="slm-hud"><button class="slm-hud-chip" data-ref="zfit">Zoom to fit</button></div>
|
|
1649
|
+
</div>
|
|
1650
|
+
<aside class="slm-rail"><div class="slm-railscroll" data-ref="rail"></div></aside>
|
|
1651
|
+
</div>
|
|
1652
|
+
<div class="slm-toast" data-ref="toast"></div>
|
|
1653
|
+
`;
|
|
1654
|
+
this.host.appendChild(root);
|
|
1655
|
+
this.root = root;
|
|
1656
|
+
this.updateContainerLayout();
|
|
1657
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1658
|
+
this.layoutObserver = new ResizeObserver(() => this.updateContainerLayout());
|
|
1659
|
+
this.layoutObserver.observe(root);
|
|
1660
|
+
}
|
|
1661
|
+
const ref = (n) => root.querySelector(`[data-ref="${n}"]`);
|
|
1662
|
+
this.mapHost = ref("maphost");
|
|
1663
|
+
this.els = {
|
|
1664
|
+
modes: ref("modes"),
|
|
1665
|
+
tools: ref("tools"),
|
|
1666
|
+
livetext: ref("livetext"),
|
|
1667
|
+
kpis: ref("kpis"),
|
|
1668
|
+
follow: ref("follow"),
|
|
1669
|
+
heat: ref("heat"),
|
|
1670
|
+
fullscreen: ref("fullscreen"),
|
|
1671
|
+
zoomhint: ref("zoomhint"),
|
|
1672
|
+
liveevent: ref("liveevent"),
|
|
1673
|
+
rail: ref("rail"),
|
|
1674
|
+
toast: ref("toast"),
|
|
1675
|
+
zfit: ref("zfit")
|
|
1676
|
+
};
|
|
1677
|
+
this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
|
|
1678
|
+
this.els.tools.addEventListener("change", () => this.setMode(this.els.tools.value));
|
|
1679
|
+
this.els.zfit.addEventListener("click", () => this.zoomToFit());
|
|
1680
|
+
this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
|
|
1681
|
+
this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
|
|
1682
|
+
this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
|
|
1683
|
+
root.addEventListener("keydown", this.onKeyDown);
|
|
1684
|
+
this.els.rail.addEventListener("click", this.onRailClick);
|
|
1685
|
+
document.addEventListener("fullscreenchange", this.onFullscreenChange);
|
|
1686
|
+
this.paintModeTabs();
|
|
1687
|
+
this.paintFollowLiveButton();
|
|
1688
|
+
this.paintHeatButton();
|
|
1689
|
+
this.paintFullscreenButton();
|
|
1690
|
+
}
|
|
1691
|
+
updateContainerLayout() {
|
|
1692
|
+
const width = this.root?.getBoundingClientRect().width || this.host.clientWidth;
|
|
1693
|
+
this.root?.classList.toggle("compact", width > 0 && width < 800);
|
|
1694
|
+
this.channels?.handleLayoutChange();
|
|
1695
|
+
}
|
|
1696
|
+
buildSectionOptions() {
|
|
1697
|
+
if (!this.doc) return;
|
|
1698
|
+
try {
|
|
1699
|
+
const secs = computeSections(this.doc);
|
|
1700
|
+
this.sectionsBase = secs;
|
|
1701
|
+
this.sectionOptions = [];
|
|
1702
|
+
this.sectionByObject = new Map(secs.objectToSection);
|
|
1703
|
+
this.sectionLabelById.clear();
|
|
1704
|
+
for (const s of secs.sections) {
|
|
1705
|
+
this.sectionOptions.push({ id: s.id, label: s.label });
|
|
1706
|
+
this.sectionLabelById.set(s.id, s.label);
|
|
1707
|
+
}
|
|
1708
|
+
if (secs.ungrouped) {
|
|
1709
|
+
this.sectionOptions.push({ id: UNGROUPED_ID, label: secs.ungrouped.label });
|
|
1710
|
+
this.sectionLabelById.set(UNGROUPED_ID, secs.ungrouped.label);
|
|
1711
|
+
}
|
|
1712
|
+
} catch {
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
paintModeTabs() {
|
|
1716
|
+
const available = [];
|
|
1717
|
+
this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
|
|
1718
|
+
const el = b;
|
|
1719
|
+
const mode = el.dataset.mode;
|
|
1720
|
+
const permitted = mode !== "channels" || this.channelCaps.view;
|
|
1721
|
+
el.hidden = !permitted;
|
|
1722
|
+
if (!permitted) return;
|
|
1723
|
+
available.push({ mode, label: el.textContent ?? mode });
|
|
1724
|
+
const active = mode === this.mode;
|
|
1725
|
+
el.classList.toggle("on", active);
|
|
1726
|
+
el.setAttribute("aria-selected", String(active));
|
|
1727
|
+
el.tabIndex = active ? 0 : -1;
|
|
1728
|
+
});
|
|
1729
|
+
const tools = this.els.tools;
|
|
1730
|
+
if (tools) {
|
|
1731
|
+
tools.innerHTML = available.map((entry) => `<option value="${entry.mode}"${entry.mode === this.mode ? " selected" : ""}>${esc(entry.label)}</option>`).join("");
|
|
1732
|
+
tools.value = this.mode;
|
|
1733
|
+
}
|
|
1734
|
+
this.root?.classList.toggle("block-mode", this.mode === "block");
|
|
1735
|
+
}
|
|
1736
|
+
paintFollowLiveButton() {
|
|
1737
|
+
const button = this.els.follow;
|
|
1738
|
+
if (!button) return;
|
|
1739
|
+
button.classList.toggle("on", this.followLive);
|
|
1740
|
+
button.setAttribute("aria-pressed", String(this.followLive));
|
|
1741
|
+
button.setAttribute("title", this.followLive ? "Following new buyer holds and bookings. Turn off to keep the current view." : "Stay on the current map view. Enable to follow new buyer holds and bookings.");
|
|
1742
|
+
}
|
|
1743
|
+
paintHeatButton() {
|
|
1744
|
+
const button = this.els.heat;
|
|
1745
|
+
if (!button) return;
|
|
1746
|
+
button.classList.toggle("on", this.heatEnabled);
|
|
1747
|
+
button.setAttribute("aria-pressed", String(this.heatEnabled));
|
|
1748
|
+
button.setAttribute("aria-label", `Sales momentum overlay ${this.heatEnabled ? "on" : "off"}`);
|
|
1749
|
+
button.setAttribute("title", `${this.heatEnabled ? "Hide" : "Highlight"} sections selling fastest in the selected time window`);
|
|
1750
|
+
button.textContent = "Sales momentum";
|
|
1751
|
+
this.paintMomentumHelp();
|
|
1752
|
+
}
|
|
1753
|
+
paintMomentumHelp() {
|
|
1754
|
+
const help = this.els.rail?.querySelector('[data-ref="momentumhelp"]');
|
|
1755
|
+
if (!help) return;
|
|
1756
|
+
help.hidden = !this.heatEnabled;
|
|
1757
|
+
const copy = help.querySelector('[data-ref="momentumcopy"]');
|
|
1758
|
+
if (!copy) return;
|
|
1759
|
+
const hasRecentSales = this.controlRoomSnapshot?.velocity.bySection.some((row) => row.netBooked > 0);
|
|
1760
|
+
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.`;
|
|
1761
|
+
}
|
|
1762
|
+
paintFullscreenButton() {
|
|
1763
|
+
if (!this.els.fullscreen) return;
|
|
1764
|
+
this.els.fullscreen.textContent = this.isFullscreen() ? "Exit full screen" : "Full screen";
|
|
1765
|
+
}
|
|
1766
|
+
paintTrendWindow() {
|
|
1767
|
+
this.els.rail?.querySelectorAll("[data-window]").forEach((button) => {
|
|
1768
|
+
const value = Number(button.dataset.window);
|
|
1769
|
+
button.classList.toggle("on", value === this.trendWindowMinutes);
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
setLive(on) {
|
|
1773
|
+
this.root?.classList.toggle("live", on);
|
|
1774
|
+
if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
|
|
1775
|
+
this.paintMonitorInsights();
|
|
1776
|
+
const next = on ? "live" : "reconnecting";
|
|
1777
|
+
if (next === this.connectionStatus) return;
|
|
1778
|
+
this.connectionStatus = next;
|
|
1779
|
+
try {
|
|
1780
|
+
this.opts.onConnectionChange?.(this.getConnection());
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
this.opts.onError?.(err);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
updateZoomHint() {
|
|
1786
|
+
const hint = this.els.zoomhint;
|
|
1787
|
+
if (!hint) return;
|
|
1788
|
+
const show = this.mode === "block" && this.renderer?.getRung?.() !== "seats";
|
|
1789
|
+
hint.classList.toggle("on", !!show);
|
|
1790
|
+
}
|
|
1791
|
+
formatKpiDelta(key, delta, currency) {
|
|
1792
|
+
const sign = delta > 0 ? "+" : "\u2212";
|
|
1793
|
+
const absolute = Math.abs(delta);
|
|
1794
|
+
if (key === "gross-sales") return `${sign}${fmtMoney(absolute, currency)}`;
|
|
1795
|
+
if (key === "sold-pct") return `${sign}${absolute.toLocaleString()}pt`;
|
|
1796
|
+
return `${sign}${absolute.toLocaleString()}`;
|
|
1797
|
+
}
|
|
1798
|
+
paintKpis(t) {
|
|
1799
|
+
if (!this.els.kpis) return;
|
|
1800
|
+
const rev = t.revenueStatus === "current" ? fmtMoney(t.grossRevenue, t.currency) : "\u2014";
|
|
1801
|
+
const presence = this.presenceCounts();
|
|
1802
|
+
const items = [
|
|
1803
|
+
{ key: "sold-seats", raw: t.booked, n: t.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b", title: "Seats booked" },
|
|
1804
|
+
{ key: "held-seats", raw: t.held, n: t.held.toLocaleString(), l: "Held seats", dot: "#f4b740", title: "Seats held in a checkout right now" },
|
|
1805
|
+
{ key: "free-seats", raw: t.free, n: t.free.toLocaleString(), l: "Free seats", dot: "#6e7bff", title: "Seats on sale and unsold" },
|
|
1806
|
+
{ key: "blocked", raw: t.blocked, n: t.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac", title: "Seats withheld from sale" },
|
|
1807
|
+
{ key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers", title: "People on the map right now" },
|
|
1808
|
+
{ key: "carts", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Carts", title: "Checkouts holding seats right now \u2014 sessions, not seats" },
|
|
1809
|
+
{ key: "sold-pct", raw: t.capacityPct, n: `${t.capacityPct}%`, l: "Sold", title: "Sold seats as a share of the whole event" },
|
|
1810
|
+
{ key: "gross-sales", raw: t.revenueStatus === "current" ? t.grossRevenue : null, n: rev, l: "Gross sales", title: "Exact booked gross" }
|
|
1811
|
+
];
|
|
1812
|
+
let hasChanges = false;
|
|
1813
|
+
this.els.kpis.innerHTML = items.map((item) => {
|
|
1814
|
+
const previous = this.lastKpiValues.get(item.key);
|
|
1815
|
+
const changed = item.raw != null && previous != null && item.raw !== previous;
|
|
1816
|
+
const delta = changed ? item.raw - previous : 0;
|
|
1817
|
+
if (changed) {
|
|
1818
|
+
hasChanges = true;
|
|
1819
|
+
this.activeKpiDeltas.set(item.key, {
|
|
1820
|
+
text: this.formatKpiDelta(item.key, delta, t.currency),
|
|
1821
|
+
down: delta < 0
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
if (item.raw != null) this.lastKpiValues.set(item.key, item.raw);
|
|
1825
|
+
const activeDelta = this.activeKpiDeltas.get(item.key);
|
|
1826
|
+
return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}" title="${esc(item.title)}">
|
|
1827
|
+
<b>${item.dot ? `<span class="dot" style="background:${item.dot}"></span>` : ""}${item.n}</b><span>${item.l}</span>
|
|
1828
|
+
${activeDelta ? `<span class="slm-kpidelta${activeDelta.down ? " down" : ""}">${activeDelta.text}</span>` : ""}
|
|
1829
|
+
</div>`;
|
|
1830
|
+
}).join("");
|
|
1831
|
+
if (hasChanges) {
|
|
1832
|
+
if (this.kpiCleanupTimer) clearTimeout(this.kpiCleanupTimer);
|
|
1833
|
+
this.kpiCleanupTimer = setTimeout(() => {
|
|
1834
|
+
this.kpiCleanupTimer = null;
|
|
1835
|
+
this.activeKpiDeltas.clear();
|
|
1836
|
+
this.els.kpis?.querySelectorAll(".slm-kpidelta").forEach((element) => element.remove());
|
|
1837
|
+
this.els.kpis?.querySelectorAll(".slm-kpi.changed").forEach((element) => element.classList.remove("changed"));
|
|
1838
|
+
}, 1500);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
// ---- DOM: rails -----------------------------------------------------------
|
|
1842
|
+
paintRail() {
|
|
1843
|
+
if (this.mode === "view") this.renderViewRail();
|
|
1844
|
+
else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
|
|
1845
|
+
else if (this.mode === "sections") this.renderSectionsRail();
|
|
1846
|
+
else if (this.mode === "channels") {
|
|
1847
|
+
if (this.channels) this.channels.paintRail();
|
|
1848
|
+
else if (this.channelsLoading) {
|
|
1849
|
+
this.els.rail.innerHTML = '<div class="slm-empty" role="status">Loading sales channels\u2026</div>';
|
|
1850
|
+
} else {
|
|
1851
|
+
this.els.rail.innerHTML = '<div class="slm-empty" role="alert">Sales channels could not be loaded. Switch away and back to try again.</div>';
|
|
1852
|
+
}
|
|
1853
|
+
} else this.renderBlockRail();
|
|
1854
|
+
this.updateZoomHint();
|
|
1855
|
+
}
|
|
1856
|
+
renderViewRail() {
|
|
1857
|
+
this.els.rail.innerHTML = `
|
|
1858
|
+
<p class="slm-eyebrow">Monitor</p>
|
|
1859
|
+
<p class="slm-hint">Read-only. Inventory, buyer presence and sales movement update on the same live board.</p>
|
|
1860
|
+
<div class="slm-health" data-ref="presence"></div>
|
|
1861
|
+
<div class="slm-legend" data-ref="legend"></div>
|
|
1862
|
+
<div class="slm-sectionhead">
|
|
1863
|
+
<div><p class="slm-eyebrow">Section performance</p><p class="slm-note">Exact booked revenue \xB7 net sales velocity</p></div>
|
|
1864
|
+
<div class="slm-windows" aria-label="Sales velocity window">
|
|
1865
|
+
${[5, 15, 30, 60].map((window) => `<button class="slm-window" data-window="${window}">${window}m</button>`).join("")}
|
|
1866
|
+
</div>
|
|
1867
|
+
</div>
|
|
1868
|
+
<div class="slm-momentumhelp" data-ref="momentumhelp" ${this.heatEnabled ? "" : "hidden"}>
|
|
1869
|
+
<div class="slm-momentumscale"><span>Warm</span><span class="slm-momentumgradient"></span><span>Hot</span></div>
|
|
1870
|
+
<p class="slm-momentumcopy" data-ref="momentumcopy"></p>
|
|
1871
|
+
</div>
|
|
1872
|
+
<div class="slm-sectionlist" data-ref="sections"></div>
|
|
1873
|
+
<p class="slm-eyebrow">Activity</p>
|
|
1874
|
+
<div class="slm-feed" data-ref="feed"></div>
|
|
1875
|
+
`;
|
|
1876
|
+
this.els.presence = this.els.rail.querySelector('[data-ref="presence"]');
|
|
1877
|
+
this.els.legend = this.els.rail.querySelector('[data-ref="legend"]');
|
|
1878
|
+
this.els.sections = this.els.rail.querySelector('[data-ref="sections"]');
|
|
1879
|
+
this.els.feed = this.els.rail.querySelector('[data-ref="feed"]');
|
|
1880
|
+
this.els.rail.querySelectorAll("[data-window]").forEach((button) => button.addEventListener("click", () => {
|
|
1881
|
+
const windowMinutes = Number(button.dataset.window);
|
|
1882
|
+
void this.setTrendWindow(windowMinutes).catch((err) => this.opts.onError?.(err));
|
|
1883
|
+
}));
|
|
1884
|
+
this.recomputeTallies();
|
|
1885
|
+
this.paintMonitorInsights();
|
|
1886
|
+
this.paintTrendWindow();
|
|
1887
|
+
this.paintMomentumHelp();
|
|
1888
|
+
this.paintFeed();
|
|
1889
|
+
}
|
|
1890
|
+
/** Live presence wins over the snapshot's copy — it is the fresher channel,
|
|
1891
|
+
* and it exists from the first frame rather than the first fetch. */
|
|
1892
|
+
presenceCounts() {
|
|
1893
|
+
return this.livePresence?.value ?? this.controlRoomSnapshot?.presence ?? null;
|
|
1894
|
+
}
|
|
1895
|
+
paintMonitorInsights() {
|
|
1896
|
+
if (this.mode !== "view") return;
|
|
1897
|
+
const snapshot = this.controlRoomSnapshot;
|
|
1898
|
+
if (this.els.presence) {
|
|
1899
|
+
const connected = this.root?.classList.contains("live");
|
|
1900
|
+
const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
|
|
1901
|
+
const presence = this.presenceCounts();
|
|
1902
|
+
this.els.presence.innerHTML = `
|
|
1903
|
+
<div class="slm-healthitem" title="People on the map right now"><b>${presence ? presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyers</span></div>
|
|
1904
|
+
<div class="slm-healthitem" title="Checkouts holding seats right now \u2014 sessions, not seats"><b>${presence ? presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Carts</span></div>
|
|
1905
|
+
<div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
|
|
1906
|
+
<div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
|
|
1907
|
+
}
|
|
1908
|
+
if (!this.els.sections) return;
|
|
1909
|
+
if (!snapshot) {
|
|
1910
|
+
this.els.sections.innerHTML = '<div class="slm-empty">Loading authoritative section metrics\u2026</div>';
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const velocity = new Map(snapshot.velocity.bySection.map((row) => [row.sectionId, row]));
|
|
1914
|
+
const rows = [...snapshot.revenue.bySection].sort((a, b) => {
|
|
1915
|
+
const av = velocity.get(a.sectionId)?.netBooked ?? 0;
|
|
1916
|
+
const bv = velocity.get(b.sectionId)?.netBooked ?? 0;
|
|
1917
|
+
return bv - av || b.bookedRevenue - a.bookedRevenue;
|
|
1918
|
+
});
|
|
1919
|
+
this.els.sections.innerHTML = rows.length ? rows.map((row) => {
|
|
1920
|
+
const speed = velocity.get(row.sectionId);
|
|
1921
|
+
const net = speed?.netBooked ?? 0;
|
|
1922
|
+
const netLabel = `${net > 0 ? "+" : ""}${net}`;
|
|
1923
|
+
const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
|
|
1924
|
+
return `<button type="button" class="slm-sectionrow" data-section-focus="${esc(row.sectionId)}" title="Focus ${esc(row.sectionLabel)} on the map">
|
|
1925
|
+
<span class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></span>
|
|
1926
|
+
<span 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><span class="slm-sectionlocate">Locate</span></span>
|
|
1927
|
+
</button>`;
|
|
1928
|
+
}).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
|
|
1929
|
+
this.paintTrendWindow();
|
|
1930
|
+
this.paintMomentumHelp();
|
|
1931
|
+
}
|
|
1932
|
+
applyHeatOverlay() {
|
|
1933
|
+
const snapshot = this.controlRoomSnapshot;
|
|
1934
|
+
if (!this.heatEnabled || !snapshot) {
|
|
1935
|
+
this.renderer?.setSectionHeat(null);
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
const capacity = new Map(snapshot.revenue.bySection.map((row) => [row.sectionId, Math.max(1, row.total)]));
|
|
1939
|
+
const rates = snapshot.velocity.bySection.map((row) => ({
|
|
1940
|
+
sectionId: row.sectionId,
|
|
1941
|
+
rate: Math.max(0, row.netBooked) / (capacity.get(row.sectionId) ?? 1) / snapshot.velocity.windowMinutes
|
|
1942
|
+
}));
|
|
1943
|
+
const max = Math.max(0, ...rates.map((row) => row.rate));
|
|
1944
|
+
const scores = {};
|
|
1945
|
+
for (const row of rates) scores[row.sectionId] = max > 0 ? Math.sqrt(row.rate / max) : 0;
|
|
1946
|
+
this.renderer?.setSectionHeat(scores);
|
|
1947
|
+
}
|
|
1948
|
+
renderInspectRail(seats) {
|
|
1949
|
+
const seat = seats[seats.length - 1];
|
|
1950
|
+
if (!seat) {
|
|
1951
|
+
this.els.rail.innerHTML = `
|
|
1952
|
+
<p class="slm-eyebrow">Inspect seats</p>
|
|
1953
|
+
<p class="slm-hint">Select a seat to see its availability and sales context. Nothing changes in this view.</p>
|
|
1954
|
+
<div class="slm-empty">Select a seat on the map.</div>`;
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
const status = this.status.get(seat.label) ?? "free";
|
|
1958
|
+
const statusLabel = { free: "Free", held: "Held", booked: "Booked", blocked: "Blocked" };
|
|
1959
|
+
const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
|
|
1960
|
+
const sectionLabel = this.sectionLabelById.get(sectionId) ?? "Other seats";
|
|
1961
|
+
const category = this.doc?.categories.find((item) => item.key === seat.categoryKey);
|
|
1962
|
+
const sectionMetric = this.controlRoomSnapshot?.revenue.bySection.find((row) => row.sectionId === sectionId);
|
|
1963
|
+
const object = this.doc?.objects.find((item) => item.id === seat.rowId);
|
|
1964
|
+
const location = object?.type === "row" ? { label: "Row", value: object.label } : object?.type === "table" ? { label: "Table", value: object.label } : seat.kind === "booth" ? { label: "Type", value: "Booth" } : null;
|
|
1965
|
+
const itemKind = seat.kind === "booth" ? "Booth" : "Seat";
|
|
1966
|
+
this.els.rail.innerHTML = `
|
|
1967
|
+
<p class="slm-eyebrow">${itemKind} details</p>
|
|
1968
|
+
<p class="slm-hint">Live availability and section performance.</p>
|
|
1969
|
+
<div class="slm-inspect-card">
|
|
1970
|
+
<div class="slm-inspect-label">${esc(seat.label)}</div>
|
|
1971
|
+
<div class="slm-inspect-grid">
|
|
1972
|
+
<div><span>Status</span><b>${statusLabel[status]}</b></div>
|
|
1973
|
+
<div><span>Section</span><b>${esc(sectionLabel)}</b></div>
|
|
1974
|
+
${location ? `<div><span>${location.label}</span><b>${esc(location.value)}</b></div>` : ""}
|
|
1975
|
+
<div><span>Category</span><b>${esc(category?.label ?? seat.categoryKey)}</b></div>
|
|
1976
|
+
<div><span>Sold in section</span><b>${sectionMetric ? `${sectionMetric.booked} of ${sectionMetric.total}` : "\u2014"}</b></div>
|
|
1977
|
+
<div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>
|
|
1978
|
+
</div>
|
|
1979
|
+
</div>`;
|
|
1980
|
+
}
|
|
1981
|
+
// ---- sections: availability windows --------------------------------------
|
|
1982
|
+
/** Pull the organizer's availability rules (event:view). Called on load and on
|
|
1983
|
+
* every WS (re)connect, mirroring how the other panels re-hydrate. `closed` is
|
|
1984
|
+
* deterministic from the rules; `hidden` (which folds in already-due timed /
|
|
1985
|
+
* threshold windows) comes from the snapshot + WS effective set. */
|
|
1986
|
+
async refreshAvailability() {
|
|
1987
|
+
try {
|
|
1988
|
+
const res = await this.withAuthRetry(() => this.api.availability(this.key));
|
|
1989
|
+
this.availabilityRules = res.rules ?? {};
|
|
1990
|
+
this.effectiveClosed = new Set(this.closedIdsFromRules(this.availabilityRules));
|
|
1991
|
+
if (this.mode === "sections") this.renderSectionsRail();
|
|
1992
|
+
this.applySectionCanvasTreatment();
|
|
1993
|
+
} catch (err) {
|
|
1994
|
+
this.opts.onError?.(err);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/** Run a token-authed op; on a 401 re-mint via onTokenRefresh and retry once. */
|
|
1998
|
+
async withAuthRetry(op) {
|
|
1999
|
+
try {
|
|
2000
|
+
return await op();
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
if (err instanceof ManageApiError && err.status === 401 && this.opts.onTokenRefresh && !this.tokenRefreshInFlight) {
|
|
2003
|
+
await this.rotateToken();
|
|
2004
|
+
return op();
|
|
2005
|
+
}
|
|
2006
|
+
throw err;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
closedIdsFromRules(rules) {
|
|
2010
|
+
return Object.entries(rules).filter(([, r]) => r.mode === "closed").map(([id]) => id);
|
|
2011
|
+
}
|
|
2012
|
+
/** Adopt a new effective hidden/closed set (from a snapshot or WS broadcast) and
|
|
2013
|
+
* repaint the rail + canvas when it actually moves. */
|
|
2014
|
+
updateEffectiveAvailability(hidden, closed) {
|
|
2015
|
+
let changed = false;
|
|
2016
|
+
if (Array.isArray(hidden)) {
|
|
2017
|
+
this.effectiveHidden = new Set(hidden.filter((x) => typeof x === "string"));
|
|
2018
|
+
changed = true;
|
|
2019
|
+
}
|
|
2020
|
+
if (Array.isArray(closed)) {
|
|
2021
|
+
this.effectiveClosed = new Set(closed.filter((x) => typeof x === "string"));
|
|
2022
|
+
changed = true;
|
|
2023
|
+
}
|
|
2024
|
+
if (!changed) return;
|
|
2025
|
+
if (this.mode === "sections") this.renderSectionsRail();
|
|
2026
|
+
this.applySectionCanvasTreatment();
|
|
2027
|
+
}
|
|
2028
|
+
/** Canvas read of the availability state: dim hidden sections to a whisper,
|
|
2029
|
+
* half-light closed sections, leave open sections normal. Only in Sections mode;
|
|
2030
|
+
* cleared in every other tool. */
|
|
2031
|
+
applySectionCanvasTreatment() {
|
|
2032
|
+
if (!this.renderer) return;
|
|
2033
|
+
if (this.mode === "sections") {
|
|
2034
|
+
this.renderer.setDimmedSections([...this.effectiveHidden]);
|
|
2035
|
+
this.renderer.setClosedSections([...this.effectiveClosed]);
|
|
2036
|
+
} else {
|
|
2037
|
+
this.renderer.setDimmedSections(null);
|
|
2038
|
+
this.renderer.setClosedSections(null);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
/** Zone-grouped render tree: each zone header then its sections (which follow the
|
|
2042
|
+
* zone window), then loose sections + the ungrouped bucket. Effective hidden /
|
|
2043
|
+
* closed come from the live sets, rules from the organizer map. */
|
|
2044
|
+
buildSectionRows() {
|
|
2045
|
+
const base = this.sectionsBase;
|
|
2046
|
+
if (!base) return { rows: [], hiddenSections: 0, closedSections: 0 };
|
|
2047
|
+
const zones = this.doc?.zones ?? [];
|
|
2048
|
+
const byZone = /* @__PURE__ */ new Map();
|
|
2049
|
+
const loose = [];
|
|
2050
|
+
for (const s of base.sections) {
|
|
2051
|
+
if (s.zone && zones.some((z) => z.id === s.zone)) {
|
|
2052
|
+
const list = byZone.get(s.zone) ?? [];
|
|
2053
|
+
list.push(s);
|
|
2054
|
+
byZone.set(s.zone, list);
|
|
2055
|
+
} else {
|
|
2056
|
+
loose.push(s);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
const rows = [];
|
|
2060
|
+
let hiddenSections = 0;
|
|
2061
|
+
let closedSections = 0;
|
|
2062
|
+
const push = (kind, node, zoneRuled, parentClosed = false) => {
|
|
2063
|
+
const rule = this.availabilityRules[node.id] ?? null;
|
|
2064
|
+
const effClosed = this.effectiveClosed.has(node.id) || parentClosed;
|
|
2065
|
+
const effHidden = this.effectiveHidden.has(node.id) || zoneRuled && !effClosed;
|
|
2066
|
+
if (kind === "section" && effHidden) hiddenSections += 1;
|
|
2067
|
+
if (kind === "section" && effClosed) closedSections += 1;
|
|
2068
|
+
rows.push({
|
|
2069
|
+
kind,
|
|
2070
|
+
id: node.id,
|
|
2071
|
+
label: node.label,
|
|
2072
|
+
seatCount: node.seatCount,
|
|
2073
|
+
seatLabels: node.seatLabels,
|
|
2074
|
+
rule,
|
|
2075
|
+
hidden: effHidden,
|
|
2076
|
+
closed: effClosed,
|
|
2077
|
+
followsZone: kind === "section" && zoneRuled
|
|
2078
|
+
});
|
|
2079
|
+
};
|
|
2080
|
+
for (const z of zones) {
|
|
2081
|
+
const secs = byZone.get(z.id);
|
|
2082
|
+
if (!secs || !secs.length) continue;
|
|
2083
|
+
const zoneNode = {
|
|
2084
|
+
id: z.id,
|
|
2085
|
+
label: z.label || "Zone",
|
|
2086
|
+
seatCount: secs.reduce((sum, s) => sum + s.seatCount, 0),
|
|
2087
|
+
seatLabels: secs.flatMap((s) => s.seatLabels)
|
|
2088
|
+
};
|
|
2089
|
+
const zoneRuled = !!this.availabilityRules[z.id];
|
|
2090
|
+
const zoneClosed = this.availabilityRules[z.id]?.mode === "closed";
|
|
2091
|
+
push("zone", zoneNode, false);
|
|
2092
|
+
for (const s of secs) push("section", s, zoneRuled, zoneClosed);
|
|
2093
|
+
}
|
|
2094
|
+
for (const s of loose) push("section", s, false);
|
|
2095
|
+
if (base.ungrouped) {
|
|
2096
|
+
const u = base.ungrouped;
|
|
2097
|
+
push("section", { id: UNGROUPED_ID, label: u.label, seatCount: u.seatCount, seatLabels: u.seatLabels }, false);
|
|
2098
|
+
}
|
|
2099
|
+
return { rows, hiddenSections, closedSections };
|
|
2100
|
+
}
|
|
2101
|
+
renderSectionsRail() {
|
|
2102
|
+
const { rows, hiddenSections, closedSections } = this.buildSectionRows();
|
|
2103
|
+
if (!rows.length) {
|
|
2104
|
+
this.els.rail.innerHTML = `
|
|
2105
|
+
<p class="slm-eyebrow">Availability windows</p>
|
|
2106
|
+
<p class="slm-hint">Draw sections or zones in the designer to schedule availability per area. This chart has none yet.</p>
|
|
2107
|
+
<div class="slm-empty">No sections on this chart.</div>`;
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
const parts = [];
|
|
2111
|
+
if (hiddenSections) parts.push(`${hiddenSections} hidden`);
|
|
2112
|
+
if (closedSections) parts.push(`${closedSections} closed`);
|
|
2113
|
+
const summary = parts.length ? parts.join(" \xB7 ") : "All sections open and on sale";
|
|
2114
|
+
const warn = hiddenSections > 0 || closedSections > 0;
|
|
2115
|
+
this.els.rail.innerHTML = `
|
|
2116
|
+
<p class="slm-eyebrow">Availability windows</p>
|
|
2117
|
+
<p class="slm-hint">Control when each zone or section goes on sale. Keep it hidden, reveal it at a set time, or <b>auto-reveal once the rest sells past a threshold</b>. Hidden seats vanish for buyers; closed seats stay on the map (flat grey) but can't be bought.</p>
|
|
2118
|
+
<div class="slm-availlist" data-ref="availlist">${rows.map((row) => this.sectionRowHtml(row)).join("")}</div>
|
|
2119
|
+
<div class="slm-availsummary">
|
|
2120
|
+
<span class="slm-availdot${warn ? " warn" : ""}"></span>
|
|
2121
|
+
<span>${esc(summary)}</span>
|
|
2122
|
+
</div>
|
|
2123
|
+
<div class="slm-availcallout">
|
|
2124
|
+
<span class="slm-availstar" aria-hidden="true">\u2726</span>
|
|
2125
|
+
<p><b>Auto-reveal at % sold</b> is our differentiator \u2014 demand-triggered release: the balcony opens itself the moment the stalls hit the threshold. Neither seats.io nor Ticketmaster ships this.</p>
|
|
2126
|
+
</div>`;
|
|
2127
|
+
this.wireSectionRail();
|
|
2128
|
+
this.applySectionCanvasTreatment();
|
|
2129
|
+
}
|
|
2130
|
+
sectionRowHtml(row) {
|
|
2131
|
+
const mode = availabilityModeOf(row.rule);
|
|
2132
|
+
const cls = `slm-availrow${row.kind === "zone" ? " zone" : ""}${row.hidden ? " hidden" : ""}${row.closed ? " closed" : ""}`;
|
|
2133
|
+
const disabled = this.availabilitySaving ? " disabled" : "";
|
|
2134
|
+
const option = (value, text) => `<option value="${value}"${mode === value ? " selected" : ""}>${text}</option>`;
|
|
2135
|
+
const control = row.followsZone ? '<span class="slm-availfollows">Follows zone</span>' : `<span class="slm-availselwrap">
|
|
2136
|
+
<select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc(row.id)}"${disabled} aria-label="Availability for ${esc(row.label)}">
|
|
2137
|
+
${option("open", "Open \u2014 on sale")}
|
|
2138
|
+
${option("closed", "Closed \u2014 visible, not on sale")}
|
|
2139
|
+
${option("hidden", "Hidden \u2014 off the buyer map")}
|
|
2140
|
+
${option("timed", "Reveal at a time")}
|
|
2141
|
+
${option("threshold", "Auto-reveal at % sold")}
|
|
2142
|
+
</select>
|
|
2143
|
+
</span>`;
|
|
2144
|
+
let detail = "";
|
|
2145
|
+
if (!row.followsZone && mode === "timed") {
|
|
2146
|
+
const value = row.rule?.revealAt ? esc(toLocalInput(row.rule.revealAt)) : "";
|
|
2147
|
+
detail = `<div class="slm-availdetail">
|
|
2148
|
+
<input type="datetime-local" class="slm-input" data-avail-reveal="${esc(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc(row.label)}" />
|
|
2149
|
+
</div>`;
|
|
2150
|
+
} else if (!row.followsZone && mode === "threshold") {
|
|
2151
|
+
const pct = row.rule?.thresholdPct ?? 80;
|
|
2152
|
+
detail = `<div class="slm-availdetail">
|
|
2153
|
+
<span class="slm-availpctlabel">Reveal at</span>
|
|
2154
|
+
<input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${esc(row.id)}" value="${esc(pct)}"${disabled} aria-label="Percent sold to reveal ${esc(row.label)}" />
|
|
2155
|
+
<span class="slm-availpctlabel">% sold</span>
|
|
2156
|
+
</div>`;
|
|
2157
|
+
}
|
|
2158
|
+
const badge = row.closed ? '<span class="slm-availbadge closed">Closed</span>' : row.hidden ? '<span class="slm-availbadge hidden">Hidden</span>' : "";
|
|
2159
|
+
const caret = row.kind === "zone" ? `<span class="slm-availcaret" aria-hidden="true">${row.hidden ? "\u25B8" : "\u25BE"}</span>` : "";
|
|
2160
|
+
return `<div class="${cls}">
|
|
2161
|
+
<div class="slm-availhead">
|
|
2162
|
+
<span class="slm-availlabel">${caret}${esc(row.label)}</span>
|
|
2163
|
+
${badge}
|
|
2164
|
+
<span class="slm-availcount">${row.seatCount.toLocaleString()}</span>
|
|
2165
|
+
${control}
|
|
2166
|
+
</div>
|
|
2167
|
+
${detail}
|
|
2168
|
+
</div>`;
|
|
2169
|
+
}
|
|
2170
|
+
wireSectionRail() {
|
|
2171
|
+
const rail = this.els.rail;
|
|
2172
|
+
if (!rail) return;
|
|
2173
|
+
rail.querySelectorAll("[data-avail-id]").forEach((select) => {
|
|
2174
|
+
select.addEventListener("change", () => this.setSectionMode(select.dataset.availId, select.value));
|
|
2175
|
+
});
|
|
2176
|
+
rail.querySelectorAll("[data-avail-reveal]").forEach((input) => {
|
|
2177
|
+
input.addEventListener("change", () => {
|
|
2178
|
+
const ms = new Date(input.value).getTime();
|
|
2179
|
+
if (Number.isFinite(ms)) this.setSectionRulePatch(input.dataset.availReveal, { revealAt: ms });
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
2182
|
+
rail.querySelectorAll("[data-avail-pct]").forEach((input) => {
|
|
2183
|
+
input.addEventListener("change", () => {
|
|
2184
|
+
const pct = Math.max(1, Math.min(100, Number(input.value) || 0));
|
|
2185
|
+
this.setSectionRulePatch(input.dataset.availPct, { thresholdPct: pct });
|
|
2186
|
+
});
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
/** Change one row's availability mode. A zone rule subsumes its child section
|
|
2190
|
+
* rules, so those are dropped from the map (the zone window is the truth). */
|
|
2191
|
+
setSectionMode(id, mode) {
|
|
2192
|
+
const row = this.buildSectionRows().rows.find((r) => r.id === id);
|
|
2193
|
+
const seatLabels = row?.seatLabels ?? this.availabilityRules[id]?.labels ?? [];
|
|
2194
|
+
const next = { ...this.availabilityRules };
|
|
2195
|
+
const rule = availabilityRuleForMode(mode, seatLabels, this.availabilityRules[id]);
|
|
2196
|
+
if (rule) next[id] = rule;
|
|
2197
|
+
else delete next[id];
|
|
2198
|
+
if (row?.kind === "zone" && this.sectionsBase) {
|
|
2199
|
+
for (const s of this.sectionsBase.sections) if (s.zone === id) delete next[s.id];
|
|
2200
|
+
}
|
|
2201
|
+
void this.persistAvailability(next);
|
|
2202
|
+
}
|
|
2203
|
+
/** Edit a timed reveal time / threshold percent on an existing row rule. */
|
|
2204
|
+
setSectionRulePatch(id, patch) {
|
|
2205
|
+
const cur = this.availabilityRules[id];
|
|
2206
|
+
if (!cur) return;
|
|
2207
|
+
const row = this.buildSectionRows().rows.find((r) => r.id === id);
|
|
2208
|
+
const labels = row?.seatLabels ?? cur.labels ?? [];
|
|
2209
|
+
void this.persistAvailability({ ...this.availabilityRules, [id]: { ...cur, ...patch, labels } });
|
|
2210
|
+
}
|
|
2211
|
+
/** Optimistically adopt the new rules, then reconcile with the server-cleaned
|
|
2212
|
+
* map + effective hidden/closed sets. Rolls back the rules on failure. */
|
|
2213
|
+
async persistAvailability(next) {
|
|
2214
|
+
const prev = this.availabilityRules;
|
|
2215
|
+
this.availabilityRules = next;
|
|
2216
|
+
this.availabilitySaving = true;
|
|
2217
|
+
if (this.mode === "sections") this.renderSectionsRail();
|
|
2218
|
+
try {
|
|
2219
|
+
const res = await this.withAuthRetry(() => this.api.setAvailability(this.key, next));
|
|
2220
|
+
this.availabilityRules = res.rules;
|
|
2221
|
+
this.effectiveHidden = new Set(res.hidden);
|
|
2222
|
+
this.effectiveClosed = new Set(this.closedIdsFromRules(res.rules));
|
|
2223
|
+
this.availabilitySaving = false;
|
|
2224
|
+
if (this.mode === "sections") this.renderSectionsRail();
|
|
2225
|
+
this.applySectionCanvasTreatment();
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
this.availabilityRules = prev;
|
|
2228
|
+
this.availabilitySaving = false;
|
|
2229
|
+
if (this.mode === "sections") this.renderSectionsRail();
|
|
2230
|
+
this.toastErr("Couldn't update availability. Try again.");
|
|
2231
|
+
this.opts.onError?.(err);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
paintLegend(t) {
|
|
2235
|
+
if (!this.els.legend) return;
|
|
2236
|
+
this.els.legend.innerHTML = LEGEND.map((l) => `<div class="slm-legrow"><span class="slm-legdot" style="background:${l.color}"></span>
|
|
2237
|
+
<span class="slm-leglabel">${l.label}</span><span class="slm-legcount">${t[l.key].toLocaleString()}</span></div>`).join("");
|
|
2238
|
+
}
|
|
2239
|
+
paintFeed() {
|
|
2240
|
+
if (!this.els.feed) return;
|
|
2241
|
+
if (!this.feed.length) {
|
|
2242
|
+
this.els.feed.innerHTML = `<div class="slm-empty">No activity yet \u2014 it'll stream in live.</div>`;
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
const now = Date.now();
|
|
2246
|
+
const color = { free: "#6e7bff", held: "#f4b740", booked: "#22a06b", blocked: "#8b94ac" };
|
|
2247
|
+
this.els.feed.innerHTML = this.feed.map((a) => {
|
|
2248
|
+
const extra = a.count > 1 ? ` +${a.count - 1}` : "";
|
|
2249
|
+
const sections = a.sectionLabels ?? [];
|
|
2250
|
+
const sectionCopy = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : "";
|
|
2251
|
+
return `<button type="button" class="slm-feedrow" data-feed-id="${esc(a.id)}" title="Locate this activity on the map">
|
|
2252
|
+
<span class="slm-feeddot" style="background:${color[a.status]}"></span>
|
|
2253
|
+
<span class="slm-feedtext">${sectionCopy ? `<span class="slm-feedsection">${esc(sectionCopy)}</span>` : ""}${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
|
|
2254
|
+
<span class="slm-feedmeta"><span class="slm-feedtime">${relTime(a.at, now)}</span><span class="slm-feedlocate">Locate</span></span>
|
|
2255
|
+
</button>`;
|
|
2256
|
+
}).join("");
|
|
2257
|
+
}
|
|
2258
|
+
renderBlockRail() {
|
|
2259
|
+
const cats = this.doc?.categories ?? [];
|
|
2260
|
+
const catChips = cats.map((c) => `<button class="slm-chip" type="button" data-cat="${esc(c.key)}" aria-pressed="false">
|
|
2261
|
+
<span class="dot" style="background:${esc(c.color ?? "#6e7bff")}"></span>
|
|
2262
|
+
<span>${esc(c.label ?? c.key)}</span>
|
|
2263
|
+
<span class="slm-chipcount" data-cat-count>0</span>
|
|
2264
|
+
<span class="slm-chipcheck" aria-hidden="true">\u2713</span>
|
|
2265
|
+
</button>`).join("");
|
|
2266
|
+
const sectionField = this.sectionOptions.length ? `<div class="slm-field"><label>Select a whole section</label>
|
|
2267
|
+
<select class="slm-select" data-ref="section"><option value="">Choose a section\u2026</option>
|
|
2268
|
+
${this.sectionOptions.map((s) => `<option value="${esc(s.id)}">${esc(s.label)}</option>`).join("")}</select></div>` : "";
|
|
2269
|
+
const blockedSectionOptions = this.sectionOptions.map((s) => `<option value="${esc(s.id)}">${esc(s.label)}</option>`).join("");
|
|
2270
|
+
this.els.rail.innerHTML = `
|
|
2271
|
+
<p class="slm-eyebrow">Block & unblock</p>
|
|
2272
|
+
<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>
|
|
2273
|
+
<div class="slm-selbar" aria-live="polite"><span class="slm-selnum" data-ref="selnum">0</span><span class="slm-sellabel" data-ref="selmeta">selected</span></div>
|
|
2274
|
+
<div class="slm-row">
|
|
2275
|
+
<button class="slm-btn" data-ref="doblock" disabled>Block</button>
|
|
2276
|
+
<button class="slm-btn ghost" data-ref="dounblock" disabled>Put back on sale</button>
|
|
2277
|
+
</div>
|
|
2278
|
+
<div class="slm-row">
|
|
2279
|
+
<button class="slm-btn ghost" data-ref="selall">Select all</button>
|
|
2280
|
+
<button class="slm-btn ghost" data-ref="clearsel">Clear</button>
|
|
2281
|
+
</div>
|
|
2282
|
+
<p class="slm-eyebrow" style="margin-top:8px">Select by category</p>
|
|
2283
|
+
<p class="slm-selecthelp">Choose one or more. A checked category is selected; click it again to remove it.</p>
|
|
2284
|
+
<div class="slm-chiprow">${catChips || '<span class="slm-empty">No categories.</span>'}</div>
|
|
2285
|
+
${sectionField}
|
|
2286
|
+
<div class="slm-field">
|
|
2287
|
+
<label>Auto-release blocks at (optional)</label>
|
|
2288
|
+
<input type="datetime-local" class="slm-input" data-ref="release" />
|
|
2289
|
+
<p class="slm-note" data-ref="releasenote">Leave empty to block permanently.</p>
|
|
2290
|
+
</div>
|
|
2291
|
+
<section class="slm-blocked" aria-labelledby="slm-blocked-title">
|
|
2292
|
+
<div class="slm-blockedhead">
|
|
2293
|
+
<p class="slm-eyebrow" id="slm-blocked-title">Blocked inventory</p>
|
|
2294
|
+
<span class="slm-blockedtotal"><b data-ref="blockedcount">0</b> out of sale</span>
|
|
2295
|
+
</div>
|
|
2296
|
+
<p class="slm-selecthelp">Find blocked seats, select only the ones you need, then use \u201CPut back on sale\u201D.</p>
|
|
2297
|
+
<div class="slm-blockedtools">
|
|
2298
|
+
<input type="search" class="slm-input" data-ref="blockedsearch" placeholder="Find seat, row or category" aria-label="Search blocked seats" />
|
|
2299
|
+
<select class="slm-select" data-ref="blockedsection" aria-label="Filter blocked seats by section">
|
|
2300
|
+
<option value="">All sections</option>${blockedSectionOptions}
|
|
2301
|
+
</select>
|
|
2302
|
+
</div>
|
|
2303
|
+
<div class="slm-blockedsummary">
|
|
2304
|
+
<span data-ref="blockedshowing">No blocked seats</span>
|
|
2305
|
+
<button type="button" class="slm-linkbtn" data-ref="selblocked" disabled>Select results</button>
|
|
2306
|
+
</div>
|
|
2307
|
+
<div class="slm-blockedlist" data-ref="blockedlist"></div>
|
|
2308
|
+
</section>
|
|
2309
|
+
<div class="slm-field">
|
|
2310
|
+
<button class="slm-btn ghost" data-ref="markall" style="width:100%" disabled>Put all blocked seats on sale</button>
|
|
2311
|
+
<p class="slm-note slm-allnote" data-ref="markallnote">For a full reset only. You will be asked to confirm.</p>
|
|
2312
|
+
</div>
|
|
2313
|
+
`;
|
|
2314
|
+
const r = (n) => this.els.rail.querySelector(`[data-ref="${n}"]`);
|
|
2315
|
+
this.els.selnum = r("selnum");
|
|
2316
|
+
this.els.doblock = r("doblock");
|
|
2317
|
+
this.els.dounblock = r("dounblock");
|
|
2318
|
+
this.els.selmeta = r("selmeta");
|
|
2319
|
+
this.els.blockedcount = r("blockedcount");
|
|
2320
|
+
this.els.blockedshowing = r("blockedshowing");
|
|
2321
|
+
this.els.blockedlist = r("blockedlist");
|
|
2322
|
+
this.els.selblocked = r("selblocked");
|
|
2323
|
+
this.els.markall = r("markall");
|
|
2324
|
+
this.els.markallnote = r("markallnote");
|
|
2325
|
+
r("doblock").addEventListener("click", () => void this.block());
|
|
2326
|
+
r("dounblock").addEventListener("click", () => void this.unblock());
|
|
2327
|
+
r("selall").addEventListener("click", () => this.selectAll());
|
|
2328
|
+
r("clearsel").addEventListener("click", () => this.clearSelection());
|
|
2329
|
+
r("markall").addEventListener("click", () => this.confirmUnblockAll());
|
|
2330
|
+
this.els.rail.querySelectorAll("[data-cat]").forEach((b) => b.addEventListener("click", () => this.toggleCategory(b.dataset.cat)));
|
|
2331
|
+
const sectionSel = this.els.rail.querySelector('[data-ref="section"]');
|
|
2332
|
+
sectionSel?.addEventListener("change", () => {
|
|
2333
|
+
if (sectionSel.value) {
|
|
2334
|
+
this.selectSection(sectionSel.value);
|
|
2335
|
+
sectionSel.value = "";
|
|
2336
|
+
}
|
|
2337
|
+
});
|
|
2338
|
+
const blockedSearch = r("blockedsearch");
|
|
2339
|
+
const blockedSection = r("blockedsection");
|
|
2340
|
+
blockedSearch.value = this.blockedQuery;
|
|
2341
|
+
blockedSection.value = this.blockedSection;
|
|
2342
|
+
blockedSearch.addEventListener("input", () => {
|
|
2343
|
+
this.blockedQuery = blockedSearch.value;
|
|
2344
|
+
this.blockedResultLimit = 100;
|
|
2345
|
+
this.paintBlockedInventory();
|
|
2346
|
+
});
|
|
2347
|
+
blockedSection.addEventListener("change", () => {
|
|
2348
|
+
this.blockedSection = blockedSection.value;
|
|
2349
|
+
this.blockedResultLimit = 100;
|
|
2350
|
+
this.paintBlockedInventory();
|
|
2351
|
+
});
|
|
2352
|
+
r("selblocked").addEventListener("click", () => {
|
|
2353
|
+
this.toggleLabels(this.filteredBlockedSeats().map((seat) => seat.label));
|
|
2354
|
+
});
|
|
2355
|
+
r("blockedlist").addEventListener("click", (event) => {
|
|
2356
|
+
const target = event.target;
|
|
2357
|
+
const seatButton = target.closest("[data-blocked-label]");
|
|
2358
|
+
if (seatButton?.dataset.blockedLabel) this.toggleLabels([seatButton.dataset.blockedLabel]);
|
|
2359
|
+
else if (target.closest("[data-blocked-more]")) {
|
|
2360
|
+
this.blockedResultLimit += 100;
|
|
2361
|
+
this.paintBlockedInventory();
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
const rel = r("release");
|
|
2365
|
+
rel.addEventListener("change", () => {
|
|
2366
|
+
const ms = rel.value ? new Date(rel.value).getTime() : NaN;
|
|
2367
|
+
this.releaseAt = Number.isFinite(ms) && ms > Date.now() ? ms : null;
|
|
2368
|
+
const note = r("releasenote");
|
|
2369
|
+
note.textContent = this.releaseAt ? `New blocks auto-release ${new Date(this.releaseAt).toLocaleString()}.` : rel.value ? "Pick a time in the future." : "Leave empty to block permanently.";
|
|
2370
|
+
});
|
|
2371
|
+
this.paintSelBar(this.getSelection());
|
|
2372
|
+
}
|
|
2373
|
+
toggleCategory(catKey) {
|
|
2374
|
+
const labels = [];
|
|
2375
|
+
for (const [label, seat] of this.labelToSeat.entries()) {
|
|
2376
|
+
if (seat.categoryKey === catKey && this.isBlockSelectable(label)) labels.push(label);
|
|
2377
|
+
}
|
|
2378
|
+
this.toggleLabels(labels);
|
|
2379
|
+
}
|
|
2380
|
+
/** A category/filter is a real toggle: add the missing seats, or remove the
|
|
2381
|
+
* whole group when every eligible seat in it is already selected. */
|
|
2382
|
+
toggleLabels(labels) {
|
|
2383
|
+
if (!this.renderer) return;
|
|
2384
|
+
const eligible = labels.filter((label) => this.labelToSeat.has(label) && this.isBlockSelectable(label));
|
|
2385
|
+
if (!eligible.length) return;
|
|
2386
|
+
const selected = new Set(this.selectionLabels());
|
|
2387
|
+
const allSelected = eligible.every((label) => selected.has(label));
|
|
2388
|
+
if (allSelected) {
|
|
2389
|
+
const ids = eligible.map((label) => this.labelToId.get(label)).filter((id) => Boolean(id));
|
|
2390
|
+
this.renderer.deselect(ids);
|
|
2391
|
+
} else {
|
|
2392
|
+
this.renderer.selectByLabels(eligible);
|
|
2393
|
+
}
|
|
2394
|
+
this.syncSelection();
|
|
2395
|
+
}
|
|
2396
|
+
isBlockSelectable(label) {
|
|
2397
|
+
const status = this.status.get(label) ?? "free";
|
|
2398
|
+
return status === "free" || status === "blocked";
|
|
2399
|
+
}
|
|
2400
|
+
paintSelBar(seats) {
|
|
2401
|
+
if (!this.els.selnum) return;
|
|
2402
|
+
this.els.selnum.textContent = seats.length.toLocaleString();
|
|
2403
|
+
const freeCount = seats.filter((s) => (this.status.get(s.label) ?? "free") === "free").length;
|
|
2404
|
+
const blockedCount = seats.filter((s) => this.status.get(s.label) === "blocked").length;
|
|
2405
|
+
this.els.selmeta.textContent = seats.length ? `${freeCount.toLocaleString()} available \xB7 ${blockedCount.toLocaleString()} blocked` : "selected";
|
|
2406
|
+
const blockButton = this.els.doblock;
|
|
2407
|
+
const unblockButton = this.els.dounblock;
|
|
2408
|
+
blockButton.disabled = freeCount === 0;
|
|
2409
|
+
unblockButton.disabled = blockedCount === 0;
|
|
2410
|
+
blockButton.textContent = freeCount ? `Block ${freeCount.toLocaleString()}` : "Block selected";
|
|
2411
|
+
unblockButton.textContent = blockedCount ? `Put ${blockedCount.toLocaleString()} on sale` : "Put back on sale";
|
|
2412
|
+
this.paintCategoryControls(seats);
|
|
2413
|
+
this.paintBlockedInventory();
|
|
2414
|
+
}
|
|
2415
|
+
paintCategoryControls(seats) {
|
|
2416
|
+
const selected = new Set(seats.map((seat) => seat.label));
|
|
2417
|
+
this.els.rail?.querySelectorAll("[data-cat]").forEach((button) => {
|
|
2418
|
+
const catKey = button.dataset.cat;
|
|
2419
|
+
const labels = [];
|
|
2420
|
+
for (const [label, seat] of this.labelToSeat.entries()) {
|
|
2421
|
+
if (seat.categoryKey === catKey && this.isBlockSelectable(label)) labels.push(label);
|
|
2422
|
+
}
|
|
2423
|
+
const picked = labels.filter((label) => selected.has(label)).length;
|
|
2424
|
+
const full = labels.length > 0 && picked === labels.length;
|
|
2425
|
+
const partial = picked > 0 && !full;
|
|
2426
|
+
button.disabled = labels.length === 0;
|
|
2427
|
+
button.classList.toggle("on", full);
|
|
2428
|
+
button.classList.toggle("partial", partial);
|
|
2429
|
+
button.setAttribute("aria-pressed", full ? "true" : partial ? "mixed" : "false");
|
|
2430
|
+
button.setAttribute("title", full ? `Remove all ${labels.length.toLocaleString()} seats in this category from the selection` : partial ? `Select the remaining ${(labels.length - picked).toLocaleString()} seats in this category` : `Select all ${labels.length.toLocaleString()} seats in this category`);
|
|
2431
|
+
const count = button.querySelector("[data-cat-count]");
|
|
2432
|
+
if (count) count.textContent = picked ? `${picked.toLocaleString()}/${labels.length.toLocaleString()}` : labels.length.toLocaleString();
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
filteredBlockedSeats() {
|
|
2436
|
+
const query = this.blockedQuery.trim().toLocaleLowerCase();
|
|
2437
|
+
const seats = [];
|
|
2438
|
+
for (const [label, seat] of this.labelToSeat.entries()) {
|
|
2439
|
+
if (this.status.get(label) !== "blocked") continue;
|
|
2440
|
+
const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
|
|
2441
|
+
if (this.blockedSection && sectionId !== this.blockedSection) continue;
|
|
2442
|
+
if (query) {
|
|
2443
|
+
const category = this.doc?.categories.find((item) => item.key === seat.categoryKey)?.label ?? seat.categoryKey;
|
|
2444
|
+
const section = this.sectionLabelById.get(sectionId) ?? "Other seats";
|
|
2445
|
+
const object = this.doc?.objects.find((item) => item.id === seat.rowId);
|
|
2446
|
+
const objectLabel = object?.type === "row" || object?.type === "table" ? object.label : "";
|
|
2447
|
+
const haystack = `${label} ${category} ${section} ${objectLabel}`.toLocaleLowerCase();
|
|
2448
|
+
if (!haystack.includes(query)) continue;
|
|
2449
|
+
}
|
|
2450
|
+
seats.push(seat);
|
|
2451
|
+
}
|
|
2452
|
+
return seats.sort((a, b) => a.label.localeCompare(b.label, void 0, { numeric: true, sensitivity: "base" }));
|
|
2453
|
+
}
|
|
2454
|
+
paintBlockedInventory() {
|
|
2455
|
+
if (!this.els.blockedlist) return;
|
|
2456
|
+
const allBlocked = [...this.status.entries()].filter(([, status]) => status === "blocked").length;
|
|
2457
|
+
const filtered = this.filteredBlockedSeats();
|
|
2458
|
+
const visible = filtered.slice(0, this.blockedResultLimit);
|
|
2459
|
+
const selected = new Set(this.selectionLabels());
|
|
2460
|
+
const selectedResults = filtered.filter((seat) => selected.has(seat.label)).length;
|
|
2461
|
+
const allResultsSelected = filtered.length > 0 && selectedResults === filtered.length;
|
|
2462
|
+
this.els.blockedcount.textContent = allBlocked.toLocaleString();
|
|
2463
|
+
this.els.blockedshowing.textContent = filtered.length ? `Showing ${visible.length.toLocaleString()} of ${filtered.length.toLocaleString()}` : allBlocked ? "No matches" : "No blocked seats";
|
|
2464
|
+
const selectResults = this.els.selblocked;
|
|
2465
|
+
selectResults.disabled = filtered.length === 0;
|
|
2466
|
+
selectResults.textContent = allResultsSelected ? `Remove ${filtered.length.toLocaleString()} results` : `Select ${filtered.length.toLocaleString()} results`;
|
|
2467
|
+
this.els.blockedlist.innerHTML = visible.length ? visible.map((seat) => {
|
|
2468
|
+
const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
|
|
2469
|
+
const section = this.sectionLabelById.get(sectionId) ?? "Other seats";
|
|
2470
|
+
const category = this.doc?.categories.find((item) => item.key === seat.categoryKey)?.label ?? seat.categoryKey;
|
|
2471
|
+
const isSelected = selected.has(seat.label);
|
|
2472
|
+
return `<button type="button" class="slm-blockeditem${isSelected ? " on" : ""}" data-blocked-label="${esc(seat.label)}" aria-pressed="${isSelected}">
|
|
2473
|
+
<span class="slm-blockedcheck" aria-hidden="true">\u2713</span>
|
|
2474
|
+
<span class="slm-blockedcopy"><span class="slm-blockedlabel">${esc(seat.label)}</span>
|
|
2475
|
+
<span class="slm-blockedmeta">${esc(section)} \xB7 ${esc(category)}</span></span>
|
|
2476
|
+
</button>`;
|
|
2477
|
+
}).join("") + (filtered.length > visible.length ? `<button type="button" class="slm-blockedmore" data-blocked-more>Show 100 more</button>` : "") : `<div class="slm-blockedempty">${allBlocked ? "No blocked seats match this search or section." : "No seats are blocked. Newly blocked seats will appear here."}</div>`;
|
|
2478
|
+
const markAll = this.els.markall;
|
|
2479
|
+
const armed = markAll.dataset.confirm === "true";
|
|
2480
|
+
markAll.disabled = allBlocked === 0;
|
|
2481
|
+
markAll.textContent = armed ? `Confirm: put all ${allBlocked.toLocaleString()} on sale` : `Put all ${allBlocked.toLocaleString()} blocked seats on sale`;
|
|
2482
|
+
}
|
|
2483
|
+
confirmUnblockAll() {
|
|
2484
|
+
const button = this.els.markall;
|
|
2485
|
+
if (!button || button.disabled) return;
|
|
2486
|
+
if (button.dataset.confirm === "true") {
|
|
2487
|
+
this.resetUnblockAllConfirm();
|
|
2488
|
+
void this.unblockAll();
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
button.dataset.confirm = "true";
|
|
2492
|
+
button.classList.add("danger");
|
|
2493
|
+
this.els.markallnote.textContent = "This changes every blocked seat. Click the red button again to confirm.";
|
|
2494
|
+
this.paintBlockedInventory();
|
|
2495
|
+
if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
|
|
2496
|
+
this.unblockAllConfirmTimer = setTimeout(() => this.resetUnblockAllConfirm(), 6e3);
|
|
2497
|
+
}
|
|
2498
|
+
resetUnblockAllConfirm() {
|
|
2499
|
+
if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
|
|
2500
|
+
this.unblockAllConfirmTimer = null;
|
|
2501
|
+
const button = this.els.markall;
|
|
2502
|
+
if (!button) return;
|
|
2503
|
+
delete button.dataset.confirm;
|
|
2504
|
+
button.classList.remove("danger");
|
|
2505
|
+
if (this.els.markallnote) this.els.markallnote.textContent = "For a full reset only. You will be asked to confirm.";
|
|
2506
|
+
this.paintBlockedInventory();
|
|
2507
|
+
}
|
|
2508
|
+
// ---- toast / done / fail --------------------------------------------------
|
|
2509
|
+
done(action, labels, msg) {
|
|
2510
|
+
this.toastOk(msg);
|
|
2511
|
+
if (labels.length) {
|
|
2512
|
+
const activity = action === "block" ? this.pushActivity(labels, "blocked", "blocked") : action === "unblock" || action === "unblockAll" ? this.pushActivity(labels, "unblocked", "free") : action === "cancelBooking" ? this.pushActivity(labels, "cancelled", "free") : null;
|
|
2513
|
+
if (activity) this.paintSpatialActivity(activity);
|
|
2514
|
+
}
|
|
2515
|
+
if (action !== "setHoldTtl") void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
|
|
2516
|
+
this.opts.onActionComplete?.({ action, labels, count: labels.length });
|
|
2517
|
+
}
|
|
2518
|
+
toastOk(msg) {
|
|
2519
|
+
this.toast(msg, "ok");
|
|
2520
|
+
}
|
|
2521
|
+
toastErr(msg) {
|
|
2522
|
+
this.toast(msg, "err");
|
|
2523
|
+
}
|
|
2524
|
+
toast(msg, kind) {
|
|
2525
|
+
const el = this.els.toast;
|
|
2526
|
+
if (!el) return;
|
|
2527
|
+
el.textContent = msg;
|
|
2528
|
+
el.className = `slm-toast on ${kind}`;
|
|
2529
|
+
if (this.toastTimer) clearTimeout(this.toastTimer);
|
|
2530
|
+
this.toastTimer = setTimeout(() => {
|
|
2531
|
+
el.className = "slm-toast";
|
|
2532
|
+
}, 3200);
|
|
2533
|
+
}
|
|
2534
|
+
fail(err) {
|
|
2535
|
+
this.opts.onError?.(err);
|
|
2536
|
+
if (this.els.rail) this.els.rail.innerHTML = `<div class="slm-empty">Couldn't load this event. Check the event key and token.</div>`;
|
|
2537
|
+
}
|
|
2538
|
+
};
|
|
2539
|
+
|
|
2540
|
+
export {
|
|
2541
|
+
SeatManager
|
|
2542
|
+
};
|
|
2543
|
+
//# sourceMappingURL=chunk-CF5MS7UR.js.map
|