PageSourceSearch

https://www.grasslakemom.com/assets/js/wildflower.js?v=6r7xMPAf2_BjQ3R3

js grasslakemom.com collected 2026-10-04 08:07:19 UTC 18,863 bytes, 424 lines download raw bytes

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.