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