@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.cjs CHANGED
@@ -2985,6 +2985,8 @@ function toRenderStatus(s) {
2985
2985
  var DEFAULT_API_BASE3 = "https://api.seatlayer.io";
2986
2986
  var STYLE_ID2 = "seatlayer-manager-style";
2987
2987
  var FEED_CAP = 80;
2988
+ var MAX_LIVE_SEAT_PULSES = 16;
2989
+ var MAX_LIVE_SECTION_PULSES = 4;
2988
2990
  var LEGEND = [
2989
2991
  { key: "free", label: "Free", color: "#6e7bff" },
2990
2992
  { key: "held", label: "Held", color: "#f4b740" },
@@ -3010,13 +3012,21 @@ var CSS2 = `
3010
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)}}
3011
3013
  .slm-kpis{grid-column:1/-1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));width:100%;padding-top:10px;
3012
3014
  border-top:1px solid var(--slm-line)}
3013
- .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}
3014
3016
  .slm-kpi b{display:flex;min-width:0;align-items:baseline;justify-content:center;font-size:17px;font-weight:800;
3015
3017
  font-variant-numeric:tabular-nums;white-space:nowrap}
3016
3018
  .slm-kpi span{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--slm-muted);font-weight:700}
3017
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)}}
3018
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}
3019
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}
3020
3030
 
3021
3031
  /* body */
3022
3032
  .slm-body{display:flex;flex:1;min-height:0}
@@ -3028,6 +3038,14 @@ var CSS2 = `
3028
3038
  .slm-zoomhint{position:absolute;left:50%;top:14px;transform:translateX(-50%);padding:6px 13px;border-radius:999px;
3029
3039
  background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;pointer-events:none;opacity:0;transition:opacity .2s}
3030
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}
3031
3049
  .slm-rail{width:320px;flex:none;border-left:1px solid var(--slm-line);display:flex;flex-direction:column;min-height:0}
3032
3050
  .slm-railscroll{flex:1;overflow-y:auto;padding:16px}
3033
3051
  .slm-eyebrow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--slm-muted);font-weight:800;margin-bottom:6px}
@@ -3042,13 +3060,16 @@ var CSS2 = `
3042
3060
 
3043
3061
  /* activity feed */
3044
3062
  .slm-feed{display:flex;flex-direction:column;gap:0}
3045
- .slm-feedrow{display:flex;align-items:center;gap:9px;padding:8px 2px;border-bottom:1px solid var(--slm-line);
3046
- 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}
3047
3066
  @keyframes slm-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
3048
3067
  .slm-feeddot{width:8px;height:8px;border-radius:50%;flex:none}
3049
3068
  .slm-feedtext{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
3050
3069
  .slm-feedtext b{font-weight:800}
3051
- .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}
3052
3073
  .slm-empty{font-size:12.5px;color:var(--slm-muted);padding:12px 0}
3053
3074
 
3054
3075
  /* block toolbar */
@@ -3118,10 +3139,13 @@ var CSS2 = `
3118
3139
  .slm-barbtn.on{background:rgba(244,183,64,.13);border-color:#f4b740;color:#f7ca6b}
3119
3140
  .slm-sectionlist{display:flex;flex-direction:column;gap:8px;margin-top:4px}
3120
3141
  .slm-sectionlist + .slm-eyebrow{margin-top:18px}
3121
- .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)}
3122
3144
  .slm-sectiontop,.slm-sectionmeta{display:flex;align-items:center;justify-content:space-between;gap:10px}
3123
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}
3124
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}
3125
3149
  .slm-health{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
3126
3150
  .slm-healthitem{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface)}
3127
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}
@@ -3148,6 +3172,10 @@ var CSS2 = `
3148
3172
  .slm.compact .slm-barbtn{flex:1;padding:6px 9px}.slm.compact .slm-kpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
3149
3173
  .slm.compact .slm-kpi[data-kpi="buyers"],.slm.compact .slm-kpi[data-kpi="active-holds"],
3150
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
+ }
3151
3179
  `;
3152
3180
  function injectStyle() {
3153
3181
  if (typeof document === "undefined" || document.getElementById(STYLE_ID2)) return;
@@ -3206,6 +3234,8 @@ var SeatManager = class {
3206
3234
  this.controlRoomSnapshot = null;
3207
3235
  this.trendWindowMinutes = 15;
3208
3236
  this.heatEnabled = false;
3237
+ this.lastKpiValues = /* @__PURE__ */ new Map();
3238
+ this.activeKpiDeltas = /* @__PURE__ */ new Map();
3209
3239
  // realtime socket
3210
3240
  this.ws = null;
3211
3241
  this.reconnectTimer = null;
@@ -3215,6 +3245,10 @@ var SeatManager = class {
3215
3245
  this.feed = [];
3216
3246
  this.feedTimer = null;
3217
3247
  this.toastTimer = null;
3248
+ this.liveEventTimer = null;
3249
+ this.kpiCleanupTimer = null;
3250
+ this.followLiveTimer = null;
3251
+ this.followSeatTimer = null;
3218
3252
  this.releaseAt = null;
3219
3253
  this.layoutObserver = null;
3220
3254
  this.tokenExpiresAt = null;
@@ -3244,11 +3278,22 @@ var SeatManager = class {
3244
3278
  else return;
3245
3279
  event.preventDefault();
3246
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
+ };
3247
3291
  this.sectionOptions = [];
3248
3292
  this.opts = options;
3249
3293
  this.key = options.eventKey;
3250
3294
  this.mode = options.mode ?? "view";
3251
3295
  this.keepLive = options.keepLiveWhileHidden ?? true;
3296
+ this.followLive = options.followLive ?? false;
3252
3297
  this.currency = options.currency ?? "USD";
3253
3298
  this.tokenExpiresAt = options.tokenExpiresAt ?? null;
3254
3299
  this.api = new ManageApi(options.apiBase ?? DEFAULT_API_BASE3, options.token);
@@ -3305,6 +3350,19 @@ var SeatManager = class {
3305
3350
  this.applyHeatOverlay();
3306
3351
  this.paintHeatButton();
3307
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
+ }
3308
3366
  /** Change the current-vs-previous sales window and refresh the private projection. */
3309
3367
  setTrendWindow(windowMinutes) {
3310
3368
  const normalized = Number.isFinite(windowMinutes) ? Math.floor(windowMinutes) : 15;
@@ -3477,6 +3535,7 @@ var SeatManager = class {
3477
3535
  return Promise.resolve();
3478
3536
  }
3479
3537
  zoomToFit() {
3538
+ this.renderer?.clearSectionFocus();
3480
3539
  this.renderer?.zoomToFit();
3481
3540
  }
3482
3541
  destroy() {
@@ -3484,12 +3543,17 @@ var SeatManager = class {
3484
3543
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
3485
3544
  if (this.feedTimer) clearInterval(this.feedTimer);
3486
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);
3487
3550
  if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
3488
3551
  if (this.revenueRefreshTimer) clearTimeout(this.revenueRefreshTimer);
3489
3552
  if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
3490
3553
  this.layoutObserver?.disconnect();
3491
3554
  this.layoutObserver = null;
3492
3555
  this.root?.removeEventListener("keydown", this.onKeyDown);
3556
+ this.els.rail?.removeEventListener("click", this.onRailClick);
3493
3557
  if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", this.onFullscreenChange);
3494
3558
  if (this.ws) {
3495
3559
  try {
@@ -3627,7 +3691,6 @@ var SeatManager = class {
3627
3691
  const id = this.labelToId.get(ch.label);
3628
3692
  if (id) {
3629
3693
  this.renderer?.setStatus([id], toRenderStatus(st));
3630
- this.flash(id, st);
3631
3694
  ids.push(id);
3632
3695
  }
3633
3696
  const verb = this.verbFor(prev, st);
@@ -3636,7 +3699,10 @@ var SeatManager = class {
3636
3699
  group.labels.push(ch.label);
3637
3700
  groups.set(groupKey, group);
3638
3701
  }
3639
- 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
+ }
3640
3706
  if (ids.length) {
3641
3707
  this.lastSyncedAt = Date.now();
3642
3708
  this.afterPaint();
@@ -3682,9 +3748,108 @@ var SeatManager = class {
3682
3748
  this.renderer?.forceDraw();
3683
3749
  }
3684
3750
  }
3685
- flash(id, st) {
3686
- if (st === "held") this.renderer?.flashSeat(id, "#f4b740");
3687
- 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);
3688
3853
  }
3689
3854
  // ---- tallies + feed -------------------------------------------------------
3690
3855
  applyReportRevenue(report) {
@@ -3767,7 +3932,8 @@ var SeatManager = class {
3767
3932
  }
3768
3933
  pushActivity(labels, verb, status, at = Date.now()) {
3769
3934
  const label = labels[0];
3770
- if (!label) return;
3935
+ if (!label) return null;
3936
+ const sections = this.sectionsForLabels(labels);
3771
3937
  const item = {
3772
3938
  id: `${label}:${at}:${Math.random().toString(36).slice(2, 6)}`,
3773
3939
  at,
@@ -3775,12 +3941,16 @@ var SeatManager = class {
3775
3941
  labels: [...labels],
3776
3942
  count: labels.length,
3777
3943
  verb,
3778
- status
3944
+ status,
3945
+ sectionIds: sections.ids,
3946
+ sectionLabels: sections.labels
3779
3947
  };
3780
3948
  this.feed.unshift(item);
3781
3949
  if (this.feed.length > FEED_CAP) this.feed.length = FEED_CAP;
3782
3950
  if (this.mode === "view") this.paintFeed();
3951
+ this.showLiveEvent(item);
3783
3952
  this.opts.onActivity?.(item);
3953
+ return item;
3784
3954
  }
3785
3955
  seedFeed(entries) {
3786
3956
  const verbByAction = {
@@ -3804,6 +3974,7 @@ var SeatManager = class {
3804
3974
  for (const e of entries) {
3805
3975
  const label = e.labels[0];
3806
3976
  if (!label) continue;
3977
+ const sections = this.sectionsForLabels(e.labels);
3807
3978
  const item = {
3808
3979
  id: `log:${e.id}`,
3809
3980
  at: e.at,
@@ -3811,7 +3982,9 @@ var SeatManager = class {
3811
3982
  labels: [...e.labels],
3812
3983
  count: e.labels.length,
3813
3984
  verb: verbByAction[e.action] ?? e.action,
3814
- status: stByAction[e.action] ?? "free"
3985
+ status: stByAction[e.action] ?? "free",
3986
+ sectionIds: sections.ids,
3987
+ sectionLabels: sections.labels
3815
3988
  };
3816
3989
  this.feed.push(item);
3817
3990
  this.opts.onActivity?.(item);
@@ -3849,13 +4022,15 @@ var SeatManager = class {
3849
4022
  for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
3850
4023
  root.innerHTML = `
3851
4024
  <div class="slm-bar">
3852
- <div class="slm-modes" data-ref="modes">
3853
- <button class="slm-mode" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
3854
- <button class="slm-mode" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
3855
- <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>
3856
4029
  </div>
3857
4030
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
3858
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>
3859
4034
  <button class="slm-barbtn" data-ref="heat" aria-pressed="false"
3860
4035
  aria-label="Sales momentum overlay off"
3861
4036
  title="Highlight sections selling fastest in the selected time window">Sales momentum</button>
@@ -3867,6 +4042,7 @@ var SeatManager = class {
3867
4042
  <div class="slm-map">
3868
4043
  <div class="slm-map-host" data-ref="maphost"></div>
3869
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>
3870
4046
  <div class="slm-hud"><button class="slm-hud-chip" data-ref="zfit">Zoom to fit</button></div>
3871
4047
  </div>
3872
4048
  <aside class="slm-rail"><div class="slm-railscroll" data-ref="rail"></div></aside>
@@ -3886,20 +4062,25 @@ var SeatManager = class {
3886
4062
  modes: ref("modes"),
3887
4063
  livetext: ref("livetext"),
3888
4064
  kpis: ref("kpis"),
4065
+ follow: ref("follow"),
3889
4066
  heat: ref("heat"),
3890
4067
  fullscreen: ref("fullscreen"),
3891
4068
  zoomhint: ref("zoomhint"),
4069
+ liveevent: ref("liveevent"),
3892
4070
  rail: ref("rail"),
3893
4071
  toast: ref("toast"),
3894
4072
  zfit: ref("zfit")
3895
4073
  };
3896
4074
  this.els.modes.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => this.setMode(b.dataset.mode)));
3897
4075
  this.els.zfit.addEventListener("click", () => this.zoomToFit());
4076
+ this.els.follow.addEventListener("click", () => this.setFollowLive(!this.followLive));
3898
4077
  this.els.heat.addEventListener("click", () => this.setHeatOverlay(!this.heatEnabled));
3899
4078
  this.els.fullscreen.addEventListener("click", () => this.toggleFullscreen());
3900
4079
  root.addEventListener("keydown", this.onKeyDown);
4080
+ this.els.rail.addEventListener("click", this.onRailClick);
3901
4081
  document.addEventListener("fullscreenchange", this.onFullscreenChange);
3902
4082
  this.paintModeTabs();
4083
+ this.paintFollowLiveButton();
3903
4084
  this.paintHeatButton();
3904
4085
  this.paintFullscreenButton();
3905
4086
  }
@@ -3928,10 +4109,20 @@ var SeatManager = class {
3928
4109
  paintModeTabs() {
3929
4110
  this.els.modes?.querySelectorAll("[data-mode]").forEach((b) => {
3930
4111
  const el = b;
3931
- 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;
3932
4116
  });
3933
4117
  this.root?.classList.toggle("block-mode", this.mode === "block");
3934
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
+ }
3935
4126
  paintHeatButton() {
3936
4127
  const button = this.els.heat;
3937
4128
  if (!button) return;
@@ -3972,20 +4163,55 @@ var SeatManager = class {
3972
4163
  const show = this.mode === "block" && this.renderer?.getRung?.() !== "seats";
3973
4164
  hint.classList.toggle("on", !!show);
3974
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
+ }
3975
4173
  paintKpis(t3) {
3976
4174
  if (!this.els.kpis) return;
3977
4175
  const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
3978
4176
  const presence = this.controlRoomSnapshot?.presence;
3979
- this.els.kpis.innerHTML = [
3980
- { key: "sold-seats", n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
3981
- { key: "held-seats", n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
3982
- { key: "buyers", n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers" },
3983
- { key: "active-holds", n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Active holds" },
3984
- { key: "free-seats", n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff" },
3985
- { key: "blocked", n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac" },
3986
- { key: "sold-pct", n: `${t3.capacityPct}%`, l: "Sold" },
3987
- { key: "gross-sales", n: rev, l: "Gross sales" }
3988
- ].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
+ }
3989
4215
  }
3990
4216
  // ---- DOM: rails -----------------------------------------------------------
3991
4217
  paintRail() {
@@ -4056,10 +4282,10 @@ var SeatManager = class {
4056
4282
  const net = speed?.netBooked ?? 0;
4057
4283
  const netLabel = `${net > 0 ? "+" : ""}${net}`;
4058
4284
  const trend = speed?.trend === "rising" || speed?.trend === "cooling" ? speed.trend : "steady";
4059
- return `<div class="slm-sectionrow">
4060
- <div class="slm-sectiontop"><span>${esc(row.sectionLabel)}</span><span>${fmtMoney(row.bookedRevenue, snapshot.currency)}</span></div>
4061
- <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>
4062
- </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>`;
4063
4289
  }).join("") : '<div class="slm-empty">No section metrics are available for this chart.</div>';
4064
4290
  this.paintTrendWindow();
4065
4291
  this.paintMomentumHelp();
@@ -4128,9 +4354,13 @@ var SeatManager = class {
4128
4354
  const color = { free: "#6e7bff", held: "#f4b740", booked: "#22a06b", blocked: "#8b94ac" };
4129
4355
  this.els.feed.innerHTML = this.feed.map((a) => {
4130
4356
  const extra = a.count > 1 ? ` +${a.count - 1}` : "";
4131
- return `<div class="slm-feedrow"><span class="slm-feeddot" style="background:${color[a.status]}"></span>
4132
- <span class="slm-feedtext">${a.count === 1 ? "Seat" : "Seats"} <b>${esc(a.label)}${extra}</b> ${esc(a.verb)}</span>
4133
- <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>`;
4134
4364
  }).join("");
4135
4365
  }
4136
4366
  renderBlockRail() {
@@ -4386,6 +4616,10 @@ var SeatManager = class {
4386
4616
  // ---- toast / done / fail --------------------------------------------------
4387
4617
  done(action, labels, msg) {
4388
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
+ }
4389
4623
  if (action !== "setHoldTtl") this.scheduleRevenueRefresh(0);
4390
4624
  this.opts.onActionComplete?.({ action, labels, count: labels.length });
4391
4625
  }