@seatlayer/js 0.36.2 → 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.js CHANGED
@@ -6160,6 +6160,78 @@ function accessLine(access) {
6160
6160
  function accessIntentLabel(intent) {
6161
6161
  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";
6162
6162
  }
6163
+ var ACCESS_LINK_DEFAULTS = {
6164
+ maxRedemptions: 100,
6165
+ maxQuantity: 4
6166
+ };
6167
+ function accessLinkBadge(link) {
6168
+ switch (link.status ?? link.state) {
6169
+ case "active":
6170
+ return { text: "Active", kind: "active" };
6171
+ case "expired":
6172
+ return { text: "Expired", kind: "archived" };
6173
+ case "exhausted":
6174
+ return { text: "All used", kind: "paused" };
6175
+ case "rotated":
6176
+ return { text: "Replaced", kind: "archived" };
6177
+ default:
6178
+ return { text: "Revoked", kind: "archived" };
6179
+ }
6180
+ }
6181
+ function accessLinkIsLive(link) {
6182
+ return link.state === "active" && link.status === "active";
6183
+ }
6184
+ function formatMoment(ms) {
6185
+ if (!Number.isFinite(ms)) return "\u2014";
6186
+ return new Date(ms).toLocaleString(void 0, {
6187
+ day: "numeric",
6188
+ month: "short",
6189
+ year: "numeric",
6190
+ hour: "numeric",
6191
+ minute: "2-digit"
6192
+ });
6193
+ }
6194
+ function accessLinkPolicyLines(link) {
6195
+ return [
6196
+ { k: "Expires", v: formatMoment(link.expiresAt) },
6197
+ {
6198
+ k: "Redemptions",
6199
+ v: `${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} used`
6200
+ },
6201
+ {
6202
+ k: "Seats per buyer",
6203
+ v: `${link.maxQuantity.toLocaleString()} seat${link.maxQuantity === 1 ? "" : "s"} maximum`
6204
+ },
6205
+ {
6206
+ k: "Covers",
6207
+ v: link.includePublic ? "This channel's allocation and Public sale seats" : "This channel's allocation only"
6208
+ }
6209
+ ];
6210
+ }
6211
+ function accessLinkErrorCopy(err) {
6212
+ const fromServer = err?.serverMessage?.trim();
6213
+ switch (err?.code) {
6214
+ case "invalid_expiry":
6215
+ case "invalid_max_redemptions":
6216
+ case "invalid_max_quantity":
6217
+ case "invalid_session_ttl":
6218
+ case "invalid_label":
6219
+ return fromServer || "That setting is outside what a hosted link allows. Adjust it and try again.";
6220
+ case "too_many_access_links":
6221
+ return fromServer || "This channel already has as many live links as it can hold. Revoke one before creating another.";
6222
+ case "access_link_not_active":
6223
+ return "That link is no longer active, so it cannot be rotated or revoked.";
6224
+ case "channel_unavailable":
6225
+ return "This channel is paused or archived, so it cannot let new buyers in. Resume it first.";
6226
+ case "end_active_sessions_required":
6227
+ return "Choose what happens to the buyers who already came in through this link.";
6228
+ case "not_found":
6229
+ return "That link is no longer here. Refresh and try again.";
6230
+ default:
6231
+ if (err?.status === 403) return "Hosted access links need channel-management permission.";
6232
+ return fromServer || "That did not go through. Try again.";
6233
+ }
6234
+ }
6163
6235
  function dropReviewRows(details) {
6164
6236
  return (details?.channels ?? []).map((channel) => ({
6165
6237
  kind: "skip",
@@ -6176,13 +6248,14 @@ function stateBadge(state) {
6176
6248
 
6177
6249
  // src/manageApi.ts
6178
6250
  var ManageApiError = class extends Error {
6179
- constructor(status, message, code, conflicts, details) {
6251
+ constructor(status, message, code, conflicts, details, serverMessage) {
6180
6252
  super(message);
6181
6253
  this.name = "ManageApiError";
6182
6254
  this.status = status;
6183
6255
  this.code = code;
6184
6256
  this.conflicts = conflicts;
6185
6257
  this.details = details;
6258
+ this.serverMessage = serverMessage;
6186
6259
  }
6187
6260
  };
6188
6261
  async function parse(res) {
@@ -6195,7 +6268,8 @@ async function parse(res) {
6195
6268
  err?.error ?? `request_failed_${res.status}`,
6196
6269
  err?.code,
6197
6270
  err?.conflicts,
6198
- err?.details
6271
+ err?.details,
6272
+ typeof err?.message === "string" ? err.message : void 0
6199
6273
  );
6200
6274
  }
6201
6275
  return data;
@@ -6391,6 +6465,58 @@ var ManageApi = class {
6391
6465
  body: { accessIntent }
6392
6466
  });
6393
6467
  }
6468
+ // ---- hosted access links (M8) ----
6469
+ /**
6470
+ * Mint a hosted access link. The 201 is the ONE and ONLY time `url` and
6471
+ * `capability` exist outside the buyer's browser — SeatLayer keeps a hash, so
6472
+ * there is no route, cache, or support escalation that can produce this string
6473
+ * again. Callers must reveal it immediately and then let it go.
6474
+ *
6475
+ * Every omitted field takes the server's default: expiry = when the event
6476
+ * starts, 100 redemptions, 4 seats per buyer, this channel's allocation only.
6477
+ * Platform bounds are enforced server-side and reported as 422 with the rule
6478
+ * spelled out in `ManageApiError.serverMessage`.
6479
+ *
6480
+ * Side effect by design: this also declares the channel's access intent as
6481
+ * `hosted_link`, so the rail stops saying "no buyer access configured".
6482
+ */
6483
+ createAccessLink(key, channelId, input = {}) {
6484
+ return this.auth(
6485
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`,
6486
+ { method: "POST", body: input }
6487
+ );
6488
+ }
6489
+ /** Status only — label, expiry, redemptions, per-buyer cap, lineage, and the
6490
+ * live session count. Never the url, never the capability. Needs `:view`. */
6491
+ accessLinks(key, channelId) {
6492
+ return this.auth(
6493
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`
6494
+ );
6495
+ }
6496
+ /**
6497
+ * Rotate — the ONLY recovery for a link nobody kept. The old URL stops opening
6498
+ * immediately and the response is a fresh one-time reveal.
6499
+ *
6500
+ * `endActiveSessions` is REQUIRED, not defaulted: the organizer must say
6501
+ * whether buyers already inside finish their checkout or lose access now. The
6502
+ * server answers 422 `end_active_sessions_required` if it is omitted, and that
6503
+ * refusal is correct — a UI must not pick either branch on their behalf.
6504
+ */
6505
+ rotateAccessLink(key, channelId, linkId, endActiveSessions) {
6506
+ return this.auth(
6507
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}/rotate`,
6508
+ { method: "POST", body: { endActiveSessions } }
6509
+ );
6510
+ }
6511
+ /** Revoke. The link stops opening immediately; `endActiveSessions` decides
6512
+ * whether the buyers already inside keep their sessions. */
6513
+ revokeAccessLink(key, channelId, linkId, endActiveSessions = false) {
6514
+ const qs = endActiveSessions ? "?endActiveSessions=1" : "";
6515
+ return this.auth(
6516
+ `/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}${qs}`,
6517
+ { method: "DELETE" }
6518
+ );
6519
+ }
6394
6520
  // ---- reports (token) ----
6395
6521
  report(key) {
6396
6522
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
@@ -6543,6 +6669,26 @@ var CHANNELS_CSS = `
6543
6669
  border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
6544
6670
  overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
6545
6671
  .slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
6672
+
6673
+ /* hosted access links \u2014 STATUS only; there is no Copy control on this card */
6674
+ .slm-ch-link{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
6675
+ margin-bottom:8px}
6676
+ .slm-ch-link .lk-head{display:flex;align-items:center;gap:8px}
6677
+ .slm-ch-link .lk-name{flex:1;min-width:0;font-size:12.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;
6678
+ white-space:nowrap}
6679
+ .slm-ch-lkrow{display:flex;gap:8px;margin-top:5px;font-size:11px;color:var(--slm-muted)}
6680
+ .slm-ch-lkrow .k{flex:none;min-width:104px}
6681
+ .slm-ch-lkrow .v{color:var(--slm-text);font-variant-numeric:tabular-nums}
6682
+ .slm-ch-meter{height:5px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden;margin-top:8px}
6683
+ .slm-ch-meter i{display:block;height:100%;background:var(--slm-accent);
6684
+ transition:width var(--slm-mo-base) var(--slm-mo-out)}
6685
+ .slm-ch-radio{display:flex;gap:9px;align-items:flex-start;padding:11px 12px;border:1px solid var(--slm-line);
6686
+ border-radius:10px;margin-top:8px;font-size:12.5px;cursor:pointer;
6687
+ transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
6688
+ .slm-ch-radio:hover{border-color:var(--slm-muted)}
6689
+ .slm-ch-radio input{flex:none;margin-top:2px}
6690
+ .slm-ch-radio b{display:block;font-weight:800;margin-bottom:2px}
6691
+ .slm-ch-radio .why{display:block;color:var(--slm-muted);font-size:11.5px;line-height:1.45}
6546
6692
  .slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
6547
6693
  background:var(--slm-surface);margin-top:10px}
6548
6694
  .slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
@@ -6575,7 +6721,8 @@ var CHANNELS_CSS = `
6575
6721
  .slm.compact .slm-modes{display:none}
6576
6722
 
6577
6723
  @media (prefers-reduced-motion:reduce){
6578
- .slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail{transition:none!important}
6724
+ .slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail,
6725
+ .slm-ch-meter i,.slm-ch-radio{transition:none!important}
6579
6726
  .slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
6580
6727
  .slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
6581
6728
  .slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
@@ -6591,9 +6738,37 @@ function bucketRowsHtml(rows) {
6591
6738
  ${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
6592
6739
  </div>`).join("");
6593
6740
  }
6741
+ var SERVER_INTEGRATION_HTML = `
6742
+ <p class="slm-eyebrow" style="margin-top:18px">Server integration</p>
6743
+ <p class="slm-hint">There is nothing to set up on this screen. Your own server mints a short-lived buyer
6744
+ access session for this channel with the SeatLayer server SDK and hands it to the widget. A channel name
6745
+ on its own never grants access.</p>
6746
+ <p class="slm-note"><a class="slm-linkbtn" href="https://docs.seatlayer.io/server-api/channels"
6747
+ target="_blank" rel="noreferrer noopener">Read the server integration guide \u2192</a></p>`;
6594
6748
  function esc(value) {
6595
6749
  return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
6596
6750
  }
6751
+ function datetimeLocalValue(ms) {
6752
+ const local = new Date(ms - new Date(ms).getTimezoneOffset() * 6e4);
6753
+ return local.toISOString().slice(0, 16);
6754
+ }
6755
+ function intField(root, selector) {
6756
+ const raw = root.querySelector(selector)?.value.trim() ?? "";
6757
+ const value = Number(raw);
6758
+ return raw !== "" && Number.isInteger(value) ? value : null;
6759
+ }
6760
+ function selectSecret(dialog) {
6761
+ const node = dialog.querySelector("[data-ch-lk-url]");
6762
+ if (!node) return;
6763
+ try {
6764
+ const range = document.createRange();
6765
+ range.selectNodeContents(node);
6766
+ const selection = window.getSelection();
6767
+ selection?.removeAllRanges();
6768
+ selection?.addRange(range);
6769
+ } catch {
6770
+ }
6771
+ }
6597
6772
  var ChannelsMode = class {
6598
6773
  constructor(host, capabilities) {
6599
6774
  this.active = false;
@@ -6610,6 +6785,15 @@ var ChannelsMode = class {
6610
6785
  this.dialog = null;
6611
6786
  this.detent = "medium";
6612
6787
  this.seatListLimit = SEAT_LIST_PAGE;
6788
+ /**
6789
+ * Hosted-link STATUS for the channel whose detail panel is open. This is the
6790
+ * listing projection — it carries no url and no capability, because no route
6791
+ * returns one. `unsupported` is the honest answer for a worker that predates
6792
+ * M8, exactly like the buyer-preview probe.
6793
+ */
6794
+ this.links = [];
6795
+ this.linksChannelId = null;
6796
+ this.linksState = "idle";
6613
6797
  this.previewAudience = [];
6614
6798
  this.previewIncludePublic = false;
6615
6799
  this.previewProjection = null;
@@ -6653,6 +6837,9 @@ var ChannelsMode = class {
6653
6837
  if (this.pollTimer) clearInterval(this.pollTimer);
6654
6838
  this.pollTimer = null;
6655
6839
  this.closeDialog({ restoreFocus: false });
6840
+ this.links = [];
6841
+ this.linksChannelId = null;
6842
+ this.linksState = "idle";
6656
6843
  this.layer?.classList.remove("on");
6657
6844
  this.host.root.classList.remove(
6658
6845
  "ch-mode",
@@ -6724,6 +6911,7 @@ var ChannelsMode = class {
6724
6911
  this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
6725
6912
  }
6726
6913
  await this.loadAllocation();
6914
+ if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
6727
6915
  this.loading = false;
6728
6916
  if (this.active) {
6729
6917
  this.paintRail();
@@ -7161,11 +7349,8 @@ var ChannelsMode = class {
7161
7349
  <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>
7162
7350
  ${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
7163
7351
  <span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
7164
- <button type="button" class="slm-btn" data-ch-act="hosted-link" disabled
7165
- title="Hosted access links ship in the next milestone">Create hosted access link \xB7 Coming soon</button>
7166
- <div class="slm-ch-row2"><button type="button" class="slm-btn ghost" data-ch-act="server-access" disabled
7167
- title="Guided server setup ships in the next milestone">Configure server integration \xB7 Coming soon</button></div>
7168
- <p class="slm-note">Your own server can already mint buyer access sessions for this channel with the server SDK.</p>` : "";
7352
+ ${this.hostedLinksHtml()}
7353
+ ${SERVER_INTEGRATION_HTML}` : "";
7169
7354
  return `
7170
7355
  <p class="slm-eyebrow">
7171
7356
  <button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
@@ -7175,6 +7360,90 @@ var ChannelsMode = class {
7175
7360
  ${access}
7176
7361
  ${lifecycle}`;
7177
7362
  }
7363
+ // ---- hosted access links --------------------------------------------------
7364
+ /**
7365
+ * Read the status projection for the open channel. Never paints — the caller
7366
+ * decides when the rail repaints, so a poll-driven reload does not fight a
7367
+ * user-driven one. A worker without M8 answers 404/405 and gets the honest
7368
+ * "needs a newer server" line rather than an error toast.
7369
+ */
7370
+ async loadLinks(channelId) {
7371
+ if (!this.caps.view) return;
7372
+ if (this.linksChannelId !== channelId) {
7373
+ this.links = [];
7374
+ this.linksChannelId = channelId;
7375
+ this.linksState = "loading";
7376
+ }
7377
+ try {
7378
+ const res = await this.host.api.accessLinks(this.host.eventKey, channelId);
7379
+ if (this.linksChannelId !== channelId) return;
7380
+ this.links = res.links ?? [];
7381
+ this.linksState = "ready";
7382
+ } catch (err) {
7383
+ if (this.linksChannelId !== channelId) return;
7384
+ const status = err instanceof ManageApiError ? err.status : 0;
7385
+ this.links = [];
7386
+ this.linksState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
7387
+ if (this.linksState === "error") this.host.onError(err);
7388
+ }
7389
+ }
7390
+ /**
7391
+ * The hosted-link section of the detail panel.
7392
+ *
7393
+ * STATUS ONLY, by design (comp 06 `hosted`): label, state, expiry,
7394
+ * redemptions, seats per buyer, live sessions. There is no Copy control here
7395
+ * and no field to hang one on — the URL was shown once at creation and cannot
7396
+ * be produced again. Rotation is the recovery path, and it says so.
7397
+ */
7398
+ hostedLinksHtml() {
7399
+ const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Hosted access links</p>`;
7400
+ if (this.linksState === "unsupported") {
7401
+ return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
7402
+ <span><b>Hosted links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
7403
+ }
7404
+ if (this.linksState === "error") {
7405
+ return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
7406
+ <span><b>Couldn't load this channel's links.</b>
7407
+ <button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
7408
+ }
7409
+ const live = this.links.filter(accessLinkIsLive).length;
7410
+ 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
7411
+ they open the link and buy only these seats.</p>`;
7412
+ const create = this.caps.manage ? `<button type="button" class="slm-btn" style="width:100%" data-ch-act="link-create">
7413
+ ${live ? "Create another hosted link" : "Create hosted access link"}</button>` : "";
7414
+ return `${eyebrow}
7415
+ ${cards}
7416
+ ${create}
7417
+ <p class="slm-note">A link is shown once, when you create it. SeatLayer keeps only a fingerprint of it, so it
7418
+ can never be shown again \u2014 if a link is lost, rotate it and send the fresh one.</p>`;
7419
+ }
7420
+ linkCardHtml(link) {
7421
+ const badge = accessLinkBadge(link);
7422
+ const used = link.maxRedemptions > 0 ? Math.min(100, Math.round(link.redemptions / link.maxRedemptions * 100)) : 0;
7423
+ const rows = accessLinkPolicyLines(link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
7424
+ <span class="v">${esc(row.v)}</span></div>`).join("");
7425
+ const sessions = link.activeSessions ? `<div class="slm-ch-lkrow"><span class="k">Buyers inside now</span>
7426
+ <span class="v">${link.activeSessions.toLocaleString()}</span></div>` : "";
7427
+ const lastUsed = link.lastRedeemedAt ? `<div class="slm-ch-lkrow"><span class="k">Last opened</span>
7428
+ <span class="v">${esc(new Date(link.lastRedeemedAt).toLocaleString())}</span></div>` : "";
7429
+ const actions = this.caps.manage && accessLinkIsLive(link) ? `<div class="slm-ch-row2">
7430
+ <button type="button" class="slm-btn ghost" data-ch-rotate="${esc(link.id)}">Rotate</button>
7431
+ <button type="button" class="slm-btn ghost" data-ch-revoke="${esc(link.id)}">Revoke</button>
7432
+ </div>` : "";
7433
+ return `<div class="slm-ch-link">
7434
+ <span class="lk-head">
7435
+ <span class="lk-name">${esc(link.label || "Hosted link")}</span>
7436
+ <span class="slm-ch-badge ${badge.kind}">${esc(badge.text)}</span>
7437
+ </span>
7438
+ <div class="slm-ch-meter" role="img"
7439
+ aria-label="${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} redemptions used">
7440
+ <i style="width:${used}%"></i></div>
7441
+ ${rows}${sessions}${lastUsed}
7442
+ <div class="slm-ch-lkrow"><span class="k">The URL</span>
7443
+ <span class="v">Revealed once at creation \u2014 not recoverable</span></div>
7444
+ ${actions}
7445
+ </div>`;
7446
+ }
7178
7447
  previewRailHtml() {
7179
7448
  const audienceOptions = [
7180
7449
  { id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
@@ -7223,10 +7492,26 @@ var ChannelsMode = class {
7223
7492
  });
7224
7493
  rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
7225
7494
  button.addEventListener("click", () => {
7226
- this.detailChannelId = button.dataset.chDetail;
7495
+ const channelId = button.dataset.chDetail;
7496
+ this.detailChannelId = channelId;
7227
7497
  this.paintRail();
7498
+ void this.loadLinks(channelId).then(() => this.paintRail());
7228
7499
  });
7229
7500
  });
7501
+ rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
7502
+ button.addEventListener("click", () => this.openDialog({
7503
+ kind: "linkRotate",
7504
+ channelId: this.detailChannelId,
7505
+ linkId: button.dataset.chRotate
7506
+ }));
7507
+ });
7508
+ rail.querySelectorAll("[data-ch-revoke]").forEach((button) => {
7509
+ button.addEventListener("click", () => this.openDialog({
7510
+ kind: "linkRevoke",
7511
+ channelId: this.detailChannelId,
7512
+ linkId: button.dataset.chRevoke
7513
+ }));
7514
+ });
7230
7515
  const target = rail.querySelector("[data-ch-target]");
7231
7516
  target?.addEventListener("change", () => {
7232
7517
  this.targetChannelId = target.value;
@@ -7278,8 +7563,17 @@ var ChannelsMode = class {
7278
7563
  break;
7279
7564
  case "back":
7280
7565
  this.detailChannelId = null;
7566
+ this.linksChannelId = null;
7567
+ this.links = [];
7568
+ this.linksState = "idle";
7281
7569
  this.paintRail();
7282
7570
  break;
7571
+ case "link-create":
7572
+ this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
7573
+ break;
7574
+ case "link-reload":
7575
+ if (this.detailChannelId) void this.reloadLinks();
7576
+ break;
7283
7577
  case "retry":
7284
7578
  void this.refresh();
7285
7579
  break;
@@ -7351,6 +7645,9 @@ var ChannelsMode = class {
7351
7645
  else if (state.kind === "archive") this.renderArchiveDialog(state);
7352
7646
  else if (state.kind === "rename") this.renderRenameDialog(state);
7353
7647
  else if (state.kind === "seatlist") this.renderSeatListDialog();
7648
+ else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
7649
+ else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
7650
+ else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
7354
7651
  }
7355
7652
  /**
7356
7653
  * Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
@@ -7760,6 +8057,284 @@ var ChannelsMode = class {
7760
8057
  });
7761
8058
  });
7762
8059
  }
8060
+ // ---- hosted-link dialogs --------------------------------------------------
8061
+ async reloadLinks() {
8062
+ const channelId = this.detailChannelId;
8063
+ if (!channelId) return;
8064
+ this.linksState = this.links.length ? this.linksState : "loading";
8065
+ await this.loadLinks(channelId);
8066
+ this.paintRail();
8067
+ }
8068
+ linkById(linkId) {
8069
+ return this.links.find((link) => link.id === linkId) ?? null;
8070
+ }
8071
+ /**
8072
+ * Create. The three policy fields carry the owner's defaults and every one of
8073
+ * them is editable; the PLATFORM bounds (60s–180d, 1–10 000, 1–100, 20 live
8074
+ * links) are the server's to enforce and the server's to explain, so this form
8075
+ * checks only that a number is a number and surfaces the server's sentence for
8076
+ * everything else.
8077
+ */
8078
+ renderLinkCreateDialog(state) {
8079
+ const channel = this.list?.channels.find((item) => item.id === state.channelId);
8080
+ if (!channel || !this.caps.manage) {
8081
+ this.closeDialog();
8082
+ return;
8083
+ }
8084
+ this.renderScrim(`
8085
+ <h3 id="slm-ch-dlg-title">Create a hosted access link for ${esc(channel.name)}</h3>
8086
+ <p class="sub">Anyone who opens the link can buy from this channel's allocation \u2014 and only from it.
8087
+ You'll see the link once, right after you create it.</p>
8088
+ <div class="slm-field">
8089
+ <label for="slm-ch-lk-label">Label <span style="text-transform:none;font-weight:500">(optional)</span></label>
8090
+ <input class="slm-input" id="slm-ch-lk-label" maxlength="80" placeholder="e.g. VIP list Nov 14" />
8091
+ <p class="slm-note">So you can tell your links apart later. Buyers never see it.</p>
8092
+ </div>
8093
+ <div class="slm-field">
8094
+ <label for="slm-ch-lk-expiry">Stops working</label>
8095
+ <select class="slm-select" id="slm-ch-lk-expiry" data-ch-lk-expiry>
8096
+ <option value="event" selected>When the event starts</option>
8097
+ <option value="custom">On a date I choose</option>
8098
+ </select>
8099
+ </div>
8100
+ <div class="slm-field" data-ch-lk-when-field hidden>
8101
+ <label for="slm-ch-lk-when">Date and time</label>
8102
+ <input class="slm-input" type="datetime-local" id="slm-ch-lk-when" />
8103
+ </div>
8104
+ <div class="slm-field">
8105
+ <label for="slm-ch-lk-redemptions">How many people can use it</label>
8106
+ <input class="slm-input" type="number" id="slm-ch-lk-redemptions" inputmode="numeric"
8107
+ value="${ACCESS_LINK_DEFAULTS.maxRedemptions}" />
8108
+ <p class="slm-note">Each buyer who opens the link uses one.</p>
8109
+ </div>
8110
+ <div class="slm-field">
8111
+ <label for="slm-ch-lk-quantity">Seats per buyer</label>
8112
+ <input class="slm-input" type="number" id="slm-ch-lk-quantity" inputmode="numeric"
8113
+ value="${ACCESS_LINK_DEFAULTS.maxQuantity}" />
8114
+ </div>
8115
+ <label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:2px 0 6px">
8116
+ <input type="checkbox" id="slm-ch-lk-public" />
8117
+ Also let this link buy Public sale seats
8118
+ </label>
8119
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8120
+ <div class="foot">
8121
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8122
+ <button type="button" class="slm-btn" data-ch-lk-create>Create link</button>
8123
+ </div>`, (dialog) => {
8124
+ const expiry = dialog.querySelector("[data-ch-lk-expiry]");
8125
+ const whenField = dialog.querySelector("[data-ch-lk-when-field]");
8126
+ const when = dialog.querySelector("#slm-ch-lk-when");
8127
+ expiry.addEventListener("change", () => {
8128
+ const custom = expiry.value === "custom";
8129
+ whenField.hidden = !custom;
8130
+ if (custom && !when.value) when.value = datetimeLocalValue(Date.now() + 7 * 864e5);
8131
+ });
8132
+ dialog.querySelector("[data-ch-lk-create]")?.addEventListener("click", () => {
8133
+ const maxRedemptions = intField(dialog, "#slm-ch-lk-redemptions");
8134
+ const maxQuantity = intField(dialog, "#slm-ch-lk-quantity");
8135
+ if (maxRedemptions == null || maxQuantity == null) {
8136
+ this.showDialogError("Those two settings need to be whole numbers.");
8137
+ return;
8138
+ }
8139
+ let expiresAt;
8140
+ if (expiry.value === "custom") {
8141
+ expiresAt = Date.parse(when.value);
8142
+ if (!Number.isFinite(expiresAt)) {
8143
+ this.showDialogError("Pick the date and time the link should stop working.");
8144
+ return;
8145
+ }
8146
+ }
8147
+ void this.createLink(channel.id, {
8148
+ label: dialog.querySelector("#slm-ch-lk-label")?.value.trim() || null,
8149
+ includePublic: dialog.querySelector("#slm-ch-lk-public")?.checked ?? false,
8150
+ ...expiresAt === void 0 ? {} : { expiresAt },
8151
+ maxRedemptions,
8152
+ maxQuantity
8153
+ });
8154
+ });
8155
+ });
8156
+ }
8157
+ async createLink(channelId, input) {
8158
+ try {
8159
+ const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
8160
+ this.revealLink(reveal, { channelId });
8161
+ await this.refresh({ quiet: true });
8162
+ } catch (err) {
8163
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8164
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8165
+ }
8166
+ }
8167
+ /**
8168
+ * The ONE-TIME reveal.
8169
+ *
8170
+ * Three things make this unrecoverable rather than merely "not shown twice":
8171
+ *
8172
+ * 1. `url` is a local const. It is never assigned to a field on this class,
8173
+ * never handed to the host, never put in a `DialogState`.
8174
+ * 2. `this.dialog` is cleared FIRST, so `renderDialog()` — the only function
8175
+ * that rebuilds a sheet — has nothing to rebuild this one from.
8176
+ * 3. The string exists in exactly one DOM node inside the scrim. Dismissing
8177
+ * the dialog removes the scrim, and the closure goes with it.
8178
+ *
8179
+ * The server holds only a hash, so even a compromised client cannot ask for it
8180
+ * again. Rotation is the recovery path, and the copy says so.
8181
+ */
8182
+ revealLink(reveal, opts) {
8183
+ const url = reveal.url;
8184
+ this.dialog = null;
8185
+ const rotated = opts.rotated ? `<div class="slm-ch-alert warn" role="status"><span>\u26A0</span><span>
8186
+ <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>` : "";
8187
+ const policy = accessLinkPolicyLines(reveal.link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
8188
+ <span class="v">${esc(row.v)}</span></div>`).join("");
8189
+ this.renderScrim(`
8190
+ <h3 id="slm-ch-dlg-title">Copy this link now</h3>
8191
+ <p class="sub">This is the only time SeatLayer can show it. We keep just a fingerprint, so it cannot be
8192
+ shown again \u2014 if it is lost, rotate the link for a fresh one.</p>
8193
+ ${rotated}
8194
+ <div class="slm-ch-secret" data-ch-lk-url>${esc(url)}</div>
8195
+ <div class="slm-ch-row2" style="margin-top:8px">
8196
+ <button type="button" class="slm-btn" data-ch-lk-copy>Copy link</button>
8197
+ </div>
8198
+ <div class="slm-ch-alert warn" style="margin-top:12px"><span>\u26A0</span>
8199
+ <span>Anyone who opens this link can buy from this allocation. Send it only to the people it is meant
8200
+ for \u2014 forwarding it hands on the same access, and SeatLayer cannot tell the difference.</span></div>
8201
+ <p class="slm-eyebrow" style="margin-top:14px">What this link allows</p>
8202
+ ${policy}
8203
+ <div class="foot">
8204
+ <button type="button" class="slm-btn" data-ch-close data-ch-lk-done>I've copied it</button>
8205
+ </div>`, (dialog) => {
8206
+ const copy = dialog.querySelector("[data-ch-lk-copy]");
8207
+ copy?.addEventListener("click", () => {
8208
+ const ok = () => {
8209
+ copy.textContent = "Copied";
8210
+ this.announce("Hosted access link copied.");
8211
+ };
8212
+ const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
8213
+ if (clipboard?.writeText) {
8214
+ clipboard.writeText(url).then(ok, () => selectSecret(dialog));
8215
+ return;
8216
+ }
8217
+ selectSecret(dialog);
8218
+ });
8219
+ dialog.querySelector("[data-ch-lk-done]")?.addEventListener("click", () => {
8220
+ void this.loadLinks(opts.channelId).then(() => this.paintRail());
8221
+ });
8222
+ });
8223
+ this.announce("Your hosted access link is ready and is shown once.");
8224
+ }
8225
+ /**
8226
+ * Rotate. The organizer must SAY what happens to the buyers already inside —
8227
+ * the confirm stays disabled until one of the two choices is picked, because
8228
+ * the gentle branch and the destructive branch are both real decisions and the
8229
+ * server refuses (422 `end_active_sessions_required`) to guess either.
8230
+ */
8231
+ renderLinkRotateDialog(state) {
8232
+ const link = this.linkById(state.linkId);
8233
+ if (!link || !this.caps.manage) {
8234
+ this.closeDialog();
8235
+ return;
8236
+ }
8237
+ const sessions = link.activeSessions ?? 0;
8238
+ const warning = sessions ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
8239
+ <span><b>${sessions.toLocaleString()} buyer${sessions === 1 ? "" : "s"}</b> got in with the current link
8240
+ and still ${sessions === 1 ? "has" : "have"} active access.</span></div>` : "";
8241
+ this.renderScrim(`
8242
+ <h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "hosted")} link?</h3>
8243
+ <p class="sub">The current link stops opening immediately and cannot be restored. You will get a new
8244
+ link to copy \u2014 shown once.</p>
8245
+ ${warning}
8246
+ <label class="slm-ch-radio">
8247
+ <input type="radio" name="slm-ch-rot" value="keep" data-ch-rot />
8248
+ <span><b>Let them finish</b><span class="why">Access already handed out expires on its own; seats in
8249
+ checkout are untouched. Every new visit needs the new link.</span></span>
8250
+ </label>
8251
+ <label class="slm-ch-radio">
8252
+ <input type="radio" name="slm-ch-rot" value="end" data-ch-rot />
8253
+ <span><b>End their access now</b><span class="why">All access from the old link ends immediately.
8254
+ Buyers part-way through choosing seats lose access.</span></span>
8255
+ </label>
8256
+ <p class="slm-note">Choose one \u2014 SeatLayer will not decide this for you.</p>
8257
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8258
+ <div class="foot">
8259
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8260
+ <button type="button" class="slm-btn" data-ch-lk-rotate disabled>Rotate and copy new link</button>
8261
+ </div>`, (dialog) => {
8262
+ const confirm = dialog.querySelector("[data-ch-lk-rotate]");
8263
+ dialog.querySelectorAll("[data-ch-rot]").forEach((radio) => {
8264
+ radio.addEventListener("change", () => {
8265
+ confirm.disabled = false;
8266
+ });
8267
+ });
8268
+ confirm.addEventListener("click", () => {
8269
+ const picked = [...dialog.querySelectorAll("[data-ch-rot]")].find((radio) => radio.checked);
8270
+ if (!picked) {
8271
+ this.showDialogError(accessLinkErrorCopy({ code: "end_active_sessions_required" }));
8272
+ return;
8273
+ }
8274
+ void this.rotateLink(state.channelId, link.id, picked.value === "end");
8275
+ });
8276
+ });
8277
+ }
8278
+ async rotateLink(channelId, linkId, endActiveSessions) {
8279
+ try {
8280
+ const reveal = await this.host.api.rotateAccessLink(
8281
+ this.host.eventKey,
8282
+ channelId,
8283
+ linkId,
8284
+ endActiveSessions
8285
+ );
8286
+ this.revealLink(reveal, { channelId, rotated: true });
8287
+ } catch (err) {
8288
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8289
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8290
+ }
8291
+ }
8292
+ renderLinkRevokeDialog(state) {
8293
+ const link = this.linkById(state.linkId);
8294
+ if (!link || !this.caps.manage) {
8295
+ this.closeDialog();
8296
+ return;
8297
+ }
8298
+ const sessions = link.activeSessions ?? 0;
8299
+ this.renderScrim(`
8300
+ <h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "hosted")} link?</h3>
8301
+ <p class="sub">It stops opening immediately and cannot be restored \u2014 there is no undo, and no way to
8302
+ bring the same URL back. Seats already bought through it keep their sale.</p>
8303
+ ${sessions ? `<label class="slm-ch-radio">
8304
+ <input type="checkbox" data-ch-lk-endsessions />
8305
+ <span><b>Also end access for the ${sessions.toLocaleString()}
8306
+ buyer${sessions === 1 ? "" : "s"} already inside</b><span class="why">Leave this off and they can
8307
+ finish what they started; new visits are refused either way.</span></span>
8308
+ </label>` : ""}
8309
+ <p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
8310
+ <div class="foot">
8311
+ <button type="button" class="quiet" data-ch-close>Cancel</button>
8312
+ <button type="button" class="slm-btn danger" data-ch-lk-revoke>Revoke link</button>
8313
+ </div>`, (dialog) => {
8314
+ dialog.querySelector("[data-ch-lk-revoke]")?.addEventListener("click", () => {
8315
+ const end = dialog.querySelector("[data-ch-lk-endsessions]")?.checked ?? false;
8316
+ void this.revokeLink(state.channelId, link.id, end);
8317
+ });
8318
+ });
8319
+ }
8320
+ async revokeLink(channelId, linkId, endActiveSessions) {
8321
+ try {
8322
+ const res = await this.host.api.revokeAccessLink(
8323
+ this.host.eventKey,
8324
+ channelId,
8325
+ linkId,
8326
+ endActiveSessions
8327
+ );
8328
+ this.closeDialog();
8329
+ await this.loadLinks(channelId);
8330
+ await this.refresh({ quiet: true });
8331
+ this.paintRail();
8332
+ 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");
8333
+ } catch (err) {
8334
+ this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
8335
+ if (!(err instanceof ManageApiError)) this.host.onError(err);
8336
+ }
8337
+ }
7763
8338
  // ---- compact detents ------------------------------------------------------
7764
8339
  applySheetClasses() {
7765
8340
  const root = this.host.root;
@@ -9976,6 +10551,7 @@ var SeatManager = class {
9976
10551
  }
9977
10552
  };
9978
10553
  export {
10554
+ ACCESS_LINK_DEFAULTS,
9979
10555
  ApiError,
9980
10556
  BuyerAccessContext,
9981
10557
  BuyerAccessUnavailableError,
@@ -9991,6 +10567,10 @@ export {
9991
10567
  SeatingChart,
9992
10568
  accessIntentLabel,
9993
10569
  accessLine,
10570
+ accessLinkBadge,
10571
+ accessLinkErrorCopy,
10572
+ accessLinkIsLive,
10573
+ accessLinkPolicyLines,
9994
10574
  attachPickerFrame,
9995
10575
  bucketRows,
9996
10576
  bucketRowsHtml,