@seatlayer/js 0.22.0 → 0.24.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
@@ -172,8 +172,28 @@ var SeatingChart = class {
172
172
  ribbon.style.cssText = "position:absolute;top:18px;right:-34px;z-index:6;transform:rotate(45deg);width:140px;text-align:center;padding:4px 0;background:#f4b740;color:#1a1200;font:800 10.5px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;letter-spacing:.12em;box-shadow:0 2px 8px rgba(0,0,0,.25);pointer-events:none;";
173
173
  host.appendChild(ribbon);
174
174
  }
175
+ this.buildBadge(host);
175
176
  return this;
176
177
  }
178
+ /**
179
+ * Attribution badge pinned to the embed's bottom-right, linking to
180
+ * seatlayer.io. Rendered as an absolutely-positioned overlay with
181
+ * self-contained inline styles — the SDK embed ships no widget CSS, and an
182
+ * overlay keeps it out of the layout flow so it never disturbs the SDK v0.22
183
+ * fill-height resize contract. Mirrors the full widget's mark + wordmark and
184
+ * reuses the `picker.poweredBy` i18n string.
185
+ */
186
+ buildBadge(host) {
187
+ if (this.controller.doc?.theme?.hideBadge) return;
188
+ const badge = document.createElement("a");
189
+ badge.href = "https://seatlayer.io";
190
+ badge.target = "_blank";
191
+ badge.rel = "noopener noreferrer";
192
+ badge.setAttribute("aria-label", t("picker.poweredBy"));
193
+ badge.style.cssText = 'position:absolute;bottom:10px;right:12px;z-index:5;display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;background:rgba(255,255,255,.92);color:#4a5163;text-decoration:none;font:600 11px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.02em;box-shadow:0 2px 8px rgba(0,0,0,.12);';
194
+ badge.innerHTML = `<span aria-hidden="true" style="width:16px;height:16px;border-radius:4px;flex:none;display:flex;align-items:center;justify-content:center;background:#f4b740;color:#1a1200"><svg viewBox="0 0 24 24" style="width:11px;height:11px;fill:currentColor"><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>${t("picker.poweredBy")}</span>`;
195
+ host.appendChild(badge);
196
+ }
177
197
  placeTooltip() {
178
198
  if (!this.tipEl || !this.hostEl) return;
179
199
  const hw = this.hostEl.clientWidth;
@@ -328,6 +348,13 @@ var TYPES = /* @__PURE__ */ new Set([
328
348
  ]);
329
349
  var DEFAULT_LOADING_TIMEOUT_MS = 2e4;
330
350
  var DEFAULT_MIN_FILL_HEIGHT = 480;
351
+ var RENEW_LEAD_MS = 3 * 60 * 1e3;
352
+ var RENEW_SHORT_TTL_MS = 15 * 60 * 1e3;
353
+ var RENEW_SHORT_TTL_FRACTION = 0.8;
354
+ var RENEW_MIN_DELAY_MS = 30 * 1e3;
355
+ var FILL_PROBE_HEIGHT_PX = 1e5;
356
+ var FILL_PROBE_TRACK_EPSILON_PX = 4;
357
+ var FILL_MIN_DEFINITE_HEIGHT_PX = 50;
331
358
  function resolveContainer2(container) {
332
359
  if (typeof container !== "string") return container;
333
360
  const element = document.querySelector(container);
@@ -365,6 +392,14 @@ var EmbeddedDesigner = class {
365
392
  this.designerOrigin = "";
366
393
  this.overlay = null;
367
394
  this.timeoutTimer = null;
395
+ /** Proactive session-renewal timer; armed from each `ready`, cleared on re-mount. */
396
+ this.renewTimer = null;
397
+ /**
398
+ * One automatic recovery relaunch is allowed per expiry. Reset ONLY when a fresh
399
+ * `ready` arrives — deliberately not on re-mount — so a session that keeps failing
400
+ * to load can't loop the host through endless silent relaunches.
401
+ */
402
+ this.autoRecoverUsed = false;
368
403
  this.phase = "loading";
369
404
  this.restoreContainerPosition = null;
370
405
  // Host-side fullscreen pin: saved state we restore on `off`/Escape/destroy.
@@ -375,10 +410,17 @@ var EmbeddedDesigner = class {
375
410
  this.fsKeyHandler = null;
376
411
  /** Latest height (px string) the Designer reported; re-applied after unpin. */
377
412
  this.lastAutoHeight = "";
378
- // Viewport-fill sizing: pending rAF handle + whether listeners are attached.
413
+ // Fill sizing: pending rAF handles + whether window listeners are attached.
379
414
  this.fillRaf = null;
415
+ this.reprobeRaf = null;
380
416
  this.fillListening = false;
381
- /** rAF-throttled fill recompute, so a burst of scroll/resize ticks coalesces. */
417
+ /** Resolved container element (fill measurement + ResizeObserver target). */
418
+ this.containerEl = null;
419
+ /** Cached fill verdict: 'container' = bounded block, 'viewport' = full page. */
420
+ this.fillMode = null;
421
+ /** Live block-size tracking in container-fill mode; disconnected on destroy. */
422
+ this.resizeObs = null;
423
+ /** rAF-throttled fill recompute, so a burst of scroll/RO ticks coalesces. */
382
424
  this.scheduleFill = () => {
383
425
  if (this.fillRaf !== null) return;
384
426
  this.fillRaf = requestAnimationFrame(() => {
@@ -386,6 +428,21 @@ var EmbeddedDesigner = class {
386
428
  this.applyFill();
387
429
  });
388
430
  };
431
+ /**
432
+ * rAF-throttled re-probe: a host layout change (responsive breakpoint, a block
433
+ * gaining/losing a definite height) can flip the verdict, so `resize` /
434
+ * `orientationchange` re-detect and swap the container observer accordingly.
435
+ */
436
+ this.scheduleReprobe = () => {
437
+ if (this.reprobeRaf !== null) return;
438
+ this.reprobeRaf = requestAnimationFrame(() => {
439
+ this.reprobeRaf = null;
440
+ if (this.pinned) return;
441
+ this.fillMode = this.detectFillMode();
442
+ this.syncContainerObserver();
443
+ this.applyFill();
444
+ });
445
+ };
389
446
  this.handleMessage = (event) => {
390
447
  if (!this.frame || event.origin !== this.designerOrigin || event.source !== this.frame.contentWindow) return;
391
448
  if (!event.data || typeof event.data !== "object") return;
@@ -393,7 +450,7 @@ var EmbeddedDesigner = class {
393
450
  if (data.type === "seatlayer.designer.resize") {
394
451
  if (!this.fillEnabled() && this.autoResizeEnabled() && typeof data.px === "number" && Number.isFinite(data.px) && data.px > 0) {
395
452
  this.lastAutoHeight = `${Math.round(data.px)}px`;
396
- if (!this.pinned) this.frame.style.height = this.lastAutoHeight;
453
+ if (!this.pinned) this.setFrameHeight(this.lastAutoHeight);
397
454
  }
398
455
  return;
399
456
  }
@@ -421,6 +478,8 @@ var EmbeddedDesigner = class {
421
478
  this.phase = "ready";
422
479
  this.clearTimeoutTimer();
423
480
  this.removeOverlay();
481
+ this.autoRecoverUsed = false;
482
+ this.scheduleRenewal(message.expiresAt);
424
483
  this.options.onReady?.(message);
425
484
  break;
426
485
  case "seatlayer.designer.saved":
@@ -432,10 +491,18 @@ var EmbeddedDesigner = class {
432
491
  case "seatlayer.designer.close":
433
492
  this.options.onClose?.(message);
434
493
  break;
435
- case "seatlayer.designer.error":
436
- this.showError(causeFromCode(message.code));
494
+ case "seatlayer.designer.error": {
495
+ const cause = causeFromCode(message.code);
496
+ if (cause === "expired" && this.autoRenewEnabled() && !this.autoRecoverUsed) {
497
+ this.autoRecoverUsed = true;
498
+ this.clearRenewTimer();
499
+ this.options.onRequestRelaunch();
500
+ return;
501
+ }
502
+ this.showError(cause);
437
503
  this.options.onError?.(message);
438
504
  break;
505
+ }
439
506
  }
440
507
  };
441
508
  this.options = options;
@@ -452,12 +519,17 @@ var EmbeddedDesigner = class {
452
519
  frame.allow = this.options.allow ?? "fullscreen; clipboard-write";
453
520
  frame.referrerPolicy = this.options.referrerPolicy ?? "origin";
454
521
  frame.src = url.toString();
455
- frame.style.width = "100%";
456
- frame.style.height = typeof this.options.height === "number" ? `${this.options.height}px` : "100%";
522
+ frame.style.setProperty("width", "100%", "important");
523
+ frame.style.setProperty(
524
+ "height",
525
+ typeof this.options.height === "number" ? `${this.options.height}px` : "100%",
526
+ "important"
527
+ );
457
528
  frame.style.border = "0";
458
529
  Object.assign(frame.style, this.options.style);
459
530
  if (this.options.className) frame.className = this.options.className;
460
531
  const container = resolveContainer2(this.options.container);
532
+ this.containerEl = container;
461
533
  window.addEventListener("message", this.handleMessage);
462
534
  container.append(frame);
463
535
  this.frame = frame;
@@ -488,10 +560,13 @@ var EmbeddedDesigner = class {
488
560
  this.stopFill();
489
561
  this.unpinFullscreen();
490
562
  this.clearTimeoutTimer();
563
+ this.clearRenewTimer();
491
564
  this.removeOverlay();
492
565
  this.restoreContainerStyle();
493
566
  this.frame?.remove();
494
567
  this.frame = null;
568
+ this.containerEl = null;
569
+ this.fillMode = null;
495
570
  this.designerOrigin = "";
496
571
  this.phase = "loading";
497
572
  this.lastAutoHeight = "";
@@ -506,24 +581,77 @@ var EmbeddedDesigner = class {
506
581
  fillEnabled() {
507
582
  return typeof this.options.height !== "number";
508
583
  }
584
+ /** Write an SDK-managed height with `!important` so a host theme can't win. */
585
+ setFrameHeight(value) {
586
+ this.frame?.style.setProperty("height", value, "important");
587
+ }
509
588
  /**
510
- * Size the iframe so its bottom edge meets the bottom of the viewport
511
- * (`window.innerHeight - top`), clamped to `minHeight`. No-op while pinned
512
- * fullscreen (the pin fills the viewport itself).
589
+ * Decide whether the host gave the container a DEFINITE (bounded) height a
590
+ * fixed block the embed should fill 100% of — versus a content-sized container
591
+ * that collapses to whatever the iframe measures (full-page usage).
592
+ *
593
+ * We drive the iframe to two extreme heights within a single synchronous task
594
+ * and watch whether the container follows: a bounded box barely moves, a
595
+ * content-sized one grows with the iframe. Because we restore the height before
596
+ * yielding, the browser only lays out — it never paints the extremes, so there
597
+ * is no visible flash. Works for px, resolved `%`, and flex (`flex:1;min-h:0`)
598
+ * heights, and leaves a mere `min-height` floor classified as content-sized so
599
+ * full-page hosts keep the old viewport-fill behavior.
600
+ */
601
+ detectFillMode() {
602
+ const container = this.containerEl;
603
+ const frame = this.frame;
604
+ if (this.pinned || !container || !frame) return this.fillMode ?? "viewport";
605
+ const measure = () => container.getBoundingClientRect().height;
606
+ const savedValue = frame.style.getPropertyValue("height");
607
+ const savedPriority = frame.style.getPropertyPriority("height");
608
+ frame.style.setProperty("height", "0px", "important");
609
+ const collapsed = measure();
610
+ frame.style.setProperty("height", `${FILL_PROBE_HEIGHT_PX}px`, "important");
611
+ const expanded = measure();
612
+ if (savedValue) frame.style.setProperty("height", savedValue, savedPriority);
613
+ else frame.style.removeProperty("height");
614
+ const tracksIframe = expanded - collapsed > FILL_PROBE_TRACK_EPSILON_PX;
615
+ const bounded = !tracksIframe && collapsed >= FILL_MIN_DEFINITE_HEIGHT_PX;
616
+ return bounded ? "container" : "viewport";
617
+ }
618
+ /**
619
+ * Size the iframe for the current fill verdict, clamped to `minHeight`. In
620
+ * container mode it fills 100% of the bounded block; in viewport mode its
621
+ * bottom edge meets the bottom of the viewport (`window.innerHeight - top`).
622
+ * No-op while pinned fullscreen (the pin fills the viewport itself).
513
623
  */
514
624
  applyFill() {
515
625
  if (!this.frame || this.pinned) return;
516
626
  const min = this.options.minHeight ?? DEFAULT_MIN_FILL_HEIGHT;
627
+ if (this.fillMode === "container" && this.containerEl) {
628
+ const target2 = Math.max(min, Math.round(this.containerEl.getBoundingClientRect().height));
629
+ this.setFrameHeight(`${target2}px`);
630
+ return;
631
+ }
517
632
  const top = this.frame.getBoundingClientRect().top;
518
633
  const target = Math.max(min, Math.round(window.innerHeight - top));
519
- this.frame.style.height = `${target}px`;
634
+ this.setFrameHeight(`${target}px`);
635
+ }
636
+ /** Attach/detach the container ResizeObserver to match the current verdict. */
637
+ syncContainerObserver() {
638
+ const want = this.fillMode === "container" && !!this.containerEl && typeof ResizeObserver !== "undefined";
639
+ if (want && !this.resizeObs) {
640
+ this.resizeObs = new ResizeObserver(() => this.scheduleFill());
641
+ this.resizeObs.observe(this.containerEl);
642
+ } else if (!want && this.resizeObs) {
643
+ this.resizeObs.disconnect();
644
+ this.resizeObs = null;
645
+ }
520
646
  }
521
647
  startFill() {
648
+ this.fillMode = this.detectFillMode();
649
+ this.syncContainerObserver();
522
650
  this.applyFill();
523
651
  if (this.fillListening) return;
524
652
  this.fillListening = true;
525
- window.addEventListener("resize", this.scheduleFill);
526
- window.addEventListener("orientationchange", this.scheduleFill);
653
+ window.addEventListener("resize", this.scheduleReprobe);
654
+ window.addEventListener("orientationchange", this.scheduleReprobe);
527
655
  window.addEventListener("scroll", this.scheduleFill, { passive: true });
528
656
  }
529
657
  stopFill() {
@@ -531,10 +659,18 @@ var EmbeddedDesigner = class {
531
659
  cancelAnimationFrame(this.fillRaf);
532
660
  this.fillRaf = null;
533
661
  }
662
+ if (this.reprobeRaf !== null) {
663
+ cancelAnimationFrame(this.reprobeRaf);
664
+ this.reprobeRaf = null;
665
+ }
666
+ if (this.resizeObs) {
667
+ this.resizeObs.disconnect();
668
+ this.resizeObs = null;
669
+ }
534
670
  if (!this.fillListening) return;
535
671
  this.fillListening = false;
536
- window.removeEventListener("resize", this.scheduleFill);
537
- window.removeEventListener("orientationchange", this.scheduleFill);
672
+ window.removeEventListener("resize", this.scheduleReprobe);
673
+ window.removeEventListener("orientationchange", this.scheduleReprobe);
538
674
  window.removeEventListener("scroll", this.scheduleFill);
539
675
  }
540
676
  /**
@@ -546,16 +682,22 @@ var EmbeddedDesigner = class {
546
682
  if (this.pinned || !this.frame) return;
547
683
  this.pinned = true;
548
684
  this.frameStyleBeforeFs = this.frame.getAttribute("style");
549
- Object.assign(this.frame.style, {
685
+ const pin = {
550
686
  position: "fixed",
551
- inset: "0",
687
+ top: "0",
688
+ right: "0",
689
+ bottom: "0",
690
+ left: "0",
552
691
  width: "100vw",
553
692
  height: "100vh",
554
693
  margin: "0",
555
694
  border: "0",
556
- zIndex: "2147483000",
695
+ "z-index": "2147483000",
557
696
  background: "#101625"
558
- });
697
+ };
698
+ for (const [property, value] of Object.entries(pin)) {
699
+ this.frame.style.setProperty(property, value, "important");
700
+ }
559
701
  const docEl = document.documentElement;
560
702
  this.docOverflowBeforeFs = docEl.style.overflow;
561
703
  docEl.style.overflow = "hidden";
@@ -576,7 +718,7 @@ var EmbeddedDesigner = class {
576
718
  if (this.frameStyleBeforeFs === null) this.frame.removeAttribute("style");
577
719
  else this.frame.setAttribute("style", this.frameStyleBeforeFs);
578
720
  if (this.fillEnabled()) this.applyFill();
579
- else if (this.autoResizeEnabled() && this.lastAutoHeight) this.frame.style.height = this.lastAutoHeight;
721
+ else if (this.autoResizeEnabled() && this.lastAutoHeight) this.setFrameHeight(this.lastAutoHeight);
580
722
  }
581
723
  this.frameStyleBeforeFs = null;
582
724
  if (this.docOverflowBeforeFs !== null) {
@@ -598,6 +740,48 @@ var EmbeddedDesigner = class {
598
740
  this.timeoutTimer = null;
599
741
  }
600
742
  }
743
+ /**
744
+ * Auto-renewal (proactive + one expiry recovery) is on when the host wired a
745
+ * relaunch hook and did not opt out. Without the hook there is nothing to call,
746
+ * so it is a no-op.
747
+ */
748
+ autoRenewEnabled() {
749
+ return !!this.options.onRequestRelaunch && this.options.autoRenewSession !== false;
750
+ }
751
+ clearRenewTimer() {
752
+ if (this.renewTimer !== null) {
753
+ clearTimeout(this.renewTimer);
754
+ this.renewTimer = null;
755
+ }
756
+ }
757
+ /**
758
+ * Arm the proactive renewal timer from a `ready` message's `expiresAt` (epoch
759
+ * ms). We relaunch a comfortable lead before expiry so the host can mint a fresh
760
+ * session and swap `designerUrl` without the user ever seeing the expiry card:
761
+ *
762
+ * - normal TTL (≥ 15 min): renew {@link RENEW_LEAD_MS} (~3 min) before expiry;
763
+ * - short TTL (< 15 min): renew after {@link RENEW_SHORT_TTL_FRACTION} (80%) of
764
+ * the remaining life, so the lead can't overshoot the whole session;
765
+ * - either way, never sooner than {@link RENEW_MIN_DELAY_MS} (30s) after `ready`
766
+ * so a burst of `ready` messages can't spin the host.
767
+ *
768
+ * Re-armed on every `ready`; cleared on destroy / setDesignerUrl (via re-mount).
769
+ * A no-op when auto-renewal is off or `expiresAt` is missing/already past — the
770
+ * expiry-error path recovers a session that has already lapsed.
771
+ */
772
+ scheduleRenewal(expiresAt) {
773
+ this.clearRenewTimer();
774
+ if (!this.autoRenewEnabled()) return;
775
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return;
776
+ const remaining = expiresAt - Date.now();
777
+ if (remaining <= 0) return;
778
+ const lead = remaining < RENEW_SHORT_TTL_MS ? remaining * RENEW_SHORT_TTL_FRACTION : remaining - RENEW_LEAD_MS;
779
+ const delay = Math.max(RENEW_MIN_DELAY_MS, lead);
780
+ this.renewTimer = setTimeout(() => {
781
+ this.renewTimer = null;
782
+ if (this.autoRenewEnabled()) this.options.onRequestRelaunch();
783
+ }, delay);
784
+ }
601
785
  ensureContainerPositioned(container) {
602
786
  const position = getComputedStyle(container).position;
603
787
  if (position === "static") {