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