@seatlayer/js 0.36.2 → 0.37.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
@@ -6160,6 +6160,78 @@ function accessLine(access) {
6160
6160
  function accessIntentLabel(intent) {
6161
6161
  return intent === "internal" ? "Internal selling \u2014 our own staff sell these" : intent === "server" ? "Server integration \u2014 our backend lets buyers in" : intent === "hosted_link" ? "Hosted access link \u2014 SeatLayer issues the link" : "No buyer access yet \u2014 the allocation is just protected";
6162
6162
  }
6163
+ var ACCESS_LINK_DEFAULTS = {
6164
+ maxRedemptions: 100,
6165
+ maxQuantity: 4
6166
+ };
6167
+ function accessLinkBadge(link) {
6168
+ switch (link.status ?? link.state) {
6169
+ case "active":
6170
+ return { text: "Active", kind: "active" };
6171
+ case "expired":
6172
+ return { text: "Expired", kind: "archived" };
6173
+ case "exhausted":
6174
+ return { text: "All used", kind: "paused" };
6175
+ case "rotated":
6176
+ return { text: "Replaced", kind: "archived" };
6177
+ default:
6178
+ return { text: "Revoked", kind: "archived" };
6179
+ }
6180
+ }
6181
+ function accessLinkIsLive(link) {
6182
+ return link.state === "active" && link.status === "active";
6183
+ }
6184
+ function formatMoment(ms) {
6185
+ if (!Number.isFinite(ms)) return "\u2014";
6186
+ return new Date(ms).toLocaleString(void 0, {
6187
+ day: "numeric",
6188
+ month: "short",
6189
+ year: "numeric",
6190
+ hour: "numeric",
6191
+ minute: "2-digit"
6192
+ });
6193
+ }
6194
+ function accessLinkPolicyLines(link) {
6195
+ return [
6196
+ { k: "Expires", v: formatMoment(link.expiresAt) },
6197
+ {
6198
+ k: "Redemptions",
6199
+ v: `${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} used`
6200
+ },
6201
+ {
6202
+ k: "Seats per buyer",
6203
+ v: `${link.maxQuantity.toLocaleString()} seat${link.maxQuantity === 1 ? "" : "s"} maximum`
6204
+ },
6205
+ {
6206
+ k: "Covers",
6207
+ v: link.includePublic ? "This channel's allocation and Public sale seats" : "This channel's allocation only"
6208
+ }
6209
+ ];
6210
+ }
6211
+ function accessLinkErrorCopy(err) {
6212
+ const fromServer = err?.serverMessage?.trim();
6213
+ switch (err?.code) {
6214
+ case "invalid_expiry":
6215
+ case "invalid_max_redemptions":
6216
+ case "invalid_max_quantity":
6217
+ case "invalid_session_ttl":
6218
+ case "invalid_label":
6219
+ return fromServer || "That setting is outside what a hosted link allows. Adjust it and try again.";
6220
+ case "too_many_access_links":
6221
+ return fromServer || "This channel already has as many live links as it can hold. Revoke one before creating another.";
6222
+ case "access_link_not_active":
6223
+ return "That link is no longer active, so it cannot be rotated or revoked.";
6224
+ case "channel_unavailable":
6225
+ return "This channel is paused or archived, so it cannot let new buyers in. Resume it first.";
6226
+ case "end_active_sessions_required":
6227
+ return "Choose what happens to the buyers who already came in through this link.";
6228
+ case "not_found":
6229
+ return "That link is no longer here. Refresh and try again.";
6230
+ default:
6231
+ if (err?.status === 403) return "Hosted access links need channel-management permission.";
6232
+ return fromServer || "That did not go through. Try again.";
6233
+ }
6234
+ }
6163
6235
  function dropReviewRows(details) {
6164
6236
  return (details?.channels ?? []).map((channel) => ({
6165
6237
  kind: "skip",
@@ -6176,13 +6248,14 @@ function stateBadge(state) {
6176
6248
 
6177
6249
  // src/manageApi.ts
6178
6250
  var ManageApiError = class extends Error {
6179
- constructor(status, message, code, conflicts, details) {
6251
+ constructor(status, message, code, conflicts, details, serverMessage) {
6180
6252
  super(message);
6181
6253
  this.name = "ManageApiError";
6182
6254
  this.status = status;
6183
6255
  this.code = code;
6184
6256
  this.conflicts = conflicts;
6185
6257
  this.details = details;
6258
+ this.serverMessage = serverMessage;
6186
6259
  }
6187
6260
  };
6188
6261
  async function parse(res) {
@@ -6195,7 +6268,8 @@ async function parse(res) {
6195
6268
  err?.error ?? `request_failed_${res.status}`,
6196
6269
  err?.code,
6197
6270
  err?.conflicts,
6198
- err?.details
6271
+ err?.details,
6272
+ typeof err?.message === "string" ? err.message : void 0
6199
6273
  );
6200
6274
  }
6201
6275
  return data;
@@ -6391,6 +6465,58 @@ var ManageApi = class {
6391
6465
  body: { accessIntent }
6392
6466
  });
6393
6467
  }
6468
+ // ---- hosted access links (M8) ----
6469
+ /**
6470
+ * Mint a hosted access link. The 201 is the ONE and ONLY time `url` and
6471
+ * `capability` exist outside the buyer's browser — SeatLayer keeps a hash, so
6472
+ * there is no route, cache, or support escalation that can produce this string
6473
+ * again. Callers must reveal it immediately and then let it go.
6474
+ *
6475
+ * Every omitted field takes the server's default: expiry = when the event
6476
+ * starts, 100 redemptions, 4 seats per buyer, this channel's allocation only.
6477
+ * Platform bounds are enforced server-side and reported as 422 with the rule
6478
+ * spelled out in `ManageApiError.serverMessage`.
6479
+ *
6480
+ * Side effect by design: this also declares the channel's access intent as
6481
+ * `hosted_link`, so the rail stops saying "no buyer access configured".
6482
+ */
6483
+ createAccessLink(key, channelId, input = {}) {
6484
+ return this.auth(
6485
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`,
6486
+ { method: "POST", body: input }
6487
+ );
6488
+ }
6489
+ /** Status only — label, expiry, redemptions, per-buyer cap, lineage, and the
6490
+ * live session count. Never the url, never the capability. Needs `:view`. */
6491
+ accessLinks(key, channelId) {
6492
+ return this.auth(
6493
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`
6494
+ );
6495
+ }
6496
+ /**
6497
+ * Rotate — the ONLY recovery for a link nobody kept. The old URL stops opening
6498
+ * immediately and the response is a fresh one-time reveal.
6499
+ *
6500
+ * `endActiveSessions` is REQUIRED, not defaulted: the organizer must say
6501
+ * whether buyers already inside finish their checkout or lose access now. The
6502
+ * server answers 422 `end_active_sessions_required` if it is omitted, and that
6503
+ * refusal is correct — a UI must not pick either branch on their behalf.
6504
+ */
6505
+ rotateAccessLink(key, channelId, linkId, endActiveSessions) {
6506
+ return this.auth(
6507
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}/rotate`,
6508
+ { method: "POST", body: { endActiveSessions } }
6509
+ );
6510
+ }
6511
+ /** Revoke. The link stops opening immediately; `endActiveSessions` decides
6512
+ * whether the buyers already inside keep their sessions. */
6513
+ revokeAccessLink(key, channelId, linkId, endActiveSessions = false) {
6514
+ const qs = endActiveSessions ? "?endActiveSessions=1" : "";
6515
+ return this.auth(
6516
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}${qs}`,
6517
+ { method: "DELETE" }
6518
+ );
6519
+ }
6394
6520
  // ---- reports (token) ----
6395
6521
  report(key) {
6396
6522
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
@@ -6421,6 +6547,11 @@ var ManageApi = class {
6421
6547
  var POLL_MS = 1e4;
6422
6548
  var MAX_FLAGS = 8;
6423
6549
  var SEAT_LIST_PAGE = 300;
6550
+ var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
6551
+ var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
6552
+ var PREVIEW_UNAVAILABLE_FILL = "#303846";
6553
+ var PREVIEW_UNAVAILABLE_STROKE = "#4b5669";
6554
+ var ALLOCATION_STROKE = "#101723";
6424
6555
  var CHANNELS_CSS = `
6425
6556
  .slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
6426
6557
  --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);
@@ -6434,6 +6565,8 @@ var CHANNELS_CSS = `
6434
6565
  background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
6435
6566
  transform:translate(-50%,-50%);white-space:nowrap}
6436
6567
  .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}
6568
+ .slm-ch-section-target{position:absolute;pointer-events:auto;padding:0;border:0;border-radius:8px;background:transparent;cursor:zoom-in}
6569
+ .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)}
6437
6570
 
6438
6571
  /* preview banner \u2014 raised with the organizer chrome dim, as one transition */
6439
6572
  .slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
@@ -6453,7 +6586,7 @@ var CHANNELS_CSS = `
6453
6586
  transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
6454
6587
  .slm-ch-staged.on{transform:none;opacity:1}
6455
6588
  .slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
6456
- .slm-ch-staged.shake{animation:slm-ch-shake 320ms var(--slm-mo-in-out) 2}
6589
+ .slm-ch-staged.shake{animation:slm-ch-shake var(--slm-mo-slow) var(--slm-mo-in-out) 2}
6457
6590
  .slm-ch-staged b{font-variant-numeric:tabular-nums}
6458
6591
  .slm-ch-staged .grow{flex:1}
6459
6592
  .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
@@ -6470,6 +6603,11 @@ var CHANNELS_CSS = `
6470
6603
  .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)}
6471
6604
  .slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
6472
6605
  .slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
6606
+ .slm-ch-mapnav{margin:-2px 0 12px;padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
6607
+ .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)}
6608
+ .slm-ch-mapnav-head button{color:var(--slm-accent);font-size:11px;font-weight:800;letter-spacing:0;text-transform:none;min-height:30px}
6609
+ .slm-ch-mapnav .slm-ch-viewseg{margin:8px 0 5px}
6610
+ .slm-ch-mapnav p{margin:0;font-size:11px;line-height:1.45;color:var(--slm-muted)}
6473
6611
  .slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
6474
6612
  .slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6475
6613
  text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
@@ -6490,7 +6628,7 @@ var CHANNELS_CSS = `
6490
6628
  font-variant-numeric:tabular-nums}
6491
6629
  .slm-ch-counts b{color:var(--slm-text);font-weight:800}
6492
6630
  .slm-ch-counts .free b{color:#5bd39b}
6493
- .slm-ch-counts b.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6631
+ .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6494
6632
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
6495
6633
  .slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
6496
6634
  .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
@@ -6500,12 +6638,13 @@ var CHANNELS_CSS = `
6500
6638
  font-weight:800;color:#0e1017}
6501
6639
  .slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
6502
6640
  .slm-ch-selsrc-row span{color:var(--slm-muted)}
6503
- .slm-ch-selnum.bump{animation:slm-ch-bump .58s var(--slm-mo-spring)}
6641
+ .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6504
6642
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
6505
6643
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
6506
6644
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
6507
6645
  line-height:1.5;margin-bottom:12px}
6508
6646
  .slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
6647
+ .slm-ch-alert.info{background:rgba(110,123,255,.12);border:1px solid rgba(110,123,255,.44);color:#c5cbff}
6509
6648
  .slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
6510
6649
  .slm-ch-alert b{color:#fff}
6511
6650
  .slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
@@ -6543,6 +6682,26 @@ var CHANNELS_CSS = `
6543
6682
  border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
6544
6683
  overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
6545
6684
  .slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
6685
+
6686
+ /* hosted access links \u2014 STATUS only; there is no Copy control on this card */
6687
+ .slm-ch-link{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6688
+ margin-bottom:8px}
6689
+ .slm-ch-link .lk-head{display:flex;align-items:center;gap:8px}
6690
+ .slm-ch-link .lk-name{flex:1;min-width:0;font-size:12.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;
6691
+ white-space:nowrap}
6692
+ .slm-ch-lkrow{display:flex;gap:8px;margin-top:5px;font-size:11px;color:var(--slm-muted)}
6693
+ .slm-ch-lkrow .k{flex:none;min-width:104px}
6694
+ .slm-ch-lkrow .v{color:var(--slm-text);font-variant-numeric:tabular-nums}
6695
+ .slm-ch-meter{height:5px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden;margin-top:8px}
6696
+ .slm-ch-meter i{display:block;height:100%;background:var(--slm-accent);
6697
+ transition:width var(--slm-mo-base) var(--slm-mo-out)}
6698
+ .slm-ch-radio{display:flex;gap:9px;align-items:flex-start;padding:11px 12px;border:1px solid var(--slm-line);
6699
+ border-radius:10px;margin-top:8px;font-size:12.5px;cursor:pointer;
6700
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
6701
+ .slm-ch-radio:hover{border-color:var(--slm-muted)}
6702
+ .slm-ch-radio input{flex:none;margin-top:2px}
6703
+ .slm-ch-radio b{display:block;font-weight:800;margin-bottom:2px}
6704
+ .slm-ch-radio .why{display:block;color:var(--slm-muted);font-size:11.5px;line-height:1.45}
6546
6705
  .slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
6547
6706
  background:var(--slm-surface);margin-top:10px}
6548
6707
  .slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
@@ -6575,7 +6734,8 @@ var CHANNELS_CSS = `
6575
6734
  .slm.compact .slm-modes{display:none}
6576
6735
 
6577
6736
  @media (prefers-reduced-motion:reduce){
6578
- .slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail{transition:none!important}
6737
+ .slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail,
6738
+ .slm-ch-meter i,.slm-ch-radio{transition:none!important}
6579
6739
  .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
6580
6740
  .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
6581
6741
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
@@ -6591,9 +6751,37 @@ function bucketRowsHtml(rows) {
6591
6751
  ${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
6592
6752
  </div>`).join("");
6593
6753
  }
6754
+ var SERVER_INTEGRATION_HTML = `
6755
+ <p class="slm-eyebrow" style="margin-top:18px">Server integration</p>
6756
+ <p class="slm-hint">There is nothing to set up on this screen. Your own server mints a short-lived buyer
6757
+ access session for this channel with the SeatLayer server SDK and hands it to the widget. A channel name
6758
+ on its own never grants access.</p>
6759
+ <p class="slm-note"><a class="slm-linkbtn" href="https://docs.seatlayer.io/server-api/channels"
6760
+ target="_blank" rel="noreferrer noopener">Read the server integration guide \u2192</a></p>`;
6594
6761
  function esc(value) {
6595
6762
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
6596
6763
  }
6764
+ function datetimeLocalValue(ms) {
6765
+ const local = new Date(ms - new Date(ms).getTimezoneOffset() * 6e4);
6766
+ return local.toISOString().slice(0, 16);
6767
+ }
6768
+ function intField(root, selector) {
6769
+ const raw = root.querySelector(selector)?.value.trim() ?? "";
6770
+ const value = Number(raw);
6771
+ return raw !== "" && Number.isInteger(value) ? value : null;
6772
+ }
6773
+ function selectSecret(dialog) {
6774
+ const node = dialog.querySelector("[data-ch-lk-url]");
6775
+ if (!node) return;
6776
+ try {
6777
+ const range = document.createRange();
6778
+ range.selectNodeContents(node);
6779
+ const selection = window.getSelection();
6780
+ selection?.removeAllRanges();
6781
+ selection?.addRange(range);
6782
+ } catch {
6783
+ }
6784
+ }
6597
6785
  var ChannelsMode = class {
6598
6786
  constructor(host, capabilities) {
6599
6787
  this.active = false;
@@ -6603,6 +6791,10 @@ var ChannelsMode = class {
6603
6791
  this.loadError = null;
6604
6792
  this.loading = true;
6605
6793
  this.view = "inspect";
6794
+ /** Pan is intentionally the initial desktop interaction. Assignment's
6795
+ * marquee is powerful, but must never make an organizer lose map navigation. */
6796
+ this.mapIntent = "pan";
6797
+ this.focusedSectionId = null;
6606
6798
  this.showArchived = false;
6607
6799
  this.detailChannelId = null;
6608
6800
  this.targetChannelId = "";
@@ -6610,6 +6802,24 @@ var ChannelsMode = class {
6610
6802
  this.dialog = null;
6611
6803
  this.detent = "medium";
6612
6804
  this.seatListLimit = SEAT_LIST_PAGE;
6805
+ /**
6806
+ * Hosted-link STATUS for the channel whose detail panel is open. This is the
6807
+ * listing projection — it carries no url and no capability, because no route
6808
+ * returns one. `unsupported` is the honest answer for a worker that predates
6809
+ * M8, exactly like the buyer-preview probe.
6810
+ */
6811
+ this.links = [];
6812
+ this.linksChannelId = null;
6813
+ this.linksState = "idle";
6814
+ /**
6815
+ * Monotonic read generations — one for the channel list + allocation, one for
6816
+ * the open channel's links. Reads are concurrent (a 10s poll versus a
6817
+ * mutation's own reload), and the network does not promise to answer them in
6818
+ * order. Only the NEWEST read of each kind may write to state; an older
6819
+ * answer that arrives late is dropped, never painted.
6820
+ */
6821
+ this.listSeq = 0;
6822
+ this.linksSeq = 0;
6613
6823
  this.previewAudience = [];
6614
6824
  this.previewIncludePublic = false;
6615
6825
  this.previewProjection = null;
@@ -6636,10 +6846,14 @@ var ChannelsMode = class {
6636
6846
  enter() {
6637
6847
  if (this.active) return;
6638
6848
  this.active = true;
6849
+ this.mapIntent = "pan";
6850
+ this.focusedSectionId = null;
6639
6851
  this.ensureLayer();
6640
6852
  this.host.root.classList.add("ch-mode");
6641
6853
  this.applySheetClasses();
6854
+ if (this.host.sections().length > 1) this.host.showSectionOverview();
6642
6855
  this.paintRail();
6856
+ this.onInteractionChange?.();
6643
6857
  void this.refresh();
6644
6858
  this.pollTimer = setInterval(() => {
6645
6859
  void this.refresh({ quiet: true });
@@ -6653,6 +6867,9 @@ var ChannelsMode = class {
6653
6867
  if (this.pollTimer) clearInterval(this.pollTimer);
6654
6868
  this.pollTimer = null;
6655
6869
  this.closeDialog({ restoreFocus: false });
6870
+ this.links = [];
6871
+ this.linksChannelId = null;
6872
+ this.linksState = "idle";
6656
6873
  this.layer?.classList.remove("on");
6657
6874
  this.host.root.classList.remove(
6658
6875
  "ch-mode",
@@ -6688,6 +6905,17 @@ var ChannelsMode = class {
6688
6905
  canSelect() {
6689
6906
  return this.caps.manage && this.view === "inspect";
6690
6907
  }
6908
+ /** Bulk seat assignment is explicit. In Pan map, clicks can still inspect a
6909
+ * single seat, while a primary-button drag always moves the camera. */
6910
+ usesMarqueeSelection() {
6911
+ return this.canSelect() && this.mapIntent === "assign";
6912
+ }
6913
+ /** The renderer calls this when the organizer opens a section from overview. */
6914
+ handleSectionFocus(sectionId) {
6915
+ if (!this.active) return;
6916
+ this.focusedSectionId = sectionId;
6917
+ this.paintRail();
6918
+ }
6691
6919
  /**
6692
6920
  * Organizer realtime integration point. M5 ships a per-scope socket for
6693
6921
  * buyers; the organizer channel-count stream is a later milestone. When it
@@ -6715,21 +6943,28 @@ var ChannelsMode = class {
6715
6943
  // ---- data -----------------------------------------------------------------
6716
6944
  async refresh(opts = {}) {
6717
6945
  if (!this.caps.view) return;
6946
+ const seq = ++this.listSeq;
6947
+ const superseded = () => seq !== this.listSeq;
6718
6948
  try {
6719
6949
  const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
6950
+ if (superseded()) return;
6720
6951
  this.list = list;
6721
6952
  this.assignmentVersion = list.assignmentVersion;
6722
6953
  this.loadError = null;
6723
6954
  if (!this.targetChannelId) {
6724
6955
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
6725
6956
  }
6726
- await this.loadAllocation();
6957
+ await this.loadAllocation(seq);
6958
+ if (superseded()) return;
6959
+ if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
6960
+ if (superseded()) return;
6727
6961
  this.loading = false;
6728
6962
  if (this.active) {
6729
6963
  this.paintRail();
6730
6964
  this.paintOverlay();
6731
6965
  }
6732
6966
  } catch (err) {
6967
+ if (superseded()) return;
6733
6968
  this.loading = false;
6734
6969
  if (err instanceof ManageApiError && err.status === 403) {
6735
6970
  this.caps = { view: false, manage: false };
@@ -6741,10 +6976,11 @@ var ChannelsMode = class {
6741
6976
  }
6742
6977
  /** Walk every allocation page. Bounded by the event's seat count, and the
6743
6978
  * server caps each page, so an arena is a handful of round trips. */
6744
- async loadAllocation() {
6979
+ async loadAllocation(seq) {
6745
6980
  const next = /* @__PURE__ */ new Map();
6746
6981
  let afterLabel;
6747
6982
  for (let page = 0; page < 200; page += 1) {
6983
+ if (seq !== void 0 && seq !== this.listSeq) return;
6748
6984
  const res = await this.host.api.channelAllocation(this.host.eventKey, {
6749
6985
  afterLabel,
6750
6986
  limit: 1e3
@@ -6756,6 +6992,7 @@ var ChannelsMode = class {
6756
6992
  if (!res.nextAfterLabel) break;
6757
6993
  afterLabel = res.nextAfterLabel;
6758
6994
  }
6995
+ if (seq !== void 0 && seq !== this.listSeq) return;
6759
6996
  this.allocation = next;
6760
6997
  }
6761
6998
  // ---- lookups --------------------------------------------------------------
@@ -6822,9 +7059,10 @@ var ChannelsMode = class {
6822
7059
  * Repaint the allocation (or preview) overlay in ONE canvas pass.
6823
7060
  *
6824
7061
  * Channel identity on the map is a fill in the administrative color PLUS the
6825
- * letter flags below — never color alone. Physical status keeps its own cue:
6826
- * only FREE units take a channel fill, so sold/held/blocked seats still read
6827
- * exactly as they do in every other tool.
7062
+ * letter flags below — never color alone. In buyer preview the map instead
7063
+ * uses two explicit, channel-neutral access states. Physical status keeps its
7064
+ * own cue: only FREE units are repainted, so sold/held/blocked seats still
7065
+ * read exactly as they do in every other tool.
6828
7066
  */
6829
7067
  paintOverlay() {
6830
7068
  const canvas = this.canvas;
@@ -6849,11 +7087,14 @@ var ChannelsMode = class {
6849
7087
  if (!ctx) return;
6850
7088
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
6851
7089
  ctx.clearRect(0, 0, width, height);
7090
+ const seatDetail = this.host.isSeatDetail();
6852
7091
  const size = Math.max(3, this.host.seatPixelSize());
6853
7092
  const half = size / 2;
6854
7093
  const projection = this.view === "preview" ? this.previewProjection : null;
6855
7094
  const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
6856
7095
  const clusters = /* @__PURE__ */ new Map();
7096
+ const sectionTargets = !seatDetail && this.host.sections().length > 1 ? /* @__PURE__ */ new Map() : null;
7097
+ const previewSections = this.view === "preview" && seatDetail && size <= 15 ? /* @__PURE__ */ new Map() : null;
6857
7098
  for (const seat of this.host.seats()) {
6858
7099
  const status = this.host.statusOf(seat.label) ?? "free";
6859
7100
  const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
@@ -6864,24 +7105,150 @@ var ChannelsMode = class {
6864
7105
  cluster.n += 1;
6865
7106
  clusters.set(channelId, cluster);
6866
7107
  }
7108
+ if (!seatDetail) {
7109
+ const section = this.host.sectionOfLabel(seat.label);
7110
+ const point2 = section ? this.host.worldToScreen({ x: seat.x, y: seat.y }) : null;
7111
+ if (sectionTargets && section && point2) {
7112
+ const bounds = sectionTargets.get(section.id) ?? {
7113
+ label: section.label,
7114
+ minX: point2.x,
7115
+ minY: point2.y,
7116
+ maxX: point2.x,
7117
+ maxY: point2.y
7118
+ };
7119
+ bounds.minX = Math.min(bounds.minX, point2.x);
7120
+ bounds.minY = Math.min(bounds.minY, point2.y);
7121
+ bounds.maxX = Math.max(bounds.maxX, point2.x);
7122
+ bounds.maxY = Math.max(bounds.maxY, point2.y);
7123
+ sectionTargets.set(section.id, bounds);
7124
+ }
7125
+ continue;
7126
+ }
6867
7127
  if (status !== "free") continue;
6868
7128
  let fill = null;
7129
+ let stroke = null;
6869
7130
  if (this.view === "preview") {
6870
- fill = eligible ? eligible.has(seat.label) ? null : "#3a4051" : null;
7131
+ if (eligible?.has(seat.label)) {
7132
+ fill = PREVIEW_ELIGIBLE_FILL;
7133
+ stroke = PREVIEW_ELIGIBLE_STROKE;
7134
+ } else {
7135
+ fill = PREVIEW_UNAVAILABLE_FILL;
7136
+ stroke = PREVIEW_UNAVAILABLE_STROKE;
7137
+ }
6871
7138
  } else if (channelId !== PUBLIC_CHANNEL_ID) {
6872
7139
  fill = this.markerFor(channelId).color;
7140
+ stroke = ALLOCATION_STROKE;
6873
7141
  }
6874
7142
  if (!fill) continue;
6875
7143
  const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
6876
7144
  if (!point) continue;
6877
7145
  if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
7146
+ if (previewSections) {
7147
+ const section = this.host.sectionOfLabel(seat.label);
7148
+ if (section) {
7149
+ const bounds = previewSections.get(section.id) ?? {
7150
+ label: section.label,
7151
+ minX: point.x,
7152
+ minY: point.y,
7153
+ maxX: point.x,
7154
+ maxY: point.y
7155
+ };
7156
+ bounds.minX = Math.min(bounds.minX, point.x);
7157
+ bounds.minY = Math.min(bounds.minY, point.y);
7158
+ bounds.maxX = Math.max(bounds.maxX, point.x);
7159
+ bounds.maxY = Math.max(bounds.maxY, point.y);
7160
+ previewSections.set(section.id, bounds);
7161
+ }
7162
+ }
6878
7163
  ctx.fillStyle = fill;
6879
- ctx.globalAlpha = this.view === "preview" ? 0.9 : 0.85;
6880
- ctx.fillRect(point.x - half, point.y - half, size, size);
7164
+ if (this.view === "preview" || channelId !== PUBLIC_CHANNEL_ID) {
7165
+ const radius = Math.max(2, half + Math.min(1.5, half * 0.06));
7166
+ ctx.globalAlpha = 1;
7167
+ ctx.beginPath();
7168
+ ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
7169
+ ctx.fill();
7170
+ ctx.strokeStyle = stroke ?? fill;
7171
+ 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));
7172
+ ctx.stroke();
7173
+ if (this.view === "preview" && eligible?.has(seat.label) && size >= 22) {
7174
+ this.paintPreviewSeatLabel(ctx, seat.label, point.x, point.y, radius);
7175
+ }
7176
+ } else {
7177
+ ctx.globalAlpha = 0.85;
7178
+ ctx.fillRect(point.x - half, point.y - half, size, size);
7179
+ }
6881
7180
  }
6882
7181
  ctx.globalAlpha = 1;
7182
+ if (previewSections) this.paintPreviewSectionLabels(ctx, previewSections);
7183
+ this.paintSectionTargets(sectionTargets);
6883
7184
  this.paintFlags(clusters);
6884
7185
  }
7186
+ /** Draw an eligible seat's actual chart label without inventing a new buyer
7187
+ * identifier. Long labels scale down and are omitted rather than overflowing
7188
+ * into an adjacent seat. */
7189
+ paintPreviewSeatLabel(ctx, label, x, y, radius) {
7190
+ const maxWidth = radius * 1.55;
7191
+ let fontSize = Math.min(13, Math.max(7, radius * 0.55));
7192
+ const minFontSize = 6;
7193
+ while (fontSize >= minFontSize) {
7194
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7195
+ if (ctx.measureText(label).width <= maxWidth) break;
7196
+ fontSize -= 0.5;
7197
+ }
7198
+ if (fontSize < minFontSize) return;
7199
+ ctx.fillStyle = "#ffffff";
7200
+ ctx.textAlign = "center";
7201
+ ctx.textBaseline = "middle";
7202
+ ctx.fillText(label, x, y);
7203
+ }
7204
+ /** A section overview is a navigation map. These transparent, keyboardable
7205
+ * hit areas sit over the renderer's section shells so both mouse and keyboard
7206
+ * always take the organizer into the real focused-section camera state. */
7207
+ paintSectionTargets(sections) {
7208
+ const layer = this.layer;
7209
+ if (!layer) return;
7210
+ layer.querySelectorAll(".slm-ch-section-target").forEach((el) => el.remove());
7211
+ if (!sections) return;
7212
+ for (const [id, section] of sections) {
7213
+ const width = section.maxX - section.minX;
7214
+ const height = section.maxY - section.minY;
7215
+ if (width < 20 || height < 20) continue;
7216
+ const target = document.createElement("button");
7217
+ target.type = "button";
7218
+ target.className = "slm-ch-section-target";
7219
+ target.style.left = `${section.minX - 8}px`;
7220
+ target.style.top = `${section.minY - 8}px`;
7221
+ target.style.width = `${width + 16}px`;
7222
+ target.style.height = `${height + 16}px`;
7223
+ target.setAttribute("aria-label", `Open ${section.label} seats`);
7224
+ target.addEventListener("click", () => {
7225
+ this.focusedSectionId = id;
7226
+ this.host.focusSection(id);
7227
+ this.paintRail();
7228
+ });
7229
+ layer.appendChild(target);
7230
+ }
7231
+ }
7232
+ /** Keep renderer section names legible over a dense, zoomed-out preview. */
7233
+ paintPreviewSectionLabels(ctx, sections) {
7234
+ for (const section of sections.values()) {
7235
+ const width = section.maxX - section.minX;
7236
+ const height = section.maxY - section.minY;
7237
+ if (width < 52 || height < 26) continue;
7238
+ const centerX = (section.minX + section.maxX) / 2;
7239
+ const centerY = (section.minY + section.maxY) / 2;
7240
+ const fontSize = Math.max(11, Math.min(15, height * 0.16));
7241
+ ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
7242
+ const labelWidth = Math.min(width - 8, ctx.measureText(section.label).width + 18);
7243
+ const labelHeight = fontSize + 10;
7244
+ ctx.fillStyle = "rgba(11, 16, 28, .88)";
7245
+ ctx.fillRect(centerX - labelWidth / 2, centerY - labelHeight / 2, labelWidth, labelHeight);
7246
+ ctx.fillStyle = "#f8fafc";
7247
+ ctx.textAlign = "center";
7248
+ ctx.textBaseline = "middle";
7249
+ ctx.fillText(section.label, centerX, centerY);
7250
+ }
7251
+ }
6885
7252
  /** Letter flags at each channel's centroid — the non-color identity cue. */
6886
7253
  paintFlags(clusters) {
6887
7254
  const layer = this.layer;
@@ -6947,7 +7314,7 @@ var ChannelsMode = class {
6947
7314
  });
6948
7315
  });
6949
7316
  }
6950
- setBanner(on, name = "") {
7317
+ setBanner(on, name = "", eligibleSeats) {
6951
7318
  const banner = this.bannerEl;
6952
7319
  if (!banner) return;
6953
7320
  this.host.root.classList.toggle("ch-preview", on);
@@ -6957,8 +7324,9 @@ var ChannelsMode = class {
6957
7324
  return;
6958
7325
  }
6959
7326
  const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
7327
+ const availability = eligibleSeats == null ? "" : ` \xB7 ${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat" : "seats"} available now`;
6960
7328
  banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
6961
- Previewing buyer access \xB7 ${esc(name)} \xB7 read-only
7329
+ Previewing buyer access \xB7 ${esc(name)}${availability} \xB7 read-only
6962
7330
  <button type="button" data-ch-act="exit-preview">Exit preview</button>`;
6963
7331
  banner.classList.add("on");
6964
7332
  banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
@@ -6993,6 +7361,8 @@ var ChannelsMode = class {
6993
7361
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
6994
7362
  });
6995
7363
  this.previewSupported = true;
7364
+ const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
7365
+ this.setBanner(true, names, eligibleSeats);
6996
7366
  } catch (err) {
6997
7367
  const status = err instanceof ManageApiError ? err.status : 0;
6998
7368
  this.previewSupported = !(status === 404 || status === 405 || status === 501);
@@ -7043,6 +7413,22 @@ var ChannelsMode = class {
7043
7413
  aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
7044
7414
  <button type="button" class="${previewOn.trim()}" data-ch-view="preview"
7045
7415
  aria-pressed="${this.view === "preview"}">Preview buyer access</button>
7416
+ </div>${this.mapNavigationHtml()}`;
7417
+ }
7418
+ mapNavigationHtml() {
7419
+ if (this.host.sections().length < 2) return "";
7420
+ const focused = this.focusedSectionId ? this.host.sections().find((section) => section.id === this.focusedSectionId)?.label ?? "section" : null;
7421
+ const panOn = this.mapIntent === "pan" ? " on" : "";
7422
+ const assignOn = this.mapIntent === "assign" ? " on" : "";
7423
+ const intent = this.view === "inspect" && this.caps.manage ? `<div class="slm-ch-viewseg" role="group" aria-label="Map interaction">
7424
+ <button type="button" class="${panOn.trim()}" data-ch-map="pan" aria-pressed="${this.mapIntent === "pan"}">Pan map</button>
7425
+ <button type="button" class="${assignOn.trim()}" data-ch-map="assign" aria-pressed="${this.mapIntent === "assign"}">Assign seats</button>
7426
+ </div>
7427
+ <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>";
7428
+ return `<div class="slm-ch-mapnav">
7429
+ <div class="slm-ch-mapnav-head"><span>${focused ? `Viewing ${esc(focused)}` : "Section overview"}</span>
7430
+ <button type="button" data-ch-act="sections">All sections</button></div>
7431
+ ${intent}
7046
7432
  </div>`;
7047
7433
  }
7048
7434
  countsHtml(counts, key) {
@@ -7161,11 +7547,8 @@ var ChannelsMode = class {
7161
7547
  <p class="slm-hint">${channel.access?.hasActiveGrants ? "Buyer access is live. Only this audience can buy from the allocation." : "No buyer access is configured yet. The allocation is protected \u2014 it is not available to Public sale."}</p>
7162
7548
  ${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
7163
7549
  <span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
7164
- <button type="button" class="slm-btn" data-ch-act="hosted-link" disabled
7165
- title="Hosted access links ship in the next milestone">Create hosted access link \xB7 Coming soon</button>
7166
- <div class="slm-ch-row2"><button type="button" class="slm-btn ghost" data-ch-act="server-access" disabled
7167
- title="Guided server setup ships in the next milestone">Configure server integration \xB7 Coming soon</button></div>
7168
- <p class="slm-note">Your own server can already mint buyer access sessions for this channel with the server SDK.</p>` : "";
7550
+ ${this.hostedLinksHtml()}
7551
+ ${SERVER_INTEGRATION_HTML}` : "";
7169
7552
  return `
7170
7553
  <p class="slm-eyebrow">
7171
7554
  <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
@@ -7175,6 +7558,92 @@ var ChannelsMode = class {
7175
7558
  ${access}
7176
7559
  ${lifecycle}`;
7177
7560
  }
7561
+ // ---- hosted access links --------------------------------------------------
7562
+ /**
7563
+ * Read the status projection for the open channel. Never paints — the caller
7564
+ * decides when the rail repaints, so a poll-driven reload does not fight a
7565
+ * user-driven one. A worker without M8 answers 404/405 and gets the honest
7566
+ * "needs a newer server" line rather than an error toast.
7567
+ */
7568
+ async loadLinks(channelId) {
7569
+ if (!this.caps.view) return;
7570
+ const seq = ++this.linksSeq;
7571
+ const superseded = () => seq !== this.linksSeq || this.linksChannelId !== channelId;
7572
+ if (this.linksChannelId !== channelId) {
7573
+ this.links = [];
7574
+ this.linksChannelId = channelId;
7575
+ this.linksState = "loading";
7576
+ }
7577
+ try {
7578
+ const res = await this.host.api.accessLinks(this.host.eventKey, channelId);
7579
+ if (superseded()) return;
7580
+ this.links = res.links ?? [];
7581
+ this.linksState = "ready";
7582
+ } catch (err) {
7583
+ if (superseded()) return;
7584
+ const status = err instanceof ManageApiError ? err.status : 0;
7585
+ this.links = [];
7586
+ this.linksState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
7587
+ if (this.linksState === "error") this.host.onError(err);
7588
+ }
7589
+ }
7590
+ /**
7591
+ * The hosted-link section of the detail panel.
7592
+ *
7593
+ * STATUS ONLY, by design (comp 06 `hosted`): label, state, expiry,
7594
+ * redemptions, seats per buyer, live sessions. There is no Copy control here
7595
+ * and no field to hang one on — the URL was shown once at creation and cannot
7596
+ * be produced again. Rotation is the recovery path, and it says so.
7597
+ */
7598
+ hostedLinksHtml() {
7599
+ const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Hosted access links</p>`;
7600
+ if (this.linksState === "unsupported") {
7601
+ return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
7602
+ <span><b>Hosted links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
7603
+ }
7604
+ if (this.linksState === "error") {
7605
+ return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7606
+ <span><b>Couldn't load this channel's links.</b>
7607
+ <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
7608
+ }
7609
+ const live = this.links.filter(accessLinkIsLive).length;
7610
+ const cards = this.linksState === "loading" && !this.links.length ? `<div class="slm-empty">Loading links\u2026</div>` : this.links.length ? this.links.map((link) => this.linkCardHtml(link)).join("") : `<p class="slm-hint">No hosted link yet. Create one to send this allocation to a named group \u2014
7611
+ they open the link and buy only these seats.</p>`;
7612
+ const create = this.caps.manage ? `<button type="button" class="slm-btn" style="width:100%" data-ch-act="link-create">
7613
+ ${live ? "Create another hosted link" : "Create hosted access link"}</button>` : "";
7614
+ return `${eyebrow}
7615
+ ${cards}
7616
+ ${create}
7617
+ <p class="slm-note">A link is shown once, when you create it. SeatLayer keeps only a fingerprint of it, so it
7618
+ can never be shown again \u2014 if a link is lost, rotate it and send the fresh one.</p>`;
7619
+ }
7620
+ linkCardHtml(link) {
7621
+ const badge = accessLinkBadge(link);
7622
+ const used = link.maxRedemptions > 0 ? Math.min(100, Math.round(link.redemptions / link.maxRedemptions * 100)) : 0;
7623
+ const rows = accessLinkPolicyLines(link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
7624
+ <span class="v">${esc(row.v)}</span></div>`).join("");
7625
+ const sessions = link.activeSessions ? `<div class="slm-ch-lkrow"><span class="k">Buyers inside now</span>
7626
+ <span class="v">${link.activeSessions.toLocaleString()}</span></div>` : "";
7627
+ const lastUsed = link.lastRedeemedAt ? `<div class="slm-ch-lkrow"><span class="k">Last opened</span>
7628
+ <span class="v">${esc(new Date(link.lastRedeemedAt).toLocaleString())}</span></div>` : "";
7629
+ const actions = this.caps.manage && accessLinkIsLive(link) ? `<div class="slm-ch-row2">
7630
+ <button type="button" class="slm-btn ghost" data-ch-rotate="${esc(link.id)}">Rotate</button>
7631
+ <button type="button" class="slm-btn ghost" data-ch-revoke="${esc(link.id)}">Revoke</button>
7632
+ </div>` : "";
7633
+ return `<div class="slm-ch-link">
7634
+ <span class="lk-head">
7635
+ <span class="lk-name">${esc(link.label || "Hosted link")}</span>
7636
+ <span class="slm-ch-badge ${badge.kind}">${esc(badge.text)}</span>
7637
+ </span>
7638
+ <div class="slm-ch-meter" role="img"
7639
+ aria-label="${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} redemptions used">
7640
+ <i style="width:${used}%"></i></div>
7641
+ ${rows}${sessions}${lastUsed}
7642
+ <div class="slm-ch-lkrow"><span class="k">The URL</span>
7643
+ <span class="v">Revealed once at creation \u2014 not recoverable</span></div>
7644
+ ${actions}
7645
+ </div>`;
7646
+ }
7178
7647
  previewRailHtml() {
7179
7648
  const audienceOptions = [
7180
7649
  { id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
@@ -7188,9 +7657,9 @@ var ChannelsMode = class {
7188
7657
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
7189
7658
  <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")}.
7190
7659
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
7191
- const counts = this.previewProjection?.counts;
7192
- 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
7193
- through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
7660
+ const eligibleSeats = this.previewProjection?.counts?.eligible ?? this.previewProjection?.eligible?.length;
7661
+ 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>
7662
+ This is the exact buyer-visible allocation.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this access." : ""}</span></div>` : "";
7194
7663
  const includePublic = isPublicChannelId(current) ? "" : `
7195
7664
  <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
7196
7665
  <input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
@@ -7221,12 +7690,35 @@ var ChannelsMode = class {
7221
7690
  rail.querySelectorAll("[data-ch-view]").forEach((button) => {
7222
7691
  button.addEventListener("click", () => this.setView(button.dataset.chView));
7223
7692
  });
7693
+ rail.querySelectorAll("[data-ch-map]").forEach((button) => {
7694
+ button.addEventListener("click", () => {
7695
+ this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
7696
+ this.paintRail();
7697
+ this.onInteractionChange?.();
7698
+ });
7699
+ });
7224
7700
  rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
7225
7701
  button.addEventListener("click", () => {
7226
- this.detailChannelId = button.dataset.chDetail;
7702
+ const channelId = button.dataset.chDetail;
7703
+ this.detailChannelId = channelId;
7227
7704
  this.paintRail();
7705
+ void this.loadLinks(channelId).then(() => this.paintRail());
7228
7706
  });
7229
7707
  });
7708
+ rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
7709
+ button.addEventListener("click", () => this.openDialog({
7710
+ kind: "linkRotate",
7711
+ channelId: this.detailChannelId,
7712
+ linkId: button.dataset.chRotate
7713
+ }));
7714
+ });
7715
+ rail.querySelectorAll("[data-ch-revoke]").forEach((button) => {
7716
+ button.addEventListener("click", () => this.openDialog({
7717
+ kind: "linkRevoke",
7718
+ channelId: this.detailChannelId,
7719
+ linkId: button.dataset.chRevoke
7720
+ }));
7721
+ });
7230
7722
  const target = rail.querySelector("[data-ch-target]");
7231
7723
  target?.addEventListener("change", () => {
7232
7724
  this.targetChannelId = target.value;
@@ -7255,6 +7747,11 @@ var ChannelsMode = class {
7255
7747
  }
7256
7748
  railAction(action) {
7257
7749
  switch (action) {
7750
+ case "sections":
7751
+ this.focusedSectionId = null;
7752
+ this.host.showSectionOverview();
7753
+ this.paintRail();
7754
+ break;
7258
7755
  case "create":
7259
7756
  this.openDialog({ kind: "create" });
7260
7757
  break;
@@ -7278,8 +7775,17 @@ var ChannelsMode = class {
7278
7775
  break;
7279
7776
  case "back":
7280
7777
  this.detailChannelId = null;
7778
+ this.linksChannelId = null;
7779
+ this.links = [];
7780
+ this.linksState = "idle";
7281
7781
  this.paintRail();
7282
7782
  break;
7783
+ case "link-create":
7784
+ this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
7785
+ break;
7786
+ case "link-reload":
7787
+ if (this.detailChannelId) void this.reloadLinks();
7788
+ break;
7283
7789
  case "retry":
7284
7790
  void this.refresh();
7285
7791
  break;
@@ -7351,6 +7857,9 @@ var ChannelsMode = class {
7351
7857
  else if (state.kind === "archive") this.renderArchiveDialog(state);
7352
7858
  else if (state.kind === "rename") this.renderRenameDialog(state);
7353
7859
  else if (state.kind === "seatlist") this.renderSeatListDialog();
7860
+ else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
7861
+ else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
7862
+ else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
7354
7863
  }
7355
7864
  /**
7356
7865
  * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
@@ -7417,7 +7926,7 @@ var ChannelsMode = class {
7417
7926
  <label>Marker</label>
7418
7927
  <div style="display:flex;gap:8px;align-items:center">
7419
7928
  <span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
7420
- <span class="slm-note" style="margin:0">Letter + colour suggested from the name. Buyers never see either.</span>
7929
+ <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>
7421
7930
  </div>
7422
7931
  </div>
7423
7932
  <div class="slm-field">
@@ -7760,6 +8269,298 @@ var ChannelsMode = class {
7760
8269
  });
7761
8270
  });
7762
8271
  }
8272
+ // ---- hosted-link dialogs --------------------------------------------------
8273
+ async reloadLinks() {
8274
+ const channelId = this.detailChannelId;
8275
+ if (!channelId) return;
8276
+ this.linksState = this.links.length ? this.linksState : "loading";
8277
+ await this.loadLinks(channelId);
8278
+ this.paintRail();
8279
+ }
8280
+ /**
8281
+ * The reload EVERY link mutation owes the panel.
8282
+ *
8283
+ * A create/rotate/revoke changes two things the detail panel renders: the
8284
+ * channel's access line (the server sets `access.intent` on create, and clears
8285
+ * it when the last live link goes) and the link status list. Both are re-read
8286
+ * here and the rail repainted, so the panel the organizer is already looking
8287
+ * at is current the moment the mutation lands — no reload, and no dependence
8288
+ * on HOW the one-time reveal was dismissed (the button, Escape, or never).
8289
+ */
8290
+ async reloadAfterLinkChange(channelId) {
8291
+ if (this.detailChannelId === channelId) {
8292
+ await this.refresh({ quiet: true });
8293
+ return;
8294
+ }
8295
+ await this.loadLinks(channelId);
8296
+ if (this.active) this.paintRail();
8297
+ }
8298
+ linkById(linkId) {
8299
+ return this.links.find((link) => link.id === linkId) ?? null;
8300
+ }
8301
+ /**
8302
+ * Create. The three policy fields carry the owner's defaults and every one of
8303
+ * them is editable; the PLATFORM bounds (60s–180d, 1–10 000, 1–100, 20 live
8304
+ * links) are the server's to enforce and the server's to explain, so this form
8305
+ * checks only that a number is a number and surfaces the server's sentence for
8306
+ * everything else.
8307
+ */
8308
+ renderLinkCreateDialog(state) {
8309
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
8310
+ if (!channel || !this.caps.manage) {
8311
+ this.closeDialog();
8312
+ return;
8313
+ }
8314
+ this.renderScrim(`
8315
+ <h3 id="slm-ch-dlg-title">Create a hosted access link for ${esc(channel.name)}</h3>
8316
+ <p class="sub">Anyone who opens the link can buy from this channel's allocation \u2014 and only from it.
8317
+ You'll see the link once, right after you create it.</p>
8318
+ <div class="slm-field">
8319
+ <label for="slm-ch-lk-label">Label <span style="text-transform:none;font-weight:500">(optional)</span></label>
8320
+ <input class="slm-input" id="slm-ch-lk-label" maxlength="80" placeholder="e.g. VIP list Nov 14" />
8321
+ <p class="slm-note">So you can tell your links apart later. Buyers never see it.</p>
8322
+ </div>
8323
+ <div class="slm-field">
8324
+ <label for="slm-ch-lk-expiry">Stops working</label>
8325
+ <select class="slm-select" id="slm-ch-lk-expiry" data-ch-lk-expiry>
8326
+ <option value="event" selected>When the event starts</option>
8327
+ <option value="custom">On a date I choose</option>
8328
+ </select>
8329
+ </div>
8330
+ <div class="slm-field" data-ch-lk-when-field hidden>
8331
+ <label for="slm-ch-lk-when">Date and time</label>
8332
+ <input class="slm-input" type="datetime-local" id="slm-ch-lk-when" />
8333
+ </div>
8334
+ <div class="slm-field">
8335
+ <label for="slm-ch-lk-redemptions">How many people can use it</label>
8336
+ <input class="slm-input" type="number" id="slm-ch-lk-redemptions" inputmode="numeric"
8337
+ value="${ACCESS_LINK_DEFAULTS.maxRedemptions}" />
8338
+ <p class="slm-note">Each buyer who opens the link uses one.</p>
8339
+ </div>
8340
+ <div class="slm-field">
8341
+ <label for="slm-ch-lk-quantity">Seats per buyer</label>
8342
+ <input class="slm-input" type="number" id="slm-ch-lk-quantity" inputmode="numeric"
8343
+ value="${ACCESS_LINK_DEFAULTS.maxQuantity}" />
8344
+ </div>
8345
+ <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:2px 0 6px">
8346
+ <input type="checkbox" id="slm-ch-lk-public" />
8347
+ Also let this link buy Public sale seats
8348
+ </label>
8349
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8350
+ <div class="foot">
8351
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8352
+ <button type="button" class="slm-btn" data-ch-lk-create>Create link</button>
8353
+ </div>`, (dialog) => {
8354
+ const expiry = dialog.querySelector("[data-ch-lk-expiry]");
8355
+ const whenField = dialog.querySelector("[data-ch-lk-when-field]");
8356
+ const when = dialog.querySelector("#slm-ch-lk-when");
8357
+ expiry.addEventListener("change", () => {
8358
+ const custom = expiry.value === "custom";
8359
+ whenField.hidden = !custom;
8360
+ if (custom && !when.value) when.value = datetimeLocalValue(Date.now() + 7 * 864e5);
8361
+ });
8362
+ dialog.querySelector("[data-ch-lk-create]")?.addEventListener("click", () => {
8363
+ const maxRedemptions = intField(dialog, "#slm-ch-lk-redemptions");
8364
+ const maxQuantity = intField(dialog, "#slm-ch-lk-quantity");
8365
+ if (maxRedemptions == null || maxQuantity == null) {
8366
+ this.showDialogError("Those two settings need to be whole numbers.");
8367
+ return;
8368
+ }
8369
+ let expiresAt;
8370
+ if (expiry.value === "custom") {
8371
+ expiresAt = Date.parse(when.value);
8372
+ if (!Number.isFinite(expiresAt)) {
8373
+ this.showDialogError("Pick the date and time the link should stop working.");
8374
+ return;
8375
+ }
8376
+ }
8377
+ void this.createLink(channel.id, {
8378
+ label: dialog.querySelector("#slm-ch-lk-label")?.value.trim() || null,
8379
+ includePublic: dialog.querySelector("#slm-ch-lk-public")?.checked ?? false,
8380
+ ...expiresAt === void 0 ? {} : { expiresAt },
8381
+ maxRedemptions,
8382
+ maxQuantity
8383
+ });
8384
+ });
8385
+ });
8386
+ }
8387
+ async createLink(channelId, input) {
8388
+ try {
8389
+ const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
8390
+ this.revealLink(reveal, { channelId });
8391
+ await this.reloadAfterLinkChange(channelId);
8392
+ } catch (err) {
8393
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8394
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8395
+ }
8396
+ }
8397
+ /**
8398
+ * The ONE-TIME reveal.
8399
+ *
8400
+ * Three things make this unrecoverable rather than merely "not shown twice":
8401
+ *
8402
+ * 1. `url` is a local const. It is never assigned to a field on this class,
8403
+ * never handed to the host, never put in a `DialogState`.
8404
+ * 2. `this.dialog` is cleared FIRST, so `renderDialog()` — the only function
8405
+ * that rebuilds a sheet — has nothing to rebuild this one from.
8406
+ * 3. The string exists in exactly one DOM node inside the scrim. Dismissing
8407
+ * the dialog removes the scrim, and the closure goes with it.
8408
+ *
8409
+ * The server holds only a hash, so even a compromised client cannot ask for it
8410
+ * again. Rotation is the recovery path, and the copy says so.
8411
+ */
8412
+ revealLink(reveal, opts) {
8413
+ const url = reveal.url;
8414
+ this.dialog = null;
8415
+ const rotated = opts.rotated ? `<div class="slm-ch-alert warn" role="status"><span>\u26A0</span><span>
8416
+ <b>The old link has stopped working.</b> ${reveal.endedSessions ? `${reveal.endedSessions.toLocaleString()} buyer${reveal.endedSessions === 1 ? "" : "s"} lost access immediately.` : "Buyers who already came in can finish; every new visit needs this link."}</span></div>` : "";
8417
+ const policy = accessLinkPolicyLines(reveal.link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
8418
+ <span class="v">${esc(row.v)}</span></div>`).join("");
8419
+ this.renderScrim(`
8420
+ <h3 id="slm-ch-dlg-title">Copy this link now</h3>
8421
+ <p class="sub">This is the only time SeatLayer can show it. We keep just a fingerprint, so it cannot be
8422
+ shown again \u2014 if it is lost, rotate the link for a fresh one.</p>
8423
+ ${rotated}
8424
+ <div class="slm-ch-secret" data-ch-lk-url>${esc(url)}</div>
8425
+ <div class="slm-ch-row2" style="margin-top:8px">
8426
+ <button type="button" class="slm-btn" data-ch-lk-copy>Copy link</button>
8427
+ </div>
8428
+ <div class="slm-ch-alert warn" style="margin-top:12px"><span>\u26A0</span>
8429
+ <span>Anyone who opens this link can buy from this allocation. Send it only to the people it is meant
8430
+ for \u2014 forwarding it hands on the same access, and SeatLayer cannot tell the difference.</span></div>
8431
+ <p class="slm-eyebrow" style="margin-top:14px">What this link allows</p>
8432
+ ${policy}
8433
+ <div class="foot">
8434
+ <button type="button" class="slm-btn" data-ch-close data-ch-lk-done>I've copied it</button>
8435
+ </div>`, (dialog) => {
8436
+ const copy = dialog.querySelector("[data-ch-lk-copy]");
8437
+ copy?.addEventListener("click", () => {
8438
+ const ok = () => {
8439
+ copy.textContent = "Copied";
8440
+ this.announce("Hosted access link copied.");
8441
+ };
8442
+ const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
8443
+ if (clipboard?.writeText) {
8444
+ clipboard.writeText(url).then(ok, () => selectSecret(dialog));
8445
+ return;
8446
+ }
8447
+ selectSecret(dialog);
8448
+ });
8449
+ });
8450
+ this.announce("Your hosted access link is ready and is shown once.");
8451
+ }
8452
+ /**
8453
+ * Rotate. The organizer must SAY what happens to the buyers already inside —
8454
+ * the confirm stays disabled until one of the two choices is picked, because
8455
+ * the gentle branch and the destructive branch are both real decisions and the
8456
+ * server refuses (422 `end_active_sessions_required`) to guess either.
8457
+ */
8458
+ renderLinkRotateDialog(state) {
8459
+ const link = this.linkById(state.linkId);
8460
+ if (!link || !this.caps.manage) {
8461
+ this.closeDialog();
8462
+ return;
8463
+ }
8464
+ const sessions = link.activeSessions ?? 0;
8465
+ const warning = sessions ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
8466
+ <span><b>${sessions.toLocaleString()} buyer${sessions === 1 ? "" : "s"}</b> got in with the current link
8467
+ and still ${sessions === 1 ? "has" : "have"} active access.</span></div>` : "";
8468
+ this.renderScrim(`
8469
+ <h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "hosted")} link?</h3>
8470
+ <p class="sub">The current link stops opening immediately and cannot be restored. You will get a new
8471
+ link to copy \u2014 shown once.</p>
8472
+ ${warning}
8473
+ <label class="slm-ch-radio">
8474
+ <input type="radio" name="slm-ch-rot" value="keep" data-ch-rot />
8475
+ <span><b>Let them finish</b><span class="why">Access already handed out expires on its own; seats in
8476
+ checkout are untouched. Every new visit needs the new link.</span></span>
8477
+ </label>
8478
+ <label class="slm-ch-radio">
8479
+ <input type="radio" name="slm-ch-rot" value="end" data-ch-rot />
8480
+ <span><b>End their access now</b><span class="why">All access from the old link ends immediately.
8481
+ Buyers part-way through choosing seats lose access.</span></span>
8482
+ </label>
8483
+ <p class="slm-note">Choose one \u2014 SeatLayer will not decide this for you.</p>
8484
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8485
+ <div class="foot">
8486
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8487
+ <button type="button" class="slm-btn" data-ch-lk-rotate disabled>Rotate and copy new link</button>
8488
+ </div>`, (dialog) => {
8489
+ const confirm = dialog.querySelector("[data-ch-lk-rotate]");
8490
+ dialog.querySelectorAll("[data-ch-rot]").forEach((radio) => {
8491
+ radio.addEventListener("change", () => {
8492
+ confirm.disabled = false;
8493
+ });
8494
+ });
8495
+ confirm.addEventListener("click", () => {
8496
+ const picked = [...dialog.querySelectorAll("[data-ch-rot]")].find((radio) => radio.checked);
8497
+ if (!picked) {
8498
+ this.showDialogError(accessLinkErrorCopy({ code: "end_active_sessions_required" }));
8499
+ return;
8500
+ }
8501
+ void this.rotateLink(state.channelId, link.id, picked.value === "end");
8502
+ });
8503
+ });
8504
+ }
8505
+ async rotateLink(channelId, linkId, endActiveSessions) {
8506
+ try {
8507
+ const reveal = await this.host.api.rotateAccessLink(
8508
+ this.host.eventKey,
8509
+ channelId,
8510
+ linkId,
8511
+ endActiveSessions
8512
+ );
8513
+ this.revealLink(reveal, { channelId, rotated: true });
8514
+ await this.reloadAfterLinkChange(channelId);
8515
+ } catch (err) {
8516
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8517
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8518
+ }
8519
+ }
8520
+ renderLinkRevokeDialog(state) {
8521
+ const link = this.linkById(state.linkId);
8522
+ if (!link || !this.caps.manage) {
8523
+ this.closeDialog();
8524
+ return;
8525
+ }
8526
+ const sessions = link.activeSessions ?? 0;
8527
+ this.renderScrim(`
8528
+ <h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "hosted")} link?</h3>
8529
+ <p class="sub">It stops opening immediately and cannot be restored \u2014 there is no undo, and no way to
8530
+ bring the same URL back. Seats already bought through it keep their sale.</p>
8531
+ ${sessions ? `<label class="slm-ch-radio">
8532
+ <input type="checkbox" data-ch-lk-endsessions />
8533
+ <span><b>Also end access for the ${sessions.toLocaleString()}
8534
+ buyer${sessions === 1 ? "" : "s"} already inside</b><span class="why">Leave this off and they can
8535
+ finish what they started; new visits are refused either way.</span></span>
8536
+ </label>` : ""}
8537
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8538
+ <div class="foot">
8539
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8540
+ <button type="button" class="slm-btn danger" data-ch-lk-revoke>Revoke link</button>
8541
+ </div>`, (dialog) => {
8542
+ dialog.querySelector("[data-ch-lk-revoke]")?.addEventListener("click", () => {
8543
+ const end = dialog.querySelector("[data-ch-lk-endsessions]")?.checked ?? false;
8544
+ void this.revokeLink(state.channelId, link.id, end);
8545
+ });
8546
+ });
8547
+ }
8548
+ async revokeLink(channelId, linkId, endActiveSessions) {
8549
+ try {
8550
+ const res = await this.host.api.revokeAccessLink(
8551
+ this.host.eventKey,
8552
+ channelId,
8553
+ linkId,
8554
+ endActiveSessions
8555
+ );
8556
+ this.closeDialog();
8557
+ await this.reloadAfterLinkChange(channelId);
8558
+ 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");
8559
+ } catch (err) {
8560
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8561
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8562
+ }
8563
+ }
7763
8564
  // ---- compact detents ------------------------------------------------------
7764
8565
  applySheetClasses() {
7765
8566
  const root = this.host.root;
@@ -7838,9 +8639,17 @@ var LEGEND = [
7838
8639
  { key: "booked", label: "Booked", color: "#22a06b" },
7839
8640
  { key: "blocked", label: "Blocked", color: "#8b94ac" }
7840
8641
  ];
7841
- var CSS2 = `
8642
+ var MANAGER_CSS = `
7842
8643
  .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:480px;overflow:hidden;
7843
- background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius)}
8644
+ background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius);
8645
+ /* Motion tokens (motion-system \xA72), declared by the cockpit ROOT rather than
8646
+ borrowed from CHANNELS_CSS. The base cockpit animates whether or not
8647
+ Channels mode is in use, so owning its own tokens is what stops a token
8648
+ edit from silently changing only half the surface. Channels mode declares
8649
+ the identical values so an embed of it stays self-contained. */
8650
+ --slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
8651
+ --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);
8652
+ --slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
7844
8653
  .slm *{box-sizing:border-box;margin:0;padding:0}
7845
8654
  .slm button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
7846
8655
  .slm input{font:inherit}
@@ -7853,7 +8662,8 @@ var CSS2 = `
7853
8662
  .slm-mode.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
7854
8663
  .slm-live{display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.12em;font-weight:800;color:var(--slm-muted)}
7855
8664
  .slm-live-dot{width:8px;height:8px;border-radius:50%;background:#8b94ac}
7856
- .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);animation:slm-pulse 2s infinite}
8665
+ .slm.live .slm-live-dot{background:#22a06b;box-shadow:0 0 0 0 rgba(34,160,107,.55);
8666
+ animation:slm-pulse var(--slm-mo-ambient) infinite}
7857
8667
  @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)}}
7858
8668
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
7859
8669
  border-top:1px solid var(--slm-line)}
@@ -7862,7 +8672,11 @@ var CSS2 = `
7862
8672
  font-variant-numeric:tabular-nums;white-space:nowrap}
7863
8673
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
7864
8674
  .slm-kpi .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:baseline}
7865
- .slm-kpi.changed b{animation:slm-kpi-bump .58s cubic-bezier(.2,.8,.2,1)}
8675
+ /* The one playful moment this surface is allowed (\xA72 --mo-spring). It ran at
8676
+ .58s against a 200ms catalog, which read as a different design language from
8677
+ the dashboard tile it mirrors; Channels mode's own count bump is the same
8678
+ pattern and must stay in step with it. */
8679
+ .slm-kpi.changed b{animation:slm-kpi-bump var(--slm-mo-base) var(--slm-mo-spring)}
7866
8680
  .slm-kpidelta{position:absolute;right:4px;top:-12px;padding:2px 5px;border-radius:999px;background:rgba(34,160,107,.17);
7867
8681
  color:#5bd39b!important;font-size:9px!important;letter-spacing:0!important;text-transform:none!important;white-space:nowrap;
7868
8682
  animation:slm-kpi-delta 1.45s ease-out both;pointer-events:none}
@@ -7881,12 +8695,14 @@ var CSS2 = `
7881
8695
  .slm-hud-chip{padding:6px 11px;border-radius:999px;font-size:12px;font-weight:700;background:var(--slm-surface);
7882
8696
  border:1px solid var(--slm-line);color:var(--slm-text)}
7883
8697
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
7884
- background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
8698
+ background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;
8699
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
7885
8700
  .slm-zoomhint.on{opacity:1}
7886
8701
  .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));
7887
8702
  padding:8px 12px;border:1px solid var(--slm-line);border-radius:999px;background:color-mix(in srgb,var(--slm-surface) 92%,transparent);
7888
8703
  box-shadow:0 10px 34px rgba(0,0,0,.32);opacity:0;transform:translate(-50%,-8px);pointer-events:none;
7889
- transition:opacity .18s ease,transform .24s ease;backdrop-filter:blur(10px)}
8704
+ transition:opacity var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out);
8705
+ backdrop-filter:blur(10px)}
7890
8706
  .slm-liveevent.on{opacity:1;transform:translate(-50%,0)}
7891
8707
  .slm.block-mode .slm-liveevent{top:52px}
7892
8708
  .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
@@ -7906,7 +8722,8 @@ var CSS2 = `
7906
8722
  /* activity feed */
7907
8723
  .slm-feed{display:flex;flex-direction:column;gap:0}
7908
8724
  .slm-feedrow{display:flex!important;width:100%;align-items:center;gap:9px;padding:8px 2px!important;border-bottom:1px solid var(--slm-line)!important;
7909
- border-radius:6px;font-size:12.5px;text-align:left!important;animation:slm-in .35s ease}
8725
+ border-radius:6px;font-size:12.5px;text-align:left!important;
8726
+ animation:slm-in var(--slm-mo-quick) var(--slm-mo-out)}
7910
8727
  .slm-feedrow:hover{background:rgba(255,255,255,.035)!important}
7911
8728
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
7912
8729
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
@@ -7973,7 +8790,8 @@ var CSS2 = `
7973
8790
 
7974
8791
  /* toast */
7975
8792
  .slm-toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);padding:10px 16px;border-radius:10px;
7976
- font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;transition:opacity .2s;
8793
+ font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;pointer-events:none;
8794
+ transition:opacity var(--slm-mo-base) var(--slm-mo-out);
7977
8795
  background:var(--slm-surface);color:var(--slm-text);border:1px solid var(--slm-line);z-index:5}
7978
8796
  .slm-toast.on{opacity:1}
7979
8797
  .slm-toast.err{background:#c0392b;color:#fff;border-color:#c0392b}
@@ -7984,7 +8802,9 @@ var CSS2 = `
7984
8802
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
7985
8803
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
7986
8804
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
7987
- .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}
8805
+ .slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;
8806
+ background:var(--slm-surface)!important;text-align:left!important;
8807
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),transform var(--slm-mo-quick) var(--slm-mo-out)}
7988
8808
  .slm-sectionrow:hover{border-color:var(--slm-muted)!important;transform:translateY(-1px)}
7989
8809
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
7990
8810
  .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
@@ -8003,7 +8823,8 @@ var CSS2 = `
8003
8823
  .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
8004
8824
  /* sections: availability windows */
8005
8825
  .slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
8006
- .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}
8826
+ .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
8827
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out),opacity var(--slm-mo-quick) var(--slm-mo-out)}
8007
8828
  .slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
8008
8829
  .slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
8009
8830
  .slm-availhead{display:flex;align-items:center;gap:8px}
@@ -8043,16 +8864,29 @@ var CSS2 = `
8043
8864
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
8044
8865
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
8045
8866
  .slm.compact .slm-kpi[data-kpi="sold-pct"],.slm.compact .slm-kpi[data-kpi="gross-sales"]{display:none}
8867
+ /* Reduced motion, as a BLANKET over the cockpit subtree rather than a list of
8868
+ selectors. The list this replaces named four animations and two transitions,
8869
+ and had silently fallen behind the stylesheet: the zoom hint, the toast and
8870
+ the availability rows all still animated for a user who had asked the OS for
8871
+ none. An enumerated list has to be edited every time a rule is added, and
8872
+ nothing fails when it isn't \u2014 so it drifts. This cannot.
8873
+
8874
+ Motion is removed, never the information it carried: Channels mode's own
8875
+ block substitutes static outlines for its shake and success states, and it
8876
+ stays authoritative for those. No JS here waits on animationend or
8877
+ transitionend, so cutting them outright strands no state. */
8046
8878
  @media (prefers-reduced-motion:reduce){
8047
- .slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
8048
- .slm-liveevent,.slm-sectionrow{transition:none!important}
8879
+ .slm,.slm *,.slm *::before,.slm *::after{
8880
+ animation:none!important;
8881
+ transition:none!important;
8882
+ scroll-behavior:auto!important}
8049
8883
  }
8050
8884
  ${CHANNELS_CSS}`;
8051
8885
  function injectStyle() {
8052
8886
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
8053
8887
  const el = document.createElement("style");
8054
8888
  el.id = STYLE_ID2;
8055
- el.textContent = CSS2;
8889
+ el.textContent = MANAGER_CSS;
8056
8890
  document.head.appendChild(el);
8057
8891
  }
8058
8892
  function themeVars(theme) {
@@ -8112,6 +8946,11 @@ var SeatManager = class {
8112
8946
  this.reconnectTimer = null;
8113
8947
  this.attempt = 0;
8114
8948
  this.closed = false;
8949
+ /** Mirrors the `live` root class, so the getter never has to read the DOM. */
8950
+ this.connectionStatus = "reconnecting";
8951
+ /** When the server last told us something. Stamped on accepted traffic only —
8952
+ * a socket that opens and says nothing has not refreshed anything. */
8953
+ this.lastMessageAt = null;
8115
8954
  this.ready = false;
8116
8955
  this.feed = [];
8117
8956
  this.feedTimer = null;
@@ -8321,6 +9160,12 @@ var SeatManager = class {
8321
9160
  },
8322
9161
  worldToScreen: (point) => this.renderer?.worldToScreen(point) ?? null,
8323
9162
  seatPixelSize: () => this.seatPixelSize(),
9163
+ isSeatDetail: () => this.renderer?.getRung?.() === "seats",
9164
+ showSectionOverview: () => {
9165
+ this.renderer?.clearSectionFocus();
9166
+ this.renderer?.setRung?.("sections");
9167
+ },
9168
+ focusSection: (sectionId) => this.renderer?.focusSection(sectionId),
8324
9169
  isCompact: () => !!this.root?.classList.contains("compact"),
8325
9170
  setMapInert: (inert) => {
8326
9171
  this.mapHost.toggleAttribute("inert", inert);
@@ -8330,13 +9175,15 @@ var SeatManager = class {
8330
9175
  onError: (err) => this.opts.onError?.(err)
8331
9176
  };
8332
9177
  }
8333
- /** Approximate on-screen seat size, for the channel overlay's marks. Derived
8334
- * from the live camera so the overlay tracks zoom without a renderer hook. */
9178
+ /** Actual on-screen seat diameter, for the channel overlay's marks. The
9179
+ * renderer's base seat radius is 9 chart units; retaining the camera scale
9180
+ * (rather than capping it) keeps every preview paint aligned with the real
9181
+ * chart geometry at deep zoom. */
8335
9182
  seatPixelSize() {
8336
9183
  const rect = this.renderer?.getVisibleWorldRect?.();
8337
9184
  const width = this.mapHost?.clientWidth ?? 0;
8338
9185
  if (!rect?.width || !width) return 6;
8339
- return Math.max(3, Math.min(24, width / rect.width * 14));
9186
+ return Math.max(3, width / rect.width * 18);
8340
9187
  }
8341
9188
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
8342
9189
  setHeatOverlay(enabled) {
@@ -8512,6 +9359,16 @@ var SeatManager = class {
8512
9359
  getControlRoomSnapshot(windowMinutes = this.trendWindowMinutes) {
8513
9360
  return this.setTrendWindow(windowMinutes);
8514
9361
  }
9362
+ /**
9363
+ * The realtime link's current state and the "as of" behind it.
9364
+ *
9365
+ * Pair with `onConnectionChange` for the edges: a host that mounts after a
9366
+ * drop, or re-reads on tab focus, needs to be able to ASK rather than wait
9367
+ * for the next transition that may never come.
9368
+ */
9369
+ getConnection() {
9370
+ return { status: this.connectionStatus, lastMessageAt: this.lastMessageAt };
9371
+ }
8515
9372
  getLog(opts = {}) {
8516
9373
  return this.api.log(this.key, opts);
8517
9374
  }
@@ -8576,6 +9433,10 @@ var SeatManager = class {
8576
9433
  onSelect: (seat) => this.handleSeatSelect(seat),
8577
9434
  onDeselect: () => this.syncSelection(),
8578
9435
  onMarquee: () => this.syncSelection(),
9436
+ onSectionTap: (sectionId) => {
9437
+ this.renderer?.focusSection(sectionId);
9438
+ this.channels?.handleSectionFocus(sectionId);
9439
+ },
8579
9440
  onViewChange: () => {
8580
9441
  this.updateZoomHint();
8581
9442
  this.channels?.handleViewChange();
@@ -8586,10 +9447,11 @@ var SeatManager = class {
8586
9447
  this.applyHeatOverlay();
8587
9448
  this.updateZoomHint();
8588
9449
  }
8589
- /** Block and Channels are both bulk-selection tools: marquee, ⌘A, category,
8590
- * section. The two differ only in WHICH statuses they may act on. */
9450
+ /** Block always uses a marquee. Channels only enables its marquee after the
9451
+ * organizer deliberately chooses Assign seats; Pan map keeps desktop drag
9452
+ * available for large charts. */
8591
9453
  isBulkSelectMode() {
8592
- return this.mode === "block" || this.mode === "channels" && this.channels?.canSelect() === true;
9454
+ return this.mode === "block" || this.mode === "channels" && this.channels?.usesMarqueeSelection() === true;
8593
9455
  }
8594
9456
  /**
8595
9457
  * Block never touches held or booked inventory, so it cannot select it.
@@ -8599,7 +9461,7 @@ var SeatManager = class {
8599
9461
  */
8600
9462
  selectableStatuses() {
8601
9463
  if (this.mode === "block") return ["free", "not_for_sale"];
8602
- if (this.mode === "inspect" || this.isBulkSelectMode()) {
9464
+ if (this.mode === "inspect" || this.isBulkSelectMode() || this.mode === "channels" && this.channels?.canSelect() === true) {
8603
9465
  return ["free", "held", "booked", "not_for_sale"];
8604
9466
  }
8605
9467
  return [];
@@ -8702,6 +9564,7 @@ var SeatManager = class {
8702
9564
  return;
8703
9565
  }
8704
9566
  if (!msg || typeof msg !== "object") return;
9567
+ this.lastMessageAt = Date.now();
8705
9568
  const m = msg;
8706
9569
  if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
8707
9570
  this.updateEffectiveAvailability(m.hidden, m.closed);
@@ -8758,6 +9621,7 @@ var SeatManager = class {
8758
9621
  const objs = await this.api.objects(this.key);
8759
9622
  this.applySnapshot(objs.seats);
8760
9623
  this.updateEffectiveAvailability(objs.hidden, objs.closed);
9624
+ this.lastMessageAt = Date.now();
8761
9625
  } catch {
8762
9626
  }
8763
9627
  }
@@ -9232,6 +10096,14 @@ var SeatManager = class {
9232
10096
  this.root?.classList.toggle("live", on);
9233
10097
  if (this.els.livetext) this.els.livetext.textContent = on ? "LIVE" : "RECONNECTING";
9234
10098
  this.paintMonitorInsights();
10099
+ const next = on ? "live" : "reconnecting";
10100
+ if (next === this.connectionStatus) return;
10101
+ this.connectionStatus = next;
10102
+ try {
10103
+ this.opts.onConnectionChange?.(this.getConnection());
10104
+ } catch (err) {
10105
+ this.opts.onError?.(err);
10106
+ }
9235
10107
  }
9236
10108
  updateZoomHint() {
9237
10109
  const hint = this.els.zoomhint;
@@ -9976,6 +10848,7 @@ var SeatManager = class {
9976
10848
  }
9977
10849
  };
9978
10850
  export {
10851
+ ACCESS_LINK_DEFAULTS,
9979
10852
  ApiError,
9980
10853
  BuyerAccessContext,
9981
10854
  BuyerAccessUnavailableError,
@@ -9991,6 +10864,10 @@ export {
9991
10864
  SeatingChart,
9992
10865
  accessIntentLabel,
9993
10866
  accessLine,
10867
+ accessLinkBadge,
10868
+ accessLinkErrorCopy,
10869
+ accessLinkIsLive,
10870
+ accessLinkPolicyLines,
9994
10871
  attachPickerFrame,
9995
10872
  bucketRows,
9996
10873
  bucketRowsHtml,