PageSourceSearch

https://victorygardensnj.gov/theme.js?v=8f7813cc

js victorygardensnj.gov collected 2026-10-02 19:19:33 UTC 77,614 bytes, 2,129 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
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 &amp; 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.