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.