@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.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(
@@ -7410,10 +7485,14 @@ var CHANNELS_CSS = (
7410
7485
  .slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
7411
7486
  .slm-ch-row2{display:flex;gap:8px;margin-top:8px}
7412
7487
  .slm-ch-row2 .slm-btn{flex:1;min-width:0}
7413
- /* 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. */
7414
7490
  .slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
7415
7491
  border-radius:10px;background:var(--slm-surface)}
7416
- .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}
7417
7496
  .slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
7418
7497
  .slm-ch-dist .slm-btn{width:100%;margin-top:2px}
7419
7498
  .slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
@@ -8488,49 +8567,52 @@ var ChannelsMode = class {
8488
8567
  ${lifecycle}`;
8489
8568
  }
8490
8569
  /**
8491
- * "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.
8492
8579
  *
8493
- * This replaced a four-value "access intent" picker (none / internal /
8494
- * hosted_link / server). Two of those values did nothing anywhere: no buyer or
8495
- * inventory path reads `access_intent`, so `none` and `internal` were labels an
8496
- * organizer could set and then wait forever for something to happen. A third,
8497
- * `hosted_link`, is not the organizer's to choose at all — the server sets it
8498
- * 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.
8499
8583
  *
8500
- * So this is two ACTIONS, not a setting: create a buyer link, or point the
8501
- * channel at a website integration. Both of them do something the moment they
8502
- * are pressed. A legacy row still carrying `none` or `internal` renders the
8503
- * neutral "not distributed yet" state with both actions offered — the stored
8504
- * value is left alone (it is organizer-declared metadata and the API that
8505
- * 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.
8506
8588
  */
8507
8589
  distributeHtml(channel) {
8508
8590
  const intent = channel.access?.intent ?? "none";
8509
8591
  const live = channel.access?.hasActiveGrants === true;
8510
- 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.";
8511
- const option = (title, why, action, cta, primary) => `<div class="slm-ch-dist">
8512
- <b>${esc(title)}</b>
8513
- <span class="why">${esc(why)}</span>
8514
- <button type="button" class="slm-btn${primary ? "" : " ghost"}" data-ch-act="${esc(action)}">${esc(cta)}</button>
8515
- </div>`;
8516
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
+ };
8517
8604
  return `
8518
8605
  <p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
8519
8606
  <p class="slm-hint">${esc(state)}</p>
8520
8607
  ${linksSupported ? option(
8521
- "Sell with a buyer link",
8522
- "SeatLayer makes a link you send to a named group. They open it and buy only these seats.",
8608
+ "hosted_link",
8523
8609
  "link-create",
8524
- this.links.some(accessLinkIsLive) ? "Create another buyer link" : "Create buyer link",
8610
+ hasLiveLink ? "Create another buyer link" : "Create buyer link",
8525
8611
  true
8526
8612
  ) : ""}
8527
- ${option(
8528
- "Integrate a website or app",
8529
- "Your website's backend grants each buyer access \u2014 set up on the Embed page.",
8530
- "embed-code",
8531
- "Get embed code",
8532
- false
8533
- )}
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)}
8534
8616
  ${this.hostedLinksHtml()}
8535
8617
  ${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
8536
8618
  }
@@ -8778,16 +8860,25 @@ var ChannelsMode = class {
8778
8860
  this.linksState = "idle";
8779
8861
  this.paintRail();
8780
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.
8781
8866
  case "link-create":
8782
8867
  this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
8783
8868
  break;
8784
8869
  // The one thing this screen can actually do for a website integration is
8785
- // record that the channel is meant for one — which is exactly the flag the
8786
- // dashboard's Embed page reads before it offers the snippet. Saying so is
8787
- // 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.
8788
8873
  case "embed-code":
8789
8874
  void this.chooseWebsiteIntegration();
8790
8875
  break;
8876
+ case "route-internal":
8877
+ void this.setAccessIntent("internal");
8878
+ break;
8879
+ case "route-none":
8880
+ void this.setAccessIntent("none");
8881
+ break;
8791
8882
  case "link-reload":
8792
8883
  if (this.detailChannelId) void this.reloadLinks();
8793
8884
  break;
@@ -9094,6 +9185,77 @@ var ChannelsMode = class {
9094
9185
  else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
9095
9186
  else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
9096
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);
9097
9259
  }
9098
9260
  /**
9099
9261
  * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
@@ -9434,31 +9596,69 @@ var ChannelsMode = class {
9434
9596
  });
9435
9597
  }
9436
9598
  /**
9437
- * "Get embed code" — mark the channel as reached through the organizer's own
9438
- * backend. The write itself is `setChannelAccessIntent(…, 'server')`, the same
9439
- * API the old picker called; the difference is that it is now a deliberate
9440
- * action with a consequence the organizer is told about, rather than one of
9441
- * 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.
9442
9605
  */
9443
9606
  async chooseWebsiteIntegration() {
9444
9607
  const channelId = this.detailChannelId;
9445
9608
  if (!channelId) return;
9446
9609
  await this.setAccessIntent("server");
9447
9610
  }
9448
- 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 = {}) {
9449
9622
  const channelId = this.detailChannelId;
9450
- if (!channelId || !this.caps.manage) return;
9623
+ if (!channelId || !this.caps.manage) return false;
9451
9624
  try {
9452
- 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
+ );
9453
9631
  await this.refresh();
9454
- if (accessIntent === "server") {
9455
- this.host.toast("Marked for your website. The embed code is on the Embed page.", "ok");
9456
- }
9632
+ this.host.toast(this.intentSavedCopy(accessIntent, result?.intentSwitch), "ok");
9633
+ return true;
9457
9634
  } catch (err) {
9458
- 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");
9459
9649
  this.host.onError(err);
9650
+ return false;
9460
9651
  }
9461
9652
  }
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
+ }
9462
9662
  /** `channelId` is explicit because this is reachable from the ⋯ menu on a row
9463
9663
  * that is NOT the open channel, as well as from the detail panel itself. */
9464
9664
  async togglePause(channelId = this.detailChannelId) {
@@ -9711,7 +9911,35 @@ var ChannelsMode = class {
9711
9911
  });
9712
9912
  });
9713
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
+ */
9714
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) {
9715
9943
  try {
9716
9944
  const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
9717
9945
  this.revealLink(reveal, { channelId });
@@ -9721,6 +9949,37 @@ var ChannelsMode = class {
9721
9949
  if (!(err instanceof ManageApiError)) this.host.onError(err);
9722
9950
  }
9723
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
+ }
9724
9983
  /**
9725
9984
  * The ONE-TIME reveal.
9726
9985
  *
@@ -12387,6 +12646,7 @@ var SeatManager = class {
12387
12646
  SeatManager,
12388
12647
  SeatPicker,
12389
12648
  SeatingChart,
12649
+ accessIntentDescription,
12390
12650
  accessIntentLabel,
12391
12651
  accessLine,
12392
12652
  accessLinkBadge,
@@ -12399,6 +12659,8 @@ var SeatManager = class {
12399
12659
  createBuyerAccessContext,
12400
12660
  createControllerSink,
12401
12661
  dropReviewRows,
12662
+ intentForbidsCopy,
12663
+ intentSwitchBlockedCopy,
12402
12664
  isPublicChannelId,
12403
12665
  markerLetter,
12404
12666
  markerOf,