1/** 2 * Better structure for theme.js 3 * We need to handle functions that are need to work in customizer and one that don't need to work in customizer 4 * we can think of some wrappers with contitions if it's needed to both or not 5 */ 6 7/** 8 * Events Calendar - Homepage section ----------------------------------------------------------------- 9 * Functionality for events calendar on homepage in section - events-calendar 10 */ 11let timezone = "America/Chicago" // Timezone From Settings 12if (typeof jTimezone !== "undefined") { 13 timezone = jTimezone 14} 15let today = new Date() 16let currentMonth = today.getMonth() 17let currentYear = today.getFullYear() 18let todayDay = today.getDate() 19let todayMonth = today.getMonth() 20let todayYear = today.getFullYear() 21let events = [] 22let monthAndYear = "" 23 24var eventsList = document.getElementById("events-calendar-list") 25var eventsListContainer = document.getElementById("events-calendar-list-container") 26var eventsListDate = document.getElementById("events-calendar-list-date") 27var eventsListEmpty = '<p class="alert tw-secondary tw-bg-primary d-flex align-items-center"><i class="far fa-calendar-times fa-2x me-3"></i> There are no events on this day.</p>' 28var calendar = document.getElementById("events-calendar") 29 30var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "
30November", "December"] 31var dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] 32 33var dayNamesContent = "<tr>" 34dayNames.forEach((name) => { 35 dayNamesContent += '<th data-days="' + name + '"><div class="text-uppercase fw-normal py-4 d-flex justify-content-center align-items-center">' + name + "</div></th>" 36}) 37dayNamesContent += "</tr>" 38if ((jActiveHomepageSections && jActiveHomepageSections.includes("Events-calendar") && window.location.pathname == "/") || window.location.pathname.includes("customizer")) { 39 let headMonth = document.getElementById("thead-month") 40 if (headMonth !== null) { 41 headMonth.innerHTML = dayNamesContent 42 monthAndYear = document.getElementById("monthAndYearTitle") 43 } 44} 45 46// Handle next month 47function eventCalendarNext() { 48 currentYear = currentMonth === 11 ? currentYear + 1 : currentYear 49 currentMonth = (currentMonth + 1) % 12 50 showCalendar(currentMonth, currentYear) 51} 52 53// Handle previous month 54function eventCalendarPrev() { 55 currentYear = currentMonth === 0 ? currentYear - 1 : currentYear 56 currentMonth = currentMonth === 0 ? 11 : currentMonth - 1 57 showCalendar(currentMonth, currentYear) 58} 59 60// Show Calendar 61async function showCalendar(month, year) { 62 var firstDay = new Date(year, month).getDay() 63 let tbl = document.getElementById("calendar-body") 64 tbl.innerHTML = "" 65 monthAndYear.innerHTML = months[month] + " " + year 66 events = await getEvents(month, year) 67 // Create all calendar items 68 var date = 1 69 for (var i = 0; i < 6; i++) { 70 var row = document.createElement("tr") 71 72 for (var j = 0; j < 7; j++) { 73 if (i === 0 && j < firstDay) { 74 let cell = document.createElement("td") 75 let cellText = document.createTextNode("") 76 cell.appendChild(cellText) 77 row.appendChild(cell) 78 } else if (date > daysInMonth(month, year)) { 79 break 80 } else { 81 let dayEvents = getEventOnDay(date) 82 83 let cell = document.createElement("td") 84 cell.setAttribute("data-date", date) 85 cell.setAttribute("data-month", month + 1) 86 cell.setAttribute("data-year", year) 87 cell.setAttribute("data-month_name", months[month]) 88 cell.className = "date-picker" 89 //cell.innerHTML = "<span>" + date + "</span>"; 90 91 let eventItemClass = "" 92 93 if (dayEvents.length > 0) { 94 eventItemClass = "tw-calendar-has-events-date cursor-pointer" 95 cell.className = "date-picker has-events" 96 } else { 97 eventItemClass = "" 98 } 99 100 // Check is current date 101 if (todayMonth == month && todayDay == date && todayYear == year) { 102 eventItemClass += " tw-calendar-current-date" 103 } 104 105 cell.innerHTML = '<div class="tw-calendar-day py-4 d-flex justify-content-center align-items-center position-relative ' + eventItemClass + '"> ' + '<span data-date="' + date + '" data-month="' + month + '" data-year="' + year + '" class="position-absolute rounded-circle event-item-btn"></span> ' + '<span data-date="' + date + '" data-month="' + month + '" data-year="' + year + '" class="d-flex position-relative event-item-btn">' + date + "</span> " + "</div>" 106 107 if (date === today.getDate() && year === today.getFullYear() && month === today.getMonth()) { 108 cell.className = "date-picker selected" 109 } 110 row.appendChild(cell) 111 date++ 112 } 113 } 114 115 tbl.appendChild(row) 116 } 117} 118 119// Get Events for selected period - Getting Events from HeyGov 120async function getEvents(month, year) { 121 let firstDay = new Date(year, month, 1) 122 let lastDay = new Date(year, month + 1, 0) 123 let startDate = formatDate(new Date(year, month, firstDay.getDate())) 124 let endDate = formatDate(new Date(year, month, lastDay.getDate())) 125 const response = await fetch("https://api.heygov.com/" + jHeyGovJurisdiction + "/events?date_start=" + startDate + "&date_end=" + endDate) 126 const events = await response.json() 127 128 const isUnifiedDashboard = typeof jMainDashboard !== "undefined" && jMainDashboard === "unified-dashboard" 129 if (!isUnifiedDashboard) { 130 return events 131 } 132 133 // Fetch meetings and normalize them to the same shape as events 134 let meetings = [] 135 try { 136 const meetingsResponse = await fetch("https://api.heygov.com/" + jHeyGovJurisdiction + "/meetings?api=1&status=publish&order=desc&limit=100&date_start=" + startDate + "&date_end=" + endDate) 137 const meetingsData = await meetingsResponse.json() 138 if (Array.isArray(meetingsData)) { 139 meetings = meetingsData 140 .filter((m) => { 141 if (!m.starts_at) return false 142 const d = new Date(m.starts_at) 143 return d.getFullYear() === year && d.getMonth() === month 144 }) 145 .map((m) => ({ 146 pid: m.pid || m.id, 147 id: m.pid || m.id, 148 name: m.title, 149 starts_at: m.starts_at, 150 ends_at: m.ends_at, 151 metadata: null, 152 _isMeeting: true 153 })) 154 } 155 } catch (e) { 156 // meetings fetch failed â continue with events only 157 } 158 159 const combined = events.concat(meetings) 160 combined.sort((a, b) => new Date(a.starts_at) - new Date(b.starts_at)) 161 return combined 162} 163 164// Get Events on selected day of month 165function getEventOnDay(day) { 166 let dayEvents = []
167 events.forEach((event) => { 168 if (!event || !event.starts_at) return 169 try { 170 let eventStartDate = new Date( 171 new Date(event.starts_at).toLocaleString("en", { 172 timeZone: timezone 173 }) 174 ) 175 let endRaw = event.ends_at || event.starts_at 176 let eventEndDate = new Date( 177 new Date(endRaw).toLocaleString("en", { 178 timeZone: timezone 179 }) 180 ) 181 if (eventStartDate.getDate() <= day && eventEndDate.getDate() >= day) { 182 dayEvents.push(event) 183 } 184 } catch (err) { 185 // Skip event with invalid date â don't let one bad row break the calendar 186 } 187 }) 188 return dayEvents 189} 190 191// Set date for events list (list of day events) 192function setEventsListDate(date) { 193 let value = "" 194 value = formatProperDate(date) 195 if (eventsListDate) { 196 eventsListDate.innerHTML = value 197 } 198} 199 200// Generate list of day events 201function generateDayEventsList(day, month, year) { 202 let dayEvents = getEventOnDay(day) 203 let eventsList = "" 204 let eDate = new Date(new Date(year, month, day).toLocaleString("en")) 205 setEventsListDate(eDate) 206 dayEvents.forEach((e) => { 207 try { 208 if (!e || !e.starts_at) return 209 let startsAt = new Date( 210 new Date(e.starts_at).toLocaleString("en", { 211 timeZone: timezone 212 }) 213 ) 214 let endRaw = e.ends_at || e.starts_at 215 let endsAt = new Date( 216 new Date(endRaw).toLocaleString("en", { 217 timeZone: timezone 218 }) 219 ) 220 let startTime = formatAMPM(startsAt) 221 let endTime = formatAMPM(endsAt) 222 let hideEndTime = e.metadata && e.metadata.hide_end_time 223 let endTimeHtml = hideEndTime || !e.ends_at ? "" : " - <span>" + endTime + "</span>" 224 let eventId = e.pid != null ? e.pid : e.id 225 eventsList += 226 '<article class="tw-calendar-event-article bg-white tw-secondary mb-3 p-3"> ' + 227 '<div class="row"> ' + 228 '<div class="tw-calendar-event-article-date col-6 fw-bold">' + 229 formatProperDate(startsAt) + 230 "</div> " + 231 '<div class="tw-calendar-event-article-time col-6 text-end"><span>' + 232 startTime + 233 "</span>" + 234 endTimeHtml + 235 "</div> " + 236 '<div class="tw-calendar-event-article-title col-12 mt-3 mb-2"> ' + 237 "<h3>" + 238 (e.name || "") + 239 "</h3> " + 240 "</div> " + 241 '<div class="tw-calendar-event-article-venue col-6"></div> ' + 242 '<div class="tw-calendar-event-article-more col-6 text-end"> ' + 243 '<a class="tw-bg-secondary btn rounded-0 text-capitalize d-none d-lg-inline-block" href="/' + 244 jEventsPage + 245 "/?" + 246 (e._isMeeting ? "heygov-meeting" : "heygov-event") + 247 "=" + 248 eventId + 249 '">learn more</a> ' + 250 "</div> " + 251 "</div> " + 252 "</article>" 253 } catch (err) { 254 // Skip event with invalid data 255 } 256 }) 257 if (eventsList == "") { 258 eventsList = eventsListEmpty 259 } 260 if (eventsListContainer) { 261 eventsListContainer.innerHTML = eventsList 262 } 263} 264 265// Format Time to AM/PM 266const formatAMPM = (date) => { 267 let hours = date.getHours() 268 let minutes = date.getMinutes() 269 const ampm = hours >= 12 ? "PM" : "AM" 270 271 hours %= 12 272 hours = hours || 12 273 minutes = minutes < 10 ? `0${minutes}` : minutes 274 275 const strTime = `${hours}:${minutes} ${ampm}` 276 277 return strTime 278} 279 280// Format Date to Sunday, January 14, 2023 281const formatProperDate = (date) => { 282 let dateFormated = "" 283 let weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][date.getDay()] 284 let monthName = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "
284November", "December"][date.getMonth()] 285 let dateNumber = date.getDate() 286 let yearNumber = date.getFullYear() 287 dateFormated = weekday + ", " + monthName + " " + dateNumber + ", " + yearNumber 288 return dateFormated 289} 290 291// Format date 292function formatDate(date = new Date()) { 293 const year = date.toLocaleString("default", { year: "numeric" }) 294 const month = date.toLocaleString("default", { 295 month: "2-digit" 296 }) 297 const day = date.toLocaleString("default", { day: "2-digit" }) 298 299 return [year, month, day].join("-") 300} 301 302// Calculate days in month 303function daysInMonth(iMonth, iYear) { 304 return 32 - new Date(iYear, iMonth, 32).getDate() 305} 306 307/** 308 * END Events Calendar - Homepage section --------------------------------------------------------------------------- 309 */ 310 311/** 312 * Meetings --------------------------------------------------------------------------------------------------------- 313 * Function is created with vanila JavaScript 314 */ 315var meetingItemsPerTable = 10 316var searchCategory = "" 317var dateFrom = "" 318var dateTo = "" 319var categories = [] 320var pager = 1 321var pagerCategory = 0 322 323// Fetch categories from JSON 324function fetchCategories() { 325 fetch(`/json/meetings/categories.json?sk=${Date.now()}`) 326 .then((response) => response.json()) 327 .then((data) => { 328 categories = data 329 if (window.location.pathname.includes(jmeetingPage)) { 330 // Set search category from URL before generating tables 331 searchCategory = getInitialCategoryFromUrl() 332 generateMeetingTables() 333 generateCategoriesSelect() 334 // Set the selected option in select box after it's generated 335 handleMeetingCategoryFromUrl() 336 } 337 }) 338 .catch((error) => { 339 console.error("Error loading categories", error) 340 }) 341} 342 343// Search Meetings by Date or Category 344function searchMeetings() { 345 let categoryId = document.getElementById("categoriesSelect").value 346 searchCategory = categoryId 347 dateFrom = document.getElementById("dateFrom").value 348 dateTo = document.getElementById("dateTo").value 349 generateMeetingTables() 350} 351 352// Generate Categories Select Input for search 353function generateCategoriesSelect() { 354 var categoriesSelect = document.getElementById("categoriesSelect") 355 // No aria-label on options: the accessible name must contain the visible 356 // text (WCAG 2.5.3 Label in Name), and the text content already provides it. 357 var categoriesSelectOptions = `<option value="">All Categories</option>` 358 categories.forEach((category) => { 359 categoriesSelectOptions += `<option value="${category.id}">${category.name}${category.meetings_count ? ` (${category.meetings_count})` : ''}</option>` 360 }) 361 categoriesSelect.innerHTML = categoriesSelectOptions 362} 363 364// Load more items in table per category 365function loadMore(categoryId) { 366 if (categoryId == pagerCategory) { 367 pager = pager + 1 368 } else { 369 pagerCategory = categoryId 370 pager = 2 371 } 372 var loader = document.getElementById("load-" + categoryId) 373 loader.classList.add("fa-spin") 374 let category = categories.find((element) => element.id == categoryId) 375 category.perPage = meetingItemsPerTable * pager 376 populateCategoryTable(category) 377} 378 379// Generate tables for categories with no data 380function generateCategoryEmptyTable(category) { 381 // Get a reference to the table element 382 var content = document.getElementById("juniperMeetings") 383 let html = `<div id="Category-${category.id}"> 384 <h3 class="twd-category-name"><span>${category.name}</span></h3> 385 <table class="table table-striped twd-table tw-meeting-repo-table"> 386 <thead> 387 <tr>` 388 if (jMeetingActiveColumns.includes("meeting_date")) { 389 html += `<th class="meeting-date-column">Meeting Date</th>` 390 } 391 if (jMeetingActiveColumns.includes("title")) { 392 html += `<th class="agenda-column">Name</th>` 393 } 394 if (jMeetingActiveColumns.includes("agenda")) { 395 html += `<th class="agenda-column">Agenda</th>` 396 } 397 if (jMeetingActiveColumns.includes("agenda_packet")) { 398 html += `<th class="agenda-packet-column">Agenda Packet</th>` 399 } 400 if (jMeetingActiveColumns.includes("minutes")) { 401 html += `<th class="meeting-minutes-column">Meeting Minutes</th>` 402 } 403 if (jMeetingActiveColumns.includes("audio")) { 404 html += `<th class="audio-column">Audio</th>` 405 } 406 if (jMeetingActiveColumns.includes("video")) { 407 html += `<th class="video-column">Video</th>` 408 } 409 if (jMeetingActiveColumns.includes("additional_documents")) { 410 html += `<th class="additional-documents-column">Additional Documents</th>` 411 } 412 if (jMeetingActiveColumns.includes("notes")) { 413 html += `<th class="notes-column">Notes</th>` 414 } 415 html += `</tr> 416 </thead> 417 <tbody id="table-category-${category.id}"> 418 419 </tbody> 420 <tfoot id="table-category-footer-${category.id}"></tfoot> 421 </table> 422 </div>` 423 424 content.innerHTML = content.innerHTML + html 425} 426 427// Check is meeting date in range between search dates 428function inRange(meetingDate) { 429 let show = false 430 if (dateFrom !== "") { 431 dateF = Date.parse(dateFrom) 432 } else { 433 dateF = "" 434 } 435 if (dateTo !== "") { 436 dateT = Date.parse(dateTo) 437 } else { 438 dateT = "" 439 } 440 if (dateF !== "" && dateT !== "") { 441 if (dateF <= meetingDate && dateT >= meetingDate) { 442 show = true 443 } 444 } 445 if (dateF !== "" && dateT == "") { 446 if (dateF <= meetingDate) { 447 show = true 448 } 449 } 450 if (dateF == "" && dateT !== "") { 451 if (dateT >= meetingDate) { 452 show = true 453 } 454 } 455 if (dateF == "" && dateT == "") { 456 show = true 457 } 458 return show 459} 460 461// Filtering meetings by date 462function filterDateData(data) { 463 let filteredData = [] 464 data.forEach((item) => {
465 let checkDate = Date.parse(item.meeting_date) 466 if (inRange(checkDate)) { 467 filteredData.push(item) 468 } 469 }) 470 return filteredData 471} 472 473// Generate data for category table 474function populateCategoryTable(category) { 475 fetch(`/json/meetings/category-${category.id}.json?sk=${Date.now()}`) 476 .then((response) => response.json()) 477 .then((data) => { 478 let html = "" 479 data = filterDateData(data) 480 // Generate table rows for the municipalities 481 for (var i = 0; i < category.perPage; i++) { 482 if (i >= data.length) { 483 break 484 } 485 html += `<tr>` 486 487 if (jMeetingActiveColumns.includes("meeting_date")) { 488 html += `<td class="meeting-date-column">${new Date(data[i].meeting_date).toLocaleDateString("default", { 489 dateStyle: "medium" 490 })}</td>` 491 } 492 493 if (jMeetingActiveColumns.includes("title")) { 494 html += `<td class="meeting-date-column"> 495 ${data[i].title} 496 </td>` 497 } 498 499 if (jMeetingActiveColumns.includes("agenda")) { 500 if (data[i].agenda_file_path) { 501 html += `<td class="agenda-column"> 502 <a href="${data[i].agenda_file_path}" target="_blank" aria-label="Download PDF Agenda for ${data[i].title} ${data[i].meeting_date}"><i class="fa-solid fa-file-pdf"></i> Agenda</a> 503 </td>` 504 } else { 505 html += `<td class="agenda-column"></td>` 506 } 507 } 508 509 if (jMeetingActiveColumns.includes("agenda_packet")) { 510 if (data[i].agenda_pack_file_path) { 511 html += `<td class="agenda-packet-column"> 512 <a href="${data[i].agenda_pack_file_path}" target="_blank" aria-label="Download PDF Agenda Pack for ${data[i].title} ${data[i].meeting_date}"><i class="fa-solid fa-file-pdf"></i> Packet</a> 513 </td>` 514 } else { 515 html += `<td class="agenda-packet-column"></td>` 516 } 517 } 518 519 if (jMeetingActiveColumns.includes("minutes")) { 520 if (data[i].meeting_minutes_file_path) { 521 html += `<td class="meeting-minutes-column"> 522 <a href="${data[i].meeting_minutes_file_path}" target="_blank" aria-label="Download PDF Minutes for ${data[i].title} ${data[i].meeting_date}"><i class="fa-solid fa-file-pdf"></i> Minutes</a> 523 </td>` 524 } else { 525 html += `<td class="meeting-minutes-column"></td>` 526 } 527 } 528 529 if (jMeetingActiveColumns.includes("audio")) { 530 if (data[i].audio_public_url && data[i].audio_public_url !== "") { 531 html += `<td class="audio-column"> 532 <a href="${data[i].audio_public_url}" target="_blank" aria-label="Download Audio for ${data[i].title} ${data[i].meeting_date}"><i class="fa-solid fa-file-pdf"></i> Audio</a> 533 </td>` 534 } else { 535 html += `<td class="audio-column"></td>` 536 } 537 } 538 539 if (jMeetingActiveColumns.includes("video")) { 540 if (data[i].video_public_url && data[i].video_public_url !== "") { 541 html += `<td class="video-column"> 542 <a href="${data[i].video_public_url}" target="_blank" aria-label="Download Video for ${data[i].title} ${data[i].meeting_date}"><i class="fa-solid fa-file-video"></i> Video</a> 543 </td>` 544 } else { 545 html += `<td class="video-column"></td>` 546 } 547 } 548 549 if (jMeetingActiveColumns.includes("additional_documents")) { 550 if (data[i].additional_file_path && data[i].additional_file_path !== "") { 551 html += `<td class="additional-documents-column"> 552 <a href="${data[i].additional_file_path}" target="_blank" aria-label="Download Video for ${data[i].title} ${data[i].meeting_date}"><i class="fa-solid fa-file-pdf"></i> ${data[i].additional_doc}</a> 553 </td>` 554 } else if (data[i].additional_url && data[i].additional_url !== "") { 555 html += `<td class="additional-documents-column"> 556 <a href="${data[i].additional_url}" target="_blank" aria-label="Download Video for ${data[i].title} ${data[i].meeting_date}"><i class="fa-solid fa-file-pdf"></i> ${data[i].additional_doc}</a> 557 </td>` 558 } else { 559 html += `<td class="additional-documents-column"></td>` 560 } 561 } 562 563 if (jMeetingActiveColumns.includes("notes")) { 564 if (data[i].notes && data[i].notes !== "") { 565 html += `<td class="notes-column"> 566 ${data[i].notes} 567 </td>` 568 } else { 569 html += `<td class="notes-column"></td>` 570 } 571 } 572 573 html += `</tr>` 574 } 575 576 if (data.length == 0) { 577 html += `<tr><td class="text-center" colspan="8">No data in this category</td></tr>` 578 } 579 580 if (category.perPage < data.length) { 581 let tfootElement = document.getElementById(`table-category-footer-${category.id}`) 582 tfoot = `<tr> 583 <td colspan="8"> 584 <a 585 tabindex="0" 586 aria-label="View more agendas & minutes" 587 role="button"
588 onclick="loadMore(${category.id})" 589 class="load-more" 590 > 591 <i id="load-${category.id}" class="fa fa-refresh"></i> View more 592 </a> 593 </td> 594 </tr>` 595 tfootElement.innerHTML = tfoot 596 } else { 597 let tfootElement = document.getElementById(`table-category-footer-${category.id}`) 598 tfootElement.innerHTML = "" 599 } 600 let categoryContent = document.getElementById("table-category-" + category.id) 601 categoryContent.innerHTML = html 602 }) 603 .catch((error) => { 604 console.error("Error fetching data:", error) 605 }) 606} 607 608// Generate meering tables 609function generateMeetingTables() { 610 var content = document.getElementById("juniperMeetings") 611 content.innerHTML = "" 612 // Initiali populate tables 613 categories.forEach((category) => { 614 if (searchCategory == "") { 615 category.perPage = meetingItemsPerTable 616 generateCategoryEmptyTable(category) 617 populateCategoryTable(category) 618 } else { 619 if (searchCategory == category.id) { 620 category.perPage = meetingItemsPerTable 621 generateCategoryEmptyTable(category) 622 populateCategoryTable(category) 623 } 624 } 625 }) 626} 627 628// END Meetings ---------------------------------------------------------------------------------------------------------------- 629 630// Weather Widget -------------------------------------------------------------------------------------------------------------- 631function handleWeatherData(url) { 632 let cacheTime = 3 * 60 * 60 * 1000 // 3 hours 633 const storedData = localStorage.getItem("weatherData") 634 const storedTimestamp = localStorage.getItem("weatherDataTimestamp") 635 const currentTime = new Date().getTime() 636
637 function getWeatherIcon(shortForecast) { 638 const forecast = shortForecast.toLowerCase() 639 if (forecast.includes("clear") && forecast.includes("night")) return "clear_night" 640 if (forecast.includes("clear")) return "clear_day" 641 if (forecast.includes("sunny")) return "clear_day" 642 if (forecast.includes("rain")) return "rain" 643 if (forecast.includes("snow")) return "snow" 644 if (forecast.includes("sleet")) return "sleet" 645 if (forecast.includes("wind")) return "wind" 646 if (forecast.includes("fog")) return "fog" 647 if (forecast.includes("cloudy") && forecast.includes("partly")) { 648 return forecast.includes("night") ? "partly_cloudy_night" : "partly_cloudy_day" 649 } 650 if (forecast.includes("cloudy")) return "cloudy" 651 return "unknown" 652 } 653 654 function getTemperatureAtCurrentTime(json) { 655 const currentTime = new Date() 656 const timezoneFormatter = new Intl.DateTimeFormat("en-US", { 657 timeZone: "America/Chicago", 658 hour: "2-digit", 659 minute: "2-digit", 660 second: "2-digit", 661 hour12: false 662 }) 663 const timezoneStart = timezoneFormatter.format(currentTime) 664 const [currentHour, currentMinutes] = timezoneStart.split(":").map(Number) 665 666 const data = JSON.parse(json).hourly?.data 667 668 if (!data) return "NO DATA" 669 for (const entry of data) { 670 const startTime = new Date(entry.startTime) 671 const timezoneEnd = timezoneFormatter.format(startTime) 672 const [startHour, startMinutes] = timezoneEnd.split(":").map(Number) 673 674 if (startHour === currentHour && currentMinutes >= startMinutes) { 675 const temperature = entry.temperature 676 const unit = entry.temperatureUnit 677 const icon = getWeatherIcon(entry.shortForecast) 678 return { 679 formattedTemperature: `${temperature}°${unit}`, 680 temperature, 681 unit, 682 icon, 683 forecast: entry.shortForecast 684 } 685 } 686 } 687 return "NO DATA" 688 } 689 690 function setWeatherData(data) { 691 const weatherWidget = document.querySelector(".weather-widget") 692 const weatherIconElement = document.querySelector(".weather-widget .icon-weather") 693 const weatherTemperatureElement = document.querySelector(".weather-temperature") 694 695 if (weatherWidget) { 696 weatherWidget.setAttribute("title", data.forecast) 697 } 698 699 if (weatherIconElement) { 700 weatherIconElement.setAttribute("data-icon", data.icon) 701 let color = window.getComputedStyle(weatherWidget).color 702 // Initialize Skycons with a reference to the canvas element 703 var skycons = new Skycons({ color: color }) 704 // Set the icon for the canvas element 705 skycons.add(weatherIconElement, data.icon) 706 // Start the animation 707 skycons.play() 708 } 709 710 if (weatherTemperatureElement) { 711 weatherTemperatureElement.textContent = data.formattedTemperature 712 } 713 } 714 715 if (storedData && storedTimestamp && currentTime - storedTimestamp < cacheTime) { 716 //console.log("Using data from localStorage") 717 const temperatureData = getTemperatureAtCurrentTime(storedData) 718 setWeatherData(temperatureData) 719 } else { 720 fetch(url) 721 .then((response) => response.json()) 722 .then((data) => { 723 //console.log("Fetching new data from API:", data) 724 const json = JSON.stringify(data) 725 if (!data.error) { 726 localStorage.setItem("weatherData", json) 727 localStorage.setItem("weatherDataTimestamp", currentTime) 728 const temperatureData = getTemperatureAtCurrentTime(json) 729 setWeatherData(temperatureData) 730 } 731 }) 732 .catch((error) => console.error("Error fetching data:", error)) 733 } 734} 735 736/** 737 * Handle category selection from parameter 738 */ 739 740// Function to get the value of a URL parameter by name 741function getParameterByName(name) { 742 const urlParams = new URLSearchParams(window.location.search) 743 return urlParams.get(name) 744} 745 746function updateURLParameter(param, paramVal) { 747 const url = new URL(window.location.href) 748 url.searchParams.set(param, paramVal) 749 window.history.replaceState(null, null, url) 750} 751 752// Get initial category ID from URL parameter 753function getInitialCategoryFromUrl() { 754 const categoryText = getParameterByName("category") 755 if (!categoryText || categories.length === 0) { 756 return "" 757 } 758 759 // Find category by name 760 const category = categories.find((cat) => cat.name === categoryText) 761 return category ? category.id : "" 762} 763 764function handleMeetingCategoryFromUrl() { 765 // Get the 'category' parameter from the URL 766 const categoryText = getParameterByName("category") 767 768 // Find the select box 769 const selectBox = document.getElementById("categoriesSelect") 770 771 // If the category parameter exists, set the selected option 772 if (categoryText && selectBox) { 773 const options = selectBox.options 774 for (let i = 0; i < options.length; i++) { 775 if (options[i].innerText === categoryText) { 776 selectBox.selectedIndex = i 777 break 778 } 779 } 780 } 781 782 // Listen for search button clicks to update URL 783 const searchButton = document.getElementById("searchMeetings") 784 if (searchButton && selectBox) { 785 searchButton.addEventListener("click", function () { 786 const selectedText = selectBox.options[selectBox.selectedIndex].text 787 // Update the URL with the new category parameter 788 updateURLParameter("category", selectedText) 789 }) 790 } 791} 792 793$(document).ready(async function ($) { 794 let mc2url = "https://mc2api.townweb.com/api" 795 let url = "" 796 if (typeof jid !== "undefined") { 797 url = mc2url + "/weather/" + jid 798 } 799 handleWeatherData(url) 800 // EVENTS CALENDAR HOMEPAGE --------------------------------------------------- 801 // Load events from HeyGov (guarded so a failed fetch can't abort the rest of document.ready) 802 if (typeof jHeyGovJurisdiction !== "undefined" && jHeyGovJurisdiction !== "") { 803 try { 804 events = await getEvents(todayMonth, todayYear) 805 } catch (err) { 806 console.error("Error loading HeyGov events:", err) 807 events = [] 808 } 809 } 810 // Handle events only if pathname is / and events-calendar is active on homepage 811 // Wrapped in try/catch so a single bad event can't prevent click handlers below from registering 812 if ((jActiveHomepageSections && jActiveHomepageSections.includes("Events-calendar") && window.location.pathname == "/") || window.location.pathname.includes("customizer")) { 813 try { 814 showCalendar(currentMonth, currentYear) 815 setEventsListDate(today) 816 generateDayEventsList(todayDay, todayMonth, todayYear) 817 } catch (err) { 818 console.error("Error initializing events calendar:", err) 819 } 820 } 821 // Events calendar item click 822 $("body").on("click", ".event-item-btn", function () { 823 generateDayEventsList($(this).data("date"), $(this).data("month"), $(this).data("year")) 824 }) 825 // Events calendar next month 826 $("body").on("click", ".event-calendar-next", function () { 827 eventCalendarNext() 828 }) 829 // Events calendar prev month 830 $("body").on("click", ".event-calendar-prev", function () { 831 eventCalendarPrev() 832 }) 833 // #EVENTS CALENDAR HOMEPAGE --------------------------------------------------- 834 835 // Fetch categories for Meetings 836 if (window.location.pathname == "/" + jmeetingPage || window.location.pathname == "/" + jmeetingPage + "/") { 837 fetchCategories() 838 } 839 840 let $window = $(window), 841 height = $window.height(), 842 width = $window.width(), 843 navheight = $("#defaultHeader #topHeader").height() 844 845 let scrollDirection, oldScrollDirection 846 let oldScrollTop = 0 847 let fixedInitClass = "fixed-init" 848 849 let headerHeight = $("#defaultHeader").height() 850 let headerBottomHeight = $("#defaultHeader #bottomHeader").height() 851 if ($("div#menuHero ul").length) { 852 let heroLineMenuTopOffset = $("div#menuHero ul").offset().top 853 let headerHeroMenuDistance = heroLineMenuTopOffset - headerHeight 854 } 855 let stickyPoint = false 856 $window.on("resize orientationchange", setMeasures) 857 858 // Menu snippet 859 let viewWidth = document.documentElement.clientWidth 860 let elements = document.querySelectorAll(".menu-level-one li > ul") 861 const resizeWatcher = new ResizeObserver((entries) => {
862 for (const entry of entries) { 863 let elementObject = entry.target.getBoundingClientRect() 864 865 if (elementObject.right > viewWidth) { 866 entry.target.style.left = "auto" 867 entry.target.style.right = "100%" 868 } 869 } 870 }) 871 872 elements.forEach((element) => { 873 resizeWatcher.observe(element) 874 }) 875 876 function setMeasures() { 877 ;(height = $window.height()), (width = $window.width()), (navheight = $("#defaultHeader #topHeader").height()) 878 879 headerHeight = $("#defaultHeader").height() 880 headerBottomHeight = $("#defaultHeader #bottomHeader").height() 881 882 if ($("div#menuHero ul").length) { 883 heroLineMenuTopOffset = $("div#menuHero ul").offset().top 884 headerHeroMenuDistance = heroLineMenuTopOffset - headerHeight 885 } 886 } 887 888 // Function Menu Hero Sticky 889 function sticky() { 890 var scrollTop = $window.scrollTop() 891 if (scrollTop > oldScrollTop) { 892 scrollDirection = "down" 893 } else { 894 scrollDirection = "up" 895 } 896 oldScrollTop = scrollTop 897 898 if (!$("#defaultHeader #bottomHeader").hasClass(scrollDirection)) { 899 $("#defaultHeader #bottomHeader").removeClass(oldScrollDirection).addClass(scrollDirection) 900 } 901 902 if (scrollTop > navheight) { 903 $("#defaultHeader").addClass("fixed-header") 904 $("#defaultHeader #bottomHeader").addClass("fixed") 905 $("#defaultHeader #bottomHeader").removeClass("fixed-init").addClass(fixedInitClass) 906 $("#defaultHeader nav.navbar").addClass("fixed-navbar") 907 fixedInitClass = "" 908 $('body [data-bs-toggle="tooltip"]').tooltip("enable") 909 if ($("div#menuHero ul").length) { 910 if (!stickyPoint) { 911 $("div#menuHero").css({ 912 top: "180px" 913 }) 914 } 915 stickyPoint = true 916 $("div#menuHero").addClass("fixed-side-menu").removeClass("absolute-side-menu") 917 } 918 } else { 919 $("#defaultHeader").removeClass("fixed-header") 920 $("#defaultHeader #bottomHeader").removeClass("fixed") 921 $("#defaultHeader nav.navbar").removeClass("fixed-navbar") 922 $('body [data-bs-toggle="tooltip"]').tooltip("disable") 923 924 fixedInitClass = "fixed-init" 925 926 if ($("div#menuHero ul").length) { 927 if (stickyPoint) { 928 $("div#menuHero").css({ 929 top: 0 930 }) 931 } 932 stickyPoint = false 933 $("div#menuHero").addClass("absolute-side-menu").removeClass("fixed-side-menu") 934 } 935 } 936 oldScrollDirection = scrollDirection 937 } 938 939 $window.on("scroll resize orientationchange", sticky) 940 941 // Back to top button 942 $(window).scroll(function () { 943 if ($(this).scrollTop() != 0) { 944 $("#back-to-top").fadeIn() 945 } else { 946 $("#back-to-top").fadeOut() 947 } 948 }) 949 950 $("#back-to-top").on("click touchstart", function (e) { 951 e.preventDefault() 952 $("body,html").animate({ scrollTop: 0 }, 700) 953 return false 954 }) 955 956 $(".marquee").marquee({ 957 //duration in milliseconds of the marquee 958 // duration: 7000, 959 speed: 120, 960 //gap in pixels between the tickers 961 gap: 50, 962 //time in milliseconds before the marquee will start animating 963 delayBeforeStart: 0, 964 //'left' or 'right' 965 direction: "left", 966 //true or false - should the marquee be duplicated to show an effect of continues flow 967 duplicated: false, 968 //true or false - should the marquee animation pause on hover 969 pauseOnHover: true 970 }) 971 972 /* 973 * Ticker Pause/Play functionality 974 * WCAG 2.1 Level A (2.2.2 Pause, Stop, Hide) 975 * The ticker auto-scrolls continuously; give users a keyboard-reachable control 976 * to pause it, and pause by default when reduced motion is requested. 977 */ 978 var $ticker = $(".marquee") 979 var tickerBtn = document.getElementById("tickerPlayPause") 980 if (tickerBtn && $ticker.length) { 981 var tickerPlaying = true 982 var setTicker = function (playing) { 983 var icon = tickerBtn.querySelector("i") 984 var sr = tickerBtn.querySelector(".visually-hidden") 985 if (playing) { 986 $ticker.marquee("resume") 987 tickerBtn.setAttribute("aria-label", "Pause news ticker") 988 icon.classList.remove("fa-play") 989 icon.classList.add("fa-pause") 990 if (sr) sr.textContent = "Pause news ticker" 991 } else { 992 $ticker.marquee("pause") 993 tickerBtn.setAttribute("aria-label", "Play news ticker") 994 icon.classList.remove("fa-pause") 995 icon.classList.add("fa-play") 996 if (sr) sr.textContent = "Play news ticker" 997 } 998 tickerPlaying = playing 999 } 1000 tickerBtn.addEventListener("click", function () { 1001 setTicker(!tickerPlaying) 1002 }) 1003 if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) { 1004 setTicker(false) 1005 } 1006 } 1007 1008 let owl = $("#sectionNewsDefault .owl-carousel") 1009 let owlOptions = { 1010 stagePadding: 0, 1011 loop: true, 1012 dots: true, 1013 margin: 0, 1014 nav: true, 1015 navText: ['<i class="fas fa-angle-left fa-2x"></i>', '<i class="fas fa-angle-right fa-2x"></i>'], 1016 // navContainer: '.custom-nav', 1017 navClass: ["owl-prev text-white", "owl-next float-end tw-secondary"], 1018 items: 3, 1019 autoWidth: false, 1020 center: true, 1021 URLhashListener: true, 1022 autoplayHoverPause: false, 1023 startPosition: "URLHash", 1024 responsive: { 1025 0: { 1026 items: 1 1027 }, 1028 992: { 1029 items: 1 1030 } 1031 } 1032 } 1033 // Hide cloned items from screen readers and remove them from tab order 1034 // (loop: true causes owl to duplicate slides, creating duplicate aria labels) 1035 function newsOwlHideClones() { 1036 var cloned = $("#sectionNewsDefault .owl-carousel .owl-item.cloned") 1037 cloned.attr("aria-hidden", "true") 1038 cloned.find("a, button, input, select, textarea, [tabindex]").attr("tabindex", "-1") 1039 } 1040 1041 // Add accessibility labels to Owl Carousel controls - WCAG 2.1 Level A (4.1.2) 1042 // Owl generates nav buttons with role="presentation" and unlabeled dots; fix 1043 // them synchronously on owl's own lifecycle events (bound BEFORE init â owl 1044 // fires "initialized" during the .owlCarousel() call) so audit snapshots 1045 // never catch the raw markup. 1046 function newsOwlA11yFix() { 1047 var $carousel = $("#sectionNewsDefault .owl-carousel") 1048 $carousel.find(".owl-prev").attr("aria-label", "Previous news article").removeAttr("role") 1049 $carousel.find(".owl-next").attr("aria-label", "Next news article").removeAttr("role") 1050 $carousel.find(".owl-dot").each(function (index) { 1051 $(this).attr("aria-label", "Go to news article " + (index + 1)) 1052 }) 1053 $carousel.find(".owl-dot").removeAttr("aria-current") 1054 $carousel.find(".owl-dot.active").attr("aria-current", "true") 1055 newsOwlHideClones() 1056 } 1057 1058 owl.on("initialized.owl.carousel refreshed.owl.carousel resized.owl.carousel", function () { 1059 newsOwlA11yFix() 1060 }) 1061 1062 var owlActive = owl.owlCarousel(owlOptions) 1063 1064 // Safety net in case a plugin re-renders controls after init 1065 newsOwlA11yFix() 1066 setTimeout(newsOwlA11yFix, 100) 1067 1068 // Update aria-current when slide changes and re-hide clones 1069 owlActive.on("changed.owl.carousel", function (event) { 1070 newsOwlA11yFix() 1071 }) 1072 1073 $(".customizer-content a").click(function (e) { 1074 e.preventDefault() 1075 }) 1076 1077 let owlEvents = $("#tw-events .owl-carousel") 1078 let owlOptionsEvents = { 1079 // stagePadding: 0, 1080 loop: false, 1081 dots: false, 1082 // margin:0, 1083 nav: true, 1084 navText: ['<i class="fas fa-angle-left fa-2x"></i>', '<i class="fas fa-angle-right fa-2x"></i>'], 1085 // navContainer: '.custom-nav', 1086 navClass: ["owl-prev tw-secondary", "owl-next float-end tw-secondary"], 1087 items: 5, 1088 autoWidth: false, 1089 center: false, 1090 URLhashListener: true, 1091 autoplayHoverPause: false, 1092 startPosition: "URLHash", 1093 responsive: { 1094 0: { 1095 items: 2 1096 }, 1097 768: { 1098 items: 3 1099 }, 1100 992: { 1101 items: 4 1102 }, 1103 1184: { 1104 items: 5 1105 } 1106 } 1107 } 1108 // Add accessibility labels to Events Owl Carousel controls - WCAG 2.1 Level A (4.1.2)
1109 // Bound BEFORE init for the same audit-snapshot reason as the news carousel. 1110 function eventsOwlA11yFix() { 1111 var $carousel = $("#tw-events .owl-carousel") 1112 $carousel.find(".owl-prev").attr("aria-label", "Previous event").removeAttr("role") 1113 $carousel.find(".owl-next").attr("aria-label", "Next event").removeAttr("role") 1114 $carousel.find(".owl-dot").each(function (index) { 1115 $(this).attr("aria-label", "Go to event " + (index + 1)) 1116 }) 1117 $carousel.find(".owl-dot").removeAttr("aria-current") 1118 $carousel.find(".owl-dot.active").attr("aria-current", "true") 1119 } 1120 1121 owlEvents.on("initialized.owl.carousel refreshed.owl.carousel resized.owl.carousel", function () { 1122 eventsOwlA11yFix() 1123 }) 1124 1125 var owlActiveEvents = owlEvents.owlCarousel(owlOptionsEvents) 1126 1127 // Safety net in case a plugin re-renders controls after init 1128 eventsOwlA11yFix() 1129 setTimeout(eventsOwlA11yFix, 100) 1130 1131 // Update aria-current when slide changes 1132 owlActiveEvents.on("changed.owl.carousel", function (event) { 1133 eventsOwlA11yFix() 1134 }) 1135 1136 // Handle search ----------------------------------------------------------------------------------------------------------------------------------------------- 1137 function generateSearchItem(item) { 1138 if (jActiveAdvancedSearch == 0) { 1139 var dom = new DOMParser().parseFromString(item.content, "text/html") 1140 var content = dom.body.textContent 1141 content = content.substring(0, 150) + "..." 1142 } 1143 let html = "" 1144 html = ` 1145 <div class='col-md-4 mb-3'><h2><a href=" 1146 ${jActiveAdvancedSearch == 1 ? item.file_url || item.url_tw : window.location.protocol + "//" + window.location.host + item.slug} 1147 "> 1148 ${item.title} 1149 </a></h2><div> 1150 ${content || item.content} 1151 </div></div> 1152 ` 1153 return html 1154 } 1155 1156 if (window.location.pathname.includes("search")) { 1157 const queryString = window.location.search 1158 const urlParams = new URLSearchParams(queryString) 1159 const q = urlParams.get("q") 1160 // Load the locations once, on page-load. 1161 if (jActiveAdvancedSearch == 1) { 1162 $(async function () { 1163 const res = await fetch("/search/search.php", { 1164 method: "POST", 1165 headers: { 1166 "Content-Type": "application/json" 1167 }, 1168 body: JSON.stringify({ q: q }) 1169 }) 1170 const data = await res.json() 1171 1172 let searchContainer = document.getElementById("search-results") 1173 let searchString = document.getElementById("search-string") 1174 searchString.innerHTML = q 1175 let searchArr = [] 1176 // Iterate over each element in the array 1177 for (var i = 0; i < data.length; i++) { 1178 searchContainer.innerHTML += generateSearchItem(data[i]) 1179 searchArr.push(data[i]) 1180 } 1181 if (searchArr.length < 1) { 1182 searchContainer.innerHTML += '<div class="text-center text-warning fs-1">There are no results found</div>' 1183 } 1184 // console.log(data); 1185 }) 1186 } else { 1187 $(function () { 1188 $.getJSON(window.location.protocol + "//" + window.location.host + `/json/search/search.json?sk=${Date.now()}`) 1189 .done(function (data) { 1190 let searchContainer = document.getElementById("search-results") 1191 let searchString = document.getElementById("search-string") 1192 searchString.innerHTML = q 1193 let searchArr = [] 1194 // Iterate over each element in the array 1195 for (var i = 0; i < data.length; i++) { 1196 var content = data[i].content 1197 // look for the entry with a matching `code` value 1198 if (data[i].title.toLowerCase().includes(q.toLowerCase())) { 1199 searchContainer.innerHTML += generateSearchItem(data[i]) 1200 searchArr.push(data[i]) 1201 } else { 1202 if (content && content.toLowerCase().includes(q.toLowerCase())) { 1203 searchContainer.innerHTML += generateSearchItem(data[i]) 1204 searchArr.push(data[i]) 1205 } 1206 } 1207 } 1208 if (searchArr.length < 1) { 1209 searchContainer.innerHTML += '<div class="text-center text-warning fs-1">There are no results found</div>' 1210 } 1211 }) 1212 .fail(function (data) { 1213 console.log("Error loading results â") 1214 }) 1215 }) 1216 } 1217 } 1218 1219 // Handle Contact Us ----------------------------------------------------------------------------------------------------------------------------------------------------- 1220 if (typeof jid !== "undefined") { 1221 $(".contact-form #submit-btn").click(function (e) { 1222 e.preventDefault() 1223 data = $(".contact-form").serialize() 1224 url = window.location.protocol + "//" + window.location.host 1225 console.log(url) 1226 $.ajax({ 1227 method: "post", 1228 url: jurl + "/static/contact", // NEED TO UPDATE 1229 data: $(".contact-form").serialize() + "&url=" + url, 1230 success: function (msg) { 1231 console.log(msg) 1232 if (msg.message == "Success") { 1233 $(".contact-success").removeClass("d-none") 1234 $(".contact-success").html(jcontactSuccessMsg) 1235 setTimeout(cleanContactUs, 3000) 1236 } 1237 }, 1238 fail: function (err) { 1239 console.log("Error submitting contact form") 1240 } 1241 }) 1242 }) 1243 } 1244 1245 // Clean inputs and hide success message after 3000ms 1246 function cleanContactUs() { 1247 $("#first").val("") 1248 $("#last").val("") 1249 $("#email").val("") 1250 $("#phone").val("") 1251 $("#message").val("") 1252 $(".contact-success").addClass("d-none") 1253 } 1254 1255 // Handle Quick Subscribe ------------------------------------------------------------------------------------------------------------------------------------------------- 1256 1257 function validateEmail(email) { 1258 var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 1259 return re.test(String(email).toLowerCase()) 1260 } 1261 1262 // Validation regex patterns (same as unified.php) 1263 const nameRegex = /^[A-Za-zÃ-ÃÃ-öø-ÿ' -]{2,50}$/ 1264 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ 1265 const usPhoneRegex = /^\+?1?\s?\(?([2-9][0-9]{2})\)?[-.\s]?[2-9][0-9]{2}
1265[-.\s]?[0-9]{4}$/ 1266 1267 // Validation function for names 1268 function validateName(name) { 1269 return name && name.trim() && nameRegex.test(name.trim()) 1270 } 1271 1272 // Validation function for email (improved) 1273 function validateEmailStrict(email) { 1274 return email && email.trim() && emailRegex.test(email.trim()) 1275 } 1276 1277 // Validation function for US phone 1278 function validateUSPhone(phone) { 1279 return phone && phone.trim() && usPhoneRegex.test(phone.trim()) 1280 } 1281 1282 // Check if unified subscribe is being used 1283 const useUnifiedSubscribe = typeof jUseUnifiedSubscribe !== "undefined" && jUseUnifiedSubscribe 1284 const isUnifiedDashboard = typeof jMainDashboard !== "undefined" && jMainDashboard === "unified-dashboard" 1285 1286 // Unified Quick Subscribe Handler (legacy) 1287 async function handleUnifiedQuickSubscribe(firstName, lastName, email, phone) { 1288 try { 1289 // Fetch departments 1290 const departmentsResponse = await fetch("https://api.heygov.com/" + jHeyGovJurisdiction + "/departments") 1291 const departments = await departmentsResponse.json() 1292 1293 // Fetch lists 1294 const listsResponse = await fetch("https://heygov-api-main-438038920523.us-central1.run.app/" + jHeyGovJurisdiction + "/lists") 1295 const lists = await listsResponse.json() 1296 1297 // Find Agenda and Minutes list 1298 let amlist_id = null 1299 const filteredLists = [] 1300 1301 lists.forEach((list) => { 1302 if (list.name === "Agenda and Minutes") { 1303 amlist_id = list.id 1304 } else { 1305 filteredLists.push(list.id) 1306 } 1307 }) 1308 1309 // Build subscribed_to array 1310 const subscribedTo = [] 1311 if (email && email.trim() !== "") { 1312 subscribedTo.push("email") 1313 } 1314 if (phone && phone.trim() !== "") { 1315 subscribedTo.push("sms") 1316 } 1317 1318 // Build lists array 1319 const listsData = [] 1320 1321 // Add all departments with Agenda and Minutes list 1322 if (departments.length > 0 && amlist_id) { 1323 listsData.push({ 1324 list_id: amlist_id, 1325 departments: departments.map((dept) => dept.id), 1326 subscribed_to: subscribedTo 1327 }) 1328 } 1329 1330 // Add all other lists 1331 filteredLists.forEach((listId) => { 1332 listsData.push({ 1333 list_id: listId, 1334 departments: null, 1335 subscribed_to: subscribedTo 1336 }) 1337 }) 1338 1339 // Convert phone to number with 1 prefix for US 1340 let phoneNumber = null 1341 if (phone && phone.trim() !== "") { 1342 const phoneDigits = phone.replace(/\D/g, "") 1343 phoneNumber = parseInt("1" + phoneDigits, 10) 1344 } 1345 1346 // Build final request data - only include non-empty fields 1347 const data = { 1348 lists: listsData 1349 } 1350 1351 // Add email if provided 1352 if (email && email.trim() !== "") { 1353 data.email = email 1354 } 1355 1356 // Add phone_number if provided 1357 if (phoneNumber) { 1358 data.phone_number = phoneNumber 1359 } 1360 1361 // Add first_name if provided 1362 if (firstName && firstName.trim() !== "") { 1363 data.first_name = firstName 1364 } 1365 1366 // Add last_name only if provided 1367 if (lastName && lastName.trim() !== "") { 1368 data.last_name = lastName 1369 } 1370 1371 // Submit to unified API 1372 const response = await fetch("https://api.heygov.com/" + jHeyGovJurisdiction + "/subscribe", { 1373 method: "POST", 1374 headers: { 1375 "Content-Type": "application/json" 1376 }, 1377 body: JSON.stringify(data) 1378 }) 1379 1380 if (!response.ok) { 1381 throw new Error("Server responded with status: " + response.status) 1382 } 1383 1384 return await response.json() 1385 } catch (error) { 1386 throw error 1387 } 1388 } 1389 1390 // Auto Lists Quick Subscribe Handler (for unified-dashboard) 1391 async function handleAutoListsSubscribe(firstName, lastName, email, phone) { 1392 try { 1393 // Convert phone to number with 1 prefix for US 1394 let phoneNumber = null 1395 if (phone && phone.trim() !== "") { 1396 const phoneDigits = phone.replace(/\D/g, "") 1397 phoneNumber = parseInt("1" + phoneDigits, 10) 1398 } 1399 1400 // Build final request data - only include non-empty fields 1401 const data = { 1402 auto_lists: true 1403 } 1404 1405 // Add email if provided 1406 if (email && email.trim() !== "") { 1407 data.email = email 1408 } 1409 1410 // Add phone_number if provided 1411 if (phoneNumber) { 1412 data.phone_number = phoneNumber 1413 } 1414
1415 // Add first_name if provided 1416 if (firstName && firstName.trim() !== "") { 1417 data.first_name = firstName 1418 } 1419 1420 // Add last_name only if provided 1421 if (lastName && lastName.trim() !== "") { 1422 data.last_name = lastName 1423 } 1424 1425 // Submit to unified API 1426 const response = await fetch("https://heygov-api-main-438038920523.us-central1.run.app/" + jHeyGovJurisdiction + "/lists/subscribe", { 1427 method: "POST", 1428 headers: { 1429 "Content-Type": "application/json" 1430 }, 1431 body: JSON.stringify(data) 1432 }) 1433 1434 // Parse response 1435 const responseText = await response.text() 1436 let responseData = {} 1437 try { 1438 responseData = responseText ? JSON.parse(responseText) : {} 1439 } catch (e) { 1440 // Failed to parse JSON 1441 } 1442 1443 if (!response.ok) { 1444 // Extract error message from response 1445 let errorMessage = "An error occurred while processing your subscription. Please try again later." 1446 if (responseData && responseData.message) { 1447 errorMessage = responseData.message 1448 } else if (responseData && responseData.error) { 1449 errorMessage = responseData.error 1450 } 1451 1452 const error = new Error(errorMessage) 1453 error.responseData = responseData 1454 throw error 1455 } 1456 1457 return responseData 1458 } catch (error) { 1459 throw error 1460 } 1461 } 1462 1463 $("#sub_phone").mask("999-999-9999", { 1464 placeholder: "___-___-____", 1465 clearIfNotMatch: true 1466 }) 1467 1468 // Clear error message when user starts typing in name field 1469 $("#sub_name").on("input", function() { 1470 $("#sub_name-error").remove() 1471 }) 1472 1473 $("#subs_button").click(function (e) { 1474 e.preventDefault() 1475 1476 let fullName = $("#sub_name").val().trim() 1477 let nameParts = fullName.split(" ") 1478 let firstName = nameParts[0] || "" 1479 let lastName = nameParts.length > 1 ? nameParts.slice(1).join(" ") : "" 1480 let email = $("#sub_email").val() 1481 let phone = $("#sub_phone").val() 1482 1483 // Validate that at least first name is provided 1484 if (!firstName) { 1485 $("#sub_name-error").remove() 1486 $("#sub_name").after('<div id="sub_name-error" style="color: red; font-size: 12px; margin-top: 4px;">Please enter your name</div>') 1487 $("#sub_name").focus() 1488 return 1489 } 1490 1491 // Check if form is valid using HTML5 validation 1492 if ($("#homepage-subscriber-form")[0].checkValidity()) { 1493 let btn_html = $("#subs_button").html() 1494 $("#subs_button + div.alert").remove() 1495 $("#subs_button").html('<i class="fa fa-spinner fa-spin"></i> Working') 1496 1497 $("#sub_name").attr("readonly", true) 1498 $("#sub_email").attr("readonly", true) 1499 $("#sub_phone").attr("readonly", true) 1500 1501 // Use unified-dashboard auto lists subscribe 1502 if (isUnifiedDashboard) { 1503 handleAutoListsSubscribe(firstName, lastName, email, phone) 1504 .then((result) => { 1505 // Success 1506 $("#subs_button").addClass("btn-success homepage-subscribed-success font-weight-bold") 1507 $("#subs_button").html("Successfully Subscribed!") 1508 1509 setTimeout(function () { 1510 $("#subs_button").removeClass("btn-success") 1511 $("#subs_button").removeClass("homepage-subscribed-success") 1512 $("#subs_button").html("Subscribe now") 1513 }, 6000) 1514 1515 // Clear inputs 1516 $("#sub_name").val("") 1517 $("#sub_email").val("") 1518 $("#sub_phone").val("") 1519 $("#sub_name").attr("readonly", false) 1520 $("#sub_email").attr("readonly", false) 1521 $("#sub_phone").attr("readonly", false) 1522 }) 1523 .catch((error) => { 1524 console.error("Error:", error) 1525 $("#subs_button").addClass("btn-danger homepage-subscribed-danger font-weight-bold") 1526 $("#subs_button").html("Error Occurred!") 1527 1528 // Use error message from API if available 1529 const errorMessage = error.message || "An error occurred while processing your subscription. Please try again later." 1530 1531 $("#homepage-subscriber-form").after(`<div class="row ms-1">
1531<div id="subscribe-error" class="alert alert-danger alert-dismissible" role="alert">${errorMessage}<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></div></div>`) 1532 1533 setTimeout(function () { 1534 $("#subs_button").removeClass("btn-danger") 1535 $("#subs_button").removeClass("homepage-subscribed-danger") 1536 $("#subs_button").html("Subscribe now") 1537 $("#subscribe-error").fadeOut() 1538 }, 6000) 1539 1540 $("#sub_name").attr("readonly", false) 1541 $("#sub_email").attr("readonly", false) 1542 $("#sub_phone").attr("readonly", false) 1543 }) 1544 } else if (useUnifiedSubscribe) { 1545 // Use old unified subscribe (legacy) 1546 handleUnifiedQuickSubscribe(firstName, lastName, email, phone) 1547 .then((result) => { 1548 // Success 1549 $("#subs_button").addClass("btn-success homepage-subscribed-success font-weight-bold") 1550 $("#subs_button").html("Successfully Subscribed!") 1551 1552 setTimeout(function () { 1553 $("#subs_button").removeClass("btn-success") 1554 $("#subs_button").removeClass("homepage-subscribed-success") 1555 $("#subs_button").html("Subscribe now") 1556 }, 6000) 1557 1558 // Clear inputs 1559 $("#sub_name").val("") 1560 $("#sub_email").val("") 1561 $("#sub_phone").val("") 1562 $("#sub_name").attr("readonly", false) 1563 $("#sub_email").attr("readonly", false) 1564 $("#sub_phone").attr("readonly", false) 1565 }) 1566 .catch((error) => { 1567 console.error("Error:", error) 1568 $("#subs_button").addClass("btn-danger homepage-subscribed-danger font-weight-bold") 1569 $("#subs_button").html("Error Occurred!") 1570 $("#homepage-subscriber-form").after(`<div class="row ms-1"><div id="subscribe-error" class="alert alert-danger alert-dismissible" role="alert">An error occurred while processing your subscription.<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></div></div>`) 1571 1572 setTimeout(function () { 1573 $("#subs_button").removeClass("btn-danger") 1574 $("#subs_button").removeClass("homepage-subscribed-danger") 1575 $("#subs_button").html("Subscribe now") 1576 $("#subscribe-error").fadeOut() 1577 }, 6000) 1578 1579 $("#sub_name").attr("readonly", false) 1580 $("#sub_email").attr("readonly", false) 1581 $("#sub_phone").attr("readonly", false) 1582 }) 1583 } else { 1584 // Use legacy subscribe 1585 $.ajax({ 1586 method: "post", 1587 url: jurl + "/subscribers/register?client_id=" + jid, 1588 data: { 1589 name: fullName, 1590 email: email, 1591 phone: phone, 1592 quick: true 1593 }, 1594 success: function (msg) { 1595 //display success 1596 if (msg.type != "success") { 1597 errrMessage = msg.message 1598 if (msg && msg.errors && msg.errors.email) { 1599 errrMessage = msg.errors.email[0] 1600 } 1601 $("#subs_button").addClass("btn-danger homepage-subscribed-danger font-weight-bold") 1602 $("#subs_button").html("Error Occurred!") 1603 $("#homepage-subscriber-form").after(`<div class="row ms-1"><div id="subscribe-error" class="alert alert-danger alert-dismissible" role="alert"> ${errrMessage}<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></div></div>`) 1604 //Remove alert, button danger & button text after 3 sec 1605 setTimeout(function () { 1606 $("#subs_button").removeClass("btn-danger") 1607 $("#subs_button").addClass("subscribe-button") 1608 $("#subs_button").html("Subscribe now") 1609 $("#subscribe-error").fadeOut() 1610 }, 6000) 1611 } else { 1612 if ($("#subs_button").hasClass("homepage-subscribed-danger")) { 1613 $("#subs_button").removeClass("homepage-subscribed-danger") 1614 } 1615 if ($("#subs_button").hasClass("btn-danger")) { 1616 $("#subs_button").removeClass("btn-danger") 1617 } 1618 $("#subs_button").addClass("btn-success homepage-subscribed-success font-weight-bold") 1619 $("#subs_button").html("Successfully Subscribed!") 1620 // Remove success class after 3 sec 1621 setTimeout(function () { 1622 $("#subs_button").removeClass("btn-success") 1623 $("#subs_button").addClass("subscribe-button") 1624 $("#subs_button").html("Subscribe now") 1625 }, 6000) 1626 } 1627 //clear input values 1628 $("#sub_name").val("") 1629 $("#sub_email").val("") 1630 $("#sub_phone").val("") 1631 $("#sub_name").attr("readonly", false) 1632 $("#sub_email").attr("readonly", false) 1633 $("#sub_phone").attr("readonly", false) 1634 }, 1635 fail: function (jqXHR, textStatus) { 1636 //display error message 1637 $("#subs_button").html("Error") 1638 1639 //clear input values 1640 $("#sub_name").val("") 1641 $("#sub_email").val("") 1642 $("#sub_phone").val("") 1643 $("#sub_name").attr("readonly", false) 1644 $("#sub_email").attr("readonly", false) 1645 $("#sub_phone").attr("readonly", false) 1646 } 1647 }) 1648 } 1649 } else { 1650 // Trigger browser validation messages 1651 $("#homepage-subscriber-form")[0].reportValidity() 1652 } 1653 }) 1654 1655 // Hande Subscribe ------------------------------------------------------------------------------------------------------------------------------------------------------------ 1656 1657 // Create meeting categories 1658 function generateMeetingCategories() {
1659 let categoriesHtml = "" 1660 let categoriesContainer = $("#juniper-meetings-categories") 1661 let meetingCategories = JSON.parse(jMeetingsSubscribableCategories) 1662 meetingCategories.forEach((category) => { 1663 categoriesHtml += `<div class="sub"> 1664 <input id="juniper-subscriber-agenda-${category.id}" class="juniper-child-checkbox" type="checkbox" name="agenda_terms[]" value="${category.id}"> 1665 <label for="juniper-subscriber-agenda-${category.id}">${category.name}</label><br> 1666 </div>` 1667 }) 1668 categoriesContainer.html(categoriesHtml) 1669 } 1670 1671 function generateNewsCategories() { 1672 let newsCategoriesHtml = "" 1673 let newsCategoriesContainer = $("#juniper-news-categories") 1674 let newsCategories = JSON.parse(jNewsSubscribableCategories) 1675 newsCategories.forEach((category) => { 1676 newsCategoriesHtml += `<div class="sub"> 1677 <input id="juniper-subscriber-news-${category.id}" class="juniper-child-checkbox" type="checkbox" name="post_terms[]" value="${category.id}"> 1678 <label for="juniper-subscriber-news-${category.id}">${category.name}</label><br> 1679 </div>` 1680 }) 1681 newsCategoriesContainer.html(newsCategoriesHtml) 1682 } 1683 1684 // Invoke generate meeting categories 1685 generateMeetingCategories() 1686 // Invoke generate news categories 1687 generateNewsCategories() 1688 1689 // Mask for Phone 1690 $("#juniper-subscriber-phone").mask("(999) 999-9999") 1691 1692 // Checkbox 1693 $(".juniper-parent-checkbox") 1694 .click(function () { 1695 $(this).parent().find(".juniper-child-checkbox").prop("disabled", !this.checked).prop("checked", this.checked) 1696 }) 1697 .trigger("click") 1698 1699 // Phone required when sms checked 1700 $("#juniper-subscriber-receive-text-message") 1701 .click(function () { 1702 $("#juniper-subscriber-phone, #juniper-subscriber-carrier").prop("required", this.checked) 1703 }) 1704 .trigger("click") 1705 1706 // Handle subscribe now button 1707 if (typeof jid !== "undefined") { 1708 $("body").on("submit", "#juniper-subscriber-form", function () { 1709 $.ajax({ 1710 type: "POST", 1711 url: jurl + "/subscribers/register?client_id=" + jid, 1712 data: $(this).serialize(), 1713 success: function (data) { 1714 if (data.type == "success") { 1715 $("#juniper-subscribe-complete").removeClass("d-none") 1716 } 1717 if (data.type == "error") { 1718 let text = "" 1719 for (const [key, value] of Object.entries(data.errors)) { 1720 text += `<p>${value}</p>` 1721 } 1722 $("#juniper-subscribe-error").removeClass("d-none") 1723 $("#juniper-subscribe-error").html(text) 1724 } 1725 }, 1726 fail: function (error) { 1727 console.log("Error submiting subscriber form") 1728 } 1729 }) 1730 1731 return true 1732 }) 1733 } 1734 1735 // Handle unsubscribe button 1736 if (typeof jid !== "undefined") { 1737 $("body").on("click", "#twd-unsubscribe-btn", function () { 1738 if (confirm("Are you sure?")) { 1739 $.ajax({ 1740 type: "GET", 1741 url: $(this).attr("href"), 1742 dataType: "html" 1743 }).done(function (date) { 1744 $("#twd-subscribe-complete").html(date) 1745 $("#twd-subscriber-submit").val("Subscribe") 1746 $("#twd-subscriber-form").slideDown() 1747 }) 1748 } 1749 return false 1750 }) 1751 } 1752 1753 // Fix Department dont have contact us 1754 const contactUsDiv = document.getElementById("tw-contact-us") 1755 1756 // Check if the div has child div elements 1757 if (contactUsDiv && !contactUsDiv.querySelector("div")) { 1758 // If no child div elements, hide the parent div 1759 contactUsDiv.style.display = "none" 1760 } 1761 1762 document.querySelectorAll("#menuPrimary .dropdown-menu .dropdown-toggle").forEach((item) => { 1763 item.addEventListener("click", function (event) { 1764 event.stopPropagation() // Prevent Bootstrap from closing the entire dropdown 1765 let submenu = this.nextElementSibling 1766 if (submenu) { 1767 submenu.classList.toggle("show") 1768 } 1769 }) 1770 }) 1771 1772 // Close all submenus when clicking outside 1773 document.addEventListener("click", function () { 1774 document.querySelectorAll("#menuPrimary .dropdown-menu .show").forEach((submenu) => { 1775 submenu.classList.remove("show") 1776 }) 1777 }) 1778}) 1779 1780function waitForElement(selector) { 1781 return new Promise((resolve) => { 1782 if (document.querySelector(selector)) { 1783 return resolve(document.querySelector(selector)) 1784 } 1785 1786 const observer = new MutationObserver((mutations) => { 1787 if (document.querySelector(selector)) { 1788 observer.disconnect() 1789 resolve(document.querySelector(selector)) 1790 } 1791 }) 1792 1793 observer.observe(document.body, { 1794 childList: true, 1795 subtree: true 1796 }) 1797 }) 1798} 1799 1800/** 1801 * Accessibility improvement: Remove redundant "Navigate to" text from menu items 1802 * Screen readers already announce links, so "Navigate to" is unnecessary and redundant 1803 */ 1804document.addEventListener("DOMContentLoaded", function () { 1805 const menuItems = document.querySelectorAll("#menuPrimary .visually-hidden")
1806 menuItems.forEach(function (el) { 1807 if (el.textContent.trim() === "Navigate to" || el.textContent.trim() === "Navigate to ") { 1808 el.remove() 1809 } 1810 }) 1811 1812 /** 1813 * Accessibility improvement: Add proper ARIA attributes to dropdown menu items 1814 * WCAG 2.1 Level A (4.1.2) - Name, Role, Value 1815 */ 1816 const dropdownToggles = document.querySelectorAll('#menuPrimary [data-bs-toggle="dropdown"]') 1817 dropdownToggles.forEach(function (toggle) { 1818 // Add aria-haspopup if not present 1819 if (!toggle.hasAttribute("aria-haspopup")) { 1820 toggle.setAttribute("aria-haspopup", "true") 1821 } 1822 1823 // Ensure aria-expanded is set correctly on click for mobile 1824 toggle.addEventListener("click", function () { 1825 const isExpanded = toggle.getAttribute("aria-expanded") === "true" 1826 toggle.setAttribute("aria-expanded", !isExpanded) 1827 }) 1828 }) 1829 1830 /** 1831 * Close mobile menu when clicking outside on mobile devices 1832 * Improves UX and accessibility 1833 */ 1834 if (window.innerWidth <= 768) { 1835 document.addEventListener("click", function (event) { 1836 const menuCollapse = document.getElementById("menuPrimaryContent") 1837 const menuToggler = document.querySelector(".navbar-toggler") 1838 1839 if (menuCollapse && menuToggler) { 1840 const isClickInsideMenu = menuCollapse.contains(event.target) 1841 const isClickOnToggler = menuToggler.contains(event.target) 1842 1843 if (!isClickInsideMenu && !isClickOnToggler && menuCollapse.classList.contains("show")) { 1844 menuToggler.click() 1845 } 1846 } 1847 }) 1848 } 1849 1850 /** 1851 * Remove empty headings from footer 1852 * WCAG 2.1 Level A (1.3.1) - Empty headings cause accessibility issues 1853 * Fix for WAVE "Empty heading" errors 1854 */ 1855 const footerHeadings = document.querySelectorAll("footer#footerDefault .footer-default-heading")
1856 footerHeadings.forEach(function (heading) { 1857 const text = heading.textContent.trim() 1858 if (text === "" || text.length === 0) { 1859 heading.remove() 1860 } 1861 }) 1862 1863 /** 1864 * Header Search Toggle Functionality 1865 * Accessible toggle for search input 1866 */ 1867 const searchForm = document.getElementById("header-search-form") 1868 const searchInput = document.getElementById("searchright") 1869 const searchButton = document.querySelector(".search-button.searchbutton") 1870 1871 if (searchForm && searchInput && searchButton) { 1872 let isSearchOpen = false 1873 1874 searchButton.addEventListener("click", function (e) { 1875 e.preventDefault() 1876 1877 if (!isSearchOpen) { 1878 // Open search 1879 searchInput.classList.add("open") 1880 searchButton.setAttribute("aria-expanded", "true") 1881 searchButton.setAttribute("aria-label", "Close search or submit search") 1882 searchInput.focus() 1883 isSearchOpen = true 1884 } else { 1885 // Check if there's a search query 1886 if (searchInput.value.trim().length > 0) { 1887 // Submit the form 1888 searchForm.submit() 1889 } else { 1890 // Close search 1891 searchInput.classList.remove("open") 1892 searchButton.setAttribute("aria-expanded", "false") 1893 searchButton.setAttribute("aria-label", "Toggle search") 1894 isSearchOpen = false 1895 } 1896 } 1897 }) 1898 1899 // Submit form on Enter key 1900 searchInput.addEventListener("keypress", function (e) { 1901 if (e.key === "Enter" && searchInput.value.trim().length > 0) { 1902 e.preventDefault() 1903 searchForm.submit() 1904 } 1905 }) 1906 1907 // Close search when clicking outside 1908 document.addEventListener("click", function (e) { 1909 if (isSearchOpen && !searchForm.contains(e.target)) { 1910 searchInput.classList.remove("open") 1911 searchButton.setAttribute("aria-expanded", "false") 1912 searchButton.setAttribute("aria-label", "Toggle search") 1913 isSearchOpen = false 1914 } 1915 }) 1916 1917 // Close search on Escape key 1918 document.addEventListener("keydown", function (e) { 1919 if (e.key === "Escape" && isSearchOpen) { 1920 searchInput.classList.remove("open") 1921 searchButton.setAttribute("aria-expanded", "false") 1922 searchButton.setAttribute("aria-label", "Toggle search") 1923 searchInput.value = "" 1924 isSearchOpen = false 1925 } 1926 }) 1927 } 1928 1929 /** 1930 * Add aria-current to active carousel indicator 1931 * WCAG 2.1 Best Practice 1932 */ 1933 const carouselIndicators = document.querySelectorAll(".carousel-indicators button") 1934 if (carouselIndicators.length > 0) { 1935 carouselIndicators.forEach(function (indicator, index) { 1936 if (indicator.classList.contains("active")) { 1937 indicator.setAttribute("aria-current", "true") 1938 } 1939 }) 1940 1941 // Update aria-current when slide changes 1942 const carouselEl = document.getElementById("heroSlider") 1943 if (carouselEl) { 1944 carouselEl.addEventListener("slid.bs.carousel", function (event) { 1945 carouselIndicators.forEach(function (indicator) { 1946 indicator.removeAttribute("aria-current") 1947 }) 1948 if (carouselIndicators[event.to]) { 1949 carouselIndicators[event.to].setAttribute("aria-current", "true") 1950 } 1951 }) 1952 } 1953 } 1954 1955 /** 1956 * Carousel Pause/Play functionality 1957 * WCAG 2.1 Level A (2.2.2 Pause, Stop, Hide) 1958 * Allows users to control auto-rotating carousel 1959 */ 1960 const carouselElement = document.getElementById("heroSlider") 1961 const playPauseBtn = document.getElementById("carouselPlayPause") 1962 1963 if (carouselElement && playPauseBtn) { 1964 // Initialize Bootstrap Carousel instance 1965 const carousel = new bootstrap.Carousel(carouselElement, { 1966 ride: "carousel" 1967 }) 1968 1969 let isPlaying = true 1970 1971 playPauseBtn.addEventListener("click", function () { 1972 if (isPlaying) { 1973 // Pause the carousel 1974 carousel.pause() 1975 playPauseBtn.setAttribute("aria-label", "Play carousel auto-rotation") 1976 playPauseBtn.querySelector("i").classList.remove("fa-pause") 1977 playPauseBtn.querySelector("i").classList.add("fa-play") 1978 playPauseBtn.querySelector(".visually-hidden").textContent = "Play" 1979 isPlaying = false 1980 } else { 1981 // Play the carousel
1982 carousel.cycle() 1983 playPauseBtn.setAttribute("aria-label", "Pause carousel auto-rotation") 1984 playPauseBtn.querySelector("i").classList.remove("fa-play") 1985 playPauseBtn.querySelector("i").classList.add("fa-pause") 1986 playPauseBtn.querySelector(".visually-hidden").textContent = "Pause" 1987 isPlaying = true 1988 } 1989 }) 1990 1991 // Pause carousel when user hovers over it (better UX) 1992 carouselElement.addEventListener("mouseenter", function () { 1993 if (isPlaying) { 1994 carousel.pause() 1995 } 1996 }) 1997 1998 // Resume carousel when mouse leaves (only if it was playing) 1999 carouselElement.addEventListener("mouseleave", function () { 2000 if (isPlaying) { 2001 carousel.cycle() 2002 } 2003 }) 2004 2005 // Pause carousel when it receives keyboard focus (accessibility) 2006 carouselElement.addEventListener("focusin", function () { 2007 carousel.pause() 2008 }) 2009 } 2010 2011 /** 2012 * Prevent body scroll when mobile menu is open 2013 * Allows menu content to scroll independently while preventing page scroll 2014 * Fixes issue where entire page scrolls instead of just the menu 2015 */ 2016 const menuCollapseElement = document.getElementById("menuPrimaryContent") 2017 2018 if (menuCollapseElement) { 2019 // Listen for Bootstrap collapse show event 2020 menuCollapseElement.addEventListener("show.bs.collapse", function () { 2021 document.body.style.overflow = "hidden" 2022 }) 2023 2024 // Listen for Bootstrap collapse hide event 2025 menuCollapseElement.addEventListener("hide.bs.collapse", function () { 2026 document.body.style.overflow = "" 2027 }) 2028 } 2029})
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.