@seatlayer/js 0.36.1 → 0.36.3

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
@@ -38,6 +38,7 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
38
38
  // src/index.ts
39
39
  var index_exports = {};
40
40
  __export(index_exports, {
41
+ ACCESS_LINK_DEFAULTS: () => ACCESS_LINK_DEFAULTS,
41
42
  ApiError: () => ApiError,
42
43
  BuyerAccessContext: () => BuyerAccessContext,
43
44
  BuyerAccessUnavailableError: () => BuyerAccessUnavailableError,
@@ -53,12 +54,18 @@ __export(index_exports, {
53
54
  SeatingChart: () => SeatingChart,
54
55
  accessIntentLabel: () => accessIntentLabel,
55
56
  accessLine: () => accessLine,
57
+ accessLinkBadge: () => accessLinkBadge,
58
+ accessLinkErrorCopy: () => accessLinkErrorCopy,
59
+ accessLinkIsLive: () => accessLinkIsLive,
60
+ accessLinkPolicyLines: () => accessLinkPolicyLines,
56
61
  attachPickerFrame: () => attachPickerFrame,
57
62
  bucketRows: () => bucketRows,
58
63
  bucketRowsHtml: () => bucketRowsHtml,
59
64
  createBuyerAccessContext: () => createBuyerAccessContext,
60
65
  createControllerSink: () => createControllerSink,
61
66
  dropReviewRows: () => dropReviewRows,
67
+ isPublicChannelId: () => isPublicChannelId,
68
+ markerLetter: () => markerLetter,
62
69
  markerOf: () => markerOf,
63
70
  mutationCount: () => mutationCount,
64
71
  needsMoveConfirmation: () => needsMoveConfirmation,
@@ -5989,8 +5996,11 @@ function attachPickerFrame(iframe, opts = {}) {
5989
5996
  var import_core3 = require("@seatlayer/core");
5990
5997
 
5991
5998
  // src/channelPlan.ts
5992
- var PUBLIC_CHANNEL_ID = "";
5999
+ var PUBLIC_CHANNEL_ID = "public";
5993
6000
  var PUBLIC_CHANNEL_NAME = "Public sale";
6001
+ function isPublicChannelId(id) {
6002
+ return id == null || id === "" || id === PUBLIC_CHANNEL_ID;
6003
+ }
5994
6004
  var CHANNEL_COLORS = [
5995
6005
  "#a78bfa",
5996
6006
  "#2dd4bf",
@@ -6005,27 +6015,35 @@ var CHANNEL_COLORS = [
6005
6015
  ];
6006
6016
  var PUBLIC_CHANNEL_COLOR = "#f4b740";
6007
6017
  var LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
6018
+ function markerLetter(raw, fallback) {
6019
+ const text = (raw ?? "").trim();
6020
+ const letter = /\p{L}/u.exec(text)?.[0] ?? text[0] ?? "";
6021
+ return (letter || fallback).toUpperCase().slice(0, 1);
6022
+ }
6008
6023
  function suggestMarker(name, taken) {
6009
- const used = new Set([...taken].map((m) => m.trim().toUpperCase()).filter(Boolean));
6010
- const first = (name.trim()[0] ?? "").toUpperCase();
6024
+ const used = new Set([...taken].map((m) => markerLetter(m, "")).filter(Boolean));
6025
+ const first = markerLetter(name, "");
6011
6026
  const letter = LETTERS.includes(first) && !used.has(first) ? first : [...LETTERS].find((candidate) => !used.has(candidate)) ?? (first || "X");
6012
6027
  return { letter, color: CHANNEL_COLORS[used.size % CHANNEL_COLORS.length] };
6013
6028
  }
6014
6029
  function markerOf(channel, index = 0) {
6015
- if (channel.id === PUBLIC_CHANNEL_ID) {
6016
- return { letter: (channel.marker || "P").slice(0, 2).toUpperCase(), color: channel.color || PUBLIC_CHANNEL_COLOR };
6030
+ if (isPublicChannelId(channel.id)) {
6031
+ return {
6032
+ letter: markerLetter(channel.marker, "P"),
6033
+ color: channel.color || PUBLIC_CHANNEL_COLOR
6034
+ };
6017
6035
  }
6018
- const letter = (channel.marker || channel.name.trim()[0] || "?").slice(0, 2).toUpperCase();
6036
+ const letter = markerLetter(channel.marker || channel.name, "?");
6019
6037
  return { letter, color: channel.color || CHANNEL_COLORS[index % CHANNEL_COLORS.length] };
6020
6038
  }
6021
6039
  function selectionSources(labels, allocation, list) {
6022
6040
  const counts = /* @__PURE__ */ new Map();
6023
6041
  for (const label of labels) {
6024
- const channelId = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
6042
+ const channelId = normalizeChannelId(allocation.get(label));
6025
6043
  counts.set(channelId, (counts.get(channelId) ?? 0) + 1);
6026
6044
  }
6027
6045
  const order = [
6028
- { id: PUBLIC_CHANNEL_ID, name: list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
6046
+ { id: PUBLIC_CHANNEL_ID, name: list?.publicSale?.name ?? PUBLIC_CHANNEL_NAME },
6029
6047
  ...(list?.channels ?? []).map((channel) => ({ id: channel.id, name: channel.name }))
6030
6048
  ];
6031
6049
  const rows = [];
@@ -6035,10 +6053,17 @@ function selectionSources(labels, allocation, list) {
6035
6053
  counts.delete(entry.id);
6036
6054
  }
6037
6055
  for (const [channelId, count] of counts) {
6038
- rows.push({ channelId, name: channelId ? "Another channel" : PUBLIC_CHANNEL_NAME, count });
6056
+ rows.push({
6057
+ channelId,
6058
+ name: isPublicChannelId(channelId) ? PUBLIC_CHANNEL_NAME : "Another channel",
6059
+ count
6060
+ });
6039
6061
  }
6040
6062
  return rows;
6041
6063
  }
6064
+ function normalizeChannelId(id) {
6065
+ return isPublicChannelId(id) ? PUBLIC_CHANNEL_ID : id;
6066
+ }
6042
6067
  var SKIP_SAMPLE = 12;
6043
6068
  function skipBucket(labels) {
6044
6069
  return {
@@ -6048,7 +6073,8 @@ function skipBucket(labels) {
6048
6073
  };
6049
6074
  }
6050
6075
  function planAssignment(input) {
6051
- const { labels, targetChannelId, allocation, statusOf, nameOf } = input;
6076
+ const { labels, allocation, statusOf, nameOf } = input;
6077
+ const targetChannelId = normalizeChannelId(input.targetChannelId);
6052
6078
  const seen = /* @__PURE__ */ new Set();
6053
6079
  let fromPublic = 0;
6054
6080
  let alreadyIn = 0;
@@ -6064,7 +6090,7 @@ function planAssignment(input) {
6064
6090
  missing.push(label);
6065
6091
  continue;
6066
6092
  }
6067
- const current = allocation.get(label) ?? PUBLIC_CHANNEL_ID;
6093
+ const current = normalizeChannelId(allocation.get(label));
6068
6094
  if (current === targetChannelId) {
6069
6095
  alreadyIn += 1;
6070
6096
  continue;
@@ -6185,6 +6211,78 @@ function accessLine(access) {
6185
6211
  function accessIntentLabel(intent) {
6186
6212
  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";
6187
6213
  }
6214
+ var ACCESS_LINK_DEFAULTS = {
6215
+ maxRedemptions: 100,
6216
+ maxQuantity: 4
6217
+ };
6218
+ function accessLinkBadge(link) {
6219
+ switch (link.status ?? link.state) {
6220
+ case "active":
6221
+ return { text: "Active", kind: "active" };
6222
+ case "expired":
6223
+ return { text: "Expired", kind: "archived" };
6224
+ case "exhausted":
6225
+ return { text: "All used", kind: "paused" };
6226
+ case "rotated":
6227
+ return { text: "Replaced", kind: "archived" };
6228
+ default:
6229
+ return { text: "Revoked", kind: "archived" };
6230
+ }
6231
+ }
6232
+ function accessLinkIsLive(link) {
6233
+ return link.state === "active" && link.status === "active";
6234
+ }
6235
+ function formatMoment(ms) {
6236
+ if (!Number.isFinite(ms)) return "\u2014";
6237
+ return new Date(ms).toLocaleString(void 0, {
6238
+ day: "numeric",
6239
+ month: "short",
6240
+ year: "numeric",
6241
+ hour: "numeric",
6242
+ minute: "2-digit"
6243
+ });
6244
+ }
6245
+ function accessLinkPolicyLines(link) {
6246
+ return [
6247
+ { k: "Expires", v: formatMoment(link.expiresAt) },
6248
+ {
6249
+ k: "Redemptions",
6250
+ v: `${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} used`
6251
+ },
6252
+ {
6253
+ k: "Seats per buyer",
6254
+ v: `${link.maxQuantity.toLocaleString()} seat${link.maxQuantity === 1 ? "" : "s"} maximum`
6255
+ },
6256
+ {
6257
+ k: "Covers",
6258
+ v: link.includePublic ? "This channel's allocation and Public sale seats" : "This channel's allocation only"
6259
+ }
6260
+ ];
6261
+ }
6262
+ function accessLinkErrorCopy(err) {
6263
+ const fromServer = err?.serverMessage?.trim();
6264
+ switch (err?.code) {
6265
+ case "invalid_expiry":
6266
+ case "invalid_max_redemptions":
6267
+ case "invalid_max_quantity":
6268
+ case "invalid_session_ttl":
6269
+ case "invalid_label":
6270
+ return fromServer || "That setting is outside what a hosted link allows. Adjust it and try again.";
6271
+ case "too_many_access_links":
6272
+ return fromServer || "This channel already has as many live links as it can hold. Revoke one before creating another.";
6273
+ case "access_link_not_active":
6274
+ return "That link is no longer active, so it cannot be rotated or revoked.";
6275
+ case "channel_unavailable":
6276
+ return "This channel is paused or archived, so it cannot let new buyers in. Resume it first.";
6277
+ case "end_active_sessions_required":
6278
+ return "Choose what happens to the buyers who already came in through this link.";
6279
+ case "not_found":
6280
+ return "That link is no longer here. Refresh and try again.";
6281
+ default:
6282
+ if (err?.status === 403) return "Hosted access links need channel-management permission.";
6283
+ return fromServer || "That did not go through. Try again.";
6284
+ }
6285
+ }
6188
6286
  function dropReviewRows(details) {
6189
6287
  return (details?.channels ?? []).map((channel) => ({
6190
6288
  kind: "skip",
@@ -6201,13 +6299,14 @@ function stateBadge(state) {
6201
6299
 
6202
6300
  // src/manageApi.ts
6203
6301
  var ManageApiError = class extends Error {
6204
- constructor(status, message, code, conflicts, details) {
6302
+ constructor(status, message, code, conflicts, details, serverMessage) {
6205
6303
  super(message);
6206
6304
  this.name = "ManageApiError";
6207
6305
  this.status = status;
6208
6306
  this.code = code;
6209
6307
  this.conflicts = conflicts;
6210
6308
  this.details = details;
6309
+ this.serverMessage = serverMessage;
6211
6310
  }
6212
6311
  };
6213
6312
  async function parse(res) {
@@ -6220,7 +6319,8 @@ async function parse(res) {
6220
6319
  err?.error ?? `request_failed_${res.status}`,
6221
6320
  err?.code,
6222
6321
  err?.conflicts,
6223
- err?.details
6322
+ err?.details,
6323
+ typeof err?.message === "string" ? err.message : void 0
6224
6324
  );
6225
6325
  }
6226
6326
  return data;
@@ -6247,12 +6347,38 @@ var ManageApi = class {
6247
6347
  pub(path) {
6248
6348
  return fetch(`${this.base}${path}`, { credentials: "omit" }).then((r) => parse(r));
6249
6349
  }
6250
- // ---- realtime read (public, no token) ----
6350
+ // ---- realtime read ----
6351
+ /** The chart geometry. Genuinely public — it is the same map buyers see. */
6251
6352
  chart(key) {
6252
6353
  return this.pub(`/pub/events/${encodeURIComponent(key)}/chart`);
6253
6354
  }
6355
+ /**
6356
+ * The ORGANIZER's seat map: physical state, token-authed.
6357
+ *
6358
+ * This used to read `/pub/events/:key/objects` with no credential, which
6359
+ * answers with the BUYER projection — every unit the caller may not buy
6360
+ * collapses to a neutral `blocked`. An anonymous caller may buy only Public
6361
+ * sale inventory, so the cockpit rendered every channel-allocated seat as
6362
+ * blocked and then computed its KPIs, sell-through and (worse) its
6363
+ * block/unblock target sets from that. `/v1/events/:key/objects` returns the
6364
+ * unprojected snapshot the control-room read model already trusts.
6365
+ */
6254
6366
  objects(key) {
6255
- return this.pub(`/pub/events/${encodeURIComponent(key)}/objects`);
6367
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/objects`);
6368
+ }
6369
+ /**
6370
+ * Exchange the manage token for a one-use organizer socket ticket.
6371
+ *
6372
+ * A browser `WebSocket` cannot send an Authorization header, so the socket's
6373
+ * scope is established here, over ordinary HTTPS. Without it the DO treats a
6374
+ * manager socket as an anonymous public buyer and projects its deltas — so a
6375
+ * hold inside a private allocation is structurally suppressed and the map
6376
+ * drifts away from the truth `objects()` just established.
6377
+ *
6378
+ * Tickets are single-redemption and expire in ~30s: mint one per connect.
6379
+ */
6380
+ subscribeTicket(key) {
6381
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/subscribe-tickets`, { method: "POST" });
6256
6382
  }
6257
6383
  socketUrl(key) {
6258
6384
  return `${this.base.replace(/^http/, "ws")}/pub/events/${encodeURIComponent(key)}/subscribe?surface=manager`;
@@ -6390,6 +6516,58 @@ var ManageApi = class {
6390
6516
  body: { accessIntent }
6391
6517
  });
6392
6518
  }
6519
+ // ---- hosted access links (M8) ----
6520
+ /**
6521
+ * Mint a hosted access link. The 201 is the ONE and ONLY time `url` and
6522
+ * `capability` exist outside the buyer's browser — SeatLayer keeps a hash, so
6523
+ * there is no route, cache, or support escalation that can produce this string
6524
+ * again. Callers must reveal it immediately and then let it go.
6525
+ *
6526
+ * Every omitted field takes the server's default: expiry = when the event
6527
+ * starts, 100 redemptions, 4 seats per buyer, this channel's allocation only.
6528
+ * Platform bounds are enforced server-side and reported as 422 with the rule
6529
+ * spelled out in `ManageApiError.serverMessage`.
6530
+ *
6531
+ * Side effect by design: this also declares the channel's access intent as
6532
+ * `hosted_link`, so the rail stops saying "no buyer access configured".
6533
+ */
6534
+ createAccessLink(key, channelId, input = {}) {
6535
+ return this.auth(
6536
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`,
6537
+ { method: "POST", body: input }
6538
+ );
6539
+ }
6540
+ /** Status only — label, expiry, redemptions, per-buyer cap, lineage, and the
6541
+ * live session count. Never the url, never the capability. Needs `:view`. */
6542
+ accessLinks(key, channelId) {
6543
+ return this.auth(
6544
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`
6545
+ );
6546
+ }
6547
+ /**
6548
+ * Rotate — the ONLY recovery for a link nobody kept. The old URL stops opening
6549
+ * immediately and the response is a fresh one-time reveal.
6550
+ *
6551
+ * `endActiveSessions` is REQUIRED, not defaulted: the organizer must say
6552
+ * whether buyers already inside finish their checkout or lose access now. The
6553
+ * server answers 422 `end_active_sessions_required` if it is omitted, and that
6554
+ * refusal is correct — a UI must not pick either branch on their behalf.
6555
+ */
6556
+ rotateAccessLink(key, channelId, linkId, endActiveSessions) {
6557
+ return this.auth(
6558
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}/rotate`,
6559
+ { method: "POST", body: { endActiveSessions } }
6560
+ );
6561
+ }
6562
+ /** Revoke. The link stops opening immediately; `endActiveSessions` decides
6563
+ * whether the buyers already inside keep their sessions. */
6564
+ revokeAccessLink(key, channelId, linkId, endActiveSessions = false) {
6565
+ const qs = endActiveSessions ? "?endActiveSessions=1" : "";
6566
+ return this.auth(
6567
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}${qs}`,
6568
+ { method: "DELETE" }
6569
+ );
6570
+ }
6393
6571
  // ---- reports (token) ----
6394
6572
  report(key) {
6395
6573
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
@@ -6542,6 +6720,26 @@ var CHANNELS_CSS = `
6542
6720
  border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
6543
6721
  overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
6544
6722
  .slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
6723
+
6724
+ /* hosted access links \u2014 STATUS only; there is no Copy control on this card */
6725
+ .slm-ch-link{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6726
+ margin-bottom:8px}
6727
+ .slm-ch-link .lk-head{display:flex;align-items:center;gap:8px}
6728
+ .slm-ch-link .lk-name{flex:1;min-width:0;font-size:12.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;
6729
+ white-space:nowrap}
6730
+ .slm-ch-lkrow{display:flex;gap:8px;margin-top:5px;font-size:11px;color:var(--slm-muted)}
6731
+ .slm-ch-lkrow .k{flex:none;min-width:104px}
6732
+ .slm-ch-lkrow .v{color:var(--slm-text);font-variant-numeric:tabular-nums}
6733
+ .slm-ch-meter{height:5px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden;margin-top:8px}
6734
+ .slm-ch-meter i{display:block;height:100%;background:var(--slm-accent);
6735
+ transition:width var(--slm-mo-base) var(--slm-mo-out)}
6736
+ .slm-ch-radio{display:flex;gap:9px;align-items:flex-start;padding:11px 12px;border:1px solid var(--slm-line);
6737
+ border-radius:10px;margin-top:8px;font-size:12.5px;cursor:pointer;
6738
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
6739
+ .slm-ch-radio:hover{border-color:var(--slm-muted)}
6740
+ .slm-ch-radio input{flex:none;margin-top:2px}
6741
+ .slm-ch-radio b{display:block;font-weight:800;margin-bottom:2px}
6742
+ .slm-ch-radio .why{display:block;color:var(--slm-muted);font-size:11.5px;line-height:1.45}
6545
6743
  .slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
6546
6744
  background:var(--slm-surface);margin-top:10px}
6547
6745
  .slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
@@ -6574,7 +6772,8 @@ var CHANNELS_CSS = `
6574
6772
  .slm.compact .slm-modes{display:none}
6575
6773
 
6576
6774
  @media (prefers-reduced-motion:reduce){
6577
- .slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail{transition:none!important}
6775
+ .slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail,
6776
+ .slm-ch-meter i,.slm-ch-radio{transition:none!important}
6578
6777
  .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
6579
6778
  .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
6580
6779
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
@@ -6590,9 +6789,37 @@ function bucketRowsHtml(rows) {
6590
6789
  ${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
6591
6790
  </div>`).join("");
6592
6791
  }
6792
+ var SERVER_INTEGRATION_HTML = `
6793
+ <p class="slm-eyebrow" style="margin-top:18px">Server integration</p>
6794
+ <p class="slm-hint">There is nothing to set up on this screen. Your own server mints a short-lived buyer
6795
+ access session for this channel with the SeatLayer server SDK and hands it to the widget. A channel name
6796
+ on its own never grants access.</p>
6797
+ <p class="slm-note"><a class="slm-linkbtn" href="https://docs.seatlayer.io/server-api/channels"
6798
+ target="_blank" rel="noreferrer noopener">Read the server integration guide \u2192</a></p>`;
6593
6799
  function esc(value) {
6594
6800
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
6595
6801
  }
6802
+ function datetimeLocalValue(ms) {
6803
+ const local = new Date(ms - new Date(ms).getTimezoneOffset() * 6e4);
6804
+ return local.toISOString().slice(0, 16);
6805
+ }
6806
+ function intField(root, selector) {
6807
+ const raw = root.querySelector(selector)?.value.trim() ?? "";
6808
+ const value = Number(raw);
6809
+ return raw !== "" && Number.isInteger(value) ? value : null;
6810
+ }
6811
+ function selectSecret(dialog) {
6812
+ const node = dialog.querySelector("[data-ch-lk-url]");
6813
+ if (!node) return;
6814
+ try {
6815
+ const range = document.createRange();
6816
+ range.selectNodeContents(node);
6817
+ const selection = window.getSelection();
6818
+ selection?.removeAllRanges();
6819
+ selection?.addRange(range);
6820
+ } catch {
6821
+ }
6822
+ }
6596
6823
  var ChannelsMode = class {
6597
6824
  constructor(host, capabilities) {
6598
6825
  this.active = false;
@@ -6609,6 +6836,15 @@ var ChannelsMode = class {
6609
6836
  this.dialog = null;
6610
6837
  this.detent = "medium";
6611
6838
  this.seatListLimit = SEAT_LIST_PAGE;
6839
+ /**
6840
+ * Hosted-link STATUS for the channel whose detail panel is open. This is the
6841
+ * listing projection — it carries no url and no capability, because no route
6842
+ * returns one. `unsupported` is the honest answer for a worker that predates
6843
+ * M8, exactly like the buyer-preview probe.
6844
+ */
6845
+ this.links = [];
6846
+ this.linksChannelId = null;
6847
+ this.linksState = "idle";
6612
6848
  this.previewAudience = [];
6613
6849
  this.previewIncludePublic = false;
6614
6850
  this.previewProjection = null;
@@ -6652,6 +6888,9 @@ var ChannelsMode = class {
6652
6888
  if (this.pollTimer) clearInterval(this.pollTimer);
6653
6889
  this.pollTimer = null;
6654
6890
  this.closeDialog({ restoreFocus: false });
6891
+ this.links = [];
6892
+ this.linksChannelId = null;
6893
+ this.linksState = "idle";
6655
6894
  this.layer?.classList.remove("on");
6656
6895
  this.host.root.classList.remove(
6657
6896
  "ch-mode",
@@ -6723,6 +6962,7 @@ var ChannelsMode = class {
6723
6962
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
6724
6963
  }
6725
6964
  await this.loadAllocation();
6965
+ if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
6726
6966
  this.loading = false;
6727
6967
  if (this.active) {
6728
6968
  this.paintRail();
@@ -6749,7 +6989,7 @@ var ChannelsMode = class {
6749
6989
  limit: 1e3
6750
6990
  });
6751
6991
  for (const row of res.allocations) {
6752
- if (row.channelId && row.channelId !== PUBLIC_CHANNEL_ID) next.set(row.label, row.channelId);
6992
+ if (!isPublicChannelId(row.channelId)) next.set(row.label, row.channelId);
6753
6993
  }
6754
6994
  this.assignmentVersion = res.assignmentVersion;
6755
6995
  if (!res.nextAfterLabel) break;
@@ -6759,8 +6999,13 @@ var ChannelsMode = class {
6759
6999
  }
6760
7000
  // ---- lookups --------------------------------------------------------------
6761
7001
  channelById(id) {
6762
- if (id === PUBLIC_CHANNEL_ID) {
6763
- return { id, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME, marker: "P", color: null };
7002
+ if (isPublicChannelId(id)) {
7003
+ return {
7004
+ id: PUBLIC_CHANNEL_ID,
7005
+ name: this.list?.publicSale?.name ?? PUBLIC_CHANNEL_NAME,
7006
+ marker: "P",
7007
+ color: null
7008
+ };
6764
7009
  }
6765
7010
  const found = this.list?.channels.find((channel) => channel.id === id);
6766
7011
  return found ? { id, name: found.name, marker: found.marker, color: found.color } : null;
@@ -6981,7 +7226,10 @@ var ChannelsMode = class {
6981
7226
  this.setBanner(true, names);
6982
7227
  try {
6983
7228
  this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
6984
- includePublic: this.previewIncludePublic
7229
+ // Naming Public sale as the audience IS asking for public inventory; the
7230
+ // route filters the 'public' sentinel out of `channelIds`, so without
7231
+ // this the request would resolve to an empty scope and 422.
7232
+ includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
6985
7233
  });
6986
7234
  this.previewSupported = true;
6987
7235
  } catch (err) {
@@ -7152,11 +7400,8 @@ var ChannelsMode = class {
7152
7400
  <p class="slm-hint">${channel.access?.hasActiveGrants ? "Buyer access is live. Only this audience can buy from the allocation." : "No buyer access is configured yet. The allocation is protected \u2014 it is not available to Public sale."}</p>
7153
7401
  ${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
7154
7402
  <span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
7155
- <button type="button" class="slm-btn" data-ch-act="hosted-link" disabled
7156
- title="Hosted access links ship in the next milestone">Create hosted access link \xB7 Coming soon</button>
7157
- <div class="slm-ch-row2"><button type="button" class="slm-btn ghost" data-ch-act="server-access" disabled
7158
- title="Guided server setup ships in the next milestone">Configure server integration \xB7 Coming soon</button></div>
7159
- <p class="slm-note">Your own server can already mint buyer access sessions for this channel with the server SDK.</p>` : "";
7403
+ ${this.hostedLinksHtml()}
7404
+ ${SERVER_INTEGRATION_HTML}` : "";
7160
7405
  return `
7161
7406
  <p class="slm-eyebrow">
7162
7407
  <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
@@ -7166,6 +7411,90 @@ var ChannelsMode = class {
7166
7411
  ${access}
7167
7412
  ${lifecycle}`;
7168
7413
  }
7414
+ // ---- hosted access links --------------------------------------------------
7415
+ /**
7416
+ * Read the status projection for the open channel. Never paints — the caller
7417
+ * decides when the rail repaints, so a poll-driven reload does not fight a
7418
+ * user-driven one. A worker without M8 answers 404/405 and gets the honest
7419
+ * "needs a newer server" line rather than an error toast.
7420
+ */
7421
+ async loadLinks(channelId) {
7422
+ if (!this.caps.view) return;
7423
+ if (this.linksChannelId !== channelId) {
7424
+ this.links = [];
7425
+ this.linksChannelId = channelId;
7426
+ this.linksState = "loading";
7427
+ }
7428
+ try {
7429
+ const res = await this.host.api.accessLinks(this.host.eventKey, channelId);
7430
+ if (this.linksChannelId !== channelId) return;
7431
+ this.links = res.links ?? [];
7432
+ this.linksState = "ready";
7433
+ } catch (err) {
7434
+ if (this.linksChannelId !== channelId) return;
7435
+ const status = err instanceof ManageApiError ? err.status : 0;
7436
+ this.links = [];
7437
+ this.linksState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
7438
+ if (this.linksState === "error") this.host.onError(err);
7439
+ }
7440
+ }
7441
+ /**
7442
+ * The hosted-link section of the detail panel.
7443
+ *
7444
+ * STATUS ONLY, by design (comp 06 `hosted`): label, state, expiry,
7445
+ * redemptions, seats per buyer, live sessions. There is no Copy control here
7446
+ * and no field to hang one on — the URL was shown once at creation and cannot
7447
+ * be produced again. Rotation is the recovery path, and it says so.
7448
+ */
7449
+ hostedLinksHtml() {
7450
+ const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Hosted access links</p>`;
7451
+ if (this.linksState === "unsupported") {
7452
+ return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
7453
+ <span><b>Hosted links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
7454
+ }
7455
+ if (this.linksState === "error") {
7456
+ return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7457
+ <span><b>Couldn't load this channel's links.</b>
7458
+ <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
7459
+ }
7460
+ const live = this.links.filter(accessLinkIsLive).length;
7461
+ const cards = this.linksState === "loading" && !this.links.length ? `<div class="slm-empty">Loading links\u2026</div>` : this.links.length ? this.links.map((link) => this.linkCardHtml(link)).join("") : `<p class="slm-hint">No hosted link yet. Create one to send this allocation to a named group \u2014
7462
+ they open the link and buy only these seats.</p>`;
7463
+ const create = this.caps.manage ? `<button type="button" class="slm-btn" style="width:100%" data-ch-act="link-create">
7464
+ ${live ? "Create another hosted link" : "Create hosted access link"}</button>` : "";
7465
+ return `${eyebrow}
7466
+ ${cards}
7467
+ ${create}
7468
+ <p class="slm-note">A link is shown once, when you create it. SeatLayer keeps only a fingerprint of it, so it
7469
+ can never be shown again \u2014 if a link is lost, rotate it and send the fresh one.</p>`;
7470
+ }
7471
+ linkCardHtml(link) {
7472
+ const badge = accessLinkBadge(link);
7473
+ const used = link.maxRedemptions > 0 ? Math.min(100, Math.round(link.redemptions / link.maxRedemptions * 100)) : 0;
7474
+ const rows = accessLinkPolicyLines(link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
7475
+ <span class="v">${esc(row.v)}</span></div>`).join("");
7476
+ const sessions = link.activeSessions ? `<div class="slm-ch-lkrow"><span class="k">Buyers inside now</span>
7477
+ <span class="v">${link.activeSessions.toLocaleString()}</span></div>` : "";
7478
+ const lastUsed = link.lastRedeemedAt ? `<div class="slm-ch-lkrow"><span class="k">Last opened</span>
7479
+ <span class="v">${esc(new Date(link.lastRedeemedAt).toLocaleString())}</span></div>` : "";
7480
+ const actions = this.caps.manage && accessLinkIsLive(link) ? `<div class="slm-ch-row2">
7481
+ <button type="button" class="slm-btn ghost" data-ch-rotate="${esc(link.id)}">Rotate</button>
7482
+ <button type="button" class="slm-btn ghost" data-ch-revoke="${esc(link.id)}">Revoke</button>
7483
+ </div>` : "";
7484
+ return `<div class="slm-ch-link">
7485
+ <span class="lk-head">
7486
+ <span class="lk-name">${esc(link.label || "Hosted link")}</span>
7487
+ <span class="slm-ch-badge ${badge.kind}">${esc(badge.text)}</span>
7488
+ </span>
7489
+ <div class="slm-ch-meter" role="img"
7490
+ aria-label="${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} redemptions used">
7491
+ <i style="width:${used}%"></i></div>
7492
+ ${rows}${sessions}${lastUsed}
7493
+ <div class="slm-ch-lkrow"><span class="k">The URL</span>
7494
+ <span class="v">Revealed once at creation \u2014 not recoverable</span></div>
7495
+ ${actions}
7496
+ </div>`;
7497
+ }
7169
7498
  previewRailHtml() {
7170
7499
  const audienceOptions = [
7171
7500
  { id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
@@ -7182,7 +7511,7 @@ var ChannelsMode = class {
7182
7511
  const counts = this.previewProjection?.counts;
7183
7512
  const summary = counts?.eligible != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>${counts.eligible.toLocaleString()} seats are buyable
7184
7513
  through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
7185
- const includePublic = current === PUBLIC_CHANNEL_ID ? "" : `
7514
+ const includePublic = isPublicChannelId(current) ? "" : `
7186
7515
  <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
7187
7516
  <input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
7188
7517
  Also include Public sale seats in this grant
@@ -7214,10 +7543,26 @@ var ChannelsMode = class {
7214
7543
  });
7215
7544
  rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
7216
7545
  button.addEventListener("click", () => {
7217
- this.detailChannelId = button.dataset.chDetail;
7546
+ const channelId = button.dataset.chDetail;
7547
+ this.detailChannelId = channelId;
7218
7548
  this.paintRail();
7549
+ void this.loadLinks(channelId).then(() => this.paintRail());
7219
7550
  });
7220
7551
  });
7552
+ rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
7553
+ button.addEventListener("click", () => this.openDialog({
7554
+ kind: "linkRotate",
7555
+ channelId: this.detailChannelId,
7556
+ linkId: button.dataset.chRotate
7557
+ }));
7558
+ });
7559
+ rail.querySelectorAll("[data-ch-revoke]").forEach((button) => {
7560
+ button.addEventListener("click", () => this.openDialog({
7561
+ kind: "linkRevoke",
7562
+ channelId: this.detailChannelId,
7563
+ linkId: button.dataset.chRevoke
7564
+ }));
7565
+ });
7221
7566
  const target = rail.querySelector("[data-ch-target]");
7222
7567
  target?.addEventListener("change", () => {
7223
7568
  this.targetChannelId = target.value;
@@ -7269,8 +7614,17 @@ var ChannelsMode = class {
7269
7614
  break;
7270
7615
  case "back":
7271
7616
  this.detailChannelId = null;
7617
+ this.linksChannelId = null;
7618
+ this.links = [];
7619
+ this.linksState = "idle";
7272
7620
  this.paintRail();
7273
7621
  break;
7622
+ case "link-create":
7623
+ this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
7624
+ break;
7625
+ case "link-reload":
7626
+ if (this.detailChannelId) void this.reloadLinks();
7627
+ break;
7274
7628
  case "retry":
7275
7629
  void this.refresh();
7276
7630
  break;
@@ -7342,6 +7696,9 @@ var ChannelsMode = class {
7342
7696
  else if (state.kind === "archive") this.renderArchiveDialog(state);
7343
7697
  else if (state.kind === "rename") this.renderRenameDialog(state);
7344
7698
  else if (state.kind === "seatlist") this.renderSeatListDialog();
7699
+ else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
7700
+ else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
7701
+ else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
7345
7702
  }
7346
7703
  /**
7347
7704
  * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
@@ -7394,7 +7751,7 @@ var ChannelsMode = class {
7394
7751
  this.lastFocus = null;
7395
7752
  }
7396
7753
  renderCreateDialog(state) {
7397
- const taken = (this.list?.channels ?? []).map((channel) => channel.marker ?? channel.name[0] ?? "");
7754
+ const taken = (this.list?.channels ?? []).map((channel) => markerLetter(channel.marker || channel.name, ""));
7398
7755
  const suggestion = suggestMarker("", taken);
7399
7756
  this.renderScrim(`
7400
7757
  <h3 id="slm-ch-dlg-title">Create channel</h3>
@@ -7452,7 +7809,8 @@ var ChannelsMode = class {
7452
7809
  try {
7453
7810
  const res = await this.host.api.createChannel(this.host.eventKey, {
7454
7811
  name: trimmed,
7455
- marker: letter || null,
7812
+ // The column is free text; we only ever store what the chip can draw.
7813
+ marker: markerLetter(letter, "") || null,
7456
7814
  color: color || null,
7457
7815
  externalRef: externalRef.trim() || null
7458
7816
  });
@@ -7521,7 +7879,9 @@ var ChannelsMode = class {
7521
7879
  this.renderDialog();
7522
7880
  try {
7523
7881
  const result = await this.host.api.applyChannelAssignment(this.host.eventKey, {
7524
- targetChannelId: this.targetChannelId || null,
7882
+ // `null` is the wire spelling of "back to public sale" every worker
7883
+ // accepts, including ones that predate the 'public' sentinel.
7884
+ targetChannelId: isPublicChannelId(this.targetChannelId) ? null : this.targetChannelId,
7525
7885
  labels,
7526
7886
  assignmentVersion: this.assignmentVersion
7527
7887
  });
@@ -7748,6 +8108,284 @@ var ChannelsMode = class {
7748
8108
  });
7749
8109
  });
7750
8110
  }
8111
+ // ---- hosted-link dialogs --------------------------------------------------
8112
+ async reloadLinks() {
8113
+ const channelId = this.detailChannelId;
8114
+ if (!channelId) return;
8115
+ this.linksState = this.links.length ? this.linksState : "loading";
8116
+ await this.loadLinks(channelId);
8117
+ this.paintRail();
8118
+ }
8119
+ linkById(linkId) {
8120
+ return this.links.find((link) => link.id === linkId) ?? null;
8121
+ }
8122
+ /**
8123
+ * Create. The three policy fields carry the owner's defaults and every one of
8124
+ * them is editable; the PLATFORM bounds (60s–180d, 1–10 000, 1–100, 20 live
8125
+ * links) are the server's to enforce and the server's to explain, so this form
8126
+ * checks only that a number is a number and surfaces the server's sentence for
8127
+ * everything else.
8128
+ */
8129
+ renderLinkCreateDialog(state) {
8130
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
8131
+ if (!channel || !this.caps.manage) {
8132
+ this.closeDialog();
8133
+ return;
8134
+ }
8135
+ this.renderScrim(`
8136
+ <h3 id="slm-ch-dlg-title">Create a hosted access link for ${esc(channel.name)}</h3>
8137
+ <p class="sub">Anyone who opens the link can buy from this channel's allocation \u2014 and only from it.
8138
+ You'll see the link once, right after you create it.</p>
8139
+ <div class="slm-field">
8140
+ <label for="slm-ch-lk-label">Label <span style="text-transform:none;font-weight:500">(optional)</span></label>
8141
+ <input class="slm-input" id="slm-ch-lk-label" maxlength="80" placeholder="e.g. VIP list Nov 14" />
8142
+ <p class="slm-note">So you can tell your links apart later. Buyers never see it.</p>
8143
+ </div>
8144
+ <div class="slm-field">
8145
+ <label for="slm-ch-lk-expiry">Stops working</label>
8146
+ <select class="slm-select" id="slm-ch-lk-expiry" data-ch-lk-expiry>
8147
+ <option value="event" selected>When the event starts</option>
8148
+ <option value="custom">On a date I choose</option>
8149
+ </select>
8150
+ </div>
8151
+ <div class="slm-field" data-ch-lk-when-field hidden>
8152
+ <label for="slm-ch-lk-when">Date and time</label>
8153
+ <input class="slm-input" type="datetime-local" id="slm-ch-lk-when" />
8154
+ </div>
8155
+ <div class="slm-field">
8156
+ <label for="slm-ch-lk-redemptions">How many people can use it</label>
8157
+ <input class="slm-input" type="number" id="slm-ch-lk-redemptions" inputmode="numeric"
8158
+ value="${ACCESS_LINK_DEFAULTS.maxRedemptions}" />
8159
+ <p class="slm-note">Each buyer who opens the link uses one.</p>
8160
+ </div>
8161
+ <div class="slm-field">
8162
+ <label for="slm-ch-lk-quantity">Seats per buyer</label>
8163
+ <input class="slm-input" type="number" id="slm-ch-lk-quantity" inputmode="numeric"
8164
+ value="${ACCESS_LINK_DEFAULTS.maxQuantity}" />
8165
+ </div>
8166
+ <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:2px 0 6px">
8167
+ <input type="checkbox" id="slm-ch-lk-public" />
8168
+ Also let this link buy Public sale seats
8169
+ </label>
8170
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8171
+ <div class="foot">
8172
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8173
+ <button type="button" class="slm-btn" data-ch-lk-create>Create link</button>
8174
+ </div>`, (dialog) => {
8175
+ const expiry = dialog.querySelector("[data-ch-lk-expiry]");
8176
+ const whenField = dialog.querySelector("[data-ch-lk-when-field]");
8177
+ const when = dialog.querySelector("#slm-ch-lk-when");
8178
+ expiry.addEventListener("change", () => {
8179
+ const custom = expiry.value === "custom";
8180
+ whenField.hidden = !custom;
8181
+ if (custom && !when.value) when.value = datetimeLocalValue(Date.now() + 7 * 864e5);
8182
+ });
8183
+ dialog.querySelector("[data-ch-lk-create]")?.addEventListener("click", () => {
8184
+ const maxRedemptions = intField(dialog, "#slm-ch-lk-redemptions");
8185
+ const maxQuantity = intField(dialog, "#slm-ch-lk-quantity");
8186
+ if (maxRedemptions == null || maxQuantity == null) {
8187
+ this.showDialogError("Those two settings need to be whole numbers.");
8188
+ return;
8189
+ }
8190
+ let expiresAt;
8191
+ if (expiry.value === "custom") {
8192
+ expiresAt = Date.parse(when.value);
8193
+ if (!Number.isFinite(expiresAt)) {
8194
+ this.showDialogError("Pick the date and time the link should stop working.");
8195
+ return;
8196
+ }
8197
+ }
8198
+ void this.createLink(channel.id, {
8199
+ label: dialog.querySelector("#slm-ch-lk-label")?.value.trim() || null,
8200
+ includePublic: dialog.querySelector("#slm-ch-lk-public")?.checked ?? false,
8201
+ ...expiresAt === void 0 ? {} : { expiresAt },
8202
+ maxRedemptions,
8203
+ maxQuantity
8204
+ });
8205
+ });
8206
+ });
8207
+ }
8208
+ async createLink(channelId, input) {
8209
+ try {
8210
+ const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
8211
+ this.revealLink(reveal, { channelId });
8212
+ await this.refresh({ quiet: true });
8213
+ } catch (err) {
8214
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8215
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8216
+ }
8217
+ }
8218
+ /**
8219
+ * The ONE-TIME reveal.
8220
+ *
8221
+ * Three things make this unrecoverable rather than merely "not shown twice":
8222
+ *
8223
+ * 1. `url` is a local const. It is never assigned to a field on this class,
8224
+ * never handed to the host, never put in a `DialogState`.
8225
+ * 2. `this.dialog` is cleared FIRST, so `renderDialog()` — the only function
8226
+ * that rebuilds a sheet — has nothing to rebuild this one from.
8227
+ * 3. The string exists in exactly one DOM node inside the scrim. Dismissing
8228
+ * the dialog removes the scrim, and the closure goes with it.
8229
+ *
8230
+ * The server holds only a hash, so even a compromised client cannot ask for it
8231
+ * again. Rotation is the recovery path, and the copy says so.
8232
+ */
8233
+ revealLink(reveal, opts) {
8234
+ const url = reveal.url;
8235
+ this.dialog = null;
8236
+ const rotated = opts.rotated ? `<div class="slm-ch-alert warn" role="status"><span>\u26A0</span><span>
8237
+ <b>The old link has stopped working.</b> ${reveal.endedSessions ? `${reveal.endedSessions.toLocaleString()} buyer${reveal.endedSessions === 1 ? "" : "s"} lost access immediately.` : "Buyers who already came in can finish; every new visit needs this link."}</span></div>` : "";
8238
+ const policy = accessLinkPolicyLines(reveal.link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
8239
+ <span class="v">${esc(row.v)}</span></div>`).join("");
8240
+ this.renderScrim(`
8241
+ <h3 id="slm-ch-dlg-title">Copy this link now</h3>
8242
+ <p class="sub">This is the only time SeatLayer can show it. We keep just a fingerprint, so it cannot be
8243
+ shown again \u2014 if it is lost, rotate the link for a fresh one.</p>
8244
+ ${rotated}
8245
+ <div class="slm-ch-secret" data-ch-lk-url>${esc(url)}</div>
8246
+ <div class="slm-ch-row2" style="margin-top:8px">
8247
+ <button type="button" class="slm-btn" data-ch-lk-copy>Copy link</button>
8248
+ </div>
8249
+ <div class="slm-ch-alert warn" style="margin-top:12px"><span>\u26A0</span>
8250
+ <span>Anyone who opens this link can buy from this allocation. Send it only to the people it is meant
8251
+ for \u2014 forwarding it hands on the same access, and SeatLayer cannot tell the difference.</span></div>
8252
+ <p class="slm-eyebrow" style="margin-top:14px">What this link allows</p>
8253
+ ${policy}
8254
+ <div class="foot">
8255
+ <button type="button" class="slm-btn" data-ch-close data-ch-lk-done>I've copied it</button>
8256
+ </div>`, (dialog) => {
8257
+ const copy = dialog.querySelector("[data-ch-lk-copy]");
8258
+ copy?.addEventListener("click", () => {
8259
+ const ok = () => {
8260
+ copy.textContent = "Copied";
8261
+ this.announce("Hosted access link copied.");
8262
+ };
8263
+ const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
8264
+ if (clipboard?.writeText) {
8265
+ clipboard.writeText(url).then(ok, () => selectSecret(dialog));
8266
+ return;
8267
+ }
8268
+ selectSecret(dialog);
8269
+ });
8270
+ dialog.querySelector("[data-ch-lk-done]")?.addEventListener("click", () => {
8271
+ void this.loadLinks(opts.channelId).then(() => this.paintRail());
8272
+ });
8273
+ });
8274
+ this.announce("Your hosted access link is ready and is shown once.");
8275
+ }
8276
+ /**
8277
+ * Rotate. The organizer must SAY what happens to the buyers already inside —
8278
+ * the confirm stays disabled until one of the two choices is picked, because
8279
+ * the gentle branch and the destructive branch are both real decisions and the
8280
+ * server refuses (422 `end_active_sessions_required`) to guess either.
8281
+ */
8282
+ renderLinkRotateDialog(state) {
8283
+ const link = this.linkById(state.linkId);
8284
+ if (!link || !this.caps.manage) {
8285
+ this.closeDialog();
8286
+ return;
8287
+ }
8288
+ const sessions = link.activeSessions ?? 0;
8289
+ const warning = sessions ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
8290
+ <span><b>${sessions.toLocaleString()} buyer${sessions === 1 ? "" : "s"}</b> got in with the current link
8291
+ and still ${sessions === 1 ? "has" : "have"} active access.</span></div>` : "";
8292
+ this.renderScrim(`
8293
+ <h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "hosted")} link?</h3>
8294
+ <p class="sub">The current link stops opening immediately and cannot be restored. You will get a new
8295
+ link to copy \u2014 shown once.</p>
8296
+ ${warning}
8297
+ <label class="slm-ch-radio">
8298
+ <input type="radio" name="slm-ch-rot" value="keep" data-ch-rot />
8299
+ <span><b>Let them finish</b><span class="why">Access already handed out expires on its own; seats in
8300
+ checkout are untouched. Every new visit needs the new link.</span></span>
8301
+ </label>
8302
+ <label class="slm-ch-radio">
8303
+ <input type="radio" name="slm-ch-rot" value="end" data-ch-rot />
8304
+ <span><b>End their access now</b><span class="why">All access from the old link ends immediately.
8305
+ Buyers part-way through choosing seats lose access.</span></span>
8306
+ </label>
8307
+ <p class="slm-note">Choose one \u2014 SeatLayer will not decide this for you.</p>
8308
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8309
+ <div class="foot">
8310
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8311
+ <button type="button" class="slm-btn" data-ch-lk-rotate disabled>Rotate and copy new link</button>
8312
+ </div>`, (dialog) => {
8313
+ const confirm = dialog.querySelector("[data-ch-lk-rotate]");
8314
+ dialog.querySelectorAll("[data-ch-rot]").forEach((radio) => {
8315
+ radio.addEventListener("change", () => {
8316
+ confirm.disabled = false;
8317
+ });
8318
+ });
8319
+ confirm.addEventListener("click", () => {
8320
+ const picked = [...dialog.querySelectorAll("[data-ch-rot]")].find((radio) => radio.checked);
8321
+ if (!picked) {
8322
+ this.showDialogError(accessLinkErrorCopy({ code: "end_active_sessions_required" }));
8323
+ return;
8324
+ }
8325
+ void this.rotateLink(state.channelId, link.id, picked.value === "end");
8326
+ });
8327
+ });
8328
+ }
8329
+ async rotateLink(channelId, linkId, endActiveSessions) {
8330
+ try {
8331
+ const reveal = await this.host.api.rotateAccessLink(
8332
+ this.host.eventKey,
8333
+ channelId,
8334
+ linkId,
8335
+ endActiveSessions
8336
+ );
8337
+ this.revealLink(reveal, { channelId, rotated: true });
8338
+ } catch (err) {
8339
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8340
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8341
+ }
8342
+ }
8343
+ renderLinkRevokeDialog(state) {
8344
+ const link = this.linkById(state.linkId);
8345
+ if (!link || !this.caps.manage) {
8346
+ this.closeDialog();
8347
+ return;
8348
+ }
8349
+ const sessions = link.activeSessions ?? 0;
8350
+ this.renderScrim(`
8351
+ <h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "hosted")} link?</h3>
8352
+ <p class="sub">It stops opening immediately and cannot be restored \u2014 there is no undo, and no way to
8353
+ bring the same URL back. Seats already bought through it keep their sale.</p>
8354
+ ${sessions ? `<label class="slm-ch-radio">
8355
+ <input type="checkbox" data-ch-lk-endsessions />
8356
+ <span><b>Also end access for the ${sessions.toLocaleString()}
8357
+ buyer${sessions === 1 ? "" : "s"} already inside</b><span class="why">Leave this off and they can
8358
+ finish what they started; new visits are refused either way.</span></span>
8359
+ </label>` : ""}
8360
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8361
+ <div class="foot">
8362
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8363
+ <button type="button" class="slm-btn danger" data-ch-lk-revoke>Revoke link</button>
8364
+ </div>`, (dialog) => {
8365
+ dialog.querySelector("[data-ch-lk-revoke]")?.addEventListener("click", () => {
8366
+ const end = dialog.querySelector("[data-ch-lk-endsessions]")?.checked ?? false;
8367
+ void this.revokeLink(state.channelId, link.id, end);
8368
+ });
8369
+ });
8370
+ }
8371
+ async revokeLink(channelId, linkId, endActiveSessions) {
8372
+ try {
8373
+ const res = await this.host.api.revokeAccessLink(
8374
+ this.host.eventKey,
8375
+ channelId,
8376
+ linkId,
8377
+ endActiveSessions
8378
+ );
8379
+ this.closeDialog();
8380
+ await this.loadLinks(channelId);
8381
+ await this.refresh({ quiet: true });
8382
+ this.paintRail();
8383
+ this.host.toast(res.endedSessions ? `Link revoked. ${res.endedSessions.toLocaleString()} buyer${res.endedSessions === 1 ? "" : "s"} lost access.` : "Link revoked. It no longer opens for anyone.", "ok");
8384
+ } catch (err) {
8385
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8386
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8387
+ }
8388
+ }
7751
8389
  // ---- compact detents ------------------------------------------------------
7752
8390
  applySheetClasses() {
7753
8391
  const root = this.host.root;
@@ -8199,7 +8837,7 @@ var SeatManager = class {
8199
8837
  if (controlRoom?.activity) this.seedFeed(controlRoom.activity);
8200
8838
  else this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
8201
8839
  });
8202
- this.connect();
8840
+ void this.connect();
8203
8841
  this.startFeedClock();
8204
8842
  this.ready = true;
8205
8843
  await this.resolveChannelCapabilities();
@@ -8623,11 +9261,33 @@ var SeatManager = class {
8623
9261
  });
8624
9262
  }
8625
9263
  // ---- realtime -------------------------------------------------------------
8626
- connect() {
9264
+ /**
9265
+ * Open the cockpit's realtime socket AS THE ORGANIZER.
9266
+ *
9267
+ * The scope has to be established before the upgrade, because a browser
9268
+ * `WebSocket` cannot send an Authorization header: the manage token is traded
9269
+ * over HTTPS for a one-use ticket which rides in `Sec-WebSocket-Protocol`.
9270
+ * Without it the server treats this socket as an anonymous public buyer and
9271
+ * projects its deltas, so any change inside a private channel allocation is
9272
+ * structurally suppressed and the map silently drifts.
9273
+ *
9274
+ * If the mint fails (an expired token, a worker that predates the route) we
9275
+ * still connect unticketed rather than going dark — the public-sale stream is
9276
+ * worth having, and every `resnapshot()` re-establishes physical truth from
9277
+ * the authenticated HTTP read.
9278
+ */
9279
+ async connect() {
9280
+ if (this.closed) return;
9281
+ let protocols;
9282
+ try {
9283
+ protocols = (await this.api.subscribeTicket(this.key)).protocols;
9284
+ } catch {
9285
+ protocols = void 0;
9286
+ }
8627
9287
  if (this.closed) return;
8628
9288
  let ws;
8629
9289
  try {
8630
- ws = new WebSocket(this.api.socketUrl(this.key));
9290
+ ws = protocols ? new WebSocket(this.api.socketUrl(this.key), protocols) : new WebSocket(this.api.socketUrl(this.key));
8631
9291
  } catch {
8632
9292
  this.scheduleReconnect();
8633
9293
  return;
@@ -8657,7 +9317,7 @@ var SeatManager = class {
8657
9317
  const delay = Math.min(1e3 * 2 ** Math.min(this.attempt++, 5), 15e3);
8658
9318
  this.reconnectTimer = setTimeout(() => {
8659
9319
  this.reconnectTimer = null;
8660
- this.connect();
9320
+ void this.connect();
8661
9321
  }, delay);
8662
9322
  }
8663
9323
  onMessage(e) {
@@ -8687,7 +9347,7 @@ var SeatManager = class {
8687
9347
  }
8688
9348
  if (m.type === "hidden") return;
8689
9349
  if (m.seats && typeof m.seats === "object") {
8690
- this.applySnapshot(m.seats);
9350
+ this.applySnapshot(m.seats, typeof m.default === "string" ? m.default : void 0);
8691
9351
  } else if (Array.isArray(m.changes)) {
8692
9352
  const ids = [];
8693
9353
  const groups = /* @__PURE__ */ new Map();
@@ -8727,10 +9387,23 @@ var SeatManager = class {
8727
9387
  } catch {
8728
9388
  }
8729
9389
  }
8730
- applySnapshot(seats) {
9390
+ /**
9391
+ * Replace the whole seat model.
9392
+ *
9393
+ * `fallback` is the compact frame's modal status: those snapshots list only
9394
+ * the seats that DIFFER from it, so every other known label takes it. Without
9395
+ * this the omitted majority would silently fall back to `free` — fine when
9396
+ * the mode really is free, wrong the moment it is not.
9397
+ */
9398
+ applySnapshot(seats, fallback) {
9399
+ const known = (st) => ["free", "held", "booked", "blocked"].includes(st) ? st : "free";
8731
9400
  const next = /* @__PURE__ */ new Map();
9401
+ if (fallback !== void 0) {
9402
+ const base = known(fallback);
9403
+ for (const label of this.labelToId.keys()) next.set(label, base);
9404
+ }
8732
9405
  for (const [label, st] of Object.entries(seats)) {
8733
- next.set(label, ["free", "held", "booked", "blocked"].includes(st) ? st : "free");
9406
+ next.set(label, known(st));
8734
9407
  }
8735
9408
  this.status = next;
8736
9409
  this.lastSyncedAt = Date.now();
@@ -9930,6 +10603,7 @@ var SeatManager = class {
9930
10603
  };
9931
10604
  // Annotate the CommonJS export names for ESM import in node:
9932
10605
  0 && (module.exports = {
10606
+ ACCESS_LINK_DEFAULTS,
9933
10607
  ApiError,
9934
10608
  BuyerAccessContext,
9935
10609
  BuyerAccessUnavailableError,
@@ -9945,12 +10619,18 @@ var SeatManager = class {
9945
10619
  SeatingChart,
9946
10620
  accessIntentLabel,
9947
10621
  accessLine,
10622
+ accessLinkBadge,
10623
+ accessLinkErrorCopy,
10624
+ accessLinkIsLive,
10625
+ accessLinkPolicyLines,
9948
10626
  attachPickerFrame,
9949
10627
  bucketRows,
9950
10628
  bucketRowsHtml,
9951
10629
  createBuyerAccessContext,
9952
10630
  createControllerSink,
9953
10631
  dropReviewRows,
10632
+ isPublicChannelId,
10633
+ markerLetter,
9954
10634
  markerOf,
9955
10635
  mutationCount,
9956
10636
  needsMoveConfirmation,