@seatlayer/js 0.35.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2890 -113
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1171 -5
- package/dist/index.d.ts +1171 -5
- package/dist/index.js +2868 -111
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __typeError = (msg) => {
|
|
9
|
+
throw TypeError(msg);
|
|
10
|
+
};
|
|
8
11
|
var __export = (target, all) => {
|
|
9
12
|
for (var name in all)
|
|
10
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -26,18 +29,44 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
29
|
mod
|
|
27
30
|
));
|
|
28
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
|
+
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
|
|
33
|
+
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
|
|
34
|
+
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
|
|
35
|
+
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
|
|
36
|
+
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
|
|
29
37
|
|
|
30
38
|
// src/index.ts
|
|
31
39
|
var index_exports = {};
|
|
32
40
|
__export(index_exports, {
|
|
33
41
|
ApiError: () => ApiError,
|
|
42
|
+
BuyerAccessContext: () => BuyerAccessContext,
|
|
43
|
+
BuyerAccessUnavailableError: () => BuyerAccessUnavailableError,
|
|
44
|
+
BuyerRealtimeClient: () => BuyerRealtimeClient,
|
|
45
|
+
ChannelsMode: () => ChannelsMode,
|
|
34
46
|
EmbeddedDesigner: () => EmbeddedDesigner,
|
|
35
47
|
ManageApi: () => ManageApi,
|
|
36
48
|
ManageApiError: () => ManageApiError,
|
|
49
|
+
PUBLIC_CHANNEL_ID: () => PUBLIC_CHANNEL_ID,
|
|
50
|
+
PUBLIC_CHANNEL_NAME: () => PUBLIC_CHANNEL_NAME,
|
|
37
51
|
SeatManager: () => SeatManager,
|
|
38
52
|
SeatPicker: () => SeatPicker,
|
|
39
53
|
SeatingChart: () => SeatingChart,
|
|
40
|
-
|
|
54
|
+
accessIntentLabel: () => accessIntentLabel,
|
|
55
|
+
accessLine: () => accessLine,
|
|
56
|
+
attachPickerFrame: () => attachPickerFrame,
|
|
57
|
+
bucketRows: () => bucketRows,
|
|
58
|
+
bucketRowsHtml: () => bucketRowsHtml,
|
|
59
|
+
createBuyerAccessContext: () => createBuyerAccessContext,
|
|
60
|
+
createControllerSink: () => createControllerSink,
|
|
61
|
+
dropReviewRows: () => dropReviewRows,
|
|
62
|
+
markerOf: () => markerOf,
|
|
63
|
+
mutationCount: () => mutationCount,
|
|
64
|
+
needsMoveConfirmation: () => needsMoveConfirmation,
|
|
65
|
+
planAssignment: () => planAssignment,
|
|
66
|
+
retryAfterCopy: () => retryAfterCopy,
|
|
67
|
+
selectionSources: () => selectionSources,
|
|
68
|
+
stateBadge: () => stateBadge,
|
|
69
|
+
suggestMarker: () => suggestMarker
|
|
41
70
|
});
|
|
42
71
|
module.exports = __toCommonJS(index_exports);
|
|
43
72
|
|
|
@@ -55,63 +84,88 @@ var ApiError = class extends Error {
|
|
|
55
84
|
this.reason = reason;
|
|
56
85
|
}
|
|
57
86
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
body = JSON.stringify(init.body);
|
|
65
|
-
}
|
|
66
|
-
const res = await fetch(`${base}${path}`, { method, headers, body, credentials: "omit" });
|
|
67
|
-
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
68
|
-
const data = isJson ? await res.json().catch(() => null) : null;
|
|
69
|
-
if (!res.ok) {
|
|
70
|
-
const err = data;
|
|
71
|
-
throw new ApiError(
|
|
72
|
-
res.status,
|
|
73
|
-
err?.error ?? `request_failed_${res.status}`,
|
|
74
|
-
err?.code ?? err?.error,
|
|
75
|
-
err?.conflicts,
|
|
76
|
-
err?.reason
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
return data;
|
|
80
|
-
}
|
|
87
|
+
var OBJECT_UNAVAILABLE_CODES = {
|
|
88
|
+
seat_conflict: "taken",
|
|
89
|
+
conflict: "taken",
|
|
90
|
+
channel_assignment_conflict: "ineligible",
|
|
91
|
+
allocation_exhausted: "exhausted"
|
|
92
|
+
};
|
|
81
93
|
var PubApi = class {
|
|
82
|
-
constructor(base) {
|
|
94
|
+
constructor(base, options = {}) {
|
|
83
95
|
this.base = base;
|
|
84
96
|
this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
97
|
+
this.access = options.access;
|
|
98
|
+
this.onObjectUnavailable = options.onObjectUnavailable;
|
|
99
|
+
}
|
|
100
|
+
/** True when this client is bound to a buyer access session. */
|
|
101
|
+
get accessScoped() {
|
|
102
|
+
return !!this.access?.configured;
|
|
103
|
+
}
|
|
104
|
+
async request(path, init = {}, retried = false) {
|
|
105
|
+
const method = init.method ?? "GET";
|
|
106
|
+
const headers = {};
|
|
107
|
+
let body;
|
|
108
|
+
if (init.body !== void 0) {
|
|
109
|
+
headers["Content-Type"] = "application/json";
|
|
110
|
+
body = JSON.stringify(init.body);
|
|
111
|
+
}
|
|
112
|
+
const authorization = await this.access?.authorization(retried ? "unauthorized" : "initial");
|
|
113
|
+
if (authorization) headers.Authorization = authorization;
|
|
114
|
+
const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
|
|
115
|
+
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
116
|
+
const data = isJson ? await res.json().catch(() => null) : null;
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const err = data;
|
|
119
|
+
const code = err?.code ?? err?.error;
|
|
120
|
+
if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
|
|
121
|
+
const refreshed = await this.access.handleFailure(res.status, code);
|
|
122
|
+
if (refreshed && !retried) return this.request(path, init, true);
|
|
123
|
+
}
|
|
124
|
+
if (res.status === 409) {
|
|
125
|
+
const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
|
|
126
|
+
const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
|
|
127
|
+
if (reason) this.onObjectUnavailable?.({ labels, reason, code });
|
|
128
|
+
}
|
|
129
|
+
throw new ApiError(
|
|
130
|
+
res.status,
|
|
131
|
+
err?.error ?? `request_failed_${res.status}`,
|
|
132
|
+
code,
|
|
133
|
+
err?.conflicts,
|
|
134
|
+
err?.reason
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return data;
|
|
85
138
|
}
|
|
86
139
|
chart(key) {
|
|
87
|
-
return request(
|
|
140
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
|
|
88
141
|
}
|
|
89
142
|
objects(key) {
|
|
90
|
-
return request(
|
|
143
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
|
|
91
144
|
}
|
|
92
145
|
hold(key, selections, ttlMs, replaceHoldId) {
|
|
93
|
-
return request(
|
|
146
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
|
|
94
147
|
method: "POST",
|
|
95
|
-
body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} }
|
|
148
|
+
body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
|
|
149
|
+
labels: selections.map((s) => s.label)
|
|
96
150
|
});
|
|
97
151
|
}
|
|
98
152
|
// `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
|
|
99
153
|
// window — both are part of the route contract, and dropping either here made
|
|
100
154
|
// the SDK quietly pick venue-wide and hold for the server default instead.
|
|
101
155
|
bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
|
|
102
|
-
return request(
|
|
156
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
|
|
103
157
|
method: "POST",
|
|
104
158
|
body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
|
|
105
159
|
});
|
|
106
160
|
}
|
|
107
161
|
resume(key, holdId) {
|
|
108
|
-
return request(
|
|
162
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
|
|
109
163
|
method: "POST",
|
|
110
164
|
body: { holdId }
|
|
111
165
|
});
|
|
112
166
|
}
|
|
113
167
|
release(key, labels, holdId) {
|
|
114
|
-
return request(
|
|
168
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
|
|
115
169
|
method: "POST",
|
|
116
170
|
body: { labels, holdId }
|
|
117
171
|
});
|
|
@@ -119,17 +173,662 @@ var PubApi = class {
|
|
|
119
173
|
/** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
|
|
120
174
|
* (reason: expired | extend_limit | not_found | not_active) if it can't. */
|
|
121
175
|
extend(key, holdId, ttlMs) {
|
|
122
|
-
return request(
|
|
176
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
|
|
123
177
|
method: "POST",
|
|
124
178
|
body: { holdId, ...ttlMs ? { ttlMs } : {} }
|
|
125
179
|
});
|
|
126
180
|
}
|
|
127
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Mint a one-use subscribe ticket for the next socket attempt (protocol doc
|
|
183
|
+
* §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
|
|
184
|
+
* already apply; the socket then carries only the short-lived ticket, in its
|
|
185
|
+
* subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
|
|
186
|
+
*/
|
|
187
|
+
subscribeTicket(key) {
|
|
188
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: {}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The subscribe URL. Never carries a credential — not the bearer, not the
|
|
195
|
+
* ticket. Query parameters are diagnostics only.
|
|
196
|
+
*/
|
|
197
|
+
subscribeUrl(key) {
|
|
128
198
|
const wsBase = this.base.replace(/^http/, "ws");
|
|
129
199
|
const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
|
|
130
200
|
return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
|
|
131
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* What PickerController opens its own socket with.
|
|
204
|
+
*
|
|
205
|
+
* Empty for an access-scoped client: a private scope authenticates with a
|
|
206
|
+
* subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
|
|
207
|
+
* BuyerRealtimeClient owns that socket instead and the controller skips its
|
|
208
|
+
* own (an empty URL is its documented "no live feed" contract). A tokenless
|
|
209
|
+
* public client returns exactly the URL it always has, so nothing about the
|
|
210
|
+
* public picker's realtime path changes.
|
|
211
|
+
*/
|
|
212
|
+
socketUrl(key) {
|
|
213
|
+
return this.accessScoped ? "" : this.subscribeUrl(key);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// src/buyerAccess.ts
|
|
218
|
+
var BuyerAccessUnavailableError = class extends Error {
|
|
219
|
+
constructor(event) {
|
|
220
|
+
super(`buyer_access_unavailable:${event.reason}`);
|
|
221
|
+
this.name = "BuyerAccessUnavailableError";
|
|
222
|
+
this.reason = event.reason;
|
|
223
|
+
this.code = event.code;
|
|
224
|
+
this.status = event.status;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
|
|
228
|
+
var RECOVERABLE = /* @__PURE__ */ new Set([
|
|
229
|
+
"paused",
|
|
230
|
+
"provider_failed",
|
|
231
|
+
"channel_denied"
|
|
232
|
+
]);
|
|
233
|
+
function classifyAccessFailure(status, code) {
|
|
234
|
+
switch (code) {
|
|
235
|
+
case "buyer_access_invalid":
|
|
236
|
+
return "invalid";
|
|
237
|
+
case "buyer_access_revoked":
|
|
238
|
+
return "revoked";
|
|
239
|
+
case "buyer_access_origin_mismatch":
|
|
240
|
+
return "origin_mismatch";
|
|
241
|
+
case "buyer_access_event_mismatch":
|
|
242
|
+
return "event_mismatch";
|
|
243
|
+
case "buyer_access_mode_mismatch":
|
|
244
|
+
return "mode_mismatch";
|
|
245
|
+
case "channel_access_denied":
|
|
246
|
+
return "channel_denied";
|
|
247
|
+
case "channel_paused":
|
|
248
|
+
return "paused";
|
|
249
|
+
case "invalid_channel_scope":
|
|
250
|
+
return "invalid_scope";
|
|
251
|
+
default:
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
if (status === 401) return "invalid";
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
function isAccessExpiry(status, code) {
|
|
258
|
+
return status === 401 && !!code && EXPIRED_CODES.has(code);
|
|
259
|
+
}
|
|
260
|
+
var DEFAULT_SKEW_MS = 3e4;
|
|
261
|
+
var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
|
|
262
|
+
var BuyerAccessContext = class {
|
|
263
|
+
constructor(options) {
|
|
264
|
+
__privateAdd(this, _BuyerAccessContext_instances);
|
|
265
|
+
/** Private field: not enumerable, not spreadable, not serializable. */
|
|
266
|
+
__privateAdd(this, _token, null);
|
|
267
|
+
__privateAdd(this, _expiresAt, 0);
|
|
268
|
+
__privateAdd(this, _provider);
|
|
269
|
+
__privateAdd(this, _skewMs);
|
|
270
|
+
__privateAdd(this, _inflight, null);
|
|
271
|
+
__privateAdd(this, _terminal, null);
|
|
272
|
+
/** The most recent failure, terminal or not — so one cause reports once. */
|
|
273
|
+
__privateAdd(this, _lastFailure, null);
|
|
274
|
+
__privateAdd(this, _onExpired);
|
|
275
|
+
__privateAdd(this, _onUnavailable);
|
|
276
|
+
/** Decided once, at construction. See the `configured` getter. */
|
|
277
|
+
__privateAdd(this, _configured, false);
|
|
278
|
+
__privateSet(this, _provider, options.provider);
|
|
279
|
+
__privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
|
|
280
|
+
__privateSet(this, _onExpired, options.onExpired);
|
|
281
|
+
__privateSet(this, _onUnavailable, options.onUnavailable);
|
|
282
|
+
if (options.token) {
|
|
283
|
+
const seed = typeof options.token === "string" ? { token: options.token } : options.token;
|
|
284
|
+
__privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
|
|
285
|
+
}
|
|
286
|
+
__privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* True when this picker is access-scoped at all. A false here is the
|
|
290
|
+
* tokenless public picker, which must behave exactly as it always has.
|
|
291
|
+
*
|
|
292
|
+
* Answered from what the HOST asked for, never from live token state. It used
|
|
293
|
+
* to be `!!#provider || !!#token`, which quietly inverted this file's central
|
|
294
|
+
* rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
|
|
295
|
+
* the first refusal turned a configured context into an "unconfigured" one,
|
|
296
|
+
* `authorization()` then returned null instead of throwing, and the very next
|
|
297
|
+
* call went out with no bearer — the anonymous Public sale fallback this
|
|
298
|
+
* module exists to prevent. A provider host never saw it, because `#provider`
|
|
299
|
+
* held `configured` true. Found against a live worker in the M9 pass.
|
|
300
|
+
*/
|
|
301
|
+
get configured() {
|
|
302
|
+
return __privateGet(this, _configured);
|
|
303
|
+
}
|
|
304
|
+
/** Set once a state arrives that refreshing cannot clear. */
|
|
305
|
+
get unavailable() {
|
|
306
|
+
return __privateGet(this, _terminal);
|
|
307
|
+
}
|
|
308
|
+
/** True while a usable bearer is held (ignores skew). */
|
|
309
|
+
get hasToken() {
|
|
310
|
+
return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
|
|
311
|
+
}
|
|
312
|
+
/** Epoch ms the current token expires, or 0 when the host didn't say. */
|
|
313
|
+
get expiresAt() {
|
|
314
|
+
return __privateGet(this, _expiresAt);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The `Authorization` header value for a scoped operation.
|
|
318
|
+
*
|
|
319
|
+
* Returns null only when this context is not configured at all (the ordinary
|
|
320
|
+
* anonymous public picker). A configured context either returns a bearer or
|
|
321
|
+
* throws `BuyerAccessUnavailableError` — it never returns null, because a
|
|
322
|
+
* null here would send the request as anonymous Public sale.
|
|
323
|
+
*/
|
|
324
|
+
async authorization(reason = "initial") {
|
|
325
|
+
if (!this.configured) return null;
|
|
326
|
+
if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
|
|
329
|
+
if (stale) {
|
|
330
|
+
const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
|
|
331
|
+
const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
|
|
332
|
+
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
|
|
333
|
+
if (!token) {
|
|
334
|
+
throw new BuyerAccessUnavailableError(
|
|
335
|
+
__privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return `Bearer ${token}`;
|
|
339
|
+
}
|
|
340
|
+
return `Bearer ${__privateGet(this, _token)}`;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Handle a 401/403 from a scoped call. Returns true when the caller should
|
|
344
|
+
* retry the same request once with the refreshed bearer.
|
|
345
|
+
*/
|
|
346
|
+
async handleFailure(status, code) {
|
|
347
|
+
var _a;
|
|
348
|
+
if (!this.configured) return false;
|
|
349
|
+
if (isAccessExpiry(status, code)) {
|
|
350
|
+
__privateSet(this, _token, null);
|
|
351
|
+
__privateSet(this, _expiresAt, 0);
|
|
352
|
+
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
|
|
353
|
+
(_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
|
|
354
|
+
return !!token;
|
|
355
|
+
}
|
|
356
|
+
const reason = classifyAccessFailure(status, code);
|
|
357
|
+
if (reason) {
|
|
358
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
/** Host-driven re-acquisition (after the buyer signs in again, say). */
|
|
364
|
+
async refresh(reason = "manual") {
|
|
365
|
+
__privateSet(this, _terminal, null);
|
|
366
|
+
__privateSet(this, _lastFailure, null);
|
|
367
|
+
__privateSet(this, _token, null);
|
|
368
|
+
__privateSet(this, _expiresAt, 0);
|
|
369
|
+
return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
|
|
370
|
+
}
|
|
371
|
+
/** Drop the bearer. Called on destroy so nothing outlives the widget. */
|
|
372
|
+
clear() {
|
|
373
|
+
__privateSet(this, _token, null);
|
|
374
|
+
__privateSet(this, _expiresAt, 0);
|
|
375
|
+
__privateSet(this, _inflight, null);
|
|
376
|
+
}
|
|
377
|
+
/** Redaction: the bearer must not survive a stringify or an interpolation. */
|
|
378
|
+
toJSON() {
|
|
379
|
+
return { configured: this.configured, hasToken: this.hasToken };
|
|
380
|
+
}
|
|
381
|
+
toString() {
|
|
382
|
+
return "[BuyerAccessContext redacted]";
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
_token = new WeakMap();
|
|
386
|
+
_expiresAt = new WeakMap();
|
|
387
|
+
_provider = new WeakMap();
|
|
388
|
+
_skewMs = new WeakMap();
|
|
389
|
+
_inflight = new WeakMap();
|
|
390
|
+
_terminal = new WeakMap();
|
|
391
|
+
_lastFailure = new WeakMap();
|
|
392
|
+
_onExpired = new WeakMap();
|
|
393
|
+
_onUnavailable = new WeakMap();
|
|
394
|
+
_configured = new WeakMap();
|
|
395
|
+
_BuyerAccessContext_instances = new WeakSet();
|
|
396
|
+
// ---- internals ------------------------------------------------------------
|
|
397
|
+
accept_fn = function(next) {
|
|
398
|
+
if (!next || typeof next.token !== "string" || !next.token) return null;
|
|
399
|
+
__privateSet(this, _token, next.token);
|
|
400
|
+
__privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
|
|
401
|
+
return __privateGet(this, _token);
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* One provider call at a time. Several operations racing an expiry (chart +
|
|
405
|
+
* objects + a socket ticket) must not mint several sessions — the guide's
|
|
406
|
+
* rotate-on-retry rule would revoke the ones they didn't observe.
|
|
407
|
+
*/
|
|
408
|
+
renew_fn = function(reason, code) {
|
|
409
|
+
if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
|
|
410
|
+
const provider = __privateGet(this, _provider);
|
|
411
|
+
if (!provider) {
|
|
412
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
|
|
413
|
+
return Promise.resolve(null);
|
|
414
|
+
}
|
|
415
|
+
const run = (async () => {
|
|
416
|
+
try {
|
|
417
|
+
const next = await provider({ reason });
|
|
418
|
+
const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
|
|
419
|
+
if (!token) {
|
|
420
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
return token;
|
|
424
|
+
} catch {
|
|
425
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
426
|
+
return null;
|
|
427
|
+
} finally {
|
|
428
|
+
__privateSet(this, _inflight, null);
|
|
429
|
+
}
|
|
430
|
+
})();
|
|
431
|
+
__privateSet(this, _inflight, run);
|
|
432
|
+
return run;
|
|
433
|
+
};
|
|
434
|
+
fail_fn = function(reason, code, status) {
|
|
435
|
+
var _a;
|
|
436
|
+
const event = {
|
|
437
|
+
reason,
|
|
438
|
+
code,
|
|
439
|
+
status,
|
|
440
|
+
// Unchanged: `retryable` means "the SAME request may succeed later".
|
|
441
|
+
// `provider_failed` is recoverable but not retryable — the host must fix
|
|
442
|
+
// its mint endpoint first — so the two sets are deliberately different.
|
|
443
|
+
retryable: reason === "paused" || reason === "channel_denied"
|
|
444
|
+
};
|
|
445
|
+
__privateSet(this, _lastFailure, event);
|
|
446
|
+
if (!RECOVERABLE.has(reason)) {
|
|
447
|
+
__privateSet(this, _terminal, event);
|
|
448
|
+
__privateSet(this, _token, null);
|
|
449
|
+
__privateSet(this, _expiresAt, 0);
|
|
450
|
+
}
|
|
451
|
+
(_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
|
|
452
|
+
return event;
|
|
453
|
+
};
|
|
454
|
+
function createBuyerAccessContext(options, hooks = {}) {
|
|
455
|
+
if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
|
|
456
|
+
return new BuyerAccessContext({
|
|
457
|
+
provider: options.buyerAccessTokenProvider,
|
|
458
|
+
token: options.buyerAccessToken,
|
|
459
|
+
...hooks
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/buyerRealtime.ts
|
|
464
|
+
var SEATLAYER_V1 = "seatlayer.v1";
|
|
465
|
+
var SEP = "\0";
|
|
466
|
+
var CLOSE_ACCESS_REVOKED = 4401;
|
|
467
|
+
var MAX_BACKOFF_MS = 15e3;
|
|
468
|
+
var PING_INTERVAL_MS = 25e3;
|
|
469
|
+
var PONG_GRACE_MS = 1e4;
|
|
470
|
+
var RESUME_ANSWER_GRACE_MS = 5e3;
|
|
471
|
+
function projectionFromSnapshot(frame) {
|
|
472
|
+
const fallback = typeof frame.default === "string" ? frame.default : "free";
|
|
473
|
+
const exceptions = {};
|
|
474
|
+
if (frame.seats && typeof frame.seats === "object") {
|
|
475
|
+
for (const [label, status] of Object.entries(frame.seats)) {
|
|
476
|
+
if (typeof status === "string" && status !== fallback) exceptions[label] = status;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return { default: fallback, exceptions };
|
|
480
|
+
}
|
|
481
|
+
function diffProjections(prev, next) {
|
|
482
|
+
if (!prev || prev.default !== next.default) return null;
|
|
483
|
+
const changes = [];
|
|
484
|
+
for (const [label, status] of Object.entries(next.exceptions)) {
|
|
485
|
+
if (prev.exceptions[label] !== status) changes.push({ label, status });
|
|
486
|
+
}
|
|
487
|
+
for (const label of Object.keys(prev.exceptions)) {
|
|
488
|
+
if (!(label in next.exceptions)) changes.push({ label, status: next.default });
|
|
489
|
+
}
|
|
490
|
+
return changes;
|
|
491
|
+
}
|
|
492
|
+
function applyChanges(projection, changes) {
|
|
493
|
+
for (const change of changes) {
|
|
494
|
+
if (change.status === projection.default) delete projection.exceptions[change.label];
|
|
495
|
+
else projection.exceptions[change.label] = change.status;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function assertCredentialFreeUrl(url) {
|
|
499
|
+
if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
|
|
500
|
+
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
501
|
+
}
|
|
502
|
+
if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
|
|
503
|
+
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
var BuyerRealtimeClient = class {
|
|
507
|
+
constructor(options) {
|
|
508
|
+
this.ws = null;
|
|
509
|
+
this.stopped = true;
|
|
510
|
+
this.attempt = 0;
|
|
511
|
+
this.reconnectTimer = null;
|
|
512
|
+
this.pingTimer = null;
|
|
513
|
+
this.pongTimer = null;
|
|
514
|
+
this.resumeTimer = null;
|
|
515
|
+
/** Our model of this scope's projection. Null until the first snapshot. */
|
|
516
|
+
this.projection = null;
|
|
517
|
+
/** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
|
|
518
|
+
this.version = null;
|
|
519
|
+
/** True once the 101 echoed `seatlayer.v1`. */
|
|
520
|
+
this.v1 = false;
|
|
521
|
+
/** Set when we offered v1 and the handshake came back without it — a proxy
|
|
522
|
+
* most likely stripped the header, so the next attempt selects the v1 frame
|
|
523
|
+
* format with the `?pv=1` marker instead (protocol doc §1). The marker
|
|
524
|
+
* selects a format and can never carry a credential or widen a scope. */
|
|
525
|
+
this.useQueryMarker = false;
|
|
526
|
+
this.hidden = null;
|
|
527
|
+
this.closedSections = null;
|
|
528
|
+
this.opts = options;
|
|
529
|
+
assertCredentialFreeUrl(options.url);
|
|
530
|
+
}
|
|
531
|
+
/** Negotiated protocol, for tests and diagnostics. */
|
|
532
|
+
get protocol() {
|
|
533
|
+
return this.ws ? this.v1 ? "v1" : "legacy" : null;
|
|
534
|
+
}
|
|
535
|
+
get snapshotVersion() {
|
|
536
|
+
return this.version;
|
|
537
|
+
}
|
|
538
|
+
start() {
|
|
539
|
+
if (!this.stopped) return;
|
|
540
|
+
this.stopped = false;
|
|
541
|
+
void this.connect();
|
|
542
|
+
}
|
|
543
|
+
/** Stop for good (destroy, or a revocation). Safe to call twice. */
|
|
544
|
+
stop() {
|
|
545
|
+
this.stopped = true;
|
|
546
|
+
this.clearTimers();
|
|
547
|
+
const ws = this.ws;
|
|
548
|
+
this.ws = null;
|
|
549
|
+
if (ws) {
|
|
550
|
+
ws.onopen = null;
|
|
551
|
+
ws.onmessage = null;
|
|
552
|
+
ws.onclose = null;
|
|
553
|
+
ws.onerror = null;
|
|
554
|
+
try {
|
|
555
|
+
ws.close();
|
|
556
|
+
} catch {
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
|
|
561
|
+
restart() {
|
|
562
|
+
this.stop();
|
|
563
|
+
this.projection = null;
|
|
564
|
+
this.version = null;
|
|
565
|
+
this.attempt = 0;
|
|
566
|
+
this.start();
|
|
567
|
+
}
|
|
568
|
+
// ---- connection -----------------------------------------------------------
|
|
569
|
+
async connect() {
|
|
570
|
+
if (this.stopped) return;
|
|
571
|
+
let protocols = [SEATLAYER_V1];
|
|
572
|
+
if (this.opts.mintTicket) {
|
|
573
|
+
let minted;
|
|
574
|
+
try {
|
|
575
|
+
minted = await this.opts.mintTicket();
|
|
576
|
+
} catch (err) {
|
|
577
|
+
this.reportIfAccessError(err);
|
|
578
|
+
this.scheduleReconnect();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (this.stopped) return;
|
|
582
|
+
if (minted?.protocols?.length) {
|
|
583
|
+
protocols = [...minted.protocols];
|
|
584
|
+
if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
|
|
585
|
+
} else if (minted?.ticket) {
|
|
586
|
+
protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const offeredResume = this.version !== null;
|
|
590
|
+
if (offeredResume) protocols.push(`sv.${this.version}`);
|
|
591
|
+
const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
|
|
592
|
+
assertCredentialFreeUrl(url);
|
|
593
|
+
let ws;
|
|
594
|
+
try {
|
|
595
|
+
const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
|
|
596
|
+
ws = make(url, protocols);
|
|
597
|
+
} catch {
|
|
598
|
+
this.scheduleReconnect();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
this.ws = ws;
|
|
602
|
+
ws.onopen = () => {
|
|
603
|
+
if (this.ws !== ws) return;
|
|
604
|
+
this.attempt = 0;
|
|
605
|
+
this.v1 = ws.protocol === SEATLAYER_V1;
|
|
606
|
+
if (!this.v1) this.useQueryMarker = true;
|
|
607
|
+
this.startKeepalive(ws);
|
|
608
|
+
if (offeredResume) {
|
|
609
|
+
this.resumeTimer = setTimeout(() => {
|
|
610
|
+
this.resumeTimer = null;
|
|
611
|
+
void this.opts.sink.resync();
|
|
612
|
+
}, RESUME_ANSWER_GRACE_MS);
|
|
613
|
+
} else {
|
|
614
|
+
void this.opts.sink.resync();
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
ws.onmessage = (event) => {
|
|
618
|
+
if (this.ws !== ws) return;
|
|
619
|
+
let parsed;
|
|
620
|
+
try {
|
|
621
|
+
parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
622
|
+
} catch {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
626
|
+
this.handleFrame(parsed);
|
|
627
|
+
};
|
|
628
|
+
ws.onclose = (event) => {
|
|
629
|
+
if (this.ws !== ws) return;
|
|
630
|
+
this.ws = null;
|
|
631
|
+
this.clearTimers();
|
|
632
|
+
if (event?.code === CLOSE_ACCESS_REVOKED) {
|
|
633
|
+
this.stopped = true;
|
|
634
|
+
this.opts.onAccessUnavailable?.({
|
|
635
|
+
reason: "revoked",
|
|
636
|
+
code: "access_revoked",
|
|
637
|
+
retryable: false
|
|
638
|
+
});
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
this.scheduleReconnect();
|
|
642
|
+
};
|
|
643
|
+
ws.onerror = () => {
|
|
644
|
+
try {
|
|
645
|
+
ws.close();
|
|
646
|
+
} catch {
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
handleFrame(frame) {
|
|
651
|
+
const type = typeof frame.type === "string" ? frame.type : "";
|
|
652
|
+
if (frame.protocol === 1) this.v1 = true;
|
|
653
|
+
if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
|
|
654
|
+
if (type === "pong") {
|
|
655
|
+
this.clearPongTimer();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
|
|
659
|
+
const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
|
|
660
|
+
const closed = Array.isArray(frame.closed) ? frame.closed : [];
|
|
661
|
+
const hKey = hidden.join(SEP);
|
|
662
|
+
const cKey = closed.join(SEP);
|
|
663
|
+
if (hKey !== this.hidden || cKey !== this.closedSections) {
|
|
664
|
+
this.hidden = hKey;
|
|
665
|
+
this.closedSections = cKey;
|
|
666
|
+
this.opts.sink.onSections?.(hidden, closed);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (type === "hidden") return;
|
|
670
|
+
if (type === "presence") {
|
|
671
|
+
this.opts.sink.onPresence?.({
|
|
672
|
+
shoppingSessions: Number(frame.shoppingSessions) || 0,
|
|
673
|
+
activeHolds: Number(frame.activeHolds) || 0
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (type === "allocation") {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (type === "snapshot" || !type && frame.seats) {
|
|
681
|
+
this.answered();
|
|
682
|
+
const next = projectionFromSnapshot(frame);
|
|
683
|
+
const changes = diffProjections(this.projection, next);
|
|
684
|
+
this.projection = next;
|
|
685
|
+
if (changes === null) void this.opts.sink.resync();
|
|
686
|
+
else if (changes.length) this.opts.sink.applyStatuses(changes);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (type === "delta" && Array.isArray(frame.changes)) {
|
|
690
|
+
this.answered();
|
|
691
|
+
const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
|
|
692
|
+
if (!changes.length) return;
|
|
693
|
+
if (this.projection) applyChanges(this.projection, changes);
|
|
694
|
+
this.opts.sink.applyStatuses(changes);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/** The server answered our resume; cancel the fallback resync. */
|
|
698
|
+
answered() {
|
|
699
|
+
if (!this.resumeTimer) return;
|
|
700
|
+
clearTimeout(this.resumeTimer);
|
|
701
|
+
this.resumeTimer = null;
|
|
702
|
+
}
|
|
703
|
+
reportIfAccessError(err) {
|
|
704
|
+
const reason = err?.reason;
|
|
705
|
+
if (err?.name !== "BuyerAccessUnavailableError") return;
|
|
706
|
+
this.stopped = true;
|
|
707
|
+
this.opts.onAccessUnavailable?.({
|
|
708
|
+
reason: reason ?? "invalid",
|
|
709
|
+
code: err.code,
|
|
710
|
+
status: err.status,
|
|
711
|
+
retryable: reason === "paused"
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
// ---- keepalive & backoff --------------------------------------------------
|
|
715
|
+
/**
|
|
716
|
+
* Liveness is ping/pong, and only ping/pong. A socket that receives nothing
|
|
717
|
+
* for minutes is the normal, correct state for a narrowly-scoped buyer on a
|
|
718
|
+
* busy event (protocol doc §5), so quiet time never triggers a reconnect.
|
|
719
|
+
*/
|
|
720
|
+
startKeepalive(ws) {
|
|
721
|
+
this.pingTimer = setInterval(() => {
|
|
722
|
+
if (this.ws !== ws) return;
|
|
723
|
+
try {
|
|
724
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
725
|
+
} catch {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
this.clearPongTimer();
|
|
729
|
+
this.pongTimer = setTimeout(() => {
|
|
730
|
+
this.pongTimer = null;
|
|
731
|
+
try {
|
|
732
|
+
ws.close();
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}, PONG_GRACE_MS);
|
|
736
|
+
}, PING_INTERVAL_MS);
|
|
737
|
+
}
|
|
738
|
+
scheduleReconnect() {
|
|
739
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
740
|
+
const attempt = Math.min(this.attempt++, 5);
|
|
741
|
+
const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
|
|
742
|
+
this.reconnectTimer = setTimeout(() => {
|
|
743
|
+
this.reconnectTimer = null;
|
|
744
|
+
void this.connect();
|
|
745
|
+
}, delay);
|
|
746
|
+
}
|
|
747
|
+
clearPongTimer() {
|
|
748
|
+
if (!this.pongTimer) return;
|
|
749
|
+
clearTimeout(this.pongTimer);
|
|
750
|
+
this.pongTimer = null;
|
|
751
|
+
}
|
|
752
|
+
clearTimers() {
|
|
753
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
754
|
+
this.pingTimer = null;
|
|
755
|
+
this.clearPongTimer();
|
|
756
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
757
|
+
this.reconnectTimer = null;
|
|
758
|
+
if (this.resumeTimer) clearTimeout(this.resumeTimer);
|
|
759
|
+
this.resumeTimer = null;
|
|
760
|
+
}
|
|
132
761
|
};
|
|
762
|
+
function rendererStatus(wire) {
|
|
763
|
+
if (wire === "blocked") return "not_for_sale";
|
|
764
|
+
if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
|
|
765
|
+
return "free";
|
|
766
|
+
}
|
|
767
|
+
function createControllerSink(controller, options = {}) {
|
|
768
|
+
const idsForLabel = (label) => {
|
|
769
|
+
const table = controller.tableSelection(label);
|
|
770
|
+
if (table) return table.physicalSeatIds;
|
|
771
|
+
const id = controller.idForLabel(label);
|
|
772
|
+
return id ? [id] : [];
|
|
773
|
+
};
|
|
774
|
+
return {
|
|
775
|
+
applyStatuses(changes) {
|
|
776
|
+
const held = controller.currentHold()?.labels ?? [];
|
|
777
|
+
const buckets = {
|
|
778
|
+
free: [],
|
|
779
|
+
held: [],
|
|
780
|
+
booked: [],
|
|
781
|
+
not_for_sale: []
|
|
782
|
+
};
|
|
783
|
+
const flashes = [];
|
|
784
|
+
const lost = [];
|
|
785
|
+
const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
|
|
786
|
+
for (const change of changes) {
|
|
787
|
+
const ids = idsForLabel(change.label);
|
|
788
|
+
if (!ids.length) continue;
|
|
789
|
+
const next = rendererStatus(change.status);
|
|
790
|
+
buckets[next].push(...ids);
|
|
791
|
+
if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
|
|
792
|
+
const color = next === "held" ? "#f4b740" : "#f43f5e";
|
|
793
|
+
for (const id of ids) flashes.push({ id, color });
|
|
794
|
+
}
|
|
795
|
+
if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
|
|
796
|
+
lost.push(change.label);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
for (const status of ["free", "held", "booked", "not_for_sale"]) {
|
|
800
|
+
if (buckets[status].length) controller.setStatus(buckets[status], status);
|
|
801
|
+
}
|
|
802
|
+
for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
|
|
803
|
+
if (lost.length) {
|
|
804
|
+
const ids = lost.flatMap((label) => idsForLabel(label));
|
|
805
|
+
if (ids.length) controller.deselect(ids);
|
|
806
|
+
const ineligible = changes.some(
|
|
807
|
+
(c) => c.status === "blocked" && lost.includes(c.label)
|
|
808
|
+
);
|
|
809
|
+
options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
|
|
810
|
+
}
|
|
811
|
+
options.onStatusChange?.();
|
|
812
|
+
},
|
|
813
|
+
async resync() {
|
|
814
|
+
await controller.refresh();
|
|
815
|
+
},
|
|
816
|
+
/**
|
|
817
|
+
* Section availability moved. Statuses are re-pulled so the map repaints.
|
|
818
|
+
*
|
|
819
|
+
* Known limit: rebuilding the chart when a section is newly HIDDEN (its
|
|
820
|
+
* seats are stripped, not greyed) lives inside PickerController's own
|
|
821
|
+
* socket handler and has no public entry point, so an access-scoped picker
|
|
822
|
+
* repaints statuses but does not restructure the chart until its next
|
|
823
|
+
* mount. Closing/opening a section — the common mid-sale move — is a
|
|
824
|
+
* status-level change and is handled here in full.
|
|
825
|
+
*/
|
|
826
|
+
onSections(hidden, closed) {
|
|
827
|
+
void controller.refresh();
|
|
828
|
+
options.onSections?.(hidden, closed);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
133
832
|
|
|
134
833
|
// src/seatLayerBrand.ts
|
|
135
834
|
var SEATLAYER_ATTRIBUTION_MARK_SVG = '<svg viewBox="0 0 64 56" width="12" height="11" fill="none" aria-hidden="true" focusable="false" style="display:block"><path d="M4 13 Q16 6 29 7 L28.5 17 Q17 16.5 7 22 Z" fill="#f4b740"/><path d="M4 13 Q16 6 29 7 L28.5 17 Q17 16.5 7 22 Z" fill="#f4b740" transform="translate(64 0) scale(-1 1)"/><path d="M8 28 Q18 22 28.6 23 L28.2 32 Q18 28.5 10 36 Z" fill="#fcf7ee"/><path d="M8 28 Q18 22 28.6 23 L28.2 32 Q18 28.5 10 36 Z" fill="#fcf7ee" transform="translate(64 0) scale(-1 1)"/><path d="M11 41 Q19 35 28.2 36 L27.8 45 Q19.5 42 13.5 49 Z" fill="#fcf7ee"/><path d="M11 41 Q19 35 28.2 36 L27.8 45 Q19.5 42 13.5 49 Z" fill="#fcf7ee" transform="translate(64 0) scale(-1 1)"/></svg>';
|
|
@@ -157,12 +856,21 @@ var SeatingChart = class {
|
|
|
157
856
|
this.tipEl = null;
|
|
158
857
|
this.tipPos = { x: 0, y: 0 };
|
|
159
858
|
this.onTipMove = null;
|
|
859
|
+
this.realtime = null;
|
|
160
860
|
if (!options || typeof options !== "object") throw new Error("seatmap: options object is required");
|
|
161
861
|
if (!options.container) throw new Error("seatmap: `container` is required");
|
|
162
862
|
if (!options.event || typeof options.event !== "string") throw new Error("seatmap: `event` key is required");
|
|
163
863
|
this.opts = options;
|
|
164
864
|
this.publicKey = options.publicKey;
|
|
165
|
-
|
|
865
|
+
this.access = createBuyerAccessContext(options, {
|
|
866
|
+
onExpired: (event) => this.opts.onAccessExpired?.(event),
|
|
867
|
+
onUnavailable: (event) => this.opts.onAccessUnavailable?.(event)
|
|
868
|
+
});
|
|
869
|
+
const api = new PubApi((options.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, ""), {
|
|
870
|
+
access: this.access ?? void 0,
|
|
871
|
+
onObjectUnavailable: (event) => this.opts.onSelectedObjectUnavailable?.(event)
|
|
872
|
+
});
|
|
873
|
+
this.api = api;
|
|
166
874
|
this.controller = new import_core.PickerController({
|
|
167
875
|
transport: api,
|
|
168
876
|
eventKey: options.event,
|
|
@@ -208,6 +916,7 @@ var SeatingChart = class {
|
|
|
208
916
|
return this;
|
|
209
917
|
}
|
|
210
918
|
this.controller.setViewMode(this.opts.initialView ?? "flat");
|
|
919
|
+
this.startRealtime();
|
|
211
920
|
this.mode_ = info.mode === "test" ? "test" : "live";
|
|
212
921
|
if (this.opts.seatTooltip !== false) {
|
|
213
922
|
const tip = document.createElement("div");
|
|
@@ -459,7 +1168,45 @@ var SeatingChart = class {
|
|
|
459
1168
|
return this.controller.releaseLabels(labels);
|
|
460
1169
|
}
|
|
461
1170
|
/** Tear everything down: close the socket, stop timers, drop the canvas. */
|
|
1171
|
+
/**
|
|
1172
|
+
* Realtime for an access-scoped chart.
|
|
1173
|
+
*
|
|
1174
|
+
* A tokenless chart never gets here: `access` is null, `PubApi.socketUrl()`
|
|
1175
|
+
* returns the URL it always has, and PickerController keeps its own socket
|
|
1176
|
+
* and its own legacy frames. Nothing about the public path changes.
|
|
1177
|
+
*/
|
|
1178
|
+
startRealtime() {
|
|
1179
|
+
if (!this.access?.configured || this.realtime) return;
|
|
1180
|
+
this.realtime = new BuyerRealtimeClient({
|
|
1181
|
+
url: this.api.subscribeUrl(this.opts.event),
|
|
1182
|
+
mintTicket: () => this.api.subscribeTicket(this.opts.event),
|
|
1183
|
+
onAccessUnavailable: (event) => this.opts.onAccessUnavailable?.(event),
|
|
1184
|
+
sink: createControllerSink(this.controller, {
|
|
1185
|
+
flashOnLiveChange: true,
|
|
1186
|
+
onSelectedObjectUnavailable: (labels, reason) => this.opts.onSelectedObjectUnavailable?.({ labels, reason })
|
|
1187
|
+
})
|
|
1188
|
+
});
|
|
1189
|
+
this.realtime.start();
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Re-acquire the buyer access session — call after your app has re-authorized
|
|
1193
|
+
* the buyer (a revoked session cannot be recovered any other way). Resolves
|
|
1194
|
+
* true when a fresh bearer is held; the realtime feed restarts with it.
|
|
1195
|
+
*/
|
|
1196
|
+
async refreshAccess() {
|
|
1197
|
+
if (!this.access?.configured) return false;
|
|
1198
|
+
const ok = await this.access.refresh("manual");
|
|
1199
|
+
if (ok) {
|
|
1200
|
+
await this.controller.refresh();
|
|
1201
|
+
this.realtime?.restart();
|
|
1202
|
+
if (!this.realtime) this.startRealtime();
|
|
1203
|
+
}
|
|
1204
|
+
return ok;
|
|
1205
|
+
}
|
|
462
1206
|
destroy() {
|
|
1207
|
+
this.realtime?.stop();
|
|
1208
|
+
this.realtime = null;
|
|
1209
|
+
this.access?.clear();
|
|
463
1210
|
if (this.hostEl && this.onTipMove) this.hostEl.removeEventListener("mousemove", this.onTipMove);
|
|
464
1211
|
this.tipEl = null;
|
|
465
1212
|
this.onTipMove = null;
|
|
@@ -1180,7 +1927,12 @@ var STYLE_ID = "seatlayer-picker-style";
|
|
|
1180
1927
|
var CSS = `
|
|
1181
1928
|
.sl-picker{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:420px;overflow:hidden;
|
|
1182
1929
|
background:var(--sl-bg);color:var(--sl-text);font-family:var(--sl-font);border-radius:var(--sl-radius);
|
|
1183
|
-
--sl-r-sm:calc(var(--sl-radius) * .55)
|
|
1930
|
+
--sl-r-sm:calc(var(--sl-radius) * .55);
|
|
1931
|
+
/* Motion tokens, defined ON the widget root so an embed is self-contained and
|
|
1932
|
+
never inherits (or fights) the host page's own timing. Values mirror
|
|
1933
|
+
docs/motion-system-2026-08-01.md \xA72. */
|
|
1934
|
+
--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;
|
|
1935
|
+
--slm-mo-out:cubic-bezier(0.2,0.8,0.2,1);--slm-mo-exit:cubic-bezier(0.4,0,1,1)}
|
|
1184
1936
|
.sl-picker *{box-sizing:border-box;margin:0;padding:0}
|
|
1185
1937
|
.sl-picker button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
1186
1938
|
|
|
@@ -1884,9 +2636,24 @@ var CSS = `
|
|
|
1884
2636
|
@keyframes slConfirmBelowIn{from{opacity:0;transform:translate(-50%,8px) scale(.96)}to{opacity:1;transform:translate(-50%,16px) scale(1)}}
|
|
1885
2637
|
@keyframes slConfirmMobileIn{from{opacity:0;transform:translate(-50%,10px) scale(.97)}to{opacity:1;transform:translate(-50%,0) scale(1)}}
|
|
1886
2638
|
|
|
2639
|
+
/* Access state (channels): the panel fades AND rises at --slm-mo-base. It never
|
|
2640
|
+
covers the map \u2014 inventory is cross-faded to neutral by the canvas in one
|
|
2641
|
+
batched pass, so nothing blinks away underneath it. */
|
|
2642
|
+
.sl-access{position:absolute;left:50%;bottom:18px;z-index:9;transform:translateX(-50%);
|
|
2643
|
+
max-width:min(420px,calc(100% - 24px));display:flex;gap:12px;align-items:flex-start;
|
|
2644
|
+
padding:12px 14px;border-radius:var(--sl-r-sm);background:var(--sl-panel,#151b2c);color:var(--sl-text);
|
|
2645
|
+
border:1px solid var(--sl-line);box-shadow:0 18px 44px -18px rgba(0,0,0,.6);
|
|
2646
|
+
animation:slAccessIn var(--slm-mo-base) var(--slm-mo-out) both}
|
|
2647
|
+
.sl-access-title{font-weight:700;font-size:13px}
|
|
2648
|
+
.sl-access-body{font-size:12px;line-height:1.5;opacity:.82;margin-top:2px}
|
|
2649
|
+
.sl-access-act{margin-top:8px;padding:6px 12px;border-radius:999px;font-size:12px;font-weight:700;
|
|
2650
|
+
background:var(--sl-accent);color:var(--sl-accent-ink)}
|
|
2651
|
+
@keyframes slAccessIn{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
|
2652
|
+
|
|
1887
2653
|
@media(prefers-reduced-motion:reduce){
|
|
1888
2654
|
.sl-picker *,.sl-modal-scrim *{animation-duration:.001ms!important;animation-iteration-count:1!important;
|
|
1889
2655
|
transition-duration:.001ms!important;scroll-behavior:auto!important}
|
|
2656
|
+
.sl-access{animation:none;opacity:1;transform:translate(-50%,0)}
|
|
1890
2657
|
}
|
|
1891
2658
|
.sl-ba [data-ba-zone]{grid-column:1/-1;width:100%}
|
|
1892
2659
|
|
|
@@ -1935,6 +2702,8 @@ function writeStoredColorblind(on) {
|
|
|
1935
2702
|
}
|
|
1936
2703
|
var SeatPicker = class _SeatPicker {
|
|
1937
2704
|
constructor(options) {
|
|
2705
|
+
this.realtime = null;
|
|
2706
|
+
this.accessEl = null;
|
|
1938
2707
|
this.root = null;
|
|
1939
2708
|
this.mapHost = null;
|
|
1940
2709
|
this.rendered = false;
|
|
@@ -2056,7 +2825,21 @@ var SeatPicker = class _SeatPicker {
|
|
|
2056
2825
|
if (!options.container) throw new Error("seatmap: `container` is required (or use SeatPicker.open())");
|
|
2057
2826
|
this.opts = { ...options, confirmSelection: options.confirmSelection ?? true };
|
|
2058
2827
|
this.apiBase = (options.apiBase ?? DEFAULT_API_BASE2).replace(/\/+$/, "");
|
|
2059
|
-
this.
|
|
2828
|
+
this.access = options.transport ? null : createBuyerAccessContext(options, {
|
|
2829
|
+
onExpired: (event) => {
|
|
2830
|
+
this.opts.onAccessExpired?.(event);
|
|
2831
|
+
if (!event.refreshed) this.showAccessPanel({ reason: "no_token", retryable: false });
|
|
2832
|
+
},
|
|
2833
|
+
onUnavailable: (event) => {
|
|
2834
|
+
this.opts.onAccessUnavailable?.(event);
|
|
2835
|
+
this.showAccessPanel(event);
|
|
2836
|
+
}
|
|
2837
|
+
});
|
|
2838
|
+
this.pubApi = options.transport ? null : new PubApi(this.apiBase, {
|
|
2839
|
+
access: this.access ?? void 0,
|
|
2840
|
+
onObjectUnavailable: (event) => this.opts.onSelectedObjectUnavailable?.(event)
|
|
2841
|
+
});
|
|
2842
|
+
this.api = options.transport ?? this.pubApi;
|
|
2060
2843
|
this.maxTickets = Math.max(1, Math.floor(options.maxSelection ?? DEFAULT_MAX_SELECTION2));
|
|
2061
2844
|
this.cbSafe = readStoredColorblind() ?? !!options.colorblindSafe;
|
|
2062
2845
|
this.controller = new import_core2.PickerController({
|
|
@@ -2600,6 +3383,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
2600
3383
|
return this;
|
|
2601
3384
|
}
|
|
2602
3385
|
this.els.boot.remove();
|
|
3386
|
+
this.startRealtime();
|
|
2603
3387
|
this.salesClosed = !!info.salesClosed;
|
|
2604
3388
|
this.controller.setViewMode(this.normalizeInitialView(this.opts.initialView));
|
|
2605
3389
|
this.buildRegions();
|
|
@@ -3530,7 +4314,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
3530
4314
|
this.tableDialog = { ...table };
|
|
3531
4315
|
this.tableDialogHeld = held;
|
|
3532
4316
|
this.tableDialogReturnFocus = returnFocus ?? document.activeElement;
|
|
3533
|
-
const
|
|
4317
|
+
const esc3 = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
|
3534
4318
|
"&": "&",
|
|
3535
4319
|
"<": "<",
|
|
3536
4320
|
">": ">",
|
|
@@ -3542,7 +4326,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
3542
4326
|
const typeWord = this.rowTypeWord(table);
|
|
3543
4327
|
const el = document.createElement("div");
|
|
3544
4328
|
el.className = "sl-table-scrim";
|
|
3545
|
-
el.innerHTML = `<section class="sl-table-dialog" role="dialog" aria-modal="true" aria-labelledby="sl-table-title" aria-describedby="sl-table-copy"><div class="sl-table-head"><div class="sl-table-eyebrow">${
|
|
4329
|
+
el.innerHTML = `<section class="sl-table-dialog" role="dialog" aria-modal="true" aria-labelledby="sl-table-title" aria-describedby="sl-table-copy"><div class="sl-table-head"><div class="sl-table-eyebrow">${esc3(variable ? `Flexible party \xB7 ${typeWord}` : `Whole ${typeWord}`)}</div><h2 class="sl-table-title" id="sl-table-title">${esc3(table.displayLabel ?? table.label)}</h2><p class="sl-table-copy" id="sl-table-copy">${variable ? `Choose how many guests will sit together. This table is held exclusively for your party.` : `All ${table.capacity} places are booked together as one exclusive table.`}</p></div><div class="sl-table-body"><div class="sl-table-summary"><span>${esc3(cat?.label ?? table.categoryKey)}</span><b data-table-unit>${this.money(this.paidPrice(table.categoryKey, table.tierId ?? null, table.price))} per guest</b><span class="muted">Table capacity</span><span>${table.capacity} guests</span><span class="muted">Total</span><b data-table-total></b></div>` + (variable ? `<label class="sl-table-qtylabel" id="sl-table-qty-label">Number of guests</label><div class="sl-table-stepper" role="group" aria-labelledby="sl-table-qty-label"><button type="button" data-table-step="-1" aria-label="Fewer guests">\u2212</button><output aria-live="polite" data-table-qty>${table.quantity}</output><button type="button" data-table-step="1" aria-label="More guests">+</button></div><div class="sl-table-range">Choose ${table.minOccupancy}\u2013${table.maxOccupancy} guests</div>` : `<input type="hidden" data-table-qty value="${table.capacity}">`) + `<div class="sl-table-actions"><button type="button" class="sl-table-cancel">Cancel</button><button type="button" class="sl-table-confirm">${held ? "Update table" : variable ? "Select table" : "Select whole table"}</button></div></div></section>`;
|
|
3546
4330
|
this.root.appendChild(el);
|
|
3547
4331
|
this.tableDialogEl = el;
|
|
3548
4332
|
this.renderTableDialogState();
|
|
@@ -4011,7 +4795,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
4011
4795
|
(this.controller.hasPremiumSeats() ? `<button type="button" class="sl-ba-premium${this.baPremium ? " on" : ""}" data-ba-premium aria-pressed="${this.baPremium ? "true" : "false"}"><span class="star" aria-hidden="true">\u2605</span>${this.tf("picker.bestSeatsPremium", "Best seats")}</button>` : "") + (cats.length > 1 ? `<select aria-label="Preferred ticket type" data-ba-cat><option value="">Any ticket type</option>` + cats.map((c) => `<option value="${c.key}"${this.baCat === c.key ? " selected" : ""}>${c.label}</option>`).join("") + `</select>` : `<span aria-hidden="true"></span>`) + (zones.length ? `<select aria-label="Preferred venue zone" data-ba-zone><option value="">Any venue zone</option>` + zones.map((zone) => `<option value="${escapeOption(zone.id)}"${this.baZone === zone.id ? " selected" : ""}>${escapeOption(zone.label)}</option>`).join("") + `</select>` : "") + `<div class="sl-ba-qty"><button type="button" data-ba="-1" aria-label="Fewer seats">\u2212</button><span>${this.baQty}</span><button type="button" data-ba="1" aria-label="More seats">+</button></div><button type="button" class="sl-ba-go"${this.bestAvailableBusy ? " disabled" : ""}>` + (this.bestAvailableBusy ? `<span class="sl-ba-spin" aria-hidden="true"></span>Finding the best seats\u2026` : `Find ${this.baQty} best ${this.baQty === 1 ? "seat" : "seats"}`) + `</button></div>`);
|
|
4012
4796
|
}
|
|
4013
4797
|
const idGrid = (seatId, label, objectType, quantity = 1, objectId, identity) => {
|
|
4014
|
-
const
|
|
4798
|
+
const esc3 = (value) => String(value ?? "\u2014").replace(/[&<>"]/g, (char) => ({
|
|
4015
4799
|
"&": "&",
|
|
4016
4800
|
"<": "<",
|
|
4017
4801
|
">": ">",
|
|
@@ -4023,18 +4807,18 @@ var SeatPicker = class _SeatPicker {
|
|
|
4023
4807
|
const typeWord = identity?.displayType?.trim() || d?.displayType?.trim() || area?.displayType?.trim() || (effectiveType === "table" ? "Table" : effectiveType === "booth" ? "Booth" : effectiveType === "ga" ? "General admission" : "Row");
|
|
4024
4808
|
const buyerName = identity?.rowLabel ?? identity?.displayLabel ?? d?.rowLabel ?? d?.displayLabel ?? area?.displayLabel ?? area?.label ?? label;
|
|
4025
4809
|
if (effectiveType === "table" && identity?.bookingMode) {
|
|
4026
|
-
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${
|
|
4810
|
+
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(buyerName)}</span></span><span class="fld mid"><span class="sl-chip-eb">Guests</span><span class="val">${quantity}</span></span></div>`;
|
|
4027
4811
|
}
|
|
4028
4812
|
if (effectiveType === "ga") {
|
|
4029
|
-
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${
|
|
4813
|
+
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(buyerName)}</span></span>` + (quantity > 1 ? `<span class="fld mid"><span class="sl-chip-eb">Tickets</span><span class="val">${quantity}</span></span>` : "") + `</div>`;
|
|
4030
4814
|
}
|
|
4031
4815
|
if (effectiveType === "booth") {
|
|
4032
|
-
return `<div class="sl-chip-id">` + (d?.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${
|
|
4816
|
+
return `<div class="sl-chip-id">` + (d?.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${esc3(d.sectionLabel)}</span></span>` : "") + `<span class="fld mid"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(buyerName)}</span></span></div>`;
|
|
4033
4817
|
}
|
|
4034
4818
|
if (!d?.sectionLabel && !d?.rowLabel && !d?.seatNumber) {
|
|
4035
|
-
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">Seat</span><span class="val">${
|
|
4819
|
+
return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">Seat</span><span class="val">${esc3(buyerName)}</span></span></div>`;
|
|
4036
4820
|
}
|
|
4037
|
-
return `<div class="sl-chip-id">` + (d.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${
|
|
4821
|
+
return `<div class="sl-chip-id">` + (d.sectionLabel ? `<span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${esc3(d.sectionLabel)}</span></span>` : "") + (d.rowLabel ? `<span class="fld mid"><span class="sl-chip-eb">${esc3(typeWord)}</span><span class="val">${esc3(this.rowShort(d))}</span></span>` : "") + (d.seatNumber ? `<span class="fld mid"><span class="sl-chip-eb">Seat</span><span class="val">${esc3(d.seatNumber)}</span></span>` : "") + `</div>`;
|
|
4038
4822
|
};
|
|
4039
4823
|
const iconRail = (rmAria, viewLabel) => `<div class="sl-chip-rail"><button type="button" class="rm" aria-label="${rmAria}"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>` + (viewLabel ? `<button type="button" class="view" data-view-label="${viewLabel}" aria-label="${(0, import_core2.t)("picker.viewFromSeat", { label: viewLabel })}"><svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>` : "") + `</div>`;
|
|
4040
4824
|
for (const item of heldItems) {
|
|
@@ -4529,19 +5313,19 @@ var SeatPicker = class _SeatPicker {
|
|
|
4529
5313
|
this.tipEl.style.display = "none";
|
|
4530
5314
|
return;
|
|
4531
5315
|
}
|
|
4532
|
-
const
|
|
5316
|
+
const esc3 = (v) => String(v ?? "\u2014").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]);
|
|
4533
5317
|
const price = this.money(this.paidPrice(details.categoryKey, details.tierId ?? null, details.price));
|
|
4534
5318
|
const isGroupedTable = details.objectType === "table" && !!details.bookingMode;
|
|
4535
5319
|
const isBooth = details.objectType === "booth";
|
|
4536
5320
|
const hasLoc = details.sectionLabel || details.rowLabel || details.seatNumber;
|
|
4537
|
-
const grid = isGroupedTable ? `<div class="sl-tip-grid"><div class="sl-tip-field"><span class="sl-tip-key">${
|
|
5321
|
+
const grid = isGroupedTable ? `<div class="sl-tip-grid"><div class="sl-tip-field"><span class="sl-tip-key">${esc3(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc3(details.rowLabel ?? details.displayLabel ?? details.label)}</span></div><div class="sl-tip-field"><span class="sl-tip-key">Guests</span><span class="sl-tip-val">${details.bookingMode === "variable" ? `${details.minOccupancy}\u2013${details.maxOccupancy}` : details.capacity}</span></div></div>` : isBooth ? `<div class="sl-tip-grid">` + (details.sectionLabel ? `<div class="sl-tip-field"><span class="sl-tip-key">Section</span><span class="sl-tip-val">${esc3(details.sectionLabel)}</span></div>` : "") + `<div class="sl-tip-field"><span class="sl-tip-key">${esc3(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc3(details.rowLabel ?? details.displayLabel ?? details.label)}</span></div></div>` : hasLoc ? `<div class="sl-tip-grid">` + (details.sectionLabel ? `<div class="sl-tip-field"><span class="sl-tip-key">Section</span><span class="sl-tip-val">${esc3(details.sectionLabel)}</span></div>` : "") + (details.rowLabel ? `<div class="sl-tip-field"><span class="sl-tip-key">${esc3(this.rowTypeWord(details))}</span><span class="sl-tip-val">${esc3(this.rowShort(details))}</span></div>` : "") + (details.seatNumber ? `<div class="sl-tip-field"><span class="sl-tip-key">Seat</span><span class="sl-tip-val">${esc3(details.seatNumber)}</span></div>` : "") + `</div>` : `<div class="sl-tip-grid one"><div class="sl-tip-field"><span class="sl-tip-key">Seat</span><span class="sl-tip-val">${esc3(details.displayLabel ?? details.label)}</span></div></div>`;
|
|
4538
5322
|
const statusLine = details.status === "free" ? "" : `<div class="sl-tip-status">${details.status === "held" ? (0, import_core2.t)("map.statusHeld") : (0, import_core2.t)("map.statusTaken")}</div>`;
|
|
4539
5323
|
const limited = this.limitedViewLabel(details.commercial);
|
|
4540
|
-
const cxLine = limited ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u25D0</span>${
|
|
5324
|
+
const cxLine = limited ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u25D0</span>${esc3(limited)}</div>` : "";
|
|
4541
5325
|
const wheelchair = this.wheelchairProvisionLabel(details.wheelchairSpaceType);
|
|
4542
|
-
const wheelchairLine = wheelchair ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u267F</span>${
|
|
5326
|
+
const wheelchairLine = wheelchair ? `<div class="sl-tip-cx"><span class="g" aria-hidden="true">\u267F</span>${esc3(wheelchair)}</div>` : "";
|
|
4543
5327
|
this.tipEl.style.setProperty("--sl-cat", details.categoryColor);
|
|
4544
|
-
this.tipEl.innerHTML = grid + `<div class="sl-tip-cat"><span class="sl-tip-dot" style="background:${details.categoryColor}"></span><span class="sl-tip-name">${
|
|
5328
|
+
this.tipEl.innerHTML = grid + `<div class="sl-tip-cat"><span class="sl-tip-dot" style="background:${details.categoryColor}"></span><span class="sl-tip-name">${esc3(details.categoryLabel)}</span><span class="sl-tip-amt">${price}</span></div>` + wheelchairLine + cxLine + statusLine;
|
|
4545
5329
|
this.tipEl.style.display = "block";
|
|
4546
5330
|
this.placeTooltip();
|
|
4547
5331
|
}
|
|
@@ -4939,8 +5723,154 @@ var SeatPicker = class _SeatPicker {
|
|
|
4939
5723
|
this.syncTray();
|
|
4940
5724
|
this.emitHoldChange();
|
|
4941
5725
|
}
|
|
5726
|
+
// ---- buyer access (Sales Channels) ---------------------------------------
|
|
5727
|
+
/**
|
|
5728
|
+
* Realtime for an access-scoped picker.
|
|
5729
|
+
*
|
|
5730
|
+
* A tokenless picker never gets here: `access` is null, `PubApi.socketUrl()`
|
|
5731
|
+
* returns the URL it always has, and PickerController keeps its own socket
|
|
5732
|
+
* and its own legacy frames. Nothing about the public path changes.
|
|
5733
|
+
*/
|
|
5734
|
+
startRealtime() {
|
|
5735
|
+
if (!this.access?.configured || !this.pubApi || this.realtime) return;
|
|
5736
|
+
const event = this.opts.event;
|
|
5737
|
+
this.realtime = new BuyerRealtimeClient({
|
|
5738
|
+
url: this.pubApi.subscribeUrl(event),
|
|
5739
|
+
mintTicket: () => this.pubApi.subscribeTicket(event),
|
|
5740
|
+
onAccessUnavailable: (state) => {
|
|
5741
|
+
this.opts.onAccessUnavailable?.(state);
|
|
5742
|
+
this.showAccessPanel(state);
|
|
5743
|
+
},
|
|
5744
|
+
sink: createControllerSink(this.controller, {
|
|
5745
|
+
flashOnLiveChange: true,
|
|
5746
|
+
onStatusChange: () => {
|
|
5747
|
+
this.syncPrices();
|
|
5748
|
+
this.detectBooked();
|
|
5749
|
+
this.refreshMinimap();
|
|
5750
|
+
this.pushAvailabilityTo3d();
|
|
5751
|
+
},
|
|
5752
|
+
onSelectedObjectUnavailable: (labels, reason) => {
|
|
5753
|
+
this.opts.onSelectedObjectUnavailable?.({ labels, reason });
|
|
5754
|
+
this.syncTray();
|
|
5755
|
+
this.toast(
|
|
5756
|
+
reason === "ineligible" ? this.tf(
|
|
5757
|
+
"picker.seatNoLongerYours",
|
|
5758
|
+
"Some seats are no longer available to you. They have been removed from your order."
|
|
5759
|
+
) : this.tf(
|
|
5760
|
+
"picker.seatTaken",
|
|
5761
|
+
"Someone else took a seat you had picked. It has been removed from your order."
|
|
5762
|
+
),
|
|
5763
|
+
"warning"
|
|
5764
|
+
);
|
|
5765
|
+
}
|
|
5766
|
+
})
|
|
5767
|
+
});
|
|
5768
|
+
this.realtime.start();
|
|
5769
|
+
}
|
|
5770
|
+
/**
|
|
5771
|
+
* Re-acquire the buyer access session — call after your app has re-authorized
|
|
5772
|
+
* the buyer. A revoked session cannot recover any other way. Resolves true
|
|
5773
|
+
* when a fresh bearer is held; the map and the realtime feed resume with it.
|
|
5774
|
+
*/
|
|
5775
|
+
async refreshAccess() {
|
|
5776
|
+
if (!this.access?.configured) return false;
|
|
5777
|
+
const ok = await this.access.refresh("manual");
|
|
5778
|
+
if (!ok) return false;
|
|
5779
|
+
this.dismissAccessPanel();
|
|
5780
|
+
await this.controller.refresh();
|
|
5781
|
+
if (this.realtime) this.realtime.restart();
|
|
5782
|
+
else this.startRealtime();
|
|
5783
|
+
return true;
|
|
5784
|
+
}
|
|
5785
|
+
/**
|
|
5786
|
+
* The buyer-facing access state. Plain language, no internal vocabulary, and
|
|
5787
|
+
* never a channel name, id or count — the buyer is told what happened and
|
|
5788
|
+
* what to do, not which allocation they missed (guide §7, §10).
|
|
5789
|
+
*
|
|
5790
|
+
* Held seats are deliberately left alone: a hold is relinquished by its own
|
|
5791
|
+
* opaque capability, not by channel access, so losing access never strands
|
|
5792
|
+
* inventory and never silently drops a buyer's cart (guide §9).
|
|
5793
|
+
*/
|
|
5794
|
+
showAccessPanel(state) {
|
|
5795
|
+
if (this.destroyed || !this.root) return;
|
|
5796
|
+
const copy = this.accessCopy(state.reason);
|
|
5797
|
+
this.dismissAccessPanel();
|
|
5798
|
+
const panel = document.createElement("div");
|
|
5799
|
+
panel.className = "sl-access";
|
|
5800
|
+
panel.setAttribute("role", "status");
|
|
5801
|
+
panel.setAttribute("aria-live", "polite");
|
|
5802
|
+
const text = document.createElement("div");
|
|
5803
|
+
const title = document.createElement("div");
|
|
5804
|
+
title.className = "sl-access-title";
|
|
5805
|
+
title.textContent = copy.title;
|
|
5806
|
+
const body = document.createElement("div");
|
|
5807
|
+
body.className = "sl-access-body";
|
|
5808
|
+
body.textContent = copy.body;
|
|
5809
|
+
text.appendChild(title);
|
|
5810
|
+
text.appendChild(body);
|
|
5811
|
+
if (copy.action) {
|
|
5812
|
+
const button = document.createElement("button");
|
|
5813
|
+
button.type = "button";
|
|
5814
|
+
button.className = "sl-access-act";
|
|
5815
|
+
button.textContent = copy.action;
|
|
5816
|
+
button.addEventListener("click", () => {
|
|
5817
|
+
void this.refreshAccess();
|
|
5818
|
+
});
|
|
5819
|
+
text.appendChild(button);
|
|
5820
|
+
}
|
|
5821
|
+
panel.appendChild(text);
|
|
5822
|
+
(this.regions?.["bottom-center"] ?? this.root).appendChild(panel);
|
|
5823
|
+
this.accessEl = panel;
|
|
5824
|
+
}
|
|
5825
|
+
dismissAccessPanel() {
|
|
5826
|
+
this.accessEl?.remove();
|
|
5827
|
+
this.accessEl = null;
|
|
5828
|
+
}
|
|
5829
|
+
accessCopy(reason) {
|
|
5830
|
+
switch (reason) {
|
|
5831
|
+
case "paused":
|
|
5832
|
+
return {
|
|
5833
|
+
title: this.tf("picker.accessPausedTitle", "These seats are on hold right now"),
|
|
5834
|
+
body: this.tf(
|
|
5835
|
+
"picker.accessPausedBody",
|
|
5836
|
+
"The organizer has paused this selection. Try again in a few minutes."
|
|
5837
|
+
),
|
|
5838
|
+
action: this.tf("picker.accessRetry", "Try again")
|
|
5839
|
+
};
|
|
5840
|
+
case "revoked":
|
|
5841
|
+
return {
|
|
5842
|
+
title: this.tf("picker.accessRevokedTitle", "This access link is no longer active"),
|
|
5843
|
+
body: this.tf(
|
|
5844
|
+
"picker.accessRevokedBody",
|
|
5845
|
+
"Ask whoever sent you here for a new link to keep booking these seats."
|
|
5846
|
+
)
|
|
5847
|
+
};
|
|
5848
|
+
case "no_token":
|
|
5849
|
+
case "provider_failed":
|
|
5850
|
+
return {
|
|
5851
|
+
title: this.tf("picker.accessExpiredTitle", "Your access session has ended"),
|
|
5852
|
+
body: this.tf(
|
|
5853
|
+
"picker.accessExpiredBody",
|
|
5854
|
+
"Sign in again, or reload the page, to keep browsing these seats. Anything you are already holding stays yours."
|
|
5855
|
+
),
|
|
5856
|
+
action: this.tf("picker.accessRetry", "Try again")
|
|
5857
|
+
};
|
|
5858
|
+
default:
|
|
5859
|
+
return {
|
|
5860
|
+
title: this.tf("picker.accessInvalidTitle", "We couldn\u2019t verify your access"),
|
|
5861
|
+
body: this.tf(
|
|
5862
|
+
"picker.accessInvalidBody",
|
|
5863
|
+
"You can still book anything shown as available. Contact whoever sent you here for access to the rest."
|
|
5864
|
+
)
|
|
5865
|
+
};
|
|
5866
|
+
}
|
|
5867
|
+
}
|
|
4942
5868
|
destroy() {
|
|
4943
5869
|
this.destroyed = true;
|
|
5870
|
+
this.realtime?.stop();
|
|
5871
|
+
this.realtime = null;
|
|
5872
|
+
this.dismissAccessPanel();
|
|
5873
|
+
this.access?.clear();
|
|
4944
5874
|
if (this.hold && !this.handedOff) void this.controller.release();
|
|
4945
5875
|
this.closeConfirm();
|
|
4946
5876
|
this.dismissTableDialog(false);
|
|
@@ -5058,28 +5988,246 @@ function attachPickerFrame(iframe, opts = {}) {
|
|
|
5058
5988
|
// src/SeatManager.ts
|
|
5059
5989
|
var import_core3 = require("@seatlayer/core");
|
|
5060
5990
|
|
|
5061
|
-
// src/
|
|
5062
|
-
var
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5991
|
+
// src/channelPlan.ts
|
|
5992
|
+
var PUBLIC_CHANNEL_ID = "";
|
|
5993
|
+
var PUBLIC_CHANNEL_NAME = "Public sale";
|
|
5994
|
+
var CHANNEL_COLORS = [
|
|
5995
|
+
"#a78bfa",
|
|
5996
|
+
"#2dd4bf",
|
|
5997
|
+
"#fb923c",
|
|
5998
|
+
"#60a5fa",
|
|
5999
|
+
"#f472b6",
|
|
6000
|
+
"#a3e635",
|
|
6001
|
+
"#f87171",
|
|
6002
|
+
"#38bdf8",
|
|
6003
|
+
"#c084fc",
|
|
6004
|
+
"#facc15"
|
|
6005
|
+
];
|
|
6006
|
+
var PUBLIC_CHANNEL_COLOR = "#f4b740";
|
|
6007
|
+
var LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
6008
|
+
function suggestMarker(name, taken) {
|
|
6009
|
+
const used = new Set([...taken].map((m) => m.trim().toUpperCase()).filter(Boolean));
|
|
6010
|
+
const first = (name.trim()[0] ?? "").toUpperCase();
|
|
6011
|
+
const letter = LETTERS.includes(first) && !used.has(first) ? first : [...LETTERS].find((candidate) => !used.has(candidate)) ?? (first || "X");
|
|
6012
|
+
return { letter, color: CHANNEL_COLORS[used.size % CHANNEL_COLORS.length] };
|
|
6013
|
+
}
|
|
6014
|
+
function markerOf(channel, index = 0) {
|
|
6015
|
+
if (channel.id === PUBLIC_CHANNEL_ID) {
|
|
6016
|
+
return { letter: (channel.marker || "P").slice(0, 2).toUpperCase(), color: channel.color || PUBLIC_CHANNEL_COLOR };
|
|
5077
6017
|
}
|
|
5078
|
-
|
|
6018
|
+
const letter = (channel.marker || channel.name.trim()[0] || "?").slice(0, 2).toUpperCase();
|
|
6019
|
+
return { letter, color: channel.color || CHANNEL_COLORS[index % CHANNEL_COLORS.length] };
|
|
5079
6020
|
}
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
6021
|
+
function selectionSources(labels, allocation, list) {
|
|
6022
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6023
|
+
for (const label of labels) {
|
|
6024
|
+
const channelId = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
|
|
6025
|
+
counts.set(channelId, (counts.get(channelId) ?? 0) + 1);
|
|
6026
|
+
}
|
|
6027
|
+
const order = [
|
|
6028
|
+
{ id: PUBLIC_CHANNEL_ID, name: list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
6029
|
+
...(list?.channels ?? []).map((channel) => ({ id: channel.id, name: channel.name }))
|
|
6030
|
+
];
|
|
6031
|
+
const rows = [];
|
|
6032
|
+
for (const entry of order) {
|
|
6033
|
+
const count = counts.get(entry.id);
|
|
6034
|
+
if (count) rows.push({ channelId: entry.id, name: entry.name, count });
|
|
6035
|
+
counts.delete(entry.id);
|
|
6036
|
+
}
|
|
6037
|
+
for (const [channelId, count] of counts) {
|
|
6038
|
+
rows.push({ channelId, name: channelId ? "Another channel" : PUBLIC_CHANNEL_NAME, count });
|
|
6039
|
+
}
|
|
6040
|
+
return rows;
|
|
6041
|
+
}
|
|
6042
|
+
var SKIP_SAMPLE = 12;
|
|
6043
|
+
function skipBucket(labels) {
|
|
6044
|
+
return {
|
|
6045
|
+
count: labels.length,
|
|
6046
|
+
labels: labels.slice(0, SKIP_SAMPLE),
|
|
6047
|
+
truncated: labels.length > SKIP_SAMPLE
|
|
6048
|
+
};
|
|
6049
|
+
}
|
|
6050
|
+
function planAssignment(input) {
|
|
6051
|
+
const { labels, targetChannelId, allocation, statusOf, nameOf } = input;
|
|
6052
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6053
|
+
let fromPublic = 0;
|
|
6054
|
+
let alreadyIn = 0;
|
|
6055
|
+
const movedBySource = /* @__PURE__ */ new Map();
|
|
6056
|
+
const held = [];
|
|
6057
|
+
const booked = [];
|
|
6058
|
+
const missing = [];
|
|
6059
|
+
for (const label of labels) {
|
|
6060
|
+
if (seen.has(label)) continue;
|
|
6061
|
+
seen.add(label);
|
|
6062
|
+
const status = statusOf(label);
|
|
6063
|
+
if (!status) {
|
|
6064
|
+
missing.push(label);
|
|
6065
|
+
continue;
|
|
6066
|
+
}
|
|
6067
|
+
const current = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
|
|
6068
|
+
if (current === targetChannelId) {
|
|
6069
|
+
alreadyIn += 1;
|
|
6070
|
+
continue;
|
|
6071
|
+
}
|
|
6072
|
+
if (status === "held") {
|
|
6073
|
+
held.push(label);
|
|
6074
|
+
continue;
|
|
6075
|
+
}
|
|
6076
|
+
if (status === "booked") {
|
|
6077
|
+
booked.push(label);
|
|
6078
|
+
continue;
|
|
6079
|
+
}
|
|
6080
|
+
if (current === PUBLIC_CHANNEL_ID) fromPublic += 1;
|
|
6081
|
+
else movedBySource.set(current, (movedBySource.get(current) ?? 0) + 1);
|
|
6082
|
+
}
|
|
6083
|
+
const channels = [...movedBySource.entries()].map(([channelId, count]) => ({
|
|
6084
|
+
channelId,
|
|
6085
|
+
name: nameOf(channelId),
|
|
6086
|
+
count
|
|
6087
|
+
}));
|
|
6088
|
+
return {
|
|
6089
|
+
changedFromPublic: { count: fromPublic },
|
|
6090
|
+
movedFromOtherChannel: {
|
|
6091
|
+
count: channels.reduce((sum, row) => sum + row.count, 0),
|
|
6092
|
+
channels
|
|
6093
|
+
},
|
|
6094
|
+
alreadyInTarget: { count: alreadyIn },
|
|
6095
|
+
skippedHeld: skipBucket(held),
|
|
6096
|
+
skippedBooked: skipBucket(booked),
|
|
6097
|
+
notFound: skipBucket(missing)
|
|
6098
|
+
};
|
|
6099
|
+
}
|
|
6100
|
+
function mutationCount(buckets) {
|
|
6101
|
+
return buckets.changedFromPublic.count + buckets.movedFromOtherChannel.count;
|
|
6102
|
+
}
|
|
6103
|
+
function needsMoveConfirmation(buckets) {
|
|
6104
|
+
return buckets.movedFromOtherChannel.count > 0;
|
|
6105
|
+
}
|
|
6106
|
+
function bucketRows(buckets, targetName) {
|
|
6107
|
+
const rows = [];
|
|
6108
|
+
if (buckets.changedFromPublic.count) {
|
|
6109
|
+
rows.push({
|
|
6110
|
+
kind: "add",
|
|
6111
|
+
icon: "+",
|
|
6112
|
+
count: buckets.changedFromPublic.count,
|
|
6113
|
+
text: `${buckets.changedFromPublic.count.toLocaleString()} from ${PUBLIC_CHANNEL_NAME}`
|
|
6114
|
+
});
|
|
6115
|
+
}
|
|
6116
|
+
for (const source of buckets.movedFromOtherChannel.channels) {
|
|
6117
|
+
rows.push({
|
|
6118
|
+
kind: "move",
|
|
6119
|
+
icon: "\u21C4",
|
|
6120
|
+
count: source.count,
|
|
6121
|
+
text: `${source.count.toLocaleString()} moved out of ${source.name ?? "another channel"}`,
|
|
6122
|
+
why: "needs this confirmation"
|
|
6123
|
+
});
|
|
6124
|
+
}
|
|
6125
|
+
if (buckets.alreadyInTarget.count) {
|
|
6126
|
+
rows.push({
|
|
6127
|
+
kind: "same",
|
|
6128
|
+
icon: "=",
|
|
6129
|
+
count: buckets.alreadyInTarget.count,
|
|
6130
|
+
text: `${buckets.alreadyInTarget.count.toLocaleString()} already in ${targetName}`,
|
|
6131
|
+
why: "unchanged"
|
|
6132
|
+
});
|
|
6133
|
+
}
|
|
6134
|
+
if (buckets.skippedHeld.count) {
|
|
6135
|
+
rows.push({
|
|
6136
|
+
kind: "skip",
|
|
6137
|
+
icon: "\u23F8",
|
|
6138
|
+
count: buckets.skippedHeld.count,
|
|
6139
|
+
text: `${buckets.skippedHeld.count.toLocaleString()} in a buyer's checkout`,
|
|
6140
|
+
why: "can't move while held",
|
|
6141
|
+
peek: peekOf(buckets.skippedHeld)
|
|
6142
|
+
});
|
|
6143
|
+
}
|
|
6144
|
+
if (buckets.skippedBooked.count) {
|
|
6145
|
+
rows.push({
|
|
6146
|
+
kind: "skip",
|
|
6147
|
+
icon: "\u{1F512}",
|
|
6148
|
+
count: buckets.skippedBooked.count,
|
|
6149
|
+
text: `${buckets.skippedBooked.count.toLocaleString()} already sold`,
|
|
6150
|
+
why: "sales are never rewritten",
|
|
6151
|
+
peek: peekOf(buckets.skippedBooked)
|
|
6152
|
+
});
|
|
6153
|
+
}
|
|
6154
|
+
if (buckets.notFound.count) {
|
|
6155
|
+
rows.push({
|
|
6156
|
+
kind: "skip",
|
|
6157
|
+
icon: "?",
|
|
6158
|
+
count: buckets.notFound.count,
|
|
6159
|
+
text: `${buckets.notFound.count.toLocaleString()} not on this map`,
|
|
6160
|
+
why: "these seats are no longer part of the event",
|
|
6161
|
+
peek: peekOf(buckets.notFound)
|
|
6162
|
+
});
|
|
6163
|
+
}
|
|
6164
|
+
return rows;
|
|
6165
|
+
}
|
|
6166
|
+
function peekOf(bucket) {
|
|
6167
|
+
if (!bucket.labels.length) return void 0;
|
|
6168
|
+
const shown = bucket.labels.slice(0, 4).join(", ");
|
|
6169
|
+
return bucket.truncated || bucket.labels.length > 4 ? `${shown}\u2026` : shown;
|
|
6170
|
+
}
|
|
6171
|
+
function retryAfterCopy(details) {
|
|
6172
|
+
const ms = details?.retryAfterMs ?? (details?.latestHoldExpiresAt ? Math.max(0, details.latestHoldExpiresAt - Date.now()) : 0);
|
|
6173
|
+
if (!ms) return "in a moment";
|
|
6174
|
+
const minutes = Math.ceil(ms / 6e4);
|
|
6175
|
+
if (minutes <= 1) return "in about a minute";
|
|
6176
|
+
return `in about ${minutes} minutes`;
|
|
6177
|
+
}
|
|
6178
|
+
function accessLine(access) {
|
|
6179
|
+
if (!access || !access.intent) return "\u2014";
|
|
6180
|
+
const base = access.intent === "internal" ? "Internal selling \xB7 no buyer access needed" : access.intent === "server" ? "Server integration" : access.intent === "hosted_link" ? "Hosted access link" : "No buyer access configured";
|
|
6181
|
+
const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
|
|
6182
|
+
const detail = access.detail ?? grants;
|
|
6183
|
+
return detail ? `${base} \xB7 ${detail}` : base;
|
|
6184
|
+
}
|
|
6185
|
+
function accessIntentLabel(intent) {
|
|
6186
|
+
return intent === "internal" ? "Internal selling \u2014 our own staff sell these" : intent === "server" ? "Server integration \u2014 our backend lets buyers in" : intent === "hosted_link" ? "Hosted access link \u2014 SeatLayer issues the link" : "No buyer access yet \u2014 the allocation is just protected";
|
|
6187
|
+
}
|
|
6188
|
+
function dropReviewRows(details) {
|
|
6189
|
+
return (details?.channels ?? []).map((channel) => ({
|
|
6190
|
+
kind: "skip",
|
|
6191
|
+
icon: "\u26A0",
|
|
6192
|
+
count: channel.count,
|
|
6193
|
+
text: `${channel.count.toLocaleString()} would leave ${channel.name ?? "a channel"}`,
|
|
6194
|
+
why: "the new chart no longer has these seats",
|
|
6195
|
+
peek: channel.labels?.length ? peekOf({ count: channel.count, labels: channel.labels, truncated: channel.truncated ?? false }) : void 0
|
|
6196
|
+
}));
|
|
6197
|
+
}
|
|
6198
|
+
function stateBadge(state) {
|
|
6199
|
+
return state === "builtin" ? "Built-in" : state === "active" ? "Active" : state === "paused" ? "Paused" : "Archived";
|
|
6200
|
+
}
|
|
6201
|
+
|
|
6202
|
+
// src/manageApi.ts
|
|
6203
|
+
var ManageApiError = class extends Error {
|
|
6204
|
+
constructor(status, message, code, conflicts, details) {
|
|
6205
|
+
super(message);
|
|
6206
|
+
this.name = "ManageApiError";
|
|
6207
|
+
this.status = status;
|
|
6208
|
+
this.code = code;
|
|
6209
|
+
this.conflicts = conflicts;
|
|
6210
|
+
this.details = details;
|
|
6211
|
+
}
|
|
6212
|
+
};
|
|
6213
|
+
async function parse(res) {
|
|
6214
|
+
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
6215
|
+
const data = isJson ? await res.json().catch(() => null) : null;
|
|
6216
|
+
if (!res.ok) {
|
|
6217
|
+
const err = data;
|
|
6218
|
+
throw new ManageApiError(
|
|
6219
|
+
res.status,
|
|
6220
|
+
err?.error ?? `request_failed_${res.status}`,
|
|
6221
|
+
err?.code,
|
|
6222
|
+
err?.conflicts,
|
|
6223
|
+
err?.details
|
|
6224
|
+
);
|
|
6225
|
+
}
|
|
6226
|
+
return data;
|
|
6227
|
+
}
|
|
6228
|
+
var ManageApi = class {
|
|
6229
|
+
constructor(apiBase, token) {
|
|
6230
|
+
this.base = apiBase.replace(/\/+$/, "");
|
|
5083
6231
|
this.token = token;
|
|
5084
6232
|
}
|
|
5085
6233
|
/** Swap the Bearer token in place (SeatManager re-mints on 401). */
|
|
@@ -5151,6 +6299,97 @@ var ManageApi = class {
|
|
|
5151
6299
|
setAvailability(key, rules) {
|
|
5152
6300
|
return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`, { method: "POST", body: { rules } });
|
|
5153
6301
|
}
|
|
6302
|
+
// ---- sales channels (token, capability-gated) ----
|
|
6303
|
+
// Reads need `event:channels:view`, mutations `event:channels:manage`.
|
|
6304
|
+
// `event:block` grants NEITHER (spec §10), so a Block-only cockpit token gets
|
|
6305
|
+
// a 403 here and Channels mode never renders.
|
|
6306
|
+
/** Allocation list with exact per-channel counts. `includeArchived` adds the
|
|
6307
|
+
* read-only archived rows behind the rail's "Show archived" control. */
|
|
6308
|
+
channels(key, opts = {}) {
|
|
6309
|
+
const qs = opts.includeArchived ? "?includeArchived=1" : "";
|
|
6310
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels${qs}`);
|
|
6311
|
+
}
|
|
6312
|
+
/** One page of the label → channel map that paints the allocation overlay.
|
|
6313
|
+
* Paged by label; follow `nextAfterLabel` until it is null. */
|
|
6314
|
+
channelAllocation(key, opts = {}) {
|
|
6315
|
+
const params = new URLSearchParams();
|
|
6316
|
+
if (opts.afterLabel) params.set("afterLabel", opts.afterLabel);
|
|
6317
|
+
if (opts.limit != null) params.set("limit", String(opts.limit));
|
|
6318
|
+
const qs = params.toString();
|
|
6319
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/allocation${qs ? `?${qs}` : ""}`);
|
|
6320
|
+
}
|
|
6321
|
+
channelAudit(key, opts = {}) {
|
|
6322
|
+
const params = new URLSearchParams();
|
|
6323
|
+
if (opts.limit != null) params.set("limit", String(opts.limit));
|
|
6324
|
+
if (opts.before != null) params.set("before", String(opts.before));
|
|
6325
|
+
const qs = params.toString();
|
|
6326
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/audit${qs ? `?${qs}` : ""}`);
|
|
6327
|
+
}
|
|
6328
|
+
createChannel(key, input) {
|
|
6329
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels`, { method: "POST", body: input });
|
|
6330
|
+
}
|
|
6331
|
+
renameChannel(key, channelId, name) {
|
|
6332
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
|
|
6333
|
+
method: "PATCH",
|
|
6334
|
+
body: { name }
|
|
6335
|
+
});
|
|
6336
|
+
}
|
|
6337
|
+
setChannelPaused(key, channelId, paused) {
|
|
6338
|
+
const path = paused ? "pause" : "unpause";
|
|
6339
|
+
return this.auth(
|
|
6340
|
+
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/${path}`,
|
|
6341
|
+
{ method: "POST", body: {} }
|
|
6342
|
+
);
|
|
6343
|
+
}
|
|
6344
|
+
/** Archive with a mandatory destination for the remaining allocation.
|
|
6345
|
+
* Throws ManageApiError 409 `channel_archive_blocked_by_holds` while any hold
|
|
6346
|
+
* is live; `err.details` carries the exact counts + retry window. */
|
|
6347
|
+
archiveChannel(key, channelId, destination) {
|
|
6348
|
+
return this.auth(
|
|
6349
|
+
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/archive`,
|
|
6350
|
+
{ method: "POST", body: { destination } }
|
|
6351
|
+
);
|
|
6352
|
+
}
|
|
6353
|
+
/**
|
|
6354
|
+
* Versioned Apply. A stale `assignmentVersion` mutates NOTHING and throws
|
|
6355
|
+
* ManageApiError 409 `channel_assignment_conflict` — the caller keeps its
|
|
6356
|
+
* selection and offers "Refresh and review". There is no dry-run: the review
|
|
6357
|
+
* sheet previews locally, this call returns the authoritative buckets.
|
|
6358
|
+
*/
|
|
6359
|
+
applyChannelAssignment(key, input) {
|
|
6360
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/assignments`, {
|
|
6361
|
+
method: "POST",
|
|
6362
|
+
body: {
|
|
6363
|
+
targetChannelId: input.targetChannelId || null,
|
|
6364
|
+
labels: input.labels,
|
|
6365
|
+
assignmentVersion: input.assignmentVersion
|
|
6366
|
+
}
|
|
6367
|
+
});
|
|
6368
|
+
}
|
|
6369
|
+
/**
|
|
6370
|
+
* Read-only buyer projection for an audience (§8.6) — the SAME scoped server
|
|
6371
|
+
* view the buyer SDK receives, never a local approximation.
|
|
6372
|
+
*
|
|
6373
|
+
* Ships on the access-hardening branch. Older workers 404/405 here; callers
|
|
6374
|
+
* MUST feature-detect and quietly say the preview needs a newer server rather
|
|
6375
|
+
* than faking a projection client-side.
|
|
6376
|
+
*/
|
|
6377
|
+
channelPreview(key, channelIds, opts = {}) {
|
|
6378
|
+
const params = new URLSearchParams();
|
|
6379
|
+
if (channelIds.length) params.set("channelIds", channelIds.join(","));
|
|
6380
|
+
if (opts.includePublic != null) params.set("includePublic", opts.includePublic ? "1" : "0");
|
|
6381
|
+
const qs = params.toString();
|
|
6382
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/preview${qs ? `?${qs}` : ""}`);
|
|
6383
|
+
}
|
|
6384
|
+
/** Declare how buyers are meant to reach this channel. Drives the rail's
|
|
6385
|
+
* access line and turns "No buyer access configured" from information into a
|
|
6386
|
+
* warning when the organizer says the channel is for buyer self-service. */
|
|
6387
|
+
setChannelAccessIntent(key, channelId, accessIntent) {
|
|
6388
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
|
|
6389
|
+
method: "PATCH",
|
|
6390
|
+
body: { accessIntent }
|
|
6391
|
+
});
|
|
6392
|
+
}
|
|
5154
6393
|
// ---- reports (token) ----
|
|
5155
6394
|
report(key) {
|
|
5156
6395
|
return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
|
|
@@ -5177,6 +6416,1369 @@ var ManageApi = class {
|
|
|
5177
6416
|
}
|
|
5178
6417
|
};
|
|
5179
6418
|
|
|
6419
|
+
// src/channelsMode.ts
|
|
6420
|
+
var POLL_MS = 1e4;
|
|
6421
|
+
var MAX_FLAGS = 8;
|
|
6422
|
+
var SEAT_LIST_PAGE = 300;
|
|
6423
|
+
var CHANNELS_CSS = `
|
|
6424
|
+
.slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
|
|
6425
|
+
--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);
|
|
6426
|
+
--slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
|
|
6427
|
+
|
|
6428
|
+
/* map overlay: ONE layer, faded in as a whole (never per seat) */
|
|
6429
|
+
.slm-ch-layer{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
6430
|
+
.slm-ch-layer.on{opacity:1}
|
|
6431
|
+
.slm-ch-canvas{position:absolute;inset:0;width:100%;height:100%}
|
|
6432
|
+
.slm-ch-flag{position:absolute;display:flex;align-items:center;gap:5px;padding:3px 8px;border-radius:999px;
|
|
6433
|
+
background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
|
|
6434
|
+
transform:translate(-50%,-50%);white-space:nowrap}
|
|
6435
|
+
.slm-ch-flag .mk{width:14px;height:14px;border-radius:4px;display:grid;place-items:center;font-size:8.5px;font-weight:800;color:#0e1017}
|
|
6436
|
+
|
|
6437
|
+
/* preview banner \u2014 raised with the organizer chrome dim, as one transition */
|
|
6438
|
+
.slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
|
|
6439
|
+
border-radius:999px;background:rgba(14,16,23,.92);border:1px solid var(--slm-line);font-size:12px;font-weight:700;
|
|
6440
|
+
transform:translate(-50%,-8px);opacity:0;pointer-events:none;
|
|
6441
|
+
transition:opacity var(--slm-mo-base) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out)}
|
|
6442
|
+
.slm-ch-banner.on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
|
|
6443
|
+
.slm-ch-banner .dot{width:8px;height:8px;border-radius:50%}
|
|
6444
|
+
.slm-ch-banner button{color:var(--slm-accent);font-weight:800;font-size:11.5px;min-height:32px}
|
|
6445
|
+
.slm.ch-preview .slm-ch-flag{opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
6446
|
+
|
|
6447
|
+
/* sticky staged bar */
|
|
6448
|
+
.slm-ch-staged{position:absolute;left:12px;right:12px;bottom:12px;z-index:6;display:flex;align-items:center;gap:12px;
|
|
6449
|
+
padding:10px 14px;min-height:44px;border-radius:12px;background:rgba(24,27,36,.96);border:1px solid var(--slm-line);
|
|
6450
|
+
box-shadow:0 12px 34px rgba(0,0,0,.45);font-size:12.5px;pointer-events:auto;
|
|
6451
|
+
transform:translateY(calc(100% + 18px));opacity:0;
|
|
6452
|
+
transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
6453
|
+
.slm-ch-staged.on{transform:none;opacity:1}
|
|
6454
|
+
.slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
|
|
6455
|
+
.slm-ch-staged.shake{animation:slm-ch-shake 320ms var(--slm-mo-in-out) 2}
|
|
6456
|
+
.slm-ch-staged b{font-variant-numeric:tabular-nums}
|
|
6457
|
+
.slm-ch-staged .grow{flex:1}
|
|
6458
|
+
.slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
|
|
6459
|
+
background:#f4b740;color:#1a1200;font-weight:800;font-size:12.5px}
|
|
6460
|
+
.slm-ch-staged .drop{color:var(--slm-muted);font-weight:700;font-size:11.5px;min-height:44px;padding-inline:8px}
|
|
6461
|
+
.slm-ch-tick{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;
|
|
6462
|
+
background:#fff;color:#1f7a4d;font-weight:900;font-size:11px;animation:slm-ch-tick var(--slm-mo-base) var(--slm-mo-spring)}
|
|
6463
|
+
@keyframes slm-ch-shake{0%,100%{transform:none}25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}
|
|
6464
|
+
@keyframes slm-ch-tick{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
|
6465
|
+
|
|
6466
|
+
/* rail */
|
|
6467
|
+
.slm-ch-viewseg{display:flex;gap:3px;padding:3px;border:1px solid var(--slm-line);border-radius:9px;
|
|
6468
|
+
background:var(--slm-surface);margin-bottom:12px}
|
|
6469
|
+
.slm-ch-viewseg button{flex:1;padding:6px 8px;min-height:34px;border-radius:7px;font-size:11px;font-weight:800;color:var(--slm-muted)}
|
|
6470
|
+
.slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
6471
|
+
.slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
|
|
6472
|
+
.slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
|
|
6473
|
+
.slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
|
|
6474
|
+
text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
|
|
6475
|
+
.slm-ch-row:hover{border-color:var(--slm-muted)}
|
|
6476
|
+
.slm-ch-row.on{border-color:var(--slm-accent);box-shadow:0 0 0 1px color-mix(in srgb,var(--slm-accent) 40%,transparent)}
|
|
6477
|
+
.slm-ch-row.public{background:linear-gradient(100deg,rgba(244,183,64,.09),var(--slm-surface) 60%)}
|
|
6478
|
+
.slm-ch-row.archived{opacity:.68}
|
|
6479
|
+
.slm-ch-head{display:flex;align-items:center;gap:8px}
|
|
6480
|
+
.slm-ch-mk{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800;
|
|
6481
|
+
color:#0e1017;flex:none}
|
|
6482
|
+
.slm-ch-mk.dim{opacity:.55}
|
|
6483
|
+
.slm-ch-name{flex:1;min-width:0;font-size:13px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
6484
|
+
.slm-ch-badge{flex:none;font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:2px 7px;border-radius:999px}
|
|
6485
|
+
.slm-ch-badge.active{background:rgba(34,160,107,.16);color:#5bd39b}
|
|
6486
|
+
.slm-ch-badge.paused,.slm-ch-badge.builtin{background:rgba(244,183,64,.15);color:#f7ca6b}
|
|
6487
|
+
.slm-ch-badge.archived{background:rgba(139,148,172,.18);color:#c2c9d8}
|
|
6488
|
+
.slm-ch-counts{display:flex;gap:10px;flex-wrap:wrap;margin-top:7px;font-size:11px;color:var(--slm-muted);
|
|
6489
|
+
font-variant-numeric:tabular-nums}
|
|
6490
|
+
.slm-ch-counts b{color:var(--slm-text);font-weight:800}
|
|
6491
|
+
.slm-ch-counts .free b{color:#5bd39b}
|
|
6492
|
+
.slm-ch-counts b.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
|
|
6493
|
+
@keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
|
|
6494
|
+
.slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
|
|
6495
|
+
.slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
|
|
6496
|
+
.slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
|
|
6497
|
+
.slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
|
|
6498
|
+
.slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
|
|
6499
|
+
font-weight:800;color:#0e1017}
|
|
6500
|
+
.slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
|
|
6501
|
+
.slm-ch-selsrc-row span{color:var(--slm-muted)}
|
|
6502
|
+
.slm-ch-selnum.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
|
|
6503
|
+
.slm-ch-row2{display:flex;gap:8px;margin-top:8px}
|
|
6504
|
+
.slm-ch-row2 .slm-btn{flex:1;min-width:0}
|
|
6505
|
+
.slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
|
|
6506
|
+
line-height:1.5;margin-bottom:12px}
|
|
6507
|
+
.slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
|
|
6508
|
+
.slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
|
|
6509
|
+
.slm-ch-alert b{color:#fff}
|
|
6510
|
+
.slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
|
|
6511
|
+
.slm-ch-legend{display:flex;flex-direction:column;gap:6px;margin-top:10px}
|
|
6512
|
+
.slm-ch-legend .r{display:flex;align-items:center;gap:9px;font-size:12px;color:var(--slm-muted)}
|
|
6513
|
+
.slm-ch-legend .sw{width:13px;height:13px;border-radius:3.5px;flex:none}
|
|
6514
|
+
.slm-ch-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
6515
|
+
|
|
6516
|
+
/* dialogs */
|
|
6517
|
+
.slm-ch-scrim{position:absolute;inset:0;z-index:12;background:rgba(4,6,12,.62);display:grid;place-items:center;
|
|
6518
|
+
padding:18px;animation:slm-ch-fade var(--slm-mo-quick) var(--slm-mo-out)}
|
|
6519
|
+
.slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;background:#12151f;border:1px solid var(--slm-line);
|
|
6520
|
+
border-radius:14px;padding:20px;box-shadow:0 24px 70px rgba(0,0,0,.6);
|
|
6521
|
+
animation:slm-ch-rise var(--slm-mo-base) var(--slm-mo-out)}
|
|
6522
|
+
.slm-ch-dialog h3{margin:0 0 4px;font-size:16px;font-weight:800;letter-spacing:-.01em}
|
|
6523
|
+
.slm-ch-dialog .sub{font-size:12.5px;color:var(--slm-muted);line-height:1.5;margin-bottom:14px}
|
|
6524
|
+
.slm-ch-dialog .foot{display:flex;gap:8px;margin-top:16px}
|
|
6525
|
+
.slm-ch-dialog .foot .slm-btn{flex:1;min-width:0}
|
|
6526
|
+
.slm-ch-dialog .foot .quiet{flex:none;padding:10px 14px;min-height:44px;color:var(--slm-muted);font-weight:700;font-size:13px}
|
|
6527
|
+
@keyframes slm-ch-fade{from{opacity:0}to{opacity:1}}
|
|
6528
|
+
@keyframes slm-ch-rise{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
|
|
6529
|
+
.slm-ch-bucket{display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:10px;padding:9px 4px;
|
|
6530
|
+
border-top:1px solid var(--slm-line);font-size:12.5px;animation:slm-ch-bucket var(--slm-mo-base) var(--slm-mo-out) both}
|
|
6531
|
+
.slm-ch-bucket:first-of-type{border-top:0}
|
|
6532
|
+
.slm-ch-bucket .ico{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800}
|
|
6533
|
+
.slm-ch-bucket .ico.add{background:rgba(34,160,107,.18);color:#5bd39b}
|
|
6534
|
+
.slm-ch-bucket .ico.move{background:rgba(167,139,250,.18);color:#c4b5fd}
|
|
6535
|
+
.slm-ch-bucket .ico.same{background:rgba(139,148,172,.14);color:#aab2c4}
|
|
6536
|
+
.slm-ch-bucket .ico.skip{background:rgba(244,183,64,.16);color:#f7ca6b}
|
|
6537
|
+
.slm-ch-bucket b{font-variant-numeric:tabular-nums;font-weight:800}
|
|
6538
|
+
.slm-ch-bucket .why{color:var(--slm-muted);font-size:11px}
|
|
6539
|
+
.slm-ch-bucket .peek{color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums}
|
|
6540
|
+
@keyframes slm-ch-bucket{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
6541
|
+
.slm-ch-secret{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed rgba(244,183,64,.55);
|
|
6542
|
+
border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
|
|
6543
|
+
overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
6544
|
+
.slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
|
|
6545
|
+
.slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
|
|
6546
|
+
background:var(--slm-surface);margin-top:10px}
|
|
6547
|
+
.slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
|
|
6548
|
+
justify-content:space-between;gap:8px;font-size:11px;font-weight:800;color:var(--slm-muted);position:sticky;top:0;
|
|
6549
|
+
background:var(--slm-surface)}
|
|
6550
|
+
.slm-ch-seatgroup button{color:var(--slm-accent);font-weight:800;font-size:11px;min-height:32px}
|
|
6551
|
+
.slm-ch-seatitem{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;border-bottom:1px solid var(--slm-line);
|
|
6552
|
+
text-align:left;font-size:12px}
|
|
6553
|
+
.slm-ch-seatitem .box{width:16px;height:16px;border-radius:4px;border:1px solid var(--slm-muted);display:grid;
|
|
6554
|
+
place-items:center;font-size:10px;font-weight:900;color:transparent;flex:none}
|
|
6555
|
+
.slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
6556
|
+
.slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
|
|
6557
|
+
|
|
6558
|
+
/* compact: bottom sheet with three detents (\xA713) */
|
|
6559
|
+
.slm.compact.ch-sheet .slm-rail{position:absolute;left:0;right:0;bottom:0;z-index:8;border-top:1px solid var(--slm-line);
|
|
6560
|
+
border-radius:18px 18px 0 0;background:#12151f;
|
|
6561
|
+
transition:height var(--slm-mo-slow) var(--slm-mo-in-out)}
|
|
6562
|
+
.slm.compact.ch-sheet.detent-collapsed .slm-rail{height:132px}
|
|
6563
|
+
.slm.compact.ch-sheet.detent-medium .slm-rail{height:46%}
|
|
6564
|
+
.slm.compact.ch-sheet.detent-full .slm-rail{height:92%}
|
|
6565
|
+
.slm.compact.ch-sheet .slm-railscroll{padding:8px 14px calc(12px + env(safe-area-inset-bottom,0px))}
|
|
6566
|
+
.slm-ch-grab{display:none}
|
|
6567
|
+
.slm.compact.ch-sheet .slm-ch-grab{display:flex;align-items:center;gap:10px;width:100%;padding:6px 0 10px}
|
|
6568
|
+
.slm-ch-grabbar{width:42px;height:4px;border-radius:2px;background:rgba(255,255,255,.22);margin:0 auto}
|
|
6569
|
+
.slm.compact .slm-ch-staged{bottom:auto;top:8px}
|
|
6570
|
+
.slm.compact .slm-btn,.slm.compact .slm-ch-row,.slm.compact .slm-ch-viewseg button{min-height:44px}
|
|
6571
|
+
.slm-tools{display:none}
|
|
6572
|
+
.slm.compact .slm-tools{display:block;width:100%;padding:9px 13px;min-height:44px;border:1px solid var(--slm-line);
|
|
6573
|
+
border-radius:10px;background:var(--slm-surface);color:var(--slm-text);font-size:13px;font-weight:800}
|
|
6574
|
+
.slm.compact .slm-modes{display:none}
|
|
6575
|
+
|
|
6576
|
+
@media (prefers-reduced-motion:reduce){
|
|
6577
|
+
.slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail{transition:none!important}
|
|
6578
|
+
.slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
|
|
6579
|
+
.slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
|
|
6580
|
+
.slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
|
|
6581
|
+
.slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
|
|
6582
|
+
}
|
|
6583
|
+
`;
|
|
6584
|
+
function bucketRowsHtml(rows) {
|
|
6585
|
+
return rows.map((row, index) => `
|
|
6586
|
+
<div class="slm-ch-bucket" style="animation-delay:${Math.min(index, 4) * 30}ms">
|
|
6587
|
+
<span class="ico ${row.kind}" aria-hidden="true">${esc(row.icon)}</span>
|
|
6588
|
+
<span><b>${row.count.toLocaleString()}</b> ${esc(row.text.replace(/^[\d,.\s]+/, ""))}
|
|
6589
|
+
${row.why ? `<span class="why">\u2014 ${esc(row.why)}</span>` : ""}</span>
|
|
6590
|
+
${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
|
|
6591
|
+
</div>`).join("");
|
|
6592
|
+
}
|
|
6593
|
+
function esc(value) {
|
|
6594
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6595
|
+
}
|
|
6596
|
+
var ChannelsMode = class {
|
|
6597
|
+
constructor(host, capabilities) {
|
|
6598
|
+
this.active = false;
|
|
6599
|
+
this.list = null;
|
|
6600
|
+
this.allocation = /* @__PURE__ */ new Map();
|
|
6601
|
+
this.assignmentVersion = 0;
|
|
6602
|
+
this.loadError = null;
|
|
6603
|
+
this.loading = true;
|
|
6604
|
+
this.view = "inspect";
|
|
6605
|
+
this.showArchived = false;
|
|
6606
|
+
this.detailChannelId = null;
|
|
6607
|
+
this.targetChannelId = "";
|
|
6608
|
+
this.conflict = false;
|
|
6609
|
+
this.dialog = null;
|
|
6610
|
+
this.detent = "medium";
|
|
6611
|
+
this.seatListLimit = SEAT_LIST_PAGE;
|
|
6612
|
+
this.previewAudience = [];
|
|
6613
|
+
this.previewIncludePublic = false;
|
|
6614
|
+
this.previewProjection = null;
|
|
6615
|
+
this.previewSupported = null;
|
|
6616
|
+
// null = not yet probed
|
|
6617
|
+
this.pollTimer = null;
|
|
6618
|
+
this.layer = null;
|
|
6619
|
+
this.canvas = null;
|
|
6620
|
+
/** undefined = not resolved yet, null = this environment has no 2d canvas. */
|
|
6621
|
+
this.ctx = void 0;
|
|
6622
|
+
this.bannerEl = null;
|
|
6623
|
+
this.stagedEl = null;
|
|
6624
|
+
this.liveEl = null;
|
|
6625
|
+
this.scrimEl = null;
|
|
6626
|
+
this.lastFocus = null;
|
|
6627
|
+
this.stagedDoneTimer = null;
|
|
6628
|
+
this.lastSelectionCount = 0;
|
|
6629
|
+
this.lastCounts = /* @__PURE__ */ new Map();
|
|
6630
|
+
this.host = host;
|
|
6631
|
+
this.caps = capabilities;
|
|
6632
|
+
}
|
|
6633
|
+
// ---- lifecycle ------------------------------------------------------------
|
|
6634
|
+
/** Called when the cockpit switches into Channels mode. */
|
|
6635
|
+
enter() {
|
|
6636
|
+
if (this.active) return;
|
|
6637
|
+
this.active = true;
|
|
6638
|
+
this.ensureLayer();
|
|
6639
|
+
this.host.root.classList.add("ch-mode");
|
|
6640
|
+
this.applySheetClasses();
|
|
6641
|
+
this.paintRail();
|
|
6642
|
+
void this.refresh();
|
|
6643
|
+
this.pollTimer = setInterval(() => {
|
|
6644
|
+
void this.refresh({ quiet: true });
|
|
6645
|
+
}, POLL_MS);
|
|
6646
|
+
}
|
|
6647
|
+
/** Called when the cockpit leaves Channels mode. Everything this mode painted
|
|
6648
|
+
* over the map goes with it — no other tool ever inherits a channel overlay. */
|
|
6649
|
+
leave() {
|
|
6650
|
+
if (!this.active) return;
|
|
6651
|
+
this.active = false;
|
|
6652
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
6653
|
+
this.pollTimer = null;
|
|
6654
|
+
this.closeDialog({ restoreFocus: false });
|
|
6655
|
+
this.layer?.classList.remove("on");
|
|
6656
|
+
this.host.root.classList.remove(
|
|
6657
|
+
"ch-mode",
|
|
6658
|
+
"ch-preview",
|
|
6659
|
+
"ch-sheet",
|
|
6660
|
+
"detent-collapsed",
|
|
6661
|
+
"detent-medium",
|
|
6662
|
+
"detent-full"
|
|
6663
|
+
);
|
|
6664
|
+
this.setBanner(false);
|
|
6665
|
+
this.setStaged(null);
|
|
6666
|
+
}
|
|
6667
|
+
destroy() {
|
|
6668
|
+
this.leave();
|
|
6669
|
+
if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
|
|
6670
|
+
this.layer?.remove();
|
|
6671
|
+
this.layer = null;
|
|
6672
|
+
}
|
|
6673
|
+
/** Capabilities can change when a token rotates. Re-render, fail-closed. */
|
|
6674
|
+
setCapabilities(capabilities) {
|
|
6675
|
+
this.caps = capabilities;
|
|
6676
|
+
if (this.active) this.paintRail();
|
|
6677
|
+
}
|
|
6678
|
+
isActive() {
|
|
6679
|
+
return this.active;
|
|
6680
|
+
}
|
|
6681
|
+
/**
|
|
6682
|
+
* Whether the map should accept bulk selection right now. Preview is a
|
|
6683
|
+
* read-only simulation of somebody else's view, and a view-only token has no
|
|
6684
|
+
* assignment to stage — in both cases the canvas must not offer selection at
|
|
6685
|
+
* all rather than collect a selection nothing can act on.
|
|
6686
|
+
*/
|
|
6687
|
+
canSelect() {
|
|
6688
|
+
return this.caps.manage && this.view === "inspect";
|
|
6689
|
+
}
|
|
6690
|
+
/**
|
|
6691
|
+
* Organizer realtime integration point. M5 ships a per-scope socket for
|
|
6692
|
+
* buyers; the organizer channel-count stream is a later milestone. When it
|
|
6693
|
+
* arrives, call this from the cockpit's WS handler instead of waiting for the
|
|
6694
|
+
* poll — everything downstream already reacts to a fresh list.
|
|
6695
|
+
*/
|
|
6696
|
+
applyRealtimeHint() {
|
|
6697
|
+
if (this.active) void this.refresh({ quiet: true });
|
|
6698
|
+
}
|
|
6699
|
+
/** The cockpit's selection changed (marquee / click / section / category). */
|
|
6700
|
+
handleSelectionChange() {
|
|
6701
|
+
if (!this.active) return;
|
|
6702
|
+
this.paintSelection();
|
|
6703
|
+
this.paintStagedBar();
|
|
6704
|
+
}
|
|
6705
|
+
/** Camera moved or the container resized — the overlay is screen-space. */
|
|
6706
|
+
handleViewChange() {
|
|
6707
|
+
if (this.active) this.paintOverlay();
|
|
6708
|
+
}
|
|
6709
|
+
handleLayoutChange() {
|
|
6710
|
+
if (!this.active) return;
|
|
6711
|
+
this.applySheetClasses();
|
|
6712
|
+
this.paintOverlay();
|
|
6713
|
+
}
|
|
6714
|
+
// ---- data -----------------------------------------------------------------
|
|
6715
|
+
async refresh(opts = {}) {
|
|
6716
|
+
if (!this.caps.view) return;
|
|
6717
|
+
try {
|
|
6718
|
+
const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
|
|
6719
|
+
this.list = list;
|
|
6720
|
+
this.assignmentVersion = list.assignmentVersion;
|
|
6721
|
+
this.loadError = null;
|
|
6722
|
+
if (!this.targetChannelId) {
|
|
6723
|
+
this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
|
|
6724
|
+
}
|
|
6725
|
+
await this.loadAllocation();
|
|
6726
|
+
this.loading = false;
|
|
6727
|
+
if (this.active) {
|
|
6728
|
+
this.paintRail();
|
|
6729
|
+
this.paintOverlay();
|
|
6730
|
+
}
|
|
6731
|
+
} catch (err) {
|
|
6732
|
+
this.loading = false;
|
|
6733
|
+
if (err instanceof ManageApiError && err.status === 403) {
|
|
6734
|
+
this.caps = { view: false, manage: false };
|
|
6735
|
+
}
|
|
6736
|
+
this.loadError = err;
|
|
6737
|
+
if (!opts.quiet) this.host.onError(err);
|
|
6738
|
+
if (this.active) this.paintRail();
|
|
6739
|
+
}
|
|
6740
|
+
}
|
|
6741
|
+
/** Walk every allocation page. Bounded by the event's seat count, and the
|
|
6742
|
+
* server caps each page, so an arena is a handful of round trips. */
|
|
6743
|
+
async loadAllocation() {
|
|
6744
|
+
const next = /* @__PURE__ */ new Map();
|
|
6745
|
+
let afterLabel;
|
|
6746
|
+
for (let page = 0; page < 200; page += 1) {
|
|
6747
|
+
const res = await this.host.api.channelAllocation(this.host.eventKey, {
|
|
6748
|
+
afterLabel,
|
|
6749
|
+
limit: 1e3
|
|
6750
|
+
});
|
|
6751
|
+
for (const row of res.allocations) {
|
|
6752
|
+
if (row.channelId && row.channelId !== PUBLIC_CHANNEL_ID) next.set(row.label, row.channelId);
|
|
6753
|
+
}
|
|
6754
|
+
this.assignmentVersion = res.assignmentVersion;
|
|
6755
|
+
if (!res.nextAfterLabel) break;
|
|
6756
|
+
afterLabel = res.nextAfterLabel;
|
|
6757
|
+
}
|
|
6758
|
+
this.allocation = next;
|
|
6759
|
+
}
|
|
6760
|
+
// ---- lookups --------------------------------------------------------------
|
|
6761
|
+
channelById(id) {
|
|
6762
|
+
if (id === PUBLIC_CHANNEL_ID) {
|
|
6763
|
+
return { id, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME, marker: "P", color: null };
|
|
6764
|
+
}
|
|
6765
|
+
const found = this.list?.channels.find((channel) => channel.id === id);
|
|
6766
|
+
return found ? { id, name: found.name, marker: found.marker, color: found.color } : null;
|
|
6767
|
+
}
|
|
6768
|
+
nameOf(id) {
|
|
6769
|
+
return this.channelById(id)?.name ?? null;
|
|
6770
|
+
}
|
|
6771
|
+
markerFor(id) {
|
|
6772
|
+
const index = Math.max(0, this.list?.channels.findIndex((channel2) => channel2.id === id) ?? 0);
|
|
6773
|
+
const channel = this.channelById(id);
|
|
6774
|
+
return markerOf(channel ?? { id, name: "?", marker: null, color: null }, index);
|
|
6775
|
+
}
|
|
6776
|
+
/** Channels an organizer may assign INTO: public sale plus every live channel. */
|
|
6777
|
+
assignableChannels() {
|
|
6778
|
+
return [
|
|
6779
|
+
{ id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
6780
|
+
...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
|
|
6781
|
+
];
|
|
6782
|
+
}
|
|
6783
|
+
currentPlan() {
|
|
6784
|
+
const labels = this.host.selectionLabels();
|
|
6785
|
+
const buckets = planAssignment({
|
|
6786
|
+
labels,
|
|
6787
|
+
targetChannelId: this.targetChannelId,
|
|
6788
|
+
allocation: this.allocation,
|
|
6789
|
+
statusOf: (label) => this.host.statusOf(label),
|
|
6790
|
+
nameOf: (id) => this.nameOf(id)
|
|
6791
|
+
});
|
|
6792
|
+
return { labels, buckets, target: this.targetChannelId };
|
|
6793
|
+
}
|
|
6794
|
+
// ---- map overlay ----------------------------------------------------------
|
|
6795
|
+
ensureLayer() {
|
|
6796
|
+
if (this.layer) return;
|
|
6797
|
+
const layer = document.createElement("div");
|
|
6798
|
+
layer.className = "slm-ch-layer";
|
|
6799
|
+
layer.innerHTML = `
|
|
6800
|
+
<canvas class="slm-ch-canvas" data-ch="canvas" aria-hidden="true"></canvas>
|
|
6801
|
+
<div class="slm-ch-banner" data-ch="banner" role="status"></div>
|
|
6802
|
+
<div class="slm-ch-staged" data-ch="staged" role="group" aria-label="Staged channel changes"></div>
|
|
6803
|
+
<div class="slm-ch-live" data-ch="live" role="status" aria-live="polite"></div>`;
|
|
6804
|
+
this.host.mapLayer.appendChild(layer);
|
|
6805
|
+
this.layer = layer;
|
|
6806
|
+
this.canvas = layer.querySelector('[data-ch="canvas"]');
|
|
6807
|
+
this.bannerEl = layer.querySelector('[data-ch="banner"]');
|
|
6808
|
+
this.stagedEl = layer.querySelector('[data-ch="staged"]');
|
|
6809
|
+
this.liveEl = layer.querySelector('[data-ch="live"]');
|
|
6810
|
+
requestAnimationFrame(() => layer.classList.add("on"));
|
|
6811
|
+
}
|
|
6812
|
+
announce(message) {
|
|
6813
|
+
if (this.liveEl) this.liveEl.textContent = message;
|
|
6814
|
+
}
|
|
6815
|
+
/**
|
|
6816
|
+
* Repaint the allocation (or preview) overlay in ONE canvas pass.
|
|
6817
|
+
*
|
|
6818
|
+
* Channel identity on the map is a fill in the administrative color PLUS the
|
|
6819
|
+
* letter flags below — never color alone. Physical status keeps its own cue:
|
|
6820
|
+
* only FREE units take a channel fill, so sold/held/blocked seats still read
|
|
6821
|
+
* exactly as they do in every other tool.
|
|
6822
|
+
*/
|
|
6823
|
+
paintOverlay() {
|
|
6824
|
+
const canvas = this.canvas;
|
|
6825
|
+
const layer = this.layer;
|
|
6826
|
+
if (!canvas || !layer) return;
|
|
6827
|
+
const rect = this.host.mapLayer.getBoundingClientRect();
|
|
6828
|
+
const width = Math.max(1, Math.round(rect.width));
|
|
6829
|
+
const height = Math.max(1, Math.round(rect.height));
|
|
6830
|
+
const dpr = typeof devicePixelRatio === "number" ? Math.min(3, Math.max(1, devicePixelRatio)) : 1;
|
|
6831
|
+
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
|
|
6832
|
+
canvas.width = width * dpr;
|
|
6833
|
+
canvas.height = height * dpr;
|
|
6834
|
+
}
|
|
6835
|
+
if (this.ctx === void 0) {
|
|
6836
|
+
try {
|
|
6837
|
+
this.ctx = canvas.getContext("2d");
|
|
6838
|
+
} catch {
|
|
6839
|
+
this.ctx = null;
|
|
6840
|
+
}
|
|
6841
|
+
}
|
|
6842
|
+
const ctx = this.ctx;
|
|
6843
|
+
if (!ctx) return;
|
|
6844
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
6845
|
+
ctx.clearRect(0, 0, width, height);
|
|
6846
|
+
const size = Math.max(3, this.host.seatPixelSize());
|
|
6847
|
+
const half = size / 2;
|
|
6848
|
+
const projection = this.view === "preview" ? this.previewProjection : null;
|
|
6849
|
+
const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
|
|
6850
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
6851
|
+
for (const seat of this.host.seats()) {
|
|
6852
|
+
const status = this.host.statusOf(seat.label) ?? "free";
|
|
6853
|
+
const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
|
|
6854
|
+
if (channelId !== PUBLIC_CHANNEL_ID) {
|
|
6855
|
+
const cluster = clusters.get(channelId) ?? { x: 0, y: 0, n: 0 };
|
|
6856
|
+
cluster.x += seat.x;
|
|
6857
|
+
cluster.y += seat.y;
|
|
6858
|
+
cluster.n += 1;
|
|
6859
|
+
clusters.set(channelId, cluster);
|
|
6860
|
+
}
|
|
6861
|
+
if (status !== "free") continue;
|
|
6862
|
+
let fill = null;
|
|
6863
|
+
if (this.view === "preview") {
|
|
6864
|
+
fill = eligible ? eligible.has(seat.label) ? null : "#3a4051" : null;
|
|
6865
|
+
} else if (channelId !== PUBLIC_CHANNEL_ID) {
|
|
6866
|
+
fill = this.markerFor(channelId).color;
|
|
6867
|
+
}
|
|
6868
|
+
if (!fill) continue;
|
|
6869
|
+
const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
|
|
6870
|
+
if (!point) continue;
|
|
6871
|
+
if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
|
|
6872
|
+
ctx.fillStyle = fill;
|
|
6873
|
+
ctx.globalAlpha = this.view === "preview" ? 0.9 : 0.85;
|
|
6874
|
+
ctx.fillRect(point.x - half, point.y - half, size, size);
|
|
6875
|
+
}
|
|
6876
|
+
ctx.globalAlpha = 1;
|
|
6877
|
+
this.paintFlags(clusters);
|
|
6878
|
+
}
|
|
6879
|
+
/** Letter flags at each channel's centroid — the non-color identity cue. */
|
|
6880
|
+
paintFlags(clusters) {
|
|
6881
|
+
const layer = this.layer;
|
|
6882
|
+
if (!layer) return;
|
|
6883
|
+
layer.querySelectorAll(".slm-ch-flag").forEach((el) => el.remove());
|
|
6884
|
+
if (this.view === "preview") return;
|
|
6885
|
+
const ranked = [...clusters.entries()].sort((a, b) => b[1].n - a[1].n).slice(0, MAX_FLAGS);
|
|
6886
|
+
for (const [channelId, cluster] of ranked) {
|
|
6887
|
+
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
6888
|
+
if (!channel) continue;
|
|
6889
|
+
const point = this.host.worldToScreen({ x: cluster.x / cluster.n, y: cluster.y / cluster.n });
|
|
6890
|
+
if (!point) continue;
|
|
6891
|
+
const marker = this.markerFor(channelId);
|
|
6892
|
+
const flag = document.createElement("span");
|
|
6893
|
+
flag.className = "slm-ch-flag";
|
|
6894
|
+
flag.style.left = `${point.x}px`;
|
|
6895
|
+
flag.style.top = `${point.y}px`;
|
|
6896
|
+
flag.innerHTML = `<span class="mk" style="background:${esc(marker.color)}">${esc(marker.letter)}</span>${esc(channel.name)}${channel.state === "paused" ? " \xB7 Paused" : ""}`;
|
|
6897
|
+
layer.appendChild(flag);
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
// ---- staged bar -----------------------------------------------------------
|
|
6901
|
+
setStaged(html, cls = "") {
|
|
6902
|
+
const bar = this.stagedEl;
|
|
6903
|
+
if (!bar) return;
|
|
6904
|
+
if (!html) {
|
|
6905
|
+
bar.classList.remove("on", "done", "shake");
|
|
6906
|
+
bar.innerHTML = "";
|
|
6907
|
+
return;
|
|
6908
|
+
}
|
|
6909
|
+
bar.innerHTML = html;
|
|
6910
|
+
bar.className = `slm-ch-staged on${cls ? ` ${cls}` : ""}`;
|
|
6911
|
+
}
|
|
6912
|
+
paintStagedBar() {
|
|
6913
|
+
if (!this.active || this.view === "preview" || !this.caps.manage) {
|
|
6914
|
+
this.setStaged(null);
|
|
6915
|
+
return;
|
|
6916
|
+
}
|
|
6917
|
+
const { labels, buckets } = this.currentPlan();
|
|
6918
|
+
this.host.onStagedChange?.(mutationCount(buckets));
|
|
6919
|
+
if (!labels.length) {
|
|
6920
|
+
this.setStaged(null);
|
|
6921
|
+
return;
|
|
6922
|
+
}
|
|
6923
|
+
const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
|
|
6924
|
+
const mutations = mutationCount(buckets);
|
|
6925
|
+
const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
|
|
6926
|
+
const parts = [
|
|
6927
|
+
`<b>${labels.length.toLocaleString()}</b> selected`,
|
|
6928
|
+
`<b>+${mutations.toLocaleString()}</b> to ${esc(target)}`
|
|
6929
|
+
];
|
|
6930
|
+
if (buckets.alreadyInTarget.count) parts.push(`<b>${buckets.alreadyInTarget.count.toLocaleString()}</b> already in`);
|
|
6931
|
+
if (skipped) parts.push(`<b>${skipped.toLocaleString()}</b> can't move now`);
|
|
6932
|
+
this.setStaged(`
|
|
6933
|
+
<span>${parts.join(" \xB7 ")}</span>
|
|
6934
|
+
<span class="grow"></span>
|
|
6935
|
+
<button type="button" class="drop" data-ch-act="discard">Discard</button>
|
|
6936
|
+
<button type="button" class="go" data-ch-act="review">Review changes</button>`);
|
|
6937
|
+
this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
|
|
6938
|
+
button.addEventListener("click", () => {
|
|
6939
|
+
if (button.dataset.chAct === "discard") this.host.clearSelection();
|
|
6940
|
+
else this.openDialog({ kind: "review" });
|
|
6941
|
+
});
|
|
6942
|
+
});
|
|
6943
|
+
}
|
|
6944
|
+
setBanner(on, name = "") {
|
|
6945
|
+
const banner = this.bannerEl;
|
|
6946
|
+
if (!banner) return;
|
|
6947
|
+
this.host.root.classList.toggle("ch-preview", on);
|
|
6948
|
+
if (!on) {
|
|
6949
|
+
banner.classList.remove("on");
|
|
6950
|
+
banner.innerHTML = "";
|
|
6951
|
+
return;
|
|
6952
|
+
}
|
|
6953
|
+
const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
|
|
6954
|
+
banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
|
|
6955
|
+
Previewing buyer access \xB7 ${esc(name)} \xB7 read-only
|
|
6956
|
+
<button type="button" data-ch-act="exit-preview">Exit preview</button>`;
|
|
6957
|
+
banner.classList.add("on");
|
|
6958
|
+
banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
|
|
6959
|
+
}
|
|
6960
|
+
// ---- rail -----------------------------------------------------------------
|
|
6961
|
+
setView(view) {
|
|
6962
|
+
this.view = view;
|
|
6963
|
+
if (view === "inspect") {
|
|
6964
|
+
this.previewProjection = null;
|
|
6965
|
+
this.setBanner(false);
|
|
6966
|
+
} else {
|
|
6967
|
+
if (!this.previewAudience.length) {
|
|
6968
|
+
const first = this.list?.channels.find((channel) => channel.state === "active");
|
|
6969
|
+
this.previewAudience = [first ? first.id : PUBLIC_CHANNEL_ID];
|
|
6970
|
+
}
|
|
6971
|
+
void this.loadPreview();
|
|
6972
|
+
}
|
|
6973
|
+
this.paintRail();
|
|
6974
|
+
this.paintOverlay();
|
|
6975
|
+
this.paintStagedBar();
|
|
6976
|
+
this.onInteractionChange?.();
|
|
6977
|
+
}
|
|
6978
|
+
async loadPreview() {
|
|
6979
|
+
const audience = [...this.previewAudience];
|
|
6980
|
+
const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
|
|
6981
|
+
this.setBanner(true, names);
|
|
6982
|
+
try {
|
|
6983
|
+
this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
|
|
6984
|
+
includePublic: this.previewIncludePublic
|
|
6985
|
+
});
|
|
6986
|
+
this.previewSupported = true;
|
|
6987
|
+
} catch (err) {
|
|
6988
|
+
const status = err instanceof ManageApiError ? err.status : 0;
|
|
6989
|
+
this.previewSupported = !(status === 404 || status === 405 || status === 501);
|
|
6990
|
+
this.previewProjection = null;
|
|
6991
|
+
if (this.previewSupported) this.host.onError(err);
|
|
6992
|
+
}
|
|
6993
|
+
if (this.active) {
|
|
6994
|
+
this.paintRail();
|
|
6995
|
+
this.paintOverlay();
|
|
6996
|
+
}
|
|
6997
|
+
}
|
|
6998
|
+
paintRail() {
|
|
6999
|
+
if (!this.active) return;
|
|
7000
|
+
const rail = this.host.rail;
|
|
7001
|
+
if (!this.caps.view) {
|
|
7002
|
+
rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
|
|
7003
|
+
<p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`;
|
|
7004
|
+
return;
|
|
7005
|
+
}
|
|
7006
|
+
if (this.loading && !this.list) {
|
|
7007
|
+
rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
|
|
7008
|
+
<div class="slm-empty">Loading allocations\u2026</div>`;
|
|
7009
|
+
return;
|
|
7010
|
+
}
|
|
7011
|
+
if (!this.list && this.loadError) {
|
|
7012
|
+
rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
|
|
7013
|
+
<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
|
|
7014
|
+
<span><b>Couldn't load sales channels.</b> Everything else on this event still works.
|
|
7015
|
+
<button type="button" data-ch-act="retry">Try again</button></span></div>`;
|
|
7016
|
+
rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
|
|
7017
|
+
void this.refresh();
|
|
7018
|
+
});
|
|
7019
|
+
return;
|
|
7020
|
+
}
|
|
7021
|
+
const selection = this.host.selectionLabels();
|
|
7022
|
+
const grab = this.host.isCompact() ? `<div class="slm-ch-grab"><span class="slm-ch-grabbar"></span></div>` : "";
|
|
7023
|
+
const segment = this.viewSegmentHtml();
|
|
7024
|
+
const body = this.view === "preview" ? this.previewRailHtml() : this.detailChannelId ? this.detailRailHtml(this.detailChannelId) : selection.length && this.caps.manage ? this.selectionRailHtml(selection) : this.listRailHtml();
|
|
7025
|
+
rail.innerHTML = `${grab}${segment}${body}`;
|
|
7026
|
+
this.wireRail();
|
|
7027
|
+
this.paintStagedBar();
|
|
7028
|
+
}
|
|
7029
|
+
viewSegmentHtml() {
|
|
7030
|
+
const inspectOn = this.view === "inspect" ? " on" : "";
|
|
7031
|
+
const previewOn = this.view === "preview" ? " on" : "";
|
|
7032
|
+
return `<div class="slm-ch-viewseg" role="group" aria-label="Channels view">
|
|
7033
|
+
<button type="button" class="${inspectOn.trim()}" data-ch-view="inspect"
|
|
7034
|
+
aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
|
|
7035
|
+
<button type="button" class="${previewOn.trim()}" data-ch-view="preview"
|
|
7036
|
+
aria-pressed="${this.view === "preview"}">Preview buyer access</button>
|
|
7037
|
+
</div>`;
|
|
7038
|
+
}
|
|
7039
|
+
countsHtml(counts, key) {
|
|
7040
|
+
const cell = (id, value, label, cls = "") => {
|
|
7041
|
+
const previous = this.lastCounts.get(`${key}:${id}`);
|
|
7042
|
+
const bump = previous != null && previous !== value ? " bump" : "";
|
|
7043
|
+
this.lastCounts.set(`${key}:${id}`, value);
|
|
7044
|
+
return `<span class="${cls}"><b class="${bump.trim()}">${value.toLocaleString()}</b> ${label}</span>`;
|
|
7045
|
+
};
|
|
7046
|
+
return `<span class="slm-ch-counts">
|
|
7047
|
+
${cell("allocated", counts.allocated, "allocated")}
|
|
7048
|
+
${cell("free", counts.free, "free", "free")}
|
|
7049
|
+
${cell("booked", counts.booked, "sold")}
|
|
7050
|
+
${counts.held ? cell("held", counts.held, "held") : ""}
|
|
7051
|
+
</span>`;
|
|
7052
|
+
}
|
|
7053
|
+
channelRowHtml(channel, opts = {}) {
|
|
7054
|
+
const marker = this.markerFor(channel.id);
|
|
7055
|
+
const badgeKind = opts.builtin ? "builtin" : channel.state;
|
|
7056
|
+
const dim = channel.state === "paused" || channel.state === "archived" ? " dim" : "";
|
|
7057
|
+
const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}`;
|
|
7058
|
+
const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-detail="${esc(channel.id)}"
|
|
7059
|
+
aria-label="Manage ${esc(channel.name)}">\u22EF</button>` : "";
|
|
7060
|
+
return `<div class="${cls}">
|
|
7061
|
+
<span class="slm-ch-head">
|
|
7062
|
+
<span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
|
|
7063
|
+
<span class="slm-ch-name">${esc(channel.name)}</span>
|
|
7064
|
+
<span class="slm-ch-badge ${badgeKind}">${esc(stateBadge(opts.builtin ? "builtin" : channel.state))}</span>
|
|
7065
|
+
${more}
|
|
7066
|
+
</span>
|
|
7067
|
+
${this.countsHtml(channel.counts, channel.id || "public")}
|
|
7068
|
+
${opts.builtin ? "" : `<span class="slm-ch-access">${esc(accessLine(channel.access))}</span>`}
|
|
7069
|
+
</div>`;
|
|
7070
|
+
}
|
|
7071
|
+
listRailHtml() {
|
|
7072
|
+
const list = this.list;
|
|
7073
|
+
const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
|
|
7074
|
+
const rows = [
|
|
7075
|
+
this.channelRowHtml(list.publicSale, { builtin: true }),
|
|
7076
|
+
...list.channels.filter((channel) => this.showArchived || channel.state !== "archived").map((channel, index) => this.channelRowHtml(channel, { index }))
|
|
7077
|
+
].join("");
|
|
7078
|
+
const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
|
|
7079
|
+
const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
|
|
7080
|
+
return `
|
|
7081
|
+
<p class="slm-eyebrow">Sales channels</p>
|
|
7082
|
+
<p class="slm-hint">Select seats on the map, then assign them. Channel colours and names are never shown to buyers.</p>
|
|
7083
|
+
<div class="slm-ch-list">${rows}</div>
|
|
7084
|
+
${create}
|
|
7085
|
+
${readOnly}
|
|
7086
|
+
<p class="slm-note" style="margin-top:10px">
|
|
7087
|
+
<button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
|
|
7088
|
+
style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
|
|
7089
|
+
</p>`;
|
|
7090
|
+
}
|
|
7091
|
+
selectionRailHtml(selection) {
|
|
7092
|
+
const sources = selectionSources(selection, this.allocation, this.list);
|
|
7093
|
+
const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
|
|
7094
|
+
<span><b>Assignments changed while you were editing.</b> Nothing was applied.
|
|
7095
|
+
Your ${selection.length.toLocaleString()}-seat selection is kept.
|
|
7096
|
+
<button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
|
|
7097
|
+
const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
|
|
7098
|
+
this.lastSelectionCount = selection.length;
|
|
7099
|
+
const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
|
|
7100
|
+
const sourceRows = sources.map((row) => {
|
|
7101
|
+
const marker = this.markerFor(row.channelId);
|
|
7102
|
+
return `<div class="slm-ch-selsrc-row">
|
|
7103
|
+
<span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
|
|
7104
|
+
<b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
|
|
7105
|
+
}).join("");
|
|
7106
|
+
const sectionSelect = this.host.sections().length ? `<button type="button" class="slm-btn ghost" data-ch-act="pick-section">Section</button>` : "";
|
|
7107
|
+
return `
|
|
7108
|
+
${conflict}
|
|
7109
|
+
<div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
|
|
7110
|
+
<span class="slm-sellabel">selected</span></div>
|
|
7111
|
+
<div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
|
|
7112
|
+
<div class="slm-field">
|
|
7113
|
+
<label for="slm-ch-target">Assign to</label>
|
|
7114
|
+
<select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
|
|
7115
|
+
</div>
|
|
7116
|
+
<p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
|
|
7117
|
+
Seats in checkout or already sold are never moved.</p>
|
|
7118
|
+
<div class="slm-ch-row2">
|
|
7119
|
+
<button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
|
|
7120
|
+
<button type="button" class="slm-btn" data-ch-act="review">Review changes</button>
|
|
7121
|
+
</div>
|
|
7122
|
+
<p class="slm-eyebrow" style="margin-top:18px">Select by</p>
|
|
7123
|
+
<div class="slm-ch-row2" style="margin-top:2px">
|
|
7124
|
+
${sectionSelect}
|
|
7125
|
+
<button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
|
|
7126
|
+
<button type="button" class="slm-btn ghost" data-ch-act="seatlist">List \u2328</button>
|
|
7127
|
+
</div>
|
|
7128
|
+
<p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
|
|
7129
|
+
}
|
|
7130
|
+
detailRailHtml(channelId) {
|
|
7131
|
+
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
7132
|
+
if (!channel) return this.listRailHtml();
|
|
7133
|
+
const lifecycle = this.caps.manage ? `
|
|
7134
|
+
<p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
|
|
7135
|
+
<div class="slm-ch-row2" style="margin-top:2px">
|
|
7136
|
+
<button type="button" class="slm-btn ghost" data-ch-act="rename">Rename</button>
|
|
7137
|
+
<button type="button" class="slm-btn ghost" data-ch-act="pause">${channel.state === "paused" ? "Resume" : "Pause"}</button>
|
|
7138
|
+
<button type="button" class="slm-btn ghost" data-ch-act="archive">Archive\u2026</button>
|
|
7139
|
+
</div>
|
|
7140
|
+
<p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
|
|
7141
|
+
const intent = channel.access?.intent ?? "none";
|
|
7142
|
+
const intents = ["none", "internal", "server", "hosted_link"];
|
|
7143
|
+
const selfServiceGap = (intent === "server" || intent === "hosted_link") && !channel.access?.hasActiveGrants;
|
|
7144
|
+
const access = this.caps.manage ? `
|
|
7145
|
+
<p class="slm-eyebrow" style="margin-top:14px">Buyer access</p>
|
|
7146
|
+
<div class="slm-field">
|
|
7147
|
+
<label for="slm-ch-intent">How should buyers reach this channel?</label>
|
|
7148
|
+
<select class="slm-select" id="slm-ch-intent" data-ch-intent>
|
|
7149
|
+
${intents.map((value) => `<option value="${value}"${value === intent ? " selected" : ""}>${esc(accessIntentLabel(value))}</option>`).join("")}
|
|
7150
|
+
</select>
|
|
7151
|
+
</div>
|
|
7152
|
+
<p class="slm-hint">${channel.access?.hasActiveGrants ? "Buyer access is live. Only this audience can buy from the allocation." : "No buyer access is configured yet. The allocation is protected \u2014 it is not available to Public sale."}</p>
|
|
7153
|
+
${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
|
|
7154
|
+
<span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
|
|
7155
|
+
<button type="button" class="slm-btn" data-ch-act="hosted-link" disabled
|
|
7156
|
+
title="Hosted access links ship in the next milestone">Create hosted access link \xB7 Coming soon</button>
|
|
7157
|
+
<div class="slm-ch-row2"><button type="button" class="slm-btn ghost" data-ch-act="server-access" disabled
|
|
7158
|
+
title="Guided server setup ships in the next milestone">Configure server integration \xB7 Coming soon</button></div>
|
|
7159
|
+
<p class="slm-note">Your own server can already mint buyer access sessions for this channel with the server SDK.</p>` : "";
|
|
7160
|
+
return `
|
|
7161
|
+
<p class="slm-eyebrow">
|
|
7162
|
+
<button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
|
|
7163
|
+
</p>
|
|
7164
|
+
<p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
|
|
7165
|
+
<div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
|
|
7166
|
+
${access}
|
|
7167
|
+
${lifecycle}`;
|
|
7168
|
+
}
|
|
7169
|
+
previewRailHtml() {
|
|
7170
|
+
const audienceOptions = [
|
|
7171
|
+
{ id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
7172
|
+
...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
|
|
7173
|
+
];
|
|
7174
|
+
const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
|
|
7175
|
+
const options = audienceOptions.map((entry) => `<option value="${esc(entry.id)}"${entry.id === current ? " selected" : ""}>${esc(entry.name)}</option>`).join("");
|
|
7176
|
+
const unsupported = this.previewSupported === false ? `<div class="slm-ch-alert warn"><span>\u2139</span>
|
|
7177
|
+
<span><b>Preview needs a newer server.</b> Allocation management works normally;
|
|
7178
|
+
the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
|
|
7179
|
+
const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
|
|
7180
|
+
<span><b>This private sale is not available.</b> ${esc((this.previewProjection.unavailable ?? []).map((entry) => `${this.nameOf(entry.channelId) ?? "This channel"} is ${entry.state}`).join("; ") || "The audience cannot buy right now")}.
|
|
7181
|
+
A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
|
|
7182
|
+
const counts = this.previewProjection?.counts;
|
|
7183
|
+
const summary = counts?.eligible != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>${counts.eligible.toLocaleString()} seats are buyable
|
|
7184
|
+
through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
|
|
7185
|
+
const includePublic = current === PUBLIC_CHANNEL_ID ? "" : `
|
|
7186
|
+
<label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
|
|
7187
|
+
<input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
|
|
7188
|
+
Also include Public sale seats in this grant
|
|
7189
|
+
</label>`;
|
|
7190
|
+
return `
|
|
7191
|
+
<p class="slm-eyebrow">Preview buyer access</p>
|
|
7192
|
+
<div class="slm-field">
|
|
7193
|
+
<label for="slm-ch-audience">Audience</label>
|
|
7194
|
+
<select class="slm-select" id="slm-ch-audience" data-ch-audience>${options}</select>
|
|
7195
|
+
</div>
|
|
7196
|
+
${includePublic}
|
|
7197
|
+
<p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
|
|
7198
|
+
approximation. It is read-only: clicks open seat details, and no holds are created.</p>
|
|
7199
|
+
${unsupported}${unavailable}
|
|
7200
|
+
<div class="slm-ch-legend">
|
|
7201
|
+
<div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible & free \u2014 buyable by this audience</div>
|
|
7202
|
+
<div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
|
|
7203
|
+
<div class="r"><span class="sw" style="background:#22a06b"></span> Sold \u2014 same as any buyer sees</div>
|
|
7204
|
+
</div>
|
|
7205
|
+
${summary}`;
|
|
7206
|
+
}
|
|
7207
|
+
paintSelection() {
|
|
7208
|
+
if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
|
|
7209
|
+
}
|
|
7210
|
+
wireRail() {
|
|
7211
|
+
const rail = this.host.rail;
|
|
7212
|
+
rail.querySelectorAll("[data-ch-view]").forEach((button) => {
|
|
7213
|
+
button.addEventListener("click", () => this.setView(button.dataset.chView));
|
|
7214
|
+
});
|
|
7215
|
+
rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
|
|
7216
|
+
button.addEventListener("click", () => {
|
|
7217
|
+
this.detailChannelId = button.dataset.chDetail;
|
|
7218
|
+
this.paintRail();
|
|
7219
|
+
});
|
|
7220
|
+
});
|
|
7221
|
+
const target = rail.querySelector("[data-ch-target]");
|
|
7222
|
+
target?.addEventListener("change", () => {
|
|
7223
|
+
this.targetChannelId = target.value;
|
|
7224
|
+
this.conflict = false;
|
|
7225
|
+
this.paintRail();
|
|
7226
|
+
});
|
|
7227
|
+
const audience = rail.querySelector("[data-ch-audience]");
|
|
7228
|
+
audience?.addEventListener("change", () => {
|
|
7229
|
+
this.previewAudience = [audience.value];
|
|
7230
|
+
void this.loadPreview();
|
|
7231
|
+
});
|
|
7232
|
+
const includePublic = rail.querySelector("[data-ch-includepublic]");
|
|
7233
|
+
includePublic?.addEventListener("change", () => {
|
|
7234
|
+
this.previewIncludePublic = includePublic.checked;
|
|
7235
|
+
void this.loadPreview();
|
|
7236
|
+
});
|
|
7237
|
+
const intent = rail.querySelector("[data-ch-intent]");
|
|
7238
|
+
intent?.addEventListener("change", () => {
|
|
7239
|
+
void this.setAccessIntent(intent.value);
|
|
7240
|
+
});
|
|
7241
|
+
const grab = rail.querySelector(".slm-ch-grab");
|
|
7242
|
+
grab?.addEventListener("click", () => this.cycleDetent());
|
|
7243
|
+
rail.querySelectorAll("[data-ch-act]").forEach((button) => {
|
|
7244
|
+
button.addEventListener("click", () => this.railAction(button.dataset.chAct));
|
|
7245
|
+
});
|
|
7246
|
+
}
|
|
7247
|
+
railAction(action) {
|
|
7248
|
+
switch (action) {
|
|
7249
|
+
case "create":
|
|
7250
|
+
this.openDialog({ kind: "create" });
|
|
7251
|
+
break;
|
|
7252
|
+
case "review":
|
|
7253
|
+
this.openDialog({ kind: "review" });
|
|
7254
|
+
break;
|
|
7255
|
+
case "rename":
|
|
7256
|
+
this.openDialog({ kind: "rename", channelId: this.detailChannelId });
|
|
7257
|
+
break;
|
|
7258
|
+
case "archive":
|
|
7259
|
+
this.openDialog({ kind: "archive", channelId: this.detailChannelId });
|
|
7260
|
+
break;
|
|
7261
|
+
case "seatlist":
|
|
7262
|
+
this.openDialog({ kind: "seatlist" });
|
|
7263
|
+
break;
|
|
7264
|
+
case "pause":
|
|
7265
|
+
void this.togglePause();
|
|
7266
|
+
break;
|
|
7267
|
+
case "discard":
|
|
7268
|
+
this.host.clearSelection();
|
|
7269
|
+
break;
|
|
7270
|
+
case "back":
|
|
7271
|
+
this.detailChannelId = null;
|
|
7272
|
+
this.paintRail();
|
|
7273
|
+
break;
|
|
7274
|
+
case "retry":
|
|
7275
|
+
void this.refresh();
|
|
7276
|
+
break;
|
|
7277
|
+
case "toggle-archived":
|
|
7278
|
+
this.showArchived = !this.showArchived;
|
|
7279
|
+
void this.refresh();
|
|
7280
|
+
break;
|
|
7281
|
+
case "refresh-review":
|
|
7282
|
+
this.conflict = false;
|
|
7283
|
+
void this.refresh().then(() => this.openDialog({ kind: "review" }));
|
|
7284
|
+
break;
|
|
7285
|
+
case "pick-section":
|
|
7286
|
+
this.pickSection();
|
|
7287
|
+
break;
|
|
7288
|
+
case "pick-category":
|
|
7289
|
+
this.pickCategory();
|
|
7290
|
+
break;
|
|
7291
|
+
default:
|
|
7292
|
+
break;
|
|
7293
|
+
}
|
|
7294
|
+
}
|
|
7295
|
+
// ---- selection helpers ----------------------------------------------------
|
|
7296
|
+
pickSection() {
|
|
7297
|
+
const sections = this.host.sections();
|
|
7298
|
+
if (!sections.length) return;
|
|
7299
|
+
this.promptChoice("Select a whole section", sections.map((s) => ({ value: s.id, label: s.label })), (value) => {
|
|
7300
|
+
this.host.selectSection(value);
|
|
7301
|
+
});
|
|
7302
|
+
}
|
|
7303
|
+
pickCategory() {
|
|
7304
|
+
const categories = this.host.categories();
|
|
7305
|
+
if (!categories.length) return;
|
|
7306
|
+
this.promptChoice("Select a whole category", categories.map((c) => ({ value: c.key, label: c.label })), (value) => {
|
|
7307
|
+
this.host.selectByLabels(this.host.labelsInCategory(value));
|
|
7308
|
+
});
|
|
7309
|
+
}
|
|
7310
|
+
/** A tiny modal chooser reusing the dialog primitive (focus trap + Escape). */
|
|
7311
|
+
promptChoice(title, options, onPick) {
|
|
7312
|
+
this.renderScrim(`
|
|
7313
|
+
<h3 id="slm-ch-dlg-title">${esc(title)}</h3>
|
|
7314
|
+
<div class="slm-field">
|
|
7315
|
+
<label for="slm-ch-choice">Choose one</label>
|
|
7316
|
+
<select class="slm-select" id="slm-ch-choice">
|
|
7317
|
+
${options.map((option) => `<option value="${esc(option.value)}">${esc(option.label)}</option>`).join("")}
|
|
7318
|
+
</select>
|
|
7319
|
+
</div>
|
|
7320
|
+
<div class="foot">
|
|
7321
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7322
|
+
<button type="button" class="slm-btn" data-ch-confirm>Select</button>
|
|
7323
|
+
</div>`, (root) => {
|
|
7324
|
+
root.querySelector("[data-ch-confirm]")?.addEventListener("click", () => {
|
|
7325
|
+
const select = root.querySelector("#slm-ch-choice");
|
|
7326
|
+
const value = select?.value;
|
|
7327
|
+
this.closeDialog();
|
|
7328
|
+
if (value) onPick(value);
|
|
7329
|
+
});
|
|
7330
|
+
});
|
|
7331
|
+
}
|
|
7332
|
+
// ---- dialogs --------------------------------------------------------------
|
|
7333
|
+
openDialog(state) {
|
|
7334
|
+
this.dialog = state;
|
|
7335
|
+
this.renderDialog();
|
|
7336
|
+
}
|
|
7337
|
+
renderDialog() {
|
|
7338
|
+
const state = this.dialog;
|
|
7339
|
+
if (!state) return;
|
|
7340
|
+
if (state.kind === "create") this.renderCreateDialog(state);
|
|
7341
|
+
else if (state.kind === "review") this.renderReviewDialog(state);
|
|
7342
|
+
else if (state.kind === "archive") this.renderArchiveDialog(state);
|
|
7343
|
+
else if (state.kind === "rename") this.renderRenameDialog(state);
|
|
7344
|
+
else if (state.kind === "seatlist") this.renderSeatListDialog();
|
|
7345
|
+
}
|
|
7346
|
+
/**
|
|
7347
|
+
* Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
|
|
7348
|
+
* Tab trapped, Escape closes WITHOUT mutating, focus restored on close (§13).
|
|
7349
|
+
*/
|
|
7350
|
+
renderScrim(inner, wire) {
|
|
7351
|
+
const existing = this.scrimEl;
|
|
7352
|
+
if (!existing) this.lastFocus = document.activeElement ?? null;
|
|
7353
|
+
existing?.remove();
|
|
7354
|
+
const scrim = document.createElement("div");
|
|
7355
|
+
scrim.className = "slm-ch-scrim";
|
|
7356
|
+
scrim.innerHTML = `<div class="slm-ch-dialog" role="dialog" aria-modal="true"
|
|
7357
|
+
aria-labelledby="slm-ch-dlg-title" tabindex="-1">${inner}</div>`;
|
|
7358
|
+
this.host.root.appendChild(scrim);
|
|
7359
|
+
this.scrimEl = scrim;
|
|
7360
|
+
const dialog = scrim.firstElementChild;
|
|
7361
|
+
dialog.querySelectorAll("[data-ch-close]").forEach((button) => {
|
|
7362
|
+
button.addEventListener("click", () => this.closeDialog());
|
|
7363
|
+
});
|
|
7364
|
+
scrim.addEventListener("keydown", (event) => {
|
|
7365
|
+
if (event.key === "Escape") {
|
|
7366
|
+
event.stopPropagation();
|
|
7367
|
+
this.closeDialog();
|
|
7368
|
+
return;
|
|
7369
|
+
}
|
|
7370
|
+
if (event.key !== "Tab") return;
|
|
7371
|
+
const focusable = [...dialog.querySelectorAll(
|
|
7372
|
+
'button:not([disabled]),select,input,textarea,a[href],[tabindex]:not([tabindex="-1"])'
|
|
7373
|
+
)];
|
|
7374
|
+
if (!focusable.length) return;
|
|
7375
|
+
const first = focusable[0];
|
|
7376
|
+
const last = focusable[focusable.length - 1];
|
|
7377
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
7378
|
+
event.preventDefault();
|
|
7379
|
+
last.focus();
|
|
7380
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
7381
|
+
event.preventDefault();
|
|
7382
|
+
first.focus();
|
|
7383
|
+
}
|
|
7384
|
+
});
|
|
7385
|
+
wire(dialog);
|
|
7386
|
+
const autofocus = dialog.querySelector("input,select,button");
|
|
7387
|
+
(autofocus ?? dialog).focus();
|
|
7388
|
+
}
|
|
7389
|
+
closeDialog(opts = {}) {
|
|
7390
|
+
this.dialog = null;
|
|
7391
|
+
this.scrimEl?.remove();
|
|
7392
|
+
this.scrimEl = null;
|
|
7393
|
+
if (opts.restoreFocus !== false) this.lastFocus?.focus?.();
|
|
7394
|
+
this.lastFocus = null;
|
|
7395
|
+
}
|
|
7396
|
+
renderCreateDialog(state) {
|
|
7397
|
+
const taken = (this.list?.channels ?? []).map((channel) => channel.marker ?? channel.name[0] ?? "");
|
|
7398
|
+
const suggestion = suggestMarker("", taken);
|
|
7399
|
+
this.renderScrim(`
|
|
7400
|
+
<h3 id="slm-ch-dlg-title">Create channel</h3>
|
|
7401
|
+
<p class="sub">A named allocation only the right audience can buy from. You'll pick the seats next.</p>
|
|
7402
|
+
<div class="slm-field">
|
|
7403
|
+
<label for="slm-ch-name">Name</label>
|
|
7404
|
+
<input class="slm-input" id="slm-ch-name" maxlength="80" />
|
|
7405
|
+
<p class="slm-note">Shown to your team and in reports \u2014 never to buyers.</p>
|
|
7406
|
+
</div>
|
|
7407
|
+
<div class="slm-field">
|
|
7408
|
+
<label>Marker</label>
|
|
7409
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
7410
|
+
<span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
|
|
7411
|
+
<span class="slm-note" style="margin:0">Letter + colour suggested from the name. Buyers never see either.</span>
|
|
7412
|
+
</div>
|
|
7413
|
+
</div>
|
|
7414
|
+
<div class="slm-field">
|
|
7415
|
+
<label for="slm-ch-ref">Reference <span style="text-transform:none;font-weight:500">(optional)</span></label>
|
|
7416
|
+
<input class="slm-input" id="slm-ch-ref" maxlength="120" placeholder="e.g. travel-agency-a" />
|
|
7417
|
+
<p class="slm-note">A stable ID for your own system and webhooks.</p>
|
|
7418
|
+
</div>
|
|
7419
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7420
|
+
<div class="foot">
|
|
7421
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7422
|
+
<button type="button" class="slm-btn ghost" data-ch-create="plain">Create without allocating</button>
|
|
7423
|
+
<button type="button" class="slm-btn" data-ch-create="allocate">Create and allocate seats</button>
|
|
7424
|
+
</div>`, (dialog) => {
|
|
7425
|
+
const name = dialog.querySelector("#slm-ch-name");
|
|
7426
|
+
const marker = dialog.querySelector("[data-ch-marker]");
|
|
7427
|
+
name.addEventListener("input", () => {
|
|
7428
|
+
const next = suggestMarker(name.value, taken);
|
|
7429
|
+
marker.textContent = next.letter;
|
|
7430
|
+
marker.style.background = next.color;
|
|
7431
|
+
});
|
|
7432
|
+
dialog.querySelectorAll("[data-ch-create]").forEach((button) => {
|
|
7433
|
+
button.addEventListener("click", () => {
|
|
7434
|
+
const allocate = button.dataset.chCreate === "allocate";
|
|
7435
|
+
void this.createChannel(
|
|
7436
|
+
name.value,
|
|
7437
|
+
marker.textContent ?? "",
|
|
7438
|
+
marker.style.background,
|
|
7439
|
+
dialog.querySelector("#slm-ch-ref")?.value ?? "",
|
|
7440
|
+
allocate
|
|
7441
|
+
);
|
|
7442
|
+
});
|
|
7443
|
+
});
|
|
7444
|
+
});
|
|
7445
|
+
}
|
|
7446
|
+
async createChannel(name, letter, color, externalRef, allocate) {
|
|
7447
|
+
const trimmed = name.trim();
|
|
7448
|
+
if (!trimmed) {
|
|
7449
|
+
this.showDialogError("Give the channel a name your team will recognise.");
|
|
7450
|
+
return;
|
|
7451
|
+
}
|
|
7452
|
+
try {
|
|
7453
|
+
const res = await this.host.api.createChannel(this.host.eventKey, {
|
|
7454
|
+
name: trimmed,
|
|
7455
|
+
marker: letter || null,
|
|
7456
|
+
color: color || null,
|
|
7457
|
+
externalRef: externalRef.trim() || null
|
|
7458
|
+
});
|
|
7459
|
+
this.closeDialog();
|
|
7460
|
+
await this.refresh();
|
|
7461
|
+
this.targetChannelId = res.channel.id;
|
|
7462
|
+
this.detailChannelId = allocate ? null : res.channel.id;
|
|
7463
|
+
this.announce(`Channel ${trimmed} created with 0 seats allocated.`);
|
|
7464
|
+
this.host.toast(allocate ? `${trimmed} created. Select seats on the map to allocate them.` : `${trimmed} created.`, "ok");
|
|
7465
|
+
this.paintRail();
|
|
7466
|
+
} catch (err) {
|
|
7467
|
+
const code = err instanceof ManageApiError ? err.code : void 0;
|
|
7468
|
+
this.showDialogError(code === "channel_name_taken" ? "That name is already used on this event. Pick another." : "Couldn't create the channel. Try again.");
|
|
7469
|
+
this.host.onError(err);
|
|
7470
|
+
}
|
|
7471
|
+
}
|
|
7472
|
+
showDialogError(message) {
|
|
7473
|
+
const field = this.scrimEl?.querySelector("[data-ch-error]");
|
|
7474
|
+
if (!field) return;
|
|
7475
|
+
field.textContent = message;
|
|
7476
|
+
field.hidden = false;
|
|
7477
|
+
}
|
|
7478
|
+
renderReviewDialog(state) {
|
|
7479
|
+
const { labels, buckets } = this.currentPlan();
|
|
7480
|
+
const authoritative = state.applied;
|
|
7481
|
+
const shown = authoritative ? authoritative.buckets : buckets;
|
|
7482
|
+
const targetName = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
|
|
7483
|
+
const rows = bucketRows(shown, targetName);
|
|
7484
|
+
const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
|
|
7485
|
+
const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
|
|
7486
|
+
const rowsHtml = bucketRowsHtml(rows);
|
|
7487
|
+
const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
|
|
7488
|
+
<button type="button" class="quiet" data-ch-close>Back</button>
|
|
7489
|
+
<button type="button" class="slm-btn" data-ch-apply ${allSkipped || state.busy ? "disabled" : ""}>
|
|
7490
|
+
${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
|
|
7491
|
+
</button>
|
|
7492
|
+
</div>`;
|
|
7493
|
+
const confirmNote = !authoritative && needsMoveConfirmation(buckets) ? `<p class="slm-note">Applying moves inventory out of another private channel. That is the line marked above.</p>` : "";
|
|
7494
|
+
const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
|
|
7495
|
+
every seat is in a buyer's checkout, already sold, or already in ${esc(targetName)}.</span></div>` : "";
|
|
7496
|
+
this.renderScrim(`
|
|
7497
|
+
<h3 id="slm-ch-dlg-title">${authoritative ? `Moved ${authoritative.applied.toLocaleString()} seat${authoritative.applied === 1 ? "" : "s"} to ${esc(targetName)}` : `Move ${labels.length.toLocaleString()} selected seat${labels.length === 1 ? "" : "s"} to ${esc(targetName)}`}</h3>
|
|
7498
|
+
<p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
|
|
7499
|
+
${skippedNote}
|
|
7500
|
+
${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
|
|
7501
|
+
${confirmNote}
|
|
7502
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7503
|
+
${foot}`, (dialog) => {
|
|
7504
|
+
dialog.querySelector("[data-ch-apply]")?.addEventListener("click", () => void this.apply());
|
|
7505
|
+
});
|
|
7506
|
+
if (authoritative) {
|
|
7507
|
+
this.announce(`Applied ${authoritative.applied} change${authoritative.applied === 1 ? "" : "s"} to ${targetName}.`);
|
|
7508
|
+
}
|
|
7509
|
+
}
|
|
7510
|
+
/**
|
|
7511
|
+
* Apply. On success the review sheet re-renders with the AUTHORITATIVE server
|
|
7512
|
+
* buckets and the staged bar morphs to a ✓ for 1.2s. On a stale version the
|
|
7513
|
+
* server mutated nothing: keep the selection, shake the bar once, and offer
|
|
7514
|
+
* exactly one action — Refresh and review.
|
|
7515
|
+
*/
|
|
7516
|
+
async apply() {
|
|
7517
|
+
if (!this.dialog || !this.caps.manage) return;
|
|
7518
|
+
const { labels } = this.currentPlan();
|
|
7519
|
+
if (!labels.length) return;
|
|
7520
|
+
this.dialog = { ...this.dialog, busy: true, error: null };
|
|
7521
|
+
this.renderDialog();
|
|
7522
|
+
try {
|
|
7523
|
+
const result = await this.host.api.applyChannelAssignment(this.host.eventKey, {
|
|
7524
|
+
targetChannelId: this.targetChannelId || null,
|
|
7525
|
+
labels,
|
|
7526
|
+
assignmentVersion: this.assignmentVersion
|
|
7527
|
+
});
|
|
7528
|
+
this.assignmentVersion = result.assignmentVersion;
|
|
7529
|
+
this.conflict = false;
|
|
7530
|
+
this.dialog = { kind: "review", applied: result };
|
|
7531
|
+
await this.refresh({ quiet: true });
|
|
7532
|
+
this.renderDialog();
|
|
7533
|
+
this.showApplied(result);
|
|
7534
|
+
this.host.clearSelection();
|
|
7535
|
+
} catch (err) {
|
|
7536
|
+
const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
|
|
7537
|
+
if (conflict) {
|
|
7538
|
+
this.conflict = true;
|
|
7539
|
+
this.closeDialog();
|
|
7540
|
+
this.shakeStaged();
|
|
7541
|
+
this.paintRail();
|
|
7542
|
+
this.announce("Assignments changed while you were editing. Nothing was applied and your selection is kept.");
|
|
7543
|
+
return;
|
|
7544
|
+
}
|
|
7545
|
+
if (err instanceof ManageApiError && err.status === 403) {
|
|
7546
|
+
this.caps = { view: this.caps.view, manage: false };
|
|
7547
|
+
this.closeDialog();
|
|
7548
|
+
this.paintRail();
|
|
7549
|
+
this.host.toast("Changing channels needs channel-management permission.", "err");
|
|
7550
|
+
return;
|
|
7551
|
+
}
|
|
7552
|
+
this.dialog = { kind: "review", busy: false, error: "Couldn't apply those changes. Try again." };
|
|
7553
|
+
this.renderDialog();
|
|
7554
|
+
this.host.onError(err);
|
|
7555
|
+
}
|
|
7556
|
+
}
|
|
7557
|
+
showApplied(result) {
|
|
7558
|
+
this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
|
|
7559
|
+
<span>Applied <b>${result.applied.toLocaleString()}</b> change${result.applied === 1 ? "" : "s"}</span>
|
|
7560
|
+
<span class="grow"></span>`, "done");
|
|
7561
|
+
if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
|
|
7562
|
+
this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 1200);
|
|
7563
|
+
}
|
|
7564
|
+
shakeStaged() {
|
|
7565
|
+
const bar = this.stagedEl;
|
|
7566
|
+
if (!bar || !bar.classList.contains("on")) return;
|
|
7567
|
+
bar.classList.remove("shake");
|
|
7568
|
+
void bar.offsetWidth;
|
|
7569
|
+
bar.classList.add("shake");
|
|
7570
|
+
}
|
|
7571
|
+
renderRenameDialog(state) {
|
|
7572
|
+
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
7573
|
+
if (!channel) {
|
|
7574
|
+
this.closeDialog();
|
|
7575
|
+
return;
|
|
7576
|
+
}
|
|
7577
|
+
this.renderScrim(`
|
|
7578
|
+
<h3 id="slm-ch-dlg-title">Rename ${esc(channel.name)}</h3>
|
|
7579
|
+
<p class="sub">Only your team and your reports see this name.</p>
|
|
7580
|
+
<div class="slm-field">
|
|
7581
|
+
<label for="slm-ch-newname">Name</label>
|
|
7582
|
+
<input class="slm-input" id="slm-ch-newname" maxlength="80" value="${esc(channel.name)}" />
|
|
7583
|
+
</div>
|
|
7584
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7585
|
+
<div class="foot">
|
|
7586
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7587
|
+
<button type="button" class="slm-btn" data-ch-rename>Save name</button>
|
|
7588
|
+
</div>`, (dialog) => {
|
|
7589
|
+
dialog.querySelector("[data-ch-rename]")?.addEventListener("click", () => {
|
|
7590
|
+
const value = dialog.querySelector("#slm-ch-newname")?.value ?? "";
|
|
7591
|
+
if (!value.trim()) {
|
|
7592
|
+
this.showDialogError("A channel needs a name.");
|
|
7593
|
+
return;
|
|
7594
|
+
}
|
|
7595
|
+
void this.host.api.renameChannel(this.host.eventKey, channel.id, value.trim()).then(() => {
|
|
7596
|
+
this.closeDialog();
|
|
7597
|
+
return this.refresh();
|
|
7598
|
+
}).catch((err) => {
|
|
7599
|
+
this.showDialogError(err instanceof ManageApiError && err.code === "channel_name_taken" ? "That name is already used on this event." : "Couldn't rename the channel.");
|
|
7600
|
+
this.host.onError(err);
|
|
7601
|
+
});
|
|
7602
|
+
});
|
|
7603
|
+
});
|
|
7604
|
+
}
|
|
7605
|
+
async setAccessIntent(accessIntent) {
|
|
7606
|
+
const channelId = this.detailChannelId;
|
|
7607
|
+
if (!channelId || !this.caps.manage) return;
|
|
7608
|
+
try {
|
|
7609
|
+
await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, accessIntent);
|
|
7610
|
+
await this.refresh();
|
|
7611
|
+
} catch (err) {
|
|
7612
|
+
this.host.toast("Couldn't save how buyers reach this channel.", "err");
|
|
7613
|
+
this.host.onError(err);
|
|
7614
|
+
}
|
|
7615
|
+
}
|
|
7616
|
+
async togglePause() {
|
|
7617
|
+
const channel = this.list?.channels.find((item) => item.id === this.detailChannelId);
|
|
7618
|
+
if (!channel) return;
|
|
7619
|
+
const paused = channel.state !== "paused";
|
|
7620
|
+
try {
|
|
7621
|
+
await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);
|
|
7622
|
+
await this.refresh();
|
|
7623
|
+
this.host.toast(paused ? `${channel.name} paused. Existing checkouts can finish; no new buyer access is issued.` : `${channel.name} resumed.`, "ok");
|
|
7624
|
+
} catch (err) {
|
|
7625
|
+
this.host.toast("Couldn't change that channel.", "err");
|
|
7626
|
+
this.host.onError(err);
|
|
7627
|
+
}
|
|
7628
|
+
}
|
|
7629
|
+
renderArchiveDialog(state) {
|
|
7630
|
+
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
7631
|
+
if (!channel) {
|
|
7632
|
+
this.closeDialog();
|
|
7633
|
+
return;
|
|
7634
|
+
}
|
|
7635
|
+
const blocked = state.archiveBlocked ?? null;
|
|
7636
|
+
const heads_up = !blocked && channel.counts.held > 0;
|
|
7637
|
+
const destinations = this.assignableChannels().filter((entry) => entry.id !== channel.id);
|
|
7638
|
+
const blockedAlert = blocked ? `<div class="slm-ch-alert warn" role="alert"><span>\u23F3</span>
|
|
7639
|
+
<span><b>${(blocked.heldUnits ?? blocked.activeHolds ?? 0).toLocaleString()} seats are in a buyer's checkout right now.</b>
|
|
7640
|
+
Archive is unavailable while seats are held \u2014 try again ${esc(retryAfterCopy(blocked))}.</span></div>` : heads_up ? `<div class="slm-ch-alert warn"><span>\u23F3</span>
|
|
7641
|
+
<span>${channel.counts.held.toLocaleString()} seats are in a buyer's checkout right now.
|
|
7642
|
+
Archive is refused while any seat is held \u2014 you can try, and we'll tell you when to come back.</span></div>` : "";
|
|
7643
|
+
this.renderScrim(`
|
|
7644
|
+
<h3 id="slm-ch-dlg-title">Archive ${esc(channel.name)}</h3>
|
|
7645
|
+
<p class="sub">The channel closes for good. Its seats move to a destination you choose;
|
|
7646
|
+
sales history keeps its attribution.</p>
|
|
7647
|
+
${blockedAlert}
|
|
7648
|
+
<div class="slm-field">
|
|
7649
|
+
<label for="slm-ch-dest">Move the ${channel.counts.free.toLocaleString()} remaining free seats to</label>
|
|
7650
|
+
<select class="slm-select" id="slm-ch-dest">
|
|
7651
|
+
${destinations.map((entry) => `<option value="${esc(entry.id)}">${esc(entry.name)}</option>`).join("")}
|
|
7652
|
+
</select>
|
|
7653
|
+
</div>
|
|
7654
|
+
<p class="slm-note">${channel.counts.booked.toLocaleString()} sold seats keep "${esc(channel.name)}" on their sale
|
|
7655
|
+
record. If one is cancelled later it returns to the destination above. Any buyer access for this channel
|
|
7656
|
+
stops working.</p>
|
|
7657
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
7658
|
+
<div class="foot">
|
|
7659
|
+
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
7660
|
+
<button type="button" class="slm-btn danger" data-ch-archive ${blocked ? "disabled" : ""}>Archive channel</button>
|
|
7661
|
+
</div>`, (dialog) => {
|
|
7662
|
+
dialog.querySelector("[data-ch-archive]")?.addEventListener("click", () => {
|
|
7663
|
+
const destination = dialog.querySelector("#slm-ch-dest")?.value ?? "";
|
|
7664
|
+
void this.archive(channel.id, destination || null);
|
|
7665
|
+
});
|
|
7666
|
+
});
|
|
7667
|
+
}
|
|
7668
|
+
async archive(channelId, destination) {
|
|
7669
|
+
try {
|
|
7670
|
+
await this.host.api.archiveChannel(this.host.eventKey, channelId, destination);
|
|
7671
|
+
this.closeDialog();
|
|
7672
|
+
this.detailChannelId = null;
|
|
7673
|
+
await this.refresh();
|
|
7674
|
+
this.host.toast("Channel archived. Its remaining seats moved to the destination you chose.", "ok");
|
|
7675
|
+
} catch (err) {
|
|
7676
|
+
if (err instanceof ManageApiError && err.status === 409 && err.code === "channel_archive_blocked_by_holds") {
|
|
7677
|
+
this.dialog = {
|
|
7678
|
+
kind: "archive",
|
|
7679
|
+
channelId,
|
|
7680
|
+
archiveBlocked: err.details ?? {}
|
|
7681
|
+
};
|
|
7682
|
+
this.renderDialog();
|
|
7683
|
+
return;
|
|
7684
|
+
}
|
|
7685
|
+
this.showDialogError("Couldn't archive that channel. Try again.");
|
|
7686
|
+
this.host.onError(err);
|
|
7687
|
+
}
|
|
7688
|
+
}
|
|
7689
|
+
/**
|
|
7690
|
+
* The synchronized inventory list (§13): the keyboard and screen-reader
|
|
7691
|
+
* equivalent of canvas click / marquee / brush, grouped by section with
|
|
7692
|
+
* per-section select actions.
|
|
7693
|
+
*/
|
|
7694
|
+
renderSeatListDialog() {
|
|
7695
|
+
const selected = new Set(this.host.selectionLabels());
|
|
7696
|
+
const groups = /* @__PURE__ */ new Map();
|
|
7697
|
+
let total = 0;
|
|
7698
|
+
for (const seat of this.host.seats()) {
|
|
7699
|
+
total += 1;
|
|
7700
|
+
if (total > this.seatListLimit) break;
|
|
7701
|
+
const section = this.host.sectionOfLabel(seat.label);
|
|
7702
|
+
const key = section?.id ?? "";
|
|
7703
|
+
const group = groups.get(key) ?? { label: section?.label ?? "Other seats", seats: [] };
|
|
7704
|
+
group.seats.push({ label: seat.label, status: this.host.statusOf(seat.label) ?? "free" });
|
|
7705
|
+
groups.set(key, group);
|
|
7706
|
+
}
|
|
7707
|
+
const body = [...groups.entries()].map(([id, group]) => `
|
|
7708
|
+
<div class="slm-ch-seatgroup">
|
|
7709
|
+
<span>${esc(group.label)}</span>
|
|
7710
|
+
${id ? `<button type="button" data-ch-section="${esc(id)}">Select section</button>` : ""}
|
|
7711
|
+
</div>
|
|
7712
|
+
${group.seats.map((seat) => {
|
|
7713
|
+
const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
|
|
7714
|
+
const channelName = this.nameOf(channelId) ?? PUBLIC_CHANNEL_NAME;
|
|
7715
|
+
return `<button type="button" class="slm-ch-seatitem" role="checkbox"
|
|
7716
|
+
aria-checked="${selected.has(seat.label)}" data-ch-seat="${esc(seat.label)}">
|
|
7717
|
+
<span class="box" aria-hidden="true">\u2713</span>
|
|
7718
|
+
<span>${esc(seat.label)}</span>
|
|
7719
|
+
<span class="meta">${esc(channelName)} \xB7 ${esc(seat.status)}</span>
|
|
7720
|
+
</button>`;
|
|
7721
|
+
}).join("")}`).join("");
|
|
7722
|
+
this.renderScrim(`
|
|
7723
|
+
<h3 id="slm-ch-dlg-title">Seat list</h3>
|
|
7724
|
+
<p class="sub">The same selection as the map, with checkboxes. Space or Enter toggles a seat.</p>
|
|
7725
|
+
<div class="slm-ch-seatlist">${body || '<div class="slm-empty">No seats on this chart.</div>'}</div>
|
|
7726
|
+
${total > this.seatListLimit ? `<div class="foot"><button type="button" class="slm-btn ghost" data-ch-more>Show more seats</button></div>` : ""}
|
|
7727
|
+
<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>`, (dialog) => {
|
|
7728
|
+
dialog.querySelectorAll("[data-ch-seat]").forEach((button) => {
|
|
7729
|
+
button.addEventListener("click", () => {
|
|
7730
|
+
const label = button.dataset.chSeat;
|
|
7731
|
+
const next = new Set(this.host.selectionLabels());
|
|
7732
|
+
if (next.has(label)) next.delete(label);
|
|
7733
|
+
else next.add(label);
|
|
7734
|
+
this.host.clearSelection();
|
|
7735
|
+
if (next.size) this.host.selectByLabels([...next]);
|
|
7736
|
+
this.renderSeatListDialog();
|
|
7737
|
+
});
|
|
7738
|
+
});
|
|
7739
|
+
dialog.querySelectorAll("[data-ch-section]").forEach((button) => {
|
|
7740
|
+
button.addEventListener("click", () => {
|
|
7741
|
+
this.host.selectSection(button.dataset.chSection);
|
|
7742
|
+
this.renderSeatListDialog();
|
|
7743
|
+
});
|
|
7744
|
+
});
|
|
7745
|
+
dialog.querySelector("[data-ch-more]")?.addEventListener("click", () => {
|
|
7746
|
+
this.seatListLimit += SEAT_LIST_PAGE;
|
|
7747
|
+
this.renderSeatListDialog();
|
|
7748
|
+
});
|
|
7749
|
+
});
|
|
7750
|
+
}
|
|
7751
|
+
// ---- compact detents ------------------------------------------------------
|
|
7752
|
+
applySheetClasses() {
|
|
7753
|
+
const root = this.host.root;
|
|
7754
|
+
const compact = this.host.isCompact();
|
|
7755
|
+
root.classList.toggle("ch-sheet", compact && this.active);
|
|
7756
|
+
for (const detent of ["collapsed", "medium", "full"]) {
|
|
7757
|
+
root.classList.toggle(`detent-${detent}`, compact && this.active && this.detent === detent);
|
|
7758
|
+
}
|
|
7759
|
+
this.host.setMapInert(compact && this.active && this.detent === "full");
|
|
7760
|
+
}
|
|
7761
|
+
cycleDetent() {
|
|
7762
|
+
const order = ["collapsed", "medium", "full"];
|
|
7763
|
+
this.detent = order[(order.indexOf(this.detent) + 1) % order.length];
|
|
7764
|
+
this.applySheetClasses();
|
|
7765
|
+
}
|
|
7766
|
+
/** Back/Close from the full detent returns to the previous one and keeps the
|
|
7767
|
+
* selection — losing a hard-won selection to a Back press is unforgivable. */
|
|
7768
|
+
handleBack() {
|
|
7769
|
+
if (this.scrimEl) {
|
|
7770
|
+
this.closeDialog();
|
|
7771
|
+
return true;
|
|
7772
|
+
}
|
|
7773
|
+
if (this.host.isCompact() && this.detent === "full") {
|
|
7774
|
+
this.detent = "medium";
|
|
7775
|
+
this.applySheetClasses();
|
|
7776
|
+
return true;
|
|
7777
|
+
}
|
|
7778
|
+
return false;
|
|
7779
|
+
}
|
|
7780
|
+
};
|
|
7781
|
+
|
|
5180
7782
|
// src/SeatManager.ts
|
|
5181
7783
|
function availabilityModeOf(rule) {
|
|
5182
7784
|
return rule ? rule.mode : "open";
|
|
@@ -5433,7 +8035,7 @@ var CSS2 = `
|
|
|
5433
8035
|
.slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
|
|
5434
8036
|
.slm-liveevent,.slm-sectionrow{transition:none!important}
|
|
5435
8037
|
}
|
|
5436
|
-
`;
|
|
8038
|
+
${CHANNELS_CSS}`;
|
|
5437
8039
|
function injectStyle() {
|
|
5438
8040
|
if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
|
|
5439
8041
|
const el = document.createElement("style");
|
|
@@ -5470,7 +8072,7 @@ function fmtMoney(amount, currency) {
|
|
|
5470
8072
|
return `${currency} ${Math.round(amount).toLocaleString()}`;
|
|
5471
8073
|
}
|
|
5472
8074
|
}
|
|
5473
|
-
function
|
|
8075
|
+
function esc2(value) {
|
|
5474
8076
|
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
5475
8077
|
}
|
|
5476
8078
|
var SeatManager = class {
|
|
@@ -5525,6 +8127,10 @@ var SeatManager = class {
|
|
|
5525
8127
|
this.blockedSection = "";
|
|
5526
8128
|
this.blockedResultLimit = 100;
|
|
5527
8129
|
this.unblockAllConfirmTimer = null;
|
|
8130
|
+
// Sales channels (M6b). The mode object is built only once the token is known
|
|
8131
|
+
// to carry `event:channels:view`; until then there is no pill and no rail.
|
|
8132
|
+
this.channels = null;
|
|
8133
|
+
this.channelCaps = { view: false, manage: false };
|
|
5528
8134
|
this.onFullscreenChange = () => {
|
|
5529
8135
|
this.paintFullscreenButton();
|
|
5530
8136
|
this.updateContainerLayout();
|
|
@@ -5539,8 +8145,13 @@ var SeatManager = class {
|
|
|
5539
8145
|
else if (key === "i") this.setMode("inspect");
|
|
5540
8146
|
else if (key === "b") this.setMode("block");
|
|
5541
8147
|
else if (key === "s") this.setMode("sections");
|
|
5542
|
-
else if (key === "
|
|
5543
|
-
|
|
8148
|
+
else if (key === "c") {
|
|
8149
|
+
if (!this.channels) return;
|
|
8150
|
+
this.setMode("channels");
|
|
8151
|
+
} else if (key === "f") this.toggleFullscreen();
|
|
8152
|
+
else if (key === "escape") {
|
|
8153
|
+
if (!this.channels?.handleBack()) return;
|
|
8154
|
+
} else return;
|
|
5544
8155
|
event.preventDefault();
|
|
5545
8156
|
};
|
|
5546
8157
|
this.onRailClick = (event) => {
|
|
@@ -5591,6 +8202,7 @@ var SeatManager = class {
|
|
|
5591
8202
|
this.connect();
|
|
5592
8203
|
this.startFeedClock();
|
|
5593
8204
|
this.ready = true;
|
|
8205
|
+
await this.resolveChannelCapabilities();
|
|
5594
8206
|
this.setMode(this.mode);
|
|
5595
8207
|
this.scheduleTokenRefresh();
|
|
5596
8208
|
this.opts.onReady?.();
|
|
@@ -5601,16 +8213,119 @@ var SeatManager = class {
|
|
|
5601
8213
|
}
|
|
5602
8214
|
// ---- public API -----------------------------------------------------------
|
|
5603
8215
|
setMode(mode) {
|
|
8216
|
+
if (mode === "channels" && !this.channels) mode = "view";
|
|
5604
8217
|
const changed = mode !== this.mode;
|
|
8218
|
+
const wasChannels = this.mode === "channels";
|
|
5605
8219
|
this.mode = mode;
|
|
5606
8220
|
if (!this.renderer && this.doc) this.buildRenderer();
|
|
5607
8221
|
else this.updateRendererInteraction();
|
|
5608
8222
|
if (changed) this.renderer?.clearSelection();
|
|
8223
|
+
if (wasChannels && mode !== "channels") this.channels?.leave();
|
|
5609
8224
|
this.paintModeTabs();
|
|
5610
8225
|
this.paintRail();
|
|
5611
8226
|
this.applySectionCanvasTreatment();
|
|
8227
|
+
if (mode === "channels") this.channels?.enter();
|
|
5612
8228
|
if (changed) this.opts.onModeChange?.(mode);
|
|
5613
8229
|
}
|
|
8230
|
+
/**
|
|
8231
|
+
* Decide what this token may do with sales channels.
|
|
8232
|
+
*
|
|
8233
|
+
* Declared capabilities win — a host that mints an `mse_…` grant knows exactly
|
|
8234
|
+
* what it asked for. Otherwise a tenant secret (`sk_…`) is org authority the
|
|
8235
|
+
* worker never narrows, so it is fully capable; and a delegated token with no
|
|
8236
|
+
* declaration is probed for read access and then treated as READ-ONLY, because
|
|
8237
|
+
* "we could not tell" must never render mutation controls.
|
|
8238
|
+
*/
|
|
8239
|
+
async resolveChannelCapabilities() {
|
|
8240
|
+
const declared = this.opts.capabilities;
|
|
8241
|
+
if (declared) {
|
|
8242
|
+
const set = new Set(declared);
|
|
8243
|
+
this.channelCaps = {
|
|
8244
|
+
view: set.has("event:channels:view"),
|
|
8245
|
+
manage: set.has("event:channels:view") && set.has("event:channels:manage")
|
|
8246
|
+
};
|
|
8247
|
+
} else if (/^sk_/.test(this.opts.token)) {
|
|
8248
|
+
this.channelCaps = { view: true, manage: true };
|
|
8249
|
+
} else {
|
|
8250
|
+
this.channelCaps = { view: false, manage: false };
|
|
8251
|
+
}
|
|
8252
|
+
if (!this.channelCaps.view && !declared && !/^sk_/.test(this.opts.token)) {
|
|
8253
|
+
try {
|
|
8254
|
+
await this.api.channels(this.key);
|
|
8255
|
+
this.channelCaps = { view: true, manage: false };
|
|
8256
|
+
} catch {
|
|
8257
|
+
this.channelCaps = { view: false, manage: false };
|
|
8258
|
+
}
|
|
8259
|
+
}
|
|
8260
|
+
if (!this.channelCaps.view) {
|
|
8261
|
+
this.channels?.destroy();
|
|
8262
|
+
this.channels = null;
|
|
8263
|
+
this.paintModeTabs();
|
|
8264
|
+
return;
|
|
8265
|
+
}
|
|
8266
|
+
if (this.channels) {
|
|
8267
|
+
this.channels.setCapabilities(this.channelCaps);
|
|
8268
|
+
} else {
|
|
8269
|
+
this.channels = new ChannelsMode(this.buildChannelsHost(), this.channelCaps);
|
|
8270
|
+
this.channels.onInteractionChange = () => this.updateRendererInteraction();
|
|
8271
|
+
}
|
|
8272
|
+
this.paintModeTabs();
|
|
8273
|
+
}
|
|
8274
|
+
/** The adapter between the cockpit's internals and Channels mode. */
|
|
8275
|
+
buildChannelsHost() {
|
|
8276
|
+
return {
|
|
8277
|
+
eventKey: this.key,
|
|
8278
|
+
api: this.api,
|
|
8279
|
+
rail: this.els.rail,
|
|
8280
|
+
mapLayer: this.root.querySelector(".slm-map"),
|
|
8281
|
+
root: this.root,
|
|
8282
|
+
seats: () => [...this.labelToSeat.values()].map((seat) => ({
|
|
8283
|
+
id: seat.id,
|
|
8284
|
+
label: seat.label,
|
|
8285
|
+
x: seat.x,
|
|
8286
|
+
y: seat.y
|
|
8287
|
+
})),
|
|
8288
|
+
statusOf: (label) => this.status.get(label) ?? (this.labelToSeat.has(label) ? "free" : void 0),
|
|
8289
|
+
selectionLabels: () => this.selectionLabels(),
|
|
8290
|
+
selectByLabels: (labels) => {
|
|
8291
|
+
this.selectByLabels(labels);
|
|
8292
|
+
},
|
|
8293
|
+
clearSelection: () => this.clearSelection(),
|
|
8294
|
+
selectSection: (sectionId) => {
|
|
8295
|
+
this.selectSection(sectionId);
|
|
8296
|
+
},
|
|
8297
|
+
sections: () => this.sectionOptions,
|
|
8298
|
+
categories: () => (this.doc?.categories ?? []).map((category) => ({
|
|
8299
|
+
key: category.key,
|
|
8300
|
+
label: category.label ?? category.key,
|
|
8301
|
+
color: category.color
|
|
8302
|
+
})),
|
|
8303
|
+
labelsInCategory: (key) => [...this.labelToSeat.entries()].filter(([, seat]) => seat.categoryKey === key).map(([label]) => label),
|
|
8304
|
+
sectionOfLabel: (label) => {
|
|
8305
|
+
const seat = this.labelToSeat.get(label);
|
|
8306
|
+
if (!seat) return null;
|
|
8307
|
+
const id = this.sectionByObject.get(seat.rowId) ?? import_core3.UNGROUPED_ID;
|
|
8308
|
+
return { id, label: this.sectionLabelById.get(id) ?? "Other seats" };
|
|
8309
|
+
},
|
|
8310
|
+
worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
|
|
8311
|
+
seatPixelSize: () => this.seatPixelSize(),
|
|
8312
|
+
isCompact: () => !!this.root?.classList.contains("compact"),
|
|
8313
|
+
setMapInert: (inert) => {
|
|
8314
|
+
this.mapHost.toggleAttribute("inert", inert);
|
|
8315
|
+
this.mapHost.setAttribute("aria-hidden", String(inert));
|
|
8316
|
+
},
|
|
8317
|
+
toast: (message, kind) => this.toast(message, kind),
|
|
8318
|
+
onError: (err) => this.opts.onError?.(err)
|
|
8319
|
+
};
|
|
8320
|
+
}
|
|
8321
|
+
/** Approximate on-screen seat size, for the channel overlay's marks. Derived
|
|
8322
|
+
* from the live camera so the overlay tracks zoom without a renderer hook. */
|
|
8323
|
+
seatPixelSize() {
|
|
8324
|
+
const rect = this.renderer?.getVisibleWorldRect?.();
|
|
8325
|
+
const width = this.mapHost?.clientWidth ?? 0;
|
|
8326
|
+
if (!rect?.width || !width) return 6;
|
|
8327
|
+
return Math.max(3, Math.min(24, width / rect.width * 14));
|
|
8328
|
+
}
|
|
5614
8329
|
/** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
|
|
5615
8330
|
setHeatOverlay(enabled) {
|
|
5616
8331
|
this.heatEnabled = enabled;
|
|
@@ -5650,14 +8365,15 @@ var SeatManager = class {
|
|
|
5650
8365
|
return typeof document !== "undefined" && document.fullscreenElement === this.root;
|
|
5651
8366
|
}
|
|
5652
8367
|
toggleFullscreen() {
|
|
5653
|
-
const
|
|
5654
|
-
void
|
|
8368
|
+
const request = this.isFullscreen() ? this.exitFullscreen() : this.enterFullscreen();
|
|
8369
|
+
void request.catch((err) => this.opts.onError?.(err));
|
|
5655
8370
|
}
|
|
5656
8371
|
/** Rotate the delegated credential without rebuilding DOM, canvas or socket. */
|
|
5657
8372
|
setToken(token, expiresAt) {
|
|
5658
8373
|
this.api.setToken(token);
|
|
5659
8374
|
this.tokenExpiresAt = expiresAt ?? null;
|
|
5660
8375
|
this.scheduleTokenRefresh();
|
|
8376
|
+
if (this.ready) void this.resolveChannelCapabilities();
|
|
5661
8377
|
}
|
|
5662
8378
|
scheduleTokenRefresh() {
|
|
5663
8379
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
@@ -5816,6 +8532,8 @@ var SeatManager = class {
|
|
|
5816
8532
|
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
5817
8533
|
if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
|
|
5818
8534
|
if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
|
|
8535
|
+
this.channels?.destroy();
|
|
8536
|
+
this.channels = null;
|
|
5819
8537
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
5820
8538
|
this.layoutObserver?.disconnect();
|
|
5821
8539
|
this.layoutObserver = null;
|
|
@@ -5836,32 +8554,51 @@ var SeatManager = class {
|
|
|
5836
8554
|
// ---- renderer lifecycle ---------------------------------------------------
|
|
5837
8555
|
buildRenderer() {
|
|
5838
8556
|
if (!this.doc) return;
|
|
5839
|
-
const
|
|
5840
|
-
const inspect = this.mode === "inspect";
|
|
8557
|
+
const bulk = this.isBulkSelectMode();
|
|
5841
8558
|
this.renderer = new import_core3.SeatmapRenderer(this.mapHost, {
|
|
5842
8559
|
manageMode: true,
|
|
5843
|
-
marqueeSelect:
|
|
8560
|
+
marqueeSelect: bulk,
|
|
5844
8561
|
maxSelection: 1e6,
|
|
5845
|
-
selectableStatuses:
|
|
8562
|
+
selectableStatuses: this.selectableStatuses(),
|
|
5846
8563
|
currency: this.currency,
|
|
5847
8564
|
onSelect: (seat) => this.handleSeatSelect(seat),
|
|
5848
8565
|
onDeselect: () => this.syncSelection(),
|
|
5849
8566
|
onMarquee: () => this.syncSelection(),
|
|
5850
|
-
onViewChange: () =>
|
|
8567
|
+
onViewChange: () => {
|
|
8568
|
+
this.updateZoomHint();
|
|
8569
|
+
this.channels?.handleViewChange();
|
|
8570
|
+
}
|
|
5851
8571
|
});
|
|
5852
8572
|
this.renderer.setChart(this.doc);
|
|
5853
8573
|
this.repaintAll();
|
|
5854
8574
|
this.applyHeatOverlay();
|
|
5855
8575
|
this.updateZoomHint();
|
|
5856
8576
|
}
|
|
8577
|
+
/** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
|
|
8578
|
+
* section. The two differ only in WHICH statuses they may act on. */
|
|
8579
|
+
isBulkSelectMode() {
|
|
8580
|
+
return this.mode === "block" || this.mode === "channels" && this.channels?.canSelect() === true;
|
|
8581
|
+
}
|
|
8582
|
+
/**
|
|
8583
|
+
* Block never touches held or booked inventory, so it cannot select it.
|
|
8584
|
+
* Channels must be able to select it — the Review sheet's honesty depends on
|
|
8585
|
+
* counting the held and sold units inside a marquee and saying they will not
|
|
8586
|
+
* move, rather than silently omitting them from the selection.
|
|
8587
|
+
*/
|
|
8588
|
+
selectableStatuses() {
|
|
8589
|
+
if (this.mode === "block") return ["free", "not_for_sale"];
|
|
8590
|
+
if (this.mode === "inspect" || this.isBulkSelectMode()) {
|
|
8591
|
+
return ["free", "held", "booked", "not_for_sale"];
|
|
8592
|
+
}
|
|
8593
|
+
return [];
|
|
8594
|
+
}
|
|
5857
8595
|
updateRendererInteraction() {
|
|
5858
|
-
const
|
|
5859
|
-
const inspect = this.mode === "inspect";
|
|
8596
|
+
const bulk = this.isBulkSelectMode();
|
|
5860
8597
|
this.renderer?.setManageInteraction({
|
|
5861
8598
|
manageMode: true,
|
|
5862
|
-
marqueeSelect:
|
|
8599
|
+
marqueeSelect: bulk,
|
|
5863
8600
|
maxSelection: 1e6,
|
|
5864
|
-
selectableStatuses:
|
|
8601
|
+
selectableStatuses: this.selectableStatuses()
|
|
5865
8602
|
});
|
|
5866
8603
|
this.updateZoomHint();
|
|
5867
8604
|
}
|
|
@@ -6113,7 +8850,7 @@ var SeatManager = class {
|
|
|
6113
8850
|
const place = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : activity.label;
|
|
6114
8851
|
const noun = activity.count === 1 ? "seat" : "seats";
|
|
6115
8852
|
element.innerHTML = `<span class="slm-liveeventdot" style="background:${this.activityColor(activity.status)}"></span>
|
|
6116
|
-
<span class="slm-liveeventcopy">${
|
|
8853
|
+
<span class="slm-liveeventcopy">${esc2(place)} \xB7 ${activity.count.toLocaleString()} ${noun} ${esc2(activity.verb)}</span>
|
|
6117
8854
|
<span class="slm-liveeventhint">Live</span>`;
|
|
6118
8855
|
element.classList.add("on");
|
|
6119
8856
|
if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
|
|
@@ -6133,10 +8870,10 @@ var SeatManager = class {
|
|
|
6133
8870
|
this.recomputeTallies();
|
|
6134
8871
|
}
|
|
6135
8872
|
async refreshControlRoom() {
|
|
6136
|
-
const
|
|
8873
|
+
const request = ++this.revenueRequest;
|
|
6137
8874
|
try {
|
|
6138
8875
|
const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
|
|
6139
|
-
if (
|
|
8876
|
+
if (request === this.revenueRequest) {
|
|
6140
8877
|
this.controlRoomSnapshot = snapshot;
|
|
6141
8878
|
this.lastSyncedAt = Date.now();
|
|
6142
8879
|
this.authoritativeGrossRevenue = snapshot.revenue.gross;
|
|
@@ -6149,7 +8886,7 @@ var SeatManager = class {
|
|
|
6149
8886
|
}
|
|
6150
8887
|
return snapshot;
|
|
6151
8888
|
} catch (err) {
|
|
6152
|
-
if (
|
|
8889
|
+
if (request === this.revenueRequest) {
|
|
6153
8890
|
this.revenueStatus = "stale";
|
|
6154
8891
|
this.recomputeTallies();
|
|
6155
8892
|
}
|
|
@@ -6193,6 +8930,7 @@ var SeatManager = class {
|
|
|
6193
8930
|
this.paintMonitorInsights();
|
|
6194
8931
|
} else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
|
|
6195
8932
|
else if (this.mode === "block") this.paintSelBar(this.getSelection());
|
|
8933
|
+
else if (this.mode === "channels") this.channels?.handleSelectionChange();
|
|
6196
8934
|
this.opts.onTallies?.(t3);
|
|
6197
8935
|
}
|
|
6198
8936
|
verbFor(prev, next) {
|
|
@@ -6281,6 +9019,7 @@ var SeatManager = class {
|
|
|
6281
9019
|
const seats = this.getSelection();
|
|
6282
9020
|
if (this.mode === "block") this.paintSelBar(seats);
|
|
6283
9021
|
else if (this.mode === "inspect") this.renderInspectRail(seats);
|
|
9022
|
+
else if (this.mode === "channels") this.channels?.handleSelectionChange();
|
|
6284
9023
|
this.opts.onSelectionChange?.(seats);
|
|
6285
9024
|
}
|
|
6286
9025
|
// ---- DOM: chrome ----------------------------------------------------------
|
|
@@ -6299,7 +9038,9 @@ var SeatManager = class {
|
|
|
6299
9038
|
<button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
|
|
6300
9039
|
<button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
|
|
6301
9040
|
<button class="slm-mode" role="tab" data-mode="sections" title="Sections (S)" aria-keyshortcuts="S">Sections</button>
|
|
9041
|
+
<button class="slm-mode" role="tab" data-mode="channels" title="Channels (C)" aria-keyshortcuts="C" hidden>Channels</button>
|
|
6302
9042
|
</div>
|
|
9043
|
+
<select class="slm-tools" data-ref="tools" aria-label="Manager tools"></select>
|
|
6303
9044
|
<span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
|
|
6304
9045
|
<div class="slm-bar-actions">
|
|
6305
9046
|
<button class="slm-barbtn follow" data-ref="follow" aria-pressed="false"
|
|
@@ -6333,6 +9074,7 @@ var SeatManager = class {
|
|
|
6333
9074
|
this.mapHost = ref("maphost");
|
|
6334
9075
|
this.els = {
|
|
6335
9076
|
modes: ref("modes"),
|
|
9077
|
+
tools: ref("tools"),
|
|
6336
9078
|
livetext: ref("livetext"),
|
|
6337
9079
|
kpis: ref("kpis"),
|
|
6338
9080
|
follow: ref("follow"),
|
|
@@ -6345,6 +9087,7 @@ var SeatManager = class {
|
|
|
6345
9087
|
zfit: ref("zfit")
|
|
6346
9088
|
};
|
|
6347
9089
|
this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
|
|
9090
|
+
this.els.tools.addEventListener("change", () => this.setMode(this.els.tools.value));
|
|
6348
9091
|
this.els.zfit.addEventListener("click", () => this.zoomToFit());
|
|
6349
9092
|
this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
|
|
6350
9093
|
this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
|
|
@@ -6360,6 +9103,7 @@ var SeatManager = class {
|
|
|
6360
9103
|
updateContainerLayout() {
|
|
6361
9104
|
const width = this.root?.getBoundingClientRect().width || this.host.clientWidth;
|
|
6362
9105
|
this.root?.classList.toggle("compact", width > 0 && width < 800);
|
|
9106
|
+
this.channels?.handleLayoutChange();
|
|
6363
9107
|
}
|
|
6364
9108
|
buildSectionOptions() {
|
|
6365
9109
|
if (!this.doc) return;
|
|
@@ -6381,13 +9125,24 @@ var SeatManager = class {
|
|
|
6381
9125
|
}
|
|
6382
9126
|
}
|
|
6383
9127
|
paintModeTabs() {
|
|
9128
|
+
const available = [];
|
|
6384
9129
|
this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
|
|
6385
9130
|
const el = b;
|
|
6386
|
-
const
|
|
9131
|
+
const mode = el.dataset.mode;
|
|
9132
|
+
const permitted = mode !== "channels" || !!this.channels;
|
|
9133
|
+
el.hidden = !permitted;
|
|
9134
|
+
if (!permitted) return;
|
|
9135
|
+
available.push({ mode, label: el.textContent ?? mode });
|
|
9136
|
+
const active = mode === this.mode;
|
|
6387
9137
|
el.classList.toggle("on", active);
|
|
6388
9138
|
el.setAttribute("aria-selected", String(active));
|
|
6389
9139
|
el.tabIndex = active ? 0 : -1;
|
|
6390
9140
|
});
|
|
9141
|
+
const tools = this.els.tools;
|
|
9142
|
+
if (tools) {
|
|
9143
|
+
tools.innerHTML = available.map((entry) => `<option value="${entry.mode}"${entry.mode === this.mode ? " selected" : ""}>${esc2(entry.label)}</option>`).join("");
|
|
9144
|
+
tools.value = this.mode;
|
|
9145
|
+
}
|
|
6391
9146
|
this.root?.classList.toggle("block-mode", this.mode === "block");
|
|
6392
9147
|
}
|
|
6393
9148
|
paintFollowLiveButton() {
|
|
@@ -6492,6 +9247,7 @@ var SeatManager = class {
|
|
|
6492
9247
|
if (this.mode === "view") this.renderViewRail();
|
|
6493
9248
|
else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
|
|
6494
9249
|
else if (this.mode === "sections") this.renderSectionsRail();
|
|
9250
|
+
else if (this.mode === "channels") this.channels?.paintRail();
|
|
6495
9251
|
else this.renderBlockRail();
|
|
6496
9252
|
this.updateZoomHint();
|
|
6497
9253
|
}
|
|
@@ -6557,8 +9313,8 @@ var SeatManager = class {
|
|
|
6557
9313
|
const net = speed?.netBooked ?? 0;
|
|
6558
9314
|
const netLabel = `${net > 0 ? "+" : ""}${net}`;
|
|
6559
9315
|
const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
|
|
6560
|
-
return `<button type="button" class="slm-sectionrow" data-section-focus="${
|
|
6561
|
-
<span class="slm-sectiontop"><span>${
|
|
9316
|
+
return `<button type="button" class="slm-sectionrow" data-section-focus="${esc2(row.sectionId)}" title="Focus ${esc2(row.sectionLabel)} on the map">
|
|
9317
|
+
<span class="slm-sectiontop"><span>${esc2(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></span>
|
|
6562
9318
|
<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>
|
|
6563
9319
|
</button>`;
|
|
6564
9320
|
}).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
|
|
@@ -6603,12 +9359,12 @@ var SeatManager = class {
|
|
|
6603
9359
|
<p class="slm-eyebrow">${itemKind} details</p>
|
|
6604
9360
|
<p class="slm-hint">Live availability and section performance.</p>
|
|
6605
9361
|
<div class="slm-inspect-card">
|
|
6606
|
-
<div class="slm-inspect-label">${
|
|
9362
|
+
<div class="slm-inspect-label">${esc2(seat.label)}</div>
|
|
6607
9363
|
<div class="slm-inspect-grid">
|
|
6608
9364
|
<div><span>Status</span><b>${statusLabel[status]}</b></div>
|
|
6609
|
-
<div><span>Section</span><b>${
|
|
6610
|
-
${location2 ? `<div><span>${location2.label}</span><b>${
|
|
6611
|
-
<div><span>Category</span><b>${
|
|
9365
|
+
<div><span>Section</span><b>${esc2(sectionLabel)}</b></div>
|
|
9366
|
+
${location2 ? `<div><span>${location2.label}</span><b>${esc2(location2.value)}</b></div>` : ""}
|
|
9367
|
+
<div><span>Category</span><b>${esc2(category?.label ?? seat.categoryKey)}</b></div>
|
|
6612
9368
|
<div><span>Sold in section</span><b>${sectionMetric ? `${sectionMetric.booked} of ${sectionMetric.total}` : "\u2014"}</b></div>
|
|
6613
9369
|
<div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>
|
|
6614
9370
|
</div>
|
|
@@ -6754,7 +9510,7 @@ var SeatManager = class {
|
|
|
6754
9510
|
<div class="slm-availlist" data-ref="availlist">${rows.map((row) => this.sectionRowHtml(row)).join("")}</div>
|
|
6755
9511
|
<div class="slm-availsummary">
|
|
6756
9512
|
<span class="slm-availdot${warn ? " warn" : ""}"></span>
|
|
6757
|
-
<span>${
|
|
9513
|
+
<span>${esc2(summary)}</span>
|
|
6758
9514
|
</div>
|
|
6759
9515
|
<div class="slm-availcallout">
|
|
6760
9516
|
<span class="slm-availstar" aria-hidden="true">\u2726</span>
|
|
@@ -6769,7 +9525,7 @@ var SeatManager = class {
|
|
|
6769
9525
|
const disabled = this.availabilitySaving ? " disabled" : "";
|
|
6770
9526
|
const option = (value, text) => `<option value="${value}"${mode === value ? " selected" : ""}>${text}</option>`;
|
|
6771
9527
|
const control = row.followsZone ? '<span class="slm-availfollows">Follows zone</span>' : `<span class="slm-availselwrap">
|
|
6772
|
-
<select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${
|
|
9528
|
+
<select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc2(row.id)}"${disabled} aria-label="Availability for ${esc2(row.label)}">
|
|
6773
9529
|
${option("open", "Open \u2014 on sale")}
|
|
6774
9530
|
${option("closed", "Closed \u2014 visible, not on sale")}
|
|
6775
9531
|
${option("hidden", "Hidden \u2014 off the buyer map")}
|
|
@@ -6779,15 +9535,15 @@ var SeatManager = class {
|
|
|
6779
9535
|
</span>`;
|
|
6780
9536
|
let detail = "";
|
|
6781
9537
|
if (!row.followsZone && mode === "timed") {
|
|
6782
|
-
const value = row.rule?.revealAt ?
|
|
9538
|
+
const value = row.rule?.revealAt ? esc2(toLocalInput(row.rule.revealAt)) : "";
|
|
6783
9539
|
detail = `<div class="slm-availdetail">
|
|
6784
|
-
<input type="datetime-local" class="slm-input" data-avail-reveal="${
|
|
9540
|
+
<input type="datetime-local" class="slm-input" data-avail-reveal="${esc2(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc2(row.label)}" />
|
|
6785
9541
|
</div>`;
|
|
6786
9542
|
} else if (!row.followsZone && mode === "threshold") {
|
|
6787
9543
|
const pct = row.rule?.thresholdPct ?? 80;
|
|
6788
9544
|
detail = `<div class="slm-availdetail">
|
|
6789
9545
|
<span class="slm-availpctlabel">Reveal at</span>
|
|
6790
|
-
<input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${
|
|
9546
|
+
<input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${esc2(row.id)}" value="${esc2(pct)}"${disabled} aria-label="Percent sold to reveal ${esc2(row.label)}" />
|
|
6791
9547
|
<span class="slm-availpctlabel">% sold</span>
|
|
6792
9548
|
</div>`;
|
|
6793
9549
|
}
|
|
@@ -6795,7 +9551,7 @@ var SeatManager = class {
|
|
|
6795
9551
|
const caret = row.kind === "zone" ? `<span class="slm-availcaret" aria-hidden="true">${row.hidden ? "\u25B8" : "\u25BE"}</span>` : "";
|
|
6796
9552
|
return `<div class="${cls}">
|
|
6797
9553
|
<div class="slm-availhead">
|
|
6798
|
-
<span class="slm-availlabel">${caret}${
|
|
9554
|
+
<span class="slm-availlabel">${caret}${esc2(row.label)}</span>
|
|
6799
9555
|
${badge}
|
|
6800
9556
|
<span class="slm-availcount">${row.seatCount.toLocaleString()}</span>
|
|
6801
9557
|
${control}
|
|
@@ -6884,25 +9640,25 @@ var SeatManager = class {
|
|
|
6884
9640
|
const extra = a.count > 1 ? ` +${a.count - 1}` : "";
|
|
6885
9641
|
const sections = a.sectionLabels ?? [];
|
|
6886
9642
|
const sectionCopy = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : "";
|
|
6887
|
-
return `<button type="button" class="slm-feedrow" data-feed-id="${
|
|
9643
|
+
return `<button type="button" class="slm-feedrow" data-feed-id="${esc2(a.id)}" title="Locate this activity on the map">
|
|
6888
9644
|
<span class="slm-feeddot" style="background:${color[a.status]}"></span>
|
|
6889
|
-
<span class="slm-feedtext">${sectionCopy ? `<span class="slm-feedsection">${
|
|
9645
|
+
<span class="slm-feedtext">${sectionCopy ? `<span class="slm-feedsection">${esc2(sectionCopy)}</span>` : ""}${a.count === 1 ? "Seat" : "Seats"} <b>${esc2(a.label)}${extra}</b> ${esc2(a.verb)}</span>
|
|
6890
9646
|
<span class="slm-feedmeta"><span class="slm-feedtime">${relTime(a.at, now)}</span><span class="slm-feedlocate">Locate</span></span>
|
|
6891
9647
|
</button>`;
|
|
6892
9648
|
}).join("");
|
|
6893
9649
|
}
|
|
6894
9650
|
renderBlockRail() {
|
|
6895
9651
|
const cats = this.doc?.categories ?? [];
|
|
6896
|
-
const catChips = cats.map((c) => `<button class="slm-chip" type="button" data-cat="${
|
|
6897
|
-
<span class="dot" style="background:${
|
|
6898
|
-
<span>${
|
|
9652
|
+
const catChips = cats.map((c) => `<button class="slm-chip" type="button" data-cat="${esc2(c.key)}" aria-pressed="false">
|
|
9653
|
+
<span class="dot" style="background:${esc2(c.color ?? "#6e7bff")}"></span>
|
|
9654
|
+
<span>${esc2(c.label ?? c.key)}</span>
|
|
6899
9655
|
<span class="slm-chipcount" data-cat-count>0</span>
|
|
6900
9656
|
<span class="slm-chipcheck" aria-hidden="true">\u2713</span>
|
|
6901
9657
|
</button>`).join("");
|
|
6902
9658
|
const sectionField = this.sectionOptions.length ? `<div class="slm-field"><label>Select a whole section</label>
|
|
6903
9659
|
<select class="slm-select" data-ref="section"><option value="">Choose a section\u2026</option>
|
|
6904
|
-
${this.sectionOptions.map((s) => `<option value="${
|
|
6905
|
-
const blockedSectionOptions = this.sectionOptions.map((s) => `<option value="${
|
|
9660
|
+
${this.sectionOptions.map((s) => `<option value="${esc2(s.id)}">${esc2(s.label)}</option>`).join("")}</select></div>` : "";
|
|
9661
|
+
const blockedSectionOptions = this.sectionOptions.map((s) => `<option value="${esc2(s.id)}">${esc2(s.label)}</option>`).join("");
|
|
6906
9662
|
this.els.rail.innerHTML = `
|
|
6907
9663
|
<p class="slm-eyebrow">Block & unblock</p>
|
|
6908
9664
|
<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>
|
|
@@ -7105,10 +9861,10 @@ var SeatManager = class {
|
|
|
7105
9861
|
const section = this.sectionLabelById.get(sectionId) ?? "Other seats";
|
|
7106
9862
|
const category = this.doc?.categories.find((item) => item.key === seat.categoryKey)?.label ?? seat.categoryKey;
|
|
7107
9863
|
const isSelected = selected.has(seat.label);
|
|
7108
|
-
return `<button type="button" class="slm-blockeditem${isSelected ? " on" : ""}" data-blocked-label="${
|
|
9864
|
+
return `<button type="button" class="slm-blockeditem${isSelected ? " on" : ""}" data-blocked-label="${esc2(seat.label)}" aria-pressed="${isSelected}">
|
|
7109
9865
|
<span class="slm-blockedcheck" aria-hidden="true">\u2713</span>
|
|
7110
|
-
<span class="slm-blockedcopy"><span class="slm-blockedlabel">${
|
|
7111
|
-
<span class="slm-blockedmeta">${
|
|
9866
|
+
<span class="slm-blockedcopy"><span class="slm-blockedlabel">${esc2(seat.label)}</span>
|
|
9867
|
+
<span class="slm-blockedmeta">${esc2(section)} \xB7 ${esc2(category)}</span></span>
|
|
7112
9868
|
</button>`;
|
|
7113
9869
|
}).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>`;
|
|
7114
9870
|
const markAll = this.els.markall;
|
|
@@ -7175,12 +9931,33 @@ var SeatManager = class {
|
|
|
7175
9931
|
// Annotate the CommonJS export names for ESM import in node:
|
|
7176
9932
|
0 && (module.exports = {
|
|
7177
9933
|
ApiError,
|
|
9934
|
+
BuyerAccessContext,
|
|
9935
|
+
BuyerAccessUnavailableError,
|
|
9936
|
+
BuyerRealtimeClient,
|
|
9937
|
+
ChannelsMode,
|
|
7178
9938
|
EmbeddedDesigner,
|
|
7179
9939
|
ManageApi,
|
|
7180
9940
|
ManageApiError,
|
|
9941
|
+
PUBLIC_CHANNEL_ID,
|
|
9942
|
+
PUBLIC_CHANNEL_NAME,
|
|
7181
9943
|
SeatManager,
|
|
7182
9944
|
SeatPicker,
|
|
7183
9945
|
SeatingChart,
|
|
7184
|
-
|
|
9946
|
+
accessIntentLabel,
|
|
9947
|
+
accessLine,
|
|
9948
|
+
attachPickerFrame,
|
|
9949
|
+
bucketRows,
|
|
9950
|
+
bucketRowsHtml,
|
|
9951
|
+
createBuyerAccessContext,
|
|
9952
|
+
createControllerSink,
|
|
9953
|
+
dropReviewRows,
|
|
9954
|
+
markerOf,
|
|
9955
|
+
mutationCount,
|
|
9956
|
+
needsMoveConfirmation,
|
|
9957
|
+
planAssignment,
|
|
9958
|
+
retryAfterCopy,
|
|
9959
|
+
selectionSources,
|
|
9960
|
+
stateBadge,
|
|
9961
|
+
suggestMarker
|
|
7185
9962
|
});
|
|
7186
9963
|
//# sourceMappingURL=index.cjs.map
|