PageSourceSearch

https://cityofmotley.com/theme.js?v=36a6a620

js cityofmotley.com collected 2026-10-02 23:41:13 UTC 84,999 bytes, 2,292 lines download raw bytes

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

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.