@seatlayer/js 0.28.4 → 0.30.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/README.md CHANGED
@@ -1,12 +1,24 @@
1
1
  # @seatlayer/js
2
2
 
3
- The framework-agnostic [SeatLayer](https://seatlayer.io) embed SDK. Render an
4
- interactive seat map, let buyers select and **hold** seats in the browser, then
5
- **book** them from your server. Full docs: <https://docs.seatlayer.io>
3
+ [![npm](https://img.shields.io/npm/v/@seatlayer/js)](https://www.npmjs.com/package/@seatlayer/js)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@seatlayer/js)](https://www.npmjs.com/package/@seatlayer/js)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](../../LICENSE)
6
+
7
+ The framework-agnostic SeatLayer embed SDK. Render the complete buyer picker or
8
+ a headless interactive chart, let buyers select and **hold** inventory in the
9
+ browser, then **book** it from your trusted server.
10
+
11
+ [Package on npm](https://www.npmjs.com/package/@seatlayer/js) ·
12
+ [Install guide](https://docs.seatlayer.io/buyer-sdk/install/) ·
13
+ [Live demo](https://app.seatlayer.io/demo/play) ·
14
+ [Website](https://seatlayer.io/developers/) ·
15
+ [AI Toolkit](https://github.com/seatlayer/seatlayer-ai-toolkit)
6
16
 
7
17
  Works in plain HTML and any framework (React, Vue, Svelte, Angular…). For React,
8
18
  prefer the [`@seatlayer/react`](https://www.npmjs.com/package/@seatlayer/react) wrapper.
9
19
 
20
+ ## Install
21
+
10
22
  ```bash
11
23
  npm install @seatlayer/js
12
24
  ```
@@ -186,5 +198,20 @@ The full `SeatPicker` automatically restores an active hold after same-tab
186
198
  checkout navigation and lets the buyer remove individual held tickets. Set
187
199
  `restoreHold: false` and pass `initialHoldId` when your host owns persistence.
188
200
 
189
- The browser **holds**; your **server books** with a secret key. See the
190
- [integration guide](https://docs.seatlayer.io/getting-started/how-it-works/).
201
+ The browser **holds**; your **server books** with a secret key. See
202
+ [how the integration works](https://docs.seatlayer.io/start/how-it-works/).
203
+
204
+ ## Related resources
205
+
206
+ - [Buyer SDK documentation](https://docs.seatlayer.io/buyer-sdk/install/)
207
+ - [SeatingChart reference](https://docs.seatlayer.io/buyer-sdk/seating-chart/)
208
+ - [Holds and checkout](https://docs.seatlayer.io/buyer-sdk/holds-and-checkout/)
209
+ - [Complete checkout example](https://docs.seatlayer.io/examples/complete-checkout/)
210
+ - [`@seatlayer/react`](https://www.npmjs.com/package/@seatlayer/react)
211
+ - [iOS SDK](https://github.com/seatlayer/seatlayer-ios)
212
+ - [Flutter package](https://pub.dev/packages/seatlayer)
213
+ - [Agent-readable documentation](https://docs.seatlayer.io/llms.txt)
214
+
215
+ ## License
216
+
217
+ MIT © SeatLayer
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -194,6 +204,7 @@ var SeatingChart = class {
194
204
  this.rendered = false;
195
205
  return this;
196
206
  }
207
+ this.controller.setViewMode(this.opts.initialView ?? "flat");
197
208
  this.mode_ = info.mode === "test" ? "test" : "live";
198
209
  if (this.opts.seatTooltip !== false) {
199
210
  const tip = document.createElement("div");
@@ -374,7 +385,14 @@ var SeatingChart = class {
374
385
  /** @internal Throwing variant of {@link bestAvailable} for the native host adapter. See {@link holdOrThrow}. */
375
386
  async bestAvailableOrThrow(qty, categoryKey, options = {}) {
376
387
  const h = await this.controller.bestAvailable(qty, categoryKey, options);
377
- return h ? { holdId: h.holdId, expiresAt: h.expiresAt, labels: h.labels, seats: h.seats, items: h.items } : null;
388
+ return h ? {
389
+ holdId: h.holdId,
390
+ expiresAt: h.expiresAt,
391
+ labels: h.labels,
392
+ seats: h.seats,
393
+ items: h.items,
394
+ ...options.zoneId ? { zoneId: options.zoneId } : {}
395
+ } : null;
378
396
  }
379
397
  /**
380
398
  * Choose a ticket tier for a selected seat (e.g. Adult → Child). The seat's
@@ -406,6 +424,17 @@ var SeatingChart = class {
406
424
  setColorblindSafe(on) {
407
425
  this.controller.setColorblindSafe(on);
408
426
  }
427
+ /** Switch the 2D canvas projection.
428
+ * @deprecated `'isometric'` and `'perspective'` are retired in favour of the
429
+ * real 3D venue view (`setBuyerView('venue3d')`); accepted for source
430
+ * compatibility until the next major. */
431
+ setViewMode(mode) {
432
+ this.controller.setViewMode(mode);
433
+ }
434
+ /** Current canvas projection. */
435
+ getViewMode() {
436
+ return this.controller.getViewMode();
437
+ }
409
438
  /** Zoom in one step (same increment as the wheel/pinch gesture). */
410
439
  zoomIn() {
411
440
  this.controller.zoomIn();
@@ -1074,6 +1103,7 @@ var EmbeddedDesigner = class {
1074
1103
 
1075
1104
  // src/SeatPicker.ts
1076
1105
  var import_core2 = require("@seatlayer/core");
1106
+ var import_meta = {};
1077
1107
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
1078
1108
  var DEFAULT_MAX_SELECTION2 = 10;
1079
1109
  var EXTEND_PROMPT_MS = 6e4;
@@ -1105,6 +1135,41 @@ function escapeOption(value) {
1105
1135
  "'": "&#39;"
1106
1136
  })[character]);
1107
1137
  }
1138
+ var SEATLAYER_MODULE_URL = (() => {
1139
+ if (typeof document !== "undefined" && document.currentScript instanceof HTMLScriptElement && document.currentScript.src) {
1140
+ return document.currentScript.src;
1141
+ }
1142
+ try {
1143
+ const u = import_meta.url;
1144
+ if (typeof u === "string" && u) return u;
1145
+ } catch {
1146
+ }
1147
+ return void 0;
1148
+ })();
1149
+ var _webgl2Cache = null;
1150
+ function hasWebGL2() {
1151
+ if (_webgl2Cache !== null) return _webgl2Cache;
1152
+ try {
1153
+ if (typeof document === "undefined") return _webgl2Cache = false;
1154
+ const canvas = document.createElement("canvas");
1155
+ _webgl2Cache = !!canvas.getContext("webgl2");
1156
+ } catch {
1157
+ _webgl2Cache = false;
1158
+ }
1159
+ return _webgl2Cache;
1160
+ }
1161
+ async function loadVenue3d() {
1162
+ if (typeof __SEATLAYER_CDN__ !== "undefined" && __SEATLAYER_CDN__) {
1163
+ const base = SEATLAYER_MODULE_URL ?? (typeof location !== "undefined" ? location.href : void 0);
1164
+ if (!base) throw new Error("seatlayer: cannot resolve the 3D view chunk URL");
1165
+ const url = new URL("./seatlayer-view3d.mjs", base).href;
1166
+ return import(
1167
+ /* @vite-ignore */
1168
+ url
1169
+ );
1170
+ }
1171
+ return import("@seatlayer/core/view3d");
1172
+ }
1108
1173
  var STYLE_ID = "seatlayer-picker-style";
1109
1174
  var CSS = `
1110
1175
  .sl-picker{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:420px;overflow:hidden;
@@ -1636,6 +1701,45 @@ var CSS = `
1636
1701
  .sl-projection button.on{background:var(--sl-accent);color:var(--sl-accent-ink)}
1637
1702
  .sl-picker[data-layout="narrow"] .sl-projection button{min-width:38px;min-height:32px;padding:5px 8px;font-size:9.5px}
1638
1703
 
1704
+ /* 3D venue overlay \u2014 mounts over the (paused) Konva stage inside the map host.
1705
+ Carries its own gradient so it paints instantly before the scene builds, and
1706
+ cross-fades on enter/exit via a compositor-only opacity transition. Sits below
1707
+ the anchored chrome (z-index:5) and the confirm card (z-index:10) so the
1708
+ Map|3D toggle and the seat confirm both stay usable over it. */
1709
+ .sl-view3d{position:absolute;inset:0;z-index:4;opacity:0;touch-action:none;
1710
+ transition:opacity .3s ease;background:radial-gradient(120% 120% at 50% 0%,#191f28 0%,#0d1014 70%)}
1711
+ .sl-view3d canvas{display:block;width:100%;height:100%}
1712
+ [data-view3d=on] .sl-chips,[data-view3d=on] .sl-rungs{display:none}
1713
+ .sl-view3d-back{position:absolute;top:12px;left:12px;z-index:2;display:inline-flex;align-items:center;gap:6px;
1714
+ padding:7px 13px 7px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.03em;
1715
+ color:#e6edf3;background:rgba(10,14,20,.62);border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(6px)}
1716
+ .sl-view3d-back:hover,.sl-view3d-back:focus-visible{background:rgba(16,22,30,.82);border-color:rgba(255,255,255,.4)}
1717
+ .sl-view3d-back svg{width:15px;height:15px;stroke:currentColor;stroke-width:2.4;fill:none;stroke-linecap:round;stroke-linejoin:round}
1718
+ /* While immersed, the 2D-only chrome is meaningless \u2014 hide it, keep Map|3D. */
1719
+ .sl-picker[data-view3d="on"] .sl-rungs,
1720
+ .sl-picker[data-view3d="on"] .sl-floors,
1721
+ .sl-picker[data-view3d="on"] .sl-zoom,
1722
+ .sl-picker[data-view3d="on"] .sl-seccard,
1723
+ .sl-picker[data-view3d="on"] .sl-minimap{display:none!important}
1724
+ /* The confirm card bottom-sheets over 3D (no 2D screen anchor to track). */
1725
+ .sl-picker[data-view3d="on"] .sl-confirm{left:50%!important;top:auto!important;bottom:16px;
1726
+ transform:translateX(-50%);width:min(342px,calc(100% - 24px))}
1727
+ .sl-picker[data-view3d="on"] .sl-confirm[data-placement]{transform:translateX(-50%)}
1728
+
1729
+ /* Plain "View from here" action shown when no real photo exists (the synthetic
1730
+ thumb is suppressed at card size \u2014 full-screen is where it earns its keep). */
1731
+ .sl-confirm-viewbtn{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
1732
+ display:flex;align-items:center;justify-content:center;gap:7px;font-size:12px;font-weight:800;
1733
+ color:var(--sl-text);background:transparent;transition:border-color .15s,background .15s}
1734
+ .sl-confirm-viewbtn:hover,.sl-confirm-viewbtn:focus-visible{border-color:var(--sl-accent);background:color-mix(in srgb,var(--sl-accent) 10%,transparent)}
1735
+ /* confirm-card "See it in 3D" / "View from this seat" action \u2014 the purchase-
1736
+ moment bridge into the cinematic. Styled like the view-from-seat button. */
1737
+ .sl-confirm-3d{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
1738
+ display:flex;align-items:center;justify-content:center;gap:7px;font-size:12px;font-weight:800;
1739
+ color:var(--sl-text);background:color-mix(in srgb,var(--sl-accent) 12%,transparent);transition:border-color .15s,background .15s}
1740
+ .sl-confirm-3d:hover,.sl-confirm-3d:focus-visible{border-color:var(--sl-accent);background:color-mix(in srgb,var(--sl-accent) 20%,transparent)}
1741
+ .sl-confirm-3d svg{width:15px;height:15px;stroke:currentColor;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}
1742
+
1639
1743
  /* multi-floor switcher (flows within the left-rail region) */
1640
1744
  .sl-floors{display:none;flex-direction:column;gap:6px;max-width:100%}
1641
1745
  .sl-floors.on{display:flex}
@@ -1863,6 +1967,15 @@ var SeatPicker = class _SeatPicker {
1863
1967
  // arena / multi-floor / seat-view chrome
1864
1968
  this.rungsEl = null;
1865
1969
  this.projectionEl = null;
1970
+ // --- 3D venue view (Map | 3D) ---
1971
+ this.buyerView = "map";
1972
+ this.view3dEl = null;
1973
+ this.view3dHandle = null;
1974
+ /** Monotonic token so a stale async mount (buyer left before OGL finished
1975
+ * loading) never installs its handle over a newer state. */
1976
+ this.view3dGen = 0;
1977
+ /** Seat whose 2D confirm card launched "See it in 3D"; re-shown on return. */
1978
+ this.view3dReturnSeat = null;
1866
1979
  this.floorsEl = null;
1867
1980
  this.secCardEl = null;
1868
1981
  this.viewEl = null;
@@ -1913,6 +2026,9 @@ var SeatPicker = class _SeatPicker {
1913
2026
  this.lastAvailFloorId = "";
1914
2027
  this.availQuietUntil = 0;
1915
2028
  this.liveTimer = null;
2029
+ /** `perspective` (2.5D) is retired from the buyer surface — accept it for
2030
+ * source compatibility but coerce to `flat` with a one-time deprecation warn. */
2031
+ this.perspectiveWarned = false;
1916
2032
  if (!options || typeof options !== "object") throw new Error("seatmap: options object is required");
1917
2033
  if (!options.event || typeof options.event !== "string") throw new Error("seatmap: `event` key is required");
1918
2034
  if (!options.container) throw new Error("seatmap: `container` is required (or use SeatPicker.open())");
@@ -1931,12 +2047,14 @@ var SeatPicker = class _SeatPicker {
1931
2047
  onSelectionChange: () => {
1932
2048
  this.syncTray();
1933
2049
  if (this.committedSelection().length) this.collapseSectionCard();
2050
+ this.syncSelectionTo3d();
1934
2051
  },
1935
2052
  onStatusChange: () => {
1936
2053
  this.syncPrices();
1937
2054
  this.evictTakenSelections();
1938
2055
  this.detectBooked();
1939
2056
  this.refreshMinimap();
2057
+ this.pushAvailabilityTo3d();
1940
2058
  },
1941
2059
  onHoldExpired: () => {
1942
2060
  this.hold = null;
@@ -2025,19 +2143,26 @@ var SeatPicker = class _SeatPicker {
2025
2143
  const realPhoto = seat.viewUrl ?? "";
2026
2144
  const hasStage = this.chartHasStage();
2027
2145
  if (!realPhoto && !hasStage) return "";
2028
- let url = realPhoto;
2029
2146
  let distance = null;
2030
- if (!url) {
2147
+ if (!realPhoto) {
2031
2148
  try {
2032
2149
  const thumb = (0, import_core2.generateSeatThumb)(seat, seat.focalPoint ?? doc.focalPoint);
2033
- url = thumb.url;
2034
2150
  distance = thumb.distanceM ?? null;
2035
2151
  } catch {
2036
2152
  return "";
2037
2153
  }
2038
2154
  }
2039
2155
  const sightHtml = hasStage ? `<div class="sl-confirm-sight"><span aria-hidden="true">\u2713</span>${distance != null ? (0, import_core2.t)("picker.sightline", { m: distance }) : this.tf("picker.sightlineClear", "Clear sightline")}</div>` : "";
2040
- return `<button type="button" class="sl-confirm-view sl-confirm-thumbwrap" aria-label="${(0, import_core2.t)("picker.viewFromSeat", { label: seat.label })}"><img class="sl-confirm-thumb" src="${url}" alt="" /><span class="sl-confirm-thumb-badge">\u{1F52D} ${this.tf("picker.viewFromHere", "View from here")}</span></button>` + sightHtml;
2156
+ const viewBtn = realPhoto ? `<button type="button" class="sl-confirm-view sl-confirm-thumbwrap" aria-label="${(0, import_core2.t)("picker.viewFromSeat", { label: seat.label })}"><img class="sl-confirm-thumb" src="${realPhoto}" alt="" /><span class="sl-confirm-thumb-badge">\u{1F52D} ${this.tf("picker.viewFromHere", "View from here")}</span></button>` : `<button type="button" class="sl-confirm-view sl-confirm-viewbtn" aria-label="${(0, import_core2.t)("picker.viewFromSeat", { label: seat.label })}"><span aria-hidden="true">\u{1F52D}</span><span>${this.tf("picker.viewFromHere", "View from here")}</span></button>`;
2157
+ return viewBtn + sightHtml;
2158
+ }
2159
+ /** "See it in 3D" (2D) / "View from this seat" (already in 3D) action for the
2160
+ * confirm card. Only when 3D is available — the purchase-moment bridge into
2161
+ * the cinematic that reaches buyers who never press the Map | 3D toggle. */
2162
+ see3dConfirmHtml() {
2163
+ if (!this.canOffer3d()) return "";
2164
+ const label = this.buyerView === "venue3d" ? this.tf("picker.viewFromThisSeat", "View from this seat") : this.tf("picker.seeItIn3d", "See it in 3D");
2165
+ return `<button type="button" class="sl-confirm-3d" aria-label="${label}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 12l9-5M12 12v10M12 12L3 7"/></svg><span>${label}</span></button>`;
2041
2166
  }
2042
2167
  /** Minimal HTML/attribute escaper for buyer-authored commercial text (notes). */
2043
2168
  escCx(value) {
@@ -2454,7 +2579,7 @@ var SeatPicker = class _SeatPicker {
2454
2579
  }
2455
2580
  this.els.boot.remove();
2456
2581
  this.salesClosed = !!info.salesClosed;
2457
- this.controller.setViewMode(this.opts.initialView ?? "flat");
2582
+ this.controller.setViewMode(this.normalizeInitialView(this.opts.initialView));
2458
2583
  this.buildRegions();
2459
2584
  this.regions["bottom-right"].appendChild(this.els.zoom);
2460
2585
  this.regions["bottom-center"].appendChild(this.els.toast);
@@ -3138,15 +3263,15 @@ var SeatPicker = class _SeatPicker {
3138
3263
  const doc = this.controller.doc;
3139
3264
  if (!doc || !this.els.map) return;
3140
3265
  const hasSections = doc.objects.some((o) => o.type === "section") || (doc.floors ?? []).some((f) => f.objects.some((o) => o.type === "section"));
3141
- if (hasSections) {
3266
+ if (this.canOffer3d()) {
3142
3267
  const projection = document.createElement("div");
3143
3268
  projection.className = "sl-projection";
3144
3269
  projection.setAttribute("role", "group");
3145
- projection.setAttribute("aria-label", "Map projection");
3146
- projection.innerHTML = '<button type="button" data-projection="flat" aria-pressed="false" title="Flat 2D map">2D</button><button type="button" data-projection="perspective" aria-pressed="false" title="Perspective 2.5D map">2.5D</button>';
3270
+ projection.setAttribute("aria-label", "Venue view");
3271
+ projection.innerHTML = '<button type="button" data-view="map" aria-pressed="true" title="Flat 2D map">Map</button><button type="button" data-view="venue3d" aria-pressed="false" title="Interactive 3D venue view">3D</button>';
3147
3272
  projection.querySelectorAll("button").forEach((button) => {
3148
3273
  button.addEventListener("click", () => {
3149
- this.setViewMode(button.dataset.projection);
3274
+ this.setBuyerView(button.dataset.view);
3150
3275
  });
3151
3276
  });
3152
3277
  this.regions["top-right"].appendChild(projection);
@@ -3216,13 +3341,17 @@ var SeatPicker = class _SeatPicker {
3216
3341
  }
3217
3342
  syncProjection() {
3218
3343
  if (!this.projectionEl) return;
3219
- const active = this.controller.getViewMode();
3220
3344
  this.projectionEl.querySelectorAll("button").forEach((button) => {
3221
- const on = button.dataset.projection === active;
3345
+ const on = button.dataset.view === this.buyerView;
3222
3346
  button.classList.toggle("on", on);
3223
3347
  button.setAttribute("aria-pressed", String(on));
3224
3348
  });
3225
3349
  }
3350
+ /** Can this picker offer the 3D venue view? Requires the option (default on)
3351
+ * and WebGL2, and a chart to render. */
3352
+ canOffer3d() {
3353
+ return this.opts.enable3D !== false && hasWebGL2() && !!this.controller.doc;
3354
+ }
3226
3355
  /** Reflect the active floor onto the switcher rail. */
3227
3356
  syncFloors() {
3228
3357
  if (!this.floorsEl) return;
@@ -3527,17 +3656,27 @@ var SeatPicker = class _SeatPicker {
3527
3656
  el.setAttribute("aria-modal", "true");
3528
3657
  el.setAttribute("aria-label", `Confirm seat ${seat.label}`);
3529
3658
  el.style.setProperty("--sl-cat", cat?.color ?? "#6e7bff");
3530
- el.innerHTML = `<div class="sl-confirm-grid">` + identityFields + `</div><div class="sl-confirm-cat"><span class="sl-dot" style="background:${cat?.color ?? "#6e7bff"}"></span><span class="sl-confirm-cat-name">${safe(details?.categoryLabel ?? cat?.label ?? seat.categoryKey)}</span>` + (price != null ? `<span class="sl-confirm-price">${this.money(price)}</span>` : "") + `</div><div class="sl-confirm-body">` + this.wheelchairConfirmHtml(details?.wheelchairSpaceType) + this.commercialConfirmHtml(seat.commercial) + (this.seatViewEnabled() ? this.confirmThumbHtml(seat) : "") + `<div class="sl-confirm-row"><button type="button" class="sl-confirm-cancel">Cancel</button><button type="button" class="sl-confirm-add"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4 4L19 7"/></svg>Select</button></div></div>`;
3659
+ el.innerHTML = `<div class="sl-confirm-grid">` + identityFields + `</div><div class="sl-confirm-cat"><span class="sl-dot" style="background:${cat?.color ?? "#6e7bff"}"></span><span class="sl-confirm-cat-name">${safe(details?.categoryLabel ?? cat?.label ?? seat.categoryKey)}</span>` + (price != null ? `<span class="sl-confirm-price">${this.money(price)}</span>` : "") + `</div><div class="sl-confirm-body">` + this.wheelchairConfirmHtml(details?.wheelchairSpaceType) + this.commercialConfirmHtml(seat.commercial) + (this.seatViewEnabled() ? this.confirmThumbHtml(seat) : "") + this.see3dConfirmHtml() + `<div class="sl-confirm-row"><button type="button" class="sl-confirm-cancel">Cancel</button><button type="button" class="sl-confirm-add"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4 4L19 7"/></svg>Select</button></div></div>`;
3531
3660
  this.els.map.appendChild(el);
3532
3661
  this.confirmEl = el;
3533
3662
  this.reanchorConfirm();
3534
3663
  el.querySelector(".sl-confirm-view")?.addEventListener("click", () => this.openSeatView(seat));
3664
+ el.querySelector(".sl-confirm-3d")?.addEventListener("click", () => {
3665
+ if (this.buyerView === "venue3d") {
3666
+ void this.view3dHandle?.flyToSeat(seat.id);
3667
+ } else {
3668
+ this.view3dReturnSeat = seat;
3669
+ this.dismissConfirm();
3670
+ void this.enter3d(seat.id);
3671
+ }
3672
+ });
3535
3673
  el.querySelector(".sl-confirm-add").addEventListener("click", () => this.commitConfirm());
3536
3674
  el.querySelector(".sl-confirm-cancel").addEventListener("click", () => this.cancelConfirm());
3537
3675
  requestAnimationFrame(() => el.querySelector(".sl-confirm-add")?.focus());
3538
3676
  }
3539
3677
  reanchorConfirm() {
3540
3678
  if (!this.confirmEl || !this.confirmSeat) return;
3679
+ if (this.root?.dataset.view3d === "on") return;
3541
3680
  const p = this.controller.worldToScreen({ x: this.confirmSeat.x, y: this.confirmSeat.y });
3542
3681
  if (this.root?.dataset.layout === "narrow") return;
3543
3682
  const mapWidth = this.els.map.clientWidth;
@@ -3620,16 +3759,21 @@ var SeatPicker = class _SeatPicker {
3620
3759
  this.viewEl = el;
3621
3760
  const pano = el.querySelector(".sl-view-pano");
3622
3761
  pano.style.backgroundImage = `url("${panoUrl}")`;
3623
- let zoom = 1.2;
3624
- let posX = 0;
3762
+ const VFOV_DEG = 70;
3763
+ const MAX_PITCH_DEG = 35;
3764
+ let zoom = 1;
3765
+ const vh0 = pano.clientHeight || 1;
3766
+ const vw0 = pano.clientWidth || 1;
3767
+ let posX = -(vh0 * (180 / VFOV_DEG) * 2 / 2 - vw0 / 2);
3625
3768
  let posY = 0;
3626
3769
  const apply = () => {
3627
3770
  const h = pano.clientHeight || 1;
3628
- const bgH = h * zoom;
3771
+ const bgH = h * (180 / VFOV_DEG) * zoom;
3629
3772
  const overV = Math.max(0, bgH - h);
3630
- posY = Math.min(overV / 2, Math.max(-overV / 2, posY));
3773
+ const pitchLimit = Math.min(overV / 2, MAX_PITCH_DEG / 180 * bgH);
3774
+ posY = Math.min(pitchLimit, Math.max(-pitchLimit, posY));
3631
3775
  pano.style.backgroundSize = `auto ${bgH}px`;
3632
- pano.style.backgroundPosition = `${posX}px ${posY + overV / 2}px`;
3776
+ pano.style.backgroundPosition = `${posX}px ${posY - overV / 2}px`;
3633
3777
  };
3634
3778
  apply();
3635
3779
  let dragging = false;
@@ -4384,9 +4528,10 @@ var SeatPicker = class _SeatPicker {
4384
4528
  this.controller.setColorblindSafe(on);
4385
4529
  writeStoredColorblind(on);
4386
4530
  }
4387
- /** Switch the buyer canvas between flat, isometric and Perspective 2.5D. */
4531
+ /** Switch the underlying 2D renderer projection (flat / isometric). The buyer
4532
+ * UI no longer exposes `perspective`; it is coerced to `flat`. */
4388
4533
  setViewMode(mode) {
4389
- this.controller.setViewMode(mode);
4534
+ this.controller.setViewMode(this.normalizeInitialView(mode));
4390
4535
  this.syncProjection();
4391
4536
  this.dismissConfirm();
4392
4537
  }
@@ -4394,6 +4539,208 @@ var SeatPicker = class _SeatPicker {
4394
4539
  getViewMode() {
4395
4540
  return this.controller.getViewMode();
4396
4541
  }
4542
+ normalizeInitialView(mode) {
4543
+ if (mode === "perspective") {
4544
+ if (!this.perspectiveWarned) {
4545
+ this.perspectiveWarned = true;
4546
+ console.warn(
4547
+ "[seatlayer] initialView:'perspective' (2.5D) is deprecated for the buyer picker and was coerced to 'flat'. Use the Map | 3D control for the immersive view."
4548
+ );
4549
+ }
4550
+ return "flat";
4551
+ }
4552
+ return mode ?? "flat";
4553
+ }
4554
+ // ---- 3D venue view ---------------------------------------------------------
4555
+ /** Current buyer view: the flat map, or the interactive 3D venue. */
4556
+ getBuyerView() {
4557
+ return this.buyerView;
4558
+ }
4559
+ /**
4560
+ * Switch between the flat seat **Map** and the interactive **3D venue** view —
4561
+ * the same control the buyer's on-widget `Map | 3D` toggle drives.
4562
+ *
4563
+ * Entering `'venue3d'` with `opts.flyToSeatId` runs the cinematic tour: the
4564
+ * camera flies to the seat and holds in the live scene (a chip offers the
4565
+ * 360° view-from-seat) —
4566
+ * the widget enters 3D and auto-flies the camera to that seat, dissolving into
4567
+ * its view-from-seat panorama (the same chain as tapping "See the view from
4568
+ * here" on a seat's confirm card). When the widget is **already** in the 3D
4569
+ * view, the camera simply flies to the requested seat — the GL scene is not
4570
+ * torn down or rebuilt, so there is no flash or re-entry.
4571
+ *
4572
+ * No-op when the view is unchanged and no `flyToSeatId` is given, or when 3D is
4573
+ * unavailable for this chart.
4574
+ *
4575
+ * @param view `'map'` for the flat picker, `'venue3d'` for the 3D venue.
4576
+ * @param opts.flyToSeatId When entering (or already in) `'venue3d'`, the seat
4577
+ * id to fly the camera to — cinematic → panorama.
4578
+ * Ignored when `view` is `'map'`.
4579
+ *
4580
+ * @example
4581
+ * // Public 3D tour entry — enter 3D and fly straight to the buyer's seat:
4582
+ * picker.setBuyerView('venue3d', { flyToSeatId: 'A-12' });
4583
+ */
4584
+ setBuyerView(view, opts) {
4585
+ if (view === "map") {
4586
+ this.exit3d();
4587
+ return;
4588
+ }
4589
+ const flyToSeatId = opts?.flyToSeatId;
4590
+ if (this.buyerView === "venue3d") {
4591
+ if (flyToSeatId) void this.view3dHandle?.flyToSeat(flyToSeatId);
4592
+ return;
4593
+ }
4594
+ void this.enter3d(flyToSeatId);
4595
+ }
4596
+ /** SeatStatus → the view3d palette state. Selection is layered separately. */
4597
+ seatState3dFor(id) {
4598
+ switch (this.controller.getStatus(id)) {
4599
+ case "held":
4600
+ return "held";
4601
+ case "booked":
4602
+ return "sold";
4603
+ case "not_for_sale":
4604
+ return "dimmed";
4605
+ default:
4606
+ return "available";
4607
+ }
4608
+ }
4609
+ /** Push the full live availability snapshot into the 3D handle (selection is
4610
+ * preserved inside the module). Cheap enough per status delta. */
4611
+ pushAvailabilityTo3d() {
4612
+ if (!this.view3dHandle) return;
4613
+ const updates = this.allSeats().map((s) => ({ seatId: s.id, state: this.seatState3dFor(s.id) }));
4614
+ this.view3dHandle.setAvailability(updates);
4615
+ }
4616
+ /** Mirror the authoritative widget selection into the 3D handle. */
4617
+ syncSelectionTo3d() {
4618
+ if (!this.view3dHandle) return;
4619
+ this.view3dHandle.setSelection(this.controller.getSelection().map((s) => s.id));
4620
+ }
4621
+ /** Build the view-from-seat panorama the cinematic dissolves into — reuses the
4622
+ * exact input path as the 2D `openSeatView` (organizer photo, else generated). */
4623
+ seatViewFor3d(seatId) {
4624
+ const seat = this.allSeats().find((s) => s.id === seatId);
4625
+ if (!seat) return null;
4626
+ if (seat.viewUrl) return { url: seat.viewUrl };
4627
+ const doc = this.controller.doc;
4628
+ if (!doc) return null;
4629
+ const activeId = this.controller.getActiveFloorId();
4630
+ const focal = seat.focalPoint ?? doc.floors?.find((f) => f.id === activeId)?.focalPoint ?? doc.focalPoint ?? { x: 0, y: 0 };
4631
+ try {
4632
+ return { url: (0, import_core2.generateSeatPanorama)(seat, focal, this.allSeats()).url };
4633
+ } catch {
4634
+ return null;
4635
+ }
4636
+ }
4637
+ /** Route the module's decoupled analytics into the host callback, tagged buyer. */
4638
+ emit3dAnalytics(event, props) {
4639
+ try {
4640
+ this.opts.onAnalytics?.(event, { ...props, surface: "buyer" });
4641
+ } catch {
4642
+ }
4643
+ }
4644
+ /** A 3D seat tap runs the SAME selection path as a 2D tap: toggle through the
4645
+ * controller, then raise the shared confirm card (bottom-sheeted in 3D). */
4646
+ onView3dSeatPick(seatId) {
4647
+ if (this.salesClosed) {
4648
+ this.toast(this.tf("picker.salesClosedToast", "Sales are closed for this event."), "warning");
4649
+ this.syncSelectionTo3d();
4650
+ return;
4651
+ }
4652
+ const seat = this.allSeats().find((s) => s.id === seatId);
4653
+ if (!seat) return;
4654
+ const already = this.committedSelection().some((s) => s.id === seatId);
4655
+ if (already) {
4656
+ this.controller.deselect([seatId]);
4657
+ this.dismissConfirm();
4658
+ return;
4659
+ }
4660
+ this.flashPickedSeat(seatId);
4661
+ this.controller.select([seatId]);
4662
+ if (this.opts.confirmSelection !== false) this.showConfirm(seat);
4663
+ else this.syncTray();
4664
+ }
4665
+ async enter3d(flySeatId) {
4666
+ if (this.view3dEl || !this.canOffer3d() || !this.els.map) return;
4667
+ const doc = this.controller.doc;
4668
+ if (!doc) return;
4669
+ this.buyerView = "venue3d";
4670
+ this.root?.setAttribute("data-view3d", "on");
4671
+ this.dismissConfirm();
4672
+ this.syncProjection();
4673
+ const overlay = document.createElement("div");
4674
+ overlay.className = "sl-view3d";
4675
+ overlay.setAttribute("role", "group");
4676
+ overlay.setAttribute("aria-label", "Interactive 3D venue view");
4677
+ const back = document.createElement("button");
4678
+ back.type = "button";
4679
+ back.className = "sl-view3d-back";
4680
+ back.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg><span>${this.tf("picker.backToMap", "Back to map")}</span>`;
4681
+ back.addEventListener("click", () => this.exit3d());
4682
+ overlay.appendChild(back);
4683
+ this.els.map.appendChild(overlay);
4684
+ this.view3dEl = overlay;
4685
+ requestAnimationFrame(() => {
4686
+ overlay.style.opacity = "1";
4687
+ });
4688
+ const gen = ++this.view3dGen;
4689
+ try {
4690
+ const seats = (0, import_core2.expandChart)(doc);
4691
+ const mod = await loadVenue3d();
4692
+ if (gen !== this.view3dGen || this.buyerView !== "venue3d" || this.view3dEl !== overlay) return;
4693
+ const handle = mod.mountVenue3D(overlay, { doc, seats }, {
4694
+ onSeatPick: (id) => this.onView3dSeatPick(id),
4695
+ // Deferred off the tap gesture: generateSeatPanorama walks every seat
4696
+ // (O(n) on a 13k chart), and the module prefetches at pick — by the
4697
+ // time the flight lands (~2.5s) the idle render has long finished. A
4698
+ // null view REJECTS so the module's no-panorama path keeps the buyer
4699
+ // in orbit instead of dissolving into an empty overlay.
4700
+ getSeatView: (id) => new Promise((resolve, reject) => {
4701
+ const run = () => {
4702
+ const view = this.seatViewFor3d(id);
4703
+ if (view) resolve(view);
4704
+ else reject(new Error("seat_view_unavailable"));
4705
+ };
4706
+ const ric = globalThis.requestIdleCallback;
4707
+ if (typeof ric === "function") ric(run, { timeout: 1500 });
4708
+ else setTimeout(run, 50);
4709
+ }),
4710
+ onAnalytics: (event, props) => this.emit3dAnalytics(event, props)
4711
+ });
4712
+ this.view3dHandle = handle;
4713
+ this.pushAvailabilityTo3d();
4714
+ this.syncSelectionTo3d();
4715
+ if (flySeatId) void handle.flyToSeat(flySeatId);
4716
+ } catch (err) {
4717
+ this.opts.onError?.(err);
4718
+ this.exit3d();
4719
+ }
4720
+ }
4721
+ exit3d() {
4722
+ if (this.buyerView !== "venue3d" && !this.view3dEl) return;
4723
+ this.view3dGen++;
4724
+ this.buyerView = "map";
4725
+ this.root?.removeAttribute("data-view3d");
4726
+ try {
4727
+ this.view3dHandle?.dispose();
4728
+ } catch {
4729
+ }
4730
+ this.view3dHandle = null;
4731
+ const overlay = this.view3dEl;
4732
+ this.view3dEl = null;
4733
+ if (overlay) {
4734
+ overlay.style.opacity = "0";
4735
+ setTimeout(() => overlay.remove(), 320);
4736
+ }
4737
+ this.syncProjection();
4738
+ const seat = this.view3dReturnSeat;
4739
+ this.view3dReturnSeat = null;
4740
+ if (seat && this.committedSelection().some((s) => s.id === seat.id) && this.opts.confirmSelection !== false) {
4741
+ this.showConfirm(seat);
4742
+ }
4743
+ }
4397
4744
  /** Current active/restored hold reflected in the tray. */
4398
4745
  getCurrentHold() {
4399
4746
  return this.hold;
@@ -4485,6 +4832,7 @@ var SeatPicker = class _SeatPicker {
4485
4832
  this.closeConfirm();
4486
4833
  this.dismissTableDialog(false);
4487
4834
  this.closeSeatView();
4835
+ this.exit3d();
4488
4836
  this.stopHoldTimer();
4489
4837
  if (this.toastTimer) clearTimeout(this.toastTimer);
4490
4838
  if (this.liveTimer) clearTimeout(this.liveTimer);
@@ -6136,7 +6484,7 @@ var SeatManager = class {
6136
6484
  const category = this.doc?.categories.find((item) => item.key === seat.categoryKey);
6137
6485
  const sectionMetric = this.controlRoomSnapshot?.revenue.bySection.find((row) => row.sectionId === sectionId);
6138
6486
  const object = this.doc?.objects.find((item) => item.id === seat.rowId);
6139
- const location = object?.type === "row" ? { label: "Row", value: object.label } : object?.type === "table" ? { label: "Table", value: object.label } : seat.kind === "booth" ? { label: "Type", value: "Booth" } : null;
6487
+ const location2 = object?.type === "row" ? { label: "Row", value: object.label } : object?.type === "table" ? { label: "Table", value: object.label } : seat.kind === "booth" ? { label: "Type", value: "Booth" } : null;
6140
6488
  const itemKind = seat.kind === "booth" ? "Booth" : "Seat";
6141
6489
  this.els.rail.innerHTML = `
6142
6490
  <p class="slm-eyebrow">${itemKind} details</p>
@@ -6146,7 +6494,7 @@ var SeatManager = class {
6146
6494
  <div class="slm-inspect-grid">
6147
6495
  <div><span>Status</span><b>${statusLabel[status]}</b></div>
6148
6496
  <div><span>Section</span><b>${esc(sectionLabel)}</b></div>
6149
- ${location ? `<div><span>${location.label}</span><b>${esc(location.value)}</b></div>` : ""}
6497
+ ${location2 ? `<div><span>${location2.label}</span><b>${esc(location2.value)}</b></div>` : ""}
6150
6498
  <div><span>Category</span><b>${esc(category?.label ?? seat.categoryKey)}</b></div>
6151
6499
  <div><span>Sold in section</span><b>${sectionMetric ? `${sectionMetric.booked} of ${sectionMetric.total}` : "\u2014"}</b></div>
6152
6500
  <div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>