@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.cjs CHANGED
@@ -6901,7 +6901,7 @@ function retryAfterCopy(details) {
6901
6901
  }
6902
6902
  function accessLine(access) {
6903
6903
  if (!access || !access.intent) return "\u2014";
6904
- 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";
6904
+ const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : "Not distributed yet";
6905
6905
  const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
6906
6906
  const detail = access.detail ?? grants;
6907
6907
  return detail ? `${base} \xB7 ${detail}` : base;
@@ -7293,9 +7293,10 @@ var ManageApi = class {
7293
7293
  };
7294
7294
 
7295
7295
  // src/channelsMode.ts
7296
- var POLL_MS = 1e4;
7296
+ var POLL_MS = 3e4;
7297
7297
  var MAX_FLAGS = 8;
7298
7298
  var SEAT_LIST_PAGE = 300;
7299
+ var MAX_ASSIGNMENT_UNITS = 5e3;
7299
7300
  var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
7300
7301
  var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
7301
7302
  var PREVIEW_UNAVAILABLE_FILL = "#303846";
@@ -7342,6 +7343,7 @@ var CHANNELS_CSS = (
7342
7343
  .slm-ch-staged .grow{flex:1}
7343
7344
  .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
7344
7345
  background:#f4b740;color:#1a1200;font-weight:800;font-size:12.5px}
7346
+ .slm-ch-staged .go:disabled{opacity:.48;cursor:not-allowed}
7345
7347
  .slm-ch-staged .drop{color:var(--slm-muted);font-weight:700;font-size:11.5px;min-height:44px;padding-inline:8px}
7346
7348
  .slm-ch-tick{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;
7347
7349
  background:#fff;color:#1f7a4d;font-weight:900;font-size:11px;animation:slm-ch-tick var(--slm-mo-base) var(--slm-mo-spring)}
@@ -7392,6 +7394,12 @@ var CHANNELS_CSS = (
7392
7394
  .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
7393
7395
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
7394
7396
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
7397
+ /* distribute options \u2014 two actions, each with the one sentence that explains it */
7398
+ .slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
7399
+ border-radius:10px;background:var(--slm-surface)}
7400
+ .slm-ch-dist b{font-size:13px;font-weight:800}
7401
+ .slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
7402
+ .slm-ch-dist .slm-btn{width:100%;margin-top:2px}
7395
7403
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
7396
7404
  line-height:1.5;margin-bottom:12px}
7397
7405
  .slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
@@ -7407,7 +7415,7 @@ var CHANNELS_CSS = (
7407
7415
  /* dialogs */
7408
7416
  .slm-ch-scrim{position:absolute;inset:0;z-index:12;background:rgba(4,6,12,.62);display:grid;place-items:center;
7409
7417
  padding:18px;animation:slm-ch-fade var(--slm-mo-quick) var(--slm-mo-out)}
7410
- .slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;background:#12151f;border:1px solid var(--slm-line);
7418
+ .slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#12151f;border:1px solid var(--slm-line);
7411
7419
  border-radius:14px;padding:20px;box-shadow:0 24px 70px rgba(0,0,0,.6);
7412
7420
  animation:slm-ch-rise var(--slm-mo-base) var(--slm-mo-out)}
7413
7421
  .slm-ch-dialog h3{margin:0 0 4px;font-size:16px;font-weight:800;letter-spacing:-.01em}
@@ -7434,7 +7442,7 @@ var CHANNELS_CSS = (
7434
7442
  overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
7435
7443
  .slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
7436
7444
 
7437
- /* hosted access links \u2014 STATUS only; there is no Copy control on this card */
7445
+ /* buyer links \u2014 STATUS only; there is no Copy control on this card */
7438
7446
  .slm-ch-link{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
7439
7447
  margin-bottom:8px}
7440
7448
  .slm-ch-link .lk-head{display:flex;align-items:center;gap:8px}
@@ -7453,8 +7461,8 @@ var CHANNELS_CSS = (
7453
7461
  .slm-ch-radio input{flex:none;margin-top:2px}
7454
7462
  .slm-ch-radio b{display:block;font-weight:800;margin-bottom:2px}
7455
7463
  .slm-ch-radio .why{display:block;color:var(--slm-muted);font-size:11.5px;line-height:1.45}
7456
- .slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
7457
- background:var(--slm-surface);margin-top:10px}
7464
+ .slm-ch-seatlist{max-height:44vh;overflow:auto;overscroll-behavior:contain;border:1px solid var(--slm-line);
7465
+ border-radius:10px;background:var(--slm-surface);margin-top:10px}
7458
7466
  .slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
7459
7467
  justify-content:space-between;gap:8px;font-size:11px;font-weight:800;color:var(--slm-muted);position:sticky;top:0;
7460
7468
  background:var(--slm-surface)}
@@ -7466,6 +7474,25 @@ var CHANNELS_CSS = (
7466
7474
  .slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
7467
7475
  .slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
7468
7476
 
7477
+ /* scope chooser: whole sections or many rows in one additive pass */
7478
+ .slm-ch-scopebar{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:10px;margin-top:12px}
7479
+ .slm-ch-scopebar label{display:grid;gap:5px;color:var(--slm-muted);font-size:10.5px;font-weight:800}
7480
+ .slm-ch-scopebar input{width:100%;min-height:40px;padding:8px 10px;border:1px solid var(--slm-line);border-radius:8px;
7481
+ background:var(--slm-surface);color:var(--slm-text);font:inherit}
7482
+ .slm-ch-scopesummary{padding-bottom:10px;color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
7483
+ .slm-ch-scopegroup{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;
7484
+ gap:6px;padding:6px 8px;border-bottom:1px solid var(--slm-line);background:var(--slm-surface)}
7485
+ .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}
7486
+ .slm-ch-groupcheck .box{width:16px;height:16px;flex:none;display:grid;place-items:center;border:1px solid var(--slm-muted);border-radius:4px;
7487
+ color:transparent;font-size:10px}
7488
+ .slm-ch-groupcheck[aria-checked="true"] .box,.slm-ch-groupcheck[aria-checked="mixed"] .box{border-color:var(--slm-accent);
7489
+ background:var(--slm-accent);color:var(--slm-accent-ink)}
7490
+ .slm-ch-groupcheck .meta{min-width:0;margin-left:auto;color:var(--slm-muted);font-size:10.5px;font-weight:600;white-space:nowrap}
7491
+ .slm-ch-grouptoggle{min-height:32px;padding:5px 8px;color:var(--slm-accent);font-size:11px;font-weight:800}
7492
+ .slm-ch-scopehint{padding:8px 10px;color:var(--slm-muted);font-size:10.5px;border-bottom:1px solid var(--slm-line)}
7493
+ .slm-ch-scope-empty{padding:18px 12px;color:var(--slm-muted);font-size:11.5px;text-align:center}
7494
+ @media(max-width:560px){.slm-ch-scopebar{grid-template-columns:1fr}.slm-ch-scopesummary{padding-bottom:0}}
7495
+
7469
7496
  /* compact: bottom sheet with three detents (\xA713) */
7470
7497
  .slm.compact.ch-sheet .slm-rail{position:absolute;left:0;right:0;bottom:0;z-index:8;border-top:1px solid var(--slm-line);
7471
7498
  border-radius:18px 18px 0 0;background:#12151f;
@@ -7504,12 +7531,12 @@ function bucketRowsHtml(rows) {
7504
7531
  </div>`).join("");
7505
7532
  }
7506
7533
  var SERVER_INTEGRATION_HTML = `
7507
- <p class="slm-eyebrow" style="margin-top:18px">Server integration</p>
7508
- <p class="slm-hint">There is nothing to set up on this screen. Your own server mints a short-lived buyer
7534
+ <p class="slm-eyebrow" style="margin-top:18px">On your website</p>
7535
+ <p class="slm-hint">There is nothing more to set up on this screen. Your own server mints a short-lived buyer
7509
7536
  access session for this channel with the SeatLayer server SDK and hands it to the widget. A channel name
7510
7537
  on its own never grants access.</p>
7511
7538
  <p class="slm-note"><a class="slm-linkbtn" href="https://docs.seatlayer.io/server-api/channels"
7512
- target="_blank" rel="noreferrer noopener">Read the server integration guide \u2192</a></p>`;
7539
+ target="_blank" rel="noreferrer noopener">Read the website integration guide \u2192</a></p>`;
7513
7540
  function esc(value) {
7514
7541
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
7515
7542
  }
@@ -7590,6 +7617,21 @@ var ChannelsMode = class {
7590
7617
  this.stagedDoneTimer = null;
7591
7618
  this.lastSelectionCount = 0;
7592
7619
  this.lastCounts = /* @__PURE__ */ new Map();
7620
+ /** Rows are structural chart data — they do not move while the organizer is
7621
+ * allocating. Deriving them walks every seat, so the view is cached for the
7622
+ * lifetime of this mode entry and ordinary selection repaints stay O(1). */
7623
+ this.assignmentRowsCache = null;
7624
+ /** The markup currently in the rail. An identical repaint is skipped, which is
7625
+ * what keeps the organizer's scroll position (and open <select>) alive. */
7626
+ this.railHtml = null;
7627
+ /**
7628
+ * The `assignmentVersion` the allocation map was built from. Walking every
7629
+ * allocation page is the expensive half of a refresh and the server already
7630
+ * tells us, in the channels response, whether ANY seat moved. Unchanged
7631
+ * version, unchanged allocation — so the walk is skipped entirely.
7632
+ */
7633
+ this.allocationVersion = null;
7634
+ this.onVisibility = null;
7593
7635
  this.host = host;
7594
7636
  this.caps = capabilities;
7595
7637
  }
@@ -7600,6 +7642,8 @@ var ChannelsMode = class {
7600
7642
  this.active = true;
7601
7643
  this.mapIntent = "pan";
7602
7644
  this.focusedSectionId = null;
7645
+ this.assignmentRowsCache = null;
7646
+ this.railHtml = null;
7603
7647
  this.ensureLayer();
7604
7648
  this.host.root.classList.add("ch-mode");
7605
7649
  this.applySheetClasses();
@@ -7608,8 +7652,15 @@ var ChannelsMode = class {
7608
7652
  this.onInteractionChange?.();
7609
7653
  void this.refresh();
7610
7654
  this.pollTimer = setInterval(() => {
7655
+ if (typeof document !== "undefined" && document.hidden) return;
7611
7656
  void this.refresh({ quiet: true });
7612
7657
  }, POLL_MS);
7658
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
7659
+ this.onVisibility = () => {
7660
+ if (this.active && !document.hidden) void this.refresh({ quiet: true });
7661
+ };
7662
+ document.addEventListener("visibilitychange", this.onVisibility);
7663
+ }
7613
7664
  }
7614
7665
  /** Called when the cockpit leaves Channels mode. Everything this mode painted
7615
7666
  * over the map goes with it — no other tool ever inherits a channel overlay. */
@@ -7618,6 +7669,11 @@ var ChannelsMode = class {
7618
7669
  this.active = false;
7619
7670
  if (this.pollTimer) clearInterval(this.pollTimer);
7620
7671
  this.pollTimer = null;
7672
+ if (this.onVisibility && typeof document !== "undefined") {
7673
+ document.removeEventListener("visibilitychange", this.onVisibility);
7674
+ }
7675
+ this.onVisibility = null;
7676
+ this.railHtml = null;
7621
7677
  this.closeDialog({ restoreFocus: false });
7622
7678
  this.links = [];
7623
7679
  this.linksChannelId = null;
@@ -7706,8 +7762,11 @@ var ChannelsMode = class {
7706
7762
  if (!this.targetChannelId) {
7707
7763
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
7708
7764
  }
7709
- await this.loadAllocation(seq);
7710
- if (superseded()) return;
7765
+ if (this.allocationVersion !== list.assignmentVersion) {
7766
+ await this.loadAllocation(seq);
7767
+ if (superseded()) return;
7768
+ this.allocationVersion = list.assignmentVersion;
7769
+ }
7711
7770
  if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
7712
7771
  if (superseded()) return;
7713
7772
  this.loading = false;
@@ -8047,6 +8106,7 @@ var ChannelsMode = class {
8047
8106
  }
8048
8107
  const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8049
8108
  const mutations = mutationCount(buckets);
8109
+ const tooLarge = labels.length > MAX_ASSIGNMENT_UNITS;
8050
8110
  const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
8051
8111
  const parts = [
8052
8112
  `<b>${labels.length.toLocaleString()}</b> selected`,
@@ -8058,7 +8118,9 @@ var ChannelsMode = class {
8058
8118
  <span>${parts.join(" \xB7 ")}</span>
8059
8119
  <span class="grow"></span>
8060
8120
  <button type="button" class="drop" data-ch-act="discard">Discard</button>
8061
- <button type="button" class="go" data-ch-act="review">Review changes</button>`);
8121
+ <button type="button" class="go" data-ch-act="review"${tooLarge ? " disabled" : ""}>
8122
+ ${tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : "Review changes"}
8123
+ </button>`);
8062
8124
  this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
8063
8125
  button.addEventListener("click", () => {
8064
8126
  if (button.dataset.chAct === "discard") this.host.clearSelection();
@@ -8126,35 +8188,59 @@ var ChannelsMode = class {
8126
8188
  this.paintOverlay();
8127
8189
  }
8128
8190
  }
8191
+ /**
8192
+ * Replace the rail's markup — but only when it actually differs, and never at
8193
+ * the cost of where the organizer had scrolled to.
8194
+ *
8195
+ * The rail repaints on a clock, on every selection change and after every
8196
+ * mutation. Rewriting `innerHTML` each time resets `scrollTop`, which is
8197
+ * exactly what "the rail gets stuck" was: scroll down to Create channel, the
8198
+ * poll ticks, and the list snaps back to the top under the cursor. So skip the
8199
+ * write when the markup is byte-identical, and restore the offset when it is
8200
+ * not.
8201
+ *
8202
+ * Returns whether the DOM was rewritten. Callers must only re-wire listeners
8203
+ * when it was — a skipped paint keeps the old nodes AND their listeners, so
8204
+ * re-wiring would double every handler.
8205
+ */
8206
+ setRailHtml(html) {
8207
+ if (html === this.railHtml) return false;
8208
+ const rail = this.host.rail;
8209
+ const scrollTop = rail.scrollTop;
8210
+ rail.innerHTML = html;
8211
+ this.railHtml = html;
8212
+ if (scrollTop) rail.scrollTop = scrollTop;
8213
+ return true;
8214
+ }
8129
8215
  paintRail() {
8130
8216
  if (!this.active) return;
8131
8217
  const rail = this.host.rail;
8132
8218
  if (!this.caps.view) {
8133
- rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
8134
- <p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`;
8219
+ this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
8220
+ <p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`);
8135
8221
  return;
8136
8222
  }
8137
8223
  if (this.loading && !this.list) {
8138
- rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
8139
- <div class="slm-empty">Loading allocations\u2026</div>`;
8224
+ this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
8225
+ <div class="slm-empty">Loading allocations\u2026</div>`);
8140
8226
  return;
8141
8227
  }
8142
8228
  if (!this.list && this.loadError) {
8143
- rail.innerHTML = `<p class="slm-eyebrow">Sales channels</p>
8229
+ if (this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
8144
8230
  <div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
8145
8231
  <span><b>Couldn't load sales channels.</b> Everything else on this event still works.
8146
- <button type="button" data-ch-act="retry">Try again</button></span></div>`;
8147
- rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
8148
- void this.refresh();
8149
- });
8232
+ <button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
8233
+ rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
8234
+ void this.refresh();
8235
+ });
8236
+ }
8150
8237
  return;
8151
8238
  }
8152
8239
  const selection = this.host.selectionLabels();
8153
8240
  const grab = this.host.isCompact() ? `<div class="slm-ch-grab"><span class="slm-ch-grabbar"></span></div>` : "";
8154
8241
  const segment = this.viewSegmentHtml();
8155
8242
  const body = this.view === "preview" ? this.previewRailHtml() : this.detailChannelId ? this.detailRailHtml(this.detailChannelId) : selection.length && this.caps.manage ? this.selectionRailHtml(selection) : this.listRailHtml();
8156
- rail.innerHTML = `${grab}${segment}${body}`;
8157
- this.wireRail();
8243
+ if (this.setRailHtml(`${grab}${segment}${body}`)) this.wireRail();
8158
8244
  this.paintStagedBar();
8159
8245
  }
8160
8246
  viewSegmentHtml() {
@@ -8225,8 +8311,9 @@ var ChannelsMode = class {
8225
8311
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
8226
8312
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
8227
8313
  return `
8314
+ ${this.caps.manage ? this.assignmentToolsHtml() : ""}
8228
8315
  <p class="slm-eyebrow">Sales channels</p>
8229
- <p class="slm-hint">Select seats on the map, then assign them. Channel colours and names are never shown to buyers.</p>
8316
+ <p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
8230
8317
  <div class="slm-ch-list">${rows}</div>
8231
8318
  ${create}
8232
8319
  ${readOnly}
@@ -8235,6 +8322,38 @@ var ChannelsMode = class {
8235
8322
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
8236
8323
  </p>`;
8237
8324
  }
8325
+ /**
8326
+ * Destination-first assignment controls.
8327
+ *
8328
+ * These used to live only inside the selection rail, which meant every route
8329
+ * into them was gated behind "select a seat on the map first" — the section,
8330
+ * row and category choosers were invisible until the organizer had already
8331
+ * done the work by hand. They now paint above the channel list too, so the
8332
+ * destination is chosen first and whole sections and rows are discoverable
8333
+ * without learning a hidden seat-first workflow.
8334
+ */
8335
+ assignmentToolsHtml() {
8336
+ const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
8337
+ const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
8338
+ const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
8339
+ const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
8340
+ return `
8341
+ <p class="slm-eyebrow">Assign inventory</p>
8342
+ <div class="slm-field">
8343
+ <label for="slm-ch-target">Assign to</label>
8344
+ <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
8345
+ </div>
8346
+ <p class="slm-eyebrow" style="margin-top:12px">Select by</p>
8347
+ <div class="slm-ch-row2" style="margin-top:2px">
8348
+ ${sections}${rows}
8349
+ <button type="button" class="${dragClass}" data-ch-act="drag-select">Drag box</button>
8350
+ </div>
8351
+ <div class="slm-ch-row2">
8352
+ <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
8353
+ <button type="button" class="slm-btn ghost" data-ch-act="seatlist">Seat list \u2328</button>
8354
+ </div>
8355
+ <p class="slm-note">Choose a destination, then add whole sections, multiple rows, a dragged area, or individual seats.</p>`;
8356
+ }
8238
8357
  selectionRailHtml(selection) {
8239
8358
  const sources = selectionSources(selection, this.allocation, this.list);
8240
8359
  const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
@@ -8243,34 +8362,27 @@ var ChannelsMode = class {
8243
8362
  <button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
8244
8363
  const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
8245
8364
  this.lastSelectionCount = selection.length;
8246
- const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
8365
+ const tooLarge = selection.length > MAX_ASSIGNMENT_UNITS;
8247
8366
  const sourceRows = sources.map((row) => {
8248
8367
  const marker = this.markerFor(row.channelId);
8249
8368
  return `<div class="slm-ch-selsrc-row">
8250
8369
  <span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
8251
8370
  <b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
8252
8371
  }).join("");
8253
- const sectionSelect = this.host.sections().length ? `<button type="button" class="slm-btn ghost" data-ch-act="pick-section">Section</button>` : "";
8254
8372
  return `
8255
8373
  ${conflict}
8374
+ ${this.assignmentToolsHtml()}
8256
8375
  <div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
8257
8376
  <span class="slm-sellabel">selected</span></div>
8258
8377
  <div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
8259
- <div class="slm-field">
8260
- <label for="slm-ch-target">Assign to</label>
8261
- <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
8262
- </div>
8378
+ ${tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span>
8379
+ <b>This selection is too large to apply at once.</b> Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()}
8380
+ seats, for example by splitting the venue into row groups.</span></div>` : ""}
8263
8381
  <p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
8264
8382
  Seats in checkout or already sold are never moved.</p>
8265
8383
  <div class="slm-ch-row2">
8266
8384
  <button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
8267
- <button type="button" class="slm-btn" data-ch-act="review">Review changes</button>
8268
- </div>
8269
- <p class="slm-eyebrow" style="margin-top:18px">Select by</p>
8270
- <div class="slm-ch-row2" style="margin-top:2px">
8271
- ${sectionSelect}
8272
- <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
8273
- <button type="button" class="slm-btn ghost" data-ch-act="seatlist">List \u2328</button>
8385
+ <button type="button" class="slm-btn" data-ch-act="review"${tooLarge ? " disabled" : ""}>Review changes</button>
8274
8386
  </div>
8275
8387
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
8276
8388
  }
@@ -8285,22 +8397,7 @@ var ChannelsMode = class {
8285
8397
  <button type="button" class="slm-btn ghost" data-ch-act="archive">Archive\u2026</button>
8286
8398
  </div>
8287
8399
  <p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
8288
- const intent = channel.access?.intent ?? "none";
8289
- const intents = ["none", "internal", "server", "hosted_link"];
8290
- const selfServiceGap = (intent === "server" || intent === "hosted_link") && !channel.access?.hasActiveGrants;
8291
- const access = this.caps.manage ? `
8292
- <p class="slm-eyebrow" style="margin-top:14px">Buyer access</p>
8293
- <div class="slm-field">
8294
- <label for="slm-ch-intent">How should buyers reach this channel?</label>
8295
- <select class="slm-select" id="slm-ch-intent" data-ch-intent>
8296
- ${intents.map((value) => `<option value="${value}"${value === intent ? " selected" : ""}>${esc(accessIntentLabel(value))}</option>`).join("")}
8297
- </select>
8298
- </div>
8299
- <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>
8300
- ${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
8301
- <span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
8302
- ${this.hostedLinksHtml()}
8303
- ${SERVER_INTEGRATION_HTML}` : "";
8400
+ const access = this.caps.manage ? this.distributeHtml(channel) : "";
8304
8401
  return `
8305
8402
  <p class="slm-eyebrow">
8306
8403
  <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
@@ -8310,7 +8407,54 @@ var ChannelsMode = class {
8310
8407
  ${access}
8311
8408
  ${lifecycle}`;
8312
8409
  }
8313
- // ---- hosted access links --------------------------------------------------
8410
+ /**
8411
+ * "Distribute" — how the seats in this channel actually reach a buyer.
8412
+ *
8413
+ * This replaced a four-value "access intent" picker (none / internal /
8414
+ * hosted_link / server). Two of those values did nothing anywhere: no buyer or
8415
+ * inventory path reads `access_intent`, so `none` and `internal` were labels an
8416
+ * organizer could set and then wait forever for something to happen. A third,
8417
+ * `hosted_link`, is not the organizer's to choose at all — the server sets it
8418
+ * when a buyer link is created and clears it when the last live one is revoked.
8419
+ *
8420
+ * So this is two ACTIONS, not a setting: create a buyer link, or point the
8421
+ * channel at a website integration. Both of them do something the moment they
8422
+ * are pressed. A legacy row still carrying `none` or `internal` renders the
8423
+ * neutral "not distributed yet" state with both actions offered — the stored
8424
+ * value is left alone (it is organizer-declared metadata and the API that
8425
+ * writes it is unchanged), it simply no longer has a control of its own.
8426
+ */
8427
+ distributeHtml(channel) {
8428
+ const intent = channel.access?.intent ?? "none";
8429
+ const live = channel.access?.hasActiveGrants === true;
8430
+ 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.";
8431
+ const option = (title, why, action, cta, primary) => `<div class="slm-ch-dist">
8432
+ <b>${esc(title)}</b>
8433
+ <span class="why">${esc(why)}</span>
8434
+ <button type="button" class="slm-btn${primary ? "" : " ghost"}" data-ch-act="${esc(action)}">${esc(cta)}</button>
8435
+ </div>`;
8436
+ const linksSupported = this.linksState !== "unsupported";
8437
+ return `
8438
+ <p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
8439
+ <p class="slm-hint">${esc(state)}</p>
8440
+ ${linksSupported ? option(
8441
+ "Sell with a buyer link",
8442
+ "SeatLayer makes a link you send to a named group. They open it and buy only these seats.",
8443
+ "link-create",
8444
+ this.links.some(accessLinkIsLive) ? "Create another buyer link" : "Create buyer link",
8445
+ true
8446
+ ) : ""}
8447
+ ${option(
8448
+ "Integrate a website or app",
8449
+ "Your website's backend grants each buyer access \u2014 set up on the Embed page.",
8450
+ "embed-code",
8451
+ "Get embed code",
8452
+ false
8453
+ )}
8454
+ ${this.hostedLinksHtml()}
8455
+ ${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
8456
+ }
8457
+ // ---- buyer links ----------------------------------------------------------
8314
8458
  /**
8315
8459
  * Read the status projection for the open channel. Never paints — the caller
8316
8460
  * decides when the rail repaints, so a poll-driven reload does not fight a
@@ -8340,32 +8484,34 @@ var ChannelsMode = class {
8340
8484
  }
8341
8485
  }
8342
8486
  /**
8343
- * The hosted-link section of the detail panel.
8487
+ * The buyer-link status section of the detail panel.
8344
8488
  *
8345
8489
  * STATUS ONLY, by design (comp 06 `hosted`): label, state, expiry,
8346
8490
  * redemptions, seats per buyer, live sessions. There is no Copy control here
8347
8491
  * and no field to hang one on — the URL was shown once at creation and cannot
8348
8492
  * be produced again. Rotation is the recovery path, and it says so.
8493
+ *
8494
+ * Creating a link is the Distribute card's action, not this section's, so a
8495
+ * channel with no links renders nothing here rather than an empty heading and
8496
+ * a second button saying the same thing.
8349
8497
  */
8350
8498
  hostedLinksHtml() {
8351
- const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Hosted access links</p>`;
8499
+ const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
8352
8500
  if (this.linksState === "unsupported") {
8353
8501
  return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
8354
- <span><b>Hosted links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
8502
+ <span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
8355
8503
  }
8356
8504
  if (this.linksState === "error") {
8357
8505
  return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
8358
8506
  <span><b>Couldn't load this channel's links.</b>
8359
8507
  <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
8360
8508
  }
8361
- const live = this.links.filter(accessLinkIsLive).length;
8362
- 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
8363
- they open the link and buy only these seats.</p>`;
8364
- const create = this.caps.manage ? `<button type="button" class="slm-btn" style="width:100%" data-ch-act="link-create">
8365
- ${live ? "Create another hosted link" : "Create hosted access link"}</button>` : "";
8509
+ if (this.linksState === "loading" && !this.links.length) {
8510
+ return `${eyebrow}<div class="slm-empty">Loading links\u2026</div>`;
8511
+ }
8512
+ if (!this.links.length) return "";
8366
8513
  return `${eyebrow}
8367
- ${cards}
8368
- ${create}
8514
+ ${this.links.map((link) => this.linkCardHtml(link)).join("")}
8369
8515
  <p class="slm-note">A link is shown once, when you create it. SeatLayer keeps only a fingerprint of it, so it
8370
8516
  can never be shown again \u2014 if a link is lost, rotate it and send the fresh one.</p>`;
8371
8517
  }
@@ -8384,7 +8530,7 @@ var ChannelsMode = class {
8384
8530
  </div>` : "";
8385
8531
  return `<div class="slm-ch-link">
8386
8532
  <span class="lk-head">
8387
- <span class="lk-name">${esc(link.label || "Hosted link")}</span>
8533
+ <span class="lk-name">${esc(link.label || "Buyer link")}</span>
8388
8534
  <span class="slm-ch-badge ${badge.kind}">${esc(badge.text)}</span>
8389
8535
  </span>
8390
8536
  <div class="slm-ch-meter" role="img"
@@ -8487,10 +8633,6 @@ var ChannelsMode = class {
8487
8633
  this.previewIncludePublic = includePublic.checked;
8488
8634
  void this.loadPreview();
8489
8635
  });
8490
- const intent = rail.querySelector("[data-ch-intent]");
8491
- intent?.addEventListener("change", () => {
8492
- void this.setAccessIntent(intent.value);
8493
- });
8494
8636
  const grab = rail.querySelector(".slm-ch-grab");
8495
8637
  grab?.addEventListener("click", () => this.cycleDetent());
8496
8638
  rail.querySelectorAll("[data-ch-act]").forEach((button) => {
@@ -8535,6 +8677,13 @@ var ChannelsMode = class {
8535
8677
  case "link-create":
8536
8678
  this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
8537
8679
  break;
8680
+ // The one thing this screen can actually do for a website integration is
8681
+ // record that the channel is meant for one — which is exactly the flag the
8682
+ // dashboard's Embed page reads before it offers the snippet. Saying so is
8683
+ // honest; a fake "copy code" button on a screen with no code would not be.
8684
+ case "embed-code":
8685
+ void this.chooseWebsiteIntegration();
8686
+ break;
8538
8687
  case "link-reload":
8539
8688
  if (this.detailChannelId) void this.reloadLinks();
8540
8689
  break;
@@ -8549,8 +8698,16 @@ var ChannelsMode = class {
8549
8698
  this.conflict = false;
8550
8699
  void this.refresh().then(() => this.openDialog({ kind: "review" }));
8551
8700
  break;
8552
- case "pick-section":
8553
- this.pickSection();
8701
+ case "pick-sections":
8702
+ this.openDialog({ kind: "scope", scope: "sections" });
8703
+ break;
8704
+ case "pick-rows":
8705
+ this.openDialog({ kind: "scope", scope: "rows" });
8706
+ break;
8707
+ case "drag-select":
8708
+ this.mapIntent = "assign";
8709
+ this.paintRail();
8710
+ this.onInteractionChange?.();
8554
8711
  break;
8555
8712
  case "pick-category":
8556
8713
  this.pickCategory();
@@ -8560,12 +8717,10 @@ var ChannelsMode = class {
8560
8717
  }
8561
8718
  }
8562
8719
  // ---- selection helpers ----------------------------------------------------
8563
- pickSection() {
8564
- const sections = this.host.sections();
8565
- if (!sections.length) return;
8566
- this.promptChoice("Select a whole section", sections.map((s) => ({ value: s.id, label: s.label })), (value) => {
8567
- this.host.selectSection(value);
8568
- });
8720
+ /** Derived once per mode entry — see `assignmentRowsCache`. */
8721
+ assignmentRows() {
8722
+ if (!this.assignmentRowsCache) this.assignmentRowsCache = this.host.rows();
8723
+ return this.assignmentRowsCache;
8569
8724
  }
8570
8725
  pickCategory() {
8571
8726
  const categories = this.host.categories();
@@ -8596,6 +8751,209 @@ var ChannelsMode = class {
8596
8751
  });
8597
8752
  });
8598
8753
  }
8754
+ /**
8755
+ * Add several whole sections, or several whole rows, in one operation.
8756
+ *
8757
+ * ADDITIVE by contract: the chooser starts from the labels already selected
8758
+ * and only ever grows that set, so opening it never destroys a hard-won
8759
+ * marquee or seat-list selection. The confirm button always states the net
8760
+ * number of seats it will add, and refuses to exceed `MAX_ASSIGNMENT_UNITS`.
8761
+ *
8762
+ * Rows get the richer variant. A large venue has thousands of them, so they
8763
+ * arrive collapsed under their section, with a section-level tri-state
8764
+ * checkbox, a search across every row, and a render cap — the flat list used
8765
+ * for sections would be an unusable wall of buttons.
8766
+ */
8767
+ renderScopeDialog(state) {
8768
+ const scope = state.scope === "rows" ? "rows" : "sections";
8769
+ const options = scope === "sections" ? this.host.sections().map((section) => ({
8770
+ id: section.id,
8771
+ label: section.label,
8772
+ group: "Sections",
8773
+ labels: this.host.labelsInSection(section.id)
8774
+ })) : this.assignmentRows().map((row) => ({
8775
+ id: row.id,
8776
+ label: row.label,
8777
+ group: row.sectionLabel,
8778
+ labels: row.labels
8779
+ }));
8780
+ const available = options.filter((option) => option.labels.length > 0);
8781
+ const grouped = /* @__PURE__ */ new Map();
8782
+ for (const option of available) {
8783
+ const group = grouped.get(option.group) ?? [];
8784
+ group.push(option);
8785
+ grouped.set(option.group, group);
8786
+ }
8787
+ if (scope === "rows") {
8788
+ const groups = [...grouped.entries()];
8789
+ this.renderScrim(`
8790
+ <h3 id="slm-ch-dlg-title">Add multiple rows</h3>
8791
+ <p class="sub">Sections stay collapsed for speed. Select a whole section, expand only the rows you need, or search across every row.</p>
8792
+ <div class="slm-ch-scopebar">
8793
+ <label>Find section or row<input type="search" data-ch-scope-search placeholder="e.g. Orchestra or AA"></label>
8794
+ <span class="slm-ch-scopesummary" data-ch-scope-summary aria-live="polite">0 rows selected</span>
8795
+ </div>
8796
+ <p class="slm-ch-scopehint" data-ch-scope-filter>Showing section groups. Expand one or search to see rows.</p>
8797
+ <div class="slm-ch-seatlist" data-ch-scope-list></div>
8798
+ <p class="slm-ch-err" data-ch-error hidden></p>
8799
+ <div class="foot">
8800
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8801
+ <button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
8802
+ </div>`, (dialog) => {
8803
+ const byId = new Map(available.map((option) => [option.id, option]));
8804
+ const picked = /* @__PURE__ */ new Set();
8805
+ const expanded = /* @__PURE__ */ new Set();
8806
+ const current = new Set(this.host.selectionLabels());
8807
+ const confirm = dialog.querySelector("[data-ch-add-scope]");
8808
+ const error = dialog.querySelector("[data-ch-error]");
8809
+ const list = dialog.querySelector("[data-ch-scope-list]");
8810
+ const search = dialog.querySelector("[data-ch-scope-search]");
8811
+ const summary = dialog.querySelector("[data-ch-scope-summary]");
8812
+ const filterHint = dialog.querySelector("[data-ch-scope-filter]");
8813
+ const renderLimit = 200;
8814
+ let query = "";
8815
+ const selectedLabels = () => {
8816
+ const labels = new Set(current);
8817
+ for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
8818
+ return labels;
8819
+ };
8820
+ const update = () => {
8821
+ const labels = selectedLabels();
8822
+ const added = labels.size - current.size;
8823
+ const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
8824
+ confirm.disabled = added === 0 || tooLarge;
8825
+ confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
8826
+ error.hidden = !tooLarge;
8827
+ error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
8828
+ summary.textContent = `${picked.size.toLocaleString()} row${picked.size === 1 ? "" : "s"} \xB7 ${added.toLocaleString()} seat${added === 1 ? "" : "s"} added`;
8829
+ };
8830
+ const renderList = () => {
8831
+ const blocks = [];
8832
+ let totalMatches = 0;
8833
+ let rendered = 0;
8834
+ groups.forEach(([group, items], groupIndex) => {
8835
+ const normalizedGroup = group.toLocaleLowerCase();
8836
+ const matches = query ? items.filter((item) => `${normalizedGroup} ${item.label.toLocaleLowerCase()}`.includes(query)) : items;
8837
+ if (query && !matches.length) return;
8838
+ totalMatches += matches.length;
8839
+ const allPicked = items.every((item) => picked.has(item.id));
8840
+ const somePicked = !allPicked && items.some((item) => picked.has(item.id));
8841
+ const open = Boolean(query) || expanded.has(groupIndex);
8842
+ const room = Math.max(0, renderLimit - rendered);
8843
+ const visible = open ? matches.slice(0, room) : [];
8844
+ rendered += visible.length;
8845
+ const seatCount = items.reduce((sum, item) => sum + item.labels.length, 0);
8846
+ blocks.push(`<div class="slm-ch-scopegroup">
8847
+ <button type="button" class="slm-ch-groupcheck" role="checkbox" aria-checked="${allPicked ? "true" : somePicked ? "mixed" : "false"}"
8848
+ aria-label="Select all ${items.length.toLocaleString()} rows in ${esc(group)}" data-ch-scope-group="${groupIndex}">
8849
+ <span class="box" aria-hidden="true">\u2713</span><span>${esc(group)}</span>
8850
+ <span class="meta">${items.length.toLocaleString()} rows \xB7 ${seatCount.toLocaleString()} seats</span>
8851
+ </button>
8852
+ <button type="button" class="slm-ch-grouptoggle" aria-expanded="${open}" aria-label="${open ? "Hide" : "Show"} rows in ${esc(group)}"
8853
+ data-ch-scope-toggle="${groupIndex}">${open ? "Hide" : "Show"}</button>
8854
+ </div>`);
8855
+ blocks.push(...visible.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
8856
+ aria-checked="${picked.has(option.id)}" data-ch-scope-id="${esc(option.id)}">
8857
+ <span class="box" aria-hidden="true">\u2713</span><span>${esc(option.label)}</span>
8858
+ <span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
8859
+ </button>`));
8860
+ if (open && matches.length > visible.length) {
8861
+ 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>`);
8862
+ }
8863
+ });
8864
+ list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div>`;
8865
+ 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.";
8866
+ list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
8867
+ const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
8868
+ const remove = items.every((item) => picked.has(item.id));
8869
+ for (const item of items) remove ? picked.delete(item.id) : picked.add(item.id);
8870
+ renderList();
8871
+ update();
8872
+ }));
8873
+ list.querySelectorAll("[data-ch-scope-toggle]").forEach((button) => button.addEventListener("click", () => {
8874
+ const index = Number(button.dataset.chScopeToggle);
8875
+ if (expanded.has(index)) expanded.delete(index);
8876
+ else expanded.add(index);
8877
+ renderList();
8878
+ }));
8879
+ list.querySelectorAll("[data-ch-scope-id]").forEach((button) => button.addEventListener("click", () => {
8880
+ const id = button.dataset.chScopeId;
8881
+ if (picked.has(id)) picked.delete(id);
8882
+ else picked.add(id);
8883
+ renderList();
8884
+ update();
8885
+ }));
8886
+ };
8887
+ search.addEventListener("input", () => {
8888
+ query = search.value.trim().toLocaleLowerCase();
8889
+ renderList();
8890
+ update();
8891
+ });
8892
+ confirm.addEventListener("click", () => {
8893
+ const labels = [...selectedLabels()];
8894
+ if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
8895
+ this.closeDialog();
8896
+ this.host.selectByLabels(labels);
8897
+ });
8898
+ renderList();
8899
+ update();
8900
+ });
8901
+ return;
8902
+ }
8903
+ const body = [...grouped.entries()].map(([group, items]) => `
8904
+ <div class="slm-ch-seatgroup"><span>${esc(group)}</span></div>
8905
+ ${items.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
8906
+ aria-checked="false" data-ch-scope-id="${esc(option.id)}">
8907
+ <span class="box" aria-hidden="true">\u2713</span>
8908
+ <span>${esc(option.label)}</span>
8909
+ <span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
8910
+ </button>`).join("")}`).join("");
8911
+ this.renderScrim(`
8912
+ <h3 id="slm-ch-dlg-title">Add whole sections</h3>
8913
+ <p class="sub">Choose one or more. They are added to the seats already selected on the map.</p>
8914
+ <div class="slm-ch-seatlist">${body || `<div class="slm-empty">No sections are available.</div>`}</div>
8915
+ <p class="slm-ch-err" data-ch-error hidden></p>
8916
+ <div class="foot">
8917
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8918
+ <button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
8919
+ </div>`, (dialog) => {
8920
+ const byId = new Map(available.map((option) => [option.id, option]));
8921
+ const picked = /* @__PURE__ */ new Set();
8922
+ const current = new Set(this.host.selectionLabels());
8923
+ const confirm = dialog.querySelector("[data-ch-add-scope]");
8924
+ const error = dialog.querySelector("[data-ch-error]");
8925
+ const selectedLabels = () => {
8926
+ const labels = new Set(current);
8927
+ for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
8928
+ return labels;
8929
+ };
8930
+ const update = () => {
8931
+ const labels = selectedLabels();
8932
+ const added = labels.size - current.size;
8933
+ const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
8934
+ confirm.disabled = added === 0 || tooLarge;
8935
+ confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
8936
+ error.hidden = !tooLarge;
8937
+ error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
8938
+ };
8939
+ dialog.querySelectorAll("[data-ch-scope-id]").forEach((button) => {
8940
+ button.addEventListener("click", () => {
8941
+ const id = button.dataset.chScopeId;
8942
+ if (picked.has(id)) picked.delete(id);
8943
+ else picked.add(id);
8944
+ button.setAttribute("aria-checked", String(picked.has(id)));
8945
+ update();
8946
+ });
8947
+ });
8948
+ confirm.addEventListener("click", () => {
8949
+ const labels = [...selectedLabels()];
8950
+ if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
8951
+ this.closeDialog();
8952
+ this.host.selectByLabels(labels);
8953
+ });
8954
+ update();
8955
+ });
8956
+ }
8599
8957
  // ---- dialogs --------------------------------------------------------------
8600
8958
  openDialog(state) {
8601
8959
  this.dialog = state;
@@ -8606,6 +8964,7 @@ var ChannelsMode = class {
8606
8964
  if (!state) return;
8607
8965
  if (state.kind === "create") this.renderCreateDialog(state);
8608
8966
  else if (state.kind === "review") this.renderReviewDialog(state);
8967
+ else if (state.kind === "scope") this.renderScopeDialog(state);
8609
8968
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8610
8969
  else if (state.kind === "rename") this.renderRenameDialog(state);
8611
8970
  else if (state.kind === "seatlist") this.renderSeatListDialog();
@@ -8750,24 +9109,28 @@ var ChannelsMode = class {
8750
9109
  const { labels, buckets } = this.currentPlan();
8751
9110
  const authoritative = state.applied;
8752
9111
  const shown = authoritative ? authoritative.buckets : buckets;
8753
- const targetName = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
9112
+ const targetName = this.nameOf(authoritative?.targetChannelId ?? this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8754
9113
  const rows = bucketRows(shown, targetName);
8755
9114
  const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
8756
9115
  const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
9116
+ const tooLarge = !authoritative && labels.length > MAX_ASSIGNMENT_UNITS;
8757
9117
  const rowsHtml = bucketRowsHtml(rows);
8758
9118
  const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
8759
9119
  <button type="button" class="quiet" data-ch-close>Back</button>
8760
- <button type="button" class="slm-btn" data-ch-apply ${allSkipped || state.busy ? "disabled" : ""}>
9120
+ <button type="button" class="slm-btn" data-ch-apply ${allSkipped || tooLarge || state.busy ? "disabled" : ""}>
8761
9121
  ${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
8762
9122
  </button>
8763
9123
  </div>`;
8764
9124
  const confirmNote = !authoritative && needsMoveConfirmation(buckets) ? `<p class="slm-note">Applying moves inventory out of another private channel. That is the line marked above.</p>` : "";
8765
9125
  const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
8766
9126
  every seat is in a buyer's checkout, already sold, or already in ${esc(targetName)}.</span></div>` : "";
9127
+ const limitNote = tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span><b>Choose fewer seats.</b>
9128
+ One Apply can cover at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats.</span></div>` : "";
8767
9129
  this.renderScrim(`
8768
9130
  <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>
8769
9131
  <p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
8770
9132
  ${skippedNote}
9133
+ ${limitNote}
8771
9134
  ${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
8772
9135
  ${confirmNote}
8773
9136
  <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
@@ -8779,15 +9142,21 @@ var ChannelsMode = class {
8779
9142
  }
8780
9143
  }
8781
9144
  /**
8782
- * Apply. On success the review sheet re-renders with the AUTHORITATIVE server
8783
- * buckets and the staged bar morphs to a for 1.2s. On a stale version the
8784
- * server mutated nothing: keep the selection, shake the bar once, and offer
8785
- * exactly one action Refresh and review.
9145
+ * Apply. On success the sheet CLOSES and the staged bar confirms what moved,
9146
+ * naming the destination and any skipped seats, with the AUTHORITATIVE server
9147
+ * buckets still one Details press away. Leaving the modal up on success made
9148
+ * the organizer dismiss a sheet to get back to a map they had just changed.
9149
+ * On a stale version the server mutated nothing: keep the selection, shake
9150
+ * the bar once, and offer exactly one action — Refresh and review.
8786
9151
  */
8787
9152
  async apply() {
8788
9153
  if (!this.dialog || !this.caps.manage) return;
8789
9154
  const { labels } = this.currentPlan();
8790
9155
  if (!labels.length) return;
9156
+ if (labels.length > MAX_ASSIGNMENT_UNITS) {
9157
+ this.showDialogError(`Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats for one Apply.`);
9158
+ return;
9159
+ }
8791
9160
  this.dialog = { ...this.dialog, busy: true, error: null };
8792
9161
  this.renderDialog();
8793
9162
  try {
@@ -8800,11 +9169,10 @@ var ChannelsMode = class {
8800
9169
  });
8801
9170
  this.assignmentVersion = result.assignmentVersion;
8802
9171
  this.conflict = false;
8803
- this.dialog = { kind: "review", applied: result };
8804
9172
  await this.refresh({ quiet: true });
8805
- this.renderDialog();
8806
- this.showApplied(result);
9173
+ this.closeDialog({ restoreFocus: false });
8807
9174
  this.host.clearSelection();
9175
+ this.showApplied(result);
8808
9176
  } catch (err) {
8809
9177
  const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
8810
9178
  if (conflict) {
@@ -8827,12 +9195,26 @@ var ChannelsMode = class {
8827
9195
  this.host.onError(err);
8828
9196
  }
8829
9197
  }
9198
+ /**
9199
+ * The success receipt, now that the review sheet closes on Apply. It names the
9200
+ * destination and the seats that could not move, keeps the authoritative
9201
+ * buckets reachable through Details, and stays up long enough to be read —
9202
+ * 1.2s was tuned for a confirmation the organizer was already looking at.
9203
+ */
8830
9204
  showApplied(result) {
9205
+ const targetName = this.nameOf(result.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
9206
+ const skipped = result.buckets.skippedHeld.count + result.buckets.skippedBooked.count + result.buckets.notFound.count;
8831
9207
  this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
8832
- <span>Applied <b>${result.applied.toLocaleString()}</b> change${result.applied === 1 ? "" : "s"}</span>
8833
- <span class="grow"></span>`, "done");
9208
+ <span>Assigned <b>${result.applied.toLocaleString()}</b> seat${result.applied === 1 ? "" : "s"} to ${esc(targetName)}
9209
+ ${skipped ? ` \xB7 ${skipped.toLocaleString()} skipped` : ""}</span>
9210
+ <span class="grow"></span>
9211
+ <button type="button" class="drop" data-ch-applied-details>Details</button>`, "done");
9212
+ this.stagedEl?.querySelector("[data-ch-applied-details]")?.addEventListener("click", () => {
9213
+ this.openDialog({ kind: "review", applied: result });
9214
+ });
9215
+ this.announce(`Assigned ${result.applied} seat${result.applied === 1 ? "" : "s"} to ${targetName}${skipped ? `; ${skipped} skipped` : ""}.`);
8834
9216
  if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
8835
- this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 1200);
9217
+ this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 5e3);
8836
9218
  }
8837
9219
  shakeStaged() {
8838
9220
  const bar = this.stagedEl;
@@ -8875,12 +9257,27 @@ var ChannelsMode = class {
8875
9257
  });
8876
9258
  });
8877
9259
  }
9260
+ /**
9261
+ * "Get embed code" — mark the channel as reached through the organizer's own
9262
+ * backend. The write itself is `setChannelAccessIntent(…, 'server')`, the same
9263
+ * API the old picker called; the difference is that it is now a deliberate
9264
+ * action with a consequence the organizer is told about, rather than one of
9265
+ * four dropdown values with no observable effect.
9266
+ */
9267
+ async chooseWebsiteIntegration() {
9268
+ const channelId = this.detailChannelId;
9269
+ if (!channelId) return;
9270
+ await this.setAccessIntent("server");
9271
+ }
8878
9272
  async setAccessIntent(accessIntent) {
8879
9273
  const channelId = this.detailChannelId;
8880
9274
  if (!channelId || !this.caps.manage) return;
8881
9275
  try {
8882
9276
  await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, accessIntent);
8883
9277
  await this.refresh();
9278
+ if (accessIntent === "server") {
9279
+ this.host.toast("Marked for your website. The embed code is on the Embed page.", "ok");
9280
+ }
8884
9281
  } catch (err) {
8885
9282
  this.host.toast("Couldn't save how buyers reach this channel.", "err");
8886
9283
  this.host.onError(err);
@@ -9021,7 +9418,7 @@ var ChannelsMode = class {
9021
9418
  });
9022
9419
  });
9023
9420
  }
9024
- // ---- hosted-link dialogs --------------------------------------------------
9421
+ // ---- buyer-link dialogs ---------------------------------------------------
9025
9422
  async reloadLinks() {
9026
9423
  const channelId = this.detailChannelId;
9027
9424
  if (!channelId) return;
@@ -9064,7 +9461,7 @@ var ChannelsMode = class {
9064
9461
  return;
9065
9462
  }
9066
9463
  this.renderScrim(`
9067
- <h3 id="slm-ch-dlg-title">Create a hosted access link for ${esc(channel.name)}</h3>
9464
+ <h3 id="slm-ch-dlg-title">Create a buyer link for ${esc(channel.name)}</h3>
9068
9465
  <p class="sub">Anyone who opens the link can buy from this channel's allocation \u2014 and only from it.
9069
9466
  You'll see the link once, right after you create it.</p>
9070
9467
  <div class="slm-field">
@@ -9189,7 +9586,7 @@ var ChannelsMode = class {
9189
9586
  copy?.addEventListener("click", () => {
9190
9587
  const ok = () => {
9191
9588
  copy.textContent = "Copied";
9192
- this.announce("Hosted access link copied.");
9589
+ this.announce("Buyer link copied.");
9193
9590
  };
9194
9591
  const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
9195
9592
  if (clipboard?.writeText) {
@@ -9199,7 +9596,7 @@ var ChannelsMode = class {
9199
9596
  selectSecret(dialog);
9200
9597
  });
9201
9598
  });
9202
- this.announce("Your hosted access link is ready and is shown once.");
9599
+ this.announce("Your buyer link is ready and is shown once.");
9203
9600
  }
9204
9601
  /**
9205
9602
  * Rotate. The organizer must SAY what happens to the buyers already inside —
@@ -9218,7 +9615,7 @@ var ChannelsMode = class {
9218
9615
  <span><b>${sessions.toLocaleString()} buyer${sessions === 1 ? "" : "s"}</b> got in with the current link
9219
9616
  and still ${sessions === 1 ? "has" : "have"} active access.</span></div>` : "";
9220
9617
  this.renderScrim(`
9221
- <h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "hosted")} link?</h3>
9618
+ <h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "buyer")} link?</h3>
9222
9619
  <p class="sub">The current link stops opening immediately and cannot be restored. You will get a new
9223
9620
  link to copy \u2014 shown once.</p>
9224
9621
  ${warning}
@@ -9277,7 +9674,7 @@ var ChannelsMode = class {
9277
9674
  }
9278
9675
  const sessions = link.activeSessions ?? 0;
9279
9676
  this.renderScrim(`
9280
- <h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "hosted")} link?</h3>
9677
+ <h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "buyer")} link?</h3>
9281
9678
  <p class="sub">It stops opening immediately and cannot be restored \u2014 there is no undo, and no way to
9282
9679
  bring the same URL back. Seats already bought through it keep their sale.</p>
9283
9680
  ${sessions ? `<label class="slm-ch-radio">
@@ -9394,7 +9791,12 @@ var LEGEND = [
9394
9791
  var MANAGER_CSS = (
9395
9792
  /* @sl-css */
9396
9793
  `
9397
- .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:480px;overflow:hidden;
9794
+ /* The floor was 480px, which trapped the cockpit in any host shorter than that:
9795
+ every ancestor here is overflow:hidden, so the bottom of the rail (Create
9796
+ channel, Show archived) was rendered but clipped away by the host and could
9797
+ not be reached by scrolling anything. 320px still keeps an unsized host from
9798
+ collapsing to the top bar, and the rail scrolls to its full content below. */
9799
+ .slm{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:320px;overflow:hidden;
9398
9800
  background:var(--slm-bg);color:var(--slm-text);font-family:var(--slm-font);border-radius:var(--slm-radius);
9399
9801
  /* Motion tokens (motion-system \xA72), declared by the cockpit ROOT rather than
9400
9802
  borrowed from CHANNELS_CSS. The base cockpit animates whether or not
@@ -9462,7 +9864,7 @@ var MANAGER_CSS = (
9462
9864
  .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
9463
9865
  white-space:nowrap;font-size:12px;font-weight:800}.slm-liveeventhint{color:var(--slm-muted);font-size:10px;white-space:nowrap}
9464
9866
  .slm-rail{width:320px;flex:none;border-left:1px solid var(--slm-line);display:flex;flex-direction:column;min-height:0}
9465
- .slm-railscroll{flex:1;overflow-y:auto;padding:16px}
9867
+ .slm-railscroll{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:16px}
9466
9868
  .slm-eyebrow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--slm-muted);font-weight:800;margin-bottom:6px}
9467
9869
  .slm-hint{font-size:12.5px;color:var(--slm-muted);line-height:1.5;margin-bottom:14px}
9468
9870
 
@@ -9527,7 +9929,8 @@ var MANAGER_CSS = (
9527
9929
  color:var(--slm-muted);font-size:10.5px}
9528
9930
  .slm-linkbtn{font-size:11px!important;font-weight:800!important;color:var(--slm-accent)!important;text-align:right}
9529
9931
  .slm-linkbtn:disabled{opacity:.45;cursor:not-allowed}
9530
- .slm-blockedlist{max-height:246px;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
9932
+ .slm-blockedlist{max-height:246px;overflow:auto;overscroll-behavior:contain;border:1px solid var(--slm-line);
9933
+ border-radius:10px;background:var(--slm-surface)}
9531
9934
  .slm-blockeditem{display:grid!important;grid-template-columns:18px minmax(0,1fr);width:100%;gap:8px;padding:8px 9px!important;
9532
9935
  border-bottom:1px solid var(--slm-line)!important;text-align:left!important}
9533
9936
  .slm-blockeditem:last-child{border-bottom:0!important}.slm-blockeditem:hover{background:rgba(255,255,255,.035)!important}
@@ -9922,6 +10325,30 @@ var SeatManager = class {
9922
10325
  this.selectSection(sectionId);
9923
10326
  },
9924
10327
  sections: () => this.sectionOptions,
10328
+ labelsInSection: (sectionId) => this.renderer?.getSelectableInSection(sectionId).map((seat) => seat.label) ?? [],
10329
+ // Logical rows, not physical row objects: a segmented row is authored as
10330
+ // several objects sharing one `logicalRowId`, and the organizer thinks of
10331
+ // it as one row. Tables carry seats too, so they are rows here as well.
10332
+ rows: () => {
10333
+ const objects = new Map((this.doc?.objects ?? []).map((object) => [object.id, object]));
10334
+ const rows = /* @__PURE__ */ new Map();
10335
+ for (const seat of this.labelToSeat.values()) {
10336
+ const object = objects.get(seat.rowId);
10337
+ if (!object || object.type !== "row" && object.type !== "table") continue;
10338
+ const id = seat.logicalRowId ?? seat.rowId;
10339
+ const sectionId = this.sectionByObject.get(seat.rowId) ?? import_core3.UNGROUPED_ID;
10340
+ const row = rows.get(id) ?? {
10341
+ id,
10342
+ label: object.type === "row" ? object.segmentedRow?.displayLabel ?? object.displayLabel ?? object.label : object.displayLabel ?? object.label,
10343
+ sectionId,
10344
+ sectionLabel: this.sectionLabelById.get(sectionId) ?? "Other seats",
10345
+ labels: []
10346
+ };
10347
+ row.labels.push(seat.label);
10348
+ rows.set(id, row);
10349
+ }
10350
+ 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" }));
10351
+ },
9925
10352
  categories: () => (this.doc?.categories ?? []).map((category) => ({
9926
10353
  key: category.key,
9927
10354
  label: category.label ?? category.key,