PageSourceSearch

https://townofnasewaupeewi.gov/theme.js?v=e811661f

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

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.