@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.js CHANGED
@@ -6976,7 +6976,23 @@ var CHANNELS_CSS = (
6976
6976
  .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6977
6977
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
6978
6978
  .slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
6979
- .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px}
6979
+ /* A row that opens its channel must READ as pressable, and be one for a keyboard
6980
+ too. It is a role="button" div rather than a <button> because the \u22EF control
6981
+ lives inside it, and a button inside a button is not valid HTML. */
6982
+ .slm-ch-row.open{cursor:pointer}
6983
+ .slm-ch-row.open:hover{border-color:var(--slm-accent);background:color-mix(in srgb,var(--slm-accent) 6%,var(--slm-surface))}
6984
+ .slm-ch-row.open:focus-visible{outline:2px solid var(--slm-accent);outline-offset:2px}
6985
+ .slm-ch-row.open:active{border-color:var(--slm-accent)}
6986
+ .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px;border-radius:6px}
6987
+ .slm-ch-more:hover{color:var(--slm-text)}
6988
+ .slm-ch-more:focus-visible{outline:2px solid var(--slm-accent);outline-offset:1px;color:var(--slm-text)}
6989
+ .slm-ch-menu{display:flex;flex-direction:column;gap:8px}
6990
+ .slm-ch-menu .slm-btn{width:100%}
6991
+ /* loading is a state, never a flash of empty: same slot, visibly working */
6992
+ .slm-ch-busy{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--slm-muted);padding:12px 0}
6993
+ .slm-ch-busy::before{content:"";width:9px;height:9px;border-radius:50%;background:var(--slm-accent);flex:none;
6994
+ animation:slm-ch-pulse var(--slm-mo-ambient) var(--slm-mo-in-out) infinite}
6995
+ @keyframes slm-ch-pulse{0%,100%{opacity:.25;transform:scale(.7)}50%{opacity:1;transform:scale(1)}}
6980
6996
  .slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
6981
6997
  .slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
6982
6998
  .slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
@@ -7108,6 +7124,7 @@ var CHANNELS_CSS = (
7108
7124
  .slm-ch-meter i,.slm-ch-radio{transition:none!important}
7109
7125
  .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
7110
7126
  .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
7127
+ .slm-ch-busy::before{animation:none!important;opacity:1}
7111
7128
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
7112
7129
  .slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
7113
7130
  }
@@ -7168,6 +7185,18 @@ var ChannelsMode = class {
7168
7185
  this.focusedSectionId = null;
7169
7186
  this.showArchived = false;
7170
7187
  this.detailChannelId = null;
7188
+ /**
7189
+ * Whether the organizer has asked to assign seats.
7190
+ *
7191
+ * The list rail leads with the CHANNELS. The assignment tooling — destination
7192
+ * picker plus five select-by routes — used to paint unconditionally above that
7193
+ * list, so a first-time organizer met a workbench for a job they had not asked
7194
+ * to do, with the thing they came for pushed below the fold. It is now
7195
+ * disclosed on intent: the "Assign seats to a channel" action under the list,
7196
+ * the map's own "Assign seats" segment, or simply having a selection. The
7197
+ * selection rail always shows the tools, because there the intent is proven.
7198
+ */
7199
+ this.assignOpen = false;
7171
7200
  this.targetChannelId = "";
7172
7201
  this.conflict = false;
7173
7202
  this.dialog = null;
@@ -7194,8 +7223,16 @@ var ChannelsMode = class {
7194
7223
  this.previewAudience = [];
7195
7224
  this.previewIncludePublic = false;
7196
7225
  this.previewProjection = null;
7197
- this.previewSupported = null;
7198
- // null = not yet probed
7226
+ /**
7227
+ * The buyer-preview read, as one honest state rather than a boolean.
7228
+ *
7229
+ * `previewSupported: boolean | null` could say "this worker has no projection
7230
+ * route", but it could not say "the read is in flight" or "the read failed" —
7231
+ * both of those painted an audience picker with no result underneath, which
7232
+ * reads as "this audience can buy nothing". Loading and error are now states
7233
+ * of their own, and the error one offers a retry.
7234
+ */
7235
+ this.previewState = "idle";
7199
7236
  this.pollTimer = null;
7200
7237
  this.layer = null;
7201
7238
  this.canvas = null;
@@ -7233,6 +7270,7 @@ var ChannelsMode = class {
7233
7270
  if (this.active) return;
7234
7271
  this.active = true;
7235
7272
  this.mapIntent = "pan";
7273
+ this.assignOpen = false;
7236
7274
  this.focusedSectionId = null;
7237
7275
  this.assignmentRowsCache = null;
7238
7276
  this.railHtml = null;
@@ -7742,6 +7780,7 @@ var ChannelsMode = class {
7742
7780
  this.view = view;
7743
7781
  if (view === "inspect") {
7744
7782
  this.previewProjection = null;
7783
+ this.previewState = "idle";
7745
7784
  this.setBanner(false);
7746
7785
  } else {
7747
7786
  if (!this.previewAudience.length) {
@@ -7759,6 +7798,9 @@ var ChannelsMode = class {
7759
7798
  const audience = [...this.previewAudience];
7760
7799
  const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
7761
7800
  this.setBanner(true, names);
7801
+ this.previewProjection = null;
7802
+ this.previewState = "loading";
7803
+ if (this.active) this.paintRail();
7762
7804
  try {
7763
7805
  this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
7764
7806
  // Naming Public sale as the audience IS asking for public inventory; the
@@ -7766,14 +7808,14 @@ var ChannelsMode = class {
7766
7808
  // this the request would resolve to an empty scope and 422.
7767
7809
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
7768
7810
  });
7769
- this.previewSupported = true;
7811
+ this.previewState = "ready";
7770
7812
  const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
7771
7813
  this.setBanner(true, names, eligibleSeats);
7772
7814
  } catch (err) {
7773
7815
  const status = err instanceof ManageApiError ? err.status : 0;
7774
- this.previewSupported = !(status === 404 || status === 405 || status === 501);
7816
+ this.previewState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
7775
7817
  this.previewProjection = null;
7776
- if (this.previewSupported) this.host.onError(err);
7818
+ if (this.previewState === "error") this.host.onError(err);
7777
7819
  }
7778
7820
  if (this.active) {
7779
7821
  this.paintRail();
@@ -7814,13 +7856,14 @@ var ChannelsMode = class {
7814
7856
  }
7815
7857
  if (this.loading && !this.list) {
7816
7858
  this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
7817
- <div class="slm-empty">Loading allocations\u2026</div>`);
7859
+ <div class="slm-ch-busy" role="status" data-ch-state="list-loading">Loading channels and allocations\u2026</div>`);
7818
7860
  return;
7819
7861
  }
7820
7862
  if (!this.list && this.loadError) {
7821
7863
  if (this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
7822
- <div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7823
- <span><b>Couldn't load sales channels.</b> Everything else on this event still works.
7864
+ <div class="slm-ch-alert err" role="alert" data-ch-state="list-error"><span>\u26A0</span>
7865
+ <span><b>Couldn't load sales channels.</b> This event may well have channels \u2014 we could not read them.
7866
+ Everything else on this event still works.
7824
7867
  <button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
7825
7868
  rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
7826
7869
  void this.refresh();
@@ -7879,10 +7922,13 @@ var ChannelsMode = class {
7879
7922
  const marker = this.markerFor(channel.id);
7880
7923
  const badgeKind = opts.builtin ? "builtin" : channel.state;
7881
7924
  const dim = channel.state === "paused" || channel.state === "archived" ? " dim" : "";
7882
- const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}`;
7883
- const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-detail="${esc(channel.id)}"
7884
- aria-label="Manage ${esc(channel.name)}">\u22EF</button>` : "";
7885
- return `<div class="${cls}">
7925
+ const opens = !opts.builtin && this.caps.manage && this.detailChannelId !== channel.id;
7926
+ const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}${opens ? " open" : ""}`;
7927
+ const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-menu="${esc(channel.id)}"
7928
+ aria-label="More actions for ${esc(channel.name)}" aria-haspopup="dialog">\u22EF</button>` : "";
7929
+ const rowAttrs = opens ? ` role="button" tabindex="0" data-ch-open="${esc(channel.id)}"
7930
+ aria-label="Open ${esc(channel.name)}"` : "";
7931
+ return `<div class="${cls}"${rowAttrs}>
7886
7932
  <span class="slm-ch-head">
7887
7933
  <span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
7888
7934
  <span class="slm-ch-name">${esc(channel.name)}</span>
@@ -7896,41 +7942,68 @@ var ChannelsMode = class {
7896
7942
  listRailHtml() {
7897
7943
  const list = this.list;
7898
7944
  const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
7945
+ const visible = list.channels.filter((channel) => this.showArchived || channel.state !== "archived");
7899
7946
  const rows = [
7900
7947
  this.channelRowHtml(list.publicSale, { builtin: true }),
7901
- ...list.channels.filter((channel) => this.showArchived || channel.state !== "archived").map((channel, index) => this.channelRowHtml(channel, { index }))
7948
+ ...visible.map((channel, index) => this.channelRowHtml(channel, { index }))
7902
7949
  ].join("");
7950
+ 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>`;
7903
7951
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
7904
7952
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
7905
7953
  return `
7906
- ${this.caps.manage ? this.assignmentToolsHtml() : ""}
7907
7954
  <p class="slm-eyebrow">Sales channels</p>
7908
7955
  <p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
7909
7956
  <div class="slm-ch-list">${rows}</div>
7957
+ ${empty}
7910
7958
  ${create}
7911
7959
  ${readOnly}
7960
+ ${this.assignEntryHtml()}
7912
7961
  <p class="slm-note" style="margin-top:10px">
7913
7962
  <button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
7914
7963
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
7915
7964
  </p>`;
7916
7965
  }
7966
+ /**
7967
+ * The assignment entry point on the list rail.
7968
+ *
7969
+ * Collapsed it is ONE plain-language action, so the channels the organizer came
7970
+ * for stay at the top of the panel. Opened it is the same tool set that has
7971
+ * always worked, in the same order, plus the way back out — and opening it is
7972
+ * also what the map's "Assign seats" segment does, so the two routes into the
7973
+ * job cannot disagree about whether it is running.
7974
+ */
7975
+ assignEntryHtml() {
7976
+ if (!this.caps.manage) return "";
7977
+ if (!this.assignOpen) {
7978
+ return `
7979
+ <button type="button" class="slm-btn ghost" style="width:100%;margin-top:8px"
7980
+ data-ch-act="assign-open" aria-expanded="false">Assign seats to a channel</button>
7981
+ <p class="slm-note">Move whole sections, rows, a dragged area or single seats out of public sale
7982
+ and into a channel. Nothing moves until you review and apply.</p>`;
7983
+ }
7984
+ return `<div style="margin-top:8px">${this.assignmentToolsHtml({ collapsible: true })}</div>`;
7985
+ }
7917
7986
  /**
7918
7987
  * Destination-first assignment controls.
7919
7988
  *
7920
7989
  * These used to live only inside the selection rail, which meant every route
7921
7990
  * into them was gated behind "select a seat on the map first" — the section,
7922
7991
  * 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.
7992
+ * done the work by hand. They are still reachable before a seat is selected,
7993
+ * but they are no longer the first thing in the panel: on the list rail they
7994
+ * are disclosed by `assignEntryHtml`, and there they carry a way back out
7995
+ * (`collapsible`). In the selection rail there is nothing to disclose — a
7996
+ * selection IS the intent — so they paint unconditionally and without Done.
7926
7997
  */
7927
- assignmentToolsHtml() {
7998
+ assignmentToolsHtml(opts = {}) {
7928
7999
  const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
7929
8000
  const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
7930
8001
  const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
7931
8002
  const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
8003
+ const done = opts.collapsible ? `<button type="button" class="slm-linkbtn" data-ch-act="assign-close"
8004
+ aria-expanded="true" style="float:right;font-weight:800">Done</button>` : "";
7932
8005
  return `
7933
- <p class="slm-eyebrow">Assign inventory</p>
8006
+ <p class="slm-eyebrow">${done}Assign inventory</p>
7934
8007
  <div class="slm-field">
7935
8008
  <label for="slm-ch-target">Assign to</label>
7936
8009
  <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
@@ -7980,8 +8053,16 @@ var ChannelsMode = class {
7980
8053
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
7981
8054
  }
7982
8055
  detailRailHtml(channelId) {
8056
+ const back = `<p class="slm-eyebrow">
8057
+ <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
8058
+ </p>`;
7983
8059
  const channel = this.list?.channels.find((item) => item.id === channelId);
7984
- if (!channel) return this.listRailHtml();
8060
+ if (!channel) {
8061
+ return `${back}
8062
+ <div class="slm-ch-alert warn" role="status" data-ch-state="detail-gone"><span>\u2139</span>
8063
+ <span><b>This channel is no longer on this event.</b> It was archived or removed while you had it open.
8064
+ ${this.showArchived ? "" : "Archived channels are hidden \u2014 use \u201CShow archived\u201D on the list to see it."}</span></div>`;
8065
+ }
7985
8066
  const lifecycle = this.caps.manage ? `
7986
8067
  <p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
7987
8068
  <div class="slm-ch-row2" style="margin-top:2px">
@@ -7992,9 +8073,7 @@ var ChannelsMode = class {
7992
8073
  <p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
7993
8074
  const access = this.caps.manage ? this.distributeHtml(channel) : "";
7994
8075
  return `
7995
- <p class="slm-eyebrow">
7996
- <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
7997
- </p>
8076
+ ${back}
7998
8077
  <p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
7999
8078
  <div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
8000
8079
  ${access}
@@ -8091,16 +8170,17 @@ var ChannelsMode = class {
8091
8170
  hostedLinksHtml() {
8092
8171
  const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
8093
8172
  if (this.linksState === "unsupported") {
8094
- return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
8173
+ return `${eyebrow}<div class="slm-ch-alert warn" data-ch-state="links-unsupported"><span>\u2139</span>
8095
8174
  <span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
8096
8175
  }
8097
8176
  if (this.linksState === "error") {
8098
- return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
8099
- <span><b>Couldn't load this channel's links.</b>
8177
+ return `${eyebrow}<div class="slm-ch-alert err" role="alert" data-ch-state="links-error"><span>\u26A0</span>
8178
+ <span><b>Couldn't load this channel's links.</b> This is a failed read, not an empty list \u2014
8179
+ any live link is still working.
8100
8180
  <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
8101
8181
  }
8102
- if (this.linksState === "loading" && !this.links.length) {
8103
- return `${eyebrow}<div class="slm-empty">Loading links\u2026</div>`;
8182
+ if (this.linksState === "idle" || this.linksState === "loading" && !this.links.length) {
8183
+ return `${eyebrow}<div class="slm-ch-busy" role="status" data-ch-state="links-loading">Loading buyer links\u2026</div>`;
8104
8184
  }
8105
8185
  if (!this.links.length) return "";
8106
8186
  return `${eyebrow}
@@ -8142,9 +8222,14 @@ var ChannelsMode = class {
8142
8222
  ];
8143
8223
  const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
8144
8224
  const options = audienceOptions.map((entry) => `<option value="${esc(entry.id)}"${entry.id === current ? " selected" : ""}>${esc(entry.name)}</option>`).join("");
8145
- const unsupported = this.previewSupported === false ? `<div class="slm-ch-alert warn"><span>\u2139</span>
8225
+ const unsupported = this.previewState === "unsupported" ? `<div class="slm-ch-alert warn" data-ch-state="preview-unsupported"><span>\u2139</span>
8146
8226
  <span><b>Preview needs a newer server.</b> Allocation management works normally;
8147
8227
  the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
8228
+ 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>` : "";
8229
+ const failed = this.previewState === "error" ? `<div class="slm-ch-alert err" role="alert" data-ch-state="preview-error"><span>\u26A0</span>
8230
+ <span><b>Couldn't load the buyer preview.</b> Nothing is wrong with this audience's seats \u2014
8231
+ the simulation itself failed to load.
8232
+ <button type="button" data-ch-act="preview-retry">Try again</button></span></div>` : "";
8148
8233
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
8149
8234
  <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")}.
8150
8235
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
@@ -8165,7 +8250,7 @@ var ChannelsMode = class {
8165
8250
  ${includePublic}
8166
8251
  <p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
8167
8252
  approximation. It is read-only: clicks open seat details, and no holds are created.</p>
8168
- ${unsupported}${unavailable}
8253
+ ${busy}${failed}${unsupported}${unavailable}
8169
8254
  <div class="slm-ch-legend">
8170
8255
  <div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible &amp; free \u2014 buyable by this audience</div>
8171
8256
  <div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
@@ -8174,6 +8259,7 @@ var ChannelsMode = class {
8174
8259
  ${summary}`;
8175
8260
  }
8176
8261
  paintSelection() {
8262
+ if (this.caps.manage && this.host.selectionLabels().length) this.assignOpen = true;
8177
8263
  if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
8178
8264
  }
8179
8265
  wireRail() {
@@ -8184,16 +8270,27 @@ var ChannelsMode = class {
8184
8270
  rail.querySelectorAll("[data-ch-map]").forEach((button) => {
8185
8271
  button.addEventListener("click", () => {
8186
8272
  this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
8273
+ if (this.mapIntent === "assign") this.assignOpen = true;
8187
8274
  this.paintRail();
8188
8275
  this.onInteractionChange?.();
8189
8276
  });
8190
8277
  });
8191
- rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
8192
- button.addEventListener("click", () => {
8193
- const channelId = button.dataset.chDetail;
8194
- this.detailChannelId = channelId;
8195
- this.paintRail();
8196
- void this.loadLinks(channelId).then(() => this.paintRail());
8278
+ rail.querySelectorAll("[data-ch-open]").forEach((row) => {
8279
+ const open = () => this.openChannel(row.dataset.chOpen);
8280
+ row.addEventListener("click", open);
8281
+ row.addEventListener("keydown", (event) => {
8282
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
8283
+ event.preventDefault();
8284
+ open();
8285
+ });
8286
+ });
8287
+ rail.querySelectorAll("[data-ch-menu]").forEach((button) => {
8288
+ button.addEventListener("click", (event) => {
8289
+ event.stopPropagation();
8290
+ this.openDialog({ kind: "menu", channelId: button.dataset.chMenu });
8291
+ });
8292
+ button.addEventListener("keydown", (event) => {
8293
+ event.stopPropagation();
8197
8294
  });
8198
8295
  });
8199
8296
  rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
@@ -8232,6 +8329,12 @@ var ChannelsMode = class {
8232
8329
  button.addEventListener("click", () => this.railAction(button.dataset.chAct));
8233
8330
  });
8234
8331
  }
8332
+ /** Open a channel's detail panel and start its buyer-link read. */
8333
+ openChannel(channelId) {
8334
+ this.detailChannelId = channelId;
8335
+ this.paintRail();
8336
+ void this.loadLinks(channelId).then(() => this.paintRail());
8337
+ }
8235
8338
  railAction(action) {
8236
8339
  switch (action) {
8237
8340
  case "sections":
@@ -8305,6 +8408,22 @@ var ChannelsMode = class {
8305
8408
  case "pick-category":
8306
8409
  this.pickCategory();
8307
8410
  break;
8411
+ case "assign-open":
8412
+ this.assignOpen = true;
8413
+ this.paintRail();
8414
+ break;
8415
+ // Collapsing the tools must also disarm the marquee. Leaving it armed
8416
+ // would hide a mode the map is still in — the organizer would drag to pan
8417
+ // and select seats instead, with no control on screen saying why.
8418
+ case "assign-close":
8419
+ this.assignOpen = false;
8420
+ this.mapIntent = "pan";
8421
+ this.paintRail();
8422
+ this.onInteractionChange?.();
8423
+ break;
8424
+ case "preview-retry":
8425
+ void this.loadPreview();
8426
+ break;
8308
8427
  default:
8309
8428
  break;
8310
8429
  }
@@ -8454,7 +8573,8 @@ var ChannelsMode = class {
8454
8573
  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>`);
8455
8574
  }
8456
8575
  });
8457
- list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div>`;
8576
+ 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.
8577
+ Use Drag box or the seat list instead.</div>`);
8458
8578
  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.";
8459
8579
  list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
8460
8580
  const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
@@ -8504,7 +8624,8 @@ var ChannelsMode = class {
8504
8624
  this.renderScrim(`
8505
8625
  <h3 id="slm-ch-dlg-title">Add whole sections</h3>
8506
8626
  <p class="sub">Choose one or more. They are added to the seats already selected on the map.</p>
8507
- <div class="slm-ch-seatlist">${body || `<div class="slm-empty">No sections are available.</div>`}</div>
8627
+ <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.
8628
+ Use Drag box or the seat list instead.</div>`}</div>
8508
8629
  <p class="slm-ch-err" data-ch-error hidden></p>
8509
8630
  <div class="foot">
8510
8631
  <button type="button" class="quiet" data-ch-close>Cancel</button>
@@ -8561,6 +8682,7 @@ var ChannelsMode = class {
8561
8682
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8562
8683
  else if (state.kind === "rename") this.renderRenameDialog(state);
8563
8684
  else if (state.kind === "seatlist") this.renderSeatListDialog();
8685
+ else if (state.kind === "menu") this.renderMenuDialog(state);
8564
8686
  else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
8565
8687
  else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
8566
8688
  else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
@@ -8816,6 +8938,59 @@ var ChannelsMode = class {
8816
8938
  void bar.offsetWidth;
8817
8939
  bar.classList.add("shake");
8818
8940
  }
8941
+ /**
8942
+ * The ⋯ menu.
8943
+ *
8944
+ * Opening a channel is the ROW's job now, so ⋯ carries what is left: the
8945
+ * secondary and destructive lifecycle actions. It is rendered through the same
8946
+ * scrim primitive as every other sheet, which is what gives it a focus trap,
8947
+ * Escape, and a name — a bare absolutely-positioned popup would have had none
8948
+ * of those. Archive keeps its own confirmation dialog; this menu never
8949
+ * destroys anything by itself.
8950
+ */
8951
+ renderMenuDialog(state) {
8952
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
8953
+ if (!channel || !this.caps.manage) {
8954
+ this.closeDialog();
8955
+ return;
8956
+ }
8957
+ const paused = channel.state === "paused";
8958
+ this.renderScrim(`
8959
+ <h3 id="slm-ch-dlg-title">${esc(channel.name)}</h3>
8960
+ <p class="sub">Open the channel to allocate seats and hand out buyer access.
8961
+ These are the rest of its actions.</p>
8962
+ <div class="slm-ch-menu">
8963
+ <button type="button" class="slm-btn" data-ch-menu-act="open">Open channel</button>
8964
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="rename">Rename</button>
8965
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="pause">${paused ? "Resume selling" : "Pause selling"}</button>
8966
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="archive">Archive\u2026</button>
8967
+ </div>
8968
+ <p class="slm-note">Pausing stops new buyer access; checkouts already running can finish.
8969
+ Archiving closes the channel for good and returns its free seats to a destination you choose.
8970
+ Nothing is ever deleted silently.</p>
8971
+ <div class="foot"><button type="button" class="quiet" data-ch-close>Cancel</button></div>`, (dialog) => {
8972
+ dialog.querySelectorAll("[data-ch-menu-act]").forEach((button) => {
8973
+ button.addEventListener("click", () => {
8974
+ const act = button.dataset.chMenuAct;
8975
+ if (act === "open") {
8976
+ this.closeDialog();
8977
+ this.openChannel(channel.id);
8978
+ return;
8979
+ }
8980
+ if (act === "rename") {
8981
+ this.openDialog({ kind: "rename", channelId: channel.id });
8982
+ return;
8983
+ }
8984
+ if (act === "archive") {
8985
+ this.openDialog({ kind: "archive", channelId: channel.id });
8986
+ return;
8987
+ }
8988
+ this.closeDialog();
8989
+ void this.togglePause(channel.id);
8990
+ });
8991
+ });
8992
+ });
8993
+ }
8819
8994
  renderRenameDialog(state) {
8820
8995
  const channel = this.list?.channels.find((item) => item.id === state.channelId);
8821
8996
  if (!channel) {
@@ -8876,9 +9051,11 @@ var ChannelsMode = class {
8876
9051
  this.host.onError(err);
8877
9052
  }
8878
9053
  }
8879
- async togglePause() {
8880
- const channel = this.list?.channels.find((item) => item.id === this.detailChannelId);
8881
- if (!channel) return;
9054
+ /** `channelId` is explicit because this is reachable from the ⋯ menu on a row
9055
+ * that is NOT the open channel, as well as from the detail panel itself. */
9056
+ async togglePause(channelId = this.detailChannelId) {
9057
+ const channel = this.list?.channels.find((item) => item.id === channelId);
9058
+ if (!channel || !this.caps.manage) return;
8882
9059
  const paused = channel.state !== "paused";
8883
9060
  try {
8884
9061
  await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);