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