@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.d.cts CHANGED
@@ -271,6 +271,40 @@ declare class EmbeddedDesigner {
271
271
  * so plain host CSS can restyle too.
272
272
  */
273
273
 
274
+ /**
275
+ * Stable checkout-handoff contract (P4). Passed as the THIRD argument to
276
+ * `onCheckout(hold, seats, handoff)` — additive, so the legacy `(hold, seats)`
277
+ * shape used by DesiPass web-v2 (SDK 0.7.3+) is untouched. This is the object to
278
+ * build your order against: it is self-contained (holdId, expiry, currency, and
279
+ * per-line tier + price) and never changes shape across minor releases.
280
+ */
281
+ interface CheckoutLineItem {
282
+ /** Seat label (or GA synthetic-unit label). */
283
+ label: string;
284
+ /** Chart object id (row/booth/GA area) the unit belongs to. */
285
+ objectId: string;
286
+ objectType: 'seat' | 'booth' | 'ga';
287
+ categoryKey: string;
288
+ /** Chosen ticket tier id (Adult/Child/…), or null when the category has no tiers. */
289
+ tierId: string | null;
290
+ /** Unit price in MAJOR currency units (e.g. 45 = 45.00). Server-authoritative. */
291
+ unitPrice: number;
292
+ /** ISO-4217, resolved server-side (per-event override → org → USD). */
293
+ currency: string;
294
+ quantity: number;
295
+ }
296
+ interface CheckoutHandoff {
297
+ /** Server hold id — pass this to YOUR book call. */
298
+ holdId: string;
299
+ /** Epoch ms the hold expires (after any extensions). */
300
+ expiresAt: number;
301
+ /** ISO-4217 currency for the whole order. */
302
+ currency: string;
303
+ /** Priced line items (tier + unit price + currency), server-authoritative. */
304
+ lineItems: CheckoutLineItem[];
305
+ /** Convenience total in major units (Σ unitPrice × quantity). */
306
+ total: number;
307
+ }
274
308
  /** Host theme overrides — any subset; unset keys fall back to the org's chart theme, then defaults. */
275
309
  interface SeatPickerTheme {
276
310
  /** Brand accent (CTA, active chips, hold pill). */
@@ -332,9 +366,18 @@ interface SeatPickerOptions {
332
366
  seatView?: boolean;
333
367
  /**
334
368
  * Buyer pressed the CTA and the hold succeeded — hand off to YOUR checkout.
335
- * The hold carries holdId, expiresAt, seat labels and priced line items.
369
+ * `hold` and `seats` are the legacy args (unchanged since 0.6). `handoff` (P4)
370
+ * is the stable, self-contained {@link CheckoutHandoff} to build your order
371
+ * against — holdId, expiry, currency and priced line items. Prefer it.
336
372
  */
337
- onCheckout?: (hold: HoldResult, seats: PickerSeat[]) => void;
373
+ onCheckout?: (hold: HoldResult, seats: PickerSeat[], handoff: CheckoutHandoff) => void;
374
+ /**
375
+ * The held seats were BOOKED (P4) — your server completed payment and the
376
+ * booking landed over the realtime channel while the widget was still open.
377
+ * The widget shows a success state; use this to advance your own UI (receipt,
378
+ * redirect). Fires once per hold.
379
+ */
380
+ onBooked?: (handoff: CheckoutHandoff) => void;
338
381
  /** Selection changed (tap or best-available). */
339
382
  onSelectionChange?: (seats: PickerSeat[]) => void;
340
383
  /** The open hold expired server-side (widget already reset itself). */
@@ -356,6 +399,14 @@ declare class SeatPicker {
356
399
  private toastTimer;
357
400
  private currency;
358
401
  private hold;
402
+ /** Latest server expiry for the open hold (moves on extend). */
403
+ private holdExpiresAt;
404
+ /** True once we handed off to checkout — arms booked-confirmation detection. */
405
+ private handedOff;
406
+ /** Guards single onBooked + single success overlay per hold. */
407
+ private bookedShown;
408
+ private extendEl;
409
+ private bookedEl;
359
410
  private gaQty;
360
411
  private tipEl;
361
412
  private tipPos;
@@ -386,6 +437,10 @@ declare class SeatPicker {
386
437
  static open(options: Omit<SeatPickerOptions, 'container'>): Promise<SeatPicker>;
387
438
  constructor(options: SeatPickerOptions);
388
439
  render(): Promise<this>;
440
+ /** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
441
+ private buildExtendPrompt;
442
+ /** Success overlay + onBooked fire when the held seats settle to booked. */
443
+ private buildBookedOverlay;
389
444
  /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
390
445
  private buildArenaChrome;
391
446
  /** Reflect the engine's current LOD rung onto the pill group. */
@@ -418,6 +473,21 @@ declare class SeatPicker {
418
473
  private handleCta;
419
474
  private startHoldTimer;
420
475
  private stopHoldTimer;
476
+ /** Show/refresh (or hide) the "Need more time?" prompt with the live seconds left. */
477
+ private setExtendPrompt;
478
+ private handleExtend;
479
+ /**
480
+ * Fire the booked-confirmation state once the buyer's held seats settle to
481
+ * booked. The controller clears its own hold the moment every held label reads
482
+ * 'booked' over the realtime channel (clearBookedHoldIfSettled), and this runs
483
+ * on the same onStatusChange — so `currentHold() === null` while we still hold
484
+ * a checkout handoff means "sold", not expired (expiry clears via onHoldExpired
485
+ * on a different path, which nulls this.hold first).
486
+ */
487
+ private detectBooked;
488
+ private showBooked;
489
+ /** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
490
+ private buildHandoff;
421
491
  private toast;
422
492
  private placeTooltip;
423
493
  private updateTooltip;
@@ -427,4 +497,4 @@ declare class SeatPicker {
427
497
  destroy(): void;
428
498
  }
429
499
 
430
- export { ApiError, type BestAvailableResult, EmbeddedDesigner, type EmbeddedDesignerEventType, type EmbeddedDesignerMessage, type EmbeddedDesignerOptions, type GAAreaAvailability, type HoldConflict, type HoldLineItem, type HoldResult, SeatPicker, type SeatPickerOptions, type SeatPickerTheme, SeatingChart, type SeatingChartOptions, type SelectedSeat };
500
+ export { ApiError, type BestAvailableResult, type CheckoutHandoff, type CheckoutLineItem, EmbeddedDesigner, type EmbeddedDesignerEventType, type EmbeddedDesignerMessage, type EmbeddedDesignerOptions, type GAAreaAvailability, type HoldConflict, type HoldLineItem, type HoldResult, SeatPicker, type SeatPickerOptions, type SeatPickerTheme, SeatingChart, type SeatingChartOptions, type SelectedSeat };
package/dist/index.d.ts CHANGED
@@ -271,6 +271,40 @@ declare class EmbeddedDesigner {
271
271
  * so plain host CSS can restyle too.
272
272
  */
273
273
 
274
+ /**
275
+ * Stable checkout-handoff contract (P4). Passed as the THIRD argument to
276
+ * `onCheckout(hold, seats, handoff)` — additive, so the legacy `(hold, seats)`
277
+ * shape used by DesiPass web-v2 (SDK 0.7.3+) is untouched. This is the object to
278
+ * build your order against: it is self-contained (holdId, expiry, currency, and
279
+ * per-line tier + price) and never changes shape across minor releases.
280
+ */
281
+ interface CheckoutLineItem {
282
+ /** Seat label (or GA synthetic-unit label). */
283
+ label: string;
284
+ /** Chart object id (row/booth/GA area) the unit belongs to. */
285
+ objectId: string;
286
+ objectType: 'seat' | 'booth' | 'ga';
287
+ categoryKey: string;
288
+ /** Chosen ticket tier id (Adult/Child/…), or null when the category has no tiers. */
289
+ tierId: string | null;
290
+ /** Unit price in MAJOR currency units (e.g. 45 = 45.00). Server-authoritative. */
291
+ unitPrice: number;
292
+ /** ISO-4217, resolved server-side (per-event override → org → USD). */
293
+ currency: string;
294
+ quantity: number;
295
+ }
296
+ interface CheckoutHandoff {
297
+ /** Server hold id — pass this to YOUR book call. */
298
+ holdId: string;
299
+ /** Epoch ms the hold expires (after any extensions). */
300
+ expiresAt: number;
301
+ /** ISO-4217 currency for the whole order. */
302
+ currency: string;
303
+ /** Priced line items (tier + unit price + currency), server-authoritative. */
304
+ lineItems: CheckoutLineItem[];
305
+ /** Convenience total in major units (Σ unitPrice × quantity). */
306
+ total: number;
307
+ }
274
308
  /** Host theme overrides — any subset; unset keys fall back to the org's chart theme, then defaults. */
275
309
  interface SeatPickerTheme {
276
310
  /** Brand accent (CTA, active chips, hold pill). */
@@ -332,9 +366,18 @@ interface SeatPickerOptions {
332
366
  seatView?: boolean;
333
367
  /**
334
368
  * Buyer pressed the CTA and the hold succeeded — hand off to YOUR checkout.
335
- * The hold carries holdId, expiresAt, seat labels and priced line items.
369
+ * `hold` and `seats` are the legacy args (unchanged since 0.6). `handoff` (P4)
370
+ * is the stable, self-contained {@link CheckoutHandoff} to build your order
371
+ * against — holdId, expiry, currency and priced line items. Prefer it.
336
372
  */
337
- onCheckout?: (hold: HoldResult, seats: PickerSeat[]) => void;
373
+ onCheckout?: (hold: HoldResult, seats: PickerSeat[], handoff: CheckoutHandoff) => void;
374
+ /**
375
+ * The held seats were BOOKED (P4) — your server completed payment and the
376
+ * booking landed over the realtime channel while the widget was still open.
377
+ * The widget shows a success state; use this to advance your own UI (receipt,
378
+ * redirect). Fires once per hold.
379
+ */
380
+ onBooked?: (handoff: CheckoutHandoff) => void;
338
381
  /** Selection changed (tap or best-available). */
339
382
  onSelectionChange?: (seats: PickerSeat[]) => void;
340
383
  /** The open hold expired server-side (widget already reset itself). */
@@ -356,6 +399,14 @@ declare class SeatPicker {
356
399
  private toastTimer;
357
400
  private currency;
358
401
  private hold;
402
+ /** Latest server expiry for the open hold (moves on extend). */
403
+ private holdExpiresAt;
404
+ /** True once we handed off to checkout — arms booked-confirmation detection. */
405
+ private handedOff;
406
+ /** Guards single onBooked + single success overlay per hold. */
407
+ private bookedShown;
408
+ private extendEl;
409
+ private bookedEl;
359
410
  private gaQty;
360
411
  private tipEl;
361
412
  private tipPos;
@@ -386,6 +437,10 @@ declare class SeatPicker {
386
437
  static open(options: Omit<SeatPickerOptions, 'container'>): Promise<SeatPicker>;
387
438
  constructor(options: SeatPickerOptions);
388
439
  render(): Promise<this>;
440
+ /** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
441
+ private buildExtendPrompt;
442
+ /** Success overlay + onBooked fire when the held seats settle to booked. */
443
+ private buildBookedOverlay;
389
444
  /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
390
445
  private buildArenaChrome;
391
446
  /** Reflect the engine's current LOD rung onto the pill group. */
@@ -418,6 +473,21 @@ declare class SeatPicker {
418
473
  private handleCta;
419
474
  private startHoldTimer;
420
475
  private stopHoldTimer;
476
+ /** Show/refresh (or hide) the "Need more time?" prompt with the live seconds left. */
477
+ private setExtendPrompt;
478
+ private handleExtend;
479
+ /**
480
+ * Fire the booked-confirmation state once the buyer's held seats settle to
481
+ * booked. The controller clears its own hold the moment every held label reads
482
+ * 'booked' over the realtime channel (clearBookedHoldIfSettled), and this runs
483
+ * on the same onStatusChange — so `currentHold() === null` while we still hold
484
+ * a checkout handoff means "sold", not expired (expiry clears via onHoldExpired
485
+ * on a different path, which nulls this.hold first).
486
+ */
487
+ private detectBooked;
488
+ private showBooked;
489
+ /** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
490
+ private buildHandoff;
421
491
  private toast;
422
492
  private placeTooltip;
423
493
  private updateTooltip;
@@ -427,4 +497,4 @@ declare class SeatPicker {
427
497
  destroy(): void;
428
498
  }
429
499
 
430
- export { ApiError, type BestAvailableResult, EmbeddedDesigner, type EmbeddedDesignerEventType, type EmbeddedDesignerMessage, type EmbeddedDesignerOptions, type GAAreaAvailability, type HoldConflict, type HoldLineItem, type HoldResult, SeatPicker, type SeatPickerOptions, type SeatPickerTheme, SeatingChart, type SeatingChartOptions, type SelectedSeat };
500
+ export { ApiError, type BestAvailableResult, type CheckoutHandoff, type CheckoutLineItem, EmbeddedDesigner, type EmbeddedDesignerEventType, type EmbeddedDesignerMessage, type EmbeddedDesignerOptions, type GAAreaAvailability, type HoldConflict, type HoldLineItem, type HoldResult, SeatPicker, type SeatPickerOptions, type SeatPickerTheme, SeatingChart, type SeatingChartOptions, type SelectedSeat };
package/dist/index.js CHANGED
@@ -57,6 +57,14 @@ var PubApi = class {
57
57
  body: { labels, holdId }
58
58
  });
59
59
  }
60
+ /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
61
+ * (reason: expired | extend_limit | not_found | not_active) if it can't. */
62
+ extend(key, holdId, ttlMs) {
63
+ return request(this.base, `/pub/events/${encodeURIComponent(key)}/extend`, {
64
+ method: "POST",
65
+ body: { holdId, ...ttlMs ? { ttlMs } : {} }
66
+ });
67
+ }
60
68
  socketUrl(key) {
61
69
  const wsBase = this.base.replace(/^http/, "ws");
62
70
  return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe`;
@@ -386,6 +394,7 @@ import {
386
394
  } from "@seatlayer/core";
387
395
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
388
396
  var DEFAULT_MAX_SELECTION2 = 10;
397
+ var EXTEND_PROMPT_MS = 6e4;
389
398
  function resolveContainer3(container) {
390
399
  if (typeof container === "string") {
391
400
  const el = document.querySelector(container);
@@ -499,6 +508,30 @@ var CSS = `
499
508
  .sl-boot-retry{margin-top:4px;padding:9px 20px;border-radius:var(--sl-r-sm);background:var(--sl-accent);
500
509
  color:var(--sl-accent-ink);font-weight:700;font-size:13px}
501
510
 
511
+ /* "Need more time?" extend prompt (bottom-center over the map, above the toast) */
512
+ .sl-extend{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(6px);z-index:9;
513
+ display:none;align-items:center;gap:12px;max-width:92%;background:var(--sl-surface);border:1px solid var(--sl-line);
514
+ color:var(--sl-text);border-radius:14px;padding:10px 12px 10px 16px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);
515
+ opacity:0;transition:opacity .2s,transform .2s}
516
+ .sl-extend.on{display:flex;opacity:1;transform:translateX(-50%) translateY(0)}
517
+ .sl-extend-txt{font-size:12.5px;font-weight:600;line-height:1.35}
518
+ .sl-extend-txt b{font-variant-numeric:tabular-nums}
519
+ .sl-extend-btn{flex:none;padding:8px 14px;border-radius:999px;font-weight:800;font-size:12.5px;
520
+ background:var(--sl-accent);color:var(--sl-accent-ink);transition:filter .15s,opacity .15s}
521
+ .sl-extend-btn:hover{filter:brightness(1.08)}
522
+ .sl-extend-btn:disabled{opacity:.5;cursor:not-allowed}
523
+
524
+ /* booked confirmation overlay (covers the widget once the held seats are sold) */
525
+ .sl-booked{position:absolute;inset:0;z-index:11;display:none;flex-direction:column;align-items:center;
526
+ justify-content:center;gap:12px;text-align:center;padding:28px;background:var(--sl-bg)}
527
+ .sl-booked.on{display:flex}
528
+ .sl-booked-badge{width:60px;height:60px;border-radius:999px;display:flex;align-items:center;justify-content:center;
529
+ background:var(--sl-accent);color:var(--sl-accent-ink)}
530
+ .sl-booked-badge svg{width:30px;height:30px;stroke:currentColor;stroke-width:2.6;fill:none;stroke-linecap:round;stroke-linejoin:round}
531
+ .sl-booked-title{font-weight:800;font-size:19px;color:var(--sl-text)}
532
+ .sl-booked-sub{font-size:13px;color:var(--sl-muted);line-height:1.5;max-width:320px}
533
+ .sl-booked-seats{font-weight:700;color:var(--sl-text)}
534
+
502
535
  /* a11y filter chips (over the map, top-left) */
503
536
  .sl-chips{position:absolute;top:12px;left:12px;z-index:5;display:flex;gap:6px;flex-wrap:wrap;max-width:70%}
504
537
  .sl-chip-f{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;font-size:12px;font-weight:700;
@@ -646,6 +679,14 @@ var SeatPicker = class _SeatPicker {
646
679
  // state
647
680
  this.currency = "USD";
648
681
  this.hold = null;
682
+ /** Latest server expiry for the open hold (moves on extend). */
683
+ this.holdExpiresAt = 0;
684
+ /** True once we handed off to checkout — arms booked-confirmation detection. */
685
+ this.handedOff = false;
686
+ /** Guards single onBooked + single success overlay per hold. */
687
+ this.bookedShown = false;
688
+ this.extendEl = null;
689
+ this.bookedEl = null;
649
690
  this.gaQty = /* @__PURE__ */ new Map();
650
691
  this.tipEl = null;
651
692
  this.tipPos = { x: 0, y: 0 };
@@ -684,9 +725,12 @@ var SeatPicker = class _SeatPicker {
684
725
  onStatusChange: () => {
685
726
  this.syncPrices();
686
727
  this.evictTakenSelections();
728
+ this.detectBooked();
687
729
  },
688
730
  onHoldExpired: () => {
689
731
  this.hold = null;
732
+ this.handedOff = false;
733
+ this.bookedShown = false;
690
734
  this.stopHoldTimer();
691
735
  this.gaQty.clear();
692
736
  this.toast(t2("picker.holdExpired", void 0) || "Your hold expired \u2014 seats released. Pick again.");
@@ -895,10 +939,36 @@ var SeatPicker = class _SeatPicker {
895
939
  this.srEl.setAttribute("aria-live", "polite");
896
940
  root.appendChild(this.srEl);
897
941
  this.buildArenaChrome();
942
+ this.buildExtendPrompt();
943
+ this.buildBookedOverlay();
898
944
  this.syncPrices();
899
945
  this.syncTray();
900
946
  return this;
901
947
  }
948
+ /** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
949
+ buildExtendPrompt() {
950
+ const el = document.createElement("div");
951
+ el.className = "sl-extend";
952
+ el.setAttribute("role", "status");
953
+ el.innerHTML = `<span class="sl-extend-txt" data-ref="extendTxt"></span><button type="button" class="sl-extend-btn" data-ref="extendBtn"></button>`;
954
+ this.els.map.appendChild(el);
955
+ this.extendEl = el;
956
+ this.els.extendTxt = el.querySelector('[data-ref="extendTxt"]');
957
+ this.els.extendBtn = el.querySelector('[data-ref="extendBtn"]');
958
+ this.els.extendBtn.textContent = "Add time";
959
+ this.els.extendBtn.addEventListener("click", () => void this.handleExtend());
960
+ }
961
+ /** Success overlay + onBooked fire when the held seats settle to booked. */
962
+ buildBookedOverlay() {
963
+ const el = document.createElement("div");
964
+ el.className = "sl-booked";
965
+ el.setAttribute("role", "status");
966
+ el.setAttribute("aria-live", "polite");
967
+ 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>`;
968
+ this.root.appendChild(el);
969
+ this.bookedEl = el;
970
+ this.els.bookedSub = el.querySelector('[data-ref="bookedSub"]');
971
+ }
902
972
  // ---- arena / multi-floor chrome -------------------------------------------
903
973
  /** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
904
974
  buildArenaChrome() {
@@ -1277,7 +1347,9 @@ var SeatPicker = class _SeatPicker {
1277
1347
  async handleCta() {
1278
1348
  const cta = this.els.cta;
1279
1349
  if (this.hold && !this.controller.getSelection().some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
1280
- this.opts.onCheckout?.(this.hold, this.hold.seats ?? this.controller.getSelection());
1350
+ const seats = this.hold.seats ?? this.controller.getSelection();
1351
+ this.handedOff = true;
1352
+ this.opts.onCheckout?.(this.hold, seats, this.buildHandoff(this.hold));
1281
1353
  return;
1282
1354
  }
1283
1355
  cta.disabled = true;
@@ -1300,8 +1372,9 @@ var SeatPicker = class _SeatPicker {
1300
1372
  return;
1301
1373
  }
1302
1374
  this.hold = hold;
1375
+ this.handedOff = true;
1303
1376
  this.startHoldTimer(hold.expiresAt);
1304
- this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? []);
1377
+ this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? [], this.buildHandoff(hold));
1305
1378
  } catch (err) {
1306
1379
  this.opts.onError?.(err);
1307
1380
  this.toast("One or more seats were just taken. Please pick again.");
@@ -1311,13 +1384,15 @@ var SeatPicker = class _SeatPicker {
1311
1384
  }
1312
1385
  startHoldTimer(expiresAt) {
1313
1386
  this.stopHoldTimer();
1387
+ this.holdExpiresAt = expiresAt;
1314
1388
  const pill = this.els.hold;
1315
1389
  const tick = () => {
1316
- const ms = Math.max(0, expiresAt - Date.now());
1390
+ const ms = Math.max(0, this.holdExpiresAt - Date.now());
1317
1391
  const m = Math.floor(ms / 6e4);
1318
1392
  const s = String(Math.floor(ms % 6e4 / 1e3)).padStart(2, "0");
1319
1393
  pill.textContent = `Held ${m}:${s}`;
1320
1394
  pill.classList.add("on");
1395
+ this.setExtendPrompt(ms > 0 && ms <= EXTEND_PROMPT_MS, ms);
1321
1396
  if (ms <= 0) this.stopHoldTimer();
1322
1397
  };
1323
1398
  tick();
@@ -1327,6 +1402,83 @@ var SeatPicker = class _SeatPicker {
1327
1402
  if (this.holdTimer) clearInterval(this.holdTimer);
1328
1403
  this.holdTimer = null;
1329
1404
  this.els.hold?.classList.remove("on");
1405
+ this.setExtendPrompt(false, 0);
1406
+ }
1407
+ /** Show/refresh (or hide) the "Need more time?" prompt with the live seconds left. */
1408
+ setExtendPrompt(show, ms) {
1409
+ if (!this.extendEl) return;
1410
+ if (show && this.controller.currentHold() && !this.bookedShown) {
1411
+ const secs = Math.ceil(ms / 1e3);
1412
+ this.els.extendTxt.innerHTML = `Your seats are held for <b>0:${String(secs).padStart(2, "0")}</b>. Need more time?`;
1413
+ this.extendEl.classList.add("on");
1414
+ } else {
1415
+ this.extendEl.classList.remove("on");
1416
+ }
1417
+ }
1418
+ async handleExtend() {
1419
+ const btn = this.els.extendBtn;
1420
+ btn.disabled = true;
1421
+ const prev = btn.textContent;
1422
+ btn.textContent = "Adding\u2026";
1423
+ try {
1424
+ const h = await this.controller.extendHold(this.opts.holdTtlMs);
1425
+ if (h) {
1426
+ this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1427
+ this.holdExpiresAt = h.expiresAt;
1428
+ this.extendEl?.classList.remove("on");
1429
+ this.toast("More time added \u2014 your seats are still held.");
1430
+ } else {
1431
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1432
+ }
1433
+ } catch (err) {
1434
+ this.opts.onError?.(err);
1435
+ this.toast("Couldn't add more time \u2014 please head to checkout now.");
1436
+ } finally {
1437
+ btn.disabled = false;
1438
+ btn.textContent = prev;
1439
+ }
1440
+ }
1441
+ /**
1442
+ * Fire the booked-confirmation state once the buyer's held seats settle to
1443
+ * booked. The controller clears its own hold the moment every held label reads
1444
+ * 'booked' over the realtime channel (clearBookedHoldIfSettled), and this runs
1445
+ * on the same onStatusChange — so `currentHold() === null` while we still hold
1446
+ * a checkout handoff means "sold", not expired (expiry clears via onHoldExpired
1447
+ * on a different path, which nulls this.hold first).
1448
+ */
1449
+ detectBooked() {
1450
+ if (this.bookedShown || !this.handedOff || !this.hold) return;
1451
+ if (this.controller.currentHold() !== null) return;
1452
+ this.showBooked();
1453
+ }
1454
+ showBooked() {
1455
+ if (this.bookedShown || !this.hold) return;
1456
+ this.bookedShown = true;
1457
+ const handoff = this.buildHandoff(this.hold);
1458
+ this.stopHoldTimer();
1459
+ const n = handoff.lineItems.reduce((sum, i) => sum + i.quantity, 0);
1460
+ if (this.els.bookedSub) {
1461
+ this.els.bookedSub.innerHTML = `<span class="sl-booked-seats">${n} ${n === 1 ? "ticket" : "tickets"}</span> confirmed. A confirmation is on its way.`;
1462
+ }
1463
+ this.bookedEl?.classList.add("on");
1464
+ this.opts.onBooked?.(handoff);
1465
+ }
1466
+ /** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
1467
+ buildHandoff(hold) {
1468
+ const items = hold.items ?? [];
1469
+ const lineItems = items.map((it) => ({
1470
+ label: it.label,
1471
+ objectId: it.objectId,
1472
+ objectType: it.objectType,
1473
+ categoryKey: it.categoryKey,
1474
+ tierId: it.tierId,
1475
+ unitPrice: it.unitPrice,
1476
+ currency: it.currency ?? this.currency,
1477
+ quantity: it.quantity ?? 1
1478
+ }));
1479
+ const currency = lineItems[0]?.currency ?? this.currency;
1480
+ const total = lineItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
1481
+ return { holdId: hold.holdId, expiresAt: hold.expiresAt, currency, lineItems, total };
1330
1482
  }
1331
1483
  toast(msg) {
1332
1484
  const el = this.els.toast;
@@ -1368,6 +1520,8 @@ var SeatPicker = class _SeatPicker {
1368
1520
  const h = await this.controller.bestAvailable(qty, categoryKey);
1369
1521
  if (h) {
1370
1522
  this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
1523
+ this.handedOff = false;
1524
+ this.bookedShown = false;
1371
1525
  this.startHoldTimer(h.expiresAt);
1372
1526
  this.syncTray();
1373
1527
  return this.hold;
@@ -1381,6 +1535,8 @@ var SeatPicker = class _SeatPicker {
1381
1535
  async release() {
1382
1536
  await this.controller.release();
1383
1537
  this.hold = null;
1538
+ this.handedOff = false;
1539
+ this.bookedShown = false;
1384
1540
  this.stopHoldTimer();
1385
1541
  this.gaQty.clear();
1386
1542
  this.syncTray();