@seatlayer/js 0.43.2 → 0.44.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
@@ -7384,7 +7384,23 @@ var CHANNELS_CSS = (
7384
7384
  .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
7385
7385
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
7386
7386
  .slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
7387
- .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
7387
+ /* A row that opens its channel must READ as pressable, and be one for a keyboard
7388
+ too. It is a role="button" div rather than a <button> because the \u22EF control
7389
+ lives inside it, and a button inside a button is not valid HTML. */
7390
+ .slm-ch-row.open{cursor:pointer}
7391
+ .slm-ch-row.open:hover{border-color:var(--slm-accent);background:color-mix(in srgb,var(--slm-accent) 6%,var(--slm-surface))}
7392
+ .slm-ch-row.open:focus-visible{outline:2px solid var(--slm-accent);outline-offset:2px}
7393
+ .slm-ch-row.open:active{border-color:var(--slm-accent)}
7394
+ .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px;border-radius:6px}
7395
+ .slm-ch-more:hover{color:var(--slm-text)}
7396
+ .slm-ch-more:focus-visible{outline:2px solid var(--slm-accent);outline-offset:1px;color:var(--slm-text)}
7397
+ .slm-ch-menu{display:flex;flex-direction:column;gap:8px}
7398
+ .slm-ch-menu .slm-btn{width:100%}
7399
+ /* loading is a state, never a flash of empty: same slot, visibly working */
7400
+ .slm-ch-busy{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--slm-muted);padding:12px 0}
7401
+ .slm-ch-busy::before{content:"";width:9px;height:9px;border-radius:50%;background:var(--slm-accent);flex:none;
7402
+ animation:slm-ch-pulse var(--slm-mo-ambient) var(--slm-mo-in-out) infinite}
7403
+ @keyframes slm-ch-pulse{0%,100%{opacity:.25;transform:scale(.7)}50%{opacity:1;transform:scale(1)}}
7388
7404
  .slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
7389
7405
  .slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
7390
7406
  .slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
@@ -7516,6 +7532,7 @@ var CHANNELS_CSS = (
7516
7532
  .slm-ch-meter i,.slm-ch-radio{transition:none!important}
7517
7533
  .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
7518
7534
  .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
7535
+ .slm-ch-busy::before{animation:none!important;opacity:1}
7519
7536
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
7520
7537
  .slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
7521
7538
  }
@@ -7576,6 +7593,18 @@ var ChannelsMode = class {
7576
7593
  this.focusedSectionId = null;
7577
7594
  this.showArchived = false;
7578
7595
  this.detailChannelId = null;
7596
+ /**
7597
+ * Whether the organizer has asked to assign seats.
7598
+ *
7599
+ * The list rail leads with the CHANNELS. The assignment tooling — destination
7600
+ * picker plus five select-by routes — used to paint unconditionally above that
7601
+ * list, so a first-time organizer met a workbench for a job they had not asked
7602
+ * to do, with the thing they came for pushed below the fold. It is now
7603
+ * disclosed on intent: the "Assign seats to a channel" action under the list,
7604
+ * the map's own "Assign seats" segment, or simply having a selection. The
7605
+ * selection rail always shows the tools, because there the intent is proven.
7606
+ */
7607
+ this.assignOpen = false;
7579
7608
  this.targetChannelId = "";
7580
7609
  this.conflict = false;
7581
7610
  this.dialog = null;
@@ -7602,8 +7631,16 @@ var ChannelsMode = class {
7602
7631
  this.previewAudience = [];
7603
7632
  this.previewIncludePublic = false;
7604
7633
  this.previewProjection = null;
7605
- this.previewSupported = null;
7606
- // null = not yet probed
7634
+ /**
7635
+ * The buyer-preview read, as one honest state rather than a boolean.
7636
+ *
7637
+ * `previewSupported: boolean | null` could say "this worker has no projection
7638
+ * route", but it could not say "the read is in flight" or "the read failed" —
7639
+ * both of those painted an audience picker with no result underneath, which
7640
+ * reads as "this audience can buy nothing". Loading and error are now states
7641
+ * of their own, and the error one offers a retry.
7642
+ */
7643
+ this.previewState = "idle";
7607
7644
  this.pollTimer = null;
7608
7645
  this.layer = null;
7609
7646
  this.canvas = null;
@@ -7641,6 +7678,7 @@ var ChannelsMode = class {
7641
7678
  if (this.active) return;
7642
7679
  this.active = true;
7643
7680
  this.mapIntent = "pan";
7681
+ this.assignOpen = false;
7644
7682
  this.focusedSectionId = null;
7645
7683
  this.assignmentRowsCache = null;
7646
7684
  this.railHtml = null;
@@ -8150,6 +8188,7 @@ var ChannelsMode = class {
8150
8188
  this.view = view;
8151
8189
  if (view === "inspect") {
8152
8190
  this.previewProjection = null;
8191
+ this.previewState = "idle";
8153
8192
  this.setBanner(false);
8154
8193
  } else {
8155
8194
  if (!this.previewAudience.length) {
@@ -8167,6 +8206,9 @@ var ChannelsMode = class {
8167
8206
  const audience = [...this.previewAudience];
8168
8207
  const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
8169
8208
  this.setBanner(true, names);
8209
+ this.previewProjection = null;
8210
+ this.previewState = "loading";
8211
+ if (this.active) this.paintRail();
8170
8212
  try {
8171
8213
  this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
8172
8214
  // Naming Public sale as the audience IS asking for public inventory; the
@@ -8174,14 +8216,14 @@ var ChannelsMode = class {
8174
8216
  // this the request would resolve to an empty scope and 422.
8175
8217
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
8176
8218
  });
8177
- this.previewSupported = true;
8219
+ this.previewState = "ready";
8178
8220
  const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
8179
8221
  this.setBanner(true, names, eligibleSeats);
8180
8222
  } catch (err) {
8181
8223
  const status = err instanceof ManageApiError ? err.status : 0;
8182
- this.previewSupported = !(status === 404 || status === 405 || status === 501);
8224
+ this.previewState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
8183
8225
  this.previewProjection = null;
8184
- if (this.previewSupported) this.host.onError(err);
8226
+ if (this.previewState === "error") this.host.onError(err);
8185
8227
  }
8186
8228
  if (this.active) {
8187
8229
  this.paintRail();
@@ -8222,13 +8264,14 @@ var ChannelsMode = class {
8222
8264
  }
8223
8265
  if (this.loading && !this.list) {
8224
8266
  this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
8225
- <div class="slm-empty">Loading allocations\u2026</div>`);
8267
+ <div class="slm-ch-busy" role="status" data-ch-state="list-loading">Loading channels and allocations\u2026</div>`);
8226
8268
  return;
8227
8269
  }
8228
8270
  if (!this.list && this.loadError) {
8229
8271
  if (this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
8230
- <div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
8231
- <span><b>Couldn't load sales channels.</b> Everything else on this event still works.
8272
+ <div class="slm-ch-alert err" role="alert" data-ch-state="list-error"><span>\u26A0</span>
8273
+ <span><b>Couldn't load sales channels.</b> This event may well have channels \u2014 we could not read them.
8274
+ Everything else on this event still works.
8232
8275
  <button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
8233
8276
  rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
8234
8277
  void this.refresh();
@@ -8287,10 +8330,13 @@ var ChannelsMode = class {
8287
8330
  const marker = this.markerFor(channel.id);
8288
8331
  const badgeKind = opts.builtin ? "builtin" : channel.state;
8289
8332
  const dim = channel.state === "paused" || channel.state === "archived" ? " dim" : "";
8290
- const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}`;
8291
- const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-detail="${esc(channel.id)}"
8292
- aria-label="Manage ${esc(channel.name)}">\u22EF</button>` : "";
8293
- return `<div class="${cls}">
8333
+ const opens = !opts.builtin && this.caps.manage && this.detailChannelId !== channel.id;
8334
+ const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}${opens ? " open" : ""}`;
8335
+ const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-menu="${esc(channel.id)}"
8336
+ aria-label="More actions for ${esc(channel.name)}" aria-haspopup="dialog">\u22EF</button>` : "";
8337
+ const rowAttrs = opens ? ` role="button" tabindex="0" data-ch-open="${esc(channel.id)}"
8338
+ aria-label="Open ${esc(channel.name)}"` : "";
8339
+ return `<div class="${cls}"${rowAttrs}>
8294
8340
  <span class="slm-ch-head">
8295
8341
  <span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
8296
8342
  <span class="slm-ch-name">${esc(channel.name)}</span>
@@ -8304,41 +8350,68 @@ var ChannelsMode = class {
8304
8350
  listRailHtml() {
8305
8351
  const list = this.list;
8306
8352
  const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
8353
+ const visible = list.channels.filter((channel) => this.showArchived || channel.state !== "archived");
8307
8354
  const rows = [
8308
8355
  this.channelRowHtml(list.publicSale, { builtin: true }),
8309
- ...list.channels.filter((channel) => this.showArchived || channel.state !== "archived").map((channel, index) => this.channelRowHtml(channel, { index }))
8356
+ ...visible.map((channel, index) => this.channelRowHtml(channel, { index }))
8310
8357
  ].join("");
8358
+ const empty = visible.length ? "" : `<p class="slm-note" data-ch-state="list-empty">${archivedCount && !this.showArchived ? `No open channels \u2014 every seat is on public sale. ${archivedCount.toLocaleString()} archived channel${archivedCount === 1 ? " is" : "s are"} hidden below.` : "No private channels yet \u2014 every seat is on public sale."}</p>`;
8311
8359
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
8312
8360
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
8313
8361
  return `
8314
- ${this.caps.manage ? this.assignmentToolsHtml() : ""}
8315
8362
  <p class="slm-eyebrow">Sales channels</p>
8316
8363
  <p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
8317
8364
  <div class="slm-ch-list">${rows}</div>
8365
+ ${empty}
8318
8366
  ${create}
8319
8367
  ${readOnly}
8368
+ ${this.assignEntryHtml()}
8320
8369
  <p class="slm-note" style="margin-top:10px">
8321
8370
  <button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
8322
8371
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
8323
8372
  </p>`;
8324
8373
  }
8374
+ /**
8375
+ * The assignment entry point on the list rail.
8376
+ *
8377
+ * Collapsed it is ONE plain-language action, so the channels the organizer came
8378
+ * for stay at the top of the panel. Opened it is the same tool set that has
8379
+ * always worked, in the same order, plus the way back out — and opening it is
8380
+ * also what the map's "Assign seats" segment does, so the two routes into the
8381
+ * job cannot disagree about whether it is running.
8382
+ */
8383
+ assignEntryHtml() {
8384
+ if (!this.caps.manage) return "";
8385
+ if (!this.assignOpen) {
8386
+ return `
8387
+ <button type="button" class="slm-btn ghost" style="width:100%;margin-top:8px"
8388
+ data-ch-act="assign-open" aria-expanded="false">Assign seats to a channel</button>
8389
+ <p class="slm-note">Move whole sections, rows, a dragged area or single seats out of public sale
8390
+ and into a channel. Nothing moves until you review and apply.</p>`;
8391
+ }
8392
+ return `<div style="margin-top:8px">${this.assignmentToolsHtml({ collapsible: true })}</div>`;
8393
+ }
8325
8394
  /**
8326
8395
  * Destination-first assignment controls.
8327
8396
  *
8328
8397
  * These used to live only inside the selection rail, which meant every route
8329
8398
  * into them was gated behind "select a seat on the map first" — the section,
8330
8399
  * 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.
8400
+ * done the work by hand. They are still reachable before a seat is selected,
8401
+ * but they are no longer the first thing in the panel: on the list rail they
8402
+ * are disclosed by `assignEntryHtml`, and there they carry a way back out
8403
+ * (`collapsible`). In the selection rail there is nothing to disclose — a
8404
+ * selection IS the intent — so they paint unconditionally and without Done.
8334
8405
  */
8335
- assignmentToolsHtml() {
8406
+ assignmentToolsHtml(opts = {}) {
8336
8407
  const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
8337
8408
  const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
8338
8409
  const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
8339
8410
  const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
8411
+ const done = opts.collapsible ? `<button type="button" class="slm-linkbtn" data-ch-act="assign-close"
8412
+ aria-expanded="true" style="float:right;font-weight:800">Done</button>` : "";
8340
8413
  return `
8341
- <p class="slm-eyebrow">Assign inventory</p>
8414
+ <p class="slm-eyebrow">${done}Assign inventory</p>
8342
8415
  <div class="slm-field">
8343
8416
  <label for="slm-ch-target">Assign to</label>
8344
8417
  <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
@@ -8388,8 +8461,16 @@ var ChannelsMode = class {
8388
8461
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
8389
8462
  }
8390
8463
  detailRailHtml(channelId) {
8464
+ const back = `<p class="slm-eyebrow">
8465
+ <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
8466
+ </p>`;
8391
8467
  const channel = this.list?.channels.find((item) => item.id === channelId);
8392
- if (!channel) return this.listRailHtml();
8468
+ if (!channel) {
8469
+ return `${back}
8470
+ <div class="slm-ch-alert warn" role="status" data-ch-state="detail-gone"><span>\u2139</span>
8471
+ <span><b>This channel is no longer on this event.</b> It was archived or removed while you had it open.
8472
+ ${this.showArchived ? "" : "Archived channels are hidden \u2014 use \u201CShow archived\u201D on the list to see it."}</span></div>`;
8473
+ }
8393
8474
  const lifecycle = this.caps.manage ? `
8394
8475
  <p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
8395
8476
  <div class="slm-ch-row2" style="margin-top:2px">
@@ -8400,9 +8481,7 @@ var ChannelsMode = class {
8400
8481
  <p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
8401
8482
  const access = this.caps.manage ? this.distributeHtml(channel) : "";
8402
8483
  return `
8403
- <p class="slm-eyebrow">
8404
- <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
8405
- </p>
8484
+ ${back}
8406
8485
  <p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
8407
8486
  <div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
8408
8487
  ${access}
@@ -8499,16 +8578,17 @@ var ChannelsMode = class {
8499
8578
  hostedLinksHtml() {
8500
8579
  const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
8501
8580
  if (this.linksState === "unsupported") {
8502
- return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
8581
+ return `${eyebrow}<div class="slm-ch-alert warn" data-ch-state="links-unsupported"><span>\u2139</span>
8503
8582
  <span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
8504
8583
  }
8505
8584
  if (this.linksState === "error") {
8506
- return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
8507
- <span><b>Couldn't load this channel's links.</b>
8585
+ return `${eyebrow}<div class="slm-ch-alert err" role="alert" data-ch-state="links-error"><span>\u26A0</span>
8586
+ <span><b>Couldn't load this channel's links.</b> This is a failed read, not an empty list \u2014
8587
+ any live link is still working.
8508
8588
  <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
8509
8589
  }
8510
- if (this.linksState === "loading" && !this.links.length) {
8511
- return `${eyebrow}<div class="slm-empty">Loading links\u2026</div>`;
8590
+ if (this.linksState === "idle" || this.linksState === "loading" && !this.links.length) {
8591
+ return `${eyebrow}<div class="slm-ch-busy" role="status" data-ch-state="links-loading">Loading buyer links\u2026</div>`;
8512
8592
  }
8513
8593
  if (!this.links.length) return "";
8514
8594
  return `${eyebrow}
@@ -8550,9 +8630,14 @@ var ChannelsMode = class {
8550
8630
  ];
8551
8631
  const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
8552
8632
  const options = audienceOptions.map((entry) => `<option value="${esc(entry.id)}"${entry.id === current ? " selected" : ""}>${esc(entry.name)}</option>`).join("");
8553
- const unsupported = this.previewSupported === false ? `<div class="slm-ch-alert warn"><span>\u2139</span>
8633
+ const unsupported = this.previewState === "unsupported" ? `<div class="slm-ch-alert warn" data-ch-state="preview-unsupported"><span>\u2139</span>
8554
8634
  <span><b>Preview needs a newer server.</b> Allocation management works normally;
8555
8635
  the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
8636
+ const busy = this.previewState === "loading" ? `<div class="slm-ch-busy" role="status" data-ch-state="preview-loading">Asking the server what this audience sees\u2026</div>` : "";
8637
+ const failed = this.previewState === "error" ? `<div class="slm-ch-alert err" role="alert" data-ch-state="preview-error"><span>\u26A0</span>
8638
+ <span><b>Couldn't load the buyer preview.</b> Nothing is wrong with this audience's seats \u2014
8639
+ the simulation itself failed to load.
8640
+ <button type="button" data-ch-act="preview-retry">Try again</button></span></div>` : "";
8556
8641
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
8557
8642
  <span><b>This private sale is not available.</b> ${esc((this.previewProjection.unavailable ?? []).map((entry) => `${this.nameOf(entry.channelId) ?? "This channel"} is ${entry.state}`).join("; ") || "The audience cannot buy right now")}.
8558
8643
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
@@ -8573,7 +8658,7 @@ var ChannelsMode = class {
8573
8658
  ${includePublic}
8574
8659
  <p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
8575
8660
  approximation. It is read-only: clicks open seat details, and no holds are created.</p>
8576
- ${unsupported}${unavailable}
8661
+ ${busy}${failed}${unsupported}${unavailable}
8577
8662
  <div class="slm-ch-legend">
8578
8663
  <div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible &amp; free \u2014 buyable by this audience</div>
8579
8664
  <div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
@@ -8582,6 +8667,7 @@ var ChannelsMode = class {
8582
8667
  ${summary}`;
8583
8668
  }
8584
8669
  paintSelection() {
8670
+ if (this.caps.manage && this.host.selectionLabels().length) this.assignOpen = true;
8585
8671
  if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
8586
8672
  }
8587
8673
  wireRail() {
@@ -8592,16 +8678,27 @@ var ChannelsMode = class {
8592
8678
  rail.querySelectorAll("[data-ch-map]").forEach((button) => {
8593
8679
  button.addEventListener("click", () => {
8594
8680
  this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
8681
+ if (this.mapIntent === "assign") this.assignOpen = true;
8595
8682
  this.paintRail();
8596
8683
  this.onInteractionChange?.();
8597
8684
  });
8598
8685
  });
8599
- rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
8600
- button.addEventListener("click", () => {
8601
- const channelId = button.dataset.chDetail;
8602
- this.detailChannelId = channelId;
8603
- this.paintRail();
8604
- void this.loadLinks(channelId).then(() => this.paintRail());
8686
+ rail.querySelectorAll("[data-ch-open]").forEach((row) => {
8687
+ const open = () => this.openChannel(row.dataset.chOpen);
8688
+ row.addEventListener("click", open);
8689
+ row.addEventListener("keydown", (event) => {
8690
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
8691
+ event.preventDefault();
8692
+ open();
8693
+ });
8694
+ });
8695
+ rail.querySelectorAll("[data-ch-menu]").forEach((button) => {
8696
+ button.addEventListener("click", (event) => {
8697
+ event.stopPropagation();
8698
+ this.openDialog({ kind: "menu", channelId: button.dataset.chMenu });
8699
+ });
8700
+ button.addEventListener("keydown", (event) => {
8701
+ event.stopPropagation();
8605
8702
  });
8606
8703
  });
8607
8704
  rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
@@ -8640,6 +8737,12 @@ var ChannelsMode = class {
8640
8737
  button.addEventListener("click", () => this.railAction(button.dataset.chAct));
8641
8738
  });
8642
8739
  }
8740
+ /** Open a channel's detail panel and start its buyer-link read. */
8741
+ openChannel(channelId) {
8742
+ this.detailChannelId = channelId;
8743
+ this.paintRail();
8744
+ void this.loadLinks(channelId).then(() => this.paintRail());
8745
+ }
8643
8746
  railAction(action) {
8644
8747
  switch (action) {
8645
8748
  case "sections":
@@ -8713,6 +8816,22 @@ var ChannelsMode = class {
8713
8816
  case "pick-category":
8714
8817
  this.pickCategory();
8715
8818
  break;
8819
+ case "assign-open":
8820
+ this.assignOpen = true;
8821
+ this.paintRail();
8822
+ break;
8823
+ // Collapsing the tools must also disarm the marquee. Leaving it armed
8824
+ // would hide a mode the map is still in — the organizer would drag to pan
8825
+ // and select seats instead, with no control on screen saying why.
8826
+ case "assign-close":
8827
+ this.assignOpen = false;
8828
+ this.mapIntent = "pan";
8829
+ this.paintRail();
8830
+ this.onInteractionChange?.();
8831
+ break;
8832
+ case "preview-retry":
8833
+ void this.loadPreview();
8834
+ break;
8716
8835
  default:
8717
8836
  break;
8718
8837
  }
@@ -8862,7 +8981,8 @@ var ChannelsMode = class {
8862
8981
  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>`);
8863
8982
  }
8864
8983
  });
8865
- list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div>`;
8984
+ list.innerHTML = blocks.join("") || (query ? `<div class="slm-ch-scope-empty" data-ch-state="scope-nomatch">No sections or rows match \u201C${esc(query)}\u201D.</div>` : `<div class="slm-ch-scope-empty" data-ch-state="scope-empty">This chart has no rows with selectable seats.
8985
+ Use Drag box or the seat list instead.</div>`);
8866
8986
  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.";
8867
8987
  list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
8868
8988
  const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
@@ -8912,7 +9032,8 @@ var ChannelsMode = class {
8912
9032
  this.renderScrim(`
8913
9033
  <h3 id="slm-ch-dlg-title">Add whole sections</h3>
8914
9034
  <p class="sub">Choose one or more. They are added to the seats already selected on the map.</p>
8915
- <div class="slm-ch-seatlist">${body || `<div class="slm-empty">No sections are available.</div>`}</div>
9035
+ <div class="slm-ch-seatlist">${body || `<div class="slm-ch-scope-empty" data-ch-state="scope-empty">This chart has no sections with selectable seats.
9036
+ Use Drag box or the seat list instead.</div>`}</div>
8916
9037
  <p class="slm-ch-err" data-ch-error hidden></p>
8917
9038
  <div class="foot">
8918
9039
  <button type="button" class="quiet" data-ch-close>Cancel</button>
@@ -8969,6 +9090,7 @@ var ChannelsMode = class {
8969
9090
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8970
9091
  else if (state.kind === "rename") this.renderRenameDialog(state);
8971
9092
  else if (state.kind === "seatlist") this.renderSeatListDialog();
9093
+ else if (state.kind === "menu") this.renderMenuDialog(state);
8972
9094
  else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
8973
9095
  else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
8974
9096
  else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
@@ -9224,6 +9346,59 @@ var ChannelsMode = class {
9224
9346
  void bar.offsetWidth;
9225
9347
  bar.classList.add("shake");
9226
9348
  }
9349
+ /**
9350
+ * The ⋯ menu.
9351
+ *
9352
+ * Opening a channel is the ROW's job now, so ⋯ carries what is left: the
9353
+ * secondary and destructive lifecycle actions. It is rendered through the same
9354
+ * scrim primitive as every other sheet, which is what gives it a focus trap,
9355
+ * Escape, and a name — a bare absolutely-positioned popup would have had none
9356
+ * of those. Archive keeps its own confirmation dialog; this menu never
9357
+ * destroys anything by itself.
9358
+ */
9359
+ renderMenuDialog(state) {
9360
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
9361
+ if (!channel || !this.caps.manage) {
9362
+ this.closeDialog();
9363
+ return;
9364
+ }
9365
+ const paused = channel.state === "paused";
9366
+ this.renderScrim(`
9367
+ <h3 id="slm-ch-dlg-title">${esc(channel.name)}</h3>
9368
+ <p class="sub">Open the channel to allocate seats and hand out buyer access.
9369
+ These are the rest of its actions.</p>
9370
+ <div class="slm-ch-menu">
9371
+ <button type="button" class="slm-btn" data-ch-menu-act="open">Open channel</button>
9372
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="rename">Rename</button>
9373
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="pause">${paused ? "Resume selling" : "Pause selling"}</button>
9374
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="archive">Archive\u2026</button>
9375
+ </div>
9376
+ <p class="slm-note">Pausing stops new buyer access; checkouts already running can finish.
9377
+ Archiving closes the channel for good and returns its free seats to a destination you choose.
9378
+ Nothing is ever deleted silently.</p>
9379
+ <div class="foot"><button type="button" class="quiet" data-ch-close>Cancel</button></div>`, (dialog) => {
9380
+ dialog.querySelectorAll("[data-ch-menu-act]").forEach((button) => {
9381
+ button.addEventListener("click", () => {
9382
+ const act = button.dataset.chMenuAct;
9383
+ if (act === "open") {
9384
+ this.closeDialog();
9385
+ this.openChannel(channel.id);
9386
+ return;
9387
+ }
9388
+ if (act === "rename") {
9389
+ this.openDialog({ kind: "rename", channelId: channel.id });
9390
+ return;
9391
+ }
9392
+ if (act === "archive") {
9393
+ this.openDialog({ kind: "archive", channelId: channel.id });
9394
+ return;
9395
+ }
9396
+ this.closeDialog();
9397
+ void this.togglePause(channel.id);
9398
+ });
9399
+ });
9400
+ });
9401
+ }
9227
9402
  renderRenameDialog(state) {
9228
9403
  const channel = this.list?.channels.find((item) => item.id === state.channelId);
9229
9404
  if (!channel) {
@@ -9284,9 +9459,11 @@ var ChannelsMode = class {
9284
9459
  this.host.onError(err);
9285
9460
  }
9286
9461
  }
9287
- async togglePause() {
9288
- const channel = this.list?.channels.find((item) => item.id === this.detailChannelId);
9289
- if (!channel) return;
9462
+ /** `channelId` is explicit because this is reachable from the ⋯ menu on a row
9463
+ * that is NOT the open channel, as well as from the detail panel itself. */
9464
+ async togglePause(channelId = this.detailChannelId) {
9465
+ const channel = this.list?.channels.find((item) => item.id === channelId);
9466
+ if (!channel || !this.caps.manage) return;
9290
9467
  const paused = channel.state !== "paused";
9291
9468
  try {
9292
9469
  await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);