@seatlayer/js 0.41.0 → 0.43.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
@@ -6493,7 +6493,7 @@ function retryAfterCopy(details) {
6493
6493
  }
6494
6494
  function accessLine(access) {
6495
6495
  if (!access || !access.intent) return "\u2014";
6496
- const base = access.intent === "internal" ? "Internal selling \xB7 no buyer access needed" : access.intent === "server" ? "Server integration" : access.intent === "hosted_link" ? "Hosted access link" : "No buyer access configured";
6496
+ const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : "Not distributed yet";
6497
6497
  const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
6498
6498
  const detail = access.detail ?? grants;
6499
6499
  return detail ? `${base} \xB7 ${detail}` : base;
@@ -6885,9 +6885,10 @@ var ManageApi = class {
6885
6885
  };
6886
6886
 
6887
6887
  // src/channelsMode.ts
6888
- var POLL_MS = 1e4;
6888
+ var POLL_MS = 3e4;
6889
6889
  var MAX_FLAGS = 8;
6890
6890
  var SEAT_LIST_PAGE = 300;
6891
+ var MAX_ASSIGNMENT_UNITS = 5e3;
6891
6892
  var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
6892
6893
  var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
6893
6894
  var PREVIEW_UNAVAILABLE_FILL = "#303846";
@@ -6934,6 +6935,7 @@ var CHANNELS_CSS = (
6934
6935
  .slm-ch-staged .grow{flex:1}
6935
6936
  .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
6936
6937
  background:#f4b740;color:#1a1200;font-weight:800;font-size:12.5px}
6938
+ .slm-ch-staged .go:disabled{opacity:.48;cursor:not-allowed}
6937
6939
  .slm-ch-staged .drop{color:var(--slm-muted);font-weight:700;font-size:11.5px;min-height:44px;padding-inline:8px}
6938
6940
  .slm-ch-tick{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;
6939
6941
  background:#fff;color:#1f7a4d;font-weight:900;font-size:11px;animation:slm-ch-tick var(--slm-mo-base) var(--slm-mo-spring)}
@@ -6984,6 +6986,12 @@ var CHANNELS_CSS = (
6984
6986
  .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6985
6987
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
6986
6988
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
6989
+ /* distribute options \u2014 two actions, each with the one sentence that explains it */
6990
+ .slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
6991
+ border-radius:10px;background:var(--slm-surface)}
6992
+ .slm-ch-dist b{font-size:13px;font-weight:800}
6993
+ .slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
6994
+ .slm-ch-dist .slm-btn{width:100%;margin-top:2px}
6987
6995
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
6988
6996
  line-height:1.5;margin-bottom:12px}
6989
6997
  .slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
@@ -6999,7 +7007,7 @@ var CHANNELS_CSS = (
6999
7007
  /* dialogs */
7000
7008
  .slm-ch-scrim{position:absolute;inset:0;z-index:12;background:rgba(4,6,12,.62);display:grid;place-items:center;
7001
7009
  padding:18px;animation:slm-ch-fade var(--slm-mo-quick) var(--slm-mo-out)}
7002
- .slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;background:#12151f;border:1px solid var(--slm-line);
7010
+ .slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#12151f;border:1px solid var(--slm-line);
7003
7011
  border-radius:14px;padding:20px;box-shadow:0 24px 70px rgba(0,0,0,.6);
7004
7012
  animation:slm-ch-rise var(--slm-mo-base) var(--slm-mo-out)}
7005
7013
  .slm-ch-dialog h3{margin:0 0 4px;font-size:16px;font-weight:800;letter-spacing:-.01em}
@@ -7026,7 +7034,7 @@ var CHANNELS_CSS = (
7026
7034
  overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
7027
7035
  .slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
7028
7036
 
7029
- /* hosted access links \u2014 STATUS only; there is no Copy control on this card */
7037
+ /* buyer links \u2014 STATUS only; there is no Copy control on this card */
7030
7038
  .slm-ch-link{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
7031
7039
  margin-bottom:8px}
7032
7040
  .slm-ch-link .lk-head{display:flex;align-items:center;gap:8px}
@@ -7045,8 +7053,8 @@ var CHANNELS_CSS = (
7045
7053
  .slm-ch-radio input{flex:none;margin-top:2px}
7046
7054
  .slm-ch-radio b{display:block;font-weight:800;margin-bottom:2px}
7047
7055
  .slm-ch-radio .why{display:block;color:var(--slm-muted);font-size:11.5px;line-height:1.45}
7048
- .slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
7049
- background:var(--slm-surface);margin-top:10px}
7056
+ .slm-ch-seatlist{max-height:44vh;overflow:auto;overscroll-behavior:contain;border:1px solid var(--slm-line);
7057
+ border-radius:10px;background:var(--slm-surface);margin-top:10px}
7050
7058
  .slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
7051
7059
  justify-content:space-between;gap:8px;font-size:11px;font-weight:800;color:var(--slm-muted);position:sticky;top:0;
7052
7060
  background:var(--slm-surface)}
@@ -7058,6 +7066,25 @@ var CHANNELS_CSS = (
7058
7066
  .slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
7059
7067
  .slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
7060
7068
 
7069
+ /* scope chooser: whole sections or many rows in one additive pass */
7070
+ .slm-ch-scopebar{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:10px;margin-top:12px}
7071
+ .slm-ch-scopebar label{display:grid;gap:5px;color:var(--slm-muted);font-size:10.5px;font-weight:800}
7072
+ .slm-ch-scopebar input{width:100%;min-height:40px;padding:8px 10px;border:1px solid var(--slm-line);border-radius:8px;
7073
+ background:var(--slm-surface);color:var(--slm-text);font:inherit}
7074
+ .slm-ch-scopesummary{padding-bottom:10px;color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
7075
+ .slm-ch-scopegroup{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;
7076
+ gap:6px;padding:6px 8px;border-bottom:1px solid var(--slm-line);background:var(--slm-surface)}
7077
+ .slm-ch-groupcheck{display:flex;min-width:0;align-items:center;gap:8px;padding:5px 2px;text-align:left;font-size:11px;font-weight:800}
7078
+ .slm-ch-groupcheck .box{width:16px;height:16px;flex:none;display:grid;place-items:center;border:1px solid var(--slm-muted);border-radius:4px;
7079
+ color:transparent;font-size:10px}
7080
+ .slm-ch-groupcheck[aria-checked="true"] .box,.slm-ch-groupcheck[aria-checked="mixed"] .box{border-color:var(--slm-accent);
7081
+ background:var(--slm-accent);color:var(--slm-accent-ink)}
7082
+ .slm-ch-groupcheck .meta{min-width:0;margin-left:auto;color:var(--slm-muted);font-size:10.5px;font-weight:600;white-space:nowrap}
7083
+ .slm-ch-grouptoggle{min-height:32px;padding:5px 8px;color:var(--slm-accent);font-size:11px;font-weight:800}
7084
+ .slm-ch-scopehint{padding:8px 10px;color:var(--slm-muted);font-size:10.5px;border-bottom:1px solid var(--slm-line)}
7085
+ .slm-ch-scope-empty{padding:18px 12px;color:var(--slm-muted);font-size:11.5px;text-align:center}
7086
+ @media(max-width:560px){.slm-ch-scopebar{grid-template-columns:1fr}.slm-ch-scopesummary{padding-bottom:0}}
7087
+
7061
7088
  /* compact: bottom sheet with three detents (\xA713) */
7062
7089
  .slm.compact.ch-sheet .slm-rail{position:absolute;left:0;right:0;bottom:0;z-index:8;border-top:1px solid var(--slm-line);
7063
7090
  border-radius:18px 18px 0 0;background:#12151f;
@@ -7096,12 +7123,12 @@ function bucketRowsHtml(rows) {
7096
7123
  </div>`).join("");
7097
7124
  }
7098
7125
  var SERVER_INTEGRATION_HTML = `
7099
- <p class="slm-eyebrow" style="margin-top:18px">Server integration</p>
7100
- <p class="slm-hint">There is nothing to set up on this screen. Your own server mints a short-lived buyer
7126
+ <p class="slm-eyebrow" style="margin-top:18px">On your website</p>
7127
+ <p class="slm-hint">There is nothing more to set up on this screen. Your own server mints a short-lived buyer
7101
7128
  access session for this channel with the SeatLayer server SDK and hands it to the widget. A channel name
7102
7129
  on its own never grants access.</p>
7103
7130
  <p class="slm-note"><a class="slm-linkbtn" href="https://docs.seatlayer.io/server-api/channels"
7104
- target="_blank" rel="noreferrer noopener">Read the server integration guide \u2192</a></p>`;
7131
+ target="_blank" rel="noreferrer noopener">Read the website integration guide \u2192</a></p>`;
7105
7132
  function esc(value) {
7106
7133
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
7107
7134
  }
@@ -7182,6 +7209,21 @@ var ChannelsMode = class {
7182
7209
  this.stagedDoneTimer = null;
7183
7210
  this.lastSelectionCount = 0;
7184
7211
  this.lastCounts = /* @__PURE__ */ new Map();
7212
+ /** Rows are structural chart data — they do not move while the organizer is
7213
+ * allocating. Deriving them walks every seat, so the view is cached for the
7214
+ * lifetime of this mode entry and ordinary selection repaints stay O(1). */
7215
+ this.assignmentRowsCache = null;
7216
+ /** The markup currently in the rail. An identical repaint is skipped, which is
7217
+ * what keeps the organizer's scroll position (and open <select>) alive. */
7218
+ this.railHtml = null;
7219
+ /**
7220
+ * The `assignmentVersion` the allocation map was built from. Walking every
7221
+ * allocation page is the expensive half of a refresh and the server already
7222
+ * tells us, in the channels response, whether ANY seat moved. Unchanged
7223
+ * version, unchanged allocation — so the walk is skipped entirely.
7224
+ */
7225
+ this.allocationVersion = null;
7226
+ this.onVisibility = null;
7185
7227
  this.host = host;
7186
7228
  this.caps = capabilities;
7187
7229
  }
@@ -7192,6 +7234,8 @@ var ChannelsMode = class {
7192
7234
  this.active = true;
7193
7235
  this.mapIntent = "pan";
7194
7236
  this.focusedSectionId = null;
7237
+ this.assignmentRowsCache = null;
7238
+ this.railHtml = null;
7195
7239
  this.ensureLayer();
7196
7240
  this.host.root.classList.add("ch-mode");
7197
7241
  this.applySheetClasses();
@@ -7200,8 +7244,15 @@ var ChannelsMode = class {
7200
7244
  this.onInteractionChange?.();
7201
7245
  void this.refresh();
7202
7246
  this.pollTimer = setInterval(() => {
7247
+ if (typeof document !== "undefined" && document.hidden) return;
7203
7248
  void this.refresh({ quiet: true });
7204
7249
  }, POLL_MS);
7250
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
7251
+ this.onVisibility = () => {
7252
+ if (this.active && !document.hidden) void this.refresh({ quiet: true });
7253
+ };
7254
+ document.addEventListener("visibilitychange", this.onVisibility);
7255
+ }
7205
7256
  }
7206
7257
  /** Called when the cockpit leaves Channels mode. Everything this mode painted
7207
7258
  * over the map goes with it — no other tool ever inherits a channel overlay. */
@@ -7210,6 +7261,11 @@ var ChannelsMode = class {
7210
7261
  this.active = false;
7211
7262
  if (this.pollTimer) clearInterval(this.pollTimer);
7212
7263
  this.pollTimer = null;
7264
+ if (this.onVisibility && typeof document !== "undefined") {
7265
+ document.removeEventListener("visibilitychange", this.onVisibility);
7266
+ }
7267
+ this.onVisibility = null;
7268
+ this.railHtml = null;
7213
7269
  this.closeDialog({ restoreFocus: false });
7214
7270
  this.links = [];
7215
7271
  this.linksChannelId = null;
@@ -7298,8 +7354,11 @@ var ChannelsMode = class {
7298
7354
  if (!this.targetChannelId) {
7299
7355
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
7300
7356
  }
7301
- await this.loadAllocation(seq);
7302
- if (superseded()) return;
7357
+ if (this.allocationVersion !== list.assignmentVersion) {
7358
+ await this.loadAllocation(seq);
7359
+ if (superseded()) return;
7360
+ this.allocationVersion = list.assignmentVersion;
7361
+ }
7303
7362
  if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
7304
7363
  if (superseded()) return;
7305
7364
  this.loading = false;
@@ -7639,6 +7698,7 @@ var ChannelsMode = class {
7639
7698
  }
7640
7699
  const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
7641
7700
  const mutations = mutationCount(buckets);
7701
+ const tooLarge = labels.length > MAX_ASSIGNMENT_UNITS;
7642
7702
  const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
7643
7703
  const parts = [
7644
7704
  `<b>${labels.length.toLocaleString()}</b> selected`,
@@ -7650,7 +7710,9 @@ var ChannelsMode = class {
7650
7710
  <span>${parts.join(" \xB7 ")}</span>
7651
7711
  <span class="grow"></span>
7652
7712
  <button type="button" class="drop" data-ch-act="discard">Discard</button>
7653
- <button type="button" class="go" data-ch-act="review">Review changes</button>`);
7713
+ <button type="button" class="go" data-ch-act="review"${tooLarge ? " disabled" : ""}>
7714
+ ${tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : "Review changes"}
7715
+ </button>`);
7654
7716
  this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
7655
7717
  button.addEventListener("click", () => {
7656
7718
  if (button.dataset.chAct === "discard") this.host.clearSelection();
@@ -7718,35 +7780,59 @@ var ChannelsMode = class {
7718
7780
  this.paintOverlay();
7719
7781
  }
7720
7782
  }
7783
+ /**
7784
+ * Replace the rail's markup — but only when it actually differs, and never at
7785
+ * the cost of where the organizer had scrolled to.
7786
+ *
7787
+ * The rail repaints on a clock, on every selection change and after every
7788
+ * mutation. Rewriting `innerHTML` each time resets `scrollTop`, which is
7789
+ * exactly what "the rail gets stuck" was: scroll down to Create channel, the
7790
+ * poll ticks, and the list snaps back to the top under the cursor. So skip the
7791
+ * write when the markup is byte-identical, and restore the offset when it is
7792
+ * not.
7793
+ *
7794
+ * Returns whether the DOM was rewritten. Callers must only re-wire listeners
7795
+ * when it was — a skipped paint keeps the old nodes AND their listeners, so
7796
+ * re-wiring would double every handler.
7797
+ */
7798
+ setRailHtml(html) {
7799
+ if (html === this.railHtml) return false;
7800
+ const rail = this.host.rail;
7801
+ const scrollTop = rail.scrollTop;
7802
+ rail.innerHTML = html;
7803
+ this.railHtml = html;
7804
+ if (scrollTop) rail.scrollTop = scrollTop;
7805
+ return true;
7806
+ }
7721
7807
  paintRail() {
7722
7808
  if (!this.active) return;
7723
7809
  const rail = this.host.rail;
7724
7810
  if (!this.caps.view) {
7725
- rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
7726
- <p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`;
7811
+ this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
7812
+ <p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`);
7727
7813
  return;
7728
7814
  }
7729
7815
  if (this.loading && !this.list) {
7730
- rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
7731
- <div class="slm-empty">Loading allocations\u2026</div>`;
7816
+ this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
7817
+ <div class="slm-empty">Loading allocations\u2026</div>`);
7732
7818
  return;
7733
7819
  }
7734
7820
  if (!this.list && this.loadError) {
7735
- rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
7821
+ if (this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
7736
7822
  <div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7737
7823
  <span><b>Couldn't load sales channels.</b> Everything else on this event still works.
7738
- <button type="button" data-ch-act="retry">Try again</button></span></div>`;
7739
- rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
7740
- void this.refresh();
7741
- });
7824
+ <button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
7825
+ rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
7826
+ void this.refresh();
7827
+ });
7828
+ }
7742
7829
  return;
7743
7830
  }
7744
7831
  const selection = this.host.selectionLabels();
7745
7832
  const grab = this.host.isCompact() ? `<div class="slm-ch-grab"><span class="slm-ch-grabbar"></span></div>` : "";
7746
7833
  const segment = this.viewSegmentHtml();
7747
7834
  const body = this.view === "preview" ? this.previewRailHtml() : this.detailChannelId ? this.detailRailHtml(this.detailChannelId) : selection.length && this.caps.manage ? this.selectionRailHtml(selection) : this.listRailHtml();
7748
- rail.innerHTML = `${grab}${segment}${body}`;
7749
- this.wireRail();
7835
+ if (this.setRailHtml(`${grab}${segment}${body}`)) this.wireRail();
7750
7836
  this.paintStagedBar();
7751
7837
  }
7752
7838
  viewSegmentHtml() {
@@ -7817,8 +7903,9 @@ var ChannelsMode = class {
7817
7903
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
7818
7904
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
7819
7905
  return `
7906
+ ${this.caps.manage ? this.assignmentToolsHtml() : ""}
7820
7907
  <p class="slm-eyebrow">Sales channels</p>
7821
- <p class="slm-hint">Select seats on the map, then assign them. Channel colours and names are never shown to buyers.</p>
7908
+ <p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
7822
7909
  <div class="slm-ch-list">${rows}</div>
7823
7910
  ${create}
7824
7911
  ${readOnly}
@@ -7827,6 +7914,38 @@ var ChannelsMode = class {
7827
7914
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
7828
7915
  </p>`;
7829
7916
  }
7917
+ /**
7918
+ * Destination-first assignment controls.
7919
+ *
7920
+ * These used to live only inside the selection rail, which meant every route
7921
+ * into them was gated behind "select a seat on the map first" — the section,
7922
+ * row and category choosers were invisible until the organizer had already
7923
+ * done the work by hand. They now paint above the channel list too, so the
7924
+ * destination is chosen first and whole sections and rows are discoverable
7925
+ * without learning a hidden seat-first workflow.
7926
+ */
7927
+ assignmentToolsHtml() {
7928
+ const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
7929
+ const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
7930
+ const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
7931
+ const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
7932
+ return `
7933
+ <p class="slm-eyebrow">Assign inventory</p>
7934
+ <div class="slm-field">
7935
+ <label for="slm-ch-target">Assign to</label>
7936
+ <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
7937
+ </div>
7938
+ <p class="slm-eyebrow" style="margin-top:12px">Select by</p>
7939
+ <div class="slm-ch-row2" style="margin-top:2px">
7940
+ ${sections}${rows}
7941
+ <button type="button" class="${dragClass}" data-ch-act="drag-select">Drag box</button>
7942
+ </div>
7943
+ <div class="slm-ch-row2">
7944
+ <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
7945
+ <button type="button" class="slm-btn ghost" data-ch-act="seatlist">Seat list \u2328</button>
7946
+ </div>
7947
+ <p class="slm-note">Choose a destination, then add whole sections, multiple rows, a dragged area, or individual seats.</p>`;
7948
+ }
7830
7949
  selectionRailHtml(selection) {
7831
7950
  const sources = selectionSources(selection, this.allocation, this.list);
7832
7951
  const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
@@ -7835,34 +7954,27 @@ var ChannelsMode = class {
7835
7954
  <button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
7836
7955
  const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
7837
7956
  this.lastSelectionCount = selection.length;
7838
- const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
7957
+ const tooLarge = selection.length > MAX_ASSIGNMENT_UNITS;
7839
7958
  const sourceRows = sources.map((row) => {
7840
7959
  const marker = this.markerFor(row.channelId);
7841
7960
  return `<div class="slm-ch-selsrc-row">
7842
7961
  <span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
7843
7962
  <b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
7844
7963
  }).join("");
7845
- const sectionSelect = this.host.sections().length ? `<button type="button" class="slm-btn ghost" data-ch-act="pick-section">Section</button>` : "";
7846
7964
  return `
7847
7965
  ${conflict}
7966
+ ${this.assignmentToolsHtml()}
7848
7967
  <div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
7849
7968
  <span class="slm-sellabel">selected</span></div>
7850
7969
  <div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
7851
- <div class="slm-field">
7852
- <label for="slm-ch-target">Assign to</label>
7853
- <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
7854
- </div>
7970
+ ${tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span>
7971
+ <b>This selection is too large to apply at once.</b> Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()}
7972
+ seats, for example by splitting the venue into row groups.</span></div>` : ""}
7855
7973
  <p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
7856
7974
  Seats in checkout or already sold are never moved.</p>
7857
7975
  <div class="slm-ch-row2">
7858
7976
  <button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
7859
- <button type="button" class="slm-btn" data-ch-act="review">Review changes</button>
7860
- </div>
7861
- <p class="slm-eyebrow" style="margin-top:18px">Select by</p>
7862
- <div class="slm-ch-row2" style="margin-top:2px">
7863
- ${sectionSelect}
7864
- <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
7865
- <button type="button" class="slm-btn ghost" data-ch-act="seatlist">List \u2328</button>
7977
+ <button type="button" class="slm-btn" data-ch-act="review"${tooLarge ? " disabled" : ""}>Review changes</button>
7866
7978
  </div>
7867
7979
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
7868
7980
  }
@@ -7877,22 +7989,7 @@ var ChannelsMode = class {
7877
7989
  <button type="button" class="slm-btn ghost" data-ch-act="archive">Archive\u2026</button>
7878
7990
  </div>
7879
7991
  <p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
7880
- const intent = channel.access?.intent ?? "none";
7881
- const intents = ["none", "internal", "server", "hosted_link"];
7882
- const selfServiceGap = (intent === "server" || intent === "hosted_link") && !channel.access?.hasActiveGrants;
7883
- const access = this.caps.manage ? `
7884
- <p class="slm-eyebrow" style="margin-top:14px">Buyer access</p>
7885
- <div class="slm-field">
7886
- <label for="slm-ch-intent">How should buyers reach this channel?</label>
7887
- <select class="slm-select" id="slm-ch-intent" data-ch-intent>
7888
- ${intents.map((value) => `<option value="${value}"${value === intent ? " selected" : ""}>${esc(accessIntentLabel(value))}</option>`).join("")}
7889
- </select>
7890
- </div>
7891
- <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>
7892
- ${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
7893
- <span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
7894
- ${this.hostedLinksHtml()}
7895
- ${SERVER_INTEGRATION_HTML}` : "";
7992
+ const access = this.caps.manage ? this.distributeHtml(channel) : "";
7896
7993
  return `
7897
7994
  <p class="slm-eyebrow">
7898
7995
  <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
@@ -7902,7 +7999,54 @@ var ChannelsMode = class {
7902
7999
  ${access}
7903
8000
  ${lifecycle}`;
7904
8001
  }
7905
- // ---- hosted access links --------------------------------------------------
8002
+ /**
8003
+ * "Distribute" — how the seats in this channel actually reach a buyer.
8004
+ *
8005
+ * This replaced a four-value "access intent" picker (none / internal /
8006
+ * hosted_link / server). Two of those values did nothing anywhere: no buyer or
8007
+ * inventory path reads `access_intent`, so `none` and `internal` were labels an
8008
+ * organizer could set and then wait forever for something to happen. A third,
8009
+ * `hosted_link`, is not the organizer's to choose at all — the server sets it
8010
+ * when a buyer link is created and clears it when the last live one is revoked.
8011
+ *
8012
+ * So this is two ACTIONS, not a setting: create a buyer link, or point the
8013
+ * channel at a website integration. Both of them do something the moment they
8014
+ * are pressed. A legacy row still carrying `none` or `internal` renders the
8015
+ * neutral "not distributed yet" state with both actions offered — the stored
8016
+ * value is left alone (it is organizer-declared metadata and the API that
8017
+ * writes it is unchanged), it simply no longer has a control of its own.
8018
+ */
8019
+ distributeHtml(channel) {
8020
+ const intent = channel.access?.intent ?? "none";
8021
+ const live = channel.access?.hasActiveGrants === true;
8022
+ const state = live ? intent === "server" ? "Your website is letting buyers in. Only they can buy these seats." : "A buyer link is live. Only people with that link can buy these seats." : intent === "server" ? "Set up for your website \u2014 no buyer has come through yet. Seats stay reserved." : "Not distributed yet \u2014 seats stay reserved.";
8023
+ const option = (title, why, action, cta, primary) => `<div class="slm-ch-dist">
8024
+ <b>${esc(title)}</b>
8025
+ <span class="why">${esc(why)}</span>
8026
+ <button type="button" class="slm-btn${primary ? "" : " ghost"}" data-ch-act="${esc(action)}">${esc(cta)}</button>
8027
+ </div>`;
8028
+ const linksSupported = this.linksState !== "unsupported";
8029
+ return `
8030
+ <p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
8031
+ <p class="slm-hint">${esc(state)}</p>
8032
+ ${linksSupported ? option(
8033
+ "Sell with a buyer link",
8034
+ "SeatLayer makes a link you send to a named group. They open it and buy only these seats.",
8035
+ "link-create",
8036
+ this.links.some(accessLinkIsLive) ? "Create another buyer link" : "Create buyer link",
8037
+ true
8038
+ ) : ""}
8039
+ ${option(
8040
+ "Integrate a website or app",
8041
+ "Your website's backend grants each buyer access \u2014 set up on the Embed page.",
8042
+ "embed-code",
8043
+ "Get embed code",
8044
+ false
8045
+ )}
8046
+ ${this.hostedLinksHtml()}
8047
+ ${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
8048
+ }
8049
+ // ---- buyer links ----------------------------------------------------------
7906
8050
  /**
7907
8051
  * Read the status projection for the open channel. Never paints — the caller
7908
8052
  * decides when the rail repaints, so a poll-driven reload does not fight a
@@ -7932,32 +8076,34 @@ var ChannelsMode = class {
7932
8076
  }
7933
8077
  }
7934
8078
  /**
7935
- * The hosted-link section of the detail panel.
8079
+ * The buyer-link status section of the detail panel.
7936
8080
  *
7937
8081
  * STATUS ONLY, by design (comp 06 `hosted`): label, state, expiry,
7938
8082
  * redemptions, seats per buyer, live sessions. There is no Copy control here
7939
8083
  * and no field to hang one on — the URL was shown once at creation and cannot
7940
8084
  * be produced again. Rotation is the recovery path, and it says so.
8085
+ *
8086
+ * Creating a link is the Distribute card's action, not this section's, so a
8087
+ * channel with no links renders nothing here rather than an empty heading and
8088
+ * a second button saying the same thing.
7941
8089
  */
7942
8090
  hostedLinksHtml() {
7943
- const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Hosted access links</p>`;
8091
+ const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
7944
8092
  if (this.linksState === "unsupported") {
7945
8093
  return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
7946
- <span><b>Hosted links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
8094
+ <span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
7947
8095
  }
7948
8096
  if (this.linksState === "error") {
7949
8097
  return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7950
8098
  <span><b>Couldn't load this channel's links.</b>
7951
8099
  <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
7952
8100
  }
7953
- const live = this.links.filter(accessLinkIsLive).length;
7954
- 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
7955
- they open the link and buy only these seats.</p>`;
7956
- const create = this.caps.manage ? `<button type="button" class="slm-btn" style="width:100%" data-ch-act="link-create">
7957
- ${live ? "Create another hosted link" : "Create hosted access link"}</button>` : "";
8101
+ if (this.linksState === "loading" && !this.links.length) {
8102
+ return `${eyebrow}<div class="slm-empty">Loading links\u2026</div>`;
8103
+ }
8104
+ if (!this.links.length) return "";
7958
8105
  return `${eyebrow}
7959
- ${cards}
7960
- ${create}
8106
+ ${this.links.map((link) => this.linkCardHtml(link)).join("")}
7961
8107
  <p class="slm-note">A link is shown once, when you create it. SeatLayer keeps only a fingerprint of it, so it
7962
8108
  can never be shown again \u2014 if a link is lost, rotate it and send the fresh one.</p>`;
7963
8109
  }
@@ -7976,7 +8122,7 @@ var ChannelsMode = class {
7976
8122
  </div>` : "";
7977
8123
  return `<div class="slm-ch-link">
7978
8124
  <span class="lk-head">
7979
- <span class="lk-name">${esc(link.label || "Hosted link")}</span>
8125
+ <span class="lk-name">${esc(link.label || "Buyer link")}</span>
7980
8126
  <span class="slm-ch-badge ${badge.kind}">${esc(badge.text)}</span>
7981
8127
  </span>
7982
8128
  <div class="slm-ch-meter" role="img"
@@ -8079,10 +8225,6 @@ var ChannelsMode = class {
8079
8225
  this.previewIncludePublic = includePublic.checked;
8080
8226
  void this.loadPreview();
8081
8227
  });
8082
- const intent = rail.querySelector("[data-ch-intent]");
8083
- intent?.addEventListener("change", () => {
8084
- void this.setAccessIntent(intent.value);
8085
- });
8086
8228
  const grab = rail.querySelector(".slm-ch-grab");
8087
8229
  grab?.addEventListener("click", () => this.cycleDetent());
8088
8230
  rail.querySelectorAll("[data-ch-act]").forEach((button) => {
@@ -8127,6 +8269,13 @@ var ChannelsMode = class {
8127
8269
  case "link-create":
8128
8270
  this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
8129
8271
  break;
8272
+ // The one thing this screen can actually do for a website integration is
8273
+ // record that the channel is meant for one — which is exactly the flag the
8274
+ // dashboard's Embed page reads before it offers the snippet. Saying so is
8275
+ // honest; a fake "copy code" button on a screen with no code would not be.
8276
+ case "embed-code":
8277
+ void this.chooseWebsiteIntegration();
8278
+ break;
8130
8279
  case "link-reload":
8131
8280
  if (this.detailChannelId) void this.reloadLinks();
8132
8281
  break;
@@ -8141,8 +8290,16 @@ var ChannelsMode = class {
8141
8290
  this.conflict = false;
8142
8291
  void this.refresh().then(() => this.openDialog({ kind: "review" }));
8143
8292
  break;
8144
- case "pick-section":
8145
- this.pickSection();
8293
+ case "pick-sections":
8294
+ this.openDialog({ kind: "scope", scope: "sections" });
8295
+ break;
8296
+ case "pick-rows":
8297
+ this.openDialog({ kind: "scope", scope: "rows" });
8298
+ break;
8299
+ case "drag-select":
8300
+ this.mapIntent = "assign";
8301
+ this.paintRail();
8302
+ this.onInteractionChange?.();
8146
8303
  break;
8147
8304
  case "pick-category":
8148
8305
  this.pickCategory();
@@ -8152,12 +8309,10 @@ var ChannelsMode = class {
8152
8309
  }
8153
8310
  }
8154
8311
  // ---- selection helpers ----------------------------------------------------
8155
- pickSection() {
8156
- const sections = this.host.sections();
8157
- if (!sections.length) return;
8158
- this.promptChoice("Select a whole section", sections.map((s) => ({ value: s.id, label: s.label })), (value) => {
8159
- this.host.selectSection(value);
8160
- });
8312
+ /** Derived once per mode entry — see `assignmentRowsCache`. */
8313
+ assignmentRows() {
8314
+ if (!this.assignmentRowsCache) this.assignmentRowsCache = this.host.rows();
8315
+ return this.assignmentRowsCache;
8161
8316
  }
8162
8317
  pickCategory() {
8163
8318
  const categories = this.host.categories();
@@ -8188,6 +8343,209 @@ var ChannelsMode = class {
8188
8343
  });
8189
8344
  });
8190
8345
  }
8346
+ /**
8347
+ * Add several whole sections, or several whole rows, in one operation.
8348
+ *
8349
+ * ADDITIVE by contract: the chooser starts from the labels already selected
8350
+ * and only ever grows that set, so opening it never destroys a hard-won
8351
+ * marquee or seat-list selection. The confirm button always states the net
8352
+ * number of seats it will add, and refuses to exceed `MAX_ASSIGNMENT_UNITS`.
8353
+ *
8354
+ * Rows get the richer variant. A large venue has thousands of them, so they
8355
+ * arrive collapsed under their section, with a section-level tri-state
8356
+ * checkbox, a search across every row, and a render cap — the flat list used
8357
+ * for sections would be an unusable wall of buttons.
8358
+ */
8359
+ renderScopeDialog(state) {
8360
+ const scope = state.scope === "rows" ? "rows" : "sections";
8361
+ const options = scope === "sections" ? this.host.sections().map((section) => ({
8362
+ id: section.id,
8363
+ label: section.label,
8364
+ group: "Sections",
8365
+ labels: this.host.labelsInSection(section.id)
8366
+ })) : this.assignmentRows().map((row) => ({
8367
+ id: row.id,
8368
+ label: row.label,
8369
+ group: row.sectionLabel,
8370
+ labels: row.labels
8371
+ }));
8372
+ const available = options.filter((option) => option.labels.length > 0);
8373
+ const grouped = /* @__PURE__ */ new Map();
8374
+ for (const option of available) {
8375
+ const group = grouped.get(option.group) ?? [];
8376
+ group.push(option);
8377
+ grouped.set(option.group, group);
8378
+ }
8379
+ if (scope === "rows") {
8380
+ const groups = [...grouped.entries()];
8381
+ this.renderScrim(`
8382
+ <h3 id="slm-ch-dlg-title">Add multiple rows</h3>
8383
+ <p class="sub">Sections stay collapsed for speed. Select a whole section, expand only the rows you need, or search across every row.</p>
8384
+ <div class="slm-ch-scopebar">
8385
+ <label>Find section or row<input type="search" data-ch-scope-search placeholder="e.g. Orchestra or AA"></label>
8386
+ <span class="slm-ch-scopesummary" data-ch-scope-summary aria-live="polite">0 rows selected</span>
8387
+ </div>
8388
+ <p class="slm-ch-scopehint" data-ch-scope-filter>Showing section groups. Expand one or search to see rows.</p>
8389
+ <div class="slm-ch-seatlist" data-ch-scope-list></div>
8390
+ <p class="slm-ch-err" data-ch-error hidden></p>
8391
+ <div class="foot">
8392
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8393
+ <button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
8394
+ </div>`, (dialog) => {
8395
+ const byId = new Map(available.map((option) => [option.id, option]));
8396
+ const picked = /* @__PURE__ */ new Set();
8397
+ const expanded = /* @__PURE__ */ new Set();
8398
+ const current = new Set(this.host.selectionLabels());
8399
+ const confirm = dialog.querySelector("[data-ch-add-scope]");
8400
+ const error = dialog.querySelector("[data-ch-error]");
8401
+ const list = dialog.querySelector("[data-ch-scope-list]");
8402
+ const search = dialog.querySelector("[data-ch-scope-search]");
8403
+ const summary = dialog.querySelector("[data-ch-scope-summary]");
8404
+ const filterHint = dialog.querySelector("[data-ch-scope-filter]");
8405
+ const renderLimit = 200;
8406
+ let query = "";
8407
+ const selectedLabels = () => {
8408
+ const labels = new Set(current);
8409
+ for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
8410
+ return labels;
8411
+ };
8412
+ const update = () => {
8413
+ const labels = selectedLabels();
8414
+ const added = labels.size - current.size;
8415
+ const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
8416
+ confirm.disabled = added === 0 || tooLarge;
8417
+ confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
8418
+ error.hidden = !tooLarge;
8419
+ error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
8420
+ summary.textContent = `${picked.size.toLocaleString()} row${picked.size === 1 ? "" : "s"} \xB7 ${added.toLocaleString()} seat${added === 1 ? "" : "s"} added`;
8421
+ };
8422
+ const renderList = () => {
8423
+ const blocks = [];
8424
+ let totalMatches = 0;
8425
+ let rendered = 0;
8426
+ groups.forEach(([group, items], groupIndex) => {
8427
+ const normalizedGroup = group.toLocaleLowerCase();
8428
+ const matches = query ? items.filter((item) => `${normalizedGroup} ${item.label.toLocaleLowerCase()}`.includes(query)) : items;
8429
+ if (query && !matches.length) return;
8430
+ totalMatches += matches.length;
8431
+ const allPicked = items.every((item) => picked.has(item.id));
8432
+ const somePicked = !allPicked && items.some((item) => picked.has(item.id));
8433
+ const open = Boolean(query) || expanded.has(groupIndex);
8434
+ const room = Math.max(0, renderLimit - rendered);
8435
+ const visible = open ? matches.slice(0, room) : [];
8436
+ rendered += visible.length;
8437
+ const seatCount = items.reduce((sum, item) => sum + item.labels.length, 0);
8438
+ blocks.push(`<div class="slm-ch-scopegroup">
8439
+ <button type="button" class="slm-ch-groupcheck" role="checkbox" aria-checked="${allPicked ? "true" : somePicked ? "mixed" : "false"}"
8440
+ aria-label="Select all ${items.length.toLocaleString()} rows in ${esc(group)}" data-ch-scope-group="${groupIndex}">
8441
+ <span class="box" aria-hidden="true">\u2713</span><span>${esc(group)}</span>
8442
+ <span class="meta">${items.length.toLocaleString()} rows \xB7 ${seatCount.toLocaleString()} seats</span>
8443
+ </button>
8444
+ <button type="button" class="slm-ch-grouptoggle" aria-expanded="${open}" aria-label="${open ? "Hide" : "Show"} rows in ${esc(group)}"
8445
+ data-ch-scope-toggle="${groupIndex}">${open ? "Hide" : "Show"}</button>
8446
+ </div>`);
8447
+ blocks.push(...visible.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
8448
+ aria-checked="${picked.has(option.id)}" data-ch-scope-id="${esc(option.id)}">
8449
+ <span class="box" aria-hidden="true">\u2713</span><span>${esc(option.label)}</span>
8450
+ <span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
8451
+ </button>`));
8452
+ if (open && matches.length > visible.length) {
8453
+ blocks.push(`<p class="slm-ch-scopehint">${(matches.length - visible.length).toLocaleString()} more row${matches.length - visible.length === 1 ? "" : "s"}. Search to narrow the list.</p>`);
8454
+ }
8455
+ });
8456
+ list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div>`;
8457
+ filterHint.textContent = query ? `Showing ${rendered.toLocaleString()} of ${totalMatches.toLocaleString()} matching rows. Section checkboxes still select every row in that section.` : "Showing section groups. Expand one or search to see rows.";
8458
+ list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
8459
+ const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
8460
+ const remove = items.every((item) => picked.has(item.id));
8461
+ for (const item of items) remove ? picked.delete(item.id) : picked.add(item.id);
8462
+ renderList();
8463
+ update();
8464
+ }));
8465
+ list.querySelectorAll("[data-ch-scope-toggle]").forEach((button) => button.addEventListener("click", () => {
8466
+ const index = Number(button.dataset.chScopeToggle);
8467
+ if (expanded.has(index)) expanded.delete(index);
8468
+ else expanded.add(index);
8469
+ renderList();
8470
+ }));
8471
+ list.querySelectorAll("[data-ch-scope-id]").forEach((button) => button.addEventListener("click", () => {
8472
+ const id = button.dataset.chScopeId;
8473
+ if (picked.has(id)) picked.delete(id);
8474
+ else picked.add(id);
8475
+ renderList();
8476
+ update();
8477
+ }));
8478
+ };
8479
+ search.addEventListener("input", () => {
8480
+ query = search.value.trim().toLocaleLowerCase();
8481
+ renderList();
8482
+ update();
8483
+ });
8484
+ confirm.addEventListener("click", () => {
8485
+ const labels = [...selectedLabels()];
8486
+ if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
8487
+ this.closeDialog();
8488
+ this.host.selectByLabels(labels);
8489
+ });
8490
+ renderList();
8491
+ update();
8492
+ });
8493
+ return;
8494
+ }
8495
+ const body = [...grouped.entries()].map(([group, items]) => `
8496
+ <div class="slm-ch-seatgroup"><span>${esc(group)}</span></div>
8497
+ ${items.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
8498
+ aria-checked="false" data-ch-scope-id="${esc(option.id)}">
8499
+ <span class="box" aria-hidden="true">\u2713</span>
8500
+ <span>${esc(option.label)}</span>
8501
+ <span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
8502
+ </button>`).join("")}`).join("");
8503
+ this.renderScrim(`
8504
+ <h3 id="slm-ch-dlg-title">Add whole sections</h3>
8505
+ <p class="sub">Choose one or more. They are added to the seats already selected on the map.</p>
8506
+ <div class="slm-ch-seatlist">${body || `<div class="slm-empty">No sections are available.</div>`}</div>
8507
+ <p class="slm-ch-err" data-ch-error hidden></p>
8508
+ <div class="foot">
8509
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8510
+ <button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
8511
+ </div>`, (dialog) => {
8512
+ const byId = new Map(available.map((option) => [option.id, option]));
8513
+ const picked = /* @__PURE__ */ new Set();
8514
+ const current = new Set(this.host.selectionLabels());
8515
+ const confirm = dialog.querySelector("[data-ch-add-scope]");
8516
+ const error = dialog.querySelector("[data-ch-error]");
8517
+ const selectedLabels = () => {
8518
+ const labels = new Set(current);
8519
+ for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
8520
+ return labels;
8521
+ };
8522
+ const update = () => {
8523
+ const labels = selectedLabels();
8524
+ const added = labels.size - current.size;
8525
+ const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
8526
+ confirm.disabled = added === 0 || tooLarge;
8527
+ confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
8528
+ error.hidden = !tooLarge;
8529
+ error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
8530
+ };
8531
+ dialog.querySelectorAll("[data-ch-scope-id]").forEach((button) => {
8532
+ button.addEventListener("click", () => {
8533
+ const id = button.dataset.chScopeId;
8534
+ if (picked.has(id)) picked.delete(id);
8535
+ else picked.add(id);
8536
+ button.setAttribute("aria-checked", String(picked.has(id)));
8537
+ update();
8538
+ });
8539
+ });
8540
+ confirm.addEventListener("click", () => {
8541
+ const labels = [...selectedLabels()];
8542
+ if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
8543
+ this.closeDialog();
8544
+ this.host.selectByLabels(labels);
8545
+ });
8546
+ update();
8547
+ });
8548
+ }
8191
8549
  // ---- dialogs --------------------------------------------------------------
8192
8550
  openDialog(state) {
8193
8551
  this.dialog = state;
@@ -8198,6 +8556,7 @@ var ChannelsMode = class {
8198
8556
  if (!state) return;
8199
8557
  if (state.kind === "create") this.renderCreateDialog(state);
8200
8558
  else if (state.kind === "review") this.renderReviewDialog(state);
8559
+ else if (state.kind === "scope") this.renderScopeDialog(state);
8201
8560
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8202
8561
  else if (state.kind === "rename") this.renderRenameDialog(state);
8203
8562
  else if (state.kind === "seatlist") this.renderSeatListDialog();
@@ -8342,24 +8701,28 @@ var ChannelsMode = class {
8342
8701
  const { labels, buckets } = this.currentPlan();
8343
8702
  const authoritative = state.applied;
8344
8703
  const shown = authoritative ? authoritative.buckets : buckets;
8345
- const targetName = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8704
+ const targetName = this.nameOf(authoritative?.targetChannelId ?? this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8346
8705
  const rows = bucketRows(shown, targetName);
8347
8706
  const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
8348
8707
  const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
8708
+ const tooLarge = !authoritative && labels.length > MAX_ASSIGNMENT_UNITS;
8349
8709
  const rowsHtml = bucketRowsHtml(rows);
8350
8710
  const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
8351
8711
  <button type="button" class="quiet" data-ch-close>Back</button>
8352
- <button type="button" class="slm-btn" data-ch-apply ${allSkipped || state.busy ? "disabled" : ""}>
8712
+ <button type="button" class="slm-btn" data-ch-apply ${allSkipped || tooLarge || state.busy ? "disabled" : ""}>
8353
8713
  ${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
8354
8714
  </button>
8355
8715
  </div>`;
8356
8716
  const confirmNote = !authoritative && needsMoveConfirmation(buckets) ? `<p class="slm-note">Applying moves inventory out of another private channel. That is the line marked above.</p>` : "";
8357
8717
  const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
8358
8718
  every seat is in a buyer's checkout, already sold, or already in ${esc(targetName)}.</span></div>` : "";
8719
+ const limitNote = tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span><b>Choose fewer seats.</b>
8720
+ One Apply can cover at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats.</span></div>` : "";
8359
8721
  this.renderScrim(`
8360
8722
  <h3 id="slm-ch-dlg-title">${authoritative ? `Moved ${authoritative.applied.toLocaleString()} seat${authoritative.applied === 1 ? "" : "s"} to ${esc(targetName)}` : `Move ${labels.length.toLocaleString()} selected seat${labels.length === 1 ? "" : "s"} to ${esc(targetName)}`}</h3>
8361
8723
  <p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
8362
8724
  ${skippedNote}
8725
+ ${limitNote}
8363
8726
  ${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
8364
8727
  ${confirmNote}
8365
8728
  <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
@@ -8371,15 +8734,21 @@ var ChannelsMode = class {
8371
8734
  }
8372
8735
  }
8373
8736
  /**
8374
- * Apply. On success the review sheet re-renders with the AUTHORITATIVE server
8375
- * buckets and the staged bar morphs to a for 1.2s. On a stale version the
8376
- * server mutated nothing: keep the selection, shake the bar once, and offer
8377
- * exactly one action Refresh and review.
8737
+ * Apply. On success the sheet CLOSES and the staged bar confirms what moved,
8738
+ * naming the destination and any skipped seats, with the AUTHORITATIVE server
8739
+ * buckets still one Details press away. Leaving the modal up on success made
8740
+ * the organizer dismiss a sheet to get back to a map they had just changed.
8741
+ * On a stale version the server mutated nothing: keep the selection, shake
8742
+ * the bar once, and offer exactly one action — Refresh and review.
8378
8743
  */
8379
8744
  async apply() {
8380
8745
  if (!this.dialog || !this.caps.manage) return;
8381
8746
  const { labels } = this.currentPlan();
8382
8747
  if (!labels.length) return;
8748
+ if (labels.length > MAX_ASSIGNMENT_UNITS) {
8749
+ this.showDialogError(`Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats for one Apply.`);
8750
+ return;
8751
+ }
8383
8752
  this.dialog = { ...this.dialog, busy: true, error: null };
8384
8753
  this.renderDialog();
8385
8754
  try {
@@ -8392,11 +8761,10 @@ var ChannelsMode = class {
8392
8761
  });
8393
8762
  this.assignmentVersion = result.assignmentVersion;
8394
8763
  this.conflict = false;
8395
- this.dialog = { kind: "review", applied: result };
8396
8764
  await this.refresh({ quiet: true });
8397
- this.renderDialog();
8398
- this.showApplied(result);
8765
+ this.closeDialog({ restoreFocus: false });
8399
8766
  this.host.clearSelection();
8767
+ this.showApplied(result);
8400
8768
  } catch (err) {
8401
8769
  const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
8402
8770
  if (conflict) {
@@ -8419,12 +8787,26 @@ var ChannelsMode = class {
8419
8787
  this.host.onError(err);
8420
8788
  }
8421
8789
  }
8790
+ /**
8791
+ * The success receipt, now that the review sheet closes on Apply. It names the
8792
+ * destination and the seats that could not move, keeps the authoritative
8793
+ * buckets reachable through Details, and stays up long enough to be read —
8794
+ * 1.2s was tuned for a confirmation the organizer was already looking at.
8795
+ */
8422
8796
  showApplied(result) {
8797
+ const targetName = this.nameOf(result.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8798
+ const skipped = result.buckets.skippedHeld.count + result.buckets.skippedBooked.count + result.buckets.notFound.count;
8423
8799
  this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
8424
- <span>Applied <b>${result.applied.toLocaleString()}</b> change${result.applied === 1 ? "" : "s"}</span>
8425
- <span class="grow"></span>`, "done");
8800
+ <span>Assigned <b>${result.applied.toLocaleString()}</b> seat${result.applied === 1 ? "" : "s"} to ${esc(targetName)}
8801
+ ${skipped ? ` \xB7 ${skipped.toLocaleString()} skipped` : ""}</span>
8802
+ <span class="grow"></span>
8803
+ <button type="button" class="drop" data-ch-applied-details>Details</button>`, "done");
8804
+ this.stagedEl?.querySelector("[data-ch-applied-details]")?.addEventListener("click", () => {
8805
+ this.openDialog({ kind: "review", applied: result });
8806
+ });
8807
+ this.announce(`Assigned ${result.applied} seat${result.applied === 1 ? "" : "s"} to ${targetName}${skipped ? `; ${skipped} skipped` : ""}.`);
8426
8808
  if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
8427
- this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 1200);
8809
+ this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 5e3);
8428
8810
  }
8429
8811
  shakeStaged() {
8430
8812
  const bar = this.stagedEl;
@@ -8467,12 +8849,27 @@ var ChannelsMode = class {
8467
8849
  });
8468
8850
  });
8469
8851
  }
8852
+ /**
8853
+ * "Get embed code" — mark the channel as reached through the organizer's own
8854
+ * backend. The write itself is `setChannelAccessIntent(…, 'server')`, the same
8855
+ * API the old picker called; the difference is that it is now a deliberate
8856
+ * action with a consequence the organizer is told about, rather than one of
8857
+ * four dropdown values with no observable effect.
8858
+ */
8859
+ async chooseWebsiteIntegration() {
8860
+ const channelId = this.detailChannelId;
8861
+ if (!channelId) return;
8862
+ await this.setAccessIntent("server");
8863
+ }
8470
8864
  async setAccessIntent(accessIntent) {
8471
8865
  const channelId = this.detailChannelId;
8472
8866
  if (!channelId || !this.caps.manage) return;
8473
8867
  try {
8474
8868
  await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, accessIntent);
8475
8869
  await this.refresh();
8870
+ if (accessIntent === "server") {
8871
+ this.host.toast("Marked for your website. The embed code is on the Embed page.", "ok");
8872
+ }
8476
8873
  } catch (err) {
8477
8874
  this.host.toast("Couldn't save how buyers reach this channel.", "err");
8478
8875
  this.host.onError(err);
@@ -8613,7 +9010,7 @@ var ChannelsMode = class {
8613
9010
  });
8614
9011
  });
8615
9012
  }
8616
- // ---- hosted-link dialogs --------------------------------------------------
9013
+ // ---- buyer-link dialogs ---------------------------------------------------
8617
9014
  async reloadLinks() {
8618
9015
  const channelId = this.detailChannelId;
8619
9016
  if (!channelId) return;
@@ -8656,7 +9053,7 @@ var ChannelsMode = class {
8656
9053
  return;
8657
9054
  }
8658
9055
  this.renderScrim(`
8659
- <h3 id="slm-ch-dlg-title">Create a hosted access link for ${esc(channel.name)}</h3>
9056
+ <h3 id="slm-ch-dlg-title">Create a buyer link for ${esc(channel.name)}</h3>
8660
9057
  <p class="sub">Anyone who opens the link can buy from this channel's allocation \u2014 and only from it.
8661
9058
  You'll see the link once, right after you create it.</p>
8662
9059
  <div class="slm-field">
@@ -8781,7 +9178,7 @@ var ChannelsMode = class {
8781
9178
  copy?.addEventListener("click", () => {
8782
9179
  const ok = () => {
8783
9180
  copy.textContent = "Copied";
8784
- this.announce("Hosted access link copied.");
9181
+ this.announce("Buyer link copied.");
8785
9182
  };
8786
9183
  const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
8787
9184
  if (clipboard?.writeText) {
@@ -8791,7 +9188,7 @@ var ChannelsMode = class {
8791
9188
  selectSecret(dialog);
8792
9189
  });
8793
9190
  });
8794
- this.announce("Your hosted access link is ready and is shown once.");
9191
+ this.announce("Your buyer link is ready and is shown once.");
8795
9192
  }
8796
9193
  /**
8797
9194
  * Rotate. The organizer must SAY what happens to the buyers already inside —
@@ -8810,7 +9207,7 @@ var ChannelsMode = class {
8810
9207
  <span><b>${sessions.toLocaleString()} buyer${sessions === 1 ? "" : "s"}</b> got in with the current link
8811
9208
  and still ${sessions === 1 ? "has" : "have"} active access.</span></div>` : "";
8812
9209
  this.renderScrim(`
8813
- <h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "hosted")} link?</h3>
9210
+ <h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "buyer")} link?</h3>
8814
9211
  <p class="sub">The current link stops opening immediately and cannot be restored. You will get a new
8815
9212
  link to copy \u2014 shown once.</p>
8816
9213
  ${warning}
@@ -8869,7 +9266,7 @@ var ChannelsMode = class {
8869
9266
  }
8870
9267
  const sessions = link.activeSessions ?? 0;
8871
9268
  this.renderScrim(`
8872
- <h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "hosted")} link?</h3>
9269
+ <h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "buyer")} link?</h3>
8873
9270
  <p class="sub">It stops opening immediately and cannot be restored \u2014 there is no undo, and no way to
8874
9271
  bring the same URL back. Seats already bought through it keep their sale.</p>
8875
9272
  ${sessions ? `<label class="slm-ch-radio">
@@ -8986,7 +9383,12 @@ var LEGEND = [
8986
9383
  var MANAGER_CSS = (
8987
9384
  /* @sl-css */
8988
9385
  `
8989
- .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:480px;overflow:hidden;
9386
+ /* The floor was 480px, which trapped the cockpit in any host shorter than that:
9387
+ every ancestor here is overflow:hidden, so the bottom of the rail (Create
9388
+ channel, Show archived) was rendered but clipped away by the host and could
9389
+ not be reached by scrolling anything. 320px still keeps an unsized host from
9390
+ collapsing to the top bar, and the rail scrolls to its full content below. */
9391
+ .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:320px;overflow:hidden;
8990
9392
  background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius);
8991
9393
  /* Motion tokens (motion-system \xA72), declared by the cockpit ROOT rather than
8992
9394
  borrowed from CHANNELS_CSS. The base cockpit animates whether or not
@@ -9054,7 +9456,7 @@ var MANAGER_CSS = (
9054
9456
  .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
9055
9457
  white-space:nowrap;font-size:12px;font-weight:800}.slm-liveeventhint{color:var(--slm-muted);font-size:10px;white-space:nowrap}
9056
9458
  .slm-rail{width:320px;flex:none;border-left:1px solid var(--slm-line);display:flex;flex-direction:column;min-height:0}
9057
- .slm-railscroll{flex:1;overflow-y:auto;padding:16px}
9459
+ .slm-railscroll{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:16px}
9058
9460
  .slm-eyebrow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--slm-muted);font-weight:800;margin-bottom:6px}
9059
9461
  .slm-hint{font-size:12.5px;color:var(--slm-muted);line-height:1.5;margin-bottom:14px}
9060
9462
 
@@ -9119,7 +9521,8 @@ var MANAGER_CSS = (
9119
9521
  color:var(--slm-muted);font-size:10.5px}
9120
9522
  .slm-linkbtn{font-size:11px!important;font-weight:800!important;color:var(--slm-accent)!important;text-align:right}
9121
9523
  .slm-linkbtn:disabled{opacity:.45;cursor:not-allowed}
9122
- .slm-blockedlist{max-height:246px;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
9524
+ .slm-blockedlist{max-height:246px;overflow:auto;overscroll-behavior:contain;border:1px solid var(--slm-line);
9525
+ border-radius:10px;background:var(--slm-surface)}
9123
9526
  .slm-blockeditem{display:grid!important;grid-template-columns:18px minmax(0,1fr);width:100%;gap:8px;padding:8px 9px!important;
9124
9527
  border-bottom:1px solid var(--slm-line)!important;text-align:left!important}
9125
9528
  .slm-blockeditem:last-child{border-bottom:0!important}.slm-blockeditem:hover{background:rgba(255,255,255,.035)!important}
@@ -9514,6 +9917,30 @@ var SeatManager = class {
9514
9917
  this.selectSection(sectionId);
9515
9918
  },
9516
9919
  sections: () => this.sectionOptions,
9920
+ labelsInSection: (sectionId) => this.renderer?.getSelectableInSection(sectionId).map((seat) => seat.label) ?? [],
9921
+ // Logical rows, not physical row objects: a segmented row is authored as
9922
+ // several objects sharing one `logicalRowId`, and the organizer thinks of
9923
+ // it as one row. Tables carry seats too, so they are rows here as well.
9924
+ rows: () => {
9925
+ const objects = new Map((this.doc?.objects ?? []).map((object) => [object.id, object]));
9926
+ const rows = /* @__PURE__ */ new Map();
9927
+ for (const seat of this.labelToSeat.values()) {
9928
+ const object = objects.get(seat.rowId);
9929
+ if (!object || object.type !== "row" && object.type !== "table") continue;
9930
+ const id = seat.logicalRowId ?? seat.rowId;
9931
+ const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
9932
+ const row = rows.get(id) ?? {
9933
+ id,
9934
+ label: object.type === "row" ? object.segmentedRow?.displayLabel ?? object.displayLabel ?? object.label : object.displayLabel ?? object.label,
9935
+ sectionId,
9936
+ sectionLabel: this.sectionLabelById.get(sectionId) ?? "Other seats",
9937
+ labels: []
9938
+ };
9939
+ row.labels.push(seat.label);
9940
+ rows.set(id, row);
9941
+ }
9942
+ return [...rows.values()].sort((left, right) => left.sectionLabel.localeCompare(right.sectionLabel, void 0, { numeric: true, sensitivity: "base" }) || left.label.localeCompare(right.label, void 0, { numeric: true, sensitivity: "base" }));
9943
+ },
9517
9944
  categories: () => (this.doc?.categories ?? []).map((category) => ({
9518
9945
  key: category.key,
9519
9946
  label: category.label ?? category.key,