@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.js CHANGED
@@ -57,6 +57,14 @@ var PubApi = class {
57
57
  body: { labels, holdId }
58
58
  });
59
59
  }
60
+ /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
61
+ * (reason: expired | extend_limit | not_found | not_active) if it can't. */
62
+ extend(key, holdId, ttlMs) {
63
+ return request(this.base, `/pub/events/${encodeURIComponent(key)}/extend`, {
64
+ method: "POST",
65
+ body: { holdId, ...ttlMs ? { ttlMs } : {} }
66
+ });
67
+ }
60
68
  socketUrl(key) {
61
69
  const wsBase = this.base.replace(/^http/, "ws");
62
70
  return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe`;
@@ -378,12 +386,15 @@ var EmbeddedDesigner = class {
378
386
  import {
379
387
  PickerController as PickerController2,
380
388
  expandChart,
389
+ generateSeatPanorama,
381
390
  loadLocale as loadLocale2,
382
391
  setStringOverrides as setStringOverrides2,
383
- t as t2
392
+ t as t2,
393
+ tCount
384
394
  } from "@seatlayer/core";
385
395
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
386
396
  var DEFAULT_MAX_SELECTION2 = 10;
397
+ var EXTEND_PROMPT_MS = 6e4;
387
398
  function resolveContainer3(container) {
388
399
  if (typeof container === "string") {
389
400
  const el = document.querySelector(container);
@@ -497,6 +508,30 @@ var CSS = `
497
508
  .sl-boot-retry{margin-top:4px;padding:9px 20px;border-radius:var(--sl-r-sm);background:var(--sl-accent);
498
509
  color:var(--sl-accent-ink);font-weight:700;font-size:13px}
499
510
 
511
+ /* "Need more time?" extend prompt (bottom-center over the map, above the toast) */
512
+ .sl-extend{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(6px);z-index:9;
513
+ display:none;align-items:center;gap:12px;max-width:92%;background:var(--sl-surface);border:1px solid var(--sl-line);
514
+ color:var(--sl-text);border-radius:14px;padding:10px 12px 10px 16px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);
515
+ opacity:0;transition:opacity .2s,transform .2s}
516
+ .sl-extend.on{display:flex;opacity:1;transform:translateX(-50%) translateY(0)}
517
+ .sl-extend-txt{font-size:12.5px;font-weight:600;line-height:1.35}
518
+ .sl-extend-txt b{font-variant-numeric:tabular-nums}
519
+ .sl-extend-btn{flex:none;padding:8px 14px;border-radius:999px;font-weight:800;font-size:12.5px;
520
+ background:var(--sl-accent);color:var(--sl-accent-ink);transition:filter .15s,opacity .15s}
521
+ .sl-extend-btn:hover{filter:brightness(1.08)}
522
+ .sl-extend-btn:disabled{opacity:.5;cursor:not-allowed}
523
+
524
+ /* booked confirmation overlay (covers the widget once the held seats are sold) */
525
+ .sl-booked{position:absolute;inset:0;z-index:11;display:none;flex-direction:column;align-items:center;
526
+ justify-content:center;gap:12px;text-align:center;padding:28px;background:var(--sl-bg)}
527
+ .sl-booked.on{display:flex}
528
+ .sl-booked-badge{width:60px;height:60px;border-radius:999px;display:flex;align-items:center;justify-content:center;
529
+ background:var(--sl-accent);color:var(--sl-accent-ink)}
530
+ .sl-booked-badge svg{width:30px;height:30px;stroke:currentColor;stroke-width:2.6;fill:none;stroke-linecap:round;stroke-linejoin:round}
531
+ .sl-booked-title{font-weight:800;font-size:19px;color:var(--sl-text)}
532
+ .sl-booked-sub{font-size:13px;color:var(--sl-muted);line-height:1.5;max-width:320px}
533
+ .sl-booked-seats{font-weight:700;color:var(--sl-text)}
534
+
500
535
  /* a11y filter chips (over the map, top-left) */
501
536
  .sl-chips{position:absolute;top:12px;left:12px;z-index:5;display:flex;gap:6px;flex-wrap:wrap;max-width:70%}
502
537
  .sl-chip-f{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;font-size:12px;font-weight:700;
@@ -530,6 +565,79 @@ var CSS = `
530
565
  /* screen-reader live region */
531
566
  .sl-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
532
567
 
568
+ /* per-seat ticket-tier select + view-from-seat button in tray chips */
569
+ .sl-chip .tier{background:var(--sl-bg);color:var(--sl-text);border:1px solid var(--sl-line);border-radius:7px;
570
+ font:inherit;font-size:11px;padding:3px 5px;max-width:130px;cursor:pointer}
571
+ .sl-chip .view{width:24px;height:24px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
572
+ color:var(--sl-muted);transition:color .15s}
573
+ .sl-chip .view:hover{color:var(--sl-text)}
574
+ .sl-chip .view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
575
+
576
+ /* arena: LOD rung pills (top-center over the map) */
577
+ .sl-rungs{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;display:none;
578
+ background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
579
+ .sl-rungs.on{display:inline-flex;gap:2px}
580
+ .sl-rungs button{padding:6px 13px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.07em;
581
+ color:var(--sl-muted);white-space:nowrap;transition:color .15s}
582
+ .sl-rungs button:hover{color:var(--sl-text)}
583
+ .sl-rungs button.on{background:var(--sl-accent);color:var(--sl-accent-ink)}
584
+
585
+ /* multi-floor switcher (center-left rail over the map) */
586
+ .sl-floors{position:absolute;top:50%;left:12px;transform:translateY(-50%);z-index:5;display:none;
587
+ flex-direction:column;gap:6px;max-width:42%}
588
+ .sl-floors.on{display:flex}
589
+ .sl-floors button{padding:7px 13px;border-radius:999px;font-size:12px;font-weight:700;background:var(--sl-surface);
590
+ border:1px solid var(--sl-line);color:var(--sl-muted);white-space:nowrap;max-width:100%;overflow:hidden;
591
+ text-overflow:ellipsis;transition:color .15s,border-color .15s}
592
+ .sl-floors button:hover{color:var(--sl-text)}
593
+ .sl-floors button.on{background:var(--sl-accent);color:var(--sl-accent-ink);border-color:transparent}
594
+
595
+ /* tapped-section summary card (top-center, under the rung pills) */
596
+ .sl-seccard{position:absolute;top:54px;left:50%;transform:translateX(-50%);z-index:6;width:250px;
597
+ max-width:calc(100% - 24px);background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:12px;
598
+ padding:12px 14px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);display:none}
599
+ .sl-seccard.on{display:block}
600
+ .sl-seccard-head{display:flex;align-items:center;gap:8px}
601
+ .sl-seccard-dot{width:10px;height:10px;border-radius:50%;flex:none}
602
+ .sl-seccard-name{font-weight:800;font-size:14px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
603
+ .sl-seccard-price{font-weight:800;font-size:12.5px;font-variant-numeric:tabular-nums}
604
+ .sl-seccard-x{width:22px;height:22px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
605
+ color:var(--sl-muted);font-size:12px}
606
+ .sl-seccard-x:hover{color:var(--sl-text)}
607
+ .sl-seccard-zone{font-size:11.5px;color:var(--sl-muted);margin-top:6px}
608
+ .sl-seccard-left{color:var(--sl-text);font-weight:700}
609
+ .sl-seccard-mix{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:8px}
610
+ .sl-seccard-mix-item{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--sl-muted)}
611
+ .sl-seccard-mix-dot{width:8px;height:8px;border-radius:50%;flex:none}
612
+ .sl-seccard-mix-price{font-weight:700;color:var(--sl-text)}
613
+ .sl-seccard-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:10px}
614
+ .sl-seccard-overview{font-size:12px;font-weight:800;color:var(--sl-accent)}
615
+ .sl-seccard-hint{font-size:10.5px;color:var(--sl-muted)}
616
+
617
+ /* view-from-seat button on the confirm popover */
618
+ .sl-confirm-view{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
619
+ color:var(--sl-text);font-weight:700;font-size:12px;display:flex;align-items:center;justify-content:center;gap:7px}
620
+ .sl-confirm-view:hover{border-color:var(--sl-muted)}
621
+ .sl-confirm-view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
622
+
623
+ /* 360\xB0 seat-view modal (fills the widget; drag-to-look-around equirectangular) */
624
+ .sl-view{position:absolute;inset:0;z-index:12;display:flex;flex-direction:column;background:var(--sl-bg)}
625
+ .sl-view-head{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--sl-line);flex:none}
626
+ .sl-view-title{font-weight:800;font-size:15px}
627
+ .sl-view-cap{font-size:11px;color:var(--sl-muted)}
628
+ .sl-view-x{margin-left:auto;width:32px;height:32px;border-radius:999px;border:1px solid var(--sl-line);color:var(--sl-muted);
629
+ flex:none;display:flex;align-items:center;justify-content:center;transition:color .15s,border-color .15s}
630
+ .sl-view-x:hover{color:var(--sl-text);border-color:var(--sl-muted)}
631
+ .sl-view-x svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round}
632
+ .sl-view-pano{position:relative;flex:1;min-height:0;overflow:hidden;cursor:grab;background-color:#05070c;
633
+ background-repeat:repeat-x;touch-action:none;user-select:none}
634
+ .sl-view-pano.drag{cursor:grabbing}
635
+ .sl-view-badge{position:absolute;top:12px;left:12px;padding:5px 11px;border-radius:999px;font-size:10px;font-weight:800;
636
+ letter-spacing:.08em;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted)}
637
+ .sl-view-hint{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);padding:6px 14px;border-radius:999px;
638
+ font-size:11.5px;font-weight:600;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted);
639
+ white-space:nowrap;pointer-events:none;max-width:90%;overflow:hidden;text-overflow:ellipsis}
640
+
533
641
  /* modal host */
534
642
  .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}
535
643
  .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)}
@@ -571,6 +679,14 @@ var SeatPicker = class _SeatPicker {
571
679
  // state
572
680
  this.currency = "USD";
573
681
  this.hold = null;
682
+ /** Latest server expiry for the open hold (moves on extend). */
683
+ this.holdExpiresAt = 0;
684
+ /** True once we handed off to checkout — arms booked-confirmation detection. */
685
+ this.handedOff = false;
686
+ /** Guards single onBooked + single success overlay per hold. */
687
+ this.bookedShown = false;
688
+ this.extendEl = null;
689
+ this.bookedEl = null;
574
690
  this.gaQty = /* @__PURE__ */ new Map();
575
691
  this.tipEl = null;
576
692
  this.tipPos = { x: 0, y: 0 };
@@ -580,6 +696,13 @@ var SeatPicker = class _SeatPicker {
580
696
  this.a11yFilter = "all";
581
697
  this.baQty = 2;
582
698
  this.baCat = "";
699
+ // arena / multi-floor / seat-view chrome
700
+ this.rungsEl = null;
701
+ this.floorsEl = null;
702
+ this.secCardEl = null;
703
+ this.viewEl = null;
704
+ this.viewCleanup = null;
705
+ this.allSeatsCache = null;
583
706
  // modal plumbing (set by open())
584
707
  this.modalScrim = null;
585
708
  this.prevFocus = null;
@@ -602,9 +725,12 @@ var SeatPicker = class _SeatPicker {
602
725
  onStatusChange: () => {
603
726
  this.syncPrices();
604
727
  this.evictTakenSelections();
728
+ this.detectBooked();
605
729
  },
606
730
  onHoldExpired: () => {
607
731
  this.hold = null;
732
+ this.handedOff = false;
733
+ this.bookedShown = false;
608
734
  this.stopHoldTimer();
609
735
  this.gaQty.clear();
610
736
  this.toast(t2("picker.holdExpired", void 0) || "Your hold expired \u2014 seats released. Pick again.");
@@ -615,7 +741,12 @@ var SeatPicker = class _SeatPicker {
615
741
  onSelect: (seat) => {
616
742
  if (this.opts.confirmSelection) this.showConfirm(seat);
617
743
  },
618
- onViewChange: () => this.reanchorConfirm(),
744
+ onViewChange: () => {
745
+ this.reanchorConfirm();
746
+ this.syncRung();
747
+ },
748
+ // Tapped-section glide-in → surface (or clear) the section-summary card.
749
+ onSectionFocus: (summary) => this.showSectionCard(summary),
619
750
  onFocusSeat: (seat) => this.announceSeat(seat),
620
751
  onSeatHover: (d) => this.updateTooltip(d),
621
752
  onHint: (m) => {
@@ -807,10 +938,130 @@ var SeatPicker = class _SeatPicker {
807
938
  this.srEl.className = "sl-sr";
808
939
  this.srEl.setAttribute("aria-live", "polite");
809
940
  root.appendChild(this.srEl);
941
+ this.buildArenaChrome();
942
+ this.buildExtendPrompt();
943
+ this.buildBookedOverlay();
810
944
  this.syncPrices();
811
945
  this.syncTray();
812
946
  return this;
813
947
  }
948
+ /** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
949
+ buildExtendPrompt() {
950
+ const el = document.createElement("div");
951
+ el.className = "sl-extend";
952
+ el.setAttribute("role", "status");
953
+ el.innerHTML = `<span class="sl-extend-txt" data-ref="extendTxt"></span><button type="button" class="sl-extend-btn" data-ref="extendBtn"></button>`;
954
+ this.els.map.appendChild(el);
955
+ this.extendEl = el;
956
+ this.els.extendTxt = el.querySelector('[data-ref="extendTxt"]');
957
+ this.els.extendBtn = el.querySelector('[data-ref="extendBtn"]');
958
+ this.els.extendBtn.textContent = "Add time";
959
+ this.els.extendBtn.addEventListener("click", () => void this.handleExtend());
960
+ }
961
+ /** Success overlay + onBooked fire when the held seats settle to booked. */
962
+ buildBookedOverlay() {
963
+ const el = document.createElement("div");
964
+ el.className = "sl-booked";
965
+ el.setAttribute("role", "status");
966
+ el.setAttribute("aria-live", "polite");
967
+ 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>`;
968
+ this.root.appendChild(el);
969
+ this.bookedEl = el;
970
+ this.els.bookedSub = el.querySelector('[data-ref="bookedSub"]');
971
+ }
972
+ // ---- arena / multi-floor chrome -------------------------------------------
973
+ /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
974
+ buildArenaChrome() {
975
+ const doc = this.controller.doc;
976
+ if (!doc || !this.els.map) return;
977
+ const hasSections = doc.objects.some((o) => o.type === "section") || (doc.floors ?? []).some((f) => f.objects.some((o) => o.type === "section"));
978
+ if (hasSections) {
979
+ const RUNGS = ["zones", "sections", "seats"];
980
+ const pills = document.createElement("div");
981
+ pills.className = "sl-rungs on";
982
+ pills.setAttribute("role", "group");
983
+ pills.setAttribute("aria-label", t2("picker.zoomLevel"));
984
+ const LABEL = {
985
+ zones: t2("picker.rungLabel.zones"),
986
+ sections: t2("picker.rungLabel.sections"),
987
+ seats: t2("picker.rungLabel.seats")
988
+ };
989
+ const TIP = {
990
+ zones: t2("picker.rungTip.zones"),
991
+ sections: t2("picker.rungTip.sections"),
992
+ seats: t2("picker.rungTip.seats")
993
+ };
994
+ pills.innerHTML = RUNGS.map(
995
+ (r) => `<button type="button" data-rung="${r}" title="${TIP[r]}" aria-pressed="false">${LABEL[r]}</button>`
996
+ ).join("");
997
+ pills.querySelectorAll("button").forEach((btn) => {
998
+ btn.addEventListener("click", () => this.controller.setRung(btn.dataset.rung));
999
+ });
1000
+ this.els.map.appendChild(pills);
1001
+ this.rungsEl = pills;
1002
+ this.syncRung();
1003
+ }
1004
+ if (this.controller.isMultiFloor()) {
1005
+ const floors = this.controller.getFloors();
1006
+ const rail = document.createElement("div");
1007
+ rail.className = "sl-floors on";
1008
+ rail.setAttribute("role", "group");
1009
+ rail.setAttribute("aria-label", t2("picker.floor"));
1010
+ rail.innerHTML = floors.map((f) => `<button type="button" data-floor="${f.id}">${f.name}</button>`).join("");
1011
+ rail.querySelectorAll("button").forEach((btn) => {
1012
+ btn.addEventListener("click", () => {
1013
+ this.controller.setFloor(btn.dataset.floor);
1014
+ this.showSectionCard(null);
1015
+ this.syncFloors();
1016
+ this.syncRung();
1017
+ });
1018
+ });
1019
+ this.els.map.appendChild(rail);
1020
+ this.floorsEl = rail;
1021
+ this.syncFloors();
1022
+ }
1023
+ }
1024
+ /** Reflect the engine's current LOD rung onto the pill group. */
1025
+ syncRung() {
1026
+ if (!this.rungsEl) return;
1027
+ const active = this.controller.getRung();
1028
+ this.rungsEl.querySelectorAll("button").forEach((btn) => {
1029
+ const on = btn.dataset.rung === active;
1030
+ btn.classList.toggle("on", on);
1031
+ btn.setAttribute("aria-pressed", String(on));
1032
+ });
1033
+ }
1034
+ /** Reflect the active floor onto the switcher rail. */
1035
+ syncFloors() {
1036
+ if (!this.floorsEl) return;
1037
+ const active = this.controller.getActiveFloorId();
1038
+ this.floorsEl.querySelectorAll("button").forEach((btn) => {
1039
+ btn.classList.toggle("on", btn.dataset.floor === active);
1040
+ });
1041
+ }
1042
+ /** Show (or clear, on null) the tapped-section summary card. */
1043
+ showSectionCard(summary) {
1044
+ if (!summary) {
1045
+ this.secCardEl?.remove();
1046
+ this.secCardEl = null;
1047
+ return;
1048
+ }
1049
+ if (!this.els.map) return;
1050
+ this.secCardEl?.remove();
1051
+ const priceLabel = summary.priceMin === summary.priceMax ? this.money(summary.priceMin) : `${this.money(summary.priceMin)}\u2013${this.money(summary.priceMax)}`;
1052
+ const card = document.createElement("div");
1053
+ card.className = "sl-seccard on";
1054
+ card.setAttribute("role", "dialog");
1055
+ card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
1056
+ const mix = summary.categories.map(
1057
+ (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>`
1058
+ ).join("");
1059
+ 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="${t2("picker.closeSectionSummary")}">\u2715</button></div><div class="sl-seccard-zone">${summary.zoneLabel ? `${summary.zoneLabel} \xB7 ` : ""}<span class="sl-seccard-left">${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 ${t2("picker.overview")}</button><span class="sl-seccard-hint">${t2("picker.tapSeatHint")}</span></div>`;
1060
+ card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
1061
+ card.querySelector(".sl-seccard-overview").addEventListener("click", () => this.controller.overview());
1062
+ this.els.map.appendChild(card);
1063
+ this.secCardEl = card;
1064
+ }
814
1065
  /** aria-live readout when keyboard focus lands on a seat. */
815
1066
  announceSeat(seat) {
816
1067
  if (!this.srEl) return;
@@ -832,11 +1083,12 @@ var SeatPicker = class _SeatPicker {
832
1083
  const price = cat?.tiers?.length ? cat.tiers[0].price : cat?.price;
833
1084
  const el = document.createElement("div");
834
1085
  el.className = "sl-confirm";
835
- 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>`;
1086
+ 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>${t2("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>`;
836
1087
  this.els.map.appendChild(el);
837
1088
  this.confirmEl = el;
838
1089
  this.confirmSeat = seat;
839
1090
  this.reanchorConfirm();
1091
+ el.querySelector(".sl-confirm-view")?.addEventListener("click", () => this.openSeatView(seat));
840
1092
  el.querySelector(".sl-confirm-add").addEventListener("click", () => this.closeConfirm());
841
1093
  el.querySelector(".sl-confirm-cancel").addEventListener("click", () => {
842
1094
  this.controller.deselect([seat.id]);
@@ -854,6 +1106,122 @@ var SeatPicker = class _SeatPicker {
854
1106
  this.confirmEl = null;
855
1107
  this.confirmSeat = null;
856
1108
  }
1109
+ // ---- 360° view-from-seat modal --------------------------------------------
1110
+ seatViewEnabled() {
1111
+ return this.opts.seatView !== false;
1112
+ }
1113
+ /** Every bookable seat (cached) — neighbor heads for the generated panorama. */
1114
+ allSeats() {
1115
+ if (!this.allSeatsCache) {
1116
+ const doc = this.controller.doc;
1117
+ this.allSeatsCache = doc ? expandChart(doc) : [];
1118
+ }
1119
+ return this.allSeatsCache;
1120
+ }
1121
+ /**
1122
+ * Open the drag-to-look-around 360° preview for a seat. Uses the organizer's
1123
+ * uploaded photo (seat.viewUrl) when present, else a panorama generated from
1124
+ * the chart geometry — the stage placed at this seat's true bearing + size.
1125
+ * Zero extra dependencies: an equirectangular image panned with `repeat-x`.
1126
+ */
1127
+ openSeatView(seat) {
1128
+ if (!this.root || !this.seatViewEnabled()) return;
1129
+ this.closeSeatView();
1130
+ this.closeConfirm();
1131
+ const doc = this.controller.doc;
1132
+ const activeId = this.controller.getActiveFloorId();
1133
+ const focal = doc?.floors?.find((f) => f.id === activeId)?.focalPoint ?? doc?.focalPoint ?? { x: 0, y: 0 };
1134
+ let panoUrl;
1135
+ let caption;
1136
+ let real = false;
1137
+ if (seat.viewUrl) {
1138
+ panoUrl = seat.viewUrl;
1139
+ caption = t2("picker.panorama360");
1140
+ real = true;
1141
+ } else {
1142
+ const pano2 = generateSeatPanorama(seat, focal, this.allSeats());
1143
+ panoUrl = pano2.url;
1144
+ caption = t2("picker.illustrationCaption", { m: pano2.distanceM });
1145
+ }
1146
+ const el = document.createElement("div");
1147
+ el.className = "sl-view";
1148
+ el.setAttribute("role", "dialog");
1149
+ el.setAttribute("aria-label", t2("picker.viewFromSeat", { label: seat.label }));
1150
+ el.innerHTML = `<div class="sl-view-head"><span class="sl-view-title">${t2("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 ? t2("picker.real360") : t2("picker.preview")}</span><span class="sl-view-hint">Drag to look around \xB7 scroll to zoom</span></div>`;
1151
+ this.root.appendChild(el);
1152
+ this.viewEl = el;
1153
+ const pano = el.querySelector(".sl-view-pano");
1154
+ pano.style.backgroundImage = `url("${panoUrl}")`;
1155
+ let zoom = 1.2;
1156
+ let posX = 0;
1157
+ let posY = 0;
1158
+ const apply = () => {
1159
+ const h = pano.clientHeight || 1;
1160
+ const bgH = h * zoom;
1161
+ const overV = Math.max(0, bgH - h);
1162
+ posY = Math.min(overV / 2, Math.max(-overV / 2, posY));
1163
+ pano.style.backgroundSize = `auto ${bgH}px`;
1164
+ pano.style.backgroundPosition = `${posX}px ${posY + overV / 2}px`;
1165
+ };
1166
+ apply();
1167
+ let dragging = false;
1168
+ let lastX = 0;
1169
+ let lastY = 0;
1170
+ const onDown = (e) => {
1171
+ dragging = true;
1172
+ lastX = e.clientX;
1173
+ lastY = e.clientY;
1174
+ pano.classList.add("drag");
1175
+ pano.setPointerCapture?.(e.pointerId);
1176
+ };
1177
+ const onMove = (e) => {
1178
+ if (!dragging) return;
1179
+ posX += e.clientX - lastX;
1180
+ posY += e.clientY - lastY;
1181
+ lastX = e.clientX;
1182
+ lastY = e.clientY;
1183
+ apply();
1184
+ };
1185
+ const onUp = (e) => {
1186
+ dragging = false;
1187
+ pano.classList.remove("drag");
1188
+ pano.releasePointerCapture?.(e.pointerId);
1189
+ };
1190
+ const onWheel = (e) => {
1191
+ e.preventDefault();
1192
+ zoom = Math.min(2.4, Math.max(1, zoom + (e.deltaY < 0 ? 0.12 : -0.12)));
1193
+ apply();
1194
+ };
1195
+ pano.addEventListener("pointerdown", onDown);
1196
+ pano.addEventListener("pointermove", onMove);
1197
+ pano.addEventListener("pointerup", onUp);
1198
+ pano.addEventListener("pointercancel", onUp);
1199
+ pano.addEventListener("wheel", onWheel, { passive: false });
1200
+ const closeBtn = el.querySelector(".sl-view-x");
1201
+ closeBtn.addEventListener("click", () => this.closeSeatView());
1202
+ const onKey = (e) => {
1203
+ if (e.key === "Escape") {
1204
+ e.stopPropagation();
1205
+ this.closeSeatView();
1206
+ }
1207
+ };
1208
+ el.addEventListener("keydown", onKey);
1209
+ closeBtn.focus();
1210
+ this.viewCleanup = () => {
1211
+ pano.removeEventListener("pointerdown", onDown);
1212
+ pano.removeEventListener("pointermove", onMove);
1213
+ pano.removeEventListener("pointerup", onUp);
1214
+ pano.removeEventListener("pointercancel", onUp);
1215
+ pano.removeEventListener("wheel", onWheel);
1216
+ el.removeEventListener("keydown", onKey);
1217
+ };
1218
+ }
1219
+ closeSeatView() {
1220
+ this.viewCleanup?.();
1221
+ this.viewCleanup = null;
1222
+ this.viewEl?.remove();
1223
+ this.viewEl = null;
1224
+ }
857
1225
  // ---- chrome sync ----------------------------------------------------------
858
1226
  money(n) {
859
1227
  try {
@@ -896,15 +1264,20 @@ var SeatPicker = class _SeatPicker {
896
1264
  }
897
1265
  for (const item of heldItems) {
898
1266
  const cat = this.controller.doc?.categories.find((c) => c.key === item.categoryKey);
1267
+ const tierName = item.tierId ? cat?.tiers?.find((ti) => ti.id === item.tierId)?.name : void 0;
1268
+ const canView2 = this.seatViewEnabled() && item.objectType !== "ga" && !!this.controller.seatByLabel(item.label);
899
1269
  parts.push(
900
- `<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>`
1270
+ `<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="${t2("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>`
901
1271
  );
902
1272
  }
903
1273
  const heldLabels = new Set(heldItems.map((item) => item.label));
1274
+ const canView = this.seatViewEnabled();
904
1275
  for (const s of seats.filter((seat) => !heldLabels.has(seat.label))) {
905
1276
  const cat = this.controller.doc?.categories.find((c) => c.key === s.categoryKey);
1277
+ const tierSelect = s.tiers && s.tiers.length ? `<select class="tier" data-tier="${s.id}" aria-label="${t2("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>` : "";
1278
+ const viewBtn = canView ? `<button type="button" class="view" data-view-label="${s.label}" aria-label="${t2("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>` : "";
906
1279
  parts.push(
907
- `<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>`
1280
+ `<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>`
908
1281
  );
909
1282
  }
910
1283
  for (const area of gaAreas) {
@@ -938,6 +1311,15 @@ var SeatPicker = class _SeatPicker {
938
1311
  this.controller.deselect([id]);
939
1312
  });
940
1313
  });
1314
+ this.els.tray.querySelectorAll(".sl-chip .tier").forEach((sel) => {
1315
+ sel.addEventListener("change", () => this.controller.setSeatTier(sel.dataset.tier, sel.value || null));
1316
+ });
1317
+ this.els.tray.querySelectorAll(".sl-chip .view[data-view-label]").forEach((btn) => {
1318
+ btn.addEventListener("click", () => {
1319
+ const seat = this.controller.seatByLabel(btn.dataset.viewLabel);
1320
+ if (seat) this.openSeatView(seat);
1321
+ });
1322
+ });
941
1323
  this.els.tray.querySelectorAll(".sl-ga button").forEach((btn) => {
942
1324
  btn.addEventListener("click", () => {
943
1325
  const areaEl = btn.closest(".sl-ga");
@@ -965,7 +1347,9 @@ var SeatPicker = class _SeatPicker {
965
1347
  async handleCta() {
966
1348
  const cta = this.els.cta;
967
1349
  if (this.hold && !this.controller.getSelection().some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
968
- this.opts.onCheckout?.(this.hold, this.hold.seats ?? this.controller.getSelection());
1350
+ const seats = this.hold.seats ?? this.controller.getSelection();
1351
+ this.handedOff = true;
1352
+ this.opts.onCheckout?.(this.hold, seats, this.buildHandoff(this.hold));
969
1353
  return;
970
1354
  }
971
1355
  cta.disabled = true;
@@ -988,8 +1372,9 @@ var SeatPicker = class _SeatPicker {
988
1372
  return;
989
1373
  }
990
1374
  this.hold = hold;
1375
+ this.handedOff = true;
991
1376
  this.startHoldTimer(hold.expiresAt);
992
- this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? []);
1377
+ this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? [], this.buildHandoff(hold));
993
1378
  } catch (err) {
994
1379
  this.opts.onError?.(err);
995
1380
  this.toast("One or more seats were just taken. Please pick again.");
@@ -999,13 +1384,15 @@ var SeatPicker = class _SeatPicker {
999
1384
  }
1000
1385
  startHoldTimer(expiresAt) {
1001
1386
  this.stopHoldTimer();
1387
+ this.holdExpiresAt = expiresAt;
1002
1388
  const pill = this.els.hold;
1003
1389
  const tick = () => {
1004
- const ms = Math.max(0, expiresAt - Date.now());
1390
+ const ms = Math.max(0, this.holdExpiresAt - Date.now());
1005
1391
  const m = Math.floor(ms / 6e4);
1006
1392
  const s = String(Math.floor(ms % 6e4 / 1e3)).padStart(2, "0");
1007
1393
  pill.textContent = `Held ${m}:${s}`;
1008
1394
  pill.classList.add("on");
1395
+ this.setExtendPrompt(ms > 0 && ms <= EXTEND_PROMPT_MS, ms);
1009
1396
  if (ms <= 0) this.stopHoldTimer();
1010
1397
  };
1011
1398
  tick();
@@ -1015,6 +1402,83 @@ var SeatPicker = class _SeatPicker {
1015
1402
  if (this.holdTimer) clearInterval(this.holdTimer);
1016
1403
  this.holdTimer = null;
1017
1404
  this.els.hold?.classList.remove("on");
1405
+ this.setExtendPrompt(false, 0);
1406
+ }
1407
+ /** Show/refresh (or hide) the "Need more time?" prompt with the live seconds left. */
1408
+ setExtendPrompt(show, ms) {
1409
+ if (!this.extendEl) return;
1410
+ if (show && this.controller.currentHold() && !this.bookedShown) {
1411
+ const secs = Math.ceil(ms / 1e3);
1412
+ this.els.extendTxt.innerHTML = `Your seats are held for <b>0:${String(secs).padStart(2, "0")}</b>. Need more time?`;
1413
+ this.extendEl.classList.add("on");
1414
+ } else {
1415
+ this.extendEl.classList.remove("on");
1416
+ }
1417
+ }
1418
+ async handleExtend() {
1419
+ const btn = this.els.extendBtn;
1420
+ btn.disabled = true;
1421
+ const prev = btn.textContent;
1422
+ btn.textContent = "Adding\u2026";
1423
+ try {
1424
+ const h = await this.controller.extendHold(this.opts.holdTtlMs);
1425
+ if (h) {
1426
+ this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1427
+ this.holdExpiresAt = h.expiresAt;
1428
+ this.extendEl?.classList.remove("on");
1429
+ this.toast("More time added \u2014 your seats are still held.");
1430
+ } else {
1431
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1432
+ }
1433
+ } catch (err) {
1434
+ this.opts.onError?.(err);
1435
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1436
+ } finally {
1437
+ btn.disabled = false;
1438
+ btn.textContent = prev;
1439
+ }
1440
+ }
1441
+ /**
1442
+ * Fire the booked-confirmation state once the buyer's held seats settle to
1443
+ * booked. The controller clears its own hold the moment every held label reads
1444
+ * 'booked' over the realtime channel (clearBookedHoldIfSettled), and this runs
1445
+ * on the same onStatusChange — so `currentHold() === null` while we still hold
1446
+ * a checkout handoff means "sold", not expired (expiry clears via onHoldExpired
1447
+ * on a different path, which nulls this.hold first).
1448
+ */
1449
+ detectBooked() {
1450
+ if (this.bookedShown || !this.handedOff || !this.hold) return;
1451
+ if (this.controller.currentHold() !== null) return;
1452
+ this.showBooked();
1453
+ }
1454
+ showBooked() {
1455
+ if (this.bookedShown || !this.hold) return;
1456
+ this.bookedShown = true;
1457
+ const handoff = this.buildHandoff(this.hold);
1458
+ this.stopHoldTimer();
1459
+ const n = handoff.lineItems.reduce((sum, i) => sum + i.quantity, 0);
1460
+ if (this.els.bookedSub) {
1461
+ this.els.bookedSub.innerHTML = `<span class="sl-booked-seats">${n} ${n === 1 ? "ticket" : "tickets"}</span> confirmed. A confirmation is on its way.`;
1462
+ }
1463
+ this.bookedEl?.classList.add("on");
1464
+ this.opts.onBooked?.(handoff);
1465
+ }
1466
+ /** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
1467
+ buildHandoff(hold) {
1468
+ const items = hold.items ?? [];
1469
+ const lineItems = items.map((it) => ({
1470
+ label: it.label,
1471
+ objectId: it.objectId,
1472
+ objectType: it.objectType,
1473
+ categoryKey: it.categoryKey,
1474
+ tierId: it.tierId,
1475
+ unitPrice: it.unitPrice,
1476
+ currency: it.currency ?? this.currency,
1477
+ quantity: it.quantity ?? 1
1478
+ }));
1479
+ const currency = lineItems[0]?.currency ?? this.currency;
1480
+ const total = lineItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
1481
+ return { holdId: hold.holdId, expiresAt: hold.expiresAt, currency, lineItems, total };
1018
1482
  }
1019
1483
  toast(msg) {
1020
1484
  const el = this.els.toast;
@@ -1056,6 +1520,8 @@ var SeatPicker = class _SeatPicker {
1056
1520
  const h = await this.controller.bestAvailable(qty, categoryKey);
1057
1521
  if (h) {
1058
1522
  this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1523
+ this.handedOff = false;
1524
+ this.bookedShown = false;
1059
1525
  this.startHoldTimer(h.expiresAt);
1060
1526
  this.syncTray();
1061
1527
  return this.hold;
@@ -1069,6 +1535,8 @@ var SeatPicker = class _SeatPicker {
1069
1535
  async release() {
1070
1536
  await this.controller.release();
1071
1537
  this.hold = null;
1538
+ this.handedOff = false;
1539
+ this.bookedShown = false;
1072
1540
  this.stopHoldTimer();
1073
1541
  this.gaQty.clear();
1074
1542
  this.syncTray();
@@ -1076,6 +1544,7 @@ var SeatPicker = class _SeatPicker {
1076
1544
  destroy() {
1077
1545
  this.destroyed = true;
1078
1546
  this.closeConfirm();
1547
+ this.closeSeatView();
1079
1548
  this.stopHoldTimer();
1080
1549
  if (this.toastTimer) clearTimeout(this.toastTimer);
1081
1550
  this.ro?.disconnect();