@seatlayer/js 0.18.1 → 0.19.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
@@ -366,10 +366,30 @@ var EmbeddedDesigner = class {
366
366
  this.timeoutTimer = null;
367
367
  this.phase = "loading";
368
368
  this.restoreContainerPosition = null;
369
+ // Host-side fullscreen pin: saved state we restore on `off`/Escape/destroy.
370
+ this.pinned = false;
371
+ this.frameStyleBeforeFs = null;
372
+ this.docOverflowBeforeFs = null;
373
+ this.bodyOverflowBeforeFs = null;
374
+ this.fsKeyHandler = null;
375
+ /** Latest height (px string) the Designer reported; re-applied after unpin. */
376
+ this.lastAutoHeight = "";
369
377
  this.handleMessage = (event) => {
370
378
  if (!this.frame || event.origin !== this.designerOrigin || event.source !== this.frame.contentWindow) return;
371
379
  if (!event.data || typeof event.data !== "object") return;
372
380
  const data = event.data;
381
+ if (data.type === "seatlayer.designer.resize") {
382
+ if (this.autoResizeEnabled() && typeof data.px === "number" && Number.isFinite(data.px) && data.px > 0) {
383
+ this.lastAutoHeight = `${Math.round(data.px)}px`;
384
+ if (!this.pinned) this.frame.style.height = this.lastAutoHeight;
385
+ }
386
+ return;
387
+ }
388
+ if (data.type === "seatlayer.designer.fullscreen") {
389
+ if (data.on === true) this.pinFullscreen();
390
+ else if (data.on === false) this.unpinFullscreen();
391
+ return;
392
+ }
373
393
  if (typeof data.type !== "string" || !TYPES.has(data.type)) return;
374
394
  const message = {
375
395
  type: data.type,
@@ -452,6 +472,7 @@ var EmbeddedDesigner = class {
452
472
  }
453
473
  destroy() {
454
474
  window.removeEventListener("message", this.handleMessage);
475
+ this.unpinFullscreen();
455
476
  this.clearTimeoutTimer();
456
477
  this.removeOverlay();
457
478
  this.restoreContainerStyle();
@@ -459,10 +480,68 @@ var EmbeddedDesigner = class {
459
480
  this.frame = null;
460
481
  this.designerOrigin = "";
461
482
  this.phase = "loading";
483
+ this.lastAutoHeight = "";
462
484
  }
463
485
  loadingStateEnabled() {
464
486
  return this.options.showLoadingState !== false;
465
487
  }
488
+ autoResizeEnabled() {
489
+ return this.options.autoResize !== false;
490
+ }
491
+ /**
492
+ * Pin the iframe over the host page as a viewport-filling overlay. We save the
493
+ * iframe's inline style and the document scroll state so `unpinFullscreen`
494
+ * restores everything exactly. Escape (host-side) also exits.
495
+ */
496
+ pinFullscreen() {
497
+ if (this.pinned || !this.frame) return;
498
+ this.pinned = true;
499
+ this.frameStyleBeforeFs = this.frame.getAttribute("style");
500
+ Object.assign(this.frame.style, {
501
+ position: "fixed",
502
+ inset: "0",
503
+ width: "100vw",
504
+ height: "100vh",
505
+ margin: "0",
506
+ border: "0",
507
+ zIndex: "2147483000",
508
+ background: "#101625"
509
+ });
510
+ const docEl = document.documentElement;
511
+ this.docOverflowBeforeFs = docEl.style.overflow;
512
+ docEl.style.overflow = "hidden";
513
+ if (document.body) {
514
+ this.bodyOverflowBeforeFs = document.body.style.overflow;
515
+ document.body.style.overflow = "hidden";
516
+ }
517
+ this.fsKeyHandler = (event) => {
518
+ if (event.key === "Escape") this.unpinFullscreen();
519
+ };
520
+ window.addEventListener("keydown", this.fsKeyHandler);
521
+ }
522
+ /** Undo `pinFullscreen`: restore the iframe style + scroll lock. Idempotent. */
523
+ unpinFullscreen() {
524
+ if (!this.pinned) return;
525
+ this.pinned = false;
526
+ if (this.frame) {
527
+ if (this.frameStyleBeforeFs === null) this.frame.removeAttribute("style");
528
+ else this.frame.setAttribute("style", this.frameStyleBeforeFs);
529
+ if (this.autoResizeEnabled() && this.lastAutoHeight) this.frame.style.height = this.lastAutoHeight;
530
+ }
531
+ this.frameStyleBeforeFs = null;
532
+ if (this.docOverflowBeforeFs !== null) {
533
+ document.documentElement.style.overflow = this.docOverflowBeforeFs;
534
+ this.docOverflowBeforeFs = null;
535
+ }
536
+ if (this.bodyOverflowBeforeFs !== null && document.body) {
537
+ document.body.style.overflow = this.bodyOverflowBeforeFs;
538
+ this.bodyOverflowBeforeFs = null;
539
+ }
540
+ if (this.fsKeyHandler) {
541
+ window.removeEventListener("keydown", this.fsKeyHandler);
542
+ this.fsKeyHandler = null;
543
+ }
544
+ }
466
545
  clearTimeoutTimer() {
467
546
  if (this.timeoutTimer !== null) {
468
547
  clearTimeout(this.timeoutTimer);
@@ -1371,6 +1450,10 @@ var SeatPicker = class _SeatPicker {
1371
1450
  this.fsFallback = false;
1372
1451
  this.fsChangeHandler = null;
1373
1452
  this.fsEscHandler = null;
1453
+ /** True once we've asked the host page to pin us fullscreen (framed, no native). */
1454
+ this.framedFs = false;
1455
+ /** Last height (px) posted to a host frame; dedupes redundant reports. */
1456
+ this.lastPostedHeight = 0;
1374
1457
  this.cbEl = null;
1375
1458
  // modal plumbing (set by open())
1376
1459
  this.modalScrim = null;
@@ -1474,24 +1557,98 @@ var SeatPicker = class _SeatPicker {
1474
1557
  const sight = distance != null ? `${distance}${this.tf("picker.sightline", "m to stage \xB7 clear sightline")}` : this.tf("picker.sightlineClear", "Clear sightline");
1475
1558
  return `<button type="button" class="sl-confirm-view sl-confirm-thumbwrap" aria-label="${t2("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>`;
1476
1559
  }
1560
+ /** True when the picker is rendered inside an iframe (snippet embed at /e/:key). */
1561
+ isFramed() {
1562
+ return typeof window !== "undefined" && window.parent !== window;
1563
+ }
1564
+ /**
1565
+ * Post a widget→host message when framed. targetOrigin is '*' because the
1566
+ * payload carries nothing sensitive (a height number / a fullscreen flag);
1567
+ * hosts verify `event.origin` on their side (see `attachPickerFrame`).
1568
+ */
1569
+ postToHost(message) {
1570
+ if (!this.isFramed()) return;
1571
+ try {
1572
+ window.parent.postMessage(message, "*");
1573
+ } catch {
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Height (px) to advertise to a host frame.
1578
+ *
1579
+ * The picker fills whatever box it's given: `.sl-picker` is `height:100%;
1580
+ * overflow:hidden`, and the /e/:key shell mounts it `position:fixed; inset:0`.
1581
+ * So it has no intrinsic *document* height to read — `scrollHeight` just
1582
+ * collapses to the current viewport, which for a framed embed would echo the
1583
+ * host's own iframe height straight back (a circular value). We therefore
1584
+ * report a width-driven *desired* height: a pleasant landscape box on desktop,
1585
+ * taller on narrow widths where the bottom sheet needs room, clamped to the
1586
+ * widget's `min-height` of 420. Width is host-controlled and never moves in
1587
+ * response to the height we report, so this cannot feedback-loop.
1588
+ */
1589
+ measureFramedHeight() {
1590
+ const root = this.root;
1591
+ if (!root) return 0;
1592
+ const width = root.clientWidth || (typeof window !== "undefined" ? window.innerWidth : 0) || 0;
1593
+ if (width <= 0) return 0;
1594
+ const ratio = width < 640 ? 1.2 : 0.62;
1595
+ return Math.max(420, Math.round(width * ratio));
1596
+ }
1597
+ /** Post `seatlayer:height` to the host when framed and the value changed. */
1598
+ reportFramedHeight() {
1599
+ if (!this.isFramed()) return;
1600
+ const px = this.measureFramedHeight();
1601
+ if (px <= 0 || px === this.lastPostedHeight) return;
1602
+ this.lastPostedHeight = px;
1603
+ this.postToHost({ type: "seatlayer:height", px });
1604
+ }
1477
1605
  /** Full screen via the native API, falling back to a fixed-position overlay (iOS Safari). */
1478
1606
  toggleFullscreen() {
1479
1607
  const root = this.root;
1480
1608
  if (!root) return;
1481
- const active = !!document.fullscreenElement || this.fsFallback;
1609
+ const active = !!document.fullscreenElement || this.fsFallback || this.framedFs;
1482
1610
  if (!active) {
1483
1611
  if (root.requestFullscreen) {
1484
- root.requestFullscreen().catch(() => this.setFsFallback(true));
1612
+ root.requestFullscreen().catch(() => this.enterFsFallback());
1485
1613
  } else {
1486
- this.setFsFallback(true);
1614
+ this.enterFsFallback();
1487
1615
  }
1488
1616
  } else if (document.fullscreenElement) {
1489
1617
  void document.exitFullscreen().catch(() => {
1490
1618
  });
1619
+ } else if (this.framedFs) {
1620
+ this.setFramedFs(false);
1491
1621
  } else {
1492
1622
  this.setFsFallback(false);
1493
1623
  }
1494
1624
  }
1625
+ /**
1626
+ * Native element-fullscreen was unavailable or rejected. When framed, a CSS
1627
+ * `.sl-fs` overlay can't escape the iframe, so we ask the host page to pin us
1628
+ * (`seatlayer:fullscreen`). Otherwise (iOS Safari, same document) fall back to
1629
+ * the `.sl-fs` overlay as before.
1630
+ */
1631
+ enterFsFallback() {
1632
+ if (this.isFramed()) this.setFramedFs(true);
1633
+ else this.setFsFallback(true);
1634
+ }
1635
+ /** Toggle host-driven (framed) fullscreen: post the flag + own the Esc key. */
1636
+ setFramedFs(on) {
1637
+ if (this.framedFs === on) return;
1638
+ this.framedFs = on;
1639
+ this.els.zfs?.setAttribute("aria-pressed", String(on || !!document.fullscreenElement));
1640
+ this.postToHost({ type: "seatlayer:fullscreen", on });
1641
+ if (on && !this.fsEscHandler) {
1642
+ this.fsEscHandler = (e) => {
1643
+ if (e.key === "Escape" && !document.fullscreenElement) this.setFramedFs(false);
1644
+ };
1645
+ window.addEventListener("keydown", this.fsEscHandler);
1646
+ } else if (!on && this.fsEscHandler) {
1647
+ window.removeEventListener("keydown", this.fsEscHandler);
1648
+ this.fsEscHandler = null;
1649
+ }
1650
+ requestAnimationFrame(() => this.controller.zoomToFit());
1651
+ }
1495
1652
  setFsFallback(on) {
1496
1653
  if (this.fsFallback === on) return;
1497
1654
  this.fsFallback = on;
@@ -1661,6 +1818,7 @@ var SeatPicker = class _SeatPicker {
1661
1818
  const applyLayout = () => {
1662
1819
  const w = root.clientWidth;
1663
1820
  if (w <= 0) return;
1821
+ this.reportFramedHeight();
1664
1822
  const next = w < 640 ? "narrow" : "wide";
1665
1823
  if (root.dataset.layout === next) return;
1666
1824
  root.dataset.layout = next;
@@ -1677,7 +1835,7 @@ var SeatPicker = class _SeatPicker {
1677
1835
  this.els.zfs.addEventListener("click", () => this.toggleFullscreen());
1678
1836
  this.fsChangeHandler = () => {
1679
1837
  if (!document.fullscreenElement) this.setFsFallback(false);
1680
- this.els.zfs?.setAttribute("aria-pressed", String(!!document.fullscreenElement || this.fsFallback));
1838
+ this.els.zfs?.setAttribute("aria-pressed", String(!!document.fullscreenElement || this.fsFallback || this.framedFs));
1681
1839
  requestAnimationFrame(() => this.controller.zoomToFit());
1682
1840
  };
1683
1841
  document.addEventListener("fullscreenchange", this.fsChangeHandler);
@@ -3435,6 +3593,7 @@ var SeatPicker = class _SeatPicker {
3435
3593
  this.motionTimers.clear();
3436
3594
  this.ro?.disconnect();
3437
3595
  this.ro = null;
3596
+ if (this.framedFs) this.setFramedFs(false);
3438
3597
  if (this.escHandler) document.removeEventListener("keydown", this.escHandler);
3439
3598
  if (this.fsChangeHandler) document.removeEventListener("fullscreenchange", this.fsChangeHandler);
3440
3599
  if (this.fsEscHandler) window.removeEventListener("keydown", this.fsEscHandler);
@@ -3449,6 +3608,92 @@ var SeatPicker = class _SeatPicker {
3449
3608
  }
3450
3609
  };
3451
3610
 
3611
+ // src/attachPickerFrame.ts
3612
+ function attachPickerFrame(iframe, opts = {}) {
3613
+ let expectedOrigin = opts.origin ?? "";
3614
+ if (!expectedOrigin) {
3615
+ try {
3616
+ expectedOrigin = new URL(iframe.src, window.location.href).origin;
3617
+ } catch {
3618
+ expectedOrigin = "";
3619
+ }
3620
+ }
3621
+ let pinned = false;
3622
+ let frameStyleBeforeFs = null;
3623
+ let docOverflowBeforeFs = null;
3624
+ let bodyOverflowBeforeFs = null;
3625
+ let lastAutoHeight = "";
3626
+ let keyHandler = null;
3627
+ const pin = () => {
3628
+ if (pinned) return;
3629
+ pinned = true;
3630
+ frameStyleBeforeFs = iframe.getAttribute("style");
3631
+ Object.assign(iframe.style, {
3632
+ position: "fixed",
3633
+ inset: "0",
3634
+ width: "100vw",
3635
+ height: "100vh",
3636
+ margin: "0",
3637
+ border: "0",
3638
+ zIndex: "2147483000",
3639
+ background: "#101625"
3640
+ });
3641
+ const docEl = document.documentElement;
3642
+ docOverflowBeforeFs = docEl.style.overflow;
3643
+ docEl.style.overflow = "hidden";
3644
+ if (document.body) {
3645
+ bodyOverflowBeforeFs = document.body.style.overflow;
3646
+ document.body.style.overflow = "hidden";
3647
+ }
3648
+ keyHandler = (event) => {
3649
+ if (event.key === "Escape") unpin();
3650
+ };
3651
+ window.addEventListener("keydown", keyHandler);
3652
+ };
3653
+ const unpin = () => {
3654
+ if (!pinned) return;
3655
+ pinned = false;
3656
+ if (frameStyleBeforeFs === null) iframe.removeAttribute("style");
3657
+ else iframe.setAttribute("style", frameStyleBeforeFs);
3658
+ frameStyleBeforeFs = null;
3659
+ if (lastAutoHeight) iframe.style.height = lastAutoHeight;
3660
+ if (docOverflowBeforeFs !== null) {
3661
+ document.documentElement.style.overflow = docOverflowBeforeFs;
3662
+ docOverflowBeforeFs = null;
3663
+ }
3664
+ if (bodyOverflowBeforeFs !== null && document.body) {
3665
+ document.body.style.overflow = bodyOverflowBeforeFs;
3666
+ bodyOverflowBeforeFs = null;
3667
+ }
3668
+ if (keyHandler) {
3669
+ window.removeEventListener("keydown", keyHandler);
3670
+ keyHandler = null;
3671
+ }
3672
+ };
3673
+ const onMessage = (event) => {
3674
+ if (event.source !== iframe.contentWindow) return;
3675
+ if (expectedOrigin && event.origin !== expectedOrigin) return;
3676
+ if (!event.data || typeof event.data !== "object") return;
3677
+ const data = event.data;
3678
+ if (data.type === "seatlayer:height") {
3679
+ if (typeof data.px === "number" && Number.isFinite(data.px) && data.px > 0) {
3680
+ lastAutoHeight = `${Math.round(data.px)}px`;
3681
+ if (!pinned) iframe.style.height = lastAutoHeight;
3682
+ }
3683
+ return;
3684
+ }
3685
+ if (data.type === "seatlayer:fullscreen") {
3686
+ if (data.on === true) pin();
3687
+ else if (data.on === false) unpin();
3688
+ }
3689
+ };
3690
+ window.addEventListener("message", onMessage);
3691
+ return () => {
3692
+ window.removeEventListener("message", onMessage);
3693
+ unpin();
3694
+ };
3695
+ }
3696
+
3452
3697
  // src/SeatManager.ts
3453
3698
  import {
3454
3699
  SeatmapRenderer,
@@ -3535,6 +3780,21 @@ var ManageApi = class {
3535
3780
  setHoldTtl(key, holdTtlMs) {
3536
3781
  return this.auth(`/v1/events/${encodeURIComponent(key)}/hold-ttl`, { method: "POST", body: { holdTtlMs } });
3537
3782
  }
3783
+ // ---- availability windows (token) ----
3784
+ /** The organizer's current per section/zone availability windows (needs
3785
+ * `event:view`). Ids absent from `rules` are open / on sale. */
3786
+ availability(key) {
3787
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`);
3788
+ }
3789
+ /** Replace the availability windows for a set of section/zone ids (needs
3790
+ * `event:block`). Ids absent from `rules` become open / on sale; a zone rule
3791
+ * cascades to its sections. The worker derives each id's seat labels, so
3792
+ * `labels` on the sent rules is best-effort. Resolves with the authoritative
3793
+ * effective `hidden` set (a due rule may fire at once) and the server-cleaned
3794
+ * `rules` map (fired timed/threshold windows dropped). */
3795
+ setAvailability(key, rules) {
3796
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`, { method: "POST", body: { rules } });
3797
+ }
3538
3798
  // ---- reports (token) ----
3539
3799
  report(key) {
3540
3800
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
@@ -3562,6 +3822,27 @@ var ManageApi = class {
3562
3822
  };
3563
3823
 
3564
3824
  // src/SeatManager.ts
3825
+ function availabilityModeOf(rule) {
3826
+ return rule ? rule.mode : "open";
3827
+ }
3828
+ function availabilityRuleForMode(mode, seatLabels, prev) {
3829
+ switch (mode) {
3830
+ case "open":
3831
+ return null;
3832
+ case "hidden":
3833
+ return { mode: "hidden", labels: seatLabels };
3834
+ case "closed":
3835
+ return { mode: "closed", labels: seatLabels };
3836
+ case "timed":
3837
+ return { mode: "timed", revealAt: prev?.revealAt ?? Date.now() + 36e5, labels: seatLabels };
3838
+ case "threshold":
3839
+ return { mode: "threshold", thresholdPct: prev?.thresholdPct ?? 80, labels: seatLabels };
3840
+ }
3841
+ }
3842
+ function toLocalInput(ms) {
3843
+ const d = new Date(ms - (/* @__PURE__ */ new Date()).getTimezoneOffset() * 6e4);
3844
+ return d.toISOString().slice(0, 16);
3845
+ }
3565
3846
  function resolveContainer4(container) {
3566
3847
  if (typeof container === "string") {
3567
3848
  const el = document.querySelector(container);
@@ -3750,6 +4031,32 @@ var CSS2 = `
3750
4031
  .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}
3751
4032
  .slm-momentumgradient{height:6px;min-width:64px;flex:1;border-radius:999px;background:linear-gradient(90deg,#f4b740,#ef4444)}
3752
4033
  .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
4034
+ /* sections: availability windows */
4035
+ .slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
4036
+ .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}
4037
+ .slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
4038
+ .slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
4039
+ .slm-availhead{display:flex;align-items:center;gap:8px}
4040
+ .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}
4041
+ .slm-availcaret{flex:none;color:var(--slm-muted);font-size:10px}
4042
+ .slm-availcount{flex:none;font-size:11px;font-weight:700;color:var(--slm-muted);font-variant-numeric:tabular-nums}
4043
+ .slm-availbadge{flex:none;font-size:9px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:999px}
4044
+ .slm-availbadge.hidden{background:rgba(139,148,172,.18);color:#c2c9d8}
4045
+ .slm-availbadge.closed{background:rgba(244,183,64,.16);color:#f7ca6b}
4046
+ .slm-availselwrap{position:relative;flex:none;display:inline-flex}
4047
+ .slm-availmode{width:auto;max-width:190px;padding:6px 8px;font-size:11.5px;font-weight:700;cursor:pointer}
4048
+ .slm-availmode.on{border-color:var(--slm-accent);color:var(--slm-text)}
4049
+ .slm-availmode:disabled{opacity:.55;cursor:progress}
4050
+ .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}
4051
+ .slm-availdetail{display:flex;align-items:center;gap:8px;margin-top:9px}
4052
+ .slm-availdetail .slm-input{flex:1}
4053
+ .slm-availpct{max-width:74px;flex:none!important}
4054
+ .slm-availpctlabel{font-size:11px;color:var(--slm-muted);font-weight:600;white-space:nowrap}
4055
+ .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}
4056
+ .slm-availdot{width:9px;height:9px;border-radius:50%;flex:none;background:#22a06b}.slm-availdot.warn{background:#f4b740}
4057
+ .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)}
4058
+ .slm-availstar{flex:none;margin-top:1px;color:#f4b740;font-size:13px;line-height:1}
4059
+ .slm-availcallout p{font-size:11.5px;line-height:1.55;color:#f4d58a}.slm-availcallout b{color:#ffe4a3;font-weight:800}
3753
4060
  .slm-inspect-card{padding:16px;border:1px solid var(--slm-line);border-radius:12px;background:var(--slm-surface)}
3754
4061
  .slm-inspect-label{font-size:24px;font-weight:850;letter-spacing:-.02em;line-height:1.1}
3755
4062
  .slm-inspect-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px 20px;margin-top:18px}
@@ -3850,6 +4157,13 @@ var SeatManager = class {
3850
4157
  this.tokenRefreshInFlight = false;
3851
4158
  this.sectionByObject = /* @__PURE__ */ new Map();
3852
4159
  this.sectionLabelById = /* @__PURE__ */ new Map();
4160
+ this.sectionsBase = null;
4161
+ // Sections mode (availability windows): organizer rules + the live effective
4162
+ // hidden/closed sets from the snapshot + WS (a timed/threshold rule fires DO-side).
4163
+ this.availabilityRules = {};
4164
+ this.effectiveHidden = /* @__PURE__ */ new Set();
4165
+ this.effectiveClosed = /* @__PURE__ */ new Set();
4166
+ this.availabilitySaving = false;
3853
4167
  this.lastSyncedAt = null;
3854
4168
  this.blockedQuery = "";
3855
4169
  this.blockedSection = "";
@@ -3868,6 +4182,7 @@ var SeatManager = class {
3868
4182
  if (key === "m") this.setMode("view");
3869
4183
  else if (key === "i") this.setMode("inspect");
3870
4184
  else if (key === "b") this.setMode("block");
4185
+ else if (key === "s") this.setMode("sections");
3871
4186
  else if (key === "f") this.toggleFullscreen();
3872
4187
  else return;
3873
4188
  event.preventDefault();
@@ -3911,7 +4226,8 @@ var SeatManager = class {
3911
4226
  this.buildSectionOptions();
3912
4227
  const [, controlRoom] = await Promise.all([
3913
4228
  this.resnapshot(),
3914
- this.refreshControlRoom().catch((err) => this.opts.onError?.(err))
4229
+ this.refreshControlRoom().catch((err) => this.opts.onError?.(err)),
4230
+ this.refreshAvailability()
3915
4231
  ]);
3916
4232
  if (controlRoom?.activity) this.seedFeed(controlRoom.activity);
3917
4233
  else this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
@@ -3936,6 +4252,7 @@ var SeatManager = class {
3936
4252
  if (changed) this.renderer?.clearSelection();
3937
4253
  this.paintModeTabs();
3938
4254
  this.paintRail();
4255
+ this.applySectionCanvasTreatment();
3939
4256
  if (changed) this.opts.onModeChange?.(mode);
3940
4257
  }
3941
4258
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
@@ -4227,6 +4544,7 @@ var SeatManager = class {
4227
4544
  this.attempt = 0;
4228
4545
  this.setLive(true);
4229
4546
  void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
4547
+ void this.refreshAvailability();
4230
4548
  };
4231
4549
  ws.onmessage = (e) => this.onMessage(e);
4232
4550
  ws.onclose = () => {
@@ -4258,6 +4576,9 @@ var SeatManager = class {
4258
4576
  }
4259
4577
  if (!msg || typeof msg !== "object") return;
4260
4578
  const m = msg;
4579
+ if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
4580
+ this.updateEffectiveAvailability(m.hidden, m.closed);
4581
+ }
4261
4582
  if (m.type === "presence") {
4262
4583
  if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
4263
4584
  this.controlRoomSnapshot = {
@@ -4309,6 +4630,7 @@ var SeatManager = class {
4309
4630
  try {
4310
4631
  const objs = await this.api.objects(this.key);
4311
4632
  this.applySnapshot(objs.seats);
4633
+ this.updateEffectiveAvailability(objs.hidden, objs.closed);
4312
4634
  } catch {
4313
4635
  }
4314
4636
  }
@@ -4620,6 +4942,7 @@ var SeatManager = class {
4620
4942
  <button class="slm-mode" role="tab" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
4621
4943
  <button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
4622
4944
  <button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
4945
+ <button class="slm-mode" role="tab" data-mode="sections" title="Sections (S)" aria-keyshortcuts="S">Sections</button>
4623
4946
  </div>
4624
4947
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
4625
4948
  <div class="slm-bar-actions">
@@ -4686,6 +5009,7 @@ var SeatManager = class {
4686
5009
  if (!this.doc) return;
4687
5010
  try {
4688
5011
  const secs = computeSections(this.doc);
5012
+ this.sectionsBase = secs;
4689
5013
  this.sectionOptions = [];
4690
5014
  this.sectionByObject = new Map(secs.objectToSection);
4691
5015
  this.sectionLabelById.clear();
@@ -4811,6 +5135,7 @@ var SeatManager = class {
4811
5135
  paintRail() {
4812
5136
  if (this.mode === "view") this.renderViewRail();
4813
5137
  else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
5138
+ else if (this.mode === "sections") this.renderSectionsRail();
4814
5139
  else this.renderBlockRail();
4815
5140
  this.updateZoomHint();
4816
5141
  }
@@ -4933,6 +5258,259 @@ var SeatManager = class {
4933
5258
  </div>
4934
5259
  </div>`;
4935
5260
  }
5261
+ // ---- sections: availability windows --------------------------------------
5262
+ /** Pull the organizer's availability rules (event:view). Called on load and on
5263
+ * every WS (re)connect, mirroring how the other panels re-hydrate. `closed` is
5264
+ * deterministic from the rules; `hidden` (which folds in already-due timed /
5265
+ * threshold windows) comes from the snapshot + WS effective set. */
5266
+ async refreshAvailability() {
5267
+ try {
5268
+ const res = await this.withAuthRetry(() => this.api.availability(this.key));
5269
+ this.availabilityRules = res.rules ?? {};
5270
+ this.effectiveClosed = new Set(this.closedIdsFromRules(this.availabilityRules));
5271
+ if (this.mode === "sections") this.renderSectionsRail();
5272
+ this.applySectionCanvasTreatment();
5273
+ } catch (err) {
5274
+ this.opts.onError?.(err);
5275
+ }
5276
+ }
5277
+ /** Run a token-authed op; on a 401 re-mint via onTokenRefresh and retry once. */
5278
+ async withAuthRetry(op) {
5279
+ try {
5280
+ return await op();
5281
+ } catch (err) {
5282
+ if (err instanceof ManageApiError && err.status === 401 && this.opts.onTokenRefresh && !this.tokenRefreshInFlight) {
5283
+ await this.rotateToken();
5284
+ return op();
5285
+ }
5286
+ throw err;
5287
+ }
5288
+ }
5289
+ closedIdsFromRules(rules) {
5290
+ return Object.entries(rules).filter(([, r]) => r.mode === "closed").map(([id]) => id);
5291
+ }
5292
+ /** Adopt a new effective hidden/closed set (from a snapshot or WS broadcast) and
5293
+ * repaint the rail + canvas when it actually moves. */
5294
+ updateEffectiveAvailability(hidden, closed) {
5295
+ let changed = false;
5296
+ if (Array.isArray(hidden)) {
5297
+ this.effectiveHidden = new Set(hidden.filter((x) => typeof x === "string"));
5298
+ changed = true;
5299
+ }
5300
+ if (Array.isArray(closed)) {
5301
+ this.effectiveClosed = new Set(closed.filter((x) => typeof x === "string"));
5302
+ changed = true;
5303
+ }
5304
+ if (!changed) return;
5305
+ if (this.mode === "sections") this.renderSectionsRail();
5306
+ this.applySectionCanvasTreatment();
5307
+ }
5308
+ /** Canvas read of the availability state: dim hidden sections to a whisper,
5309
+ * half-light closed sections, leave open sections normal. Only in Sections mode;
5310
+ * cleared in every other tool. */
5311
+ applySectionCanvasTreatment() {
5312
+ if (!this.renderer) return;
5313
+ if (this.mode === "sections") {
5314
+ this.renderer.setDimmedSections([...this.effectiveHidden]);
5315
+ this.renderer.setClosedSections([...this.effectiveClosed]);
5316
+ } else {
5317
+ this.renderer.setDimmedSections(null);
5318
+ this.renderer.setClosedSections(null);
5319
+ }
5320
+ }
5321
+ /** Zone-grouped render tree: each zone header then its sections (which follow the
5322
+ * zone window), then loose sections + the ungrouped bucket. Effective hidden /
5323
+ * closed come from the live sets, rules from the organizer map. */
5324
+ buildSectionRows() {
5325
+ const base = this.sectionsBase;
5326
+ if (!base) return { rows: [], hiddenSections: 0, closedSections: 0 };
5327
+ const zones = this.doc?.zones ?? [];
5328
+ const byZone = /* @__PURE__ */ new Map();
5329
+ const loose = [];
5330
+ for (const s of base.sections) {
5331
+ if (s.zone && zones.some((z) => z.id === s.zone)) {
5332
+ const list = byZone.get(s.zone) ?? [];
5333
+ list.push(s);
5334
+ byZone.set(s.zone, list);
5335
+ } else {
5336
+ loose.push(s);
5337
+ }
5338
+ }
5339
+ const rows = [];
5340
+ let hiddenSections = 0;
5341
+ let closedSections = 0;
5342
+ const push = (kind, node, zoneRuled, parentClosed = false) => {
5343
+ const rule = this.availabilityRules[node.id] ?? null;
5344
+ const effClosed = this.effectiveClosed.has(node.id) || parentClosed;
5345
+ const effHidden = this.effectiveHidden.has(node.id) || zoneRuled && !effClosed;
5346
+ if (kind === "section" && effHidden) hiddenSections += 1;
5347
+ if (kind === "section" && effClosed) closedSections += 1;
5348
+ rows.push({
5349
+ kind,
5350
+ id: node.id,
5351
+ label: node.label,
5352
+ seatCount: node.seatCount,
5353
+ seatLabels: node.seatLabels,
5354
+ rule,
5355
+ hidden: effHidden,
5356
+ closed: effClosed,
5357
+ followsZone: kind === "section" && zoneRuled
5358
+ });
5359
+ };
5360
+ for (const z of zones) {
5361
+ const secs = byZone.get(z.id);
5362
+ if (!secs || !secs.length) continue;
5363
+ const zoneNode = {
5364
+ id: z.id,
5365
+ label: z.label || "Zone",
5366
+ seatCount: secs.reduce((sum, s) => sum + s.seatCount, 0),
5367
+ seatLabels: secs.flatMap((s) => s.seatLabels)
5368
+ };
5369
+ const zoneRuled = !!this.availabilityRules[z.id];
5370
+ const zoneClosed = this.availabilityRules[z.id]?.mode === "closed";
5371
+ push("zone", zoneNode, false);
5372
+ for (const s of secs) push("section", s, zoneRuled, zoneClosed);
5373
+ }
5374
+ for (const s of loose) push("section", s, false);
5375
+ if (base.ungrouped) {
5376
+ const u = base.ungrouped;
5377
+ push("section", { id: UNGROUPED_ID, label: u.label, seatCount: u.seatCount, seatLabels: u.seatLabels }, false);
5378
+ }
5379
+ return { rows, hiddenSections, closedSections };
5380
+ }
5381
+ renderSectionsRail() {
5382
+ const { rows, hiddenSections, closedSections } = this.buildSectionRows();
5383
+ if (!rows.length) {
5384
+ this.els.rail.innerHTML = `
5385
+ <p class="slm-eyebrow">Availability windows</p>
5386
+ <p class="slm-hint">Draw sections or zones in the designer to schedule availability per area. This chart has none yet.</p>
5387
+ <div class="slm-empty">No sections on this chart.</div>`;
5388
+ return;
5389
+ }
5390
+ const parts = [];
5391
+ if (hiddenSections) parts.push(`${hiddenSections} hidden`);
5392
+ if (closedSections) parts.push(`${closedSections} closed`);
5393
+ const summary = parts.length ? parts.join(" \xB7 ") : "All sections open and on sale";
5394
+ const warn = hiddenSections > 0 || closedSections > 0;
5395
+ this.els.rail.innerHTML = `
5396
+ <p class="slm-eyebrow">Availability windows</p>
5397
+ <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>
5398
+ <div class="slm-availlist" data-ref="availlist">${rows.map((row) => this.sectionRowHtml(row)).join("")}</div>
5399
+ <div class="slm-availsummary">
5400
+ <span class="slm-availdot${warn ? " warn" : ""}"></span>
5401
+ <span>${esc(summary)}</span>
5402
+ </div>
5403
+ <div class="slm-availcallout">
5404
+ <span class="slm-availstar" aria-hidden="true">\u2726</span>
5405
+ <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>
5406
+ </div>`;
5407
+ this.wireSectionRail();
5408
+ this.applySectionCanvasTreatment();
5409
+ }
5410
+ sectionRowHtml(row) {
5411
+ const mode = availabilityModeOf(row.rule);
5412
+ const cls = `slm-availrow${row.kind === "zone" ? " zone" : ""}${row.hidden ? " hidden" : ""}${row.closed ? " closed" : ""}`;
5413
+ const disabled = this.availabilitySaving ? " disabled" : "";
5414
+ const option = (value, text) => `<option value="${value}"${mode === value ? " selected" : ""}>${text}</option>`;
5415
+ const control = row.followsZone ? '<span class="slm-availfollows">Follows zone</span>' : `<span class="slm-availselwrap">
5416
+ <select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc(row.id)}"${disabled} aria-label="Availability for ${esc(row.label)}">
5417
+ ${option("open", "Open \u2014 on sale")}
5418
+ ${option("closed", "Closed \u2014 visible, not on sale")}
5419
+ ${option("hidden", "Hidden \u2014 off the buyer map")}
5420
+ ${option("timed", "Reveal at a time")}
5421
+ ${option("threshold", "Auto-reveal at % sold")}
5422
+ </select>
5423
+ </span>`;
5424
+ let detail = "";
5425
+ if (!row.followsZone && mode === "timed") {
5426
+ const value = row.rule?.revealAt ? esc(toLocalInput(row.rule.revealAt)) : "";
5427
+ detail = `<div class="slm-availdetail">
5428
+ <input type="datetime-local" class="slm-input" data-avail-reveal="${esc(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc(row.label)}" />
5429
+ </div>`;
5430
+ } else if (!row.followsZone && mode === "threshold") {
5431
+ const pct = row.rule?.thresholdPct ?? 80;
5432
+ detail = `<div class="slm-availdetail">
5433
+ <span class="slm-availpctlabel">Reveal at</span>
5434
+ <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)}" />
5435
+ <span class="slm-availpctlabel">% sold</span>
5436
+ </div>`;
5437
+ }
5438
+ const badge = row.closed ? '<span class="slm-availbadge closed">Closed</span>' : row.hidden ? '<span class="slm-availbadge hidden">Hidden</span>' : "";
5439
+ const caret = row.kind === "zone" ? `<span class="slm-availcaret" aria-hidden="true">${row.hidden ? "\u25B8" : "\u25BE"}</span>` : "";
5440
+ return `<div class="${cls}">
5441
+ <div class="slm-availhead">
5442
+ <span class="slm-availlabel">${caret}${esc(row.label)}</span>
5443
+ ${badge}
5444
+ <span class="slm-availcount">${row.seatCount.toLocaleString()}</span>
5445
+ ${control}
5446
+ </div>
5447
+ ${detail}
5448
+ </div>`;
5449
+ }
5450
+ wireSectionRail() {
5451
+ const rail = this.els.rail;
5452
+ if (!rail) return;
5453
+ rail.querySelectorAll("[data-avail-id]").forEach((select) => {
5454
+ select.addEventListener("change", () => this.setSectionMode(select.dataset.availId, select.value));
5455
+ });
5456
+ rail.querySelectorAll("[data-avail-reveal]").forEach((input) => {
5457
+ input.addEventListener("change", () => {
5458
+ const ms = new Date(input.value).getTime();
5459
+ if (Number.isFinite(ms)) this.setSectionRulePatch(input.dataset.availReveal, { revealAt: ms });
5460
+ });
5461
+ });
5462
+ rail.querySelectorAll("[data-avail-pct]").forEach((input) => {
5463
+ input.addEventListener("change", () => {
5464
+ const pct = Math.max(1, Math.min(100, Number(input.value) || 0));
5465
+ this.setSectionRulePatch(input.dataset.availPct, { thresholdPct: pct });
5466
+ });
5467
+ });
5468
+ }
5469
+ /** Change one row's availability mode. A zone rule subsumes its child section
5470
+ * rules, so those are dropped from the map (the zone window is the truth). */
5471
+ setSectionMode(id, mode) {
5472
+ const row = this.buildSectionRows().rows.find((r) => r.id === id);
5473
+ const seatLabels = row?.seatLabels ?? this.availabilityRules[id]?.labels ?? [];
5474
+ const next = { ...this.availabilityRules };
5475
+ const rule = availabilityRuleForMode(mode, seatLabels, this.availabilityRules[id]);
5476
+ if (rule) next[id] = rule;
5477
+ else delete next[id];
5478
+ if (row?.kind === "zone" && this.sectionsBase) {
5479
+ for (const s of this.sectionsBase.sections) if (s.zone === id) delete next[s.id];
5480
+ }
5481
+ void this.persistAvailability(next);
5482
+ }
5483
+ /** Edit a timed reveal time / threshold percent on an existing row rule. */
5484
+ setSectionRulePatch(id, patch) {
5485
+ const cur = this.availabilityRules[id];
5486
+ if (!cur) return;
5487
+ const row = this.buildSectionRows().rows.find((r) => r.id === id);
5488
+ const labels = row?.seatLabels ?? cur.labels ?? [];
5489
+ void this.persistAvailability({ ...this.availabilityRules, [id]: { ...cur, ...patch, labels } });
5490
+ }
5491
+ /** Optimistically adopt the new rules, then reconcile with the server-cleaned
5492
+ * map + effective hidden/closed sets. Rolls back the rules on failure. */
5493
+ async persistAvailability(next) {
5494
+ const prev = this.availabilityRules;
5495
+ this.availabilityRules = next;
5496
+ this.availabilitySaving = true;
5497
+ if (this.mode === "sections") this.renderSectionsRail();
5498
+ try {
5499
+ const res = await this.withAuthRetry(() => this.api.setAvailability(this.key, next));
5500
+ this.availabilityRules = res.rules;
5501
+ this.effectiveHidden = new Set(res.hidden);
5502
+ this.effectiveClosed = new Set(this.closedIdsFromRules(res.rules));
5503
+ this.availabilitySaving = false;
5504
+ if (this.mode === "sections") this.renderSectionsRail();
5505
+ this.applySectionCanvasTreatment();
5506
+ } catch (err) {
5507
+ this.availabilityRules = prev;
5508
+ this.availabilitySaving = false;
5509
+ if (this.mode === "sections") this.renderSectionsRail();
5510
+ this.toastErr("Couldn't update availability. Try again.");
5511
+ this.opts.onError?.(err);
5512
+ }
5513
+ }
4936
5514
  paintLegend(t3) {
4937
5515
  if (!this.els.legend) return;
4938
5516
  this.els.legend.innerHTML = LEGEND.map((l) => `<div class="slm-legrow"><span class="slm-legdot" style="background:${l.color}"></span>
@@ -5245,6 +5823,7 @@ export {
5245
5823
  ManageApiError,
5246
5824
  SeatManager,
5247
5825
  SeatPicker,
5248
- SeatingChart
5826
+ SeatingChart,
5827
+ attachPickerFrame
5249
5828
  };
5250
5829
  //# sourceMappingURL=index.js.map