@seatlayer/js 0.42.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
@@ -7296,6 +7296,7 @@ var ManageApi = class {
7296
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)}
@@ -7472,6 +7474,25 @@ var CHANNELS_CSS = (
7472
7474
  .slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
7473
7475
  .slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
7474
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
+
7475
7496
  /* compact: bottom sheet with three detents (\xA713) */
7476
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);
7477
7498
  border-radius:18px 18px 0 0;background:#12151f;
@@ -7596,6 +7617,10 @@ var ChannelsMode = class {
7596
7617
  this.stagedDoneTimer = null;
7597
7618
  this.lastSelectionCount = 0;
7598
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;
7599
7624
  /** The markup currently in the rail. An identical repaint is skipped, which is
7600
7625
  * what keeps the organizer's scroll position (and open <select>) alive. */
7601
7626
  this.railHtml = null;
@@ -7617,6 +7642,7 @@ var ChannelsMode = class {
7617
7642
  this.active = true;
7618
7643
  this.mapIntent = "pan";
7619
7644
  this.focusedSectionId = null;
7645
+ this.assignmentRowsCache = null;
7620
7646
  this.railHtml = null;
7621
7647
  this.ensureLayer();
7622
7648
  this.host.root.classList.add("ch-mode");
@@ -8080,6 +8106,7 @@ var ChannelsMode = class {
8080
8106
  }
8081
8107
  const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8082
8108
  const mutations = mutationCount(buckets);
8109
+ const tooLarge = labels.length > MAX_ASSIGNMENT_UNITS;
8083
8110
  const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
8084
8111
  const parts = [
8085
8112
  `<b>${labels.length.toLocaleString()}</b> selected`,
@@ -8091,7 +8118,9 @@ var ChannelsMode = class {
8091
8118
  <span>${parts.join(" \xB7 ")}</span>
8092
8119
  <span class="grow"></span>
8093
8120
  <button type="button" class="drop" data-ch-act="discard">Discard</button>
8094
- <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>`);
8095
8124
  this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
8096
8125
  button.addEventListener("click", () => {
8097
8126
  if (button.dataset.chAct === "discard") this.host.clearSelection();
@@ -8282,8 +8311,9 @@ var ChannelsMode = class {
8282
8311
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
8283
8312
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
8284
8313
  return `
8314
+ ${this.caps.manage ? this.assignmentToolsHtml() : ""}
8285
8315
  <p class="slm-eyebrow">Sales channels</p>
8286
- <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>
8287
8317
  <div class="slm-ch-list">${rows}</div>
8288
8318
  ${create}
8289
8319
  ${readOnly}
@@ -8292,6 +8322,38 @@ var ChannelsMode = class {
8292
8322
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
8293
8323
  </p>`;
8294
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
+ }
8295
8357
  selectionRailHtml(selection) {
8296
8358
  const sources = selectionSources(selection, this.allocation, this.list);
8297
8359
  const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
@@ -8300,34 +8362,27 @@ var ChannelsMode = class {
8300
8362
  <button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
8301
8363
  const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
8302
8364
  this.lastSelectionCount = selection.length;
8303
- 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;
8304
8366
  const sourceRows = sources.map((row) => {
8305
8367
  const marker = this.markerFor(row.channelId);
8306
8368
  return `<div class="slm-ch-selsrc-row">
8307
8369
  <span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
8308
8370
  <b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
8309
8371
  }).join("");
8310
- const sectionSelect = this.host.sections().length ? `<button type="button" class="slm-btn ghost" data-ch-act="pick-section">Section</button>` : "";
8311
8372
  return `
8312
8373
  ${conflict}
8374
+ ${this.assignmentToolsHtml()}
8313
8375
  <div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
8314
8376
  <span class="slm-sellabel">selected</span></div>
8315
8377
  <div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
8316
- <div class="slm-field">
8317
- <label for="slm-ch-target">Assign to</label>
8318
- <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
8319
- </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>` : ""}
8320
8381
  <p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
8321
8382
  Seats in checkout or already sold are never moved.</p>
8322
8383
  <div class="slm-ch-row2">
8323
8384
  <button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
8324
- <button type="button" class="slm-btn" data-ch-act="review">Review changes</button>
8325
- </div>
8326
- <p class="slm-eyebrow" style="margin-top:18px">Select by</p>
8327
- <div class="slm-ch-row2" style="margin-top:2px">
8328
- ${sectionSelect}
8329
- <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
8330
- <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>
8331
8386
  </div>
8332
8387
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
8333
8388
  }
@@ -8643,8 +8698,16 @@ var ChannelsMode = class {
8643
8698
  this.conflict = false;
8644
8699
  void this.refresh().then(() => this.openDialog({ kind: "review" }));
8645
8700
  break;
8646
- case "pick-section":
8647
- 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?.();
8648
8711
  break;
8649
8712
  case "pick-category":
8650
8713
  this.pickCategory();
@@ -8654,12 +8717,10 @@ var ChannelsMode = class {
8654
8717
  }
8655
8718
  }
8656
8719
  // ---- selection helpers ----------------------------------------------------
8657
- pickSection() {
8658
- const sections = this.host.sections();
8659
- if (!sections.length) return;
8660
- this.promptChoice("Select a whole section", sections.map((s) => ({ value: s.id, label: s.label })), (value) => {
8661
- this.host.selectSection(value);
8662
- });
8720
+ /** Derived once per mode entry — see `assignmentRowsCache`. */
8721
+ assignmentRows() {
8722
+ if (!this.assignmentRowsCache) this.assignmentRowsCache = this.host.rows();
8723
+ return this.assignmentRowsCache;
8663
8724
  }
8664
8725
  pickCategory() {
8665
8726
  const categories = this.host.categories();
@@ -8690,6 +8751,209 @@ var ChannelsMode = class {
8690
8751
  });
8691
8752
  });
8692
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
+ }
8693
8957
  // ---- dialogs --------------------------------------------------------------
8694
8958
  openDialog(state) {
8695
8959
  this.dialog = state;
@@ -8700,6 +8964,7 @@ var ChannelsMode = class {
8700
8964
  if (!state) return;
8701
8965
  if (state.kind === "create") this.renderCreateDialog(state);
8702
8966
  else if (state.kind === "review") this.renderReviewDialog(state);
8967
+ else if (state.kind === "scope") this.renderScopeDialog(state);
8703
8968
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8704
8969
  else if (state.kind === "rename") this.renderRenameDialog(state);
8705
8970
  else if (state.kind === "seatlist") this.renderSeatListDialog();
@@ -8844,24 +9109,28 @@ var ChannelsMode = class {
8844
9109
  const { labels, buckets } = this.currentPlan();
8845
9110
  const authoritative = state.applied;
8846
9111
  const shown = authoritative ? authoritative.buckets : buckets;
8847
- const targetName = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
9112
+ const targetName = this.nameOf(authoritative?.targetChannelId ?? this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8848
9113
  const rows = bucketRows(shown, targetName);
8849
9114
  const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
8850
9115
  const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
9116
+ const tooLarge = !authoritative && labels.length > MAX_ASSIGNMENT_UNITS;
8851
9117
  const rowsHtml = bucketRowsHtml(rows);
8852
9118
  const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
8853
9119
  <button type="button" class="quiet" data-ch-close>Back</button>
8854
- <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" : ""}>
8855
9121
  ${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
8856
9122
  </button>
8857
9123
  </div>`;
8858
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>` : "";
8859
9125
  const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
8860
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>` : "";
8861
9129
  this.renderScrim(`
8862
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>
8863
9131
  <p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
8864
9132
  ${skippedNote}
9133
+ ${limitNote}
8865
9134
  ${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
8866
9135
  ${confirmNote}
8867
9136
  <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
@@ -8873,15 +9142,21 @@ var ChannelsMode = class {
8873
9142
  }
8874
9143
  }
8875
9144
  /**
8876
- * Apply. On success the review sheet re-renders with the AUTHORITATIVE server
8877
- * buckets and the staged bar morphs to a for 1.2s. On a stale version the
8878
- * server mutated nothing: keep the selection, shake the bar once, and offer
8879
- * 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.
8880
9151
  */
8881
9152
  async apply() {
8882
9153
  if (!this.dialog || !this.caps.manage) return;
8883
9154
  const { labels } = this.currentPlan();
8884
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
+ }
8885
9160
  this.dialog = { ...this.dialog, busy: true, error: null };
8886
9161
  this.renderDialog();
8887
9162
  try {
@@ -8894,11 +9169,10 @@ var ChannelsMode = class {
8894
9169
  });
8895
9170
  this.assignmentVersion = result.assignmentVersion;
8896
9171
  this.conflict = false;
8897
- this.dialog = { kind: "review", applied: result };
8898
9172
  await this.refresh({ quiet: true });
8899
- this.renderDialog();
8900
- this.showApplied(result);
9173
+ this.closeDialog({ restoreFocus: false });
8901
9174
  this.host.clearSelection();
9175
+ this.showApplied(result);
8902
9176
  } catch (err) {
8903
9177
  const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
8904
9178
  if (conflict) {
@@ -8921,12 +9195,26 @@ var ChannelsMode = class {
8921
9195
  this.host.onError(err);
8922
9196
  }
8923
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
+ */
8924
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;
8925
9207
  this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
8926
- <span>Applied <b>${result.applied.toLocaleString()}</b> change${result.applied === 1 ? "" : "s"}</span>
8927
- <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` : ""}.`);
8928
9216
  if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
8929
- this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 1200);
9217
+ this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 5e3);
8930
9218
  }
8931
9219
  shakeStaged() {
8932
9220
  const bar = this.stagedEl;
@@ -10037,6 +10325,30 @@ var SeatManager = class {
10037
10325
  this.selectSection(sectionId);
10038
10326
  },
10039
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
+ },
10040
10352
  categories: () => (this.doc?.categories ?? []).map((category) => ({
10041
10353
  key: category.key,
10042
10354
  label: category.label ?? category.key,