PageSourceSearch

https://townofrosehillnc.com/theme.js?v=99c0169a

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

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.