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