@seatlayer/js 0.38.0 → 0.40.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.js CHANGED
@@ -13,796 +13,875 @@ import {
13
13
  t
14
14
  } from "@seatlayer/core";
15
15
 
16
- // src/api.ts
17
- var ApiError = class extends Error {
18
- constructor(status, message, code, conflicts, reason) {
19
- super(message);
20
- this.name = "ApiError";
21
- this.status = status;
22
- this.code = code;
23
- this.conflicts = conflicts;
24
- this.reason = reason;
25
- }
26
- };
27
- var OBJECT_UNAVAILABLE_CODES = {
28
- seat_conflict: "taken",
29
- conflict: "taken",
30
- channel_assignment_conflict: "ineligible",
31
- allocation_exhausted: "exhausted"
32
- };
33
- var PubApi = class {
34
- constructor(base, options = {}) {
35
- this.base = base;
36
- this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
37
- this.access = options.access;
38
- this.onObjectUnavailable = options.onObjectUnavailable;
39
- }
40
- /** True when this client is bound to a buyer access session. */
41
- get accessScoped() {
42
- return !!this.access?.configured;
43
- }
44
- async request(path, init = {}, retried = false) {
45
- const method = init.method ?? "GET";
46
- const headers = {};
47
- let body;
48
- if (init.body !== void 0) {
49
- headers["Content-Type"] = "application/json";
50
- body = JSON.stringify(init.body);
51
- }
52
- const authorization = await this.access?.authorization(retried ? "unauthorized" : "initial");
53
- if (authorization) headers.Authorization = authorization;
54
- const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
55
- const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
56
- const data = isJson ? await res.json().catch(() => null) : null;
57
- if (!res.ok) {
58
- const err = data;
59
- const code = err?.code ?? err?.error;
60
- if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
61
- const refreshed = await this.access.handleFailure(res.status, code);
62
- if (refreshed && !retried) return this.request(path, init, true);
63
- }
64
- if (res.status === 409) {
65
- const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
66
- const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
67
- if (reason) this.onObjectUnavailable?.({ labels, reason, code });
68
- }
69
- throw new ApiError(
70
- res.status,
71
- err?.error ?? `request_failed_${res.status}`,
72
- code,
73
- err?.conflicts,
74
- err?.reason
75
- );
16
+ // src/buyerRealtime.ts
17
+ var SEATLAYER_V1 = "seatlayer.v1";
18
+ var SEP = "\0";
19
+ var CLOSE_ACCESS_REVOKED = 4401;
20
+ var MAX_BACKOFF_MS = 15e3;
21
+ var PING_INTERVAL_MS = 25e3;
22
+ var PONG_GRACE_MS = 1e4;
23
+ var RESUME_ANSWER_GRACE_MS = 5e3;
24
+ function projectionFromSnapshot(frame) {
25
+ const fallback = typeof frame.default === "string" ? frame.default : "free";
26
+ const exceptions = {};
27
+ if (frame.seats && typeof frame.seats === "object") {
28
+ for (const [label, status] of Object.entries(frame.seats)) {
29
+ if (typeof status === "string" && status !== fallback) exceptions[label] = status;
76
30
  }
77
- return data;
78
- }
79
- chart(key) {
80
- return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
81
- }
82
- objects(key) {
83
- return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
84
31
  }
85
- hold(key, selections, ttlMs, replaceHoldId) {
86
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
87
- method: "POST",
88
- body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
89
- labels: selections.map((s) => s.label)
90
- });
32
+ return { default: fallback, exceptions };
33
+ }
34
+ function diffProjections(prev, next) {
35
+ if (!prev || prev.default !== next.default) return null;
36
+ const changes = [];
37
+ for (const [label, status] of Object.entries(next.exceptions)) {
38
+ if (prev.exceptions[label] !== status) changes.push({ label, status });
91
39
  }
92
- // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
93
- // window both are part of the route contract, and dropping either here made
94
- // the SDK quietly pick venue-wide and hold for the server default instead.
95
- bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
96
- return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
97
- method: "POST",
98
- body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
99
- });
40
+ for (const label of Object.keys(prev.exceptions)) {
41
+ if (!(label in next.exceptions)) changes.push({ label, status: next.default });
100
42
  }
101
- resume(key, holdId) {
102
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
103
- method: "POST",
104
- body: { holdId }
105
- });
43
+ return changes;
44
+ }
45
+ function applyChanges(projection, changes) {
46
+ for (const change of changes) {
47
+ if (change.status === projection.default) delete projection.exceptions[change.label];
48
+ else projection.exceptions[change.label] = change.status;
106
49
  }
107
- release(key, labels, holdId) {
108
- return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
109
- method: "POST",
110
- body: { labels, holdId }
111
- });
50
+ }
51
+ function assertCredentialFreeUrl(url) {
52
+ if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
53
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
112
54
  }
113
- /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
114
- * (reason: expired | extend_limit | not_found | not_active) if it can't. */
115
- extend(key, holdId, ttlMs) {
116
- return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
117
- method: "POST",
118
- body: { holdId, ...ttlMs ? { ttlMs } : {} }
119
- });
55
+ if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
56
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
120
57
  }
121
- /**
122
- * Which gateways this event can actually take money through — the question
123
- * `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
124
- * the answer is never discovered by failing a payment.
125
- *
126
- * Anonymous, and it discloses no account, key, mode or currency for a gateway
127
- * that did not match.
128
- */
129
- paymentOptions(key) {
130
- return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
58
+ }
59
+ var BuyerRealtimeClient = class {
60
+ constructor(options) {
61
+ this.ws = null;
62
+ this.stopped = true;
63
+ this.attempt = 0;
64
+ this.reconnectTimer = null;
65
+ this.pingTimer = null;
66
+ this.pongTimer = null;
67
+ this.resumeTimer = null;
68
+ /** Our model of this scope's projection. Null until the first snapshot. */
69
+ this.projection = null;
70
+ /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
71
+ this.version = null;
72
+ /** True once the 101 echoed `seatlayer.v1`. */
73
+ this.v1 = false;
74
+ /** Set when we offered v1 and the handshake came back without it — a proxy
75
+ * most likely stripped the header, so the next attempt selects the v1 frame
76
+ * format with the `?pv=1` marker instead (protocol doc §1). The marker
77
+ * selects a format and can never carry a credential or widen a scope. */
78
+ this.useQueryMarker = false;
79
+ this.hidden = null;
80
+ this.closedSections = null;
81
+ this.opts = options;
82
+ assertCredentialFreeUrl(options.url);
131
83
  }
132
- /**
133
- * Turn a live hold into an order and start a payment.
134
- *
135
- * The amount is NOT sent: the server recomputes it from the hold's own items,
136
- * which is the only reason a browser cannot alter what it pays. Nor is the
137
- * PROVIDER — the event row decides which gateway charges, and a `provider` in
138
- * the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
139
- * it is the shape that cannot disagree.
140
- */
141
- startCheckout(key, input) {
142
- return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
143
- method: "POST",
144
- body: input
145
- });
84
+ /** Negotiated protocol, for tests and diagnostics. */
85
+ get protocol() {
86
+ return this.ws ? this.v1 ? "v1" : "legacy" : null;
146
87
  }
147
- /**
148
- * Poll an order while its gateway webhook lands. The order id is an
149
- * unguessable token the buyer already holds, so it acts as the capability —
150
- * which is also why a buyer returning from a gateway page can be told what
151
- * happened with nothing but the id in the return URL.
152
- */
153
- orderStatus(orderId) {
154
- return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
88
+ get snapshotVersion() {
89
+ return this.version;
155
90
  }
156
- /**
157
- * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
158
- * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
159
- * already apply; the socket then carries only the short-lived ticket, in its
160
- * subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
161
- */
162
- subscribeTicket(key) {
163
- return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
164
- method: "POST",
165
- body: {}
166
- });
91
+ start() {
92
+ if (!this.stopped) return;
93
+ this.stopped = false;
94
+ void this.connect();
167
95
  }
168
- /**
169
- * The subscribe URL. Never carries a credential — not the bearer, not the
170
- * ticket. Query parameters are diagnostics only.
171
- */
172
- subscribeUrl(key) {
173
- const wsBase = this.base.replace(/^http/, "ws");
174
- const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
175
- return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
96
+ /** Stop for good (destroy, or a revocation). Safe to call twice. */
97
+ stop() {
98
+ this.stopped = true;
99
+ this.clearTimers();
100
+ const ws = this.ws;
101
+ this.ws = null;
102
+ if (ws) {
103
+ ws.onopen = null;
104
+ ws.onmessage = null;
105
+ ws.onclose = null;
106
+ ws.onerror = null;
107
+ try {
108
+ ws.close();
109
+ } catch {
110
+ }
111
+ }
176
112
  }
177
- /**
178
- * What PickerController opens its own socket with.
179
- *
180
- * Empty for an access-scoped client: a private scope authenticates with a
181
- * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
182
- * BuyerRealtimeClient owns that socket instead and the controller skips its
183
- * own (an empty URL is its documented "no live feed" contract). A tokenless
184
- * public client returns exactly the URL it always has, so nothing about the
185
- * public picker's realtime path changes.
186
- */
187
- socketUrl(key) {
188
- return this.accessScoped ? "" : this.subscribeUrl(key);
113
+ /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
114
+ restart() {
115
+ this.stop();
116
+ this.projection = null;
117
+ this.version = null;
118
+ this.attempt = 0;
119
+ this.start();
189
120
  }
190
- };
191
-
192
- // src/buyerAccess.ts
193
- var BuyerAccessUnavailableError = class extends Error {
194
- constructor(event) {
195
- super(`buyer_access_unavailable:${event.reason}`);
196
- this.name = "BuyerAccessUnavailableError";
197
- this.reason = event.reason;
198
- this.code = event.code;
199
- this.status = event.status;
121
+ // ---- connection -----------------------------------------------------------
122
+ async connect() {
123
+ if (this.stopped) return;
124
+ let protocols = [SEATLAYER_V1];
125
+ if (this.opts.mintTicket) {
126
+ let minted;
127
+ try {
128
+ minted = await this.opts.mintTicket();
129
+ } catch (err) {
130
+ this.reportIfAccessError(err);
131
+ this.scheduleReconnect();
132
+ return;
133
+ }
134
+ if (this.stopped) return;
135
+ if (minted?.protocols?.length) {
136
+ protocols = [...minted.protocols];
137
+ if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
138
+ } else if (minted?.ticket) {
139
+ protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
140
+ }
141
+ }
142
+ const offeredResume = this.version !== null;
143
+ if (offeredResume) protocols.push(`sv.${this.version}`);
144
+ const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
145
+ assertCredentialFreeUrl(url);
146
+ let ws;
147
+ try {
148
+ const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
149
+ ws = make(url, protocols);
150
+ } catch {
151
+ this.scheduleReconnect();
152
+ return;
153
+ }
154
+ this.ws = ws;
155
+ ws.onopen = () => {
156
+ if (this.ws !== ws) return;
157
+ this.attempt = 0;
158
+ this.v1 = ws.protocol === SEATLAYER_V1;
159
+ if (!this.v1) this.useQueryMarker = true;
160
+ this.startKeepalive(ws);
161
+ if (offeredResume) {
162
+ this.resumeTimer = setTimeout(() => {
163
+ this.resumeTimer = null;
164
+ void this.opts.sink.resync();
165
+ }, RESUME_ANSWER_GRACE_MS);
166
+ } else {
167
+ void this.opts.sink.resync();
168
+ }
169
+ };
170
+ ws.onmessage = (event) => {
171
+ if (this.ws !== ws) return;
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
175
+ } catch {
176
+ return;
177
+ }
178
+ if (!parsed || typeof parsed !== "object") return;
179
+ this.handleFrame(parsed);
180
+ };
181
+ ws.onclose = (event) => {
182
+ if (this.ws !== ws) return;
183
+ this.ws = null;
184
+ this.clearTimers();
185
+ if (event?.code === CLOSE_ACCESS_REVOKED) {
186
+ this.stopped = true;
187
+ this.opts.onAccessUnavailable?.({
188
+ reason: "revoked",
189
+ code: "access_revoked",
190
+ retryable: false
191
+ });
192
+ return;
193
+ }
194
+ this.scheduleReconnect();
195
+ };
196
+ ws.onerror = () => {
197
+ try {
198
+ ws.close();
199
+ } catch {
200
+ }
201
+ };
200
202
  }
201
- };
202
- var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
203
- var RECOVERABLE = /* @__PURE__ */ new Set([
204
- "paused",
205
- "provider_failed",
206
- "channel_denied"
207
- ]);
208
- function classifyAccessFailure(status, code) {
209
- switch (code) {
210
- case "buyer_access_invalid":
211
- return "invalid";
212
- case "buyer_access_revoked":
213
- return "revoked";
214
- case "buyer_access_origin_mismatch":
215
- return "origin_mismatch";
216
- case "buyer_access_event_mismatch":
217
- return "event_mismatch";
218
- case "buyer_access_mode_mismatch":
219
- return "mode_mismatch";
220
- case "channel_access_denied":
221
- return "channel_denied";
222
- case "channel_paused":
223
- return "paused";
224
- case "invalid_channel_scope":
225
- return "invalid_scope";
226
- default:
227
- break;
203
+ handleFrame(frame) {
204
+ const type = typeof frame.type === "string" ? frame.type : "";
205
+ if (frame.protocol === 1) this.v1 = true;
206
+ if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
207
+ if (type === "pong") {
208
+ this.clearPongTimer();
209
+ return;
210
+ }
211
+ if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
212
+ const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
213
+ const closed = Array.isArray(frame.closed) ? frame.closed : [];
214
+ const hKey = hidden.join(SEP);
215
+ const cKey = closed.join(SEP);
216
+ if (hKey !== this.hidden || cKey !== this.closedSections) {
217
+ this.hidden = hKey;
218
+ this.closedSections = cKey;
219
+ this.opts.sink.onSections?.(hidden, closed);
220
+ }
221
+ }
222
+ if (type === "hidden") return;
223
+ if (type === "presence") {
224
+ this.opts.sink.onPresence?.({
225
+ shoppingSessions: Number(frame.shoppingSessions) || 0,
226
+ activeHolds: Number(frame.activeHolds) || 0
227
+ });
228
+ return;
229
+ }
230
+ if (type === "allocation") {
231
+ return;
232
+ }
233
+ if (type === "snapshot" || !type && frame.seats) {
234
+ this.answered();
235
+ const next = projectionFromSnapshot(frame);
236
+ const changes = diffProjections(this.projection, next);
237
+ this.projection = next;
238
+ if (changes === null) void this.opts.sink.resync();
239
+ else if (changes.length) this.opts.sink.applyStatuses(changes);
240
+ return;
241
+ }
242
+ if (type === "delta" && Array.isArray(frame.changes)) {
243
+ this.answered();
244
+ const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
245
+ if (!changes.length) return;
246
+ if (this.projection) applyChanges(this.projection, changes);
247
+ this.opts.sink.applyStatuses(changes);
248
+ }
228
249
  }
229
- if (status === 401) return "invalid";
230
- return null;
250
+ /** The server answered our resume; cancel the fallback resync. */
251
+ answered() {
252
+ if (!this.resumeTimer) return;
253
+ clearTimeout(this.resumeTimer);
254
+ this.resumeTimer = null;
255
+ }
256
+ reportIfAccessError(err) {
257
+ const reason = err?.reason;
258
+ if (err?.name !== "BuyerAccessUnavailableError") return;
259
+ this.stopped = true;
260
+ this.opts.onAccessUnavailable?.({
261
+ reason: reason ?? "invalid",
262
+ code: err.code,
263
+ status: err.status,
264
+ retryable: reason === "paused"
265
+ });
266
+ }
267
+ // ---- keepalive & backoff --------------------------------------------------
268
+ /**
269
+ * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
270
+ * for minutes is the normal, correct state for a narrowly-scoped buyer on a
271
+ * busy event (protocol doc §5), so quiet time never triggers a reconnect.
272
+ */
273
+ startKeepalive(ws) {
274
+ this.pingTimer = setInterval(() => {
275
+ if (this.ws !== ws) return;
276
+ try {
277
+ ws.send(JSON.stringify({ type: "ping" }));
278
+ } catch {
279
+ return;
280
+ }
281
+ this.clearPongTimer();
282
+ this.pongTimer = setTimeout(() => {
283
+ this.pongTimer = null;
284
+ try {
285
+ ws.close();
286
+ } catch {
287
+ }
288
+ }, PONG_GRACE_MS);
289
+ }, PING_INTERVAL_MS);
290
+ }
291
+ /**
292
+ * FULL jitter, not plain exponential backoff.
293
+ *
294
+ * A deterministic `2**attempt` schedule makes every browser that lost the same
295
+ * socket — a worker redeploy, a DO eviction, a flaky edge PoP — come back in
296
+ * the same millisecond, and an on-sale crowd reconnecting in lockstep is the
297
+ * thing that turns one blip into a self-sustaining thundering herd. Full
298
+ * jitter (`random() * ceiling`) spreads the same crowd across the whole
299
+ * window; the ceiling still doubles, so a persistent outage still backs off.
300
+ *
301
+ * `Math.random` is correct here: this is client code choosing a delay, not a
302
+ * Workflow step that has to replay deterministically.
303
+ */
304
+ scheduleReconnect() {
305
+ if (this.stopped || this.reconnectTimer) return;
306
+ const attempt = Math.min(this.attempt++, 5);
307
+ const ceiling = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
308
+ const delay = Math.random() * ceiling;
309
+ this.reconnectTimer = setTimeout(() => {
310
+ this.reconnectTimer = null;
311
+ void this.connect();
312
+ }, delay);
313
+ }
314
+ clearPongTimer() {
315
+ if (!this.pongTimer) return;
316
+ clearTimeout(this.pongTimer);
317
+ this.pongTimer = null;
318
+ }
319
+ clearTimers() {
320
+ if (this.pingTimer) clearInterval(this.pingTimer);
321
+ this.pingTimer = null;
322
+ this.clearPongTimer();
323
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
324
+ this.reconnectTimer = null;
325
+ if (this.resumeTimer) clearTimeout(this.resumeTimer);
326
+ this.resumeTimer = null;
327
+ }
328
+ };
329
+ function rendererStatus(wire) {
330
+ if (wire === "blocked") return "not_for_sale";
331
+ if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
332
+ return "free";
231
333
  }
232
- function isAccessExpiry(status, code) {
233
- return status === 401 && !!code && EXPIRED_CODES.has(code);
334
+ function createControllerSink(controller, options = {}) {
335
+ const idsForLabel = (label) => {
336
+ const table = controller.tableSelection(label);
337
+ if (table) return table.physicalSeatIds;
338
+ const id = controller.idForLabel(label);
339
+ return id ? [id] : [];
340
+ };
341
+ return {
342
+ applyStatuses(changes) {
343
+ const held = controller.currentHold()?.labels ?? [];
344
+ const buckets = {
345
+ free: [],
346
+ held: [],
347
+ booked: [],
348
+ not_for_sale: []
349
+ };
350
+ const flashes = [];
351
+ const lost = [];
352
+ const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
353
+ for (const change of changes) {
354
+ const ids = idsForLabel(change.label);
355
+ if (!ids.length) continue;
356
+ const next = rendererStatus(change.status);
357
+ buckets[next].push(...ids);
358
+ if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
359
+ const color = next === "held" ? "#f4b740" : "#f43f5e";
360
+ for (const id of ids) flashes.push({ id, color });
361
+ }
362
+ if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
363
+ lost.push(change.label);
364
+ }
365
+ }
366
+ for (const status of ["free", "held", "booked", "not_for_sale"]) {
367
+ if (buckets[status].length) controller.setStatus(buckets[status], status);
368
+ }
369
+ for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
370
+ if (lost.length) {
371
+ const ids = lost.flatMap((label) => idsForLabel(label));
372
+ if (ids.length) controller.deselect(ids);
373
+ const ineligible = changes.some(
374
+ (c) => c.status === "blocked" && lost.includes(c.label)
375
+ );
376
+ options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
377
+ }
378
+ options.onStatusChange?.();
379
+ },
380
+ async resync() {
381
+ await controller.refresh();
382
+ },
383
+ /**
384
+ * Section availability moved. Statuses are re-pulled so the map repaints.
385
+ *
386
+ * Known limit: rebuilding the chart when a section is newly HIDDEN (its
387
+ * seats are stripped, not greyed) lives inside PickerController's own
388
+ * socket handler and has no public entry point, so an access-scoped picker
389
+ * repaints statuses but does not restructure the chart until its next
390
+ * mount. Closing/opening a section — the common mid-sale move — is a
391
+ * status-level change and is handled here in full.
392
+ */
393
+ onSections(hidden, closed) {
394
+ void controller.refresh();
395
+ options.onSections?.(hidden, closed);
396
+ }
397
+ };
234
398
  }
235
- var DEFAULT_SKEW_MS = 3e4;
236
- var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
237
- var BuyerAccessContext = class {
238
- constructor(options) {
239
- __privateAdd(this, _BuyerAccessContext_instances);
240
- /** Private field: not enumerable, not spreadable, not serializable. */
241
- __privateAdd(this, _token, null);
242
- __privateAdd(this, _expiresAt, 0);
243
- __privateAdd(this, _provider);
244
- __privateAdd(this, _skewMs);
245
- __privateAdd(this, _inflight, null);
246
- __privateAdd(this, _terminal, null);
247
- /** The most recent failure, terminal or not — so one cause reports once. */
248
- __privateAdd(this, _lastFailure, null);
249
- __privateAdd(this, _onExpired);
250
- __privateAdd(this, _onUnavailable);
251
- /** Decided once, at construction. See the `configured` getter. */
252
- __privateAdd(this, _configured, false);
253
- __privateSet(this, _provider, options.provider);
254
- __privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
255
- __privateSet(this, _onExpired, options.onExpired);
256
- __privateSet(this, _onUnavailable, options.onUnavailable);
257
- if (options.token) {
258
- const seed = typeof options.token === "string" ? { token: options.token } : options.token;
259
- __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
399
+
400
+ // src/api.ts
401
+ var ApiError = class extends Error {
402
+ constructor(status, message, code, conflicts, reason, retryAfterS) {
403
+ super(message);
404
+ this.name = "ApiError";
405
+ this.status = status;
406
+ this.code = code;
407
+ this.conflicts = conflicts;
408
+ this.reason = reason;
409
+ this.retryAfterS = retryAfterS;
410
+ }
411
+ };
412
+ var MAX_RATE_LIMIT_WAIT_S = 10;
413
+ var DEFAULT_RATE_LIMIT_WAIT_S = 1;
414
+ function parseRetryAfter(header, bodyValue) {
415
+ const raw = (header ?? "").trim();
416
+ if (raw) {
417
+ const seconds = Number(raw);
418
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
419
+ const at = Date.parse(raw);
420
+ if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - Date.now()) / 1e3));
421
+ }
422
+ if (typeof bodyValue === "number" && Number.isFinite(bodyValue) && bodyValue >= 0) {
423
+ return Math.ceil(bodyValue);
424
+ }
425
+ return void 0;
426
+ }
427
+ var OBJECT_UNAVAILABLE_CODES = {
428
+ seat_conflict: "taken",
429
+ conflict: "taken",
430
+ channel_assignment_conflict: "ineligible",
431
+ allocation_exhausted: "exhausted"
432
+ };
433
+ var PubApi = class {
434
+ constructor(base, options = {}) {
435
+ this.base = base;
436
+ this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
437
+ this.access = options.access;
438
+ this.onObjectUnavailable = options.onObjectUnavailable;
439
+ }
440
+ /** True when this client is bound to a buyer access session. */
441
+ get accessScoped() {
442
+ return !!this.access?.configured;
443
+ }
444
+ async request(path, init = {}, retried = {}) {
445
+ const method = init.method ?? "GET";
446
+ const headers = {};
447
+ let body;
448
+ if (init.body !== void 0) {
449
+ headers["Content-Type"] = "application/json";
450
+ body = JSON.stringify(init.body);
260
451
  }
261
- __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
452
+ const authorization = await this.access?.authorization(retried.auth ? "unauthorized" : "initial");
453
+ if (authorization) headers.Authorization = authorization;
454
+ const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
455
+ const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
456
+ const data = isJson ? await res.json().catch(() => null) : null;
457
+ if (!res.ok) {
458
+ const err = data;
459
+ const code = err?.code ?? err?.error;
460
+ if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
461
+ const refreshed = await this.access.handleFailure(res.status, code);
462
+ if (refreshed && !retried.auth) return this.request(path, init, { ...retried, auth: true });
463
+ }
464
+ if (res.status === 409) {
465
+ const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
466
+ const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
467
+ if (reason) this.onObjectUnavailable?.({ labels, reason, code });
468
+ }
469
+ let retryAfterS;
470
+ if (res.status === 429) {
471
+ retryAfterS = parseRetryAfter(res.headers.get("Retry-After"), err?.retryAfterSeconds) ?? DEFAULT_RATE_LIMIT_WAIT_S;
472
+ if (method === "GET" && !retried.rateLimit && retryAfterS <= MAX_RATE_LIMIT_WAIT_S) {
473
+ await new Promise((resolve) => setTimeout(resolve, retryAfterS * 1e3));
474
+ return this.request(path, init, { ...retried, rateLimit: true });
475
+ }
476
+ }
477
+ throw new ApiError(
478
+ res.status,
479
+ err?.error ?? `request_failed_${res.status}`,
480
+ code,
481
+ err?.conflicts,
482
+ err?.reason,
483
+ retryAfterS
484
+ );
485
+ }
486
+ return data;
487
+ }
488
+ chart(key) {
489
+ return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
490
+ }
491
+ objects(key) {
492
+ return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
493
+ }
494
+ hold(key, selections, ttlMs, replaceHoldId) {
495
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
496
+ method: "POST",
497
+ body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
498
+ labels: selections.map((s) => s.label)
499
+ });
500
+ }
501
+ // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
502
+ // window — both are part of the route contract, and dropping either here made
503
+ // the SDK quietly pick venue-wide and hold for the server default instead.
504
+ bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
505
+ return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
506
+ method: "POST",
507
+ body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
508
+ });
509
+ }
510
+ resume(key, holdId) {
511
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
512
+ method: "POST",
513
+ body: { holdId }
514
+ });
515
+ }
516
+ release(key, labels, holdId) {
517
+ return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
518
+ method: "POST",
519
+ body: { labels, holdId }
520
+ });
521
+ }
522
+ /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
523
+ * (reason: expired | extend_limit | not_found | not_active) if it can't. */
524
+ extend(key, holdId, ttlMs) {
525
+ return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
526
+ method: "POST",
527
+ body: { holdId, ...ttlMs ? { ttlMs } : {} }
528
+ });
262
529
  }
263
530
  /**
264
- * True when this picker is access-scoped at all. A false here is the
265
- * tokenless public picker, which must behave exactly as it always has.
531
+ * Which gateways this event can actually take money through the question
532
+ * `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
533
+ * the answer is never discovered by failing a payment.
266
534
  *
267
- * Answered from what the HOST asked for, never from live token state. It used
268
- * to be `!!#provider || !!#token`, which quietly inverted this file's central
269
- * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
270
- * the first refusal turned a configured context into an "unconfigured" one,
271
- * `authorization()` then returned null instead of throwing, and the very next
272
- * call went out with no bearer — the anonymous Public sale fallback this
273
- * module exists to prevent. A provider host never saw it, because `#provider`
274
- * held `configured` true. Found against a live worker in the M9 pass.
535
+ * Anonymous, and it discloses no account, key, mode or currency for a gateway
536
+ * that did not match.
275
537
  */
276
- get configured() {
277
- return __privateGet(this, _configured);
538
+ paymentOptions(key) {
539
+ return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
278
540
  }
279
- /** Set once a state arrives that refreshing cannot clear. */
280
- get unavailable() {
281
- return __privateGet(this, _terminal);
541
+ /**
542
+ * Turn a live hold into an order and start a payment.
543
+ *
544
+ * The amount is NOT sent: the server recomputes it from the hold's own items,
545
+ * which is the only reason a browser cannot alter what it pays. Nor is the
546
+ * PROVIDER — the event row decides which gateway charges, and a `provider` in
547
+ * the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
548
+ * it is the shape that cannot disagree.
549
+ */
550
+ startCheckout(key, input) {
551
+ return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
552
+ method: "POST",
553
+ body: input
554
+ });
282
555
  }
283
- /** True while a usable bearer is held (ignores skew). */
284
- get hasToken() {
285
- return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
556
+ /**
557
+ * Poll an order while its gateway webhook lands. The order id is an
558
+ * unguessable token the buyer already holds, so it acts as the capability
559
+ * which is also why a buyer returning from a gateway page can be told what
560
+ * happened with nothing but the id in the return URL.
561
+ */
562
+ orderStatus(orderId) {
563
+ return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
286
564
  }
287
- /** Epoch ms the current token expires, or 0 when the host didn't say. */
288
- get expiresAt() {
289
- return __privateGet(this, _expiresAt);
565
+ /**
566
+ * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
567
+ * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
568
+ * already apply; the socket then carries only the short-lived ticket, in its
569
+ * subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
570
+ */
571
+ subscribeTicket(key) {
572
+ return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
573
+ method: "POST",
574
+ body: {}
575
+ });
576
+ }
577
+ /**
578
+ * The subscribe URL. Never carries a credential — not the bearer, not the
579
+ * ticket. Query parameters are diagnostics only.
580
+ */
581
+ subscribeUrl(key) {
582
+ const wsBase = this.base.replace(/^http/, "ws");
583
+ const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
584
+ return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
585
+ }
586
+ /**
587
+ * What PickerController opens its own socket with.
588
+ *
589
+ * Empty for an access-scoped client: a private scope authenticates with a
590
+ * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
591
+ * BuyerRealtimeClient owns that socket instead and the controller skips its
592
+ * own (an empty URL is its documented "no live feed" contract). A tokenless
593
+ * public client returns exactly the URL it always has, so nothing about the
594
+ * public picker's realtime path changes.
595
+ */
596
+ socketUrl(key) {
597
+ return this.accessScoped ? "" : this.subscribeUrl(key);
290
598
  }
291
599
  /**
292
- * The `Authorization` header value for a scoped operation.
600
+ * The subprotocol list a PLAIN `new WebSocket(url, protocols)` must offer for
601
+ * this transport — `PickerTransport.socketProtocols`, which PickerController
602
+ * calls optionally and which nothing implemented until now.
293
603
  *
294
- * Returns null only when this context is not configured at all (the ordinary
295
- * anonymous public picker). A configured context either returns a bearer or
296
- * throws `BuyerAccessUnavailableError` it never returns null, because a
297
- * null here would send the request as anonymous Public sale.
604
+ * Offering `seatlayer.v1` is the whole point: without it the DO answers an
605
+ * anonymous socket with the LEGACY verbose frame every unit of a 10k-seat
606
+ * event, on connect and on every reconnect instead of the compact
607
+ * `{default, exceptions}` form. Empty for an access-scoped client, which
608
+ * authenticates with a one-use ticket a URL-only constructor cannot carry and
609
+ * whose socket BuyerRealtimeClient owns instead (see `socketUrl`).
610
+ *
611
+ * `createRealtime` below is the preferred path and supersedes this for any
612
+ * host that can use it; this stays the correct answer for a host that builds
613
+ * the socket itself from the transport contract.
298
614
  */
299
- async authorization(reason = "initial") {
300
- if (!this.configured) return null;
301
- if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
302
- const now = Date.now();
303
- const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
304
- if (stale) {
305
- const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
306
- const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
307
- const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
308
- if (!token) {
309
- throw new BuyerAccessUnavailableError(
310
- __privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
311
- );
312
- }
313
- return `Bearer ${token}`;
314
- }
315
- return `Bearer ${__privateGet(this, _token)}`;
615
+ socketProtocols(key) {
616
+ void key;
617
+ return this.accessScoped ? [] : [SEATLAYER_V1];
316
618
  }
317
619
  /**
318
- * Handle a 401/403 from a scoped call. Returns true when the caller should
319
- * retry the same request once with the refreshed bearer.
620
+ * Hand PickerController the v1 realtime client instead of letting it open a
621
+ * bare socket `PickerTransport.createRealtime`.
622
+ *
623
+ * This is what puts an ANONYMOUS buyer (the on-sale case) on the same wire as
624
+ * a private-channel one: compact snapshots, `sv.<n>` resume so a reconnect
625
+ * inside the ring costs a delta rather than a full re-snapshot, ping/pong
626
+ * liveness, and one jittered backoff implementation shared by both. The
627
+ * anonymous case simply passes no `mintTicket` — the `/pub/events/:key/
628
+ * subscribe` upgrade requires no ticket, and the DO resolves a ticketless
629
+ * socket to the public scope.
630
+ *
631
+ * Null when access-scoped: that socket is owned by the widget's own
632
+ * BuyerRealtimeClient (with the ticket exchange), and `socketUrl()` already
633
+ * returns '' so the controller opens nothing.
320
634
  */
321
- async handleFailure(status, code) {
322
- var _a;
323
- if (!this.configured) return false;
324
- if (isAccessExpiry(status, code)) {
325
- __privateSet(this, _token, null);
326
- __privateSet(this, _expiresAt, 0);
327
- const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
328
- (_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
329
- return !!token;
330
- }
331
- const reason = classifyAccessFailure(status, code);
332
- if (reason) {
333
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
334
- return false;
335
- }
336
- return false;
337
- }
338
- /** Host-driven re-acquisition (after the buyer signs in again, say). */
339
- async refresh(reason = "manual") {
340
- __privateSet(this, _terminal, null);
341
- __privateSet(this, _lastFailure, null);
342
- __privateSet(this, _token, null);
343
- __privateSet(this, _expiresAt, 0);
344
- return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
345
- }
346
- /** Drop the bearer. Called on destroy so nothing outlives the widget. */
347
- clear() {
348
- __privateSet(this, _token, null);
349
- __privateSet(this, _expiresAt, 0);
350
- __privateSet(this, _inflight, null);
351
- }
352
- /** Redaction: the bearer must not survive a stringify or an interpolation. */
353
- toJSON() {
354
- return { configured: this.configured, hasToken: this.hasToken };
355
- }
356
- toString() {
357
- return "[BuyerAccessContext redacted]";
358
- }
359
- };
360
- _token = new WeakMap();
361
- _expiresAt = new WeakMap();
362
- _provider = new WeakMap();
363
- _skewMs = new WeakMap();
364
- _inflight = new WeakMap();
365
- _terminal = new WeakMap();
366
- _lastFailure = new WeakMap();
367
- _onExpired = new WeakMap();
368
- _onUnavailable = new WeakMap();
369
- _configured = new WeakMap();
370
- _BuyerAccessContext_instances = new WeakSet();
371
- // ---- internals ------------------------------------------------------------
372
- accept_fn = function(next) {
373
- if (!next || typeof next.token !== "string" || !next.token) return null;
374
- __privateSet(this, _token, next.token);
375
- __privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
376
- return __privateGet(this, _token);
377
- };
378
- /**
379
- * One provider call at a time. Several operations racing an expiry (chart +
380
- * objects + a socket ticket) must not mint several sessions — the guide's
381
- * rotate-on-retry rule would revoke the ones they didn't observe.
382
- */
383
- renew_fn = function(reason, code) {
384
- if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
385
- const provider = __privateGet(this, _provider);
386
- if (!provider) {
387
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
388
- return Promise.resolve(null);
389
- }
390
- const run = (async () => {
391
- try {
392
- const next = await provider({ reason });
393
- const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
394
- if (!token) {
395
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
396
- return null;
397
- }
398
- return token;
399
- } catch {
400
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
401
- return null;
402
- } finally {
403
- __privateSet(this, _inflight, null);
404
- }
405
- })();
406
- __privateSet(this, _inflight, run);
407
- return run;
408
- };
409
- fail_fn = function(reason, code, status) {
410
- var _a;
411
- const event = {
412
- reason,
413
- code,
414
- status,
415
- // Unchanged: `retryable` means "the SAME request may succeed later".
416
- // `provider_failed` is recoverable but not retryable — the host must fix
417
- // its mint endpoint first — so the two sets are deliberately different.
418
- retryable: reason === "paused" || reason === "channel_denied"
419
- };
420
- __privateSet(this, _lastFailure, event);
421
- if (!RECOVERABLE.has(reason)) {
422
- __privateSet(this, _terminal, event);
423
- __privateSet(this, _token, null);
424
- __privateSet(this, _expiresAt, 0);
635
+ createRealtime(key, sink) {
636
+ if (this.accessScoped) return null;
637
+ return new BuyerRealtimeClient({ url: this.subscribeUrl(key), sink });
425
638
  }
426
- (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
427
- return event;
428
639
  };
429
- function createBuyerAccessContext(options, hooks = {}) {
430
- if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
431
- return new BuyerAccessContext({
432
- provider: options.buyerAccessTokenProvider,
433
- token: options.buyerAccessToken,
434
- ...hooks
435
- });
436
- }
437
640
 
438
- // src/buyerRealtime.ts
439
- var SEATLAYER_V1 = "seatlayer.v1";
440
- var SEP = "\0";
441
- var CLOSE_ACCESS_REVOKED = 4401;
442
- var MAX_BACKOFF_MS = 15e3;
443
- var PING_INTERVAL_MS = 25e3;
444
- var PONG_GRACE_MS = 1e4;
445
- var RESUME_ANSWER_GRACE_MS = 5e3;
446
- function projectionFromSnapshot(frame) {
447
- const fallback = typeof frame.default === "string" ? frame.default : "free";
448
- const exceptions = {};
449
- if (frame.seats && typeof frame.seats === "object") {
450
- for (const [label, status] of Object.entries(frame.seats)) {
451
- if (typeof status === "string" && status !== fallback) exceptions[label] = status;
452
- }
453
- }
454
- return { default: fallback, exceptions };
455
- }
456
- function diffProjections(prev, next) {
457
- if (!prev || prev.default !== next.default) return null;
458
- const changes = [];
459
- for (const [label, status] of Object.entries(next.exceptions)) {
460
- if (prev.exceptions[label] !== status) changes.push({ label, status });
461
- }
462
- for (const label of Object.keys(prev.exceptions)) {
463
- if (!(label in next.exceptions)) changes.push({ label, status: next.default });
641
+ // src/buyerAccess.ts
642
+ var BuyerAccessUnavailableError = class extends Error {
643
+ constructor(event) {
644
+ super(`buyer_access_unavailable:${event.reason}`);
645
+ this.name = "BuyerAccessUnavailableError";
646
+ this.reason = event.reason;
647
+ this.code = event.code;
648
+ this.status = event.status;
464
649
  }
465
- return changes;
466
- }
467
- function applyChanges(projection, changes) {
468
- for (const change of changes) {
469
- if (change.status === projection.default) delete projection.exceptions[change.label];
470
- else projection.exceptions[change.label] = change.status;
650
+ };
651
+ var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
652
+ var RECOVERABLE = /* @__PURE__ */ new Set([
653
+ "paused",
654
+ "provider_failed",
655
+ "channel_denied"
656
+ ]);
657
+ function classifyAccessFailure(status, code) {
658
+ switch (code) {
659
+ case "buyer_access_invalid":
660
+ return "invalid";
661
+ case "buyer_access_revoked":
662
+ return "revoked";
663
+ case "buyer_access_origin_mismatch":
664
+ return "origin_mismatch";
665
+ case "buyer_access_event_mismatch":
666
+ return "event_mismatch";
667
+ case "buyer_access_mode_mismatch":
668
+ return "mode_mismatch";
669
+ case "channel_access_denied":
670
+ return "channel_denied";
671
+ case "channel_paused":
672
+ return "paused";
673
+ case "invalid_channel_scope":
674
+ return "invalid_scope";
675
+ default:
676
+ break;
471
677
  }
678
+ if (status === 401) return "invalid";
679
+ return null;
472
680
  }
473
- function assertCredentialFreeUrl(url) {
474
- if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
475
- throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
476
- }
477
- if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
478
- throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
479
- }
681
+ function isAccessExpiry(status, code) {
682
+ return status === 401 && !!code && EXPIRED_CODES.has(code);
480
683
  }
481
- var BuyerRealtimeClient = class {
684
+ var DEFAULT_SKEW_MS = 3e4;
685
+ var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
686
+ var BuyerAccessContext = class {
482
687
  constructor(options) {
483
- this.ws = null;
484
- this.stopped = true;
485
- this.attempt = 0;
486
- this.reconnectTimer = null;
487
- this.pingTimer = null;
488
- this.pongTimer = null;
489
- this.resumeTimer = null;
490
- /** Our model of this scope's projection. Null until the first snapshot. */
491
- this.projection = null;
492
- /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
493
- this.version = null;
494
- /** True once the 101 echoed `seatlayer.v1`. */
495
- this.v1 = false;
496
- /** Set when we offered v1 and the handshake came back without it — a proxy
497
- * most likely stripped the header, so the next attempt selects the v1 frame
498
- * format with the `?pv=1` marker instead (protocol doc §1). The marker
499
- * selects a format and can never carry a credential or widen a scope. */
500
- this.useQueryMarker = false;
501
- this.hidden = null;
502
- this.closedSections = null;
503
- this.opts = options;
504
- assertCredentialFreeUrl(options.url);
505
- }
506
- /** Negotiated protocol, for tests and diagnostics. */
507
- get protocol() {
508
- return this.ws ? this.v1 ? "v1" : "legacy" : null;
688
+ __privateAdd(this, _BuyerAccessContext_instances);
689
+ /** Private field: not enumerable, not spreadable, not serializable. */
690
+ __privateAdd(this, _token, null);
691
+ __privateAdd(this, _expiresAt, 0);
692
+ __privateAdd(this, _provider);
693
+ __privateAdd(this, _skewMs);
694
+ __privateAdd(this, _inflight, null);
695
+ __privateAdd(this, _terminal, null);
696
+ /** The most recent failure, terminal or not — so one cause reports once. */
697
+ __privateAdd(this, _lastFailure, null);
698
+ __privateAdd(this, _onExpired);
699
+ __privateAdd(this, _onUnavailable);
700
+ /** Decided once, at construction. See the `configured` getter. */
701
+ __privateAdd(this, _configured, false);
702
+ __privateSet(this, _provider, options.provider);
703
+ __privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
704
+ __privateSet(this, _onExpired, options.onExpired);
705
+ __privateSet(this, _onUnavailable, options.onUnavailable);
706
+ if (options.token) {
707
+ const seed = typeof options.token === "string" ? { token: options.token } : options.token;
708
+ __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
709
+ }
710
+ __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
509
711
  }
510
- get snapshotVersion() {
511
- return this.version;
712
+ /**
713
+ * True when this picker is access-scoped at all. A false here is the
714
+ * tokenless public picker, which must behave exactly as it always has.
715
+ *
716
+ * Answered from what the HOST asked for, never from live token state. It used
717
+ * to be `!!#provider || !!#token`, which quietly inverted this file's central
718
+ * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
719
+ * the first refusal turned a configured context into an "unconfigured" one,
720
+ * `authorization()` then returned null instead of throwing, and the very next
721
+ * call went out with no bearer — the anonymous Public sale fallback this
722
+ * module exists to prevent. A provider host never saw it, because `#provider`
723
+ * held `configured` true. Found against a live worker in the M9 pass.
724
+ */
725
+ get configured() {
726
+ return __privateGet(this, _configured);
512
727
  }
513
- start() {
514
- if (!this.stopped) return;
515
- this.stopped = false;
516
- void this.connect();
728
+ /** Set once a state arrives that refreshing cannot clear. */
729
+ get unavailable() {
730
+ return __privateGet(this, _terminal);
517
731
  }
518
- /** Stop for good (destroy, or a revocation). Safe to call twice. */
519
- stop() {
520
- this.stopped = true;
521
- this.clearTimers();
522
- const ws = this.ws;
523
- this.ws = null;
524
- if (ws) {
525
- ws.onopen = null;
526
- ws.onmessage = null;
527
- ws.onclose = null;
528
- ws.onerror = null;
529
- try {
530
- ws.close();
531
- } catch {
532
- }
533
- }
732
+ /** True while a usable bearer is held (ignores skew). */
733
+ get hasToken() {
734
+ return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
534
735
  }
535
- /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
536
- restart() {
537
- this.stop();
538
- this.projection = null;
539
- this.version = null;
540
- this.attempt = 0;
541
- this.start();
736
+ /** Epoch ms the current token expires, or 0 when the host didn't say. */
737
+ get expiresAt() {
738
+ return __privateGet(this, _expiresAt);
542
739
  }
543
- // ---- connection -----------------------------------------------------------
544
- async connect() {
545
- if (this.stopped) return;
546
- let protocols = [SEATLAYER_V1];
547
- if (this.opts.mintTicket) {
548
- let minted;
549
- try {
550
- minted = await this.opts.mintTicket();
551
- } catch (err) {
552
- this.reportIfAccessError(err);
553
- this.scheduleReconnect();
554
- return;
555
- }
556
- if (this.stopped) return;
557
- if (minted?.protocols?.length) {
558
- protocols = [...minted.protocols];
559
- if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
560
- } else if (minted?.ticket) {
561
- protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
740
+ /**
741
+ * The `Authorization` header value for a scoped operation.
742
+ *
743
+ * Returns null only when this context is not configured at all (the ordinary
744
+ * anonymous public picker). A configured context either returns a bearer or
745
+ * throws `BuyerAccessUnavailableError` — it never returns null, because a
746
+ * null here would send the request as anonymous Public sale.
747
+ */
748
+ async authorization(reason = "initial") {
749
+ if (!this.configured) return null;
750
+ if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
751
+ const now = Date.now();
752
+ const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
753
+ if (stale) {
754
+ const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
755
+ const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
756
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
757
+ if (!token) {
758
+ throw new BuyerAccessUnavailableError(
759
+ __privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
760
+ );
562
761
  }
762
+ return `Bearer ${token}`;
563
763
  }
564
- const offeredResume = this.version !== null;
565
- if (offeredResume) protocols.push(`sv.${this.version}`);
566
- const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
567
- assertCredentialFreeUrl(url);
568
- let ws;
569
- try {
570
- const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
571
- ws = make(url, protocols);
572
- } catch {
573
- this.scheduleReconnect();
574
- return;
575
- }
576
- this.ws = ws;
577
- ws.onopen = () => {
578
- if (this.ws !== ws) return;
579
- this.attempt = 0;
580
- this.v1 = ws.protocol === SEATLAYER_V1;
581
- if (!this.v1) this.useQueryMarker = true;
582
- this.startKeepalive(ws);
583
- if (offeredResume) {
584
- this.resumeTimer = setTimeout(() => {
585
- this.resumeTimer = null;
586
- void this.opts.sink.resync();
587
- }, RESUME_ANSWER_GRACE_MS);
588
- } else {
589
- void this.opts.sink.resync();
590
- }
591
- };
592
- ws.onmessage = (event) => {
593
- if (this.ws !== ws) return;
594
- let parsed;
595
- try {
596
- parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
597
- } catch {
598
- return;
599
- }
600
- if (!parsed || typeof parsed !== "object") return;
601
- this.handleFrame(parsed);
602
- };
603
- ws.onclose = (event) => {
604
- if (this.ws !== ws) return;
605
- this.ws = null;
606
- this.clearTimers();
607
- if (event?.code === CLOSE_ACCESS_REVOKED) {
608
- this.stopped = true;
609
- this.opts.onAccessUnavailable?.({
610
- reason: "revoked",
611
- code: "access_revoked",
612
- retryable: false
613
- });
614
- return;
615
- }
616
- this.scheduleReconnect();
617
- };
618
- ws.onerror = () => {
619
- try {
620
- ws.close();
621
- } catch {
622
- }
623
- };
764
+ return `Bearer ${__privateGet(this, _token)}`;
624
765
  }
625
- handleFrame(frame) {
626
- const type = typeof frame.type === "string" ? frame.type : "";
627
- if (frame.protocol === 1) this.v1 = true;
628
- if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
629
- if (type === "pong") {
630
- this.clearPongTimer();
631
- return;
632
- }
633
- if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
634
- const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
635
- const closed = Array.isArray(frame.closed) ? frame.closed : [];
636
- const hKey = hidden.join(SEP);
637
- const cKey = closed.join(SEP);
638
- if (hKey !== this.hidden || cKey !== this.closedSections) {
639
- this.hidden = hKey;
640
- this.closedSections = cKey;
641
- this.opts.sink.onSections?.(hidden, closed);
642
- }
643
- }
644
- if (type === "hidden") return;
645
- if (type === "presence") {
646
- this.opts.sink.onPresence?.({
647
- shoppingSessions: Number(frame.shoppingSessions) || 0,
648
- activeHolds: Number(frame.activeHolds) || 0
649
- });
650
- return;
651
- }
652
- if (type === "allocation") {
653
- return;
654
- }
655
- if (type === "snapshot" || !type && frame.seats) {
656
- this.answered();
657
- const next = projectionFromSnapshot(frame);
658
- const changes = diffProjections(this.projection, next);
659
- this.projection = next;
660
- if (changes === null) void this.opts.sink.resync();
661
- else if (changes.length) this.opts.sink.applyStatuses(changes);
662
- return;
663
- }
664
- if (type === "delta" && Array.isArray(frame.changes)) {
665
- this.answered();
666
- const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
667
- if (!changes.length) return;
668
- if (this.projection) applyChanges(this.projection, changes);
669
- this.opts.sink.applyStatuses(changes);
766
+ /**
767
+ * Handle a 401/403 from a scoped call. Returns true when the caller should
768
+ * retry the same request once with the refreshed bearer.
769
+ */
770
+ async handleFailure(status, code) {
771
+ var _a;
772
+ if (!this.configured) return false;
773
+ if (isAccessExpiry(status, code)) {
774
+ __privateSet(this, _token, null);
775
+ __privateSet(this, _expiresAt, 0);
776
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
777
+ (_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
778
+ return !!token;
670
779
  }
780
+ const reason = classifyAccessFailure(status, code);
781
+ if (reason) {
782
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
783
+ return false;
784
+ }
785
+ return false;
671
786
  }
672
- /** The server answered our resume; cancel the fallback resync. */
673
- answered() {
674
- if (!this.resumeTimer) return;
675
- clearTimeout(this.resumeTimer);
676
- this.resumeTimer = null;
677
- }
678
- reportIfAccessError(err) {
679
- const reason = err?.reason;
680
- if (err?.name !== "BuyerAccessUnavailableError") return;
681
- this.stopped = true;
682
- this.opts.onAccessUnavailable?.({
683
- reason: reason ?? "invalid",
684
- code: err.code,
685
- status: err.status,
686
- retryable: reason === "paused"
687
- });
688
- }
689
- // ---- keepalive & backoff --------------------------------------------------
690
- /**
691
- * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
692
- * for minutes is the normal, correct state for a narrowly-scoped buyer on a
693
- * busy event (protocol doc §5), so quiet time never triggers a reconnect.
694
- */
695
- startKeepalive(ws) {
696
- this.pingTimer = setInterval(() => {
697
- if (this.ws !== ws) return;
698
- try {
699
- ws.send(JSON.stringify({ type: "ping" }));
700
- } catch {
701
- return;
702
- }
703
- this.clearPongTimer();
704
- this.pongTimer = setTimeout(() => {
705
- this.pongTimer = null;
706
- try {
707
- ws.close();
708
- } catch {
709
- }
710
- }, PONG_GRACE_MS);
711
- }, PING_INTERVAL_MS);
787
+ /** Host-driven re-acquisition (after the buyer signs in again, say). */
788
+ async refresh(reason = "manual") {
789
+ __privateSet(this, _terminal, null);
790
+ __privateSet(this, _lastFailure, null);
791
+ __privateSet(this, _token, null);
792
+ __privateSet(this, _expiresAt, 0);
793
+ return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
712
794
  }
713
- scheduleReconnect() {
714
- if (this.stopped || this.reconnectTimer) return;
715
- const attempt = Math.min(this.attempt++, 5);
716
- const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
717
- this.reconnectTimer = setTimeout(() => {
718
- this.reconnectTimer = null;
719
- void this.connect();
720
- }, delay);
795
+ /** Drop the bearer. Called on destroy so nothing outlives the widget. */
796
+ clear() {
797
+ __privateSet(this, _token, null);
798
+ __privateSet(this, _expiresAt, 0);
799
+ __privateSet(this, _inflight, null);
721
800
  }
722
- clearPongTimer() {
723
- if (!this.pongTimer) return;
724
- clearTimeout(this.pongTimer);
725
- this.pongTimer = null;
801
+ /** Redaction: the bearer must not survive a stringify or an interpolation. */
802
+ toJSON() {
803
+ return { configured: this.configured, hasToken: this.hasToken };
726
804
  }
727
- clearTimers() {
728
- if (this.pingTimer) clearInterval(this.pingTimer);
729
- this.pingTimer = null;
730
- this.clearPongTimer();
731
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
732
- this.reconnectTimer = null;
733
- if (this.resumeTimer) clearTimeout(this.resumeTimer);
734
- this.resumeTimer = null;
805
+ toString() {
806
+ return "[BuyerAccessContext redacted]";
735
807
  }
736
808
  };
737
- function rendererStatus(wire) {
738
- if (wire === "blocked") return "not_for_sale";
739
- if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
740
- return "free";
741
- }
742
- function createControllerSink(controller, options = {}) {
743
- const idsForLabel = (label) => {
744
- const table = controller.tableSelection(label);
745
- if (table) return table.physicalSeatIds;
746
- const id = controller.idForLabel(label);
747
- return id ? [id] : [];
748
- };
749
- return {
750
- applyStatuses(changes) {
751
- const held = controller.currentHold()?.labels ?? [];
752
- const buckets = {
753
- free: [],
754
- held: [],
755
- booked: [],
756
- not_for_sale: []
757
- };
758
- const flashes = [];
759
- const lost = [];
760
- const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
761
- for (const change of changes) {
762
- const ids = idsForLabel(change.label);
763
- if (!ids.length) continue;
764
- const next = rendererStatus(change.status);
765
- buckets[next].push(...ids);
766
- if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
767
- const color = next === "held" ? "#f4b740" : "#f43f5e";
768
- for (const id of ids) flashes.push({ id, color });
769
- }
770
- if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
771
- lost.push(change.label);
772
- }
773
- }
774
- for (const status of ["free", "held", "booked", "not_for_sale"]) {
775
- if (buckets[status].length) controller.setStatus(buckets[status], status);
776
- }
777
- for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
778
- if (lost.length) {
779
- const ids = lost.flatMap((label) => idsForLabel(label));
780
- if (ids.length) controller.deselect(ids);
781
- const ineligible = changes.some(
782
- (c) => c.status === "blocked" && lost.includes(c.label)
783
- );
784
- options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
809
+ _token = new WeakMap();
810
+ _expiresAt = new WeakMap();
811
+ _provider = new WeakMap();
812
+ _skewMs = new WeakMap();
813
+ _inflight = new WeakMap();
814
+ _terminal = new WeakMap();
815
+ _lastFailure = new WeakMap();
816
+ _onExpired = new WeakMap();
817
+ _onUnavailable = new WeakMap();
818
+ _configured = new WeakMap();
819
+ _BuyerAccessContext_instances = new WeakSet();
820
+ // ---- internals ------------------------------------------------------------
821
+ accept_fn = function(next) {
822
+ if (!next || typeof next.token !== "string" || !next.token) return null;
823
+ __privateSet(this, _token, next.token);
824
+ __privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
825
+ return __privateGet(this, _token);
826
+ };
827
+ /**
828
+ * One provider call at a time. Several operations racing an expiry (chart +
829
+ * objects + a socket ticket) must not mint several sessions — the guide's
830
+ * rotate-on-retry rule would revoke the ones they didn't observe.
831
+ */
832
+ renew_fn = function(reason, code) {
833
+ if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
834
+ const provider = __privateGet(this, _provider);
835
+ if (!provider) {
836
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
837
+ return Promise.resolve(null);
838
+ }
839
+ const run = (async () => {
840
+ try {
841
+ const next = await provider({ reason });
842
+ const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
843
+ if (!token) {
844
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
845
+ return null;
785
846
  }
786
- options.onStatusChange?.();
787
- },
788
- async resync() {
789
- await controller.refresh();
790
- },
791
- /**
792
- * Section availability moved. Statuses are re-pulled so the map repaints.
793
- *
794
- * Known limit: rebuilding the chart when a section is newly HIDDEN (its
795
- * seats are stripped, not greyed) lives inside PickerController's own
796
- * socket handler and has no public entry point, so an access-scoped picker
797
- * repaints statuses but does not restructure the chart until its next
798
- * mount. Closing/opening a section — the common mid-sale move — is a
799
- * status-level change and is handled here in full.
800
- */
801
- onSections(hidden, closed) {
802
- void controller.refresh();
803
- options.onSections?.(hidden, closed);
847
+ return token;
848
+ } catch {
849
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
850
+ return null;
851
+ } finally {
852
+ __privateSet(this, _inflight, null);
804
853
  }
854
+ })();
855
+ __privateSet(this, _inflight, run);
856
+ return run;
857
+ };
858
+ fail_fn = function(reason, code, status) {
859
+ var _a;
860
+ const event = {
861
+ reason,
862
+ code,
863
+ status,
864
+ // Unchanged: `retryable` means "the SAME request may succeed later".
865
+ // `provider_failed` is recoverable but not retryable — the host must fix
866
+ // its mint endpoint first — so the two sets are deliberately different.
867
+ retryable: reason === "paused" || reason === "channel_denied"
805
868
  };
869
+ __privateSet(this, _lastFailure, event);
870
+ if (!RECOVERABLE.has(reason)) {
871
+ __privateSet(this, _terminal, event);
872
+ __privateSet(this, _token, null);
873
+ __privateSet(this, _expiresAt, 0);
874
+ }
875
+ (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
876
+ return event;
877
+ };
878
+ function createBuyerAccessContext(options, hooks = {}) {
879
+ if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
880
+ return new BuyerAccessContext({
881
+ provider: options.buyerAccessTokenProvider,
882
+ token: options.buyerAccessToken,
883
+ ...hooks
884
+ });
806
885
  }
807
886
 
808
887
  // src/seatLayerBrand.ts
@@ -6165,6 +6244,8 @@ import {
6165
6244
  SeatmapRenderer,
6166
6245
  expandChart as expandChart2,
6167
6246
  computeSections,
6247
+ gaAreasOf,
6248
+ gaUnitLabels,
6168
6249
  UNGROUPED_ID
6169
6250
  } from "@seatlayer/core";
6170
6251
 
@@ -9160,13 +9241,39 @@ var SeatManager = class {
9160
9241
  this.labelToId = /* @__PURE__ */ new Map();
9161
9242
  this.labelToSeat = /* @__PURE__ */ new Map();
9162
9243
  this.allIds = [];
9244
+ /**
9245
+ * GA inventory units — real sellable labels the server counts, with NO seat
9246
+ * geometry and therefore no renderer binding. They live here rather than in
9247
+ * `labelToId`/`allIds` so every paint path keeps addressing paintable nodes
9248
+ * only, while the tally denominator finally covers the same universe the
9249
+ * numerator does. Without them a GA sale hit `booked` but not `total`:
9250
+ * Free under-reported by GA capacity and SOLD% could exceed 100%.
9251
+ */
9252
+ this.gaUnitLabelSet = /* @__PURE__ */ new Set();
9163
9253
  this.status = /* @__PURE__ */ new Map();
9254
+ /** Live non-free counters, moved by each delta rather than re-walked. */
9255
+ this.counts = { held: 0, booked: 0, blocked: 0 };
9256
+ /** Bumped whenever the seat model is replaced wholesale (a full snapshot). */
9257
+ this.modelVersion = 0;
9164
9258
  this.currency = "USD";
9165
9259
  this.authoritativeGrossRevenue = 0;
9166
9260
  this.revenueStatus = "loading";
9167
9261
  this.revenueRequest = 0;
9168
- this.revenueRefreshTimer = null;
9169
9262
  this.controlRoomSnapshot = null;
9263
+ /**
9264
+ * The server's own totals, pinned to the client model they were read against.
9265
+ * Display = server baseline + (client now − client then), so the authoritative
9266
+ * numbers land exactly on arrival and deltas still move them between reads.
9267
+ * A wholesale model replacement invalidates the pairing (`model`), and the
9268
+ * client tallies — themselves a fresh authenticated read — take over.
9269
+ */
9270
+ this.serverBaseline = null;
9271
+ /** Latest presence frame, held whether or not a snapshot has landed yet. */
9272
+ this.livePresence = null;
9273
+ /** Latest cumulative booked gross pushed on a delta frame. */
9274
+ this.liveGross = null;
9275
+ /** Coalesces a burst of deltas into one KPI/rail repaint. */
9276
+ this.paintHandle = null;
9170
9277
  this.trendWindowMinutes = 15;
9171
9278
  this.heatEnabled = false;
9172
9279
  this.lastKpiValues = /* @__PURE__ */ new Map();
@@ -9264,12 +9371,7 @@ var SeatManager = class {
9264
9371
  const res = await this.api.chart(this.key);
9265
9372
  this.doc = res.doc;
9266
9373
  this.currency = res.event.currency ?? this.opts.currency ?? this.currency;
9267
- const seats = expandChart2(res.doc);
9268
- for (const s of seats) {
9269
- this.labelToId.set(s.label, s.id);
9270
- this.labelToSeat.set(s.label, s);
9271
- this.allIds.push(s.id);
9272
- }
9374
+ this.buildUnitUniverse(res.doc);
9273
9375
  this.buildRenderer();
9274
9376
  this.buildSectionOptions();
9275
9377
  const [, controlRoom] = await Promise.all([
@@ -9630,7 +9732,10 @@ var SeatManager = class {
9630
9732
  if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
9631
9733
  if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
9632
9734
  if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
9633
- if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
9735
+ if (this.paintHandle !== null && typeof cancelAnimationFrame === "function") {
9736
+ cancelAnimationFrame(this.paintHandle);
9737
+ }
9738
+ this.paintHandle = null;
9634
9739
  this.channels?.destroy();
9635
9740
  this.channels = null;
9636
9741
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
@@ -9713,6 +9818,37 @@ var SeatManager = class {
9713
9818
  }
9714
9819
  this.syncSelection();
9715
9820
  }
9821
+ /**
9822
+ * Build the client's inventory universe from the chart.
9823
+ *
9824
+ * `expandChart` yields SEATS — it has no output for a GA area, whose capacity
9825
+ * is sold as N synthetic unit labels. The server's seat map keys, its deltas
9826
+ * and its `totals` all speak those labels, so a client that only knows seats
9827
+ * counts GA sales in the numerator (every key of the snapshot is written into
9828
+ * `status`) while leaving them out of the denominator. Registering the GA
9829
+ * units here — labels only, never a render binding — is what makes the two
9830
+ * agree.
9831
+ */
9832
+ buildUnitUniverse(doc) {
9833
+ for (const seat of expandChart2(doc)) {
9834
+ this.labelToId.set(seat.label, seat.id);
9835
+ this.labelToSeat.set(seat.label, seat);
9836
+ this.allIds.push(seat.id);
9837
+ }
9838
+ for (const area of gaAreasOf(doc)) {
9839
+ for (const label of gaUnitLabels(area)) {
9840
+ if (!this.labelToId.has(label)) this.gaUnitLabelSet.add(label);
9841
+ }
9842
+ }
9843
+ }
9844
+ /** Every sellable unit the client knows: seats + GA capacity. */
9845
+ unitTotal() {
9846
+ return this.allIds.length + this.gaUnitLabelSet.size;
9847
+ }
9848
+ /** Every label the client models, whether or not it can be painted. */
9849
+ knownLabels() {
9850
+ return [...this.labelToId.keys(), ...this.gaUnitLabelSet];
9851
+ }
9716
9852
  repaintAll() {
9717
9853
  const r = this.renderer;
9718
9854
  if (!r) return;
@@ -9762,7 +9898,7 @@ var SeatManager = class {
9762
9898
  ws.onopen = () => {
9763
9899
  this.attempt = 0;
9764
9900
  this.setLive(true);
9765
- void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
9901
+ void this.resnapshot().then(() => this.refreshControlRoom()).catch((err) => this.opts.onError?.(err));
9766
9902
  void this.refreshAvailability();
9767
9903
  };
9768
9904
  ws.onmessage = (e) => this.onMessage(e);
@@ -9800,15 +9936,18 @@ var SeatManager = class {
9800
9936
  this.updateEffectiveAvailability(m.hidden, m.closed);
9801
9937
  }
9802
9938
  if (m.type === "presence") {
9803
- if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
9804
- this.controlRoomSnapshot = {
9805
- ...this.controlRoomSnapshot,
9806
- presence: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
9939
+ if (typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
9940
+ this.livePresence = {
9941
+ at: Date.now(),
9942
+ value: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
9807
9943
  };
9944
+ if (this.controlRoomSnapshot) {
9945
+ this.controlRoomSnapshot = { ...this.controlRoomSnapshot, presence: this.livePresence.value };
9946
+ this.opts.onControlRoom?.(this.controlRoomSnapshot);
9947
+ }
9808
9948
  this.lastSyncedAt = Date.now();
9809
9949
  this.recomputeTallies();
9810
9950
  this.paintMonitorInsights();
9811
- this.opts.onControlRoom?.(this.controlRoomSnapshot);
9812
9951
  }
9813
9952
  return;
9814
9953
  }
@@ -9822,7 +9961,7 @@ var SeatManager = class {
9822
9961
  const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
9823
9962
  const prev = this.status.get(ch.label) ?? "free";
9824
9963
  if (prev === st) continue;
9825
- this.status.set(ch.label, st);
9964
+ this.setStatusLabel(ch.label, st, prev);
9826
9965
  const id = this.labelToId.get(ch.label);
9827
9966
  if (id) {
9828
9967
  this.renderer?.setStatus([id], toRenderStatus(st));
@@ -9842,10 +9981,38 @@ var SeatManager = class {
9842
9981
  this.lastSyncedAt = Date.now();
9843
9982
  this.afterPaint();
9844
9983
  }
9984
+ if (typeof m.revenue?.gross === "number" && Number.isFinite(m.revenue.gross)) {
9985
+ this.applyLiveGross(m.revenue.gross);
9986
+ }
9845
9987
  this.recomputeTallies();
9846
- if (ids.length) this.scheduleRevenueRefresh();
9847
9988
  }
9848
9989
  }
9990
+ /**
9991
+ * Adopt the cumulative booked gross a delta frame carried.
9992
+ *
9993
+ * Stashed with its arrival time so an in-flight control-room read can decide
9994
+ * whether it is holding the newer number: a frame that landed after the
9995
+ * request started is newer than the response, one that landed before is not.
9996
+ */
9997
+ applyLiveGross(gross) {
9998
+ this.liveGross = { at: Date.now(), value: gross };
9999
+ this.authoritativeGrossRevenue = gross;
10000
+ this.revenueStatus = "current";
10001
+ if (this.controlRoomSnapshot) {
10002
+ this.controlRoomSnapshot = {
10003
+ ...this.controlRoomSnapshot,
10004
+ revenue: { ...this.controlRoomSnapshot.revenue, gross }
10005
+ };
10006
+ this.opts.onControlRoom?.(this.controlRoomSnapshot);
10007
+ }
10008
+ }
10009
+ /** The single writer for a label's status, so the counters never drift. */
10010
+ setStatusLabel(label, next, prev = this.status.get(label) ?? "free") {
10011
+ this.status.set(label, next);
10012
+ if (prev === next) return;
10013
+ if (prev !== "free") this.counts[prev] -= 1;
10014
+ if (next !== "free") this.counts[next] += 1;
10015
+ }
9849
10016
  async resnapshot() {
9850
10017
  try {
9851
10018
  const objs = await this.api.objects(this.key);
@@ -9868,23 +10035,31 @@ var SeatManager = class {
9868
10035
  const next = /* @__PURE__ */ new Map();
9869
10036
  if (fallback !== void 0) {
9870
10037
  const base = known(fallback);
9871
- for (const label of this.labelToId.keys()) next.set(label, base);
10038
+ for (const label of this.knownLabels()) next.set(label, base);
9872
10039
  }
9873
10040
  for (const [label, st] of Object.entries(seats)) {
9874
10041
  next.set(label, known(st));
9875
10042
  }
9876
10043
  this.status = next;
10044
+ this.modelVersion += 1;
10045
+ this.recountAll();
9877
10046
  this.lastSyncedAt = Date.now();
9878
10047
  this.repaintAll();
9879
10048
  this.afterPaint();
9880
10049
  this.recomputeTallies();
9881
10050
  }
10051
+ /** The one O(n) walk left: a wholesale model replacement re-bases the counters. */
10052
+ recountAll() {
10053
+ const counts = { held: 0, booked: 0, blocked: 0 };
10054
+ for (const st of this.status.values()) if (st !== "free") counts[st] += 1;
10055
+ this.counts = counts;
10056
+ }
9882
10057
  /** Optimistic local write shared by organizer actions. Paint and tally once,
9883
10058
  * even when an arena-sized operation changes hundreds of seats. */
9884
10059
  setSeatsLocal(labels, st) {
9885
10060
  const ids = [];
9886
10061
  for (const label of labels) {
9887
- this.status.set(label, st);
10062
+ this.setStatusLabel(label, st);
9888
10063
  const id = this.labelToId.get(label);
9889
10064
  if (id) ids.push(id);
9890
10065
  }
@@ -10010,12 +10185,33 @@ var SeatManager = class {
10010
10185
  this.revenueStatus = "current";
10011
10186
  this.recomputeTallies();
10012
10187
  }
10188
+ /**
10189
+ * Read the server's own control-room projection.
10190
+ *
10191
+ * Called on mount, on every socket (re)connect and after an organizer action —
10192
+ * never on a timer and never per delta frame. Presence and gross that arrived
10193
+ * on the socket AFTER this request started are newer than the response, so
10194
+ * they survive it; anything older defers to the read.
10195
+ */
10013
10196
  async refreshControlRoom() {
10014
10197
  const request = ++this.revenueRequest;
10198
+ const requestedAt = Date.now();
10015
10199
  try {
10016
- const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
10200
+ const fetched = await this.api.controlRoom(this.key, this.trendWindowMinutes);
10201
+ let snapshot = fetched;
10017
10202
  if (request === this.revenueRequest) {
10203
+ if (this.livePresence && this.livePresence.at >= requestedAt) {
10204
+ snapshot = { ...snapshot, presence: this.livePresence.value };
10205
+ } else {
10206
+ this.livePresence = null;
10207
+ }
10208
+ if (this.liveGross && this.liveGross.at >= requestedAt) {
10209
+ snapshot = { ...snapshot, revenue: { ...snapshot.revenue, gross: this.liveGross.value } };
10210
+ } else {
10211
+ this.liveGross = null;
10212
+ }
10018
10213
  this.controlRoomSnapshot = snapshot;
10214
+ this.rebaseServerTotals(snapshot);
10019
10215
  this.lastSyncedAt = Date.now();
10020
10216
  this.authoritativeGrossRevenue = snapshot.revenue.gross;
10021
10217
  this.currency = snapshot.currency;
@@ -10034,37 +10230,80 @@ var SeatManager = class {
10034
10230
  throw err;
10035
10231
  }
10036
10232
  }
10037
- scheduleRevenueRefresh(delay = 140) {
10038
- this.revenueStatus = "stale";
10039
- this.recomputeTallies();
10040
- if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
10041
- this.revenueRefreshTimer = setTimeout(() => {
10042
- this.revenueRefreshTimer = null;
10043
- void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
10044
- }, delay);
10233
+ /** Pin the server's totals to the client model they were read against. */
10234
+ rebaseServerTotals(snapshot) {
10235
+ const totals = snapshot.totals;
10236
+ if (!totals || ["free", "held", "booked", "blocked"].some(
10237
+ (key) => !Number.isFinite(totals[key])
10238
+ )) {
10239
+ this.serverBaseline = null;
10240
+ return;
10241
+ }
10242
+ this.serverBaseline = {
10243
+ model: this.modelVersion,
10244
+ server: { free: totals.free, held: totals.held, booked: totals.booked, blocked: totals.blocked },
10245
+ client: this.clientTallies()
10246
+ };
10045
10247
  }
10046
- recomputeTallies() {
10248
+ /** What the client's own model says — GA units included since `render()`. */
10249
+ clientTallies() {
10250
+ const { held, booked, blocked } = this.counts;
10251
+ return { held, booked, blocked, free: Math.max(0, this.unitTotal() - held - booked - blocked) };
10252
+ }
10253
+ /**
10254
+ * The numbers the KPI bar and rail render.
10255
+ *
10256
+ * The server is the authority: its totals land exactly as read, and the
10257
+ * delta-driven client model carries them forward until the next read. Before
10258
+ * the first snapshot — and after a wholesale model replacement invalidates the
10259
+ * pairing — the client model stands alone.
10260
+ */
10261
+ buildTallies() {
10262
+ const client = this.clientTallies();
10263
+ const baseline = this.serverBaseline?.model === this.modelVersion ? this.serverBaseline : null;
10264
+ const of = (key) => baseline ? Math.max(0, baseline.server[key] + (client[key] - baseline.client[key])) : client[key];
10265
+ const seatTotal = this.controlRoomSnapshot?.event?.seatTotal;
10047
10266
  const t3 = {
10048
- free: 0,
10049
- held: 0,
10050
- booked: 0,
10051
- blocked: 0,
10052
- total: this.allIds.length,
10267
+ free: of("free"),
10268
+ held: of("held"),
10269
+ booked: of("booked"),
10270
+ blocked: of("blocked"),
10271
+ total: Number.isFinite(seatTotal) ? seatTotal : this.unitTotal(),
10053
10272
  capacityPct: 0,
10054
10273
  sellThroughPct: 0,
10055
10274
  grossRevenue: this.authoritativeGrossRevenue,
10056
10275
  revenueStatus: this.revenueStatus,
10057
10276
  currency: this.currency
10058
10277
  };
10059
- let nonFree = 0;
10060
- for (const st of this.status.values()) {
10061
- t3[st] += 1;
10062
- if (st !== "free") nonFree += 1;
10063
- }
10064
- t3.free = Math.max(0, t3.total - nonFree);
10065
10278
  t3.capacityPct = t3.total ? Math.round(t3.booked / t3.total * 100) : 0;
10066
10279
  const sellable = t3.total - t3.blocked;
10067
10280
  t3.sellThroughPct = sellable > 0 ? Math.round(t3.booked / sellable * 100) : 0;
10281
+ return t3;
10282
+ }
10283
+ /**
10284
+ * Queue one KPI/rail repaint for this burst of changes.
10285
+ *
10286
+ * A delta frame can carry hundreds of seats and `paintKpis` rebuilds eight
10287
+ * nodes from scratch, so painting per change is what made an arena-sized
10288
+ * frame expensive. Coalescing on a frame keeps the burst to a single rebuild;
10289
+ * without `requestAnimationFrame` (SSR, an older test env) it paints inline
10290
+ * rather than dropping the update.
10291
+ */
10292
+ recomputeTallies() {
10293
+ if (this.closed) return;
10294
+ if (typeof requestAnimationFrame !== "function") {
10295
+ this.flushTallies();
10296
+ return;
10297
+ }
10298
+ if (this.paintHandle !== null) return;
10299
+ this.paintHandle = requestAnimationFrame(() => {
10300
+ this.paintHandle = null;
10301
+ this.flushTallies();
10302
+ });
10303
+ }
10304
+ flushTallies() {
10305
+ if (this.closed) return;
10306
+ const t3 = this.buildTallies();
10068
10307
  this.paintKpis(t3);
10069
10308
  if (this.mode === "view") {
10070
10309
  this.paintLegend(t3);
@@ -10351,16 +10590,16 @@ var SeatManager = class {
10351
10590
  paintKpis(t3) {
10352
10591
  if (!this.els.kpis) return;
10353
10592
  const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
10354
- const presence = this.controlRoomSnapshot?.presence;
10593
+ const presence = this.presenceCounts();
10355
10594
  const items = [
10356
- { key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
10357
- { key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
10358
- { key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
10359
- { key: "active-holds", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
10360
- { key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
10361
- { key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
10362
- { key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold" },
10363
- { key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales" }
10595
+ { key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b", title: "Seats booked" },
10596
+ { key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740", title: "Seats held in a checkout right now" },
10597
+ { key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff", title: "Seats on sale and unsold" },
10598
+ { key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac", title: "Seats withheld from sale" },
10599
+ { key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers", title: "People on the map right now" },
10600
+ { key: "carts", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Carts", title: "Checkouts holding seats right now \u2014 sessions, not seats" },
10601
+ { key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold", title: "Sold seats as a share of the whole event" },
10602
+ { key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales", title: "Exact booked gross" }
10364
10603
  ];
10365
10604
  let hasChanges = false;
10366
10605
  this.els.kpis.innerHTML = items.map((item) => {
@@ -10376,7 +10615,7 @@ var SeatManager = class {
10376
10615
  }
10377
10616
  if (item.raw != null) this.lastKpiValues.set(item.key, item.raw);
10378
10617
  const activeDelta = this.activeKpiDeltas.get(item.key);
10379
- return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}">
10618
+ return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}" title="${esc2(item.title)}">
10380
10619
  <b>${item.dot ? `<span class="dot" style="background:${item.dot}"></span>` : ""}${item.n}</b><span>${item.l}</span>
10381
10620
  ${activeDelta ? `<span class="slm-kpidelta${activeDelta.down ? " down" : ""}">${activeDelta.text}</span>` : ""}
10382
10621
  </div>`;
@@ -10434,15 +10673,21 @@ var SeatManager = class {
10434
10673
  this.paintMomentumHelp();
10435
10674
  this.paintFeed();
10436
10675
  }
10676
+ /** Live presence wins over the snapshot's copy — it is the fresher channel,
10677
+ * and it exists from the first frame rather than the first fetch. */
10678
+ presenceCounts() {
10679
+ return this.livePresence?.value ?? this.controlRoomSnapshot?.presence ?? null;
10680
+ }
10437
10681
  paintMonitorInsights() {
10438
10682
  if (this.mode !== "view") return;
10439
10683
  const snapshot = this.controlRoomSnapshot;
10440
10684
  if (this.els.presence) {
10441
10685
  const connected = this.root?.classList.contains("live");
10442
10686
  const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
10687
+ const presence = this.presenceCounts();
10443
10688
  this.els.presence.innerHTML = `
10444
- <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyer sessions</span></div>
10445
- <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Active holds</span></div>
10689
+ <div class="slm-healthitem" title="People on the map right now"><b>${presence ? presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyers</span></div>
10690
+ <div class="slm-healthitem" title="Checkouts holding seats right now \u2014 sessions, not seats"><b>${presence ? presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Carts</span></div>
10446
10691
  <div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
10447
10692
  <div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
10448
10693
  }
@@ -11053,7 +11298,7 @@ var SeatManager = class {
11053
11298
  const activity = action === "block" ? this.pushActivity(labels, "blocked", "blocked") : action === "unblock" || action === "unblockAll" ? this.pushActivity(labels, "unblocked", "free") : action === "cancelBooking" ? this.pushActivity(labels, "cancelled", "free") : null;
11054
11299
  if (activity) this.paintSpatialActivity(activity);
11055
11300
  }
11056
- if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
11301
+ if (action !== "setHoldTtl") void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
11057
11302
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
11058
11303
  }
11059
11304
  toastOk(msg) {