@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.cjs CHANGED
@@ -408,6 +408,7 @@ __export(index_exports, {
408
408
  SeatManager: () => SeatManager,
409
409
  SeatPicker: () => SeatPicker,
410
410
  SeatingChart: () => SeatingChart,
411
+ accessIntentDescription: () => accessIntentDescription,
411
412
  accessIntentLabel: () => accessIntentLabel,
412
413
  accessLine: () => accessLine,
413
414
  accessLinkBadge: () => accessLinkBadge,
@@ -420,6 +421,8 @@ __export(index_exports, {
420
421
  createBuyerAccessContext: () => createBuyerAccessContext,
421
422
  createControllerSink: () => createControllerSink,
422
423
  dropReviewRows: () => dropReviewRows,
424
+ intentForbidsCopy: () => intentForbidsCopy,
425
+ intentSwitchBlockedCopy: () => intentSwitchBlockedCopy,
423
426
  isPublicChannelId: () => isPublicChannelId,
424
427
  markerLetter: () => markerLetter,
425
428
  markerOf: () => markerOf,
@@ -6901,13 +6904,59 @@ function retryAfterCopy(details) {
6901
6904
  }
6902
6905
  function accessLine(access) {
6903
6906
  if (!access || !access.intent) return "\u2014";
6904
- const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : "Not distributed yet";
6907
+ const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : access.intent === "internal" ? "Your staff sell these" : "Protected reserve";
6905
6908
  const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
6906
6909
  const detail = access.detail ?? grants;
6907
6910
  return detail ? `${base} \xB7 ${detail}` : base;
6908
6911
  }
6909
6912
  function accessIntentLabel(intent) {
6910
- 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";
6913
+ 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";
6914
+ }
6915
+ function accessIntentDescription(intent) {
6916
+ switch (intent) {
6917
+ case "internal":
6918
+ return "Only your own box office can sell these seats, through your secret key. Buyer links and website integrations are refused.";
6919
+ case "server":
6920
+ 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.";
6921
+ case "hosted_link":
6922
+ 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.";
6923
+ default:
6924
+ 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.";
6925
+ }
6926
+ }
6927
+ function intentForRoute(route) {
6928
+ return route === "hosted_link" ? "hosted_link" : route === "server" ? "server" : route === "staff" ? "internal" : null;
6929
+ }
6930
+ function intentForbidsCopy(details) {
6931
+ const current = parseIntent(details?.accessIntent);
6932
+ const wanted = intentForRoute(details?.route);
6933
+ const head = `This channel is set to "${accessIntentLabel(current)}"`;
6934
+ 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.`;
6935
+ }
6936
+ function parseIntent(value) {
6937
+ return value === "internal" || value === "server" || value === "hosted_link" || value === "none" ? value : "none";
6938
+ }
6939
+ function plural(count, one, many) {
6940
+ return `${count.toLocaleString()} ${count === 1 ? one : many}`;
6941
+ }
6942
+ function intentSwitchBlockedCopy(details) {
6943
+ const links = Math.max(0, details?.liveAccessLinks ?? 0);
6944
+ const sessions = Math.max(0, details?.activeSessions ?? 0);
6945
+ const from = parseIntent(details?.from);
6946
+ const to = parseIntent(details?.to);
6947
+ const live = [
6948
+ links ? plural(links, "buyer link is live", "buyer links are live") : null,
6949
+ sessions ? plural(sessions, "buyer is in a checkout", "buyers are in a checkout") : null
6950
+ ].filter(Boolean).join(", and ");
6951
+ const headline = `${live || "Buyers are inside this channel"} on "${accessIntentLabel(from)}". Moving it to "${accessIntentLabel(to)}" changes what happens to them.`;
6952
+ const consequences = [];
6953
+ if (links) {
6954
+ 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.`);
6955
+ }
6956
+ if (sessions) {
6957
+ 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.`);
6958
+ }
6959
+ return { headline, consequences };
6911
6960
  }
6912
6961
  var ACCESS_LINK_DEFAULTS = {
6913
6962
  maxRedemptions: 100,
@@ -6972,6 +7021,11 @@ function accessLinkErrorCopy(err) {
6972
7021
  return "That link is no longer active, so it cannot be rotated or revoked.";
6973
7022
  case "channel_unavailable":
6974
7023
  return "This channel is paused or archived, so it cannot let new buyers in. Resume it first.";
7024
+ // The channel declares a different sale route. The UI declares `hosted_link`
7025
+ // before it creates, so reaching this means the declaration itself was
7026
+ // refused or raced — say which route is in the way, not the code.
7027
+ case "channel_access_intent_forbids":
7028
+ return intentForbidsCopy(err?.details);
6975
7029
  case "end_active_sessions_required":
6976
7030
  return "Choose what happens to the buyers who already came in through this link.";
6977
7031
  case "not_found":
@@ -7205,13 +7259,31 @@ var ManageApi = class {
7205
7259
  const qs = params.toString();
7206
7260
  return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/preview${qs ? `?${qs}` : ""}`);
7207
7261
  }
7208
- /** Declare how buyers are meant to reach this channel. Drives the rail's
7209
- * access line and turns "No buyer access configured" from information into a
7210
- * warning when the organizer says the channel is for buyer self-service. */
7211
- setChannelAccessIntent(key, channelId, accessIntent) {
7262
+ /**
7263
+ * Choose which sale route this channel opens.
7264
+ *
7265
+ * Since the server's 2026-08-06 change this is AUTHORIZATION, not a label:
7266
+ * exactly one of the four routes may mint buyer access for the channel and the
7267
+ * other three refuse with 409 `channel_access_intent_forbids`. The default is
7268
+ * `none`, which refuses all four — so a route has to be declared before any
7269
+ * buyer-facing action on the channel can succeed.
7270
+ *
7271
+ * Switching the route while buyers are already inside the current one is
7272
+ * refused with 409 `channel_intent_switch_blocked`, whose `details` name what
7273
+ * is live (`liveAccessLinks`, `activeSessions`). Retry with
7274
+ * `acknowledgeLiveAccess: true`: hosted links on the channel are revoked,
7275
+ * while sessions already minted keep their holds and drain on their own.
7276
+ * `intentSwitch` is present on the response ONLY when the switch disturbed
7277
+ * something, so the ordinary case stays the two-key body it has always been.
7278
+ */
7279
+ setChannelAccessIntent(key, channelId, accessIntent, opts = {}) {
7212
7280
  return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
7213
7281
  method: "PATCH",
7214
- body: { accessIntent }
7282
+ body: {
7283
+ accessIntent,
7284
+ ...opts.acknowledgeLiveAccess ? { acknowledgeLiveAccess: true } : {},
7285
+ ...opts.reason ? { reason: opts.reason } : {}
7286
+ }
7215
7287
  });
7216
7288
  }
7217
7289
  // ---- hosted access links (M8) ----
@@ -7226,8 +7298,11 @@ var ManageApi = class {
7226
7298
  * Platform bounds are enforced server-side and reported as 422 with the rule
7227
7299
  * spelled out in `ManageApiError.serverMessage`.
7228
7300
  *
7229
- * Side effect by design: this also declares the channel's access intent as
7230
- * `hosted_link`, so the rail stops saying "no buyer access configured".
7301
+ * NOT a side effect any more. This used to SET the channel's access intent to
7302
+ * `hosted_link`; since 2026-08-06 it REQUIRES it, and a channel declaring any
7303
+ * other route refuses with 409 `channel_access_intent_forbids`. Callers must
7304
+ * declare the route first — `ChannelsMode` does exactly that before it
7305
+ * creates, so a first buyer link on a fresh channel is still one gesture.
7231
7306
  */
7232
7307
  createAccessLink(key, channelId, input = {}) {
7233
7308
  return this.auth(
@@ -7384,7 +7459,23 @@ var CHANNELS_CSS = (
7384
7459
  .slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
7385
7460
  @keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
7386
7461
  .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}
7462
+ /* A row that opens its channel must READ as pressable, and be one for a keyboard
7463
+ too. It is a role="button" div rather than a <button> because the \u22EF control
7464
+ lives inside it, and a button inside a button is not valid HTML. */
7465
+ .slm-ch-row.open{cursor:pointer}
7466
+ .slm-ch-row.open:hover{border-color:var(--slm-accent);background:color-mix(in srgb,var(--slm-accent) 6%,var(--slm-surface))}
7467
+ .slm-ch-row.open:focus-visible{outline:2px solid var(--slm-accent);outline-offset:2px}
7468
+ .slm-ch-row.open:active{border-color:var(--slm-accent)}
7469
+ .slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px;border-radius:6px}
7470
+ .slm-ch-more:hover{color:var(--slm-text)}
7471
+ .slm-ch-more:focus-visible{outline:2px solid var(--slm-accent);outline-offset:1px;color:var(--slm-text)}
7472
+ .slm-ch-menu{display:flex;flex-direction:column;gap:8px}
7473
+ .slm-ch-menu .slm-btn{width:100%}
7474
+ /* loading is a state, never a flash of empty: same slot, visibly working */
7475
+ .slm-ch-busy{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--slm-muted);padding:12px 0}
7476
+ .slm-ch-busy::before{content:"";width:9px;height:9px;border-radius:50%;background:var(--slm-accent);flex:none;
7477
+ animation:slm-ch-pulse var(--slm-mo-ambient) var(--slm-mo-in-out) infinite}
7478
+ @keyframes slm-ch-pulse{0%,100%{opacity:.25;transform:scale(.7)}50%{opacity:1;transform:scale(1)}}
7388
7479
  .slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
7389
7480
  .slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
7390
7481
  .slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
@@ -7394,10 +7485,14 @@ var CHANNELS_CSS = (
7394
7485
  .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
7395
7486
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
7396
7487
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
7397
- /* distribute options \u2014 two actions, each with the one sentence that explains it */
7488
+ /* distribute routes \u2014 four choices, each with the one sentence that explains it.
7489
+ The current one is NAMED in its own card, never signalled by colour alone. */
7398
7490
  .slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
7399
7491
  border-radius:10px;background:var(--slm-surface)}
7400
- .slm-ch-dist b{font-size:13px;font-weight:800}
7492
+ .slm-ch-dist.on{border-color:var(--slm-accent,#8b7cf6)}
7493
+ .slm-ch-dist b{font-size:13px;font-weight:800;display:flex;align-items:center;gap:8px;justify-content:space-between}
7494
+ .slm-ch-dist b .cur{font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
7495
+ color:var(--slm-accent,#8b7cf6);white-space:nowrap}
7401
7496
  .slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
7402
7497
  .slm-ch-dist .slm-btn{width:100%;margin-top:2px}
7403
7498
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
@@ -7516,6 +7611,7 @@ var CHANNELS_CSS = (
7516
7611
  .slm-ch-meter i,.slm-ch-radio{transition:none!important}
7517
7612
  .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
7518
7613
  .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
7614
+ .slm-ch-busy::before{animation:none!important;opacity:1}
7519
7615
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
7520
7616
  .slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
7521
7617
  }
@@ -7576,6 +7672,18 @@ var ChannelsMode = class {
7576
7672
  this.focusedSectionId = null;
7577
7673
  this.showArchived = false;
7578
7674
  this.detailChannelId = null;
7675
+ /**
7676
+ * Whether the organizer has asked to assign seats.
7677
+ *
7678
+ * The list rail leads with the CHANNELS. The assignment tooling — destination
7679
+ * picker plus five select-by routes — used to paint unconditionally above that
7680
+ * list, so a first-time organizer met a workbench for a job they had not asked
7681
+ * to do, with the thing they came for pushed below the fold. It is now
7682
+ * disclosed on intent: the "Assign seats to a channel" action under the list,
7683
+ * the map's own "Assign seats" segment, or simply having a selection. The
7684
+ * selection rail always shows the tools, because there the intent is proven.
7685
+ */
7686
+ this.assignOpen = false;
7579
7687
  this.targetChannelId = "";
7580
7688
  this.conflict = false;
7581
7689
  this.dialog = null;
@@ -7602,8 +7710,16 @@ var ChannelsMode = class {
7602
7710
  this.previewAudience = [];
7603
7711
  this.previewIncludePublic = false;
7604
7712
  this.previewProjection = null;
7605
- this.previewSupported = null;
7606
- // null = not yet probed
7713
+ /**
7714
+ * The buyer-preview read, as one honest state rather than a boolean.
7715
+ *
7716
+ * `previewSupported: boolean | null` could say "this worker has no projection
7717
+ * route", but it could not say "the read is in flight" or "the read failed" —
7718
+ * both of those painted an audience picker with no result underneath, which
7719
+ * reads as "this audience can buy nothing". Loading and error are now states
7720
+ * of their own, and the error one offers a retry.
7721
+ */
7722
+ this.previewState = "idle";
7607
7723
  this.pollTimer = null;
7608
7724
  this.layer = null;
7609
7725
  this.canvas = null;
@@ -7641,6 +7757,7 @@ var ChannelsMode = class {
7641
7757
  if (this.active) return;
7642
7758
  this.active = true;
7643
7759
  this.mapIntent = "pan";
7760
+ this.assignOpen = false;
7644
7761
  this.focusedSectionId = null;
7645
7762
  this.assignmentRowsCache = null;
7646
7763
  this.railHtml = null;
@@ -8150,6 +8267,7 @@ var ChannelsMode = class {
8150
8267
  this.view = view;
8151
8268
  if (view === "inspect") {
8152
8269
  this.previewProjection = null;
8270
+ this.previewState = "idle";
8153
8271
  this.setBanner(false);
8154
8272
  } else {
8155
8273
  if (!this.previewAudience.length) {
@@ -8167,6 +8285,9 @@ var ChannelsMode = class {
8167
8285
  const audience = [...this.previewAudience];
8168
8286
  const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
8169
8287
  this.setBanner(true, names);
8288
+ this.previewProjection = null;
8289
+ this.previewState = "loading";
8290
+ if (this.active) this.paintRail();
8170
8291
  try {
8171
8292
  this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
8172
8293
  // Naming Public sale as the audience IS asking for public inventory; the
@@ -8174,14 +8295,14 @@ var ChannelsMode = class {
8174
8295
  // this the request would resolve to an empty scope and 422.
8175
8296
  includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
8176
8297
  });
8177
- this.previewSupported = true;
8298
+ this.previewState = "ready";
8178
8299
  const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
8179
8300
  this.setBanner(true, names, eligibleSeats);
8180
8301
  } catch (err) {
8181
8302
  const status = err instanceof ManageApiError ? err.status : 0;
8182
- this.previewSupported = !(status === 404 || status === 405 || status === 501);
8303
+ this.previewState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
8183
8304
  this.previewProjection = null;
8184
- if (this.previewSupported) this.host.onError(err);
8305
+ if (this.previewState === "error") this.host.onError(err);
8185
8306
  }
8186
8307
  if (this.active) {
8187
8308
  this.paintRail();
@@ -8222,13 +8343,14 @@ var ChannelsMode = class {
8222
8343
  }
8223
8344
  if (this.loading && !this.list) {
8224
8345
  this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
8225
- <div class="slm-empty">Loading allocations\u2026</div>`);
8346
+ <div class="slm-ch-busy" role="status" data-ch-state="list-loading">Loading channels and allocations\u2026</div>`);
8226
8347
  return;
8227
8348
  }
8228
8349
  if (!this.list && this.loadError) {
8229
8350
  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.
8351
+ <div class="slm-ch-alert err" role="alert" data-ch-state="list-error"><span>\u26A0</span>
8352
+ <span><b>Couldn't load sales channels.</b> This event may well have channels \u2014 we could not read them.
8353
+ Everything else on this event still works.
8232
8354
  <button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
8233
8355
  rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
8234
8356
  void this.refresh();
@@ -8287,10 +8409,13 @@ var ChannelsMode = class {
8287
8409
  const marker = this.markerFor(channel.id);
8288
8410
  const badgeKind = opts.builtin ? "builtin" : channel.state;
8289
8411
  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}">
8412
+ const opens = !opts.builtin && this.caps.manage && this.detailChannelId !== channel.id;
8413
+ const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}${opens ? " open" : ""}`;
8414
+ const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-menu="${esc(channel.id)}"
8415
+ aria-label="More actions for ${esc(channel.name)}" aria-haspopup="dialog">\u22EF</button>` : "";
8416
+ const rowAttrs = opens ? ` role="button" tabindex="0" data-ch-open="${esc(channel.id)}"
8417
+ aria-label="Open ${esc(channel.name)}"` : "";
8418
+ return `<div class="${cls}"${rowAttrs}>
8294
8419
  <span class="slm-ch-head">
8295
8420
  <span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
8296
8421
  <span class="slm-ch-name">${esc(channel.name)}</span>
@@ -8304,41 +8429,68 @@ var ChannelsMode = class {
8304
8429
  listRailHtml() {
8305
8430
  const list = this.list;
8306
8431
  const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
8432
+ const visible = list.channels.filter((channel) => this.showArchived || channel.state !== "archived");
8307
8433
  const rows = [
8308
8434
  this.channelRowHtml(list.publicSale, { builtin: true }),
8309
- ...list.channels.filter((channel) => this.showArchived || channel.state !== "archived").map((channel, index) => this.channelRowHtml(channel, { index }))
8435
+ ...visible.map((channel, index) => this.channelRowHtml(channel, { index }))
8310
8436
  ].join("");
8437
+ 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
8438
  const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
8312
8439
  const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
8313
8440
  return `
8314
- ${this.caps.manage ? this.assignmentToolsHtml() : ""}
8315
8441
  <p class="slm-eyebrow">Sales channels</p>
8316
8442
  <p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
8317
8443
  <div class="slm-ch-list">${rows}</div>
8444
+ ${empty}
8318
8445
  ${create}
8319
8446
  ${readOnly}
8447
+ ${this.assignEntryHtml()}
8320
8448
  <p class="slm-note" style="margin-top:10px">
8321
8449
  <button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
8322
8450
  style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
8323
8451
  </p>`;
8324
8452
  }
8453
+ /**
8454
+ * The assignment entry point on the list rail.
8455
+ *
8456
+ * Collapsed it is ONE plain-language action, so the channels the organizer came
8457
+ * for stay at the top of the panel. Opened it is the same tool set that has
8458
+ * always worked, in the same order, plus the way back out — and opening it is
8459
+ * also what the map's "Assign seats" segment does, so the two routes into the
8460
+ * job cannot disagree about whether it is running.
8461
+ */
8462
+ assignEntryHtml() {
8463
+ if (!this.caps.manage) return "";
8464
+ if (!this.assignOpen) {
8465
+ return `
8466
+ <button type="button" class="slm-btn ghost" style="width:100%;margin-top:8px"
8467
+ data-ch-act="assign-open" aria-expanded="false">Assign seats to a channel</button>
8468
+ <p class="slm-note">Move whole sections, rows, a dragged area or single seats out of public sale
8469
+ and into a channel. Nothing moves until you review and apply.</p>`;
8470
+ }
8471
+ return `<div style="margin-top:8px">${this.assignmentToolsHtml({ collapsible: true })}</div>`;
8472
+ }
8325
8473
  /**
8326
8474
  * Destination-first assignment controls.
8327
8475
  *
8328
8476
  * These used to live only inside the selection rail, which meant every route
8329
8477
  * into them was gated behind "select a seat on the map first" — the section,
8330
8478
  * 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.
8479
+ * done the work by hand. They are still reachable before a seat is selected,
8480
+ * but they are no longer the first thing in the panel: on the list rail they
8481
+ * are disclosed by `assignEntryHtml`, and there they carry a way back out
8482
+ * (`collapsible`). In the selection rail there is nothing to disclose — a
8483
+ * selection IS the intent — so they paint unconditionally and without Done.
8334
8484
  */
8335
- assignmentToolsHtml() {
8485
+ assignmentToolsHtml(opts = {}) {
8336
8486
  const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
8337
8487
  const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
8338
8488
  const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
8339
8489
  const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
8490
+ const done = opts.collapsible ? `<button type="button" class="slm-linkbtn" data-ch-act="assign-close"
8491
+ aria-expanded="true" style="float:right;font-weight:800">Done</button>` : "";
8340
8492
  return `
8341
- <p class="slm-eyebrow">Assign inventory</p>
8493
+ <p class="slm-eyebrow">${done}Assign inventory</p>
8342
8494
  <div class="slm-field">
8343
8495
  <label for="slm-ch-target">Assign to</label>
8344
8496
  <select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
@@ -8388,8 +8540,16 @@ var ChannelsMode = class {
8388
8540
  <p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
8389
8541
  }
8390
8542
  detailRailHtml(channelId) {
8543
+ const back = `<p class="slm-eyebrow">
8544
+ <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
8545
+ </p>`;
8391
8546
  const channel = this.list?.channels.find((item) => item.id === channelId);
8392
- if (!channel) return this.listRailHtml();
8547
+ if (!channel) {
8548
+ return `${back}
8549
+ <div class="slm-ch-alert warn" role="status" data-ch-state="detail-gone"><span>\u2139</span>
8550
+ <span><b>This channel is no longer on this event.</b> It was archived or removed while you had it open.
8551
+ ${this.showArchived ? "" : "Archived channels are hidden \u2014 use \u201CShow archived\u201D on the list to see it."}</span></div>`;
8552
+ }
8393
8553
  const lifecycle = this.caps.manage ? `
8394
8554
  <p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
8395
8555
  <div class="slm-ch-row2" style="margin-top:2px">
@@ -8400,58 +8560,59 @@ var ChannelsMode = class {
8400
8560
  <p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
8401
8561
  const access = this.caps.manage ? this.distributeHtml(channel) : "";
8402
8562
  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>
8563
+ ${back}
8406
8564
  <p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
8407
8565
  <div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
8408
8566
  ${access}
8409
8567
  ${lifecycle}`;
8410
8568
  }
8411
8569
  /**
8412
- * "Distribute" — how the seats in this channel actually reach a buyer.
8570
+ * "Distribute" — the ONE way this channel's seats reach a buyer.
8571
+ *
8572
+ * All four routes are here, and this is a real chooser again. It was cut down
8573
+ * to two actions in 0.42.0 for a good reason: `access_intent` was stored,
8574
+ * audited, and read by nothing, so "Keep as protected reserve" and "Sell
8575
+ * through your own staff" were labels an organizer could set and then wait
8576
+ * forever for something to happen. The server closed that hole on 2026-08-06 —
8577
+ * each declaration now opens exactly one route and REFUSES the other three —
8578
+ * so all four are honest choices and belong on the surface.
8413
8579
  *
8414
- * This replaced a four-value "access intent" picker (none / internal /
8415
- * hosted_link / server). Two of those values did nothing anywhere: no buyer or
8416
- * inventory path reads `access_intent`, so `none` and `internal` were labels an
8417
- * organizer could set and then wait forever for something to happen. A third,
8418
- * `hosted_link`, is not the organizer's to choose at all — the server sets it
8419
- * when a buyer link is created and clears it when the last live one is revoked.
8580
+ * None of the old copy came back with them. These sentences are written
8581
+ * against the enforcement matrix (`accessIntentDescription`), which is why
8582
+ * each one says what the route refuses as well as what it allows.
8420
8583
  *
8421
- * So this is two ACTIONS, not a setting: create a buyer link, or point the
8422
- * channel at a website integration. Both of them do something the moment they
8423
- * are pressed. A legacy row still carrying `none` or `internal` renders the
8424
- * neutral "not distributed yet" state with both actions offered — the stored
8425
- * value is left alone (it is organizer-declared metadata and the API that
8426
- * writes it is unchanged), it simply no longer has a control of its own.
8584
+ * The current route is stated, not merely styled: a chooser whose selection
8585
+ * you have to infer from a border is not a chooser. Its card carries a
8586
+ * "Current route" marker and drops its own select button, because pressing it
8587
+ * would do nothing.
8427
8588
  */
8428
8589
  distributeHtml(channel) {
8429
8590
  const intent = channel.access?.intent ?? "none";
8430
8591
  const live = channel.access?.hasActiveGrants === true;
8431
- 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.";
8432
- const option = (title, why, action, cta, primary) => `<div class="slm-ch-dist">
8433
- <b>${esc(title)}</b>
8434
- <span class="why">${esc(why)}</span>
8435
- <button type="button" class="slm-btn${primary ? "" : " ghost"}" data-ch-act="${esc(action)}">${esc(cta)}</button>
8436
- </div>`;
8437
8592
  const linksSupported = this.linksState !== "unsupported";
8593
+ const hasLiveLink = this.links.some(accessLinkIsLive);
8594
+ 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.";
8595
+ const option = (route, action, cta, primary) => {
8596
+ const current = route === intent;
8597
+ return `<div class="slm-ch-dist${current ? " on" : ""}" data-ch-route="${esc(route)}">
8598
+ <b>${esc(accessIntentLabel(route))}${current ? '<span class="cur">Current route</span>' : ""}</b>
8599
+ <span class="why">${esc(accessIntentDescription(route))}</span>
8600
+ ${current && !cta ? "" : `<button type="button" class="slm-btn${primary && !current ? "" : " ghost"}"
8601
+ data-ch-act="${esc(action)}">${esc(cta)}</button>`}
8602
+ </div>`;
8603
+ };
8438
8604
  return `
8439
8605
  <p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
8440
8606
  <p class="slm-hint">${esc(state)}</p>
8441
8607
  ${linksSupported ? option(
8442
- "Sell with a buyer link",
8443
- "SeatLayer makes a link you send to a named group. They open it and buy only these seats.",
8608
+ "hosted_link",
8444
8609
  "link-create",
8445
- this.links.some(accessLinkIsLive) ? "Create another buyer link" : "Create buyer link",
8610
+ hasLiveLink ? "Create another buyer link" : "Create buyer link",
8446
8611
  true
8447
8612
  ) : ""}
8448
- ${option(
8449
- "Integrate a website or app",
8450
- "Your website's backend grants each buyer access \u2014 set up on the Embed page.",
8451
- "embed-code",
8452
- "Get embed code",
8453
- false
8454
- )}
8613
+ ${option("server", "embed-code", intent === "server" ? "Get embed code" : "Use a website or app", false)}
8614
+ ${option("internal", "route-internal", intent === "internal" ? "" : "Hand to your staff", false)}
8615
+ ${option("none", "route-none", intent === "none" ? "" : "Keep as reserve", false)}
8455
8616
  ${this.hostedLinksHtml()}
8456
8617
  ${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
8457
8618
  }
@@ -8499,16 +8660,17 @@ var ChannelsMode = class {
8499
8660
  hostedLinksHtml() {
8500
8661
  const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
8501
8662
  if (this.linksState === "unsupported") {
8502
- return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
8663
+ return `${eyebrow}<div class="slm-ch-alert warn" data-ch-state="links-unsupported"><span>\u2139</span>
8503
8664
  <span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
8504
8665
  }
8505
8666
  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>
8667
+ return `${eyebrow}<div class="slm-ch-alert err" role="alert" data-ch-state="links-error"><span>\u26A0</span>
8668
+ <span><b>Couldn't load this channel's links.</b> This is a failed read, not an empty list \u2014
8669
+ any live link is still working.
8508
8670
  <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
8509
8671
  }
8510
- if (this.linksState === "loading" && !this.links.length) {
8511
- return `${eyebrow}<div class="slm-empty">Loading links\u2026</div>`;
8672
+ if (this.linksState === "idle" || this.linksState === "loading" && !this.links.length) {
8673
+ return `${eyebrow}<div class="slm-ch-busy" role="status" data-ch-state="links-loading">Loading buyer links\u2026</div>`;
8512
8674
  }
8513
8675
  if (!this.links.length) return "";
8514
8676
  return `${eyebrow}
@@ -8550,9 +8712,14 @@ var ChannelsMode = class {
8550
8712
  ];
8551
8713
  const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
8552
8714
  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>
8715
+ const unsupported = this.previewState === "unsupported" ? `<div class="slm-ch-alert warn" data-ch-state="preview-unsupported"><span>\u2139</span>
8554
8716
  <span><b>Preview needs a newer server.</b> Allocation management works normally;
8555
8717
  the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
8718
+ 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>` : "";
8719
+ const failed = this.previewState === "error" ? `<div class="slm-ch-alert err" role="alert" data-ch-state="preview-error"><span>\u26A0</span>
8720
+ <span><b>Couldn't load the buyer preview.</b> Nothing is wrong with this audience's seats \u2014
8721
+ the simulation itself failed to load.
8722
+ <button type="button" data-ch-act="preview-retry">Try again</button></span></div>` : "";
8556
8723
  const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
8557
8724
  <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
8725
  A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
@@ -8573,7 +8740,7 @@ var ChannelsMode = class {
8573
8740
  ${includePublic}
8574
8741
  <p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
8575
8742
  approximation. It is read-only: clicks open seat details, and no holds are created.</p>
8576
- ${unsupported}${unavailable}
8743
+ ${busy}${failed}${unsupported}${unavailable}
8577
8744
  <div class="slm-ch-legend">
8578
8745
  <div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible &amp; free \u2014 buyable by this audience</div>
8579
8746
  <div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
@@ -8582,6 +8749,7 @@ var ChannelsMode = class {
8582
8749
  ${summary}`;
8583
8750
  }
8584
8751
  paintSelection() {
8752
+ if (this.caps.manage && this.host.selectionLabels().length) this.assignOpen = true;
8585
8753
  if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
8586
8754
  }
8587
8755
  wireRail() {
@@ -8592,16 +8760,27 @@ var ChannelsMode = class {
8592
8760
  rail.querySelectorAll("[data-ch-map]").forEach((button) => {
8593
8761
  button.addEventListener("click", () => {
8594
8762
  this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
8763
+ if (this.mapIntent === "assign") this.assignOpen = true;
8595
8764
  this.paintRail();
8596
8765
  this.onInteractionChange?.();
8597
8766
  });
8598
8767
  });
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());
8768
+ rail.querySelectorAll("[data-ch-open]").forEach((row) => {
8769
+ const open = () => this.openChannel(row.dataset.chOpen);
8770
+ row.addEventListener("click", open);
8771
+ row.addEventListener("keydown", (event) => {
8772
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
8773
+ event.preventDefault();
8774
+ open();
8775
+ });
8776
+ });
8777
+ rail.querySelectorAll("[data-ch-menu]").forEach((button) => {
8778
+ button.addEventListener("click", (event) => {
8779
+ event.stopPropagation();
8780
+ this.openDialog({ kind: "menu", channelId: button.dataset.chMenu });
8781
+ });
8782
+ button.addEventListener("keydown", (event) => {
8783
+ event.stopPropagation();
8605
8784
  });
8606
8785
  });
8607
8786
  rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
@@ -8640,6 +8819,12 @@ var ChannelsMode = class {
8640
8819
  button.addEventListener("click", () => this.railAction(button.dataset.chAct));
8641
8820
  });
8642
8821
  }
8822
+ /** Open a channel's detail panel and start its buyer-link read. */
8823
+ openChannel(channelId) {
8824
+ this.detailChannelId = channelId;
8825
+ this.paintRail();
8826
+ void this.loadLinks(channelId).then(() => this.paintRail());
8827
+ }
8643
8828
  railAction(action) {
8644
8829
  switch (action) {
8645
8830
  case "sections":
@@ -8675,16 +8860,25 @@ var ChannelsMode = class {
8675
8860
  this.linksState = "idle";
8676
8861
  this.paintRail();
8677
8862
  break;
8863
+ // Choosing the buyer-link route IS creating the first link — the dialog
8864
+ // declares the route on submit (see `createLink`), so this stays one
8865
+ // gesture whether the channel is already on `hosted_link` or not.
8678
8866
  case "link-create":
8679
8867
  this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
8680
8868
  break;
8681
8869
  // The one thing this screen can actually do for a website integration is
8682
- // record that the channel is meant for one — which is exactly the flag the
8683
- // dashboard's Embed page reads before it offers the snippet. Saying so is
8684
- // honest; a fake "copy code" button on a screen with no code would not be.
8870
+ // record that the channel is meant for one — and since 2026-08-06 that
8871
+ // record is what AUTHORIZES the integration to mint buyer sessions at all,
8872
+ // so it is the substantive half of the job, not a flag.
8685
8873
  case "embed-code":
8686
8874
  void this.chooseWebsiteIntegration();
8687
8875
  break;
8876
+ case "route-internal":
8877
+ void this.setAccessIntent("internal");
8878
+ break;
8879
+ case "route-none":
8880
+ void this.setAccessIntent("none");
8881
+ break;
8688
8882
  case "link-reload":
8689
8883
  if (this.detailChannelId) void this.reloadLinks();
8690
8884
  break;
@@ -8713,6 +8907,22 @@ var ChannelsMode = class {
8713
8907
  case "pick-category":
8714
8908
  this.pickCategory();
8715
8909
  break;
8910
+ case "assign-open":
8911
+ this.assignOpen = true;
8912
+ this.paintRail();
8913
+ break;
8914
+ // Collapsing the tools must also disarm the marquee. Leaving it armed
8915
+ // would hide a mode the map is still in — the organizer would drag to pan
8916
+ // and select seats instead, with no control on screen saying why.
8917
+ case "assign-close":
8918
+ this.assignOpen = false;
8919
+ this.mapIntent = "pan";
8920
+ this.paintRail();
8921
+ this.onInteractionChange?.();
8922
+ break;
8923
+ case "preview-retry":
8924
+ void this.loadPreview();
8925
+ break;
8716
8926
  default:
8717
8927
  break;
8718
8928
  }
@@ -8862,7 +9072,8 @@ var ChannelsMode = class {
8862
9072
  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
9073
  }
8864
9074
  });
8865
- list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div>`;
9075
+ 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.
9076
+ Use Drag box or the seat list instead.</div>`);
8866
9077
  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
9078
  list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
8868
9079
  const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
@@ -8912,7 +9123,8 @@ var ChannelsMode = class {
8912
9123
  this.renderScrim(`
8913
9124
  <h3 id="slm-ch-dlg-title">Add whole sections</h3>
8914
9125
  <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>
9126
+ <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.
9127
+ Use Drag box or the seat list instead.</div>`}</div>
8916
9128
  <p class="slm-ch-err" data-ch-error hidden></p>
8917
9129
  <div class="foot">
8918
9130
  <button type="button" class="quiet" data-ch-close>Cancel</button>
@@ -8969,9 +9181,81 @@ var ChannelsMode = class {
8969
9181
  else if (state.kind === "archive") this.renderArchiveDialog(state);
8970
9182
  else if (state.kind === "rename") this.renderRenameDialog(state);
8971
9183
  else if (state.kind === "seatlist") this.renderSeatListDialog();
9184
+ else if (state.kind === "menu") this.renderMenuDialog(state);
8972
9185
  else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
8973
9186
  else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
8974
9187
  else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
9188
+ else if (state.kind === "intentSwitch") this.renderIntentSwitchDialog(state);
9189
+ }
9190
+ /**
9191
+ * `channel_intent_switch_blocked` — buyers are inside the route being left.
9192
+ *
9193
+ * The same review-then-acknowledge shape as the archive and chart-drop guards,
9194
+ * because it is the same kind of decision: the server refuses once, names
9195
+ * exactly what is at stake, and only a deliberate second press goes through.
9196
+ * What acknowledging does is spelled out per consequence — links close now,
9197
+ * checkouts already running survive and drain — rather than hidden behind a
9198
+ * word like "force".
9199
+ */
9200
+ renderIntentSwitchDialog(state) {
9201
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
9202
+ const to = state.intentTo;
9203
+ if (!channel || !to || !this.caps.manage) {
9204
+ this.closeDialog();
9205
+ return;
9206
+ }
9207
+ const { headline, consequences } = intentSwitchBlockedCopy(state.switchBlocked);
9208
+ this.renderScrim(`
9209
+ <h3 id="slm-ch-dlg-title">Change how ${esc(channel.name)} reaches buyers?</h3>
9210
+ <p class="sub">${esc(headline)}</p>
9211
+ <div class="slm-ch-alert warn" role="alert"><span>\u26A0</span><span>
9212
+ ${consequences.map((line) => `<span style="display:block;margin-bottom:6px">${esc(line)}</span>`).join("")}
9213
+ </span></div>
9214
+ ${state.pendingLink ? `<p class="slm-note">Your new buyer link is created as soon as
9215
+ the route changes \u2014 you will not have to fill the form in again.</p>` : ""}
9216
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
9217
+ <div class="foot">
9218
+ <button type="button" class="quiet" data-ch-close>Leave it as it is</button>
9219
+ <button type="button" class="slm-btn" data-ch-intent-ack${state.busy ? " disabled" : ""}>
9220
+ ${state.busy ? "Changing\u2026" : `Change to "${esc(accessIntentLabel(to))}"`}</button>
9221
+ </div>`, (dialog) => {
9222
+ dialog.querySelector("[data-ch-intent-ack]")?.addEventListener("click", () => {
9223
+ void this.acknowledgeIntentSwitch(to, state.pendingLink ?? null);
9224
+ });
9225
+ });
9226
+ }
9227
+ /**
9228
+ * The acknowledged retry. The declare and the create stay one gesture across
9229
+ * the sheet: if this switch was the first half of a declare-then-create, the
9230
+ * held form finishes on the far side of the acknowledgement.
9231
+ */
9232
+ async acknowledgeIntentSwitch(intent, pendingLink) {
9233
+ const channelId = this.detailChannelId;
9234
+ if (!channelId) {
9235
+ this.closeDialog();
9236
+ return;
9237
+ }
9238
+ if (this.dialog) {
9239
+ this.dialog.busy = true;
9240
+ this.renderDialog();
9241
+ }
9242
+ const ok = await this.setAccessIntent(intent, { acknowledgeLiveAccess: true });
9243
+ if (!ok) {
9244
+ if (this.dialog?.kind === "intentSwitch") {
9245
+ this.dialog.busy = false;
9246
+ this.renderDialog();
9247
+ }
9248
+ return;
9249
+ }
9250
+ if (!pendingLink) {
9251
+ this.closeDialog();
9252
+ return;
9253
+ }
9254
+ if (this.dialog?.kind === "intentSwitch") {
9255
+ this.dialog.busy = false;
9256
+ this.renderDialog();
9257
+ }
9258
+ await this.mintLink(channelId, pendingLink);
8975
9259
  }
8976
9260
  /**
8977
9261
  * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
@@ -9224,6 +9508,59 @@ var ChannelsMode = class {
9224
9508
  void bar.offsetWidth;
9225
9509
  bar.classList.add("shake");
9226
9510
  }
9511
+ /**
9512
+ * The ⋯ menu.
9513
+ *
9514
+ * Opening a channel is the ROW's job now, so ⋯ carries what is left: the
9515
+ * secondary and destructive lifecycle actions. It is rendered through the same
9516
+ * scrim primitive as every other sheet, which is what gives it a focus trap,
9517
+ * Escape, and a name — a bare absolutely-positioned popup would have had none
9518
+ * of those. Archive keeps its own confirmation dialog; this menu never
9519
+ * destroys anything by itself.
9520
+ */
9521
+ renderMenuDialog(state) {
9522
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
9523
+ if (!channel || !this.caps.manage) {
9524
+ this.closeDialog();
9525
+ return;
9526
+ }
9527
+ const paused = channel.state === "paused";
9528
+ this.renderScrim(`
9529
+ <h3 id="slm-ch-dlg-title">${esc(channel.name)}</h3>
9530
+ <p class="sub">Open the channel to allocate seats and hand out buyer access.
9531
+ These are the rest of its actions.</p>
9532
+ <div class="slm-ch-menu">
9533
+ <button type="button" class="slm-btn" data-ch-menu-act="open">Open channel</button>
9534
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="rename">Rename</button>
9535
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="pause">${paused ? "Resume selling" : "Pause selling"}</button>
9536
+ <button type="button" class="slm-btn ghost" data-ch-menu-act="archive">Archive\u2026</button>
9537
+ </div>
9538
+ <p class="slm-note">Pausing stops new buyer access; checkouts already running can finish.
9539
+ Archiving closes the channel for good and returns its free seats to a destination you choose.
9540
+ Nothing is ever deleted silently.</p>
9541
+ <div class="foot"><button type="button" class="quiet" data-ch-close>Cancel</button></div>`, (dialog) => {
9542
+ dialog.querySelectorAll("[data-ch-menu-act]").forEach((button) => {
9543
+ button.addEventListener("click", () => {
9544
+ const act = button.dataset.chMenuAct;
9545
+ if (act === "open") {
9546
+ this.closeDialog();
9547
+ this.openChannel(channel.id);
9548
+ return;
9549
+ }
9550
+ if (act === "rename") {
9551
+ this.openDialog({ kind: "rename", channelId: channel.id });
9552
+ return;
9553
+ }
9554
+ if (act === "archive") {
9555
+ this.openDialog({ kind: "archive", channelId: channel.id });
9556
+ return;
9557
+ }
9558
+ this.closeDialog();
9559
+ void this.togglePause(channel.id);
9560
+ });
9561
+ });
9562
+ });
9563
+ }
9227
9564
  renderRenameDialog(state) {
9228
9565
  const channel = this.list?.channels.find((item) => item.id === state.channelId);
9229
9566
  if (!channel) {
@@ -9259,34 +9596,74 @@ var ChannelsMode = class {
9259
9596
  });
9260
9597
  }
9261
9598
  /**
9262
- * "Get embed code" — mark the channel as reached through the organizer's own
9263
- * backend. The write itself is `setChannelAccessIntent(…, 'server')`, the same
9264
- * API the old picker called; the difference is that it is now a deliberate
9265
- * action with a consequence the organizer is told about, rather than one of
9266
- * four dropdown values with no observable effect.
9599
+ * "Use a website or app" — declare the channel's route as `server`.
9600
+ *
9601
+ * This is no longer a flag the Embed page happens to read: since 2026-08-06 it
9602
+ * is what AUTHORIZES `POST /v1/events/:key/buyer-access-sessions` to mint for
9603
+ * this channel at all. Without it, an integration that is otherwise perfectly
9604
+ * wired up gets a 409 on every buyer.
9267
9605
  */
9268
9606
  async chooseWebsiteIntegration() {
9269
9607
  const channelId = this.detailChannelId;
9270
9608
  if (!channelId) return;
9271
9609
  await this.setAccessIntent("server");
9272
9610
  }
9273
- async setAccessIntent(accessIntent) {
9611
+ /**
9612
+ * Declare this channel's sale route.
9613
+ *
9614
+ * Reports through the toast lane the rest of the rail's direct actions use.
9615
+ * The two enforcement refusals get real answers rather than a generic failure:
9616
+ * `channel_intent_switch_blocked` opens the review sheet (there is a decision
9617
+ * to make, and a sheet is where decisions live), and
9618
+ * `channel_access_intent_forbids` — which the organizer can hit by racing
9619
+ * their own second tab — says which route is in the way.
9620
+ */
9621
+ async setAccessIntent(accessIntent, opts = {}) {
9274
9622
  const channelId = this.detailChannelId;
9275
- if (!channelId || !this.caps.manage) return;
9623
+ if (!channelId || !this.caps.manage) return false;
9276
9624
  try {
9277
- await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, accessIntent);
9625
+ const result = await this.host.api.setChannelAccessIntent(
9626
+ this.host.eventKey,
9627
+ channelId,
9628
+ accessIntent,
9629
+ opts
9630
+ );
9278
9631
  await this.refresh();
9279
- if (accessIntent === "server") {
9280
- this.host.toast("Marked for your website. The embed code is on the Embed page.", "ok");
9281
- }
9632
+ this.host.toast(this.intentSavedCopy(accessIntent, result?.intentSwitch), "ok");
9633
+ return true;
9282
9634
  } catch (err) {
9283
- this.host.toast("Couldn't save how buyers reach this channel.", "err");
9635
+ if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
9636
+ this.openDialog({
9637
+ kind: "intentSwitch",
9638
+ channelId,
9639
+ intentTo: accessIntent,
9640
+ switchBlocked: err.details
9641
+ });
9642
+ return false;
9643
+ }
9644
+ if (err instanceof ManageApiError && err.code === "channel_access_intent_forbids") {
9645
+ this.host.toast(intentForbidsCopy(err.details), "err");
9646
+ return false;
9647
+ }
9648
+ this.host.toast("Couldn't change how this channel reaches buyers.", "err");
9284
9649
  this.host.onError(err);
9650
+ return false;
9285
9651
  }
9286
9652
  }
9287
- async togglePause() {
9288
- const channel = this.list?.channels.find((item) => item.id === this.detailChannelId);
9289
- if (!channel) return;
9653
+ /** What just happened, including anything the switch took down with it — the
9654
+ * server reports `intentSwitch` only when it actually disturbed something. */
9655
+ intentSavedCopy(intent, disturbed) {
9656
+ 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.";
9657
+ if (!disturbed) return head;
9658
+ const closed = disturbed.closedLinks ? ` ${disturbed.closedLinks.toLocaleString()} buyer link${disturbed.closedLinks === 1 ? "" : "s"} closed.` : "";
9659
+ const kept = disturbed.keptSessions ? ` ${disturbed.keptSessions.toLocaleString()} buyer${disturbed.keptSessions === 1 ? "" : "s"} already in a checkout can still finish.` : "";
9660
+ return `${head}${closed}${kept}`;
9661
+ }
9662
+ /** `channelId` is explicit because this is reachable from the ⋯ menu on a row
9663
+ * that is NOT the open channel, as well as from the detail panel itself. */
9664
+ async togglePause(channelId = this.detailChannelId) {
9665
+ const channel = this.list?.channels.find((item) => item.id === channelId);
9666
+ if (!channel || !this.caps.manage) return;
9290
9667
  const paused = channel.state !== "paused";
9291
9668
  try {
9292
9669
  await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);
@@ -9534,7 +9911,35 @@ var ChannelsMode = class {
9534
9911
  });
9535
9912
  });
9536
9913
  }
9914
+ /**
9915
+ * DECLARE, then create.
9916
+ *
9917
+ * `createAccessLink` used to set the channel's route to `hosted_link` as a
9918
+ * side effect, which is exactly why the picker could never refuse anything.
9919
+ * The server took that side effect away and now REQUIRES the declaration —
9920
+ * and channels default to `none`, so a create that did not declare first
9921
+ * would 409 on the organizer's very first "Create buyer link".
9922
+ *
9923
+ * So the route is declared here, immediately before the create. It stays ONE
9924
+ * gesture: nothing is declared while the organizer is still filling the form
9925
+ * in (cancelling changes nothing), and if the declaration is the part that is
9926
+ * refused, the review sheet holds this form and finishes the job on the far
9927
+ * side of the acknowledgement.
9928
+ */
9537
9929
  async createLink(channelId, input) {
9930
+ if (!await this.ensureHostedLinkRoute(channelId, input)) return;
9931
+ await this.mintLink(channelId, input);
9932
+ }
9933
+ /**
9934
+ * The create half, on its own.
9935
+ *
9936
+ * Separate from `createLink` because the acknowledge path has ALREADY declared
9937
+ * the route — with the very acknowledgement the plain declaration was refused
9938
+ * for. Sending it back through `ensureHostedLinkRoute` would re-derive the
9939
+ * route from a channel list that has not necessarily caught up, and could
9940
+ * refuse the organizer a second time for a decision they just made.
9941
+ */
9942
+ async mintLink(channelId, input) {
9538
9943
  try {
9539
9944
  const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
9540
9945
  this.revealLink(reveal, { channelId });
@@ -9544,6 +9949,37 @@ var ChannelsMode = class {
9544
9949
  if (!(err instanceof ManageApiError)) this.host.onError(err);
9545
9950
  }
9546
9951
  }
9952
+ /**
9953
+ * Make sure the channel declares the buyer-link route before a link is minted.
9954
+ *
9955
+ * Returns false when the create must NOT proceed — either it was refused, or
9956
+ * the decision has been handed to the switch-review sheet, which resumes it.
9957
+ * A channel already on `hosted_link` costs no request at all, so creating a
9958
+ * second link is the same single call it has always been.
9959
+ */
9960
+ async ensureHostedLinkRoute(channelId, pendingLink) {
9961
+ const channel = this.list?.channels.find((item) => item.id === channelId);
9962
+ if ((channel?.access?.intent ?? "none") === "hosted_link") return true;
9963
+ try {
9964
+ await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, "hosted_link");
9965
+ await this.refresh();
9966
+ return true;
9967
+ } catch (err) {
9968
+ if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
9969
+ this.openDialog({
9970
+ kind: "intentSwitch",
9971
+ channelId,
9972
+ intentTo: "hosted_link",
9973
+ switchBlocked: err.details,
9974
+ pendingLink
9975
+ });
9976
+ return false;
9977
+ }
9978
+ 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.");
9979
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
9980
+ return false;
9981
+ }
9982
+ }
9547
9983
  /**
9548
9984
  * The ONE-TIME reveal.
9549
9985
  *
@@ -12210,6 +12646,7 @@ var SeatManager = class {
12210
12646
  SeatManager,
12211
12647
  SeatPicker,
12212
12648
  SeatingChart,
12649
+ accessIntentDescription,
12213
12650
  accessIntentLabel,
12214
12651
  accessLine,
12215
12652
  accessLinkBadge,
@@ -12222,6 +12659,8 @@ var SeatManager = class {
12222
12659
  createBuyerAccessContext,
12223
12660
  createControllerSink,
12224
12661
  dropReviewRows,
12662
+ intentForbidsCopy,
12663
+ intentSwitchBlockedCopy,
12225
12664
  isPublicChannelId,
12226
12665
  markerLetter,
12227
12666
  markerOf,