@seatlayer/js 0.7.3 → 0.9.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
@@ -86,6 +86,14 @@ var PubApi = class {
86
86
  body: { labels, holdId }
87
87
  });
88
88
  }
89
+ /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
90
+ * (reason: expired | extend_limit | not_found | not_active) if it can't. */
91
+ extend(key, holdId, ttlMs) {
92
+ return request(this.base, `/pub/events/${encodeURIComponent(key)}/extend`, {
93
+ method: "POST",
94
+ body: { holdId, ...ttlMs ? { ttlMs } : {} }
95
+ });
96
+ }
89
97
  socketUrl(key) {
90
98
  const wsBase = this.base.replace(/^http/, "ws");
91
99
  return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe`;
@@ -407,6 +415,7 @@ var EmbeddedDesigner = class {
407
415
  var import_core2 = require("@seatlayer/core");
408
416
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
409
417
  var DEFAULT_MAX_SELECTION2 = 10;
418
+ var EXTEND_PROMPT_MS = 6e4;
410
419
  function resolveContainer3(container) {
411
420
  if (typeof container === "string") {
412
421
  const el = document.querySelector(container);
@@ -520,6 +529,30 @@ var CSS = `
520
529
  .sl-boot-retry{margin-top:4px;padding:9px 20px;border-radius:var(--sl-r-sm);background:var(--sl-accent);
521
530
  color:var(--sl-accent-ink);font-weight:700;font-size:13px}
522
531
 
532
+ /* "Need more time?" extend prompt (bottom-center over the map, above the toast) */
533
+ .sl-extend{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(6px);z-index:9;
534
+ display:none;align-items:center;gap:12px;max-width:92%;background:var(--sl-surface);border:1px solid var(--sl-line);
535
+ color:var(--sl-text);border-radius:14px;padding:10px 12px 10px 16px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);
536
+ opacity:0;transition:opacity .2s,transform .2s}
537
+ .sl-extend.on{display:flex;opacity:1;transform:translateX(-50%) translateY(0)}
538
+ .sl-extend-txt{font-size:12.5px;font-weight:600;line-height:1.35}
539
+ .sl-extend-txt b{font-variant-numeric:tabular-nums}
540
+ .sl-extend-btn{flex:none;padding:8px 14px;border-radius:999px;font-weight:800;font-size:12.5px;
541
+ background:var(--sl-accent);color:var(--sl-accent-ink);transition:filter .15s,opacity .15s}
542
+ .sl-extend-btn:hover{filter:brightness(1.08)}
543
+ .sl-extend-btn:disabled{opacity:.5;cursor:not-allowed}
544
+
545
+ /* booked confirmation overlay (covers the widget once the held seats are sold) */
546
+ .sl-booked{position:absolute;inset:0;z-index:11;display:none;flex-direction:column;align-items:center;
547
+ justify-content:center;gap:12px;text-align:center;padding:28px;background:var(--sl-bg)}
548
+ .sl-booked.on{display:flex}
549
+ .sl-booked-badge{width:60px;height:60px;border-radius:999px;display:flex;align-items:center;justify-content:center;
550
+ background:var(--sl-accent);color:var(--sl-accent-ink)}
551
+ .sl-booked-badge svg{width:30px;height:30px;stroke:currentColor;stroke-width:2.6;fill:none;stroke-linecap:round;stroke-linejoin:round}
552
+ .sl-booked-title{font-weight:800;font-size:19px;color:var(--sl-text)}
553
+ .sl-booked-sub{font-size:13px;color:var(--sl-muted);line-height:1.5;max-width:320px}
554
+ .sl-booked-seats{font-weight:700;color:var(--sl-text)}
555
+
523
556
  /* a11y filter chips (over the map, top-left) */
524
557
  .sl-chips{position:absolute;top:12px;left:12px;z-index:5;display:flex;gap:6px;flex-wrap:wrap;max-width:70%}
525
558
  .sl-chip-f{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;font-size:12px;font-weight:700;
@@ -553,6 +586,79 @@ var CSS = `
553
586
  /* screen-reader live region */
554
587
  .sl-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
555
588
 
589
+ /* per-seat ticket-tier select + view-from-seat button in tray chips */
590
+ .sl-chip .tier{background:var(--sl-bg);color:var(--sl-text);border:1px solid var(--sl-line);border-radius:7px;
591
+ font:inherit;font-size:11px;padding:3px 5px;max-width:130px;cursor:pointer}
592
+ .sl-chip .view{width:24px;height:24px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
593
+ color:var(--sl-muted);transition:color .15s}
594
+ .sl-chip .view:hover{color:var(--sl-text)}
595
+ .sl-chip .view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
596
+
597
+ /* arena: LOD rung pills (top-center over the map) */
598
+ .sl-rungs{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;display:none;
599
+ background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
600
+ .sl-rungs.on{display:inline-flex;gap:2px}
601
+ .sl-rungs button{padding:6px 13px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.07em;
602
+ color:var(--sl-muted);white-space:nowrap;transition:color .15s}
603
+ .sl-rungs button:hover{color:var(--sl-text)}
604
+ .sl-rungs button.on{background:var(--sl-accent);color:var(--sl-accent-ink)}
605
+
606
+ /* multi-floor switcher (center-left rail over the map) */
607
+ .sl-floors{position:absolute;top:50%;left:12px;transform:translateY(-50%);z-index:5;display:none;
608
+ flex-direction:column;gap:6px;max-width:42%}
609
+ .sl-floors.on{display:flex}
610
+ .sl-floors button{padding:7px 13px;border-radius:999px;font-size:12px;font-weight:700;background:var(--sl-surface);
611
+ border:1px solid var(--sl-line);color:var(--sl-muted);white-space:nowrap;max-width:100%;overflow:hidden;
612
+ text-overflow:ellipsis;transition:color .15s,border-color .15s}
613
+ .sl-floors button:hover{color:var(--sl-text)}
614
+ .sl-floors button.on{background:var(--sl-accent);color:var(--sl-accent-ink);border-color:transparent}
615
+
616
+ /* tapped-section summary card (top-center, under the rung pills) */
617
+ .sl-seccard{position:absolute;top:54px;left:50%;transform:translateX(-50%);z-index:6;width:250px;
618
+ max-width:calc(100% - 24px);background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:12px;
619
+ padding:12px 14px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);display:none}
620
+ .sl-seccard.on{display:block}
621
+ .sl-seccard-head{display:flex;align-items:center;gap:8px}
622
+ .sl-seccard-dot{width:10px;height:10px;border-radius:50%;flex:none}
623
+ .sl-seccard-name{font-weight:800;font-size:14px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
624
+ .sl-seccard-price{font-weight:800;font-size:12.5px;font-variant-numeric:tabular-nums}
625
+ .sl-seccard-x{width:22px;height:22px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
626
+ color:var(--sl-muted);font-size:12px}
627
+ .sl-seccard-x:hover{color:var(--sl-text)}
628
+ .sl-seccard-zone{font-size:11.5px;color:var(--sl-muted);margin-top:6px}
629
+ .sl-seccard-left{color:var(--sl-text);font-weight:700}
630
+ .sl-seccard-mix{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:8px}
631
+ .sl-seccard-mix-item{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--sl-muted)}
632
+ .sl-seccard-mix-dot{width:8px;height:8px;border-radius:50%;flex:none}
633
+ .sl-seccard-mix-price{font-weight:700;color:var(--sl-text)}
634
+ .sl-seccard-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:10px}
635
+ .sl-seccard-overview{font-size:12px;font-weight:800;color:var(--sl-accent)}
636
+ .sl-seccard-hint{font-size:10.5px;color:var(--sl-muted)}
637
+
638
+ /* view-from-seat button on the confirm popover */
639
+ .sl-confirm-view{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
640
+ color:var(--sl-text);font-weight:700;font-size:12px;display:flex;align-items:center;justify-content:center;gap:7px}
641
+ .sl-confirm-view:hover{border-color:var(--sl-muted)}
642
+ .sl-confirm-view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
643
+
644
+ /* 360\xB0 seat-view modal (fills the widget; drag-to-look-around equirectangular) */
645
+ .sl-view{position:absolute;inset:0;z-index:12;display:flex;flex-direction:column;background:var(--sl-bg)}
646
+ .sl-view-head{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--sl-line);flex:none}
647
+ .sl-view-title{font-weight:800;font-size:15px}
648
+ .sl-view-cap{font-size:11px;color:var(--sl-muted)}
649
+ .sl-view-x{margin-left:auto;width:32px;height:32px;border-radius:999px;border:1px solid var(--sl-line);color:var(--sl-muted);
650
+ flex:none;display:flex;align-items:center;justify-content:center;transition:color .15s,border-color .15s}
651
+ .sl-view-x:hover{color:var(--sl-text);border-color:var(--sl-muted)}
652
+ .sl-view-x svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round}
653
+ .sl-view-pano{position:relative;flex:1;min-height:0;overflow:hidden;cursor:grab;background-color:#05070c;
654
+ background-repeat:repeat-x;touch-action:none;user-select:none}
655
+ .sl-view-pano.drag{cursor:grabbing}
656
+ .sl-view-badge{position:absolute;top:12px;left:12px;padding:5px 11px;border-radius:999px;font-size:10px;font-weight:800;
657
+ letter-spacing:.08em;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted)}
658
+ .sl-view-hint{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);padding:6px 14px;border-radius:999px;
659
+ font-size:11.5px;font-weight:600;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted);
660
+ white-space:nowrap;pointer-events:none;max-width:90%;overflow:hidden;text-overflow:ellipsis}
661
+
556
662
  /* modal host */
557
663
  .sl-modal-scrim{position:fixed;inset:0;z-index:2147483000;background:rgba(5,7,12,.66);display:flex;align-items:center;justify-content:center;padding:18px}
558
664
  .sl-modal-frame{width:min(1200px,100%);height:min(820px,100%);border-radius:16px;overflow:hidden;box-shadow:0 40px 120px -30px rgba(0,0,0,.8)}
@@ -594,6 +700,14 @@ var SeatPicker = class _SeatPicker {
594
700
  // state
595
701
  this.currency = "USD";
596
702
  this.hold = null;
703
+ /** Latest server expiry for the open hold (moves on extend). */
704
+ this.holdExpiresAt = 0;
705
+ /** True once we handed off to checkout — arms booked-confirmation detection. */
706
+ this.handedOff = false;
707
+ /** Guards single onBooked + single success overlay per hold. */
708
+ this.bookedShown = false;
709
+ this.extendEl = null;
710
+ this.bookedEl = null;
597
711
  this.gaQty = /* @__PURE__ */ new Map();
598
712
  this.tipEl = null;
599
713
  this.tipPos = { x: 0, y: 0 };
@@ -603,6 +717,13 @@ var SeatPicker = class _SeatPicker {
603
717
  this.a11yFilter = "all";
604
718
  this.baQty = 2;
605
719
  this.baCat = "";
720
+ // arena / multi-floor / seat-view chrome
721
+ this.rungsEl = null;
722
+ this.floorsEl = null;
723
+ this.secCardEl = null;
724
+ this.viewEl = null;
725
+ this.viewCleanup = null;
726
+ this.allSeatsCache = null;
606
727
  // modal plumbing (set by open())
607
728
  this.modalScrim = null;
608
729
  this.prevFocus = null;
@@ -625,9 +746,12 @@ var SeatPicker = class _SeatPicker {
625
746
  onStatusChange: () => {
626
747
  this.syncPrices();
627
748
  this.evictTakenSelections();
749
+ this.detectBooked();
628
750
  },
629
751
  onHoldExpired: () => {
630
752
  this.hold = null;
753
+ this.handedOff = false;
754
+ this.bookedShown = false;
631
755
  this.stopHoldTimer();
632
756
  this.gaQty.clear();
633
757
  this.toast((0, import_core2.t)("picker.holdExpired", void 0) || "Your hold expired \u2014 seats released. Pick again.");
@@ -638,7 +762,12 @@ var SeatPicker = class _SeatPicker {
638
762
  onSelect: (seat) => {
639
763
  if (this.opts.confirmSelection) this.showConfirm(seat);
640
764
  },
641
- onViewChange: () => this.reanchorConfirm(),
765
+ onViewChange: () => {
766
+ this.reanchorConfirm();
767
+ this.syncRung();
768
+ },
769
+ // Tapped-section glide-in → surface (or clear) the section-summary card.
770
+ onSectionFocus: (summary) => this.showSectionCard(summary),
642
771
  onFocusSeat: (seat) => this.announceSeat(seat),
643
772
  onSeatHover: (d) => this.updateTooltip(d),
644
773
  onHint: (m) => {
@@ -830,10 +959,130 @@ var SeatPicker = class _SeatPicker {
830
959
  this.srEl.className = "sl-sr";
831
960
  this.srEl.setAttribute("aria-live", "polite");
832
961
  root.appendChild(this.srEl);
962
+ this.buildArenaChrome();
963
+ this.buildExtendPrompt();
964
+ this.buildBookedOverlay();
833
965
  this.syncPrices();
834
966
  this.syncTray();
835
967
  return this;
836
968
  }
969
+ /** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
970
+ buildExtendPrompt() {
971
+ const el = document.createElement("div");
972
+ el.className = "sl-extend";
973
+ el.setAttribute("role", "status");
974
+ el.innerHTML = `<span class="sl-extend-txt" data-ref="extendTxt"></span><button type="button" class="sl-extend-btn" data-ref="extendBtn"></button>`;
975
+ this.els.map.appendChild(el);
976
+ this.extendEl = el;
977
+ this.els.extendTxt = el.querySelector('[data-ref="extendTxt"]');
978
+ this.els.extendBtn = el.querySelector('[data-ref="extendBtn"]');
979
+ this.els.extendBtn.textContent = "Add time";
980
+ this.els.extendBtn.addEventListener("click", () => void this.handleExtend());
981
+ }
982
+ /** Success overlay + onBooked fire when the held seats settle to booked. */
983
+ buildBookedOverlay() {
984
+ const el = document.createElement("div");
985
+ el.className = "sl-booked";
986
+ el.setAttribute("role", "status");
987
+ el.setAttribute("aria-live", "polite");
988
+ el.innerHTML = `<div class="sl-booked-badge"><svg viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg></div><div class="sl-booked-title">You're all set</div><div class="sl-booked-sub" data-ref="bookedSub"></div>`;
989
+ this.root.appendChild(el);
990
+ this.bookedEl = el;
991
+ this.els.bookedSub = el.querySelector('[data-ref="bookedSub"]');
992
+ }
993
+ // ---- arena / multi-floor chrome -------------------------------------------
994
+ /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
995
+ buildArenaChrome() {
996
+ const doc = this.controller.doc;
997
+ if (!doc || !this.els.map) return;
998
+ const hasSections = doc.objects.some((o) => o.type === "section") || (doc.floors ?? []).some((f) => f.objects.some((o) => o.type === "section"));
999
+ if (hasSections) {
1000
+ const RUNGS = ["zones", "sections", "seats"];
1001
+ const pills = document.createElement("div");
1002
+ pills.className = "sl-rungs on";
1003
+ pills.setAttribute("role", "group");
1004
+ pills.setAttribute("aria-label", (0, import_core2.t)("picker.zoomLevel"));
1005
+ const LABEL = {
1006
+ zones: (0, import_core2.t)("picker.rungLabel.zones"),
1007
+ sections: (0, import_core2.t)("picker.rungLabel.sections"),
1008
+ seats: (0, import_core2.t)("picker.rungLabel.seats")
1009
+ };
1010
+ const TIP = {
1011
+ zones: (0, import_core2.t)("picker.rungTip.zones"),
1012
+ sections: (0, import_core2.t)("picker.rungTip.sections"),
1013
+ seats: (0, import_core2.t)("picker.rungTip.seats")
1014
+ };
1015
+ pills.innerHTML = RUNGS.map(
1016
+ (r) => `<button type="button" data-rung="${r}" title="${TIP[r]}" aria-pressed="false">${LABEL[r]}</button>`
1017
+ ).join("");
1018
+ pills.querySelectorAll("button").forEach((btn) => {
1019
+ btn.addEventListener("click", () => this.controller.setRung(btn.dataset.rung));
1020
+ });
1021
+ this.els.map.appendChild(pills);
1022
+ this.rungsEl = pills;
1023
+ this.syncRung();
1024
+ }
1025
+ if (this.controller.isMultiFloor()) {
1026
+ const floors = this.controller.getFloors();
1027
+ const rail = document.createElement("div");
1028
+ rail.className = "sl-floors on";
1029
+ rail.setAttribute("role", "group");
1030
+ rail.setAttribute("aria-label", (0, import_core2.t)("picker.floor"));
1031
+ rail.innerHTML = floors.map((f) => `<button type="button" data-floor="${f.id}">${f.name}</button>`).join("");
1032
+ rail.querySelectorAll("button").forEach((btn) => {
1033
+ btn.addEventListener("click", () => {
1034
+ this.controller.setFloor(btn.dataset.floor);
1035
+ this.showSectionCard(null);
1036
+ this.syncFloors();
1037
+ this.syncRung();
1038
+ });
1039
+ });
1040
+ this.els.map.appendChild(rail);
1041
+ this.floorsEl = rail;
1042
+ this.syncFloors();
1043
+ }
1044
+ }
1045
+ /** Reflect the engine's current LOD rung onto the pill group. */
1046
+ syncRung() {
1047
+ if (!this.rungsEl) return;
1048
+ const active = this.controller.getRung();
1049
+ this.rungsEl.querySelectorAll("button").forEach((btn) => {
1050
+ const on = btn.dataset.rung === active;
1051
+ btn.classList.toggle("on", on);
1052
+ btn.setAttribute("aria-pressed", String(on));
1053
+ });
1054
+ }
1055
+ /** Reflect the active floor onto the switcher rail. */
1056
+ syncFloors() {
1057
+ if (!this.floorsEl) return;
1058
+ const active = this.controller.getActiveFloorId();
1059
+ this.floorsEl.querySelectorAll("button").forEach((btn) => {
1060
+ btn.classList.toggle("on", btn.dataset.floor === active);
1061
+ });
1062
+ }
1063
+ /** Show (or clear, on null) the tapped-section summary card. */
1064
+ showSectionCard(summary) {
1065
+ if (!summary) {
1066
+ this.secCardEl?.remove();
1067
+ this.secCardEl = null;
1068
+ return;
1069
+ }
1070
+ if (!this.els.map) return;
1071
+ this.secCardEl?.remove();
1072
+ const priceLabel = summary.priceMin === summary.priceMax ? this.money(summary.priceMin) : `${this.money(summary.priceMin)}\u2013${this.money(summary.priceMax)}`;
1073
+ const card = document.createElement("div");
1074
+ card.className = "sl-seccard on";
1075
+ card.setAttribute("role", "dialog");
1076
+ card.setAttribute("aria-label", (0, import_core2.t)("picker.sectionSummaryAria", { label: summary.label }));
1077
+ const mix = summary.categories.map(
1078
+ (c) => `<span class="sl-seccard-mix-item"><span class="sl-seccard-mix-dot" style="background:${c.color}"></span>${c.label} <span class="sl-seccard-mix-price">${this.money(c.price)}</span></span>`
1079
+ ).join("");
1080
+ card.innerHTML = `<div class="sl-seccard-head"><span class="sl-seccard-dot" style="background:${summary.color}"></span><span class="sl-seccard-name">${summary.label}</span>` + (summary.categories.length ? `<span class="sl-seccard-price">${priceLabel}</span>` : "") + `<button type="button" class="sl-seccard-x" aria-label="${(0, import_core2.t)("picker.closeSectionSummary")}">\u2715</button></div><div class="sl-seccard-zone">${summary.zoneLabel ? `${summary.zoneLabel} \xB7 ` : ""}<span class="sl-seccard-left">${(0, import_core2.tCount)("picker.seatsLeftInSection", summary.seatsLeft)}</span></div>` + (mix ? `<div class="sl-seccard-mix">${mix}</div>` : "") + `<div class="sl-seccard-foot"><button type="button" class="sl-seccard-overview">\u2190 ${(0, import_core2.t)("picker.overview")}</button><span class="sl-seccard-hint">${(0, import_core2.t)("picker.tapSeatHint")}</span></div>`;
1081
+ card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
1082
+ card.querySelector(".sl-seccard-overview").addEventListener("click", () => this.controller.overview());
1083
+ this.els.map.appendChild(card);
1084
+ this.secCardEl = card;
1085
+ }
837
1086
  /** aria-live readout when keyboard focus lands on a seat. */
838
1087
  announceSeat(seat) {
839
1088
  if (!this.srEl) return;
@@ -855,11 +1104,12 @@ var SeatPicker = class _SeatPicker {
855
1104
  const price = cat?.tiers?.length ? cat.tiers[0].price : cat?.price;
856
1105
  const el = document.createElement("div");
857
1106
  el.className = "sl-confirm";
858
- el.innerHTML = `<div class="sl-confirm-label">${seat.label}</div><div class="sl-confirm-meta"><span class="sl-dot" style="background:${cat?.color ?? "#6e7bff"}"></span>${cat?.label ?? seat.categoryKey}${price != null ? `<b>${this.money(price)}</b>` : ""}</div><div class="sl-confirm-row"><button type="button" class="sl-confirm-cancel">Cancel</button><button type="button" class="sl-confirm-add">Add seat</button></div>`;
1107
+ el.innerHTML = `<div class="sl-confirm-label">${seat.label}</div><div class="sl-confirm-meta"><span class="sl-dot" style="background:${cat?.color ?? "#6e7bff"}"></span>${cat?.label ?? seat.categoryKey}${price != null ? `<b>${this.money(price)}</b>` : ""}</div>` + (this.seatViewEnabled() ? `<button type="button" class="sl-confirm-view"><svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>${(0, import_core2.t)("picker.open360")}</button>` : "") + `<div class="sl-confirm-row"><button type="button" class="sl-confirm-cancel">Cancel</button><button type="button" class="sl-confirm-add">Add seat</button></div>`;
859
1108
  this.els.map.appendChild(el);
860
1109
  this.confirmEl = el;
861
1110
  this.confirmSeat = seat;
862
1111
  this.reanchorConfirm();
1112
+ el.querySelector(".sl-confirm-view")?.addEventListener("click", () => this.openSeatView(seat));
863
1113
  el.querySelector(".sl-confirm-add").addEventListener("click", () => this.closeConfirm());
864
1114
  el.querySelector(".sl-confirm-cancel").addEventListener("click", () => {
865
1115
  this.controller.deselect([seat.id]);
@@ -877,6 +1127,122 @@ var SeatPicker = class _SeatPicker {
877
1127
  this.confirmEl = null;
878
1128
  this.confirmSeat = null;
879
1129
  }
1130
+ // ---- 360° view-from-seat modal --------------------------------------------
1131
+ seatViewEnabled() {
1132
+ return this.opts.seatView !== false;
1133
+ }
1134
+ /** Every bookable seat (cached) — neighbor heads for the generated panorama. */
1135
+ allSeats() {
1136
+ if (!this.allSeatsCache) {
1137
+ const doc = this.controller.doc;
1138
+ this.allSeatsCache = doc ? (0, import_core2.expandChart)(doc) : [];
1139
+ }
1140
+ return this.allSeatsCache;
1141
+ }
1142
+ /**
1143
+ * Open the drag-to-look-around 360° preview for a seat. Uses the organizer's
1144
+ * uploaded photo (seat.viewUrl) when present, else a panorama generated from
1145
+ * the chart geometry — the stage placed at this seat's true bearing + size.
1146
+ * Zero extra dependencies: an equirectangular image panned with `repeat-x`.
1147
+ */
1148
+ openSeatView(seat) {
1149
+ if (!this.root || !this.seatViewEnabled()) return;
1150
+ this.closeSeatView();
1151
+ this.closeConfirm();
1152
+ const doc = this.controller.doc;
1153
+ const activeId = this.controller.getActiveFloorId();
1154
+ const focal = doc?.floors?.find((f) => f.id === activeId)?.focalPoint ?? doc?.focalPoint ?? { x: 0, y: 0 };
1155
+ let panoUrl;
1156
+ let caption;
1157
+ let real = false;
1158
+ if (seat.viewUrl) {
1159
+ panoUrl = seat.viewUrl;
1160
+ caption = (0, import_core2.t)("picker.panorama360");
1161
+ real = true;
1162
+ } else {
1163
+ const pano2 = (0, import_core2.generateSeatPanorama)(seat, focal, this.allSeats());
1164
+ panoUrl = pano2.url;
1165
+ caption = (0, import_core2.t)("picker.illustrationCaption", { m: pano2.distanceM });
1166
+ }
1167
+ const el = document.createElement("div");
1168
+ el.className = "sl-view";
1169
+ el.setAttribute("role", "dialog");
1170
+ el.setAttribute("aria-label", (0, import_core2.t)("picker.viewFromSeat", { label: seat.label }));
1171
+ el.innerHTML = `<div class="sl-view-head"><span class="sl-view-title">${(0, import_core2.t)("picker.viewFromSeat", { label: seat.label })}</span><span class="sl-view-cap">${caption}</span><button type="button" class="sl-view-x" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="sl-view-pano"><span class="sl-view-badge">${real ? (0, import_core2.t)("picker.real360") : (0, import_core2.t)("picker.preview")}</span><span class="sl-view-hint">Drag to look around \xB7 scroll to zoom</span></div>`;
1172
+ this.root.appendChild(el);
1173
+ this.viewEl = el;
1174
+ const pano = el.querySelector(".sl-view-pano");
1175
+ pano.style.backgroundImage = `url("${panoUrl}")`;
1176
+ let zoom = 1.2;
1177
+ let posX = 0;
1178
+ let posY = 0;
1179
+ const apply = () => {
1180
+ const h = pano.clientHeight || 1;
1181
+ const bgH = h * zoom;
1182
+ const overV = Math.max(0, bgH - h);
1183
+ posY = Math.min(overV / 2, Math.max(-overV / 2, posY));
1184
+ pano.style.backgroundSize = `auto ${bgH}px`;
1185
+ pano.style.backgroundPosition = `${posX}px ${posY + overV / 2}px`;
1186
+ };
1187
+ apply();
1188
+ let dragging = false;
1189
+ let lastX = 0;
1190
+ let lastY = 0;
1191
+ const onDown = (e) => {
1192
+ dragging = true;
1193
+ lastX = e.clientX;
1194
+ lastY = e.clientY;
1195
+ pano.classList.add("drag");
1196
+ pano.setPointerCapture?.(e.pointerId);
1197
+ };
1198
+ const onMove = (e) => {
1199
+ if (!dragging) return;
1200
+ posX += e.clientX - lastX;
1201
+ posY += e.clientY - lastY;
1202
+ lastX = e.clientX;
1203
+ lastY = e.clientY;
1204
+ apply();
1205
+ };
1206
+ const onUp = (e) => {
1207
+ dragging = false;
1208
+ pano.classList.remove("drag");
1209
+ pano.releasePointerCapture?.(e.pointerId);
1210
+ };
1211
+ const onWheel = (e) => {
1212
+ e.preventDefault();
1213
+ zoom = Math.min(2.4, Math.max(1, zoom + (e.deltaY < 0 ? 0.12 : -0.12)));
1214
+ apply();
1215
+ };
1216
+ pano.addEventListener("pointerdown", onDown);
1217
+ pano.addEventListener("pointermove", onMove);
1218
+ pano.addEventListener("pointerup", onUp);
1219
+ pano.addEventListener("pointercancel", onUp);
1220
+ pano.addEventListener("wheel", onWheel, { passive: false });
1221
+ const closeBtn = el.querySelector(".sl-view-x");
1222
+ closeBtn.addEventListener("click", () => this.closeSeatView());
1223
+ const onKey = (e) => {
1224
+ if (e.key === "Escape") {
1225
+ e.stopPropagation();
1226
+ this.closeSeatView();
1227
+ }
1228
+ };
1229
+ el.addEventListener("keydown", onKey);
1230
+ closeBtn.focus();
1231
+ this.viewCleanup = () => {
1232
+ pano.removeEventListener("pointerdown", onDown);
1233
+ pano.removeEventListener("pointermove", onMove);
1234
+ pano.removeEventListener("pointerup", onUp);
1235
+ pano.removeEventListener("pointercancel", onUp);
1236
+ pano.removeEventListener("wheel", onWheel);
1237
+ el.removeEventListener("keydown", onKey);
1238
+ };
1239
+ }
1240
+ closeSeatView() {
1241
+ this.viewCleanup?.();
1242
+ this.viewCleanup = null;
1243
+ this.viewEl?.remove();
1244
+ this.viewEl = null;
1245
+ }
880
1246
  // ---- chrome sync ----------------------------------------------------------
881
1247
  money(n) {
882
1248
  try {
@@ -919,15 +1285,20 @@ var SeatPicker = class _SeatPicker {
919
1285
  }
920
1286
  for (const item of heldItems) {
921
1287
  const cat = this.controller.doc?.categories.find((c) => c.key === item.categoryKey);
1288
+ const tierName = item.tierId ? cat?.tiers?.find((ti) => ti.id === item.tierId)?.name : void 0;
1289
+ const canView2 = this.seatViewEnabled() && item.objectType !== "ga" && !!this.controller.seatByLabel(item.label);
922
1290
  parts.push(
923
- `<div class="sl-chip"><b>${item.label}</b><span class="cat">${cat?.label ?? item.categoryKey}</span><span class="amt">${this.money(item.unitPrice * (item.quantity ?? 1))}</span></div>`
1291
+ `<div class="sl-chip"><b>${item.label}</b><span class="cat">${cat?.label ?? item.categoryKey}${tierName ? ` \xB7 ${tierName}` : ""}</span><span class="amt">${this.money(item.unitPrice * (item.quantity ?? 1))}</span>` + (canView2 ? `<button type="button" class="view" data-view-label="${item.label}" aria-label="${(0, import_core2.t)("picker.viewFromSeat", { label: item.label })}"><svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>` : "") + `</div>`
924
1292
  );
925
1293
  }
926
1294
  const heldLabels = new Set(heldItems.map((item) => item.label));
1295
+ const canView = this.seatViewEnabled();
927
1296
  for (const s of seats.filter((seat) => !heldLabels.has(seat.label))) {
928
1297
  const cat = this.controller.doc?.categories.find((c) => c.key === s.categoryKey);
1298
+ const tierSelect = s.tiers && s.tiers.length ? `<select class="tier" data-tier="${s.id}" aria-label="${(0, import_core2.t)("picker.ticketTierFor", { label: s.label })}">` + s.tiers.map((ti) => `<option value="${ti.id}"${ti.id === s.tierId ? " selected" : ""}>${ti.name} \xB7 ${this.money(ti.price)}</option>`).join("") + `</select>` : "";
1299
+ const viewBtn = canView ? `<button type="button" class="view" data-view-label="${s.label}" aria-label="${(0, import_core2.t)("picker.viewFromSeat", { label: s.label })}"><svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg></button>` : "";
929
1300
  parts.push(
930
- `<div class="sl-chip" data-seat="${s.id}"><b>${s.label}</b><span class="cat">${cat?.label ?? s.categoryKey}</span><span class="amt">${this.money(s.price)}</span><button type="button" class="rm" aria-label="Remove ${s.label}"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>`
1301
+ `<div class="sl-chip" data-seat="${s.id}"><b>${s.label}</b><span class="cat">${cat?.label ?? s.categoryKey}</span>` + tierSelect + `<span class="amt">${this.money(s.price)}</span>` + viewBtn + `<button type="button" class="rm" aria-label="Remove ${s.label}"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>`
931
1302
  );
932
1303
  }
933
1304
  for (const area of gaAreas) {
@@ -961,6 +1332,15 @@ var SeatPicker = class _SeatPicker {
961
1332
  this.controller.deselect([id]);
962
1333
  });
963
1334
  });
1335
+ this.els.tray.querySelectorAll(".sl-chip .tier").forEach((sel) => {
1336
+ sel.addEventListener("change", () => this.controller.setSeatTier(sel.dataset.tier, sel.value || null));
1337
+ });
1338
+ this.els.tray.querySelectorAll(".sl-chip .view[data-view-label]").forEach((btn) => {
1339
+ btn.addEventListener("click", () => {
1340
+ const seat = this.controller.seatByLabel(btn.dataset.viewLabel);
1341
+ if (seat) this.openSeatView(seat);
1342
+ });
1343
+ });
964
1344
  this.els.tray.querySelectorAll(".sl-ga button").forEach((btn) => {
965
1345
  btn.addEventListener("click", () => {
966
1346
  const areaEl = btn.closest(".sl-ga");
@@ -988,7 +1368,9 @@ var SeatPicker = class _SeatPicker {
988
1368
  async handleCta() {
989
1369
  const cta = this.els.cta;
990
1370
  if (this.hold && !this.controller.getSelection().some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
991
- this.opts.onCheckout?.(this.hold, this.hold.seats ?? this.controller.getSelection());
1371
+ const seats = this.hold.seats ?? this.controller.getSelection();
1372
+ this.handedOff = true;
1373
+ this.opts.onCheckout?.(this.hold, seats, this.buildHandoff(this.hold));
992
1374
  return;
993
1375
  }
994
1376
  cta.disabled = true;
@@ -1011,8 +1393,9 @@ var SeatPicker = class _SeatPicker {
1011
1393
  return;
1012
1394
  }
1013
1395
  this.hold = hold;
1396
+ this.handedOff = true;
1014
1397
  this.startHoldTimer(hold.expiresAt);
1015
- this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? []);
1398
+ this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? [], this.buildHandoff(hold));
1016
1399
  } catch (err) {
1017
1400
  this.opts.onError?.(err);
1018
1401
  this.toast("One or more seats were just taken. Please pick again.");
@@ -1022,13 +1405,15 @@ var SeatPicker = class _SeatPicker {
1022
1405
  }
1023
1406
  startHoldTimer(expiresAt) {
1024
1407
  this.stopHoldTimer();
1408
+ this.holdExpiresAt = expiresAt;
1025
1409
  const pill = this.els.hold;
1026
1410
  const tick = () => {
1027
- const ms = Math.max(0, expiresAt - Date.now());
1411
+ const ms = Math.max(0, this.holdExpiresAt - Date.now());
1028
1412
  const m = Math.floor(ms / 6e4);
1029
1413
  const s = String(Math.floor(ms % 6e4 / 1e3)).padStart(2, "0");
1030
1414
  pill.textContent = `Held ${m}:${s}`;
1031
1415
  pill.classList.add("on");
1416
+ this.setExtendPrompt(ms > 0 && ms <= EXTEND_PROMPT_MS, ms);
1032
1417
  if (ms <= 0) this.stopHoldTimer();
1033
1418
  };
1034
1419
  tick();
@@ -1038,6 +1423,83 @@ var SeatPicker = class _SeatPicker {
1038
1423
  if (this.holdTimer) clearInterval(this.holdTimer);
1039
1424
  this.holdTimer = null;
1040
1425
  this.els.hold?.classList.remove("on");
1426
+ this.setExtendPrompt(false, 0);
1427
+ }
1428
+ /** Show/refresh (or hide) the "Need more time?" prompt with the live seconds left. */
1429
+ setExtendPrompt(show, ms) {
1430
+ if (!this.extendEl) return;
1431
+ if (show && this.controller.currentHold() && !this.bookedShown) {
1432
+ const secs = Math.ceil(ms / 1e3);
1433
+ this.els.extendTxt.innerHTML = `Your seats are held for <b>0:${String(secs).padStart(2, "0")}</b>. Need more time?`;
1434
+ this.extendEl.classList.add("on");
1435
+ } else {
1436
+ this.extendEl.classList.remove("on");
1437
+ }
1438
+ }
1439
+ async handleExtend() {
1440
+ const btn = this.els.extendBtn;
1441
+ btn.disabled = true;
1442
+ const prev = btn.textContent;
1443
+ btn.textContent = "Adding\u2026";
1444
+ try {
1445
+ const h = await this.controller.extendHold(this.opts.holdTtlMs);
1446
+ if (h) {
1447
+ this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1448
+ this.holdExpiresAt = h.expiresAt;
1449
+ this.extendEl?.classList.remove("on");
1450
+ this.toast("More time added \u2014 your seats are still held.");
1451
+ } else {
1452
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1453
+ }
1454
+ } catch (err) {
1455
+ this.opts.onError?.(err);
1456
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1457
+ } finally {
1458
+ btn.disabled = false;
1459
+ btn.textContent = prev;
1460
+ }
1461
+ }
1462
+ /**
1463
+ * Fire the booked-confirmation state once the buyer's held seats settle to
1464
+ * booked. The controller clears its own hold the moment every held label reads
1465
+ * 'booked' over the realtime channel (clearBookedHoldIfSettled), and this runs
1466
+ * on the same onStatusChange — so `currentHold() === null` while we still hold
1467
+ * a checkout handoff means "sold", not expired (expiry clears via onHoldExpired
1468
+ * on a different path, which nulls this.hold first).
1469
+ */
1470
+ detectBooked() {
1471
+ if (this.bookedShown || !this.handedOff || !this.hold) return;
1472
+ if (this.controller.currentHold() !== null) return;
1473
+ this.showBooked();
1474
+ }
1475
+ showBooked() {
1476
+ if (this.bookedShown || !this.hold) return;
1477
+ this.bookedShown = true;
1478
+ const handoff = this.buildHandoff(this.hold);
1479
+ this.stopHoldTimer();
1480
+ const n = handoff.lineItems.reduce((sum, i) => sum + i.quantity, 0);
1481
+ if (this.els.bookedSub) {
1482
+ this.els.bookedSub.innerHTML = `<span class="sl-booked-seats">${n} ${n === 1 ? "ticket" : "tickets"}</span> confirmed. A confirmation is on its way.`;
1483
+ }
1484
+ this.bookedEl?.classList.add("on");
1485
+ this.opts.onBooked?.(handoff);
1486
+ }
1487
+ /** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
1488
+ buildHandoff(hold) {
1489
+ const items = hold.items ?? [];
1490
+ const lineItems = items.map((it) => ({
1491
+ label: it.label,
1492
+ objectId: it.objectId,
1493
+ objectType: it.objectType,
1494
+ categoryKey: it.categoryKey,
1495
+ tierId: it.tierId,
1496
+ unitPrice: it.unitPrice,
1497
+ currency: it.currency ?? this.currency,
1498
+ quantity: it.quantity ?? 1
1499
+ }));
1500
+ const currency = lineItems[0]?.currency ?? this.currency;
1501
+ const total = lineItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
1502
+ return { holdId: hold.holdId, expiresAt: hold.expiresAt, currency, lineItems, total };
1041
1503
  }
1042
1504
  toast(msg) {
1043
1505
  const el = this.els.toast;
@@ -1079,6 +1541,8 @@ var SeatPicker = class _SeatPicker {
1079
1541
  const h = await this.controller.bestAvailable(qty, categoryKey);
1080
1542
  if (h) {
1081
1543
  this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1544
+ this.handedOff = false;
1545
+ this.bookedShown = false;
1082
1546
  this.startHoldTimer(h.expiresAt);
1083
1547
  this.syncTray();
1084
1548
  return this.hold;
@@ -1092,6 +1556,8 @@ var SeatPicker = class _SeatPicker {
1092
1556
  async release() {
1093
1557
  await this.controller.release();
1094
1558
  this.hold = null;
1559
+ this.handedOff = false;
1560
+ this.bookedShown = false;
1095
1561
  this.stopHoldTimer();
1096
1562
  this.gaQty.clear();
1097
1563
  this.syncTray();
@@ -1099,6 +1565,7 @@ var SeatPicker = class _SeatPicker {
1099
1565
  destroy() {
1100
1566
  this.destroyed = true;
1101
1567
  this.closeConfirm();
1568
+ this.closeSeatView();
1102
1569
  this.stopHoldTimer();
1103
1570
  if (this.toastTimer) clearTimeout(this.toastTimer);
1104
1571
  this.ro?.disconnect();