@seatlayer/js 0.7.2 → 0.8.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.d.cts CHANGED
@@ -324,6 +324,12 @@ interface SeatPickerOptions {
324
324
  * price · Add/Cancel) instead of adding straight to the tray. Default false.
325
325
  */
326
326
  confirmSelection?: boolean;
327
+ /**
328
+ * Offer a "View from seat" 360° preview (confirm popover + tray chips). The
329
+ * panorama is generated from the chart geometry, or the organizer's uploaded
330
+ * photo when a seat carries one. Default true; set false to hide the affordance.
331
+ */
332
+ seatView?: boolean;
327
333
  /**
328
334
  * Buyer pressed the CTA and the hold succeeded — hand off to YOUR checkout.
329
335
  * The hold carries holdId, expiresAt, seat labels and priced line items.
@@ -359,6 +365,12 @@ declare class SeatPicker {
359
365
  private a11yFilter;
360
366
  private baQty;
361
367
  private baCat;
368
+ private rungsEl;
369
+ private floorsEl;
370
+ private secCardEl;
371
+ private viewEl;
372
+ private viewCleanup;
373
+ private allSeatsCache;
362
374
  private modalScrim;
363
375
  private prevFocus;
364
376
  private escHandler;
@@ -374,11 +386,30 @@ declare class SeatPicker {
374
386
  static open(options: Omit<SeatPickerOptions, 'container'>): Promise<SeatPicker>;
375
387
  constructor(options: SeatPickerOptions);
376
388
  render(): Promise<this>;
389
+ /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
390
+ private buildArenaChrome;
391
+ /** Reflect the engine's current LOD rung onto the pill group. */
392
+ private syncRung;
393
+ /** Reflect the active floor onto the switcher rail. */
394
+ private syncFloors;
395
+ /** Show (or clear, on null) the tapped-section summary card. */
396
+ private showSectionCard;
377
397
  /** aria-live readout when keyboard focus lands on a seat. */
378
398
  private announceSeat;
379
399
  private showConfirm;
380
400
  private reanchorConfirm;
381
401
  private closeConfirm;
402
+ private seatViewEnabled;
403
+ /** Every bookable seat (cached) — neighbor heads for the generated panorama. */
404
+ private allSeats;
405
+ /**
406
+ * Open the drag-to-look-around 360° preview for a seat. Uses the organizer's
407
+ * uploaded photo (seat.viewUrl) when present, else a panorama generated from
408
+ * the chart geometry — the stage placed at this seat's true bearing + size.
409
+ * Zero extra dependencies: an equirectangular image panned with `repeat-x`.
410
+ */
411
+ private openSeatView;
412
+ private closeSeatView;
382
413
  private money;
383
414
  private syncPrices;
384
415
  /** A live delta took one of OUR selected (not yet held) seats — evict + tell the buyer. */
package/dist/index.d.ts CHANGED
@@ -324,6 +324,12 @@ interface SeatPickerOptions {
324
324
  * price · Add/Cancel) instead of adding straight to the tray. Default false.
325
325
  */
326
326
  confirmSelection?: boolean;
327
+ /**
328
+ * Offer a "View from seat" 360° preview (confirm popover + tray chips). The
329
+ * panorama is generated from the chart geometry, or the organizer's uploaded
330
+ * photo when a seat carries one. Default true; set false to hide the affordance.
331
+ */
332
+ seatView?: boolean;
327
333
  /**
328
334
  * Buyer pressed the CTA and the hold succeeded — hand off to YOUR checkout.
329
335
  * The hold carries holdId, expiresAt, seat labels and priced line items.
@@ -359,6 +365,12 @@ declare class SeatPicker {
359
365
  private a11yFilter;
360
366
  private baQty;
361
367
  private baCat;
368
+ private rungsEl;
369
+ private floorsEl;
370
+ private secCardEl;
371
+ private viewEl;
372
+ private viewCleanup;
373
+ private allSeatsCache;
362
374
  private modalScrim;
363
375
  private prevFocus;
364
376
  private escHandler;
@@ -374,11 +386,30 @@ declare class SeatPicker {
374
386
  static open(options: Omit<SeatPickerOptions, 'container'>): Promise<SeatPicker>;
375
387
  constructor(options: SeatPickerOptions);
376
388
  render(): Promise<this>;
389
+ /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
390
+ private buildArenaChrome;
391
+ /** Reflect the engine's current LOD rung onto the pill group. */
392
+ private syncRung;
393
+ /** Reflect the active floor onto the switcher rail. */
394
+ private syncFloors;
395
+ /** Show (or clear, on null) the tapped-section summary card. */
396
+ private showSectionCard;
377
397
  /** aria-live readout when keyboard focus lands on a seat. */
378
398
  private announceSeat;
379
399
  private showConfirm;
380
400
  private reanchorConfirm;
381
401
  private closeConfirm;
402
+ private seatViewEnabled;
403
+ /** Every bookable seat (cached) — neighbor heads for the generated panorama. */
404
+ private allSeats;
405
+ /**
406
+ * Open the drag-to-look-around 360° preview for a seat. Uses the organizer's
407
+ * uploaded photo (seat.viewUrl) when present, else a panorama generated from
408
+ * the chart geometry — the stage placed at this seat's true bearing + size.
409
+ * Zero extra dependencies: an equirectangular image panned with `repeat-x`.
410
+ */
411
+ private openSeatView;
412
+ private closeSeatView;
382
413
  private money;
383
414
  private syncPrices;
384
415
  /** A live delta took one of OUR selected (not yet held) seats — evict + tell the buyer. */
package/dist/index.js CHANGED
@@ -378,9 +378,11 @@ var EmbeddedDesigner = class {
378
378
  import {
379
379
  PickerController as PickerController2,
380
380
  expandChart,
381
+ generateSeatPanorama,
381
382
  loadLocale as loadLocale2,
382
383
  setStringOverrides as setStringOverrides2,
383
- t as t2
384
+ t as t2,
385
+ tCount
384
386
  } from "@seatlayer/core";
385
387
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
386
388
  var DEFAULT_MAX_SELECTION2 = 10;
@@ -530,6 +532,79 @@ var CSS = `
530
532
  /* screen-reader live region */
531
533
  .sl-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
532
534
 
535
+ /* per-seat ticket-tier select + view-from-seat button in tray chips */
536
+ .sl-chip .tier{background:var(--sl-bg);color:var(--sl-text);border:1px solid var(--sl-line);border-radius:7px;
537
+ font:inherit;font-size:11px;padding:3px 5px;max-width:130px;cursor:pointer}
538
+ .sl-chip .view{width:24px;height:24px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
539
+ color:var(--sl-muted);transition:color .15s}
540
+ .sl-chip .view:hover{color:var(--sl-text)}
541
+ .sl-chip .view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
542
+
543
+ /* arena: LOD rung pills (top-center over the map) */
544
+ .sl-rungs{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;display:none;
545
+ background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
546
+ .sl-rungs.on{display:inline-flex;gap:2px}
547
+ .sl-rungs button{padding:6px 13px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.07em;
548
+ color:var(--sl-muted);white-space:nowrap;transition:color .15s}
549
+ .sl-rungs button:hover{color:var(--sl-text)}
550
+ .sl-rungs button.on{background:var(--sl-accent);color:var(--sl-accent-ink)}
551
+
552
+ /* multi-floor switcher (center-left rail over the map) */
553
+ .sl-floors{position:absolute;top:50%;left:12px;transform:translateY(-50%);z-index:5;display:none;
554
+ flex-direction:column;gap:6px;max-width:42%}
555
+ .sl-floors.on{display:flex}
556
+ .sl-floors button{padding:7px 13px;border-radius:999px;font-size:12px;font-weight:700;background:var(--sl-surface);
557
+ border:1px solid var(--sl-line);color:var(--sl-muted);white-space:nowrap;max-width:100%;overflow:hidden;
558
+ text-overflow:ellipsis;transition:color .15s,border-color .15s}
559
+ .sl-floors button:hover{color:var(--sl-text)}
560
+ .sl-floors button.on{background:var(--sl-accent);color:var(--sl-accent-ink);border-color:transparent}
561
+
562
+ /* tapped-section summary card (top-center, under the rung pills) */
563
+ .sl-seccard{position:absolute;top:54px;left:50%;transform:translateX(-50%);z-index:6;width:250px;
564
+ max-width:calc(100% - 24px);background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:12px;
565
+ padding:12px 14px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);display:none}
566
+ .sl-seccard.on{display:block}
567
+ .sl-seccard-head{display:flex;align-items:center;gap:8px}
568
+ .sl-seccard-dot{width:10px;height:10px;border-radius:50%;flex:none}
569
+ .sl-seccard-name{font-weight:800;font-size:14px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
570
+ .sl-seccard-price{font-weight:800;font-size:12.5px;font-variant-numeric:tabular-nums}
571
+ .sl-seccard-x{width:22px;height:22px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
572
+ color:var(--sl-muted);font-size:12px}
573
+ .sl-seccard-x:hover{color:var(--sl-text)}
574
+ .sl-seccard-zone{font-size:11.5px;color:var(--sl-muted);margin-top:6px}
575
+ .sl-seccard-left{color:var(--sl-text);font-weight:700}
576
+ .sl-seccard-mix{display:flex;flex-wrap:wrap;gap:6px 10px;margin-top:8px}
577
+ .sl-seccard-mix-item{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--sl-muted)}
578
+ .sl-seccard-mix-dot{width:8px;height:8px;border-radius:50%;flex:none}
579
+ .sl-seccard-mix-price{font-weight:700;color:var(--sl-text)}
580
+ .sl-seccard-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:10px}
581
+ .sl-seccard-overview{font-size:12px;font-weight:800;color:var(--sl-accent)}
582
+ .sl-seccard-hint{font-size:10.5px;color:var(--sl-muted)}
583
+
584
+ /* view-from-seat button on the confirm popover */
585
+ .sl-confirm-view{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
586
+ color:var(--sl-text);font-weight:700;font-size:12px;display:flex;align-items:center;justify-content:center;gap:7px}
587
+ .sl-confirm-view:hover{border-color:var(--sl-muted)}
588
+ .sl-confirm-view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
589
+
590
+ /* 360\xB0 seat-view modal (fills the widget; drag-to-look-around equirectangular) */
591
+ .sl-view{position:absolute;inset:0;z-index:12;display:flex;flex-direction:column;background:var(--sl-bg)}
592
+ .sl-view-head{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--sl-line);flex:none}
593
+ .sl-view-title{font-weight:800;font-size:15px}
594
+ .sl-view-cap{font-size:11px;color:var(--sl-muted)}
595
+ .sl-view-x{margin-left:auto;width:32px;height:32px;border-radius:999px;border:1px solid var(--sl-line);color:var(--sl-muted);
596
+ flex:none;display:flex;align-items:center;justify-content:center;transition:color .15s,border-color .15s}
597
+ .sl-view-x:hover{color:var(--sl-text);border-color:var(--sl-muted)}
598
+ .sl-view-x svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round}
599
+ .sl-view-pano{position:relative;flex:1;min-height:0;overflow:hidden;cursor:grab;background-color:#05070c;
600
+ background-repeat:repeat-x;touch-action:none;user-select:none}
601
+ .sl-view-pano.drag{cursor:grabbing}
602
+ .sl-view-badge{position:absolute;top:12px;left:12px;padding:5px 11px;border-radius:999px;font-size:10px;font-weight:800;
603
+ letter-spacing:.08em;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted)}
604
+ .sl-view-hint{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);padding:6px 14px;border-radius:999px;
605
+ font-size:11.5px;font-weight:600;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted);
606
+ white-space:nowrap;pointer-events:none;max-width:90%;overflow:hidden;text-overflow:ellipsis}
607
+
533
608
  /* modal host */
534
609
  .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
610
  .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)}
@@ -580,6 +655,13 @@ var SeatPicker = class _SeatPicker {
580
655
  this.a11yFilter = "all";
581
656
  this.baQty = 2;
582
657
  this.baCat = "";
658
+ // arena / multi-floor / seat-view chrome
659
+ this.rungsEl = null;
660
+ this.floorsEl = null;
661
+ this.secCardEl = null;
662
+ this.viewEl = null;
663
+ this.viewCleanup = null;
664
+ this.allSeatsCache = null;
583
665
  // modal plumbing (set by open())
584
666
  this.modalScrim = null;
585
667
  this.prevFocus = null;
@@ -615,7 +697,12 @@ var SeatPicker = class _SeatPicker {
615
697
  onSelect: (seat) => {
616
698
  if (this.opts.confirmSelection) this.showConfirm(seat);
617
699
  },
618
- onViewChange: () => this.reanchorConfirm(),
700
+ onViewChange: () => {
701
+ this.reanchorConfirm();
702
+ this.syncRung();
703
+ },
704
+ // Tapped-section glide-in → surface (or clear) the section-summary card.
705
+ onSectionFocus: (summary) => this.showSectionCard(summary),
619
706
  onFocusSeat: (seat) => this.announceSeat(seat),
620
707
  onSeatHover: (d) => this.updateTooltip(d),
621
708
  onHint: (m) => {
@@ -807,10 +894,104 @@ var SeatPicker = class _SeatPicker {
807
894
  this.srEl.className = "sl-sr";
808
895
  this.srEl.setAttribute("aria-live", "polite");
809
896
  root.appendChild(this.srEl);
897
+ this.buildArenaChrome();
810
898
  this.syncPrices();
811
899
  this.syncTray();
812
900
  return this;
813
901
  }
902
+ // ---- arena / multi-floor chrome -------------------------------------------
903
+ /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
904
+ buildArenaChrome() {
905
+ const doc = this.controller.doc;
906
+ if (!doc || !this.els.map) return;
907
+ const hasSections = doc.objects.some((o) => o.type === "section") || (doc.floors ?? []).some((f) => f.objects.some((o) => o.type === "section"));
908
+ if (hasSections) {
909
+ const RUNGS = ["zones", "sections", "seats"];
910
+ const pills = document.createElement("div");
911
+ pills.className = "sl-rungs on";
912
+ pills.setAttribute("role", "group");
913
+ pills.setAttribute("aria-label", t2("picker.zoomLevel"));
914
+ const LABEL = {
915
+ zones: t2("picker.rungLabel.zones"),
916
+ sections: t2("picker.rungLabel.sections"),
917
+ seats: t2("picker.rungLabel.seats")
918
+ };
919
+ const TIP = {
920
+ zones: t2("picker.rungTip.zones"),
921
+ sections: t2("picker.rungTip.sections"),
922
+ seats: t2("picker.rungTip.seats")
923
+ };
924
+ pills.innerHTML = RUNGS.map(
925
+ (r) => `<button type="button" data-rung="${r}" title="${TIP[r]}" aria-pressed="false">${LABEL[r]}</button>`
926
+ ).join("");
927
+ pills.querySelectorAll("button").forEach((btn) => {
928
+ btn.addEventListener("click", () => this.controller.setRung(btn.dataset.rung));
929
+ });
930
+ this.els.map.appendChild(pills);
931
+ this.rungsEl = pills;
932
+ this.syncRung();
933
+ }
934
+ if (this.controller.isMultiFloor()) {
935
+ const floors = this.controller.getFloors();
936
+ const rail = document.createElement("div");
937
+ rail.className = "sl-floors on";
938
+ rail.setAttribute("role", "group");
939
+ rail.setAttribute("aria-label", t2("picker.floor"));
940
+ rail.innerHTML = floors.map((f) => `<button type="button" data-floor="${f.id}">${f.name}</button>`).join("");
941
+ rail.querySelectorAll("button").forEach((btn) => {
942
+ btn.addEventListener("click", () => {
943
+ this.controller.setFloor(btn.dataset.floor);
944
+ this.showSectionCard(null);
945
+ this.syncFloors();
946
+ this.syncRung();
947
+ });
948
+ });
949
+ this.els.map.appendChild(rail);
950
+ this.floorsEl = rail;
951
+ this.syncFloors();
952
+ }
953
+ }
954
+ /** Reflect the engine's current LOD rung onto the pill group. */
955
+ syncRung() {
956
+ if (!this.rungsEl) return;
957
+ const active = this.controller.getRung();
958
+ this.rungsEl.querySelectorAll("button").forEach((btn) => {
959
+ const on = btn.dataset.rung === active;
960
+ btn.classList.toggle("on", on);
961
+ btn.setAttribute("aria-pressed", String(on));
962
+ });
963
+ }
964
+ /** Reflect the active floor onto the switcher rail. */
965
+ syncFloors() {
966
+ if (!this.floorsEl) return;
967
+ const active = this.controller.getActiveFloorId();
968
+ this.floorsEl.querySelectorAll("button").forEach((btn) => {
969
+ btn.classList.toggle("on", btn.dataset.floor === active);
970
+ });
971
+ }
972
+ /** Show (or clear, on null) the tapped-section summary card. */
973
+ showSectionCard(summary) {
974
+ if (!summary) {
975
+ this.secCardEl?.remove();
976
+ this.secCardEl = null;
977
+ return;
978
+ }
979
+ if (!this.els.map) return;
980
+ this.secCardEl?.remove();
981
+ const priceLabel = summary.priceMin === summary.priceMax ? this.money(summary.priceMin) : `${this.money(summary.priceMin)}\u2013${this.money(summary.priceMax)}`;
982
+ const card = document.createElement("div");
983
+ card.className = "sl-seccard on";
984
+ card.setAttribute("role", "dialog");
985
+ card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
986
+ const mix = summary.categories.map(
987
+ (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>`
988
+ ).join("");
989
+ 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>`;
990
+ card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
991
+ card.querySelector(".sl-seccard-overview").addEventListener("click", () => this.controller.overview());
992
+ this.els.map.appendChild(card);
993
+ this.secCardEl = card;
994
+ }
814
995
  /** aria-live readout when keyboard focus lands on a seat. */
815
996
  announceSeat(seat) {
816
997
  if (!this.srEl) return;
@@ -832,11 +1013,12 @@ var SeatPicker = class _SeatPicker {
832
1013
  const price = cat?.tiers?.length ? cat.tiers[0].price : cat?.price;
833
1014
  const el = document.createElement("div");
834
1015
  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>`;
1016
+ 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
1017
  this.els.map.appendChild(el);
837
1018
  this.confirmEl = el;
838
1019
  this.confirmSeat = seat;
839
1020
  this.reanchorConfirm();
1021
+ el.querySelector(".sl-confirm-view")?.addEventListener("click", () => this.openSeatView(seat));
840
1022
  el.querySelector(".sl-confirm-add").addEventListener("click", () => this.closeConfirm());
841
1023
  el.querySelector(".sl-confirm-cancel").addEventListener("click", () => {
842
1024
  this.controller.deselect([seat.id]);
@@ -854,6 +1036,122 @@ var SeatPicker = class _SeatPicker {
854
1036
  this.confirmEl = null;
855
1037
  this.confirmSeat = null;
856
1038
  }
1039
+ // ---- 360° view-from-seat modal --------------------------------------------
1040
+ seatViewEnabled() {
1041
+ return this.opts.seatView !== false;
1042
+ }
1043
+ /** Every bookable seat (cached) — neighbor heads for the generated panorama. */
1044
+ allSeats() {
1045
+ if (!this.allSeatsCache) {
1046
+ const doc = this.controller.doc;
1047
+ this.allSeatsCache = doc ? expandChart(doc) : [];
1048
+ }
1049
+ return this.allSeatsCache;
1050
+ }
1051
+ /**
1052
+ * Open the drag-to-look-around 360° preview for a seat. Uses the organizer's
1053
+ * uploaded photo (seat.viewUrl) when present, else a panorama generated from
1054
+ * the chart geometry — the stage placed at this seat's true bearing + size.
1055
+ * Zero extra dependencies: an equirectangular image panned with `repeat-x`.
1056
+ */
1057
+ openSeatView(seat) {
1058
+ if (!this.root || !this.seatViewEnabled()) return;
1059
+ this.closeSeatView();
1060
+ this.closeConfirm();
1061
+ const doc = this.controller.doc;
1062
+ const activeId = this.controller.getActiveFloorId();
1063
+ const focal = doc?.floors?.find((f) => f.id === activeId)?.focalPoint ?? doc?.focalPoint ?? { x: 0, y: 0 };
1064
+ let panoUrl;
1065
+ let caption;
1066
+ let real = false;
1067
+ if (seat.viewUrl) {
1068
+ panoUrl = seat.viewUrl;
1069
+ caption = t2("picker.panorama360");
1070
+ real = true;
1071
+ } else {
1072
+ const pano2 = generateSeatPanorama(seat, focal, this.allSeats());
1073
+ panoUrl = pano2.url;
1074
+ caption = t2("picker.illustrationCaption", { m: pano2.distanceM });
1075
+ }
1076
+ const el = document.createElement("div");
1077
+ el.className = "sl-view";
1078
+ el.setAttribute("role", "dialog");
1079
+ el.setAttribute("aria-label", t2("picker.viewFromSeat", { label: seat.label }));
1080
+ 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>`;
1081
+ this.root.appendChild(el);
1082
+ this.viewEl = el;
1083
+ const pano = el.querySelector(".sl-view-pano");
1084
+ pano.style.backgroundImage = `url("${panoUrl}")`;
1085
+ let zoom = 1.2;
1086
+ let posX = 0;
1087
+ let posY = 0;
1088
+ const apply = () => {
1089
+ const h = pano.clientHeight || 1;
1090
+ const bgH = h * zoom;
1091
+ const overV = Math.max(0, bgH - h);
1092
+ posY = Math.min(overV / 2, Math.max(-overV / 2, posY));
1093
+ pano.style.backgroundSize = `auto ${bgH}px`;
1094
+ pano.style.backgroundPosition = `${posX}px ${posY + overV / 2}px`;
1095
+ };
1096
+ apply();
1097
+ let dragging = false;
1098
+ let lastX = 0;
1099
+ let lastY = 0;
1100
+ const onDown = (e) => {
1101
+ dragging = true;
1102
+ lastX = e.clientX;
1103
+ lastY = e.clientY;
1104
+ pano.classList.add("drag");
1105
+ pano.setPointerCapture?.(e.pointerId);
1106
+ };
1107
+ const onMove = (e) => {
1108
+ if (!dragging) return;
1109
+ posX += e.clientX - lastX;
1110
+ posY += e.clientY - lastY;
1111
+ lastX = e.clientX;
1112
+ lastY = e.clientY;
1113
+ apply();
1114
+ };
1115
+ const onUp = (e) => {
1116
+ dragging = false;
1117
+ pano.classList.remove("drag");
1118
+ pano.releasePointerCapture?.(e.pointerId);
1119
+ };
1120
+ const onWheel = (e) => {
1121
+ e.preventDefault();
1122
+ zoom = Math.min(2.4, Math.max(1, zoom + (e.deltaY < 0 ? 0.12 : -0.12)));
1123
+ apply();
1124
+ };
1125
+ pano.addEventListener("pointerdown", onDown);
1126
+ pano.addEventListener("pointermove", onMove);
1127
+ pano.addEventListener("pointerup", onUp);
1128
+ pano.addEventListener("pointercancel", onUp);
1129
+ pano.addEventListener("wheel", onWheel, { passive: false });
1130
+ const closeBtn = el.querySelector(".sl-view-x");
1131
+ closeBtn.addEventListener("click", () => this.closeSeatView());
1132
+ const onKey = (e) => {
1133
+ if (e.key === "Escape") {
1134
+ e.stopPropagation();
1135
+ this.closeSeatView();
1136
+ }
1137
+ };
1138
+ el.addEventListener("keydown", onKey);
1139
+ closeBtn.focus();
1140
+ this.viewCleanup = () => {
1141
+ pano.removeEventListener("pointerdown", onDown);
1142
+ pano.removeEventListener("pointermove", onMove);
1143
+ pano.removeEventListener("pointerup", onUp);
1144
+ pano.removeEventListener("pointercancel", onUp);
1145
+ pano.removeEventListener("wheel", onWheel);
1146
+ el.removeEventListener("keydown", onKey);
1147
+ };
1148
+ }
1149
+ closeSeatView() {
1150
+ this.viewCleanup?.();
1151
+ this.viewCleanup = null;
1152
+ this.viewEl?.remove();
1153
+ this.viewEl = null;
1154
+ }
857
1155
  // ---- chrome sync ----------------------------------------------------------
858
1156
  money(n) {
859
1157
  try {
@@ -896,15 +1194,20 @@ var SeatPicker = class _SeatPicker {
896
1194
  }
897
1195
  for (const item of heldItems) {
898
1196
  const cat = this.controller.doc?.categories.find((c) => c.key === item.categoryKey);
1197
+ const tierName = item.tierId ? cat?.tiers?.find((ti) => ti.id === item.tierId)?.name : void 0;
1198
+ const canView2 = this.seatViewEnabled() && item.objectType !== "ga" && !!this.controller.seatByLabel(item.label);
899
1199
  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>`
1200
+ `<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
1201
  );
902
1202
  }
903
1203
  const heldLabels = new Set(heldItems.map((item) => item.label));
1204
+ const canView = this.seatViewEnabled();
904
1205
  for (const s of seats.filter((seat) => !heldLabels.has(seat.label))) {
905
1206
  const cat = this.controller.doc?.categories.find((c) => c.key === s.categoryKey);
1207
+ 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>` : "";
1208
+ 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
1209
  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>`
1210
+ `<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
1211
  );
909
1212
  }
910
1213
  for (const area of gaAreas) {
@@ -938,6 +1241,15 @@ var SeatPicker = class _SeatPicker {
938
1241
  this.controller.deselect([id]);
939
1242
  });
940
1243
  });
1244
+ this.els.tray.querySelectorAll(".sl-chip .tier").forEach((sel) => {
1245
+ sel.addEventListener("change", () => this.controller.setSeatTier(sel.dataset.tier, sel.value || null));
1246
+ });
1247
+ this.els.tray.querySelectorAll(".sl-chip .view[data-view-label]").forEach((btn) => {
1248
+ btn.addEventListener("click", () => {
1249
+ const seat = this.controller.seatByLabel(btn.dataset.viewLabel);
1250
+ if (seat) this.openSeatView(seat);
1251
+ });
1252
+ });
941
1253
  this.els.tray.querySelectorAll(".sl-ga button").forEach((btn) => {
942
1254
  btn.addEventListener("click", () => {
943
1255
  const areaEl = btn.closest(".sl-ga");
@@ -965,7 +1277,7 @@ var SeatPicker = class _SeatPicker {
965
1277
  async handleCta() {
966
1278
  const cta = this.els.cta;
967
1279
  if (this.hold && !this.controller.getSelection().some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
968
- this.opts.onCheckout?.(this.hold, this.controller.getSelection());
1280
+ this.opts.onCheckout?.(this.hold, this.hold.seats ?? this.controller.getSelection());
969
1281
  return;
970
1282
  }
971
1283
  cta.disabled = true;
@@ -989,7 +1301,7 @@ var SeatPicker = class _SeatPicker {
989
1301
  }
990
1302
  this.hold = hold;
991
1303
  this.startHoldTimer(hold.expiresAt);
992
- this.opts.onCheckout?.(hold, chosenSeats);
1304
+ this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? []);
993
1305
  } catch (err) {
994
1306
  this.opts.onError?.(err);
995
1307
  this.toast("One or more seats were just taken. Please pick again.");
@@ -1076,6 +1388,7 @@ var SeatPicker = class _SeatPicker {
1076
1388
  destroy() {
1077
1389
  this.destroyed = true;
1078
1390
  this.closeConfirm();
1391
+ this.closeSeatView();
1079
1392
  this.stopHoldTimer();
1080
1393
  if (this.toastTimer) clearTimeout(this.toastTimer);
1081
1394
  this.ro?.disconnect();