@seatlayer/js 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -366,10 +366,30 @@ var EmbeddedDesigner = class {
366
366
  this.timeoutTimer = null;
367
367
  this.phase = "loading";
368
368
  this.restoreContainerPosition = null;
369
+ // Host-side fullscreen pin: saved state we restore on `off`/Escape/destroy.
370
+ this.pinned = false;
371
+ this.frameStyleBeforeFs = null;
372
+ this.docOverflowBeforeFs = null;
373
+ this.bodyOverflowBeforeFs = null;
374
+ this.fsKeyHandler = null;
375
+ /** Latest height (px string) the Designer reported; re-applied after unpin. */
376
+ this.lastAutoHeight = "";
369
377
  this.handleMessage = (event) => {
370
378
  if (!this.frame || event.origin !== this.designerOrigin || event.source !== this.frame.contentWindow) return;
371
379
  if (!event.data || typeof event.data !== "object") return;
372
380
  const data = event.data;
381
+ if (data.type === "seatlayer.designer.resize") {
382
+ if (this.autoResizeEnabled() && typeof data.px === "number" && Number.isFinite(data.px) && data.px > 0) {
383
+ this.lastAutoHeight = `${Math.round(data.px)}px`;
384
+ if (!this.pinned) this.frame.style.height = this.lastAutoHeight;
385
+ }
386
+ return;
387
+ }
388
+ if (data.type === "seatlayer.designer.fullscreen") {
389
+ if (data.on === true) this.pinFullscreen();
390
+ else if (data.on === false) this.unpinFullscreen();
391
+ return;
392
+ }
373
393
  if (typeof data.type !== "string" || !TYPES.has(data.type)) return;
374
394
  const message = {
375
395
  type: data.type,
@@ -452,6 +472,7 @@ var EmbeddedDesigner = class {
452
472
  }
453
473
  destroy() {
454
474
  window.removeEventListener("message", this.handleMessage);
475
+ this.unpinFullscreen();
455
476
  this.clearTimeoutTimer();
456
477
  this.removeOverlay();
457
478
  this.restoreContainerStyle();
@@ -459,10 +480,68 @@ var EmbeddedDesigner = class {
459
480
  this.frame = null;
460
481
  this.designerOrigin = "";
461
482
  this.phase = "loading";
483
+ this.lastAutoHeight = "";
462
484
  }
463
485
  loadingStateEnabled() {
464
486
  return this.options.showLoadingState !== false;
465
487
  }
488
+ autoResizeEnabled() {
489
+ return this.options.autoResize !== false;
490
+ }
491
+ /**
492
+ * Pin the iframe over the host page as a viewport-filling overlay. We save the
493
+ * iframe's inline style and the document scroll state so `unpinFullscreen`
494
+ * restores everything exactly. Escape (host-side) also exits.
495
+ */
496
+ pinFullscreen() {
497
+ if (this.pinned || !this.frame) return;
498
+ this.pinned = true;
499
+ this.frameStyleBeforeFs = this.frame.getAttribute("style");
500
+ Object.assign(this.frame.style, {
501
+ position: "fixed",
502
+ inset: "0",
503
+ width: "100vw",
504
+ height: "100vh",
505
+ margin: "0",
506
+ border: "0",
507
+ zIndex: "2147483000",
508
+ background: "#101625"
509
+ });
510
+ const docEl = document.documentElement;
511
+ this.docOverflowBeforeFs = docEl.style.overflow;
512
+ docEl.style.overflow = "hidden";
513
+ if (document.body) {
514
+ this.bodyOverflowBeforeFs = document.body.style.overflow;
515
+ document.body.style.overflow = "hidden";
516
+ }
517
+ this.fsKeyHandler = (event) => {
518
+ if (event.key === "Escape") this.unpinFullscreen();
519
+ };
520
+ window.addEventListener("keydown", this.fsKeyHandler);
521
+ }
522
+ /** Undo `pinFullscreen`: restore the iframe style + scroll lock. Idempotent. */
523
+ unpinFullscreen() {
524
+ if (!this.pinned) return;
525
+ this.pinned = false;
526
+ if (this.frame) {
527
+ if (this.frameStyleBeforeFs === null) this.frame.removeAttribute("style");
528
+ else this.frame.setAttribute("style", this.frameStyleBeforeFs);
529
+ if (this.autoResizeEnabled() && this.lastAutoHeight) this.frame.style.height = this.lastAutoHeight;
530
+ }
531
+ this.frameStyleBeforeFs = null;
532
+ if (this.docOverflowBeforeFs !== null) {
533
+ document.documentElement.style.overflow = this.docOverflowBeforeFs;
534
+ this.docOverflowBeforeFs = null;
535
+ }
536
+ if (this.bodyOverflowBeforeFs !== null && document.body) {
537
+ document.body.style.overflow = this.bodyOverflowBeforeFs;
538
+ this.bodyOverflowBeforeFs = null;
539
+ }
540
+ if (this.fsKeyHandler) {
541
+ window.removeEventListener("keydown", this.fsKeyHandler);
542
+ this.fsKeyHandler = null;
543
+ }
544
+ }
466
545
  clearTimeoutTimer() {
467
546
  if (this.timeoutTimer !== null) {
468
547
  clearTimeout(this.timeoutTimer);
@@ -662,6 +741,7 @@ import {
662
741
  PickerController as PickerController2,
663
742
  expandChart,
664
743
  generateSeatPanorama,
744
+ generateSeatThumb,
665
745
  loadLocale as loadLocale2,
666
746
  setStringOverrides as setStringOverrides2,
667
747
  t as t2,
@@ -826,26 +906,43 @@ var CSS = `
826
906
  .sl-tray{flex:1;padding:10px 14px 14px;display:flex;flex-direction:column;gap:7px;min-height:0;overflow-y:auto;
827
907
  overscroll-behavior:contain;scrollbar-gutter:stable}
828
908
  .sl-tray-hint{font-size:12.5px;color:var(--sl-muted);line-height:1.5}
829
- .sl-chip{position:relative;display:grid;grid-template-columns:24px minmax(0,1fr) auto 30px;align-items:center;gap:8px;
830
- min-height:53px;padding:7px 7px 7px 9px;border:1px solid var(--sl-line);border-radius:var(--sl-r-sm);
909
+ .sl-chip{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 34px;align-items:stretch;
910
+ flex:none;min-height:53px;border:1px solid var(--sl-line);border-radius:var(--sl-r-sm);overflow:hidden;
831
911
  background:var(--sl-surface);font-size:13px;transform-origin:center;transition:border-color .15s,background .15s}
832
912
  .sl-chip:hover{border-color:color-mix(in srgb,var(--sl-accent) 38%,var(--sl-line))}
833
913
  .sl-chip.sl-enter{animation:slChipIn .38s cubic-bezier(.2,.8,.2,1) both}
834
914
  .sl-chip.sl-leave{pointer-events:none;animation:slChipOut .16s ease-in both}
835
915
  .sl-chip.sl-held{border-color:var(--sl-line);background:color-mix(in srgb,var(--sl-accent) 7%,var(--sl-surface));
836
916
  box-shadow:inset 3px 0 0 color-mix(in srgb,var(--sl-accent) 72%,transparent)}
837
- .sl-ticket-state{width:23px;height:23px;border-radius:999px;display:flex;align-items:center;justify-content:center;
917
+ .sl-ticket-state{width:17px;height:17px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
838
918
  background:var(--sl-accent);color:var(--sl-accent-ink)}
839
919
  .sl-ticket-state.held{background:color-mix(in srgb,var(--sl-accent) 18%,var(--sl-surface));color:var(--sl-accent)}
840
- .sl-ticket-state svg{width:13px;height:13px;stroke:currentColor;stroke-width:2.4;fill:none;stroke-linecap:round;stroke-linejoin:round}
841
- .sl-chip-main{min-width:0}
842
- .sl-chip b{display:block;font-weight:800;min-width:max-content;white-space:nowrap}
843
- .sl-chip-sub{display:flex;align-items:center;gap:5px;min-width:0;margin-top:2px}
920
+ .sl-ticket-state svg{width:10px;height:10px;stroke:currentColor;stroke-width:2.6;fill:none;stroke-linecap:round;stroke-linejoin:round}
921
+ .sl-chip-main{min-width:0;padding:8px 10px 8px 11px;display:flex;flex-direction:column;justify-content:center;gap:5px}
922
+ .sl-chip-id{display:flex;gap:12px;min-width:0}
923
+ .sl-chip-id .fld{min-width:0}
924
+ .sl-chip-id .fld.sec{flex:1}
925
+ .sl-chip-id .fld.mid{flex:none;text-align:center}
926
+ .sl-chip-eb{display:block;font-size:8px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--sl-muted);margin-bottom:1px}
927
+ .sl-chip-id .val{display:block;font-weight:600;font-size:13px;line-height:1.25;white-space:nowrap}
928
+ .sl-chip-id .fld.sec .val{overflow:hidden;text-overflow:ellipsis}
929
+ .sl-chip-sub{display:flex;align-items:center;gap:6px;min-width:0}
844
930
  .sl-chip .cat{color:var(--sl-muted);font-size:10.5px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
845
931
  .sl-chip .amt{font-weight:700;font-variant-numeric:tabular-nums;flex:none;white-space:nowrap}
846
- .sl-chip .rm{width:29px;height:29px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center;color:var(--sl-muted)}
847
- .sl-chip .rm:hover,.sl-chip .rm:focus-visible{color:var(--sl-accent);background:color-mix(in srgb,var(--sl-accent) 10%,transparent)}
932
+ .sl-chip-rail{display:flex;flex-direction:column;border-left:1px solid var(--sl-line)}
933
+ .sl-chip .rm,.sl-chip .view{flex:1;min-height:26px;border-radius:0;display:flex;align-items:center;justify-content:center;
934
+ color:var(--sl-muted);transition:color .15s,background .15s}
935
+ .sl-chip .view{border-top:1px solid var(--sl-line)}
936
+ .sl-chip .rm:hover,.sl-chip .rm:focus-visible{color:#e5484d;background:color-mix(in srgb,#e5484d 9%,transparent)}
937
+ .sl-chip .view:hover,.sl-chip .view:focus-visible{color:var(--sl-text);background:color-mix(in srgb,var(--sl-accent) 10%,transparent)}
848
938
  .sl-chip .rm svg{width:11px;height:11px;stroke:currentColor;stroke-width:2.4;fill:none;stroke-linecap:round}
939
+ .sl-chip .view svg{width:13px;height:13px;stroke:currentColor;stroke-width:1.8;fill:none}
940
+ /* live-activity strip \u2014 narrates WS availability deltas (social proof + urgency) */
941
+ .sl-live{display:flex;align-items:center;gap:7px;margin:10px 14px 0;padding:7px 9px;flex:none;
942
+ border:1px solid var(--sl-line);border-radius:8px;background:color-mix(in srgb,var(--sl-accent) 4%,var(--sl-surface));
943
+ font-size:11px;color:var(--sl-muted)}
944
+ .sl-live .dot{width:6px;height:6px;border-radius:999px;background:#22a06b;box-shadow:0 0 6px rgba(34,160,107,.75);flex:none}
945
+ .sl-live span:last-child{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
849
946
 
850
947
  /* GA rows */
851
948
  .sl-ga{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px dashed var(--sl-line);border-radius:var(--sl-r-sm)}
@@ -921,6 +1018,8 @@ var CSS = `
921
1018
 
922
1019
  /* zoom column (flows within the bottom-right region) */
923
1020
  .sl-zoom{display:flex;flex-direction:column;gap:6px}
1021
+ /* CSS-fallback full screen (iOS Safari has no element fullscreen API) */
1022
+ .sl-picker.sl-fs{position:fixed;inset:0;z-index:2147483000;width:auto;height:auto;max-height:none;border-radius:0}
924
1023
  .sl-zoom button{width:36px;height:36px;border-radius:999px;background:var(--sl-surface);border:1px solid var(--sl-line);
925
1024
  color:var(--sl-text);font-size:17px;font-weight:700;display:flex;align-items:center;justify-content:center;transition:border-color .15s}
926
1025
  .sl-zoom button:hover{border-color:var(--sl-muted)}
@@ -979,6 +1078,39 @@ var CSS = `
979
1078
  .sl-booked.on .sl-booked-title{animation-delay:.22s}
980
1079
  .sl-booked.on .sl-booked-sub{animation-delay:.3s}
981
1080
 
1081
+ /* sold-out overlay \u2014 every SEATED category's live availability is 0. Centered
1082
+ over the map; a stub (disabled) "Join waitlist" button, exactly like the page.
1083
+ Suppressed when GA areas exist (GA capacity isn't seat-counted). Clears live
1084
+ the moment WS frees a seat up. */
1085
+ .sl-soldout{position:absolute;inset:0;z-index:10;display:none;flex-direction:column;align-items:center;
1086
+ justify-content:center;text-align:center;gap:8px;padding:24px;
1087
+ background:color-mix(in srgb,var(--sl-bg) 82%,transparent);backdrop-filter:blur(4px)}
1088
+ .sl-soldout.on{display:flex}
1089
+ .sl-soldout-eyebrow{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--sl-accent);font-weight:800}
1090
+ .sl-soldout-title{font-size:32px;font-weight:800;color:var(--sl-text);line-height:1.05}
1091
+ .sl-soldout-copy{max-width:360px;font-size:13px;color:var(--sl-muted);line-height:1.5}
1092
+ .sl-picker .sl-soldout-btn{margin-top:10px;min-height:40px;padding:10px 18px;border-radius:var(--sl-r-sm);
1093
+ background:var(--sl-surface);color:var(--sl-muted);border:1px solid var(--sl-line);font-weight:800;font-size:13px;
1094
+ cursor:not-allowed;opacity:.85}
1095
+
1096
+ /* sales-closed pill (header) \u2014 persistent read-only state when the event's sales
1097
+ window is closed at load or closes live mid-session. Neutral (not accent) so it
1098
+ reads as "unavailable", distinct from the accent hold pill next to it. */
1099
+ .sl-closed-pill{display:none;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;flex:none;
1100
+ background:color-mix(in srgb,var(--sl-text) 12%,var(--sl-surface));color:var(--sl-text);
1101
+ font-weight:700;font-size:12px;white-space:nowrap}
1102
+ .sl-closed-pill.on{display:inline-flex}
1103
+ .sl-closed-pill svg{width:13px;height:13px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
1104
+
1105
+ /* "Powered by SeatLayer" attribution badge (side-panel foot) \u2014 the small gold
1106
+ rounded logo mark + wordmark. Hidden when the host opts out or the org's paid
1107
+ theme sets hideBadge. */
1108
+ .sl-powered{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:10px;
1109
+ font-size:11px;letter-spacing:.03em;color:var(--sl-muted)}
1110
+ .sl-powered-mark{width:16px;height:16px;border-radius:4px;flex:none;display:flex;align-items:center;justify-content:center;
1111
+ background:var(--sl-accent);color:var(--sl-accent-ink)}
1112
+ .sl-powered-mark svg{width:11px;height:11px;fill:currentColor}
1113
+
982
1114
  /* a11y filter chips (flow within the top-left region) */
983
1115
  .sl-chips{display:flex;gap:6px;flex-wrap:wrap}
984
1116
  .sl-chip-f{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;font-size:12px;font-weight:700;
@@ -1017,8 +1149,30 @@ var CSS = `
1017
1149
  .sl-picker[data-layout="narrow"] .sl-confirm{left:50%!important;top:auto!important;bottom:14px;width:min(342px,calc(100% - 24px));
1018
1150
  transform:translateX(-50%);animation:slConfirmMobileIn .24s cubic-bezier(.2,.8,.2,1) both}
1019
1151
 
1152
+ /* hover preview \u2014 a COMPACT echo of the confirm card (deliberately smaller: it's
1153
+ a passing preview on hover, not the click/select action surface). Reuses the
1154
+ Section\xB7Row\xB7Seat identity grid so hover, confirm and the cart chip all share
1155
+ one visual language, just at three sizes. */
1156
+ .sl-tip{position:absolute;z-index:7;pointer-events:none;display:none;width:190px;overflow:hidden;
1157
+ background:var(--sl-surface);color:var(--sl-text);border:1px solid var(--sl-line);border-radius:11px;
1158
+ box-shadow:0 12px 30px -14px rgba(0,0,0,.6)}
1159
+ .sl-tip-grid{display:grid;grid-template-columns:1.3fr .85fr .85fr;border-bottom:1px solid var(--sl-line)}
1160
+ .sl-tip-grid.one{grid-template-columns:1fr}
1161
+ .sl-tip-field{min-width:0;padding:6px 9px;border-right:1px solid var(--sl-line)}
1162
+ .sl-tip-field:last-child{border-right:0;text-align:center}
1163
+ .sl-tip-grid:not(.one) .sl-tip-field:nth-child(2){text-align:center}
1164
+ .sl-tip-key{display:block;font-size:7.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--sl-muted);font-weight:800}
1165
+ .sl-tip-val{display:block;margin-top:2px;color:var(--sl-text);font-size:13px;line-height:1.1;font-weight:750;
1166
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1167
+ .sl-tip-cat{display:flex;align-items:center;gap:7px;padding:6px 10px;font-size:11px;
1168
+ background:color-mix(in srgb,var(--sl-cat) 12%,var(--sl-surface))}
1169
+ .sl-tip-dot{width:8px;height:8px;border-radius:50%;flex:none}
1170
+ .sl-tip-name{color:var(--sl-muted);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1171
+ .sl-tip-amt{margin-left:auto;font-weight:800;color:var(--sl-text);font-variant-numeric:tabular-nums;font-size:12px}
1172
+ .sl-tip-status{padding:5px 10px 7px;font-size:8.5px;letter-spacing:.09em;text-transform:uppercase;font-weight:700;color:var(--sl-muted)}
1173
+
1020
1174
  /* Best available is a first-class shortcut, not an anonymous utility row. */
1021
- .sl-ba{position:relative;overflow:hidden;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:7px;
1175
+ .sl-ba{position:relative;flex:none;overflow:hidden;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:7px;
1022
1176
  padding:13px;border:1px solid color-mix(in srgb,var(--sl-accent) 34%,var(--sl-line));border-radius:13px;
1023
1177
  background:linear-gradient(135deg,color-mix(in srgb,var(--sl-accent) 5%,var(--sl-surface)),color-mix(in srgb,var(--sl-accent) 11%,var(--sl-surface)))}
1024
1178
  .sl-ba::after{content:'\u2726';position:absolute;right:10px;top:3px;color:color-mix(in srgb,var(--sl-accent) 20%,transparent);font-size:42px;line-height:1}
@@ -1054,12 +1208,6 @@ var CSS = `
1054
1208
  /* per-seat ticket-tier select + view-from-seat button in tray chips */
1055
1209
  .sl-chip .tier{background:var(--sl-bg);color:var(--sl-text);border:1px solid var(--sl-line);border-radius:6px;
1056
1210
  font:inherit;font-size:10px;padding:2px 4px;min-width:0;max-width:100%;cursor:pointer}
1057
- .sl-chip .view{width:20px;height:20px;border-radius:999px;flex:none;display:flex;align-items:center;justify-content:center;
1058
- color:var(--sl-muted);opacity:.36;transition:color .15s,opacity .15s}
1059
- .sl-chip .view:hover{color:var(--sl-text)}
1060
- .sl-chip:hover .view,.sl-chip .view:focus-visible{opacity:1;color:var(--sl-text)}
1061
- .sl-chip .view svg{width:12px;height:12px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
1062
- @media(pointer:coarse){.sl-chip .view{opacity:.58}}
1063
1211
 
1064
1212
  /* arena: LOD rung pills (flow within the top-center region) */
1065
1213
  .sl-rungs{display:none;background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
@@ -1117,6 +1265,14 @@ var CSS = `
1117
1265
  .sl-seccard-hint{font-size:10.5px;color:var(--sl-muted)}
1118
1266
 
1119
1267
  /* view-from-seat button on the confirm popover */
1268
+ /* Eager sightline preview inside the confirm card */
1269
+ .sl-confirm-thumbwrap{position:relative;display:block;width:100%;height:74px;margin:0 0 8px;padding:0!important;
1270
+ border-radius:9px;overflow:hidden;border:1px solid var(--sl-line);cursor:pointer}
1271
+ .sl-confirm-thumb{display:block;width:100%;height:100%;object-fit:cover}
1272
+ .sl-confirm-thumb-badge{position:absolute;right:7px;top:7px;display:inline-flex;align-items:center;gap:5px;
1273
+ font-size:10px;font-weight:700;color:#fff;background:rgba(10,14,22,0.72);border-radius:12px;padding:4px 9px;backdrop-filter:blur(3px)}
1274
+ .sl-confirm-sight{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--sl-muted);margin-bottom:2px}
1275
+ .sl-confirm-sight span{color:#22a06b;font-weight:800}
1120
1276
  .sl-confirm-view{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
1121
1277
  color:var(--sl-text);font-weight:700;font-size:12px;display:flex;align-items:center;justify-content:center;gap:7px}
1122
1278
  .sl-confirm-view:hover{border-color:var(--sl-muted)}
@@ -1200,6 +1356,22 @@ function resolveTokens(chart, host) {
1200
1356
  "--sl-radius": `${host?.radius ?? 14}px`
1201
1357
  };
1202
1358
  }
1359
+ var CB_STORAGE_KEY = "seatmap.a11y.cb";
1360
+ function readStoredColorblind() {
1361
+ try {
1362
+ if (typeof window === "undefined") return null;
1363
+ const raw = window.localStorage.getItem(CB_STORAGE_KEY);
1364
+ return raw == null ? null : raw === "1";
1365
+ } catch {
1366
+ return null;
1367
+ }
1368
+ }
1369
+ function writeStoredColorblind(on) {
1370
+ try {
1371
+ window.localStorage.setItem(CB_STORAGE_KEY, on ? "1" : "0");
1372
+ } catch {
1373
+ }
1374
+ }
1203
1375
  var SeatPicker = class _SeatPicker {
1204
1376
  constructor(options) {
1205
1377
  this.root = null;
@@ -1232,11 +1404,17 @@ var SeatPicker = class _SeatPicker {
1232
1404
  this.confirmEl = null;
1233
1405
  this.confirmSeat = null;
1234
1406
  this.srEl = null;
1235
- this.a11yFilter = "all";
1236
1407
  this.baQty = 2;
1237
1408
  this.baCat = "";
1238
1409
  this.bestAvailableConfirm = false;
1239
1410
  this.releasingHold = false;
1411
+ /** Event sales window is closed (read-only load state / live close). */
1412
+ this.salesClosed = false;
1413
+ /** Every seated category's live availability is 0 (sold-out overlay is up). */
1414
+ this.soldOut = false;
1415
+ this.soldoutEl = null;
1416
+ /** Resolved colorblind-safe state — stored preference wins over the option. */
1417
+ this.cbSafe = false;
1240
1418
  // arena / multi-floor / seat-view chrome
1241
1419
  this.rungsEl = null;
1242
1420
  this.floorsEl = null;
@@ -1245,13 +1423,11 @@ var SeatPicker = class _SeatPicker {
1245
1423
  this.viewCleanup = null;
1246
1424
  this.allSeatsCache = null;
1247
1425
  // F3 minimap
1248
- this.miniEl = null;
1249
1426
  this.miniCanvas = null;
1250
1427
  this.miniBase = null;
1251
1428
  this.miniTf = null;
1252
1429
  // F4 price-band filter — active band's category keys (null = all prices)
1253
1430
  this.priceBandKeys = null;
1254
- this.priceFilterEl = null;
1255
1431
  /** Last surfaced section summary (re-rendered when the price band changes). */
1256
1432
  this.lastSection = null;
1257
1433
  /** Section card collapsed to its slim pill (seat-picking has begun). */
@@ -1271,6 +1447,13 @@ var SeatPicker = class _SeatPicker {
1271
1447
  this.ctaPhase = "idle";
1272
1448
  // narrow-layout chrome that docks into the sheet's Filters row on mobile
1273
1449
  this.a11yChipsEl = null;
1450
+ this.fsFallback = false;
1451
+ this.fsChangeHandler = null;
1452
+ this.fsEscHandler = null;
1453
+ /** True once we've asked the host page to pin us fullscreen (framed, no native). */
1454
+ this.framedFs = false;
1455
+ /** Last height (px) posted to a host frame; dedupes redundant reports. */
1456
+ this.lastPostedHeight = 0;
1274
1457
  this.cbEl = null;
1275
1458
  // modal plumbing (set by open())
1276
1459
  this.modalScrim = null;
@@ -1278,20 +1461,22 @@ var SeatPicker = class _SeatPicker {
1278
1461
  this.escHandler = null;
1279
1462
  /** Set by open(): closes the modal (scroll restore + destroy + onClose). */
1280
1463
  this.closeModal = null;
1464
+ this.lastCatAvail = null;
1281
1465
  if (!options || typeof options !== "object") throw new Error("seatmap: options object is required");
1282
1466
  if (!options.event || typeof options.event !== "string") throw new Error("seatmap: `event` key is required");
1283
1467
  if (!options.container) throw new Error("seatmap: `container` is required (or use SeatPicker.open())");
1284
1468
  this.opts = { ...options, confirmSelection: options.confirmSelection ?? true };
1285
1469
  this.apiBase = (options.apiBase ?? DEFAULT_API_BASE2).replace(/\/+$/, "");
1286
- this.api = new PubApi(this.apiBase);
1470
+ this.api = options.transport ?? new PubApi(this.apiBase);
1287
1471
  this.maxTickets = Math.max(1, Math.floor(options.maxSelection ?? DEFAULT_MAX_SELECTION2));
1472
+ this.cbSafe = readStoredColorblind() ?? !!options.colorblindSafe;
1288
1473
  this.controller = new PickerController2({
1289
1474
  transport: this.api,
1290
1475
  eventKey: options.event,
1291
1476
  maxSelection: this.maxTickets,
1292
1477
  currency: options.currency,
1293
1478
  flashOnLiveChange: true,
1294
- colorblindSafe: options.colorblindSafe,
1479
+ colorblindSafe: this.cbSafe,
1295
1480
  onSelectionChange: () => {
1296
1481
  this.syncTray();
1297
1482
  if (this.committedSelection().length) this.collapseSectionCard();
@@ -1317,6 +1502,11 @@ var SeatPicker = class _SeatPicker {
1317
1502
  },
1318
1503
  confirmSelection: this.opts.confirmSelection,
1319
1504
  onSelect: (seat) => {
1505
+ if (this.salesClosed) {
1506
+ this.controller.deselect([seat.id]);
1507
+ this.toast(this.tf("picker.salesClosedToast", "Sales are closed for this event."), "warning");
1508
+ return;
1509
+ }
1320
1510
  this.flashPickedSeat(seat.id);
1321
1511
  if (this.opts.confirmSelection) this.showConfirm(seat);
1322
1512
  },
@@ -1339,9 +1529,142 @@ var SeatPicker = class _SeatPicker {
1339
1529
  onHint: (m) => {
1340
1530
  if (m) this.toast(m);
1341
1531
  },
1532
+ // Server declared the event closed mid-session (409 event_closed) — keep
1533
+ // the toast (raised by handleCta), and add the persistent read-only state.
1534
+ onSalesClosed: () => this.setSalesClosed(true),
1342
1535
  onError: (err) => this.opts.onError?.(err)
1343
1536
  });
1344
1537
  }
1538
+ /**
1539
+ * Eager sightline preview for the confirm card: a cheap generated forward
1540
+ * view (or the organizer's real photo) plus a "Nm to stage · clear
1541
+ * sightline" line — the premium at-a-glance moment; click opens the 360.
1542
+ */
1543
+ confirmThumbHtml(seat) {
1544
+ const doc = this.controller.doc;
1545
+ if (!doc) return "";
1546
+ let url = seat.viewUrl ?? "";
1547
+ let distance = null;
1548
+ if (!url) {
1549
+ try {
1550
+ const thumb = generateSeatThumb(seat, doc.focalPoint);
1551
+ url = thumb.url;
1552
+ distance = thumb.distanceM ?? null;
1553
+ } catch {
1554
+ return "";
1555
+ }
1556
+ }
1557
+ const sight = distance != null ? `${distance}${this.tf("picker.sightline", "m to stage \xB7 clear sightline")}` : this.tf("picker.sightlineClear", "Clear sightline");
1558
+ return `<button type="button" class="sl-confirm-view sl-confirm-thumbwrap" aria-label="${t2("picker.viewFromSeat", { label: seat.label })}"><img class="sl-confirm-thumb" src="${url}" alt="" /><span class="sl-confirm-thumb-badge">\u{1F52D} ${this.tf("picker.viewFromHere", "View from here")}</span></button><div class="sl-confirm-sight"><span aria-hidden="true">\u2713</span>${sight}</div>`;
1559
+ }
1560
+ /** True when the picker is rendered inside an iframe (snippet embed at /e/:key). */
1561
+ isFramed() {
1562
+ return typeof window !== "undefined" && window.parent !== window;
1563
+ }
1564
+ /**
1565
+ * Post a widget→host message when framed. targetOrigin is '*' because the
1566
+ * payload carries nothing sensitive (a height number / a fullscreen flag);
1567
+ * hosts verify `event.origin` on their side (see `attachPickerFrame`).
1568
+ */
1569
+ postToHost(message) {
1570
+ if (!this.isFramed()) return;
1571
+ try {
1572
+ window.parent.postMessage(message, "*");
1573
+ } catch {
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Height (px) to advertise to a host frame.
1578
+ *
1579
+ * The picker fills whatever box it's given: `.sl-picker` is `height:100%;
1580
+ * overflow:hidden`, and the /e/:key shell mounts it `position:fixed; inset:0`.
1581
+ * So it has no intrinsic *document* height to read — `scrollHeight` just
1582
+ * collapses to the current viewport, which for a framed embed would echo the
1583
+ * host's own iframe height straight back (a circular value). We therefore
1584
+ * report a width-driven *desired* height: a pleasant landscape box on desktop,
1585
+ * taller on narrow widths where the bottom sheet needs room, clamped to the
1586
+ * widget's `min-height` of 420. Width is host-controlled and never moves in
1587
+ * response to the height we report, so this cannot feedback-loop.
1588
+ */
1589
+ measureFramedHeight() {
1590
+ const root = this.root;
1591
+ if (!root) return 0;
1592
+ const width = root.clientWidth || (typeof window !== "undefined" ? window.innerWidth : 0) || 0;
1593
+ if (width <= 0) return 0;
1594
+ const ratio = width < 640 ? 1.2 : 0.62;
1595
+ return Math.max(420, Math.round(width * ratio));
1596
+ }
1597
+ /** Post `seatlayer:height` to the host when framed and the value changed. */
1598
+ reportFramedHeight() {
1599
+ if (!this.isFramed()) return;
1600
+ const px = this.measureFramedHeight();
1601
+ if (px <= 0 || px === this.lastPostedHeight) return;
1602
+ this.lastPostedHeight = px;
1603
+ this.postToHost({ type: "seatlayer:height", px });
1604
+ }
1605
+ /** Full screen via the native API, falling back to a fixed-position overlay (iOS Safari). */
1606
+ toggleFullscreen() {
1607
+ const root = this.root;
1608
+ if (!root) return;
1609
+ const active = !!document.fullscreenElement || this.fsFallback || this.framedFs;
1610
+ if (!active) {
1611
+ if (root.requestFullscreen) {
1612
+ root.requestFullscreen().catch(() => this.enterFsFallback());
1613
+ } else {
1614
+ this.enterFsFallback();
1615
+ }
1616
+ } else if (document.fullscreenElement) {
1617
+ void document.exitFullscreen().catch(() => {
1618
+ });
1619
+ } else if (this.framedFs) {
1620
+ this.setFramedFs(false);
1621
+ } else {
1622
+ this.setFsFallback(false);
1623
+ }
1624
+ }
1625
+ /**
1626
+ * Native element-fullscreen was unavailable or rejected. When framed, a CSS
1627
+ * `.sl-fs` overlay can't escape the iframe, so we ask the host page to pin us
1628
+ * (`seatlayer:fullscreen`). Otherwise (iOS Safari, same document) fall back to
1629
+ * the `.sl-fs` overlay as before.
1630
+ */
1631
+ enterFsFallback() {
1632
+ if (this.isFramed()) this.setFramedFs(true);
1633
+ else this.setFsFallback(true);
1634
+ }
1635
+ /** Toggle host-driven (framed) fullscreen: post the flag + own the Esc key. */
1636
+ setFramedFs(on) {
1637
+ if (this.framedFs === on) return;
1638
+ this.framedFs = on;
1639
+ this.els.zfs?.setAttribute("aria-pressed", String(on || !!document.fullscreenElement));
1640
+ this.postToHost({ type: "seatlayer:fullscreen", on });
1641
+ if (on && !this.fsEscHandler) {
1642
+ this.fsEscHandler = (e) => {
1643
+ if (e.key === "Escape" && !document.fullscreenElement) this.setFramedFs(false);
1644
+ };
1645
+ window.addEventListener("keydown", this.fsEscHandler);
1646
+ } else if (!on && this.fsEscHandler) {
1647
+ window.removeEventListener("keydown", this.fsEscHandler);
1648
+ this.fsEscHandler = null;
1649
+ }
1650
+ requestAnimationFrame(() => this.controller.zoomToFit());
1651
+ }
1652
+ setFsFallback(on) {
1653
+ if (this.fsFallback === on) return;
1654
+ this.fsFallback = on;
1655
+ this.root?.classList.toggle("sl-fs", on);
1656
+ this.els.zfs?.setAttribute("aria-pressed", String(on || !!document.fullscreenElement));
1657
+ if (on && !this.fsEscHandler) {
1658
+ this.fsEscHandler = (e) => {
1659
+ if (e.key === "Escape" && !document.fullscreenElement) this.setFsFallback(false);
1660
+ };
1661
+ window.addEventListener("keydown", this.fsEscHandler);
1662
+ } else if (!on && this.fsEscHandler) {
1663
+ window.removeEventListener("keydown", this.fsEscHandler);
1664
+ this.fsEscHandler = null;
1665
+ }
1666
+ requestAnimationFrame(() => this.controller.zoomToFit());
1667
+ }
1345
1668
  /**
1346
1669
  * Close the picker. In modal mode (SeatPicker.open()) this dismisses the
1347
1670
  * modal exactly like ESC/scrim/✕ — restores page scroll and fires onClose.
@@ -1436,6 +1759,10 @@ var SeatPicker = class _SeatPicker {
1436
1759
  <div class="sl-head-meta" data-ref="meta"></div>
1437
1760
  </div>
1438
1761
  <span class="sl-hold-pill" data-ref="hold"></span>
1762
+ <span class="sl-closed-pill" data-ref="closedPill" role="status">
1763
+ <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
1764
+ <span data-ref="closedPillText"></span>
1765
+ </span>
1439
1766
  <button type="button" class="sl-close" data-ref="close" aria-label="Close">
1440
1767
  <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>
1441
1768
  </button>
@@ -1449,6 +1776,9 @@ var SeatPicker = class _SeatPicker {
1449
1776
  <button type="button" aria-label="Fit to screen" data-ref="zfit">
1450
1777
  <svg viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
1451
1778
  </button>
1779
+ <button type="button" aria-label="Full screen" aria-pressed="false" data-ref="zfs">
1780
+ <svg viewBox="0 0 24 24"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
1781
+ </button>
1452
1782
  </div>
1453
1783
  <div class="sl-boot" data-ref="boot"><span class="sl-boot-spin"></span>Loading seat map\u2026</div>
1454
1784
  <div class="sl-toast" data-ref="toast" role="status" aria-live="polite"></div>
@@ -1467,6 +1797,7 @@ var SeatPicker = class _SeatPicker {
1467
1797
  <div class="sl-filters" data-ref="filters"></div>
1468
1798
  <div class="sl-sec sl-prices-sec" data-ref="pricesSec"><span>Ticket prices</span></div>
1469
1799
  <div class="sl-prices" data-ref="prices"></div>
1800
+ <div class="sl-live" data-ref="live" role="status" aria-live="polite"><span class="dot" aria-hidden="true"></span><span data-ref="liveText">Live availability \u2014 seats update in real time</span></div>
1470
1801
  <div class="sl-sec sl-seats-sec"><span>Your seats</span><span class="sl-seat-summary" data-ref="seatSummary"></span></div>
1471
1802
  <div class="sl-tray" data-ref="tray"></div>
1472
1803
  <div class="sl-foot" data-ref="foot">
@@ -1487,6 +1818,7 @@ var SeatPicker = class _SeatPicker {
1487
1818
  const applyLayout = () => {
1488
1819
  const w = root.clientWidth;
1489
1820
  if (w <= 0) return;
1821
+ this.reportFramedHeight();
1490
1822
  const next = w < 640 ? "narrow" : "wide";
1491
1823
  if (root.dataset.layout === next) return;
1492
1824
  root.dataset.layout = next;
@@ -1500,6 +1832,13 @@ var SeatPicker = class _SeatPicker {
1500
1832
  this.els.zin.addEventListener("click", () => this.controller.zoomIn());
1501
1833
  this.els.zout.addEventListener("click", () => this.controller.zoomOut());
1502
1834
  this.els.zfit.addEventListener("click", () => this.controller.zoomToFit());
1835
+ this.els.zfs.addEventListener("click", () => this.toggleFullscreen());
1836
+ this.fsChangeHandler = () => {
1837
+ if (!document.fullscreenElement) this.setFsFallback(false);
1838
+ this.els.zfs?.setAttribute("aria-pressed", String(!!document.fullscreenElement || this.fsFallback || this.framedFs));
1839
+ requestAnimationFrame(() => this.controller.zoomToFit());
1840
+ };
1841
+ document.addEventListener("fullscreenchange", this.fsChangeHandler);
1503
1842
  const head = this.els.sheetHead;
1504
1843
  if (head) {
1505
1844
  const toggle = this.els.sheetToggle;
@@ -1543,7 +1882,7 @@ var SeatPicker = class _SeatPicker {
1543
1882
  }
1544
1883
  this.tipEl = document.createElement("div");
1545
1884
  this.tipEl.setAttribute("role", "tooltip");
1546
- this.tipEl.style.cssText = "position:absolute;z-index:7;pointer-events:none;display:none;max-width:240px;background:var(--sl-surface);color:var(--sl-text);border:1px solid var(--sl-line);border-radius:10px;padding:9px 12px;font-size:12px;line-height:1.45;";
1885
+ this.tipEl.className = "sl-tip";
1547
1886
  this.els.map.appendChild(this.tipEl);
1548
1887
  this.els.map.addEventListener("mousemove", (e) => {
1549
1888
  const r = this.els.map.getBoundingClientRect();
@@ -1568,6 +1907,7 @@ var SeatPicker = class _SeatPicker {
1568
1907
  return this;
1569
1908
  }
1570
1909
  this.els.boot.remove();
1910
+ this.salesClosed = !!info.salesClosed;
1571
1911
  this.buildRegions();
1572
1912
  this.regions["bottom-right"].appendChild(this.els.zoom);
1573
1913
  this.regions["bottom-center"].appendChild(this.els.toast);
@@ -1587,6 +1927,7 @@ var SeatPicker = class _SeatPicker {
1587
1927
  this.els.name.textContent = info.eventName ?? "";
1588
1928
  const when = info.startsAt ? new Date(info.startsAt).toLocaleString(this.opts.locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "";
1589
1929
  this.els.meta.textContent = [info.venue, when].filter(Boolean).join(" \xB7 ");
1930
+ this.buildBadge(chartTheme);
1590
1931
  const present = /* @__PURE__ */ new Set();
1591
1932
  if (this.controller.doc) {
1592
1933
  for (const seat of expandChart(this.controller.doc)) {
@@ -1602,12 +1943,29 @@ var SeatPicker = class _SeatPicker {
1602
1943
  chips.innerHTML = mk("all", "All seats") + [...present].map((type) => mk(type, `${GLYPH[type] ? GLYPH[type] + " " : ""}${type[0].toUpperCase()}${type.slice(1).replace(/-/g, " ")}`)).join("");
1603
1944
  this.regions["top-left"].appendChild(chips);
1604
1945
  this.a11yChipsEl = chips;
1946
+ const active = /* @__PURE__ */ new Set();
1947
+ const syncChips = () => {
1948
+ chips.querySelectorAll("button").forEach((b) => {
1949
+ const f = b.dataset.f;
1950
+ const on = f === "all" ? active.size === 0 : active.has(f);
1951
+ b.classList.toggle("on", on);
1952
+ b.setAttribute("aria-pressed", String(on));
1953
+ });
1954
+ const filter = active.size ? [...active] : null;
1955
+ this.controller.setAccessibilityFilter(filter);
1956
+ if (filter && this.rungsEl && this.controller.getRung() !== "seats") {
1957
+ this.controller.setRung("seats");
1958
+ this.collapseSectionCard();
1959
+ this.syncRung();
1960
+ }
1961
+ };
1605
1962
  chips.querySelectorAll("button").forEach((btn) => {
1606
1963
  btn.addEventListener("click", () => {
1607
1964
  const f = btn.dataset.f;
1608
- this.a11yFilter = f;
1609
- chips.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn));
1610
- this.controller.setAccessibilityFilter(f === "all" ? null : [f]);
1965
+ if (f === "all") active.clear();
1966
+ else if (active.has(f)) active.delete(f);
1967
+ else active.add(f);
1968
+ syncChips();
1611
1969
  });
1612
1970
  });
1613
1971
  }
@@ -1616,14 +1974,14 @@ var SeatPicker = class _SeatPicker {
1616
1974
  cb.className = "sl-cbbtn";
1617
1975
  this.cbEl = cb;
1618
1976
  cb.setAttribute("aria-label", "Toggle colorblind-friendly colors");
1619
- cb.setAttribute("aria-pressed", String(!!this.opts.colorblindSafe));
1977
+ cb.setAttribute("aria-pressed", String(this.cbSafe));
1620
1978
  cb.innerHTML = '<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>';
1621
1979
  this.els.zfit.parentElement.appendChild(cb);
1622
- let cbOn = !!this.opts.colorblindSafe;
1623
1980
  cb.addEventListener("click", () => {
1624
- cbOn = !cbOn;
1625
- cb.setAttribute("aria-pressed", String(cbOn));
1626
- this.controller.setColorblindSafe(cbOn);
1981
+ this.cbSafe = !this.cbSafe;
1982
+ cb.setAttribute("aria-pressed", String(this.cbSafe));
1983
+ this.controller.setColorblindSafe(this.cbSafe);
1984
+ writeStoredColorblind(this.cbSafe);
1627
1985
  });
1628
1986
  this.srEl = document.createElement("div");
1629
1987
  this.srEl.className = "sl-sr";
@@ -1634,9 +1992,11 @@ var SeatPicker = class _SeatPicker {
1634
1992
  this.buildPriceFilter();
1635
1993
  this.buildExtendPrompt();
1636
1994
  this.buildBookedOverlay();
1995
+ this.buildSoldoutOverlay();
1637
1996
  this.dockLayoutChrome();
1638
1997
  await this.restoreRememberedHold();
1639
1998
  if (this.destroyed) return this;
1999
+ if (this.salesClosed) this.applySalesClosed();
1640
2000
  this.syncPrices();
1641
2001
  this.syncTray();
1642
2002
  return this;
@@ -1688,6 +2048,85 @@ var SeatPicker = class _SeatPicker {
1688
2048
  this.bookedEl = el;
1689
2049
  this.els.bookedSub = el.querySelector('[data-ref="bookedSub"]');
1690
2050
  }
2051
+ /**
2052
+ * Localized string with a literal fallback. `t()` returns the key itself for
2053
+ * unknown keys, so this collapses that to `fallback` — while still honoring a
2054
+ * host `messages` override (which makes `t()` return the override, not the key).
2055
+ */
2056
+ tf(key, fallback) {
2057
+ const v = t2(key);
2058
+ return v === key ? fallback : v;
2059
+ }
2060
+ /** Sold-out overlay — centered over the map, disabled waitlist stub (Gap 2). */
2061
+ buildSoldoutOverlay() {
2062
+ if (!this.els.map) return;
2063
+ const el = document.createElement("div");
2064
+ el.className = "sl-soldout";
2065
+ el.setAttribute("role", "status");
2066
+ const name = (this.controller.doc?.theme?.brandName ?? this.opts.theme?.brandName ?? this.els.name?.textContent ?? this.tf("picker.soldOutEyebrow", "This event")).toUpperCase();
2067
+ el.innerHTML = `<div class="sl-soldout-eyebrow">${name}</div><div class="sl-soldout-title">${this.tf("picker.soldOutTitle", "Sold out")}</div><p class="sl-soldout-copy">${this.tf("picker.soldOutCopy", "Every seat is gone. Join the waitlist and we\u2019ll email you if seats are released.")}</p><button type="button" class="sl-soldout-btn" disabled>${this.tf("picker.waitlist", "Join waitlist")}</button>`;
2068
+ this.els.map.appendChild(el);
2069
+ this.soldoutEl = el;
2070
+ }
2071
+ /**
2072
+ * Recompute the sold-out state on every price/availability sync. Sold-out ⇔
2073
+ * every SEATED category's live free count is 0. Suppressed when the chart has
2074
+ * GA areas (GA capacity isn't per-seat, so seated counts would read 0 and
2075
+ * falsely block standing room) — mirrors the public page. Clears live when WS
2076
+ * frees a seat up.
2077
+ */
2078
+ syncSoldout(categories, left) {
2079
+ const hasGA = this.controller.getGAAreas().length > 0;
2080
+ const soldOut = this.isSoldOut(categories, left, hasGA);
2081
+ if (soldOut === this.soldOut) return;
2082
+ this.soldOut = soldOut;
2083
+ this.soldoutEl?.classList.toggle("on", soldOut);
2084
+ }
2085
+ /**
2086
+ * Pure sold-out predicate: every SEATED category's free count is 0, there is at
2087
+ * least one seated category, and there are no GA areas (GA capacity isn't
2088
+ * per-seat, so seated counts read 0 and would falsely block standing room).
2089
+ * `left` is seeded implicitly — a missing key means a fully-booked tier (0 free).
2090
+ */
2091
+ isSoldOut(categories, left, hasGA) {
2092
+ return !hasGA && categories.length > 0 && categories.every((c) => (left[c.key] ?? 0) === 0);
2093
+ }
2094
+ /**
2095
+ * Sales-closed read-only state (Gap 3): persistent header pill, disabled CTA
2096
+ * with a closed label, and frozen best-available / GA controls. `setSalesClosed`
2097
+ * is the reactive entry (live 409 event_closed); `applySalesClosed` is the
2098
+ * idempotent DOM apply used at load and on transition.
2099
+ */
2100
+ setSalesClosed(closed) {
2101
+ if (this.salesClosed === closed) return;
2102
+ this.salesClosed = closed;
2103
+ this.applySalesClosed();
2104
+ }
2105
+ applySalesClosed() {
2106
+ const pill = this.els.closedPill;
2107
+ if (pill) {
2108
+ pill.classList.toggle("on", this.salesClosed);
2109
+ const text = this.els.closedPillText ?? pill;
2110
+ text.textContent = this.tf("picker.salesClosedPill", "Sales are closed");
2111
+ }
2112
+ this.root?.setAttribute("data-sales-closed", String(this.salesClosed));
2113
+ this.syncCta();
2114
+ this.syncTray();
2115
+ }
2116
+ /** The badge is hidden when the host opts out OR the org's theme sets hideBadge. */
2117
+ badgeHidden(chartTheme) {
2118
+ return !!(this.opts.hideBadge || chartTheme?.hideBadge);
2119
+ }
2120
+ /** Attribution badge in the side-panel foot (Gap 7). Hidden per host/theme. */
2121
+ buildBadge(chartTheme) {
2122
+ if (this.badgeHidden(chartTheme)) return;
2123
+ const foot = this.els.foot;
2124
+ if (!foot) return;
2125
+ const el = document.createElement("div");
2126
+ el.className = "sl-powered";
2127
+ el.innerHTML = `<span class="sl-powered-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 15c0-1.1.9-2 2-2h12a2 2 0 0 1 2 2v3h-3v-2H7v2H4v-3Z"/><rect x="7" y="7" width="10" height="5" rx="1.6"/></svg></span><span>${this.tf("picker.poweredBy", "Powered by SeatLayer")}</span>`;
2128
+ foot.appendChild(el);
2129
+ }
1691
2130
  // ---- Feature 6: chrome anchor regions -------------------------------------
1692
2131
  /**
1693
2132
  * Create the positioned flex containers that own every persistent map overlay.
@@ -1800,13 +2239,18 @@ var SeatPicker = class _SeatPicker {
1800
2239
  heldGA.set(item.objectId, (heldGA.get(item.objectId) ?? 0) + (item.quantity ?? 1));
1801
2240
  }
1802
2241
  return gaAreas.reduce(
1803
- (sum, area) => sum + area.price * Math.max(0, (this.gaQty.get(area.id) ?? 0) - (heldGA.get(area.id) ?? 0)),
2242
+ (sum, area) => sum + this.paidPrice(area.categoryKey, null, area.price) * Math.max(0, (this.gaQty.get(area.id) ?? 0) - (heldGA.get(area.id) ?? 0)),
1804
2243
  0
1805
2244
  );
1806
2245
  }
1807
2246
  syncCta(count = this.lastTrayCount, pending = this.pendingSelectionCount()) {
1808
2247
  const cta = this.els.cta;
1809
2248
  if (!cta) return;
2249
+ if (this.salesClosed) {
2250
+ cta.disabled = true;
2251
+ cta.textContent = this.tf("picker.salesClosedCta", "Sales closed");
2252
+ return;
2253
+ }
1810
2254
  if (this.confirmSeat) {
1811
2255
  cta.disabled = true;
1812
2256
  cta.textContent = "Confirm or cancel this seat";
@@ -1942,7 +2386,6 @@ var SeatPicker = class _SeatPicker {
1942
2386
  canvas.style.height = `${h}px`;
1943
2387
  wrap.appendChild(canvas);
1944
2388
  (this.regions["bottom-left"] ?? this.els.map).appendChild(wrap);
1945
- this.miniEl = wrap;
1946
2389
  this.miniCanvas = canvas;
1947
2390
  const scale = Math.min((w - PAD * 2) / Math.max(1, b.width), (h - PAD * 2) / Math.max(1, b.height)) * dpr;
1948
2391
  const offX = (w * dpr - b.width * scale) / 2 - b.x * scale;
@@ -2055,9 +2498,11 @@ var SeatPicker = class _SeatPicker {
2055
2498
  this.controller.overview();
2056
2499
  }
2057
2500
  // ---- F4 price-band filter -------------------------------------------------
2058
- /** Effective price of a category (first tier when tiered, else base price). */
2501
+ /** Effective display price of a category: host pricing override → first tier base. */
2059
2502
  catPrice(c) {
2060
- return c.tiers?.length ? c.tiers[0].price : c.price;
2503
+ const chart = c.tiers?.length ? c.tiers[0].price : c.price;
2504
+ if (chart === void 0 || !c.key) return chart;
2505
+ return this.paidPrice(c.key, c.tiers?.[0]?.id ?? null, chart);
2061
2506
  }
2062
2507
  /** Derive price bands: one chip per distinct price (≤5), else quantile ranges. */
2063
2508
  priceBands() {
@@ -2102,7 +2547,6 @@ var SeatPicker = class _SeatPicker {
2102
2547
  select.setAttribute("aria-label", "Filter and focus seats by price");
2103
2548
  select.innerHTML = `<option value="all">All prices</option>` + bands.map((band) => `<option value="${band.id}">${band.label}</option>`).join("");
2104
2549
  this.els.pricesSec.appendChild(select);
2105
- this.priceFilterEl = select;
2106
2550
  select.addEventListener("change", () => {
2107
2551
  const band = bands.find((candidate) => candidate.id === select.value);
2108
2552
  const keys = band?.keys ?? null;
@@ -2206,7 +2650,10 @@ var SeatPicker = class _SeatPicker {
2206
2650
  renderSectionCard(summary) {
2207
2651
  if (!this.els.map) return;
2208
2652
  this.secCardEl?.remove();
2209
- const priceLabel = summary.priceMin === summary.priceMax ? this.money(summary.priceMin) : `${this.money(summary.priceMin)}\u2013${this.money(summary.priceMax)}`;
2653
+ const paid = summary.categories.length ? summary.categories.map((c) => this.paidPrice(c.key, null, c.price)) : [summary.priceMin, summary.priceMax];
2654
+ const paidMin = Math.min(...paid);
2655
+ const paidMax = Math.max(...paid);
2656
+ const priceLabel = paidMin === paidMax ? this.money(paidMin) : `${this.money(paidMin)}\u2013${this.money(paidMax)}`;
2210
2657
  const leftLabel = tCount("picker.seatsLeftInSection", summary.seatsLeft);
2211
2658
  const xBtn = `<button type="button" class="sl-seccard-x" aria-label="${t2("picker.closeSectionSummary")}">\u2715</button>`;
2212
2659
  const card = document.createElement("div");
@@ -2236,7 +2683,7 @@ var SeatPicker = class _SeatPicker {
2236
2683
  card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
2237
2684
  const mix = summary.categories.map((c) => {
2238
2685
  const dim = this.priceBandKeys != null && !this.priceBandKeys.has(c.key);
2239
- return `<span class="sl-seccard-mix-item${dim ? " sl-dim" : ""}"><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>`;
2686
+ return `<span class="sl-seccard-mix-item${dim ? " sl-dim" : ""}"><span class="sl-seccard-mix-dot" style="background:${c.color}"></span>${c.label} <span class="sl-seccard-mix-price">${this.money(this.paidPrice(c.key, null, c.price))}</span></span>`;
2240
2687
  }).join("");
2241
2688
  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>` : "") + xBtn + `</div><div class="sl-seccard-zone">${summary.zoneLabel ? `${summary.zoneLabel} \xB7 ` : ""}<span class="sl-seccard-left">${leftLabel}</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>`;
2242
2689
  card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
@@ -2303,7 +2750,7 @@ var SeatPicker = class _SeatPicker {
2303
2750
  const cat = this.controller.doc?.categories.find((c) => c.key === seat.categoryKey);
2304
2751
  const status = this.controller.getStatus(seat.id) ?? "free";
2305
2752
  const statusText = status === "free" ? "available" : status === "held" ? "on hold" : "taken";
2306
- const price = cat?.tiers?.length ? cat.tiers[0].price : cat?.price;
2753
+ const price = cat ? this.catPrice(cat) : void 0;
2307
2754
  this.srEl.textContent = `Seat ${seat.label}, ${cat?.label ?? seat.categoryKey}${price != null ? `, ${this.money(price)}` : ""}, ${statusText}`;
2308
2755
  }
2309
2756
  // ---- seat candidate confirmation ------------------------------------------
@@ -2318,7 +2765,8 @@ var SeatPicker = class _SeatPicker {
2318
2765
  if (this.tipEl) this.tipEl.style.display = "none";
2319
2766
  const details = this.controller.seatDetails(seat.id);
2320
2767
  const cat = this.controller.doc?.categories.find((c) => c.key === seat.categoryKey);
2321
- const price = details?.price ?? (cat?.tiers?.length ? cat.tiers[0].price : cat?.price);
2768
+ const chartPrice = details?.price ?? (cat?.tiers?.length ? cat.tiers[0].price : cat?.price);
2769
+ const price = chartPrice != null ? this.paidPrice(seat.categoryKey, details?.tierId ?? cat?.tiers?.[0]?.id ?? null, chartPrice) : void 0;
2322
2770
  const safe = (value) => String(value ?? "\u2014").replace(/[&<>"]/g, (char) => ({
2323
2771
  "&": "&amp;",
2324
2772
  "<": "&lt;",
@@ -2331,7 +2779,7 @@ var SeatPicker = class _SeatPicker {
2331
2779
  el.setAttribute("aria-modal", "true");
2332
2780
  el.setAttribute("aria-label", `Confirm seat ${seat.label}`);
2333
2781
  el.style.setProperty("--sl-cat", cat?.color ?? "#6e7bff");
2334
- el.innerHTML = `<div class="sl-confirm-grid"><div class="sl-confirm-field"><span class="sl-confirm-key">Section</span><span class="sl-confirm-value">${safe(details?.sectionLabel)}</span></div><div class="sl-confirm-field"><span class="sl-confirm-key">Row</span><span class="sl-confirm-value">${safe(details?.rowLabel)}</span></div><div class="sl-confirm-field"><span class="sl-confirm-key">Seat</span><span class="sl-confirm-value">${safe(details?.seatNumber ?? seat.label)}</span></div></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.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"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4 4L19 7"/></svg>Select</button></div></div>`;
2782
+ el.innerHTML = `<div class="sl-confirm-grid"><div class="sl-confirm-field"><span class="sl-confirm-key">Section</span><span class="sl-confirm-value">${safe(details?.sectionLabel)}</span></div><div class="sl-confirm-field"><span class="sl-confirm-key">Row</span><span class="sl-confirm-value">${safe(this.rowShort(details))}</span></div><div class="sl-confirm-field"><span class="sl-confirm-key">Seat</span><span class="sl-confirm-value">${safe(details?.seatNumber ?? seat.label)}</span></div></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.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>`;
2335
2783
  this.els.map.appendChild(el);
2336
2784
  this.confirmEl = el;
2337
2785
  this.reanchorConfirm();
@@ -2496,18 +2944,35 @@ var SeatPicker = class _SeatPicker {
2496
2944
  }
2497
2945
  // ---- chrome sync ----------------------------------------------------------
2498
2946
  money(n) {
2947
+ const formatter = this.opts.pricing?.formatter;
2948
+ if (formatter) return formatter(n, this.currency);
2499
2949
  try {
2500
2950
  return new Intl.NumberFormat(this.opts.locale, { style: "currency", currency: this.currency }).format(n);
2501
2951
  } catch {
2502
2952
  return `${n} ${this.currency}`;
2503
2953
  }
2504
2954
  }
2955
+ /**
2956
+ * The price the buyer will actually pay for a category (+tier): the host's
2957
+ * `pricing` override when present, else the chart's stored price. Every
2958
+ * price the widget DISPLAYS or hands off must flow through here — a map
2959
+ * that shows one price while checkout charges another destroys trust.
2960
+ */
2961
+ paidPrice(categoryKey, tierId, fallback) {
2962
+ const entry = categoryKey ? this.opts.pricing?.prices?.[categoryKey] : void 0;
2963
+ if (entry === void 0) return fallback;
2964
+ if (typeof entry === "number") return entry;
2965
+ if (tierId && entry.tiers?.[tierId] !== void 0) return entry.tiers[tierId];
2966
+ return entry.base ?? fallback;
2967
+ }
2505
2968
  syncPrices() {
2506
2969
  const doc = this.controller.doc;
2507
2970
  if (!doc || !this.els.prices) return;
2508
2971
  const left = this.controller.categoryAvailability();
2972
+ this.narrateAvailability(doc.categories, left);
2973
+ this.syncSoldout(doc.categories, left);
2509
2974
  this.els.prices.innerHTML = doc.categories.map((c) => {
2510
- const price = c.tiers?.length ? c.tiers[0].price : c.price;
2975
+ const price = this.catPrice(c);
2511
2976
  const dim = this.priceBandKeys != null && !this.priceBandKeys.has(c.key);
2512
2977
  return `<div class="sl-price-row${dim ? " sl-dim" : ""}" data-cat="${c.key}"><span class="sl-dot" style="background:${c.color}"></span><span class="sl-price-label">${c.label}</span><span class="sl-price-left">${left[c.key] ?? 0} left</span>` + (price != null ? `<span class="sl-price-amt">${this.money(price)}</span>` : "") + `</div>`;
2513
2978
  }).join("") + `<div class="sl-status-key" aria-label="Seat status legend"><span class="sl-status-item"><i class="sl-status-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></i>Temporarily held</span><span class="sl-status-item"><i class="sl-status-icon sold" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 17L17 7"/></svg></i>Sold</span></div>`;
@@ -2516,6 +2981,26 @@ var SeatPicker = class _SeatPicker {
2516
2981
  row.addEventListener("mouseleave", () => this.controller.getRenderer()?.setCategoryHighlight?.(null));
2517
2982
  });
2518
2983
  }
2984
+ /**
2985
+ * Live-activity strip: turn WS availability deltas into one quiet line of
2986
+ * social proof ("2 seats just taken in VIP · 118 left"). Diffs per-category
2987
+ * counts on every status change — no per-seat payload needed. Skips the very
2988
+ * first computation (initial load is not "activity").
2989
+ */
2990
+ narrateAvailability(categories, left) {
2991
+ const textEl = this.els.liveText;
2992
+ const prev = this.lastCatAvail;
2993
+ this.lastCatAvail = { ...left };
2994
+ if (!textEl || !prev) return;
2995
+ for (const cat of categories) {
2996
+ const before = prev[cat.key];
2997
+ const now = left[cat.key] ?? 0;
2998
+ if (before === void 0 || now >= before) continue;
2999
+ const taken = before - now;
3000
+ textEl.textContent = `${taken} seat${taken === 1 ? "" : "s"} just taken in ${cat.label} \xB7 ${now} left`;
3001
+ return;
3002
+ }
3003
+ }
2519
3004
  /** A live delta took one of OUR selected (not yet held) seats — evict + tell the buyer. */
2520
3005
  evictTakenSelections() {
2521
3006
  const ownLabels = /* @__PURE__ */ new Set([
@@ -2544,15 +3029,23 @@ var SeatPicker = class _SeatPicker {
2544
3029
  const cats = this.controller.doc?.categories ?? [];
2545
3030
  parts.push(this.bestAvailableConfirm ? `<div class="sl-ba" role="alert"><div class="sl-ba-title"><span class="spark" aria-hidden="true">\u2726</span>Replace your current choices?</div><div class="sl-ba-replace"><b>We\u2019ll find ${this.baQty} seats together.</b><span>Your manually selected tickets will be removed only after a new group is secured.</span></div><div class="sl-ba-actions"><button type="button" data-ba-cancel>Keep mine</button><button type="button" class="replace" data-ba-replace>Find new seats</button></div></div>` : `<div class="sl-ba"><div class="sl-ba-title"><span class="spark" aria-hidden="true">\u2726</span>Find the best seats together</div><div class="sl-ba-copy"><span class="wide">We\u2019ll choose the closest available group for you.</span><span class="narrow">Closest available group, chosen instantly.</span></div>` + (cats.length > 1 ? `<select aria-label="Preferred ticket type" data-ba-cat><option value="">Any ticket type</option>` + cats.map((c) => `<option value="${c.key}"${this.baCat === c.key ? " selected" : ""}>${c.label}</option>`).join("") + `</select>` : `<span aria-hidden="true"></span>`) + `<div class="sl-ba-qty"><button type="button" data-ba="-1" aria-label="Fewer seats">\u2212</button><span>${this.baQty}</span><button type="button" data-ba="1" aria-label="More seats">+</button></div><button type="button" class="sl-ba-go"${this.bestAvailableBusy ? " disabled" : ""}>` + (this.bestAvailableBusy ? `<span class="sl-ba-spin" aria-hidden="true"></span>Finding the best seats\u2026` : `Find ${this.baQty} best ${this.baQty === 1 ? "seat" : "seats"}`) + `</button></div>`);
2546
3031
  }
3032
+ const idGrid = (seatId, label) => {
3033
+ const d = seatId ? this.controller.seatDetails(seatId) : null;
3034
+ if (!d?.sectionLabel && !d?.rowLabel && !d?.seatNumber) {
3035
+ return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">Seat</span><span class="val">${label}</span></span></div>`;
3036
+ }
3037
+ return `<div class="sl-chip-id"><span class="fld sec"><span class="sl-chip-eb">Section</span><span class="val">${d.sectionLabel ?? "\u2014"}</span></span>` + (d.rowLabel ? `<span class="fld mid"><span class="sl-chip-eb">Row</span><span class="val">${this.rowShort(d)}</span></span>` : "") + (d.seatNumber ? `<span class="fld mid"><span class="sl-chip-eb">Seat</span><span class="val">${d.seatNumber}</span></span>` : "") + `</div>`;
3038
+ };
3039
+ const iconRail = (rmAria, viewLabel) => `<div class="sl-chip-rail"><button type="button" class="rm" aria-label="${rmAria}"><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>` + (viewLabel ? `<button type="button" class="view" data-view-label="${viewLabel}" aria-label="${t2("picker.viewFromSeat", { label: viewLabel })}"><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>`;
2547
3040
  for (const item of heldItems) {
2548
3041
  const itemKey = `held:${item.label}`;
2549
3042
  nextTrayKeys.add(itemKey);
2550
3043
  const cat = this.controller.doc?.categories.find((c) => c.key === item.categoryKey);
2551
3044
  const tierName = item.tierId ? cat?.tiers?.find((ti) => ti.id === item.tierId)?.name : void 0;
2552
- const canView2 = this.seatViewEnabled() && item.objectType !== "ga" && !!this.controller.seatByLabel(item.label);
2553
- const viewBtn = 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>` : "";
3045
+ const heldSeat = item.objectType !== "ga" ? this.controller.seatByLabel(item.label) : null;
3046
+ const canView2 = this.seatViewEnabled() && !!heldSeat;
2554
3047
  parts.push(
2555
- `<div class="sl-chip sl-held${this.lastTrayKeys.has(itemKey) ? "" : " sl-enter"}" data-key="${itemKey}" data-held="${encodeURIComponent(item.label)}"><span class="sl-ticket-state held" aria-label="Held for you" title="Held for you"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></span><div class="sl-chip-main"><b>${item.label}</b><div class="sl-chip-sub"><span class="cat">${cat?.label ?? item.categoryKey}${tierName ? ` \xB7 ${tierName}` : ""}</span>${viewBtn}</div></div><span class="amt">${this.money(item.unitPrice * (item.quantity ?? 1))}</span><button type="button" class="rm" aria-label="Remove held ticket ${item.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>`
3048
+ `<div class="sl-chip sl-held${this.lastTrayKeys.has(itemKey) ? "" : " sl-enter"}" data-key="${itemKey}" data-held="${encodeURIComponent(item.label)}"${heldSeat ? ` data-locate="${heldSeat.id}"` : ""}><div class="sl-chip-main">` + idGrid(heldSeat?.id ?? null, item.label) + `<div class="sl-chip-sub"><span class="sl-ticket-state held" aria-label="Held for you" title="Held for you"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></span><span class="cat">${cat?.label ?? item.categoryKey}${tierName ? ` \xB7 ${tierName}` : ""}</span><span class="amt">${this.money(this.paidPrice(item.categoryKey, item.tierId, item.unitPrice) * (item.quantity ?? 1))}</span></div></div>` + iconRail(`Remove held ticket ${item.label}`, canView2 ? item.label : null) + `</div>`
2556
3049
  );
2557
3050
  }
2558
3051
  const heldLabels = new Set(heldItems.map((item) => item.label));
@@ -2561,16 +3054,15 @@ var SeatPicker = class _SeatPicker {
2561
3054
  const itemKey = `seat:${s.id}`;
2562
3055
  nextTrayKeys.add(itemKey);
2563
3056
  const cat = this.controller.doc?.categories.find((c) => c.key === s.categoryKey);
2564
- 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>` : "";
2565
- 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>` : "";
3057
+ 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(this.paidPrice(s.categoryKey, ti.id, ti.price))}</option>`).join("") + `</select>` : "";
2566
3058
  parts.push(
2567
- `<div class="sl-chip${this.lastTrayKeys.has(itemKey) ? "" : " sl-enter"}" data-key="${itemKey}" data-seat="${s.id}"><span class="sl-ticket-state" aria-label="Selected" title="Selected"><svg viewBox="0 0 24 24"><path d="M5 12l4 4L19 6"/></svg></span><div class="sl-chip-main"><b>${s.label}</b><div class="sl-chip-sub"><span class="cat">${cat?.label ?? s.categoryKey}</span>${tierSelect}${viewBtn}</div></div><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>`
3059
+ `<div class="sl-chip${this.lastTrayKeys.has(itemKey) ? "" : " sl-enter"}" data-key="${itemKey}" data-seat="${s.id}" data-locate="${s.id}"><div class="sl-chip-main">` + idGrid(s.id, s.label) + `<div class="sl-chip-sub"><span class="sl-ticket-state" aria-label="Selected" title="Selected"><svg viewBox="0 0 24 24"><path d="M5 12l4 4L19 6"/></svg></span><span class="cat">${cat?.label ?? s.categoryKey}</span>${tierSelect}<span class="amt">${this.money(this.paidPrice(s.categoryKey, s.tierId ?? null, s.price))}</span></div></div>` + iconRail(`Remove ${s.label}`, canView ? s.label : null) + `</div>`
2568
3060
  );
2569
3061
  }
2570
3062
  for (const area of gaAreas) {
2571
3063
  const qty = this.gaQty.get(area.id) ?? 0;
2572
3064
  parts.push(
2573
- `<div class="sl-ga" data-ga="${area.id}"><div class="sl-ga-info"><div class="sl-ga-name">${area.label}</div><div class="sl-ga-sub">${this.money(area.price)} \xB7 ${area.available} left</div></div><div class="sl-ga-qty"><button type="button" data-d="-1" aria-label="Fewer">\u2212</button><span>${qty}</span><button type="button" data-d="1" aria-label="More">+</button></div></div>`
3065
+ `<div class="sl-ga" data-ga="${area.id}"><div class="sl-ga-info"><div class="sl-ga-name">${area.label}</div><div class="sl-ga-sub">${this.money(this.paidPrice(area.categoryKey, null, area.price))} \xB7 ${area.available} left</div></div><div class="sl-ga-qty"><button type="button" data-d="-1" aria-label="Fewer">\u2212</button><span>${qty}</span><button type="button" data-d="1" aria-label="More">+</button></div></div>`
2574
3066
  );
2575
3067
  }
2576
3068
  this.els.tray.innerHTML = parts.join("");
@@ -2610,7 +3102,7 @@ var SeatPicker = class _SeatPicker {
2610
3102
  return;
2611
3103
  }
2612
3104
  const id = chip.dataset.seat;
2613
- const label = chip.querySelector("b")?.textContent ?? "Seat";
3105
+ const label = this.controller.getSelection().find((sel) => sel.id === id)?.label ?? "Seat";
2614
3106
  const remove = () => {
2615
3107
  this.controller.deselect([id]);
2616
3108
  this.toast(`${label} removed.`, "neutral", {
@@ -2641,6 +3133,11 @@ var SeatPicker = class _SeatPicker {
2641
3133
  if (seat) this.openSeatView(seat);
2642
3134
  });
2643
3135
  });
3136
+ this.els.tray.querySelectorAll(".sl-chip[data-locate]").forEach((chip) => {
3137
+ const locate = () => this.controller.flashSeat(chip.dataset.locate, this.cssVar("--sl-accent") || "#f4b740");
3138
+ chip.addEventListener("mouseenter", locate);
3139
+ chip.addEventListener("focusin", locate);
3140
+ });
2644
3141
  this.els.tray.querySelectorAll(".sl-ga button").forEach((btn) => {
2645
3142
  btn.addEventListener("click", () => {
2646
3143
  const areaEl = btn.closest(".sl-ga");
@@ -2653,12 +3150,17 @@ var SeatPicker = class _SeatPicker {
2653
3150
  this.syncTray();
2654
3151
  });
2655
3152
  });
3153
+ if (this.salesClosed) {
3154
+ this.els.tray.querySelectorAll(".sl-ba-go,[data-ba],[data-ba-cat],[data-ba-replace],.sl-ga button").forEach((el) => {
3155
+ el.disabled = true;
3156
+ });
3157
+ }
2656
3158
  const gaTotal = this.pendingGATotal(gaAreas);
2657
3159
  const gaCount = this.pendingGACount();
2658
- const heldTotal = heldItems.reduce((sum, item) => sum + item.unitPrice * (item.quantity ?? 1), 0);
3160
+ const heldTotal = heldItems.reduce((sum, item) => sum + this.paidPrice(item.categoryKey, item.tierId, item.unitPrice) * (item.quantity ?? 1), 0);
2659
3161
  const heldCount = heldItems.reduce((sum, item) => sum + (item.quantity ?? 1), 0);
2660
3162
  const freshSeats = seats.filter((seat) => !heldLabels.has(seat.label));
2661
- const total = freshSeats.reduce((sum, s) => sum + s.price, 0) + gaTotal + heldTotal;
3163
+ const total = freshSeats.reduce((sum, s) => sum + this.paidPrice(s.categoryKey, s.tierId ?? null, s.price), 0) + gaTotal + heldTotal;
2662
3164
  const count = freshSeats.length + gaCount + heldCount;
2663
3165
  const pendingCount = this.pendingSelectionCount();
2664
3166
  const previousCount = this.lastTrayCount;
@@ -2754,6 +3256,7 @@ var SeatPicker = class _SeatPicker {
2754
3256
  }
2755
3257
  }
2756
3258
  async handleCta() {
3259
+ if (this.salesClosed) return;
2757
3260
  if (this.totalTicketCount() > this.maxTickets) {
2758
3261
  this.toast(`Remove tickets until your order has ${this.maxTickets} or fewer.`, "warning");
2759
3262
  return;
@@ -2797,6 +3300,7 @@ var SeatPicker = class _SeatPicker {
2797
3300
  this.opts.onError?.(err);
2798
3301
  const problem = err;
2799
3302
  const labels = (problem.conflicts ?? []).map((conflict) => conflict.label).filter(Boolean).slice(0, 3);
3303
+ if (problem.reason === "event_closed") this.setSalesClosed(true);
2800
3304
  const message = problem.reason === "event_closed" ? "Seat sales have closed for this event." : labels.length ? `${labels.join(", ")} ${labels.length === 1 ? "is" : "are"} no longer available. Choose another ${labels.length === 1 ? "seat" : "group"}.` : "One or more seats were just taken. Please pick again.";
2801
3305
  this.toast(message, "error");
2802
3306
  this.setCtaPhase("idle");
@@ -2905,7 +3409,7 @@ var SeatPicker = class _SeatPicker {
2905
3409
  objectType: it.objectType,
2906
3410
  categoryKey: it.categoryKey,
2907
3411
  tierId: it.tierId,
2908
- unitPrice: it.unitPrice,
3412
+ unitPrice: this.paidPrice(it.categoryKey, it.tierId, it.unitPrice),
2909
3413
  currency: it.currency ?? this.currency,
2910
3414
  quantity: it.quantity ?? 1
2911
3415
  }));
@@ -2958,19 +3462,37 @@ var SeatPicker = class _SeatPicker {
2958
3462
  this.tipEl.style.left = `${Math.max(8, x)}px`;
2959
3463
  this.tipEl.style.top = `${Math.max(8, y)}px`;
2960
3464
  }
3465
+ /**
3466
+ * Row label without the redundant section prefix. Charts commonly name row
3467
+ * objects "104-A" while the Section column already shows "104" — so the Row
3468
+ * cell repeats the section and, in the compact hover card, truncates to
3469
+ * "10…". Strip a leading "<section><sep>" so Row reads a clean "A". Only when
3470
+ * the prefix is exact (won't touch "1040-A" under section "104"); otherwise
3471
+ * the label is shown verbatim.
3472
+ */
3473
+ rowShort(details) {
3474
+ const row = details?.rowLabel;
3475
+ const sec = details?.sectionLabel;
3476
+ if (!row || !sec) return row;
3477
+ for (const sep of ["-", " ", "\xB7", "/", "_"]) {
3478
+ const prefix = `${sec}${sep}`;
3479
+ if (row.startsWith(prefix) && row.length > prefix.length) return row.slice(prefix.length);
3480
+ }
3481
+ return row;
3482
+ }
2961
3483
  updateTooltip(details) {
2962
3484
  if (!this.tipEl) return;
2963
3485
  if (!details) {
2964
3486
  this.tipEl.style.display = "none";
2965
3487
  return;
2966
3488
  }
2967
- const statusLine = details.status === "free" ? "" : `<div style="margin-top:5px;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;font-weight:700">${details.status === "held" ? t2("map.statusHeld") : t2("map.statusTaken")}</div>`;
2968
- const location = [
2969
- details.sectionLabel ? `Section ${details.sectionLabel}` : "",
2970
- details.rowLabel ? `Row ${details.rowLabel}` : "",
2971
- details.seatNumber ? `Seat ${details.seatNumber}` : details.label
2972
- ].filter(Boolean).join(" \xB7 ");
2973
- this.tipEl.innerHTML = `<div style="font-weight:800;font-size:13px">${location}</div><div style="display:flex;align-items:center;gap:6px;margin-top:4px"><span style="width:9px;height:9px;border-radius:50%;flex:none;background:${details.categoryColor}"></span><span style="opacity:.75">${details.categoryLabel}</span><span style="margin-left:auto;font-weight:800">${this.money(details.price)}</span></div>` + statusLine;
3489
+ const esc2 = (v) => String(v ?? "\u2014").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]);
3490
+ const price = this.money(this.paidPrice(details.categoryKey, details.tierId ?? null, details.price));
3491
+ const hasLoc = details.sectionLabel || details.rowLabel || details.seatNumber;
3492
+ const grid = hasLoc ? `<div class="sl-tip-grid"><div class="sl-tip-field"><span class="sl-tip-key">Section</span><span class="sl-tip-val">${esc2(details.sectionLabel)}</span></div><div class="sl-tip-field"><span class="sl-tip-key">Row</span><span class="sl-tip-val">${esc2(this.rowShort(details))}</span></div><div class="sl-tip-field"><span class="sl-tip-key">Seat</span><span class="sl-tip-val">${esc2(details.seatNumber ?? details.label)}</span></div></div>` : `<div class="sl-tip-grid one"><div class="sl-tip-field"><span class="sl-tip-key">Seat</span><span class="sl-tip-val">${esc2(details.label)}</span></div></div>`;
3493
+ const statusLine = details.status === "free" ? "" : `<div class="sl-tip-status">${details.status === "held" ? t2("map.statusHeld") : t2("map.statusTaken")}</div>`;
3494
+ this.tipEl.style.setProperty("--sl-cat", details.categoryColor);
3495
+ this.tipEl.innerHTML = grid + `<div class="sl-tip-cat"><span class="sl-tip-dot" style="background:${details.categoryColor}"></span><span class="sl-tip-name">${esc2(details.categoryLabel)}</span><span class="sl-tip-amt">${price}</span></div>` + statusLine;
2974
3496
  this.tipEl.style.display = "block";
2975
3497
  this.placeTooltip();
2976
3498
  }
@@ -2991,7 +3513,7 @@ var SeatPicker = class _SeatPicker {
2991
3513
  return this.removeHeldLabel(label);
2992
3514
  }
2993
3515
  async bestAvailable(qty, categoryKey) {
2994
- if (this.bestAvailableBusy) return null;
3516
+ if (this.salesClosed || this.bestAvailableBusy) return null;
2995
3517
  qty = Math.max(1, Math.min(this.maxTickets, Math.floor(qty)));
2996
3518
  if (this.confirmSeat) this.cancelConfirm();
2997
3519
  this.bestAvailableConfirm = false;
@@ -3071,7 +3593,10 @@ var SeatPicker = class _SeatPicker {
3071
3593
  this.motionTimers.clear();
3072
3594
  this.ro?.disconnect();
3073
3595
  this.ro = null;
3596
+ if (this.framedFs) this.setFramedFs(false);
3074
3597
  if (this.escHandler) document.removeEventListener("keydown", this.escHandler);
3598
+ if (this.fsChangeHandler) document.removeEventListener("fullscreenchange", this.fsChangeHandler);
3599
+ if (this.fsEscHandler) window.removeEventListener("keydown", this.fsEscHandler);
3075
3600
  this.controller.destroy();
3076
3601
  this.root?.remove();
3077
3602
  this.root = null;
@@ -3083,6 +3608,92 @@ var SeatPicker = class _SeatPicker {
3083
3608
  }
3084
3609
  };
3085
3610
 
3611
+ // src/attachPickerFrame.ts
3612
+ function attachPickerFrame(iframe, opts = {}) {
3613
+ let expectedOrigin = opts.origin ?? "";
3614
+ if (!expectedOrigin) {
3615
+ try {
3616
+ expectedOrigin = new URL(iframe.src, window.location.href).origin;
3617
+ } catch {
3618
+ expectedOrigin = "";
3619
+ }
3620
+ }
3621
+ let pinned = false;
3622
+ let frameStyleBeforeFs = null;
3623
+ let docOverflowBeforeFs = null;
3624
+ let bodyOverflowBeforeFs = null;
3625
+ let lastAutoHeight = "";
3626
+ let keyHandler = null;
3627
+ const pin = () => {
3628
+ if (pinned) return;
3629
+ pinned = true;
3630
+ frameStyleBeforeFs = iframe.getAttribute("style");
3631
+ Object.assign(iframe.style, {
3632
+ position: "fixed",
3633
+ inset: "0",
3634
+ width: "100vw",
3635
+ height: "100vh",
3636
+ margin: "0",
3637
+ border: "0",
3638
+ zIndex: "2147483000",
3639
+ background: "#101625"
3640
+ });
3641
+ const docEl = document.documentElement;
3642
+ docOverflowBeforeFs = docEl.style.overflow;
3643
+ docEl.style.overflow = "hidden";
3644
+ if (document.body) {
3645
+ bodyOverflowBeforeFs = document.body.style.overflow;
3646
+ document.body.style.overflow = "hidden";
3647
+ }
3648
+ keyHandler = (event) => {
3649
+ if (event.key === "Escape") unpin();
3650
+ };
3651
+ window.addEventListener("keydown", keyHandler);
3652
+ };
3653
+ const unpin = () => {
3654
+ if (!pinned) return;
3655
+ pinned = false;
3656
+ if (frameStyleBeforeFs === null) iframe.removeAttribute("style");
3657
+ else iframe.setAttribute("style", frameStyleBeforeFs);
3658
+ frameStyleBeforeFs = null;
3659
+ if (lastAutoHeight) iframe.style.height = lastAutoHeight;
3660
+ if (docOverflowBeforeFs !== null) {
3661
+ document.documentElement.style.overflow = docOverflowBeforeFs;
3662
+ docOverflowBeforeFs = null;
3663
+ }
3664
+ if (bodyOverflowBeforeFs !== null && document.body) {
3665
+ document.body.style.overflow = bodyOverflowBeforeFs;
3666
+ bodyOverflowBeforeFs = null;
3667
+ }
3668
+ if (keyHandler) {
3669
+ window.removeEventListener("keydown", keyHandler);
3670
+ keyHandler = null;
3671
+ }
3672
+ };
3673
+ const onMessage = (event) => {
3674
+ if (event.source !== iframe.contentWindow) return;
3675
+ if (expectedOrigin && event.origin !== expectedOrigin) return;
3676
+ if (!event.data || typeof event.data !== "object") return;
3677
+ const data = event.data;
3678
+ if (data.type === "seatlayer:height") {
3679
+ if (typeof data.px === "number" && Number.isFinite(data.px) && data.px > 0) {
3680
+ lastAutoHeight = `${Math.round(data.px)}px`;
3681
+ if (!pinned) iframe.style.height = lastAutoHeight;
3682
+ }
3683
+ return;
3684
+ }
3685
+ if (data.type === "seatlayer:fullscreen") {
3686
+ if (data.on === true) pin();
3687
+ else if (data.on === false) unpin();
3688
+ }
3689
+ };
3690
+ window.addEventListener("message", onMessage);
3691
+ return () => {
3692
+ window.removeEventListener("message", onMessage);
3693
+ unpin();
3694
+ };
3695
+ }
3696
+
3086
3697
  // src/SeatManager.ts
3087
3698
  import {
3088
3699
  SeatmapRenderer,
@@ -3169,6 +3780,21 @@ var ManageApi = class {
3169
3780
  setHoldTtl(key, holdTtlMs) {
3170
3781
  return this.auth(`/v1/events/${encodeURIComponent(key)}/hold-ttl`, { method: "POST", body: { holdTtlMs } });
3171
3782
  }
3783
+ // ---- availability windows (token) ----
3784
+ /** The organizer's current per section/zone availability windows (needs
3785
+ * `event:view`). Ids absent from `rules` are open / on sale. */
3786
+ availability(key) {
3787
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`);
3788
+ }
3789
+ /** Replace the availability windows for a set of section/zone ids (needs
3790
+ * `event:block`). Ids absent from `rules` become open / on sale; a zone rule
3791
+ * cascades to its sections. The worker derives each id's seat labels, so
3792
+ * `labels` on the sent rules is best-effort. Resolves with the authoritative
3793
+ * effective `hidden` set (a due rule may fire at once) and the server-cleaned
3794
+ * `rules` map (fired timed/threshold windows dropped). */
3795
+ setAvailability(key, rules) {
3796
+ return this.auth(`/v1/events/${encodeURIComponent(key)}/availability`, { method: "POST", body: { rules } });
3797
+ }
3172
3798
  // ---- reports (token) ----
3173
3799
  report(key) {
3174
3800
  return this.auth(`/v1/events/${encodeURIComponent(key)}/report`);
@@ -3196,6 +3822,27 @@ var ManageApi = class {
3196
3822
  };
3197
3823
 
3198
3824
  // src/SeatManager.ts
3825
+ function availabilityModeOf(rule) {
3826
+ return rule ? rule.mode : "open";
3827
+ }
3828
+ function availabilityRuleForMode(mode, seatLabels, prev) {
3829
+ switch (mode) {
3830
+ case "open":
3831
+ return null;
3832
+ case "hidden":
3833
+ return { mode: "hidden", labels: seatLabels };
3834
+ case "closed":
3835
+ return { mode: "closed", labels: seatLabels };
3836
+ case "timed":
3837
+ return { mode: "timed", revealAt: prev?.revealAt ?? Date.now() + 36e5, labels: seatLabels };
3838
+ case "threshold":
3839
+ return { mode: "threshold", thresholdPct: prev?.thresholdPct ?? 80, labels: seatLabels };
3840
+ }
3841
+ }
3842
+ function toLocalInput(ms) {
3843
+ const d = new Date(ms - (/* @__PURE__ */ new Date()).getTimezoneOffset() * 6e4);
3844
+ return d.toISOString().slice(0, 16);
3845
+ }
3199
3846
  function resolveContainer4(container) {
3200
3847
  if (typeof container === "string") {
3201
3848
  const el = document.querySelector(container);
@@ -3384,6 +4031,32 @@ var CSS2 = `
3384
4031
  .slm-momentumhelp[hidden]{display:none}.slm-momentumscale{display:flex;align-items:center;gap:7px;color:var(--slm-muted);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.07em}
3385
4032
  .slm-momentumgradient{height:6px;min-width:64px;flex:1;border-radius:999px;background:linear-gradient(90deg,#f4b740,#ef4444)}
3386
4033
  .slm-momentumcopy{margin-top:7px;color:var(--slm-muted);font-size:11px;line-height:1.45}
4034
+ /* sections: availability windows */
4035
+ .slm-availlist{display:flex;flex-direction:column;gap:8px;margin:2px 0 12px}
4036
+ .slm-availrow{padding:10px;border:1px solid var(--slm-line);border-radius:10px;background:var(--slm-surface);transition:border-color .15s ease,opacity .15s ease}
4037
+ .slm-availrow.zone{background:color-mix(in srgb,var(--slm-surface) 82%,#000)}
4038
+ .slm-availrow.hidden{opacity:.62}.slm-availrow.closed{opacity:.82}
4039
+ .slm-availhead{display:flex;align-items:center;gap:8px}
4040
+ .slm-availlabel{display:flex;align-items:center;gap:5px;flex:1;min-width:0;font-size:12.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4041
+ .slm-availcaret{flex:none;color:var(--slm-muted);font-size:10px}
4042
+ .slm-availcount{flex:none;font-size:11px;font-weight:700;color:var(--slm-muted);font-variant-numeric:tabular-nums}
4043
+ .slm-availbadge{flex:none;font-size:9px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:999px}
4044
+ .slm-availbadge.hidden{background:rgba(139,148,172,.18);color:#c2c9d8}
4045
+ .slm-availbadge.closed{background:rgba(244,183,64,.16);color:#f7ca6b}
4046
+ .slm-availselwrap{position:relative;flex:none;display:inline-flex}
4047
+ .slm-availmode{width:auto;max-width:190px;padding:6px 8px;font-size:11.5px;font-weight:700;cursor:pointer}
4048
+ .slm-availmode.on{border-color:var(--slm-accent);color:var(--slm-text)}
4049
+ .slm-availmode:disabled{opacity:.55;cursor:progress}
4050
+ .slm-availfollows{flex:none;padding:5px 10px;border:1px solid var(--slm-line);border-radius:7px;background:var(--slm-surface);color:var(--slm-muted);font-size:11px;font-weight:600;white-space:nowrap}
4051
+ .slm-availdetail{display:flex;align-items:center;gap:8px;margin-top:9px}
4052
+ .slm-availdetail .slm-input{flex:1}
4053
+ .slm-availpct{max-width:74px;flex:none!important}
4054
+ .slm-availpctlabel{font-size:11px;color:var(--slm-muted);font-weight:600;white-space:nowrap}
4055
+ .slm-availsummary{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--slm-line);border-radius:9px;color:var(--slm-muted);font-size:12.5px}
4056
+ .slm-availdot{width:9px;height:9px;border-radius:50%;flex:none;background:#22a06b}.slm-availdot.warn{background:#f4b740}
4057
+ .slm-availcallout{display:flex;align-items:flex-start;gap:8px;margin-top:10px;padding:10px 12px;border:1px solid rgba(244,183,64,.45);border-radius:9px;background:rgba(244,183,64,.1)}
4058
+ .slm-availstar{flex:none;margin-top:1px;color:#f4b740;font-size:13px;line-height:1}
4059
+ .slm-availcallout p{font-size:11.5px;line-height:1.55;color:#f4d58a}.slm-availcallout b{color:#ffe4a3;font-weight:800}
3387
4060
  .slm-inspect-card{padding:16px;border:1px solid var(--slm-line);border-radius:12px;background:var(--slm-surface)}
3388
4061
  .slm-inspect-label{font-size:24px;font-weight:850;letter-spacing:-.02em;line-height:1.1}
3389
4062
  .slm-inspect-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px 20px;margin-top:18px}
@@ -3484,6 +4157,13 @@ var SeatManager = class {
3484
4157
  this.tokenRefreshInFlight = false;
3485
4158
  this.sectionByObject = /* @__PURE__ */ new Map();
3486
4159
  this.sectionLabelById = /* @__PURE__ */ new Map();
4160
+ this.sectionsBase = null;
4161
+ // Sections mode (availability windows): organizer rules + the live effective
4162
+ // hidden/closed sets from the snapshot + WS (a timed/threshold rule fires DO-side).
4163
+ this.availabilityRules = {};
4164
+ this.effectiveHidden = /* @__PURE__ */ new Set();
4165
+ this.effectiveClosed = /* @__PURE__ */ new Set();
4166
+ this.availabilitySaving = false;
3487
4167
  this.lastSyncedAt = null;
3488
4168
  this.blockedQuery = "";
3489
4169
  this.blockedSection = "";
@@ -3502,6 +4182,7 @@ var SeatManager = class {
3502
4182
  if (key === "m") this.setMode("view");
3503
4183
  else if (key === "i") this.setMode("inspect");
3504
4184
  else if (key === "b") this.setMode("block");
4185
+ else if (key === "s") this.setMode("sections");
3505
4186
  else if (key === "f") this.toggleFullscreen();
3506
4187
  else return;
3507
4188
  event.preventDefault();
@@ -3545,7 +4226,8 @@ var SeatManager = class {
3545
4226
  this.buildSectionOptions();
3546
4227
  const [, controlRoom] = await Promise.all([
3547
4228
  this.resnapshot(),
3548
- this.refreshControlRoom().catch((err) => this.opts.onError?.(err))
4229
+ this.refreshControlRoom().catch((err) => this.opts.onError?.(err)),
4230
+ this.refreshAvailability()
3549
4231
  ]);
3550
4232
  if (controlRoom?.activity) this.seedFeed(controlRoom.activity);
3551
4233
  else this.api.log(this.key, { limit: 24 }).then((page) => this.seedFeed(page.entries)).catch(() => {
@@ -3570,6 +4252,7 @@ var SeatManager = class {
3570
4252
  if (changed) this.renderer?.clearSelection();
3571
4253
  this.paintModeTabs();
3572
4254
  this.paintRail();
4255
+ this.applySectionCanvasTreatment();
3573
4256
  if (changed) this.opts.onModeChange?.(mode);
3574
4257
  }
3575
4258
  /** Toggle the normalized sales-velocity outline overlay without changing seat colors. */
@@ -3861,6 +4544,7 @@ var SeatManager = class {
3861
4544
  this.attempt = 0;
3862
4545
  this.setLive(true);
3863
4546
  void this.resnapshot().then(() => this.scheduleRevenueRefresh(0));
4547
+ void this.refreshAvailability();
3864
4548
  };
3865
4549
  ws.onmessage = (e) => this.onMessage(e);
3866
4550
  ws.onclose = () => {
@@ -3892,6 +4576,9 @@ var SeatManager = class {
3892
4576
  }
3893
4577
  if (!msg || typeof msg !== "object") return;
3894
4578
  const m = msg;
4579
+ if (Array.isArray(m.hidden) || Array.isArray(m.closed)) {
4580
+ this.updateEffectiveAvailability(m.hidden, m.closed);
4581
+ }
3895
4582
  if (m.type === "presence") {
3896
4583
  if (this.controlRoomSnapshot && typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
3897
4584
  this.controlRoomSnapshot = {
@@ -3943,6 +4630,7 @@ var SeatManager = class {
3943
4630
  try {
3944
4631
  const objs = await this.api.objects(this.key);
3945
4632
  this.applySnapshot(objs.seats);
4633
+ this.updateEffectiveAvailability(objs.hidden, objs.closed);
3946
4634
  } catch {
3947
4635
  }
3948
4636
  }
@@ -4254,6 +4942,7 @@ var SeatManager = class {
4254
4942
  <button class="slm-mode" role="tab" data-mode="view" title="Monitor (M)" aria-keyshortcuts="M">Monitor</button>
4255
4943
  <button class="slm-mode" role="tab" data-mode="inspect" title="Inspect (I)" aria-keyshortcuts="I">Inspect</button>
4256
4944
  <button class="slm-mode" role="tab" data-mode="block" title="Block (B)" aria-keyshortcuts="B">Block</button>
4945
+ <button class="slm-mode" role="tab" data-mode="sections" title="Sections (S)" aria-keyshortcuts="S">Sections</button>
4257
4946
  </div>
4258
4947
  <span class="slm-live"><span class="slm-live-dot"></span><span data-ref="livetext">CONNECTING</span></span>
4259
4948
  <div class="slm-bar-actions">
@@ -4320,6 +5009,7 @@ var SeatManager = class {
4320
5009
  if (!this.doc) return;
4321
5010
  try {
4322
5011
  const secs = computeSections(this.doc);
5012
+ this.sectionsBase = secs;
4323
5013
  this.sectionOptions = [];
4324
5014
  this.sectionByObject = new Map(secs.objectToSection);
4325
5015
  this.sectionLabelById.clear();
@@ -4445,6 +5135,7 @@ var SeatManager = class {
4445
5135
  paintRail() {
4446
5136
  if (this.mode === "view") this.renderViewRail();
4447
5137
  else if (this.mode === "inspect") this.renderInspectRail(this.getSelection());
5138
+ else if (this.mode === "sections") this.renderSectionsRail();
4448
5139
  else this.renderBlockRail();
4449
5140
  this.updateZoomHint();
4450
5141
  }
@@ -4567,6 +5258,259 @@ var SeatManager = class {
4567
5258
  </div>
4568
5259
  </div>`;
4569
5260
  }
5261
+ // ---- sections: availability windows --------------------------------------
5262
+ /** Pull the organizer's availability rules (event:view). Called on load and on
5263
+ * every WS (re)connect, mirroring how the other panels re-hydrate. `closed` is
5264
+ * deterministic from the rules; `hidden` (which folds in already-due timed /
5265
+ * threshold windows) comes from the snapshot + WS effective set. */
5266
+ async refreshAvailability() {
5267
+ try {
5268
+ const res = await this.withAuthRetry(() => this.api.availability(this.key));
5269
+ this.availabilityRules = res.rules ?? {};
5270
+ this.effectiveClosed = new Set(this.closedIdsFromRules(this.availabilityRules));
5271
+ if (this.mode === "sections") this.renderSectionsRail();
5272
+ this.applySectionCanvasTreatment();
5273
+ } catch (err) {
5274
+ this.opts.onError?.(err);
5275
+ }
5276
+ }
5277
+ /** Run a token-authed op; on a 401 re-mint via onTokenRefresh and retry once. */
5278
+ async withAuthRetry(op) {
5279
+ try {
5280
+ return await op();
5281
+ } catch (err) {
5282
+ if (err instanceof ManageApiError && err.status === 401 && this.opts.onTokenRefresh && !this.tokenRefreshInFlight) {
5283
+ await this.rotateToken();
5284
+ return op();
5285
+ }
5286
+ throw err;
5287
+ }
5288
+ }
5289
+ closedIdsFromRules(rules) {
5290
+ return Object.entries(rules).filter(([, r]) => r.mode === "closed").map(([id]) => id);
5291
+ }
5292
+ /** Adopt a new effective hidden/closed set (from a snapshot or WS broadcast) and
5293
+ * repaint the rail + canvas when it actually moves. */
5294
+ updateEffectiveAvailability(hidden, closed) {
5295
+ let changed = false;
5296
+ if (Array.isArray(hidden)) {
5297
+ this.effectiveHidden = new Set(hidden.filter((x) => typeof x === "string"));
5298
+ changed = true;
5299
+ }
5300
+ if (Array.isArray(closed)) {
5301
+ this.effectiveClosed = new Set(closed.filter((x) => typeof x === "string"));
5302
+ changed = true;
5303
+ }
5304
+ if (!changed) return;
5305
+ if (this.mode === "sections") this.renderSectionsRail();
5306
+ this.applySectionCanvasTreatment();
5307
+ }
5308
+ /** Canvas read of the availability state: dim hidden sections to a whisper,
5309
+ * half-light closed sections, leave open sections normal. Only in Sections mode;
5310
+ * cleared in every other tool. */
5311
+ applySectionCanvasTreatment() {
5312
+ if (!this.renderer) return;
5313
+ if (this.mode === "sections") {
5314
+ this.renderer.setDimmedSections([...this.effectiveHidden]);
5315
+ this.renderer.setClosedSections([...this.effectiveClosed]);
5316
+ } else {
5317
+ this.renderer.setDimmedSections(null);
5318
+ this.renderer.setClosedSections(null);
5319
+ }
5320
+ }
5321
+ /** Zone-grouped render tree: each zone header then its sections (which follow the
5322
+ * zone window), then loose sections + the ungrouped bucket. Effective hidden /
5323
+ * closed come from the live sets, rules from the organizer map. */
5324
+ buildSectionRows() {
5325
+ const base = this.sectionsBase;
5326
+ if (!base) return { rows: [], hiddenSections: 0, closedSections: 0 };
5327
+ const zones = this.doc?.zones ?? [];
5328
+ const byZone = /* @__PURE__ */ new Map();
5329
+ const loose = [];
5330
+ for (const s of base.sections) {
5331
+ if (s.zone && zones.some((z) => z.id === s.zone)) {
5332
+ const list = byZone.get(s.zone) ?? [];
5333
+ list.push(s);
5334
+ byZone.set(s.zone, list);
5335
+ } else {
5336
+ loose.push(s);
5337
+ }
5338
+ }
5339
+ const rows = [];
5340
+ let hiddenSections = 0;
5341
+ let closedSections = 0;
5342
+ const push = (kind, node, zoneRuled, parentClosed = false) => {
5343
+ const rule = this.availabilityRules[node.id] ?? null;
5344
+ const effClosed = this.effectiveClosed.has(node.id) || parentClosed;
5345
+ const effHidden = this.effectiveHidden.has(node.id) || zoneRuled && !effClosed;
5346
+ if (kind === "section" && effHidden) hiddenSections += 1;
5347
+ if (kind === "section" && effClosed) closedSections += 1;
5348
+ rows.push({
5349
+ kind,
5350
+ id: node.id,
5351
+ label: node.label,
5352
+ seatCount: node.seatCount,
5353
+ seatLabels: node.seatLabels,
5354
+ rule,
5355
+ hidden: effHidden,
5356
+ closed: effClosed,
5357
+ followsZone: kind === "section" && zoneRuled
5358
+ });
5359
+ };
5360
+ for (const z of zones) {
5361
+ const secs = byZone.get(z.id);
5362
+ if (!secs || !secs.length) continue;
5363
+ const zoneNode = {
5364
+ id: z.id,
5365
+ label: z.label || "Zone",
5366
+ seatCount: secs.reduce((sum, s) => sum + s.seatCount, 0),
5367
+ seatLabels: secs.flatMap((s) => s.seatLabels)
5368
+ };
5369
+ const zoneRuled = !!this.availabilityRules[z.id];
5370
+ const zoneClosed = this.availabilityRules[z.id]?.mode === "closed";
5371
+ push("zone", zoneNode, false);
5372
+ for (const s of secs) push("section", s, zoneRuled, zoneClosed);
5373
+ }
5374
+ for (const s of loose) push("section", s, false);
5375
+ if (base.ungrouped) {
5376
+ const u = base.ungrouped;
5377
+ push("section", { id: UNGROUPED_ID, label: u.label, seatCount: u.seatCount, seatLabels: u.seatLabels }, false);
5378
+ }
5379
+ return { rows, hiddenSections, closedSections };
5380
+ }
5381
+ renderSectionsRail() {
5382
+ const { rows, hiddenSections, closedSections } = this.buildSectionRows();
5383
+ if (!rows.length) {
5384
+ this.els.rail.innerHTML = `
5385
+ <p class="slm-eyebrow">Availability windows</p>
5386
+ <p class="slm-hint">Draw sections or zones in the designer to schedule availability per area. This chart has none yet.</p>
5387
+ <div class="slm-empty">No sections on this chart.</div>`;
5388
+ return;
5389
+ }
5390
+ const parts = [];
5391
+ if (hiddenSections) parts.push(`${hiddenSections} hidden`);
5392
+ if (closedSections) parts.push(`${closedSections} closed`);
5393
+ const summary = parts.length ? parts.join(" \xB7 ") : "All sections open and on sale";
5394
+ const warn = hiddenSections > 0 || closedSections > 0;
5395
+ this.els.rail.innerHTML = `
5396
+ <p class="slm-eyebrow">Availability windows</p>
5397
+ <p class="slm-hint">Control when each zone or section goes on sale. Keep it hidden, reveal it at a set time, or <b>auto-reveal once the rest sells past a threshold</b>. Hidden seats vanish for buyers; closed seats stay on the map (flat grey) but can't be bought.</p>
5398
+ <div class="slm-availlist" data-ref="availlist">${rows.map((row) => this.sectionRowHtml(row)).join("")}</div>
5399
+ <div class="slm-availsummary">
5400
+ <span class="slm-availdot${warn ? " warn" : ""}"></span>
5401
+ <span>${esc(summary)}</span>
5402
+ </div>
5403
+ <div class="slm-availcallout">
5404
+ <span class="slm-availstar" aria-hidden="true">\u2726</span>
5405
+ <p><b>Auto-reveal at % sold</b> is our differentiator \u2014 demand-triggered release: the balcony opens itself the moment the stalls hit the threshold. Neither seats.io nor Ticketmaster ships this.</p>
5406
+ </div>`;
5407
+ this.wireSectionRail();
5408
+ this.applySectionCanvasTreatment();
5409
+ }
5410
+ sectionRowHtml(row) {
5411
+ const mode = availabilityModeOf(row.rule);
5412
+ const cls = `slm-availrow${row.kind === "zone" ? " zone" : ""}${row.hidden ? " hidden" : ""}${row.closed ? " closed" : ""}`;
5413
+ const disabled = this.availabilitySaving ? " disabled" : "";
5414
+ const option = (value, text) => `<option value="${value}"${mode === value ? " selected" : ""}>${text}</option>`;
5415
+ const control = row.followsZone ? '<span class="slm-availfollows">Follows zone</span>' : `<span class="slm-availselwrap">
5416
+ <select class="slm-select slm-availmode${mode !== "open" ? " on" : ""}" data-avail-id="${esc(row.id)}"${disabled} aria-label="Availability for ${esc(row.label)}">
5417
+ ${option("open", "Open \u2014 on sale")}
5418
+ ${option("closed", "Closed \u2014 visible, not on sale")}
5419
+ ${option("hidden", "Hidden \u2014 off the buyer map")}
5420
+ ${option("timed", "Reveal at a time")}
5421
+ ${option("threshold", "Auto-reveal at % sold")}
5422
+ </select>
5423
+ </span>`;
5424
+ let detail = "";
5425
+ if (!row.followsZone && mode === "timed") {
5426
+ const value = row.rule?.revealAt ? esc(toLocalInput(row.rule.revealAt)) : "";
5427
+ detail = `<div class="slm-availdetail">
5428
+ <input type="datetime-local" class="slm-input" data-avail-reveal="${esc(row.id)}" value="${value}"${disabled} aria-label="Reveal time for ${esc(row.label)}" />
5429
+ </div>`;
5430
+ } else if (!row.followsZone && mode === "threshold") {
5431
+ const pct = row.rule?.thresholdPct ?? 80;
5432
+ detail = `<div class="slm-availdetail">
5433
+ <span class="slm-availpctlabel">Reveal at</span>
5434
+ <input type="number" min="1" max="100" class="slm-input slm-availpct" data-avail-pct="${esc(row.id)}" value="${esc(pct)}"${disabled} aria-label="Percent sold to reveal ${esc(row.label)}" />
5435
+ <span class="slm-availpctlabel">% sold</span>
5436
+ </div>`;
5437
+ }
5438
+ const badge = row.closed ? '<span class="slm-availbadge closed">Closed</span>' : row.hidden ? '<span class="slm-availbadge hidden">Hidden</span>' : "";
5439
+ const caret = row.kind === "zone" ? `<span class="slm-availcaret" aria-hidden="true">${row.hidden ? "\u25B8" : "\u25BE"}</span>` : "";
5440
+ return `<div class="${cls}">
5441
+ <div class="slm-availhead">
5442
+ <span class="slm-availlabel">${caret}${esc(row.label)}</span>
5443
+ ${badge}
5444
+ <span class="slm-availcount">${row.seatCount.toLocaleString()}</span>
5445
+ ${control}
5446
+ </div>
5447
+ ${detail}
5448
+ </div>`;
5449
+ }
5450
+ wireSectionRail() {
5451
+ const rail = this.els.rail;
5452
+ if (!rail) return;
5453
+ rail.querySelectorAll("[data-avail-id]").forEach((select) => {
5454
+ select.addEventListener("change", () => this.setSectionMode(select.dataset.availId, select.value));
5455
+ });
5456
+ rail.querySelectorAll("[data-avail-reveal]").forEach((input) => {
5457
+ input.addEventListener("change", () => {
5458
+ const ms = new Date(input.value).getTime();
5459
+ if (Number.isFinite(ms)) this.setSectionRulePatch(input.dataset.availReveal, { revealAt: ms });
5460
+ });
5461
+ });
5462
+ rail.querySelectorAll("[data-avail-pct]").forEach((input) => {
5463
+ input.addEventListener("change", () => {
5464
+ const pct = Math.max(1, Math.min(100, Number(input.value) || 0));
5465
+ this.setSectionRulePatch(input.dataset.availPct, { thresholdPct: pct });
5466
+ });
5467
+ });
5468
+ }
5469
+ /** Change one row's availability mode. A zone rule subsumes its child section
5470
+ * rules, so those are dropped from the map (the zone window is the truth). */
5471
+ setSectionMode(id, mode) {
5472
+ const row = this.buildSectionRows().rows.find((r) => r.id === id);
5473
+ const seatLabels = row?.seatLabels ?? this.availabilityRules[id]?.labels ?? [];
5474
+ const next = { ...this.availabilityRules };
5475
+ const rule = availabilityRuleForMode(mode, seatLabels, this.availabilityRules[id]);
5476
+ if (rule) next[id] = rule;
5477
+ else delete next[id];
5478
+ if (row?.kind === "zone" && this.sectionsBase) {
5479
+ for (const s of this.sectionsBase.sections) if (s.zone === id) delete next[s.id];
5480
+ }
5481
+ void this.persistAvailability(next);
5482
+ }
5483
+ /** Edit a timed reveal time / threshold percent on an existing row rule. */
5484
+ setSectionRulePatch(id, patch) {
5485
+ const cur = this.availabilityRules[id];
5486
+ if (!cur) return;
5487
+ const row = this.buildSectionRows().rows.find((r) => r.id === id);
5488
+ const labels = row?.seatLabels ?? cur.labels ?? [];
5489
+ void this.persistAvailability({ ...this.availabilityRules, [id]: { ...cur, ...patch, labels } });
5490
+ }
5491
+ /** Optimistically adopt the new rules, then reconcile with the server-cleaned
5492
+ * map + effective hidden/closed sets. Rolls back the rules on failure. */
5493
+ async persistAvailability(next) {
5494
+ const prev = this.availabilityRules;
5495
+ this.availabilityRules = next;
5496
+ this.availabilitySaving = true;
5497
+ if (this.mode === "sections") this.renderSectionsRail();
5498
+ try {
5499
+ const res = await this.withAuthRetry(() => this.api.setAvailability(this.key, next));
5500
+ this.availabilityRules = res.rules;
5501
+ this.effectiveHidden = new Set(res.hidden);
5502
+ this.effectiveClosed = new Set(this.closedIdsFromRules(res.rules));
5503
+ this.availabilitySaving = false;
5504
+ if (this.mode === "sections") this.renderSectionsRail();
5505
+ this.applySectionCanvasTreatment();
5506
+ } catch (err) {
5507
+ this.availabilityRules = prev;
5508
+ this.availabilitySaving = false;
5509
+ if (this.mode === "sections") this.renderSectionsRail();
5510
+ this.toastErr("Couldn't update availability. Try again.");
5511
+ this.opts.onError?.(err);
5512
+ }
5513
+ }
4570
5514
  paintLegend(t3) {
4571
5515
  if (!this.els.legend) return;
4572
5516
  this.els.legend.innerHTML = LEGEND.map((l) => `<div class="slm-legrow"><span class="slm-legdot" style="background:${l.color}"></span>
@@ -4879,6 +5823,7 @@ export {
4879
5823
  ManageApiError,
4880
5824
  SeatManager,
4881
5825
  SeatPicker,
4882
- SeatingChart
5826
+ SeatingChart,
5827
+ attachPickerFrame
4883
5828
  };
4884
5829
  //# sourceMappingURL=index.js.map