PageSourceSearch

https://villageofsharon.com/theme.js?v=e811661f

js villageofsharon.com collected 2026-10-03 00:02:09 UTC 72,646 bytes, 2,029 lines download raw bytes

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 &amp; 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.