1/* Wildflower â small enhancements that don't need a framework. */ 2(function () { 3 'use strict'; 4 5 // Topbar weather â pull the current temperature from NWS for Grass Lake 6 // and prepend it to the location span. Caches the temp for 30 min and 7 // the gridpoint URL forever (it never changes for a fixed location), so 8 // most page loads make zero network calls. Fails silently if the API is 9 // unreachable â the span just stays "Grass Lake, MI" with no surprise. 10 (function loadWeather() { 11 var el = document.querySelector('.topbar-weather'); 12 if (!el || !window.fetch || !window.localStorage) return; 13 14 var LAT = 42.2503, LNG = -84.2156; // Grass Lake, MI 15 var TEMP_KEY = 'glmom-weather-temp'; 16 var GRID_KEY = 'glmom-weather-grid'; 17 var TTL = 30 * 60 * 1000; // 30-minute cache 18 var fallback = el.dataset.weatherDefault || el.textContent; 19 20 function paint(temp) { 21 el.textContent = temp + '°F · ' + fallback; 22 } 23 24 try { 25 var cached = JSON.parse(localStorage.getItem(TEMP_KEY) || 'null'); 26 if (cached && cached.exp > Date.now()) { 27 paint(cached.temp); 28 return; 29 } 30 } catch (e) {} 31 32 function fetchTemp(forecastUrl) { 33 return fetch(forecastUrl).then(function (r) { 34 if (!r.ok) throw new Error('forecast ' + r.status); 35 return r.json(); 36 }).then(function (data) { 37 var temp = data.properties.periods[0].temperature; 38 paint(temp); 39 try { 40 localStorage.setItem(TEMP_KEY, JSON.stringify({ 41 temp: temp, exp: Date.now() + TTL 42 })); 43 } catch (e) {} 44 }); 45 } 46 47 var cachedGrid = localStorage.getItem(GRID_KEY); 48 if (cachedGrid) { 49 fetchTemp(cachedGrid).catch(function () { /* silent */ }); 50 return; 51 } 52 53 fetch('https://api.weather.gov/points/' + LAT + ',' + LNG) 54 .then(function (r) { 55 if (!r.ok) throw new Error('points ' + r.status); 56 return r.json(); 57 }) 58 .then(function (data) { 59 var url = data.properties && data.properties.forecastHourly; 60 if (!url) throw new Error('no forecastHourly URL'); 61 try { localStorage.setItem(GRID_KEY, url); } catch (e) {} 62 return fetchTemp(url); 63 }) 64 .catch(function () { /* silent */ }); 65 })(); 66 67 // Toggle visual states on the Ghost Members signup form so the 68 // button reflects loading / success / error without relying on 69 // Portal styling. Ghost adds these classes via portal.min.js. 70 document.addEventListener('submit', function (e) { 71 var form = e.target.closest && e.target.closest('[data-members-form="signup"]'); 72 if (!form) return; 73 form.classList.remove('error', 'success'); 74 form.classList.add('loading'); 75 }); 76 77 document.addEventListener('members:signup:success', function (e) { 78 var form = e.target; 79 if (!form || !form.classList) return; 80 form.classList.remove('loading', 'error'); 81 form.classList.add('success'); 82 }); 83 84 document.addEventListener('members:signup:error', function (e) { 85 var form = e.target; 86 if (!form || !form.classList) return; 87 form.classList.remove('loading', 'success'); 88 form.classList.add('error'); 89 }); 90 91 // Show the nav logo only when the sticky nav is currently stuck to the 92 // top. Uses a 1px sentinel right above the nav: when it scrolls out of 93 // the viewport the nav has stuck, so we toggle .is-stuck on .site-nav 94 // and CSS fades the logo in. 95 var sentinel = document.querySelector('.nav-sticky-sentinel'); 96 var nav = document.querySelector('.site-nav'); 97 if (sentinel && nav && 'IntersectionObserver' in window) { 98 new IntersectionObserver(function (entries) { 99 nav.classList.toggle('is-stuck', !entries[0].isIntersecting); 100 }, { threshold: 0 }).observe(sentinel); 101 } 102 103 // Homepage events widget â sort by event date, hide past events, 104 // cap the visible list at 4. The widget fetches up to 20 events from 105 // Ghost ordered server-side by published_at desc (Ghost can't sort 106 // by meta_description). Each <li> carries data-event-time with the 107 // ISO datetime of the actual event, so we re-sort client-side here. 108 var eventLis = Array.from(document.querySelectorAll('.events-list .event[data-event-time]')); 109 if (eventLis.length) { 110 var cutoff = Date.now() - 2 * 60 * 60 * 1000; // 2-hour grace 111 112 // Sort ascending by event time (soonest first). Items with bad/empty 113 // dates fall to the bottom of the list. 114 eventLis.sort(function (a, b) { 115 var ta = new Date(a.dataset.eventTime).getTime(); 116 var tb = new Date(b.dataset.eventTime).getTime(); 117 if (isNaN(ta) && isNaN(tb)) return 0; 118 if (isNaN(ta)) return 1; 119 if (isNaN(tb)) return -1; 120 return ta - tb; 121 }); 122 123 // Reorder in the DOM to match the sort. 124 var parent = eventLis[0].parentNode; 125 eventLis.forEach(function (li) { parent.appendChild(li); }); 126 127 // Hide past events + cap at 4 visible. 128 var shown = 0; 129 eventLis.forEach(function (li) { 130 var t = new Date(li.dataset.eventTime).getTime(); 131 var isPast = !isNaN(t) && t < cutoff; 132 if (isPast || shown >= 4) { 133 li.style.display = 'none'; 134 } else { 135 shown++; 136 } 137 }); 138 if (shown === 0) { 139 var empty = document.querySelector('.events-empty'); 140 if (empty) empty.hidden = false;
141 } 142 } 143 144 // /tag/event/ page â sort events chronologically, hide past, update 145 // the header count to reflect the visible-only number. Same pattern as 146 // the homepage widget but no 4-item cap (this is the full archive). 147 var eventArchiveRows = Array.from(document.querySelectorAll('.event-archive .schedule-row[data-event-time]')); 148 if (eventArchiveRows.length) { 149 var archiveCutoff = Date.now() - 2 * 60 * 60 * 1000; 150 151 eventArchiveRows.sort(function (a, b) { 152 var ta = new Date(a.dataset.eventTime).getTime(); 153 var tb = new Date(b.dataset.eventTime).getTime(); 154 if (isNaN(ta) && isNaN(tb)) return 0; 155 if (isNaN(ta)) return 1; 156 if (isNaN(tb)) return -1; 157 return ta - tb; 158 }); 159 160 var archiveParent = eventArchiveRows[0].parentNode; 161 eventArchiveRows.forEach(function (row) { archiveParent.appendChild(row); }); 162 163 var archiveVisible = 0; 164 eventArchiveRows.forEach(function (row) { 165 var t = new Date(row.dataset.eventTime).getTime(); 166 if (!isNaN(t) && t < archiveCutoff) { 167 row.classList.add('past'); 168 } else { 169 archiveVisible++; 170 } 171 }); 172 173 var archiveCount = document.querySelector('.event-archive-count'); 174 if (archiveCount) { 175 archiveCount.textContent = archiveVisible === 0 176 ? 'No upcoming events' 177 : (archiveVisible === 1 ? '1 upcoming event' : archiveVisible + ' upcoming events'); 178 } 179 if (archiveVisible === 0) { 180 var emptyArchive = document.querySelector('.event-archive-empty'); 181 if (emptyArchive) emptyArchive.hidden = false; 182 } 183 } 184 185 // Homepage sports widget. Prefer to show the next 5 upcoming games 186 // (sorted soonest-first). If there are zero upcoming games â usually 187 // because the warriors-sync is behind on a fresh fetch â fall back to 188 // showing the 5 MOST RECENT past games so the section never goes 189 // empty mid-season. Past games render with their actual date next to 190 // them so it's still honest, just gracefully populated. 191 var sportsGames = Array.from(document.querySelectorAll('.sports-games .game[data-game-time]')); 192 if (sportsGames.length) { 193 var sportsCutoff = Date.now() - 2 * 60 * 60 * 1000; 194 var sportsParent = sportsGames[0].parentNode; 195 196 // Sort ascending by game time (soonest/oldest first). 197 sportsGames.sort(function (a, b) { 198 var ta = new Date(a.dataset.gameTime).getTime(); 199 var tb = new Date(b.dataset.gameTime).getTime(); 200 if (isNaN(ta) && isNaN(tb)) return 0; 201 if (isNaN(ta)) return 1; 202 if (isNaN(tb)) return -1; 203 return ta - tb; 204 }); 205 206 // Find upcoming games (>= cutoff, or undated which we keep visible). 207 var futureGames = sportsGames.filter(function (g) { 208 var t = new Date(g.dataset.gameTime).getTime(); 209 return isNaN(t) || t >= sportsCutoff; 210 }); 211 212 // Pick what to display: next 5 upcoming, OR fall back to most recent 213 // 5 past games if there's nothing upcoming yet. 214 var toShow = futureGames.length > 0 215 ? futureGames.slice(0, 5) 216 : sportsGames.slice(-5).reverse(); 217 218 // Re-append in display order; hidden ones go to the end so they 219 // don't break the visible flow if CSS later un-hides them. 220 toShow.forEach(function (g) { sportsParent.appendChild(g); }); 221 sportsGames.forEach(function (g) { 222 if (toShow.indexOf(g) === -1) { 223 g.style.display = 'none'; 224 sportsParent.appendChild(g); 225 } else { 226 g.style.display = ''; 227 } 228 }); 229 } 230 231 // Schedule rows whose data-event-time / data-game-time is just a date 232 // (no "T" separator, e.g. "2026-07-11") have no real time component â 233 // the {{date}} helper renders "12:00am" by default which is misleading. 234 // Hide the .schedule-time cell on those rows so only the date shows. 235 document.querySelectorAll('[data-event-time], [data-game-time]').forEach(function (row) { 236 var t = row.dataset.eventTime || row.dataset.gameTime; 237 if (t && t.indexOf('T') === -1) { 238 var timeCell = row.querySelector('.schedule-time');
239 if (timeCell) timeCell.style.visibility = 'hidden'; 240 } 241 }); 242 243 // Mobile hamburger drawer. Toggled by .nav-toggle, closed by clicks on 244 // the overlay, the X button, or the Escape key. 245 var toggle = document.querySelector('.nav-toggle'); 246 var drawer = document.getElementById('mobile-nav'); 247 if (toggle && drawer) { 248 var open = function () { 249 drawer.classList.add('is-open'); 250 drawer.setAttribute('aria-hidden', 'false'); 251 toggle.setAttribute('aria-expanded', 'true'); 252 document.body.classList.add('mobile-nav-open'); 253 }; 254 var close = function () { 255 drawer.classList.remove('is-open'); 256 drawer.setAttribute('aria-hidden', 'true'); 257 toggle.setAttribute('aria-expanded', 'false'); 258 document.body.classList.remove('mobile-nav-open'); 259 }; 260 toggle.addEventListener('click', function () { 261 drawer.classList.contains('is-open') ? close() : open(); 262 }); 263 drawer.querySelectorAll('[data-mobile-nav-close]').forEach(function (el) { 264 el.addEventListener('click', close); 265 }); 266 // Close after tapping a nav link inside the drawer. 267 drawer.querySelectorAll('a').forEach(function (a) { 268 a.addEventListener('click', close); 269 }); 270 document.addEventListener('keydown', function (e) { 271 if (e.key === 'Escape' && drawer.classList.contains('is-open')) close(); 272 }); 273 } 274 275 // Local business directory page (custom-local.hbs) â filter behavior. 276 // Cards carry data-categories="eats shop ..." (space-separated). Click 277 // a sidebar item or the mobile accordion option, hide cards that don't 278 // match, update the URL hash for deep-linking, and smooth-scroll back 279 // to the directory header so the filtered results read from the top. 280 var bizCards = document.querySelectorAll('.business-card[data-categories]'); 281 if (bizCards.length) { 282 var filterEls = document.querySelectorAll('[data-filter]'); 283 var head = document.querySelector('.directory-head'); 284 var mobileWrap = document.querySelector('.filter-mobile'); 285 var toggleBtn = mobileWrap && mobileWrap.querySelector('.filter-toggle'); 286 var toggleIcon = mobileWrap && mobileWrap.querySelector('[data-toggle-icon]'); 287 var toggleText = mobileWrap && mobileWrap.querySelector('[data-toggle-text]'); 288 var labels = { 289 all: { icon: 'â¦', text: 'All businesses' }, 290 eats: { icon: 'ð½ï¸', text: 'Eats' }, 291 shop: { icon: 'ðï¸', text: 'Shop' }, 292 services: { icon: 'ð ï¸', text: 'Services' }, 293 family: { icon: 'ð', text: 'Family' }, 294 sports: { icon: 'ð', text: 'Youth Sports' } 295 }; 296 297 // Tally counts from the actual rendered cards. Server-side counts 298 // would require multiple {{#get}} calls, which is wasteful when the 299 // data is already in the DOM. 300 var counts = { all: 0 }; 301 bizCards.forEach(function (card) { 302 counts.all++; 303 (card.dataset.categories || '').split(/\s+/).filter(Boolean).forEach(function (c) { 304 counts[c] = (counts[c] || 0) + 1; 305 }); 306 }); 307 Object.keys(counts).forEach(function (cat) { 308 document.querySelectorAll('[data-filter="' + cat + '"] .filter-count').forEach(function (el) { 309 el.textContent = counts[cat]; 310 }); 311 }); 312 313 function applyFilter(category, opts) { 314 var scrolledPast = head ? head.getBoundingClientRect().top < 0 : false; 315 bizCards.forEach(function (card) { 316 var cats = (card.dataset.categories || '').split(/\s+/); 317 var matches = category === 'all' || cats.indexOf(category) !== -1; 318 card.classList.toggle('is-hidden', !matches); 319 }); 320 filterEls.forEach(function (el) { 321 el.classList.toggle('active', el.dataset.filter === category); 322 }); 323 var label = labels[category] || labels.all; 324 if (toggleIcon) toggleIcon.textContent = label.icon; 325 if (toggleText) toggleText.textContent = label.text; 326 if (category === 'all') { 327 history.replaceState(null, '', window.location.pathname); 328 } else { 329 history.replaceState(null, '', '#' + category); 330 } 331 if (mobileWrap && mobileWrap.classList.contains('open')) { 332 mobileWrap.classList.remove('open'); 333 toggleBtn.setAttribute('aria-expanded', 'false'); 334 } 335 if (scrolledPast && head && !(opts && opts.silent)) { 336 head.scrollIntoView({ behavior: 'smooth', block: 'start' }); 337 } 338 } 339
340 filterEls.forEach(function (el) { 341 el.addEventListener('click', function () { applyFilter(el.dataset.filter); }); 342 }); 343 if (toggleBtn) { 344 toggleBtn.addEventListener('click', function () { 345 var open = mobileWrap.classList.toggle('open'); 346 toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); 347 }); 348 } 349 350 var initial = window.location.hash.replace('#', '') || 'all'; 351 if (document.querySelector('[data-filter="' + initial + '"]')) { 352 applyFilter(initial, { silent: true }); 353 } 354 } 355 356 // Contact form (custom-contact.hbs) â submit via fetch so the user 357 // stays on the page and gets inline success/error feedback. Falls back 358 // to a normal POST submission for users with JS disabled, in which 359 // case Formspree handles the redirect to its own thank-you page. 360 var contactForm = document.querySelector('.contact-form'); 361 if (contactForm && window.fetch) { 362 contactForm.addEventListener('submit', function (e) { 363 e.preventDefault(); 364 var btn = contactForm.querySelector('.contact-submit'); 365 var result = contactForm.querySelector('[data-contact-result]'); 366 var states = { 367 'default': contactForm.querySelector('[data-state-default]'), 368 loading: contactForm.querySelector('[data-state-loading]'), 369 success: contactForm.querySelector('[data-state-success]'), 370 error: contactForm.querySelector('[data-state-error]') 371 }; 372 function show(state) { 373 Object.keys(states).forEach(function (k) { 374 if (states[k]) states[k].hidden = (k !== state); 375 }); 376 } 377 378 // Reset state classes 379 contactForm.classList.remove('is-success', 'is-error'); 380 result.hidden = true; 381 result.textContent = ''; 382 show('loading'); 383 btn.disabled = true; 384 385 var data = new FormData(contactForm); 386 fetch(contactForm.action, { 387 method: 'POST', 388 body: data, 389 headers: { Accept: 'application/json' } 390 }) 391 .then(function (r) { 392 if (r.ok) return r.json(); 393 return r.json().then(function (err) { throw err; }); 394 }) 395 .then(function () { 396 contactForm.classList.add('is-success'); 397 show('success'); 398 result.textContent = "Got it â your message landed. We'll be in touch soon."; 399 result.hidden = false; 400 contactForm.reset(); 401 // Re-enable after a beat so the user can send another if they want 402 setTimeout(function () { 403 btn.disabled = false; 404 show('default'); 405 contactForm.classList.remove('is-success'); 406 result.hidden = true; 407 }, 6000); 408 }) 409 .catch(function (err) { 410 contactForm.classList.add('is-error'); 411 show('error'); 412 var msg = "Sorry â something went wrong sending your message."; 413 if (err && err.errors && err.errors.length) { 414 msg = err.errors.map(function (e) { return e.message; }).join(' '); 415 } 416 msg += " You can also email us directly."; 417 result.textContent = msg; 418 result.hidden = false; 419 btn.disabled = false; 420 setTimeout(function () { show('default'); }, 4000); 421 }); 422 }); 423 } 424})();
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.