@seatlayer/js 0.36.3 → 0.38.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
@@ -1,11 +1,9 @@
1
- var __typeError = (msg) => {
2
- throw TypeError(msg);
3
- };
4
- var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
5
- var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
6
- var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
7
- var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
8
- var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
1
+ import {
2
+ __privateAdd,
3
+ __privateGet,
4
+ __privateMethod,
5
+ __privateSet
6
+ } from "./chunk-HMLY7DHA.js";
9
7
 
10
8
  // src/SeatingChart.ts
11
9
  import {
@@ -120,6 +118,41 @@ var PubApi = class {
120
118
  body: { holdId, ...ttlMs ? { ttlMs } : {} }
121
119
  });
122
120
  }
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`);
131
+ }
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
+ });
146
+ }
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`);
155
+ }
123
156
  /**
124
157
  * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
125
158
  * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
@@ -1673,7 +1706,8 @@ var EmbeddedDesigner = class {
1673
1706
  }
1674
1707
  buildSkeleton(overlay) {
1675
1708
  const style = document.createElement("style");
1676
- style.textContent = `
1709
+ style.textContent = /* @sl-css */
1710
+ `
1677
1711
  @media (prefers-reduced-motion: no-preference) {
1678
1712
  @keyframes seatlayer-designer-shimmer {
1679
1713
  0% { background-position: -320px 0; }
@@ -1799,7 +1833,6 @@ import {
1799
1833
  PickerController as PickerController2,
1800
1834
  ACCESSIBILITY_TYPES,
1801
1835
  expandChart,
1802
- generateSeatPanorama,
1803
1836
  generateSeatThumb,
1804
1837
  loadLocale as loadLocale2,
1805
1838
  setStringOverrides as setStringOverrides2,
@@ -1862,20 +1895,45 @@ function hasWebGL2() {
1862
1895
  }
1863
1896
  return _webgl2Cache;
1864
1897
  }
1898
+ function cdnChunkUrl(fileName) {
1899
+ const base = SEATLAYER_MODULE_URL ?? (typeof location !== "undefined" ? location.href : void 0);
1900
+ if (!base) throw new Error(`seatlayer: cannot resolve the ${fileName} chunk URL`);
1901
+ return new URL(`./${fileName}`, base).href;
1902
+ }
1865
1903
  async function loadVenue3d() {
1866
1904
  if (typeof __SEATLAYER_CDN__ !== "undefined" && __SEATLAYER_CDN__) {
1867
- const base = SEATLAYER_MODULE_URL ?? (typeof location !== "undefined" ? location.href : void 0);
1868
- if (!base) throw new Error("seatlayer: cannot resolve the 3D view chunk URL");
1869
- const url = new URL("./seatlayer-view3d.mjs", base).href;
1870
1905
  return import(
1871
1906
  /* @vite-ignore */
1872
- url
1907
+ cdnChunkUrl("seatlayer-view3d.mjs")
1873
1908
  );
1874
1909
  }
1875
1910
  return import("@seatlayer/core/view3d");
1876
1911
  }
1912
+ async function loadPanorama() {
1913
+ if (typeof __SEATLAYER_CDN__ !== "undefined" && __SEATLAYER_CDN__) {
1914
+ return import(
1915
+ /* @vite-ignore */
1916
+ cdnChunkUrl("seatlayer-panorama.mjs")
1917
+ );
1918
+ }
1919
+ return import("@seatlayer/core");
1920
+ }
1921
+ async function loadHostedCheckout() {
1922
+ if (typeof __SEATLAYER_CDN__ !== "undefined" && __SEATLAYER_CDN__) {
1923
+ return import(
1924
+ /* @vite-ignore */
1925
+ cdnChunkUrl("seatlayer-checkout.mjs")
1926
+ );
1927
+ }
1928
+ return import("./hostedCheckout-PLHKPT6W.js");
1929
+ }
1930
+ function paymentsOffReason(reason) {
1931
+ return reason === "unavailable_for_event" || reason === "payments_off_for_event" ? reason : "not_configured";
1932
+ }
1877
1933
  var STYLE_ID = "seatlayer-picker-style";
1878
- var CSS = `
1934
+ var CSS = (
1935
+ /* @sl-css */
1936
+ `
1879
1937
  .sl-picker{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:420px;overflow:hidden;
1880
1938
  background:var(--sl-bg);color:var(--sl-text);font-family:var(--sl-font);border-radius:var(--sl-radius);
1881
1939
  --sl-r-sm:calc(var(--sl-radius) * .55);
@@ -2612,7 +2670,8 @@ var CSS = `
2612
2670
  .sl-modal-scrim{position:fixed;inset:0;z-index:2147483000;background:rgba(5,7,12,.66);display:flex;align-items:center;justify-content:center;padding:18px}
2613
2671
  .sl-modal-frame{width:min(1200px,100%);height:min(820px,100%);border-radius:16px;overflow:hidden;box-shadow:0 40px 120px -30px rgba(0,0,0,.8)}
2614
2672
  @media(max-width:640px){.sl-modal-scrim{padding:0}.sl-modal-frame{width:100%;height:100%;border-radius:0}}
2615
- `;
2673
+ `
2674
+ );
2616
2675
  function ensureStyle() {
2617
2676
  if (document.getElementById(STYLE_ID)) return;
2618
2677
  const el = document.createElement("style");
@@ -2677,6 +2736,14 @@ var SeatPicker = class _SeatPicker {
2677
2736
  this.handedOff = false;
2678
2737
  /** Guards single onBooked + single success overlay per hold. */
2679
2738
  this.bookedShown = false;
2739
+ /**
2740
+ * In-flight or settled `payment-options` for this event, started at render in
2741
+ * hosted mode. One request, kicked off while the buyer is still choosing, so
2742
+ * pressing Pay does not wait on a lookup whose answer never changes mid-session.
2743
+ */
2744
+ this.paymentOptions = null;
2745
+ /** The mounted payment card, while one is up. */
2746
+ this.checkoutPanel = null;
2680
2747
  this.extendEl = null;
2681
2748
  this.bookedEl = null;
2682
2749
  this.gaQty = /* @__PURE__ */ new Map();
@@ -2791,6 +2858,12 @@ var SeatPicker = class _SeatPicker {
2791
2858
  onObjectUnavailable: (event) => this.opts.onSelectedObjectUnavailable?.(event)
2792
2859
  });
2793
2860
  this.api = options.transport ?? this.pubApi;
2861
+ if (options.checkout === "hosted" && !this.pubApi) {
2862
+ console.warn(
2863
+ 'seatlayer: checkout: "hosted" needs the widget\'s own transport \u2014 a custom `transport` owns its backend, so the picker is staying on onCheckout for this mount.'
2864
+ );
2865
+ }
2866
+ this.checkoutMode = options.checkout === "hosted" && this.pubApi ? "hosted" : "handoff";
2794
2867
  this.maxTickets = Math.max(1, Math.floor(options.maxSelection ?? DEFAULT_MAX_SELECTION2));
2795
2868
  this.cbSafe = readStoredColorblind() ?? !!options.colorblindSafe;
2796
2869
  this.controller = new PickerController2({
@@ -3154,6 +3227,7 @@ var SeatPicker = class _SeatPicker {
3154
3227
  ensureStyle();
3155
3228
  await loadLocale2(this.opts.locale);
3156
3229
  if (this.opts.messages) setStringOverrides2(this.opts.messages);
3230
+ if (this.checkoutMode === "hosted") this.paymentOptions = this.pubApi.paymentOptions(this.opts.event);
3157
3231
  const mount = resolveContainer3(this.opts.container);
3158
3232
  const root = document.createElement("div");
3159
3233
  root.className = "sl-picker";
@@ -3451,8 +3525,40 @@ var SeatPicker = class _SeatPicker {
3451
3525
  if (this.salesClosed) this.applySalesClosed();
3452
3526
  this.syncPrices();
3453
3527
  this.syncTray();
3528
+ this.resumeHostedOrder();
3454
3529
  return this;
3455
3530
  }
3531
+ /**
3532
+ * A hosted gateway returned this buyer to a page that runs the widget, with
3533
+ * `?order=…&status=…` in the URL. Pick the order up and finish the story.
3534
+ *
3535
+ * Only `success` resumes. `cancelled` means the buyer backed out at the
3536
+ * gateway and their seats are still held — the map they are looking at IS the
3537
+ * right screen, and opening a card to say "you cancelled" would be noise.
3538
+ *
3539
+ * The two parameters are then stripped with `replaceState`, because they are a
3540
+ * one-shot instruction: leaving them in place would re-open the confirmation
3541
+ * on every later navigation, and would carry an order id into browser history
3542
+ * and any Referer this page later sends. `status` is only ever removed
3543
+ * alongside an `order` we actually consumed, so a host page that uses a
3544
+ * `status` parameter of its own keeps it.
3545
+ */
3546
+ resumeHostedOrder() {
3547
+ if (this.checkoutMode !== "hosted" || typeof location === "undefined") return;
3548
+ const params = new URLSearchParams(location.search);
3549
+ const orderId = params.get("order");
3550
+ if (!orderId) return;
3551
+ const status = params.get("status");
3552
+ params.delete("order");
3553
+ params.delete("status");
3554
+ try {
3555
+ const query = params.toString();
3556
+ history.replaceState(history.state, "", `${location.pathname}${query ? `?${query}` : ""}${location.hash}`);
3557
+ } catch {
3558
+ }
3559
+ if (status !== "success") return;
3560
+ void this.openCheckoutPanel({ kind: "resume", orderId });
3561
+ }
3456
3562
  /**
3457
3563
  * Move layout-dependent chrome between its wide dock (map regions / zoom
3458
3564
  * column) and its narrow dock (the sheet's consolidated Filters row), and
@@ -4432,7 +4538,7 @@ var SeatPicker = class _SeatPicker {
4432
4538
  this.els.map.appendChild(el);
4433
4539
  this.confirmEl = el;
4434
4540
  this.reanchorConfirm();
4435
- el.querySelector(".sl-confirm-view")?.addEventListener("click", () => this.openSeatView(seat));
4541
+ el.querySelector(".sl-confirm-view")?.addEventListener("click", () => void this.openSeatView(seat));
4436
4542
  el.querySelector(".sl-confirm-3d")?.addEventListener("click", () => {
4437
4543
  if (this.buyerView === "venue3d") {
4438
4544
  void this.view3dHandle?.flyToSeat(seat.id);
@@ -4503,10 +4609,13 @@ var SeatPicker = class _SeatPicker {
4503
4609
  * uploaded photo (seat.viewUrl) when present, else a panorama generated from
4504
4610
  * the chart geometry — the stage placed at this seat's true bearing + size.
4505
4611
  * Zero extra dependencies: an equirectangular image panned with `repeat-x`.
4612
+ *
4613
+ * Async only because the generator is a lazy chunk (see `loadPanorama`); an
4614
+ * organizer photo needs no generator and never waits on it. The two callers
4615
+ * are click handlers, so nothing observes the promise.
4506
4616
  */
4507
- openSeatView(seat) {
4617
+ async openSeatView(seat) {
4508
4618
  if (!this.root || !this.seatViewEnabled()) return;
4509
- this.closeSeatView();
4510
4619
  const doc = this.controller.doc;
4511
4620
  const activeId = this.controller.getActiveFloorId();
4512
4621
  const focal = seat.focalPoint ?? doc?.floors?.find((f) => f.id === activeId)?.focalPoint ?? doc?.focalPoint ?? { x: 0, y: 0 };
@@ -4518,10 +4627,19 @@ var SeatPicker = class _SeatPicker {
4518
4627
  caption = t2("picker.panorama360");
4519
4628
  real = true;
4520
4629
  } else {
4521
- const pano2 = generateSeatPanorama(seat, focal, this.allSeats());
4630
+ let pano2;
4631
+ try {
4632
+ const { generateSeatPanorama } = await loadPanorama();
4633
+ pano2 = generateSeatPanorama(seat, focal, this.allSeats());
4634
+ } catch (err) {
4635
+ this.opts.onError?.(err);
4636
+ return;
4637
+ }
4638
+ if (!this.root || !this.seatViewEnabled()) return;
4522
4639
  panoUrl = pano2.url;
4523
4640
  caption = t2("picker.illustrationCaption", { m: pano2.distanceM });
4524
4641
  }
4642
+ this.closeSeatView();
4525
4643
  const el = document.createElement("div");
4526
4644
  el.className = "sl-view";
4527
4645
  el.setAttribute("role", "dialog");
@@ -4887,7 +5005,7 @@ var SeatPicker = class _SeatPicker {
4887
5005
  this.els.tray.querySelectorAll(".sl-chip .view[data-view-label]").forEach((btn) => {
4888
5006
  btn.addEventListener("click", () => {
4889
5007
  const seat = this.controller.seatByLabel(btn.dataset.viewLabel);
4890
- if (seat) this.openSeatView(seat);
5008
+ if (seat) void this.openSeatView(seat);
4891
5009
  });
4892
5010
  });
4893
5011
  this.els.tray.querySelectorAll(".sl-chip[data-locate]").forEach((chip) => {
@@ -5030,7 +5148,7 @@ var SeatPicker = class _SeatPicker {
5030
5148
  const seats = this.hold.seats ?? committed;
5031
5149
  this.handedOff = true;
5032
5150
  this.setCtaPhase("checkout");
5033
- this.opts.onCheckout?.(this.hold, seats, this.buildHandoff(this.hold));
5151
+ this.checkoutHandoff(this.hold, seats);
5034
5152
  return;
5035
5153
  }
5036
5154
  this.holdingLabels = new Set(committed.map((seat) => seat.label));
@@ -5059,7 +5177,7 @@ var SeatPicker = class _SeatPicker {
5059
5177
  this.flashHeldSeats(hold);
5060
5178
  this.setCtaPhase("checkout");
5061
5179
  this.emitHoldChange();
5062
- this.opts.onCheckout?.(hold, hold.seats ?? chosenSeats, this.buildHandoff(hold));
5180
+ this.checkoutHandoff(hold, hold.seats ?? chosenSeats);
5063
5181
  } catch (err) {
5064
5182
  this.opts.onError?.(err);
5065
5183
  const problem = err;
@@ -5164,6 +5282,109 @@ var SeatPicker = class _SeatPicker {
5164
5282
  this.bookedEl?.classList.add("on");
5165
5283
  this.opts.onBooked?.(handoff);
5166
5284
  }
5285
+ /**
5286
+ * The seats are held. Send the buyer wherever this picker's `checkout` option
5287
+ * says they go.
5288
+ *
5289
+ * The default branch is the literal call that stood here before hosted
5290
+ * checkout existed, unchanged, so nothing about an existing integration moves.
5291
+ */
5292
+ checkoutHandoff(hold, seats) {
5293
+ if (this.checkoutMode === "hosted") {
5294
+ void this.startHostedCheckout(hold, seats);
5295
+ return;
5296
+ }
5297
+ this.opts.onCheckout?.(hold, seats, this.buildHandoff(hold));
5298
+ }
5299
+ /**
5300
+ * Take the money ourselves, through the organizer's own gateway.
5301
+ *
5302
+ * Order of operations matters: ASK FIRST, load second. `payment-options` is
5303
+ * already in flight from render, and its answer decides whether any payment
5304
+ * code is fetched at all — an event that cannot charge never downloads the
5305
+ * card that would have charged it.
5306
+ *
5307
+ * An empty list is not a failure and never dead-ends the buyer. It routes them
5308
+ * to whatever the host has: `onCheckoutUnavailable` (with the server's reason,
5309
+ * so the host can say the right one of three very different sentences), then
5310
+ * `onCheckout` with the ordinary handoff. A host that supplied neither gets
5311
+ * the widget's own honest card instead of a press that did nothing.
5312
+ */
5313
+ async startHostedCheckout(hold, seats) {
5314
+ const handoff = this.buildHandoff(hold);
5315
+ let options = null;
5316
+ try {
5317
+ options = await (this.paymentOptions ??= this.pubApi.paymentOptions(this.opts.event));
5318
+ } catch (err) {
5319
+ this.opts.onError?.(err);
5320
+ }
5321
+ if (this.destroyed) return;
5322
+ const provider = options?.providers?.[0];
5323
+ if (!provider) {
5324
+ const reason = paymentsOffReason(options?.reason);
5325
+ const handled = !!this.opts.onCheckoutUnavailable || !!this.opts.onCheckout;
5326
+ this.opts.onCheckoutUnavailable?.({ reason, handoff });
5327
+ this.opts.onCheckout?.(hold, seats, handoff);
5328
+ if (!handled) {
5329
+ void this.openCheckoutPanel({
5330
+ kind: "unavailable",
5331
+ reason,
5332
+ seatCount: handoff.lineItems.reduce((sum, item) => sum + item.quantity, 0)
5333
+ });
5334
+ }
5335
+ return;
5336
+ }
5337
+ await this.openCheckoutPanel({
5338
+ kind: "pay",
5339
+ provider,
5340
+ order: {
5341
+ holdId: handoff.holdId,
5342
+ expiresAt: handoff.expiresAt,
5343
+ currency: handoff.currency,
5344
+ total: handoff.total,
5345
+ labels: handoff.lineItems.map((item) => item.displayLabel ?? item.label)
5346
+ }
5347
+ });
5348
+ }
5349
+ /**
5350
+ * Fetch the checkout chunk and put its card over the map.
5351
+ *
5352
+ * Every failure here lands the buyer back on a map with their seats still
5353
+ * held, which is a place they can act from — a blocked chunk request must not
5354
+ * leave them staring at a CTA that no longer does anything.
5355
+ */
5356
+ async openCheckoutPanel(state) {
5357
+ let mountCheckout;
5358
+ try {
5359
+ ({ mountCheckout } = await loadHostedCheckout());
5360
+ } catch (err) {
5361
+ this.opts.onError?.(err);
5362
+ this.toast("Checkout could not be opened. Your seats are still held \u2014 please try again.", "error");
5363
+ this.setCtaPhase("idle");
5364
+ return;
5365
+ }
5366
+ if (this.destroyed || !this.root) return;
5367
+ this.closeCheckoutPanel();
5368
+ this.checkoutPanel = mountCheckout({
5369
+ root: this.root,
5370
+ state,
5371
+ startSession: (input) => this.pubApi.startCheckout(this.opts.event, input),
5372
+ orderStatus: (orderId) => this.pubApi.orderStatus(orderId),
5373
+ onCancel: () => {
5374
+ this.checkoutPanel = null;
5375
+ if (!this.hold) this.setCtaPhase("idle");
5376
+ },
5377
+ onConfirmed: (order) => {
5378
+ this.opts.onOrderConfirmed?.(order);
5379
+ void this.controller.refresh();
5380
+ },
5381
+ onError: (err) => this.opts.onError?.(err)
5382
+ });
5383
+ }
5384
+ closeCheckoutPanel() {
5385
+ this.checkoutPanel?.destroy();
5386
+ this.checkoutPanel = null;
5387
+ }
5167
5388
  /** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
5168
5389
  buildHandoff(hold) {
5169
5390
  const items = hold.items ?? [];
@@ -5398,7 +5619,7 @@ var SeatPicker = class _SeatPicker {
5398
5619
  }
5399
5620
  /** Build the view-from-seat panorama the cinematic dissolves into — reuses the
5400
5621
  * exact input path as the 2D `openSeatView` (organizer photo, else generated). */
5401
- seatViewFor3d(seatId) {
5622
+ async seatViewFor3d(seatId) {
5402
5623
  const seat = this.allSeats().find((s) => s.id === seatId);
5403
5624
  if (!seat) return null;
5404
5625
  if (seat.viewUrl) return { url: seat.viewUrl };
@@ -5407,6 +5628,7 @@ var SeatPicker = class _SeatPicker {
5407
5628
  const activeId = this.controller.getActiveFloorId();
5408
5629
  const focal = seat.focalPoint ?? doc.floors?.find((f) => f.id === activeId)?.focalPoint ?? doc.focalPoint ?? { x: 0, y: 0 };
5409
5630
  try {
5631
+ const { generateSeatPanorama } = await loadPanorama();
5410
5632
  return { url: generateSeatPanorama(seat, focal, this.allSeats()).url };
5411
5633
  } catch {
5412
5634
  return null;
@@ -5546,9 +5768,10 @@ var SeatPicker = class _SeatPicker {
5546
5768
  // in orbit instead of dissolving into an empty overlay.
5547
5769
  getSeatView: (id) => new Promise((resolve, reject) => {
5548
5770
  const run = () => {
5549
- const view = this.seatViewFor3d(id);
5550
- if (view) resolve(view);
5551
- else reject(new Error("seat_view_unavailable"));
5771
+ void this.seatViewFor3d(id).then((view) => {
5772
+ if (view) resolve(view);
5773
+ else reject(new Error("seat_view_unavailable"));
5774
+ });
5552
5775
  };
5553
5776
  const ric = globalThis.requestIdleCallback;
5554
5777
  if (typeof ric === "function") ric(run, { timeout: 1500 });
@@ -5826,6 +6049,7 @@ var SeatPicker = class _SeatPicker {
5826
6049
  this.closeConfirm();
5827
6050
  this.dismissTableDialog(false);
5828
6051
  this.closeSeatView();
6052
+ this.closeCheckoutPanel();
5829
6053
  this.exit3d();
5830
6054
  this.stopHoldTimer();
5831
6055
  if (this.toastTimer) clearTimeout(this.toastTimer);
@@ -6547,7 +6771,14 @@ var ManageApi = class {
6547
6771
  var POLL_MS = 1e4;
6548
6772
  var MAX_FLAGS = 8;
6549
6773
  var SEAT_LIST_PAGE = 300;
6550
- var CHANNELS_CSS = `
6774
+ var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
6775
+ var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
6776
+ var PREVIEW_UNAVAILABLE_FILL = "#303846";
6777
+ var PREVIEW_UNAVAILABLE_STROKE = "#4b5669";
6778
+ var ALLOCATION_STROKE = "#101723";
6779
+ var CHANNELS_CSS = (
6780
+ /* @sl-css */
6781
+ `
6551
6782
  .slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
6552
6783
  --slm-mo-out:cubic-bezier(.2,.8,.2,1);--slm-mo-in-out:cubic-bezier(.4,0,.2,1);--slm-mo-exit:cubic-bezier(.4,0,1,1);
6553
6784
  --slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
@@ -6560,6 +6791,8 @@ var CHANNELS_CSS = `
6560
6791
  background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
6561
6792
  transform:translate(-50%,-50%);white-space:nowrap}
6562
6793
  .slm-ch-flag .mk{width:14px;height:14px;border-radius:4px;display:grid;place-items:center;font-size:8.5px;font-weight:800;color:#0e1017}
6794
+ .slm-ch-section-target{position:absolute;pointer-events:auto;padding:0;border:0;border-radius:8px;background:transparent;cursor:zoom-in}
6795
+ .slm-ch-section-target:focus-visible{outline:2px solid var(--slm-accent);outline-offset:-3px;background:color-mix(in srgb,var(--slm-accent) 12%,transparent)}
6563
6796
 
6564
6797
  /* preview banner \u2014 raised with the organizer chrome dim, as one transition */
6565
6798
  .slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
@@ -6579,7 +6812,7 @@ var CHANNELS_CSS = `
6579
6812
  transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
6580
6813
  .slm-ch-staged.on{transform:none;opacity:1}
6581
6814
  .slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
6582
- .slm-ch-staged.shake{animation:slm-ch-shake 320ms var(--slm-mo-in-out) 2}
6815
+ .slm-ch-staged.shake{animation:slm-ch-shake var(--slm-mo-slow) var(--slm-mo-in-out) 2}
6583
6816
  .slm-ch-staged b{font-variant-numeric:tabular-nums}
6584
6817
  .slm-ch-staged .grow{flex:1}
6585
6818
  .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
@@ -6596,6 +6829,11 @@ var CHANNELS_CSS = `
6596
6829
  .slm-ch-viewseg button{flex:1;padding:6px 8px;min-height:34px;border-radius:7px;font-size:11px;font-weight:800;color:var(--slm-muted)}
6597
6830
  .slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
6598
6831
  .slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
6832
+ .slm-ch-mapnav{margin:-2px 0 12px;padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
6833
+ .slm-ch-mapnav-head{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--slm-muted)}
6834
+ .slm-ch-mapnav-head button{color:var(--slm-accent);font-size:11px;font-weight:800;letter-spacing:0;text-transform:none;min-height:30px}
6835
+ .slm-ch-mapnav .slm-ch-viewseg{margin:8px 0 5px}
6836
+ .slm-ch-mapnav p{margin:0;font-size:11px;line-height:1.45;color:var(--slm-muted)}
6599
6837
  .slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
6600
6838
  .slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6601
6839
  text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
@@ -6616,7 +6854,7 @@ var CHANNELS_CSS = `
6616
6854
  font-variant-numeric:tabular-nums}
6617
6855
  .slm-ch-counts b{color:var(--slm-text);font-weight:800}
6618
6856
  .slm-ch-counts .free b{color:#5bd39b}
6619
- .slm-ch-counts b.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6857
+ .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6620
6858
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
6621
6859
  .slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
6622
6860
  .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
@@ -6626,12 +6864,13 @@ var CHANNELS_CSS = `
6626
6864
  font-weight:800;color:#0e1017}
6627
6865
  .slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
6628
6866
  .slm-ch-selsrc-row span{color:var(--slm-muted)}
6629
- .slm-ch-selnum.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6867
+ .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6630
6868
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
6631
6869
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
6632
6870
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
6633
6871
  line-height:1.5;margin-bottom:12px}
6634
6872
  .slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
6873
+ .slm-ch-alert.info{background:rgba(110,123,255,.12);border:1px solid rgba(110,123,255,.44);color:#c5cbff}
6635
6874
  .slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
6636
6875
  .slm-ch-alert b{color:#fff}
6637
6876
  .slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
@@ -6728,7 +6967,8 @@ var CHANNELS_CSS = `
6728
6967
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
6729
6968
  .slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
6730
6969
  }
6731
- `;
6970
+ `
6971
+ );
6732
6972
  function bucketRowsHtml(rows) {
6733
6973
  return rows.map((row, index) => `
6734
6974
  <div class="slm-ch-bucket" style="animation-delay:${Math.min(index, 4) * 30}ms">
@@ -6778,6 +7018,10 @@ var ChannelsMode = class {
6778
7018
  this.loadError = null;
6779
7019
  this.loading = true;
6780
7020
  this.view = "inspect";
7021
+ /** Pan is intentionally the initial desktop interaction. Assignment's
7022
+ * marquee is powerful, but must never make an organizer lose map navigation. */
7023
+ this.mapIntent = "pan";
7024
+ this.focusedSectionId = null;
6781
7025
  this.showArchived = false;
6782
7026
  this.detailChannelId = null;
6783
7027
  this.targetChannelId = "";
@@ -6794,6 +7038,15 @@ var ChannelsMode = class {
6794
7038
  this.links = [];
6795
7039
  this.linksChannelId = null;
6796
7040
  this.linksState = "idle";
7041
+ /**
7042
+ * Monotonic read generations — one for the channel list + allocation, one for
7043
+ * the open channel's links. Reads are concurrent (a 10s poll versus a
7044
+ * mutation's own reload), and the network does not promise to answer them in
7045
+ * order. Only the NEWEST read of each kind may write to state; an older
7046
+ * answer that arrives late is dropped, never painted.
7047
+ */
7048
+ this.listSeq = 0;
7049
+ this.linksSeq = 0;
6797
7050
  this.previewAudience = [];
6798
7051
  this.previewIncludePublic = false;
6799
7052
  this.previewProjection = null;
@@ -6820,10 +7073,14 @@ var ChannelsMode = class {
6820
7073
  enter() {
6821
7074
  if (this.active) return;
6822
7075
  this.active = true;
7076
+ this.mapIntent = "pan";
7077
+ this.focusedSectionId = null;
6823
7078
  this.ensureLayer();
6824
7079
  this.host.root.classList.add("ch-mode");
6825
7080
  this.applySheetClasses();
7081
+ if (this.host.sections().length > 1) this.host.showSectionOverview();
6826
7082
  this.paintRail();
7083
+ this.onInteractionChange?.();
6827
7084
  void this.refresh();
6828
7085
  this.pollTimer = setInterval(() => {
6829
7086
  void this.refresh({ quiet: true });
@@ -6875,6 +7132,17 @@ var ChannelsMode = class {
6875
7132
  canSelect() {
6876
7133
  return this.caps.manage && this.view === "inspect";
6877
7134
  }
7135
+ /** Bulk seat assignment is explicit. In Pan map, clicks can still inspect a
7136
+ * single seat, while a primary-button drag always moves the camera. */
7137
+ usesMarqueeSelection() {
7138
+ return this.canSelect() && this.mapIntent === "assign";
7139
+ }
7140
+ /** The renderer calls this when the organizer opens a section from overview. */
7141
+ handleSectionFocus(sectionId) {
7142
+ if (!this.active) return;
7143
+ this.focusedSectionId = sectionId;
7144
+ this.paintRail();
7145
+ }
6878
7146
  /**
6879
7147
  * Organizer realtime integration point. M5 ships a per-scope socket for
6880
7148
  * buyers; the organizer channel-count stream is a later milestone. When it
@@ -6902,22 +7170,28 @@ var ChannelsMode = class {
6902
7170
  // ---- data -----------------------------------------------------------------
6903
7171
  async refresh(opts = {}) {
6904
7172
  if (!this.caps.view) return;
7173
+ const seq = ++this.listSeq;
7174
+ const superseded = () => seq !== this.listSeq;
6905
7175
  try {
6906
7176
  const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
7177
+ if (superseded()) return;
6907
7178
  this.list = list;
6908
7179
  this.assignmentVersion = list.assignmentVersion;
6909
7180
  this.loadError = null;
6910
7181
  if (!this.targetChannelId) {
6911
7182
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
6912
7183
  }
6913
- await this.loadAllocation();
7184
+ await this.loadAllocation(seq);
7185
+ if (superseded()) return;
6914
7186
  if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
7187
+ if (superseded()) return;
6915
7188
  this.loading = false;
6916
7189
  if (this.active) {
6917
7190
  this.paintRail();
6918
7191
  this.paintOverlay();
6919
7192
  }
6920
7193
  } catch (err) {
7194
+ if (superseded()) return;
6921
7195
  this.loading = false;
6922
7196
  if (err instanceof ManageApiError && err.status === 403) {
6923
7197
  this.caps = { view: false, manage: false };
@@ -6929,10 +7203,11 @@ var ChannelsMode = class {
6929
7203
  }
6930
7204
  /** Walk every allocation page. Bounded by the event's seat count, and the
6931
7205
  * server caps each page, so an arena is a handful of round trips. */
6932
- async loadAllocation() {
7206
+ async loadAllocation(seq) {
6933
7207
  const next = /* @__PURE__ */ new Map();
6934
7208
  let afterLabel;
6935
7209
  for (let page = 0; page < 200; page += 1) {
7210
+ if (seq !== void 0 && seq !== this.listSeq) return;
6936
7211
  const res = await this.host.api.channelAllocation(this.host.eventKey, {
6937
7212
  afterLabel,
6938
7213
  limit: 1e3
@@ -6944,6 +7219,7 @@ var ChannelsMode = class {
6944
7219
  if (!res.nextAfterLabel) break;
6945
7220
  afterLabel = res.nextAfterLabel;
6946
7221
  }
7222
+ if (seq !== void 0 && seq !== this.listSeq) return;
6947
7223
  this.allocation = next;
6948
7224
  }
6949
7225
  // ---- lookups --------------------------------------------------------------
@@ -7010,9 +7286,10 @@ var ChannelsMode = class {
7010
7286
  * Repaint the allocation (or preview) overlay in ONE canvas pass.
7011
7287
  *
7012
7288
  * Channel identity on the map is a fill in the administrative color PLUS the
7013
- * letter flags below — never color alone. Physical status keeps its own cue:
7014
- * only FREE units take a channel fill, so sold/held/blocked seats still read
7015
- * exactly as they do in every other tool.
7289
+ * letter flags below — never color alone. In buyer preview the map instead
7290
+ * uses two explicit, channel-neutral access states. Physical status keeps its
7291
+ * own cue: only FREE units are repainted, so sold/held/blocked seats still
7292
+ * read exactly as they do in every other tool.
7016
7293
  */
7017
7294
  paintOverlay() {
7018
7295
  const canvas = this.canvas;
@@ -7037,11 +7314,14 @@ var ChannelsMode = class {
7037
7314
  if (!ctx) return;
7038
7315
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
7039
7316
  ctx.clearRect(0, 0, width, height);
7317
+ const seatDetail = this.host.isSeatDetail();
7040
7318
  const size = Math.max(3, this.host.seatPixelSize());
7041
7319
  const half = size / 2;
7042
7320
  const projection = this.view === "preview" ? this.previewProjection : null;
7043
7321
  const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
7044
7322
  const clusters = /* @__PURE__ */ new Map();
7323
+ const sectionTargets = !seatDetail && this.host.sections().length > 1 ? /* @__PURE__ */ new Map() : null;
7324
+ const previewSections = this.view === "preview" && seatDetail && size <= 15 ? /* @__PURE__ */ new Map() : null;
7045
7325
  for (const seat of this.host.seats()) {
7046
7326
  const status = this.host.statusOf(seat.label) ?? "free";
7047
7327
  const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
@@ -7052,24 +7332,150 @@ var ChannelsMode = class {
7052
7332
  cluster.n += 1;
7053
7333
  clusters.set(channelId, cluster);
7054
7334
  }
7335
+ if (!seatDetail) {
7336
+ const section = this.host.sectionOfLabel(seat.label);
7337
+ const point2 = section ? this.host.worldToScreen({ x: seat.x, y: seat.y }) : null;
7338
+ if (sectionTargets && section && point2) {
7339
+ const bounds = sectionTargets.get(section.id) ?? {
7340
+ label: section.label,
7341
+ minX: point2.x,
7342
+ minY: point2.y,
7343
+ maxX: point2.x,
7344
+ maxY: point2.y
7345
+ };
7346
+ bounds.minX = Math.min(bounds.minX, point2.x);
7347
+ bounds.minY = Math.min(bounds.minY, point2.y);
7348
+ bounds.maxX = Math.max(bounds.maxX, point2.x);
7349
+ bounds.maxY = Math.max(bounds.maxY, point2.y);
7350
+ sectionTargets.set(section.id, bounds);
7351
+ }
7352
+ continue;
7353
+ }
7055
7354
  if (status !== "free") continue;
7056
7355
  let fill = null;
7356
+ let stroke = null;
7057
7357
  if (this.view === "preview") {
7058
- fill = eligible ? eligible.has(seat.label) ? null : "#3a4051" : null;
7358
+ if (eligible?.has(seat.label)) {
7359
+ fill = PREVIEW_ELIGIBLE_FILL;
7360
+ stroke = PREVIEW_ELIGIBLE_STROKE;
7361
+ } else {
7362
+ fill = PREVIEW_UNAVAILABLE_FILL;
7363
+ stroke = PREVIEW_UNAVAILABLE_STROKE;
7364
+ }
7059
7365
  } else if (channelId !== PUBLIC_CHANNEL_ID) {
7060
7366
  fill = this.markerFor(channelId).color;
7367
+ stroke = ALLOCATION_STROKE;
7061
7368
  }
7062
7369
  if (!fill) continue;
7063
7370
  const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
7064
7371
  if (!point) continue;
7065
7372
  if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
7373
+ if (previewSections) {
7374
+ const section = this.host.sectionOfLabel(seat.label);
7375
+ if (section) {
7376
+ const bounds = previewSections.get(section.id) ?? {
7377
+ label: section.label,
7378
+ minX: point.x,
7379
+ minY: point.y,
7380
+ maxX: point.x,
7381
+ maxY: point.y
7382
+ };
7383
+ bounds.minX = Math.min(bounds.minX, point.x);
7384
+ bounds.minY = Math.min(bounds.minY, point.y);
7385
+ bounds.maxX = Math.max(bounds.maxX, point.x);
7386
+ bounds.maxY = Math.max(bounds.maxY, point.y);
7387
+ previewSections.set(section.id, bounds);
7388
+ }
7389
+ }
7066
7390
  ctx.fillStyle = fill;
7067
- ctx.globalAlpha = this.view === "preview" ? 0.9 : 0.85;
7068
- ctx.fillRect(point.x - half, point.y - half, size, size);
7391
+ if (this.view === "preview" || channelId !== PUBLIC_CHANNEL_ID) {
7392
+ const radius = Math.max(2, half + Math.min(1.5, half * 0.06));
7393
+ ctx.globalAlpha = 1;
7394
+ ctx.beginPath();
7395
+ ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
7396
+ ctx.fill();
7397
+ ctx.strokeStyle = stroke ?? fill;
7398
+ ctx.lineWidth = this.view === "preview" ? Math.max(1, Math.min(1.75, size * 0.13)) : Math.max(1, Math.min(1.5, size * 0.1));
7399
+ ctx.stroke();
7400
+ if (this.view === "preview" && eligible?.has(seat.label) && size >= 22) {
7401
+ this.paintPreviewSeatLabel(ctx, seat.label, point.x, point.y, radius);
7402
+ }
7403
+ } else {
7404
+ ctx.globalAlpha = 0.85;
7405
+ ctx.fillRect(point.x - half, point.y - half, size, size);
7406
+ }
7069
7407
  }
7070
7408
  ctx.globalAlpha = 1;
7409
+ if (previewSections) this.paintPreviewSectionLabels(ctx, previewSections);
7410
+ this.paintSectionTargets(sectionTargets);
7071
7411
  this.paintFlags(clusters);
7072
7412
  }
7413
+ /** Draw an eligible seat's actual chart label without inventing a new buyer
7414
+ * identifier. Long labels scale down and are omitted rather than overflowing
7415
+ * into an adjacent seat. */
7416
+ paintPreviewSeatLabel(ctx, label, x, y, radius) {
7417
+ const maxWidth = radius * 1.55;
7418
+ let fontSize = Math.min(13, Math.max(7, radius * 0.55));
7419
+ const minFontSize = 6;
7420
+ while (fontSize >= minFontSize) {
7421
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7422
+ if (ctx.measureText(label).width <= maxWidth) break;
7423
+ fontSize -= 0.5;
7424
+ }
7425
+ if (fontSize < minFontSize) return;
7426
+ ctx.fillStyle = "#ffffff";
7427
+ ctx.textAlign = "center";
7428
+ ctx.textBaseline = "middle";
7429
+ ctx.fillText(label, x, y);
7430
+ }
7431
+ /** A section overview is a navigation map. These transparent, keyboardable
7432
+ * hit areas sit over the renderer's section shells so both mouse and keyboard
7433
+ * always take the organizer into the real focused-section camera state. */
7434
+ paintSectionTargets(sections) {
7435
+ const layer = this.layer;
7436
+ if (!layer) return;
7437
+ layer.querySelectorAll(".slm-ch-section-target").forEach((el) => el.remove());
7438
+ if (!sections) return;
7439
+ for (const [id, section] of sections) {
7440
+ const width = section.maxX - section.minX;
7441
+ const height = section.maxY - section.minY;
7442
+ if (width < 20 || height < 20) continue;
7443
+ const target = document.createElement("button");
7444
+ target.type = "button";
7445
+ target.className = "slm-ch-section-target";
7446
+ target.style.left = `${section.minX - 8}px`;
7447
+ target.style.top = `${section.minY - 8}px`;
7448
+ target.style.width = `${width + 16}px`;
7449
+ target.style.height = `${height + 16}px`;
7450
+ target.setAttribute("aria-label", `Open ${section.label} seats`);
7451
+ target.addEventListener("click", () => {
7452
+ this.focusedSectionId = id;
7453
+ this.host.focusSection(id);
7454
+ this.paintRail();
7455
+ });
7456
+ layer.appendChild(target);
7457
+ }
7458
+ }
7459
+ /** Keep renderer section names legible over a dense, zoomed-out preview. */
7460
+ paintPreviewSectionLabels(ctx, sections) {
7461
+ for (const section of sections.values()) {
7462
+ const width = section.maxX - section.minX;
7463
+ const height = section.maxY - section.minY;
7464
+ if (width < 52 || height < 26) continue;
7465
+ const centerX = (section.minX + section.maxX) / 2;
7466
+ const centerY = (section.minY + section.maxY) / 2;
7467
+ const fontSize = Math.max(11, Math.min(15, height * 0.16));
7468
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7469
+ const labelWidth = Math.min(width - 8, ctx.measureText(section.label).width + 18);
7470
+ const labelHeight = fontSize + 10;
7471
+ ctx.fillStyle = "rgba(11, 16, 28, .88)";
7472
+ ctx.fillRect(centerX - labelWidth / 2, centerY - labelHeight / 2, labelWidth, labelHeight);
7473
+ ctx.fillStyle = "#f8fafc";
7474
+ ctx.textAlign = "center";
7475
+ ctx.textBaseline = "middle";
7476
+ ctx.fillText(section.label, centerX, centerY);
7477
+ }
7478
+ }
7073
7479
  /** Letter flags at each channel's centroid — the non-color identity cue. */
7074
7480
  paintFlags(clusters) {
7075
7481
  const layer = this.layer;
@@ -7135,7 +7541,7 @@ var ChannelsMode = class {
7135
7541
  });
7136
7542
  });
7137
7543
  }
7138
- setBanner(on, name = "") {
7544
+ setBanner(on, name = "", eligibleSeats) {
7139
7545
  const banner = this.bannerEl;
7140
7546
  if (!banner) return;
7141
7547
  this.host.root.classList.toggle("ch-preview", on);
@@ -7145,8 +7551,9 @@ var ChannelsMode = class {
7145
7551
  return;
7146
7552
  }
7147
7553
  const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
7554
+ const availability = eligibleSeats == null ? "" : ` \xB7 ${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat" : "seats"} available now`;
7148
7555
  banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
7149
- Previewing buyer access \xB7 ${esc(name)} \xB7 read-only
7556
+ Previewing buyer access \xB7 ${esc(name)}${availability} \xB7 read-only
7150
7557
  <button type="button" data-ch-act="exit-preview">Exit preview</button>`;
7151
7558
  banner.classList.add("on");
7152
7559
  banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
@@ -7181,6 +7588,8 @@ var ChannelsMode = class {
7181
7588
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
7182
7589
  });
7183
7590
  this.previewSupported = true;
7591
+ const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
7592
+ this.setBanner(true, names, eligibleSeats);
7184
7593
  } catch (err) {
7185
7594
  const status = err instanceof ManageApiError ? err.status : 0;
7186
7595
  this.previewSupported = !(status === 404 || status === 405 || status === 501);
@@ -7231,6 +7640,22 @@ var ChannelsMode = class {
7231
7640
  aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
7232
7641
  <button type="button" class="${previewOn.trim()}" data-ch-view="preview"
7233
7642
  aria-pressed="${this.view === "preview"}">Preview buyer access</button>
7643
+ </div>${this.mapNavigationHtml()}`;
7644
+ }
7645
+ mapNavigationHtml() {
7646
+ if (this.host.sections().length < 2) return "";
7647
+ const focused = this.focusedSectionId ? this.host.sections().find((section) => section.id === this.focusedSectionId)?.label ?? "section" : null;
7648
+ const panOn = this.mapIntent === "pan" ? " on" : "";
7649
+ const assignOn = this.mapIntent === "assign" ? " on" : "";
7650
+ const intent = this.view === "inspect" && this.caps.manage ? `<div class="slm-ch-viewseg" role="group" aria-label="Map interaction">
7651
+ <button type="button" class="${panOn.trim()}" data-ch-map="pan" aria-pressed="${this.mapIntent === "pan"}">Pan map</button>
7652
+ <button type="button" class="${assignOn.trim()}" data-ch-map="assign" aria-pressed="${this.mapIntent === "assign"}">Assign seats</button>
7653
+ </div>
7654
+ <p>${this.mapIntent === "pan" ? "Drag to explore. Click a section to open its seats." : "Drag across seats to select them for allocation."}</p>` : "<p>Drag to explore. Click a section to open its seats.</p>";
7655
+ return `<div class="slm-ch-mapnav">
7656
+ <div class="slm-ch-mapnav-head"><span>${focused ? `Viewing ${esc(focused)}` : "Section overview"}</span>
7657
+ <button type="button" data-ch-act="sections">All sections</button></div>
7658
+ ${intent}
7234
7659
  </div>`;
7235
7660
  }
7236
7661
  countsHtml(counts, key) {
@@ -7369,6 +7794,8 @@ var ChannelsMode = class {
7369
7794
  */
7370
7795
  async loadLinks(channelId) {
7371
7796
  if (!this.caps.view) return;
7797
+ const seq = ++this.linksSeq;
7798
+ const superseded = () => seq !== this.linksSeq || this.linksChannelId !== channelId;
7372
7799
  if (this.linksChannelId !== channelId) {
7373
7800
  this.links = [];
7374
7801
  this.linksChannelId = channelId;
@@ -7376,11 +7803,11 @@ var ChannelsMode = class {
7376
7803
  }
7377
7804
  try {
7378
7805
  const res = await this.host.api.accessLinks(this.host.eventKey, channelId);
7379
- if (this.linksChannelId !== channelId) return;
7806
+ if (superseded()) return;
7380
7807
  this.links = res.links ?? [];
7381
7808
  this.linksState = "ready";
7382
7809
  } catch (err) {
7383
- if (this.linksChannelId !== channelId) return;
7810
+ if (superseded()) return;
7384
7811
  const status = err instanceof ManageApiError ? err.status : 0;
7385
7812
  this.links = [];
7386
7813
  this.linksState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
@@ -7457,9 +7884,9 @@ var ChannelsMode = class {
7457
7884
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
7458
7885
  <span><b>This private sale is not available.</b> ${esc((this.previewProjection.unavailable ?? []).map((entry) => `${this.nameOf(entry.channelId) ?? "This channel"} is ${entry.state}`).join("; ") || "The audience cannot buy right now")}.
7459
7886
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
7460
- const counts = this.previewProjection?.counts;
7461
- const summary = counts?.eligible != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>${counts.eligible.toLocaleString()} seats are buyable
7462
- through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
7887
+ const eligibleSeats = this.previewProjection?.counts?.eligible ?? this.previewProjection?.eligible?.length;
7888
+ const summary = eligibleSeats != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert info"><span>\u2713</span><span><b>${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat is" : "seats are"} available now.</b>
7889
+ This is the exact buyer-visible allocation.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this access." : ""}</span></div>` : "";
7463
7890
  const includePublic = isPublicChannelId(current) ? "" : `
7464
7891
  <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
7465
7892
  <input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
@@ -7490,6 +7917,13 @@ var ChannelsMode = class {
7490
7917
  rail.querySelectorAll("[data-ch-view]").forEach((button) => {
7491
7918
  button.addEventListener("click", () => this.setView(button.dataset.chView));
7492
7919
  });
7920
+ rail.querySelectorAll("[data-ch-map]").forEach((button) => {
7921
+ button.addEventListener("click", () => {
7922
+ this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
7923
+ this.paintRail();
7924
+ this.onInteractionChange?.();
7925
+ });
7926
+ });
7493
7927
  rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
7494
7928
  button.addEventListener("click", () => {
7495
7929
  const channelId = button.dataset.chDetail;
@@ -7540,6 +7974,11 @@ var ChannelsMode = class {
7540
7974
  }
7541
7975
  railAction(action) {
7542
7976
  switch (action) {
7977
+ case "sections":
7978
+ this.focusedSectionId = null;
7979
+ this.host.showSectionOverview();
7980
+ this.paintRail();
7981
+ break;
7543
7982
  case "create":
7544
7983
  this.openDialog({ kind: "create" });
7545
7984
  break;
@@ -7714,7 +8153,7 @@ var ChannelsMode = class {
7714
8153
  <label>Marker</label>
7715
8154
  <div style="display:flex;gap:8px;align-items:center">
7716
8155
  <span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
7717
- <span class="slm-note" style="margin:0">Letter + colour suggested from the name. Buyers never see either.</span>
8156
+ <span class="slm-note" style="margin:0">Letter comes from the name; colour is chosen automatically from the next available palette. Buyers never see either.</span>
7718
8157
  </div>
7719
8158
  </div>
7720
8159
  <div class="slm-field">
@@ -8065,6 +8504,24 @@ var ChannelsMode = class {
8065
8504
  await this.loadLinks(channelId);
8066
8505
  this.paintRail();
8067
8506
  }
8507
+ /**
8508
+ * The reload EVERY link mutation owes the panel.
8509
+ *
8510
+ * A create/rotate/revoke changes two things the detail panel renders: the
8511
+ * channel's access line (the server sets `access.intent` on create, and clears
8512
+ * it when the last live link goes) and the link status list. Both are re-read
8513
+ * here and the rail repainted, so the panel the organizer is already looking
8514
+ * at is current the moment the mutation lands — no reload, and no dependence
8515
+ * on HOW the one-time reveal was dismissed (the button, Escape, or never).
8516
+ */
8517
+ async reloadAfterLinkChange(channelId) {
8518
+ if (this.detailChannelId === channelId) {
8519
+ await this.refresh({ quiet: true });
8520
+ return;
8521
+ }
8522
+ await this.loadLinks(channelId);
8523
+ if (this.active) this.paintRail();
8524
+ }
8068
8525
  linkById(linkId) {
8069
8526
  return this.links.find((link) => link.id === linkId) ?? null;
8070
8527
  }
@@ -8158,7 +8615,7 @@ var ChannelsMode = class {
8158
8615
  try {
8159
8616
  const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
8160
8617
  this.revealLink(reveal, { channelId });
8161
- await this.refresh({ quiet: true });
8618
+ await this.reloadAfterLinkChange(channelId);
8162
8619
  } catch (err) {
8163
8620
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8164
8621
  if (!(err instanceof ManageApiError)) this.host.onError(err);
@@ -8216,9 +8673,6 @@ var ChannelsMode = class {
8216
8673
  }
8217
8674
  selectSecret(dialog);
8218
8675
  });
8219
- dialog.querySelector("[data-ch-lk-done]")?.addEventListener("click", () => {
8220
- void this.loadLinks(opts.channelId).then(() => this.paintRail());
8221
- });
8222
8676
  });
8223
8677
  this.announce("Your hosted access link is ready and is shown once.");
8224
8678
  }
@@ -8284,6 +8738,7 @@ var ChannelsMode = class {
8284
8738
  endActiveSessions
8285
8739
  );
8286
8740
  this.revealLink(reveal, { channelId, rotated: true });
8741
+ await this.reloadAfterLinkChange(channelId);
8287
8742
  } catch (err) {
8288
8743
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8289
8744
  if (!(err instanceof ManageApiError)) this.host.onError(err);
@@ -8326,9 +8781,7 @@ var ChannelsMode = class {
8326
8781
  endActiveSessions
8327
8782
  );
8328
8783
  this.closeDialog();
8329
- await this.loadLinks(channelId);
8330
- await this.refresh({ quiet: true });
8331
- this.paintRail();
8784
+ await this.reloadAfterLinkChange(channelId);
8332
8785
  this.host.toast(res.endedSessions ? `Link revoked. ${res.endedSessions.toLocaleString()} buyer${res.endedSessions === 1 ? "" : "s"} lost access.` : "Link revoked. It no longer opens for anyone.", "ok");
8333
8786
  } catch (err) {
8334
8787
  this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
@@ -8413,9 +8866,19 @@ var LEGEND = [
8413
8866
  { key: "booked", label: "Booked", color: "#22a06b" },
8414
8867
  { key: "blocked", label: "Blocked", color: "#8b94ac" }
8415
8868
  ];
8416
- var CSS2 = `
8869
+ var MANAGER_CSS = (
8870
+ /* @sl-css */
8871
+ `
8417
8872
  .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:480px;overflow:hidden;
8418
- background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius)}
8873
+ background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius);
8874
+ /* Motion tokens (motion-system \xA72), declared by the cockpit ROOT rather than
8875
+ borrowed from CHANNELS_CSS. The base cockpit animates whether or not
8876
+ Channels mode is in use, so owning its own tokens is what stops a token
8877
+ edit from silently changing only half the surface. Channels mode declares
8878
+ the identical values so an embed of it stays self-contained. */
8879
+ --slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
8880
+ --slm-mo-out:cubic-bezier(.2,.8,.2,1);--slm-mo-in-out:cubic-bezier(.4,0,.2,1);--slm-mo-exit:cubic-bezier(.4,0,1,1);
8881
+ --slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
8419
8882
  .slm *{box-sizing:border-box;margin:0;padding:0}
8420
8883
  .slm button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
8421
8884
  .slm input{font:inherit}
@@ -8428,7 +8891,8 @@ var CSS2 = `
8428
8891
  .slm-mode.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
8429
8892
  .slm-live{display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.12em;font-weight:800;color:var(--slm-muted)}
8430
8893
  .slm-live-dot{width:8px;height:8px;border-radius:50%;background:#8b94ac}
8431
- .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);animation:slm-pulse 2s infinite}
8894
+ .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);
8895
+ animation:slm-pulse var(--slm-mo-ambient) infinite}
8432
8896
  @keyframes slm-pulse{0%{box-shadow:0 0 0 0 rgba(34,160,107,.5)}70%{box-shadow:0 0 0 7px rgba(34,160,107,0)}100%{box-shadow:0 0 0 0 rgba(34,160,107,0)}}
8433
8897
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
8434
8898
  border-top:1px solid var(--slm-line)}
@@ -8437,7 +8901,11 @@ var CSS2 = `
8437
8901
  font-variant-numeric:tabular-nums;white-space:nowrap}
8438
8902
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
8439
8903
  .slm-kpi .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:baseline}
8440
- .slm-kpi.changed b{animation:slm-kpi-bump .58s cubic-bezier(.2,.8,.2,1)}
8904
+ /* The one playful moment this surface is allowed (\xA72 --mo-spring). It ran at
8905
+ .58s against a 200ms catalog, which read as a different design language from
8906
+ the dashboard tile it mirrors; Channels mode's own count bump is the same
8907
+ pattern and must stay in step with it. */
8908
+ .slm-kpi.changed b{animation:slm-kpi-bump var(--slm-mo-base) var(--slm-mo-spring)}
8441
8909
  .slm-kpidelta{position:absolute;right:4px;top:-12px;padding:2px 5px;border-radius:999px;background:rgba(34,160,107,.17);
8442
8910
  color:#5bd39b!important;font-size:9px!important;letter-spacing:0!important;text-transform:none!important;white-space:nowrap;
8443
8911
  animation:slm-kpi-delta 1.45s ease-out both;pointer-events:none}
@@ -8456,12 +8924,14 @@ var CSS2 = `
8456
8924
  .slm-hud-chip{padding:6px 11px;border-radius:999px;font-size:12px;font-weight:700;background:var(--slm-surface);
8457
8925
  border:1px solid var(--slm-line);color:var(--slm-text)}
8458
8926
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
8459
- background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
8927
+ background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;
8928
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
8460
8929
  .slm-zoomhint.on{opacity:1}
8461
8930
  .slm-liveevent{position:absolute;left:50%;top:14px;z-index:4;display:flex;align-items:center;gap:8px;max-width:min(560px,calc(100% - 32px));
8462
8931
  padding:8px 12px;border:1px solid var(--slm-line);border-radius:999px;background:color-mix(in srgb,var(--slm-surface) 92%,transparent);
8463
8932
  box-shadow:0 10px 34px rgba(0,0,0,.32);opacity:0;transform:translate(-50%,-8px);pointer-events:none;
8464
- transition:opacity .18s ease,transform .24s ease;backdrop-filter:blur(10px)}
8933
+ transition:opacity var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out);
8934
+ backdrop-filter:blur(10px)}
8465
8935
  .slm-liveevent.on{opacity:1;transform:translate(-50%,0)}
8466
8936
  .slm.block-mode .slm-liveevent{top:52px}
8467
8937
  .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
@@ -8481,7 +8951,8 @@ var CSS2 = `
8481
8951
  /* activity feed */
8482
8952
  .slm-feed{display:flex;flex-direction:column;gap:0}
8483
8953
  .slm-feedrow{display:flex!important;width:100%;align-items:center;gap:9px;padding:8px 2px!important;border-bottom:1px solid var(--slm-line)!important;
8484
- border-radius:6px;font-size:12.5px;text-align:left!important;animation:slm-in .35s ease}
8954
+ border-radius:6px;font-size:12.5px;text-align:left!important;
8955
+ animation:slm-in var(--slm-mo-quick) var(--slm-mo-out)}
8485
8956
  .slm-feedrow:hover{background:rgba(255,255,255,.035)!important}
8486
8957
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
8487
8958
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
@@ -8548,7 +9019,8 @@ var CSS2 = `
8548
9019
 
8549
9020
  /* toast */
8550
9021
  .slm-toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);padding:10px 16px;border-radius:10px;
8551
- font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;transition:opacity .2s;
9022
+ font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;
9023
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out);
8552
9024
  background:var(--slm-surface);color:var(--slm-text);border:1px solid var(--slm-line);z-index:5}
8553
9025
  .slm-toast.on{opacity:1}
8554
9026
  .slm-toast.err{background:#c0392b;color:#fff;border-color:#c0392b}
@@ -8559,7 +9031,9 @@ var CSS2 = `
8559
9031
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
8560
9032
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
8561
9033
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
8562
- .slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;background:var(--slm-surface)!important;text-align:left!important;transition:border-color .15s ease,transform .15s ease}
9034
+ .slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;
9035
+ background:var(--slm-surface)!important;text-align:left!important;
9036
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-quick) var(--slm-mo-out)}
8563
9037
  .slm-sectionrow:hover{border-color:var(--slm-muted)!important;transform:translateY(-1px)}
8564
9038
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
8565
9039
  .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
@@ -8578,7 +9052,8 @@ var CSS2 = `
8578
9052
  .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
8579
9053
  /* sections: availability windows */
8580
9054
  .slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
8581
- .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);transition:border-color .15s ease,opacity .15s ease}
9055
+ .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
9056
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),opacity var(--slm-mo-quick) var(--slm-mo-out)}
8582
9057
  .slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
8583
9058
  .slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
8584
9059
  .slm-availhead{display:flex;align-items:center;gap:8px}
@@ -8618,16 +9093,30 @@ var CSS2 = `
8618
9093
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
8619
9094
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
8620
9095
  .slm.compact .slm-kpi[data-kpi="sold-pct"],.slm.compact .slm-kpi[data-kpi="gross-sales"]{display:none}
9096
+ /* Reduced motion, as a BLANKET over the cockpit subtree rather than a list of
9097
+ selectors. The list this replaces named four animations and two transitions,
9098
+ and had silently fallen behind the stylesheet: the zoom hint, the toast and
9099
+ the availability rows all still animated for a user who had asked the OS for
9100
+ none. An enumerated list has to be edited every time a rule is added, and
9101
+ nothing fails when it isn't \u2014 so it drifts. This cannot.
9102
+
9103
+ Motion is removed, never the information it carried: Channels mode's own
9104
+ block substitutes static outlines for its shake and success states, and it
9105
+ stays authoritative for those. No JS here waits on animationend or
9106
+ transitionend, so cutting them outright strands no state. */
8621
9107
  @media (prefers-reduced-motion:reduce){
8622
- .slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
8623
- .slm-liveevent,.slm-sectionrow{transition:none!important}
9108
+ .slm,.slm *,.slm *::before,.slm *::after{
9109
+ animation:none!important;
9110
+ transition:none!important;
9111
+ scroll-behavior:auto!important}
8624
9112
  }
8625
- ${CHANNELS_CSS}`;
9113
+ ${CHANNELS_CSS}`
9114
+ );
8626
9115
  function injectStyle() {
8627
9116
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
8628
9117
  const el = document.createElement("style");
8629
9118
  el.id = STYLE_ID2;
8630
- el.textContent = CSS2;
9119
+ el.textContent = MANAGER_CSS;
8631
9120
  document.head.appendChild(el);
8632
9121
  }
8633
9122
  function themeVars(theme) {
@@ -8687,6 +9176,11 @@ var SeatManager = class {
8687
9176
  this.reconnectTimer = null;
8688
9177
  this.attempt = 0;
8689
9178
  this.closed = false;
9179
+ /** Mirrors the `live` root class, so the getter never has to read the DOM. */
9180
+ this.connectionStatus = "reconnecting";
9181
+ /** When the server last told us something. Stamped on accepted traffic only —
9182
+ * a socket that opens and says nothing has not refreshed anything. */
9183
+ this.lastMessageAt = null;
8690
9184
  this.ready = false;
8691
9185
  this.feed = [];
8692
9186
  this.feedTimer = null;
@@ -8896,6 +9390,12 @@ var SeatManager = class {
8896
9390
  },
8897
9391
  worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
8898
9392
  seatPixelSize: () => this.seatPixelSize(),
9393
+ isSeatDetail: () => this.renderer?.getRung?.() === "seats",
9394
+ showSectionOverview: () => {
9395
+ this.renderer?.clearSectionFocus();
9396
+ this.renderer?.setRung?.("sections");
9397
+ },
9398
+ focusSection: (sectionId) => this.renderer?.focusSection(sectionId),
8899
9399
  isCompact: () => !!this.root?.classList.contains("compact"),
8900
9400
  setMapInert: (inert) => {
8901
9401
  this.mapHost.toggleAttribute("inert", inert);
@@ -8905,13 +9405,15 @@ var SeatManager = class {
8905
9405
  onError: (err) => this.opts.onError?.(err)
8906
9406
  };
8907
9407
  }
8908
- /** Approximate on-screen seat size, for the channel overlay's marks. Derived
8909
- * from the live camera so the overlay tracks zoom without a renderer hook. */
9408
+ /** Actual on-screen seat diameter, for the channel overlay's marks. The
9409
+ * renderer's base seat radius is 9 chart units; retaining the camera scale
9410
+ * (rather than capping it) keeps every preview paint aligned with the real
9411
+ * chart geometry at deep zoom. */
8910
9412
  seatPixelSize() {
8911
9413
  const rect = this.renderer?.getVisibleWorldRect?.();
8912
9414
  const width = this.mapHost?.clientWidth ?? 0;
8913
9415
  if (!rect?.width || !width) return 6;
8914
- return Math.max(3, Math.min(24, width / rect.width * 14));
9416
+ return Math.max(3, width / rect.width * 18);
8915
9417
  }
8916
9418
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
8917
9419
  setHeatOverlay(enabled) {
@@ -9087,6 +9589,16 @@ var SeatManager = class {
9087
9589
  getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
9088
9590
  return this.setTrendWindow(windowMinutes);
9089
9591
  }
9592
+ /**
9593
+ * The realtime link's current state and the "as of" behind it.
9594
+ *
9595
+ * Pair with `onConnectionChange` for the edges: a host that mounts after a
9596
+ * drop, or re-reads on tab focus, needs to be able to ASK rather than wait
9597
+ * for the next transition that may never come.
9598
+ */
9599
+ getConnection() {
9600
+ return { status: this.connectionStatus, lastMessageAt: this.lastMessageAt };
9601
+ }
9090
9602
  getLog(opts = {}) {
9091
9603
  return this.api.log(this.key, opts);
9092
9604
  }
@@ -9151,6 +9663,10 @@ var SeatManager = class {
9151
9663
  onSelect: (seat) => this.handleSeatSelect(seat),
9152
9664
  onDeselect: () => this.syncSelection(),
9153
9665
  onMarquee: () => this.syncSelection(),
9666
+ onSectionTap: (sectionId) => {
9667
+ this.renderer?.focusSection(sectionId);
9668
+ this.channels?.handleSectionFocus(sectionId);
9669
+ },
9154
9670
  onViewChange: () => {
9155
9671
  this.updateZoomHint();
9156
9672
  this.channels?.handleViewChange();
@@ -9161,10 +9677,11 @@ var SeatManager = class {
9161
9677
  this.applyHeatOverlay();
9162
9678
  this.updateZoomHint();
9163
9679
  }
9164
- /** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
9165
- * section. The two differ only in WHICH statuses they may act on. */
9680
+ /** Block always uses a marquee. Channels only enables its marquee after the
9681
+ * organizer deliberately chooses Assign seats; Pan map keeps desktop drag
9682
+ * available for large charts. */
9166
9683
  isBulkSelectMode() {
9167
- return this.mode === "block" || this.mode === "channels" && this.channels?.canSelect() === true;
9684
+ return this.mode === "block" || this.mode === "channels" && this.channels?.usesMarqueeSelection() === true;
9168
9685
  }
9169
9686
  /**
9170
9687
  * Block never touches held or booked inventory, so it cannot select it.
@@ -9174,7 +9691,7 @@ var SeatManager = class {
9174
9691
  */
9175
9692
  selectableStatuses() {
9176
9693
  if (this.mode === "block") return ["free", "not_for_sale"];
9177
- if (this.mode === "inspect" || this.isBulkSelectMode()) {
9694
+ if (this.mode === "inspect" || this.isBulkSelectMode() || this.mode === "channels" && this.channels?.canSelect() === true) {
9178
9695
  return ["free", "held", "booked", "not_for_sale"];
9179
9696
  }
9180
9697
  return [];
@@ -9277,6 +9794,7 @@ var SeatManager = class {
9277
9794
  return;
9278
9795
  }
9279
9796
  if (!msg || typeof msg !== "object") return;
9797
+ this.lastMessageAt = Date.now();
9280
9798
  const m = msg;
9281
9799
  if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
9282
9800
  this.updateEffectiveAvailability(m.hidden, m.closed);
@@ -9333,6 +9851,7 @@ var SeatManager = class {
9333
9851
  const objs = await this.api.objects(this.key);
9334
9852
  this.applySnapshot(objs.seats);
9335
9853
  this.updateEffectiveAvailability(objs.hidden, objs.closed);
9854
+ this.lastMessageAt = Date.now();
9336
9855
  } catch {
9337
9856
  }
9338
9857
  }
@@ -9807,6 +10326,14 @@ var SeatManager = class {
9807
10326
  this.root?.classList.toggle("live", on);
9808
10327
  if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
9809
10328
  this.paintMonitorInsights();
10329
+ const next = on ? "live" : "reconnecting";
10330
+ if (next === this.connectionStatus) return;
10331
+ this.connectionStatus = next;
10332
+ try {
10333
+ this.opts.onConnectionChange?.(this.getConnection());
10334
+ } catch (err) {
10335
+ this.opts.onError?.(err);
10336
+ }
9810
10337
  }
9811
10338
  updateZoomHint() {
9812
10339
  const hint = this.els.zoomhint;