@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 +717 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +330 -9
- package/dist/index.d.ts +330 -9
- package/dist/index.js +710 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -5945,8 +5945,11 @@ import {
|
|
|
5945
5945
|
} from "@seatlayer/core";
|
|
5946
5946
|
|
|
5947
5947
|
// src/channelPlan.ts
|
|
5948
|
-
var PUBLIC_CHANNEL_ID = "";
|
|
5948
|
+
var PUBLIC_CHANNEL_ID = "public";
|
|
5949
5949
|
var PUBLIC_CHANNEL_NAME = "Public sale";
|
|
5950
|
+
function isPublicChannelId(id) {
|
|
5951
|
+
return id == null || id === "" || id === PUBLIC_CHANNEL_ID;
|
|
5952
|
+
}
|
|
5950
5953
|
var CHANNEL_COLORS = [
|
|
5951
5954
|
"#a78bfa",
|
|
5952
5955
|
"#2dd4bf",
|
|
@@ -5961,27 +5964,35 @@ var CHANNEL_COLORS = [
|
|
|
5961
5964
|
];
|
|
5962
5965
|
var PUBLIC_CHANNEL_COLOR = "#f4b740";
|
|
5963
5966
|
var LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
5967
|
+
function markerLetter(raw, fallback) {
|
|
5968
|
+
const text = (raw ?? "").trim();
|
|
5969
|
+
const letter = /\p{L}/u.exec(text)?.[0] ?? text[0] ?? "";
|
|
5970
|
+
return (letter || fallback).toUpperCase().slice(0, 1);
|
|
5971
|
+
}
|
|
5964
5972
|
function suggestMarker(name, taken) {
|
|
5965
|
-
const used = new Set([...taken].map((m) => m
|
|
5966
|
-
const first = (name
|
|
5973
|
+
const used = new Set([...taken].map((m) => markerLetter(m, "")).filter(Boolean));
|
|
5974
|
+
const first = markerLetter(name, "");
|
|
5967
5975
|
const letter = LETTERS.includes(first) && !used.has(first) ? first : [...LETTERS].find((candidate) => !used.has(candidate)) ?? (first || "X");
|
|
5968
5976
|
return { letter, color: CHANNEL_COLORS[used.size % CHANNEL_COLORS.length] };
|
|
5969
5977
|
}
|
|
5970
5978
|
function markerOf(channel, index = 0) {
|
|
5971
|
-
if (channel.id
|
|
5972
|
-
return {
|
|
5979
|
+
if (isPublicChannelId(channel.id)) {
|
|
5980
|
+
return {
|
|
5981
|
+
letter: markerLetter(channel.marker, "P"),
|
|
5982
|
+
color: channel.color || PUBLIC_CHANNEL_COLOR
|
|
5983
|
+
};
|
|
5973
5984
|
}
|
|
5974
|
-
const letter = (channel.marker || channel.name
|
|
5985
|
+
const letter = markerLetter(channel.marker || channel.name, "?");
|
|
5975
5986
|
return { letter, color: channel.color || CHANNEL_COLORS[index % CHANNEL_COLORS.length] };
|
|
5976
5987
|
}
|
|
5977
5988
|
function selectionSources(labels, allocation, list) {
|
|
5978
5989
|
const counts = /* @__PURE__ */ new Map();
|
|
5979
5990
|
for (const label of labels) {
|
|
5980
|
-
const channelId = allocation.get(label)
|
|
5991
|
+
const channelId = normalizeChannelId(allocation.get(label));
|
|
5981
5992
|
counts.set(channelId, (counts.get(channelId) ?? 0) + 1);
|
|
5982
5993
|
}
|
|
5983
5994
|
const order = [
|
|
5984
|
-
{ id: PUBLIC_CHANNEL_ID, name: list?.publicSale
|
|
5995
|
+
{ id: PUBLIC_CHANNEL_ID, name: list?.publicSale?.name ?? PUBLIC_CHANNEL_NAME },
|
|
5985
5996
|
...(list?.channels ?? []).map((channel) => ({ id: channel.id, name: channel.name }))
|
|
5986
5997
|
];
|
|
5987
5998
|
const rows = [];
|
|
@@ -5991,10 +6002,17 @@ function selectionSources(labels, allocation, list) {
|
|
|
5991
6002
|
counts.delete(entry.id);
|
|
5992
6003
|
}
|
|
5993
6004
|
for (const [channelId, count] of counts) {
|
|
5994
|
-
rows.push({
|
|
6005
|
+
rows.push({
|
|
6006
|
+
channelId,
|
|
6007
|
+
name: isPublicChannelId(channelId) ? PUBLIC_CHANNEL_NAME : "Another channel",
|
|
6008
|
+
count
|
|
6009
|
+
});
|
|
5995
6010
|
}
|
|
5996
6011
|
return rows;
|
|
5997
6012
|
}
|
|
6013
|
+
function normalizeChannelId(id) {
|
|
6014
|
+
return isPublicChannelId(id) ? PUBLIC_CHANNEL_ID : id;
|
|
6015
|
+
}
|
|
5998
6016
|
var SKIP_SAMPLE = 12;
|
|
5999
6017
|
function skipBucket(labels) {
|
|
6000
6018
|
return {
|
|
@@ -6004,7 +6022,8 @@ function skipBucket(labels) {
|
|
|
6004
6022
|
};
|
|
6005
6023
|
}
|
|
6006
6024
|
function planAssignment(input) {
|
|
6007
|
-
const { labels,
|
|
6025
|
+
const { labels, allocation, statusOf, nameOf } = input;
|
|
6026
|
+
const targetChannelId = normalizeChannelId(input.targetChannelId);
|
|
6008
6027
|
const seen = /* @__PURE__ */ new Set();
|
|
6009
6028
|
let fromPublic = 0;
|
|
6010
6029
|
let alreadyIn = 0;
|
|
@@ -6020,7 +6039,7 @@ function planAssignment(input) {
|
|
|
6020
6039
|
missing.push(label);
|
|
6021
6040
|
continue;
|
|
6022
6041
|
}
|
|
6023
|
-
const current = allocation.get(label)
|
|
6042
|
+
const current = normalizeChannelId(allocation.get(label));
|
|
6024
6043
|
if (current === targetChannelId) {
|
|
6025
6044
|
alreadyIn += 1;
|
|
6026
6045
|
continue;
|
|
@@ -6141,6 +6160,78 @@ function accessLine(access) {
|
|
|
6141
6160
|
function accessIntentLabel(intent) {
|
|
6142
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";
|
|
6143
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
|
+
}
|
|
6144
6235
|
function dropReviewRows(details) {
|
|
6145
6236
|
return (details?.channels ?? []).map((channel) => ({
|
|
6146
6237
|
kind: "skip",
|
|
@@ -6157,13 +6248,14 @@ function stateBadge(state) {
|
|
|
6157
6248
|
|
|
6158
6249
|
// src/manageApi.ts
|
|
6159
6250
|
var ManageApiError = class extends Error {
|
|
6160
|
-
constructor(status, message, code, conflicts, details) {
|
|
6251
|
+
constructor(status, message, code, conflicts, details, serverMessage) {
|
|
6161
6252
|
super(message);
|
|
6162
6253
|
this.name = "ManageApiError";
|
|
6163
6254
|
this.status = status;
|
|
6164
6255
|
this.code = code;
|
|
6165
6256
|
this.conflicts = conflicts;
|
|
6166
6257
|
this.details = details;
|
|
6258
|
+
this.serverMessage = serverMessage;
|
|
6167
6259
|
}
|
|
6168
6260
|
};
|
|
6169
6261
|
async function parse(res) {
|
|
@@ -6176,7 +6268,8 @@ async function parse(res) {
|
|
|
6176
6268
|
err?.error ?? `request_failed_${res.status}`,
|
|
6177
6269
|
err?.code,
|
|
6178
6270
|
err?.conflicts,
|
|
6179
|
-
err?.details
|
|
6271
|
+
err?.details,
|
|
6272
|
+
typeof err?.message === "string" ? err.message : void 0
|
|
6180
6273
|
);
|
|
6181
6274
|
}
|
|
6182
6275
|
return data;
|
|
@@ -6203,12 +6296,38 @@ var ManageApi = class {
|
|
|
6203
6296
|
pub(path) {
|
|
6204
6297
|
return fetch(`${this.base}${path}`, { credentials: "omit" }).then((r) => parse(r));
|
|
6205
6298
|
}
|
|
6206
|
-
// ---- realtime read
|
|
6299
|
+
// ---- realtime read ----
|
|
6300
|
+
/** The chart geometry. Genuinely public — it is the same map buyers see. */
|
|
6207
6301
|
chart(key) {
|
|
6208
6302
|
return this.pub(`/pub/events/${encodeURIComponent(key)}/chart`);
|
|
6209
6303
|
}
|
|
6304
|
+
/**
|
|
6305
|
+
* The ORGANIZER's seat map: physical state, token-authed.
|
|
6306
|
+
*
|
|
6307
|
+
* This used to read `/pub/events/:key/objects` with no credential, which
|
|
6308
|
+
* answers with the BUYER projection — every unit the caller may not buy
|
|
6309
|
+
* collapses to a neutral `blocked`. An anonymous caller may buy only Public
|
|
6310
|
+
* sale inventory, so the cockpit rendered every channel-allocated seat as
|
|
6311
|
+
* blocked and then computed its KPIs, sell-through and (worse) its
|
|
6312
|
+
* block/unblock target sets from that. `/v1/events/:key/objects` returns the
|
|
6313
|
+
* unprojected snapshot the control-room read model already trusts.
|
|
6314
|
+
*/
|
|
6210
6315
|
objects(key) {
|
|
6211
|
-
return this.
|
|
6316
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/objects`);
|
|
6317
|
+
}
|
|
6318
|
+
/**
|
|
6319
|
+
* Exchange the manage token for a one-use organizer socket ticket.
|
|
6320
|
+
*
|
|
6321
|
+
* A browser `WebSocket` cannot send an Authorization header, so the socket's
|
|
6322
|
+
* scope is established here, over ordinary HTTPS. Without it the DO treats a
|
|
6323
|
+
* manager socket as an anonymous public buyer and projects its deltas — so a
|
|
6324
|
+
* hold inside a private allocation is structurally suppressed and the map
|
|
6325
|
+
* drifts away from the truth `objects()` just established.
|
|
6326
|
+
*
|
|
6327
|
+
* Tickets are single-redemption and expire in ~30s: mint one per connect.
|
|
6328
|
+
*/
|
|
6329
|
+
subscribeTicket(key) {
|
|
6330
|
+
return this.auth(`/v1/events/${encodeURIComponent(key)}/subscribe-tickets`, { method: "POST" });
|
|
6212
6331
|
}
|
|
6213
6332
|
socketUrl(key) {
|
|
6214
6333
|
return `${this.base.replace(/^http/, "ws")}/pub/events/${encodeURIComponent(key)}/subscribe?surface=manager`;
|
|
@@ -6346,6 +6465,58 @@ var ManageApi = class {
|
|
|
6346
6465
|
body: { accessIntent }
|
|
6347
6466
|
});
|
|
6348
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
|
+
}
|
|
6349
6520
|
// ---- reports (token) ----
|
|
6350
6521
|
report(key) {
|
|
6351
6522
|
return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
|
|
@@ -6498,6 +6669,26 @@ var CHANNELS_CSS = `
|
|
|
6498
6669
|
border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
|
|
6499
6670
|
overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
6500
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}
|
|
6501
6692
|
.slm-ch-seatlist{max-height:44vh;overflow:auto;border:1px solid var(--slm-line);border-radius:10px;
|
|
6502
6693
|
background:var(--slm-surface);margin-top:10px}
|
|
6503
6694
|
.slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
|
|
@@ -6530,7 +6721,8 @@ var CHANNELS_CSS = `
|
|
|
6530
6721
|
.slm.compact .slm-modes{display:none}
|
|
6531
6722
|
|
|
6532
6723
|
@media (prefers-reduced-motion:reduce){
|
|
6533
|
-
.slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail
|
|
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}
|
|
6534
6726
|
.slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
|
|
6535
6727
|
.slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
|
|
6536
6728
|
.slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
|
|
@@ -6546,9 +6738,37 @@ function bucketRowsHtml(rows) {
|
|
|
6546
6738
|
${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
|
|
6547
6739
|
</div>`).join("");
|
|
6548
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>`;
|
|
6549
6748
|
function esc(value) {
|
|
6550
6749
|
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6551
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
|
+
}
|
|
6552
6772
|
var ChannelsMode = class {
|
|
6553
6773
|
constructor(host, capabilities) {
|
|
6554
6774
|
this.active = false;
|
|
@@ -6565,6 +6785,15 @@ var ChannelsMode = class {
|
|
|
6565
6785
|
this.dialog = null;
|
|
6566
6786
|
this.detent = "medium";
|
|
6567
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";
|
|
6568
6797
|
this.previewAudience = [];
|
|
6569
6798
|
this.previewIncludePublic = false;
|
|
6570
6799
|
this.previewProjection = null;
|
|
@@ -6608,6 +6837,9 @@ var ChannelsMode = class {
|
|
|
6608
6837
|
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
6609
6838
|
this.pollTimer = null;
|
|
6610
6839
|
this.closeDialog({ restoreFocus: false });
|
|
6840
|
+
this.links = [];
|
|
6841
|
+
this.linksChannelId = null;
|
|
6842
|
+
this.linksState = "idle";
|
|
6611
6843
|
this.layer?.classList.remove("on");
|
|
6612
6844
|
this.host.root.classList.remove(
|
|
6613
6845
|
"ch-mode",
|
|
@@ -6679,6 +6911,7 @@ var ChannelsMode = class {
|
|
|
6679
6911
|
this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
|
|
6680
6912
|
}
|
|
6681
6913
|
await this.loadAllocation();
|
|
6914
|
+
if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
|
|
6682
6915
|
this.loading = false;
|
|
6683
6916
|
if (this.active) {
|
|
6684
6917
|
this.paintRail();
|
|
@@ -6705,7 +6938,7 @@ var ChannelsMode = class {
|
|
|
6705
6938
|
limit: 1e3
|
|
6706
6939
|
});
|
|
6707
6940
|
for (const row of res.allocations) {
|
|
6708
|
-
if (row.channelId
|
|
6941
|
+
if (!isPublicChannelId(row.channelId)) next.set(row.label, row.channelId);
|
|
6709
6942
|
}
|
|
6710
6943
|
this.assignmentVersion = res.assignmentVersion;
|
|
6711
6944
|
if (!res.nextAfterLabel) break;
|
|
@@ -6715,8 +6948,13 @@ var ChannelsMode = class {
|
|
|
6715
6948
|
}
|
|
6716
6949
|
// ---- lookups --------------------------------------------------------------
|
|
6717
6950
|
channelById(id) {
|
|
6718
|
-
if (id
|
|
6719
|
-
return {
|
|
6951
|
+
if (isPublicChannelId(id)) {
|
|
6952
|
+
return {
|
|
6953
|
+
id: PUBLIC_CHANNEL_ID,
|
|
6954
|
+
name: this.list?.publicSale?.name ?? PUBLIC_CHANNEL_NAME,
|
|
6955
|
+
marker: "P",
|
|
6956
|
+
color: null
|
|
6957
|
+
};
|
|
6720
6958
|
}
|
|
6721
6959
|
const found = this.list?.channels.find((channel) => channel.id === id);
|
|
6722
6960
|
return found ? { id, name: found.name, marker: found.marker, color: found.color } : null;
|
|
@@ -6937,7 +7175,10 @@ var ChannelsMode = class {
|
|
|
6937
7175
|
this.setBanner(true, names);
|
|
6938
7176
|
try {
|
|
6939
7177
|
this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
|
|
6940
|
-
|
|
7178
|
+
// Naming Public sale as the audience IS asking for public inventory; the
|
|
7179
|
+
// route filters the 'public' sentinel out of `channelIds`, so without
|
|
7180
|
+
// this the request would resolve to an empty scope and 422.
|
|
7181
|
+
includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
|
|
6941
7182
|
});
|
|
6942
7183
|
this.previewSupported = true;
|
|
6943
7184
|
} catch (err) {
|
|
@@ -7108,11 +7349,8 @@ var ChannelsMode = class {
|
|
|
7108
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>
|
|
7109
7350
|
${selfServiceGap ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
|
|
7110
7351
|
<span>This channel is marked for buyer self-service but no buyer has been let in yet.</span></div>` : ""}
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
<div class="slm-ch-row2"><button type="button" class="slm-btn ghost" data-ch-act="server-access" disabled
|
|
7114
|
-
title="Guided server setup ships in the next milestone">Configure server integration \xB7 Coming soon</button></div>
|
|
7115
|
-
<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}` : "";
|
|
7116
7354
|
return `
|
|
7117
7355
|
<p class="slm-eyebrow">
|
|
7118
7356
|
<button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
|
|
@@ -7122,6 +7360,90 @@ var ChannelsMode = class {
|
|
|
7122
7360
|
${access}
|
|
7123
7361
|
${lifecycle}`;
|
|
7124
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
|
+
}
|
|
7125
7447
|
previewRailHtml() {
|
|
7126
7448
|
const audienceOptions = [
|
|
7127
7449
|
{ id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
@@ -7138,7 +7460,7 @@ var ChannelsMode = class {
|
|
|
7138
7460
|
const counts = this.previewProjection?.counts;
|
|
7139
7461
|
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
|
|
7140
7462
|
through this access.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this grant." : ""}</span></div>` : "";
|
|
7141
|
-
const includePublic = current
|
|
7463
|
+
const includePublic = isPublicChannelId(current) ? "" : `
|
|
7142
7464
|
<label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
|
|
7143
7465
|
<input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
|
|
7144
7466
|
Also include Public sale seats in this grant
|
|
@@ -7170,10 +7492,26 @@ var ChannelsMode = class {
|
|
|
7170
7492
|
});
|
|
7171
7493
|
rail.querySelectorAll("[data-ch-detail]").forEach((button) => {
|
|
7172
7494
|
button.addEventListener("click", () => {
|
|
7173
|
-
|
|
7495
|
+
const channelId = button.dataset.chDetail;
|
|
7496
|
+
this.detailChannelId = channelId;
|
|
7174
7497
|
this.paintRail();
|
|
7498
|
+
void this.loadLinks(channelId).then(() => this.paintRail());
|
|
7175
7499
|
});
|
|
7176
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
|
+
});
|
|
7177
7515
|
const target = rail.querySelector("[data-ch-target]");
|
|
7178
7516
|
target?.addEventListener("change", () => {
|
|
7179
7517
|
this.targetChannelId = target.value;
|
|
@@ -7225,8 +7563,17 @@ var ChannelsMode = class {
|
|
|
7225
7563
|
break;
|
|
7226
7564
|
case "back":
|
|
7227
7565
|
this.detailChannelId = null;
|
|
7566
|
+
this.linksChannelId = null;
|
|
7567
|
+
this.links = [];
|
|
7568
|
+
this.linksState = "idle";
|
|
7228
7569
|
this.paintRail();
|
|
7229
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;
|
|
7230
7577
|
case "retry":
|
|
7231
7578
|
void this.refresh();
|
|
7232
7579
|
break;
|
|
@@ -7298,6 +7645,9 @@ var ChannelsMode = class {
|
|
|
7298
7645
|
else if (state.kind === "archive") this.renderArchiveDialog(state);
|
|
7299
7646
|
else if (state.kind === "rename") this.renderRenameDialog(state);
|
|
7300
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);
|
|
7301
7651
|
}
|
|
7302
7652
|
/**
|
|
7303
7653
|
* Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
|
|
@@ -7350,7 +7700,7 @@ var ChannelsMode = class {
|
|
|
7350
7700
|
this.lastFocus = null;
|
|
7351
7701
|
}
|
|
7352
7702
|
renderCreateDialog(state) {
|
|
7353
|
-
const taken = (this.list?.channels ?? []).map((channel) => channel.marker
|
|
7703
|
+
const taken = (this.list?.channels ?? []).map((channel) => markerLetter(channel.marker || channel.name, ""));
|
|
7354
7704
|
const suggestion = suggestMarker("", taken);
|
|
7355
7705
|
this.renderScrim(`
|
|
7356
7706
|
<h3 id="slm-ch-dlg-title">Create channel</h3>
|
|
@@ -7408,7 +7758,8 @@ var ChannelsMode = class {
|
|
|
7408
7758
|
try {
|
|
7409
7759
|
const res = await this.host.api.createChannel(this.host.eventKey, {
|
|
7410
7760
|
name: trimmed,
|
|
7411
|
-
|
|
7761
|
+
// The column is free text; we only ever store what the chip can draw.
|
|
7762
|
+
marker: markerLetter(letter, "") || null,
|
|
7412
7763
|
color: color || null,
|
|
7413
7764
|
externalRef: externalRef.trim() || null
|
|
7414
7765
|
});
|
|
@@ -7477,7 +7828,9 @@ var ChannelsMode = class {
|
|
|
7477
7828
|
this.renderDialog();
|
|
7478
7829
|
try {
|
|
7479
7830
|
const result = await this.host.api.applyChannelAssignment(this.host.eventKey, {
|
|
7480
|
-
|
|
7831
|
+
// `null` is the wire spelling of "back to public sale" every worker
|
|
7832
|
+
// accepts, including ones that predate the 'public' sentinel.
|
|
7833
|
+
targetChannelId: isPublicChannelId(this.targetChannelId) ? null : this.targetChannelId,
|
|
7481
7834
|
labels,
|
|
7482
7835
|
assignmentVersion: this.assignmentVersion
|
|
7483
7836
|
});
|
|
@@ -7704,6 +8057,284 @@ var ChannelsMode = class {
|
|
|
7704
8057
|
});
|
|
7705
8058
|
});
|
|
7706
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
|
+
}
|
|
7707
8338
|
// ---- compact detents ------------------------------------------------------
|
|
7708
8339
|
applySheetClasses() {
|
|
7709
8340
|
const root = this.host.root;
|
|
@@ -8155,7 +8786,7 @@ var SeatManager = class {
|
|
|
8155
8786
|
if (controlRoom?.activity) this.seedFeed(controlRoom.activity);
|
|
8156
8787
|
else this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
|
|
8157
8788
|
});
|
|
8158
|
-
this.connect();
|
|
8789
|
+
void this.connect();
|
|
8159
8790
|
this.startFeedClock();
|
|
8160
8791
|
this.ready = true;
|
|
8161
8792
|
await this.resolveChannelCapabilities();
|
|
@@ -8579,11 +9210,33 @@ var SeatManager = class {
|
|
|
8579
9210
|
});
|
|
8580
9211
|
}
|
|
8581
9212
|
// ---- realtime -------------------------------------------------------------
|
|
8582
|
-
|
|
9213
|
+
/**
|
|
9214
|
+
* Open the cockpit's realtime socket AS THE ORGANIZER.
|
|
9215
|
+
*
|
|
9216
|
+
* The scope has to be established before the upgrade, because a browser
|
|
9217
|
+
* `WebSocket` cannot send an Authorization header: the manage token is traded
|
|
9218
|
+
* over HTTPS for a one-use ticket which rides in `Sec-WebSocket-Protocol`.
|
|
9219
|
+
* Without it the server treats this socket as an anonymous public buyer and
|
|
9220
|
+
* projects its deltas, so any change inside a private channel allocation is
|
|
9221
|
+
* structurally suppressed and the map silently drifts.
|
|
9222
|
+
*
|
|
9223
|
+
* If the mint fails (an expired token, a worker that predates the route) we
|
|
9224
|
+
* still connect unticketed rather than going dark — the public-sale stream is
|
|
9225
|
+
* worth having, and every `resnapshot()` re-establishes physical truth from
|
|
9226
|
+
* the authenticated HTTP read.
|
|
9227
|
+
*/
|
|
9228
|
+
async connect() {
|
|
9229
|
+
if (this.closed) return;
|
|
9230
|
+
let protocols;
|
|
9231
|
+
try {
|
|
9232
|
+
protocols = (await this.api.subscribeTicket(this.key)).protocols;
|
|
9233
|
+
} catch {
|
|
9234
|
+
protocols = void 0;
|
|
9235
|
+
}
|
|
8583
9236
|
if (this.closed) return;
|
|
8584
9237
|
let ws;
|
|
8585
9238
|
try {
|
|
8586
|
-
ws = new WebSocket(this.api.socketUrl(this.key));
|
|
9239
|
+
ws = protocols ? new WebSocket(this.api.socketUrl(this.key), protocols) : new WebSocket(this.api.socketUrl(this.key));
|
|
8587
9240
|
} catch {
|
|
8588
9241
|
this.scheduleReconnect();
|
|
8589
9242
|
return;
|
|
@@ -8613,7 +9266,7 @@ var SeatManager = class {
|
|
|
8613
9266
|
const delay = Math.min(1e3 * 2 ** Math.min(this.attempt++, 5), 15e3);
|
|
8614
9267
|
this.reconnectTimer = setTimeout(() => {
|
|
8615
9268
|
this.reconnectTimer = null;
|
|
8616
|
-
this.connect();
|
|
9269
|
+
void this.connect();
|
|
8617
9270
|
}, delay);
|
|
8618
9271
|
}
|
|
8619
9272
|
onMessage(e) {
|
|
@@ -8643,7 +9296,7 @@ var SeatManager = class {
|
|
|
8643
9296
|
}
|
|
8644
9297
|
if (m.type === "hidden") return;
|
|
8645
9298
|
if (m.seats && typeof m.seats === "object") {
|
|
8646
|
-
this.applySnapshot(m.seats);
|
|
9299
|
+
this.applySnapshot(m.seats, typeof m.default === "string" ? m.default : void 0);
|
|
8647
9300
|
} else if (Array.isArray(m.changes)) {
|
|
8648
9301
|
const ids = [];
|
|
8649
9302
|
const groups = /* @__PURE__ */ new Map();
|
|
@@ -8683,10 +9336,23 @@ var SeatManager = class {
|
|
|
8683
9336
|
} catch {
|
|
8684
9337
|
}
|
|
8685
9338
|
}
|
|
8686
|
-
|
|
9339
|
+
/**
|
|
9340
|
+
* Replace the whole seat model.
|
|
9341
|
+
*
|
|
9342
|
+
* `fallback` is the compact frame's modal status: those snapshots list only
|
|
9343
|
+
* the seats that DIFFER from it, so every other known label takes it. Without
|
|
9344
|
+
* this the omitted majority would silently fall back to `free` — fine when
|
|
9345
|
+
* the mode really is free, wrong the moment it is not.
|
|
9346
|
+
*/
|
|
9347
|
+
applySnapshot(seats, fallback) {
|
|
9348
|
+
const known = (st) => ["free", "held", "booked", "blocked"].includes(st) ? st : "free";
|
|
8687
9349
|
const next = /* @__PURE__ */ new Map();
|
|
9350
|
+
if (fallback !== void 0) {
|
|
9351
|
+
const base = known(fallback);
|
|
9352
|
+
for (const label of this.labelToId.keys()) next.set(label, base);
|
|
9353
|
+
}
|
|
8688
9354
|
for (const [label, st] of Object.entries(seats)) {
|
|
8689
|
-
next.set(label,
|
|
9355
|
+
next.set(label, known(st));
|
|
8690
9356
|
}
|
|
8691
9357
|
this.status = next;
|
|
8692
9358
|
this.lastSyncedAt = Date.now();
|
|
@@ -9885,6 +10551,7 @@ var SeatManager = class {
|
|
|
9885
10551
|
}
|
|
9886
10552
|
};
|
|
9887
10553
|
export {
|
|
10554
|
+
ACCESS_LINK_DEFAULTS,
|
|
9888
10555
|
ApiError,
|
|
9889
10556
|
BuyerAccessContext,
|
|
9890
10557
|
BuyerAccessUnavailableError,
|
|
@@ -9900,12 +10567,18 @@ export {
|
|
|
9900
10567
|
SeatingChart,
|
|
9901
10568
|
accessIntentLabel,
|
|
9902
10569
|
accessLine,
|
|
10570
|
+
accessLinkBadge,
|
|
10571
|
+
accessLinkErrorCopy,
|
|
10572
|
+
accessLinkIsLive,
|
|
10573
|
+
accessLinkPolicyLines,
|
|
9903
10574
|
attachPickerFrame,
|
|
9904
10575
|
bucketRows,
|
|
9905
10576
|
bucketRowsHtml,
|
|
9906
10577
|
createBuyerAccessContext,
|
|
9907
10578
|
createControllerSink,
|
|
9908
10579
|
dropReviewRows,
|
|
10580
|
+
isPublicChannelId,
|
|
10581
|
+
markerLetter,
|
|
9909
10582
|
markerOf,
|
|
9910
10583
|
mutationCount,
|
|
9911
10584
|
needsMoveConfirmation,
|