@seatlayer/js 0.43.2 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +536 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +240 -31
- package/dist/index.d.ts +240 -31
- package/dist/index.js +533 -97
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -6493,13 +6493,59 @@ function retryAfterCopy(details) {
|
|
|
6493
6493
|
}
|
|
6494
6494
|
function accessLine(access) {
|
|
6495
6495
|
if (!access || !access.intent) return "\u2014";
|
|
6496
|
-
const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : "
|
|
6496
|
+
const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : access.intent === "internal" ? "Your staff sell these" : "Protected reserve";
|
|
6497
6497
|
const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
|
|
6498
6498
|
const detail = access.detail ?? grants;
|
|
6499
6499
|
return detail ? `${base} \xB7 ${detail}` : base;
|
|
6500
6500
|
}
|
|
6501
6501
|
function accessIntentLabel(intent) {
|
|
6502
|
-
return intent === "internal" ? "
|
|
6502
|
+
return intent === "internal" ? "Sell through your own staff" : intent === "server" ? "Integrate a website or app" : intent === "hosted_link" ? "Sell with a buyer link" : "Keep as protected reserve";
|
|
6503
|
+
}
|
|
6504
|
+
function accessIntentDescription(intent) {
|
|
6505
|
+
switch (intent) {
|
|
6506
|
+
case "internal":
|
|
6507
|
+
return "Only your own box office can sell these seats, through your secret key. Buyer links and website integrations are refused.";
|
|
6508
|
+
case "server":
|
|
6509
|
+
return "Your website's backend mints each buyer a short-lived session for these seats. Buyer links are refused; the code lives on the Embed page.";
|
|
6510
|
+
case "hosted_link":
|
|
6511
|
+
return "SeatLayer makes a link you send to a named group. They open it and buy only these seats. No other route can sell them.";
|
|
6512
|
+
default:
|
|
6513
|
+
return "Nobody can buy these seats. Every way of letting a buyer in \u2014 a buyer link, your website, even your own staff \u2014 is refused while this is the route. The seats stay out of public sale.";
|
|
6514
|
+
}
|
|
6515
|
+
}
|
|
6516
|
+
function intentForRoute(route) {
|
|
6517
|
+
return route === "hosted_link" ? "hosted_link" : route === "server" ? "server" : route === "staff" ? "internal" : null;
|
|
6518
|
+
}
|
|
6519
|
+
function intentForbidsCopy(details) {
|
|
6520
|
+
const current = parseIntent(details?.accessIntent);
|
|
6521
|
+
const wanted = intentForRoute(details?.route);
|
|
6522
|
+
const head = `This channel is set to "${accessIntentLabel(current)}"`;
|
|
6523
|
+
return wanted ? `${head}, so it cannot do that. Switch it to "${accessIntentLabel(wanted)}" first.` : `${head}, so it cannot do that. Choose a different route for this channel first.`;
|
|
6524
|
+
}
|
|
6525
|
+
function parseIntent(value) {
|
|
6526
|
+
return value === "internal" || value === "server" || value === "hosted_link" || value === "none" ? value : "none";
|
|
6527
|
+
}
|
|
6528
|
+
function plural(count, one, many) {
|
|
6529
|
+
return `${count.toLocaleString()} ${count === 1 ? one : many}`;
|
|
6530
|
+
}
|
|
6531
|
+
function intentSwitchBlockedCopy(details) {
|
|
6532
|
+
const links = Math.max(0, details?.liveAccessLinks ?? 0);
|
|
6533
|
+
const sessions = Math.max(0, details?.activeSessions ?? 0);
|
|
6534
|
+
const from = parseIntent(details?.from);
|
|
6535
|
+
const to = parseIntent(details?.to);
|
|
6536
|
+
const live = [
|
|
6537
|
+
links ? plural(links, "buyer link is live", "buyer links are live") : null,
|
|
6538
|
+
sessions ? plural(sessions, "buyer is in a checkout", "buyers are in a checkout") : null
|
|
6539
|
+
].filter(Boolean).join(", and ");
|
|
6540
|
+
const headline = `${live || "Buyers are inside this channel"} on "${accessIntentLabel(from)}". Moving it to "${accessIntentLabel(to)}" changes what happens to them.`;
|
|
6541
|
+
const consequences = [];
|
|
6542
|
+
if (links) {
|
|
6543
|
+
consequences.push(`${plural(links, "buyer link closes", "buyer links close")} immediately. Anyone who has not opened it yet never will \u2014 send a new link if you still need one.`);
|
|
6544
|
+
}
|
|
6545
|
+
if (sessions) {
|
|
6546
|
+
consequences.push(`${plural(sessions, "buyer who is already in a checkout keeps", "buyers who are already in a checkout keep")} their seats and can finish paying. Nobody is thrown out. No new buyers come in this way, so the old route empties on its own within 12 hours.`);
|
|
6547
|
+
}
|
|
6548
|
+
return { headline, consequences };
|
|
6503
6549
|
}
|
|
6504
6550
|
var ACCESS_LINK_DEFAULTS = {
|
|
6505
6551
|
maxRedemptions: 100,
|
|
@@ -6564,6 +6610,11 @@ function accessLinkErrorCopy(err) {
|
|
|
6564
6610
|
return "That link is no longer active, so it cannot be rotated or revoked.";
|
|
6565
6611
|
case "channel_unavailable":
|
|
6566
6612
|
return "This channel is paused or archived, so it cannot let new buyers in. Resume it first.";
|
|
6613
|
+
// The channel declares a different sale route. The UI declares `hosted_link`
|
|
6614
|
+
// before it creates, so reaching this means the declaration itself was
|
|
6615
|
+
// refused or raced — say which route is in the way, not the code.
|
|
6616
|
+
case "channel_access_intent_forbids":
|
|
6617
|
+
return intentForbidsCopy(err?.details);
|
|
6567
6618
|
case "end_active_sessions_required":
|
|
6568
6619
|
return "Choose what happens to the buyers who already came in through this link.";
|
|
6569
6620
|
case "not_found":
|
|
@@ -6797,13 +6848,31 @@ var ManageApi = class {
|
|
|
6797
6848
|
const qs = params.toString();
|
|
6798
6849
|
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/preview${qs ? `?${qs}` : ""}`);
|
|
6799
6850
|
}
|
|
6800
|
-
/**
|
|
6801
|
-
*
|
|
6802
|
-
*
|
|
6803
|
-
|
|
6851
|
+
/**
|
|
6852
|
+
* Choose which sale route this channel opens.
|
|
6853
|
+
*
|
|
6854
|
+
* Since the server's 2026-08-06 change this is AUTHORIZATION, not a label:
|
|
6855
|
+
* exactly one of the four routes may mint buyer access for the channel and the
|
|
6856
|
+
* other three refuse with 409 `channel_access_intent_forbids`. The default is
|
|
6857
|
+
* `none`, which refuses all four — so a route has to be declared before any
|
|
6858
|
+
* buyer-facing action on the channel can succeed.
|
|
6859
|
+
*
|
|
6860
|
+
* Switching the route while buyers are already inside the current one is
|
|
6861
|
+
* refused with 409 `channel_intent_switch_blocked`, whose `details` name what
|
|
6862
|
+
* is live (`liveAccessLinks`, `activeSessions`). Retry with
|
|
6863
|
+
* `acknowledgeLiveAccess: true`: hosted links on the channel are revoked,
|
|
6864
|
+
* while sessions already minted keep their holds and drain on their own.
|
|
6865
|
+
* `intentSwitch` is present on the response ONLY when the switch disturbed
|
|
6866
|
+
* something, so the ordinary case stays the two-key body it has always been.
|
|
6867
|
+
*/
|
|
6868
|
+
setChannelAccessIntent(key, channelId, accessIntent, opts = {}) {
|
|
6804
6869
|
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
|
|
6805
6870
|
method: "PATCH",
|
|
6806
|
-
body: {
|
|
6871
|
+
body: {
|
|
6872
|
+
accessIntent,
|
|
6873
|
+
...opts.acknowledgeLiveAccess ? { acknowledgeLiveAccess: true } : {},
|
|
6874
|
+
...opts.reason ? { reason: opts.reason } : {}
|
|
6875
|
+
}
|
|
6807
6876
|
});
|
|
6808
6877
|
}
|
|
6809
6878
|
// ---- hosted access links (M8) ----
|
|
@@ -6818,8 +6887,11 @@ var ManageApi = class {
|
|
|
6818
6887
|
* Platform bounds are enforced server-side and reported as 422 with the rule
|
|
6819
6888
|
* spelled out in `ManageApiError.serverMessage`.
|
|
6820
6889
|
*
|
|
6821
|
-
*
|
|
6822
|
-
* `hosted_link
|
|
6890
|
+
* NOT a side effect any more. This used to SET the channel's access intent to
|
|
6891
|
+
* `hosted_link`; since 2026-08-06 it REQUIRES it, and a channel declaring any
|
|
6892
|
+
* other route refuses with 409 `channel_access_intent_forbids`. Callers must
|
|
6893
|
+
* declare the route first — `ChannelsMode` does exactly that before it
|
|
6894
|
+
* creates, so a first buyer link on a fresh channel is still one gesture.
|
|
6823
6895
|
*/
|
|
6824
6896
|
createAccessLink(key, channelId, input = {}) {
|
|
6825
6897
|
return this.auth(
|
|
@@ -6976,7 +7048,23 @@ var CHANNELS_CSS = (
|
|
|
6976
7048
|
.slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
|
|
6977
7049
|
@keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
|
|
6978
7050
|
.slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
|
|
6979
|
-
|
|
7051
|
+
/* A row that opens its channel must READ as pressable, and be one for a keyboard
|
|
7052
|
+
too. It is a role="button" div rather than a <button> because the \u22EF control
|
|
7053
|
+
lives inside it, and a button inside a button is not valid HTML. */
|
|
7054
|
+
.slm-ch-row.open{cursor:pointer}
|
|
7055
|
+
.slm-ch-row.open:hover{border-color:var(--slm-accent);background:color-mix(in srgb,var(--slm-accent) 6%,var(--slm-surface))}
|
|
7056
|
+
.slm-ch-row.open:focus-visible{outline:2px solid var(--slm-accent);outline-offset:2px}
|
|
7057
|
+
.slm-ch-row.open:active{border-color:var(--slm-accent)}
|
|
7058
|
+
.slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px;border-radius:6px}
|
|
7059
|
+
.slm-ch-more:hover{color:var(--slm-text)}
|
|
7060
|
+
.slm-ch-more:focus-visible{outline:2px solid var(--slm-accent);outline-offset:1px;color:var(--slm-text)}
|
|
7061
|
+
.slm-ch-menu{display:flex;flex-direction:column;gap:8px}
|
|
7062
|
+
.slm-ch-menu .slm-btn{width:100%}
|
|
7063
|
+
/* loading is a state, never a flash of empty: same slot, visibly working */
|
|
7064
|
+
.slm-ch-busy{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--slm-muted);padding:12px 0}
|
|
7065
|
+
.slm-ch-busy::before{content:"";width:9px;height:9px;border-radius:50%;background:var(--slm-accent);flex:none;
|
|
7066
|
+
animation:slm-ch-pulse var(--slm-mo-ambient) var(--slm-mo-in-out) infinite}
|
|
7067
|
+
@keyframes slm-ch-pulse{0%,100%{opacity:.25;transform:scale(.7)}50%{opacity:1;transform:scale(1)}}
|
|
6980
7068
|
.slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
|
|
6981
7069
|
.slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
|
|
6982
7070
|
.slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
|
|
@@ -6986,10 +7074,14 @@ var CHANNELS_CSS = (
|
|
|
6986
7074
|
.slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
|
|
6987
7075
|
.slm-ch-row2{display:flex;gap:8px;margin-top:8px}
|
|
6988
7076
|
.slm-ch-row2 .slm-btn{flex:1;min-width:0}
|
|
6989
|
-
/* distribute
|
|
7077
|
+
/* distribute routes \u2014 four choices, each with the one sentence that explains it.
|
|
7078
|
+
The current one is NAMED in its own card, never signalled by colour alone. */
|
|
6990
7079
|
.slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
|
|
6991
7080
|
border-radius:10px;background:var(--slm-surface)}
|
|
6992
|
-
.slm-ch-dist
|
|
7081
|
+
.slm-ch-dist.on{border-color:var(--slm-accent,#8b7cf6)}
|
|
7082
|
+
.slm-ch-dist b{font-size:13px;font-weight:800;display:flex;align-items:center;gap:8px;justify-content:space-between}
|
|
7083
|
+
.slm-ch-dist b .cur{font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
|
|
7084
|
+
color:var(--slm-accent,#8b7cf6);white-space:nowrap}
|
|
6993
7085
|
.slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
|
|
6994
7086
|
.slm-ch-dist .slm-btn{width:100%;margin-top:2px}
|
|
6995
7087
|
.slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
|
|
@@ -7108,6 +7200,7 @@ var CHANNELS_CSS = (
|
|
|
7108
7200
|
.slm-ch-meter i,.slm-ch-radio{transition:none!important}
|
|
7109
7201
|
.slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
|
|
7110
7202
|
.slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
|
|
7203
|
+
.slm-ch-busy::before{animation:none!important;opacity:1}
|
|
7111
7204
|
.slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
|
|
7112
7205
|
.slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
|
|
7113
7206
|
}
|
|
@@ -7168,6 +7261,18 @@ var ChannelsMode = class {
|
|
|
7168
7261
|
this.focusedSectionId = null;
|
|
7169
7262
|
this.showArchived = false;
|
|
7170
7263
|
this.detailChannelId = null;
|
|
7264
|
+
/**
|
|
7265
|
+
* Whether the organizer has asked to assign seats.
|
|
7266
|
+
*
|
|
7267
|
+
* The list rail leads with the CHANNELS. The assignment tooling — destination
|
|
7268
|
+
* picker plus five select-by routes — used to paint unconditionally above that
|
|
7269
|
+
* list, so a first-time organizer met a workbench for a job they had not asked
|
|
7270
|
+
* to do, with the thing they came for pushed below the fold. It is now
|
|
7271
|
+
* disclosed on intent: the "Assign seats to a channel" action under the list,
|
|
7272
|
+
* the map's own "Assign seats" segment, or simply having a selection. The
|
|
7273
|
+
* selection rail always shows the tools, because there the intent is proven.
|
|
7274
|
+
*/
|
|
7275
|
+
this.assignOpen = false;
|
|
7171
7276
|
this.targetChannelId = "";
|
|
7172
7277
|
this.conflict = false;
|
|
7173
7278
|
this.dialog = null;
|
|
@@ -7194,8 +7299,16 @@ var ChannelsMode = class {
|
|
|
7194
7299
|
this.previewAudience = [];
|
|
7195
7300
|
this.previewIncludePublic = false;
|
|
7196
7301
|
this.previewProjection = null;
|
|
7197
|
-
|
|
7198
|
-
|
|
7302
|
+
/**
|
|
7303
|
+
* The buyer-preview read, as one honest state rather than a boolean.
|
|
7304
|
+
*
|
|
7305
|
+
* `previewSupported: boolean | null` could say "this worker has no projection
|
|
7306
|
+
* route", but it could not say "the read is in flight" or "the read failed" —
|
|
7307
|
+
* both of those painted an audience picker with no result underneath, which
|
|
7308
|
+
* reads as "this audience can buy nothing". Loading and error are now states
|
|
7309
|
+
* of their own, and the error one offers a retry.
|
|
7310
|
+
*/
|
|
7311
|
+
this.previewState = "idle";
|
|
7199
7312
|
this.pollTimer = null;
|
|
7200
7313
|
this.layer = null;
|
|
7201
7314
|
this.canvas = null;
|
|
@@ -7233,6 +7346,7 @@ var ChannelsMode = class {
|
|
|
7233
7346
|
if (this.active) return;
|
|
7234
7347
|
this.active = true;
|
|
7235
7348
|
this.mapIntent = "pan";
|
|
7349
|
+
this.assignOpen = false;
|
|
7236
7350
|
this.focusedSectionId = null;
|
|
7237
7351
|
this.assignmentRowsCache = null;
|
|
7238
7352
|
this.railHtml = null;
|
|
@@ -7742,6 +7856,7 @@ var ChannelsMode = class {
|
|
|
7742
7856
|
this.view = view;
|
|
7743
7857
|
if (view === "inspect") {
|
|
7744
7858
|
this.previewProjection = null;
|
|
7859
|
+
this.previewState = "idle";
|
|
7745
7860
|
this.setBanner(false);
|
|
7746
7861
|
} else {
|
|
7747
7862
|
if (!this.previewAudience.length) {
|
|
@@ -7759,6 +7874,9 @@ var ChannelsMode = class {
|
|
|
7759
7874
|
const audience = [...this.previewAudience];
|
|
7760
7875
|
const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
|
|
7761
7876
|
this.setBanner(true, names);
|
|
7877
|
+
this.previewProjection = null;
|
|
7878
|
+
this.previewState = "loading";
|
|
7879
|
+
if (this.active) this.paintRail();
|
|
7762
7880
|
try {
|
|
7763
7881
|
this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
|
|
7764
7882
|
// Naming Public sale as the audience IS asking for public inventory; the
|
|
@@ -7766,14 +7884,14 @@ var ChannelsMode = class {
|
|
|
7766
7884
|
// this the request would resolve to an empty scope and 422.
|
|
7767
7885
|
includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
|
|
7768
7886
|
});
|
|
7769
|
-
this.
|
|
7887
|
+
this.previewState = "ready";
|
|
7770
7888
|
const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
|
|
7771
7889
|
this.setBanner(true, names, eligibleSeats);
|
|
7772
7890
|
} catch (err) {
|
|
7773
7891
|
const status = err instanceof ManageApiError ? err.status : 0;
|
|
7774
|
-
this.
|
|
7892
|
+
this.previewState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
|
|
7775
7893
|
this.previewProjection = null;
|
|
7776
|
-
if (this.
|
|
7894
|
+
if (this.previewState === "error") this.host.onError(err);
|
|
7777
7895
|
}
|
|
7778
7896
|
if (this.active) {
|
|
7779
7897
|
this.paintRail();
|
|
@@ -7814,13 +7932,14 @@ var ChannelsMode = class {
|
|
|
7814
7932
|
}
|
|
7815
7933
|
if (this.loading && !this.list) {
|
|
7816
7934
|
this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
|
|
7817
|
-
<div class="slm-
|
|
7935
|
+
<div class="slm-ch-busy" role="status" data-ch-state="list-loading">Loading channels and allocations\u2026</div>`);
|
|
7818
7936
|
return;
|
|
7819
7937
|
}
|
|
7820
7938
|
if (!this.list && this.loadError) {
|
|
7821
7939
|
if (this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
|
|
7822
|
-
<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
|
|
7823
|
-
<span><b>Couldn't load sales channels.</b>
|
|
7940
|
+
<div class="slm-ch-alert err" role="alert" data-ch-state="list-error"><span>\u26A0</span>
|
|
7941
|
+
<span><b>Couldn't load sales channels.</b> This event may well have channels \u2014 we could not read them.
|
|
7942
|
+
Everything else on this event still works.
|
|
7824
7943
|
<button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
|
|
7825
7944
|
rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
|
|
7826
7945
|
void this.refresh();
|
|
@@ -7879,10 +7998,13 @@ var ChannelsMode = class {
|
|
|
7879
7998
|
const marker = this.markerFor(channel.id);
|
|
7880
7999
|
const badgeKind = opts.builtin ? "builtin" : channel.state;
|
|
7881
8000
|
const dim = channel.state === "paused" || channel.state === "archived" ? " dim" : "";
|
|
7882
|
-
const
|
|
7883
|
-
const
|
|
7884
|
-
|
|
7885
|
-
|
|
8001
|
+
const opens = !opts.builtin && this.caps.manage && this.detailChannelId !== channel.id;
|
|
8002
|
+
const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}${opens ? " open" : ""}`;
|
|
8003
|
+
const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-menu="${esc(channel.id)}"
|
|
8004
|
+
aria-label="More actions for ${esc(channel.name)}" aria-haspopup="dialog">\u22EF</button>` : "";
|
|
8005
|
+
const rowAttrs = opens ? ` role="button" tabindex="0" data-ch-open="${esc(channel.id)}"
|
|
8006
|
+
aria-label="Open ${esc(channel.name)}"` : "";
|
|
8007
|
+
return `<div class="${cls}"${rowAttrs}>
|
|
7886
8008
|
<span class="slm-ch-head">
|
|
7887
8009
|
<span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
|
|
7888
8010
|
<span class="slm-ch-name">${esc(channel.name)}</span>
|
|
@@ -7896,41 +8018,68 @@ var ChannelsMode = class {
|
|
|
7896
8018
|
listRailHtml() {
|
|
7897
8019
|
const list = this.list;
|
|
7898
8020
|
const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
|
|
8021
|
+
const visible = list.channels.filter((channel) => this.showArchived || channel.state !== "archived");
|
|
7899
8022
|
const rows = [
|
|
7900
8023
|
this.channelRowHtml(list.publicSale, { builtin: true }),
|
|
7901
|
-
...
|
|
8024
|
+
...visible.map((channel, index) => this.channelRowHtml(channel, { index }))
|
|
7902
8025
|
].join("");
|
|
8026
|
+
const empty = visible.length ? "" : `<p class="slm-note" data-ch-state="list-empty">${archivedCount && !this.showArchived ? `No open channels \u2014 every seat is on public sale. ${archivedCount.toLocaleString()} archived channel${archivedCount === 1 ? " is" : "s are"} hidden below.` : "No private channels yet \u2014 every seat is on public sale."}</p>`;
|
|
7903
8027
|
const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
|
|
7904
8028
|
const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
|
|
7905
8029
|
return `
|
|
7906
|
-
${this.caps.manage ? this.assignmentToolsHtml() : ""}
|
|
7907
8030
|
<p class="slm-eyebrow">Sales channels</p>
|
|
7908
8031
|
<p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
|
|
7909
8032
|
<div class="slm-ch-list">${rows}</div>
|
|
8033
|
+
${empty}
|
|
7910
8034
|
${create}
|
|
7911
8035
|
${readOnly}
|
|
8036
|
+
${this.assignEntryHtml()}
|
|
7912
8037
|
<p class="slm-note" style="margin-top:10px">
|
|
7913
8038
|
<button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
|
|
7914
8039
|
style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
|
|
7915
8040
|
</p>`;
|
|
7916
8041
|
}
|
|
8042
|
+
/**
|
|
8043
|
+
* The assignment entry point on the list rail.
|
|
8044
|
+
*
|
|
8045
|
+
* Collapsed it is ONE plain-language action, so the channels the organizer came
|
|
8046
|
+
* for stay at the top of the panel. Opened it is the same tool set that has
|
|
8047
|
+
* always worked, in the same order, plus the way back out — and opening it is
|
|
8048
|
+
* also what the map's "Assign seats" segment does, so the two routes into the
|
|
8049
|
+
* job cannot disagree about whether it is running.
|
|
8050
|
+
*/
|
|
8051
|
+
assignEntryHtml() {
|
|
8052
|
+
if (!this.caps.manage) return "";
|
|
8053
|
+
if (!this.assignOpen) {
|
|
8054
|
+
return `
|
|
8055
|
+
<button type="button" class="slm-btn ghost" style="width:100%;margin-top:8px"
|
|
8056
|
+
data-ch-act="assign-open" aria-expanded="false">Assign seats to a channel</button>
|
|
8057
|
+
<p class="slm-note">Move whole sections, rows, a dragged area or single seats out of public sale
|
|
8058
|
+
and into a channel. Nothing moves until you review and apply.</p>`;
|
|
8059
|
+
}
|
|
8060
|
+
return `<div style="margin-top:8px">${this.assignmentToolsHtml({ collapsible: true })}</div>`;
|
|
8061
|
+
}
|
|
7917
8062
|
/**
|
|
7918
8063
|
* Destination-first assignment controls.
|
|
7919
8064
|
*
|
|
7920
8065
|
* These used to live only inside the selection rail, which meant every route
|
|
7921
8066
|
* into them was gated behind "select a seat on the map first" — the section,
|
|
7922
8067
|
* row and category choosers were invisible until the organizer had already
|
|
7923
|
-
* done the work by hand. They
|
|
7924
|
-
*
|
|
7925
|
-
*
|
|
8068
|
+
* done the work by hand. They are still reachable before a seat is selected,
|
|
8069
|
+
* but they are no longer the first thing in the panel: on the list rail they
|
|
8070
|
+
* are disclosed by `assignEntryHtml`, and there they carry a way back out
|
|
8071
|
+
* (`collapsible`). In the selection rail there is nothing to disclose — a
|
|
8072
|
+
* selection IS the intent — so they paint unconditionally and without Done.
|
|
7926
8073
|
*/
|
|
7927
|
-
assignmentToolsHtml() {
|
|
8074
|
+
assignmentToolsHtml(opts = {}) {
|
|
7928
8075
|
const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
|
|
7929
8076
|
const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
|
|
7930
8077
|
const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
|
|
7931
8078
|
const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
|
|
8079
|
+
const done = opts.collapsible ? `<button type="button" class="slm-linkbtn" data-ch-act="assign-close"
|
|
8080
|
+
aria-expanded="true" style="float:right;font-weight:800">Done</button>` : "";
|
|
7932
8081
|
return `
|
|
7933
|
-
<p class="slm-eyebrow"
|
|
8082
|
+
<p class="slm-eyebrow">${done}Assign inventory</p>
|
|
7934
8083
|
<div class="slm-field">
|
|
7935
8084
|
<label for="slm-ch-target">Assign to</label>
|
|
7936
8085
|
<select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
|
|
@@ -7980,8 +8129,16 @@ var ChannelsMode = class {
|
|
|
7980
8129
|
<p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
|
|
7981
8130
|
}
|
|
7982
8131
|
detailRailHtml(channelId) {
|
|
8132
|
+
const back = `<p class="slm-eyebrow">
|
|
8133
|
+
<button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
|
|
8134
|
+
</p>`;
|
|
7983
8135
|
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
7984
|
-
if (!channel)
|
|
8136
|
+
if (!channel) {
|
|
8137
|
+
return `${back}
|
|
8138
|
+
<div class="slm-ch-alert warn" role="status" data-ch-state="detail-gone"><span>\u2139</span>
|
|
8139
|
+
<span><b>This channel is no longer on this event.</b> It was archived or removed while you had it open.
|
|
8140
|
+
${this.showArchived ? "" : "Archived channels are hidden \u2014 use \u201CShow archived\u201D on the list to see it."}</span></div>`;
|
|
8141
|
+
}
|
|
7985
8142
|
const lifecycle = this.caps.manage ? `
|
|
7986
8143
|
<p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
|
|
7987
8144
|
<div class="slm-ch-row2" style="margin-top:2px">
|
|
@@ -7992,58 +8149,59 @@ var ChannelsMode = class {
|
|
|
7992
8149
|
<p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
|
|
7993
8150
|
const access = this.caps.manage ? this.distributeHtml(channel) : "";
|
|
7994
8151
|
return `
|
|
7995
|
-
|
|
7996
|
-
<button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
|
|
7997
|
-
</p>
|
|
8152
|
+
${back}
|
|
7998
8153
|
<p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
|
|
7999
8154
|
<div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
|
|
8000
8155
|
${access}
|
|
8001
8156
|
${lifecycle}`;
|
|
8002
8157
|
}
|
|
8003
8158
|
/**
|
|
8004
|
-
* "Distribute" —
|
|
8159
|
+
* "Distribute" — the ONE way this channel's seats reach a buyer.
|
|
8160
|
+
*
|
|
8161
|
+
* All four routes are here, and this is a real chooser again. It was cut down
|
|
8162
|
+
* to two actions in 0.42.0 for a good reason: `access_intent` was stored,
|
|
8163
|
+
* audited, and read by nothing, so "Keep as protected reserve" and "Sell
|
|
8164
|
+
* through your own staff" were labels an organizer could set and then wait
|
|
8165
|
+
* forever for something to happen. The server closed that hole on 2026-08-06 —
|
|
8166
|
+
* each declaration now opens exactly one route and REFUSES the other three —
|
|
8167
|
+
* so all four are honest choices and belong on the surface.
|
|
8005
8168
|
*
|
|
8006
|
-
*
|
|
8007
|
-
*
|
|
8008
|
-
*
|
|
8009
|
-
* organizer could set and then wait forever for something to happen. A third,
|
|
8010
|
-
* `hosted_link`, is not the organizer's to choose at all — the server sets it
|
|
8011
|
-
* when a buyer link is created and clears it when the last live one is revoked.
|
|
8169
|
+
* None of the old copy came back with them. These sentences are written
|
|
8170
|
+
* against the enforcement matrix (`accessIntentDescription`), which is why
|
|
8171
|
+
* each one says what the route refuses as well as what it allows.
|
|
8012
8172
|
*
|
|
8013
|
-
*
|
|
8014
|
-
*
|
|
8015
|
-
*
|
|
8016
|
-
*
|
|
8017
|
-
* value is left alone (it is organizer-declared metadata and the API that
|
|
8018
|
-
* writes it is unchanged), it simply no longer has a control of its own.
|
|
8173
|
+
* The current route is stated, not merely styled: a chooser whose selection
|
|
8174
|
+
* you have to infer from a border is not a chooser. Its card carries a
|
|
8175
|
+
* "Current route" marker and drops its own select button, because pressing it
|
|
8176
|
+
* would do nothing.
|
|
8019
8177
|
*/
|
|
8020
8178
|
distributeHtml(channel) {
|
|
8021
8179
|
const intent = channel.access?.intent ?? "none";
|
|
8022
8180
|
const live = channel.access?.hasActiveGrants === true;
|
|
8023
|
-
const state = live ? intent === "server" ? "Your website is letting buyers in. Only they can buy these seats." : "A buyer link is live. Only people with that link can buy these seats." : intent === "server" ? "Set up for your website \u2014 no buyer has come through yet. Seats stay reserved." : "Not distributed yet \u2014 seats stay reserved.";
|
|
8024
|
-
const option = (title, why, action, cta, primary) => `<div class="slm-ch-dist">
|
|
8025
|
-
<b>${esc(title)}</b>
|
|
8026
|
-
<span class="why">${esc(why)}</span>
|
|
8027
|
-
<button type="button" class="slm-btn${primary ? "" : " ghost"}" data-ch-act="${esc(action)}">${esc(cta)}</button>
|
|
8028
|
-
</div>`;
|
|
8029
8181
|
const linksSupported = this.linksState !== "unsupported";
|
|
8182
|
+
const hasLiveLink = this.links.some(accessLinkIsLive);
|
|
8183
|
+
const state = intent === "none" ? "Protected reserve \u2014 no route can sell these seats. Every buyer path is refused." : intent === "internal" ? "Your staff sell these seats. No buyer-facing route is open." : live ? intent === "server" ? "Your website is letting buyers in. Only they can buy these seats." : "A buyer link is live. Only people with that link can buy these seats." : intent === "server" ? "Set up for your website \u2014 no buyer has come through yet. Seats stay reserved." : "Set up for buyer links \u2014 no buyer has come through yet. Seats stay reserved.";
|
|
8184
|
+
const option = (route, action, cta, primary) => {
|
|
8185
|
+
const current = route === intent;
|
|
8186
|
+
return `<div class="slm-ch-dist${current ? " on" : ""}" data-ch-route="${esc(route)}">
|
|
8187
|
+
<b>${esc(accessIntentLabel(route))}${current ? '<span class="cur">Current route</span>' : ""}</b>
|
|
8188
|
+
<span class="why">${esc(accessIntentDescription(route))}</span>
|
|
8189
|
+
${current && !cta ? "" : `<button type="button" class="slm-btn${primary && !current ? "" : " ghost"}"
|
|
8190
|
+
data-ch-act="${esc(action)}">${esc(cta)}</button>`}
|
|
8191
|
+
</div>`;
|
|
8192
|
+
};
|
|
8030
8193
|
return `
|
|
8031
8194
|
<p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
|
|
8032
8195
|
<p class="slm-hint">${esc(state)}</p>
|
|
8033
8196
|
${linksSupported ? option(
|
|
8034
|
-
"
|
|
8035
|
-
"SeatLayer makes a link you send to a named group. They open it and buy only these seats.",
|
|
8197
|
+
"hosted_link",
|
|
8036
8198
|
"link-create",
|
|
8037
|
-
|
|
8199
|
+
hasLiveLink ? "Create another buyer link" : "Create buyer link",
|
|
8038
8200
|
true
|
|
8039
8201
|
) : ""}
|
|
8040
|
-
${option(
|
|
8041
|
-
"
|
|
8042
|
-
"
|
|
8043
|
-
"embed-code",
|
|
8044
|
-
"Get embed code",
|
|
8045
|
-
false
|
|
8046
|
-
)}
|
|
8202
|
+
${option("server", "embed-code", intent === "server" ? "Get embed code" : "Use a website or app", false)}
|
|
8203
|
+
${option("internal", "route-internal", intent === "internal" ? "" : "Hand to your staff", false)}
|
|
8204
|
+
${option("none", "route-none", intent === "none" ? "" : "Keep as reserve", false)}
|
|
8047
8205
|
${this.hostedLinksHtml()}
|
|
8048
8206
|
${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
|
|
8049
8207
|
}
|
|
@@ -8091,16 +8249,17 @@ var ChannelsMode = class {
|
|
|
8091
8249
|
hostedLinksHtml() {
|
|
8092
8250
|
const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
|
|
8093
8251
|
if (this.linksState === "unsupported") {
|
|
8094
|
-
return `${eyebrow}<div class="slm-ch-alert warn"><span>\u2139</span>
|
|
8252
|
+
return `${eyebrow}<div class="slm-ch-alert warn" data-ch-state="links-unsupported"><span>\u2139</span>
|
|
8095
8253
|
<span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
|
|
8096
8254
|
}
|
|
8097
8255
|
if (this.linksState === "error") {
|
|
8098
|
-
return `${eyebrow}<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
|
|
8099
|
-
<span><b>Couldn't load this channel's links.</b>
|
|
8256
|
+
return `${eyebrow}<div class="slm-ch-alert err" role="alert" data-ch-state="links-error"><span>\u26A0</span>
|
|
8257
|
+
<span><b>Couldn't load this channel's links.</b> This is a failed read, not an empty list \u2014
|
|
8258
|
+
any live link is still working.
|
|
8100
8259
|
<button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
|
|
8101
8260
|
}
|
|
8102
|
-
if (this.linksState === "loading" && !this.links.length) {
|
|
8103
|
-
return `${eyebrow}<div class="slm-
|
|
8261
|
+
if (this.linksState === "idle" || this.linksState === "loading" && !this.links.length) {
|
|
8262
|
+
return `${eyebrow}<div class="slm-ch-busy" role="status" data-ch-state="links-loading">Loading buyer links\u2026</div>`;
|
|
8104
8263
|
}
|
|
8105
8264
|
if (!this.links.length) return "";
|
|
8106
8265
|
return `${eyebrow}
|
|
@@ -8142,9 +8301,14 @@ var ChannelsMode = class {
|
|
|
8142
8301
|
];
|
|
8143
8302
|
const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
|
|
8144
8303
|
const options = audienceOptions.map((entry) => `<option value="${esc(entry.id)}"${entry.id === current ? " selected" : ""}>${esc(entry.name)}</option>`).join("");
|
|
8145
|
-
const unsupported = this.
|
|
8304
|
+
const unsupported = this.previewState === "unsupported" ? `<div class="slm-ch-alert warn" data-ch-state="preview-unsupported"><span>\u2139</span>
|
|
8146
8305
|
<span><b>Preview needs a newer server.</b> Allocation management works normally;
|
|
8147
8306
|
the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
|
|
8307
|
+
const busy = this.previewState === "loading" ? `<div class="slm-ch-busy" role="status" data-ch-state="preview-loading">Asking the server what this audience sees\u2026</div>` : "";
|
|
8308
|
+
const failed = this.previewState === "error" ? `<div class="slm-ch-alert err" role="alert" data-ch-state="preview-error"><span>\u26A0</span>
|
|
8309
|
+
<span><b>Couldn't load the buyer preview.</b> Nothing is wrong with this audience's seats \u2014
|
|
8310
|
+
the simulation itself failed to load.
|
|
8311
|
+
<button type="button" data-ch-act="preview-retry">Try again</button></span></div>` : "";
|
|
8148
8312
|
const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
|
|
8149
8313
|
<span><b>This private sale is not available.</b> ${esc((this.previewProjection.unavailable ?? []).map((entry) => `${this.nameOf(entry.channelId) ?? "This channel"} is ${entry.state}`).join("; ") || "The audience cannot buy right now")}.
|
|
8150
8314
|
A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
|
|
@@ -8165,7 +8329,7 @@ var ChannelsMode = class {
|
|
|
8165
8329
|
${includePublic}
|
|
8166
8330
|
<p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
|
|
8167
8331
|
approximation. It is read-only: clicks open seat details, and no holds are created.</p>
|
|
8168
|
-
${unsupported}${unavailable}
|
|
8332
|
+
${busy}${failed}${unsupported}${unavailable}
|
|
8169
8333
|
<div class="slm-ch-legend">
|
|
8170
8334
|
<div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible & free \u2014 buyable by this audience</div>
|
|
8171
8335
|
<div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
|
|
@@ -8174,6 +8338,7 @@ var ChannelsMode = class {
|
|
|
8174
8338
|
${summary}`;
|
|
8175
8339
|
}
|
|
8176
8340
|
paintSelection() {
|
|
8341
|
+
if (this.caps.manage && this.host.selectionLabels().length) this.assignOpen = true;
|
|
8177
8342
|
if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
|
|
8178
8343
|
}
|
|
8179
8344
|
wireRail() {
|
|
@@ -8184,16 +8349,27 @@ var ChannelsMode = class {
|
|
|
8184
8349
|
rail.querySelectorAll("[data-ch-map]").forEach((button) => {
|
|
8185
8350
|
button.addEventListener("click", () => {
|
|
8186
8351
|
this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
|
|
8352
|
+
if (this.mapIntent === "assign") this.assignOpen = true;
|
|
8187
8353
|
this.paintRail();
|
|
8188
8354
|
this.onInteractionChange?.();
|
|
8189
8355
|
});
|
|
8190
8356
|
});
|
|
8191
|
-
rail.querySelectorAll("[data-ch-
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8195
|
-
|
|
8196
|
-
|
|
8357
|
+
rail.querySelectorAll("[data-ch-open]").forEach((row) => {
|
|
8358
|
+
const open = () => this.openChannel(row.dataset.chOpen);
|
|
8359
|
+
row.addEventListener("click", open);
|
|
8360
|
+
row.addEventListener("keydown", (event) => {
|
|
8361
|
+
if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
|
|
8362
|
+
event.preventDefault();
|
|
8363
|
+
open();
|
|
8364
|
+
});
|
|
8365
|
+
});
|
|
8366
|
+
rail.querySelectorAll("[data-ch-menu]").forEach((button) => {
|
|
8367
|
+
button.addEventListener("click", (event) => {
|
|
8368
|
+
event.stopPropagation();
|
|
8369
|
+
this.openDialog({ kind: "menu", channelId: button.dataset.chMenu });
|
|
8370
|
+
});
|
|
8371
|
+
button.addEventListener("keydown", (event) => {
|
|
8372
|
+
event.stopPropagation();
|
|
8197
8373
|
});
|
|
8198
8374
|
});
|
|
8199
8375
|
rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
|
|
@@ -8232,6 +8408,12 @@ var ChannelsMode = class {
|
|
|
8232
8408
|
button.addEventListener("click", () => this.railAction(button.dataset.chAct));
|
|
8233
8409
|
});
|
|
8234
8410
|
}
|
|
8411
|
+
/** Open a channel's detail panel and start its buyer-link read. */
|
|
8412
|
+
openChannel(channelId) {
|
|
8413
|
+
this.detailChannelId = channelId;
|
|
8414
|
+
this.paintRail();
|
|
8415
|
+
void this.loadLinks(channelId).then(() => this.paintRail());
|
|
8416
|
+
}
|
|
8235
8417
|
railAction(action) {
|
|
8236
8418
|
switch (action) {
|
|
8237
8419
|
case "sections":
|
|
@@ -8267,16 +8449,25 @@ var ChannelsMode = class {
|
|
|
8267
8449
|
this.linksState = "idle";
|
|
8268
8450
|
this.paintRail();
|
|
8269
8451
|
break;
|
|
8452
|
+
// Choosing the buyer-link route IS creating the first link — the dialog
|
|
8453
|
+
// declares the route on submit (see `createLink`), so this stays one
|
|
8454
|
+
// gesture whether the channel is already on `hosted_link` or not.
|
|
8270
8455
|
case "link-create":
|
|
8271
8456
|
this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
|
|
8272
8457
|
break;
|
|
8273
8458
|
// The one thing this screen can actually do for a website integration is
|
|
8274
|
-
// record that the channel is meant for one —
|
|
8275
|
-
//
|
|
8276
|
-
//
|
|
8459
|
+
// record that the channel is meant for one — and since 2026-08-06 that
|
|
8460
|
+
// record is what AUTHORIZES the integration to mint buyer sessions at all,
|
|
8461
|
+
// so it is the substantive half of the job, not a flag.
|
|
8277
8462
|
case "embed-code":
|
|
8278
8463
|
void this.chooseWebsiteIntegration();
|
|
8279
8464
|
break;
|
|
8465
|
+
case "route-internal":
|
|
8466
|
+
void this.setAccessIntent("internal");
|
|
8467
|
+
break;
|
|
8468
|
+
case "route-none":
|
|
8469
|
+
void this.setAccessIntent("none");
|
|
8470
|
+
break;
|
|
8280
8471
|
case "link-reload":
|
|
8281
8472
|
if (this.detailChannelId) void this.reloadLinks();
|
|
8282
8473
|
break;
|
|
@@ -8305,6 +8496,22 @@ var ChannelsMode = class {
|
|
|
8305
8496
|
case "pick-category":
|
|
8306
8497
|
this.pickCategory();
|
|
8307
8498
|
break;
|
|
8499
|
+
case "assign-open":
|
|
8500
|
+
this.assignOpen = true;
|
|
8501
|
+
this.paintRail();
|
|
8502
|
+
break;
|
|
8503
|
+
// Collapsing the tools must also disarm the marquee. Leaving it armed
|
|
8504
|
+
// would hide a mode the map is still in — the organizer would drag to pan
|
|
8505
|
+
// and select seats instead, with no control on screen saying why.
|
|
8506
|
+
case "assign-close":
|
|
8507
|
+
this.assignOpen = false;
|
|
8508
|
+
this.mapIntent = "pan";
|
|
8509
|
+
this.paintRail();
|
|
8510
|
+
this.onInteractionChange?.();
|
|
8511
|
+
break;
|
|
8512
|
+
case "preview-retry":
|
|
8513
|
+
void this.loadPreview();
|
|
8514
|
+
break;
|
|
8308
8515
|
default:
|
|
8309
8516
|
break;
|
|
8310
8517
|
}
|
|
@@ -8454,7 +8661,8 @@ var ChannelsMode = class {
|
|
|
8454
8661
|
blocks.push(`<p class="slm-ch-scopehint">${(matches.length - visible.length).toLocaleString()} more row${matches.length - visible.length === 1 ? "" : "s"}. Search to narrow the list.</p>`);
|
|
8455
8662
|
}
|
|
8456
8663
|
});
|
|
8457
|
-
list.innerHTML = blocks.join("") || `<div class="slm-ch-scope-empty">No sections or rows match \u201C${esc(query)}\u201D.</div
|
|
8664
|
+
list.innerHTML = blocks.join("") || (query ? `<div class="slm-ch-scope-empty" data-ch-state="scope-nomatch">No sections or rows match \u201C${esc(query)}\u201D.</div>` : `<div class="slm-ch-scope-empty" data-ch-state="scope-empty">This chart has no rows with selectable seats.
|
|
8665
|
+
Use Drag box or the seat list instead.</div>`);
|
|
8458
8666
|
filterHint.textContent = query ? `Showing ${rendered.toLocaleString()} of ${totalMatches.toLocaleString()} matching rows. Section checkboxes still select every row in that section.` : "Showing section groups. Expand one or search to see rows.";
|
|
8459
8667
|
list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
|
|
8460
8668
|
const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
|
|
@@ -8504,7 +8712,8 @@ var ChannelsMode = class {
|
|
|
8504
8712
|
this.renderScrim(`
|
|
8505
8713
|
<h3 id="slm-ch-dlg-title">Add whole sections</h3>
|
|
8506
8714
|
<p class="sub">Choose one or more. They are added to the seats already selected on the map.</p>
|
|
8507
|
-
<div class="slm-ch-seatlist">${body || `<div class="slm-empty">
|
|
8715
|
+
<div class="slm-ch-seatlist">${body || `<div class="slm-ch-scope-empty" data-ch-state="scope-empty">This chart has no sections with selectable seats.
|
|
8716
|
+
Use Drag box or the seat list instead.</div>`}</div>
|
|
8508
8717
|
<p class="slm-ch-err" data-ch-error hidden></p>
|
|
8509
8718
|
<div class="foot">
|
|
8510
8719
|
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
@@ -8561,9 +8770,81 @@ var ChannelsMode = class {
|
|
|
8561
8770
|
else if (state.kind === "archive") this.renderArchiveDialog(state);
|
|
8562
8771
|
else if (state.kind === "rename") this.renderRenameDialog(state);
|
|
8563
8772
|
else if (state.kind === "seatlist") this.renderSeatListDialog();
|
|
8773
|
+
else if (state.kind === "menu") this.renderMenuDialog(state);
|
|
8564
8774
|
else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
|
|
8565
8775
|
else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
|
|
8566
8776
|
else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
|
|
8777
|
+
else if (state.kind === "intentSwitch") this.renderIntentSwitchDialog(state);
|
|
8778
|
+
}
|
|
8779
|
+
/**
|
|
8780
|
+
* `channel_intent_switch_blocked` — buyers are inside the route being left.
|
|
8781
|
+
*
|
|
8782
|
+
* The same review-then-acknowledge shape as the archive and chart-drop guards,
|
|
8783
|
+
* because it is the same kind of decision: the server refuses once, names
|
|
8784
|
+
* exactly what is at stake, and only a deliberate second press goes through.
|
|
8785
|
+
* What acknowledging does is spelled out per consequence — links close now,
|
|
8786
|
+
* checkouts already running survive and drain — rather than hidden behind a
|
|
8787
|
+
* word like "force".
|
|
8788
|
+
*/
|
|
8789
|
+
renderIntentSwitchDialog(state) {
|
|
8790
|
+
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
8791
|
+
const to = state.intentTo;
|
|
8792
|
+
if (!channel || !to || !this.caps.manage) {
|
|
8793
|
+
this.closeDialog();
|
|
8794
|
+
return;
|
|
8795
|
+
}
|
|
8796
|
+
const { headline, consequences } = intentSwitchBlockedCopy(state.switchBlocked);
|
|
8797
|
+
this.renderScrim(`
|
|
8798
|
+
<h3 id="slm-ch-dlg-title">Change how ${esc(channel.name)} reaches buyers?</h3>
|
|
8799
|
+
<p class="sub">${esc(headline)}</p>
|
|
8800
|
+
<div class="slm-ch-alert warn" role="alert"><span>\u26A0</span><span>
|
|
8801
|
+
${consequences.map((line) => `<span style="display:block;margin-bottom:6px">${esc(line)}</span>`).join("")}
|
|
8802
|
+
</span></div>
|
|
8803
|
+
${state.pendingLink ? `<p class="slm-note">Your new buyer link is created as soon as
|
|
8804
|
+
the route changes \u2014 you will not have to fill the form in again.</p>` : ""}
|
|
8805
|
+
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
8806
|
+
<div class="foot">
|
|
8807
|
+
<button type="button" class="quiet" data-ch-close>Leave it as it is</button>
|
|
8808
|
+
<button type="button" class="slm-btn" data-ch-intent-ack${state.busy ? " disabled" : ""}>
|
|
8809
|
+
${state.busy ? "Changing\u2026" : `Change to "${esc(accessIntentLabel(to))}"`}</button>
|
|
8810
|
+
</div>`, (dialog) => {
|
|
8811
|
+
dialog.querySelector("[data-ch-intent-ack]")?.addEventListener("click", () => {
|
|
8812
|
+
void this.acknowledgeIntentSwitch(to, state.pendingLink ?? null);
|
|
8813
|
+
});
|
|
8814
|
+
});
|
|
8815
|
+
}
|
|
8816
|
+
/**
|
|
8817
|
+
* The acknowledged retry. The declare and the create stay one gesture across
|
|
8818
|
+
* the sheet: if this switch was the first half of a declare-then-create, the
|
|
8819
|
+
* held form finishes on the far side of the acknowledgement.
|
|
8820
|
+
*/
|
|
8821
|
+
async acknowledgeIntentSwitch(intent, pendingLink) {
|
|
8822
|
+
const channelId = this.detailChannelId;
|
|
8823
|
+
if (!channelId) {
|
|
8824
|
+
this.closeDialog();
|
|
8825
|
+
return;
|
|
8826
|
+
}
|
|
8827
|
+
if (this.dialog) {
|
|
8828
|
+
this.dialog.busy = true;
|
|
8829
|
+
this.renderDialog();
|
|
8830
|
+
}
|
|
8831
|
+
const ok = await this.setAccessIntent(intent, { acknowledgeLiveAccess: true });
|
|
8832
|
+
if (!ok) {
|
|
8833
|
+
if (this.dialog?.kind === "intentSwitch") {
|
|
8834
|
+
this.dialog.busy = false;
|
|
8835
|
+
this.renderDialog();
|
|
8836
|
+
}
|
|
8837
|
+
return;
|
|
8838
|
+
}
|
|
8839
|
+
if (!pendingLink) {
|
|
8840
|
+
this.closeDialog();
|
|
8841
|
+
return;
|
|
8842
|
+
}
|
|
8843
|
+
if (this.dialog?.kind === "intentSwitch") {
|
|
8844
|
+
this.dialog.busy = false;
|
|
8845
|
+
this.renderDialog();
|
|
8846
|
+
}
|
|
8847
|
+
await this.mintLink(channelId, pendingLink);
|
|
8567
8848
|
}
|
|
8568
8849
|
/**
|
|
8569
8850
|
* Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
|
|
@@ -8816,6 +9097,59 @@ var ChannelsMode = class {
|
|
|
8816
9097
|
void bar.offsetWidth;
|
|
8817
9098
|
bar.classList.add("shake");
|
|
8818
9099
|
}
|
|
9100
|
+
/**
|
|
9101
|
+
* The ⋯ menu.
|
|
9102
|
+
*
|
|
9103
|
+
* Opening a channel is the ROW's job now, so ⋯ carries what is left: the
|
|
9104
|
+
* secondary and destructive lifecycle actions. It is rendered through the same
|
|
9105
|
+
* scrim primitive as every other sheet, which is what gives it a focus trap,
|
|
9106
|
+
* Escape, and a name — a bare absolutely-positioned popup would have had none
|
|
9107
|
+
* of those. Archive keeps its own confirmation dialog; this menu never
|
|
9108
|
+
* destroys anything by itself.
|
|
9109
|
+
*/
|
|
9110
|
+
renderMenuDialog(state) {
|
|
9111
|
+
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
9112
|
+
if (!channel || !this.caps.manage) {
|
|
9113
|
+
this.closeDialog();
|
|
9114
|
+
return;
|
|
9115
|
+
}
|
|
9116
|
+
const paused = channel.state === "paused";
|
|
9117
|
+
this.renderScrim(`
|
|
9118
|
+
<h3 id="slm-ch-dlg-title">${esc(channel.name)}</h3>
|
|
9119
|
+
<p class="sub">Open the channel to allocate seats and hand out buyer access.
|
|
9120
|
+
These are the rest of its actions.</p>
|
|
9121
|
+
<div class="slm-ch-menu">
|
|
9122
|
+
<button type="button" class="slm-btn" data-ch-menu-act="open">Open channel</button>
|
|
9123
|
+
<button type="button" class="slm-btn ghost" data-ch-menu-act="rename">Rename</button>
|
|
9124
|
+
<button type="button" class="slm-btn ghost" data-ch-menu-act="pause">${paused ? "Resume selling" : "Pause selling"}</button>
|
|
9125
|
+
<button type="button" class="slm-btn ghost" data-ch-menu-act="archive">Archive\u2026</button>
|
|
9126
|
+
</div>
|
|
9127
|
+
<p class="slm-note">Pausing stops new buyer access; checkouts already running can finish.
|
|
9128
|
+
Archiving closes the channel for good and returns its free seats to a destination you choose.
|
|
9129
|
+
Nothing is ever deleted silently.</p>
|
|
9130
|
+
<div class="foot"><button type="button" class="quiet" data-ch-close>Cancel</button></div>`, (dialog) => {
|
|
9131
|
+
dialog.querySelectorAll("[data-ch-menu-act]").forEach((button) => {
|
|
9132
|
+
button.addEventListener("click", () => {
|
|
9133
|
+
const act = button.dataset.chMenuAct;
|
|
9134
|
+
if (act === "open") {
|
|
9135
|
+
this.closeDialog();
|
|
9136
|
+
this.openChannel(channel.id);
|
|
9137
|
+
return;
|
|
9138
|
+
}
|
|
9139
|
+
if (act === "rename") {
|
|
9140
|
+
this.openDialog({ kind: "rename", channelId: channel.id });
|
|
9141
|
+
return;
|
|
9142
|
+
}
|
|
9143
|
+
if (act === "archive") {
|
|
9144
|
+
this.openDialog({ kind: "archive", channelId: channel.id });
|
|
9145
|
+
return;
|
|
9146
|
+
}
|
|
9147
|
+
this.closeDialog();
|
|
9148
|
+
void this.togglePause(channel.id);
|
|
9149
|
+
});
|
|
9150
|
+
});
|
|
9151
|
+
});
|
|
9152
|
+
}
|
|
8819
9153
|
renderRenameDialog(state) {
|
|
8820
9154
|
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
8821
9155
|
if (!channel) {
|
|
@@ -8851,34 +9185,74 @@ var ChannelsMode = class {
|
|
|
8851
9185
|
});
|
|
8852
9186
|
}
|
|
8853
9187
|
/**
|
|
8854
|
-
* "
|
|
8855
|
-
*
|
|
8856
|
-
*
|
|
8857
|
-
*
|
|
8858
|
-
*
|
|
9188
|
+
* "Use a website or app" — declare the channel's route as `server`.
|
|
9189
|
+
*
|
|
9190
|
+
* This is no longer a flag the Embed page happens to read: since 2026-08-06 it
|
|
9191
|
+
* is what AUTHORIZES `POST /v1/events/:key/buyer-access-sessions` to mint for
|
|
9192
|
+
* this channel at all. Without it, an integration that is otherwise perfectly
|
|
9193
|
+
* wired up gets a 409 on every buyer.
|
|
8859
9194
|
*/
|
|
8860
9195
|
async chooseWebsiteIntegration() {
|
|
8861
9196
|
const channelId = this.detailChannelId;
|
|
8862
9197
|
if (!channelId) return;
|
|
8863
9198
|
await this.setAccessIntent("server");
|
|
8864
9199
|
}
|
|
8865
|
-
|
|
9200
|
+
/**
|
|
9201
|
+
* Declare this channel's sale route.
|
|
9202
|
+
*
|
|
9203
|
+
* Reports through the toast lane the rest of the rail's direct actions use.
|
|
9204
|
+
* The two enforcement refusals get real answers rather than a generic failure:
|
|
9205
|
+
* `channel_intent_switch_blocked` opens the review sheet (there is a decision
|
|
9206
|
+
* to make, and a sheet is where decisions live), and
|
|
9207
|
+
* `channel_access_intent_forbids` — which the organizer can hit by racing
|
|
9208
|
+
* their own second tab — says which route is in the way.
|
|
9209
|
+
*/
|
|
9210
|
+
async setAccessIntent(accessIntent, opts = {}) {
|
|
8866
9211
|
const channelId = this.detailChannelId;
|
|
8867
|
-
if (!channelId || !this.caps.manage) return;
|
|
9212
|
+
if (!channelId || !this.caps.manage) return false;
|
|
8868
9213
|
try {
|
|
8869
|
-
await this.host.api.setChannelAccessIntent(
|
|
9214
|
+
const result = await this.host.api.setChannelAccessIntent(
|
|
9215
|
+
this.host.eventKey,
|
|
9216
|
+
channelId,
|
|
9217
|
+
accessIntent,
|
|
9218
|
+
opts
|
|
9219
|
+
);
|
|
8870
9220
|
await this.refresh();
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
}
|
|
9221
|
+
this.host.toast(this.intentSavedCopy(accessIntent, result?.intentSwitch), "ok");
|
|
9222
|
+
return true;
|
|
8874
9223
|
} catch (err) {
|
|
8875
|
-
|
|
9224
|
+
if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
|
|
9225
|
+
this.openDialog({
|
|
9226
|
+
kind: "intentSwitch",
|
|
9227
|
+
channelId,
|
|
9228
|
+
intentTo: accessIntent,
|
|
9229
|
+
switchBlocked: err.details
|
|
9230
|
+
});
|
|
9231
|
+
return false;
|
|
9232
|
+
}
|
|
9233
|
+
if (err instanceof ManageApiError && err.code === "channel_access_intent_forbids") {
|
|
9234
|
+
this.host.toast(intentForbidsCopy(err.details), "err");
|
|
9235
|
+
return false;
|
|
9236
|
+
}
|
|
9237
|
+
this.host.toast("Couldn't change how this channel reaches buyers.", "err");
|
|
8876
9238
|
this.host.onError(err);
|
|
9239
|
+
return false;
|
|
8877
9240
|
}
|
|
8878
9241
|
}
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
9242
|
+
/** What just happened, including anything the switch took down with it — the
|
|
9243
|
+
* server reports `intentSwitch` only when it actually disturbed something. */
|
|
9244
|
+
intentSavedCopy(intent, disturbed) {
|
|
9245
|
+
const head = intent === "server" ? "Set to your website or app. The embed code is on the Embed page." : intent === "internal" ? "Only your own staff can sell this channel now." : intent === "hosted_link" ? "Set to buyer links. Create one to let buyers in." : "Kept as a protected reserve. No route can sell these seats.";
|
|
9246
|
+
if (!disturbed) return head;
|
|
9247
|
+
const closed = disturbed.closedLinks ? ` ${disturbed.closedLinks.toLocaleString()} buyer link${disturbed.closedLinks === 1 ? "" : "s"} closed.` : "";
|
|
9248
|
+
const kept = disturbed.keptSessions ? ` ${disturbed.keptSessions.toLocaleString()} buyer${disturbed.keptSessions === 1 ? "" : "s"} already in a checkout can still finish.` : "";
|
|
9249
|
+
return `${head}${closed}${kept}`;
|
|
9250
|
+
}
|
|
9251
|
+
/** `channelId` is explicit because this is reachable from the ⋯ menu on a row
|
|
9252
|
+
* that is NOT the open channel, as well as from the detail panel itself. */
|
|
9253
|
+
async togglePause(channelId = this.detailChannelId) {
|
|
9254
|
+
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
9255
|
+
if (!channel || !this.caps.manage) return;
|
|
8882
9256
|
const paused = channel.state !== "paused";
|
|
8883
9257
|
try {
|
|
8884
9258
|
await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);
|
|
@@ -9126,7 +9500,35 @@ var ChannelsMode = class {
|
|
|
9126
9500
|
});
|
|
9127
9501
|
});
|
|
9128
9502
|
}
|
|
9503
|
+
/**
|
|
9504
|
+
* DECLARE, then create.
|
|
9505
|
+
*
|
|
9506
|
+
* `createAccessLink` used to set the channel's route to `hosted_link` as a
|
|
9507
|
+
* side effect, which is exactly why the picker could never refuse anything.
|
|
9508
|
+
* The server took that side effect away and now REQUIRES the declaration —
|
|
9509
|
+
* and channels default to `none`, so a create that did not declare first
|
|
9510
|
+
* would 409 on the organizer's very first "Create buyer link".
|
|
9511
|
+
*
|
|
9512
|
+
* So the route is declared here, immediately before the create. It stays ONE
|
|
9513
|
+
* gesture: nothing is declared while the organizer is still filling the form
|
|
9514
|
+
* in (cancelling changes nothing), and if the declaration is the part that is
|
|
9515
|
+
* refused, the review sheet holds this form and finishes the job on the far
|
|
9516
|
+
* side of the acknowledgement.
|
|
9517
|
+
*/
|
|
9129
9518
|
async createLink(channelId, input) {
|
|
9519
|
+
if (!await this.ensureHostedLinkRoute(channelId, input)) return;
|
|
9520
|
+
await this.mintLink(channelId, input);
|
|
9521
|
+
}
|
|
9522
|
+
/**
|
|
9523
|
+
* The create half, on its own.
|
|
9524
|
+
*
|
|
9525
|
+
* Separate from `createLink` because the acknowledge path has ALREADY declared
|
|
9526
|
+
* the route — with the very acknowledgement the plain declaration was refused
|
|
9527
|
+
* for. Sending it back through `ensureHostedLinkRoute` would re-derive the
|
|
9528
|
+
* route from a channel list that has not necessarily caught up, and could
|
|
9529
|
+
* refuse the organizer a second time for a decision they just made.
|
|
9530
|
+
*/
|
|
9531
|
+
async mintLink(channelId, input) {
|
|
9130
9532
|
try {
|
|
9131
9533
|
const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
|
|
9132
9534
|
this.revealLink(reveal, { channelId });
|
|
@@ -9136,6 +9538,37 @@ var ChannelsMode = class {
|
|
|
9136
9538
|
if (!(err instanceof ManageApiError)) this.host.onError(err);
|
|
9137
9539
|
}
|
|
9138
9540
|
}
|
|
9541
|
+
/**
|
|
9542
|
+
* Make sure the channel declares the buyer-link route before a link is minted.
|
|
9543
|
+
*
|
|
9544
|
+
* Returns false when the create must NOT proceed — either it was refused, or
|
|
9545
|
+
* the decision has been handed to the switch-review sheet, which resumes it.
|
|
9546
|
+
* A channel already on `hosted_link` costs no request at all, so creating a
|
|
9547
|
+
* second link is the same single call it has always been.
|
|
9548
|
+
*/
|
|
9549
|
+
async ensureHostedLinkRoute(channelId, pendingLink) {
|
|
9550
|
+
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
9551
|
+
if ((channel?.access?.intent ?? "none") === "hosted_link") return true;
|
|
9552
|
+
try {
|
|
9553
|
+
await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, "hosted_link");
|
|
9554
|
+
await this.refresh();
|
|
9555
|
+
return true;
|
|
9556
|
+
} catch (err) {
|
|
9557
|
+
if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
|
|
9558
|
+
this.openDialog({
|
|
9559
|
+
kind: "intentSwitch",
|
|
9560
|
+
channelId,
|
|
9561
|
+
intentTo: "hosted_link",
|
|
9562
|
+
switchBlocked: err.details,
|
|
9563
|
+
pendingLink
|
|
9564
|
+
});
|
|
9565
|
+
return false;
|
|
9566
|
+
}
|
|
9567
|
+
this.showDialogError(err instanceof ManageApiError && err.code === "channel_access_intent_forbids" ? intentForbidsCopy(err.details) : "Couldn't set this channel up for buyer links. Try again.");
|
|
9568
|
+
if (!(err instanceof ManageApiError)) this.host.onError(err);
|
|
9569
|
+
return false;
|
|
9570
|
+
}
|
|
9571
|
+
}
|
|
9139
9572
|
/**
|
|
9140
9573
|
* The ONE-TIME reveal.
|
|
9141
9574
|
*
|
|
@@ -11801,6 +12234,7 @@ export {
|
|
|
11801
12234
|
SeatManager,
|
|
11802
12235
|
SeatPicker,
|
|
11803
12236
|
SeatingChart,
|
|
12237
|
+
accessIntentDescription,
|
|
11804
12238
|
accessIntentLabel,
|
|
11805
12239
|
accessLine,
|
|
11806
12240
|
accessLinkBadge,
|
|
@@ -11813,6 +12247,8 @@ export {
|
|
|
11813
12247
|
createBuyerAccessContext,
|
|
11814
12248
|
createControllerSink,
|
|
11815
12249
|
dropReviewRows,
|
|
12250
|
+
intentForbidsCopy,
|
|
12251
|
+
intentSwitchBlockedCopy,
|
|
11816
12252
|
isPublicChannelId,
|
|
11817
12253
|
markerLetter,
|
|
11818
12254
|
markerOf,
|