@seatlayer/js 0.43.2 → 0.45.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
@@ -6493,13 +6493,59 @@ function retryAfterCopy(details) {
6493
6493
  }
6494
6494
  function accessLine(access) {
6495
6495
  if (!access || !access.intent) return "\u2014";
6496
- const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : "Not distributed yet";
6496
+ const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : access.intent === "internal" ? "Your staff sell these" : "Protected reserve";
6497
6497
  const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
6498
6498
  const detail = access.detail ?? grants;
6499
6499
  return detail ? `${base} \xB7 ${detail}` : base;
6500
6500
  }
6501
6501
  function accessIntentLabel(intent) {
6502
- return intent === "internal" ? "Internal selling \u2014 our own staff sell these" : intent === "server" ? "Server integration \u2014 our backend lets buyers in" : intent === "hosted_link" ? "Hosted access link \u2014 SeatLayer issues the link" : "No buyer access yet \u2014 the allocation is just protected";
6502
+ return intent === "internal" ? "Sell through your own staff" : intent === "server" ? "Integrate a website or app" : intent === "hosted_link" ? "Sell with a buyer link" : "Keep as protected reserve";
6503
+ }
6504
+ function accessIntentDescription(intent) {
6505
+ switch (intent) {
6506
+ case "internal":
6507
+ return "Only your own box office can sell these seats, through your secret key. Buyer links and website integrations are refused.";
6508
+ case "server":
6509
+ return "Your website's backend mints each buyer a short-lived session for these seats. Buyer links are refused; the code lives on the Embed page.";
6510
+ case "hosted_link":
6511
+ return "SeatLayer makes a link you send to a named group. They open it and buy only these seats. No other route can sell them.";
6512
+ default:
6513
+ return "Nobody can buy these seats. Every way of letting a buyer in \u2014 a buyer link, your website, even your own staff \u2014 is refused while this is the route. The seats stay out of public sale.";
6514
+ }
6515
+ }
6516
+ function intentForRoute(route) {
6517
+ return route === "hosted_link" ? "hosted_link" : route === "server" ? "server" : route === "staff" ? "internal" : null;
6518
+ }
6519
+ function intentForbidsCopy(details) {
6520
+ const current = parseIntent(details?.accessIntent);
6521
+ const wanted = intentForRoute(details?.route);
6522
+ const head = `This channel is set to "${accessIntentLabel(current)}"`;
6523
+ return wanted ? `${head}, so it cannot do that. Switch it to "${accessIntentLabel(wanted)}" first.` : `${head}, so it cannot do that. Choose a different route for this channel first.`;
6524
+ }
6525
+ function parseIntent(value) {
6526
+ return value === "internal" || value === "server" || value === "hosted_link" || value === "none" ? value : "none";
6527
+ }
6528
+ function plural(count, one, many) {
6529
+ return `${count.toLocaleString()} ${count === 1 ? one : many}`;
6530
+ }
6531
+ function intentSwitchBlockedCopy(details) {
6532
+ const links = Math.max(0, details?.liveAccessLinks ?? 0);
6533
+ const sessions = Math.max(0, details?.activeSessions ?? 0);
6534
+ const from = parseIntent(details?.from);
6535
+ const to = parseIntent(details?.to);
6536
+ const live = [
6537
+ links ? plural(links, "buyer link is live", "buyer links are live") : null,
6538
+ sessions ? plural(sessions, "buyer is in a checkout", "buyers are in a checkout") : null
6539
+ ].filter(Boolean).join(", and ");
6540
+ const headline = `${live || "Buyers are inside this channel"} on "${accessIntentLabel(from)}". Moving it to "${accessIntentLabel(to)}" changes what happens to them.`;
6541
+ const consequences = [];
6542
+ if (links) {
6543
+ consequences.push(`${plural(links, "buyer link closes", "buyer links close")} immediately. Anyone who has not opened it yet never will \u2014 send a new link if you still need one.`);
6544
+ }
6545
+ if (sessions) {
6546
+ consequences.push(`${plural(sessions, "buyer who is already in a checkout keeps", "buyers who are already in a checkout keep")} their seats and can finish paying. Nobody is thrown out. No new buyers come in this way, so the old route empties on its own within 12 hours.`);
6547
+ }
6548
+ return { headline, consequences };
6503
6549
  }
6504
6550
  var ACCESS_LINK_DEFAULTS = {
6505
6551
  maxRedemptions: 100,
@@ -6564,6 +6610,11 @@ function accessLinkErrorCopy(err) {
6564
6610
  return "That link is no longer active, so it cannot be rotated or revoked.";
6565
6611
  case "channel_unavailable":
6566
6612
  return "This channel is paused or archived, so it cannot let new buyers in. Resume it first.";
6613
+ // The channel declares a different sale route. The UI declares `hosted_link`
6614
+ // before it creates, so reaching this means the declaration itself was
6615
+ // refused or raced — say which route is in the way, not the code.
6616
+ case "channel_access_intent_forbids":
6617
+ return intentForbidsCopy(err?.details);
6567
6618
  case "end_active_sessions_required":
6568
6619
  return "Choose what happens to the buyers who already came in through this link.";
6569
6620
  case "not_found":
@@ -6797,13 +6848,31 @@ var ManageApi = class {
6797
6848
  const qs = params.toString();
6798
6849
  return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/preview${qs ? `?${qs}` : ""}`);
6799
6850
  }
6800
- /** Declare how buyers are meant to reach this channel. Drives the rail's
6801
- * access line and turns "No buyer access configured" from information into a
6802
- * warning when the organizer says the channel is for buyer self-service. */
6803
- setChannelAccessIntent(key, channelId, accessIntent) {
6851
+ /**
6852
+ * Choose which sale route this channel opens.
6853
+ *
6854
+ * Since the server's 2026-08-06 change this is AUTHORIZATION, not a label:
6855
+ * exactly one of the four routes may mint buyer access for the channel and the
6856
+ * other three refuse with 409 `channel_access_intent_forbids`. The default is
6857
+ * `none`, which refuses all four — so a route has to be declared before any
6858
+ * buyer-facing action on the channel can succeed.
6859
+ *
6860
+ * Switching the route while buyers are already inside the current one is
6861
+ * refused with 409 `channel_intent_switch_blocked`, whose `details` name what
6862
+ * is live (`liveAccessLinks`, `activeSessions`). Retry with
6863
+ * `acknowledgeLiveAccess: true`: hosted links on the channel are revoked,
6864
+ * while sessions already minted keep their holds and drain on their own.
6865
+ * `intentSwitch` is present on the response ONLY when the switch disturbed
6866
+ * something, so the ordinary case stays the two-key body it has always been.
6867
+ */
6868
+ setChannelAccessIntent(key, channelId, accessIntent, opts = {}) {
6804
6869
  return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
6805
6870
  method: "PATCH",
6806
- body: { accessIntent }
6871
+ body: {
6872
+ accessIntent,
6873
+ ...opts.acknowledgeLiveAccess ? { acknowledgeLiveAccess: true } : {},
6874
+ ...opts.reason ? { reason: opts.reason } : {}
6875
+ }
6807
6876
  });
6808
6877
  }
6809
6878
  // ---- hosted access links (M8) ----
@@ -6818,8 +6887,11 @@ var ManageApi = class {
6818
6887
  * Platform bounds are enforced server-side and reported as 422 with the rule
6819
6888
  * spelled out in `ManageApiError.serverMessage`.
6820
6889
  *
6821
- * Side effect by design: this also declares the channel's access intent as
6822
- * `hosted_link`, so the rail stops saying "no buyer access configured".
6890
+ * NOT a side effect any more. This used to SET the channel's access intent to
6891
+ * `hosted_link`; since 2026-08-06 it REQUIRES it, and a channel declaring any
6892
+ * other route refuses with 409 `channel_access_intent_forbids`. Callers must
6893
+ * declare the route first — `ChannelsMode` does exactly that before it
6894
+ * creates, so a first buyer link on a fresh channel is still one gesture.
6823
6895
  */
6824
6896
  createAccessLink(key, channelId, input = {}) {
6825
6897
  return this.auth(
@@ -6976,7 +7048,23 @@ var CHANNELS_CSS = (
6976
7048
  .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6977
7049
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
6978
7050
  .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}
7051
+ /* A row that opens its channel must READ as pressable, and be one for a keyboard
7052
+ too. It is a role="button" div rather than a <button> because the \u22EF control
7053
+ lives inside it, and a button inside a button is not valid HTML. */
7054
+ .slm-ch-row.open{cursor:pointer}
7055
+ .slm-ch-row.open:hover{border-color:var(--slm-accent);background:color-mix(in srgb,var(--slm-accent) 6%,var(--slm-surface))}
7056
+ .slm-ch-row.open:focus-visible{outline:2px solid var(--slm-accent);outline-offset:2px}
7057
+ .slm-ch-row.open:active{border-color:var(--slm-accent)}
7058
+ .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px;border-radius:6px}
7059
+ .slm-ch-more:hover{color:var(--slm-text)}
7060
+ .slm-ch-more:focus-visible{outline:2px solid var(--slm-accent);outline-offset:1px;color:var(--slm-text)}
7061
+ .slm-ch-menu{display:flex;flex-direction:column;gap:8px}
7062
+ .slm-ch-menu .slm-btn{width:100%}
7063
+ /* loading is a state, never a flash of empty: same slot, visibly working */
7064
+ .slm-ch-busy{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--slm-muted);padding:12px 0}
7065
+ .slm-ch-busy::before{content:"";width:9px;height:9px;border-radius:50%;background:var(--slm-accent);flex:none;
7066
+ animation:slm-ch-pulse var(--slm-mo-ambient) var(--slm-mo-in-out) infinite}
7067
+ @keyframes slm-ch-pulse{0%,100%{opacity:.25;transform:scale(.7)}50%{opacity:1;transform:scale(1)}}
6980
7068
  .slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
6981
7069
  .slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
6982
7070
  .slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
@@ -6986,10 +7074,14 @@ var CHANNELS_CSS = (
6986
7074
  .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
6987
7075
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
6988
7076
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
6989
- /* distribute options \u2014 two actions, each with the one sentence that explains it */
7077
+ /* distribute routes \u2014 four choices, each with the one sentence that explains it.
7078
+ The current one is NAMED in its own card, never signalled by colour alone. */
6990
7079
  .slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
6991
7080
  border-radius:10px;background:var(--slm-surface)}
6992
- .slm-ch-dist b{font-size:13px;font-weight:800}
7081
+ .slm-ch-dist.on{border-color:var(--slm-accent,#8b7cf6)}
7082
+ .slm-ch-dist b{font-size:13px;font-weight:800;display:flex;align-items:center;gap:8px;justify-content:space-between}
7083
+ .slm-ch-dist b .cur{font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
7084
+ color:var(--slm-accent,#8b7cf6);white-space:nowrap}
6993
7085
  .slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
6994
7086
  .slm-ch-dist .slm-btn{width:100%;margin-top:2px}
6995
7087
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
@@ -7108,6 +7200,7 @@ var CHANNELS_CSS = (
7108
7200
  .slm-ch-meter i,.slm-ch-radio{transition:none!important}
7109
7201
  .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
7110
7202
  .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
7203
+ .slm-ch-busy::before{animation:none!important;opacity:1}
7111
7204
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
7112
7205
  .slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
7113
7206
  }
@@ -7168,6 +7261,18 @@ var ChannelsMode = class {
7168
7261
  this.focusedSectionId = null;
7169
7262
  this.showArchived = false;
7170
7263
  this.detailChannelId = null;
7264
+ /**
7265
+ * Whether the organizer has asked to assign seats.
7266
+ *
7267
+ * The list rail leads with the CHANNELS. The assignment tooling — destination
7268
+ * picker plus five select-by routes — used to paint unconditionally above that
7269
+ * list, so a first-time organizer met a workbench for a job they had not asked
7270
+ * to do, with the thing they came for pushed below the fold. It is now
7271
+ * disclosed on intent: the "Assign seats to a channel" action under the list,
7272
+ * the map's own "Assign seats" segment, or simply having a selection. The
7273
+ * selection rail always shows the tools, because there the intent is proven.
7274
+ */
7275
+ this.assignOpen = false;
7171
7276
  this.targetChannelId = "";
7172
7277
  this.conflict = false;
7173
7278
  this.dialog = null;
@@ -7194,8 +7299,16 @@ var ChannelsMode = class {
7194
7299
  this.previewAudience = [];
7195
7300
  this.previewIncludePublic = false;
7196
7301
  this.previewProjection = null;
7197
- this.previewSupported = null;
7198
- // null = not yet probed
7302
+ /**
7303
+ * The buyer-preview read, as one honest state rather than a boolean.
7304
+ *
7305
+ * `previewSupported: boolean | null` could say "this worker has no projection
7306
+ * route", but it could not say "the read is in flight" or "the read failed" —
7307
+ * both of those painted an audience picker with no result underneath, which
7308
+ * reads as "this audience can buy nothing". Loading and error are now states
7309
+ * of their own, and the error one offers a retry.
7310
+ */
7311
+ this.previewState = "idle";
7199
7312
  this.pollTimer = null;
7200
7313
  this.layer = null;
7201
7314
  this.canvas = null;
@@ -7233,6 +7346,7 @@ var ChannelsMode = class {
7233
7346
  if (this.active) return;
7234
7347
  this.active = true;
7235
7348
  this.mapIntent = "pan";
7349
+ this.assignOpen = false;
7236
7350
  this.focusedSectionId = null;
7237
7351
  this.assignmentRowsCache = null;
7238
7352
  this.railHtml = null;
@@ -7742,6 +7856,7 @@ var ChannelsMode = class {
7742
7856
  this.view = view;
7743
7857
  if (view === "inspect") {
7744
7858
  this.previewProjection = null;
7859
+ this.previewState = "idle";
7745
7860
  this.setBanner(false);
7746
7861
  } else {
7747
7862
  if (!this.previewAudience.length) {
@@ -7759,6 +7874,9 @@ var ChannelsMode = class {
7759
7874
  const audience = [...this.previewAudience];
7760
7875
  const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
7761
7876
  this.setBanner(true, names);
7877
+ this.previewProjection = null;
7878
+ this.previewState = "loading";
7879
+ if (this.active) this.paintRail();
7762
7880
  try {
7763
7881
  this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
7764
7882
  // Naming Public sale as the audience IS asking for public inventory; the
@@ -7766,14 +7884,14 @@ var ChannelsMode = class {
7766
7884
  // this the request would resolve to an empty scope and 422.
7767
7885
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
7768
7886
  });
7769
- this.previewSupported = true;
7887
+ this.previewState = "ready";
7770
7888
  const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
7771
7889
  this.setBanner(true, names, eligibleSeats);
7772
7890
  } catch (err) {
7773
7891
  const status = err instanceof ManageApiError ? err.status : 0;
7774
- this.previewSupported = !(status === 404 || status === 405 || status === 501);
7892
+ this.previewState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
7775
7893
  this.previewProjection = null;
7776
- if (this.previewSupported) this.host.onError(err);
7894
+ if (this.previewState === "error") this.host.onError(err);
7777
7895
  }
7778
7896
  if (this.active) {
7779
7897
  this.paintRail();
@@ -7814,13 +7932,14 @@ var ChannelsMode = class {
7814
7932
  }
7815
7933
  if (this.loading && !this.list) {
7816
7934
  this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
7817
- <div class="slm-empty">Loading allocations\u2026</div>`);
7935
+ <div class="slm-ch-busy" role="status" data-ch-state="list-loading">Loading channels and allocations\u2026</div>`);
7818
7936
  return;
7819
7937
  }
7820
7938
  if (!this.list && this.loadError) {
7821
7939
  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.
7940
+ <div class="slm-ch-alert err" role="alert" data-ch-state="list-error"><span>\u26A0</span>
7941
+ <span><b>Couldn't load sales channels.</b> This event may well have channels \u2014 we could not read them.
7942
+ Everything else on this event still works.
7824
7943
  <button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
7825
7944
  rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
7826
7945
  void this.refresh();
@@ -7879,10 +7998,13 @@ var ChannelsMode = class {
7879
7998
  const marker = this.markerFor(channel.id);
7880
7999
  const badgeKind = opts.builtin ? "builtin" : channel.state;
7881
8000
  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}">
8001
+ const opens = !opts.builtin && this.caps.manage && this.detailChannelId !== channel.id;
8002
+ const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}${opens ? " open" : ""}`;
8003
+ const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-menu="${esc(channel.id)}"
8004
+ aria-label="More actions for ${esc(channel.name)}" aria-haspopup="dialog">\u22EF</button>` : "";
8005
+ const rowAttrs = opens ? ` role="button" tabindex="0" data-ch-open="${esc(channel.id)}"
8006
+ aria-label="Open ${esc(channel.name)}"` : "";
8007
+ return `<div class="${cls}"${rowAttrs}>
7886
8008
  <span class="slm-ch-head">
7887
8009
  <span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
7888
8010
  <span class="slm-ch-name">${esc(channel.name)}</span>
@@ -7896,41 +8018,68 @@ var ChannelsMode = class {
7896
8018
  listRailHtml() {
7897
8019
  const list = this.list;
7898
8020
  const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
8021
+ const visible = list.channels.filter((channel) => this.showArchived || channel.state !== "archived");
7899
8022
  const rows = [
7900
8023
  this.channelRowHtml(list.publicSale, { builtin: true }),
7901
- ...list.channels.filter((channel) => this.showArchived || channel.state !== "archived").map((channel, index) => this.channelRowHtml(channel, { index }))
8024
+ ...visible.map((channel, index) => this.channelRowHtml(channel, { index }))
7902
8025
  ].join("");
8026
+ 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
8027
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
7904
8028
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
7905
8029
  return `
7906
- ${this.caps.manage ? this.assignmentToolsHtml() : ""}
7907
8030
  <p class="slm-eyebrow">Sales channels</p>
7908
8031
  <p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
7909
8032
  <div class="slm-ch-list">${rows}</div>
8033
+ ${empty}
7910
8034
  ${create}
7911
8035
  ${readOnly}
8036
+ ${this.assignEntryHtml()}
7912
8037
  <p class="slm-note" style="margin-top:10px">
7913
8038
  <button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
7914
8039
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
7915
8040
  </p>`;
7916
8041
  }
8042
+ /**
8043
+ * The assignment entry point on the list rail.
8044
+ *
8045
+ * Collapsed it is ONE plain-language action, so the channels the organizer came
8046
+ * for stay at the top of the panel. Opened it is the same tool set that has
8047
+ * always worked, in the same order, plus the way back out — and opening it is
8048
+ * also what the map's "Assign seats" segment does, so the two routes into the
8049
+ * job cannot disagree about whether it is running.
8050
+ */
8051
+ assignEntryHtml() {
8052
+ if (!this.caps.manage) return "";
8053
+ if (!this.assignOpen) {
8054
+ return `
8055
+ <button type="button" class="slm-btn ghost" style="width:100%;margin-top:8px"
8056
+ data-ch-act="assign-open" aria-expanded="false">Assign seats to a channel</button>
8057
+ <p class="slm-note">Move whole sections, rows, a dragged area or single seats out of public sale
8058
+ and into a channel. Nothing moves until you review and apply.</p>`;
8059
+ }
8060
+ return `<div style="margin-top:8px">${this.assignmentToolsHtml({ collapsible: true })}</div>`;
8061
+ }
7917
8062
  /**
7918
8063
  * Destination-first assignment controls.
7919
8064
  *
7920
8065
  * These used to live only inside the selection rail, which meant every route
7921
8066
  * into them was gated behind "select a seat on the map first" — the section,
7922
8067
  * 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.
8068
+ * done the work by hand. They are still reachable before a seat is selected,
8069
+ * but they are no longer the first thing in the panel: on the list rail they
8070
+ * are disclosed by `assignEntryHtml`, and there they carry a way back out
8071
+ * (`collapsible`). In the selection rail there is nothing to disclose — a
8072
+ * selection IS the intent — so they paint unconditionally and without Done.
7926
8073
  */
7927
- assignmentToolsHtml() {
8074
+ assignmentToolsHtml(opts = {}) {
7928
8075
  const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
7929
8076
  const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
7930
8077
  const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
7931
8078
  const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
8079
+ const done = opts.collapsible ? `<button type="button" class="slm-linkbtn" data-ch-act="assign-close"
8080
+ aria-expanded="true" style="float:right;font-weight:800">Done</button>` : "";
7932
8081
  return `
7933
- <p class="slm-eyebrow">Assign inventory</p>
8082
+ <p class="slm-eyebrow">${done}Assign inventory</p>
7934
8083
  <div class="slm-field">
7935
8084
  <label for="slm-ch-target">Assign to</label>
7936
8085
  <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
@@ -7980,8 +8129,16 @@ var ChannelsMode = class {
7980
8129
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
7981
8130
  }
7982
8131
  detailRailHtml(channelId) {
8132
+ const back = `<p class="slm-eyebrow">
8133
+ <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
8134
+ </p>`;
7983
8135
  const channel = this.list?.channels.find((item) => item.id === channelId);
7984
- if (!channel) return this.listRailHtml();
8136
+ if (!channel) {
8137
+ return `${back}
8138
+ <div class="slm-ch-alert warn" role="status" data-ch-state="detail-gone"><span>\u2139</span>
8139
+ <span><b>This channel is no longer on this event.</b> It was archived or removed while you had it open.
8140
+ ${this.showArchived ? "" : "Archived channels are hidden \u2014 use \u201CShow archived\u201D on the list to see it."}</span></div>`;
8141
+ }
7985
8142
  const lifecycle = this.caps.manage ? `
7986
8143
  <p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
7987
8144
  <div class="slm-ch-row2" style="margin-top:2px">
@@ -7992,58 +8149,59 @@ var ChannelsMode = class {
7992
8149
  <p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
7993
8150
  const access = this.caps.manage ? this.distributeHtml(channel) : "";
7994
8151
  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>
8152
+ ${back}
7998
8153
  <p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
7999
8154
  <div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
8000
8155
  ${access}
8001
8156
  ${lifecycle}`;
8002
8157
  }
8003
8158
  /**
8004
- * "Distribute" — how the seats in this channel actually reach a buyer.
8159
+ * "Distribute" — the ONE way this channel's seats reach a buyer.
8160
+ *
8161
+ * All four routes are here, and this is a real chooser again. It was cut down
8162
+ * to two actions in 0.42.0 for a good reason: `access_intent` was stored,
8163
+ * audited, and read by nothing, so "Keep as protected reserve" and "Sell
8164
+ * through your own staff" were labels an organizer could set and then wait
8165
+ * forever for something to happen. The server closed that hole on 2026-08-06 —
8166
+ * each declaration now opens exactly one route and REFUSES the other three —
8167
+ * so all four are honest choices and belong on the surface.
8005
8168
  *
8006
- * This replaced a four-value "access intent" picker (none / internal /
8007
- * hosted_link / server). Two of those values did nothing anywhere: no buyer or
8008
- * inventory path reads `access_intent`, so `none` and `internal` were labels an
8009
- * organizer could set and then wait forever for something to happen. A third,
8010
- * `hosted_link`, is not the organizer's to choose at all — the server sets it
8011
- * when a buyer link is created and clears it when the last live one is revoked.
8169
+ * None of the old copy came back with them. These sentences are written
8170
+ * against the enforcement matrix (`accessIntentDescription`), which is why
8171
+ * each one says what the route refuses as well as what it allows.
8012
8172
  *
8013
- * So this is two ACTIONS, not a setting: create a buyer link, or point the
8014
- * channel at a website integration. Both of them do something the moment they
8015
- * are pressed. A legacy row still carrying `none` or `internal` renders the
8016
- * neutral "not distributed yet" state with both actions offered — the stored
8017
- * value is left alone (it is organizer-declared metadata and the API that
8018
- * writes it is unchanged), it simply no longer has a control of its own.
8173
+ * The current route is stated, not merely styled: a chooser whose selection
8174
+ * you have to infer from a border is not a chooser. Its card carries a
8175
+ * "Current route" marker and drops its own select button, because pressing it
8176
+ * would do nothing.
8019
8177
  */
8020
8178
  distributeHtml(channel) {
8021
8179
  const intent = channel.access?.intent ?? "none";
8022
8180
  const live = channel.access?.hasActiveGrants === true;
8023
- const state = live ? intent === "server" ? "Your website is letting buyers in. Only they can buy these seats." : "A buyer link is live. Only people with that link can buy these seats." : intent === "server" ? "Set up for your website \u2014 no buyer has come through yet. Seats stay reserved." : "Not distributed yet \u2014 seats stay reserved.";
8024
- const option = (title, why, action, cta, primary) => `<div class="slm-ch-dist">
8025
- <b>${esc(title)}</b>
8026
- <span class="why">${esc(why)}</span>
8027
- <button type="button" class="slm-btn${primary ? "" : " ghost"}" data-ch-act="${esc(action)}">${esc(cta)}</button>
8028
- </div>`;
8029
8181
  const linksSupported = this.linksState !== "unsupported";
8182
+ const hasLiveLink = this.links.some(accessLinkIsLive);
8183
+ const state = intent === "none" ? "Protected reserve \u2014 no route can sell these seats. Every buyer path is refused." : intent === "internal" ? "Your staff sell these seats. No buyer-facing route is open." : live ? intent === "server" ? "Your website is letting buyers in. Only they can buy these seats." : "A buyer link is live. Only people with that link can buy these seats." : intent === "server" ? "Set up for your website \u2014 no buyer has come through yet. Seats stay reserved." : "Set up for buyer links \u2014 no buyer has come through yet. Seats stay reserved.";
8184
+ const option = (route, action, cta, primary) => {
8185
+ const current = route === intent;
8186
+ return `<div class="slm-ch-dist${current ? " on" : ""}" data-ch-route="${esc(route)}">
8187
+ <b>${esc(accessIntentLabel(route))}${current ? '<span class="cur">Current route</span>' : ""}</b>
8188
+ <span class="why">${esc(accessIntentDescription(route))}</span>
8189
+ ${current && !cta ? "" : `<button type="button" class="slm-btn${primary && !current ? "" : " ghost"}"
8190
+ data-ch-act="${esc(action)}">${esc(cta)}</button>`}
8191
+ </div>`;
8192
+ };
8030
8193
  return `
8031
8194
  <p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
8032
8195
  <p class="slm-hint">${esc(state)}</p>
8033
8196
  ${linksSupported ? option(
8034
- "Sell with a buyer link",
8035
- "SeatLayer makes a link you send to a named group. They open it and buy only these seats.",
8197
+ "hosted_link",
8036
8198
  "link-create",
8037
- this.links.some(accessLinkIsLive) ? "Create another buyer link" : "Create buyer link",
8199
+ hasLiveLink ? "Create another buyer link" : "Create buyer link",
8038
8200
  true
8039
8201
  ) : ""}
8040
- ${option(
8041
- "Integrate a website or app",
8042
- "Your website's backend grants each buyer access \u2014 set up on the Embed page.",
8043
- "embed-code",
8044
- "Get embed code",
8045
- false
8046
- )}
8202
+ ${option("server", "embed-code", intent === "server" ? "Get embed code" : "Use a website or app", false)}
8203
+ ${option("internal", "route-internal", intent === "internal" ? "" : "Hand to your staff", false)}
8204
+ ${option("none", "route-none", intent === "none" ? "" : "Keep as reserve", false)}
8047
8205
  ${this.hostedLinksHtml()}
8048
8206
  ${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
8049
8207
  }
@@ -8091,16 +8249,17 @@ var ChannelsMode = class {
8091
8249
  hostedLinksHtml() {
8092
8250
  const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
8093
8251
  if (this.linksState === "unsupported") {
8094
- return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
8252
+ return `${eyebrow}<div class="slm-ch-alert warn" data-ch-state="links-unsupported"><span>\u2139</span>
8095
8253
  <span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
8096
8254
  }
8097
8255
  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>
8256
+ return `${eyebrow}<div class="slm-ch-alert err" role="alert" data-ch-state="links-error"><span>\u26A0</span>
8257
+ <span><b>Couldn't load this channel's links.</b> This is a failed read, not an empty list \u2014
8258
+ any live link is still working.
8100
8259
  <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
8101
8260
  }
8102
- if (this.linksState === "loading" && !this.links.length) {
8103
- return `${eyebrow}<div class="slm-empty">Loading links\u2026</div>`;
8261
+ if (this.linksState === "idle" || this.linksState === "loading" && !this.links.length) {
8262
+ return `${eyebrow}<div class="slm-ch-busy" role="status" data-ch-state="links-loading">Loading buyer links\u2026</div>`;
8104
8263
  }
8105
8264
  if (!this.links.length) return "";
8106
8265
  return `${eyebrow}
@@ -8142,9 +8301,14 @@ var ChannelsMode = class {
8142
8301
  ];
8143
8302
  const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
8144
8303
  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>
8304
+ const unsupported = this.previewState === "unsupported" ? `<div class="slm-ch-alert warn" data-ch-state="preview-unsupported"><span>\u2139</span>
8146
8305
  <span><b>Preview needs a newer server.</b> Allocation management works normally;
8147
8306
  the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
8307
+ 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>` : "";
8308
+ const failed = this.previewState === "error" ? `<div class="slm-ch-alert err" role="alert" data-ch-state="preview-error"><span>\u26A0</span>
8309
+ <span><b>Couldn't load the buyer preview.</b> Nothing is wrong with this audience's seats \u2014
8310
+ the simulation itself failed to load.
8311
+ <button type="button" data-ch-act="preview-retry">Try again</button></span></div>` : "";
8148
8312
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
8149
8313
  <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
8314
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
@@ -8165,7 +8329,7 @@ var ChannelsMode = class {
8165
8329
  ${includePublic}
8166
8330
  <p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
8167
8331
  approximation. It is read-only: clicks open seat details, and no holds are created.</p>
8168
- ${unsupported}${unavailable}
8332
+ ${busy}${failed}${unsupported}${unavailable}
8169
8333
  <div class="slm-ch-legend">
8170
8334
  <div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible &amp; free \u2014 buyable by this audience</div>
8171
8335
  <div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
@@ -8174,6 +8338,7 @@ var ChannelsMode = class {
8174
8338
  ${summary}`;
8175
8339
  }
8176
8340
  paintSelection() {
8341
+ if (this.caps.manage && this.host.selectionLabels().length) this.assignOpen = true;
8177
8342
  if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
8178
8343
  }
8179
8344
  wireRail() {
@@ -8184,16 +8349,27 @@ var ChannelsMode = class {
8184
8349
  rail.querySelectorAll("[data-ch-map]").forEach((button) => {
8185
8350
  button.addEventListener("click", () => {
8186
8351
  this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
8352
+ if (this.mapIntent === "assign") this.assignOpen = true;
8187
8353
  this.paintRail();
8188
8354
  this.onInteractionChange?.();
8189
8355
  });
8190
8356
  });
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());
8357
+ rail.querySelectorAll("[data-ch-open]").forEach((row) => {
8358
+ const open = () => this.openChannel(row.dataset.chOpen);
8359
+ row.addEventListener("click", open);
8360
+ row.addEventListener("keydown", (event) => {
8361
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
8362
+ event.preventDefault();
8363
+ open();
8364
+ });
8365
+ });
8366
+ rail.querySelectorAll("[data-ch-menu]").forEach((button) => {
8367
+ button.addEventListener("click", (event) => {
8368
+ event.stopPropagation();
8369
+ this.openDialog({ kind: "menu", channelId: button.dataset.chMenu });
8370
+ });
8371
+ button.addEventListener("keydown", (event) => {
8372
+ event.stopPropagation();
8197
8373
  });
8198
8374
  });
8199
8375
  rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
@@ -8232,6 +8408,12 @@ var ChannelsMode = class {
8232
8408
  button.addEventListener("click", () => this.railAction(button.dataset.chAct));
8233
8409
  });
8234
8410
  }
8411
+ /** Open a channel's detail panel and start its buyer-link read. */
8412
+ openChannel(channelId) {
8413
+ this.detailChannelId = channelId;
8414
+ this.paintRail();
8415
+ void this.loadLinks(channelId).then(() => this.paintRail());
8416
+ }
8235
8417
  railAction(action) {
8236
8418
  switch (action) {
8237
8419
  case "sections":
@@ -8267,16 +8449,25 @@ var ChannelsMode = class {
8267
8449
  this.linksState = "idle";
8268
8450
  this.paintRail();
8269
8451
  break;
8452
+ // Choosing the buyer-link route IS creating the first link — the dialog
8453
+ // declares the route on submit (see `createLink`), so this stays one
8454
+ // gesture whether the channel is already on `hosted_link` or not.
8270
8455
  case "link-create":
8271
8456
  this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
8272
8457
  break;
8273
8458
  // The one thing this screen can actually do for a website integration is
8274
- // record that the channel is meant for one — which is exactly the flag the
8275
- // dashboard's Embed page reads before it offers the snippet. Saying so is
8276
- // honest; a fake "copy code" button on a screen with no code would not be.
8459
+ // record that the channel is meant for one — and since 2026-08-06 that
8460
+ // record is what AUTHORIZES the integration to mint buyer sessions at all,
8461
+ // so it is the substantive half of the job, not a flag.
8277
8462
  case "embed-code":
8278
8463
  void this.chooseWebsiteIntegration();
8279
8464
  break;
8465
+ case "route-internal":
8466
+ void this.setAccessIntent("internal");
8467
+ break;
8468
+ case "route-none":
8469
+ void this.setAccessIntent("none");
8470
+ break;
8280
8471
  case "link-reload":
8281
8472
  if (this.detailChannelId) void this.reloadLinks();
8282
8473
  break;
@@ -8305,6 +8496,22 @@ var ChannelsMode = class {
8305
8496
  case "pick-category":
8306
8497
  this.pickCategory();
8307
8498
  break;
8499
+ case "assign-open":
8500
+ this.assignOpen = true;
8501
+ this.paintRail();
8502
+ break;
8503
+ // Collapsing the tools must also disarm the marquee. Leaving it armed
8504
+ // would hide a mode the map is still in — the organizer would drag to pan
8505
+ // and select seats instead, with no control on screen saying why.
8506
+ case "assign-close":
8507
+ this.assignOpen = false;
8508
+ this.mapIntent = "pan";
8509
+ this.paintRail();
8510
+ this.onInteractionChange?.();
8511
+ break;
8512
+ case "preview-retry":
8513
+ void this.loadPreview();
8514
+ break;
8308
8515
  default:
8309
8516
  break;
8310
8517
  }
@@ -8454,7 +8661,8 @@ var ChannelsMode = class {
8454
8661
  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
8662
  }
8456
8663
  });
8457
- list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div>`;
8664
+ 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.
8665
+ Use Drag box or the seat list instead.</div>`);
8458
8666
  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
8667
  list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
8460
8668
  const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
@@ -8504,7 +8712,8 @@ var ChannelsMode = class {
8504
8712
  this.renderScrim(`
8505
8713
  <h3 id="slm-ch-dlg-title">Add whole sections</h3>
8506
8714
  <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>
8715
+ <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.
8716
+ Use Drag box or the seat list instead.</div>`}</div>
8508
8717
  <p class="slm-ch-err" data-ch-error hidden></p>
8509
8718
  <div class="foot">
8510
8719
  <button type="button" class="quiet" data-ch-close>Cancel</button>
@@ -8561,9 +8770,81 @@ var ChannelsMode = class {
8561
8770
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8562
8771
  else if (state.kind === "rename") this.renderRenameDialog(state);
8563
8772
  else if (state.kind === "seatlist") this.renderSeatListDialog();
8773
+ else if (state.kind === "menu") this.renderMenuDialog(state);
8564
8774
  else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
8565
8775
  else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
8566
8776
  else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
8777
+ else if (state.kind === "intentSwitch") this.renderIntentSwitchDialog(state);
8778
+ }
8779
+ /**
8780
+ * `channel_intent_switch_blocked` — buyers are inside the route being left.
8781
+ *
8782
+ * The same review-then-acknowledge shape as the archive and chart-drop guards,
8783
+ * because it is the same kind of decision: the server refuses once, names
8784
+ * exactly what is at stake, and only a deliberate second press goes through.
8785
+ * What acknowledging does is spelled out per consequence — links close now,
8786
+ * checkouts already running survive and drain — rather than hidden behind a
8787
+ * word like "force".
8788
+ */
8789
+ renderIntentSwitchDialog(state) {
8790
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
8791
+ const to = state.intentTo;
8792
+ if (!channel || !to || !this.caps.manage) {
8793
+ this.closeDialog();
8794
+ return;
8795
+ }
8796
+ const { headline, consequences } = intentSwitchBlockedCopy(state.switchBlocked);
8797
+ this.renderScrim(`
8798
+ <h3 id="slm-ch-dlg-title">Change how ${esc(channel.name)} reaches buyers?</h3>
8799
+ <p class="sub">${esc(headline)}</p>
8800
+ <div class="slm-ch-alert warn" role="alert"><span>\u26A0</span><span>
8801
+ ${consequences.map((line) => `<span style="display:block;margin-bottom:6px">${esc(line)}</span>`).join("")}
8802
+ </span></div>
8803
+ ${state.pendingLink ? `<p class="slm-note">Your new buyer link is created as soon as
8804
+ the route changes \u2014 you will not have to fill the form in again.</p>` : ""}
8805
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8806
+ <div class="foot">
8807
+ <button type="button" class="quiet" data-ch-close>Leave it as it is</button>
8808
+ <button type="button" class="slm-btn" data-ch-intent-ack${state.busy ? " disabled" : ""}>
8809
+ ${state.busy ? "Changing\u2026" : `Change to "${esc(accessIntentLabel(to))}"`}</button>
8810
+ </div>`, (dialog) => {
8811
+ dialog.querySelector("[data-ch-intent-ack]")?.addEventListener("click", () => {
8812
+ void this.acknowledgeIntentSwitch(to, state.pendingLink ?? null);
8813
+ });
8814
+ });
8815
+ }
8816
+ /**
8817
+ * The acknowledged retry. The declare and the create stay one gesture across
8818
+ * the sheet: if this switch was the first half of a declare-then-create, the
8819
+ * held form finishes on the far side of the acknowledgement.
8820
+ */
8821
+ async acknowledgeIntentSwitch(intent, pendingLink) {
8822
+ const channelId = this.detailChannelId;
8823
+ if (!channelId) {
8824
+ this.closeDialog();
8825
+ return;
8826
+ }
8827
+ if (this.dialog) {
8828
+ this.dialog.busy = true;
8829
+ this.renderDialog();
8830
+ }
8831
+ const ok = await this.setAccessIntent(intent, { acknowledgeLiveAccess: true });
8832
+ if (!ok) {
8833
+ if (this.dialog?.kind === "intentSwitch") {
8834
+ this.dialog.busy = false;
8835
+ this.renderDialog();
8836
+ }
8837
+ return;
8838
+ }
8839
+ if (!pendingLink) {
8840
+ this.closeDialog();
8841
+ return;
8842
+ }
8843
+ if (this.dialog?.kind === "intentSwitch") {
8844
+ this.dialog.busy = false;
8845
+ this.renderDialog();
8846
+ }
8847
+ await this.mintLink(channelId, pendingLink);
8567
8848
  }
8568
8849
  /**
8569
8850
  * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
@@ -8816,6 +9097,59 @@ var ChannelsMode = class {
8816
9097
  void bar.offsetWidth;
8817
9098
  bar.classList.add("shake");
8818
9099
  }
9100
+ /**
9101
+ * The ⋯ menu.
9102
+ *
9103
+ * Opening a channel is the ROW's job now, so ⋯ carries what is left: the
9104
+ * secondary and destructive lifecycle actions. It is rendered through the same
9105
+ * scrim primitive as every other sheet, which is what gives it a focus trap,
9106
+ * Escape, and a name — a bare absolutely-positioned popup would have had none
9107
+ * of those. Archive keeps its own confirmation dialog; this menu never
9108
+ * destroys anything by itself.
9109
+ */
9110
+ renderMenuDialog(state) {
9111
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
9112
+ if (!channel || !this.caps.manage) {
9113
+ this.closeDialog();
9114
+ return;
9115
+ }
9116
+ const paused = channel.state === "paused";
9117
+ this.renderScrim(`
9118
+ <h3 id="slm-ch-dlg-title">${esc(channel.name)}</h3>
9119
+ <p class="sub">Open the channel to allocate seats and hand out buyer access.
9120
+ These are the rest of its actions.</p>
9121
+ <div class="slm-ch-menu">
9122
+ <button type="button" class="slm-btn" data-ch-menu-act="open">Open channel</button>
9123
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="rename">Rename</button>
9124
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="pause">${paused ? "Resume selling" : "Pause selling"}</button>
9125
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="archive">Archive\u2026</button>
9126
+ </div>
9127
+ <p class="slm-note">Pausing stops new buyer access; checkouts already running can finish.
9128
+ Archiving closes the channel for good and returns its free seats to a destination you choose.
9129
+ Nothing is ever deleted silently.</p>
9130
+ <div class="foot"><button type="button" class="quiet" data-ch-close>Cancel</button></div>`, (dialog) => {
9131
+ dialog.querySelectorAll("[data-ch-menu-act]").forEach((button) => {
9132
+ button.addEventListener("click", () => {
9133
+ const act = button.dataset.chMenuAct;
9134
+ if (act === "open") {
9135
+ this.closeDialog();
9136
+ this.openChannel(channel.id);
9137
+ return;
9138
+ }
9139
+ if (act === "rename") {
9140
+ this.openDialog({ kind: "rename", channelId: channel.id });
9141
+ return;
9142
+ }
9143
+ if (act === "archive") {
9144
+ this.openDialog({ kind: "archive", channelId: channel.id });
9145
+ return;
9146
+ }
9147
+ this.closeDialog();
9148
+ void this.togglePause(channel.id);
9149
+ });
9150
+ });
9151
+ });
9152
+ }
8819
9153
  renderRenameDialog(state) {
8820
9154
  const channel = this.list?.channels.find((item) => item.id === state.channelId);
8821
9155
  if (!channel) {
@@ -8851,34 +9185,74 @@ var ChannelsMode = class {
8851
9185
  });
8852
9186
  }
8853
9187
  /**
8854
- * "Get embed code" — mark the channel as reached through the organizer's own
8855
- * backend. The write itself is `setChannelAccessIntent(…, 'server')`, the same
8856
- * API the old picker called; the difference is that it is now a deliberate
8857
- * action with a consequence the organizer is told about, rather than one of
8858
- * four dropdown values with no observable effect.
9188
+ * "Use a website or app" — declare the channel's route as `server`.
9189
+ *
9190
+ * This is no longer a flag the Embed page happens to read: since 2026-08-06 it
9191
+ * is what AUTHORIZES `POST /v1/events/:key/buyer-access-sessions` to mint for
9192
+ * this channel at all. Without it, an integration that is otherwise perfectly
9193
+ * wired up gets a 409 on every buyer.
8859
9194
  */
8860
9195
  async chooseWebsiteIntegration() {
8861
9196
  const channelId = this.detailChannelId;
8862
9197
  if (!channelId) return;
8863
9198
  await this.setAccessIntent("server");
8864
9199
  }
8865
- async setAccessIntent(accessIntent) {
9200
+ /**
9201
+ * Declare this channel's sale route.
9202
+ *
9203
+ * Reports through the toast lane the rest of the rail's direct actions use.
9204
+ * The two enforcement refusals get real answers rather than a generic failure:
9205
+ * `channel_intent_switch_blocked` opens the review sheet (there is a decision
9206
+ * to make, and a sheet is where decisions live), and
9207
+ * `channel_access_intent_forbids` — which the organizer can hit by racing
9208
+ * their own second tab — says which route is in the way.
9209
+ */
9210
+ async setAccessIntent(accessIntent, opts = {}) {
8866
9211
  const channelId = this.detailChannelId;
8867
- if (!channelId || !this.caps.manage) return;
9212
+ if (!channelId || !this.caps.manage) return false;
8868
9213
  try {
8869
- await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, accessIntent);
9214
+ const result = await this.host.api.setChannelAccessIntent(
9215
+ this.host.eventKey,
9216
+ channelId,
9217
+ accessIntent,
9218
+ opts
9219
+ );
8870
9220
  await this.refresh();
8871
- if (accessIntent === "server") {
8872
- this.host.toast("Marked for your website. The embed code is on the Embed page.", "ok");
8873
- }
9221
+ this.host.toast(this.intentSavedCopy(accessIntent, result?.intentSwitch), "ok");
9222
+ return true;
8874
9223
  } catch (err) {
8875
- this.host.toast("Couldn't save how buyers reach this channel.", "err");
9224
+ if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
9225
+ this.openDialog({
9226
+ kind: "intentSwitch",
9227
+ channelId,
9228
+ intentTo: accessIntent,
9229
+ switchBlocked: err.details
9230
+ });
9231
+ return false;
9232
+ }
9233
+ if (err instanceof ManageApiError && err.code === "channel_access_intent_forbids") {
9234
+ this.host.toast(intentForbidsCopy(err.details), "err");
9235
+ return false;
9236
+ }
9237
+ this.host.toast("Couldn't change how this channel reaches buyers.", "err");
8876
9238
  this.host.onError(err);
9239
+ return false;
8877
9240
  }
8878
9241
  }
8879
- async togglePause() {
8880
- const channel = this.list?.channels.find((item) => item.id === this.detailChannelId);
8881
- if (!channel) return;
9242
+ /** What just happened, including anything the switch took down with it — the
9243
+ * server reports `intentSwitch` only when it actually disturbed something. */
9244
+ intentSavedCopy(intent, disturbed) {
9245
+ const head = intent === "server" ? "Set to your website or app. The embed code is on the Embed page." : intent === "internal" ? "Only your own staff can sell this channel now." : intent === "hosted_link" ? "Set to buyer links. Create one to let buyers in." : "Kept as a protected reserve. No route can sell these seats.";
9246
+ if (!disturbed) return head;
9247
+ const closed = disturbed.closedLinks ? ` ${disturbed.closedLinks.toLocaleString()} buyer link${disturbed.closedLinks === 1 ? "" : "s"} closed.` : "";
9248
+ const kept = disturbed.keptSessions ? ` ${disturbed.keptSessions.toLocaleString()} buyer${disturbed.keptSessions === 1 ? "" : "s"} already in a checkout can still finish.` : "";
9249
+ return `${head}${closed}${kept}`;
9250
+ }
9251
+ /** `channelId` is explicit because this is reachable from the ⋯ menu on a row
9252
+ * that is NOT the open channel, as well as from the detail panel itself. */
9253
+ async togglePause(channelId = this.detailChannelId) {
9254
+ const channel = this.list?.channels.find((item) => item.id === channelId);
9255
+ if (!channel || !this.caps.manage) return;
8882
9256
  const paused = channel.state !== "paused";
8883
9257
  try {
8884
9258
  await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);
@@ -9126,7 +9500,35 @@ var ChannelsMode = class {
9126
9500
  });
9127
9501
  });
9128
9502
  }
9503
+ /**
9504
+ * DECLARE, then create.
9505
+ *
9506
+ * `createAccessLink` used to set the channel's route to `hosted_link` as a
9507
+ * side effect, which is exactly why the picker could never refuse anything.
9508
+ * The server took that side effect away and now REQUIRES the declaration —
9509
+ * and channels default to `none`, so a create that did not declare first
9510
+ * would 409 on the organizer's very first "Create buyer link".
9511
+ *
9512
+ * So the route is declared here, immediately before the create. It stays ONE
9513
+ * gesture: nothing is declared while the organizer is still filling the form
9514
+ * in (cancelling changes nothing), and if the declaration is the part that is
9515
+ * refused, the review sheet holds this form and finishes the job on the far
9516
+ * side of the acknowledgement.
9517
+ */
9129
9518
  async createLink(channelId, input) {
9519
+ if (!await this.ensureHostedLinkRoute(channelId, input)) return;
9520
+ await this.mintLink(channelId, input);
9521
+ }
9522
+ /**
9523
+ * The create half, on its own.
9524
+ *
9525
+ * Separate from `createLink` because the acknowledge path has ALREADY declared
9526
+ * the route — with the very acknowledgement the plain declaration was refused
9527
+ * for. Sending it back through `ensureHostedLinkRoute` would re-derive the
9528
+ * route from a channel list that has not necessarily caught up, and could
9529
+ * refuse the organizer a second time for a decision they just made.
9530
+ */
9531
+ async mintLink(channelId, input) {
9130
9532
  try {
9131
9533
  const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
9132
9534
  this.revealLink(reveal, { channelId });
@@ -9136,6 +9538,37 @@ var ChannelsMode = class {
9136
9538
  if (!(err instanceof ManageApiError)) this.host.onError(err);
9137
9539
  }
9138
9540
  }
9541
+ /**
9542
+ * Make sure the channel declares the buyer-link route before a link is minted.
9543
+ *
9544
+ * Returns false when the create must NOT proceed — either it was refused, or
9545
+ * the decision has been handed to the switch-review sheet, which resumes it.
9546
+ * A channel already on `hosted_link` costs no request at all, so creating a
9547
+ * second link is the same single call it has always been.
9548
+ */
9549
+ async ensureHostedLinkRoute(channelId, pendingLink) {
9550
+ const channel = this.list?.channels.find((item) => item.id === channelId);
9551
+ if ((channel?.access?.intent ?? "none") === "hosted_link") return true;
9552
+ try {
9553
+ await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, "hosted_link");
9554
+ await this.refresh();
9555
+ return true;
9556
+ } catch (err) {
9557
+ if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
9558
+ this.openDialog({
9559
+ kind: "intentSwitch",
9560
+ channelId,
9561
+ intentTo: "hosted_link",
9562
+ switchBlocked: err.details,
9563
+ pendingLink
9564
+ });
9565
+ return false;
9566
+ }
9567
+ this.showDialogError(err instanceof ManageApiError && err.code === "channel_access_intent_forbids" ? intentForbidsCopy(err.details) : "Couldn't set this channel up for buyer links. Try again.");
9568
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
9569
+ return false;
9570
+ }
9571
+ }
9139
9572
  /**
9140
9573
  * The ONE-TIME reveal.
9141
9574
  *
@@ -11801,6 +12234,7 @@ export {
11801
12234
  SeatManager,
11802
12235
  SeatPicker,
11803
12236
  SeatingChart,
12237
+ accessIntentDescription,
11804
12238
  accessIntentLabel,
11805
12239
  accessLine,
11806
12240
  accessLinkBadge,
@@ -11813,6 +12247,8 @@ export {
11813
12247
  createBuyerAccessContext,
11814
12248
  createControllerSink,
11815
12249
  dropReviewRows,
12250
+ intentForbidsCopy,
12251
+ intentSwitchBlockedCopy,
11816
12252
  isPublicChannelId,
11817
12253
  markerLetter,
11818
12254
  markerOf,