@seatlayer/js 0.44.0 → 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(
@@ -7002,10 +7074,14 @@ var CHANNELS_CSS = (
7002
7074
  .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
7003
7075
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
7004
7076
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
7005
- /* 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. */
7006
7079
  .slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
7007
7080
  border-radius:10px;background:var(--slm-surface)}
7008
- .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}
7009
7085
  .slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
7010
7086
  .slm-ch-dist .slm-btn{width:100%;margin-top:2px}
7011
7087
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
@@ -8080,49 +8156,52 @@ var ChannelsMode = class {
8080
8156
  ${lifecycle}`;
8081
8157
  }
8082
8158
  /**
8083
- * "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.
8084
8168
  *
8085
- * This replaced a four-value "access intent" picker (none / internal /
8086
- * hosted_link / server). Two of those values did nothing anywhere: no buyer or
8087
- * inventory path reads `access_intent`, so `none` and `internal` were labels an
8088
- * organizer could set and then wait forever for something to happen. A third,
8089
- * `hosted_link`, is not the organizer's to choose at all — the server sets it
8090
- * 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.
8091
8172
  *
8092
- * So this is two ACTIONS, not a setting: create a buyer link, or point the
8093
- * channel at a website integration. Both of them do something the moment they
8094
- * are pressed. A legacy row still carrying `none` or `internal` renders the
8095
- * neutral "not distributed yet" state with both actions offered — the stored
8096
- * value is left alone (it is organizer-declared metadata and the API that
8097
- * 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.
8098
8177
  */
8099
8178
  distributeHtml(channel) {
8100
8179
  const intent = channel.access?.intent ?? "none";
8101
8180
  const live = channel.access?.hasActiveGrants === true;
8102
- 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.";
8103
- const option = (title, why, action, cta, primary) => `<div class="slm-ch-dist">
8104
- <b>${esc(title)}</b>
8105
- <span class="why">${esc(why)}</span>
8106
- <button type="button" class="slm-btn${primary ? "" : " ghost"}" data-ch-act="${esc(action)}">${esc(cta)}</button>
8107
- </div>`;
8108
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
+ };
8109
8193
  return `
8110
8194
  <p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
8111
8195
  <p class="slm-hint">${esc(state)}</p>
8112
8196
  ${linksSupported ? option(
8113
- "Sell with a buyer link",
8114
- "SeatLayer makes a link you send to a named group. They open it and buy only these seats.",
8197
+ "hosted_link",
8115
8198
  "link-create",
8116
- this.links.some(accessLinkIsLive) ? "Create another buyer link" : "Create buyer link",
8199
+ hasLiveLink ? "Create another buyer link" : "Create buyer link",
8117
8200
  true
8118
8201
  ) : ""}
8119
- ${option(
8120
- "Integrate a website or app",
8121
- "Your website's backend grants each buyer access \u2014 set up on the Embed page.",
8122
- "embed-code",
8123
- "Get embed code",
8124
- false
8125
- )}
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)}
8126
8205
  ${this.hostedLinksHtml()}
8127
8206
  ${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
8128
8207
  }
@@ -8370,16 +8449,25 @@ var ChannelsMode = class {
8370
8449
  this.linksState = "idle";
8371
8450
  this.paintRail();
8372
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.
8373
8455
  case "link-create":
8374
8456
  this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
8375
8457
  break;
8376
8458
  // The one thing this screen can actually do for a website integration is
8377
- // record that the channel is meant for one — which is exactly the flag the
8378
- // dashboard's Embed page reads before it offers the snippet. Saying so is
8379
- // 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.
8380
8462
  case "embed-code":
8381
8463
  void this.chooseWebsiteIntegration();
8382
8464
  break;
8465
+ case "route-internal":
8466
+ void this.setAccessIntent("internal");
8467
+ break;
8468
+ case "route-none":
8469
+ void this.setAccessIntent("none");
8470
+ break;
8383
8471
  case "link-reload":
8384
8472
  if (this.detailChannelId) void this.reloadLinks();
8385
8473
  break;
@@ -8686,6 +8774,77 @@ var ChannelsMode = class {
8686
8774
  else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
8687
8775
  else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
8688
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);
8689
8848
  }
8690
8849
  /**
8691
8850
  * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
@@ -9026,31 +9185,69 @@ var ChannelsMode = class {
9026
9185
  });
9027
9186
  }
9028
9187
  /**
9029
- * "Get embed code" — mark the channel as reached through the organizer's own
9030
- * backend. The write itself is `setChannelAccessIntent(…, 'server')`, the same
9031
- * API the old picker called; the difference is that it is now a deliberate
9032
- * action with a consequence the organizer is told about, rather than one of
9033
- * 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.
9034
9194
  */
9035
9195
  async chooseWebsiteIntegration() {
9036
9196
  const channelId = this.detailChannelId;
9037
9197
  if (!channelId) return;
9038
9198
  await this.setAccessIntent("server");
9039
9199
  }
9040
- 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 = {}) {
9041
9211
  const channelId = this.detailChannelId;
9042
- if (!channelId || !this.caps.manage) return;
9212
+ if (!channelId || !this.caps.manage) return false;
9043
9213
  try {
9044
- 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
+ );
9045
9220
  await this.refresh();
9046
- if (accessIntent === "server") {
9047
- this.host.toast("Marked for your website. The embed code is on the Embed page.", "ok");
9048
- }
9221
+ this.host.toast(this.intentSavedCopy(accessIntent, result?.intentSwitch), "ok");
9222
+ return true;
9049
9223
  } catch (err) {
9050
- 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");
9051
9238
  this.host.onError(err);
9239
+ return false;
9052
9240
  }
9053
9241
  }
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
+ }
9054
9251
  /** `channelId` is explicit because this is reachable from the ⋯ menu on a row
9055
9252
  * that is NOT the open channel, as well as from the detail panel itself. */
9056
9253
  async togglePause(channelId = this.detailChannelId) {
@@ -9303,7 +9500,35 @@ var ChannelsMode = class {
9303
9500
  });
9304
9501
  });
9305
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
+ */
9306
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) {
9307
9532
  try {
9308
9533
  const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
9309
9534
  this.revealLink(reveal, { channelId });
@@ -9313,6 +9538,37 @@ var ChannelsMode = class {
9313
9538
  if (!(err instanceof ManageApiError)) this.host.onError(err);
9314
9539
  }
9315
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
+ }
9316
9572
  /**
9317
9573
  * The ONE-TIME reveal.
9318
9574
  *
@@ -11978,6 +12234,7 @@ export {
11978
12234
  SeatManager,
11979
12235
  SeatPicker,
11980
12236
  SeatingChart,
12237
+ accessIntentDescription,
11981
12238
  accessIntentLabel,
11982
12239
  accessLine,
11983
12240
  accessLinkBadge,
@@ -11990,6 +12247,8 @@ export {
11990
12247
  createBuyerAccessContext,
11991
12248
  createControllerSink,
11992
12249
  dropReviewRows,
12250
+ intentForbidsCopy,
12251
+ intentSwitchBlockedCopy,
11993
12252
  isPublicChannelId,
11994
12253
  markerLetter,
11995
12254
  markerOf,