@seatlayer/js 0.18.1 → 0.20.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
@@ -26,7 +26,8 @@ __export(index_exports, {
26
26
  ManageApiError: () => ManageApiError,
27
27
  SeatManager: () => SeatManager,
28
28
  SeatPicker: () => SeatPicker,
29
- SeatingChart: () => SeatingChart
29
+ SeatingChart: () => SeatingChart,
30
+ attachPickerFrame: () => attachPickerFrame
30
31
  });
31
32
  module.exports = __toCommonJS(index_exports);
32
33
 
@@ -398,10 +399,30 @@ var EmbeddedDesigner = class {
398
399
  this.timeoutTimer = null;
399
400
  this.phase = "loading";
400
401
  this.restoreContainerPosition = null;
402
+ // Host-side fullscreen pin: saved state we restore on `off`/Escape/destroy.
403
+ this.pinned = false;
404
+ this.frameStyleBeforeFs = null;
405
+ this.docOverflowBeforeFs = null;
406
+ this.bodyOverflowBeforeFs = null;
407
+ this.fsKeyHandler = null;
408
+ /** Latest height (px string) the Designer reported; re-applied after unpin. */
409
+ this.lastAutoHeight = "";
401
410
  this.handleMessage = (event) => {
402
411
  if (!this.frame || event.origin !== this.designerOrigin || event.source !== this.frame.contentWindow) return;
403
412
  if (!event.data || typeof event.data !== "object") return;
404
413
  const data = event.data;
414
+ if (data.type === "seatlayer.designer.resize") {
415
+ if (this.autoResizeEnabled() && typeof data.px === "number" && Number.isFinite(data.px) && data.px > 0) {
416
+ this.lastAutoHeight = `${Math.round(data.px)}px`;
417
+ if (!this.pinned) this.frame.style.height = this.lastAutoHeight;
418
+ }
419
+ return;
420
+ }
421
+ if (data.type === "seatlayer.designer.fullscreen") {
422
+ if (data.on === true) this.pinFullscreen();
423
+ else if (data.on === false) this.unpinFullscreen();
424
+ return;
425
+ }
405
426
  if (typeof data.type !== "string" || !TYPES.has(data.type)) return;
406
427
  const message = {
407
428
  type: data.type,
@@ -484,6 +505,7 @@ var EmbeddedDesigner = class {
484
505
  }
485
506
  destroy() {
486
507
  window.removeEventListener("message", this.handleMessage);
508
+ this.unpinFullscreen();
487
509
  this.clearTimeoutTimer();
488
510
  this.removeOverlay();
489
511
  this.restoreContainerStyle();
@@ -491,10 +513,68 @@ var EmbeddedDesigner = class {
491
513
  this.frame = null;
492
514
  this.designerOrigin = "";
493
515
  this.phase = "loading";
516
+ this.lastAutoHeight = "";
494
517
  }
495
518
  loadingStateEnabled() {
496
519
  return this.options.showLoadingState !== false;
497
520
  }
521
+ autoResizeEnabled() {
522
+ return this.options.autoResize !== false;
523
+ }
524
+ /**
525
+ * Pin the iframe over the host page as a viewport-filling overlay. We save the
526
+ * iframe's inline style and the document scroll state so `unpinFullscreen`
527
+ * restores everything exactly. Escape (host-side) also exits.
528
+ */
529
+ pinFullscreen() {
530
+ if (this.pinned || !this.frame) return;
531
+ this.pinned = true;
532
+ this.frameStyleBeforeFs = this.frame.getAttribute("style");
533
+ Object.assign(this.frame.style, {
534
+ position: "fixed",
535
+ inset: "0",
536
+ width: "100vw",
537
+ height: "100vh",
538
+ margin: "0",
539
+ border: "0",
540
+ zIndex: "2147483000",
541
+ background: "#101625"
542
+ });
543
+ const docEl = document.documentElement;
544
+ this.docOverflowBeforeFs = docEl.style.overflow;
545
+ docEl.style.overflow = "hidden";
546
+ if (document.body) {
547
+ this.bodyOverflowBeforeFs = document.body.style.overflow;
548
+ document.body.style.overflow = "hidden";
549
+ }
550
+ this.fsKeyHandler = (event) => {
551
+ if (event.key === "Escape") this.unpinFullscreen();
552
+ };
553
+ window.addEventListener("keydown", this.fsKeyHandler);
554
+ }
555
+ /** Undo `pinFullscreen`: restore the iframe style + scroll lock. Idempotent. */
556
+ unpinFullscreen() {
557
+ if (!this.pinned) return;
558
+ this.pinned = false;
559
+ if (this.frame) {
560
+ if (this.frameStyleBeforeFs === null) this.frame.removeAttribute("style");
561
+ else this.frame.setAttribute("style", this.frameStyleBeforeFs);
562
+ if (this.autoResizeEnabled() && this.lastAutoHeight) this.frame.style.height = this.lastAutoHeight;
563
+ }
564
+ this.frameStyleBeforeFs = null;
565
+ if (this.docOverflowBeforeFs !== null) {
566
+ document.documentElement.style.overflow = this.docOverflowBeforeFs;
567
+ this.docOverflowBeforeFs = null;
568
+ }
569
+ if (this.bodyOverflowBeforeFs !== null && document.body) {
570
+ document.body.style.overflow = this.bodyOverflowBeforeFs;
571
+ this.bodyOverflowBeforeFs = null;
572
+ }
573
+ if (this.fsKeyHandler) {
574
+ window.removeEventListener("keydown", this.fsKeyHandler);
575
+ this.fsKeyHandler = null;
576
+ }
577
+ }
498
578
  clearTimeoutTimer() {
499
579
  if (this.timeoutTimer !== null) {
500
580
  clearTimeout(this.timeoutTimer);
@@ -1394,6 +1474,10 @@ var SeatPicker = class _SeatPicker {
1394
1474
  this.fsFallback = false;
1395
1475
  this.fsChangeHandler = null;
1396
1476
  this.fsEscHandler = null;
1477
+ /** True once we've asked the host page to pin us fullscreen (framed, no native). */
1478
+ this.framedFs = false;
1479
+ /** Last height (px) posted to a host frame; dedupes redundant reports. */
1480
+ this.lastPostedHeight = 0;
1397
1481
  this.cbEl = null;
1398
1482
  // modal plumbing (set by open())
1399
1483
  this.modalScrim = null;
@@ -1497,24 +1581,98 @@ var SeatPicker = class _SeatPicker {
1497
1581
  const sight = distance != null ? `${distance}${this.tf("picker.sightline", "m to stage \xB7 clear sightline")}` : this.tf("picker.sightlineClear", "Clear sightline");
1498
1582
  return `<button type="button" class="sl-confirm-view sl-confirm-thumbwrap" aria-label="${(0, import_core2.t)("picker.viewFromSeat", { label: seat.label })}"><img class="sl-confirm-thumb" src="${url}" alt="" /><span class="sl-confirm-thumb-badge">\u{1F52D} ${this.tf("picker.viewFromHere", "View from here")}</span></button><div class="sl-confirm-sight"><span aria-hidden="true">\u2713</span>${sight}</div>`;
1499
1583
  }
1584
+ /** True when the picker is rendered inside an iframe (snippet embed at /e/:key). */
1585
+ isFramed() {
1586
+ return typeof window !== "undefined" && window.parent !== window;
1587
+ }
1588
+ /**
1589
+ * Post a widget→host message when framed. targetOrigin is '*' because the
1590
+ * payload carries nothing sensitive (a height number / a fullscreen flag);
1591
+ * hosts verify `event.origin` on their side (see `attachPickerFrame`).
1592
+ */
1593
+ postToHost(message) {
1594
+ if (!this.isFramed()) return;
1595
+ try {
1596
+ window.parent.postMessage(message, "*");
1597
+ } catch {
1598
+ }
1599
+ }
1600
+ /**
1601
+ * Height (px) to advertise to a host frame.
1602
+ *
1603
+ * The picker fills whatever box it's given: `.sl-picker` is `height:100%;
1604
+ * overflow:hidden`, and the /e/:key shell mounts it `position:fixed; inset:0`.
1605
+ * So it has no intrinsic *document* height to read — `scrollHeight` just
1606
+ * collapses to the current viewport, which for a framed embed would echo the
1607
+ * host's own iframe height straight back (a circular value). We therefore
1608
+ * report a width-driven *desired* height: a pleasant landscape box on desktop,
1609
+ * taller on narrow widths where the bottom sheet needs room, clamped to the
1610
+ * widget's `min-height` of 420. Width is host-controlled and never moves in
1611
+ * response to the height we report, so this cannot feedback-loop.
1612
+ */
1613
+ measureFramedHeight() {
1614
+ const root = this.root;
1615
+ if (!root) return 0;
1616
+ const width = root.clientWidth || (typeof window !== "undefined" ? window.innerWidth : 0) || 0;
1617
+ if (width <= 0) return 0;
1618
+ const ratio = width < 640 ? 1.2 : 0.62;
1619
+ return Math.max(420, Math.round(width * ratio));
1620
+ }
1621
+ /** Post `seatlayer:height` to the host when framed and the value changed. */
1622
+ reportFramedHeight() {
1623
+ if (!this.isFramed()) return;
1624
+ const px = this.measureFramedHeight();
1625
+ if (px <= 0 || px === this.lastPostedHeight) return;
1626
+ this.lastPostedHeight = px;
1627
+ this.postToHost({ type: "seatlayer:height", px });
1628
+ }
1500
1629
  /** Full screen via the native API, falling back to a fixed-position overlay (iOS Safari). */
1501
1630
  toggleFullscreen() {
1502
1631
  const root = this.root;
1503
1632
  if (!root) return;
1504
- const active = !!document.fullscreenElement || this.fsFallback;
1633
+ const active = !!document.fullscreenElement || this.fsFallback || this.framedFs;
1505
1634
  if (!active) {
1506
1635
  if (root.requestFullscreen) {
1507
- root.requestFullscreen().catch(() => this.setFsFallback(true));
1636
+ root.requestFullscreen().catch(() => this.enterFsFallback());
1508
1637
  } else {
1509
- this.setFsFallback(true);
1638
+ this.enterFsFallback();
1510
1639
  }
1511
1640
  } else if (document.fullscreenElement) {
1512
1641
  void document.exitFullscreen().catch(() => {
1513
1642
  });
1643
+ } else if (this.framedFs) {
1644
+ this.setFramedFs(false);
1514
1645
  } else {
1515
1646
  this.setFsFallback(false);
1516
1647
  }
1517
1648
  }
1649
+ /**
1650
+ * Native element-fullscreen was unavailable or rejected. When framed, a CSS
1651
+ * `.sl-fs` overlay can't escape the iframe, so we ask the host page to pin us
1652
+ * (`seatlayer:fullscreen`). Otherwise (iOS Safari, same document) fall back to
1653
+ * the `.sl-fs` overlay as before.
1654
+ */
1655
+ enterFsFallback() {
1656
+ if (this.isFramed()) this.setFramedFs(true);
1657
+ else this.setFsFallback(true);
1658
+ }
1659
+ /** Toggle host-driven (framed) fullscreen: post the flag + own the Esc key. */
1660
+ setFramedFs(on) {
1661
+ if (this.framedFs === on) return;
1662
+ this.framedFs = on;
1663
+ this.els.zfs?.setAttribute("aria-pressed", String(on || !!document.fullscreenElement));
1664
+ this.postToHost({ type: "seatlayer:fullscreen", on });
1665
+ if (on && !this.fsEscHandler) {
1666
+ this.fsEscHandler = (e) => {
1667
+ if (e.key === "Escape" && !document.fullscreenElement) this.setFramedFs(false);
1668
+ };
1669
+ window.addEventListener("keydown", this.fsEscHandler);
1670
+ } else if (!on && this.fsEscHandler) {
1671
+ window.removeEventListener("keydown", this.fsEscHandler);
1672
+ this.fsEscHandler = null;
1673
+ }
1674
+ requestAnimationFrame(() => this.controller.zoomToFit());
1675
+ }
1518
1676
  setFsFallback(on) {
1519
1677
  if (this.fsFallback === on) return;
1520
1678
  this.fsFallback = on;
@@ -1684,6 +1842,7 @@ var SeatPicker = class _SeatPicker {
1684
1842
  const applyLayout = () => {
1685
1843
  const w = root.clientWidth;
1686
1844
  if (w <= 0) return;
1845
+ this.reportFramedHeight();
1687
1846
  const next = w < 640 ? "narrow" : "wide";
1688
1847
  if (root.dataset.layout === next) return;
1689
1848
  root.dataset.layout = next;
@@ -1700,7 +1859,7 @@ var SeatPicker = class _SeatPicker {
1700
1859
  this.els.zfs.addEventListener("click", () => this.toggleFullscreen());
1701
1860
  this.fsChangeHandler = () => {
1702
1861
  if (!document.fullscreenElement) this.setFsFallback(false);
1703
- this.els.zfs?.setAttribute("aria-pressed", String(!!document.fullscreenElement || this.fsFallback));
1862
+ this.els.zfs?.setAttribute("aria-pressed", String(!!document.fullscreenElement || this.fsFallback || this.framedFs));
1704
1863
  requestAnimationFrame(() => this.controller.zoomToFit());
1705
1864
  };
1706
1865
  document.addEventListener("fullscreenchange", this.fsChangeHandler);
@@ -3458,6 +3617,7 @@ var SeatPicker = class _SeatPicker {
3458
3617
  this.motionTimers.clear();
3459
3618
  this.ro?.disconnect();
3460
3619
  this.ro = null;
3620
+ if (this.framedFs) this.setFramedFs(false);
3461
3621
  if (this.escHandler) document.removeEventListener("keydown", this.escHandler);
3462
3622
  if (this.fsChangeHandler) document.removeEventListener("fullscreenchange", this.fsChangeHandler);
3463
3623
  if (this.fsEscHandler) window.removeEventListener("keydown", this.fsEscHandler);
@@ -3472,6 +3632,92 @@ var SeatPicker = class _SeatPicker {
3472
3632
  }
3473
3633
  };
3474
3634
 
3635
+ // src/attachPickerFrame.ts
3636
+ function attachPickerFrame(iframe, opts = {}) {
3637
+ let expectedOrigin = opts.origin ?? "";
3638
+ if (!expectedOrigin) {
3639
+ try {
3640
+ expectedOrigin = new URL(iframe.src, window.location.href).origin;
3641
+ } catch {
3642
+ expectedOrigin = "";
3643
+ }
3644
+ }
3645
+ let pinned = false;
3646
+ let frameStyleBeforeFs = null;
3647
+ let docOverflowBeforeFs = null;
3648
+ let bodyOverflowBeforeFs = null;
3649
+ let lastAutoHeight = "";
3650
+ let keyHandler = null;
3651
+ const pin = () => {
3652
+ if (pinned) return;
3653
+ pinned = true;
3654
+ frameStyleBeforeFs = iframe.getAttribute("style");
3655
+ Object.assign(iframe.style, {
3656
+ position: "fixed",
3657
+ inset: "0",
3658
+ width: "100vw",
3659
+ height: "100vh",
3660
+ margin: "0",
3661
+ border: "0",
3662
+ zIndex: "2147483000",
3663
+ background: "#101625"
3664
+ });
3665
+ const docEl = document.documentElement;
3666
+ docOverflowBeforeFs = docEl.style.overflow;
3667
+ docEl.style.overflow = "hidden";
3668
+ if (document.body) {
3669
+ bodyOverflowBeforeFs = document.body.style.overflow;
3670
+ document.body.style.overflow = "hidden";
3671
+ }
3672
+ keyHandler = (event) => {
3673
+ if (event.key === "Escape") unpin();
3674
+ };
3675
+ window.addEventListener("keydown", keyHandler);
3676
+ };
3677
+ const unpin = () => {
3678
+ if (!pinned) return;
3679
+ pinned = false;
3680
+ if (frameStyleBeforeFs === null) iframe.removeAttribute("style");
3681
+ else iframe.setAttribute("style", frameStyleBeforeFs);
3682
+ frameStyleBeforeFs = null;
3683
+ if (lastAutoHeight) iframe.style.height = lastAutoHeight;
3684
+ if (docOverflowBeforeFs !== null) {
3685
+ document.documentElement.style.overflow = docOverflowBeforeFs;
3686
+ docOverflowBeforeFs = null;
3687
+ }
3688
+ if (bodyOverflowBeforeFs !== null && document.body) {
3689
+ document.body.style.overflow = bodyOverflowBeforeFs;
3690
+ bodyOverflowBeforeFs = null;
3691
+ }
3692
+ if (keyHandler) {
3693
+ window.removeEventListener("keydown", keyHandler);
3694
+ keyHandler = null;
3695
+ }
3696
+ };
3697
+ const onMessage = (event) => {
3698
+ if (event.source !== iframe.contentWindow) return;
3699
+ if (expectedOrigin && event.origin !== expectedOrigin) return;
3700
+ if (!event.data || typeof event.data !== "object") return;
3701
+ const data = event.data;
3702
+ if (data.type === "seatlayer:height") {
3703
+ if (typeof data.px === "number" && Number.isFinite(data.px) && data.px > 0) {
3704
+ lastAutoHeight = `${Math.round(data.px)}px`;
3705
+ if (!pinned) iframe.style.height = lastAutoHeight;
3706
+ }
3707
+ return;
3708
+ }
3709
+ if (data.type === "seatlayer:fullscreen") {
3710
+ if (data.on === true) pin();
3711
+ else if (data.on === false) unpin();
3712
+ }
3713
+ };
3714
+ window.addEventListener("message", onMessage);
3715
+ return () => {
3716
+ window.removeEventListener("message", onMessage);
3717
+ unpin();
3718
+ };
3719
+ }
3720
+
3475
3721
  // src/SeatManager.ts
3476
3722
  var import_core3 = require("@seatlayer/core");
3477
3723
 
@@ -3553,6 +3799,21 @@ var ManageApi = class {
3553
3799
  setHoldTtl(key, holdTtlMs) {
3554
3800
  return this.auth(`/v1/events/${encodeURIComponent(key)}/hold-ttl`, { method: "POST", body: { holdTtlMs } });
3555
3801
  }
3802
+ // ---- availability windows (token) ----
3803
+ /** The organizer's current per section/zone availability windows (needs
3804
+ * `event:view`). Ids absent from `rules` are open / on sale. */
3805
+ availability(key) {
3806
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`);
3807
+ }
3808
+ /** Replace the availability windows for a set of section/zone ids (needs
3809
+ * `event:block`). Ids absent from `rules` become open / on sale; a zone rule
3810
+ * cascades to its sections. The worker derives each id's seat labels, so
3811
+ * `labels` on the sent rules is best-effort. Resolves with the authoritative
3812
+ * effective `hidden` set (a due rule may fire at once) and the server-cleaned
3813
+ * `rules` map (fired timed/threshold windows dropped). */
3814
+ setAvailability(key, rules) {
3815
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`, { method: "POST", body: { rules } });
3816
+ }
3556
3817
  // ---- reports (token) ----
3557
3818
  report(key) {
3558
3819
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
@@ -3580,6 +3841,27 @@ var ManageApi = class {
3580
3841
  };
3581
3842
 
3582
3843
  // src/SeatManager.ts
3844
+ function availabilityModeOf(rule) {
3845
+ return rule ? rule.mode : "open";
3846
+ }
3847
+ function availabilityRuleForMode(mode, seatLabels, prev) {
3848
+ switch (mode) {
3849
+ case "open":
3850
+ return null;
3851
+ case "hidden":
3852
+ return { mode: "hidden", labels: seatLabels };
3853
+ case "closed":
3854
+ return { mode: "closed", labels: seatLabels };
3855
+ case "timed":
3856
+ return { mode: "timed", revealAt: prev?.revealAt ?? Date.now() + 36e5, labels: seatLabels };
3857
+ case "threshold":
3858
+ return { mode: "threshold", thresholdPct: prev?.thresholdPct ?? 80, labels: seatLabels };
3859
+ }
3860
+ }
3861
+ function toLocalInput(ms) {
3862
+ const d = new Date(ms - (/* @__PURE__ */ new Date()).getTimezoneOffset() * 6e4);
3863
+ return d.toISOString().slice(0, 16);
3864
+ }
3583
3865
  function resolveContainer4(container) {
3584
3866
  if (typeof container === "string") {
3585
3867
  const el = document.querySelector(container);
@@ -3768,6 +4050,32 @@ var CSS2 = `
3768
4050
  .slm-momentumhelp[hidden]{display:none}.slm-momentumscale{display:flex;align-items:center;gap:7px;color:var(--slm-muted);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.07em}
3769
4051
  .slm-momentumgradient{height:6px;min-width:64px;flex:1;border-radius:999px;background:linear-gradient(90deg,#f4b740,#ef4444)}
3770
4052
  .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
4053
+ /* sections: availability windows */
4054
+ .slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
4055
+ .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);transition:border-color .15s ease,opacity .15s ease}
4056
+ .slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
4057
+ .slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
4058
+ .slm-availhead{display:flex;align-items:center;gap:8px}
4059
+ .slm-availlabel{display:flex;align-items:center;gap:5px;flex:1;min-width:0;font-size:12.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4060
+ .slm-availcaret{flex:none;color:var(--slm-muted);font-size:10px}
4061
+ .slm-availcount{flex:none;font-size:11px;font-weight:700;color:var(--slm-muted);font-variant-numeric:tabular-nums}
4062
+ .slm-availbadge{flex:none;font-size:9px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:999px}
4063
+ .slm-availbadge.hidden{background:rgba(139,148,172,.18);color:#c2c9d8}
4064
+ .slm-availbadge.closed{background:rgba(244,183,64,.16);color:#f7ca6b}
4065
+ .slm-availselwrap{position:relative;flex:none;display:inline-flex}
4066
+ .slm-availmode{width:auto;max-width:190px;padding:6px 8px;font-size:11.5px;font-weight:700;cursor:pointer}
4067
+ .slm-availmode.on{border-color:var(--slm-accent);color:var(--slm-text)}
4068
+ .slm-availmode:disabled{opacity:.55;cursor:progress}
4069
+ .slm-availfollows{flex:none;padding:5px 10px;border:1px solid var(--slm-line);border-radius:7px;background:var(--slm-surface);color:var(--slm-muted);font-size:11px;font-weight:600;white-space:nowrap}
4070
+ .slm-availdetail{display:flex;align-items:center;gap:8px;margin-top:9px}
4071
+ .slm-availdetail .slm-input{flex:1}
4072
+ .slm-availpct{max-width:74px;flex:none!important}
4073
+ .slm-availpctlabel{font-size:11px;color:var(--slm-muted);font-weight:600;white-space:nowrap}
4074
+ .slm-availsummary{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--slm-line);border-radius:9px;color:var(--slm-muted);font-size:12.5px}
4075
+ .slm-availdot{width:9px;height:9px;border-radius:50%;flex:none;background:#22a06b}.slm-availdot.warn{background:#f4b740}
4076
+ .slm-availcallout{display:flex;align-items:flex-start;gap:8px;margin-top:10px;padding:10px 12px;border:1px solid rgba(244,183,64,.45);border-radius:9px;background:rgba(244,183,64,.1)}
4077
+ .slm-availstar{flex:none;margin-top:1px;color:#f4b740;font-size:13px;line-height:1}
4078
+ .slm-availcallout p{font-size:11.5px;line-height:1.55;color:#f4d58a}.slm-availcallout b{color:#ffe4a3;font-weight:800}
3771
4079
  .slm-inspect-card{padding:16px;border:1px solid var(--slm-line);border-radius:12px;background:var(--slm-surface)}
3772
4080
  .slm-inspect-label{font-size:24px;font-weight:850;letter-spacing:-.02em;line-height:1.1}
3773
4081
  .slm-inspect-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px 20px;margin-top:18px}
@@ -3868,6 +4176,13 @@ var SeatManager = class {
3868
4176
  this.tokenRefreshInFlight = false;
3869
4177
  this.sectionByObject = /* @__PURE__ */ new Map();
3870
4178
  this.sectionLabelById = /* @__PURE__ */ new Map();
4179
+ this.sectionsBase = null;
4180
+ // Sections mode (availability windows): organizer rules + the live effective
4181
+ // hidden/closed sets from the snapshot + WS (a timed/threshold rule fires DO-side).
4182
+ this.availabilityRules = {};
4183
+ this.effectiveHidden = /* @__PURE__ */ new Set();
4184
+ this.effectiveClosed = /* @__PURE__ */ new Set();
4185
+ this.availabilitySaving = false;
3871
4186
  this.lastSyncedAt = null;
3872
4187
  this.blockedQuery = "";
3873
4188
  this.blockedSection = "";
@@ -3886,6 +4201,7 @@ var SeatManager = class {
3886
4201
  if (key === "m") this.setMode("view");
3887
4202
  else if (key === "i") this.setMode("inspect");
3888
4203
  else if (key === "b") this.setMode("block");
4204
+ else if (key === "s") this.setMode("sections");
3889
4205
  else if (key === "f") this.toggleFullscreen();
3890
4206
  else return;
3891
4207
  event.preventDefault();
@@ -3929,7 +4245,8 @@ var SeatManager = class {
3929
4245
  this.buildSectionOptions();
3930
4246
  const [, controlRoom] = await Promise.all([
3931
4247
  this.resnapshot(),
3932
- this.refreshControlRoom().catch((err) => this.opts.onError?.(err))
4248
+ this.refreshControlRoom().catch((err) => this.opts.onError?.(err)),
4249
+ this.refreshAvailability()
3933
4250
  ]);
3934
4251
  if (controlRoom?.activity) this.seedFeed(controlRoom.activity);
3935
4252
  else this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
@@ -3954,6 +4271,7 @@ var SeatManager = class {
3954
4271
  if (changed) this.renderer?.clearSelection();
3955
4272
  this.paintModeTabs();
3956
4273
  this.paintRail();
4274
+ this.applySectionCanvasTreatment();
3957
4275
  if (changed) this.opts.onModeChange?.(mode);
3958
4276
  }
3959
4277
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
@@ -4245,6 +4563,7 @@ var SeatManager = class {
4245
4563
  this.attempt = 0;
4246
4564
  this.setLive(true);
4247
4565
  void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
4566
+ void this.refreshAvailability();
4248
4567
  };
4249
4568
  ws.onmessage = (e) => this.onMessage(e);
4250
4569
  ws.onclose = () => {
@@ -4276,6 +4595,9 @@ var SeatManager = class {
4276
4595
  }
4277
4596
  if (!msg || typeof msg !== "object") return;
4278
4597
  const m = msg;
4598
+ if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
4599
+ this.updateEffectiveAvailability(m.hidden, m.closed);
4600
+ }
4279
4601
  if (m.type === "presence") {
4280
4602
  if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
4281
4603
  this.controlRoomSnapshot = {
@@ -4327,6 +4649,7 @@ var SeatManager = class {
4327
4649
  try {
4328
4650
  const objs = await this.api.objects(this.key);
4329
4651
  this.applySnapshot(objs.seats);
4652
+ this.updateEffectiveAvailability(objs.hidden, objs.closed);
4330
4653
  } catch {
4331
4654
  }
4332
4655
  }
@@ -4638,6 +4961,7 @@ var SeatManager = class {
4638
4961
  <button class="slm-mode" role="tab" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
4639
4962
  <button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
4640
4963
  <button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
4964
+ <button class="slm-mode" role="tab" data-mode="sections" title="Sections (S)" aria-keyshortcuts="S">Sections</button>
4641
4965
  </div>
4642
4966
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
4643
4967
  <div class="slm-bar-actions">
@@ -4704,6 +5028,7 @@ var SeatManager = class {
4704
5028
  if (!this.doc) return;
4705
5029
  try {
4706
5030
  const secs = (0, import_core3.computeSections)(this.doc);
5031
+ this.sectionsBase = secs;
4707
5032
  this.sectionOptions = [];
4708
5033
  this.sectionByObject = new Map(secs.objectToSection);
4709
5034
  this.sectionLabelById.clear();
@@ -4829,6 +5154,7 @@ var SeatManager = class {
4829
5154
  paintRail() {
4830
5155
  if (this.mode === "view") this.renderViewRail();
4831
5156
  else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
5157
+ else if (this.mode === "sections") this.renderSectionsRail();
4832
5158
  else this.renderBlockRail();
4833
5159
  this.updateZoomHint();
4834
5160
  }
@@ -4951,6 +5277,259 @@ var SeatManager = class {
4951
5277
  </div>
4952
5278
  </div>`;
4953
5279
  }
5280
+ // ---- sections: availability windows --------------------------------------
5281
+ /** Pull the organizer's availability rules (event:view). Called on load and on
5282
+ * every WS (re)connect, mirroring how the other panels re-hydrate. `closed` is
5283
+ * deterministic from the rules; `hidden` (which folds in already-due timed /
5284
+ * threshold windows) comes from the snapshot + WS effective set. */
5285
+ async refreshAvailability() {
5286
+ try {
5287
+ const res = await this.withAuthRetry(() => this.api.availability(this.key));
5288
+ this.availabilityRules = res.rules ?? {};
5289
+ this.effectiveClosed = new Set(this.closedIdsFromRules(this.availabilityRules));
5290
+ if (this.mode === "sections") this.renderSectionsRail();
5291
+ this.applySectionCanvasTreatment();
5292
+ } catch (err) {
5293
+ this.opts.onError?.(err);
5294
+ }
5295
+ }
5296
+ /** Run a token-authed op; on a 401 re-mint via onTokenRefresh and retry once. */
5297
+ async withAuthRetry(op) {
5298
+ try {
5299
+ return await op();
5300
+ } catch (err) {
5301
+ if (err instanceof ManageApiError && err.status === 401 && this.opts.onTokenRefresh && !this.tokenRefreshInFlight) {
5302
+ await this.rotateToken();
5303
+ return op();
5304
+ }
5305
+ throw err;
5306
+ }
5307
+ }
5308
+ closedIdsFromRules(rules) {
5309
+ return Object.entries(rules).filter(([, r]) => r.mode === "closed").map(([id]) => id);
5310
+ }
5311
+ /** Adopt a new effective hidden/closed set (from a snapshot or WS broadcast) and
5312
+ * repaint the rail + canvas when it actually moves. */
5313
+ updateEffectiveAvailability(hidden, closed) {
5314
+ let changed = false;
5315
+ if (Array.isArray(hidden)) {
5316
+ this.effectiveHidden = new Set(hidden.filter((x) => typeof x === "string"));
5317
+ changed = true;
5318
+ }
5319
+ if (Array.isArray(closed)) {
5320
+ this.effectiveClosed = new Set(closed.filter((x) => typeof x === "string"));
5321
+ changed = true;
5322
+ }
5323
+ if (!changed) return;
5324
+ if (this.mode === "sections") this.renderSectionsRail();
5325
+ this.applySectionCanvasTreatment();
5326
+ }
5327
+ /** Canvas read of the availability state: dim hidden sections to a whisper,
5328
+ * half-light closed sections, leave open sections normal. Only in Sections mode;
5329
+ * cleared in every other tool. */
5330
+ applySectionCanvasTreatment() {
5331
+ if (!this.renderer) return;
5332
+ if (this.mode === "sections") {
5333
+ this.renderer.setDimmedSections([...this.effectiveHidden]);
5334
+ this.renderer.setClosedSections([...this.effectiveClosed]);
5335
+ } else {
5336
+ this.renderer.setDimmedSections(null);
5337
+ this.renderer.setClosedSections(null);
5338
+ }
5339
+ }
5340
+ /** Zone-grouped render tree: each zone header then its sections (which follow the
5341
+ * zone window), then loose sections + the ungrouped bucket. Effective hidden /
5342
+ * closed come from the live sets, rules from the organizer map. */
5343
+ buildSectionRows() {
5344
+ const base = this.sectionsBase;
5345
+ if (!base) return { rows: [], hiddenSections: 0, closedSections: 0 };
5346
+ const zones = this.doc?.zones ?? [];
5347
+ const byZone = /* @__PURE__ */ new Map();
5348
+ const loose = [];
5349
+ for (const s of base.sections) {
5350
+ if (s.zone && zones.some((z) => z.id === s.zone)) {
5351
+ const list = byZone.get(s.zone) ?? [];
5352
+ list.push(s);
5353
+ byZone.set(s.zone, list);
5354
+ } else {
5355
+ loose.push(s);
5356
+ }
5357
+ }
5358
+ const rows = [];
5359
+ let hiddenSections = 0;
5360
+ let closedSections = 0;
5361
+ const push = (kind, node, zoneRuled, parentClosed = false) => {
5362
+ const rule = this.availabilityRules[node.id] ?? null;
5363
+ const effClosed = this.effectiveClosed.has(node.id) || parentClosed;
5364
+ const effHidden = this.effectiveHidden.has(node.id) || zoneRuled && !effClosed;
5365
+ if (kind === "section" && effHidden) hiddenSections += 1;
5366
+ if (kind === "section" && effClosed) closedSections += 1;
5367
+ rows.push({
5368
+ kind,
5369
+ id: node.id,
5370
+ label: node.label,
5371
+ seatCount: node.seatCount,
5372
+ seatLabels: node.seatLabels,
5373
+ rule,
5374
+ hidden: effHidden,
5375
+ closed: effClosed,
5376
+ followsZone: kind === "section" && zoneRuled
5377
+ });
5378
+ };
5379
+ for (const z of zones) {
5380
+ const secs = byZone.get(z.id);
5381
+ if (!secs || !secs.length) continue;
5382
+ const zoneNode = {
5383
+ id: z.id,
5384
+ label: z.label || "Zone",
5385
+ seatCount: secs.reduce((sum, s) => sum + s.seatCount, 0),
5386
+ seatLabels: secs.flatMap((s) => s.seatLabels)
5387
+ };
5388
+ const zoneRuled = !!this.availabilityRules[z.id];
5389
+ const zoneClosed = this.availabilityRules[z.id]?.mode === "closed";
5390
+ push("zone", zoneNode, false);
5391
+ for (const s of secs) push("section", s, zoneRuled, zoneClosed);
5392
+ }
5393
+ for (const s of loose) push("section", s, false);
5394
+ if (base.ungrouped) {
5395
+ const u = base.ungrouped;
5396
+ push("section", { id: import_core3.UNGROUPED_ID, label: u.label, seatCount: u.seatCount, seatLabels: u.seatLabels }, false);
5397
+ }
5398
+ return { rows, hiddenSections, closedSections };
5399
+ }
5400
+ renderSectionsRail() {
5401
+ const { rows, hiddenSections, closedSections } = this.buildSectionRows();
5402
+ if (!rows.length) {
5403
+ this.els.rail.innerHTML = `
5404
+ <p class="slm-eyebrow">Availability windows</p>
5405
+ <p class="slm-hint">Draw sections or zones in the designer to schedule availability per area. This chart has none yet.</p>
5406
+ <div class="slm-empty">No sections on this chart.</div>`;
5407
+ return;
5408
+ }
5409
+ const parts = [];
5410
+ if (hiddenSections) parts.push(`${hiddenSections} hidden`);
5411
+ if (closedSections) parts.push(`${closedSections} closed`);
5412
+ const summary = parts.length ? parts.join(" \xB7 ") : "All sections open and on sale";
5413
+ const warn = hiddenSections > 0 || closedSections > 0;
5414
+ this.els.rail.innerHTML = `
5415
+ <p class="slm-eyebrow">Availability windows</p>
5416
+ <p class="slm-hint">Control when each zone or section goes on sale. Keep it hidden, reveal it at a set time, or <b>auto-reveal once the rest sells past a threshold</b>. Hidden seats vanish for buyers; closed seats stay on the map (flat grey) but can't be bought.</p>
5417
+ <div class="slm-availlist" data-ref="availlist">${rows.map((row) => this.sectionRowHtml(row)).join("")}</div>
5418
+ <div class="slm-availsummary">
5419
+ <span class="slm-availdot${warn ? " warn" : ""}"></span>
5420
+ <span>${esc(summary)}</span>
5421
+ </div>
5422
+ <div class="slm-availcallout">
5423
+ <span class="slm-availstar" aria-hidden="true">\u2726</span>
5424
+ <p><b>Auto-reveal at % sold</b> is our differentiator \u2014 demand-triggered release: the balcony opens itself the moment the stalls hit the threshold. Neither seats.io nor Ticketmaster ships this.</p>
5425
+ </div>`;
5426
+ this.wireSectionRail();
5427
+ this.applySectionCanvasTreatment();
5428
+ }
5429
+ sectionRowHtml(row) {
5430
+ const mode = availabilityModeOf(row.rule);
5431
+ const cls = `slm-availrow${row.kind === "zone" ? " zone" : ""}${row.hidden ? " hidden" : ""}${row.closed ? " closed" : ""}`;
5432
+ const disabled = this.availabilitySaving ? " disabled" : "";
5433
+ const option = (value, text) => `<option value="${value}"${mode === value ? " selected" : ""}>${text}</option>`;
5434
+ const control = row.followsZone ? '<span class="slm-availfollows">Follows zone</span>' : `<span class="slm-availselwrap">
5435
+ <select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc(row.id)}"${disabled} aria-label="Availability for ${esc(row.label)}">
5436
+ ${option("open", "Open \u2014 on sale")}
5437
+ ${option("closed", "Closed \u2014 visible, not on sale")}
5438
+ ${option("hidden", "Hidden \u2014 off the buyer map")}
5439
+ ${option("timed", "Reveal at a time")}
5440
+ ${option("threshold", "Auto-reveal at % sold")}
5441
+ </select>
5442
+ </span>`;
5443
+ let detail = "";
5444
+ if (!row.followsZone && mode === "timed") {
5445
+ const value = row.rule?.revealAt ? esc(toLocalInput(row.rule.revealAt)) : "";
5446
+ detail = `<div class="slm-availdetail">
5447
+ <input type="datetime-local" class="slm-input" data-avail-reveal="${esc(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc(row.label)}" />
5448
+ </div>`;
5449
+ } else if (!row.followsZone && mode === "threshold") {
5450
+ const pct = row.rule?.thresholdPct ?? 80;
5451
+ detail = `<div class="slm-availdetail">
5452
+ <span class="slm-availpctlabel">Reveal at</span>
5453
+ <input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${esc(row.id)}" value="${esc(pct)}"${disabled} aria-label="Percent sold to reveal ${esc(row.label)}" />
5454
+ <span class="slm-availpctlabel">% sold</span>
5455
+ </div>`;
5456
+ }
5457
+ const badge = row.closed ? '<span class="slm-availbadge closed">Closed</span>' : row.hidden ? '<span class="slm-availbadge hidden">Hidden</span>' : "";
5458
+ const caret = row.kind === "zone" ? `<span class="slm-availcaret" aria-hidden="true">${row.hidden ? "\u25B8" : "\u25BE"}</span>` : "";
5459
+ return `<div class="${cls}">
5460
+ <div class="slm-availhead">
5461
+ <span class="slm-availlabel">${caret}${esc(row.label)}</span>
5462
+ ${badge}
5463
+ <span class="slm-availcount">${row.seatCount.toLocaleString()}</span>
5464
+ ${control}
5465
+ </div>
5466
+ ${detail}
5467
+ </div>`;
5468
+ }
5469
+ wireSectionRail() {
5470
+ const rail = this.els.rail;
5471
+ if (!rail) return;
5472
+ rail.querySelectorAll("[data-avail-id]").forEach((select) => {
5473
+ select.addEventListener("change", () => this.setSectionMode(select.dataset.availId, select.value));
5474
+ });
5475
+ rail.querySelectorAll("[data-avail-reveal]").forEach((input) => {
5476
+ input.addEventListener("change", () => {
5477
+ const ms = new Date(input.value).getTime();
5478
+ if (Number.isFinite(ms)) this.setSectionRulePatch(input.dataset.availReveal, { revealAt: ms });
5479
+ });
5480
+ });
5481
+ rail.querySelectorAll("[data-avail-pct]").forEach((input) => {
5482
+ input.addEventListener("change", () => {
5483
+ const pct = Math.max(1, Math.min(100, Number(input.value) || 0));
5484
+ this.setSectionRulePatch(input.dataset.availPct, { thresholdPct: pct });
5485
+ });
5486
+ });
5487
+ }
5488
+ /** Change one row's availability mode. A zone rule subsumes its child section
5489
+ * rules, so those are dropped from the map (the zone window is the truth). */
5490
+ setSectionMode(id, mode) {
5491
+ const row = this.buildSectionRows().rows.find((r) => r.id === id);
5492
+ const seatLabels = row?.seatLabels ?? this.availabilityRules[id]?.labels ?? [];
5493
+ const next = { ...this.availabilityRules };
5494
+ const rule = availabilityRuleForMode(mode, seatLabels, this.availabilityRules[id]);
5495
+ if (rule) next[id] = rule;
5496
+ else delete next[id];
5497
+ if (row?.kind === "zone" && this.sectionsBase) {
5498
+ for (const s of this.sectionsBase.sections) if (s.zone === id) delete next[s.id];
5499
+ }
5500
+ void this.persistAvailability(next);
5501
+ }
5502
+ /** Edit a timed reveal time / threshold percent on an existing row rule. */
5503
+ setSectionRulePatch(id, patch) {
5504
+ const cur = this.availabilityRules[id];
5505
+ if (!cur) return;
5506
+ const row = this.buildSectionRows().rows.find((r) => r.id === id);
5507
+ const labels = row?.seatLabels ?? cur.labels ?? [];
5508
+ void this.persistAvailability({ ...this.availabilityRules, [id]: { ...cur, ...patch, labels } });
5509
+ }
5510
+ /** Optimistically adopt the new rules, then reconcile with the server-cleaned
5511
+ * map + effective hidden/closed sets. Rolls back the rules on failure. */
5512
+ async persistAvailability(next) {
5513
+ const prev = this.availabilityRules;
5514
+ this.availabilityRules = next;
5515
+ this.availabilitySaving = true;
5516
+ if (this.mode === "sections") this.renderSectionsRail();
5517
+ try {
5518
+ const res = await this.withAuthRetry(() => this.api.setAvailability(this.key, next));
5519
+ this.availabilityRules = res.rules;
5520
+ this.effectiveHidden = new Set(res.hidden);
5521
+ this.effectiveClosed = new Set(this.closedIdsFromRules(res.rules));
5522
+ this.availabilitySaving = false;
5523
+ if (this.mode === "sections") this.renderSectionsRail();
5524
+ this.applySectionCanvasTreatment();
5525
+ } catch (err) {
5526
+ this.availabilityRules = prev;
5527
+ this.availabilitySaving = false;
5528
+ if (this.mode === "sections") this.renderSectionsRail();
5529
+ this.toastErr("Couldn't update availability. Try again.");
5530
+ this.opts.onError?.(err);
5531
+ }
5532
+ }
4954
5533
  paintLegend(t3) {
4955
5534
  if (!this.els.legend) return;
4956
5535
  this.els.legend.innerHTML = LEGEND.map((l) => `<div class="slm-legrow"><span class="slm-legdot" style="background:${l.color}"></span>
@@ -5264,6 +5843,7 @@ var SeatManager = class {
5264
5843
  ManageApiError,
5265
5844
  SeatManager,
5266
5845
  SeatPicker,
5267
- SeatingChart
5846
+ SeatingChart,
5847
+ attachPickerFrame
5268
5848
  });
5269
5849
  //# sourceMappingURL=index.cjs.map