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