PageSourceSearch

https://villageofstarprairie.com/theme.js?v=8f7813cc

js villageofstarprairie.com collected 2026-10-02 20:04:01 UTC 85,259 bytes, 2,294 lines download raw bytes

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

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.