1/** 2 * GPDC Dynamic Components Loader 3 * Loads shared header, footer, hero components, and CMS-managed images across all pages 4 */ 5 6(function() { 7 'use strict'; 8 9 // Hardcoded fallback heroes (used if _data/heroes.json fetch fails) 10 const fallbackHeroes = { 11 'home': { image: 'images/heroes/scales-of-justice.jpg', position: 'center center' }, 12 'about': { image: 'images/heroes/georgia-capitol.jpg', label: 'ABOUT US', title: 'Who We Are', subtitle: 'Providing high-quality legal defense for individuals accused of crimes who cannot afford representation.', position: 'center 30%' }, 13 'team': { image: 'images/heroes/team-collaboration.jpg', label: 'ABOUT US', title: 'Our Team', subtitle: 'Meet the dedicated professionals working to ensure justice for all Georgians.', position: 'center center' }, 14 'divisions': { image: 'images/heroes/modern-office.jpg', label: 'ABOUT US', title: 'Divisions and Services', subtitle: 'Specialized divisions working together to provide comprehensive legal defense across Georgia.', position: 'center center' }, 15 'clients': { image: 'images/heroes/community-support.jpg', label: 'CLIENTS & FAMILIES', title: 'Clients & Families', subtitle: 'Re
15sources and information for those we serve and their loved ones.', position: 'center center' }, 16 'find-defender': { image: 'images/heroes/find-defender.jpg', label: 'CLIENTS & FAMILIES', title: 'Find Your Public Defender', subtitle: 'Locate the public defender office serving your county.', position: 'center center' }, 17 'apply': { image: 'images/heroes/community-support.jpg', label: 'CLIENTS & FAMILIES', title: 'Applying For Legal Representation', subtitle: 'Learn how to request a public defender if you cannot afford an attorney.', position: 'center center' }, 18 'legal-process': { image: 'images/heroes/scales-of-justice.jpg', label: 'CLIENTS & FAMILIES', title: 'The Legal Process', subtitle: 'Understanding the steps in a criminal case in Georgia.', position: 'center center' }, 19 'faq': { image: 'images/heroes/community-support.jpg', label: 'CLIENTS & FAMILIES', title: 'FAQs and Resources', subtitle: 'Answers to common questions about public defense in Georgia.', position: 'center center' }, 20 'careers': { image: 'images/heroes/careers.jpg', label: 'CAREERS', title: 'Join Our Team', subtitle: 'Build a meaningful career defending the constitutional rights of Georgians.', position: 'center center' }, 21 'positions': { image: 'images/heroes/careers.jpg', label: 'CAREERS', title: 'Available Positions', subtitle: 'Current job openings across Georgia\'s public defender offices.', position: 'center center' }, 22 'newsroom': { image: 'images/heroes/newsroom.jpg', label: 'NEWSROOM', title: 'News & Updates', subtitle: 'The latest news and announcements from GPDC.', position: 'center center' }, 23 'contact': { image: 'images/heroes/contact.jpg', label: 'CONTACT', title: 'Contact Us', subtitle: 'Get in touch with the Georgia Public Defender Council.', position: 'center center' }, 24 'division-default': { image: 'images/heroes/division-default.jpg', position: 'center center' }, 25 'circuit-default': { image: 'images/heroes/scales-of-justice.jpg', position: 'center center' }, 26 'article-default': { image: 'images/heroes/newsroom.jpg', position: 'center center' } 27 }; 28 29 // Active heroes object (will be overwritten by CMS data if available) 30 let heroes = fallbackHeroes; 31 32 // Determine base path based on current page location 33 function getBasePath() { 34 const path = window.location.pathname; 35 const depth = (path.match(/\//g) || []).length - 1; 36 37 // Division pages can be nested (e.g. /divisions/training/leadership-academy), 38 // so derive the number of "../" from the actual directory depth. 39 if (path.includes('/divisions/')) { 40 return '../'.repeat(Math.max(1, depth)); 41 } 42 43 // Other one-level-deep sections 44 if (path.includes('/local-offices/') || 45 path.includes('/articles/') || 46 path.includes('/circuits/') || 47 path.includes('/tools/')) { 48 return '../'; 49 } 50 return ''; 51 } 52 53 // Fetch a JSON data file with fallback 54 async function fetchData(basePath, dataPath) { 55 try { 56 // _headers already serves /_data/* as must-revalidate, so a plain URL 57 // gets a cheap 304 instead of a fresh download on every page view. 58 const response = await fetch(basePath + dataPath); 59 if (!response.ok) throw new Error('Data file not found'); 60 return await response.json(); 61 } catch (error) { 62 console.warn('Failed to load data file: ' + dataPath, error); 63 return null; 64 } 65 } 66 67 function setText(target, value) { 68 if (!target || typeof value !== 'string') return; 69 target.textContent = value; 70 } 71 72 function setHtml(target, value) { 73 if (!target || typeof value !== 'string') return; 74 target.innerHTML = value; 75 } 76 77 function setLabelText(target, value) { 78 if (!target || typeof value !== 'string') return; 79 var textNode = Array.prototype.find.call(target.childNodes, function(node) { 80 return node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0; 81 }); 82 if (textNode) { 83 textNode.textContent = value + ' '; 84 return; 85 } 86 target.insertBefore(document.createTextNode(value + ' '), target.firstChild); 87 } 88 89 function normalizeHeroConfig(entry) { 90 if (!entry) return null; 91 return entry.page_hero ? entry.page_hero : entry; 92 } 93 94 async function loadHeroesConfig(basePath) { 95 // New structure: one hero file per page under _data/heroes/*.json 96 const manifest = await fetchData(basePath, '_data/heroes/index.json'); 97 if (manifest && Array.isArray(manifest.items) && manifest.items.length > 0) { 98 const loaded = await Promise.all(manifest.items.map(async function(item) { 99 const data = await fetchData(basePath, '_data/heroes/' + item.file); 100 return { key: item.key, data: normalizeHeroConfig(data) }; 101 })); 102 103 const nextHeroes = {}; 104 loaded.forEach(function(entry) { 105 if (entry.data && entry.data.image) { 106 nextHeroes[entry.key] = entry.data; 107 } 108 }); 109 110 if (Object.keys(nextHeroes).length > 0) { 111 return nextHeroes; 112 } 113 } 114 115 // Legacy fallback: monolithic heroes object 116 const legacy = await fetchData(basePath, '_data/heroes.json'); 117 if (legacy) return legacy; 118 return null; 119 } 120 121 // Load a component and insert it into the page 122 async function loadComponent(elementId, componentPath, basePath) { 123 const element = document.getElementById(elementId); 124 if (!element) return; 125 126 try { 127 const response = await fetch(basePath + componentPath + '?v=8'); 128 if (!response.ok) throw new Error('Component not found'); 129 130 let html = await response.text(); 131 132 // Replace {{BASE}} placeholder with actual base path 133 html = html.replace(/\{\{BASE\}\}/g, basePath); 134 135 element.innerHTML = html; 136 137 // Dispatch event when component is loaded 138 element.dispatchEvent(new CustomEvent('componentLoaded', { 139 bubbles: true, 140 detail: { component: componentPath } 141 })); 142 } catch (error) { 143 console.warn(`Failed to load component: ${componentPath}`, error); 144 } 145 } 146 147 // The skip link has to exist before anything else focusable, and the header is 148 // injected, so it is prepended here rather than shipped in components/header.html. 149 function ensureSkipLink(headerEl) { 150 if (!headerEl || headerEl.querySelector('a.skip')) return; 151 152 const skip = document.createElement('a'); 153 skip.className = 'skip'; 154 skip.href = '#main'; 155 skip.textContent = 'Skip to content'; 156 headerEl.insertBefore(skip, headerEl.firstChild); 157 } 158 159 // Every template lays its sections out as direct <body> siblings between 160 // #site-header and the footer, so there is no existing element to promote to 161 // <main> â the landmark has to be built around them. The run is re-parented in 162 // one synchronous block so media elements are not paused (the spec only pauses 163 // a media element still detached at the next stable state). 164 function ensureMainLandmark() { 165 const existing = document.querySelector('main'); 166 if (existing) { 167 if (!existing.id) existing.id = 'main'; 168 if (!existing.hasAttribute('tabindex')) existing.setAttribute('tabindex', '-1'); 169 return; 170 } 171 172 const header = document.getElementById('site-header'); 173 const end = document.getElementById('site-connect') || document.getElementById('site-footer'); 174 if (!header || !end || header.parentNode !== end.parentNode) return; 175 176 const content = []; 177 for (let node = header.nextElementSibling; node && node !== end; node = node.nextElementSibling) { 178 content.push(node); 179 } 180 if (content.length === 0) return; 181 182 const main = document.createElement('main'); 183 main.id = 'main'; 184 main.setAttribute('tabindex', '-1'); 185 header.parentNode.insertBefore(main, header.nextSibling); 186 187 const frag = document.createDocumentFragment(); 188 content.forEach(function(node) { frag.appendChild(node); }); 189 main.appendChild(frag); 190 } 191 192 // Load hero section based on data-hero attribute 193 function loadHero() { 194 const heroElement = document.getElementById('page-hero'); 195 if (!heroElement) { 196 return; 197 } 198 199 const heroKey = heroElement.dataset.hero; 200 if (!heroKey) { 201 console.warn('Hero element found but no data-hero attribute'); 202 return; 203 } 204 205 if (!heroes[heroKey]) { 206 console.warn('Hero config not found for key:', heroKey); 207 return; 208 } 209 210 const config = heroes[heroKey]; 211 const basePath = getBasePath(); 212 213 // Resolve image path â prepend basePath for relative local paths 214 const imgSrc = (config.image.startsWith('http') || config.image.startsWith('/')) 215 ? config.image 216 : basePath + config.image; 217 218 // Build hero HTML 219 const html = ` 220 <div class="page-hero-bg"> 221 <img src="${imgSrc}" alt="${config.title || 'Hero image'}" style="object-position: ${config.position || 'center center'}"> 222 </div> 223 <div class="page-hero-content"> 224 ${config.label ? `<p class="label">${config.label}</p>` : ''} 225 ${config.title ? `<h1 class="page-hero-title">${config.title}</h1>` : ''} 226 ${config.subtitle ? `<p class="page-hero-subtitle">${config.subtitle}</p>` : ''} 227 </div> 228 `; 229 230 heroElement.innerHTML = html; 231 } 232 233 // Apply hero image position to existing hero sections (for pages n
233ot using component) 234 function applyHeroPosition() { 235 const heroImg = document.querySelector('.page-hero-bg img'); 236 if (!heroImg) return; 237 238 // Check for data-position attribute on the hero section or image 239 const heroSection = document.querySelector('.page-hero'); 240 const position = heroSection?.dataset.position || heroImg.dataset.position; 241 242 if (position) { 243 heroImg.style.objectPosition = position; 244 } 245 } 246 247 // Default placeholder image for members without photos 248 var PLACEHOLDER_IMG = 'https://cdn.prod.website-files.com/66c9595306b0d169d1677ecc/696679f045b5c1a68607201e_general-img-landscape.png'; 249 250 // Helper: escape text for HTML attributes 251 function escAttr(str) { 252 return str.replace(/'/g, "\\'").replace(/"/g, '"'); 253 } 254 255 // Map of leadership member id -> their public division page. 256 // Source of truth is _data/team-divisions.json, shared with the intranet 257 // hub so a director change only has to be made in one place. Falls back to 258 // an empty map if the fetch fails; cards then render without the link. 259 let teamDivisionLinks = {}; 260 261 262 function getTeamDivisionLink(member, basePath) { 263 const link = teamDivisionLinks[member.id]; 264 if (!link) return null; 265 return { 266 href: basePath + link.href, 267 label: link.label || 'View Division', 268 ariaLabel: link.ariaLabel || link.label || 'View Division' 269 }; 270 } 271 272 // Generate a single team card HTML 273 function renderTeamCard(member, divisionLink) { 274 var actionHtml = divisionLink ? 275 '<a class="btn btn-outline team-division-btn" href="' + divisionLink.href + '" aria-label="' + escAttr(divisionLink.ariaLabel) + '">' + 276 '<span>' + divisionLink.label + '</span>' + 277 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' + 278 '<path d="M5 12h14M12 5l7 7-7 7"/>' + 279 '</svg>' + 280 '</a>' : 281 ''; 282 283 return '<div class="team-card-new fade-in" data-person="' + member.id + '">' + 284 '<div class="team-image">' + 285 '<img loading="lazy" decoding="async" width="260" height="260" src="' + (member.photo || PLACEHOLDER_IMG) + '" alt="' + escAttr(member.name) + '">' + 286 '</div>' + 287 '<div class="team-info">' + 288 '<h3>' + member.name + '</h3>' + 289 '<p class="team-title">' + member.title + '</p>' + 290 actionHtml + 291 '</div>' + 292 '</div>'; 293 } 294 295 // Generate the featured executive card HTML 296 function renderFeaturedCard(member) { 297 return '<div class="featured-image">' + 298 '<img loading="lazy" decoding="async" width="300" height="300" src="' + (member.photo || PLACEHOLDER_IMG) + '" alt="' + escAttr(member.name) + '">' + 299 '</div>' + 300 '<div class="featured-content">' + 301 '<p class="label">' + member.title.toUpperCase() + '</p>' + 302 '<h2>' + member.name + '</h2>' + 303 '<p class="featured-bio">Leading the Georgia Public Defender Council with a commitment to ensuring quality legal representation for all Georgians. Focused on augmenting indigent defense services and promoting rehabilitative outcomes throughout the state.</p>' + 304 '<a href="assets/docs/Omotayo-Alli-Biography.pdf" class="btn btn-primary" style="width: auto; display: inline-flex;" download>' + 305 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' + 306 '<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>' + 307 '<polyline points="7 10 12 15 17 10"/>' + 308 '<line x1="12" y1="15" x2="12" y2="3"/>' + 309 '</svg>' + 310 '<span>Download Bio</span>' + 311 '</a>' + 312 '</div>'; 313 } 314 315 // Load leadership data and dynamically render team sections 316 async function loadLeadership(basePath) { 317 // Only run on team.html
318 const pagePath = window.location.pathname; 319 if (!pagePath.endsWith('/team.html') && !pagePath.endsWith('/team')) return; 320 321 const [data, divMap] = await Promise.all([ 322 fetchData(basePath, '_data/leadership.json'), 323 fetchData(basePath, '_data/team-divisions.json') 324 ]); 325 if (divMap && divMap.members) teamDivisionLinks = divMap.members; 326 if (!data || !data.members) return; 327 328 const members = data.members; 329 330 // Build teamImages lookup for the contact modal 331 var teamImagesMap = {}; 332 members.forEach(function(m) { 333 teamImagesMap[m.id] = m.photo; 334 }); 335 336 // Expose to global scope so the inline modal script can use it 337 window.teamImages = teamImagesMap; 338 339 // Split members by role (preserve JSON order within each group) 340 var executives = members.filter(function(m) { return m.role === 'Executive'; }); 341 var directors = members.filter(function(m) { return m.role !== 'Executive'; }); 342 343 // Render featured executive card (first executive in the list) 344 var featuredEl = document.getElementById('featuredExecutive'); 345 if (featuredEl && executives.length > 0) { 346 featuredEl.setAttribute('data-person', executives[0].id); 347 featuredEl.innerHTML = renderFeaturedCard(executives[0]); 348 } 349 350 // Render executives grid (all executives, in JSON order) 351 var execGrid = document.getElementById('executivesGrid'); 352 if (execGrid) { 353 execGrid.innerHTML = executives.map(function(member) { return renderTeamCard(member); }).join(''); 354 } 355 356 // Render directors/managers grid (in JSON order) 357 var dirGrid = document.getElementById('directorsGrid'); 358 if (dirGrid) { 359 dirGrid.innerHTML = directors.map(function(member) { 360 return renderTeamCard(member, getTeamDivisionLink(member, basePath)); 361 }).join(''); 362 } 363 364 // Trigger fade-in animations for dynamically added cards 365 requestAnimationFrame(function() { 366 document.querySelectorAll('.team-card-new.fade-in, .featured-card.fade-in').forEach(function(el) { 367 el.classList.add('visible'); 368 }); 369 }); 370 } 371 372 // Division slug â leadership member ID mapping (fallback only) 373 const divisionLeaderMap = { 374 'training': 'tbertram', 375 'appellate-defense': 'charrison', 376 'capital-defense': 'nstudelska', 377 'customer-support': 'jwalker', 378 'mental-health-defense': 'srhinehart', 379 'youth-defense': 'gmurray', 380 'conflict': 'aragas', 381 'administration': 'lonley' 382 }; 383 384 // Division slug â title keywords used to infer the current leader assignment. 385 // This allows CMS updates to propagate without changing hardcoded IDs. 386 const divisionTitleKeywords = { 387 'training': ['training', 'professional development'], 388 'appellate-defense': ['appellate'], 389 'capital-defense': ['capital'], 390 'customer-support': ['customer support', 'compliance', 'client support'], 391 'mental-health-defense': ['mental health'], 392 'youth-defense': ['youth'], 393 'conflict': ['conflict'], 394 'administration': ['administration'] 395 }; 396 397 // Load divisions data and update division images 398 // Cross-references leadership.json for director photos 399 async function loadDivisions(basePath) { 400 var pagePath = window.location.pathname; 401 var isDivisionsOverview = pagePath.endsWith('/divisions.html') || pagePath.endsWith('/divisions'); 402 var isDivisionPage = pagePath.includes('/divisions/'); 403 404 if (!isDivisionsOverview && !isDivisionPage) return; 405 406 // Fetch divisions, leadership, and editable division copy data in parallel 407 var [divData, leaderData, divisionContentData] = await Promise.all([ 408 fetchData(basePath, '_data/divisions.json'), 409 fetchData(basePath, '_data/leadership.json'), 410 fetchData(basePath, '_data/division-content.json') 411 ]); 412 413 if (!divData || !divData.divisions) return; 414 415 var divisions = divData.divisions; 416 417 // Build leadership lookup by ID 418 var leaderLookup = {}; 419 if (leaderData && leaderData.members) {
420 leaderData.members.forEach(function(m) { 421 leaderLookup[m.id] = m; 422 }); 423 } 424 425 function normalizeName(value) { 426 return (value || '') 427 .toLowerCase() 428 .replace(/[^a-z\s'-]/g, '') 429 .replace(/\s+/g, ' ') 430 .trim(); 431 } 432 433 function getDivisionLeaderByCardName(profileCard) { 434 if (!profileCard || !leaderData || !leaderData.members) return null; 435 var nameEl = profileCard.querySelector('.leader-profile-info h3') || 436 profileCard.querySelector('.pd-leader-info h3'); 437 if (!nameEl) return null; 438 439 var rendered = normalizeName(nameEl.textContent || ''); 440 if (!rendered) return null; 441 442 var exact = leaderData.members.find(function(member) { 443 return normalizeName(member.name) === rendered; 444 }); 445 if (exact) return exact; 446 447 var renderedLast = rendered.split(' ').pop(); 448 if (!renderedLast) return null; 449 450 return leaderData.members.find(function(member) { 451 var last = normalizeName(member.name).split(' ').pop(); 452 return last && last === renderedLast; 453 }) || null; 454 } 455 456 // Helper: find leader assignment for a division slug. 457 // 1) Prefer matching the currently rendered card name. 458 // 2) Then title keyword matching (dynamic and CMS-friendly). 459 // 3) Fall back to static ID map for legacy data. 460 function getDivisionLeader(slug, profileCard) { 461 var byCardName = getDivisionLeaderByCardName(profileCard); 462 if (byCardName) return byCardName; 463 464 var keywords = divisionTitleKeywords[slug] || []; 465 if (keywords.length > 0 && leaderData && leaderData.members) { 466 var byTitle = leaderData.members.find(function(member) { 467 var title = (member.title || '').toLowerCase(); 468 return keywords.some(function(keyword) { 469 return title.indexOf(keyword) !== -1; 470 }); 471 }); 472 if (byTitle) return byTitle; 473 } 474 475 var leaderId = divisionLeaderMap[slug]; 476 if (leaderId && leaderLookup[leaderId]) { 477 return leaderLookup[leaderId]; 478 } 479 return null; 480 } 481 482 if (isDivisionsOverview) { 483 // Optional editable section copy for the overview page 484 if (divisionContentData && divisionContentData.overview) { 485 var overviewHeader = document.querySelector('.content-section .section-header'); 486 if (overviewHeader) { 487 if (divisionContentData.overview.section_label) { 488 var overviewLabel = overviewHeader.querySelector('.label'); 489 if (overviewLabel) overviewLabel.textContent = divisionContentData.overview.section_label; 490 } 491 if (divisionContentData.overview.title) { 492 var overviewTitle = overviewHeader.querySelector('.section-title'); 493 if (overviewTitle) overviewTitle.textContent = divisionContentData.overview.title; 494 } 495 if (divisionContentData.overview.subtitle) { 496 var overviewSubtitle = overviewHeader.querySelector('.section-subtitle'); 497 if (overviewSubtitle) overviewSubtitle.textContent = divisionContentData.overview.subtitle; 498 } 499 } 500 } 501 502 // Update division cards on the overview page 503 divisions.forEach(function(div) { 504 var card = document.querySelector('[data-division="' + div.slug + '"]'); 505 if (!card) return; 506 507 // Update header image 508 var headerImg = card.querySelector('.division-card-image img'); 509 if (headerImg) { 510 headerImg.src = div.header_image; 511 } 512 513 // Update director photo from leadership data 514 var directorImg = card.querySelector('.division-card-leader img'); 515 if (directorImg) { 516 var leader = getDivisionLeader(div.slug, null); 517 if (leader && leader.photo) { 518 directorImg.src = leader.photo; 519 } 520 } 521 522 // Optional editable card copy 523 if (divisionContentData && divisionContentData.overview && divisionContentData.overview.cards) { 524 var cardCopy = divisionContentData.overview.cards.find(function(c) { return c.slug === div.slug; }); 525 if (cardCopy) { 526 var contentWrap = card.querySelector('.division-card-content'); 527 if (contentWrap) { 528 if (cardCopy.label) { 529 var labelEl = contentWrap.querySelector('.label'); 530 if (labelEl) labelEl.textContent = cardCopy.label; 531 } 532 if (cardCopy.title) { 533 var titleEl = contentWrap.querySelector('h3'); 534 if (titleEl) titleEl.textContent = cardCopy.title; 535 } 536 if (cardCopy.description) { 537 var descEl = contentWrap.querySelector('p:not(.label)'); 538 if (descEl) descEl.textContent = cardCopy.description; 539 } 540 } 541 } 542 } 543 }); 544 } 545 546 if (isDivisionPage) { 547 // Extract the division slug from the URL 548 var slug = pagePath.split('/divisions/')[1].replace('.html', '').replace(/\/$/, ''); 549 var divMatch = divisions.find(function(d) { return d.slug === slug; }); 550 if (!divMatch) return; 551 552 // Update leader profile image (supports both standard and training page layouts) 553 var profileCard = document.querySelector('[data-division="' + slug + '"]'); 554 if (profileCard) { 555 var leader = getDivisionLeader(slug, profileCard); 556 if (!leader) return; 557 558 var profileImg = profileCard.querySelector('.leader-profile-image img') || 559 profileCard.querySelector('.pd-leader-image img'); 560 if (profileImg && leader.photo) { 561 profileImg.src = leader.photo; 562 profileImg.alt = leader.name || profileImg.alt; 563 } 564 565 var profileName = profileCard.querySelector('.leader-profile-info h3') || 566 profileCard.querySelector('.pd-leader-info h3'); 567 if (profileName && leader.name) { 568 profileName.textContent = leader.name; 569 } 570 571 var profileTitle = profileCard.querySelector('.leader-title'); 572 if (profileTitle && leader.title) { 573 profileTitle.textContent = leader.title; 574 } 575 576 // Optional editable body/section copy for standard division pages 577 if (divisionContentData && divisionContentData.pages) { 578 var pageCopy = divisionContentData.pages.find(function(p) { return p.slug === slug; }); 579 if (pageCopy) { 580 if (pageCopy.hero_description) { 581 var heroDesc = document.querySelector('.division-hero .hero-description'); 582 if (heroDesc) heroDesc.textContent = pageCopy.hero_description; 583 } 584 585 var aboutSectionHeader = document.querySelector('.division-about .section-header'); 586 if (aboutSectionHeader) { 587 if (pageCopy.about_section_label) { 588 var aboutLabel = aboutSectionHeader.querySelector('.label'); 589 if (aboutLabel) aboutLabel.textContent = pageCopy.about_section_label; 590 } 591 if (pageCopy.about_heading) { 592 var aboutHeading = aboutSectionHeader.querySelector('.section-title'); 593 if (aboutHeading) aboutHeading.textContent = pageCopy.about_heading; 594 } 595 } 596 597 var aboutBlocks = document.querySelectorAll('.division-about .division-about-text'); 598 if (aboutBlocks.length >= 1) { 599 if (pageCopy.mission_title) { 600 var missionTitleEl = aboutBlocks[0].querySelector('h3'); 601 if (missionTitleEl) missionTitleEl.textContent = pageCopy.mission_title; 602 } 603 if (pageCopy.mission_body) { 604 var missionBodyEl = aboutBlocks[0].querySelector('p'); 605 if (missionBodyEl) missionBodyEl.textContent = pageCopy.mission_body; 606 } 607 } 608 if (aboutBlocks.length >= 2) { 609 if (pageCopy.expertise_title) { 610 var expertiseTitleEl = aboutBlocks[1].querySelector('h3'); 611 if (expertiseTitleEl) expertiseTitleEl.textContent = pageCopy.expertise_title; 612 } 613 if (pageCopy.expertise_body) { 614 var expertiseBodyEl = aboutBlocks[1].querySelector('p'); 615 if (expertiseBodyEl) expertiseBodyEl.textContent = pageCopy.expertise_body; 616 } 617 } 618 619 var servicesHeader = document.querySelector('.division-services .section-header'); 620 if (servicesHeader) { 621 if (pageCopy.services_section_label) { 622 var servicesLabel = servicesHeader.querySelector('.label'); 623 if (servicesLabel) servicesLabel.textContent = pageCopy.services_section_label; 624 } 625 if (pageCopy.services_heading) { 626 var servicesHeading = servicesHeader.querySelector('.section-title'); 627 if (servicesHeading) servicesHeading.textContent = pageCopy.services_heading; 628 } 629 if (pageCopy.services_subtitle) { 630 var servicesSubtitle = servicesHeader.querySelector('.section-subtitle'); 631 if (servicesSubtitle) servicesSubtitle.textContent = pageCopy.services_subtitle; 632 } 633 } 634 635 if (Array.isArray(pageCopy.services)) { 636 var serviceCards = document.querySelectorAll('.division-services .service-card-modern');
637 pageCopy.services.forEach(function(service, idx) { 638 if (!serviceCards[idx]) return; 639 if (service.title) { 640 var sTitle = serviceCards[idx].querySelector('h3'); 641 if (sTitle) sTitle.textContent = service.title; 642 } 643 if (service.description) { 644 var sDesc = serviceCards[idx].querySelector('p'); 645 if (sDesc) sDesc.textContent = service.description; 646 } 647 }); 648 } 649 } 650 } 651 } 652 } 653 } 654 655 // Only these pages read CMS copy below. Keyed by filename so every other page 656 // can skip the manifest instead of pulling all seven content files for nothing. 657 var PAGE_CONTENT_KEYS = { 658 'about.html': 'about', 659 'clients.html': 'clients', 660 'apply.html': 'apply', 661 'legal-process.html': 'legal_process', 662 'faq.html': 'faq', 663 'contact.html': 'contact', 664 'careers.html': 'careers' 665 }; 666 667 async function loadPageContent(basePath) { 668 var pagePath = window.location.pathname.split('/').pop() || 'index.html'; 669 var pageKey = PAGE_CONTENT_KEYS[pagePath]; 670 if (!pageKey) return; 671 672 var pages = null; 673 674 // Preferred structure: one file per page under _data/page-content/*.json 675 var manifest = await fetchData(basePath, '_data/page-content/index.json'); 676 var item = manifest && Array.isArray(manifest.items) 677 ? manifest.items.filter(function(candidate) { return candidate.key === pageKey; })[0] 678 : null; 679 if (item) { 680 var entry = await fetchData(basePath, '_data/page-content/' + item.file); 681 if (entry) { 682 pages = {}; 683 pages[pageKey] = entry; 684 } 685 } 686 687 // Backward compatibility: legacy monolithic file 688 if (!pages) { 689 var legacyContentData = await fetchData(basePath, '_data/page-content.json'); 690 if (legacyContentData && legacyContentData.pages) { 691 pages = legacyContentData.pages; 692 } 693 } 694 695 if (!pages) return; 696 697 if (pagePath === 'about.html' && pages.about) { 698 var about = pages.about; 699 setText(document.querySelector('.ds-hero__label'), about.hero_label); 700 setHtml(document.querySelector('.ds-hero__title'), about.hero_title_html); 701 setText(document.querySelector('.ds-hero__subtitle'), about.hero_subtitle); 702 setText(document.querySelector('.content-section .content-text .label'), about.mission_label); 703 setHtml(document.querySelector('.content-section .content-text .section-title'), about.mission_title_html); 704 705 var missionParagraphs = document.querySelectorAll('.content-section .content-text .content-paragraph'); 706 if (Array.isArray(about.mission_paragraphs)) { 707 about.mission_paragraphs.forEach(function(p, idx) { 708 setText(missionParagraphs[idx], p); 709 }); 710 } 711 712 setText(document.querySelector('#principles .section-header .label'), about.values_label); 713 setText(document.querySelector('#principles .section-header .section-title'), about.values_title); 714 if (Array.isArray(about.values_cards)) { 715 var aboutValueCards = document.querySelectorAll('#principles .values-card'); 716 about.values_cards.forEach(function(card, idx) { 717 var cardEl = aboutValueCards[idx]; 718 if (!cardEl) return; 719 setText(cardEl.querySelector('.values-card__title'), card.title); 720 setText(cardEl.querySelector('.values-card__desc'), card.description); 721 }); 722 } 723 } 724 725 if (pagePath === 'clients.html' && pages.clients) { 726 var clients = pages.clients; 727 setText(document.querySelector('.ds-hero__label'), clients.hero_label); 728 setHtml(document.querySelector('.ds-hero__title'), clients.hero_title_html); 729 setText(document.querySelector('.ds-hero__subtitle'), clients.hero_subtitle); 730 731 /* The lead hero carries whichever installment of the series is 732 current, so its link has to move with it. Without this the copy 733 would update from the CMS each week while the button kept 734 pointing at the first article â the worst of both, because it 735 would look right. Both fields are optional; absent, the markup's 736 own href and label stand. */ 737 var heroCta = document.querySelector('[data-hero-cta]'); 738 if (heroCta && typeof clients.hero_cta_href === 'string' && clients.hero_cta_href) { 739 heroCta.setAttribute('href', clients.hero_cta_href); 740 } 741 setText(document.querySelector('[data-hero-cta-label]'), clients.hero_cta_label); 742 setText(document.querySelector('.cf-journey .section-header .label'), clients.journey_label); 743 setText(document.querySelector('.cf-journey .section-header .section-title'), clients.journey_title); 744 745 if (Array.isArray(clients.journey_cards)) { 746 var journeyCards = document.querySelectorAll('.cf-journey-gri
746d .cf-tile'); 747 clients.journey_cards.forEach(function(card, idx) { 748 var cardEl = journeyCards[idx]; 749 if (!cardEl) return; 750 setText(cardEl.querySelector('h3'), card.title); 751 setText(cardEl.querySelector('p'), card.description); 752 setLabelText(cardEl.querySelector('.link-arrow'), card.link_label); 753 }); 754 } 755 756 setText(document.querySelector('.content-section-alt .section-header .label'), clients.family_support_label); 757 setText(document.querySelector('.content-section-alt .section-header .section-title'), clients.family_support_title); 758 if (Array.isArray(clients.family_support_cards)) { 759 var supportCards = document.querySelectorAll('.content-section-alt .values-card'); 760 clients.family_support_cards.forEach(function(card, idx) { 761 var cardEl = supportCards[idx]; 762 if (!cardEl) return; 763 setText(cardEl.querySelector('.values-card__title'), card.title); 764 setText(cardEl.querySelector('.values-card__desc'), card.description); 765 }); 766 } 767 } 768 769 if (pagePath === 'apply.html' && pages.apply) { 770 var apply = pages.apply; 771 setText(document.querySelector('.apply-intro h2'), apply.intro_title); 772 setText(document.querySelector('.apply-intro p'), apply.intro_body); 773 774 if (Array.isArray(apply.steps)) { 775 var applySteps = document.querySelectorAll('.apply-steps .apply-step'); 776 apply.steps.forEach(function(step, idx) { 777 var stepEl = applySteps[idx]; 778 if (!stepEl) return; 779 setText(stepEl.querySelector('.step-content h3'), step.title); 780 setText(stepEl.querySelector('.step-content p'), step.description); 781 }); 782 } 783 784 setText(document.querySelector('.apply-note h4'), apply.note_title); 785 setText(document.querySelector('.apply-note p'), apply.note_body); 786 setText(document.querySelector('.apply-cta h3'), apply.cta_title); 787 setText(document.querySelector('.apply-cta p'), apply.cta_body); 788 setText(document.querySelector('.apply-cta .btn span'), apply.cta_button_label); 789 } 790 791 if (pagePath === 'legal-process.html' && pages.legal_process) { 792 var process = pages.legal_process; 793 setText(document.querySelector('.process-intro h2'), process.intro_title); 794 setText(document.querySelector('.process-intro p'), process.intro_body); 795 if (Array.isArray(process.steps)) { 796 var processSteps = document.querySelectorAll('.process-timeline .process-step'); 797 process.steps.forEach(function(step, idx) { 798 var stepEl = processSteps[idx]; 799 if (!stepEl) return; 800 setText(stepEl.querySelector('h3'), step.title); 801 setText(stepEl.querySelector('p'), step.description); 802 }); 803 } 804 } 805 806 if (pagePath === 'faq.html' && pages.faq) { 807 var faq = pages.faq; 808 if (Array.isArray(faq.items)) { 809 var faqItems = document.querySelectorAll('.faq-section .faq-item'); 810 faq.items.forEach(function(item, idx) { 811 var itemEl = faqItems[idx]; 812 if (!itemEl) return; 813 setText(itemEl.querySelector('.faq-question span'), item.question); 814 setHtml(itemEl.querySelector('.faq-answer p'), item.answer_html); 815 }); 816 } 817 setText(document.querySelector('.cta-card h2'), faq.cta_title); 818 setText(document.querySelector('.cta-card > p'), faq.cta_body); 819 setText(document.querySelector('.cta-contact-item:first-child span'), faq.cta_phone); 820 setText(document.querySelector('.cta-contact-item:last-child span'), faq.cta_email); 821 setText(document.querySelector('.cta-card .btn span'), faq.cta_button_label); 822 } 823
824 if (pagePath === 'contact.html' && pages.contact) { 825 var contact = pages.contact; 826 setText(document.querySelector('.contact-hero-hq-info .label'), contact.headquarters_label); 827 setText(document.querySelector('.contact-hero-hq-info h2'), contact.headquarters_city); 828 setText(document.querySelector('.contact-hero-hq-detail:nth-child(1) span'), contact.headquarters_address); 829 setText(document.querySelector('.contact-hero-hq-detail:nth-child(2) a'), contact.headquarters_phone); 830 setText(document.querySelector('.contact-hero-hq-detail:nth-child(3) span'), contact.headquarters_hours); 831 setText(document.querySelector('.contact-hq-banner-cta-title'), contact.office_cta_title); 832 setText(document.querySelector('.contact-hq-banner-cta-sub'), contact.office_cta_subtitle); 833 setText(document.querySelector('.contact-cta-label'), contact.service_label); 834 setText(document.querySelector('.contact-cta-phone'), contact.service_phone); 835 setText(document.querySelector('.contact-cta-hours'), contact.service_hours); 836 setText(document.querySelector('.contact-cta-actions .btn span'), contact.service_button_label); 837 setText(document.querySelector('.contact-cta-email'), contact.service_email_label); 838 839 if (Array.isArray(contact.contact_rows)) { 840 var rows = document.querySelectorAll('.contact-list .contact-list-row'); 841 contact.contact_rows.forEach(function(row, idx) { 842 var rowEl = rows[idx]; 843 if (!rowEl) return; 844 setText(rowEl.querySelector('h4'), row.title); 845 setText(rowEl.querySelector('p'), row.description); 846 }); 847 } 848 849 setText(document.querySelector('.contact-faq-header .label'), contact.faq_label); 850 setText(document.querySelector('.contact-faq-header h3'), contact.faq_title); 851 if (Array.isArray(contact.faq_items)) { 852 var contactFaqItems = document.querySelectorAll('.contact-faq-list .faq-item'); 853 contact.faq_items.forEach(function(item, idx) { 854 var itemEl = contactFaqItems[idx]; 855 if (!itemEl) return; 856 setText(itemEl.querySelector('.faq-question span'), item.question); 857 setHtml(itemEl.querySelector('.faq-answer p'), item.answer_html); 858 }); 859 } 860 setText(document.querySelector('.contact-faq-more .btn span'), contact.faq_button_label); 861 } 862 863 if (pagePath === 'careers.html' && pages.careers) { 864 var careers = pages.careers; 865 setText(document.querySelector('.ds-hero__label'), careers.hero_label); 866 setHtml(document.querySelector('.ds-hero__title'), careers.hero_title_html); 867 setText(document.querySelector('.ds-hero__subtitle'), careers.hero_subtitle); 868 setText(document.querySelector('.ds-hero__actions .btn.btn-primary span'), careers.hero_primary_button_label); 869 setText(document.querySelector('.ds-hero__actions .hero-fmpd-btn'), careers.hero_secondary_button_label); 870 871 setText(document.querySelector('.content-section-alt .section-header .label'), careers.benefits_label); 872 setText(document.querySelector('.content-section-alt .section-header .section-title'), careers.benefits_title); 873 if (Array.isArray(careers.benefits_cards)) { 874 var benefitCards = document.querySelectorAll('.values-grid--3col .values-card'); 875 careers.benefits_cards.forEach(function(card, idx) { 876 var cardEl = benefitCards[idx]; 877 if (!cardEl) return; 878 setText(cardEl.querySelector('.values-card__title'), card.title); 879 setText(cardEl.querySelector('.values-card__desc'), card.description); 880 }); 881 } 882 883 setText(document.querySelector('.cr-paths .section-header .label'), careers.career_paths_label); 884 setText(document.querySelector('.cr-paths .section-header .section-title'), careers.career_paths_title); 885 if (Array.isArray(careers.career_paths)) { 886 var pathCards = document.querySelectorAll('.cr-paths-grid .cr-path-card'); 887 careers.career_paths.forEach(function(path, idx) { 888 var pathEl = pathCards[idx]; 889 if (!pathEl) return; 890 setText(pathEl.querySelector('h3'), path.title); 891 setText(pathEl.querySelector('p'), path.description); 892 setLabelText(pathEl.querySelector('.link-arrow'), path.link_label); 893 }); 894 } 895 } 896 } 897 898 async function loadDivisionSpecialContent(basePath) {
899 var pagePath = window.location.pathname; 900 var key = null; 901 if (pagePath.endsWith('/divisions/training.html') || pagePath.endsWith('/divisions/training')) { 902 key = 'training'; 903 } else if (pagePath.endsWith('/divisions/social-services.html') || pagePath.endsWith('/divisions/social-services')) { 904 key = 'social-services'; 905 } 906 907 if (!key) return; 908 909 var manifest = await fetchData(basePath, '_data/division-special-content/index.json'); 910 if (!manifest || !Array.isArray(manifest.items)) return; 911 var item = manifest.items.find(function(it) { return it.key === key; }); 912 if (!item || !item.file) return; 913 914 var data = await fetchData(basePath, '_data/division-special-content/' + item.file); 915 if (!data) return; 916 917 if (key === 'training') { 918 if (data.hero) { 919 setText(document.querySelector('.pd-hero-content .label'), data.hero.label); 920 setText(document.querySelector('.pd-hero-content h1'), data.hero.title); 921 setText(document.querySelector('.pd-hero-content .hero-description'), data.hero.description); 922 setText(document.querySelector('.pd-hero-actions .btn.btn-primary span'), data.hero.primary_button); 923 setText(document.querySelector('.pd-hero-actions .btn.btn-outline span'), data.hero.secondary_button); 924 } 925 926 if (Array.isArray(data.stats)) { 927 var stats = document.querySelectorAll('.pd-stats .pd-stat');
928 data.stats.forEach(function(stat, idx) { 929 var statEl = stats[idx]; 930 if (!statEl) return; 931 setText(statEl.querySelector('.number'), stat.number); 932 setText(statEl.querySelector('.label'), stat.label); 933 }); 934 } 935 936 if (data.benefits) { 937 setText(document.querySelector('.pd-benefits-section .section-header .label'), data.benefits.label); 938 setText(document.querySelector('.pd-benefits-section .section-header .section-title'), data.benefits.title); 939 if (Array.isArray(data.benefits.cards)) { 940 var benefitCards = document.querySelectorAll('.pd-benefits-section .fade-in.fade-in-delay-1 > div'); 941 data.benefits.cards.forEach(function(card, idx) { 942 var cardEl = benefitCards[idx]; 943 if (!cardEl) return; 944 setText(cardEl.querySelector('h3'), card.title); 945 setText(cardEl.querySelector('p'), card.description); 946 }); 947 } 948 } 949 950 if (data.events) { 951 setText(document.querySelector('#events .section-header .label'), data.events.label); 952 setText(document.querySelector('#events .section-header .section-title'), data.events.title); 953 setText(document.querySelector('#events .section-header .section-subtitle'), data.events.subtitle); 954 } 955 956 if (data.programs) { 957 setText(document.querySelector('#programs .section-header .label'), data.programs.label); 958 setText(document.querySelector('#programs .section-header .section-title'), data.programs.title); 959 setText(document.querySelector('#programs .section-header .section-subtitle'), data.programs.subtitle); 960 if (Array.isArray(data.programs.cards)) { 961 var programCards = document.querySelectorAll('#programs .program-card'); 962 data.programs.cards.forEach(function(card, idx) { 963 var cardEl = programCards[idx]; 964 if (!cardEl) return; 965 setText(cardEl.querySelector('h3'), card.title); 966 setText(cardEl.querySelector('.program-card-body > p'), card.description); 967 if (Array.isArray(card.features)) { 968 var features = cardEl.querySelectorAll('.program-features li'); 969 card.features.forEach(function(feature, fIdx) { 970 var featureEl = features[fIdx]; 971 if (!featureEl) return; 972 var textNode = Array.prototype.find.call(featureEl.childNodes, function(node) { 973 return node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0; 974 }); 975 if (textNode) { 976 textNode.textContent = ' ' + feature; 977 } else { 978 featureEl.appendChild(document.createTextNode(' ' + feature)); 979 } 980 }); 981 } 982 }); 983 } 984 } 985 } 986 987 if (key === 'social-services') { 988 if (data.hero) { 989 setText(document.querySelector('.division-hero-content .label'), data.hero.label); 990 setText(document.querySelector('.division-hero-content h1'), data.hero.title); 991 setText(document.querySelector('.division-hero-content .hero-description'), data.hero.description); 992 setText(document.querySelector('.division-hero-actions .btn.btn-primary span'), data.hero.primary_button); 993 setText(document.querySelector('.division-hero-actions .btn.btn-outline span'), data.hero.secondary_button); 994 } 995 996 if (data.crisis_banner) { 997 setText(document.querySelector('.crisis-banner-content h3'), data.crisis_banner.title); 998 setText(document.querySelector('.crisis-banner-content p'), data.crisis_banner.description); 999 if (Array.isArray(data.crisis_banner.links)) { 1000 var crisisLinks = document.querySelectorAll('.crisis-banner-links .crisis-banner-link'); 1001 data.crisis_banner.links.forEach(function(link, idx) { 1002 var linkEl = crisisLinks[idx]; 1003 if (!linkEl) return; 1004 setText(linkEl.querySelector('.crisis-banner-number'), link.number); 1005 setText(linkEl.querySelector('.crisis-banner-name'), link.name); 1006 }); 1007 } 1008 } 1009 1010 if (Array.isArray(data.managers)) { 1011 var managers = document.querySelectorAll('.managers-bar .manager-card');
1012 data.managers.forEach(function(manager, idx) { 1013 var card = managers[idx]; 1014 if (!card) return; 1015 setText(card.querySelector('.manager-info h3'), manager.name); 1016 setText(card.querySelector('.manager-role'), manager.role); 1017 setText(card.querySelector('.manager-program'), manager.program); 1018 }); 1019 } 1020 1021 if (data.overview) { 1022 setText(document.querySelector('.wwd-hero-label'), data.overview.label); 1023 setText(document.querySelector('.wwd-hero-text h2'), data.overview.title); 1024 setText(document.querySelector('.wwd-hero-text p'), data.overview.description); 1025 setText(document.querySelector('.wwd-services-label'), data.overview.support_label); 1026 setText(document.querySelector('.wwd-services-header h3'), data.overview.support_title); 1027 if (Array.isArray(data.overview.support_items)) { 1028 var supportItems = document.querySelectorAll('.wwd-services-list li'); 1029 data.overview.support_items.forEach(function(item, idx) { 1030 var itemEl = supportItems[idx]; 1031 if (!itemEl) return; 1032 var dot = itemEl.querySelector('.wwd-list-dot'); 1033 itemEl.textContent = item; 1034 if (dot) itemEl.prepend(dot); 1035 }); 1036 } 1037 } 1038 1039 if (data.program_basics) { 1040 setText(document.querySelector('.program-basics-top .label'), data.program_basics.label); 1041 setText(document.querySelector('.program-basics-top h2'), data.program_basics.title); 1042 setText(document.querySelector('.program-basics-top p:not(.label)'), data.program_basics.description); 1043 setText(document.querySelector('.program-basics-quiz h3'), data.program_basics.quiz_title); 1044 setText(document.querySelector('.program-basics-quiz p'), data.program_basics.quiz_description); 1045 setLabelText(document.querySelector('.quiz-start-btn'), data.program_basics.quiz_button); 1046 if (Array.isArray(data.program_basics.cards)) { 1047 var basicCards = document.querySelectorAll('.program-basics-grid .program-basics-card'); 1048 data.program_basics.cards.forEach(function(card, idx) { 1049 var cardEl = basicCards[idx]; 1050 if (!cardEl) return; 1051 setText(cardEl.querySelector('h3'), card.title); 1052 setText(cardEl.querySelector('p'), card.description); 1053 }); 1054 } 1055 } 1056 1057 if (data.css_experience) { 1058 setText(document.querySelector('#css-hero .css-exp-hero-badge'), data.css_experience.hero_badge); 1059 setHtml(document.querySelector('#css-hero h2'), data.css_experience.hero_title_html); 1060 setText(document.querySelector('#css-hero .css-exp-hero-sub'), data.css_experience.hero_subtitle); 1061 setText(document.querySelector('#css-services .css-exp-section-label'), data.css_experience.services_label); 1062 setText(document.querySelector('#css-services .css-what-we-do h3'), data.css_experience.services_title); 1063 if (Array.isArray(data.css_experience.services_intro)) { 1064 var introPs = document.querySelectorAll('#css-services .css-what-we-do p'); 1065 data.css_experience.services_intro.forEach(function(p, idx) { 1066 setText(introPs[idx], p); 1067 }); 1068 } 1069 if (Array.isArray(data.css_experience.services_cards)) { 1070 var serviceCards = document.querySelectorAll('#css-services .css-expand-card'); 1071 data.css_experience.services_cards.forEach(function(card, idx) { 1072 var cardEl = serviceCards[idx]; 1073 if (!cardEl) return; 1074 setText(cardEl.querySelector('.css-expand-card-body h4'), card.title); 1075 setText(cardEl.querySelector('.css-expand-card-body p'), card.summary); 1076 setText(cardEl.querySelector('.css-expand-detail p'), card.detail); 1077 }); 1078 } 1079 setText(document.querySelector('#css-process .css-exp-section-label'), data.css_experience.process_label); 1080 setText(document.querySelector('#css-process .css-process-section h3'), data.css_experience.process_title); 1081 if (Array.isArray(data.css_experience.process_steps)) { 1082 var processSteps = document.querySelectorAll('#css-process .css-scroll-timeline-step'); 1083 data.css_experience.process_steps.forEach(function(step, idx) { 1084 var stepEl = processSteps[idx]; 1085 if (!stepEl) return; 1086 setText(stepEl.querySelector('h4'), step.title); 1087 setText(stepEl.querySelector('p'), step.description); 1088 }); 1089 } 1090 setText(document.querySelector('#css-contact .css-exp-section-label'), data.css_experience.contact_label); 1091 setHtml(document.querySelector('#css-contact h3'), data.css_experience.contact_title_html); 1092 setText(document.querySelector('#css-contact p'), data.css_experience.contact_description); 1093 setText(document.querySelector('#css-contact a'), data.css_experience.contact_email); 1094 } 1095 1096 if (data.ladders_experience) { 1097 setText(document.querySelector('#ld-hero .ld-hero-badge'), data.ladders_experience.hero_badge); 1098 setHtml(document.querySelector('#ld-hero h2'), data.ladders_experience.hero_title_html); 1099 setText(document.querySelector('#ld-hero .ld-hero-sub'), data.ladders_experience.hero_subtitle); 1100 setLabelText(document.querySelector('#ld-hero .ld-hero-cta'), data.ladders_experience.hero_button); 1101 setText(document.querySelector('#ld-pillars .ld-section-label'), data.ladders_experience.pillars_label); 1102 setText(document.querySelector('#ld-pillars h3'), data.ladders_experience.pillars_title); 1103 setText(document.querySelector('#ld-pillars > p'), data.ladders_experience.pillars_description); 1104 if (Array.isArray(data.ladders_experience.pillars)) { 1105 var pillars = document.querySelectorAll('#ld-pillars .ld-
1105pillar-card'); 1106 data.ladders_experience.pillars.forEach(function(pillar, idx) { 1107 var pillarEl = pillars[idx]; 1108 if (!pillarEl) return; 1109 setText(pillarEl.querySelector('h4'), pillar.title); 1110 setText(pillarEl.querySelector('p'), pillar.description); 1111 }); 1112 } 1113 setText(document.querySelector('#ld-quiz .ld-section-label'), data.ladders_experience.quiz_label); 1114 setText(document.querySelector('#ld-quiz .ld-quiz-header h3'), data.ladders_experience.quiz_title); 1115 setText(document.querySelector('#ld-quiz .ld-quiz-header p'), data.ladders_experience.quiz_description); 1116 setText(document.querySelector('#ld-journey .ld-section-label'), data.ladders_experience.journey_label); 1117 setText(document.querySelector('#ld-journey h3'), data.ladders_experience.journey_title); 1118 setText(document.querySelector('#ld-journey > p'), data.ladders_experience.journey_description); 1119 if (Array.isArray(data.ladders_experience.journey_steps)) { 1120 var journeySteps = document.querySelectorAll('#ld-journey .ld-timeline-step'); 1121 data.ladders_experience.journey_steps.forEach(function(step, idx) { 1122 var stepEl = journeySteps[idx]; 1123 if (!stepEl) return; 1124 setText(stepEl.querySelector('.ld-timeline-month'), step.month); 1125 setText(stepEl.querySelector('.ld-timeline-title'), step.title); 1126 setText(stepEl.querySelector('.ld-timeline-desc'), step.description); 1127 }); 1128 } 1129 setText(document.querySelector('#ld-testimonial blockquote'), data.ladders_experience.testimonial_quote); 1130 setText(document.querySelector('#ld-testimonial cite span:last-child'), data.ladders_experience.testimonial_name); 1131 setHtml(document.querySelector('#ld-apply h3'), data.ladders_experience.apply_title_html); 1132 setText(document.querySelector('#ld-apply p'), data.ladders_experience.apply_description); 1133 setLabelText(document.querySelector('#ld-apply .ld-btn-primary'), data.ladders_experience.apply_primary_button); 1134 setLabelText(document.querySelector('#ld-apply .ld-btn-secondary'), data.ladders_experience.apply_secondary_button); 1135 } 1136 } 1137 } 1138 1139 // Initialize components when DOM is ready 1140 async function init() { 1141 const basePath = getBasePath(); 1142 1143 // Inject Connect With Us section before footer (skip on contact page) 1144 const footerEl = document.getElementById('site-footer'); 1145 const isContactPage = window.location.pathname.includes('contact'); 1146 if (footerEl && !document.getElementById('site-connect') && !isContactPage) { 1147 const connectEl = document.createElement('div'); 1148 connectEl.id = 'site-connect'; 1149 footerEl.parentNode.insertBefore(connectEl, footerEl); 1150 } 1151 1152 // Homepage hero command bar (mobile-only inline search) 1153 initNavCommandSearch(document.getElementById('heroCommandBar')); 1154 1155 // Start shared component fetches immediately so the shell does not 1156 // wait on hero/CMS requests before the nav can render. 1157 const headerPromise = loadComponent('site-header', 'components/header.html', basePath); 1158 const connectPromise = loadComponent('site-connect', 'components/connect.html', basePath); 1159 const footerPromise = loadComponent('site-footer', 'components/footer.html', basePath); 1160 1161 headerPromise.then(() => { 1162 ensureSkipLink(document.getElementById('site-header')); 1163 ensureMainLandmark(); 1164 initNavigation(); 1165 decodeEmails(); 1166 // Needs the nav in the DOM before it can reveal the dot. 1167 initJobAlerts(basePath); 1168 }); 1169 1170 Promise.all([connectPromise, footerPromise]).then(() => { 1171 decodeEmails(); 1172 }); 1173 1174 // The hero config is a manifest plus one file per page; only pages that 1175 // actually render a hero from it need to pay for those requests. 1176 if (document.getElementById('page-hero')) { 1177 const heroData = await loadHeroesConfig(basePath); 1178 if (heroData) { 1179 heroes = heroData; 1180 } 1181 } 1182 1183 // Load hero component (if using data-hero attribute) 1184 loadHero(); 1185 1186 // Apply hero position (for pages with inline hero) 1187 applyHeroPosition(); 1188 1189 // Load CMS-managed data (runs in parallel, non-blocking) 1190 loadLeadership(basePath); 1191 loadDivisions(basePath); 1192 loadPageContent(basePath); 1193 loadDivisionSpecialContent(basePath); 1194 loadCircuits(basePath); 1195 loadCouncil(basePath); 1196 loadCouncilMeetings(basePath); 1197 } 1198 1199 // ==================== NEW-JOB INDICATOR ==================== 1200 // Diffs the active-requisition ids in _data/job-alerts.json against the 1201 // ids this visitor has already been shown (localStorage). The indicator 1202 // therefore means "posted since you last looked", not "we are hiring" â 1203 // and it clears itself the moment the visitor reaches a page that shows 1204 // the new opening, so it only ever appears on that first visit. 1205 var JOB_ALERT_KEY = 'gpdc:jobAlerts:seen'; 1206 var JOB_ALERT_CAP = 400; // keep the key bounded as postings churn 1207 1208 function readSeenJobs() { 1209 try { 1210 var raw = window.localStorage.getItem(JOB_ALERT_KEY); 1211 if (!raw) return null; // null = no record yet (first ever visit) 1212 var parsed = JSON.parse(raw); 1213 return Array.isArray(parsed) ? parsed : null; 1214 } catch (e) { 1215 return null; // private mode, disabled storage, or corrupt value 1216 } 1217 } 1218 1219 function writeSeenJobs(ids) { 1220 // Dedupe â callers concat freely, and a duplicate would otherwise eat 1221 // a slot against the cap and eventually evict a still-live id, which 1222 // would re-alert a posting the visitor has already been shown. 1223 var deduped = []; 1224 var already = Object.create(null); 1225 ids.forEach(function(id) { 1226 if (already[id]) return; 1227 already[id] = true; 1228 deduped.push(id); 1229 }); 1230 try { 1231 window.localStorage.setItem(JOB_ALERT_KEY, JSON.stringify(deduped.slice(-JOB_ALERT_CAP))); 1232 } catch (e) { 1233 // Storage unavailable â the indicator simply won't persist. 1234 } 1235 } 1236 1237 function showJobAlertEls(selector) { 1238 var els = document.querySelectorAll(selector);
1239 els.forEach(function(el) { el.removeAttribute('hidden'); }); 1240 return els.length; 1241 } 1242 1243 async function initJobAlerts(basePath) { 1244 var data = await fetchData(basePath, '_data/job-alerts.json'); 1245 if (!data || !Array.isArray(data.all) || !data.all.length) return; 1246 1247 var seen = readSeenJobs(); 1248 1249 // First ever visit: record the current slate silently. Without this 1250 // every new visitor would get a "new postings" dot for a listing they 1251 // never had the chance to miss, which makes the signal meaningless. 1252 if (seen === null) { 1253 writeSeenJobs(data.all); 1254 return; 1255 } 1256 1257 var seenSet = Object.create(null); 1258 seen.forEach(function(id) { seenSet[id] = true; }); 1259 1260 var unseen = data.all.filter(function(id) { return !seenSet[id]; }); 1261 if (!unseen.length) return; 1262 1263 // Ids this particular page puts in front of the visitor. Anything in 1264 // here is considered delivered and is marked seen on the way out. 1265 var delivered = Object.create(null); 1266 1267 // --- Circuit page: flag a new opening in this circuit specifically. 1268 var circuitSection = document.querySelector('[data-job-alert-circuit]'); 1269 if (circuitSection) { 1270 var circuitName = circuitSection.getAttribute('data-job-alert-circuit'); 1271 var circuitIds = (data.circuits && data.circuits[circuitName]) || []; 1272 var circuitUnseen = circuitIds.filter(function(id) { return !seenSet[id]; }); 1273 if (circuitUnseen.length) { 1274 circuitSection.querySelectorAll('[data-job-alert-circuit-pill]').forEach(function(el) { 1275 el.textContent = circuitUnseen.length > 1 1276 ? circuitUnseen.length + ' New Openings' 1277 : 'New Opening'; 1278 el.removeAttribute('hidden'); 1279 }); 1280 circuitUnseen.forEach(function(id) { delivered[id] = true; }); 1281 } 1282 } 1283 1284 // --- Positions page: the whole slate is on screen, so consume it all. 1285 var onPositionsPage = /\/positions(\.html)?$/.test(window.location.pathname); 1286 if (onPositionsPage) { 1287 data.all.forEach(function(id) { delivered[id] = true; }); 1288 } 1289 1290 // --- Nav dot: only for postings this page did NOT already surface. 1291 var remaining = unseen.filter(function(id) { return !delivered[id]; }); 1292 if (remaining.length) { 1293 showJobAlertEls('[data-job-alert-dot]'); 1294 showJobAlertEls('[data-job-alert-pill]'); 1295 var anchor = document.querySelector('[data-job-alert-anchor]'); 1296 if (anchor) { 1297 anchor.setAttribute('title', remaining.length > 1 1298 ? remaining.length + ' new job postings since your last visit' 1299 : 'New job posting since your last visit'); 1300 } 1301 // The nav dot is informational only; mark it delivered so it does 1302 // not follow the visitor from page to page for the same openings. 1303 remaining.forEach(function(id) { delivered[id] = true; }); 1304 } 1305 1306 var deliveredIds = Object.keys(delivered); 1307 if (deliveredIds.length) { 1308 // Drop ids that are no longer posted before storing. Everything we 1309 // deliver comes from the active slate, so the record stays the size 1310 // of the current listing instead of growing with every closed job. 1311 var stillActive = Object.create(null); 1312 data.all.forEach(function(id) { stillActive[id] = true; }); 1313 var retained = seen.filter(function(id) { return stillActive[id]; }); 1314 writeSeenJobs(retained.concat(deliveredIds)); 1315 } 1316 } 1317 1318 // Load circuit data from CMS and override the hardcoded global 1319 async function loadCircuits(basePath) { 1320 var data = await fetchData(basePath, '_data/circuits.json'); 1321 if (!data) return; 1322 1323 if (data.circuits && data.circuits.length > 0) { 1324 // Normalize counties: CMS stores {name,seat} objects but 1325 // main.js search expects plain strings. Keep objects only 1326 // in a separate property for scripts that need seat info.
1327 data.circuits.forEach(function(c) { 1328 if (c.counties && c.counties.length > 0 && typeof c.counties[0] === 'object') { 1329 c._countiesRaw = c.counties; 1330 c.counties = c.counties.map(function(county) { return county.name; }); 1331 } 1332 }); 1333 window.circuitData = data.circuits; 1334 } 1335 if (data.optOutCircuits && data.optOutCircuits.length > 0) { 1336 data.optOutCircuits.forEach(function(c) { 1337 if (c.counties && c.counties.length > 0 && typeof c.counties[0] === 'object') { 1338 c.counties = c.counties.map(function(county) { return county.name; }); 1339 } 1340 }); 1341 window.optOutCircuits = data.optOutCircuits; 1342 } 1343 1344 // Notify county-map.js and other scripts that fresh data is available 1345 window.dispatchEvent(new CustomEvent('circuitDataUpdated')); 1346 } 1347 1348 // Load council members and dynamically render on team.html 1349 async function loadCouncil(basePath) { 1350 var pagePath = window.location.pathname; 1351 if (!pagePath.endsWith('/team.html') && !pagePath.endsWith('/team')) return; 1352 1353 var data = await fetchData(basePath, '_data/council.json'); 1354 if (!data || !data.members) return; 1355 1356 var grid = document.getElementById('council-grid'); 1357 if (!grid) return; 1358 1359 grid.innerHTML = data.members.map(function(member, index) { 1360 var isChairman = member.role && member.role.toLowerCase().indexOf('chairman') !== -1; 1361 var delay = (index * 0.06).toFixed(2); 1362 return '<div class="council-card" style="animation-delay: ' + delay + 's;">' + 1363 '<h4>' + member.name + '</h4>' + 1364 '<p class="council-role' + (isChairman ? ' chairman' : '') + '">' + member.role + '</p>' + 1365 '</div>'; 1366 }).join(''); 1367 } 1368 1369 // Load council meeting agendas & minutes and render on team.html 1370 async function loadCouncilMeetings(basePath) { 1371 var pagePath = window.location.pathname; 1372 if (!pagePath.endsWith('/team.html') && !pagePath.endsWith('/team')) return; 1373 1374 var data = await fetchData(basePath, '_data/council-meetings.json'); 1375 if (!data || !data.meetings || !data.meetings.length) return; 1376 1377 var pillsEl = document.getElementById('cm-year-pills'); 1378 var recordsEl = document.getElementById('cm-records'); 1379 var band = document.getElementById('council-meetings-band'); 1380 if (!pillsEl || !recordsEl || !band) return; 1381 1382 // Group by year (JSON is already newest-first) 1383 var byYear = {}, years = []; 1384 data.meetings.forEach(function(m) { 1385 var y = m.date.slice(0, 4); 1386 if (!byYear[y]) { byYear[y] = []; years.push(y); } 1387 byYear[y].push(m); 1388 }); 1389 1390 var pdfIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'; 1391 1392 function cardsForYear(year) { 1393 return byYear[year].map(function(m) { 1394 var links = ''; 1395 if (m.agenda) links += '<a href="' + m.agenda + '" target="_blank" rel="noopener" class="cm-link cm-link--agenda">' + pdfIcon + 'Agenda</a>'; 1396 if (m.agenda_amended) links += '<a href="' + m.agenda_amended + '" target="_blank" rel="noopener" class="cm-link cm-link--amended">' + pdfIcon + 'Amended</a>'; 1397 if (m.minutes) links += '<a href="' + m.minutes + '" target="_blank" rel="noopener" class="cm-link cm-link--minutes">' + pdfIcon + 'Minutes</a>'; 1398 return '<div class="cm-card"><div class="cm-card__date">' + m.label + '</div><div class="cm-card__links">' + links + '</div></div>'; 1399 }).join(''); 1400 } 1401 1402 function selectYear(year) { 1403 pillsEl.querySelectorAll('.cm-year-btn').forEach(function(btn) { 1404 btn.classList.toggle('active', btn.dataset.year === year); 1405 }); 1406 recordsEl.innerHTML = cardsForYear(year); 1407 } 1408 1409 // Render year pills 1410 pillsEl.innerHTML = years.map(function(y) { 1411 return '<button class="cm-year-btn" data-year="' + y + '">' + y + '</button>'; 1412 }).join(''); 1413 1414 pillsEl.addEventListener('click', function(e) { 1415 var btn = e.target.closest('.cm-year-btn'); 1416 if (btn) selectYear(btn.dataset.year); 1417 }); 1418 1419 // Default to most recent year 1420 selectYear(years[0]); 1421 band.style.display = ''; 1422 } 1423 1424 // Initialize navigation (mobile toggle, dropdowns, scroll behavior) 1425 function initNavigation() { 1426 const nav = document.getElementById('nav'); 1427 const navToggle = document.getElementById('navToggle'); 1428 const mobileNav = document.getElementById('mobileNav'); 1429 1430 let savedScrollY = 0; 1431 1432 // A collapsed group animates grid-template-rows rather than display, so its 1433 // sublinks still have layout boxes and have to be filtered out by hand or 1434 // the trap walks focus through links the visitor cannot see. 1435 function mobileNavFocusables() { 1436 if (!mobileNav) return []; 1437 return Array.prototype.filter.call( 1438 mobileNav.querySelectorAll('a[href], button:not([disabled])'), 1439 function(el) { 1440 return !el.closest('.mobile-nav-group:not(.expanded) .mobile-nav-sublinks'); 1441 } 1442 ); 1443 } 1444 1445 function setMobileNav(open) { 1446 if (!navToggle || !mobileNav) return; 1447 1448 navToggle.classList.toggle('active', open); 1449 mobileNav.classList.toggle('active', open); 1450 navToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); 1451 1452 if (open) { 1453 savedScrollY = window.scrollY; 1454 document.body.classList.add('nav-open'); 1455 document.body.style.top = `-${savedScrollY}px`;
1456 // The panel is visibility:hidden until the class lands, and focus() 1457 // is a no-op on an invisible element, so wait for the next frame. 1458 requestAnimationFrame(function() { 1459 const first = mobileNavFocusables()[0]; 1460 if (first) first.focus(); 1461 }); 1462 } else { 1463 document.body.classList.remove('nav-open'); 1464 document.body.style.top = ''; 1465 window.scrollTo(0, savedScrollY); 1466 } 1467 } 1468 1469 if (navToggle && mobileNav) { 1470 navToggle.addEventListener('click', function() { 1471 setMobileNav(!mobileNav.classList.contains('active')); 1472 }); 1473 1474 mobileNav.addEventListener('keydown', function(e) { 1475 if (e.key === 'Escape') { 1476 setMobileNav(false); 1477 navToggle.focus(); 1478 return; 1479 } 1480 if (e.key !== 'Tab') return; 1481 1482 const items = mobileNavFocusables(); 1483 if (items.length === 0) return; 1484 1485 const first = items[0]; 1486 const last = items[items.length - 1]; 1487 if (e.shiftKey && document.activeElement === first) { 1488 e.preventDefault(); 1489 last.focus(); 1490 } else if (!e.shiftKey && document.activeElement === last) { 1491 e.preventDefault(); 1492 first.focus(); 1493 } 1494 }); 1495 } 1496 1497 // Scroll behavior for nav (throttled via rAF) 1498 if (nav) { 1499 let scrollTicking = false; 1500 window.addEventListener('scroll', function() { 1501 if (!scrollTicking) { 1502 requestAnimationFrame(function() { 1503 const currentScroll = window.pageYOffset; 1504 if (currentScroll > 100) { 1505 nav.classList.add('scrolled'); 1506 } else { 1507 nav.classList.remove('scrolled'); 1508 } 1509 scrollTicking = false; 1510 }); 1511 scrollTicking = true; 1512 } 1513 }); 1514 } 1515 1516 // Close mobile nav when clicking any link inside it 1517 const mobileLinks = document.querySelectorAll('#mobileNav a[href]'); 1518 mobileLinks.forEach(link => { 1519 link.addEventListener('click', () => setMobileNav(false)); 1520 }); 1521 1522 // Mobile nav collapsible groups 1523 const groupHeaders = document.querySelectorAll('.mobile-nav-group-header'); 1524 groupHeaders.forEach(header => { 1525 header.addEventListener('click', function(e) { 1526 e.preventDefault(); 1527 const group = this.closest('.mobile-nav-group'); 1528 const isExpanded = group.classList.contains('expanded'); 1529 1530 // Close all other groups 1531 document.querySelectorAll('.mobile-nav-group.expanded').forEach(g => { 1532 if (g !== group) { 1533 g.classList.remove('expanded'); 1534 g.querySelector('.mobile-nav-group-header').setAttribute('aria-expanded', 'false'); 1535 } 1536 }); 1537 1538 // Toggle current group 1539 group.classList.toggle('expanded'); 1540 this.setAttribute('aria-expanded', !isExpanded); 1541 }); 1542 }); 1543 1544 // FMPD inline search in mobile nav (no-ops if element no longer present) 1545 initFmpdNavSearch(); 1546 1547 // Nav command bar (replaces old .search-wrapper) 1548 initNavCommandSearch(document.getElementById('navCommandBar')); 1549 1550 initNavDropdowns(nav); 1551 } 1552 1553 // The dropdowns open on :hover and :focus-within in CSS, which leaves no way to 1554 // dismiss one or to reach it on a device without hover. This owns the explicit 1555 // "is-open" state and the aria-expanded that goes with it; the CSS reveals stay. 1556 function initNavDropdowns(nav) { 1557 if (!nav) return; 1558 1559 const groups = Array.prototype.slice.call(nav.querySelectorAll('.dropdown, .has-submenu')); 1560 if (groups.length === 0) return; 1561 1562 function triggerOf(group) { 1563 return group.querySelector(':scope > a, :scope > button'); 1564 } 1565 1566 function setOpen(group, open) {
1567 group.classList.toggle('is-open', open); 1568 1569 const trigger = triggerOf(group); 1570 if (trigger) trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); 1571 1572 if (!open) { 1573 group.querySelectorAll('.dropdown, .has-submenu').forEach(function(inner) { 1574 setOpen(inner, false); 1575 }); 1576 } 1577 } 1578 1579 function closeOthers(keep) { 1580 groups.forEach(function(group) { 1581 if (group === keep || (keep && group.contains(keep))) return; 1582 setOpen(group, false); 1583 }); 1584 } 1585 1586 function toggle(group) { 1587 const open = !group.classList.contains('is-open'); 1588 closeOthers(group); 1589 setOpen(group, open); 1590 } 1591 1592 groups.forEach(function(group) { 1593 const trigger = triggerOf(group); 1594 if (!trigger) return; 1595 1596 trigger.addEventListener('keydown', function(e) { 1597 const isSpace = e.key === ' ' || e.key === 'Spacebar'; 1598 if (e.key !== 'Enter' && !isSpace) return; 1599 // Enter on an already-open trigger falls through to the link, so the 1600 // trigger's own page stays reachable from the keyboard â only the 1601 // first Enter is spent opening the menu. 1602 if (!isSpace && group.classList.contains('is-open')) return; 1603 e.preventDefault(); 1604 toggle(group); 1605 }); 1606 1607 trigger.addEventListener('click', function(e) { 1608 // Mouse users still navigate on click and get the menu from :hover; 1609 // only a device without hover needs the tap to open it instead. 1610 if (e.detail === 0 || window.matchMedia('(hover: hover)').matches) return; 1611 e.preventDefault(); 1612 toggle(group); 1613 }); 1614 1615 group.addEventListener('keydown', function(e) { 1616 if (e.key !== 'Escape' || !group.classList.contains('is-open')) return; 1617 // Stop here so an Escape inside a submenu does not also close its parent. 1618 e.stopPropagation(); 1619 setOpen(group, false); 1620 trigger.focus(); 1621 }); 1622 1623 group.addEventListener('focusout', function(e) { 1624 if (group.contains(e.relatedTarget)) return; 1625 setOpen(group, false); 1626 }); 1627 }); 1628 1629 /* Tells the stylesheet its :focus-within fallback can stand down: from 1630 here on .is-open is the single source of truth for an open menu. */ 1631 nav.classList.add('js-nav'); 1632 1633 document.addEventListener('click', function(e) { 1634 if (!nav.contains(e.target)) closeOthers(null); 1635 }); 1636 } 1637 1638 // Initialize the FMPD county search in the mobile nav 1639 function initFmpdNavSearch() { 1640 var searchInput = document.getElementById('fmpdNavSearch'); 1641 var resultsContainer = document.getElementById('fmpdNavResults'); 1642 if (!searchInput || !resultsContainer) return; 1643 1644 var basePath = getBasePath(); 1645 var debounceTimer = null; 1646 1647 searchInput.addEventListener('input', function() { 1648 clearTimeout(debounceTimer); 1649 var query = this.value.trim().toLowerCase(); 1650 1651 if (query.length < 2) { 1652 resultsContainer.innerHTML = ''; 1653 return; 1654 } 1655 1656 debounceTimer = setTimeout(function() { 1657 searchCounty(query, resultsContainer, basePath); 1658 }, 200); 1659 }); 1660 1661 // Prevent nav from closing when interacting with search 1662 searchInput.addEventListener('click', function(e) { 1663 e.stopPropagation(); 1664 }); 1665 } 1666 1667 function searchCounty(query, container, basePath) { 1668 var circuits = window.circuitData; 1669 if (!circuits || !circuits.length) { 1670 container.innerHTML = '<div class="fmpd-no-results">Loading circuit data...</div>'; 1671 return; 1672 } 1673 1674 // Normalize query: replace hyphens with spaces for matching 1675 var normalizedQuery = query.replace(/-/g, ' '); 1676 var results = []; 1677
1678 circuits.forEach(function(circuit) { 1679 circuit.counties.forEach(function(c) { 1680 var name = typeof c === 'string' ? c : c.name; 1681 var countyLower = name.toLowerCase(); 1682 if (countyLower.indexOf(normalizedQuery) === 0 || countyLower.indexOf(query) === 0) { 1683 results.push({ 1684 county: name, 1685 circuit: circuit.circuit, 1686 slug: circuit.slug, 1687 phone: circuit.phone || '', 1688 defender: circuit.defender || '' 1689 }); 1690 } 1691 }); 1692 }); 1693 1694 // Also check opt-out circuits 1695 var optOuts = window.optOutCircuits; 1696 if (optOuts && optOuts.length) { 1697 optOuts.forEach(function(circuit) { 1698 circuit.counties.forEach(function(c) { 1699 var name = typeof c === 'string' ? c : c.name; 1700 var countyLower = name.toLowerCase(); 1701 if (countyLower.indexOf(normalizedQuery) === 0 || countyLower.indexOf(query) === 0) { 1702 results.push({ 1703 county: name, 1704 circuit: circuit.circuit, 1705 slug: circuit.slug || '', 1706 phone: '', 1707 note: circuit.note || '' 1708 }); 1709 } 1710 }); 1711 }); 1712 } 1713 1714 // Limit to 4 results 1715 results = results.slice(0, 4); 1716 1717 if (results.length === 0) { 1718 var nr = document.createElement('div'); nr.className = 'fmpd-no-results'; nr.textContent = 'No county found matching \u201c' + query + '\u201d'; container.innerHTML = '';
1718 container.appendChild(nr); 1719 return; 1720 } 1721 1722 container.innerHTML = results.map(function(r) { 1723 if (r.note) { 1724 var noteInner = '<div class="fmpd-result-circuit">' + r.county + ' County</div>' + 1725 '<div class="fmpd-result-county">' + r.note + '</div>'; 1726 if (r.slug) { 1727 return '<a href="' + basePath + 'local-offices/' + r.slug + '.html" class="fmpd-result-item">' + 1728 noteInner + 1729 '<div class="fmpd-result-phone">View office contact details →</div>' + 1730 '</a>'; 1731 } 1732 return '<div class="fmpd-result-item">' + noteInner + '</div>'; 1733 } 1734 var href = basePath + 'local-offices/' + r.slug + '.html'; 1735 return '<a href="' + href + '" class="fmpd-result-item">' + 1736 '<div class="fmpd-result-circuit">' + r.circuit + '</div>' + 1737 '<div class="fmpd-result-county">' + r.county + ' County â ' + r.defender + '</div>' + 1738 (r.phone ? '<div class="fmpd-result-phone">' + r.phone + '</div>' : '') + 1739 '</a>'; 1740 }).join(''); 1741 } 1742 1743 // ââ NAV COMMAND SEARCH ââââââââââââââââââââââââââââââââââââââââââââââââ 1744 // Vanilla-JS driver for the FYPD-style command bar used in the site nav 1745 // and homepage hero. Reuses all .fypd-command-* CSS classes. 1746 // Selecting a result navigates to local-offices/{slug}.html. 1747 function initNavCommandSearch(root) { 1748 if (!root) return; 1749 1750 var bar = root.querySelector('.fypd-command-bar'); 1751 var input = root.querySelector('.nav-cmd-input'); 1752 var clearBtn = root.querySelector('.nav-cmd-clear'); 1753 var dropdown = root.querySelector('.nav-cmd-dropdown'); 1754 var detectedBtn = root.querySelector('.nav-cmd-detected'); 1755 var detectedName = root.querySelector('.nav-cmd-detected-name'); 1756 var divider = root.querySelector('.nav-cmd-divider'); 1757 var geoBtn = root.querySelector('.nav-cmd-geo'); 1758 var geoLabel = root.querySelector('.nav-cmd-geo-label'); 1759 var geoRing = root.querySelector('.nav-cmd-geo .fypd-geo-ring'); 1760 1761 if (!bar || !input || !dropdown) return; 1762 1763 var basePath = getBasePath(); 1764 1765 // State 1766 var query = ''; 1767 var results = []; 1768 var focusIdx = -1; 1769 var isOpen = false; 1770 var geoAvail = !!window.isSecureContext && 'geolocation' in navigator; 1771 var geoState = 'idle'; // idle | loading | success | error 1772 var geoCircuit = null; 1773 var geoErrorTimer = null; 1774 1775 // Show geo button if geolocation is available (user must click to activate) 1776 if (geoAvail && divider && geoBtn) { 1777 divider.style.display = ''; 1778 geoBtn.style.display = ''; 1779 } 1780 1781 // ââ Events âââââââââââââââââââââââââââââââââââââââââââââââââââââ 1782 input.addEventListener('input', function() { 1783 query = input.value; 1784 updateClear(); 1785 runSearch(); 1786 }); 1787 1788 input.addEventListener('focus', function() { 1789 bar.classList.add('is-focused'); 1790 if (results.length > 0 && !isGeoActive()) openDropdown(); 1791 }); 1792 1793 input.addEventListener('blur', function() { 1794 setTimeout(function() { 1795 bar.classList.remove('is-focused'); 1796 closeDropdown(); 1797 }, 180); 1798 }); 1799 1800 input.addEventListener('keydown', function(e) { 1801 if (!isOpen) return; 1802 if (e.key === 'ArrowDown') { 1803 e.preventDefault(); 1804 moveFocus(1); 1805 } else if (e.key === 'ArrowUp') { 1806 e.preventDefault(); 1807 moveFocus(-1); 1808 } else if (e.key === 'Enter') { 1809 e.preventDefault();
1810 var idx = focusIdx >= 0 ? focusIdx : (results.length > 0 ? 0 : -1); 1811 if (idx >= 0 && results[idx]) navigate(results[idx]); 1812 } else if (e.key === 'Escape') { 1813 clearSearch(); 1814 input.blur(); 1815 } 1816 }); 1817 1818 if (clearBtn) clearBtn.addEventListener('click', clearSearch); 1819 1820 if (detectedBtn) { 1821 detectedBtn.addEventListener('click', function() { 1822 if (geoCircuit) navigate(geoCircuit); 1823 }); 1824 } 1825 1826 if (geoBtn) { 1827 geoBtn.addEventListener('click', function() { 1828 if (geoState === 'success' && geoCircuit) { 1829 navigate(geoCircuit); 1830 return; 1831 } 1832 runGeo(false); 1833 }); 1834 } 1835 1836 document.addEventListener('click', function(e) { 1837 if (!root.contains(e.target)) closeDropdown(); 1838 }); 1839 1840 // ââ Search âââââââââââââââââââââââââââââââââââââââââââââââââââââ 1841 function runSearch() { 1842 var q = query.toLowerCase().trim(); 1843 if (!q) { 1844 results = []; 1845 focusIdx = -1; 1846 closeDropdown(); 1847 return; 1848 } 1849 1850 var circuits = window.circuitData || []; 1851 var scored = circuits.map(function(c) { 1852 var score = 0, matchLabel = ''; 1853 if (c.circuit.toLowerCase().indexOf(q) !== -1) { 1854 score += 10; 1855 matchLabel = 'Circuit'; 1856 } 1857 var countyHit = null; 1858 for (var i = 0; i < c.counties.length; i++) { 1859 var cn = typeof c.counties[i] === 'string' ? c.counties[i] : c.counties[i].name; 1860 if (cn.toLowerCase().indexOf(q) !== -1) { countyHit = cn; score += 15; break; } 1861 } 1862 if (countyHit) matchLabel = countyHit + ' County'; 1863 if ((c.defender || '').toLowerCase().indexOf(q) !== -1) { 1864 score += 5; 1865 if (!matchLabel) matchLabel = 'Defender'; 1866 } 1867 return Object.assign({}, c, { _score: score, _matchLabel: matchLabel }); 1868 }).filter(function(c) { return c._score > 0; }) 1869 .sort(function(a, b) { return b._score - a._score; }); 1870 1871 // Include opt-out (independent) circuits so counties like Cobb resolve. 1872 var optOuts = window.optOutCircuits || []; 1873 optOuts.forEach(function(o) { 1874 if (!o.slug) return; 1875 var score = 0, matchLabel = ''; 1876 if ((o.circuit || '').toLowerCase().indexOf(q) !== -1) { score += 10; matchLabel = 'Circuit'; } 1877 var countyHit = null; 1878 var oCounties = o.counties || []; 1879 for (var k = 0; k < oCounties.length; k++) { 1880 var ocn = typeof oCounties[k] === 'string' ? oCounties[k] : oCounties[k].name; 1881 if (ocn.toLowerCase().indexOf(q) !== -1) { countyHit = ocn; score += 15; break; } 1882 } 1883 if (countyHit) matchLabel = countyHit + ' County'; 1884 if (score > 0) { 1885 scored.push(Object.assign({}, o, { _score: score, _matchLabel: matchLabel, _optOut: true })); 1886 } 1887 }); 1888 scored.sort(function(a, b) { return b._score - a._score; }); 1889 1890 results = scored.slice(0, 8); 1891 focusIdx = results.length > 0 ? 0 : -1; 1892 renderDropdown(); 1893 if (results.length > 0) openDropdown(); 1894 else closeDropdown(); 1895 } 1896 1897 function renderDropdown() { 1898 if (!results.length) { dropdown.innerHTML = ''; return; } 1899 dropdown.innerHTML = results.map(function(r, idx) { 1900 var cnt = (r.counties || []).length; 1901 var cntTxt = cnt + (cnt === 1 ? ' county' : ' counties'); 1902 var metaTxt = r._optOut ? 'Independent office' : cntTxt; 1903 return '<div class="fypd-dropdown-item' + (idx === focusIdx ? ' is-focused' : '') + 1904 (r._optOut ? ' is-optout' : '') + 1905 '" data-idx="' + idx + '" role="option">' + 1906 '<div class="fypd-dropdown-item-icon">' + 1907 '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">' + 1908 '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/>' + 1909 '<circle cx="12" cy="10" r="3"/>' + 1910 '</svg>' + 1911 '</div>' + 1912 '<div class="fypd-dropdown-item-body">' + 1913 '<span class="fypd-dropdown-circuit-name">' + esc(r.circuit) + '</span>' + 1914 '<span class="fypd-dropdown-match-label">' + esc(r._matchLabel) + '</span>' + 1915 '</div>' + 1916 '<div class="fypd-dropdown-item-meta">' + 1917 '<span class="fypd-dropdown-county-count">' + metaTxt + '</span>' + 1918 '</div>' + 1919 '</div>'; 1920 }).join(''); 1921 1922 dropdown.querySelectorAll('.fypd-dropdown-item').forEach(function(item) { 1923 item.addEventListener('mousedown', function(e) { 1924 e.preventDefault(); 1925 var i = parseInt(item.dataset.idx, 10); 1926 if (!isNaN(i) && results[i]) navigate(results[i]); 1927 }); 1928 item.addEventListener('mouseenter', function() { 1929 focusIdx = parseInt(item.dataset.idx, 10); 1930 updateFocusHighlight(); 1931 }); 1932 }); 1933 } 1934 1935 function moveFocus(dir) { 1936 if (!results.length) return; 1937 focusIdx = Math.max(0, Math.min(results.length - 1, focusIdx + dir)); 1938 updateFocusHighlight(); 1939 } 1940 1941 function updateFocusHighlight() { 1942 dropdown.querySelectorAll('.fypd-dropdown-item').forEach(function(item) { 1943 item.classList.toggle('is-focused', parseInt(item.dataset.idx, 10) === focusIdx); 1944 }); 1945 } 1946 1947 function navigate(circuit) { 1948 window.location.href = basePath + 'local-offices/' + cir
1948cuit.slug + '.html'; 1949 } 1950 1951 function openDropdown() { 1952 if (isGeoActive()) return; 1953 dropdown.style.display = ''; 1954 bar.classList.add('has-results'); 1955 isOpen = true; 1956 } 1957 1958 function closeDropdown() { 1959 dropdown.style.display = 'none'; 1960 bar.classList.remove('has-results'); 1961 isOpen = false; 1962 } 1963 1964 function clearSearch() { 1965 query = input.value = ''; 1966 results = []; 1967 focusIdx = -1; 1968 updateClear(); 1969 closeDropdown(); 1970 } 1971 1972 function updateClear() { 1973 if (clearBtn) clearBtn.style.display = query.length > 0 ? '' : 'none'; 1974 } 1975 1976 function isGeoActive() { 1977 return geoState === 'success'; 1978 } 1979 1980 // ââ Geolocation âââââââââââââââââââââââââââââââââââââââââââââââââ 1981 function runGeo(silent) { 1982 if (!geoAvail || geoState === 'loading') return; 1983 setGeoState('loading'); 1984 navigator.geolocation.getCurrentPosition( 1985 function(pos) { onGeoSuccess(pos, silent); }, 1986 function() { onGeoError(silent); },
1987 { enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 } 1988 ); 1989 } 1990 1991 function onGeoSuccess(pos, silent) { 1992 var lat = pos.coords.latitude, lon = pos.coords.longitude; 1993 var url = 'https://geo.fcc.gov/api/census/block/find?latitude=' + 1994 encodeURIComponent(lat) + '&longitude=' + encodeURIComponent(lon) + '&format=json'; 1995 fetch(url, { headers: { 'Accept': 'application/json' } }) 1996 .then(function(r) { return r.ok ? r.json() : Promise.reject(); }) 1997 .then(function(data) { 1998 if (!data || !data.County) { onGeoError(silent); return; } 1999 var slug = normalizeSlug(data.County.name); 2000 var circuit = findCircuitByCountySlug(slug); 2001 if (circuit) applyGeoMatch(circuit); 2002 else onGeoError(silent); 2003 }) 2004 .catch(function() { onGeoError(silent); }); 2005 } 2006 2007 function onGeoError(silent) { 2008 if (silent) { setGeoState('idle'); return; } 2009 setGeoState('error'); 2010 if (geoErrorTimer) clearTimeout(geoErrorTimer); 2011 geoErrorTimer = setTimeout(function() { setGeoState('idle'); }, 5000); 2012 } 2013 2014 function applyGeoMatch(circuit) { 2015 geoCircuit = circuit; 2016 setGeoState('success'); 2017 bar.classList.add('is-geo-active'); 2018 // Show detected button, hide input 2019 if (detectedName) detectedName.textContent = circuit.circuit; 2020 if (detectedBtn) detectedBtn.style.display = ''; 2021 input.style.display = 'none'; 2022 clearSearch(); 2023 closeDropdown(); 2024 } 2025 2026 function setGeoState(state) { 2027 geoState = state; 2028 if (!geoBtn) return; 2029 geoBtn.classList.remove('loading', 'success', 'error'); 2030 if (state !== 'idle') geoBtn.classList.add(state); 2031 if (geoRing) geoRing.style.display = state === 'loading' ? '' : 'none'; 2032 geoBtn.disabled = state === 'loading'; 2033 if (geoLabel) { 2034 var labels = { idle: 'Use my location', loading: 'Locatingâ¦', success: '', error: 'Try again' }; 2035 geoLabel.textContent = labels[state] || ''; 2036 } 2037 } 2038 2039 function findCircuitByCountySlug(slug) { 2040 var circuits = window.circuitData || []; 2041 for (var i = 0; i < circuits.length; i++) { 2042 var c = circuits[i]; 2043 for (var j = 0; j < c.counties.length; j++) { 2044 var cn = typeof c.counties[j] === 'string' ? c.counties[j] : c.counties[j].name; 2045 if (normalizeSlug(cn) === slug) return c; 2046 } 2047 } 2048 return null; 2049 } 2050 2051 function normalizeSlug(v) { 2052 return (v || '').replace(/\s+county$/i, '').toLowerCase() 2053 .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); 2054 } 2055 2056 function esc(s) { 2057 return (s || '').replace(/&/g, '&').replace(/</g, '<') 2058 .replace(/>/g, '>').replace(/"/g, '"'); 2059 } 2060 2061 // ââ Typewriter placeholder (hero only) ââââââââââââââââââââââââ 2062 if (root.id === 'heroCommandBar') { 2063 var twTexts = [ 2064 'Find your public defender', 2065 'Search "Tift County"', 2066 'Search "Cordele Circuit"', 2067 'Search "Lowndes County"', 2068 'Search "Waycross Circuit"' 2069 ]; 2070 var twIdx = 0, twChar = 0, twDeleting = false, twTimer = null, twActive = false; 2071 2072 function twRun() { 2073 if (input.value.length > 0) { 2074 twStop(); 2075 return; 2076 } 2077 twActive = true; 2078 var text = twTexts[twIdx]; 2079 if (twDeleting) { 2080 twChar--; 2081 input.placeholder = text.substring(0, twChar); 2082 if (twChar === 0) { 2083 twDeleting = false;
2084 twIdx = (twIdx + 1) % twTexts.length; 2085 twTimer = setTimeout(twRun, 300); 2086 } else { 2087 twTimer = setTimeout(twRun, 30); 2088 } 2089 } else { 2090 twChar++; 2091 input.placeholder = text.substring(0, twChar); 2092 if (twChar === text.length) { 2093 twDeleting = true; 2094 twTimer = setTimeout(twRun, 2000); 2095 } else { 2096 twTimer = setTimeout(twRun, 80); 2097 } 2098 } 2099 } 2100 2101 function twStop() { 2102 if (twTimer) { clearTimeout(twTimer); twTimer = null; } 2103 twIdx = 0; twChar = 0; twDeleting = false; twActive = false; 2104 input.placeholder = twTexts[0]; 2105 } 2106 2107 function twStart() { 2108 if (!twActive && input.value.length === 0) { 2109 twTimer = setTimeout(twRun, 500); 2110 } 2111 } 2112 2113 // Start on focus 2114 input.addEventListener('focus', twStart); 2115 input.addEventListener('blur', function() { twStop(); }); 2116 2117 // Auto-start when visible on screen 2118 if ('IntersectionObserver' in window) { 2119 var twObs = new IntersectionObserver(function(entries) { 2120 entries.forEach(function(e) { 2121 if (e.isIntersecting) { twStart(); } 2122 else { twStop(); } 2123 }); 2124 }, { threshold: 0.5 }); 2125 twObs.observe(root); 2126 } 2127 } 2128 } 2129 2130 /* Email obfuscation â decode data-email attributes at runtime */ 2131 function decodeEmails() { 2132 document.querySelectorAll('a[data-email]').forEach(function(el) { 2133 try { 2134 var email = atob(el.getAttribute('data-email')); 2135 el.href = 'mailto:' + email; 2136 if (el.querySelector('.email-text')) { 2137 el.querySelector('.email-text').textContent = email; 2138 } else if (!el.children.length) { 2139 el.textContent = email; 2140 } else if (!el.querySelector('svg') && el.textContent.trim() === '') { 2141 el.textContent = email; 2142 } 2143 } catch(e) {} 2144 }); 2145 } 2146 2147 // Run when DOM is ready 2148 if (document.readyState === 'loading') { 2149 document.addEventListener('DOMContentLoaded', init); 2150 } else { 2151 init(); 2152 } 2153 2154 // Also decode any page-level emails after DOM is ready 2155 if (document.readyState === 'loading') { 2156 document.addEventListener('DOMContentLoaded', decodeEmails); 2157 } else { 2158 decodeEmails(); 2159 } 2160})();
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.