@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.js CHANGED
@@ -476,7 +476,7 @@ var CSS = `
476
476
  .sl-body{display:flex;flex:1;min-height:0}
477
477
  .sl-map{position:relative;flex:1;min-width:0}
478
478
  .sl-map-host{position:absolute;inset:0}
479
- .sl-side{width:300px;flex:none;border-left:1px solid var(--sl-line);display:flex;flex-direction:column;min-height:0;overflow-y:auto}
479
+ .sl-side{width:300px;flex:none;border-left:1px solid var(--sl-line);display:flex;flex-direction:column;min-height:0;overflow:hidden}
480
480
 
481
481
  /* narrow (container < 640px): map-first \u2014 the map claims ~80-85% of the
482
482
  container and the side panel becomes a PEEKING bottom sheet (AXS/Ticketmaster
@@ -559,6 +559,7 @@ var CSS = `
559
559
  .sl-price-select{min-height:32px;max-width:130px;padding:5px 28px 5px 9px;border:1px solid var(--sl-line);border-radius:9px;
560
560
  background:var(--sl-surface);color:var(--sl-text);font:inherit;font-size:11px;font-weight:750;letter-spacing:0;text-transform:none}
561
561
  .sl-prices{padding:5px 14px 10px;border-bottom:1px solid var(--sl-line)}
562
+ .sl-prices-sec,.sl-prices,.sl-seats-sec{flex:none}
562
563
  .sl-price-row{display:flex;align-items:center;gap:7px;min-height:28px;font-size:12px}
563
564
  .sl-dot{width:9px;height:9px;border-radius:50%;flex:none}
564
565
  .sl-price-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
@@ -575,7 +576,8 @@ var CSS = `
575
576
  /* tray */
576
577
  .sl-seats-sec{display:flex;align-items:center;justify-content:space-between;gap:10px;padding-top:13px}
577
578
  .sl-seat-summary{font-size:10px;letter-spacing:0;text-transform:none;white-space:nowrap}
578
- .sl-tray{flex:1;padding:10px 14px;display:flex;flex-direction:column;gap:7px;min-height:0}
579
+ .sl-tray{flex:1;padding:10px 14px 14px;display:flex;flex-direction:column;gap:7px;min-height:0;overflow-y:auto;
580
+ overscroll-behavior:contain;scrollbar-gutter:stable}
579
581
  .sl-tray-hint{font-size:12.5px;color:var(--sl-muted);line-height:1.5}
580
582
  .sl-chip{position:relative;display:grid;grid-template-columns:24px minmax(0,1fr) auto 30px;align-items:center;gap:8px;
581
583
  min-height:53px;padding:7px 7px 7px 9px;border:1px solid var(--sl-line);border-radius:var(--sl-r-sm);
@@ -610,16 +612,22 @@ var CSS = `
610
612
  .sl-ga-qty span{min-width:16px;text-align:center;font-weight:800;font-variant-numeric:tabular-nums}
611
613
 
612
614
  /* footer */
613
- .sl-foot{padding:12px 16px 14px;border-top:1px solid var(--sl-line);flex:none}
614
- .sl-hold-note{display:none;align-items:center;gap:9px;margin-bottom:10px;padding:9px 10px;border-radius:var(--sl-r-sm);
615
+ .sl-foot{position:relative;z-index:2;padding:12px 16px 14px;border-top:1px solid var(--sl-line);flex:none;
616
+ background:var(--sl-bg);box-shadow:0 -10px 24px -22px rgba(0,0,0,.72)}
617
+ .sl-hold-note{display:none;align-items:center;gap:7px;margin-bottom:8px;padding:7px 8px;border-radius:var(--sl-r-sm);
615
618
  border:1px solid var(--sl-line);background:color-mix(in srgb,var(--sl-accent) 7%,var(--sl-surface));
616
619
  box-shadow:inset 3px 0 0 color-mix(in srgb,var(--sl-accent) 72%,transparent);
617
620
  font-size:11.5px;line-height:1.35;color:var(--sl-muted)}
618
621
  .sl-hold-note.on{display:flex;animation:slNoticeIn .38s cubic-bezier(.2,.8,.2,1) both}
619
622
  .sl-hold-note svg{width:16px;height:16px;flex:none;stroke:var(--sl-accent);stroke-width:2.4;fill:none;
620
623
  stroke-linecap:round;stroke-linejoin:round}
621
- .sl-hold-note b{display:block;color:var(--sl-text);font-size:12px}
622
- .sl-hold-copy{display:block}
624
+ .sl-hold-note b{display:block;color:var(--sl-text);font-size:11.5px;white-space:nowrap}
625
+ .sl-hold-copy{display:block;white-space:nowrap;font-size:10.5px}
626
+ .sl-hold-note>span{flex:1;min-width:0}
627
+ .sl-hold-change{flex:none;min-height:30px;padding:5px 8px;border-radius:8px;border:1px solid var(--sl-line);
628
+ color:var(--sl-text);font-size:10.5px;font-weight:750;white-space:nowrap}
629
+ .sl-hold-change:hover,.sl-hold-change:focus-visible{border-color:var(--sl-accent);color:var(--sl-accent)}
630
+ .sl-hold-change:disabled{opacity:.58;cursor:wait}
623
631
  .sl-total{display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:10px}
624
632
  .sl-total b{font-size:17px;font-variant-numeric:tabular-nums}
625
633
  .sl-value-pop{animation:slValuePop .32s cubic-bezier(.2,.8,.2,1)}
@@ -677,6 +685,9 @@ var CSS = `
677
685
  font-size:12.5px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .22s,transform .22s;white-space:nowrap;
678
686
  overflow:hidden;text-overflow:ellipsis}
679
687
  .sl-toast.on{opacity:1;transform:translateY(0) scale(1)}
688
+ .sl-toast.has-action{pointer-events:auto;display:flex;align-items:center;gap:12px;padding-right:8px}
689
+ .sl-toast-action{min-height:30px;padding:5px 10px;border-radius:999px;background:var(--sl-accent);color:var(--sl-accent-ink);
690
+ font:inherit;font-weight:800}
680
691
  .sl-toast[data-tone="error"]{border-color:#ef4444}
681
692
  .sl-toast[data-tone="warning"]{border-color:var(--sl-accent)}
682
693
  .sl-toast[data-tone="success"]{border-color:#22c55e}
@@ -780,6 +791,12 @@ var CSS = `
780
791
  box-shadow:0 8px 18px color-mix(in srgb,var(--sl-accent) 18%,transparent)}
781
792
  .sl-picker .sl-ba-go:hover{filter:brightness(1.06)}
782
793
  .sl-picker .sl-ba-go:disabled{opacity:.62;cursor:wait}
794
+ .sl-ba-replace{grid-column:1/-1;padding:3px 0 1px}
795
+ .sl-ba-replace b{display:block;font-size:12.5px}
796
+ .sl-ba-replace span{display:block;margin-top:3px;color:var(--sl-muted);font-size:10.5px;line-height:1.35}
797
+ .sl-ba-actions{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:7px}
798
+ .sl-ba-actions button{min-height:36px;border-radius:9px;border:1px solid var(--sl-line);font-size:11.5px;font-weight:800}
799
+ .sl-ba-actions .replace{border-color:var(--sl-accent);background:var(--sl-accent);color:var(--sl-accent-ink)}
783
800
  .sl-picker[data-layout="narrow"] .sl-ba{padding:11px}
784
801
  .sl-picker[data-layout="narrow"] .sl-ba-copy .wide{display:none}
785
802
  .sl-picker[data-layout="narrow"] .sl-ba-copy .narrow{display:inline}
@@ -971,6 +988,8 @@ var SeatPicker = class _SeatPicker {
971
988
  this.a11yFilter = "all";
972
989
  this.baQty = 2;
973
990
  this.baCat = "";
991
+ this.bestAvailableConfirm = false;
992
+ this.releasingHold = false;
974
993
  // arena / multi-floor / seat-view chrome
975
994
  this.rungsEl = null;
976
995
  this.floorsEl = null;
@@ -1018,10 +1037,11 @@ var SeatPicker = class _SeatPicker {
1018
1037
  this.opts = { ...options, confirmSelection: options.confirmSelection ?? true };
1019
1038
  this.apiBase = (options.apiBase ?? DEFAULT_API_BASE2).replace(/\/+$/, "");
1020
1039
  this.api = new PubApi(this.apiBase);
1040
+ this.maxTickets = Math.max(1, Math.floor(options.maxSelection ?? DEFAULT_MAX_SELECTION2));
1021
1041
  this.controller = new PickerController2({
1022
1042
  transport: this.api,
1023
1043
  eventKey: options.event,
1024
- maxSelection: options.maxSelection ?? DEFAULT_MAX_SELECTION2,
1044
+ maxSelection: this.maxTickets,
1025
1045
  currency: options.currency,
1026
1046
  flashOnLiveChange: true,
1027
1047
  colorblindSafe: options.colorblindSafe,
@@ -1056,6 +1076,9 @@ var SeatPicker = class _SeatPicker {
1056
1076
  onDeselect: (seat) => {
1057
1077
  if (this.confirmSeat?.id === seat.id) this.dismissConfirm();
1058
1078
  },
1079
+ onSelectionLimit: () => {
1080
+ this.toast(`You can select up to ${this.maxTickets} tickets for this order.`, "warning");
1081
+ },
1059
1082
  onViewChange: () => {
1060
1083
  this.reanchorConfirm();
1061
1084
  this.syncRung();
@@ -1118,6 +1141,10 @@ var SeatPicker = class _SeatPicker {
1118
1141
  if (picker.confirmSeat) {
1119
1142
  e.preventDefault();
1120
1143
  picker.cancelConfirm();
1144
+ } else if (picker.bestAvailableConfirm) {
1145
+ e.preventDefault();
1146
+ picker.bestAvailableConfirm = false;
1147
+ picker.syncTray();
1121
1148
  } else {
1122
1149
  close();
1123
1150
  }
@@ -1141,10 +1168,17 @@ var SeatPicker = class _SeatPicker {
1141
1168
  this.root = root;
1142
1169
  mount.appendChild(root);
1143
1170
  root.addEventListener("keydown", (e) => {
1144
- if (e.key !== "Escape" || !this.confirmSeat) return;
1145
- e.preventDefault();
1146
- e.stopPropagation();
1147
- this.cancelConfirm();
1171
+ if (e.key !== "Escape") return;
1172
+ if (this.confirmSeat) {
1173
+ e.preventDefault();
1174
+ e.stopPropagation();
1175
+ this.cancelConfirm();
1176
+ } else if (this.bestAvailableConfirm) {
1177
+ e.preventDefault();
1178
+ e.stopPropagation();
1179
+ this.bestAvailableConfirm = false;
1180
+ this.syncTray();
1181
+ }
1148
1182
  });
1149
1183
  Object.entries(resolveTokens(void 0, this.opts.theme)).forEach(([k, v]) => root.style.setProperty(k, v));
1150
1184
  root.innerHTML = `
@@ -1192,6 +1226,7 @@ var SeatPicker = class _SeatPicker {
1192
1226
  <div class="sl-hold-note" data-ref="holdNote" role="status" aria-live="polite">
1193
1227
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
1194
1228
  <span><b data-ref="holdTitle">Seats secured</b><span class="sl-hold-copy" data-ref="holdCopy">Checkout timer is running.</span></span>
1229
+ <button type="button" class="sl-hold-change" data-ref="holdChange" aria-label="Release held tickets and choose different seats">Change</button>
1195
1230
  </div>
1196
1231
  <div class="sl-total"><span data-ref="count"></span><b data-ref="total"></b></div>
1197
1232
  <button type="button" class="sl-cta" data-ref="cta" disabled></button>
@@ -1269,6 +1304,7 @@ var SeatPicker = class _SeatPicker {
1269
1304
  if (this.tipEl && this.tipEl.style.display !== "none") this.placeTooltip();
1270
1305
  });
1271
1306
  this.els.cta.addEventListener("click", () => void this.handleCta());
1307
+ this.els.holdChange?.addEventListener("click", () => void this.handleChangeSeats());
1272
1308
  const canvasHost = document.createElement("div");
1273
1309
  canvasHost.style.cssText = "position:absolute;inset:0";
1274
1310
  this.mapHost.appendChild(canvasHost);
@@ -1475,15 +1511,51 @@ var SeatPicker = class _SeatPicker {
1475
1511
  const heldItems = this.hold?.items ?? [];
1476
1512
  const heldLabels = new Set(heldItems.map((item) => item.label));
1477
1513
  const pendingSeats = this.committedSelection().filter((seat) => !heldLabels.has(seat.label)).length;
1514
+ return pendingSeats + this.pendingGACount();
1515
+ }
1516
+ heldGACounts() {
1478
1517
  const heldGA = /* @__PURE__ */ new Map();
1479
- for (const item of heldItems.filter((candidate) => candidate.objectType === "ga")) {
1518
+ for (const item of (this.hold?.items ?? []).filter((candidate) => candidate.objectType === "ga")) {
1480
1519
  heldGA.set(item.objectId, (heldGA.get(item.objectId) ?? 0) + (item.quantity ?? 1));
1481
1520
  }
1482
- const pendingGA = [...this.gaQty.entries()].reduce(
1521
+ return heldGA;
1522
+ }
1523
+ pendingGACount() {
1524
+ const heldGA = this.heldGACounts();
1525
+ return [...this.gaQty.entries()].reduce(
1483
1526
  (sum, [areaId, qty]) => sum + Math.max(0, qty - (heldGA.get(areaId) ?? 0)),
1484
1527
  0
1485
1528
  );
1486
- return pendingSeats + pendingGA;
1529
+ }
1530
+ heldTicketCount() {
1531
+ return (this.hold?.items ?? []).reduce((sum, item) => sum + (item.quantity ?? 1), 0);
1532
+ }
1533
+ totalTicketCount() {
1534
+ const heldLabels = new Set((this.hold?.items ?? []).map((item) => item.label));
1535
+ const freshSeats = this.committedSelection().filter((seat) => !heldLabels.has(seat.label)).length;
1536
+ return this.heldTicketCount() + freshSeats + this.pendingGACount();
1537
+ }
1538
+ /** Held tickets and standing quantities consume the same order-wide cap. */
1539
+ updateSelectionCapacity() {
1540
+ const heldLabels = new Set((this.hold?.items ?? []).map((item) => item.label));
1541
+ const selectedHeld = this.committedSelection().filter((seat) => heldLabels.has(seat.label)).length;
1542
+ const remaining = Math.max(0, this.maxTickets - this.heldTicketCount() - this.pendingGACount());
1543
+ this.controller.setMaxSelection(selectedHeld + remaining);
1544
+ }
1545
+ canAddTicket() {
1546
+ if (this.totalTicketCount() < this.maxTickets) return true;
1547
+ this.toast(`You can select up to ${this.maxTickets} tickets for this order.`, "warning");
1548
+ return false;
1549
+ }
1550
+ pendingGATotal(gaAreas) {
1551
+ const heldGA = /* @__PURE__ */ new Map();
1552
+ for (const item of (this.hold?.items ?? []).filter((candidate) => candidate.objectType === "ga")) {
1553
+ heldGA.set(item.objectId, (heldGA.get(item.objectId) ?? 0) + (item.quantity ?? 1));
1554
+ }
1555
+ return gaAreas.reduce(
1556
+ (sum, area) => sum + area.price * Math.max(0, (this.gaQty.get(area.id) ?? 0) - (heldGA.get(area.id) ?? 0)),
1557
+ 0
1558
+ );
1487
1559
  }
1488
1560
  syncCta(count = this.lastTrayCount, pending = this.pendingSelectionCount()) {
1489
1561
  const cta = this.els.cta;
@@ -2210,6 +2282,7 @@ var SeatPicker = class _SeatPicker {
2210
2282
  }
2211
2283
  syncTray() {
2212
2284
  if (!this.els.tray) return;
2285
+ this.updateSelectionCapacity();
2213
2286
  const seats = this.committedSelection();
2214
2287
  const gaAreas = this.controller.getGAAreas();
2215
2288
  const heldItems = this.hold?.items ?? [];
@@ -2222,9 +2295,7 @@ var SeatPicker = class _SeatPicker {
2222
2295
  }
2223
2296
  if (!this.hold) {
2224
2297
  const cats = this.controller.doc?.categories ?? [];
2225
- parts.push(
2226
- `<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>`
2227
- );
2298
+ 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>`);
2228
2299
  }
2229
2300
  for (const item of heldItems) {
2230
2301
  const itemKey = `held:${item.label}`;
@@ -2259,7 +2330,7 @@ var SeatPicker = class _SeatPicker {
2259
2330
  this.lastTrayKeys = nextTrayKeys;
2260
2331
  this.els.tray.querySelectorAll("[data-ba]").forEach((btn) => {
2261
2332
  btn.addEventListener("click", () => {
2262
- this.baQty = Math.max(1, Math.min(8, this.baQty + Number(btn.dataset.ba)));
2333
+ this.baQty = Math.max(1, Math.min(this.maxTickets, this.baQty + Number(btn.dataset.ba)));
2263
2334
  this.syncTray();
2264
2335
  });
2265
2336
  });
@@ -2267,6 +2338,21 @@ var SeatPicker = class _SeatPicker {
2267
2338
  this.baCat = e.target.value;
2268
2339
  });
2269
2340
  this.els.tray.querySelector(".sl-ba-go")?.addEventListener("click", () => {
2341
+ if (this.pendingSelectionCount() > 0) {
2342
+ this.bestAvailableConfirm = true;
2343
+ this.syncTray();
2344
+ this.els.tray.querySelector("[data-ba-replace]")?.focus();
2345
+ return;
2346
+ }
2347
+ void this.bestAvailable(this.baQty, this.baCat || void 0);
2348
+ });
2349
+ this.els.tray.querySelector("[data-ba-cancel]")?.addEventListener("click", () => {
2350
+ this.bestAvailableConfirm = false;
2351
+ this.syncTray();
2352
+ this.els.tray.querySelector(".sl-ba-go")?.focus();
2353
+ });
2354
+ this.els.tray.querySelector("[data-ba-replace]")?.addEventListener("click", () => {
2355
+ this.bestAvailableConfirm = false;
2270
2356
  void this.bestAvailable(this.baQty, this.baCat || void 0);
2271
2357
  });
2272
2358
  this.els.tray.querySelectorAll(".sl-chip .rm").forEach((btn) => {
@@ -2277,12 +2363,26 @@ var SeatPicker = class _SeatPicker {
2277
2363
  return;
2278
2364
  }
2279
2365
  const id = chip.dataset.seat;
2280
- if (this.reducedMotion()) {
2366
+ const label = chip.querySelector("b")?.textContent ?? "Seat";
2367
+ const remove = () => {
2281
2368
  this.controller.deselect([id]);
2369
+ this.toast(`${label} removed.`, "neutral", {
2370
+ label: "Undo",
2371
+ onClick: () => {
2372
+ const restored = this.controller.select([id]);
2373
+ this.toast(
2374
+ restored.length ? `${label} restored.` : `${label} is no longer available.`,
2375
+ restored.length ? "success" : "warning"
2376
+ );
2377
+ }
2378
+ });
2379
+ };
2380
+ if (this.reducedMotion()) {
2381
+ remove();
2282
2382
  return;
2283
2383
  }
2284
2384
  chip.classList.add("sl-leave");
2285
- this.scheduleMotion(() => this.controller.deselect([id]), 150);
2385
+ this.scheduleMotion(remove, 150);
2286
2386
  });
2287
2387
  });
2288
2388
  this.els.tray.querySelectorAll(".sl-chip .tier").forEach((sel) => {
@@ -2299,13 +2399,15 @@ var SeatPicker = class _SeatPicker {
2299
2399
  const areaEl = btn.closest(".sl-ga");
2300
2400
  const id = areaEl.dataset.ga;
2301
2401
  const area = gaAreas.find((a) => a.id === id);
2302
- const next = Math.max(0, Math.min(area?.available ?? 0, (this.gaQty.get(id) ?? 0) + Number(btn.dataset.d)));
2402
+ const delta = Number(btn.dataset.d);
2403
+ if (delta > 0 && !this.canAddTicket()) return;
2404
+ const next = Math.max(0, Math.min(area?.available ?? 0, (this.gaQty.get(id) ?? 0) + delta));
2303
2405
  this.gaQty.set(id, next);
2304
2406
  this.syncTray();
2305
2407
  });
2306
2408
  });
2307
- const gaTotal = gaAreas.reduce((sum, a) => sum + a.price * (this.gaQty.get(a.id) ?? 0), 0);
2308
- const gaCount = [...this.gaQty.values()].reduce((a, b) => a + b, 0);
2409
+ const gaTotal = this.pendingGATotal(gaAreas);
2410
+ const gaCount = this.pendingGACount();
2309
2411
  const heldTotal = heldItems.reduce((sum, item) => sum + item.unitPrice * (item.quantity ?? 1), 0);
2310
2412
  const heldCount = heldItems.reduce((sum, item) => sum + (item.quantity ?? 1), 0);
2311
2413
  const freshSeats = seats.filter((seat) => !heldLabels.has(seat.label));
@@ -2325,10 +2427,15 @@ var SeatPicker = class _SeatPicker {
2325
2427
  if (this.hold) {
2326
2428
  const securedCount = heldCount || this.hold.seats?.length || 0;
2327
2429
  if (this.els.holdTitle) {
2328
- this.els.holdTitle.textContent = `${securedCount} ${securedCount === 1 ? "seat" : "seats"} secured`;
2430
+ this.els.holdTitle.textContent = `${securedCount} secured`;
2329
2431
  }
2330
2432
  if (this.els.holdCopy) {
2331
- this.els.holdCopy.textContent = pendingCount ? `${pendingCount} more selected \u2014 secure before checkout.` : "Checkout timer is running.";
2433
+ this.els.holdCopy.textContent = pendingCount ? `${pendingCount} more selected` : "Checkout timer running";
2434
+ }
2435
+ const change = this.els.holdChange;
2436
+ if (change) {
2437
+ change.disabled = this.releasingHold;
2438
+ change.textContent = this.releasingHold ? "Releasing\u2026" : "Change";
2332
2439
  }
2333
2440
  }
2334
2441
  if (count !== previousCount) this.animateOnce(this.els.count, "sl-value-pop", 380);
@@ -2380,7 +2487,30 @@ var SeatPicker = class _SeatPicker {
2380
2487
  if (button?.isConnected) button.disabled = false;
2381
2488
  }
2382
2489
  }
2490
+ async handleChangeSeats() {
2491
+ if (!this.hold || this.releasingHold) return;
2492
+ this.releasingHold = true;
2493
+ const button = this.els.holdChange;
2494
+ if (button) {
2495
+ button.disabled = true;
2496
+ button.textContent = "Releasing\u2026";
2497
+ }
2498
+ try {
2499
+ await this.release();
2500
+ if (!this.hold) this.toast("Held tickets released. Choose your new seats.", "success");
2501
+ } finally {
2502
+ this.releasingHold = false;
2503
+ if (button?.isConnected) {
2504
+ button.disabled = false;
2505
+ button.textContent = "Change";
2506
+ }
2507
+ }
2508
+ }
2383
2509
  async handleCta() {
2510
+ if (this.totalTicketCount() > this.maxTickets) {
2511
+ this.toast(`Remove tickets until your order has ${this.maxTickets} or fewer.`, "warning");
2512
+ return;
2513
+ }
2384
2514
  const committed = this.committedSelection();
2385
2515
  if (this.hold && !committed.some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
2386
2516
  const seats = this.hold.seats ?? committed;
@@ -2418,7 +2548,10 @@ var SeatPicker = class _SeatPicker {
2418
2548
  this.opts.onCheckout?.(hold, hold.seats ?? chosenSeats, this.buildHandoff(hold));
2419
2549
  } catch (err) {
2420
2550
  this.opts.onError?.(err);
2421
- this.toast("One or more seats were just taken. Please pick again.", "error");
2551
+ const problem = err;
2552
+ const labels = (problem.conflicts ?? []).map((conflict) => conflict.label).filter(Boolean).slice(0, 3);
2553
+ 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.";
2554
+ this.toast(message, "error");
2422
2555
  this.setCtaPhase("idle");
2423
2556
  } finally {
2424
2557
  this.holdingLabels.clear();
@@ -2541,15 +2674,28 @@ var SeatPicker = class _SeatPicker {
2541
2674
  hold ? this.buildHandoff(hold) : null
2542
2675
  );
2543
2676
  }
2544
- toast(msg, tone = "neutral") {
2677
+ toast(msg, tone = "neutral", action) {
2545
2678
  const el = this.els.toast;
2546
2679
  if (!el) return;
2547
- el.textContent = msg;
2680
+ el.replaceChildren();
2681
+ const copy = document.createElement("span");
2682
+ copy.textContent = msg;
2683
+ el.appendChild(copy);
2684
+ el.classList.toggle("has-action", !!action);
2685
+ if (action) {
2686
+ const button = document.createElement("button");
2687
+ button.type = "button";
2688
+ button.className = "sl-toast-action";
2689
+ button.textContent = action.label;
2690
+ button.addEventListener("click", action.onClick, { once: true });
2691
+ el.appendChild(button);
2692
+ }
2548
2693
  el.dataset.tone = tone;
2549
2694
  el.classList.add("on");
2550
2695
  if (this.toastTimer) clearTimeout(this.toastTimer);
2551
2696
  this.toastTimer = setTimeout(() => {
2552
2697
  el.classList.remove("on");
2698
+ el.classList.remove("has-action");
2553
2699
  el.dataset.tone = "neutral";
2554
2700
  }, 4200);
2555
2701
  }
@@ -2599,7 +2745,9 @@ var SeatPicker = class _SeatPicker {
2599
2745
  }
2600
2746
  async bestAvailable(qty, categoryKey) {
2601
2747
  if (this.bestAvailableBusy) return null;
2748
+ qty = Math.max(1, Math.min(this.maxTickets, Math.floor(qty)));
2602
2749
  if (this.confirmSeat) this.cancelConfirm();
2750
+ this.bestAvailableConfirm = false;
2603
2751
  this.bestAvailableBusy = true;
2604
2752
  const button = this.els.tray?.querySelector(".sl-ba-go");
2605
2753
  if (button) {
@@ -2612,6 +2760,7 @@ var SeatPicker = class _SeatPicker {
2612
2760
  this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
2613
2761
  this.handedOff = false;
2614
2762
  this.bookedShown = false;
2763
+ this.gaQty.clear();
2615
2764
  this.startHoldTimer(h.expiresAt);
2616
2765
  this.flashHeldSeats(this.hold);
2617
2766
  this.syncTray();
@@ -2621,14 +2770,13 @@ var SeatPicker = class _SeatPicker {
2621
2770
  return null;
2622
2771
  } catch (err) {
2623
2772
  this.opts.onError?.(err);
2624
- this.toast("Those seats are no longer available. Try another quantity or category.", "error");
2773
+ const reason = err?.reason;
2774
+ 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.";
2775
+ this.toast(message, "error");
2625
2776
  return null;
2626
2777
  } finally {
2627
2778
  this.bestAvailableBusy = false;
2628
- if (!this.hold && button?.isConnected) {
2629
- button.disabled = false;
2630
- button.textContent = "Best available";
2631
- }
2779
+ this.syncTray();
2632
2780
  }
2633
2781
  }
2634
2782
  async release() {
@@ -2818,6 +2966,8 @@ function toRenderStatus(s) {
2818
2966
  var DEFAULT_API_BASE3 = "https://api.seatlayer.io";
2819
2967
  var STYLE_ID2 = "seatlayer-manager-style";
2820
2968
  var FEED_CAP = 80;
2969
+ var MAX_LIVE_SEAT_PULSES = 16;
2970
+ var MAX_LIVE_SECTION_PULSES = 4;
2821
2971
  var LEGEND = [
2822
2972
  { key: "free", label: "Free", color: "#6e7bff" },
2823
2973
  { key: "held", label: "Held", color: "#f4b740" },
@@ -2843,13 +2993,21 @@ var CSS2 = `
2843
2993
  @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)}}
2844
2994
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
2845
2995
  border-top:1px solid var(--slm-line)}
2846
- .slm-kpi{display:flex;min-width:0;flex-direction:column;align-items:center;padding:0 5px;line-height:1.15;text-align:center}
2996
+ .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}
2847
2997
  .slm-kpi b{display:flex;min-width:0;align-items:baseline;justify-content:center;font-size:17px;font-weight:800;
2848
2998
  font-variant-numeric:tabular-nums;white-space:nowrap}
2849
2999
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
2850
3000
  .slm-kpi .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:baseline}
3001
+ .slm-kpi.changed b{animation:slm-kpi-bump .58s cubic-bezier(.2,.8,.2,1)}
3002
+ .slm-kpidelta{position:absolute;right:4px;top:-12px;padding:2px 5px;border-radius:999px;background:rgba(34,160,107,.17);
3003
+ color:#5bd39b!important;font-size:9px!important;letter-spacing:0!important;text-transform:none!important;white-space:nowrap;
3004
+ animation:slm-kpi-delta 1.45s ease-out both;pointer-events:none}
3005
+ .slm-kpidelta.down{background:rgba(244,183,64,.14);color:#f7ca6b!important}
3006
+ @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)}}
3007
+ @keyframes slm-kpi-delta{0%{opacity:0;transform:translateY(5px)}18%,72%{opacity:1;transform:none}100%{opacity:0;transform:translateY(-5px)}}
2851
3008
  .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}
2852
3009
  .slm-barbtn:hover{border-color:var(--slm-muted)}
3010
+ .slm-barbtn.follow.on{background:rgba(34,160,107,.13);border-color:#22a06b;color:#5bd39b}
2853
3011
 
2854
3012
  /* body */
2855
3013
  .slm-body{display:flex;flex:1;min-height:0}
@@ -2861,6 +3019,14 @@ var CSS2 = `
2861
3019
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
2862
3020
  background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
2863
3021
  .slm-zoomhint.on{opacity:1}
3022
+ .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));
3023
+ padding:8px 12px;border:1px solid var(--slm-line);border-radius:999px;background:color-mix(in srgb,var(--slm-surface) 92%,transparent);
3024
+ box-shadow:0 10px 34px rgba(0,0,0,.32);opacity:0;transform:translate(-50%,-8px);pointer-events:none;
3025
+ transition:opacity .18s ease,transform .24s ease;backdrop-filter:blur(10px)}
3026
+ .slm-liveevent.on{opacity:1;transform:translate(-50%,0)}
3027
+ .slm.block-mode .slm-liveevent{top:52px}
3028
+ .slm-liveeventdot{width:8px;height:8px;border-radius:50%;flex:none}.slm-liveeventcopy{min-width:0;overflow:hidden;text-overflow:ellipsis;
3029
+ white-space:nowrap;font-size:12px;font-weight:800}.slm-liveeventhint{color:var(--slm-muted);font-size:10px;white-space:nowrap}
2864
3030
  .slm-rail{width:320px;flex:none;border-left:1px solid var(--slm-line);display:flex;flex-direction:column;min-height:0}
2865
3031
  .slm-railscroll{flex:1;overflow-y:auto;padding:16px}
2866
3032
  .slm-eyebrow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--slm-muted);font-weight:800;margin-bottom:6px}
@@ -2875,13 +3041,16 @@ var CSS2 = `
2875
3041
 
2876
3042
  /* activity feed */
2877
3043
  .slm-feed{display:flex;flex-direction:column;gap:0}
2878
- .slm-feedrow{display:flex;align-items:center;gap:9px;padding:8px 2px;border-bottom:1px solid var(--slm-line);
2879
- font-size:12.5px;animation:slm-in .35s ease}
3044
+ .slm-feedrow{display:flex!important;width:100%;align-items:center;gap:9px;padding:8px 2px!important;border-bottom:1px solid var(--slm-line)!important;
3045
+ border-radius:6px;font-size:12.5px;text-align:left!important;animation:slm-in .35s ease}
3046
+ .slm-feedrow:hover{background:rgba(255,255,255,.035)!important}
2880
3047
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
2881
3048
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
2882
3049
  .slm-feedtext{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2883
3050
  .slm-feedtext b{font-weight:800}
2884
- .slm-feedtime{font-size:11px;color:var(--slm-muted);font-variant-numeric:tabular-nums;flex:none}
3051
+ .slm-feedsection{display:block;overflow:hidden;text-overflow:ellipsis;color:var(--slm-muted);font-size:10px;font-weight:750}
3052
+ .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}
3053
+ .slm-feedlocate{font-size:9.5px;color:var(--slm-accent);font-weight:800}
2885
3054
  .slm-empty{font-size:12.5px;color:var(--slm-muted);padding:12px 0}
2886
3055
 
2887
3056
  /* block toolbar */
@@ -2951,10 +3120,13 @@ var CSS2 = `
2951
3120
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
2952
3121
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
2953
3122
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
2954
- .slm-sectionrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
3123
+ .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}
3124
+ .slm-sectionrow:hover{border-color:var(--slm-muted)!important;transform:translateY(-1px)}
2955
3125
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
2956
3126
  .slm-sectiontop{font-size:12.5px;font-weight:800}.slm-sectionmeta{margin-top:5px;color:var(--slm-muted);font-size:11px}
3127
+ .slm-sectionmeta>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2957
3128
  .slm-trend{font-size:10px;text-transform:uppercase;letter-spacing:.08em}.slm-trend.rising{color:#22a06b}.slm-trend.cooling{color:#f4b740}
3129
+ .slm-sectionlocate{color:var(--slm-accent);font-size:9.5px;font-weight:800}
2958
3130
  .slm-health{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
2959
3131
  .slm-healthitem{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
2960
3132
  .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}
@@ -2981,6 +3153,10 @@ var CSS2 = `
2981
3153
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
2982
3154
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
2983
3155
  .slm.compact .slm-kpi[data-kpi="sold-pct"],.slm.compact .slm-kpi[data-kpi="gross-sales"]{display:none}
3156
+ @media (prefers-reduced-motion:reduce){
3157
+ .slm.live .slm-live-dot,.slm-feedrow,.slm-kpi.changed b,.slm-kpidelta{animation:none!important}
3158
+ .slm-liveevent,.slm-sectionrow{transition:none!important}
3159
+ }
2984
3160
  `;
2985
3161
  function injectStyle() {
2986
3162
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
@@ -3039,6 +3215,8 @@ var SeatManager = class {
3039
3215
  this.controlRoomSnapshot = null;
3040
3216
  this.trendWindowMinutes = 15;
3041
3217
  this.heatEnabled = false;
3218
+ this.lastKpiValues = /* @__PURE__ */ new Map();
3219
+ this.activeKpiDeltas = /* @__PURE__ */ new Map();
3042
3220
  // realtime socket
3043
3221
  this.ws = null;
3044
3222
  this.reconnectTimer = null;
@@ -3048,6 +3226,10 @@ var SeatManager = class {
3048
3226
  this.feed = [];
3049
3227
  this.feedTimer = null;
3050
3228
  this.toastTimer = null;
3229
+ this.liveEventTimer = null;
3230
+ this.kpiCleanupTimer = null;
3231
+ this.followLiveTimer = null;
3232
+ this.followSeatTimer = null;
3051
3233
  this.releaseAt = null;
3052
3234
  this.layoutObserver = null;
3053
3235
  this.tokenExpiresAt = null;
@@ -3077,11 +3259,22 @@ var SeatManager = class {
3077
3259
  else return;
3078
3260
  event.preventDefault();
3079
3261
  };
3262
+ this.onRailClick = (event) => {
3263
+ const target = event.target;
3264
+ const sectionButton = target?.closest("[data-section-focus]");
3265
+ if (sectionButton?.dataset.sectionFocus) {
3266
+ this.locateSection(sectionButton.dataset.sectionFocus);
3267
+ return;
3268
+ }
3269
+ const feedButton = target?.closest("[data-feed-id]");
3270
+ if (feedButton?.dataset.feedId) this.locateActivity(feedButton.dataset.feedId);
3271
+ };
3080
3272
  this.sectionOptions = [];
3081
3273
  this.opts = options;
3082
3274
  this.key = options.eventKey;
3083
3275
  this.mode = options.mode ?? "view";
3084
3276
  this.keepLive = options.keepLiveWhileHidden ?? true;
3277
+ this.followLive = options.followLive ?? false;
3085
3278
  this.currency = options.currency ?? "USD";
3086
3279
  this.tokenExpiresAt = options.tokenExpiresAt ?? null;
3087
3280
  this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE3, options.token);
@@ -3138,6 +3331,19 @@ var SeatManager = class {
3138
3331
  this.applyHeatOverlay();
3139
3332
  this.paintHeatButton();
3140
3333
  }
3334
+ /** Toggle opt-in camera following for new buyer hold/book events. */
3335
+ setFollowLive(enabled) {
3336
+ const changed = this.followLive !== enabled;
3337
+ this.followLive = enabled;
3338
+ if (!enabled) {
3339
+ if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
3340
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3341
+ this.followLiveTimer = null;
3342
+ this.followSeatTimer = null;
3343
+ }
3344
+ this.paintFollowLiveButton();
3345
+ if (changed) this.opts.onFollowLiveChange?.(enabled);
3346
+ }
3141
3347
  /** Change the current-vs-previous sales window and refresh the private projection. */
3142
3348
  setTrendWindow(windowMinutes) {
3143
3349
  const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
@@ -3310,6 +3516,7 @@ var SeatManager = class {
3310
3516
  return Promise.resolve();
3311
3517
  }
3312
3518
  zoomToFit() {
3519
+ this.renderer?.clearSectionFocus();
3313
3520
  this.renderer?.zoomToFit();
3314
3521
  }
3315
3522
  destroy() {
@@ -3317,12 +3524,17 @@ var SeatManager = class {
3317
3524
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
3318
3525
  if (this.feedTimer) clearInterval(this.feedTimer);
3319
3526
  if (this.toastTimer) clearTimeout(this.toastTimer);
3527
+ if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
3528
+ if (this.kpiCleanupTimer) clearTimeout(this.kpiCleanupTimer);
3529
+ if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
3530
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3320
3531
  if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
3321
3532
  if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
3322
3533
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
3323
3534
  this.layoutObserver?.disconnect();
3324
3535
  this.layoutObserver = null;
3325
3536
  this.root?.removeEventListener("keydown", this.onKeyDown);
3537
+ this.els.rail?.removeEventListener("click", this.onRailClick);
3326
3538
  if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
3327
3539
  if (this.ws) {
3328
3540
  try {
@@ -3460,7 +3672,6 @@ var SeatManager = class {
3460
3672
  const id = this.labelToId.get(ch.label);
3461
3673
  if (id) {
3462
3674
  this.renderer?.setStatus([id], toRenderStatus(st));
3463
- this.flash(id, st);
3464
3675
  ids.push(id);
3465
3676
  }
3466
3677
  const verb = this.verbFor(prev, st);
@@ -3469,7 +3680,10 @@ var SeatManager = class {
3469
3680
  group.labels.push(ch.label);
3470
3681
  groups.set(groupKey, group);
3471
3682
  }
3472
- for (const group of groups.values()) this.pushActivity(group.labels, group.verb, group.status);
3683
+ for (const group of groups.values()) {
3684
+ const activity = this.pushActivity(group.labels, group.verb, group.status);
3685
+ if (activity) this.paintSpatialActivity(activity);
3686
+ }
3473
3687
  if (ids.length) {
3474
3688
  this.lastSyncedAt = Date.now();
3475
3689
  this.afterPaint();
@@ -3515,9 +3729,108 @@ var SeatManager = class {
3515
3729
  this.renderer?.forceDraw();
3516
3730
  }
3517
3731
  }
3518
- flash(id, st) {
3519
- if (st === "held") this.renderer?.flashSeat(id, "#f4b740");
3520
- else if (st === "booked") this.renderer?.flashSeat(id, "#22a06b");
3732
+ activityColor(status) {
3733
+ return status === "held" ? "#f4b740" : status === "booked" ? "#22a06b" : status === "blocked" ? "#8b94ac" : "#6e7bff";
3734
+ }
3735
+ sectionsForLabels(labels) {
3736
+ const ids = /* @__PURE__ */ new Set();
3737
+ for (const label of labels) {
3738
+ const seat = this.labelToSeat.get(label);
3739
+ if (!seat) continue;
3740
+ const sectionId = this.sectionByObject.get(seat.rowId);
3741
+ if (sectionId && sectionId !== UNGROUPED_ID) ids.add(sectionId);
3742
+ }
3743
+ const sectionIds = [...ids];
3744
+ return {
3745
+ ids: sectionIds,
3746
+ labels: sectionIds.map((id) => this.sectionLabelById.get(id) ?? id)
3747
+ };
3748
+ }
3749
+ pulseSeatLabels(labels, status) {
3750
+ const color = this.activityColor(status);
3751
+ for (const label of labels.slice(0, MAX_LIVE_SEAT_PULSES)) {
3752
+ const id = this.labelToId.get(label);
3753
+ if (id) this.renderer?.flashSeat(id, color);
3754
+ }
3755
+ }
3756
+ /** Render one grouped realtime operation at the right semantic zoom level. */
3757
+ paintSpatialActivity(activity) {
3758
+ const sectionIds = activity.sectionIds ?? this.sectionsForLabels(activity.labels).ids;
3759
+ const focused = this.renderer?.getFocusedSection() ?? null;
3760
+ const followable = this.followLive && sectionIds.length === 1 && (activity.status === "held" || activity.status === "booked");
3761
+ if (followable && focused === sectionIds[0]) {
3762
+ this.pulseSeatLabels(activity.labels, activity.status);
3763
+ return;
3764
+ }
3765
+ if (followable) {
3766
+ if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
3767
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3768
+ this.followLiveTimer = setTimeout(() => {
3769
+ this.followLiveTimer = null;
3770
+ this.renderer?.focusSection(sectionIds[0]);
3771
+ this.followSeatTimer = setTimeout(() => {
3772
+ this.followSeatTimer = null;
3773
+ this.pulseSeatLabels(activity.labels, activity.status);
3774
+ }, 520);
3775
+ }, 220);
3776
+ return;
3777
+ }
3778
+ if (!focused && sectionIds.length) {
3779
+ const color = this.activityColor(activity.status);
3780
+ for (const sectionId of sectionIds.slice(0, MAX_LIVE_SECTION_PULSES)) {
3781
+ this.renderer?.flashSection(sectionId, color);
3782
+ }
3783
+ return;
3784
+ }
3785
+ if (!sectionIds.length || focused && sectionIds.includes(focused)) {
3786
+ this.pulseSeatLabels(activity.labels, activity.status);
3787
+ }
3788
+ }
3789
+ locateSection(sectionId) {
3790
+ this.renderer?.focusSection(sectionId);
3791
+ }
3792
+ locateActivity(activityId) {
3793
+ const activity = this.feed.find((item) => item.id === activityId);
3794
+ if (!activity) return;
3795
+ const sectionIds = activity.sectionIds ?? this.sectionsForLabels(activity.labels).ids;
3796
+ if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
3797
+ if (sectionIds.length === 1) {
3798
+ this.locateSection(sectionIds[0]);
3799
+ this.followSeatTimer = setTimeout(() => {
3800
+ this.followSeatTimer = null;
3801
+ this.pulseSeatLabels(activity.labels, activity.status);
3802
+ }, 520);
3803
+ return;
3804
+ }
3805
+ this.zoomToFit();
3806
+ this.followSeatTimer = setTimeout(() => {
3807
+ this.followSeatTimer = null;
3808
+ if (sectionIds.length) {
3809
+ const color = this.activityColor(activity.status);
3810
+ for (const sectionId of sectionIds.slice(0, MAX_LIVE_SECTION_PULSES)) {
3811
+ this.renderer?.flashSection(sectionId, color);
3812
+ }
3813
+ } else {
3814
+ this.pulseSeatLabels(activity.labels, activity.status);
3815
+ }
3816
+ }, 280);
3817
+ }
3818
+ showLiveEvent(activity) {
3819
+ const element = this.els.liveevent;
3820
+ if (!element) return;
3821
+ const sections = activity.sectionLabels ?? [];
3822
+ const place = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : activity.label;
3823
+ const noun = activity.count === 1 ? "seat" : "seats";
3824
+ element.innerHTML = `<span class="slm-liveeventdot" style="background:${this.activityColor(activity.status)}"></span>
3825
+ <span class="slm-liveeventcopy">${esc(place)} \xB7 ${activity.count.toLocaleString()} ${noun} ${esc(activity.verb)}</span>
3826
+ <span class="slm-liveeventhint">Live</span>`;
3827
+ element.classList.add("on");
3828
+ if (this.liveEventTimer) clearTimeout(this.liveEventTimer);
3829
+ this.liveEventTimer = setTimeout(() => {
3830
+ this.liveEventTimer = null;
3831
+ element.classList.remove("on");
3832
+ element.innerHTML = "";
3833
+ }, 2800);
3521
3834
  }
3522
3835
  // ---- tallies + feed -------------------------------------------------------
3523
3836
  applyReportRevenue(report) {
@@ -3600,7 +3913,8 @@ var SeatManager = class {
3600
3913
  }
3601
3914
  pushActivity(labels, verb, status, at = Date.now()) {
3602
3915
  const label = labels[0];
3603
- if (!label) return;
3916
+ if (!label) return null;
3917
+ const sections = this.sectionsForLabels(labels);
3604
3918
  const item = {
3605
3919
  id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
3606
3920
  at,
@@ -3608,12 +3922,16 @@ var SeatManager = class {
3608
3922
  labels: [...labels],
3609
3923
  count: labels.length,
3610
3924
  verb,
3611
- status
3925
+ status,
3926
+ sectionIds: sections.ids,
3927
+ sectionLabels: sections.labels
3612
3928
  };
3613
3929
  this.feed.unshift(item);
3614
3930
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
3615
3931
  if (this.mode === "view") this.paintFeed();
3932
+ this.showLiveEvent(item);
3616
3933
  this.opts.onActivity?.(item);
3934
+ return item;
3617
3935
  }
3618
3936
  seedFeed(entries) {
3619
3937
  const verbByAction = {
@@ -3637,6 +3955,7 @@ var SeatManager = class {
3637
3955
  for (const e of entries) {
3638
3956
  const label = e.labels[0];
3639
3957
  if (!label) continue;
3958
+ const sections = this.sectionsForLabels(e.labels);
3640
3959
  const item = {
3641
3960
  id: `log:${e.id}`,
3642
3961
  at: e.at,
@@ -3644,7 +3963,9 @@ var SeatManager = class {
3644
3963
  labels: [...e.labels],
3645
3964
  count: e.labels.length,
3646
3965
  verb: verbByAction[e.action] ?? e.action,
3647
- status: stByAction[e.action] ?? "free"
3966
+ status: stByAction[e.action] ?? "free",
3967
+ sectionIds: sections.ids,
3968
+ sectionLabels: sections.labels
3648
3969
  };
3649
3970
  this.feed.push(item);
3650
3971
  this.opts.onActivity?.(item);
@@ -3682,13 +4003,15 @@ var SeatManager = class {
3682
4003
  for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
3683
4004
  root.innerHTML = `
3684
4005
  <div class="slm-bar">
3685
- <div class="slm-modes" data-ref="modes">
3686
- <button class="slm-mode" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
3687
- <button class="slm-mode" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
3688
- <button class="slm-mode" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
4006
+ <div class="slm-modes" data-ref="modes" role="tablist" aria-label="Manager tools">
4007
+ <button class="slm-mode" role="tab" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
4008
+ <button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
4009
+ <button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
3689
4010
  </div>
3690
4011
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
3691
4012
  <div class="slm-bar-actions">
4013
+ <button class="slm-barbtn follow" data-ref="follow" aria-pressed="false"
4014
+ title="Stay on the current map view unless enabled">Follow live</button>
3692
4015
  <button class="slm-barbtn" data-ref="heat" aria-pressed="false"
3693
4016
  aria-label="Sales momentum overlay off"
3694
4017
  title="Highlight sections selling fastest in the selected time window">Sales momentum</button>
@@ -3700,6 +4023,7 @@ var SeatManager = class {
3700
4023
  <div class="slm-map">
3701
4024
  <div class="slm-map-host" data-ref="maphost"></div>
3702
4025
  <div class="slm-zoomhint" data-ref="zoomhint">Zoom in to marquee-select</div>
4026
+ <div class="slm-liveevent" data-ref="liveevent" role="status" aria-live="polite"></div>
3703
4027
  <div class="slm-hud"><button class="slm-hud-chip" data-ref="zfit">Zoom to fit</button></div>
3704
4028
  </div>
3705
4029
  <aside class="slm-rail"><div class="slm-railscroll" data-ref="rail"></div></aside>
@@ -3719,20 +4043,25 @@ var SeatManager = class {
3719
4043
  modes: ref("modes"),
3720
4044
  livetext: ref("livetext"),
3721
4045
  kpis: ref("kpis"),
4046
+ follow: ref("follow"),
3722
4047
  heat: ref("heat"),
3723
4048
  fullscreen: ref("fullscreen"),
3724
4049
  zoomhint: ref("zoomhint"),
4050
+ liveevent: ref("liveevent"),
3725
4051
  rail: ref("rail"),
3726
4052
  toast: ref("toast"),
3727
4053
  zfit: ref("zfit")
3728
4054
  };
3729
4055
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
3730
4056
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
4057
+ this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
3731
4058
  this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
3732
4059
  this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
3733
4060
  root.addEventListener("keydown", this.onKeyDown);
4061
+ this.els.rail.addEventListener("click", this.onRailClick);
3734
4062
  document.addEventListener("fullscreenchange", this.onFullscreenChange);
3735
4063
  this.paintModeTabs();
4064
+ this.paintFollowLiveButton();
3736
4065
  this.paintHeatButton();
3737
4066
  this.paintFullscreenButton();
3738
4067
  }
@@ -3761,10 +4090,20 @@ var SeatManager = class {
3761
4090
  paintModeTabs() {
3762
4091
  this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
3763
4092
  const el = b;
3764
- el.classList.toggle("on", el.dataset.mode === this.mode);
4093
+ const active = el.dataset.mode === this.mode;
4094
+ el.classList.toggle("on", active);
4095
+ el.setAttribute("aria-selected", String(active));
4096
+ el.tabIndex = active ? 0 : -1;
3765
4097
  });
3766
4098
  this.root?.classList.toggle("block-mode", this.mode === "block");
3767
4099
  }
4100
+ paintFollowLiveButton() {
4101
+ const button = this.els.follow;
4102
+ if (!button) return;
4103
+ button.classList.toggle("on", this.followLive);
4104
+ button.setAttribute("aria-pressed", String(this.followLive));
4105
+ 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.");
4106
+ }
3768
4107
  paintHeatButton() {
3769
4108
  const button = this.els.heat;
3770
4109
  if (!button) return;
@@ -3805,20 +4144,55 @@ var SeatManager = class {
3805
4144
  const show = this.mode === "block" && this.renderer?.getRung?.() !== "seats";
3806
4145
  hint.classList.toggle("on", !!show);
3807
4146
  }
4147
+ formatKpiDelta(key, delta, currency) {
4148
+ const sign = delta > 0 ? "+" : "\u2212";
4149
+ const absolute = Math.abs(delta);
4150
+ if (key === "gross-sales") return `${sign}${fmtMoney(absolute, currency)}`;
4151
+ if (key === "sold-pct") return `${sign}${absolute.toLocaleString()}pt`;
4152
+ return `${sign}${absolute.toLocaleString()}`;
4153
+ }
3808
4154
  paintKpis(t3) {
3809
4155
  if (!this.els.kpis) return;
3810
4156
  const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
3811
4157
  const presence = this.controlRoomSnapshot?.presence;
3812
- this.els.kpis.innerHTML = [
3813
- { key: "sold-seats", n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
3814
- { key: "held-seats", n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
3815
- { key: "buyers", n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
3816
- { key: "active-holds", n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
3817
- { key: "free-seats", n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
3818
- { key: "blocked", n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
3819
- { key: "sold-pct", n: `${t3.capacityPct}%`, l: "Sold" },
3820
- { key: "gross-sales", n: rev, l: "Gross sales" }
3821
- ].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("");
4158
+ const items = [
4159
+ { key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
4160
+ { key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
4161
+ { key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
4162
+ { key: "active-holds", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
4163
+ { key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
4164
+ { key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
4165
+ { key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold" },
4166
+ { key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales" }
4167
+ ];
4168
+ let hasChanges = false;
4169
+ this.els.kpis.innerHTML = items.map((item) => {
4170
+ const previous = this.lastKpiValues.get(item.key);
4171
+ const changed = item.raw != null && previous != null && item.raw !== previous;
4172
+ const delta = changed ? item.raw - previous : 0;
4173
+ if (changed) {
4174
+ hasChanges = true;
4175
+ this.activeKpiDeltas.set(item.key, {
4176
+ text: this.formatKpiDelta(item.key, delta, t3.currency),
4177
+ down: delta < 0
4178
+ });
4179
+ }
4180
+ if (item.raw != null) this.lastKpiValues.set(item.key, item.raw);
4181
+ const activeDelta = this.activeKpiDeltas.get(item.key);
4182
+ return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}">
4183
+ <b>${item.dot ? `<span class="dot" style="background:${item.dot}"></span>` : ""}${item.n}</b><span>${item.l}</span>
4184
+ ${activeDelta ? `<span class="slm-kpidelta${activeDelta.down ? " down" : ""}">${activeDelta.text}</span>` : ""}
4185
+ </div>`;
4186
+ }).join("");
4187
+ if (hasChanges) {
4188
+ if (this.kpiCleanupTimer) clearTimeout(this.kpiCleanupTimer);
4189
+ this.kpiCleanupTimer = setTimeout(() => {
4190
+ this.kpiCleanupTimer = null;
4191
+ this.activeKpiDeltas.clear();
4192
+ this.els.kpis?.querySelectorAll(".slm-kpidelta").forEach((element) => element.remove());
4193
+ this.els.kpis?.querySelectorAll(".slm-kpi.changed").forEach((element) => element.classList.remove("changed"));
4194
+ }, 1500);
4195
+ }
3822
4196
  }
3823
4197
  // ---- DOM: rails -----------------------------------------------------------
3824
4198
  paintRail() {
@@ -3889,10 +4263,10 @@ var SeatManager = class {
3889
4263
  const net = speed?.netBooked ?? 0;
3890
4264
  const netLabel = `${net > 0 ? "+" : ""}${net}`;
3891
4265
  const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
3892
- return `<div class="slm-sectionrow">
3893
- <div class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></div>
3894
- <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>
3895
- </div>`;
4266
+ return `<button type="button" class="slm-sectionrow" data-section-focus="${esc(row.sectionId)}" title="Focus ${esc(row.sectionLabel)} on the map">
4267
+ <span class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></span>
4268
+ <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>
4269
+ </button>`;
3896
4270
  }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
3897
4271
  this.paintTrendWindow();
3898
4272
  this.paintMomentumHelp();
@@ -3961,9 +4335,13 @@ var SeatManager = class {
3961
4335
  const color = { free: "#6e7bff", held: "#f4b740", booked: "#22a06b", blocked: "#8b94ac" };
3962
4336
  this.els.feed.innerHTML = this.feed.map((a) => {
3963
4337
  const extra = a.count > 1 ? ` +${a.count - 1}` : "";
3964
- return `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
3965
- <span class="slm-feedtext">${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
3966
- <span class="slm-feedtime">${relTime(a.at, now)}</span></div>`;
4338
+ const sections = a.sectionLabels ?? [];
4339
+ const sectionCopy = sections.length === 1 ? sections[0] : sections.length > 1 ? `${sections.length} sections` : "";
4340
+ return `<button type="button" class="slm-feedrow" data-feed-id="${esc(a.id)}" title="Locate this activity on the map">
4341
+ <span class="slm-feeddot" style="background:${color[a.status]}"></span>
4342
+ <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>
4343
+ <span class="slm-feedmeta"><span class="slm-feedtime">${relTime(a.at, now)}</span><span class="slm-feedlocate">Locate</span></span>
4344
+ </button>`;
3967
4345
  }).join("");
3968
4346
  }
3969
4347
  renderBlockRail() {
@@ -4219,6 +4597,10 @@ var SeatManager = class {
4219
4597
  // ---- toast / done / fail --------------------------------------------------
4220
4598
  done(action, labels, msg) {
4221
4599
  this.toastOk(msg);
4600
+ if (labels.length) {
4601
+ 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;
4602
+ if (activity) this.paintSpatialActivity(activity);
4603
+ }
4222
4604
  if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
4223
4605
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
4224
4606
  }