@seatlayer/js 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -500,7 +500,7 @@ var CSS = `
500
500
  .sl-body{display:flex;flex:1;min-height:0}
501
501
  .sl-map{position:relative;flex:1;min-width:0}
502
502
  .sl-map-host{position:absolute;inset:0}
503
- .sl-side{width:300px;flex:none;border-left:1px solid var(--sl-line);display:flex;flex-direction:column;min-height:0;overflow-y:auto}
503
+ .sl-side{width:300px;flex:none;border-left:1px solid var(--sl-line);display:flex;flex-direction:column;min-height:0;overflow:hidden}
504
504
 
505
505
  /* narrow (container < 640px): map-first \u2014 the map claims ~80-85% of the
506
506
  container and the side panel becomes a PEEKING bottom sheet (AXS/Ticketmaster
@@ -583,6 +583,7 @@ var CSS = `
583
583
  .sl-price-select{min-height:32px;max-width:130px;padding:5px 28px 5px 9px;border:1px solid var(--sl-line);border-radius:9px;
584
584
  background:var(--sl-surface);color:var(--sl-text);font:inherit;font-size:11px;font-weight:750;letter-spacing:0;text-transform:none}
585
585
  .sl-prices{padding:5px 14px 10px;border-bottom:1px solid var(--sl-line)}
586
+ .sl-prices-sec,.sl-prices,.sl-seats-sec{flex:none}
586
587
  .sl-price-row{display:flex;align-items:center;gap:7px;min-height:28px;font-size:12px}
587
588
  .sl-dot{width:9px;height:9px;border-radius:50%;flex:none}
588
589
  .sl-price-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
@@ -599,7 +600,8 @@ var CSS = `
599
600
  /* tray */
600
601
  .sl-seats-sec{display:flex;align-items:center;justify-content:space-between;gap:10px;padding-top:13px}
601
602
  .sl-seat-summary{font-size:10px;letter-spacing:0;text-transform:none;white-space:nowrap}
602
- .sl-tray{flex:1;padding:10px 14px;display:flex;flex-direction:column;gap:7px;min-height:0}
603
+ .sl-tray{flex:1;padding:10px 14px 14px;display:flex;flex-direction:column;gap:7px;min-height:0;overflow-y:auto;
604
+ overscroll-behavior:contain;scrollbar-gutter:stable}
603
605
  .sl-tray-hint{font-size:12.5px;color:var(--sl-muted);line-height:1.5}
604
606
  .sl-chip{position:relative;display:grid;grid-template-columns:24px minmax(0,1fr) auto 30px;align-items:center;gap:8px;
605
607
  min-height:53px;padding:7px 7px 7px 9px;border:1px solid var(--sl-line);border-radius:var(--sl-r-sm);
@@ -634,16 +636,22 @@ var CSS = `
634
636
  .sl-ga-qty span{min-width:16px;text-align:center;font-weight:800;font-variant-numeric:tabular-nums}
635
637
 
636
638
  /* footer */
637
- .sl-foot{padding:12px 16px 14px;border-top:1px solid var(--sl-line);flex:none}
638
- .sl-hold-note{display:none;align-items:center;gap:9px;margin-bottom:10px;padding:9px 10px;border-radius:var(--sl-r-sm);
639
+ .sl-foot{position:relative;z-index:2;padding:12px 16px 14px;border-top:1px solid var(--sl-line);flex:none;
640
+ background:var(--sl-bg);box-shadow:0 -10px 24px -22px rgba(0,0,0,.72)}
641
+ .sl-hold-note{display:none;align-items:center;gap:7px;margin-bottom:8px;padding:7px 8px;border-radius:var(--sl-r-sm);
639
642
  border:1px solid var(--sl-line);background:color-mix(in srgb,var(--sl-accent) 7%,var(--sl-surface));
640
643
  box-shadow:inset 3px 0 0 color-mix(in srgb,var(--sl-accent) 72%,transparent);
641
644
  font-size:11.5px;line-height:1.35;color:var(--sl-muted)}
642
645
  .sl-hold-note.on{display:flex;animation:slNoticeIn .38s cubic-bezier(.2,.8,.2,1) both}
643
646
  .sl-hold-note svg{width:16px;height:16px;flex:none;stroke:var(--sl-accent);stroke-width:2.4;fill:none;
644
647
  stroke-linecap:round;stroke-linejoin:round}
645
- .sl-hold-note b{display:block;color:var(--sl-text);font-size:12px}
646
- .sl-hold-copy{display:block}
648
+ .sl-hold-note b{display:block;color:var(--sl-text);font-size:11.5px;white-space:nowrap}
649
+ .sl-hold-copy{display:block;white-space:nowrap;font-size:10.5px}
650
+ .sl-hold-note>span{flex:1;min-width:0}
651
+ .sl-hold-change{flex:none;min-height:30px;padding:5px 8px;border-radius:8px;border:1px solid var(--sl-line);
652
+ color:var(--sl-text);font-size:10.5px;font-weight:750;white-space:nowrap}
653
+ .sl-hold-change:hover,.sl-hold-change:focus-visible{border-color:var(--sl-accent);color:var(--sl-accent)}
654
+ .sl-hold-change:disabled{opacity:.58;cursor:wait}
647
655
  .sl-total{display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:10px}
648
656
  .sl-total b{font-size:17px;font-variant-numeric:tabular-nums}
649
657
  .sl-value-pop{animation:slValuePop .32s cubic-bezier(.2,.8,.2,1)}
@@ -701,6 +709,9 @@ var CSS = `
701
709
  font-size:12.5px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .22s,transform .22s;white-space:nowrap;
702
710
  overflow:hidden;text-overflow:ellipsis}
703
711
  .sl-toast.on{opacity:1;transform:translateY(0) scale(1)}
712
+ .sl-toast.has-action{pointer-events:auto;display:flex;align-items:center;gap:12px;padding-right:8px}
713
+ .sl-toast-action{min-height:30px;padding:5px 10px;border-radius:999px;background:var(--sl-accent);color:var(--sl-accent-ink);
714
+ font:inherit;font-weight:800}
704
715
  .sl-toast[data-tone="error"]{border-color:#ef4444}
705
716
  .sl-toast[data-tone="warning"]{border-color:var(--sl-accent)}
706
717
  .sl-toast[data-tone="success"]{border-color:#22c55e}
@@ -804,6 +815,12 @@ var CSS = `
804
815
  box-shadow:0 8px 18px color-mix(in srgb,var(--sl-accent) 18%,transparent)}
805
816
  .sl-picker .sl-ba-go:hover{filter:brightness(1.06)}
806
817
  .sl-picker .sl-ba-go:disabled{opacity:.62;cursor:wait}
818
+ .sl-ba-replace{grid-column:1/-1;padding:3px 0 1px}
819
+ .sl-ba-replace b{display:block;font-size:12.5px}
820
+ .sl-ba-replace span{display:block;margin-top:3px;color:var(--sl-muted);font-size:10.5px;line-height:1.35}
821
+ .sl-ba-actions{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:7px}
822
+ .sl-ba-actions button{min-height:36px;border-radius:9px;border:1px solid var(--sl-line);font-size:11.5px;font-weight:800}
823
+ .sl-ba-actions .replace{border-color:var(--sl-accent);background:var(--sl-accent);color:var(--sl-accent-ink)}
807
824
  .sl-picker[data-layout="narrow"] .sl-ba{padding:11px}
808
825
  .sl-picker[data-layout="narrow"] .sl-ba-copy .wide{display:none}
809
826
  .sl-picker[data-layout="narrow"] .sl-ba-copy .narrow{display:inline}
@@ -995,6 +1012,8 @@ var SeatPicker = class _SeatPicker {
995
1012
  this.a11yFilter = "all";
996
1013
  this.baQty = 2;
997
1014
  this.baCat = "";
1015
+ this.bestAvailableConfirm = false;
1016
+ this.releasingHold = false;
998
1017
  // arena / multi-floor / seat-view chrome
999
1018
  this.rungsEl = null;
1000
1019
  this.floorsEl = null;
@@ -1042,10 +1061,11 @@ var SeatPicker = class _SeatPicker {
1042
1061
  this.opts = { ...options, confirmSelection: options.confirmSelection ?? true };
1043
1062
  this.apiBase = (options.apiBase ?? DEFAULT_API_BASE2).replace(/\/+$/, "");
1044
1063
  this.api = new PubApi(this.apiBase);
1064
+ this.maxTickets = Math.max(1, Math.floor(options.maxSelection ?? DEFAULT_MAX_SELECTION2));
1045
1065
  this.controller = new import_core2.PickerController({
1046
1066
  transport: this.api,
1047
1067
  eventKey: options.event,
1048
- maxSelection: options.maxSelection ?? DEFAULT_MAX_SELECTION2,
1068
+ maxSelection: this.maxTickets,
1049
1069
  currency: options.currency,
1050
1070
  flashOnLiveChange: true,
1051
1071
  colorblindSafe: options.colorblindSafe,
@@ -1080,6 +1100,9 @@ var SeatPicker = class _SeatPicker {
1080
1100
  onDeselect: (seat) => {
1081
1101
  if (this.confirmSeat?.id === seat.id) this.dismissConfirm();
1082
1102
  },
1103
+ onSelectionLimit: () => {
1104
+ this.toast(`You can select up to ${this.maxTickets} tickets for this order.`, "warning");
1105
+ },
1083
1106
  onViewChange: () => {
1084
1107
  this.reanchorConfirm();
1085
1108
  this.syncRung();
@@ -1142,6 +1165,10 @@ var SeatPicker = class _SeatPicker {
1142
1165
  if (picker.confirmSeat) {
1143
1166
  e.preventDefault();
1144
1167
  picker.cancelConfirm();
1168
+ } else if (picker.bestAvailableConfirm) {
1169
+ e.preventDefault();
1170
+ picker.bestAvailableConfirm = false;
1171
+ picker.syncTray();
1145
1172
  } else {
1146
1173
  close();
1147
1174
  }
@@ -1165,10 +1192,17 @@ var SeatPicker = class _SeatPicker {
1165
1192
  this.root = root;
1166
1193
  mount.appendChild(root);
1167
1194
  root.addEventListener("keydown", (e) => {
1168
- if (e.key !== "Escape" || !this.confirmSeat) return;
1169
- e.preventDefault();
1170
- e.stopPropagation();
1171
- this.cancelConfirm();
1195
+ if (e.key !== "Escape") return;
1196
+ if (this.confirmSeat) {
1197
+ e.preventDefault();
1198
+ e.stopPropagation();
1199
+ this.cancelConfirm();
1200
+ } else if (this.bestAvailableConfirm) {
1201
+ e.preventDefault();
1202
+ e.stopPropagation();
1203
+ this.bestAvailableConfirm = false;
1204
+ this.syncTray();
1205
+ }
1172
1206
  });
1173
1207
  Object.entries(resolveTokens(void 0, this.opts.theme)).forEach(([k, v]) => root.style.setProperty(k, v));
1174
1208
  root.innerHTML = `
@@ -1216,6 +1250,7 @@ var SeatPicker = class _SeatPicker {
1216
1250
  <div class="sl-hold-note" data-ref="holdNote" role="status" aria-live="polite">
1217
1251
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
1218
1252
  <span><b data-ref="holdTitle">Seats secured</b><span class="sl-hold-copy" data-ref="holdCopy">Checkout timer is running.</span></span>
1253
+ <button type="button" class="sl-hold-change" data-ref="holdChange" aria-label="Release held tickets and choose different seats">Change</button>
1219
1254
  </div>
1220
1255
  <div class="sl-total"><span data-ref="count"></span><b data-ref="total"></b></div>
1221
1256
  <button type="button" class="sl-cta" data-ref="cta" disabled></button>
@@ -1293,6 +1328,7 @@ var SeatPicker = class _SeatPicker {
1293
1328
  if (this.tipEl && this.tipEl.style.display !== "none") this.placeTooltip();
1294
1329
  });
1295
1330
  this.els.cta.addEventListener("click", () => void this.handleCta());
1331
+ this.els.holdChange?.addEventListener("click", () => void this.handleChangeSeats());
1296
1332
  const canvasHost = document.createElement("div");
1297
1333
  canvasHost.style.cssText = "position:absolute;inset:0";
1298
1334
  this.mapHost.appendChild(canvasHost);
@@ -1499,15 +1535,51 @@ var SeatPicker = class _SeatPicker {
1499
1535
  const heldItems = this.hold?.items ?? [];
1500
1536
  const heldLabels = new Set(heldItems.map((item) => item.label));
1501
1537
  const pendingSeats = this.committedSelection().filter((seat) => !heldLabels.has(seat.label)).length;
1538
+ return pendingSeats + this.pendingGACount();
1539
+ }
1540
+ heldGACounts() {
1502
1541
  const heldGA = /* @__PURE__ */ new Map();
1503
- for (const item of heldItems.filter((candidate) => candidate.objectType === "ga")) {
1542
+ for (const item of (this.hold?.items ?? []).filter((candidate) => candidate.objectType === "ga")) {
1504
1543
  heldGA.set(item.objectId, (heldGA.get(item.objectId) ?? 0) + (item.quantity ?? 1));
1505
1544
  }
1506
- const pendingGA = [...this.gaQty.entries()].reduce(
1545
+ return heldGA;
1546
+ }
1547
+ pendingGACount() {
1548
+ const heldGA = this.heldGACounts();
1549
+ return [...this.gaQty.entries()].reduce(
1507
1550
  (sum, [areaId, qty]) => sum + Math.max(0, qty - (heldGA.get(areaId) ?? 0)),
1508
1551
  0
1509
1552
  );
1510
- return pendingSeats + pendingGA;
1553
+ }
1554
+ heldTicketCount() {
1555
+ return (this.hold?.items ?? []).reduce((sum, item) => sum + (item.quantity ?? 1), 0);
1556
+ }
1557
+ totalTicketCount() {
1558
+ const heldLabels = new Set((this.hold?.items ?? []).map((item) => item.label));
1559
+ const freshSeats = this.committedSelection().filter((seat) => !heldLabels.has(seat.label)).length;
1560
+ return this.heldTicketCount() + freshSeats + this.pendingGACount();
1561
+ }
1562
+ /** Held tickets and standing quantities consume the same order-wide cap. */
1563
+ updateSelectionCapacity() {
1564
+ const heldLabels = new Set((this.hold?.items ?? []).map((item) => item.label));
1565
+ const selectedHeld = this.committedSelection().filter((seat) => heldLabels.has(seat.label)).length;
1566
+ const remaining = Math.max(0, this.maxTickets - this.heldTicketCount() - this.pendingGACount());
1567
+ this.controller.setMaxSelection(selectedHeld + remaining);
1568
+ }
1569
+ canAddTicket() {
1570
+ if (this.totalTicketCount() < this.maxTickets) return true;
1571
+ this.toast(`You can select up to ${this.maxTickets} tickets for this order.`, "warning");
1572
+ return false;
1573
+ }
1574
+ pendingGATotal(gaAreas) {
1575
+ const heldGA = /* @__PURE__ */ new Map();
1576
+ for (const item of (this.hold?.items ?? []).filter((candidate) => candidate.objectType === "ga")) {
1577
+ heldGA.set(item.objectId, (heldGA.get(item.objectId) ?? 0) + (item.quantity ?? 1));
1578
+ }
1579
+ return gaAreas.reduce(
1580
+ (sum, area) => sum + area.price * Math.max(0, (this.gaQty.get(area.id) ?? 0) - (heldGA.get(area.id) ?? 0)),
1581
+ 0
1582
+ );
1511
1583
  }
1512
1584
  syncCta(count = this.lastTrayCount, pending = this.pendingSelectionCount()) {
1513
1585
  const cta = this.els.cta;
@@ -2234,6 +2306,7 @@ var SeatPicker = class _SeatPicker {
2234
2306
  }
2235
2307
  syncTray() {
2236
2308
  if (!this.els.tray) return;
2309
+ this.updateSelectionCapacity();
2237
2310
  const seats = this.committedSelection();
2238
2311
  const gaAreas = this.controller.getGAAreas();
2239
2312
  const heldItems = this.hold?.items ?? [];
@@ -2246,9 +2319,7 @@ var SeatPicker = class _SeatPicker {
2246
2319
  }
2247
2320
  if (!this.hold) {
2248
2321
  const cats = this.controller.doc?.categories ?? [];
2249
- parts.push(
2250
- `<div class="sl-ba"><div class="sl-ba-title"><span class="spark" aria-hidden="true">\u2726</span>Find the best seats together</div><div class="sl-ba-copy"><span class="wide">We\u2019ll choose the closest available group for you.</span><span class="narrow">Closest available group, chosen instantly.</span></div>` + (cats.length > 1 ? `<select aria-label="Preferred ticket type" data-ba-cat><option value="">Any ticket type</option>` + cats.map((c) => `<option value="${c.key}"${this.baCat === c.key ? " selected" : ""}>${c.label}</option>`).join("") + `</select>` : `<span aria-hidden="true"></span>`) + `<div class="sl-ba-qty"><button type="button" data-ba="-1" aria-label="Fewer seats">\u2212</button><span>${this.baQty}</span><button type="button" data-ba="1" aria-label="More seats">+</button></div><button type="button" class="sl-ba-go"${this.bestAvailableBusy ? " disabled" : ""}>` + (this.bestAvailableBusy ? `<span class="sl-ba-spin" aria-hidden="true"></span>Finding the best seats\u2026` : `Find ${this.baQty} best ${this.baQty === 1 ? "seat" : "seats"}`) + `</button></div>`
2251
- );
2322
+ parts.push(this.bestAvailableConfirm ? `<div class="sl-ba" role="alert"><div class="sl-ba-title"><span class="spark" aria-hidden="true">\u2726</span>Replace your current choices?</div><div class="sl-ba-replace"><b>We\u2019ll find ${this.baQty} seats together.</b><span>Your manually selected tickets will be removed only after a new group is secured.</span></div><div class="sl-ba-actions"><button type="button" data-ba-cancel>Keep mine</button><button type="button" class="replace" data-ba-replace>Find new seats</button></div></div>` : `<div class="sl-ba"><div class="sl-ba-title"><span class="spark" aria-hidden="true">\u2726</span>Find the best seats together</div><div class="sl-ba-copy"><span class="wide">We\u2019ll choose the closest available group for you.</span><span class="narrow">Closest available group, chosen instantly.</span></div>` + (cats.length > 1 ? `<select aria-label="Preferred ticket type" data-ba-cat><option value="">Any ticket type</option>` + cats.map((c) => `<option value="${c.key}"${this.baCat === c.key ? " selected" : ""}>${c.label}</option>`).join("") + `</select>` : `<span aria-hidden="true"></span>`) + `<div class="sl-ba-qty"><button type="button" data-ba="-1" aria-label="Fewer seats">\u2212</button><span>${this.baQty}</span><button type="button" data-ba="1" aria-label="More seats">+</button></div><button type="button" class="sl-ba-go"${this.bestAvailableBusy ? " disabled" : ""}>` + (this.bestAvailableBusy ? `<span class="sl-ba-spin" aria-hidden="true"></span>Finding the best seats\u2026` : `Find ${this.baQty} best ${this.baQty === 1 ? "seat" : "seats"}`) + `</button></div>`);
2252
2323
  }
2253
2324
  for (const item of heldItems) {
2254
2325
  const itemKey = `held:${item.label}`;
@@ -2283,7 +2354,7 @@ var SeatPicker = class _SeatPicker {
2283
2354
  this.lastTrayKeys = nextTrayKeys;
2284
2355
  this.els.tray.querySelectorAll("[data-ba]").forEach((btn) => {
2285
2356
  btn.addEventListener("click", () => {
2286
- this.baQty = Math.max(1, Math.min(8, this.baQty + Number(btn.dataset.ba)));
2357
+ this.baQty = Math.max(1, Math.min(this.maxTickets, this.baQty + Number(btn.dataset.ba)));
2287
2358
  this.syncTray();
2288
2359
  });
2289
2360
  });
@@ -2291,6 +2362,21 @@ var SeatPicker = class _SeatPicker {
2291
2362
  this.baCat = e.target.value;
2292
2363
  });
2293
2364
  this.els.tray.querySelector(".sl-ba-go")?.addEventListener("click", () => {
2365
+ if (this.pendingSelectionCount() > 0) {
2366
+ this.bestAvailableConfirm = true;
2367
+ this.syncTray();
2368
+ this.els.tray.querySelector("[data-ba-replace]")?.focus();
2369
+ return;
2370
+ }
2371
+ void this.bestAvailable(this.baQty, this.baCat || void 0);
2372
+ });
2373
+ this.els.tray.querySelector("[data-ba-cancel]")?.addEventListener("click", () => {
2374
+ this.bestAvailableConfirm = false;
2375
+ this.syncTray();
2376
+ this.els.tray.querySelector(".sl-ba-go")?.focus();
2377
+ });
2378
+ this.els.tray.querySelector("[data-ba-replace]")?.addEventListener("click", () => {
2379
+ this.bestAvailableConfirm = false;
2294
2380
  void this.bestAvailable(this.baQty, this.baCat || void 0);
2295
2381
  });
2296
2382
  this.els.tray.querySelectorAll(".sl-chip .rm").forEach((btn) => {
@@ -2301,12 +2387,26 @@ var SeatPicker = class _SeatPicker {
2301
2387
  return;
2302
2388
  }
2303
2389
  const id = chip.dataset.seat;
2304
- if (this.reducedMotion()) {
2390
+ const label = chip.querySelector("b")?.textContent ?? "Seat";
2391
+ const remove = () => {
2305
2392
  this.controller.deselect([id]);
2393
+ this.toast(`${label} removed.`, "neutral", {
2394
+ label: "Undo",
2395
+ onClick: () => {
2396
+ const restored = this.controller.select([id]);
2397
+ this.toast(
2398
+ restored.length ? `${label} restored.` : `${label} is no longer available.`,
2399
+ restored.length ? "success" : "warning"
2400
+ );
2401
+ }
2402
+ });
2403
+ };
2404
+ if (this.reducedMotion()) {
2405
+ remove();
2306
2406
  return;
2307
2407
  }
2308
2408
  chip.classList.add("sl-leave");
2309
- this.scheduleMotion(() => this.controller.deselect([id]), 150);
2409
+ this.scheduleMotion(remove, 150);
2310
2410
  });
2311
2411
  });
2312
2412
  this.els.tray.querySelectorAll(".sl-chip .tier").forEach((sel) => {
@@ -2323,13 +2423,15 @@ var SeatPicker = class _SeatPicker {
2323
2423
  const areaEl = btn.closest(".sl-ga");
2324
2424
  const id = areaEl.dataset.ga;
2325
2425
  const area = gaAreas.find((a) => a.id === id);
2326
- const next = Math.max(0, Math.min(area?.available ?? 0, (this.gaQty.get(id) ?? 0) + Number(btn.dataset.d)));
2426
+ const delta = Number(btn.dataset.d);
2427
+ if (delta > 0 && !this.canAddTicket()) return;
2428
+ const next = Math.max(0, Math.min(area?.available ?? 0, (this.gaQty.get(id) ?? 0) + delta));
2327
2429
  this.gaQty.set(id, next);
2328
2430
  this.syncTray();
2329
2431
  });
2330
2432
  });
2331
- const gaTotal = gaAreas.reduce((sum, a) => sum + a.price * (this.gaQty.get(a.id) ?? 0), 0);
2332
- const gaCount = [...this.gaQty.values()].reduce((a, b) => a + b, 0);
2433
+ const gaTotal = this.pendingGATotal(gaAreas);
2434
+ const gaCount = this.pendingGACount();
2333
2435
  const heldTotal = heldItems.reduce((sum, item) => sum + item.unitPrice * (item.quantity ?? 1), 0);
2334
2436
  const heldCount = heldItems.reduce((sum, item) => sum + (item.quantity ?? 1), 0);
2335
2437
  const freshSeats = seats.filter((seat) => !heldLabels.has(seat.label));
@@ -2349,10 +2451,15 @@ var SeatPicker = class _SeatPicker {
2349
2451
  if (this.hold) {
2350
2452
  const securedCount = heldCount || this.hold.seats?.length || 0;
2351
2453
  if (this.els.holdTitle) {
2352
- this.els.holdTitle.textContent = `${securedCount} ${securedCount === 1 ? "seat" : "seats"} secured`;
2454
+ this.els.holdTitle.textContent = `${securedCount} secured`;
2353
2455
  }
2354
2456
  if (this.els.holdCopy) {
2355
- this.els.holdCopy.textContent = pendingCount ? `${pendingCount} more selected \u2014 secure before checkout.` : "Checkout timer is running.";
2457
+ this.els.holdCopy.textContent = pendingCount ? `${pendingCount} more selected` : "Checkout timer running";
2458
+ }
2459
+ const change = this.els.holdChange;
2460
+ if (change) {
2461
+ change.disabled = this.releasingHold;
2462
+ change.textContent = this.releasingHold ? "Releasing\u2026" : "Change";
2356
2463
  }
2357
2464
  }
2358
2465
  if (count !== previousCount) this.animateOnce(this.els.count, "sl-value-pop", 380);
@@ -2404,7 +2511,30 @@ var SeatPicker = class _SeatPicker {
2404
2511
  if (button?.isConnected) button.disabled = false;
2405
2512
  }
2406
2513
  }
2514
+ async handleChangeSeats() {
2515
+ if (!this.hold || this.releasingHold) return;
2516
+ this.releasingHold = true;
2517
+ const button = this.els.holdChange;
2518
+ if (button) {
2519
+ button.disabled = true;
2520
+ button.textContent = "Releasing\u2026";
2521
+ }
2522
+ try {
2523
+ await this.release();
2524
+ if (!this.hold) this.toast("Held tickets released. Choose your new seats.", "success");
2525
+ } finally {
2526
+ this.releasingHold = false;
2527
+ if (button?.isConnected) {
2528
+ button.disabled = false;
2529
+ button.textContent = "Change";
2530
+ }
2531
+ }
2532
+ }
2407
2533
  async handleCta() {
2534
+ if (this.totalTicketCount() > this.maxTickets) {
2535
+ this.toast(`Remove tickets until your order has ${this.maxTickets} or fewer.`, "warning");
2536
+ return;
2537
+ }
2408
2538
  const committed = this.committedSelection();
2409
2539
  if (this.hold && !committed.some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
2410
2540
  const seats = this.hold.seats ?? committed;
@@ -2442,7 +2572,10 @@ var SeatPicker = class _SeatPicker {
2442
2572
  this.opts.onCheckout?.(hold, hold.seats ?? chosenSeats, this.buildHandoff(hold));
2443
2573
  } catch (err) {
2444
2574
  this.opts.onError?.(err);
2445
- this.toast("One or more seats were just taken. Please pick again.", "error");
2575
+ const problem = err;
2576
+ const labels = (problem.conflicts ?? []).map((conflict) => conflict.label).filter(Boolean).slice(0, 3);
2577
+ const message = problem.reason === "event_closed" ? "Seat sales have closed for this event." : labels.length ? `${labels.join(", ")} ${labels.length === 1 ? "is" : "are"} no longer available. Choose another ${labels.length === 1 ? "seat" : "group"}.` : "One or more seats were just taken. Please pick again.";
2578
+ this.toast(message, "error");
2446
2579
  this.setCtaPhase("idle");
2447
2580
  } finally {
2448
2581
  this.holdingLabels.clear();
@@ -2565,15 +2698,28 @@ var SeatPicker = class _SeatPicker {
2565
2698
  hold ? this.buildHandoff(hold) : null
2566
2699
  );
2567
2700
  }
2568
- toast(msg, tone = "neutral") {
2701
+ toast(msg, tone = "neutral", action) {
2569
2702
  const el = this.els.toast;
2570
2703
  if (!el) return;
2571
- el.textContent = msg;
2704
+ el.replaceChildren();
2705
+ const copy = document.createElement("span");
2706
+ copy.textContent = msg;
2707
+ el.appendChild(copy);
2708
+ el.classList.toggle("has-action", !!action);
2709
+ if (action) {
2710
+ const button = document.createElement("button");
2711
+ button.type = "button";
2712
+ button.className = "sl-toast-action";
2713
+ button.textContent = action.label;
2714
+ button.addEventListener("click", action.onClick, { once: true });
2715
+ el.appendChild(button);
2716
+ }
2572
2717
  el.dataset.tone = tone;
2573
2718
  el.classList.add("on");
2574
2719
  if (this.toastTimer) clearTimeout(this.toastTimer);
2575
2720
  this.toastTimer = setTimeout(() => {
2576
2721
  el.classList.remove("on");
2722
+ el.classList.remove("has-action");
2577
2723
  el.dataset.tone = "neutral";
2578
2724
  }, 4200);
2579
2725
  }
@@ -2623,7 +2769,9 @@ var SeatPicker = class _SeatPicker {
2623
2769
  }
2624
2770
  async bestAvailable(qty, categoryKey) {
2625
2771
  if (this.bestAvailableBusy) return null;
2772
+ qty = Math.max(1, Math.min(this.maxTickets, Math.floor(qty)));
2626
2773
  if (this.confirmSeat) this.cancelConfirm();
2774
+ this.bestAvailableConfirm = false;
2627
2775
  this.bestAvailableBusy = true;
2628
2776
  const button = this.els.tray?.querySelector(".sl-ba-go");
2629
2777
  if (button) {
@@ -2636,6 +2784,7 @@ var SeatPicker = class _SeatPicker {
2636
2784
  this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
2637
2785
  this.handedOff = false;
2638
2786
  this.bookedShown = false;
2787
+ this.gaQty.clear();
2639
2788
  this.startHoldTimer(h.expiresAt);
2640
2789
  this.flashHeldSeats(this.hold);
2641
2790
  this.syncTray();
@@ -2645,14 +2794,13 @@ var SeatPicker = class _SeatPicker {
2645
2794
  return null;
2646
2795
  } catch (err) {
2647
2796
  this.opts.onError?.(err);
2648
- this.toast("Those seats are no longer available. Try another quantity or category.", "error");
2797
+ const reason = err?.reason;
2798
+ const message = reason === "not_enough_together" ? `We couldn't find ${qty} seats together. Try fewer seats or another ticket type.` : reason === "sold_out" ? "That ticket type is sold out. Try another ticket type." : reason === "event_closed" ? "Seat sales have closed for this event." : "Those seats are no longer available. Try another quantity or ticket type.";
2799
+ this.toast(message, "error");
2649
2800
  return null;
2650
2801
  } finally {
2651
2802
  this.bestAvailableBusy = false;
2652
- if (!this.hold && button?.isConnected) {
2653
- button.disabled = false;
2654
- button.textContent = "Best available";
2655
- }
2803
+ this.syncTray();
2656
2804
  }
2657
2805
  }
2658
2806
  async release() {
@@ -2837,6 +2985,8 @@ function toRenderStatus(s) {
2837
2985
  var DEFAULT_API_BASE3 = "https://api.seatlayer.io";
2838
2986
  var STYLE_ID2 = "seatlayer-manager-style";
2839
2987
  var FEED_CAP = 80;
2988
+ var MAX_LIVE_SEAT_PULSES = 16;
2989
+ var MAX_LIVE_SECTION_PULSES = 4;
2840
2990
  var LEGEND = [
2841
2991
  { key: "free", label: "Free", color: "#6e7bff" },
2842
2992
  { key: "held", label: "Held", color: "#f4b740" },
@@ -2862,13 +3012,21 @@ var CSS2 = `
2862
3012
  @keyframes slm-pulse{0%{box-shadow:0 0 0 0 rgba(34,160,107,.5)}70%{box-shadow:0 0 0 7px rgba(34,160,107,0)}100%{box-shadow:0 0 0 0 rgba(34,160,107,0)}}
2863
3013
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
2864
3014
  border-top:1px solid var(--slm-line)}
2865
- .slm-kpi{display:flex;min-width:0;flex-direction:column;align-items:center;padding:0 5px;line-height:1.15;text-align:center}
3015
+ .slm-kpi{position:relative;display:flex;min-width:0;flex-direction:column;align-items:center;padding:0 5px;line-height:1.15;text-align:center}
2866
3016
  .slm-kpi b{display:flex;min-width:0;align-items:baseline;justify-content:center;font-size:17px;font-weight:800;
2867
3017
  font-variant-numeric:tabular-nums;white-space:nowrap}
2868
3018
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
2869
3019
  .slm-kpi .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:baseline}
3020
+ .slm-kpi.changed b{animation:slm-kpi-bump .58s cubic-bezier(.2,.8,.2,1)}
3021
+ .slm-kpidelta{position:absolute;right:4px;top:-12px;padding:2px 5px;border-radius:999px;background:rgba(34,160,107,.17);
3022
+ color:#5bd39b!important;font-size:9px!important;letter-spacing:0!important;text-transform:none!important;white-space:nowrap;
3023
+ animation:slm-kpi-delta 1.45s ease-out both;pointer-events:none}
3024
+ .slm-kpidelta.down{background:rgba(244,183,64,.14);color:#f7ca6b!important}
3025
+ @keyframes slm-kpi-bump{0%,100%{transform:none}35%{transform:translateY(-2px) scale(1.08);text-shadow:0 0 18px rgba(255,255,255,.24)}}
3026
+ @keyframes slm-kpi-delta{0%{opacity:0;transform:translateY(5px)}18%,72%{opacity:1;transform:none}100%{opacity:0;transform:translateY(-5px)}}
2870
3027
  .slm-barbtn{padding:7px 13px;border-radius:9px;border:1px solid var(--slm-line);color:var(--slm-text);font-weight:700;font-size:12.5px}
2871
3028
  .slm-barbtn:hover{border-color:var(--slm-muted)}
3029
+ .slm-barbtn.follow.on{background:rgba(34,160,107,.13);border-color:#22a06b;color:#5bd39b}
2872
3030
 
2873
3031
  /* body */
2874
3032
  .slm-body{display:flex;flex:1;min-height:0}
@@ -2880,6 +3038,14 @@ var CSS2 = `
2880
3038
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
2881
3039
  background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
2882
3040
  .slm-zoomhint.on{opacity:1}
3041
+ .slm-liveevent{position:absolute;left:50%;top:14px;z-index:4;display:flex;align-items:center;gap:8px;max-width:min(560px,calc(100% - 32px));
3042
+ padding:8px 12px;border:1px solid var(--slm-line);border-radius:999px;background:color-mix(in srgb,var(--slm-surface) 92%,transparent);
3043
+ box-shadow:0 10px 34px rgba(0,0,0,.32);opacity:0;transform:translate(-50%,-8px);pointer-events:none;
3044
+ transition:opacity .18s ease,transform .24s ease;backdrop-filter:blur(10px)}
3045
+ .slm-liveevent.on{opacity:1;transform:translate(-50%,0)}
3046
+ .slm.block-mode .slm-liveevent{top:52px}
3047
+ .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
3048
+ white-space:nowrap;font-size:12px;font-weight:800}.slm-liveeventhint{color:var(--slm-muted);font-size:10px;white-space:nowrap}
2883
3049
  .slm-rail{width:320px;flex:none;border-left:1px solid var(--slm-line);display:flex;flex-direction:column;min-height:0}
2884
3050
  .slm-railscroll{flex:1;overflow-y:auto;padding:16px}
2885
3051
  .slm-eyebrow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--slm-muted);font-weight:800;margin-bottom:6px}
@@ -2894,13 +3060,16 @@ var CSS2 = `
2894
3060
 
2895
3061
  /* activity feed */
2896
3062
  .slm-feed{display:flex;flex-direction:column;gap:0}
2897
- .slm-feedrow{display:flex;align-items:center;gap:9px;padding:8px 2px;border-bottom:1px solid var(--slm-line);
2898
- font-size:12.5px;animation:slm-in .35s ease}
3063
+ .slm-feedrow{display:flex!important;width:100%;align-items:center;gap:9px;padding:8px 2px!important;border-bottom:1px solid var(--slm-line)!important;
3064
+ border-radius:6px;font-size:12.5px;text-align:left!important;animation:slm-in .35s ease}
3065
+ .slm-feedrow:hover{background:rgba(255,255,255,.035)!important}
2899
3066
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
2900
3067
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
2901
3068
  .slm-feedtext{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2902
3069
  .slm-feedtext b{font-weight:800}
2903
- .slm-feedtime{font-size:11px;color:var(--slm-muted);font-variant-numeric:tabular-nums;flex:none}
3070
+ .slm-feedsection{display:block;overflow:hidden;text-overflow:ellipsis;color:var(--slm-muted);font-size:10px;font-weight:750}
3071
+ .slm-feedmeta{display:flex;flex:none;flex-direction:column;align-items:flex-end;gap:1px}.slm-feedtime{font-size:10px;color:var(--slm-muted);font-variant-numeric:tabular-nums}
3072
+ .slm-feedlocate{font-size:9.5px;color:var(--slm-accent);font-weight:800}
2904
3073
  .slm-empty{font-size:12.5px;color:var(--slm-muted);padding:12px 0}
2905
3074
 
2906
3075
  /* block toolbar */
@@ -2970,10 +3139,13 @@ var CSS2 = `
2970
3139
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
2971
3140
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
2972
3141
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
2973
- .slm-sectionrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
3142
+ .slm-sectionrow{width:100%;padding:10px!important;border:1px solid var(--slm-line)!important;border-radius:10px;background:var(--slm-surface)!important;text-align:left!important;transition:border-color .15s ease,transform .15s ease}
3143
+ .slm-sectionrow:hover{border-color:var(--slm-muted)!important;transform:translateY(-1px)}
2974
3144
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
2975
3145
  .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
3146
+ .slm-sectionmeta>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2976
3147
  .slm-trend{font-size:10px;text-transform:uppercase;letter-spacing:.08em}.slm-trend.rising{color:#22a06b}.slm-trend.cooling{color:#f4b740}
3148
+ .slm-sectionlocate{color:var(--slm-accent);font-size:9.5px;font-weight:800}
2977
3149
  .slm-health{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
2978
3150
  .slm-healthitem{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
2979
3151
  .slm-healthitem b{display:block;font-size:17px;font-variant-numeric:tabular-nums}.slm-healthitem span{display:block;margin-top:2px;color:var(--slm-muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}
@@ -3000,6 +3172,10 @@ var CSS2 = `
3000
3172
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
3001
3173
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
3002
3174
  .slm.compact .slm-kpi[data-kpi="sold-pct"],.slm.compact .slm-kpi[data-kpi="gross-sales"]{display:none}
3175
+ @media (prefers-reduced-motion:reduce){
3176
+ .slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
3177
+ .slm-liveevent,.slm-sectionrow{transition:none!important}
3178
+ }
3003
3179
  `;
3004
3180
  function injectStyle() {
3005
3181
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
@@ -3058,6 +3234,8 @@ var SeatManager = class {
3058
3234
  this.controlRoomSnapshot = null;
3059
3235
  this.trendWindowMinutes = 15;
3060
3236
  this.heatEnabled = false;
3237
+ this.lastKpiValues = /* @__PURE__ */ new Map();
3238
+ this.activeKpiDeltas = /* @__PURE__ */ new Map();
3061
3239
  // realtime socket
3062
3240
  this.ws = null;
3063
3241
  this.reconnectTimer = null;
@@ -3067,6 +3245,10 @@ var SeatManager = class {
3067
3245
  this.feed = [];
3068
3246
  this.feedTimer = null;
3069
3247
  this.toastTimer = null;
3248
+ this.liveEventTimer = null;
3249
+ this.kpiCleanupTimer = null;
3250
+ this.followLiveTimer = null;
3251
+ this.followSeatTimer = null;
3070
3252
  this.releaseAt = null;
3071
3253
  this.layoutObserver = null;
3072
3254
  this.tokenExpiresAt = null;
@@ -3096,11 +3278,22 @@ var SeatManager = class {
3096
3278
  else return;
3097
3279
  event.preventDefault();
3098
3280
  };
3281
+ this.onRailClick = (event) => {
3282
+ const target = event.target;
3283
+ const sectionButton = target?.closest("[data-section-focus]");
3284
+ if (sectionButton?.dataset.sectionFocus) {
3285
+ this.locateSection(sectionButton.dataset.sectionFocus);
3286
+ return;
3287
+ }
3288
+ const feedButton = target?.closest("[data-feed-id]");
3289
+ if (feedButton?.dataset.feedId) this.locateActivity(feedButton.dataset.feedId);
3290
+ };
3099
3291
  this.sectionOptions = [];
3100
3292
  this.opts = options;
3101
3293
  this.key = options.eventKey;
3102
3294
  this.mode = options.mode ?? "view";
3103
3295
  this.keepLive = options.keepLiveWhileHidden ?? true;
3296
+ this.followLive = options.followLive ?? false;
3104
3297
  this.currency = options.currency ?? "USD";
3105
3298
  this.tokenExpiresAt = options.tokenExpiresAt ?? null;
3106
3299
  this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE3, options.token);
@@ -3157,6 +3350,19 @@ var SeatManager = class {
3157
3350
  this.applyHeatOverlay();
3158
3351
  this.paintHeatButton();
3159
3352
  }
3353
+ /** Toggle opt-in camera following for new buyer hold/book events. */
3354
+ setFollowLive(enabled) {
3355
+ const changed = this.followLive !== enabled;
3356
+ this.followLive = enabled;
3357
+ if (!enabled) {
3358
+ if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
3359
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3360
+ this.followLiveTimer = null;
3361
+ this.followSeatTimer = null;
3362
+ }
3363
+ this.paintFollowLiveButton();
3364
+ if (changed) this.opts.onFollowLiveChange?.(enabled);
3365
+ }
3160
3366
  /** Change the current-vs-previous sales window and refresh the private projection. */
3161
3367
  setTrendWindow(windowMinutes) {
3162
3368
  const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
@@ -3329,6 +3535,7 @@ var SeatManager = class {
3329
3535
  return Promise.resolve();
3330
3536
  }
3331
3537
  zoomToFit() {
3538
+ this.renderer?.clearSectionFocus();
3332
3539
  this.renderer?.zoomToFit();
3333
3540
  }
3334
3541
  destroy() {
@@ -3336,12 +3543,17 @@ var SeatManager = class {
3336
3543
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
3337
3544
  if (this.feedTimer) clearInterval(this.feedTimer);
3338
3545
  if (this.toastTimer) clearTimeout(this.toastTimer);
3546
+ if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
3547
+ if (this.kpiCleanupTimer) clearTimeout(this.kpiCleanupTimer);
3548
+ if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
3549
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3339
3550
  if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
3340
3551
  if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
3341
3552
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
3342
3553
  this.layoutObserver?.disconnect();
3343
3554
  this.layoutObserver = null;
3344
3555
  this.root?.removeEventListener("keydown", this.onKeyDown);
3556
+ this.els.rail?.removeEventListener("click", this.onRailClick);
3345
3557
  if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
3346
3558
  if (this.ws) {
3347
3559
  try {
@@ -3479,7 +3691,6 @@ var SeatManager = class {
3479
3691
  const id = this.labelToId.get(ch.label);
3480
3692
  if (id) {
3481
3693
  this.renderer?.setStatus([id], toRenderStatus(st));
3482
- this.flash(id, st);
3483
3694
  ids.push(id);
3484
3695
  }
3485
3696
  const verb = this.verbFor(prev, st);
@@ -3488,7 +3699,10 @@ var SeatManager = class {
3488
3699
  group.labels.push(ch.label);
3489
3700
  groups.set(groupKey, group);
3490
3701
  }
3491
- for (const group of groups.values()) this.pushActivity(group.labels, group.verb, group.status);
3702
+ for (const group of groups.values()) {
3703
+ const activity = this.pushActivity(group.labels, group.verb, group.status);
3704
+ if (activity) this.paintSpatialActivity(activity);
3705
+ }
3492
3706
  if (ids.length) {
3493
3707
  this.lastSyncedAt = Date.now();
3494
3708
  this.afterPaint();
@@ -3534,9 +3748,108 @@ var SeatManager = class {
3534
3748
  this.renderer?.forceDraw();
3535
3749
  }
3536
3750
  }
3537
- flash(id, st) {
3538
- if (st === "held") this.renderer?.flashSeat(id, "#f4b740");
3539
- else if (st === "booked") this.renderer?.flashSeat(id, "#22a06b");
3751
+ activityColor(status) {
3752
+ return status === "held" ? "#f4b740" : status === "booked" ? "#22a06b" : status === "blocked" ? "#8b94ac" : "#6e7bff";
3753
+ }
3754
+ sectionsForLabels(labels) {
3755
+ const ids = /* @__PURE__ */ new Set();
3756
+ for (const label of labels) {
3757
+ const seat = this.labelToSeat.get(label);
3758
+ if (!seat) continue;
3759
+ const sectionId = this.sectionByObject.get(seat.rowId);
3760
+ if (sectionId && sectionId !== import_core3.UNGROUPED_ID) ids.add(sectionId);
3761
+ }
3762
+ const sectionIds = [...ids];
3763
+ return {
3764
+ ids: sectionIds,
3765
+ labels: sectionIds.map((id) => this.sectionLabelById.get(id) ?? id)
3766
+ };
3767
+ }
3768
+ pulseSeatLabels(labels, status) {
3769
+ const color = this.activityColor(status);
3770
+ for (const label of labels.slice(0, MAX_LIVE_SEAT_PULSES)) {
3771
+ const id = this.labelToId.get(label);
3772
+ if (id) this.renderer?.flashSeat(id, color);
3773
+ }
3774
+ }
3775
+ /** Render one grouped realtime operation at the right semantic zoom level. */
3776
+ paintSpatialActivity(activity) {
3777
+ const sectionIds = activity.sectionIds ?? this.sectionsForLabels(activity.labels).ids;
3778
+ const focused = this.renderer?.getFocusedSection() ?? null;
3779
+ const followable = this.followLive && sectionIds.length === 1 && (activity.status === "held" || activity.status === "booked");
3780
+ if (followable && focused === sectionIds[0]) {
3781
+ this.pulseSeatLabels(activity.labels, activity.status);
3782
+ return;
3783
+ }
3784
+ if (followable) {
3785
+ if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
3786
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3787
+ this.followLiveTimer = setTimeout(() => {
3788
+ this.followLiveTimer = null;
3789
+ this.renderer?.focusSection(sectionIds[0]);
3790
+ this.followSeatTimer = setTimeout(() => {
3791
+ this.followSeatTimer = null;
3792
+ this.pulseSeatLabels(activity.labels, activity.status);
3793
+ }, 520);
3794
+ }, 220);
3795
+ return;
3796
+ }
3797
+ if (!focused && sectionIds.length) {
3798
+ const color = this.activityColor(activity.status);
3799
+ for (const sectionId of sectionIds.slice(0, MAX_LIVE_SECTION_PULSES)) {
3800
+ this.renderer?.flashSection(sectionId, color);
3801
+ }
3802
+ return;
3803
+ }
3804
+ if (!sectionIds.length || focused && sectionIds.includes(focused)) {
3805
+ this.pulseSeatLabels(activity.labels, activity.status);
3806
+ }
3807
+ }
3808
+ locateSection(sectionId) {
3809
+ this.renderer?.focusSection(sectionId);
3810
+ }
3811
+ locateActivity(activityId) {
3812
+ const activity = this.feed.find((item) => item.id === activityId);
3813
+ if (!activity) return;
3814
+ const sectionIds = activity.sectionIds ?? this.sectionsForLabels(activity.labels).ids;
3815
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3816
+ if (sectionIds.length === 1) {
3817
+ this.locateSection(sectionIds[0]);
3818
+ this.followSeatTimer = setTimeout(() => {
3819
+ this.followSeatTimer = null;
3820
+ this.pulseSeatLabels(activity.labels, activity.status);
3821
+ }, 520);
3822
+ return;
3823
+ }
3824
+ this.zoomToFit();
3825
+ this.followSeatTimer = setTimeout(() => {
3826
+ this.followSeatTimer = null;
3827
+ if (sectionIds.length) {
3828
+ const color = this.activityColor(activity.status);
3829
+ for (const sectionId of sectionIds.slice(0, MAX_LIVE_SECTION_PULSES)) {
3830
+ this.renderer?.flashSection(sectionId, color);
3831
+ }
3832
+ } else {
3833
+ this.pulseSeatLabels(activity.labels, activity.status);
3834
+ }
3835
+ }, 280);
3836
+ }
3837
+ showLiveEvent(activity) {
3838
+ const element = this.els.liveevent;
3839
+ if (!element) return;
3840
+ const sections = activity.sectionLabels ?? [];
3841
+ const place = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : activity.label;
3842
+ const noun = activity.count === 1 ? "seat" : "seats";
3843
+ element.innerHTML = `<span class="slm-liveeventdot" style="background:${this.activityColor(activity.status)}"></span>
3844
+ <span class="slm-liveeventcopy">${esc(place)} \xB7 ${activity.count.toLocaleString()} ${noun} ${esc(activity.verb)}</span>
3845
+ <span class="slm-liveeventhint">Live</span>`;
3846
+ element.classList.add("on");
3847
+ if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
3848
+ this.liveEventTimer = setTimeout(() => {
3849
+ this.liveEventTimer = null;
3850
+ element.classList.remove("on");
3851
+ element.innerHTML = "";
3852
+ }, 2800);
3540
3853
  }
3541
3854
  // ---- tallies + feed -------------------------------------------------------
3542
3855
  applyReportRevenue(report) {
@@ -3619,7 +3932,8 @@ var SeatManager = class {
3619
3932
  }
3620
3933
  pushActivity(labels, verb, status, at = Date.now()) {
3621
3934
  const label = labels[0];
3622
- if (!label) return;
3935
+ if (!label) return null;
3936
+ const sections = this.sectionsForLabels(labels);
3623
3937
  const item = {
3624
3938
  id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
3625
3939
  at,
@@ -3627,12 +3941,16 @@ var SeatManager = class {
3627
3941
  labels: [...labels],
3628
3942
  count: labels.length,
3629
3943
  verb,
3630
- status
3944
+ status,
3945
+ sectionIds: sections.ids,
3946
+ sectionLabels: sections.labels
3631
3947
  };
3632
3948
  this.feed.unshift(item);
3633
3949
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
3634
3950
  if (this.mode === "view") this.paintFeed();
3951
+ this.showLiveEvent(item);
3635
3952
  this.opts.onActivity?.(item);
3953
+ return item;
3636
3954
  }
3637
3955
  seedFeed(entries) {
3638
3956
  const verbByAction = {
@@ -3656,6 +3974,7 @@ var SeatManager = class {
3656
3974
  for (const e of entries) {
3657
3975
  const label = e.labels[0];
3658
3976
  if (!label) continue;
3977
+ const sections = this.sectionsForLabels(e.labels);
3659
3978
  const item = {
3660
3979
  id: `log:${e.id}`,
3661
3980
  at: e.at,
@@ -3663,7 +3982,9 @@ var SeatManager = class {
3663
3982
  labels: [...e.labels],
3664
3983
  count: e.labels.length,
3665
3984
  verb: verbByAction[e.action] ?? e.action,
3666
- status: stByAction[e.action] ?? "free"
3985
+ status: stByAction[e.action] ?? "free",
3986
+ sectionIds: sections.ids,
3987
+ sectionLabels: sections.labels
3667
3988
  };
3668
3989
  this.feed.push(item);
3669
3990
  this.opts.onActivity?.(item);
@@ -3701,13 +4022,15 @@ var SeatManager = class {
3701
4022
  for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
3702
4023
  root.innerHTML = `
3703
4024
  <div class="slm-bar">
3704
- <div class="slm-modes" data-ref="modes">
3705
- <button class="slm-mode" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
3706
- <button class="slm-mode" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
3707
- <button class="slm-mode" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
4025
+ <div class="slm-modes" data-ref="modes" role="tablist" aria-label="Manager tools">
4026
+ <button class="slm-mode" role="tab" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
4027
+ <button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
4028
+ <button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
3708
4029
  </div>
3709
4030
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
3710
4031
  <div class="slm-bar-actions">
4032
+ <button class="slm-barbtn follow" data-ref="follow" aria-pressed="false"
4033
+ title="Stay on the current map view unless enabled">Follow live</button>
3711
4034
  <button class="slm-barbtn" data-ref="heat" aria-pressed="false"
3712
4035
  aria-label="Sales momentum overlay off"
3713
4036
  title="Highlight sections selling fastest in the selected time window">Sales momentum</button>
@@ -3719,6 +4042,7 @@ var SeatManager = class {
3719
4042
  <div class="slm-map">
3720
4043
  <div class="slm-map-host" data-ref="maphost"></div>
3721
4044
  <div class="slm-zoomhint" data-ref="zoomhint">Zoom in to marquee-select</div>
4045
+ <div class="slm-liveevent" data-ref="liveevent" role="status" aria-live="polite"></div>
3722
4046
  <div class="slm-hud"><button class="slm-hud-chip" data-ref="zfit">Zoom to fit</button></div>
3723
4047
  </div>
3724
4048
  <aside class="slm-rail"><div class="slm-railscroll" data-ref="rail"></div></aside>
@@ -3738,20 +4062,25 @@ var SeatManager = class {
3738
4062
  modes: ref("modes"),
3739
4063
  livetext: ref("livetext"),
3740
4064
  kpis: ref("kpis"),
4065
+ follow: ref("follow"),
3741
4066
  heat: ref("heat"),
3742
4067
  fullscreen: ref("fullscreen"),
3743
4068
  zoomhint: ref("zoomhint"),
4069
+ liveevent: ref("liveevent"),
3744
4070
  rail: ref("rail"),
3745
4071
  toast: ref("toast"),
3746
4072
  zfit: ref("zfit")
3747
4073
  };
3748
4074
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
3749
4075
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
4076
+ this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
3750
4077
  this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
3751
4078
  this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
3752
4079
  root.addEventListener("keydown", this.onKeyDown);
4080
+ this.els.rail.addEventListener("click", this.onRailClick);
3753
4081
  document.addEventListener("fullscreenchange", this.onFullscreenChange);
3754
4082
  this.paintModeTabs();
4083
+ this.paintFollowLiveButton();
3755
4084
  this.paintHeatButton();
3756
4085
  this.paintFullscreenButton();
3757
4086
  }
@@ -3780,10 +4109,20 @@ var SeatManager = class {
3780
4109
  paintModeTabs() {
3781
4110
  this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
3782
4111
  const el = b;
3783
- el.classList.toggle("on", el.dataset.mode === this.mode);
4112
+ const active = el.dataset.mode === this.mode;
4113
+ el.classList.toggle("on", active);
4114
+ el.setAttribute("aria-selected", String(active));
4115
+ el.tabIndex = active ? 0 : -1;
3784
4116
  });
3785
4117
  this.root?.classList.toggle("block-mode", this.mode === "block");
3786
4118
  }
4119
+ paintFollowLiveButton() {
4120
+ const button = this.els.follow;
4121
+ if (!button) return;
4122
+ button.classList.toggle("on", this.followLive);
4123
+ button.setAttribute("aria-pressed", String(this.followLive));
4124
+ button.setAttribute("title", this.followLive ? "Following new buyer holds and bookings. Turn off to keep the current view." : "Stay on the current map view. Enable to follow new buyer holds and bookings.");
4125
+ }
3787
4126
  paintHeatButton() {
3788
4127
  const button = this.els.heat;
3789
4128
  if (!button) return;
@@ -3824,20 +4163,55 @@ var SeatManager = class {
3824
4163
  const show = this.mode === "block" && this.renderer?.getRung?.() !== "seats";
3825
4164
  hint.classList.toggle("on", !!show);
3826
4165
  }
4166
+ formatKpiDelta(key, delta, currency) {
4167
+ const sign = delta > 0 ? "+" : "\u2212";
4168
+ const absolute = Math.abs(delta);
4169
+ if (key === "gross-sales") return `${sign}${fmtMoney(absolute, currency)}`;
4170
+ if (key === "sold-pct") return `${sign}${absolute.toLocaleString()}pt`;
4171
+ return `${sign}${absolute.toLocaleString()}`;
4172
+ }
3827
4173
  paintKpis(t3) {
3828
4174
  if (!this.els.kpis) return;
3829
4175
  const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
3830
4176
  const presence = this.controlRoomSnapshot?.presence;
3831
- this.els.kpis.innerHTML = [
3832
- { key: "sold-seats", n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
3833
- { key: "held-seats", n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
3834
- { key: "buyers", n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
3835
- { key: "active-holds", n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
3836
- { key: "free-seats", n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
3837
- { key: "blocked", n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
3838
- { key: "sold-pct", n: `${t3.capacityPct}%`, l: "Sold" },
3839
- { key: "gross-sales", n: rev, l: "Gross sales" }
3840
- ].map((k) => `<div class="slm-kpi" data-kpi="${k.key}"><b>${k.dot ? `<span class="dot" style="background:${k.dot}"></span>` : ""}${k.n}</b><span>${k.l}</span></div>`).join("");
4177
+ const items = [
4178
+ { key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
4179
+ { key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
4180
+ { key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
4181
+ { key: "active-holds", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
4182
+ { key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
4183
+ { key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
4184
+ { key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold" },
4185
+ { key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales" }
4186
+ ];
4187
+ let hasChanges = false;
4188
+ this.els.kpis.innerHTML = items.map((item) => {
4189
+ const previous = this.lastKpiValues.get(item.key);
4190
+ const changed = item.raw != null && previous != null && item.raw !== previous;
4191
+ const delta = changed ? item.raw - previous : 0;
4192
+ if (changed) {
4193
+ hasChanges = true;
4194
+ this.activeKpiDeltas.set(item.key, {
4195
+ text: this.formatKpiDelta(item.key, delta, t3.currency),
4196
+ down: delta < 0
4197
+ });
4198
+ }
4199
+ if (item.raw != null) this.lastKpiValues.set(item.key, item.raw);
4200
+ const activeDelta = this.activeKpiDeltas.get(item.key);
4201
+ return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}">
4202
+ <b>${item.dot ? `<span class="dot" style="background:${item.dot}"></span>` : ""}${item.n}</b><span>${item.l}</span>
4203
+ ${activeDelta ? `<span class="slm-kpidelta${activeDelta.down ? " down" : ""}">${activeDelta.text}</span>` : ""}
4204
+ </div>`;
4205
+ }).join("");
4206
+ if (hasChanges) {
4207
+ if (this.kpiCleanupTimer) clearTimeout(this.kpiCleanupTimer);
4208
+ this.kpiCleanupTimer = setTimeout(() => {
4209
+ this.kpiCleanupTimer = null;
4210
+ this.activeKpiDeltas.clear();
4211
+ this.els.kpis?.querySelectorAll(".slm-kpidelta").forEach((element) => element.remove());
4212
+ this.els.kpis?.querySelectorAll(".slm-kpi.changed").forEach((element) => element.classList.remove("changed"));
4213
+ }, 1500);
4214
+ }
3841
4215
  }
3842
4216
  // ---- DOM: rails -----------------------------------------------------------
3843
4217
  paintRail() {
@@ -3908,10 +4282,10 @@ var SeatManager = class {
3908
4282
  const net = speed?.netBooked ?? 0;
3909
4283
  const netLabel = `${net > 0 ? "+" : ""}${net}`;
3910
4284
  const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
3911
- return `<div class="slm-sectionrow">
3912
- <div class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></div>
3913
- <div class="slm-sectionmeta"><span>${row.booked.toLocaleString()}/${row.total.toLocaleString()} sold \xB7 ${netLabel} in ${snapshot.velocity.windowMinutes}m</span><span class="slm-trend ${trend}">${trend}</span></div>
3914
- </div>`;
4285
+ return `<button type="button" class="slm-sectionrow" data-section-focus="${esc(row.sectionId)}" title="Focus ${esc(row.sectionLabel)} on the map">
4286
+ <span class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></span>
4287
+ <span class="slm-sectionmeta"><span>${row.booked.toLocaleString()}/${row.total.toLocaleString()} sold \xB7 ${netLabel} in ${snapshot.velocity.windowMinutes}m</span><span class="slm-trend ${trend}">${trend}</span><span class="slm-sectionlocate">Locate</span></span>
4288
+ </button>`;
3915
4289
  }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
3916
4290
  this.paintTrendWindow();
3917
4291
  this.paintMomentumHelp();
@@ -3980,9 +4354,13 @@ var SeatManager = class {
3980
4354
  const color = { free: "#6e7bff", held: "#f4b740", booked: "#22a06b", blocked: "#8b94ac" };
3981
4355
  this.els.feed.innerHTML = this.feed.map((a) => {
3982
4356
  const extra = a.count > 1 ? ` +${a.count - 1}` : "";
3983
- return `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
3984
- <span class="slm-feedtext">${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
3985
- <span class="slm-feedtime">${relTime(a.at, now)}</span></div>`;
4357
+ const sections = a.sectionLabels ?? [];
4358
+ const sectionCopy = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : "";
4359
+ return `<button type="button" class="slm-feedrow" data-feed-id="${esc(a.id)}" title="Locate this activity on the map">
4360
+ <span class="slm-feeddot" style="background:${color[a.status]}"></span>
4361
+ <span class="slm-feedtext">${sectionCopy ? `<span class="slm-feedsection">${esc(sectionCopy)}</span>` : ""}${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
4362
+ <span class="slm-feedmeta"><span class="slm-feedtime">${relTime(a.at, now)}</span><span class="slm-feedlocate">Locate</span></span>
4363
+ </button>`;
3986
4364
  }).join("");
3987
4365
  }
3988
4366
  renderBlockRail() {
@@ -4238,6 +4616,10 @@ var SeatManager = class {
4238
4616
  // ---- toast / done / fail --------------------------------------------------
4239
4617
  done(action, labels, msg) {
4240
4618
  this.toastOk(msg);
4619
+ if (labels.length) {
4620
+ const activity = action === "block" ? this.pushActivity(labels, "blocked", "blocked") : action === "unblock" || action === "unblockAll" ? this.pushActivity(labels, "unblocked", "free") : action === "cancelBooking" ? this.pushActivity(labels, "cancelled", "free") : null;
4621
+ if (activity) this.paintSpatialActivity(activity);
4622
+ }
4241
4623
  if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
4242
4624
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
4243
4625
  }