@seatlayer/js 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -86,6 +86,14 @@ var PubApi = class {
86
86
  body: { labels, holdId }
87
87
  });
88
88
  }
89
+ /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
90
+ * (reason: expired | extend_limit | not_found | not_active) if it can't. */
91
+ extend(key, holdId, ttlMs) {
92
+ return request(this.base, `/pub/events/${encodeURIComponent(key)}/extend`, {
93
+ method: "POST",
94
+ body: { holdId, ...ttlMs ? { ttlMs } : {} }
95
+ });
96
+ }
89
97
  socketUrl(key) {
90
98
  const wsBase = this.base.replace(/^http/, "ws");
91
99
  return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe`;
@@ -407,6 +415,7 @@ var EmbeddedDesigner = class {
407
415
  var import_core2 = require("@seatlayer/core");
408
416
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
409
417
  var DEFAULT_MAX_SELECTION2 = 10;
418
+ var EXTEND_PROMPT_MS = 6e4;
410
419
  function resolveContainer3(container) {
411
420
  if (typeof container === "string") {
412
421
  const el = document.querySelector(container);
@@ -520,6 +529,30 @@ var CSS = `
520
529
  .sl-boot-retry{margin-top:4px;padding:9px 20px;border-radius:var(--sl-r-sm);background:var(--sl-accent);
521
530
  color:var(--sl-accent-ink);font-weight:700;font-size:13px}
522
531
 
532
+ /* "Need more time?" extend prompt (bottom-center over the map, above the toast) */
533
+ .sl-extend{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(6px);z-index:9;
534
+ display:none;align-items:center;gap:12px;max-width:92%;background:var(--sl-surface);border:1px solid var(--sl-line);
535
+ color:var(--sl-text);border-radius:14px;padding:10px 12px 10px 16px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);
536
+ opacity:0;transition:opacity .2s,transform .2s}
537
+ .sl-extend.on{display:flex;opacity:1;transform:translateX(-50%) translateY(0)}
538
+ .sl-extend-txt{font-size:12.5px;font-weight:600;line-height:1.35}
539
+ .sl-extend-txt b{font-variant-numeric:tabular-nums}
540
+ .sl-extend-btn{flex:none;padding:8px 14px;border-radius:999px;font-weight:800;font-size:12.5px;
541
+ background:var(--sl-accent);color:var(--sl-accent-ink);transition:filter .15s,opacity .15s}
542
+ .sl-extend-btn:hover{filter:brightness(1.08)}
543
+ .sl-extend-btn:disabled{opacity:.5;cursor:not-allowed}
544
+
545
+ /* booked confirmation overlay (covers the widget once the held seats are sold) */
546
+ .sl-booked{position:absolute;inset:0;z-index:11;display:none;flex-direction:column;align-items:center;
547
+ justify-content:center;gap:12px;text-align:center;padding:28px;background:var(--sl-bg)}
548
+ .sl-booked.on{display:flex}
549
+ .sl-booked-badge{width:60px;height:60px;border-radius:999px;display:flex;align-items:center;justify-content:center;
550
+ background:var(--sl-accent);color:var(--sl-accent-ink)}
551
+ .sl-booked-badge svg{width:30px;height:30px;stroke:currentColor;stroke-width:2.6;fill:none;stroke-linecap:round;stroke-linejoin:round}
552
+ .sl-booked-title{font-weight:800;font-size:19px;color:var(--sl-text)}
553
+ .sl-booked-sub{font-size:13px;color:var(--sl-muted);line-height:1.5;max-width:320px}
554
+ .sl-booked-seats{font-weight:700;color:var(--sl-text)}
555
+
523
556
  /* a11y filter chips (over the map, top-left) */
524
557
  .sl-chips{position:absolute;top:12px;left:12px;z-index:5;display:flex;gap:6px;flex-wrap:wrap;max-width:70%}
525
558
  .sl-chip-f{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;font-size:12px;font-weight:700;
@@ -667,6 +700,14 @@ var SeatPicker = class _SeatPicker {
667
700
  // state
668
701
  this.currency = "USD";
669
702
  this.hold = null;
703
+ /** Latest server expiry for the open hold (moves on extend). */
704
+ this.holdExpiresAt = 0;
705
+ /** True once we handed off to checkout — arms booked-confirmation detection. */
706
+ this.handedOff = false;
707
+ /** Guards single onBooked + single success overlay per hold. */
708
+ this.bookedShown = false;
709
+ this.extendEl = null;
710
+ this.bookedEl = null;
670
711
  this.gaQty = /* @__PURE__ */ new Map();
671
712
  this.tipEl = null;
672
713
  this.tipPos = { x: 0, y: 0 };
@@ -705,9 +746,12 @@ var SeatPicker = class _SeatPicker {
705
746
  onStatusChange: () => {
706
747
  this.syncPrices();
707
748
  this.evictTakenSelections();
749
+ this.detectBooked();
708
750
  },
709
751
  onHoldExpired: () => {
710
752
  this.hold = null;
753
+ this.handedOff = false;
754
+ this.bookedShown = false;
711
755
  this.stopHoldTimer();
712
756
  this.gaQty.clear();
713
757
  this.toast((0, import_core2.t)("picker.holdExpired", void 0) || "Your hold expired \u2014 seats released. Pick again.");
@@ -916,10 +960,36 @@ var SeatPicker = class _SeatPicker {
916
960
  this.srEl.setAttribute("aria-live", "polite");
917
961
  root.appendChild(this.srEl);
918
962
  this.buildArenaChrome();
963
+ this.buildExtendPrompt();
964
+ this.buildBookedOverlay();
919
965
  this.syncPrices();
920
966
  this.syncTray();
921
967
  return this;
922
968
  }
969
+ /** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
970
+ buildExtendPrompt() {
971
+ const el = document.createElement("div");
972
+ el.className = "sl-extend";
973
+ el.setAttribute("role", "status");
974
+ el.innerHTML = `<span class="sl-extend-txt" data-ref="extendTxt"></span><button type="button" class="sl-extend-btn" data-ref="extendBtn"></button>`;
975
+ this.els.map.appendChild(el);
976
+ this.extendEl = el;
977
+ this.els.extendTxt = el.querySelector('[data-ref="extendTxt"]');
978
+ this.els.extendBtn = el.querySelector('[data-ref="extendBtn"]');
979
+ this.els.extendBtn.textContent = "Add time";
980
+ this.els.extendBtn.addEventListener("click", () => void this.handleExtend());
981
+ }
982
+ /** Success overlay + onBooked fire when the held seats settle to booked. */
983
+ buildBookedOverlay() {
984
+ const el = document.createElement("div");
985
+ el.className = "sl-booked";
986
+ el.setAttribute("role", "status");
987
+ el.setAttribute("aria-live", "polite");
988
+ el.innerHTML = `<div class="sl-booked-badge"><svg viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg></div><div class="sl-booked-title">You're all set</div><div class="sl-booked-sub" data-ref="bookedSub"></div>`;
989
+ this.root.appendChild(el);
990
+ this.bookedEl = el;
991
+ this.els.bookedSub = el.querySelector('[data-ref="bookedSub"]');
992
+ }
923
993
  // ---- arena / multi-floor chrome -------------------------------------------
924
994
  /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
925
995
  buildArenaChrome() {
@@ -1298,7 +1368,9 @@ var SeatPicker = class _SeatPicker {
1298
1368
  async handleCta() {
1299
1369
  const cta = this.els.cta;
1300
1370
  if (this.hold && !this.controller.getSelection().some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
1301
- this.opts.onCheckout?.(this.hold, this.hold.seats ?? this.controller.getSelection());
1371
+ const seats = this.hold.seats ?? this.controller.getSelection();
1372
+ this.handedOff = true;
1373
+ this.opts.onCheckout?.(this.hold, seats, this.buildHandoff(this.hold));
1302
1374
  return;
1303
1375
  }
1304
1376
  cta.disabled = true;
@@ -1321,8 +1393,9 @@ var SeatPicker = class _SeatPicker {
1321
1393
  return;
1322
1394
  }
1323
1395
  this.hold = hold;
1396
+ this.handedOff = true;
1324
1397
  this.startHoldTimer(hold.expiresAt);
1325
- this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? []);
1398
+ this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? [], this.buildHandoff(hold));
1326
1399
  } catch (err) {
1327
1400
  this.opts.onError?.(err);
1328
1401
  this.toast("One or more seats were just taken. Please pick again.");
@@ -1332,13 +1405,15 @@ var SeatPicker = class _SeatPicker {
1332
1405
  }
1333
1406
  startHoldTimer(expiresAt) {
1334
1407
  this.stopHoldTimer();
1408
+ this.holdExpiresAt = expiresAt;
1335
1409
  const pill = this.els.hold;
1336
1410
  const tick = () => {
1337
- const ms = Math.max(0, expiresAt - Date.now());
1411
+ const ms = Math.max(0, this.holdExpiresAt - Date.now());
1338
1412
  const m = Math.floor(ms / 6e4);
1339
1413
  const s = String(Math.floor(ms % 6e4 / 1e3)).padStart(2, "0");
1340
1414
  pill.textContent = `Held ${m}:${s}`;
1341
1415
  pill.classList.add("on");
1416
+ this.setExtendPrompt(ms > 0 && ms <= EXTEND_PROMPT_MS, ms);
1342
1417
  if (ms <= 0) this.stopHoldTimer();
1343
1418
  };
1344
1419
  tick();
@@ -1348,6 +1423,83 @@ var SeatPicker = class _SeatPicker {
1348
1423
  if (this.holdTimer) clearInterval(this.holdTimer);
1349
1424
  this.holdTimer = null;
1350
1425
  this.els.hold?.classList.remove("on");
1426
+ this.setExtendPrompt(false, 0);
1427
+ }
1428
+ /** Show/refresh (or hide) the "Need more time?" prompt with the live seconds left. */
1429
+ setExtendPrompt(show, ms) {
1430
+ if (!this.extendEl) return;
1431
+ if (show && this.controller.currentHold() && !this.bookedShown) {
1432
+ const secs = Math.ceil(ms / 1e3);
1433
+ this.els.extendTxt.innerHTML = `Your seats are held for <b>0:${String(secs).padStart(2, "0")}</b>. Need more time?`;
1434
+ this.extendEl.classList.add("on");
1435
+ } else {
1436
+ this.extendEl.classList.remove("on");
1437
+ }
1438
+ }
1439
+ async handleExtend() {
1440
+ const btn = this.els.extendBtn;
1441
+ btn.disabled = true;
1442
+ const prev = btn.textContent;
1443
+ btn.textContent = "Adding\u2026";
1444
+ try {
1445
+ const h = await this.controller.extendHold(this.opts.holdTtlMs);
1446
+ if (h) {
1447
+ this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1448
+ this.holdExpiresAt = h.expiresAt;
1449
+ this.extendEl?.classList.remove("on");
1450
+ this.toast("More time added \u2014 your seats are still held.");
1451
+ } else {
1452
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1453
+ }
1454
+ } catch (err) {
1455
+ this.opts.onError?.(err);
1456
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1457
+ } finally {
1458
+ btn.disabled = false;
1459
+ btn.textContent = prev;
1460
+ }
1461
+ }
1462
+ /**
1463
+ * Fire the booked-confirmation state once the buyer's held seats settle to
1464
+ * booked. The controller clears its own hold the moment every held label reads
1465
+ * 'booked' over the realtime channel (clearBookedHoldIfSettled), and this runs
1466
+ * on the same onStatusChange — so `currentHold() === null` while we still hold
1467
+ * a checkout handoff means "sold", not expired (expiry clears via onHoldExpired
1468
+ * on a different path, which nulls this.hold first).
1469
+ */
1470
+ detectBooked() {
1471
+ if (this.bookedShown || !this.handedOff || !this.hold) return;
1472
+ if (this.controller.currentHold() !== null) return;
1473
+ this.showBooked();
1474
+ }
1475
+ showBooked() {
1476
+ if (this.bookedShown || !this.hold) return;
1477
+ this.bookedShown = true;
1478
+ const handoff = this.buildHandoff(this.hold);
1479
+ this.stopHoldTimer();
1480
+ const n = handoff.lineItems.reduce((sum, i) => sum + i.quantity, 0);
1481
+ if (this.els.bookedSub) {
1482
+ this.els.bookedSub.innerHTML = `<span class="sl-booked-seats">${n} ${n === 1 ? "ticket" : "tickets"}</span> confirmed. A confirmation is on its way.`;
1483
+ }
1484
+ this.bookedEl?.classList.add("on");
1485
+ this.opts.onBooked?.(handoff);
1486
+ }
1487
+ /** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
1488
+ buildHandoff(hold) {
1489
+ const items = hold.items ?? [];
1490
+ const lineItems = items.map((it) => ({
1491
+ label: it.label,
1492
+ objectId: it.objectId,
1493
+ objectType: it.objectType,
1494
+ categoryKey: it.categoryKey,
1495
+ tierId: it.tierId,
1496
+ unitPrice: it.unitPrice,
1497
+ currency: it.currency ?? this.currency,
1498
+ quantity: it.quantity ?? 1
1499
+ }));
1500
+ const currency = lineItems[0]?.currency ?? this.currency;
1501
+ const total = lineItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
1502
+ return { holdId: hold.holdId, expiresAt: hold.expiresAt, currency, lineItems, total };
1351
1503
  }
1352
1504
  toast(msg) {
1353
1505
  const el = this.els.toast;
@@ -1389,6 +1541,8 @@ var SeatPicker = class _SeatPicker {
1389
1541
  const h = await this.controller.bestAvailable(qty, categoryKey);
1390
1542
  if (h) {
1391
1543
  this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1544
+ this.handedOff = false;
1545
+ this.bookedShown = false;
1392
1546
  this.startHoldTimer(h.expiresAt);
1393
1547
  this.syncTray();
1394
1548
  return this.hold;
@@ -1402,6 +1556,8 @@ var SeatPicker = class _SeatPicker {
1402
1556
  async release() {
1403
1557
  await this.controller.release();
1404
1558
  this.hold = null;
1559
+ this.handedOff = false;
1560
+ this.bookedShown = false;
1405
1561
  this.stopHoldTimer();
1406
1562
  this.gaQty.clear();
1407
1563
  this.syncTray();