PageSourceSearch

https://studioseaview.com/app/plugins/farlo-lineup/public/js/farlo-calendar-full.js?ver=1789560989

js studioseaview.com collected 2026-09-25 20:06:07 UTC 35,039 bytes, 943 lines download raw bytes

1class FarloCalendar {
2  constructor(options = {}) {
3    this.container = document.querySelector("." + options.calendarID);
4
5    // Defaults
6    this.options = {
7      disablePastDays: true,
8      displayFormat: 'full',
9      minDate: moment(),
10      maxDate: moment().add(12, 'months'),
11      performances: { available: [], sold_out: [] },
12      lowAvailability: 10,
13      messages: {
14        goodAvailability: 'Good availability',
15        lowAvailability: 'Last Few',
16        noAvailability: 'Sold out'
17      },
18      display: 'split',
19      filters:{
20        pax:2,
21        product:null,
22        tier:null,
23        tags:[],
24      },
25      products: {},
26      productColours: {},
27      showDetails: {}
28    };
29
30    this.options = { ...this.options, ...options };
31
32    const now = moment();
33    this.options.minDate = moment(this.options.minDate);
34    if (this.options.minDate.isBefore(now) && this.options.disablePastDays) {
35      this.options.minDate = now.clone().startOf('day');
36    }
37    this.options.maxDate = moment(this.options.maxDate);
38
39    this.month    = this.container.querySelector('[data-calendar-area="month"]');
40    this.list     = this.container.querySelector('[data-calendar-area="list"]');
41    this.next     = this.container.querySelector('[data-calendar-toggle="next"]');
42    this.previous = this.container.querySelector('[data-calendar-toggle="previous"]');
43    this.label    = this.container.querySelector('[data-calendar-label="month"]');
44    this.weekPrev  = this.container.querySelector('[data-calendar-toggle="week-previous"]');
45    this.weekNext  = this.container.querySelector('[data-calendar-toggle="week-next"]');
46    this.weekLabel = this.container.querySelector('[data-calendar-label="week"]');
47    this.product  = this.container.querySelector('[data-calendar-product]');
48    this.tier     = this.container.querySelector('[data-calendar-tier]');
49    this.pax      = this.container.querySelector('[data-calendar-pax]');
50    this.viewButtons = this.container.querySelectorAll('[data-calendar-view]');
51    this.viewToggle  = this.container.querySelector('.farlo-cal-view-toggle');
52
53    if (!this.month || !this.next || !this.previous || !this.label) {
54      throw new Error('FarloCalendar: required child elements not found in container');
55    }
56
57    this.activeDates = null;
58    this.viewMode           = this.getStoredViewMode() || 'calendar';
59    this.date               = this.options.minDate.clone().startOf('month');
60    this.viewMonth          = this.options.minDate.clone().startOf('month');
61    this.todaysDate         = moment();
62    this.options.variants   = this.createVariantArray();
63    this.resetWeekToStartOfMonth();
64
65    this.viewButtons.forEach((btn) => {
66      btn.classList.toggle('farlo-cal-view-btn--active', btn.dataset.calendarView === this.viewMode);
67    });
68    this.container.classList.toggle('farlo-cal--list-view', this.viewMode === 'list');
69
70    if (this.viewMode === 'list') {
71      this.createList();
72    } else {
73      this.createMonth();
74    }
75
76    this.createNavListeners();
77    this.createWeekNavListeners();
78    this.createFilterListeners();
79    this.createViewToggleListeners();
80    this.renderShowKey();
81    this.bindEventPanel();
82
83    this.positionViewToggle();
84    window.addEventListener('resize', () => {
85      this.positionViewToggle();
86      this.positionSidePanel();
87    });
88  }
89
90  // Aligns the toggle (positioned against #farlo-cal so it doesn't shift when the
91  // sidebar narrows .farlo-cal-main) vertically with the row it visually belongs to:
92  // the show key / week nav row on mobile, the month header row on desktop.
93  positionViewToggle() {
94    if (!this.viewToggle) return;
95
96    const isMobile = !window.matchMedia('(min-width: 980px)').matches;
97    let reference = null;
98
99    if (isMobile) {
100      reference = this.viewMode === 'list'
101        ? this.container.querySelector('.farlo-cal-week-nav')
102        : this.container.querySelector('.farlo-cal-show-key');
103    }
104    if (!reference) {
105      reference = this.container.querySelector('.farlo-cal-header');
106    }
107    if (!reference) return;
108
109    const calRect = this.container.getBoundingClientRect();
110    const refRect = reference.getBoundingClientRect();
111    const refCenter = (refRect.top - calRect.top) + refRect.height / 2;
112
113    this.viewToggle.style.top = `${refCenter - this.viewToggle.offsetHeight / 2}px`;
114  }
115
116  getStoredViewMode() {
117    const match = document.cookie.match(/(?:^|;\s*)farlo_calendar_view=([^;]+)/);
118    return match ? match[1] : null;
119  }
120
121  setStoredViewMode(mode) {
122    const expires = new Date(Date.now() + 60 * 60 * 1000);
123    document.cookie = `farlo_calendar_view=${mode}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
124  }
125
126  refreshView() {
127    if (this.viewMode === 'list') {
128      this.createList();
129    } else {
130      this.clearCalendar();
131      this.createMonth();
132    }
133  }
134
135  createViewToggleListeners() {
136    this.viewButtons.forEach((btn) => {
137      btn.addEventListener('click', () => this.setViewMode(btn.dataset.calendarView));
138    });
139  }
140
141  setViewMode(mode) {
142    if (this.viewMode === mode) return;
143    this.viewMode = mode;
144    this.setStoredViewMode(mode);
145
146    this.viewButtons.forEach((btn) => {
147      btn.classList.toggle('farlo-cal-view-btn--active', btn.dataset.calendarView === mode);
148    });
149    this.container.classList.toggle('farlo-cal--list-view', mode === 'list');
150
151    if (mode === 'list') {
152      this.resetWeekToStartOfMonth();
153      this.closeEventPanel();
154    }
155
156    this.refreshView();
157    this.positionViewToggle();
158  }
159
160  createVariantArray() {
161    const variants = [];
162    if (typeof window.variant_pricing !== 'undefined' && Array.isArray(window.variant_pricing)) {
163      
164    }
165    return variants;
166  }
167
168  createNavListeners() {
169    this.next.addEventListener('click', () => {
170      if (!this.canGoNext()) return;
171      this.viewMonth.add(1, 'month');
172
173      if (this.viewMode === 'list') {
174        this.resetWeekToStartOfMonth();
175        this.createList();
176      } else {
177        this.clearCalendar();
178        this.createMonth();
179      }
180    });
181
182    this.previous.addEventListener('click', () => {
183      if (!this.canGoPrev()) return;
184      this.viewMonth.subtract(1, 'month');
185
186      if (this.viewMode === 'list') {
187        this.resetWeekToStartOfMonth();
188        this.createList();
189      } else {
190        this.clearCalendar();
191        this.createMonth();
192      }
193    });
194  }
195
196  createWeekNavListeners() {
197    if (this.weekNext) {
198      this.weekNext.addEventListener('click', () => {
199        if (!this.canGoNextWeek()) return;
200        this.viewWeek.add(1, 'week');
201        this.syncMonthToWeek();
202        this.createList();
203      });
204    }
205
206    if (this.weekPrev) {
207      this.weekPrev.addEventListener('click', () => {
208        if (!this.canGoPrevWeek()) return;
209        this.viewWeek.subtract(1, 'week');
210        this.syncMonthToWeek();
211        this.createList();
212      });
213    }
214  }
215
216  resetWeekToStartOfMonth() {
217    let firstWeek = this.viewMonth.clone().startOf('month').startOf('week');
218    if (this.options.disablePastDays && firstWeek.isBefore(this.options.minDate, 'week')) {
219      firstWeek = this.options.minDate.clone().startOf('week');
220    }
221    // Never open the list on a week that's entirely in the past.
222    const currentWeek = moment().startOf('week');
223    if (firstWeek.isBefore(currentWeek, 'week')) {
224      firstWeek = currentWeek;
225    }
226    this.viewWeek = firstWeek;
227  }
228
229  // Keeps the top month label in sync when the week nav steps across a month boundary.
230  syncMonthToWeek() {
231    const weekMonth = this.viewWeek.clone().startOf('month');
232    if (!weekMonth.isSame(this.viewMonth, 'month')) {
233      this.viewMonth = weekMonth;
234    }
235  }
236
237  canGoPrevWeek() {
238    if (!this.options.disablePastDays) return true;
239    const prevEnd = this.viewWeek.clone().subtract(1, 'week').endOf('week');
240    return !prevEnd.isBefore(this.options.minDate, 'day');
241  }
242
243  canGoNextWeek() {
244    const nextStart = this.viewWeek.clone().add(1, 'week').startOf('week');
245    return nextStart.isSameOrBefore(this.options.maxDate, 'day');
246  }
247
248  updateHeaderLabel() {
249    this.label.innerHTML = `<span>${this.viewMonth.format('MMMM')}</span><span style="color:var(--global-primary-colour);">${this.viewMonth.format('YYYY')}</span>`;
250  }
251
252  createFilterListeners() {
253    const self = this;
254    if (this.product) {
255      let selectedProduct = null;
256      this.product.addEventListener("change",(e) => {
257        selectedProduct = e.target.value;
258        if (self.options.filters.product !== selectedProduct) {
259              self.options.filters.product = selectedProduct;
260              self.refreshView();
261        }
262      });
263    
264    }
265    if (this.pax) {
266      let selectedPax = 0;
267      this.pax.addEventListener("change",(e) => {
268        selectedPax = e.target.value;
269        if (self.options.filters.pax !== selectedPax) {
270              self.options.filters.pax = selectedPax;
271              self.refreshView();
272        }
273      });
274    
275    }
276
277    if (this.tier) {
278      let selectedTier = null;
279      this.tier.addEventListener("change",(e) => {
280        selectedTier = e.target.value;
281        if (self.options.filters.tier !== selectedTier) {
282              self.options.filters.tier = selectedTier;
283              self.refreshView();
284        }
285      });
286    
287    }
288
289  }
290
291  createDay(num, day, year, month) {
292    const newDay = document.createElement('div');
293    const dateEl = document.createElement('span');
294    dateEl.innerHTML = `<span class="farlo-cal-date-number">${String(num).padStart(2, '0')}</span>`;
295    newDay.className = 'farlo-cal-date';
296
297    const thisCellDate = moment().year(year).month(month).date(num).startOf('day');
298    newDay.setAttribute('data-calendar-date', thisCellDate.format());
299
300    const compare = thisCellDate.format('YYYY-MM-DD');
301
302    if (compare === this.getTodayInNewYork()) {
303      newDay.classList.add('farlo-cal-date--today');
304    }
305
306    const product = this.options.filters.product;
307    const pax = this.options.filters.pax;
308    const tier = this.options.filters.tier;
309
310    let collection = (this.options.performances || []).filter(x =>
311      moment(x.start).format('YYYY-MM-DD') === compare && 
312      (!product ? true : x.product_id == product) && 
313      (!pax ? true : x.availability === 0 || x.availability >= pax) &&
314      (!tier ? true : x.prices.filter(p => {return p.name == tier && p.availability >= pax } ).length)
315    );
316
317    const addPerfMarkup = (html) => {
318      const wrap = `<span class="farlo-cal-date-perf">${html}</span>`;
319      dateEl.innerHTML += wrap;
320    };
321
322    if (collection.length > 0) {
323      const productIds = [...new Set(collection.map((c) => c.product_id))];
324      newDay.dataset.productIds = productIds.join(',');
325
326      newDay.classList.add('farlo-cal-date--active');
327      newDay.dataset.calendarStatus = 'active';
328
329      let html = '';
330      collection.forEach((_perf) => {
331        let cls = '';
332        const msg = moment(_perf.start).format('h:mma');
333
334        let avail = parseInt(_perf.availability, 10);
335        if (this.options.filters.tier) {
336          const selectedTier = (_perf.prices || []).filter(_p => {
337            return _p.name == this.options.filters.tier;
338          });
339          if (selectedTier.length) {
340            avail = parseInt(selectedTier[0].availability, 10);
341          }
342        }
343
344        const title = _perf.title ? `<span class="perf__title">${_perf.title}</span>` : '';
345
346        const perfLowAvail = _perf.lowAvailability != null ? _perf.lowAvailability : this.options.lowAvailability;
347        if (avail > perfLowAvail) {
348          cls = 'perf-good';
349        } else if (avail > 0) {
350          cls = 'perf-low';
351        } else {
352          cls = 'perf-sold-out';
353        }
354
355        const href = _perf.url ? ` href="${_perf.url}"` : '';
356        const styleAttr = this.getPerfStyleAttr(_perf.product_id);
357        const dotStyle = this.getPerfDotStyle(_perf.product_id);
358        html += `<a class="perf ${cls}" data-product-id="${_perf.product_id}" data-performance-id="${_perf.id}"${href}${styleAttr}>${dotStyle ? `<span class="perf__dot"${dotStyle}></span>` : ''}<span class="perf__time">${msg}</span>${title}</a>`;
359      });
360      addPerfMarkup(html);
361    } else {
362      newDay.classList.add('farlo-cal-date--disabled');
363    }
364
365    newDay.appendChild(dateEl);
366    this.month.appendChild(newDay);
367  }
368
369  dateClicked() {
370    this.activeDates = this.container.querySelectorAll('[data-calendar-status="active"]');
371    this.activeDates.forEach((el) => {
372      el.addEventListener('click', (event) => {
373        const picked = this.container.querySelector('[data-calendar-label="picked"]');
374        if (!picked) return;
375
376        const sel = event.currentTarget;
377        const selDate = moment(sel.dataset.calendarDate);
378        const collection = (this.options.performances || []).filter(x =>
379          moment(x.start).format('YYYY-MM-DD') === selDate.format('YYYY-MM-DD')
380        );
381
382        if (window._farlo && _farlo.events && typeof _farlo.events.push === 'function') {
383          _farlo.events.push('Date Selected', this.options.product_id, selDate.format('YYYY-MM-DD'), sel.dataset.calendarPrice);
384        }
385
386        let html = `<h5>${selDate.format('DD MMM YYYY')}</h5>`;
387
388        collection.forEach((c) => {
389            const dt = moment(c.start);
390            let cls = '';
391            const msg = dt.format('h:mma');
392            const title = c.title ? `<span class="perf__title">${c.title}</span>` : '';
393            const avail = parseInt(c.availability, 10);
394
395            const cLowAvail = c.lowAvailability != null ? c.lowAvailability : this.options.lowAvailability;
396            if (avail > cLowAvail) cls = 'perf-good';
397            else if (avail > 0) cls = 'perf-low';
398            else cls = 'perf-sold-out';
399
400            const href = c.url ? ` href="${c.url}"` : '';
401            html += `<a class="farlo-event-cal-link" data-calendar-datetime="${dt.format()}"${href}><span class="perf ${cls}"><span class="perf__time">${msg}</span>${title}</span></a>`;
402          });
403          picked.innerHTML = html;
404          this.showPerformances(collection.length * 71);
405
406
407        this.removeActiveClass();
408        sel.classList.add('farlo-cal-date--selected');
409      });
410    });
411  }
412
413  showPerformances(n) {
414    const footer = this.container.querySelector('.farlo-cal-footer');
415    const side   = this.container.querySelector('.farlo-cal-side');
416    if (!footer) return;
417
418    if (n === 0) {
419      // footer.style.opacity = 0; // keep behaviour minimal as per original
420    } else {
421      if (window.innerWidth < 980 && side) {
422        const elementPosition = side.getBoundingClientRect().top;
423        const headerOffset = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0;
424        const offsetPosition = elementPosition + window.scrollY - headerOffset;
425        window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
426      }
427      footer.style.opacity = 1;
428    }
429  }
430
431canGoPrev() {
432  if (!this.options.disablePastDays) return true;
433  const prevEnd = this.viewMonth.clone().subtract(1, 'month').endOf('month');
434  return !prevEnd.isBefore(this.options.minDate, 'day');
435}
436
437canGoNext() {
438  const nextStart = this.viewMonth.clone().add(1, 'month').startOf('month');
439  return nextStart.isSameOrBefore(this.options.maxDate, 'day');
440}
441
442  createAdjacentDay(date) {
443    const b = document.createElement('div');
444    b.className = 'farlo-cal-date farlo-cal-date--disabled farlo-cal-date--adjacent';
445
446    let html = `<span class="farlo-cal-date-number">
446${String(date.date()).padStart(2, '0')}</span>`;
447
448    const compare = date.format('YYYY-MM-DD');
449    const collection = this.filterPerformances(
450      (this.options.performances || []).filter((x) => moment(x.start).format('YYYY-MM-DD') === compare)
451    );
452
453    if (collection.length > 0) {
454      let perfHtml = '';
455      collection.forEach((_perf) => {
456        const msg = moment(_perf.start).format('h:mma');
457        const title = _perf.title ? `<span class="perf__price perf__title">${_perf.title}</span>` : '';
458        // Use an <a> (matching the markup for active days) so it picks up the same
459        // .farlo-cal-date-perf a font-family/size rules instead of falling back to
460        // the cell's default font on a plain span.
461        const styleAttr = this.getPerfStyleAttr(_perf.product_id);
462        const dotStyle = this.getPerfDotStyle(_perf.product_id);
463        perfHtml += `<a class="perf" tabindex="-1" aria-disabled="true"${styleAttr}>${dotStyle ? `<span class="perf__dot"${dotStyle}></span>` : ''}<span class="perf__time">${msg}</span>${title}</a>`;
464      });
465      html += `<span class="farlo-cal-date-perf">${perfHtml}</span>`;
466    }
467
468    b.innerHTML = `<span>${html}</span>`;
469    return b;
470  }
471
472  createMonth() {
473   this.previous.classList.toggle('farlo-cal-link-disabled', !this.canGoPrev());
474  this.next.classList.toggle('farlo-cal-link-disabled', !this.canGoNext());
475
476  this.showPerformances(0);
477  this.clearCalendar();
478    const cursor = this.viewMonth.clone().startOf('month');
479    let fillGrid = 0;
480
481    const leadBlanks = cursor.day();
482    const leadCursor = cursor.clone().subtract(leadBlanks, 'days');
483    for (let i = 0; i < leadBlanks; i++) {
484      fillGrid++;
485      this.month.appendChild(this.createAdjacentDay(leadCursor.clone()));
486      leadCursor.add(1, 'day');
487    }
488
489  const monthIndex = cursor.month();
490
491  while (cursor.month() === monthIndex) {
492    this.createDay(cursor.date(), cursor.day(), cursor.year(), cursor.month());
493    cursor.add(1, 'day');
494    fillGrid++;
495  }
496
497
498  while (fillGrid % 7 > 0) {
499      this.month.appendChild(this.createAdjacentDay(cursor.clone()));
500      cursor.add(1, 'day');
501      fillGrid++;
502  }
503
504  // label reflects the anchor month, not the advanced cursor
505  this.updateHeaderLabel();
506
507
508  if (this.options.displayFormat === 'split') {
509    this.dateClicked();
510  }
511  }
512
513  clearCalendar() {
514    this.month.innerHTML = '';
515  }
516
517  filterPerformances(collection) {
518    const product = this.options.filters.product;
519    const pax = this.options.filters.pax;
520    const tier = this.options.filters.tier;
521
522    return collection.filter((x) =>
523      (!product ? true : x.product_id == product) &&
524      (!pax ? true : x.availability === 0 || x.availability >= pax) &&
525      (!tier ? true : (x.prices || []).filter((p) => p.name == tier && p.availability >= pax).length)
526    );
527  }
528
529  buildListRow(perf, showTitle) {
530    const row = document.createElement('div');
531    row.className = 'farlo-cal-list__row';
532    row.dataset.productId = perf.product_id;
533
534    const dateStr = moment(perf.start).format('MMM DD');
535    const timeStr = moment(perf.start).format('h:mmA');
536    const details = (this.options.showDetails || {})[perf.product_id] || {};
537    const title = showTitle ? (details.title || (this.options.products || {})[perf.product_id] || perf.title || '') : '';
538    const ticketUrl = perf.url && perf.url !== '#' ? perf.url : null;
539
540    row.innerHTML = `
541      <span class="farlo-cal-list__date">${dateStr}</span>
542      <span class="farlo-cal-list__time">${timeStr}</span>
543      ${showTitle ? `<span class="farlo-cal-list__title">${title}</span>` : ''}
544      ${ticketUrl
545        ? `<a class="farlo-cal-list__cta" href="${ticketUrl}">Buy Tickets</a>`
546        : `<span class="farlo-cal-list__cta farlo-cal-list__cta--disabled">${this.options.messages.noAvailability}</span>`}
547    `;
548    return row;
549  }
550
551  buildShowGroup(productId, perfs, showTitle = false) {
552    const starSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 63 60" width="16" height="16" aria-hidden="true"><path d="M32.54,.76l6.93,21.32h22.43c1.06,0,1.51,1.36,.65,1.98l-18.14,13.18,6.93,21.32c.33,1.01-.83,1.85-1.69,1.23l-18.14-13.18-18.14,13.18c-.86,.62-2.02-.22-1.69-1.23l6.93-21.32L.45,24.06c-.86-.62-.42-1.98,.65-1.98H23.53L30.46,.76c.33-1.01,1.76-1.01,2.09,0Z"/></svg>';
553
554    const details = (this.options.showDetails || {})[productId] || {};
555    const stars = Math.max(0, parseInt(details.reviewStars, 10) || 0);
556    const starsHtml = stars ? Array(stars).fill(starSvg).join('') : '';
557
558    const group = document.createElement('div');
559    group.className = 'farlo-cal-list__show';
560    group.innerHTML = `
561      <div class="farlo-cal-list__show-header">
562        ${details.image ? `<div class="farlo-cal-list__show-image"><img src="${details.image}" alt="${details.imageAlt || ''}" /></div>` : ''}
563        <div class="farlo-cal-list__show-info">
564          <span class="farlo-cal-list__show-title">${details.title || (this.options.products || {})[productId] || ''}</span>
565          ${(stars || details.reviewAttribution) ? `<div class="farlo-cal-list__show-review">
566            ${stars ? `<span class="farlo-cal-list__show-stars">${starsHtml}</span>` : ''}
567            ${details.reviewAttribution ? `<span class="farlo-cal-list__show-attribution">- ${details.reviewAttribution}</span>` : ''}
568          </div>` : ''}
569        </div>
570      </div>
571    `;
572
573    const rows = document.createElement('div');
574    rows.className = 'farlo-cal-list__rows';
575    perfs.forEach((perf) => {
576      rows.appendChild(this.buildListRow(perf, showTitle));
577    });
578    group.appendChild(rows);
579
580    return group;
581  }
582
583  createList() {
584    this.previous.classList.toggle('farlo-cal-link-disabled', !this.canGoPrev());
585    this.next.classList.toggle('farlo-cal-link-disabled', !this.canGoNext());
586    this.updateHeaderLabel();
587
588    if (this.weekPrev) {
589      this.weekPrev.classList.toggle('farlo-cal-link-disabled', !this.canGoPrevWeek());
590    }
591    if (this.weekNext) {
592      this.weekNext.classList.toggle('farlo-cal-link-disabled', !this.canGoNextWeek());
593    }
594
595    const weekStart = this.viewWeek.clone().startOf('week');
596    const weekEnd = this.viewWeek.clone().endOf('week');
597
598    if (this.weekLabel) {
599      this.weekLabel.innerHTML = `<span>${weekStart.format('MMM DD')}</span><span style="color:var(--global-primary-colour);"> - ${weekEnd.format('MMM DD')}</span>`;
600    }
601
602    const performances = this.filterPerformances(
603      (this.options.performances || []).filter((p) => {
604        const d = moment(p.start);
605        return d.isSameOrAfter(weekStart) && d.isSameOrBefore(weekEnd);
606      })
607    ).sort((a, b) => moment(a.start) - moment(b.start));
608
609    this.list.innerHTML = '';
610
611    if (!performances.length) {
612      this.list.innerHTML = '<div class="farlo-cal-list__empty">No performances this week.</div>';
613      return;
614    }
615
616    const mobileList = document.createElement('div');
617    mobileList.className = 'farlo-cal-list__mobile';
618    performances.forEach((perf) => {
619      mobileList.appendChild(this.buildListRow(perf, true));
620    });
621
622    const groups = {};
623    const order = [];
624    performances.forEach((perf) => {
625      const key = perf.product_id;
626      if (!groups[key]) {
627        groups[key] = [];
628        order.push(key);
629      }
630      groups[key].push(perf);
631    });
632
633    const desktopGrid = document.createElement('div');
634    desktopGrid.className = 'farlo-cal-list__desktop';
635    const colCount = order.length >= 4 ? 2 : order.length;
636    desktopGrid.style.gridTemplateColumns = `repeat(${colCount}, 1fr)`;
637    const showTitleInRows = order.length !== 3;
638    order.forEach((productId) => {
639      desktopGrid.appendChild(this.buildShowGroup(productId, groups[productId], showTitleInRows));
640    });
641
642    this.list.appendChild(mobileList);
643    this.list.appendChild(desktopGrid);
644  }
645
646  getTodayInNewYork() {
647    const parts = new Intl.DateTimeFormat('en-US', {
648      timeZone: 'America/New_York',
649      year: 'numeric',
650      month: '2-digit',
651      day: '2-digit'
652    }).formatToParts(new Date());
653
654    const map = {};
655    parts.forEach((p) => { map[p.type] = p.value; });
656    return `${map.year}-${map.month}-${map.day}`;
657  }
658
659  // True if black has enough contrast to stay legible against the given
660  // colour (WCAG AA, 4.5:1). A colour that is itself black/near-black fails
661  // this, since black-on-black is unreadable.
662  colourPassesContrastWithBlack(hex) {
663    let c = hex.replace('#', '');
664    if (c.length === 3) {
665      c = c.split('').map((ch) => ch + ch).join('');
666    }
667    c = c.slice(0, 6).padEnd(6, '0');
668
669    const r = parseInt(c.substr(0, 2), 16);
670    const g = parseInt(c.substr(2, 2), 16);
671    const b = parseInt(c.substr(4, 2), 16);
672    const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
673
674    // WCAG contrast ratio of pure black (luminance 0) against this colour.
675    const contrastWithBlack = (luminance + 0.05) / 0.05;
676    return contrastWithBlack >= 4.5;
677  }
678
679  // A show's colour as used for backgrounds: kept as-is if black reads
680  // legibly on top of it, otherwise swapped for white so a too-dark/black
681  // show colour never gets used as a background in its own right.
682  getAccessibleShowColour(hex) {
683    return this.colourPassesContrastWithBlack(hex) ? hex : '#ffffff';
684  }
685
686  // Cream background + dark text for every performance tile.
687  getPerfStyleAttr(productId) {
688    return ` style="background-color:var(--global-secondary-accent-colour); color:#212121;"`;
689  }
690
691  // Inline style for the coloured dot inside a tile. Returns '' if no colour set.
692  getPerfDotStyle(productId) {
693    const rawColour = (this.options.productColours || {})[productId];
694    if (!rawColour || !/^#[0-9a-fA-F]{3,8}$/.test(rawColour)) return '';
695    const colour = this.getAccessibleShowColour(rawColour);
696    return ` style="background-color:${colour};"`;
697  }
698
699  renderShowKey() {
700    const products = this.options.products || {};
701    const colours = this.options.productColours || {};
702    const ids = Object.keys(products);
703    if (!ids.length) return;
704
705    const dim = (id) => {
706      this.container.querySelectorAll('.perf[data-product-id]').forEach((el) => {
707        el.classList.toggle('perf--dimmed', el.dataset.productId !== String(id));
708      });
709      this.container.querySelectorAll('.farlo-cal-date').forEach((el) => {
710        const productIds = el.dataset.productIds;
711        const dayIds = productIds ? productIds.split(',') : [];
712        el.classList.toggle('farlo-cal-date--dimmed', !dayIds.includes(String(id)));
713      });
714    };
715
716    const clear = () => {
717      this.container.querySelectorAll('.perf--dimmed').forEach((el) => el.classList.remove('perf--dimmed'));
718      this.container.querySelectorAll('.farlo-cal-date--dimmed').forEach((el) => el.classList.remove('farlo-cal-date--dimmed'));
719    };
720
721    const legend = document.createElement('ul');
722    legend.className = 'farlo-cal-show-key';
723
724    const isSingleShow = ids.length === 1;
725    const singleShowColour = isSingleShow
726      ? (getComputedStyle(this.container).getPropertyValue('--global-secondary-accent-colour').trim() || '#fffbe5')
727      : null;
728
729    ids.forEach((id) => {
730      const item = document.createElement('li');
731      item.className = 'farlo-cal-show-key-item';
732      item.dataset.productId = id;
733
734      const swatch = document.createElement('span');
735      swatch.className = 'farlo-cal-show-key-swatch';
736      const rawColour = colours[id] || '#cccccc';
737      const safeColour = /^#[0-9a-fA-F]{3,8}$/.test(rawColour) ? rawColour : '#cccccc';
738      const swatchColour = singleShowColour || this.getAccessibleShowColour(safeColour);
739      swatch.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
740        <rect width="16" height="16" fill="${swatchColour}"/>
741        <rect x="1" y="8" width="14" height="6" fill="black"/>
742        <rect x="1" y="1" width="3" height="3" fill="black"/>
743      </svg>`;
744
745      const label = document.createElement('span');
746      label.className = 'farlo-cal-show-key-label';
747      label.textContent = products[id];
748
749      item.appendChild(swatch);
750      item.appendChild(label);
751
752      item.addEventListener('mouseenter', () => dim(id));
753      item.addEventListener('mouseleave', () => clear());
754
755      item.addEventListener('touchstart', (e) => {
756        e.stopPropagation();
757        if (this._activeShowKeyId === id) {
758          this._activeShowKeyId = null;
759          clear();
760        } else {
761          this._activeShowKeyId = id;
762          clear();
763          dim(id);
764        }
765      }, { passive: true });
766
767      legend.appendChild(item);
768    });
769
770    document.addEventListener('touchstart', (e) => {
771      if (this._activeShowKeyId && !legend.contains(e.target)) {
772        this._activeShowKeyId = null;
773        clear();
774      }
775    }, { passive: true });
776
777    const main = this.container.querySelector('.farlo-cal-main') || this.container;
778    const header = main.querySelector('.farlo-cal-header');
779    if (header && header.parentNode === main) {
780      header.insertAdjacentElement('afterend', legend);
781    } else {
782      main.prepend(legend);
783    }
784  }
785
786  bindEventPanel() {
787    this.eventPanel = this.container.querySelector('[data-calendar-event-panel]');
788    this.sidePanel = this.container.querySelector('[data-calendar-side-panel]');
789    const closeBtn = this.container.querySelector('[data-calendar-side-close]');
790
791    if (closeBtn) {
792      closeBtn.addEventListener('click', () => this.closeEventPanel());
793    }
794
795    this.month.addEventListener('click', (e) => {
796      const link = e.target.closest('.perf[data-performance-id]');
797      if (!link) return;
798      e.preventDefault();
799
800      const perf = (this.options.performances || []).find(
801        (p) => String(p.id) === link.dataset.performanceId
802      );
803      if (perf) this.openEventPanel(perf);
804    });
805  }
806
807  openEventPanel(perf) {
808    if (!this.eventPanel) return;
809
810    const details = (this.options.showDetails || {})[perf.product_id] || {};
811    const dateStr = moment(perf.start).format('dddd, MMMM DD YYYY');
812    const timeStr = moment(perf.start).format('h:mma');
813    const title = details.title || (this.options.products || {})[perf.product_id] || perf.title || '';
814
815    const stars = Math.max(0, parseInt(details.reviewStars, 10) || 0);
816    const starSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 63 60" width="16" height="16" aria-hidden="true"><path d="M32.54,.76l6.93,21.32h22.43c1.06,0,1.51,1.36,.65,1.98l-18.14,13.18,6.93,21.32c.33,1.01-.83,1.85-1.69,1.23l-18.14-13.18-18.14,13.18c-.86,.62-2.02-.22-1.69-1.23l6.93-21.32L.45,24.06c-.86-.62-.42-1.98,.65-1.98H23.53L30.46,.76c.33-1.01,1.76-1.01,2.09,0Z"/></svg>';
817    const starsHtml = stars ? Array(stars).fill(starSvg).join('') : '';
818
819    const reviewHtml = (stars || details.reviewAttribution)
820      ? `<div class="farlo-cal-event-panel__review">
821          ${stars ? `<span class="farlo-cal-event-panel__stars">${starsHtml}</span>` : ''}
822          ${details.reviewAttribution ? `<span class="farlo-cal-event-panel__attribution">- ${details.reviewAttribution}</span>` : ''}
823        </div>`
824      : '';
825
826    const showUrl = details.url || '#';
827
828    this.eventPanel.innerHTML = `
829      <span class="is-style-heading-4">DATE:</span>
830      <div class="farlo-cal-event-panel__date">${dateStr}</div>
831      <a class="farlo-cal-event-panel__main" href="${showUrl}">
832        <div class="farlo-cal-event-panel__image-col">
833          ${details.image ? `<img src="${details.image}" alt="${details.imageAlt || ''}" />` : ''}
834        </div>
835        <div class="farlo-cal-event-panel__info-col">
836          <span class="farlo-cal-event-panel__time">${timeStr}</span>
837          <span class="farlo-cal-event-panel__title">${title}</span>
838          ${reviewHtml}
839        </div>
840      </a>
841      ${details.excerpt ? `<div class="farlo-cal-event-panel__excerpt">${details.excerpt}</div>` : ''}
842      <a class="farlo-cal-event-panel__cta" href="${showUrl}">Buy Tickets</a>
843    `;
844
845    const footer = this.container.querySelector('.farlo-cal-footer');
846    if (footer) footer.style.display = 'none';
847
848    this.container.classList.add('farlo-cal--side-open');
849    this.setSidePanelHeight(true);
850    this.positionSidePanel();
851    this.scrollSidePanelIntoView();
852  }
853
854  // On mobile the side panel renders below the calendar grid; scroll it into
855  // view so clicking a performance doesn't appear to do nothing.
856  scrollSidePanelIntoView() {
857    if (!this.sidePanel) return;
858
859    window.setTimeout(() => {
860      const rect = this.sidePanel.getBoundingClientRect();
861      const fullyVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
862      if (!fullyVisible) {
863        this.sidePanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
864      }
865    }, 360);
866  }
867
868  closeEventPanel() {
869    this.setSidePanelHeight(false);
870    this.container.classList.remove('farlo-cal--side-open');
871
872    const footer = this.container.querySelector('.farlo-cal-footer');
873    if (footer) footer.style.display = '';
874  }
875
876  // Aligns the top of the open side panel with the calendar grid (instead of the
877  // header row) on desktop, so it doesn't overlap the view-toggle buttons.
878  positionSidePanel() {
879    if (!this.sidePanel) return;
880
881    if (!window.matchMedia('(min-width: 980px)').matches) {
882      this.sidePanel.style.marginTop = '';
883      return;
884    }
885
886    const reference = this.container.querySelector('.farlo-cal-week') || this.container.querySelector('.farlo-cal-header');
887    if (!reference) return;
888
889    const calRect = this.container.getBoundingClientRect();
890    const refRect = reference.getBoundingClientRect();
891    this.sidePanel.style.marginTop = `${refRect.top - calRect.top}px`;
892  }
893
894  setSidePanelHeight(expand) {
895    if (!this.sidePanel) return;
896
897    // Width-based collapse (desktop) doesn't need a height clamp.
898    if (window.matchMedia('(min-width: 980px)').matches) {
899      this.sidePanel.style.maxHeight = '';
900      this.stopWatchingSidePanelHeight();
901      return;
902    }
903
904    if (expand) {
905      // Read scrollHeight synchronously, after the open class (and its padding) is
906      // applied but before any transition frame paints, so it reflects the true
907      // expanded height instead of an arbitrary large guess.
908      this.sidePanel.style.maxHeight = `${this.sidePanel.scrollHeight}px`;
909      this.watchSidePanelHeight();
910    } else {
911      this.sidePanel.style.maxHeight = '0px';
912      this.stopWatchingSidePanelHeight();
913    }
914  }
915
916  // The panel's content can grow after the initial measurement (e.g. a web font
917  // swaps in and reflows the text), which would otherwise leave the fixed
918  // max-height stale and clip content like the Buy Tickets button. Keep it in
919  // sync for as long as the panel is open.
920  watchSidePanelHeight() {
921    if (!window.ResizeObserver || !this.eventPanel) return;
922    this.stopWatchingSidePanelHeight();
923
924    this.sidePanelResizeObserver = new ResizeObserver(() => {
925      if (!this.container.classList.contains('farlo-cal--side-open')) return;
926      if (window.matchMedia('(min-width: 980px)').matches) return;
927      this.sidePanel.style.maxHeight = `${this.sidePanel.scrollHeight}px`;
928    });
929    this.sidePanelResizeObserver.observe(this.eventPanel);
930  }
931
932  stopWatchingSidePanelHeight() {
933    if (this.sidePanelResizeObserver) {
934      this.sidePanelResizeObserver.disconnect();
935      this.sidePanelResizeObserver = null;
936    }
937  }
938
939  removeActiveClass() {
940    if (!this.activeDates) return;
941    this.activeDates.forEach((d) => d.classList.remove('farlo-cal-date--selected'));
942  }
943}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.