@seatlayer/js 0.8.0 → 0.10.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 +750 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -3
- package/dist/index.d.ts +151 -3
- package/dist/index.js +750 -52
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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,15 @@ 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;
|
|
398
|
+
function pointInPolygon(x, y, poly) {
|
|
399
|
+
let inside = false;
|
|
400
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
401
|
+
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
|
|
402
|
+
if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
|
|
403
|
+
}
|
|
404
|
+
return inside;
|
|
405
|
+
}
|
|
389
406
|
function resolveContainer3(container) {
|
|
390
407
|
if (typeof container === "string") {
|
|
391
408
|
const el = document.querySelector(container);
|
|
@@ -429,12 +446,44 @@ var CSS = `
|
|
|
429
446
|
.sl-map-host{position:absolute;inset:0}
|
|
430
447
|
.sl-side{width:300px;flex:none;border-left:1px solid var(--sl-line);display:flex;flex-direction:column;min-height:0;overflow-y:auto}
|
|
431
448
|
|
|
432
|
-
/* narrow (container < 640px):
|
|
449
|
+
/* narrow (container < 640px): map-first \u2014 the map claims ~80-85% of the
|
|
450
|
+
container and the side panel becomes a PEEKING bottom sheet (AXS/Ticketmaster
|
|
451
|
+
mobile pattern). data-sheet on the root: "peek" (default: grab handle + one
|
|
452
|
+
summary line) / "open" (\u226450%, swipe up or auto-expand on first selection).
|
|
453
|
+
Swipe handling lives on the sheet head ONLY \u2014 never the map host, so the
|
|
454
|
+
map's raw-pointer gesture pipeline is untouched. */
|
|
433
455
|
.sl-picker[data-layout="narrow"] .sl-body{flex-direction:column}
|
|
434
456
|
.sl-picker[data-layout="narrow"] .sl-map{min-height:0;flex:1}
|
|
435
|
-
.sl-picker[data-layout="narrow"] .sl-side{width:100%;
|
|
457
|
+
.sl-picker[data-layout="narrow"] .sl-side{width:100%;border-left:0;border-top:1px solid var(--sl-line);
|
|
458
|
+
flex:none;height:50%;transition:height .22s ease}
|
|
459
|
+
.sl-picker[data-layout="narrow"][data-sheet="peek"] .sl-side{height:86px;overflow:hidden}
|
|
460
|
+
.sl-picker[data-layout="narrow"][data-sheet="peek"] .sl-side > :not(.sl-sheet-head){display:none}
|
|
436
461
|
.sl-picker[data-layout="narrow"] .sl-tray{flex:none}
|
|
437
462
|
.sl-picker[data-layout="narrow"] .sl-foot{position:sticky;bottom:0;background:var(--sl-bg)}
|
|
463
|
+
/* touch chrome: pinch-zoom exists \u2014 hide +/\u2212 on the sheet layout (keep fit) */
|
|
464
|
+
.sl-picker[data-layout="narrow"] .sl-zoom [data-ref="zin"],
|
|
465
|
+
.sl-picker[data-layout="narrow"] .sl-zoom [data-ref="zout"]{display:none}
|
|
466
|
+
|
|
467
|
+
/* bottom-sheet head: grab handle + one-line summary (narrow only) */
|
|
468
|
+
.sl-sheet-head{display:none;flex-direction:column;padding:6px 16px 8px;cursor:grab;touch-action:none;
|
|
469
|
+
user-select:none;-webkit-user-select:none;flex:none}
|
|
470
|
+
.sl-picker[data-layout="narrow"] .sl-sheet-head{display:flex}
|
|
471
|
+
.sl-sheet-grab{width:36px;height:4px;border-radius:999px;background:var(--sl-muted);opacity:.55;margin:2px auto 7px}
|
|
472
|
+
.sl-sheet-peek{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:700;color:var(--sl-text);
|
|
473
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:20px}
|
|
474
|
+
.sl-sheet-peek .sub{color:var(--sl-muted);font-weight:600}
|
|
475
|
+
.sl-sheet-peek .go{margin-left:auto;color:var(--sl-accent);font-weight:800;flex:none}
|
|
476
|
+
|
|
477
|
+
/* consolidated Filters row inside the sheet (a11y chips + colorblind toggle
|
|
478
|
+
dock here on narrow; they live on the map / zoom column on wide) */
|
|
479
|
+
.sl-filtersec{display:none}
|
|
480
|
+
.sl-filters{display:none;gap:6px;flex-wrap:wrap;align-items:center;padding:2px 16px 10px}
|
|
481
|
+
.sl-picker[data-layout="narrow"] .sl-filtersec.has{display:block}
|
|
482
|
+
.sl-picker[data-layout="narrow"] .sl-filters.has{display:flex}
|
|
483
|
+
.sl-cbbtn{width:32px;height:32px;border-radius:999px;background:var(--sl-surface);border:1px solid var(--sl-line);
|
|
484
|
+
color:var(--sl-text);display:flex;align-items:center;justify-content:center;transition:border-color .15s}
|
|
485
|
+
.sl-cbbtn:hover{border-color:var(--sl-muted)}
|
|
486
|
+
.sl-cbbtn svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
438
487
|
|
|
439
488
|
/* price panel */
|
|
440
489
|
.sl-sec{padding:14px 16px 4px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--sl-muted);font-weight:700}
|
|
@@ -477,19 +526,44 @@ var CSS = `
|
|
|
477
526
|
.sl-cta:hover{filter:brightness(1.08)}
|
|
478
527
|
.sl-cta:disabled{opacity:.45;cursor:not-allowed}
|
|
479
528
|
|
|
480
|
-
/*
|
|
481
|
-
|
|
529
|
+
/* Chrome anchor regions (Feature 6) \u2014 every persistent map overlay is APPENDED
|
|
530
|
+
INTO one of these positioned flex containers and flows/stacks within it, so no
|
|
531
|
+
two pieces of chrome free-float on top of each other. Regions never overlap:
|
|
532
|
+
the top strip splits into left/center/right; rails + corners own their edge. */
|
|
533
|
+
.sl-anchor{position:absolute;z-index:5;display:flex;gap:8px;pointer-events:none}
|
|
534
|
+
.sl-anchor > *{pointer-events:auto}
|
|
535
|
+
.sl-anchor[data-region="top-left"]{top:12px;left:12px;flex-wrap:wrap;max-width:38%}
|
|
536
|
+
.sl-anchor[data-region="top-center"]{top:12px;left:50%;transform:translateX(-50%);flex-direction:column;
|
|
537
|
+
align-items:center;max-width:44%}
|
|
538
|
+
.sl-anchor[data-region="top-right"]{top:12px;right:12px;justify-content:flex-end;flex-wrap:wrap;max-width:38%}
|
|
539
|
+
.sl-anchor[data-region="left-rail"]{top:50%;left:12px;transform:translateY(-50%);flex-direction:column;max-width:42%;gap:6px}
|
|
540
|
+
.sl-anchor[data-region="bottom-left"]{left:12px;bottom:12px;flex-direction:column;align-items:flex-start}
|
|
541
|
+
.sl-anchor[data-region="bottom-center"]{left:50%;bottom:14px;transform:translateX(-50%);z-index:9;
|
|
542
|
+
flex-direction:column;align-items:center;gap:8px;max-width:92%}
|
|
543
|
+
.sl-anchor[data-region="bottom-right"]{right:12px;bottom:12px;flex-direction:column;align-items:flex-end;gap:6px}
|
|
544
|
+
/* narrow: tighten the top strip so left/center can't crowd each other */
|
|
545
|
+
.sl-picker[data-layout="narrow"] .sl-anchor[data-region="top-left"]{max-width:30%}
|
|
546
|
+
.sl-picker[data-layout="narrow"] .sl-anchor[data-region="top-center"]{max-width:44%}
|
|
547
|
+
|
|
548
|
+
/* TEST MODE badge \u2014 a small pill in the top-right region (shrinks on narrow) */
|
|
549
|
+
.sl-testbadge{padding:5px 11px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.1em;
|
|
550
|
+
text-transform:uppercase;white-space:nowrap;background:var(--sl-accent);color:var(--sl-accent-ink);
|
|
551
|
+
box-shadow:0 2px 8px rgba(0,0,0,.25)}
|
|
552
|
+
.sl-picker[data-layout="narrow"] .sl-testbadge{padding:3px 8px;font-size:8.5px;letter-spacing:.06em}
|
|
553
|
+
|
|
554
|
+
/* zoom column (flows within the bottom-right region) */
|
|
555
|
+
.sl-zoom{display:flex;flex-direction:column;gap:6px}
|
|
482
556
|
.sl-zoom button{width:36px;height:36px;border-radius:999px;background:var(--sl-surface);border:1px solid var(--sl-line);
|
|
483
557
|
color:var(--sl-text);font-size:17px;font-weight:700;display:flex;align-items:center;justify-content:center;transition:border-color .15s}
|
|
484
558
|
.sl-zoom button:hover{border-color:var(--sl-muted)}
|
|
485
559
|
.sl-zoom svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
486
560
|
|
|
487
|
-
/* toast + boot states */
|
|
488
|
-
.sl-toast{
|
|
561
|
+
/* toast + boot states (toast flows in the bottom-center region) */
|
|
562
|
+
.sl-toast{transform:translateY(6px);max-width:100%;
|
|
489
563
|
background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-text);border-radius:999px;padding:9px 16px;
|
|
490
564
|
font-size:12.5px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;white-space:nowrap;
|
|
491
565
|
overflow:hidden;text-overflow:ellipsis}
|
|
492
|
-
.sl-toast.on{opacity:1;transform:
|
|
566
|
+
.sl-toast.on{opacity:1;transform:translateY(0)}
|
|
493
567
|
.sl-boot{position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
494
568
|
gap:10px;background:var(--sl-bg);font-size:13px;font-weight:600;color:var(--sl-muted)}
|
|
495
569
|
.sl-boot-spin{width:24px;height:24px;border-radius:50%;border:3px solid var(--sl-line);border-top-color:var(--sl-accent);
|
|
@@ -499,8 +573,32 @@ var CSS = `
|
|
|
499
573
|
.sl-boot-retry{margin-top:4px;padding:9px 20px;border-radius:var(--sl-r-sm);background:var(--sl-accent);
|
|
500
574
|
color:var(--sl-accent-ink);font-weight:700;font-size:13px}
|
|
501
575
|
|
|
502
|
-
/*
|
|
503
|
-
.sl-
|
|
576
|
+
/* "Need more time?" extend prompt (flows in the bottom-center region, above the toast) */
|
|
577
|
+
.sl-extend{transform:translateY(6px);
|
|
578
|
+
display:none;align-items:center;gap:12px;max-width:100%;background:var(--sl-surface);border:1px solid var(--sl-line);
|
|
579
|
+
color:var(--sl-text);border-radius:14px;padding:10px 12px 10px 16px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);
|
|
580
|
+
opacity:0;transition:opacity .2s,transform .2s}
|
|
581
|
+
.sl-extend.on{display:flex;opacity:1;transform:translateY(0)}
|
|
582
|
+
.sl-extend-txt{font-size:12.5px;font-weight:600;line-height:1.35}
|
|
583
|
+
.sl-extend-txt b{font-variant-numeric:tabular-nums}
|
|
584
|
+
.sl-extend-btn{flex:none;padding:8px 14px;border-radius:999px;font-weight:800;font-size:12.5px;
|
|
585
|
+
background:var(--sl-accent);color:var(--sl-accent-ink);transition:filter .15s,opacity .15s}
|
|
586
|
+
.sl-extend-btn:hover{filter:brightness(1.08)}
|
|
587
|
+
.sl-extend-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
588
|
+
|
|
589
|
+
/* booked confirmation overlay (covers the widget once the held seats are sold) */
|
|
590
|
+
.sl-booked{position:absolute;inset:0;z-index:11;display:none;flex-direction:column;align-items:center;
|
|
591
|
+
justify-content:center;gap:12px;text-align:center;padding:28px;background:var(--sl-bg)}
|
|
592
|
+
.sl-booked.on{display:flex}
|
|
593
|
+
.sl-booked-badge{width:60px;height:60px;border-radius:999px;display:flex;align-items:center;justify-content:center;
|
|
594
|
+
background:var(--sl-accent);color:var(--sl-accent-ink)}
|
|
595
|
+
.sl-booked-badge svg{width:30px;height:30px;stroke:currentColor;stroke-width:2.6;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
596
|
+
.sl-booked-title{font-weight:800;font-size:19px;color:var(--sl-text)}
|
|
597
|
+
.sl-booked-sub{font-size:13px;color:var(--sl-muted);line-height:1.5;max-width:320px}
|
|
598
|
+
.sl-booked-seats{font-weight:700;color:var(--sl-text)}
|
|
599
|
+
|
|
600
|
+
/* a11y filter chips (flow within the top-left region) */
|
|
601
|
+
.sl-chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
504
602
|
.sl-chip-f{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;font-size:12px;font-weight:700;
|
|
505
603
|
background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted);transition:color .15s,border-color .15s}
|
|
506
604
|
.sl-chip-f:hover{color:var(--sl-text)}
|
|
@@ -540,18 +638,18 @@ var CSS = `
|
|
|
540
638
|
.sl-chip .view:hover{color:var(--sl-text)}
|
|
541
639
|
.sl-chip .view svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
542
640
|
|
|
543
|
-
/* arena: LOD rung pills (top-center
|
|
544
|
-
.sl-rungs{
|
|
545
|
-
background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
|
|
641
|
+
/* arena: LOD rung pills (flow within the top-center region) */
|
|
642
|
+
.sl-rungs{display:none;background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:999px;padding:3px}
|
|
546
643
|
.sl-rungs.on{display:inline-flex;gap:2px}
|
|
547
644
|
.sl-rungs button{padding:6px 13px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.07em;
|
|
548
645
|
color:var(--sl-muted);white-space:nowrap;transition:color .15s}
|
|
549
646
|
.sl-rungs button:hover{color:var(--sl-text)}
|
|
550
647
|
.sl-rungs button.on{background:var(--sl-accent);color:var(--sl-accent-ink)}
|
|
648
|
+
/* narrow: shrink the rung pills so the centered row can't reach the corner regions */
|
|
649
|
+
.sl-picker[data-layout="narrow"] .sl-rungs button{padding:5px 9px;font-size:9px;letter-spacing:.03em}
|
|
551
650
|
|
|
552
|
-
/* multi-floor switcher (
|
|
553
|
-
.sl-floors{
|
|
554
|
-
flex-direction:column;gap:6px;max-width:42%}
|
|
651
|
+
/* multi-floor switcher (flows within the left-rail region) */
|
|
652
|
+
.sl-floors{display:none;flex-direction:column;gap:6px;max-width:100%}
|
|
555
653
|
.sl-floors.on{display:flex}
|
|
556
654
|
.sl-floors button{padding:7px 13px;border-radius:999px;font-size:12px;font-weight:700;background:var(--sl-surface);
|
|
557
655
|
border:1px solid var(--sl-line);color:var(--sl-muted);white-space:nowrap;max-width:100%;overflow:hidden;
|
|
@@ -559,11 +657,25 @@ var CSS = `
|
|
|
559
657
|
.sl-floors button:hover{color:var(--sl-text)}
|
|
560
658
|
.sl-floors button.on{background:var(--sl-accent);color:var(--sl-accent-ink);border-color:transparent}
|
|
561
659
|
|
|
562
|
-
/* tapped-section summary card
|
|
563
|
-
|
|
564
|
-
|
|
660
|
+
/* tapped-section summary card \u2014 docks INSIDE the top-center anchor region on
|
|
661
|
+
wide (flows below the rung pills, never over them, never floating over the
|
|
662
|
+
seats at the tap point). Auto-collapses to a slim pill once seat-picking
|
|
663
|
+
begins (first seat select, or a pan/zoom after the focus glide); tapping the
|
|
664
|
+
pill re-expands; \u2715 closes in both states. On narrow it renders as a compact
|
|
665
|
+
strip inside the bottom sheet's peek head \u2014 never over the canvas. */
|
|
666
|
+
.sl-seccard{width:250px;max-width:100%;background:var(--sl-surface);border:1px solid var(--sl-line);border-radius:12px;
|
|
565
667
|
padding:12px 14px;box-shadow:0 18px 50px -18px rgba(0,0,0,.6);display:none}
|
|
566
668
|
.sl-seccard.on{display:block}
|
|
669
|
+
/* collapsed pill (wide) */
|
|
670
|
+
.sl-seccard.mini{width:auto;padding:5px 7px 5px 12px;border-radius:999px;cursor:pointer}
|
|
671
|
+
.sl-seccard.mini.on{display:inline-flex;align-items:center;gap:7px}
|
|
672
|
+
.sl-seccard.mini .sl-seccard-name{font-size:12px;flex:none;max-width:120px}
|
|
673
|
+
.sl-seccard.mini .sl-seccard-left{font-size:11px}
|
|
674
|
+
/* narrow: compact strip inside the sheet head (peek area) */
|
|
675
|
+
.sl-seccard.strip{width:100%;padding:7px 0 0;border:0;border-radius:0;box-shadow:none;background:none;cursor:default}
|
|
676
|
+
.sl-seccard.strip.on{display:flex;align-items:center;gap:7px;font-size:12.5px}
|
|
677
|
+
.sl-seccard.strip .sl-seccard-name{font-size:12.5px}
|
|
678
|
+
.sl-seccard.strip .sl-seccard-price{margin-left:auto}
|
|
567
679
|
.sl-seccard-head{display:flex;align-items:center;gap:8px}
|
|
568
680
|
.sl-seccard-dot{width:10px;height:10px;border-radius:50%;flex:none}
|
|
569
681
|
.sl-seccard-name{font-weight:800;font-size:14px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -605,6 +717,20 @@ var CSS = `
|
|
|
605
717
|
font-size:11.5px;font-weight:600;background:var(--sl-surface);border:1px solid var(--sl-line);color:var(--sl-muted);
|
|
606
718
|
white-space:nowrap;pointer-events:none;max-width:90%;overflow:hidden;text-overflow:ellipsis}
|
|
607
719
|
|
|
720
|
+
/* F3 minimap \u2014 venue overview + live viewport rect (flows in the bottom-left region) */
|
|
721
|
+
.sl-minimap{border:1px solid var(--sl-line);border-radius:9px;
|
|
722
|
+
overflow:hidden;background:var(--sl-surface);box-shadow:0 12px 34px -14px rgba(0,0,0,.55);line-height:0;cursor:pointer}
|
|
723
|
+
.sl-minimap canvas{display:block}
|
|
724
|
+
.sl-picker[data-layout="narrow"] .sl-minimap{display:none}
|
|
725
|
+
|
|
726
|
+
/* F4 price-band filter \u2014 chip row in the side panel, above the price legend it
|
|
727
|
+
* dims (keeps clear of the map's top-center rungs / top-left a11y chips). */
|
|
728
|
+
.sl-pricef{display:flex;gap:6px;flex-wrap:wrap;padding:2px 16px 10px}
|
|
729
|
+
.sl-pricef .sl-chip-f{padding:6px 11px;font-size:11.5px}
|
|
730
|
+
/* F4 legend reflection: rows + counts for out-of-band categories read muted */
|
|
731
|
+
.sl-price-row.sl-dim{opacity:.4}
|
|
732
|
+
.sl-seccard-mix-item.sl-dim{opacity:.4}
|
|
733
|
+
|
|
608
734
|
/* modal host */
|
|
609
735
|
.sl-modal-scrim{position:fixed;inset:0;z-index:2147483000;background:rgba(5,7,12,.66);display:flex;align-items:center;justify-content:center;padding:18px}
|
|
610
736
|
.sl-modal-frame{width:min(1200px,100%);height:min(820px,100%);border-radius:16px;overflow:hidden;box-shadow:0 40px 120px -30px rgba(0,0,0,.8)}
|
|
@@ -640,12 +766,22 @@ var SeatPicker = class _SeatPicker {
|
|
|
640
766
|
this.destroyed = false;
|
|
641
767
|
// chrome refs
|
|
642
768
|
this.els = {};
|
|
769
|
+
/** Feature 6 anchor regions — positioned flex containers over the map. */
|
|
770
|
+
this.regions = {};
|
|
643
771
|
this.ro = null;
|
|
644
772
|
this.holdTimer = null;
|
|
645
773
|
this.toastTimer = null;
|
|
646
774
|
// state
|
|
647
775
|
this.currency = "USD";
|
|
648
776
|
this.hold = null;
|
|
777
|
+
/** Latest server expiry for the open hold (moves on extend). */
|
|
778
|
+
this.holdExpiresAt = 0;
|
|
779
|
+
/** True once we handed off to checkout — arms booked-confirmation detection. */
|
|
780
|
+
this.handedOff = false;
|
|
781
|
+
/** Guards single onBooked + single success overlay per hold. */
|
|
782
|
+
this.bookedShown = false;
|
|
783
|
+
this.extendEl = null;
|
|
784
|
+
this.bookedEl = null;
|
|
649
785
|
this.gaQty = /* @__PURE__ */ new Map();
|
|
650
786
|
this.tipEl = null;
|
|
651
787
|
this.tipPos = { x: 0, y: 0 };
|
|
@@ -662,6 +798,25 @@ var SeatPicker = class _SeatPicker {
|
|
|
662
798
|
this.viewEl = null;
|
|
663
799
|
this.viewCleanup = null;
|
|
664
800
|
this.allSeatsCache = null;
|
|
801
|
+
// F3 minimap
|
|
802
|
+
this.miniEl = null;
|
|
803
|
+
this.miniCanvas = null;
|
|
804
|
+
this.miniBase = null;
|
|
805
|
+
this.miniTf = null;
|
|
806
|
+
// F4 price-band filter — active band's category keys (null = all prices)
|
|
807
|
+
this.priceBandKeys = null;
|
|
808
|
+
this.priceFilterEl = null;
|
|
809
|
+
/** Last surfaced section summary (re-rendered when the price band changes). */
|
|
810
|
+
this.lastSection = null;
|
|
811
|
+
/** Section card collapsed to its slim pill (seat-picking has begun). */
|
|
812
|
+
this.secCardCollapsed = false;
|
|
813
|
+
/** When the card was (re)shown — the focus glide's own view change must not collapse it. */
|
|
814
|
+
this.secCardShownAt = 0;
|
|
815
|
+
/** Previous tray ticket count — first 0→n transition auto-expands the mobile sheet. */
|
|
816
|
+
this.lastTrayCount = 0;
|
|
817
|
+
// narrow-layout chrome that docks into the sheet's Filters row on mobile
|
|
818
|
+
this.a11yChipsEl = null;
|
|
819
|
+
this.cbEl = null;
|
|
665
820
|
// modal plumbing (set by open())
|
|
666
821
|
this.modalScrim = null;
|
|
667
822
|
this.prevFocus = null;
|
|
@@ -680,13 +835,20 @@ var SeatPicker = class _SeatPicker {
|
|
|
680
835
|
currency: options.currency,
|
|
681
836
|
flashOnLiveChange: true,
|
|
682
837
|
colorblindSafe: options.colorblindSafe,
|
|
683
|
-
onSelectionChange: () =>
|
|
838
|
+
onSelectionChange: () => {
|
|
839
|
+
this.syncTray();
|
|
840
|
+
if (this.controller.getSelection().length) this.collapseSectionCard();
|
|
841
|
+
},
|
|
684
842
|
onStatusChange: () => {
|
|
685
843
|
this.syncPrices();
|
|
686
844
|
this.evictTakenSelections();
|
|
845
|
+
this.detectBooked();
|
|
846
|
+
this.refreshMinimap();
|
|
687
847
|
},
|
|
688
848
|
onHoldExpired: () => {
|
|
689
849
|
this.hold = null;
|
|
850
|
+
this.handedOff = false;
|
|
851
|
+
this.bookedShown = false;
|
|
690
852
|
this.stopHoldTimer();
|
|
691
853
|
this.gaQty.clear();
|
|
692
854
|
this.toast(t2("picker.holdExpired", void 0) || "Your hold expired \u2014 seats released. Pick again.");
|
|
@@ -700,6 +862,8 @@ var SeatPicker = class _SeatPicker {
|
|
|
700
862
|
onViewChange: () => {
|
|
701
863
|
this.reanchorConfirm();
|
|
702
864
|
this.syncRung();
|
|
865
|
+
this.drawMinimapRect();
|
|
866
|
+
this.sectionCardOnView();
|
|
703
867
|
},
|
|
704
868
|
// Tapped-section glide-in → surface (or clear) the section-summary card.
|
|
705
869
|
onSectionFocus: (summary) => this.showSectionCard(summary),
|
|
@@ -779,7 +943,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
779
943
|
<div class="sl-body">
|
|
780
944
|
<div class="sl-map">
|
|
781
945
|
<div class="sl-map-host" data-ref="map"></div>
|
|
782
|
-
<div class="sl-zoom">
|
|
946
|
+
<div class="sl-zoom" data-ref="zoom">
|
|
783
947
|
<button type="button" aria-label="Zoom in" data-ref="zin">+</button>
|
|
784
948
|
<button type="button" aria-label="Zoom out" data-ref="zout">\u2212</button>
|
|
785
949
|
<button type="button" aria-label="Fit to screen" data-ref="zfit">
|
|
@@ -789,7 +953,13 @@ var SeatPicker = class _SeatPicker {
|
|
|
789
953
|
<div class="sl-boot" data-ref="boot"><span class="sl-boot-spin"></span>Loading seat map\u2026</div>
|
|
790
954
|
<div class="sl-toast" data-ref="toast" role="status" aria-live="polite"></div>
|
|
791
955
|
</div>
|
|
792
|
-
<div class="sl-side">
|
|
956
|
+
<div class="sl-side" data-ref="side">
|
|
957
|
+
<div class="sl-sheet-head" data-ref="sheetHead">
|
|
958
|
+
<div class="sl-sheet-grab"></div>
|
|
959
|
+
<div class="sl-sheet-peek" data-ref="peek"></div>
|
|
960
|
+
</div>
|
|
961
|
+
<div class="sl-sec sl-filtersec" data-ref="filtersSec">Filters</div>
|
|
962
|
+
<div class="sl-filters" data-ref="filters"></div>
|
|
793
963
|
<div class="sl-sec" data-ref="pricesSec">Prices</div>
|
|
794
964
|
<div class="sl-prices" data-ref="prices"></div>
|
|
795
965
|
<div class="sl-sec">Your seats</div>
|
|
@@ -804,14 +974,55 @@ var SeatPicker = class _SeatPicker {
|
|
|
804
974
|
this.els[el.dataset.ref] = el;
|
|
805
975
|
});
|
|
806
976
|
this.mapHost = this.els.map;
|
|
807
|
-
|
|
977
|
+
const applyLayout = () => {
|
|
808
978
|
const w = root.clientWidth;
|
|
809
|
-
|
|
810
|
-
|
|
979
|
+
if (w <= 0) return;
|
|
980
|
+
const next = w < 640 ? "narrow" : "wide";
|
|
981
|
+
if (root.dataset.layout === next) return;
|
|
982
|
+
root.dataset.layout = next;
|
|
983
|
+
if (next === "narrow" && !root.dataset.sheet) root.dataset.sheet = "peek";
|
|
984
|
+
this.dockLayoutChrome();
|
|
985
|
+
};
|
|
986
|
+
this.ro = new ResizeObserver(applyLayout);
|
|
811
987
|
this.ro.observe(root);
|
|
988
|
+
applyLayout();
|
|
989
|
+
requestAnimationFrame(applyLayout);
|
|
812
990
|
this.els.zin.addEventListener("click", () => this.controller.zoomIn());
|
|
813
991
|
this.els.zout.addEventListener("click", () => this.controller.zoomOut());
|
|
814
992
|
this.els.zfit.addEventListener("click", () => this.controller.zoomToFit());
|
|
993
|
+
const head = this.els.sheetHead;
|
|
994
|
+
if (head) {
|
|
995
|
+
const setSheet = (open) => {
|
|
996
|
+
root.dataset.sheet = open ? "open" : "peek";
|
|
997
|
+
};
|
|
998
|
+
let startY = 0;
|
|
999
|
+
let swiped = false;
|
|
1000
|
+
let tracking = false;
|
|
1001
|
+
head.addEventListener("pointerdown", (e) => {
|
|
1002
|
+
tracking = true;
|
|
1003
|
+
swiped = false;
|
|
1004
|
+
startY = e.clientY;
|
|
1005
|
+
head.setPointerCapture?.(e.pointerId);
|
|
1006
|
+
});
|
|
1007
|
+
head.addEventListener("pointermove", (e) => {
|
|
1008
|
+
if (!tracking || swiped) return;
|
|
1009
|
+
const dy = e.clientY - startY;
|
|
1010
|
+
if (dy < -18) {
|
|
1011
|
+
setSheet(true);
|
|
1012
|
+
swiped = true;
|
|
1013
|
+
} else if (dy > 18) {
|
|
1014
|
+
setSheet(false);
|
|
1015
|
+
swiped = true;
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
head.addEventListener("pointerup", (e) => {
|
|
1019
|
+
if (tracking && !swiped && Math.abs(e.clientY - startY) < 6) {
|
|
1020
|
+
if (!e.target.closest(".sl-seccard")) setSheet(root.dataset.sheet !== "open");
|
|
1021
|
+
}
|
|
1022
|
+
tracking = false;
|
|
1023
|
+
head.releasePointerCapture?.(e.pointerId);
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
815
1026
|
this.tipEl = document.createElement("div");
|
|
816
1027
|
this.tipEl.setAttribute("role", "tooltip");
|
|
817
1028
|
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;";
|
|
@@ -838,13 +1049,15 @@ var SeatPicker = class _SeatPicker {
|
|
|
838
1049
|
return this;
|
|
839
1050
|
}
|
|
840
1051
|
this.els.boot.remove();
|
|
1052
|
+
this.buildRegions();
|
|
1053
|
+
this.regions["bottom-right"].appendChild(this.els.zoom);
|
|
1054
|
+
this.regions["bottom-center"].appendChild(this.els.toast);
|
|
841
1055
|
if (info.mode === "test") {
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
this.
|
|
847
|
-
this.els.map.appendChild(ribbon);
|
|
1056
|
+
const badge = document.createElement("div");
|
|
1057
|
+
badge.className = "sl-testbadge";
|
|
1058
|
+
badge.textContent = t2("picker.testMode");
|
|
1059
|
+
badge.setAttribute("aria-label", t2("picker.testMode"));
|
|
1060
|
+
this.regions["top-right"].appendChild(badge);
|
|
848
1061
|
}
|
|
849
1062
|
const chartTheme = this.controller.doc?.theme;
|
|
850
1063
|
Object.entries(resolveTokens(chartTheme, this.opts.theme)).forEach(([k, v]) => root.style.setProperty(k, v));
|
|
@@ -868,7 +1081,8 @@ var SeatPicker = class _SeatPicker {
|
|
|
868
1081
|
const GLYPH = { wheelchair: "\u267F", companion: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" };
|
|
869
1082
|
const mk = (key, label) => `<button type="button" class="sl-chip-f${key === "all" ? " on" : ""}" data-f="${key}">${label}</button>`;
|
|
870
1083
|
chips.innerHTML = mk("all", "All seats") + [...present].map((type) => mk(type, `${GLYPH[type] ? GLYPH[type] + " " : ""}${type[0].toUpperCase()}${type.slice(1).replace(/-/g, " ")}`)).join("");
|
|
871
|
-
this.
|
|
1084
|
+
this.regions["top-left"].appendChild(chips);
|
|
1085
|
+
this.a11yChipsEl = chips;
|
|
872
1086
|
chips.querySelectorAll("button").forEach((btn) => {
|
|
873
1087
|
btn.addEventListener("click", () => {
|
|
874
1088
|
const f = btn.dataset.f;
|
|
@@ -880,6 +1094,8 @@ var SeatPicker = class _SeatPicker {
|
|
|
880
1094
|
}
|
|
881
1095
|
const cb = document.createElement("button");
|
|
882
1096
|
cb.type = "button";
|
|
1097
|
+
cb.className = "sl-cbbtn";
|
|
1098
|
+
this.cbEl = cb;
|
|
883
1099
|
cb.setAttribute("aria-label", "Toggle colorblind-friendly colors");
|
|
884
1100
|
cb.setAttribute("aria-pressed", String(!!this.opts.colorblindSafe));
|
|
885
1101
|
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>';
|
|
@@ -895,10 +1111,314 @@ var SeatPicker = class _SeatPicker {
|
|
|
895
1111
|
this.srEl.setAttribute("aria-live", "polite");
|
|
896
1112
|
root.appendChild(this.srEl);
|
|
897
1113
|
this.buildArenaChrome();
|
|
1114
|
+
this.buildMinimap();
|
|
1115
|
+
this.buildPriceFilter();
|
|
1116
|
+
this.buildExtendPrompt();
|
|
1117
|
+
this.buildBookedOverlay();
|
|
1118
|
+
this.dockLayoutChrome();
|
|
898
1119
|
this.syncPrices();
|
|
899
1120
|
this.syncTray();
|
|
900
1121
|
return this;
|
|
901
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Move layout-dependent chrome between its wide dock (map regions / zoom
|
|
1125
|
+
* column) and its narrow dock (the sheet's consolidated Filters row), and
|
|
1126
|
+
* re-render the section card in the form the layout wants (docked card/pill
|
|
1127
|
+
* on wide, sheet strip on narrow). Runs on every layout flip + once post-render.
|
|
1128
|
+
*/
|
|
1129
|
+
dockLayoutChrome() {
|
|
1130
|
+
const narrow = this.root?.dataset.layout === "narrow";
|
|
1131
|
+
const filters = this.els.filters;
|
|
1132
|
+
if (filters) {
|
|
1133
|
+
if (narrow) {
|
|
1134
|
+
if (this.a11yChipsEl) filters.appendChild(this.a11yChipsEl);
|
|
1135
|
+
if (this.cbEl) filters.appendChild(this.cbEl);
|
|
1136
|
+
} else {
|
|
1137
|
+
if (this.a11yChipsEl) this.regions["top-left"]?.appendChild(this.a11yChipsEl);
|
|
1138
|
+
if (this.cbEl) this.els.zoom?.appendChild(this.cbEl);
|
|
1139
|
+
}
|
|
1140
|
+
const has = narrow && filters.children.length > 0;
|
|
1141
|
+
filters.classList.toggle("has", has);
|
|
1142
|
+
this.els.filtersSec?.classList.toggle("has", has);
|
|
1143
|
+
}
|
|
1144
|
+
if (this.lastSection) this.renderSectionCard(this.lastSection);
|
|
1145
|
+
}
|
|
1146
|
+
/** The "Need more time?" prompt shown in the hold's final EXTEND_PROMPT_MS. */
|
|
1147
|
+
buildExtendPrompt() {
|
|
1148
|
+
const el = document.createElement("div");
|
|
1149
|
+
el.className = "sl-extend";
|
|
1150
|
+
el.setAttribute("role", "status");
|
|
1151
|
+
el.innerHTML = `<span class="sl-extend-txt" data-ref="extendTxt"></span><button type="button" class="sl-extend-btn" data-ref="extendBtn"></button>`;
|
|
1152
|
+
(this.regions["bottom-center"] ?? this.els.map).appendChild(el);
|
|
1153
|
+
this.extendEl = el;
|
|
1154
|
+
this.els.extendTxt = el.querySelector('[data-ref="extendTxt"]');
|
|
1155
|
+
this.els.extendBtn = el.querySelector('[data-ref="extendBtn"]');
|
|
1156
|
+
this.els.extendBtn.textContent = "Add time";
|
|
1157
|
+
this.els.extendBtn.addEventListener("click", () => void this.handleExtend());
|
|
1158
|
+
}
|
|
1159
|
+
/** Success overlay + onBooked fire when the held seats settle to booked. */
|
|
1160
|
+
buildBookedOverlay() {
|
|
1161
|
+
const el = document.createElement("div");
|
|
1162
|
+
el.className = "sl-booked";
|
|
1163
|
+
el.setAttribute("role", "status");
|
|
1164
|
+
el.setAttribute("aria-live", "polite");
|
|
1165
|
+
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>`;
|
|
1166
|
+
this.root.appendChild(el);
|
|
1167
|
+
this.bookedEl = el;
|
|
1168
|
+
this.els.bookedSub = el.querySelector('[data-ref="bookedSub"]');
|
|
1169
|
+
}
|
|
1170
|
+
// ---- Feature 6: chrome anchor regions -------------------------------------
|
|
1171
|
+
/**
|
|
1172
|
+
* Create the positioned flex containers that own every persistent map overlay.
|
|
1173
|
+
* Appended once after controller.render(); each chrome piece is then appended
|
|
1174
|
+
* INTO its region and flows within it, so nothing free-floats over anything
|
|
1175
|
+
* else. Regions carve the map into non-overlapping zones (top strip split into
|
|
1176
|
+
* left/center/right, left rail, and the three used corners).
|
|
1177
|
+
*/
|
|
1178
|
+
buildRegions() {
|
|
1179
|
+
if (!this.els.map) return;
|
|
1180
|
+
const REGIONS = ["top-left", "top-center", "top-right", "left-rail", "bottom-left", "bottom-center", "bottom-right"];
|
|
1181
|
+
for (const region of REGIONS) {
|
|
1182
|
+
const el = document.createElement("div");
|
|
1183
|
+
el.className = "sl-anchor";
|
|
1184
|
+
el.dataset.region = region;
|
|
1185
|
+
this.els.map.appendChild(el);
|
|
1186
|
+
this.regions[region] = el;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
// ---- F3 minimap -----------------------------------------------------------
|
|
1190
|
+
/** Read a resolved --sl-* token value (canvas needs a real color, not var()). */
|
|
1191
|
+
cssVar(name) {
|
|
1192
|
+
return this.root ? getComputedStyle(this.root).getPropertyValue(name).trim() : "";
|
|
1193
|
+
}
|
|
1194
|
+
/** Section-bearing objects on the active floor (single-floor → doc.objects). */
|
|
1195
|
+
activeFloorObjects() {
|
|
1196
|
+
const doc = this.controller.doc;
|
|
1197
|
+
if (!doc) return [];
|
|
1198
|
+
const floors = doc.floors;
|
|
1199
|
+
if (floors?.length) {
|
|
1200
|
+
const id = this.controller.getActiveFloorId();
|
|
1201
|
+
return (floors.find((f) => f.id === id) ?? floors[0]).objects ?? [];
|
|
1202
|
+
}
|
|
1203
|
+
return doc.objects ?? [];
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Build the overview minimap: a static venue thumbnail (section outlines, or
|
|
1207
|
+
* seat dots when the chart has no sections) with the live viewport rectangle
|
|
1208
|
+
* drawn on top. The rect tracks pan/zoom via the constructor's onViewChange.
|
|
1209
|
+
*/
|
|
1210
|
+
buildMinimap() {
|
|
1211
|
+
const vp = this.controller.getViewport();
|
|
1212
|
+
if (!vp || !this.els.map) return;
|
|
1213
|
+
const b = vp.bounds;
|
|
1214
|
+
if (!(b.width > 0 && b.height > 0)) return;
|
|
1215
|
+
const MAXW = 158;
|
|
1216
|
+
const MAXH = 118;
|
|
1217
|
+
const PAD = 6;
|
|
1218
|
+
const aspect = b.width / Math.max(1, b.height);
|
|
1219
|
+
let w = MAXW;
|
|
1220
|
+
let h = Math.round(MAXW / aspect);
|
|
1221
|
+
if (h > MAXH) {
|
|
1222
|
+
h = MAXH;
|
|
1223
|
+
w = Math.round(MAXH * aspect);
|
|
1224
|
+
}
|
|
1225
|
+
w = Math.max(64, w);
|
|
1226
|
+
h = Math.max(48, h);
|
|
1227
|
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
1228
|
+
const wrap = document.createElement("div");
|
|
1229
|
+
wrap.className = "sl-minimap";
|
|
1230
|
+
wrap.setAttribute("aria-hidden", "true");
|
|
1231
|
+
const canvas = document.createElement("canvas");
|
|
1232
|
+
canvas.width = Math.round(w * dpr);
|
|
1233
|
+
canvas.height = Math.round(h * dpr);
|
|
1234
|
+
canvas.style.width = `${w}px`;
|
|
1235
|
+
canvas.style.height = `${h}px`;
|
|
1236
|
+
wrap.appendChild(canvas);
|
|
1237
|
+
(this.regions["bottom-left"] ?? this.els.map).appendChild(wrap);
|
|
1238
|
+
this.miniEl = wrap;
|
|
1239
|
+
this.miniCanvas = canvas;
|
|
1240
|
+
const scale = Math.min((w - PAD * 2) / Math.max(1, b.width), (h - PAD * 2) / Math.max(1, b.height)) * dpr;
|
|
1241
|
+
const offX = (w * dpr - b.width * scale) / 2 - b.x * scale;
|
|
1242
|
+
const offY = (h * dpr - b.height * scale) / 2 - b.y * scale;
|
|
1243
|
+
this.miniTf = { scale, offX, offY, dpr };
|
|
1244
|
+
const base = document.createElement("canvas");
|
|
1245
|
+
base.width = canvas.width;
|
|
1246
|
+
base.height = canvas.height;
|
|
1247
|
+
this.miniBase = base;
|
|
1248
|
+
wrap.addEventListener("click", (e) => this.minimapJump(e));
|
|
1249
|
+
this.drawMinimapStatic();
|
|
1250
|
+
this.drawMinimapRect();
|
|
1251
|
+
}
|
|
1252
|
+
/** Repaint the static overview + rect (floor switch, live open/close). */
|
|
1253
|
+
refreshMinimap() {
|
|
1254
|
+
if (!this.miniBase) return;
|
|
1255
|
+
this.drawMinimapStatic();
|
|
1256
|
+
this.drawMinimapRect();
|
|
1257
|
+
}
|
|
1258
|
+
/** Paint the venue overview into the offscreen base canvas. */
|
|
1259
|
+
drawMinimapStatic() {
|
|
1260
|
+
const base = this.miniBase;
|
|
1261
|
+
const tf = this.miniTf;
|
|
1262
|
+
const doc = this.controller.doc;
|
|
1263
|
+
if (!base || !tf || !doc) return;
|
|
1264
|
+
const ctx = base.getContext("2d");
|
|
1265
|
+
if (!ctx) return;
|
|
1266
|
+
ctx.clearRect(0, 0, base.width, base.height);
|
|
1267
|
+
const fx = (x) => x * tf.scale + tf.offX;
|
|
1268
|
+
const fy = (y) => y * tf.scale + tf.offY;
|
|
1269
|
+
const line = this.cssVar("--sl-line") || "rgba(139,147,167,.5)";
|
|
1270
|
+
const muted = this.cssVar("--sl-muted") || "#8b93a7";
|
|
1271
|
+
const accent = this.cssVar("--sl-accent") || "#6e7bff";
|
|
1272
|
+
const zoneColor = new Map((doc.zones ?? []).map((z) => [z.id, z.color]));
|
|
1273
|
+
let drewSection = false;
|
|
1274
|
+
for (const o of this.activeFloorObjects()) {
|
|
1275
|
+
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1276
|
+
drewSection = true;
|
|
1277
|
+
const closed = this.controller.isSectionClosed(o.id);
|
|
1278
|
+
const fill = closed ? muted : o.color ?? (o.zone && zoneColor.get(o.zone)) ?? accent;
|
|
1279
|
+
ctx.beginPath();
|
|
1280
|
+
o.outline.forEach((p, i) => i === 0 ? ctx.moveTo(fx(p.x), fy(p.y)) : ctx.lineTo(fx(p.x), fy(p.y)));
|
|
1281
|
+
ctx.closePath();
|
|
1282
|
+
ctx.globalAlpha = closed ? 0.26 : 0.42;
|
|
1283
|
+
ctx.fillStyle = fill;
|
|
1284
|
+
ctx.fill();
|
|
1285
|
+
ctx.globalAlpha = 0.85;
|
|
1286
|
+
ctx.lineWidth = Math.max(1, tf.dpr);
|
|
1287
|
+
ctx.strokeStyle = line;
|
|
1288
|
+
ctx.stroke();
|
|
1289
|
+
}
|
|
1290
|
+
ctx.globalAlpha = 1;
|
|
1291
|
+
if (!drewSection) {
|
|
1292
|
+
const r = Math.max(1, tf.dpr);
|
|
1293
|
+
for (const seat of expandChart(doc)) {
|
|
1294
|
+
const cat = doc.categories.find((c) => c.key === seat.categoryKey);
|
|
1295
|
+
ctx.fillStyle = cat?.color ?? accent;
|
|
1296
|
+
ctx.beginPath();
|
|
1297
|
+
ctx.arc(fx(seat.x), fy(seat.y), r, 0, Math.PI * 2);
|
|
1298
|
+
ctx.fill();
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/** Blit the base overview, then stroke the current viewport rectangle on top. */
|
|
1303
|
+
drawMinimapRect() {
|
|
1304
|
+
const canvas = this.miniCanvas;
|
|
1305
|
+
const base = this.miniBase;
|
|
1306
|
+
const tf = this.miniTf;
|
|
1307
|
+
if (!canvas || !base || !tf) return;
|
|
1308
|
+
const ctx = canvas.getContext("2d");
|
|
1309
|
+
if (!ctx) return;
|
|
1310
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1311
|
+
ctx.drawImage(base, 0, 0);
|
|
1312
|
+
const vp = this.controller.getViewport();
|
|
1313
|
+
if (!vp) return;
|
|
1314
|
+
const v = vp.visible;
|
|
1315
|
+
const x = v.x * tf.scale + tf.offX;
|
|
1316
|
+
const y = v.y * tf.scale + tf.offY;
|
|
1317
|
+
const w = v.width * tf.scale;
|
|
1318
|
+
const h = v.height * tf.scale;
|
|
1319
|
+
const accent = this.cssVar("--sl-accent") || "#f4b740";
|
|
1320
|
+
ctx.save();
|
|
1321
|
+
ctx.globalAlpha = 0.14;
|
|
1322
|
+
ctx.fillStyle = accent;
|
|
1323
|
+
ctx.fillRect(x, y, w, h);
|
|
1324
|
+
ctx.globalAlpha = 1;
|
|
1325
|
+
ctx.lineWidth = Math.max(1.5, tf.dpr * 1.5);
|
|
1326
|
+
ctx.strokeStyle = accent;
|
|
1327
|
+
ctx.strokeRect(x, y, w, h);
|
|
1328
|
+
ctx.restore();
|
|
1329
|
+
}
|
|
1330
|
+
/** Minimap click → focus the section under the point (or overview on a miss). */
|
|
1331
|
+
minimapJump(e) {
|
|
1332
|
+
const canvas = this.miniCanvas;
|
|
1333
|
+
const tf = this.miniTf;
|
|
1334
|
+
if (!canvas || !tf) return;
|
|
1335
|
+
const r = canvas.getBoundingClientRect();
|
|
1336
|
+
const px = (e.clientX - r.left) * (canvas.width / r.width);
|
|
1337
|
+
const py = (e.clientY - r.top) * (canvas.height / r.height);
|
|
1338
|
+
const wx = (px - tf.offX) / tf.scale;
|
|
1339
|
+
const wy = (py - tf.offY) / tf.scale;
|
|
1340
|
+
for (const o of this.activeFloorObjects()) {
|
|
1341
|
+
if (o.type !== "section" || !o.outline || o.outline.length < 3) continue;
|
|
1342
|
+
if (this.controller.isSectionClosed(o.id)) continue;
|
|
1343
|
+
if (pointInPolygon(wx, wy, o.outline)) {
|
|
1344
|
+
this.controller.focusSection(o.id);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
this.controller.overview();
|
|
1349
|
+
}
|
|
1350
|
+
// ---- F4 price-band filter -------------------------------------------------
|
|
1351
|
+
/** Effective price of a category (first tier when tiered, else base price). */
|
|
1352
|
+
catPrice(c) {
|
|
1353
|
+
return c.tiers?.length ? c.tiers[0].price : c.price;
|
|
1354
|
+
}
|
|
1355
|
+
/** Derive price bands: one chip per distinct price (≤5), else quantile ranges. */
|
|
1356
|
+
priceBands() {
|
|
1357
|
+
const doc = this.controller.doc;
|
|
1358
|
+
if (!doc) return [];
|
|
1359
|
+
const priced = doc.categories.map((c) => ({ key: c.key, price: this.catPrice(c) })).filter((x) => x.price != null);
|
|
1360
|
+
if (!priced.length) return [];
|
|
1361
|
+
const distinct = [...new Set(priced.map((p) => p.price))].sort((a, b) => a - b);
|
|
1362
|
+
if (distinct.length <= 5) {
|
|
1363
|
+
return distinct.map((price) => ({
|
|
1364
|
+
id: `p${price}`,
|
|
1365
|
+
label: this.money(price),
|
|
1366
|
+
keys: priced.filter((p) => p.price === price).map((p) => p.key),
|
|
1367
|
+
min: price,
|
|
1368
|
+
max: price
|
|
1369
|
+
}));
|
|
1370
|
+
}
|
|
1371
|
+
const chunk = Math.ceil(distinct.length / 4);
|
|
1372
|
+
const bands = [];
|
|
1373
|
+
for (let i = 0; i < distinct.length; i += chunk) {
|
|
1374
|
+
const slice = distinct.slice(i, i + chunk);
|
|
1375
|
+
const lo = slice[0];
|
|
1376
|
+
const hi = slice[slice.length - 1];
|
|
1377
|
+
bands.push({
|
|
1378
|
+
id: `b${i}`,
|
|
1379
|
+
label: lo === hi ? this.money(lo) : `${this.money(lo)}\u2013${this.money(hi)}`,
|
|
1380
|
+
keys: priced.filter((p) => p.price >= lo && p.price <= hi).map((p) => p.key),
|
|
1381
|
+
min: lo,
|
|
1382
|
+
max: hi
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
return bands;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Build the price-band chip row in the side panel, directly under the "Prices"
|
|
1389
|
+
* header and above the legend it dims. Living in the panel (not a map overlay)
|
|
1390
|
+
* keeps it clear of the top-center rung pills and top-left a11y chips, and it
|
|
1391
|
+
* rides the bottom sheet on narrow layouts. Skipped when there's <2 bands.
|
|
1392
|
+
*/
|
|
1393
|
+
buildPriceFilter() {
|
|
1394
|
+
if (!this.els.prices || !this.els.pricesSec) return;
|
|
1395
|
+
const bands = this.priceBands();
|
|
1396
|
+
if (bands.length < 2) return;
|
|
1397
|
+
const wrap = document.createElement("div");
|
|
1398
|
+
wrap.className = "sl-pricef";
|
|
1399
|
+
wrap.setAttribute("role", "group");
|
|
1400
|
+
wrap.setAttribute("aria-label", "Filter seats by price");
|
|
1401
|
+
const mk = (band, label, on) => `<button type="button" class="sl-chip-f${on ? " on" : ""}" data-band="${band}">${label}</button>`;
|
|
1402
|
+
wrap.innerHTML = mk("all", "All prices", true) + bands.map((bd) => mk(bd.id, bd.label, false)).join("");
|
|
1403
|
+
this.els.pricesSec.after(wrap);
|
|
1404
|
+
this.priceFilterEl = wrap;
|
|
1405
|
+
wrap.querySelectorAll("button").forEach((btn) => {
|
|
1406
|
+
btn.addEventListener("click", () => {
|
|
1407
|
+
wrap.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn));
|
|
1408
|
+
const id = btn.dataset.band;
|
|
1409
|
+
if (id === "all") {
|
|
1410
|
+
this.priceBandKeys = null;
|
|
1411
|
+
this.controller.setCategoryFilter(null);
|
|
1412
|
+
} else {
|
|
1413
|
+
const bd = bands.find((x) => x.id === id);
|
|
1414
|
+
this.priceBandKeys = bd ? new Set(bd.keys) : null;
|
|
1415
|
+
this.controller.setCategoryFilter(bd ? bd.keys : null);
|
|
1416
|
+
}
|
|
1417
|
+
this.syncPrices();
|
|
1418
|
+
if (this.lastSection) this.showSectionCard(this.lastSection);
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
902
1422
|
// ---- arena / multi-floor chrome -------------------------------------------
|
|
903
1423
|
/** Build the rung pills (charts with sections) and floor switcher (>1 floor). */
|
|
904
1424
|
buildArenaChrome() {
|
|
@@ -927,7 +1447,7 @@ var SeatPicker = class _SeatPicker {
|
|
|
927
1447
|
pills.querySelectorAll("button").forEach((btn) => {
|
|
928
1448
|
btn.addEventListener("click", () => this.controller.setRung(btn.dataset.rung));
|
|
929
1449
|
});
|
|
930
|
-
this.
|
|
1450
|
+
this.regions["top-center"].appendChild(pills);
|
|
931
1451
|
this.rungsEl = pills;
|
|
932
1452
|
this.syncRung();
|
|
933
1453
|
}
|
|
@@ -944,9 +1464,10 @@ var SeatPicker = class _SeatPicker {
|
|
|
944
1464
|
this.showSectionCard(null);
|
|
945
1465
|
this.syncFloors();
|
|
946
1466
|
this.syncRung();
|
|
1467
|
+
this.refreshMinimap();
|
|
947
1468
|
});
|
|
948
1469
|
});
|
|
949
|
-
this.
|
|
1470
|
+
this.regions["left-rail"].appendChild(rail);
|
|
950
1471
|
this.floorsEl = rail;
|
|
951
1472
|
this.syncFloors();
|
|
952
1473
|
}
|
|
@@ -971,27 +1492,105 @@ var SeatPicker = class _SeatPicker {
|
|
|
971
1492
|
}
|
|
972
1493
|
/** Show (or clear, on null) the tapped-section summary card. */
|
|
973
1494
|
showSectionCard(summary) {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1495
|
+
this.lastSection = summary;
|
|
1496
|
+
this.secCardEl?.remove();
|
|
1497
|
+
this.secCardEl = null;
|
|
1498
|
+
if (!summary) return;
|
|
1499
|
+
this.secCardCollapsed = false;
|
|
1500
|
+
this.secCardShownAt = Date.now();
|
|
1501
|
+
this.renderSectionCard(summary);
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Render the section card in the form the layout + state want: expanded card
|
|
1505
|
+
* or slim pill in the top-center anchor region (wide), or a compact strip in
|
|
1506
|
+
* the sheet head (narrow). Never floats over the seats at the tap point.
|
|
1507
|
+
*/
|
|
1508
|
+
renderSectionCard(summary) {
|
|
979
1509
|
if (!this.els.map) return;
|
|
980
1510
|
this.secCardEl?.remove();
|
|
981
1511
|
const priceLabel = summary.priceMin === summary.priceMax ? this.money(summary.priceMin) : `${this.money(summary.priceMin)}\u2013${this.money(summary.priceMax)}`;
|
|
1512
|
+
const leftLabel = tCount("picker.seatsLeftInSection", summary.seatsLeft);
|
|
1513
|
+
const xBtn = `<button type="button" class="sl-seccard-x" aria-label="${t2("picker.closeSectionSummary")}">\u2715</button>`;
|
|
982
1514
|
const card = document.createElement("div");
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1515
|
+
const narrow = this.root?.dataset.layout === "narrow";
|
|
1516
|
+
if (narrow) {
|
|
1517
|
+
card.className = "sl-seccard strip on";
|
|
1518
|
+
card.setAttribute("role", "status");
|
|
1519
|
+
card.innerHTML = `<span class="sl-seccard-dot" style="background:${summary.color}"></span><span class="sl-seccard-name">${summary.label}</span><span class="sl-seccard-left">${leftLabel}</span>` + (summary.categories.length ? `<span class="sl-seccard-price">${priceLabel}</span>` : "") + xBtn;
|
|
1520
|
+
card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
|
|
1521
|
+
(this.els.sheetHead ?? this.els.side ?? this.els.map).appendChild(card);
|
|
1522
|
+
} else if (this.secCardCollapsed) {
|
|
1523
|
+
card.className = "sl-seccard mini on";
|
|
1524
|
+
card.setAttribute("role", "button");
|
|
1525
|
+
card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
|
|
1526
|
+
card.innerHTML = `<span class="sl-seccard-dot" style="background:${summary.color}"></span><span class="sl-seccard-name">${summary.label}</span><span class="sl-seccard-left">${leftLabel}</span>` + xBtn;
|
|
1527
|
+
card.addEventListener("click", (e) => {
|
|
1528
|
+
if (e.target.closest(".sl-seccard-x")) return;
|
|
1529
|
+
this.secCardCollapsed = false;
|
|
1530
|
+
this.secCardShownAt = Date.now();
|
|
1531
|
+
this.renderSectionCard(summary);
|
|
1532
|
+
});
|
|
1533
|
+
card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
|
|
1534
|
+
(this.regions["top-center"] ?? this.els.map).appendChild(card);
|
|
1535
|
+
} else {
|
|
1536
|
+
card.className = "sl-seccard on";
|
|
1537
|
+
card.setAttribute("role", "dialog");
|
|
1538
|
+
card.setAttribute("aria-label", t2("picker.sectionSummaryAria", { label: summary.label }));
|
|
1539
|
+
const mix = summary.categories.map((c) => {
|
|
1540
|
+
const dim = this.priceBandKeys != null && !this.priceBandKeys.has(c.key);
|
|
1541
|
+
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>`;
|
|
1542
|
+
}).join("");
|
|
1543
|
+
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>`;
|
|
1544
|
+
card.querySelector(".sl-seccard-x").addEventListener("click", () => this.controller.overview());
|
|
1545
|
+
card.querySelector(".sl-seccard-overview").addEventListener("click", () => this.controller.overview());
|
|
1546
|
+
(this.regions["top-center"] ?? this.els.map).appendChild(card);
|
|
1547
|
+
}
|
|
993
1548
|
this.secCardEl = card;
|
|
994
1549
|
}
|
|
1550
|
+
/** Collapse the expanded card to its slim pill (seat-picking started). */
|
|
1551
|
+
collapseSectionCard() {
|
|
1552
|
+
if (!this.secCardEl || this.secCardCollapsed || !this.lastSection) return;
|
|
1553
|
+
if (this.root?.dataset.layout === "narrow") return;
|
|
1554
|
+
this.secCardCollapsed = true;
|
|
1555
|
+
this.renderSectionCard(this.lastSection);
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* onViewChange hook for the card. The focus glide's own settle (within the
|
|
1559
|
+
* grace window) enforces the ~25% coverage rule with the FINAL viewport; any
|
|
1560
|
+
* later pan/zoom means seat-picking has begun → collapse to the pill.
|
|
1561
|
+
*/
|
|
1562
|
+
sectionCardOnView() {
|
|
1563
|
+
if (!this.secCardEl || this.secCardCollapsed || !this.lastSection) return;
|
|
1564
|
+
if (this.root?.dataset.layout === "narrow") return;
|
|
1565
|
+
if (Date.now() - this.secCardShownAt < 1400) {
|
|
1566
|
+
if (this.sectionCardCoverage() > 0.25) this.collapseSectionCard();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
this.collapseSectionCard();
|
|
1570
|
+
}
|
|
1571
|
+
/** Fraction of the focused section's on-screen bbox covered by the card. */
|
|
1572
|
+
sectionCardCoverage() {
|
|
1573
|
+
const card = this.secCardEl;
|
|
1574
|
+
const sec = this.lastSection;
|
|
1575
|
+
if (!card || !sec || !this.els.map) return 0;
|
|
1576
|
+
const outline = this.activeFloorObjects().find((o) => o.type === "section" && o.id === sec.id)?.outline;
|
|
1577
|
+
if (!outline || outline.length < 3) return 0;
|
|
1578
|
+
const pts = outline.map((p) => this.controller.worldToScreen(p));
|
|
1579
|
+
const xs = pts.map((p) => p.x);
|
|
1580
|
+
const ys = pts.map((p) => p.y);
|
|
1581
|
+
const bx = Math.min(...xs);
|
|
1582
|
+
const by = Math.min(...ys);
|
|
1583
|
+
const bw = Math.max(...xs) - bx;
|
|
1584
|
+
const bh = Math.max(...ys) - by;
|
|
1585
|
+
if (bw <= 0 || bh <= 0) return 0;
|
|
1586
|
+
const mapR = this.els.map.getBoundingClientRect();
|
|
1587
|
+
const cr = card.getBoundingClientRect();
|
|
1588
|
+
const cx = cr.left - mapR.left;
|
|
1589
|
+
const cy = cr.top - mapR.top;
|
|
1590
|
+
const ox = Math.max(0, Math.min(cx + cr.width, bx + bw) - Math.max(cx, bx));
|
|
1591
|
+
const oy = Math.max(0, Math.min(cy + cr.height, by + bh) - Math.max(cy, by));
|
|
1592
|
+
return ox * oy / (bw * bh);
|
|
1593
|
+
}
|
|
995
1594
|
/** aria-live readout when keyboard focus lands on a seat. */
|
|
996
1595
|
announceSeat(seat) {
|
|
997
1596
|
if (!this.srEl) return;
|
|
@@ -1166,7 +1765,8 @@ var SeatPicker = class _SeatPicker {
|
|
|
1166
1765
|
const left = this.controller.categoryAvailability();
|
|
1167
1766
|
this.els.prices.innerHTML = doc.categories.map((c) => {
|
|
1168
1767
|
const price = c.tiers?.length ? c.tiers[0].price : c.price;
|
|
1169
|
-
|
|
1768
|
+
const dim = this.priceBandKeys != null && !this.priceBandKeys.has(c.key);
|
|
1769
|
+
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>`;
|
|
1170
1770
|
}).join("");
|
|
1171
1771
|
this.els.prices.querySelectorAll(".sl-price-row").forEach((row) => {
|
|
1172
1772
|
row.addEventListener("mouseenter", () => this.controller.getRenderer()?.setCategoryHighlight?.(row.dataset.cat ?? null));
|
|
@@ -1272,12 +1872,26 @@ var SeatPicker = class _SeatPicker {
|
|
|
1272
1872
|
const cta = this.els.cta;
|
|
1273
1873
|
cta.disabled = count === 0;
|
|
1274
1874
|
cta.textContent = this.hold ? "Continue to checkout" : count ? "Hold seats & checkout" : "Select seats";
|
|
1875
|
+
if (this.els.peek) {
|
|
1876
|
+
if (count) {
|
|
1877
|
+
this.els.peek.innerHTML = `<span>${count} ${count === 1 ? "ticket" : "tickets"} \xB7 ${this.money(total)}</span><span class="go">${this.hold ? "Continue \u2192" : "Review \u2192"}</span>`;
|
|
1878
|
+
} else {
|
|
1879
|
+
const prices = (this.controller.doc?.categories ?? []).map((c) => this.catPrice(c)).filter((p) => p != null);
|
|
1880
|
+
this.els.peek.innerHTML = (prices.length ? `<span>From ${this.money(Math.min(...prices))}</span>` : "<span>Pick your seats</span>") + `<span class="sub">\xB7 Best available</span><span class="go">\u2191</span>`;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
if (this.root?.dataset.layout === "narrow" && count > 0 && this.lastTrayCount === 0) {
|
|
1884
|
+
this.root.dataset.sheet = "open";
|
|
1885
|
+
}
|
|
1886
|
+
this.lastTrayCount = count;
|
|
1275
1887
|
this.opts.onSelectionChange?.(seats);
|
|
1276
1888
|
}
|
|
1277
1889
|
async handleCta() {
|
|
1278
1890
|
const cta = this.els.cta;
|
|
1279
1891
|
if (this.hold && !this.controller.getSelection().some((s) => !(this.hold.items ?? []).some((i) => i.label === s.label))) {
|
|
1280
|
-
|
|
1892
|
+
const seats = this.hold.seats ?? this.controller.getSelection();
|
|
1893
|
+
this.handedOff = true;
|
|
1894
|
+
this.opts.onCheckout?.(this.hold, seats, this.buildHandoff(this.hold));
|
|
1281
1895
|
return;
|
|
1282
1896
|
}
|
|
1283
1897
|
cta.disabled = true;
|
|
@@ -1300,8 +1914,9 @@ var SeatPicker = class _SeatPicker {
|
|
|
1300
1914
|
return;
|
|
1301
1915
|
}
|
|
1302
1916
|
this.hold = hold;
|
|
1917
|
+
this.handedOff = true;
|
|
1303
1918
|
this.startHoldTimer(hold.expiresAt);
|
|
1304
|
-
this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? []);
|
|
1919
|
+
this.opts.onCheckout?.(hold, chosenSeats.length ? chosenSeats : hold.seats ?? [], this.buildHandoff(hold));
|
|
1305
1920
|
} catch (err) {
|
|
1306
1921
|
this.opts.onError?.(err);
|
|
1307
1922
|
this.toast("One or more seats were just taken. Please pick again.");
|
|
@@ -1311,13 +1926,15 @@ var SeatPicker = class _SeatPicker {
|
|
|
1311
1926
|
}
|
|
1312
1927
|
startHoldTimer(expiresAt) {
|
|
1313
1928
|
this.stopHoldTimer();
|
|
1929
|
+
this.holdExpiresAt = expiresAt;
|
|
1314
1930
|
const pill = this.els.hold;
|
|
1315
1931
|
const tick = () => {
|
|
1316
|
-
const ms = Math.max(0,
|
|
1932
|
+
const ms = Math.max(0, this.holdExpiresAt - Date.now());
|
|
1317
1933
|
const m = Math.floor(ms / 6e4);
|
|
1318
1934
|
const s = String(Math.floor(ms % 6e4 / 1e3)).padStart(2, "0");
|
|
1319
1935
|
pill.textContent = `Held ${m}:${s}`;
|
|
1320
1936
|
pill.classList.add("on");
|
|
1937
|
+
this.setExtendPrompt(ms > 0 && ms <= EXTEND_PROMPT_MS, ms);
|
|
1321
1938
|
if (ms <= 0) this.stopHoldTimer();
|
|
1322
1939
|
};
|
|
1323
1940
|
tick();
|
|
@@ -1327,6 +1944,83 @@ var SeatPicker = class _SeatPicker {
|
|
|
1327
1944
|
if (this.holdTimer) clearInterval(this.holdTimer);
|
|
1328
1945
|
this.holdTimer = null;
|
|
1329
1946
|
this.els.hold?.classList.remove("on");
|
|
1947
|
+
this.setExtendPrompt(false, 0);
|
|
1948
|
+
}
|
|
1949
|
+
/** Show/refresh (or hide) the "Need more time?" prompt with the live seconds left. */
|
|
1950
|
+
setExtendPrompt(show, ms) {
|
|
1951
|
+
if (!this.extendEl) return;
|
|
1952
|
+
if (show && this.controller.currentHold() && !this.bookedShown) {
|
|
1953
|
+
const secs = Math.ceil(ms / 1e3);
|
|
1954
|
+
this.els.extendTxt.innerHTML = `Your seats are held for <b>0:${String(secs).padStart(2, "0")}</b>. Need more time?`;
|
|
1955
|
+
this.extendEl.classList.add("on");
|
|
1956
|
+
} else {
|
|
1957
|
+
this.extendEl.classList.remove("on");
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
async handleExtend() {
|
|
1961
|
+
const btn = this.els.extendBtn;
|
|
1962
|
+
btn.disabled = true;
|
|
1963
|
+
const prev = btn.textContent;
|
|
1964
|
+
btn.textContent = "Adding\u2026";
|
|
1965
|
+
try {
|
|
1966
|
+
const h = await this.controller.extendHold(this.opts.holdTtlMs);
|
|
1967
|
+
if (h) {
|
|
1968
|
+
this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
|
|
1969
|
+
this.holdExpiresAt = h.expiresAt;
|
|
1970
|
+
this.extendEl?.classList.remove("on");
|
|
1971
|
+
this.toast("More time added \u2014 your seats are still held.");
|
|
1972
|
+
} else {
|
|
1973
|
+
this.toast("Couldn't add more time \u2014 please head to checkout now.");
|
|
1974
|
+
}
|
|
1975
|
+
} catch (err) {
|
|
1976
|
+
this.opts.onError?.(err);
|
|
1977
|
+
this.toast("Couldn't add more time \u2014 please head to checkout now.");
|
|
1978
|
+
} finally {
|
|
1979
|
+
btn.disabled = false;
|
|
1980
|
+
btn.textContent = prev;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Fire the booked-confirmation state once the buyer's held seats settle to
|
|
1985
|
+
* booked. The controller clears its own hold the moment every held label reads
|
|
1986
|
+
* 'booked' over the realtime channel (clearBookedHoldIfSettled), and this runs
|
|
1987
|
+
* on the same onStatusChange — so `currentHold() === null` while we still hold
|
|
1988
|
+
* a checkout handoff means "sold", not expired (expiry clears via onHoldExpired
|
|
1989
|
+
* on a different path, which nulls this.hold first).
|
|
1990
|
+
*/
|
|
1991
|
+
detectBooked() {
|
|
1992
|
+
if (this.bookedShown || !this.handedOff || !this.hold) return;
|
|
1993
|
+
if (this.controller.currentHold() !== null) return;
|
|
1994
|
+
this.showBooked();
|
|
1995
|
+
}
|
|
1996
|
+
showBooked() {
|
|
1997
|
+
if (this.bookedShown || !this.hold) return;
|
|
1998
|
+
this.bookedShown = true;
|
|
1999
|
+
const handoff = this.buildHandoff(this.hold);
|
|
2000
|
+
this.stopHoldTimer();
|
|
2001
|
+
const n = handoff.lineItems.reduce((sum, i) => sum + i.quantity, 0);
|
|
2002
|
+
if (this.els.bookedSub) {
|
|
2003
|
+
this.els.bookedSub.innerHTML = `<span class="sl-booked-seats">${n} ${n === 1 ? "ticket" : "tickets"}</span> confirmed. A confirmation is on its way.`;
|
|
2004
|
+
}
|
|
2005
|
+
this.bookedEl?.classList.add("on");
|
|
2006
|
+
this.opts.onBooked?.(handoff);
|
|
2007
|
+
}
|
|
2008
|
+
/** Assemble the stable {@link CheckoutHandoff} from a hold's server line items. */
|
|
2009
|
+
buildHandoff(hold) {
|
|
2010
|
+
const items = hold.items ?? [];
|
|
2011
|
+
const lineItems = items.map((it) => ({
|
|
2012
|
+
label: it.label,
|
|
2013
|
+
objectId: it.objectId,
|
|
2014
|
+
objectType: it.objectType,
|
|
2015
|
+
categoryKey: it.categoryKey,
|
|
2016
|
+
tierId: it.tierId,
|
|
2017
|
+
unitPrice: it.unitPrice,
|
|
2018
|
+
currency: it.currency ?? this.currency,
|
|
2019
|
+
quantity: it.quantity ?? 1
|
|
2020
|
+
}));
|
|
2021
|
+
const currency = lineItems[0]?.currency ?? this.currency;
|
|
2022
|
+
const total = lineItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
|
2023
|
+
return { holdId: hold.holdId, expiresAt: hold.expiresAt, currency, lineItems, total };
|
|
1330
2024
|
}
|
|
1331
2025
|
toast(msg) {
|
|
1332
2026
|
const el = this.els.toast;
|
|
@@ -1368,6 +2062,8 @@ var SeatPicker = class _SeatPicker {
|
|
|
1368
2062
|
const h = await this.controller.bestAvailable(qty, categoryKey);
|
|
1369
2063
|
if (h) {
|
|
1370
2064
|
this.hold = { holdId: h.holdId, expiresAt: h.expiresAt, seats: h.seats, items: h.items };
|
|
2065
|
+
this.handedOff = false;
|
|
2066
|
+
this.bookedShown = false;
|
|
1371
2067
|
this.startHoldTimer(h.expiresAt);
|
|
1372
2068
|
this.syncTray();
|
|
1373
2069
|
return this.hold;
|
|
@@ -1381,6 +2077,8 @@ var SeatPicker = class _SeatPicker {
|
|
|
1381
2077
|
async release() {
|
|
1382
2078
|
await this.controller.release();
|
|
1383
2079
|
this.hold = null;
|
|
2080
|
+
this.handedOff = false;
|
|
2081
|
+
this.bookedShown = false;
|
|
1384
2082
|
this.stopHoldTimer();
|
|
1385
2083
|
this.gaQty.clear();
|
|
1386
2084
|
this.syncTray();
|