@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.cjs CHANGED
@@ -436,796 +436,875 @@ module.exports = __toCommonJS(index_exports);
436
436
  // src/SeatingChart.ts
437
437
  var import_core = require("@seatlayer/core");
438
438
 
439
- // src/api.ts
440
- var ApiError = class extends Error {
441
- constructor(status, message, code, conflicts, reason) {
442
- super(message);
443
- this.name = "ApiError";
444
- this.status = status;
445
- this.code = code;
446
- this.conflicts = conflicts;
447
- this.reason = reason;
448
- }
449
- };
450
- var OBJECT_UNAVAILABLE_CODES = {
451
- seat_conflict: "taken",
452
- conflict: "taken",
453
- channel_assignment_conflict: "ineligible",
454
- allocation_exhausted: "exhausted"
455
- };
456
- var PubApi = class {
457
- constructor(base, options = {}) {
458
- this.base = base;
459
- this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
460
- this.access = options.access;
461
- this.onObjectUnavailable = options.onObjectUnavailable;
462
- }
463
- /** True when this client is bound to a buyer access session. */
464
- get accessScoped() {
465
- return !!this.access?.configured;
466
- }
467
- async request(path, init = {}, retried = false) {
468
- const method = init.method ?? "GET";
469
- const headers = {};
470
- let body;
471
- if (init.body !== void 0) {
472
- headers["Content-Type"] = "application/json";
473
- body = JSON.stringify(init.body);
474
- }
475
- const authorization = await this.access?.authorization(retried ? "unauthorized" : "initial");
476
- if (authorization) headers.Authorization = authorization;
477
- const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
478
- const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
479
- const data = isJson ? await res.json().catch(() => null) : null;
480
- if (!res.ok) {
481
- const err = data;
482
- const code = err?.code ?? err?.error;
483
- if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
484
- const refreshed = await this.access.handleFailure(res.status, code);
485
- if (refreshed && !retried) return this.request(path, init, true);
486
- }
487
- if (res.status === 409) {
488
- const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
489
- const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
490
- if (reason) this.onObjectUnavailable?.({ labels, reason, code });
491
- }
492
- throw new ApiError(
493
- res.status,
494
- err?.error ?? `request_failed_${res.status}`,
495
- code,
496
- err?.conflicts,
497
- err?.reason
498
- );
439
+ // src/buyerRealtime.ts
440
+ var SEATLAYER_V1 = "seatlayer.v1";
441
+ var SEP = "\0";
442
+ var CLOSE_ACCESS_REVOKED = 4401;
443
+ var MAX_BACKOFF_MS = 15e3;
444
+ var PING_INTERVAL_MS = 25e3;
445
+ var PONG_GRACE_MS = 1e4;
446
+ var RESUME_ANSWER_GRACE_MS = 5e3;
447
+ function projectionFromSnapshot(frame) {
448
+ const fallback = typeof frame.default === "string" ? frame.default : "free";
449
+ const exceptions = {};
450
+ if (frame.seats && typeof frame.seats === "object") {
451
+ for (const [label, status] of Object.entries(frame.seats)) {
452
+ if (typeof status === "string" && status !== fallback) exceptions[label] = status;
499
453
  }
500
- return data;
501
- }
502
- chart(key) {
503
- return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
504
- }
505
- objects(key) {
506
- return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
507
454
  }
508
- hold(key, selections, ttlMs, replaceHoldId) {
509
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
510
- method: "POST",
511
- body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
512
- labels: selections.map((s) => s.label)
513
- });
455
+ return { default: fallback, exceptions };
456
+ }
457
+ function diffProjections(prev, next) {
458
+ if (!prev || prev.default !== next.default) return null;
459
+ const changes = [];
460
+ for (const [label, status] of Object.entries(next.exceptions)) {
461
+ if (prev.exceptions[label] !== status) changes.push({ label, status });
514
462
  }
515
- // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
516
- // window both are part of the route contract, and dropping either here made
517
- // the SDK quietly pick venue-wide and hold for the server default instead.
518
- bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
519
- return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
520
- method: "POST",
521
- body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
522
- });
463
+ for (const label of Object.keys(prev.exceptions)) {
464
+ if (!(label in next.exceptions)) changes.push({ label, status: next.default });
523
465
  }
524
- resume(key, holdId) {
525
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
526
- method: "POST",
527
- body: { holdId }
528
- });
466
+ return changes;
467
+ }
468
+ function applyChanges(projection, changes) {
469
+ for (const change of changes) {
470
+ if (change.status === projection.default) delete projection.exceptions[change.label];
471
+ else projection.exceptions[change.label] = change.status;
529
472
  }
530
- release(key, labels, holdId) {
531
- return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
532
- method: "POST",
533
- body: { labels, holdId }
534
- });
473
+ }
474
+ function assertCredentialFreeUrl(url) {
475
+ if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
476
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
535
477
  }
536
- /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
537
- * (reason: expired | extend_limit | not_found | not_active) if it can't. */
538
- extend(key, holdId, ttlMs) {
539
- return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
540
- method: "POST",
541
- body: { holdId, ...ttlMs ? { ttlMs } : {} }
542
- });
478
+ if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
479
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
543
480
  }
544
- /**
545
- * Which gateways this event can actually take money through — the question
546
- * `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
547
- * the answer is never discovered by failing a payment.
548
- *
549
- * Anonymous, and it discloses no account, key, mode or currency for a gateway
550
- * that did not match.
551
- */
552
- paymentOptions(key) {
553
- return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
481
+ }
482
+ var BuyerRealtimeClient = class {
483
+ constructor(options) {
484
+ this.ws = null;
485
+ this.stopped = true;
486
+ this.attempt = 0;
487
+ this.reconnectTimer = null;
488
+ this.pingTimer = null;
489
+ this.pongTimer = null;
490
+ this.resumeTimer = null;
491
+ /** Our model of this scope's projection. Null until the first snapshot. */
492
+ this.projection = null;
493
+ /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
494
+ this.version = null;
495
+ /** True once the 101 echoed `seatlayer.v1`. */
496
+ this.v1 = false;
497
+ /** Set when we offered v1 and the handshake came back without it — a proxy
498
+ * most likely stripped the header, so the next attempt selects the v1 frame
499
+ * format with the `?pv=1` marker instead (protocol doc §1). The marker
500
+ * selects a format and can never carry a credential or widen a scope. */
501
+ this.useQueryMarker = false;
502
+ this.hidden = null;
503
+ this.closedSections = null;
504
+ this.opts = options;
505
+ assertCredentialFreeUrl(options.url);
554
506
  }
555
- /**
556
- * Turn a live hold into an order and start a payment.
557
- *
558
- * The amount is NOT sent: the server recomputes it from the hold's own items,
559
- * which is the only reason a browser cannot alter what it pays. Nor is the
560
- * PROVIDER — the event row decides which gateway charges, and a `provider` in
561
- * the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
562
- * it is the shape that cannot disagree.
563
- */
564
- startCheckout(key, input) {
565
- return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
566
- method: "POST",
567
- body: input
568
- });
507
+ /** Negotiated protocol, for tests and diagnostics. */
508
+ get protocol() {
509
+ return this.ws ? this.v1 ? "v1" : "legacy" : null;
569
510
  }
570
- /**
571
- * Poll an order while its gateway webhook lands. The order id is an
572
- * unguessable token the buyer already holds, so it acts as the capability —
573
- * which is also why a buyer returning from a gateway page can be told what
574
- * happened with nothing but the id in the return URL.
575
- */
576
- orderStatus(orderId) {
577
- return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
511
+ get snapshotVersion() {
512
+ return this.version;
578
513
  }
579
- /**
580
- * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
581
- * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
582
- * already apply; the socket then carries only the short-lived ticket, in its
583
- * subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
584
- */
585
- subscribeTicket(key) {
586
- return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
587
- method: "POST",
588
- body: {}
589
- });
514
+ start() {
515
+ if (!this.stopped) return;
516
+ this.stopped = false;
517
+ void this.connect();
590
518
  }
591
- /**
592
- * The subscribe URL. Never carries a credential — not the bearer, not the
593
- * ticket. Query parameters are diagnostics only.
594
- */
595
- subscribeUrl(key) {
596
- const wsBase = this.base.replace(/^http/, "ws");
597
- const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
598
- return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
519
+ /** Stop for good (destroy, or a revocation). Safe to call twice. */
520
+ stop() {
521
+ this.stopped = true;
522
+ this.clearTimers();
523
+ const ws = this.ws;
524
+ this.ws = null;
525
+ if (ws) {
526
+ ws.onopen = null;
527
+ ws.onmessage = null;
528
+ ws.onclose = null;
529
+ ws.onerror = null;
530
+ try {
531
+ ws.close();
532
+ } catch {
533
+ }
534
+ }
599
535
  }
600
- /**
601
- * What PickerController opens its own socket with.
602
- *
603
- * Empty for an access-scoped client: a private scope authenticates with a
604
- * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
605
- * BuyerRealtimeClient owns that socket instead and the controller skips its
606
- * own (an empty URL is its documented "no live feed" contract). A tokenless
607
- * public client returns exactly the URL it always has, so nothing about the
608
- * public picker's realtime path changes.
609
- */
610
- socketUrl(key) {
611
- return this.accessScoped ? "" : this.subscribeUrl(key);
536
+ /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
537
+ restart() {
538
+ this.stop();
539
+ this.projection = null;
540
+ this.version = null;
541
+ this.attempt = 0;
542
+ this.start();
612
543
  }
613
- };
614
-
615
- // src/buyerAccess.ts
616
- var BuyerAccessUnavailableError = class extends Error {
617
- constructor(event) {
618
- super(`buyer_access_unavailable:${event.reason}`);
619
- this.name = "BuyerAccessUnavailableError";
620
- this.reason = event.reason;
621
- this.code = event.code;
622
- this.status = event.status;
544
+ // ---- connection -----------------------------------------------------------
545
+ async connect() {
546
+ if (this.stopped) return;
547
+ let protocols = [SEATLAYER_V1];
548
+ if (this.opts.mintTicket) {
549
+ let minted;
550
+ try {
551
+ minted = await this.opts.mintTicket();
552
+ } catch (err) {
553
+ this.reportIfAccessError(err);
554
+ this.scheduleReconnect();
555
+ return;
556
+ }
557
+ if (this.stopped) return;
558
+ if (minted?.protocols?.length) {
559
+ protocols = [...minted.protocols];
560
+ if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
561
+ } else if (minted?.ticket) {
562
+ protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
563
+ }
564
+ }
565
+ const offeredResume = this.version !== null;
566
+ if (offeredResume) protocols.push(`sv.${this.version}`);
567
+ const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
568
+ assertCredentialFreeUrl(url);
569
+ let ws;
570
+ try {
571
+ const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
572
+ ws = make(url, protocols);
573
+ } catch {
574
+ this.scheduleReconnect();
575
+ return;
576
+ }
577
+ this.ws = ws;
578
+ ws.onopen = () => {
579
+ if (this.ws !== ws) return;
580
+ this.attempt = 0;
581
+ this.v1 = ws.protocol === SEATLAYER_V1;
582
+ if (!this.v1) this.useQueryMarker = true;
583
+ this.startKeepalive(ws);
584
+ if (offeredResume) {
585
+ this.resumeTimer = setTimeout(() => {
586
+ this.resumeTimer = null;
587
+ void this.opts.sink.resync();
588
+ }, RESUME_ANSWER_GRACE_MS);
589
+ } else {
590
+ void this.opts.sink.resync();
591
+ }
592
+ };
593
+ ws.onmessage = (event) => {
594
+ if (this.ws !== ws) return;
595
+ let parsed;
596
+ try {
597
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
598
+ } catch {
599
+ return;
600
+ }
601
+ if (!parsed || typeof parsed !== "object") return;
602
+ this.handleFrame(parsed);
603
+ };
604
+ ws.onclose = (event) => {
605
+ if (this.ws !== ws) return;
606
+ this.ws = null;
607
+ this.clearTimers();
608
+ if (event?.code === CLOSE_ACCESS_REVOKED) {
609
+ this.stopped = true;
610
+ this.opts.onAccessUnavailable?.({
611
+ reason: "revoked",
612
+ code: "access_revoked",
613
+ retryable: false
614
+ });
615
+ return;
616
+ }
617
+ this.scheduleReconnect();
618
+ };
619
+ ws.onerror = () => {
620
+ try {
621
+ ws.close();
622
+ } catch {
623
+ }
624
+ };
623
625
  }
624
- };
625
- var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
626
- var RECOVERABLE = /* @__PURE__ */ new Set([
627
- "paused",
628
- "provider_failed",
629
- "channel_denied"
630
- ]);
631
- function classifyAccessFailure(status, code) {
632
- switch (code) {
633
- case "buyer_access_invalid":
634
- return "invalid";
635
- case "buyer_access_revoked":
636
- return "revoked";
637
- case "buyer_access_origin_mismatch":
638
- return "origin_mismatch";
639
- case "buyer_access_event_mismatch":
640
- return "event_mismatch";
641
- case "buyer_access_mode_mismatch":
642
- return "mode_mismatch";
643
- case "channel_access_denied":
644
- return "channel_denied";
645
- case "channel_paused":
646
- return "paused";
647
- case "invalid_channel_scope":
648
- return "invalid_scope";
649
- default:
650
- break;
626
+ handleFrame(frame) {
627
+ const type = typeof frame.type === "string" ? frame.type : "";
628
+ if (frame.protocol === 1) this.v1 = true;
629
+ if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
630
+ if (type === "pong") {
631
+ this.clearPongTimer();
632
+ return;
633
+ }
634
+ if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
635
+ const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
636
+ const closed = Array.isArray(frame.closed) ? frame.closed : [];
637
+ const hKey = hidden.join(SEP);
638
+ const cKey = closed.join(SEP);
639
+ if (hKey !== this.hidden || cKey !== this.closedSections) {
640
+ this.hidden = hKey;
641
+ this.closedSections = cKey;
642
+ this.opts.sink.onSections?.(hidden, closed);
643
+ }
644
+ }
645
+ if (type === "hidden") return;
646
+ if (type === "presence") {
647
+ this.opts.sink.onPresence?.({
648
+ shoppingSessions: Number(frame.shoppingSessions) || 0,
649
+ activeHolds: Number(frame.activeHolds) || 0
650
+ });
651
+ return;
652
+ }
653
+ if (type === "allocation") {
654
+ return;
655
+ }
656
+ if (type === "snapshot" || !type && frame.seats) {
657
+ this.answered();
658
+ const next = projectionFromSnapshot(frame);
659
+ const changes = diffProjections(this.projection, next);
660
+ this.projection = next;
661
+ if (changes === null) void this.opts.sink.resync();
662
+ else if (changes.length) this.opts.sink.applyStatuses(changes);
663
+ return;
664
+ }
665
+ if (type === "delta" && Array.isArray(frame.changes)) {
666
+ this.answered();
667
+ const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
668
+ if (!changes.length) return;
669
+ if (this.projection) applyChanges(this.projection, changes);
670
+ this.opts.sink.applyStatuses(changes);
671
+ }
651
672
  }
652
- if (status === 401) return "invalid";
653
- return null;
673
+ /** The server answered our resume; cancel the fallback resync. */
674
+ answered() {
675
+ if (!this.resumeTimer) return;
676
+ clearTimeout(this.resumeTimer);
677
+ this.resumeTimer = null;
678
+ }
679
+ reportIfAccessError(err) {
680
+ const reason = err?.reason;
681
+ if (err?.name !== "BuyerAccessUnavailableError") return;
682
+ this.stopped = true;
683
+ this.opts.onAccessUnavailable?.({
684
+ reason: reason ?? "invalid",
685
+ code: err.code,
686
+ status: err.status,
687
+ retryable: reason === "paused"
688
+ });
689
+ }
690
+ // ---- keepalive & backoff --------------------------------------------------
691
+ /**
692
+ * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
693
+ * for minutes is the normal, correct state for a narrowly-scoped buyer on a
694
+ * busy event (protocol doc §5), so quiet time never triggers a reconnect.
695
+ */
696
+ startKeepalive(ws) {
697
+ this.pingTimer = setInterval(() => {
698
+ if (this.ws !== ws) return;
699
+ try {
700
+ ws.send(JSON.stringify({ type: "ping" }));
701
+ } catch {
702
+ return;
703
+ }
704
+ this.clearPongTimer();
705
+ this.pongTimer = setTimeout(() => {
706
+ this.pongTimer = null;
707
+ try {
708
+ ws.close();
709
+ } catch {
710
+ }
711
+ }, PONG_GRACE_MS);
712
+ }, PING_INTERVAL_MS);
713
+ }
714
+ /**
715
+ * FULL jitter, not plain exponential backoff.
716
+ *
717
+ * A deterministic `2**attempt` schedule makes every browser that lost the same
718
+ * socket — a worker redeploy, a DO eviction, a flaky edge PoP — come back in
719
+ * the same millisecond, and an on-sale crowd reconnecting in lockstep is the
720
+ * thing that turns one blip into a self-sustaining thundering herd. Full
721
+ * jitter (`random() * ceiling`) spreads the same crowd across the whole
722
+ * window; the ceiling still doubles, so a persistent outage still backs off.
723
+ *
724
+ * `Math.random` is correct here: this is client code choosing a delay, not a
725
+ * Workflow step that has to replay deterministically.
726
+ */
727
+ scheduleReconnect() {
728
+ if (this.stopped || this.reconnectTimer) return;
729
+ const attempt = Math.min(this.attempt++, 5);
730
+ const ceiling = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
731
+ const delay = Math.random() * ceiling;
732
+ this.reconnectTimer = setTimeout(() => {
733
+ this.reconnectTimer = null;
734
+ void this.connect();
735
+ }, delay);
736
+ }
737
+ clearPongTimer() {
738
+ if (!this.pongTimer) return;
739
+ clearTimeout(this.pongTimer);
740
+ this.pongTimer = null;
741
+ }
742
+ clearTimers() {
743
+ if (this.pingTimer) clearInterval(this.pingTimer);
744
+ this.pingTimer = null;
745
+ this.clearPongTimer();
746
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
747
+ this.reconnectTimer = null;
748
+ if (this.resumeTimer) clearTimeout(this.resumeTimer);
749
+ this.resumeTimer = null;
750
+ }
751
+ };
752
+ function rendererStatus(wire) {
753
+ if (wire === "blocked") return "not_for_sale";
754
+ if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
755
+ return "free";
654
756
  }
655
- function isAccessExpiry(status, code) {
656
- return status === 401 && !!code && EXPIRED_CODES.has(code);
757
+ function createControllerSink(controller, options = {}) {
758
+ const idsForLabel = (label) => {
759
+ const table = controller.tableSelection(label);
760
+ if (table) return table.physicalSeatIds;
761
+ const id = controller.idForLabel(label);
762
+ return id ? [id] : [];
763
+ };
764
+ return {
765
+ applyStatuses(changes) {
766
+ const held = controller.currentHold()?.labels ?? [];
767
+ const buckets = {
768
+ free: [],
769
+ held: [],
770
+ booked: [],
771
+ not_for_sale: []
772
+ };
773
+ const flashes = [];
774
+ const lost = [];
775
+ const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
776
+ for (const change of changes) {
777
+ const ids = idsForLabel(change.label);
778
+ if (!ids.length) continue;
779
+ const next = rendererStatus(change.status);
780
+ buckets[next].push(...ids);
781
+ if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
782
+ const color = next === "held" ? "#f4b740" : "#f43f5e";
783
+ for (const id of ids) flashes.push({ id, color });
784
+ }
785
+ if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
786
+ lost.push(change.label);
787
+ }
788
+ }
789
+ for (const status of ["free", "held", "booked", "not_for_sale"]) {
790
+ if (buckets[status].length) controller.setStatus(buckets[status], status);
791
+ }
792
+ for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
793
+ if (lost.length) {
794
+ const ids = lost.flatMap((label) => idsForLabel(label));
795
+ if (ids.length) controller.deselect(ids);
796
+ const ineligible = changes.some(
797
+ (c) => c.status === "blocked" && lost.includes(c.label)
798
+ );
799
+ options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
800
+ }
801
+ options.onStatusChange?.();
802
+ },
803
+ async resync() {
804
+ await controller.refresh();
805
+ },
806
+ /**
807
+ * Section availability moved. Statuses are re-pulled so the map repaints.
808
+ *
809
+ * Known limit: rebuilding the chart when a section is newly HIDDEN (its
810
+ * seats are stripped, not greyed) lives inside PickerController's own
811
+ * socket handler and has no public entry point, so an access-scoped picker
812
+ * repaints statuses but does not restructure the chart until its next
813
+ * mount. Closing/opening a section — the common mid-sale move — is a
814
+ * status-level change and is handled here in full.
815
+ */
816
+ onSections(hidden, closed) {
817
+ void controller.refresh();
818
+ options.onSections?.(hidden, closed);
819
+ }
820
+ };
657
821
  }
658
- var DEFAULT_SKEW_MS = 3e4;
659
- var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
660
- var BuyerAccessContext = class {
661
- constructor(options) {
662
- __privateAdd(this, _BuyerAccessContext_instances);
663
- /** Private field: not enumerable, not spreadable, not serializable. */
664
- __privateAdd(this, _token, null);
665
- __privateAdd(this, _expiresAt, 0);
666
- __privateAdd(this, _provider);
667
- __privateAdd(this, _skewMs);
668
- __privateAdd(this, _inflight, null);
669
- __privateAdd(this, _terminal, null);
670
- /** The most recent failure, terminal or not — so one cause reports once. */
671
- __privateAdd(this, _lastFailure, null);
672
- __privateAdd(this, _onExpired);
673
- __privateAdd(this, _onUnavailable);
674
- /** Decided once, at construction. See the `configured` getter. */
675
- __privateAdd(this, _configured, false);
676
- __privateSet(this, _provider, options.provider);
677
- __privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
678
- __privateSet(this, _onExpired, options.onExpired);
679
- __privateSet(this, _onUnavailable, options.onUnavailable);
680
- if (options.token) {
681
- const seed = typeof options.token === "string" ? { token: options.token } : options.token;
682
- __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
822
+
823
+ // src/api.ts
824
+ var ApiError = class extends Error {
825
+ constructor(status, message, code, conflicts, reason, retryAfterS) {
826
+ super(message);
827
+ this.name = "ApiError";
828
+ this.status = status;
829
+ this.code = code;
830
+ this.conflicts = conflicts;
831
+ this.reason = reason;
832
+ this.retryAfterS = retryAfterS;
833
+ }
834
+ };
835
+ var MAX_RATE_LIMIT_WAIT_S = 10;
836
+ var DEFAULT_RATE_LIMIT_WAIT_S = 1;
837
+ function parseRetryAfter(header, bodyValue) {
838
+ const raw = (header ?? "").trim();
839
+ if (raw) {
840
+ const seconds = Number(raw);
841
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
842
+ const at = Date.parse(raw);
843
+ if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - Date.now()) / 1e3));
844
+ }
845
+ if (typeof bodyValue === "number" && Number.isFinite(bodyValue) && bodyValue >= 0) {
846
+ return Math.ceil(bodyValue);
847
+ }
848
+ return void 0;
849
+ }
850
+ var OBJECT_UNAVAILABLE_CODES = {
851
+ seat_conflict: "taken",
852
+ conflict: "taken",
853
+ channel_assignment_conflict: "ineligible",
854
+ allocation_exhausted: "exhausted"
855
+ };
856
+ var PubApi = class {
857
+ constructor(base, options = {}) {
858
+ this.base = base;
859
+ this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
860
+ this.access = options.access;
861
+ this.onObjectUnavailable = options.onObjectUnavailable;
862
+ }
863
+ /** True when this client is bound to a buyer access session. */
864
+ get accessScoped() {
865
+ return !!this.access?.configured;
866
+ }
867
+ async request(path, init = {}, retried = {}) {
868
+ const method = init.method ?? "GET";
869
+ const headers = {};
870
+ let body;
871
+ if (init.body !== void 0) {
872
+ headers["Content-Type"] = "application/json";
873
+ body = JSON.stringify(init.body);
683
874
  }
684
- __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
875
+ const authorization = await this.access?.authorization(retried.auth ? "unauthorized" : "initial");
876
+ if (authorization) headers.Authorization = authorization;
877
+ const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
878
+ const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
879
+ const data = isJson ? await res.json().catch(() => null) : null;
880
+ if (!res.ok) {
881
+ const err = data;
882
+ const code = err?.code ?? err?.error;
883
+ if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
884
+ const refreshed = await this.access.handleFailure(res.status, code);
885
+ if (refreshed && !retried.auth) return this.request(path, init, { ...retried, auth: true });
886
+ }
887
+ if (res.status === 409) {
888
+ const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
889
+ const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
890
+ if (reason) this.onObjectUnavailable?.({ labels, reason, code });
891
+ }
892
+ let retryAfterS;
893
+ if (res.status === 429) {
894
+ retryAfterS = parseRetryAfter(res.headers.get("Retry-After"), err?.retryAfterSeconds) ?? DEFAULT_RATE_LIMIT_WAIT_S;
895
+ if (method === "GET" && !retried.rateLimit && retryAfterS <= MAX_RATE_LIMIT_WAIT_S) {
896
+ await new Promise((resolve) => setTimeout(resolve, retryAfterS * 1e3));
897
+ return this.request(path, init, { ...retried, rateLimit: true });
898
+ }
899
+ }
900
+ throw new ApiError(
901
+ res.status,
902
+ err?.error ?? `request_failed_${res.status}`,
903
+ code,
904
+ err?.conflicts,
905
+ err?.reason,
906
+ retryAfterS
907
+ );
908
+ }
909
+ return data;
910
+ }
911
+ chart(key) {
912
+ return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
913
+ }
914
+ objects(key) {
915
+ return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
916
+ }
917
+ hold(key, selections, ttlMs, replaceHoldId) {
918
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
919
+ method: "POST",
920
+ body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
921
+ labels: selections.map((s) => s.label)
922
+ });
923
+ }
924
+ // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
925
+ // window — both are part of the route contract, and dropping either here made
926
+ // the SDK quietly pick venue-wide and hold for the server default instead.
927
+ bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
928
+ return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
929
+ method: "POST",
930
+ body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
931
+ });
932
+ }
933
+ resume(key, holdId) {
934
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
935
+ method: "POST",
936
+ body: { holdId }
937
+ });
938
+ }
939
+ release(key, labels, holdId) {
940
+ return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
941
+ method: "POST",
942
+ body: { labels, holdId }
943
+ });
944
+ }
945
+ /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
946
+ * (reason: expired | extend_limit | not_found | not_active) if it can't. */
947
+ extend(key, holdId, ttlMs) {
948
+ return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
949
+ method: "POST",
950
+ body: { holdId, ...ttlMs ? { ttlMs } : {} }
951
+ });
685
952
  }
686
953
  /**
687
- * True when this picker is access-scoped at all. A false here is the
688
- * tokenless public picker, which must behave exactly as it always has.
954
+ * Which gateways this event can actually take money through the question
955
+ * `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
956
+ * the answer is never discovered by failing a payment.
689
957
  *
690
- * Answered from what the HOST asked for, never from live token state. It used
691
- * to be `!!#provider || !!#token`, which quietly inverted this file's central
692
- * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
693
- * the first refusal turned a configured context into an "unconfigured" one,
694
- * `authorization()` then returned null instead of throwing, and the very next
695
- * call went out with no bearer — the anonymous Public sale fallback this
696
- * module exists to prevent. A provider host never saw it, because `#provider`
697
- * held `configured` true. Found against a live worker in the M9 pass.
958
+ * Anonymous, and it discloses no account, key, mode or currency for a gateway
959
+ * that did not match.
698
960
  */
699
- get configured() {
700
- return __privateGet(this, _configured);
961
+ paymentOptions(key) {
962
+ return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
701
963
  }
702
- /** Set once a state arrives that refreshing cannot clear. */
703
- get unavailable() {
704
- return __privateGet(this, _terminal);
964
+ /**
965
+ * Turn a live hold into an order and start a payment.
966
+ *
967
+ * The amount is NOT sent: the server recomputes it from the hold's own items,
968
+ * which is the only reason a browser cannot alter what it pays. Nor is the
969
+ * PROVIDER — the event row decides which gateway charges, and a `provider` in
970
+ * the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
971
+ * it is the shape that cannot disagree.
972
+ */
973
+ startCheckout(key, input) {
974
+ return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
975
+ method: "POST",
976
+ body: input
977
+ });
705
978
  }
706
- /** True while a usable bearer is held (ignores skew). */
707
- get hasToken() {
708
- return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
979
+ /**
980
+ * Poll an order while its gateway webhook lands. The order id is an
981
+ * unguessable token the buyer already holds, so it acts as the capability
982
+ * which is also why a buyer returning from a gateway page can be told what
983
+ * happened with nothing but the id in the return URL.
984
+ */
985
+ orderStatus(orderId) {
986
+ return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
709
987
  }
710
- /** Epoch ms the current token expires, or 0 when the host didn't say. */
711
- get expiresAt() {
712
- return __privateGet(this, _expiresAt);
988
+ /**
989
+ * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
990
+ * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
991
+ * already apply; the socket then carries only the short-lived ticket, in its
992
+ * subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
993
+ */
994
+ subscribeTicket(key) {
995
+ return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
996
+ method: "POST",
997
+ body: {}
998
+ });
999
+ }
1000
+ /**
1001
+ * The subscribe URL. Never carries a credential — not the bearer, not the
1002
+ * ticket. Query parameters are diagnostics only.
1003
+ */
1004
+ subscribeUrl(key) {
1005
+ const wsBase = this.base.replace(/^http/, "ws");
1006
+ const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
1007
+ return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
1008
+ }
1009
+ /**
1010
+ * What PickerController opens its own socket with.
1011
+ *
1012
+ * Empty for an access-scoped client: a private scope authenticates with a
1013
+ * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
1014
+ * BuyerRealtimeClient owns that socket instead and the controller skips its
1015
+ * own (an empty URL is its documented "no live feed" contract). A tokenless
1016
+ * public client returns exactly the URL it always has, so nothing about the
1017
+ * public picker's realtime path changes.
1018
+ */
1019
+ socketUrl(key) {
1020
+ return this.accessScoped ? "" : this.subscribeUrl(key);
713
1021
  }
714
1022
  /**
715
- * The `Authorization` header value for a scoped operation.
1023
+ * The subprotocol list a PLAIN `new WebSocket(url, protocols)` must offer for
1024
+ * this transport — `PickerTransport.socketProtocols`, which PickerController
1025
+ * calls optionally and which nothing implemented until now.
716
1026
  *
717
- * Returns null only when this context is not configured at all (the ordinary
718
- * anonymous public picker). A configured context either returns a bearer or
719
- * throws `BuyerAccessUnavailableError` it never returns null, because a
720
- * null here would send the request as anonymous Public sale.
1027
+ * Offering `seatlayer.v1` is the whole point: without it the DO answers an
1028
+ * anonymous socket with the LEGACY verbose frame every unit of a 10k-seat
1029
+ * event, on connect and on every reconnect instead of the compact
1030
+ * `{default, exceptions}` form. Empty for an access-scoped client, which
1031
+ * authenticates with a one-use ticket a URL-only constructor cannot carry and
1032
+ * whose socket BuyerRealtimeClient owns instead (see `socketUrl`).
1033
+ *
1034
+ * `createRealtime` below is the preferred path and supersedes this for any
1035
+ * host that can use it; this stays the correct answer for a host that builds
1036
+ * the socket itself from the transport contract.
721
1037
  */
722
- async authorization(reason = "initial") {
723
- if (!this.configured) return null;
724
- if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
725
- const now = Date.now();
726
- const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
727
- if (stale) {
728
- const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
729
- const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
730
- const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
731
- if (!token) {
732
- throw new BuyerAccessUnavailableError(
733
- __privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
734
- );
735
- }
736
- return `Bearer ${token}`;
737
- }
738
- return `Bearer ${__privateGet(this, _token)}`;
1038
+ socketProtocols(key) {
1039
+ void key;
1040
+ return this.accessScoped ? [] : [SEATLAYER_V1];
739
1041
  }
740
1042
  /**
741
- * Handle a 401/403 from a scoped call. Returns true when the caller should
742
- * retry the same request once with the refreshed bearer.
1043
+ * Hand PickerController the v1 realtime client instead of letting it open a
1044
+ * bare socket `PickerTransport.createRealtime`.
1045
+ *
1046
+ * This is what puts an ANONYMOUS buyer (the on-sale case) on the same wire as
1047
+ * a private-channel one: compact snapshots, `sv.<n>` resume so a reconnect
1048
+ * inside the ring costs a delta rather than a full re-snapshot, ping/pong
1049
+ * liveness, and one jittered backoff implementation shared by both. The
1050
+ * anonymous case simply passes no `mintTicket` — the `/pub/events/:key/
1051
+ * subscribe` upgrade requires no ticket, and the DO resolves a ticketless
1052
+ * socket to the public scope.
1053
+ *
1054
+ * Null when access-scoped: that socket is owned by the widget's own
1055
+ * BuyerRealtimeClient (with the ticket exchange), and `socketUrl()` already
1056
+ * returns '' so the controller opens nothing.
743
1057
  */
744
- async handleFailure(status, code) {
745
- var _a;
746
- if (!this.configured) return false;
747
- if (isAccessExpiry(status, code)) {
748
- __privateSet(this, _token, null);
749
- __privateSet(this, _expiresAt, 0);
750
- const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
751
- (_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
752
- return !!token;
753
- }
754
- const reason = classifyAccessFailure(status, code);
755
- if (reason) {
756
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
757
- return false;
758
- }
759
- return false;
760
- }
761
- /** Host-driven re-acquisition (after the buyer signs in again, say). */
762
- async refresh(reason = "manual") {
763
- __privateSet(this, _terminal, null);
764
- __privateSet(this, _lastFailure, null);
765
- __privateSet(this, _token, null);
766
- __privateSet(this, _expiresAt, 0);
767
- return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
768
- }
769
- /** Drop the bearer. Called on destroy so nothing outlives the widget. */
770
- clear() {
771
- __privateSet(this, _token, null);
772
- __privateSet(this, _expiresAt, 0);
773
- __privateSet(this, _inflight, null);
774
- }
775
- /** Redaction: the bearer must not survive a stringify or an interpolation. */
776
- toJSON() {
777
- return { configured: this.configured, hasToken: this.hasToken };
778
- }
779
- toString() {
780
- return "[BuyerAccessContext redacted]";
781
- }
782
- };
783
- _token = new WeakMap();
784
- _expiresAt = new WeakMap();
785
- _provider = new WeakMap();
786
- _skewMs = new WeakMap();
787
- _inflight = new WeakMap();
788
- _terminal = new WeakMap();
789
- _lastFailure = new WeakMap();
790
- _onExpired = new WeakMap();
791
- _onUnavailable = new WeakMap();
792
- _configured = new WeakMap();
793
- _BuyerAccessContext_instances = new WeakSet();
794
- // ---- internals ------------------------------------------------------------
795
- accept_fn = function(next) {
796
- if (!next || typeof next.token !== "string" || !next.token) return null;
797
- __privateSet(this, _token, next.token);
798
- __privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
799
- return __privateGet(this, _token);
800
- };
801
- /**
802
- * One provider call at a time. Several operations racing an expiry (chart +
803
- * objects + a socket ticket) must not mint several sessions — the guide's
804
- * rotate-on-retry rule would revoke the ones they didn't observe.
805
- */
806
- renew_fn = function(reason, code) {
807
- if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
808
- const provider = __privateGet(this, _provider);
809
- if (!provider) {
810
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
811
- return Promise.resolve(null);
812
- }
813
- const run = (async () => {
814
- try {
815
- const next = await provider({ reason });
816
- const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
817
- if (!token) {
818
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
819
- return null;
820
- }
821
- return token;
822
- } catch {
823
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
824
- return null;
825
- } finally {
826
- __privateSet(this, _inflight, null);
827
- }
828
- })();
829
- __privateSet(this, _inflight, run);
830
- return run;
831
- };
832
- fail_fn = function(reason, code, status) {
833
- var _a;
834
- const event = {
835
- reason,
836
- code,
837
- status,
838
- // Unchanged: `retryable` means "the SAME request may succeed later".
839
- // `provider_failed` is recoverable but not retryable — the host must fix
840
- // its mint endpoint first — so the two sets are deliberately different.
841
- retryable: reason === "paused" || reason === "channel_denied"
842
- };
843
- __privateSet(this, _lastFailure, event);
844
- if (!RECOVERABLE.has(reason)) {
845
- __privateSet(this, _terminal, event);
846
- __privateSet(this, _token, null);
847
- __privateSet(this, _expiresAt, 0);
1058
+ createRealtime(key, sink) {
1059
+ if (this.accessScoped) return null;
1060
+ return new BuyerRealtimeClient({ url: this.subscribeUrl(key), sink });
848
1061
  }
849
- (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
850
- return event;
851
1062
  };
852
- function createBuyerAccessContext(options, hooks = {}) {
853
- if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
854
- return new BuyerAccessContext({
855
- provider: options.buyerAccessTokenProvider,
856
- token: options.buyerAccessToken,
857
- ...hooks
858
- });
859
- }
860
1063
 
861
- // src/buyerRealtime.ts
862
- var SEATLAYER_V1 = "seatlayer.v1";
863
- var SEP = "\0";
864
- var CLOSE_ACCESS_REVOKED = 4401;
865
- var MAX_BACKOFF_MS = 15e3;
866
- var PING_INTERVAL_MS = 25e3;
867
- var PONG_GRACE_MS = 1e4;
868
- var RESUME_ANSWER_GRACE_MS = 5e3;
869
- function projectionFromSnapshot(frame) {
870
- const fallback = typeof frame.default === "string" ? frame.default : "free";
871
- const exceptions = {};
872
- if (frame.seats && typeof frame.seats === "object") {
873
- for (const [label, status] of Object.entries(frame.seats)) {
874
- if (typeof status === "string" && status !== fallback) exceptions[label] = status;
875
- }
876
- }
877
- return { default: fallback, exceptions };
878
- }
879
- function diffProjections(prev, next) {
880
- if (!prev || prev.default !== next.default) return null;
881
- const changes = [];
882
- for (const [label, status] of Object.entries(next.exceptions)) {
883
- if (prev.exceptions[label] !== status) changes.push({ label, status });
884
- }
885
- for (const label of Object.keys(prev.exceptions)) {
886
- if (!(label in next.exceptions)) changes.push({ label, status: next.default });
1064
+ // src/buyerAccess.ts
1065
+ var BuyerAccessUnavailableError = class extends Error {
1066
+ constructor(event) {
1067
+ super(`buyer_access_unavailable:${event.reason}`);
1068
+ this.name = "BuyerAccessUnavailableError";
1069
+ this.reason = event.reason;
1070
+ this.code = event.code;
1071
+ this.status = event.status;
887
1072
  }
888
- return changes;
889
- }
890
- function applyChanges(projection, changes) {
891
- for (const change of changes) {
892
- if (change.status === projection.default) delete projection.exceptions[change.label];
893
- else projection.exceptions[change.label] = change.status;
1073
+ };
1074
+ var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
1075
+ var RECOVERABLE = /* @__PURE__ */ new Set([
1076
+ "paused",
1077
+ "provider_failed",
1078
+ "channel_denied"
1079
+ ]);
1080
+ function classifyAccessFailure(status, code) {
1081
+ switch (code) {
1082
+ case "buyer_access_invalid":
1083
+ return "invalid";
1084
+ case "buyer_access_revoked":
1085
+ return "revoked";
1086
+ case "buyer_access_origin_mismatch":
1087
+ return "origin_mismatch";
1088
+ case "buyer_access_event_mismatch":
1089
+ return "event_mismatch";
1090
+ case "buyer_access_mode_mismatch":
1091
+ return "mode_mismatch";
1092
+ case "channel_access_denied":
1093
+ return "channel_denied";
1094
+ case "channel_paused":
1095
+ return "paused";
1096
+ case "invalid_channel_scope":
1097
+ return "invalid_scope";
1098
+ default:
1099
+ break;
894
1100
  }
1101
+ if (status === 401) return "invalid";
1102
+ return null;
895
1103
  }
896
- function assertCredentialFreeUrl(url) {
897
- if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
898
- throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
899
- }
900
- if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
901
- throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
902
- }
1104
+ function isAccessExpiry(status, code) {
1105
+ return status === 401 && !!code && EXPIRED_CODES.has(code);
903
1106
  }
904
- var BuyerRealtimeClient = class {
1107
+ var DEFAULT_SKEW_MS = 3e4;
1108
+ var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
1109
+ var BuyerAccessContext = class {
905
1110
  constructor(options) {
906
- this.ws = null;
907
- this.stopped = true;
908
- this.attempt = 0;
909
- this.reconnectTimer = null;
910
- this.pingTimer = null;
911
- this.pongTimer = null;
912
- this.resumeTimer = null;
913
- /** Our model of this scope's projection. Null until the first snapshot. */
914
- this.projection = null;
915
- /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
916
- this.version = null;
917
- /** True once the 101 echoed `seatlayer.v1`. */
918
- this.v1 = false;
919
- /** Set when we offered v1 and the handshake came back without it — a proxy
920
- * most likely stripped the header, so the next attempt selects the v1 frame
921
- * format with the `?pv=1` marker instead (protocol doc §1). The marker
922
- * selects a format and can never carry a credential or widen a scope. */
923
- this.useQueryMarker = false;
924
- this.hidden = null;
925
- this.closedSections = null;
926
- this.opts = options;
927
- assertCredentialFreeUrl(options.url);
928
- }
929
- /** Negotiated protocol, for tests and diagnostics. */
930
- get protocol() {
931
- return this.ws ? this.v1 ? "v1" : "legacy" : null;
1111
+ __privateAdd(this, _BuyerAccessContext_instances);
1112
+ /** Private field: not enumerable, not spreadable, not serializable. */
1113
+ __privateAdd(this, _token, null);
1114
+ __privateAdd(this, _expiresAt, 0);
1115
+ __privateAdd(this, _provider);
1116
+ __privateAdd(this, _skewMs);
1117
+ __privateAdd(this, _inflight, null);
1118
+ __privateAdd(this, _terminal, null);
1119
+ /** The most recent failure, terminal or not — so one cause reports once. */
1120
+ __privateAdd(this, _lastFailure, null);
1121
+ __privateAdd(this, _onExpired);
1122
+ __privateAdd(this, _onUnavailable);
1123
+ /** Decided once, at construction. See the `configured` getter. */
1124
+ __privateAdd(this, _configured, false);
1125
+ __privateSet(this, _provider, options.provider);
1126
+ __privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
1127
+ __privateSet(this, _onExpired, options.onExpired);
1128
+ __privateSet(this, _onUnavailable, options.onUnavailable);
1129
+ if (options.token) {
1130
+ const seed = typeof options.token === "string" ? { token: options.token } : options.token;
1131
+ __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
1132
+ }
1133
+ __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
932
1134
  }
933
- get snapshotVersion() {
934
- return this.version;
1135
+ /**
1136
+ * True when this picker is access-scoped at all. A false here is the
1137
+ * tokenless public picker, which must behave exactly as it always has.
1138
+ *
1139
+ * Answered from what the HOST asked for, never from live token state. It used
1140
+ * to be `!!#provider || !!#token`, which quietly inverted this file's central
1141
+ * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
1142
+ * the first refusal turned a configured context into an "unconfigured" one,
1143
+ * `authorization()` then returned null instead of throwing, and the very next
1144
+ * call went out with no bearer — the anonymous Public sale fallback this
1145
+ * module exists to prevent. A provider host never saw it, because `#provider`
1146
+ * held `configured` true. Found against a live worker in the M9 pass.
1147
+ */
1148
+ get configured() {
1149
+ return __privateGet(this, _configured);
935
1150
  }
936
- start() {
937
- if (!this.stopped) return;
938
- this.stopped = false;
939
- void this.connect();
1151
+ /** Set once a state arrives that refreshing cannot clear. */
1152
+ get unavailable() {
1153
+ return __privateGet(this, _terminal);
940
1154
  }
941
- /** Stop for good (destroy, or a revocation). Safe to call twice. */
942
- stop() {
943
- this.stopped = true;
944
- this.clearTimers();
945
- const ws = this.ws;
946
- this.ws = null;
947
- if (ws) {
948
- ws.onopen = null;
949
- ws.onmessage = null;
950
- ws.onclose = null;
951
- ws.onerror = null;
952
- try {
953
- ws.close();
954
- } catch {
955
- }
956
- }
1155
+ /** True while a usable bearer is held (ignores skew). */
1156
+ get hasToken() {
1157
+ return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
957
1158
  }
958
- /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
959
- restart() {
960
- this.stop();
961
- this.projection = null;
962
- this.version = null;
963
- this.attempt = 0;
964
- this.start();
1159
+ /** Epoch ms the current token expires, or 0 when the host didn't say. */
1160
+ get expiresAt() {
1161
+ return __privateGet(this, _expiresAt);
965
1162
  }
966
- // ---- connection -----------------------------------------------------------
967
- async connect() {
968
- if (this.stopped) return;
969
- let protocols = [SEATLAYER_V1];
970
- if (this.opts.mintTicket) {
971
- let minted;
972
- try {
973
- minted = await this.opts.mintTicket();
974
- } catch (err) {
975
- this.reportIfAccessError(err);
976
- this.scheduleReconnect();
977
- return;
978
- }
979
- if (this.stopped) return;
980
- if (minted?.protocols?.length) {
981
- protocols = [...minted.protocols];
982
- if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
983
- } else if (minted?.ticket) {
984
- protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
1163
+ /**
1164
+ * The `Authorization` header value for a scoped operation.
1165
+ *
1166
+ * Returns null only when this context is not configured at all (the ordinary
1167
+ * anonymous public picker). A configured context either returns a bearer or
1168
+ * throws `BuyerAccessUnavailableError` — it never returns null, because a
1169
+ * null here would send the request as anonymous Public sale.
1170
+ */
1171
+ async authorization(reason = "initial") {
1172
+ if (!this.configured) return null;
1173
+ if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
1174
+ const now = Date.now();
1175
+ const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
1176
+ if (stale) {
1177
+ const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
1178
+ const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
1179
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
1180
+ if (!token) {
1181
+ throw new BuyerAccessUnavailableError(
1182
+ __privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
1183
+ );
985
1184
  }
1185
+ return `Bearer ${token}`;
986
1186
  }
987
- const offeredResume = this.version !== null;
988
- if (offeredResume) protocols.push(`sv.${this.version}`);
989
- const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
990
- assertCredentialFreeUrl(url);
991
- let ws;
992
- try {
993
- const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
994
- ws = make(url, protocols);
995
- } catch {
996
- this.scheduleReconnect();
997
- return;
998
- }
999
- this.ws = ws;
1000
- ws.onopen = () => {
1001
- if (this.ws !== ws) return;
1002
- this.attempt = 0;
1003
- this.v1 = ws.protocol === SEATLAYER_V1;
1004
- if (!this.v1) this.useQueryMarker = true;
1005
- this.startKeepalive(ws);
1006
- if (offeredResume) {
1007
- this.resumeTimer = setTimeout(() => {
1008
- this.resumeTimer = null;
1009
- void this.opts.sink.resync();
1010
- }, RESUME_ANSWER_GRACE_MS);
1011
- } else {
1012
- void this.opts.sink.resync();
1013
- }
1014
- };
1015
- ws.onmessage = (event) => {
1016
- if (this.ws !== ws) return;
1017
- let parsed;
1018
- try {
1019
- parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
1020
- } catch {
1021
- return;
1022
- }
1023
- if (!parsed || typeof parsed !== "object") return;
1024
- this.handleFrame(parsed);
1025
- };
1026
- ws.onclose = (event) => {
1027
- if (this.ws !== ws) return;
1028
- this.ws = null;
1029
- this.clearTimers();
1030
- if (event?.code === CLOSE_ACCESS_REVOKED) {
1031
- this.stopped = true;
1032
- this.opts.onAccessUnavailable?.({
1033
- reason: "revoked",
1034
- code: "access_revoked",
1035
- retryable: false
1036
- });
1037
- return;
1038
- }
1039
- this.scheduleReconnect();
1040
- };
1041
- ws.onerror = () => {
1042
- try {
1043
- ws.close();
1044
- } catch {
1045
- }
1046
- };
1187
+ return `Bearer ${__privateGet(this, _token)}`;
1047
1188
  }
1048
- handleFrame(frame) {
1049
- const type = typeof frame.type === "string" ? frame.type : "";
1050
- if (frame.protocol === 1) this.v1 = true;
1051
- if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
1052
- if (type === "pong") {
1053
- this.clearPongTimer();
1054
- return;
1055
- }
1056
- if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
1057
- const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
1058
- const closed = Array.isArray(frame.closed) ? frame.closed : [];
1059
- const hKey = hidden.join(SEP);
1060
- const cKey = closed.join(SEP);
1061
- if (hKey !== this.hidden || cKey !== this.closedSections) {
1062
- this.hidden = hKey;
1063
- this.closedSections = cKey;
1064
- this.opts.sink.onSections?.(hidden, closed);
1065
- }
1066
- }
1067
- if (type === "hidden") return;
1068
- if (type === "presence") {
1069
- this.opts.sink.onPresence?.({
1070
- shoppingSessions: Number(frame.shoppingSessions) || 0,
1071
- activeHolds: Number(frame.activeHolds) || 0
1072
- });
1073
- return;
1074
- }
1075
- if (type === "allocation") {
1076
- return;
1077
- }
1078
- if (type === "snapshot" || !type && frame.seats) {
1079
- this.answered();
1080
- const next = projectionFromSnapshot(frame);
1081
- const changes = diffProjections(this.projection, next);
1082
- this.projection = next;
1083
- if (changes === null) void this.opts.sink.resync();
1084
- else if (changes.length) this.opts.sink.applyStatuses(changes);
1085
- return;
1086
- }
1087
- if (type === "delta" && Array.isArray(frame.changes)) {
1088
- this.answered();
1089
- const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
1090
- if (!changes.length) return;
1091
- if (this.projection) applyChanges(this.projection, changes);
1092
- this.opts.sink.applyStatuses(changes);
1189
+ /**
1190
+ * Handle a 401/403 from a scoped call. Returns true when the caller should
1191
+ * retry the same request once with the refreshed bearer.
1192
+ */
1193
+ async handleFailure(status, code) {
1194
+ var _a;
1195
+ if (!this.configured) return false;
1196
+ if (isAccessExpiry(status, code)) {
1197
+ __privateSet(this, _token, null);
1198
+ __privateSet(this, _expiresAt, 0);
1199
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
1200
+ (_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
1201
+ return !!token;
1093
1202
  }
1203
+ const reason = classifyAccessFailure(status, code);
1204
+ if (reason) {
1205
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
1206
+ return false;
1207
+ }
1208
+ return false;
1094
1209
  }
1095
- /** The server answered our resume; cancel the fallback resync. */
1096
- answered() {
1097
- if (!this.resumeTimer) return;
1098
- clearTimeout(this.resumeTimer);
1099
- this.resumeTimer = null;
1100
- }
1101
- reportIfAccessError(err) {
1102
- const reason = err?.reason;
1103
- if (err?.name !== "BuyerAccessUnavailableError") return;
1104
- this.stopped = true;
1105
- this.opts.onAccessUnavailable?.({
1106
- reason: reason ?? "invalid",
1107
- code: err.code,
1108
- status: err.status,
1109
- retryable: reason === "paused"
1110
- });
1111
- }
1112
- // ---- keepalive & backoff --------------------------------------------------
1113
- /**
1114
- * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
1115
- * for minutes is the normal, correct state for a narrowly-scoped buyer on a
1116
- * busy event (protocol doc §5), so quiet time never triggers a reconnect.
1117
- */
1118
- startKeepalive(ws) {
1119
- this.pingTimer = setInterval(() => {
1120
- if (this.ws !== ws) return;
1121
- try {
1122
- ws.send(JSON.stringify({ type: "ping" }));
1123
- } catch {
1124
- return;
1125
- }
1126
- this.clearPongTimer();
1127
- this.pongTimer = setTimeout(() => {
1128
- this.pongTimer = null;
1129
- try {
1130
- ws.close();
1131
- } catch {
1132
- }
1133
- }, PONG_GRACE_MS);
1134
- }, PING_INTERVAL_MS);
1210
+ /** Host-driven re-acquisition (after the buyer signs in again, say). */
1211
+ async refresh(reason = "manual") {
1212
+ __privateSet(this, _terminal, null);
1213
+ __privateSet(this, _lastFailure, null);
1214
+ __privateSet(this, _token, null);
1215
+ __privateSet(this, _expiresAt, 0);
1216
+ return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
1135
1217
  }
1136
- scheduleReconnect() {
1137
- if (this.stopped || this.reconnectTimer) return;
1138
- const attempt = Math.min(this.attempt++, 5);
1139
- const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
1140
- this.reconnectTimer = setTimeout(() => {
1141
- this.reconnectTimer = null;
1142
- void this.connect();
1143
- }, delay);
1218
+ /** Drop the bearer. Called on destroy so nothing outlives the widget. */
1219
+ clear() {
1220
+ __privateSet(this, _token, null);
1221
+ __privateSet(this, _expiresAt, 0);
1222
+ __privateSet(this, _inflight, null);
1144
1223
  }
1145
- clearPongTimer() {
1146
- if (!this.pongTimer) return;
1147
- clearTimeout(this.pongTimer);
1148
- this.pongTimer = null;
1224
+ /** Redaction: the bearer must not survive a stringify or an interpolation. */
1225
+ toJSON() {
1226
+ return { configured: this.configured, hasToken: this.hasToken };
1149
1227
  }
1150
- clearTimers() {
1151
- if (this.pingTimer) clearInterval(this.pingTimer);
1152
- this.pingTimer = null;
1153
- this.clearPongTimer();
1154
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
1155
- this.reconnectTimer = null;
1156
- if (this.resumeTimer) clearTimeout(this.resumeTimer);
1157
- this.resumeTimer = null;
1228
+ toString() {
1229
+ return "[BuyerAccessContext redacted]";
1158
1230
  }
1159
1231
  };
1160
- function rendererStatus(wire) {
1161
- if (wire === "blocked") return "not_for_sale";
1162
- if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
1163
- return "free";
1164
- }
1165
- function createControllerSink(controller, options = {}) {
1166
- const idsForLabel = (label) => {
1167
- const table = controller.tableSelection(label);
1168
- if (table) return table.physicalSeatIds;
1169
- const id = controller.idForLabel(label);
1170
- return id ? [id] : [];
1171
- };
1172
- return {
1173
- applyStatuses(changes) {
1174
- const held = controller.currentHold()?.labels ?? [];
1175
- const buckets = {
1176
- free: [],
1177
- held: [],
1178
- booked: [],
1179
- not_for_sale: []
1180
- };
1181
- const flashes = [];
1182
- const lost = [];
1183
- const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
1184
- for (const change of changes) {
1185
- const ids = idsForLabel(change.label);
1186
- if (!ids.length) continue;
1187
- const next = rendererStatus(change.status);
1188
- buckets[next].push(...ids);
1189
- if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
1190
- const color = next === "held" ? "#f4b740" : "#f43f5e";
1191
- for (const id of ids) flashes.push({ id, color });
1192
- }
1193
- if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
1194
- lost.push(change.label);
1195
- }
1196
- }
1197
- for (const status of ["free", "held", "booked", "not_for_sale"]) {
1198
- if (buckets[status].length) controller.setStatus(buckets[status], status);
1199
- }
1200
- for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
1201
- if (lost.length) {
1202
- const ids = lost.flatMap((label) => idsForLabel(label));
1203
- if (ids.length) controller.deselect(ids);
1204
- const ineligible = changes.some(
1205
- (c) => c.status === "blocked" && lost.includes(c.label)
1206
- );
1207
- options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
1232
+ _token = new WeakMap();
1233
+ _expiresAt = new WeakMap();
1234
+ _provider = new WeakMap();
1235
+ _skewMs = new WeakMap();
1236
+ _inflight = new WeakMap();
1237
+ _terminal = new WeakMap();
1238
+ _lastFailure = new WeakMap();
1239
+ _onExpired = new WeakMap();
1240
+ _onUnavailable = new WeakMap();
1241
+ _configured = new WeakMap();
1242
+ _BuyerAccessContext_instances = new WeakSet();
1243
+ // ---- internals ------------------------------------------------------------
1244
+ accept_fn = function(next) {
1245
+ if (!next || typeof next.token !== "string" || !next.token) return null;
1246
+ __privateSet(this, _token, next.token);
1247
+ __privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
1248
+ return __privateGet(this, _token);
1249
+ };
1250
+ /**
1251
+ * One provider call at a time. Several operations racing an expiry (chart +
1252
+ * objects + a socket ticket) must not mint several sessions — the guide's
1253
+ * rotate-on-retry rule would revoke the ones they didn't observe.
1254
+ */
1255
+ renew_fn = function(reason, code) {
1256
+ if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
1257
+ const provider = __privateGet(this, _provider);
1258
+ if (!provider) {
1259
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
1260
+ return Promise.resolve(null);
1261
+ }
1262
+ const run = (async () => {
1263
+ try {
1264
+ const next = await provider({ reason });
1265
+ const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
1266
+ if (!token) {
1267
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
1268
+ return null;
1208
1269
  }
1209
- options.onStatusChange?.();
1210
- },
1211
- async resync() {
1212
- await controller.refresh();
1213
- },
1214
- /**
1215
- * Section availability moved. Statuses are re-pulled so the map repaints.
1216
- *
1217
- * Known limit: rebuilding the chart when a section is newly HIDDEN (its
1218
- * seats are stripped, not greyed) lives inside PickerController's own
1219
- * socket handler and has no public entry point, so an access-scoped picker
1220
- * repaints statuses but does not restructure the chart until its next
1221
- * mount. Closing/opening a section — the common mid-sale move — is a
1222
- * status-level change and is handled here in full.
1223
- */
1224
- onSections(hidden, closed) {
1225
- void controller.refresh();
1226
- options.onSections?.(hidden, closed);
1270
+ return token;
1271
+ } catch {
1272
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
1273
+ return null;
1274
+ } finally {
1275
+ __privateSet(this, _inflight, null);
1227
1276
  }
1277
+ })();
1278
+ __privateSet(this, _inflight, run);
1279
+ return run;
1280
+ };
1281
+ fail_fn = function(reason, code, status) {
1282
+ var _a;
1283
+ const event = {
1284
+ reason,
1285
+ code,
1286
+ status,
1287
+ // Unchanged: `retryable` means "the SAME request may succeed later".
1288
+ // `provider_failed` is recoverable but not retryable — the host must fix
1289
+ // its mint endpoint first — so the two sets are deliberately different.
1290
+ retryable: reason === "paused" || reason === "channel_denied"
1228
1291
  };
1292
+ __privateSet(this, _lastFailure, event);
1293
+ if (!RECOVERABLE.has(reason)) {
1294
+ __privateSet(this, _terminal, event);
1295
+ __privateSet(this, _token, null);
1296
+ __privateSet(this, _expiresAt, 0);
1297
+ }
1298
+ (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
1299
+ return event;
1300
+ };
1301
+ function createBuyerAccessContext(options, hooks = {}) {
1302
+ if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
1303
+ return new BuyerAccessContext({
1304
+ provider: options.buyerAccessTokenProvider,
1305
+ token: options.buyerAccessToken,
1306
+ ...hooks
1307
+ });
1229
1308
  }
1230
1309
 
1231
1310
  // src/seatLayerBrand.ts
@@ -9570,13 +9649,39 @@ var SeatManager = class {
9570
9649
  this.labelToId = /* @__PURE__ */ new Map();
9571
9650
  this.labelToSeat = /* @__PURE__ */ new Map();
9572
9651
  this.allIds = [];
9652
+ /**
9653
+ * GA inventory units — real sellable labels the server counts, with NO seat
9654
+ * geometry and therefore no renderer binding. They live here rather than in
9655
+ * `labelToId`/`allIds` so every paint path keeps addressing paintable nodes
9656
+ * only, while the tally denominator finally covers the same universe the
9657
+ * numerator does. Without them a GA sale hit `booked` but not `total`:
9658
+ * Free under-reported by GA capacity and SOLD% could exceed 100%.
9659
+ */
9660
+ this.gaUnitLabelSet = /* @__PURE__ */ new Set();
9573
9661
  this.status = /* @__PURE__ */ new Map();
9662
+ /** Live non-free counters, moved by each delta rather than re-walked. */
9663
+ this.counts = { held: 0, booked: 0, blocked: 0 };
9664
+ /** Bumped whenever the seat model is replaced wholesale (a full snapshot). */
9665
+ this.modelVersion = 0;
9574
9666
  this.currency = "USD";
9575
9667
  this.authoritativeGrossRevenue = 0;
9576
9668
  this.revenueStatus = "loading";
9577
9669
  this.revenueRequest = 0;
9578
- this.revenueRefreshTimer = null;
9579
9670
  this.controlRoomSnapshot = null;
9671
+ /**
9672
+ * The server's own totals, pinned to the client model they were read against.
9673
+ * Display = server baseline + (client now − client then), so the authoritative
9674
+ * numbers land exactly on arrival and deltas still move them between reads.
9675
+ * A wholesale model replacement invalidates the pairing (`model`), and the
9676
+ * client tallies — themselves a fresh authenticated read — take over.
9677
+ */
9678
+ this.serverBaseline = null;
9679
+ /** Latest presence frame, held whether or not a snapshot has landed yet. */
9680
+ this.livePresence = null;
9681
+ /** Latest cumulative booked gross pushed on a delta frame. */
9682
+ this.liveGross = null;
9683
+ /** Coalesces a burst of deltas into one KPI/rail repaint. */
9684
+ this.paintHandle = null;
9580
9685
  this.trendWindowMinutes = 15;
9581
9686
  this.heatEnabled = false;
9582
9687
  this.lastKpiValues = /* @__PURE__ */ new Map();
@@ -9674,12 +9779,7 @@ var SeatManager = class {
9674
9779
  const res = await this.api.chart(this.key);
9675
9780
  this.doc = res.doc;
9676
9781
  this.currency = res.event.currency ?? this.opts.currency ?? this.currency;
9677
- const seats = (0, import_core3.expandChart)(res.doc);
9678
- for (const s of seats) {
9679
- this.labelToId.set(s.label, s.id);
9680
- this.labelToSeat.set(s.label, s);
9681
- this.allIds.push(s.id);
9682
- }
9782
+ this.buildUnitUniverse(res.doc);
9683
9783
  this.buildRenderer();
9684
9784
  this.buildSectionOptions();
9685
9785
  const [, controlRoom] = await Promise.all([
@@ -10040,7 +10140,10 @@ var SeatManager = class {
10040
10140
  if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
10041
10141
  if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
10042
10142
  if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
10043
- if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
10143
+ if (this.paintHandle !== null && typeof cancelAnimationFrame === "function") {
10144
+ cancelAnimationFrame(this.paintHandle);
10145
+ }
10146
+ this.paintHandle = null;
10044
10147
  this.channels?.destroy();
10045
10148
  this.channels = null;
10046
10149
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
@@ -10123,6 +10226,37 @@ var SeatManager = class {
10123
10226
  }
10124
10227
  this.syncSelection();
10125
10228
  }
10229
+ /**
10230
+ * Build the client's inventory universe from the chart.
10231
+ *
10232
+ * `expandChart` yields SEATS — it has no output for a GA area, whose capacity
10233
+ * is sold as N synthetic unit labels. The server's seat map keys, its deltas
10234
+ * and its `totals` all speak those labels, so a client that only knows seats
10235
+ * counts GA sales in the numerator (every key of the snapshot is written into
10236
+ * `status`) while leaving them out of the denominator. Registering the GA
10237
+ * units here — labels only, never a render binding — is what makes the two
10238
+ * agree.
10239
+ */
10240
+ buildUnitUniverse(doc) {
10241
+ for (const seat of (0, import_core3.expandChart)(doc)) {
10242
+ this.labelToId.set(seat.label, seat.id);
10243
+ this.labelToSeat.set(seat.label, seat);
10244
+ this.allIds.push(seat.id);
10245
+ }
10246
+ for (const area of (0, import_core3.gaAreasOf)(doc)) {
10247
+ for (const label of (0, import_core3.gaUnitLabels)(area)) {
10248
+ if (!this.labelToId.has(label)) this.gaUnitLabelSet.add(label);
10249
+ }
10250
+ }
10251
+ }
10252
+ /** Every sellable unit the client knows: seats + GA capacity. */
10253
+ unitTotal() {
10254
+ return this.allIds.length + this.gaUnitLabelSet.size;
10255
+ }
10256
+ /** Every label the client models, whether or not it can be painted. */
10257
+ knownLabels() {
10258
+ return [...this.labelToId.keys(), ...this.gaUnitLabelSet];
10259
+ }
10126
10260
  repaintAll() {
10127
10261
  const r = this.renderer;
10128
10262
  if (!r) return;
@@ -10172,7 +10306,7 @@ var SeatManager = class {
10172
10306
  ws.onopen = () => {
10173
10307
  this.attempt = 0;
10174
10308
  this.setLive(true);
10175
- void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
10309
+ void this.resnapshot().then(() => this.refreshControlRoom()).catch((err) => this.opts.onError?.(err));
10176
10310
  void this.refreshAvailability();
10177
10311
  };
10178
10312
  ws.onmessage = (e) => this.onMessage(e);
@@ -10210,15 +10344,18 @@ var SeatManager = class {
10210
10344
  this.updateEffectiveAvailability(m.hidden, m.closed);
10211
10345
  }
10212
10346
  if (m.type === "presence") {
10213
- if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
10214
- this.controlRoomSnapshot = {
10215
- ...this.controlRoomSnapshot,
10216
- presence: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
10347
+ if (typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
10348
+ this.livePresence = {
10349
+ at: Date.now(),
10350
+ value: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
10217
10351
  };
10352
+ if (this.controlRoomSnapshot) {
10353
+ this.controlRoomSnapshot = { ...this.controlRoomSnapshot, presence: this.livePresence.value };
10354
+ this.opts.onControlRoom?.(this.controlRoomSnapshot);
10355
+ }
10218
10356
  this.lastSyncedAt = Date.now();
10219
10357
  this.recomputeTallies();
10220
10358
  this.paintMonitorInsights();
10221
- this.opts.onControlRoom?.(this.controlRoomSnapshot);
10222
10359
  }
10223
10360
  return;
10224
10361
  }
@@ -10232,7 +10369,7 @@ var SeatManager = class {
10232
10369
  const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
10233
10370
  const prev = this.status.get(ch.label) ?? "free";
10234
10371
  if (prev === st) continue;
10235
- this.status.set(ch.label, st);
10372
+ this.setStatusLabel(ch.label, st, prev);
10236
10373
  const id = this.labelToId.get(ch.label);
10237
10374
  if (id) {
10238
10375
  this.renderer?.setStatus([id], toRenderStatus(st));
@@ -10252,10 +10389,38 @@ var SeatManager = class {
10252
10389
  this.lastSyncedAt = Date.now();
10253
10390
  this.afterPaint();
10254
10391
  }
10392
+ if (typeof m.revenue?.gross === "number" && Number.isFinite(m.revenue.gross)) {
10393
+ this.applyLiveGross(m.revenue.gross);
10394
+ }
10255
10395
  this.recomputeTallies();
10256
- if (ids.length) this.scheduleRevenueRefresh();
10257
10396
  }
10258
10397
  }
10398
+ /**
10399
+ * Adopt the cumulative booked gross a delta frame carried.
10400
+ *
10401
+ * Stashed with its arrival time so an in-flight control-room read can decide
10402
+ * whether it is holding the newer number: a frame that landed after the
10403
+ * request started is newer than the response, one that landed before is not.
10404
+ */
10405
+ applyLiveGross(gross) {
10406
+ this.liveGross = { at: Date.now(), value: gross };
10407
+ this.authoritativeGrossRevenue = gross;
10408
+ this.revenueStatus = "current";
10409
+ if (this.controlRoomSnapshot) {
10410
+ this.controlRoomSnapshot = {
10411
+ ...this.controlRoomSnapshot,
10412
+ revenue: { ...this.controlRoomSnapshot.revenue, gross }
10413
+ };
10414
+ this.opts.onControlRoom?.(this.controlRoomSnapshot);
10415
+ }
10416
+ }
10417
+ /** The single writer for a label's status, so the counters never drift. */
10418
+ setStatusLabel(label, next, prev = this.status.get(label) ?? "free") {
10419
+ this.status.set(label, next);
10420
+ if (prev === next) return;
10421
+ if (prev !== "free") this.counts[prev] -= 1;
10422
+ if (next !== "free") this.counts[next] += 1;
10423
+ }
10259
10424
  async resnapshot() {
10260
10425
  try {
10261
10426
  const objs = await this.api.objects(this.key);
@@ -10278,23 +10443,31 @@ var SeatManager = class {
10278
10443
  const next = /* @__PURE__ */ new Map();
10279
10444
  if (fallback !== void 0) {
10280
10445
  const base = known(fallback);
10281
- for (const label of this.labelToId.keys()) next.set(label, base);
10446
+ for (const label of this.knownLabels()) next.set(label, base);
10282
10447
  }
10283
10448
  for (const [label, st] of Object.entries(seats)) {
10284
10449
  next.set(label, known(st));
10285
10450
  }
10286
10451
  this.status = next;
10452
+ this.modelVersion += 1;
10453
+ this.recountAll();
10287
10454
  this.lastSyncedAt = Date.now();
10288
10455
  this.repaintAll();
10289
10456
  this.afterPaint();
10290
10457
  this.recomputeTallies();
10291
10458
  }
10459
+ /** The one O(n) walk left: a wholesale model replacement re-bases the counters. */
10460
+ recountAll() {
10461
+ const counts = { held: 0, booked: 0, blocked: 0 };
10462
+ for (const st of this.status.values()) if (st !== "free") counts[st] += 1;
10463
+ this.counts = counts;
10464
+ }
10292
10465
  /** Optimistic local write shared by organizer actions. Paint and tally once,
10293
10466
  * even when an arena-sized operation changes hundreds of seats. */
10294
10467
  setSeatsLocal(labels, st) {
10295
10468
  const ids = [];
10296
10469
  for (const label of labels) {
10297
- this.status.set(label, st);
10470
+ this.setStatusLabel(label, st);
10298
10471
  const id = this.labelToId.get(label);
10299
10472
  if (id) ids.push(id);
10300
10473
  }
@@ -10420,12 +10593,33 @@ var SeatManager = class {
10420
10593
  this.revenueStatus = "current";
10421
10594
  this.recomputeTallies();
10422
10595
  }
10596
+ /**
10597
+ * Read the server's own control-room projection.
10598
+ *
10599
+ * Called on mount, on every socket (re)connect and after an organizer action —
10600
+ * never on a timer and never per delta frame. Presence and gross that arrived
10601
+ * on the socket AFTER this request started are newer than the response, so
10602
+ * they survive it; anything older defers to the read.
10603
+ */
10423
10604
  async refreshControlRoom() {
10424
10605
  const request = ++this.revenueRequest;
10606
+ const requestedAt = Date.now();
10425
10607
  try {
10426
- const snapshot = await this.api.controlRoom(this.key, this.trendWindowMinutes);
10608
+ const fetched = await this.api.controlRoom(this.key, this.trendWindowMinutes);
10609
+ let snapshot = fetched;
10427
10610
  if (request === this.revenueRequest) {
10611
+ if (this.livePresence && this.livePresence.at >= requestedAt) {
10612
+ snapshot = { ...snapshot, presence: this.livePresence.value };
10613
+ } else {
10614
+ this.livePresence = null;
10615
+ }
10616
+ if (this.liveGross && this.liveGross.at >= requestedAt) {
10617
+ snapshot = { ...snapshot, revenue: { ...snapshot.revenue, gross: this.liveGross.value } };
10618
+ } else {
10619
+ this.liveGross = null;
10620
+ }
10428
10621
  this.controlRoomSnapshot = snapshot;
10622
+ this.rebaseServerTotals(snapshot);
10429
10623
  this.lastSyncedAt = Date.now();
10430
10624
  this.authoritativeGrossRevenue = snapshot.revenue.gross;
10431
10625
  this.currency = snapshot.currency;
@@ -10444,37 +10638,80 @@ var SeatManager = class {
10444
10638
  throw err;
10445
10639
  }
10446
10640
  }
10447
- scheduleRevenueRefresh(delay = 140) {
10448
- this.revenueStatus = "stale";
10449
- this.recomputeTallies();
10450
- if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
10451
- this.revenueRefreshTimer = setTimeout(() => {
10452
- this.revenueRefreshTimer = null;
10453
- void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
10454
- }, delay);
10641
+ /** Pin the server's totals to the client model they were read against. */
10642
+ rebaseServerTotals(snapshot) {
10643
+ const totals = snapshot.totals;
10644
+ if (!totals || ["free", "held", "booked", "blocked"].some(
10645
+ (key) => !Number.isFinite(totals[key])
10646
+ )) {
10647
+ this.serverBaseline = null;
10648
+ return;
10649
+ }
10650
+ this.serverBaseline = {
10651
+ model: this.modelVersion,
10652
+ server: { free: totals.free, held: totals.held, booked: totals.booked, blocked: totals.blocked },
10653
+ client: this.clientTallies()
10654
+ };
10455
10655
  }
10456
- recomputeTallies() {
10656
+ /** What the client's own model says — GA units included since `render()`. */
10657
+ clientTallies() {
10658
+ const { held, booked, blocked } = this.counts;
10659
+ return { held, booked, blocked, free: Math.max(0, this.unitTotal() - held - booked - blocked) };
10660
+ }
10661
+ /**
10662
+ * The numbers the KPI bar and rail render.
10663
+ *
10664
+ * The server is the authority: its totals land exactly as read, and the
10665
+ * delta-driven client model carries them forward until the next read. Before
10666
+ * the first snapshot — and after a wholesale model replacement invalidates the
10667
+ * pairing — the client model stands alone.
10668
+ */
10669
+ buildTallies() {
10670
+ const client = this.clientTallies();
10671
+ const baseline = this.serverBaseline?.model === this.modelVersion ? this.serverBaseline : null;
10672
+ const of = (key) => baseline ? Math.max(0, baseline.server[key] + (client[key] - baseline.client[key])) : client[key];
10673
+ const seatTotal = this.controlRoomSnapshot?.event?.seatTotal;
10457
10674
  const t3 = {
10458
- free: 0,
10459
- held: 0,
10460
- booked: 0,
10461
- blocked: 0,
10462
- total: this.allIds.length,
10675
+ free: of("free"),
10676
+ held: of("held"),
10677
+ booked: of("booked"),
10678
+ blocked: of("blocked"),
10679
+ total: Number.isFinite(seatTotal) ? seatTotal : this.unitTotal(),
10463
10680
  capacityPct: 0,
10464
10681
  sellThroughPct: 0,
10465
10682
  grossRevenue: this.authoritativeGrossRevenue,
10466
10683
  revenueStatus: this.revenueStatus,
10467
10684
  currency: this.currency
10468
10685
  };
10469
- let nonFree = 0;
10470
- for (const st of this.status.values()) {
10471
- t3[st] += 1;
10472
- if (st !== "free") nonFree += 1;
10473
- }
10474
- t3.free = Math.max(0, t3.total - nonFree);
10475
10686
  t3.capacityPct = t3.total ? Math.round(t3.booked / t3.total * 100) : 0;
10476
10687
  const sellable = t3.total - t3.blocked;
10477
10688
  t3.sellThroughPct = sellable > 0 ? Math.round(t3.booked / sellable * 100) : 0;
10689
+ return t3;
10690
+ }
10691
+ /**
10692
+ * Queue one KPI/rail repaint for this burst of changes.
10693
+ *
10694
+ * A delta frame can carry hundreds of seats and `paintKpis` rebuilds eight
10695
+ * nodes from scratch, so painting per change is what made an arena-sized
10696
+ * frame expensive. Coalescing on a frame keeps the burst to a single rebuild;
10697
+ * without `requestAnimationFrame` (SSR, an older test env) it paints inline
10698
+ * rather than dropping the update.
10699
+ */
10700
+ recomputeTallies() {
10701
+ if (this.closed) return;
10702
+ if (typeof requestAnimationFrame !== "function") {
10703
+ this.flushTallies();
10704
+ return;
10705
+ }
10706
+ if (this.paintHandle !== null) return;
10707
+ this.paintHandle = requestAnimationFrame(() => {
10708
+ this.paintHandle = null;
10709
+ this.flushTallies();
10710
+ });
10711
+ }
10712
+ flushTallies() {
10713
+ if (this.closed) return;
10714
+ const t3 = this.buildTallies();
10478
10715
  this.paintKpis(t3);
10479
10716
  if (this.mode === "view") {
10480
10717
  this.paintLegend(t3);
@@ -10761,16 +10998,16 @@ var SeatManager = class {
10761
10998
  paintKpis(t3) {
10762
10999
  if (!this.els.kpis) return;
10763
11000
  const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
10764
- const presence = this.controlRoomSnapshot?.presence;
11001
+ const presence = this.presenceCounts();
10765
11002
  const items = [
10766
- { key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
10767
- { key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
10768
- { key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
10769
- { key: "active-holds", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
10770
- { key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
10771
- { key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
10772
- { key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold" },
10773
- { key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales" }
11003
+ { key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b", title: "Seats booked" },
11004
+ { key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740", title: "Seats held in a checkout right now" },
11005
+ { key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff", title: "Seats on sale and unsold" },
11006
+ { key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac", title: "Seats withheld from sale" },
11007
+ { key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers", title: "People on the map right now" },
11008
+ { key: "carts", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Carts", title: "Checkouts holding seats right now \u2014 sessions, not seats" },
11009
+ { key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold", title: "Sold seats as a share of the whole event" },
11010
+ { key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales", title: "Exact booked gross" }
10774
11011
  ];
10775
11012
  let hasChanges = false;
10776
11013
  this.els.kpis.innerHTML = items.map((item) => {
@@ -10786,7 +11023,7 @@ var SeatManager = class {
10786
11023
  }
10787
11024
  if (item.raw != null) this.lastKpiValues.set(item.key, item.raw);
10788
11025
  const activeDelta = this.activeKpiDeltas.get(item.key);
10789
- return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}">
11026
+ return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}" title="${esc2(item.title)}">
10790
11027
  <b>${item.dot ? `<span class="dot" style="background:${item.dot}"></span>` : ""}${item.n}</b><span>${item.l}</span>
10791
11028
  ${activeDelta ? `<span class="slm-kpidelta${activeDelta.down ? " down" : ""}">${activeDelta.text}</span>` : ""}
10792
11029
  </div>`;
@@ -10844,15 +11081,21 @@ var SeatManager = class {
10844
11081
  this.paintMomentumHelp();
10845
11082
  this.paintFeed();
10846
11083
  }
11084
+ /** Live presence wins over the snapshot's copy — it is the fresher channel,
11085
+ * and it exists from the first frame rather than the first fetch. */
11086
+ presenceCounts() {
11087
+ return this.livePresence?.value ?? this.controlRoomSnapshot?.presence ?? null;
11088
+ }
10847
11089
  paintMonitorInsights() {
10848
11090
  if (this.mode !== "view") return;
10849
11091
  const snapshot = this.controlRoomSnapshot;
10850
11092
  if (this.els.presence) {
10851
11093
  const connected = this.root?.classList.contains("live");
10852
11094
  const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
11095
+ const presence = this.presenceCounts();
10853
11096
  this.els.presence.innerHTML = `
10854
- <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyer sessions</span></div>
10855
- <div class="slm-healthitem"><b>${snapshot ? snapshot.presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Active holds</span></div>
11097
+ <div class="slm-healthitem" title="People on the map right now"><b>${presence ? presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyers</span></div>
11098
+ <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>
10856
11099
  <div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
10857
11100
  <div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
10858
11101
  }
@@ -11463,7 +11706,7 @@ var SeatManager = class {
11463
11706
  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;
11464
11707
  if (activity) this.paintSpatialActivity(activity);
11465
11708
  }
11466
- if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
11709
+ if (action !== "setHoldTtl") void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
11467
11710
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
11468
11711
  }
11469
11712
  toastOk(msg) {