@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.js CHANGED
@@ -6888,6 +6888,7 @@ var ManageApi = class {
6888
6888
  var POLL_MS = 3e4;
6889
6889
  var MAX_FLAGS = 8;
6890
6890
  var SEAT_LIST_PAGE = 300;
6891
+ var MAX_ASSIGNMENT_UNITS = 5e3;
6891
6892
  var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
6892
6893
  var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
6893
6894
  var PREVIEW_UNAVAILABLE_FILL = "#303846";
@@ -6934,6 +6935,7 @@ var CHANNELS_CSS = (
6934
6935
  .slm-ch-staged .grow{flex:1}
6935
6936
  .slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
6936
6937
  background:#f4b740;color:#1a1200;font-weight:800;font-size:12.5px}
6938
+ .slm-ch-staged .go:disabled{opacity:.48;cursor:not-allowed}
6937
6939
  .slm-ch-staged .drop{color:var(--slm-muted);font-weight:700;font-size:11.5px;min-height:44px;padding-inline:8px}
6938
6940
  .slm-ch-tick{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;
6939
6941
  background:#fff;color:#1f7a4d;font-weight:900;font-size:11px;animation:slm-ch-tick var(--slm-mo-base) var(--slm-mo-spring)}
@@ -7064,6 +7066,25 @@ var CHANNELS_CSS = (
7064
7066
  .slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
7065
7067
  .slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
7066
7068
 
7069
+ /* scope chooser: whole sections or many rows in one additive pass */
7070
+ .slm-ch-scopebar{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:10px;margin-top:12px}
7071
+ .slm-ch-scopebar label{display:grid;gap:5px;color:var(--slm-muted);font-size:10.5px;font-weight:800}
7072
+ .slm-ch-scopebar input{width:100%;min-height:40px;padding:8px 10px;border:1px solid var(--slm-line);border-radius:8px;
7073
+ background:var(--slm-surface);color:var(--slm-text);font:inherit}
7074
+ .slm-ch-scopesummary{padding-bottom:10px;color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
7075
+ .slm-ch-scopegroup{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;
7076
+ gap:6px;padding:6px 8px;border-bottom:1px solid var(--slm-line);background:var(--slm-surface)}
7077
+ .slm-ch-groupcheck{display:flex;min-width:0;align-items:center;gap:8px;padding:5px 2px;text-align:left;font-size:11px;font-weight:800}
7078
+ .slm-ch-groupcheck .box{width:16px;height:16px;flex:none;display:grid;place-items:center;border:1px solid var(--slm-muted);border-radius:4px;
7079
+ color:transparent;font-size:10px}
7080
+ .slm-ch-groupcheck[aria-checked="true"] .box,.slm-ch-groupcheck[aria-checked="mixed"] .box{border-color:var(--slm-accent);
7081
+ background:var(--slm-accent);color:var(--slm-accent-ink)}
7082
+ .slm-ch-groupcheck .meta{min-width:0;margin-left:auto;color:var(--slm-muted);font-size:10.5px;font-weight:600;white-space:nowrap}
7083
+ .slm-ch-grouptoggle{min-height:32px;padding:5px 8px;color:var(--slm-accent);font-size:11px;font-weight:800}
7084
+ .slm-ch-scopehint{padding:8px 10px;color:var(--slm-muted);font-size:10.5px;border-bottom:1px solid var(--slm-line)}
7085
+ .slm-ch-scope-empty{padding:18px 12px;color:var(--slm-muted);font-size:11.5px;text-align:center}
7086
+ @media(max-width:560px){.slm-ch-scopebar{grid-template-columns:1fr}.slm-ch-scopesummary{padding-bottom:0}}
7087
+
7067
7088
  /* compact: bottom sheet with three detents (\xA713) */
7068
7089
  .slm.compact.ch-sheet .slm-rail{position:absolute;left:0;right:0;bottom:0;z-index:8;border-top:1px solid var(--slm-line);
7069
7090
  border-radius:18px 18px 0 0;background:#12151f;
@@ -7188,6 +7209,10 @@ var ChannelsMode = class {
7188
7209
  this.stagedDoneTimer = null;
7189
7210
  this.lastSelectionCount = 0;
7190
7211
  this.lastCounts = /* @__PURE__ */ new Map();
7212
+ /** Rows are structural chart data — they do not move while the organizer is
7213
+ * allocating. Deriving them walks every seat, so the view is cached for the
7214
+ * lifetime of this mode entry and ordinary selection repaints stay O(1). */
7215
+ this.assignmentRowsCache = null;
7191
7216
  /** The markup currently in the rail. An identical repaint is skipped, which is
7192
7217
  * what keeps the organizer's scroll position (and open <select>) alive. */
7193
7218
  this.railHtml = null;
@@ -7209,6 +7234,7 @@ var ChannelsMode = class {
7209
7234
  this.active = true;
7210
7235
  this.mapIntent = "pan";
7211
7236
  this.focusedSectionId = null;
7237
+ this.assignmentRowsCache = null;
7212
7238
  this.railHtml = null;
7213
7239
  this.ensureLayer();
7214
7240
  this.host.root.classList.add("ch-mode");
@@ -7672,6 +7698,7 @@ var ChannelsMode = class {
7672
7698
  }
7673
7699
  const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
7674
7700
  const mutations = mutationCount(buckets);
7701
+ const tooLarge = labels.length > MAX_ASSIGNMENT_UNITS;
7675
7702
  const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
7676
7703
  const parts = [
7677
7704
  `<b>${labels.length.toLocaleString()}</b> selected`,
@@ -7683,7 +7710,9 @@ var ChannelsMode = class {
7683
7710
  <span>${parts.join(" \xB7 ")}</span>
7684
7711
  <span class="grow"></span>
7685
7712
  <button type="button" class="drop" data-ch-act="discard">Discard</button>
7686
- <button type="button" class="go" data-ch-act="review">Review changes</button>`);
7713
+ <button type="button" class="go" data-ch-act="review"${tooLarge ? " disabled" : ""}>
7714
+ ${tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : "Review changes"}
7715
+ </button>`);
7687
7716
  this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
7688
7717
  button.addEventListener("click", () => {
7689
7718
  if (button.dataset.chAct === "discard") this.host.clearSelection();
@@ -7874,8 +7903,9 @@ var ChannelsMode = class {
7874
7903
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
7875
7904
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
7876
7905
  return `
7906
+ ${this.caps.manage ? this.assignmentToolsHtml() : ""}
7877
7907
  <p class="slm-eyebrow">Sales channels</p>
7878
- <p class="slm-hint">Select seats on the map, then assign them. Channel colours and names are never shown to buyers.</p>
7908
+ <p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
7879
7909
  <div class="slm-ch-list">${rows}</div>
7880
7910
  ${create}
7881
7911
  ${readOnly}
@@ -7884,6 +7914,38 @@ var ChannelsMode = class {
7884
7914
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
7885
7915
  </p>`;
7886
7916
  }
7917
+ /**
7918
+ * Destination-first assignment controls.
7919
+ *
7920
+ * These used to live only inside the selection rail, which meant every route
7921
+ * into them was gated behind "select a seat on the map first" — the section,
7922
+ * row and category choosers were invisible until the organizer had already
7923
+ * done the work by hand. They now paint above the channel list too, so the
7924
+ * destination is chosen first and whole sections and rows are discoverable
7925
+ * without learning a hidden seat-first workflow.
7926
+ */
7927
+ assignmentToolsHtml() {
7928
+ const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
7929
+ const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
7930
+ const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
7931
+ const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
7932
+ return `
7933
+ <p class="slm-eyebrow">Assign inventory</p>
7934
+ <div class="slm-field">
7935
+ <label for="slm-ch-target">Assign to</label>
7936
+ <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
7937
+ </div>
7938
+ <p class="slm-eyebrow" style="margin-top:12px">Select by</p>
7939
+ <div class="slm-ch-row2" style="margin-top:2px">
7940
+ ${sections}${rows}
7941
+ <button type="button" class="${dragClass}" data-ch-act="drag-select">Drag box</button>
7942
+ </div>
7943
+ <div class="slm-ch-row2">
7944
+ <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
7945
+ <button type="button" class="slm-btn ghost" data-ch-act="seatlist">Seat list \u2328</button>
7946
+ </div>
7947
+ <p class="slm-note">Choose a destination, then add whole sections, multiple rows, a dragged area, or individual seats.</p>`;
7948
+ }
7887
7949
  selectionRailHtml(selection) {
7888
7950
  const sources = selectionSources(selection, this.allocation, this.list);
7889
7951
  const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
@@ -7892,34 +7954,27 @@ var ChannelsMode = class {
7892
7954
  <button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
7893
7955
  const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
7894
7956
  this.lastSelectionCount = selection.length;
7895
- const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
7957
+ const tooLarge = selection.length > MAX_ASSIGNMENT_UNITS;
7896
7958
  const sourceRows = sources.map((row) => {
7897
7959
  const marker = this.markerFor(row.channelId);
7898
7960
  return `<div class="slm-ch-selsrc-row">
7899
7961
  <span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
7900
7962
  <b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
7901
7963
  }).join("");
7902
- const sectionSelect = this.host.sections().length ? `<button type="button" class="slm-btn ghost" data-ch-act="pick-section">Section</button>` : "";
7903
7964
  return `
7904
7965
  ${conflict}
7966
+ ${this.assignmentToolsHtml()}
7905
7967
  <div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
7906
7968
  <span class="slm-sellabel">selected</span></div>
7907
7969
  <div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
7908
- <div class="slm-field">
7909
- <label for="slm-ch-target">Assign to</label>
7910
- <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
7911
- </div>
7970
+ ${tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span>
7971
+ <b>This selection is too large to apply at once.</b> Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()}
7972
+ seats, for example by splitting the venue into row groups.</span></div>` : ""}
7912
7973
  <p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
7913
7974
  Seats in checkout or already sold are never moved.</p>
7914
7975
  <div class="slm-ch-row2">
7915
7976
  <button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
7916
- <button type="button" class="slm-btn" data-ch-act="review">Review changes</button>
7917
- </div>
7918
- <p class="slm-eyebrow" style="margin-top:18px">Select by</p>
7919
- <div class="slm-ch-row2" style="margin-top:2px">
7920
- ${sectionSelect}
7921
- <button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
7922
- <button type="button" class="slm-btn ghost" data-ch-act="seatlist">List \u2328</button>
7977
+ <button type="button" class="slm-btn" data-ch-act="review"${tooLarge ? " disabled" : ""}>Review changes</button>
7923
7978
  </div>
7924
7979
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
7925
7980
  }
@@ -8235,8 +8290,16 @@ var ChannelsMode = class {
8235
8290
  this.conflict = false;
8236
8291
  void this.refresh().then(() => this.openDialog({ kind: "review" }));
8237
8292
  break;
8238
- case "pick-section":
8239
- this.pickSection();
8293
+ case "pick-sections":
8294
+ this.openDialog({ kind: "scope", scope: "sections" });
8295
+ break;
8296
+ case "pick-rows":
8297
+ this.openDialog({ kind: "scope", scope: "rows" });
8298
+ break;
8299
+ case "drag-select":
8300
+ this.mapIntent = "assign";
8301
+ this.paintRail();
8302
+ this.onInteractionChange?.();
8240
8303
  break;
8241
8304
  case "pick-category":
8242
8305
  this.pickCategory();
@@ -8246,12 +8309,10 @@ var ChannelsMode = class {
8246
8309
  }
8247
8310
  }
8248
8311
  // ---- selection helpers ----------------------------------------------------
8249
- pickSection() {
8250
- const sections = this.host.sections();
8251
- if (!sections.length) return;
8252
- this.promptChoice("Select a whole section", sections.map((s) => ({ value: s.id, label: s.label })), (value) => {
8253
- this.host.selectSection(value);
8254
- });
8312
+ /** Derived once per mode entry — see `assignmentRowsCache`. */
8313
+ assignmentRows() {
8314
+ if (!this.assignmentRowsCache) this.assignmentRowsCache = this.host.rows();
8315
+ return this.assignmentRowsCache;
8255
8316
  }
8256
8317
  pickCategory() {
8257
8318
  const categories = this.host.categories();
@@ -8282,6 +8343,209 @@ var ChannelsMode = class {
8282
8343
  });
8283
8344
  });
8284
8345
  }
8346
+ /**
8347
+ * Add several whole sections, or several whole rows, in one operation.
8348
+ *
8349
+ * ADDITIVE by contract: the chooser starts from the labels already selected
8350
+ * and only ever grows that set, so opening it never destroys a hard-won
8351
+ * marquee or seat-list selection. The confirm button always states the net
8352
+ * number of seats it will add, and refuses to exceed `MAX_ASSIGNMENT_UNITS`.
8353
+ *
8354
+ * Rows get the richer variant. A large venue has thousands of them, so they
8355
+ * arrive collapsed under their section, with a section-level tri-state
8356
+ * checkbox, a search across every row, and a render cap — the flat list used
8357
+ * for sections would be an unusable wall of buttons.
8358
+ */
8359
+ renderScopeDialog(state) {
8360
+ const scope = state.scope === "rows" ? "rows" : "sections";
8361
+ const options = scope === "sections" ? this.host.sections().map((section) => ({
8362
+ id: section.id,
8363
+ label: section.label,
8364
+ group: "Sections",
8365
+ labels: this.host.labelsInSection(section.id)
8366
+ })) : this.assignmentRows().map((row) => ({
8367
+ id: row.id,
8368
+ label: row.label,
8369
+ group: row.sectionLabel,
8370
+ labels: row.labels
8371
+ }));
8372
+ const available = options.filter((option) => option.labels.length > 0);
8373
+ const grouped = /* @__PURE__ */ new Map();
8374
+ for (const option of available) {
8375
+ const group = grouped.get(option.group) ?? [];
8376
+ group.push(option);
8377
+ grouped.set(option.group, group);
8378
+ }
8379
+ if (scope === "rows") {
8380
+ const groups = [...grouped.entries()];
8381
+ this.renderScrim(`
8382
+ <h3 id="slm-ch-dlg-title">Add multiple rows</h3>
8383
+ <p class="sub">Sections stay collapsed for speed. Select a whole section, expand only the rows you need, or search across every row.</p>
8384
+ <div class="slm-ch-scopebar">
8385
+ <label>Find section or row<input type="search" data-ch-scope-search placeholder="e.g. Orchestra or AA"></label>
8386
+ <span class="slm-ch-scopesummary" data-ch-scope-summary aria-live="polite">0 rows selected</span>
8387
+ </div>
8388
+ <p class="slm-ch-scopehint" data-ch-scope-filter>Showing section groups. Expand one or search to see rows.</p>
8389
+ <div class="slm-ch-seatlist" data-ch-scope-list></div>
8390
+ <p class="slm-ch-err" data-ch-error hidden></p>
8391
+ <div class="foot">
8392
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8393
+ <button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
8394
+ </div>`, (dialog) => {
8395
+ const byId = new Map(available.map((option) => [option.id, option]));
8396
+ const picked = /* @__PURE__ */ new Set();
8397
+ const expanded = /* @__PURE__ */ new Set();
8398
+ const current = new Set(this.host.selectionLabels());
8399
+ const confirm = dialog.querySelector("[data-ch-add-scope]");
8400
+ const error = dialog.querySelector("[data-ch-error]");
8401
+ const list = dialog.querySelector("[data-ch-scope-list]");
8402
+ const search = dialog.querySelector("[data-ch-scope-search]");
8403
+ const summary = dialog.querySelector("[data-ch-scope-summary]");
8404
+ const filterHint = dialog.querySelector("[data-ch-scope-filter]");
8405
+ const renderLimit = 200;
8406
+ let query = "";
8407
+ const selectedLabels = () => {
8408
+ const labels = new Set(current);
8409
+ for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
8410
+ return labels;
8411
+ };
8412
+ const update = () => {
8413
+ const labels = selectedLabels();
8414
+ const added = labels.size - current.size;
8415
+ const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
8416
+ confirm.disabled = added === 0 || tooLarge;
8417
+ confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
8418
+ error.hidden = !tooLarge;
8419
+ error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
8420
+ summary.textContent = `${picked.size.toLocaleString()} row${picked.size === 1 ? "" : "s"} \xB7 ${added.toLocaleString()} seat${added === 1 ? "" : "s"} added`;
8421
+ };
8422
+ const renderList = () => {
8423
+ const blocks = [];
8424
+ let totalMatches = 0;
8425
+ let rendered = 0;
8426
+ groups.forEach(([group, items], groupIndex) => {
8427
+ const normalizedGroup = group.toLocaleLowerCase();
8428
+ const matches = query ? items.filter((item) => `${normalizedGroup} ${item.label.toLocaleLowerCase()}`.includes(query)) : items;
8429
+ if (query && !matches.length) return;
8430
+ totalMatches += matches.length;
8431
+ const allPicked = items.every((item) => picked.has(item.id));
8432
+ const somePicked = !allPicked && items.some((item) => picked.has(item.id));
8433
+ const open = Boolean(query) || expanded.has(groupIndex);
8434
+ const room = Math.max(0, renderLimit - rendered);
8435
+ const visible = open ? matches.slice(0, room) : [];
8436
+ rendered += visible.length;
8437
+ const seatCount = items.reduce((sum, item) => sum + item.labels.length, 0);
8438
+ blocks.push(`<div class="slm-ch-scopegroup">
8439
+ <button type="button" class="slm-ch-groupcheck" role="checkbox" aria-checked="${allPicked ? "true" : somePicked ? "mixed" : "false"}"
8440
+ aria-label="Select all ${items.length.toLocaleString()} rows in ${esc(group)}" data-ch-scope-group="${groupIndex}">
8441
+ <span class="box" aria-hidden="true">\u2713</span><span>${esc(group)}</span>
8442
+ <span class="meta">${items.length.toLocaleString()} rows \xB7 ${seatCount.toLocaleString()} seats</span>
8443
+ </button>
8444
+ <button type="button" class="slm-ch-grouptoggle" aria-expanded="${open}" aria-label="${open ? "Hide" : "Show"} rows in ${esc(group)}"
8445
+ data-ch-scope-toggle="${groupIndex}">${open ? "Hide" : "Show"}</button>
8446
+ </div>`);
8447
+ blocks.push(...visible.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
8448
+ aria-checked="${picked.has(option.id)}" data-ch-scope-id="${esc(option.id)}">
8449
+ <span class="box" aria-hidden="true">\u2713</span><span>${esc(option.label)}</span>
8450
+ <span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
8451
+ </button>`));
8452
+ if (open && matches.length > visible.length) {
8453
+ blocks.push(`<p class="slm-ch-scopehint">${(matches.length - visible.length).toLocaleString()} more row${matches.length - visible.length === 1 ? "" : "s"}. Search to narrow the list.</p>`);
8454
+ }
8455
+ });
8456
+ list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div>`;
8457
+ filterHint.textContent = query ? `Showing ${rendered.toLocaleString()} of ${totalMatches.toLocaleString()} matching rows. Section checkboxes still select every row in that section.` : "Showing section groups. Expand one or search to see rows.";
8458
+ list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
8459
+ const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
8460
+ const remove = items.every((item) => picked.has(item.id));
8461
+ for (const item of items) remove ? picked.delete(item.id) : picked.add(item.id);
8462
+ renderList();
8463
+ update();
8464
+ }));
8465
+ list.querySelectorAll("[data-ch-scope-toggle]").forEach((button) => button.addEventListener("click", () => {
8466
+ const index = Number(button.dataset.chScopeToggle);
8467
+ if (expanded.has(index)) expanded.delete(index);
8468
+ else expanded.add(index);
8469
+ renderList();
8470
+ }));
8471
+ list.querySelectorAll("[data-ch-scope-id]").forEach((button) => button.addEventListener("click", () => {
8472
+ const id = button.dataset.chScopeId;
8473
+ if (picked.has(id)) picked.delete(id);
8474
+ else picked.add(id);
8475
+ renderList();
8476
+ update();
8477
+ }));
8478
+ };
8479
+ search.addEventListener("input", () => {
8480
+ query = search.value.trim().toLocaleLowerCase();
8481
+ renderList();
8482
+ update();
8483
+ });
8484
+ confirm.addEventListener("click", () => {
8485
+ const labels = [...selectedLabels()];
8486
+ if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
8487
+ this.closeDialog();
8488
+ this.host.selectByLabels(labels);
8489
+ });
8490
+ renderList();
8491
+ update();
8492
+ });
8493
+ return;
8494
+ }
8495
+ const body = [...grouped.entries()].map(([group, items]) => `
8496
+ <div class="slm-ch-seatgroup"><span>${esc(group)}</span></div>
8497
+ ${items.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
8498
+ aria-checked="false" data-ch-scope-id="${esc(option.id)}">
8499
+ <span class="box" aria-hidden="true">\u2713</span>
8500
+ <span>${esc(option.label)}</span>
8501
+ <span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
8502
+ </button>`).join("")}`).join("");
8503
+ this.renderScrim(`
8504
+ <h3 id="slm-ch-dlg-title">Add whole sections</h3>
8505
+ <p class="sub">Choose one or more. They are added to the seats already selected on the map.</p>
8506
+ <div class="slm-ch-seatlist">${body || `<div class="slm-empty">No sections are available.</div>`}</div>
8507
+ <p class="slm-ch-err" data-ch-error hidden></p>
8508
+ <div class="foot">
8509
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8510
+ <button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
8511
+ </div>`, (dialog) => {
8512
+ const byId = new Map(available.map((option) => [option.id, option]));
8513
+ const picked = /* @__PURE__ */ new Set();
8514
+ const current = new Set(this.host.selectionLabels());
8515
+ const confirm = dialog.querySelector("[data-ch-add-scope]");
8516
+ const error = dialog.querySelector("[data-ch-error]");
8517
+ const selectedLabels = () => {
8518
+ const labels = new Set(current);
8519
+ for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
8520
+ return labels;
8521
+ };
8522
+ const update = () => {
8523
+ const labels = selectedLabels();
8524
+ const added = labels.size - current.size;
8525
+ const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
8526
+ confirm.disabled = added === 0 || tooLarge;
8527
+ confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
8528
+ error.hidden = !tooLarge;
8529
+ error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
8530
+ };
8531
+ dialog.querySelectorAll("[data-ch-scope-id]").forEach((button) => {
8532
+ button.addEventListener("click", () => {
8533
+ const id = button.dataset.chScopeId;
8534
+ if (picked.has(id)) picked.delete(id);
8535
+ else picked.add(id);
8536
+ button.setAttribute("aria-checked", String(picked.has(id)));
8537
+ update();
8538
+ });
8539
+ });
8540
+ confirm.addEventListener("click", () => {
8541
+ const labels = [...selectedLabels()];
8542
+ if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
8543
+ this.closeDialog();
8544
+ this.host.selectByLabels(labels);
8545
+ });
8546
+ update();
8547
+ });
8548
+ }
8285
8549
  // ---- dialogs --------------------------------------------------------------
8286
8550
  openDialog(state) {
8287
8551
  this.dialog = state;
@@ -8292,6 +8556,7 @@ var ChannelsMode = class {
8292
8556
  if (!state) return;
8293
8557
  if (state.kind === "create") this.renderCreateDialog(state);
8294
8558
  else if (state.kind === "review") this.renderReviewDialog(state);
8559
+ else if (state.kind === "scope") this.renderScopeDialog(state);
8295
8560
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8296
8561
  else if (state.kind === "rename") this.renderRenameDialog(state);
8297
8562
  else if (state.kind === "seatlist") this.renderSeatListDialog();
@@ -8436,24 +8701,28 @@ var ChannelsMode = class {
8436
8701
  const { labels, buckets } = this.currentPlan();
8437
8702
  const authoritative = state.applied;
8438
8703
  const shown = authoritative ? authoritative.buckets : buckets;
8439
- const targetName = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8704
+ const targetName = this.nameOf(authoritative?.targetChannelId ?? this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8440
8705
  const rows = bucketRows(shown, targetName);
8441
8706
  const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
8442
8707
  const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
8708
+ const tooLarge = !authoritative && labels.length > MAX_ASSIGNMENT_UNITS;
8443
8709
  const rowsHtml = bucketRowsHtml(rows);
8444
8710
  const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
8445
8711
  <button type="button" class="quiet" data-ch-close>Back</button>
8446
- <button type="button" class="slm-btn" data-ch-apply ${allSkipped || state.busy ? "disabled" : ""}>
8712
+ <button type="button" class="slm-btn" data-ch-apply ${allSkipped || tooLarge || state.busy ? "disabled" : ""}>
8447
8713
  ${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
8448
8714
  </button>
8449
8715
  </div>`;
8450
8716
  const confirmNote = !authoritative && needsMoveConfirmation(buckets) ? `<p class="slm-note">Applying moves inventory out of another private channel. That is the line marked above.</p>` : "";
8451
8717
  const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
8452
8718
  every seat is in a buyer's checkout, already sold, or already in ${esc(targetName)}.</span></div>` : "";
8719
+ const limitNote = tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span><b>Choose fewer seats.</b>
8720
+ One Apply can cover at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats.</span></div>` : "";
8453
8721
  this.renderScrim(`
8454
8722
  <h3 id="slm-ch-dlg-title">${authoritative ? `Moved ${authoritative.applied.toLocaleString()} seat${authoritative.applied === 1 ? "" : "s"} to ${esc(targetName)}` : `Move ${labels.length.toLocaleString()} selected seat${labels.length === 1 ? "" : "s"} to ${esc(targetName)}`}</h3>
8455
8723
  <p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
8456
8724
  ${skippedNote}
8725
+ ${limitNote}
8457
8726
  ${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
8458
8727
  ${confirmNote}
8459
8728
  <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
@@ -8465,15 +8734,21 @@ var ChannelsMode = class {
8465
8734
  }
8466
8735
  }
8467
8736
  /**
8468
- * Apply. On success the review sheet re-renders with the AUTHORITATIVE server
8469
- * buckets and the staged bar morphs to a for 1.2s. On a stale version the
8470
- * server mutated nothing: keep the selection, shake the bar once, and offer
8471
- * exactly one action Refresh and review.
8737
+ * Apply. On success the sheet CLOSES and the staged bar confirms what moved,
8738
+ * naming the destination and any skipped seats, with the AUTHORITATIVE server
8739
+ * buckets still one Details press away. Leaving the modal up on success made
8740
+ * the organizer dismiss a sheet to get back to a map they had just changed.
8741
+ * On a stale version the server mutated nothing: keep the selection, shake
8742
+ * the bar once, and offer exactly one action — Refresh and review.
8472
8743
  */
8473
8744
  async apply() {
8474
8745
  if (!this.dialog || !this.caps.manage) return;
8475
8746
  const { labels } = this.currentPlan();
8476
8747
  if (!labels.length) return;
8748
+ if (labels.length > MAX_ASSIGNMENT_UNITS) {
8749
+ this.showDialogError(`Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats for one Apply.`);
8750
+ return;
8751
+ }
8477
8752
  this.dialog = { ...this.dialog, busy: true, error: null };
8478
8753
  this.renderDialog();
8479
8754
  try {
@@ -8486,11 +8761,10 @@ var ChannelsMode = class {
8486
8761
  });
8487
8762
  this.assignmentVersion = result.assignmentVersion;
8488
8763
  this.conflict = false;
8489
- this.dialog = { kind: "review", applied: result };
8490
8764
  await this.refresh({ quiet: true });
8491
- this.renderDialog();
8492
- this.showApplied(result);
8765
+ this.closeDialog({ restoreFocus: false });
8493
8766
  this.host.clearSelection();
8767
+ this.showApplied(result);
8494
8768
  } catch (err) {
8495
8769
  const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
8496
8770
  if (conflict) {
@@ -8513,12 +8787,26 @@ var ChannelsMode = class {
8513
8787
  this.host.onError(err);
8514
8788
  }
8515
8789
  }
8790
+ /**
8791
+ * The success receipt, now that the review sheet closes on Apply. It names the
8792
+ * destination and the seats that could not move, keeps the authoritative
8793
+ * buckets reachable through Details, and stays up long enough to be read —
8794
+ * 1.2s was tuned for a confirmation the organizer was already looking at.
8795
+ */
8516
8796
  showApplied(result) {
8797
+ const targetName = this.nameOf(result.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
8798
+ const skipped = result.buckets.skippedHeld.count + result.buckets.skippedBooked.count + result.buckets.notFound.count;
8517
8799
  this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
8518
- <span>Applied <b>${result.applied.toLocaleString()}</b> change${result.applied === 1 ? "" : "s"}</span>
8519
- <span class="grow"></span>`, "done");
8800
+ <span>Assigned <b>${result.applied.toLocaleString()}</b> seat${result.applied === 1 ? "" : "s"} to ${esc(targetName)}
8801
+ ${skipped ? ` \xB7 ${skipped.toLocaleString()} skipped` : ""}</span>
8802
+ <span class="grow"></span>
8803
+ <button type="button" class="drop" data-ch-applied-details>Details</button>`, "done");
8804
+ this.stagedEl?.querySelector("[data-ch-applied-details]")?.addEventListener("click", () => {
8805
+ this.openDialog({ kind: "review", applied: result });
8806
+ });
8807
+ this.announce(`Assigned ${result.applied} seat${result.applied === 1 ? "" : "s"} to ${targetName}${skipped ? `; ${skipped} skipped` : ""}.`);
8520
8808
  if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
8521
- this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 1200);
8809
+ this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 5e3);
8522
8810
  }
8523
8811
  shakeStaged() {
8524
8812
  const bar = this.stagedEl;
@@ -9629,6 +9917,30 @@ var SeatManager = class {
9629
9917
  this.selectSection(sectionId);
9630
9918
  },
9631
9919
  sections: () => this.sectionOptions,
9920
+ labelsInSection: (sectionId) => this.renderer?.getSelectableInSection(sectionId).map((seat) => seat.label) ?? [],
9921
+ // Logical rows, not physical row objects: a segmented row is authored as
9922
+ // several objects sharing one `logicalRowId`, and the organizer thinks of
9923
+ // it as one row. Tables carry seats too, so they are rows here as well.
9924
+ rows: () => {
9925
+ const objects = new Map((this.doc?.objects ?? []).map((object) => [object.id, object]));
9926
+ const rows = /* @__PURE__ */ new Map();
9927
+ for (const seat of this.labelToSeat.values()) {
9928
+ const object = objects.get(seat.rowId);
9929
+ if (!object || object.type !== "row" && object.type !== "table") continue;
9930
+ const id = seat.logicalRowId ?? seat.rowId;
9931
+ const sectionId = this.sectionByObject.get(seat.rowId) ?? UNGROUPED_ID;
9932
+ const row = rows.get(id) ?? {
9933
+ id,
9934
+ label: object.type === "row" ? object.segmentedRow?.displayLabel ?? object.displayLabel ?? object.label : object.displayLabel ?? object.label,
9935
+ sectionId,
9936
+ sectionLabel: this.sectionLabelById.get(sectionId) ?? "Other seats",
9937
+ labels: []
9938
+ };
9939
+ row.labels.push(seat.label);
9940
+ rows.set(id, row);
9941
+ }
9942
+ return [...rows.values()].sort((left, right) => left.sectionLabel.localeCompare(right.sectionLabel, void 0, { numeric: true, sensitivity: "base" }) || left.label.localeCompare(right.label, void 0, { numeric: true, sensitivity: "base" }));
9943
+ },
9632
9944
  categories: () => (this.doc?.categories ?? []).map((category) => ({
9633
9945
  key: category.key,
9634
9946
  label: category.label ?? category.key,