@seatlayer/js 0.51.0 → 0.53.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/LICENSE +16 -17
- package/README.md +2 -2
- package/dist/channelsMode-BX7s9bUl.d.cts +2241 -0
- package/dist/channelsMode-BX7s9bUl.d.ts +2241 -0
- package/dist/channelsMode-RUNPEHKD.js +1 -0
- package/dist/chunk-7F6RSJRL.js +695 -0
- package/dist/chunk-OSZ7DOEH.js +1 -0
- package/dist/hostedCheckout-3NTOVIZG.js +35 -0
- package/dist/index.cjs +103 -7371
- package/dist/index.d.cts +187 -96
- package/dist/index.d.ts +187 -96
- package/dist/index.js +102 -6992
- package/dist/manager.cjs +298 -5656
- package/dist/manager.d.cts +8 -2261
- package/dist/manager.d.ts +8 -2261
- package/dist/manager.js +71 -2418
- package/package.json +3 -8
- package/dist/channelsMode-S4GEWCWS.js +0 -12
- package/dist/channelsMode-S4GEWCWS.js.map +0 -1
- package/dist/chunk-5URCWBHM.js +0 -3683
- package/dist/chunk-5URCWBHM.js.map +0 -1
- package/dist/chunk-HMLY7DHA.js +0 -16
- package/dist/chunk-HMLY7DHA.js.map +0 -1
- package/dist/hostedCheckout-GVS6UKYA.js +0 -370
- package/dist/hostedCheckout-GVS6UKYA.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/manager.cjs.map +0 -1
- package/dist/manager.js.map +0 -1
package/dist/chunk-5URCWBHM.js
DELETED
|
@@ -1,3683 +0,0 @@
|
|
|
1
|
-
// src/channelPlan.ts
|
|
2
|
-
var PUBLIC_CHANNEL_ID = "public";
|
|
3
|
-
var PUBLIC_CHANNEL_NAME = "Public sale";
|
|
4
|
-
function isPublicChannelId(id) {
|
|
5
|
-
return id == null || id === "" || id === PUBLIC_CHANNEL_ID;
|
|
6
|
-
}
|
|
7
|
-
var CHANNEL_COLORS = [
|
|
8
|
-
"#a78bfa",
|
|
9
|
-
"#2dd4bf",
|
|
10
|
-
"#fb923c",
|
|
11
|
-
"#60a5fa",
|
|
12
|
-
"#f472b6",
|
|
13
|
-
"#a3e635",
|
|
14
|
-
"#f87171",
|
|
15
|
-
"#38bdf8",
|
|
16
|
-
"#c084fc",
|
|
17
|
-
"#facc15"
|
|
18
|
-
];
|
|
19
|
-
var PUBLIC_CHANNEL_COLOR = "#f4b740";
|
|
20
|
-
var LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
21
|
-
function markerLetter(raw, fallback) {
|
|
22
|
-
const text = (raw ?? "").trim();
|
|
23
|
-
const letter = /\p{L}/u.exec(text)?.[0] ?? text[0] ?? "";
|
|
24
|
-
return (letter || fallback).toUpperCase().slice(0, 1);
|
|
25
|
-
}
|
|
26
|
-
function suggestMarker(name, taken) {
|
|
27
|
-
const used = new Set([...taken].map((m) => markerLetter(m, "")).filter(Boolean));
|
|
28
|
-
const first = markerLetter(name, "");
|
|
29
|
-
const letter = LETTERS.includes(first) && !used.has(first) ? first : [...LETTERS].find((candidate) => !used.has(candidate)) ?? (first || "X");
|
|
30
|
-
return { letter, color: CHANNEL_COLORS[used.size % CHANNEL_COLORS.length] };
|
|
31
|
-
}
|
|
32
|
-
function markerOf(channel, index = 0) {
|
|
33
|
-
if (isPublicChannelId(channel.id)) {
|
|
34
|
-
return {
|
|
35
|
-
letter: markerLetter(channel.marker, "P"),
|
|
36
|
-
color: channel.color || PUBLIC_CHANNEL_COLOR
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
const letter = markerLetter(channel.marker || channel.name, "?");
|
|
40
|
-
return { letter, color: channel.color || CHANNEL_COLORS[index % CHANNEL_COLORS.length] };
|
|
41
|
-
}
|
|
42
|
-
function selectionSources(labels, allocation, list) {
|
|
43
|
-
const counts = /* @__PURE__ */ new Map();
|
|
44
|
-
for (const label of labels) {
|
|
45
|
-
const channelId = normalizeChannelId(allocation.get(label));
|
|
46
|
-
counts.set(channelId, (counts.get(channelId) ?? 0) + 1);
|
|
47
|
-
}
|
|
48
|
-
const order = [
|
|
49
|
-
{ id: PUBLIC_CHANNEL_ID, name: list?.publicSale?.name ?? PUBLIC_CHANNEL_NAME },
|
|
50
|
-
...(list?.channels ?? []).map((channel) => ({ id: channel.id, name: channel.name }))
|
|
51
|
-
];
|
|
52
|
-
const rows = [];
|
|
53
|
-
for (const entry of order) {
|
|
54
|
-
const count = counts.get(entry.id);
|
|
55
|
-
if (count) rows.push({ channelId: entry.id, name: entry.name, count });
|
|
56
|
-
counts.delete(entry.id);
|
|
57
|
-
}
|
|
58
|
-
for (const [channelId, count] of counts) {
|
|
59
|
-
rows.push({
|
|
60
|
-
channelId,
|
|
61
|
-
name: isPublicChannelId(channelId) ? PUBLIC_CHANNEL_NAME : "Another channel",
|
|
62
|
-
count
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
return rows;
|
|
66
|
-
}
|
|
67
|
-
function normalizeChannelId(id) {
|
|
68
|
-
return isPublicChannelId(id) ? PUBLIC_CHANNEL_ID : id;
|
|
69
|
-
}
|
|
70
|
-
var SKIP_SAMPLE = 12;
|
|
71
|
-
function skipBucket(labels) {
|
|
72
|
-
return {
|
|
73
|
-
count: labels.length,
|
|
74
|
-
labels: labels.slice(0, SKIP_SAMPLE),
|
|
75
|
-
truncated: labels.length > SKIP_SAMPLE
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
function planAssignment(input) {
|
|
79
|
-
const { labels, allocation, statusOf, nameOf } = input;
|
|
80
|
-
const targetChannelId = normalizeChannelId(input.targetChannelId);
|
|
81
|
-
const seen = /* @__PURE__ */ new Set();
|
|
82
|
-
let fromPublic = 0;
|
|
83
|
-
let alreadyIn = 0;
|
|
84
|
-
const movedBySource = /* @__PURE__ */ new Map();
|
|
85
|
-
const held = [];
|
|
86
|
-
const booked = [];
|
|
87
|
-
const missing = [];
|
|
88
|
-
for (const label of labels) {
|
|
89
|
-
if (seen.has(label)) continue;
|
|
90
|
-
seen.add(label);
|
|
91
|
-
const status = statusOf(label);
|
|
92
|
-
if (!status) {
|
|
93
|
-
missing.push(label);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
const current = normalizeChannelId(allocation.get(label));
|
|
97
|
-
if (current === targetChannelId) {
|
|
98
|
-
alreadyIn += 1;
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (status === "held") {
|
|
102
|
-
held.push(label);
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (status === "booked") {
|
|
106
|
-
booked.push(label);
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (current === PUBLIC_CHANNEL_ID) fromPublic += 1;
|
|
110
|
-
else movedBySource.set(current, (movedBySource.get(current) ?? 0) + 1);
|
|
111
|
-
}
|
|
112
|
-
const channels = [...movedBySource.entries()].map(([channelId, count]) => ({
|
|
113
|
-
channelId,
|
|
114
|
-
name: nameOf(channelId),
|
|
115
|
-
count
|
|
116
|
-
}));
|
|
117
|
-
return {
|
|
118
|
-
changedFromPublic: { count: fromPublic },
|
|
119
|
-
movedFromOtherChannel: {
|
|
120
|
-
count: channels.reduce((sum, row) => sum + row.count, 0),
|
|
121
|
-
channels
|
|
122
|
-
},
|
|
123
|
-
alreadyInTarget: { count: alreadyIn },
|
|
124
|
-
skippedHeld: skipBucket(held),
|
|
125
|
-
skippedBooked: skipBucket(booked),
|
|
126
|
-
notFound: skipBucket(missing)
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
function mutationCount(buckets) {
|
|
130
|
-
return buckets.changedFromPublic.count + buckets.movedFromOtherChannel.count;
|
|
131
|
-
}
|
|
132
|
-
function needsMoveConfirmation(buckets) {
|
|
133
|
-
return buckets.movedFromOtherChannel.count > 0;
|
|
134
|
-
}
|
|
135
|
-
function bucketRows(buckets, targetName) {
|
|
136
|
-
const rows = [];
|
|
137
|
-
if (buckets.changedFromPublic.count) {
|
|
138
|
-
rows.push({
|
|
139
|
-
kind: "add",
|
|
140
|
-
icon: "+",
|
|
141
|
-
count: buckets.changedFromPublic.count,
|
|
142
|
-
text: `${buckets.changedFromPublic.count.toLocaleString()} from ${PUBLIC_CHANNEL_NAME}`
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
for (const source of buckets.movedFromOtherChannel.channels) {
|
|
146
|
-
rows.push({
|
|
147
|
-
kind: "move",
|
|
148
|
-
icon: "\u21C4",
|
|
149
|
-
count: source.count,
|
|
150
|
-
text: `${source.count.toLocaleString()} moved out of ${source.name ?? "another channel"}`,
|
|
151
|
-
why: "needs this confirmation"
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
if (buckets.alreadyInTarget.count) {
|
|
155
|
-
rows.push({
|
|
156
|
-
kind: "same",
|
|
157
|
-
icon: "=",
|
|
158
|
-
count: buckets.alreadyInTarget.count,
|
|
159
|
-
text: `${buckets.alreadyInTarget.count.toLocaleString()} already in ${targetName}`,
|
|
160
|
-
why: "unchanged"
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
if (buckets.skippedHeld.count) {
|
|
164
|
-
rows.push({
|
|
165
|
-
kind: "skip",
|
|
166
|
-
icon: "\u23F8",
|
|
167
|
-
count: buckets.skippedHeld.count,
|
|
168
|
-
text: `${buckets.skippedHeld.count.toLocaleString()} in a buyer's checkout`,
|
|
169
|
-
why: "can't move while held",
|
|
170
|
-
peek: peekOf(buckets.skippedHeld)
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
if (buckets.skippedBooked.count) {
|
|
174
|
-
rows.push({
|
|
175
|
-
kind: "skip",
|
|
176
|
-
icon: "\u{1F512}",
|
|
177
|
-
count: buckets.skippedBooked.count,
|
|
178
|
-
text: `${buckets.skippedBooked.count.toLocaleString()} already sold`,
|
|
179
|
-
why: "sales are never rewritten",
|
|
180
|
-
peek: peekOf(buckets.skippedBooked)
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
if (buckets.notFound.count) {
|
|
184
|
-
rows.push({
|
|
185
|
-
kind: "skip",
|
|
186
|
-
icon: "?",
|
|
187
|
-
count: buckets.notFound.count,
|
|
188
|
-
text: `${buckets.notFound.count.toLocaleString()} not on this map`,
|
|
189
|
-
why: "these seats are no longer part of the event",
|
|
190
|
-
peek: peekOf(buckets.notFound)
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
return rows;
|
|
194
|
-
}
|
|
195
|
-
function peekOf(bucket) {
|
|
196
|
-
if (!bucket.labels.length) return void 0;
|
|
197
|
-
const shown = bucket.labels.slice(0, 4).join(", ");
|
|
198
|
-
return bucket.truncated || bucket.labels.length > 4 ? `${shown}\u2026` : shown;
|
|
199
|
-
}
|
|
200
|
-
function retryAfterCopy(details) {
|
|
201
|
-
const ms = details?.retryAfterMs ?? (details?.latestHoldExpiresAt ? Math.max(0, details.latestHoldExpiresAt - Date.now()) : 0);
|
|
202
|
-
if (!ms) return "in a moment";
|
|
203
|
-
const minutes = Math.ceil(ms / 6e4);
|
|
204
|
-
if (minutes <= 1) return "in about a minute";
|
|
205
|
-
return `in about ${minutes} minutes`;
|
|
206
|
-
}
|
|
207
|
-
function accessLine(access) {
|
|
208
|
-
if (!access || !access.intent) return "\u2014";
|
|
209
|
-
const base = access.intent === "server" ? "Website integration" : access.intent === "hosted_link" ? "Buyer link" : access.intent === "internal" ? "Your staff sell these" : "Protected reserve";
|
|
210
|
-
const grants = access.hasActiveGrants ? "in use now" : access.lastMintAt ? `last used ${new Date(access.lastMintAt).toLocaleDateString()}` : null;
|
|
211
|
-
const detail = access.detail ?? grants;
|
|
212
|
-
return detail ? `${base} \xB7 ${detail}` : base;
|
|
213
|
-
}
|
|
214
|
-
function accessIntentLabel(intent) {
|
|
215
|
-
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";
|
|
216
|
-
}
|
|
217
|
-
function accessIntentDescription(intent) {
|
|
218
|
-
switch (intent) {
|
|
219
|
-
case "internal":
|
|
220
|
-
return "Only your own box office can sell these seats, through your secret key. Buyer links and website integrations are refused.";
|
|
221
|
-
case "server":
|
|
222
|
-
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.";
|
|
223
|
-
case "hosted_link":
|
|
224
|
-
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.";
|
|
225
|
-
default:
|
|
226
|
-
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.";
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
function intentForRoute(route) {
|
|
230
|
-
return route === "hosted_link" ? "hosted_link" : route === "server" ? "server" : route === "staff" ? "internal" : null;
|
|
231
|
-
}
|
|
232
|
-
function intentForbidsCopy(details) {
|
|
233
|
-
const current = parseIntent(details?.accessIntent);
|
|
234
|
-
const wanted = intentForRoute(details?.route);
|
|
235
|
-
const head = `This channel is set to "${accessIntentLabel(current)}"`;
|
|
236
|
-
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.`;
|
|
237
|
-
}
|
|
238
|
-
function parseIntent(value) {
|
|
239
|
-
return value === "internal" || value === "server" || value === "hosted_link" || value === "none" ? value : "none";
|
|
240
|
-
}
|
|
241
|
-
function plural(count, one, many) {
|
|
242
|
-
return `${count.toLocaleString()} ${count === 1 ? one : many}`;
|
|
243
|
-
}
|
|
244
|
-
function intentSwitchBlockedCopy(details) {
|
|
245
|
-
const links = Math.max(0, details?.liveAccessLinks ?? 0);
|
|
246
|
-
const sessions = Math.max(0, details?.activeSessions ?? 0);
|
|
247
|
-
const from = parseIntent(details?.from);
|
|
248
|
-
const to = parseIntent(details?.to);
|
|
249
|
-
const live = [
|
|
250
|
-
links ? plural(links, "buyer link is live", "buyer links are live") : null,
|
|
251
|
-
sessions ? plural(sessions, "buyer is in a checkout", "buyers are in a checkout") : null
|
|
252
|
-
].filter(Boolean).join(", and ");
|
|
253
|
-
const headline = `${live || "Buyers are inside this channel"} on "${accessIntentLabel(from)}". Moving it to "${accessIntentLabel(to)}" changes what happens to them.`;
|
|
254
|
-
const consequences = [];
|
|
255
|
-
if (links) {
|
|
256
|
-
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.`);
|
|
257
|
-
}
|
|
258
|
-
if (sessions) {
|
|
259
|
-
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.`);
|
|
260
|
-
}
|
|
261
|
-
return { headline, consequences };
|
|
262
|
-
}
|
|
263
|
-
var ACCESS_LINK_DEFAULTS = {
|
|
264
|
-
maxRedemptions: 100,
|
|
265
|
-
maxQuantity: 4
|
|
266
|
-
};
|
|
267
|
-
function accessLinkBadge(link) {
|
|
268
|
-
switch (link.status ?? link.state) {
|
|
269
|
-
case "active":
|
|
270
|
-
return { text: "Active", kind: "active" };
|
|
271
|
-
case "expired":
|
|
272
|
-
return { text: "Expired", kind: "archived" };
|
|
273
|
-
case "exhausted":
|
|
274
|
-
return { text: "All used", kind: "paused" };
|
|
275
|
-
case "rotated":
|
|
276
|
-
return { text: "Replaced", kind: "archived" };
|
|
277
|
-
default:
|
|
278
|
-
return { text: "Revoked", kind: "archived" };
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
function accessLinkIsLive(link) {
|
|
282
|
-
return link.state === "active" && link.status === "active";
|
|
283
|
-
}
|
|
284
|
-
function formatMoment(ms) {
|
|
285
|
-
if (!Number.isFinite(ms)) return "\u2014";
|
|
286
|
-
return new Date(ms).toLocaleString(void 0, {
|
|
287
|
-
day: "numeric",
|
|
288
|
-
month: "short",
|
|
289
|
-
year: "numeric",
|
|
290
|
-
hour: "numeric",
|
|
291
|
-
minute: "2-digit"
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
function accessLinkPolicyLines(link) {
|
|
295
|
-
return [
|
|
296
|
-
{ k: "Expires", v: formatMoment(link.expiresAt) },
|
|
297
|
-
{
|
|
298
|
-
k: "Redemptions",
|
|
299
|
-
v: `${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} used`
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
k: "Seats per buyer",
|
|
303
|
-
v: `${link.maxQuantity.toLocaleString()} seat${link.maxQuantity === 1 ? "" : "s"} maximum`
|
|
304
|
-
},
|
|
305
|
-
{
|
|
306
|
-
k: "Covers",
|
|
307
|
-
v: link.includePublic ? "This channel's allocation and Public sale seats" : "This channel's allocation only"
|
|
308
|
-
}
|
|
309
|
-
];
|
|
310
|
-
}
|
|
311
|
-
function accessLinkErrorCopy(err) {
|
|
312
|
-
const fromServer = err?.serverMessage?.trim();
|
|
313
|
-
switch (err?.code) {
|
|
314
|
-
case "invalid_expiry":
|
|
315
|
-
case "invalid_max_redemptions":
|
|
316
|
-
case "invalid_max_quantity":
|
|
317
|
-
case "invalid_session_ttl":
|
|
318
|
-
case "invalid_label":
|
|
319
|
-
return fromServer || "That setting is outside what a hosted link allows. Adjust it and try again.";
|
|
320
|
-
case "too_many_access_links":
|
|
321
|
-
return fromServer || "This channel already has as many live links as it can hold. Revoke one before creating another.";
|
|
322
|
-
case "access_link_not_active":
|
|
323
|
-
return "That link is no longer active, so it cannot be rotated or revoked.";
|
|
324
|
-
case "channel_unavailable":
|
|
325
|
-
return "This channel is paused or archived, so it cannot let new buyers in. Resume it first.";
|
|
326
|
-
// The channel declares a different sale route. The UI declares `hosted_link`
|
|
327
|
-
// before it creates, so reaching this means the declaration itself was
|
|
328
|
-
// refused or raced — say which route is in the way, not the code.
|
|
329
|
-
case "channel_access_intent_forbids":
|
|
330
|
-
return intentForbidsCopy(err?.details);
|
|
331
|
-
case "end_active_sessions_required":
|
|
332
|
-
return "Choose what happens to the buyers who already came in through this link.";
|
|
333
|
-
case "not_found":
|
|
334
|
-
return "That link is no longer here. Refresh and try again.";
|
|
335
|
-
default:
|
|
336
|
-
if (err?.status === 403) return "Hosted access links need channel-management permission.";
|
|
337
|
-
return fromServer || "That did not go through. Try again.";
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
function dropReviewRows(details) {
|
|
341
|
-
return (details?.channels ?? []).map((channel) => ({
|
|
342
|
-
kind: "skip",
|
|
343
|
-
icon: "\u26A0",
|
|
344
|
-
count: channel.count,
|
|
345
|
-
text: `${channel.count.toLocaleString()} would leave ${channel.name ?? "a channel"}`,
|
|
346
|
-
why: "the new chart no longer has these seats",
|
|
347
|
-
peek: channel.labels?.length ? peekOf({ count: channel.count, labels: channel.labels, truncated: channel.truncated ?? false }) : void 0
|
|
348
|
-
}));
|
|
349
|
-
}
|
|
350
|
-
function stateBadge(state) {
|
|
351
|
-
return state === "builtin" ? "Built-in" : state === "active" ? "Active" : state === "paused" ? "Paused" : "Archived";
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// src/manageApi.ts
|
|
355
|
-
var ManageApiError = class extends Error {
|
|
356
|
-
constructor(status, message, code, conflicts, details, serverMessage) {
|
|
357
|
-
super(message);
|
|
358
|
-
this.name = "ManageApiError";
|
|
359
|
-
this.status = status;
|
|
360
|
-
this.code = code;
|
|
361
|
-
this.conflicts = conflicts;
|
|
362
|
-
this.details = details;
|
|
363
|
-
this.serverMessage = serverMessage;
|
|
364
|
-
}
|
|
365
|
-
};
|
|
366
|
-
async function parse(res) {
|
|
367
|
-
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
368
|
-
const data = isJson ? await res.json().catch(() => null) : null;
|
|
369
|
-
if (!res.ok) {
|
|
370
|
-
const err = data;
|
|
371
|
-
throw new ManageApiError(
|
|
372
|
-
res.status,
|
|
373
|
-
err?.error ?? `request_failed_${res.status}`,
|
|
374
|
-
err?.code,
|
|
375
|
-
err?.conflicts,
|
|
376
|
-
err?.details,
|
|
377
|
-
typeof err?.message === "string" ? err.message : void 0
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
return data;
|
|
381
|
-
}
|
|
382
|
-
function record(value) {
|
|
383
|
-
return value && typeof value === "object" ? value : {};
|
|
384
|
-
}
|
|
385
|
-
function finite(primary, legacy, fallback = 0) {
|
|
386
|
-
if (typeof primary === "number" && Number.isFinite(primary)) return primary;
|
|
387
|
-
if (typeof legacy === "number" && Number.isFinite(legacy)) return legacy;
|
|
388
|
-
return fallback;
|
|
389
|
-
}
|
|
390
|
-
function nullableFinite(primary, legacy) {
|
|
391
|
-
if (primary === null) return null;
|
|
392
|
-
if (typeof primary === "number" && Number.isFinite(primary)) return primary;
|
|
393
|
-
if (primary !== void 0) return null;
|
|
394
|
-
if (typeof legacy === "number" && Number.isFinite(legacy)) return legacy;
|
|
395
|
-
return null;
|
|
396
|
-
}
|
|
397
|
-
function normalizeSection(value) {
|
|
398
|
-
const row = record(value);
|
|
399
|
-
const bookedValue = finite(row.bookedValue, row.bookedRevenue);
|
|
400
|
-
return { ...row, bookedValue, bookedRevenue: bookedValue };
|
|
401
|
-
}
|
|
402
|
-
function normalizeReportResult(value) {
|
|
403
|
-
const source = record(value);
|
|
404
|
-
const report = record(source.report);
|
|
405
|
-
const byCategory = Array.isArray(report.byCategory) ? report.byCategory.map((value2) => {
|
|
406
|
-
const row = record(value2);
|
|
407
|
-
const bookedValue = finite(row.bookedValue, row.bookedRevenue);
|
|
408
|
-
return { ...row, bookedValue, bookedRevenue: bookedValue };
|
|
409
|
-
}) : [];
|
|
410
|
-
const bySection = Array.isArray(report.bySection) ? report.bySection.map(normalizeSection) : void 0;
|
|
411
|
-
return {
|
|
412
|
-
...source,
|
|
413
|
-
report: { ...report, byCategory, ...bySection ? { bySection } : {} }
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
function normalizeControlRoomSnapshot(value) {
|
|
417
|
-
const source = record(value);
|
|
418
|
-
const canonical = record(source.bookedValue);
|
|
419
|
-
const legacy = record(source.revenue);
|
|
420
|
-
const selected = Object.keys(canonical).length ? canonical : legacy;
|
|
421
|
-
const bySectionSource = Array.isArray(canonical.bySection) ? canonical.bySection : Array.isArray(legacy.bySection) ? legacy.bySection : [];
|
|
422
|
-
const bookedValue = {
|
|
423
|
-
...selected,
|
|
424
|
-
gross: finite(canonical.gross, legacy.gross),
|
|
425
|
-
bySection: bySectionSource.map(normalizeSection)
|
|
426
|
-
};
|
|
427
|
-
const velocity = record(source.velocity);
|
|
428
|
-
const velocityRows = Array.isArray(velocity.bySection) ? velocity.bySection.map((value2) => {
|
|
429
|
-
const row = record(value2);
|
|
430
|
-
const rowValue = finite(row.bookedValue, row.grossRevenue);
|
|
431
|
-
return { ...row, bookedValue: rowValue, grossRevenue: rowValue };
|
|
432
|
-
}) : [];
|
|
433
|
-
return {
|
|
434
|
-
...source,
|
|
435
|
-
bookedValue,
|
|
436
|
-
revenue: bookedValue,
|
|
437
|
-
velocity: { ...velocity, bySection: velocityRows }
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
function normalizeChannelReportResult(value) {
|
|
441
|
-
const source = record(value);
|
|
442
|
-
const report = record(source.report);
|
|
443
|
-
const includesBookedValue = typeof report.includesBookedValue === "boolean" ? report.includesBookedValue : report.includesRevenue === true;
|
|
444
|
-
const rows = Array.isArray(report.rows) ? report.rows.map((value2) => {
|
|
445
|
-
const row = record(value2);
|
|
446
|
-
const attribution = record(row.attribution);
|
|
447
|
-
const bookedValue = nullableFinite(attribution.bookedValue, attribution.revenue);
|
|
448
|
-
return {
|
|
449
|
-
...row,
|
|
450
|
-
attribution: { ...attribution, bookedValue, revenue: bookedValue }
|
|
451
|
-
};
|
|
452
|
-
}) : [];
|
|
453
|
-
const totals = record(report.totals);
|
|
454
|
-
const totalBookedValue = nullableFinite(totals.bookedValue, totals.revenue);
|
|
455
|
-
return {
|
|
456
|
-
...source,
|
|
457
|
-
report: {
|
|
458
|
-
...report,
|
|
459
|
-
includesBookedValue,
|
|
460
|
-
includesRevenue: includesBookedValue,
|
|
461
|
-
rows,
|
|
462
|
-
totals: { ...totals, bookedValue: totalBookedValue, revenue: totalBookedValue }
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
function normalizeChannelReportLink(value) {
|
|
467
|
-
const link = record(value);
|
|
468
|
-
const includesBookedValue = typeof link.includesBookedValue === "boolean" ? link.includesBookedValue : link.includesRevenue === true;
|
|
469
|
-
return {
|
|
470
|
-
...link,
|
|
471
|
-
includesBookedValue,
|
|
472
|
-
includesRevenue: includesBookedValue
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
var ManageApi = class {
|
|
476
|
-
constructor(apiBase, token) {
|
|
477
|
-
this.base = apiBase.replace(/\/+$/, "");
|
|
478
|
-
this.token = token;
|
|
479
|
-
}
|
|
480
|
-
/** Swap the Bearer token in place (SeatManager re-mints on 401). */
|
|
481
|
-
setToken(token) {
|
|
482
|
-
this.token = token;
|
|
483
|
-
}
|
|
484
|
-
auth(path, init = {}) {
|
|
485
|
-
const method = init.method ?? "GET";
|
|
486
|
-
const headers = { Authorization: `Bearer ${this.token}` };
|
|
487
|
-
let body;
|
|
488
|
-
if (init.body !== void 0) {
|
|
489
|
-
headers["Content-Type"] = "application/json";
|
|
490
|
-
body = JSON.stringify(init.body);
|
|
491
|
-
}
|
|
492
|
-
return fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" }).then((r) => parse(r));
|
|
493
|
-
}
|
|
494
|
-
async authBlob(path) {
|
|
495
|
-
const res = await fetch(`${this.base}${path}`, {
|
|
496
|
-
method: "GET",
|
|
497
|
-
headers: { Authorization: `Bearer ${this.token}` },
|
|
498
|
-
credentials: "omit"
|
|
499
|
-
});
|
|
500
|
-
if (!res.ok) await parse(res);
|
|
501
|
-
return res.blob();
|
|
502
|
-
}
|
|
503
|
-
// ---- realtime read ----
|
|
504
|
-
/** Event-pinned organizer geometry. A manage token is never sent to `/pub`. */
|
|
505
|
-
chart(key) {
|
|
506
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/chart`);
|
|
507
|
-
}
|
|
508
|
-
/** Authenticated bytes for an Event-scoped organizer chart asset. */
|
|
509
|
-
asset(key, asset) {
|
|
510
|
-
if (!/^[a-zA-Z0-9._-]+$/.test(asset)) {
|
|
511
|
-
return Promise.reject(new ManageApiError(404, "not_found", "not_found"));
|
|
512
|
-
}
|
|
513
|
-
return this.authBlob(
|
|
514
|
-
`/v1/events/${encodeURIComponent(key)}/assets/${encodeURIComponent(asset)}`
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
/**
|
|
518
|
-
* The ORGANIZER's seat map: physical state, token-authed.
|
|
519
|
-
*
|
|
520
|
-
* This used to read `/pub/events/:key/objects` with no credential, which
|
|
521
|
-
* answers with the BUYER projection — every unit the caller may not buy
|
|
522
|
-
* collapses to a neutral `blocked`. An anonymous caller may buy only Public
|
|
523
|
-
* sale inventory, so the cockpit rendered every channel-allocated seat as
|
|
524
|
-
* blocked and then computed its KPIs, sell-through and (worse) its
|
|
525
|
-
* block/unblock target sets from that. `/v1/events/:key/objects` returns the
|
|
526
|
-
* unprojected snapshot the control-room read model already trusts.
|
|
527
|
-
*/
|
|
528
|
-
objects(key) {
|
|
529
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/objects`);
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* Exchange the manage token for a one-use organizer socket ticket.
|
|
533
|
-
*
|
|
534
|
-
* A browser `WebSocket` cannot send an Authorization header, so the socket's
|
|
535
|
-
* scope is established here, over ordinary HTTPS. Without it the DO treats a
|
|
536
|
-
* manager socket as an anonymous public buyer and projects its deltas — so a
|
|
537
|
-
* hold inside a private allocation is structurally suppressed and the map
|
|
538
|
-
* drifts away from the truth `objects()` just established.
|
|
539
|
-
*
|
|
540
|
-
* Tickets are single-redemption and expire in ~30s: mint one per connect.
|
|
541
|
-
*/
|
|
542
|
-
subscribeTicket(key) {
|
|
543
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/subscribe-tickets`, { method: "POST" });
|
|
544
|
-
}
|
|
545
|
-
socketUrl(key) {
|
|
546
|
-
return `${this.base.replace(/^http/, "ws")}/pub/events/${encodeURIComponent(key)}/subscribe?surface=manager`;
|
|
547
|
-
}
|
|
548
|
-
// ---- inventory writes (token) ----
|
|
549
|
-
/** Take FREE seats off sale in one batched call. Optional `releaseAt` (epoch
|
|
550
|
-
* ms, future) auto-returns them to sale; `reason` tags the block (M3 uses it).
|
|
551
|
-
* Throws ManageApiError 409 (conflicts) if any seat was just taken. */
|
|
552
|
-
block(key, labels, opts = {}) {
|
|
553
|
-
const body = { labels };
|
|
554
|
-
if (typeof opts.releaseAt === "number") body.releaseAt = opts.releaseAt;
|
|
555
|
-
if (opts.reason) body.reason = opts.reason;
|
|
556
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/block`, { method: "POST", body });
|
|
557
|
-
}
|
|
558
|
-
/** Return specific blocked seats to sale (one batched call). */
|
|
559
|
-
unblock(key, labels) {
|
|
560
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/unblock`, { method: "POST", body: { labels } });
|
|
561
|
-
}
|
|
562
|
-
/** Return every blocked seat to sale; resolves with the freed count. */
|
|
563
|
-
unblockAll(key) {
|
|
564
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/unblock-all`, { method: "POST" });
|
|
565
|
-
}
|
|
566
|
-
/** Cancel bookings — return BOOKED seats to free (credit not refunded).
|
|
567
|
-
* Guarded by the original booking reference. */
|
|
568
|
-
unbook(key, labels, bookingRef) {
|
|
569
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/unbook`, { method: "POST", body: { labels, bookingRef } });
|
|
570
|
-
}
|
|
571
|
-
/** Set (ms, clamped 1–60 min server-side) or clear (null) the hold TTL. */
|
|
572
|
-
setHoldTtl(key, holdTtlMs) {
|
|
573
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/hold-ttl`, { method: "POST", body: { holdTtlMs } });
|
|
574
|
-
}
|
|
575
|
-
// ---- Platform inventory booking history (token) ----
|
|
576
|
-
/** Inventory lifecycle by stable integrator bookingRef. Absent on Managed. */
|
|
577
|
-
bookings(key, query = {}) {
|
|
578
|
-
const params = new URLSearchParams();
|
|
579
|
-
if (query.q) params.set("q", query.q);
|
|
580
|
-
if (query.state) params.set("state", query.state);
|
|
581
|
-
if (query.cursor) params.set("cursor", query.cursor);
|
|
582
|
-
if (query.limit != null) params.set("limit", String(query.limit));
|
|
583
|
-
const qs = params.toString();
|
|
584
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/bookings${qs ? `?${qs}` : ""}`);
|
|
585
|
-
}
|
|
586
|
-
/** Exact configured-value snapshot plus book/replay/cancellation audit. */
|
|
587
|
-
booking(key, bookingRef) {
|
|
588
|
-
return this.auth(
|
|
589
|
-
`/v1/events/${encodeURIComponent(key)}/bookings/${encodeURIComponent(bookingRef)}`
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
/** Alias matching the server SDK vocabulary. */
|
|
593
|
-
listBookings(key, query = {}) {
|
|
594
|
-
return this.bookings(key, query);
|
|
595
|
-
}
|
|
596
|
-
/** Alias matching the server SDK vocabulary. */
|
|
597
|
-
retrieveBooking(key, bookingRef) {
|
|
598
|
-
return this.booking(key, bookingRef);
|
|
599
|
-
}
|
|
600
|
-
// ---- availability windows (token) ----
|
|
601
|
-
/** The organizer's current per section/zone availability windows (needs
|
|
602
|
-
* `event:view`). Ids absent from `rules` are open / on sale. */
|
|
603
|
-
availability(key) {
|
|
604
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`);
|
|
605
|
-
}
|
|
606
|
-
/** Replace the availability windows for a set of section/zone ids (needs
|
|
607
|
-
* `event:block`). Ids absent from `rules` become open / on sale; a zone rule
|
|
608
|
-
* cascades to its sections. The worker derives each id's seat labels, so
|
|
609
|
-
* `labels` on the sent rules is best-effort. Resolves with the authoritative
|
|
610
|
-
* effective `hidden` set (a due rule may fire at once) and the server-cleaned
|
|
611
|
-
* `rules` map (fired timed/threshold windows dropped). */
|
|
612
|
-
setAvailability(key, rules) {
|
|
613
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`, { method: "POST", body: { rules } });
|
|
614
|
-
}
|
|
615
|
-
// ---- sales channels (token, capability-gated) ----
|
|
616
|
-
// Reads need `event:channels:view`, mutations `event:channels:manage`.
|
|
617
|
-
// `event:block` grants NEITHER (spec §10), so a Block-only cockpit token gets
|
|
618
|
-
// a 403 here and Channels mode never renders.
|
|
619
|
-
/** Allocation list with exact per-channel counts. `includeArchived` adds the
|
|
620
|
-
* read-only archived rows behind the rail's "Show archived" control. */
|
|
621
|
-
channels(key, opts = {}) {
|
|
622
|
-
const qs = opts.includeArchived ? "?includeArchived=1" : "";
|
|
623
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels${qs}`);
|
|
624
|
-
}
|
|
625
|
-
/** One page of the label → channel map that paints the allocation overlay.
|
|
626
|
-
* Paged by label; follow `nextAfterLabel` until it is null. */
|
|
627
|
-
channelAllocation(key, opts = {}) {
|
|
628
|
-
const params = new URLSearchParams();
|
|
629
|
-
if (opts.afterLabel) params.set("afterLabel", opts.afterLabel);
|
|
630
|
-
if (opts.limit != null) params.set("limit", String(opts.limit));
|
|
631
|
-
const qs = params.toString();
|
|
632
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/allocation${qs ? `?${qs}` : ""}`);
|
|
633
|
-
}
|
|
634
|
-
channelAudit(key, opts = {}) {
|
|
635
|
-
const params = new URLSearchParams();
|
|
636
|
-
if (opts.limit != null) params.set("limit", String(opts.limit));
|
|
637
|
-
if (opts.before != null) params.set("before", String(opts.before));
|
|
638
|
-
const qs = params.toString();
|
|
639
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/audit${qs ? `?${qs}` : ""}`);
|
|
640
|
-
}
|
|
641
|
-
createChannel(key, input) {
|
|
642
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels`, { method: "POST", body: input });
|
|
643
|
-
}
|
|
644
|
-
renameChannel(key, channelId, name) {
|
|
645
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
|
|
646
|
-
method: "PATCH",
|
|
647
|
-
body: { name }
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
setChannelPaused(key, channelId, paused) {
|
|
651
|
-
const path = paused ? "pause" : "unpause";
|
|
652
|
-
return this.auth(
|
|
653
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/${path}`,
|
|
654
|
-
{ method: "POST", body: {} }
|
|
655
|
-
);
|
|
656
|
-
}
|
|
657
|
-
/** Archive with a mandatory destination for the remaining allocation.
|
|
658
|
-
* Throws ManageApiError 409 `channel_archive_blocked_by_holds` while any hold
|
|
659
|
-
* is live; `err.details` carries the exact counts + retry window. */
|
|
660
|
-
archiveChannel(key, channelId, destination) {
|
|
661
|
-
return this.auth(
|
|
662
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/archive`,
|
|
663
|
-
{ method: "POST", body: { destination } }
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Versioned Apply. A stale `assignmentVersion` mutates NOTHING and throws
|
|
668
|
-
* ManageApiError 409 `channel_assignment_conflict` — the caller keeps its
|
|
669
|
-
* selection and offers "Refresh and review". There is no dry-run: the review
|
|
670
|
-
* sheet previews locally, this call returns the authoritative buckets.
|
|
671
|
-
*/
|
|
672
|
-
applyChannelAssignment(key, input) {
|
|
673
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/assignments`, {
|
|
674
|
-
method: "POST",
|
|
675
|
-
body: {
|
|
676
|
-
targetChannelId: input.targetChannelId || null,
|
|
677
|
-
labels: input.labels,
|
|
678
|
-
assignmentVersion: input.assignmentVersion
|
|
679
|
-
}
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
/**
|
|
683
|
-
* Read-only buyer projection for an audience (§8.6) — the SAME scoped server
|
|
684
|
-
* view the buyer SDK receives, never a local approximation.
|
|
685
|
-
*
|
|
686
|
-
* Ships on the access-hardening branch. Older workers 404/405 here; callers
|
|
687
|
-
* MUST feature-detect and quietly say the preview needs a newer server rather
|
|
688
|
-
* than faking a projection client-side.
|
|
689
|
-
*/
|
|
690
|
-
channelPreview(key, channelIds, opts = {}) {
|
|
691
|
-
const params = new URLSearchParams();
|
|
692
|
-
if (channelIds.length) params.set("channelIds", channelIds.join(","));
|
|
693
|
-
if (opts.includePublic != null) params.set("includePublic", opts.includePublic ? "1" : "0");
|
|
694
|
-
const qs = params.toString();
|
|
695
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/preview${qs ? `?${qs}` : ""}`);
|
|
696
|
-
}
|
|
697
|
-
/**
|
|
698
|
-
* Choose which sale route this channel opens.
|
|
699
|
-
*
|
|
700
|
-
* Since the server's 2026-08-06 change this is AUTHORIZATION, not a label:
|
|
701
|
-
* exactly one of the four routes may mint buyer access for the channel and the
|
|
702
|
-
* other three refuse with 409 `channel_access_intent_forbids`. The default is
|
|
703
|
-
* `none`, which refuses all four — so a route has to be declared before any
|
|
704
|
-
* buyer-facing action on the channel can succeed.
|
|
705
|
-
*
|
|
706
|
-
* Switching the route while buyers are already inside the current one is
|
|
707
|
-
* refused with 409 `channel_intent_switch_blocked`, whose `details` name what
|
|
708
|
-
* is live (`liveAccessLinks`, `activeSessions`). Retry with
|
|
709
|
-
* `acknowledgeLiveAccess: true`: hosted links on the channel are revoked,
|
|
710
|
-
* while sessions already minted keep their holds and drain on their own.
|
|
711
|
-
* `intentSwitch` is present on the response ONLY when the switch disturbed
|
|
712
|
-
* something, so the ordinary case stays the two-key body it has always been.
|
|
713
|
-
*/
|
|
714
|
-
setChannelAccessIntent(key, channelId, accessIntent, opts = {}) {
|
|
715
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}`, {
|
|
716
|
-
method: "PATCH",
|
|
717
|
-
body: {
|
|
718
|
-
accessIntent,
|
|
719
|
-
...opts.acknowledgeLiveAccess ? { acknowledgeLiveAccess: true } : {},
|
|
720
|
-
...opts.reason ? { reason: opts.reason } : {}
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
// ---- hosted access links (M8) ----
|
|
725
|
-
/**
|
|
726
|
-
* Mint a hosted access link. The 201 is the ONE and ONLY time `url` and
|
|
727
|
-
* `capability` exist outside the buyer's browser — SeatLayer keeps a hash, so
|
|
728
|
-
* there is no route, cache, or support escalation that can produce this string
|
|
729
|
-
* again. Callers must reveal it immediately and then let it go.
|
|
730
|
-
*
|
|
731
|
-
* Every omitted field takes the server's default: expiry = when the event
|
|
732
|
-
* starts, 100 redemptions, 4 seats per buyer, this channel's allocation only.
|
|
733
|
-
* Platform bounds are enforced server-side and reported as 422 with the rule
|
|
734
|
-
* spelled out in `ManageApiError.serverMessage`.
|
|
735
|
-
*
|
|
736
|
-
* NOT a side effect any more. This used to SET the channel's access intent to
|
|
737
|
-
* `hosted_link`; since 2026-08-06 it REQUIRES it, and a channel declaring any
|
|
738
|
-
* other route refuses with 409 `channel_access_intent_forbids`. Callers must
|
|
739
|
-
* declare the route first — `ChannelsMode` does exactly that before it
|
|
740
|
-
* creates, so a first buyer link on a fresh channel is still one gesture.
|
|
741
|
-
*/
|
|
742
|
-
createAccessLink(key, channelId, input = {}) {
|
|
743
|
-
return this.auth(
|
|
744
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`,
|
|
745
|
-
{ method: "POST", body: input }
|
|
746
|
-
);
|
|
747
|
-
}
|
|
748
|
-
/** Status only — label, expiry, redemptions, per-buyer cap, lineage, and the
|
|
749
|
-
* live session count. Never the url, never the capability. Needs `:view`. */
|
|
750
|
-
accessLinks(key, channelId) {
|
|
751
|
-
return this.auth(
|
|
752
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links`
|
|
753
|
-
);
|
|
754
|
-
}
|
|
755
|
-
/**
|
|
756
|
-
* Rotate — the ONLY recovery for a link nobody kept. The old URL stops opening
|
|
757
|
-
* immediately and the response is a fresh one-time reveal.
|
|
758
|
-
*
|
|
759
|
-
* `endActiveSessions` is REQUIRED, not defaulted: the organizer must say
|
|
760
|
-
* whether buyers already inside finish their checkout or lose access now. The
|
|
761
|
-
* server answers 422 `end_active_sessions_required` if it is omitted, and that
|
|
762
|
-
* refusal is correct — a UI must not pick either branch on their behalf.
|
|
763
|
-
*/
|
|
764
|
-
rotateAccessLink(key, channelId, linkId, endActiveSessions) {
|
|
765
|
-
return this.auth(
|
|
766
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}/rotate`,
|
|
767
|
-
{ method: "POST", body: { endActiveSessions } }
|
|
768
|
-
);
|
|
769
|
-
}
|
|
770
|
-
/** Revoke. The link stops opening immediately; `endActiveSessions` decides
|
|
771
|
-
* whether the buyers already inside keep their sessions. */
|
|
772
|
-
revokeAccessLink(key, channelId, linkId, endActiveSessions = false) {
|
|
773
|
-
const qs = endActiveSessions ? "?endActiveSessions=1" : "";
|
|
774
|
-
return this.auth(
|
|
775
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/access-links/${encodeURIComponent(linkId)}${qs}`,
|
|
776
|
-
{ method: "DELETE" }
|
|
777
|
-
);
|
|
778
|
-
}
|
|
779
|
-
// ---- reports (token) ----
|
|
780
|
-
report(key) {
|
|
781
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/report`).then(normalizeReportResult);
|
|
782
|
-
}
|
|
783
|
-
controlRoom(key, windowMinutes = 15) {
|
|
784
|
-
return this.auth(
|
|
785
|
-
`/v1/events/${encodeURIComponent(key)}/control-room?window=${windowMinutes}`
|
|
786
|
-
).then(normalizeControlRoomSnapshot);
|
|
787
|
-
}
|
|
788
|
-
/** Allocation beside immutable booking-time channel attribution. */
|
|
789
|
-
channelReport(key) {
|
|
790
|
-
return this.auth(
|
|
791
|
-
`/v1/events/${encodeURIComponent(key)}/channels/report`
|
|
792
|
-
).then(normalizeChannelReportResult);
|
|
793
|
-
}
|
|
794
|
-
createChannelReportLink(key, channelId, input = {}) {
|
|
795
|
-
return this.auth(
|
|
796
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/report-links`,
|
|
797
|
-
{ method: "POST", body: input }
|
|
798
|
-
).then((value) => {
|
|
799
|
-
const reveal = record(value);
|
|
800
|
-
return { ...reveal, link: normalizeChannelReportLink(reveal.link) };
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
channelReportLinks(key, channelId) {
|
|
804
|
-
return this.auth(
|
|
805
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/report-links`
|
|
806
|
-
).then((value) => {
|
|
807
|
-
const result = record(value);
|
|
808
|
-
return {
|
|
809
|
-
links: Array.isArray(result.links) ? result.links.map(normalizeChannelReportLink) : []
|
|
810
|
-
};
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
revokeChannelReportLink(key, channelId, linkId) {
|
|
814
|
-
return this.auth(
|
|
815
|
-
`/v1/events/${encodeURIComponent(key)}/channels/${encodeURIComponent(channelId)}/report-links/${encodeURIComponent(linkId)}`,
|
|
816
|
-
{ method: "DELETE" }
|
|
817
|
-
).then((value) => {
|
|
818
|
-
const result = record(value);
|
|
819
|
-
return { ...result, link: normalizeChannelReportLink(result.link) };
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
log(key, opts = {}) {
|
|
823
|
-
const params = new URLSearchParams();
|
|
824
|
-
if (opts.limit != null) params.set("limit", String(opts.limit));
|
|
825
|
-
if (opts.before != null) params.set("before", String(opts.before));
|
|
826
|
-
const qs = params.toString();
|
|
827
|
-
return this.auth(`/v1/events/${encodeURIComponent(key)}/log${qs ? `?${qs}` : ""}`);
|
|
828
|
-
}
|
|
829
|
-
/** CSV report as a Blob (Bearer auth can't ride a plain <a href>). Host builds
|
|
830
|
-
* an object URL for download. */
|
|
831
|
-
async reportCsv(key) {
|
|
832
|
-
const res = await fetch(`${this.base}/v1/events/${encodeURIComponent(key)}/report.csv`, {
|
|
833
|
-
headers: { Authorization: `Bearer ${this.token}` },
|
|
834
|
-
credentials: "omit"
|
|
835
|
-
});
|
|
836
|
-
if (!res.ok) throw new ManageApiError(res.status, `request_failed_${res.status}`);
|
|
837
|
-
return res.blob();
|
|
838
|
-
}
|
|
839
|
-
};
|
|
840
|
-
|
|
841
|
-
// src/channelsMode.ts
|
|
842
|
-
var POLL_MS = 3e4;
|
|
843
|
-
var MAX_FLAGS = 8;
|
|
844
|
-
var SEAT_LIST_PAGE = 300;
|
|
845
|
-
var MAX_ASSIGNMENT_UNITS = 5e3;
|
|
846
|
-
var PREVIEW_ELIGIBLE_FILL = "#6e7bff";
|
|
847
|
-
var PREVIEW_ELIGIBLE_STROKE = "#b9c0ff";
|
|
848
|
-
var PREVIEW_UNAVAILABLE_FILL = "#303846";
|
|
849
|
-
var PREVIEW_UNAVAILABLE_STROKE = "#4b5669";
|
|
850
|
-
var ALLOCATION_STROKE = "#101723";
|
|
851
|
-
var CHANNELS_CSS = (
|
|
852
|
-
/* @sl-css */
|
|
853
|
-
`
|
|
854
|
-
.slm{--slm-mo-instant:80ms;--slm-mo-quick:140ms;--slm-mo-base:200ms;--slm-mo-slow:320ms;--slm-mo-ambient:2000ms;
|
|
855
|
-
--slm-mo-out:cubic-bezier(.2,.8,.2,1);--slm-mo-in-out:cubic-bezier(.4,0,.2,1);--slm-mo-exit:cubic-bezier(.4,0,1,1);
|
|
856
|
-
--slm-mo-spring:cubic-bezier(.34,1.3,.64,1)}
|
|
857
|
-
|
|
858
|
-
/* map overlay: ONE layer, faded in as a whole (never per seat) */
|
|
859
|
-
.slm-ch-layer{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
860
|
-
.slm-ch-layer.on{opacity:1}
|
|
861
|
-
.slm-ch-canvas{position:absolute;inset:0;width:100%;height:100%}
|
|
862
|
-
.slm-ch-flag{position:absolute;display:flex;align-items:center;gap:5px;padding:3px 8px;border-radius:999px;
|
|
863
|
-
background:rgba(14,16,23,.88);border:1px solid var(--slm-line);font-size:10px;font-weight:800;letter-spacing:.04em;
|
|
864
|
-
transform:translate(-50%,-50%);white-space:nowrap}
|
|
865
|
-
.slm-ch-flag .mk{width:14px;height:14px;border-radius:4px;display:grid;place-items:center;font-size:8.5px;font-weight:800;color:#0e1017}
|
|
866
|
-
.slm-ch-section-target{position:absolute;pointer-events:auto;padding:0;border:0;border-radius:8px;background:transparent;cursor:zoom-in}
|
|
867
|
-
.slm-ch-section-target:focus-visible{outline:2px solid var(--slm-accent);outline-offset:-3px;background:color-mix(in srgb,var(--slm-accent) 12%,transparent)}
|
|
868
|
-
|
|
869
|
-
/* preview banner \u2014 raised with the organizer chrome dim, as one transition */
|
|
870
|
-
.slm-ch-banner{position:absolute;left:50%;top:14px;z-index:6;display:flex;align-items:center;gap:9px;padding:8px 14px;
|
|
871
|
-
border-radius:999px;background:rgba(14,16,23,.92);border:1px solid var(--slm-line);font-size:12px;font-weight:700;
|
|
872
|
-
transform:translate(-50%,-8px);opacity:0;pointer-events:none;
|
|
873
|
-
transition:opacity var(--slm-mo-base) var(--slm-mo-out),transform var(--slm-mo-base) var(--slm-mo-out)}
|
|
874
|
-
.slm-ch-banner.on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
|
|
875
|
-
.slm-ch-banner .dot{width:8px;height:8px;border-radius:50%}
|
|
876
|
-
.slm-ch-banner button{color:var(--slm-accent);font-weight:800;font-size:11.5px;min-height:32px}
|
|
877
|
-
.slm.ch-preview .slm-ch-flag{opacity:0;transition:opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
878
|
-
|
|
879
|
-
/* sticky staged bar */
|
|
880
|
-
.slm-ch-staged{position:absolute;left:12px;right:12px;bottom:12px;z-index:6;display:flex;align-items:center;gap:12px;
|
|
881
|
-
padding:10px 14px;min-height:44px;border-radius:12px;background:rgba(24,27,36,.96);border:1px solid var(--slm-line);
|
|
882
|
-
box-shadow:0 12px 34px rgba(0,0,0,.45);font-size:12.5px;pointer-events:auto;
|
|
883
|
-
transform:translateY(calc(100% + 18px));opacity:0;
|
|
884
|
-
transition:transform var(--slm-mo-slow) var(--slm-mo-out),opacity var(--slm-mo-base) var(--slm-mo-out)}
|
|
885
|
-
.slm-ch-staged.on{transform:none;opacity:1}
|
|
886
|
-
.slm-ch-staged.done{background:rgba(31,122,77,.96);border-color:#1f7a4d}
|
|
887
|
-
.slm-ch-staged.shake{animation:slm-ch-shake var(--slm-mo-slow) var(--slm-mo-in-out) 2}
|
|
888
|
-
.slm-ch-staged b{font-variant-numeric:tabular-nums}
|
|
889
|
-
.slm-ch-staged .grow{flex:1}
|
|
890
|
-
.slm-ch-staged .go{padding:9px 16px;min-height:44px;display:inline-flex;align-items:center;border-radius:9px;
|
|
891
|
-
background:#f4b740;color:#1a1200;font-weight:800;font-size:12.5px}
|
|
892
|
-
.slm-ch-staged .go:disabled{opacity:.48;cursor:not-allowed}
|
|
893
|
-
.slm-ch-staged .drop{color:var(--slm-muted);font-weight:700;font-size:11.5px;min-height:44px;padding-inline:8px}
|
|
894
|
-
.slm-ch-tick{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;
|
|
895
|
-
background:#fff;color:#1f7a4d;font-weight:900;font-size:11px;animation:slm-ch-tick var(--slm-mo-base) var(--slm-mo-spring)}
|
|
896
|
-
@keyframes slm-ch-shake{0%,100%{transform:none}25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}
|
|
897
|
-
@keyframes slm-ch-tick{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
|
898
|
-
|
|
899
|
-
/* rail */
|
|
900
|
-
.slm-ch-viewseg{display:flex;gap:3px;padding:3px;border:1px solid var(--slm-line);border-radius:9px;
|
|
901
|
-
background:var(--slm-surface);margin-bottom:12px}
|
|
902
|
-
.slm-ch-viewseg button{flex:1;padding:6px 8px;min-height:34px;border-radius:7px;font-size:11px;font-weight:800;color:var(--slm-muted)}
|
|
903
|
-
.slm-ch-viewseg button.on{background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
904
|
-
.slm-ch-viewseg button:disabled{opacity:.5;cursor:not-allowed}
|
|
905
|
-
.slm-ch-mapnav{margin:-2px 0 12px;padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
|
|
906
|
-
.slm-ch-mapnav-head{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--slm-muted)}
|
|
907
|
-
.slm-ch-mapnav-head button{color:var(--slm-accent);font-size:11px;font-weight:800;letter-spacing:0;text-transform:none;min-height:30px}
|
|
908
|
-
.slm-ch-mapnav .slm-ch-viewseg{margin:8px 0 5px}
|
|
909
|
-
.slm-ch-mapnav p{margin:0;font-size:11px;line-height:1.45;color:var(--slm-muted)}
|
|
910
|
-
.slm-ch-list{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
|
|
911
|
-
.slm-ch-row{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
|
|
912
|
-
text-align:left;width:100%;display:block;transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
|
|
913
|
-
.slm-ch-row:hover{border-color:var(--slm-muted)}
|
|
914
|
-
.slm-ch-row.on{border-color:var(--slm-accent);box-shadow:0 0 0 1px color-mix(in srgb,var(--slm-accent) 40%,transparent)}
|
|
915
|
-
.slm-ch-row.public{background:linear-gradient(100deg,rgba(244,183,64,.09),var(--slm-surface) 60%)}
|
|
916
|
-
.slm-ch-row.archived{opacity:.68}
|
|
917
|
-
.slm-ch-head{display:flex;align-items:center;gap:8px}
|
|
918
|
-
.slm-ch-mk{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800;
|
|
919
|
-
color:#0e1017;flex:none}
|
|
920
|
-
.slm-ch-mk.dim{opacity:.55}
|
|
921
|
-
.slm-ch-name{flex:1;min-width:0;font-size:13px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
922
|
-
.slm-ch-badge{flex:none;font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;padding:2px 7px;border-radius:999px}
|
|
923
|
-
.slm-ch-badge.active{background:rgba(34,160,107,.16);color:#5bd39b}
|
|
924
|
-
.slm-ch-badge.paused,.slm-ch-badge.builtin{background:rgba(244,183,64,.15);color:#f7ca6b}
|
|
925
|
-
.slm-ch-badge.archived{background:rgba(139,148,172,.18);color:#c2c9d8}
|
|
926
|
-
.slm-ch-counts{display:flex;gap:10px;flex-wrap:wrap;margin-top:7px;font-size:11px;color:var(--slm-muted);
|
|
927
|
-
font-variant-numeric:tabular-nums}
|
|
928
|
-
.slm-ch-counts b{color:var(--slm-text);font-weight:800}
|
|
929
|
-
.slm-ch-counts .free b{color:#5bd39b}
|
|
930
|
-
.slm-ch-counts b.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
|
|
931
|
-
@keyframes slm-ch-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08)}}
|
|
932
|
-
.slm-ch-access{margin-top:6px;font-size:10.5px;color:var(--slm-muted)}
|
|
933
|
-
/* A row that opens its channel must READ as pressable, and be one for a keyboard
|
|
934
|
-
too. It is a role="button" div rather than a <button> because the \u22EF control
|
|
935
|
-
lives inside it, and a button inside a button is not valid HTML. */
|
|
936
|
-
.slm-ch-row.open{cursor:pointer}
|
|
937
|
-
.slm-ch-row.open:hover{border-color:var(--slm-accent);background:color-mix(in srgb,var(--slm-accent) 6%,var(--slm-surface))}
|
|
938
|
-
.slm-ch-row.open:focus-visible{outline:2px solid var(--slm-accent);outline-offset:2px}
|
|
939
|
-
.slm-ch-row.open:active{border-color:var(--slm-accent)}
|
|
940
|
-
.slm-ch-more{flex:none;color:var(--slm-muted);font-weight:800;padding:0 4px;min-height:28px;border-radius:6px}
|
|
941
|
-
.slm-ch-more:hover{color:var(--slm-text)}
|
|
942
|
-
.slm-ch-more:focus-visible{outline:2px solid var(--slm-accent);outline-offset:1px;color:var(--slm-text)}
|
|
943
|
-
.slm-ch-menu{display:flex;flex-direction:column;gap:8px}
|
|
944
|
-
.slm-ch-menu .slm-btn{width:100%}
|
|
945
|
-
/* loading is a state, never a flash of empty: same slot, visibly working */
|
|
946
|
-
.slm-ch-busy{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--slm-muted);padding:12px 0}
|
|
947
|
-
.slm-ch-busy::before{content:"";width:9px;height:9px;border-radius:50%;background:var(--slm-accent);flex:none;
|
|
948
|
-
animation:slm-ch-pulse var(--slm-mo-ambient) var(--slm-mo-in-out) infinite}
|
|
949
|
-
@keyframes slm-ch-pulse{0%,100%{opacity:.25;transform:scale(.7)}50%{opacity:1;transform:scale(1)}}
|
|
950
|
-
.slm-ch-selsrc{display:flex;flex-direction:column;gap:5px;margin:8px 0 12px}
|
|
951
|
-
.slm-ch-selsrc-row{display:flex;align-items:center;gap:8px;font-size:12px;font-variant-numeric:tabular-nums}
|
|
952
|
-
.slm-ch-selsrc-row .mk{width:15px;height:15px;border-radius:4px;display:grid;place-items:center;font-size:8px;
|
|
953
|
-
font-weight:800;color:#0e1017}
|
|
954
|
-
.slm-ch-selsrc-row b{min-width:30px;text-align:right;font-weight:800}
|
|
955
|
-
.slm-ch-selsrc-row span{color:var(--slm-muted)}
|
|
956
|
-
.slm-ch-selnum.bump{animation:slm-ch-bump var(--slm-mo-base) var(--slm-mo-spring)}
|
|
957
|
-
.slm-ch-row2{display:flex;gap:8px;margin-top:8px}
|
|
958
|
-
.slm-ch-row2 .slm-btn{flex:1;min-width:0}
|
|
959
|
-
/* distribute routes \u2014 four choices, each with the one sentence that explains it.
|
|
960
|
-
The current one is NAMED in its own card, never signalled by colour alone. */
|
|
961
|
-
.slm-ch-dist{display:flex;flex-direction:column;gap:6px;padding:12px;margin-bottom:8px;border:1px solid var(--slm-line);
|
|
962
|
-
border-radius:10px;background:var(--slm-surface)}
|
|
963
|
-
.slm-ch-dist.on{border-color:var(--slm-accent,#8b7cf6)}
|
|
964
|
-
.slm-ch-dist b{font-size:13px;font-weight:800;display:flex;align-items:center;gap:8px;justify-content:space-between}
|
|
965
|
-
.slm-ch-dist b .cur{font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
|
|
966
|
-
color:var(--slm-accent,#8b7cf6);white-space:nowrap}
|
|
967
|
-
.slm-ch-dist .why{color:var(--slm-muted);font-size:11.5px;line-height:1.45}
|
|
968
|
-
.slm-ch-dist .slm-btn{width:100%;margin-top:2px}
|
|
969
|
-
.slm-ch-alert{display:flex;align-items:flex-start;gap:9px;padding:11px 13px;border-radius:10px;font-size:12.5px;
|
|
970
|
-
line-height:1.5;margin-bottom:12px}
|
|
971
|
-
.slm-ch-alert.warn{background:rgba(244,183,64,.1);border:1px solid rgba(244,183,64,.4);color:#f4d58a}
|
|
972
|
-
.slm-ch-alert.info{background:rgba(110,123,255,.12);border:1px solid rgba(110,123,255,.44);color:#c5cbff}
|
|
973
|
-
.slm-ch-alert.err{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.45);color:#f1a4a6}
|
|
974
|
-
.slm-ch-alert b{color:#fff}
|
|
975
|
-
.slm-ch-alert button{display:block;margin-top:6px;color:#fff;font-weight:800;min-height:36px}
|
|
976
|
-
.slm-ch-legend{display:flex;flex-direction:column;gap:6px;margin-top:10px}
|
|
977
|
-
.slm-ch-legend .r{display:flex;align-items:center;gap:9px;font-size:12px;color:var(--slm-muted)}
|
|
978
|
-
.slm-ch-legend .sw{width:13px;height:13px;border-radius:3.5px;flex:none}
|
|
979
|
-
.slm-ch-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
980
|
-
|
|
981
|
-
/* dialogs */
|
|
982
|
-
.slm-ch-scrim{position:absolute;inset:0;z-index:12;background:rgba(4,6,12,.62);display:grid;place-items:center;
|
|
983
|
-
padding:18px;animation:slm-ch-fade var(--slm-mo-quick) var(--slm-mo-out)}
|
|
984
|
-
.slm-ch-dialog{width:min(460px,100%);max-height:100%;overflow:auto;overscroll-behavior:contain;background:#12151f;border:1px solid var(--slm-line);
|
|
985
|
-
border-radius:14px;padding:20px;box-shadow:0 24px 70px rgba(0,0,0,.6);
|
|
986
|
-
animation:slm-ch-rise var(--slm-mo-base) var(--slm-mo-out)}
|
|
987
|
-
.slm-ch-dialog h3{margin:0 0 4px;font-size:16px;font-weight:800;letter-spacing:-.01em}
|
|
988
|
-
.slm-ch-dialog .sub{font-size:12.5px;color:var(--slm-muted);line-height:1.5;margin-bottom:14px}
|
|
989
|
-
.slm-ch-dialog .foot{display:flex;gap:8px;margin-top:16px}
|
|
990
|
-
.slm-ch-dialog .foot .slm-btn{flex:1;min-width:0}
|
|
991
|
-
.slm-ch-dialog .foot .quiet{flex:none;padding:10px 14px;min-height:44px;color:var(--slm-muted);font-weight:700;font-size:13px}
|
|
992
|
-
@keyframes slm-ch-fade{from{opacity:0}to{opacity:1}}
|
|
993
|
-
@keyframes slm-ch-rise{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
|
|
994
|
-
.slm-ch-bucket{display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:10px;padding:9px 4px;
|
|
995
|
-
border-top:1px solid var(--slm-line);font-size:12.5px;animation:slm-ch-bucket var(--slm-mo-base) var(--slm-mo-out) both}
|
|
996
|
-
.slm-ch-bucket:first-of-type{border-top:0}
|
|
997
|
-
.slm-ch-bucket .ico{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:800}
|
|
998
|
-
.slm-ch-bucket .ico.add{background:rgba(34,160,107,.18);color:#5bd39b}
|
|
999
|
-
.slm-ch-bucket .ico.move{background:rgba(167,139,250,.18);color:#c4b5fd}
|
|
1000
|
-
.slm-ch-bucket .ico.same{background:rgba(139,148,172,.14);color:#aab2c4}
|
|
1001
|
-
.slm-ch-bucket .ico.skip{background:rgba(244,183,64,.16);color:#f7ca6b}
|
|
1002
|
-
.slm-ch-bucket b{font-variant-numeric:tabular-nums;font-weight:800}
|
|
1003
|
-
.slm-ch-bucket .why{color:var(--slm-muted);font-size:11px}
|
|
1004
|
-
.slm-ch-bucket .peek{color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums}
|
|
1005
|
-
@keyframes slm-ch-bucket{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
1006
|
-
.slm-ch-secret{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed rgba(244,183,64,.55);
|
|
1007
|
-
border-radius:10px;background:rgba(244,183,64,.06);font-family:ui-monospace,Menlo,monospace;font-size:11px;
|
|
1008
|
-
overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
1009
|
-
.slm-ch-err{color:#f1a4a6;font-size:11.5px;margin-top:6px}
|
|
1010
|
-
|
|
1011
|
-
/* buyer links \u2014 STATUS only; there is no Copy control on this card */
|
|
1012
|
-
.slm-ch-link{padding:10px 11px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);
|
|
1013
|
-
margin-bottom:8px}
|
|
1014
|
-
.slm-ch-link .lk-head{display:flex;align-items:center;gap:8px}
|
|
1015
|
-
.slm-ch-link .lk-name{flex:1;min-width:0;font-size:12.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;
|
|
1016
|
-
white-space:nowrap}
|
|
1017
|
-
.slm-ch-lkrow{display:flex;gap:8px;margin-top:5px;font-size:11px;color:var(--slm-muted)}
|
|
1018
|
-
.slm-ch-lkrow .k{flex:none;min-width:104px}
|
|
1019
|
-
.slm-ch-lkrow .v{color:var(--slm-text);font-variant-numeric:tabular-nums}
|
|
1020
|
-
.slm-ch-meter{height:5px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden;margin-top:8px}
|
|
1021
|
-
.slm-ch-meter i{display:block;height:100%;background:var(--slm-accent);
|
|
1022
|
-
transition:width var(--slm-mo-base) var(--slm-mo-out)}
|
|
1023
|
-
.slm-ch-radio{display:flex;gap:9px;align-items:flex-start;padding:11px 12px;border:1px solid var(--slm-line);
|
|
1024
|
-
border-radius:10px;margin-top:8px;font-size:12.5px;cursor:pointer;
|
|
1025
|
-
transition:border-color var(--slm-mo-quick) var(--slm-mo-out)}
|
|
1026
|
-
.slm-ch-radio:hover{border-color:var(--slm-muted)}
|
|
1027
|
-
.slm-ch-radio input{flex:none;margin-top:2px}
|
|
1028
|
-
.slm-ch-radio b{display:block;font-weight:800;margin-bottom:2px}
|
|
1029
|
-
.slm-ch-radio .why{display:block;color:var(--slm-muted);font-size:11.5px;line-height:1.45}
|
|
1030
|
-
.slm-ch-seatlist{max-height:44vh;overflow:auto;overscroll-behavior:contain;border:1px solid var(--slm-line);
|
|
1031
|
-
border-radius:10px;background:var(--slm-surface);margin-top:10px}
|
|
1032
|
-
.slm-ch-seatgroup{padding:8px 10px;border-bottom:1px solid var(--slm-line);display:flex;align-items:center;
|
|
1033
|
-
justify-content:space-between;gap:8px;font-size:11px;font-weight:800;color:var(--slm-muted);position:sticky;top:0;
|
|
1034
|
-
background:var(--slm-surface)}
|
|
1035
|
-
.slm-ch-seatgroup button{color:var(--slm-accent);font-weight:800;font-size:11px;min-height:32px}
|
|
1036
|
-
.slm-ch-seatitem{display:flex;width:100%;align-items:center;gap:9px;padding:8px 10px;border-bottom:1px solid var(--slm-line);
|
|
1037
|
-
text-align:left;font-size:12px}
|
|
1038
|
-
.slm-ch-seatitem .box{width:16px;height:16px;border-radius:4px;border:1px solid var(--slm-muted);display:grid;
|
|
1039
|
-
place-items:center;font-size:10px;font-weight:900;color:transparent;flex:none}
|
|
1040
|
-
.slm-ch-seatitem[aria-checked="true"] .box{border-color:var(--slm-accent);background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
1041
|
-
.slm-ch-seatitem .meta{margin-left:auto;color:var(--slm-muted);font-size:10.5px}
|
|
1042
|
-
|
|
1043
|
-
/* scope chooser: whole sections or many rows in one additive pass */
|
|
1044
|
-
.slm-ch-scopebar{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:10px;margin-top:12px}
|
|
1045
|
-
.slm-ch-scopebar label{display:grid;gap:5px;color:var(--slm-muted);font-size:10.5px;font-weight:800}
|
|
1046
|
-
.slm-ch-scopebar input{width:100%;min-height:40px;padding:8px 10px;border:1px solid var(--slm-line);border-radius:8px;
|
|
1047
|
-
background:var(--slm-surface);color:var(--slm-text);font:inherit}
|
|
1048
|
-
.slm-ch-scopesummary{padding-bottom:10px;color:var(--slm-muted);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
1049
|
-
.slm-ch-scopegroup{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;
|
|
1050
|
-
gap:6px;padding:6px 8px;border-bottom:1px solid var(--slm-line);background:var(--slm-surface)}
|
|
1051
|
-
.slm-ch-groupcheck{display:flex;min-width:0;align-items:center;gap:8px;padding:5px 2px;text-align:left;font-size:11px;font-weight:800}
|
|
1052
|
-
.slm-ch-groupcheck .box{width:16px;height:16px;flex:none;display:grid;place-items:center;border:1px solid var(--slm-muted);border-radius:4px;
|
|
1053
|
-
color:transparent;font-size:10px}
|
|
1054
|
-
.slm-ch-groupcheck[aria-checked="true"] .box,.slm-ch-groupcheck[aria-checked="mixed"] .box{border-color:var(--slm-accent);
|
|
1055
|
-
background:var(--slm-accent);color:var(--slm-accent-ink)}
|
|
1056
|
-
.slm-ch-groupcheck .meta{min-width:0;margin-left:auto;color:var(--slm-muted);font-size:10.5px;font-weight:600;white-space:nowrap}
|
|
1057
|
-
.slm-ch-grouptoggle{min-height:32px;padding:5px 8px;color:var(--slm-accent);font-size:11px;font-weight:800}
|
|
1058
|
-
.slm-ch-scopehint{padding:8px 10px;color:var(--slm-muted);font-size:10.5px;border-bottom:1px solid var(--slm-line)}
|
|
1059
|
-
.slm-ch-scope-empty{padding:18px 12px;color:var(--slm-muted);font-size:11.5px;text-align:center}
|
|
1060
|
-
@media(max-width:560px){.slm-ch-scopebar{grid-template-columns:1fr}.slm-ch-scopesummary{padding-bottom:0}}
|
|
1061
|
-
|
|
1062
|
-
/* compact: bottom sheet with three detents (\xA713) */
|
|
1063
|
-
.slm.compact.ch-sheet .slm-rail{position:absolute;left:0;right:0;bottom:0;z-index:8;border-top:1px solid var(--slm-line);
|
|
1064
|
-
border-radius:18px 18px 0 0;background:#12151f;
|
|
1065
|
-
transition:height var(--slm-mo-slow) var(--slm-mo-in-out)}
|
|
1066
|
-
.slm.compact.ch-sheet.detent-collapsed .slm-rail{height:132px}
|
|
1067
|
-
.slm.compact.ch-sheet.detent-medium .slm-rail{height:46%}
|
|
1068
|
-
.slm.compact.ch-sheet.detent-full .slm-rail{height:92%}
|
|
1069
|
-
.slm.compact.ch-sheet .slm-railscroll{padding:8px 14px calc(12px + env(safe-area-inset-bottom,0px))}
|
|
1070
|
-
.slm-ch-grab{display:none}
|
|
1071
|
-
.slm.compact.ch-sheet .slm-ch-grab{display:flex;align-items:center;gap:10px;width:100%;padding:6px 0 10px}
|
|
1072
|
-
.slm-ch-grabbar{width:42px;height:4px;border-radius:2px;background:rgba(255,255,255,.22);margin:0 auto}
|
|
1073
|
-
.slm.compact .slm-ch-staged{bottom:auto;top:8px}
|
|
1074
|
-
.slm.compact .slm-btn,.slm.compact .slm-ch-row,.slm.compact .slm-ch-viewseg button{min-height:44px}
|
|
1075
|
-
.slm-tools{display:none}
|
|
1076
|
-
.slm.compact .slm-tools{display:block;width:100%;padding:9px 13px;min-height:44px;border:1px solid var(--slm-line);
|
|
1077
|
-
border-radius:10px;background:var(--slm-surface);color:var(--slm-text);font-size:13px;font-weight:800}
|
|
1078
|
-
.slm.compact .slm-modes{display:none}
|
|
1079
|
-
|
|
1080
|
-
@media (prefers-reduced-motion:reduce){
|
|
1081
|
-
.slm-ch-layer,.slm-ch-banner,.slm-ch-staged,.slm-ch-row,.slm.compact.ch-sheet .slm-rail,
|
|
1082
|
-
.slm-ch-meter i,.slm-ch-radio{transition:none!important}
|
|
1083
|
-
.slm-ch-staged.shake,.slm-ch-tick,.slm-ch-bucket,.slm-ch-scrim,.slm-ch-dialog,
|
|
1084
|
-
.slm-ch-counts b.bump,.slm-ch-selnum.bump{animation:none!important}
|
|
1085
|
-
.slm-ch-busy::before{animation:none!important;opacity:1}
|
|
1086
|
-
.slm-ch-staged.shake{outline:2px solid #e5484d;outline-offset:2px}
|
|
1087
|
-
.slm-ch-staged.done{outline:2px solid #5bd39b;outline-offset:2px}
|
|
1088
|
-
}
|
|
1089
|
-
`
|
|
1090
|
-
);
|
|
1091
|
-
function bucketRowsHtml(rows) {
|
|
1092
|
-
return rows.map((row, index) => `
|
|
1093
|
-
<div class="slm-ch-bucket" style="animation-delay:${Math.min(index, 4) * 30}ms">
|
|
1094
|
-
<span class="ico ${row.kind}" aria-hidden="true">${esc(row.icon)}</span>
|
|
1095
|
-
<span><b>${row.count.toLocaleString()}</b> ${esc(row.text.replace(/^[\d,.\s]+/, ""))}
|
|
1096
|
-
${row.why ? `<span class="why">\u2014 ${esc(row.why)}</span>` : ""}</span>
|
|
1097
|
-
${row.peek ? `<span class="peek">${esc(row.peek)}</span>` : "<span></span>"}
|
|
1098
|
-
</div>`).join("");
|
|
1099
|
-
}
|
|
1100
|
-
var SERVER_INTEGRATION_HTML = `
|
|
1101
|
-
<p class="slm-eyebrow" style="margin-top:18px">On your website</p>
|
|
1102
|
-
<p class="slm-hint">There is nothing more to set up on this screen. Your own server mints a short-lived buyer
|
|
1103
|
-
access session for this channel with the SeatLayer server SDK and hands it to the widget. A channel name
|
|
1104
|
-
on its own never grants access.</p>
|
|
1105
|
-
<p class="slm-note"><a class="slm-linkbtn" href="https://docs.seatlayer.io/server-api/channels"
|
|
1106
|
-
target="_blank" rel="noreferrer noopener">Read the website integration guide \u2192</a></p>`;
|
|
1107
|
-
function esc(value) {
|
|
1108
|
-
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1109
|
-
}
|
|
1110
|
-
function datetimeLocalValue(ms) {
|
|
1111
|
-
const local = new Date(ms - new Date(ms).getTimezoneOffset() * 6e4);
|
|
1112
|
-
return local.toISOString().slice(0, 16);
|
|
1113
|
-
}
|
|
1114
|
-
function intField(root, selector) {
|
|
1115
|
-
const raw = root.querySelector(selector)?.value.trim() ?? "";
|
|
1116
|
-
const value = Number(raw);
|
|
1117
|
-
return raw !== "" && Number.isInteger(value) ? value : null;
|
|
1118
|
-
}
|
|
1119
|
-
function selectSecret(dialog) {
|
|
1120
|
-
const node = dialog.querySelector("[data-ch-lk-url]");
|
|
1121
|
-
if (!node) return;
|
|
1122
|
-
try {
|
|
1123
|
-
const range = document.createRange();
|
|
1124
|
-
range.selectNodeContents(node);
|
|
1125
|
-
const selection = window.getSelection();
|
|
1126
|
-
selection?.removeAllRanges();
|
|
1127
|
-
selection?.addRange(range);
|
|
1128
|
-
} catch {
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
var ChannelsMode = class {
|
|
1132
|
-
constructor(host, capabilities) {
|
|
1133
|
-
this.active = false;
|
|
1134
|
-
this.list = null;
|
|
1135
|
-
this.allocation = /* @__PURE__ */ new Map();
|
|
1136
|
-
this.assignmentVersion = 0;
|
|
1137
|
-
this.loadError = null;
|
|
1138
|
-
this.loading = true;
|
|
1139
|
-
this.view = "inspect";
|
|
1140
|
-
/** Pan is intentionally the initial desktop interaction. Assignment's
|
|
1141
|
-
* marquee is powerful, but must never make an organizer lose map navigation. */
|
|
1142
|
-
this.mapIntent = "pan";
|
|
1143
|
-
this.focusedSectionId = null;
|
|
1144
|
-
this.showArchived = false;
|
|
1145
|
-
this.detailChannelId = null;
|
|
1146
|
-
/**
|
|
1147
|
-
* Whether the organizer has asked to assign seats.
|
|
1148
|
-
*
|
|
1149
|
-
* The list rail leads with the CHANNELS. The assignment tooling — destination
|
|
1150
|
-
* picker plus five select-by routes — used to paint unconditionally above that
|
|
1151
|
-
* list, so a first-time organizer met a workbench for a job they had not asked
|
|
1152
|
-
* to do, with the thing they came for pushed below the fold. It is now
|
|
1153
|
-
* disclosed on intent: the "Assign seats to a channel" action under the list,
|
|
1154
|
-
* the map's own "Assign seats" segment, or simply having a selection. The
|
|
1155
|
-
* selection rail always shows the tools, because there the intent is proven.
|
|
1156
|
-
*/
|
|
1157
|
-
this.assignOpen = false;
|
|
1158
|
-
this.targetChannelId = "";
|
|
1159
|
-
this.conflict = false;
|
|
1160
|
-
this.dialog = null;
|
|
1161
|
-
this.detent = "medium";
|
|
1162
|
-
this.seatListLimit = SEAT_LIST_PAGE;
|
|
1163
|
-
/**
|
|
1164
|
-
* Hosted-link STATUS for the channel whose detail panel is open. This is the
|
|
1165
|
-
* listing projection — it carries no url and no capability, because no route
|
|
1166
|
-
* returns one. `unsupported` is the honest answer for a worker that predates
|
|
1167
|
-
* M8, exactly like the buyer-preview probe.
|
|
1168
|
-
*/
|
|
1169
|
-
this.links = [];
|
|
1170
|
-
this.linksChannelId = null;
|
|
1171
|
-
this.linksState = "idle";
|
|
1172
|
-
/**
|
|
1173
|
-
* Monotonic read generations — one for the channel list + allocation, one for
|
|
1174
|
-
* the open channel's links. Reads are concurrent (a 10s poll versus a
|
|
1175
|
-
* mutation's own reload), and the network does not promise to answer them in
|
|
1176
|
-
* order. Only the NEWEST read of each kind may write to state; an older
|
|
1177
|
-
* answer that arrives late is dropped, never painted.
|
|
1178
|
-
*/
|
|
1179
|
-
this.listSeq = 0;
|
|
1180
|
-
this.linksSeq = 0;
|
|
1181
|
-
this.previewAudience = [];
|
|
1182
|
-
this.previewIncludePublic = false;
|
|
1183
|
-
this.previewProjection = null;
|
|
1184
|
-
/**
|
|
1185
|
-
* The buyer-preview read, as one honest state rather than a boolean.
|
|
1186
|
-
*
|
|
1187
|
-
* `previewSupported: boolean | null` could say "this worker has no projection
|
|
1188
|
-
* route", but it could not say "the read is in flight" or "the read failed" —
|
|
1189
|
-
* both of those painted an audience picker with no result underneath, which
|
|
1190
|
-
* reads as "this audience can buy nothing". Loading and error are now states
|
|
1191
|
-
* of their own, and the error one offers a retry.
|
|
1192
|
-
*/
|
|
1193
|
-
this.previewState = "idle";
|
|
1194
|
-
this.pollTimer = null;
|
|
1195
|
-
this.layer = null;
|
|
1196
|
-
this.canvas = null;
|
|
1197
|
-
/** undefined = not resolved yet, null = this environment has no 2d canvas. */
|
|
1198
|
-
this.ctx = void 0;
|
|
1199
|
-
this.bannerEl = null;
|
|
1200
|
-
this.stagedEl = null;
|
|
1201
|
-
this.liveEl = null;
|
|
1202
|
-
this.scrimEl = null;
|
|
1203
|
-
this.lastFocus = null;
|
|
1204
|
-
this.stagedDoneTimer = null;
|
|
1205
|
-
this.lastSelectionCount = 0;
|
|
1206
|
-
this.lastCounts = /* @__PURE__ */ new Map();
|
|
1207
|
-
/** Rows are structural chart data — they do not move while the organizer is
|
|
1208
|
-
* allocating. Deriving them walks every seat, so the view is cached for the
|
|
1209
|
-
* lifetime of this mode entry and ordinary selection repaints stay O(1). */
|
|
1210
|
-
this.assignmentRowsCache = null;
|
|
1211
|
-
/** The markup currently in the rail. An identical repaint is skipped, which is
|
|
1212
|
-
* what keeps the organizer's scroll position (and open <select>) alive. */
|
|
1213
|
-
this.railHtml = null;
|
|
1214
|
-
/**
|
|
1215
|
-
* The `assignmentVersion` the allocation map was built from. Walking every
|
|
1216
|
-
* allocation page is the expensive half of a refresh and the server already
|
|
1217
|
-
* tells us, in the channels response, whether ANY seat moved. Unchanged
|
|
1218
|
-
* version, unchanged allocation — so the walk is skipped entirely.
|
|
1219
|
-
*/
|
|
1220
|
-
this.allocationVersion = null;
|
|
1221
|
-
this.onVisibility = null;
|
|
1222
|
-
this.host = host;
|
|
1223
|
-
this.caps = capabilities;
|
|
1224
|
-
}
|
|
1225
|
-
// ---- lifecycle ------------------------------------------------------------
|
|
1226
|
-
/** Called when the cockpit switches into Channels mode. */
|
|
1227
|
-
enter() {
|
|
1228
|
-
if (this.active) return;
|
|
1229
|
-
this.active = true;
|
|
1230
|
-
this.mapIntent = "pan";
|
|
1231
|
-
this.assignOpen = false;
|
|
1232
|
-
this.focusedSectionId = null;
|
|
1233
|
-
this.assignmentRowsCache = null;
|
|
1234
|
-
this.railHtml = null;
|
|
1235
|
-
this.ensureLayer();
|
|
1236
|
-
this.host.root.classList.add("ch-mode");
|
|
1237
|
-
this.applySheetClasses();
|
|
1238
|
-
if (this.host.sections().length > 1) this.host.showSectionOverview();
|
|
1239
|
-
this.paintRail();
|
|
1240
|
-
this.onInteractionChange?.();
|
|
1241
|
-
void this.refresh();
|
|
1242
|
-
this.pollTimer = setInterval(() => {
|
|
1243
|
-
if (typeof document !== "undefined" && document.hidden) return;
|
|
1244
|
-
void this.refresh({ quiet: true });
|
|
1245
|
-
}, POLL_MS);
|
|
1246
|
-
if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
|
|
1247
|
-
this.onVisibility = () => {
|
|
1248
|
-
if (this.active && !document.hidden) void this.refresh({ quiet: true });
|
|
1249
|
-
};
|
|
1250
|
-
document.addEventListener("visibilitychange", this.onVisibility);
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
/** Called when the cockpit leaves Channels mode. Everything this mode painted
|
|
1254
|
-
* over the map goes with it — no other tool ever inherits a channel overlay. */
|
|
1255
|
-
leave() {
|
|
1256
|
-
if (!this.active) return;
|
|
1257
|
-
this.active = false;
|
|
1258
|
-
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
1259
|
-
this.pollTimer = null;
|
|
1260
|
-
if (this.onVisibility && typeof document !== "undefined") {
|
|
1261
|
-
document.removeEventListener("visibilitychange", this.onVisibility);
|
|
1262
|
-
}
|
|
1263
|
-
this.onVisibility = null;
|
|
1264
|
-
this.railHtml = null;
|
|
1265
|
-
this.closeDialog({ restoreFocus: false });
|
|
1266
|
-
this.links = [];
|
|
1267
|
-
this.linksChannelId = null;
|
|
1268
|
-
this.linksState = "idle";
|
|
1269
|
-
this.layer?.classList.remove("on");
|
|
1270
|
-
this.host.root.classList.remove(
|
|
1271
|
-
"ch-mode",
|
|
1272
|
-
"ch-preview",
|
|
1273
|
-
"ch-sheet",
|
|
1274
|
-
"detent-collapsed",
|
|
1275
|
-
"detent-medium",
|
|
1276
|
-
"detent-full"
|
|
1277
|
-
);
|
|
1278
|
-
this.setBanner(false);
|
|
1279
|
-
this.setStaged(null);
|
|
1280
|
-
}
|
|
1281
|
-
destroy() {
|
|
1282
|
-
this.leave();
|
|
1283
|
-
if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
|
|
1284
|
-
this.layer?.remove();
|
|
1285
|
-
this.layer = null;
|
|
1286
|
-
}
|
|
1287
|
-
/** Capabilities can change when a token rotates. Re-render, fail-closed. */
|
|
1288
|
-
setCapabilities(capabilities) {
|
|
1289
|
-
this.caps = capabilities;
|
|
1290
|
-
if (this.active) this.paintRail();
|
|
1291
|
-
}
|
|
1292
|
-
isActive() {
|
|
1293
|
-
return this.active;
|
|
1294
|
-
}
|
|
1295
|
-
/**
|
|
1296
|
-
* Whether the map should accept bulk selection right now. Preview is a
|
|
1297
|
-
* read-only simulation of somebody else's view, and a view-only token has no
|
|
1298
|
-
* assignment to stage — in both cases the canvas must not offer selection at
|
|
1299
|
-
* all rather than collect a selection nothing can act on.
|
|
1300
|
-
*/
|
|
1301
|
-
canSelect() {
|
|
1302
|
-
return this.caps.manage && this.view === "inspect";
|
|
1303
|
-
}
|
|
1304
|
-
/** Bulk seat assignment is explicit. In Pan map, clicks can still inspect a
|
|
1305
|
-
* single seat, while a primary-button drag always moves the camera. */
|
|
1306
|
-
usesMarqueeSelection() {
|
|
1307
|
-
return this.canSelect() && this.mapIntent === "assign";
|
|
1308
|
-
}
|
|
1309
|
-
/** The renderer calls this when the organizer opens a section from overview. */
|
|
1310
|
-
handleSectionFocus(sectionId) {
|
|
1311
|
-
if (!this.active) return;
|
|
1312
|
-
this.focusedSectionId = sectionId;
|
|
1313
|
-
this.paintRail();
|
|
1314
|
-
}
|
|
1315
|
-
/**
|
|
1316
|
-
* Organizer realtime integration point. M5 ships a per-scope socket for
|
|
1317
|
-
* buyers; the organizer channel-count stream is a later milestone. When it
|
|
1318
|
-
* arrives, call this from the cockpit's WS handler instead of waiting for the
|
|
1319
|
-
* poll — everything downstream already reacts to a fresh list.
|
|
1320
|
-
*/
|
|
1321
|
-
applyRealtimeHint() {
|
|
1322
|
-
if (this.active) void this.refresh({ quiet: true });
|
|
1323
|
-
}
|
|
1324
|
-
/** The cockpit's selection changed (marquee / click / section / category). */
|
|
1325
|
-
handleSelectionChange() {
|
|
1326
|
-
if (!this.active) return;
|
|
1327
|
-
this.paintSelection();
|
|
1328
|
-
this.paintStagedBar();
|
|
1329
|
-
}
|
|
1330
|
-
/** Camera moved or the container resized — the overlay is screen-space. */
|
|
1331
|
-
handleViewChange() {
|
|
1332
|
-
if (this.active) this.paintOverlay();
|
|
1333
|
-
}
|
|
1334
|
-
handleLayoutChange() {
|
|
1335
|
-
if (!this.active) return;
|
|
1336
|
-
this.applySheetClasses();
|
|
1337
|
-
this.paintOverlay();
|
|
1338
|
-
}
|
|
1339
|
-
// ---- data -----------------------------------------------------------------
|
|
1340
|
-
async refresh(opts = {}) {
|
|
1341
|
-
if (!this.caps.view) return;
|
|
1342
|
-
const seq = ++this.listSeq;
|
|
1343
|
-
const superseded = () => seq !== this.listSeq;
|
|
1344
|
-
try {
|
|
1345
|
-
const list = await this.host.api.channels(this.host.eventKey, { includeArchived: this.showArchived });
|
|
1346
|
-
if (superseded()) return;
|
|
1347
|
-
this.list = list;
|
|
1348
|
-
this.assignmentVersion = list.assignmentVersion;
|
|
1349
|
-
this.loadError = null;
|
|
1350
|
-
if (!this.targetChannelId) {
|
|
1351
|
-
this.targetChannelId = list.channels.find((c) => c.state === "active")?.id ?? PUBLIC_CHANNEL_ID;
|
|
1352
|
-
}
|
|
1353
|
-
if (this.allocationVersion !== list.assignmentVersion) {
|
|
1354
|
-
await this.loadAllocation(seq);
|
|
1355
|
-
if (superseded()) return;
|
|
1356
|
-
this.allocationVersion = list.assignmentVersion;
|
|
1357
|
-
}
|
|
1358
|
-
if (this.detailChannelId) await this.loadLinks(this.detailChannelId);
|
|
1359
|
-
if (superseded()) return;
|
|
1360
|
-
this.loading = false;
|
|
1361
|
-
if (this.active) {
|
|
1362
|
-
this.paintRail();
|
|
1363
|
-
this.paintOverlay();
|
|
1364
|
-
}
|
|
1365
|
-
} catch (err) {
|
|
1366
|
-
if (superseded()) return;
|
|
1367
|
-
this.loading = false;
|
|
1368
|
-
if (err instanceof ManageApiError && err.status === 403) {
|
|
1369
|
-
this.caps = { view: false, manage: false };
|
|
1370
|
-
}
|
|
1371
|
-
this.loadError = err;
|
|
1372
|
-
if (!opts.quiet) this.host.onError(err);
|
|
1373
|
-
if (this.active) this.paintRail();
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
/** Walk every allocation page. Bounded by the event's seat count, and the
|
|
1377
|
-
* server caps each page, so an arena is a handful of round trips. */
|
|
1378
|
-
async loadAllocation(seq) {
|
|
1379
|
-
const next = /* @__PURE__ */ new Map();
|
|
1380
|
-
let afterLabel;
|
|
1381
|
-
for (let page = 0; page < 200; page += 1) {
|
|
1382
|
-
if (seq !== void 0 && seq !== this.listSeq) return;
|
|
1383
|
-
const res = await this.host.api.channelAllocation(this.host.eventKey, {
|
|
1384
|
-
afterLabel,
|
|
1385
|
-
limit: 1e3
|
|
1386
|
-
});
|
|
1387
|
-
for (const row of res.allocations) {
|
|
1388
|
-
if (!isPublicChannelId(row.channelId)) next.set(row.label, row.channelId);
|
|
1389
|
-
}
|
|
1390
|
-
this.assignmentVersion = res.assignmentVersion;
|
|
1391
|
-
if (!res.nextAfterLabel) break;
|
|
1392
|
-
afterLabel = res.nextAfterLabel;
|
|
1393
|
-
}
|
|
1394
|
-
if (seq !== void 0 && seq !== this.listSeq) return;
|
|
1395
|
-
this.allocation = next;
|
|
1396
|
-
}
|
|
1397
|
-
// ---- lookups --------------------------------------------------------------
|
|
1398
|
-
channelById(id) {
|
|
1399
|
-
if (isPublicChannelId(id)) {
|
|
1400
|
-
return {
|
|
1401
|
-
id: PUBLIC_CHANNEL_ID,
|
|
1402
|
-
name: this.list?.publicSale?.name ?? PUBLIC_CHANNEL_NAME,
|
|
1403
|
-
marker: "P",
|
|
1404
|
-
color: null
|
|
1405
|
-
};
|
|
1406
|
-
}
|
|
1407
|
-
const found = this.list?.channels.find((channel) => channel.id === id);
|
|
1408
|
-
return found ? { id, name: found.name, marker: found.marker, color: found.color } : null;
|
|
1409
|
-
}
|
|
1410
|
-
nameOf(id) {
|
|
1411
|
-
return this.channelById(id)?.name ?? null;
|
|
1412
|
-
}
|
|
1413
|
-
markerFor(id) {
|
|
1414
|
-
const index = Math.max(0, this.list?.channels.findIndex((channel2) => channel2.id === id) ?? 0);
|
|
1415
|
-
const channel = this.channelById(id);
|
|
1416
|
-
return markerOf(channel ?? { id, name: "?", marker: null, color: null }, index);
|
|
1417
|
-
}
|
|
1418
|
-
/** Channels an organizer may assign INTO: public sale plus every live channel. */
|
|
1419
|
-
assignableChannels() {
|
|
1420
|
-
return [
|
|
1421
|
-
{ id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
1422
|
-
...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
|
|
1423
|
-
];
|
|
1424
|
-
}
|
|
1425
|
-
currentPlan() {
|
|
1426
|
-
const labels = this.host.selectionLabels();
|
|
1427
|
-
const buckets = planAssignment({
|
|
1428
|
-
labels,
|
|
1429
|
-
targetChannelId: this.targetChannelId,
|
|
1430
|
-
allocation: this.allocation,
|
|
1431
|
-
statusOf: (label) => this.host.statusOf(label),
|
|
1432
|
-
nameOf: (id) => this.nameOf(id)
|
|
1433
|
-
});
|
|
1434
|
-
return { labels, buckets, target: this.targetChannelId };
|
|
1435
|
-
}
|
|
1436
|
-
// ---- map overlay ----------------------------------------------------------
|
|
1437
|
-
ensureLayer() {
|
|
1438
|
-
if (this.layer) return;
|
|
1439
|
-
const layer = document.createElement("div");
|
|
1440
|
-
layer.className = "slm-ch-layer";
|
|
1441
|
-
layer.innerHTML = `
|
|
1442
|
-
<canvas class="slm-ch-canvas" data-ch="canvas" aria-hidden="true"></canvas>
|
|
1443
|
-
<div class="slm-ch-banner" data-ch="banner" role="status"></div>
|
|
1444
|
-
<div class="slm-ch-staged" data-ch="staged" role="group" aria-label="Staged channel changes"></div>
|
|
1445
|
-
<div class="slm-ch-live" data-ch="live" role="status" aria-live="polite"></div>`;
|
|
1446
|
-
this.host.mapLayer.appendChild(layer);
|
|
1447
|
-
this.layer = layer;
|
|
1448
|
-
this.canvas = layer.querySelector('[data-ch="canvas"]');
|
|
1449
|
-
this.bannerEl = layer.querySelector('[data-ch="banner"]');
|
|
1450
|
-
this.stagedEl = layer.querySelector('[data-ch="staged"]');
|
|
1451
|
-
this.liveEl = layer.querySelector('[data-ch="live"]');
|
|
1452
|
-
requestAnimationFrame(() => layer.classList.add("on"));
|
|
1453
|
-
}
|
|
1454
|
-
announce(message) {
|
|
1455
|
-
if (this.liveEl) this.liveEl.textContent = message;
|
|
1456
|
-
}
|
|
1457
|
-
/**
|
|
1458
|
-
* Repaint the allocation (or preview) overlay in ONE canvas pass.
|
|
1459
|
-
*
|
|
1460
|
-
* Channel identity on the map is a fill in the administrative color PLUS the
|
|
1461
|
-
* letter flags below — never color alone. In buyer preview the map instead
|
|
1462
|
-
* uses two explicit, channel-neutral access states. Physical status keeps its
|
|
1463
|
-
* own cue: only FREE units are repainted, so sold/held/blocked seats still
|
|
1464
|
-
* read exactly as they do in every other tool.
|
|
1465
|
-
*/
|
|
1466
|
-
paintOverlay() {
|
|
1467
|
-
const canvas = this.canvas;
|
|
1468
|
-
const layer = this.layer;
|
|
1469
|
-
if (!canvas || !layer) return;
|
|
1470
|
-
const rect = this.host.mapLayer.getBoundingClientRect();
|
|
1471
|
-
const width = Math.max(1, Math.round(rect.width));
|
|
1472
|
-
const height = Math.max(1, Math.round(rect.height));
|
|
1473
|
-
const dpr = typeof devicePixelRatio === "number" ? Math.min(3, Math.max(1, devicePixelRatio)) : 1;
|
|
1474
|
-
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
|
|
1475
|
-
canvas.width = width * dpr;
|
|
1476
|
-
canvas.height = height * dpr;
|
|
1477
|
-
}
|
|
1478
|
-
if (this.ctx === void 0) {
|
|
1479
|
-
try {
|
|
1480
|
-
this.ctx = canvas.getContext("2d");
|
|
1481
|
-
} catch {
|
|
1482
|
-
this.ctx = null;
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
const ctx = this.ctx;
|
|
1486
|
-
if (!ctx) return;
|
|
1487
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1488
|
-
ctx.clearRect(0, 0, width, height);
|
|
1489
|
-
const seatDetail = this.host.isSeatDetail();
|
|
1490
|
-
const size = Math.max(3, this.host.seatPixelSize());
|
|
1491
|
-
const half = size / 2;
|
|
1492
|
-
const projection = this.view === "preview" ? this.previewProjection : null;
|
|
1493
|
-
const eligible = projection ? new Set(projection.available === false ? [] : projection.eligible ?? []) : null;
|
|
1494
|
-
const clusters = /* @__PURE__ */ new Map();
|
|
1495
|
-
const sectionTargets = !seatDetail && this.host.sections().length > 1 ? /* @__PURE__ */ new Map() : null;
|
|
1496
|
-
const previewSections = this.view === "preview" && seatDetail && size <= 15 ? /* @__PURE__ */ new Map() : null;
|
|
1497
|
-
for (const seat of this.host.seats()) {
|
|
1498
|
-
const status = this.host.statusOf(seat.label) ?? "free";
|
|
1499
|
-
const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
|
|
1500
|
-
if (channelId !== PUBLIC_CHANNEL_ID) {
|
|
1501
|
-
const cluster = clusters.get(channelId) ?? { x: 0, y: 0, n: 0 };
|
|
1502
|
-
cluster.x += seat.x;
|
|
1503
|
-
cluster.y += seat.y;
|
|
1504
|
-
cluster.n += 1;
|
|
1505
|
-
clusters.set(channelId, cluster);
|
|
1506
|
-
}
|
|
1507
|
-
if (!seatDetail) {
|
|
1508
|
-
const section = this.host.sectionOfLabel(seat.label);
|
|
1509
|
-
const point2 = section ? this.host.worldToScreen({ x: seat.x, y: seat.y }) : null;
|
|
1510
|
-
if (sectionTargets && section && point2) {
|
|
1511
|
-
const bounds = sectionTargets.get(section.id) ?? {
|
|
1512
|
-
label: section.label,
|
|
1513
|
-
minX: point2.x,
|
|
1514
|
-
minY: point2.y,
|
|
1515
|
-
maxX: point2.x,
|
|
1516
|
-
maxY: point2.y
|
|
1517
|
-
};
|
|
1518
|
-
bounds.minX = Math.min(bounds.minX, point2.x);
|
|
1519
|
-
bounds.minY = Math.min(bounds.minY, point2.y);
|
|
1520
|
-
bounds.maxX = Math.max(bounds.maxX, point2.x);
|
|
1521
|
-
bounds.maxY = Math.max(bounds.maxY, point2.y);
|
|
1522
|
-
sectionTargets.set(section.id, bounds);
|
|
1523
|
-
}
|
|
1524
|
-
continue;
|
|
1525
|
-
}
|
|
1526
|
-
if (status !== "free") continue;
|
|
1527
|
-
let fill = null;
|
|
1528
|
-
let stroke = null;
|
|
1529
|
-
if (this.view === "preview") {
|
|
1530
|
-
if (eligible?.has(seat.label)) {
|
|
1531
|
-
fill = PREVIEW_ELIGIBLE_FILL;
|
|
1532
|
-
stroke = PREVIEW_ELIGIBLE_STROKE;
|
|
1533
|
-
} else {
|
|
1534
|
-
fill = PREVIEW_UNAVAILABLE_FILL;
|
|
1535
|
-
stroke = PREVIEW_UNAVAILABLE_STROKE;
|
|
1536
|
-
}
|
|
1537
|
-
} else if (channelId !== PUBLIC_CHANNEL_ID) {
|
|
1538
|
-
fill = this.markerFor(channelId).color;
|
|
1539
|
-
stroke = ALLOCATION_STROKE;
|
|
1540
|
-
}
|
|
1541
|
-
if (!fill) continue;
|
|
1542
|
-
const point = this.host.worldToScreen({ x: seat.x, y: seat.y });
|
|
1543
|
-
if (!point) continue;
|
|
1544
|
-
if (point.x < -size || point.y < -size || point.x > width + size || point.y > height + size) continue;
|
|
1545
|
-
if (previewSections) {
|
|
1546
|
-
const section = this.host.sectionOfLabel(seat.label);
|
|
1547
|
-
if (section) {
|
|
1548
|
-
const bounds = previewSections.get(section.id) ?? {
|
|
1549
|
-
label: section.label,
|
|
1550
|
-
minX: point.x,
|
|
1551
|
-
minY: point.y,
|
|
1552
|
-
maxX: point.x,
|
|
1553
|
-
maxY: point.y
|
|
1554
|
-
};
|
|
1555
|
-
bounds.minX = Math.min(bounds.minX, point.x);
|
|
1556
|
-
bounds.minY = Math.min(bounds.minY, point.y);
|
|
1557
|
-
bounds.maxX = Math.max(bounds.maxX, point.x);
|
|
1558
|
-
bounds.maxY = Math.max(bounds.maxY, point.y);
|
|
1559
|
-
previewSections.set(section.id, bounds);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
ctx.fillStyle = fill;
|
|
1563
|
-
if (this.view === "preview" || channelId !== PUBLIC_CHANNEL_ID) {
|
|
1564
|
-
const radius = Math.max(2, half + Math.min(1.5, half * 0.06));
|
|
1565
|
-
ctx.globalAlpha = 1;
|
|
1566
|
-
ctx.beginPath();
|
|
1567
|
-
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
|
1568
|
-
ctx.fill();
|
|
1569
|
-
ctx.strokeStyle = stroke ?? fill;
|
|
1570
|
-
ctx.lineWidth = this.view === "preview" ? Math.max(1, Math.min(1.75, size * 0.13)) : Math.max(1, Math.min(1.5, size * 0.1));
|
|
1571
|
-
ctx.stroke();
|
|
1572
|
-
if (this.view === "preview" && eligible?.has(seat.label) && size >= 22) {
|
|
1573
|
-
this.paintPreviewSeatLabel(ctx, seat.label, point.x, point.y, radius);
|
|
1574
|
-
}
|
|
1575
|
-
} else {
|
|
1576
|
-
ctx.globalAlpha = 0.85;
|
|
1577
|
-
ctx.fillRect(point.x - half, point.y - half, size, size);
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
ctx.globalAlpha = 1;
|
|
1581
|
-
if (previewSections) this.paintPreviewSectionLabels(ctx, previewSections);
|
|
1582
|
-
this.paintSectionTargets(sectionTargets);
|
|
1583
|
-
this.paintFlags(clusters);
|
|
1584
|
-
}
|
|
1585
|
-
/** Draw an eligible seat's actual chart label without inventing a new buyer
|
|
1586
|
-
* identifier. Long labels scale down and are omitted rather than overflowing
|
|
1587
|
-
* into an adjacent seat. */
|
|
1588
|
-
paintPreviewSeatLabel(ctx, label, x, y, radius) {
|
|
1589
|
-
const maxWidth = radius * 1.55;
|
|
1590
|
-
let fontSize = Math.min(13, Math.max(7, radius * 0.55));
|
|
1591
|
-
const minFontSize = 6;
|
|
1592
|
-
while (fontSize >= minFontSize) {
|
|
1593
|
-
ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
|
|
1594
|
-
if (ctx.measureText(label).width <= maxWidth) break;
|
|
1595
|
-
fontSize -= 0.5;
|
|
1596
|
-
}
|
|
1597
|
-
if (fontSize < minFontSize) return;
|
|
1598
|
-
ctx.fillStyle = "#ffffff";
|
|
1599
|
-
ctx.textAlign = "center";
|
|
1600
|
-
ctx.textBaseline = "middle";
|
|
1601
|
-
ctx.fillText(label, x, y);
|
|
1602
|
-
}
|
|
1603
|
-
/** A section overview is a navigation map. These transparent, keyboardable
|
|
1604
|
-
* hit areas sit over the renderer's section shells so both mouse and keyboard
|
|
1605
|
-
* always take the organizer into the real focused-section camera state. */
|
|
1606
|
-
paintSectionTargets(sections) {
|
|
1607
|
-
const layer = this.layer;
|
|
1608
|
-
if (!layer) return;
|
|
1609
|
-
layer.querySelectorAll(".slm-ch-section-target").forEach((el) => el.remove());
|
|
1610
|
-
if (!sections) return;
|
|
1611
|
-
for (const [id, section] of sections) {
|
|
1612
|
-
const width = section.maxX - section.minX;
|
|
1613
|
-
const height = section.maxY - section.minY;
|
|
1614
|
-
if (width < 20 || height < 20) continue;
|
|
1615
|
-
const target = document.createElement("button");
|
|
1616
|
-
target.type = "button";
|
|
1617
|
-
target.className = "slm-ch-section-target";
|
|
1618
|
-
target.style.left = `${section.minX - 8}px`;
|
|
1619
|
-
target.style.top = `${section.minY - 8}px`;
|
|
1620
|
-
target.style.width = `${width + 16}px`;
|
|
1621
|
-
target.style.height = `${height + 16}px`;
|
|
1622
|
-
target.setAttribute("aria-label", `Open ${section.label} seats`);
|
|
1623
|
-
target.addEventListener("click", () => {
|
|
1624
|
-
this.focusedSectionId = id;
|
|
1625
|
-
this.host.focusSection(id);
|
|
1626
|
-
this.paintRail();
|
|
1627
|
-
});
|
|
1628
|
-
layer.appendChild(target);
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
/** Keep renderer section names legible over a dense, zoomed-out preview. */
|
|
1632
|
-
paintPreviewSectionLabels(ctx, sections) {
|
|
1633
|
-
for (const section of sections.values()) {
|
|
1634
|
-
const width = section.maxX - section.minX;
|
|
1635
|
-
const height = section.maxY - section.minY;
|
|
1636
|
-
if (width < 52 || height < 26) continue;
|
|
1637
|
-
const centerX = (section.minX + section.maxX) / 2;
|
|
1638
|
-
const centerY = (section.minY + section.maxY) / 2;
|
|
1639
|
-
const fontSize = Math.max(11, Math.min(15, height * 0.16));
|
|
1640
|
-
ctx.font = `800 ${fontSize}px var(--slm-font, system-ui, sans-serif)`;
|
|
1641
|
-
const labelWidth = Math.min(width - 8, ctx.measureText(section.label).width + 18);
|
|
1642
|
-
const labelHeight = fontSize + 10;
|
|
1643
|
-
ctx.fillStyle = "rgba(11, 16, 28, .88)";
|
|
1644
|
-
ctx.fillRect(centerX - labelWidth / 2, centerY - labelHeight / 2, labelWidth, labelHeight);
|
|
1645
|
-
ctx.fillStyle = "#f8fafc";
|
|
1646
|
-
ctx.textAlign = "center";
|
|
1647
|
-
ctx.textBaseline = "middle";
|
|
1648
|
-
ctx.fillText(section.label, centerX, centerY);
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
/** Letter flags at each channel's centroid — the non-color identity cue. */
|
|
1652
|
-
paintFlags(clusters) {
|
|
1653
|
-
const layer = this.layer;
|
|
1654
|
-
if (!layer) return;
|
|
1655
|
-
layer.querySelectorAll(".slm-ch-flag").forEach((el) => el.remove());
|
|
1656
|
-
if (this.view === "preview") return;
|
|
1657
|
-
const ranked = [...clusters.entries()].sort((a, b) => b[1].n - a[1].n).slice(0, MAX_FLAGS);
|
|
1658
|
-
for (const [channelId, cluster] of ranked) {
|
|
1659
|
-
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
1660
|
-
if (!channel) continue;
|
|
1661
|
-
const point = this.host.worldToScreen({ x: cluster.x / cluster.n, y: cluster.y / cluster.n });
|
|
1662
|
-
if (!point) continue;
|
|
1663
|
-
const marker = this.markerFor(channelId);
|
|
1664
|
-
const flag = document.createElement("span");
|
|
1665
|
-
flag.className = "slm-ch-flag";
|
|
1666
|
-
flag.style.left = `${point.x}px`;
|
|
1667
|
-
flag.style.top = `${point.y}px`;
|
|
1668
|
-
flag.innerHTML = `<span class="mk" style="background:${esc(marker.color)}">${esc(marker.letter)}</span>${esc(channel.name)}${channel.state === "paused" ? " \xB7 Paused" : ""}`;
|
|
1669
|
-
layer.appendChild(flag);
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
// ---- staged bar -----------------------------------------------------------
|
|
1673
|
-
setStaged(html, cls = "") {
|
|
1674
|
-
const bar = this.stagedEl;
|
|
1675
|
-
if (!bar) return;
|
|
1676
|
-
if (!html) {
|
|
1677
|
-
bar.classList.remove("on", "done", "shake");
|
|
1678
|
-
bar.innerHTML = "";
|
|
1679
|
-
return;
|
|
1680
|
-
}
|
|
1681
|
-
bar.innerHTML = html;
|
|
1682
|
-
bar.className = `slm-ch-staged on${cls ? ` ${cls}` : ""}`;
|
|
1683
|
-
}
|
|
1684
|
-
paintStagedBar() {
|
|
1685
|
-
if (!this.active || this.view === "preview" || !this.caps.manage) {
|
|
1686
|
-
this.setStaged(null);
|
|
1687
|
-
return;
|
|
1688
|
-
}
|
|
1689
|
-
const { labels, buckets } = this.currentPlan();
|
|
1690
|
-
this.host.onStagedChange?.(mutationCount(buckets));
|
|
1691
|
-
if (!labels.length) {
|
|
1692
|
-
this.setStaged(null);
|
|
1693
|
-
return;
|
|
1694
|
-
}
|
|
1695
|
-
const target = this.nameOf(this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
|
|
1696
|
-
const mutations = mutationCount(buckets);
|
|
1697
|
-
const tooLarge = labels.length > MAX_ASSIGNMENT_UNITS;
|
|
1698
|
-
const skipped = buckets.skippedHeld.count + buckets.skippedBooked.count;
|
|
1699
|
-
const parts = [
|
|
1700
|
-
`<b>${labels.length.toLocaleString()}</b> selected`,
|
|
1701
|
-
`<b>+${mutations.toLocaleString()}</b> to ${esc(target)}`
|
|
1702
|
-
];
|
|
1703
|
-
if (buckets.alreadyInTarget.count) parts.push(`<b>${buckets.alreadyInTarget.count.toLocaleString()}</b> already in`);
|
|
1704
|
-
if (skipped) parts.push(`<b>${skipped.toLocaleString()}</b> can't move now`);
|
|
1705
|
-
this.setStaged(`
|
|
1706
|
-
<span>${parts.join(" \xB7 ")}</span>
|
|
1707
|
-
<span class="grow"></span>
|
|
1708
|
-
<button type="button" class="drop" data-ch-act="discard">Discard</button>
|
|
1709
|
-
<button type="button" class="go" data-ch-act="review"${tooLarge ? " disabled" : ""}>
|
|
1710
|
-
${tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : "Review changes"}
|
|
1711
|
-
</button>`);
|
|
1712
|
-
this.stagedEl?.querySelectorAll("[data-ch-act]").forEach((button) => {
|
|
1713
|
-
button.addEventListener("click", () => {
|
|
1714
|
-
if (button.dataset.chAct === "discard") this.host.clearSelection();
|
|
1715
|
-
else this.openDialog({ kind: "review" });
|
|
1716
|
-
});
|
|
1717
|
-
});
|
|
1718
|
-
}
|
|
1719
|
-
setBanner(on, name = "", eligibleSeats) {
|
|
1720
|
-
const banner = this.bannerEl;
|
|
1721
|
-
if (!banner) return;
|
|
1722
|
-
this.host.root.classList.toggle("ch-preview", on);
|
|
1723
|
-
if (!on) {
|
|
1724
|
-
banner.classList.remove("on");
|
|
1725
|
-
banner.innerHTML = "";
|
|
1726
|
-
return;
|
|
1727
|
-
}
|
|
1728
|
-
const marker = this.previewAudience.length === 1 ? this.markerFor(this.previewAudience[0]) : { color: "var(--slm-accent)", letter: "" };
|
|
1729
|
-
const availability = eligibleSeats == null ? "" : ` \xB7 ${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat" : "seats"} available now`;
|
|
1730
|
-
banner.innerHTML = `<span class="dot" style="background:${esc(marker.color)}"></span>
|
|
1731
|
-
Previewing buyer access \xB7 ${esc(name)}${availability} \xB7 read-only
|
|
1732
|
-
<button type="button" data-ch-act="exit-preview">Exit preview</button>`;
|
|
1733
|
-
banner.classList.add("on");
|
|
1734
|
-
banner.querySelector('[data-ch-act="exit-preview"]')?.addEventListener("click", () => this.setView("inspect"));
|
|
1735
|
-
}
|
|
1736
|
-
// ---- rail -----------------------------------------------------------------
|
|
1737
|
-
setView(view) {
|
|
1738
|
-
this.view = view;
|
|
1739
|
-
if (view === "inspect") {
|
|
1740
|
-
this.previewProjection = null;
|
|
1741
|
-
this.previewState = "idle";
|
|
1742
|
-
this.setBanner(false);
|
|
1743
|
-
} else {
|
|
1744
|
-
if (!this.previewAudience.length) {
|
|
1745
|
-
const first = this.list?.channels.find((channel) => channel.state === "active");
|
|
1746
|
-
this.previewAudience = [first ? first.id : PUBLIC_CHANNEL_ID];
|
|
1747
|
-
}
|
|
1748
|
-
void this.loadPreview();
|
|
1749
|
-
}
|
|
1750
|
-
this.paintRail();
|
|
1751
|
-
this.paintOverlay();
|
|
1752
|
-
this.paintStagedBar();
|
|
1753
|
-
this.onInteractionChange?.();
|
|
1754
|
-
}
|
|
1755
|
-
async loadPreview() {
|
|
1756
|
-
const audience = [...this.previewAudience];
|
|
1757
|
-
const names = audience.map((id) => this.nameOf(id) ?? PUBLIC_CHANNEL_NAME).join(" + ");
|
|
1758
|
-
this.setBanner(true, names);
|
|
1759
|
-
this.previewProjection = null;
|
|
1760
|
-
this.previewState = "loading";
|
|
1761
|
-
if (this.active) this.paintRail();
|
|
1762
|
-
try {
|
|
1763
|
-
this.previewProjection = await this.host.api.channelPreview(this.host.eventKey, audience, {
|
|
1764
|
-
// Naming Public sale as the audience IS asking for public inventory; the
|
|
1765
|
-
// route filters the 'public' sentinel out of `channelIds`, so without
|
|
1766
|
-
// this the request would resolve to an empty scope and 422.
|
|
1767
|
-
includePublic: this.previewIncludePublic || audience.some(isPublicChannelId)
|
|
1768
|
-
});
|
|
1769
|
-
this.previewState = "ready";
|
|
1770
|
-
const eligibleSeats = this.previewProjection.available === false ? void 0 : this.previewProjection.counts?.eligible ?? this.previewProjection.eligible?.length;
|
|
1771
|
-
this.setBanner(true, names, eligibleSeats);
|
|
1772
|
-
} catch (err) {
|
|
1773
|
-
const status = err instanceof ManageApiError ? err.status : 0;
|
|
1774
|
-
this.previewState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
|
|
1775
|
-
this.previewProjection = null;
|
|
1776
|
-
if (this.previewState === "error") this.host.onError(err);
|
|
1777
|
-
}
|
|
1778
|
-
if (this.active) {
|
|
1779
|
-
this.paintRail();
|
|
1780
|
-
this.paintOverlay();
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
/**
|
|
1784
|
-
* Replace the rail's markup — but only when it actually differs, and never at
|
|
1785
|
-
* the cost of where the organizer had scrolled to.
|
|
1786
|
-
*
|
|
1787
|
-
* The rail repaints on a clock, on every selection change and after every
|
|
1788
|
-
* mutation. Rewriting `innerHTML` each time resets `scrollTop`, which is
|
|
1789
|
-
* exactly what "the rail gets stuck" was: scroll down to Create channel, the
|
|
1790
|
-
* poll ticks, and the list snaps back to the top under the cursor. So skip the
|
|
1791
|
-
* write when the markup is byte-identical, and restore the offset when it is
|
|
1792
|
-
* not.
|
|
1793
|
-
*
|
|
1794
|
-
* Returns whether the DOM was rewritten. Callers must only re-wire listeners
|
|
1795
|
-
* when it was — a skipped paint keeps the old nodes AND their listeners, so
|
|
1796
|
-
* re-wiring would double every handler.
|
|
1797
|
-
*/
|
|
1798
|
-
setRailHtml(html) {
|
|
1799
|
-
if (html === this.railHtml) return false;
|
|
1800
|
-
const rail = this.host.rail;
|
|
1801
|
-
const scrollTop = rail.scrollTop;
|
|
1802
|
-
rail.innerHTML = html;
|
|
1803
|
-
this.railHtml = html;
|
|
1804
|
-
if (scrollTop) rail.scrollTop = scrollTop;
|
|
1805
|
-
return true;
|
|
1806
|
-
}
|
|
1807
|
-
paintRail() {
|
|
1808
|
-
if (!this.active) return;
|
|
1809
|
-
const rail = this.host.rail;
|
|
1810
|
-
if (!this.caps.view) {
|
|
1811
|
-
this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
|
|
1812
|
-
<p class="slm-hint">You need channel-management permission on this event to see allocations.</p>`);
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
if (this.loading && !this.list) {
|
|
1816
|
-
this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
|
|
1817
|
-
<div class="slm-ch-busy" role="status" data-ch-state="list-loading">Loading channels and allocations\u2026</div>`);
|
|
1818
|
-
return;
|
|
1819
|
-
}
|
|
1820
|
-
if (!this.list && this.loadError) {
|
|
1821
|
-
if (this.setRailHtml(`<p class="slm-eyebrow">Sales channels</p>
|
|
1822
|
-
<div class="slm-ch-alert err" role="alert" data-ch-state="list-error"><span>\u26A0</span>
|
|
1823
|
-
<span><b>Couldn't load sales channels.</b> This event may well have channels \u2014 we could not read them.
|
|
1824
|
-
Everything else on this event still works.
|
|
1825
|
-
<button type="button" data-ch-act="retry">Try again</button></span></div>`)) {
|
|
1826
|
-
rail.querySelector('[data-ch-act="retry"]')?.addEventListener("click", () => {
|
|
1827
|
-
void this.refresh();
|
|
1828
|
-
});
|
|
1829
|
-
}
|
|
1830
|
-
return;
|
|
1831
|
-
}
|
|
1832
|
-
const selection = this.host.selectionLabels();
|
|
1833
|
-
const grab = this.host.isCompact() ? `<div class="slm-ch-grab"><span class="slm-ch-grabbar"></span></div>` : "";
|
|
1834
|
-
const segment = this.viewSegmentHtml();
|
|
1835
|
-
const body = this.view === "preview" ? this.previewRailHtml() : this.detailChannelId ? this.detailRailHtml(this.detailChannelId) : selection.length && this.caps.manage ? this.selectionRailHtml(selection) : this.listRailHtml();
|
|
1836
|
-
if (this.setRailHtml(`${grab}${segment}${body}`)) this.wireRail();
|
|
1837
|
-
this.paintStagedBar();
|
|
1838
|
-
}
|
|
1839
|
-
viewSegmentHtml() {
|
|
1840
|
-
const inspectOn = this.view === "inspect" ? " on" : "";
|
|
1841
|
-
const previewOn = this.view === "preview" ? " on" : "";
|
|
1842
|
-
return `<div class="slm-ch-viewseg" role="group" aria-label="Channels view">
|
|
1843
|
-
<button type="button" class="${inspectOn.trim()}" data-ch-view="inspect"
|
|
1844
|
-
aria-pressed="${this.view === "inspect"}">Inspect allocation</button>
|
|
1845
|
-
<button type="button" class="${previewOn.trim()}" data-ch-view="preview"
|
|
1846
|
-
aria-pressed="${this.view === "preview"}">Preview buyer access</button>
|
|
1847
|
-
</div>${this.mapNavigationHtml()}`;
|
|
1848
|
-
}
|
|
1849
|
-
mapNavigationHtml() {
|
|
1850
|
-
if (this.host.sections().length < 2) return "";
|
|
1851
|
-
const focused = this.focusedSectionId ? this.host.sections().find((section) => section.id === this.focusedSectionId)?.label ?? "section" : null;
|
|
1852
|
-
const panOn = this.mapIntent === "pan" ? " on" : "";
|
|
1853
|
-
const assignOn = this.mapIntent === "assign" ? " on" : "";
|
|
1854
|
-
const intent = this.view === "inspect" && this.caps.manage ? `<div class="slm-ch-viewseg" role="group" aria-label="Map interaction">
|
|
1855
|
-
<button type="button" class="${panOn.trim()}" data-ch-map="pan" aria-pressed="${this.mapIntent === "pan"}">Pan map</button>
|
|
1856
|
-
<button type="button" class="${assignOn.trim()}" data-ch-map="assign" aria-pressed="${this.mapIntent === "assign"}">Assign seats</button>
|
|
1857
|
-
</div>
|
|
1858
|
-
<p>${this.mapIntent === "pan" ? "Drag to explore. Click a section to open its seats." : "Drag across seats to select them for allocation."}</p>` : "<p>Drag to explore. Click a section to open its seats.</p>";
|
|
1859
|
-
return `<div class="slm-ch-mapnav">
|
|
1860
|
-
<div class="slm-ch-mapnav-head"><span>${focused ? `Viewing ${esc(focused)}` : "Section overview"}</span>
|
|
1861
|
-
<button type="button" data-ch-act="sections">All sections</button></div>
|
|
1862
|
-
${intent}
|
|
1863
|
-
</div>`;
|
|
1864
|
-
}
|
|
1865
|
-
countsHtml(counts, key) {
|
|
1866
|
-
const cell = (id, value, label, cls = "") => {
|
|
1867
|
-
const previous = this.lastCounts.get(`${key}:${id}`);
|
|
1868
|
-
const bump = previous != null && previous !== value ? " bump" : "";
|
|
1869
|
-
this.lastCounts.set(`${key}:${id}`, value);
|
|
1870
|
-
return `<span class="${cls}"><b class="${bump.trim()}">${value.toLocaleString()}</b> ${label}</span>`;
|
|
1871
|
-
};
|
|
1872
|
-
return `<span class="slm-ch-counts">
|
|
1873
|
-
${cell("allocated", counts.allocated, "allocated")}
|
|
1874
|
-
${cell("free", counts.free, "free", "free")}
|
|
1875
|
-
${cell("booked", counts.booked, "sold")}
|
|
1876
|
-
${counts.held ? cell("held", counts.held, "held") : ""}
|
|
1877
|
-
</span>`;
|
|
1878
|
-
}
|
|
1879
|
-
channelRowHtml(channel, opts = {}) {
|
|
1880
|
-
const marker = this.markerFor(channel.id);
|
|
1881
|
-
const badgeKind = opts.builtin ? "builtin" : channel.state;
|
|
1882
|
-
const dim = channel.state === "paused" || channel.state === "archived" ? " dim" : "";
|
|
1883
|
-
const opens = !opts.builtin && this.caps.manage && this.detailChannelId !== channel.id;
|
|
1884
|
-
const cls = `slm-ch-row${opts.builtin ? " public" : ""}${channel.state === "archived" ? " archived" : ""}${this.detailChannelId === channel.id ? " on" : ""}${opens ? " open" : ""}`;
|
|
1885
|
-
const more = !opts.builtin && this.caps.manage ? `<button type="button" class="slm-ch-more" data-ch-menu="${esc(channel.id)}"
|
|
1886
|
-
aria-label="More actions for ${esc(channel.name)}" aria-haspopup="dialog">\u22EF</button>` : "";
|
|
1887
|
-
const rowAttrs = opens ? ` role="button" tabindex="0" data-ch-open="${esc(channel.id)}"
|
|
1888
|
-
aria-label="Open ${esc(channel.name)}"` : "";
|
|
1889
|
-
return `<div class="${cls}"${rowAttrs}>
|
|
1890
|
-
<span class="slm-ch-head">
|
|
1891
|
-
<span class="slm-ch-mk${dim}" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
|
|
1892
|
-
<span class="slm-ch-name">${esc(channel.name)}</span>
|
|
1893
|
-
<span class="slm-ch-badge ${badgeKind}">${esc(stateBadge(opts.builtin ? "builtin" : channel.state))}</span>
|
|
1894
|
-
${more}
|
|
1895
|
-
</span>
|
|
1896
|
-
${this.countsHtml(channel.counts, channel.id || "public")}
|
|
1897
|
-
${opts.builtin ? "" : `<span class="slm-ch-access">${esc(accessLine(channel.access))}</span>`}
|
|
1898
|
-
</div>`;
|
|
1899
|
-
}
|
|
1900
|
-
listRailHtml() {
|
|
1901
|
-
const list = this.list;
|
|
1902
|
-
const archivedCount = list.channels.filter((channel) => channel.state === "archived").length;
|
|
1903
|
-
const visible = list.channels.filter((channel) => this.showArchived || channel.state !== "archived");
|
|
1904
|
-
const rows = [
|
|
1905
|
-
this.channelRowHtml(list.publicSale, { builtin: true }),
|
|
1906
|
-
...visible.map((channel, index) => this.channelRowHtml(channel, { index }))
|
|
1907
|
-
].join("");
|
|
1908
|
-
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>`;
|
|
1909
|
-
const create = this.caps.manage ? `<button type="button" class="slm-btn ghost" style="width:100%" data-ch-act="create">+ Create channel</button>` : "";
|
|
1910
|
-
const readOnly = this.caps.manage ? "" : `<p class="slm-note">You can see how inventory is allocated. Changing it needs channel-management permission.</p>`;
|
|
1911
|
-
return `
|
|
1912
|
-
<p class="slm-eyebrow">Sales channels</p>
|
|
1913
|
-
<p class="slm-hint">Channel colours and names are only visible to organizers, never to buyers.</p>
|
|
1914
|
-
<div class="slm-ch-list">${rows}</div>
|
|
1915
|
-
${empty}
|
|
1916
|
-
${create}
|
|
1917
|
-
${readOnly}
|
|
1918
|
-
${this.assignEntryHtml()}
|
|
1919
|
-
<p class="slm-note" style="margin-top:10px">
|
|
1920
|
-
<button type="button" class="slm-linkbtn" data-ch-act="toggle-archived" aria-pressed="${this.showArchived}"
|
|
1921
|
-
style="text-align:left">${this.showArchived ? "Hide" : "Show"} archived${archivedCount ? ` (${archivedCount})` : ""}</button>
|
|
1922
|
-
</p>`;
|
|
1923
|
-
}
|
|
1924
|
-
/**
|
|
1925
|
-
* The assignment entry point on the list rail.
|
|
1926
|
-
*
|
|
1927
|
-
* Collapsed it is ONE plain-language action, so the channels the organizer came
|
|
1928
|
-
* for stay at the top of the panel. Opened it is the same tool set that has
|
|
1929
|
-
* always worked, in the same order, plus the way back out — and opening it is
|
|
1930
|
-
* also what the map's "Assign seats" segment does, so the two routes into the
|
|
1931
|
-
* job cannot disagree about whether it is running.
|
|
1932
|
-
*/
|
|
1933
|
-
assignEntryHtml() {
|
|
1934
|
-
if (!this.caps.manage) return "";
|
|
1935
|
-
if (!this.assignOpen) {
|
|
1936
|
-
return `
|
|
1937
|
-
<button type="button" class="slm-btn ghost" style="width:100%;margin-top:8px"
|
|
1938
|
-
data-ch-act="assign-open" aria-expanded="false">Assign seats to a channel</button>
|
|
1939
|
-
<p class="slm-note">Move whole sections, rows, a dragged area or single seats out of public sale
|
|
1940
|
-
and into a channel. Nothing moves until you review and apply.</p>`;
|
|
1941
|
-
}
|
|
1942
|
-
return `<div style="margin-top:8px">${this.assignmentToolsHtml({ collapsible: true })}</div>`;
|
|
1943
|
-
}
|
|
1944
|
-
/**
|
|
1945
|
-
* Destination-first assignment controls.
|
|
1946
|
-
*
|
|
1947
|
-
* These used to live only inside the selection rail, which meant every route
|
|
1948
|
-
* into them was gated behind "select a seat on the map first" — the section,
|
|
1949
|
-
* row and category choosers were invisible until the organizer had already
|
|
1950
|
-
* done the work by hand. They are still reachable before a seat is selected,
|
|
1951
|
-
* but they are no longer the first thing in the panel: on the list rail they
|
|
1952
|
-
* are disclosed by `assignEntryHtml`, and there they carry a way back out
|
|
1953
|
-
* (`collapsible`). In the selection rail there is nothing to disclose — a
|
|
1954
|
-
* selection IS the intent — so they paint unconditionally and without Done.
|
|
1955
|
-
*/
|
|
1956
|
-
assignmentToolsHtml(opts = {}) {
|
|
1957
|
-
const options = this.assignableChannels().map((channel) => `<option value="${esc(channel.id)}"${channel.id === this.targetChannelId ? " selected" : ""}>${esc(channel.name)}</option>`).join("");
|
|
1958
|
-
const sections = this.host.sections().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-sections">Sections</button>' : "";
|
|
1959
|
-
const rows = this.assignmentRows().length ? '<button type="button" class="slm-btn ghost" data-ch-act="pick-rows">Rows</button>' : "";
|
|
1960
|
-
const dragClass = this.mapIntent === "assign" ? "slm-btn" : "slm-btn ghost";
|
|
1961
|
-
const done = opts.collapsible ? `<button type="button" class="slm-linkbtn" data-ch-act="assign-close"
|
|
1962
|
-
aria-expanded="true" style="float:right;font-weight:800">Done</button>` : "";
|
|
1963
|
-
return `
|
|
1964
|
-
<p class="slm-eyebrow">${done}Assign inventory</p>
|
|
1965
|
-
<div class="slm-field">
|
|
1966
|
-
<label for="slm-ch-target">Assign to</label>
|
|
1967
|
-
<select class="slm-select" id="slm-ch-target" data-ch-target>${options}</select>
|
|
1968
|
-
</div>
|
|
1969
|
-
<p class="slm-eyebrow" style="margin-top:12px">Select by</p>
|
|
1970
|
-
<div class="slm-ch-row2" style="margin-top:2px">
|
|
1971
|
-
${sections}${rows}
|
|
1972
|
-
<button type="button" class="${dragClass}" data-ch-act="drag-select">Drag box</button>
|
|
1973
|
-
</div>
|
|
1974
|
-
<div class="slm-ch-row2">
|
|
1975
|
-
<button type="button" class="slm-btn ghost" data-ch-act="pick-category">Category</button>
|
|
1976
|
-
<button type="button" class="slm-btn ghost" data-ch-act="seatlist">Seat list \u2328</button>
|
|
1977
|
-
</div>
|
|
1978
|
-
<p class="slm-note" style="margin-bottom:18px">Choose a destination, then add whole sections,
|
|
1979
|
-
multiple rows, a dragged area, or individual seats.</p>`;
|
|
1980
|
-
}
|
|
1981
|
-
selectionRailHtml(selection) {
|
|
1982
|
-
const sources = selectionSources(selection, this.allocation, this.list);
|
|
1983
|
-
const conflict = this.conflict ? `<div class="slm-ch-alert err" role="alert"><span>\u26A0</span>
|
|
1984
|
-
<span><b>Assignments changed while you were editing.</b> Nothing was applied.
|
|
1985
|
-
Your ${selection.length.toLocaleString()}-seat selection is kept.
|
|
1986
|
-
<button type="button" data-ch-act="refresh-review">Refresh and review \u2192</button></span></div>` : "";
|
|
1987
|
-
const bump = selection.length !== this.lastSelectionCount ? " bump" : "";
|
|
1988
|
-
this.lastSelectionCount = selection.length;
|
|
1989
|
-
const tooLarge = selection.length > MAX_ASSIGNMENT_UNITS;
|
|
1990
|
-
const sourceRows = sources.map((row) => {
|
|
1991
|
-
const marker = this.markerFor(row.channelId);
|
|
1992
|
-
return `<div class="slm-ch-selsrc-row">
|
|
1993
|
-
<span class="mk" style="background:${esc(marker.color)}" aria-hidden="true">${esc(marker.letter)}</span>
|
|
1994
|
-
<b>${row.count.toLocaleString()}</b><span>${esc(row.name)}</span></div>`;
|
|
1995
|
-
}).join("");
|
|
1996
|
-
return `
|
|
1997
|
-
${conflict}
|
|
1998
|
-
${this.assignmentToolsHtml()}
|
|
1999
|
-
<div class="slm-selbar"><span class="slm-selnum slm-ch-selnum${bump}">${selection.length.toLocaleString()}</span>
|
|
2000
|
-
<span class="slm-sellabel">selected</span></div>
|
|
2001
|
-
<div class="slm-ch-selsrc" aria-live="polite" aria-label="Selection sources">${sourceRows}</div>
|
|
2002
|
-
${tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span>
|
|
2003
|
-
<b>This selection is too large to apply at once.</b> Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()}
|
|
2004
|
-
seats, for example by splitting the venue into row groups.</span></div>` : ""}
|
|
2005
|
-
<p class="slm-note">Changes are staged \u2014 nothing moves until you review and apply.
|
|
2006
|
-
Seats in checkout or already sold are never moved.</p>
|
|
2007
|
-
<div class="slm-ch-row2">
|
|
2008
|
-
<button type="button" class="slm-btn ghost" data-ch-act="discard">Clear selection</button>
|
|
2009
|
-
<button type="button" class="slm-btn" data-ch-act="review"${tooLarge ? " disabled" : ""}>Review changes</button>
|
|
2010
|
-
</div>
|
|
2011
|
-
<p class="slm-note">The seat list offers the same selection with checkboxes for keyboard and screen-reader use.</p>`;
|
|
2012
|
-
}
|
|
2013
|
-
detailRailHtml(channelId) {
|
|
2014
|
-
const back = `<p class="slm-eyebrow">
|
|
2015
|
-
<button type="button" class="slm-linkbtn" data-ch-act="back" style="text-align:left">\u2039 All channels</button>
|
|
2016
|
-
</p>`;
|
|
2017
|
-
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
2018
|
-
if (!channel) {
|
|
2019
|
-
return `${back}
|
|
2020
|
-
<div class="slm-ch-alert warn" role="status" data-ch-state="detail-gone"><span>\u2139</span>
|
|
2021
|
-
<span><b>This channel is no longer on this event.</b> It was archived or removed while you had it open.
|
|
2022
|
-
${this.showArchived ? "" : "Archived channels are hidden \u2014 use \u201CShow archived\u201D on the list to see it."}</span></div>`;
|
|
2023
|
-
}
|
|
2024
|
-
const lifecycle = this.caps.manage ? `
|
|
2025
|
-
<p class="slm-eyebrow" style="margin-top:18px">Lifecycle</p>
|
|
2026
|
-
<div class="slm-ch-row2" style="margin-top:2px">
|
|
2027
|
-
<button type="button" class="slm-btn ghost" data-ch-act="rename">Rename</button>
|
|
2028
|
-
<button type="button" class="slm-btn ghost" data-ch-act="pause">${channel.state === "paused" ? "Resume" : "Pause"}</button>
|
|
2029
|
-
<button type="button" class="slm-btn ghost" data-ch-act="archive">Archive\u2026</button>
|
|
2030
|
-
</div>
|
|
2031
|
-
<p class="slm-note">Archive returns the allocation to a destination you choose. Nothing is ever deleted silently.</p>` : "";
|
|
2032
|
-
const access = this.caps.manage ? this.distributeHtml(channel) : "";
|
|
2033
|
-
return `
|
|
2034
|
-
${back}
|
|
2035
|
-
<p class="slm-eyebrow">Channel \xB7 ${esc(channel.name)}</p>
|
|
2036
|
-
<div class="slm-ch-list">${this.channelRowHtml(channel)}</div>
|
|
2037
|
-
${access}
|
|
2038
|
-
${lifecycle}`;
|
|
2039
|
-
}
|
|
2040
|
-
/**
|
|
2041
|
-
* "Distribute" — the ONE way this channel's seats reach a buyer.
|
|
2042
|
-
*
|
|
2043
|
-
* All four routes are here, and this is a real chooser again. It was cut down
|
|
2044
|
-
* to two actions in 0.42.0 for a good reason: `access_intent` was stored,
|
|
2045
|
-
* audited, and read by nothing, so "Keep as protected reserve" and "Sell
|
|
2046
|
-
* through your own staff" were labels an organizer could set and then wait
|
|
2047
|
-
* forever for something to happen. The server closed that hole on 2026-08-06 —
|
|
2048
|
-
* each declaration now opens exactly one route and REFUSES the other three —
|
|
2049
|
-
* so all four are honest choices and belong on the surface.
|
|
2050
|
-
*
|
|
2051
|
-
* None of the old copy came back with them. These sentences are written
|
|
2052
|
-
* against the enforcement matrix (`accessIntentDescription`), which is why
|
|
2053
|
-
* each one says what the route refuses as well as what it allows.
|
|
2054
|
-
*
|
|
2055
|
-
* The current route is stated, not merely styled: a chooser whose selection
|
|
2056
|
-
* you have to infer from a border is not a chooser. Its card carries a
|
|
2057
|
-
* "Current route" marker and drops its own select button, because pressing it
|
|
2058
|
-
* would do nothing.
|
|
2059
|
-
*/
|
|
2060
|
-
distributeHtml(channel) {
|
|
2061
|
-
const intent = channel.access?.intent ?? "none";
|
|
2062
|
-
const live = channel.access?.hasActiveGrants === true;
|
|
2063
|
-
const linksSupported = this.linksState !== "unsupported";
|
|
2064
|
-
const hasLiveLink = this.links.some(accessLinkIsLive);
|
|
2065
|
-
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.";
|
|
2066
|
-
const option = (route, action, cta, primary) => {
|
|
2067
|
-
const current = route === intent;
|
|
2068
|
-
return `<div class="slm-ch-dist${current ? " on" : ""}" data-ch-route="${esc(route)}">
|
|
2069
|
-
<b>${esc(accessIntentLabel(route))}${current ? '<span class="cur">Current route</span>' : ""}</b>
|
|
2070
|
-
<span class="why">${esc(accessIntentDescription(route))}</span>
|
|
2071
|
-
${current && !cta ? "" : `<button type="button" class="slm-btn${primary && !current ? "" : " ghost"}"
|
|
2072
|
-
data-ch-act="${esc(action)}">${esc(cta)}</button>`}
|
|
2073
|
-
</div>`;
|
|
2074
|
-
};
|
|
2075
|
-
return `
|
|
2076
|
-
<p class="slm-eyebrow" style="margin-top:14px">Distribute</p>
|
|
2077
|
-
<p class="slm-hint">${esc(state)}</p>
|
|
2078
|
-
${linksSupported ? option(
|
|
2079
|
-
"hosted_link",
|
|
2080
|
-
"link-create",
|
|
2081
|
-
hasLiveLink ? "Create another buyer link" : "Create buyer link",
|
|
2082
|
-
true
|
|
2083
|
-
) : ""}
|
|
2084
|
-
${option("server", "embed-code", intent === "server" ? "Get embed code" : "Use a website or app", false)}
|
|
2085
|
-
${option("internal", "route-internal", intent === "internal" ? "" : "Hand to your staff", false)}
|
|
2086
|
-
${option("none", "route-none", intent === "none" ? "" : "Keep as reserve", false)}
|
|
2087
|
-
${this.hostedLinksHtml()}
|
|
2088
|
-
${intent === "server" ? SERVER_INTEGRATION_HTML : ""}`;
|
|
2089
|
-
}
|
|
2090
|
-
// ---- buyer links ----------------------------------------------------------
|
|
2091
|
-
/**
|
|
2092
|
-
* Read the status projection for the open channel. Never paints — the caller
|
|
2093
|
-
* decides when the rail repaints, so a poll-driven reload does not fight a
|
|
2094
|
-
* user-driven one. A worker without M8 answers 404/405 and gets the honest
|
|
2095
|
-
* "needs a newer server" line rather than an error toast.
|
|
2096
|
-
*/
|
|
2097
|
-
async loadLinks(channelId) {
|
|
2098
|
-
if (!this.caps.view) return;
|
|
2099
|
-
const seq = ++this.linksSeq;
|
|
2100
|
-
const superseded = () => seq !== this.linksSeq || this.linksChannelId !== channelId;
|
|
2101
|
-
if (this.linksChannelId !== channelId) {
|
|
2102
|
-
this.links = [];
|
|
2103
|
-
this.linksChannelId = channelId;
|
|
2104
|
-
this.linksState = "loading";
|
|
2105
|
-
}
|
|
2106
|
-
try {
|
|
2107
|
-
const res = await this.host.api.accessLinks(this.host.eventKey, channelId);
|
|
2108
|
-
if (superseded()) return;
|
|
2109
|
-
this.links = res.links ?? [];
|
|
2110
|
-
this.linksState = "ready";
|
|
2111
|
-
} catch (err) {
|
|
2112
|
-
if (superseded()) return;
|
|
2113
|
-
const status = err instanceof ManageApiError ? err.status : 0;
|
|
2114
|
-
this.links = [];
|
|
2115
|
-
this.linksState = status === 404 || status === 405 || status === 501 ? "unsupported" : "error";
|
|
2116
|
-
if (this.linksState === "error") this.host.onError(err);
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
/**
|
|
2120
|
-
* The buyer-link status section of the detail panel.
|
|
2121
|
-
*
|
|
2122
|
-
* STATUS ONLY, by design (comp 06 `hosted`): label, state, expiry,
|
|
2123
|
-
* redemptions, seats per buyer, live sessions. There is no Copy control here
|
|
2124
|
-
* and no field to hang one on — the URL was shown once at creation and cannot
|
|
2125
|
-
* be produced again. Rotation is the recovery path, and it says so.
|
|
2126
|
-
*
|
|
2127
|
-
* Creating a link is the Distribute card's action, not this section's, so a
|
|
2128
|
-
* channel with no links renders nothing here rather than an empty heading and
|
|
2129
|
-
* a second button saying the same thing.
|
|
2130
|
-
*/
|
|
2131
|
-
hostedLinksHtml() {
|
|
2132
|
-
const eyebrow = `<p class="slm-eyebrow" style="margin-top:18px">Buyer links</p>`;
|
|
2133
|
-
if (this.linksState === "unsupported") {
|
|
2134
|
-
return `${eyebrow}<div class="slm-ch-alert warn" data-ch-state="links-unsupported"><span>\u2139</span>
|
|
2135
|
-
<span><b>Buyer links need a newer server.</b> Everything else on this channel works normally.</span></div>`;
|
|
2136
|
-
}
|
|
2137
|
-
if (this.linksState === "error") {
|
|
2138
|
-
return `${eyebrow}<div class="slm-ch-alert err" role="alert" data-ch-state="links-error"><span>\u26A0</span>
|
|
2139
|
-
<span><b>Couldn't load this channel's links.</b> This is a failed read, not an empty list \u2014
|
|
2140
|
-
any live link is still working.
|
|
2141
|
-
<button type="button" data-ch-act="link-reload">Try again</button></span></div>`;
|
|
2142
|
-
}
|
|
2143
|
-
if (this.linksState === "idle" || this.linksState === "loading" && !this.links.length) {
|
|
2144
|
-
return `${eyebrow}<div class="slm-ch-busy" role="status" data-ch-state="links-loading">Loading buyer links\u2026</div>`;
|
|
2145
|
-
}
|
|
2146
|
-
if (!this.links.length) return "";
|
|
2147
|
-
return `${eyebrow}
|
|
2148
|
-
${this.links.map((link) => this.linkCardHtml(link)).join("")}
|
|
2149
|
-
<p class="slm-note">A link is shown once, when you create it. SeatLayer keeps only a fingerprint of it, so it
|
|
2150
|
-
can never be shown again \u2014 if a link is lost, rotate it and send the fresh one.</p>`;
|
|
2151
|
-
}
|
|
2152
|
-
linkCardHtml(link) {
|
|
2153
|
-
const badge = accessLinkBadge(link);
|
|
2154
|
-
const used = link.maxRedemptions > 0 ? Math.min(100, Math.round(link.redemptions / link.maxRedemptions * 100)) : 0;
|
|
2155
|
-
const rows = accessLinkPolicyLines(link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
|
|
2156
|
-
<span class="v">${esc(row.v)}</span></div>`).join("");
|
|
2157
|
-
const sessions = link.activeSessions ? `<div class="slm-ch-lkrow"><span class="k">Buyers inside now</span>
|
|
2158
|
-
<span class="v">${link.activeSessions.toLocaleString()}</span></div>` : "";
|
|
2159
|
-
const lastUsed = link.lastRedeemedAt ? `<div class="slm-ch-lkrow"><span class="k">Last opened</span>
|
|
2160
|
-
<span class="v">${esc(new Date(link.lastRedeemedAt).toLocaleString())}</span></div>` : "";
|
|
2161
|
-
const actions = this.caps.manage && accessLinkIsLive(link) ? `<div class="slm-ch-row2">
|
|
2162
|
-
<button type="button" class="slm-btn ghost" data-ch-rotate="${esc(link.id)}">Rotate</button>
|
|
2163
|
-
<button type="button" class="slm-btn ghost" data-ch-revoke="${esc(link.id)}">Revoke</button>
|
|
2164
|
-
</div>` : "";
|
|
2165
|
-
return `<div class="slm-ch-link">
|
|
2166
|
-
<span class="lk-head">
|
|
2167
|
-
<span class="lk-name">${esc(link.label || "Buyer link")}</span>
|
|
2168
|
-
<span class="slm-ch-badge ${badge.kind}">${esc(badge.text)}</span>
|
|
2169
|
-
</span>
|
|
2170
|
-
<div class="slm-ch-meter" role="img"
|
|
2171
|
-
aria-label="${link.redemptions.toLocaleString()} of ${link.maxRedemptions.toLocaleString()} redemptions used">
|
|
2172
|
-
<i style="width:${used}%"></i></div>
|
|
2173
|
-
${rows}${sessions}${lastUsed}
|
|
2174
|
-
<div class="slm-ch-lkrow"><span class="k">The URL</span>
|
|
2175
|
-
<span class="v">Revealed once at creation \u2014 not recoverable</span></div>
|
|
2176
|
-
${actions}
|
|
2177
|
-
</div>`;
|
|
2178
|
-
}
|
|
2179
|
-
previewRailHtml() {
|
|
2180
|
-
const audienceOptions = [
|
|
2181
|
-
{ id: PUBLIC_CHANNEL_ID, name: this.list?.publicSale.name ?? PUBLIC_CHANNEL_NAME },
|
|
2182
|
-
...(this.list?.channels ?? []).filter((channel) => channel.state !== "archived").map((channel) => ({ id: channel.id, name: channel.name }))
|
|
2183
|
-
];
|
|
2184
|
-
const current = this.previewAudience[0] ?? PUBLIC_CHANNEL_ID;
|
|
2185
|
-
const options = audienceOptions.map((entry) => `<option value="${esc(entry.id)}"${entry.id === current ? " selected" : ""}>${esc(entry.name)}</option>`).join("");
|
|
2186
|
-
const unsupported = this.previewState === "unsupported" ? `<div class="slm-ch-alert warn" data-ch-state="preview-unsupported"><span>\u2139</span>
|
|
2187
|
-
<span><b>Preview needs a newer server.</b> Allocation management works normally;
|
|
2188
|
-
the buyer-view simulation will appear once this event's API is updated.</span></div>` : "";
|
|
2189
|
-
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>` : "";
|
|
2190
|
-
const failed = this.previewState === "error" ? `<div class="slm-ch-alert err" role="alert" data-ch-state="preview-error"><span>\u26A0</span>
|
|
2191
|
-
<span><b>Couldn't load the buyer preview.</b> Nothing is wrong with this audience's seats \u2014
|
|
2192
|
-
the simulation itself failed to load.
|
|
2193
|
-
<button type="button" data-ch-act="preview-retry">Try again</button></span></div>` : "";
|
|
2194
|
-
const unavailable = this.previewProjection?.available === false ? `<div class="slm-ch-alert warn" role="status"><span>\u23F8</span>
|
|
2195
|
-
<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")}.
|
|
2196
|
-
A buyer arriving with this access sees this message, not these seats.</span></div>` : "";
|
|
2197
|
-
const eligibleSeats = this.previewProjection?.counts?.eligible ?? this.previewProjection?.eligible?.length;
|
|
2198
|
-
const summary = eligibleSeats != null && this.previewProjection?.available !== false ? `<div class="slm-ch-alert info"><span>\u2713</span><span><b>${eligibleSeats.toLocaleString()} ${eligibleSeats === 1 ? "seat is" : "seats are"} available now.</b>
|
|
2199
|
-
This is the exact buyer-visible allocation.${this.previewProjection?.includePublic === false ? " Public sale seats are <b>not</b> included in this access." : ""}</span></div>` : "";
|
|
2200
|
-
const includePublic = isPublicChannelId(current) ? "" : `
|
|
2201
|
-
<label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:10px 0">
|
|
2202
|
-
<input type="checkbox" data-ch-includepublic ${this.previewIncludePublic ? "checked" : ""} />
|
|
2203
|
-
Also include Public sale seats in this grant
|
|
2204
|
-
</label>`;
|
|
2205
|
-
return `
|
|
2206
|
-
<p class="slm-eyebrow">Preview buyer access</p>
|
|
2207
|
-
<div class="slm-field">
|
|
2208
|
-
<label for="slm-ch-audience">Audience</label>
|
|
2209
|
-
<select class="slm-select" id="slm-ch-audience" data-ch-audience>${options}</select>
|
|
2210
|
-
</div>
|
|
2211
|
-
${includePublic}
|
|
2212
|
-
<p class="slm-hint">This is the same projection the buyer SDK receives for this audience \u2014 not a local
|
|
2213
|
-
approximation. It is read-only: clicks open seat details, and no holds are created.</p>
|
|
2214
|
-
${busy}${failed}${unsupported}${unavailable}
|
|
2215
|
-
<div class="slm-ch-legend">
|
|
2216
|
-
<div class="r"><span class="sw" style="background:#6e7bff"></span> Eligible & free \u2014 buyable by this audience</div>
|
|
2217
|
-
<div class="r"><span class="sw" style="background:#3a4051"></span> Unavailable to this audience (one neutral state)</div>
|
|
2218
|
-
<div class="r"><span class="sw" style="background:#22a06b"></span> Sold \u2014 same as any buyer sees</div>
|
|
2219
|
-
</div>
|
|
2220
|
-
${summary}`;
|
|
2221
|
-
}
|
|
2222
|
-
paintSelection() {
|
|
2223
|
-
if (this.caps.manage && this.host.selectionLabels().length) this.assignOpen = true;
|
|
2224
|
-
if (this.view === "inspect" && !this.detailChannelId) this.paintRail();
|
|
2225
|
-
}
|
|
2226
|
-
wireRail() {
|
|
2227
|
-
const rail = this.host.rail;
|
|
2228
|
-
rail.querySelectorAll("[data-ch-view]").forEach((button) => {
|
|
2229
|
-
button.addEventListener("click", () => this.setView(button.dataset.chView));
|
|
2230
|
-
});
|
|
2231
|
-
rail.querySelectorAll("[data-ch-map]").forEach((button) => {
|
|
2232
|
-
button.addEventListener("click", () => {
|
|
2233
|
-
this.mapIntent = button.dataset.chMap === "assign" ? "assign" : "pan";
|
|
2234
|
-
if (this.mapIntent === "assign") this.assignOpen = true;
|
|
2235
|
-
this.paintRail();
|
|
2236
|
-
this.onInteractionChange?.();
|
|
2237
|
-
});
|
|
2238
|
-
});
|
|
2239
|
-
rail.querySelectorAll("[data-ch-open]").forEach((row) => {
|
|
2240
|
-
const open = () => this.openChannel(row.dataset.chOpen);
|
|
2241
|
-
row.addEventListener("click", open);
|
|
2242
|
-
row.addEventListener("keydown", (event) => {
|
|
2243
|
-
if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
|
|
2244
|
-
event.preventDefault();
|
|
2245
|
-
open();
|
|
2246
|
-
});
|
|
2247
|
-
});
|
|
2248
|
-
rail.querySelectorAll("[data-ch-menu]").forEach((button) => {
|
|
2249
|
-
button.addEventListener("click", (event) => {
|
|
2250
|
-
event.stopPropagation();
|
|
2251
|
-
this.openDialog({ kind: "menu", channelId: button.dataset.chMenu });
|
|
2252
|
-
});
|
|
2253
|
-
button.addEventListener("keydown", (event) => {
|
|
2254
|
-
event.stopPropagation();
|
|
2255
|
-
});
|
|
2256
|
-
});
|
|
2257
|
-
rail.querySelectorAll("[data-ch-rotate]").forEach((button) => {
|
|
2258
|
-
button.addEventListener("click", () => this.openDialog({
|
|
2259
|
-
kind: "linkRotate",
|
|
2260
|
-
channelId: this.detailChannelId,
|
|
2261
|
-
linkId: button.dataset.chRotate
|
|
2262
|
-
}));
|
|
2263
|
-
});
|
|
2264
|
-
rail.querySelectorAll("[data-ch-revoke]").forEach((button) => {
|
|
2265
|
-
button.addEventListener("click", () => this.openDialog({
|
|
2266
|
-
kind: "linkRevoke",
|
|
2267
|
-
channelId: this.detailChannelId,
|
|
2268
|
-
linkId: button.dataset.chRevoke
|
|
2269
|
-
}));
|
|
2270
|
-
});
|
|
2271
|
-
const target = rail.querySelector("[data-ch-target]");
|
|
2272
|
-
target?.addEventListener("change", () => {
|
|
2273
|
-
this.targetChannelId = target.value;
|
|
2274
|
-
this.conflict = false;
|
|
2275
|
-
this.paintRail();
|
|
2276
|
-
});
|
|
2277
|
-
const audience = rail.querySelector("[data-ch-audience]");
|
|
2278
|
-
audience?.addEventListener("change", () => {
|
|
2279
|
-
this.previewAudience = [audience.value];
|
|
2280
|
-
void this.loadPreview();
|
|
2281
|
-
});
|
|
2282
|
-
const includePublic = rail.querySelector("[data-ch-includepublic]");
|
|
2283
|
-
includePublic?.addEventListener("change", () => {
|
|
2284
|
-
this.previewIncludePublic = includePublic.checked;
|
|
2285
|
-
void this.loadPreview();
|
|
2286
|
-
});
|
|
2287
|
-
const grab = rail.querySelector(".slm-ch-grab");
|
|
2288
|
-
grab?.addEventListener("click", () => this.cycleDetent());
|
|
2289
|
-
rail.querySelectorAll("[data-ch-act]").forEach((button) => {
|
|
2290
|
-
button.addEventListener("click", () => this.railAction(button.dataset.chAct));
|
|
2291
|
-
});
|
|
2292
|
-
}
|
|
2293
|
-
/** Open a channel's detail panel and start its buyer-link read. */
|
|
2294
|
-
openChannel(channelId) {
|
|
2295
|
-
this.detailChannelId = channelId;
|
|
2296
|
-
this.paintRail();
|
|
2297
|
-
void this.loadLinks(channelId).then(() => this.paintRail());
|
|
2298
|
-
}
|
|
2299
|
-
railAction(action) {
|
|
2300
|
-
switch (action) {
|
|
2301
|
-
case "sections":
|
|
2302
|
-
this.focusedSectionId = null;
|
|
2303
|
-
this.host.showSectionOverview();
|
|
2304
|
-
this.paintRail();
|
|
2305
|
-
break;
|
|
2306
|
-
case "create":
|
|
2307
|
-
this.openDialog({ kind: "create" });
|
|
2308
|
-
break;
|
|
2309
|
-
case "review":
|
|
2310
|
-
this.openDialog({ kind: "review" });
|
|
2311
|
-
break;
|
|
2312
|
-
case "rename":
|
|
2313
|
-
this.openDialog({ kind: "rename", channelId: this.detailChannelId });
|
|
2314
|
-
break;
|
|
2315
|
-
case "archive":
|
|
2316
|
-
this.openDialog({ kind: "archive", channelId: this.detailChannelId });
|
|
2317
|
-
break;
|
|
2318
|
-
case "seatlist":
|
|
2319
|
-
this.openDialog({ kind: "seatlist" });
|
|
2320
|
-
break;
|
|
2321
|
-
case "pause":
|
|
2322
|
-
void this.togglePause();
|
|
2323
|
-
break;
|
|
2324
|
-
case "discard":
|
|
2325
|
-
this.host.clearSelection();
|
|
2326
|
-
break;
|
|
2327
|
-
case "back":
|
|
2328
|
-
this.detailChannelId = null;
|
|
2329
|
-
this.linksChannelId = null;
|
|
2330
|
-
this.links = [];
|
|
2331
|
-
this.linksState = "idle";
|
|
2332
|
-
this.paintRail();
|
|
2333
|
-
break;
|
|
2334
|
-
// Choosing the buyer-link route IS creating the first link — the dialog
|
|
2335
|
-
// declares the route on submit (see `createLink`), so this stays one
|
|
2336
|
-
// gesture whether the channel is already on `hosted_link` or not.
|
|
2337
|
-
case "link-create":
|
|
2338
|
-
this.openDialog({ kind: "linkCreate", channelId: this.detailChannelId });
|
|
2339
|
-
break;
|
|
2340
|
-
// The one thing this screen can actually do for a website integration is
|
|
2341
|
-
// record that the channel is meant for one — and since 2026-08-06 that
|
|
2342
|
-
// record is what AUTHORIZES the integration to mint buyer sessions at all,
|
|
2343
|
-
// so it is the substantive half of the job, not a flag.
|
|
2344
|
-
case "embed-code":
|
|
2345
|
-
void this.chooseWebsiteIntegration();
|
|
2346
|
-
break;
|
|
2347
|
-
case "route-internal":
|
|
2348
|
-
void this.setAccessIntent("internal");
|
|
2349
|
-
break;
|
|
2350
|
-
case "route-none":
|
|
2351
|
-
void this.setAccessIntent("none");
|
|
2352
|
-
break;
|
|
2353
|
-
case "link-reload":
|
|
2354
|
-
if (this.detailChannelId) void this.reloadLinks();
|
|
2355
|
-
break;
|
|
2356
|
-
case "retry":
|
|
2357
|
-
void this.refresh();
|
|
2358
|
-
break;
|
|
2359
|
-
case "toggle-archived":
|
|
2360
|
-
this.showArchived = !this.showArchived;
|
|
2361
|
-
void this.refresh();
|
|
2362
|
-
break;
|
|
2363
|
-
case "refresh-review":
|
|
2364
|
-
this.conflict = false;
|
|
2365
|
-
void this.refresh().then(() => this.openDialog({ kind: "review" }));
|
|
2366
|
-
break;
|
|
2367
|
-
case "pick-sections":
|
|
2368
|
-
this.openDialog({ kind: "scope", scope: "sections" });
|
|
2369
|
-
break;
|
|
2370
|
-
case "pick-rows":
|
|
2371
|
-
this.openDialog({ kind: "scope", scope: "rows" });
|
|
2372
|
-
break;
|
|
2373
|
-
case "drag-select":
|
|
2374
|
-
this.mapIntent = "assign";
|
|
2375
|
-
this.paintRail();
|
|
2376
|
-
this.onInteractionChange?.();
|
|
2377
|
-
break;
|
|
2378
|
-
case "pick-category":
|
|
2379
|
-
this.pickCategory();
|
|
2380
|
-
break;
|
|
2381
|
-
case "assign-open":
|
|
2382
|
-
this.assignOpen = true;
|
|
2383
|
-
this.paintRail();
|
|
2384
|
-
break;
|
|
2385
|
-
// Collapsing the tools must also disarm the marquee. Leaving it armed
|
|
2386
|
-
// would hide a mode the map is still in — the organizer would drag to pan
|
|
2387
|
-
// and select seats instead, with no control on screen saying why.
|
|
2388
|
-
case "assign-close":
|
|
2389
|
-
this.assignOpen = false;
|
|
2390
|
-
this.mapIntent = "pan";
|
|
2391
|
-
this.paintRail();
|
|
2392
|
-
this.onInteractionChange?.();
|
|
2393
|
-
break;
|
|
2394
|
-
case "preview-retry":
|
|
2395
|
-
void this.loadPreview();
|
|
2396
|
-
break;
|
|
2397
|
-
default:
|
|
2398
|
-
break;
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
// ---- selection helpers ----------------------------------------------------
|
|
2402
|
-
/** Derived once per mode entry — see `assignmentRowsCache`. */
|
|
2403
|
-
assignmentRows() {
|
|
2404
|
-
if (!this.assignmentRowsCache) this.assignmentRowsCache = this.host.rows();
|
|
2405
|
-
return this.assignmentRowsCache;
|
|
2406
|
-
}
|
|
2407
|
-
pickCategory() {
|
|
2408
|
-
const categories = this.host.categories();
|
|
2409
|
-
if (!categories.length) return;
|
|
2410
|
-
this.promptChoice("Select a whole category", categories.map((c) => ({ value: c.key, label: c.label })), (value) => {
|
|
2411
|
-
this.host.selectByLabels(this.host.labelsInCategory(value));
|
|
2412
|
-
});
|
|
2413
|
-
}
|
|
2414
|
-
/** A tiny modal chooser reusing the dialog primitive (focus trap + Escape). */
|
|
2415
|
-
promptChoice(title, options, onPick) {
|
|
2416
|
-
this.renderScrim(`
|
|
2417
|
-
<h3 id="slm-ch-dlg-title">${esc(title)}</h3>
|
|
2418
|
-
<div class="slm-field">
|
|
2419
|
-
<label for="slm-ch-choice">Choose one</label>
|
|
2420
|
-
<select class="slm-select" id="slm-ch-choice">
|
|
2421
|
-
${options.map((option) => `<option value="${esc(option.value)}">${esc(option.label)}</option>`).join("")}
|
|
2422
|
-
</select>
|
|
2423
|
-
</div>
|
|
2424
|
-
<div class="foot">
|
|
2425
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
2426
|
-
<button type="button" class="slm-btn" data-ch-confirm>Select</button>
|
|
2427
|
-
</div>`, (root) => {
|
|
2428
|
-
root.querySelector("[data-ch-confirm]")?.addEventListener("click", () => {
|
|
2429
|
-
const select = root.querySelector("#slm-ch-choice");
|
|
2430
|
-
const value = select?.value;
|
|
2431
|
-
this.closeDialog();
|
|
2432
|
-
if (value) onPick(value);
|
|
2433
|
-
});
|
|
2434
|
-
});
|
|
2435
|
-
}
|
|
2436
|
-
/**
|
|
2437
|
-
* Add several whole sections, or several whole rows, in one operation.
|
|
2438
|
-
*
|
|
2439
|
-
* ADDITIVE by contract: the chooser starts from the labels already selected
|
|
2440
|
-
* and only ever grows that set, so opening it never destroys a hard-won
|
|
2441
|
-
* marquee or seat-list selection. The confirm button always states the net
|
|
2442
|
-
* number of seats it will add, and refuses to exceed `MAX_ASSIGNMENT_UNITS`.
|
|
2443
|
-
*
|
|
2444
|
-
* Rows get the richer variant. A large venue has thousands of them, so they
|
|
2445
|
-
* arrive collapsed under their section, with a section-level tri-state
|
|
2446
|
-
* checkbox, a search across every row, and a render cap — the flat list used
|
|
2447
|
-
* for sections would be an unusable wall of buttons.
|
|
2448
|
-
*/
|
|
2449
|
-
renderScopeDialog(state) {
|
|
2450
|
-
const scope = state.scope === "rows" ? "rows" : "sections";
|
|
2451
|
-
const options = scope === "sections" ? this.host.sections().map((section) => ({
|
|
2452
|
-
id: section.id,
|
|
2453
|
-
label: section.label,
|
|
2454
|
-
group: "Sections",
|
|
2455
|
-
labels: this.host.labelsInSection(section.id)
|
|
2456
|
-
})) : this.assignmentRows().map((row) => ({
|
|
2457
|
-
id: row.id,
|
|
2458
|
-
label: row.label,
|
|
2459
|
-
group: row.sectionLabel,
|
|
2460
|
-
labels: row.labels
|
|
2461
|
-
}));
|
|
2462
|
-
const available = options.filter((option) => option.labels.length > 0);
|
|
2463
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
2464
|
-
for (const option of available) {
|
|
2465
|
-
const group = grouped.get(option.group) ?? [];
|
|
2466
|
-
group.push(option);
|
|
2467
|
-
grouped.set(option.group, group);
|
|
2468
|
-
}
|
|
2469
|
-
if (scope === "rows") {
|
|
2470
|
-
const groups = [...grouped.entries()];
|
|
2471
|
-
this.renderScrim(`
|
|
2472
|
-
<h3 id="slm-ch-dlg-title">Add multiple rows</h3>
|
|
2473
|
-
<p class="sub">Sections stay collapsed for speed. Select a whole section, expand only the rows you need, or search across every row.</p>
|
|
2474
|
-
<div class="slm-ch-scopebar">
|
|
2475
|
-
<label>Find section or row<input type="search" data-ch-scope-search placeholder="e.g. Orchestra or AA"></label>
|
|
2476
|
-
<span class="slm-ch-scopesummary" data-ch-scope-summary aria-live="polite">0 rows selected</span>
|
|
2477
|
-
</div>
|
|
2478
|
-
<p class="slm-ch-scopehint" data-ch-scope-filter>Showing section groups. Expand one or search to see rows.</p>
|
|
2479
|
-
<div class="slm-ch-seatlist" data-ch-scope-list></div>
|
|
2480
|
-
<p class="slm-ch-err" data-ch-error hidden></p>
|
|
2481
|
-
<div class="foot">
|
|
2482
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
2483
|
-
<button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
|
|
2484
|
-
</div>`, (dialog) => {
|
|
2485
|
-
const byId = new Map(available.map((option) => [option.id, option]));
|
|
2486
|
-
const picked = /* @__PURE__ */ new Set();
|
|
2487
|
-
const expanded = /* @__PURE__ */ new Set();
|
|
2488
|
-
const current = new Set(this.host.selectionLabels());
|
|
2489
|
-
const confirm = dialog.querySelector("[data-ch-add-scope]");
|
|
2490
|
-
const error = dialog.querySelector("[data-ch-error]");
|
|
2491
|
-
const list = dialog.querySelector("[data-ch-scope-list]");
|
|
2492
|
-
const search = dialog.querySelector("[data-ch-scope-search]");
|
|
2493
|
-
const summary = dialog.querySelector("[data-ch-scope-summary]");
|
|
2494
|
-
const filterHint = dialog.querySelector("[data-ch-scope-filter]");
|
|
2495
|
-
const renderLimit = 200;
|
|
2496
|
-
let query = "";
|
|
2497
|
-
const selectedLabels = () => {
|
|
2498
|
-
const labels = new Set(current);
|
|
2499
|
-
for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
|
|
2500
|
-
return labels;
|
|
2501
|
-
};
|
|
2502
|
-
const update = () => {
|
|
2503
|
-
const labels = selectedLabels();
|
|
2504
|
-
const added = labels.size - current.size;
|
|
2505
|
-
const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
|
|
2506
|
-
confirm.disabled = added === 0 || tooLarge;
|
|
2507
|
-
confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
|
|
2508
|
-
error.hidden = !tooLarge;
|
|
2509
|
-
error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
|
|
2510
|
-
summary.textContent = `${picked.size.toLocaleString()} row${picked.size === 1 ? "" : "s"} \xB7 ${added.toLocaleString()} seat${added === 1 ? "" : "s"} added`;
|
|
2511
|
-
};
|
|
2512
|
-
const renderList = () => {
|
|
2513
|
-
const blocks = [];
|
|
2514
|
-
let totalMatches = 0;
|
|
2515
|
-
let rendered = 0;
|
|
2516
|
-
groups.forEach(([group, items], groupIndex) => {
|
|
2517
|
-
const normalizedGroup = group.toLocaleLowerCase();
|
|
2518
|
-
const matches = query ? items.filter((item) => `${normalizedGroup} ${item.label.toLocaleLowerCase()}`.includes(query)) : items;
|
|
2519
|
-
if (query && !matches.length) return;
|
|
2520
|
-
totalMatches += matches.length;
|
|
2521
|
-
const allPicked = items.every((item) => picked.has(item.id));
|
|
2522
|
-
const somePicked = !allPicked && items.some((item) => picked.has(item.id));
|
|
2523
|
-
const open = Boolean(query) || expanded.has(groupIndex);
|
|
2524
|
-
const room = Math.max(0, renderLimit - rendered);
|
|
2525
|
-
const visible = open ? matches.slice(0, room) : [];
|
|
2526
|
-
rendered += visible.length;
|
|
2527
|
-
const seatCount = items.reduce((sum, item) => sum + item.labels.length, 0);
|
|
2528
|
-
blocks.push(`<div class="slm-ch-scopegroup">
|
|
2529
|
-
<button type="button" class="slm-ch-groupcheck" role="checkbox" aria-checked="${allPicked ? "true" : somePicked ? "mixed" : "false"}"
|
|
2530
|
-
aria-label="Select all ${items.length.toLocaleString()} rows in ${esc(group)}" data-ch-scope-group="${groupIndex}">
|
|
2531
|
-
<span class="box" aria-hidden="true">\u2713</span><span>${esc(group)}</span>
|
|
2532
|
-
<span class="meta">${items.length.toLocaleString()} rows \xB7 ${seatCount.toLocaleString()} seats</span>
|
|
2533
|
-
</button>
|
|
2534
|
-
<button type="button" class="slm-ch-grouptoggle" aria-expanded="${open}" aria-label="${open ? "Hide" : "Show"} rows in ${esc(group)}"
|
|
2535
|
-
data-ch-scope-toggle="${groupIndex}">${open ? "Hide" : "Show"}</button>
|
|
2536
|
-
</div>`);
|
|
2537
|
-
blocks.push(...visible.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
|
|
2538
|
-
aria-checked="${picked.has(option.id)}" data-ch-scope-id="${esc(option.id)}">
|
|
2539
|
-
<span class="box" aria-hidden="true">\u2713</span><span>${esc(option.label)}</span>
|
|
2540
|
-
<span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
|
|
2541
|
-
</button>`));
|
|
2542
|
-
if (open && matches.length > visible.length) {
|
|
2543
|
-
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>`);
|
|
2544
|
-
}
|
|
2545
|
-
});
|
|
2546
|
-
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.
|
|
2547
|
-
Use Drag box or the seat list instead.</div>`);
|
|
2548
|
-
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.";
|
|
2549
|
-
list.querySelectorAll("[data-ch-scope-group]").forEach((button) => button.addEventListener("click", () => {
|
|
2550
|
-
const items = groups[Number(button.dataset.chScopeGroup)]?.[1] ?? [];
|
|
2551
|
-
const remove = items.every((item) => picked.has(item.id));
|
|
2552
|
-
for (const item of items) remove ? picked.delete(item.id) : picked.add(item.id);
|
|
2553
|
-
renderList();
|
|
2554
|
-
update();
|
|
2555
|
-
}));
|
|
2556
|
-
list.querySelectorAll("[data-ch-scope-toggle]").forEach((button) => button.addEventListener("click", () => {
|
|
2557
|
-
const index = Number(button.dataset.chScopeToggle);
|
|
2558
|
-
if (expanded.has(index)) expanded.delete(index);
|
|
2559
|
-
else expanded.add(index);
|
|
2560
|
-
renderList();
|
|
2561
|
-
}));
|
|
2562
|
-
list.querySelectorAll("[data-ch-scope-id]").forEach((button) => button.addEventListener("click", () => {
|
|
2563
|
-
const id = button.dataset.chScopeId;
|
|
2564
|
-
if (picked.has(id)) picked.delete(id);
|
|
2565
|
-
else picked.add(id);
|
|
2566
|
-
renderList();
|
|
2567
|
-
update();
|
|
2568
|
-
}));
|
|
2569
|
-
};
|
|
2570
|
-
search.addEventListener("input", () => {
|
|
2571
|
-
query = search.value.trim().toLocaleLowerCase();
|
|
2572
|
-
renderList();
|
|
2573
|
-
update();
|
|
2574
|
-
});
|
|
2575
|
-
confirm.addEventListener("click", () => {
|
|
2576
|
-
const labels = [...selectedLabels()];
|
|
2577
|
-
if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
|
|
2578
|
-
this.closeDialog();
|
|
2579
|
-
this.host.selectByLabels(labels);
|
|
2580
|
-
});
|
|
2581
|
-
renderList();
|
|
2582
|
-
update();
|
|
2583
|
-
});
|
|
2584
|
-
return;
|
|
2585
|
-
}
|
|
2586
|
-
const body = [...grouped.entries()].map(([group, items]) => `
|
|
2587
|
-
<div class="slm-ch-seatgroup"><span>${esc(group)}</span></div>
|
|
2588
|
-
${items.map((option) => `<button type="button" class="slm-ch-seatitem" role="checkbox"
|
|
2589
|
-
aria-checked="false" data-ch-scope-id="${esc(option.id)}">
|
|
2590
|
-
<span class="box" aria-hidden="true">\u2713</span>
|
|
2591
|
-
<span>${esc(option.label)}</span>
|
|
2592
|
-
<span class="meta">${option.labels.length.toLocaleString()} seat${option.labels.length === 1 ? "" : "s"}</span>
|
|
2593
|
-
</button>`).join("")}`).join("");
|
|
2594
|
-
this.renderScrim(`
|
|
2595
|
-
<h3 id="slm-ch-dlg-title">Add whole sections</h3>
|
|
2596
|
-
<p class="sub">Choose one or more. They are added to the seats already selected on the map.</p>
|
|
2597
|
-
<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.
|
|
2598
|
-
Use Drag box or the seat list instead.</div>`}</div>
|
|
2599
|
-
<p class="slm-ch-err" data-ch-error hidden></p>
|
|
2600
|
-
<div class="foot">
|
|
2601
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
2602
|
-
<button type="button" class="slm-btn" data-ch-add-scope disabled>Add seats</button>
|
|
2603
|
-
</div>`, (dialog) => {
|
|
2604
|
-
const byId = new Map(available.map((option) => [option.id, option]));
|
|
2605
|
-
const picked = /* @__PURE__ */ new Set();
|
|
2606
|
-
const current = new Set(this.host.selectionLabels());
|
|
2607
|
-
const confirm = dialog.querySelector("[data-ch-add-scope]");
|
|
2608
|
-
const error = dialog.querySelector("[data-ch-error]");
|
|
2609
|
-
const selectedLabels = () => {
|
|
2610
|
-
const labels = new Set(current);
|
|
2611
|
-
for (const id of picked) for (const label of byId.get(id)?.labels ?? []) labels.add(label);
|
|
2612
|
-
return labels;
|
|
2613
|
-
};
|
|
2614
|
-
const update = () => {
|
|
2615
|
-
const labels = selectedLabels();
|
|
2616
|
-
const added = labels.size - current.size;
|
|
2617
|
-
const tooLarge = labels.size > MAX_ASSIGNMENT_UNITS;
|
|
2618
|
-
confirm.disabled = added === 0 || tooLarge;
|
|
2619
|
-
confirm.textContent = tooLarge ? `Maximum ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats` : `Add ${added.toLocaleString()} seat${added === 1 ? "" : "s"}`;
|
|
2620
|
-
error.hidden = !tooLarge;
|
|
2621
|
-
error.textContent = tooLarge ? `That would make ${labels.size.toLocaleString()} selected seats. Choose fewer rows or sections.` : "";
|
|
2622
|
-
};
|
|
2623
|
-
dialog.querySelectorAll("[data-ch-scope-id]").forEach((button) => {
|
|
2624
|
-
button.addEventListener("click", () => {
|
|
2625
|
-
const id = button.dataset.chScopeId;
|
|
2626
|
-
if (picked.has(id)) picked.delete(id);
|
|
2627
|
-
else picked.add(id);
|
|
2628
|
-
button.setAttribute("aria-checked", String(picked.has(id)));
|
|
2629
|
-
update();
|
|
2630
|
-
});
|
|
2631
|
-
});
|
|
2632
|
-
confirm.addEventListener("click", () => {
|
|
2633
|
-
const labels = [...selectedLabels()];
|
|
2634
|
-
if (!picked.size || labels.length > MAX_ASSIGNMENT_UNITS) return;
|
|
2635
|
-
this.closeDialog();
|
|
2636
|
-
this.host.selectByLabels(labels);
|
|
2637
|
-
});
|
|
2638
|
-
update();
|
|
2639
|
-
});
|
|
2640
|
-
}
|
|
2641
|
-
// ---- dialogs --------------------------------------------------------------
|
|
2642
|
-
openDialog(state) {
|
|
2643
|
-
this.dialog = state;
|
|
2644
|
-
this.renderDialog();
|
|
2645
|
-
}
|
|
2646
|
-
renderDialog() {
|
|
2647
|
-
const state = this.dialog;
|
|
2648
|
-
if (!state) return;
|
|
2649
|
-
if (state.kind === "create") this.renderCreateDialog(state);
|
|
2650
|
-
else if (state.kind === "review") this.renderReviewDialog(state);
|
|
2651
|
-
else if (state.kind === "scope") this.renderScopeDialog(state);
|
|
2652
|
-
else if (state.kind === "archive") this.renderArchiveDialog(state);
|
|
2653
|
-
else if (state.kind === "rename") this.renderRenameDialog(state);
|
|
2654
|
-
else if (state.kind === "seatlist") this.renderSeatListDialog();
|
|
2655
|
-
else if (state.kind === "menu") this.renderMenuDialog(state);
|
|
2656
|
-
else if (state.kind === "linkCreate") this.renderLinkCreateDialog(state);
|
|
2657
|
-
else if (state.kind === "linkRotate") this.renderLinkRotateDialog(state);
|
|
2658
|
-
else if (state.kind === "linkRevoke") this.renderLinkRevokeDialog(state);
|
|
2659
|
-
else if (state.kind === "intentSwitch") this.renderIntentSwitchDialog(state);
|
|
2660
|
-
}
|
|
2661
|
-
/**
|
|
2662
|
-
* `channel_intent_switch_blocked` — buyers are inside the route being left.
|
|
2663
|
-
*
|
|
2664
|
-
* The same review-then-acknowledge shape as the archive and chart-drop guards,
|
|
2665
|
-
* because it is the same kind of decision: the server refuses once, names
|
|
2666
|
-
* exactly what is at stake, and only a deliberate second press goes through.
|
|
2667
|
-
* What acknowledging does is spelled out per consequence — links close now,
|
|
2668
|
-
* checkouts already running survive and drain — rather than hidden behind a
|
|
2669
|
-
* word like "force".
|
|
2670
|
-
*/
|
|
2671
|
-
renderIntentSwitchDialog(state) {
|
|
2672
|
-
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
2673
|
-
const to = state.intentTo;
|
|
2674
|
-
if (!channel || !to || !this.caps.manage) {
|
|
2675
|
-
this.closeDialog();
|
|
2676
|
-
return;
|
|
2677
|
-
}
|
|
2678
|
-
const { headline, consequences } = intentSwitchBlockedCopy(state.switchBlocked);
|
|
2679
|
-
this.renderScrim(`
|
|
2680
|
-
<h3 id="slm-ch-dlg-title">Change how ${esc(channel.name)} reaches buyers?</h3>
|
|
2681
|
-
<p class="sub">${esc(headline)}</p>
|
|
2682
|
-
<div class="slm-ch-alert warn" role="alert"><span>\u26A0</span><span>
|
|
2683
|
-
${consequences.map((line) => `<span style="display:block;margin-bottom:6px">${esc(line)}</span>`).join("")}
|
|
2684
|
-
</span></div>
|
|
2685
|
-
${state.pendingLink ? `<p class="slm-note">Your new buyer link is created as soon as
|
|
2686
|
-
the route changes \u2014 you will not have to fill the form in again.</p>` : ""}
|
|
2687
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
2688
|
-
<div class="foot">
|
|
2689
|
-
<button type="button" class="quiet" data-ch-close>Leave it as it is</button>
|
|
2690
|
-
<button type="button" class="slm-btn" data-ch-intent-ack${state.busy ? " disabled" : ""}>
|
|
2691
|
-
${state.busy ? "Changing\u2026" : `Change to "${esc(accessIntentLabel(to))}"`}</button>
|
|
2692
|
-
</div>`, (dialog) => {
|
|
2693
|
-
dialog.querySelector("[data-ch-intent-ack]")?.addEventListener("click", () => {
|
|
2694
|
-
void this.acknowledgeIntentSwitch(to, state.pendingLink ?? null);
|
|
2695
|
-
});
|
|
2696
|
-
});
|
|
2697
|
-
}
|
|
2698
|
-
/**
|
|
2699
|
-
* The acknowledged retry. The declare and the create stay one gesture across
|
|
2700
|
-
* the sheet: if this switch was the first half of a declare-then-create, the
|
|
2701
|
-
* held form finishes on the far side of the acknowledgement.
|
|
2702
|
-
*/
|
|
2703
|
-
async acknowledgeIntentSwitch(intent, pendingLink) {
|
|
2704
|
-
const channelId = this.detailChannelId;
|
|
2705
|
-
if (!channelId) {
|
|
2706
|
-
this.closeDialog();
|
|
2707
|
-
return;
|
|
2708
|
-
}
|
|
2709
|
-
if (this.dialog) {
|
|
2710
|
-
this.dialog.busy = true;
|
|
2711
|
-
this.renderDialog();
|
|
2712
|
-
}
|
|
2713
|
-
const ok = await this.setAccessIntent(intent, { acknowledgeLiveAccess: true });
|
|
2714
|
-
if (!ok) {
|
|
2715
|
-
if (this.dialog?.kind === "intentSwitch") {
|
|
2716
|
-
this.dialog.busy = false;
|
|
2717
|
-
this.renderDialog();
|
|
2718
|
-
}
|
|
2719
|
-
return;
|
|
2720
|
-
}
|
|
2721
|
-
if (!pendingLink) {
|
|
2722
|
-
this.closeDialog();
|
|
2723
|
-
return;
|
|
2724
|
-
}
|
|
2725
|
-
if (this.dialog?.kind === "intentSwitch") {
|
|
2726
|
-
this.dialog.busy = false;
|
|
2727
|
-
this.renderDialog();
|
|
2728
|
-
}
|
|
2729
|
-
await this.mintLink(channelId, pendingLink);
|
|
2730
|
-
}
|
|
2731
|
-
/**
|
|
2732
|
-
* Mount a modal: `aria-modal` dialog, programmatic name, focus moved inside,
|
|
2733
|
-
* Tab trapped, Escape closes WITHOUT mutating, focus restored on close (§13).
|
|
2734
|
-
*/
|
|
2735
|
-
renderScrim(inner, wire) {
|
|
2736
|
-
const existing = this.scrimEl;
|
|
2737
|
-
if (!existing) this.lastFocus = document.activeElement ?? null;
|
|
2738
|
-
existing?.remove();
|
|
2739
|
-
const scrim = document.createElement("div");
|
|
2740
|
-
scrim.className = "slm-ch-scrim";
|
|
2741
|
-
scrim.innerHTML = `<div class="slm-ch-dialog" role="dialog" aria-modal="true"
|
|
2742
|
-
aria-labelledby="slm-ch-dlg-title" tabindex="-1">${inner}</div>`;
|
|
2743
|
-
this.host.root.appendChild(scrim);
|
|
2744
|
-
this.scrimEl = scrim;
|
|
2745
|
-
const dialog = scrim.firstElementChild;
|
|
2746
|
-
dialog.querySelectorAll("[data-ch-close]").forEach((button) => {
|
|
2747
|
-
button.addEventListener("click", () => this.closeDialog());
|
|
2748
|
-
});
|
|
2749
|
-
scrim.addEventListener("keydown", (event) => {
|
|
2750
|
-
if (event.key === "Escape") {
|
|
2751
|
-
event.stopPropagation();
|
|
2752
|
-
this.closeDialog();
|
|
2753
|
-
return;
|
|
2754
|
-
}
|
|
2755
|
-
if (event.key !== "Tab") return;
|
|
2756
|
-
const focusable = [...dialog.querySelectorAll(
|
|
2757
|
-
'button:not([disabled]),select,input,textarea,a[href],[tabindex]:not([tabindex="-1"])'
|
|
2758
|
-
)];
|
|
2759
|
-
if (!focusable.length) return;
|
|
2760
|
-
const first = focusable[0];
|
|
2761
|
-
const last = focusable[focusable.length - 1];
|
|
2762
|
-
if (event.shiftKey && document.activeElement === first) {
|
|
2763
|
-
event.preventDefault();
|
|
2764
|
-
last.focus();
|
|
2765
|
-
} else if (!event.shiftKey && document.activeElement === last) {
|
|
2766
|
-
event.preventDefault();
|
|
2767
|
-
first.focus();
|
|
2768
|
-
}
|
|
2769
|
-
});
|
|
2770
|
-
wire(dialog);
|
|
2771
|
-
const autofocus = dialog.querySelector("input,select,button");
|
|
2772
|
-
(autofocus ?? dialog).focus();
|
|
2773
|
-
}
|
|
2774
|
-
closeDialog(opts = {}) {
|
|
2775
|
-
this.dialog = null;
|
|
2776
|
-
this.scrimEl?.remove();
|
|
2777
|
-
this.scrimEl = null;
|
|
2778
|
-
if (opts.restoreFocus !== false) this.lastFocus?.focus?.();
|
|
2779
|
-
this.lastFocus = null;
|
|
2780
|
-
}
|
|
2781
|
-
renderCreateDialog(state) {
|
|
2782
|
-
const taken = (this.list?.channels ?? []).map((channel) => markerLetter(channel.marker || channel.name, ""));
|
|
2783
|
-
const suggestion = suggestMarker("", taken);
|
|
2784
|
-
this.renderScrim(`
|
|
2785
|
-
<h3 id="slm-ch-dlg-title">Create channel</h3>
|
|
2786
|
-
<p class="sub">A named allocation only the right audience can buy from. You'll pick the seats next.</p>
|
|
2787
|
-
<div class="slm-field">
|
|
2788
|
-
<label for="slm-ch-name">Name</label>
|
|
2789
|
-
<input class="slm-input" id="slm-ch-name" maxlength="80" />
|
|
2790
|
-
<p class="slm-note">Shown to your team and in reports \u2014 never to buyers.</p>
|
|
2791
|
-
</div>
|
|
2792
|
-
<div class="slm-field">
|
|
2793
|
-
<label>Marker</label>
|
|
2794
|
-
<div style="display:flex;gap:8px;align-items:center">
|
|
2795
|
-
<span class="slm-ch-mk" data-ch-marker style="background:${esc(suggestion.color)};width:28px;height:28px;font-size:13px">${esc(suggestion.letter)}</span>
|
|
2796
|
-
<span class="slm-note" style="margin:0">Letter comes from the name; colour is chosen automatically from the next available palette. Buyers never see either.</span>
|
|
2797
|
-
</div>
|
|
2798
|
-
</div>
|
|
2799
|
-
<div class="slm-field">
|
|
2800
|
-
<label for="slm-ch-ref">Reference <span style="text-transform:none;font-weight:500">(optional)</span></label>
|
|
2801
|
-
<input class="slm-input" id="slm-ch-ref" maxlength="120" placeholder="e.g. travel-agency-a" />
|
|
2802
|
-
<p class="slm-note">A stable ID for your own system and webhooks.</p>
|
|
2803
|
-
</div>
|
|
2804
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
2805
|
-
<div class="foot">
|
|
2806
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
2807
|
-
<button type="button" class="slm-btn ghost" data-ch-create="plain">Create without allocating</button>
|
|
2808
|
-
<button type="button" class="slm-btn" data-ch-create="allocate">Create and allocate seats</button>
|
|
2809
|
-
</div>`, (dialog) => {
|
|
2810
|
-
const name = dialog.querySelector("#slm-ch-name");
|
|
2811
|
-
const marker = dialog.querySelector("[data-ch-marker]");
|
|
2812
|
-
name.addEventListener("input", () => {
|
|
2813
|
-
const next = suggestMarker(name.value, taken);
|
|
2814
|
-
marker.textContent = next.letter;
|
|
2815
|
-
marker.style.background = next.color;
|
|
2816
|
-
});
|
|
2817
|
-
dialog.querySelectorAll("[data-ch-create]").forEach((button) => {
|
|
2818
|
-
button.addEventListener("click", () => {
|
|
2819
|
-
const allocate = button.dataset.chCreate === "allocate";
|
|
2820
|
-
void this.createChannel(
|
|
2821
|
-
name.value,
|
|
2822
|
-
marker.textContent ?? "",
|
|
2823
|
-
marker.style.background,
|
|
2824
|
-
dialog.querySelector("#slm-ch-ref")?.value ?? "",
|
|
2825
|
-
allocate
|
|
2826
|
-
);
|
|
2827
|
-
});
|
|
2828
|
-
});
|
|
2829
|
-
});
|
|
2830
|
-
}
|
|
2831
|
-
async createChannel(name, letter, color, externalRef, allocate) {
|
|
2832
|
-
const trimmed = name.trim();
|
|
2833
|
-
if (!trimmed) {
|
|
2834
|
-
this.showDialogError("Give the channel a name your team will recognise.");
|
|
2835
|
-
return;
|
|
2836
|
-
}
|
|
2837
|
-
try {
|
|
2838
|
-
const res = await this.host.api.createChannel(this.host.eventKey, {
|
|
2839
|
-
name: trimmed,
|
|
2840
|
-
// The column is free text; we only ever store what the chip can draw.
|
|
2841
|
-
marker: markerLetter(letter, "") || null,
|
|
2842
|
-
color: color || null,
|
|
2843
|
-
externalRef: externalRef.trim() || null
|
|
2844
|
-
});
|
|
2845
|
-
this.closeDialog();
|
|
2846
|
-
await this.refresh();
|
|
2847
|
-
this.targetChannelId = res.channel.id;
|
|
2848
|
-
this.detailChannelId = allocate ? null : res.channel.id;
|
|
2849
|
-
this.announce(`Channel ${trimmed} created with 0 seats allocated.`);
|
|
2850
|
-
this.host.toast(allocate ? `${trimmed} created. Select seats on the map to allocate them.` : `${trimmed} created.`, "ok");
|
|
2851
|
-
this.paintRail();
|
|
2852
|
-
} catch (err) {
|
|
2853
|
-
const code = err instanceof ManageApiError ? err.code : void 0;
|
|
2854
|
-
this.showDialogError(code === "channel_name_taken" ? "That name is already used on this event. Pick another." : "Couldn't create the channel. Try again.");
|
|
2855
|
-
this.host.onError(err);
|
|
2856
|
-
}
|
|
2857
|
-
}
|
|
2858
|
-
showDialogError(message) {
|
|
2859
|
-
const field = this.scrimEl?.querySelector("[data-ch-error]");
|
|
2860
|
-
if (!field) return;
|
|
2861
|
-
field.textContent = message;
|
|
2862
|
-
field.hidden = false;
|
|
2863
|
-
}
|
|
2864
|
-
renderReviewDialog(state) {
|
|
2865
|
-
const { labels, buckets } = this.currentPlan();
|
|
2866
|
-
const authoritative = state.applied;
|
|
2867
|
-
const shown = authoritative ? authoritative.buckets : buckets;
|
|
2868
|
-
const targetName = this.nameOf(authoritative?.targetChannelId ?? this.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
|
|
2869
|
-
const rows = bucketRows(shown, targetName);
|
|
2870
|
-
const mutations = authoritative ? authoritative.applied : mutationCount(buckets);
|
|
2871
|
-
const allSkipped = !authoritative && labels.length > 0 && mutations === 0;
|
|
2872
|
-
const tooLarge = !authoritative && labels.length > MAX_ASSIGNMENT_UNITS;
|
|
2873
|
-
const rowsHtml = bucketRowsHtml(rows);
|
|
2874
|
-
const foot = authoritative ? `<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>` : `<div class="foot">
|
|
2875
|
-
<button type="button" class="quiet" data-ch-close>Back</button>
|
|
2876
|
-
<button type="button" class="slm-btn" data-ch-apply ${allSkipped || tooLarge || state.busy ? "disabled" : ""}>
|
|
2877
|
-
${state.busy ? "Applying\u2026" : `Apply ${mutations.toLocaleString()} change${mutations === 1 ? "" : "s"}`}
|
|
2878
|
-
</button>
|
|
2879
|
-
</div>`;
|
|
2880
|
-
const confirmNote = !authoritative && needsMoveConfirmation(buckets) ? `<p class="slm-note">Applying moves inventory out of another private channel. That is the line marked above.</p>` : "";
|
|
2881
|
-
const skippedNote = allSkipped ? `<div class="slm-ch-alert warn"><span>\u2139</span><span>Nothing in this selection can move right now \u2014
|
|
2882
|
-
every seat is in a buyer's checkout, already sold, or already in ${esc(targetName)}.</span></div>` : "";
|
|
2883
|
-
const limitNote = tooLarge ? `<div class="slm-ch-alert warn" role="alert"><span>\u2139</span><span><b>Choose fewer seats.</b>
|
|
2884
|
-
One Apply can cover at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats.</span></div>` : "";
|
|
2885
|
-
this.renderScrim(`
|
|
2886
|
-
<h3 id="slm-ch-dlg-title">${authoritative ? `Moved ${authoritative.applied.toLocaleString()} seat${authoritative.applied === 1 ? "" : "s"} to ${esc(targetName)}` : `Move ${labels.length.toLocaleString()} selected seat${labels.length === 1 ? "" : "s"} to ${esc(targetName)}`}</h3>
|
|
2887
|
-
<p class="sub">${authoritative ? "These are the exact counts the server applied." : "Every selected seat is in exactly one line below."}</p>
|
|
2888
|
-
${skippedNote}
|
|
2889
|
-
${limitNote}
|
|
2890
|
-
${rowsHtml || '<div class="slm-empty">Nothing selected.</div>'}
|
|
2891
|
-
${confirmNote}
|
|
2892
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
2893
|
-
${foot}`, (dialog) => {
|
|
2894
|
-
dialog.querySelector("[data-ch-apply]")?.addEventListener("click", () => void this.apply());
|
|
2895
|
-
});
|
|
2896
|
-
if (authoritative) {
|
|
2897
|
-
this.announce(`Applied ${authoritative.applied} change${authoritative.applied === 1 ? "" : "s"} to ${targetName}.`);
|
|
2898
|
-
}
|
|
2899
|
-
}
|
|
2900
|
-
/**
|
|
2901
|
-
* Apply. On success the sheet CLOSES and the staged bar confirms what moved,
|
|
2902
|
-
* naming the destination and any skipped seats, with the AUTHORITATIVE server
|
|
2903
|
-
* buckets still one Details press away. Leaving the modal up on success made
|
|
2904
|
-
* the organizer dismiss a sheet to get back to a map they had just changed.
|
|
2905
|
-
* On a stale version the server mutated nothing: keep the selection, shake
|
|
2906
|
-
* the bar once, and offer exactly one action — Refresh and review.
|
|
2907
|
-
*/
|
|
2908
|
-
async apply() {
|
|
2909
|
-
if (!this.dialog || !this.caps.manage) return;
|
|
2910
|
-
const { labels } = this.currentPlan();
|
|
2911
|
-
if (!labels.length) return;
|
|
2912
|
-
if (labels.length > MAX_ASSIGNMENT_UNITS) {
|
|
2913
|
-
this.showDialogError(`Choose at most ${MAX_ASSIGNMENT_UNITS.toLocaleString()} seats for one Apply.`);
|
|
2914
|
-
return;
|
|
2915
|
-
}
|
|
2916
|
-
this.dialog = { ...this.dialog, busy: true, error: null };
|
|
2917
|
-
this.renderDialog();
|
|
2918
|
-
try {
|
|
2919
|
-
const result = await this.host.api.applyChannelAssignment(this.host.eventKey, {
|
|
2920
|
-
// `null` is the wire spelling of "back to public sale" every worker
|
|
2921
|
-
// accepts, including ones that predate the 'public' sentinel.
|
|
2922
|
-
targetChannelId: isPublicChannelId(this.targetChannelId) ? null : this.targetChannelId,
|
|
2923
|
-
labels,
|
|
2924
|
-
assignmentVersion: this.assignmentVersion
|
|
2925
|
-
});
|
|
2926
|
-
this.assignmentVersion = result.assignmentVersion;
|
|
2927
|
-
this.conflict = false;
|
|
2928
|
-
await this.refresh({ quiet: true });
|
|
2929
|
-
this.closeDialog({ restoreFocus: false });
|
|
2930
|
-
this.host.clearSelection();
|
|
2931
|
-
this.showApplied(result);
|
|
2932
|
-
} catch (err) {
|
|
2933
|
-
const conflict = err instanceof ManageApiError && err.status === 409 && err.code === "channel_assignment_conflict";
|
|
2934
|
-
if (conflict) {
|
|
2935
|
-
this.conflict = true;
|
|
2936
|
-
this.closeDialog();
|
|
2937
|
-
this.shakeStaged();
|
|
2938
|
-
this.paintRail();
|
|
2939
|
-
this.announce("Assignments changed while you were editing. Nothing was applied and your selection is kept.");
|
|
2940
|
-
return;
|
|
2941
|
-
}
|
|
2942
|
-
if (err instanceof ManageApiError && err.status === 403) {
|
|
2943
|
-
this.caps = { view: this.caps.view, manage: false };
|
|
2944
|
-
this.closeDialog();
|
|
2945
|
-
this.paintRail();
|
|
2946
|
-
this.host.toast("Changing channels needs channel-management permission.", "err");
|
|
2947
|
-
return;
|
|
2948
|
-
}
|
|
2949
|
-
this.dialog = { kind: "review", busy: false, error: "Couldn't apply those changes. Try again." };
|
|
2950
|
-
this.renderDialog();
|
|
2951
|
-
this.host.onError(err);
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
/**
|
|
2955
|
-
* The success receipt, now that the review sheet closes on Apply. It names the
|
|
2956
|
-
* destination and the seats that could not move, keeps the authoritative
|
|
2957
|
-
* buckets reachable through Details, and stays up long enough to be read —
|
|
2958
|
-
* 1.2s was tuned for a confirmation the organizer was already looking at.
|
|
2959
|
-
*/
|
|
2960
|
-
showApplied(result) {
|
|
2961
|
-
const targetName = this.nameOf(result.targetChannelId) ?? PUBLIC_CHANNEL_NAME;
|
|
2962
|
-
const skipped = result.buckets.skippedHeld.count + result.buckets.skippedBooked.count + result.buckets.notFound.count;
|
|
2963
|
-
this.setStaged(`<span class="slm-ch-tick" aria-hidden="true">\u2713</span>
|
|
2964
|
-
<span>Assigned <b>${result.applied.toLocaleString()}</b> seat${result.applied === 1 ? "" : "s"} to ${esc(targetName)}
|
|
2965
|
-
${skipped ? ` \xB7 ${skipped.toLocaleString()} skipped` : ""}</span>
|
|
2966
|
-
<span class="grow"></span>
|
|
2967
|
-
<button type="button" class="drop" data-ch-applied-details>Details</button>`, "done");
|
|
2968
|
-
this.stagedEl?.querySelector("[data-ch-applied-details]")?.addEventListener("click", () => {
|
|
2969
|
-
this.openDialog({ kind: "review", applied: result });
|
|
2970
|
-
});
|
|
2971
|
-
this.announce(`Assigned ${result.applied} seat${result.applied === 1 ? "" : "s"} to ${targetName}${skipped ? `; ${skipped} skipped` : ""}.`);
|
|
2972
|
-
if (this.stagedDoneTimer) clearTimeout(this.stagedDoneTimer);
|
|
2973
|
-
this.stagedDoneTimer = setTimeout(() => this.setStaged(null), 5e3);
|
|
2974
|
-
}
|
|
2975
|
-
shakeStaged() {
|
|
2976
|
-
const bar = this.stagedEl;
|
|
2977
|
-
if (!bar || !bar.classList.contains("on")) return;
|
|
2978
|
-
bar.classList.remove("shake");
|
|
2979
|
-
void bar.offsetWidth;
|
|
2980
|
-
bar.classList.add("shake");
|
|
2981
|
-
}
|
|
2982
|
-
/**
|
|
2983
|
-
* The ⋯ menu.
|
|
2984
|
-
*
|
|
2985
|
-
* Opening a channel is the ROW's job now, so ⋯ carries what is left: the
|
|
2986
|
-
* secondary and destructive lifecycle actions. It is rendered through the same
|
|
2987
|
-
* scrim primitive as every other sheet, which is what gives it a focus trap,
|
|
2988
|
-
* Escape, and a name — a bare absolutely-positioned popup would have had none
|
|
2989
|
-
* of those. Archive keeps its own confirmation dialog; this menu never
|
|
2990
|
-
* destroys anything by itself.
|
|
2991
|
-
*/
|
|
2992
|
-
renderMenuDialog(state) {
|
|
2993
|
-
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
2994
|
-
if (!channel || !this.caps.manage) {
|
|
2995
|
-
this.closeDialog();
|
|
2996
|
-
return;
|
|
2997
|
-
}
|
|
2998
|
-
const paused = channel.state === "paused";
|
|
2999
|
-
this.renderScrim(`
|
|
3000
|
-
<h3 id="slm-ch-dlg-title">${esc(channel.name)}</h3>
|
|
3001
|
-
<p class="sub">Open the channel to allocate seats and hand out buyer access.
|
|
3002
|
-
These are the rest of its actions.</p>
|
|
3003
|
-
<div class="slm-ch-menu">
|
|
3004
|
-
<button type="button" class="slm-btn" data-ch-menu-act="open">Open channel</button>
|
|
3005
|
-
<button type="button" class="slm-btn ghost" data-ch-menu-act="rename">Rename</button>
|
|
3006
|
-
<button type="button" class="slm-btn ghost" data-ch-menu-act="pause">${paused ? "Resume selling" : "Pause selling"}</button>
|
|
3007
|
-
<button type="button" class="slm-btn ghost" data-ch-menu-act="archive">Archive\u2026</button>
|
|
3008
|
-
</div>
|
|
3009
|
-
<p class="slm-note">Pausing stops new buyer access; checkouts already running can finish.
|
|
3010
|
-
Archiving closes the channel for good and returns its free seats to a destination you choose.
|
|
3011
|
-
Nothing is ever deleted silently.</p>
|
|
3012
|
-
<div class="foot"><button type="button" class="quiet" data-ch-close>Cancel</button></div>`, (dialog) => {
|
|
3013
|
-
dialog.querySelectorAll("[data-ch-menu-act]").forEach((button) => {
|
|
3014
|
-
button.addEventListener("click", () => {
|
|
3015
|
-
const act = button.dataset.chMenuAct;
|
|
3016
|
-
if (act === "open") {
|
|
3017
|
-
this.closeDialog();
|
|
3018
|
-
this.openChannel(channel.id);
|
|
3019
|
-
return;
|
|
3020
|
-
}
|
|
3021
|
-
if (act === "rename") {
|
|
3022
|
-
this.openDialog({ kind: "rename", channelId: channel.id });
|
|
3023
|
-
return;
|
|
3024
|
-
}
|
|
3025
|
-
if (act === "archive") {
|
|
3026
|
-
this.openDialog({ kind: "archive", channelId: channel.id });
|
|
3027
|
-
return;
|
|
3028
|
-
}
|
|
3029
|
-
this.closeDialog();
|
|
3030
|
-
void this.togglePause(channel.id);
|
|
3031
|
-
});
|
|
3032
|
-
});
|
|
3033
|
-
});
|
|
3034
|
-
}
|
|
3035
|
-
renderRenameDialog(state) {
|
|
3036
|
-
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
3037
|
-
if (!channel) {
|
|
3038
|
-
this.closeDialog();
|
|
3039
|
-
return;
|
|
3040
|
-
}
|
|
3041
|
-
this.renderScrim(`
|
|
3042
|
-
<h3 id="slm-ch-dlg-title">Rename ${esc(channel.name)}</h3>
|
|
3043
|
-
<p class="sub">Only your team and your reports see this name.</p>
|
|
3044
|
-
<div class="slm-field">
|
|
3045
|
-
<label for="slm-ch-newname">Name</label>
|
|
3046
|
-
<input class="slm-input" id="slm-ch-newname" maxlength="80" value="${esc(channel.name)}" />
|
|
3047
|
-
</div>
|
|
3048
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
3049
|
-
<div class="foot">
|
|
3050
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
3051
|
-
<button type="button" class="slm-btn" data-ch-rename>Save name</button>
|
|
3052
|
-
</div>`, (dialog) => {
|
|
3053
|
-
dialog.querySelector("[data-ch-rename]")?.addEventListener("click", () => {
|
|
3054
|
-
const value = dialog.querySelector("#slm-ch-newname")?.value ?? "";
|
|
3055
|
-
if (!value.trim()) {
|
|
3056
|
-
this.showDialogError("A channel needs a name.");
|
|
3057
|
-
return;
|
|
3058
|
-
}
|
|
3059
|
-
void this.host.api.renameChannel(this.host.eventKey, channel.id, value.trim()).then(() => {
|
|
3060
|
-
this.closeDialog();
|
|
3061
|
-
return this.refresh();
|
|
3062
|
-
}).catch((err) => {
|
|
3063
|
-
this.showDialogError(err instanceof ManageApiError && err.code === "channel_name_taken" ? "That name is already used on this event." : "Couldn't rename the channel.");
|
|
3064
|
-
this.host.onError(err);
|
|
3065
|
-
});
|
|
3066
|
-
});
|
|
3067
|
-
});
|
|
3068
|
-
}
|
|
3069
|
-
/**
|
|
3070
|
-
* "Use a website or app" — declare the channel's route as `server`.
|
|
3071
|
-
*
|
|
3072
|
-
* This is no longer a flag the Embed page happens to read: since 2026-08-06 it
|
|
3073
|
-
* is what AUTHORIZES `POST /v1/events/:key/buyer-access-sessions` to mint for
|
|
3074
|
-
* this channel at all. Without it, an integration that is otherwise perfectly
|
|
3075
|
-
* wired up gets a 409 on every buyer.
|
|
3076
|
-
*/
|
|
3077
|
-
async chooseWebsiteIntegration() {
|
|
3078
|
-
const channelId = this.detailChannelId;
|
|
3079
|
-
if (!channelId) return;
|
|
3080
|
-
await this.setAccessIntent("server");
|
|
3081
|
-
}
|
|
3082
|
-
/**
|
|
3083
|
-
* Declare this channel's sale route.
|
|
3084
|
-
*
|
|
3085
|
-
* Reports through the toast lane the rest of the rail's direct actions use.
|
|
3086
|
-
* The two enforcement refusals get real answers rather than a generic failure:
|
|
3087
|
-
* `channel_intent_switch_blocked` opens the review sheet (there is a decision
|
|
3088
|
-
* to make, and a sheet is where decisions live), and
|
|
3089
|
-
* `channel_access_intent_forbids` — which the organizer can hit by racing
|
|
3090
|
-
* their own second tab — says which route is in the way.
|
|
3091
|
-
*/
|
|
3092
|
-
async setAccessIntent(accessIntent, opts = {}) {
|
|
3093
|
-
const channelId = this.detailChannelId;
|
|
3094
|
-
if (!channelId || !this.caps.manage) return false;
|
|
3095
|
-
try {
|
|
3096
|
-
const result = await this.host.api.setChannelAccessIntent(
|
|
3097
|
-
this.host.eventKey,
|
|
3098
|
-
channelId,
|
|
3099
|
-
accessIntent,
|
|
3100
|
-
opts
|
|
3101
|
-
);
|
|
3102
|
-
await this.refresh();
|
|
3103
|
-
this.host.toast(this.intentSavedCopy(accessIntent, result?.intentSwitch), "ok");
|
|
3104
|
-
return true;
|
|
3105
|
-
} catch (err) {
|
|
3106
|
-
if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
|
|
3107
|
-
this.openDialog({
|
|
3108
|
-
kind: "intentSwitch",
|
|
3109
|
-
channelId,
|
|
3110
|
-
intentTo: accessIntent,
|
|
3111
|
-
switchBlocked: err.details
|
|
3112
|
-
});
|
|
3113
|
-
return false;
|
|
3114
|
-
}
|
|
3115
|
-
if (err instanceof ManageApiError && err.code === "channel_access_intent_forbids") {
|
|
3116
|
-
this.host.toast(intentForbidsCopy(err.details), "err");
|
|
3117
|
-
return false;
|
|
3118
|
-
}
|
|
3119
|
-
this.host.toast("Couldn't change how this channel reaches buyers.", "err");
|
|
3120
|
-
this.host.onError(err);
|
|
3121
|
-
return false;
|
|
3122
|
-
}
|
|
3123
|
-
}
|
|
3124
|
-
/** What just happened, including anything the switch took down with it — the
|
|
3125
|
-
* server reports `intentSwitch` only when it actually disturbed something. */
|
|
3126
|
-
intentSavedCopy(intent, disturbed) {
|
|
3127
|
-
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.";
|
|
3128
|
-
if (!disturbed) return head;
|
|
3129
|
-
const closed = disturbed.closedLinks ? ` ${disturbed.closedLinks.toLocaleString()} buyer link${disturbed.closedLinks === 1 ? "" : "s"} closed.` : "";
|
|
3130
|
-
const kept = disturbed.keptSessions ? ` ${disturbed.keptSessions.toLocaleString()} buyer${disturbed.keptSessions === 1 ? "" : "s"} already in a checkout can still finish.` : "";
|
|
3131
|
-
return `${head}${closed}${kept}`;
|
|
3132
|
-
}
|
|
3133
|
-
/** `channelId` is explicit because this is reachable from the ⋯ menu on a row
|
|
3134
|
-
* that is NOT the open channel, as well as from the detail panel itself. */
|
|
3135
|
-
async togglePause(channelId = this.detailChannelId) {
|
|
3136
|
-
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
3137
|
-
if (!channel || !this.caps.manage) return;
|
|
3138
|
-
const paused = channel.state !== "paused";
|
|
3139
|
-
try {
|
|
3140
|
-
await this.host.api.setChannelPaused(this.host.eventKey, channel.id, paused);
|
|
3141
|
-
await this.refresh();
|
|
3142
|
-
this.host.toast(paused ? `${channel.name} paused. Existing checkouts can finish; no new buyer access is issued.` : `${channel.name} resumed.`, "ok");
|
|
3143
|
-
} catch (err) {
|
|
3144
|
-
this.host.toast("Couldn't change that channel.", "err");
|
|
3145
|
-
this.host.onError(err);
|
|
3146
|
-
}
|
|
3147
|
-
}
|
|
3148
|
-
renderArchiveDialog(state) {
|
|
3149
|
-
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
3150
|
-
if (!channel) {
|
|
3151
|
-
this.closeDialog();
|
|
3152
|
-
return;
|
|
3153
|
-
}
|
|
3154
|
-
const blocked = state.archiveBlocked ?? null;
|
|
3155
|
-
const heads_up = !blocked && channel.counts.held > 0;
|
|
3156
|
-
const destinations = this.assignableChannels().filter((entry) => entry.id !== channel.id);
|
|
3157
|
-
const blockedAlert = blocked ? `<div class="slm-ch-alert warn" role="alert"><span>\u23F3</span>
|
|
3158
|
-
<span><b>${(blocked.heldUnits ?? blocked.activeHolds ?? 0).toLocaleString()} seats are in a buyer's checkout right now.</b>
|
|
3159
|
-
Archive is unavailable while seats are held \u2014 try again ${esc(retryAfterCopy(blocked))}.</span></div>` : heads_up ? `<div class="slm-ch-alert warn"><span>\u23F3</span>
|
|
3160
|
-
<span>${channel.counts.held.toLocaleString()} seats are in a buyer's checkout right now.
|
|
3161
|
-
Archive is refused while any seat is held \u2014 you can try, and we'll tell you when to come back.</span></div>` : "";
|
|
3162
|
-
this.renderScrim(`
|
|
3163
|
-
<h3 id="slm-ch-dlg-title">Archive ${esc(channel.name)}</h3>
|
|
3164
|
-
<p class="sub">The channel closes for good. Its seats move to a destination you choose;
|
|
3165
|
-
sales history keeps its attribution.</p>
|
|
3166
|
-
${blockedAlert}
|
|
3167
|
-
<div class="slm-field">
|
|
3168
|
-
<label for="slm-ch-dest">Move the ${channel.counts.free.toLocaleString()} remaining free seats to</label>
|
|
3169
|
-
<select class="slm-select" id="slm-ch-dest">
|
|
3170
|
-
${destinations.map((entry) => `<option value="${esc(entry.id)}">${esc(entry.name)}</option>`).join("")}
|
|
3171
|
-
</select>
|
|
3172
|
-
</div>
|
|
3173
|
-
<p class="slm-note">${channel.counts.booked.toLocaleString()} sold seats keep "${esc(channel.name)}" on their sale
|
|
3174
|
-
record. If one is cancelled later it returns to the destination above. Any buyer access for this channel
|
|
3175
|
-
stops working.</p>
|
|
3176
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
3177
|
-
<div class="foot">
|
|
3178
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
3179
|
-
<button type="button" class="slm-btn danger" data-ch-archive ${blocked ? "disabled" : ""}>Archive channel</button>
|
|
3180
|
-
</div>`, (dialog) => {
|
|
3181
|
-
dialog.querySelector("[data-ch-archive]")?.addEventListener("click", () => {
|
|
3182
|
-
const destination = dialog.querySelector("#slm-ch-dest")?.value ?? "";
|
|
3183
|
-
void this.archive(channel.id, destination || null);
|
|
3184
|
-
});
|
|
3185
|
-
});
|
|
3186
|
-
}
|
|
3187
|
-
async archive(channelId, destination) {
|
|
3188
|
-
try {
|
|
3189
|
-
await this.host.api.archiveChannel(this.host.eventKey, channelId, destination);
|
|
3190
|
-
this.closeDialog();
|
|
3191
|
-
this.detailChannelId = null;
|
|
3192
|
-
await this.refresh();
|
|
3193
|
-
this.host.toast("Channel archived. Its remaining seats moved to the destination you chose.", "ok");
|
|
3194
|
-
} catch (err) {
|
|
3195
|
-
if (err instanceof ManageApiError && err.status === 409 && err.code === "channel_archive_blocked_by_holds") {
|
|
3196
|
-
this.dialog = {
|
|
3197
|
-
kind: "archive",
|
|
3198
|
-
channelId,
|
|
3199
|
-
archiveBlocked: err.details ?? {}
|
|
3200
|
-
};
|
|
3201
|
-
this.renderDialog();
|
|
3202
|
-
return;
|
|
3203
|
-
}
|
|
3204
|
-
this.showDialogError("Couldn't archive that channel. Try again.");
|
|
3205
|
-
this.host.onError(err);
|
|
3206
|
-
}
|
|
3207
|
-
}
|
|
3208
|
-
/**
|
|
3209
|
-
* The synchronized inventory list (§13): the keyboard and screen-reader
|
|
3210
|
-
* equivalent of canvas click / marquee / brush, grouped by section with
|
|
3211
|
-
* per-section select actions.
|
|
3212
|
-
*/
|
|
3213
|
-
renderSeatListDialog() {
|
|
3214
|
-
const selected = new Set(this.host.selectionLabels());
|
|
3215
|
-
const groups = /* @__PURE__ */ new Map();
|
|
3216
|
-
let total = 0;
|
|
3217
|
-
for (const seat of this.host.seats()) {
|
|
3218
|
-
total += 1;
|
|
3219
|
-
if (total > this.seatListLimit) break;
|
|
3220
|
-
const section = this.host.sectionOfLabel(seat.label);
|
|
3221
|
-
const key = section?.id ?? "";
|
|
3222
|
-
const group = groups.get(key) ?? { label: section?.label ?? "Other seats", seats: [] };
|
|
3223
|
-
group.seats.push({ label: seat.label, status: this.host.statusOf(seat.label) ?? "free" });
|
|
3224
|
-
groups.set(key, group);
|
|
3225
|
-
}
|
|
3226
|
-
const body = [...groups.entries()].map(([id, group]) => `
|
|
3227
|
-
<div class="slm-ch-seatgroup">
|
|
3228
|
-
<span>${esc(group.label)}</span>
|
|
3229
|
-
${id ? `<button type="button" data-ch-section="${esc(id)}">Select section</button>` : ""}
|
|
3230
|
-
</div>
|
|
3231
|
-
${group.seats.map((seat) => {
|
|
3232
|
-
const channelId = this.allocation.get(seat.label) ?? PUBLIC_CHANNEL_ID;
|
|
3233
|
-
const channelName = this.nameOf(channelId) ?? PUBLIC_CHANNEL_NAME;
|
|
3234
|
-
return `<button type="button" class="slm-ch-seatitem" role="checkbox"
|
|
3235
|
-
aria-checked="${selected.has(seat.label)}" data-ch-seat="${esc(seat.label)}">
|
|
3236
|
-
<span class="box" aria-hidden="true">\u2713</span>
|
|
3237
|
-
<span>${esc(seat.label)}</span>
|
|
3238
|
-
<span class="meta">${esc(channelName)} \xB7 ${esc(seat.status)}</span>
|
|
3239
|
-
</button>`;
|
|
3240
|
-
}).join("")}`).join("");
|
|
3241
|
-
this.renderScrim(`
|
|
3242
|
-
<h3 id="slm-ch-dlg-title">Seat list</h3>
|
|
3243
|
-
<p class="sub">The same selection as the map, with checkboxes. Space or Enter toggles a seat.</p>
|
|
3244
|
-
<div class="slm-ch-seatlist">${body || '<div class="slm-empty">No seats on this chart.</div>'}</div>
|
|
3245
|
-
${total > this.seatListLimit ? `<div class="foot"><button type="button" class="slm-btn ghost" data-ch-more>Show more seats</button></div>` : ""}
|
|
3246
|
-
<div class="foot"><button type="button" class="slm-btn" data-ch-close>Done</button></div>`, (dialog) => {
|
|
3247
|
-
dialog.querySelectorAll("[data-ch-seat]").forEach((button) => {
|
|
3248
|
-
button.addEventListener("click", () => {
|
|
3249
|
-
const label = button.dataset.chSeat;
|
|
3250
|
-
const next = new Set(this.host.selectionLabels());
|
|
3251
|
-
if (next.has(label)) next.delete(label);
|
|
3252
|
-
else next.add(label);
|
|
3253
|
-
this.host.clearSelection();
|
|
3254
|
-
if (next.size) this.host.selectByLabels([...next]);
|
|
3255
|
-
this.renderSeatListDialog();
|
|
3256
|
-
});
|
|
3257
|
-
});
|
|
3258
|
-
dialog.querySelectorAll("[data-ch-section]").forEach((button) => {
|
|
3259
|
-
button.addEventListener("click", () => {
|
|
3260
|
-
this.host.selectSection(button.dataset.chSection);
|
|
3261
|
-
this.renderSeatListDialog();
|
|
3262
|
-
});
|
|
3263
|
-
});
|
|
3264
|
-
dialog.querySelector("[data-ch-more]")?.addEventListener("click", () => {
|
|
3265
|
-
this.seatListLimit += SEAT_LIST_PAGE;
|
|
3266
|
-
this.renderSeatListDialog();
|
|
3267
|
-
});
|
|
3268
|
-
});
|
|
3269
|
-
}
|
|
3270
|
-
// ---- buyer-link dialogs ---------------------------------------------------
|
|
3271
|
-
async reloadLinks() {
|
|
3272
|
-
const channelId = this.detailChannelId;
|
|
3273
|
-
if (!channelId) return;
|
|
3274
|
-
this.linksState = this.links.length ? this.linksState : "loading";
|
|
3275
|
-
await this.loadLinks(channelId);
|
|
3276
|
-
this.paintRail();
|
|
3277
|
-
}
|
|
3278
|
-
/**
|
|
3279
|
-
* The reload EVERY link mutation owes the panel.
|
|
3280
|
-
*
|
|
3281
|
-
* A create/rotate/revoke changes two things the detail panel renders: the
|
|
3282
|
-
* channel's access line (the server sets `access.intent` on create, and clears
|
|
3283
|
-
* it when the last live link goes) and the link status list. Both are re-read
|
|
3284
|
-
* here and the rail repainted, so the panel the organizer is already looking
|
|
3285
|
-
* at is current the moment the mutation lands — no reload, and no dependence
|
|
3286
|
-
* on HOW the one-time reveal was dismissed (the button, Escape, or never).
|
|
3287
|
-
*/
|
|
3288
|
-
async reloadAfterLinkChange(channelId) {
|
|
3289
|
-
if (this.detailChannelId === channelId) {
|
|
3290
|
-
await this.refresh({ quiet: true });
|
|
3291
|
-
return;
|
|
3292
|
-
}
|
|
3293
|
-
await this.loadLinks(channelId);
|
|
3294
|
-
if (this.active) this.paintRail();
|
|
3295
|
-
}
|
|
3296
|
-
linkById(linkId) {
|
|
3297
|
-
return this.links.find((link) => link.id === linkId) ?? null;
|
|
3298
|
-
}
|
|
3299
|
-
/**
|
|
3300
|
-
* Create. The three policy fields carry the owner's defaults and every one of
|
|
3301
|
-
* them is editable; the PLATFORM bounds (60s–180d, 1–10 000, 1–100, 20 live
|
|
3302
|
-
* links) are the server's to enforce and the server's to explain, so this form
|
|
3303
|
-
* checks only that a number is a number and surfaces the server's sentence for
|
|
3304
|
-
* everything else.
|
|
3305
|
-
*/
|
|
3306
|
-
renderLinkCreateDialog(state) {
|
|
3307
|
-
const channel = this.list?.channels.find((item) => item.id === state.channelId);
|
|
3308
|
-
if (!channel || !this.caps.manage) {
|
|
3309
|
-
this.closeDialog();
|
|
3310
|
-
return;
|
|
3311
|
-
}
|
|
3312
|
-
this.renderScrim(`
|
|
3313
|
-
<h3 id="slm-ch-dlg-title">Create a buyer link for ${esc(channel.name)}</h3>
|
|
3314
|
-
<p class="sub">Anyone who opens the link can buy from this channel's allocation \u2014 and only from it.
|
|
3315
|
-
You'll see the link once, right after you create it.</p>
|
|
3316
|
-
<div class="slm-field">
|
|
3317
|
-
<label for="slm-ch-lk-label">Label <span style="text-transform:none;font-weight:500">(optional)</span></label>
|
|
3318
|
-
<input class="slm-input" id="slm-ch-lk-label" maxlength="80" placeholder="e.g. VIP list Nov 14" />
|
|
3319
|
-
<p class="slm-note">So you can tell your links apart later. Buyers never see it.</p>
|
|
3320
|
-
</div>
|
|
3321
|
-
<div class="slm-field">
|
|
3322
|
-
<label for="slm-ch-lk-expiry">Stops working</label>
|
|
3323
|
-
<select class="slm-select" id="slm-ch-lk-expiry" data-ch-lk-expiry>
|
|
3324
|
-
<option value="event" selected>When the event starts</option>
|
|
3325
|
-
<option value="custom">On a date I choose</option>
|
|
3326
|
-
</select>
|
|
3327
|
-
</div>
|
|
3328
|
-
<div class="slm-field" data-ch-lk-when-field hidden>
|
|
3329
|
-
<label for="slm-ch-lk-when">Date and time</label>
|
|
3330
|
-
<input class="slm-input" type="datetime-local" id="slm-ch-lk-when" />
|
|
3331
|
-
</div>
|
|
3332
|
-
<div class="slm-field">
|
|
3333
|
-
<label for="slm-ch-lk-redemptions">How many people can use it</label>
|
|
3334
|
-
<input class="slm-input" type="number" id="slm-ch-lk-redemptions" inputmode="numeric"
|
|
3335
|
-
value="${ACCESS_LINK_DEFAULTS.maxRedemptions}" />
|
|
3336
|
-
<p class="slm-note">Each buyer who opens the link uses one.</p>
|
|
3337
|
-
</div>
|
|
3338
|
-
<div class="slm-field">
|
|
3339
|
-
<label for="slm-ch-lk-quantity">Seats per buyer</label>
|
|
3340
|
-
<input class="slm-input" type="number" id="slm-ch-lk-quantity" inputmode="numeric"
|
|
3341
|
-
value="${ACCESS_LINK_DEFAULTS.maxQuantity}" />
|
|
3342
|
-
</div>
|
|
3343
|
-
<label class="slm-note" style="display:flex;gap:8px;align-items:center;margin:2px 0 6px">
|
|
3344
|
-
<input type="checkbox" id="slm-ch-lk-public" />
|
|
3345
|
-
Also let this link buy Public sale seats
|
|
3346
|
-
</label>
|
|
3347
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
3348
|
-
<div class="foot">
|
|
3349
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
3350
|
-
<button type="button" class="slm-btn" data-ch-lk-create>Create link</button>
|
|
3351
|
-
</div>`, (dialog) => {
|
|
3352
|
-
const expiry = dialog.querySelector("[data-ch-lk-expiry]");
|
|
3353
|
-
const whenField = dialog.querySelector("[data-ch-lk-when-field]");
|
|
3354
|
-
const when = dialog.querySelector("#slm-ch-lk-when");
|
|
3355
|
-
expiry.addEventListener("change", () => {
|
|
3356
|
-
const custom = expiry.value === "custom";
|
|
3357
|
-
whenField.hidden = !custom;
|
|
3358
|
-
if (custom && !when.value) when.value = datetimeLocalValue(Date.now() + 7 * 864e5);
|
|
3359
|
-
});
|
|
3360
|
-
dialog.querySelector("[data-ch-lk-create]")?.addEventListener("click", () => {
|
|
3361
|
-
const maxRedemptions = intField(dialog, "#slm-ch-lk-redemptions");
|
|
3362
|
-
const maxQuantity = intField(dialog, "#slm-ch-lk-quantity");
|
|
3363
|
-
if (maxRedemptions == null || maxQuantity == null) {
|
|
3364
|
-
this.showDialogError("Those two settings need to be whole numbers.");
|
|
3365
|
-
return;
|
|
3366
|
-
}
|
|
3367
|
-
let expiresAt;
|
|
3368
|
-
if (expiry.value === "custom") {
|
|
3369
|
-
expiresAt = Date.parse(when.value);
|
|
3370
|
-
if (!Number.isFinite(expiresAt)) {
|
|
3371
|
-
this.showDialogError("Pick the date and time the link should stop working.");
|
|
3372
|
-
return;
|
|
3373
|
-
}
|
|
3374
|
-
}
|
|
3375
|
-
void this.createLink(channel.id, {
|
|
3376
|
-
label: dialog.querySelector("#slm-ch-lk-label")?.value.trim() || null,
|
|
3377
|
-
includePublic: dialog.querySelector("#slm-ch-lk-public")?.checked ?? false,
|
|
3378
|
-
...expiresAt === void 0 ? {} : { expiresAt },
|
|
3379
|
-
maxRedemptions,
|
|
3380
|
-
maxQuantity
|
|
3381
|
-
});
|
|
3382
|
-
});
|
|
3383
|
-
});
|
|
3384
|
-
}
|
|
3385
|
-
/**
|
|
3386
|
-
* DECLARE, then create.
|
|
3387
|
-
*
|
|
3388
|
-
* `createAccessLink` used to set the channel's route to `hosted_link` as a
|
|
3389
|
-
* side effect, which is exactly why the picker could never refuse anything.
|
|
3390
|
-
* The server took that side effect away and now REQUIRES the declaration —
|
|
3391
|
-
* and channels default to `none`, so a create that did not declare first
|
|
3392
|
-
* would 409 on the organizer's very first "Create buyer link".
|
|
3393
|
-
*
|
|
3394
|
-
* So the route is declared here, immediately before the create. It stays ONE
|
|
3395
|
-
* gesture: nothing is declared while the organizer is still filling the form
|
|
3396
|
-
* in (cancelling changes nothing), and if the declaration is the part that is
|
|
3397
|
-
* refused, the review sheet holds this form and finishes the job on the far
|
|
3398
|
-
* side of the acknowledgement.
|
|
3399
|
-
*/
|
|
3400
|
-
async createLink(channelId, input) {
|
|
3401
|
-
if (!await this.ensureHostedLinkRoute(channelId, input)) return;
|
|
3402
|
-
await this.mintLink(channelId, input);
|
|
3403
|
-
}
|
|
3404
|
-
/**
|
|
3405
|
-
* The create half, on its own.
|
|
3406
|
-
*
|
|
3407
|
-
* Separate from `createLink` because the acknowledge path has ALREADY declared
|
|
3408
|
-
* the route — with the very acknowledgement the plain declaration was refused
|
|
3409
|
-
* for. Sending it back through `ensureHostedLinkRoute` would re-derive the
|
|
3410
|
-
* route from a channel list that has not necessarily caught up, and could
|
|
3411
|
-
* refuse the organizer a second time for a decision they just made.
|
|
3412
|
-
*/
|
|
3413
|
-
async mintLink(channelId, input) {
|
|
3414
|
-
try {
|
|
3415
|
-
const reveal = await this.host.api.createAccessLink(this.host.eventKey, channelId, input);
|
|
3416
|
-
this.revealLink(reveal, { channelId });
|
|
3417
|
-
await this.reloadAfterLinkChange(channelId);
|
|
3418
|
-
} catch (err) {
|
|
3419
|
-
this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
|
|
3420
|
-
if (!(err instanceof ManageApiError)) this.host.onError(err);
|
|
3421
|
-
}
|
|
3422
|
-
}
|
|
3423
|
-
/**
|
|
3424
|
-
* Make sure the channel declares the buyer-link route before a link is minted.
|
|
3425
|
-
*
|
|
3426
|
-
* Returns false when the create must NOT proceed — either it was refused, or
|
|
3427
|
-
* the decision has been handed to the switch-review sheet, which resumes it.
|
|
3428
|
-
* A channel already on `hosted_link` costs no request at all, so creating a
|
|
3429
|
-
* second link is the same single call it has always been.
|
|
3430
|
-
*/
|
|
3431
|
-
async ensureHostedLinkRoute(channelId, pendingLink) {
|
|
3432
|
-
const channel = this.list?.channels.find((item) => item.id === channelId);
|
|
3433
|
-
if ((channel?.access?.intent ?? "none") === "hosted_link") return true;
|
|
3434
|
-
try {
|
|
3435
|
-
await this.host.api.setChannelAccessIntent(this.host.eventKey, channelId, "hosted_link");
|
|
3436
|
-
await this.refresh();
|
|
3437
|
-
return true;
|
|
3438
|
-
} catch (err) {
|
|
3439
|
-
if (err instanceof ManageApiError && err.code === "channel_intent_switch_blocked") {
|
|
3440
|
-
this.openDialog({
|
|
3441
|
-
kind: "intentSwitch",
|
|
3442
|
-
channelId,
|
|
3443
|
-
intentTo: "hosted_link",
|
|
3444
|
-
switchBlocked: err.details,
|
|
3445
|
-
pendingLink
|
|
3446
|
-
});
|
|
3447
|
-
return false;
|
|
3448
|
-
}
|
|
3449
|
-
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.");
|
|
3450
|
-
if (!(err instanceof ManageApiError)) this.host.onError(err);
|
|
3451
|
-
return false;
|
|
3452
|
-
}
|
|
3453
|
-
}
|
|
3454
|
-
/**
|
|
3455
|
-
* The ONE-TIME reveal.
|
|
3456
|
-
*
|
|
3457
|
-
* Three things make this unrecoverable rather than merely "not shown twice":
|
|
3458
|
-
*
|
|
3459
|
-
* 1. `url` is a local const. It is never assigned to a field on this class,
|
|
3460
|
-
* never handed to the host, never put in a `DialogState`.
|
|
3461
|
-
* 2. `this.dialog` is cleared FIRST, so `renderDialog()` — the only function
|
|
3462
|
-
* that rebuilds a sheet — has nothing to rebuild this one from.
|
|
3463
|
-
* 3. The string exists in exactly one DOM node inside the scrim. Dismissing
|
|
3464
|
-
* the dialog removes the scrim, and the closure goes with it.
|
|
3465
|
-
*
|
|
3466
|
-
* The server holds only a hash, so even a compromised client cannot ask for it
|
|
3467
|
-
* again. Rotation is the recovery path, and the copy says so.
|
|
3468
|
-
*/
|
|
3469
|
-
revealLink(reveal, opts) {
|
|
3470
|
-
const url = reveal.url;
|
|
3471
|
-
this.dialog = null;
|
|
3472
|
-
const rotated = opts.rotated ? `<div class="slm-ch-alert warn" role="status"><span>\u26A0</span><span>
|
|
3473
|
-
<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>` : "";
|
|
3474
|
-
const policy = accessLinkPolicyLines(reveal.link).map((row) => `<div class="slm-ch-lkrow"><span class="k">${esc(row.k)}</span>
|
|
3475
|
-
<span class="v">${esc(row.v)}</span></div>`).join("");
|
|
3476
|
-
this.renderScrim(`
|
|
3477
|
-
<h3 id="slm-ch-dlg-title">Copy this link now</h3>
|
|
3478
|
-
<p class="sub">This is the only time SeatLayer can show it. We keep just a fingerprint, so it cannot be
|
|
3479
|
-
shown again \u2014 if it is lost, rotate the link for a fresh one.</p>
|
|
3480
|
-
${rotated}
|
|
3481
|
-
<div class="slm-ch-secret" data-ch-lk-url>${esc(url)}</div>
|
|
3482
|
-
<div class="slm-ch-row2" style="margin-top:8px">
|
|
3483
|
-
<button type="button" class="slm-btn" data-ch-lk-copy>Copy link</button>
|
|
3484
|
-
</div>
|
|
3485
|
-
<div class="slm-ch-alert warn" style="margin-top:12px"><span>\u26A0</span>
|
|
3486
|
-
<span>Anyone who opens this link can buy from this allocation. Send it only to the people it is meant
|
|
3487
|
-
for \u2014 forwarding it hands on the same access, and SeatLayer cannot tell the difference.</span></div>
|
|
3488
|
-
<p class="slm-eyebrow" style="margin-top:14px">What this link allows</p>
|
|
3489
|
-
${policy}
|
|
3490
|
-
<div class="foot">
|
|
3491
|
-
<button type="button" class="slm-btn" data-ch-close data-ch-lk-done>I've copied it</button>
|
|
3492
|
-
</div>`, (dialog) => {
|
|
3493
|
-
const copy = dialog.querySelector("[data-ch-lk-copy]");
|
|
3494
|
-
copy?.addEventListener("click", () => {
|
|
3495
|
-
const ok = () => {
|
|
3496
|
-
copy.textContent = "Copied";
|
|
3497
|
-
this.announce("Buyer link copied.");
|
|
3498
|
-
};
|
|
3499
|
-
const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard;
|
|
3500
|
-
if (clipboard?.writeText) {
|
|
3501
|
-
clipboard.writeText(url).then(ok, () => selectSecret(dialog));
|
|
3502
|
-
return;
|
|
3503
|
-
}
|
|
3504
|
-
selectSecret(dialog);
|
|
3505
|
-
});
|
|
3506
|
-
});
|
|
3507
|
-
this.announce("Your buyer link is ready and is shown once.");
|
|
3508
|
-
}
|
|
3509
|
-
/**
|
|
3510
|
-
* Rotate. The organizer must SAY what happens to the buyers already inside —
|
|
3511
|
-
* the confirm stays disabled until one of the two choices is picked, because
|
|
3512
|
-
* the gentle branch and the destructive branch are both real decisions and the
|
|
3513
|
-
* server refuses (422 `end_active_sessions_required`) to guess either.
|
|
3514
|
-
*/
|
|
3515
|
-
renderLinkRotateDialog(state) {
|
|
3516
|
-
const link = this.linkById(state.linkId);
|
|
3517
|
-
if (!link || !this.caps.manage) {
|
|
3518
|
-
this.closeDialog();
|
|
3519
|
-
return;
|
|
3520
|
-
}
|
|
3521
|
-
const sessions = link.activeSessions ?? 0;
|
|
3522
|
-
const warning = sessions ? `<div class="slm-ch-alert warn"><span>\u26A0</span>
|
|
3523
|
-
<span><b>${sessions.toLocaleString()} buyer${sessions === 1 ? "" : "s"}</b> got in with the current link
|
|
3524
|
-
and still ${sessions === 1 ? "has" : "have"} active access.</span></div>` : "";
|
|
3525
|
-
this.renderScrim(`
|
|
3526
|
-
<h3 id="slm-ch-dlg-title">Rotate the ${esc(link.label || "buyer")} link?</h3>
|
|
3527
|
-
<p class="sub">The current link stops opening immediately and cannot be restored. You will get a new
|
|
3528
|
-
link to copy \u2014 shown once.</p>
|
|
3529
|
-
${warning}
|
|
3530
|
-
<label class="slm-ch-radio">
|
|
3531
|
-
<input type="radio" name="slm-ch-rot" value="keep" data-ch-rot />
|
|
3532
|
-
<span><b>Let them finish</b><span class="why">Access already handed out expires on its own; seats in
|
|
3533
|
-
checkout are untouched. Every new visit needs the new link.</span></span>
|
|
3534
|
-
</label>
|
|
3535
|
-
<label class="slm-ch-radio">
|
|
3536
|
-
<input type="radio" name="slm-ch-rot" value="end" data-ch-rot />
|
|
3537
|
-
<span><b>End their access now</b><span class="why">All access from the old link ends immediately.
|
|
3538
|
-
Buyers part-way through choosing seats lose access.</span></span>
|
|
3539
|
-
</label>
|
|
3540
|
-
<p class="slm-note">Choose one \u2014 SeatLayer will not decide this for you.</p>
|
|
3541
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
3542
|
-
<div class="foot">
|
|
3543
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
3544
|
-
<button type="button" class="slm-btn" data-ch-lk-rotate disabled>Rotate and copy new link</button>
|
|
3545
|
-
</div>`, (dialog) => {
|
|
3546
|
-
const confirm = dialog.querySelector("[data-ch-lk-rotate]");
|
|
3547
|
-
dialog.querySelectorAll("[data-ch-rot]").forEach((radio) => {
|
|
3548
|
-
radio.addEventListener("change", () => {
|
|
3549
|
-
confirm.disabled = false;
|
|
3550
|
-
});
|
|
3551
|
-
});
|
|
3552
|
-
confirm.addEventListener("click", () => {
|
|
3553
|
-
const picked = [...dialog.querySelectorAll("[data-ch-rot]")].find((radio) => radio.checked);
|
|
3554
|
-
if (!picked) {
|
|
3555
|
-
this.showDialogError(accessLinkErrorCopy({ code: "end_active_sessions_required" }));
|
|
3556
|
-
return;
|
|
3557
|
-
}
|
|
3558
|
-
void this.rotateLink(state.channelId, link.id, picked.value === "end");
|
|
3559
|
-
});
|
|
3560
|
-
});
|
|
3561
|
-
}
|
|
3562
|
-
async rotateLink(channelId, linkId, endActiveSessions) {
|
|
3563
|
-
try {
|
|
3564
|
-
const reveal = await this.host.api.rotateAccessLink(
|
|
3565
|
-
this.host.eventKey,
|
|
3566
|
-
channelId,
|
|
3567
|
-
linkId,
|
|
3568
|
-
endActiveSessions
|
|
3569
|
-
);
|
|
3570
|
-
this.revealLink(reveal, { channelId, rotated: true });
|
|
3571
|
-
await this.reloadAfterLinkChange(channelId);
|
|
3572
|
-
} catch (err) {
|
|
3573
|
-
this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
|
|
3574
|
-
if (!(err instanceof ManageApiError)) this.host.onError(err);
|
|
3575
|
-
}
|
|
3576
|
-
}
|
|
3577
|
-
renderLinkRevokeDialog(state) {
|
|
3578
|
-
const link = this.linkById(state.linkId);
|
|
3579
|
-
if (!link || !this.caps.manage) {
|
|
3580
|
-
this.closeDialog();
|
|
3581
|
-
return;
|
|
3582
|
-
}
|
|
3583
|
-
const sessions = link.activeSessions ?? 0;
|
|
3584
|
-
this.renderScrim(`
|
|
3585
|
-
<h3 id="slm-ch-dlg-title">Revoke the ${esc(link.label || "buyer")} link?</h3>
|
|
3586
|
-
<p class="sub">It stops opening immediately and cannot be restored \u2014 there is no undo, and no way to
|
|
3587
|
-
bring the same URL back. Seats already bought through it keep their sale.</p>
|
|
3588
|
-
${sessions ? `<label class="slm-ch-radio">
|
|
3589
|
-
<input type="checkbox" data-ch-lk-endsessions />
|
|
3590
|
-
<span><b>Also end access for the ${sessions.toLocaleString()}
|
|
3591
|
-
buyer${sessions === 1 ? "" : "s"} already inside</b><span class="why">Leave this off and they can
|
|
3592
|
-
finish what they started; new visits are refused either way.</span></span>
|
|
3593
|
-
</label>` : ""}
|
|
3594
|
-
<p class="slm-ch-err" data-ch-error ${state.error ? "" : "hidden"}>${esc(state.error ?? "")}</p>
|
|
3595
|
-
<div class="foot">
|
|
3596
|
-
<button type="button" class="quiet" data-ch-close>Cancel</button>
|
|
3597
|
-
<button type="button" class="slm-btn danger" data-ch-lk-revoke>Revoke link</button>
|
|
3598
|
-
</div>`, (dialog) => {
|
|
3599
|
-
dialog.querySelector("[data-ch-lk-revoke]")?.addEventListener("click", () => {
|
|
3600
|
-
const end = dialog.querySelector("[data-ch-lk-endsessions]")?.checked ?? false;
|
|
3601
|
-
void this.revokeLink(state.channelId, link.id, end);
|
|
3602
|
-
});
|
|
3603
|
-
});
|
|
3604
|
-
}
|
|
3605
|
-
async revokeLink(channelId, linkId, endActiveSessions) {
|
|
3606
|
-
try {
|
|
3607
|
-
const res = await this.host.api.revokeAccessLink(
|
|
3608
|
-
this.host.eventKey,
|
|
3609
|
-
channelId,
|
|
3610
|
-
linkId,
|
|
3611
|
-
endActiveSessions
|
|
3612
|
-
);
|
|
3613
|
-
this.closeDialog();
|
|
3614
|
-
await this.reloadAfterLinkChange(channelId);
|
|
3615
|
-
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");
|
|
3616
|
-
} catch (err) {
|
|
3617
|
-
this.showDialogError(accessLinkErrorCopy(err instanceof ManageApiError ? err : void 0));
|
|
3618
|
-
if (!(err instanceof ManageApiError)) this.host.onError(err);
|
|
3619
|
-
}
|
|
3620
|
-
}
|
|
3621
|
-
// ---- compact detents ------------------------------------------------------
|
|
3622
|
-
applySheetClasses() {
|
|
3623
|
-
const root = this.host.root;
|
|
3624
|
-
const compact = this.host.isCompact();
|
|
3625
|
-
root.classList.toggle("ch-sheet", compact && this.active);
|
|
3626
|
-
for (const detent of ["collapsed", "medium", "full"]) {
|
|
3627
|
-
root.classList.toggle(`detent-${detent}`, compact && this.active && this.detent === detent);
|
|
3628
|
-
}
|
|
3629
|
-
this.host.setMapInert(compact && this.active && this.detent === "full");
|
|
3630
|
-
}
|
|
3631
|
-
cycleDetent() {
|
|
3632
|
-
const order = ["collapsed", "medium", "full"];
|
|
3633
|
-
this.detent = order[(order.indexOf(this.detent) + 1) % order.length];
|
|
3634
|
-
this.applySheetClasses();
|
|
3635
|
-
}
|
|
3636
|
-
/** Back/Close from the full detent returns to the previous one and keeps the
|
|
3637
|
-
* selection — losing a hard-won selection to a Back press is unforgivable. */
|
|
3638
|
-
handleBack() {
|
|
3639
|
-
if (this.scrimEl) {
|
|
3640
|
-
this.closeDialog();
|
|
3641
|
-
return true;
|
|
3642
|
-
}
|
|
3643
|
-
if (this.host.isCompact() && this.detent === "full") {
|
|
3644
|
-
this.detent = "medium";
|
|
3645
|
-
this.applySheetClasses();
|
|
3646
|
-
return true;
|
|
3647
|
-
}
|
|
3648
|
-
return false;
|
|
3649
|
-
}
|
|
3650
|
-
};
|
|
3651
|
-
|
|
3652
|
-
export {
|
|
3653
|
-
ManageApiError,
|
|
3654
|
-
ManageApi,
|
|
3655
|
-
PUBLIC_CHANNEL_ID,
|
|
3656
|
-
PUBLIC_CHANNEL_NAME,
|
|
3657
|
-
isPublicChannelId,
|
|
3658
|
-
markerLetter,
|
|
3659
|
-
suggestMarker,
|
|
3660
|
-
markerOf,
|
|
3661
|
-
selectionSources,
|
|
3662
|
-
planAssignment,
|
|
3663
|
-
mutationCount,
|
|
3664
|
-
needsMoveConfirmation,
|
|
3665
|
-
bucketRows,
|
|
3666
|
-
retryAfterCopy,
|
|
3667
|
-
accessLine,
|
|
3668
|
-
accessIntentLabel,
|
|
3669
|
-
accessIntentDescription,
|
|
3670
|
-
intentForbidsCopy,
|
|
3671
|
-
intentSwitchBlockedCopy,
|
|
3672
|
-
ACCESS_LINK_DEFAULTS,
|
|
3673
|
-
accessLinkBadge,
|
|
3674
|
-
accessLinkIsLive,
|
|
3675
|
-
accessLinkPolicyLines,
|
|
3676
|
-
accessLinkErrorCopy,
|
|
3677
|
-
dropReviewRows,
|
|
3678
|
-
stateBadge,
|
|
3679
|
-
CHANNELS_CSS,
|
|
3680
|
-
bucketRowsHtml,
|
|
3681
|
-
ChannelsMode
|
|
3682
|
-
};
|
|
3683
|
-
//# sourceMappingURL=chunk-5URCWBHM.js.map
|