@seatlayer/js 0.16.1 → 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
@@ -2966,6 +2966,8 @@ function toRenderStatus(s) {
2966
2966
  var DEFAULT_API_BASE3 = "https://api.seatlayer.io";
2967
2967
  var STYLE_ID2 = "seatlayer-manager-style";
2968
2968
  var FEED_CAP = 80;
2969
+ var MAX_LIVE_SEAT_PULSES = 16;
2970
+ var MAX_LIVE_SECTION_PULSES = 4;
2969
2971
  var LEGEND = [
2970
2972
  { key: "free", label: "Free", color: "#6e7bff" },
2971
2973
  { key: "held", label: "Held", color: "#f4b740" },
@@ -2991,13 +2993,21 @@ var CSS2 = `
2991
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)}}
2992
2994
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
2993
2995
  border-top:1px solid var(--slm-line)}
2994
- .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}
2995
2997
  .slm-kpi b{display:flex;min-width:0;align-items:baseline;justify-content:center;font-size:17px;font-weight:800;
2996
2998
  font-variant-numeric:tabular-nums;white-space:nowrap}
2997
2999
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
2998
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)}}
2999
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}
3000
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}
3001
3011
 
3002
3012
  /* body */
3003
3013
  .slm-body{display:flex;flex:1;min-height:0}
@@ -3009,6 +3019,14 @@ var CSS2 = `
3009
3019
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
3010
3020
  background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
3011
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}
3012
3030
  .slm-rail{width:320px;flex:none;border-left:1px solid var(--slm-line);display:flex;flex-direction:column;min-height:0}
3013
3031
  .slm-railscroll{flex:1;overflow-y:auto;padding:16px}
3014
3032
  .slm-eyebrow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--slm-muted);font-weight:800;margin-bottom:6px}
@@ -3023,13 +3041,16 @@ var CSS2 = `
3023
3041
 
3024
3042
  /* activity feed */
3025
3043
  .slm-feed{display:flex;flex-direction:column;gap:0}
3026
- .slm-feedrow{display:flex;align-items:center;gap:9px;padding:8px 2px;border-bottom:1px solid var(--slm-line);
3027
- 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}
3028
3047
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
3029
3048
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
3030
3049
  .slm-feedtext{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3031
3050
  .slm-feedtext b{font-weight:800}
3032
- .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}
3033
3054
  .slm-empty{font-size:12.5px;color:var(--slm-muted);padding:12px 0}
3034
3055
 
3035
3056
  /* block toolbar */
@@ -3099,10 +3120,13 @@ var CSS2 = `
3099
3120
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
3100
3121
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
3101
3122
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
3102
- .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)}
3103
3125
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
3104
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}
3105
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}
3106
3130
  .slm-health{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
3107
3131
  .slm-healthitem{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
3108
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}
@@ -3129,6 +3153,10 @@ var CSS2 = `
3129
3153
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
3130
3154
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
3131
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
+ }
3132
3160
  `;
3133
3161
  function injectStyle() {
3134
3162
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
@@ -3187,6 +3215,8 @@ var SeatManager = class {
3187
3215
  this.controlRoomSnapshot = null;
3188
3216
  this.trendWindowMinutes = 15;
3189
3217
  this.heatEnabled = false;
3218
+ this.lastKpiValues = /* @__PURE__ */ new Map();
3219
+ this.activeKpiDeltas = /* @__PURE__ */ new Map();
3190
3220
  // realtime socket
3191
3221
  this.ws = null;
3192
3222
  this.reconnectTimer = null;
@@ -3196,6 +3226,10 @@ var SeatManager = class {
3196
3226
  this.feed = [];
3197
3227
  this.feedTimer = null;
3198
3228
  this.toastTimer = null;
3229
+ this.liveEventTimer = null;
3230
+ this.kpiCleanupTimer = null;
3231
+ this.followLiveTimer = null;
3232
+ this.followSeatTimer = null;
3199
3233
  this.releaseAt = null;
3200
3234
  this.layoutObserver = null;
3201
3235
  this.tokenExpiresAt = null;
@@ -3225,11 +3259,22 @@ var SeatManager = class {
3225
3259
  else return;
3226
3260
  event.preventDefault();
3227
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
+ };
3228
3272
  this.sectionOptions = [];
3229
3273
  this.opts = options;
3230
3274
  this.key = options.eventKey;
3231
3275
  this.mode = options.mode ?? "view";
3232
3276
  this.keepLive = options.keepLiveWhileHidden ?? true;
3277
+ this.followLive = options.followLive ?? false;
3233
3278
  this.currency = options.currency ?? "USD";
3234
3279
  this.tokenExpiresAt = options.tokenExpiresAt ?? null;
3235
3280
  this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE3, options.token);
@@ -3286,6 +3331,19 @@ var SeatManager = class {
3286
3331
  this.applyHeatOverlay();
3287
3332
  this.paintHeatButton();
3288
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
+ }
3289
3347
  /** Change the current-vs-previous sales window and refresh the private projection. */
3290
3348
  setTrendWindow(windowMinutes) {
3291
3349
  const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
@@ -3458,6 +3516,7 @@ var SeatManager = class {
3458
3516
  return Promise.resolve();
3459
3517
  }
3460
3518
  zoomToFit() {
3519
+ this.renderer?.clearSectionFocus();
3461
3520
  this.renderer?.zoomToFit();
3462
3521
  }
3463
3522
  destroy() {
@@ -3465,12 +3524,17 @@ var SeatManager = class {
3465
3524
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
3466
3525
  if (this.feedTimer) clearInterval(this.feedTimer);
3467
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);
3468
3531
  if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
3469
3532
  if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
3470
3533
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
3471
3534
  this.layoutObserver?.disconnect();
3472
3535
  this.layoutObserver = null;
3473
3536
  this.root?.removeEventListener("keydown", this.onKeyDown);
3537
+ this.els.rail?.removeEventListener("click", this.onRailClick);
3474
3538
  if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
3475
3539
  if (this.ws) {
3476
3540
  try {
@@ -3608,7 +3672,6 @@ var SeatManager = class {
3608
3672
  const id = this.labelToId.get(ch.label);
3609
3673
  if (id) {
3610
3674
  this.renderer?.setStatus([id], toRenderStatus(st));
3611
- this.flash(id, st);
3612
3675
  ids.push(id);
3613
3676
  }
3614
3677
  const verb = this.verbFor(prev, st);
@@ -3617,7 +3680,10 @@ var SeatManager = class {
3617
3680
  group.labels.push(ch.label);
3618
3681
  groups.set(groupKey, group);
3619
3682
  }
3620
- 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
+ }
3621
3687
  if (ids.length) {
3622
3688
  this.lastSyncedAt = Date.now();
3623
3689
  this.afterPaint();
@@ -3663,9 +3729,108 @@ var SeatManager = class {
3663
3729
  this.renderer?.forceDraw();
3664
3730
  }
3665
3731
  }
3666
- flash(id, st) {
3667
- if (st === "held") this.renderer?.flashSeat(id, "#f4b740");
3668
- 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);
3669
3834
  }
3670
3835
  // ---- tallies + feed -------------------------------------------------------
3671
3836
  applyReportRevenue(report) {
@@ -3748,7 +3913,8 @@ var SeatManager = class {
3748
3913
  }
3749
3914
  pushActivity(labels, verb, status, at = Date.now()) {
3750
3915
  const label = labels[0];
3751
- if (!label) return;
3916
+ if (!label) return null;
3917
+ const sections = this.sectionsForLabels(labels);
3752
3918
  const item = {
3753
3919
  id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
3754
3920
  at,
@@ -3756,12 +3922,16 @@ var SeatManager = class {
3756
3922
  labels: [...labels],
3757
3923
  count: labels.length,
3758
3924
  verb,
3759
- status
3925
+ status,
3926
+ sectionIds: sections.ids,
3927
+ sectionLabels: sections.labels
3760
3928
  };
3761
3929
  this.feed.unshift(item);
3762
3930
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
3763
3931
  if (this.mode === "view") this.paintFeed();
3932
+ this.showLiveEvent(item);
3764
3933
  this.opts.onActivity?.(item);
3934
+ return item;
3765
3935
  }
3766
3936
  seedFeed(entries) {
3767
3937
  const verbByAction = {
@@ -3785,6 +3955,7 @@ var SeatManager = class {
3785
3955
  for (const e of entries) {
3786
3956
  const label = e.labels[0];
3787
3957
  if (!label) continue;
3958
+ const sections = this.sectionsForLabels(e.labels);
3788
3959
  const item = {
3789
3960
  id: `log:${e.id}`,
3790
3961
  at: e.at,
@@ -3792,7 +3963,9 @@ var SeatManager = class {
3792
3963
  labels: [...e.labels],
3793
3964
  count: e.labels.length,
3794
3965
  verb: verbByAction[e.action] ?? e.action,
3795
- status: stByAction[e.action] ?? "free"
3966
+ status: stByAction[e.action] ?? "free",
3967
+ sectionIds: sections.ids,
3968
+ sectionLabels: sections.labels
3796
3969
  };
3797
3970
  this.feed.push(item);
3798
3971
  this.opts.onActivity?.(item);
@@ -3830,13 +4003,15 @@ var SeatManager = class {
3830
4003
  for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
3831
4004
  root.innerHTML = `
3832
4005
  <div class="slm-bar">
3833
- <div class="slm-modes" data-ref="modes">
3834
- <button class="slm-mode" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
3835
- <button class="slm-mode" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
3836
- <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>
3837
4010
  </div>
3838
4011
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
3839
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>
3840
4015
  <button class="slm-barbtn" data-ref="heat" aria-pressed="false"
3841
4016
  aria-label="Sales momentum overlay off"
3842
4017
  title="Highlight sections selling fastest in the selected time window">Sales momentum</button>
@@ -3848,6 +4023,7 @@ var SeatManager = class {
3848
4023
  <div class="slm-map">
3849
4024
  <div class="slm-map-host" data-ref="maphost"></div>
3850
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>
3851
4027
  <div class="slm-hud"><button class="slm-hud-chip" data-ref="zfit">Zoom to fit</button></div>
3852
4028
  </div>
3853
4029
  <aside class="slm-rail"><div class="slm-railscroll" data-ref="rail"></div></aside>
@@ -3867,20 +4043,25 @@ var SeatManager = class {
3867
4043
  modes: ref("modes"),
3868
4044
  livetext: ref("livetext"),
3869
4045
  kpis: ref("kpis"),
4046
+ follow: ref("follow"),
3870
4047
  heat: ref("heat"),
3871
4048
  fullscreen: ref("fullscreen"),
3872
4049
  zoomhint: ref("zoomhint"),
4050
+ liveevent: ref("liveevent"),
3873
4051
  rail: ref("rail"),
3874
4052
  toast: ref("toast"),
3875
4053
  zfit: ref("zfit")
3876
4054
  };
3877
4055
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
3878
4056
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
4057
+ this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
3879
4058
  this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
3880
4059
  this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
3881
4060
  root.addEventListener("keydown", this.onKeyDown);
4061
+ this.els.rail.addEventListener("click", this.onRailClick);
3882
4062
  document.addEventListener("fullscreenchange", this.onFullscreenChange);
3883
4063
  this.paintModeTabs();
4064
+ this.paintFollowLiveButton();
3884
4065
  this.paintHeatButton();
3885
4066
  this.paintFullscreenButton();
3886
4067
  }
@@ -3909,10 +4090,20 @@ var SeatManager = class {
3909
4090
  paintModeTabs() {
3910
4091
  this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
3911
4092
  const el = b;
3912
- 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;
3913
4097
  });
3914
4098
  this.root?.classList.toggle("block-mode", this.mode === "block");
3915
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
+ }
3916
4107
  paintHeatButton() {
3917
4108
  const button = this.els.heat;
3918
4109
  if (!button) return;
@@ -3953,20 +4144,55 @@ var SeatManager = class {
3953
4144
  const show = this.mode === "block" && this.renderer?.getRung?.() !== "seats";
3954
4145
  hint.classList.toggle("on", !!show);
3955
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
+ }
3956
4154
  paintKpis(t3) {
3957
4155
  if (!this.els.kpis) return;
3958
4156
  const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
3959
4157
  const presence = this.controlRoomSnapshot?.presence;
3960
- this.els.kpis.innerHTML = [
3961
- { key: "sold-seats", n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
3962
- { key: "held-seats", n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
3963
- { key: "buyers", n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
3964
- { key: "active-holds", n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
3965
- { key: "free-seats", n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
3966
- { key: "blocked", n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
3967
- { key: "sold-pct", n: `${t3.capacityPct}%`, l: "Sold" },
3968
- { key: "gross-sales", n: rev, l: "Gross sales" }
3969
- ].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
+ }
3970
4196
  }
3971
4197
  // ---- DOM: rails -----------------------------------------------------------
3972
4198
  paintRail() {
@@ -4037,10 +4263,10 @@ var SeatManager = class {
4037
4263
  const net = speed?.netBooked ?? 0;
4038
4264
  const netLabel = `${net > 0 ? "+" : ""}${net}`;
4039
4265
  const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
4040
- return `<div class="slm-sectionrow">
4041
- <div class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></div>
4042
- <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>
4043
- </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>`;
4044
4270
  }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
4045
4271
  this.paintTrendWindow();
4046
4272
  this.paintMomentumHelp();
@@ -4109,9 +4335,13 @@ var SeatManager = class {
4109
4335
  const color = { free: "#6e7bff", held: "#f4b740", booked: "#22a06b", blocked: "#8b94ac" };
4110
4336
  this.els.feed.innerHTML = this.feed.map((a) => {
4111
4337
  const extra = a.count > 1 ? ` +${a.count - 1}` : "";
4112
- return `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
4113
- <span class="slm-feedtext">${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
4114
- <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>`;
4115
4345
  }).join("");
4116
4346
  }
4117
4347
  renderBlockRail() {
@@ -4367,6 +4597,10 @@ var SeatManager = class {
4367
4597
  // ---- toast / done / fail --------------------------------------------------
4368
4598
  done(action, labels, msg) {
4369
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
+ }
4370
4604
  if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
4371
4605
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
4372
4606
  }